@narumitw/pi-sync 0.50.3 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/chunks/{chunk-6LP3MCR2.ts → chunk-2JZFHNLM.ts} +3 -5
- package/dist/chunks/chunk-2JZFHNLM.ts.map +7 -0
- package/dist/chunks/{chunk-LCRI3ABW.ts → chunk-2RZSQWBH.ts} +8 -19
- package/dist/chunks/chunk-2RZSQWBH.ts.map +7 -0
- package/dist/chunks/{chunk-IJ6FJFA5.ts → chunk-4AIHXRKE.ts} +11 -17
- package/dist/chunks/chunk-4AIHXRKE.ts.map +7 -0
- package/dist/chunks/{chunk-C7XY5NF5.ts → chunk-4MPIH46N.ts} +29 -51
- package/dist/chunks/chunk-4MPIH46N.ts.map +7 -0
- package/dist/chunks/{chunk-5B3FXBLE.ts → chunk-6546TS54.ts} +16 -20
- package/dist/chunks/chunk-6546TS54.ts.map +7 -0
- package/dist/chunks/{chunk-Q2TUM5X6.ts → chunk-6NT3YFGY.ts} +2 -2
- package/dist/chunks/chunk-6NT3YFGY.ts.map +7 -0
- package/dist/chunks/{chunk-2B2ELUF5.ts → chunk-7D5SF7P3.ts} +1 -1
- package/dist/chunks/chunk-7D5SF7P3.ts.map +7 -0
- package/dist/chunks/{chunk-YDWUZVPN.ts → chunk-7KHKWL3Q.ts} +28 -47
- package/dist/chunks/chunk-7KHKWL3Q.ts.map +7 -0
- package/dist/chunks/{chunk-ZI5MXMEX.ts → chunk-BVVP33U5.ts} +2 -4
- package/dist/chunks/chunk-BVVP33U5.ts.map +7 -0
- package/dist/chunks/{chunk-SZWBGPID.ts → chunk-DM4X4FIA.ts} +8 -13
- package/dist/chunks/chunk-DM4X4FIA.ts.map +7 -0
- package/dist/chunks/{chunk-SL64RLXR.ts → chunk-DZUSTGQV.ts} +8 -16
- package/dist/chunks/chunk-DZUSTGQV.ts.map +7 -0
- package/dist/chunks/{chunk-YHEMENKV.ts → chunk-E42GXD6D.ts} +13 -19
- package/dist/chunks/chunk-E42GXD6D.ts.map +7 -0
- package/dist/chunks/{chunk-Z4DQF6L3.ts → chunk-ECYCNZOE.ts} +1 -1
- package/dist/chunks/chunk-ECYCNZOE.ts.map +7 -0
- package/dist/chunks/{chunk-OEX4ABY6.ts → chunk-EEJPYTWE.ts} +11 -21
- package/dist/chunks/chunk-EEJPYTWE.ts.map +7 -0
- package/dist/chunks/{chunk-CPEHGXJV.ts → chunk-G4RUXRWZ.ts} +3 -5
- package/dist/chunks/chunk-G4RUXRWZ.ts.map +7 -0
- package/dist/chunks/{chunk-T2B5IU74.ts → chunk-HGJH56KR.ts} +4 -4
- package/dist/chunks/chunk-HGJH56KR.ts.map +7 -0
- package/dist/chunks/{chunk-JAAKLL6M.ts → chunk-IVXEDPAD.ts} +10 -34
- package/dist/chunks/chunk-IVXEDPAD.ts.map +7 -0
- package/dist/chunks/chunk-K3RIEL6J.ts +20 -0
- package/dist/chunks/chunk-K3RIEL6J.ts.map +7 -0
- package/dist/chunks/{chunk-LTBWGSBV.ts → chunk-MSSW6F3R.ts} +1 -1
- package/dist/chunks/chunk-MSSW6F3R.ts.map +7 -0
- package/dist/chunks/{chunk-T3HH3UEJ.ts → chunk-OVDTF5CH.ts} +4 -10
- package/dist/chunks/chunk-OVDTF5CH.ts.map +7 -0
- package/dist/chunks/{chunk-NJFUIVYL.ts → chunk-PWMWOJP4.ts} +1 -1
- package/dist/chunks/chunk-PWMWOJP4.ts.map +7 -0
- package/dist/chunks/{chunk-HUDQATCL.ts → chunk-Q32CQWPM.ts} +6 -8
- package/dist/chunks/chunk-Q32CQWPM.ts.map +7 -0
- package/dist/chunks/{chunk-ETTRJDNP.ts → chunk-QLSN7AGX.ts} +6 -13
- package/dist/chunks/chunk-QLSN7AGX.ts.map +7 -0
- package/dist/chunks/{chunk-THMF4BVL.ts → chunk-SWUXA3MH.ts} +1 -1
- package/dist/chunks/chunk-SWUXA3MH.ts.map +7 -0
- package/dist/chunks/{chunk-TEN2ZBJV.ts → chunk-T2SJD3N7.ts} +2 -4
- package/dist/chunks/chunk-T2SJD3N7.ts.map +7 -0
- package/dist/chunks/{chunk-AUSDLD6F.ts → chunk-T56RNRZJ.ts} +10 -26
- package/dist/chunks/chunk-T56RNRZJ.ts.map +7 -0
- package/dist/chunks/{chunk-DHVFS4QK.ts → chunk-UA7RXOIT.ts} +37 -90
- package/dist/chunks/chunk-UA7RXOIT.ts.map +7 -0
- package/dist/chunks/{chunk-D5X27LCA.ts → chunk-UETBKTDI.ts} +1 -1
- package/dist/chunks/chunk-UETBKTDI.ts.map +7 -0
- package/dist/chunks/{chunk-CHH26D4F.ts → chunk-YFADF5ZQ.ts} +7 -6
- package/dist/chunks/chunk-YFADF5ZQ.ts.map +7 -0
- package/dist/chunks/{chunk-CCSN3F47.ts → chunk-YPXGU5OR.ts} +4 -4
- package/dist/chunks/chunk-YPXGU5OR.ts.map +7 -0
- package/dist/chunks/{file-selection-FTTKAOYX.ts → file-selection-RAVHRYFG.ts} +14 -26
- package/dist/chunks/file-selection-RAVHRYFG.ts.map +7 -0
- package/dist/chunks/{git-backend-GMVS4PD4.ts → git-backend-IAANDL3K.ts} +41 -104
- package/dist/chunks/git-backend-IAANDL3K.ts.map +7 -0
- package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +23 -0
- package/dist/chunks/{manager-ui-IPAOAWZA.ts → manager-ui-763NEKJY.ts} +124 -251
- package/dist/chunks/manager-ui-763NEKJY.ts.map +7 -0
- package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +22 -0
- package/dist/chunks/{s3-backend-66O4FGBH.ts → s3-backend-XGXUOPUN.ts} +23 -54
- package/dist/chunks/s3-backend-XGXUOPUN.ts.map +7 -0
- package/dist/chunks/{setup-switch-IS2PEZ3J.ts → setup-switch-5G2VNJHL.ts} +9 -9
- package/dist/chunks/{setup-wizard-JUJZYNNM.ts → setup-wizard-YIEWGX32.ts} +12 -12
- package/dist/chunks/setup-wizard-YIEWGX32.ts.map +7 -0
- package/dist/chunks/{snapshot-HKEWCVK6.ts → snapshot-JL5DFSXI.ts} +5 -5
- package/dist/chunks/sync-inspection-P3ENJLNF.ts +20 -0
- package/dist/chunks/{sync-operations-LBX3GKCM.ts → sync-operations-AR76EB6C.ts} +85 -201
- package/dist/chunks/sync-operations-AR76EB6C.ts.map +7 -0
- package/dist/chunks/{sync-state-Q2FTXTW6.ts → sync-state-VHJUZ4YG.ts} +6 -6
- package/dist/chunks/{webdav-backend-ECIKD7SO.ts → webdav-backend-3EGUONAT.ts} +20 -51
- package/dist/chunks/webdav-backend-3EGUONAT.ts.map +7 -0
- package/dist/index.ts +49 -74
- package/dist/index.ts.map +3 -3
- package/docs/settings.md +9 -0
- package/package.json +61 -62
- package/src/backends/backend-factory.ts +15 -15
- package/src/backends/backend-identity.ts +31 -31
- package/src/backends/backend-types.ts +42 -42
- package/src/backends/git/git-backend.ts +714 -788
- package/src/backends/git/git-config.ts +104 -111
- package/src/backends/git/git-runner.ts +230 -247
- package/src/backends/git/git-storage.ts +228 -245
- package/src/backends/s3/s3-backend.ts +408 -441
- package/src/backends/s3/s3-client.ts +288 -333
- package/src/backends/sync-backend.ts +65 -65
- package/src/backends/webdav/webdav-backend.ts +542 -589
- package/src/backends/webdav/webdav-client.ts +292 -316
- package/src/backends/webdav/webdav-config.ts +57 -68
- package/src/commands/command-execution.ts +270 -289
- package/src/commands/command-handler.ts +160 -163
- package/src/commands/command-types.ts +13 -13
- package/src/commands/command.ts +190 -196
- package/src/commands/setup-completions.ts +1 -1
- package/src/paths.ts +47 -47
- package/src/settings/config-errors.ts +4 -5
- package/src/settings/config-file.ts +473 -506
- package/src/settings/config.ts +171 -174
- package/src/settings/settings-management.ts +191 -216
- package/src/settings/settings-store.ts +63 -77
- package/src/settings/settings-types.ts +78 -75
- package/src/settings/settings-validation.ts +328 -364
- package/src/snapshot/session-paths.ts +41 -55
- package/src/snapshot/snapshot-apply.ts +197 -229
- package/src/snapshot/snapshot-codec.ts +29 -31
- package/src/snapshot/snapshot-paths.ts +4 -6
- package/src/snapshot/snapshot-transaction.ts +161 -170
- package/src/snapshot/snapshot-types.ts +24 -24
- package/src/snapshot/snapshot.ts +310 -343
- package/src/state/json-file.ts +11 -11
- package/src/state/lock.ts +226 -242
- package/src/state/lockfile-fs.ts +20 -20
- package/src/state/operation-availability.ts +36 -41
- package/src/state/state-directory.ts +171 -173
- package/src/state/state-types.ts +15 -15
- package/src/state/sync-state-store.ts +23 -23
- package/src/sync/automatic-sync.ts +75 -80
- package/src/sync/remote-snapshot.ts +52 -60
- package/src/sync/setup-switch.ts +99 -114
- package/src/sync/signals.ts +3 -5
- package/src/sync/startup-check.ts +97 -111
- package/src/sync/sync-decision.ts +76 -80
- package/src/sync/sync-error-guidance.ts +41 -48
- package/src/sync/sync-errors.ts +10 -10
- package/src/sync/sync-inspection.ts +29 -33
- package/src/sync/sync-loaders.ts +28 -36
- package/src/sync/sync-mutations.ts +510 -622
- package/src/sync/sync-operations.ts +7 -7
- package/src/sync/sync-policy.ts +296 -326
- package/src/sync/sync-queries.ts +175 -194
- package/src/sync/sync-state.ts +74 -100
- package/src/sync-extension.ts +89 -101
- package/src/sync.ts +26 -26
- package/src/types.ts +37 -37
- package/src/ui/automatic-sync-summary.ts +1 -3
- package/src/ui/cancellable-operation.ts +89 -101
- package/src/ui/file-selection.ts +250 -274
- package/src/ui/manager-attention.ts +51 -63
- package/src/ui/manager-recovery.ts +48 -57
- package/src/ui/manager-result-dispatcher.ts +62 -76
- package/src/ui/manager-state.ts +200 -232
- package/src/ui/manager-ui.ts +264 -362
- package/src/ui/remote-selection-ui.ts +547 -588
- package/src/ui/secret-input.ts +197 -205
- package/src/ui/settings-ui.ts +175 -172
- package/src/ui/setup/git-ui.ts +232 -261
- package/src/ui/setup/s3-credentials-ui.ts +58 -65
- package/src/ui/setup/s3-ui.ts +171 -195
- package/src/ui/setup/setup-actions.ts +91 -101
- package/src/ui/setup/setup-location-ui.ts +121 -137
- package/src/ui/setup/setup-name-ui.ts +27 -27
- package/src/ui/setup/setup-prompts.ts +65 -65
- package/src/ui/setup/setup-review.ts +58 -59
- package/src/ui/setup/setup-switcher.ts +156 -160
- package/src/ui/setup/setup-wizard.ts +28 -28
- package/src/ui/setup/text-input.ts +54 -58
- package/src/ui/setup/webdav-ui.ts +246 -293
- package/src/ui/storage-connections-ui.ts +317 -369
- package/src/ui/sync-attention.ts +148 -164
- package/src/ui/sync-format.ts +110 -121
- package/src/ui/sync-resolution-ui.ts +147 -153
- package/src/ui/sync-setups-ui.ts +218 -238
- package/src/ui/sync-status.ts +17 -0
- package/src/ui/terminal-text.ts +2 -2
- package/dist/chunks/chunk-2B2ELUF5.ts.map +0 -7
- package/dist/chunks/chunk-5B3FXBLE.ts.map +0 -7
- package/dist/chunks/chunk-6LP3MCR2.ts.map +0 -7
- package/dist/chunks/chunk-AUSDLD6F.ts.map +0 -7
- package/dist/chunks/chunk-C7XY5NF5.ts.map +0 -7
- package/dist/chunks/chunk-CCSN3F47.ts.map +0 -7
- package/dist/chunks/chunk-CHH26D4F.ts.map +0 -7
- package/dist/chunks/chunk-CPEHGXJV.ts.map +0 -7
- package/dist/chunks/chunk-D5X27LCA.ts.map +0 -7
- package/dist/chunks/chunk-DHVFS4QK.ts.map +0 -7
- package/dist/chunks/chunk-ETTRJDNP.ts.map +0 -7
- package/dist/chunks/chunk-HUDQATCL.ts.map +0 -7
- package/dist/chunks/chunk-IJ6FJFA5.ts.map +0 -7
- package/dist/chunks/chunk-JAAKLL6M.ts.map +0 -7
- package/dist/chunks/chunk-LCRI3ABW.ts.map +0 -7
- package/dist/chunks/chunk-LTBWGSBV.ts.map +0 -7
- package/dist/chunks/chunk-NJFUIVYL.ts.map +0 -7
- package/dist/chunks/chunk-OEX4ABY6.ts.map +0 -7
- package/dist/chunks/chunk-Q2TUM5X6.ts.map +0 -7
- package/dist/chunks/chunk-SL64RLXR.ts.map +0 -7
- package/dist/chunks/chunk-SZWBGPID.ts.map +0 -7
- package/dist/chunks/chunk-T2B5IU74.ts.map +0 -7
- package/dist/chunks/chunk-T3HH3UEJ.ts.map +0 -7
- package/dist/chunks/chunk-TEN2ZBJV.ts.map +0 -7
- package/dist/chunks/chunk-THMF4BVL.ts.map +0 -7
- package/dist/chunks/chunk-YDWUZVPN.ts.map +0 -7
- package/dist/chunks/chunk-YHEMENKV.ts.map +0 -7
- package/dist/chunks/chunk-Z4DQF6L3.ts.map +0 -7
- package/dist/chunks/chunk-ZI5MXMEX.ts.map +0 -7
- package/dist/chunks/file-selection-FTTKAOYX.ts.map +0 -7
- package/dist/chunks/git-backend-GMVS4PD4.ts.map +0 -7
- package/dist/chunks/manager-result-dispatcher-ASKGOSW5.ts +0 -22
- package/dist/chunks/manager-ui-IPAOAWZA.ts.map +0 -7
- package/dist/chunks/remote-selection-ui-QDNR7WEI.ts +0 -21
- package/dist/chunks/s3-backend-66O4FGBH.ts.map +0 -7
- package/dist/chunks/setup-wizard-JUJZYNNM.ts.map +0 -7
- package/dist/chunks/sync-inspection-CST2KL65.ts +0 -20
- package/dist/chunks/sync-operations-LBX3GKCM.ts.map +0 -7
- package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +0 -7
- /package/dist/chunks/{manager-result-dispatcher-ASKGOSW5.ts.map → manager-result-dispatcher-OTUMQM26.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-QDNR7WEI.ts.map → remote-selection-ui-TZTJF32Y.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-IS2PEZ3J.ts.map → setup-switch-5G2VNJHL.ts.map} +0 -0
- /package/dist/chunks/{snapshot-HKEWCVK6.ts.map → snapshot-JL5DFSXI.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-CST2KL65.ts.map → sync-inspection-P3ENJLNF.ts.map} +0 -0
- /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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
+
return posixJoin(storageRoot(config), "latest.json");
|
|
283
265
|
}
|
|
284
266
|
|
|
285
267
|
export function historyKey(config: ResolvedS3Backend) {
|
|
286
|
-
|
|
268
|
+
return posixJoin(storageRoot(config), "history.json");
|
|
287
269
|
}
|
|
288
270
|
|
|
289
271
|
export function snapshotKey(config: ResolvedS3Backend, id: string) {
|
|
290
|
-
|
|
291
|
-
|
|
272
|
+
requireSnapshotReference(id);
|
|
273
|
+
return posixJoin(storageRoot(config), "snapshots", `${id}.json.gz`);
|
|
292
274
|
}
|
|
293
275
|
|
|
294
276
|
export function storageRoot(config: ResolvedS3Backend) {
|
|
295
|
-
|
|
277
|
+
return config.destination.prefix === "./" ? "" : config.destination.prefix;
|
|
296
278
|
}
|
|
297
279
|
|
|
298
|
-
export function pointerFor(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
325
|
+
return value.replace(/^\/+|\/+$/g, "");
|
|
350
326
|
}
|
|
351
327
|
|
|
352
328
|
function remoteHistoryEntry(pointer: LatestPointer): RemoteHistoryEntry {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
351
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
376
352
|
}
|
|
377
353
|
|
|
378
354
|
function canonicalJson(value: unknown) {
|
|
379
|
-
|
|
355
|
+
return JSON.stringify(canonicalValue(value));
|
|
380
356
|
}
|
|
381
357
|
|
|
382
358
|
function canonicalValue(value: unknown): unknown {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
394
|
-
|
|
369
|
+
if (expected.kind === "missing") return head === undefined;
|
|
370
|
+
return head?.revision === expected.revision;
|
|
395
371
|
}
|
|
396
372
|
|
|
397
|
-
function requirePointer(
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
496
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
482
|
+
return value.length > 0 && value.length <= maxLength && !hasControlCharacter(value);
|
|
514
483
|
}
|
|
515
484
|
|
|
516
485
|
function hasControlCharacter(value: string) {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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
|
-
|
|
498
|
+
return createHash("sha256").update(value).digest("hex");
|
|
532
499
|
}
|
|
533
500
|
|
|
534
501
|
function errorMessage(error: unknown) {
|
|
535
|
-
|
|
502
|
+
return error instanceof Error ? error.message : String(error);
|
|
536
503
|
}
|
|
537
504
|
|
|
538
505
|
export { encodeKey };
|