@narumitw/pi-sync 0.49.9 → 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
- package/src/state-directory.ts +55 -1
|
@@ -0,0 +1,869 @@
|
|
|
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
|
+
decodeSnapshot,
|
|
5
|
+
encodeSnapshot
|
|
6
|
+
} from "./chunk-HGV24P4T.js";
|
|
7
|
+
import {
|
|
8
|
+
SyncBackendConflictError,
|
|
9
|
+
SyncBackendPublicationOutcomeUnknownError
|
|
10
|
+
} from "./chunk-5MQ76BK5.js";
|
|
11
|
+
import {
|
|
12
|
+
portableSnapshotSelection
|
|
13
|
+
} from "./chunk-YQ6UW7IF.js";
|
|
14
|
+
|
|
15
|
+
// src/webdav-backend.ts
|
|
16
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
// src/webdav-client.ts
|
|
19
|
+
import { XMLParser } from "fast-xml-parser";
|
|
20
|
+
var JSON_LIMIT = 1024 * 1024;
|
|
21
|
+
var ERROR_LIMIT = 64 * 1024;
|
|
22
|
+
var SNAPSHOT_LIMIT = 256 * 1024 * 1024;
|
|
23
|
+
var XML_LIMIT = 1024 * 1024;
|
|
24
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
25
|
+
var MAX_REDIRECTS = 3;
|
|
26
|
+
var WebDavHttpError = class extends Error {
|
|
27
|
+
constructor(message, status, options) {
|
|
28
|
+
super(message, options);
|
|
29
|
+
this.status = status;
|
|
30
|
+
this.name = "WebDavHttpError";
|
|
31
|
+
}
|
|
32
|
+
status;
|
|
33
|
+
};
|
|
34
|
+
var WebDavPreconditionError = class extends WebDavHttpError {
|
|
35
|
+
constructor(message = "WebDAV precondition failed.") {
|
|
36
|
+
super(message, 412);
|
|
37
|
+
this.name = "WebDavPreconditionError";
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var WebDavClient = class {
|
|
41
|
+
constructor(config, signal, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
42
|
+
this.config = config;
|
|
43
|
+
this.signal = signal;
|
|
44
|
+
this.timeoutMs = timeoutMs;
|
|
45
|
+
this.baseUrl = new URL(config.profile.url);
|
|
46
|
+
assertSafeAuthenticatedUrl(this.baseUrl);
|
|
47
|
+
if (!config.profile.username || !config.profile.password || config.profile.username.includes(":") || hasControlCharacter(config.profile.username) || hasControlCharacter(config.profile.password)) {
|
|
48
|
+
throw new Error("Invalid WebDAV credentials.");
|
|
49
|
+
}
|
|
50
|
+
this.authorization = `Basic ${Buffer.from(`${config.profile.username}:${config.profile.password}`).toString("base64")}`;
|
|
51
|
+
}
|
|
52
|
+
config;
|
|
53
|
+
signal;
|
|
54
|
+
timeoutMs;
|
|
55
|
+
baseUrl;
|
|
56
|
+
authorization;
|
|
57
|
+
async getBuffer(remotePath) {
|
|
58
|
+
const response = await this.request(remotePath, { method: "GET" });
|
|
59
|
+
if (response.status === 404) {
|
|
60
|
+
await response.body?.cancel();
|
|
61
|
+
return { missing: true };
|
|
62
|
+
}
|
|
63
|
+
await this.requireOk(response, "read");
|
|
64
|
+
return {
|
|
65
|
+
value: await readBounded(response, SNAPSHOT_LIMIT, "WebDAV response is too large"),
|
|
66
|
+
etag: response.headers.get("etag") ?? void 0,
|
|
67
|
+
missing: false
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async getJson(remotePath) {
|
|
71
|
+
const response = await this.request(remotePath, { method: "GET" });
|
|
72
|
+
if (response.status === 404) {
|
|
73
|
+
await response.body?.cancel();
|
|
74
|
+
return { missing: true };
|
|
75
|
+
}
|
|
76
|
+
await this.requireOk(response, "read");
|
|
77
|
+
const bytes = await readBounded(response, JSON_LIMIT, "WebDAV JSON response is too large");
|
|
78
|
+
try {
|
|
79
|
+
return {
|
|
80
|
+
value: JSON.parse(bytes.toString("utf8")),
|
|
81
|
+
etag: response.headers.get("etag") ?? void 0,
|
|
82
|
+
missing: false
|
|
83
|
+
};
|
|
84
|
+
} catch (error) {
|
|
85
|
+
throw new Error("WebDAV JSON response is malformed.", { cause: error });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async putBuffer(remotePath, body, contentType, options = {}) {
|
|
89
|
+
const headers = { "content-type": contentType };
|
|
90
|
+
if (options.ifAbsent) headers["if-none-match"] = "*";
|
|
91
|
+
if (options.ifMatch) headers["if-match"] = options.ifMatch;
|
|
92
|
+
const response = await this.request(remotePath, {
|
|
93
|
+
method: "PUT",
|
|
94
|
+
headers,
|
|
95
|
+
body
|
|
96
|
+
});
|
|
97
|
+
if (response.status === 412) {
|
|
98
|
+
await response.body?.cancel();
|
|
99
|
+
throw new WebDavPreconditionError();
|
|
100
|
+
}
|
|
101
|
+
await this.requireOk(response, "write");
|
|
102
|
+
await response.body?.cancel();
|
|
103
|
+
return response.headers.get("etag") ?? void 0;
|
|
104
|
+
}
|
|
105
|
+
async putJson(remotePath, value, options = {}) {
|
|
106
|
+
return this.putBuffer(
|
|
107
|
+
remotePath,
|
|
108
|
+
Buffer.from(JSON.stringify(value)),
|
|
109
|
+
"application/json",
|
|
110
|
+
options
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
async makeCollection(remotePath) {
|
|
114
|
+
const response = await this.request(remotePath, { method: "MKCOL" });
|
|
115
|
+
if (response.status === 405) {
|
|
116
|
+
await response.body?.cancel();
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
await this.requireOk(response, "create collection");
|
|
120
|
+
await response.body?.cancel();
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
async ensureCollection(remotePath) {
|
|
124
|
+
const segments = safeSegments(remotePath);
|
|
125
|
+
for (let index = 1; index <= segments.length; index += 1) {
|
|
126
|
+
await this.makeCollection(segments.slice(0, index).join("/"));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async listCollection(remotePath) {
|
|
130
|
+
const response = await this.request(remotePath, {
|
|
131
|
+
method: "PROPFIND",
|
|
132
|
+
headers: { depth: "1", "content-type": "application/xml; charset=utf-8" },
|
|
133
|
+
body: '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>'
|
|
134
|
+
});
|
|
135
|
+
if (response.status === 404) {
|
|
136
|
+
await response.body?.cancel();
|
|
137
|
+
throw new WebDavHttpError("WebDAV collection is missing.", 404);
|
|
138
|
+
}
|
|
139
|
+
if (response.status !== 207) await this.requireOk(response, "list collection");
|
|
140
|
+
const xml = (await readBounded(response, XML_LIMIT, "WebDAV directory response is too large")).toString("utf8");
|
|
141
|
+
try {
|
|
142
|
+
const parsed = new XMLParser({
|
|
143
|
+
removeNSPrefix: true,
|
|
144
|
+
ignoreAttributes: false,
|
|
145
|
+
processEntities: false
|
|
146
|
+
}).parse(xml);
|
|
147
|
+
if (!parsed.multistatus || parsed.multistatus.response === void 0) {
|
|
148
|
+
throw new Error("Missing DAV multistatus response.");
|
|
149
|
+
}
|
|
150
|
+
const responses = asArray(parsed.multistatus.response);
|
|
151
|
+
return responses.map(parseEntry);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw new Error("WebDAV directory response is malformed.", { cause: error });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async delete(remotePath) {
|
|
157
|
+
const response = await this.request(remotePath, { method: "DELETE" });
|
|
158
|
+
if (response.status === 404) {
|
|
159
|
+
await response.body?.cancel();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (response.status === 207) {
|
|
163
|
+
await response.body?.cancel();
|
|
164
|
+
throw new WebDavHttpError("WebDAV probe cleanup returned a partial response.", 207);
|
|
165
|
+
}
|
|
166
|
+
await this.requireOk(response, "delete probe resource");
|
|
167
|
+
await response.body?.cancel();
|
|
168
|
+
}
|
|
169
|
+
async request(remotePath, init) {
|
|
170
|
+
throwIfAborted(this.signal);
|
|
171
|
+
let url = appendRemotePath(this.baseUrl, remotePath);
|
|
172
|
+
for (let redirects = 0; ; redirects += 1) {
|
|
173
|
+
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
174
|
+
const signal = this.signal ? AbortSignal.any([this.signal, timeout]) : timeout;
|
|
175
|
+
let response;
|
|
176
|
+
try {
|
|
177
|
+
response = await fetch(url, {
|
|
178
|
+
...init,
|
|
179
|
+
headers: {
|
|
180
|
+
accept: "*/*",
|
|
181
|
+
authorization: this.authorization,
|
|
182
|
+
...headersObject(init.headers)
|
|
183
|
+
},
|
|
184
|
+
redirect: "manual",
|
|
185
|
+
signal
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
throwIfAborted(this.signal);
|
|
189
|
+
throw new Error(
|
|
190
|
+
`WebDAV ${String(init.method ?? "GET")} request failed for ${redactText(safeUrl(url), this.config)}.`,
|
|
191
|
+
{
|
|
192
|
+
cause: redactError(error, this.config)
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) return response;
|
|
197
|
+
await response.body?.cancel();
|
|
198
|
+
const method = String(init.method ?? "GET").toUpperCase();
|
|
199
|
+
if ([301, 302, 303].includes(response.status) && method !== "GET" && method !== "HEAD") {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`WebDAV refused an ambiguous HTTP ${response.status} redirect for ${method}; configure the canonical collection URL or require HTTP 307/308.`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (redirects >= MAX_REDIRECTS) throw new Error("WebDAV redirect limit exceeded.");
|
|
205
|
+
const location = response.headers.get("location");
|
|
206
|
+
if (!location) throw new Error("WebDAV redirect is missing Location.");
|
|
207
|
+
const next = new URL(location, url);
|
|
208
|
+
if (next.origin !== this.baseUrl.origin) {
|
|
209
|
+
throw new Error("WebDAV refused a cross-origin authenticated redirect.");
|
|
210
|
+
}
|
|
211
|
+
url = next;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async requireOk(response, action) {
|
|
215
|
+
if (response.ok) return;
|
|
216
|
+
const body = (await readBounded(response, ERROR_LIMIT, "WebDAV error body is too large")).toString("utf8").replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ").trim();
|
|
217
|
+
const detail = body ? `: ${redactText(body, this.config)}` : "";
|
|
218
|
+
throw new WebDavHttpError(
|
|
219
|
+
`WebDAV ${action} failed with HTTP ${response.status}${detail}`,
|
|
220
|
+
response.status
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
function assertSafeAuthenticatedUrl(url) {
|
|
225
|
+
const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
226
|
+
if (url.username || url.password || url.search || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"Invalid WebDAV URL: HTTPS is required and embedded credentials, query, or fragment are not allowed."
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function hasControlCharacter(value) {
|
|
233
|
+
return [...value].some((character) => {
|
|
234
|
+
const code = character.codePointAt(0) ?? 0;
|
|
235
|
+
return code < 32 || code >= 127 && code <= 159;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function appendRemotePath(base, remotePath) {
|
|
239
|
+
const url = new URL(base);
|
|
240
|
+
const suffix = safeSegments(remotePath).map(encodeURIComponent).join("/");
|
|
241
|
+
url.pathname = `${base.pathname.replace(/\/+$/u, "")}/${suffix}`;
|
|
242
|
+
return url;
|
|
243
|
+
}
|
|
244
|
+
function safeSegments(value) {
|
|
245
|
+
const segments = value.replace(/^\/+|\/+$/gu, "").split("/").filter(Boolean);
|
|
246
|
+
if (segments.some(
|
|
247
|
+
(segment) => segment === "." || segment === ".." || // biome-ignore lint/suspicious/noControlCharactersInRegex: Remote path segments cannot contain controls.
|
|
248
|
+
/[\u0000-\u001f]/u.test(segment)
|
|
249
|
+
)) {
|
|
250
|
+
throw new Error("Invalid WebDAV remote path.");
|
|
251
|
+
}
|
|
252
|
+
return segments;
|
|
253
|
+
}
|
|
254
|
+
async function readBounded(response, limit, message) {
|
|
255
|
+
const length = Number(response.headers.get("content-length"));
|
|
256
|
+
if (Number.isFinite(length) && length > limit) {
|
|
257
|
+
await response.body?.cancel();
|
|
258
|
+
throw new Error(message);
|
|
259
|
+
}
|
|
260
|
+
if (!response.body) return Buffer.alloc(0);
|
|
261
|
+
const reader = response.body.getReader();
|
|
262
|
+
const chunks = [];
|
|
263
|
+
let total = 0;
|
|
264
|
+
try {
|
|
265
|
+
while (true) {
|
|
266
|
+
const { done, value } = await reader.read();
|
|
267
|
+
if (done) break;
|
|
268
|
+
total += value.byteLength;
|
|
269
|
+
if (total > limit) throw new Error(message);
|
|
270
|
+
chunks.push(Buffer.from(value));
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
await reader.cancel().catch(() => void 0);
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
return Buffer.concat(chunks, total);
|
|
277
|
+
}
|
|
278
|
+
function parseEntry(value) {
|
|
279
|
+
if (!value || typeof value !== "object") throw new Error("Invalid WebDAV response entry.");
|
|
280
|
+
const record = value;
|
|
281
|
+
const href = typeof record.href === "string" ? record.href : void 0;
|
|
282
|
+
if (!href) throw new Error("Invalid WebDAV response href.");
|
|
283
|
+
const propstat = asArray(record.propstat).find((item) => {
|
|
284
|
+
if (!item || typeof item !== "object") return false;
|
|
285
|
+
return String(item.status ?? "").includes(" 200 ");
|
|
286
|
+
});
|
|
287
|
+
const prop = propstat?.prop;
|
|
288
|
+
return {
|
|
289
|
+
href,
|
|
290
|
+
etag: typeof prop?.getetag === "string" ? prop.getetag : void 0,
|
|
291
|
+
collection: !!prop?.resourcetype && typeof prop.resourcetype === "object" && Object.hasOwn(prop.resourcetype, "collection")
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function asArray(value) {
|
|
295
|
+
if (value === void 0) return [];
|
|
296
|
+
return Array.isArray(value) ? value : [value];
|
|
297
|
+
}
|
|
298
|
+
function headersObject(headers) {
|
|
299
|
+
return Object.fromEntries(new Headers(headers).entries());
|
|
300
|
+
}
|
|
301
|
+
function safeUrl(url) {
|
|
302
|
+
return `${url.protocol}//${url.host}${url.pathname}`;
|
|
303
|
+
}
|
|
304
|
+
function redactError(error, config) {
|
|
305
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
306
|
+
return new Error(redactText(message, config));
|
|
307
|
+
}
|
|
308
|
+
function redactText(value, config) {
|
|
309
|
+
let result = value;
|
|
310
|
+
for (const secret of [config.profile.username, config.profile.password, config.profile.url]) {
|
|
311
|
+
if (!secret) continue;
|
|
312
|
+
for (const variant of /* @__PURE__ */ new Set([secret, encodeURIComponent(secret)])) {
|
|
313
|
+
result = result.replace(new RegExp(escapeRegExp(variant), "giu"), "[redacted]");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return result.replace(/basic\s+[a-z0-9+/=]+/giu, "Basic [redacted]").replace(/\?[^\s]*/gu, "?[redacted]");
|
|
317
|
+
}
|
|
318
|
+
function escapeRegExp(value) {
|
|
319
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
320
|
+
}
|
|
321
|
+
function throwIfAborted(signal) {
|
|
322
|
+
if (!signal?.aborted) return;
|
|
323
|
+
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/webdav-backend.ts
|
|
327
|
+
var VERSION = 1;
|
|
328
|
+
var POST_COMMIT_TIMEOUT_MS = 3e4;
|
|
329
|
+
var WebDavSyncBackend = class {
|
|
330
|
+
constructor(config, postCommitTimeoutMs = POST_COMMIT_TIMEOUT_MS) {
|
|
331
|
+
this.config = config;
|
|
332
|
+
this.postCommitTimeoutMs = postCommitTimeoutMs;
|
|
333
|
+
assertSafeDestination(config);
|
|
334
|
+
this.identity = webDavBackendIdentity(config);
|
|
335
|
+
this.destination = webDavStorageLocation(config);
|
|
336
|
+
}
|
|
337
|
+
config;
|
|
338
|
+
postCommitTimeoutMs;
|
|
339
|
+
identity;
|
|
340
|
+
destination;
|
|
341
|
+
conditionalVerified = false;
|
|
342
|
+
checksums = /* @__PURE__ */ new Map();
|
|
343
|
+
capabilityCheck;
|
|
344
|
+
get capability() {
|
|
345
|
+
return this.conditionalVerified ? "atomic-conditional" : "conditional-required";
|
|
346
|
+
}
|
|
347
|
+
sameRevision(left, right) {
|
|
348
|
+
return left === right;
|
|
349
|
+
}
|
|
350
|
+
async readHead(signal) {
|
|
351
|
+
const object = await new WebDavClient(this.config, signal).getJson(
|
|
352
|
+
latestPath(this.config)
|
|
353
|
+
);
|
|
354
|
+
throwIfAborted2(signal);
|
|
355
|
+
if (object.missing) return void 0;
|
|
356
|
+
const pointer = requirePointer(object.value, this.config.destination.namespace);
|
|
357
|
+
this.registerChecksum(pointer.snapshot, pointer.sha256);
|
|
358
|
+
return remoteHead(pointer, this.identity, object.etag);
|
|
359
|
+
}
|
|
360
|
+
async readSnapshot(reference, signal) {
|
|
361
|
+
requireSnapshotReference(reference);
|
|
362
|
+
const expectedChecksum = this.checksums.get(reference) ?? await this.resolveChecksum(reference, signal);
|
|
363
|
+
const object = await new WebDavClient(this.config, signal).getBuffer(
|
|
364
|
+
snapshotPath(this.config, reference)
|
|
365
|
+
);
|
|
366
|
+
throwIfAborted2(signal);
|
|
367
|
+
if (!object.value) throw new Error(`Snapshot not found: ${reference}`);
|
|
368
|
+
if (!expectedChecksum || sha256(object.value) !== expectedChecksum) {
|
|
369
|
+
throw new Error("Remote snapshot checksum mismatch.");
|
|
370
|
+
}
|
|
371
|
+
const snapshot = await decodeSnapshot(object.value, { signal });
|
|
372
|
+
if (snapshot.id !== reference || snapshot.profile !== this.config.destination.namespace) {
|
|
373
|
+
throw new Error("Remote snapshot identity mismatch.");
|
|
374
|
+
}
|
|
375
|
+
validateSnapshotBundle(snapshot);
|
|
376
|
+
return snapshot;
|
|
377
|
+
}
|
|
378
|
+
async publishSnapshot(snapshot, expected, options = {}) {
|
|
379
|
+
throwIfAborted2(options.signal);
|
|
380
|
+
assertSnapshotIdentity(snapshot, this.config.destination.namespace);
|
|
381
|
+
await this.ensureAtomicConditions(options.signal);
|
|
382
|
+
throwIfAborted2(options.signal);
|
|
383
|
+
const client = new WebDavClient(this.config, options.signal);
|
|
384
|
+
await client.ensureCollection(snapshotsPath(this.config));
|
|
385
|
+
const encoded = await encodeSnapshot(snapshot);
|
|
386
|
+
throwIfAborted2(options.signal);
|
|
387
|
+
const pointer = pointerFor(this.config, snapshot, sha256(encoded));
|
|
388
|
+
const stagedPath = snapshotPath(this.config, snapshot.id);
|
|
389
|
+
try {
|
|
390
|
+
await client.putBuffer(stagedPath, encoded, "application/gzip", { ifAbsent: true });
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (!(error instanceof WebDavPreconditionError)) throw error;
|
|
393
|
+
const existing = await client.getBuffer(stagedPath);
|
|
394
|
+
if (!existing.value || sha256(existing.value) !== pointer.sha256) {
|
|
395
|
+
throw new SyncBackendConflictError(
|
|
396
|
+
`Immutable snapshot id already exists with different content: ${snapshot.id}`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const currentObject = await client.getJson(latestPath(this.config));
|
|
401
|
+
throwIfAborted2(options.signal);
|
|
402
|
+
const current = currentObject.missing ? void 0 : remoteHead(
|
|
403
|
+
requirePointer(currentObject.value, this.config.destination.namespace),
|
|
404
|
+
this.identity,
|
|
405
|
+
currentObject.etag
|
|
406
|
+
);
|
|
407
|
+
if (!matchesExpected(current, expected)) {
|
|
408
|
+
throw new SyncBackendConflictError(
|
|
409
|
+
"Remote changed while pushing. Run /sync pull first, then retry.",
|
|
410
|
+
{
|
|
411
|
+
currentHead: current
|
|
412
|
+
}
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const condition = publicationCondition(currentObject, expected, this.identity);
|
|
416
|
+
throwIfAborted2(options.signal);
|
|
417
|
+
options.onCommit?.();
|
|
418
|
+
const commitClient = new WebDavClient(
|
|
419
|
+
this.config,
|
|
420
|
+
AbortSignal.timeout(this.postCommitTimeoutMs),
|
|
421
|
+
this.postCommitTimeoutMs
|
|
422
|
+
);
|
|
423
|
+
try {
|
|
424
|
+
await commitClient.putJson(latestPath(this.config), pointer, condition);
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (error instanceof WebDavPreconditionError) {
|
|
427
|
+
const latest = await this.readHeadAfterCommit(commitClient).catch(() => void 0);
|
|
428
|
+
throw new SyncBackendConflictError("Remote changed while publishing the WebDAV pointer.", {
|
|
429
|
+
currentHead: latest
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
throw new SyncBackendPublicationOutcomeUnknownError(
|
|
433
|
+
`Remote publication outcome is unknown: ${errorMessage(error)}`,
|
|
434
|
+
{ cause: error }
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
let verifiedObject;
|
|
438
|
+
try {
|
|
439
|
+
verifiedObject = await commitClient.getJson(latestPath(this.config));
|
|
440
|
+
} catch (error) {
|
|
441
|
+
throw new SyncBackendPublicationOutcomeUnknownError(
|
|
442
|
+
`Remote snapshot may be active, but publication could not be verified: ${errorMessage(error)}`,
|
|
443
|
+
{ cause: error }
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
let verified;
|
|
447
|
+
try {
|
|
448
|
+
verified = requirePointer(verifiedObject.value, this.config.destination.namespace);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
throw new SyncBackendPublicationOutcomeUnknownError(
|
|
451
|
+
`Remote snapshot may be active, but publication verification was malformed: ${errorMessage(error)}`,
|
|
452
|
+
{ cause: error }
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
if (!samePointer(pointer, verified)) {
|
|
456
|
+
throw new SyncBackendConflictError(
|
|
457
|
+
"Remote latest changed immediately after push. Run /sync status before continuing.",
|
|
458
|
+
{
|
|
459
|
+
phase: "after-commit",
|
|
460
|
+
currentHead: remoteHead(verified, this.identity, verifiedObject.etag),
|
|
461
|
+
candidateMayHaveBeenActive: true
|
|
462
|
+
}
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (!strongEtag(verifiedObject.etag)) {
|
|
466
|
+
throw new SyncBackendPublicationOutcomeUnknownError(
|
|
467
|
+
"Remote snapshot is active, but the WebDAV server returned no strong ETag."
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
this.registerChecksum(verified.snapshot, verified.sha256);
|
|
471
|
+
const head = remoteHead(verified, this.identity, verifiedObject.etag);
|
|
472
|
+
const warning = await this.updateHistorySafely(commitClient, pointer);
|
|
473
|
+
return { head, warnings: warning ? [warning] : [] };
|
|
474
|
+
}
|
|
475
|
+
async listHistory(signal) {
|
|
476
|
+
const object = await new WebDavClient(this.config, signal).getJson(historyPath(this.config));
|
|
477
|
+
throwIfAborted2(signal);
|
|
478
|
+
if (object.missing) return [];
|
|
479
|
+
const pointers = requireHistory(object.value, this.config.destination.namespace);
|
|
480
|
+
for (const pointer of pointers) this.registerChecksum(pointer.snapshot, pointer.sha256);
|
|
481
|
+
return pointers.map(remoteHistoryEntry);
|
|
482
|
+
}
|
|
483
|
+
async diagnose(signal) {
|
|
484
|
+
const diagnostics = [
|
|
485
|
+
{
|
|
486
|
+
key: "webdav-url",
|
|
487
|
+
level: "info",
|
|
488
|
+
message: `webdav URL/TLS/auth: configured (${webDavStorageLocation(this.config)})`
|
|
489
|
+
}
|
|
490
|
+
];
|
|
491
|
+
try {
|
|
492
|
+
await this.runCapabilityProbe(signal);
|
|
493
|
+
diagnostics.push(
|
|
494
|
+
{ key: "webdav-collection", level: "info", message: "webdav collection read/write: ok" },
|
|
495
|
+
{
|
|
496
|
+
key: "webdav-conditional",
|
|
497
|
+
level: "info",
|
|
498
|
+
message: "webdav conditional publication: atomic-conditional (verified)"
|
|
499
|
+
},
|
|
500
|
+
{ key: "webdav-cleanup", level: "info", message: "webdav probe cleanup: ok" }
|
|
501
|
+
);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
this.capabilityCheck = void 0;
|
|
504
|
+
this.conditionalVerified = false;
|
|
505
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
506
|
+
diagnostics.push({
|
|
507
|
+
key: "webdav-probe",
|
|
508
|
+
level: "error",
|
|
509
|
+
message: `webdav publication is read-only until diagnostics pass: ${errorMessage(error)}`
|
|
510
|
+
});
|
|
511
|
+
return diagnostics;
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
diagnostics.push({
|
|
515
|
+
key: "webdav-history",
|
|
516
|
+
level: "info",
|
|
517
|
+
message: await this.reconcileActiveHistory(signal)
|
|
518
|
+
});
|
|
519
|
+
} catch (error) {
|
|
520
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
521
|
+
diagnostics.push({
|
|
522
|
+
key: "webdav-history",
|
|
523
|
+
level: "error",
|
|
524
|
+
message: `webdav history repair failed: ${errorMessage(error)}`
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
return diagnostics;
|
|
528
|
+
}
|
|
529
|
+
ensureAtomicConditions(signal) {
|
|
530
|
+
if (this.capabilityCheck) return this.capabilityCheck;
|
|
531
|
+
this.conditionalVerified = false;
|
|
532
|
+
const check = this.runCapabilityProbe(signal).finally(() => {
|
|
533
|
+
if (this.capabilityCheck === check) this.capabilityCheck = void 0;
|
|
534
|
+
});
|
|
535
|
+
this.capabilityCheck = check;
|
|
536
|
+
return check;
|
|
537
|
+
}
|
|
538
|
+
async runCapabilityProbe(signal) {
|
|
539
|
+
throwIfAborted2(signal);
|
|
540
|
+
const probeCollection = `${rootPath(this.config)}/.pi-sync-probes/${randomUUID()}`;
|
|
541
|
+
const probe = `${probeCollection}/conditional.txt`;
|
|
542
|
+
const client = new WebDavClient(this.config, signal);
|
|
543
|
+
let created = false;
|
|
544
|
+
let operationError;
|
|
545
|
+
try {
|
|
546
|
+
created = true;
|
|
547
|
+
await client.ensureCollection(probeCollection);
|
|
548
|
+
await client.listCollection(probeCollection);
|
|
549
|
+
await client.putBuffer(probe, Buffer.from("first"), "text/plain", { ifAbsent: true });
|
|
550
|
+
const read = await client.getBuffer(probe);
|
|
551
|
+
const etag = strongEtag(read.etag);
|
|
552
|
+
if (!read.value || !etag) {
|
|
553
|
+
throw new Error("WebDAV server did not return a strong ETag for the capability probe.");
|
|
554
|
+
}
|
|
555
|
+
await expectPrecondition(
|
|
556
|
+
client.putBuffer(probe, Buffer.from("replace"), "text/plain", { ifAbsent: true }),
|
|
557
|
+
"If-None-Match"
|
|
558
|
+
);
|
|
559
|
+
await expectPrecondition(
|
|
560
|
+
client.putBuffer(probe, Buffer.from("replace"), "text/plain", {
|
|
561
|
+
ifMatch: '"pi-sync-deliberately-stale"'
|
|
562
|
+
}),
|
|
563
|
+
"If-Match"
|
|
564
|
+
);
|
|
565
|
+
const unchanged = await client.getBuffer(probe);
|
|
566
|
+
if (!unchanged.value?.equals(Buffer.from("first"))) {
|
|
567
|
+
throw new Error("WebDAV server changed a probe despite a failed precondition.");
|
|
568
|
+
}
|
|
569
|
+
await client.putBuffer(probe, Buffer.from("second"), "text/plain", { ifMatch: etag });
|
|
570
|
+
const changed = await client.getBuffer(probe);
|
|
571
|
+
const changedEtag = strongEtag(changed.etag);
|
|
572
|
+
if (!changed.value?.equals(Buffer.from("second")) || !changedEtag || changedEtag === etag) {
|
|
573
|
+
throw new Error(
|
|
574
|
+
"WebDAV server did not rotate its strong ETag after changing the capability probe."
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
} catch (error) {
|
|
578
|
+
operationError = error;
|
|
579
|
+
}
|
|
580
|
+
if (created) {
|
|
581
|
+
try {
|
|
582
|
+
await new WebDavClient(this.config, void 0).delete(probeCollection);
|
|
583
|
+
} catch (cleanupError) {
|
|
584
|
+
const operationDetail = operationError ? `WebDAV probe failed: ${errorMessage(operationError)}; ` : "";
|
|
585
|
+
throw new Error(
|
|
586
|
+
`${operationDetail}probe cleanup also failed; remove ${probeCollection}: ${errorMessage(cleanupError)}`,
|
|
587
|
+
{ cause: operationError ?? cleanupError }
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (operationError) throw operationError;
|
|
592
|
+
this.conditionalVerified = true;
|
|
593
|
+
}
|
|
594
|
+
async resolveChecksum(reference, signal) {
|
|
595
|
+
await this.readHead(signal);
|
|
596
|
+
if (this.checksums.has(reference)) return this.checksums.get(reference);
|
|
597
|
+
await this.listHistory(signal);
|
|
598
|
+
return this.checksums.get(reference);
|
|
599
|
+
}
|
|
600
|
+
registerChecksum(reference, checksum) {
|
|
601
|
+
const known = this.checksums.get(reference);
|
|
602
|
+
if (known && known !== checksum) {
|
|
603
|
+
throw new Error("Remote snapshot reference was rebound to a different checksum.");
|
|
604
|
+
}
|
|
605
|
+
this.checksums.set(reference, checksum);
|
|
606
|
+
}
|
|
607
|
+
async readHeadAfterCommit(client) {
|
|
608
|
+
const object = await client.getJson(latestPath(this.config));
|
|
609
|
+
if (object.missing) return void 0;
|
|
610
|
+
return remoteHead(
|
|
611
|
+
requirePointer(object.value, this.config.destination.namespace),
|
|
612
|
+
this.identity,
|
|
613
|
+
object.etag
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
async reconcileActiveHistory(signal) {
|
|
617
|
+
const client = new WebDavClient(this.config, signal);
|
|
618
|
+
const object = await client.getJson(latestPath(this.config));
|
|
619
|
+
throwIfAborted2(signal);
|
|
620
|
+
if (object.missing) return "webdav history: no active snapshot";
|
|
621
|
+
const pointer = requirePointer(object.value, this.config.destination.namespace);
|
|
622
|
+
this.registerChecksum(pointer.snapshot, pointer.sha256);
|
|
623
|
+
const repaired = await this.ensureHistoryPointer(client, pointer);
|
|
624
|
+
return repaired ? "webdav history: repaired active snapshot entry" : "webdav history: active snapshot entry present";
|
|
625
|
+
}
|
|
626
|
+
async ensureHistoryPointer(client, pointer) {
|
|
627
|
+
const object = await client.getJson(
|
|
628
|
+
historyPath(this.config)
|
|
629
|
+
);
|
|
630
|
+
const snapshots = object.missing ? [] : requireHistory(object.value, this.config.destination.namespace);
|
|
631
|
+
const existing = snapshots.find((entry) => entry.snapshot === pointer.snapshot);
|
|
632
|
+
if (existing) {
|
|
633
|
+
if (!samePointer(existing, pointer)) {
|
|
634
|
+
throw new Error("Remote history rebound an immutable snapshot reference.");
|
|
635
|
+
}
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
const next = [...snapshots, pointer].slice(-100);
|
|
639
|
+
const condition = object.missing ? { ifAbsent: true } : { ifMatch: requireStrongEtag(object.etag, "history") };
|
|
640
|
+
await client.putJson(
|
|
641
|
+
historyPath(this.config),
|
|
642
|
+
{ version: VERSION, snapshots: next },
|
|
643
|
+
condition
|
|
644
|
+
);
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
async updateHistorySafely(client, pointer) {
|
|
648
|
+
try {
|
|
649
|
+
await this.ensureHistoryPointer(client, pointer);
|
|
650
|
+
return void 0;
|
|
651
|
+
} catch (error) {
|
|
652
|
+
return `Remote snapshot is active, but history could not be updated: ${errorMessage(error)}. Run /sync doctor before relying on history.`;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
function rootPath(config) {
|
|
657
|
+
return config.destination.path;
|
|
658
|
+
}
|
|
659
|
+
function latestPath(config) {
|
|
660
|
+
return joinRemote(rootPath(config), "latest.json");
|
|
661
|
+
}
|
|
662
|
+
function historyPath(config) {
|
|
663
|
+
return joinRemote(rootPath(config), "history.json");
|
|
664
|
+
}
|
|
665
|
+
function snapshotsPath(config) {
|
|
666
|
+
return joinRemote(rootPath(config), "snapshots");
|
|
667
|
+
}
|
|
668
|
+
function snapshotPath(config, reference) {
|
|
669
|
+
requireSnapshotReference(reference);
|
|
670
|
+
return joinRemote(snapshotsPath(config), `${reference}.json.gz`);
|
|
671
|
+
}
|
|
672
|
+
function webDavBackendIdentity(config) {
|
|
673
|
+
return `webdav:${sha256(
|
|
674
|
+
Buffer.from(JSON.stringify([secretFreeUrl(config.profile.url), config.destination.path]))
|
|
675
|
+
)}`;
|
|
676
|
+
}
|
|
677
|
+
function webDavStorageLocation(config) {
|
|
678
|
+
const url = new URL(secretFreeUrl(config.profile.url));
|
|
679
|
+
return `${url.host} \xB7 ${rootPath(config)}`;
|
|
680
|
+
}
|
|
681
|
+
function pointerFor(config, snapshot, checksum) {
|
|
682
|
+
return {
|
|
683
|
+
version: VERSION,
|
|
684
|
+
profile: config.destination.namespace,
|
|
685
|
+
snapshot: snapshot.id,
|
|
686
|
+
sha256: checksum,
|
|
687
|
+
createdAt: snapshot.createdAt,
|
|
688
|
+
machine: snapshot.machine,
|
|
689
|
+
syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
|
|
690
|
+
...snapshot.selection === void 0 ? {} : { selection: snapshot.selection }
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
function remoteHead(pointer, identity, etag) {
|
|
694
|
+
return {
|
|
695
|
+
snapshotRef: pointer.snapshot,
|
|
696
|
+
snapshotId: pointer.snapshot,
|
|
697
|
+
revision: encodeRevision(identity, etag, pointer),
|
|
698
|
+
createdAt: pointer.createdAt,
|
|
699
|
+
machine: pointer.machine,
|
|
700
|
+
syncSessions: pointer.syncSessions === true,
|
|
701
|
+
...pointer.selection === void 0 ? {} : { selection: pointer.selection }
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function encodeRevision(identity, etag, pointer) {
|
|
705
|
+
return `webdav-v1:${Buffer.from(JSON.stringify({ identity, etag: etag ?? null, pointer: sha256(Buffer.from(canonicalJson(pointer))) })).toString("base64url")}`;
|
|
706
|
+
}
|
|
707
|
+
function decodeRevision(value, identity) {
|
|
708
|
+
try {
|
|
709
|
+
if (!value.startsWith("webdav-v1:")) return void 0;
|
|
710
|
+
const parsed = JSON.parse(Buffer.from(value.slice(10), "base64url").toString("utf8"));
|
|
711
|
+
if (parsed.identity !== identity || typeof parsed.etag !== "string") return void 0;
|
|
712
|
+
return strongEtag(parsed.etag);
|
|
713
|
+
} catch {
|
|
714
|
+
return void 0;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
function publicationCondition(current, expected, identity) {
|
|
718
|
+
if (expected.kind === "missing") return { ifAbsent: true };
|
|
719
|
+
const etag = decodeRevision(expected.revision, identity) ?? strongEtag(current.etag);
|
|
720
|
+
if (!etag) throw new SyncBackendConflictError("WebDAV head has no usable strong ETag.");
|
|
721
|
+
return { ifMatch: etag };
|
|
722
|
+
}
|
|
723
|
+
async function expectPrecondition(operation, header) {
|
|
724
|
+
try {
|
|
725
|
+
await operation;
|
|
726
|
+
} catch (error) {
|
|
727
|
+
if (error instanceof WebDavPreconditionError) return;
|
|
728
|
+
throw error;
|
|
729
|
+
}
|
|
730
|
+
throw new Error(`WebDAV server ignored ${header}; publication is read-only for safety.`);
|
|
731
|
+
}
|
|
732
|
+
function requireStrongEtag(value, resource) {
|
|
733
|
+
const etag = strongEtag(value);
|
|
734
|
+
if (!etag) throw new Error(`WebDAV ${resource} has no strong ETag.`);
|
|
735
|
+
return etag;
|
|
736
|
+
}
|
|
737
|
+
function strongEtag(value) {
|
|
738
|
+
return value && !value.startsWith("W/") && /^"[^"\r\n]+"$/u.test(value) ? value : void 0;
|
|
739
|
+
}
|
|
740
|
+
function requirePointer(value, expectedProfile) {
|
|
741
|
+
if (!value || value.version !== VERSION || value.profile !== expectedProfile || !isSafeReference(value.snapshot) || !/^[0-9a-f]{64}$/u.test(value.sha256) || !isSafeMetadata(value.createdAt, 64) || !isSafeMetadata(value.machine, 256) || value.syncSessions !== void 0 && typeof value.syncSessions !== "boolean") {
|
|
742
|
+
throw new Error("Remote latest pointer is malformed.");
|
|
743
|
+
}
|
|
744
|
+
if (value.selection !== void 0) portableSnapshotSelection(value.selection);
|
|
745
|
+
return value;
|
|
746
|
+
}
|
|
747
|
+
function requireHistory(value, expectedProfile) {
|
|
748
|
+
if (!value || value.version !== VERSION || !Array.isArray(value.snapshots)) {
|
|
749
|
+
throw new Error("Remote history is malformed.");
|
|
750
|
+
}
|
|
751
|
+
const pointers = value.snapshots.map((item) => requirePointer(item, expectedProfile));
|
|
752
|
+
if (new Set(pointers.map((item) => item.snapshot)).size !== pointers.length) {
|
|
753
|
+
throw new Error("Remote history contains duplicate snapshot references.");
|
|
754
|
+
}
|
|
755
|
+
return pointers;
|
|
756
|
+
}
|
|
757
|
+
function remoteHistoryEntry(pointer) {
|
|
758
|
+
return {
|
|
759
|
+
snapshotRef: pointer.snapshot,
|
|
760
|
+
snapshotId: pointer.snapshot,
|
|
761
|
+
createdAt: pointer.createdAt,
|
|
762
|
+
machine: pointer.machine,
|
|
763
|
+
syncSessions: pointer.syncSessions === true
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
function assertSnapshotIdentity(snapshot, namespace) {
|
|
767
|
+
if (snapshot.version !== VERSION || snapshot.profile !== namespace || !isSafeReference(snapshot.id) || !isSafeMetadata(snapshot.createdAt, 64) || !isSafeMetadata(snapshot.machine, 256) || !Array.isArray(snapshot.files)) {
|
|
768
|
+
throw new Error("Invalid snapshot identity for WebDAV publication.");
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
function validateSnapshotBundle(snapshot) {
|
|
772
|
+
const paths = /* @__PURE__ */ new Set();
|
|
773
|
+
for (const file of snapshot.files) {
|
|
774
|
+
if (!file || !isSafeSnapshotPath(file.path) || paths.has(file.path) || typeof file.contentBase64 !== "string" || typeof file.sha256 !== "string") {
|
|
775
|
+
throw new Error("Remote snapshot bundle is malformed.");
|
|
776
|
+
}
|
|
777
|
+
const content = Buffer.from(file.contentBase64, "base64");
|
|
778
|
+
if (content.toString("base64") !== file.contentBase64 || sha256(content) !== file.sha256) {
|
|
779
|
+
throw new Error("Remote snapshot bundle is malformed.");
|
|
780
|
+
}
|
|
781
|
+
paths.add(file.path);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function isSafeSnapshotPath(value) {
|
|
785
|
+
if (typeof value !== "string" || !value || value.length > 4096 || value.includes("\\")) {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
if ([...value].some((character) => {
|
|
789
|
+
const code = character.codePointAt(0) ?? 0;
|
|
790
|
+
return code < 32 || code >= 127 && code <= 159;
|
|
791
|
+
})) {
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
return value.split("/").every((segment) => segment && segment !== "." && segment !== "..");
|
|
795
|
+
}
|
|
796
|
+
function assertSafeDestination(config) {
|
|
797
|
+
const url = new URL(config.profile.url);
|
|
798
|
+
if (url.username || url.password || url.search || url.hash)
|
|
799
|
+
throw new Error("Invalid WebDAV URL.");
|
|
800
|
+
for (const value of [config.destination.path, config.destination.namespace]) {
|
|
801
|
+
if (!value || value.includes("\\") || [...value].some((character) => {
|
|
802
|
+
const code = character.codePointAt(0) ?? 0;
|
|
803
|
+
return code < 32 || code >= 127 && code <= 159;
|
|
804
|
+
}) || value.split("/").some((part) => part === "." || part === "..")) {
|
|
805
|
+
throw new Error("Invalid WebDAV storage location.");
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
function isSafeMetadata(value, maxLength) {
|
|
810
|
+
return typeof value === "string" && value.length > 0 && value.length <= maxLength && ![...value].some((character) => {
|
|
811
|
+
const code = character.codePointAt(0) ?? 0;
|
|
812
|
+
return code < 32 || code >= 127 && code <= 159;
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
function requireSnapshotReference(reference) {
|
|
816
|
+
if (!isSafeReference(reference)) throw new Error("Invalid WebDAV snapshot reference.");
|
|
817
|
+
}
|
|
818
|
+
function isSafeReference(value) {
|
|
819
|
+
return !!value && value.length <= 512 && // biome-ignore lint/suspicious/noControlCharactersInRegex: Remote references cannot contain terminal controls.
|
|
820
|
+
!/[\\/\u0000-\u001f\u007f-\u009f]/u.test(value) && value !== "." && value !== "..";
|
|
821
|
+
}
|
|
822
|
+
function matchesExpected(head, expected) {
|
|
823
|
+
return expected.kind === "missing" ? head === void 0 : head?.revision === expected.revision;
|
|
824
|
+
}
|
|
825
|
+
function samePointer(left, right) {
|
|
826
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
827
|
+
}
|
|
828
|
+
function canonicalJson(value) {
|
|
829
|
+
if (Array.isArray(value))
|
|
830
|
+
return JSON.stringify(value.map((item) => JSON.parse(canonicalJson(item))));
|
|
831
|
+
if (!value || typeof value !== "object") return JSON.stringify(value);
|
|
832
|
+
return JSON.stringify(
|
|
833
|
+
Object.fromEntries(
|
|
834
|
+
Object.keys(value).sort().map((key) => [key, JSON.parse(canonicalJson(value[key]))])
|
|
835
|
+
)
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
function joinRemote(...parts) {
|
|
839
|
+
return parts.flatMap((part) => part.split("/")).filter(Boolean).join("/");
|
|
840
|
+
}
|
|
841
|
+
function secretFreeUrl(value) {
|
|
842
|
+
const url = new URL(value);
|
|
843
|
+
url.username = "";
|
|
844
|
+
url.password = "";
|
|
845
|
+
url.search = "";
|
|
846
|
+
url.hash = "";
|
|
847
|
+
return url.toString();
|
|
848
|
+
}
|
|
849
|
+
function sha256(value) {
|
|
850
|
+
return createHash("sha256").update(value).digest("hex");
|
|
851
|
+
}
|
|
852
|
+
function throwIfAborted2(signal) {
|
|
853
|
+
if (!signal?.aborted) return;
|
|
854
|
+
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
855
|
+
}
|
|
856
|
+
function errorMessage(error) {
|
|
857
|
+
if (error instanceof WebDavHttpError) return error.message;
|
|
858
|
+
return error instanceof Error ? error.message : String(error);
|
|
859
|
+
}
|
|
860
|
+
export {
|
|
861
|
+
WebDavSyncBackend,
|
|
862
|
+
historyPath,
|
|
863
|
+
latestPath,
|
|
864
|
+
rootPath,
|
|
865
|
+
snapshotPath,
|
|
866
|
+
snapshotsPath,
|
|
867
|
+
webDavBackendIdentity
|
|
868
|
+
};
|
|
869
|
+
//# sourceMappingURL=webdav-backend-ZD5RHRIU.ts.map
|