chore: sync upstream (218 Commits) #6

Merged
alf merged 218 commits from chore/sync-upstream-20261023 into main 2026-10-23 11:10:24 +00:00
Owner

Upstream-Sync von upstream/main (218 Commits). Fast-Forward.

  • 0dc77fe4 Merge pull request #4129 from aiongg/fix/coderabbit-c5-forms-misc
  • 5f7b9e24 Merge pull request #4109 from aiongg/fix/coderabbit-a1-audit
  • 7fa38de1 Merge pull request #4170 from dylanmtaylor/fix/sonar-shell-scripts
  • 1d29f067 Merge pull request #4169 from dylanmtaylor/chore/sonar-scope-vendor-and-spec-hashes
  • e93f11e8 fix(packaging): use [[ tests and name positional parameters in the scripts (SonarCloud shelldre:S7688, S7679, S7682, S131)
  • bd9030b2 chore(sonar): exclude vendored crates and the spec-mandated MD5/RIPEMD-160 (SonarCloud rust:S4790)
  • 7345e5f5 Merge main into fix/coderabbit-a1-audit
  • 07a9109f Merge remote-tracking branch 'origin/main' into HEAD
  • 93b25f67 Merge main into fix/coderabbit-c5-forms-misc
  • de916329 Merge pull request #4151 from aiongg/fix/coderabbit-c2-edit-organize
  • 68422671 Merge remote-tracking branch 'origin/main' into HEAD
  • d31a96f4 Merge pull request #4121 from aiongg/fix/coderabbit-c1-js-xfa
  • 8ee1ac0c Merge pull request #4117 from aiongg/fix/coderabbit-b-caps
  • 3cee9f33 Merge pull request #4128 from aiongg/fix/coderabbit-c4-core
  • f7a3a52f Merge pull request #4114 from aiongg/fix/coderabbit-b-shared
Upstream-Sync von `upstream/main` (218 Commits). Fast-Forward. - 0dc77fe4 Merge pull request #4129 from aiongg/fix/coderabbit-c5-forms-misc - 5f7b9e24 Merge pull request #4109 from aiongg/fix/coderabbit-a1-audit - 7fa38de1 Merge pull request #4170 from dylanmtaylor/fix/sonar-shell-scripts - 1d29f067 Merge pull request #4169 from dylanmtaylor/chore/sonar-scope-vendor-and-spec-hashes - e93f11e8 fix(packaging): use [[ tests and name positional parameters in the scripts (SonarCloud shelldre:S7688, S7679, S7682, S131) - bd9030b2 chore(sonar): exclude vendored crates and the spec-mandated MD5/RIPEMD-160 (SonarCloud rust:S4790) - 7345e5f5 Merge main into fix/coderabbit-a1-audit - 07a9109f Merge remote-tracking branch 'origin/main' into HEAD - 93b25f67 Merge main into fix/coderabbit-c5-forms-misc - de916329 Merge pull request #4151 from aiongg/fix/coderabbit-c2-edit-organize - 68422671 Merge remote-tracking branch 'origin/main' into HEAD - d31a96f4 Merge pull request #4121 from aiongg/fix/coderabbit-c1-js-xfa - 8ee1ac0c Merge pull request #4117 from aiongg/fix/coderabbit-b-caps - 3cee9f33 Merge pull request #4128 from aiongg/fix/coderabbit-c4-core - f7a3a52f Merge pull request #4114 from aiongg/fix/coderabbit-b-shared
alf added 218 commits 2026-10-23 11:10:23 +00:00
README: the app icon above the title
Each font-family in assets/demo/showcase.html named only a specific font with
no generic fallback. Add an appropriate generic (serif/sans-serif/monospace/
cursive/math) so text still renders if the specific face is unavailable.
* Build each crate's integration tests as one binary

Every tests/*.rs file is its own executable that links the whole crate
graph. Move each crate's test files into tests/it/ as modules of one
tests/it/main.rs, for apps/pdfcraft, apps/pdfcraft-cli, automation, cos,
crypt, engine, render, sign and ui-egui. ui-egui's ui_group binary and
the support modules move into tests/it/ too. Crates with a single test
file keep it. The test executables go from 150 to 52, and every crate
runs the same number of tests as before.

ui-egui perf keeps its own binary (own process): its frame-time budgets
would charge each measurement for the other tests running beside it.

What the move needs:
- include_str!/include_bytes! and #[path] paths gain one "../".
- ui-egui: one GPU lock in tests/it/main.rs replaces the five per-file
  ones (content, document_links, editing, fill_sign, view), so one
  process creates one wgpu device at a time. The drop-line test in ui.rs
  and the screenshot blocks behind PDFCRAFT_*_SHOT(S) and
  CRAFT_UI_DOCKING_DIR, which render without holding it, take it too.
- pdfcraft winit_wayland_scroll: the vendored winit source it compiles
  names crate::event, so that shim lives in main.rs.
- parity evidence names the new paths.

* Name the one-binary test paths in docs and comments

- READMEs, docs, ATTRIBUTION and source comments name tests/it/<file>.rs.
- The oracle README's example filters with --test it encryption::qpdf_agrees.
- pdfcraft's GPU lock comment points at the UI tests' shared lock in
  crates/ui-egui/tests/it/main.rs.
Saving with encryption left a dictionary's /Contents in clear whenever it
had a string /Contents and an array /ByteRange, whatever else it was. A
source file could add a /ByteRange to an ordinary annotation and its
comment text was then written unencrypted. A dictionary with a /Subtype or
a /Type other than Sig or DocTimeStamp is never a signature dictionary, so
its /Contents is encrypted like any other string.
A stream that named its own crypt filter (/Filter /Crypt with
/DecodeParms /Name) kept that name when the document was given new
password protection. The new /Encrypt defines only /StdCF, so the
inherited name resolved to /Identity and the stream was written in
clear into a file the user asked to protect. When saving with protection
applied by set_encryption, a selector the new handler does not define is
removed with its parameters and the stream follows the new policy.
Saving with the original handler keeps the source's selectors.
The qpdf oracle test wrote into temp_dir()/pdfcraft-enc-<pid>, reusing the
directory if it existed and following any symlink inside it, so another
local account could prepare it and have the test overwrite a file of the
developer's. The directory now has an unpredictable name, is created by
the test (mode 0700 on Unix) and the files are created new.
The example passed a constant seed to set_encryption. AES-256 derives the
file key from the seed alone, so anyone could rebuild the key and read a
file it protected without either password. The seed now comes from the
operating system's randomness through RandomState, as in the engine.
An OCSP response's signer counted as the issuer itself whenever its
certificate carried the issuer's subject name. A response naming its
responder by key hash could embed a certificate for any key with the
issuer's name copied in, skip the delegation checks, and mark a valid
certificate revoked. Whether the responder is the issuer now follows from
how it was selected (the issuer's own name or key hash); every embedded
certificate must be signed by the issuer and carry the OCSP-signing
purpose.
Revision checks compared objects only. A later revision that pointed the
trailer at another catalog already present in the signed file changed no
object, so the signature stayed valid while the reader showed a different
document. A /Root that differs from the signed revision's is now a
disallowed change to the document structure.
Chain building accepted an intermediate CA without looking at its critical
extensions, which were checked only on the signer's certificate. A
subordinate CA limited by critical name constraints could vouch for a
signer outside its namespace, because PdfCraft does not enforce those
constraints. A CA certificate (other than a self-signed root, whose
limits are the trust store's business) with a critical extension
PdfCraft doesn't process is refused as an issuer, and the reason is
reported.
A timestamp counted as trusted time whenever its signer's certificate
chained to a trusted certificate, whatever the certificate was issued
for. Any key from a trusted CA could then vouch for an arbitrary time.
Trusted time, for a document timestamp and for a signature's embedded
one, now also needs a certificate whose extended key usage includes
timeStamping, whose key usage allows signing, and which has no critical
extension PdfCraft doesn't process (RFC 3161 §2.3). The test timestamp
authority gets such a certificate.
Czech and Vietnamese get translations; Catalan, Finnish, Lithuanian,
Brazilian Portuguese and Romanian carry the English until translated.
The only mutable use of the DirBuilder is the Unix mode call, so the binding
is mutable only where that call exists.
The cover walk cleared the current path when it restored the graphics
state, but the current path is not part of the graphics state. A
rectangle built before Q and filled after it paints a box over the
text, and the audit missed it and reported the page clean.
The annotation scan and the form XObject walk read /Subtype only as a
direct name. A /Subtype stored as a reference to the name read as
none, so a /Redact annotation or a covering form named that way was
skipped as an unrelated object and the page reported clean. Both now
follow the reference, and a reference that cannot be loaded fails the
audit.
A text line joins compatible runs on one baseline, and the audit took
the line's last painting operator as its paint position. Text drawn
after a cover on the same baseline joined the covered words and moved
the line past the cover, so the covered words went unreported. The
audit now compares against the line's first painting operator: any
run painted before the cover is checked, and runs painted after it
in the same low-contrast color are unreadable on it too.
Every cover was compared with every text line of its page, and each
finding kept its own copy of the text it covered. Both grow with the
square of the content: a few megabytes of covers over one long line
meant hours of comparisons and gigabytes of report. The audit now
counts comparisons per page and covered-text bytes across the document
and fails with an error past either bound, never with a partial
report.
The audit returned the text under each cover whatever the document's
permissions, and the CLI and the automation tool printed it. A user
whose password withholds copying could read through the audit the text
that text extraction refuses them. The audit now reports every finding
for such a document with empty covered text; the owner password lifts
the restriction as it does elsewhere.
MAX_DECODED bounds one decode, so a page whose /Contents repeats a
reference to one compressed stream, or a form drawn many times, gets a
fresh 1 GiB allowance per occurrence. DecodeBudget is one cumulative
decoded-byte allowance plus a form-entry count and a seen-set of stream
references, shared by a whole page walk. A decode that runs past the
remaining allowance spends all of it.
The checker decoded every /Contents entry and every form a page drew
with a fresh 1 GiB allowance each, so a page repeating one compressed
stream reference produced gigabytes. The two copies of the page-content
helper are one now, and it and the form walk charge a per-page
DecodeBudget.
Image measurement and fragment merging decoded every /Contents entry
and every form occurrence with a fresh 1 GiB allowance, so repeated
references to one compressed stream multiplied memory. Both charge a
per-page DecodeBudget. A page whose content does not decode within it
is left unmerged, and its images are not resampled, since a use may be
missed; fragment merging no longer rewrites a page from content it
could not fully decode.
Printing and the label text-direction check decoded every /Contents
entry with a fresh 1 GiB allowance, and the direction check re-entered
forms at every Do with only a depth limit, so a few hundred bytes of
nested repeated draws meant hundreds of thousands of form walks. Both
charge a per-page DecodeBudget: printing refuses a page whose content
runs past it, and the direction check stops reading there.
Pruning and page_as_form joined every /Contents entry with a fresh
1 GiB allowance each, so a page naming one compressed stream many
times became gigabytes. Both share one helper that decodes against a
per-page DecodeBudget and fails when it runs out.
The error named the bytes left at the failing decode (often 0), which
says nothing about why the operation stopped.
Each field action and document-script entry decoded its /JS stream
with a fresh 1 GiB allowance and kept its own copy, so many fields or
name-tree pairs naming one compressed stream retained gigabytes. A
reading of the fields, one field's actions, and the document scripts
each share a 64 MiB DecodeBudget for their script streams.
The interpreter entered a form again at every Do with only a nesting
limit, so a few shared forms drawing each other produced exponential
work in apply, sanitize and verify. Scope carries a per-page
DecodeBudget for form entries and form bytes; past it, apply refuses
the page and verification counts the form as possible residue. Page
content streams are decoded against a page budget too.
The redaction audit treats any damaged stream as a failure, so it needs
the strict decoder under the same shared allowance.
The cover walk decoded every /Contents entry and every form draw with
a fresh 1 GiB allowance; the operator bound does not count whitespace,
so a large blank form drawn many times meant hours of decoding. The
walk charges a per-page DecodeBudget with the strict decoder, and
spending it fails the audit.
The editor's page readers decoded every /Contents entry and every form
entry with a fresh 1 GiB allowance, and the reading-order image
inventory added a record per image draw in every form entered, so a few
shared forms drawing each other produced millions of records. Content
streams and form entries are decoded against one per-page DecodeBudget
(content past it is an error, so no edit rewrites a page from part of
its content; forms past it are reported truncated), and an inventory
past 100,000 image draws is refused.
Callers with a tighter limit for one stream (an embedded font program)
need it alongside the shared allowance. A decode that runs past that
bound charges the bound, the work it took, so repeated failures still
spend the allowance.
embedded_sfnt decoded each program with a fresh 16 MiB allowance and
charged nothing when the program turned out unusable, so a page whose
font resources all named one bad program inflated it once per alias.
embedded_sfnt_within charges every decode to a caller's DecodeBudget.
text_editor_fonts counted only the programs it kept against its 32 MiB
budget, so up to 128 aliases of one unusable program were each inflated
in full. Every decode now spends a shared 64 MiB allowance.
The image module kept its own copy of the page-content reader; the one
in text carries the page decode budget.
The parser pushed every array element and dictionary value with only a
nesting limit, so a small compressed object stream decoding to a long run
of '0 ' became tens of millions of Object values. Each top-level parse now
counts the objects it builds and fails past 4M, including inside
tolerated junk.
The cloud border clamped bumps per edge only, so a polygon with many long
edges asked for 10,000 cubic segments on each one. The bump radius now
grows so the whole outline draws at most 20,000 bumps plus one per edge.
The link listing copied the action's URI into a new String for every
/Annots entry, so one annotation referenced many times, or many small
links sharing one long URI string, multiplied that string in memory.
The listing skips annotation references it has already listed and keeps
at most 16 MiB of web-address text; a link past that lists as an
uneditable action, which leaves it unchanged in the file.
Image export decoded a stream lookup table under the general 1 GiB
stream limit although a table holds at most 1024 bytes, and every image
sharing the palette decoded it again. The table now decodes within
64 KiB and a larger one is refused for that image.
Looking for an already embedded full program decoded every candidate up
to 17 MiB, once per resource naming it, so a page whose PCFull resources
all alias one compressed stream, or many marked streams, made one text
edit inflate gigabytes. Candidates now decode within the program's own
length, each once per embedding, and at most 16 in all; the resource
check compares its CMap streams within their expected lengths too.
Both CMap streams decoded under the general 1 GiB stream limit, so a
small Flate stream of whitespace inflated to a gigabyte that was then
lexed on every font load. They now decode within 16 MiB, like the
font programs beside them, and a larger CMap is not read.
The listing read a measurement for every /Annots entry, so one annotation
referenced many times, or many small annotations sharing one indirect
2048-point vertex array, multiplied its points and text in memory. The
listing skips annotation references it has already read and stops,
marked truncated, once the kept points and text pass 32 MiB.
Snap geometry charged decoded bytes only on success, so a /Contents
array repeating one stream that inflates past the 16 MiB limit ran that
inflation for every entry without spending any budget. A tolerant decode
fails only on an unsupported filter or after producing the remaining
budget, so a failure now spends the rest of the budget and later parts
are skipped as truncated.
Reading the colour space from an ICC header when /N is missing decoded
the whole profile under the general 1 GiB stream limit to look at four
bytes. The profile now decodes within 16 MiB; a larger one leaves the
component count unknown.
Inspection built an annotation record, with owned copies of its text,
for every /Annots entry on every page, so one comment referenced many
times, a page-shared /Annots array, or many small comments sharing one
long /Contents string multiplied that text in memory. Pages are read in
order, an annotation or /Annots array already listed is skipped, and
the listing stops once the kept text and geometry pass 64 MiB.
Revocation checking decoded every /CRLs and /OCSPs entry under the
general 1 GiB stream limit and kept them all before parsing any, so one
compressed stream referenced many times multiplied decoding and memory.
Evidence now decodes lazily, each stream once and within the 2 MiB
evidence cap, with at most 1024 entries and 64 MiB per array.
Nested repeating subforms multiplied their instances (up to 50 per level
over 64 levels), and an empty instance neither measured nor placed
anything, so it spent none of the measurement or item budgets. Each
instance now counts as a measurement, and layout stops with TooLarge
once the budget is spent.
The packet reader decoded the stream of every /XFA array entry and
charged only the decoded output to its 64 MB budget, so one stream that
decodes to almost nothing (Flate over hex whitespace), listed many
times, repeated a large decode for free. A stream reference already
read is skipped.
Clippy's explicit_auto_deref rejects the explicit derefs.
eval and the Function constructors compiled code built at run time, which never
passed the size and nesting checks a script passes before it runs, so a short
form script could hand boa's recursive parser a deeply nested string and
overflow the stack. Form scripts now refuse to compile code from strings. XFA
scripts, which the runner itself starts through eval, hold such code to the
same checks as the script.
text_lines returned a copy-restricted document's lines, ocr_recognize returned
the words it recognised, and text_edit on a paragraph echoed the paragraph's
own text, all without checking the copying permission that the other text
tools check. text_lines is refused like them; recognising and restyling still
work with permission to change the document, and leave the text out.
Compare read both documents' words out with no check of the copying
permission, and its result, report and difference comments carry that text,
so a copy-restricted document's text could be taken out through a comparison.
Both documents must now allow copying.
Printing checked the print permission but not the separate high-quality one
(Table 22, bit 12), so a document limited to low-resolution printing came out
as its original vector pages, text included. Such a document now always
prints as pictures of at most 150 dpi, whatever the settings ask for.
A step that applies an edit logged any error and carried on, which is right
for a flatten with nothing to flatten but not for removing hidden
information or sanitizing: on a signed file the removal was refused, and the
unchanged file was written out as the action's successful result. Those
steps now fail the action unless there was nothing to remove.
The atomic writer gave the new file the replaced file's mode but left it in
the saver's own group, so a file readable by one group became readable by
another. The staging file joins the replaced file's group before it gets the
mode and before anything is written; where it can't, group and others get no
access.
Pages printed as pictures were drawn with everything on them, so form fields only
printed the page content, and the document with stamps printed every comment. The
pictures are drawn from a copy holding only what the vector print draws.
util.printd measured the run of identical characters at every position of
the format, literals included, but copied a literal one character at a time.
A format with a long stretch of one literal character rescanned the rest of
that stretch at every step: quadratic work from one script-built string.
Only the date letters are read as runs now.
The AcroForm script host kept every alert, console line and request a
script made, each as long as the script made it. A loop printing a long
script-built string held a copy per iteration. The host applies the XFA
host's limits: message and console counts, the effect count for requests,
each message, URL and field name cut to a line, and a byte budget across
all of them, with a closing console line saying how many were left out.
The XFA script host capped the number of effects but not their size: URLs,
focus targets and resetData lists were kept whole, a resetData list kept
every repeated name, and values written to fields were each cut but not
counted together. A loop repeating one long script-built string kept a copy
per effect or field. URLs, focus targets and reset names are cut to a
message's length, reset lists name each field once, and every effect,
console line and value written charges a byte budget for the run; past it
they are left out and noted. FormCalc charges an assignment to many fields
once per field copied.
Text layout broke a word wider than its line by measuring the whole growing
piece after every character, and counting its characters again each time.
A long unbroken word in a wide box with a small font made that quadratic.
The piece's advance is kept as a running sum of character widths instead.
Rich text resolved every embedded-field reference to a fresh copy of the
field's value, so a template repeating one reference to a long value many
times laid out a copy per reference. One text block expands at most as many
bytes of embedded values as a text of its own may hold; past that the
values are cut.
Every instance of a repeated subform records its own copy of each script,
and only the number of events was capped. A long script inside nested
repeating subforms was copied once per instance before any script ran. The
events hold at most as much script text as the XFA packets may; past that
the live form is marked truncated, as it is past the event cap.
write_data_ops expanded every instance-count op into one queued node per
instance, then deduplicated the pass. A script adding rows one at a time
asks for one more instance each time, so n rows queued about n*n/2 nodes
before the deduplication. Each op queues only the instances the pass has
not queued yet, and a budget bounds the instances queued across passes
that removals separate, with a warning past it.
Converting an imported data file copied each element's and attribute's
resolved namespace URI, so a long namespace declared once on the root was
copied into every element that inherits it; only the node count was
limited. The import counts the names, namespaces, attributes and text it
copies against a byte budget of four times the file-size limit, and refuses
the file as too large past it.
Writing a value at a high index creates every missing sibling before it,
each with its own copy of the name. The node budget counted siblings but not
their names, so a long name with a high index made thousands of copies of
it. The write's budget also counts the bytes of the names it creates; a
path past it is skipped with the existing warning.
Image export parsed an Indexed colour space's base before checking whether
that base was itself Indexed. A base that refers back to the same array
recursed until the stack overflowed. The base is now checked first; since
Indexed is the only colour space that recurses, this also ends any cycle.
The fragment merge multiplied the merged width by a height cast from a
float without checking either fit. A tiny fragment rectangle gives a huge
height: the multiplication overflowed (a panic in debug builds, a wrap past
the pixel limit in release builds) and the u32 cast saturated, sizing an
enormous allocation. The height must now fit u32 before the pixel limit is
checked, and the buffer size uses checked multiplication.
Comment import replaces an existing annotation with the same /NM in place.
The check for annotations it must keep covered locked comments and
redaction marks but not widgets or field dictionaries, so an imported
comment could overwrite a field's widget object, setting its value and
validation entries without the form value setter while the AcroForm field
list still pointed at it. Widgets and field dictionaries are now kept, and
the imported comment is added beside them under no name.
A redaction mark's outline is drawn from its /QuadPoints, but applying the
mark falls back to its /Rect when it has none. An imported mark with only a
/Rect, or with quads the outline can't draw, therefore had no visible
outline while still removing content when applied, and an imported opacity
of 0 hid the outline too. Imported marks now get quads from their /Rect
when their own are unusable (or none, so they remove nothing), and their
/CA and /ca are dropped.
Merge Data Files into Spreadsheet writes field names and values from other
people's files as CSV cells, escaping only the delimiters. A cell starting
with =, +, - or @ is evaluated as a formula when the CSV is opened in a
spreadsheet program. Such cells, after any leading whitespace, are now
written with a leading apostrophe; finite numbers such as -12.5 are left
as they are.
A quad whose corners all lie on one line (or on one point) encloses
nothing, yet such /QuadPoints counted as drawable, replaced the mark's
/Rect and left an imported redaction that covers nothing. Any quad without
area now makes the mark's quads unusable, so its /Rect is used instead.
The /Rect fallback dropped non-numbers before checking the length, so a
malformed /Rect such as [0 0 /Bad 100 100] read as a valid rectangle and
gave the mark quads. The array itself must now hold exactly four finite
numbers.
The hidden-information count followed every /Kids entry of the attachment
and document-script name trees with only a depth limit, so a node listed
twice by each level was read once per path: a few hundred bytes made the
scan take 2^32 steps. Indirect nodes are now read once each, under a total
node budget.
Page and Form content was decoded tolerantly, keeping what came before a
corrupt spot and dropping the rest without saying so. A page whose content
broke at its first bytes therefore read as empty: the hidden-text scan
counted nothing, the removal was skipped and the original stream, which
other viewers still draw, stayed in the file. Content is now decoded
strictly; an unreadable page counts as something to remove, so the removal
runs and refuses the page, and a Form read only in part is treated like an
undecodable one.
Removing form fields flattens them and then drops the interactive form,
but flattening keeps hidden and no-view widgets (they draw nothing) and
widgets with no appearance. The pages still listed them, so their values
and their parent fields' values survived the full rewrite. Every widget
left after flattening now leaves the pages and the structure tree, inline
annotations included.
Removing hidden text or hidden layers rewrote page and Form content but
left the structure tree alone, unlike applying redactions. An element's
ActualText, Alt or expansion text still described the removed content,
and a marked-content reference into a Form kept the original Form, with
the removed text, in the full rewrite. The hidden-content pass now cleans
the tags of what each page no longer shows and moves Form references to
the rewritten copy, refusing when the tree can't be cleaned completely.
Metadata, private application data, associated files and additional
actions were removed from the catalog and pages only. The same entries on
an image or font (XMP), a Form XObject (PieceInfo), a page or annotation
(/AF with its embedded file) or a form field (/AA scripts) survived the
full rewrite, and the scan did not count them, so removing only such a
category reported nothing to remove. A walk of every object reachable from
the catalog now counts and strips these entries wherever they are, leaving
name maps such as resource dictionaries alone (their keys are names a
producer chose), and refuses a graph nested too deeply to walk in full.
The name-tree count cloned each node's /Kids array and skipped repeated
references without charging the budget, so a node listing the same kid
many times cost work proportional to every entry while the budget saw one
step. The walk now borrows the array, charges every entry it does not
descend into, and stops once the budget is spent.
Dict::set searched every existing entry before inserting a key, so a
parsed dictionary of n distinct keys cost n^2/2 key comparisons: a
few megabytes of keys stalled parsing for minutes. From 32 entries a
dictionary keeps a key index, so each insert and lookup is one hash
probe. Equality and debug output still compare and show the entries
alone.
To read the longest valid prefix of a token like 1.2.3 the parser
retried the conversion on each shorter prefix, so a long digit run
followed by long junk was converted once per junk byte: quadratic in
the token. The prefix is found in one scan and converted once. A
prefix that spells infinity or NaN no longer reads as a number.
When an operand failed to parse, the content parser went back to the
byte after its start and tried again, so a run of n bytes that never
reads as a number (a long run of '+', or digits ending in a letter) was
read again from every one of its bytes: n^2/2 bytes from a tiny
compressed stream. A failed number token longer than 64 bytes is now
skipped whole; shorter ones still recover byte by byte.
Pieces::splice copied the unedited bytes between edits by visiting
every piece for every edit, so a page of many content pieces and many
replaced operators cost pieces x edits steps. The copy starts at the
piece holding its first byte and stops past its last one.
Every stage of a stream's filter chain could produce up to the output
limit and nothing bounded the number of stages, so a long /Filter array
over data that decodes to itself (FF FF under RunLengthDecode) repeated
the full work once per array entry. Chains of more than eight filters
are unsupported, like an image codec, and stay encoded.
Stream::filters reads only a direct name or array of names, so an
indirect /Filter (or an array holding a reference) gave an empty chain
and every decode returned the encoded bytes as if they were the data:
a strict decode reported success on content it never decoded, while a
viewer resolving the reference shows something else. Decoding such a
stream is an error, so callers keep it encoded or refuse it.
The object caches weigh what each cached object owns, but the walk
stopped four levels down and counted anything deeper as nothing. Many
objects holding a large string inside a few nested arrays stayed cached
far past the byte budget (34 MiB against 32 MiB in the test, and without
limit as objects grow). The walk now goes past the parser's nesting
limit; anything deeper counts as too large to keep.
A stream whose /Length is wrong or missing is ended at the next
endstream, which the parser found by searching the rest of the file.
Many such streams before one late endstream (a damaged file being
reconstructed reads every one) cost the file size per stream. The
document lists where endstream occurs the first time it needs it, and
each stream looks its end up there.
The materialized cross-reference reader reread a section's /XRefStm
for every revision that named it, decoding the stream and inserting
its rows each time, although the first read had already merged every
entry. Up to 10,000 revisions naming one large stream repeated that
work 10,000 times. A stream already read is skipped, with one repair
log line.
Every string and stream decrypted searched the document's /CF filters
for the method by name. A file controls both the number of filters and
the number of strings, so the search multiplied the two when the
filter in use was listed last. The handler resolves the string, stream
and embedded-file methods when it is built, and looks named filters up
in a map.
Each leaf page gets its own copy of every attribute it inherits, so a
large direct /Resources on the root of a page tree with thousands of
leaves was copied thousands of times: a compressed file of a few
kilobytes could need tens of gigabytes. The walk weighs each inherited
value once where it is declared and charges every copy to a 1 GiB
budget; past it the walk is cut off, as an over-large tree already is.
Each unmarked redraw of an /ActualText span's glyphs took its own copy
of the replacement text. The work budget counts comparisons, not
bytes, so a long replacement around one glyph followed by many redraws
at the same place copied it once per redraw: gigabytes from a small
page. Redraws on one page share 4 MiB of replacement text; past it a
redraw keeps its own glyphs, as it does past the work budget.
page_form listed every page of the document to take the one it
printed, so imposing P pages walked the page tree and copied its leaf
dictionaries P times over: P^2 page copies. impose passes the list it
already holds. A page index outside it is an error, not a panic.
The index is boxed so that it adds one pointer to every Dict, and so
to every Object, rather than an inline map.
The head and tail attributes were written from the annotation's /LE names
without XML escaping, so a name holding a quote or angle bracket could add
attributes or elements, including field values, to the exported file.
The FDF field walker and the comment copier followed every reference again
each time it appeared, so a field tree or comment whose kids or properties
repeat one reference level after level expanded a small file into millions
of rows or values before the depth limit stopped it. Both now skip a
reference back to an object they are inside of, and share one budget per
import of values and text; past it the import is refused.
parse_response compared only the digest algorithm and imprint with the
query, and the token parser dropped the TSTInfo nonce. A genuine token for
the same imprint from another request or under another policy was
accepted. The token keeps its nonce, and a requested nonce or policy must
match.
content_colour parsed each content stream into a full list of operators
before looking at any of them, so a small compressed stream of millions of
tiny operators became gigabytes of parsed operators in memory. The content
parser's loop is now an iterator (pdfcraft_content::ops), which parse
collects; preflight reads it one operator at a time and stops once gray,
RGB and CMYK are all found.
verify found each annotation's page by copying and searching every page's
/Annots array again for each annotation, so a page with many annotations
cost quadratic time. The annotation-to-page map is built once (the first
page that lists an annotation keeps it, as before).
Conversion and verification looked only at an action's own /S. An allowed
action (a GoTo, say) whose /Next held a JavaScript or other forbidden
action written directly inside it was kept, and verification never looked
at actions written directly in a catalog /OpenAction or an annotation /A.
Both now follow /Next through the actions written inside an action (an
indirect /Next is an object of its own, checked as one), within a bound,
and conversion removes the whole chain.
Office export encoded and kept a separate copy of an image for every place
a page drew it, so a short content stream repeating Do on one large image
multiplied it into gigabytes before the writers' limits applied. Each image
object is now encoded once and its bytes shared (pdfcraft_export::Image
holds an Arc<[u8]>), distinct pictures share a 512 MiB budget whose
overflow lists the page as truncated, and RTF, which leaves pictures out,
prepares none.
Word assembly checked whether the whole word so far was underscores for
every glyph it added, so a long run of underscores on one line took
quadratic time. The underscore state of the current word is kept as it
grows.
calculation_order merged the names a Calculate script reads into the
field's other reads by searching the list so far for each name, so a
script naming many fields took quadratic time before the link limits
applied. The merge looks names up in a set.
Every Do operator resolved the stream's /XObject resources again, and
resolving a direct dictionary copies it, so a large direct XObject
dictionary and many short Do operators multiplied the copying. The
dictionary is read on the first Do and kept for the rest of the stream.
scale_at converted every number of a viewport's /BBox before checking it
had four, and read every viewport the page listed, so many viewports
sharing one long box array took time proportional to their product. A box
of any other length is skipped unread, and at most 1024 viewports are read,
the most set_scale gives a page.
The page walk pushed a node's /Kids array for every new node, so many page
tree nodes sharing one indirect /Kids array re-read it for each node:
quadratic work while the seen check skipped every entry. A shared array
object is read under the first node that lists it, and the walk stops
after four million /Kids entries.
add_many added links one at a time, and each addition walked the page tree
and copied and rewrote the page's whole /Annots array, so a batch of links
cost quadratic time. The pages are found once and each page's new links are
appended in one update.
Marked-text extraction and figure bounds searched the whole marked-content
stack before every operator, so deeply nested unmarked BMC blocks made a page
quadratic. The text stack now holds each level's effective id (its own, else
the enclosing one's), and the bounds pass counts the open sequences that
match.
Figure listing decoded and parsed a page's content once per figure on it,
so many small Figure elements pointing at one page multiplied the work.
The figures are grouped by page and one pass gathers the bounds of every
marked-content id they own; what a sequence draws also counts for the
sequences around it, as before.
A sequential bfrange stores one destination and builds every covered
glyph's text from all of it, so one huge destination and many repeats of
its codes made text extraction copy megabytes per glyph. Destinations keep
at most 256 UTF-16 units (the format's 512 bytes), and marked-text
extraction stops asking for a glyph's text once its id has all the text it
keeps.
Code-to-CID and /W range lookups scanned every record in declaration
order, once per glyph and once per explicit ToUnicode entry while a font
was read, so a few thousand records multiplied by as many codes took
billions of steps. Both tables are indexed once into stretches whose
first-declared covering record wins, the precedence the scan had, and the
/W ranges stop at the CMap entry limit.
Line wrapping built and measured the whole candidate line for every word,
so a long field value or annotation text at a tiny font size, with many
words on one line, took quadratic time and copying. The wrapper keeps the
line's width as it goes and measures each word, space and broken
character once.
The body-size tally and the RTF colour table searched every slot seen so
far for each block, and the RTF writer searched the colour table again for
every paragraph and cell, so a document with many distinct sizes or colours
made export quadratic. Both keep their first-seen order and look slots up
in a hash map.
The pixel reader capped the image size the dictionary gives, then decoded
the JPEG at the size its header gives and compared afterwards, so a small
image dictionary over a huge JPEG allocated the huge one first. The reader
takes the size from the JPEG header and refuses a mismatch before decoding.
A run of images that is not one picture was searched again from each of
its groups, collecting the rest of the run every time, so many images drawn
over one another made a page quadratic. The search reads at most 16 groups
per content operator (at least 65 536) and leaves the rest of the page as
it is once that is spent.
Deleting a field took it off its pages and out of the field tree but left
its entry in the form's /CO array. That reference kept the field
dictionary, its value and its widgets reachable, so a full save after
applying redactions over the field still wrote the redacted value.
Both checks for text left under applied redaction marks (redaction's own
verify pass and the engine's renderer cross-check) skipped any page
content stream whose dictionary said /PCMark /Redaction, so that the
overlay's label would not count. An input file could put that label on
its own content stream and keep it out of both checks. Applying
redactions now records the overlay streams it adds (stamp returns the
new stream), and only those are skipped.
The redaction interpreter started every form XObject with an empty text
state (no font, size 0, no spacing) although a form inherits its caller's
graphics state, and it ignored the /Font entry of an ExtGState set by gs.
Text shown that way was placed at the wrong positions, so removal and
the verify pass could both miss glyphs under a mark. Forms now start
from the caller's state, and gs sets the font and size as Tf does.

The engine's renderer cross-check test used the gs case as its example
of interpreter/renderer disagreement; it now runs the check directly on
an unredacted page, which also covers an input stream labelled as a
redaction overlay.
After applying redactions, removed images and forms were unlinked from
page-tree resource dictionaries but not from the resources of forms the
page still draws. A form sharing the page's original resource dictionary
(a common producer layout) kept the removed image reachable, so a full
save wrote it. The walk over surviving forms now drops XObject entries
for removed objects that the form's own content does not draw, and fails
closed when that cannot be determined.
Clearing the pixels of a partly covered image decoded its stream with
the global 1 GiB allowance and only then cut it to the raster length its
Width, Height and bit depth declare. A small Flate stream could make
every such image allocate and inflate far more than it can use. Decoding
now stops at twice the raster plus a byte per row (filter-chain and
predictor overhead) and a little slack; an image past that is removed
whole, as an undecodable one already is.
Finding which marked content a redaction changed copied every operator
into the buffer of each MCID enclosing it, so content nested n deep was
held n times: a modest stream of nested BDCs could exhaust memory.
Each operator inside marked content is now serialized once, and each
MCID keeps the ranges its sequences span. The bytes compared are capped;
MCIDs past the cap count as changed, so their descriptions are cleared
rather than kept.
Structure cleanup cleared alternate text, actual text and expansions
only on the element that directly owned changed marked content, and an
element that lost an annotation reference kept its descriptions unless
it lost every kid. An ancestor's /ActualText covering a redacted span,
or a link element's /Alt repeating the address of a removed link, stayed
in the saved file. Elements that lose an annotation now always lose
their descriptions, and every ancestor of an affected element (found
through both /P and the tree's /K, which may disagree) loses its own.
An element written in place above one with a description to clear
refuses the operation, as other in-place cases already do.
Filtered inline image data has no length, so its end is an EI chosen
among candidates, and the content parser chose by a different rule
than the renderer's tokenizer (hayro-syntax): the renderer also takes an
EI with no whitespace before it, and checks a candidate by reading what
follows as operators. With image data such as "7FEI 1 0 0 1 200 0 cm",
the renderer ran a cm that redaction read as image data, so text was
drawn 200 pt from where removal and its verify pass looked.

The parser now takes each inline image's end from the renderer's
tokenizer (already in the tree through pdfcraft-cos), so both read the
same operators after the image; a test runs both on the same fixtures.
Only where that tokenizer reads no image (no EI it accepts, or
parameters it can't read) does the parser fall back to its own rules,
counting the image in Parsed::ambiguous_images, and applying redactions
refuses such a page as unreadable.
covers walks a line's word boxes, so a page with few lines and many words did more work than the line count charged.
A copy-restricted document reports no text, so it no longer fails the bound on text it withholds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Redaction reads page content and form XObjects strictly (upstream) and
within one page's decode budget (this branch): both now go through
DecodeBudget::decode_strict.
An address of invalid UTF-8 grows up to three times when converted, so
the listing's 16 MiB cap counted fewer bytes than it kept. The cap now
counts the converted text; the raw length is still checked first, so an
address already over the cap is never converted.
Reaching the 64 MiB comment budget ended the whole annotation scan, so
links and file attachments listed after it were missing, and a later
JavaScript link left the document unmarked. Past the budget, comments
are no longer built, and the scan goes on for links and attachments.
The evidence caps counted repeated references to one stream, so a /DSS
array could list one benign stream 1024 times ahead of a revoking CRL,
and the check never reached it. Repeats are skipped before the entry
cap counts anything, and when a cap still stops an array being read in
full without a revocation found, the signature's status is unknown
rather than valid, with the reason in its details.
A positioned root draws its children instead of flowing them, so nested
repeating subforms under it multiplied their instances without spending
the measurement budget that flowed instances spend. Drawing a subform
instance spends it too.
set_value charged the snapshot's copy, changed the field, then charged the
effect's copy in push. When only the first fit, the effect was dropped while
later reads in the script saw the new value. Both copies are charged at once
before the field changes.
The text layout cut each embedded value to the bytes its block had left, but
the resolver cloned the whole value first. With a byte or two left and a
value starting with a multibyte character, every later run cloned the full
value and kept none of it. The resolver is given the bytes left and returns
only a prefix that fits.
#4114 moves the bodies of Stream::decoded_within and decoded_strict into
decoded_within_typed and decoded_strict_within_typed, which return
FilterError so DecodeBudget can tell a spent limit from corrupt data.
This branch checks /Filter with readable_filters there. Kept #4114's
typed functions and made readable_filters return
FilterError::Unsupported, so a /Filter that is not a name fails both
typed decodes without charging the budget; decoded_first_filter_strict
maps it to CosError as before.
A section naming an /XRefStm that a newer section had already read
skipped the stream entirely, so an entry in its own table that
disagreed with the stream was no longer recorded in xref_oddities.
Signature validation reads those records to refuse an appended section
another reader could follow. The reader keeps each hybrid stream's
entries (up to MAX_XREF_OBJECTS in all) and compares every later
naming section's table with them, without decoding the stream again.
Conflict in crates/content/src/lib.rs: main ends inline images where the
renderer's tokenizer ends them and counts the images whose end is a guess
(Parsed::ambiguous_images); this branch reads operators through the
streaming Ops iterator that parse collects. The iterator's BI branch takes
main's inline_image result and keeps the ambiguous-image count, which parse
copies into Parsed and Ops::ambiguous_images reports.
Each imported top-level field was checked against every field already in
/AcroForm /Fields before it was appended, so a page with many distinct
widget fields cost time quadratic in their number. Membership is now kept
in a set beside the list.

Adds a test-only work counter to the crate, so tests can bound the work a
hostile document causes without timing it.
Renaming a colliding field tried Name-1, Name-2, ... from 1 every time, so
many fields of one name cost a quadratic number of attempts during import,
combine and their conflict previews. Taken names only grow, so the search
now resumes after the last suffix handed out for that name; the result is
still the first free variant.
Fixing up copied annotations cloned the whole source-to-destination map for
every non-GoTo action and dropped the copies it made, so many annotations
cost quadratic memory and time and an object shared by their actions was
copied once per action. The copier now lends its map to the action copy and
takes it back with the new entries.
Looking up a named destination and listing attachments in a source's name
trees followed every /Kids entry with only a depth limit, so a node listing
the same child twice per level made the walk (and the attachment list)
exponential in the depth. Both walks now skip a node already read.
The pruner decoded and walked a form XObject again on every Do that drew
it, so a form drawing itself a few times per level was analysed
exponentially often within the depth limit. Forms are now remembered with
the resources owner they were drawn against, and a repeat records nothing
new.
Each candidate attribute match searched the whole packet before it for the
last < and >, so a long text node repeating the attribute name cost time
quadratic in the packet size (up to the 16 MiB decode cap). The scan now
keeps the last < and > as it moves forward.
Writing an added text item re-wrapped the whole text inside the loop over its
lines to learn the line count, so multiline text cost time and temporary
memory quadratic in its length. The wrapped lines are computed once.
Removing flattened widgets from the field tree recursed into every /Kids
entry with only a depth limit, so a node listing its child twice per level
(or itself) made the walk exponential. Each node's answer is now remembered;
a node met again while it is still being visited survives, as it did when
the depth limit stopped a cycle.
Past the marked-content depth budget the number of open sequences is unknown,
and the mark on top was appended with none of them closed, so the page's own
optional-content scopes (possibly hidden) could hide a redaction box, a
header or a watermark. Placing on top of such a page is now refused before
anything changes; behind it is still possible.
Text extraction copies a ToUnicode destination for every glyph showing its
code, and the page's total text was not bounded, so a small page could expand
into gigabytes. One page's extraction (its forms included) stops with an error
past 16 MiB of text.
The redaction audit read each page's text through text_lines, which walked
the whole page tree again for every page (quadratic in the page count) and
let /ActualText replace the glyphs, so an empty or substitute ActualText hid
covered text from the audit. Edit gains glyph_text_lines, which reads the
page dictionary it is given and ignores ActualText; the audit uses it.
A TIFF's pages were each capped at 2 GiB but all kept in memory until
embedded, so many small directories sharing one compressed strip could ask
for many times that; embedding one image also decoded every page. The pages
of one TIFF are now refused past 4 GiB together, and image_xobject reads
only the first page.
A one-pixel GIF on a huge logical screen decodes to a canvas that size,
which the conversion then copied before splitting it into colour and alpha,
so a few bytes could hold over a gigabyte. Screens past 16 Mpx are refused
before decoding, and the decoded canvas is converted without a copy.
The JPEG decoder re-sorts and rescans every pending Extended XMP chunk after
each marker, so a file of many small chunks cost time quadratic in their
number before any pixel limit applied. Turning a photo upright now counts
the chunks first and leaves a JPEG with more than 1024 as it is.
The tests each branch appends to the audit, edit and organize test files are
kept side by side; prune.rs merges cleanly, with the form memo on top of the
budgeted page content.
A /Kids array listing one widget many times, or shared by several fields,
built a Widget for every listing, so a small file expanded into fields times
listings widgets. A widget now belongs to the first field that lists it, and a
page widget already owned is not adopted again.
The annotation listing skipped a repeated /Annots entry by its raw
reference, so an indirect object holding only a reference to a link
listed that link a second time. Entries and page arrays are now told
apart by the object their reference chain ends at. The lopdf reader
reported the first object of a chain, because lopdf's get_object
already follows it; it now reports the last, as the lazy reader does.
The link and attachment listings copied a link's web address and an
attachment's name and description for every annotation or name-tree
entry, so many entries sharing one long string multiplied it in
memory. Together they keep at most 16 MiB of that text, and a string
past what is left is not converted. A link past it lists as a URI
action without its address, an attachment under its generic name
without a description.
The fixture's image stream held six bytes under /Length 4, and the test
only checked that the picture was missing at a zero budget, which a
picture that could not be prepared also passes. The stream is four
bytes, and the picture is first shown to export at a 1 MiB budget.
* M14: recover existing macOS PDF associations

* M14: address macOS association recovery review

* M14: reuse upstream save metadata preservation
A web address or attachment text that converted to more than the budget
left spent nothing, so every link or attachment sharing it converted it
again. A conversion that comes out too long now spends the raw length,
and so does attachment text that trims to nothing.
An annotation attachment's /Contents was read and charged to the text
budget before the file specification's /UF or /F, which then named the
attachment, so the discarded text could leave a later web address
unlisted. /Contents is now read only when neither name is there.
The embedded-files name tree copied every key, and an attachment
without a file name copied its key again as the name, so many entries
sharing one long key string multiplied it. Keys, and a key kept again
as the name, now spend the 16 MiB link and attachment text budget; an
embedded file whose key does not fit is not listed.
A kept web address or attachment text spent only its converted length,
so a long raw value that trimmed short was converted in full for every
entry sharing it. A kept conversion now spends the longer of its raw
and converted lengths.
Extraction walked the name tree again with a fresh key budget, so keys
the listing had left out could fill it and a listed embedded file came
back as not found. The lookup compares each key with the one wanted and
keeps none, so it needs no budget; a key object or /Names array that
several places reference is converted once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(cos): one decode budget per page walk, so repeated stream references cannot multiply decoding
fix(cos, content, filters, crypt, model, render, print, a11y): bound the work that repeated keys, streams and page values multiply
fix(limits): bound the decoding and listing work that repeated references and small inputs multiply
fix(js, xfa): bound the text and work that form scripts and XFA data can multiply
# Conflicts:
#	crates/audit/src/tests.rs
fix(edit,organize,create): bound the work hostile files cause in editing, page organizing and image import
Keep the Ops iterator from this branch and carry main's long-number skip
(MAX_RETRIED_TOKEN) into its operand error path.
Keep this branch's budgeted audit() and read lines with main's
glyph_text_lines, so /ActualText cannot hide text under a cover.
vendor/ is third-party code kept close to upstream (vendor/README.md), so SonarCloud's
186 findings there are not ours to restyle. MD5 in crates/crypt is what ISO 32000's
standard security handler mandates, and RIPEMD-160 in crates/sign is a digest existing
signatures can name; both S4790 findings are scoped to those two files.
Every packaging script runs under bash, so [[ ]] is available. Name the positional
parameters of sha256, render and has, end env.sh's functions with an explicit return
(bare, so the exit status is unchanged), and give the build-label case a default arm.
chore(sonar): exclude vendored crates and the spec-mandated MD5/RIPEMD-160 (SonarCloud rust:S4790)
fix(packaging): use [[ tests and name positional parameters (SonarCloud shelldre:S7688, S7679, S7682, S131)
fix(audit): find covers the audit missed, bound its work and honour the copy permission
Merge pull request #4129 from aiongg/fix/coderabbit-c5-forms-misc
Some checks failed
freebsd.yml / Merge pull request #4129 from aiongg/fix/coderabbit-c5-forms-misc (pull_request) Failing after 0s
nix.yml / Merge pull request #4129 from aiongg/fix/coderabbit-c5-forms-misc (pull_request) Failing after 0s
packaging-lint.yml / Merge pull request #4129 from aiongg/fix/coderabbit-c5-forms-misc (pull_request) Failing after 0s
sonarcloud.yml / Merge pull request #4129 from aiongg/fix/coderabbit-c5-forms-misc (pull_request) Failing after 0s
freebsd.yml / Merge pull request #4129 from aiongg/fix/coderabbit-c5-forms-misc (push) Failing after 0s
nix.yml / Merge pull request #4129 from aiongg/fix/coderabbit-c5-forms-misc (push) Failing after 0s
packaging-lint.yml / Merge pull request #4129 from aiongg/fix/coderabbit-c5-forms-misc (push) Failing after 0s
sonarcloud.yml / Merge pull request #4129 from aiongg/fix/coderabbit-c5-forms-misc (push) Failing after 0s
CI / test (ubuntu-latest) (pull_request) Has been cancelled
CI / test (windows-latest) (pull_request) Has been cancelled
CI / wasm32 check (pull_request) Has been cancelled
CI / dependency licences and advisories (pull_request) Has been cancelled
CI / fuzz (mutation, 15 min) (pull_request) Has been cancelled
CI / test (ubuntu-latest) (push) Has been cancelled
CI / test (windows-latest) (push) Has been cancelled
CI / wasm32 check (push) Has been cancelled
CI / dependency licences and advisories (push) Has been cancelled
CI / fuzz (mutation, 15 min) (push) Has been cancelled
0dc77fe4ed
fix(xfdf, sign, preflight, engine, forms, measure, annot): bound repeated work in form exchange, preflight and export, and check what imports and time stamps claim
alf merged commit 0dc77fe4ed into main 2026-10-23 11:10:24 +00:00
alf deleted branch chore/sync-upstream-20261023 2026-10-23 11:10:25 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
iris/pdfcraft!6
No description provided.