@narumitw/pi-sync 0.30.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ const DEFAULT_GIT_BRANCH = "pi-sync";
2
+ const DEFAULT_GIT_DIRECTORY = "pi-sync";
3
+
4
+ export function normalizeGitRemote(value: string | undefined) {
5
+ const normalized = normalizeOptionalString(value);
6
+ if (!normalized) return undefined;
7
+ if (
8
+ hasControlCharacter(normalized) ||
9
+ /\s/u.test(normalized) ||
10
+ normalized.startsWith("-") ||
11
+ normalized.includes("\\")
12
+ ) {
13
+ throw new Error("Invalid pi-sync Git remote.");
14
+ }
15
+ if (
16
+ !normalized.includes("://") &&
17
+ /^(?:[A-Za-z0-9._-]+@)?(?:\[[0-9A-Fa-f:]+\]|[A-Za-z0-9.-]+):(?!-)[^:].+$/u.test(normalized)
18
+ ) {
19
+ return normalized;
20
+ }
21
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(normalized) && !normalized.includes("://")) {
22
+ throw new Error("Invalid pi-sync Git remote: unsupported transport or remote-helper syntax.");
23
+ }
24
+ let url: URL;
25
+ try {
26
+ url = new URL(normalized);
27
+ } catch {
28
+ throw new Error("Invalid pi-sync Git remote: use an SSH or HTTPS remote.");
29
+ }
30
+ if (url.protocol !== "https:" && url.protocol !== "ssh:") {
31
+ throw new Error("Invalid pi-sync Git remote: only SSH and HTTPS are supported.");
32
+ }
33
+ if (url.password || (url.protocol === "https:" && url.username)) {
34
+ throw new Error("Invalid pi-sync Git remote: URL credentials or userinfo are not allowed.");
35
+ }
36
+ if (url.search || url.hash || !url.hostname || !url.pathname || url.pathname === "/") {
37
+ throw new Error("Invalid pi-sync Git remote.");
38
+ }
39
+ return url.toString();
40
+ }
41
+
42
+ export function normalizeGitRemoteIdentity(value: string) {
43
+ const normalized = normalizeGitRemote(value);
44
+ if (!normalized) return "";
45
+ if (!normalized.includes("://")) {
46
+ const match =
47
+ /^(?<user>[A-Za-z0-9._-]+@)?(?<host>\[[0-9A-Fa-f:]+\]|[A-Za-z0-9.-]+):(?<path>.+)$/u.exec(
48
+ normalized,
49
+ );
50
+ if (!match?.groups) return normalized;
51
+ const userAndHost = `${match.groups.user ?? ""}${match.groups.host.toLowerCase()}`;
52
+ const remotePath = match.groups.path.replace(/\/+$/gu, "");
53
+ return remotePath.startsWith("/")
54
+ ? `scp-absolute://${userAndHost}${remotePath}`
55
+ : `ssh://${userAndHost}/${remotePath}`;
56
+ }
57
+ const url = new URL(normalized);
58
+ url.hostname = url.hostname.toLowerCase();
59
+ if (url.protocol === "ssh:" && url.port === "22") url.port = "";
60
+ url.pathname = url.pathname.replace(/\/+$/gu, "");
61
+ if (url.protocol === "ssh:") {
62
+ return `ssh://${url.username ? `${url.username}@` : ""}${url.host}${url.pathname}`;
63
+ }
64
+ return url.toString();
65
+ }
66
+
67
+ export function normalizeGitBranch(value: string | undefined) {
68
+ const branch = normalizeOptionalString(value) ?? DEFAULT_GIT_BRANCH;
69
+ if (
70
+ branch === "@" ||
71
+ branch.startsWith("-") ||
72
+ branch.startsWith("refs/") ||
73
+ branch.startsWith("/") ||
74
+ branch.endsWith("/") ||
75
+ branch.endsWith(".") ||
76
+ branch.includes("..") ||
77
+ branch.includes("@{") ||
78
+ branch.includes("\\") ||
79
+ hasControlCharacter(branch) ||
80
+ /[ ~^:?*[\]]/u.test(branch) ||
81
+ branch
82
+ .split("/")
83
+ .some((segment) => !segment || segment.startsWith(".") || segment.endsWith(".lock"))
84
+ ) {
85
+ throw new Error("Invalid pi-sync Git branch.");
86
+ }
87
+ return branch;
88
+ }
89
+
90
+ export function normalizeGitDirectory(value: string | undefined) {
91
+ const directory = trimSlashes(normalizeOptionalString(value) ?? DEFAULT_GIT_DIRECTORY);
92
+ if (
93
+ !directory ||
94
+ directory.startsWith("-") ||
95
+ directory.includes("\\") ||
96
+ hasControlCharacter(directory) ||
97
+ directory
98
+ .split("/")
99
+ .some((segment) => !segment || segment === "." || segment === ".." || segment === ".git")
100
+ ) {
101
+ throw new Error("Invalid pi-sync Git directory.");
102
+ }
103
+ return directory;
104
+ }
105
+
106
+ export function validateGitNamespace(value: string) {
107
+ if (
108
+ value.length > 256 ||
109
+ value === "." ||
110
+ value === ".." ||
111
+ value.includes("/") ||
112
+ value.includes("\\") ||
113
+ hasControlCharacter(value)
114
+ ) {
115
+ throw new Error("Invalid pi-sync Git namespace.");
116
+ }
117
+ }
118
+
119
+ function normalizeOptionalString(value: unknown) {
120
+ if (value !== undefined && typeof value !== "string") {
121
+ throw new Error("Invalid pi-sync settings: expected a string.");
122
+ }
123
+ const normalized = value?.trim();
124
+ return normalized || undefined;
125
+ }
126
+
127
+ function trimSlashes(value: string) {
128
+ return value.replace(/^\/+|\/+$/gu, "");
129
+ }
130
+
131
+ function hasControlCharacter(value: string) {
132
+ return [...value].some((character) => {
133
+ const code = character.codePointAt(0) ?? 0;
134
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f);
135
+ });
136
+ }
@@ -0,0 +1,276 @@
1
+ import { spawn } from "node:child_process";
2
+ import process from "node:process";
3
+
4
+ const DEFAULT_TIMEOUT_MS = 30_000;
5
+ const DEFAULT_OUTPUT_LIMIT = 1024 * 1024;
6
+
7
+ export interface GitRunOptions {
8
+ gitDir?: string;
9
+ cwd?: string;
10
+ input?: Buffer | string;
11
+ env?: NodeJS.ProcessEnv;
12
+ signal?: AbortSignal;
13
+ timeoutMs?: number;
14
+ maxOutputBytes?: number;
15
+ allowFileProtocol?: boolean;
16
+ }
17
+
18
+ export interface GitRunResult {
19
+ stdout: Buffer;
20
+ stderr: Buffer;
21
+ }
22
+
23
+ export class GitCommandError extends Error {
24
+ readonly code = "GIT_COMMAND_FAILED";
25
+ constructor(
26
+ message: string,
27
+ readonly exitCode: number | null,
28
+ readonly stderr: string,
29
+ options?: ErrorOptions,
30
+ ) {
31
+ super(message, options);
32
+ this.name = "GitCommandError";
33
+ }
34
+ }
35
+
36
+ export async function runGit(args: string[], options: GitRunOptions = {}): Promise<GitRunResult> {
37
+ throwIfAborted(options.signal);
38
+ const hooksPath = process.platform === "win32" ? "NUL" : "/dev/null";
39
+ const protocolArgs = [
40
+ "-c",
41
+ `core.hooksPath=${hooksPath}`,
42
+ "-c",
43
+ "gc.auto=0",
44
+ "-c",
45
+ "maintenance.auto=false",
46
+ "-c",
47
+ "protocol.allow=never",
48
+ "-c",
49
+ "protocol.https.allow=always",
50
+ "-c",
51
+ "protocol.ssh.allow=always",
52
+ ];
53
+ if (options.allowFileProtocol) protocolArgs.push("-c", "protocol.file.allow=always");
54
+ const commandArgs = [
55
+ ...(options.gitDir ? [`--git-dir=${options.gitDir}`] : []),
56
+ ...protocolArgs,
57
+ ...args,
58
+ ];
59
+ const inheritedEnvironment = Object.fromEntries(
60
+ Object.entries(process.env).filter(
61
+ ([key]) =>
62
+ !key.startsWith("GIT_") &&
63
+ key !== "PAGER" &&
64
+ key !== "EDITOR" &&
65
+ key !== "VISUAL" &&
66
+ key !== "SSH_ASKPASS" &&
67
+ key !== "SSH_ASKPASS_REQUIRE",
68
+ ),
69
+ );
70
+ const allowedGitOverrides = 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: NodeJS.ProcessEnv = {
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: process.platform !== "win32",
107
+ windowsHide: true,
108
+ });
109
+ const stdout: Buffer[] = [];
110
+ const stderr: Buffer[] = [];
111
+ let total = 0;
112
+ let settled = false;
113
+ let terminationError: Error | undefined;
114
+ let escalationTimer: NodeJS.Timeout | undefined;
115
+ const limit = options.maxOutputBytes ?? DEFAULT_OUTPUT_LIMIT;
116
+
117
+ const terminate = (error: Error) => {
118
+ if (settled || terminationError) return;
119
+ terminationError = error;
120
+ if (child.pid && process.platform !== "win32") {
121
+ try {
122
+ process.kill(-child.pid, "SIGTERM");
123
+ } catch {
124
+ child.kill("SIGTERM");
125
+ }
126
+ } else {
127
+ child.kill("SIGTERM");
128
+ if (child.pid && process.platform === "win32") {
129
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
130
+ stdio: "ignore",
131
+ windowsHide: true,
132
+ });
133
+ killer.on("error", () => undefined);
134
+ killer.unref();
135
+ }
136
+ }
137
+ escalationTimer = setTimeout(() => {
138
+ if (settled) return;
139
+ if (child.pid && process.platform !== "win32") {
140
+ try {
141
+ process.kill(-child.pid, "SIGKILL");
142
+ } catch {
143
+ child.kill("SIGKILL");
144
+ }
145
+ } else {
146
+ child.kill("SIGKILL");
147
+ }
148
+ }, 2_000);
149
+ };
150
+ const collect = (target: Buffer[]) => (chunk: Buffer) => {
151
+ total += chunk.byteLength;
152
+ if (total > limit) {
153
+ terminate(new Error(`Git output exceeds the ${limit}-byte limit.`));
154
+ return;
155
+ }
156
+ target.push(Buffer.from(chunk));
157
+ };
158
+ child.stdout.on("data", collect(stdout));
159
+ child.stderr.on("data", collect(stderr));
160
+ child.stdin.on("error", () => undefined);
161
+ child.stdin.end(options.input);
162
+ const onAbort = () =>
163
+ terminate(
164
+ options.signal?.reason instanceof Error
165
+ ? options.signal.reason
166
+ : new DOMException("The operation was aborted", "AbortError"),
167
+ );
168
+ options.signal?.addEventListener("abort", onAbort, { once: true });
169
+ const timer = setTimeout(
170
+ () =>
171
+ terminate(
172
+ new Error(`Git command timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms.`),
173
+ ),
174
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
175
+ );
176
+
177
+ let result: GitRunResult;
178
+ try {
179
+ result = await new Promise<GitRunResult>((resolve, reject) => {
180
+ child.once("error", reject);
181
+ child.once("close", (code) => {
182
+ settled = true;
183
+ if (escalationTimer) clearTimeout(escalationTimer);
184
+ const stdoutBuffer = Buffer.concat(stdout);
185
+ const stderrBuffer = Buffer.concat(stderr);
186
+ if (terminationError) {
187
+ reject(terminationError);
188
+ return;
189
+ }
190
+ if (code !== 0) {
191
+ const stderrText = stderrBuffer.toString("utf8").trim();
192
+ reject(
193
+ new GitCommandError(
194
+ stderrText || `Git exited with status ${code ?? "unknown"}.`,
195
+ code,
196
+ stderrText,
197
+ ),
198
+ );
199
+ return;
200
+ }
201
+ resolve({ stdout: stdoutBuffer, stderr: stderrBuffer });
202
+ });
203
+ });
204
+ } finally {
205
+ clearTimeout(timer);
206
+ if (escalationTimer) clearTimeout(escalationTimer);
207
+ options.signal?.removeEventListener("abort", onAbort);
208
+ }
209
+ return result;
210
+ }
211
+
212
+ export function parseGitBlobBatch(output: Buffer, expectedCount: number, maxContentBytes: number) {
213
+ if (!Number.isSafeInteger(expectedCount) || expectedCount < 0) {
214
+ throw new Error("Invalid Git batch object count.");
215
+ }
216
+ if (!Number.isSafeInteger(maxContentBytes) || maxContentBytes < 0) {
217
+ throw new Error("Invalid Git batch content limit.");
218
+ }
219
+ const blobs: Buffer[] = [];
220
+ let offset = 0;
221
+ let contentBytes = 0;
222
+ for (let index = 0; index < expectedCount; index += 1) {
223
+ const headerEnd = output.indexOf(0x0a, offset);
224
+ if (headerEnd < 0) throw new Error("Git cat-file batch response is truncated.");
225
+ const header = output.subarray(offset, headerEnd).toString("utf8");
226
+ if (header.endsWith(" missing")) throw new Error("Git cat-file batch object is missing.");
227
+ const match = /^(?<object>[0-9a-f]{40}) blob (?<size>0|[1-9][0-9]*)$/u.exec(header);
228
+ if (!match?.groups) throw new Error("Git cat-file batch response is malformed.");
229
+ const size = Number(match.groups.size);
230
+ if (!Number.isSafeInteger(size)) throw new Error("Git cat-file batch size is malformed.");
231
+ contentBytes += size;
232
+ if (contentBytes > maxContentBytes) {
233
+ throw new Error(`Git cat-file batch content exceeds the ${maxContentBytes}-byte limit.`);
234
+ }
235
+ const contentStart = headerEnd + 1;
236
+ const contentEnd = contentStart + size;
237
+ if (contentEnd >= output.length) throw new Error("Git cat-file batch response is truncated.");
238
+ if (output[contentEnd] !== 0x0a) {
239
+ throw new Error("Git cat-file batch response is malformed.");
240
+ }
241
+ blobs.push(Buffer.from(output.subarray(contentStart, contentEnd)));
242
+ offset = contentEnd + 1;
243
+ }
244
+ if (offset !== output.length) throw new Error("Git cat-file batch response has trailing data.");
245
+ return blobs;
246
+ }
247
+
248
+ export async function readGitBlobs(
249
+ objects: string[],
250
+ options: Omit<GitRunOptions, "input" | "maxOutputBytes"> & { maxOutputBytes: number },
251
+ ) {
252
+ if (objects.length === 0) return [];
253
+ if (!Number.isSafeInteger(options.maxOutputBytes) || options.maxOutputBytes < 0) {
254
+ throw new Error("Invalid Git batch output limit.");
255
+ }
256
+ if (objects.some((object) => !/^[0-9a-f]{40}$/u.test(object))) {
257
+ throw new Error("Invalid Git blob object id.");
258
+ }
259
+ const protocolOverhead = objects.length * 96;
260
+ if (!Number.isSafeInteger(protocolOverhead + options.maxOutputBytes)) {
261
+ throw new Error("Invalid Git batch output limit.");
262
+ }
263
+ const result = await runGit(["cat-file", "--batch"], {
264
+ ...options,
265
+ input: `${objects.join("\n")}\n`,
266
+ maxOutputBytes: options.maxOutputBytes + protocolOverhead,
267
+ });
268
+ return parseGitBlobBatch(result.stdout, objects.length, options.maxOutputBytes);
269
+ }
270
+
271
+ function throwIfAborted(signal?: AbortSignal) {
272
+ if (!signal?.aborted) return;
273
+ throw signal.reason instanceof Error
274
+ ? signal.reason
275
+ : new DOMException("The operation was aborted", "AbortError");
276
+ }
@@ -0,0 +1,288 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Snapshot } from "./types.js";
3
+
4
+ export const GIT_MANIFEST_VERSION = 2;
5
+ export const MAX_GIT_MANIFEST_BYTES = 1024 * 1024;
6
+ export const MAX_GIT_TREE_OUTPUT_BYTES = 16 * 1024 * 1024;
7
+ export const MAX_GIT_PAYLOAD_BYTES = 100 * 1024 * 1024;
8
+ export const MAX_GIT_SNAPSHOT_BYTES = 512 * 1024 * 1024;
9
+ const SNAPSHOT_VERSION = 1;
10
+
11
+ export interface GitManifestFile {
12
+ path: string;
13
+ sha256: string;
14
+ size: number;
15
+ }
16
+
17
+ export interface GitManifest {
18
+ version: number;
19
+ snapshotVersion: number;
20
+ snapshotId: string;
21
+ createdAt: string;
22
+ machine: string;
23
+ profile: string;
24
+ syncSessions: boolean;
25
+ snapshotSyncSessions?: boolean;
26
+ files: GitManifestFile[];
27
+ }
28
+
29
+ export interface PreparedGitFile extends GitManifestFile {
30
+ content: Buffer;
31
+ }
32
+
33
+ export interface GitTreeEntry {
34
+ mode: string;
35
+ type: string;
36
+ object: string;
37
+ path: string;
38
+ }
39
+
40
+ export function isGitPayloadSizeAllowed(size: number) {
41
+ return Number.isSafeInteger(size) && size >= 0 && size <= MAX_GIT_PAYLOAD_BYTES;
42
+ }
43
+
44
+ export function requireGitManifest(value: unknown): GitManifest {
45
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
46
+ throw new Error("Git publication manifest is malformed.");
47
+ }
48
+ const manifest = value as Partial<GitManifest>;
49
+ if (manifest.version === 1) {
50
+ throw new Error(
51
+ "Git publication uses the unsupported pre-release gzip format; recreate this pi-sync-owned test branch.",
52
+ );
53
+ }
54
+ if (
55
+ manifest.version !== GIT_MANIFEST_VERSION ||
56
+ manifest.snapshotVersion !== SNAPSHOT_VERSION ||
57
+ typeof manifest.snapshotId !== "string" ||
58
+ manifest.snapshotId.length > 512 ||
59
+ !/^[A-Za-z0-9._-]+$/u.test(manifest.snapshotId) ||
60
+ typeof manifest.createdAt !== "string" ||
61
+ manifest.createdAt.length > 64 ||
62
+ hasControlCharacter(manifest.createdAt) ||
63
+ Number.isNaN(Date.parse(manifest.createdAt)) ||
64
+ typeof manifest.machine !== "string" ||
65
+ manifest.machine.length > 256 ||
66
+ hasControlCharacter(manifest.machine) ||
67
+ typeof manifest.profile !== "string" ||
68
+ manifest.profile.length === 0 ||
69
+ manifest.profile.length > 256 ||
70
+ hasControlCharacter(manifest.profile) ||
71
+ typeof manifest.syncSessions !== "boolean" ||
72
+ (manifest.snapshotSyncSessions !== undefined &&
73
+ typeof manifest.snapshotSyncSessions !== "boolean") ||
74
+ !Array.isArray(manifest.files) ||
75
+ !hasExactKeys(manifest as Record<string, unknown>, [
76
+ "version",
77
+ "snapshotVersion",
78
+ "snapshotId",
79
+ "createdAt",
80
+ "machine",
81
+ "profile",
82
+ "syncSessions",
83
+ ...(manifest.snapshotSyncSessions === undefined ? [] : ["snapshotSyncSessions"]),
84
+ "files",
85
+ ])
86
+ ) {
87
+ throw new Error("Git publication manifest is malformed.");
88
+ }
89
+ let total = 0;
90
+ const paths = new Set<string>();
91
+ for (const rawFile of manifest.files) {
92
+ if (!rawFile || typeof rawFile !== "object" || Array.isArray(rawFile)) {
93
+ throw new Error("Git publication manifest file is malformed.");
94
+ }
95
+ const file = rawFile as Partial<GitManifestFile>;
96
+ if (
97
+ !hasExactKeys(file as Record<string, unknown>, ["path", "sha256", "size"]) ||
98
+ !isSafeSnapshotPath(file.path) ||
99
+ typeof file.sha256 !== "string" ||
100
+ !/^[0-9a-f]{64}$/u.test(file.sha256) ||
101
+ typeof file.size !== "number" ||
102
+ !isGitPayloadSizeAllowed(file.size) ||
103
+ paths.has(file.path)
104
+ ) {
105
+ throw new Error("Git publication manifest file is malformed.");
106
+ }
107
+ total += file.size;
108
+ if (!Number.isSafeInteger(total) || total > MAX_GIT_SNAPSHOT_BYTES) {
109
+ throw new Error(`Git snapshot content exceeds the ${MAX_GIT_SNAPSHOT_BYTES}-byte limit.`);
110
+ }
111
+ paths.add(file.path);
112
+ }
113
+ assertNoPathConflicts([...paths]);
114
+ return manifest as GitManifest;
115
+ }
116
+
117
+ export function validateGitSnapshot(snapshot: Snapshot, manifest: GitManifest, namespace: string) {
118
+ const prepared = prepareGitSnapshot(snapshot, namespace);
119
+ const syncSessions =
120
+ snapshot.syncSessions === true ||
121
+ snapshot.files.some((file) => file.path.startsWith("sessions/"));
122
+ if (
123
+ snapshot.id !== manifest.snapshotId ||
124
+ snapshot.createdAt !== manifest.createdAt ||
125
+ snapshot.machine !== manifest.machine ||
126
+ snapshot.profile !== manifest.profile ||
127
+ snapshot.syncSessions !== manifest.snapshotSyncSessions ||
128
+ syncSessions !== manifest.syncSessions ||
129
+ prepared.length !== manifest.files.length ||
130
+ prepared.some((file, index) => {
131
+ const expected = manifest.files[index];
132
+ return (
133
+ !expected ||
134
+ file.path !== expected.path ||
135
+ file.sha256 !== expected.sha256 ||
136
+ file.size !== expected.size
137
+ );
138
+ })
139
+ ) {
140
+ throw new Error("Git snapshot identity does not match its publication manifest.");
141
+ }
142
+ }
143
+
144
+ export function prepareGitSnapshot(snapshot: Snapshot, namespace: string) {
145
+ if (
146
+ snapshot.version !== SNAPSHOT_VERSION ||
147
+ typeof snapshot.id !== "string" ||
148
+ !snapshot.id ||
149
+ snapshot.id.length > 512 ||
150
+ !/^[A-Za-z0-9._-]+$/u.test(snapshot.id) ||
151
+ snapshot.profile !== namespace ||
152
+ !Array.isArray(snapshot.files) ||
153
+ typeof snapshot.createdAt !== "string" ||
154
+ !snapshot.createdAt ||
155
+ snapshot.createdAt.length > 64 ||
156
+ hasControlCharacter(snapshot.createdAt) ||
157
+ Number.isNaN(Date.parse(snapshot.createdAt)) ||
158
+ typeof snapshot.machine !== "string" ||
159
+ snapshot.machine.length > 256 ||
160
+ hasControlCharacter(snapshot.machine)
161
+ ) {
162
+ throw new Error("Invalid Git snapshot publication.");
163
+ }
164
+ const paths = new Set<string>();
165
+ const prepared: PreparedGitFile[] = [];
166
+ let total = 0;
167
+ for (const file of snapshot.files) {
168
+ if (
169
+ !isSafeSnapshotPath(file.path) ||
170
+ typeof file.contentBase64 !== "string" ||
171
+ typeof file.sha256 !== "string" ||
172
+ !/^[0-9a-f]{64}$/u.test(file.sha256) ||
173
+ paths.has(file.path)
174
+ ) {
175
+ throw new Error("Invalid Git snapshot file.");
176
+ }
177
+ const content = Buffer.from(file.contentBase64, "base64");
178
+ if (content.toString("base64") !== file.contentBase64 || sha256(content) !== file.sha256) {
179
+ throw new Error("Git snapshot file checksum mismatch.");
180
+ }
181
+ if (!isGitPayloadSizeAllowed(content.byteLength)) {
182
+ throw new Error(
183
+ `Git snapshot file exceeds GitHub's ${MAX_GIT_PAYLOAD_BYTES}-byte regular-Git limit: ${file.path}`,
184
+ );
185
+ }
186
+ total += content.byteLength;
187
+ if (!Number.isSafeInteger(total) || total > MAX_GIT_SNAPSHOT_BYTES) {
188
+ throw new Error(`Git snapshot content exceeds the ${MAX_GIT_SNAPSHOT_BYTES}-byte limit.`);
189
+ }
190
+ paths.add(file.path);
191
+ prepared.push({ path: file.path, sha256: file.sha256, size: content.byteLength, content });
192
+ }
193
+ assertNoPathConflicts([...paths]);
194
+ return prepared;
195
+ }
196
+
197
+ export function parseGitTree(output: Buffer): GitTreeEntry[] {
198
+ if (output.byteLength === 0) return [];
199
+ if (output.at(-1) !== 0) throw new Error("Git publication tree response is malformed.");
200
+ return output
201
+ .subarray(0, -1)
202
+ .toString("utf8")
203
+ .split("\0")
204
+ .map((line) => {
205
+ const match =
206
+ /^(?<mode>[0-9]{6}) (?<type>blob|tree|commit) (?<object>[0-9a-f]{40})\t(?<path>.+)$/u.exec(
207
+ line,
208
+ );
209
+ if (!match?.groups || hasControlCharacter(match.groups.path)) {
210
+ throw new Error("Git publication tree response is malformed.");
211
+ }
212
+ return {
213
+ mode: match.groups.mode,
214
+ type: match.groups.type,
215
+ object: match.groups.object,
216
+ path: match.groups.path,
217
+ };
218
+ });
219
+ }
220
+
221
+ export function validateGitPublicationTree(
222
+ entries: GitTreeEntry[],
223
+ manifest: GitManifest,
224
+ manifestPath: string,
225
+ filePath: (path: string) => string,
226
+ ) {
227
+ const byPath = new Map<string, GitTreeEntry>();
228
+ for (const entry of entries) {
229
+ if (byPath.has(entry.path)) throw new Error("Git publication tree contains duplicate paths.");
230
+ byPath.set(entry.path, entry);
231
+ }
232
+ const expectedPaths = [manifestPath, ...manifest.files.map((file) => filePath(file.path))];
233
+ if (entries.length !== expectedPaths.length || expectedPaths.some((path) => !byPath.has(path))) {
234
+ throw new Error("Git publication tree has missing or extra files.");
235
+ }
236
+ for (const expectedPath of expectedPaths) {
237
+ const entry = byPath.get(expectedPath);
238
+ if (entry?.mode !== "100644" || entry.type !== "blob") {
239
+ throw new Error(`Git publication tree contains a non-regular file: ${expectedPath}`);
240
+ }
241
+ }
242
+ return manifest.files.map((file) => byPath.get(filePath(file.path)) as GitTreeEntry);
243
+ }
244
+
245
+ function isSafeSnapshotPath(value: unknown): value is string {
246
+ return (
247
+ typeof value === "string" &&
248
+ value.length > 0 &&
249
+ value.length <= 4096 &&
250
+ !value.startsWith("/") &&
251
+ !value.includes("\\") &&
252
+ !hasControlCharacter(value) &&
253
+ value
254
+ .split("/")
255
+ .every(
256
+ (segment) =>
257
+ segment && segment !== "." && segment !== ".." && segment.toLowerCase() !== ".git",
258
+ )
259
+ );
260
+ }
261
+
262
+ function hasExactKeys(value: Record<string, unknown>, expected: string[]) {
263
+ const keys = Object.keys(value).sort();
264
+ const expectedKeys = [...expected].sort();
265
+ return (
266
+ keys.length === expectedKeys.length && keys.every((key, index) => key === expectedKeys[index])
267
+ );
268
+ }
269
+
270
+ function assertNoPathConflicts(paths: string[]) {
271
+ const sorted = [...paths].sort();
272
+ for (let index = 1; index < sorted.length; index += 1) {
273
+ const parent = sorted[index - 1];
274
+ const child = sorted[index];
275
+ if (parent && child?.startsWith(`${parent}/`)) {
276
+ throw new Error(`Git snapshot file path conflict: ${parent} and ${child}`);
277
+ }
278
+ }
279
+ }
280
+
281
+ function sha256(value: Buffer) {
282
+ return createHash("sha256").update(value).digest("hex");
283
+ }
284
+
285
+ function hasControlCharacter(value: string) {
286
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Reject untrusted terminal/ref controls.
287
+ return /[\u0000-\u001f\u007f-\u009f]/u.test(value);
288
+ }