backupdrill 2.1.3 → 2.1.5
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 +3 -3
- package/dist/backup.js +11 -2
- package/dist/drill.js +11 -5
- package/dist/lib.d.ts +2 -2
- package/dist/lib.js +2 -2
- package/dist/restore-engine.d.ts +82 -3
- package/dist/restore-engine.js +265 -48
- package/dist/restore.js +9 -2
- package/dist/supabase-ca.d.ts +22 -0
- package/dist/supabase-ca.js +129 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# backupdrill
|
|
1
|
+
# backupdrill — free, open-source Supabase backup CLI
|
|
2
2
|
|
|
3
3
|
Open-source CLI to back up a **Supabase** project to **your own** S3/R2/B2 bucket — the Postgres database and, once you configure its S3 keys, your Storage files — with a checksummed `manifest.json` so you can prove what was captured.
|
|
4
4
|
|
|
@@ -6,7 +6,7 @@ Backup is half the job. `backupdrill drill` restores your latest snapshot into a
|
|
|
6
6
|
|
|
7
7
|
Backups stream through the CLI on your machine (or your CI) straight to your bucket. **Your data never touches our servers, because there are none — this is just a CLI.**
|
|
8
8
|
|
|
9
|
-
>
|
|
9
|
+
> This is the exact engine the hosted service runs in production. Want it on a schedule, with drills that run themselves, alerts, and emailed reports? That's **[backupdrill.com](https://backupdrill.com)**. This CLI is the DIY layer; the cloud adds scheduling, alerting, and reports on top.
|
|
10
10
|
|
|
11
11
|
🎥 Prefer watching? [Where do your backups actually go — explained in about a minute](https://backupdrill.com/guides/test-supabase-backup-restore#explainer-video).
|
|
12
12
|
|
|
@@ -160,7 +160,7 @@ backupdrill drill --check-cmd "npm run smoke" # BACKUPDRILL_SANDBOX_URL points
|
|
|
160
160
|
|
|
161
161
|
Your command runs after all structural checks pass, with `BACKUPDRILL_SANDBOX_URL` set to the sandbox's connection string. Exit 0 means pass; the result shows up in the report as an `app checks` line, and a non-zero exit fails the drill (the report keeps structural and app-level failures clearly separate). Entirely optional — without the flag, nothing changes and the report has no `app checks` line. There's a 10-minute timeout (the whole process tree is killed), and `--keep` leaves the sandbox container running when a drill fails so you can inspect it — the report JSON then includes a `keptSandbox` object with the container id and connection string (`docker rm -f <id>` when done).
|
|
162
162
|
|
|
163
|
-
**What this can and cannot verify, honestly:** `BACKUPDRILL_SANDBOX_URL` connects as the sandbox **superuser**, and superusers bypass row-level security —
|
|
163
|
+
**What this can and cannot verify, honestly:** `BACKUPDRILL_SANDBOX_URL` connects as the sandbox **superuser**, and superusers bypass row-level security. The sandbox does carry a stub of the Supabase runtime — an `auth` schema whose `uid()`/`jwt()`/`role()`/`email()` return null, and the `anon`/`authenticated`/`service_role` roles — so functions, column defaults and policies that reference them restore instead of being skipped. The sandbox deliberately does not create Supabase-managed *tables* such as `auth.users`: an index, constraint or policy that points at one is reported as a skipped managed object (these are restored in a second, post-data pass where that exemption applies), while a *view* over one is created in the strict first pass and fails the drill — a real gap in what a public-only backup can rebuild, shown rather than hidden. A function *body* that selects from `auth.users` is different: PostgreSQL does not validate string-literal bodies (`AS $$ … $$`) during a restore (`check_function_bodies` is off in every dump), so such a function is recreated and the drill passes — it would only fail when called. The exception is an SQL-standard `BEGIN ATOMIC … END` body, which is parsed and dependency-checked at creation regardless of that setting, so one of those referencing a managed table fails the drill like a view does. That is exactly what `--check-cmd` is for: if your application depends on such a function, call it there. 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 — `auth.uid()` is a stub and you are a superuser; verifying RLS takes an authenticated client stack with real JWTs, which no restore sandbox can fake generically.
|
|
164
164
|
|
|
165
165
|
When you actually need to recover, `restore` puts the database back into a fresh
|
|
166
166
|
target project (through the same engine the drills use, with verification) and
|
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
|
-
],
|
|
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/drill.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { parseManifest } from "./manifest.js";
|
|
7
7
|
import { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
|
|
8
|
-
import { SANDBOX_MANAGED_ERROR, classifyBlocks, finalizePass, installExtensions, quoteIdent, restoreDatabaseArtifact, } from "./restore-engine.js";
|
|
8
|
+
import { SANDBOX_MANAGED_ERROR, classifyBlocks, finalizePass, installExtensions, quoteIdent, restoreDatabaseArtifact, installSandboxShim, } from "./restore-engine.js";
|
|
9
9
|
import { connectPg } from "./supabase-ca.js";
|
|
10
10
|
import { log } from "./log.js";
|
|
11
11
|
const execFileAsync = promisify(execFile);
|
|
@@ -112,8 +112,8 @@ export function postDataResult(code, stderr) {
|
|
|
112
112
|
* 的托管对象跳过如实返回。pre-data 的 schema 冲突跳过(dump 自带 CREATE SCHEMA,
|
|
113
113
|
* 容器恒有 public)是非事件,不计入报告。
|
|
114
114
|
*/
|
|
115
|
-
async function pgRestore(dumpPath, connString) {
|
|
116
|
-
const result = await restoreDatabaseArtifact({ dumpPath, connString, target: "sandbox" });
|
|
115
|
+
async function pgRestore(dumpPath, connString, dumpedSchemas) {
|
|
116
|
+
const result = await restoreDatabaseArtifact({ dumpPath, connString, target: "sandbox", dumpedSchemas });
|
|
117
117
|
if (result.preData.failures.length) {
|
|
118
118
|
throw new Error(`pg_restore failed: ${result.preData.failures.join(" | ")}`);
|
|
119
119
|
}
|
|
@@ -327,6 +327,12 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
|
|
|
327
327
|
// 旧 manifest(≤0.1.1)没有 extensions 字段 → 不装任何扩展,行为与从前一致
|
|
328
328
|
const extensions = manifest.database.extensions ?? [];
|
|
329
329
|
const unavailable = await installExtensions(pg.connString, extensions);
|
|
330
|
+
// Supabase 运行面的桩(auth.uid() 等 + 标准角色):没有它,参数/列默认值里引用
|
|
331
|
+
// auth.uid() 的函数与表在 pre-data 就建不出来 —— 见 installSandboxShim 头注。
|
|
332
|
+
// 转储自己带了 auth schema 就不建函数,否则 pg_restore 会撞 "already exists"。
|
|
333
|
+
await installSandboxShim(pg.connString, {
|
|
334
|
+
authFunctions: !manifest.database.schemas.includes("auth"),
|
|
335
|
+
});
|
|
330
336
|
if (extensions.length) {
|
|
331
337
|
// 装不上 ≠ 演练失败:扩展本体不在 public 转储里,没丢任何已备份的数据;
|
|
332
338
|
// 若恢复真的需要它,下面的 pgRestore 会失败并给出分类错误
|
|
@@ -343,7 +349,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
|
|
|
343
349
|
log.step("Restoring into ephemeral Postgres…");
|
|
344
350
|
let postData;
|
|
345
351
|
try {
|
|
346
|
-
postData = await pgRestore(dumpPath, pg.connString);
|
|
352
|
+
postData = await pgRestore(dumpPath, pg.connString, manifest.database.schemas);
|
|
347
353
|
}
|
|
348
354
|
catch (error) {
|
|
349
355
|
// 沙箱装不上扩展 + 错误特征命中"缺类型/schema/扩展" → 假设式归因到沙箱环境
|
|
@@ -365,7 +371,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
|
|
|
365
371
|
detail: postData.failures.length > 0
|
|
366
372
|
? `${postData.failures.length} failed: ${postData.failures[0]}`
|
|
367
373
|
: postData.supabaseSkipped > 0
|
|
368
|
-
? `user objects restored; ${postData.supabaseSkipped} Supabase-managed object(s) skipped (auth
|
|
374
|
+
? `user objects restored; ${postData.supabaseSkipped} Supabase-managed object(s) skipped (Supabase-managed tables such as auth.users do not exist in the drill sandbox)`
|
|
369
375
|
: "all post-data objects restored",
|
|
370
376
|
});
|
|
371
377
|
const verify = await verifyRestored(pg.connString, manifest);
|
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";
|
package/dist/restore-engine.d.ts
CHANGED
|
@@ -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,16 +34,54 @@ export interface EngineResult {
|
|
|
26
34
|
postData: ClassifiedPass;
|
|
27
35
|
ok: boolean;
|
|
28
36
|
}
|
|
29
|
-
|
|
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
|
}>;
|
|
57
|
+
/**
|
|
58
|
+
* 沙箱 allowlist,**按 manifest 实际转储的 schema 生成**(交叉审查 2026-09-11 第五轮):
|
|
59
|
+
* CLI 用户用 BACKUPDRILL_SCHEMAS=public,auth 把 auth 也转了,那 auth 里的对象就是**他自己的**
|
|
60
|
+
* 备份内容 —— 缺了是真失败,不能再当托管缺席豁免;否则一条依赖 auth.normalize_key() 的
|
|
61
|
+
* 唯一索引建不出来,演练照样 PASS。这是 installSandboxShim "转储带 auth 就不建桩"的对称
|
|
62
|
+
* 另一半:桩不建、豁免也不给,两边口径一致。角色豁免不受影响 —— 转储里从来没有 CREATE ROLE。
|
|
63
|
+
*
|
|
64
|
+
* **两端锚定**(第四轮,终结子串匹配这一整类):匹配的是 classifyBlocks 抽出来的**整条主消息**,
|
|
65
|
+
* 必须从头到尾就是一条"缺对象"诊断。不锚定的话用户行值能借道混进来 —— 表达式索引 `(col::uuid)`
|
|
66
|
+
* 撞上一行内容恰好是 `relation "auth.users" does not exist` 的数据,主消息就是
|
|
67
|
+
* `invalid input syntax for type uuid: "relation "auth.users" does not exist"`,子串一匹配,
|
|
68
|
+
* 一条建失败的唯一索引就被报成了预期跳过。旧的 \bauth\.uid\b / \bauth\.jwt\b 裸子串已删:
|
|
69
|
+
* 它们唯一对应的真实诊断 `function auth.uid() does not exist` 由 function 分支覆盖。
|
|
70
|
+
*/
|
|
71
|
+
export declare function sandboxManagedError(dumpedSchemas?: string[]): RegExp;
|
|
72
|
+
/** 默认形态(什么托管 schema 都没转):兼容层与"只转 public"的托管 worker 路径。 */
|
|
33
73
|
export declare const SANDBOX_MANAGED_ERROR: RegExp;
|
|
34
74
|
export declare const SCHEMA_EXISTS_ERROR: RegExp;
|
|
35
75
|
/**
|
|
36
76
|
* 把 pg_restore 的 stderr 拆成单个错误块并按 allowlist 分类。
|
|
37
|
-
*
|
|
38
|
-
*
|
|
77
|
+
* **只对 ERROR 那一行分类**:不看 "Command was:" 之后的语句文本,也不看 DETAIL /
|
|
78
|
+
* CONTEXT / LINE 行。前者的理由:一个恰好引用 auth.uid 的用户对象因"损坏/语法错误"
|
|
79
|
+
* 失败时,会被误判成预期跳过。后者(交叉审查 2026-09-11 复现):DETAIL 里带的是**行值**
|
|
80
|
+
* —— `Key (message)=(relation "auth.users" does not exist) already exists` 是用户的数据,
|
|
81
|
+
* 不是错误原因,拿它匹配等于让用户数据决定演练结论;一条失败的唯一索引就能这样混过去。
|
|
82
|
+
* Postgres 的主消息恒为单行,且在块的第一行(`pg_restore: error: ... ERROR: …`)。
|
|
83
|
+
* 抽出 `ERROR:` 之后的那段作为主消息,allowlist 对它**整条**匹配(两端锚定,见上);
|
|
84
|
+
* 没有 `ERROR:` 前缀的块(归档器致命错等)整行当主消息,锚定的 allowlist 不会认它 → 失败。
|
|
39
85
|
*/
|
|
40
86
|
export declare function classifyBlocks(stderr: string, allow: RegExp): ClassifiedPass;
|
|
41
87
|
/**
|
|
@@ -56,6 +102,37 @@ export declare function quoteIdent(name: string): string;
|
|
|
56
102
|
* 本就装不上,public 转储通常不引用),真实目标应当阻断(用户能在 dashboard 开启)。
|
|
57
103
|
*/
|
|
58
104
|
export declare function installExtensions(connString: string, extensions: ExtensionInfo[]): Promise<string[]>;
|
|
105
|
+
/**
|
|
106
|
+
* 沙箱专用:预置 Supabase 的最小运行面,让**引用**它的用户对象能建出来被验证,而不是
|
|
107
|
+
* 被当作"托管对象跳过"。只装桩,不装数据 —— 演练验证的是用户自己的 schema。
|
|
108
|
+
*
|
|
109
|
+
* 触因(2026-09-11,真实用户 105 表 12 GB 库的首次演练):`create function
|
|
110
|
+
* public.is_admin(p uuid default auth.uid())` —— 函数在 pre-data,参数默认值建函数时
|
|
111
|
+
* 就要解析,沙箱没有 auth schema 就整遍硬失败。同一根因还会吃掉更常见的
|
|
112
|
+
* `create table ... (created_by uuid default auth.uid())`:表建不出来、数据也没了,
|
|
113
|
+
* 演练只能报"缺表"。把 pre-data 的容错放宽不解决后者;补上运行面两者都解决。
|
|
114
|
+
*
|
|
115
|
+
* 桩的形状:
|
|
116
|
+
* auth.uid() → uuid、auth.jwt() → jsonb、auth.role() → text、auth.email() → text,
|
|
117
|
+
* 全部返回 null(恢复过程中不会真调它们:COPY 不求值列默认,策略/函数体不执行);
|
|
118
|
+
* 角色 anon / authenticated / service_role(nologin):RLS 策略的 `to authenticated`
|
|
119
|
+
* 要它们在场才能建。转储带 --no-privileges,不会有 GRANT 引用这些角色。
|
|
120
|
+
* 刻意不建 auth.users 之类的表:空的 auth.users 会让 FK 校验在 post-data 真失败,
|
|
121
|
+
* 比"缺表跳过"更糟。FK → auth.users 仍按 SANDBOX_MANAGED_ERROR 的 relation 形态跳过。
|
|
122
|
+
*
|
|
123
|
+
* 转储自带 auth schema 时(CLI 的 BACKUPDRILL_SCHEMAS=public,auth,自托管场景)**不建函数**:
|
|
124
|
+
* pg_restore 的 CREATE FUNCTION auth.uid() 会撞我们的桩报 "already exists",而那是 pre-data、
|
|
125
|
+
* 全严格 → 一次本来能过的演练被 shim 弄挂(交叉审查 2026-09-11)。角色照建:转储里
|
|
126
|
+
* 从来没有 CREATE ROLE。
|
|
127
|
+
*
|
|
128
|
+
* **绝不对真实 Supabase 目标调用**:那里 auth 是真的,create or replace 会覆盖平台函数。
|
|
129
|
+
*/
|
|
130
|
+
export declare function sandboxShimSql(opts: {
|
|
131
|
+
authFunctions: boolean;
|
|
132
|
+
}): string;
|
|
133
|
+
export declare function installSandboxShim(connString: string, opts: {
|
|
134
|
+
authFunctions: boolean;
|
|
135
|
+
}): Promise<void>;
|
|
59
136
|
/**
|
|
60
137
|
* 两遍恢复(真实首演炸出的设计,现为 drill 与真实恢复共用):
|
|
61
138
|
* 1. pre-data+data —— 表结构 + 数据。除 schema 冲突(预期)外任何错误都是硬失败。
|
|
@@ -86,4 +163,6 @@ export declare function restoreDatabaseArtifact(opts: {
|
|
|
86
163
|
dumpPath: string;
|
|
87
164
|
connString: string;
|
|
88
165
|
target: RestoreTargetKind;
|
|
166
|
+
/** manifest.database.schemas:沙箱 allowlist 据此剔掉转储自带的托管 schema(见 sandboxManagedError)。 */
|
|
167
|
+
dumpedSchemas?: string[];
|
|
89
168
|
}): Promise<EngineResult>;
|
package/dist/restore-engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
164
|
+
env: libpqChildEnv(extraEnv, process.env, opts),
|
|
73
165
|
});
|
|
74
166
|
let stderr = "";
|
|
75
167
|
proc.stderr.on("data", (d) => (stderr += d.toString()));
|
|
@@ -79,18 +171,70 @@ export function spawnPgRestore(bin, args, extraEnv = {}) {
|
|
|
79
171
|
proc.on("close", (code) => resolve({ code, stderr }));
|
|
80
172
|
});
|
|
81
173
|
}
|
|
82
|
-
// 演练沙箱是裸 Postgres,Supabase 托管的 schema
|
|
83
|
-
//
|
|
84
|
-
// 真实 Supabase
|
|
85
|
-
|
|
174
|
+
// 演练沙箱是裸 Postgres,Supabase 托管的 schema/表必然缺席。post-data 里引用它们的
|
|
175
|
+
// 失败是"环境预期",不是备份坏了;其余失败才是演练要抓的。
|
|
176
|
+
// 真实 Supabase 目标不适用:托管 schema/角色恒在,post-data 全严格。
|
|
177
|
+
//
|
|
178
|
+
// 沙箱经 installSandboxShim 预置了 auth schema 的桩函数与三个标准角色(2026-09-11),
|
|
179
|
+
// 所以缺席的形态从 schema/role 级变成了 relation/function 级(FK → auth.users、
|
|
180
|
+
// 调到没桩的 auth.xxx())。旧形态保留:manifest 更老的路径、或 shim 本身没装上时仍会出现。
|
|
181
|
+
// 托管 schema 两张清单。relation/function 级豁免**刻意不含 extensions**(交叉审查
|
|
182
|
+
// 2026-09-11 复现):那个 schema 装的是扩展对象,installExtensions 能装的都装了;里面还缺
|
|
183
|
+
// 函数/表 = 扩展没装上,或用户把自己的函数放进了 extensions —— 两种都该让演练失败并走
|
|
184
|
+
// "沙箱装不上扩展"的归因,吞成跳过就是给一份缺索引的备份盖"通过"章。schema 级形态保留
|
|
185
|
+
// (那是 schema 本身没建出来的老路径)。条目是正则片段(graphql[a-z_]* 覆盖 graphql_public)。
|
|
186
|
+
const MANAGED_SCHEMA_PATTERNS = ["auth", "storage", "realtime", "vault", "extensions", "graphql[a-z_]*"];
|
|
187
|
+
const MANAGED_OBJECT_SCHEMA_PATTERNS = ["auth", "storage", "realtime", "vault", "graphql[a-z_]*"];
|
|
188
|
+
/** 从托管清单里剔掉**这份转储自己带了**的 schema:带了就不再是"沙箱预期缺席"。 */
|
|
189
|
+
function notDumped(patterns, dumpedSchemas) {
|
|
190
|
+
return patterns.filter((pattern) => !dumpedSchemas.some((schema) => new RegExp(`^(?:${pattern})$`, "i").test(schema)));
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 沙箱 allowlist,**按 manifest 实际转储的 schema 生成**(交叉审查 2026-09-11 第五轮):
|
|
194
|
+
* CLI 用户用 BACKUPDRILL_SCHEMAS=public,auth 把 auth 也转了,那 auth 里的对象就是**他自己的**
|
|
195
|
+
* 备份内容 —— 缺了是真失败,不能再当托管缺席豁免;否则一条依赖 auth.normalize_key() 的
|
|
196
|
+
* 唯一索引建不出来,演练照样 PASS。这是 installSandboxShim "转储带 auth 就不建桩"的对称
|
|
197
|
+
* 另一半:桩不建、豁免也不给,两边口径一致。角色豁免不受影响 —— 转储里从来没有 CREATE ROLE。
|
|
198
|
+
*
|
|
199
|
+
* **两端锚定**(第四轮,终结子串匹配这一整类):匹配的是 classifyBlocks 抽出来的**整条主消息**,
|
|
200
|
+
* 必须从头到尾就是一条"缺对象"诊断。不锚定的话用户行值能借道混进来 —— 表达式索引 `(col::uuid)`
|
|
201
|
+
* 撞上一行内容恰好是 `relation "auth.users" does not exist` 的数据,主消息就是
|
|
202
|
+
* `invalid input syntax for type uuid: "relation "auth.users" does not exist"`,子串一匹配,
|
|
203
|
+
* 一条建失败的唯一索引就被报成了预期跳过。旧的 \bauth\.uid\b / \bauth\.jwt\b 裸子串已删:
|
|
204
|
+
* 它们唯一对应的真实诊断 `function auth.uid() does not exist` 由 function 分支覆盖。
|
|
205
|
+
*/
|
|
206
|
+
export function sandboxManagedError(dumpedSchemas = []) {
|
|
207
|
+
const schemas = notDumped(MANAGED_SCHEMA_PATTERNS, dumpedSchemas).join("|");
|
|
208
|
+
const objects = notDumped(MANAGED_OBJECT_SCHEMA_PATTERNS, dumpedSchemas).join("|");
|
|
209
|
+
const alternatives = [
|
|
210
|
+
...(schemas ? [`schema "(${schemas})" does not exist`] : []),
|
|
211
|
+
...(objects
|
|
212
|
+
? [
|
|
213
|
+
`relation "(${objects})\\.[^"]+" does not exist`,
|
|
214
|
+
// 完整签名:auth.can_read(uuid, uuid) / auth.x(character varying) 都带空格
|
|
215
|
+
`function (${objects})\\.[^(]+\\([^)]*\\) does not exist`,
|
|
216
|
+
]
|
|
217
|
+
: []),
|
|
218
|
+
`role "(authenticated|anon|service_role|supabase_[a-z_]+)" does not exist`,
|
|
219
|
+
];
|
|
220
|
+
return new RegExp(`^(?:${alternatives.join("|")})$`, "i");
|
|
221
|
+
}
|
|
222
|
+
/** 默认形态(什么托管 schema 都没转):兼容层与"只转 public"的托管 worker 路径。 */
|
|
223
|
+
export const SANDBOX_MANAGED_ERROR = sandboxManagedError();
|
|
86
224
|
// pre-data 唯一的预期冲突(见文件头注 3)。目标空门/新容器保证没有其他冲突源,
|
|
87
|
-
// 任何别的 "already exists"
|
|
88
|
-
export const SCHEMA_EXISTS_ERROR =
|
|
225
|
+
// 任何别的 "already exists" 都是真冲突,必须失败。同样两端锚定。
|
|
226
|
+
export const SCHEMA_EXISTS_ERROR = /^schema "[^"]+" already exists$/i;
|
|
89
227
|
const NEVER_MATCH = /(?!)/;
|
|
90
228
|
/**
|
|
91
229
|
* 把 pg_restore 的 stderr 拆成单个错误块并按 allowlist 分类。
|
|
92
|
-
*
|
|
93
|
-
*
|
|
230
|
+
* **只对 ERROR 那一行分类**:不看 "Command was:" 之后的语句文本,也不看 DETAIL /
|
|
231
|
+
* CONTEXT / LINE 行。前者的理由:一个恰好引用 auth.uid 的用户对象因"损坏/语法错误"
|
|
232
|
+
* 失败时,会被误判成预期跳过。后者(交叉审查 2026-09-11 复现):DETAIL 里带的是**行值**
|
|
233
|
+
* —— `Key (message)=(relation "auth.users" does not exist) already exists` 是用户的数据,
|
|
234
|
+
* 不是错误原因,拿它匹配等于让用户数据决定演练结论;一条失败的唯一索引就能这样混过去。
|
|
235
|
+
* Postgres 的主消息恒为单行,且在块的第一行(`pg_restore: error: ... ERROR: …`)。
|
|
236
|
+
* 抽出 `ERROR:` 之后的那段作为主消息,allowlist 对它**整条**匹配(两端锚定,见上);
|
|
237
|
+
* 没有 `ERROR:` 前缀的块(归档器致命错等)整行当主消息,锚定的 allowlist 不会认它 → 失败。
|
|
94
238
|
*/
|
|
95
239
|
export function classifyBlocks(stderr, allow) {
|
|
96
240
|
const blocks = stderr
|
|
@@ -99,8 +243,9 @@ export function classifyBlocks(stderr, allow) {
|
|
|
99
243
|
let expectedSkips = 0;
|
|
100
244
|
const failures = [];
|
|
101
245
|
for (const block of blocks) {
|
|
102
|
-
const
|
|
103
|
-
|
|
246
|
+
const firstLine = block.split("\n")[0];
|
|
247
|
+
const message = firstLine.replace(/^.*?\bERROR:\s+/, "").trim();
|
|
248
|
+
if (allow.test(message)) {
|
|
104
249
|
expectedSkips += 1;
|
|
105
250
|
}
|
|
106
251
|
else {
|
|
@@ -164,6 +309,61 @@ export async function installExtensions(connString, extensions) {
|
|
|
164
309
|
}
|
|
165
310
|
return unavailable;
|
|
166
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* 沙箱专用:预置 Supabase 的最小运行面,让**引用**它的用户对象能建出来被验证,而不是
|
|
314
|
+
* 被当作"托管对象跳过"。只装桩,不装数据 —— 演练验证的是用户自己的 schema。
|
|
315
|
+
*
|
|
316
|
+
* 触因(2026-09-11,真实用户 105 表 12 GB 库的首次演练):`create function
|
|
317
|
+
* public.is_admin(p uuid default auth.uid())` —— 函数在 pre-data,参数默认值建函数时
|
|
318
|
+
* 就要解析,沙箱没有 auth schema 就整遍硬失败。同一根因还会吃掉更常见的
|
|
319
|
+
* `create table ... (created_by uuid default auth.uid())`:表建不出来、数据也没了,
|
|
320
|
+
* 演练只能报"缺表"。把 pre-data 的容错放宽不解决后者;补上运行面两者都解决。
|
|
321
|
+
*
|
|
322
|
+
* 桩的形状:
|
|
323
|
+
* auth.uid() → uuid、auth.jwt() → jsonb、auth.role() → text、auth.email() → text,
|
|
324
|
+
* 全部返回 null(恢复过程中不会真调它们:COPY 不求值列默认,策略/函数体不执行);
|
|
325
|
+
* 角色 anon / authenticated / service_role(nologin):RLS 策略的 `to authenticated`
|
|
326
|
+
* 要它们在场才能建。转储带 --no-privileges,不会有 GRANT 引用这些角色。
|
|
327
|
+
* 刻意不建 auth.users 之类的表:空的 auth.users 会让 FK 校验在 post-data 真失败,
|
|
328
|
+
* 比"缺表跳过"更糟。FK → auth.users 仍按 SANDBOX_MANAGED_ERROR 的 relation 形态跳过。
|
|
329
|
+
*
|
|
330
|
+
* 转储自带 auth schema 时(CLI 的 BACKUPDRILL_SCHEMAS=public,auth,自托管场景)**不建函数**:
|
|
331
|
+
* pg_restore 的 CREATE FUNCTION auth.uid() 会撞我们的桩报 "already exists",而那是 pre-data、
|
|
332
|
+
* 全严格 → 一次本来能过的演练被 shim 弄挂(交叉审查 2026-09-11)。角色照建:转储里
|
|
333
|
+
* 从来没有 CREATE ROLE。
|
|
334
|
+
*
|
|
335
|
+
* **绝不对真实 Supabase 目标调用**:那里 auth 是真的,create or replace 会覆盖平台函数。
|
|
336
|
+
*/
|
|
337
|
+
export function sandboxShimSql(opts) {
|
|
338
|
+
const roles = `
|
|
339
|
+
do $$
|
|
340
|
+
declare r text;
|
|
341
|
+
begin
|
|
342
|
+
foreach r in array array['anon', 'authenticated', 'service_role'] loop
|
|
343
|
+
if not exists (select 1 from pg_roles where rolname = r) then
|
|
344
|
+
execute format('create role %I nologin', r);
|
|
345
|
+
end if;
|
|
346
|
+
end loop;
|
|
347
|
+
end $$;`;
|
|
348
|
+
if (!opts.authFunctions)
|
|
349
|
+
return roles;
|
|
350
|
+
return `
|
|
351
|
+
create schema if not exists auth;
|
|
352
|
+
create or replace function auth.uid() returns uuid language sql stable as $$ select null::uuid $$;
|
|
353
|
+
create or replace function auth.jwt() returns jsonb language sql stable as $$ select null::jsonb $$;
|
|
354
|
+
create or replace function auth.role() returns text language sql stable as $$ select null::text $$;
|
|
355
|
+
create or replace function auth.email() returns text language sql stable as $$ select null::text $$;
|
|
356
|
+
${roles}`;
|
|
357
|
+
}
|
|
358
|
+
export async function installSandboxShim(connString, opts) {
|
|
359
|
+
const client = await connectPg(connString);
|
|
360
|
+
try {
|
|
361
|
+
await client.query(sandboxShimSql(opts));
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
await client.end();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
167
367
|
/**
|
|
168
368
|
* 两遍恢复(真实首演炸出的设计,现为 drill 与真实恢复共用):
|
|
169
369
|
* 1. pre-data+data —— 表结构 + 数据。除 schema 冲突(预期)外任何错误都是硬失败。
|
|
@@ -257,41 +457,57 @@ export function credentialSafeDbArgs(connString) {
|
|
|
257
457
|
* 目标)的 options 没有租户语义,照常放行。
|
|
258
458
|
*/
|
|
259
459
|
export function assertNoHostOverride(connString) {
|
|
460
|
+
// 整串 NUL 检查放在 URL 解析之前:query 值、路径、任何位置的 NUL 都是启动包注入,
|
|
461
|
+
// libpq 稍后也会拒,但驱动侧先发出的启动包已经到了 pooler——在任何连接之前就拒
|
|
462
|
+
if (containsNul(connString)) {
|
|
463
|
+
throw new Error("connection string contains a NUL byte — use a plain connection string.");
|
|
464
|
+
}
|
|
465
|
+
// 解析不了的连接串直接拒:曾经"放行让 pg 自己报错",但 pg 的解析器比 WHATWG URL 宽
|
|
466
|
+
// (`postgresql://user@/db?host=…` 这类串 pg 接受、这里解析失败),放行 = 身份判定为空却照样连接
|
|
467
|
+
// (交叉审查)。校验逻辑放在 try 外面——曾按文案里有没有 "override" 决定重抛,新理由一措辞不同就被吞。
|
|
468
|
+
let url;
|
|
260
469
|
try {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
470
|
+
url = new URL(connString);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
throw new Error("connection string could not be parsed as a URL — use a plain postgresql://user:password@host:port/database string.");
|
|
474
|
+
}
|
|
475
|
+
// 空 authority(postgresql:///db):主机只能来自环境变量或 ?host=,Node 与 libpq 子进程各自
|
|
476
|
+
// 回退到不同默认值,身份判定也无从谈起——必须显式写主机(交叉审查)
|
|
477
|
+
if (!url.hostname) {
|
|
478
|
+
throw new Error("connection string has no host — use a plain postgresql://user:password@host:port/database string.");
|
|
479
|
+
}
|
|
480
|
+
const params = url.searchParams;
|
|
481
|
+
const isSupabasePooler = /\.pooler\.supabase\.com$/.test(normalizeHost(url.hostname));
|
|
482
|
+
let username;
|
|
483
|
+
try {
|
|
484
|
+
username = decodeURIComponent(url.username);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// 非法百分号编码不能按原样放行:pg 会做部分解码(%75 → u,%GG 保留),这里看到的字符串
|
|
488
|
+
// 与驱动实际发出的用户名不同,别名/身份判定都可能被绕过(交叉审查)。解不开就拒。
|
|
489
|
+
throw new Error("connection string username has invalid percent-encoding — use a plain connection string.");
|
|
490
|
+
}
|
|
491
|
+
// 集群别名连接:我们解析不出它真正路由到哪个项目,身份判定为空 → 同源/目标一致性都没法保证,
|
|
492
|
+
// 在任何 I/O 之前拒绝,而不是让"null 身份"静默放行
|
|
493
|
+
if (isSupabasePooler && isClusterAliasUsername(username)) {
|
|
494
|
+
throw new Error("connection string uses Supavisor's <user>.cluster.<alias> syntax — BackupDrill cannot resolve which project " +
|
|
495
|
+
"the alias routes to, so identity checks cannot run. Use the project's own pooler string (<user>.<project-ref>).");
|
|
496
|
+
}
|
|
497
|
+
for (const key of params.keys()) {
|
|
498
|
+
// dbname 也算目标覆盖:libpq 让 ?dbname= 压过路径里的库名,node-postgres 却不认这个键——
|
|
499
|
+
// 预检看的是一个库、pg_restore 写的是另一个。service/servicefile 让 libpq 从 pg_service.conf
|
|
500
|
+
// 整段加载 host/options(含租户覆盖),node-postgres 不认——同样拒绝(交叉审查)。
|
|
501
|
+
// URLSearchParams 已把键解码,%64bname 这类编码形态同样命中。
|
|
502
|
+
if (/^(host|hostaddr|user|dbname|service|servicefile)$/i.test(key)) {
|
|
503
|
+
throw new Error(`connection string carries a ?${key}= override — the effective server/identity would ` +
|
|
504
|
+
`differ from the URL authority that identity checks inspect. Use a plain connection string.`);
|
|
276
505
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
}
|
|
506
|
+
if (isSupabasePooler && /^options$/i.test(key)) {
|
|
507
|
+
throw new Error("connection string carries ?options= on a Supabase pooler host — the pooler reads a tenant override " +
|
|
508
|
+
"(reference=…) from it, so identity checks could not trust the username. Remove ?options= entirely.");
|
|
288
509
|
}
|
|
289
510
|
}
|
|
290
|
-
catch (error) {
|
|
291
|
-
if (error.message.includes("override"))
|
|
292
|
-
throw error;
|
|
293
|
-
// URL 解析不了的连接串由 pg 自己报错,这里放行
|
|
294
|
-
}
|
|
295
511
|
}
|
|
296
512
|
export async function restoreDatabaseArtifact(opts) {
|
|
297
513
|
const bin = resolvePgRestoreBin();
|
|
@@ -300,12 +516,13 @@ export async function restoreDatabaseArtifact(opts) {
|
|
|
300
516
|
// 中间人可拿到连接与数据);沙箱/外部主机原样透传。先改写 SSL 再拆密码。
|
|
301
517
|
const { url, env } = credentialSafeDbArgs(dumpUrlFor(opts.connString));
|
|
302
518
|
const common = ["--no-owner", "--no-privileges", "--dbname", url, opts.dumpPath];
|
|
303
|
-
const
|
|
519
|
+
const childOpts = { supabaseHost: isSupabaseHost(opts.connString), urlSslMode: urlSslModeOf(url) };
|
|
520
|
+
const first = await spawnPgRestore(bin, ["--section=pre-data", "--section=data", ...common], env, childOpts);
|
|
304
521
|
const preData = finalizePass(first.code, first.stderr, classifyBlocks(first.stderr, SCHEMA_EXISTS_ERROR));
|
|
305
522
|
if (preData.failures.length) {
|
|
306
523
|
return { preData, postData: { expectedSkips: 0, failures: [] }, ok: false };
|
|
307
524
|
}
|
|
308
|
-
const second = await spawnPgRestore(bin, ["--section=post-data", ...common], env);
|
|
309
|
-
const postData = finalizePass(second.code, second.stderr, classifyBlocks(second.stderr, opts.target === "sandbox" ?
|
|
525
|
+
const second = await spawnPgRestore(bin, ["--section=post-data", ...common], env, childOpts);
|
|
526
|
+
const postData = finalizePass(second.code, second.stderr, classifyBlocks(second.stderr, opts.target === "sandbox" ? sandboxManagedError(opts.dumpedSchemas ?? []) : NEVER_MATCH));
|
|
310
527
|
return { preData, postData, ok: postData.failures.length === 0 };
|
|
311
528
|
}
|
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);
|
package/dist/supabase-ca.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/supabase-ca.js
CHANGED
|
@@ -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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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