Finding 61: Recorder copied every gradient's stop list (and every clip
glyph's outline) per op, capped only by op count, so a compact COLR graph
reusing one long stop list could expand to billions of stops. Stops and
path elements now share MAX_PAYLOAD (65,536) per glyph, charged before
copying; past it the glyph falls back to its outline and no further clip
outlines are built. The render cache charges each glyph's real ops,
stops and path bytes (ops_bytes) instead of a flat 64 bytes per op.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 59: enumerate_faces took a collection's member count straight from
its header (a TTC can list one table directory millions of times) and
font_faces read every fvar named instance (up to 65535, with a linear
style dedupe and a name-table lookup each). Every entry became a FontFace
kept for the session, and add_fonts then checked each new face against
every face in the database.
Now a file yields at most 256 faces (MAX_FILE_FACES; user fonts ask for
one more so they can still be refused as "more than 256 faces"), named
instances are read to the same cap, a face with the family and style of
an earlier one in the file is skipped (repeated collection offsets), and
add_fonts dedupes against an indexed set.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 46: install_font logged the caller-supplied file name raw when the
font couldn't be stored, and the desktop log writes line breaks as they
come, so a name could forge extra log records. The name is now logged
with {:?} (escaped and quoted), and so are the font-derived family names in
the two glyph-outline warnings, which come from a document's own fonts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Findings 39 and 70.
39: wuff inflates each WOFF 1.0 table into a buffer sized from the
directory's origLength, before anything checks it against our 128 MB
cap, so a tiny file could ask for gigabytes. The directory's table sizes
are now summed first and a file over MAX_DECODED_BYTES is TooLarge.
70: wuff's WOFF2 glyf reconstruction allocates a glyph's points from its
nPoints counts and checks the 65536-point limit only afterwards. The
brotli stream is now decompressed by us (brotli-decompressor, already in
the tree through wuff) and every transformed glyf table's per-glyph point
totals are checked before wuff unpacks them; a glyph over the limit (which
wuff would refuse anyway) is refused up front.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The layout's linear-time tests (#3866) asserted CPU-time ratios, which a busy
CI machine can skew. Each walk that once ran again for every item it served
(a line's clusters, a page's items, the revision authors, the sections, a
table's rows, a run's glyphs, the clipped cells) now counts the elements it
looks at in a test-only counter (crate::work, nothing outside tests), and the
tests assert 8x the input takes at most 12x the steps (quadratic: ~64x).
proof_text, whose walk is the document model's, stays timed, and a ratio
over the limit is measured twice more before it fails.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Finding 3: the table's rows carry no cells, only a column with a width skipped before and after,
so each row adds two spanning entries for next to no layout and counting the grid per entry
dominates (old code 75x for 8x the rows, fixed code 4-8x).
Finding 13: sections that link to the previous section's headers and footers are laid out
against the same sections naming them (title page on, so first-page parts are looked up too);
old code 6.1x the time, fixed code 1.0x, limit 2x.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Findings 1, 3, 5, 13, 23, 28, 29 and 30 (#3706): one Progress log row, one Performance row in layout-parity.md, revision-history rows.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 30: author_index rebuilt the list of revision authors for every tracked run and paragraph mark drawn, and balloons rebuilt the comment-author list for every comment (quadratic in revisions or comments). display::author_order ranks authors by first appearance once; page_display, page_layers and object_display build the revision order lazily once per call and pass it down, and balloons builds the comment order once per layout. Same colours.
Tests: drawing_tracked_changes_by_many_authors_takes_linear_time, tracked_changes_colour_by_author_in_order_of_appearance, balloons_for_many_authors_lay_out_in_linear_time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 1: cell_clip searched every clipped cell on the page for each line drawn (quadratic in a page of many exact-height cells). clipped_cells now indexes them by story and content path, keeping each one's order; a line looks up only its path's prefixes and takes the earliest, the same cell the scan found first.
Test: drawing_many_clipped_cells_takes_linear_time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 28: column_segments and selection_rects called pieces() per paragraph, and for a header, footer or note each call scanned the whole page (pieces_on). PartPages indexes a page's lines of the story by paragraph the first time the page is looked at; the page order (hint first, continued notes) is unchanged.
Test: selecting_a_long_header_takes_linear_time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 13: linked() and defining_width() walked back through the sections before each section, per section and again per page (layout_pass, headers_footers, footer_table_wraps, header_table_areas). HfLinks resolves every section's linked header and footer sets incrementally once (each inherits the previous one's for the parts it leaves out) and keeps, per part id, the sections naming it, so the defining section is a binary search. Same sets and widths as before.
Test: headers_inherited_through_many_sections_lay_out_in_linear_time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 3: column_content called Table::cols (a walk of every row) for each spanning cell, and there can be one in every row. Hoisted out of the loop; the end is also added with saturating_add.
Test: autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 23: Ctx::glyphs found each character's first glyph by scanning the shaped glyphs from the start (quadratic in a long run). The first glyph at or past a boundary never moves back as the boundary grows, so one walk gives the same cuts.
Test: math::tests::a_long_text_run_lays_out_in_linear_time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 5: off_at_x and visual_step on a bidirectional line called x_of for every caret offset, each searching the clusters from the line's start (quadratic in a line of many zero-width offsets, e.g. hidden text). ParaLayout::line_carets walks them with a cursor; the first matching cluster never moves back for non-decreasing offsets, so the x's are the same.
Tests: bidi_clicks_and_caret_steps_take_linear_time, line_carets_match_x_of.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 29: proof_text looked up each character's run with props_of_char, walking the runs from the first for every character (quadratic in a paragraph of many runs). A run cursor advancing with the text gives the same props in one pass.
Adds layout::tests_linear (sharing tests.rs's cost_ratio helpers) with proof_text_walks_the_runs_once.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Build wordcraft.wxs a second time as a dual-purpose package
(Scope=perUserOrMachine: ALLUSERS=2, MSIINSTALLPERUSER=1), shipped as
wordcraft-<ver>-windows-user-<arch>.msi. Registry keys move from HKLM to
HKMU so the same components land in HKLM per-machine and HKCU per-user.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Wall-clock bounds of 1-2 s failed on loaded runners. Each bound is now 10 s, at least 10x the
fixed code's time and well under the old code's on the same input (measured by running the
pre-fix code):
- self-continuing rules: 40,000 rules (was 16,000); ~1 ms now, old checker 14 s at 16,000.
- many strips per suffix: 200,000 rules (was 30,000); 0.4 s now, old index 3 s at 30,000 and
11 min at 300,000.
- forms of self-continuing rules: the MAX_FORMS count catches the old code (9 million forms);
the time bound is a backstop.
- overlong Turkish words: ~20 ms now; without the caps it ran past 5 minutes.
- capped Hunspell reads: no clock; the "not a regular file" error is the proof (the old reader
never returned from /dev/zero).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Asked to confirm a non-UTF-8 Turkish dictionary still loads: before this PR
tr_TR was read with read_to_string, so a non-UTF-8 file never loaded; now it
goes through read_capped and decode, which didn't know ISO 8859-9 (Latin-5,
the encoding of older tr_TR dictionaries) and refused it as unsupported.
decode now reads `SET ISO8859-9`, and a test writes an ISO 8859-9 .aff/.dic
to disk and finds words with ğ ı ş İ ç ö ü, as stems and with suffixes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Audit finding 20.
- tr::suggest_with refuses words over 64 letters (and words with spaces), like the other
languages' suggesters (40 there; Turkish agglutinated words run past 40). Each candidate edit
copies the whole word, so a long word cost O(n^2) per suggestion request on the UI thread.
- TrDict::contains returns false past MAX_WORD_BYTES (120, the affix engine's bound) instead of
collecting the char indices of the whole word.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Audit findings 10 (#3657) and 15 (#3692).
- ru::hyphen::points: the cuts come in increasing order, so a repeat can only be the last one;
compare with `out.last()` instead of `out.contains` per syllable (O(V^2) on a long Cyrillic
word, during layout).
- hyphenate_word_in: walk the sorted break points alongside the chars instead of searching the
point list for every char. Same output.
Test: russian_hyphenation_and_hyphenated_output_are_linear (8x the letters costs ~8x; the old
code measured 31x and 30x).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Audit finding 4, and the rest of #3661.
- dict.rs: `Dictionary::from_bytes` inflates with `decompress_to_vec_with_limit` (64 MiB; the
bundled en-US one is ~1 MB) and caps the words it keeps (bytes and count): front coding lets a
2-byte line spell out a 255-byte word, so the inflated size alone doesn't bound storage.
- hunspell.rs: one `read_capped` reader — regular files only, size checked on the open handle and
the read itself bounded by `take(MAX_FILE + 1)`, so no TOCTOU between metadata and read and no
endless read from a FIFO or device.
- tr: the Turkish loader reads through `read_capped` and decodes with `hunspell::decode` (it
read both files whole, with no cap at all).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Part of #3613 (audit findings 2 and 6).
- Checker: `suffixed` and `form_has_flag` OR the flags of a group's matching rules into one
mask and recurse once, not once per rule. Same answer (the walk is a disjunction over flags);
16k self-continuing empty-append rules: 6 s -> <1 ms per check (debug).
- Finding 2: `forms` expands each (form, continuation) pair once, walks continuation rules through
a per-flag rule list, and stops at MAX_FORMS (4096) forms / MAX_FORMS_WORK rule visits, so a
dictionary of self-continuing rules can't allocate R^2 strings per stem.
- Finding 6: the suffix index finds a rule's (append, strip) group by hash, not by scanning the
append's groups: building with many strips per append is linear.
Not done here (rest of #3613): a work budget for distinct-strip chains in the checker, rule caps
in parse_aff, proofing off the UI thread.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Wall-clock bounds of 2 s failed on loaded runners (checked against the pre-fix code, put back
in the worktree):
- note referenced many times: no clock; listing the note once per reference gave a million
fields, which the count of 200 catches.
- rich text with many runs: 2 s -> 10 s; 0.35 s now, merging after every run ran past 9 minutes.
- Document_convert with one id repeated: 2 s -> 10 s, 1,000 fields (was 300); 22 ms now, the
per-id check took 81 s with 300 fields and grows with the field count.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Ultrareview finding 3 follow-up (owner's call): with our own socket's
row missing from /proc/net/tcp(6), a listed listener of another user
was let through on the loopback rule. Our uid now falls back to the
owner of /proc/self, so a listed listener with a different uid is
refused either way. An unlisted listener still falls back to the
loopback rule. Test: a_listed_listener_is_checked_against_our_process_uid.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ultrareview finding 3: on Linux, when /proc/net/tcp(6) doesn't list the
listener on Zotero's port (WSL2 mirrored networking with Zotero on
Windows, a local proxy, a sandbox), every connection was refused, so
Zotero couldn't be used at all there. Per the owner's decision, refuse
only when both sockets are found and the listener's uid differs from
ours; otherwise log at debug and apply the plain loopback rule, as on
macOS/Windows/BSD. Test: an_unlisted_listener_falls_back_to_the_loopback_rule.
docs/zotero.md and the progress-log row say so. Non-Linux owner check
stays with #3831.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Audit finding 21 (CWE-306): run_command handed document callback authority to
whatever accepted the TCP connection. Zotero's protocol is fixed and has no
authentication, so WordCraft now establishes what it can about the peer itself:
- the address must be loopback, refused before connecting (ZoteroError::Refused);
- on Linux/Android, before the command is sent, the kernel socket table
(/proc/net/tcp, tcp6) must show the listener's end of this connection owned
by the same UID as ours; another user's listener, or one whose owner can't be
established, is refused. No unsafe, no new dependencies.
macOS, Windows and the BSDs have no such table without OS calls; there the
loopback rule is the guard. The residual risk is documented in docs/zotero.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Audit finding 79 (CWE-117): the CLI printed Zotero's alert text (and, with
--trace, the method name and call/reply JSON) to stderr as received, so a peer
on Zotero's port could end the "Zotero:" line, forge following output or send
terminal escape sequences. escape_peer shows backslashes, control characters
(C0, DEL, C1), line separators and bidi overrides as visible escapes (\\, \n,
\u{1b}), keeping each record on its own attributable line.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Audit finding 32 (CWE-407): Document_convert rebuilt the whole field list for
every id it was given, duplicates included, before deciding whether anything
had to move, so one valid id repeated 100,000 times took 18 s on the UI thread
(now milliseconds). Ids are deduplicated (a repeated id takes its last note
type), all of them are checked against one indexed snapshot before anything
moves, already-placed citations are skipped from the snapshot, and the index
is rebuilt only after a citation actually moves. The id list is capped at
fields::MAX_FIELDS, the most fields Zotero is ever handed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Audit finding 16 (CWE-407): rich::from_doc added each formatting run through
Paragraph::insert_text, which splits and normalizes every earlier run, so a
compact paragraph of alternating formatting was quadratic on the UI thread
(100,000 runs: over 9 minutes; now 0.1 s). Runs are appended, merged only with
the run before, and the paragraph is normalized once. A cumulative MAX_RUNS
budget keeps the text but stops adding runs past it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Audit finding 12 (CWE-400): fields::list expanded a note's fields again for
every body reference to it, so a crafted file with thousands of references to
one citation-heavy endnote multiplied into millions of copied fields on the UI
thread. Each note part is now enumerated once, at its first reference, and the
list stops at MAX_FIELDS fields / MAX_CODE_BYTES of codes. Note references are
walked with the paragraph's objects directly instead of object_at per marker.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Wall-clock bounds of 1-2 s failed on loaded runners (checked against the pre-fix behaviour,
re-enabled in the worktree):
- DIFAT precheck: no clock; the precheck's own error is the proof it ran before cfb.
- long hyperlink: no clock (the old per-character copy took ~2.5 s, too close to any safe
bound); finish_field on the same field charges the work budget exactly one target copy.
- large Prc: 1 s -> 10 s; ~60 ms now, interpreting it per character took 88 s.
- group named by many anchors: 1 s -> 10 s backstop; ~10 ms now. The old code (6-8 s here)
is caught by the group count, [1000; 3] instead of [1000].
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
- repeating_difat_sample is built only for tests: this crate's, and the
readers' through a `test-support` feature they turn on as a
dev-dependency; it is no longer in the production API.
- Tests write valid compound files with the cfb crate in Word's and Excel's
shapes (WordDocument + table stream with Data, summary and ObjectPool
streams; Workbook with summary streams), in 512- and 4096-byte sectors,
plus an 8 MB one whose FAT sectors spill into a DIFAT chain; each passes
cfb_guard::check and opens (no Word- or Excel-made files are added).
- The .doc work budget notes what it leaves out (floating objects, link
targets, alt text, Prm formatting) and logs one warning per kind once the
document is read (#3692).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
CodeRabbit review: the anchor copy cost counted only the struct size, so an aliased picture's
alt text (up to ~12 KB) was copied per walk uncharged. Recursion is bounded by MAX_GROUP_DEPTH.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 63 follow-up. #3642 names three cfb call sites: the .doc reader, the encrypted-package sniff and decrypt in docx/crypt.rs (reached for any .docx name), and the .xls reader. The DIFAT precheck moves from docbin to wordcraft_doc::cfb_guard::check (bytes only, no cfb dependency) and runs before every CompoundFile::open in the three crates. The crypt.rs change is only the two precheck calls (open PR #2125 also edits that file).
Regression tests use the shared hostile sample (cfb_guard::repeating_difat_sample): docbin a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat, docx tests/malformed.rs and formats sheet::xls a_repeating_difat_is_refused_before_cfb_opens_it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Findings 49, 60, 63, 64, 68, 75: progress-log row and a note on the .doc parity row.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 60 (also #3712). Metafile inflation and DIB decoding had per-picture caps only: thousands of BLIP store entries aliasing one delay-stream record each inflated and copied it again, failed inflations cost nothing, and an RLE DIB tiny on disk decoded to its full declared w x h x 4 pixels.
Now every inflate (its declared size, success or not) and every DIB decode (its declared pixels) is charged to a 512 MB per-document decode budget before it runs; past it metafiles are skipped and DIBs stay BMP placeholders. Store entries naming the same (foDelay, end) record share one decode and one media key.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>