Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-core/src/review-detail.ts
Snapshot at 34e3175
1import { HttpError } from "./http";
2import { ZERO_SHA } from "./trees";
3import type { Repository } from "./types";
4
5const SHA = /^[a-f0-9]{40}$/;
6const MAX_ENTRIES = 20000;
7const MAX_COMMITS = 2048;
8const MAX_BLOB = 1024 * 1024;
9const MAX_PATCH = 64 * 1024;
10const encoder = new TextEncoder();
11const limit = () => new HttpError(413, "Review exceeds the bounded read limit.");
12const invalid = () => new Error("Invalid repository object");
13type Entry = Pick<ArtifactsTreeEntry, "hash" | "mode" | "type">;
14export interface ReviewFile { path: string; status: "added" | "modified" | "deleted" }
15
16/** Git paths are data, never filesystem paths or refs. Reject ambiguous tree components. */
17function component(name: string): boolean {
18 return !!name && name !== "." && name !== ".." && name.toLowerCase() !== ".git" &&
19 !/[\\/]/.test(name) && ![...name].some(c => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
20}
21
22/** Request-local cache and hard object/byte budgets for exact-commit repository reads. */
23export class ReviewReader {
24 private commits = new Map<string, ArtifactsCommitMetadata>();
25 private entries = 0;
26 private metadataBytes = 0;
27 constructor(private repo: Repository) {}
28
29 private async commit(sha: string): Promise<ArtifactsCommitMetadata> {
30 if (!SHA.test(sha)) throw invalid();
31 const cached = this.commits.get(sha);
32 if (cached) return cached;
33 if (this.commits.size >= MAX_COMMITS) throw limit();
34 const commit = await this.repo.readCommit(sha);
35 if (!commit || commit.hash !== sha || !SHA.test(commit.treeHash) ||
36 commit.parents.length > 64 || !commit.parents.every(p => SHA.test(p)) ||
37 !Number.isFinite(commit.committedAt)) throw invalid();
38 if (commit.message.length > MAX_PATCH || commit.author.name.length > 4096 ||
39 encoder.encode(commit.message).length > MAX_PATCH ||
40 encoder.encode(commit.author.name).length > 4096) throw limit();
41 this.metadataBytes += encoder.encode(commit.message).length + encoder.encode(commit.author.name).length;
42 if (this.metadataBytes > 2 * MAX_BLOB) throw limit();
43 this.commits.set(sha, commit);
44 return commit;
45 }
46
47 private async snapshot(sha: string): Promise<Map<string, Entry>> {
48 const files = new Map<string, Entry>();
49 if (sha === ZERO_SHA) return files;
50 const root = (await this.commit(sha)).treeHash;
51 const visit = async (hash: string, prefix: string, ancestors: Set<string>): Promise<void> => {
52 if (ancestors.has(hash)) throw invalid();
53 if (ancestors.size >= 64) throw limit();
54 const tree = await this.repo.readTree(hash);
55 if (!tree) throw invalid();
56 this.entries += tree.length;
57 if (this.entries > MAX_ENTRIES) throw limit();
58 const names = new Set<string>();
59 for (const entry of tree) {
60 if (entry.name.length > 4096) throw limit();
61 if (!component(entry.name) || !SHA.test(entry.hash) || names.has(entry.name) ||
62 !["tree", "blob", "exec", "symlink", "gitlink"].includes(entry.type)) throw invalid();
63 names.add(entry.name);
64 const path = prefix + entry.name;
65 if (encoder.encode(path).length > 4096) throw limit();
66 if (entry.type === "tree") await visit(entry.hash, path + "/", new Set([...ancestors, hash]));
67 else files.set(path, { hash: entry.hash, mode: entry.mode, type: entry.type });
68 }
69 };
70 await visit(root, "", new Set());
71 return files;
72 }
73
74 async changes(base: string, head: string) {
75 const before = await this.snapshot(base), after = await this.snapshot(head);
76 const files: ReviewFile[] = [...new Set([...before.keys(), ...after.keys()])].toSorted()
77 .filter(path => before.get(path)?.hash !== after.get(path)?.hash ||
78 before.get(path)?.mode !== after.get(path)?.mode || before.get(path)?.type !== after.get(path)?.type)
79 .map(path => ({ path, status: !before.has(path) ? "added" : !after.has(path) ? "deleted" : "modified" }));
80 return { before, after, public: { base, head, fileCount: files.length, truncated: files.length > 200, files: files.slice(0, 200) } };
81 }
82
83 /** Full set difference excludes every ancestor of base, including merged side branches. */
84 async history(base: string, head: string) {
85 const walk = async (start: string, excluded: Set<string>): Promise<Set<string>> => {
86 const found = new Set<string>(), pending = start === ZERO_SHA ? [] : [start];
87 while (pending.length) {
88 const sha = pending.pop()!;
89 if (found.has(sha) || excluded.has(sha)) continue;
90 found.add(sha);
91 const commit = await this.commit(sha);
92 pending.push(...commit.parents);
93 }
94 return found;
95 };
96 const excluded = await walk(base, new Set());
97 const included = await walk(head, excluded);
98 const commits = [...included].map(sha => this.commits.get(sha)!)
99 .toSorted((a, b) => b.committedAt - a.committedAt || a.hash.localeCompare(b.hash));
100 return { commits: commits.slice(0, 50).map(commit => ({
101 sha: commit.hash, subject: commit.message.split(/\r?\n/, 1)[0]!, authorName: commit.author.name,
102 packetTrailer: trailer(commit.message, "Fork-Horse-Packet"),
103 agentTrailer: trailer(commit.message, "Fork-Horse-Agent"),
104 })), commitsTruncated: commits.length > 50 };
105 }
106
107 async diff(path: string, changes: Awaited<ReturnType<ReviewReader["changes"]>>) {
108 const file = changes.public.files.find(candidate => candidate.path === path);
109 if (!file) throw new HttpError(404, "Changed path not found.");
110 const content = async (entry: Entry | undefined): Promise<string | null> => {
111 if (!entry) return "";
112 // A gitlink names a commit, never a blob to follow into a submodule.
113 if (entry.type === "gitlink") return `Subproject commit ${entry.hash}\n`;
114 const blob = await this.repo.readBlob(entry.hash);
115 if (!blob) throw invalid();
116 if (blob.size > MAX_BLOB) throw limit();
117 const bytes = new Uint8Array(await blob.arrayBuffer());
118 if (bytes.includes(0)) return null;
119 try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
120 catch { return null; }
121 };
122 const before = await content(changes.before.get(path));
123 const after = await content(changes.after.get(path));
124 if (before === null || after === null) return { ...file, binary: true, patch: "", truncated: false };
125 return { ...file, binary: false, ...patch(path, file.status, before, after) };
126 }
127}
128
129/** Only the final trailer paragraph counts; prose mentioning a trailer is not provenance. */
130function trailer(message: string, key: string): string | null {
131 const paragraphs = message.trimEnd().split(/\r?\n\s*\r?\n/);
132 if (paragraphs.length < 2) return null;
133 const lines = paragraphs.at(-1)!.split(/\r?\n/);
134 if (!lines.every(line => /^[A-Za-z0-9-]+: .+$/.test(line))) return null;
135 const values = lines.filter(line => line.toLowerCase().startsWith(key.toLowerCase() + ": "));
136 return values.length ? values.at(-1)!.slice(key.length + 2).trim() || null : null;
137}
138
139const lines = (text: string) => text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
140
141/** Linear-time unified replacement hunk, with shared edges trimmed to three context lines.
142 * Avoids quadratic diff work for adversarial input. A capped patch is explicitly incomplete. */
143function patch(path: string, status: ReviewFile["status"], before: string, after: string) {
144 if (before === after) return { patch: "", truncated: false };
145 const a = lines(before), b = lines(after);
146 let prefix = 0, suffix = 0;
147 while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix++;
148 while (suffix < a.length - prefix && suffix < b.length - prefix && a[a.length - suffix - 1] === b[b.length - suffix - 1]) suffix++;
149 const start = Math.max(0, prefix - 3), context = Math.min(3, suffix);
150 const oldCount = a.length - suffix + context - start, newCount = b.length - suffix + context - start;
151 // Quote paths as Git does, preventing tabs/quotes from changing header interpretation.
152 const label = (side: string) => JSON.stringify(`${side}/${path}`);
153 let result = `--- ${status === "added" ? "/dev/null" : label("a")}\n+++ ${status === "deleted" ? "/dev/null" : label("b")}\n@@ -${oldCount ? start + 1 : start},${oldCount} +${newCount ? start + 1 : start},${newCount} @@\n`;
154 let size = encoder.encode(result).length, truncated = false;
155 const append = (mark: string, line: string): boolean => {
156 const value = mark + line + (line.endsWith("\n") ? "" : "\n\\ No newline at end of file\n");
157 const bytes = encoder.encode(value).length;
158 if (size + bytes > MAX_PATCH) { truncated = true; return false; }
159 size += bytes; result += value; return true;
160 };
161 const groups: [string, string[], number, number][] = [
162 [" ", a, start, prefix], ["-", a, prefix, a.length - suffix],
163 ["+", b, prefix, b.length - suffix], [" ", a, a.length - suffix, a.length - suffix + context],
164 ];
165 outer: for (const [mark, source, from, to] of groups)
166 for (let i = from; i < to; i++) if (!append(mark, source[i]!)) break outer;
167 return { patch: result, truncated };
168}
169