Finding 73: Package lookup matched part names case-insensitively, but relationship targets kept the spelling they were written in, so every casing of one part became its own media entry, graphic parse and passthrough key, and a save wrote a full copy per spelling (64 spellings of a 64 KB picture: 64 media parts). Package now keeps a lowercase index, Package::canonical returns the stored spelling, and rels() resolves internal targets to it, so every downstream cache is keyed by the one part; get_shared uses the index instead of a linear scan.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 55: flat.rs xml_fragment inlined kept part bytes into pkg:xmlData after a prefix check only, so a part whose text closed the wrapper (</pkg:xmlData></pkg:part><pkg:part ...>) wrote parts of its own into the saved .xml - e.g. a second core properties part. Now the bytes are inlined only when quick-xml reads them as exactly one balanced element with nothing but whitespace, comments and PIs around it; anything else is written as pkg:binaryData and still round-trips.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 76. chart_xml trusted its ChartSpec to be sanitized, but a .json
document is deserialised without ChartSpec::sanitize, and every series
repeats every category, so a chart past the limits multiplied into a
gigabyte-sized part on save. chart_xml now writes a bounded, sanitized copy:
only the series and points the limits keep are copied before sanitizing.
(Sanitizing on .json open, and SmartArt, are in the engine and not covered
here.)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 72. An unchanged glossary was carried byte for byte, judged by a
fingerprint of its entries that leaves out the document's privacy settings,
so a save with "remove personal information" or "remove date and time" kept
the authors and dates of the glossary's tracked changes. With either setting
on, the glossary is now written from the model, which applies them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 51. embedded_fonts copied a part's bytes for every font-table
reference, so the 64 references allowed to one 256 MB part could make 16 GB of
copies on open. A reference repeating one already taken (same part, key and
name) is skipped, and copies stop at MAX_FONT_BYTES (one part's limit),
checked before each copy is made.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
w:id isn't a number against every id mapped so far, so a comments part of
many non-numeric ids took time quadratic in their count (49,000 comments:
2.4 s, now 0.35 s in a debug build). The ids mapped before the part are
collected into a set once; ids given in the part are already in `entries`.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 43. Package::open_limited dropped an entry whose read failed before
adding the bytes it had already inflated to the running total, so many
entries with a wrong CRC (checked only after the whole stream inflates) could
each inflate up to the part limit for free. The bytes are now charged first
and the size check runs before the read result is looked at.
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