backupdrill 1.1.0 → 2.0.1

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
@@ -160,14 +160,20 @@ Your command runs after all structural checks pass, with `BACKUPDRILL_SANDBOX_UR
160
160
 
161
161
  **What this can and cannot verify, honestly:** `BACKUPDRILL_SANDBOX_URL` connects as the sandbox **superuser**, and superusers bypass row-level security — plus policies referencing Supabase-managed roles (`authenticated`, `anon`) are skipped during restore, since those roles don't exist outside the platform. So use `--check-cmd` for data and business invariants ("orders reference existing users", "the row count I care about is sane"). It is **not** an RLS behavior test — verifying RLS takes an authenticated client stack with real JWTs, which no restore sandbox can fake generically.
162
162
 
163
- When you actually need to recover, `restore` puts the database back into a target you name and pulls the Storage files down to a local folder:
163
+ When you actually need to recover, `restore` puts the database back into a fresh
164
+ target project (through the same engine the drills use, with verification) and
165
+ uploads the Storage files back — start with `--dry-run`, then confirm by typing
166
+ the target's project ref:
164
167
 
165
168
  ```bash
166
- backupdrill restore \
167
- --target-database-url "postgresql://…/postgres" \
168
- --storage-dir ./recovered
169
+ # credentials via hidden prompts (nothing lands in shell history); CI can inject
170
+ # BACKUPDRILL_TARGET_DATABASE_URL / BACKUPDRILL_TARGET_SERVICE_ROLE_KEY as managed secrets instead
171
+ backupdrill restore --database --target-supabase-url https://<ref>.supabase.co --dry-run
172
+ backupdrill restore --database --target-supabase-url https://<ref>.supabase.co --confirm-target <ref>
169
173
  ```
170
174
 
175
+ Prefer files on disk instead? Skip the Storage target and pass `--storage-dir ./recovered`.
176
+
171
177
  Or restore the dump by hand — into an **empty** database, without `--clean`:
172
178
 
173
179
  ```bash
@@ -180,6 +186,21 @@ carries no ACLs), leaving the restored API returning 401s. One error is expected
180
186
  harmless: `schema "public" already exists` — the target always has it. The
181
187
  `backupdrill restore` command above handles all of this (plus verification) for you.
182
188
 
189
+ ## 2.0 migration — `restore` flags changed
190
+
191
+ Restore now refuses to take credentials on the command line (argv is visible to
192
+ every process on the machine):
193
+
194
+ - `--target-database-url <url>` was **removed**. Set `BACKUPDRILL_TARGET_DATABASE_URL`
195
+ (or let the hidden prompt ask) and pass `--database` to say you want the
196
+ database restored.
197
+ - Writing to a target now requires `--confirm-target <target-ref>` — type the
198
+ target project's ref to confirm. Run `--dry-run` first to preview every check.
199
+ - The service-role key for Storage upload comes from
200
+ `BACKUPDRILL_TARGET_SERVICE_ROLE_KEY` (or the hidden prompt), never a flag.
201
+
202
+ `backup`, `drill` and `estimate` are unchanged.
203
+
183
204
  ## GitHub Action / scheduled backups
184
205
 
185
206
  Want this running on a schedule without paying for a server? Run it in GitHub Actions.
@@ -199,7 +220,7 @@ Before switching to a **daily** schedule, read the egress-cost section above —
199
220
  - [x] `estimate` — project your monthly Supabase egress cost before you schedule
200
221
  - [x] `drill` — restore a snapshot into an ephemeral Postgres and prove it comes back
201
222
  - [x] Storage-file integrity check inside the drill (checksums, sampled by default)
202
- - [x] `restore` — recover a snapshot into a target DB + pull Storage files down
223
+ - [x] `restore` — recover a snapshot into a fresh Supabase project: database through the drill-grade engine with verification, Storage uploaded back with per-file reconciliation (local download remains an option)
203
224
  - [x] GitHub Action wrapper
204
225
 
205
226
  For scheduling, automated restore drills, and alerting, use the hosted service at [backupdrill.com](https://backupdrill.com).
package/dist/backup.js CHANGED
@@ -8,6 +8,8 @@ import { Upload } from "@aws-sdk/lib-storage";
8
8
  import { MANIFEST_SCHEMA_VERSION } from "./manifest.js";
9
9
  import { syncStorage } from "./storage.js";
10
10
  import { inspectBucketAttrs } from "./storage-catalog.js";
11
+ import { renderRecoveryDoc } from "./recovery-doc.js";
12
+ import { projectRefOf, refFromStorageEndpoint, assertNoHostOverride } from "./restore-engine.js";
11
13
  import { log } from "./log.js";
12
14
  import { TOOL_VERSION } from "./version.js";
13
15
  import { pgConnectOptions, dumpUrlFor } from "./supabase-ca.js";
@@ -149,6 +151,19 @@ async function dumpToS3(config, s3, key, pgDumpBin) {
149
151
  return { bytes, sha256: hash.digest("hex") };
150
152
  }
151
153
  export async function runBackup(config) {
154
+ // 源身份先于一切传输(评审第 12 轮):①连接串不许带 ?host=/user= 覆盖——否则
155
+ // 快照记的 ref 与实际读取对象脱节,恢复端的同源保护随之失真;②库与 Storage
156
+ // 跨项目 = 配置错误,全量 dump+同步之后才发现会白烧 egress 还留下半截快照前缀
157
+ assertNoHostOverride(config.databaseUrl);
158
+ const dbRef = projectRefOf(config.databaseUrl);
159
+ const storageRef = config.supabaseStorage
160
+ ? refFromStorageEndpoint(config.supabaseStorage.endpoint)
161
+ : null;
162
+ if (dbRef && storageRef && dbRef !== storageRef) {
163
+ throw new Error(`database points at Supabase project "${dbRef}" but Storage at "${storageRef}" — ` +
164
+ `one BackupDrill project backs up one Supabase project; fix the config before backing up.`);
165
+ }
166
+ const sourceProjectRef = dbRef ?? storageRef;
152
167
  const pgDumpBin = process.env.BACKUPDRILL_PG_DUMP || "pg_dump";
153
168
  log.step("Inspecting database…");
154
169
  const db = await inspectDatabase(config.databaseUrl, config.schemas);
@@ -214,6 +229,7 @@ export async function runBackup(config) {
214
229
  toolVersion: TOOL_VERSION,
215
230
  createdAt: new Date().toISOString(),
216
231
  projectName: config.projectName,
232
+ ...(sourceProjectRef ? { sourceProjectRef } : {}),
217
233
  database: {
218
234
  serverVersion: db.serverVersion,
219
235
  pgDumpVersion: pgd.raw,
@@ -226,6 +242,23 @@ export async function runBackup(config) {
226
242
  dump: { key: dumpKey, format: "custom", bytes, sha256 },
227
243
  storage: storageResult,
228
244
  };
245
+ // RECOVERY.md(PRD §5.9.2.1)先于 manifest 落桶:manifest 是快照的"完成标记"
246
+ // (演练/恢复按它找快照)——先写手册再写标记,"快照完成"就蕴含"手册在场";
247
+ // 手册写失败 = 备份失败(同桶同权限,与 manifest 自身的写入风险一致)。
248
+ await s3.send(new PutObjectCommand({
249
+ Bucket: config.storage.bucket,
250
+ Key: `${base}/RECOVERY.md`,
251
+ Body: renderRecoveryDoc(manifest, {
252
+ snapshot: timestamp,
253
+ bucket: config.storage.bucket,
254
+ endpoint: config.storage.endpoint,
255
+ region: config.storage.region,
256
+ prefix: config.storage.prefix,
257
+ projectName: config.projectName,
258
+ }),
259
+ ContentType: "text/markdown",
260
+ }));
261
+ log.ok(`Recovery runbook written → s3://${config.storage.bucket}/${base}/RECOVERY.md`);
229
262
  await s3.send(new PutObjectCommand({
230
263
  Bucket: config.storage.bucket,
231
264
  Key: `${base}/manifest.json`,
package/dist/index.js CHANGED
@@ -4,7 +4,28 @@ 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 { createInterface } from "node:readline/promises";
8
+ import { Writable } from "node:stream";
7
9
  import { runRestore, NO_SOURCE_DATABASE } from "./restore.js";
10
+ /** 隐藏式秘密输入(PRD §5.3.1):终端不回显;无 TTY(CI/脚本)时明确要求走环境变量。 */
11
+ async function promptSecret(label) {
12
+ if (!process.stdin.isTTY) {
13
+ throw new Error(`${label} is required — set the environment variable (no TTY available for a prompt).`);
14
+ }
15
+ const muted = new Writable({ write: (_chunk, _enc, cb) => cb() });
16
+ const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
17
+ process.stderr.write(`${label} (hidden): `);
18
+ try {
19
+ const value = (await rl.question("")).trim();
20
+ process.stderr.write("\n");
21
+ if (!value)
22
+ throw new Error(`${label} was empty.`);
23
+ return value;
24
+ }
25
+ finally {
26
+ rl.close();
27
+ }
28
+ }
8
29
  import { log } from "./log.js";
9
30
  import { TOOL_VERSION } from "./version.js";
10
31
  // Supabase egress 定价表(2026-07-10 核实,来源见 README「Egress & cost」)。
@@ -125,10 +146,18 @@ program
125
146
  });
126
147
  program
127
148
  .command("restore")
128
- .description("Recover a snapshot: restore the database into a target and pull Storage files down")
149
+ .description("Recover a snapshot into a FRESH Supabase project: database via the drill-grade engine, " +
150
+ "Storage files uploaded back (or downloaded locally). Start with --dry-run.")
129
151
  .option("-c, --config <path>", "path to a JSON config file", "backupdrill.config.json")
130
- .option("--target-database-url <url>", "connection string to restore the database INTO")
131
- .option("--storage-dir <path>", "local directory to write Storage files to")
152
+ .option("--database", "restore the database — connection string via env BACKUPDRILL_TARGET_DATABASE_URL or a hidden prompt " +
153
+ "(2.0: the credential-bearing --target-database-url flag was removed; argv is visible to every process)")
154
+ .option("--target-supabase-url <url>", "target project URL (https://<ref>.supabase.co) to upload Storage files into; " +
155
+ "service-role key via env BACKUPDRILL_TARGET_SERVICE_ROLE_KEY or a hidden prompt")
156
+ .option("--confirm-target <ref>", "type the TARGET project ref to confirm writing to it")
157
+ .option("--dry-run", "run every read-only preflight check and write nothing")
158
+ .option("--acknowledge-unverified-source", "proceed although the source identity cannot be verified (legacy snapshot without config) — " +
159
+ "same-source protection is inert; double-check the target first")
160
+ .option("--storage-dir <path>", "local directory to write Storage files to (when not uploading)")
132
161
  .option("--snapshot <timestamp>", "which snapshot to restore (default: latest)")
133
162
  .option("--bucket <name>", "bucket the backups live in")
134
163
  .option("--endpoint <url>", "S3-compatible endpoint (for R2/B2)")
@@ -160,18 +189,51 @@ program
160
189
  overrides: { ...overrides, databaseUrl: NO_SOURCE_DATABASE },
161
190
  });
162
191
  }
192
+ // 凭据形态(决策 D6,2.0 起):env 或隐藏式交互输入,绝不经 argv。
193
+ // --database 只表达意图,秘密自身不上命令行
194
+ // 秘密只随各自的意图旗标读取(评审第 9/10 轮):环境变量残留不能把 storage-only
195
+ // 变成意外的数据库恢复,也不能把无 Storage 目标的运行拖进凭据配对检查;
196
+ // 空串按缺失处理(误 export 空值不许静默吞掉一个显式请求)
197
+ let targetDatabaseUrl;
198
+ if (opts.database) {
199
+ targetDatabaseUrl =
200
+ process.env.BACKUPDRILL_TARGET_DATABASE_URL?.trim() ||
201
+ (await promptSecret("Target database connection string (BACKUPDRILL_TARGET_DATABASE_URL)"));
202
+ }
203
+ let targetServiceRoleKey;
204
+ if (opts.targetSupabaseUrl) {
205
+ targetServiceRoleKey =
206
+ process.env.BACKUPDRILL_TARGET_SERVICE_ROLE_KEY?.trim() ||
207
+ (await promptSecret("Target service-role key (BACKUPDRILL_TARGET_SERVICE_ROLE_KEY)"));
208
+ }
163
209
  const result = await runRestore(config, {
164
- targetDatabaseUrl: opts.targetDatabaseUrl,
210
+ targetDatabaseUrl,
211
+ targetSupabaseUrl: opts.targetSupabaseUrl,
212
+ targetServiceRoleKey,
213
+ confirmTarget: opts.confirmTarget,
214
+ dryRun: opts.dryRun,
215
+ acknowledgeUnverifiedSource: opts.acknowledgeUnverifiedSource,
165
216
  storageDir: opts.storageDir,
166
217
  snapshot: opts.snapshot,
167
218
  });
219
+ // stdout = 机器可读通道(人读日志全在 stderr):完整结构化结果供脚本/报告消费
220
+ console.log(JSON.stringify(result));
221
+ if (result.dryRun)
222
+ return; // 预检自行打印结论;走到这里 = 零 blocker
168
223
  const failedChecks = (result.databaseChecks ?? []).filter((c) => !c.pass);
224
+ const rec = result.storageSummary?.reconcile;
225
+ const storageProblems = (result.storageSummary?.filesFailed.length ?? 0) +
226
+ (result.storageSummary?.checksumSample.mismatched.length ?? 0) +
227
+ // 属性漂移进裁决(评审第 10 轮):访问行为与快照不一致不算干净恢复
228
+ (result.storageSummary?.bucketAttrDrift.length ?? 0) +
229
+ (rec ? rec.missing.length + rec.sizeMismatched.length + rec.extras : 0);
169
230
  const summary = `snapshot ${result.snapshot}: ` +
170
231
  `database ${result.restoredToDatabase ? "restored" : "skipped"}, ` +
171
- `${result.storageFilesWritten} Storage files written` +
232
+ `${result.storageFilesWritten} Storage files ${result.storageSummary ? "uploaded to target" : "written"}` +
172
233
  (result.storageDir ? ` to ${result.storageDir}` : "");
173
- if (failedChecks.length) {
174
- log.error(`Restore finished with ${failedChecks.length} FAILED verification check(s) — ${summary}`);
234
+ if (failedChecks.length || storageProblems) {
235
+ log.error(`Restore finished with issues (${failedChecks.length} failed check(s), ` +
236
+ `${storageProblems} storage problem(s)) — ${summary}`);
175
237
  process.exitCode = 1;
176
238
  }
177
239
  else {
@@ -58,6 +58,7 @@ export interface Manifest {
58
58
  toolVersion: string;
59
59
  createdAt: string;
60
60
  projectName: string;
61
+ sourceProjectRef?: string;
61
62
  database: {
62
63
  serverVersion: string;
63
64
  pgDumpVersion: string;
package/dist/manifest.js CHANGED
@@ -29,6 +29,10 @@ function optionalString(value, what) {
29
29
  malformed(what);
30
30
  }
31
31
  function assertManifestShape(m) {
32
+ // ref 驱动同源安全门:malformed 值会被当"身份已知"却永远匹配不上 = 保护静默失效
33
+ if (m.sourceProjectRef !== undefined && !/^[a-z0-9]{16,}$/.test(m.sourceProjectRef)) {
34
+ malformed("sourceProjectRef (not a Supabase project ref)");
35
+ }
32
36
  const d = m.dump;
33
37
  if (typeof d !== "object" || d === null)
34
38
  malformed("dump section missing");
@@ -45,6 +49,10 @@ function assertManifestShape(m) {
45
49
  if (typeof m.database.serverVersion !== "string" || !(parseInt(m.database.serverVersion, 10) > 0)) {
46
50
  malformed("database.serverVersion");
47
51
  }
52
+ // 恢复端的工具版本闸要解析它(requiredRestoreToolMajor)——错型值不能到那才炸
53
+ if (typeof m.database.pgDumpVersion !== "string" || !m.database.pgDumpVersion) {
54
+ malformed("database.pgDumpVersion");
55
+ }
48
56
  if (!Array.isArray(m.database.schemas) || m.database.schemas.some((s) => typeof s !== "string" || !s)) {
49
57
  malformed("database.schemas");
50
58
  }
@@ -0,0 +1,11 @@
1
+ import type { Manifest } from "./manifest.js";
2
+ /** 生成手册所需的非秘密快照坐标。刻意不收连接串/密钥类型——签名即防线。 */
3
+ export interface RecoveryDocContext {
4
+ snapshot: string;
5
+ bucket: string;
6
+ endpoint?: string;
7
+ region?: string;
8
+ prefix: string;
9
+ projectName: string;
10
+ }
11
+ export declare function renderRecoveryDoc(manifest: Manifest, ctx: RecoveryDocContext): string;
@@ -0,0 +1,123 @@
1
+ function quoteSqlIdent(name) {
2
+ return `"${name.replace(/"/g, '""')}"`;
3
+ }
4
+ /** POSIX 安全的 shell 值引用:项目名/前缀里合法的空格与元字符不得拆散或改写命令。 */
5
+ function shellQuote(value) {
6
+ return /^[A-Za-z0-9._/:@=-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
7
+ }
8
+ export function renderRecoveryDoc(manifest, ctx) {
9
+ const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
10
+ const db = manifest.database;
11
+ const extensions = db.extensions ?? [];
12
+ const storage = manifest.storage;
13
+ const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
14
+ // 归档格式跟随 dump 工具版本(允许比服务端新):pg_restore 必须 ≥ 两者较大值,
15
+ // 只按服务端版本指引会让 PG17 的 pg_restore 读不了 PG18 pg_dump 写的归档。
16
+ // 解析口径与 backup.ts parsePgDumpMajor 一致(不 import:backup → recovery-doc 会成环)
17
+ const dumpToolMajor = db.pgDumpVersion.match(/\(PostgreSQL\)\s+(\d+)/)?.[1] ??
18
+ db.pgDumpVersion.match(/(\d+)(?:\.\d+)+/)?.[1];
19
+ const requiredRestoreMajor = Math.max(Number(dumpToolMajor ?? 0), parseInt(db.serverVersion, 10) || 0);
20
+ const extensionSql = extensions.length
21
+ ? extensions
22
+ .map((e) => `create schema if not exists ${quoteSqlIdent(e.schema)};\n` +
23
+ `create extension if not exists ${quoteSqlIdent(e.name)} schema ${quoteSqlIdent(e.schema)} cascade;`)
24
+ .join("\n")
25
+ : "-- (this snapshot records no extensions)";
26
+ // 2.0 形态:凭据只经环境变量(argv 对全机器可见);写入需要键入目标 ref 确认。
27
+ // DB-only 快照不出现 Storage 凭据与旗标——不为用不上的能力索要宽权限 key
28
+ const cliRestoreCommand = [
29
+ `read -rs BACKUPDRILL_TARGET_DATABASE_URL && export BACKUPDRILL_TARGET_DATABASE_URL # hidden prompt`,
30
+ ...(storage
31
+ ? [`read -rs BACKUPDRILL_TARGET_SERVICE_ROLE_KEY && export BACKUPDRILL_TARGET_SERVICE_ROLE_KEY`]
32
+ : []),
33
+ `backupdrill restore \\`,
34
+ ` --snapshot ${shellQuote(ctx.snapshot)} \\`,
35
+ ` --bucket ${shellQuote(ctx.bucket)} \\`,
36
+ ...(ctx.endpoint ? [` --endpoint ${shellQuote(ctx.endpoint)} \\`] : []),
37
+ ...(ctx.region ? [` --region ${shellQuote(ctx.region)} \\`] : []),
38
+ ` --prefix ${shellQuote(ctx.prefix)} \\`,
39
+ ` --project-name ${shellQuote(ctx.projectName)} \\`,
40
+ ` --database \\`,
41
+ ...(storage ? [` --target-supabase-url https://<target-ref>.supabase.co \\`] : []),
42
+ ` --confirm-target <target-ref>`,
43
+ ].join("\n");
44
+ return `# BackupDrill recovery runbook — ${ctx.projectName} @ ${ctx.snapshot}
45
+
46
+ This file was written next to the snapshot it describes and is **self-contained**:
47
+ even if BackupDrill (the service and the website) is unreachable, everything below
48
+ works with standard PostgreSQL tools against this bucket alone.
49
+
50
+ ## What this snapshot contains
51
+
52
+ - **Database dump**: \`${manifest.dump.key}\`
53
+ (pg_dump custom format, ${mb(manifest.dump.bytes)} MB, sha256 \`${manifest.dump.sha256}\`)
54
+ - schemas: ${db.schemas.join(", ")} — ${db.tableCount} tables, ~${db.estimatedRowTotal.toLocaleString("en-US")} rows at backup time
55
+ - source PostgreSQL ${db.serverVersion}, archive written by ${db.pgDumpVersion} — restore with pg_restore ${requiredRestoreMajor} or newer (the archive format follows the dump tool, not the server)
56
+ ${extensions.length
57
+ ? ` - extensions required: ${extensions.map((e) => `${e.name} (schema "${e.schema}")`).join(", ")}`
58
+ : " - no extensions recorded"}
59
+ - **Storage files**: ${storage
60
+ ? `${storage.fileCount} files, ${mb(storage.totalBytes)} MB under \`${base}/storage/<bucket>/<key>\` — per-file sha256 in manifest.json`
61
+ : "none (database-only snapshot)"}
62
+ - **Machine-readable inventory**: \`${base}/manifest.json\`
63
+
64
+ ## Not covered — plan for these separately
65
+
66
+ Supabase **Auth users/sessions, secret values, Edge Function code and most
67
+ platform settings are NOT in this snapshot**. After restoring, reconfigure them
68
+ by hand. This is a database + Storage snapshot, not a full project image.
69
+
70
+ ## Restore the database (into an EMPTY project)
71
+
72
+ 1. Create a **fresh** Supabase project (never restore onto the source project),
73
+ and use its **Session Pooler** connection string.
74
+ 2. Install the required extensions first (the dump references their types):
75
+
76
+ \`\`\`sql
77
+ ${extensionSql}
78
+ \`\`\`
79
+
80
+ 3. Download \`${manifest.dump.key}\` from this bucket, then verify it:
81
+
82
+ \`\`\`bash
83
+ shasum -a 256 dump.pgcustom # must print ${manifest.dump.sha256}
84
+ \`\`\`
85
+
86
+ 4. Restore — **without \`--clean\`**, into the empty target. Enter the password at
87
+ a hidden prompt so it never touches the URL, argv, or shell history:
88
+
89
+ \`\`\`bash
90
+ read -rs PGPASSWORD && export PGPASSWORD # hidden prompt
91
+ pg_restore --no-owner --no-privileges \\
92
+ --dbname "postgresql://postgres.<target-ref>@<target-pooler-host>:5432/postgres" dump.pgcustom
93
+ unset PGPASSWORD
94
+ \`\`\`
95
+
96
+ One error is expected and harmless: \`schema "public" already exists\` (the
97
+ target always has it). Do **not** use \`--clean\` against a Supabase project —
98
+ dropping and recreating \`public\` silently wipes Supabase's default grants for
99
+ \`anon\`/\`authenticated\`, and the restored API answers 401.
100
+
101
+ ## Restore Storage files
102
+
103
+ Files live at \`${base}/storage/<original-bucket>/<original-key>\` in this
104
+ bucket. Recreate each bucket in the target project, then re-upload the files
105
+ ${storage?.buckets ? "(bucket attributes — public flag, size and MIME limits — are recorded in manifest.json under `storage.buckets`)" : "(bucket attributes were not captured for this snapshot; recreate buckets with the settings you know)"}.
106
+
107
+ ## Easiest path — the BackupDrill CLI
108
+
109
+ \`\`\`bash
110
+ npm install -g backupdrill
111
+ ${cliRestoreCommand}
112
+ \`\`\`
113
+
114
+ Add \`--dry-run\` first to preview every check without writing anything. (Skip the
115
+ \`read\` lines to have the CLI prompt you itself; \`unset\` both variables afterwards.)
116
+ The CLI verifies the archive checksum, refuses non-empty targets, installs the
117
+ extensions, restores through the same engine the weekly drills use, verifies
118
+ table counts and row presence afterwards, then rebuilds buckets, uploads the
119
+ Storage files back and reconciles every one against the target.
120
+
121
+ More: https://backupdrill.com/docs/restore
122
+ `;
123
+ }
@@ -1,5 +1,16 @@
1
1
  import type { ExtensionInfo } from "./manifest.js";
2
2
  export type RestoreTargetKind = "sandbox" | "supabase";
3
+ /** 主机名规范化:剥掉合法的 DNS 根点(db.x.supabase.co. ≡ db.x.supabase.co)。
4
+ * 身份比较不规范化 = 一个尾点就能绕过同源阻断(supabase-ca 对同类早有钉子)。 */
5
+ export declare function normalizeHost(hostname: string): string;
6
+ /**
7
+ * 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db.<ref>.supabase.co` 或
8
+ * pooler 用户名 `postgres.<ref>`。放在引擎层:备份端(写 manifest.sourceProjectRef)
9
+ * 与恢复端(同源阻断)共用同一个身份判定。非 Supabase 形态返回 null。
10
+ */
11
+ export declare function projectRefOf(connString: string): string | null;
12
+ /** 从 Supabase Storage S3 端点(https://<ref>.storage.supabase.co/…)提取项目 ref。 */
13
+ export declare function refFromStorageEndpoint(endpoint: string): string | null;
3
14
  export interface ClassifiedPass {
4
15
  /** allowlist 命中数(环境预期/托管冲突)——如实入报告,不算失败 */
5
16
  expectedSkips: number;
@@ -56,6 +67,12 @@ export declare function credentialSafeDbArgs(connString: string): {
56
67
  url: string;
57
68
  env: NodeJS.ProcessEnv;
58
69
  };
70
+ /**
71
+ * 拒绝连接串里的 host/hostaddr/user 查询覆盖:驱动会让它们改写实际连接目标/租户——
72
+ * 身份判定(ref/主机)看的是 authority,读写却发生在别处。备份侧(源身份自记)与
73
+ * 恢复侧(确认门/同源阻断)共用本检查。
74
+ */
75
+ export declare function assertNoHostOverride(connString: string): void;
59
76
  export declare function restoreDatabaseArtifact(opts: {
60
77
  dumpPath: string;
61
78
  connString: string;
@@ -11,7 +11,44 @@
11
11
  import { spawn } from "node:child_process";
12
12
  import { Client } from "pg";
13
13
  import { resolvePgRestoreBin } from "./pgbin.js";
14
- import { pgConnectOptions } from "./supabase-ca.js";
14
+ import { pgConnectOptions, dumpUrlFor } from "./supabase-ca.js";
15
+ /** 主机名规范化:剥掉合法的 DNS 根点(db.x.supabase.co. ≡ db.x.supabase.co)。
16
+ * 身份比较不规范化 = 一个尾点就能绕过同源阻断(supabase-ca 对同类早有钉子)。 */
17
+ export function normalizeHost(hostname) {
18
+ return hostname.replace(/\.$/, "").toLowerCase();
19
+ }
20
+ /**
21
+ * 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db.<ref>.supabase.co` 或
22
+ * pooler 用户名 `postgres.<ref>`。放在引擎层:备份端(写 manifest.sourceProjectRef)
23
+ * 与恢复端(同源阻断)共用同一个身份判定。非 Supabase 形态返回 null。
24
+ */
25
+ export function projectRefOf(connString) {
26
+ try {
27
+ const url = new URL(connString);
28
+ const direct = normalizeHost(url.hostname).match(/^db\.([a-z0-9]{16,})\.supabase\.co$/);
29
+ if (direct)
30
+ return direct[1];
31
+ // 用户名必须先解码再匹配:URL 解析器保留百分号编码,而 pg/libpq 会解码——
32
+ // postgres%2Eref 在驱动眼里就是 postgres.ref,不解码 = 身份判定可被编码绕过
33
+ const pooled = decodeURIComponent(url.username).match(/^postgres\.([a-z0-9]{16,})$/);
34
+ if (pooled)
35
+ return pooled[1];
36
+ return null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /** 从 Supabase Storage S3 端点(https://<ref>.storage.supabase.co/…)提取项目 ref。 */
43
+ export function refFromStorageEndpoint(endpoint) {
44
+ try {
45
+ const match = normalizeHost(new URL(endpoint).hostname).match(/^([a-z0-9]{16,})\.(?:storage\.)?supabase\.co$/);
46
+ return match ? match[1] : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
15
52
  export function spawnPgRestore(bin, args, extraEnv = {}) {
16
53
  return new Promise((resolve, reject) => {
17
54
  const proc = spawn(bin, args, {
@@ -124,21 +161,103 @@ export async function installExtensions(connString, extensions) {
124
161
  * 原样透传(libpq 自己适配的形态,不在这里破坏)。
125
162
  */
126
163
  export function credentialSafeDbArgs(connString) {
164
+ let url;
127
165
  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 } };
166
+ url = new URL(connString);
134
167
  }
135
168
  catch {
169
+ // 非 URL(libpq keyword)形态拆不了密码——含内联密码的一律拒绝,不赌
170
+ if (/\bpassword\s*=/i.test(connString)) {
171
+ throw new Error("keyword-style connection strings with an inline password are not accepted — " +
172
+ "use the URL form; the password is moved into PGPASSWORD automatically");
173
+ }
136
174
  return { url: connString, env: {} };
137
175
  }
176
+ let password = "";
177
+ if (url.password) {
178
+ try {
179
+ password = decodeURIComponent(url.password);
180
+ }
181
+ catch {
182
+ // 编码坏了不许 fail-open(评审第 13 轮):原样放行 = 密码整段留在 argv
183
+ throw new Error("the connection string password contains malformed percent-encoding — " +
184
+ "fix the URL; refusing to pass it through argv");
185
+ }
186
+ url.password = "";
187
+ }
188
+ // ?password= 同样不许进 argv(libpq 认它)。剥参按**解码后的键名**匹配
189
+ // (pass%77ord= 也是 password=,评审第 15 轮),不用 URLSearchParams.delete:
190
+ // 它会重序列化其余参数(%20 变 +),libpq 按字面处理,options 之类会被改坏
191
+ const rawQuery = url.search.startsWith("?") ? url.search.slice(1) : url.search;
192
+ if (rawQuery) {
193
+ const kept = [];
194
+ const queryPasswords = [];
195
+ for (const part of rawQuery.split("&")) {
196
+ const eq = part.indexOf("=");
197
+ const rawKey = eq === -1 ? part : part.slice(0, eq);
198
+ let key;
199
+ try {
200
+ key = decodeURIComponent(rawKey.replace(/\+/g, "%20"));
201
+ }
202
+ catch {
203
+ key = rawKey;
204
+ }
205
+ if (/^password$/i.test(key)) {
206
+ const rawValue = eq === -1 ? "" : part.slice(eq + 1);
207
+ try {
208
+ const value = decodeURIComponent(rawValue.replace(/\+/g, "%20"));
209
+ if (value)
210
+ queryPasswords.push(value);
211
+ }
212
+ catch {
213
+ throw new Error("the connection string ?password= value contains malformed percent-encoding — " +
214
+ "fix the URL; refusing to pass it through argv");
215
+ }
216
+ }
217
+ else {
218
+ kept.push(part);
219
+ }
220
+ }
221
+ if (queryPasswords.length) {
222
+ // authority 与查询、以及重复查询项之间不一致:libpq 的取值优先级和我们的猜测
223
+ // 可能不同,预检用 A 通过、pg_restore 用 B 失败的分叉不可接受——直接拒绝
224
+ const distinct = new Set(password ? [password, ...queryPasswords] : queryPasswords);
225
+ if (distinct.size > 1) {
226
+ throw new Error("the connection string carries two different passwords (authority/?password=) — remove one");
227
+ }
228
+ password = [...distinct][0];
229
+ url.search = kept.length ? `?${kept.join("&")}` : "";
230
+ }
231
+ }
232
+ return { url: url.toString(), env: password ? { PGPASSWORD: password } : {} };
233
+ }
234
+ /**
235
+ * 拒绝连接串里的 host/hostaddr/user 查询覆盖:驱动会让它们改写实际连接目标/租户——
236
+ * 身份判定(ref/主机)看的是 authority,读写却发生在别处。备份侧(源身份自记)与
237
+ * 恢复侧(确认门/同源阻断)共用本检查。
238
+ */
239
+ export function assertNoHostOverride(connString) {
240
+ try {
241
+ const params = new URL(connString).searchParams;
242
+ for (const key of params.keys()) {
243
+ if (/^(host|hostaddr|user)$/i.test(key)) {
244
+ throw new Error(`connection string carries a ?${key}= override — the effective server/identity would ` +
245
+ `differ from the URL authority that identity checks inspect. Use a plain connection string.`);
246
+ }
247
+ }
248
+ }
249
+ catch (error) {
250
+ if (error.message.includes("override"))
251
+ throw error;
252
+ // URL 解析不了的连接串由 pg 自己报错,这里放行
253
+ }
138
254
  }
139
255
  export async function restoreDatabaseArtifact(opts) {
140
256
  const bin = resolvePgRestoreBin();
141
- const { url, env } = credentialSafeDbArgs(opts.connString);
257
+ // TLS 全链路 verify-full(创始人承诺项):pg_restore 与 pg_dump 同一改写——
258
+ // Supabase 主机强制 verify-full + 打包根 CA(libpq 的 require 不验证书,
259
+ // 中间人可拿到连接与数据);沙箱/外部主机原样透传。先改写 SSL 再拆密码。
260
+ const { url, env } = credentialSafeDbArgs(dumpUrlFor(opts.connString));
142
261
  const common = ["--no-owner", "--no-privileges", "--dbname", url, opts.dumpPath];
143
262
  const first = await spawnPgRestore(bin, ["--section=pre-data", "--section=data", ...common], env);
144
263
  const preData = finalizePass(first.code, first.stderr, classifyBlocks(first.stderr, SCHEMA_EXISTS_ERROR));