backupdrill 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/restore.d.ts CHANGED
@@ -1,16 +1,79 @@
1
1
  import type { BackupConfig } from "./config.js";
2
+ import type { Manifest } from "./manifest.js";
3
+ import { type DrillCheck } from "./drill.js";
4
+ import { type StorageRestoreSummary } from "./storage-restore.js";
5
+ /** 归档可读性要求:pg_restore 必须 ≥ max(写入工具版本, 源服务端版本)——格式跟随写入工具。 */
6
+ export declare function requiredRestoreToolMajor(manifest: Manifest): number;
2
7
  export interface RestoreResult {
3
8
  snapshot: string;
9
+ /** dry-run:只做只读预检,未发生任何写入 */
10
+ dryRun?: boolean;
4
11
  restoredToDatabase: boolean;
12
+ /** 表级验证结果(与演练同一套 verifyRestored);未恢复数据库时不存在 */
13
+ databaseChecks?: DrillCheck[];
14
+ /**
15
+ * 验证总裁决(= databaseChecks 全 pass)。库级调用方(worker)必须看它,
16
+ * 不能只看 restoredToDatabase——"恢复完成"与"恢复通过验证"是两个事实。
17
+ */
18
+ databaseVerified?: boolean;
19
+ /** Storage 回传摘要(提供了目标 Storage 凭据时);本地下载模式不存在 */
20
+ storageSummary?: StorageRestoreSummary;
5
21
  storageFilesWritten: number;
6
22
  storageDir?: string;
7
23
  }
8
24
  /**
9
- * 把一份快照恢复出来:数据库还原进 --target-database-url;Storage 文件下载到本地目录
10
- * (拿回文件后你可以按需回传到新 Supabase 项目)。这是 US-5 恢复向导的 DIY 版本。
25
+ * restore 无源库配置时的占位连接串(纯 flag 驱动、没有 backupdrill.config.json 的场景)。
26
+ * 同源阻断门对它不生效——没有可比对的源;有真实源配置时阻断门照常工作。
27
+ */
28
+ export declare const NO_SOURCE_DATABASE = "postgresql://unused";
29
+ export { projectRefOf } from "./restore-engine.js";
30
+ /**
31
+ * 两个连接串是否指向同一数据库租户(纯函数,可测)。
32
+ * Supabase 双方以项目 ref 为准(直连/pooler 形态互认);其余退回 host+user 比对
33
+ * ——只比 host 不够:pooler 主机是区域级共享的,租户身份在用户名里。
34
+ * 任一解析失败 → false(pg 侧会给出自己的连接错误,这里不误伤)。
35
+ */
36
+ export declare function sameDatabaseTarget(a: string, b: string): boolean;
37
+ /**
38
+ * 从目标 Project URL(https://<ref>.supabase.co)提取 ref。 */
39
+ export declare function refFromSupabaseUrl(supabaseUrl: string): string | null;
40
+ /**
41
+ * 目标 Storage URL 的严格校验:必须是 https 且恰为 <ref>.supabase.co 源(无路径/查询)。
42
+ * service-role key 会作为请求头发往这个地址——校验必须发生在**第一次 fetch 之前**,
43
+ * 否则任意主机(或明文 http)都能收到 key(评审第 8 轮)。返回规范化 origin。
44
+ */
45
+ export declare function validateStorageTargetOrigin(supabaseUrl: string): string;
46
+ export { assertNoHostOverride } from "./restore-engine.js";
47
+ /**
48
+ * 备份配置能导出的全部源项目 ref:数据库连接串 + Storage 读取端点
49
+ * (https://<ref>.storage.supabase.co/...)。storage-only 恢复没有目标库连接串,
50
+ * 同源阻断必须同样覆盖"目标 Storage = 源项目"的组合(评审第 8 轮)。
51
+ */
52
+ export declare function sourceProjectRefs(config: BackupConfig, manifest?: Manifest): Set<string>;
53
+ /**
54
+ * 请求与快照内容必须相交(评审第 15 轮,纯函数可测):什么都不会做的调用
55
+ * 不得走到"Restore complete"——自动化会把 no-op 记成一次成功恢复。
56
+ */
57
+ export declare function assertRequestApplies(manifest: Manifest, wantsDatabase: boolean, wantsStorageUpload: boolean): void;
58
+ /**
59
+ * 正式执行的确认门(PRD §5.3.3):用户必须键入目标 project ref(或非 Supabase 目标的
60
+ * 主机名)与实际连接目标一致。误把生产源当目标是恢复流程里代价最不对称的失误,
61
+ * 这道门要求人把目标身份亲手打一遍。
62
+ */
63
+ export declare function assertConfirmedTarget(confirmTarget: string | undefined, targetDatabaseUrl?: string, targetSupabaseUrl?: string): void;
64
+ /**
65
+ * 把一份快照恢复出来:数据库经统一引擎还原进目标(与演练同一条 pg_restore 路径与
66
+ * 错误分类),恢复后跑同一套表级验证;Storage 提供目标凭据时回传+对账(P0-D),
67
+ * 否则下载到本地目录。这是 US-5 恢复向导的 CLI 形态。
11
68
  */
12
69
  export declare function runRestore(config: BackupConfig, opts: {
13
70
  targetDatabaseUrl?: string;
71
+ targetSupabaseUrl?: string;
72
+ targetServiceRoleKey?: string;
73
+ confirmTarget?: string;
74
+ dryRun?: boolean;
75
+ /** 源身份不可考(v1 快照 + 无配置)时的显式风险认知——没有它一律拒绝写入 */
76
+ acknowledgeUnverifiedSource?: boolean;
14
77
  snapshot?: string;
15
78
  storageDir?: string;
16
79
  }): Promise<RestoreResult>;
package/dist/restore.js CHANGED
@@ -1,29 +1,328 @@
1
- import { spawn } from "node:child_process";
2
- import { mkdtemp, mkdir, rm } from "node:fs/promises";
1
+ import { mkdtemp, mkdir, rm, readdir, lstat } from "node:fs/promises";
3
2
  import { createWriteStream } from "node:fs";
4
3
  import { tmpdir } from "node:os";
5
- import { join, dirname } from "node:path";
4
+ import { join, dirname, resolve, sep } from "node:path";
6
5
  import { pipeline } from "node:stream/promises";
7
6
  import { GetObjectCommand } from "@aws-sdk/client-s3";
7
+ import { Client } from "pg";
8
8
  import { parseManifest } from "./manifest.js";
9
9
  import { targetClient, resolveSnapshot, getObjectText, downloadToFile, } from "./snapshots.js";
10
+ import { restoreDatabaseArtifact, installExtensions, projectRefOf, refFromStorageEndpoint, assertNoHostOverride, normalizeHost, } from "./restore-engine.js";
11
+ import { verifyRestored } from "./drill.js";
12
+ import { restoreStorage, planStorageRestore, } from "./storage-restore.js";
13
+ import { pgConnectOptions } from "./supabase-ca.js";
10
14
  import { resolvePgRestoreBin } from "./pgbin.js";
15
+ import { parsePgDumpMajor } from "./backup.js";
16
+ import { execFile } from "node:child_process";
17
+ import { promisify } from "node:util";
11
18
  import { log } from "./log.js";
12
- function pgRestore(dumpPath, targetUrl) {
13
- const bin = resolvePgRestoreBin();
14
- return new Promise((resolve, reject) => {
15
- const proc = spawn(bin, ["--clean", "--if-exists", "--no-owner", "--no-privileges", "--dbname", targetUrl, dumpPath], { stdio: ["ignore", "ignore", "pipe"] });
16
- let stderr = "";
17
- proc.stderr.on("data", (d) => (stderr += d.toString()));
18
- proc.on("error", reject);
19
- proc.on("close", (code) => code === 0 || !/error:/i.test(stderr)
20
- ? resolve()
21
- : reject(new Error(`pg_restore failed: ${stderr.trim()}`)));
22
- });
19
+ const execFileAsync = promisify(execFile);
20
+ /** 本地 pg_restore 主版本(拿不到 → null,由引擎自己的报错兜底)。 */
21
+ async function localPgRestoreMajor() {
22
+ try {
23
+ const { stdout } = await execFileAsync(resolvePgRestoreBin(), ["--version"]);
24
+ return parsePgDumpMajor(stdout.trim());
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /** 归档可读性要求:pg_restore 必须 ≥ max(写入工具版本, 源服务端版本)——格式跟随写入工具。 */
31
+ export function requiredRestoreToolMajor(manifest) {
32
+ return Math.max(parsePgDumpMajor(manifest.database.pgDumpVersion) ?? 0, parseInt(manifest.database.serverVersion, 10) || 0);
33
+ }
34
+ /**
35
+ * restore 无源库配置时的占位连接串(纯 flag 驱动、没有 backupdrill.config.json 的场景)。
36
+ * 同源阻断门对它不生效——没有可比对的源;有真实源配置时阻断门照常工作。
37
+ */
38
+ export const NO_SOURCE_DATABASE = "postgresql://unused";
39
+ // projectRefOf 上移到引擎层(备份端写 manifest.sourceProjectRef 也要用);此处转出口
40
+ export { projectRefOf } from "./restore-engine.js";
41
+ /**
42
+ * 两个连接串是否指向同一数据库租户(纯函数,可测)。
43
+ * Supabase 双方以项目 ref 为准(直连/pooler 形态互认);其余退回 host+user 比对
44
+ * ——只比 host 不够:pooler 主机是区域级共享的,租户身份在用户名里。
45
+ * 任一解析失败 → false(pg 侧会给出自己的连接错误,这里不误伤)。
46
+ */
47
+ export function sameDatabaseTarget(a, b) {
48
+ const refA = projectRefOf(a);
49
+ const refB = projectRefOf(b);
50
+ if (refA !== null && refB !== null)
51
+ return refA === refB;
52
+ try {
53
+ const ua = new URL(a);
54
+ const ub = new URL(b);
55
+ if (!ua.hostname || !ub.hostname)
56
+ return false;
57
+ // 用户名按驱动语义(解码后)比较,与 projectRefOf 同一口径
58
+ return (normalizeHost(ua.hostname) === normalizeHost(ub.hostname) &&
59
+ decodeURIComponent(ua.username) === decodeURIComponent(ub.username));
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ /**
66
+ * 从目标 Project URL(https://<ref>.supabase.co)提取 ref。 */
67
+ export function refFromSupabaseUrl(supabaseUrl) {
68
+ try {
69
+ const match = normalizeHost(new URL(supabaseUrl).hostname).match(/^([a-z0-9]{16,})\.supabase\.co$/);
70
+ return match ? match[1] : null;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /**
77
+ * 目标 Storage URL 的严格校验:必须是 https 且恰为 <ref>.supabase.co 源(无路径/查询)。
78
+ * service-role key 会作为请求头发往这个地址——校验必须发生在**第一次 fetch 之前**,
79
+ * 否则任意主机(或明文 http)都能收到 key(评审第 8 轮)。返回规范化 origin。
80
+ */
81
+ export function validateStorageTargetOrigin(supabaseUrl) {
82
+ let url;
83
+ try {
84
+ url = new URL(supabaseUrl);
85
+ }
86
+ catch {
87
+ throw new Error(`--target-supabase-url is not a valid URL: ${supabaseUrl}`);
88
+ }
89
+ const ref = normalizeHost(url.hostname).match(/^([a-z0-9]{16,})\.supabase\.co$/);
90
+ if (url.protocol !== "https:" || !ref || (url.pathname !== "/" && url.pathname !== "") || url.search) {
91
+ throw new Error(`--target-supabase-url must be exactly https://<project-ref>.supabase.co — got "${supabaseUrl}". ` +
92
+ `The service-role key is only ever sent to a verified Supabase project origin.`);
93
+ }
94
+ return url.origin;
95
+ }
96
+ // assertNoHostOverride 上移到引擎层(备份侧的源身份自记也要用);此处转出口
97
+ export { assertNoHostOverride } from "./restore-engine.js";
98
+ /**
99
+ * 备份配置能导出的全部源项目 ref:数据库连接串 + Storage 读取端点
100
+ * (https://<ref>.storage.supabase.co/...)。storage-only 恢复没有目标库连接串,
101
+ * 同源阻断必须同样覆盖"目标 Storage = 源项目"的组合(评审第 8 轮)。
102
+ */
103
+ export function sourceProjectRefs(config, manifest) {
104
+ const refs = new Set();
105
+ if (config.databaseUrl !== NO_SOURCE_DATABASE) {
106
+ const ref = projectRefOf(config.databaseUrl);
107
+ if (ref)
108
+ refs.add(ref);
109
+ }
110
+ const endpoint = config.supabaseStorage?.endpoint;
111
+ if (endpoint) {
112
+ const ref = refFromStorageEndpoint(endpoint);
113
+ if (ref)
114
+ refs.add(ref);
115
+ }
116
+ // manifest 自记的源 ref(评审第 9 轮):纯 flag 恢复没有 config 可比对,
117
+ // 快照自己就是最后一道源身份来源
118
+ if (manifest?.sourceProjectRef)
119
+ refs.add(manifest.sourceProjectRef);
120
+ return refs;
121
+ }
122
+ /**
123
+ * 请求与快照内容必须相交(评审第 15 轮,纯函数可测):什么都不会做的调用
124
+ * 不得走到"Restore complete"——自动化会把 no-op 记成一次成功恢复。
125
+ */
126
+ export function assertRequestApplies(manifest, wantsDatabase, wantsStorageUpload) {
127
+ if (wantsStorageUpload && manifest.storage === null) {
128
+ throw new Error("this snapshot is database-only — there is no Storage to upload. Drop --target-supabase-url " +
129
+ "(and pass --database if the database restore is what you want).");
130
+ }
131
+ if (!wantsDatabase && !wantsStorageUpload && manifest.storage === null) {
132
+ throw new Error("nothing to do: no database restore was requested (--database) and this snapshot has no " +
133
+ "Storage files to download.");
134
+ }
135
+ }
136
+ /**
137
+ * 正式执行的确认门(PRD §5.3.3):用户必须键入目标 project ref(或非 Supabase 目标的
138
+ * 主机名)与实际连接目标一致。误把生产源当目标是恢复流程里代价最不对称的失误,
139
+ * 这道门要求人把目标身份亲手打一遍。
140
+ */
141
+ export function assertConfirmedTarget(confirmTarget, targetDatabaseUrl, targetSupabaseUrl) {
142
+ const dbRef = targetDatabaseUrl ? projectRefOf(targetDatabaseUrl) : null;
143
+ const apiRef = targetSupabaseUrl ? refFromSupabaseUrl(targetSupabaseUrl) : null;
144
+ // 两个入口都给了且指向不同项目 → 直接硬错,和确认无关
145
+ if (dbRef && apiRef && dbRef !== apiRef) {
146
+ throw new Error(`the target database URL points at project "${dbRef}" but --target-supabase-url at "${apiRef}" — ` +
147
+ `these are different projects; fix one of them.`);
148
+ }
149
+ // 外部(非 Supabase)库目标 + Supabase Storage 目标:一个确认值盖不住两个不相干
150
+ // 的目标(评审第 12 轮)——这种混搭直接拒绝,分两次跑各自确认
151
+ if (targetDatabaseUrl && !dbRef && apiRef) {
152
+ throw new Error("the database target is not a Supabase project while the Storage target is — one confirmation " +
153
+ "cannot cover two unrelated targets. Run the database restore and the Storage restore separately.");
154
+ }
155
+ let expected = dbRef ?? apiRef;
156
+ if (!expected && targetDatabaseUrl) {
157
+ try {
158
+ expected = new URL(targetDatabaseUrl).hostname || null;
159
+ }
160
+ catch {
161
+ expected = null;
162
+ }
163
+ }
164
+ if (!expected) {
165
+ throw new Error("cannot derive the target's identity from the given URLs — refusing to write without a confirmable target.");
166
+ }
167
+ if (confirmTarget !== expected) {
168
+ throw new Error(`restore writes to target "${expected}" — confirm by re-running with --confirm-target ${expected} ` +
169
+ `(use --dry-run first to preview all checks without writing).`);
170
+ }
171
+ }
172
+ /**
173
+ * 空目标门(PRD §5.3.3 / 决策 D5):目标 schema 里存在任何用户对象即拒绝写入。
174
+ * "空"的定义来自 R0 spike:不是"schema 不存在"(Supabase 恒有 public),而是
175
+ * schema 内零用户对象——关系、函数/过程、独立类型(enum/domain/range/composite)
176
+ * 全部计入:只查关系时,"目标里只有函数"会放行,然后在 pre-data 中途撞冲突,
177
+ * 留下半截目标(评审第 7 轮)。
178
+ */
179
+ async function assertEmptyTarget(targetUrl, schemas) {
180
+ const client = new Client(pgConnectOptions(targetUrl));
181
+ await client.connect();
182
+ try {
183
+ const res = await client.query(`select
184
+ (select count(*) from pg_class c join pg_namespace n on n.oid = c.relnamespace
185
+ where n.nspname = any($1::text[]) and c.relkind in ('r','p','m','v','S','f','c'))
186
+ + (select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace
187
+ where n.nspname = any($1::text[]))
188
+ + (select count(*) from pg_type t join pg_namespace n on n.oid = t.typnamespace
189
+ where n.nspname = any($1::text[]) and t.typtype in ('e','d','r'))
190
+ as n`, [schemas]);
191
+ const objectCount = Number(res.rows[0].n);
192
+ if (objectCount > 0) {
193
+ throw new Error(`target schema(s) ${schemas.join(", ")} contain ${objectCount} existing object(s) — ` +
194
+ `restore only writes into an empty target. Create a fresh Supabase project ` +
195
+ `(or empty these schemas) and retry. If a previous run already restored the ` +
196
+ `database and only Storage was interrupted, re-run WITHOUT the database target ` +
197
+ `to retry Storage alone.`);
198
+ }
199
+ }
200
+ finally {
201
+ await client.end();
202
+ }
203
+ }
204
+ /**
205
+ * dry-run 只读预检(PRD §5.3.2):零写入,把正式执行会撞的墙全部提前撞一遍。
206
+ * 逐项打印,任何 blocker 汇总后抛出(退出码非零 = "现在执行不会成功")。
207
+ */
208
+ async function dryRunPreflight(config, manifest, targetUrl, storageTarget) {
209
+ const blockers = [];
210
+ const db = manifest.database;
211
+ const mb = (n) => (n / 1024 / 1024).toFixed(1);
212
+ log.ok(`coverage: schemas ${db.schemas.join(", ")} — ${db.tableCount} tables, ` +
213
+ `~${db.estimatedRowTotal.toLocaleString()} rows, dump ${mb(manifest.dump.bytes)} MB`);
214
+ log.ok(manifest.storage
215
+ ? `storage: ${manifest.storage.fileCount} files, ${mb(manifest.storage.totalBytes)} MB` +
216
+ (manifest.storage.buckets ? `, ${manifest.storage.buckets.length} bucket(s) with attributes` : ", bucket attributes not captured")
217
+ : "storage: none (database-only snapshot)");
218
+ log.warn("not covered: Auth users/sessions, secret values, Edge Functions, most platform settings");
219
+ // 双目标一致性(dry-run 也要查,不能绿灯放行到正式执行才拒):库与 Storage
220
+ // 两个入口指向不同项目 = 配置错了一个
221
+ const dbRef = targetUrl ? projectRefOf(targetUrl) : null;
222
+ const apiRef = storageTarget ? refFromSupabaseUrl(storageTarget.supabaseUrl) : null;
223
+ if (dbRef && apiRef && dbRef !== apiRef) {
224
+ blockers.push(`database URL targets "${dbRef}" but Storage URL targets "${apiRef}"`);
225
+ log.error(`target consistency: database → ${dbRef}, storage → ${apiRef} — different projects`);
226
+ }
227
+ else if (dbRef || apiRef) {
228
+ log.ok(`target project: ${dbRef ?? apiRef}`);
229
+ }
230
+ if (targetUrl) {
231
+ if (config.databaseUrl !== NO_SOURCE_DATABASE && sameDatabaseTarget(config.databaseUrl, targetUrl)) {
232
+ blockers.push("target database is the backup SOURCE (same project)");
233
+ log.error("target identity: SAME AS SOURCE — would be blocked");
234
+ }
235
+ else {
236
+ log.ok("target identity: distinct from source");
237
+ }
238
+ try {
239
+ await assertEmptyTarget(targetUrl, db.schemas);
240
+ log.ok(`target schemas (${db.schemas.join(", ")}) are empty`);
241
+ }
242
+ catch (error) {
243
+ blockers.push(error.message);
244
+ log.error(`empty-target check: ${error.message}`);
245
+ }
246
+ const client = new Client(pgConnectOptions(targetUrl));
247
+ await client.connect();
248
+ try {
249
+ // SHOW 的结果列名是 server_version;用 current_setting 显式起别名,
250
+ // 否则取错列得 NaN,而 NaN < x 恒 false = 版本降级永远查不出来
251
+ const version = await client.query("select current_setting('server_version') as v");
252
+ const targetMajor = parseInt(version.rows[0].v, 10);
253
+ const sourceMajor = parseInt(db.serverVersion, 10);
254
+ if (!Number.isFinite(targetMajor)) {
255
+ blockers.push(`cannot determine target PostgreSQL version (got "${version.rows[0].v}")`);
256
+ log.error("postgres version: undeterminable — refusing to guess");
257
+ }
258
+ else if (targetMajor < sourceMajor) {
259
+ blockers.push(`target PostgreSQL ${targetMajor} < source ${sourceMajor}`);
260
+ log.error(`postgres version: target ${targetMajor} < source ${sourceMajor} — would fail`);
261
+ }
262
+ else {
263
+ log.ok(`postgres version: target ${targetMajor} ≥ source ${sourceMajor}`);
264
+ }
265
+ const toolMajor = await localPgRestoreMajor();
266
+ const requiredMajor = requiredRestoreToolMajor(manifest);
267
+ if (toolMajor === null) {
268
+ blockers.push("local pg_restore missing or version unparseable");
269
+ log.error("pg_restore tool: not found / version unparseable — set BACKUPDRILL_PG_RESTORE");
270
+ }
271
+ else if (toolMajor < requiredMajor) {
272
+ blockers.push(`local pg_restore v${toolMajor} < archive requirement v${requiredMajor}`);
273
+ log.error(`pg_restore tool: local v${toolMajor} cannot read a v${requiredMajor} archive`);
274
+ }
275
+ else {
276
+ log.ok(`pg_restore tool: local v${toolMajor} ≥ archive requirement v${requiredMajor}`);
277
+ }
278
+ const extensions = db.extensions ?? [];
279
+ if (extensions.length) {
280
+ const available = await client.query(`select name from pg_available_extensions where name = any($1::text[])`, [extensions.map((e) => e.name)]);
281
+ const availableNames = new Set(available.rows.map((r) => r.name));
282
+ const missing = extensions.filter((e) => !availableNames.has(e.name)).map((e) => e.name);
283
+ if (missing.length) {
284
+ blockers.push(`extensions unavailable on target: ${missing.join(", ")}`);
285
+ log.error(`extensions: ${missing.join(", ")} unavailable — enable in dashboard first`);
286
+ }
287
+ else {
288
+ log.ok(`extensions: all ${extensions.length} available on target`);
289
+ }
290
+ }
291
+ }
292
+ finally {
293
+ await client.end();
294
+ }
295
+ }
296
+ else {
297
+ log.warn("no target database URL — database restore would be skipped");
298
+ }
299
+ if (storageTarget) {
300
+ // 完整的 Storage 只读计划(评审第 16 轮):dry-run 不止测可达性,把桶校验、
301
+ // 残留净度、残留哈希全部提前撞一遍
302
+ try {
303
+ const plan = await planStorageRestore(storageTarget, manifest);
304
+ log.ok(`storage plan: ${plan.bucketsToCreate.length} bucket(s) to create, ` +
305
+ `${plan.verifiedResidue} verified residue file(s)`);
306
+ for (const warning of plan.warnings)
307
+ log.warn(warning);
308
+ }
309
+ catch (error) {
310
+ blockers.push(`storage plan: ${error.message}`);
311
+ log.error(`storage plan: ${error.message}`);
312
+ }
313
+ }
314
+ else if (manifest.storage) {
315
+ log.warn("no Storage target credentials — files would be downloaded locally, not uploaded");
316
+ }
317
+ if (blockers.length) {
318
+ throw new Error(`dry run found ${blockers.length} blocker(s):\n - ${blockers.join("\n - ")}`);
319
+ }
320
+ log.ok("dry run passed — nothing was written; re-run with --confirm-target to execute");
23
321
  }
24
322
  /**
25
- * 把一份快照恢复出来:数据库还原进 --target-database-url;Storage 文件下载到本地目录
26
- * (拿回文件后你可以按需回传到新 Supabase 项目)。这是 US-5 恢复向导的 DIY 版本。
323
+ * 把一份快照恢复出来:数据库经统一引擎还原进目标(与演练同一条 pg_restore 路径与
324
+ * 错误分类),恢复后跑同一套表级验证;Storage 提供目标凭据时回传+对账(P0-D),
325
+ * 否则下载到本地目录。这是 US-5 恢复向导的 CLI 形态。
27
326
  */
28
327
  export async function runRestore(config, opts) {
29
328
  const s3 = targetClient(config);
@@ -31,32 +330,225 @@ export async function runRestore(config, opts) {
31
330
  const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
32
331
  const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
33
332
  const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
333
+ // Storage 目标构造即校验(https + 恰为 <ref>.supabase.co):service key 只发往
334
+ // 已验证的 Supabase 项目源;目标库连接串拒绝 host 覆盖(身份判定=实际写入目标)
335
+ // 两个字段必须成对:只给其一时静默退回"本地下载"会让库级调用方把受保护文件
336
+ // 写进 worker 本地目录还以为回传成功了(评审第 9 轮)
337
+ if (!!opts.targetSupabaseUrl !== !!opts.targetServiceRoleKey) {
338
+ throw new Error("targetSupabaseUrl and targetServiceRoleKey must be provided together — " +
339
+ "refusing to silently fall back to a local download.");
340
+ }
341
+ const storageTarget = opts.targetSupabaseUrl && opts.targetServiceRoleKey
342
+ ? {
343
+ supabaseUrl: validateStorageTargetOrigin(opts.targetSupabaseUrl),
344
+ serviceRoleKey: opts.targetServiceRoleKey,
345
+ }
346
+ : null;
347
+ if (opts.targetDatabaseUrl)
348
+ assertNoHostOverride(opts.targetDatabaseUrl);
349
+ // 同源阻断(统一收口):目标(库或 Storage)的 ref 命中任何源项目 ref → 拒绝。
350
+ // 覆盖 storage-only 组合:目标 Storage = 备份读取源的项目时,upsert 会改写源。
351
+ const sourceRefs = sourceProjectRefs(config, manifest);
352
+ const targetRefs = [
353
+ opts.targetDatabaseUrl ? projectRefOf(opts.targetDatabaseUrl) : null,
354
+ opts.targetSupabaseUrl ? refFromSupabaseUrl(opts.targetSupabaseUrl) : null,
355
+ ].filter((r) => r !== null);
356
+ for (const ref of targetRefs) {
357
+ if (sourceRefs.has(ref)) {
358
+ throw new Error(`target project "${ref}" is the backup SOURCE — restoring onto the source is blocked. ` +
359
+ `Point the restore at a fresh Supabase project.`);
360
+ }
361
+ }
362
+ // 源身份必须**对每类目标可比**才算已知(评审第 10/14 轮):
363
+ // - 库目标:有真实源连接串即可(host+user 逐一可比),或有 ref;
364
+ // - Storage 目标:只有 ref 能比(无关的外部 DATABASE_URL 不构成 Storage 侧身份)。
365
+ // 不可比 + 未显式认知风险 = 拒绝,不 fail-open。
366
+ const refIdentityKnown = sourceRefs.size > 0;
367
+ const dbIdentityComparable = config.databaseUrl !== NO_SOURCE_DATABASE || refIdentityKnown;
368
+ const unverifiable = (opts.targetDatabaseUrl && !dbIdentityComparable) || (storageTarget && !refIdentityKnown);
369
+ if (unverifiable && !opts.acknowledgeUnverifiedSource) {
370
+ throw new Error("source identity cannot be verified for every requested target (legacy snapshot without a " +
371
+ "comparable source record) — same-source protection would be inert. Restore with the original " +
372
+ "backup config (-c backupdrill.config.json), or pass --acknowledge-unverified-source after " +
373
+ "double-checking the target is NOT the original project.");
374
+ }
34
375
  const result = {
35
376
  snapshot,
36
377
  restoredToDatabase: false,
37
378
  storageFilesWritten: 0,
38
379
  };
380
+ assertRequestApplies(manifest, !!opts.targetDatabaseUrl, !!storageTarget);
381
+ // dry-run:只读预检后直接返回,零写入(PRD §5.3.2)
382
+ if (opts.dryRun) {
383
+ log.step(`Dry run for snapshot ${snapshot} — nothing will be written`);
384
+ await dryRunPreflight(config, manifest, opts.targetDatabaseUrl, storageTarget);
385
+ result.dryRun = true;
386
+ return result;
387
+ }
388
+ // 正式执行的确认门(PRD §5.3.3):任何目标写入(库或 Storage)之前
389
+ if (opts.targetDatabaseUrl || storageTarget) {
390
+ assertConfirmedTarget(opts.confirmTarget, opts.targetDatabaseUrl, opts.targetSupabaseUrl);
391
+ }
39
392
  const workdir = await mkdtemp(join(tmpdir(), "backupdrill-restore-"));
40
393
  try {
41
- // 1. 数据库
394
+ // 0. Storage 只读计划先行(评审第 16 轮):可预见的 Storage 冲突必须在数据库
395
+ // 写入之前发现——"库好了、Storage 拒了"的半截目标是本序修掉的形态。
396
+ // 执行阶段(下方 2a)会重新计划:库恢复耗时内目标可能变化,复验是正确成本。
397
+ if (storageTarget && manifest.storage) {
398
+ log.step("Planning Storage restore (read-only, before any database write)…");
399
+ await planStorageRestore(storageTarget, manifest);
400
+ }
401
+ // 1. 数据库(写入前依次过安全门:同源阻断 → 空目标 → 归档校验 → 扩展预装)
42
402
  if (opts.targetDatabaseUrl) {
403
+ const targetUrl = opts.targetDatabaseUrl;
404
+ // 同源阻断(PRD §9.3):恢复绝不写回备份的源库。误把生产源当目标是
405
+ // 恢复流程里代价最不对称的失误。
406
+ if (config.databaseUrl !== NO_SOURCE_DATABASE &&
407
+ sameDatabaseTarget(config.databaseUrl, targetUrl)) {
408
+ throw new Error("target database resolves to the same host and user as the backup source — " +
409
+ "restoring onto the source project is blocked. Point BACKUPDRILL_TARGET_DATABASE_URL " +
410
+ "at a fresh Supabase project.");
411
+ }
412
+ log.step("Checking target is empty…");
413
+ await assertEmptyTarget(targetUrl, manifest.database.schemas);
414
+ // 版本预检不只属于 dry-run:跳过 dry-run 直接执行时,目标比源旧照样会
415
+ // 半途炸掉或部分恢复(评审第 8 轮)——写入前必须拦住
416
+ const versionClient = new Client(pgConnectOptions(targetUrl));
417
+ await versionClient.connect();
418
+ let targetMajor;
419
+ try {
420
+ const res = await versionClient.query("select current_setting('server_version') as v");
421
+ targetMajor = parseInt(res.rows[0].v, 10);
422
+ }
423
+ finally {
424
+ await versionClient.end();
425
+ }
426
+ const sourceMajor = parseInt(manifest.database.serverVersion, 10);
427
+ if (!Number.isFinite(targetMajor) || targetMajor < sourceMajor) {
428
+ throw new Error(`target PostgreSQL (${Number.isFinite(targetMajor) ? targetMajor : "undeterminable"}) is older ` +
429
+ `than the source (${sourceMajor}) — pg_restore would fail or partially restore. ` +
430
+ `Create the target on PostgreSQL ${sourceMajor} or newer.`);
431
+ }
432
+ // 本地工具闸(评审第 10 轮):dump 归档格式跟随写入工具版本,老 pg_restore
433
+ // 读不了新归档——不查的话会先装完扩展再在恢复中途才炸
434
+ const toolMajor = await localPgRestoreMajor();
435
+ const requiredMajor = requiredRestoreToolMajor(manifest);
436
+ // 查不到版本(pg_restore 缺失/输出不可解析)= fail-closed:不能先装扩展改了目标,
437
+ // 才发现根本没有可用的恢复工具(评审第 11 轮)
438
+ if (toolMajor === null) {
439
+ throw new Error(`cannot determine the local pg_restore version — install postgresql client ${requiredMajor}+ ` +
440
+ `and/or point BACKUPDRILL_PG_RESTORE at it before restoring.`);
441
+ }
442
+ if (toolMajor < requiredMajor) {
443
+ throw new Error(`local pg_restore is v${toolMajor} but this archive needs v${requiredMajor}+ ` +
444
+ `(archive format follows the writing tool). Install postgresql client ${requiredMajor}+ ` +
445
+ `and/or point BACKUPDRILL_PG_RESTORE at it.`);
446
+ }
43
447
  log.step("Downloading dump…");
44
448
  const dumpPath = join(workdir, "dump.pgcustom");
45
- await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
449
+ const { sha256 } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
450
+ // 归档完整性是恢复的前提:哈希不符还继续,只会把"备份坏了"变成一堆误导性
451
+ // 恢复错误(演练侧同一裁决:integrity FAIL 即短路)
452
+ if (sha256 !== manifest.dump.sha256) {
453
+ throw new Error(`archive integrity check failed: downloaded sha256 ${sha256.slice(0, 12)}… ` +
454
+ `does not match manifest ${manifest.dump.sha256.slice(0, 12)}… — refusing to restore a corrupted dump`);
455
+ }
456
+ // 扩展预装:沙箱装不上只能记录(环境局限),真实目标装不上必须阻断——
457
+ // 用户在 dashboard 一键可开,恢复残缺副本则要人工收拾(引擎头注的裁决分工)
458
+ const extensions = manifest.database.extensions ?? [];
459
+ if (extensions.length) {
460
+ log.step(`Installing ${extensions.length} extension(s) on target…`);
461
+ const unavailable = await installExtensions(targetUrl, extensions);
462
+ if (unavailable.length) {
463
+ throw new Error(`target cannot install extension(s): ${unavailable.join(", ")} — enable them in ` +
464
+ `the Supabase dashboard (Database → Extensions) and retry.`);
465
+ }
466
+ }
46
467
  log.step("Restoring database into target…");
47
- await pgRestore(dumpPath, opts.targetDatabaseUrl);
468
+ const engine = await restoreDatabaseArtifact({ dumpPath, connString: targetUrl, target: "supabase" });
469
+ if (!engine.ok) {
470
+ const failures = [...engine.preData.failures, ...engine.postData.failures];
471
+ throw new Error(`database restore failed: ${failures.join(" | ")}`);
472
+ }
48
473
  result.restoredToDatabase = true;
49
- log.ok(`Database restored (${manifest.database.tableCount} tables)`);
474
+ // 与演练同一套表级验证:两条路径对同一快照必须同一结论(PRD §5.2 验收)
475
+ log.step("Verifying restored tables…");
476
+ const verified = await verifyRestored(targetUrl, manifest);
477
+ result.databaseChecks = verified.checks;
478
+ result.databaseVerified = verified.checks.every((c) => c.pass);
479
+ for (const check of verified.checks) {
480
+ (check.pass ? log.ok : log.warn)(`${check.name}: ${check.detail}`);
481
+ }
482
+ log.ok(`Database restored (${verified.tableCount} tables, ` +
483
+ `${verified.rowTotal.toLocaleString()} rows)`);
50
484
  }
51
485
  else {
52
- log.warn("No --target-database-url given; skipping database restore.");
486
+ log.warn("No database target (--database + BACKUPDRILL_TARGET_DATABASE_URL); skipping database restore.");
53
487
  }
54
- // 2. Storage 文件 → 本地目录
55
- if (manifest.storage && manifest.storage.files.length) {
488
+ // 2a. Storage → 目标项目回传(P0-D:给了目标 Storage 凭据时)
489
+ if (manifest.storage && storageTarget) {
490
+ const summary = await restoreStorage({ s3, bucket: config.storage.bucket, base }, storageTarget, manifest);
491
+ result.storageSummary = summary;
492
+ result.storageFilesWritten = summary.filesUploaded;
493
+ log.ok(`Storage restored: ${summary.filesUploaded}/${manifest.storage.files.length} files uploaded` +
494
+ (summary.filesSkippedIdentical
495
+ ? `, ${summary.filesSkippedIdentical} already present (identical size)`
496
+ : "") +
497
+ ` (${(summary.bytesUploaded / 1024 / 1024).toFixed(1)} MB), ` +
498
+ `buckets created ${summary.bucketsCreated.length} / existing ${summary.bucketsExisting.length}`);
499
+ if (summary.bucketsWithoutAttrs.length) {
500
+ log.warn(`bucket attributes not captured for: ${summary.bucketsWithoutAttrs.join(", ")} — ` +
501
+ `created with defaults; review public/size/MIME settings by hand`);
502
+ }
503
+ for (const failed of summary.filesFailed.slice(0, 5)) {
504
+ log.warn(`file failed: ${failed.file} — ${failed.reason}`);
505
+ }
506
+ if (summary.filesFailed.length > 5) {
507
+ log.warn(`…and ${summary.filesFailed.length - 5} more file failure(s)`);
508
+ }
509
+ for (const drift of summary.bucketAttrDrift) {
510
+ log.warn(`bucket attribute drift: ${drift}`);
511
+ }
512
+ for (const note of summary.metadataNotes) {
513
+ log.warn(`metadata: ${note}`);
514
+ }
515
+ if (summary.retries)
516
+ log.warn(`transient failures retried: ${summary.retries}`);
517
+ const rec = summary.reconcile;
518
+ (rec.matches ? log.ok : log.error)(`reconcile: ${rec.verified}/${manifest.storage.files.length} file(s) present with matching size` +
519
+ (rec.missing.length ? `; MISSING: ${rec.missing.slice(0, 5).join(", ")}` : "") +
520
+ (rec.sizeMismatched.length ? `; SIZE MISMATCH: ${rec.sizeMismatched.slice(0, 5).join(", ")}` : "") +
521
+ (rec.extras ? `; ${rec.extras} extra object(s) on target not in this snapshot` : ""));
522
+ if (summary.checksumSample.checked) {
523
+ (summary.checksumSample.mismatched.length === 0 ? log.ok : log.error)(`checksum sample: ${summary.checksumSample.checked} file(s) re-hashed from target` +
524
+ (summary.checksumSample.mismatched.length
525
+ ? `, MISMATCH: ${summary.checksumSample.mismatched.join(", ")}`
526
+ : ", all match"));
527
+ }
528
+ log.warn(summary.ownerNote);
529
+ }
530
+ else if (manifest.storage && manifest.storage.files.length) {
56
531
  const outDir = opts.storageDir ?? join(process.cwd(), `restored-storage-${snapshot}`);
532
+ // 输出目录必须是新的/空的:路径锚定检查是词法层面的,防不住目录里**已有**的
533
+ // 符号链接把写入引到别处;保证目录从空开始 = 所有子路径都由本进程创建,无链接可循
534
+ // 根路径若已存在,必须是真目录:mkdir/readdir/词法锚定都会跟随根上的符号链接,
535
+ // 一条预置的 link 就能把"新目录"指到别处(评审第 12 轮)
536
+ const rootStat = await lstat(outDir).catch(() => null);
537
+ if (rootStat && !rootStat.isDirectory()) {
538
+ throw new Error(`storage output path exists and is not a real directory: ${outDir}`);
539
+ }
540
+ await mkdir(outDir, { recursive: true });
541
+ if ((await readdir(outDir)).length > 0) {
542
+ throw new Error(`storage output directory is not empty: ${outDir} — restore only writes into a fresh directory`);
543
+ }
57
544
  log.step(`Downloading ${manifest.storage.files.length} Storage files → ${outDir}`);
58
545
  for (const f of manifest.storage.files) {
59
546
  const dest = join(outDir, f.bucket, f.key);
547
+ // 纵深防御:parseManifest 已拒绝穿越段,这里仍锚定最终路径必须落在 outDir 内
548
+ // ——两道防线独立失效才会发生"篡改的 manifest 写穿输出目录"
549
+ if (!resolve(dest).startsWith(resolve(outDir) + sep)) {
550
+ throw new Error(`unsafe storage path escapes output directory: ${f.bucket}/${f.key}`);
551
+ }
60
552
  await mkdir(dirname(dest), { recursive: true });
61
553
  const res = await s3.send(new GetObjectCommand({
62
554
  Bucket: config.storage.bucket,