backupdrill 0.2.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ import type { BucketAttrs } from "./manifest.js";
2
+ interface BucketRow {
3
+ id: string;
4
+ name: string;
5
+ public: boolean | null;
6
+ file_size_limit: string | number | null;
7
+ allowed_mime_types: string[] | null;
8
+ }
9
+ /**
10
+ * 行 → manifest 形状的纯映射,含完整性关口:查询结果必须覆盖每一个请求的 bucket。
11
+ * storage.buckets 上的 RLS 或并发删除会**静默**少返回行——把不完整清单写进 manifest,
12
+ * 恢复端就会漏建 bucket 还以为自己全知。缺任何一个 → 抛错,由调用方整体降级为"未捕获"。
13
+ * 请求标识来自 S3 层(ListBuckets),优先按 buckets.id 匹配(id 与 name 允许不同),
14
+ * 无中再退 name。null 属性原样保留(= 源端未设置,不是猜测值)。
15
+ */
16
+ export declare function bucketAttrsFromRows(requestedBuckets: string[], bucketRows: BucketRow[]): BucketAttrs[];
17
+ /** 查询源库的 bucket 属性。bucketNames 含空 bucket(恢复端要能重建它们)。 */
18
+ export declare function inspectBucketAttrs(databaseUrl: string, bucketNames: string[]): Promise<BucketAttrs[]>;
19
+ export {};
@@ -0,0 +1,50 @@
1
+ // Bucket 属性采集(Manifest v2,恢复闭环 PRD §5.1.3):public / 大小限制 / MIME 限制。
2
+ // 通道 = 源库 storage.buckets —— S3 兼容端点看不到这些属性,而备份本来就持有只读库连接,
3
+ // 零新增凭据(R0 spike 实证)。per-file 元数据不在这里:它随 syncStorage 的 GetObject
4
+ // 响应原子捕获(见 storage.ts fileMetadataFromS3)。
5
+ // 任何失败都由调用方降级为"未捕获"(manifest 缺 buckets 字段),绝不弄失败一次备份。
6
+ import { Client } from "pg";
7
+ import { pgConnectOptions } from "./supabase-ca.js";
8
+ /**
9
+ * 行 → manifest 形状的纯映射,含完整性关口:查询结果必须覆盖每一个请求的 bucket。
10
+ * storage.buckets 上的 RLS 或并发删除会**静默**少返回行——把不完整清单写进 manifest,
11
+ * 恢复端就会漏建 bucket 还以为自己全知。缺任何一个 → 抛错,由调用方整体降级为"未捕获"。
12
+ * 请求标识来自 S3 层(ListBuckets),优先按 buckets.id 匹配(id 与 name 允许不同),
13
+ * 无中再退 name。null 属性原样保留(= 源端未设置,不是猜测值)。
14
+ */
15
+ export function bucketAttrsFromRows(requestedBuckets, bucketRows) {
16
+ const attrs = [];
17
+ const missing = [];
18
+ for (const req of requestedBuckets) {
19
+ const row = bucketRows.find((r) => r.id === req) ?? bucketRows.find((r) => r.name === req);
20
+ if (!row) {
21
+ missing.push(req);
22
+ continue;
23
+ }
24
+ attrs.push({
25
+ name: req, // 快照对象布局用的标识(S3 层名),恢复端按它重建
26
+ public: row.public,
27
+ fileSizeLimit: row.file_size_limit === null ? null : Number(row.file_size_limit),
28
+ allowedMimeTypes: row.allowed_mime_types,
29
+ });
30
+ }
31
+ if (missing.length) {
32
+ throw new Error(`storage.buckets returned no row for: ${missing.join(", ")} ` +
33
+ `(RLS filtering or concurrent deletion) — refusing a partial bucket capture`);
34
+ }
35
+ return attrs;
36
+ }
37
+ /** 查询源库的 bucket 属性。bucketNames 含空 bucket(恢复端要能重建它们)。 */
38
+ export async function inspectBucketAttrs(databaseUrl, bucketNames) {
39
+ const client = new Client(pgConnectOptions(databaseUrl));
40
+ await client.connect();
41
+ try {
42
+ const bucketRows = await client.query(`select id, name, public, file_size_limit, allowed_mime_types
43
+ from storage.buckets where id = any($1::text[]) or name = any($1::text[])
44
+ order by name`, [bucketNames]);
45
+ return bucketAttrsFromRows(bucketNames, bucketRows.rows);
46
+ }
47
+ finally {
48
+ await client.end();
49
+ }
50
+ }
package/dist/storage.d.ts CHANGED
@@ -1,6 +1,20 @@
1
- import { S3Client } from "@aws-sdk/client-s3";
1
+ import { S3Client, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
2
2
  import type { SupabaseStorageConfig } from "./config.js";
3
3
  import type { StorageFile } from "./manifest.js";
4
+ /**
5
+ * 从 GetObject 响应提取文件的可恢复元数据。Supabase 的 S3 handler 把这些头直接
6
+ * 从 storage.objects.metadata / user_metadata 取出(content-type、cache-control、
7
+ * x-amz-meta-*,源码实证 2026-07-24)——所以这份元数据与刚流过的字节**原子一致**,
8
+ * 不存在"拷完再查 DB"的并发漂移窗口,也不依赖 DB 上 storage schema 的读权限。
9
+ * 缺席的头不落字段(= 源端未设置/未提供),不猜测。
10
+ */
11
+ export declare function fileMetadataFromS3(got: GetObjectCommandOutput): {
12
+ contentType?: string;
13
+ cacheControl?: string;
14
+ contentEncoding?: string;
15
+ metadata?: Record<string, string>;
16
+ lastModified?: string;
17
+ };
4
18
  /**
5
19
  * 仅列举、不下载 —— 用于接入时的容量/egress 估算,几乎不产生 egress。
6
20
  */
@@ -21,4 +35,5 @@ export declare function syncStorage(cfg: SupabaseStorageConfig, target: {
21
35
  fileCount: number;
22
36
  totalBytes: number;
23
37
  files: StorageFile[];
38
+ buckets: string[];
24
39
  }>;
package/dist/storage.js CHANGED
@@ -37,6 +37,30 @@ async function* listObjects(client, bucket) {
37
37
  token = res.IsTruncated ? res.NextContinuationToken : undefined;
38
38
  } while (token);
39
39
  }
40
+ /**
41
+ * 从 GetObject 响应提取文件的可恢复元数据。Supabase 的 S3 handler 把这些头直接
42
+ * 从 storage.objects.metadata / user_metadata 取出(content-type、cache-control、
43
+ * x-amz-meta-*,源码实证 2026-07-24)——所以这份元数据与刚流过的字节**原子一致**,
44
+ * 不存在"拷完再查 DB"的并发漂移窗口,也不依赖 DB 上 storage schema 的读权限。
45
+ * 缺席的头不落字段(= 源端未设置/未提供),不猜测。
46
+ */
47
+ export function fileMetadataFromS3(got) {
48
+ const meta = {};
49
+ if (got.ContentType)
50
+ meta.contentType = got.ContentType;
51
+ if (got.CacheControl)
52
+ meta.cacheControl = got.CacheControl;
53
+ if (got.ContentEncoding)
54
+ meta.contentEncoding = got.ContentEncoding;
55
+ // MissingMeta > 0 = 服务端明确声明有元数据条目没能放进 x-amz-meta 头(非法头名等)。
56
+ // 记一份自知残缺的 metadata 违反"未捕获就不写"原则 → 整个字段省略,如实缺席。
57
+ if (got.Metadata && Object.keys(got.Metadata).length && !(got.MissingMeta && got.MissingMeta > 0)) {
58
+ meta.metadata = got.Metadata;
59
+ }
60
+ if (got.LastModified)
61
+ meta.lastModified = got.LastModified.toISOString();
62
+ return meta;
63
+ }
40
64
  /**
41
65
  * 仅列举、不下载 —— 用于接入时的容量/egress 估算,几乎不产生 egress。
42
66
  */
@@ -95,10 +119,17 @@ export async function syncStorage(cfg, target) {
95
119
  await upload.abort().catch(() => { });
96
120
  throw error;
97
121
  }
98
- files.push({ bucket, key: obj.key, bytes, sha256: hash.digest("hex") });
122
+ files.push({
123
+ bucket,
124
+ key: obj.key,
125
+ bytes,
126
+ sha256: hash.digest("hex"),
127
+ ...fileMetadataFromS3(got),
128
+ });
99
129
  totalBytes += bytes;
100
130
  }
101
131
  log.step(` ${bucket}: ${files.filter((f) => f.bucket === bucket).length} files`);
102
132
  }
103
- return { fileCount: files.length, totalBytes, files };
133
+ // buckets 含空 bucket(files 推不出来)——manifest v2 的 bucket 属性采集要按它查
134
+ return { fileCount: files.length, totalBytes, files, buckets };
104
135
  }
@@ -0,0 +1,32 @@
1
+ export declare const SUPABASE_ROOT_CA = "-----BEGIN CERTIFICATE-----\nMIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW\nQyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q\nDmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2\nGtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi\ncvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4\nO4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt\nNaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX\nuXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt\naUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU\ntVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b\nVW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6\njB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx\nCea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2\nCMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P\no/bKiIz+Fq8=\n-----END CERTIFICATE-----\n";
2
+ /** node-pg 的 ssl 配置:验签(用打包的 Supabase 根 CA)+ 验主机名(rejectUnauthorized)。 */
3
+ export declare const SUPABASE_SSL: {
4
+ readonly ca: "-----BEGIN CERTIFICATE-----\nMIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW\nQyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q\nDmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2\nGtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi\ncvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4\nO4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt\nNaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX\nuXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt\naUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU\ntVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b\nVW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6\njB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx\nCea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2\nCMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P\no/bKiIz+Fq8=\n-----END CERTIFICATE-----\n";
5
+ readonly rejectUnauthorized: true;
6
+ };
7
+ /**
8
+ * 把打包的 CA 写到临时文件并返回路径:pg_dump(libpq)的 sslrootcert 只认文件路径,不认字符串。
9
+ * 缓存,进程内只写一次;内容是公开 CA(非密),mode 0600 足矣。
10
+ */
11
+ export declare function supabaseCaFile(): string;
12
+ /**
13
+ * 把连接串强制改写成 verify-full + 我们的 CA(给 pg_dump 用)。剥掉用户自带的所有 ssl* 参数
14
+ * ——libpq 里 URL 参数优先级高于环境变量,残留的 sslmode=require 会盖掉我们的 verify-full。
15
+ */
16
+ export declare function forceVerifyFull(databaseUrl: string, caPath: string): string;
17
+ /** pg_dump 用:Supabase 主机才改写成 verify-full;其它原样透传。 */
18
+ export declare function dumpUrlFor(databaseUrl: string): string;
19
+ /**
20
+ * node-pg 连接选项:把"剥离连接串里的 SSL 参数"与"套上打包 CA"做成**原子**的一步。
21
+ *
22
+ * 为什么必须一起做(2026-07-22 实测,pg 8.22):连接串里的 SSL 参数**会覆盖**显式传的
23
+ * `ssl` 选项——`?sslmode=disable` / `?ssl=0` 会让我们的 CA 被整个丢弃、退化成不加密,
24
+ * `?ssl=true` 则加密但不带我们的 CA。而 sslmode 在产品的参数黑名单里是**放行**的(为兼容
25
+ * 存量连接串),所以不剥就等于强制 verify-full 可被用户参数静默绕过。
26
+ *
27
+ * 非 Supabase 主机(本 CLI 不限制目标)原样返回:不剥、不套,尊重用户自己的 sslmode。
28
+ */
29
+ export declare function pgConnectOptions(databaseUrl: string): {
30
+ connectionString: string;
31
+ ssl?: typeof SUPABASE_SSL;
32
+ };
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Supabase Root 2021 CA —— 打包进引擎,用于对用户 Supabase 源库连接做 verify-full。
3
+ *
4
+ * 为什么必须自带:Supabase pooler(Supavisor)的证书由 Supabase 自签 CA 签发
5
+ * (链:*.pooler.supabase.com ← Supabase Intermediate 2021 CA ← Supabase Root 2021 CA),
6
+ * **不在系统 CA 信任库里**——直接 rejectUnauthorized:true 会报 SELF_SIGNED_CERT_IN_CHAIN
7
+ * (2026-07-20 真机实测)。把这个自签根作为 ca 传入,node-pg / libpq(pg_dump)即可完成
8
+ * verify-full(验签 + 验通配主机名 *.pooler.supabase.com)。所有 Supabase 项目共用这一张根,
9
+ * 有效期至 2031-04(过期前需更新)。
10
+ *
11
+ * 只对**用户 Supabase 源库**连接使用(backup 探查 / pg_dump / estimate / 预检);演练用的
12
+ * 本地 Docker 沙箱、restore 的任意目标库不适用,勿套用。
13
+ */
14
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ export const SUPABASE_ROOT_CA = `-----BEGIN CERTIFICATE-----
18
+ MIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL
19
+ BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l
20
+ dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh
21
+ c2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow
22
+ azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD
23
+ YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug
24
+ Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW
25
+ QyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q
26
+ DmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2
27
+ GtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi
28
+ cvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4
29
+ O4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt
30
+ NaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX
31
+ uXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt
32
+ aUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU
33
+ tVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b
34
+ VW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6
35
+ jB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx
36
+ Cea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2
37
+ CMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P
38
+ o/bKiIz+Fq8=
39
+ -----END CERTIFICATE-----
40
+ `;
41
+ /** node-pg 的 ssl 配置:验签(用打包的 Supabase 根 CA)+ 验主机名(rejectUnauthorized)。 */
42
+ export const SUPABASE_SSL = { ca: SUPABASE_ROOT_CA, rejectUnauthorized: true };
43
+ let caFilePath = null;
44
+ /**
45
+ * 把打包的 CA 写到临时文件并返回路径:pg_dump(libpq)的 sslrootcert 只认文件路径,不认字符串。
46
+ * 缓存,进程内只写一次;内容是公开 CA(非密),mode 0600 足矣。
47
+ */
48
+ export function supabaseCaFile() {
49
+ // 缓存的文件可能被系统 tmp 清理删掉;长驻 worker 若一直返回失效路径,之后**每次**
50
+ // pg_dump 都会失败直到重启。发现不存在就重建。
51
+ if (caFilePath && !existsSync(caFilePath))
52
+ caFilePath = null;
53
+ if (!caFilePath) {
54
+ // 私有随机目录(mkdtemp 建的是 0700、属主为本进程):固定的 /tmp 路径在多用户机器上
55
+ // 可被他人预置符号链接,或**替换成伪造 CA** —— 那我们的 pg_dump 就会去信任攻击者的
56
+ // 证书,等于自己开了 MITM 的门。
57
+ const dir = mkdtempSync(join(tmpdir(), "backupdrill-ca-"));
58
+ const p = join(dir, "supabase-root-ca.pem");
59
+ writeFileSync(p, SUPABASE_ROOT_CA, { mode: 0o600 });
60
+ caFilePath = p;
61
+ // 长驻进程(worker)整个生命周期都要用它,退出时再尽力清掉,避免反复起停堆积
62
+ process.once("exit", () => {
63
+ try {
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ catch {
67
+ /* 清理失败无所谓,内容是公开 CA */
68
+ }
69
+ });
70
+ }
71
+ return caFilePath;
72
+ }
73
+ /** 所有会影响 TLS 行为的连接串参数——必须全部剥掉,否则会盖过我们显式传的 ssl 配置。 */
74
+ const SSL_QUERY_PARAMS = [
75
+ "sslmode",
76
+ "ssl",
77
+ "requiressl", // 老式开关
78
+ "sslrootcert",
79
+ "sslcert",
80
+ "sslkey",
81
+ "sslcrl",
82
+ "sslcrldir",
83
+ "sslpassword",
84
+ "sslcertmode",
85
+ "sslcompression",
86
+ "sslsni",
87
+ "sslnegotiation",
88
+ "ssl_min_protocol_version",
89
+ "ssl_max_protocol_version",
90
+ // PG18:把 TLS 会话密钥写到文件 —— 留着它,"强制加密"就是句空话(流量可被解密)
91
+ "sslkeylogfile",
92
+ "uselibpqcompat",
93
+ // GSS 会在 TLS 之前协商,带 GSS 的 libpq 构建可能先走 GSS 再谈 TLS;下面统一置 disable
94
+ "gssencmode",
95
+ ];
96
+ /**
97
+ * 手工摘除查询参数——**不能**用 URLSearchParams:一旦碰它,URL 会按 form-urlencoded
98
+ * 重新序列化整个查询串,把 `%20` 写成 `+`(2026-07-22 实测)。而 libpq 把 `+` 当字面量,
99
+ * 于是合法的 `?options=-c%20statement_timeout%3D0` 会被改成 `-c+statement_timeout=0`
100
+ * 让 pg_dump 直接失败。这里逐段过滤、原样保留其余片段的编码。
101
+ */
102
+ function stripSslParams(databaseUrl) {
103
+ const q = databaseUrl.indexOf("?");
104
+ if (q === -1)
105
+ return databaseUrl;
106
+ const base = databaseUrl.slice(0, q);
107
+ const pairs = databaseUrl
108
+ .slice(q + 1)
109
+ .split("&")
110
+ .filter((pair) => pair !== "");
111
+ const keyOf = (pair) => {
112
+ const raw = pair.split("=")[0] ?? "";
113
+ try {
114
+ return decodeURIComponent(raw).toLowerCase();
115
+ }
116
+ catch {
117
+ return raw.toLowerCase(); // 编码坏了就按原文比对
118
+ }
119
+ };
120
+ const valueOf = (pair) => pair.slice((pair.split("=")[0] ?? "").length + 1);
121
+ // 两端都是"最后一个 host 生效"。若最后一个是**空值**,实际目标回落到 authority ——
122
+ // 这时必须删掉**所有** host 条目:只删空的那条,会把前面的 host=other 留给 libpq,
123
+ // 造成探查连 authority、pg_dump 连 other 的分裂。
124
+ const hostValues = pairs.filter((p) => keyOf(p) === "host").map(valueOf);
125
+ const dropAllHosts = hostValues.length > 0 && hostValues[hostValues.length - 1] === "";
126
+ const kept = pairs.filter((pair) => {
127
+ const key = keyOf(pair);
128
+ if (SSL_QUERY_PARAMS.includes(key))
129
+ return false;
130
+ if (key === "host" && dropAllHosts)
131
+ return false;
132
+ return true;
133
+ });
134
+ return kept.length ? `${base}?${kept.join("&")}` : base;
135
+ }
136
+ /**
137
+ * 把连接串强制改写成 verify-full + 我们的 CA(给 pg_dump 用)。剥掉用户自带的所有 ssl* 参数
138
+ * ——libpq 里 URL 参数优先级高于环境变量,残留的 sslmode=require 会盖掉我们的 verify-full。
139
+ */
140
+ export function forceVerifyFull(databaseUrl, caPath) {
141
+ const stripped = stripSslParams(databaseUrl);
142
+ const sep = stripped.includes("?") ? "&" : "?";
143
+ // gssencmode=disable:带 GSS 的 libpq 构建会在 TLS 之前尝试 GSS 协商,可能在用上我们的
144
+ // CA 之前就失败;我们从不用 Kerberos,显式关掉让行为跨构建确定。
145
+ return `${stripped}${sep}sslmode=verify-full&sslrootcert=${encodeURIComponent(caPath)}&gssencmode=disable`;
146
+ }
147
+ /**
148
+ * 按主机名决定是否启用 Supabase verify-full。
149
+ *
150
+ * 关键:本 CLI 是公开工具,**不限制**连接串必须是 Supabase(那道校验只在产品侧的
151
+ * shared/endpoints.ts)。若无条件套 Supabase CA,指向自建 Supabase / 其它 Postgres 的
152
+ * 用户会直接 TLS 失败——对开源工具是回归。因此只在目标确实是 Supabase 主机时启用;
153
+ * 其余情形返回 undefined,保持 pg 的默认行为(由连接串自身的 sslmode 决定)。
154
+ */
155
+ const SUPABASE_HOST = /\.supabase\.(co|com|net)$/;
156
+ /**
157
+ * 真正会被连接的主机。两个坑:
158
+ * - libpq/pg 允许 `?host=` 覆盖 authority 里的主机 → 必须按实际目标判定,否则会出现
159
+ * "给 A 套了 B 的 CA"(证书必然不匹配)或"真连 Supabase 却没套 CA"。
160
+ * - DNS 末尾根点(`...supabase.com.`)是合法写法,不归一化会把合法 Supabase 主机漏判成外部库。
161
+ */
162
+ const EMPTY_AUTHORITY_HOST = "backupdrill-empty-authority.invalid";
163
+ /**
164
+ * 按 node-pg 的宽容度解析连接串。WHATWG URL **拒绝空 authority**
165
+ * (`postgresql://u:p@/db?host=…`),但 node-pg 接受并连到 host= 指定的主机
166
+ * ——直接 try/catch 判 null 会把这种串当成外部库放行,于是 `sslmode=disable`
167
+ * 原样保留、真连 Supabase 却走明文(2026-07-22 审查抓到)。
168
+ * 这里给空 authority 塞个占位主机让它可解析,并记住 authority 实为空。
169
+ */
170
+ function parseLikePg(databaseUrl) {
171
+ try {
172
+ const url = new URL(databaseUrl);
173
+ return { url, authorityHost: url.hostname };
174
+ }
175
+ catch {
176
+ /* 可能正是 node-pg 允许、WHATWG 拒绝的空 authority */
177
+ }
178
+ const m = databaseUrl.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/?#]*)([\s\S]*)$/);
179
+ if (!m)
180
+ return null;
181
+ const [, scheme, authority, rest] = m;
182
+ // 只处理"空 authority"或"有用户名但无主机(以 @ 结尾)"这两种
183
+ if (authority !== "" && !authority.endsWith("@"))
184
+ return null;
185
+ try {
186
+ return { url: new URL(`${scheme}${authority}${EMPTY_AUTHORITY_HOST}${rest}`), authorityHost: "" };
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ }
192
+ function effectiveHost(databaseUrl) {
193
+ const parsed = parseLikePg(databaseUrl);
194
+ if (!parsed)
195
+ return null; // 实在解析不了就别自作主张套 CA
196
+ const { url, authorityHost } = parsed;
197
+ // 必须**与 node-pg 的解析语义逐字对齐**(2026-07-22 实测):
198
+ // - 重复的 host= 取**最后一个**(URLSearchParams.get 取第一个,会判错)
199
+ // - host= 为**空值**时回落到 authority 主机(当成"没写")
200
+ // 判错的后果不是小事:真连 Supabase 却被当成外部库 → 不剥 sslmode=disable → 明文。
201
+ // query 里的 host= 已被 URLSearchParams 解码;authority 主机名 WHATWG URL **不解码**,
202
+ // 而 node-pg 会解码(实测 `supabase%2Ecom` → `supabase.com` 并真的连上去)。不解码就会
203
+ // 把合法 Supabase 主机判成外部库 → 保留 sslmode=disable → 明文。坏转义按原文处理。
204
+ let decodedAuthority = authorityHost;
205
+ try {
206
+ decodedAuthority = decodeURIComponent(authorityHost);
207
+ }
208
+ catch {
209
+ /* 转义损坏:按原文比对,宁可判成外部库也不误套 CA */
210
+ }
211
+ const all = url.searchParams.getAll("host");
212
+ const last = all.length ? all[all.length - 1] : "";
213
+ const host = (last !== "" ? last : decodedAuthority).toLowerCase().replace(/\.$/, "");
214
+ return host || null;
215
+ }
216
+ function isSupabaseHost(databaseUrl) {
217
+ const host = effectiveHost(databaseUrl);
218
+ return host !== null && SUPABASE_HOST.test(host);
219
+ }
220
+ /** pg_dump 用:Supabase 主机才改写成 verify-full;其它原样透传。 */
221
+ export function dumpUrlFor(databaseUrl) {
222
+ return isSupabaseHost(databaseUrl)
223
+ ? forceVerifyFull(databaseUrl, supabaseCaFile())
224
+ : databaseUrl;
225
+ }
226
+ /**
227
+ * node-pg 连接选项:把"剥离连接串里的 SSL 参数"与"套上打包 CA"做成**原子**的一步。
228
+ *
229
+ * 为什么必须一起做(2026-07-22 实测,pg 8.22):连接串里的 SSL 参数**会覆盖**显式传的
230
+ * `ssl` 选项——`?sslmode=disable` / `?ssl=0` 会让我们的 CA 被整个丢弃、退化成不加密,
231
+ * `?ssl=true` 则加密但不带我们的 CA。而 sslmode 在产品的参数黑名单里是**放行**的(为兼容
232
+ * 存量连接串),所以不剥就等于强制 verify-full 可被用户参数静默绕过。
233
+ *
234
+ * 非 Supabase 主机(本 CLI 不限制目标)原样返回:不剥、不套,尊重用户自己的 sslmode。
235
+ */
236
+ export function pgConnectOptions(databaseUrl) {
237
+ if (!isSupabaseHost(databaseUrl))
238
+ return { connectionString: databaseUrl };
239
+ return { connectionString: stripSslParams(databaseUrl), ssl: SUPABASE_SSL };
240
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "0.2.1",
3
+ "version": "1.1.0",
4
4
  "description": "Open-source CLI to back up a Supabase project — Postgres database and Storage files — to your own S3/R2/B2 bucket, with a checksummed manifest.",
5
5
  "type": "module",
6
6
  "bin": {