backupdrill 2.1.3 → 2.1.4

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/backup.js CHANGED
@@ -11,7 +11,8 @@ import { renderRecoveryDoc } from "./recovery-doc.js";
11
11
  import { projectRefOf, refFromStorageEndpoint, assertNoHostOverride } from "./restore-engine.js";
12
12
  import { log } from "./log.js";
13
13
  import { TOOL_VERSION } from "./version.js";
14
- import { dumpUrlFor, connectPg } from "./supabase-ca.js";
14
+ import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
15
+ import { libpqChildEnv, urlSslModeOf } from "./restore-engine.js";
15
16
  const execFileAsync = promisify(execFile);
16
17
  function majorOf(serverVersionNum) {
17
18
  // server_version_num: 150004 → 15, 170002 → 17
@@ -111,7 +112,15 @@ async function dumpToS3(config, s3, key, pgDumpBin) {
111
112
  ...schemaArgs,
112
113
  "--dbname",
113
114
  dumpDbUrl,
114
- ], { stdio: ["ignore", "pipe", "pipe"] });
115
+ ],
116
+ // 环境剔除改写目标/身份的 PG* 变量:pg_dump 只认 --dbname 里的 URL(TLS 策略等保留)
117
+ {
118
+ stdio: ["ignore", "pipe", "pipe"],
119
+ env: libpqChildEnv({}, process.env, {
120
+ supabaseHost: isSupabaseHost(config.databaseUrl),
121
+ urlSslMode: urlSslModeOf(dumpDbUrl),
122
+ }),
123
+ });
115
124
  let stderr = "";
116
125
  dump.stderr.on("data", (d) => {
117
126
  stderr += d.toString();
package/dist/lib.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { runBackup } from "./backup.js";
2
- export { SUPABASE_ROOT_CA, pgConnectOptions } from "./supabase-ca.js";
2
+ export { SUPABASE_ROOT_CA, pgConnectOptions, PG_CLIENT_OPTIONS, normalizeConnectionTarget } from "./supabase-ca.js";
3
3
  export { runDrill, drillDump } from "./drill.js";
4
4
  export { runRestore } from "./restore.js";
5
- export { projectRefOf, assertNoHostOverride } from "./restore-engine.js";
5
+ export { projectRefOf, assertNoHostOverride, libpqChildEnv } from "./restore-engine.js";
6
6
  export { sameDatabaseTarget } from "./restore.js";
7
7
  export { runEstimate, project } from "./estimate.js";
8
8
  export { loadConfig } from "./config.js";
package/dist/lib.js CHANGED
@@ -3,11 +3,11 @@
3
3
  export { runBackup } from "./backup.js";
4
4
  // verify-full:只导出**原子** API。刻意不导出裸 SUPABASE_SSL —— 它一旦和 connectionString
5
5
  // 搭配使用,URL 里的 sslmode=disable 又会把它覆盖掉,等于把这次修掉的绕过再放回去。
6
- export { SUPABASE_ROOT_CA, pgConnectOptions } from "./supabase-ca.js";
6
+ export { SUPABASE_ROOT_CA, pgConnectOptions, PG_CLIENT_OPTIONS, normalizeConnectionTarget } from "./supabase-ca.js";
7
7
  export { runDrill, drillDump } from "./drill.js";
8
8
  export { runRestore } from "./restore.js";
9
9
  // 身份判定三件套对外导出:托管 worker 用它们与 app 侧 shared 的副本做同规则回归(交叉审查)
10
- export { projectRefOf, assertNoHostOverride } from "./restore-engine.js";
10
+ export { projectRefOf, assertNoHostOverride, libpqChildEnv } from "./restore-engine.js";
11
11
  export { sameDatabaseTarget } from "./restore.js";
12
12
  export { runEstimate, project } from "./estimate.js";
13
13
  export { loadConfig } from "./config.js";
@@ -3,6 +3,14 @@ export type RestoreTargetKind = "sandbox" | "supabase";
3
3
  /** 主机名规范化:先按驱动语义百分号解码(pg-connection-string / libpq 都会解码主机名,
4
4
  * `supabase%2Ecom` 在驱动眼里就是 supabase.com),再剥掉合法的 DNS 根点(db.x.supabase.co. ≡
5
5
  * db.x.supabase.co)。身份比较不规范化 = 一个尾点或一个 %2E 就能绕过同源阻断(交叉审查)。 */
6
+ /**
7
+ * 连接串任何位置解码后出现 NUL(或原文里的 %00)都视为启动包字段注入:驱动把 NUL 当字段分隔符,
8
+ * 后面的内容(user=… / options=reference=…)会成为额外的启动参数,连接目标与身份判定脱节。
9
+ * 合法连接串不存在 NUL,整串一刀切,不再逐字段追(交叉审查:query 值里的 NUL 同样能注入)。
10
+ */
11
+ export declare function containsNul(connString: string): boolean;
12
+ /** Supavisor 集群别名语法:用户名里出现字面量小写 `.cluster.`(handler_helpers.ex),之后全是别名。 */
13
+ export declare function isClusterAliasUsername(username: string): boolean;
6
14
  export declare function normalizeHost(hostname: string): string;
7
15
  /**
8
16
  * 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db.<ref>.supabase.co` 或
@@ -26,7 +34,23 @@ export interface EngineResult {
26
34
  postData: ClassifiedPass;
27
35
  ok: boolean;
28
36
  }
29
- export declare function spawnPgRestore(bin: string, args: string[], extraEnv?: NodeJS.ProcessEnv): Promise<{
37
+ /**
38
+ * libpq 子进程(pg_dump / pg_restore)的环境。`supabaseHost` 为真时再剔除 PGOPTIONS:Supavisor 会从
39
+ * options 里读租户覆盖(reference=…),而 Node 客户端对 Supabase 主机已显式钉死 options;非 Supabase
40
+ * 主机两边都尊重用户的 PGOPTIONS——两个客户端在任何情况下看到的都是同一份连接语义。
41
+ */
42
+ export declare function libpqChildEnv(extraEnv?: NodeJS.ProcessEnv, base?: NodeJS.ProcessEnv, opts?: {
43
+ supabaseHost: boolean;
44
+ urlSslMode?: string | null;
45
+ }): NodeJS.ProcessEnv;
46
+ /** 见 libpqChildEnv:一个绝不存在的根证书路径 = 让 libpq 的 require 既不验证也不去找默认 root.crt。 */
47
+ export declare const NO_VERIFY_ROOTCERT_SENTINEL = "/nonexistent/backupdrill-no-verify-root.crt";
48
+ /** 连接串 query 里的 sslmode(驱动语义:URL 优先于环境变量);解析不了或没写 → null。 */
49
+ export declare function urlSslModeOf(connString: string): string | null;
50
+ export declare function spawnPgRestore(bin: string, args: string[], extraEnv?: NodeJS.ProcessEnv, opts?: {
51
+ supabaseHost: boolean;
52
+ urlSslMode?: string | null;
53
+ }): Promise<{
30
54
  code: number | null;
31
55
  stderr: string;
32
56
  }>;
@@ -10,10 +10,29 @@
10
10
  // pre-data 阶段的 schema "already exists" 是两类目标共同的预期冲突。
11
11
  import { spawn } from "node:child_process";
12
12
  import { resolvePgRestoreBin } from "./pgbin.js";
13
- import { dumpUrlFor, connectPg } from "./supabase-ca.js";
13
+ import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
14
14
  /** 主机名规范化:先按驱动语义百分号解码(pg-connection-string / libpq 都会解码主机名,
15
15
  * `supabase%2Ecom` 在驱动眼里就是 supabase.com),再剥掉合法的 DNS 根点(db.x.supabase.co. ≡
16
16
  * db.x.supabase.co)。身份比较不规范化 = 一个尾点或一个 %2E 就能绕过同源阻断(交叉审查)。 */
17
+ /**
18
+ * 连接串任何位置解码后出现 NUL(或原文里的 %00)都视为启动包字段注入:驱动把 NUL 当字段分隔符,
19
+ * 后面的内容(user=… / options=reference=…)会成为额外的启动参数,连接目标与身份判定脱节。
20
+ * 合法连接串不存在 NUL,整串一刀切,不再逐字段追(交叉审查:query 值里的 NUL 同样能注入)。
21
+ */
22
+ export function containsNul(connString) {
23
+ if (/%00/i.test(connString))
24
+ return true;
25
+ try {
26
+ return decodeURIComponent(connString).includes("\0");
27
+ }
28
+ catch {
29
+ return connString.includes("\0");
30
+ }
31
+ }
32
+ /** Supavisor 集群别名语法:用户名里出现字面量小写 `.cluster.`(handler_helpers.ex),之后全是别名。 */
33
+ export function isClusterAliasUsername(username) {
34
+ return username.includes(".cluster.");
35
+ }
17
36
  export function normalizeHost(hostname) {
18
37
  let decoded = hostname;
19
38
  try {
@@ -33,6 +52,8 @@ export function normalizeHost(hostname) {
33
52
  * 与恢复端(同源阻断)共用同一个身份判定。非 Supabase 形态返回 null。
34
53
  */
35
54
  export function projectRefOf(connString) {
55
+ if (containsNul(connString))
56
+ return null;
36
57
  try {
37
58
  const url = new URL(connString);
38
59
  const direct = normalizeHost(url.hostname).match(/^db\.([a-z0-9]{16,})\.supabase\.co$/);
@@ -44,7 +65,9 @@ export function projectRefOf(connString) {
44
65
  // 解码后含 NUL 一律不认:启动包用 NUL 分隔字段,`postgres%00options%00reference=…%00x.<ref>`
45
66
  // 在驱动/Supavisor 眼里是 user=postgres 外加一个 options 字段,身份判定看到的 ref 是假的(交叉审查)
46
67
  const username = decodeURIComponent(url.username);
47
- if (username.includes("\0"))
68
+ // `<user>.cluster.<alias>` 是 Supavisor 的保留语法(只认小写 `.cluster.`,别名可含点):
69
+ // 别名经成员关系解析到真正的项目,不是 ref,认不出就不猜;连接本身由 assertNoHostOverride 拒绝
70
+ if (isClusterAliasUsername(username))
48
71
  return null;
49
72
  const pooled = username.match(/^[\s\S]+\.([a-z0-9]{16,})$/);
50
73
  if (pooled && /\.pooler\.supabase\.com$/.test(normalizeHost(url.hostname)))
@@ -65,11 +88,80 @@ export function refFromStorageEndpoint(endpoint) {
65
88
  return null;
66
89
  }
67
90
  }
68
- export function spawnPgRestore(bin, args, extraEnv = {}) {
91
+ /**
92
+ * libpq 环境变量里会改写**连接到哪里、以谁的身份**的那些:剔除它们,子进程才只认 --dbname 里的
93
+ * URL。TLS 策略(PGSSLMODE / PGSSLROOTCERT…)、超时、编码、应用名这类**不改目标**的变量保留——
94
+ * 外部 Postgres 用户常靠 PGSSLMODE=require 上 TLS,一刀切剔除等于把他们降级到 prefer(交叉审查)。
95
+ * Supabase 主机的 TLS 由 dumpUrlFor 直接写进 URL(verify-full + 打包 CA),不依赖环境。
96
+ * 密码来源保留:URL 不带密码时 node-postgres 同样读 PGPASSWORD,也经 pgpass 模块读 PGPASSFILE,
97
+ * 两个客户端才对得上;URL 带密码时 credentialSafeDbArgs 会显式覆盖。
98
+ * 服务文件三件套(PGSERVICE / PGSERVICEFILE / PGSYSCONFDIR)剔除:pg_service.conf 能整段改写
99
+ * host/options,是 libpq 独有的旁路;URL 里的 ?service= 也在 assertNoHostOverride 拒绝。
100
+ */
101
+ const LIBPQ_TARGET_ENV = new Set([
102
+ "PGHOST",
103
+ "PGHOSTADDR",
104
+ "PGPORT",
105
+ "PGDATABASE",
106
+ "PGUSER",
107
+ "PGSERVICE",
108
+ "PGSERVICEFILE",
109
+ "PGSYSCONFDIR",
110
+ "PGTARGETSESSIONATTRS",
111
+ ]);
112
+ /**
113
+ * libpq 子进程(pg_dump / pg_restore)的环境。`supabaseHost` 为真时再剔除 PGOPTIONS:Supavisor 会从
114
+ * options 里读租户覆盖(reference=…),而 Node 客户端对 Supabase 主机已显式钉死 options;非 Supabase
115
+ * 主机两边都尊重用户的 PGOPTIONS——两个客户端在任何情况下看到的都是同一份连接语义。
116
+ */
117
+ export function libpqChildEnv(extraEnv = {}, base = process.env, opts = { supabaseHost: false }) {
118
+ const env = {};
119
+ // node-postgres 独有的 sslmode 值:no-verify = 加密但**不验证书**。libpq 里最接近的是 require,
120
+ // 但 require 一旦找到根证书就升级成 verify-ca(显式 sslrootcert、sslrootcert=system,或默认
121
+ // 发现 ~/.postgresql/root.crt 都算),遇到 sslrootcert=system 还直接拒连。翻译时:去掉 CRL 变量,
122
+ // 并把 PGSSLROOTCERT 指向一个不存在的路径——libpq 对显式路径 stat 失败时在 require 模式下
123
+ // 跳过验证、且不再去找默认的 root.crt,子进程才真的是"加密、不验",与 Node 侧一致(交叉审查)。
124
+ // URL 里自带 sslmode 时它压过环境变量(两个客户端都如此),这时不做 no-verify 翻译——否则
125
+ // 哨兵根证书会让 verify-full 因"证书文件不存在"而拒连(交叉审查)
126
+ const noVerify = base.PGSSLMODE === "no-verify" && !opts.urlSslMode;
127
+ for (const [key, value] of Object.entries(base)) {
128
+ if (LIBPQ_TARGET_ENV.has(key))
129
+ continue;
130
+ if (opts.supabaseHost && key === "PGOPTIONS")
131
+ continue;
132
+ if (noVerify) {
133
+ if (key === "PGSSLMODE") {
134
+ env[key] = "require";
135
+ continue;
136
+ }
137
+ if (key === "PGSSLROOTCERT" || key === "PGSSLCRL" || key === "PGSSLCRLDIR")
138
+ continue;
139
+ }
140
+ env[key] = value;
141
+ }
142
+ if (noVerify)
143
+ env.PGSSLROOTCERT = NO_VERIFY_ROOTCERT_SENTINEL;
144
+ return { ...env, ...extraEnv };
145
+ }
146
+ /** 见 libpqChildEnv:一个绝不存在的根证书路径 = 让 libpq 的 require 既不验证也不去找默认 root.crt。 */
147
+ export const NO_VERIFY_ROOTCERT_SENTINEL = "/nonexistent/backupdrill-no-verify-root.crt";
148
+ /** 连接串 query 里的 sslmode(驱动语义:URL 优先于环境变量);解析不了或没写 → null。 */
149
+ export function urlSslModeOf(connString) {
150
+ try {
151
+ // 重复参数两个驱动都取最后一个;最后一个为空视同没写(pg 会回退环境变量)
152
+ const all = new URL(connString).searchParams.getAll("sslmode");
153
+ const last = all.length ? all[all.length - 1] : "";
154
+ return last === "" ? null : last;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
160
+ export function spawnPgRestore(bin, args, extraEnv = {}, opts = { supabaseHost: false }) {
69
161
  return new Promise((resolve, reject) => {
70
162
  const proc = spawn(bin, args, {
71
163
  stdio: ["ignore", "ignore", "pipe"],
72
- env: { ...process.env, ...extraEnv },
164
+ env: libpqChildEnv(extraEnv, process.env, opts),
73
165
  });
74
166
  let stderr = "";
75
167
  proc.stderr.on("data", (d) => (stderr += d.toString()));
@@ -257,41 +349,57 @@ export function credentialSafeDbArgs(connString) {
257
349
  * 目标)的 options 没有租户语义,照常放行。
258
350
  */
259
351
  export function assertNoHostOverride(connString) {
352
+ // 整串 NUL 检查放在 URL 解析之前:query 值、路径、任何位置的 NUL 都是启动包注入,
353
+ // libpq 稍后也会拒,但驱动侧先发出的启动包已经到了 pooler——在任何连接之前就拒
354
+ if (containsNul(connString)) {
355
+ throw new Error("connection string contains a NUL byte — use a plain connection string.");
356
+ }
357
+ // 解析不了的连接串直接拒:曾经"放行让 pg 自己报错",但 pg 的解析器比 WHATWG URL 宽
358
+ // (`postgresql://user@/db?host=…` 这类串 pg 接受、这里解析失败),放行 = 身份判定为空却照样连接
359
+ // (交叉审查)。校验逻辑放在 try 外面——曾按文案里有没有 "override" 决定重抛,新理由一措辞不同就被吞。
360
+ let url;
260
361
  try {
261
- const url = new URL(connString);
262
- // 用户名/密码/主机里解码后出现 NUL:启动包以 NUL 分隔字段,后面的内容会被当成额外的启动参数
263
- // (options=reference=… 之类),连接目标与身份判定脱节。libpq 稍后也会拒,但驱动侧先发出的
264
- // 启动包已经到了 pooler——在任何连接/身份判定之前就拒(交叉审查)。
265
- for (const [label, raw] of [["username", url.username], ["password", url.password], ["host", url.hostname]]) {
266
- let decoded = raw;
267
- try {
268
- decoded = decodeURIComponent(raw);
269
- }
270
- catch {
271
- // 非法编码:驱动同样解不开,按原样看
272
- }
273
- if (decoded.includes("\0")) {
274
- throw new Error(`connection string ${label} contains a NUL byte override — use a plain connection string.`);
275
- }
362
+ url = new URL(connString);
363
+ }
364
+ catch {
365
+ throw new Error("connection string could not be parsed as a URL — use a plain postgresql://user:password@host:port/database string.");
366
+ }
367
+ // 空 authority(postgresql:///db):主机只能来自环境变量或 ?host=,Node 与 libpq 子进程各自
368
+ // 回退到不同默认值,身份判定也无从谈起——必须显式写主机(交叉审查)
369
+ if (!url.hostname) {
370
+ throw new Error("connection string has no host — use a plain postgresql://user:password@host:port/database string.");
371
+ }
372
+ const params = url.searchParams;
373
+ const isSupabasePooler = /\.pooler\.supabase\.com$/.test(normalizeHost(url.hostname));
374
+ let username;
375
+ try {
376
+ username = decodeURIComponent(url.username);
377
+ }
378
+ catch {
379
+ // 非法百分号编码不能按原样放行:pg 会做部分解码(%75 → u,%GG 保留),这里看到的字符串
380
+ // 与驱动实际发出的用户名不同,别名/身份判定都可能被绕过(交叉审查)。解不开就拒。
381
+ throw new Error("connection string username has invalid percent-encoding — use a plain connection string.");
382
+ }
383
+ // 集群别名连接:我们解析不出它真正路由到哪个项目,身份判定为空 → 同源/目标一致性都没法保证,
384
+ // 在任何 I/O 之前拒绝,而不是让"null 身份"静默放行
385
+ if (isSupabasePooler && isClusterAliasUsername(username)) {
386
+ throw new Error("connection string uses Supavisor's <user>.cluster.<alias> syntax — BackupDrill cannot resolve which project " +
387
+ "the alias routes to, so identity checks cannot run. Use the project's own pooler string (<user>.<project-ref>).");
388
+ }
389
+ for (const key of params.keys()) {
390
+ // dbname 也算目标覆盖:libpq 让 ?dbname= 压过路径里的库名,node-postgres 却不认这个键——
391
+ // 预检看的是一个库、pg_restore 写的是另一个。service/servicefile 让 libpq 从 pg_service.conf
392
+ // 整段加载 host/options(含租户覆盖),node-postgres 不认——同样拒绝(交叉审查)。
393
+ // URLSearchParams 已把键解码,%64bname 这类编码形态同样命中。
394
+ if (/^(host|hostaddr|user|dbname|service|servicefile)$/i.test(key)) {
395
+ throw new Error(`connection string carries a ?${key}= override — the effective server/identity would ` +
396
+ `differ from the URL authority that identity checks inspect. Use a plain connection string.`);
276
397
  }
277
- const params = url.searchParams;
278
- const isSupabasePooler = /\.pooler\.supabase\.com$/.test(normalizeHost(url.hostname));
279
- for (const key of params.keys()) {
280
- if (/^(host|hostaddr|user)$/i.test(key)) {
281
- throw new Error(`connection string carries a ?${key}= override — the effective server/identity would ` +
282
- `differ from the URL authority that identity checks inspect. Use a plain connection string.`);
283
- }
284
- if (isSupabasePooler && /^options$/i.test(key)) {
285
- throw new Error("connection string carries ?options= on a Supabase pooler host — the pooler reads a tenant override " +
286
- "(reference=…) from it, so identity checks could not trust the username. Remove ?options= entirely.");
287
- }
398
+ if (isSupabasePooler && /^options$/i.test(key)) {
399
+ throw new Error("connection string carries ?options= on a Supabase pooler host — the pooler reads a tenant override " +
400
+ "(reference=…) from it, so identity checks could not trust the username. Remove ?options= entirely.");
288
401
  }
289
402
  }
290
- catch (error) {
291
- if (error.message.includes("override"))
292
- throw error;
293
- // URL 解析不了的连接串由 pg 自己报错,这里放行
294
- }
295
403
  }
296
404
  export async function restoreDatabaseArtifact(opts) {
297
405
  const bin = resolvePgRestoreBin();
@@ -300,12 +408,13 @@ export async function restoreDatabaseArtifact(opts) {
300
408
  // 中间人可拿到连接与数据);沙箱/外部主机原样透传。先改写 SSL 再拆密码。
301
409
  const { url, env } = credentialSafeDbArgs(dumpUrlFor(opts.connString));
302
410
  const common = ["--no-owner", "--no-privileges", "--dbname", url, opts.dumpPath];
303
- const first = await spawnPgRestore(bin, ["--section=pre-data", "--section=data", ...common], env);
411
+ const childOpts = { supabaseHost: isSupabaseHost(opts.connString), urlSslMode: urlSslModeOf(url) };
412
+ const first = await spawnPgRestore(bin, ["--section=pre-data", "--section=data", ...common], env, childOpts);
304
413
  const preData = finalizePass(first.code, first.stderr, classifyBlocks(first.stderr, SCHEMA_EXISTS_ERROR));
305
414
  if (preData.failures.length) {
306
415
  return { preData, postData: { expectedSkips: 0, failures: [] }, ok: false };
307
416
  }
308
- const second = await spawnPgRestore(bin, ["--section=post-data", ...common], env);
417
+ const second = await spawnPgRestore(bin, ["--section=post-data", ...common], env, childOpts);
309
418
  const postData = finalizePass(second.code, second.stderr, classifyBlocks(second.stderr, opts.target === "sandbox" ? SANDBOX_MANAGED_ERROR : NEVER_MATCH));
310
419
  return { preData, postData, ok: postData.failures.length === 0 };
311
420
  }
package/dist/restore.js CHANGED
@@ -322,6 +322,15 @@ async function dryRunPreflight(config, manifest, targetUrl, storageTarget) {
322
322
  * 否则下载到本地目录。这是 US-5 恢复向导的 CLI 形态。
323
323
  */
324
324
  export async function runRestore(config, opts) {
325
+ // 任何 I/O 之前先把源与目标的连接串过一遍覆盖/别名守卫:源串带集群别名或租户覆盖时
326
+ // 身份判定为空,同源阻断会失效——不能只查目标(交叉审查)。备份端 runBackup 对源做同样的事。
327
+ // 源守卫只在**有远端目标**时才需要:纯本地下载(无目标库、无目标 Storage)不写任何远端,
328
+ // 老快照的源配置哪怕不可考也应能下载出来(交叉审查)。
329
+ const hasRemoteTarget = Boolean(opts.targetDatabaseUrl || opts.targetSupabaseUrl);
330
+ if (hasRemoteTarget && config.databaseUrl !== NO_SOURCE_DATABASE)
331
+ assertNoHostOverride(config.databaseUrl);
332
+ if (opts.targetDatabaseUrl)
333
+ assertNoHostOverride(opts.targetDatabaseUrl);
325
334
  const s3 = targetClient(config);
326
335
  const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
327
336
  const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
@@ -341,8 +350,6 @@ export async function runRestore(config, opts) {
341
350
  serviceRoleKey: opts.targetServiceRoleKey,
342
351
  }
343
352
  : null;
344
- if (opts.targetDatabaseUrl)
345
- assertNoHostOverride(opts.targetDatabaseUrl);
346
353
  // 同源阻断(统一收口):目标(库或 Storage)的 ref 命中任何源项目 ref → 拒绝。
347
354
  // 覆盖 storage-only 组合:目标 Storage = 备份读取源的项目时,upsert 会改写源。
348
355
  const sourceRefs = sourceProjectRefs(config, manifest);
@@ -15,7 +15,19 @@ export declare function supabaseCaFile(): string;
15
15
  * ——libpq 里 URL 参数优先级高于环境变量,残留的 sslmode=require 会盖掉我们的 verify-full。
16
16
  */
17
17
  export declare function forceVerifyFull(databaseUrl: string, caPath: string): string;
18
+ export declare function isSupabaseHost(databaseUrl: string): boolean;
18
19
  /** pg_dump 用:Supabase 主机才改写成 verify-full;其它原样透传。 */
20
+ /**
21
+ * 把连接目标写死在连接串里:端口缺省 5432、库名缺省 postgres、并去掉**空的** options 参数。
22
+ * 为什么:node-pg 与 libpq 对"URL 里没写的字段"各自回退到环境变量(PGPORT / PGDATABASE /
23
+ * PGOPTIONS…)。子进程环境已剔除 PG*,而 Node 侧的预检客户端仍会读——两边可能连到不同的
24
+ * 库/端口,空目标检查看的是一个库、pg_restore 写的是另一个(交叉审查)。Node 客户端与
25
+ * pg_dump/pg_restore 都用规范化后的串,谁也不再依赖环境变量补字段。
26
+ * 空的 `?options=` 会让 pg 的 val() 回退读 PGOPTIONS,同样去掉;非空 options 是用户显式意图,
27
+ * 保留(pooler 主机上任何 options 已被 assertNoHostOverride 拒绝)。
28
+ * 解析不了的串原样返回:上游守卫(assertNoHostOverride / assertSafeDatabaseUrl)负责拒绝。
29
+ */
30
+ export declare function normalizeConnectionTarget(databaseUrl: string): string;
19
31
  export declare function dumpUrlFor(databaseUrl: string): string;
20
32
  /**
21
33
  * node-pg 连接选项:把"剥离连接串里的 SSL 参数"与"套上打包 CA"做成**原子**的一步。
@@ -27,8 +39,18 @@ export declare function dumpUrlFor(databaseUrl: string): string;
27
39
  *
28
40
  * 非 Supabase 主机(本 CLI 不限制目标)原样返回:不剥、不套,尊重用户自己的 sslmode。
29
41
  */
42
+ /**
43
+ * Supabase 主机的 pg Client 显式带 startup `options`。为什么:node-postgres 只在 config.options
44
+ * 为假值时才读环境变量 PGOPTIONS,而 Supavisor 会从 options 里解析 `reference=<ref>` 并让它优先于
45
+ * 用户名里的租户——继承的 PGOPTIONS 能把连接悄悄路由到别的项目(交叉审查)。给一个无害的真值就把
46
+ * 环境变量挡在门外(Supavisor 与直连 Postgres 都实测接受);顺带在 pg_stat_activity 里能认出是谁在连。
47
+ * 只对 Supabase 主机做:租户覆盖只存在于 Supavisor,而 PgBouncer 这类中间件默认拒绝陌生的启动参数
48
+ * (unsupported startup parameter: options),别给自带 Postgres 的用户制造回归。
49
+ */
50
+ export declare const PG_CLIENT_OPTIONS = "-c application_name=backupdrill";
30
51
  export declare function pgConnectOptions(databaseUrl: string): {
31
52
  connectionString: string;
53
+ options?: string;
32
54
  ssl?: typeof SUPABASE_SSL;
33
55
  };
34
56
  /**
@@ -214,15 +214,126 @@ function effectiveHost(databaseUrl) {
214
214
  const host = (last !== "" ? last : decodedAuthority).toLowerCase().replace(/\.$/, "");
215
215
  return host || null;
216
216
  }
217
- function isSupabaseHost(databaseUrl) {
217
+ export function isSupabaseHost(databaseUrl) {
218
218
  const host = effectiveHost(databaseUrl);
219
219
  return host !== null && SUPABASE_HOST.test(host);
220
220
  }
221
221
  /** pg_dump 用:Supabase 主机才改写成 verify-full;其它原样透传。 */
222
+ /**
223
+ * 把连接目标写死在连接串里:端口缺省 5432、库名缺省 postgres、并去掉**空的** options 参数。
224
+ * 为什么:node-pg 与 libpq 对"URL 里没写的字段"各自回退到环境变量(PGPORT / PGDATABASE /
225
+ * PGOPTIONS…)。子进程环境已剔除 PG*,而 Node 侧的预检客户端仍会读——两边可能连到不同的
226
+ * 库/端口,空目标检查看的是一个库、pg_restore 写的是另一个(交叉审查)。Node 客户端与
227
+ * pg_dump/pg_restore 都用规范化后的串,谁也不再依赖环境变量补字段。
228
+ * 空的 `?options=` 会让 pg 的 val() 回退读 PGOPTIONS,同样去掉;非空 options 是用户显式意图,
229
+ * 保留(pooler 主机上任何 options 已被 assertNoHostOverride 拒绝)。
230
+ * 解析不了的串原样返回:上游守卫(assertNoHostOverride / assertSafeDatabaseUrl)负责拒绝。
231
+ */
232
+ export function normalizeConnectionTarget(databaseUrl) {
233
+ let url;
234
+ try {
235
+ url = new URL(databaseUrl);
236
+ }
237
+ catch {
238
+ return databaseUrl;
239
+ }
240
+ // 空 authority 形态(postgresql:///db?host=…):URL 解析得到空主机,node-pg 却能接受——这类串由
241
+ // dumpUrlFor 的主机识别与 assertNoHostOverride 的 ?host= 拒绝各自处理,这里不碰、不猜
242
+ if (!url.hostname)
243
+ return databaseUrl;
244
+ if (!url.port)
245
+ url.port = "5432";
246
+ // 没有用户名就没有可钉死的身份与缺省库名(libpq 退回操作系统用户、node-postgres 退回 PGUSER/
247
+ // PGDATABASE 环境变量,两边立刻分叉):直接拒绝,连接串必须自带用户名
248
+ if (!url.username) {
249
+ throw new Error("connection string must include a user name (postgresql://user:password@host:port/database).");
250
+ }
251
+ // 库名缺省 = 用户名:这是 libpq 与 node-postgres 共同的语义(两者都在 dbname 缺失时回退到
252
+ // user),写死它只是不让环境变量 PGDATABASE 插进来,不改变既有连接串的目标(交叉审查:
253
+ // 曾错写成 postgres,会让 postgresql://app:pw@host 这类外部库串静默换库)。
254
+ // 路径里的百分号编码两个客户端解法不同:libpq 全部解码,node-postgres(pg-connection-string)
255
+ // 用 decodeURI——保留字(/ ? # : @ & = + $ , ;)的编码不解。所以缺省库名用**解码后的用户名**
256
+ // 写进路径,让 URL 只对需要的字符做编码,再逐一验证两种解法得到同一个库名;做不到就要求显式库名。
257
+ if (!url.pathname || url.pathname === "/") {
258
+ let user;
259
+ try {
260
+ user = decodeURIComponent(url.username);
261
+ }
262
+ catch {
263
+ throw new Error("connection string user name has invalid percent-encoding.");
264
+ }
265
+ const explicit = "connection string omits the database name and the user name cannot serve as the default — add /<database> after the host.";
266
+ // / 破坏路径结构,? # 会被编成保留字序列(两边解法分叉),% 本身有歧义,控制字符不进路径
267
+ if (/[/?#%\u0000-\u001f\u007f]/.test(user) || user === "")
268
+ throw new Error(explicit);
269
+ url.pathname = `/${user}`;
270
+ const encoded = url.pathname.slice(1);
271
+ let libpqView;
272
+ let nodeView;
273
+ try {
274
+ libpqView = decodeURIComponent(encoded);
275
+ nodeView = decodeURI(encoded);
276
+ }
277
+ catch {
278
+ throw new Error(explicit);
279
+ }
280
+ if (libpqView !== user || nodeView !== user)
281
+ throw new Error(explicit);
282
+ }
283
+ // options 参数按"最后一个生效"的驱动语义处理(pg-connection-string 与 libpq 对重复参数都取最后
284
+ // 一个):最后一个 options 为空 → 整组 options 全部剔除(空值会让 pg 回退读 PGOPTIONS;只删空的
285
+ // 那份会让更早的非空 options 复活,改变语义——交叉审查);最后一个非空 → 原样保留整组。
286
+ // 按原文操作、不经 URLSearchParams 重新序列化,否则其它参数值里的 %20 会被改写成 +
287
+ // (pg 解成空格、libpq 按字面 + 处理,两边密码就对不上)。
288
+ if (url.search) {
289
+ // 空片段(?&x=1)与无 = 的裸键(?options&…)Node 能容忍、libpq 报 "missing key/value separator":
290
+ // 一律清掉,两边看到同一份 query。键按**大小写敏感**比对:驱动的键是大小写敏感的
291
+ // (OPTIONS 对 pg 是另一个键、对 libpq 是非法关键字),按不敏感归组会把合法的小写 options 一起删掉。
292
+ const pairs = url.search.slice(1).split("&").filter((pair) => pair !== "");
293
+ const keyOf = (pair) => {
294
+ const eq = pair.indexOf("=");
295
+ const rawKey = eq === -1 ? pair : pair.slice(0, eq);
296
+ try {
297
+ return decodeURIComponent(rawKey);
298
+ }
299
+ catch {
300
+ return rawKey; // 编码坏了按原文比对
301
+ }
302
+ };
303
+ const valueOf = (pair) => {
304
+ const eq = pair.indexOf("=");
305
+ return eq === -1 ? "" : pair.slice(eq + 1);
306
+ };
307
+ const optionPairs = pairs.filter((pair) => keyOf(pair) === "options");
308
+ const lastIsEmpty = optionPairs.length > 0 && valueOf(optionPairs[optionPairs.length - 1]) === "";
309
+ // 其它键的裸形态(?sslmode=disable&sslmode)两边语义对不上:Node 取最后一个(空 → 回退环境变量),
310
+ // libpq 直接报错;静默删掉会让更早的值(这里是 disable = 明文)复活。不猜,拒绝(交叉审查)。
311
+ const bareOther = pairs.find((pair) => !pair.includes("=") && keyOf(pair) !== "options");
312
+ if (bareOther !== undefined) {
313
+ throw new Error(`connection string parameter "${keyOf(bareOther)}" has no value — remove it or give it a value.`);
314
+ }
315
+ // 空的 sslmode(?sslmode=)两边对不上:libpq 报 invalid sslmode value,Node 回退环境变量——拒绝。
316
+ // 只管 sslmode:空 host= 等已有各自的既定处理(stripSslParams / forceVerifyFull 那套),不在这里动
317
+ // 两个驱动都取最后一个 sslmode:只有最后一个为空才算空(?sslmode=&sslmode=verify-full 是合法的)
318
+ const sslModePairs = pairs.filter((pair) => keyOf(pair) === "sslmode" && pair.includes("="));
319
+ if (sslModePairs.length > 0 && valueOf(sslModePairs[sslModePairs.length - 1]) === "") {
320
+ throw new Error('connection string parameter "sslmode" is empty — remove it or give it a value.');
321
+ }
322
+ const kept = pairs.filter((pair) => {
323
+ // options 组:最后一个为空/裸 → 整组删;否则只留带 = 的(裸的已被后面的值取代,libpq 会报错)
324
+ if (keyOf(pair) === "options")
325
+ return !lastIsEmpty && pair.includes("=");
326
+ return true;
327
+ });
328
+ url.search = kept.length ? `?${kept.join("&")}` : "";
329
+ }
330
+ return url.toString();
331
+ }
222
332
  export function dumpUrlFor(databaseUrl) {
223
- return isSupabaseHost(databaseUrl)
224
- ? forceVerifyFull(databaseUrl, supabaseCaFile())
225
- : databaseUrl;
333
+ const normalized = normalizeConnectionTarget(databaseUrl);
334
+ return isSupabaseHost(normalized)
335
+ ? forceVerifyFull(normalized, supabaseCaFile())
336
+ : normalized;
226
337
  }
227
338
  /**
228
339
  * node-pg 连接选项:把"剥离连接串里的 SSL 参数"与"套上打包 CA"做成**原子**的一步。
@@ -234,10 +345,21 @@ export function dumpUrlFor(databaseUrl) {
234
345
  *
235
346
  * 非 Supabase 主机(本 CLI 不限制目标)原样返回:不剥、不套,尊重用户自己的 sslmode。
236
347
  */
348
+ /**
349
+ * Supabase 主机的 pg Client 显式带 startup `options`。为什么:node-postgres 只在 config.options
350
+ * 为假值时才读环境变量 PGOPTIONS,而 Supavisor 会从 options 里解析 `reference=<ref>` 并让它优先于
351
+ * 用户名里的租户——继承的 PGOPTIONS 能把连接悄悄路由到别的项目(交叉审查)。给一个无害的真值就把
352
+ * 环境变量挡在门外(Supavisor 与直连 Postgres 都实测接受);顺带在 pg_stat_activity 里能认出是谁在连。
353
+ * 只对 Supabase 主机做:租户覆盖只存在于 Supavisor,而 PgBouncer 这类中间件默认拒绝陌生的启动参数
354
+ * (unsupported startup parameter: options),别给自带 Postgres 的用户制造回归。
355
+ */
356
+ export const PG_CLIENT_OPTIONS = "-c application_name=backupdrill";
237
357
  export function pgConnectOptions(databaseUrl) {
238
- if (!isSupabaseHost(databaseUrl))
239
- return { connectionString: databaseUrl };
240
- return { connectionString: stripSslParams(databaseUrl), ssl: SUPABASE_SSL };
358
+ // 与 dumpUrlFor 同一份规范化:Node 客户端与 libpq 子进程看到的目标必须逐字段一致
359
+ const normalized = normalizeConnectionTarget(databaseUrl);
360
+ if (!isSupabaseHost(normalized))
361
+ return { connectionString: normalized };
362
+ return { connectionString: stripSslParams(normalized), options: PG_CLIENT_OPTIONS, ssl: SUPABASE_SSL };
241
363
  }
242
364
  /**
243
365
  * 给一个 pg Client 挂上 error 监听。**必须在 connect() 之前挂。**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
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": {