Commit Graph
1998 Commits
Author SHA1 Message Date
Martin BallandClaude Opus 5.5 a1f8be2a49 Progress log row and the file-format parity Hostile input line for findings 42, 43, 51, 55, 72, 73 and 76.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:52 -04:00
Martin BallandClaude Opus 5.5 60a64acec3 #3704: a part named in several casings is kept, cached and written once
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>
2026-10-25 21:02:46 -04:00
Martin BallandClaude Opus 5.5 722afa4201 #3742: kept XML is inlined in Flat OPC only when it is one well-formed element
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>
2026-10-25 21:02:46 -04:00
Martin BallandClaude Opus 5.5 1955793aa6 #3648: a chart part is written within the chart limits whatever the model
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>
2026-10-25 21:02:46 -04:00
Martin BallandClaude Opus 5.5 639964b0ed #3692: a glossary is written from the model when the privacy settings ask for changes
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>
2026-10-25 21:02:46 -04:00
Martin BallandClaude Opus 5.5 4c5930dac1 #3692: embedded fonts are copied once per file and within a byte budget
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>
2026-10-25 21:02:46 -04:00
Martin BallandClaude Opus 5.5 12afc75411 Finding 42. read_comments checked each candidate id for a comment whose
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>
2026-10-25 21:02:45 -04:00
Martin BallandClaude Opus 5.5 2337ff75fa #3723: zip entries that fail their CRC check are charged to the package total
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>
2026-10-25 21:02:28 -04:00
Dylan M. Taylor d81b43b336 Merge pull request #3876 from benandez/sec/k
Security: Zotero bridge budgets; loopback-only client, same-user check on Linux only
2026-10-25 20:50:29 -04:00
Dylan M. Taylor fd53f15332 Merge pull request #3871 from benandez/sec/f
Security: legacy .doc reader budgets (#3642, #3708–#3712)
2026-10-25 20:50:23 -04:00
Dylan M. Taylor d41b8b48ca Merge pull request #3868 from benandez/sec/c
Security: bound proofing work in affixes, hyphenation and Turkish (#3661, #3657, #3625)
2026-10-25 20:50:12 -04:00
Dylan M. Taylor 00ba3d4198 Merge pull request #3866 from benandez/sec/a
Security: layout runs in linear time on hostile documents (#3706)
2026-10-25 20:50:07 -04:00
Dylan M. Taylor d7530233cc Merge pull request #2560 from benandez/feat/39-per-user-msi
#39: per-user Windows installer that needs no administrator rights
2026-10-25 20:37:35 -04:00
Martin BallandClaude Sonnet 5.5 1fa7383264 #3692: rebase onto main — drop the Ctx sections list the linked header/footer index replaces
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:19:17 +00:00
Martin BallandClaude Sonnet 5.5 17e648bde7 #3692: rebase onto main — sort the merged test module declarations (rustfmt)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:19:17 +00:00
Martin BallandClaude Opus 5.5 ddb67eaa45 #3692: review — linear-time tests count lookup steps instead of timing them
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
2026-10-26 00:19:17 +00:00
Martin BallandClaude Opus 5.5 74dbec4107 #3692: findings 3 and 13 tests fail clearly on the old code
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>
2026-10-26 00:19:17 +00:00
Martin BallandClaude Opus 5.5 0674573b45 #3692: progress log and layout parity note for linear-time lookups
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>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 ee96c4c99d #3706: author colours ranked once per drawing, not per run or comment
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>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 342855315e #3692: clipped cells looked up by path, not scanned per line
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>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 5a07c46534 #3692: selections across a header index each page's pieces once
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>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 897add99a8 #3692: headers and footers linked through sections once per layout
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>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 629711582f #3692: autofit counts the grid's columns once
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>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 4d027de402 #3692: math text runs find character boundaries in one walk
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>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 7d31c01f6f #3692: bidi clicks and caret steps walk a line's carets once
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>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 6c20b3e609 #3692: proof_text walks the runs once
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>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 c1eb02f23c #39: README: installing both Windows installers gives two entries
Review follow-up (FredApps): the all-users and just-me MSIs install side
by side, so pick one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr
2026-10-26 00:18:09 +00:00
Martin BallandClaude Opus 5.5 7f203bc1c8 #39: per-user Windows installer that needs no administrator rights
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>
2026-10-26 00:18:09 +00:00
Martin BallandClaude Opus 5.5 6bcd997be8 #3692: review — proof timing tests get a margin a busy CI machine can't break
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
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 5a5aa591cd #3661: review — ISO 8859-9 Turkish dictionaries load through the capped reader
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
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 6a66004a06 #3625: review — "bir kaç" still gets "birkaç": the replacement table is checked before multi-word input is refused
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 5cd68ee632 #3692: progress log and gap 3 note for the proofing robustness fixes
Audit findings 2, 4, 6, 10, 15, 20 (#3613 part, #3661, #3657, #3625).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 c187515ba1 #3625: Turkish suggestions and lookups have a word-length cap
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>
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 2cff091c1a #3657: Russian hyphenation and hyphenated output linear in word length
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>
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 e4d2dafa9d #3661: bounded dictionary reads — hyphenation inflate limit, capped Hunspell and Turkish reads
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>
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 45556583a5 #3613: affix rules — checker follows merged flag masks, forms bounded, suffix index hashed
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>
2026-10-26 00:17:17 +00:00
Martin BallandClaude Opus 5.5 d785c05c53 #3692: review — zotero timing tests get a margin a busy CI machine can't break
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
2026-10-25 23:49:15 +00:00
Martin BallandClaude Opus 5.5 bf2a379ec6 #3692: review — say plainly the same-user Zotero check is Linux only; Windows and macOS rely on loopback alone
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 23:49:15 +00:00
Martin BallandClaude Opus 5.5 e251c960d9 #3692: review — doc comments on the functions the Zotero hardening added or changed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 23:49:15 +00:00
Martin BallandClaude Opus 5.5 65f3c3418f #3692: Zotero owner check falls back to this process's uid when our socket row is missing
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>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 7ee101a5b9 #3692: Zotero owner check refuses only another user's listener; unlisted falls back to loopback
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>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 a9cc06dc0f #3692: Zotero client talks only to this computer and, on Linux, only to our own user
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>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 d8d75e4473 #3692: wordcraft-cli zotero escapes peer text on stderr
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>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 ab26f22c4a #3692: Document_convert checks each distinct field id once against one indexed list
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>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 020194ebdb #3692: Zotero rich text is built in one pass, not re-normalized per run
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>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 90efee7636 #3692: Zotero lists each note's fields once, within a field and code-byte budget
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>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 58898d5168 #3692: review — docbin timing tests get a margin a busy CI machine can't break
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
2026-10-25 23:47:39 +00:00
Martin BallandClaude Opus 5.5 17d19b42c5 #3642: review — the DIFAT sample is test-only, valid .doc/.xls files pass the guard, and budget drops are logged
- 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
2026-10-25 23:47:39 +00:00
Martin BallandClaude Opus 5.5 bd3d260837 #3708: review — finish_field's doc comment back on finish_field
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 23:47:30 +00:00
Martin BallandClaude Opus 5.5 3aadd0a4af #3642: review — compound-file check refuses a FAT sector listed twice or more FAT sectors than the file needs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 23:47:30 +00:00