@narumitw/pi-sync 0.49.10 → 0.49.11
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 +10 -1
- package/dist/chunks/chunk-3GRWMWUZ.ts +213 -0
- package/dist/chunks/chunk-3GRWMWUZ.ts.map +7 -0
- package/dist/chunks/chunk-3NDD5QPM.ts +203 -0
- package/dist/chunks/chunk-3NDD5QPM.ts.map +7 -0
- package/dist/chunks/chunk-4F3W4CSC.ts +195 -0
- package/dist/chunks/chunk-4F3W4CSC.ts.map +7 -0
- package/dist/chunks/chunk-4U6AFLJB.ts +221 -0
- package/dist/chunks/chunk-4U6AFLJB.ts.map +7 -0
- package/dist/chunks/chunk-5MQ76BK5.ts +34 -0
- package/dist/chunks/chunk-5MQ76BK5.ts.map +7 -0
- package/dist/chunks/chunk-APZVBSM6.ts +1238 -0
- package/dist/chunks/chunk-APZVBSM6.ts.map +7 -0
- package/dist/chunks/chunk-BHJUISB4.ts +134 -0
- package/dist/chunks/chunk-BHJUISB4.ts.map +7 -0
- package/dist/chunks/chunk-D3S54OXI.ts +653 -0
- package/dist/chunks/chunk-D3S54OXI.ts.map +7 -0
- package/dist/chunks/chunk-EWX3TPLQ.ts +356 -0
- package/dist/chunks/chunk-EWX3TPLQ.ts.map +7 -0
- package/dist/chunks/chunk-F5QL2GPY.ts +341 -0
- package/dist/chunks/chunk-F5QL2GPY.ts.map +7 -0
- package/dist/chunks/chunk-FR6CVXAL.ts +81 -0
- package/dist/chunks/chunk-FR6CVXAL.ts.map +7 -0
- package/dist/chunks/chunk-HGV24P4T.ts +53 -0
- package/dist/chunks/chunk-HGV24P4T.ts.map +7 -0
- package/dist/chunks/chunk-JNOOSPEW.ts +82 -0
- package/dist/chunks/chunk-JNOOSPEW.ts.map +7 -0
- package/dist/chunks/chunk-LJDSHNS3.ts +259 -0
- package/dist/chunks/chunk-LJDSHNS3.ts.map +7 -0
- package/dist/chunks/chunk-RGJ6SOYR.ts +128 -0
- package/dist/chunks/chunk-RGJ6SOYR.ts.map +7 -0
- package/dist/chunks/chunk-W64NEHXT.ts +167 -0
- package/dist/chunks/chunk-W64NEHXT.ts.map +7 -0
- package/dist/chunks/chunk-YQ6UW7IF.ts +394 -0
- package/dist/chunks/chunk-YQ6UW7IF.ts.map +7 -0
- package/dist/chunks/file-selection-4UABOP44.ts +290 -0
- package/dist/chunks/file-selection-4UABOP44.ts.map +7 -0
- package/dist/chunks/git-backend-E3UOUUOM.ts +1104 -0
- package/dist/chunks/git-backend-E3UOUUOM.ts.map +7 -0
- package/dist/chunks/manager-result-dispatcher-YKBLZ7KL.ts +18 -0
- package/dist/chunks/manager-result-dispatcher-YKBLZ7KL.ts.map +7 -0
- package/dist/chunks/manager-ui-7E25VWUP.ts +2754 -0
- package/dist/chunks/manager-ui-7E25VWUP.ts.map +7 -0
- package/dist/chunks/remote-selection-ui-J6OW5DKZ.ts +17 -0
- package/dist/chunks/remote-selection-ui-J6OW5DKZ.ts.map +7 -0
- package/dist/chunks/s3-backend-3QZGO2EF.ts +680 -0
- package/dist/chunks/s3-backend-3QZGO2EF.ts.map +7 -0
- package/dist/chunks/setup-switch-X3W3FALZ.ts +27 -0
- package/dist/chunks/setup-switch-X3W3FALZ.ts.map +7 -0
- package/dist/chunks/snapshot-WBSV4MQG.ts +45 -0
- package/dist/chunks/snapshot-WBSV4MQG.ts.map +7 -0
- package/dist/chunks/sync-operations-MPAFVVHA.ts +1112 -0
- package/dist/chunks/sync-operations-MPAFVVHA.ts.map +7 -0
- package/dist/chunks/sync-state-36H7ULG2.ts +41 -0
- package/dist/chunks/sync-state-36H7ULG2.ts.map +7 -0
- package/dist/chunks/webdav-backend-ZD5RHRIU.ts +869 -0
- package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +7 -0
- package/dist/index.ts +668 -0
- package/dist/index.ts.map +7 -0
- package/package.json +10 -6
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
// @generated by scripts/build-runtime.mjs; do not edit.
|
|
2
|
+
// @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader.
|
|
3
|
+
import {
|
|
4
|
+
sessionTokenWarnings
|
|
5
|
+
} from "./chunk-APZVBSM6.js";
|
|
6
|
+
import {
|
|
7
|
+
decodeSnapshot,
|
|
8
|
+
encodeSnapshot
|
|
9
|
+
} from "./chunk-HGV24P4T.js";
|
|
10
|
+
import "./chunk-F5QL2GPY.js";
|
|
11
|
+
import {
|
|
12
|
+
SyncBackendConflictError,
|
|
13
|
+
SyncBackendPublicationOutcomeUnknownError
|
|
14
|
+
} from "./chunk-5MQ76BK5.js";
|
|
15
|
+
import {
|
|
16
|
+
encodeKey,
|
|
17
|
+
portableSnapshotSelection,
|
|
18
|
+
posixJoin
|
|
19
|
+
} from "./chunk-YQ6UW7IF.js";
|
|
20
|
+
|
|
21
|
+
// src/s3-backend.ts
|
|
22
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
23
|
+
|
|
24
|
+
// src/s3-client.ts
|
|
25
|
+
import { createHash, createHmac } from "node:crypto";
|
|
26
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
27
|
+
var MAX_JSON_RESPONSE_BYTES = 1024 * 1024;
|
|
28
|
+
var MAX_SNAPSHOT_RESPONSE_BYTES = 256 * 1024 * 1024;
|
|
29
|
+
var MAX_ERROR_RESPONSE_BYTES = 64 * 1024;
|
|
30
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
31
|
+
function iso8601Basic(date) {
|
|
32
|
+
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
33
|
+
}
|
|
34
|
+
function isCloudflareR2Endpoint(endpoint) {
|
|
35
|
+
const value = endpoint?.trim();
|
|
36
|
+
if (!value) return false;
|
|
37
|
+
try {
|
|
38
|
+
const hostname = new URL(value).hostname.toLowerCase();
|
|
39
|
+
return hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com");
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
var S3ObjectAlreadyExistsError = class extends Error {
|
|
45
|
+
constructor(key) {
|
|
46
|
+
super(`S3 object already exists: ${key}`);
|
|
47
|
+
this.key = key;
|
|
48
|
+
this.name = "S3ObjectAlreadyExistsError";
|
|
49
|
+
}
|
|
50
|
+
key;
|
|
51
|
+
};
|
|
52
|
+
var S3Client = class {
|
|
53
|
+
config;
|
|
54
|
+
endpoint;
|
|
55
|
+
signal;
|
|
56
|
+
requestTimeoutMs;
|
|
57
|
+
omitSessionTokenAfterRejection = false;
|
|
58
|
+
constructor(config, signal, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
59
|
+
this.config = config;
|
|
60
|
+
this.endpoint = new URL(config.profile.endpoint);
|
|
61
|
+
this.signal = signal;
|
|
62
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
63
|
+
}
|
|
64
|
+
async getJson(key) {
|
|
65
|
+
const maxAttempts = 3;
|
|
66
|
+
let lastError;
|
|
67
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
68
|
+
const object = await this.request("GET", key);
|
|
69
|
+
if (object.status === 404) return { missing: true };
|
|
70
|
+
if (!object.ok) {
|
|
71
|
+
throw new Error(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`);
|
|
72
|
+
}
|
|
73
|
+
const body = await readBoundedText(object, MAX_JSON_RESPONSE_BYTES, "S3 JSON response");
|
|
74
|
+
if (body.length > 0) {
|
|
75
|
+
return {
|
|
76
|
+
value: JSON.parse(body),
|
|
77
|
+
etag: normalizeEtag(object.headers.get("etag")),
|
|
78
|
+
missing: false
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
lastError = new Error(`S3 GET returned an empty body for ${key}`);
|
|
82
|
+
if (attempt < maxAttempts) {
|
|
83
|
+
await sleep(250 * attempt, void 0, { signal: this.signal });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw lastError;
|
|
87
|
+
}
|
|
88
|
+
async getBuffer(key) {
|
|
89
|
+
const maxAttempts = 3;
|
|
90
|
+
let lastError;
|
|
91
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
92
|
+
const object = await this.request("GET", key);
|
|
93
|
+
if (object.status === 404) return { missing: true };
|
|
94
|
+
if (!object.ok) {
|
|
95
|
+
throw new Error(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`);
|
|
96
|
+
}
|
|
97
|
+
const buffer = await readBoundedBuffer(
|
|
98
|
+
object,
|
|
99
|
+
MAX_SNAPSHOT_RESPONSE_BYTES,
|
|
100
|
+
"S3 snapshot response"
|
|
101
|
+
);
|
|
102
|
+
if (buffer.length > 0) {
|
|
103
|
+
return { value: buffer, etag: normalizeEtag(object.headers.get("etag")), missing: false };
|
|
104
|
+
}
|
|
105
|
+
lastError = new Error(`S3 GET returned an empty body for ${key}`);
|
|
106
|
+
if (attempt < maxAttempts) {
|
|
107
|
+
await sleep(250 * attempt, void 0, { signal: this.signal });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
throw lastError;
|
|
111
|
+
}
|
|
112
|
+
async putJson(key, value) {
|
|
113
|
+
const body = Buffer.from(JSON.stringify(value, null, " "), "utf8");
|
|
114
|
+
await this.putBuffer(key, body, "application/json");
|
|
115
|
+
}
|
|
116
|
+
async putBuffer(key, body, contentType, options = {}) {
|
|
117
|
+
const headers = { "content-type": contentType };
|
|
118
|
+
if (options.ifAbsent) headers["if-none-match"] = "*";
|
|
119
|
+
const response = await this.request("PUT", key, body, headers);
|
|
120
|
+
if (options.ifAbsent && response.status === 412) {
|
|
121
|
+
throw new S3ObjectAlreadyExistsError(key);
|
|
122
|
+
}
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error(`S3 PUT failed (${response.status}): ${await this.readErrorText(response)}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async request(method, key, body, extraHeaders = {}) {
|
|
128
|
+
const url = new URL(this.endpoint.toString());
|
|
129
|
+
url.pathname = posixJoin(url.pathname, this.config.destination.bucket, encodeKey(key));
|
|
130
|
+
const send = async (sessionToken2) => {
|
|
131
|
+
const transportSignal = this.transportSignal();
|
|
132
|
+
const headers = await signedHeaders({
|
|
133
|
+
method,
|
|
134
|
+
url,
|
|
135
|
+
body,
|
|
136
|
+
extraHeaders,
|
|
137
|
+
accessKeyId: this.config.profile.accessKeyId,
|
|
138
|
+
secretAccessKey: this.config.profile.secretAccessKey,
|
|
139
|
+
sessionToken: sessionToken2,
|
|
140
|
+
region: this.config.profile.region
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
return await fetch(url, {
|
|
144
|
+
method,
|
|
145
|
+
headers,
|
|
146
|
+
body: body ? new Uint8Array(body) : void 0,
|
|
147
|
+
signal: transportSignal
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
151
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
152
|
+
throw new Error("S3 request timed out.", { cause: error });
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`S3 request failed: ${this.redact(errorMessage(error))}`, { cause: error });
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const sessionToken = this.omitSessionTokenAfterRejection ? void 0 : this.config.profile.sessionToken;
|
|
158
|
+
const response = await send(sessionToken);
|
|
159
|
+
if (!await this.shouldRetryWithoutSessionToken(response, sessionToken)) return response;
|
|
160
|
+
const retry = await send(void 0);
|
|
161
|
+
if (retry.ok || retry.status === 404) this.omitSessionTokenAfterRejection = true;
|
|
162
|
+
return retry;
|
|
163
|
+
}
|
|
164
|
+
transportSignal() {
|
|
165
|
+
const timeout = AbortSignal.timeout(this.requestTimeoutMs);
|
|
166
|
+
return this.signal ? AbortSignal.any([this.signal, timeout]) : timeout;
|
|
167
|
+
}
|
|
168
|
+
async shouldRetryWithoutSessionToken(response, sessionToken) {
|
|
169
|
+
if (!sessionToken || !isCloudflareR2Endpoint(this.config.profile.endpoint) || response.ok || response.status !== 400) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return isSecurityTokenInvalidArgument(
|
|
173
|
+
await readBoundedText(response.clone(), MAX_ERROR_RESPONSE_BYTES, "S3 error response")
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
async readErrorText(response) {
|
|
177
|
+
try {
|
|
178
|
+
return this.redact(
|
|
179
|
+
await readBoundedText(response, MAX_ERROR_RESPONSE_BYTES, "S3 error response")
|
|
180
|
+
);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return this.redact(errorMessage(error));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
redact(value) {
|
|
186
|
+
let redacted = value;
|
|
187
|
+
let endpointUsername;
|
|
188
|
+
let endpointPassword;
|
|
189
|
+
let endpointQueryValues = [];
|
|
190
|
+
try {
|
|
191
|
+
const endpoint = new URL(this.config.profile.endpoint);
|
|
192
|
+
endpointUsername = endpoint.username;
|
|
193
|
+
endpointPassword = endpoint.password;
|
|
194
|
+
endpointQueryValues = [...endpoint.searchParams.values()];
|
|
195
|
+
} catch {
|
|
196
|
+
}
|
|
197
|
+
for (const secret of [
|
|
198
|
+
this.config.profile.accessKeyId,
|
|
199
|
+
this.config.profile.secretAccessKey,
|
|
200
|
+
this.config.profile.sessionToken,
|
|
201
|
+
endpointUsername,
|
|
202
|
+
endpointPassword,
|
|
203
|
+
...endpointQueryValues
|
|
204
|
+
]) {
|
|
205
|
+
if (secret) redacted = redacted.replaceAll(secret, "[REDACTED]");
|
|
206
|
+
}
|
|
207
|
+
return redacted;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
async function readBoundedText(response, limit, label) {
|
|
211
|
+
return (await readBoundedBuffer(response, limit, label)).toString("utf8");
|
|
212
|
+
}
|
|
213
|
+
async function readBoundedBuffer(response, limit, label) {
|
|
214
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
215
|
+
if (Number.isFinite(contentLength) && contentLength > limit) {
|
|
216
|
+
throw new Error(`${label} exceeds the ${limit}-byte limit.`);
|
|
217
|
+
}
|
|
218
|
+
if (!response.body) return Buffer.alloc(0);
|
|
219
|
+
const reader = response.body.getReader();
|
|
220
|
+
const chunks = [];
|
|
221
|
+
let total = 0;
|
|
222
|
+
try {
|
|
223
|
+
while (true) {
|
|
224
|
+
const { done, value } = await reader.read();
|
|
225
|
+
if (done) break;
|
|
226
|
+
total += value.byteLength;
|
|
227
|
+
if (total > limit) {
|
|
228
|
+
await reader.cancel().catch(() => void 0);
|
|
229
|
+
throw new Error(`${label} exceeds the ${limit}-byte limit.`);
|
|
230
|
+
}
|
|
231
|
+
chunks.push(Buffer.from(value));
|
|
232
|
+
}
|
|
233
|
+
} finally {
|
|
234
|
+
reader.releaseLock();
|
|
235
|
+
}
|
|
236
|
+
return Buffer.concat(chunks, total);
|
|
237
|
+
}
|
|
238
|
+
function errorMessage(error) {
|
|
239
|
+
return error instanceof Error ? error.message : String(error);
|
|
240
|
+
}
|
|
241
|
+
async function signedHeaders(input) {
|
|
242
|
+
const now = /* @__PURE__ */ new Date();
|
|
243
|
+
const amzDate = iso8601Basic(now);
|
|
244
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
245
|
+
const payloadHash = sha256(input.body ?? Buffer.alloc(0));
|
|
246
|
+
const headers = {
|
|
247
|
+
...lowercaseKeys(input.extraHeaders),
|
|
248
|
+
host: input.url.host,
|
|
249
|
+
"x-amz-content-sha256": payloadHash,
|
|
250
|
+
"x-amz-date": amzDate
|
|
251
|
+
};
|
|
252
|
+
if (input.sessionToken) headers["x-amz-security-token"] = input.sessionToken;
|
|
253
|
+
const signedHeaderNames = Object.keys(headers).sort();
|
|
254
|
+
const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers[name]?.trim()}
|
|
255
|
+
`).join("");
|
|
256
|
+
const canonicalRequest = [
|
|
257
|
+
input.method,
|
|
258
|
+
input.url.pathname,
|
|
259
|
+
input.url.searchParams.toString(),
|
|
260
|
+
canonicalHeaders,
|
|
261
|
+
signedHeaderNames.join(";"),
|
|
262
|
+
payloadHash
|
|
263
|
+
].join("\n");
|
|
264
|
+
const scope = `${dateStamp}/${input.region}/s3/aws4_request`;
|
|
265
|
+
const stringToSign = [
|
|
266
|
+
"AWS4-HMAC-SHA256",
|
|
267
|
+
amzDate,
|
|
268
|
+
scope,
|
|
269
|
+
sha256(Buffer.from(canonicalRequest))
|
|
270
|
+
].join("\n");
|
|
271
|
+
const signingKey = hmac(
|
|
272
|
+
hmac(hmac(hmac(Buffer.from(`AWS4${input.secretAccessKey}`), dateStamp), input.region), "s3"),
|
|
273
|
+
"aws4_request"
|
|
274
|
+
);
|
|
275
|
+
const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
|
|
276
|
+
return {
|
|
277
|
+
...headers,
|
|
278
|
+
authorization: `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, SignedHeaders=${signedHeaderNames.join(";")}, Signature=${signature}`
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function hmac(key, value) {
|
|
282
|
+
return createHmac("sha256", key).update(value).digest();
|
|
283
|
+
}
|
|
284
|
+
function sha256(value) {
|
|
285
|
+
return createHash("sha256").update(value).digest("hex");
|
|
286
|
+
}
|
|
287
|
+
function lowercaseKeys(value) {
|
|
288
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key.toLowerCase(), item]));
|
|
289
|
+
}
|
|
290
|
+
function normalizeEtag(value) {
|
|
291
|
+
return value ?? void 0;
|
|
292
|
+
}
|
|
293
|
+
function isSecurityTokenInvalidArgument(text) {
|
|
294
|
+
return text.includes("<Code>InvalidArgument</Code>") && text.includes("<Message>X-Amz-Security-Token</Message>");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/s3-backend.ts
|
|
298
|
+
var VERSION = 1;
|
|
299
|
+
var POST_COMMIT_TIMEOUT_MS = 3e4;
|
|
300
|
+
var S3SyncBackend = class {
|
|
301
|
+
constructor(config, postCommitTimeoutMs = POST_COMMIT_TIMEOUT_MS) {
|
|
302
|
+
this.config = config;
|
|
303
|
+
this.postCommitTimeoutMs = postCommitTimeoutMs;
|
|
304
|
+
assertSafeDestination(config);
|
|
305
|
+
this.identity = s3BackendIdentity(config);
|
|
306
|
+
this.destination = s3Destination(config);
|
|
307
|
+
}
|
|
308
|
+
config;
|
|
309
|
+
postCommitTimeoutMs;
|
|
310
|
+
identity;
|
|
311
|
+
destination;
|
|
312
|
+
capability = "read-check-write-verify";
|
|
313
|
+
checksums = /* @__PURE__ */ new Map();
|
|
314
|
+
sameRevision(left, right) {
|
|
315
|
+
return left === right;
|
|
316
|
+
}
|
|
317
|
+
async readHead(signal) {
|
|
318
|
+
const object = await new S3Client(this.config, signal).getJson(
|
|
319
|
+
latestKey(this.config)
|
|
320
|
+
);
|
|
321
|
+
throwIfAborted(signal);
|
|
322
|
+
if (object.missing) return void 0;
|
|
323
|
+
const pointer = requirePointer(
|
|
324
|
+
object.value,
|
|
325
|
+
"Remote latest pointer is malformed.",
|
|
326
|
+
this.config.destination.namespace
|
|
327
|
+
);
|
|
328
|
+
this.registerChecksum(pointer.snapshot, pointer.sha256);
|
|
329
|
+
return remoteHead(pointer, this.identity, object.etag);
|
|
330
|
+
}
|
|
331
|
+
async readSnapshot(reference, signal) {
|
|
332
|
+
const expectedChecksum = this.checksums.get(reference) ?? await this.resolveChecksum(reference, signal);
|
|
333
|
+
const object = await new S3Client(this.config, signal).getBuffer(
|
|
334
|
+
snapshotKey(this.config, reference)
|
|
335
|
+
);
|
|
336
|
+
throwIfAborted(signal);
|
|
337
|
+
if (!object.value) throw new Error(`Snapshot not found: ${reference}`);
|
|
338
|
+
if (expectedChecksum && sha2562(object.value) !== expectedChecksum) {
|
|
339
|
+
throw new Error("Remote snapshot checksum mismatch.");
|
|
340
|
+
}
|
|
341
|
+
const snapshot = await decodeSnapshot(object.value, { signal });
|
|
342
|
+
if (snapshot.id !== reference || snapshot.profile !== this.config.destination.namespace) {
|
|
343
|
+
throw new Error("Remote snapshot identity mismatch.");
|
|
344
|
+
}
|
|
345
|
+
validateSnapshotBundle(snapshot);
|
|
346
|
+
return snapshot;
|
|
347
|
+
}
|
|
348
|
+
async publishSnapshot(snapshot, expected, options = {}) {
|
|
349
|
+
throwIfAborted(options.signal);
|
|
350
|
+
assertSnapshotIdentity(snapshot, this.config.destination.namespace);
|
|
351
|
+
const stagedKey = snapshotKey(this.config, snapshot.id);
|
|
352
|
+
const encoded = await encodeSnapshot(snapshot);
|
|
353
|
+
throwIfAborted(options.signal);
|
|
354
|
+
const pointer = pointerFor(this.config, snapshot, sha2562(encoded));
|
|
355
|
+
const cancellableClient = new S3Client(this.config, options.signal);
|
|
356
|
+
try {
|
|
357
|
+
await cancellableClient.putBuffer(stagedKey, encoded, "application/gzip", {
|
|
358
|
+
ifAbsent: true
|
|
359
|
+
});
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (!(error instanceof S3ObjectAlreadyExistsError)) throw error;
|
|
362
|
+
const existing = await cancellableClient.getBuffer(stagedKey);
|
|
363
|
+
if (!existing.value || sha2562(existing.value) !== pointer.sha256) {
|
|
364
|
+
throw new SyncBackendConflictError(
|
|
365
|
+
`Immutable snapshot id already exists with different content: ${snapshot.id}`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const currentObject = await cancellableClient.getJson(latestKey(this.config));
|
|
370
|
+
throwIfAborted(options.signal);
|
|
371
|
+
let current;
|
|
372
|
+
if (!currentObject.missing) {
|
|
373
|
+
const currentPointer = requirePointer(
|
|
374
|
+
currentObject.value,
|
|
375
|
+
"Remote latest pointer is malformed.",
|
|
376
|
+
this.config.destination.namespace
|
|
377
|
+
);
|
|
378
|
+
this.registerChecksum(currentPointer.snapshot, currentPointer.sha256);
|
|
379
|
+
current = remoteHead(currentPointer, this.identity, currentObject.etag);
|
|
380
|
+
}
|
|
381
|
+
if (!matchesExpected(current, expected)) {
|
|
382
|
+
throw new SyncBackendConflictError(
|
|
383
|
+
"Remote changed while pushing. Run /sync pull first, then retry.",
|
|
384
|
+
{ currentHead: current }
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
throwIfAborted(options.signal);
|
|
388
|
+
options.onCommit?.();
|
|
389
|
+
const commitClient = new S3Client(this.config, AbortSignal.timeout(this.postCommitTimeoutMs));
|
|
390
|
+
try {
|
|
391
|
+
await commitClient.putJson(latestKey(this.config), pointer);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
throw new SyncBackendPublicationOutcomeUnknownError(
|
|
394
|
+
`Remote publication outcome is unknown: ${errorMessage2(error)}`,
|
|
395
|
+
{ cause: error }
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
let verifiedObject;
|
|
399
|
+
try {
|
|
400
|
+
verifiedObject = await commitClient.getJson(latestKey(this.config));
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw new SyncBackendPublicationOutcomeUnknownError(
|
|
403
|
+
`Remote snapshot may be active, but publication could not be verified: ${errorMessage2(error)}`,
|
|
404
|
+
{ cause: error }
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
let verifiedPointer;
|
|
408
|
+
try {
|
|
409
|
+
verifiedPointer = requirePointer(
|
|
410
|
+
verifiedObject.value,
|
|
411
|
+
"Remote latest pointer is malformed after publication.",
|
|
412
|
+
this.config.destination.namespace
|
|
413
|
+
);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
throw new SyncBackendPublicationOutcomeUnknownError(
|
|
416
|
+
`Remote snapshot may be active, but publication verification was malformed: ${errorMessage2(error)}`,
|
|
417
|
+
{ cause: error }
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
if (!samePointer(verifiedPointer, pointer)) {
|
|
421
|
+
throw new SyncBackendConflictError(
|
|
422
|
+
"Remote latest changed immediately after push. Run /sync status before continuing.",
|
|
423
|
+
{
|
|
424
|
+
phase: "after-commit",
|
|
425
|
+
currentHead: remoteHead(verifiedPointer, this.identity, verifiedObject.etag),
|
|
426
|
+
candidateMayHaveBeenActive: true
|
|
427
|
+
}
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
this.registerChecksum(verifiedPointer.snapshot, verifiedPointer.sha256);
|
|
431
|
+
const head = remoteHead(verifiedPointer, this.identity, verifiedObject.etag);
|
|
432
|
+
const warning = await this.updateHistorySafely(commitClient, pointer);
|
|
433
|
+
return { head, warnings: warning ? [warning] : [] };
|
|
434
|
+
}
|
|
435
|
+
async listHistory(signal) {
|
|
436
|
+
const object = await new S3Client(this.config, signal).getJson(historyKey(this.config));
|
|
437
|
+
throwIfAborted(signal);
|
|
438
|
+
if (object.missing) return [];
|
|
439
|
+
const pointers = requireHistory(object.value, this.config.destination.namespace);
|
|
440
|
+
for (const pointer of pointers) {
|
|
441
|
+
const known = this.checksums.get(pointer.snapshot);
|
|
442
|
+
if (known && known !== pointer.sha256) {
|
|
443
|
+
throw new Error("Remote history conflicts with an already observed snapshot checksum.");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
for (const pointer of pointers) this.registerChecksum(pointer.snapshot, pointer.sha256);
|
|
447
|
+
return pointers.map(remoteHistoryEntry);
|
|
448
|
+
}
|
|
449
|
+
async diagnose(signal) {
|
|
450
|
+
throwIfAborted(signal);
|
|
451
|
+
return [
|
|
452
|
+
{
|
|
453
|
+
key: "s3-config",
|
|
454
|
+
level: "info",
|
|
455
|
+
message: `s3 config: ok (${this.config.destination.bucket}/${storageRoot(this.config)})`
|
|
456
|
+
},
|
|
457
|
+
...sessionTokenWarnings(this.config.profile).map((message) => ({
|
|
458
|
+
key: "s3-session-token",
|
|
459
|
+
level: "warning",
|
|
460
|
+
message
|
|
461
|
+
}))
|
|
462
|
+
];
|
|
463
|
+
}
|
|
464
|
+
async resolveChecksum(reference, signal) {
|
|
465
|
+
await this.readHead(signal);
|
|
466
|
+
const headChecksum = this.checksums.get(reference);
|
|
467
|
+
if (headChecksum) return headChecksum;
|
|
468
|
+
await this.listHistory(signal);
|
|
469
|
+
return this.checksums.get(reference);
|
|
470
|
+
}
|
|
471
|
+
registerChecksum(reference, checksum) {
|
|
472
|
+
const known = this.checksums.get(reference);
|
|
473
|
+
if (known && known !== checksum) {
|
|
474
|
+
throw new Error("Remote snapshot reference was rebound to a different checksum.");
|
|
475
|
+
}
|
|
476
|
+
this.checksums.set(reference, checksum);
|
|
477
|
+
}
|
|
478
|
+
async updateHistorySafely(client, pointer) {
|
|
479
|
+
try {
|
|
480
|
+
await this.updateHistory(client, pointer);
|
|
481
|
+
return void 0;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
return `Remote snapshot is active, but history could not be updated: ${errorMessage2(error)}. Run /sync doctor before relying on history.`;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async updateHistory(client, pointer) {
|
|
487
|
+
const object = await client.getJson(
|
|
488
|
+
historyKey(this.config)
|
|
489
|
+
);
|
|
490
|
+
const snapshots = object.missing ? [] : requireHistory(object.value, this.config.destination.namespace);
|
|
491
|
+
const next = [
|
|
492
|
+
...snapshots.filter((snapshot) => snapshot.snapshot !== pointer.snapshot),
|
|
493
|
+
pointer
|
|
494
|
+
].slice(-100);
|
|
495
|
+
await client.putJson(historyKey(this.config), { version: VERSION, snapshots: next });
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
function latestKey(config) {
|
|
499
|
+
return posixJoin(storageRoot(config), "latest.json");
|
|
500
|
+
}
|
|
501
|
+
function historyKey(config) {
|
|
502
|
+
return posixJoin(storageRoot(config), "history.json");
|
|
503
|
+
}
|
|
504
|
+
function snapshotKey(config, id) {
|
|
505
|
+
requireSnapshotReference(id);
|
|
506
|
+
return posixJoin(storageRoot(config), "snapshots", `${id}.json.gz`);
|
|
507
|
+
}
|
|
508
|
+
function storageRoot(config) {
|
|
509
|
+
return config.destination.prefix;
|
|
510
|
+
}
|
|
511
|
+
function pointerFor(config, snapshot, checksum) {
|
|
512
|
+
return {
|
|
513
|
+
version: VERSION,
|
|
514
|
+
profile: config.destination.namespace,
|
|
515
|
+
snapshot: snapshot.id,
|
|
516
|
+
sha256: checksum,
|
|
517
|
+
createdAt: snapshot.createdAt,
|
|
518
|
+
machine: snapshot.machine,
|
|
519
|
+
syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
|
|
520
|
+
...snapshot.selection === void 0 ? {} : { selection: snapshot.selection }
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
function s3BackendIdentity(config) {
|
|
524
|
+
const destination = JSON.stringify([
|
|
525
|
+
secretFreeEndpoint(config.profile.endpoint),
|
|
526
|
+
trimSlashes(config.destination.bucket),
|
|
527
|
+
trimSlashes(config.destination.prefix)
|
|
528
|
+
]);
|
|
529
|
+
return `s3:${sha2562(Buffer.from(destination))}`;
|
|
530
|
+
}
|
|
531
|
+
function s3Destination(config) {
|
|
532
|
+
let host = secretFreeEndpoint(config.profile.endpoint);
|
|
533
|
+
try {
|
|
534
|
+
host = new URL(host).hostname;
|
|
535
|
+
} catch {
|
|
536
|
+
host = "invalid S3 endpoint";
|
|
537
|
+
}
|
|
538
|
+
return `${host} \xB7 ${config.destination.bucket}/${storageRoot(config)}`;
|
|
539
|
+
}
|
|
540
|
+
function secretFreeEndpoint(value) {
|
|
541
|
+
const normalized = value.trim();
|
|
542
|
+
try {
|
|
543
|
+
const url = new URL(normalized);
|
|
544
|
+
url.username = "";
|
|
545
|
+
url.password = "";
|
|
546
|
+
return url.toString();
|
|
547
|
+
} catch {
|
|
548
|
+
return normalized.replace(/\/\/[^/@\s]+@/u, "//");
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function trimSlashes(value) {
|
|
552
|
+
return value.replace(/^\/+|\/+$/g, "");
|
|
553
|
+
}
|
|
554
|
+
function remoteHistoryEntry(pointer) {
|
|
555
|
+
return {
|
|
556
|
+
snapshotRef: pointer.snapshot,
|
|
557
|
+
snapshotId: pointer.snapshot,
|
|
558
|
+
createdAt: pointer.createdAt,
|
|
559
|
+
machine: pointer.machine,
|
|
560
|
+
syncSessions: pointer.syncSessions === true
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function remoteHead(pointer, identity, etag) {
|
|
564
|
+
return {
|
|
565
|
+
snapshotRef: pointer.snapshot,
|
|
566
|
+
snapshotId: pointer.snapshot,
|
|
567
|
+
revision: `s3:${sha2562(Buffer.from(canonicalJson([identity, etag ?? null, pointer])))}`,
|
|
568
|
+
createdAt: pointer.createdAt,
|
|
569
|
+
machine: pointer.machine,
|
|
570
|
+
syncSessions: pointer.syncSessions === true,
|
|
571
|
+
...pointer.selection === void 0 ? {} : { selection: pointer.selection }
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
function samePointer(left, right) {
|
|
575
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
576
|
+
}
|
|
577
|
+
function canonicalJson(value) {
|
|
578
|
+
return JSON.stringify(canonicalValue(value));
|
|
579
|
+
}
|
|
580
|
+
function canonicalValue(value) {
|
|
581
|
+
if (Array.isArray(value)) return value.map(canonicalValue);
|
|
582
|
+
if (!value || typeof value !== "object") return value;
|
|
583
|
+
return Object.fromEntries(
|
|
584
|
+
Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])])
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
function matchesExpected(head, expected) {
|
|
588
|
+
if (expected.kind === "missing") return head === void 0;
|
|
589
|
+
return head?.revision === expected.revision;
|
|
590
|
+
}
|
|
591
|
+
function requirePointer(value, message, expectedProfile) {
|
|
592
|
+
if (!value || value.version !== VERSION || value.profile !== expectedProfile || typeof value.snapshot !== "string" || !isSafeSnapshotReference(value.snapshot) || typeof value.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(value.sha256) || typeof value.createdAt !== "string" || !isSafeMetadata(value.createdAt, 64) || typeof value.machine !== "string" || !isSafeMetadata(value.machine, 256) || value.syncSessions !== void 0 && typeof value.syncSessions !== "boolean") {
|
|
593
|
+
throw new Error(message);
|
|
594
|
+
}
|
|
595
|
+
if (value.selection !== void 0) portableSnapshotSelection(value.selection);
|
|
596
|
+
return value;
|
|
597
|
+
}
|
|
598
|
+
function requireHistory(value, expectedProfile) {
|
|
599
|
+
if (!value || value.version !== VERSION || !Array.isArray(value.snapshots)) {
|
|
600
|
+
throw new Error("Remote history is malformed.");
|
|
601
|
+
}
|
|
602
|
+
const pointers = value.snapshots.map(
|
|
603
|
+
(pointer) => requirePointer(pointer, "Remote history entry is malformed.", expectedProfile)
|
|
604
|
+
);
|
|
605
|
+
const references = /* @__PURE__ */ new Set();
|
|
606
|
+
for (const pointer of pointers) {
|
|
607
|
+
if (references.has(pointer.snapshot)) {
|
|
608
|
+
throw new Error("Remote history contains duplicate snapshot references.");
|
|
609
|
+
}
|
|
610
|
+
references.add(pointer.snapshot);
|
|
611
|
+
}
|
|
612
|
+
return pointers;
|
|
613
|
+
}
|
|
614
|
+
function assertSnapshotIdentity(snapshot, expectedProfile) {
|
|
615
|
+
if (snapshot.version !== VERSION || snapshot.profile !== expectedProfile || !isSafeSnapshotReference(snapshot.id) || !isSafeMetadata(snapshot.createdAt, 64) || !isSafeMetadata(snapshot.machine, 256) || !Array.isArray(snapshot.files)) {
|
|
616
|
+
throw new Error("Invalid snapshot identity for S3 publication.");
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function validateSnapshotBundle(snapshot) {
|
|
620
|
+
const paths = /* @__PURE__ */ new Set();
|
|
621
|
+
for (const file of snapshot.files) {
|
|
622
|
+
if (!file || typeof file.path !== "string" || typeof file.contentBase64 !== "string" || typeof file.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(file.sha256) || paths.has(file.path)) {
|
|
623
|
+
throw new Error("Remote snapshot bundle is malformed.");
|
|
624
|
+
}
|
|
625
|
+
const content = Buffer.from(file.contentBase64, "base64");
|
|
626
|
+
if (content.toString("base64") !== file.contentBase64 || sha2562(content) !== file.sha256) {
|
|
627
|
+
throw new Error("Remote snapshot file checksum mismatch.");
|
|
628
|
+
}
|
|
629
|
+
paths.add(file.path);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function assertSafeDestination(config) {
|
|
633
|
+
for (const [label, value, allowEmpty] of [
|
|
634
|
+
["bucket", config.destination.bucket, false],
|
|
635
|
+
["prefix", config.destination.prefix, true],
|
|
636
|
+
["namespace", config.destination.namespace, false]
|
|
637
|
+
]) {
|
|
638
|
+
if (!allowEmpty && value.length === 0 || value.includes("\\") || hasControlCharacter(value) || value.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
639
|
+
throw new Error(`Invalid S3 storage location ${label}.`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function requireSnapshotReference(reference) {
|
|
644
|
+
if (!isSafeSnapshotReference(reference)) {
|
|
645
|
+
throw new Error("Invalid S3 snapshot reference.");
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function isSafeSnapshotReference(reference) {
|
|
649
|
+
return reference.length > 0 && reference.length <= 512 && reference !== "." && reference !== ".." && !reference.includes("/") && !reference.includes("\\") && !hasControlCharacter(reference);
|
|
650
|
+
}
|
|
651
|
+
function isSafeMetadata(value, maxLength) {
|
|
652
|
+
return value.length > 0 && value.length <= maxLength && !hasControlCharacter(value);
|
|
653
|
+
}
|
|
654
|
+
function hasControlCharacter(value) {
|
|
655
|
+
return [...value].some((character) => {
|
|
656
|
+
const code = character.codePointAt(0) ?? 0;
|
|
657
|
+
return code < 32 || code === 127;
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
function throwIfAborted(signal) {
|
|
661
|
+
if (!signal?.aborted) return;
|
|
662
|
+
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
663
|
+
}
|
|
664
|
+
function sha2562(value) {
|
|
665
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
666
|
+
}
|
|
667
|
+
function errorMessage2(error) {
|
|
668
|
+
return error instanceof Error ? error.message : String(error);
|
|
669
|
+
}
|
|
670
|
+
export {
|
|
671
|
+
S3SyncBackend,
|
|
672
|
+
encodeKey,
|
|
673
|
+
historyKey,
|
|
674
|
+
latestKey,
|
|
675
|
+
pointerFor,
|
|
676
|
+
s3BackendIdentity,
|
|
677
|
+
snapshotKey,
|
|
678
|
+
storageRoot
|
|
679
|
+
};
|
|
680
|
+
//# sourceMappingURL=s3-backend-3QZGO2EF.ts.map
|