@narumitw/pi-sync 0.50.3 → 0.51.1

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 (218) hide show
  1. package/README.md +4 -1
  2. package/dist/chunks/{chunk-6LP3MCR2.ts → chunk-2JZFHNLM.ts} +3 -5
  3. package/dist/chunks/chunk-2JZFHNLM.ts.map +7 -0
  4. package/dist/chunks/{chunk-LCRI3ABW.ts → chunk-2RZSQWBH.ts} +8 -19
  5. package/dist/chunks/chunk-2RZSQWBH.ts.map +7 -0
  6. package/dist/chunks/{chunk-IJ6FJFA5.ts → chunk-4AIHXRKE.ts} +11 -17
  7. package/dist/chunks/chunk-4AIHXRKE.ts.map +7 -0
  8. package/dist/chunks/{chunk-C7XY5NF5.ts → chunk-4MPIH46N.ts} +29 -51
  9. package/dist/chunks/chunk-4MPIH46N.ts.map +7 -0
  10. package/dist/chunks/{chunk-5B3FXBLE.ts → chunk-6546TS54.ts} +16 -20
  11. package/dist/chunks/chunk-6546TS54.ts.map +7 -0
  12. package/dist/chunks/{chunk-Q2TUM5X6.ts → chunk-6NT3YFGY.ts} +2 -2
  13. package/dist/chunks/chunk-6NT3YFGY.ts.map +7 -0
  14. package/dist/chunks/{chunk-2B2ELUF5.ts → chunk-7D5SF7P3.ts} +1 -1
  15. package/dist/chunks/chunk-7D5SF7P3.ts.map +7 -0
  16. package/dist/chunks/{chunk-YDWUZVPN.ts → chunk-7KHKWL3Q.ts} +28 -47
  17. package/dist/chunks/chunk-7KHKWL3Q.ts.map +7 -0
  18. package/dist/chunks/{chunk-ZI5MXMEX.ts → chunk-BVVP33U5.ts} +2 -4
  19. package/dist/chunks/chunk-BVVP33U5.ts.map +7 -0
  20. package/dist/chunks/{chunk-SZWBGPID.ts → chunk-DM4X4FIA.ts} +8 -13
  21. package/dist/chunks/chunk-DM4X4FIA.ts.map +7 -0
  22. package/dist/chunks/{chunk-SL64RLXR.ts → chunk-DZUSTGQV.ts} +8 -16
  23. package/dist/chunks/chunk-DZUSTGQV.ts.map +7 -0
  24. package/dist/chunks/{chunk-YHEMENKV.ts → chunk-E42GXD6D.ts} +13 -19
  25. package/dist/chunks/chunk-E42GXD6D.ts.map +7 -0
  26. package/dist/chunks/{chunk-Z4DQF6L3.ts → chunk-ECYCNZOE.ts} +1 -1
  27. package/dist/chunks/chunk-ECYCNZOE.ts.map +7 -0
  28. package/dist/chunks/{chunk-OEX4ABY6.ts → chunk-EEJPYTWE.ts} +11 -21
  29. package/dist/chunks/chunk-EEJPYTWE.ts.map +7 -0
  30. package/dist/chunks/{chunk-CPEHGXJV.ts → chunk-G4RUXRWZ.ts} +3 -5
  31. package/dist/chunks/chunk-G4RUXRWZ.ts.map +7 -0
  32. package/dist/chunks/{chunk-T2B5IU74.ts → chunk-HGJH56KR.ts} +4 -4
  33. package/dist/chunks/chunk-HGJH56KR.ts.map +7 -0
  34. package/dist/chunks/{chunk-JAAKLL6M.ts → chunk-IVXEDPAD.ts} +10 -34
  35. package/dist/chunks/chunk-IVXEDPAD.ts.map +7 -0
  36. package/dist/chunks/chunk-K3RIEL6J.ts +20 -0
  37. package/dist/chunks/chunk-K3RIEL6J.ts.map +7 -0
  38. package/dist/chunks/{chunk-LTBWGSBV.ts → chunk-MSSW6F3R.ts} +1 -1
  39. package/dist/chunks/chunk-MSSW6F3R.ts.map +7 -0
  40. package/dist/chunks/{chunk-T3HH3UEJ.ts → chunk-OVDTF5CH.ts} +4 -10
  41. package/dist/chunks/chunk-OVDTF5CH.ts.map +7 -0
  42. package/dist/chunks/{chunk-NJFUIVYL.ts → chunk-PWMWOJP4.ts} +1 -1
  43. package/dist/chunks/chunk-PWMWOJP4.ts.map +7 -0
  44. package/dist/chunks/{chunk-HUDQATCL.ts → chunk-Q32CQWPM.ts} +6 -8
  45. package/dist/chunks/chunk-Q32CQWPM.ts.map +7 -0
  46. package/dist/chunks/{chunk-ETTRJDNP.ts → chunk-QLSN7AGX.ts} +6 -13
  47. package/dist/chunks/chunk-QLSN7AGX.ts.map +7 -0
  48. package/dist/chunks/{chunk-THMF4BVL.ts → chunk-SWUXA3MH.ts} +1 -1
  49. package/dist/chunks/chunk-SWUXA3MH.ts.map +7 -0
  50. package/dist/chunks/{chunk-TEN2ZBJV.ts → chunk-T2SJD3N7.ts} +2 -4
  51. package/dist/chunks/chunk-T2SJD3N7.ts.map +7 -0
  52. package/dist/chunks/{chunk-AUSDLD6F.ts → chunk-T56RNRZJ.ts} +10 -26
  53. package/dist/chunks/chunk-T56RNRZJ.ts.map +7 -0
  54. package/dist/chunks/{chunk-DHVFS4QK.ts → chunk-UA7RXOIT.ts} +37 -90
  55. package/dist/chunks/chunk-UA7RXOIT.ts.map +7 -0
  56. package/dist/chunks/{chunk-D5X27LCA.ts → chunk-UETBKTDI.ts} +1 -1
  57. package/dist/chunks/chunk-UETBKTDI.ts.map +7 -0
  58. package/dist/chunks/{chunk-CHH26D4F.ts → chunk-YFADF5ZQ.ts} +7 -6
  59. package/dist/chunks/chunk-YFADF5ZQ.ts.map +7 -0
  60. package/dist/chunks/{chunk-CCSN3F47.ts → chunk-YPXGU5OR.ts} +4 -4
  61. package/dist/chunks/chunk-YPXGU5OR.ts.map +7 -0
  62. package/dist/chunks/{file-selection-FTTKAOYX.ts → file-selection-RAVHRYFG.ts} +14 -26
  63. package/dist/chunks/file-selection-RAVHRYFG.ts.map +7 -0
  64. package/dist/chunks/{git-backend-GMVS4PD4.ts → git-backend-IAANDL3K.ts} +41 -104
  65. package/dist/chunks/git-backend-IAANDL3K.ts.map +7 -0
  66. package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +23 -0
  67. package/dist/chunks/{manager-ui-IPAOAWZA.ts → manager-ui-763NEKJY.ts} +124 -251
  68. package/dist/chunks/manager-ui-763NEKJY.ts.map +7 -0
  69. package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +22 -0
  70. package/dist/chunks/{s3-backend-66O4FGBH.ts → s3-backend-XGXUOPUN.ts} +23 -54
  71. package/dist/chunks/s3-backend-XGXUOPUN.ts.map +7 -0
  72. package/dist/chunks/{setup-switch-IS2PEZ3J.ts → setup-switch-5G2VNJHL.ts} +9 -9
  73. package/dist/chunks/{setup-wizard-JUJZYNNM.ts → setup-wizard-YIEWGX32.ts} +12 -12
  74. package/dist/chunks/setup-wizard-YIEWGX32.ts.map +7 -0
  75. package/dist/chunks/{snapshot-HKEWCVK6.ts → snapshot-JL5DFSXI.ts} +5 -5
  76. package/dist/chunks/sync-inspection-P3ENJLNF.ts +20 -0
  77. package/dist/chunks/{sync-operations-LBX3GKCM.ts → sync-operations-AR76EB6C.ts} +85 -201
  78. package/dist/chunks/sync-operations-AR76EB6C.ts.map +7 -0
  79. package/dist/chunks/{sync-state-Q2FTXTW6.ts → sync-state-VHJUZ4YG.ts} +6 -6
  80. package/dist/chunks/{webdav-backend-ECIKD7SO.ts → webdav-backend-3EGUONAT.ts} +20 -51
  81. package/dist/chunks/webdav-backend-3EGUONAT.ts.map +7 -0
  82. package/dist/index.ts +49 -74
  83. package/dist/index.ts.map +3 -3
  84. package/docs/settings.md +9 -0
  85. package/package.json +61 -62
  86. package/src/backends/backend-factory.ts +15 -15
  87. package/src/backends/backend-identity.ts +31 -31
  88. package/src/backends/backend-types.ts +42 -42
  89. package/src/backends/git/git-backend.ts +714 -788
  90. package/src/backends/git/git-config.ts +104 -111
  91. package/src/backends/git/git-runner.ts +230 -247
  92. package/src/backends/git/git-storage.ts +228 -245
  93. package/src/backends/s3/s3-backend.ts +408 -441
  94. package/src/backends/s3/s3-client.ts +288 -333
  95. package/src/backends/sync-backend.ts +65 -65
  96. package/src/backends/webdav/webdav-backend.ts +542 -589
  97. package/src/backends/webdav/webdav-client.ts +292 -316
  98. package/src/backends/webdav/webdav-config.ts +57 -68
  99. package/src/commands/command-execution.ts +270 -289
  100. package/src/commands/command-handler.ts +160 -163
  101. package/src/commands/command-types.ts +13 -13
  102. package/src/commands/command.ts +190 -196
  103. package/src/commands/setup-completions.ts +1 -1
  104. package/src/paths.ts +47 -47
  105. package/src/settings/config-errors.ts +4 -5
  106. package/src/settings/config-file.ts +473 -506
  107. package/src/settings/config.ts +171 -174
  108. package/src/settings/settings-management.ts +191 -216
  109. package/src/settings/settings-store.ts +63 -77
  110. package/src/settings/settings-types.ts +78 -75
  111. package/src/settings/settings-validation.ts +328 -364
  112. package/src/snapshot/session-paths.ts +41 -55
  113. package/src/snapshot/snapshot-apply.ts +197 -229
  114. package/src/snapshot/snapshot-codec.ts +29 -31
  115. package/src/snapshot/snapshot-paths.ts +4 -6
  116. package/src/snapshot/snapshot-transaction.ts +161 -170
  117. package/src/snapshot/snapshot-types.ts +24 -24
  118. package/src/snapshot/snapshot.ts +310 -343
  119. package/src/state/json-file.ts +11 -11
  120. package/src/state/lock.ts +226 -242
  121. package/src/state/lockfile-fs.ts +20 -20
  122. package/src/state/operation-availability.ts +36 -41
  123. package/src/state/state-directory.ts +171 -173
  124. package/src/state/state-types.ts +15 -15
  125. package/src/state/sync-state-store.ts +23 -23
  126. package/src/sync/automatic-sync.ts +75 -80
  127. package/src/sync/remote-snapshot.ts +52 -60
  128. package/src/sync/setup-switch.ts +99 -114
  129. package/src/sync/signals.ts +3 -5
  130. package/src/sync/startup-check.ts +97 -111
  131. package/src/sync/sync-decision.ts +76 -80
  132. package/src/sync/sync-error-guidance.ts +41 -48
  133. package/src/sync/sync-errors.ts +10 -10
  134. package/src/sync/sync-inspection.ts +29 -33
  135. package/src/sync/sync-loaders.ts +28 -36
  136. package/src/sync/sync-mutations.ts +510 -622
  137. package/src/sync/sync-operations.ts +7 -7
  138. package/src/sync/sync-policy.ts +296 -326
  139. package/src/sync/sync-queries.ts +175 -194
  140. package/src/sync/sync-state.ts +74 -100
  141. package/src/sync-extension.ts +89 -101
  142. package/src/sync.ts +26 -26
  143. package/src/types.ts +37 -37
  144. package/src/ui/automatic-sync-summary.ts +1 -3
  145. package/src/ui/cancellable-operation.ts +89 -101
  146. package/src/ui/file-selection.ts +250 -274
  147. package/src/ui/manager-attention.ts +51 -63
  148. package/src/ui/manager-recovery.ts +48 -57
  149. package/src/ui/manager-result-dispatcher.ts +62 -76
  150. package/src/ui/manager-state.ts +200 -232
  151. package/src/ui/manager-ui.ts +264 -362
  152. package/src/ui/remote-selection-ui.ts +547 -588
  153. package/src/ui/secret-input.ts +197 -205
  154. package/src/ui/settings-ui.ts +175 -172
  155. package/src/ui/setup/git-ui.ts +232 -261
  156. package/src/ui/setup/s3-credentials-ui.ts +58 -65
  157. package/src/ui/setup/s3-ui.ts +171 -195
  158. package/src/ui/setup/setup-actions.ts +91 -101
  159. package/src/ui/setup/setup-location-ui.ts +121 -137
  160. package/src/ui/setup/setup-name-ui.ts +27 -27
  161. package/src/ui/setup/setup-prompts.ts +65 -65
  162. package/src/ui/setup/setup-review.ts +58 -59
  163. package/src/ui/setup/setup-switcher.ts +156 -160
  164. package/src/ui/setup/setup-wizard.ts +28 -28
  165. package/src/ui/setup/text-input.ts +54 -58
  166. package/src/ui/setup/webdav-ui.ts +246 -293
  167. package/src/ui/storage-connections-ui.ts +317 -369
  168. package/src/ui/sync-attention.ts +148 -164
  169. package/src/ui/sync-format.ts +110 -121
  170. package/src/ui/sync-resolution-ui.ts +147 -153
  171. package/src/ui/sync-setups-ui.ts +218 -238
  172. package/src/ui/sync-status.ts +17 -0
  173. package/src/ui/terminal-text.ts +2 -2
  174. package/dist/chunks/chunk-2B2ELUF5.ts.map +0 -7
  175. package/dist/chunks/chunk-5B3FXBLE.ts.map +0 -7
  176. package/dist/chunks/chunk-6LP3MCR2.ts.map +0 -7
  177. package/dist/chunks/chunk-AUSDLD6F.ts.map +0 -7
  178. package/dist/chunks/chunk-C7XY5NF5.ts.map +0 -7
  179. package/dist/chunks/chunk-CCSN3F47.ts.map +0 -7
  180. package/dist/chunks/chunk-CHH26D4F.ts.map +0 -7
  181. package/dist/chunks/chunk-CPEHGXJV.ts.map +0 -7
  182. package/dist/chunks/chunk-D5X27LCA.ts.map +0 -7
  183. package/dist/chunks/chunk-DHVFS4QK.ts.map +0 -7
  184. package/dist/chunks/chunk-ETTRJDNP.ts.map +0 -7
  185. package/dist/chunks/chunk-HUDQATCL.ts.map +0 -7
  186. package/dist/chunks/chunk-IJ6FJFA5.ts.map +0 -7
  187. package/dist/chunks/chunk-JAAKLL6M.ts.map +0 -7
  188. package/dist/chunks/chunk-LCRI3ABW.ts.map +0 -7
  189. package/dist/chunks/chunk-LTBWGSBV.ts.map +0 -7
  190. package/dist/chunks/chunk-NJFUIVYL.ts.map +0 -7
  191. package/dist/chunks/chunk-OEX4ABY6.ts.map +0 -7
  192. package/dist/chunks/chunk-Q2TUM5X6.ts.map +0 -7
  193. package/dist/chunks/chunk-SL64RLXR.ts.map +0 -7
  194. package/dist/chunks/chunk-SZWBGPID.ts.map +0 -7
  195. package/dist/chunks/chunk-T2B5IU74.ts.map +0 -7
  196. package/dist/chunks/chunk-T3HH3UEJ.ts.map +0 -7
  197. package/dist/chunks/chunk-TEN2ZBJV.ts.map +0 -7
  198. package/dist/chunks/chunk-THMF4BVL.ts.map +0 -7
  199. package/dist/chunks/chunk-YDWUZVPN.ts.map +0 -7
  200. package/dist/chunks/chunk-YHEMENKV.ts.map +0 -7
  201. package/dist/chunks/chunk-Z4DQF6L3.ts.map +0 -7
  202. package/dist/chunks/chunk-ZI5MXMEX.ts.map +0 -7
  203. package/dist/chunks/file-selection-FTTKAOYX.ts.map +0 -7
  204. package/dist/chunks/git-backend-GMVS4PD4.ts.map +0 -7
  205. package/dist/chunks/manager-result-dispatcher-ASKGOSW5.ts +0 -22
  206. package/dist/chunks/manager-ui-IPAOAWZA.ts.map +0 -7
  207. package/dist/chunks/remote-selection-ui-QDNR7WEI.ts +0 -21
  208. package/dist/chunks/s3-backend-66O4FGBH.ts.map +0 -7
  209. package/dist/chunks/setup-wizard-JUJZYNNM.ts.map +0 -7
  210. package/dist/chunks/sync-inspection-CST2KL65.ts +0 -20
  211. package/dist/chunks/sync-operations-LBX3GKCM.ts.map +0 -7
  212. package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +0 -7
  213. /package/dist/chunks/{manager-result-dispatcher-ASKGOSW5.ts.map → manager-result-dispatcher-OTUMQM26.ts.map} +0 -0
  214. /package/dist/chunks/{remote-selection-ui-QDNR7WEI.ts.map → remote-selection-ui-TZTJF32Y.ts.map} +0 -0
  215. /package/dist/chunks/{setup-switch-IS2PEZ3J.ts.map → setup-switch-5G2VNJHL.ts.map} +0 -0
  216. /package/dist/chunks/{snapshot-HKEWCVK6.ts.map → snapshot-JL5DFSXI.ts.map} +0 -0
  217. /package/dist/chunks/{sync-inspection-CST2KL65.ts.map → sync-inspection-P3ENJLNF.ts.map} +0 -0
  218. /package/dist/chunks/{sync-state-Q2FTXTW6.ts.map → sync-state-VHJUZ4YG.ts.map} +0 -0
@@ -7,15 +7,15 @@ import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
7
7
  import { portableSnapshotSelection } from "../../sync/sync-policy.js";
8
8
  import type { LatestPointer, RemoteObject, ResolvedS3Backend } from "../backend-types.js";
9
9
  import {
10
- type BackendDiagnostic,
11
- type ExpectedRemoteHead,
12
- type PublishSnapshotOptions,
13
- type PublishSnapshotResult,
14
- type RemoteHead,
15
- type RemoteHistoryEntry,
16
- type SyncBackend,
17
- SyncBackendConflictError,
18
- SyncBackendPublicationOutcomeUnknownError,
10
+ type BackendDiagnostic,
11
+ type ExpectedRemoteHead,
12
+ type PublishSnapshotOptions,
13
+ type PublishSnapshotResult,
14
+ type RemoteHead,
15
+ type RemoteHistoryEntry,
16
+ type SyncBackend,
17
+ SyncBackendConflictError,
18
+ SyncBackendPublicationOutcomeUnknownError,
19
19
  } from "../sync-backend.js";
20
20
  import { S3Client, S3ObjectAlreadyExistsError } from "./s3-client.js";
21
21
 
@@ -23,516 +23,483 @@ const VERSION = 1;
23
23
  const POST_COMMIT_TIMEOUT_MS = 30_000;
24
24
 
25
25
  export class S3SyncBackend implements SyncBackend {
26
- readonly identity: string;
27
- readonly destination: string;
28
- readonly capability = "read-check-write-verify" as const;
29
- private readonly checksums = new Map<string, string>();
30
-
31
- constructor(
32
- private readonly config: ResolvedS3Backend,
33
- private readonly postCommitTimeoutMs = POST_COMMIT_TIMEOUT_MS,
34
- ) {
35
- assertSafeDestination(config);
36
- this.identity = s3BackendIdentity(config);
37
- this.destination = s3Destination(config);
38
- }
39
-
40
- sameRevision(left: string, right: string) {
41
- return left === right;
42
- }
43
-
44
- async readHead(signal?: AbortSignal): Promise<RemoteHead | undefined> {
45
- const object = await new S3Client(this.config, signal).getJson<LatestPointer>(
46
- latestKey(this.config),
47
- );
48
- throwIfAborted(signal);
49
- if (object.missing) return undefined;
50
- const pointer = requirePointer(
51
- object.value,
52
- "Remote latest pointer is malformed.",
53
- this.config.destination.namespace,
54
- );
55
- this.registerChecksum(pointer.snapshot, pointer.sha256);
56
- return remoteHead(pointer, this.identity, object.etag);
57
- }
58
-
59
- async readSnapshot(reference: string, signal?: AbortSignal): Promise<Snapshot> {
60
- const expectedChecksum =
61
- this.checksums.get(reference) ?? (await this.resolveChecksum(reference, signal));
62
- const object = await new S3Client(this.config, signal).getBuffer(
63
- snapshotKey(this.config, reference),
64
- );
65
- throwIfAborted(signal);
66
- if (!object.value) throw new Error(`Snapshot not found: ${reference}`);
67
- if (expectedChecksum && sha256(object.value) !== expectedChecksum) {
68
- throw new Error("Remote snapshot checksum mismatch.");
69
- }
70
- const snapshot = await decodeSnapshot(object.value, { signal });
71
- if (snapshot.id !== reference || snapshot.profile !== this.config.destination.namespace) {
72
- throw new Error("Remote snapshot identity mismatch.");
73
- }
74
- validateSnapshotBundle(snapshot);
75
- return snapshot;
76
- }
77
-
78
- async publishSnapshot(
79
- snapshot: Snapshot,
80
- expected: ExpectedRemoteHead,
81
- options: PublishSnapshotOptions = {},
82
- ): Promise<PublishSnapshotResult> {
83
- throwIfAborted(options.signal);
84
- assertSnapshotIdentity(snapshot, this.config.destination.namespace);
85
- const stagedKey = snapshotKey(this.config, snapshot.id);
86
- const encoded = await encodeSnapshot(snapshot);
87
- throwIfAborted(options.signal);
88
- const pointer = pointerFor(this.config, snapshot, sha256(encoded));
89
- const cancellableClient = new S3Client(this.config, options.signal);
90
- try {
91
- await cancellableClient.putBuffer(stagedKey, encoded, "application/gzip", {
92
- ifAbsent: true,
93
- });
94
- } catch (error) {
95
- if (!(error instanceof S3ObjectAlreadyExistsError)) throw error;
96
- const existing = await cancellableClient.getBuffer(stagedKey);
97
- if (!existing.value || sha256(existing.value) !== pointer.sha256) {
98
- throw new SyncBackendConflictError(
99
- `Immutable snapshot id already exists with different content: ${snapshot.id}`,
100
- );
101
- }
102
- }
103
- const currentObject = await cancellableClient.getJson<LatestPointer>(latestKey(this.config));
104
- throwIfAborted(options.signal);
105
- let current: RemoteHead | undefined;
106
- if (!currentObject.missing) {
107
- const currentPointer = requirePointer(
108
- currentObject.value,
109
- "Remote latest pointer is malformed.",
110
- this.config.destination.namespace,
111
- );
112
- this.registerChecksum(currentPointer.snapshot, currentPointer.sha256);
113
- current = remoteHead(currentPointer, this.identity, currentObject.etag);
114
- }
115
- if (!matchesExpected(current, expected)) {
116
- throw new SyncBackendConflictError(
117
- "Remote changed while pushing. Run /sync pull first, then retry.",
118
- { currentHead: current },
119
- );
120
- }
121
- throwIfAborted(options.signal);
122
- options.onCommit?.();
123
-
124
- // The active-head PUT is the publication boundary. Do not bind it or its
125
- // verification to a user-cancellation signal after the boundary begins.
126
- const commitClient = new S3Client(this.config, AbortSignal.timeout(this.postCommitTimeoutMs));
127
- try {
128
- await commitClient.putJson(latestKey(this.config), pointer);
129
- } catch (error) {
130
- throw new SyncBackendPublicationOutcomeUnknownError(
131
- `Remote publication outcome is unknown: ${errorMessage(error)}`,
132
- { cause: error },
133
- );
134
- }
135
-
136
- let verifiedObject: RemoteObject<LatestPointer>;
137
- try {
138
- verifiedObject = await commitClient.getJson<LatestPointer>(latestKey(this.config));
139
- } catch (error) {
140
- throw new SyncBackendPublicationOutcomeUnknownError(
141
- `Remote snapshot may be active, but publication could not be verified: ${errorMessage(error)}`,
142
- { cause: error },
143
- );
144
- }
145
- let verifiedPointer: LatestPointer;
146
- try {
147
- verifiedPointer = requirePointer(
148
- verifiedObject.value,
149
- "Remote latest pointer is malformed after publication.",
150
- this.config.destination.namespace,
151
- );
152
- } catch (error) {
153
- throw new SyncBackendPublicationOutcomeUnknownError(
154
- `Remote snapshot may be active, but publication verification was malformed: ${errorMessage(error)}`,
155
- { cause: error },
156
- );
157
- }
158
- if (!samePointer(verifiedPointer, pointer)) {
159
- throw new SyncBackendConflictError(
160
- "Remote latest changed immediately after push. Run /sync status before continuing.",
161
- {
162
- phase: "after-commit",
163
- currentHead: remoteHead(verifiedPointer, this.identity, verifiedObject.etag),
164
- candidateMayHaveBeenActive: true,
165
- },
166
- );
167
- }
168
- this.registerChecksum(verifiedPointer.snapshot, verifiedPointer.sha256);
169
- const head = remoteHead(verifiedPointer, this.identity, verifiedObject.etag);
170
- const warning = await this.updateHistorySafely(commitClient, pointer);
171
- return { head, warnings: warning ? [warning] : [] };
172
- }
173
-
174
- async listHistory(signal?: AbortSignal): Promise<RemoteHistoryEntry[]> {
175
- const object = await new S3Client(this.config, signal).getJson<{
176
- version: number;
177
- snapshots: LatestPointer[];
178
- }>(historyKey(this.config));
179
- throwIfAborted(signal);
180
- if (object.missing) return [];
181
- const pointers = requireHistory(object.value, this.config.destination.namespace);
182
- for (const pointer of pointers) {
183
- const known = this.checksums.get(pointer.snapshot);
184
- if (known && known !== pointer.sha256) {
185
- throw new Error("Remote history conflicts with an already observed snapshot checksum.");
186
- }
187
- }
188
- for (const pointer of pointers) this.registerChecksum(pointer.snapshot, pointer.sha256);
189
- return pointers.map(remoteHistoryEntry);
190
- }
191
-
192
- async diagnose(signal?: AbortSignal): Promise<BackendDiagnostic[]> {
193
- throwIfAborted(signal);
194
- const diagnostics: BackendDiagnostic[] = [
195
- {
196
- key: "s3-config",
197
- level: "info",
198
- message: `s3 config: ok (${this.config.destination.bucket}/${storageRoot(this.config)})`,
199
- },
200
- ...sessionTokenWarnings(this.config.profile).map((message) => ({
201
- key: "s3-session-token",
202
- level: "warning" as const,
203
- message,
204
- })),
205
- ];
206
- const deadline = AbortSignal.timeout(10_000);
207
- const probeSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
208
- try {
209
- const result = await new S3Client(this.config, probeSignal).diagnoseRead(
210
- latestKey(this.config),
211
- );
212
- throwIfAborted(probeSignal);
213
- const messages = {
214
- readable:
215
- "s3 remote read: HTTP success. Snapshot contents and write access are not tested.",
216
- "missing-key":
217
- "s3 remote read: no snapshot at this path (NoSuchKey). Write access is not tested; verify the bucket and path before first sync.",
218
- "missing-bucket":
219
- "s3 remote read: bucket not found (NoSuchBucket). Create the bucket with your provider or edit the setup's bucket name.",
220
- "unknown-404":
221
- "s3 remote read: HTTP 404; cannot confirm whether the bucket or snapshot is missing. Verify the endpoint, bucket, and path with your provider. Write access is not tested.",
222
- };
223
- diagnostics.push({
224
- key: "s3-read",
225
- level: result === "readable" ? "info" : "warning",
226
- message: messages[result],
227
- });
228
- } catch (error) {
229
- throwIfAborted(signal);
230
- diagnostics.push({
231
- key: "s3-read",
232
- level: "warning",
233
- message: syncErrorGuidance(
234
- deadline.aborted ? new Error("S3 diagnostic request timed out.") : error,
235
- ),
236
- });
237
- }
238
- return diagnostics;
239
- }
240
-
241
- private async resolveChecksum(reference: string, signal?: AbortSignal) {
242
- await this.readHead(signal);
243
- const headChecksum = this.checksums.get(reference);
244
- if (headChecksum) return headChecksum;
245
- await this.listHistory(signal);
246
- return this.checksums.get(reference);
247
- }
248
-
249
- private registerChecksum(reference: string, checksum: string) {
250
- const known = this.checksums.get(reference);
251
- if (known && known !== checksum) {
252
- throw new Error("Remote snapshot reference was rebound to a different checksum.");
253
- }
254
- this.checksums.set(reference, checksum);
255
- }
256
-
257
- private async updateHistorySafely(client: S3Client, pointer: LatestPointer) {
258
- try {
259
- await this.updateHistory(client, pointer);
260
- return undefined;
261
- } catch (error) {
262
- return `Remote snapshot is active, but history could not be updated: ${errorMessage(error)}. Run /sync doctor before relying on history.`;
263
- }
264
- }
265
-
266
- private async updateHistory(client: S3Client, pointer: LatestPointer) {
267
- const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(
268
- historyKey(this.config),
269
- );
270
- const snapshots = object.missing
271
- ? []
272
- : requireHistory(object.value, this.config.destination.namespace);
273
- const next = [
274
- ...snapshots.filter((snapshot) => snapshot.snapshot !== pointer.snapshot),
275
- pointer,
276
- ].slice(-100);
277
- await client.putJson(historyKey(this.config), { version: VERSION, snapshots: next });
278
- }
26
+ readonly identity: string;
27
+ readonly destination: string;
28
+ readonly capability = "read-check-write-verify" as const;
29
+ private readonly checksums = new Map<string, string>();
30
+
31
+ constructor(
32
+ private readonly config: ResolvedS3Backend,
33
+ private readonly postCommitTimeoutMs = POST_COMMIT_TIMEOUT_MS,
34
+ ) {
35
+ assertSafeDestination(config);
36
+ this.identity = s3BackendIdentity(config);
37
+ this.destination = s3Destination(config);
38
+ }
39
+
40
+ sameRevision(left: string, right: string) {
41
+ return left === right;
42
+ }
43
+
44
+ async readHead(signal?: AbortSignal): Promise<RemoteHead | undefined> {
45
+ const object = await new S3Client(this.config, signal).getJson<LatestPointer>(latestKey(this.config));
46
+ throwIfAborted(signal);
47
+ if (object.missing) return undefined;
48
+ const pointer = requirePointer(
49
+ object.value,
50
+ "Remote latest pointer is malformed.",
51
+ this.config.destination.namespace,
52
+ );
53
+ this.registerChecksum(pointer.snapshot, pointer.sha256);
54
+ return remoteHead(pointer, this.identity, object.etag);
55
+ }
56
+
57
+ async readSnapshot(reference: string, signal?: AbortSignal): Promise<Snapshot> {
58
+ const expectedChecksum = this.checksums.get(reference) ?? (await this.resolveChecksum(reference, signal));
59
+ const object = await new S3Client(this.config, signal).getBuffer(snapshotKey(this.config, reference));
60
+ throwIfAborted(signal);
61
+ if (!object.value) throw new Error(`Snapshot not found: ${reference}`);
62
+ if (expectedChecksum && sha256(object.value) !== expectedChecksum) {
63
+ throw new Error("Remote snapshot checksum mismatch.");
64
+ }
65
+ const snapshot = await decodeSnapshot(object.value, { signal });
66
+ if (snapshot.id !== reference || snapshot.profile !== this.config.destination.namespace) {
67
+ throw new Error("Remote snapshot identity mismatch.");
68
+ }
69
+ validateSnapshotBundle(snapshot);
70
+ return snapshot;
71
+ }
72
+
73
+ async publishSnapshot(
74
+ snapshot: Snapshot,
75
+ expected: ExpectedRemoteHead,
76
+ options: PublishSnapshotOptions = {},
77
+ ): Promise<PublishSnapshotResult> {
78
+ throwIfAborted(options.signal);
79
+ assertSnapshotIdentity(snapshot, this.config.destination.namespace);
80
+ const stagedKey = snapshotKey(this.config, snapshot.id);
81
+ const encoded = await encodeSnapshot(snapshot);
82
+ throwIfAborted(options.signal);
83
+ const pointer = pointerFor(this.config, snapshot, sha256(encoded));
84
+ const cancellableClient = new S3Client(this.config, options.signal);
85
+ try {
86
+ await cancellableClient.putBuffer(stagedKey, encoded, "application/gzip", {
87
+ ifAbsent: true,
88
+ });
89
+ } catch (error) {
90
+ if (!(error instanceof S3ObjectAlreadyExistsError)) throw error;
91
+ const existing = await cancellableClient.getBuffer(stagedKey);
92
+ if (!existing.value || sha256(existing.value) !== pointer.sha256) {
93
+ throw new SyncBackendConflictError(
94
+ `Immutable snapshot id already exists with different content: ${snapshot.id}`,
95
+ );
96
+ }
97
+ }
98
+ const currentObject = await cancellableClient.getJson<LatestPointer>(latestKey(this.config));
99
+ throwIfAborted(options.signal);
100
+ let current: RemoteHead | undefined;
101
+ if (!currentObject.missing) {
102
+ const currentPointer = requirePointer(
103
+ currentObject.value,
104
+ "Remote latest pointer is malformed.",
105
+ this.config.destination.namespace,
106
+ );
107
+ this.registerChecksum(currentPointer.snapshot, currentPointer.sha256);
108
+ current = remoteHead(currentPointer, this.identity, currentObject.etag);
109
+ }
110
+ if (!matchesExpected(current, expected)) {
111
+ throw new SyncBackendConflictError("Remote changed while pushing. Run /sync pull first, then retry.", {
112
+ currentHead: current,
113
+ });
114
+ }
115
+ throwIfAborted(options.signal);
116
+ options.onCommit?.();
117
+
118
+ // The active-head PUT is the publication boundary. Do not bind it or its
119
+ // verification to a user-cancellation signal after the boundary begins.
120
+ const commitClient = new S3Client(this.config, AbortSignal.timeout(this.postCommitTimeoutMs));
121
+ try {
122
+ await commitClient.putJson(latestKey(this.config), pointer);
123
+ } catch (error) {
124
+ throw new SyncBackendPublicationOutcomeUnknownError(
125
+ `Remote publication outcome is unknown: ${errorMessage(error)}`,
126
+ { cause: error },
127
+ );
128
+ }
129
+
130
+ let verifiedObject: RemoteObject<LatestPointer>;
131
+ try {
132
+ verifiedObject = await commitClient.getJson<LatestPointer>(latestKey(this.config));
133
+ } catch (error) {
134
+ throw new SyncBackendPublicationOutcomeUnknownError(
135
+ `Remote snapshot may be active, but publication could not be verified: ${errorMessage(error)}`,
136
+ { cause: error },
137
+ );
138
+ }
139
+ let verifiedPointer: LatestPointer;
140
+ try {
141
+ verifiedPointer = requirePointer(
142
+ verifiedObject.value,
143
+ "Remote latest pointer is malformed after publication.",
144
+ this.config.destination.namespace,
145
+ );
146
+ } catch (error) {
147
+ throw new SyncBackendPublicationOutcomeUnknownError(
148
+ `Remote snapshot may be active, but publication verification was malformed: ${errorMessage(error)}`,
149
+ { cause: error },
150
+ );
151
+ }
152
+ if (!samePointer(verifiedPointer, pointer)) {
153
+ throw new SyncBackendConflictError(
154
+ "Remote latest changed immediately after push. Run /sync status before continuing.",
155
+ {
156
+ phase: "after-commit",
157
+ currentHead: remoteHead(verifiedPointer, this.identity, verifiedObject.etag),
158
+ candidateMayHaveBeenActive: true,
159
+ },
160
+ );
161
+ }
162
+ this.registerChecksum(verifiedPointer.snapshot, verifiedPointer.sha256);
163
+ const head = remoteHead(verifiedPointer, this.identity, verifiedObject.etag);
164
+ const warning = await this.updateHistorySafely(commitClient, pointer);
165
+ return { head, warnings: warning ? [warning] : [] };
166
+ }
167
+
168
+ async listHistory(signal?: AbortSignal): Promise<RemoteHistoryEntry[]> {
169
+ const object = await new S3Client(this.config, signal).getJson<{
170
+ version: number;
171
+ snapshots: LatestPointer[];
172
+ }>(historyKey(this.config));
173
+ throwIfAborted(signal);
174
+ if (object.missing) return [];
175
+ const pointers = requireHistory(object.value, this.config.destination.namespace);
176
+ for (const pointer of pointers) {
177
+ const known = this.checksums.get(pointer.snapshot);
178
+ if (known && known !== pointer.sha256) {
179
+ throw new Error("Remote history conflicts with an already observed snapshot checksum.");
180
+ }
181
+ }
182
+ for (const pointer of pointers) this.registerChecksum(pointer.snapshot, pointer.sha256);
183
+ return pointers.map(remoteHistoryEntry);
184
+ }
185
+
186
+ async diagnose(signal?: AbortSignal): Promise<BackendDiagnostic[]> {
187
+ throwIfAborted(signal);
188
+ const diagnostics: BackendDiagnostic[] = [
189
+ {
190
+ key: "s3-config",
191
+ level: "info",
192
+ message: `s3 config: ok (${this.config.destination.bucket}/${storageRoot(this.config)})`,
193
+ },
194
+ ...sessionTokenWarnings(this.config.profile).map((message) => ({
195
+ key: "s3-session-token",
196
+ level: "warning" as const,
197
+ message,
198
+ })),
199
+ ];
200
+ const deadline = AbortSignal.timeout(10_000);
201
+ const probeSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
202
+ try {
203
+ const result = await new S3Client(this.config, probeSignal).diagnoseRead(latestKey(this.config));
204
+ throwIfAborted(probeSignal);
205
+ const messages = {
206
+ readable: "s3 remote read: HTTP success. Snapshot contents and write access are not tested.",
207
+ "missing-key":
208
+ "s3 remote read: no snapshot at this path (NoSuchKey). Write access is not tested; verify the bucket and path before first sync.",
209
+ "missing-bucket":
210
+ "s3 remote read: bucket not found (NoSuchBucket). Create the bucket with your provider or edit the setup's bucket name.",
211
+ "unknown-404":
212
+ "s3 remote read: HTTP 404; cannot confirm whether the bucket or snapshot is missing. Verify the endpoint, bucket, and path with your provider. Write access is not tested.",
213
+ };
214
+ diagnostics.push({
215
+ key: "s3-read",
216
+ level: result === "readable" ? "info" : "warning",
217
+ message: messages[result],
218
+ });
219
+ } catch (error) {
220
+ throwIfAborted(signal);
221
+ diagnostics.push({
222
+ key: "s3-read",
223
+ level: "warning",
224
+ message: syncErrorGuidance(deadline.aborted ? new Error("S3 diagnostic request timed out.") : error),
225
+ });
226
+ }
227
+ return diagnostics;
228
+ }
229
+
230
+ private async resolveChecksum(reference: string, signal?: AbortSignal) {
231
+ await this.readHead(signal);
232
+ const headChecksum = this.checksums.get(reference);
233
+ if (headChecksum) return headChecksum;
234
+ await this.listHistory(signal);
235
+ return this.checksums.get(reference);
236
+ }
237
+
238
+ private registerChecksum(reference: string, checksum: string) {
239
+ const known = this.checksums.get(reference);
240
+ if (known && known !== checksum) {
241
+ throw new Error("Remote snapshot reference was rebound to a different checksum.");
242
+ }
243
+ this.checksums.set(reference, checksum);
244
+ }
245
+
246
+ private async updateHistorySafely(client: S3Client, pointer: LatestPointer) {
247
+ try {
248
+ await this.updateHistory(client, pointer);
249
+ return undefined;
250
+ } catch (error) {
251
+ return `Remote snapshot is active, but history could not be updated: ${errorMessage(error)}. Run /sync doctor before relying on history.`;
252
+ }
253
+ }
254
+
255
+ private async updateHistory(client: S3Client, pointer: LatestPointer) {
256
+ const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(historyKey(this.config));
257
+ const snapshots = object.missing ? [] : requireHistory(object.value, this.config.destination.namespace);
258
+ const next = [...snapshots.filter((snapshot) => snapshot.snapshot !== pointer.snapshot), pointer].slice(-100);
259
+ await client.putJson(historyKey(this.config), { version: VERSION, snapshots: next });
260
+ }
279
261
  }
280
262
 
281
263
  export function latestKey(config: ResolvedS3Backend) {
282
- return posixJoin(storageRoot(config), "latest.json");
264
+ return posixJoin(storageRoot(config), "latest.json");
283
265
  }
284
266
 
285
267
  export function historyKey(config: ResolvedS3Backend) {
286
- return posixJoin(storageRoot(config), "history.json");
268
+ return posixJoin(storageRoot(config), "history.json");
287
269
  }
288
270
 
289
271
  export function snapshotKey(config: ResolvedS3Backend, id: string) {
290
- requireSnapshotReference(id);
291
- return posixJoin(storageRoot(config), "snapshots", `${id}.json.gz`);
272
+ requireSnapshotReference(id);
273
+ return posixJoin(storageRoot(config), "snapshots", `${id}.json.gz`);
292
274
  }
293
275
 
294
276
  export function storageRoot(config: ResolvedS3Backend) {
295
- return config.destination.prefix === "./" ? "" : config.destination.prefix;
277
+ return config.destination.prefix === "./" ? "" : config.destination.prefix;
296
278
  }
297
279
 
298
- export function pointerFor(
299
- config: ResolvedS3Backend,
300
- snapshot: Snapshot,
301
- checksum: string,
302
- ): LatestPointer {
303
- return {
304
- version: VERSION,
305
- profile: config.destination.namespace,
306
- snapshot: snapshot.id,
307
- sha256: checksum,
308
- createdAt: snapshot.createdAt,
309
- machine: snapshot.machine,
310
- syncSessions:
311
- snapshot.syncSessions === true ||
312
- snapshot.files.some((file) => file.path.startsWith("sessions/")),
313
- ...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
314
- };
280
+ export function pointerFor(config: ResolvedS3Backend, snapshot: Snapshot, checksum: string): LatestPointer {
281
+ return {
282
+ version: VERSION,
283
+ profile: config.destination.namespace,
284
+ snapshot: snapshot.id,
285
+ sha256: checksum,
286
+ createdAt: snapshot.createdAt,
287
+ machine: snapshot.machine,
288
+ syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
289
+ ...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
290
+ };
315
291
  }
316
292
 
317
293
  export function s3BackendIdentity(config: ResolvedS3Backend) {
318
- const destination = JSON.stringify([
319
- secretFreeEndpoint(config.profile.endpoint),
320
- trimSlashes(config.destination.bucket),
321
- trimSlashes(config.destination.prefix),
322
- ]);
323
- return `s3:${sha256(Buffer.from(destination))}`;
294
+ const destination = JSON.stringify([
295
+ secretFreeEndpoint(config.profile.endpoint),
296
+ trimSlashes(config.destination.bucket),
297
+ trimSlashes(config.destination.prefix),
298
+ ]);
299
+ return `s3:${sha256(Buffer.from(destination))}`;
324
300
  }
325
301
 
326
302
  function s3Destination(config: ResolvedS3Backend) {
327
- let host = secretFreeEndpoint(config.profile.endpoint);
328
- try {
329
- host = new URL(host).hostname;
330
- } catch {
331
- host = "invalid S3 endpoint";
332
- }
333
- return `${host} · ${config.destination.bucket}/${storageRoot(config)}`;
303
+ let host = secretFreeEndpoint(config.profile.endpoint);
304
+ try {
305
+ host = new URL(host).hostname;
306
+ } catch {
307
+ host = "invalid S3 endpoint";
308
+ }
309
+ return `${host} · ${config.destination.bucket}/${storageRoot(config)}`;
334
310
  }
335
311
 
336
312
  function secretFreeEndpoint(value: string) {
337
- const normalized = value.trim();
338
- try {
339
- const url = new URL(normalized);
340
- url.username = "";
341
- url.password = "";
342
- return url.toString();
343
- } catch {
344
- return normalized.replace(/\/\/[^/@\s]+@/u, "//");
345
- }
313
+ const normalized = value.trim();
314
+ try {
315
+ const url = new URL(normalized);
316
+ url.username = "";
317
+ url.password = "";
318
+ return url.toString();
319
+ } catch {
320
+ return normalized.replace(/\/\/[^/@\s]+@/u, "//");
321
+ }
346
322
  }
347
323
 
348
324
  function trimSlashes(value: string) {
349
- return value.replace(/^\/+|\/+$/g, "");
325
+ return value.replace(/^\/+|\/+$/g, "");
350
326
  }
351
327
 
352
328
  function remoteHistoryEntry(pointer: LatestPointer): RemoteHistoryEntry {
353
- return {
354
- snapshotRef: pointer.snapshot,
355
- snapshotId: pointer.snapshot,
356
- createdAt: pointer.createdAt,
357
- machine: pointer.machine,
358
- syncSessions: pointer.syncSessions === true,
359
- };
329
+ return {
330
+ snapshotRef: pointer.snapshot,
331
+ snapshotId: pointer.snapshot,
332
+ createdAt: pointer.createdAt,
333
+ machine: pointer.machine,
334
+ syncSessions: pointer.syncSessions === true,
335
+ };
360
336
  }
361
337
 
362
338
  function remoteHead(pointer: LatestPointer, identity: string, etag?: string): RemoteHead {
363
- return {
364
- snapshotRef: pointer.snapshot,
365
- snapshotId: pointer.snapshot,
366
- revision: `s3:${sha256(Buffer.from(canonicalJson([identity, etag ?? null, pointer])))}`,
367
- createdAt: pointer.createdAt,
368
- machine: pointer.machine,
369
- syncSessions: pointer.syncSessions === true,
370
- ...(pointer.selection === undefined ? {} : { selection: pointer.selection }),
371
- };
339
+ return {
340
+ snapshotRef: pointer.snapshot,
341
+ snapshotId: pointer.snapshot,
342
+ revision: `s3:${sha256(Buffer.from(canonicalJson([identity, etag ?? null, pointer])))}`,
343
+ createdAt: pointer.createdAt,
344
+ machine: pointer.machine,
345
+ syncSessions: pointer.syncSessions === true,
346
+ ...(pointer.selection === undefined ? {} : { selection: pointer.selection }),
347
+ };
372
348
  }
373
349
 
374
350
  function samePointer(left: LatestPointer, right: LatestPointer) {
375
- return canonicalJson(left) === canonicalJson(right);
351
+ return canonicalJson(left) === canonicalJson(right);
376
352
  }
377
353
 
378
354
  function canonicalJson(value: unknown) {
379
- return JSON.stringify(canonicalValue(value));
355
+ return JSON.stringify(canonicalValue(value));
380
356
  }
381
357
 
382
358
  function canonicalValue(value: unknown): unknown {
383
- if (Array.isArray(value)) return value.map(canonicalValue);
384
- if (!value || typeof value !== "object") return value;
385
- return Object.fromEntries(
386
- Object.keys(value)
387
- .sort()
388
- .map((key) => [key, canonicalValue((value as Record<string, unknown>)[key])]),
389
- );
359
+ if (Array.isArray(value)) return value.map(canonicalValue);
360
+ if (!value || typeof value !== "object") return value;
361
+ return Object.fromEntries(
362
+ Object.keys(value)
363
+ .sort()
364
+ .map((key) => [key, canonicalValue((value as Record<string, unknown>)[key])]),
365
+ );
390
366
  }
391
367
 
392
368
  function matchesExpected(head: RemoteHead | undefined, expected: ExpectedRemoteHead) {
393
- if (expected.kind === "missing") return head === undefined;
394
- return head?.revision === expected.revision;
369
+ if (expected.kind === "missing") return head === undefined;
370
+ return head?.revision === expected.revision;
395
371
  }
396
372
 
397
- function requirePointer(
398
- value: LatestPointer | undefined,
399
- message: string,
400
- expectedProfile: string,
401
- ) {
402
- if (
403
- !value ||
404
- value.version !== VERSION ||
405
- value.profile !== expectedProfile ||
406
- typeof value.snapshot !== "string" ||
407
- !isSafeSnapshotReference(value.snapshot) ||
408
- typeof value.sha256 !== "string" ||
409
- !/^[0-9a-f]{64}$/.test(value.sha256) ||
410
- typeof value.createdAt !== "string" ||
411
- !isSafeMetadata(value.createdAt, 64) ||
412
- typeof value.machine !== "string" ||
413
- !isSafeMetadata(value.machine, 256) ||
414
- (value.syncSessions !== undefined && typeof value.syncSessions !== "boolean")
415
- ) {
416
- throw new Error(message);
417
- }
418
- if (value.selection !== undefined) portableSnapshotSelection(value.selection);
419
- return value;
373
+ function requirePointer(value: LatestPointer | undefined, message: string, expectedProfile: string) {
374
+ if (
375
+ !value ||
376
+ value.version !== VERSION ||
377
+ value.profile !== expectedProfile ||
378
+ typeof value.snapshot !== "string" ||
379
+ !isSafeSnapshotReference(value.snapshot) ||
380
+ typeof value.sha256 !== "string" ||
381
+ !/^[0-9a-f]{64}$/.test(value.sha256) ||
382
+ typeof value.createdAt !== "string" ||
383
+ !isSafeMetadata(value.createdAt, 64) ||
384
+ typeof value.machine !== "string" ||
385
+ !isSafeMetadata(value.machine, 256) ||
386
+ (value.syncSessions !== undefined && typeof value.syncSessions !== "boolean")
387
+ ) {
388
+ throw new Error(message);
389
+ }
390
+ if (value.selection !== undefined) portableSnapshotSelection(value.selection);
391
+ return value;
420
392
  }
421
393
 
422
- function requireHistory(
423
- value: { version: number; snapshots: LatestPointer[] } | undefined,
424
- expectedProfile: string,
425
- ) {
426
- if (!value || value.version !== VERSION || !Array.isArray(value.snapshots)) {
427
- throw new Error("Remote history is malformed.");
428
- }
429
- const pointers = value.snapshots.map((pointer) =>
430
- requirePointer(pointer, "Remote history entry is malformed.", expectedProfile),
431
- );
432
- const references = new Set<string>();
433
- for (const pointer of pointers) {
434
- if (references.has(pointer.snapshot)) {
435
- throw new Error("Remote history contains duplicate snapshot references.");
436
- }
437
- references.add(pointer.snapshot);
438
- }
439
- return pointers;
394
+ function requireHistory(value: { version: number; snapshots: LatestPointer[] } | undefined, expectedProfile: string) {
395
+ if (!value || value.version !== VERSION || !Array.isArray(value.snapshots)) {
396
+ throw new Error("Remote history is malformed.");
397
+ }
398
+ const pointers = value.snapshots.map((pointer) =>
399
+ requirePointer(pointer, "Remote history entry is malformed.", expectedProfile),
400
+ );
401
+ const references = new Set<string>();
402
+ for (const pointer of pointers) {
403
+ if (references.has(pointer.snapshot)) {
404
+ throw new Error("Remote history contains duplicate snapshot references.");
405
+ }
406
+ references.add(pointer.snapshot);
407
+ }
408
+ return pointers;
440
409
  }
441
410
 
442
411
  function assertSnapshotIdentity(snapshot: Snapshot, expectedProfile: string) {
443
- if (
444
- snapshot.version !== VERSION ||
445
- snapshot.profile !== expectedProfile ||
446
- !isSafeSnapshotReference(snapshot.id) ||
447
- !isSafeMetadata(snapshot.createdAt, 64) ||
448
- !isSafeMetadata(snapshot.machine, 256) ||
449
- !Array.isArray(snapshot.files)
450
- ) {
451
- throw new Error("Invalid snapshot identity for S3 publication.");
452
- }
412
+ if (
413
+ snapshot.version !== VERSION ||
414
+ snapshot.profile !== expectedProfile ||
415
+ !isSafeSnapshotReference(snapshot.id) ||
416
+ !isSafeMetadata(snapshot.createdAt, 64) ||
417
+ !isSafeMetadata(snapshot.machine, 256) ||
418
+ !Array.isArray(snapshot.files)
419
+ ) {
420
+ throw new Error("Invalid snapshot identity for S3 publication.");
421
+ }
453
422
  }
454
423
 
455
424
  function validateSnapshotBundle(snapshot: Snapshot) {
456
- const paths = new Set<string>();
457
- for (const file of snapshot.files) {
458
- if (
459
- !file ||
460
- typeof file.path !== "string" ||
461
- typeof file.contentBase64 !== "string" ||
462
- typeof file.sha256 !== "string" ||
463
- !/^[0-9a-f]{64}$/.test(file.sha256) ||
464
- paths.has(file.path)
465
- ) {
466
- throw new Error("Remote snapshot bundle is malformed.");
467
- }
468
- const content = Buffer.from(file.contentBase64, "base64");
469
- if (content.toString("base64") !== file.contentBase64 || sha256(content) !== file.sha256) {
470
- throw new Error("Remote snapshot file checksum mismatch.");
471
- }
472
- paths.add(file.path);
473
- }
425
+ const paths = new Set<string>();
426
+ for (const file of snapshot.files) {
427
+ if (
428
+ !file ||
429
+ typeof file.path !== "string" ||
430
+ typeof file.contentBase64 !== "string" ||
431
+ typeof file.sha256 !== "string" ||
432
+ !/^[0-9a-f]{64}$/.test(file.sha256) ||
433
+ paths.has(file.path)
434
+ ) {
435
+ throw new Error("Remote snapshot bundle is malformed.");
436
+ }
437
+ const content = Buffer.from(file.contentBase64, "base64");
438
+ if (content.toString("base64") !== file.contentBase64 || sha256(content) !== file.sha256) {
439
+ throw new Error("Remote snapshot file checksum mismatch.");
440
+ }
441
+ paths.add(file.path);
442
+ }
474
443
  }
475
444
 
476
445
  function assertSafeDestination(config: ResolvedS3Backend) {
477
- for (const [label, value, allowEmpty] of [
478
- ["bucket", config.destination.bucket, false],
479
- ["prefix", config.destination.prefix, true],
480
- ["namespace", config.destination.namespace, false],
481
- ] as const) {
482
- if (label === "prefix" && value === "./") continue;
483
- if (
484
- (!allowEmpty && value.length === 0) ||
485
- value.includes("\\") ||
486
- hasControlCharacter(value) ||
487
- value.split("/").some((segment) => segment === "." || segment === "..")
488
- ) {
489
- throw new Error(`Invalid S3 storage location ${label}.`);
490
- }
491
- }
446
+ for (const [label, value, allowEmpty] of [
447
+ ["bucket", config.destination.bucket, false],
448
+ ["prefix", config.destination.prefix, true],
449
+ ["namespace", config.destination.namespace, false],
450
+ ] as const) {
451
+ if (label === "prefix" && value === "./") continue;
452
+ if (
453
+ (!allowEmpty && value.length === 0) ||
454
+ value.includes("\\") ||
455
+ hasControlCharacter(value) ||
456
+ value.split("/").some((segment) => segment === "." || segment === "..")
457
+ ) {
458
+ throw new Error(`Invalid S3 storage location ${label}.`);
459
+ }
460
+ }
492
461
  }
493
462
 
494
463
  function requireSnapshotReference(reference: string) {
495
- if (!isSafeSnapshotReference(reference)) {
496
- throw new Error("Invalid S3 snapshot reference.");
497
- }
464
+ if (!isSafeSnapshotReference(reference)) {
465
+ throw new Error("Invalid S3 snapshot reference.");
466
+ }
498
467
  }
499
468
 
500
469
  function isSafeSnapshotReference(reference: string) {
501
- return (
502
- reference.length > 0 &&
503
- reference.length <= 512 &&
504
- reference !== "." &&
505
- reference !== ".." &&
506
- !reference.includes("/") &&
507
- !reference.includes("\\") &&
508
- !hasControlCharacter(reference)
509
- );
470
+ return (
471
+ reference.length > 0 &&
472
+ reference.length <= 512 &&
473
+ reference !== "." &&
474
+ reference !== ".." &&
475
+ !reference.includes("/") &&
476
+ !reference.includes("\\") &&
477
+ !hasControlCharacter(reference)
478
+ );
510
479
  }
511
480
 
512
481
  function isSafeMetadata(value: string, maxLength: number) {
513
- return value.length > 0 && value.length <= maxLength && !hasControlCharacter(value);
482
+ return value.length > 0 && value.length <= maxLength && !hasControlCharacter(value);
514
483
  }
515
484
 
516
485
  function hasControlCharacter(value: string) {
517
- return [...value].some((character) => {
518
- const code = character.codePointAt(0) ?? 0;
519
- return code < 0x20 || code === 0x7f;
520
- });
486
+ return [...value].some((character) => {
487
+ const code = character.codePointAt(0) ?? 0;
488
+ return code < 0x20 || code === 0x7f;
489
+ });
521
490
  }
522
491
 
523
492
  function throwIfAborted(signal?: AbortSignal) {
524
- if (!signal?.aborted) return;
525
- throw signal.reason instanceof Error
526
- ? signal.reason
527
- : new DOMException("The operation was aborted", "AbortError");
493
+ if (!signal?.aborted) return;
494
+ throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
528
495
  }
529
496
 
530
497
  function sha256(value: Buffer) {
531
- return createHash("sha256").update(value).digest("hex");
498
+ return createHash("sha256").update(value).digest("hex");
532
499
  }
533
500
 
534
501
  function errorMessage(error: unknown) {
535
- return error instanceof Error ? error.message : String(error);
502
+ return error instanceof Error ? error.message : String(error);
536
503
  }
537
504
 
538
505
  export { encodeKey };