Skip to content

Commit 1d6f5ac

Browse files
committed
Bound aggregate Git diff bytes and retained file records
1 parent 876d142 commit 1d6f5ac

4 files changed

Lines changed: 28 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Inputs such as `planText` and the ledger must come from the caller. The future `
4343

4444
## Current limits and safety
4545

46-
- History must be linear and descend from the requested base. Merge histories are rejected with a rebase instruction; repositories using object alternates or symlinked object storage are rejected (storage inspection is limited to 100,000 entries). Reads are bounded to 500 commits, 32 MiB per Git response, 64 MiB of unique blob bytes across the history (callers may lower `maxBlobBytes`), and a 2-second budget per line diff; oversized work fails explicitly.
46+
- History must be linear and descend from the requested base. Merge histories are rejected with a rebase instruction; repositories using object alternates or symlinked object storage are rejected (storage inspection is limited to 100,000 entries). Reads are bounded to 500 commits, 32 MiB per Git response, 64 MiB of unique blob bytes across the history (callers may lower `maxBlobBytes`), 8 MiB of cumulative diff output, 20,000 cumulative file records (including the final diff), and a 2-second budget per line diff; oversized work fails explicitly.
4747
- The caller selects and trusts the repository and its Git administrative directory. Normal Git discovery, linked-worktree gitfiles, and symlinked gitdirs are supported; object-storage links and alternates inside that selected gitdir are rejected. This adapter is not a filesystem-containment boundary for untrusted repository roots.
4848
- Object storage must remain unchanged during a read; these library checks do not isolate a concurrently hostile filesystem.
4949
- Ownership uses line diffs, not semantic inference. Within one replacement block, new lines inherit all affected owners conservatively. Function context comes from Git hunk headers, not an AST.

‎docs/implementation/build-step-1.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ Declined the YAML finding: the pinned yaml 2.9.1 implementation and types explic
6363

6464
Clarified the gitdir policy rather than rejecting normal linked worktrees. The caller selects and trusts the repository and its administrative directory; gitfiles and symlinked gitdirs are supported, while object-storage symlinks and alternates within that gitdir are rejected. Real-Git fixtures exercise both administrative layouts. Filesystem containment of an untrusted repository root belongs to the runner, not this read-only library.
6565

66+
## Review round 8
67+
68+
Fixed aggregate diff retention outside the blob budget. The adapter limits total raw-diff and context-patch output to 8 MiB and total file records across commit and final diffs to 20,000. Both budgets can be lowered by callers. Small-budget cases first failed and now reject explicitly; the exact file-record boundary succeeds.
69+
6670
## Remaining gates
6771

6872
This is a working foundation, not a completed application or a claim that all implementation tasks are done. T18's pure validation/edit core is present; its agent adapters, import UI, and persistence are pending. Ledger storage, rebase mappings, and the read-only review screen remain next. The already-fixed GitHub check belongs to the later GitHub/runner integration.

‎git/history.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,21 @@ import { resolve as resolvePath, join } from 'node:path';
44
import type { FileDelta, FileVersion, History } from '../core/linking.ts';
55

66
/** Read-only Git adapter. Never follows working-tree symlinks or runs diff helpers. */
7-
export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', limits: { maxBlobBytes?: number; maxObjectEntries?: number } = {}): History {
7+
export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', limits: { maxBlobBytes?: number; maxObjectEntries?: number; maxDiffBytes?: number; maxFileEntries?: number } = {}): History {
88
const maxBlobBytes = limits.maxBlobBytes ?? 64 * 1024 * 1024;
99
if (!Number.isSafeInteger(maxBlobBytes) || maxBlobBytes < 1 || maxBlobBytes > 64 * 1024 * 1024) throw new Error('Blob byte budget must be a positive integer no larger than 64 MiB.');
1010
const maxObjectEntries = limits.maxObjectEntries ?? 100_000;
1111
if (!Number.isSafeInteger(maxObjectEntries) || maxObjectEntries < 1 || maxObjectEntries > 100_000) throw new Error('Object entry budget must be a positive integer no larger than 100000.');
12-
let blobBytes = 0;
12+
const maxDiffBytes = limits.maxDiffBytes ?? 8 * 1024 * 1024;
13+
if (!Number.isSafeInteger(maxDiffBytes) || maxDiffBytes < 1 || maxDiffBytes > 8 * 1024 * 1024) throw new Error('Diff byte budget must be a positive integer no larger than 8 MiB.');
14+
const maxFileEntries = limits.maxFileEntries ?? 20_000;
15+
if (!Number.isSafeInteger(maxFileEntries) || maxFileEntries < 1 || maxFileEntries > 20_000) throw new Error('File entry budget must be a positive integer no larger than 20000.');
16+
let blobBytes = 0, diffBytes = 0, fileEntries = 0;
17+
const accountDiff = (data: Buffer): Buffer => {
18+
diffBytes += data.length;
19+
if (diffBytes > maxDiffBytes) throw new Error('Review history exceeds the cumulative diff byte budget; choose a narrower base.');
20+
return data;
21+
};
1322
// Inherited Git variables can redirect repository, index, config, and object lookup.
1423
const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key)));
1524
const run = (...args: string[]) => execFileSync('git', ['--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'protocol.allow=never', ...args], {
@@ -67,10 +76,11 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim
6776
return { oid, mode, text: blobs.get(oid)! };
6877
};
6978
const diff = (from: string, to: string, contexts: boolean): FileDelta[] => {
70-
const raw = run('diff', '--ignore-submodules=none', '--no-relative', '--raw', '-z', '--no-abbrev', '--no-ext-diff', '--no-textconv', '-M', from, to, '--');
79+
const raw = accountDiff(run('diff', '--ignore-submodules=none', '--no-relative', '--raw', '-z', '--no-abbrev', '--no-ext-diff', '--no-textconv', '-M', from, to, '--'));
7180
const fields = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw).split('\0');
7281
const result: FileDelta[] = [];
7382
for (let i = 0; i < fields.length && fields[i];) {
83+
if (++fileEntries > maxFileEntries) throw new Error('Review history exceeds the cumulative file entry budget; choose a narrower base.');
7484
const match = /^:(\d+) (\d+) ([0-9a-f]+) ([0-9a-f]+) ([A-Z])\d*$/.exec(fields[i++]!);
7585
if (!match) throw new Error('Unexpected Git raw diff record.');
7686
const [, oldMode, newMode, oldOid, newOid, status] = match;
@@ -82,7 +92,7 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim
8292
if (contexts && (before?.text !== null || after?.text !== null)) {
8393
const paths = [...new Set([oldPath, newPath].filter((path): path is string => path !== null))];
8494
// Literal pathspecs preserve filenames containing Git pathspec metacharacters.
85-
const patch = run('diff', '--ignore-submodules=none', '--no-relative', '--no-ext-diff', '--no-textconv', '--no-color', '--unified=0', '-M', from, to, '--', ...paths.map(path => `:(literal)${path}`)).toString();
95+
const patch = accountDiff(run('diff', '--ignore-submodules=none', '--no-relative', '--no-ext-diff', '--no-textconv', '--no-color', '--unified=0', '-M', from, to, '--', ...paths.map(path => `:(literal)${path}`))).toString();
8696
for (const line of patch.split('\n')) {
8797
const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line);
8898
if (hunk) ranges.push({ oldStart: +hunk[1]!, oldCount: +(hunk[2] ?? 1), newStart: +hunk[3]!, newCount: +(hunk[4] ?? 1), name: hunk[5]!.trim() });

‎test/history.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,12 @@ it('supports a caller-selected symlinked gitdir with regular object storage', ()
225225
rmSync(metadata, { recursive: true }); renameSync(join(f.dir, '.git'), metadata); symlinkSync(metadata, join(f.dir, '.git'));
226226
expect(readHistory(f.dir, f.base).final[0]!.after!.text).toBe('changed\n');
227227
});
228+
229+
it.each([
230+
[{ maxDiffBytes: 1 }, /diff byte budget/i],
231+
[{ maxFileEntries: 1 }, /file entry budget/i],
232+
] as const)('bounds accumulated diff records with %j', (limits, error) => {
233+
const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1');
234+
expect(() => readHistory(f.dir, f.base, 'HEAD', limits)).toThrow(error);
235+
expect(readHistory(f.dir, f.base, 'HEAD', { maxFileEntries: 2 }).final).toHaveLength(1);
236+
});

0 commit comments

Comments
 (0)