Commit Graph
2003 Commits
Author SHA1 Message Date
Martin BallandClaude Opus 5.5 22df7caf4d #3692: review — formats timing tests get a margin a busy CI machine can't break
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
2026-10-25 21:00:54 -04:00
Martin BallandClaude Opus 5.5 124243b1be Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:00:54 -04:00
Martin BallandClaude Opus 5.5 2c4f4669c6 #3713: review — importer limits that drop fonts, links, style names or sheet rows log a warning once per document
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
2026-10-25 21:00:20 -04:00
Martin BallandClaude Opus 5.5 41de47398a #3713: review — ODS repeated cells are budgeted only for the copies the grid can still keep
With one cell slot left, a long value repeated a thousand times was refused for all
its copies' text although Grid::push keeps only the first, which fits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:00:20 -04:00
Martin BallandClaude Opus 5.5 582f411876 #3692: Markdown export wraps link destinations with any Unicode whitespace in <…>
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>
2026-10-25 21:00:20 -04:00
Martin BallandClaude Opus 5.5 d8501a5aeb #3713: rustfmt
Finding 58 follow-up: formatting only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:00:20 -04:00
Martin BallandClaude Opus 5.5 9bee82220e Findings 40, 47, 52, 53, 57, 58, 65, 67, 74, 77 (#3692, #3705, #3713,
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:00:19 -04:00
Martin BallandClaude Opus 5.5 79e3610cbb #3713: importers drop overlong font names and links; spans share them; runs' copies budgeted
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>
2026-10-25 20:59:48 -04:00
Martin BallandClaude Opus 5.5 faa8221bca #3692: LaTeX — verbatim delimiter check after control characters go; multicolumn/multirow unwrapping bounded
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>
2026-10-25 20:59:47 -04:00
Martin BallandClaude Opus 5.5 e9fe3d29b6 #3692: Markdown export escapes link destinations instead of deleting characters
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>
2026-10-25 20:59:47 -04:00
Martin BallandClaude Opus 5.5 f6398e29a9 #3714: Markdown import tracks the text before each delimiter instead of searching back for it
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>
2026-10-25 20:59:47 -04:00
Martin BallandClaude Opus 5.5 42c254c87a #3705: Para::trim_with drops the leading blank spans with one drain
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>
2026-10-25 20:59:47 -04:00
Martin BallandClaude Opus 5.5 89a64d1ef5 #3692: sheet import — linear duplicate-header naming; ODS string attributes capped and over-budget rows refused before copying
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>
2026-10-25 20:59:47 -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