Skip to content

Commit a6f2717

Browse files
committed
Bound history replay and fix rename lineage and link target checks
1 parent a8534cc commit a6f2717

7 files changed

Lines changed: 220 additions & 45 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ Inputs such as `planText` and the ledger must come from the caller. The future `
4545

4646
## Current limits and safety
4747

48-
- 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.
48+
- 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 shared 30-second monotonic deadline for a read. Git children receive only the remaining time and are killed on timeout. Filesystem inspections check the deadline between operations; a blocked filesystem syscall still requires an external worker supervisor. Oversized work fails explicitly.
49+
- Linking separately bounds cumulative split lines and candidate segments to 100,000 each, reference/work operations to 1,000,000, and text/origin strings to 32 Mi UTF-16 code units. It checks before expanding lines/origin sets; grouping no longer repeatedly splits accumulated content. Callers may lower these budgets. Linking has a 30-second overall deadline and each line diff uses at most 2 seconds or the remaining total, whichever is smaller.
4950
- 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.
5051
- Object storage must remain unchanged during a read; these library checks do not isolate a concurrently hostile filesystem.
5152
- 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.

‎core/linking.ts‎

Lines changed: 121 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ export interface Segment {
2020
}
2121
interface Evidence { owners: (string | null)[]; outOfScope: string[] }
2222
interface TrackedLine { text: string; evidence: Evidence; origins: string[]; moved: Evidence }
23-
interface TrackedFile { lines: TrackedLine[]; metadata: Evidence }
24-
const lines = (text: string | null | undefined): string[] => text?.match(/[^\n]*\n|[^\n]+$/g) ?? [];
23+
interface TrackedFile { lines: TrackedLine[]; metadata: Evidence; metadataPaths: string[] }
2524
const unique = <T>(values: T[]): T[] => [...new Set(values)];
2625
const origin = (path: string, i: number) => `${path}\0${i}`;
2726
function textFile(file: FileVersion | null): boolean {
@@ -32,10 +31,6 @@ function metadataChange(delta: FileDelta): boolean {
3231
!textFile(delta.before) || !textFile(delta.after);
3332
}
3433
const empty = (): Evidence => ({ owners: [], outOfScope: [] });
35-
const combine = (...evidence: Evidence[]): Evidence => ({
36-
owners: unique(evidence.flatMap(e => e.owners)),
37-
outOfScope: unique(evidence.flatMap(e => e.outOfScope)),
38-
});
3934
function classify(evidence: Evidence): Pick<Segment, 'row' | 'scope'> {
4035
const { owners, outOfScope } = evidence;
4136
if (!owners.length || owners.includes(null)) return { row: 'Unplanned', scope: 'unplanned' };
@@ -44,72 +39,139 @@ function classify(evidence: Evidence): Pick<Segment, 'row' | 'scope'> {
4439
return { row: owner, scope: outOfScope.includes(owner) ? 'out-of-scope' : 'in-scope' };
4540
}
4641

42+
export interface LinkingLimits { maxLines?: number; maxSegments?: number; maxReferences?: number; maxDurationMs?: number }
43+
4744
/** Replays a linear history. Commit messages and Plan-Item trailers are never trusted. */
48-
export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<string, string | null>, pathKey: (path: string) => string): Segment[] {
45+
export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<string, string | null>, pathKey: (path: string) => string, limits: LinkingLimits = {}): Segment[] {
4946
if (typeof pathKey !== 'function') throw new Error('Known checkout path identity is required.');
47+
const budget = (value: number | undefined, ceiling: number, name: string) => {
48+
const limit = value ?? ceiling;
49+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > ceiling) throw new Error(`Invalid ${name} budget.`);
50+
return limit;
51+
};
52+
const maxLines = budget(limits.maxLines, 100_000, 'line');
53+
const maxSegments = budget(limits.maxSegments, 100_000, 'segment');
54+
const maxReferences = budget(limits.maxReferences, 1_000_000, 'reference');
55+
const deadline = performance.now() + budget(limits.maxDurationMs, 30_000, 'duration');
56+
const remaining = () => {
57+
const ms = deadline - performance.now();
58+
if (ms <= 0) throw new Error('Linking exceeded its overall deadline.');
59+
return Math.max(1, Math.min(2000, Math.ceil(ms)));
60+
};
61+
let lineCount = 0, segmentCount = 0, references = 0, characters = 0;
62+
const charge = (count: number) => {
63+
remaining(); references += count;
64+
if (references > maxReferences) throw new Error('Linking exceeds its cumulative reference/work budget.');
65+
};
66+
const chargeText = (count: number) => {
67+
characters += count;
68+
if (characters > 32 * 1024 * 1024) throw new Error('Linking exceeds its cumulative text/origin character budget.');
69+
};
70+
const lines = (text: string | null | undefined): string[] => {
71+
remaining();
72+
if (!text) return [];
73+
chargeText(text.length);
74+
const result: string[] = [];
75+
for (let start = 0; start < text.length;) {
76+
if (++lineCount > maxLines) throw new Error('Linking exceeds its cumulative line budget.');
77+
remaining();
78+
const newline = text.indexOf('\n', start), end = newline < 0 ? text.length : newline + 1;
79+
result.push(text.slice(start, end)); start = end;
80+
}
81+
return result;
82+
};
83+
const combine = (evidence: readonly Evidence[]): Evidence => {
84+
const owners = new Set<string | null>(), outOfScope = new Set<string>();
85+
for (const entry of evidence) {
86+
charge(entry.owners.length + entry.outOfScope.length);
87+
for (const owner of entry.owners) owners.add(owner);
88+
for (const owner of entry.outOfScope) outOfScope.add(owner);
89+
}
90+
return { owners: [...owners], outOfScope: [...outOfScope] };
91+
};
92+
const mergeOrigins = (tracked: readonly TrackedLine[]): string[] => {
93+
const origins = new Set<string>();
94+
for (const line of tracked) { charge(line.origins.length); for (const id of line.origins) origins.add(id); }
95+
return [...origins];
96+
};
5097
const files = new Map<string, TrackedFile>();
5198
const removed = new Map<string, Evidence>();
5299
// Deletions retain metadata even after the file leaves the tree.
53100
const metadata = new Map<string, Evidence>();
54101
let parent = history.base;
55102
for (const commit of history.commits) {
103+
remaining();
56104
if (commit.parent !== parent) throw new Error('Linking requires a contiguous linear history.');
57105
parent = commit.sha;
58106
const owner = ledger.get(commit.sha) ?? null;
59107
if (owner !== null && !plan.items.some(item => item.id === owner)) throw new Error(`Unknown ledger item: ${owner}`);
60108
for (const delta of commit.files) {
109+
remaining();
61110
const oldPath = delta.oldPath;
62111
const item = plan.items.find(item => item.id === owner);
63112
const declared = new Set(item?.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey));
64113
const touched = [delta.oldPath, delta.newPath].filter((path): path is string => path !== null);
65114
const current: Evidence = { owners: [owner], outOfScope: owner !== null && touched.some(path => !declared.has(pathKey(path))) ? [owner] : [] };
66115
let previous = oldPath ? files.get(oldPath) : undefined;
67116
if (!previous) previous = {
68-
lines: lines(textFile(delta.before) ? delta.before!.text : '').map((text, i) => ({ text, evidence: empty(), origins: [origin(oldPath!, i)], moved: empty() })),
69-
metadata: empty(),
117+
lines: lines(textFile(delta.before) ? delta.before!.text : '').map((text, i) => {
118+
charge(1); chargeText((oldPath?.length ?? 0) + 12);
119+
return { text, evidence: empty(), origins: [origin(oldPath!, i)], moved: empty() };
120+
}),
121+
metadata: empty(), metadataPaths: oldPath ? [oldPath] : [],
70122
};
71123
const next: TrackedLine[] = [];
72-
const changes = diffArrays(previous.lines.map(line => line.text), lines(textFile(delta.after) ? delta.after!.text : ''), { timeout: 2000 });
124+
const changes = diffArrays(previous.lines.map(line => line.text), lines(textFile(delta.after) ? delta.after!.text : ''), { timeout: remaining() });
73125
if (!changes) throw new Error('Line attribution exceeded the diff time budget.');
74126
let cursor = 0;
75127
for (let n = 0; n < changes.length; n++) {
76128
const change = changes[n]!;
77-
if (!change.added && !change.removed) { next.push(...previous.lines.slice(cursor, cursor + change.value.length)); cursor += change.value.length; continue; }
129+
if (!change.added && !change.removed) { for (const line of previous.lines.slice(cursor, cursor + change.value.length)) next.push(line); cursor += change.value.length; continue; }
78130
const deleted = change.removed ? previous.lines.slice(cursor, cursor + change.value.length) : [];
79131
if (change.removed) cursor += change.value.length;
80-
const evidence = combine(...deleted.map(line => line.evidence), current);
81-
const origins = unique(deleted.flatMap(line => line.origins));
132+
const evidence = combine([...deleted.map(line => line.evidence), current]);
133+
const origins = mergeOrigins(deleted);
82134
for (const id of origins) removed.set(id, evidence);
83135
const added = change.added ? change : changes[n + 1]?.added ? changes[++n]! : undefined;
84-
if (added) for (const text of added.value) next.push({ text, evidence, origins, moved: combine(...deleted.map(line => line.moved)) });
136+
if (added) {
137+
const moved = combine(deleted.map(line => line.moved));
138+
for (const text of added.value) { charge(origins.length + 1); next.push({ text, evidence, origins, moved }); }
139+
}
85140
}
86141
if (oldPath && delta.newPath && oldPath !== delta.newPath) {
87142
for (let i = 0; i < next.length; i++) {
88143
const line = next[i]!;
89-
next[i] = { ...line, moved: combine(line.moved, current) };
90-
for (const id of line.origins) if (!removed.has(id)) removed.set(id, combine(line.evidence, current));
144+
next[i] = { ...line, moved: combine([line.moved, current]) };
145+
charge(line.origins.length);
146+
for (const id of line.origins) if (!removed.has(id)) removed.set(id, combine([line.evidence, current]));
91147
}
92148
}
93-
const metadataEvidence = metadataChange(delta) ? combine(previous.metadata, current) : previous.metadata;
94-
if (oldPath) { files.delete(oldPath); metadata.set(oldPath, metadataEvidence); }
95-
if (delta.newPath) { files.set(delta.newPath, { lines: next, metadata: metadataEvidence }); metadata.set(delta.newPath, metadataEvidence); }
149+
const metadataEvidence = metadataChange(delta) ? combine([previous.metadata, current]) : previous.metadata;
150+
const metadataPaths = unique([...previous.metadataPaths, ...touched]);
151+
charge(metadataPaths.length);
152+
for (const path of metadataPaths) metadata.set(path, metadataEvidence);
153+
if (oldPath) files.delete(oldPath);
154+
if (delta.newPath) files.set(delta.newPath, { lines: next, metadata: metadataEvidence, metadataPaths });
96155
}
97156
}
98157
if (parent !== history.head) throw new Error('History does not end at the requested head.');
99158
const segments: Segment[] = [];
100159
for (const delta of history.final) {
160+
remaining();
101161
const path = delta.newPath ?? delta.oldPath!;
102162
const tracked = delta.newPath ? files.get(delta.newPath) : undefined;
103163
const oldLines = lines(textFile(delta.before) ? delta.before!.text : '');
104164
const newLines = lines(textFile(delta.after) ? delta.after!.text : '');
105165
let oldIndex = 0, newIndex = 0, hunk = 0;
106166
const fileSegments: Segment[] = [];
107167
const affectedPaths = unique([delta.oldPath, delta.newPath].filter((p): p is string => p !== null));
108-
const push = (part: Omit<Segment, 'row' | 'scope' | 'sharesHunkWith' | 'owners'>, evidence: Evidence) => fileSegments.push({
109-
...part, owners: evidence.owners, ...classify(evidence), sharesHunkWith: [],
110-
});
168+
const push = (part: Omit<Segment, 'row' | 'scope' | 'sharesHunkWith' | 'owners'>, evidence: Evidence) => {
169+
remaining();
170+
if (++segmentCount > maxSegments) throw new Error('Linking exceeds its cumulative segment budget.');
171+
fileSegments.push({ ...part, owners: evidence.owners, ...classify(evidence), sharesHunkWith: [] });
172+
};
111173
if (metadataChange(delta)) {
112-
const evidence = combine(...affectedPaths.map(path => metadata.get(path) ?? empty()));
174+
const evidence = combine(affectedPaths.map(path => metadata.get(path) ?? empty()));
113175
push({ path, oldPath: delta.oldPath, kind: 'file', oldLine: null, newLine: null,
114176
operation: null, context: '', hunk: -1,
115177
content: JSON.stringify({ oldPath: delta.oldPath, newPath: delta.newPath,
@@ -118,7 +180,7 @@ export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<st
118180
newObject: delta.after ? { kind: delta.after.mode === '160000' ? 'commit' : 'blob', oid: delta.after.oid } : null }),
119181
}, evidence);
120182
}
121-
const finalChanges = diffArrays(oldLines, newLines, { timeout: 2000 });
183+
const finalChanges = diffArrays(oldLines, newLines, { timeout: remaining() });
122184
if (!finalChanges) throw new Error('Final diff exceeded the time budget.');
123185
for (const change of finalChanges) {
124186
if (!change.added && !change.removed) {
@@ -143,18 +205,46 @@ export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<st
143205
}
144206
// Adjacent lines with identical ownership/context form one segment.
145207
const grouped: Segment[] = [];
208+
let groupedLineCount = 0;
146209
for (const part of fileSegments) {
210+
remaining();
147211
const last = grouped.at(-1);
148212
if (last && part.kind === 'text' && last.kind === 'text' && last.hunk === part.hunk &&
149213
last.operation === part.operation && last.context === part.context && last.scope === part.scope &&
150-
(part.operation === '+' ? last.newLine! + lines(last.content).length === part.newLine : last.oldLine! + lines(last.content).length === part.oldLine) &&
151-
JSON.stringify(last.owners) === JSON.stringify(part.owners)) last.content += part.content;
152-
else grouped.push({ ...part });
214+
(part.operation === '+' ? last.newLine! + groupedLineCount === part.newLine : last.oldLine! + groupedLineCount === part.oldLine) &&
215+
JSON.stringify(last.owners) === JSON.stringify(part.owners)) { last.content += part.content; groupedLineCount++; }
216+
else { grouped.push({ ...part }); groupedLineCount = part.kind === 'text' ? 1 : 0; }
217+
}
218+
const hunkRows = new Map<number, Map<string, Set<string>>>();
219+
for (const part of grouped) {
220+
charge(part.owners.length + 1);
221+
let rows = hunkRows.get(part.hunk);
222+
if (!rows) { rows = new Map(); hunkRows.set(part.hunk, rows); }
223+
let owners = rows.get(part.row);
224+
if (!owners) { owners = new Set(); rows.set(part.row, owners); }
225+
for (const owner of part.owners) if (owner !== null) owners.add(owner);
226+
}
227+
const sharing = new Map<number, Map<string, string[]>>();
228+
for (const [hunk, rows] of hunkRows) {
229+
const byRow = new Map<string, string[]>(); sharing.set(hunk, byRow);
230+
for (const row of rows.keys()) {
231+
const owners = new Set<string>();
232+
for (const [otherRow, otherOwners] of rows) {
233+
charge(1);
234+
if (row === otherRow) continue;
235+
charge(otherOwners.size);
236+
for (const owner of otherOwners) owners.add(owner);
237+
}
238+
byRow.set(row, [...owners]);
239+
}
240+
}
241+
for (const part of grouped) {
242+
const owners = sharing.get(part.hunk)!.get(part.row)!;
243+
charge(owners.length);
244+
part.sharesHunkWith = [...owners];
153245
}
154-
for (const part of grouped) part.sharesHunkWith = unique(grouped
155-
.filter(other => other !== part && other.hunk === part.hunk && other.row !== part.row)
156-
.flatMap(other => other.owners.filter((owner): owner is string => owner !== null)));
157-
segments.push(...grouped);
246+
for (const part of grouped) segments.push(part);
158247
}
248+
remaining();
159249
return segments;
160250
}

‎core/plan.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,16 @@ function parents(path: string): string[] {
111111
function linkTarget(path: string, target: string, key: (path: string) => string, entries: ReadonlyMap<string, BaseEntry>): string | null {
112112
if (!target || target.startsWith('/') || /[\\:\p{Cc}]/u.test(target)) return null;
113113
const parts = path.split('/').slice(0, -1);
114-
for (const component of target.split('/')) {
114+
const components = target.split('/');
115+
for (const [index, component] of components.entries()) {
115116
if (component === '' || component === '.') continue;
116117
if (component === '..') { if (!parts.length) return null; parts.pop(); }
117118
else {
118119
parts.push(component);
119-
try { if (entries.get(key(parts.join('/')))?.kind === 'symlink') return null; } catch { return null; }
120+
try {
121+
const entry = entries.get(key(parts.join('/')));
122+
if (entry && (entry.kind === 'symlink' || index < components.length - 1)) return null;
123+
} catch { return null; }
120124
}
121125
}
122126
try { return parts.length ? key(parts.join('/')) : ''; } catch { return null; }
@@ -228,13 +232,14 @@ function validateV1(value: Plan, context: PlanContext): Validation {
228232
const target = linkTarget(location, entry.target, key, entries);
229233
const traversesLink = target !== null && [...parents(target), target].some(p => entries.get(p)?.kind === 'symlink');
230234
if (target === null || traversesLink) {
231-
if (file.kind !== 'delete' && file.kind !== 'edit') error('symlink-target', 'Retained link target is unsafe.', item.id);
235+
if ((file.kind !== 'delete' && file.kind !== 'edit') || item.files.length !== 1)
236+
error('symlink-target', 'Unsafe link repair must be isolated in its own item; retained rename targets must be safe.', item.id);
232237
continue; // Deletion or replacement may repair an unsafe old link.
233238
}
234239
for (const other of item.files) {
235240
if (other === file) continue;
236241
const otherPaths = other.kind === 'rename' ? [other.path, other.renamed_from!] : [other.path];
237-
if (otherPaths.some(p => p === target || target === '' || p.startsWith(`${target}/`)))
242+
if (otherPaths.some(p => p === target || target === '' || p.startsWith(`${target}/`) || target.startsWith(`${p}/`)))
238243
error('symlink-target', 'A link and its writable target cannot share an invocation.', item.id);
239244
}
240245
}

0 commit comments

Comments
 (0)