Skip to content

fix: harden against confirmed review findings#2

Merged
conorbronsdon merged 1 commit into
mainfrom
fix/hardening-review
Jul 7, 2026
Merged

fix: harden against confirmed review findings#2
conorbronsdon merged 1 commit into
mainfrom
fix/hardening-review

Conversation

@conorbronsdon

Copy link
Copy Markdown
Owner

From an automated multi-agent review (personal-context#62); implemented + verified by Claude Code.

1. (security/DoS) O(D^2) memory blowup on dissimilar inputs

matching_blocks (Myers O(ND)) stores one V snapshot per edit-distance step in trace, so peak memory is O(D^2) in the edit distance. On fully-dissimilar inputs D ≈ len(a)+len(b), so two unrelated 25k-line files would allocate ~24 GB — a DoS. difflib uses a different, linear-space algorithm.

Fix: cap the exact Myers search at _MAX_EDIT_DISTANCE (4096) steps. Past the cap, matching_blocks returns a coarse result — shared leading/trailing lines preserved as equal blocks, differing middle collapsed to one block-level change (get_opcodes renders it as a single replace/insert/delete). This never allocates the quadratic trace, so peak memory is bounded (~170 MB worst case). The exact path is otherwise untouched.

Byte-parity constraint honored: output stays byte-identical to difflib for every input whose edit distance is within the cap. All 11 unified-diff fixtures and the 5k scattered-edit perf test (edit distance ~200) pass unchanged. This is the prefix/suffix-strip + capped-coarse-fallback path the review allowed; larger-than-cap diffs degrade gracefully (documented in the module docstring + SECURITY.md).

Before/after evidence (fully-dissimilar N-line inputs, peak RSS via /usr/bin/time -l, real repro binary on Mojo 1.0.0b3.dev2026070506):

N before (peak RSS) after
3,000 344 MB 174 MB
10,000 quadratic (~3.8 GB proj.) 175 MB
25,000 ~24 GB proj. (matches finding) 176 MB

Before: RSS grows quadratically (500->21MB, 1k->54MB, 2k->167MB, 3k->344MB). After: flat ~174 MB regardless of N; the 25k case that would OOM now completes in ~50 ms.

Regression tests added (test/test_diff.mojo): test_cap_coarse_fallback (coarse collapse preserves prefix/suffix; sub-cap diff stays fine-grained) and test_cap_bounds_dissimilar (dissimilar input -> single coarse replace, no blowup).

Bundled build prerequisite: pixi mojo pin

pixi.toml pinned mojo = ">=1.0.0b3,<2", which sorts below dev nightlies (1.0.0b3.dev...), so pixi install failed to solve (No candidates were found for mojo >=1.0.0b3,<2). Fixed to >=1.0.0b3.dev0,<2 — same fix already confirmed in mojo-redis. Required before the repo could build/test at all.

Verification

  • pixi install now solves; pixi run test -> 34/34 pass (32 pre-existing + 2 new), incl. all 11 difflib fixtures.
  • pixi run fuzz and pixi run demo both run clean.
  • No live/external services needed.

Overlap note

Touches SECURITY.md (one paragraph documenting the memory bound, tied to the fix). Does not touch README.md/CHANGELOG.md — a parallel agent is editing those on docs/python-onramp-and-accuracy. No conflict expected; SECURITY.md is not part of that branch.

Draft — not for merge without Conor's review.

Cap the Myers exact search at _MAX_EDIT_DISTANCE (4096) steps. The
backtrack trace stores one V snapshot per edit-distance step, so on
fully-dissimilar inputs (edit distance ~= len(a)+len(b)) memory is
O(D^2): two unrelated 25k-line files would allocate ~24 GB (measured
quadratic growth: 3k->344MB). Past the cap, matching_blocks returns a
coarse result that preserves shared prefix/suffix as equal blocks and
collapses the differing middle to one block-level change. Peak trace
memory is now bounded (~170 MB), verified flat at N=3k/10k/25k.

Output stays byte-compatible with difflib for every input whose edit
distance is within the cap: all 11 unified-diff fixtures and the 5k
scattered-edit perf test still pass unchanged.

Also fix the pixi mojo pin (>=1.0.0b3 -> >=1.0.0b3.dev0,<2): the old
pin sorts below dev nightlies so `pixi install` could not solve.

Adds regression tests for the coarse fallback and the dissimilar-input
guard, and a SECURITY.md note.

Co-Authored-By: Claude <noreply@anthropic.com>
@conorbronsdon

Copy link
Copy Markdown
Owner Author

🤖 Independent Claude review: Ready to mark for review.

Adversarial second look — no blocking correctness or security issue found.

Verified:

  • Build/tests: cloned the branch fresh, pixi run test34/34 pass (incl. all 11 difflib fixtures + the 2 new cap tests). The pixi pin >=1.0.0b3.dev0,<2 solves correctly (dev nightlies sort below b3, so the old pin excluded them — fix is right, matches PEP 440 ordering).
  • Memory bound is real: trace is the O(D²) structure; capping the forward loop at max_edit_distance bounds it to ~(cap+1)² ints. The v array is O(n+m) (linear in input, no amplification), so peak is genuinely bounded. Cap check sits at loop top before the per-d snap allocation — correct.
  • Coarse fallback is a valid diff for all shapes: prefix+suffix are non-overlapping by construction (suffix < n-prefix / m-prefix), and get_opcodes renders replace/insert/delete correctly. Hand-traced replace-middle, pure-insert-middle (a-middle empty → insert), and fully-dissimilar (prefix=suffix=0 → single replace) — all reconstruct b from a.
  • No regression on the exact path: the exact backtrack is untouched; prefix/suffix are computed but unused unless capped. test_perf_5k (edit distance ~200 ≪ 4096) still takes the exact path → 200 opcodes, unchanged.
  • Tests are non-vacuous: test_cap_coarse_fallback uses cap=2 on a distance-4 input (triggers cap), asserts the internal "MID" match is collapsed in coarse but preserved sub-cap (len(exact) > len(coarse)) — would fail if the cap logic were absent/misbehaving.

Non-blocking notes (design, not defects):

  • Past the cap, output silently diverges from difflib (coarse) and ratio()/unified_diff under-report similarity — this is documented (SECURITY.md + docstrings) and configurable via max_edit_distance, and the DoS tradeoff is sound. Worth a one-line README mention so callers know accuracy degrades (not just memory) beyond 4096.
  • The regression tests exercise the behavioral proxy (coarse fallback triggers) at tiny caps; the actual RSS bound lives only in the PR body, not an automated guard. Acceptable for unit tests.
  • prefix/suffix scan runs on every call incl. the exact path (~O(n) wasted work) — negligible.

@conorbronsdon conorbronsdon marked this pull request as ready for review July 6, 2026 07:58
@conorbronsdon

Copy link
Copy Markdown
Owner Author

Code review (opus, static): SHIP-WITH-NITS

DoS cap is correct and minimal — the exact-path/within-cap output is byte-identical to pre-PR, so difflib parity is preserved; the coarse fallback is a valid (just coarser) diff, and both new tests exercise real behavior (traced by hand).

Nit: src/diff/diff.mojo:36_MAX_EDIT_DISTANCE = 4096 can coarsen legitimately large diffs, not just adversarial ones (e.g. two 3k-line files with ~2.5k changed lines → one giant replace hunk). For a "byte-for-byte vs difflib" lib that's a silent functional divergence on plausible input. ratio()/unified_diff() inherit it silently too. Consider a higher cap or a caller-visible signal. (low-med)

@conorbronsdon conorbronsdon merged commit 0f253d6 into main Jul 7, 2026
1 check passed
@conorbronsdon conorbronsdon deleted the fix/hardening-review branch July 7, 2026 06:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant