backupdrill 1.0.0 → 1.1.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/README.md CHANGED
@@ -168,12 +168,18 @@ backupdrill restore \
168
168
  --storage-dir ./recovered
169
169
  ```
170
170
 
171
- Or restore the dump by hand:
171
+ Or restore the dump by hand — into an **empty** database, without `--clean`:
172
172
 
173
173
  ```bash
174
- pg_restore --clean --if-exists --no-owner --dbname "<target-connection-string>" dump.pgcustom
174
+ pg_restore --no-owner --no-privileges --dbname "<target-connection-string>" dump.pgcustom
175
175
  ```
176
176
 
177
+ Don't use `--clean` against a Supabase project: dropping and recreating the `public`
178
+ schema silently wipes Supabase's default grants for `anon`/`authenticated` (the dump
179
+ carries no ACLs), leaving the restored API returning 401s. One error is expected and
180
+ harmless: `schema "public" already exists` — the target always has it. The
181
+ `backupdrill restore` command above handles all of this (plus verification) for you.
182
+
177
183
  ## GitHub Action / scheduled backups
178
184
 
179
185
  Want this running on a schedule without paying for a server? Run it in GitHub Actions.
package/dist/backup.js CHANGED
@@ -7,6 +7,7 @@ import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
7
7
  import { Upload } from "@aws-sdk/lib-storage";
8
8
  import { MANIFEST_SCHEMA_VERSION } from "./manifest.js";
9
9
  import { syncStorage } from "./storage.js";
10
+ import { inspectBucketAttrs } from "./storage-catalog.js";
10
11
  import { log } from "./log.js";
11
12
  import { TOOL_VERSION } from "./version.js";
12
13
  import { pgConnectOptions, dumpUrlFor } from "./supabase-ca.js";
@@ -186,7 +187,20 @@ export async function runBackup(config) {
186
187
  bucket: config.storage.bucket,
187
188
  base, // syncStorage 内部会加 /storage/<bucket>/<key>
188
189
  });
190
+ // v2 bucket 属性采集,通道 = 已有的只读库连接(per-file 元数据已随 GetObject
191
+ // 响应在 syncStorage 内原子捕获)。失败只降级不报错:storage schema 读不到
192
+ // (如收紧的 backup_reader 角色)时,manifest 缺 buckets 字段,恢复端把缺失
193
+ // 如实标 not captured(PRD §5.1.3)。
194
+ let bucketAttrs;
195
+ try {
196
+ bucketAttrs = await inspectBucketAttrs(config.databaseUrl, synced.buckets);
197
+ }
198
+ catch (error) {
199
+ log.warn(`Bucket attributes not captured (${error.message}); ` +
200
+ `this manifest will record them as absent — restore will say so instead of guessing.`);
201
+ }
189
202
  storageResult = {
203
+ ...(bucketAttrs ? { buckets: bucketAttrs } : {}),
190
204
  fileCount: synced.fileCount,
191
205
  totalBytes: synced.totalBytes,
192
206
  files: synced.files,
package/dist/drill.d.ts CHANGED
@@ -31,13 +31,16 @@ interface PostDataResult {
31
31
  supabaseSkipped: number;
32
32
  failures: string[];
33
33
  }
34
- /** 把 pg_restore 的 stderr 拆成单个错误块并分类(Supabase 托管 vs 用户对象)。 */
34
+ /** 兼容层:历史签名保留,分类逻辑的单一权威在统一引擎(restore-engine.ts)。 */
35
35
  export declare function classifyPostDataErrors(stderr: string): PostDataResult;
36
- /**
37
- * post-data 第二遍的最终裁决:分类之外,非零退出却没解析到任何错误块(被信号杀死、
38
- * 归档器致命错误等)绝不能谎报"全部恢复"——记为通用失败。
39
- */
36
+ /** 兼容层:见 restore-engine.ts finalizePass(非零退出且零错误块 = 通用失败)。 */
40
37
  export declare function postDataResult(code: number | null, stderr: string): PostDataResult;
38
+ /** 导出:真实恢复(restore.ts)复用同一套表级验证——两条路径对同一快照必须同一结论。 */
39
+ export declare function verifyRestored(connString: string, manifest: Manifest): Promise<{
40
+ checks: DrillCheck[];
41
+ tableCount: number;
42
+ rowTotal: number;
43
+ }>;
41
44
  export declare const APP_CHECK_TIMEOUT_MS: number;
42
45
  /**
43
46
  * 语义层钩子:结构检查全绿后、销毁沙箱前,把沙箱连接串经 BACKUPDRILL_SANDBOX_URL
package/dist/drill.js CHANGED
@@ -6,7 +6,8 @@ import { promisify } from "node:util";
6
6
  import { Client } from "pg";
7
7
  import { parseManifest } from "./manifest.js";
8
8
  import { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
9
- import { resolvePgRestoreBin } from "./pgbin.js";
9
+ import { SANDBOX_MANAGED_ERROR, classifyBlocks, finalizePass, installExtensions, quoteIdent, restoreDatabaseArtifact, } from "./restore-engine.js";
10
+ import { pgConnectOptions } from "./supabase-ca.js";
10
11
  import { log } from "./log.js";
11
12
  const execFileAsync = promisify(execFile);
12
13
  // Storage 校验默认抽样上限(避免每次演练重下全部文件产生大量 egress);超限如实记录
@@ -92,114 +93,42 @@ async function startEphemeralPostgres(image) {
92
93
  throw error;
93
94
  }
94
95
  }
95
- function quoteIdent(name) {
96
- return `"${name.replace(/"/g, '""')}"`;
97
- }
98
- /**
99
- * 恢复前把 manifest 记录的扩展装进沙箱(--schema 转储不含 CREATE EXTENSION)。
100
- * 必须装进与源库相同的 schema:dump 里的类型是限定名(如 extensions.vector)。
101
- * 装不上的扩展如实收集返回,**不在这里失败**——Supabase 项目普遍装着沙箱镜像
102
- * 没有的托管扩展(pg_graphql、supabase_vault…),public 转储通常并不引用它们;
103
- * 只有恢复真的因此失败时才升级为分类错误(见 drillDump)。
104
- */
105
- async function installExtensions(connString, extensions) {
106
- if (!extensions.length)
107
- return [];
108
- const client = new Client({ connectionString: connString });
109
- await client.connect();
110
- const unavailable = [];
111
- try {
112
- for (const ext of extensions) {
113
- try {
114
- await client.query(`create schema if not exists ${quoteIdent(ext.schema)}`);
115
- // CASCADE 自动带上依赖扩展,免受 manifest 里的安装顺序摆布
116
- await client.query(`create extension if not exists ${quoteIdent(ext.name)} schema ${quoteIdent(ext.schema)} cascade`);
117
- }
118
- catch {
119
- unavailable.push(ext.name);
120
- }
121
- }
122
- }
123
- finally {
124
- await client.end();
125
- }
126
- return unavailable;
127
- }
128
- function spawnPgRestore(bin, args) {
129
- return new Promise((resolve, reject) => {
130
- const proc = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
131
- let stderr = "";
132
- proc.stderr.on("data", (d) => (stderr += d.toString()));
133
- proc.on("error", reject);
134
- proc.on("close", (code) => resolve({ code, stderr }));
135
- });
136
- }
137
96
  // 只有这些特征说明恢复失败源于沙箱缺扩展(限定类型/schema 解析不到、扩展装不上)。
138
97
  // 真实 Supabase 项目的 manifest 几乎必然含沙箱装不上的托管扩展(pg_graphql 等),
139
98
  // 若不按特征过滤,任何硬失败(归档损坏、OOM…)都会被误归因到沙箱。
140
99
  const MISSING_EXTENSION_ERROR = /type "[^"]+" does not exist|schema "[^"]+" does not exist|extension "[^"]+" is not available/i;
141
- // 演练沙箱是裸 Postgres,Supabase 托管的 schema/角色必然缺席。post-data 里
142
- // 引用它们的失败是"环境预期",不是备份坏了;其余失败才是演练要抓的。
143
- const SUPABASE_MANAGED_ERROR = /schema "(auth|storage|realtime|vault|extensions|graphql[a-z_]*)" does not exist|role "(authenticated|anon|service_role|supabase_[a-z_]+)" does not exist|\bauth\.uid\b|\bauth\.jwt\b/i;
144
- /** 把 pg_restore 的 stderr 拆成单个错误块并分类(Supabase 托管 vs 用户对象)。 */
100
+ /** 兼容层:历史签名保留,分类逻辑的单一权威在统一引擎(restore-engine.ts)。 */
145
101
  export function classifyPostDataErrors(stderr) {
146
- const blocks = stderr
147
- .split(/(?=pg_restore: error:)/)
148
- .filter((b) => /pg_restore: error:/.test(b));
149
- let supabaseSkipped = 0;
150
- const failures = [];
151
- for (const block of blocks) {
152
- // 只对 ERROR 原因行分类,不看 "Command was:" 之后的语句文本——否则一个恰好
153
- // 引用 auth.uid 的用户对象因"损坏/语法错误"失败时,会被误判成预期跳过。
154
- const cause = block.split(/Command was:/i)[0];
155
- if (SUPABASE_MANAGED_ERROR.test(cause)) {
156
- supabaseSkipped += 1;
157
- }
158
- else {
159
- failures.push(block.replace(/\s+/g, " ").trim().slice(0, 200));
160
- }
161
- }
162
- return { supabaseSkipped, failures };
102
+ const classified = classifyBlocks(stderr, SANDBOX_MANAGED_ERROR);
103
+ return { supabaseSkipped: classified.expectedSkips, failures: classified.failures };
163
104
  }
164
- /**
165
- * post-data 第二遍的最终裁决:分类之外,非零退出却没解析到任何错误块(被信号杀死、
166
- * 归档器致命错误等)绝不能谎报"全部恢复"——记为通用失败。
167
- */
105
+ /** 兼容层:见 restore-engine.ts finalizePass(非零退出且零错误块 = 通用失败)。 */
168
106
  export function postDataResult(code, stderr) {
169
- const result = classifyPostDataErrors(stderr);
170
- if (code !== 0 && result.supabaseSkipped === 0 && result.failures.length === 0) {
171
- result.failures.push(`pg_restore post-data exited with ${code === null ? "a signal" : `code ${code}`}: ` +
172
- (stderr.trim().replace(/\s+/g, " ").slice(0, 200) || "(no stderr)"));
173
- }
174
- return result;
107
+ const classified = finalizePass(code, stderr, classifyBlocks(stderr, SANDBOX_MANAGED_ERROR));
108
+ return { supabaseSkipped: classified.expectedSkips, failures: classified.failures };
175
109
  }
176
110
  /**
177
- * 两遍恢复(真实首演炸出的设计):
178
- * 1. pre-data+data —— 表结构 + 数据。任何错误都是硬失败:数据回不来 = 演练失败。
179
- * 2. post-data —— 用户自己的索引/约束/触发器**必须**恢复成功(它们是"备份完整"的
180
- * 一部分,一刀切跳过会让坏备份漏网);只有引用 Supabase 托管对象的失败
181
- * (policy TO authenticated、FK → auth.users)才归类为沙箱预期,记入报告而非失败。
111
+ * 沙箱恢复 = 统一引擎(target: "sandbox")。pre-data 失败按硬失败抛出——数据回不来
112
+ * = 演练失败,且错误文本要供缺扩展归因(MISSING_EXTENSION_ERROR)使用;post-data
113
+ * 的托管对象跳过如实返回。pre-data 的 schema 冲突跳过(dump 自带 CREATE SCHEMA,
114
+ * 容器恒有 public)是非事件,不计入报告。
182
115
  */
183
116
  async function pgRestore(dumpPath, connString) {
184
- const pgRestoreBin = resolvePgRestoreBin();
185
- const common = ["--no-owner", "--no-privileges", "--dbname", connString, dumpPath];
186
- const first = await spawnPgRestore(pgRestoreBin, [
187
- "--clean",
188
- "--if-exists",
189
- "--section=pre-data",
190
- "--section=data",
191
- ...common,
192
- ]);
193
- // pg_restore 对无害告警会返回非 0;仅在明确 error 时判失败
194
- if (first.code !== 0 && /error:/i.test(first.stderr)) {
195
- throw new Error(`pg_restore failed: ${first.stderr.trim()}`);
117
+ const result = await restoreDatabaseArtifact({ dumpPath, connString, target: "sandbox" });
118
+ if (result.preData.failures.length) {
119
+ throw new Error(`pg_restore failed: ${result.preData.failures.join(" | ")}`);
196
120
  }
197
- const second = await spawnPgRestore(pgRestoreBin, ["--section=post-data", ...common]);
198
- return postDataResult(second.code, second.stderr);
121
+ return {
122
+ supabaseSkipped: result.postData.expectedSkips,
123
+ failures: result.postData.failures,
124
+ };
199
125
  }
200
126
  // ── 校验 ────────────────────────────────────────────────────────────
201
- async function verifyRestored(connString, manifest) {
202
- const client = new Client({ connectionString: connString });
127
+ /** 导出:真实恢复(restore.ts)复用同一套表级验证——两条路径对同一快照必须同一结论。 */
128
+ export async function verifyRestored(connString, manifest) {
129
+ // pgConnectOptions:真实 Supabase 目标(restore 复用本函数)需要打包根 CA;
130
+ // 沙箱的 docker 连接串非 Supabase 主机,原样透传,行为不变
131
+ const client = new Client(pgConnectOptions(connString));
203
132
  await client.connect();
204
133
  try {
205
134
  const schemas = manifest.database.schemas;
@@ -229,7 +158,9 @@ async function verifyRestored(connString, manifest) {
229
158
  unpopulatedMatviews.add(key);
230
159
  continue;
231
160
  }
232
- const c = await client.query(`select count(*)::bigint as n from "${t.schema}"."${t.name}"`);
161
+ // 名字来自恢复出的库(= 来自 dump = 桶里的数据):必须走标识符转义,
162
+ // 内嵌引号的合法名不炸,构造名不注入
163
+ const c = await client.query(`select count(*)::bigint as n from ${quoteIdent(t.schema)}.${quoteIdent(t.name)}`);
233
164
  const n = Number(c.rows[0].n);
234
165
  counts.set(key, n);
235
166
  // 分区父表的 count(*) 是子分区行的总和,子分区又各自计一次;
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { loadConfig } from "./config.js";
4
4
  import { runBackup } from "./backup.js";
5
5
  import { runEstimate } from "./estimate.js";
6
6
  import { runDrill } from "./drill.js";
7
- import { runRestore } from "./restore.js";
7
+ import { runRestore, NO_SOURCE_DATABASE } from "./restore.js";
8
8
  import { log } from "./log.js";
9
9
  import { TOOL_VERSION } from "./version.js";
10
10
  // Supabase egress 定价表(2026-07-10 核实,来源见 README「Egress & cost」)。
@@ -137,28 +137,46 @@ program
137
137
  .option("--project-name <name>", "project name used in the object key")
138
138
  .action(async (opts) => {
139
139
  try {
140
- const config = await loadConfig({
141
- configPath: opts.config,
142
- overrides: {
143
- projectName: opts.projectName,
144
- databaseUrl: "postgresql://unused", // restore 读桶,不连源库
145
- storage: {
146
- bucket: opts.bucket,
147
- endpoint: opts.endpoint,
148
- region: opts.region,
149
- prefix: opts.prefix,
150
- },
140
+ // 源库连接串不再无条件屏蔽:配置/环境里有真实源时,runRestore 的同源阻断门
141
+ // 要拿它比对;纯 flag 驱动(无任何源配置)才退回占位符
142
+ const overrides = {
143
+ projectName: opts.projectName,
144
+ storage: {
145
+ bucket: opts.bucket,
146
+ endpoint: opts.endpoint,
147
+ region: opts.region,
148
+ prefix: opts.prefix,
151
149
  },
152
- });
150
+ };
151
+ let config;
152
+ try {
153
+ config = await loadConfig({ configPath: opts.config, overrides });
154
+ }
155
+ catch (error) {
156
+ if (!/databaseUrl/.test(error.message))
157
+ throw error;
158
+ config = await loadConfig({
159
+ configPath: opts.config,
160
+ overrides: { ...overrides, databaseUrl: NO_SOURCE_DATABASE },
161
+ });
162
+ }
153
163
  const result = await runRestore(config, {
154
164
  targetDatabaseUrl: opts.targetDatabaseUrl,
155
165
  storageDir: opts.storageDir,
156
166
  snapshot: opts.snapshot,
157
167
  });
158
- log.ok(`Restore complete — snapshot ${result.snapshot}: ` +
168
+ const failedChecks = (result.databaseChecks ?? []).filter((c) => !c.pass);
169
+ const summary = `snapshot ${result.snapshot}: ` +
159
170
  `database ${result.restoredToDatabase ? "restored" : "skipped"}, ` +
160
171
  `${result.storageFilesWritten} Storage files written` +
161
- (result.storageDir ? ` to ${result.storageDir}` : ""));
172
+ (result.storageDir ? ` to ${result.storageDir}` : "");
173
+ if (failedChecks.length) {
174
+ log.error(`Restore finished with ${failedChecks.length} FAILED verification check(s) — ${summary}`);
175
+ process.exitCode = 1;
176
+ }
177
+ else {
178
+ log.ok(`Restore complete — ${summary}`);
179
+ }
162
180
  }
163
181
  catch (error) {
164
182
  log.error(error.message);
package/dist/lib.d.ts CHANGED
@@ -6,7 +6,7 @@ export { runEstimate, project } from "./estimate.js";
6
6
  export { loadConfig } from "./config.js";
7
7
  export { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
8
8
  export type { BackupConfig, StorageConfig, SupabaseStorageConfig, } from "./config.js";
9
- export type { ExtensionInfo, Manifest, StorageFile, TableStat } from "./manifest.js";
9
+ export type { BucketAttrs, ExtensionInfo, Manifest, StorageFile, TableStat } from "./manifest.js";
10
10
  export type { DrillReport, DrillCheck } from "./drill.js";
11
11
  export type { RestoreResult } from "./restore.js";
12
12
  export type { EgressPricing, EgressProjection } from "./estimate.js";
@@ -7,6 +7,23 @@ export interface TableStat {
7
7
  schema: string;
8
8
  name: string;
9
9
  estimatedRows: number;
10
+ primaryKeyColumns?: string[];
11
+ exactRows?: number;
12
+ sampleHash?: string;
13
+ sampleAlgorithm?: string;
14
+ freshnessColumn?: string;
15
+ freshnessValue?: string;
16
+ }
17
+ /**
18
+ * 源 bucket 的可恢复属性(v2,读自源库 storage.buckets——S3 兼容端点拿不到这些)。
19
+ * 值为 null = 源端就没设置;字段整体缺失 = 当次备份没捕获到(如 storage schema 无读权限),
20
+ * 恢复端遇缺失须如实标 "not captured",不得猜测(PRD §5.1.3)。
21
+ */
22
+ export interface BucketAttrs {
23
+ name: string;
24
+ public?: boolean | null;
25
+ fileSizeLimit?: number | null;
26
+ allowedMimeTypes?: string[] | null;
10
27
  }
11
28
  /**
12
29
  * 源库安装的非内置扩展(pg_extension,排除必装的 plpgsql)。
@@ -27,8 +44,14 @@ export interface ExtensionInfo {
27
44
  * - 缺失该字段的 manifest = 版本 1(1.0 之前写出的旧快照)
28
45
  * - 读取端遇到**更高**的版本必须明确报错,而不是按旧结构硬解析出错误结果
29
46
  * ——对备份工具来说,"旧程序静默误读新归档"是最不能接受的失败方式。
47
+ *
48
+ * 版本史:
49
+ * - 1:初始格式。
50
+ * - 2(恢复闭环 PRD §5.1):storage 增加 buckets 属性清单(public/大小/MIME 限制),
51
+ * files 增加 contentType/cacheControl/metadata 等恢复所需元数据;tables 预留
52
+ * 验证升级字段(本期不生成)。全部为**可加字段**——v2 读取端兼容 v1 快照。
30
53
  */
31
- export declare const MANIFEST_SCHEMA_VERSION = 1;
54
+ export declare const MANIFEST_SCHEMA_VERSION = 2;
32
55
  export interface Manifest {
33
56
  schemaVersion?: number;
34
57
  tool: "backupdrill-cli";
@@ -51,6 +74,7 @@ export interface Manifest {
51
74
  sha256: string;
52
75
  };
53
76
  storage: null | {
77
+ buckets?: BucketAttrs[];
54
78
  fileCount: number;
55
79
  totalBytes: number;
56
80
  files: StorageFile[];
@@ -61,9 +85,15 @@ export interface StorageFile {
61
85
  key: string;
62
86
  bytes: number;
63
87
  sha256: string;
88
+ contentType?: string;
89
+ cacheControl?: string;
90
+ contentEncoding?: string;
91
+ metadata?: Record<string, unknown>;
92
+ lastModified?: string;
64
93
  }
65
94
  /**
66
95
  * 解析桶里的 manifest.json,并做**向前兼容**校验:比本程序更新的格式一律拒绝,
67
96
  * 明确告诉用户升级,绝不按旧结构硬解析(那会静默产出错误的演练/恢复结果)。
97
+ * 版本关之后再做结构校验(assertManifestShape)——两类失败要给出不同的处置指引。
68
98
  */
69
99
  export declare function parseManifest(json: string): Manifest;
package/dist/manifest.js CHANGED
@@ -6,11 +6,147 @@
6
6
  * - 缺失该字段的 manifest = 版本 1(1.0 之前写出的旧快照)
7
7
  * - 读取端遇到**更高**的版本必须明确报错,而不是按旧结构硬解析出错误结果
8
8
  * ——对备份工具来说,"旧程序静默误读新归档"是最不能接受的失败方式。
9
+ *
10
+ * 版本史:
11
+ * - 1:初始格式。
12
+ * - 2(恢复闭环 PRD §5.1):storage 增加 buckets 属性清单(public/大小/MIME 限制),
13
+ * files 增加 contentType/cacheControl/metadata 等恢复所需元数据;tables 预留
14
+ * 验证升级字段(本期不生成)。全部为**可加字段**——v2 读取端兼容 v1 快照。
15
+ */
16
+ export const MANIFEST_SCHEMA_VERSION = 2;
17
+ /** 结构断言失败时的统一出错口径:说清哪个字段坏了,让"桶里的 manifest 被篡改/截断"可定位。 */
18
+ function malformed(what) {
19
+ throw new Error(`manifest.json is malformed: ${what}`);
20
+ }
21
+ /**
22
+ * 演练/恢复实际依赖的字段做结构校验(手写而非 JSON Schema 依赖:CLI 保持零校验库依赖,
23
+ * 校验面 = 恢复代码真正会解引用的路径,新增可选字段不在此列——它们缺失是合法的 v1 形态)。
24
+ * 半截上传、被篡改或非本工具写的 JSON 要在这里被拦下,而不是在 pg_restore 中途炸开。
9
25
  */
10
- export const MANIFEST_SCHEMA_VERSION = 1;
26
+ /** 可选字段:缺席合法(v1 形态),在场就必须是期望类型——错型值会驱动恢复端做错事。 */
27
+ function optionalString(value, what) {
28
+ if (value !== undefined && typeof value !== "string")
29
+ malformed(what);
30
+ }
31
+ function assertManifestShape(m) {
32
+ const d = m.dump;
33
+ if (typeof d !== "object" || d === null)
34
+ malformed("dump section missing");
35
+ const dump = m.dump;
36
+ if (typeof dump.key !== "string" || !dump.key)
37
+ malformed("dump.key");
38
+ if (typeof dump.sha256 !== "string" || !dump.sha256)
39
+ malformed("dump.sha256");
40
+ if (typeof dump.bytes !== "number")
41
+ malformed("dump.bytes");
42
+ if (typeof m.database !== "object" || m.database === null)
43
+ malformed("database section missing");
44
+ // serverVersion 决定沙箱镜像 tag(parseInt)——不可解析的值会在起容器后才炸
45
+ if (typeof m.database.serverVersion !== "string" || !(parseInt(m.database.serverVersion, 10) > 0)) {
46
+ malformed("database.serverVersion");
47
+ }
48
+ if (!Array.isArray(m.database.schemas) || m.database.schemas.some((s) => typeof s !== "string" || !s)) {
49
+ malformed("database.schemas");
50
+ }
51
+ if (m.database.extensions !== undefined) {
52
+ if (!Array.isArray(m.database.extensions))
53
+ malformed("database.extensions");
54
+ for (const e of m.database.extensions) {
55
+ // 这些值会进 CREATE EXTENSION/SCHEMA 的标识符(quoteIdent 兜住注入,这里兜类型)
56
+ if (typeof e?.name !== "string" || !e.name || typeof e?.version !== "string" || typeof e?.schema !== "string" || !e.schema) {
57
+ malformed("database.extensions[]");
58
+ }
59
+ }
60
+ }
61
+ if (typeof m.database.tableCount !== "number")
62
+ malformed("database.tableCount");
63
+ if (!Array.isArray(m.database.tables))
64
+ malformed("database.tables");
65
+ // 写入端恒有 tableCount === tables.length;不相等 = tables 数组被截断/篡改,
66
+ // 逐表检查会静默缩水而表数检查还显得正常
67
+ if (m.database.tableCount !== m.database.tables.length) {
68
+ malformed("database.tableCount does not match tables.length");
69
+ }
70
+ for (const t of m.database.tables) {
71
+ if (typeof t?.schema !== "string" || typeof t?.name !== "string")
72
+ malformed("database.tables[]");
73
+ // estimatedRows 缺失/错型会让演练的"备份时非空的表恢复后不为空"检查静默失效
74
+ // (undefined > 0 恒 false)——空表假 PASS 是最不能接受的失败方式
75
+ if (typeof t.estimatedRows !== "number" || !Number.isFinite(t.estimatedRows) || t.estimatedRows < 0) {
76
+ malformed("database.tables[].estimatedRows");
77
+ }
78
+ }
79
+ // 写入端恒写 storage: null(DB-only)或对象——字段整体缺失只可能是截断/损坏,
80
+ // 静默当成 DB-only 会跳过全部 Storage 验证与恢复
81
+ if (m.storage === undefined)
82
+ malformed("storage section missing (null means DB-only)");
83
+ if (m.storage !== null) {
84
+ if (typeof m.storage !== "object")
85
+ malformed("storage section");
86
+ if (typeof m.storage.fileCount !== "number" || typeof m.storage.totalBytes !== "number") {
87
+ malformed("storage.fileCount/totalBytes");
88
+ }
89
+ if (!Array.isArray(m.storage.files))
90
+ malformed("storage.files");
91
+ // 聚合数与清单对账:files 被截断而 fileCount 完好时,Storage 验证会静默缩水
92
+ if (m.storage.fileCount !== m.storage.files.length) {
93
+ malformed("storage.fileCount does not match files.length");
94
+ }
95
+ const byteSum = m.storage.files.reduce((n, f) => n + (typeof f?.bytes === "number" ? f.bytes : 0), 0);
96
+ if (m.storage.totalBytes !== byteSum)
97
+ malformed("storage.totalBytes does not match files");
98
+ for (const f of m.storage.files) {
99
+ if (typeof f?.bucket !== "string" || typeof f?.key !== "string")
100
+ malformed("storage.files[]");
101
+ if (typeof f.bytes !== "number" || typeof f.sha256 !== "string")
102
+ malformed("storage.files[]");
103
+ // 路径穿越防线:bucket/key 会被拼进本地写路径(restore)与目标上传路径。
104
+ // manifest 是"从桶里来的数据"——被篡改时不得升级成任意文件写。
105
+ // 不破坏向后兼容(2026-07-24 实测):Supabase 在写入时就把 //、/./、/../ 和
106
+ // 前导 / 规范化掉、拒绝反斜杠 —— storage.objects.name(备份列举到的形态)
107
+ // 永远不含这些段,合法快照不可能被此处拒绝。
108
+ if (!f.bucket || f.bucket.includes("/") || f.bucket.includes("\\") || f.bucket.includes("\0") || f.bucket === "." || f.bucket === "..") {
109
+ malformed("storage.files[].bucket (unsafe path segment)");
110
+ }
111
+ if (!f.key ||
112
+ f.key.includes("\\") ||
113
+ f.key.includes("\0") ||
114
+ f.key.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) {
115
+ malformed("storage.files[].key (unsafe path segment)");
116
+ }
117
+ optionalString(f.contentType, "storage.files[].contentType");
118
+ optionalString(f.cacheControl, "storage.files[].cacheControl");
119
+ optionalString(f.contentEncoding, "storage.files[].contentEncoding");
120
+ optionalString(f.lastModified, "storage.files[].lastModified");
121
+ if (f.metadata !== undefined && (typeof f.metadata !== "object" || f.metadata === null || Array.isArray(f.metadata))) {
122
+ malformed("storage.files[].metadata");
123
+ }
124
+ }
125
+ if (m.storage.buckets !== undefined) {
126
+ if (!Array.isArray(m.storage.buckets))
127
+ malformed("storage.buckets");
128
+ for (const b of m.storage.buckets) {
129
+ if (typeof b?.name !== "string" || !b.name)
130
+ malformed("storage.buckets[]");
131
+ if (b.public !== null && b.public !== undefined && typeof b.public !== "boolean") {
132
+ malformed("storage.buckets[].public");
133
+ }
134
+ if (b.fileSizeLimit !== null && b.fileSizeLimit !== undefined && typeof b.fileSizeLimit !== "number") {
135
+ malformed("storage.buckets[].fileSizeLimit");
136
+ }
137
+ if (b.allowedMimeTypes !== null &&
138
+ b.allowedMimeTypes !== undefined &&
139
+ !(Array.isArray(b.allowedMimeTypes) && b.allowedMimeTypes.every((v) => typeof v === "string"))) {
140
+ malformed("storage.buckets[].allowedMimeTypes");
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
11
146
  /**
12
147
  * 解析桶里的 manifest.json,并做**向前兼容**校验:比本程序更新的格式一律拒绝,
13
148
  * 明确告诉用户升级,绝不按旧结构硬解析(那会静默产出错误的演练/恢复结果)。
149
+ * 版本关之后再做结构校验(assertManifestShape)——两类失败要给出不同的处置指引。
14
150
  */
15
151
  export function parseManifest(json) {
16
152
  let parsed;
@@ -20,11 +156,21 @@ export function parseManifest(json) {
20
156
  catch (error) {
21
157
  throw new Error(`manifest.json is not valid JSON: ${error.message}`);
22
158
  }
23
- const version = parsed.schemaVersion ?? 1; // 缺失 = 1.0 之前的旧快照
24
- if (!Number.isFinite(version) || version > MANIFEST_SCHEMA_VERSION) {
159
+ // 根检查必须先于一切属性访问:JSON 里合法的 null/数字/字符串会把裸 TypeError 抛给用户
160
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
161
+ malformed("root is not an object");
162
+ }
163
+ // 版本字段:**属性缺席**才算 1.0 之前的旧快照;显式 null/0/负数/小数/字符串
164
+ // 都不是本工具写过的值,按坏 manifest 拒绝,不得混过前向兼容闸门
165
+ const version = "schemaVersion" in parsed ? parsed.schemaVersion : 1;
166
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
167
+ malformed(`schemaVersion ${JSON.stringify(parsed.schemaVersion)}`);
168
+ }
169
+ if (version > MANIFEST_SCHEMA_VERSION) {
25
170
  throw new Error(`This snapshot's manifest uses schema version ${String(parsed.schemaVersion)}, but this ` +
26
171
  `BackupDrill understands up to ${MANIFEST_SCHEMA_VERSION}. Upgrade the CLI ` +
27
172
  `(npm i -g backupdrill@latest) to read it.`);
28
173
  }
174
+ assertManifestShape(parsed);
29
175
  return parsed;
30
176
  }
@@ -0,0 +1,63 @@
1
+ import type { ExtensionInfo } from "./manifest.js";
2
+ export type RestoreTargetKind = "sandbox" | "supabase";
3
+ export interface ClassifiedPass {
4
+ /** allowlist 命中数(环境预期/托管冲突)——如实入报告,不算失败 */
5
+ expectedSkips: number;
6
+ /** 真失败(压缩到 200 字符的错误块) */
7
+ failures: string[];
8
+ }
9
+ export interface EngineResult {
10
+ preData: ClassifiedPass;
11
+ postData: ClassifiedPass;
12
+ ok: boolean;
13
+ }
14
+ export declare function spawnPgRestore(bin: string, args: string[], extraEnv?: NodeJS.ProcessEnv): Promise<{
15
+ code: number | null;
16
+ stderr: string;
17
+ }>;
18
+ export declare const SANDBOX_MANAGED_ERROR: RegExp;
19
+ export declare const SCHEMA_EXISTS_ERROR: RegExp;
20
+ /**
21
+ * 把 pg_restore 的 stderr 拆成单个错误块并按 allowlist 分类。
22
+ * 只对 ERROR 原因行分类,不看 "Command was:" 之后的语句文本——否则一个恰好
23
+ * 引用 auth.uid 的用户对象因"损坏/语法错误"失败时,会被误判成预期跳过。
24
+ */
25
+ export declare function classifyBlocks(stderr: string, allow: RegExp): ClassifiedPass;
26
+ /**
27
+ * 一遍恢复的最终裁决,两条独立规则:
28
+ * 1. 被信号杀死(code=null)**无条件失败**——即使 stderr 里已有被 allowlist 解释的
29
+ * 错误块。真实目标恒有一个预期 schema 跳过,若跳过能抵扣信号死亡,中途被 OOM/kill
30
+ * 的半截恢复就会被报成功(评审第 7 轮抓出的假成功路径)。
31
+ * 2. 非零退出且零错误块(归档器致命错、非英文 locale…)同样不能谎报成功。
32
+ * 非零退出但每个错误都已分类 = pg_restore 对被忽略错误的正常退出形态,通过。
33
+ */
34
+ export declare function finalizePass(code: number | null, stderr: string, classified: ClassifiedPass): ClassifiedPass;
35
+ /** 标识符转义(内嵌引号翻倍)。导出:任何把目录里读到的名字拼进 SQL 的地方都必须用它。 */
36
+ export declare function quoteIdent(name: string): string;
37
+ /**
38
+ * 恢复前把 manifest 记录的扩展装进目标(--schema 转储不含 CREATE EXTENSION)。
39
+ * 必须装进与源库相同的 schema:dump 里的类型是限定名(如 extensions.vector)。
40
+ * 装不上的扩展如实收集返回,由调用方按目标类型裁决:沙箱记录后继续(托管扩展
41
+ * 本就装不上,public 转储通常不引用),真实目标应当阻断(用户能在 dashboard 开启)。
42
+ */
43
+ export declare function installExtensions(connString: string, extensions: ExtensionInfo[]): Promise<string[]>;
44
+ /**
45
+ * 两遍恢复(真实首演炸出的设计,现为 drill 与真实恢复共用):
46
+ * 1. pre-data+data —— 表结构 + 数据。除 schema 冲突(预期)外任何错误都是硬失败。
47
+ * 2. post-data —— 用户自己的索引/约束/触发器必须恢复成功;allowlist 按目标类型:
48
+ * 沙箱豁免 Supabase 托管对象引用,真实 Supabase 目标零豁免。
49
+ */
50
+ /**
51
+ * 凭据不进 argv(D6 同源要求):连接串里的密码对 `ps` 全程可见——拆出来经
52
+ * PGPASSWORD 环境变量传给 libpq,argv 里的 URL 不再携带。解析不了的连接串
53
+ * 原样透传(libpq 自己适配的形态,不在这里破坏)。
54
+ */
55
+ export declare function credentialSafeDbArgs(connString: string): {
56
+ url: string;
57
+ env: NodeJS.ProcessEnv;
58
+ };
59
+ export declare function restoreDatabaseArtifact(opts: {
60
+ dumpPath: string;
61
+ connString: string;
62
+ target: RestoreTargetKind;
63
+ }): Promise<EngineResult>;
@@ -0,0 +1,151 @@
1
+ // 统一数据库恢复引擎(恢复闭环 PRD §5.2):drill 沙箱与真实 Supabase 目标共用同一条
2
+ // pg_restore 路径与错误分类,同一快照不得在演练与真实恢复中得出不同结论。
3
+ //
4
+ // R0 spike 的三条实证直接塑造了这里的形状:
5
+ // 1. 裸退出码在真实目标上永远不干净 → 成功判定 = 零未分类错误(版本化 allowlist);
6
+ // 2. 不用 --clean:与"只允许空目标"(D5)冲突、制造伪错,且在真实 Supabase 上
7
+ // DROP/CREATE public 会抹掉 anon/authenticated 的默认授权(--no-privileges 转储
8
+ // 不会带回来),恢复出的 API 直接 401;
9
+ // 3. dump 自带 CREATE SCHEMA,而目标(新容器或 Supabase)恒有同名 schema →
10
+ // pre-data 阶段的 schema "already exists" 是两类目标共同的预期冲突。
11
+ import { spawn } from "node:child_process";
12
+ import { Client } from "pg";
13
+ import { resolvePgRestoreBin } from "./pgbin.js";
14
+ import { pgConnectOptions } from "./supabase-ca.js";
15
+ export function spawnPgRestore(bin, args, extraEnv = {}) {
16
+ return new Promise((resolve, reject) => {
17
+ const proc = spawn(bin, args, {
18
+ stdio: ["ignore", "ignore", "pipe"],
19
+ env: { ...process.env, ...extraEnv },
20
+ });
21
+ let stderr = "";
22
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
23
+ // 不透传原始 spawn Error:node 会把 spawnargs(含 --dbname 连接串=凭据)挂在
24
+ // 异常对象上,顺着 reject 流进日志/Sentry。只保留启动失败的事实与 bin 名。
25
+ proc.on("error", (error) => reject(new Error(`${bin} failed to start: ${error.code ?? error.message}`)));
26
+ proc.on("close", (code) => resolve({ code, stderr }));
27
+ });
28
+ }
29
+ // 演练沙箱是裸 Postgres,Supabase 托管的 schema/角色必然缺席。post-data 里
30
+ // 引用它们的失败是"环境预期",不是备份坏了;其余失败才是演练要抓的。
31
+ // 真实 Supabase 目标不适用:角色/托管 schema 恒在,post-data 全严格。
32
+ export const SANDBOX_MANAGED_ERROR = /schema "(auth|storage|realtime|vault|extensions|graphql[a-z_]*)" does not exist|role "(authenticated|anon|service_role|supabase_[a-z_]+)" does not exist|\bauth\.uid\b|\bauth\.jwt\b/i;
33
+ // pre-data 唯一的预期冲突(见文件头注 3)。目标空门/新容器保证没有其他冲突源,
34
+ // 任何别的 "already exists" 都是真冲突,必须失败。
35
+ export const SCHEMA_EXISTS_ERROR = /schema "[^"]+" already exists/i;
36
+ const NEVER_MATCH = /(?!)/;
37
+ /**
38
+ * 把 pg_restore 的 stderr 拆成单个错误块并按 allowlist 分类。
39
+ * 只对 ERROR 原因行分类,不看 "Command was:" 之后的语句文本——否则一个恰好
40
+ * 引用 auth.uid 的用户对象因"损坏/语法错误"失败时,会被误判成预期跳过。
41
+ */
42
+ export function classifyBlocks(stderr, allow) {
43
+ const blocks = stderr
44
+ .split(/(?=pg_restore: error:)/)
45
+ .filter((b) => /pg_restore: error:/.test(b));
46
+ let expectedSkips = 0;
47
+ const failures = [];
48
+ for (const block of blocks) {
49
+ const cause = block.split(/Command was:/i)[0];
50
+ if (allow.test(cause)) {
51
+ expectedSkips += 1;
52
+ }
53
+ else {
54
+ failures.push(block.replace(/\s+/g, " ").trim().slice(0, 200));
55
+ }
56
+ }
57
+ return { expectedSkips, failures };
58
+ }
59
+ /**
60
+ * 一遍恢复的最终裁决,两条独立规则:
61
+ * 1. 被信号杀死(code=null)**无条件失败**——即使 stderr 里已有被 allowlist 解释的
62
+ * 错误块。真实目标恒有一个预期 schema 跳过,若跳过能抵扣信号死亡,中途被 OOM/kill
63
+ * 的半截恢复就会被报成功(评审第 7 轮抓出的假成功路径)。
64
+ * 2. 非零退出且零错误块(归档器致命错、非英文 locale…)同样不能谎报成功。
65
+ * 非零退出但每个错误都已分类 = pg_restore 对被忽略错误的正常退出形态,通过。
66
+ */
67
+ export function finalizePass(code, stderr, classified) {
68
+ const detail = stderr.trim().replace(/\s+/g, " ").slice(0, 200) || "(no stderr)";
69
+ if (code === null) {
70
+ return {
71
+ ...classified,
72
+ failures: [...classified.failures, `pg_restore was killed by a signal: ${detail}`],
73
+ };
74
+ }
75
+ if (code !== 0 && classified.expectedSkips === 0 && classified.failures.length === 0) {
76
+ return { ...classified, failures: [`pg_restore exited with code ${code}: ${detail}`] };
77
+ }
78
+ return classified;
79
+ }
80
+ /** 标识符转义(内嵌引号翻倍)。导出:任何把目录里读到的名字拼进 SQL 的地方都必须用它。 */
81
+ export function quoteIdent(name) {
82
+ return `"${name.replace(/"/g, '""')}"`;
83
+ }
84
+ /**
85
+ * 恢复前把 manifest 记录的扩展装进目标(--schema 转储不含 CREATE EXTENSION)。
86
+ * 必须装进与源库相同的 schema:dump 里的类型是限定名(如 extensions.vector)。
87
+ * 装不上的扩展如实收集返回,由调用方按目标类型裁决:沙箱记录后继续(托管扩展
88
+ * 本就装不上,public 转储通常不引用),真实目标应当阻断(用户能在 dashboard 开启)。
89
+ */
90
+ export async function installExtensions(connString, extensions) {
91
+ if (!extensions.length)
92
+ return [];
93
+ // pgConnectOptions:Supabase 主机自动带打包根 CA(node-pg 把 require 按 verify-full
94
+ // 处理,裸 Client 对 pooler 必报 self-signed chain);沙箱等非 Supabase 目标原样透传
95
+ const client = new Client(pgConnectOptions(connString));
96
+ await client.connect();
97
+ const unavailable = [];
98
+ try {
99
+ for (const ext of extensions) {
100
+ try {
101
+ await client.query(`create schema if not exists ${quoteIdent(ext.schema)}`);
102
+ // CASCADE 自动带上依赖扩展,免受 manifest 里的安装顺序摆布
103
+ await client.query(`create extension if not exists ${quoteIdent(ext.name)} schema ${quoteIdent(ext.schema)} cascade`);
104
+ }
105
+ catch {
106
+ unavailable.push(ext.name);
107
+ }
108
+ }
109
+ }
110
+ finally {
111
+ await client.end();
112
+ }
113
+ return unavailable;
114
+ }
115
+ /**
116
+ * 两遍恢复(真实首演炸出的设计,现为 drill 与真实恢复共用):
117
+ * 1. pre-data+data —— 表结构 + 数据。除 schema 冲突(预期)外任何错误都是硬失败。
118
+ * 2. post-data —— 用户自己的索引/约束/触发器必须恢复成功;allowlist 按目标类型:
119
+ * 沙箱豁免 Supabase 托管对象引用,真实 Supabase 目标零豁免。
120
+ */
121
+ /**
122
+ * 凭据不进 argv(D6 同源要求):连接串里的密码对 `ps` 全程可见——拆出来经
123
+ * PGPASSWORD 环境变量传给 libpq,argv 里的 URL 不再携带。解析不了的连接串
124
+ * 原样透传(libpq 自己适配的形态,不在这里破坏)。
125
+ */
126
+ export function credentialSafeDbArgs(connString) {
127
+ try {
128
+ const url = new URL(connString);
129
+ if (!url.password)
130
+ return { url: connString, env: {} };
131
+ const password = decodeURIComponent(url.password);
132
+ url.password = "";
133
+ return { url: url.toString(), env: { PGPASSWORD: password } };
134
+ }
135
+ catch {
136
+ return { url: connString, env: {} };
137
+ }
138
+ }
139
+ export async function restoreDatabaseArtifact(opts) {
140
+ const bin = resolvePgRestoreBin();
141
+ const { url, env } = credentialSafeDbArgs(opts.connString);
142
+ const common = ["--no-owner", "--no-privileges", "--dbname", url, opts.dumpPath];
143
+ const first = await spawnPgRestore(bin, ["--section=pre-data", "--section=data", ...common], env);
144
+ const preData = finalizePass(first.code, first.stderr, classifyBlocks(first.stderr, SCHEMA_EXISTS_ERROR));
145
+ if (preData.failures.length) {
146
+ return { preData, postData: { expectedSkips: 0, failures: [] }, ok: false };
147
+ }
148
+ const second = await spawnPgRestore(bin, ["--section=post-data", ...common], env);
149
+ const postData = finalizePass(second.code, second.stderr, classifyBlocks(second.stderr, opts.target === "sandbox" ? SANDBOX_MANAGED_ERROR : NEVER_MATCH));
150
+ return { preData, postData, ok: postData.failures.length === 0 };
151
+ }
package/dist/restore.d.ts CHANGED
@@ -1,13 +1,40 @@
1
1
  import type { BackupConfig } from "./config.js";
2
+ import { type DrillCheck } from "./drill.js";
2
3
  export interface RestoreResult {
3
4
  snapshot: string;
4
5
  restoredToDatabase: boolean;
6
+ /** 表级验证结果(与演练同一套 verifyRestored);未恢复数据库时不存在 */
7
+ databaseChecks?: DrillCheck[];
8
+ /**
9
+ * 验证总裁决(= databaseChecks 全 pass)。库级调用方(worker)必须看它,
10
+ * 不能只看 restoredToDatabase——"恢复完成"与"恢复通过验证"是两个事实。
11
+ */
12
+ databaseVerified?: boolean;
5
13
  storageFilesWritten: number;
6
14
  storageDir?: string;
7
15
  }
8
16
  /**
9
- * 把一份快照恢复出来:数据库还原进 --target-database-url;Storage 文件下载到本地目录
10
- * (拿回文件后你可以按需回传到新 Supabase 项目)。这是 US-5 恢复向导的 DIY 版本。
17
+ * restore 无源库配置时的占位连接串(纯 flag 驱动、没有 backupdrill.config.json 的场景)。
18
+ * 同源阻断门对它不生效——没有可比对的源;有真实源配置时阻断门照常工作。
19
+ */
20
+ export declare const NO_SOURCE_DATABASE = "postgresql://unused";
21
+ /**
22
+ * 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db.<ref>.supabase.co` 或
23
+ * pooler 用户名 `postgres.<ref>`。两种形态指向同一租户——只比 host/user 会漏掉
24
+ * "源填直连、目标填 pooler"的同项目组合(评审第 7 轮)。非 Supabase 形态返回 null。
25
+ */
26
+ export declare function projectRefOf(connString: string): string | null;
27
+ /**
28
+ * 两个连接串是否指向同一数据库租户(纯函数,可测)。
29
+ * Supabase 双方以项目 ref 为准(直连/pooler 形态互认);其余退回 host+user 比对
30
+ * ——只比 host 不够:pooler 主机是区域级共享的,租户身份在用户名里。
31
+ * 任一解析失败 → false(pg 侧会给出自己的连接错误,这里不误伤)。
32
+ */
33
+ export declare function sameDatabaseTarget(a: string, b: string): boolean;
34
+ /**
35
+ * 把一份快照恢复出来:数据库经统一引擎还原进 --target-database-url(与演练同一条
36
+ * pg_restore 路径与错误分类),恢复后跑同一套表级验证;Storage 文件下载到本地目录
37
+ * (回传到新 Supabase 项目的自动化是 P0-D,未到)。这是 US-5 恢复向导的 CLI 形态。
11
38
  */
12
39
  export declare function runRestore(config: BackupConfig, opts: {
13
40
  targetDatabaseUrl?: string;
package/dist/restore.js CHANGED
@@ -1,29 +1,97 @@
1
- import { spawn } from "node:child_process";
2
- import { mkdtemp, mkdir, rm } from "node:fs/promises";
1
+ import { mkdtemp, mkdir, rm, readdir } 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 { resolvePgRestoreBin } from "./pgbin.js";
10
+ import { restoreDatabaseArtifact, installExtensions } from "./restore-engine.js";
11
+ import { verifyRestored } from "./drill.js";
12
+ import { pgConnectOptions } from "./supabase-ca.js";
11
13
  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
- });
14
+ /**
15
+ * restore 无源库配置时的占位连接串(纯 flag 驱动、没有 backupdrill.config.json 的场景)。
16
+ * 同源阻断门对它不生效——没有可比对的源;有真实源配置时阻断门照常工作。
17
+ */
18
+ export const NO_SOURCE_DATABASE = "postgresql://unused";
19
+ /**
20
+ * 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db.<ref>.supabase.co` 或
21
+ * pooler 用户名 `postgres.<ref>`。两种形态指向同一租户——只比 host/user 会漏掉
22
+ * "源填直连、目标填 pooler"的同项目组合(评审第 7 轮)。非 Supabase 形态返回 null。
23
+ */
24
+ export function projectRefOf(connString) {
25
+ try {
26
+ const url = new URL(connString);
27
+ const direct = url.hostname.match(/^db\.([a-z0-9]{16,})\.supabase\.co$/);
28
+ if (direct)
29
+ return direct[1];
30
+ const pooled = url.username.match(/^postgres\.([a-z0-9]{16,})$/);
31
+ if (pooled)
32
+ return pooled[1];
33
+ return null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
23
38
  }
24
39
  /**
25
- * 把一份快照恢复出来:数据库还原进 --target-database-url;Storage 文件下载到本地目录
26
- * (拿回文件后你可以按需回传到新 Supabase 项目)。这是 US-5 恢复向导的 DIY 版本。
40
+ * 两个连接串是否指向同一数据库租户(纯函数,可测)。
41
+ * Supabase 双方以项目 ref 为准(直连/pooler 形态互认);其余退回 host+user 比对
42
+ * ——只比 host 不够:pooler 主机是区域级共享的,租户身份在用户名里。
43
+ * 任一解析失败 → false(pg 侧会给出自己的连接错误,这里不误伤)。
44
+ */
45
+ export function sameDatabaseTarget(a, b) {
46
+ const refA = projectRefOf(a);
47
+ const refB = projectRefOf(b);
48
+ if (refA !== null && refB !== null)
49
+ return refA === refB;
50
+ try {
51
+ const ua = new URL(a);
52
+ const ub = new URL(b);
53
+ if (!ua.hostname || !ub.hostname)
54
+ return false;
55
+ return ua.hostname === ub.hostname && ua.username === ub.username;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ /**
62
+ * 空目标门(PRD §5.3.3 / 决策 D5):目标 schema 里存在任何用户对象即拒绝写入。
63
+ * "空"的定义来自 R0 spike:不是"schema 不存在"(Supabase 恒有 public),而是
64
+ * schema 内零用户对象——关系、函数/过程、独立类型(enum/domain/range/composite)
65
+ * 全部计入:只查关系时,"目标里只有函数"会放行,然后在 pre-data 中途撞冲突,
66
+ * 留下半截目标(评审第 7 轮)。
67
+ */
68
+ async function assertEmptyTarget(targetUrl, schemas) {
69
+ const client = new Client(pgConnectOptions(targetUrl));
70
+ await client.connect();
71
+ try {
72
+ const res = await client.query(`select
73
+ (select count(*) from pg_class c join pg_namespace n on n.oid = c.relnamespace
74
+ where n.nspname = any($1::text[]) and c.relkind in ('r','p','m','v','S','f','c'))
75
+ + (select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace
76
+ where n.nspname = any($1::text[]))
77
+ + (select count(*) from pg_type t join pg_namespace n on n.oid = t.typnamespace
78
+ where n.nspname = any($1::text[]) and t.typtype in ('e','d','r'))
79
+ as n`, [schemas]);
80
+ const objectCount = Number(res.rows[0].n);
81
+ if (objectCount > 0) {
82
+ throw new Error(`target schema(s) ${schemas.join(", ")} contain ${objectCount} existing object(s) — ` +
83
+ `restore only writes into an empty target. Create a fresh Supabase project ` +
84
+ `(or empty these schemas) and retry.`);
85
+ }
86
+ }
87
+ finally {
88
+ await client.end();
89
+ }
90
+ }
91
+ /**
92
+ * 把一份快照恢复出来:数据库经统一引擎还原进 --target-database-url(与演练同一条
93
+ * pg_restore 路径与错误分类),恢复后跑同一套表级验证;Storage 文件下载到本地目录
94
+ * (回传到新 Supabase 项目的自动化是 P0-D,未到)。这是 US-5 恢复向导的 CLI 形态。
27
95
  */
28
96
  export async function runRestore(config, opts) {
29
97
  const s3 = targetClient(config);
@@ -38,15 +106,56 @@ export async function runRestore(config, opts) {
38
106
  };
39
107
  const workdir = await mkdtemp(join(tmpdir(), "backupdrill-restore-"));
40
108
  try {
41
- // 1. 数据库
109
+ // 1. 数据库(写入前依次过安全门:同源阻断 → 空目标 → 归档校验 → 扩展预装)
42
110
  if (opts.targetDatabaseUrl) {
111
+ const targetUrl = opts.targetDatabaseUrl;
112
+ // 同源阻断(PRD §9.3):恢复绝不写回备份的源库。误把生产源当目标是
113
+ // 恢复流程里代价最不对称的失误。
114
+ if (config.databaseUrl !== NO_SOURCE_DATABASE &&
115
+ sameDatabaseTarget(config.databaseUrl, targetUrl)) {
116
+ throw new Error("target database resolves to the same host and user as the backup source — " +
117
+ "restoring onto the source project is blocked. Point --target-database-url " +
118
+ "at a fresh Supabase project.");
119
+ }
120
+ log.step("Checking target is empty…");
121
+ await assertEmptyTarget(targetUrl, manifest.database.schemas);
43
122
  log.step("Downloading dump…");
44
123
  const dumpPath = join(workdir, "dump.pgcustom");
45
- await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
124
+ const { sha256 } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
125
+ // 归档完整性是恢复的前提:哈希不符还继续,只会把"备份坏了"变成一堆误导性
126
+ // 恢复错误(演练侧同一裁决:integrity FAIL 即短路)
127
+ if (sha256 !== manifest.dump.sha256) {
128
+ throw new Error(`archive integrity check failed: downloaded sha256 ${sha256.slice(0, 12)}… ` +
129
+ `does not match manifest ${manifest.dump.sha256.slice(0, 12)}… — refusing to restore a corrupted dump`);
130
+ }
131
+ // 扩展预装:沙箱装不上只能记录(环境局限),真实目标装不上必须阻断——
132
+ // 用户在 dashboard 一键可开,恢复残缺副本则要人工收拾(引擎头注的裁决分工)
133
+ const extensions = manifest.database.extensions ?? [];
134
+ if (extensions.length) {
135
+ log.step(`Installing ${extensions.length} extension(s) on target…`);
136
+ const unavailable = await installExtensions(targetUrl, extensions);
137
+ if (unavailable.length) {
138
+ throw new Error(`target cannot install extension(s): ${unavailable.join(", ")} — enable them in ` +
139
+ `the Supabase dashboard (Database → Extensions) and retry.`);
140
+ }
141
+ }
46
142
  log.step("Restoring database into target…");
47
- await pgRestore(dumpPath, opts.targetDatabaseUrl);
143
+ const engine = await restoreDatabaseArtifact({ dumpPath, connString: targetUrl, target: "supabase" });
144
+ if (!engine.ok) {
145
+ const failures = [...engine.preData.failures, ...engine.postData.failures];
146
+ throw new Error(`database restore failed: ${failures.join(" | ")}`);
147
+ }
48
148
  result.restoredToDatabase = true;
49
- log.ok(`Database restored (${manifest.database.tableCount} tables)`);
149
+ // 与演练同一套表级验证:两条路径对同一快照必须同一结论(PRD §5.2 验收)
150
+ log.step("Verifying restored tables…");
151
+ const verified = await verifyRestored(targetUrl, manifest);
152
+ result.databaseChecks = verified.checks;
153
+ result.databaseVerified = verified.checks.every((c) => c.pass);
154
+ for (const check of verified.checks) {
155
+ (check.pass ? log.ok : log.warn)(`${check.name}: ${check.detail}`);
156
+ }
157
+ log.ok(`Database restored (${verified.tableCount} tables, ` +
158
+ `${verified.rowTotal.toLocaleString()} rows)`);
50
159
  }
51
160
  else {
52
161
  log.warn("No --target-database-url given; skipping database restore.");
@@ -54,9 +163,20 @@ export async function runRestore(config, opts) {
54
163
  // 2. Storage 文件 → 本地目录
55
164
  if (manifest.storage && manifest.storage.files.length) {
56
165
  const outDir = opts.storageDir ?? join(process.cwd(), `restored-storage-${snapshot}`);
166
+ // 输出目录必须是新的/空的:路径锚定检查是词法层面的,防不住目录里**已有**的
167
+ // 符号链接把写入引到别处;保证目录从空开始 = 所有子路径都由本进程创建,无链接可循
168
+ await mkdir(outDir, { recursive: true });
169
+ if ((await readdir(outDir)).length > 0) {
170
+ throw new Error(`storage output directory is not empty: ${outDir} — restore only writes into a fresh directory`);
171
+ }
57
172
  log.step(`Downloading ${manifest.storage.files.length} Storage files → ${outDir}`);
58
173
  for (const f of manifest.storage.files) {
59
174
  const dest = join(outDir, f.bucket, f.key);
175
+ // 纵深防御:parseManifest 已拒绝穿越段,这里仍锚定最终路径必须落在 outDir 内
176
+ // ——两道防线独立失效才会发生"篡改的 manifest 写穿输出目录"
177
+ if (!resolve(dest).startsWith(resolve(outDir) + sep)) {
178
+ throw new Error(`unsafe storage path escapes output directory: ${f.bucket}/${f.key}`);
179
+ }
60
180
  await mkdir(dirname(dest), { recursive: true });
61
181
  const res = await s3.send(new GetObjectCommand({
62
182
  Bucket: config.storage.bucket,
@@ -0,0 +1,19 @@
1
+ import type { BucketAttrs } from "./manifest.js";
2
+ interface BucketRow {
3
+ id: string;
4
+ name: string;
5
+ public: boolean | null;
6
+ file_size_limit: string | number | null;
7
+ allowed_mime_types: string[] | null;
8
+ }
9
+ /**
10
+ * 行 → manifest 形状的纯映射,含完整性关口:查询结果必须覆盖每一个请求的 bucket。
11
+ * storage.buckets 上的 RLS 或并发删除会**静默**少返回行——把不完整清单写进 manifest,
12
+ * 恢复端就会漏建 bucket 还以为自己全知。缺任何一个 → 抛错,由调用方整体降级为"未捕获"。
13
+ * 请求标识来自 S3 层(ListBuckets),优先按 buckets.id 匹配(id 与 name 允许不同),
14
+ * 无中再退 name。null 属性原样保留(= 源端未设置,不是猜测值)。
15
+ */
16
+ export declare function bucketAttrsFromRows(requestedBuckets: string[], bucketRows: BucketRow[]): BucketAttrs[];
17
+ /** 查询源库的 bucket 属性。bucketNames 含空 bucket(恢复端要能重建它们)。 */
18
+ export declare function inspectBucketAttrs(databaseUrl: string, bucketNames: string[]): Promise<BucketAttrs[]>;
19
+ export {};
@@ -0,0 +1,50 @@
1
+ // Bucket 属性采集(Manifest v2,恢复闭环 PRD §5.1.3):public / 大小限制 / MIME 限制。
2
+ // 通道 = 源库 storage.buckets —— S3 兼容端点看不到这些属性,而备份本来就持有只读库连接,
3
+ // 零新增凭据(R0 spike 实证)。per-file 元数据不在这里:它随 syncStorage 的 GetObject
4
+ // 响应原子捕获(见 storage.ts fileMetadataFromS3)。
5
+ // 任何失败都由调用方降级为"未捕获"(manifest 缺 buckets 字段),绝不弄失败一次备份。
6
+ import { Client } from "pg";
7
+ import { pgConnectOptions } from "./supabase-ca.js";
8
+ /**
9
+ * 行 → manifest 形状的纯映射,含完整性关口:查询结果必须覆盖每一个请求的 bucket。
10
+ * storage.buckets 上的 RLS 或并发删除会**静默**少返回行——把不完整清单写进 manifest,
11
+ * 恢复端就会漏建 bucket 还以为自己全知。缺任何一个 → 抛错,由调用方整体降级为"未捕获"。
12
+ * 请求标识来自 S3 层(ListBuckets),优先按 buckets.id 匹配(id 与 name 允许不同),
13
+ * 无中再退 name。null 属性原样保留(= 源端未设置,不是猜测值)。
14
+ */
15
+ export function bucketAttrsFromRows(requestedBuckets, bucketRows) {
16
+ const attrs = [];
17
+ const missing = [];
18
+ for (const req of requestedBuckets) {
19
+ const row = bucketRows.find((r) => r.id === req) ?? bucketRows.find((r) => r.name === req);
20
+ if (!row) {
21
+ missing.push(req);
22
+ continue;
23
+ }
24
+ attrs.push({
25
+ name: req, // 快照对象布局用的标识(S3 层名),恢复端按它重建
26
+ public: row.public,
27
+ fileSizeLimit: row.file_size_limit === null ? null : Number(row.file_size_limit),
28
+ allowedMimeTypes: row.allowed_mime_types,
29
+ });
30
+ }
31
+ if (missing.length) {
32
+ throw new Error(`storage.buckets returned no row for: ${missing.join(", ")} ` +
33
+ `(RLS filtering or concurrent deletion) — refusing a partial bucket capture`);
34
+ }
35
+ return attrs;
36
+ }
37
+ /** 查询源库的 bucket 属性。bucketNames 含空 bucket(恢复端要能重建它们)。 */
38
+ export async function inspectBucketAttrs(databaseUrl, bucketNames) {
39
+ const client = new Client(pgConnectOptions(databaseUrl));
40
+ await client.connect();
41
+ try {
42
+ const bucketRows = await client.query(`select id, name, public, file_size_limit, allowed_mime_types
43
+ from storage.buckets where id = any($1::text[]) or name = any($1::text[])
44
+ order by name`, [bucketNames]);
45
+ return bucketAttrsFromRows(bucketNames, bucketRows.rows);
46
+ }
47
+ finally {
48
+ await client.end();
49
+ }
50
+ }
package/dist/storage.d.ts CHANGED
@@ -1,6 +1,20 @@
1
- import { S3Client } from "@aws-sdk/client-s3";
1
+ import { S3Client, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
2
2
  import type { SupabaseStorageConfig } from "./config.js";
3
3
  import type { StorageFile } from "./manifest.js";
4
+ /**
5
+ * 从 GetObject 响应提取文件的可恢复元数据。Supabase 的 S3 handler 把这些头直接
6
+ * 从 storage.objects.metadata / user_metadata 取出(content-type、cache-control、
7
+ * x-amz-meta-*,源码实证 2026-07-24)——所以这份元数据与刚流过的字节**原子一致**,
8
+ * 不存在"拷完再查 DB"的并发漂移窗口,也不依赖 DB 上 storage schema 的读权限。
9
+ * 缺席的头不落字段(= 源端未设置/未提供),不猜测。
10
+ */
11
+ export declare function fileMetadataFromS3(got: GetObjectCommandOutput): {
12
+ contentType?: string;
13
+ cacheControl?: string;
14
+ contentEncoding?: string;
15
+ metadata?: Record<string, string>;
16
+ lastModified?: string;
17
+ };
4
18
  /**
5
19
  * 仅列举、不下载 —— 用于接入时的容量/egress 估算,几乎不产生 egress。
6
20
  */
@@ -21,4 +35,5 @@ export declare function syncStorage(cfg: SupabaseStorageConfig, target: {
21
35
  fileCount: number;
22
36
  totalBytes: number;
23
37
  files: StorageFile[];
38
+ buckets: string[];
24
39
  }>;
package/dist/storage.js CHANGED
@@ -37,6 +37,30 @@ async function* listObjects(client, bucket) {
37
37
  token = res.IsTruncated ? res.NextContinuationToken : undefined;
38
38
  } while (token);
39
39
  }
40
+ /**
41
+ * 从 GetObject 响应提取文件的可恢复元数据。Supabase 的 S3 handler 把这些头直接
42
+ * 从 storage.objects.metadata / user_metadata 取出(content-type、cache-control、
43
+ * x-amz-meta-*,源码实证 2026-07-24)——所以这份元数据与刚流过的字节**原子一致**,
44
+ * 不存在"拷完再查 DB"的并发漂移窗口,也不依赖 DB 上 storage schema 的读权限。
45
+ * 缺席的头不落字段(= 源端未设置/未提供),不猜测。
46
+ */
47
+ export function fileMetadataFromS3(got) {
48
+ const meta = {};
49
+ if (got.ContentType)
50
+ meta.contentType = got.ContentType;
51
+ if (got.CacheControl)
52
+ meta.cacheControl = got.CacheControl;
53
+ if (got.ContentEncoding)
54
+ meta.contentEncoding = got.ContentEncoding;
55
+ // MissingMeta > 0 = 服务端明确声明有元数据条目没能放进 x-amz-meta 头(非法头名等)。
56
+ // 记一份自知残缺的 metadata 违反"未捕获就不写"原则 → 整个字段省略,如实缺席。
57
+ if (got.Metadata && Object.keys(got.Metadata).length && !(got.MissingMeta && got.MissingMeta > 0)) {
58
+ meta.metadata = got.Metadata;
59
+ }
60
+ if (got.LastModified)
61
+ meta.lastModified = got.LastModified.toISOString();
62
+ return meta;
63
+ }
40
64
  /**
41
65
  * 仅列举、不下载 —— 用于接入时的容量/egress 估算,几乎不产生 egress。
42
66
  */
@@ -95,10 +119,17 @@ export async function syncStorage(cfg, target) {
95
119
  await upload.abort().catch(() => { });
96
120
  throw error;
97
121
  }
98
- files.push({ bucket, key: obj.key, bytes, sha256: hash.digest("hex") });
122
+ files.push({
123
+ bucket,
124
+ key: obj.key,
125
+ bytes,
126
+ sha256: hash.digest("hex"),
127
+ ...fileMetadataFromS3(got),
128
+ });
99
129
  totalBytes += bytes;
100
130
  }
101
131
  log.step(` ${bucket}: ${files.filter((f) => f.bucket === bucket).length} files`);
102
132
  }
103
- return { fileCount: files.length, totalBytes, files };
133
+ // buckets 含空 bucket(files 推不出来)——manifest v2 的 bucket 属性采集要按它查
134
+ return { fileCount: files.length, totalBytes, files, buckets };
104
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Open-source CLI to back up a Supabase project — Postgres database and Storage files — to your own S3/R2/B2 bucket, with a checksummed manifest.",
5
5
  "type": "module",
6
6
  "bin": {