* #3871: review — metafile input charged to the decode budget; inline BLIPs deduped; budget scales with input
- A compressed metafile is charged max(inflated size, zlib input), so a record claiming to inflate to nothing still costs its input.
- Inline pictures share one decode per BLIP record, cached by (stream, foDelay, record's own length) instead of the FBSE size field.
- The decode budget is max(512 MB, 64 x input bytes), capped at 2 GiB, so documents of many large legitimate DIBs decode; high-ratio bombs stay capped.
- Decode-budget refusals get their own log line.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
* #3871: review — a DIB's copy into a BMP is charged to the decode budget before it is made
The charge was w*h*4 after the copy, so a 1x1 header followed by megabytes of tail cost 4 bytes per picture. It is now max(pixels, DIB length), charged first; a bitmap whose copy doesn't fit is dropped rather than copied. The doc comment on the budget scale now says RLE8/RLE4 bitmaps (about 510x) rely on the 512 MB floor.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Wall-clock bounds of 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 under the same load):
- many distinct media: 0.5 s now; the old scan of every image took 131 s.
- object walks: under 0.3 s now; the old walks took 360 s (#3614).
- delete across lone control markers: 3,000 markers (was 1,500); 1 ms now, the old delete
took 18 s at 1,500 and 114 s at 3,000.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
- A text box whose story doesn't fit what is left of the paste's byte
budget gets no story, but later boxes with smaller stories are still
copied while the budget lasts (it used to zero the budget). Each
source story is costed once per paste, so skipping a big story many
times doesn't walk it again. The paste logs one warning with the
number of boxes left empty. wordcraft-doc now depends on log.
- Document::media_index is a private field and MediaIndex a private
type: a lookup cache, not API. The one struct-update literal outside
the crate (a formats test) builds the document field by field.
- kept_control_markers documents that delete_range relies on its
sorted, deduplicated output; delete_range sorts it if it ever isn't,
and control_markers_kept_are_sorted pins it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Finding 80 (CWE-400): adopting a fragment deep-copied a box's source story
once per text box showing it, including boxes inside the story being copied,
so 4096 boxes (the story budget) pointing at one large story copied it 4096
times. Now: a box showing a story it is inside gets no story (a cycle);
blocks without text boxes are shared with the source (copy-on-write Arcs)
instead of copied; and a paste adopts at most MAX_PASTED_BOX_BYTES (32 MiB,
estimated text + runs + objects) of box content, charged before copying,
after which later boxes keep no story, as when the story budget runs out.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 71 (CWE-407): loops of object_offsets + object_at recounted the
objects before every offset, so walking a paragraph's objects was quadratic
(40k markers: 360 s for the regression test before, 0.2 s after).
Paragraph::objects_with_offsets yields (offset, object) in one pass, and
every such walk in crates/doc uses it: the field scan (field_ranges, run on
every docx load), control events, bookmarks, perm ranges, text_box_anchor,
past_field_ends, typing_pos and the markers a range delete keeps. Also:
perm_ranges keeps open ranges per id instead of searching a list for each
end; kept_control_markers pairs each container with its table wraps once
(was once per marker) and probes a set instead of every range.
Part of #3614: the engine, docx reader and UI callers it lists live outside
crates/doc and still use object_at in loops; they can move to
objects_with_offsets separately.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 48 (CWE-407): P::word scanned and copied the rest of an alphabetic run
for every letter that didn't start a function name, so a 20,000-letter
equation did ~200 million steps outside the parser's step budget (5 parses:
8.3 s before, well under 0.1 s after). The scan now stops one past the
longest name in FUNCTION_NAMES (computed at compile time); a longer run is
never a name, as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 45 (CWE-407): Document::add_media compared each new image with every
stored one, so an import with many distinct small images did quadratic work
(40k images: 213 s before, 0.26 s after). A MediaIndex cache (not saved,
ignored by ==) maps bytes to their first key, is rebuilt when media changed
size behind its back, and every hit is checked against media. Generated
image{n} names also skip taken names once instead of on every add.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Folding zip entries whose names differ only in case now keeps the
spelling a relationship part targets, and the first in byte order only
when no relationship names any of them. The fold logs one warning per
package, not one per entry.
Limits that leave data out now say so, once per document or save:
- a chart past the chart limits on save (series or points dropped);
- embedded fonts past the byte budget (one warning with the count);
- a kept glossary that can't be cleaned of personal information and is
written from the model instead.
Tests capture the warnings (crate::test_log) and check each is logged
once.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Ultrareview finding 1. With "remove personal information" or "remove date
and time" set, the glossary was always written from the model (01c78899),
which holds only the entries the reader took: one without a name, past the
entry cap or with a bad content type was lost, and with none left the whole
glossary part went. The kept parts are now written with the same names and
dates removed that the writer leaves out of the main document (annotation
authors become "Author", comment initials "A", annotation and UTC dates
go; a people part's authors and presence too), rewritten with quick-xml and
matched by namespace. A part that can't be rewritten (malformed, UTF-16)
falls back to the model, so the privacy guarantee holds either way.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
named_instances_and_collection_members_are_capped: 2 s -> 10 s. The face counts in the test
(one face for a million identical members, MAX_FILE_FACES from add_font) are what catch the old
code, which built a face per member; the time (under 0.1 s on a loaded machine, so 10 s is over
100x) only backs up "read once".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
The hinting breaker stays wall-clock: skrifa's interpreter budget is fixed,
counts instructions rather than the points each one moves, and reports no
work count, so there is nothing deterministic to bound. The comment now says
the result can depend on the machine by design, and that only faces needing
the interpreter for their contours are hinted at all. Cargo.toml notes that
brotli-decompressor is the crate and version wuff already uses: pure Rust,
forbid(unsafe_code) by default, wasm-ready, BSD-3-Clause/MIT.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
CodeRabbit on #3869: two concurrent install_font calls could pick the same
destination and temporary, and the leftover-unlink retry could remove the
other call's open temporary. write_unique now holds a process-wide lock.
In-process only (noted in a ponytail comment).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Ultrareview finding 5: the slow-draw count only grew, so three draws slowed
by a busy machine at any point in a session switched hinting off until
restart, breaking hint-reliant faces (MingLiU, DFKai-SB). Hinting now stops
only when at least three draws were slow and they are at least one in 16 of
the face's hinted draws: a face slow throughout still stops at its third
glyph, and slow glyphs spread among fast ones are still bounded. Counting
interpreter work instead of wall-clock stays with #3827.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CodeRabbit review of finding 62's fix: one hinted draw slowed by a busy
machine (scheduler pause, page fault) switched hinting off for the face for
the rest of the process, leaving hint-reliant faces with timing-dependent
missing strokes. A strike counter now drops hinting only at the third slow
glyph.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 62 (partial): skrifa 0.47 caps a hinting run at 1M instructions
but not the points each moves (SHZ, IUP... walk a whole zone), and a font
opts into the interpreter by its family name. Faces asking for a
twilight zone over 1,024 points or a stack over 4,096 values get no
interpreter; a glyph whose hinting buffer passes 256 KiB is drawn
unhinted; and (native) one hinted glyph over 50 ms turns hinting off for
its face. Residual: a face within the limits can still spend about
1M x zone size point moves on one glyph (and its prep program) before
the strike lands; the full fix is a point-work budget in skrifa or an
isolated worker with a deadline.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 44: font.add stored the file in the user font folder and
font.remove deleted stored files directly, bypassing the automation
write root (#769). Both now check the user font folder against the
write policy in force before touching the database or the disk; with no
folder (headless) a font still lasts for the session only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>