fix(transport): cap LineDiffer input size — oversized script diffs degrade to summary instead of O((N+M)^2) memory

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-09 16:08:15 -04:00
parent ef59c752ba
commit 53ce64ba79
2 changed files with 53 additions and 0 deletions
@@ -51,6 +51,16 @@ public sealed record LineDiffResult(
/// </remarks>
public static class LineDiffer
{
/// <summary>
/// Hard ceiling on the combined old+new line count fed to the Myers algorithm. Myers
/// records a V frontier snapshot per edit distance <c>d</c> (up to <c>n + m</c> rounds),
/// each snapshot sized <c>O(n + m)</c>, so a fully-divergent diff is <c>O((n + m)^2)</c>
/// transient memory. To keep a bloated or crafted bundle from OOM-ing the active central
/// node during an import preview, oversized inputs short-circuit to a summary-only result
/// (the same shape the output cap emits) instead of running the trace.
/// </summary>
private const int MaxInputLines = 4000;
/// <summary>
/// Computes a minimal per-line diff between <paramref name="oldText"/> and
/// <paramref name="newText"/> (either may be <see langword="null"/>, treated as empty).
@@ -68,6 +78,13 @@ public static class LineDiffer
string[] oldLines = SplitLines(oldText);
string[] newLines = SplitLines(newText);
// Input-size guard: bound the ALGORITHM INPUT (distinct from the output cap below) so an
// oversized diff degrades to a summary instead of allocating O((n + m)^2) Myers snapshots.
if (oldLines.Length + newLines.Length > MaxInputLines)
{
return SummaryOnlyResult(oldLines.Length, newLines.Length);
}
IReadOnlyList<LineDiffLine> full = BuildDiff(oldLines, newLines, out int added, out int removed);
bool truncated = full.Count > maxLines;
@@ -91,6 +108,15 @@ public static class LineDiffer
return new LineDiffResult(lines, truncated, added, removed);
}
/// <summary>
/// Builds the truncated summary-only shape returned when the diff cannot be materialized in
/// full — no hunks, <see cref="LineDiffResult.Truncated"/> set, and the add/remove totals
/// approximated as a full replacement (every new line added, every old line removed). Used
/// by the input-size guard so a diff too large to run cheaply still reports its magnitude.
/// </summary>
private static LineDiffResult SummaryOnlyResult(int oldCount, int newCount)
=> new([], Truncated: true, AddedCount: newCount, RemovedCount: oldCount);
/// <summary>
/// Normalizes line endings (<c>\r\n</c> and lone <c>\r</c> to <c>\n</c>) and splits on <c>\n</c>.
/// A <see langword="null"/> or empty input yields an empty array (no lines), so the diff of two