dsh-config-manager 0.1.78 → 0.1.80

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 (302) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +401 -112
  26. package/lib/client.js +2899 -1525
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/ancestor.d.ts +22 -0
  82. package/lib/sync/ancestor.js +66 -2
  83. package/lib/sync/autosync-config.d.ts +73 -0
  84. package/lib/sync/autosync-config.js +89 -0
  85. package/lib/sync/autosync-scheduler.d.ts +80 -6
  86. package/lib/sync/autosync-scheduler.js +263 -40
  87. package/lib/sync/gist/gist-transport.d.ts +18 -1
  88. package/lib/sync/gist/gist-transport.js +42 -3
  89. package/lib/sync/gist/messages.d.ts +2 -0
  90. package/lib/sync/gist/messages.js +5 -0
  91. package/lib/sync/git/git-transport.d.ts +42 -3
  92. package/lib/sync/git/git-transport.js +146 -3
  93. package/lib/sync/merge.d.ts +47 -14
  94. package/lib/sync/merge.js +242 -222
  95. package/lib/sync/messages.d.ts +8 -0
  96. package/lib/sync/messages.js +19 -0
  97. package/lib/sync/risk.d.ts +19 -2
  98. package/lib/sync/risk.js +10 -3
  99. package/lib/sync/s3/messages.d.ts +6 -0
  100. package/lib/sync/s3/messages.js +13 -0
  101. package/lib/sync/s3/s3-transport.d.ts +9 -1
  102. package/lib/sync/s3/s3-transport.js +78 -6
  103. package/lib/sync/s3/sigv4.js +11 -2
  104. package/lib/sync/sync-engine.d.ts +212 -8
  105. package/lib/sync/sync-engine.js +514 -58
  106. package/lib/sync/sync-history.d.ts +11 -0
  107. package/lib/sync/sync-history.js +20 -0
  108. package/lib/sync/sync-state.d.ts +43 -0
  109. package/lib/sync/sync-state.js +44 -0
  110. package/lib/sync/sync-watcher.d.ts +76 -0
  111. package/lib/sync/sync-watcher.js +202 -0
  112. package/lib/sync/text-merge.d.ts +85 -0
  113. package/lib/sync/text-merge.js +513 -0
  114. package/lib/sync/transport.d.ts +48 -0
  115. package/lib/sync/transport.js +12 -0
  116. package/lib/sync/watch-scope.d.ts +38 -0
  117. package/lib/sync/watch-scope.js +95 -0
  118. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  119. package/lib/sync/webdav/webdav-transport.js +128 -11
  120. package/lib/ui/foreign-view.js +10 -0
  121. package/lib/ui/i18n.d.ts +9 -0
  122. package/lib/ui/i18n.js +22 -0
  123. package/lib/ui/rollback-view.d.ts +12 -0
  124. package/lib/ui/rollback-view.js +2 -0
  125. package/lib/ui/sync-settings-view.d.ts +47 -0
  126. package/lib/ui/sync-settings-view.js +24 -0
  127. package/lib/ui/types.d.ts +21 -0
  128. package/lib/utils/env-lock.d.ts +1 -0
  129. package/lib/utils/env-lock.js +7 -4
  130. package/package.json +6 -6
  131. package/src/adapters/credentials.ts +22 -4
  132. package/src/adapters/file-collection.ts +2 -1
  133. package/src/adapters/mcp.test.ts +235 -1
  134. package/src/adapters/mcp.ts +276 -5
  135. package/src/adapters/messages.ts +7 -0
  136. package/src/adapters/plugin-files.ts +2 -1
  137. package/src/adapters/plugins.test.ts +176 -2
  138. package/src/adapters/plugins.ts +107 -21
  139. package/src/adapters/pnpm-workspace.test.ts +89 -1
  140. package/src/adapters/pnpm-workspace.ts +127 -2
  141. package/src/adapters/prompts.ts +20 -3
  142. package/src/adapters/providers.ts +62 -4
  143. package/src/adapters/settings.ts +16 -3
  144. package/src/adapters/skills.test.ts +141 -0
  145. package/src/adapters/skills.ts +58 -2
  146. package/src/adapters/ui.ts +16 -3
  147. package/src/adapters/units-entries.test.ts +190 -0
  148. package/src/adapters/units.ts +50 -0
  149. package/src/adapters/workspaces.ts +2 -1
  150. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  151. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  152. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  153. package/src/client/common/Skeleton.tsx +32 -0
  154. package/src/client/common/api-base.test.ts +36 -0
  155. package/src/client/common/http.test.ts +12 -1
  156. package/src/client/common/http.ts +8 -2
  157. package/src/client/common/routes.ts +46 -3
  158. package/src/client/config-manager.module.css +321 -11
  159. package/src/client/home/HomePanel.tsx +14 -6
  160. package/src/client/import/ForeignImportView.tsx +3 -1
  161. package/src/client/import/import-wizard-steps.tsx +2 -1
  162. package/src/client/library/ArtifactRow.tsx +12 -2
  163. package/src/client/library/LibraryActions.tsx +6 -3
  164. package/src/client/library/LibraryPanel.tsx +21 -3
  165. package/src/client/library/RestorePlanView.tsx +4 -2
  166. package/src/client/locales.ts +3 -0
  167. package/src/client/market/market-api.test.ts +3 -2
  168. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  169. package/src/client/recovery/RollbackCard.tsx +7 -2
  170. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  171. package/src/client/recovery/recovery-locales.ts +20 -2
  172. package/src/client/recovery/recovery-view.test.ts +39 -1
  173. package/src/client/recovery/recovery-view.ts +23 -1
  174. package/src/client/run-store.test.ts +1 -1
  175. package/src/client/run-store.ts +4 -0
  176. package/src/client/sync/AutosyncCard.tsx +49 -6
  177. package/src/client/sync/MergeModal.tsx +220 -0
  178. package/src/client/sync/MergePreviewView.tsx +306 -0
  179. package/src/client/sync/SyncActivityBar.tsx +31 -0
  180. package/src/client/sync/SyncConfirmView.tsx +2 -1
  181. package/src/client/sync/SyncPanel.tsx +130 -87
  182. package/src/client/sync/SyncProbeRow.tsx +162 -0
  183. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  184. package/src/client/sync/WorkDirCard.tsx +44 -6
  185. package/src/client/sync/history-model.ts +4 -1
  186. package/src/client/sync/merge-view.test.ts +248 -0
  187. package/src/client/sync/merge-view.ts +398 -0
  188. package/src/client/sync/probe-view.test.ts +90 -0
  189. package/src/client/sync/probe-view.ts +63 -0
  190. package/src/client/sync/remote-device-view.test.ts +35 -0
  191. package/src/client/sync/remote-device-view.ts +25 -0
  192. package/src/client/sync/sync-api.test.ts +5 -3
  193. package/src/client/sync/sync-api.ts +212 -2
  194. package/src/client/sync/sync-entry.test.ts +78 -0
  195. package/src/client/sync/sync-entry.ts +53 -0
  196. package/src/client/sync/sync-locales.ts +234 -2
  197. package/src/client/sync/sync-view.test.ts +18 -1
  198. package/src/client/sync/sync-view.ts +20 -0
  199. package/src/core/backup.test.ts +41 -0
  200. package/src/core/backup.ts +20 -0
  201. package/src/core/mcp-connector-store.test.ts +265 -0
  202. package/src/core/mcp-connector-store.ts +516 -0
  203. package/src/core/messages-core.ts +4 -0
  204. package/src/core/messages.ts +46 -0
  205. package/src/core/phase3-host.ts +18 -1
  206. package/src/core/phase3-production-integration.test.ts +4 -0
  207. package/src/core/plugin-cli.test.ts +26 -0
  208. package/src/core/plugin-cli.ts +30 -4
  209. package/src/core/recovery-orchestrator.test.ts +104 -5
  210. package/src/core/recovery-orchestrator.ts +74 -17
  211. package/src/core/rollback-activity.test.ts +311 -0
  212. package/src/core/rollback-activity.ts +367 -0
  213. package/src/core/rollback-cross-session.test.ts +694 -0
  214. package/src/core/rollback-interrupted.test.ts +137 -0
  215. package/src/core/rollback-ledger.ts +32 -0
  216. package/src/core/rollback-merge.test.ts +130 -0
  217. package/src/core/rollback-merge.ts +78 -14
  218. package/src/core/rollback-point.ts +468 -91
  219. package/src/core/rollback-undo-journal.test.ts +508 -0
  220. package/src/core/rollback-undo-journal.ts +571 -0
  221. package/src/core/self-update.ts +2 -5
  222. package/src/core/text-fidelity.test.ts +189 -0
  223. package/src/core/text-fidelity.ts +197 -0
  224. package/src/core/types.ts +11 -3
  225. package/src/core/update-check.ts +2 -5
  226. package/src/core/validator.ts +14 -0
  227. package/src/foreign/bundle-origin.test.ts +265 -0
  228. package/src/foreign/bundle.ts +45 -5
  229. package/src/foreign/claude-code.test.ts +268 -0
  230. package/src/foreign/claude-code.ts +406 -25
  231. package/src/foreign/claude-plugins.test.ts +234 -0
  232. package/src/foreign/claude-sessions.test.ts +141 -0
  233. package/src/foreign/claude-sessions.ts +179 -23
  234. package/src/foreign/file-budget.test.ts +4 -1
  235. package/src/foreign/read-claude-code.test.ts +320 -0
  236. package/src/foreign/read-claude-code.ts +490 -71
  237. package/src/foreign/read-claude-plugins.ts +465 -0
  238. package/src/foreign/registry.ts +25 -2
  239. package/src/foreign/session-ir.test.ts +65 -0
  240. package/src/foreign/truth-table.test.ts +81 -0
  241. package/src/foreign/truth-table.ts +6 -3
  242. package/src/foreign/types.ts +162 -2
  243. package/src/index.facade.test.ts +26 -4
  244. package/src/index.ts +76 -19
  245. package/src/market/git-file-writer.test.ts +4 -0
  246. package/src/market/git-file-writer.ts +7 -1
  247. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  248. package/src/profiles/dsh-profile-launcher.ts +56 -1
  249. package/src/profiles/dsh-profile-shared.ts +8 -2
  250. package/src/routes/foreign.ts +8 -1
  251. package/src/routes/index.ts +1 -1
  252. package/src/routes/kit.ts +2 -2
  253. package/src/routes/sync.ts +176 -9
  254. package/src/schema/manifest-schema.test.ts +87 -2
  255. package/src/schema/manifest.ts +57 -1
  256. package/src/schema/types.ts +45 -2
  257. package/src/sync/ancestor.ts +76 -2
  258. package/src/sync/autosync-config.test.ts +73 -1
  259. package/src/sync/autosync-config.ts +124 -0
  260. package/src/sync/autosync-risk-tier.test.ts +38 -2
  261. package/src/sync/autosync-scheduler.test.ts +264 -12
  262. package/src/sync/autosync-scheduler.ts +296 -44
  263. package/src/sync/gist/gist-conditional-write.test.ts +203 -0
  264. package/src/sync/gist/gist-transport.ts +53 -5
  265. package/src/sync/gist/messages.ts +5 -0
  266. package/src/sync/git/git-probe.test.ts +80 -0
  267. package/src/sync/git/git-transport.test.ts +185 -0
  268. package/src/sync/git/git-transport.ts +178 -5
  269. package/src/sync/merge.test.ts +188 -55
  270. package/src/sync/merge.ts +282 -224
  271. package/src/sync/messages.ts +19 -0
  272. package/src/sync/risk.ts +29 -5
  273. package/src/sync/s3/messages.ts +13 -0
  274. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  275. package/src/sync/s3/s3-transport.test.ts +2 -1
  276. package/src/sync/s3/s3-transport.ts +87 -9
  277. package/src/sync/s3/sigv4.test.ts +4 -1
  278. package/src/sync/s3/sigv4.ts +11 -2
  279. package/src/sync/sync-device-identity.test.ts +267 -0
  280. package/src/sync/sync-engine.test.ts +410 -19
  281. package/src/sync/sync-engine.ts +602 -57
  282. package/src/sync/sync-history.test.ts +16 -1
  283. package/src/sync/sync-history.ts +26 -0
  284. package/src/sync/sync-state.test.ts +64 -0
  285. package/src/sync/sync-state.ts +79 -0
  286. package/src/sync/sync-watcher.test.ts +197 -0
  287. package/src/sync/sync-watcher.ts +246 -0
  288. package/src/sync/text-merge.test.ts +358 -0
  289. package/src/sync/text-merge.ts +554 -0
  290. package/src/sync/transport.ts +57 -0
  291. package/src/sync/watch-scope.test.ts +122 -0
  292. package/src/sync/watch-scope.ts +120 -0
  293. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  294. package/src/sync/webdav/webdav-transport.ts +137 -14
  295. package/src/ui/foreign-view.test.ts +34 -0
  296. package/src/ui/foreign-view.ts +10 -0
  297. package/src/ui/i18n.ts +22 -0
  298. package/src/ui/rollback-view.ts +14 -0
  299. package/src/ui/sync-settings-view.test.ts +28 -1
  300. package/src/ui/sync-settings-view.ts +43 -0
  301. package/src/ui/types.ts +22 -0
  302. package/src/utils/env-lock.ts +8 -5
package/src/sync/risk.ts CHANGED
@@ -9,8 +9,16 @@
9
9
  * 本模块只做分流,`SECTION_RISK_TIER` 是注册表的派生视图(保留给既有调用方);
10
10
  * - 双向 conflict 分区永远进 review(无论风险与 firstSync 与否);
11
11
  * - firstSync=true 时所有非 skip 项一律进 review(安全第一:人工确认后才开启自动);
12
- * - firstSync=false 时按风险等级分流:低风险且 useRemote/keepLocal 进 autoApply;
13
- * 中风险、high、双向冲突 → review;skip 或无变化 → skipped。
12
+ * - firstSync=false 时按风险等级分流:决策明确(useRemote / keepLocal / **merge** 即三方精细
13
+ 合并成功)且「风险等级 ≤ maxAutoTier」的分区进 autoApply;超过阈值的、high、双向冲突 → review;
14
+ skip 或无变化 → skipped。
15
+ * - 'merge' 与 useRemote/keepLocal 同等对待:它的 merged 是双方改动的合并结果(不是单侧原值)。
16
+ *
17
+ * maxAutoTier(真·自动同步,2026-10):
18
+ * - 缺省 'low' = 改造前的口径(只有低风险分区自动落盘),既有调用方(预览/UI)行为不变;
19
+ * - 自动同步按用户配置传 'medium' ⇒ plugins / mcp 的中风险改动也自动落盘。
20
+ * 这是用户拍板的产品决策:不改这条,「同步所有更改」就只是句口号(中风险分区永远停在待确认)。
21
+ * - **high 永远不参与自动应用**:类型上就只允许 low|medium,杜绝「传 high 顺手放行全部」。
14
22
  */
15
23
  import type { MergePlan, MergeSectionResult } from './merge.ts';
16
24
  import type { SectionId } from '../schema/types.ts';
@@ -48,6 +56,21 @@ export interface ClassifyOptions {
48
56
  * 调用方在 SyncEngine 中维护 firstSyncCompleted 标志。
49
57
  */
50
58
  firstSync: boolean;
59
+ /**
60
+ * 允许自动落盘的最高风险等级(缺省 'low' = 改造前的行为)。
61
+ *
62
+ * 只有自动同步路径会传 'medium'(用户在配置里显式选的,缺省也是 medium);
63
+ * high 在类型层面就被排除 —— 那不是「配置项」,是安全边界。
64
+ */
65
+ maxAutoTier?: 'low' | 'medium';
66
+ }
67
+
68
+ /** 风险等级序(用于「等级 ≤ 阈值」比较;high 永不满足自动应用,因为阈值上限就是 medium)。 */
69
+ const TIER_RANK: Readonly<Record<RiskTier, number>> = { low: 0, medium: 1, high: 2 };
70
+
71
+ /** 该风险等级是否在允许自动落盘的阈值之内。 */
72
+ export function tierWithinAutoApply(tier: RiskTier, maxAutoTier: 'low' | 'medium'): boolean {
73
+ return TIER_RANK[tier] <= TIER_RANK[maxAutoTier];
51
74
  }
52
75
 
53
76
  /** 自动应用三组结果(互斥:同一 sectionId 只出现在一组) */
@@ -65,6 +88,7 @@ export function classifyMergePlan(plan: MergePlan, opts: ClassifyOptions): SyncA
65
88
  const autoApply: MergeSectionResult[] = [];
66
89
  const review: MergeSectionResult[] = [];
67
90
  const skipped: MergeSectionResult[] = [];
91
+ const maxAutoTier = opts.maxAutoTier ?? 'low';
68
92
  for (const r of plan.sections) {
69
93
  // 跳过:决策为 skip(远端缺且本地未改 / 完全无变化)
70
94
  if (r.decision === 'skip') {
@@ -87,11 +111,11 @@ export function classifyMergePlan(plan: MergePlan, opts: ClassifyOptions): SyncA
87
111
  review.push(r);
88
112
  continue;
89
113
  }
90
- if (tier === 'low') {
91
- // 低风险且决策明确(useRemote / keepLocal)→ 自动应用
114
+ if (tierWithinAutoApply(tier, maxAutoTier)) {
115
+ // 决策明确(useRemote / keepLocal / merge)且等级在阈值内 → 自动应用
92
116
  autoApply.push(r);
93
117
  } else {
94
- // medium / high → 待审
118
+ // 超过阈值(缺省阈值下即 medium / high)→ 待审
95
119
  review.push(r);
96
120
  }
97
121
  }
@@ -39,6 +39,13 @@ export const s3Zh = {
39
39
  'sync.s3.providerNoteKodo': '七牛 Kodo S3 兼容(region 必填,如 cn-east-1;endpoint 可覆盖)',
40
40
  'sync.s3.unknownSignatureDialect': '未知签名方言: {id}',
41
41
  'sync.s3.blobHashInvalid': '内容哈希形状非法,拒绝写入 blob 仓: {hash}',
42
+ // ---- cloud-sync 借鉴项 ①②:条件写并发保护 + 连通性探针 ----
43
+ 'sync.s3.concurrentWrite': '远端索引已被其它设备更新(HTTP {status}):本次写入已中止,请重新同步后再试',
44
+ 'sync.s3.probeOk': '连接正常(远端 {count} 份快照)',
45
+ 'sync.s3.probeRegionRedirect': '端点把请求重定向到区域 {region}:请把 region 改成该值后重试',
46
+ 'sync.s3.probeRedirectNoRegion': '端点返回了重定向(HTTP {status})但没有给出区域:请核对 endpoint 与 region',
47
+ 'sync.s3.probeBucketMissing': '桶不存在或无权访问(HTTP 404):请核对 bucket 名与 endpoint',
48
+ 'sync.s3.probeAccessDenied': '访问被拒绝(HTTP 403):请核对 AccessKey、密钥与桶权限',
42
49
  } as const;
43
50
 
44
51
  export const s3En: Record<keyof typeof s3Zh, string> = {
@@ -67,6 +74,12 @@ export const s3En: Record<keyof typeof s3Zh, string> = {
67
74
  'sync.s3.providerNoteKodo': 'Qiniu Kodo S3-compatible (region required, e.g. cn-east-1; endpoint can be overridden)',
68
75
  'sync.s3.unknownSignatureDialect': 'Unknown signature dialect: {id}',
69
76
  'sync.s3.blobHashInvalid': 'Malformed content hash; refusing to write to the blob store: {hash}',
77
+ 'sync.s3.concurrentWrite': 'The remote index was updated by another device (HTTP {status}): this write was aborted, please sync again',
78
+ 'sync.s3.probeOk': 'Connection OK ({count} remote snapshots)',
79
+ 'sync.s3.probeRegionRedirect': 'The endpoint redirects to region {region}: set region to that value and retry',
80
+ 'sync.s3.probeRedirectNoRegion': 'The endpoint returned a redirect (HTTP {status}) without a region: check endpoint and region',
81
+ 'sync.s3.probeBucketMissing': 'Bucket not found or not accessible (HTTP 404): check the bucket name and endpoint',
82
+ 'sync.s3.probeAccessDenied': 'Access denied (HTTP 403): check the AccessKey, secret, and bucket permissions',
70
83
  };
71
84
 
72
85
  /** 插值:{param} → 形参值;缺参原样保留(与 core/messages.ts 同口径)。 */
@@ -0,0 +1,330 @@
1
+ /**
2
+ * S3 兼容系通道:本轮借鉴项的**针对性**单测(不重复 s3-transport.test.ts 的既有契约)。
3
+ * - ① 条件写:索引 ETag → if-match;索引缺失且远端无内容 → if-none-match: *;
4
+ * 索引 PUT 412 → conflict;409 **带条件头** → conflict;
5
+ * 409 **不带条件头** → 绝不走并发写冲突那条路径(isConcurrentWrite 只认带条件头的 409);
6
+ * - ② 探针:HEAD 回 301/307/308 + x-amz-bucket-region → 抛错且文案里带可执行的 region;
7
+ * 404 / 403 各自文案;HEAD 200 时读索引回报快照数。
8
+ */
9
+ import test from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+
12
+ import { S3Transport, S3TransportError } from './s3-transport.ts';
13
+ import type { S3RequestFn, S3Response, S3TransportOptions } from './s3-transport.ts';
14
+ import { SyncTransportError } from '../transport.ts';
15
+ import type { SyncSnapshot } from '../transport.ts';
16
+ import { s3Msg } from './messages.ts';
17
+
18
+ const ENDPOINT = 'https://minio.example.com:9000';
19
+ const BUCKET = 'demo-bucket';
20
+ const PREFIX = 'dsh-config-manager';
21
+ const ACCESS_KEY_ID = 'AKIAIOSFODNN7EXAMPLE';
22
+ const SECRET = 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY';
23
+ const BASE_OBJECT = ENDPOINT + '/' + BUCKET + '/' + PREFIX;
24
+ const INDEX_URL = BASE_OBJECT + '/index.json';
25
+ const BLOBS_INDEX_URL = BASE_OBJECT + '/blobs-index.json';
26
+ const SNAPSHOT_URL = BASE_OBJECT + '/snap-001.json';
27
+
28
+ interface S3Call { method: string; url: string; headers: Record<string, string>; body?: string }
29
+
30
+ function res(status: number, bodyText = '', headers?: Record<string, string>): S3Response {
31
+ return {
32
+ status,
33
+ ok: status >= 200 && status < 300,
34
+ ...(headers !== undefined ? { headers } : {}),
35
+ async text() { return bodyText; },
36
+ };
37
+ }
38
+
39
+ function record(calls: S3Call[], handler: (m: S3Call) => S3Response): S3RequestFn {
40
+ return async (method, url, opts = {}) => {
41
+ const rec: S3Call = { method, url, headers: opts.headers ?? {}, ...(opts.body !== undefined ? { body: opts.body } : {}) };
42
+ calls.push(rec);
43
+ return handler(rec);
44
+ };
45
+ }
46
+
47
+ function makeOptions(request: S3RequestFn, overrides: Partial<S3TransportOptions> = {}): S3TransportOptions {
48
+ return {
49
+ provider: 'minio',
50
+ endpoint: ENDPOINT,
51
+ region: 'us-east-1',
52
+ bucket: BUCKET,
53
+ accessKeyId: ACCESS_KEY_ID,
54
+ credentials: { getSecretAccessKey: async () => SECRET },
55
+ pathStyle: true,
56
+ request,
57
+ now: () => new Date('2026-10-18T10:00:00.000Z'),
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ function sampleSnapshot(overrides: Partial<SyncSnapshot> = {}): SyncSnapshot {
63
+ return {
64
+ id: 'snap-001',
65
+ createdAt: '2026-10-18T10:00:00.000Z',
66
+ manifest: {
67
+ schemaVersion: 1,
68
+ dshVersion: '1.2.3',
69
+ platform: 'win32',
70
+ sectionIds: ['settings', 'providers'],
71
+ containsSecrets: false,
72
+ },
73
+ sections: {
74
+ settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } },
75
+ providers: { version: 1, providers: { deepseek: { route: '/v1' } } },
76
+ },
77
+ ...overrides,
78
+ };
79
+ }
80
+
81
+ const putIndexCalls = (calls: S3Call[]): S3Call[] => calls.filter((c) => c.method === 'PUT' && c.url === INDEX_URL);
82
+
83
+ /* ---------------- ① 条件写 ---------------- */
84
+
85
+ test('条件写①:索引 GET 回 ETag → 索引 PUT 带 if-match(小写头)钉住该 ETag', async () => {
86
+ const calls: S3Call[] = [];
87
+ const t = new S3Transport(makeOptions(record(calls, (m) => {
88
+ if (m.method === 'GET' && m.url === INDEX_URL) return res(200, '[]', { etag: '"v1-s3"' });
89
+ if (m.method === 'PUT' && m.url === SNAPSHOT_URL) return res(200, '');
90
+ if (m.method === 'PUT' && m.url === INDEX_URL) return res(200, '');
91
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
92
+ })));
93
+
94
+ await t.upload(sampleSnapshot());
95
+
96
+ const puts = putIndexCalls(calls);
97
+ assert.equal(puts.length, 1, '索引只 PUT 一次');
98
+ assert.equal(puts[0]!.headers['if-match'], '"v1-s3"', '有 ETag 时必须用 if-match');
99
+ assert.equal(puts[0]!.headers['if-none-match'], undefined);
100
+ });
101
+
102
+ test('条件写①:索引 404 且远端无内容(blobs-index 404)→ 索引 PUT 带 if-none-match: *', async () => {
103
+ const calls: S3Call[] = [];
104
+ const t = new S3Transport(makeOptions(record(calls, (m) => {
105
+ if (m.method === 'GET' && m.url === INDEX_URL) return res(404, '');
106
+ if (m.method === 'GET' && m.url === BLOBS_INDEX_URL) return res(404, '');
107
+ if (m.method === 'PUT' && m.url === SNAPSHOT_URL) return res(200, '');
108
+ if (m.method === 'PUT' && m.url === INDEX_URL) return res(200, '');
109
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
110
+ })));
111
+
112
+ await t.upload(sampleSnapshot());
113
+
114
+ const puts = putIndexCalls(calls);
115
+ assert.equal(puts.length, 1);
116
+ assert.equal(puts[0]!.headers['if-none-match'], '*', '首推(远端无内容)必须用 if-none-match: * 防两设备同时首推');
117
+ assert.equal(puts[0]!.headers['if-match'], undefined);
118
+ });
119
+
120
+ test('条件写①:索引 PUT 回 412(带条件头)→ 抛 conflict,文案来自 sync.s3.concurrentWrite', async () => {
121
+ const calls: S3Call[] = [];
122
+ const t = new S3Transport(makeOptions(record(calls, (m) => {
123
+ if (m.method === 'GET' && m.url === INDEX_URL) return res(200, '[]', { etag: '"v1-s3"' });
124
+ if (m.method === 'PUT' && m.url === SNAPSHOT_URL) return res(200, '');
125
+ if (m.method === 'PUT' && m.url === INDEX_URL) return res(412, 'PreconditionFailed');
126
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
127
+ })));
128
+
129
+ await assert.rejects(
130
+ () => t.upload(sampleSnapshot()),
131
+ (err: unknown) => {
132
+ assert.ok(err instanceof S3TransportError);
133
+ assert.ok(err instanceof SyncTransportError, '统一错误基类(上层按 kind 分流)');
134
+ assert.equal(err.kind, 'conflict');
135
+ assert.equal(err.retryable, false);
136
+ assert.equal(err.status, 412);
137
+ assert.equal(err.message, s3Msg('sync.s3.concurrentWrite', { status: '412', url: INDEX_URL }));
138
+ return true;
139
+ },
140
+ );
141
+ });
142
+
143
+ test('条件写①:索引 PUT 回 409 且**带了条件头** → 仍判 conflict(S3 兼容端点的并发写语义)', async () => {
144
+ const calls: S3Call[] = [];
145
+ const t = new S3Transport(makeOptions(record(calls, (m) => {
146
+ if (m.method === 'GET' && m.url === INDEX_URL) return res(200, '[]', { etag: '"v1-s3"' });
147
+ if (m.method === 'PUT' && m.url === SNAPSHOT_URL) return res(200, '');
148
+ if (m.method === 'PUT' && m.url === INDEX_URL) return res(409, 'Conflict');
149
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
150
+ })));
151
+
152
+ await assert.rejects(
153
+ () => t.upload(sampleSnapshot()),
154
+ (err: unknown) => {
155
+ assert.ok(err instanceof S3TransportError);
156
+ assert.equal(err.kind, 'conflict');
157
+ assert.equal(err.status, 409);
158
+ assert.equal(err.message, s3Msg('sync.s3.concurrentWrite', { status: '409', url: INDEX_URL }));
159
+ return true;
160
+ },
161
+ );
162
+ });
163
+
164
+ test('回归:索引 PUT 回 409 但**没带条件头** → 不得被当成并发写冲突(SSE/无条件写场景)', async () => {
165
+ const calls: S3Call[] = [];
166
+ const t = new S3Transport(makeOptions(record(calls, (m) => {
167
+ // 服务端不返回 ETag、索引又存在(非 404)⇒ 条件头为空 ⇒ 无条件写
168
+ if (m.method === 'GET' && m.url === INDEX_URL) return res(200, '[]');
169
+ if (m.method === 'PUT' && m.url === SNAPSHOT_URL) return res(200, '');
170
+ if (m.method === 'PUT' && m.url === INDEX_URL) return res(409, 'Conflict');
171
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
172
+ })));
173
+
174
+ await assert.rejects(
175
+ () => t.upload(sampleSnapshot()),
176
+ (err: unknown) => {
177
+ assert.ok(err instanceof S3TransportError);
178
+ assert.equal(err.status, 409);
179
+ const put = putIndexCalls(calls)[0]!;
180
+ assert.equal(Object.keys(put.headers).filter((k) => k === 'if-match' || k === 'if-none-match').length, 0,
181
+ '本用例前提:这一 PUT 确实没带条件头');
182
+ assert.notEqual(
183
+ err.message,
184
+ s3Msg('sync.s3.concurrentWrite', { status: '409', url: INDEX_URL }),
185
+ '无条件的 409 另有含义,绝不能套用「远端被其它设备改过」的并发写文案',
186
+ );
187
+ assert.equal(
188
+ err.message,
189
+ s3Msg('sync.s3.requestFailed', { method: 'PUT', url: INDEX_URL, status: '409', err: 'Conflict' }),
190
+ '应走通用请求失败文案(isConcurrentWrite 只认带条件头的 409)',
191
+ );
192
+ return true;
193
+ },
194
+ );
195
+ });
196
+
197
+ test('条件写①(删除路径):delete 读 index 拿 ETag → 写回剩余索引的 PUT 带 if-match;412 → conflict', async () => {
198
+ const meta = {
199
+ id: 'snap-001',
200
+ createdAt: '2026-10-18T09:00:00.000Z',
201
+ sections: {},
202
+ manifest: { schemaVersion: 1, dshVersion: '1', platform: 'win32', sectionIds: [], containsSecrets: false },
203
+ };
204
+ const make = (putStatus: number) => new S3Transport(makeOptions(record([], (m) => {
205
+ if (m.method === 'GET' && m.url === INDEX_URL) return res(200, JSON.stringify([meta]), { etag: '"v1-del"' });
206
+ if (m.method === 'DELETE' && m.url === SNAPSHOT_URL) return res(204, '');
207
+ if (m.method === 'PUT' && m.url === INDEX_URL) return res(putStatus, putStatus === 412 ? 'PreconditionFailed' : '');
208
+ if (m.method === 'GET' && m.url === BLOBS_INDEX_URL) return res(404, '');
209
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
210
+ })));
211
+
212
+ const calls: S3Call[] = [];
213
+ const t = new S3Transport(makeOptions(record(calls, (m) => {
214
+ if (m.method === 'GET' && m.url === INDEX_URL) return res(200, JSON.stringify([meta]), { etag: '"v1-del"' });
215
+ if (m.method === 'DELETE' && m.url === SNAPSHOT_URL) return res(204, '');
216
+ if (m.method === 'PUT' && m.url === INDEX_URL) return res(200, '');
217
+ if (m.method === 'GET' && m.url === BLOBS_INDEX_URL) return res(404, '');
218
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
219
+ })));
220
+ await t.delete('snap-001');
221
+ const put = putIndexCalls(calls)[0]!;
222
+ assert.equal(put.headers['if-match'], '"v1-del"', '删除路径的索引写回同样是条件写');
223
+ assert.equal(put.body, '[]');
224
+
225
+ await assert.rejects(
226
+ () => make(412).delete('snap-001'),
227
+ (err: unknown) => {
228
+ assert.ok(err instanceof S3TransportError);
229
+ assert.equal(err.kind, 'conflict');
230
+ assert.equal(err.status, 412);
231
+ assert.equal(err.message, s3Msg('sync.s3.concurrentWrite', { status: '412', url: INDEX_URL }));
232
+ return true;
233
+ },
234
+ );
235
+ });
236
+
237
+ /* ---------------- ② 探针 ---------------- */
238
+
239
+ test('② 探针:HEAD 回 301 + x-amz-bucket-region: cn-hangzhou → 抛错且文案含该 region', async () => {
240
+ const calls: S3Call[] = [];
241
+ const t = new S3Transport(makeOptions(record(calls, (m) => {
242
+ if (m.method === 'HEAD') return res(301, '', { 'x-amz-bucket-region': 'cn-hangzhou' });
243
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
244
+ })));
245
+
246
+ await assert.rejects(
247
+ () => t.probe(),
248
+ (err: unknown) => {
249
+ assert.ok(err instanceof S3TransportError);
250
+ assert.equal(err.status, 301);
251
+ assert.equal(err.kind, 'client');
252
+ assert.equal(err.message, s3Msg('sync.s3.probeRegionRedirect', { region: 'cn-hangzhou' }));
253
+ assert.match(err.message, /cn-hangzhou/, '文案必须给出可执行的 region 提示');
254
+ return true;
255
+ },
256
+ );
257
+ assert.equal(calls.length, 1, '301 应立即抛错,不再读索引');
258
+ });
259
+
260
+ test('② 探针:region 头缺失 / 大写形态 → 分别走「无 region 提示」与正常解析', async () => {
261
+ const callsA: S3Call[] = [];
262
+ const tA = new S3Transport(makeOptions(record(callsA, (m) => (m.method === 'HEAD' ? res(307, '') : res(500, '')))));
263
+ await assert.rejects(
264
+ () => tA.probe(),
265
+ (err: unknown) => {
266
+ assert.equal((err as S3TransportError).message, s3Msg('sync.s3.probeRedirectNoRegion', { status: '307' }));
267
+ assert.equal((err as S3TransportError).status, 307);
268
+ return true;
269
+ },
270
+ );
271
+
272
+ const callsB: S3Call[] = [];
273
+ const tB = new S3Transport(makeOptions(record(callsB, (m) => {
274
+ if (m.method === 'HEAD') return res(308, '', { 'X-Amz-Bucket-Region': 'ap-guangzhou' });
275
+ return res(500, '');
276
+ })));
277
+ await assert.rejects(
278
+ () => tB.probe(),
279
+ (err: unknown) => {
280
+ assert.equal((err as S3TransportError).message, s3Msg('sync.s3.probeRegionRedirect', { region: 'ap-guangzhou' }));
281
+ return true;
282
+ },
283
+ );
284
+ });
285
+
286
+ test('② 探针:HEAD 404 → 桶不存在文案(kind=notfound);HEAD 403 → 权限文案(kind=auth)', async () => {
287
+ const mk = (status: number): S3Transport => new S3Transport(makeOptions(record([], (m) => {
288
+ if (m.method === 'HEAD') return res(status, '');
289
+ return res(500, '');
290
+ })));
291
+
292
+ await assert.rejects(
293
+ () => mk(404).probe(),
294
+ (err: unknown) => {
295
+ assert.equal((err as S3TransportError).message, s3Msg('sync.s3.probeBucketMissing'));
296
+ assert.equal((err as S3TransportError).kind, 'notfound');
297
+ assert.equal((err as S3TransportError).status, 404);
298
+ return true;
299
+ },
300
+ );
301
+ await assert.rejects(
302
+ () => mk(403).probe(),
303
+ (err: unknown) => {
304
+ assert.equal((err as S3TransportError).message, s3Msg('sync.s3.probeAccessDenied'));
305
+ assert.equal((err as S3TransportError).kind, 'auth');
306
+ assert.equal((err as S3TransportError).status, 403);
307
+ return true;
308
+ },
309
+ );
310
+ });
311
+
312
+ test('② 探针:HEAD 200 + index 有 1 条 → snapshotCount=1、message 带计数、零写入', async () => {
313
+ const calls: S3Call[] = [];
314
+ const meta = {
315
+ id: 'snap-a',
316
+ createdAt: '2026-10-18T08:00:00.000Z',
317
+ sections: {},
318
+ manifest: { schemaVersion: 1, dshVersion: '1', platform: 'win32', sectionIds: [], containsSecrets: false },
319
+ };
320
+ const t = new S3Transport(makeOptions(record(calls, (m) => {
321
+ if (m.method === 'HEAD') return res(200, '');
322
+ if (m.method === 'GET' && m.url === INDEX_URL) return res(200, JSON.stringify([meta]));
323
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
324
+ })));
325
+
326
+ const result = await t.probe();
327
+ assert.equal(result.snapshotCount, 1);
328
+ assert.equal(result.message, s3Msg('sync.s3.probeOk', { count: '1' }));
329
+ assert.equal(calls.filter((c) => c.method === 'PUT' || c.method === 'DELETE').length, 0, '探针零写入');
330
+ });
@@ -291,7 +291,8 @@ test('阿里云 OSS 变体:请求头走 OSS4(x-oss-date / x-oss-content-sha2
291
291
  assert.equal(call.headers['x-oss-date'], '20261005T014910Z');
292
292
  assert.equal(call.headers['x-oss-content-sha256'], 'UNSIGNED-PAYLOAD');
293
293
  assert.equal(call.headers['x-amz-date'], undefined);
294
- assert.match(call.headers['authorization'] ?? '', /^OSS4-HMAC-SHA256 Credential=.*\/cn-hangzhou\/oss\/aliyun_v4_request, AdditionalHeaders=, Signature=[a-f0-9]{64}$/);
294
+ // 官方 SDK 格式:无空格、空 AdditionalHeaders 省略(issue #109:写成 ", AdditionalHeaders=, Signature=" 会被 OSS 拒绝)
295
+ assert.match(call.headers['authorization'] ?? '', /^OSS4-HMAC-SHA256 Credential=.*\/cn-hangzhou\/oss\/aliyun_v4_request,Signature=[a-f0-9]{64}$/);
295
296
  });
296
297
 
297
298
  test('密钥纪律:错误消息里的响应体被脱敏(密钥绝不出现),且目标对象不含任何密钥', async () => {
@@ -37,7 +37,7 @@ import {
37
37
  isEncryptedSections, sectionsEqual, SyncTransportError, withSyncRetry,
38
38
  } from '../transport.ts';
39
39
  import type {
40
- SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions,
40
+ SyncProbeResult, SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions,
41
41
  } from '../transport.ts';
42
42
  import { signRequest } from './sigv4.ts';
43
43
  import type { SigV4DialectId } from './sigv4.ts';
@@ -57,6 +57,21 @@ const ERR_BODY_MAX = 500;
57
57
  const BLOB_HASH_RE = /^[a-f0-9]{64}$/;
58
58
  const REDACTED = '[REDACTED]';
59
59
 
60
+ /**
61
+ * 条件写头(cloud-sync 借鉴项 ①):有 ETag → If-Match(远端必须仍是我读到的那一版);
62
+ * 索引本来就不存在 → If-None-Match: *(防两设备同时首推);服务端不给 ETag → 空(无条件写,不改既有行为)。
63
+ */
64
+ function conditionalHeaders(etag: string | undefined, indexMissing: boolean): Record<string, string> {
65
+ if (etag !== undefined) return { 'if-match': etag };
66
+ return indexMissing ? { 'if-none-match': '*' } : {};
67
+ }
68
+
69
+ /** 条件写失败判定:412 恒是;409 仅在**确实带了条件头**时才算(无条件的 409 另有含义,不吞)。 */
70
+ function isConcurrentWrite(status: number, headers: Record<string, string>): boolean {
71
+ if (status === 412) return true;
72
+ return status === 409 && Object.keys(headers).length > 0;
73
+ }
74
+
60
75
  /** 请求选项:headers / body / 覆盖默认超时(ms;0 = 不超时) */
61
76
  export interface S3RequestOptions {
62
77
  headers?: Record<string, string>;
@@ -258,7 +273,7 @@ export class S3Transport implements SyncTransport {
258
273
  const secret = await this.secretOnce();
259
274
  const meta = computeSnapshotMeta(snapshot);
260
275
  // audit-sync sync-N1 覆盖面补全(t66,来自 t22 的 C7):upload 也是写路径,**不接受 404 = 空集合**。
261
- const { entries: idxBefore, missing: indexMissing } = await this.readIndexDetailed(secret);
276
+ const { entries: idxBefore, missing: indexMissing, etag: idxEtag } = await this.readIndexDetailed(secret);
262
277
  if (indexMissing && await this.remoteHasPriorContent(secret)) {
263
278
  // 索引 404 只是**缺信息**:远端已有历史内容时按空索引读改写,会把权威索引 PUT 成只有本条目
264
279
  // → 其余快照从列表整体消失,随后 delete + GC 会回收仍被现存快照文件引用的 blob(不可恢复)。
@@ -281,7 +296,13 @@ export class S3Transport implements SyncTransport {
281
296
  const merged = idxBefore.filter((m) => m.id !== snapshot.id);
282
297
  merged.push(meta);
283
298
  merged.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0));
284
- const putIdx = await this.send('PUT', indexKey(this.target.prefix), { body: JSON.stringify(merged), secret });
299
+ const condHeaders = conditionalHeaders(idxEtag, indexMissing);
300
+ const putIdx = await this.send('PUT', indexKey(this.target.prefix), {
301
+ body: JSON.stringify(merged),
302
+ secret,
303
+ headers: condHeaders,
304
+ });
305
+ if (isConcurrentWrite(putIdx.res.status, condHeaders)) throw this.concurrentWrite(putIdx.url, putIdx.res.status);
285
306
  if (!putIdx.res.ok) {
286
307
  throw new S3TransportError(await this.failText('PUT', putIdx.url, putIdx.res, secret), classifyHttpStatus(putIdx.res.status));
287
308
  }
@@ -318,7 +339,7 @@ export class S3Transport implements SyncTransport {
318
339
  // index.json 覆盖成 [](其余快照从列表消失)并让 blob GC 删掉仍被引用的会话 blob —— 一律中止。
319
340
  // audit-sync sync-N1(P0):与 WebDAV 同型 —— 写路径**不接受 404 = 空集合**。索引真缺失时
320
341
  // 「远端一条快照都没有」是缺信息而非事实,沿用空索引会覆盖 index.json 并用空引用集回收 blob。
321
- const { entries: idx, missing: indexMissing } = await this.readIndexDetailed(secret);
342
+ const { entries: idx, missing: indexMissing, etag: idxEtag } = await this.readIndexDetailed(secret);
322
343
  const del = await this.send('DELETE', snapshotKey(this.target.prefix, id), { secret });
323
344
  if (!del.res.ok && del.res.status !== 404) {
324
345
  throw new S3TransportError(await this.failText('DELETE', del.url, del.res, secret), classifyHttpStatus(del.res.status));
@@ -328,7 +349,13 @@ export class S3Transport implements SyncTransport {
328
349
  if (indexMissing) return;
329
350
  const remaining = idx.filter((m) => m.id !== id);
330
351
  if (remaining.length === idx.length && del.res.status === 404) return; // 对象与索引都没有 → 静默成功
331
- const putIdx = await this.send('PUT', indexKey(this.target.prefix), { body: JSON.stringify(remaining), secret });
352
+ const condHeaders = conditionalHeaders(idxEtag, false);
353
+ const putIdx = await this.send('PUT', indexKey(this.target.prefix), {
354
+ body: JSON.stringify(remaining),
355
+ secret,
356
+ headers: condHeaders,
357
+ });
358
+ if (isConcurrentWrite(putIdx.res.status, condHeaders)) throw this.concurrentWrite(putIdx.url, putIdx.res.status);
332
359
  if (!putIdx.res.ok) {
333
360
  throw new S3TransportError(await this.failText('PUT', putIdx.url, putIdx.res, secret), classifyHttpStatus(putIdx.res.status));
334
361
  }
@@ -337,6 +364,46 @@ export class S3Transport implements SyncTransport {
337
364
 
338
365
  /* ---------------- 内部实现 ---------------- */
339
366
 
367
+ /** 条件写失败:远端索引在本次读取之后被别人改过 —— 中止写入让用户重跑(cloud-sync 借鉴项 ①)。 */
368
+ private concurrentWrite(url: string, status: number): S3TransportError {
369
+ return new S3TransportError(
370
+ this.o.msg('sync.s3.concurrentWrite', { status: String(status), url }),
371
+ { kind: 'conflict', retryable: false, status },
372
+ );
373
+ }
374
+
375
+ /**
376
+ * 连通性探针(cloud-sync 借鉴项 ②):HEAD 桶证明端点/凭据/桶可达,再读一次索引回报快照数。
377
+ * 重定向(301/307/308)时读 x-amz-bucket-region 直接把「region 该填什么」告诉用户 ——
378
+ * 这是 S3 兼容端点最常见的失败原因(AWS/OSS/COS/Kodo 各自的 region 值不同)。
379
+ */
380
+ async probe(): Promise<SyncProbeResult> {
381
+ const secret = await this.secretOnce();
382
+ const bucket = await this.send('HEAD', '', { secret });
383
+ const status = bucket.res.status;
384
+ if (status === 301 || status === 307 || status === 308) {
385
+ const region = bucket.res.headers?.['x-amz-bucket-region'] ?? bucket.res.headers?.['X-Amz-Bucket-Region'];
386
+ throw new S3TransportError(
387
+ region !== undefined && region !== ''
388
+ ? this.o.msg('sync.s3.probeRegionRedirect', { region })
389
+ : this.o.msg('sync.s3.probeRedirectNoRegion', { status: String(status) }),
390
+ { kind: 'client', retryable: false, status },
391
+ );
392
+ }
393
+ if (status === 404) {
394
+ throw new S3TransportError(this.o.msg('sync.s3.probeBucketMissing'), { kind: 'notfound', retryable: false, status });
395
+ }
396
+ if (status === 403) {
397
+ throw new S3TransportError(this.o.msg('sync.s3.probeAccessDenied'), { kind: 'auth', retryable: false, status });
398
+ }
399
+ if (!bucket.res.ok) {
400
+ throw new S3TransportError(await this.failText('HEAD', bucket.url, bucket.res, secret), classifyHttpStatus(status));
401
+ }
402
+ const { entries, missing } = await this.readIndexDetailed(secret);
403
+ const count = missing ? 0 : entries.length;
404
+ return { message: this.o.msg('sync.s3.probeOk', { count: String(count) }), snapshotCount: count };
405
+ }
406
+
340
407
  private assertSafeId(id: string): void {
341
408
  if (typeof id !== 'string' || !SAFE_ID_RE.test(id) || RESERVED_IDS.has(id)) {
342
409
  throw new S3TransportError(this.o.msg('sync.s3.invalidSnapshotId', { id: JSON.stringify(id) }));
@@ -356,10 +423,15 @@ export class S3Transport implements SyncTransport {
356
423
  private async send(
357
424
  method: string,
358
425
  key: string,
359
- opts: { body?: string; secret: string },
426
+ opts: { body?: string; secret: string; headers?: Record<string, string> },
360
427
  ): Promise<{ res: S3Response; url: string }> {
361
428
  const url = objectUrl(this.target, key);
362
- const headers: Record<string, string> = { 'content-type': 'application/json' };
429
+ const headers: Record<string, string> = {
430
+ 'content-type': 'application/json',
431
+ // 条件写头(if-match / if-none-match)从这里注入(cloud-sync 借鉴项 ①);
432
+ // 签名在注入之后计算,因此条件头也进 SignedHeaders。
433
+ ...(opts.headers ?? {}),
434
+ };
363
435
  if (opts.body !== undefined) headers['content-length'] = String(Buffer.byteLength(opts.body, 'utf8'));
364
436
  const signed = signRequest({
365
437
  method,
@@ -440,13 +512,19 @@ export class S3Transport implements SyncTransport {
440
512
  * 读索引的**带缺失标志**版本(audit-sync sync-N1,与 WebDAV 同型):
441
513
  * 404 → `missing = true`(缺信息,不是事实);其它非 2xx / 非法 → 抛错(F2 语义)。
442
514
  */
443
- private async readIndexDetailed(secret: string): Promise<{ entries: SyncSnapshotMeta[]; missing: boolean }> {
515
+ private async readIndexDetailed(secret: string): Promise<{ entries: SyncSnapshotMeta[]; missing: boolean; etag?: string }> {
444
516
  const sent = await this.send('GET', indexKey(this.target.prefix), { secret });
445
517
  if (sent.res.status === 404) return { entries: [], missing: true };
446
518
  if (!sent.res.ok) {
447
519
  throw new S3TransportError(await this.failText('GET', sent.url, sent.res, secret), classifyHttpStatus(sent.res.status));
448
520
  }
449
- return { entries: this.parseIndex(await sent.res.text(), sent.url, secret), missing: false };
521
+ // ETag 是条件写的前置条件(cloud-sync 借鉴项 ①);服务端没给就退化为无条件写,绝不判成失败。
522
+ const etag = sent.res.headers?.['etag'];
523
+ return {
524
+ entries: this.parseIndex(await sent.res.text(), sent.url, secret),
525
+ missing: false,
526
+ ...(typeof etag === 'string' && etag !== '' ? { etag } : {}),
527
+ };
450
528
  }
451
529
 
452
530
  private parseIndex(raw: string, url: string, secret: string): SyncSnapshotMeta[] {
@@ -156,7 +156,10 @@ test('OSS4 方言:algorithm/头名/UNSIGNED-PAYLOAD/AdditionalHeaders/scope
156
156
  assert.equal(out.headers['x-oss-content-sha256'], UNSIGNED_PAYLOAD, 'OSS4 只支持 UNSIGNED-PAYLOAD');
157
157
  assert.equal(out.headers['x-amz-date'], undefined, 'OSS4 不得出现 AWS 头名');
158
158
  assert.equal(out.credentialScope, '20261005/cn-hangzhou/oss/aliyun_v4_request');
159
- assert.match(out.authorization, /^OSS4-HMAC-SHA256 Credential=LTAIEXAMPLE\/20261005\/cn-hangzhou\/oss\/aliyun_v4_request, AdditionalHeaders=, Signature=[a-f0-9]{64}$/);
159
+ // 官方 Go/PHP SDK 的格式:逗号后无空格,AdditionalHeaders 为空时整段省略(issue #109)
160
+ assert.match(out.authorization, /^OSS4-HMAC-SHA256 Credential=LTAIEXAMPLE\/20261005\/cn-hangzhou\/oss\/aliyun_v4_request,Signature=[a-f0-9]{64}$/);
161
+ assert.equal(out.authorization.includes(', '), false, 'OSS4 授权头不得有逗号后的空格');
162
+ assert.equal(out.authorization.includes('AdditionalHeaders='), false, '没有附加头时不得写空的 AdditionalHeaders=');
160
163
  assert.equal(out.stringToSign.split('\n')[0], 'OSS4-HMAC-SHA256');
161
164
  // 规范化头只含 content-type 与 x-oss-*(host 默认不参与)
162
165
  assert.equal(out.canonicalRequest.split('\n')[3], 'content-type:application/json');
@@ -269,8 +269,17 @@ export function signRequest(input: SigV4RequestInput): SigV4RequestOutput {
269
269
  dialect,
270
270
  });
271
271
  const signature = createHmac('sha256', signingKey).update(stringToSign, 'utf8').digest('hex');
272
- const authorization = dialect.algorithm + ' Credential=' + input.accessKeyId + '/' + credentialScope
273
- + ', ' + dialect.headerListLabel + '=' + headerList + ', Signature=' + signature;
272
+ // 授权头序列化按方言分两套(issue #109 实证):
273
+ // - AWS:AWS4-HMAC-SHA256 Credential=<ak>/<scope>, SignedHeaders=<list>, Signature=<sig>
274
+ // - OSS4:官方 Go/PHP SDK 的格式是**逗号后不带空格**,且 AdditionalHeaders 为空时**整段省略**;
275
+ // 写成 ", AdditionalHeaders=, Signature=" 会被 OSS 判成 Authorization header value is empty.
276
+ // (HTTP 400 InvalidArgument,错误码 EC 0002-00000209,真机逐一比对过两种格式)。
277
+ const authorization = dialect.headerListLabel === 'AdditionalHeaders'
278
+ ? dialect.algorithm + ' Credential=' + input.accessKeyId + '/' + credentialScope
279
+ + (headerList === '' ? '' : ',' + dialect.headerListLabel + '=' + headerList)
280
+ + ',Signature=' + signature
281
+ : dialect.algorithm + ' Credential=' + input.accessKeyId + '/' + credentialScope
282
+ + ', ' + dialect.headerListLabel + '=' + headerList + ', Signature=' + signature;
274
283
 
275
284
  return {
276
285
  headers: { ...all, authorization },