dsh-config-manager 0.2.4 → 0.3.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.
Files changed (129) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/lib/adapters/self.d.ts +44 -4
  4. package/lib/adapters/self.js +63 -3
  5. package/lib/client.d.ts +11 -6
  6. package/lib/client.js +1263 -1192
  7. package/lib/core/analyzer.d.ts +3 -0
  8. package/lib/core/analyzer.js +32 -56
  9. package/lib/core/backup.d.ts +36 -0
  10. package/lib/core/backup.js +125 -3
  11. package/lib/core/import-defaults.d.ts +13 -0
  12. package/lib/core/import-defaults.js +14 -0
  13. package/lib/core/importer.d.ts +10 -1
  14. package/lib/core/importer.js +7 -1
  15. package/lib/core/messages.d.ts +4 -0
  16. package/lib/core/messages.js +8 -0
  17. package/lib/core/model-tools.js +19 -3
  18. package/lib/index.d.ts +1 -1
  19. package/lib/index.js +12 -22
  20. package/lib/routes/import.js +3 -1
  21. package/lib/routes/recovery.js +226 -2
  22. package/lib/routes/sync.js +26 -7
  23. package/lib/security/integrity.d.ts +24 -0
  24. package/lib/security/integrity.js +30 -0
  25. package/lib/security/unverified-file-blocks.d.ts +73 -0
  26. package/lib/security/unverified-file-blocks.js +151 -0
  27. package/lib/sync/autosync-config.d.ts +8 -4
  28. package/lib/sync/autosync-config.js +2 -2
  29. package/lib/sync/autosync-scheduler.d.ts +61 -3
  30. package/lib/sync/autosync-scheduler.js +87 -10
  31. package/lib/sync/config-issue-reporting.d.ts +32 -0
  32. package/lib/sync/config-issue-reporting.js +63 -0
  33. package/lib/sync/gc-skip.d.ts +42 -0
  34. package/lib/sync/gc-skip.js +57 -0
  35. package/lib/sync/git/git-transport.d.ts +10 -0
  36. package/lib/sync/git/git-transport.js +72 -14
  37. package/lib/sync/merge-preview-text.d.ts +11 -0
  38. package/lib/sync/merge-preview-text.js +38 -0
  39. package/lib/sync/s3/s3-transport.d.ts +8 -1
  40. package/lib/sync/s3/s3-transport.js +19 -5
  41. package/lib/sync/session-tombstones.d.ts +53 -0
  42. package/lib/sync/session-tombstones.js +52 -0
  43. package/lib/sync/sync-config.d.ts +55 -5
  44. package/lib/sync/sync-config.js +188 -15
  45. package/lib/sync/sync-engine.d.ts +21 -7
  46. package/lib/sync/sync-engine.js +63 -85
  47. package/lib/sync/webdav/webdav-transport.d.ts +11 -1
  48. package/lib/sync/webdav/webdav-transport.js +22 -6
  49. package/lib/ui/i18n.js +56 -36
  50. package/lib/ui/import-wizard.d.ts +1 -1
  51. package/lib/ui/import-wizard.js +2 -1
  52. package/lib/ui/overview-view.d.ts +6 -2
  53. package/lib/ui/overview-view.js +8 -1
  54. package/lib/ui/report.js +2 -1
  55. package/package.json +2 -2
  56. package/src/adapters/self.test.ts +126 -85
  57. package/src/adapters/self.ts +73 -6
  58. package/src/client/ConfigManagerSection.tsx +0 -3
  59. package/src/client/common/Icon.tsx +3 -0
  60. package/src/client/common/SectionComposition.tsx +6 -1
  61. package/src/client/common/info-hint-guard.test.ts +7 -5
  62. package/src/client/config-manager.module.css +254 -13
  63. package/src/client/environment/EnvironmentPanel.tsx +57 -47
  64. package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
  65. package/src/client/export/ExportView.tsx +3 -1
  66. package/src/client/home/HomePanel.tsx +25 -5
  67. package/src/client/import/ImportWizardView.tsx +1 -3
  68. package/src/client/import/import-wizard-steps.tsx +1 -17
  69. package/src/client/library/ArtifactRow.tsx +13 -2
  70. package/src/client/library/LibraryPanel.tsx +12 -2
  71. package/src/client/locales.ts +53 -34
  72. package/src/client/lucide-icons.d.ts +2 -0
  73. package/src/client/market/MarketPanel.tsx +12 -11
  74. package/src/client/market/market-locales.ts +16 -16
  75. package/src/client/recovery/RollbackCard.tsx +4 -2
  76. package/src/client/recovery/recovery-locales.test.ts +4 -2
  77. package/src/client/recovery/recovery-locales.ts +23 -23
  78. package/src/client/sync/AutosyncCard.tsx +36 -10
  79. package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
  80. package/src/client/sync/SyncPanel.tsx +32 -33
  81. package/src/client/sync/SyncProbeRow.tsx +23 -6
  82. package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
  83. package/src/client/sync/sync-locales.ts +24 -24
  84. package/src/core/analyzer.ts +37 -56
  85. package/src/core/backup.test.ts +139 -1
  86. package/src/core/backup.ts +145 -4
  87. package/src/core/import-defaults.ts +13 -0
  88. package/src/core/importer.ts +12 -2
  89. package/src/core/integrity-extra-entries.test.ts +205 -0
  90. package/src/core/messages.test.ts +3 -1
  91. package/src/core/messages.ts +8 -0
  92. package/src/core/model-tools.test.ts +33 -0
  93. package/src/core/model-tools.ts +20 -4
  94. package/src/index.ts +12 -21
  95. package/src/routes/import.ts +4 -1
  96. package/src/routes/recovery.test.ts +198 -1
  97. package/src/routes/recovery.ts +194 -1
  98. package/src/routes/sync.ts +27 -8
  99. package/src/security/integrity.ts +37 -0
  100. package/src/security/unverified-file-blocks.ts +192 -0
  101. package/src/sync/autosync-config.ts +9 -5
  102. package/src/sync/autosync-scheduler.test.ts +133 -6
  103. package/src/sync/autosync-scheduler.ts +118 -14
  104. package/src/sync/config-issue-reporting.test.ts +148 -0
  105. package/src/sync/config-issue-reporting.ts +75 -0
  106. package/src/sync/gc-skip.test.ts +66 -0
  107. package/src/sync/gc-skip.ts +64 -0
  108. package/src/sync/git/git-probe.test.ts +3 -2
  109. package/src/sync/git/git-transport.test.ts +168 -7
  110. package/src/sync/git/git-transport.ts +70 -14
  111. package/src/sync/merge-preview-text.ts +37 -0
  112. package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
  113. package/src/sync/s3/s3-transport.ts +23 -4
  114. package/src/sync/session-tombstones.ts +90 -0
  115. package/src/sync/sync-config.test.ts +170 -1
  116. package/src/sync/sync-config.ts +184 -14
  117. package/src/sync/sync-engine.test.ts +238 -0
  118. package/src/sync/sync-engine.ts +79 -83
  119. package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
  120. package/src/sync/webdav/webdav-transport.ts +26 -5
  121. package/src/ui/disk-usage-view.test.ts +2 -1
  122. package/src/ui/i18n.test.ts +47 -0
  123. package/src/ui/i18n.ts +58 -38
  124. package/src/ui/import-wizard.test.ts +18 -1
  125. package/src/ui/import-wizard.ts +3 -2
  126. package/src/ui/overview-view.test.ts +9 -0
  127. package/src/ui/overview-view.ts +9 -2
  128. package/src/ui/report.test.ts +4 -3
  129. package/src/ui/report.ts +2 -1
@@ -119,6 +119,17 @@ function joinedArgs(calls: CallRecord[], needle: string): string[] {
119
119
  return calls.filter((c) => c.args.join(' ').includes(needle)).map((c) => c.args.join(' '));
120
120
  }
121
121
 
122
+ /** 去掉 argv 前置的协议护栏(-c protocol.*.allow=never),用于断言「业务参数」本身 */
123
+ function stripProtocolGuards(joined: string): string {
124
+ return joined.replace(/^(?:-c protocol\.[a-z]+\.allow=never )+/, '');
125
+ }
126
+
127
+ /** 单条调用是否带某个 -c 配置(必须成对出现) */
128
+ function hasGitConfig(args: string[], kv: string): boolean {
129
+ const i = args.indexOf(kv);
130
+ return i > 0 && args[i - 1] === '-c';
131
+ }
132
+
122
133
  /**
123
134
  * 拉取命令判定(issue #122):fetch 与 origin 之间现在会插 `--no-tags`/`--filter=blob:none`,
124
135
  * 所以不能再按 'fetch origin' 子串匹配。
@@ -977,7 +988,8 @@ test('远端同步命令序列:显式 refspec fetch + merge --ff-only,绝不
977
988
  workDir: dir, exec: pullScriptedExec(calls, [{ stdout: '', stderr: '', code: 0 }]),
978
989
  }));
979
990
  await transport.list();
980
- const joined = calls.map((c) => c.args.join(' '));
991
+ // SEC-12:每条 argv 前都挂协议护栏,断言业务参数前先剥掉
992
+ const joined = calls.map((c) => stripProtocolGuards(c.args.join(' ')));
981
993
  assert.ok(!joined.some((j) => /^pull(\s|$)/.test(j)), '不得再执行 git pull: ' + joined.join(' | '));
982
994
  // 网络命令前面会挂 credential helper 的 -c 参数,所以按「末尾 3 个参数」判命令本身
983
995
  // issue #122:fetch 与 origin 之间还会插 `--no-tags`(partial clone 时另有 `--filter=blob:none`),
@@ -1118,7 +1130,7 @@ test('issue #122:首次 clone 走 partial clone + sparse-checkout,只 materi
1118
1130
  const sparse = calls.find((c) => c.args.includes('sparse-checkout'));
1119
1131
  assert.ok(sparse !== undefined, '必须设置 sparse-checkout');
1120
1132
  assert.equal(
1121
- sparse.args.join(' '),
1133
+ stripProtocolGuards(sparse.args.join(' ')),
1122
1134
  'sparse-checkout set --cone snapshots snapshots-encrypted blobs',
1123
1135
  'sparse 面必须与同步目录同源: ' + sparse.args.join(' '),
1124
1136
  );
@@ -1325,10 +1337,10 @@ test('issue #79:已有工作副本且 origin 与 repoUrl 不一致 → set-url
1325
1337
  exec: probeExec(calls, 'ssh://git@old.example:2222/owner/repo.git'),
1326
1338
  }));
1327
1339
  await transport.list();
1328
- assert.deepEqual(joinedArgs(calls, 'remote set-url'), [
1329
- `remote set-url origin ${target}`,
1330
- `remote set-url --push origin ${target}`,
1331
- ], 'fetch 与 push 地址都必须跟随(否则推送仍走旧地址)');
1340
+ assert.deepEqual(joinedArgs(calls, 'remote set-url').map(stripProtocolGuards), [
1341
+ `remote set-url origin -- ${target}`,
1342
+ `remote set-url --push origin -- ${target}`,
1343
+ ], 'fetch 与 push 地址都必须跟随(否则推送仍走旧地址);repoUrl 恒由 -- 结束选项解析');
1332
1344
  });
1333
1345
 
1334
1346
  test('issue #79:origin 已一致 → 一条写命令都不发(幂等,只有只读探针)', async (t) => {
@@ -1347,7 +1359,7 @@ test('issue #79:无 origin(读不出来)→ remote add origin,否则 pus
1347
1359
  const target = 'ssh://git@fresh.example:2222/owner/repo.git';
1348
1360
  const transport = new GitTransport(makeOptions({ repoUrl: target, workDir: dir, exec: probeExec(calls, null) }));
1349
1361
  await transport.list();
1350
- assert.deepEqual(joinedArgs(calls, 'remote add origin'), [`remote add origin ${target}`]);
1362
+ assert.deepEqual(joinedArgs(calls, 'remote add origin').map(stripProtocolGuards), [`remote add origin -- ${target}`]);
1351
1363
  });
1352
1364
 
1353
1365
  test('issue #79:origin 读得出但为空串 → 视为读不到,不改写(不拿不确定的读取覆盖配置)', async (t) => {
@@ -1716,3 +1728,152 @@ test('只读自检有界:锁被活进程持有时报「工作副本忙」,
1716
1728
  });
1717
1729
 
1718
1730
 
1731
+
1732
+ /* ------------------------------------------------------------------------------------------------
1733
+ * SEC-12 / SEC-03:repoUrl 不得被 git 当成命令行选项,也不得借协议开关读到本机任意仓库
1734
+ *
1735
+ * 事实:repoUrl 原样进 argv。于是「--upload-pack=touch /tmp/pwn」「-c core.pager=…」这类取值会被
1736
+ * git 当成**选项**执行;「file:///…」则让 git 用本地文件协议去读任意本机仓库(sync 通道本该只碰
1737
+ * 同步内容)。三道防线:构造期白名单(方案/主机/首字符)→ argv 恒带协议护栏 → repoUrl 前恒有 --。
1738
+ * ---------------------------------------------------------------------------------------------- */
1739
+ test('SEC-12:以 - 开头 / 非网络方案 / 私网环回地址的 repoUrl → 构造期直接拒绝(一条 git 都不 spawn)', async (t) => {
1740
+ const dir = await makeTempDir(t);
1741
+ for (const repoUrl of [
1742
+ '--upload-pack=touch /tmp/pwned',
1743
+ '-c core.pager=touch /tmp/pwned',
1744
+ 'file:///c:/repos/x.git',
1745
+ 'ext::sh -c touch% /tmp/pwned',
1746
+ 'ftp://git.example.com/r.git',
1747
+ 'git://git.example.com/r.git',
1748
+ 'https://127.0.0.1:8443/r.git',
1749
+ 'https://[::1]/r.git',
1750
+ 'https://169.254.169.254/latest/meta-data/',
1751
+ 'https://10.1.2.3/r.git',
1752
+ 'https://172.20.0.1/r.git',
1753
+ 'https://192.168.0.9/r.git',
1754
+ ]) {
1755
+ const calls: CallRecord[] = [];
1756
+ assert.throws(
1757
+ () => new GitTransport(makeOptions({ repoUrl, workDir: dir, exec: mockExec(calls) })),
1758
+ (err: unknown) => {
1759
+ assert.ok(err instanceof GitTransportError, '必须是 GitTransportError: ' + repoUrl);
1760
+ assert.match(err.message, /repoUrl/, '错误必须点明 repoUrl');
1761
+ assert.ok(err.message.includes('[REPO_URL]'), '错误消息里的地址必须脱敏');
1762
+ assert.ok(!err.message.includes('169.254.169.254'), '不得把危险地址原样回显');
1763
+ return true;
1764
+ },
1765
+ );
1766
+ assert.equal(calls.length, 0, '构造期拦截,不得先 spawn 再失败: ' + repoUrl);
1767
+ }
1768
+
1769
+ // 本机路径仍是 git 通道既有的合法目标(real-git 集成测试就靠它),不得被白名单误伤
1770
+ const local = new GitTransport(makeOptions({ repoUrl: path.join(dir, 'remote.git'), workDir: dir, exec: mockExec([]) }));
1771
+ assert.ok(local);
1772
+ });
1773
+
1774
+ test('SEC-12:协议护栏 —— 网络地址恒禁 ext + file;本机路径只禁 ext(否则本地仓老用法失效)', async (t) => {
1775
+ const net = await makeTempDir(t);
1776
+ const netCalls: CallRecord[] = [];
1777
+ const netTransport = new GitTransport(makeOptions({ workDir: net, exec: mockExec(netCalls) }));
1778
+ await netTransport.list();
1779
+ assert.ok(netCalls.length > 0, '应有 git 调用');
1780
+ for (const c of netCalls) {
1781
+ assert.ok(hasGitConfig(c.args, 'protocol.ext.allow=never'), '每条 git 都必须禁 ext 兜底(ext:: 可执行任意命令): ' + c.args.join(' '));
1782
+ assert.ok(hasGitConfig(c.args, 'protocol.file.allow=never'), '网络地址还须禁 file(防被改写成 file:// 后读本机仓): ' + c.args.join(' '));
1783
+ }
1784
+
1785
+ const localDir = await makeTempDir(t);
1786
+ const localCalls: CallRecord[] = [];
1787
+ const localTransport = new GitTransport(makeOptions({
1788
+ repoUrl: path.join(localDir, 'remote.git'), workDir: localDir, exec: mockExec(localCalls),
1789
+ }));
1790
+ await localTransport.list();
1791
+ assert.ok(localCalls.length > 0, '应有 git 调用');
1792
+ for (const c of localCalls) {
1793
+ assert.ok(hasGitConfig(c.args, 'protocol.ext.allow=never'), 'ext 护栏对任何地址都开: ' + c.args.join(' '));
1794
+ assert.ok(!hasGitConfig(c.args, 'protocol.file.allow=never'), '本机路径不得禁 file(否则 clone 本地仓直接失败): ' + c.args.join(' '));
1795
+ }
1796
+ });
1797
+
1798
+ test('SEC-12:repoUrl 恒由 -- 与选项分开(clone / ls-remote / remote add)', async (t) => {
1799
+ const dir = await makeTempDir(t);
1800
+ const calls: CallRecord[] = [];
1801
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
1802
+ await transport.list();
1803
+
1804
+ const urlArgIndex = (args: string[]): number => args.indexOf('https://github.com/example/private-config.git');
1805
+ const withUrl = calls.filter((c) => urlArgIndex(c.args) >= 0);
1806
+ assert.ok(withUrl.length > 0, '应有带 repoUrl 的调用: ' + calls.map((c) => c.args.join(' ')).join(' | '));
1807
+ for (const c of withUrl) {
1808
+ const i = urlArgIndex(c.args);
1809
+ assert.equal(c.args[i - 1], '--', 'repoUrl 前必须有 -- 结束选项解析: ' + c.args.join(' '));
1810
+ }
1811
+
1812
+ // remote add 同样受约束(工作副本已存在但 origin 读不到)
1813
+ const wd = await makeGitWorkDir(t);
1814
+ const remotes: CallRecord[] = [];
1815
+ const t2 = new GitTransport(makeOptions({ workDir: wd, exec: probeExec(remotes, null) }));
1816
+ await t2.list();
1817
+ const add = remotes.filter((c) => c.args.includes('add'));
1818
+ assert.equal(add.length, 1, '应 remote add: ' + remotes.map((c) => c.args.join(' ')).join(' | '));
1819
+ const ai = add[0]!.args.indexOf('https://github.com/example/private-config.git');
1820
+ assert.equal(add[0]!.args[ai - 1], '--', 'remote add 的 repoUrl 前必须有 --');
1821
+ });
1822
+
1823
+ /* ------------------------------------------------------------------------------------------------
1824
+ * RV1-F8:错误消息与返回体不得回显真 repoUrl
1825
+ *
1826
+ * 事实:构造期已用 `[REPO_URL]` 占位符,但 `repoHost()` 的 catch、`cmdFailed` 的 `{args}`(argv 里
1827
+ * 原样带着 repoUrl)、以及 `inspectWorkDir()` 回传的 origin/pushUrl 仍可能把 `user:token@` 带出去 ——
1828
+ * 这些字符串会进日志、进 GET /sync/status 响应、进用户贴出来的 issue 截图。
1829
+ * 口径:错误/日志一律占位符(mask),返回体保留 host/path 但剥掉 userinfo。
1830
+ * ---------------------------------------------------------------------------------------------- */
1831
+ test('RV1-F8:错误文本与工作副本自检返回体都不含 userinfo(token 一条都不许出)', async (t) => {
1832
+ const secret = 'ghp_leaktoken0123456789';
1833
+ const secretUrl = 'ssh://user:' + secret + '@git.example.com/u/r.git';
1834
+
1835
+ // ① exec 抛错 → cmdFailed 的 {args} 含 repoUrl 原文,必须经 mask
1836
+ const dir = await makeTempDir(t);
1837
+ const throwing: GitExecFn = async (_cmd, args): Promise<GitExecResult> => {
1838
+ if (args.includes('ls-remote')) throw new Error('spawn git ENOENT: ' + args.join(' '));
1839
+ return { stdout: '', stderr: '', code: 0 };
1840
+ };
1841
+ const transport = new GitTransport(makeOptions({ repoUrl: secretUrl, workDir: dir, exec: throwing }));
1842
+ let raised: unknown = null;
1843
+ try {
1844
+ await transport.checkIsPrivate();
1845
+ } catch (err) {
1846
+ raised = err;
1847
+ }
1848
+ assert.ok(raised instanceof GitTransportError, '必须归一为 GitTransportError');
1849
+ const msg1 = (raised as GitTransportError).message;
1850
+ assert.ok(msg1.includes('[REPO_URL]'), '回显必须走占位符: ' + msg1);
1851
+ assert.ok(!msg1.includes(secret), 'argv 里的地址含 token —— 错误文本不得回显: ' + msg1);
1852
+ assert.ok(!msg1.includes('user:'), '不得回显 userinfo 片段: ' + msg1);
1853
+
1854
+ // ② 工作副本自检的 origin / pushUrl 会进 GET /sync/status 响应 → 只剥 userinfo,保留 host/path
1855
+ const wd = await makeGitWorkDir(t);
1856
+ const calls: CallRecord[] = [];
1857
+ const inspector = new GitTransport(makeOptions({ repoUrl: secretUrl, workDir: wd, exec: probeExec(calls, secretUrl) }));
1858
+ const facts = await inspector.inspectWorkDir();
1859
+ assert.equal(facts.origin, 'ssh://git.example.com/u/r.git', 'origin 必须剥掉 userinfo: ' + String(facts.origin));
1860
+ assert.equal(facts.pushUrl, 'ssh://git.example.com/u/r.git', 'pushUrl 同样剥掉 userinfo: ' + String(facts.pushUrl));
1861
+ assert.ok(!JSON.stringify(facts).includes(secret), 'facts 里任何字段都不得残留 token');
1862
+ assert.equal(facts.match, true, '匹配判定用原文比对,不受脱敏影响');
1863
+
1864
+ // ③ 地址解析不出主机时(本机路径走 repoHost),错误里也只是占位符
1865
+ const localDir = await makeTempDir(t);
1866
+ const localTransport = new GitTransport(makeOptions({
1867
+ repoUrl: path.join(localDir, 'remote.git'), workDir: localDir, exec: mockExec([]),
1868
+ }));
1869
+ let hostErr: unknown = null;
1870
+ try {
1871
+ // 私有可见性探测会走 buildCredentialArgs → repoHost();本机路径 new URL() 必失败
1872
+ await localTransport.checkIsPrivate();
1873
+ } catch (err) {
1874
+ hostErr = err;
1875
+ }
1876
+ if (hostErr instanceof GitTransportError) {
1877
+ assert.ok(!hostErr.message.includes(localDir), '本机路径同样不得原样回显: ' + hostErr.message);
1878
+ }
1879
+ });
@@ -31,6 +31,7 @@ import type { SnapshotDirManifest } from '../layout.ts';
31
31
  import { BLOB_SECTIONS, gcBlobs, referencedBlobHashes } from '../blob-store.ts';
32
32
  import type { BlobSink } from '../blob-store.ts';
33
33
  import { deserializeSnapshot, serializeSnapshot } from '../snapshot-json.ts';
34
+ import { classifyRepoUrl, isPrivateRepoHost } from '../sync-config.ts';
34
35
  import {
35
36
  classifyNetworkErrorText, computeSnapshotMeta, DEFAULT_SYNC_TIMEOUT_MS, isEncryptedSections,
36
37
  SyncTransportError, withSyncRetry,
@@ -198,6 +199,19 @@ const SYNC_ROOT_DIRS: readonly string[] = [...ADOPT_ROOT_ALLOW];
198
199
  */
199
200
  const NETWORK_SUBCOMMANDS = new Set<string>(['clone', 'fetch', 'push', 'ls-remote', 'pull']);
200
201
  const LOW_SPEED_ARGS: readonly string[] = ['-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=30'];
202
+ /**
203
+ * 协议护栏(SEC-12):**所有** git 子进程恒带,与「这条命令走不走网络」无关。
204
+ *
205
+ * 为什么恒带而不是只给网络命令:危险的不只是远端协议本身 —— `ext::sh -c ...` / `file://`
206
+ * 这类地址在 clone/fetch 之外还能经 `remote set-url`、配置重载等路径被触发,而本插件的 repoUrl
207
+ * 来自用户可编辑的 sync-config.json 与可导入的配置包。把 `ext` 与 `file` 传输整体关掉,
208
+ * 就等于「repoUrl 无论被改成什么,都只能走 http/https/ssh 三种真协议」。
209
+ */
210
+ const PROTOCOL_GUARD_ARGS: readonly string[] = ['-c', 'protocol.ext.allow=never'];
211
+ /** 网络仓库额外禁 file 传输(本地目录仓库是显式支持的用法,不能一刀切禁掉)。 */
212
+ const FILE_PROTOCOL_GUARD_ARGS: readonly string[] = ['-c', 'protocol.file.allow=never'];
213
+ /** 报错/日志里回显的地址占位符:不带真实 repoUrl(脱敏由上层的 mask() 负责)。 */
214
+ const REPO_URL_PLACEHOLDER = '[REPO_URL]';
201
215
  const DEFAULT_AUTHOR: GitAuthor = { name: 'DSH Config Sync', email: 'sync@dsh.local' };
202
216
  const DEFAULT_CREDENTIAL_USERNAME = 'oauth2';
203
217
  /** 快照 id 安全字符集:字母数字开头,仅 . _ -;防路径穿越与 commit message 注入 */
@@ -361,6 +375,8 @@ export class GitTransport implements SyncTransport {
361
375
  */
362
376
  private readErrors: { file: string; reason: string }[] = [];
363
377
  private readonly msg: MsgFunc;
378
+ /** repoUrl 是否为网络地址(本地目录仓库 == false):决定要不要额外禁 file 传输(SEC-12)。 */
379
+ private readonly networkRepo: boolean;
364
380
 
365
381
  constructor(options: GitTransportOptions) {
366
382
  if (typeof options.repoUrl !== 'string' || options.repoUrl.length === 0) {
@@ -374,6 +390,16 @@ export class GitTransport implements SyncTransport {
374
390
  throw new GitTransportError(zhMsg('sync.git.credentialsRequired'));
375
391
  }
376
392
  this.msg = options.msg ?? zhMsg;
393
+ // SEC-12:repoUrl 来自用户可编辑配置与可导入配置包 —— 构造期就收成「只能是真网络地址或本地目录」,
394
+ // 环回/私网/链路本地一律拒(把家目录配置推到 localhost 上的抓取器是最省事的外泄方式)。
395
+ // **已知行为变更**:局域网 NAS / 内网 git 服务器作为同步目标此后会被拒绝(见 sync-config.ts
396
+ // 的 isPrivateRepoHost 注释里的取舍说明),这是有意的安全收紧,不是回归。
397
+ // 地址本体不回显进错误(占位符代替),脱敏仍由 mask() 兜底。
398
+ const repoShape = classifyRepoUrl(options.repoUrl);
399
+ if (repoShape === null || (repoShape.kind !== 'local' && isPrivateRepoHost(repoShape.host))) {
400
+ throw new GitTransportError(this.msg('sync.git.repoUrlInvalid', { url: REPO_URL_PLACEHOLDER }));
401
+ }
402
+ this.networkRepo = repoShape.kind !== 'local';
377
403
  this.o = {
378
404
  repoUrl: options.repoUrl,
379
405
  workDir: options.workDir,
@@ -583,12 +609,12 @@ export class GitTransport implements SyncTransport {
583
609
  */
584
610
  async checkIsPrivate(): Promise<boolean> {
585
611
  if (this.privateHint !== null) return this.privateHint;
586
- const anon = await this.runGit(['ls-remote', this.o.repoUrl], { allowNonZero: true, cwd: undefined });
612
+ const anon = await this.runGit(['ls-remote', '--', this.o.repoUrl], { allowNonZero: true, cwd: undefined });
587
613
  if (anon.code === 0) {
588
614
  this.privateHint = false;
589
615
  return false;
590
616
  }
591
- const authed = await this.runGit(['ls-remote', this.o.repoUrl], { allowNonZero: true, cwd: undefined, withCredential: true });
617
+ const authed = await this.runGit(['ls-remote', '--', this.o.repoUrl], { allowNonZero: true, cwd: undefined, withCredential: true });
592
618
  if (authed.code === 0) {
593
619
  this.privateHint = true;
594
620
  return true;
@@ -708,7 +734,7 @@ export class GitTransport implements SyncTransport {
708
734
  private async cloneIntoWorkDir(): Promise<void> {
709
735
  const cwd = this.o.workDir;
710
736
  this.partialClone = null; // reclone 后重新判定(配置可能变)
711
- const filteredArgs = ['clone', '--filter=blob:none', '--no-checkout', this.o.repoUrl, '.'];
737
+ const filteredArgs = ['clone', '--filter=blob:none', '--no-checkout', '--', this.o.repoUrl, '.'];
712
738
  const filtered = await this.runGit(filteredArgs, { cwd, withCredential: true, allowNonZero: true });
713
739
  if (filtered.code !== 0) {
714
740
  if (!UNSUPPORTED_GIT_OPTION_RE.test(filtered.stderr + ' ' + filtered.stdout)) {
@@ -717,7 +743,7 @@ export class GitTransport implements SyncTransport {
717
743
  }
718
744
  // 老 git:回落全量 clone(全量 clone 自带 checkout)
719
745
  await clearDirContents(cwd);
720
- await this.runGit(['clone', this.o.repoUrl, '.'], { cwd, withCredential: true });
746
+ await this.runGit(['clone', '--', this.o.repoUrl, '.'], { cwd, withCredential: true });
721
747
  return;
722
748
  }
723
749
  const branch = await this.currentBranch();
@@ -789,15 +815,15 @@ export class GitTransport implements SyncTransport {
789
815
  const current = await this.runGit(['remote', 'get-url', 'origin'], { allowNonZero: true });
790
816
  const origin = current.code === 0 ? current.stdout.trim() : '';
791
817
  if (current.code !== 0) {
792
- await this.runGit(['remote', 'add', 'origin', this.o.repoUrl]);
818
+ await this.runGit(['remote', 'add', 'origin', '--', this.o.repoUrl]);
793
819
  } else if (origin !== '' && origin !== this.o.repoUrl) {
794
- await this.runGit(['remote', 'set-url', 'origin', this.o.repoUrl]);
820
+ await this.runGit(['remote', 'set-url', 'origin', '--', this.o.repoUrl]);
795
821
  this.partialClone = null; // 换了远端 ⇒ promisor 判定失效(issue #122)
796
822
  }
797
823
  const push = await this.runGit(['remote', 'get-url', '--push', 'origin'], { allowNonZero: true });
798
824
  const pushUrl = push.code === 0 ? push.stdout.trim() : '';
799
825
  if (pushUrl !== '' && pushUrl !== this.o.repoUrl) {
800
- await this.runGit(['remote', 'set-url', '--push', 'origin', this.o.repoUrl]);
826
+ await this.runGit(['remote', 'set-url', '--push', 'origin', '--', this.o.repoUrl]);
801
827
  }
802
828
  }
803
829
 
@@ -896,8 +922,9 @@ export class GitTransport implements SyncTransport {
896
922
  return {
897
923
  path: this.o.workDir,
898
924
  exists: true,
899
- origin: origin === '' ? null : origin,
900
- pushUrl: pushRaw === '' ? null : pushRaw,
925
+ // RV1-F8:这两个字段会进 GET /sync/status 的响应 → 剥掉可能存在的 userinfo token
926
+ origin: origin === '' ? null : this.bareUrl(origin),
927
+ pushUrl: pushRaw === '' ? null : this.bareUrl(pushRaw),
901
928
  // 读不出 origin(null)→ 不算 match(状态接口据此提示「工作副本地址未知」)
902
929
  match: origin === this.o.repoUrl && (pushRaw === '' || pushRaw === this.o.repoUrl),
903
930
  ahead,
@@ -1138,6 +1165,10 @@ export class GitTransport implements SyncTransport {
1138
1165
  const cwd = opts.cwd === undefined ? this.o.workDir : opts.cwd;
1139
1166
  // 网络子命令一律带低速上限:真卡住在 30 s 内明确失败,而不是耗满 timeoutMs(issue #122)
1140
1167
  const networkArgs = NETWORK_SUBCOMMANDS.has(args[0] ?? '') ? [...LOW_SPEED_ARGS] : [];
1168
+ // SEC-12:协议护栏恒带(见 PROTOCOL_GUARD_ARGS);网络仓库再额外禁 file 传输。
1169
+ const guardArgs = this.networkRepo
1170
+ ? [...PROTOCOL_GUARD_ARGS, ...FILE_PROTOCOL_GUARD_ARGS]
1171
+ : [...PROTOCOL_GUARD_ARGS];
1141
1172
  let extra: string[] = [];
1142
1173
  let token: string | null = null;
1143
1174
  let cleanup: (() => Promise<void>) | null = null;
@@ -1156,7 +1187,7 @@ export class GitTransport implements SyncTransport {
1156
1187
  // 而目录里原本就有别的内容时宁可放弃重试,也绝不删用户文件。
1157
1188
  const cloneTargetWasEmpty = args[0] === 'clone' ? await isEmptyDir(cwd) : false;
1158
1189
  try {
1159
- let result = await this.o.exec(this.o.gitBin, [...extra, ...networkArgs, ...args], { cwd, timeoutMs: this.o.timeoutMs });
1190
+ let result = await this.o.exec(this.o.gitBin, [...extra, ...guardArgs, ...networkArgs, ...args], { cwd, timeoutMs: this.o.timeoutMs });
1160
1191
  // issue #120:credential helper 必须经 shell 执行(值里不含空格也一样,git 统一拼成
1161
1192
  // `sh -c 'git <helper> ...'`),而 Android 上 git 二进制的 GIT_SHELL_PATH 指向 App 沙箱里
1162
1193
  // 不存在的 Termux sh;`core.askpass` 又要 exec 权限(Android 10+ 对 targetSdk ≥ 29 禁执行应用
@@ -1166,7 +1197,7 @@ export class GitTransport implements SyncTransport {
1166
1197
  const headerEnv = credentialHeaderEnv(this.o.repoUrl, this.o.credentialUsername, token);
1167
1198
  if (Object.keys(headerEnv).length > 0 && (args[0] !== 'clone' || cloneTargetWasEmpty)) {
1168
1199
  if (args[0] === 'clone') await clearDirContents(cwd);
1169
- result = await this.o.exec(this.o.gitBin, [...networkArgs, ...args], { cwd, timeoutMs: this.o.timeoutMs, env: headerEnv });
1200
+ result = await this.o.exec(this.o.gitBin, [...guardArgs, ...networkArgs, ...args], { cwd, timeoutMs: this.o.timeoutMs, env: headerEnv });
1170
1201
  }
1171
1202
  }
1172
1203
  if (result.code !== 0 && !opts.allowNonZero) {
@@ -1178,7 +1209,8 @@ export class GitTransport implements SyncTransport {
1178
1209
  // 注入的 exec 实现可能直接抛错(默认实现只返回非零 code)——同样归一为带分类的传输层错误
1179
1210
  const detail = err instanceof Error ? err.message : String(err);
1180
1211
  throw new GitTransportError(
1181
- this.msg('sync.git.cmdFailed', { args: args.join(' '), code: 'exec', err: this.mask(detail, token) }),
1212
+ // argv 里含 repoUrl 原文(clone/ls-remote/remote set-url -- <url>)→ 必须经 mask 才进文案
1213
+ this.msg('sync.git.cmdFailed', { args: this.mask(args.join(' '), token), code: 'exec', err: this.mask(detail, token) }),
1182
1214
  { ...classifyNetworkErrorText(detail), cause: err },
1183
1215
  );
1184
1216
  } finally {
@@ -1210,7 +1242,7 @@ export class GitTransport implements SyncTransport {
1210
1242
  ? this.msg('sync.git.pushAuthFailed', { err: this.mask(result.stderr, token) })
1211
1243
  : args[0] === 'clone' && classified.kind === 'auth'
1212
1244
  ? this.msg('sync.git.cloneAuthFailed', { err: this.mask(result.stderr, token) })
1213
- : this.msg('sync.git.cmdFailed', { args: args.join(' '), code: String(result.code), err: this.mask(result.stderr, token) });
1245
+ : this.msg('sync.git.cmdFailed', { args: this.mask(args.join(' '), token), code: String(result.code), err: this.mask(result.stderr, token) });
1214
1246
  // 归类同样不能交给原文:`cannot exec ...: No such file or directory` 里的 ENOENT 会被判成
1215
1247
  // notfound(「仓库不存在」),而真实原因是凭据通道不可用 → 钉成 auth(非瞬时、别重试)。
1216
1248
  throw new GitTransportError(message, credentialBlocked ? { kind: 'auth', retryable: false } : classified);
@@ -1258,7 +1290,8 @@ export class GitTransport implements SyncTransport {
1258
1290
  const u = new URL(this.o.repoUrl);
1259
1291
  return u.port ? `${u.hostname}:${u.port}` : u.hostname;
1260
1292
  } catch {
1261
- throw new GitTransportError(this.msg('sync.git.repoUrlInvalid', { url: this.o.repoUrl }));
1293
+ // RV1-F8:回显一律占位符 —— repoUrl 可能带 userinfo token,错误消息会进日志/响应/issue 截图
1294
+ throw new GitTransportError(this.msg('sync.git.repoUrlInvalid', { url: REPO_URL_PLACEHOLDER }));
1262
1295
  }
1263
1296
  }
1264
1297
 
@@ -1268,6 +1301,29 @@ export class GitTransport implements SyncTransport {
1268
1301
  try { return await this.o.credentials.getToken(); } catch { return null; }
1269
1302
  }
1270
1303
 
1304
+ /**
1305
+ * 回传体里的地址(工作副本自检的 origin / pushUrl)脱敏:只剥「凭据段」,其余原样保留。
1306
+ *
1307
+ * 为什么不整串换占位符:状态面板要显示「工作副本现在指向哪个远端」,用户靠 host/path 判断是否
1308
+ * 已跟随配置。为什么不一律剥 userinfo:`ssh://git@host` 里的 `git` 是 ssh 登录名(标准写法),
1309
+ * 剥掉会让用户认不出自己的远端 —— 与 validateRepoUrl 的口径保持一致(只有 password 位是凭据)。
1310
+ */
1311
+ private bareUrl(url: string): string {
1312
+ try {
1313
+ const parsed = new URL(url);
1314
+ const scheme = parsed.protocol.replace(/:$/, '').toLowerCase();
1315
+ const keepSshUser = scheme === 'ssh' && parsed.password === '';
1316
+ const dirty = parsed.password !== '' || (parsed.username !== '' && !keepSshUser);
1317
+ if (!dirty) return url;
1318
+ parsed.username = '';
1319
+ parsed.password = '';
1320
+ return parsed.toString();
1321
+ } catch {
1322
+ // 解析不了(scp 式 `git@host:path` 或本机路径):`git@` 是登录名不是凭据,原样返回
1323
+ return url;
1324
+ }
1325
+ }
1326
+
1271
1327
  /** 错误/日志脱敏:token(原文与 URL 编码两种形态)与 repoUrl 一律替换 */
1272
1328
  private mask(text: string, token: string | null): string {
1273
1329
  let out = text.split(this.o.repoUrl).join('[REPO_URL]');
@@ -0,0 +1,37 @@
1
+ import { redact } from '../security/redaction.ts';
2
+
3
+ /** 单侧预览文本上限(2 KiB **字符**预算):超出截断并追加省略号。 */
4
+ export const MERGE_PREVIEW_TEXT_LIMIT = 2048;
5
+
6
+ /** 严格 UTF-8 解码器:非法字节序列直接抛错(用于区分文本与二进制文件冲突)。 */
7
+ const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
8
+
9
+ /**
10
+ * 预览文本统一出口:先 redact(敏感值绝不回传浏览器),再截断到 2 KiB。
11
+ *
12
+ * 顺序恒为「先 redact 再截断」:反过来会把跨边界的敏感值切成半个形状,让脱敏匹配不到它
13
+ * (等于把秘密截一半发出去)。redact 之后的剩余文本已不含秘密形状,截断才安全。
14
+ */
15
+ export function finalizePreviewText(text: string): string {
16
+ const masked = redact(text);
17
+ return masked.length > MERGE_PREVIEW_TEXT_LIMIT ? `${masked.slice(0, MERGE_PREVIEW_TEXT_LIMIT)}…` : masked;
18
+ }
19
+
20
+ /** 把任意冲突侧值转成**可读字符串**;二进制只回传形状与字节数,绝不回传原始值对象。 */
21
+ export function previewTextOf(value: unknown): string {
22
+ if (value === undefined) return '';
23
+ if (typeof value === 'string') return finalizePreviewText(value);
24
+ if (value instanceof Uint8Array) {
25
+ try {
26
+ return finalizePreviewText(STRICT_UTF8.decode(value));
27
+ } catch {
28
+ // 二进制内容解码只会得到乱码(同样误导用户)→ 只回传形状;JSON 是数据形态,不是文案
29
+ return JSON.stringify({ binary: true, bytes: value.length });
30
+ }
31
+ }
32
+ try {
33
+ return finalizePreviewText(JSON.stringify(value) ?? '');
34
+ } catch {
35
+ return finalizePreviewText(String(value));
36
+ }
37
+ }
@@ -11,7 +11,9 @@
11
11
  * ② index 读操作抛错 → 显式失败(F2 语义,绝不回落成空索引);
12
12
  * ③ index = 200 + [] → 索引权威地声明集合为空,保持既有语义(既有用例
13
13
  * 「blob GC:删除最后一份快照后回收超保护窗的无人引用 blob」钉住该路径);
14
- * ④ GC 遇到「索引有条目但快照文件读不出来(非 404)」→ 本轮放弃(读不出来 ≠ 没引用)。
14
+ * ④ GC 遇到「索引有条目但快照文件读不出来(非 404)」→ 本轮放弃(读不出来 ≠ 没引用);
15
+ * ⑤ SYN-04(P1):与 WebDAV 同型 —— **404 也放弃本轮**(孤儿条目 / 假 404 拿不到内容就无法证明
16
+ * 「没人引用」),本轮 0 删除并记下放弃码(`lastGcSkip`,机器可判定)。
15
17
  */
16
18
  import test from 'node:test';
17
19
  import assert from 'node:assert/strict';
@@ -168,3 +170,26 @@ test('N1⑤:索引有条目但该快照文件读不出来(非 404)→ 本
168
170
  assert.equal(remote.deletedBlobs.length, 0, '读不出 sync-ccc-333 就无法证明它没引用 blob → 本轮不得删任何 blob');
169
171
  assert.equal(remote.objects.has(PREFIX + '/blobs/' + BLOB_HASH), true);
170
172
  });
173
+
174
+ test('N1⑥(SYN-04):索引有条目但该快照文件真 404 → 本轮放弃 GC(404 也证明不了「没引用」)+ 记放弃码', async () => {
175
+ const remote = makeRemote({
176
+ index: JSON.stringify([meta('sync-aaa-111', '1'), meta('sync-ccc-333', '3')]),
177
+ snapshotFiles: { 'sync-aaa-111': snapOf('sync-aaa-111', PLAIN) },
178
+ });
179
+ const t = new S3Transport(makeOptions(remote));
180
+ await t.delete('sync-aaa-111');
181
+ assert.equal(remote.deletedBlobs.length, 0, '404 与「读不出来」同属「无法证明没有引用」→ 本轮 0 删除');
182
+ assert.equal(remote.objects.has(PREFIX + '/blobs/' + BLOB_HASH), true, '没有被证明无引用的 blob 必须保留');
183
+ assert.equal(t.lastGcSkip, 'gc-skip-reference-missing', '必须记下本轮放弃码(机器可判定,文案由上层按码翻译)');
184
+ });
185
+
186
+ test('N1⑦(SYN-04):引用者 200 但内容坏掉 → 本轮放弃,放弃码 = snapshot-unparsable', async () => {
187
+ const remote = makeRemote({
188
+ index: JSON.stringify([meta('sync-aaa-111', '1'), meta('sync-ccc-333', '3')]),
189
+ snapshotFiles: { 'sync-aaa-111': snapOf('sync-aaa-111', PLAIN), 'sync-ccc-333': 'not-json' },
190
+ });
191
+ const t = new S3Transport(makeOptions(remote));
192
+ await t.delete('sync-aaa-111');
193
+ assert.equal(remote.deletedBlobs.length, 0, '解析不了 ⇒ 引用集不完整 ⇒ 本轮不得删任何 blob');
194
+ assert.equal(t.lastGcSkip, 'gc-skip-snapshot-unparsable');
195
+ });
@@ -26,6 +26,8 @@ import { requestOnce } from '../../utils/proxy.ts';
26
26
  import type { RawResponse } from '../../utils/proxy.ts';
27
27
  import { parseJsonSafe } from '../../utils/json.ts';
28
28
  import type { MsgFunc } from '../../core/msg-types.ts';
29
+ import { GC_SKIP_REFERENCE_MISSING, GC_SKIP_REFERENCE_UNREADABLE, GC_SKIP_SNAPSHOT_UNPARSABLE } from '../gc-skip.ts';
30
+ import type { GcSkipCode } from '../gc-skip.ts';
29
31
  import { deserializeSnapshot, serializeSnapshot } from '../snapshot-json.ts';
30
32
  import {
31
33
  BLOB_SECTIONS, gcBlobs, isBlobRefsSection, isFilesSectionLike, referencedBlobHashes,
@@ -213,6 +215,14 @@ export class S3Transport implements SyncTransport {
213
215
  msg: MsgFunc;
214
216
  };
215
217
 
218
+ /** 最近一轮 blob GC 的放弃码(`null` = 跑完或未触发;SYN-04)。 */
219
+ private gcSkip: GcSkipCode | null = null;
220
+
221
+ /** 最近一轮 blob GC 是否因「无法证明引用集完整」而整体放弃(`null` = 正常跑完),供上层给用户提示。 */
222
+ get lastGcSkip(): GcSkipCode | null {
223
+ return this.gcSkip;
224
+ }
225
+
216
226
  constructor(options: S3TransportOptions) {
217
227
  // t79:宿主传进来的 msg(core 目录)**不包含** sync.s3.* 键,直接用会显示裸键名 —— 合并本地目录。
218
228
  const msg = composeMsg(options.msg, s3Msg);
@@ -708,20 +718,29 @@ export class S3Transport implements SyncTransport {
708
718
  }
709
719
 
710
720
  /** blob 仓 GC:逐份读仍存在的快照收集引用,删除无人引用且超保护窗的 blob;
711
- * 读坏任一份快照 → 本轮直接放弃(宁可留垃圾,不可删在用的) */
721
+ * 读不坏任何一份快照 → 正常回收,否则本轮整体放弃(宁可留垃圾,不可删在用的)。
722
+ * SYN-04(P1):**404 也放弃本轮** —— 条目在但文件 404 = 拿不到内容 = 无法证明「没人引用」,
723
+ * 按残集回收会删掉仍在用的 blob(与 WebDAV 同型,口径一致)。 */
712
724
  private async gcBlobStore(secret: string, remaining: SyncSnapshotMeta[]): Promise<void> {
725
+ this.gcSkip = null;
713
726
  const index = await this.readBlobIndex(secret);
714
727
  if (Object.keys(index).length === 0) return;
715
728
  const referenced = new Set<string>();
716
729
  for (const meta of remaining) {
717
730
  const sent = await this.send('GET', snapshotKey(this.target.prefix, meta.id), { secret });
718
- // 条目在但文件 404 ⇒ 不可能引用 blob(安全跳过);其它失败 ⇒ 读不出来 ≠ 没引用,本轮放弃。
719
- if (sent.res.status === 404) continue;
720
- if (!sent.res.ok) return;
731
+ if (sent.res.status === 404) {
732
+ this.gcSkip = GC_SKIP_REFERENCE_MISSING;
733
+ return;
734
+ }
735
+ if (!sent.res.ok) {
736
+ this.gcSkip = GC_SKIP_REFERENCE_UNREADABLE;
737
+ return;
738
+ }
721
739
  let snap: SyncSnapshot;
722
740
  try {
723
741
  snap = deserializeSnapshot(await sent.res.text());
724
742
  } catch {
743
+ this.gcSkip = GC_SKIP_SNAPSHOT_UNPARSABLE;
725
744
  return;
726
745
  }
727
746
  const plain = snap.sections as Partial<Record<SectionId, unknown>>;
@@ -18,6 +18,9 @@
18
18
  * 本次不实现(保留信息可见,不代用户删数据)。
19
19
  */
20
20
  import { sha256Hex } from '../utils/hashing.ts';
21
+ import { isEncryptedSections } from './transport.ts';
22
+ import type { SectionData, SectionId } from '../schema/types.ts';
23
+ import type { SyncSnapshotMeta, SyncTransport } from './transport.ts';
21
24
 
22
25
  /** 会话单元 id 前缀(与 SessionsAdapter.unitIdOf 同口径:`sessions:<projectKey>/<sessionId>`)。 */
23
26
  export const SESSION_UNIT_PREFIX = 'sessions:';
@@ -121,3 +124,90 @@ export function sessionUnitIdsOfSection(section: unknown): string[] {
121
124
  export function unitSetFingerprint(units: readonly string[]): string {
122
125
  return sha256Hex([...units].sort().join('\n'));
123
126
  }
127
+
128
+ /* ---------------- SYN-02:墓碑跨设备继承(远端 → 本机 → 下一份快照) ---------------- */
129
+
130
+ /**
131
+ * 一次远端 list() 的读取结果。
132
+ *
133
+ * `metas` 为空**且**无 `reason` 才代表「远端确实为空」;有 reason = 读不到(按「看不到远端」
134
+ * 处理,调用方告警后继续,绝不把它当成「远端没东西」而误判)。
135
+ */
136
+ export interface RemoteSnapshotIndex {
137
+ metas: SyncSnapshotMeta[];
138
+ reason?: string;
139
+ }
140
+
141
+ /** 读一次远端快照索引(只读、零写入)。push 的「是否被推进」判定与墓碑继承共用这一份。 */
142
+ export async function listRemoteSnapshotIndex(transport: Pick<SyncTransport, 'list'>): Promise<RemoteSnapshotIndex> {
143
+ try {
144
+ return { metas: await transport.list() };
145
+ } catch (error) {
146
+ return { metas: [], reason: error instanceof Error ? error.message : String(error) };
147
+ }
148
+ }
149
+
150
+ /**
151
+ * 远端最新快照携带的会话删除墓碑(去重、去空、保序)。
152
+ *
153
+ * 只取**最新一份**:manifest.deletedSessions 是累积清单(见文件头),旧快照的墓碑必然被后续
154
+ * 快照包含;把整段历史并起来只会让「已经在别处撤销的墓碑」复活。
155
+ * 远端为空 / 老快照缺字段 → 空数组(行为与改造前逐字一致:不继承任何墓碑)。
156
+ */
157
+ export function remoteTombstonesOf(metas: readonly SyncSnapshotMeta[]): string[] {
158
+ const latest = metas.length === 0 ? undefined : metas[metas.length - 1];
159
+ const list = latest?.manifest.deletedSessions ?? [];
160
+ return [...new Set(list.filter((id) => typeof id === 'string' && id !== ''))];
161
+ }
162
+
163
+ /** 一次 push 的会话簿记结果(manifest / baseline / 告警共用这一份)。 */
164
+ export interface SessionBookkeepingResult {
165
+ /** false = 本次没有可用的会话枚举(没推 sessions / 适配器不支持 / 枚举失败或为空) */
166
+ tracked: boolean;
167
+ sessionUnits: string[];
168
+ tombstones: string[];
169
+ /** 本次**新**发现的删除(之前推过、这次本机没了) */
170
+ deletedNow: string[];
171
+ /** 从远端继承来的墓碑条数(本机此前没有、且本机也没有实体) */
172
+ inherited: number;
173
+ }
174
+
175
+ /**
176
+ * 墓碑集合的唯一计算点(本机删除 + 远端继承合进同一条累积链)。
177
+ *
178
+ * 去重、FIFO 上限、以及「本机还有实体 ⇒ 撤销墓碑」这条不变量对继承来的墓碑同样适用:
179
+ * 另一台设备删了、本机仍有 → 用户在本机显然没删,绝不把它记成本机删除。
180
+ */
181
+ export function planSessionBookkeeping(args: {
182
+ previousUnits: readonly string[];
183
+ previousTombstones: readonly string[];
184
+ localUnits: readonly string[];
185
+ exportedSessions: SectionData | undefined;
186
+ remoteTombstones: readonly string[];
187
+ }): Omit<SessionBookkeepingResult, 'tracked'> {
188
+ const known = args.previousTombstones;
189
+ const knownSet = new Set(known);
190
+ const localSet = new Set(args.localUnits);
191
+ const inherited = args.remoteTombstones.filter((id) => !knownSet.has(id) && !localSet.has(id)).length;
192
+ const { tombstones, deletedNow } = nextSessionTombstones({
193
+ previousUnits: [...args.previousUnits],
194
+ localUnits: [...args.localUnits],
195
+ previousTombstones: [...known, ...args.remoteTombstones],
196
+ });
197
+ return { sessionUnits: sessionUnitIdsOfSection(args.exportedSessions), tombstones, deletedNow, inherited };
198
+ }
199
+
200
+ /** 按墓碑剔除**将要导入**的会话(旧快照不得复活已删除的对话)。只动载荷,不动本机数据。 */
201
+ export function stripTombstonedSessionsInPlace<P extends {
202
+ manifest: { deletedSessions?: readonly string[] };
203
+ sections: unknown;
204
+ }>(snapshot: P): string[] {
205
+ const tombstones = snapshot.manifest.deletedSessions ?? [];
206
+ if (tombstones.length === 0 || isEncryptedSections(snapshot.sections)) return [];
207
+ const plain = snapshot.sections as Partial<Record<SectionId, SectionData>>;
208
+ const stripped = stripTombstonedUnits(plain['sessions'], tombstones);
209
+ if (stripped.removed.length === 0) return [];
210
+ plain['sessions'] = stripped.section as SectionData;
211
+ return stripped.removed;
212
+ }
213
+