@narumitw/pi-sync 0.49.10 → 0.49.12
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 +11 -7
|
@@ -0,0 +1,1104 @@
|
|
|
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
|
+
normalizeGitBranch,
|
|
5
|
+
normalizeGitDirectory,
|
|
6
|
+
normalizeGitRemote,
|
|
7
|
+
normalizeGitRemoteIdentity,
|
|
8
|
+
stateDir,
|
|
9
|
+
validateGitNamespace
|
|
10
|
+
} from "./chunk-F5QL2GPY.ts";
|
|
11
|
+
import {
|
|
12
|
+
SyncBackendConflictError,
|
|
13
|
+
SyncBackendPublicationOutcomeUnknownError
|
|
14
|
+
} from "./chunk-5MQ76BK5.ts";
|
|
15
|
+
import {
|
|
16
|
+
portableSnapshotSelection,
|
|
17
|
+
posixJoin,
|
|
18
|
+
snapshotSelectionInclude
|
|
19
|
+
} from "./chunk-YQ6UW7IF.ts";
|
|
20
|
+
|
|
21
|
+
// src/git-backend.ts
|
|
22
|
+
import { createHash as createHash2, randomUUID } from "node:crypto";
|
|
23
|
+
import fs from "node:fs/promises";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
// src/git-runner.ts
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import process2 from "node:process";
|
|
29
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
30
|
+
var DEFAULT_OUTPUT_LIMIT = 1024 * 1024;
|
|
31
|
+
var GitCommandError = class extends Error {
|
|
32
|
+
constructor(message, exitCode, stderr, options) {
|
|
33
|
+
super(message, options);
|
|
34
|
+
this.exitCode = exitCode;
|
|
35
|
+
this.stderr = stderr;
|
|
36
|
+
this.name = "GitCommandError";
|
|
37
|
+
}
|
|
38
|
+
exitCode;
|
|
39
|
+
stderr;
|
|
40
|
+
code = "GIT_COMMAND_FAILED";
|
|
41
|
+
};
|
|
42
|
+
async function runGit(args, options = {}) {
|
|
43
|
+
throwIfAborted(options.signal);
|
|
44
|
+
const hooksPath = process2.platform === "win32" ? "NUL" : "/dev/null";
|
|
45
|
+
const protocolArgs = [
|
|
46
|
+
"-c",
|
|
47
|
+
`core.hooksPath=${hooksPath}`,
|
|
48
|
+
"-c",
|
|
49
|
+
"gc.auto=0",
|
|
50
|
+
"-c",
|
|
51
|
+
"maintenance.auto=false",
|
|
52
|
+
"-c",
|
|
53
|
+
"protocol.allow=never",
|
|
54
|
+
"-c",
|
|
55
|
+
"protocol.https.allow=always",
|
|
56
|
+
"-c",
|
|
57
|
+
"protocol.ssh.allow=always"
|
|
58
|
+
];
|
|
59
|
+
if (options.allowFileProtocol) protocolArgs.push("-c", "protocol.file.allow=always");
|
|
60
|
+
const commandArgs = [
|
|
61
|
+
...options.gitDir ? [`--git-dir=${options.gitDir}`] : [],
|
|
62
|
+
...protocolArgs,
|
|
63
|
+
...args
|
|
64
|
+
];
|
|
65
|
+
const inheritedEnvironment = Object.fromEntries(
|
|
66
|
+
Object.entries(process2.env).filter(
|
|
67
|
+
([key]) => !key.startsWith("GIT_") && key !== "PAGER" && key !== "EDITOR" && key !== "VISUAL" && key !== "SSH_ASKPASS" && key !== "SSH_ASKPASS_REQUIRE"
|
|
68
|
+
)
|
|
69
|
+
);
|
|
70
|
+
const allowedGitOverrides = /* @__PURE__ */ new Set([
|
|
71
|
+
"GIT_INDEX_FILE",
|
|
72
|
+
"GIT_AUTHOR_NAME",
|
|
73
|
+
"GIT_AUTHOR_EMAIL",
|
|
74
|
+
"GIT_AUTHOR_DATE",
|
|
75
|
+
"GIT_COMMITTER_NAME",
|
|
76
|
+
"GIT_COMMITTER_EMAIL",
|
|
77
|
+
"GIT_COMMITTER_DATE"
|
|
78
|
+
]);
|
|
79
|
+
const suppliedEnvironment = Object.fromEntries(
|
|
80
|
+
Object.entries(options.env ?? {}).filter(
|
|
81
|
+
([key]) => !key.startsWith("GIT_") || allowedGitOverrides.has(key)
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
const env = {
|
|
85
|
+
...inheritedEnvironment,
|
|
86
|
+
...suppliedEnvironment,
|
|
87
|
+
LC_ALL: "C",
|
|
88
|
+
LANG: "C",
|
|
89
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
90
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
91
|
+
GCM_INTERACTIVE: "Never",
|
|
92
|
+
GIT_PAGER: "cat",
|
|
93
|
+
PAGER: "cat",
|
|
94
|
+
GIT_EDITOR: "true",
|
|
95
|
+
EDITOR: "true",
|
|
96
|
+
VISUAL: "true",
|
|
97
|
+
GIT_ASKPASS: "",
|
|
98
|
+
SSH_ASKPASS: "",
|
|
99
|
+
SSH_ASKPASS_REQUIRE: "never",
|
|
100
|
+
GIT_SSH_COMMAND: "ssh -oBatchMode=yes"
|
|
101
|
+
};
|
|
102
|
+
const child = spawn("git", commandArgs, {
|
|
103
|
+
cwd: options.cwd,
|
|
104
|
+
env,
|
|
105
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
106
|
+
detached: process2.platform !== "win32",
|
|
107
|
+
windowsHide: true
|
|
108
|
+
});
|
|
109
|
+
const stdout = [];
|
|
110
|
+
const stderr = [];
|
|
111
|
+
let total = 0;
|
|
112
|
+
let settled = false;
|
|
113
|
+
let terminationError;
|
|
114
|
+
let escalationTimer;
|
|
115
|
+
const limit = options.maxOutputBytes ?? DEFAULT_OUTPUT_LIMIT;
|
|
116
|
+
const terminate = (error) => {
|
|
117
|
+
if (settled || terminationError) return;
|
|
118
|
+
terminationError = error;
|
|
119
|
+
if (child.pid && process2.platform !== "win32") {
|
|
120
|
+
try {
|
|
121
|
+
process2.kill(-child.pid, "SIGTERM");
|
|
122
|
+
} catch {
|
|
123
|
+
child.kill("SIGTERM");
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
child.kill("SIGTERM");
|
|
127
|
+
if (child.pid && process2.platform === "win32") {
|
|
128
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
|
|
129
|
+
stdio: "ignore",
|
|
130
|
+
windowsHide: true
|
|
131
|
+
});
|
|
132
|
+
killer.on("error", () => void 0);
|
|
133
|
+
killer.unref();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
escalationTimer = setTimeout(() => {
|
|
137
|
+
if (settled) return;
|
|
138
|
+
if (child.pid && process2.platform !== "win32") {
|
|
139
|
+
try {
|
|
140
|
+
process2.kill(-child.pid, "SIGKILL");
|
|
141
|
+
} catch {
|
|
142
|
+
child.kill("SIGKILL");
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
child.kill("SIGKILL");
|
|
146
|
+
}
|
|
147
|
+
}, 2e3);
|
|
148
|
+
};
|
|
149
|
+
const collect = (target) => (chunk) => {
|
|
150
|
+
total += chunk.byteLength;
|
|
151
|
+
if (total > limit) {
|
|
152
|
+
terminate(new Error(`Git output exceeds the ${limit}-byte limit.`));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
target.push(Buffer.from(chunk));
|
|
156
|
+
};
|
|
157
|
+
child.stdout.on("data", collect(stdout));
|
|
158
|
+
child.stderr.on("data", collect(stderr));
|
|
159
|
+
child.stdin.on("error", () => void 0);
|
|
160
|
+
child.stdin.end(options.input);
|
|
161
|
+
const onAbort = () => terminate(
|
|
162
|
+
options.signal?.reason instanceof Error ? options.signal.reason : new DOMException("The operation was aborted", "AbortError")
|
|
163
|
+
);
|
|
164
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
165
|
+
const timer = setTimeout(
|
|
166
|
+
() => terminate(
|
|
167
|
+
new Error(`Git command timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms.`)
|
|
168
|
+
),
|
|
169
|
+
options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
170
|
+
);
|
|
171
|
+
let result;
|
|
172
|
+
try {
|
|
173
|
+
result = await new Promise((resolve, reject) => {
|
|
174
|
+
child.once("error", reject);
|
|
175
|
+
child.once("close", (code) => {
|
|
176
|
+
settled = true;
|
|
177
|
+
if (escalationTimer) clearTimeout(escalationTimer);
|
|
178
|
+
const stdoutBuffer = Buffer.concat(stdout);
|
|
179
|
+
const stderrBuffer = Buffer.concat(stderr);
|
|
180
|
+
if (terminationError) {
|
|
181
|
+
reject(terminationError);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (code !== 0) {
|
|
185
|
+
const stderrText = stderrBuffer.toString("utf8").trim();
|
|
186
|
+
reject(
|
|
187
|
+
new GitCommandError(
|
|
188
|
+
stderrText || `Git exited with status ${code ?? "unknown"}.`,
|
|
189
|
+
code,
|
|
190
|
+
stderrText
|
|
191
|
+
)
|
|
192
|
+
);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
resolve({ stdout: stdoutBuffer, stderr: stderrBuffer });
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
if (escalationTimer) clearTimeout(escalationTimer);
|
|
201
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
function parseGitBlobBatch(output, expectedCount, maxContentBytes) {
|
|
206
|
+
if (!Number.isSafeInteger(expectedCount) || expectedCount < 0) {
|
|
207
|
+
throw new Error("Invalid Git batch object count.");
|
|
208
|
+
}
|
|
209
|
+
if (!Number.isSafeInteger(maxContentBytes) || maxContentBytes < 0) {
|
|
210
|
+
throw new Error("Invalid Git batch content limit.");
|
|
211
|
+
}
|
|
212
|
+
const blobs = [];
|
|
213
|
+
let offset = 0;
|
|
214
|
+
let contentBytes = 0;
|
|
215
|
+
for (let index = 0; index < expectedCount; index += 1) {
|
|
216
|
+
const headerEnd = output.indexOf(10, offset);
|
|
217
|
+
if (headerEnd < 0) throw new Error("Git cat-file batch response is truncated.");
|
|
218
|
+
const header = output.subarray(offset, headerEnd).toString("utf8");
|
|
219
|
+
if (header.endsWith(" missing")) throw new Error("Git cat-file batch object is missing.");
|
|
220
|
+
const match = /^(?<object>[0-9a-f]{40}) blob (?<size>0|[1-9][0-9]*)$/u.exec(header);
|
|
221
|
+
if (!match?.groups) throw new Error("Git cat-file batch response is malformed.");
|
|
222
|
+
const size = Number(match.groups.size);
|
|
223
|
+
if (!Number.isSafeInteger(size)) throw new Error("Git cat-file batch size is malformed.");
|
|
224
|
+
contentBytes += size;
|
|
225
|
+
if (contentBytes > maxContentBytes) {
|
|
226
|
+
throw new Error(`Git cat-file batch content exceeds the ${maxContentBytes}-byte limit.`);
|
|
227
|
+
}
|
|
228
|
+
const contentStart = headerEnd + 1;
|
|
229
|
+
const contentEnd = contentStart + size;
|
|
230
|
+
if (contentEnd >= output.length) throw new Error("Git cat-file batch response is truncated.");
|
|
231
|
+
if (output[contentEnd] !== 10) {
|
|
232
|
+
throw new Error("Git cat-file batch response is malformed.");
|
|
233
|
+
}
|
|
234
|
+
blobs.push(Buffer.from(output.subarray(contentStart, contentEnd)));
|
|
235
|
+
offset = contentEnd + 1;
|
|
236
|
+
}
|
|
237
|
+
if (offset !== output.length) throw new Error("Git cat-file batch response has trailing data.");
|
|
238
|
+
return blobs;
|
|
239
|
+
}
|
|
240
|
+
async function readGitBlobs(objects, options) {
|
|
241
|
+
if (objects.length === 0) return [];
|
|
242
|
+
if (!Number.isSafeInteger(options.maxOutputBytes) || options.maxOutputBytes < 0) {
|
|
243
|
+
throw new Error("Invalid Git batch output limit.");
|
|
244
|
+
}
|
|
245
|
+
if (objects.some((object) => !/^[0-9a-f]{40}$/u.test(object))) {
|
|
246
|
+
throw new Error("Invalid Git blob object id.");
|
|
247
|
+
}
|
|
248
|
+
const protocolOverhead = objects.length * 96;
|
|
249
|
+
if (!Number.isSafeInteger(protocolOverhead + options.maxOutputBytes)) {
|
|
250
|
+
throw new Error("Invalid Git batch output limit.");
|
|
251
|
+
}
|
|
252
|
+
const result = await runGit(["cat-file", "--batch"], {
|
|
253
|
+
...options,
|
|
254
|
+
input: `${objects.join("\n")}
|
|
255
|
+
`,
|
|
256
|
+
maxOutputBytes: options.maxOutputBytes + protocolOverhead
|
|
257
|
+
});
|
|
258
|
+
return parseGitBlobBatch(result.stdout, objects.length, options.maxOutputBytes);
|
|
259
|
+
}
|
|
260
|
+
function throwIfAborted(signal) {
|
|
261
|
+
if (!signal?.aborted) return;
|
|
262
|
+
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/git-storage.ts
|
|
266
|
+
import { createHash } from "node:crypto";
|
|
267
|
+
var GIT_MANIFEST_VERSION = 2;
|
|
268
|
+
var MAX_GIT_MANIFEST_BYTES = 1024 * 1024;
|
|
269
|
+
var MAX_GIT_TREE_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
270
|
+
var MAX_GIT_PAYLOAD_BYTES = 100 * 1024 * 1024;
|
|
271
|
+
var MAX_GIT_SNAPSHOT_BYTES = 512 * 1024 * 1024;
|
|
272
|
+
var SNAPSHOT_VERSION = 1;
|
|
273
|
+
function isGitPayloadSizeAllowed(size) {
|
|
274
|
+
return Number.isSafeInteger(size) && size >= 0 && size <= MAX_GIT_PAYLOAD_BYTES;
|
|
275
|
+
}
|
|
276
|
+
function requireGitManifest(value) {
|
|
277
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
278
|
+
throw new Error("Git publication manifest is malformed.");
|
|
279
|
+
}
|
|
280
|
+
const manifest = value;
|
|
281
|
+
if (manifest.version === 1) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
"Git publication uses the unsupported pre-release gzip format; recreate this pi-sync-owned test branch."
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (manifest.version !== GIT_MANIFEST_VERSION || manifest.snapshotVersion !== SNAPSHOT_VERSION || typeof manifest.snapshotId !== "string" || manifest.snapshotId.length > 512 || !/^[A-Za-z0-9._-]+$/u.test(manifest.snapshotId) || typeof manifest.createdAt !== "string" || manifest.createdAt.length > 64 || hasControlCharacter(manifest.createdAt) || Number.isNaN(Date.parse(manifest.createdAt)) || typeof manifest.machine !== "string" || manifest.machine.length > 256 || hasControlCharacter(manifest.machine) || typeof manifest.profile !== "string" || manifest.profile.length === 0 || manifest.profile.length > 256 || hasControlCharacter(manifest.profile) || typeof manifest.syncSessions !== "boolean" || manifest.snapshotSyncSessions !== void 0 && typeof manifest.snapshotSyncSessions !== "boolean" || !Array.isArray(manifest.files) || !hasExactKeys(manifest, [
|
|
287
|
+
"version",
|
|
288
|
+
"snapshotVersion",
|
|
289
|
+
"snapshotId",
|
|
290
|
+
"createdAt",
|
|
291
|
+
"machine",
|
|
292
|
+
"profile",
|
|
293
|
+
"syncSessions",
|
|
294
|
+
...manifest.snapshotSyncSessions === void 0 ? [] : ["snapshotSyncSessions"],
|
|
295
|
+
...manifest.selection === void 0 ? [] : ["selection"],
|
|
296
|
+
"files"
|
|
297
|
+
])) {
|
|
298
|
+
throw new Error("Git publication manifest is malformed.");
|
|
299
|
+
}
|
|
300
|
+
if (manifest.selection !== void 0) portableSnapshotSelection(manifest.selection);
|
|
301
|
+
let total = 0;
|
|
302
|
+
const paths = /* @__PURE__ */ new Set();
|
|
303
|
+
for (const rawFile of manifest.files) {
|
|
304
|
+
if (!rawFile || typeof rawFile !== "object" || Array.isArray(rawFile)) {
|
|
305
|
+
throw new Error("Git publication manifest file is malformed.");
|
|
306
|
+
}
|
|
307
|
+
const file = rawFile;
|
|
308
|
+
if (!hasExactKeys(file, ["path", "sha256", "size"]) || !isSafeSnapshotPath(file.path) || typeof file.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(file.sha256) || typeof file.size !== "number" || !isGitPayloadSizeAllowed(file.size) || paths.has(file.path)) {
|
|
309
|
+
throw new Error("Git publication manifest file is malformed.");
|
|
310
|
+
}
|
|
311
|
+
total += file.size;
|
|
312
|
+
if (!Number.isSafeInteger(total) || total > MAX_GIT_SNAPSHOT_BYTES) {
|
|
313
|
+
throw new Error(`Git snapshot content exceeds the ${MAX_GIT_SNAPSHOT_BYTES}-byte limit.`);
|
|
314
|
+
}
|
|
315
|
+
paths.add(file.path);
|
|
316
|
+
}
|
|
317
|
+
assertNoPathConflicts([...paths]);
|
|
318
|
+
return manifest;
|
|
319
|
+
}
|
|
320
|
+
function validateGitSnapshot(snapshot, manifest, namespace) {
|
|
321
|
+
const prepared = prepareGitSnapshot(snapshot, namespace);
|
|
322
|
+
const syncSessions = snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/"));
|
|
323
|
+
if (snapshot.id !== manifest.snapshotId || snapshot.createdAt !== manifest.createdAt || snapshot.machine !== manifest.machine || snapshot.profile !== manifest.profile || snapshot.syncSessions !== manifest.snapshotSyncSessions || syncSessions !== manifest.syncSessions || !sameOptionalInclude(
|
|
324
|
+
snapshotSelectionInclude(snapshot),
|
|
325
|
+
manifest.selection === void 0 ? void 0 : portableSnapshotSelection(manifest.selection).include
|
|
326
|
+
) || prepared.length !== manifest.files.length || prepared.some((file, index) => {
|
|
327
|
+
const expected = manifest.files[index];
|
|
328
|
+
return !expected || file.path !== expected.path || file.sha256 !== expected.sha256 || file.size !== expected.size;
|
|
329
|
+
})) {
|
|
330
|
+
throw new Error("Git snapshot identity does not match its publication manifest.");
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function prepareGitSnapshot(snapshot, namespace) {
|
|
334
|
+
snapshotSelectionInclude(snapshot);
|
|
335
|
+
if (snapshot.version !== SNAPSHOT_VERSION || typeof snapshot.id !== "string" || !snapshot.id || snapshot.id.length > 512 || !/^[A-Za-z0-9._-]+$/u.test(snapshot.id) || snapshot.profile !== namespace || !Array.isArray(snapshot.files) || typeof snapshot.createdAt !== "string" || !snapshot.createdAt || snapshot.createdAt.length > 64 || hasControlCharacter(snapshot.createdAt) || Number.isNaN(Date.parse(snapshot.createdAt)) || typeof snapshot.machine !== "string" || snapshot.machine.length > 256 || hasControlCharacter(snapshot.machine)) {
|
|
336
|
+
throw new Error("Invalid Git snapshot publication.");
|
|
337
|
+
}
|
|
338
|
+
const paths = /* @__PURE__ */ new Set();
|
|
339
|
+
const prepared = [];
|
|
340
|
+
let total = 0;
|
|
341
|
+
for (const file of snapshot.files) {
|
|
342
|
+
if (!isSafeSnapshotPath(file.path) || typeof file.contentBase64 !== "string" || typeof file.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(file.sha256) || paths.has(file.path)) {
|
|
343
|
+
throw new Error("Invalid Git snapshot file.");
|
|
344
|
+
}
|
|
345
|
+
const content = Buffer.from(file.contentBase64, "base64");
|
|
346
|
+
if (content.toString("base64") !== file.contentBase64 || sha256(content) !== file.sha256) {
|
|
347
|
+
throw new Error("Git snapshot file checksum mismatch.");
|
|
348
|
+
}
|
|
349
|
+
if (!isGitPayloadSizeAllowed(content.byteLength)) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`Git snapshot file exceeds GitHub's ${MAX_GIT_PAYLOAD_BYTES}-byte regular-Git limit: ${file.path}`
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
total += content.byteLength;
|
|
355
|
+
if (!Number.isSafeInteger(total) || total > MAX_GIT_SNAPSHOT_BYTES) {
|
|
356
|
+
throw new Error(`Git snapshot content exceeds the ${MAX_GIT_SNAPSHOT_BYTES}-byte limit.`);
|
|
357
|
+
}
|
|
358
|
+
paths.add(file.path);
|
|
359
|
+
prepared.push({ path: file.path, sha256: file.sha256, size: content.byteLength, content });
|
|
360
|
+
}
|
|
361
|
+
assertNoPathConflicts([...paths]);
|
|
362
|
+
return prepared;
|
|
363
|
+
}
|
|
364
|
+
function parseGitTree(output) {
|
|
365
|
+
if (output.byteLength === 0) return [];
|
|
366
|
+
if (output.at(-1) !== 0) throw new Error("Git publication tree response is malformed.");
|
|
367
|
+
return output.subarray(0, -1).toString("utf8").split("\0").map((line) => {
|
|
368
|
+
const match = /^(?<mode>[0-9]{6}) (?<type>blob|tree|commit) (?<object>[0-9a-f]{40})\t(?<path>.+)$/u.exec(
|
|
369
|
+
line
|
|
370
|
+
);
|
|
371
|
+
if (!match?.groups || hasControlCharacter(match.groups.path)) {
|
|
372
|
+
throw new Error("Git publication tree response is malformed.");
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
mode: match.groups.mode,
|
|
376
|
+
type: match.groups.type,
|
|
377
|
+
object: match.groups.object,
|
|
378
|
+
path: match.groups.path
|
|
379
|
+
};
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
function validateGitPublicationTree(entries, manifest, manifestPath, filePath) {
|
|
383
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
384
|
+
for (const entry of entries) {
|
|
385
|
+
if (byPath.has(entry.path)) throw new Error("Git publication tree contains duplicate paths.");
|
|
386
|
+
byPath.set(entry.path, entry);
|
|
387
|
+
}
|
|
388
|
+
const expectedPaths = [manifestPath, ...manifest.files.map((file) => filePath(file.path))];
|
|
389
|
+
if (entries.length !== expectedPaths.length || expectedPaths.some((path2) => !byPath.has(path2))) {
|
|
390
|
+
throw new Error("Git publication tree has missing or extra files.");
|
|
391
|
+
}
|
|
392
|
+
for (const expectedPath of expectedPaths) {
|
|
393
|
+
const entry = byPath.get(expectedPath);
|
|
394
|
+
if (entry?.mode !== "100644" || entry.type !== "blob") {
|
|
395
|
+
throw new Error(`Git publication tree contains a non-regular file: ${expectedPath}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return manifest.files.map((file) => byPath.get(filePath(file.path)));
|
|
399
|
+
}
|
|
400
|
+
function sameOptionalInclude(left, right) {
|
|
401
|
+
if (!left || !right) return left === right;
|
|
402
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
403
|
+
}
|
|
404
|
+
function isSafeSnapshotPath(value) {
|
|
405
|
+
return typeof value === "string" && value.length > 0 && value.length <= 4096 && !value.startsWith("/") && !value.includes("\\") && !hasControlCharacter(value) && value.split("/").every(
|
|
406
|
+
(segment) => segment && segment !== "." && segment !== ".." && segment.toLowerCase() !== ".git"
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
function hasExactKeys(value, expected) {
|
|
410
|
+
const keys = Object.keys(value).sort();
|
|
411
|
+
const expectedKeys = [...expected].sort();
|
|
412
|
+
return keys.length === expectedKeys.length && keys.every((key, index) => key === expectedKeys[index]);
|
|
413
|
+
}
|
|
414
|
+
function assertNoPathConflicts(paths) {
|
|
415
|
+
const sorted = [...paths].sort();
|
|
416
|
+
for (let index = 1; index < sorted.length; index += 1) {
|
|
417
|
+
const parent = sorted[index - 1];
|
|
418
|
+
const child = sorted[index];
|
|
419
|
+
if (parent && child?.startsWith(`${parent}/`)) {
|
|
420
|
+
throw new Error(`Git snapshot file path conflict: ${parent} and ${child}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function sha256(value) {
|
|
425
|
+
return createHash("sha256").update(value).digest("hex");
|
|
426
|
+
}
|
|
427
|
+
function hasControlCharacter(value) {
|
|
428
|
+
return /[\u0000-\u001f\u007f-\u009f]/u.test(value);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// src/git-backend.ts
|
|
432
|
+
var COMMAND_TIMEOUT_MS = 3e4;
|
|
433
|
+
var POST_COMMIT_TIMEOUT_MS = 45e3;
|
|
434
|
+
var gitCacheMutationQueues = /* @__PURE__ */ new Map();
|
|
435
|
+
var GitSyncBackend = class {
|
|
436
|
+
constructor(config, options = {}) {
|
|
437
|
+
this.config = config;
|
|
438
|
+
assertGitDestination(config);
|
|
439
|
+
this.allowLocalRemotes = options.allowLocalRemotes === true;
|
|
440
|
+
if (!this.allowLocalRemotes) assertProductionRemote(config.profile.remote);
|
|
441
|
+
this.identity = gitBackendIdentity(config);
|
|
442
|
+
this.destination = gitDestination(config);
|
|
443
|
+
this.cacheRoot = options.cacheRoot ?? path.join(stateDir(), "git");
|
|
444
|
+
this.cacheDir = path.join(this.cacheRoot, this.identity.slice("git:".length), "repository.git");
|
|
445
|
+
this.commandTimeoutMs = options.commandTimeoutMs ?? COMMAND_TIMEOUT_MS;
|
|
446
|
+
this.postCommitTimeoutMs = options.postCommitTimeoutMs ?? POST_COMMIT_TIMEOUT_MS;
|
|
447
|
+
this.afterPushForTest = options.afterPushForTest;
|
|
448
|
+
this.afterLsRemoteForTest = options.afterLsRemoteForTest;
|
|
449
|
+
this.afterPayloadWriteForTest = options.afterPayloadWriteForTest;
|
|
450
|
+
}
|
|
451
|
+
config;
|
|
452
|
+
identity;
|
|
453
|
+
destination;
|
|
454
|
+
capability = "lease-protected";
|
|
455
|
+
cacheRoot;
|
|
456
|
+
cacheDir;
|
|
457
|
+
allowLocalRemotes;
|
|
458
|
+
commandTimeoutMs;
|
|
459
|
+
postCommitTimeoutMs;
|
|
460
|
+
afterPushForTest;
|
|
461
|
+
afterLsRemoteForTest;
|
|
462
|
+
afterPayloadWriteForTest;
|
|
463
|
+
cacheReady;
|
|
464
|
+
sameRevision(left, right) {
|
|
465
|
+
return decodeRevision(left, this.identity) === decodeRevision(right, this.identity);
|
|
466
|
+
}
|
|
467
|
+
async readHead(signal) {
|
|
468
|
+
const sha = await this.fetchRemoteHead(signal);
|
|
469
|
+
if (!sha) return void 0;
|
|
470
|
+
const { manifest } = await this.readPublication(sha, signal);
|
|
471
|
+
return remoteHead(sha, manifest, this.identity);
|
|
472
|
+
}
|
|
473
|
+
async readSnapshot(reference, signal) {
|
|
474
|
+
const head = await this.fetchRemoteHead(signal);
|
|
475
|
+
if (!head) throw new Error(`Git snapshot publication was not found: ${reference}`);
|
|
476
|
+
const commit = await this.resolveSnapshotReference(reference, head, signal);
|
|
477
|
+
try {
|
|
478
|
+
await this.git(["cat-file", "-e", `${commit}^{commit}`], { signal });
|
|
479
|
+
await this.git(["merge-base", "--is-ancestor", commit, head], { signal });
|
|
480
|
+
} catch (error) {
|
|
481
|
+
throw new Error(`Git snapshot publication was not found: ${reference}`, { cause: error });
|
|
482
|
+
}
|
|
483
|
+
const { manifest, payloadEntries } = await this.readPublication(commit, signal);
|
|
484
|
+
let blobs;
|
|
485
|
+
try {
|
|
486
|
+
blobs = await readGitBlobs(
|
|
487
|
+
payloadEntries.map((entry) => entry.object),
|
|
488
|
+
{
|
|
489
|
+
gitDir: this.cacheDir,
|
|
490
|
+
signal,
|
|
491
|
+
timeoutMs: this.commandTimeoutMs,
|
|
492
|
+
allowFileProtocol: this.allowLocalRemotes,
|
|
493
|
+
maxOutputBytes: manifest.files.reduce((total, file) => total + file.size, 0)
|
|
494
|
+
}
|
|
495
|
+
);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (error instanceof Error && /exceeds/u.test(error.message)) {
|
|
498
|
+
throw new Error("Git snapshot file content exceeds its manifest size.", { cause: error });
|
|
499
|
+
}
|
|
500
|
+
throw this.redactedError(error);
|
|
501
|
+
}
|
|
502
|
+
throwIfAborted2(signal);
|
|
503
|
+
const files = manifest.files.map((file, index) => {
|
|
504
|
+
const content = blobs[index];
|
|
505
|
+
if (!content || content.byteLength !== file.size || sha2562(content) !== file.sha256) {
|
|
506
|
+
throw new Error(`Git snapshot file checksum or size mismatch: ${file.path}`);
|
|
507
|
+
}
|
|
508
|
+
return { path: file.path, contentBase64: content.toString("base64"), sha256: file.sha256 };
|
|
509
|
+
});
|
|
510
|
+
const snapshot = {
|
|
511
|
+
version: manifest.snapshotVersion,
|
|
512
|
+
id: manifest.snapshotId,
|
|
513
|
+
createdAt: manifest.createdAt,
|
|
514
|
+
machine: manifest.machine,
|
|
515
|
+
profile: manifest.profile,
|
|
516
|
+
...manifest.snapshotSyncSessions === void 0 ? {} : { syncSessions: manifest.snapshotSyncSessions },
|
|
517
|
+
...manifest.selection === void 0 ? {} : { selection: manifest.selection },
|
|
518
|
+
files
|
|
519
|
+
};
|
|
520
|
+
validateGitSnapshot(snapshot, manifest, this.config.destination.namespace);
|
|
521
|
+
return snapshot;
|
|
522
|
+
}
|
|
523
|
+
async publishSnapshot(snapshot, expected, options = {}) {
|
|
524
|
+
throwIfAborted2(options.signal);
|
|
525
|
+
const files = prepareGitSnapshot(snapshot, this.config.destination.namespace);
|
|
526
|
+
const observed = await this.fetchRemoteHead(options.signal);
|
|
527
|
+
if (!matchesExpected(observed, expected, this.identity)) {
|
|
528
|
+
throw new SyncBackendConflictError(
|
|
529
|
+
"Git remote changed while preparing publication. Run /sync status and retry.",
|
|
530
|
+
{ currentHead: observed ? await this.headForSha(observed, options.signal) : void 0 }
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
throwIfAborted2(options.signal);
|
|
534
|
+
const manifest = {
|
|
535
|
+
version: GIT_MANIFEST_VERSION,
|
|
536
|
+
snapshotVersion: snapshot.version,
|
|
537
|
+
snapshotId: snapshot.id,
|
|
538
|
+
createdAt: snapshot.createdAt,
|
|
539
|
+
machine: snapshot.machine,
|
|
540
|
+
profile: snapshot.profile,
|
|
541
|
+
syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
|
|
542
|
+
...snapshot.syncSessions === void 0 ? {} : { snapshotSyncSessions: snapshot.syncSessions },
|
|
543
|
+
...snapshot.selection === void 0 ? {} : { selection: snapshot.selection },
|
|
544
|
+
files: files.map(({ path: filePath, sha256: fileSha, size }) => ({
|
|
545
|
+
path: filePath,
|
|
546
|
+
sha256: fileSha,
|
|
547
|
+
size
|
|
548
|
+
}))
|
|
549
|
+
};
|
|
550
|
+
let candidate;
|
|
551
|
+
try {
|
|
552
|
+
candidate = await this.createCommit(snapshot, files, manifest, observed, options.signal);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
throw this.redactedError(error);
|
|
555
|
+
}
|
|
556
|
+
throwIfAborted2(options.signal);
|
|
557
|
+
options.onCommit?.();
|
|
558
|
+
const ref = this.remoteRef();
|
|
559
|
+
const lease = `--force-with-lease=${ref}:${observed ?? ""}`;
|
|
560
|
+
let pushError;
|
|
561
|
+
try {
|
|
562
|
+
await this.git(
|
|
563
|
+
[
|
|
564
|
+
"push",
|
|
565
|
+
"--porcelain",
|
|
566
|
+
"--no-verify",
|
|
567
|
+
lease,
|
|
568
|
+
this.config.profile.remote,
|
|
569
|
+
`${candidate}:${ref}`
|
|
570
|
+
],
|
|
571
|
+
{ timeoutMs: this.postCommitTimeoutMs }
|
|
572
|
+
);
|
|
573
|
+
await this.afterPushForTest?.();
|
|
574
|
+
} catch (error) {
|
|
575
|
+
pushError = error;
|
|
576
|
+
}
|
|
577
|
+
let current;
|
|
578
|
+
try {
|
|
579
|
+
current = await this.fetchRemoteHead(AbortSignal.timeout(this.postCommitTimeoutMs));
|
|
580
|
+
} catch (error) {
|
|
581
|
+
throw new SyncBackendPublicationOutcomeUnknownError(
|
|
582
|
+
`Git publication outcome is unknown: ${this.safeError(pushError ?? error)}`,
|
|
583
|
+
{ cause: pushError ?? error }
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (current !== candidate) {
|
|
587
|
+
if (pushError && current === observed) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`Git publication failed without updating the owned branch: ${this.safeError(pushError)}`,
|
|
590
|
+
{
|
|
591
|
+
cause: pushError
|
|
592
|
+
}
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
throw new SyncBackendConflictError(
|
|
596
|
+
pushError ? `Git publication lease was rejected: ${this.safeError(pushError)}` : "Git remote changed immediately after publication.",
|
|
597
|
+
{
|
|
598
|
+
phase: "after-commit",
|
|
599
|
+
currentHead: current ? await this.headForSha(current) : void 0,
|
|
600
|
+
candidateMayHaveBeenActive: true,
|
|
601
|
+
cause: pushError instanceof Error ? pushError : void 0
|
|
602
|
+
}
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
const head = await this.headForSha(candidate);
|
|
606
|
+
return { head, warnings: [] };
|
|
607
|
+
}
|
|
608
|
+
async listHistory(signal) {
|
|
609
|
+
const sha = await this.fetchRemoteHead(signal);
|
|
610
|
+
if (!sha) return [];
|
|
611
|
+
const result = await this.git(
|
|
612
|
+
["rev-list", "--first-parent", "--reverse", "--max-count=100", sha],
|
|
613
|
+
{ signal }
|
|
614
|
+
);
|
|
615
|
+
const commits = result.stdout.toString("utf8").trim().split("\n").filter(Boolean);
|
|
616
|
+
const entries = [];
|
|
617
|
+
for (const commit of commits) {
|
|
618
|
+
const { manifest } = await this.readPublication(commit, signal);
|
|
619
|
+
entries.push({
|
|
620
|
+
snapshotRef: commit,
|
|
621
|
+
snapshotId: manifest.snapshotId,
|
|
622
|
+
createdAt: manifest.createdAt,
|
|
623
|
+
machine: manifest.machine,
|
|
624
|
+
syncSessions: manifest.syncSessions
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
return entries;
|
|
628
|
+
}
|
|
629
|
+
async diagnose(signal) {
|
|
630
|
+
const diagnostics = [];
|
|
631
|
+
try {
|
|
632
|
+
const version = await runGit(["--version"], {
|
|
633
|
+
signal,
|
|
634
|
+
timeoutMs: this.commandTimeoutMs
|
|
635
|
+
});
|
|
636
|
+
const versionText = version.stdout.toString("utf8").trim();
|
|
637
|
+
const supported = isSupportedGitVersion(versionText);
|
|
638
|
+
diagnostics.push({
|
|
639
|
+
key: "git-version",
|
|
640
|
+
level: supported ? "info" : "error",
|
|
641
|
+
message: supported ? versionText : `${versionText || "unknown Git version"}; pi-sync requires Git 2.30 or newer`
|
|
642
|
+
});
|
|
643
|
+
} catch (error) {
|
|
644
|
+
return [{ key: "git-version", level: "error", message: this.safeError(error) }];
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
const head = await this.readHead(signal);
|
|
648
|
+
if (head) await this.readSnapshot(head.snapshotRef, signal);
|
|
649
|
+
diagnostics.push({
|
|
650
|
+
key: "git-remote",
|
|
651
|
+
level: "info",
|
|
652
|
+
message: head ? `git remote: reachable; owned branch ${this.config.destination.branch} is valid` : `git remote: reachable; owned branch ${this.config.destination.branch} is not created yet`
|
|
653
|
+
});
|
|
654
|
+
diagnostics.push({
|
|
655
|
+
key: "git-cache",
|
|
656
|
+
level: "info",
|
|
657
|
+
message: "git cache: private bare repository is healthy"
|
|
658
|
+
});
|
|
659
|
+
} catch (error) {
|
|
660
|
+
diagnostics.push({
|
|
661
|
+
key: "git-remote",
|
|
662
|
+
level: "error",
|
|
663
|
+
message: `git remote: ${this.safeError(error)}`
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
return diagnostics;
|
|
667
|
+
}
|
|
668
|
+
async resolveSnapshotReference(reference, head, signal) {
|
|
669
|
+
if (isCommitSha(reference) || /^[0-9a-f]{64}$/u.test(reference)) {
|
|
670
|
+
requireCommitSha(reference);
|
|
671
|
+
return reference;
|
|
672
|
+
}
|
|
673
|
+
if (!reference || reference.length > 512 || !/^[A-Za-z0-9._-]+$/u.test(reference)) {
|
|
674
|
+
throw new Error("Invalid Git publication reference.");
|
|
675
|
+
}
|
|
676
|
+
const result = await this.git(["rev-list", "--first-parent", "--max-count=100", head], {
|
|
677
|
+
signal
|
|
678
|
+
});
|
|
679
|
+
const commits = result.stdout.toString("utf8").trim().split("\n").filter(Boolean);
|
|
680
|
+
const matches = [];
|
|
681
|
+
for (const commit of commits) {
|
|
682
|
+
const { manifest } = await this.readPublication(commit, signal);
|
|
683
|
+
if (manifest.snapshotId === reference) matches.push(commit);
|
|
684
|
+
}
|
|
685
|
+
if (matches.length === 0) {
|
|
686
|
+
throw new Error(`Git snapshot publication was not found: ${reference}`);
|
|
687
|
+
}
|
|
688
|
+
if (matches.length > 1) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
`Git snapshot id is ambiguous; use a commit reference from /sync history: ${reference}`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
return matches[0];
|
|
694
|
+
}
|
|
695
|
+
async headForSha(sha, signal) {
|
|
696
|
+
const { manifest } = await this.readPublication(sha, signal);
|
|
697
|
+
return remoteHead(sha, manifest, this.identity);
|
|
698
|
+
}
|
|
699
|
+
async fetchRemoteHead(signal) {
|
|
700
|
+
await this.ensureCache(signal);
|
|
701
|
+
const result = await this.git(
|
|
702
|
+
["ls-remote", "--refs", this.config.profile.remote, this.remoteRef()],
|
|
703
|
+
{ signal }
|
|
704
|
+
);
|
|
705
|
+
const line = result.stdout.toString("utf8").trim();
|
|
706
|
+
if (!line) return void 0;
|
|
707
|
+
const [sha, ref, ...extra] = line.split(/\s+/u);
|
|
708
|
+
if (extra.length > 0 || ref !== this.remoteRef() || !sha) {
|
|
709
|
+
throw new Error("Git remote returned a malformed owned-ref response.");
|
|
710
|
+
}
|
|
711
|
+
requireCommitSha(sha);
|
|
712
|
+
await this.afterLsRemoteForTest?.();
|
|
713
|
+
return withGitCacheMutation(
|
|
714
|
+
this.cacheDir,
|
|
715
|
+
async () => {
|
|
716
|
+
const localRef = `refs/pisync/fetch/${process.pid}-${randomUUID()}`;
|
|
717
|
+
try {
|
|
718
|
+
await this.git(
|
|
719
|
+
[
|
|
720
|
+
"fetch",
|
|
721
|
+
"--no-tags",
|
|
722
|
+
"--force",
|
|
723
|
+
this.config.profile.remote,
|
|
724
|
+
`${this.remoteRef()}:${localRef}`
|
|
725
|
+
],
|
|
726
|
+
{ signal }
|
|
727
|
+
);
|
|
728
|
+
const fetched = (await this.git(["rev-parse", "--verify", localRef], { signal })).stdout.toString("utf8").trim();
|
|
729
|
+
requireCommitSha(fetched);
|
|
730
|
+
return fetched;
|
|
731
|
+
} finally {
|
|
732
|
+
await this.git(["update-ref", "-d", localRef], { timeoutMs: 5e3 }).catch(
|
|
733
|
+
() => void 0
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
signal
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
async readManifest(commit, signal) {
|
|
741
|
+
requireCommitSha(commit);
|
|
742
|
+
const bytes = await this.showFile(commit, this.manifestPath(), signal, MAX_GIT_MANIFEST_BYTES);
|
|
743
|
+
let parsed;
|
|
744
|
+
try {
|
|
745
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
746
|
+
} catch (error) {
|
|
747
|
+
throw new Error("Git publication manifest is malformed.", { cause: error });
|
|
748
|
+
}
|
|
749
|
+
return requireGitManifest(parsed);
|
|
750
|
+
}
|
|
751
|
+
showFile(commit, filePath, signal, maxOutputBytes) {
|
|
752
|
+
return this.git(["show", `${commit}:${filePath}`], { signal, maxOutputBytes }).then(
|
|
753
|
+
(result) => result.stdout
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
async createCommit(snapshot, files, manifest, parent, signal) {
|
|
757
|
+
const manifestBytes = Buffer.from(`${JSON.stringify(manifest)}
|
|
758
|
+
`, "utf8");
|
|
759
|
+
if (manifestBytes.byteLength > MAX_GIT_MANIFEST_BYTES) {
|
|
760
|
+
throw new Error(`Git publication manifest exceeds the ${MAX_GIT_MANIFEST_BYTES}-byte limit.`);
|
|
761
|
+
}
|
|
762
|
+
await this.ensureCache(signal);
|
|
763
|
+
const temporaryDirectory = await fs.mkdtemp(path.join(path.dirname(this.cacheDir), ".index-"));
|
|
764
|
+
const indexPath = path.join(temporaryDirectory, "index");
|
|
765
|
+
const payloadDirectory = path.join(temporaryDirectory, "payloads");
|
|
766
|
+
const env = { GIT_INDEX_FILE: indexPath };
|
|
767
|
+
try {
|
|
768
|
+
await fs.mkdir(payloadDirectory, { mode: 448 });
|
|
769
|
+
const uniqueFiles = [...new Map(files.map((file) => [file.sha256, file])).values()].sort(
|
|
770
|
+
(left, right) => left.sha256.localeCompare(right.sha256)
|
|
771
|
+
);
|
|
772
|
+
for (const file of uniqueFiles) {
|
|
773
|
+
throwIfAborted2(signal);
|
|
774
|
+
await fs.writeFile(path.join(payloadDirectory, file.sha256), file.content, {
|
|
775
|
+
flag: "wx",
|
|
776
|
+
mode: 384
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
await this.afterPayloadWriteForTest?.();
|
|
780
|
+
throwIfAborted2(signal);
|
|
781
|
+
const hashed = await this.git(["hash-object", "-w", "--no-filters", "--stdin-paths"], {
|
|
782
|
+
cwd: payloadDirectory,
|
|
783
|
+
input: uniqueFiles.map((file) => file.sha256).join("\n") + (uniqueFiles.length ? "\n" : ""),
|
|
784
|
+
signal,
|
|
785
|
+
maxOutputBytes: Math.max(1024, uniqueFiles.length * 64)
|
|
786
|
+
});
|
|
787
|
+
const objectIds = hashed.stdout.toString("utf8").trim().split("\n").filter(Boolean);
|
|
788
|
+
if (objectIds.length !== uniqueFiles.length || objectIds.some((id) => !isCommitSha(id))) {
|
|
789
|
+
throw new Error("Git hash-object returned a malformed payload response.");
|
|
790
|
+
}
|
|
791
|
+
const objectsBySha256 = new Map(
|
|
792
|
+
uniqueFiles.map((file, index) => [file.sha256, objectIds[index]])
|
|
793
|
+
);
|
|
794
|
+
const manifestBlob = (await this.git(["hash-object", "-w", "--stdin"], { input: manifestBytes, signal })).stdout.toString("utf8").trim();
|
|
795
|
+
if (!isCommitSha(manifestBlob)) throw new Error("Git returned an invalid manifest blob id.");
|
|
796
|
+
await this.git(["read-tree", "--empty"], { env, signal });
|
|
797
|
+
const indexLines = [
|
|
798
|
+
`100644 ${manifestBlob} ${this.manifestPath()}`,
|
|
799
|
+
...files.map((file) => {
|
|
800
|
+
const object = objectsBySha256.get(file.sha256);
|
|
801
|
+
if (!object) throw new Error("Git payload object is missing after hashing.");
|
|
802
|
+
return `100644 ${object} ${this.filePath(file.path)}`;
|
|
803
|
+
})
|
|
804
|
+
];
|
|
805
|
+
await this.git(["update-index", "-z", "--index-info"], {
|
|
806
|
+
env,
|
|
807
|
+
signal,
|
|
808
|
+
input: Buffer.from(`${indexLines.join("\0")}\0`, "utf8")
|
|
809
|
+
});
|
|
810
|
+
const tree = (await this.git(["write-tree"], { env, signal })).stdout.toString("utf8").trim();
|
|
811
|
+
const date = Number.isNaN(Date.parse(snapshot.createdAt)) ? (/* @__PURE__ */ new Date()).toISOString() : snapshot.createdAt;
|
|
812
|
+
const commit = await this.git(
|
|
813
|
+
["commit-tree", tree, ...parent ? ["-p", parent] : [], "-F", "-"],
|
|
814
|
+
{
|
|
815
|
+
signal,
|
|
816
|
+
input: `pi-sync snapshot ${snapshot.id}
|
|
817
|
+
`,
|
|
818
|
+
env: {
|
|
819
|
+
GIT_AUTHOR_NAME: "pi-sync",
|
|
820
|
+
GIT_AUTHOR_EMAIL: "pi-sync@localhost",
|
|
821
|
+
GIT_COMMITTER_NAME: "pi-sync",
|
|
822
|
+
GIT_COMMITTER_EMAIL: "pi-sync@localhost",
|
|
823
|
+
GIT_AUTHOR_DATE: date,
|
|
824
|
+
GIT_COMMITTER_DATE: date
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
);
|
|
828
|
+
const sha = commit.stdout.toString("utf8").trim();
|
|
829
|
+
requireCommitSha(sha);
|
|
830
|
+
return sha;
|
|
831
|
+
} finally {
|
|
832
|
+
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
ensureCache(signal) {
|
|
836
|
+
if (!this.cacheReady) {
|
|
837
|
+
const operation = withGitCacheMutation(
|
|
838
|
+
this.cacheDir,
|
|
839
|
+
() => this.initializeCache(signal),
|
|
840
|
+
signal
|
|
841
|
+
);
|
|
842
|
+
const wrapped = operation.catch((error) => {
|
|
843
|
+
if (this.cacheReady === wrapped) this.cacheReady = void 0;
|
|
844
|
+
throw this.redactedError(error);
|
|
845
|
+
});
|
|
846
|
+
this.cacheReady = wrapped;
|
|
847
|
+
}
|
|
848
|
+
return this.cacheReady;
|
|
849
|
+
}
|
|
850
|
+
async initializeCache(signal) {
|
|
851
|
+
const version = await runGit(["--version"], {
|
|
852
|
+
signal,
|
|
853
|
+
timeoutMs: this.commandTimeoutMs
|
|
854
|
+
});
|
|
855
|
+
const versionText = version.stdout.toString("utf8").trim();
|
|
856
|
+
if (!isSupportedGitVersion(versionText)) {
|
|
857
|
+
throw new Error(
|
|
858
|
+
`${versionText || "Unknown Git version"}; pi-sync requires Git 2.30 or newer.`
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
const parent = path.dirname(this.cacheDir);
|
|
862
|
+
const cacheParent = path.dirname(this.cacheRoot);
|
|
863
|
+
await assertNotSymlink(cacheParent, "Git cache parent");
|
|
864
|
+
await assertNotSymlink(this.cacheRoot, "Git cache root");
|
|
865
|
+
await fs.mkdir(this.cacheRoot, { recursive: true, mode: 448 });
|
|
866
|
+
await assertNotSymlink(cacheParent, "Git cache parent");
|
|
867
|
+
await assertNotSymlink(this.cacheRoot, "Git cache root");
|
|
868
|
+
await assertNotSymlink(parent, "Git cache identity directory");
|
|
869
|
+
await fs.mkdir(parent, { recursive: true, mode: 448 });
|
|
870
|
+
await assertNotSymlink(parent, "Git cache identity directory");
|
|
871
|
+
let recreate = false;
|
|
872
|
+
try {
|
|
873
|
+
const stat = await fs.lstat(this.cacheDir);
|
|
874
|
+
if (stat.isSymbolicLink()) throw new Error("Refusing symlinked Git cache.");
|
|
875
|
+
if (!stat.isDirectory()) recreate = true;
|
|
876
|
+
else {
|
|
877
|
+
try {
|
|
878
|
+
recreate = !await this.cacheUsesSha1(signal);
|
|
879
|
+
} catch {
|
|
880
|
+
recreate = true;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
} catch (error) {
|
|
884
|
+
if (error.code !== "ENOENT") throw error;
|
|
885
|
+
}
|
|
886
|
+
if (recreate) await fs.rm(this.cacheDir, { recursive: true, force: true });
|
|
887
|
+
try {
|
|
888
|
+
await fs.access(this.cacheDir);
|
|
889
|
+
} catch {
|
|
890
|
+
try {
|
|
891
|
+
await runGit(["init", "--bare", "--object-format=sha1", this.cacheDir], {
|
|
892
|
+
signal,
|
|
893
|
+
timeoutMs: this.commandTimeoutMs,
|
|
894
|
+
allowFileProtocol: this.allowLocalRemotes
|
|
895
|
+
});
|
|
896
|
+
} catch (initError) {
|
|
897
|
+
const concurrent = await this.cacheUsesSha1(signal).catch(() => false);
|
|
898
|
+
if (!concurrent) throw initError;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (process.platform !== "win32") await fs.chmod(parent, 448);
|
|
902
|
+
}
|
|
903
|
+
async cacheUsesSha1(signal) {
|
|
904
|
+
const result = await this.git(["rev-parse", "--is-bare-repository", "--show-object-format"], {
|
|
905
|
+
signal
|
|
906
|
+
});
|
|
907
|
+
return result.stdout.toString("utf8").trim() === "true\nsha1";
|
|
908
|
+
}
|
|
909
|
+
git(args, options = {}) {
|
|
910
|
+
return runGit(args, {
|
|
911
|
+
gitDir: this.cacheDir,
|
|
912
|
+
allowFileProtocol: this.allowLocalRemotes,
|
|
913
|
+
timeoutMs: options.timeoutMs ?? this.commandTimeoutMs,
|
|
914
|
+
...options
|
|
915
|
+
}).catch((error) => {
|
|
916
|
+
throw this.redactedError(error);
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
remoteRef() {
|
|
920
|
+
return `refs/heads/${this.config.destination.branch}`;
|
|
921
|
+
}
|
|
922
|
+
publicationPath() {
|
|
923
|
+
return this.config.destination.directory;
|
|
924
|
+
}
|
|
925
|
+
manifestPath() {
|
|
926
|
+
return posixJoin(this.publicationPath(), "manifest.json");
|
|
927
|
+
}
|
|
928
|
+
filePath(filePath) {
|
|
929
|
+
return posixJoin(this.publicationPath(), "files", filePath);
|
|
930
|
+
}
|
|
931
|
+
async readPublication(commit, signal) {
|
|
932
|
+
const manifest = await this.readManifest(commit, signal);
|
|
933
|
+
const entries = await this.readPublicationTree(commit, signal);
|
|
934
|
+
const payloadEntries = validateGitPublicationTree(
|
|
935
|
+
entries,
|
|
936
|
+
manifest,
|
|
937
|
+
this.manifestPath(),
|
|
938
|
+
(filePath) => this.filePath(filePath)
|
|
939
|
+
);
|
|
940
|
+
return { manifest, payloadEntries };
|
|
941
|
+
}
|
|
942
|
+
async readPublicationTree(commit, signal) {
|
|
943
|
+
const result = await this.git(["ls-tree", "-r", "-z", commit], {
|
|
944
|
+
signal,
|
|
945
|
+
maxOutputBytes: MAX_GIT_TREE_OUTPUT_BYTES
|
|
946
|
+
});
|
|
947
|
+
return parseGitTree(result.stdout);
|
|
948
|
+
}
|
|
949
|
+
redactedError(error) {
|
|
950
|
+
if (error instanceof Error && error.name === "AbortError") return error;
|
|
951
|
+
return new Error(this.safeError(error));
|
|
952
|
+
}
|
|
953
|
+
safeError(error) {
|
|
954
|
+
const raw = error instanceof GitCommandError ? error.stderr || error.message : error instanceof Error ? error.message : String(error);
|
|
955
|
+
return redactGitError(raw, this.config.profile.remote, this.cacheDir);
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
function gitBackendIdentity(config) {
|
|
959
|
+
let remoteIdentity;
|
|
960
|
+
try {
|
|
961
|
+
remoteIdentity = normalizeGitRemoteIdentity(config.profile.remote);
|
|
962
|
+
} catch {
|
|
963
|
+
remoteIdentity = config.profile.remote;
|
|
964
|
+
}
|
|
965
|
+
const canonical = JSON.stringify([
|
|
966
|
+
remoteIdentity,
|
|
967
|
+
config.destination.branch,
|
|
968
|
+
config.destination.directory
|
|
969
|
+
]);
|
|
970
|
+
return `git:${sha2562(Buffer.from(canonical))}`;
|
|
971
|
+
}
|
|
972
|
+
function gitDestination(config) {
|
|
973
|
+
let host = "Git remote";
|
|
974
|
+
const remote = config.profile.remote;
|
|
975
|
+
if (remote.includes("://")) {
|
|
976
|
+
try {
|
|
977
|
+
host = new URL(remote).host;
|
|
978
|
+
} catch {
|
|
979
|
+
host = "Git remote";
|
|
980
|
+
}
|
|
981
|
+
} else {
|
|
982
|
+
const match = /^(?:[^@]+@)?(?<host>\[[^\]]+\]|[^:]+):/u.exec(remote);
|
|
983
|
+
if (match?.groups?.host) host = match.groups.host;
|
|
984
|
+
}
|
|
985
|
+
return `${host} \xB7 ${config.destination.branch}:${config.destination.directory}`;
|
|
986
|
+
}
|
|
987
|
+
function remoteHead(sha, manifest, identity) {
|
|
988
|
+
return {
|
|
989
|
+
snapshotRef: sha,
|
|
990
|
+
snapshotId: manifest.snapshotId,
|
|
991
|
+
revision: `${identity}:${sha}`,
|
|
992
|
+
createdAt: manifest.createdAt,
|
|
993
|
+
machine: manifest.machine,
|
|
994
|
+
syncSessions: manifest.syncSessions,
|
|
995
|
+
...manifest.selection === void 0 ? {} : { selection: manifest.selection }
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
function matchesExpected(current, expected, identity) {
|
|
999
|
+
if (expected.kind === "missing") return current === void 0;
|
|
1000
|
+
try {
|
|
1001
|
+
return current === decodeRevision(expected.revision, identity);
|
|
1002
|
+
} catch {
|
|
1003
|
+
return false;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
function decodeRevision(revision, identity) {
|
|
1007
|
+
const prefix = `${identity}:`;
|
|
1008
|
+
const sha = revision.startsWith(prefix) ? revision.slice(prefix.length) : "";
|
|
1009
|
+
if (!/^[0-9a-f]{40}$/u.test(sha)) throw new Error("Invalid Git remote revision.");
|
|
1010
|
+
return sha;
|
|
1011
|
+
}
|
|
1012
|
+
function isCommitSha(value) {
|
|
1013
|
+
return /^[0-9a-f]{40}$/u.test(value);
|
|
1014
|
+
}
|
|
1015
|
+
function requireCommitSha(value) {
|
|
1016
|
+
if (/^[0-9a-f]{64}$/u.test(value)) {
|
|
1017
|
+
throw new Error("Unsupported Git SHA-256 repository; pi-sync currently requires SHA-1 refs.");
|
|
1018
|
+
}
|
|
1019
|
+
if (!/^[0-9a-f]{40}$/u.test(value)) throw new Error("Invalid Git publication reference.");
|
|
1020
|
+
}
|
|
1021
|
+
function isSupportedGitVersion(value) {
|
|
1022
|
+
const match = /git version (\d+)\.(\d+)/u.exec(value);
|
|
1023
|
+
if (!match) return false;
|
|
1024
|
+
const major = Number(match[1]);
|
|
1025
|
+
const minor = Number(match[2]);
|
|
1026
|
+
return major > 2 || major === 2 && minor >= 30;
|
|
1027
|
+
}
|
|
1028
|
+
function assertGitDestination(config) {
|
|
1029
|
+
try {
|
|
1030
|
+
if (normalizeGitBranch(config.destination.branch) !== config.destination.branch || normalizeGitDirectory(config.destination.directory) !== config.destination.directory) {
|
|
1031
|
+
throw new Error("Git storage location is not normalized.");
|
|
1032
|
+
}
|
|
1033
|
+
validateGitNamespace(config.destination.namespace);
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
throw new Error("Invalid Git storage location.", { cause: error });
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
function assertProductionRemote(remote) {
|
|
1039
|
+
let normalized;
|
|
1040
|
+
try {
|
|
1041
|
+
normalized = normalizeGitRemote(remote);
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
throw new Error(error instanceof Error ? error.message : "Invalid Git remote.", {
|
|
1044
|
+
cause: error
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
if (!normalized || normalized !== remote)
|
|
1048
|
+
throw new Error("Invalid or non-normalized Git remote.");
|
|
1049
|
+
}
|
|
1050
|
+
async function withGitCacheMutation(cacheDir, run, signal) {
|
|
1051
|
+
const previous = gitCacheMutationQueues.get(cacheDir) ?? Promise.resolve();
|
|
1052
|
+
const operation = previous.catch(() => void 0).then(() => {
|
|
1053
|
+
throwIfAborted2(signal);
|
|
1054
|
+
return run();
|
|
1055
|
+
});
|
|
1056
|
+
const tail = operation.then(
|
|
1057
|
+
() => void 0,
|
|
1058
|
+
() => void 0
|
|
1059
|
+
);
|
|
1060
|
+
gitCacheMutationQueues.set(cacheDir, tail);
|
|
1061
|
+
void tail.then(() => {
|
|
1062
|
+
if (gitCacheMutationQueues.get(cacheDir) === tail) gitCacheMutationQueues.delete(cacheDir);
|
|
1063
|
+
});
|
|
1064
|
+
if (!signal) return operation;
|
|
1065
|
+
throwIfAborted2(signal);
|
|
1066
|
+
let rejectAbort;
|
|
1067
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
1068
|
+
rejectAbort = reject;
|
|
1069
|
+
});
|
|
1070
|
+
const onAbort = () => rejectAbort?.(abortReason(signal));
|
|
1071
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1072
|
+
if (signal.aborted) onAbort();
|
|
1073
|
+
try {
|
|
1074
|
+
return await Promise.race([operation, aborted]);
|
|
1075
|
+
} finally {
|
|
1076
|
+
signal.removeEventListener("abort", onAbort);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
async function assertNotSymlink(target, label) {
|
|
1080
|
+
try {
|
|
1081
|
+
const stat = await fs.lstat(target);
|
|
1082
|
+
if (stat.isSymbolicLink()) throw new Error(`Refusing symlinked ${label}.`);
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
if (error.code !== "ENOENT") throw error;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
function redactGitError(value, remote, cacheDir) {
|
|
1088
|
+
return value.replaceAll(remote, "<git-remote>").replaceAll(cacheDir, "<git-cache>").replace(/https:\/\/[^/@\s]+@/gu, "https://<credentials>@").replace(/\b(password|token|authorization)=\S+/giu, "$1=<redacted>").replace(/\bBearer\s+\S+/giu, "Bearer <redacted>").replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ").trim().slice(0, 4096);
|
|
1089
|
+
}
|
|
1090
|
+
function sha2562(value) {
|
|
1091
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
1092
|
+
}
|
|
1093
|
+
function abortReason(signal) {
|
|
1094
|
+
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
1095
|
+
}
|
|
1096
|
+
function throwIfAborted2(signal) {
|
|
1097
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
1098
|
+
}
|
|
1099
|
+
export {
|
|
1100
|
+
GitSyncBackend,
|
|
1101
|
+
gitBackendIdentity,
|
|
1102
|
+
isSupportedGitVersion
|
|
1103
|
+
};
|
|
1104
|
+
//# sourceMappingURL=git-backend-E3UOUUOM.ts.map
|