Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-core/src/review-detail.ts

Snapshot at 34e3175

Containing directory

9,116 bytes · typescriptView raw
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