many_formatted_leading_breaks_trim_quickly failed at its 2 s bound on a machine at load ~50
(it takes about a second there). Bounds now sit at least 10x over the fixed code's time on a
loaded machine and far under the old code's on the same input (measured by running the
pre-fix code, capped at 400 s):
- HTML leading breaks: 2 s -> 20 s; ~1 s now, old trim ran past 6 minutes.
- LaTeX nested multicolumn: 2 s -> 10 s; under 1 s now, old unwrapping took 213 s.
- Markdown delimiters: 2 s -> 10 s; under 1 s now, old backwards search past 6 minutes.
- Para::trim blank spans: 1 s -> 10 s; 0.16 s now, old Vec::remove(0) past 6 minutes.
- repeated sheet headers: 2 s -> 10 s; 20 ms now, old numbering past 6 minutes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Every limit this branch added that leaves data out now says so:
- font names over 256 bytes and links over 8 KiB (HTML, ODT, RTF,
LaTeX, Markdown), and RTF style names cut to 256 bytes;
- runs' links and fonts past the 64 MiB run-string budget;
- spreadsheet rows refused for being over the text budget (ODS and
Grid::push), once per sheet.
model::dropped logs each kind once per imported document; each
importer's entry point starts a new document with begin_import. Tests
capture the warnings (crate::test_log) and check each is logged once,
again for the next document, and not at all when nothing is dropped.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
CodeRabbit review: only an ASCII space triggered the angle brackets, but the reader ends a bare
destination at any whitespace (U+00A0, U+3000), so such a target read back as plain text.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Findings 58 and 65. Every span deep-copied its inherited link and font
strings, so one long name or Markdown definition times many spans or
references expanded a small file into gigabytes. Fmt now holds both as
Arc<str> (spans and Markdown references share one copy), names over
256 bytes and links over 8 KiB are dropped at the importers (RTF, HTML,
Markdown, ODT, LaTeX), and the document builder stops giving runs link
and font copies past 64 MiB.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 57: Writer::code replaced `\end{verbatim}` in a code line and then removed control
characters, so `\end{verb<U+000E>atim}` became a real closing delimiter and the rest of
the line was compiled as TeX. Control characters are now removed first and the
replacement runs on the final text.
Finding 52: the table reader unwrapped every nested \multicolumn / \multirow in a cell,
rescanning the remaining braced content each time (quadratic; the nesting limit didn't
apply). At most MAX_NEST wrappers are unwrapped now; real tables use one or two, which
read as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 53: link_target removed `<`, `>` and newlines from a destination after
is_safe_href had accepted it, so an accepted relative target such as
`java<script:alert(1)` was written as `<javascript:alert(1)>` (or `/</host` as
`//host`). The exporter now backslash-escapes `<`, `>`, a backslash that a reader would
treat as an escape, and `&` that starts an entity reference, so a CommonMark reader gets
back exactly the checked target; control characters, which the check ignores, are still
left out. The importer now ends a `<...>` destination at the first unescaped `>`, so these
round-trip. Ordinary destinations are written as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 67: when emphasis was flattened, every delimiter run searched backwards for the
nearest text node to borrow its formatting, reaching the start of the paragraph when the
paragraph was all delimiters (or links): quadratic. The last text node's formatting is now
carried forward through the loop, and nothing is looked up for a delimiter with no literal
left. The result is the same.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 74: trimming removed each leading whitespace-only inline with Vec::remove(0),
shifting the rest every time. A paragraph opening with many separately formatted line
breaks (HTML <b><br></b><i><br></i>…, LaTeX \\) took quadratic time on open or paste.
The removable prefix is now counted and drained once; the first remaining span is
trimmed as before. 200,000 pairs ran past 60 s before, now milliseconds.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 40: field_names restarted every repeated header at "Name 2" and compared each
candidate with every name so far (cubic). It now keeps the names in an ASCII-lowercased
set and remembers each base name's next number; existing headers like "Name 2" stay
collision-free and the output is unchanged.
Finding 47: an ODS office:string-value bypassed MAX_CELL_CHARS and was copied for each
repeated column before the text budget was checked. The attribute is now capped like
<text:p> text, a row stops being built (and is refused) once its text can't fit the
remaining budget, and Grid::push measures what fits before cloning.
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>