From a08d974ac14bbaa37b34e5b1ccf728eb89b378ef Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:45 +0000 Subject: [PATCH 001/191] fix(cos): encrypt the /Contents of an annotation that carries a /ByteRange 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. --- crates/cos/src/security.rs | 13 ++++++++++--- crates/cos/tests/it/encryption.rs | 32 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index cd0d04a144..67d95aa7ac 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -88,10 +88,17 @@ fn named_crypt_filter(dict: &Dict) -> Option> { /// whose `/Contents` is never encrypted (ISO 32000-2 §7.6.2, §12.8.1): the signer writes a /// placeholder, hashes the file around it and patches the value in place, so encrypting it /// would make signing impossible. Identified as other readers do: by `/Type`, or by a string -/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). +/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). A signature dictionary +/// has no `/Subtype`; a dictionary with one (an annotation, whose `/Contents` is the comment +/// text) or with another `/Type` is never exempt, whatever else it carries. fn is_signature_dict(d: &Dict) -> bool { - matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")) - || (matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_)))) + if d.get(b"Subtype").is_some() { + return false; + } + match d.get(b"Type") { + Some(_) => matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")), + None => matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_))), + } } /// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object, diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index e05161558d..dd2dcb1a41 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -279,6 +279,38 @@ fn signature_contents_stay_unencrypted() { } } +/// Only a signature dictionary keeps its `/Contents` in clear. An annotation that carries a +/// `/ByteRange` (or even `/Type /Sig`) is still an annotation, and its comment is encrypted. +#[test] +fn a_byte_range_does_not_exempt_an_annotation() { + for (ty, subtype) in [(Some("Annot"), "Text"), (None, "FreeText"), (Some("Sig"), "Text")] { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let mut note = pdfcraft_cos::Dict::new(); + if let Some(ty) = ty { + note.set(b"Type".to_vec(), Object::name(ty)); + } + note.set(b"Subtype".to_vec(), Object::name(subtype)); + note.set(b"Contents".to_vec(), PdfString::literal(b"Private note".to_vec())); + note.set(b"ByteRange".to_vec(), Object::Array(Vec::new())); + let note = doc.add(Object::Dict(note)); + doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Ref(note))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Private note"), "{ty:?} {subtype}: comment encrypted"); + let back = Document::open(Arc::new(bytes)).unwrap(); + let note = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"Test").unwrap(); + assert_eq!(back.get(note).as_dict().unwrap().get(b"Contents").unwrap().as_string().unwrap().bytes, b"Private note"); + } +} + #[test] fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() { // A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is From 60d2d1e3f21729befdd70cc734a3fca2ee09480c Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:57 +0000 Subject: [PATCH 002/191] fix(cos): put inherited crypt-filter selectors under the new protection 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. --- crates/cos/src/document.rs | 2 +- crates/cos/src/security.rs | 75 ++++++++++++++++++++++++++----- crates/cos/src/writer.rs | 5 ++- crates/cos/tests/it/encryption.rs | 44 +++++++++++++++++- 4 files changed, 113 insertions(+), 13 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index ae00234d0e..c3f6c6b748 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -858,7 +858,7 @@ impl Document { /// Decrypt a freshly parsed indirect object if the document is encrypted. fn decrypted(&self, id: ObjRef, o: Object) -> Object { match &self.security { - Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, true), + Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, crate::security::Pass::Decrypt), _ => o, } } diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index 67d95aa7ac..12d08b8dcf 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -101,36 +101,91 @@ fn is_signature_dict(d: &Dict) -> bool { } } -/// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object, -/// except a signature dictionary's `/Contents` (see [`is_signature_dict`]). -pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, decrypt: bool) -> Object { +/// Remove the `/Crypt` entry from a stream's filter chain, with its decode parameters. +fn drop_crypt_filter(dict: &mut Dict) { + let i = match dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().position(|n| n.as_name() == Some(b"Crypt")), + Some(Object::Name(n)) if n.as_slice() == b"Crypt" => Some(0), + _ => None, + }; + let Some(i) = i else { return }; + let mut filters = match dict.get(b"Filter") { + Some(Object::Array(a)) => a.clone(), + _ => Vec::new(), + }; + if i < filters.len() { + filters.remove(i); + } + // A lone parameter dictionary belongs to the first filter (as `named_crypt_filter` reads it). + match dict.get(b"DecodeParms").cloned() { + Some(Object::Array(mut parms)) if i < parms.len() => { + parms.remove(i); + dict.set(b"DecodeParms".to_vec(), Object::Array(parms)); + } + Some(Object::Dict(_)) if i == 0 => { + dict.remove(b"DecodeParms"); + } + _ => {} + } + if filters.is_empty() { + dict.remove(b"Filter"); + if matches!(dict.get(b"DecodeParms"), Some(Object::Array(p)) if p.is_empty()) { + dict.remove(b"DecodeParms"); + } + } else { + dict.set(b"Filter".to_vec(), Object::Array(filters)); + } +} + +/// What [`transform`] does to an object. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Pass { + /// Decrypt, with the handler the document was opened with. + Decrypt, + /// Encrypt with the handler the document was opened with: streams keep their crypt filters. + Encrypt, + /// Encrypt with protection applied by `set_encryption`. A stream's crypt filter came from + /// the source's `/Encrypt`; unless the new one defines that name it is dropped, so the + /// stream follows the new policy instead of reading as `/Identity` and staying in clear. + EncryptReplacing, +} + +/// Decrypt or encrypt every string and stream in an indirect object, except a signature +/// dictionary's `/Contents` (see [`is_signature_dict`]). +pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, pass: Pass) -> Object { + let decrypt = pass == Pass::Decrypt; let strings = |s: &PdfString| { let bytes = if decrypt { h.decrypt_string(num, generation, &s.bytes) } else { h.encrypt_string(num, generation, &s.bytes) }; PdfString { bytes, hex: s.hex || !decrypt } }; - walk(o, &strings, h, num, generation, decrypt) + walk(o, &strings, h, num, generation, pass) } -fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, decrypt: bool) -> Object { +fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, pass: Pass) -> Object { + let decrypt = pass == Pass::Decrypt; match o { Object::String(s) => Object::String(strings(s)), - Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, decrypt)).collect()), + Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, pass)).collect()), Object::Dict(d) if is_signature_dict(d) => Object::Dict( d.iter() .map(|(k, v)| match (k.as_slice(), v) { (b"Contents", Object::String(_)) => (k.clone(), v.clone()), - _ => (k.clone(), walk(v, strings, h, num, generation, decrypt)), + _ => (k.clone(), walk(v, strings, h, num, generation, pass)), }) .collect(), ), - Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect()), + Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect()), Object::Stream(s) => { - let dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect(); + let mut dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect(); let ty = s.dict.name(b"Type"); if ty == Some(b"XRef") || (ty == Some(b"Metadata") && !h.encrypts_metadata()) { return Object::Stream(Stream { dict, raw: s.raw.clone() }); } - let named = named_crypt_filter(&s.dict); + let mut named = named_crypt_filter(&s.dict); + if pass == Pass::EncryptReplacing && named.as_deref().is_some_and(|n| !h.dict().crypt_filters.iter().any(|(f, _)| f == n)) { + drop_crypt_filter(&mut dict); + named = None; + } let kind = match (&named, ty) { (Some(n), _) => StreamKind::Named(n), (None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile, diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 3db0566ffe..b151399fef 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -132,7 +132,10 @@ fn write_stream(s: &Stream, out: &mut Vec) { /// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document). fn prepared<'a>(doc: &Document, source_num: u32, num: u32, generation: u16, o: &'a Object) -> Cow<'a, Object> { match doc.output_security() { - (Some(h), encrypt_num) if Some(source_num) != encrypt_num => Cow::Owned(crate::security::transform(h, o, num, generation, false)), + (Some(h), encrypt_num) if Some(source_num) != encrypt_num => { + let pass = if doc.encryption_changed() { crate::security::Pass::EncryptReplacing } else { crate::security::Pass::Encrypt }; + Cow::Owned(crate::security::transform(h, o, num, generation, pass)) + } _ => Cow::Borrowed(o), } } diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index dd2dcb1a41..b0688b9304 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -9,11 +9,16 @@ use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, O const SECRET: &str = "BT /F1 18 Tf 20 100 Td (Secret page) Tj ET"; fn fixture() -> Vec { + fixture_with("") +} + +/// The fixture with `extra` added to the content stream's dictionary. +fn fixture_with(extra: &str) -> Vec { let objs = [ "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_string(), "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), - format!("<< /Length {} >>\nstream\n{SECRET}\nendstream", SECRET.len()), + format!("<< /Length {}{extra} >>\nstream\n{SECRET}\nendstream", SECRET.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), "<< /Type /Metadata /Subtype /XML /Length 22 >>\nstream\nmeta\nendstream".replace("22", "26"), "<< /Title (Quarterly numbers) >>".to_string(), @@ -311,6 +316,43 @@ fn a_byte_range_does_not_exempt_an_annotation() { } } +/// Protecting a document puts every stream under the new handler. A stream that selects a +/// crypt filter of its own loses the selector: the new `/Encrypt` defines only `/StdCF`, so an +/// inherited name (or `/Identity`) would leave the stream in clear. +#[test] +fn protecting_drops_inherited_crypt_filter_selectors() { + for selector in [" /Filter /Crypt /DecodeParms << /Name /MyCF >>", " /Filter [/Crypt] /DecodeParms [<< /Name /Identity >>]", " /Filter /Crypt"] { + let mut doc = Document::open(Arc::new(fixture_with(selector))).unwrap(); + assert_eq!(content(&doc), SECRET, "{selector}"); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Secret page"), "{selector}: stream encrypted"); + assert_eq!(content(&Document::open(Arc::new(bytes.clone())).unwrap()), SECRET, "{selector}"); + assert!(hayro_syntax::Pdf::new(bytes).is_ok(), "{selector}"); + } + // Under the handler the document was opened with, `/Identity` is the author's choice (a + // stream meant to stay readable) and is kept. + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let pages = doc.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap(); + let page = doc.resolve(&pages.get(b"Kids").unwrap().as_array().unwrap()[0]).as_dict().cloned().unwrap(); + let contents = page.reference(b"Contents").unwrap(); + let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") }; + s.dict.set(b"Filter".to_vec(), Object::name("Crypt")); + doc.set(contents, Object::Stream(s)); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(String::from_utf8_lossy(&saved).contains("Secret page"), "written as the stream asks"); + assert_eq!(content(&Document::open(Arc::new(saved)).unwrap()), SECRET); +} + #[test] fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() { // A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is From 7fb6ab0f1fd731efe8eda5e04fd4e35b320ba20a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:13:03 +0000 Subject: [PATCH 003/191] test(cos): write the qpdf oracle files into a private new directory The qpdf oracle test wrote into temp_dir()/pdfcraft-enc-, 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. --- crates/cos/tests/it/encryption.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index b0688b9304..8d03d22790 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -2,6 +2,7 @@ //! with our reader and with an independent one (hayro-syntax's decryption). Also editing //! encrypted documents incrementally, changing and removing passwords, and permissions. +use std::io::Write; use std::sync::Arc; use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, Object, PdfString, SaveOptions, write_full, write_incremental}; @@ -214,11 +215,10 @@ fn unencrypted_metadata_is_left_readable() { #[test] fn qpdf_agrees() { let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); - let dir = std::env::temp_dir().join(format!("pdfcraft-enc-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let dir = private_temp_dir("pdfcraft-enc"); for alg in ALL { let path = dir.join(format!("{alg:?}.pdf")); - std::fs::write(&path, protect(alg, "user", "owner", -1)).unwrap(); + std::fs::File::create_new(&path).and_then(|mut f| f.write_all(&protect(alg, "user", "owner", -1))).unwrap(); for (pw, expect) in [("user", "user password"), ("owner", "owner password")] { let out = qpdf.command().arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap(); let text = String::from_utf8_lossy(&out.stdout); @@ -230,6 +230,30 @@ fn qpdf_agrees() { let _ = std::fs::remove_dir_all(dir); } +/// A new directory for oracle files, private to this user: an unpredictable name (`RandomState` +/// is seeded by the operating system), created by this call or not at all. +fn private_temp_dir(prefix: &str) -> std::path::PathBuf { + use std::hash::{BuildHasher, Hasher}; + let mut name = std::collections::hash_map::RandomState::new().build_hasher(); + name.write_u32(std::process::id()); + let dir = std::env::temp_dir().join(format!("{prefix}-{:016x}", name.finish())); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder.create(&dir).unwrap(); + dir +} + +/// Oracle files go in a directory this test created: another local account cannot have +/// prepared it (or symlinks inside it) in advance. +#[test] +fn oracle_files_go_in_a_fresh_directory() { + let (a, b) = (private_temp_dir("pdfcraft-enc-probe"), private_temp_dir("pdfcraft-enc-probe")); + assert_ne!(a, b); + assert_eq!(std::fs::read_dir(&a).unwrap().count(), 0); + let _ = (std::fs::remove_dir(a), std::fs::remove_dir(b)); +} + /// A signature dictionary's `/Contents` is never encrypted (§7.6.2): signers patch it in place /// after hashing the file around it. Its other strings, and the `/Contents` of anything that /// is not a signature dictionary, are encrypted as usual, and reading leaves it untouched. From b72c730a951352cd427c24d0b11cfba9a17b00e0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:13:36 +0000 Subject: [PATCH 004/191] fix(cos): give the protect example a fresh key on every run 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. --- crates/cos/examples/protect.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/cos/examples/protect.rs b/crates/cos/examples/protect.rs index 6f27a60969..48498aefa8 100644 --- a/crates/cos/examples/protect.rs +++ b/crates/cos/examples/protect.rs @@ -1,4 +1,17 @@ //! Encrypt a PDF: `cargo run -p pdfcraft-cos --example protect in.pdf out.pdf rc4-40|rc4-128|aes-128|aes-256 USER OWNER` +/// 32 fresh bytes for the file key. `RandomState` is seeded by the operating system (as the +/// engine's `entropy` uses it), so every run encrypts with a different key. +fn seed() -> [u8; 32] { + use std::hash::{BuildHasher, Hasher}; + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_usize(i); + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + out +} + fn main() { let a: Vec = std::env::args().skip(1).collect(); let [input, output, alg, user, owner] = &a[..] else { panic!("usage: protect in out alg user owner") }; @@ -15,7 +28,7 @@ fn main() { owner_password: owner, permissions: -1, encrypt_metadata: true, - seed: [1; 32], + seed: seed(), }) .unwrap(); std::fs::write(output, pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()).unwrap(); From faab42d4058ff70588d22a1ac82e7b2b27b375e3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:58 +0000 Subject: [PATCH 005/191] fix(sign): authorize an embedded OCSP responder by signature, not by name 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. --- crates/sign/src/revocation.rs | 15 ++++++------ crates/sign/tests/it/crypto.rs | 43 ++++++++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/crates/sign/src/revocation.rs b/crates/sign/src/revocation.rs index 8f9dac0cdc..b4a9d820f9 100644 --- a/crates/sign/src/revocation.rs +++ b/crates/sign/src/revocation.rs @@ -259,26 +259,27 @@ impl OcspResponse { /// response window. pub fn check(&self, cert: &Certificate, issuer: &Certificate, at: Time) -> RevocationStatus { // Who signed the response? The issuer identified by responderID, or a delegated, - // embedded responder identified the same way. - let responder: Option<&Certificate> = match &self.responder_id { + // embedded responder identified the same way. Which of the two it is comes from that + // choice, never from the embedded certificate's names, which anyone can copy. + let (responder, delegated): (Option<&Certificate>, bool) = match &self.responder_id { ResponderId::ByName(name) => { if *name == issuer.subject.raw { - Some(issuer) + (Some(issuer), false) } else { - self.responder_cert.as_ref().filter(|c| *name == c.subject.raw) + (self.responder_cert.as_ref().filter(|c| *name == c.subject.raw), true) } } ResponderId::ByKey(hash) => { if *hash == DigestAlg::Sha1.digest(&[&issuer.public_key.key_bits()]) { - Some(issuer) + (Some(issuer), false) } else { - self.responder_cert.as_ref().filter(|c| *hash == DigestAlg::Sha1.digest(&[&c.public_key.key_bits()])) + (self.responder_cert.as_ref().filter(|c| *hash == DigestAlg::Sha1.digest(&[&c.public_key.key_bits()])), true) } } }; let Some(responder) = responder else { return RevocationStatus::Unknown }; // A delegated responder must carry the OCSP-signing EKU and be issued by the issuer. - if responder.subject.raw != issuer.subject.raw { + if delegated { if responder.issuer.raw != issuer.subject.raw || !responder.signed_by(&issuer.public_key) { return RevocationStatus::Unknown; } diff --git a/crates/sign/tests/it/crypto.rs b/crates/sign/tests/it/crypto.rs index cd36c52774..193fd06c62 100644 --- a/crates/sign/tests/it/crypto.rs +++ b/crates/sign/tests/it/crypto.rs @@ -355,10 +355,19 @@ fn ocsp(issuer: &pkcs12::DigitalId, status: RevocationStatus, this: Time, next: }; let single = der::seq(&[&cert_id, &status, &this.encode(), &der::tlv(tag::ctx(0), &next.encode())]); let responder_id = der::tlv(tag::ctx(1), &issuer.certificate.subject.raw); - let tbs = der::seq(&[&responder_id, &this.encode(), &der::seq(&[&single])]); - let alg = issuer.key.signature_algorithm(DigestAlg::Sha256); - let sig = issuer.key.sign(DigestAlg::Sha256, &tbs).unwrap(); - let basic = der::seq(&[&tbs, &alg, &der::bit_string(&sig)]); + signed_ocsp(&issuer.key, &responder_id, None, &single, this) +} + +/// A successful OCSP response holding `single`, signed with `key` and naming its responder by +/// `responder_id`, with `certs` (the encoded `[0]` field) if given. +fn signed_ocsp(key: &pdfcraft_sign::PrivateKey, responder_id: &[u8], certs: Option<&[u8]>, single: &[u8], at: Time) -> Vec { + let tbs = der::seq(&[responder_id, &at.encode(), &der::seq(&[single])]); + let alg = key.signature_algorithm(DigestAlg::Sha256); + let sig = key.sign(DigestAlg::Sha256, &tbs).unwrap(); + let basic = match certs { + Some(c) => der::seq(&[&tbs, &alg, &der::bit_string(&sig), c]), + None => der::seq(&[&tbs, &alg, &der::bit_string(&sig)]), + }; let basic_type = der::oid("1.3.6.1.5.5.7.48.1.1"); der::seq(&[&der::int(0), &der::explicit(0, &der::seq(&[&basic_type, &der::octets(&basic)]))]) } @@ -465,6 +474,32 @@ fn ocsp_responses_verify_and_match_the_certificate() { assert!(OcspResponse::parse(&error).is_err()); } +/// An embedded responder certificate answers for an issuer only when the issuer signed it for +/// OCSP signing. One that merely copies the issuer's name, found by its key hash, does not. +#[test] +fn a_responder_that_copies_the_issuers_name_does_not_answer_for_it() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let issuer = &id.certificate; + let (this, next) = (mid_june(), Time { year: 2026, month: 7, day: 15, hour: 12, minute: 0, second: 0 }); + let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); + let impostor = Certificate::self_signed(&issuer.subject, &key, Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, 5, &[9]).unwrap(); + assert_eq!(impostor.subject.raw, issuer.subject.raw); + let cert_id = der::seq(&[ + &DigestAlg::Sha1.algorithm(), + &der::octets(&DigestAlg::Sha1.digest(&[&issuer.issuer.raw])), + &der::octets(&DigestAlg::Sha1.digest(&[&issuer.public_key.key_bits()])), + &der::uint(&issuer.serial), + ]); + let revoked = der::tlv(tag::ctx(1), &Time { year: 2026, month: 6, day: 2, hour: 9, minute: 0, second: 0 }.encode()); + let single = der::seq(&[&cert_id, &revoked, &this.encode(), &der::tlv(tag::ctx(0), &next.encode())]); + let by_key = der::tlv(tag::ctx(2), &der::octets(&DigestAlg::Sha1.digest(&[&impostor.public_key.key_bits()]))); + // RFC 6960's `[0] EXPLICIT SEQUENCE OF Certificate`, and the certificate directly in `[0]`. + for certs in [der::explicit(0, &der::seq(&[&impostor.raw])), der::explicit(0, &impostor.raw)] { + let forged = OcspResponse::parse(&signed_ocsp(&key, &by_key, Some(&certs), &single, this)).unwrap(); + assert_eq!(forged.check(issuer, issuer, mid_june()), RevocationStatus::Unknown); + } +} + // ── tolerance for the encodings found in old and unusual signers ─────────────────────────────── // RSA signatures over SHA-256("hello") by the key in rsa-aes.p12, made with OpenSSL 3 (see // tests/data/README.md): the standard DigestInfo, DigestInfo without the NULL parameter, the bare From fbada004d32d4fbb24638b2fe6eb27c0b9637f49 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:38:04 +0000 Subject: [PATCH 006/191] fix(sign): treat a changed trailer /Root after signing as not permitted 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. --- crates/sign/src/pdf.rs | 5 +++++ crates/sign/tests/it/pdf.rs | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 57c9f95fde..50f5d4a19f 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1286,6 +1286,11 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod disallowed.push(what); } } + // The trailer chooses the catalog. A later revision that points it at another catalog (one + // already in the signed file, so no object changes) shows another document. + if doc.root() != old.root() { + disallowed.push(kind::STRUCTURE); + } for num in doc.object_numbers() { if same_place(num) { continue; diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index e9db0b5700..29f61305f8 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -2471,3 +2471,27 @@ fn rewritten_page_resources_are_page_content_whatever_keys_they_carry() { s.modification ); } + +/// A later revision that only points the trailer at another catalog already in the signed file +/// shows a different document while every signed object is unchanged. That is not permitted. +#[test] +fn switching_the_trailer_to_another_signed_catalog_is_not_permitted() { + let base = fixture_with( + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", + &[ + b"<< /Type /Catalog /Pages 8 0 R /AcroForm << /Fields [6 0 R] >> >>", + b"<< /Type /Pages /Kids [9 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 8 0 R /MediaBox [0 0 300 300] /Contents 10 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>", + b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract void) Tj ET\nendstream", + ], + ); + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let signed = pdfcraft_sign::sign(&open(&base), &id, &SignOptions { field: Some("Approval".into()), rect: None, ..opts() }).unwrap(); + let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Valid, "{:?}", s.details); + let switched = edit_after(&signed, |doc| doc.trailer_mut().set(b"Root".to_vec(), oref(7))); + let s = signatures(&open(&switched), &switched, &trust).into_iter().find(|s| s.signed).expect("the signature is still listed"); + assert!(matches!(s.modification, Modification::Disallowed(_)), "{:?}", s.modification); + assert_eq!(s.status, Status::Invalid, "{:?}", s.details); +} From d9cb7c2fe7e48708a65b4fbd8ef0b87245d42e8f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:38:19 +0000 Subject: [PATCH 007/191] fix(sign): don't build chains through a CA with unprocessed critical extensions 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. --- crates/sign/src/pdf.rs | 1 + crates/sign/src/x509.rs | 7 ++++++- crates/sign/tests/it/chain.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 50f5d4a19f..ef2715f831 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -378,6 +378,7 @@ pub const WORDINGS: &[&str] = &[ "The signer's certificate has critical extensions PdfCraft does not recognize ({}).", // Why a certificate that matched the issuer was not used to build the chain. "The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}.", + "The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}.", "The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}.", "The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}.", "The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates.", diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 53c80e2695..56ec8fbddc 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -492,7 +492,8 @@ pub fn subject_of(raw: &[u8]) -> Option { } /// The chain from `leaf` up through `pool`, as far as issuers can be found and may issue: each -/// issuer's key verifies the certificate below it, it is a CA allowed to sign certificates, its +/// issuer's key verifies the certificate below it, it is a CA allowed to sign certificates, it +/// has no critical extension PdfCraft doesn't process (unless it is self-signed), its /// `pathLenConstraint` allows the CA certificates below it, and (when `at` is given) it was valid /// then. Stops at a self-signed certificate. Certificates embedded in a document are in `pool` /// too, so an ordinary end-entity certificate must never be accepted as an issuer. @@ -522,6 +523,10 @@ pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: // two names where it needs them. let why = if !c.may_issue() { Some("The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}.") + } else if !c.unknown_critical.is_empty() && !c.is_self_signed() { + // What such an extension limits (name constraints, policy constraints) would + // go unchecked. A self-signed root's extensions are the trust store's business. + Some("The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}.") } else if c.path_len.is_some_and(|n| below > n as usize) { Some("The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}.") } else if at.is_some_and(|t| !c.valid_at(t)) { diff --git a/crates/sign/tests/it/chain.rs b/crates/sign/tests/it/chain.rs index 5defd859d5..2b6b5f85a3 100644 --- a/crates/sign/tests/it/chain.rs +++ b/crates/sign/tests/it/chain.rs @@ -340,3 +340,23 @@ fn an_unreadable_extended_key_usage_allows_no_purpose() { verdict(signer_with("Good EKU", &r, &[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["2.5.29.37.0"])]), vec![], &r.cert); assert_eq!(status, Status::Valid, "{details:?}"); } + +/// A CA certificate with a critical extension PdfCraft does not enforce (here name constraints) +/// vouches for nothing: what that extension limits would go unchecked. A self-signed root's +/// extensions are the trust store's business, so such a root still anchors. +#[test] +fn an_issuer_with_an_unprocessed_critical_extension_does_not_vouch() { + let name_constraints = ext("2.5.29.30", &der::seq(&[])); + let r = root(); + let constrained = issue("Name Constrained CA", Some(&r), FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints.clone()]); + let l = end_entity("Outside Its Names", &constrained); + let pool = [constrained.cert.clone(), r.cert.clone()]; + let (chain, why) = build_chain_noted(&l.cert, &pool, None); + assert_eq!(names(&chain), ["Outside Its Names"]); + assert!(why.is_some_and(|w| w.message.contains("critical extensions")), "the reason is reported"); + let constrained_root = issue("Name Constrained Root", None, FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints]); + let i = ca("Under Constrained Root", &constrained_root, None); + let l = end_entity("Leaf", &i); + let pool = [i.cert.clone(), constrained_root.cert.clone()]; + assert_eq!(names(&build_chain(&l.cert, &pool, None)), ["Leaf", "Under Constrained Root", "Name Constrained Root"]); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index db00c69dd6..1d121c59bc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2359,6 +2359,7 @@ action wizard Start Iniciar The signer's certificate has critical extensions PdfCraft does not recognize ({}). El certificado del firmante tiene extensiones críticas que PdfCraft no reconoce ({}). # Why a certificate that matched the issuer was not used to build the chain. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. El certificado de {} no es un certificado de CA que pueda emitir certificados, así que no se usa para avalar a {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. El certificado de {} tiene extensiones críticas que PdfCraft no reconoce, así que no se usa para avalar a {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. El certificado de {} no admite tantos certificados de CA por debajo (longitud de ruta), así que no se usa para avalar a {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. El certificado de {} no era válido en el momento de la firma, así que no se usa para avalar a {}. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. La identidad del firmante es desconocida porque no está en tu lista de certificados de confianza y ninguno de los certificados superiores es de confianza. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cd381b5393..8176e498bd 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2503,6 +2503,7 @@ animation effects Off Wyłączone The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Certyfikat podpisującego nie jest wystawiony do podpisywania dokumentów (jego rozszerzone użycie klucza nie obejmuje podpisywania dokumentów). The signer's certificate has critical extensions PdfCraft does not recognize ({}). Certyfikat podpisującego ma rozszerzenia krytyczne, których PdfCraft nie rozpoznaje ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Certyfikat podmiotu {} nie jest certyfikatem urzędu certyfikacji, który może wystawiać certyfikaty, dlatego nie poświadcza certyfikatu {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Certyfikat podmiotu {} ma rozszerzenia krytyczne, których PdfCraft nie rozpoznaje, dlatego nie poświadcza certyfikatu {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Certyfikat podmiotu {} nie dopuszcza tylu certyfikatów urzędów certyfikacji poniżej siebie (długość ścieżki), dlatego nie poświadcza certyfikatu {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Certyfikat podmiotu {} nie był ważny w chwili podpisania, dlatego nie poświadcza certyfikatu {}. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Tożsamość podpisującego jest nieznana, ponieważ jego certyfikatu nie ma na liście zaufanych certyfikatów i żaden z certyfikatów nadrzędnych nie jest zaufany. From 4e9d8f9b3b8f9f6403bdcb9145d8290ab9a58537 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:38:19 +0000 Subject: [PATCH 008/191] fix(sign): require the time-stamping purpose for trusted timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/sign/src/pdf.rs | 23 +++++++---- crates/sign/src/x509.rs | 15 ++++++++ crates/sign/tests/it/pdf.rs | 70 +++++++++++++++++++++++++++++----- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + 5 files changed, 94 insertions(+), 16 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index ef2715f831..82a801dee8 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -401,6 +401,7 @@ pub const WORDINGS: &[&str] = &[ "The timestamp token could not be read ({}).", "The document has been altered or corrupted since the timestamp was applied.", "The timestamp authority's certificate was not valid at the time of timestamping.", + "The timestamp authority's certificate is not issued for time stamping.", "This document has not been modified since this timestamp was applied.", "The document has been modified since this timestamp was applied, but the changes are permitted ({}).", "The document has been altered since this timestamp was applied in ways it does not permit ({}).", @@ -771,13 +772,16 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf // own claimed time, as without a timestamp. let mut pool = crate::timestamp::token_certs(raw); pool.extend(trust.certs.iter().cloned()); - let trusted_tsa = t - .signer_certificate() - .is_some_and(|c| c.valid_at(t.gen_time) && build_chain(&c, &pool, Some(t.gen_time)).iter().any(|x| trust.trusts(x))); - if trusted_tsa { + let tsa = t.signer_certificate(); + let trusted_tsa = tsa + .as_ref() + .is_some_and(|c| c.valid_at(t.gen_time) && build_chain(c, &pool, Some(t.gen_time)).iter().any(|x| trust.trusts(x))); + if !trusted_tsa { + unverified_time = Some(t.gen_time); + } else if tsa.as_ref().is_some_and(Certificate::may_timestamp) { info.timestamp_time = Some(t.gen_time); } else { - unverified_time = Some(t.gen_time); + info.details.push(Detail::plain("The timestamp authority's certificate is not issued for time stamping.")); } } } @@ -1085,6 +1089,10 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig info.status = Status::Unknown; info.details.push(Detail::plain("The timestamp authority's certificate was not valid at the time of timestamping.")); } + if !cert.may_timestamp() { + info.status = Status::Unknown; + info.details.push(Detail::plain("The timestamp authority's certificate is not issued for time stamping.")); + } } info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) { Modification::None @@ -1112,9 +1120,10 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig pool.extend(trust.certs.iter().cloned()); // A trusted authority whose certificate was not valid at the time it stamped proves // nothing either (its detail is already recorded above): never upgrade that to Valid. - let in_validity = token.signer_certificate().is_some_and(|c| c.valid_at(token.gen_time)); + // Nor does one whose certificate is not issued for time stamping. + let may_stamp = token.signer_certificate().is_some_and(|c| c.valid_at(token.gen_time) && c.may_timestamp()); let trusted_tsa = token.signer_certificate().is_some_and(|c| build_chain(&c, &pool, Some(token.gen_time)).iter().any(|x| trust.trusts(x))); - if trusted_tsa && in_validity { + if trusted_tsa && may_stamp { info.status = Status::Valid; info.details.push(Detail::plain("The timestamp token is valid and its authority is trusted.")); } else if trusted_tsa { diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 56ec8fbddc..5dd03d086d 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -165,6 +165,9 @@ const PROCESSED_EXTENSIONS: [&str; 11] = [ "1.3.6.1.5.5.7.1.3", ]; +/// id-kp-timeStamping (RFC 5280 §4.2.1.12), required of a timestamp authority (RFC 3161 §2.3). +const EKU_TIME_STAMPING: &str = "1.3.6.1.5.5.7.3.8"; + /// Extended key usages that allow signing documents: anyExtendedKeyUsage, emailProtection, /// codeSigning, documentSigning (RFC 9336), Adobe Authentic Documents and Microsoft document /// signing. @@ -384,6 +387,18 @@ impl Certificate { } } + /// Whether this certificate may stamp trusted time (RFC 3161 §2.3): its extended key usage + /// includes timeStamping, its key usage (if any) allows digital signatures or + /// non-repudiation, and it has no critical extension PdfCraft doesn't process. A certificate + /// issued for another purpose proves who signed, not when. + pub fn may_timestamp(&self) -> bool { + /// keyUsage bits 0 (digitalSignature) and 1 (nonRepudiation). + const SIGNING_BITS: u16 = 0b11; + self.extended_key_usage.as_ref().is_some_and(|e| e.iter().any(|o| o == EKU_TIME_STAMPING)) + && self.key_usage.is_none_or(|u| u & SIGNING_BITS != 0) + && self.unknown_critical.is_empty() + } + /// [`signing_problem`](Self::signing_problem) as a clause that follows the certificate's /// name ("it is not issued for signing documents (…)"), for lists of digital IDs. pub fn signing_problem_clause(&self) -> Option { diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 29f61305f8..f7dc33f685 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -384,6 +384,38 @@ struct TestTsa { time: Time, } +/// A timestamp authority's digital ID: a self-signed certificate valid from October 2026 for +/// ten years, whose critical extended key usage is time stamping alone, as RFC 3161 requires. +fn tsa_id() -> DigitalId { + tsa_id_with(&["1.3.6.1.5.5.7.3.8"]) +} + +/// [`tsa_id`] with these extended key usages. +fn tsa_id_with(ekus: &[&str]) -> DigitalId { + let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); + let name = Name::build("Test TSA", "", "PdfCraft Tests", "", "US"); + let alg = key.preferred_digest(); + let sig_alg = key.signature_algorithm(alg); + let from = Time { year: 2026, month: 10, day: 2, hour: 0, minute: 0, second: 0 }; + let until = Time { year: 2036, ..from }; + let oids: Vec> = ekus.iter().map(|o| der::oid(o)).collect(); + let oids: Vec<&[u8]> = oids.iter().map(Vec::as_slice).collect(); + let eku = der::seq(&[&der::oid("2.5.29.37"), &der::boolean(true), &der::octets(&der::seq(&oids))]); + let tbs = der::seq(&[ + &der::explicit(0, &der::int(2)), + &der::uint(&[0x31]), + &sig_alg, + &name.raw, + &der::seq(&[&from.encode(), &until.encode()]), + &name.raw, + &key.public_key().spki(), + &der::explicit(3, &der::seq(&[&eku])), + ]); + let signature = key.sign(alg, &tbs).unwrap(); + let certificate = Certificate::parse(&der::seq(&[&tbs, &sig_alg, &der::bit_string(&signature)])).unwrap(); + DigitalId { key, certificate, chain: Vec::new(), friendly_name: None } +} + impl TimestampAuthority for TestTsa { fn timestamp(&self, request: &[u8]) -> Result, SignError> { let q = pdfcraft_sign::timestamp::parse_request(request)?; @@ -404,7 +436,7 @@ impl TimestampAuthority for TestTsa { fn signing_with_a_timestamp_embeds_a_verified_rfc3161_token() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); @@ -443,7 +475,7 @@ fn a_malformed_timestamp_response_fails_signing_without_a_file() { #[test] fn a_document_timestamp_covers_the_file_and_validates() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); @@ -480,7 +512,7 @@ fn a_document_timestamp_covers_the_file_and_validates() { #[test] fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let mut source = open(&fixture()); @@ -559,7 +591,7 @@ fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() { use pdfcraft_sign::dss::{self, Evidence}; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); @@ -1532,7 +1564,7 @@ fn an_untrusted_timestamp_does_not_set_the_validation_time() { // The TSA claims 2027 (inside the signer's validity) but is not trusted; the signer's own // clock says 2040, after the certificate expired. let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, }; let late = SignOptions { date: "D:20400101120000Z".into(), ..opts() }; @@ -1754,7 +1786,7 @@ fn fixture_timestamped_in_a_field(tsa: &TestTsa) -> Vec { #[test] fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let stamped = fixture_timestamped_in_a_field(&tsa); @@ -1786,7 +1818,7 @@ fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { #[test] fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), // Before the TSA certificate's validity (it starts in October 2026). time: Time { year: 2020, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, }; @@ -1802,7 +1834,7 @@ fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { #[test] fn a_standalone_timestamp_typed_sig_by_an_older_writer_is_still_a_timestamp() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); @@ -2036,7 +2068,7 @@ fn signing_waits_for_a_pending_security_change_to_be_saved() { #[test] fn encrypted_documents_take_a_document_timestamp() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let base = protected(pdfcraft_cos::Algorithm::Aes128, FILL_AND_SIGN_ONLY); @@ -2495,3 +2527,23 @@ fn switching_the_trailer_to_another_signed_catalog_is_not_permitted() { assert!(matches!(s.modification, Modification::Disallowed(_)), "{:?}", s.modification); assert_eq!(s.status, Status::Invalid, "{:?}", s.details); } + +/// Only a certificate issued for time stamping makes trusted time. A trusted certificate issued +/// for something else (here e-mail) stamps nothing trusted: neither a document timestamp nor +/// a signature's embedded one. +#[test] +fn a_timestamp_from_a_certificate_not_issued_for_time_stamping_is_not_trusted_time() { + let tsa = TestTsa { id: tsa_id_with(&["1.3.6.1.5.5.7.3.4"]), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; + let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); + let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] }; + let s = signatures(&open(&stamped), &stamped, &trust).into_iter().find(|s| s.doc_timestamp).unwrap(); + assert_eq!(s.status, Status::Unknown, "{:?}", s.details); + assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details); + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone(), tsa.id.certificate.clone()] }; + let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap(); + assert!(s.timestamp); + assert_eq!(s.timestamp_time, None, "{:?}", s.details); + assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1d121c59bc..73b0ffbf18 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2379,6 +2379,7 @@ action wizard Start Iniciar The timestamp token could not be read ({}). No se ha podido leer el sello de tiempo ({}). The document has been altered or corrupted since the timestamp was applied. El documento se ha alterado o se ha dañado desde que se aplicó el sello de tiempo. The timestamp authority's certificate was not valid at the time of timestamping. El certificado de la autoridad de sellado de tiempo no era válido en el momento del sellado. + The timestamp authority's certificate is not issued for time stamping. El certificado de la autoridad de sellado de tiempo no está emitido para sellar el tiempo. This document has not been modified since this timestamp was applied. Este documento no se ha modificado desde que se aplicó este sello de tiempo. The document has been modified since this timestamp was applied, but the changes are permitted ({}). El documento se ha modificado desde que se aplicó este sello de tiempo, pero son cambios que permite ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). El documento se ha alterado desde que se aplicó este sello de tiempo de maneras que no permite ({}). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8176e498bd..6836f3e0a3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2523,6 +2523,7 @@ animation effects Off Wyłączone The timestamp token could not be read ({}). Nie udało się odczytać tokenu znacznika czasu ({}). The document has been altered or corrupted since the timestamp was applied. Dokument został zmieniony lub uszkodzony po dodaniu znacznika czasu. The timestamp authority's certificate was not valid at the time of timestamping. Certyfikat urzędu znacznika czasu nie był ważny w chwili oznaczania czasem. + The timestamp authority's certificate is not issued for time stamping. Certyfikat urzędu znakowania czasem nie jest wystawiony do znakowania czasem. This document has not been modified since this timestamp was applied. Dokument nie był modyfikowany od dodania tego znacznika czasu. The document has been modified since this timestamp was applied, but the changes are permitted ({}). Dokument zmodyfikowano po dodaniu tego znacznika czasu, ale zmiany są dozwolone ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). Dokument zmieniono po dodaniu tego znacznika czasu w sposób, na który znacznik nie pozwala ({}). From 3e180944267020b471de53f87c09cafb3b9fe474 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:35:35 +0000 Subject: [PATCH 009/191] i18n(ui): carry the two new signature wordings in the catalogues that must cover them Czech and Vietnamese get translations; Catalan, Finnish, Lithuanian, Brazilian Portuguese and Romanian carry the English until translated. --- crates/ui-egui/src/i18n/ca.tsv | 2 ++ crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/fi.tsv | 2 ++ crates/ui-egui/src/i18n/lt.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ro.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 2 ++ 7 files changed, 14 insertions(+) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9cad6ad5ec..dd2f497b84 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2283,6 +2283,7 @@ action wizard Start Inicia The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). El certificat del signant no s'ha emès per signar documents (el seu ús estès de clau no inclou el propòsit de signatura de documents). The signer's certificate has critical extensions PdfCraft does not recognize ({}). El certificat del signant té extensions crítiques que PdfCraft no reconeix ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. El certificat de {} no és un certificat de CA que pugui emetre certificats, de manera que no s'utilitza per avalar {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. El certificat de {} no permet tants certificats de CA per sota seu (longitud de camí), de manera que no s'utilitza per avalar {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. El certificat de {} no era vàlid en el moment de signar, de manera que no s'utilitza per avalar {}. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. La identitat del signant és desconeguda perquè no s'ha inclòs a la llista de certificats de confiança i cap dels seus certificats pare és un certificat de confiança. @@ -2302,6 +2303,7 @@ action wizard Start Inicia The timestamp token could not be read ({}). No s'ha pogut llegir el testimoni de marca de temps ({}). The document has been altered or corrupted since the timestamp was applied. El document ha estat alterat o s'ha corromput des que es va aplicar la marca de temps. The timestamp authority's certificate was not valid at the time of timestamping. El certificat de l'autoritat de marca de temps no era vàlid en el moment de marcar el temps. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. This document has not been modified since this timestamp was applied. Aquest document no s'ha modificat des que es va aplicar aquesta marca de temps. The document has been modified since this timestamp was applied, but the changes are permitted ({}). El document s'ha modificat des que es va aplicar aquesta marca de temps, però els canvis són permesos ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). El document ha estat alterat des que es va aplicar aquesta marca de temps d'una manera que no permet ({}). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4e736bbf46..66cac29aaf 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2615,6 +2615,7 @@ animation effects Off Vypnuto The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo podpisu RSA nemá parametr NULL (starší podepisovací programy jej tak zapisují); přesto byl načten. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Certifikát {} nepovoluje pod sebou tolik certifikátů CA (délka cesty), proto se nepoužije k ručení za {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Certifikát {} není certifikát CA, který smí vydávat certifikáty, proto se nepoužije k ručení za {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Certifikát {} má kritická rozšíření, která PdfCraft nezná, proto se nepoužije k ručení za {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Certifikát {} nebyl v době podpisu platný, proto se nepoužije k ručení za {}. The checker found problems which may prevent the document from being fully accessible. Kontrola našla problémy, které mohou bránit plné přístupnosti dokumentu. The document has been altered or corrupted since the signature was applied. Dokument byl od připojení podpisu změněn nebo poškozen. @@ -2657,6 +2658,7 @@ animation effects Off Vypnuto The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Identita podepisující osoby je neznámá, protože není ve vašem seznamu důvěryhodných certifikátů a žádný z jejích nadřazených certifikátů není důvěryhodný. The signer's identity is valid. Identita podepisující osoby je platná. The timestamp authority's certificate was not valid at the time of timestamping. Certifikát autority časových razítek nebyl v době vystavení razítka platný. + The timestamp authority's certificate is not issued for time stamping. Certifikát autority časových razítek není vystaven pro vydávání časových razítek. The timestamp contents are not hexadecimal. Obsah časového razítka není šestnáctkový. The timestamp has no valid byte range. Časové razítko nemá platný rozsah bajtů. The timestamp token could not be read ({}). Časové razítko nebylo možné načíst ({}). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ddc299823..efcf3e3c6e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2496,6 +2496,7 @@ animation effects Off Ei käytössä The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Allekirjoittajan varmennetta ei ole myönnetty asiakirjojen allekirjoittamiseen (sen laajennettu avaimen käyttö ei sisällä asiakirjojen allekirjoitusta). The signer's certificate has critical extensions PdfCraft does not recognize ({}). Allekirjoittajan varmenteessa on kriittisiä laajennuksia, joita PdfCraft ei tunnista ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Kohteen {} varmenne ei ole varmenteita myöntävä CA-varmenne, joten sitä ei käytetä kohteen {} vahvistamiseen. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Kohteen {} varmenne ei salli näin montaa alempaa CA-varmennetta (polun pituus), joten sitä ei käytetä kohteen {} vahvistamiseen. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Kohteen {} varmenne ei ollut voimassa allekirjoitushetkellä, joten sitä ei käytetä kohteen {} vahvistamiseen. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Allekirjoittajan henkilöllisyys on tuntematon, koska sitä ei ole lisätty luotettujen varmenteiden luetteloon eikä mikään sen ylemmistä varmenteista ole luotettu varmenne. @@ -2515,6 +2516,7 @@ animation effects Off Ei käytössä The timestamp token could not be read ({}). Aikaleimatunnistetta ei voitu lukea ({}). The document has been altered or corrupted since the timestamp was applied. Asiakirjaa on muutettu tai se on vioittunut aikaleiman lisäämisen jälkeen. The timestamp authority's certificate was not valid at the time of timestamping. Aikaleiman myöntäjän varmenne ei ollut voimassa aikaleimaushetkellä. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. This document has not been modified since this timestamp was applied. Asiakirjaa ei ole muokattu tämän aikaleiman lisäämisen jälkeen. The document has been modified since this timestamp was applied, but the changes are permitted ({}). Asiakirjaa on muokattu tämän aikaleiman lisäämisen jälkeen, mutta muutokset ovat sallittuja ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). Asiakirjaa on muutettu tämän aikaleiman lisäämisen jälkeen tavoilla, joita se ei salli ({}). diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 0e542211f4..e2476f1963 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1735,6 +1735,7 @@ The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. RSA parašo „DigestInfo“ neturi parametro NULL (taip įrašo senesnės pasirašymo programos); jis vis tiek perskaitytas. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Subjekto {} sertifikatas neleidžia tiek žemesnio lygio sertifikavimo įstaigų sertifikatų (grandinės ilgio riba), todėl juo nepatvirtinamas {} sertifikatas. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Subjekto {} sertifikatas nėra sertifikavimo įstaigos sertifikatas, leidžiantis išduoti sertifikatus, todėl juo nepatvirtinamas {} sertifikatas. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Subjekto {} sertifikatas pasirašymo metu negaliojo, todėl juo nepatvirtinamas {} sertifikatas. The checker found problems which may prevent the document from being fully accessible. Tikrintuvas aptiko problemų, dėl kurių dokumentas gali būti nevisiškai pritaikytas prieinamumui. The document has been altered or corrupted since the signature was applied. Po pasirašymo dokumentas buvo pakeistas arba pažeistas. @@ -1783,6 +1784,7 @@ The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Pasirašiusiojo tapatybė nežinoma, nes jo sertifikato nėra patikimų sertifikatų sąraše ir nė vienas aukštesnio lygio sertifikatas nėra patikimas. The signer's identity is valid. Pasirašiusiojo tapatybė patvirtinta. The timestamp authority's certificate was not valid at the time of timestamping. Laiko žymą išdavusios įstaigos sertifikatas žymos sukūrimo metu negaliojo. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. The timestamp contents are not hexadecimal. Laiko žymos turinys nėra šešioliktainis. The timestamp has no valid byte range. Laiko žyma neturi tinkamo baitų intervalo. The timestamp token could not be read ({}). Nepavyko perskaityti laiko žymos žetono ({}). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8fe72f99b2..dcbebae958 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2634,6 +2634,7 @@ animation effects Off Desativados The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. O DigestInfo da assinatura RSA não tem o parâmetro NULL (assinantes mais antigos o incluem); mesmo assim, ele foi lido. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. O certificado de {} não permite essa quantidade de certificados de AC abaixo dele (comprimento do caminho), então não é usado para atestar a validade de {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. O certificado de {} não é um certificado de AC autorizado a emitir certificados, então não é usado para atestar a validade de {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. O certificado de {} não era válido no momento da assinatura, então não é usado para atestar a validade de {}. The checker found problems which may prevent the document from being fully accessible. A verificação encontrou problemas que podem impedir que o documento seja totalmente acessível. The document has been altered or corrupted since the signature was applied. O documento foi alterado ou corrompido desde que a assinatura foi aplicada. @@ -2680,6 +2681,7 @@ animation effects Off Desativados The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. A identidade do signatário é desconhecida porque não foi incluída na sua lista de certificados confiáveis e nenhum dos certificados na cadeia acima dela é confiável. The signer's identity is valid. A identidade do signatário é válida. The timestamp authority's certificate was not valid at the time of timestamping. O certificado da autoridade de carimbo de data e hora não era válido no momento da emissão do carimbo. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. The timestamp contents are not hexadecimal. O conteúdo do carimbo de data e hora não está em hexadecimal. The timestamp has no valid byte range. O carimbo de data e hora não tem um intervalo de bytes válido. The timestamp token could not be read ({}). Não foi possível ler o token de carimbo de data e hora ({}). diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 5a8025dc80..0003e38519 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -1684,6 +1684,7 @@ The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo din semnătura RSA nu are parametrul NULL (semnatarii mai vechi îl scriu așa); a fost citită oricum. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Certificatul lui {} nu permite atâtea certificate CA sub el (lungimea căii), așa că nu este folosit pentru a garanta pentru {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Certificatul lui {} nu este un certificat CA care poate emite certificate, așa că nu este folosit pentru a garanta pentru {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Certificatul lui {} nu era valabil în momentul semnării, așa că nu este folosit pentru a garanta pentru {}. The checker found problems which may prevent the document from being fully accessible. Verificatorul a găsit probleme care pot împiedica accesibilitatea completă a documentului. The document has been altered or corrupted since the signature was applied. Documentul a fost modificat sau deteriorat după aplicarea semnăturii. @@ -1729,6 +1730,7 @@ The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Identitatea semnatarului este necunoscută, deoarece nu a fost inclusă în lista certificatelor de încredere și niciunul dintre certificatele părinte nu este de încredere. The signer's identity is valid. Identitatea semnatarului este validă. The timestamp authority's certificate was not valid at the time of timestamping. Certificatul autorității de marcare temporală nu era valabil în momentul marcării. + The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. The timestamp contents are not hexadecimal. Conținutul marcajului temporal nu este hexazecimal. The timestamp has no valid byte range. Marcajul temporal nu are un interval de octeți valid. The timestamp token could not be read ({}). Tokenul de marcaj temporal nu a putut fi citit ({}). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bb20dcc5b9..87abe51290 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1724,6 +1724,7 @@ theme Appearance Giao diện The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo của chữ ký RSA không có tham số NULL (các trình ký cũ ghi như vậy); nó vẫn được đọc. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Chứng chỉ của {} không cho phép có nhiều chứng chỉ CA ở dưới nó như vậy (độ dài đường dẫn), nên nó không được dùng để bảo chứng cho {}. The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Chứng chỉ của {} không phải là chứng chỉ CA được phép cấp chứng chỉ, nên nó không được dùng để bảo chứng cho {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Chứng chỉ của {} có các phần mở rộng quan trọng mà PdfCraft không nhận ra, nên nó không được dùng để bảo chứng cho {}. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Chứng chỉ của {} không hợp lệ tại thời điểm ký, nên nó không được dùng để bảo chứng cho {}. The checker found problems which may prevent the document from being fully accessible. Trình kiểm tra tìm thấy các vấn đề có thể khiến tài liệu không thể tiếp cận đầy đủ. The document has been altered or corrupted since the signature was applied. Tài liệu đã bị thay đổi hoặc hỏng kể từ khi chữ ký được áp dụng. @@ -1772,6 +1773,7 @@ theme Appearance Giao diện The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Danh tính của người ký không rõ vì nó chưa được đưa vào danh sách chứng chỉ tin cậy của bạn và không chứng chỉ cấp trên nào của nó là chứng chỉ tin cậy. The signer's identity is valid. Danh tính của người ký hợp lệ. The timestamp authority's certificate was not valid at the time of timestamping. Chứng chỉ của cơ quan cấp dấu thời gian không hợp lệ tại thời điểm đóng dấu thời gian. + The timestamp authority's certificate is not issued for time stamping. Chứng chỉ của cơ quan cấp dấu thời gian không được cấp cho mục đích đóng dấu thời gian. The timestamp contents are not hexadecimal. Nội dung dấu thời gian không phải là thập lục phân. The timestamp has no valid byte range. Dấu thời gian không có dải byte hợp lệ. The timestamp token could not be read ({}). Không thể đọc mã thông báo dấu thời gian ({}). From 7c4731234f0e5a819461c98400676595e2a07b07 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:36:37 +0000 Subject: [PATCH 010/191] style(sign): rustfmt the new tests --- crates/sign/tests/it/chain.rs | 6 +++-- crates/sign/tests/it/crypto.rs | 3 ++- crates/sign/tests/it/pdf.rs | 40 +++++++--------------------------- 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/crates/sign/tests/it/chain.rs b/crates/sign/tests/it/chain.rs index 2b6b5f85a3..1cd5f028ac 100644 --- a/crates/sign/tests/it/chain.rs +++ b/crates/sign/tests/it/chain.rs @@ -348,13 +348,15 @@ fn an_unreadable_extended_key_usage_allows_no_purpose() { fn an_issuer_with_an_unprocessed_critical_extension_does_not_vouch() { let name_constraints = ext("2.5.29.30", &der::seq(&[])); let r = root(); - let constrained = issue("Name Constrained CA", Some(&r), FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints.clone()]); + let constrained = + issue("Name Constrained CA", Some(&r), FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints.clone()]); let l = end_entity("Outside Its Names", &constrained); let pool = [constrained.cert.clone(), r.cert.clone()]; let (chain, why) = build_chain_noted(&l.cert, &pool, None); assert_eq!(names(&chain), ["Outside Its Names"]); assert!(why.is_some_and(|w| w.message.contains("critical extensions")), "the reason is reported"); - let constrained_root = issue("Name Constrained Root", None, FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints]); + let constrained_root = + issue("Name Constrained Root", None, FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN), name_constraints]); let i = ca("Under Constrained Root", &constrained_root, None); let l = end_entity("Leaf", &i); let pool = [i.cert.clone(), constrained_root.cert.clone()]; diff --git a/crates/sign/tests/it/crypto.rs b/crates/sign/tests/it/crypto.rs index 193fd06c62..f184d7161a 100644 --- a/crates/sign/tests/it/crypto.rs +++ b/crates/sign/tests/it/crypto.rs @@ -482,7 +482,8 @@ fn a_responder_that_copies_the_issuers_name_does_not_answer_for_it() { let issuer = &id.certificate; let (this, next) = (mid_june(), Time { year: 2026, month: 7, day: 15, hour: 12, minute: 0, second: 0 }); let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); - let impostor = Certificate::self_signed(&issuer.subject, &key, Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, 5, &[9]).unwrap(); + let impostor = + Certificate::self_signed(&issuer.subject, &key, Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, 5, &[9]).unwrap(); assert_eq!(impostor.subject.raw, issuer.subject.raw); let cert_id = der::seq(&[ &DigestAlg::Sha1.algorithm(), diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index f7dc33f685..61517b53d3 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -435,10 +435,7 @@ impl TimestampAuthority for TestTsa { #[test] fn signing_with_a_timestamp_embeds_a_verified_rfc3161_token() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); assert!(signed.starts_with(&fixture()), "still an incremental update"); let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); @@ -474,10 +471,7 @@ fn a_malformed_timestamp_response_fails_signing_without_a_file() { #[test] fn a_document_timestamp_covers_the_file_and_validates() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); assert!(stamped.starts_with(&fixture()), "an incremental update"); let trust_tsa = TrustStore { certs: vec![tsa.id.certificate.clone()] }; @@ -511,10 +505,7 @@ fn a_document_timestamp_covers_the_file_and_validates() { #[test] fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let mut source = open(&fixture()); source .update_dict(source.root().unwrap(), |d| { @@ -590,10 +581,7 @@ fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() { fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() { use pdfcraft_sign::dss::{self, Evidence}; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); let ltv = dss::embed(&open(&signed), &Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }).unwrap(); let lta = pdfcraft_sign::timestamp_document(&open(<v), &tsa, "D:20261006120000Z").unwrap(); @@ -1563,10 +1551,7 @@ fn an_untrusted_timestamp_does_not_set_the_validation_time() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); // The TSA claims 2027 (inside the signer's validity) but is not trusted; the signer's own // clock says 2040, after the certificate expired. - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 } }; let late = SignOptions { date: "D:20400101120000Z".into(), ..opts() }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &late, &tsa).unwrap(); let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); @@ -1785,10 +1770,7 @@ fn fixture_timestamped_in_a_field(tsa: &TestTsa) -> Vec { #[test] fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = fixture_timestamped_in_a_field(&tsa); let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] }; let all = signatures(&open(&stamped), &stamped, &trust); @@ -1833,10 +1815,7 @@ fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { #[test] fn a_standalone_timestamp_typed_sig_by_an_older_writer_is_still_a_timestamp() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); let s = signatures(&open(&stamped), &stamped, &TrustStore::default()).into_iter().find(|s| s.doc_timestamp).unwrap(); assert_eq!(s.status, Status::Unknown, "{:?}", s.details); @@ -2067,10 +2046,7 @@ fn signing_waits_for_a_pending_security_change_to_be_saved() { #[test] fn encrypted_documents_take_a_document_timestamp() { - let tsa = TestTsa { - id: tsa_id(), - time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, - }; + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let base = protected(pdfcraft_cos::Algorithm::Aes128, FILL_AND_SIGN_ONLY); let stamped = pdfcraft_sign::timestamp_document(&open_pw(&base, None), &tsa, "D:20261006120000Z").unwrap(); assert!(stamped.starts_with(&base)); From 7cdde6c91c3343a3af23088dfdd69c1a69bd823b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:43:26 +0000 Subject: [PATCH 011/191] chore(assets): record the changed translation catalogues in ATTRIBUTION.toml --- ATTRIBUTION.md | 6 +++--- ATTRIBUTION.toml | 24 ++++++++++++------------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4efb31105f..593ae6e685 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -13,13 +13,13 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | @@ -285,7 +285,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | | `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format | | `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | | `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 7f70df3f8b..3f42a54011 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7358300715a54e72a477c5888dad53f426ddf76da141a6202bec74594eb5ff0a" +sha256 = "b08f8282923d2faaf1f658d2aff8f71fe5f2d3a33e792eeb50fb0e2b396ef24f" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,18 +63,18 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "265083ea7dfa129fcae0b9e401aef8f7c84982011ba91fb2023c52705b81e639" +sha256 = "df17dada3df736e75bd9b03e5ecceec164cce63767e33a1efdb98c56a121c890" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" title = "Spanish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ffcf42859cfe38ae13c6fe4219cbe6225a20b4572c202db9e82ee38bfb433799" +sha256 = "8e8c0cfda0886c584e5286370ec00814206c3af3699c76665e636fa6fdb6310b" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -135,12 +135,12 @@ sha256 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4" path = "crates/ui-egui/src/i18n/pl.tsv" title = "Polish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7bca90240f873fcec687bea84e9ea5835f0903827501d0e73ec26b98b82e8a52" +sha256 = "0bd75a93f8d18deb832223bd0642cc4f4abde04a322b684a143a9d4b4687a344" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -250,7 +250,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dea5076ce9c19f054812b678241931ad5e7f85dee08acf1c1e12c7ecaaa5d0f5" +sha256 = "bbef7f4e16f83e9e0116278e7a0a175bad79757f96a18dc639516572533f6d5b" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" @@ -292,7 +292,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "18d4beda8495d7f0b91f0dc0a52c2248d9ce6fb3f4e3de0d51f42235eaab7aa8" +sha256 = "00a2153c20a191fbe04f1ce61f140fd9628cfe2f8f75dad5bce8a7406e1bd091" [[asset]] @@ -304,7 +304,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7b13e11191a3f2c0e38f1940996ae62da16e3bdb057215b2737f3a9c6a840000" +sha256 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba" [[asset]] path = "assets/icons/accessibility.svg" @@ -3421,12 +3421,12 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" path = "crates/ui-egui/src/i18n/vi.tsv" title = "Vietnamese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1c73f9d51b599923d704bf00c8e56b23696de16141b7a2c20aae6f60f88927a5" +sha256 = "57f0dd007ad16518fbc87d8aeac840ef6d86412bb460aff9fb17b176408fb7a8" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3537,4 +3537,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cde106ae68eea0a2d6229863ea21bbea235e4c3c5e3e728d52689d54041f0593" +sha256 = "5879ee3540ffb630fc83d0f2cb293cc6cbf2e081ea4577161a770794e8cbd22c" From d5047aa4acdf35158c3943839dc5c98f16a8f955 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:07:32 +0000 Subject: [PATCH 012/191] test(cos): build the private temp directory without an unused mut on Windows The only mutable use of the DirBuilder is the Unix mode call, so the binding is mutable only where that call exists. --- crates/cos/tests/it/encryption.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index 8d03d22790..2c6aad244d 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -237,9 +237,14 @@ fn private_temp_dir(prefix: &str) -> std::path::PathBuf { let mut name = std::collections::hash_map::RandomState::new().build_hasher(); name.write_u32(std::process::id()); let dir = std::env::temp_dir().join(format!("{prefix}-{:016x}", name.finish())); - let mut builder = std::fs::DirBuilder::new(); #[cfg(unix)] - std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + let builder = { + let mut builder = std::fs::DirBuilder::new(); + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder + }; + #[cfg(not(unix))] + let builder = std::fs::DirBuilder::new(); builder.create(&dir).unwrap(); dir } From cc1086a86221dd18ec713b9800449747236383f4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:28 +0000 Subject: [PATCH 013/191] fix(audit): keep the pending path across Q 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. --- crates/audit/src/cover.rs | 3 ++- crates/audit/src/tests.rs | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 3df666c190..20ed811d40 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -144,10 +144,11 @@ impl<'a> Walker<'a> { match op.op.as_slice() { b"q" => stack.push(gs.clone()), b"Q" => { + // The current path is not graphics state (ISO 32000-2 §8.4.1): `re Q f` still + // fills the rectangle, whose corners are already in user space. if let Some(g) = stack.pop() { *gs = g; } - path = Path::Empty; } b"cm" => { let before = gs.ctm; diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 4ca3b0bf5b..ded3a865ca 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -592,3 +592,13 @@ fn reading_blocks_respect_stream_comments_and_split_operands() { assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1)); } } + +/// The current path is not graphics state: a rectangle built before `Q` and filled after it +/// paints the box, so it is a cover. +#[test] +fn rectangle_filled_after_restore_is_a_cover() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\nq 100 688 220 32 re Q f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); +} From 4c7ba85bebefcc4be2bd457faf5d30d07ef52e38 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:34 +0000 Subject: [PATCH 014/191] fix(audit): resolve an indirect /Subtype before skipping an object 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. --- crates/audit/src/cover.rs | 2 +- crates/audit/src/lib.rs | 12 +++++++++++- crates/audit/src/tests.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 20ed811d40..d308c75a23 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -259,7 +259,7 @@ impl<'a> Walker<'a> { let resolved = self.doc.resolve(entry); let Object::Stream(stream) = &*resolved else { return Ok(()) }; let dict = &stream.dict; - if dict.name(b"Subtype") != Some(b"Form") { + if crate::subtype(self.doc, dict)?.as_deref() != Some(b"Form") { return Ok(()); } let matrix = dict diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 365889d61a..b79353e85d 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -229,7 +229,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro continue; } let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]; - match dict.name(b"Subtype") { + match subtype(doc, &dict)?.as_deref() { Some(b"Redact") => out.push(AnnotationCover::Redact(rect)), Some(b"Square") => { let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); @@ -249,6 +249,16 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro Ok(out) } +/// The `/Subtype` name of `dict`, following an indirect reference. A reference that cannot be +/// loaded fails the audit: the subtype decides whether the object is checked at all. +fn subtype(doc: &Document, dict: &Dict) -> Result>, AuditError> { + let name = match dict.get(b"Subtype") { + Some(pdfcraft_cos::Object::Ref(r)) => doc.try_get(r.num)?.as_name().map(<[u8]>::to_vec), + other => other.and_then(pdfcraft_cos::Object::as_name).map(<[u8]>::to_vec), + }; + Ok(name) +} + fn annotation_color(doc: &Document, dict: &Dict, key: &[u8]) -> Option<[f64; 3]> { let values: Vec = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::>()?; if !values.iter().all(|v| v.is_finite()) { diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index ded3a865ca..9be067ae0d 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -602,3 +602,32 @@ fn rectangle_filled_after_restore_is_a_cover() { assert_eq!(findings.len(), 1, "{findings:?}"); assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); } + +/// A `/Subtype` may be an indirect reference to the name, like any other value: a `/Redact` +/// annotation and a covering form XObject named that way are still checked. +#[test] +fn indirect_subtype_is_resolved() { + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n"); + b.add(FONT); + b.add("<< /Type /Annot /Subtype 7 0 R /Rect [95 685 305 725] >>"); + b.add("/Redact"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::UnappliedMark); + + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> /XObject << /X 5 0 R >> >> >>"); + b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n/X Do\n"); + b.add_stream_with(b"0 g\n100 688 220 32 re f\n", "/Type /XObject /Subtype 7 0 R /BBox [0 0 612 792]"); + b.add(FONT); + b.add("/Form"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::CoveredText); +} From 3e306c22ef1dd255911e5c5ae4c2b9753ecf8299 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:43 +0000 Subject: [PATCH 015/191] fix(audit): compare a cover with the start of a merged text line 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. --- crates/audit/src/lib.rs | 10 ++++++---- crates/audit/src/tests.rs | 13 +++++++++++++ crates/edit/src/text.rs | 9 +++++---- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index b79353e85d..6dc329d9ae 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -143,11 +143,13 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> } /// A filled rectangle is a faux-redaction cover for a text line iff it was painted strictly -/// after the line, overlaps it substantially, and is dark enough against the line's color to -/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the -/// contrast check and are not flagged. +/// after the start of the line, overlaps it substantially, and is dark enough against the line's +/// color to make the text unreadable. Light covers (table shading, highlights) over dark text fail +/// the contrast check and are not flagged. A line can join runs painted before and after the +/// cover; the later ones are drawn on it in the same low-contrast color, so they are unreadable +/// too. fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool { - rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST + rect.pos > line.first_paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST } /// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough: diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 9be067ae0d..9d5a1726d2 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -631,3 +631,16 @@ fn indirect_subtype_is_resolved() { assert_eq!(findings.len(), 1, "{findings:?}"); assert_eq!(findings[0].kind, FindingKind::CoveredText); } + +/// Text drawn after a cover on the same baseline joins the covered text in one line. The cover +/// still hides the words painted before it. +#[test] +fn later_text_on_the_line_does_not_clear_an_earlier_cover() { + let content = b"0 g\nBT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"; + let doc = open(one_page(content, "")); + let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text"); + assert_eq!(lines.len(), 1, "the runs join one line: {:?}", lines.iter().map(|l| &l.text).collect::>()); + let findings = audit_redactions(&doc).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 93a5eec99e..1861b7816e 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -528,12 +528,13 @@ impl TextLine { self.origin.baseline } - /// Paint position of the line's last text-showing operator: `(content-stream index, - /// operator index in the page's joined stream)`. Lines paint in order of this tuple. + /// Paint position of the line's first operator: `(content-stream index, operator index in + /// the page's joined stream)`. A line joins runs painted at different times, so anything + /// painted after this position may lie on top of part of it. /// (Lines from `text_lines` never come from form XObjects, so `stream` is always a /// page content-stream index and `ops` are indices into the joined operator list.) - pub fn paint_pos(&self) -> (usize, usize) { - (self.stream, self.ops.iter().max().copied().unwrap_or(0)) + pub fn first_paint_pos(&self) -> (usize, usize) { + (self.stream, self.ops.iter().min().copied().unwrap_or(0)) } fn rotation(&self) -> f64 { From 65ee18d470acf96dc25acef5f2f024312b03b0e3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:49 +0000 Subject: [PATCH 016/191] fix(audit): bound cover-line comparisons and covered text 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. --- crates/audit/src/lib.rs | 86 ++++++++++++++++++++++++++++----------- crates/audit/src/tests.rs | 31 ++++++++++++++ 2 files changed, 94 insertions(+), 23 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 6dc329d9ae..d827159570 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -79,6 +79,10 @@ pub enum AuditError { TooDeep, #[error("page has a malformed /Annots entry")] BadAnnots, + #[error("page has more covers and text lines than the audit compares")] + TooManyComparisons, + #[error("covered text exceeds the audit's report budget")] + TooMuchOutput, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), #[error(transparent)] @@ -95,41 +99,77 @@ const WORD_FRACTION: f64 = 0.5; const COVER_CONTRAST: f64 = 3.0; /// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any -/// content that cannot be decoded is returned as an error, never as an empty finding list. +/// content that cannot be decoded, or that needs more work or report than the audit's bounds, is +/// returned as an error, never as an empty finding list. pub fn audit_redactions(doc: &Document) -> Result, AuditError> { + audit(doc, Limits::DEFAULT) +} + +/// Work and output bounds for one audit. +#[derive(Clone, Copy)] +struct Limits { + /// Cover-against-line comparisons per page. + comparisons: usize, + /// Bytes of covered text across all findings. + covered_text: usize, +} + +impl Limits { + /// Every cover is compared with every line of its page, and each finding repeats the text + /// it covers, so both grow with the square of the content: a few megabytes of covers over + /// one long line would otherwise take hours and gigabytes. + const DEFAULT: Limits = Limits { comparisons: 50_000_000, covered_text: 64 << 20 }; +} + +/// The work and report the audit has spent against its [`Limits`]. +struct Budget { + limits: Limits, + comparisons: usize, + covered_text: usize, +} + +impl Budget { + /// The text of the `lines` that `hides`, joined with single spaces. + fn hidden_text(&mut self, lines: &[TextLine], hides: impl Fn(&TextLine) -> bool) -> Result { + self.comparisons = self.comparisons.saturating_add(lines.len()); + if self.comparisons > self.limits.comparisons { + return Err(AuditError::TooManyComparisons); + } + let mut covered = String::new(); + for line in lines.iter().filter(|l| hides(l)) { + let text = line.text.trim(); + self.covered_text = self.covered_text.saturating_add(text.len() + 1); + if self.covered_text > self.limits.covered_text { + return Err(AuditError::TooMuchOutput); + } + if !covered.is_empty() { + covered.push(' '); + } + covered.push_str(text); + } + Ok(covered) + } +} + +fn audit(doc: &Document, limits: Limits) -> Result, AuditError> { + let mut budget = Budget { limits, comparisons: 0, covered_text: 0 }; let mut out = Vec::new(); for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() { let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?; + budget.comparisons = 0; // 1. Opaque filled rectangles over extractable text. for r in cover::filled_rects(doc, &page.dict)? { - let mut covered = String::new(); - for line in &lines { - if is_cover(&r, line) { - if !covered.is_empty() { - covered.push(' '); - } - covered.push_str(line.text.trim()); - } - } + let covered = budget.hidden_text(&lines, |line| is_cover(&r, line))?; if !covered.trim().is_empty() { out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered }); } } // 2. Annotation covers over extractable text. for annot in annots(doc, &page.dict)? { - let mut covered = String::new(); - for line in &lines { - let hidden = match &annot { - AnnotationCover::Redact(rect) => covers(rect, line), - AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, - }; - if hidden { - if !covered.is_empty() { - covered.push(' '); - } - covered.push_str(line.text.trim()); - } - } + let covered = budget.hidden_text(&lines, |line| match &annot { + AnnotationCover::Redact(rect) => covers(rect, line), + AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, + })?; if !covered.trim().is_empty() { let (kind, rect) = match annot { AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect), diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 9d5a1726d2..ed809bc633 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -644,3 +644,34 @@ fn later_text_on_the_line_does_not_clear_an_earlier_cover() { assert_eq!(findings.len(), 1, "{findings:?}"); assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); } + +/// Many covers over the same long line repeat its text in every finding: a small file must not +/// expand into an unbounded report. Past the budget the audit fails rather than truncate. +#[test] +fn covered_text_across_findings_is_bounded() { + let text = "X".repeat(60_000); + let mut content = format!("BT /F1 1 Tf 0 700 Td ({text}) Tj ET\n0 g\n").into_bytes(); + for i in 0..1_200 { + content.extend_from_slice(format!("-{i} 690 50000 20 re f\n").as_bytes()); + } + let result = audit_redactions(&open(one_page(&content, ""))); + assert!(matches!(result, Err(crate::AuditError::TooMuchOutput)), "{:?}", result.map(|f| f.len())); +} + +/// Covers compared against lines are bounded per page, so content that is quadratic in its +/// covers and lines fails the audit instead of running for hours. +#[test] +fn cover_and_line_comparisons_are_bounded() { + let mut content = Vec::new(); + for i in 0..20 { + content.extend_from_slice(format!("BT /F1 12 Tf 72 {} Td (Line {i}) Tj ET\n", 700 - 14 * i).as_bytes()); + } + for _ in 0..20 { + content.extend_from_slice(b"1 g 0 0 10 10 re f\n"); + } + let doc = open(one_page(&content, "")); + let limits = crate::Limits { comparisons: 399, covered_text: usize::MAX }; + assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); + let limits = crate::Limits { comparisons: 400, covered_text: usize::MAX }; + assert!(crate::audit(&doc, limits).expect("within the budget").is_empty()); +} From 3fce90e94a3ec5dd70f977814f75f80f9dd74bb6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:47:03 +0000 Subject: [PATCH 017/191] fix(audit): withhold covered text when copying is not permitted 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. --- crates/audit/README.md | 7 ++++++- crates/audit/src/lib.rs | 13 ++++++++++--- crates/audit/src/tests.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/audit/README.md b/crates/audit/README.md index 37771a5350..ce2c47915f 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -33,7 +33,12 @@ Limits, by design and documented rather than hidden: less than 40% of its words (a sliver of a long value) is not flagged. It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper -than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings". +than 32 or in a cycle, a walk past the operator bound, more than 50 million cover-against-line +comparisons on a page, or more than 64 MiB of covered text in the report is an error, never "no +findings". + +A document whose permissions withhold copying (Table 22, bit 5) still gets every finding, with +an empty `covered_text`: the audit hands out no text that copying would refuse. Surfaces: the `doc_audit_redactions` automation tool and `pdfcraft-cli audit`. Both report each rectangle in points from the top-left of the page as displayed (y down, after `/Rotate`), like diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index d827159570..4f1877ef67 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -65,7 +65,7 @@ pub struct AuditFinding { /// `rect[0] <= rect[2]` and `rect[1] <= rect[3]`. pub rect: [f64; 4], /// The extractable text the cover hides, lines joined with single spaces and trimmed. - /// Possibly empty when nothing decodable remains under the cover. + /// Empty when the document's permissions withhold copying (see [`audit_redactions`]). pub covered_text: String, } @@ -101,6 +101,10 @@ const COVER_CONTRAST: f64 = 3.0; /// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any /// content that cannot be decoded, or that needs more work or report than the audit's bounds, is /// returned as an error, never as an empty finding list. +/// +/// When the document's permissions withhold copying (ISO 32000-2 Table 22, bit 5), findings are +/// still reported but their `covered_text` is empty: the audit hands out no text that copying +/// would refuse. pub fn audit_redactions(doc: &Document) -> Result, AuditError> { audit(doc, Limits::DEFAULT) } @@ -152,6 +156,7 @@ impl Budget { } fn audit(doc: &Document, limits: Limits) -> Result, AuditError> { + let copying = doc.permissions().is_none_or(|p| p.copy()); let mut budget = Budget { limits, comparisons: 0, covered_text: 0 }; let mut out = Vec::new(); for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() { @@ -161,7 +166,8 @@ fn audit(doc: &Document, limits: Limits) -> Result, AuditError for r in cover::filled_rects(doc, &page.dict)? { let covered = budget.hidden_text(&lines, |line| is_cover(&r, line))?; if !covered.trim().is_empty() { - out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered }); + let covered_text = if copying { covered } else { String::new() }; + out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text }); } } // 2. Annotation covers over extractable text. @@ -175,7 +181,8 @@ fn audit(doc: &Document, limits: Limits) -> Result, AuditError AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect), AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect), }; - out.push(AuditFinding { page: n, kind, rect, covered_text: covered }); + let covered_text = if copying { covered } else { String::new() }; + out.push(AuditFinding { page: n, kind, rect, covered_text }); } } } diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index ed809bc633..de501d01a7 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -675,3 +675,29 @@ fn cover_and_line_comparisons_are_bounded() { let limits = crate::Limits { comparisons: 400, covered_text: usize::MAX }; assert!(crate::audit(&doc, limits).expect("within the budget").is_empty()); } + +/// Covered text is document text: a document whose permissions withhold copying reports where +/// its covers are but not what they hide, unless it was opened with the owner password. +#[test] +fn copy_restricted_documents_withhold_covered_text() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n"; + let mut doc = open(one_page(content, "")); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [7; 32], + }) + .expect("encrypt"); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).expect("save")); + let user = Document::open_with_password(bytes.clone(), None).expect("open"); + let findings = audit_redactions(&user).expect("audit must run"); + assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}"); + assert_eq!(findings[0].covered_text, ""); + let owner = Document::open_with_password(bytes, Some("owner")).expect("open"); + let findings = audit_redactions(&owner).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "SECRET DATA 123"); +} From 693eae1d56063ee2cf2b185cf9cc0486c6b3bae5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:49:00 +0000 Subject: [PATCH 018/191] docs(parity): cite the audit's new tests and record its bounds and copy restriction --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa776..700b74b8da 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5841,8 +5841,8 @@ milestone = "M8" status = "partial" commands = [] tools = ["doc_audit_redactions"] -evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/it/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/it/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/it/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." +evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/it/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/it/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/it/audit.rs::audit_rectangles_match_the_tool_in_top_left_points", "crates/audit/src/tests.rs::rectangle_filled_after_restore_is_a_cover", "crates/audit/src/tests.rs::later_text_on_the_line_does_not_clear_an_earlier_cover", "crates/audit/src/tests.rs::indirect_subtype_is_resolved", "crates/audit/src/tests.rs::copy_restricted_documents_withhold_covered_text", "crates/audit/src/tests.rs::cover_and_line_comparisons_are_bounded", "crates/audit/src/tests.rs::covered_text_across_findings_is_bounded"] +notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the start of the text line it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content, or more than 50 million cover-against-line comparisons on a page or 64 MiB of covered text in the report, is an error, never a clean bill. A document that withholds copying gets every finding with the covered text left out. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." [[feature]] id = "protect.remove-hidden-on-close" From 733e38bfa71840d1e96e806d81a2544871ec7d85 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:06:47 +0000 Subject: [PATCH 019/191] fix(cos): a decode budget shared by every stream one walk reads 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. --- crates/cos/src/budget.rs | 135 +++++++++++++++++++++++++++++++++++++++ crates/cos/src/lib.rs | 2 + crates/cos/src/object.rs | 14 +++- 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 crates/cos/src/budget.rs diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs new file mode 100644 index 0000000000..b89d06001a --- /dev/null +++ b/crates/cos/src/budget.rs @@ -0,0 +1,135 @@ +//! A shared allowance for operations that decode many streams. + +use std::collections::HashSet; + +use pdfcraft_filters::FilterError; + +use crate::{CosError, Document, ObjRef, Object, Stream}; + +/// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every +/// form XObject it enters, each occurrence counted again. +pub const MAX_PAGE_DECODED: usize = 256 << 20; +/// Form XObjects one page's content walk may enter, each entry counted again. +pub const MAX_PAGE_FORMS: usize = 50_000; + +/// What one operation may still decode and enter across all the streams it reads. +/// +/// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose +/// `/Contents` names the same compressed stream thousands of times, or a form drawn thousands of +/// times from other forms: each occurrence would get a fresh allowance. A `DecodeBudget` is +/// shared by every decode of one operation, so the total decoded bytes and form entries stay +/// under fixed limits however the file repeats its references. A decode that runs past the +/// remaining allowance spends all of it, so a bomb referenced many times is inflated once. +#[derive(Debug)] +pub struct DecodeBudget { + bytes: usize, + forms: usize, + seen: HashSet, +} + +impl DecodeBudget { + /// An allowance of `bytes` decoded bytes and `forms` form entries. + pub fn new(bytes: usize, forms: usize) -> Self { + Self { bytes, forms, seen: HashSet::new() } + } + + /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. + pub fn page() -> Self { + Self::new(MAX_PAGE_DECODED, MAX_PAGE_FORMS) + } + + /// Decoded bytes still allowed. + pub fn remaining(&self) -> usize { + self.bytes + } + + /// Whether a decode was refused for lack of allowance or a form entry for lack of visits. + pub fn exhausted(&self) -> bool { + self.bytes == 0 || self.forms == 0 + } + + /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without + /// decoding, once the allowance is spent. + pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { + if self.bytes == 0 && !stream.raw.is_empty() { + return Err(CosError::Filter(FilterError::LimitExceeded(0).to_string())); + } + match stream.decoded_within_typed(self.bytes) { + Ok(data) => { + self.bytes = self.bytes.saturating_sub(data.len()); + Ok(data) + } + Err(e) => { + if matches!(e, FilterError::LimitExceeded(_)) { + self.bytes = 0; + } + Err(CosError::Filter(e.to_string())) + } + } + } + + /// Counts one form entry. `false` once [`DecodeBudget::new`]'s `forms` entries are spent: + /// the caller leaves that form out. + pub fn enter_form(&mut self) -> bool { + match self.forms.checked_sub(1) { + Some(left) => { + self.forms = left; + true + } + None => false, + } + } + + /// `true` the first time `r` is passed to this budget, for callers that need each + /// referenced stream only once. + pub fn first_visit(&mut self, r: ObjRef) -> bool { + self.seen.insert(r) + } + + /// Each stream `contents` names, decoded against the allowance, in order. `contents` is a + /// page's `/Contents`: one stream, or an array whose non-stream entries are left out. + /// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its + /// place, so the caller chooses between skipping it and giving up. + pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec, CosError>> { + match &*doc.resolve(contents) { + Object::Stream(s) => vec![self.decode(s)], + Object::Array(list) => list + .iter() + .filter_map(|entry| match &*doc.resolve(entry) { + Object::Stream(s) => Some(self.decode(s)), + _ => None, + }) + .collect(), + _ => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dict; + + #[test] + fn repeated_references_share_one_allowance() { + let mut doc = Document::new_empty(); + let bomb = doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]))); + let contents = Object::Array(vec![Object::Ref(bomb); 64]); + let mut budget = DecodeBudget::new(10 << 20, 1); + let decoded = budget.decode_contents(&doc, &contents); + assert_eq!(decoded.len(), 64); + assert_eq!(decoded.iter().filter(|d| d.is_ok()).count(), 10); + let total: usize = decoded.iter().flatten().map(Vec::len).sum(); + assert_eq!(total, 10 << 20); + assert!(budget.exhausted()); + // A decode past the allowance spends it, so a bomb is inflated at most once more. + let mut budget = DecodeBudget::new((1 << 20) - 1, 1); + assert!(budget.decode_contents(&doc, &contents).iter().all(Result::is_err)); + assert_eq!(budget.remaining(), 0); + + assert!(budget.enter_form()); + assert!(!budget.enter_form()); + assert!(budget.first_visit(bomb)); + assert!(!budget.first_visit(bomb)); + } +} diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 152daed2de..f3be66b9a1 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -13,6 +13,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod budget; mod bytes; mod document; mod object; @@ -21,6 +22,7 @@ mod parser; mod security; mod writer; +pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS}; pub use bytes::Bytes; pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 438b8d5f35..fe33388be3 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -214,11 +214,21 @@ impl Stream { /// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than /// [`MAX_DECODED`]: decoding past `max` bytes is an error. pub fn decoded_within(&self, max: usize) -> Result, CosError> { + self.decoded_within_typed(max).map_err(|e| CosError::Filter(e.to_string())) + } + + /// [`Stream::decoded_within`] with the filter error kept typed, so a caller can tell a + /// spent limit from corrupt data. + pub(crate) fn decoded_within_typed(&self, max: usize) -> Result, pdfcraft_filters::FilterError> { + let max = max.min(MAX_DECODED); let chain = self.filters(); if chain.is_empty() { - return self.raw_within(max); + if self.raw.len() > max { + return Err(pdfcraft_filters::FilterError::LimitExceeded(max)); + } + return Ok(self.raw.to_vec()); } - pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string())) + pdfcraft_filters::decode_tolerant(&chain, &self.raw, max).map(|(v, _)| v) } /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. From 13d9c9bf8c83d7f87484ab4665f9998abb691392 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:48 +0000 Subject: [PATCH 020/191] fix(a11y): page content and forms share one decode budget per page 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. --- crates/a11y/src/alt.rs | 30 ++++++++++++++---------------- crates/a11y/src/content.rs | 33 +++++++++------------------------ crates/a11y/src/tests.rs | 10 ++++++++++ 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index e4755ab385..57f9390b16 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::Matrix; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream}; /// A Figure element (after the role map). #[derive(Clone, Debug, PartialEq)] @@ -157,22 +157,19 @@ fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option Vec { + page_content_within(doc, page, &mut DecodeBudget::page()) +} + +/// The page's content streams, decoded against `budget` and joined with a space. Streams that +/// don't decode, or arrive after the budget is spent, are left out. +pub(crate) fn page_content_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Vec { let Some(c) = page.get(b"Contents") else { return Vec::new() }; - match &*doc.resolve(c) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|x| match &*doc.resolve(x) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b' '); - v - } - _ => Vec::new(), - }) - .collect(), - _ => Vec::new(), + let mut out = Vec::new(); + for data in budget.decode_contents(doc, c).into_iter().flatten() { + out.extend_from_slice(&data); + out.push(b' '); } + out } fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { @@ -322,9 +319,10 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) }; let props = page_properties(doc, &page.dict); let mut streams: Vec)>> = Vec::with_capacity(entries.len()); + let mut budget = DecodeBudget::page(); for entry in &entries { match &*doc.resolve(entry) { - Object::Stream(s) => streams.push(Some((s.clone(), s.decoded().unwrap_or_default()))), + Object::Stream(s) => streams.push(Some((s.clone(), budget.decode(s).unwrap_or_default()))), _ => streams.push(None), } } diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index 8f7221b2c0..66f83867e3 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object}; use pdfcraft_model::Page; use crate::{Finding, Rule}; @@ -30,38 +30,22 @@ impl Scan { for &p in list { let Some(page) = pages.get(p) else { continue }; let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let data = page_content(doc, &page.dict); - let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new() }; + let mut budget = DecodeBudget::page(); + let data = crate::alt::page_content_within(doc, &page.dict, &mut budget); + let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget }; w.content(&data, &res, false, 0); } s } } -fn page_content(doc: &Document, page: &Dict) -> Vec { - let Some(c) = page.get(b"Contents") else { return Vec::new() }; - match &*doc.resolve(c) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|x| match &*doc.resolve(x) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b' '); - v - } - _ => Vec::new(), - }) - .collect(), - _ => Vec::new(), - } -} - struct Walk<'a> { doc: &'a Document, scan: &'a mut Scan, page: usize, forms: HashSet, + /// Decoded bytes and form entries left for this page. + budget: DecodeBudget, } impl Walk<'_> { @@ -116,14 +100,15 @@ impl Walk<'_> { self.scan.has_images = true; untagged += usize::from(!inside); } - Some(b"Form") if depth < MAX_FORM_DEPTH && self.forms.insert(r) => { + Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) && self.budget.enter_form() => { + self.forms.insert(r); let inner = x .dict .get(b"Resources") .map(|o| self.doc.resolve(o)) .and_then(|o| o.as_dict().cloned()) .unwrap_or_else(|| res.clone()); - let data = x.decoded().unwrap_or_default(); + let data = self.budget.decode(x).unwrap_or_default(); self.content(&data, &inner, inside, depth + 1); self.forms.remove(&r); } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 15ca643e83..84a836cbf9 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1191,3 +1191,13 @@ fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() { let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); assert_eq!(titles, ["I Overview"]); } + +#[test] +fn repeated_content_references_share_one_decode_budget() { + use pdfcraft_cos::{Dict, MAX_PAGE_DECODED, Object, Stream}; + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect(); + let data = crate::alt::page_content(&doc, &page); + assert!(data.len() <= MAX_PAGE_DECODED + 300, "{} bytes", data.len()); +} From 0b6adb70dab892dbead62e2c33bf5d3ef1557077 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:09:44 +0000 Subject: [PATCH 021/191] fix(optimize): page content and forms share one decode budget per page 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. --- crates/optimize/src/fragments.rs | 6 ++- crates/optimize/src/images.rs | 67 ++++++++++++++++++++++---------- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index b5967eee09..a73ac9830f 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -18,7 +18,7 @@ //! drops them. use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample}; use crate::{OptimizeError, Report, Stage}; @@ -213,7 +213,9 @@ fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) -> /// Merge the fragment runs of one page; returns how many fragments were merged. fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result { let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) }; - let parsed = parse(&content_bytes(doc, &contents)); + // A page whose content isn't all known is left alone: rewriting it would drop the rest. + let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) }; + let parsed = parse(&data); if parsed.skipped > 0 { return Ok(0); } diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 6182b08922..531c56ece2 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::{Matrix, Pieces}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; @@ -23,21 +23,11 @@ pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { None } -pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec { - match &*doc.resolve(contents) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => { - let pieces: Vec<_> = a - .iter() - .filter_map(|c| match &*doc.resolve(c) { - Object::Stream(s) => Some(s.decoded().unwrap_or_default()), - _ => None, - }) - .collect(); - Pieces::join(&pieces).data().to_vec() - } - _ => Vec::new(), - } +/// The page content `contents` names, decoded against `budget` and joined. `None` when a stream +/// doesn't decode or the budget runs out: the content is not all known. +pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option> { + let pieces = budget.decode_contents(doc, contents).into_iter().collect::, _>>().ok()?; + Some(Pieces::join(&pieces).data().to_vec()) } /// Forms nested deeper than this aren't followed. @@ -57,6 +47,8 @@ struct Walk<'a> { /// Forms walked at least once. seen: HashSet, rewalks: usize, + /// Decoded bytes left for the page being walked. + budget: DecodeBudget, /// False once a use may have been missed (depth or work limit). complete: bool, out: HashMap, @@ -135,23 +127,42 @@ impl Walk<'_> { } self.rewalks += 1; } + let Ok(data) = self.budget.decode(s) else { + self.complete = false; + return; + }; self.path.push(r); - self.content(&s.decoded().unwrap_or_default(), own.as_ref().unwrap_or(resources), inner); + self.content(&data, own.as_ref().unwrap_or(resources), inner); self.path.pop(); } } /// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses. /// -/// If forms nest too deeply or are drawn too many times to measure every use, every image is -/// given `INFINITY` (never resampled), since its largest use may be among those missed. +/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within +/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be +/// among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { - let mut walk = Walk { doc, path: Vec::new(), walked: HashSet::new(), seen: HashSet::new(), rewalks: 0, complete: true, out: HashMap::new() }; + let mut walk = Walk { + doc, + path: Vec::new(), + walked: HashSet::new(), + seen: HashSet::new(), + rewalks: 0, + budget: DecodeBudget::page(), + complete: true, + out: HashMap::new(), + }; for p in pages { let Some(d) = doc.get(*p).as_dict().cloned() else { continue }; let Some(contents) = d.get(b"Contents") else { continue }; let res = page_resources(doc, *p).unwrap_or_default(); - walk.content(&content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + walk.budget = DecodeBudget::page(); + let Some(data) = content_bytes(doc, contents, &mut walk.budget) else { + walk.complete = false; + continue; + }; + walk.content(&data, &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); } if !walk.complete { walk.out.values_mut().for_each(|v| *v = f64::INFINITY); @@ -433,3 +444,17 @@ mod dimension_tests { assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4])); } } + +#[cfg(test)] +mod tests { + use pdfcraft_cos::{DecodeBudget, Dict, Document, MAX_PAGE_DECODED, Object, Stream}; + + #[test] + fn repeated_content_references_share_one_decode_budget() { + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let contents = Object::Array(vec![Object::Ref(stream); 300]); + let data = super::content_bytes(&doc, &contents, &mut DecodeBudget::page()); + assert!(data.is_none_or(|d| d.len() <= MAX_PAGE_DECODED + 300)); + } +} From 8c8f56a2e29d77370721864198732685f8e624b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:11:06 +0000 Subject: [PATCH 022/191] fix(print): page content and label forms share one decode budget per page 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. --- crates/print/src/lib.rs | 51 +++++++++++++++++++++++---------------- crates/print/src/tests.rs | 39 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index e935cb938e..05b0770ba7 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -18,7 +18,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use pdfcraft_content::Matrix; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; pub mod range; pub mod spool; @@ -331,11 +331,14 @@ pub fn text_reads_up(doc: &Document, index: usize) -> bool { let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut score = 0i64; - text_direction(doc, &decoded_contents(doc, &page.dict), &res, to_display, 0, &mut score); + let mut budget = DecodeBudget::page(); + // Content past the budget is left out: the answer is a guess from the text read so far. + let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); + text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); score > 0 } -fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64) { +fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); for op in pdfcraft_content::parse(data).ops { match op.op.as_slice() { @@ -358,8 +361,11 @@ fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: } let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); - if let Ok(body) = form.decoded() { - text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score); + if !budget.enter_form() { + return; + } + if let Ok(body) = budget.decode(form) { + text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); } } _ => {} @@ -676,24 +682,22 @@ fn n(v: f64) -> String { if s == "-0" { "0".into() } else { s.into() } } -fn decoded_contents(doc: &Document, page: &Dict) -> Vec { - let list: Vec = match page.get(b"Contents") { - None => Vec::new(), - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - }; +/// The page's content streams decoded against `budget` and joined; streams that don't decode +/// are left out. `None` when the budget ran out, so part of the content is missing. +fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { + let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; let mut out = Vec::new(); - for o in list { - if let Object::Stream(s) = &*doc.resolve(&o) - && let Ok(d) = s.decoded() - { - out.extend_from_slice(&d); - out.push(b'\n'); + for data in budget.decode_contents(doc, contents) { + match data { + Ok(data) => { + out.extend_from_slice(&data); + out.push(b'\n'); + } + Err(_) if budget.remaining() == 0 => return None, + Err(_) => {} } } - out + Some(out) } /// Does annotation `d` print under `content`? @@ -760,7 +764,12 @@ fn page_form( let page = pdfcraft_model::pages(doc).swap_remove(index); let crop = page.crop(doc); let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { decoded_contents(doc, &page.dict) }; + let mut body = if content == Content::FormFieldsOnly { + Vec::new() + } else { + decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) + .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? + }; // Wrap the page content so a state it leaves behind can't affect the annotations. if !body.is_empty() { let mut wrapped = b"q\n".to_vec(); diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 99634f1aa1..fcf1a62689 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -877,3 +877,42 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() { } mod appearances; + +#[test] +fn repeated_content_references_share_one_decode_budget() { + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect(); + let data = decoded_contents(&doc, &page, &mut DecodeBudget::page()); + assert!(data.is_none_or(|d| d.len() <= pdfcraft_cos::MAX_PAGE_DECODED + 300)); +} + +/// 20 draws of a form that draws the next form 20 times, four levels down: 168,420 form +/// entries from a few hundred bytes. +fn form_fanout(doc: &mut Document) -> Dict { + let draws = "/X Do ".repeat(20); + let mut form = |content: &str, inner: Option| { + let mut d: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + if let Some(inner) = inner { + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + let res: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + d.set(b"Resources".to_vec(), Object::Dict(res)); + } + doc.add(Stream::from_raw(d, content.as_bytes().to_vec())) + }; + let mut inner = form("BT 0 1 -1 0 0 0 Tm (a) Tj ET", None); + for _ in 0..3 { + inner = form(&draws, Some(inner)); + } + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect() +} + +#[test] +fn text_direction_stops_at_the_form_budget() { + let mut doc = Document::new_empty(); + let res = form_fanout(&mut doc); + let mut score = 0; + text_direction(&doc, "/X Do ".repeat(20).as_bytes(), &res, Matrix::IDENTITY, 0, &mut score, &mut DecodeBudget::page()); + assert!(score > 0 && score <= pdfcraft_cos::MAX_PAGE_FORMS as i64, "score {score}"); +} From 9c9bbe2ad68d36bab7434593fa4842fea7bf3af1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:12:05 +0000 Subject: [PATCH 023/191] fix(organize): page content is joined within one decode budget 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. --- crates/organize/src/import.rs | 17 ++++----------- crates/organize/src/prune.rs | 33 ++++++++++++----------------- crates/organize/src/tests.rs | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 33 deletions(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 80f90fb2a4..024dfa5c36 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -1354,19 +1354,10 @@ pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<( let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]); let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360); // Content: the page's streams, decoded and joined. - let mut content = Vec::new(); - if let Some(c) = own.get(b"Contents") { - let list = match &*src.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }; - for o in list { - if let Object::Stream(s) = &*src.resolve(&o) { - content.extend(s.decoded().map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?); - content.push(b'\n'); - } - } - } + let content = match own.get(b"Contents") { + Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?, + None => Vec::new(), + }; let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); let resources = match d.get(b"Resources") { Some(r) => copier.copy_value(dst, r, None)?, diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index b5f5e65ffe..a42c342994 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ObjRef, Object}; use crate::OrganizeError; @@ -223,23 +223,16 @@ impl Pruner<'_> { } } -/// The page's content, concatenated (`/Contents` may be one stream or an array). `None` when a -/// stream can't be decoded: the page may draw names that can't be seen. -fn content_bytes(doc: &Document, contents: &Object) -> Option> { - match &*doc.resolve(contents) { - Object::Stream(s) => s.decoded().ok(), - Object::Array(a) => { - let mut out = Vec::new(); - for c in a { - if let Object::Stream(s) = &*doc.resolve(c) { - out.extend(s.decoded().ok()?); - out.push(b'\n'); - } - } - Some(out) - } - _ => Some(Vec::new()), +/// The page's content, concatenated (`/Contents` may be one stream or an array), decoded +/// against one page's [`DecodeBudget`]. An error when a stream can't be decoded or the budget +/// runs out: the page may draw names that can't be seen. +pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result, CosError> { + let mut out = Vec::new(); + for data in DecodeBudget::page().decode_contents(doc, contents) { + out.extend(data?); + out.push(b'\n'); } + Ok(out) } /// The resources of a page (as `walk` reports them) with their owner. @@ -321,9 +314,9 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result {} - Some(data) => pruner.content(&data, &res_dict, owner, 0), - None => { + Ok(data) if data.is_empty() => {} + Ok(data) => pruner.content(&data, &res_dict, owner, 0), + Err(_) => { pruner.protect_type3_resources(&res_dict, owner); pruner.distrust(&res_dict, owner); } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 7a80e38e4c..bc833257f2 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -3132,3 +3132,43 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_copying_attributes() { assert!(walk.next().is_none()); assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]); } + +/// One page whose /Contents names the same 1 MiB stream 300 times (a few kilobytes on disk). +fn repeated_contents() -> Document { + use pdfcraft_cos::{Dict, Stream}; + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let pages = doc.add(Object::Null); + let page: Dict = [ + (b"Type".to_vec(), Object::Name(b"Page".to_vec())), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())), + (b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300])), + ] + .into_iter() + .collect(); + let page = doc.add(page); + let tree: Dict = [ + (b"Type".to_vec(), Object::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(); + doc.set(pages, Object::Dict(tree)); + let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect(); + let catalog = doc.add(catalog); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_content_references_share_one_decode_budget() { + let src = repeated_contents(); + let mut dst = Document::new_empty(); + if let Ok((form, _)) = page_as_form(&mut dst, &src, 0) { + let Object::Stream(s) = &*dst.get(form) else { panic!("not a stream") }; + let len = s.decoded().unwrap().len(); + assert!(len <= pdfcraft_cos::MAX_PAGE_DECODED + 300, "{len} bytes"); + } +} From 92ab0e932d21e0a118900a09081424aa0b3e3195 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:12:11 +0000 Subject: [PATCH 024/191] fix(cos): a spent decode budget reports its whole allowance The error named the bytes left at the failing decode (often 0), which says nothing about why the operation stopped. --- crates/cos/src/budget.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index b89d06001a..35319f13bb 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -22,6 +22,8 @@ pub const MAX_PAGE_FORMS: usize = 50_000; /// remaining allowance spends all of it, so a bomb referenced many times is inflated once. #[derive(Debug)] pub struct DecodeBudget { + /// The whole allowance, for errors. + limit: usize, bytes: usize, forms: usize, seen: HashSet, @@ -30,7 +32,7 @@ pub struct DecodeBudget { impl DecodeBudget { /// An allowance of `bytes` decoded bytes and `forms` form entries. pub fn new(bytes: usize, forms: usize) -> Self { - Self { bytes, forms, seen: HashSet::new() } + Self { limit: bytes, bytes, forms, seen: HashSet::new() } } /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. @@ -52,19 +54,18 @@ impl DecodeBudget { /// decoding, once the allowance is spent. pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { if self.bytes == 0 && !stream.raw.is_empty() { - return Err(CosError::Filter(FilterError::LimitExceeded(0).to_string())); + return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } match stream.decoded_within_typed(self.bytes) { Ok(data) => { self.bytes = self.bytes.saturating_sub(data.len()); Ok(data) } - Err(e) => { - if matches!(e, FilterError::LimitExceeded(_)) { - self.bytes = 0; - } - Err(CosError::Filter(e.to_string())) + Err(FilterError::LimitExceeded(_)) => { + self.bytes = 0; + Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) } + Err(e) => Err(CosError::Filter(e.to_string())), } } From bb8ea65ffdc69bf589a40d1ac5b95b5e13899dad Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:14:53 +0000 Subject: [PATCH 025/191] fix(forms): script streams share one decode budget per reading 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. --- crates/forms/src/actions.rs | 7 +++-- crates/forms/src/lib.rs | 52 +++++++++++++++++++++++------------ crates/forms/src/scripting.rs | 3 +- crates/forms/src/tests.rs | 40 +++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 8128b6a719..cdc89e5632 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -105,11 +105,11 @@ fn texts(doc: &Document, o: &Object) -> Vec { } } -fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef]) -> Option { +fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option { let a = doc.resolve(o); let d = a.as_dict()?; Some(match d.name(b"S")? { - b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o).unwrap_or_default()), + b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()), b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()), b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()), b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()), @@ -145,13 +145,14 @@ pub fn field_actions(doc: &Document, name: &str) -> Result wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), Some(k) => aa.get(k).cloned(), }; // An action chain (/Next) counts as its first action here. - if let Some(a) = o.and_then(|o| read(doc, &o, &pages)) { + if let Some(a) = o.and_then(|o| read(doc, &o, &pages, &mut scripts)) { out.push((t, a)); } } diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 85284b8cc5..7bd87a2efc 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -14,7 +14,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString}; mod actions; pub mod af; @@ -264,7 +264,7 @@ fn name_obj(text: &str) -> Object { const MAX_LAYER_STATE: usize = 1024; /// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). -fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option { +fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option { // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): // clicking it picks a picture, as `buttonImportIcon()` does. if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { @@ -345,7 +345,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option af::button_script(&script(doc, &action)?), + b"JavaScript" => af::button_script(&script(doc, &action, scripts)?), _ => return None, }) } @@ -371,20 +371,30 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option { (!parts.is_empty()).then(|| parts.join(".")) } -/// The JavaScript of an action dictionary (`/JS` string or stream). -fn script(doc: &Document, action: &Object) -> Option { +/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode +/// in all, however many actions name the same stream. +const MAX_SCRIPT_BYTES: usize = 64 << 20; + +/// A budget for the script streams of one reading of the form. +fn script_budget() -> DecodeBudget { + DecodeBudget::new(MAX_SCRIPT_BYTES, 0) +} + +/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against +/// `budget`, and is `None` once it is spent. +fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option { let a = doc.resolve(action); let js = a.as_dict()?.get(b"JS")?.clone(); match &*doc.resolve(&js) { Object::String(s) => Some(s.to_text()), - Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), _ => None, } } /// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only /// widget's when the field has none). -fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { +fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions { let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { (widgets.len() == 1) .then(|| doc.get(widgets[0])) @@ -393,7 +403,7 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { }); let mut out = af::Actions::default(); let Some(aa) = aa else { return out }; - let js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a)); + let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); if let Some(f) = js(b"F") { match af::parse_format(&f) { Some(fm) => out.format = fm, @@ -580,7 +590,7 @@ fn enumerate(doc: &Document) -> (Vec, usize) { None => Inherited::default(), }; let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); + let mut seen = Seen { fields: std::collections::HashSet::new(), scripts: script_budget() }; if let Some(af) = &af { for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { if let Some(r) = f.as_ref() { @@ -604,7 +614,7 @@ fn adopt_page_fields( pages: &[ObjRef], base: &Inherited, page_of: &std::collections::HashMap, - seen: &mut std::collections::HashSet, + seen: &mut Seen, out: &mut Vec, ) { for p in pages { @@ -613,7 +623,7 @@ fn adopt_page_fields( let Some(annots) = annots.as_array() else { continue }; for e in annots { let Some(r) = e.as_ref() else { continue }; - if seen.contains(&r) { + if seen.fields.contains(&r) { continue; } let obj = doc.get(r); @@ -624,7 +634,7 @@ fn adopt_page_fields( if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") { continue; } - if let Some(root) = unlisted_root(doc, r, seen) { + if let Some(root) = unlisted_root(doc, r, &seen.fields) { walk(doc, root, base, page_of, seen, out, 0); } } @@ -756,16 +766,23 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re /// Read the field at `r` and the fields under it into `out`, with what they inherit from /// `parent`: kids with a name are fields of their own, kids without are this field's widgets. +/// What one reading of the form's fields has used: the field dictionaries read, and the script +/// budget their actions share. +struct Seen { + fields: std::collections::HashSet, + scripts: DecodeBudget, +} + fn walk( doc: &Document, r: ObjRef, parent: &Inherited, page_of: &std::collections::HashMap, - seen: &mut std::collections::HashSet, + seen: &mut Seen, out: &mut Vec, depth: usize, ) { - if depth > 64 || !seen.insert(r) { + if depth > 64 || !seen.fields.insert(r) { return; } let obj = doc.get(r); @@ -808,9 +825,10 @@ fn walk( return; } let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; - let actions = actions_of(doc, d, &widget_refs); - let button = - (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0).then(|| button_action(doc, d, &widget_refs)).flatten(); + let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); + let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) + .then(|| button_action(doc, d, &widget_refs, &mut seen.scripts)) + .flatten(); let ff = inh.ff.unwrap_or(0); let kind = match inh.ft.as_deref() { Some(b"Tx") => FieldKind::Text, diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index f5b8274459..7dd7bfde6c 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -176,6 +176,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() }; let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() }; let mut out: Vec<(String, String)> = Vec::new(); + let mut scripts = crate::script_budget(); // Kids stay unresolved on the stack, and each node is read once: a node listed many times, // or among its own kids, must not multiply the work. let mut stack = vec![tree]; @@ -203,7 +204,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { Object::String(s) => s.to_text(), _ => continue, }; - if let Some(js) = crate::script(doc, v) { + if let Some(js) = crate::script(doc, v, &mut scripts) { out.push((key, js)); } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b2e4994ecf..b2b8573ebb 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2981,3 +2981,43 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { assert_eq!(walk.next(), None); assert_eq!(page_refs(&doc), [page]); } + +/// 300 fields and 300 document scripts that all name one 1 MiB JavaScript stream. +fn repeated_script_references() -> Document { + use pdfcraft_cos::Stream; + let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; + let mut doc = Document::new_empty(); + let js = doc.add(Stream::flate(pdfcraft_cos::Dict::new(), &vec![b'x'; 1 << 20])); + let action = doc.add(dict(vec![(b"S", Object::Name(b"JavaScript".to_vec())), (b"JS", Object::Ref(js))])); + let fields: Vec = (0..300) + .map(|i| { + Object::Ref(doc.add(dict(vec![ + (b"FT", Object::Name(b"Tx".to_vec())), + (b"T", Object::String(pdfcraft_cos::PdfString::literal(format!("f{i}")))), + (b"Rect", Object::Array([0, 0, 10, 10].map(Object::from).to_vec())), + (b"AA", dict(vec![(b"C", Object::Ref(action))])), + ]))) + }) + .collect(); + let names: Vec = + (0..300).flat_map(|i| [Object::String(pdfcraft_cos::PdfString::literal(format!("s{i:03}"))), Object::Ref(action)]).collect(); + let catalog = doc.add(dict(vec![ + (b"Type", Object::Name(b"Catalog".to_vec())), + (b"AcroForm", dict(vec![(b"Fields", Object::Array(fields))])), + (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Names", Object::Array(names))]))])), + ])); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_script_references_share_one_decode_budget() { + let doc = repeated_script_references(); + let limit = MAX_SCRIPT_BYTES; + let named: usize = document_scripts_named(&doc).iter().map(|(_, js)| js.len()).sum(); + assert!(named > 0 && named <= limit, "{named} bytes of document scripts"); + let all = fields(&doc); + assert_eq!(all.len(), 300); + let calculate: usize = all.iter().filter_map(|f| f.actions.scripts.calculate.as_ref()).map(String::len).sum(); + assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts"); +} From d2e7ce72f045bc85e1bcc9e784d43708190a8305 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:17:10 +0000 Subject: [PATCH 026/191] fix(redact): form entries and page content share one budget per page 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. --- crates/redact/src/interp.rs | 25 +++++++++++++++++++++++-- crates/redact/src/lib.rs | 6 ++++-- crates/redact/src/tests.rs | 17 +++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index c9a404b935..5b37423859 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; -use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream}; use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; @@ -102,6 +102,9 @@ pub(crate) struct Scope<'a> { fonts: HashMap, Rc>, used_names: Vec>, depth: usize, + /// Form entries and decoded form bytes left for this page: shared forms drawn from other + /// shared forms would otherwise multiply the work with every level. + budget: DecodeBudget, } impl<'a> Scope<'a> { @@ -120,6 +123,7 @@ impl<'a> Scope<'a> { fonts: HashMap::new(), used_names: Vec::new(), depth: 0, + budget: DecodeBudget::page(), } } @@ -1201,8 +1205,25 @@ fn xobject( out.doomed.push(r); return Some(None); } - let data = match s.decoded() { + let data = if scope.budget.enter_form() { + scope.budget.decode(s) + } else { + Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects"))) + }; + let data = match data { Ok(d) => d, + // Past the page's budget nothing more is read: apply refuses the page, and + // verification counts the form as content that may remain. + Err(e) if scope.budget.exhausted() => { + if scope.mode == Mode::Apply { + out.error = Some(e); + } else if scope.everywhere() { + scope.layer_blocks += 1; + } else { + out.residue += 1; + } + return None; + } Err(_) if scope.mode == Mode::Apply => { scope.report.forms_removed += 1; out.replaced.push(name); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6df5102afa..8407e0c8f8 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -219,7 +219,8 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { (out, unreadable) } -/// A page's content streams: (the references or inline objects as listed, their decoded data). +/// A page's content streams: (the references or inline objects as listed, their decoded data), +/// decoded within one page's [`pdfcraft_cos::DecodeBudget`]. pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec, Vec>), RedactError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), @@ -229,9 +230,10 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< }, }; let mut data = Vec::new(); + let mut budget = pdfcraft_cos::DecodeBudget::page(); for o in &list { match &*doc.resolve(o) { - Object::Stream(s) => data.push(s.decoded().map_err(|_| RedactError::Unreadable(index + 1))?), + Object::Stream(s) => data.push(budget.decode(s).map_err(|_| RedactError::Unreadable(index + 1))?), Object::Null => data.push(Vec::new()), _ => return Err(RedactError::Unreadable(index + 1)), } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 286667937d..11cae9bb92 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2472,3 +2472,20 @@ fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() { assert_eq!(under(&mut doc, 0, &[[45.0, 95.0, 50.0, 110.0]]), 0, "nothing is drawn past LASTARR's 35pt width"); assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws"); } + +#[test] +fn form_entries_share_one_budget_per_page() { + // 20 draws of a form that draws the next 20 times, four levels down: 160,000 glyphs from a + // few hundred bytes. + let draws = "/X Do ".repeat(20); + let mut objs = vec![stream("/Type /XObject /Subtype /Form /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 10 100 Td (A) Tj ET")]; + for inner in 7..10 { + objs.push(stream(&format!("/Type /XObject /Subtype /Form /Resources << /XObject << /X {inner} 0 R >> >>"), draws.as_bytes())); + } + let mut doc = one_page(draws.as_bytes(), "/XObject << /X 10 0 R >>", objs); + let residue = under(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]]); + assert!(residue > 0 && residue <= pdfcraft_cos::MAX_PAGE_FORMS, "{residue} glyphs counted"); + // Applying refuses the page rather than leave forms past the budget unread. + mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], ""); + assert!(apply(&mut doc, None).is_err()); +} From ea4939cd08823bdd390797f5c245fe55bfb0c6e5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:17:54 +0000 Subject: [PATCH 027/191] fix(cos): a strict decode charged to a DecodeBudget The redaction audit treats any damaged stream as a failure, so it needs the strict decoder under the same shared allowance. --- crates/cos/src/budget.rs | 16 +++++++++++++++- crates/cos/src/object.rs | 21 ++++++++++++++------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 35319f13bb..cf1c3fc2a1 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -53,10 +53,20 @@ impl DecodeBudget { /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without /// decoding, once the allowance is spent. pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { + self.charge(stream, Stream::decoded_within_typed) + } + + /// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to + /// the allowance. + pub fn decode_strict(&mut self, stream: &Stream) -> Result, CosError> { + self.charge(stream, Stream::decoded_strict_within_typed) + } + + fn charge(&mut self, stream: &Stream, decode: fn(&Stream, usize) -> Result, FilterError>) -> Result, CosError> { if self.bytes == 0 && !stream.raw.is_empty() { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } - match stream.decoded_within_typed(self.bytes) { + match decode(stream, self.bytes) { Ok(data) => { self.bytes = self.bytes.saturating_sub(data.len()); Ok(data) @@ -127,6 +137,10 @@ mod tests { let mut budget = DecodeBudget::new((1 << 20) - 1, 1); assert!(budget.decode_contents(&doc, &contents).iter().all(Result::is_err)); assert_eq!(budget.remaining(), 0); + let mut budget = DecodeBudget::new((1 << 20) - 1, 1); + let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") }; + assert!(budget.decode_strict(stream).is_err()); + assert_eq!(budget.remaining(), 0); assert!(budget.enter_form()); assert!(!budget.enter_form()); diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index fe33388be3..9a07d4822e 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -223,21 +223,24 @@ impl Stream { let max = max.min(MAX_DECODED); let chain = self.filters(); if chain.is_empty() { - if self.raw.len() > max { - return Err(pdfcraft_filters::FilterError::LimitExceeded(max)); - } - return Ok(self.raw.to_vec()); + return self.raw_within_typed(max); } pdfcraft_filters::decode_tolerant(&chain, &self.raw, max).map(|(v, _)| v) } /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. pub fn decoded_strict(&self) -> Result, CosError> { + self.decoded_strict_within_typed(MAX_DECODED).map_err(|e| CosError::Filter(e.to_string())) + } + + /// [`Stream::decoded_strict`] bounded by `max`, with the filter error kept typed. + pub(crate) fn decoded_strict_within_typed(&self, max: usize) -> Result, pdfcraft_filters::FilterError> { + let max = max.min(MAX_DECODED); let chain = self.filters(); if chain.is_empty() { - return self.raw_within(MAX_DECODED); + return self.raw_within_typed(max); } - pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string())) + pdfcraft_filters::decode(&chain, &self.raw, max) } /// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded. @@ -252,11 +255,15 @@ impl Stream { } fn raw_within(&self, max: usize) -> Result, CosError> { + self.raw_within_typed(max).map_err(|e| CosError::Filter(e.to_string())) + } + + fn raw_within_typed(&self, max: usize) -> Result, pdfcraft_filters::FilterError> { let max = max.min(MAX_DECODED); // An empty decoder chain still produces an owned output buffer: check its limit // before cloning, including streams whose /Crypt filter was applied on load. if self.raw.len() > max { - return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string())); + return Err(pdfcraft_filters::FilterError::LimitExceeded(max)); } Ok(self.raw.to_vec()) } From b5cddc884d7bef1a724a339e0e91c5fd5264b904 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:18:42 +0000 Subject: [PATCH 028/191] fix(audit): page content and forms share one decode budget per page 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. --- crates/audit/src/cover.rs | 14 +++++++++----- crates/audit/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 3df666c190..516bb193d2 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -16,7 +16,7 @@ //! Positions compare lexicographically in paint order. use pdfcraft_content::{Matrix, Op, Pieces, parse}; -use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; use crate::AuditError; @@ -109,6 +109,8 @@ struct Walker<'a> { pieces: &'a Pieces, out: Vec, ops_seen: usize, + /// Decoded bytes left for the page, its forms included: spending it fails the audit. + budget: DecodeBudget, } impl<'a> Walker<'a> { @@ -272,7 +274,7 @@ impl<'a> Walker<'a> { .unwrap_or(Matrix::IDENTITY); let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); // A form stream that cannot be decoded fails the audit: it might hide a cover. - let data = stream.decoded_strict()?; + let data = self.budget.decode_strict(stream)?; let ops = parse(&data).ops; // The XObject matrix maps form space into the user space active at `Do`. let mut sub_gs = gs.clone(); @@ -304,7 +306,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { /// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint /// positions. A page's content streams are walked once, joined, with one graphics state, so /// state set in an earlier stream is still in force in the later ones. Any content stream that -/// cannot be decoded is an audit failure, never an empty result. +/// cannot be decoded, or content and forms that decode past one page's [`DecodeBudget`] in all, +/// is an audit failure, never an empty result. pub fn filled_rects(doc: &Document, page: &Dict) -> Result, AuditError> { let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let contents: Vec = match page.get(b"Contents") { @@ -314,15 +317,16 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result, Audi _ => vec![c.clone()], }, }; + let mut budget = DecodeBudget::page(); let mut streams = Vec::with_capacity(contents.len()); for c in &contents { if let Object::Stream(s) = &*doc.resolve(c) { - streams.push(s.decoded_strict()?); + streams.push(budget.decode_strict(s)?); } } let pieces = Pieces::join(&streams); let parsed = pieces.parse(); - let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0 }; + let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget }; let mut gs = Gs::default(); let mut stack = Vec::new(); w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?; diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 4ca3b0bf5b..8155c6dece 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -592,3 +592,22 @@ fn reading_blocks_respect_stream_comments_and_split_operands() { assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1)); } } + +#[test] +fn repeated_stream_references_share_one_decode_budget() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + // One 1 MiB stream of spaces (a kilobyte on disk), named 300 times: by the page's /Contents, + // and by 300 draws of a form holding it. + let mut doc = Document::new_empty(); + let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let contents: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(spaces); 300]))].into_iter().collect(); + assert!(crate::cover::filled_rects(&doc, &contents).is_err(), "repeated content streams"); + + let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::flate(form_dict, &vec![b' '; 1 << 20])); + let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(300).into_bytes())); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); + assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws"); +} From a37a908f7b56539dfb843c756b301af9577c27e8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:22:17 +0000 Subject: [PATCH 029/191] fix(edit): page content, forms and image records share per-page limits 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. --- crates/edit/src/background.rs | 2 +- crates/edit/src/images.rs | 43 +++++++++---------- crates/edit/src/tests.rs | 75 +++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 72 +++++++++++++++++++------------ crates/edit/src/text_regions.rs | 2 +- 5 files changed, 143 insertions(+), 51 deletions(-) diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs index d1d50e4502..7d745c0c10 100644 --- a/crates/edit/src/background.rs +++ b/crates/edit/src/background.rs @@ -50,7 +50,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE let crop = p.crop(doc); let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); - let streams = crate::text::content_streams(doc, &p.dict); + let streams = crate::text::content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 75bb12f47d..0f75ff3d3c 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -73,23 +73,16 @@ struct ClipPath { even_odd: bool, } -fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { - let list: Vec = match page.get(b"Contents") { - None => Vec::new(), - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - }; - list.into_iter() - .map(|o| { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }; - (o, data) - }) - .collect() +fn streams(doc: &Document, page: &Dict) -> Result)>, EditError> { + crate::text::content_streams(doc, page) +} + +/// Image draws one page's inventory lists at most: a form drawn many times from other forms +/// would otherwise multiply a few hundred bytes into millions of records. +pub(crate) const MAX_PAGE_IMAGES: usize = 100_000; + +fn too_many_images() -> EditError { + EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images")) } /// The page's streams joined into the one stream they are: an operator may span two of them. @@ -237,7 +230,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let mut out = Vec::new(); // The streams are one stream in pieces, so the graphics state and operators carry over. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let pieces = joined(&streams(doc, &p.dict)); + let pieces = joined(&streams(doc, &p.dict)?); for (i, op) in pieces.parse().ops.iter().enumerate() { state.apply(op)?; let ctm = state.placement.matrix; @@ -285,6 +278,9 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr { continue; } + if out.len() >= MAX_PAGE_IMAGES { + return Err(too_many_images()); + } out.push(PageImage { rect, visible_rect: intersection(rect, state.placement.clip), @@ -358,6 +354,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi if s.dict.name(b"Subtype") != Some(b"Image") { continue; } + if out.len() >= MAX_PAGE_IMAGES { + return Err(too_many_images()); + } out.push(PageImage { rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]), visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip), @@ -382,7 +381,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; - let pieces = joined(&streams(doc, &p.dict)); + let pieces = joined(&streams(doc, &p.dict)?); walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } @@ -483,7 +482,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let images = page_images(doc, page)?; let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = streams(doc, &p.dict)?; let pieces = joined(&all); let ops = pieces.parse().ops; if !ops.get(img.op).is_some_and(|op| op.is("Do")) { @@ -531,7 +530,7 @@ pub(crate) fn selectable_images( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = streams(doc, &p.dict)?; Ok(page_images(doc, page)? .into_iter() .enumerate() @@ -755,7 +754,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize } let images = page_images(doc, page)?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = streams(doc, &p.dict)?; let mut plans = std::collections::BTreeMap::new(); for index in indexes { let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index cf3860237b..61e5e958ed 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6355,3 +6355,78 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() { ] ); } + +/// A one-page document from the page's /Contents and /Resources, built in code. +fn page_with(build: impl FnOnce(&mut Document) -> (Object, Dict)) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.add(Object::Null); + let (contents, resources) = build(&mut doc); + let page: Dict = [ + (b"Type".to_vec(), Object::Name(b"Page".to_vec())), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())), + (b"Contents".to_vec(), contents), + (b"Resources".to_vec(), Object::Dict(resources)), + ] + .into_iter() + .collect(); + let page = doc.add(page); + let tree: Dict = [ + (b"Type".to_vec(), Object::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(); + doc.set(pages, Object::Dict(tree)); + let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect(); + let catalog = doc.add(catalog); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_content_references_share_one_decode_budget() { + // One 1 MiB stream (a kilobyte on disk) named 300 times by the page's /Contents. + let doc = page_with(|doc| { + let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + (Object::Array(vec![Object::Ref(spaces); 300]), Dict::new()) + }); + match text_lines(&doc, 0) { + Ok(lines) => panic!("read {} lines from more content than a page may decode", lines.len()), + Err(e) => assert!(matches!(e, EditError::Invalid(_)), "{e}"), + } +} + +#[test] +fn reading_images_stops_at_the_page_record_limit() { + // 20 draws of a form drawing a form 20 times that draws an image 20 times: 8,000 images + // per level-two form, 160,000 in all, from a few hundred bytes. + let doc = page_with(|doc| { + let image: Dict = [ + (b"Subtype".to_vec(), Object::Name(b"Image".to_vec())), + (b"Width".to_vec(), Object::Int(1)), + (b"Height".to_vec(), Object::Int(1)), + (b"ColorSpace".to_vec(), Object::Name(b"DeviceGray".to_vec())), + (b"BitsPerComponent".to_vec(), Object::Int(8)), + ] + .into_iter() + .collect(); + let mut inner = doc.add(Stream::from_raw(image, vec![0])); + let draws = "/X Do ".repeat(20); + let resources = |inner: pdfcraft_cos::ObjRef| -> Dict { + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect() + }; + for _ in 0..3 { + let mut d = resources(inner); + d = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec())), (b"Resources".to_vec(), Object::Dict(d))].into_iter().collect(); + inner = doc.add(Stream::from_raw(d, draws.clone().into_bytes())); + } + let page = doc.add(Stream::from_raw(Dict::new(), draws.into_bytes())); + (Object::Ref(page), resources(inner)) + }); + if let Ok(images) = crate::reading_images(&doc, 0) { + assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len()); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 93a5eec99e..5784b95f3b 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, PdfString, Stream}; use pdfcraft_fonts::pdf::Metrics; use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from}; @@ -615,7 +615,10 @@ struct Ts { unreadable: Option>, } -pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { +/// The page's content streams as listed, each with its decoded data (empty when it doesn't +/// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading +/// or rewriting a page from part of its content would lose the rest. +pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result)>, EditError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -623,15 +626,20 @@ pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec vec![c.clone()], }, }; - list.into_iter() - .map(|o| { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }; - (o, data) - }) - .collect() + let mut budget = DecodeBudget::page(); + let mut out = Vec::with_capacity(list.len()); + for o in list { + let data = match &*doc.resolve(&o) { + Object::Stream(s) => match budget.decode(s) { + Ok(data) => data, + Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))), + Err(_) => Vec::new(), + }, + _ => Vec::new(), + }; + out.push((o, data)); + } + Ok(out) } /// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators @@ -1284,7 +1292,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let mut carry = Carry::new(); // The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them // joined, so an operator whose operands end one piece still has them. - let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); + let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::>()); let ops = pieces.parse().ops; let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, Some(&properties))?; @@ -1490,10 +1498,11 @@ pub const MAX_FORM_DEPTH: usize = 50; pub const MAX_FORM_VISITS: usize = 50_000; /// One page's walk through the form XObjects it draws: how many it entered (the page's -/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread. -#[derive(Default)] +/// [`MAX_FORM_VISITS`]), the bytes their content may decode to in all, and whether a limit left +/// a form unread. pub(crate) struct FormBudget { visits: usize, + decode: DecodeBudget, /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk. pub(crate) truncated: bool, @@ -1501,6 +1510,12 @@ pub(crate) struct FormBudget { /// A form XObject a page draws: its object, content, resources and its matrix composed with the /// CTM at the `Do`. +impl Default for FormBudget { + fn default() -> Self { + Self { visits: 0, decode: DecodeBudget::page(), truncated: false } + } +} + pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, @@ -1519,7 +1534,7 @@ pub(crate) fn form_call( path: &[pdfcraft_cos::ObjRef], budget: &mut FormBudget, ) -> Option { - let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS; + let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS || budget.decode.remaining() == 0; // Once a limit has cut the page short, refusing the rest costs nothing more. if limited && budget.truncated { return None; @@ -1546,13 +1561,16 @@ pub(crate) fn form_call( }); // A form without its own resources uses the ones it was drawn with (PDF 1.1 files). let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let data = match budget.decode.decode(s) { + Ok(data) => data, + Err(_) if budget.decode.remaining() == 0 => { + budget.truncated = true; + return None; + } + Err(_) => Vec::new(), + }; budget.visits = budget.visits.saturating_add(1); - Some(FormCall { - obj, - data: s.decoded().unwrap_or_default(), - resources: own.unwrap_or_else(|| resources.clone()), - ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)), - }) + Some(FormCall { obj, data, resources: own.unwrap_or_else(|| resources.clone()), ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) } /// The lines a page shows as it reads, including text drawn by form XObjects (which @@ -1612,7 +1630,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E } let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() }; let mut carry = Carry::new(); // Read the page's streams as the one stream they are, in one pass, as rendering and editing @@ -2235,7 +2253,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; @@ -2440,7 +2458,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?; @@ -2661,7 +2679,7 @@ fn rewrite_block_from( { return rewrite_styled_block(doc, page, &b, &lines, text, style); } - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; // Explicit line breaks stay (#1443); within a line, runs of white space become one space. @@ -3044,7 +3062,7 @@ pub(crate) fn selectable_blocks( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_dict(doc, page)?; - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let lines = text_lines(doc, page)?; Ok(group_blocks(&lines) .into_iter() @@ -3133,7 +3151,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize return Ok(()); } let p = page_dict(doc, page)?; - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let lines = text_lines(doc, page)?; let blocks = group_blocks(&lines); let mut selected = std::collections::BTreeSet::new(); diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 928280d5d8..a775594944 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -772,7 +772,7 @@ pub(super) fn rewrite_styled_block( row_ranges.push(row_start..row_start + row.len()); rows.push(row); } - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and From 735c80c6c51975b6ba57ec2fa8ced1768bbba848 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:22:55 +0000 Subject: [PATCH 030/191] fix(cos): DecodeBudget decodes within a per-stream bound too 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. --- crates/cos/src/budget.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index cf1c3fc2a1..f88971222a 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -53,19 +53,35 @@ impl DecodeBudget { /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without /// decoding, once the allowance is spent. pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { - self.charge(stream, Stream::decoded_within_typed) + self.charge(stream, usize::MAX, Stream::decoded_within_typed) + } + + /// Like [`DecodeBudget::decode`], for a stream with a tighter bound of its own: decoding + /// past `max` bytes is an error that charges `max`, the work it took to find out. + pub fn decode_within(&mut self, stream: &Stream, max: usize) -> Result, CosError> { + self.charge(stream, max, Stream::decoded_within_typed) } /// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to /// the allowance. pub fn decode_strict(&mut self, stream: &Stream) -> Result, CosError> { - self.charge(stream, Stream::decoded_strict_within_typed) + self.charge(stream, usize::MAX, Stream::decoded_strict_within_typed) } - fn charge(&mut self, stream: &Stream, decode: fn(&Stream, usize) -> Result, FilterError>) -> Result, CosError> { + fn charge(&mut self, stream: &Stream, max: usize, decode: fn(&Stream, usize) -> Result, FilterError>) -> Result, CosError> { if self.bytes == 0 && !stream.raw.is_empty() { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } + if max < self.bytes { + let result = decode(stream, max); + let spent = match &result { + Ok(data) => data.len(), + Err(FilterError::LimitExceeded(_)) => max, + Err(_) => 0, + }; + self.bytes = self.bytes.saturating_sub(spent); + return result.map_err(|e| CosError::Filter(e.to_string())); + } match decode(stream, self.bytes) { Ok(data) => { self.bytes = self.bytes.saturating_sub(data.len()); @@ -142,6 +158,12 @@ mod tests { assert!(budget.decode_strict(stream).is_err()); assert_eq!(budget.remaining(), 0); + let mut budget = DecodeBudget::new(10 << 20, 1); + assert!(budget.decode_within(stream, 1000).is_err()); + assert_eq!(budget.remaining(), (10 << 20) - 1000); + assert_eq!(budget.decode_within(stream, 2 << 20).map(|d| d.len()).ok(), Some(1 << 20)); + assert_eq!(budget.remaining(), (9 << 20) - 1000); + assert!(budget.enter_form()); assert!(!budget.enter_form()); assert!(budget.first_visit(bomb)); From 7a33a85d8b95b98fb1310258f1e104bd4287ecf4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:24:00 +0000 Subject: [PATCH 031/191] fix(fonts): embedded sfnt reads can share one decode budget 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. --- crates/fonts/src/pdf.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0aaed6939e..1992116651 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -14,7 +14,7 @@ use std::borrow::Cow; use std::collections::{BinaryHeap, HashMap, HashSet}; -use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; /// How the bytes of a string map to codes. #[derive(Clone, Debug, PartialEq)] @@ -92,6 +92,15 @@ fn legacy_indic_font(base_font: &str) -> bool { /// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export /// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer. pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { + embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0)) +} + +/// The largest embedded sfnt program read for the editor. +const MAX_SFNT: usize = 16 << 20; + +/// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares: +/// each program decoded is charged whether or not it turns out to be usable. +pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()); let descriptor = dict(doc, descendant.as_ref().unwrap_or(font).get(b"FontDescriptor"))?; @@ -99,7 +108,7 @@ pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(mut bytes) = stream.decoded_within(16 << 20) else { continue }; + let Ok(mut bytes) = budget.decode_within(stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { continue; @@ -2114,4 +2123,20 @@ mod encoding_tests { assert_eq!(m.text_of(0x61).as_deref(), Some("a")); assert_eq!(m.decode(b"abc"), "abc"); } + + #[test] + fn unusable_programs_still_spend_the_decode_budget() { + // 128 resources naming one 1 MiB program that isn't a font: each decode is charged, + // so an enumeration stops decoding once the shared budget is spent. + let mut doc = Document::new_empty(); + let program = doc.add(pdfcraft_cos::Stream::flate(Dict::new(), &vec![0; 1 << 20])); + let descriptor: Dict = [(b"FontFile2".to_vec(), Object::Ref(program))].into_iter().collect(); + let descriptor = doc.add(descriptor); + let font: Dict = [(b"FontDescriptor".to_vec(), Object::Ref(descriptor))].into_iter().collect(); + let mut budget = DecodeBudget::new(64 << 20, 0); + for _ in 0..128 { + assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_none()); + } + assert_eq!(budget.remaining(), 0); + } } From c88831381e14ab461ea5cc96881a48e88d5d0d87 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:24:00 +0000 Subject: [PATCH 032/191] fix(edit): editor font enumeration shares one decode budget 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. --- crates/edit/src/text.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 5784b95f3b..34768527b3 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -113,10 +113,12 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result budget { continue; } From 37b6c530d82d4351f3598010fd5262e98bbb6882 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:25:04 +0000 Subject: [PATCH 033/191] refactor(edit): images read page content through content_streams The image module kept its own copy of the page-content reader; the one in text carries the page decode budget. --- crates/edit/src/images.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 0f75ff3d3c..1f7773a52c 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -9,6 +9,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use std::sync::Arc; use crate::EditError; +use crate::text::content_streams; /// An image drawn on a page. #[derive(Clone, Debug, PartialEq)] @@ -73,10 +74,6 @@ struct ClipPath { even_odd: bool, } -fn streams(doc: &Document, page: &Dict) -> Result)>, EditError> { - crate::text::content_streams(doc, page) -} - /// Image draws one page's inventory lists at most: a form drawn many times from other forms /// would otherwise multiply a few hundred bytes into millions of records. pub(crate) const MAX_PAGE_IMAGES: usize = 100_000; @@ -230,7 +227,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let mut out = Vec::new(); // The streams are one stream in pieces, so the graphics state and operators carry over. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let pieces = joined(&streams(doc, &p.dict)?); + let pieces = joined(&content_streams(doc, &p.dict)?); for (i, op) in pieces.parse().ops.iter().enumerate() { state.apply(op)?; let ctm = state.placement.matrix; @@ -381,7 +378,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; - let pieces = joined(&streams(doc, &p.dict)?); + let pieces = joined(&content_streams(doc, &p.dict)?); walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } @@ -482,7 +479,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let images = page_images(doc, page)?; let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict)?; + let all = content_streams(doc, &p.dict)?; let pieces = joined(&all); let ops = pieces.parse().ops; if !ops.get(img.op).is_some_and(|op| op.is("Do")) { @@ -530,7 +527,7 @@ pub(crate) fn selectable_images( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_of(doc, page)?; - let all = streams(doc, &p.dict)?; + let all = content_streams(doc, &p.dict)?; Ok(page_images(doc, page)? .into_iter() .enumerate() @@ -754,7 +751,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize } let images = page_images(doc, page)?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict)?; + let all = content_streams(doc, &p.dict)?; let mut plans = std::collections::BTreeMap::new(); for index in indexes { let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?; From 986a64bc77a2c4725b72e06d057475833ab4737b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:05:32 +0000 Subject: [PATCH 034/191] fix(cos): cap the objects one parsed object may build 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. --- crates/cos/src/parser.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 0458055961..c8b90106cf 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -10,6 +10,9 @@ use crate::object::{Dict, ObjRef, Object, PdfString, Stream}; use crate::{Bytes, CosError}; const MAX_DEPTH: usize = 128; +/// Objects one top-level [`Lexer::object`] call may build. Real arrays (page trees, parent +/// trees, widths) stay far below; a decompressed object stream of `0 0 0 ...` does not. +const MAX_NODES: usize = 1 << 22; /// One of the six PDF whitespace characters (ISO 32000-2 §7.2.3, Table 1). pub fn is_whitespace(b: u8) -> bool { @@ -28,11 +31,13 @@ fn is_regular(b: u8) -> bool { pub struct Lexer<'a> { pub data: &'a [u8], pub pos: usize, + /// Objects built by the current top-level [`Lexer::object`] call. + nodes: usize, } impl<'a> Lexer<'a> { pub fn new(data: &'a [u8], pos: usize) -> Self { - Self { data, pos: pos.min(data.len()) } + Self { data, pos: pos.min(data.len()), nodes: 0 } } fn peek(&self) -> Option { @@ -84,13 +89,22 @@ impl<'a> Lexer<'a> { /// Parse one direct object. References `N G R` are recognised. pub fn object(&mut self) -> Result { + self.nodes = 0; self.object_depth(0) } + fn over_budget(&self) -> bool { + self.nodes > MAX_NODES + } + fn object_depth(&mut self, depth: usize) -> Result { if depth > MAX_DEPTH { return Err(self.err("nesting too deep")); } + self.nodes = self.nodes.saturating_add(1); + if self.over_budget() { + return Err(self.err("object too large")); + } self.skip_ws(); let Some(b) = self.peek() else { return Err(self.err("unexpected end of data")) }; match b { @@ -125,6 +139,7 @@ impl<'a> Lexer<'a> { let before = self.pos; match self.object_depth(depth + 1) { Ok(o) => items.push(o), + Err(e) if self.over_budget() => return Err(e), // Tolerate junk inside arrays by skipping one byte. Err(_) if self.pos == before => self.pos += 1, Err(e) => return Err(e), @@ -283,6 +298,9 @@ impl<'a> Lexer<'a> { // Tolerate garbage between entries (seen in damaged files): skip a token. let before = self.pos; let _ = self.object_depth(depth + 1); + if self.over_budget() { + return Err(self.err("object too large")); + } if self.pos == before { self.pos += 1; } @@ -598,4 +616,20 @@ mod tests { let deep = "[".repeat(10_000); assert!(Lexer::new(deep.as_bytes(), 0).object().is_err()); } + + #[test] + fn one_object_has_a_node_budget() { + // A wide array (from a tiny compressed object stream) must not grow without bound. + let wide = |n: usize| format!("[{}]", "0 ".repeat(n)); + let at_cap = Lexer::new(wide(MAX_NODES - 1).as_bytes(), 0).object(); + assert!(matches!(at_cap, Ok(Object::Array(ref items)) if items.len() == MAX_NODES - 1)); + assert!(Lexer::new(wide(MAX_NODES).as_bytes(), 0).object().is_err()); + // The same inside a dictionary value and behind tolerated junk. + assert!(Lexer::new(format!("<< /A {} >>", wide(MAX_NODES)).as_bytes(), 0).object().is_err()); + assert!(Lexer::new(format!("[[{}] ]", "0 ".repeat(MAX_NODES)).as_bytes(), 0).object().is_err()); + // The budget is per top-level object: a lexer reused for many operands keeps working. + let two = wide(MAX_NODES - 1).repeat(2); + let mut lx = Lexer::new(two.as_bytes(), 0); + assert!(lx.object().is_ok() && lx.object().is_ok()); + } } From e07942c8294301cf0ee5ad911d7dc3016bd33c50 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:01 +0000 Subject: [PATCH 035/191] fix(annot): cap the bumps of a cloudy outline 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. --- crates/annot/src/appearance.rs | 6 ++++++ crates/annot/src/tests.rs | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 4668881a6c..59cacbbdd9 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -120,12 +120,18 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String { smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect() } +/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow. +pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000; + /// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of /// half-circle bumps of about `r`, bulging outwards. pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String { let area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum(); // Outward normal of an edge: to the right of travel for counter-clockwise outlines. let sign = if area >= 0.0 { 1.0 } else { -1.0 }; + let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum(); + // Each edge rounds to at least one bump, so the total stays within the budget plus one per edge. + let r = r.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64)); let mut c = String::new(); for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() { let (dx, dy) = (b.0 - a.0, b.1 - a.1); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a659966812..201fe0e086 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1504,3 +1504,11 @@ fn typed_signature_uses_nonzero_winding_fill() { assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}"); assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); } + +#[test] +fn cloud_outline_has_a_bump_budget() { + // 50 long edges would each ask for 10,000 bumps of radius 1. + let pts: Vec<(f64, f64)> = (0..50).map(|i| (f64::from(i) * std::f64::consts::TAU / 50.0).sin_cos()).map(|(s, c)| (1e7 * c, 1e7 * s)).collect(); + let bumps = appearance::cloud_path(&pts, 1.0).lines().filter(|l| l.ends_with(" c")).count(); + assert!((pts.len()..=appearance::MAX_CLOUD_BUMPS + 2 * pts.len()).contains(&bumps), "{bumps} bumps"); +} From 1695106f98e044cab8892c3b048ad6155f316027 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:01 +0000 Subject: [PATCH 036/191] fix(annot): list a repeated link once and cap its web-address text 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. --- crates/annot/src/links.rs | 29 ++++++++++++++++++++++++----- crates/annot/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 4d3eef632b..b6a2c7575c 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -78,11 +78,14 @@ pub struct LinkItem { pub style: LinkStyle, } +/// Web-address text one listing keeps; past it a link lists as [`LinkAction::Other`]. +pub(crate) const MAX_URI_BYTES: usize = 16 << 20; + fn nums(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } -fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction { +fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction { let dest_page = |dest: &Object| -> Option { let a = doc.resolve(dest); let first = a.as_array()?.first()?.clone(); @@ -97,9 +100,17 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction { } let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) }; match a.name(b"S") { - Some(b"URI") => LinkAction::Uri( - a.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned())).unwrap_or_default(), - ), + Some(b"URI") => { + let uri = a.get(b"URI").map(|u| doc.resolve(u)); + let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]); + match uri_budget.checked_sub(bytes.len()) { + Some(left) => { + *uri_budget = left; + LinkAction::Uri(String::from_utf8_lossy(bytes).into_owned()) + } + None => LinkAction::Other("web address too long".into()), + } + } Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page), Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()), None => LinkAction::Other("no action".into()), @@ -133,8 +144,16 @@ fn style_of(doc: &Document, d: &Dict) -> LinkStyle { pub fn list(doc: &Document) -> Vec { let Ok(pages) = page_refs(doc) else { return Vec::new() }; let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut uri_budget = MAX_URI_BYTES; for (pi, p) in pages.iter().enumerate() { for (k, a) in annots(doc, *p).iter().enumerate() { + // One annotation object listed under several entries or pages is one link. + if let Some(r) = a.as_ref() + && !seen.insert(r) + { + continue; + } let Some(d) = doc.resolve(a).as_dict().cloned() else { continue }; if d.name(b"Subtype") != Some(b"Link") { continue; @@ -148,7 +167,7 @@ pub fn list(doc: &Document) -> Vec { index: k, obj: a.as_ref(), rect: [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])], - action: action_of(doc, &d, &pages), + action: action_of(doc, &d, &pages, &mut uri_budget), style: style_of(doc, &d), }); } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 201fe0e086..c52732ea85 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1505,6 +1505,42 @@ fn typed_signature_uses_nonzero_winding_fill() { assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); } +fn doc_of(objs: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") +} + +#[test] +fn link_listing_skips_repeated_refs_and_caps_web_address_text() { + // One link referenced 1000 times, and 300 small links sharing one 64 KiB address. + let annots = format!("{}{}", " 4 0 R".repeat(1000), "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >>".repeat(300)); + let doc = doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A 5 0 R >>".into(), + "<< /S /URI /URI 6 0 R >>".into(), + format!("({})", "u".repeat(64 << 10)), + ]); + let all = links::list(&doc); + assert_eq!(all.iter().filter(|l| l.obj == Some(ObjRef::new(4, 0))).count(), 1); + assert_eq!(all.len(), 301, "every distinct link is still listed"); + let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum(); + assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept"); + assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10)); +} + #[test] fn cloud_outline_has_a_bump_budget() { // 50 long edges would each ask for 10,000 bumps of radius 1. From 83d8eb085603a6511afca93b82ec1bb42a03eed9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:44 +0000 Subject: [PATCH 037/191] fix(create): decode an indexed lookup table within a small cap 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. --- crates/create/src/extract.rs | 7 ++++++- crates/create/src/tests.rs | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 768da4d1f1..b1aa5e5b11 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -359,6 +359,9 @@ impl Space { } } +/// A lookup table holds at most 256 entries of 4 bytes; decoding stops well past that. +const MAX_LOOKUP: usize = 64 << 10; + fn space(doc: &Document, cs: Option<&Object>) -> Result { let Some(cs) = cs else { return Err("the image has no colour space".into()) }; let cs = doc.resolve(cs); @@ -393,7 +396,9 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { }; let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() { Some(Object::String(s)) => s.bytes.clone(), - Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?, + Some(Object::Stream(t)) => { + t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())? + } _ => return Err("an indexed colour space without a lookup table".into()), }; Ok(Space::Indexed(Box::new(base), hival, table)) diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 6356105699..131cb10d2c 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -832,6 +832,15 @@ fn indexed_hival_resolves_indirect_values_and_stream_palettes() { } } +#[test] +fn indexed_lookup_stream_has_a_size_cap() { + // A lookup table needs 256 × 4 bytes at most; a 1 MiB one is refused, not decoded whole. + let mut palette = vec![255, 0, 0, 0, 0, 255]; + palette.resize(1 << 20, 0); + let (doc, image) = indexed_export_fixture(Object::Int(1), palette, true, false, 8, vec![0, 1, 0, 1]); + assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("lookup table")); +} + #[test] fn indexed_hival_boundary_values_keep_valid_palette_entries() { let palette: Vec = (0..=255u8).flat_map(|i| [i, 255 - i, 17]).collect(); From 05df6ee4b722943a7b28ccadcb9bc9950d82480d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:10:15 +0000 Subject: [PATCH 038/191] fix(fonts): decode each candidate font program once, within its size 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. --- crates/fonts/src/embedding.rs | 140 ++++++++++++++++++++++++++-------- 1 file changed, 108 insertions(+), 32 deletions(-) diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index a5bb011e00..23f34dbb36 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -1,9 +1,10 @@ //! Unicode embedding of an explicitly supplied, embeddable TrueType face. Font files are //! document inputs or the optional craft-fonts build input, never system-interface fallbacks. +use std::collections::HashMap; use std::fmt::Write as _; -use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use skrifa::{ FontRef, MetadataProvider, instance::{LocationRef, Size}, @@ -173,7 +174,8 @@ pub fn embed_mapping( return Err("the font family name is invalid".into()); } let glyph_map = if cff { cid_encoding.as_bytes() } else { gids.as_slice() }; - if let Some(resource) = same_resource(doc, fonts, &name, bytes, glyph_map, cmap.as_bytes()) { + let mut programs = ProgramMatch::new(bytes); + if let Some(resource) = same_resource(doc, fonts, &name, &mut programs, glyph_map, cmap.as_bytes()) { return Ok(resource); } let mut descriptor = Dict::new(); @@ -193,12 +195,7 @@ pub fn embed_mapping( ); // Reuse the program another resource of this page already has, else one registered on another // page; source subset programs stay untouched. - let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| { - doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() { - Object::Stream(s) if is_program(doc, s, bytes) => Some(Object::Ref(r)), - _ => None, - }) - }); + let existing = existing_program(doc, fonts, &mut programs); let program = existing.unwrap_or_else(|| { let mut d = Dict::new(); d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64)); @@ -246,12 +243,64 @@ pub fn embed_mapping( Ok(resource) } -/// The decoded data of the stream `object` refers to. -fn stream_data(doc: &Document, object: Option<&Object>) -> Option> { - match doc.resolve(object?).as_ref() { - Object::Stream(s) => s.decoded_within(17 << 20).ok(), - _ => None, +/// Whether the stream `object` refers to decodes to `expected`. +fn stream_is(doc: &Document, object: Option<&Object>, expected: &[u8]) -> bool { + object.is_some_and(|o| match doc.resolve(o).as_ref() { + Object::Stream(s) => s.decoded_within(expected.len()).is_ok_and(|d| d == expected), + _ => false, + }) +} + +/// Full programs one embedding decodes at most while looking for its own. A real match passes +/// the cheap checks and decodes once; many candidates that pass them come from a hostile file. +const MAX_PROGRAM_DECODES: usize = 16; + +/// Recognises the full program written for `bytes`: each candidate stream decodes at most once +/// and within `bytes.len()`, and at most [`MAX_PROGRAM_DECODES`] decode in all. +struct ProgramMatch<'a> { + bytes: &'a [u8], + known: HashMap, + decodes: usize, +} + +impl<'a> ProgramMatch<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, known: HashMap::new(), decodes: 0 } } + + /// Whether `r`, holding `object`, is the program. + fn is(&mut self, doc: &Document, r: ObjRef, object: &Object) -> bool { + if let Some(&known) = self.known.get(&r) { + return known; + } + let found = match object { + Object::Stream(s) + if s.dict.get(b"PCFullFont").is_some() + && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(self.bytes.len()).ok() + && self.decodes < MAX_PROGRAM_DECODES => + { + self.decodes = self.decodes.saturating_add(1); + s.decoded_within(self.bytes.len()).is_ok_and(|data| data == self.bytes) + } + _ => false, + }; + self.known.insert(r, found); + found + } + + /// Whether the resource's program is this one. + fn written(&mut self, doc: &Document, w: &Written) -> bool { + w.program.as_ref().is_some_and(|r| self.is(doc, r, &*doc.get(r))) + } +} + +/// The program for `programs.bytes` that this page's `PCFull` resources, or else the first +/// objects of the document, already have. +fn existing_program(doc: &Document, fonts: &Dict, programs: &mut ProgramMatch) -> Option { + written(doc, fonts) + .find(|w| programs.written(doc, w)) + .map(|w| w.program) + .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &*o).then_some(Object::Ref(r)))) } /// A `PCFull` resource that [`embed_mapping`] wrote. @@ -263,21 +312,6 @@ struct Written { program: Object, } -impl Written { - /// Whether its program is `bytes`. - fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool { - matches!(doc.resolve(&self.program).as_ref(), Object::Stream(s) if is_program(doc, s, bytes)) - } -} - -/// Whether `s` is a full program written for `bytes`, compressed or not. The length it was -/// written with is compared before decoding. -fn is_program(doc: &Document, s: &Stream, bytes: &[u8]) -> bool { - s.dict.get(b"PCFullFont").is_some() - && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() - && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) -} - /// The `PCFull` resources in `fonts`, in order. fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator + 'a { fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| { @@ -291,14 +325,14 @@ fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator Option { +fn same_resource(doc: &Document, fonts: &Dict, name: &str, programs: &mut ProgramMatch, glyph_map: &[u8], unicode: &[u8]) -> Option { written(doc, fonts) .find(|w| { let glyphs = w.cid.get(b"CIDToGIDMap").or_else(|| w.top.get(b"Encoding")); w.top.name(b"BaseFont") == Some(name.as_bytes()) - && stream_data(doc, w.top.get(b"ToUnicode")).is_some_and(|d| d == unicode) - && stream_data(doc, glyphs).is_some_and(|d| d == glyph_map) - && w.has_program(doc, bytes) + && stream_is(doc, w.top.get(b"ToUnicode"), unicode) + && stream_is(doc, glyphs, glyph_map) + && programs.written(doc, w) }) .map(|w| w.resource) } @@ -385,6 +419,48 @@ mod tests { assert_eq!(programs(&doc), 2); } + #[test] + fn the_program_search_decodes_each_candidate_once_and_few_in_all() { + let bytes = b"the program".as_slice(); + let decoy = |doc: &mut Document| { + let mut d = Dict::new(); + d.set(b"PCFullFont".to_vec(), Object::Bool(true)); + d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64)); + doc.add(Object::Stream(Stream::flate(d, &[0; 4096]))) + }; + let resource = |doc: &mut Document, program: ObjRef| { + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut cid = Dict::new(); + cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + let mut top = Dict::new(); + top.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid)])); + doc.add(Object::Dict(top)) + }; + // 200 resources sharing one decoy program decode it once. + let mut doc = Document::new_empty(); + let shared = decoy(&mut doc); + let mut fonts = Dict::new(); + for i in 0..200 { + let r = resource(&mut doc, shared); + fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r)); + } + let mut programs = ProgramMatch::new(bytes); + assert_eq!(existing_program(&doc, &fonts, &mut programs), None); + assert_eq!(programs.decodes, 1); + // 100 distinct decoys decode a bounded number of times. + let mut doc = Document::new_empty(); + let mut fonts = Dict::new(); + for i in 0..100 { + let program = decoy(&mut doc); + let r = resource(&mut doc, program); + fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r)); + } + let mut programs = ProgramMatch::new(bytes); + assert_eq!(existing_program(&doc, &fonts, &mut programs), None); + assert_eq!(programs.decodes, MAX_PROGRAM_DECODES); + } + #[test] fn the_full_program_is_written_compressed_and_another_page_reuses_it() { let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); From 8da1164f7d303487d3c90861bc2994e9ab89f5e0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:10:15 +0000 Subject: [PATCH 039/191] fix(fonts): decode Encoding and ToUnicode CMaps within 16 MiB 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. --- crates/fonts/src/pdf.rs | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0aaed6939e..fb524f65ee 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -275,6 +275,9 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20; /// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few /// thousand. const MAX_CMAP_RANGES: usize = 1 << 16; +/// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger +/// one is not decoded at all. +const MAX_CMAP_BYTES: usize = 16 << 20; /// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range /// costs one record however many codes it covers; where two overlap, the one declared later wins. @@ -679,7 +682,7 @@ impl Metrics { m.codes = Codes::Two; if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e)) && let Object::Stream(s) = &*e - && let Ok(data) = s.decoded() + && let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) { let (spaces, cids) = parse_cmap(&data); if !spaces.is_empty() { @@ -1220,7 +1223,7 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { } match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() { Some(Object::Stream(s)) => { - if let Ok(data) = s.decoded() { + if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) { parse_to_unicode(&data, &mut out); } } @@ -1496,6 +1499,38 @@ mod tests { assert_eq!(m.encode("abc"), None); } + #[test] + fn cmap_streams_decode_within_a_size_cap() { + // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole. + let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange"; + let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange"; + for padded in [false, true] { + let mut doc = Document::new_empty(); + let mut stream = |cmap: &[u8]| { + let mut data = cmap.to_vec(); + if padded { + data.resize(MAX_CMAP_BYTES + 1, b' '); + } + Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data)))) + }; + let (tu, enc) = (stream(to_unicode), stream(encoding)); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", enc), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", tu), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}"); + assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}"); + } + } + #[test] fn composite_fonts_use_to_unicode() { let mut doc = Document::new_empty(); From a16661ac102e921524459bc991abb4473115ab2f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:11:27 +0000 Subject: [PATCH 040/191] fix(measure): list a repeated measurement once and cap what a listing keeps 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. --- crates/measure/src/lib.rs | 29 +++++++++++++++++++++++++++-- crates/measure/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index 72b6c4b07b..4843d0fb68 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -448,10 +448,20 @@ pub struct Unsupported { pub struct Listing { pub measurements: Vec, pub unsupported: Vec, - /// The annotation limit stopped enumeration early. + /// The annotation or retained-size limit stopped enumeration early. pub truncated: bool, } const MAX_ANNOTATIONS: usize = 100_000; +/// Points and text one listing keeps, as counted by [`retained`]: about a thousand +/// measurements of [`MAX_POINTS`] points each. +const MAX_LISTED_BYTES: usize = 32 << 20; +/// The bytes a listed measurement keeps in points and text. +fn retained(m: &Measurement) -> usize { + let s = &m.scale; + [m.id.len(), m.author.len(), m.label.len(), s.unit.len(), s.area_unit.len(), s.ratio.len()] + .into_iter() + .fold(m.points.len().saturating_mul(std::mem::size_of::()), usize::saturating_add) +} fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize) -> Result { let scale = Scale::read(doc, d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?)?; let key = if kind == Kind::Distance { &b"L"[..] } else { b"Vertices" }; @@ -471,6 +481,8 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us pub fn list(doc: &Document) -> Listing { let mut out = Listing::default(); let mut count = 0usize; + let mut kept = 0usize; + let mut seen = std::collections::HashSet::new(); for (page, p) in pdfcraft_model::pages(doc).iter().enumerate() { let Some(o) = p.dict.get(b"Annots") else { continue }; let o = doc.resolve(o); @@ -481,6 +493,12 @@ pub fn list(doc: &Document) -> Listing { out.truncated = true; return out; } + // One annotation object under several entries or pages is one measurement. + if let Some(r) = a.as_ref() + && !seen.insert(r) + { + continue; + } let o = doc.resolve(a); let Some(d) = o.as_dict() else { continue }; let kind = match d.name(b"IT") { @@ -490,7 +508,14 @@ pub fn list(doc: &Document) -> Listing { _ => continue, }; match read_measurement(doc, d, kind, page, index) { - Ok(m) => out.measurements.push(m), + Ok(m) => { + kept = kept.saturating_add(retained(&m)); + if kept > MAX_LISTED_BYTES { + out.truncated = true; + return out; + } + out.measurements.push(m); + } Err(e) => out.unsupported.push(Unsupported { page, index, reason: e.to_string() }), } } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 303f9b4386..315fedc937 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -403,3 +403,23 @@ fn a_scale_ratio_converts_its_page_side_to_points() { assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}"); } } +#[test] +fn listing_skips_repeated_refs_and_caps_what_it_keeps() { + let mut d = fixture("", "", &[]); + let points: Vec = (0..MAX_POINTS).map(|i| [i as f64 * 0.1, 0.0]).collect(); + add(&mut d, &new(Kind::Perimeter, points), &Meta::default()).unwrap(); + let (r, mut annotation) = annot(&d, 0, 0).unwrap(); + let page = pdfcraft_model::pages(&d)[0].obj; + let set_annots = |d: &mut Document, list: Vec| d.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(list))).unwrap(); + // One annotation referenced 1000 times is one measurement. + set_annots(&mut d, vec![Object::Ref(r); 1000]); + assert_eq!(list(&d).measurements.len(), 1); + // Many small annotations sharing one 2048-point vertex array stop at the retained budget. + let vertices = d.add(annotation.get(b"Vertices").unwrap().clone()); + annotation.set(b"Vertices".to_vec(), Object::Ref(vertices)); + set_annots(&mut d, vec![Object::Dict(annotation); 1200]); + let listing = list(&d); + assert!(listing.truncated); + assert!(listing.measurements.iter().map(retained).sum::() <= MAX_LISTED_BYTES); + assert!(!listing.measurements.is_empty()); +} From a34697a8eb8d67088543effce1411a85cb0604ed Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:12:31 +0000 Subject: [PATCH 041/191] fix(measure): a failed content decode uses up the snap byte budget 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. --- crates/measure/src/snap.rs | 12 +++++++++--- crates/measure/src/tests.rs | 13 +++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 3cf6bbd17e..37e64e8b8a 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -214,14 +214,14 @@ impl Walker<'_> { self.geometry.truncated = true; break; } - // An undecodable part is skipped; the rest of the page still snaps. + // An undecodable part is skipped; the parts before it still snap. match s.decoded_within(remaining) { Ok(data) => { joined.extend(data); // A space, as the renderer joins pieces: a trailing comment runs on. joined.push(b' '); } - Err(_) => self.geometry.unreadable = self.geometry.unreadable.saturating_add(1), + Err(_) => self.decode_failed(remaining), } } } @@ -236,11 +236,17 @@ impl Walker<'_> { match stream.decoded_within(remaining) { Ok(data) => self.ops(&data, resources, initial, depth), Err(_) => { - self.geometry.unreadable = self.geometry.unreadable.saturating_add(1); + self.decode_failed(remaining); Ok(()) } } } + /// A tolerant decode fails on an unsupported filter or once it has produced `remaining` bytes, + /// so a failure uses up the byte budget: repeating a failing stream cannot repeat that work. + fn decode_failed(&mut self, remaining: usize) { + self.geometry.unreadable = self.geometry.unreadable.saturating_add(1); + self.bytes = self.bytes.saturating_add(remaining); + } fn ops(&mut self, data: &[u8], resources: &Dict, initial: Matrix, depth: usize) -> Result<()> { self.bytes = self.bytes.saturating_add(data.len()); self.streams = self.streams.saturating_add(1); diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 315fedc937..e9722cb622 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -423,3 +423,16 @@ fn listing_skips_repeated_refs_and_caps_what_it_keeps() { assert!(listing.measurements.iter().map(retained).sum::() <= MAX_LISTED_BYTES); assert!(!listing.measurements.is_empty()); } +#[test] +fn a_stream_over_the_byte_budget_uses_it_up_once() { + // 50 references to one Flate stream that inflates past the 16 MiB budget decode once. + let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]); + let hex: String = big.raw.iter().map(|b| format!("{b:02x}")).collect(); + let bomb = String::from_utf8(pdfcraft_testkit::stream_obj("/Filter [/ASCIIHexDecode /FlateDecode]", hex)).unwrap(); + let contents = format!("[4 0 R{}]", " 5 0 R".repeat(50)); + let d = fixture_with(&contents, "0 0 m 10 0 l S", "", &[bomb.as_str()]); + let g = snap::geometry(&d, 0).unwrap(); + assert_eq!(g.unreadable, 1); + assert!(g.truncated); + assert_eq!(g.segments.len(), 1, "the parts before it still snap"); +} From 853a9ae1e33f9c906df7084d20a12eaf5c92ebe4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:13:06 +0000 Subject: [PATCH 042/191] fix(preflight): decode an output-intent profile within 16 MiB 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. --- crates/preflight/src/lib.rs | 5 ++++- crates/preflight/src/tests.rs | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 2cda29fea1..ce17a4a8df 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -214,6 +214,9 @@ fn has_pdfa_intent(doc: &Document) -> bool { pdfa_intent_profile(doc).is_some() } +/// Largest output-intent profile decoded to read its header; real ICC profiles stay well under it. +const MAX_ICC_PROFILE: usize = 16 << 20; + /// The PDF/A output intent's destination profile: `Some(n)` with the profile's number of colour /// components (`/N`, else the ICC header's data colour space; `None` when neither says), or /// `None` when the document has no PDF/A output intent. @@ -229,7 +232,7 @@ fn pdfa_intent_profile(doc: &Document) -> Option> { let Object::Stream(s) = &*profile else { return Some(None) }; Some(s.dict.int(b"N").or_else(|| { // ICC.1 header: the data colour space is bytes 16–19. - match s.decoded().ok()?.get(16..20)? { + match s.decoded_within(MAX_ICC_PROFILE).ok()?.get(16..20)? { b"GRAY" => Some(1), b"RGB " => Some(3), b"CMYK" => Some(4), diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 80b1c63c49..efd55f28e3 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -417,3 +417,15 @@ fn oversized_xmp_is_refused_while_decoding() { assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress"); assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding"); } + +#[test] +fn an_output_intent_profile_header_is_read_within_a_size_cap() { + let intent = "/OutputIntents [<< /S /GTS_PDFA1 /DestOutputProfile 2 0 R >>]"; + for (size, n) in [(128, Some(4)), (MAX_ICC_PROFILE + 1, None)] { + let mut doc = build(&[&format!("<< /Type /Catalog {intent} >>"), "null"], ""); + let mut profile = vec![0; size]; + profile[16..20].copy_from_slice(b"CMYK"); + doc.set(pdfcraft_cos::ObjRef::new(2, 0), Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &profile))); + assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); + } +} From dfde90787f416f8d4953f861f308a3407f075839 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:14:55 +0000 Subject: [PATCH 043/191] fix(render): list a repeated annotation once and cap what the listing keeps 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. --- crates/render/src/inspect.rs | 75 ++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index e9afb4ec47..6c3bd2c0de 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -38,6 +38,24 @@ const MAX_XFA_CONFIG_BYTES: usize = 16 << 20; const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096; const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; +/// Text and geometry `DocInfo::annotations` keeps, as counted by [`retained`]. Past it the rest +/// are not listed: real comments stay far below, many small ones sharing one long string do not. +const MAX_ANNOTATION_BYTES: usize = 64 << 20; + +/// The bytes a listed annotation keeps. +fn retained(a: &Annotation) -> usize { + let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent]; + text.into_iter() + .map(|t| t.as_ref().map_or(0, String::len)) + .chain([ + a.subtype.len(), + std::mem::size_of_val(a.quads.as_slice()), + std::mem::size_of_val(a.points.as_slice()), + std::mem::size_of::(), + ]) + .fold(0, usize::saturating_add) +} + /// At most this many layers are read from all of `/RBGroups` together. const MAX_LAYER_GROUP_ENTRIES: usize = 4096; @@ -1142,10 +1160,26 @@ impl<'a> Inspector<'a> { // ── annotations ───────────────────────────────────────────────────────────────────────── fn annotations(&self, info: &mut DocInfo) { - for (&id, &page) in &self.page_index { + let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect(); + pages.sort_unstable_by_key(|&(_, page)| page); + // An annotation, or a whole `/Annots` array, that several entries or pages list is on the + // first of them (§12.5.2 allows one page per annotation). + let mut seen = HashSet::new(); + let mut kept = 0usize; + for (id, page) in pages { let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; + if let Ok(Object::Reference(r)) = page_dict.get(b"Annots") + && !seen.insert(*r) + { + continue; + } let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; for (index, a) in annots.iter().enumerate() { + if let Object::Reference(r) = a + && !seen.insert(*r) + { + continue; + } let Some(d) = self.dict(a) else { continue }; let Some(subtype) = self.name(d, b"Subtype") else { continue }; if subtype == "FileAttachment" @@ -1209,7 +1243,7 @@ impl<'a> Inspector<'a> { } _ => Vec::new(), }; - info.annotations.push(Annotation { + let annotation = Annotation { page, subtype, author: self.text(d, b"T"), @@ -1228,7 +1262,15 @@ impl<'a> Inspector<'a> { intent: self.name(d, b"IT"), fill_sign, points, - }); + }; + kept = kept.saturating_add(retained(&annotation)); + if kept > MAX_ANNOTATION_BYTES { + break; + } + info.annotations.push(annotation); + } + if kept > MAX_ANNOTATION_BYTES { + break; } } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); @@ -2583,6 +2625,33 @@ trailer << /Root 1 0 R >> } } + /// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many + /// small comments sharing one large `/Contents` string stop at the retained-bytes budget. + #[test] + fn repeated_annotations_are_listed_once_within_a_byte_budget() { + let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 7 0 R >> ".repeat(1200); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ + 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ + 5 0 obj [{} {small}] endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (note) >> endobj\n\ + 7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + "6 0 R ".repeat(1000), + "x".repeat(64 << 10), + ); + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + let notes = &info.annotations; + assert_eq!(notes.iter().filter(|a| a.contents.as_deref() == Some("note")).count(), 1); + assert!(notes.iter().all(|a| a.page == 0), "the shared array is the first page's"); + assert!(notes.len() > 1); + assert!(notes.iter().map(retained).sum::() <= MAX_ANNOTATION_BYTES); + } + /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. From 77ee1c8a26c2efadc82952888ffd69ccba36e6de Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:16:19 +0000 Subject: [PATCH 044/191] fix(sign): read DSS revocation evidence one stream at a time within caps 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. --- crates/sign/src/pdf.rs | 47 ++++++++++++++++++-------- crates/sign/src/pdf_discovery_tests.rs | 22 ++++++++++++ crates/sign/src/revocation.rs | 2 +- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 82a801dee8..04598e0333 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -975,6 +975,37 @@ fn finish_validation( } } +/// Evidence streams read from one `/DSS` array. +const MAX_DSS_ENTRIES: usize = 1024; +/// Decoded bytes read from one `/DSS` array; real CRLs and OCSP responses are kilobytes each. +const MAX_DSS_BYTES: usize = 64 << 20; + +/// The decoded streams of the `/DSS` evidence array `key` (`/CRLs`, `/OCSPs`), one at a time so +/// each is checked before the next decodes: each stream once and within the evidence size cap, +/// at most [`MAX_DSS_ENTRIES`] of them and [`MAX_DSS_BYTES`] together. +fn dss_evidence<'a>(doc: &'a Document, dss: &Dict, key: &[u8]) -> impl Iterator> + 'a { + let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let mut items = items.into_iter().take(MAX_DSS_ENTRIES); + let mut seen = HashSet::new(); + let mut left = MAX_DSS_BYTES; + std::iter::from_fn(move || { + for item in items.by_ref() { + if left == 0 { + return None; + } + if item.as_ref().is_some_and(|r| !seen.insert(r)) { + continue; + } + let o = doc.resolve(&item); + let Object::Stream(s) = o.as_ref() else { continue }; + let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE.min(left)) else { continue }; + left = left.saturating_sub(bytes.len()); + return Some(bytes); + } + None + }) +} + /// Check the revocation evidence in the catalog's `/DSS` against the signer's chain at `at`. /// Only evidence whose signature verifies against the issuer counts; returns whether the /// certificate is revoked (which invalidates the signature). @@ -985,25 +1016,13 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio let Some(dss) = doc.get(root).as_dict().and_then(|c| c.get(b"DSS").cloned()).map(|d| doc.resolve(&d)).and_then(|d| d.as_dict().cloned()) else { return false; }; - let pull = |key: &[u8]| -> Vec> { - let mut out = Vec::new(); - for item in dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { - let o = doc.resolve(&item); - if let pdfcraft_cos::Object::Stream(s) = &*o - && let Ok(bytes) = s.decoded() - { - out.push(bytes); - } - } - out - }; let issuer = info.chain.get(1).unwrap_or(signer); // A verified revocation wins; "good" from any covering evidence is reported, but never // contradicts a revocation. let mut revoked = None; let mut covered = false; let mut sources = Vec::new(); - for r in pull(b"CRLs") { + for r in dss_evidence(doc, &dss, b"CRLs") { let Ok(list) = CertificateList::parse(&r) else { continue }; sources.push("CRL"); match list.check(signer, issuer, at) { @@ -1016,7 +1035,7 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio } } if revoked.is_none() { - for r in pull(b"OCSPs") { + for r in dss_evidence(doc, &dss, b"OCSPs") { let Ok(resp) = OcspResponse::parse(&r) else { continue }; sources.push("OCSP"); match resp.check(signer, issuer, at) { diff --git a/crates/sign/src/pdf_discovery_tests.rs b/crates/sign/src/pdf_discovery_tests.rs index 66bf7c8a0e..9bc4448238 100644 --- a/crates/sign/src/pdf_discovery_tests.rs +++ b/crates/sign/src/pdf_discovery_tests.rs @@ -371,3 +371,25 @@ fn only_grew_compares_huge_arrays_without_quadratic_work() { assert!(!only_grew(&doc, &doc, &ints(20_000), &ints(20_001), 0)); assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); } + +#[test] +fn dss_evidence_decodes_each_stream_once_within_its_caps() { + // One small stream listed 1000 times, then streams that inflate past the evidence cap. + let mut doc = fixture(&[]); + let small = doc.add(Object::Stream(Stream::flate(Dict::new(), b"evidence"))); + let mut items = vec![Object::Ref(small); 1000]; + for _ in 0..4 { + items.push(Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![0; crate::revocation::MAX_EVIDENCE + 1]))))); + } + let mut dss = Dict::new(); + dss.set(b"CRLs".to_vec(), Object::Array(items)); + let read: Vec> = dss_evidence(&doc, &dss, b"CRLs").collect(); + assert_eq!(read, vec![b"evidence".to_vec()]); + // Distinct streams stop at the entry and byte budgets. + let items: Vec = + (0..MAX_DSS_ENTRIES + 10).map(|_| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[1; 1 << 16]))))).collect(); + dss.set(b"OCSPs".to_vec(), Object::Array(items)); + let total: usize = dss_evidence(&doc, &dss, b"OCSPs").map(|b| b.len()).sum(); + assert!(total <= MAX_DSS_BYTES, "{total}"); + assert!(dss_evidence(&doc, &dss, b"OCSPs").count() <= MAX_DSS_ENTRIES); +} diff --git a/crates/sign/src/revocation.rs b/crates/sign/src/revocation.rs index b4a9d820f9..057e0717de 100644 --- a/crates/sign/src/revocation.rs +++ b/crates/sign/src/revocation.rs @@ -8,7 +8,7 @@ use crate::x509::Certificate; use crate::{SignError, Time}; /// A DER size cap: real CRLs and OCSP responses are far smaller. -const MAX_EVIDENCE: usize = 2 * 1024 * 1024; +pub(crate) const MAX_EVIDENCE: usize = 2 * 1024 * 1024; const ID_PKIX_OCSP_BASIC: &str = "1.3.6.1.5.5.7.48.1.1"; const EKU_OCSP_SIGNING: &str = "1.3.6.1.5.5.7.3.9"; From 2e1c029fa82efeaae0b9c339a6b1ad3ea6d90f08 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:18:26 +0000 Subject: [PATCH 045/191] fix(xfa): every flowed subform instance spends the layout budget 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. --- crates/xfa/src/layout.rs | 3 +++ crates/xfa/src/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 326e7f7413..b4d3312559 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -769,6 +769,9 @@ impl Layouter<'_> { self.warn_truncated(node, &ctx.som, instances); let ctx_parent = ctx; for i in 0..instances { + // Every instance spends budget, so nested repeats that place nothing still stop. + self.measures.set(self.measures.get().saturating_add(1)); + self.check_measures()?; let ctx = ctx_parent.with_som(&sf.common, sib + i); let (rect, _, _) = self.cursor(); let (x, w) = avail.unwrap_or((rect.x, rect.w)); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 84c9fc00f8..c5de7ad887 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -404,6 +404,18 @@ fn repeated_pictures_are_bounded_in_total_bytes() { assert!(layout_xml(&ok).is_ok(), "one small picture must lay out"); } +#[test] +fn empty_repeating_subforms_spend_the_layout_budget() { + // Four nested levels of 50 empty instances are 6.25 million instances that place nothing + // and measure nothing; each one counts against the layout budget. + let level = ""; + let tpl = format!("", level.repeat(4), "".repeat(4)); + assert!(matches!(layout_xml(&tpl), Err(XfaError::TooLarge(_)))); + // Three levels (125,000 instances) still lay out. + let tpl = format!("", level.repeat(3), "".repeat(3)); + assert!(layout_xml(&tpl).is_ok()); +} + #[test] fn the_packets_can_be_one_stream_or_named_parts() { // Named parts, with the template split from the rest. From 306627ee778ca04498dc4a14f467bf6def134ddc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:19:08 +0000 Subject: [PATCH 046/191] fix(xfa): read a packet stream the XFA array lists again only once 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. --- crates/xfa/src/packets.rs | 6 ++++++ crates/xfa/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/crates/xfa/src/packets.rs b/crates/xfa/src/packets.rs index 875cdc894e..f79b071c0e 100644 --- a/crates/xfa/src/packets.rs +++ b/crates/xfa/src/packets.rs @@ -113,7 +113,13 @@ pub fn read_packets(doc: &Document) -> Result, XfaError> { let needs_rendering = bool_of(catalog.as_dict().and_then(|c| c.get(b"NeedsRendering"))).or_else(|| bool_of(acro.get(b"NeedsRendering"))); let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false); let mut total = 0usize; + // A stream listed again adds nothing but another decode, which may cost far more than the + // bytes it yields (a Flate stream of hex whitespace). + let mut seen = std::collections::HashSet::new(); let mut read = |o: &Object| -> Result, XfaError> { + if o.as_ref().is_some_and(|r| !seen.insert(r)) { + return Ok(None); + } let s = doc.resolve(o); let Object::Stream(s) = &*s else { return Ok(None) }; let bytes = s.decoded_within(MAX_XDP)?; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index c5de7ad887..03a7d2c5aa 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -416,6 +416,23 @@ fn empty_repeating_subforms_spend_the_layout_budget() { assert!(layout_xml(&tpl).is_ok()); } +#[test] +fn a_packet_stream_listed_many_times_is_read_once() { + let mut doc = Document::open(Arc::new(shell(""))).unwrap(); + let packet = pdfcraft_cos::ObjRef::new(5, 0); + let items: Vec = + (0..1000).flat_map(|_| [Object::String(pdfcraft_cos::PdfString::literal(b"template".to_vec())), Object::Ref(packet)]).collect(); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + let mut acro = c.get(b"AcroForm").and_then(Object::as_dict).cloned().unwrap(); + acro.set(b"XFA".to_vec(), Object::Array(items)); + c.set(b"AcroForm".to_vec(), Object::Dict(acro)); + }) + .unwrap(); + let p = read_packets(&doc).unwrap().unwrap(); + assert_eq!(p.xdp.matches(" Date: Thu, 22 Oct 2026 17:12:57 +0000 Subject: [PATCH 047/191] fix(fonts): let auto-deref pass the candidate objects to the program match Clippy's explicit_auto_deref rejects the explicit derefs. --- crates/fonts/src/embedding.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index 23f34dbb36..2b7b4e17e1 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -290,7 +290,7 @@ impl<'a> ProgramMatch<'a> { /// Whether the resource's program is this one. fn written(&mut self, doc: &Document, w: &Written) -> bool { - w.program.as_ref().is_some_and(|r| self.is(doc, r, &*doc.get(r))) + w.program.as_ref().is_some_and(|r| self.is(doc, r, &doc.get(r))) } } @@ -300,7 +300,7 @@ fn existing_program(doc: &Document, fonts: &Dict, programs: &mut ProgramMatch) - written(doc, fonts) .find(|w| programs.written(doc, w)) .map(|w| w.program) - .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &*o).then_some(Object::Ref(r)))) + .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &o).then_some(Object::Ref(r)))) } /// A `PCFull` resource that [`embed_mapping`] wrote. From a525cf1d424de0cdf1dc50e34f1b6cfedcdda24e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:51 +0000 Subject: [PATCH 048/191] fix(js): refuse code compiled from strings that skipped the script checks 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. --- crates/js/src/lib.rs | 42 +++++++++++++++++++++++++++++++++++++++++- crates/js/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ crates/js/src/xfa.rs | 5 ++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 785345c672..ae94feb0aa 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -27,9 +27,11 @@ use std::cell::RefCell; use std::collections::BTreeSet; use std::sync::Arc; +use boa_engine::context::HostHooks; use boa_engine::object::builtins::{JsArray, JsFunction}; use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer}; use boa_engine::property::Attribute; +use boa_engine::realm::Realm; use boa_engine::{Context, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; /// The kinds of field, as `field.type` names them. @@ -1128,8 +1130,46 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc JsResult<()> { + Err(JsNativeError::typ().with_message("scripts can't compile code from strings (eval, Function)").into()) + } +} + +/// Host hooks that hold code compiled from strings to the checks a script passes ([`refuse`]), +/// for runners that start the script itself through `eval`. +pub(crate) struct CheckedCodeFromStrings; + +impl HostHooks for CheckedCodeFromStrings { + fn ensure_can_compile_strings(&self, _: Realm, parameters: &[JsString], body: &JsString, _: bool, _: &mut Context) -> JsResult<()> { + let units = parameters.iter().chain(std::iter::once(body)).fold(0usize, |n, s| n.saturating_add(s.len())); + if units > MAX_SCRIPT_BYTES { + return Err(JsNativeError::range().with_message("the code is too long to compile").into()); + } + // As one text: brackets left open in one parameter carry into the next. + let mut source = parameters.iter().map(JsString::to_std_string_lossy).collect::>().join(","); + source.push('\n'); + source.push_str(&body.to_std_string_lossy()); + match refuse(&source) { + Some(why) => Err(JsNativeError::range().with_message(why).into()), + None => Ok(()), + } + } +} + fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { - let mut ctx = Context::default(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => { + let error = Some(format!("the script engine could not start: {e}")); + return Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error, ..Default::default() }; + } + }; ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); ctx.insert_data(RefCell::new(Host { diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 7a9efb5dfd..5b6adef53c 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -313,6 +313,23 @@ fn ordinary_nesting_still_runs() { assert_eq!(go(&s, &Event::field("Calculate", "a", "")).value, "1000"); } +/// Code built from strings at run time would reach the parser without the size and nesting +/// checks the script itself passed, so `eval` and the `Function` constructors refuse it. +#[test] +fn scripts_cannot_compile_code_from_strings() { + for s in [ + "event.value = eval('1 + 1');", + "event.value = (0, eval)('1 + 1');", + "event.value = new Function('return 2')();", + "event.value = Function('a', 'return a')(2);", + "event.value = (function* () {}).constructor('yield 2')().next().value;", + "event.value = new Array(1001).join('(') + '1' + new Array(1001).join(')'); event.value = eval(event.value);", + ] { + let o = go(s, &Event::field("Calculate", "a", "")); + assert!(o.error.as_deref().is_some_and(|e| e.contains("compile code from strings")), "{s}: {o:?}"); + } +} + mod xfa_model { use crate::Limits; use crate::xfa::*; @@ -398,6 +415,22 @@ mod xfa_model { assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]); } + /// The script runs through `eval`, so code built from strings at run time is held to the + /// script's own size and nesting checks instead of being refused outright. + #[test] + fn code_built_from_strings_is_checked_like_the_script() { + let total = "form1[0].page1[0].total[0]"; + assert_eq!(go("eval('qty.rawValue * 2')", total, "calculate").result.as_deref(), Some("8")); + assert_eq!(go("new Function('return 3')()", total, "calculate").result.as_deref(), Some("3")); + for s in [ + "eval(new Array(1001).join('(') + '1' + new Array(1001).join(')'))", + "new Function(new Array(1001).join('[') + new Array(1001).join(']'))", + ] { + let o = go(s, total, "calculate"); + assert!(o.error.as_deref().is_some_and(|e| e.contains("nested too deeply")), "{s}: {o:?}"); + } + } + /// A form whose one field `d` holds the ISO date 2026-10-20, shown as 20261020. pub(super) fn dated_form() -> XfaNode { let d = XfaNode { formatted: Some("20261020".into()), ..field("d", "form1[0].page1[0].d[0]", "2026-10-20", false) }; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 5a693db9f0..490408a106 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1473,7 +1473,10 @@ pub(crate) fn warm_up() { /// `started` is called once the engine is ready: a time limit on the script runs from then. pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { warm_up(); - let mut ctx = Context::default(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, + }; ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); let host_state = XHost::new(root, &event.target, doc); From 5dc64face0131a62712d8f414352787e4ef92617 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:23:04 +0000 Subject: [PATCH 049/191] fix(automation): keep page text from tools when copying is not allowed 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. --- crates/automation/src/a11y.rs | 8 +++-- crates/automation/src/lib.rs | 13 ++++++-- crates/automation/src/tools.rs | 4 +-- crates/automation/tests/it/automation.rs | 38 ++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 2124549731..21bde353e9 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -626,13 +626,17 @@ impl Automation { pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { let settings = self.ocr_settings(a)?; let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let id = self.doc(a)?.id; + let doc = self.doc(a)?; + // Recognizing needs only permission to change the document; the words it found are + // returned only when copying is allowed too. + let (id, copy) = (doc.id, doc.allows_copying()); let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; let list: Vec = found .iter() .map(|p| match &p.skipped { Some(why) => json!({ "page": p.page + 1, "skipped": why }), - None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), + None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), + None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }), }) .collect(); Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index c90240875b..522705bb15 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -435,7 +435,7 @@ impl Automation { "doc_revisions" => self.doc_revisions(&a)?, "text_lines" => { let page = self.page(&a)?; - let doc = self.doc(&a)?; + let doc = self.doc_to_copy(&a)?; let info = &doc.info.pages[page]; let r = |x: f32| (x as f64 * 100.0).round() / 100.0; let lines: Vec = doc @@ -709,7 +709,11 @@ impl Automation { let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy]; let wanted: String = text.split_whitespace().collect(); let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; - let after = self.doc(&a)?.text_blocks(page); + let doc = self.doc(&a)?; + let after = doc.text_blocks(page); + // Without `text` the paragraph keeps the document's own words: not echoed when + // the document doesn't allow copying them. + let copy = doc.allows_copying(); // Only a paragraph that now shows exactly the requested text is the edited one. A // neighbour that overlaps the old box (overlaid text, a text layer) must not take // its number, and neither must a paragraph the edit merged it into: rewriting that @@ -717,7 +721,10 @@ impl Automation { let edited = block_overlapping(&after, moved) .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); if let Some(b) = edited.and_then(|i| after.get(i)) { - out["paragraph"] = json!({ "text": b.text, "lines": b.lines.len(), "font": b.base_font }); + out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font }); + if copy { + out["paragraph"]["text"] = json!(b.text); + } } let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after); if !stale.is_empty() { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 9ce6b12d4f..4fff59c07d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1172,7 +1172,7 @@ pub fn tools() -> Vec { )), t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.") .with(schema(json!({ "enabled": { "type": "boolean" } }), &[])), - t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") + t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text (left out when the document's security doesn't allow copying), word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") .cmd("ocr.recognize") .with(schema( json!({ @@ -1332,7 +1332,7 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])), - t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") + t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used, and for a paragraph its text unless the document's security doesn't allow copying. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0ebf3a9bc0..778deaaec4 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2130,6 +2130,44 @@ fn protecting_through_tools() { ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); } +/// A document that allows changes but not copying: the tools that return a page's text refuse, +/// or leave the text out where the tool also changes the page (a restyled paragraph, recognized +/// words). +#[test] +fn copy_restricted_text_stays_in_the_document() { + let dir = workdir("copy-restricted-text"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let locked = json!({ "open_password": "open", "permissions_password": "boss", "changes": "any-except-extract" }); + let mut protect = locked.clone(); + protect["doc"] = json!(doc); + ok(&mut a, "doc_protect", protect); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" })); + let re = ok(&mut a, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + match a.call("text_lines", &json!({ "doc": re, "page": 1 })) { + Err(ToolError::Failed(m)) => assert!(m.contains("don't allow copying"), "{m}"), + other => panic!("text_lines is refused: {other:?}"), + } + let restyled = ok(&mut a, "text_edit", json!({ "doc": re, "page": 1, "paragraph": 1, "size": 30 })); + assert!(!restyled.to_string().contains("Page"), "{restyled}"); + if ok(&mut a, "ocr_status", json!({}))["available"] != true { + eprintln!("skipped the OCR part: OCR models not installed"); + return; + } + let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Recognised words stay private" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] })); + let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path(); + let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap(); + let mut protect = locked; + protect["doc"] = json!(scan); + ok(&mut a, "doc_protect", protect); + ok(&mut a, "doc_save", json!({ "doc": scan, "path": "scan.pdf" })); + let scan = ok(&mut a, "doc_open", json!({ "path": "scan.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "ocr_recognize", json!({ "doc": scan })); + assert!(r["words"].as_u64().unwrap() >= 3, "{r}"); + assert!(r["pages"][0].get("text").is_none() && !r.to_string().to_lowercase().contains("private"), "{r}"); +} + /// A form secured with "fill-sign" changes and no open password (how secured forms are usually /// distributed): its existing signature field can be signed without the permissions password, /// the update keeps the encryption, and the signature validates after reopening. A new field From 30300ae71be58cc5e1800cdbd19c9ec432f7a0ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:46 +0000 Subject: [PATCH 050/191] fix(engine): compare only documents that allow copying their text 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. --- crates/engine/src/compare.rs | 5 ++++- crates/engine/src/tests.rs | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 50508e20ea..c23db91f1f 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -46,10 +46,13 @@ impl crate::Document { } impl Session { - /// Compare files: the text differences from document `old` to document `new`. + /// Compare files: the text differences from document `old` to document `new`. The result + /// carries both documents' text, so both must allow copying it. pub fn compare(&self, old: DocId, new: DocId) -> Result { let a = self.get(old).ok_or(EditError::NoDocument)?; let b = self.get(new).ok_or(EditError::NoDocument)?; + a.check_copying()?; + b.check_copying()?; Ok(pdfcraft_compare::compare(&a.words(), &b.words())) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0df176226a..f93e765260 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1311,6 +1311,12 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() { let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); assert!(s.get(oid).unwrap().allows_copying()); assert!(extracting.iter().all(|c| enabled(&s, oid, c))); + // Comparing reads both documents' text out, into the result, the report and the comments. + let refused = || Err(EditError::NotPermitted("copying text and images")); + assert_eq!((s.compare(id, oid).map(|_| ()), s.compare(oid, id).map(|_| ())), (refused(), refused())); + assert_eq!(s.compare_report(oid, id).map(|_| ()), refused()); + assert!(s.compare(oid, oid).is_ok()); + assert_eq!(s.mark_differences(id, oid).map(|_| ()), refused()); // Unencrypted documents allow everything. let (s, plain) = session_with(1); assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c))); From 28ab616eb62a7ddc13b32895cd2215146f7ff812 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:55 +0000 Subject: [PATCH 051/191] fix(engine): print as low-resolution pictures when only that is allowed 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. --- crates/engine/src/lib.rs | 12 +++++++++++- crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e20cbc596e..fc1a8d2074 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -232,6 +232,10 @@ pub struct DocId(pub u64); /// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. const MAX_UNDO: usize = 100; +/// The highest resolution a document that allows only low-resolution printing (Table 22, bit 12 +/// clear) is printed at. +const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0; + /// The passwords that go with one state of a document (protection can change them). #[derive(Clone, Debug, Default, PartialEq)] struct Keys { @@ -4522,9 +4526,15 @@ impl Session { if !doc.allows_printing() { return Err(EditError::NotPermitted("printing")); } + // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages + // always go as pictures, at no more than the low resolution. + let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { + dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), + grayscale: settings.raster.is_some_and(|r| r.grayscale), + }); // Print as image: the pages used become pictures first, then the picture document is // imposed in their place (its page k is the k-th distinct source page used). - if let Some(raster) = &settings.raster { + if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { let mut distinct: Vec = Vec::new(); for &p in &settings.pages { if !distinct.contains(&p) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f93e765260..2d67305445 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1340,6 +1340,38 @@ fn print_restricted_documents_disable_print_unless_opened_by_the_owner() { assert!(enabled(&s, low, "print.dialog"), "low-resolution printing is printing"); } +/// Table 22, bit 12 clear: printing is limited to a low-level representation. Whatever the +/// settings ask for, the print-ready PDF carries each page as a picture of at most 150 dpi, with +/// no text or vector artwork left; the owner password lifts the limit. +#[test] +fn low_resolution_printing_prints_pictures_of_at_most_150_dpi() { + let bytes = protected("", "owner", -1 ^ (1 << 11)); + let mut s = Session::new(); + let low = s.open("low.pdf", None, bytes.clone(), None).unwrap(); + let width = f64::from(s.get(low).unwrap().info.pages[0].width); + let actual = print::Settings { pages: vec![0], layout: print::Layout::Size(print::SizeMode::Actual), ..Default::default() }; + for raster in [None, Some(print::Raster { dpi: 600.0, grayscale: false })] { + let printed = s.print_pdf(low, &print::Settings { raster, ..actual.clone() }).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("print.pdf", None, Arc::new(printed.clone()), None).unwrap(); + assert!(page_texts(&s2, id2).iter().all(|t| t.trim().is_empty()), "{raster:?}: no text is left"); + let doc = pdfcraft_cos::Document::open(Arc::new(printed)).unwrap(); + let widths: Vec = doc + .scan_objects() + .filter_map(|(_, o)| match &*o { + pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image") => st.dict.int(b"Width"), + _ => None, + }) + .collect(); + assert!(!widths.is_empty() && widths.iter().all(|&w| w as f64 <= width * 150.0 / 72.0 + 1.0), "{raster:?}: {widths:?} px for {width} pt"); + } + let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap(); + let printed = s.print_pdf(owner, &actual).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("print.pdf", None, Arc::new(printed), None).unwrap(); + assert!(page_texts(&s2, id2).iter().any(|t| !t.trim().is_empty()), "the owner prints the pages themselves"); +} + #[test] fn the_permissions_password_unlocks_an_open_document() { // Only a permissions password (#995, #1077): opens without asking. Changes are allowed, From 73f11b2a29075094381d0a9d4c1bbcdb90627c8e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:55 +0000 Subject: [PATCH 052/191] fix(engine): fail an action whose information-removal step fails 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. --- crates/engine/src/actions.rs | 6 +++++- crates/engine/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs index f679cf87cd..f7d11d47ed 100644 --- a/crates/engine/src/actions.rs +++ b/crates/engine/src/actions.rs @@ -191,9 +191,13 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> return Ok(None); } }; - // Steps that find nothing to do (no comments to flatten, …) aren't failures. + // Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step + // that didn't remove is: the file would be passed on as cleaned with the data still in it. + let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize); match s.apply(id, edit) { Ok(()) => log.push(step.label().to_string()), + Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())), + Err(e) if removes => return Err(e), Err(e) => log.push(format!("{}: {e}", step.label())), } Ok(None) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2d67305445..217d8d166b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4274,6 +4274,24 @@ fn actions_run_their_steps_on_files() { assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err()); } +/// A removal step that fails (here: a signed file can't be rewritten without its hidden data) +/// fails the action, so the file is not passed on as cleaned. +#[test] +fn actions_fail_when_hidden_information_could_not_be_removed() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(2)), None).unwrap(); + s.apply(id, Edit::SetInfo { key: "Author".into(), value: "Private Name".into() }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + let signed = s.sign(id, &digital_id, SignOptions { page: 1, ..SignOptions::default() }).unwrap(); + let built = actions::builtin(); + let sanitize = built.iter().find(|a| a.name == "Sanitize Documents").unwrap(); + let e = actions::run_on(sanitize, "signed.pdf", signed, |_, _| {}).map(|r| r.log).unwrap_err(); + assert!(e.starts_with("Sanitize document:"), "{e}"); +} + #[test] fn exporting_office_files_keeps_images() { let mut s = Session::new(); From c3bc881607b71be14cdccbba50fc5e81eb54247d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:39:40 +0000 Subject: [PATCH 053/191] fix(platform): a saved file keeps its group along with its mode 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. --- crates/platform/src/staging.rs | 69 +++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index 0b727449cb..f6a98c4fe4 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -31,16 +31,16 @@ pub fn create_staging( create_staging_as(dir, name, form, suffixes, None) } -/// [`create_staging`], giving the new file the permissions `keep` on Unix. It is created readable -/// and writable by its owner only and then given `keep`, so its mode is never more open than that -/// of the file it will replace, not even while it is still empty (a handle opened then would keep -/// reading what is written later). Only the mode is copied: the owner and group are the saver's. +/// [`create_staging`], giving the new file the mode and group of the file `keep` describes on +/// Unix. It is created readable and writable by its owner only and then given them, so its mode +/// is never more open than that of the file it will replace, not even while it is still empty (a +/// handle opened then would keep reading what is written later). The owner is the saver's. fn create_staging_as( dir: &Path, name: &str, form: StagingName, suffixes: impl IntoIterator, - keep: Option<&std::fs::Permissions>, + keep: Option<&std::fs::Metadata>, ) -> std::io::Result<(PathBuf, std::fs::File)> { // At most 128 bytes of the target's name, cut between characters, so the staging name fits // the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is. @@ -68,7 +68,7 @@ fn create_staging_as( if let Some(keep) = keep { // A volume that can't change modes (FAT, some network shares) leaves the file // owner-only: stricter than the file it replaces, never looser. - let _ = file.set_permissions(keep.clone()); + let _ = file.set_permissions(kept_permissions(&file, keep)); } return Ok((tmp, file)); } @@ -81,6 +81,26 @@ fn create_staging_as( Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken")) } +/// The permissions a staging file gets to replace the file `keep` describes. The mode's group bits +/// are meant for that file's group, so the staging file joins it first. Where it can't (the saver +/// isn't a member), group and others get nothing: someone the group bits shut out would +/// otherwise read the file as one of the others. +#[cfg(unix)] +fn kept_permissions(file: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let mut mode = keep.permissions(); + let joined = file.metadata().is_ok_and(|m| m.gid() == keep.gid()) || std::os::unix::fs::fchown(file, None, Some(keep.gid())).is_ok(); + if !joined { + mode.set_mode(mode.mode() & !0o077); + } + mode +} + +#[cfg(not(unix))] +fn kept_permissions(_: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions { + keep.permissions() +} + /// Unpredictable staging-name suffixes, so a name can't be planted in advance. Each is the /// standard library's keyed hash of a counter (`RandomState::hash_one`, currently SipHash-1-3), /// keyed from the operating system's random source. That is all a staging name needs, but it is @@ -98,7 +118,7 @@ pub fn staging_suffixes() -> impl Iterator { /// and the target is untouched until the new file is complete. The target's folder must exist; /// a folder at the target is refused by the rename. Flushing is the staging file's own /// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file -/// that is replaced keeps its permissions, so a private file stays private; a new file gets the +/// that is replaced keeps its permissions and group, so a private file stays private; a new file gets the /// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where /// the volume stores one. /// On macOS, accessible extended attributes of an existing regular destination are copied @@ -126,9 +146,9 @@ pub fn write_atomically_with( } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save"); - // A file that is replaced keeps its permissions (#1790). Unix modes only: Windows is as before. + // A file that is replaced keeps its permissions (#1790) and group. Unix only: Windows is as before. #[cfg(unix)] - let keep = std::fs::metadata(path).ok().map(|m| m.permissions()); + let keep = std::fs::metadata(path).ok(); #[cfg(not(unix))] let keep = None; // A saved file is the same document: it keeps its creation date (#2658). @@ -583,6 +603,37 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A replaced file keeps its group as well as its mode, so group permissions never reach a + /// group that couldn't read the file before (the saver's own group, by default). + #[cfg(target_os = "linux")] + #[test] + fn a_replaced_file_keeps_its_group() { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let dir = test_dir("group"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + let created = std::fs::metadata(&target).unwrap().gid(); + let status = std::fs::read_to_string("/proc/self/status").unwrap(); + let groups = status.lines().find_map(|l| l.strip_prefix("Groups:")).unwrap_or_default(); + let Some(other) = groups.split_whitespace().filter_map(|g| g.parse::().ok()).find(|&g| g != created) else { + eprintln!("skipped: the test needs a second group"); + return; + }; + std::os::unix::fs::chown(&target, None, Some(other)).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); + let mut staged = (0, 0); + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { + let m = f.metadata()?; + staged = (m.gid(), m.permissions().mode() & 0o777); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, (other, 0o640), "the staging file has the target's group before it is flushed"); + let m = std::fs::metadata(&target).unwrap(); + assert_eq!((m.gid(), m.permissions().mode() & 0o777), (other, 0o640)); + let _ = std::fs::remove_dir_all(&dir); + } + /// #2658: a save replaces the file, but the document is the same one: it keeps its creation /// date where the system lets a program set it. #[cfg(any(windows, target_os = "macos"))] From 77480498b25f34028198c32f15afb50516664a73 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:04:07 +0000 Subject: [PATCH 054/191] fix(engine): low-resolution printing prints only the chosen content 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. --- crates/engine/src/lib.rs | 3 +- crates/engine/src/print_raster.rs | 26 +++++++---- crates/engine/src/tests.rs | 77 +++++++++++++++++++++++++++++++ crates/print/src/lib.rs | 19 ++++++++ 4 files changed, 114 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fc1a8d2074..817eb79a96 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4541,8 +4541,7 @@ impl Session { distinct.push(p); } } - let hide_comments = matches!(settings.content, print::Content::Document | print::Content::FormFieldsOnly); - let bytes = print_raster::rasterize(doc, &distinct, raster, hide_comments).map_err(EditError::Print)?; + let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; let remapped = print::Settings { pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs index 117c8e14cc..4ed5895860 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -1,25 +1,33 @@ //! Print as image (Acrobat's Advanced Print Setup ▸ Print As Image): every page //! sent to the printer as a picture at a chosen resolution, for printers that skip text, //! shading or transparency. The pages are rendered the way the viewer shows them (reading -//! colours off, the print's own colours), made into an image-only document, and that document -//! is imposed on the paper like any other. The print is no longer searchable text. +//! colours off, the print's own colours) with only what the print's content choice keeps, made +//! into an image-only document, and that document is imposed on the paper like any other. The +//! print is no longer searchable text. +use pdfcraft_cos::{SaveOptions, write_incremental}; use pdfcraft_create::ImageResolution; -use pdfcraft_print::Raster; +use pdfcraft_print::{Content, Raster}; use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest}; -use crate::Document; use crate::export::encode_png; +use crate::{Document, guard}; /// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one -/// page per entry, the size of the source page as displayed. Pages too large for the renderer -/// at `raster.dpi` render at the largest resolution it allows. -pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, hide_comments: bool) -> Result, String> { +/// page per entry, the size of the source page as displayed, drawing what `content` prints. +/// Pages too large for the renderer at `raster.dpi` render at the largest resolution it allows. +pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Content) -> Result, String> { let dpi = raster.dpi.clamp(72.0, 600.0); let src = doc.export_source(); let mut config = src.config.clone(); - config.hide_comments = hide_comments; - let mut renderer = PageRenderer::new(src.bytes, config); + // The copy below holds only what prints, so the viewer's own comment hiding plays no part. + config.hide_comments = false; + let printed = guard(|| -> Result, String> { + let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?; + pdfcraft_print::keep_printed(&mut cos, content); + write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string()) + })??; + let mut renderer = PageRenderer::new(std::sync::Arc::new(printed), config); let mut images: Vec<(String, Vec)> = Vec::with_capacity(pages.len()); let mut used: Option = None; for &page in pages { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 217d8d166b..3d347e6492 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1372,6 +1372,83 @@ fn low_resolution_printing_prints_pictures_of_at_most_150_dpi() { assert!(page_texts(&s2, id2).iter().any(|t| !t.trim().is_empty()), "the owner prints the pages themselves"); } +/// A 100 × 400 pt page with one band per kind of thing that prints, top to bottom: page content +/// (blue), a filled text field (green), a stamp (red) and a square comment (yellow). +fn print_content_pdf() -> Vec { + let content = "0 0 1 rg 0 300 100 100 re f"; + let ap = |colour: &str| { + let ops = format!("{colour} rg 0 0 100 100 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >> stream\n{ops}\nendstream", ops.len()) + }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 400] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /V (Filled) /F 4 /P 3 0 R /Rect [0 200 100 300] /AP << /N 8 0 R >> >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Stamp /F 4 /P 3 0 R /Rect [0 100 100 200] /AP << /N 9 0 R >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Square /F 4 /P 3 0 R /Rect [0 0 100 100] /AP << /N 10 0 R >> >> endobj\n\ + 8 0 obj {} endobj\n\ + 9 0 obj {} endobj\n\ + 10 0 obj {} endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + ap("0 1 0"), + ap("1 0 0"), + ap("1 1 0"), + ) + .into_bytes() +} + +/// Low-resolution printing prints what the chosen content prints when the pages themselves are +/// printed: form fields only leaves out the page content and every comment, the document alone +/// leaves out the comments, and the document with stamps keeps only the stamps. +#[test] +fn low_resolution_printing_prints_only_the_chosen_content() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(print_content_pdf())).unwrap(); + cos.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ (1 << 11), + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new(); + let low = s.open("low.pdf", None, bytes.clone(), None).unwrap(); + let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap(); + // Each band's centre, in pixels from the top of the sheet at 72 dpi, and its colour. + let bands = [("content", 50, [0, 0, 255]), ("field", 150, [0, 255, 0]), ("stamp", 250, [255, 0, 0]), ("comment", 350, [255, 255, 0])]; + let all: &[&str] = &["content", "field", "stamp", "comment"]; + for (content, shown) in [ + (print::Content::DocumentAndMarkups, all), + (print::Content::Document, &["content", "field"][..]), + (print::Content::DocumentAndStamps, &["content", "field", "stamp"][..]), + (print::Content::FormFieldsOnly, &["field"][..]), + ] { + let settings = print::Settings { + pages: vec![0], + paper: (100.0, 400.0), + layout: print::Layout::Size(print::SizeMode::Actual), + content, + ..Default::default() + }; + for (id, how) in [(owner, "pages"), (low, "pictures")] { + let (w, h, rgba) = printed_pixels(s.print_pdf(id, &settings).unwrap()); + assert_eq!((w, h), (100, 400), "{content:?} as {how}"); + for (band, y, colour) in bands { + let px = rgba.get((y * w as usize + 50) * 4..).and_then(|p| p.get(..3)).unwrap(); + let near = |c: [u8; 3]| px.iter().zip(c).all(|(&a, b)| a.abs_diff(b) < 40); + let expected = shown.contains(&band); + assert!(if expected { near(colour) } else { near([255; 3]) }, "{content:?} as {how}: {band} drawn {expected} expected, pixel {px:?}"); + } + } + } +} + #[test] fn the_permissions_password_unlocks_an_open_document() { // Only a permissions password (#995, #1077): opens without asking. Changes are allowed, diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index e935cb938e..6839e5f6b0 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -696,6 +696,25 @@ fn decoded_contents(doc: &Document, page: &Dict) -> Vec { out } +/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that +/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For +/// printing pages as pictures, where a renderer draws each page as it stands. +pub fn keep_printed(doc: &mut Document, content: Content) { + for page in pdfcraft_model::pages(doc) { + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); + // Fails only when the page object isn't a dictionary, and such a page draws nothing. + let _ = doc.update_dict(page.obj, |d| { + if content == Content::FormFieldsOnly { + d.remove(b"Contents"); + } + if d.contains(b"Annots") { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + }); + } +} + /// Does annotation `d` print under `content`? fn prints(doc: &Document, d: &Dict, content: Content) -> bool { const HIDDEN: i64 = 2; From 2e9b247f3fd16ae51c717781c15cc4698af5ca1d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:16:15 +0000 Subject: [PATCH 055/191] fix(js): read printd literals in linear time 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. --- crates/js/src/lib.rs | 10 +++++++++- crates/js/src/tests.rs | 11 +++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index ae94feb0aa..156eb9fbe3 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -752,7 +752,11 @@ fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult let mut i = 0; while i < b.len() { let c = b[i]; - let run = b[i..].iter().take_while(|x| **x == c).count(); + // Only date letters read as runs; a literal is copied one character at a time, so + // measuring its run would rescan a long literal stretch at every position. + let run = if b"ymdHhMst".contains(&c) { b[i..].iter().take_while(|x| **x == c).count() } else { 1 }; + #[cfg(test)] + PRINTD_SCANNED.fetch_add(run as u64, std::sync::atomic::Ordering::Relaxed); let push_num = |out: &mut String, v: i64, n: usize| { if n >= 2 { out.push_str(&format!("{v:02}")); @@ -1011,6 +1015,10 @@ fn event_object(ctx: &mut Context, e: &Event) -> JsObject { // stack a script can use: brackets and prefix operators by the limits below, and every other // construct (a few KiB per level, several bytes of source each) by the length limit. +/// Bytes `util.printd` has scanned for runs of a format character, for tests that bound its work. +#[cfg(test)] +pub(crate) static PRINTD_SCANNED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + /// Longest script, in bytes. pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; /// Largest width or precision accepted by `util.printf`. diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 5b6adef53c..13b51ba47b 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -244,6 +244,17 @@ fn document_requests_and_console() { ); } +#[test] +fn printd_reads_a_long_literal_run_in_linear_work() { + crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed); + let o = go("event.value = util.printd(new Array(20001).join('x'), new Date(2024, 0, 5)).length;", &Event::field("Calculate", "total", "")); + assert_eq!(o.error, None); + assert_eq!(o.value, "20000"); + // Other tests format short dates at the same time; they add a few dozen bytes at most. + let scanned = crate::PRINTD_SCANNED.load(std::sync::atomic::Ordering::Relaxed); + assert!(scanned < 40_000, "printd scanned {scanned} bytes for a 20000-byte format"); +} + #[test] fn document_level_functions_are_available() { let o = run( From a5cfc3eec43d6c615bc34852d828deac4a079a82 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:16:27 +0000 Subject: [PATCH 056/191] fix(js): cap a form script's alerts, console lines and requests 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. --- crates/js/src/lib.rs | 104 +++++++++++++++++++++++++++++++++++------ crates/js/src/tests.rs | 29 ++++++++++++ crates/js/src/xfa.rs | 5 +- 3 files changed, 124 insertions(+), 14 deletions(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 156eb9fbe3..f2221f7509 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -203,6 +203,66 @@ struct Host { console: Vec, requests: Vec, doc: DocInfo, + /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. + kept: usize, + /// Alerts, console lines and requests the limits left out. + dropped: usize, +} + +impl Host { + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= xfa::MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped = self.dropped.saturating_add(1); + false + } + } + } + + fn alert(&mut self, m: String) { + if self.alerts.len() >= xfa::MAX_ALERTS { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.len()) { + self.alerts.push(m); + } + } + + fn print(&mut self, m: String) { + if self.console.len() >= xfa::MAX_CONSOLE { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.len()) { + self.console.push(m); + } + } + + /// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`] + /// requests and the run's byte budget. + fn request(&mut self, r: Request) { + if self.requests.len() >= xfa::MAX_EFFECTS { + self.dropped = self.dropped.saturating_add(1); + return; + } + let r = match r { + Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()), + Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), + Request::Submit(u) => Request::Submit(xfa::clip(u)), + Request::Focus(n) => Request::Focus(xfa::clip(n)), + other => other, + }; + let bytes = match &r { + Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + _ => 0, + }; + if self.room(bytes) { + self.requests.push(r); + } + } } type Shared = RefCell; @@ -223,6 +283,19 @@ fn text(v: &JsValue, ctx: &mut Context) -> JsResult { Ok(v.to_string(ctx)?.to_std_string_escaped()) } +/// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer +/// string. +fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult { + if v.is_undefined() || v.is_null() { + return Ok(String::new()); + } + let js = v.to_string(ctx)?; + // Two UTF-16 units per character at most: the clip still sees more than a line's worth. + let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2); + let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js }; + Ok(xfa::clip(js.to_std_string_escaped())) +} + fn arg(args: &[JsValue], i: usize) -> JsValue { args.get(i).cloned().unwrap_or_default() } @@ -440,7 +513,7 @@ fn field_object(ctx: &mut Context, name: &str) -> JsObject { Ok(JsValue::undefined()) } fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - host(c)?.borrow_mut().requests.push(Request::Focus(name.to_std_string_escaped())); + host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped())); Ok(JsValue::undefined()) } @@ -494,12 +567,12 @@ fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsRes fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let names = strings(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().requests.push(Request::Reset(names)); + host(ctx)?.borrow_mut().request(Request::Reset(names)); Ok(JsValue::undefined()) } fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().requests.push(Request::Print); + host(ctx)?.borrow_mut().request(Request::Print); Ok(JsValue::undefined()) } @@ -512,7 +585,7 @@ fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult text(&a, ctx)?, }; - host(ctx)?.borrow_mut().requests.push(Request::Submit(url)); + host(ctx)?.borrow_mut().request(Request::Submit(url)); Ok(JsValue::undefined()) } @@ -534,38 +607,38 @@ fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { match v.as_object() { Some(o) if !o.is_array() && !o.is_callable() => { let m = o.get(JsString::from(key), ctx)?; - text(&m, ctx) + text_line(&m, ctx) } - _ => text(v, ctx), + _ => text_line(v, ctx), } } fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = message(&arg(args, 0), "cMsg", ctx)?; - host(ctx)?.borrow_mut().alerts.push(m); + host(ctx)?.borrow_mut().alert(m); // The OK button. Ok(JsValue::from(1)) } fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().requests.push(Request::Beep); + host(ctx)?.borrow_mut().request(Request::Beep); Ok(JsValue::undefined()) } fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = message(&arg(args, 0), "cURL", ctx)?; - host(ctx)?.borrow_mut().requests.push(Request::LaunchUrl(u)); + host(ctx)?.borrow_mut().request(Request::LaunchUrl(u)); Ok(JsValue::undefined()) } @@ -575,8 +648,8 @@ fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { } fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = text(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().console.push(m); + let m = text_line(&arg(args, 0), ctx)?; + host(ctx)?.borrow_mut().print(m); Ok(JsValue::undefined()) } @@ -1187,6 +1260,8 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc JsResult<()> { @@ -1217,6 +1292,9 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc 0 { + out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped)); + } } out } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 13b51ba47b..07ae8ecd05 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -244,6 +244,35 @@ fn document_requests_and_console() { ); } +/// Bytes of text a run handed back in its messages, console lines and requests. +fn kept_bytes(o: &Outcome) -> usize { + let req = |r: &Request| match r { + Request::Reset(names) => names.iter().map(String::len).sum(), + Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(), + _ => 0, + }; + o.console.iter().chain(&o.alerts).map(String::len).sum::() + o.requests.iter().map(req).sum::() +} + +#[test] +fn console_alerts_and_requests_are_capped_in_count_and_bytes() { + let o = go( + "var s = 'x'; while (s.length < 8192) s += s; \ + for (var i = 0; i < 1500; i++) { console.println(s); app.alert(s); } \ + var a = []; for (var i = 0; i < 2000; i++) a.push(s); \ + for (var i = 0; i < 5; i++) this.resetForm(a); \ + for (var i = 0; i < 12000; i++) app.launchURL('u');", + &Event::field("Mouse Up", "agree", ""), + ); + assert_eq!(o.error, None); + assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len()); + assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len()); + assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len()); + assert!(o.console.iter().chain(&o.alerts).all(|m| m.chars().count() <= 4_097)); + assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o)); + assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last()); +} + #[test] fn printd_reads_a_long_literal_run_in_linear_work() { crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed); diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 490408a106..e875023d60 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -158,7 +158,10 @@ pub const MAX_ALERTS: usize = 100; /// Most console lines one run may print. pub const MAX_CONSOLE: usize = 1_000; /// Longest message or console line kept, in characters. -const MAX_LINE: usize = 4_096; +pub(crate) const MAX_LINE: usize = 4_096; +/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names +/// and the values it sets. Past it, more are left out and noted. +pub(crate) const MAX_PAYLOAD: usize = 32 << 20; /// Longest value a script may put in a field (characters); longer ones are cut and noted. const MAX_VALUE: usize = 65_536; From 72fd2a1b24bdbeff51421165bbcb1230bf3da48b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:18:45 +0000 Subject: [PATCH 057/191] fix(js): bound the text an XFA script's effects and values hold 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. --- crates/js/src/formcalc.rs | 8 ++-- crates/js/src/tests.rs | 50 +++++++++++++++++++++++ crates/js/src/xfa.rs | 84 ++++++++++++++++++++++++++++++++++----- 3 files changed, 129 insertions(+), 13 deletions(-) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index a29397339e..7a3de5a70f 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -15,7 +15,7 @@ use std::rc::Rc; use crate::Limits; use crate::xfa::{ HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances, - resolve_som, run_within, + reset_names, resolve_som, run_within, }; /// Deepest nesting the parser follows. @@ -1466,8 +1466,7 @@ impl Interp<'_> { } "resetdata" => { let list = self.arg_str(args, 0)?; - let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); - self.h.push(XfaEffect::ResetData(names)); + self.h.push(XfaEffect::ResetData(reset_names(&list))); Ok(Val::Null) } "print" => { @@ -1646,6 +1645,8 @@ impl Interp<'_> { return Err(format!("{som} has no {other} to assign to")); } for k in kids { + // Every destination copies the value: charge each copy. + self.charge(self.cost(&v))?; self.assign_to(Val::Node(k), "rawValue", v.clone())?; } } @@ -1654,6 +1655,7 @@ impl Interp<'_> { } Val::List(items) => { for it in items { + self.charge(self.cost(&v))?; self.assign_to(it, prop, v.clone())?; } Ok(()) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 07ae8ecd05..8ce338b2e0 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -665,6 +665,22 @@ mod xfa_model { assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097)); } + #[test] + fn urls_and_reset_lists_are_clipped_and_deduplicated() { + let o = go( + "var s = 'x'; while (s.length < 65536) s += s; for (var i = 0; i < 600; i++) xfa.host.gotoURL(s + i); \ + var r = 'a,'; while (r.length < 262144) r += r; xfa.host.resetData(r);", + "form1[0].page1[0]", + "initialize", + ); + assert_eq!(o.error, None); + let urls: Vec<&String> = o.effects.iter().filter_map(|e| if let XfaEffect::LaunchUrl(u) = e { Some(u) } else { None }).collect(); + assert_eq!(urls.len(), 600); + assert!(urls.iter().all(|u| u.chars().count() <= 4_097)); + let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum(); + assert_eq!(names, 1); + } + #[test] fn a_slow_engine_start_does_not_count_against_the_script() { let ms = std::time::Duration::from_millis; @@ -716,6 +732,40 @@ mod formcalc { o.result.unwrap_or_default() } + /// Assigning one long value to many fields at once stops at the run's byte budget and is + /// charged per copy, and a reset list names each field once. + #[test] + fn bulk_assignments_and_reset_lists_are_bounded() { + let f = |i: usize| super::xfa_model::field("f", &format!("form1[0].page1[0].f[{i}]"), "", false); + let root = XfaNode { + name: "form1".into(), + som: "form1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![XfaNode { + name: "page1".into(), + som: "form1[0].page1[0]".into(), + kind: Some(XfaKind::Subform), + children: (0..1300).map(f).collect(), + ..Default::default() + }], + ..Default::default() + }; + let ev = XfaEvent { activity: "initialize".into(), target: "form1[0]".into(), ..Default::default() }; + let run = |script: &str| run_formcalc(script, &ev, &XfaDoc::default(), &root, Limits::default()); + let o = run("var s = \"x\"\nwhile (Len(s) < 65536) do s = Concat(s, s) endwhile\npage1.f = s"); + assert_eq!(o.error, None, "{o:?}"); + let kept: usize = o.effects.iter().map(|e| if let XfaEffect::SetValue { value, .. } = e { value.len() } else { 0 }).sum(); + assert!(kept <= MAX_PAYLOAD, "{kept} bytes of values kept"); + assert!(o.notes.iter().any(|n| n.contains("left out")), "{:?}", o.notes); + // 1300 copies of a 1 MiB value are more work than a script may do. + let o = run("var s = \"x\"\nwhile (Len(s) < 1048576) do s = Concat(s, s) endwhile\npage1.f = s"); + assert_eq!(o.error.as_deref(), Some("the script ran too long"), "{:?}", o.notes); + let o = run("var r = \"a,\"\nwhile (Len(r) < 262144) do r = Concat(r, r) endwhile\n$host.resetData(r)\n$host.resetData(r)"); + assert_eq!(o.error, None, "{o:?}"); + let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum(); + assert_eq!(names, 2); + } + /// FormCalc reads `formattedValue` as the field's picture shows it too, until the script /// sets the value. #[test] diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index e875023d60..ae38658e09 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -8,7 +8,7 @@ //! children, then its siblings, then each ancestor's children. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use boa_engine::object::ObjectInitializer; use boa_engine::object::builtins::{JsArray, JsProxy}; @@ -182,6 +182,36 @@ pub(crate) fn clip_value(v: String) -> (String, bool) { } } +/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and +/// named once, at most as many as a snapshot holds nodes. +pub(crate) fn reset_names(list: &str) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if out.len() >= MAX_NODES { + break; + } + let name = clip(name.to_string()); + if seen.insert(name.clone()) { + out.push(name); + } + } + out +} + +/// Bytes of text `e` holds. +fn payload(e: &XfaEffect) -> usize { + match e { + XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => { + som.len().saturating_add(s.len()) + } + XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), + XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout => 0, + } +} + /// A script's completion value (what a calculate script puts in its field) cut to /// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. pub(crate) fn clip_result(out: &mut XfaOutcome) { @@ -235,6 +265,9 @@ pub(crate) struct XHost { clipped_values: usize, dropped_alerts: usize, dropped_console: usize, + /// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`]. + kept: usize, + dropped_bytes: usize, } impl XHost { @@ -259,15 +292,34 @@ impl XHost { clipped_values: 0, dropped_alerts: 0, dropped_console: 0, + kept: 0, + dropped_bytes: 0, + } + } + + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped_bytes = self.dropped_bytes.saturating_add(1); + false + } } } /// Record an effect: values, presence and access set again on the same object replace the - /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes) effects are counted - /// and dropped. + /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] + /// bytes of text) effects are counted and dropped. pub(crate) fn push(&mut self, e: XfaEffect) { let e = match e { XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), + XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), + XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), + XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), XfaEffect::SetValue { som, value } => { let (value, cut) = clip_value(value); if cut { @@ -277,6 +329,9 @@ impl XHost { } other => other, }; + if !self.room(payload(&e)) { + return; + } let key = match &e { XfaEffect::SetValue { som, .. } => Some((0u8, som.clone())), XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), @@ -319,7 +374,7 @@ impl XHost { pub(crate) fn print(&mut self, line: String) { if self.console.len() >= MAX_CONSOLE { self.dropped_console += 1; - } else { + } else if self.room(line.len()) { self.console.push(line); } } @@ -336,6 +391,9 @@ impl XHost { if self.dropped_console > 0 { out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); } + if self.dropped_bytes > 0 { + out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes)); + } if self.clipped_values > 0 { out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); } @@ -346,19 +404,26 @@ impl XHost { /// holds a value; writing one into a subform would put text where another viewer expects /// a data group). Returns whether it was. pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { - let Some(n) = self.nodes.get_mut(i) else { return false }; + let Some(n) = self.nodes.get(i) else { return false }; if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { return false; } + let som = n.som.clone(); // Cut here, not only in the effect: later reads in the script see what is saved, and // the snapshot stays bounded however often a long value is written. let (text, cut) = clip_value(text); - n.value = text.clone(); - n.formatted = None; - let som = n.som.clone(); if cut { self.clipped_values += 1; } + // The snapshot's copy counts as well as the effect's: past the budget the write is left + // out of both. + if !self.room(text.len()) { + return true; + } + if let Some(n) = self.nodes.get_mut(i) { + n.value = text.clone(); + n.formatted = None; + } self.push(XfaEffect::SetValue { som, value: text }); true } @@ -1027,8 +1092,7 @@ fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { let list = text(&arg(args, 0), ctx)?; - let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); - effect(ctx, XfaEffect::ResetData(names))?; + effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; Ok(JsValue::undefined()) } From 17f5d80f44cdd0fe58f035adc45ddc8f8a02b95b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:20:22 +0000 Subject: [PATCH 058/191] fix(xfa): break a long word in linear time 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. --- crates/xfa/src/tests.rs | 13 +++++++++++++ crates/xfa/src/text.rs | 18 ++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 84c9fc00f8..17e286d6c5 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2657,3 +2657,16 @@ fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() { assert!((left_edge("center", "TITLE ") - (36.0 + (w - ink) / 2.0)).abs() < 1e-6); assert!((left_edge("right", "TITLE ") - (36.0 + w - ink)).abs() < 1e-6); } + +/// A word longer than its line is broken by character in work linear in its length. +#[test] +fn a_long_word_breaks_in_linear_work() { + let face = text::Face::of(&Font { typeface: "Arial".into(), size: 1.0, ..Font::default() }); + let word = "i".repeat(20_000); + text::MEASURED.with(|m| m.set(0)); + let b = text::layout(&text::plain(&word), Rect::new(0.0, 0.0, 1000.0, 0.0), &Para::default(), &face, &|_| String::new()); + let measured = text::MEASURED.with(|m| m.get()); + assert!(b.spans.len() > 2, "{} lines", b.spans.len()); + assert_eq!(b.spans.iter().map(|s| s.text.len()).sum::(), 20_000); + assert!(measured < 200_000, "{measured} bytes measured for a 20000-byte word"); +} diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index 1598a40345..fc09d355e7 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -123,6 +123,8 @@ impl Face { /// Advance of `s` at this face's size. pub fn width(&self, s: &str) -> f64 { + #[cfg(test)] + MEASURED.with(|m| m.set(m.get().saturating_add(s.len() as u64))); let designed = match self.family { Family::Courier => s.chars().count() as f64 * 0.6 * self.size, Family::Helvetica => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 1.04 } else { 1.0 }, @@ -132,6 +134,12 @@ impl Face { } } +// Bytes of text measured on this thread, for tests that bound layout work. +#[cfg(test)] +thread_local! { + pub(crate) static MEASURED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Default line height: XFA's font leading for the standard faces. pub fn line_height(size: f64) -> f64 { size * 1.15 @@ -233,11 +241,16 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn lines.push((done, p.h_align)); } if ink > avail - indent(line.first) && line.words.is_empty() { - // A word longer than a line: break it by character. + // A word longer than a line: break it by character. The piece's advance grows by + // each character's (widths add up), so no prefix is measured again. let mut piece = String::new(); + let mut advance = 0.0; + let mut buf = [0u8; 4]; for ch in w.text.chars() { + let ch_width = w.face.width(ch.encode_utf8(&mut buf)); piece.push(ch); - if w.face.width(&piece) > avail - indent(line.first) && piece.chars().count() > 1 { + advance += ch_width; + if advance > avail - indent(line.first) && piece.len() > ch.len_utf8() { piece.pop(); let width = w.face.width(&piece); line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width }); @@ -246,6 +259,7 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false }); lines.push((done, p.h_align)); piece.push(ch); + advance = ch_width; } } if !piece.is_empty() { From 3412437191ab4cbd18bd2b519d94fada7f42dfa1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:21:00 +0000 Subject: [PATCH 059/191] fix(xfa): bound the embedded field text one text block expands 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. --- crates/xfa/src/tests.rs | 14 ++++++++++++++ crates/xfa/src/text.rs | 25 +++++++++++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 17e286d6c5..31a3684814 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2670,3 +2670,17 @@ fn a_long_word_breaks_in_linear_work() { assert_eq!(b.spans.iter().map(|s| s.text.len()).sum::(), 20_000); assert!(measured < 200_000, "{measured} bytes measured for a 20000-byte word"); } + +/// Rich text that embeds one long field value many times lays out a bounded amount of it. +#[test] +fn embedded_field_values_expand_to_a_bounded_amount_of_text() { + let face = text::Face::of(&Font { typeface: "Arial".into(), size: 10.0, ..Font::default() }); + let value = "word ".repeat(3_300); + let run = Run { embed: Some("f".into()), ..Run::default() }; + let rich = RichText { paragraphs: vec![Paragraph { runs: vec![run; 100], h_align: None }] }; + let b = text::layout(&rich, Rect::new(0.0, 0.0, 500.0, 0.0), &Para::default(), &face, &|_| value.clone()); + let kept: usize = b.spans.iter().map(|s| s.text.len()).sum(); + assert!(kept > 0); + assert!(kept <= text::MAX_EMBEDDED, "{kept} bytes of embedded text laid out"); + assert!(text::natural_width(&rich, &Para::default(), &face, &|_| value.clone()) <= face.width(&value) * 70.0); +} diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index fc09d355e7..63de872444 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -177,13 +177,27 @@ fn ink_width(w: &Word) -> f64 { if ink.len() == w.text.len() { w.width } else { w.face.width(ink) } } +/// Most bytes of embedded field values one text block shows, as much as a text of its own +/// holds: a value embedded over and over is expanded only so far. +pub(crate) const MAX_EMBEDDED: usize = 1 << 20; + /// Break the runs of a paragraph into words (spaces stay attached to the word before them). -fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str) -> String) -> Vec { +/// Embedded values take from `embed_left` bytes and are cut when it runs out. +fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str) -> String, embed_left: &mut usize) -> Vec { let mut out = Vec::new(); for run in runs { let face = base.with_run(run); let text: String = match &run.embed { - Some(id) => embed(id), + Some(_) if *embed_left == 0 => String::new(), + Some(id) => { + let mut v = embed(id); + if v.len() > *embed_left { + let end = v.char_indices().map(|(i, _)| i).take_while(|i| *i <= *embed_left).last().unwrap_or(0); + v.truncate(end); + } + *embed_left = embed_left.saturating_sub(v.len()); + v + } None => run .text .chars() @@ -224,8 +238,9 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn let avail = (rect.w - para.margin_left - para.margin_right).max(1.0); let mut lines: Vec<(Line, Option)> = Vec::new(); let mut total = 0.0; + let mut embed_left = MAX_EMBEDDED; for (pi, p) in rich.paragraphs.iter().enumerate() { - let ws = words(&p.runs, base, embed); + let ws = words(&p.runs, base, embed, &mut embed_left); let mut line = Line { words: Vec::new(), width: 0.0, size: base.size, first: true }; let mut first_in_para = true; let indent = |first: bool| if first { para.text_indent.max(0.0) } else { 0.0 }; @@ -336,7 +351,9 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn /// margins and first-line indent): how wide a box without an explicit width grows. pub fn natural_width(rich: &RichText, para: &Para, base: &Face, embed: &dyn Fn(&str) -> String) -> f64 { let indent = para.text_indent.max(0.0); - let widest = rich.paragraphs.iter().map(|p| words(&p.runs, base, embed).iter().map(|w| w.width).sum::()).fold(0.0, f64::max); + let mut embed_left = MAX_EMBEDDED; + let widest = + rich.paragraphs.iter().map(|p| words(&p.runs, base, embed, &mut embed_left).iter().map(|w| w.width).sum::()).fold(0.0, f64::max); (para.margin_left + indent + widest + para.margin_right).max(0.0) } From 1dbfa251000956dc968680ebf7f53a47d7f932f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:22:52 +0000 Subject: [PATCH 060/191] fix(xfa): bound the script text the live form's events hold 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. --- crates/xfa/src/script.rs | 52 +++++++++++++++++++--------------------- crates/xfa/src/tests.rs | 27 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index d39d53e37a..cb555dc81d 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -15,6 +15,9 @@ pub const OVERRIDES_KEY: &[u8] = b"PCXfaOverrides"; const MAX_DEPTH: usize = 64; const MAX_EVENTS: usize = 20_000; +/// Most bytes of script the events hold together: each instance of a repeated subform holds its +/// own copy, so a long script repeated many times stops here (as much as the XFA packets hold). +pub(crate) const MAX_EVENT_SCRIPT_BYTES: usize = 64 << 20; /// Most objects of the live form built (the scripting engine's snapshot holds as many): /// nested repeating subforms multiply, so the walk stops here and says so. pub const MAX_FORM_NODES: usize = 200_000; @@ -67,8 +70,8 @@ pub struct LiveForm { pub root: FormNode, /// Every scripted event, in document order. pub events: Vec, - /// The form was larger than [`MAX_FORM_NODES`] objects (or [`MAX_EVENTS`] events): what - /// is past that was left out. + /// The form was larger than [`MAX_FORM_NODES`] objects (or [`MAX_EVENTS`] events, or + /// [`MAX_EVENT_SCRIPT_BYTES`] of their scripts): what is past that was left out. pub truncated: bool, } @@ -172,6 +175,8 @@ struct Walker<'a> { data: Option<&'a DataNode>, ov: &'a Overrides, events: Vec, + /// Bytes of script the events hold. + script_bytes: usize, /// Objects built so far. nodes: usize, truncated: bool, @@ -190,43 +195,34 @@ impl Walker<'_> { /// Record the scripts of the object at `som` as events: each non-empty event script, then its /// calculate and validate scripts (the validate one with `message`). Once [`MAX_EVENTS`] events - /// are recorded, nothing more is and the form is marked truncated. + /// or [`MAX_EVENT_SCRIPT_BYTES`] of their scripts are recorded, nothing more is and the form is + /// marked truncated. fn push_events(&mut self, som: &str, scripts: &[Script], calculate: Option<&Script>, validate: Option<&Script>, message: Option<&str>) { if self.events.len() >= MAX_EVENTS { self.truncated = true; return; } - for s in scripts { + let all = scripts + .iter() + .map(|s| (s, s.activity.as_str(), None)) + .chain(calculate.map(|c| (c, "calculate", None))) + .chain(validate.map(|v| (v, "validate", message))); + for (s, activity, message) in all { if s.text.trim().is_empty() { continue; } + match self.script_bytes.checked_add(s.text.len()).filter(|n| *n <= MAX_EVENT_SCRIPT_BYTES) { + Some(n) => self.script_bytes = n, + None => { + self.truncated = true; + return; + } + } self.events.push(ScriptEvent { som: som.to_string(), - activity: s.activity.clone(), + activity: activity.to_string(), script: s.text.clone(), formcalc: s.formcalc, - message: None, - }); - } - if let Some(c) = calculate - && !c.text.trim().is_empty() - { - self.events.push(ScriptEvent { - som: som.to_string(), - activity: "calculate".into(), - script: c.text.clone(), - formcalc: c.formcalc, - message: None, - }); - } - if let Some(v) = validate - && !v.text.trim().is_empty() - { - self.events.push(ScriptEvent { - som: som.to_string(), - activity: "validate".into(), - script: v.text.clone(), - formcalc: v.formcalc, message: message.map(str::to_string), }); } @@ -410,7 +406,7 @@ impl Walker<'_> { /// from the template and `ov`) and every scripted event, in document order, within /// [`MAX_FORM_NODES`] objects. pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> LiveForm { - let mut w = Walker { tpl, data, ov, events: Vec::new(), nodes: 0, truncated: false }; + let mut w = Walker { tpl, data, ov, events: Vec::new(), script_bytes: 0, nodes: 0, truncated: false }; let root = Node::Subform(Box::new(tpl.root.clone())); let mut nodes = w.children(std::slice::from_ref(&root), "", 0); let mut root_node = nodes.pop().unwrap_or_default(); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 31a3684814..a101f219b5 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2684,3 +2684,30 @@ fn embedded_field_values_expand_to_a_bounded_amount_of_text() { assert!(kept <= text::MAX_EMBEDDED, "{kept} bytes of embedded text laid out"); assert!(text::natural_width(&rich, &Para::default(), &face, &|_| value.clone()) <= face.width(&value) * 70.0); } + +/// Repeated subforms each carry their own copy of a long script: the live form holds a bounded +/// amount of script text and says it left the rest out. +#[test] +fn repeated_script_events_hold_a_bounded_amount_of_script() { + let script = format!("var a = 1; //{}", "x".repeat(32 * 1024)); + let xml = format!( + r##" + +"## + ); + let (tpl, _) = parse(&xml).unwrap(); + let live = form_tree(&tpl, None, &Overrides::default()); + assert!(!live.events.is_empty()); + let held: usize = live.events.iter().map(|e| e.script.len()).sum(); + assert!(held <= crate::script::MAX_EVENT_SCRIPT_BYTES, "{held} bytes of script held by {} events", live.events.len()); + assert!(live.truncated, "{held} bytes, {} events", live.events.len()); +} From a07ad60d529dce2c68f355ab84038b1d819f46fa Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:25:19 +0000 Subject: [PATCH 061/191] fix(xfa): queue each added data instance once per pass 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. --- crates/xfa/src/data.rs | 32 ++++++++++++++++++++++++++++++-- crates/xfa/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 7c2efaf5ed..87db6b9986 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -1516,6 +1516,16 @@ fn element_markup(src: &str, n: roxmltree::Node) -> Option { Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?)) } +/// Most instance nodes one [`write_data_ops`] queues over all its passes (a removal starts a new +/// pass, which asks for the instances again). +const MAX_ENSURED: usize = 200_000; + +// Instance nodes write_data_ops has queued on this thread, for tests that bound its work. +#[cfg(test)] +thread_local! { + pub(crate) static ENSURED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script /// event changed becomes a single new datasets stream. When the packet is already the stream /// `reuse` (written earlier for the same event), that stream is replaced in place instead of @@ -1525,6 +1535,9 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) let mut steps: Vec> = Vec::new(); let mut removes: Vec> = Vec::new(); let mut run: Vec = Vec::new(); + // Instances queued in this run, by parent and name: asking for more only queues the rest. + let mut queued: HashMap<(&DataPath, &str), usize> = HashMap::new(); + let (mut ensured, mut capped) = (0usize, false); let flush = |run: &mut Vec, steps: &mut Vec>, removes: &mut Vec>| { if !run.is_empty() { let mut seen = std::collections::HashSet::new(); @@ -1541,7 +1554,17 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false }); } DataOp::Instances { parent, name, count } => { - for k in 0..(*count).min(MAX_INDEX) { + let done = queued.entry((parent, name.as_str())).or_insert(0); + while *done < (*count).min(MAX_INDEX) { + if ensured >= MAX_ENSURED { + capped = true; + break; + } + ensured += 1; + #[cfg(test)] + ENSURED.with(|n| n.set(n.get() + 1)); + let k = *done; + *done += 1; let mut path = parent.clone(); path.push((name.clone(), k)); run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true }); @@ -1549,6 +1572,7 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) } DataOp::Remove(path) if !path.is_empty() => { flush(&mut run, &mut steps, &mut removes); + queued.clear(); steps.push(Vec::new()); removes.push(Some(path.clone())); } @@ -1564,7 +1588,11 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) None => Op::Values(w), }) .collect(); - rewrite(doc, &plan, reuse) + let mut written = rewrite(doc, &plan, reuse)?; + if capped { + written.warnings.push(format!("XFA data instances not written past {MAX_ENSURED}: the rows were added and removed too often")); + } + Ok(written) } /// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index a101f219b5..9aebee5bf4 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2711,3 +2711,34 @@ fn repeated_script_events_hold_a_bounded_amount_of_script() { assert!(held <= crate::script::MAX_EVENT_SCRIPT_BYTES, "{held} bytes of script held by {} events", live.events.len()); assert!(live.truncated, "{held} bytes, {} events", live.events.len()); } + +/// Rows added one at a time (each op asking for one more instance) queue each instance node +/// once, and removals in between don't let the queue grow without bound. +#[test] +fn added_instances_are_queued_once_each() { + use crate::data::{DataOp, ENSURED, write_data_ops}; + let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap(); + render_into(&mut doc).unwrap(); + let table = som_to_path("form[0].page1[0].table[0]"); + let ops: Vec = (1..=1000).map(|count| DataOp::Instances { parent: table.clone(), name: "row".into(), count }).collect(); + ENSURED.with(|n| n.set(0)); + let r = write_data_ops(&mut doc, &ops, None).unwrap(); + assert!(r.written, "{r:?}"); + assert_eq!(crate::data_of(&doc).unwrap().count(&table, "row"), 1000); + let queued = ENSURED.with(|n| n.get()); + assert!(queued <= 2_000, "{queued} instance nodes queued for 1000 rows"); + // Removals split the ops into separate passes; together they stay within the budget. + let ops: Vec = (0..300) + .flat_map(|_| { + [ + DataOp::Instances { parent: table.clone(), name: "row".into(), count: 1000 }, + DataOp::Remove(som_to_path("form[0].page1[0].table[0].row[999]")), + ] + }) + .collect(); + ENSURED.with(|n| n.set(0)); + let r = write_data_ops(&mut doc, &ops, None).unwrap(); + let queued = ENSURED.with(|n| n.get()); + assert!(queued <= 200_000, "{queued} instance nodes queued"); + assert!(r.warnings.iter().any(|w| w.contains("instances")), "{r:?}"); +} From bac0e610db7a314680c6790929e0e6c23a57a5a5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:25:57 +0000 Subject: [PATCH 062/191] fix(xfa): bound the bytes a data import copies out of its file 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. --- crates/xfa/src/data.rs | 64 +++++++++++++++++++++++++++-------------- crates/xfa/src/tests.rs | 16 +++++++++++ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 87db6b9986..21c12a58b9 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -801,19 +801,52 @@ pub struct ImportNode { /// Deepest data an import keeps. const MAX_IMPORT_DEPTH: usize = MAX_DEPTH; -/// `n` as an [`ImportNode`], counting nodes against `budget`; `None` past the depth or node -/// limits. -fn import_node(n: roxmltree::Node, depth: usize, budget: &mut usize) -> Option { - if depth > MAX_IMPORT_DEPTH || *budget == 0 { - return None; +/// Most bytes of names, namespaces, attributes and text an import copies out of its file: a +/// namespace declared once is copied into every element and attribute that inherits it. +pub(crate) const MAX_IMPORT_BYTES: usize = 4 * MAX_IMPORT; + +/// What an import may still copy: nodes and bytes. +struct ImportBudget { + nodes: usize, + bytes: usize, +} + +impl ImportBudget { + fn new() -> ImportBudget { + ImportBudget { nodes: MAX_NODES as usize, bytes: MAX_IMPORT_BYTES } + } + + fn take_bytes(&mut self, n: usize) -> Result<(), XfaError> { + self.bytes = self + .bytes + .checked_sub(n) + .ok_or_else(|| XfaError::TooLarge(format!("the data file expands to more than {} MB", MAX_IMPORT_BYTES >> 20)))?; + Ok(()) + } +} + +/// `n` as an [`ImportNode`], counting nodes and copied bytes against `budget`; `TooLarge` past +/// the depth or either budget. +fn import_node(n: roxmltree::Node, depth: usize, budget: &mut ImportBudget) -> Result { + if depth > MAX_IMPORT_DEPTH { + return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); + } + budget.nodes = budget.nodes.checked_sub(1).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; + let name = n.tag_name().name(); + let ns = n.tag_name().namespace(); + budget.take_bytes(name.len().saturating_add(ns.map_or(0, str::len)))?; + let mut attrs = Vec::new(); + for a in n.attributes().filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/")) { + budget.take_bytes(a.namespace().map_or(0, str::len).saturating_add(a.name().len()).saturating_add(a.value().len()))?; + attrs.push((a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string())); } - *budget -= 1; let mut children = Vec::new(); let mut text = String::new(); for c in n.children() { if c.is_element() { children.push(import_node(c, depth + 1, budget)?); } else if let Some(t) = c.text() { + budget.take_bytes(t.len())?; text.push_str(t); } } @@ -821,17 +854,7 @@ fn import_node(n: roxmltree::Node, depth: usize, budget: &mut usize) -> Option) -> Result; this form's data root is <{want}>"))); } - let mut budget = MAX_NODES as usize; - let node = import_node(root, 0, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; + let node = import_node(root, 0, &mut ImportBudget::new())?; return Ok(vec![node]); } }; - let mut budget = MAX_NODES as usize; + let mut budget = ImportBudget::new(); let mut out = Vec::new(); for c in data.children().filter(|c| c.is_element()) { - out.push(import_node(c, 1, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?); + out.push(import_node(c, 1, &mut budget)?); } Ok(out) } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 9aebee5bf4..bf8872ad58 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2742,3 +2742,19 @@ fn added_instances_are_queued_once_each() { assert!(queued <= 200_000, "{queued} instance nodes queued"); assert!(r.warnings.iter().any(|w| w.contains("instances")), "{r:?}"); } + +/// A namespace declared once and inherited by many imported elements is not copied into each +/// past the import's byte budget. +#[test] +fn an_inherited_namespace_does_not_multiply_an_import() { + use crate::data::parse_data_file; + let uri = format!("urn:{}", "n".repeat(70_000)); + let xml = format!("{}", "".repeat(1_000)); + fn ns_bytes(nodes: &[crate::data::ImportNode]) -> usize { + nodes.iter().map(|n| n.ns.as_ref().map_or(0, String::len) + ns_bytes(&n.children)).sum() + } + match parse_data_file(&xml, None) { + Ok(nodes) => assert!(ns_bytes(&nodes) <= crate::data::MAX_IMPORT_BYTES, "{} bytes of namespaces copied", ns_bytes(&nodes)), + Err(e) => assert!(matches!(e, XfaError::TooLarge(_)), "{e:?}"), + } +} From ee333376277ee7f78d17663bd11f081bae4a5df5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:26:28 +0000 Subject: [PATCH 063/191] fix(xfa): bound the name bytes of data nodes a write creates 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. --- crates/xfa/src/data.rs | 37 ++++++++++++++++++++++++++----------- crates/xfa/src/tests.rs | 13 +++++++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 21c12a58b9..2b80d8b96a 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -519,6 +519,21 @@ fn escape(s: &str) -> (String, bool) { const MAX_NEW_NODES: usize = 20_000; /// Highest same-name sibling index a write follows. const MAX_INDEX: usize = 10_000; +/// Most bytes of names the nodes one write creates hold together: every sibling an index +/// skips over gets its own copy of the name. +const MAX_NEW_NAME_BYTES: usize = 4 << 20; + +/// What one write may still create: nodes and bytes of their names. +struct NewNodes { + nodes: usize, + name_bytes: usize, +} + +impl NewNodes { + fn new() -> NewNodes { + NewNodes { nodes: MAX_NEW_NODES, name_bytes: MAX_NEW_NAME_BYTES } + } +} /// A data tree being built for writing. #[derive(Default)] @@ -533,8 +548,8 @@ struct Build { impl Build { /// The node at `path` below this one, created as needed. `None` when the path is deeper /// than [`MAX_DEPTH`], an index is above [`MAX_INDEX`], or creating it would spend more - /// than `budget` new nodes. - fn at(&mut self, path: &[(String, usize)], budget: &mut usize) -> Option<&mut Build> { + /// new nodes or name bytes than `budget` holds. + fn at(&mut self, path: &[(String, usize)], budget: &mut NewNodes) -> Option<&mut Build> { if path.len() > MAX_DEPTH { return None; } @@ -547,15 +562,15 @@ impl Build { let have = node.by_name.get(&name).map_or(0, Vec::len); if *idx >= have { let need = idx - have + 1; - if need > *budget { + let bytes = need.saturating_mul(name.len()); + if need > budget.nodes || bytes > budget.name_bytes { return None; } - *budget -= need; - for _ in 0..need { - let pos = node.children.len(); - node.by_name.entry(name.clone()).or_default().push(pos); - node.children.push(Build { name: name.clone(), ..Default::default() }); - } + budget.nodes -= need; + budget.name_bytes -= bytes; + let start = node.children.len(); + node.children.extend((0..need).map(|_| Build { name: name.clone(), ..Default::default() })); + node.by_name.entry(name.clone()).or_default().extend(start..start + need); } let pos = *node.by_name.get(&name)?.get(*idx)?; node = node.children.get_mut(pos)?; @@ -675,7 +690,7 @@ const CONTROL: &str = "Characters XML can't hold were left out of the XFA data o /// Fresh data for `wants`: the children of an `xfa:data` element. fn build_children(wants: &[Want], warnings: &mut Vec) -> String { let mut root = Build::default(); - let mut budget = MAX_NEW_NODES; + let mut budget = NewNodes::new(); for w in wants { match root.at(&w.path, &mut budget) { Some(node) => node.text = w.text.clone(), @@ -1095,7 +1110,7 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve let tree = data.document(); // Nodes to add, per existing element they go into. let mut added: Vec<(roxmltree::NodeId, Build)> = Vec::new(); - let mut budget = MAX_NEW_NODES; + let mut budget = NewNodes::new(); for w in wants { let (escaped, dropped) = escape(&w.text); let mut node = data; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index bf8872ad58..a4ebacafdb 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2758,3 +2758,16 @@ fn an_inherited_namespace_does_not_multiply_an_import() { Err(e) => assert!(matches!(e, XfaError::TooLarge(_)), "{e:?}"), } } + +/// A long data name with a high index is not copied into every sibling created before it. +#[test] +fn long_names_repeated_by_an_index_are_capped() { + let doc = Document::open(Arc::new(static_shell(""))).unwrap(); + let fields = vec![FieldDatum { + obj: pdfcraft_cos::ObjRef::new(1000, 0), + name: format!("form1[0].{}[9999]", "n".repeat(2_000)), + data: FieldData::Text("v".into()), + }]; + let data = build_data(&doc, &fields); + assert!(data.len() < 2_000_000, "{} bytes of data built", data.len()); +} From 4aad36cf199aa7d5dd568a695dd810e6dc665a43 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:05:41 +0000 Subject: [PATCH 064/191] fix(create): refuse an indexed colour space whose base is indexed before following it 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. --- crates/create/src/extract.rs | 11 +++++++++-- crates/create/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 768da4d1f1..bd5ab5271c 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -383,10 +383,17 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { Some(b"CalGray") => Ok(Space::Gray), Some(b"CalRGB") => Ok(Space::Rgb), Some(b"Indexed" | b"I") => { - let base = space(doc, a.get(1))?; - if matches!(base, Space::Indexed(..)) { + // Checked before recursing: Indexed is the only space that recurses, so refusing + // an Indexed base here also ends a cyclic base reference. + let nested = match a.get(1).map(|b| doc.resolve(b)).as_deref() { + Some(Object::Array(b)) => matches!(b.first().and_then(Object::as_name), Some(b"Indexed" | b"I")), + Some(Object::Name(n)) => matches!(n.as_slice(), b"Indexed" | b"I"), + _ => false, + }; + if nested { return Err("nested indexed colour spaces aren't valid".into()); } + let base = space(doc, a.get(1))?; let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() { Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8, _ => return Err("an indexed colour space with an invalid hival".into()), diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 6356105699..a214977675 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -1181,3 +1181,19 @@ fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() { let img = image_of(&doc, 1); assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20))); } + +#[test] +fn a_cyclic_indexed_colour_space_is_refused() { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + // 5 0 obj [/Indexed 5 0 R 255 <00>]: the base is the indexed space itself. + let cs = doc.add(Object::Null); + doc.set(cs, Object::Array(vec![Object::name("Indexed"), Object::Ref(cs), Object::Int(255), Object::String(PdfString::literal(vec![0]))])); + with_image(&mut doc, |_| { + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"ColorSpace".to_vec(), Object::Ref(cs)); + Stream::from_raw(dict, vec![0]) + }); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty()); + assert_eq!(out.skipped.len(), 1, "{:?}", out.skipped); +} From 78a562bc081b8b2afad687e2399071c003ae9a19 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:08:32 +0000 Subject: [PATCH 065/191] fix(optimize): bound a merged fragment image's height before sizing its buffer 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. --- crates/optimize/src/fragments.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index b5967eee09..caf617f717 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -143,12 +143,16 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u3 // Pixel rows per unit of height: the finest fragment's. let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max); let height = ((top - bottom) * density).round(); - if !(height.is_finite() && height >= 1.0) || (width as u64) * (height as u64) > MAX_PIXELS { + if !(height.is_finite() && height >= 1.0 && height <= f64::from(u32::MAX)) { return None; } + // In range and whole, so the cast is exact. let height = height as u32; - let row = width as usize * n; - let mut out = vec![255u8; row * height as usize]; + if u64::from(width) * u64::from(height) > MAX_PIXELS { + return None; + } + let row = (width as usize).checked_mul(n)?; + let mut out = vec![255u8; row.checked_mul(height as usize)?]; for f in run { let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height); let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height); @@ -297,3 +301,21 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, pro } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_merged_height_beyond_u32_is_refused_before_allocating() { + // Two fragments, one pixel row each, the top one 2^-48 high: the merged image would be + // 2^16 × 2^48 pixels, whose product wraps to 0 in u64. + let image = ObjRef { num: 1, generation: 0 }; + let edge = 1.0 - (-48f64).exp2(); + let run = [ + Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 }, + Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 }, + ]; + assert!(merge(&Document::new_empty(), &run, 3).is_none()); + } +} From f0e3880c35327859f82228caaa8219d86b8955c0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:12:52 +0000 Subject: [PATCH 066/191] fix(xfdf): never let a same-named imported comment replace a form widget 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. --- crates/xfdf/src/lib.rs | 11 ++++++----- crates/xfdf/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index ab258990e2..1e037b6795 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1017,10 +1017,11 @@ const REDACT_UNSEEN: i64 = 1 | 2 | 32; /// `/F` Locked (ISO 32000-2 Table 167): the user protected this annotation from change. const LOCKED: i64 = 128; -/// An existing annotation an import must never replace, move or unlock: one the user locked, or a -/// redaction mark (replacing it would drop the region the user marked to remove). +/// An existing annotation an import must never replace, move or unlock: one the user locked, a +/// redaction mark (replacing it would drop the region the user marked to remove), or a form +/// widget or field (replacing it would set the field's value and policy around the form setter). fn import_protected(x: &Dict) -> bool { - x.int(b"F").unwrap_or(0) & LOCKED != 0 || x.name(b"Subtype") == Some(b"Redact".as_slice()) + x.int(b"F").unwrap_or(0) & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT") } fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { @@ -1042,8 +1043,8 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result = Vec::new(); - // Never let a same-name import replace a locked comment or a redaction mark: keep it, and add - // the imported dict beside it under no name of its own. + // Never let a same-name import replace a protected annotation (`import_protected`): keep it, + // and add the imported dict beside it under no name of its own. let mut clash = false; for a in annots { let existing = a.as_ref().and_then(|r| doc.get(r).as_dict().cloned()); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 6c9824a245..57155e4b7a 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1660,3 +1660,22 @@ fn tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs() { import(&mut doc, b"Name,Other\r\n\"Ada\tLovelace\",other field\r\n").unwrap(); assert_eq!(fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["Ada\tLovelace"]); } + +#[test] +fn a_same_named_comment_import_never_replaces_a_form_widget() { + let mut doc = blank(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let widget = doc + .resolve(page.dict.get(b"Annots").unwrap()) + .as_array() + .unwrap() + .iter() + .filter_map(Object::as_ref) + .find(|r| doc.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget".as_slice()))) + .unwrap(); + doc.update_dict(widget, |d| d.set(b"NM".to_vec(), PdfString::text("w1"))).unwrap(); + let before = doc.get(widget); + let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Text /NM (w1) /Page 0 /Rect [0 0 1 1] /FT /Tx /T (Name) /V (forged) >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + assert_eq!(import(&mut doc, fdf).unwrap().comments, 1); + assert_eq!(doc.get(widget), before, "the widget was overwritten by an imported comment"); +} From d266222339adf378f785c98fdd3ae65eb8831867 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:22:02 +0000 Subject: [PATCH 067/191] fix(xfdf): draw every imported redaction mark's review outline, opaque 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. --- crates/xfdf/src/lib.rs | 21 +++++++++++++++++++++ crates/xfdf/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 1e037b6795..80c37b5e86 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1032,6 +1032,27 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { + let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); + d.set(b"QuadPoints".to_vec(), arr(&[l, t, r, t, l, b, r, b])); + } + _ => { + d.remove(b"QuadPoints"); + } + } + } } let nm = d.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text())); let annots: Vec = doc diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 57155e4b7a..7951bef4dd 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1679,3 +1679,26 @@ fn a_same_named_comment_import_never_replaces_a_form_widget() { assert_eq!(import(&mut doc, fdf).unwrap().comments, 1); assert_eq!(doc.get(widget), before, "the widget was overwritten by an imported comment"); } + +#[test] +fn an_imported_rect_only_or_transparent_redaction_mark_is_drawn_for_review() { + let xfdf = br#""#; + let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (r1) /Page 0 /Rect [60 600 560 620] /CA 0 /ca 0 >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + for data in [xfdf.as_slice(), fdf.as_slice()] { + let mut doc = blank(); + assert_eq!(import(&mut doc, data).unwrap().comments, 1); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mark = doc + .resolve(page.dict.get(b"Annots").unwrap()) + .as_array() + .unwrap() + .iter() + .filter_map(|a| a.as_ref().and_then(|r| doc.get(r).as_dict().cloned())) + .find(|d| d.name(b"Subtype") == Some(b"Redact".as_slice())) + .unwrap(); + assert_eq!(nums_of(&doc, mark.get(b"QuadPoints")), vec![60.0, 620.0, 560.0, 620.0, 60.0, 600.0, 560.0, 600.0]); + let ap = mark.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()); + assert!(ap.is_some_and(|ap| ap.get(b"N").is_some()), "the mark has no review appearance: {mark:?}"); + assert!(mark.get(b"CA").is_none() && mark.get(b"ca").is_none(), "the mark can be drawn transparent: {mark:?}"); + } +} From 8a8dab6c00ad27e9d3e1b66e42442c1fbd930ba7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:37:40 +0000 Subject: [PATCH 068/191] fix(xfdf): keep merged spreadsheet cells from starting a formula 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. --- crates/xfdf/src/lib.rs | 18 +++++++++++++++++- crates/xfdf/src/tests.rs | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 80c37b5e86..1eac1f96c5 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1287,6 +1287,10 @@ pub fn data_values(bytes: &[u8]) -> Result)>, DataError /// Merge Data Files into Spreadsheet: one CSV row per file, one column per field name (in the /// order they first appear). +/// +/// The names and values come from other people's files, and spreadsheet programs evaluate a cell +/// that starts with `=`, `+`, `-` or `@` (after any leading whitespace) as a formula. Such a cell +/// is written with a leading `'`, which keeps it text; a plain number such as `-12.5` is not. pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { let mut columns: Vec<&str> = Vec::new(); for f in files { @@ -1296,7 +1300,19 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { } } } - let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let literal = |s: &str| { + let t = s.trim_start(); + let formula = match t.chars().next() { + Some('=' | '@') => true, + Some('+' | '-') => !t.parse::().is_ok_and(f64::is_finite), + _ => false, + }; + if formula { format!("'{s}") } else { s.to_string() } + }; + let q = |s: &str| { + let s = literal(s); + if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s } + }; let mut out = columns.iter().map(|c| q(c)).collect::>().join(","); out.push('\n'); for f in files { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 7951bef4dd..3e6dc58a5d 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1702,3 +1702,18 @@ fn an_imported_rect_only_or_transparent_redaction_mark_is_drawn_for_review() { assert!(mark.get(b"CA").is_none() && mark.get(b"ca").is_none(), "the mark can be drawn transparent: {mark:?}"); } } + +#[test] +fn merged_spreadsheet_cells_never_start_a_formula() { + let row = vec![ + ("=HYPERLINK(\"http://x\")".to_string(), vec!["@SUM(A1)".to_string()]), + ("Total".to_string(), vec!["-2+3".to_string()]), + ("Plus".to_string(), vec!["\t+1+cmd".to_string()]), + ("Balance".to_string(), vec!["-12.5".to_string()]), + ("\tName".to_string(), vec!["Ada".to_string()]), + ]; + let csv = merge_csv(&[row]); + let lines: Vec<&str> = csv.lines().collect(); + assert_eq!(lines[0], "\"'=HYPERLINK(\"\"http://x\"\")\",Total,Plus,Balance,\"\tName\""); + assert_eq!(lines[1], "'@SUM(A1),'-2+3,\"'\t+1+cmd\",-12.5,Ada"); +} From 63f7932d06e19b1b9f9a1cb4baaab3550578bb4a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:00:43 +0000 Subject: [PATCH 069/191] fix(xfdf): fall back to the /Rect when an imported redaction mark has a zero-area quad 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. --- crates/xfdf/src/lib.rs | 22 ++++++++++++++++------ crates/xfdf/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 1eac1f96c5..9582298b34 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -246,6 +246,14 @@ fn nums_of(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } +/// Whether a quad's four corners enclose any area: some three of them are not collinear. +fn quad_has_area(q: &[f64; 8]) -> bool { + let [ax, ay, bx, by, cx, cy, dx, dy] = *q; + let cross = |(px, py): (f64, f64), (qx, qy): (f64, f64), (rx, ry): (f64, f64)| (qx - px) * (ry - py) - (qy - py) * (rx - px); + let (a, b, c, d) = ((ax, ay), (bx, by), (cx, cy), (dx, dy)); + [cross(a, b, c), cross(a, b, d), cross(a, c, d), cross(b, c, d)].iter().any(|v| v.abs() > 0.0) +} + fn text_of(doc: &Document, d: &Dict, k: &[u8]) -> Option { d.get(k).and_then(|o| match &*doc.resolve(o) { Object::String(s) => Some(s.to_text()), @@ -1036,13 +1044,15 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result = q.iter().filter_map(Object::as_f64).filter(|v| v.is_finite()).collect(); + let (quads, rest) = v.as_chunks::<8>(); + v.len() == q.len() && rest.is_empty() && !quads.is_empty() && quads.iter().all(quad_has_area) + }); if !quads_drawable { - // Quads the outline can't draw are replaced by the /Rect's, or dropped when there is - // no usable /Rect either; such a mark then removes nothing. + // Quads the outline can't draw, or any quad that encloses no area, are replaced by the + // /Rect's, or dropped when there is no usable /Rect either; such a mark then removes + // nothing. match nums_of(doc, d.get(b"Rect"))[..] { [x0, y0, x1, y1] if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) => { let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 3e6dc58a5d..de880fe664 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1717,3 +1717,37 @@ fn merged_spreadsheet_cells_never_start_a_formula() { assert_eq!(lines[0], "\"'=HYPERLINK(\"\"http://x\"\")\",Total,Plus,Balance,\"\tName\""); assert_eq!(lines[1], "'@SUM(A1),'-2+3,\"'\t+1+cmd\",-12.5,Ada"); } + +fn imported_redact_mark(data: &[u8]) -> Dict { + let mut doc = blank(); + assert_eq!(import(&mut doc, data).unwrap().comments, 1); + let page = pdfcraft_model::pages(&doc)[0].clone(); + doc.resolve(page.dict.get(b"Annots").unwrap()) + .as_array() + .unwrap() + .iter() + .filter_map(|a| a.as_ref().and_then(|r| doc.get(r).as_dict().cloned())) + .find(|d| d.name(b"Subtype") == Some(b"Redact".as_slice())) + .unwrap() +} + +#[test] +fn an_imported_redaction_mark_with_zero_area_quads_falls_back_to_its_rect() { + let rect_quads = vec![60.0, 620.0, 560.0, 620.0, 60.0, 600.0, 560.0, 600.0]; + let fdf = |quads: &str| { + format!( + "%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (r1) /Page 0 /Rect [60 600 560 620] /QuadPoints [{quads}] >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n" + ) + }; + let cases = [ + fdf("100 100 100 100 100 100 100 100").into_bytes(), + // A good quad beside one that is a line (all four corners collinear). + fdf("60 620 560 620 60 600 560 600 10 10 20 20 30 30 40 40").into_bytes(), + br#""#.to_vec(), + ]; + for data in cases { + let mark = imported_redact_mark(&data); + let quads: Vec = mark.get(b"QuadPoints").and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect(); + assert_eq!(quads, rect_quads, "zero-area quads were kept: {mark:?}"); + } +} From 256097023c57cc0574287d338051c88676b72096 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:01:55 +0000 Subject: [PATCH 070/191] fix(xfdf): use an imported redaction mark's /Rect only when it is four finite numbers 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. --- crates/xfdf/src/lib.rs | 13 +++++++++++-- crates/xfdf/src/tests.rs | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 9582298b34..219b0de81b 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -246,6 +246,15 @@ fn nums_of(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } +/// A `/Rect` of exactly four finite numbers; any other entry makes the whole rectangle unusable. +fn rect_strict(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { + let num = |x: &Object| doc.resolve(x).as_f64().filter(|v| v.is_finite()); + match doc.resolve(o?).as_array()?.as_slice() { + [x0, y0, x1, y1] => Some([num(x0)?, num(y0)?, num(x1)?, num(y1)?]), + _ => None, + } +} + /// Whether a quad's four corners enclose any area: some three of them are not collinear. fn quad_has_area(q: &[f64; 8]) -> bool { let [ax, ay, bx, by, cx, cy, dx, dy] = *q; @@ -1053,8 +1062,8 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { + match rect_strict(doc, d.get(b"Rect")) { + Some([x0, y0, x1, y1]) => { let (l, r, b, t) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1)); d.set(b"QuadPoints".to_vec(), arr(&[l, t, r, t, l, b, r, b])); } diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index de880fe664..c98fab06ac 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1751,3 +1751,10 @@ fn an_imported_redaction_mark_with_zero_area_quads_falls_back_to_its_rect() { assert_eq!(quads, rect_quads, "zero-area quads were kept: {mark:?}"); } } + +#[test] +fn an_imported_redaction_rect_with_a_non_number_is_not_used() { + let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (r1) /Page 0 /Rect [0 0 /Bad 100 100] >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + let mark = imported_redact_mark(fdf); + assert!(mark.get(b"QuadPoints").is_none(), "quads were drawn from a malformed /Rect: {mark:?}"); +} From f1c56ef86534ec182cde58cc827a700e0a693f51 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:45:44 +0000 Subject: [PATCH 071/191] fix(redact): count a name tree's shared nodes once 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. --- crates/redact/src/sanitize.rs | 34 ++++++++++++++++++++++++++++------ crates/redact/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 83db13d728..c9c2b182aa 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -87,14 +87,36 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Option { d.get(key).and_then(|o| doc.resolve(o).as_dict().cloned()) } -/// Entries of a name tree (`/Names` leaves, `/Kids` recursion), bounded against cycles. -fn name_tree_len(doc: &Document, node: &Dict, depth: usize) -> usize { - if depth > 32 { +/// The most name-tree nodes one count reads. +const NAME_TREE_NODE_BUDGET: usize = 100_000; + +/// Entries of a name tree (`/Names` leaves, `/Kids` recursion). Each indirect node is read once, +/// however many `/Kids` arrays list it, so shared or cyclic kids cost one step per node rather +/// than one per path; depth and a total node budget bound the rest. +fn name_tree_len(doc: &Document, node: &Dict) -> usize { + let (mut seen, mut visited) = (std::collections::HashSet::new(), 0); + name_tree_len_seen(doc, node, 0, &mut seen, &mut visited) +} + +fn name_tree_len_seen(doc: &Document, node: &Dict, depth: usize, seen: &mut std::collections::HashSet, visited: &mut usize) -> usize { + *visited += 1; + if depth > 32 || *visited > NAME_TREE_NODE_BUDGET { return 0; } let leaves = node.get(b"Names").and_then(|n| doc.resolve(n).as_array().map(|a| a.len() / 2)).unwrap_or(0); let kids = node.get(b"Kids").and_then(|k| doc.resolve(k).as_array().cloned()).unwrap_or_default(); - leaves + kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).map(|k| name_tree_len(doc, &k, depth + 1)).sum::() + let mut n = leaves; + for k in &kids { + if let Object::Ref(r) = k + && !seen.insert(*r) + { + continue; + } + if let Some(kid) = doc.resolve(k).as_dict() { + n = n.saturating_add(name_tree_len_seen(doc, kid, depth + 1, seen, visited)); + } + } + n } /// The most distinct outline items one count reads, over all levels together. @@ -270,7 +292,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let xmp = usize::from(cat.contains(b"Metadata")) + pages.iter().filter(|p| p.dict.contains(b"Metadata")).count(); info + xmp } - Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"), + Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + count_sub(b"FileAttachment"), Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::FormFields => pdfcraft_forms::fields(doc).len(), Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), @@ -285,7 +307,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + usize::from(cat.contains(b"AA")) + pages.iter().filter(|p| p.dict.contains(b"AA")).count() + annots.iter().filter(|a| a.contains(b"AA") || (a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A"))).count() - + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t, 0)) + + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) } Hidden::PrivateData => usize::from(cat.contains(b"PieceInfo")) + pages.iter().filter(|p| p.dict.contains(b"PieceInfo")).count(), }; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 286667937d..2a19014a9c 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2472,3 +2472,25 @@ fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() { assert_eq!(under(&mut doc, 0, &[[45.0, 95.0, 50.0, 110.0]]), 0, "nothing is drawn past LASTARR's 35pt width"); assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws"); } + +/// A name tree whose levels each list the next level twice: a walk that follows every path +/// visits 2^levels nodes. +#[test] +fn a_name_tree_whose_nodes_share_kids_counts_each_entry_once() { + use crate::sanitize::{Hidden, scan}; + let levels = 12; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 5 0 R >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"BT ET"), + ]; + for k in 0..levels { + let next = 6 + k; + objs.push(format!("<< /Kids [{next} 0 R {next} 0 R] >>").into_bytes()); + } + objs.push(b"<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".to_vec()); + let doc = pdf(objs); + let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::Attachments], 1, "one entry, reached through 2^{levels} paths"); +} From ee010fbbe5af7d7efcc8859b976a34121f3225d0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:18 +0000 Subject: [PATCH 072/191] fix(redact): read content strictly before sanitizing or redacting it 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. --- crates/redact/src/interp.rs | 3 ++- crates/redact/src/lib.rs | 4 +++- crates/redact/src/sanitize.rs | 3 +++ crates/redact/src/tests.rs | 20 ++++++++++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index c9a404b935..a608568f38 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -1201,7 +1201,8 @@ fn xobject( out.doomed.push(r); return Some(None); } - let data = match s.decoded() { + // Strict, like page content: a Form read only in part counts as undecodable. + let data = match s.decoded_strict() { Ok(d) => d, Err(_) if scope.mode == Mode::Apply => { scope.report.forms_removed += 1; diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6df5102afa..364f7cc193 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -231,7 +231,9 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< let mut data = Vec::new(); for o in &list { match &*doc.resolve(o) { - Object::Stream(s) => data.push(s.decoded().map_err(|_| RedactError::Unreadable(index + 1))?), + // Strict: a tolerant decode keeps what precedes corruption and drops the rest, which + // still draws in viewers that read further; anything not read can't be cleaned. + Object::Stream(s) => data.push(s.decoded_strict().map_err(|_| RedactError::Unreadable(index + 1))?), Object::Null => data.push(Vec::new()), _ => return Err(RedactError::Unreadable(index + 1)), } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index c9c2b182aa..f4c880e30b 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -205,6 +205,9 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re if write { return Err(RedactError::Unreadable(pi + 1)); } + // Content that can't be read may hide anything: count it so the removal runs and + // refuses the page, instead of a zero count skipping it. + total += 1; continue; }; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 2a19014a9c..a0d68af2d7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2494,3 +2494,23 @@ fn a_name_tree_whose_nodes_share_kids_counts_each_entry_once() { let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); assert_eq!(counts[&Hidden::Attachments], 1, "one entry, reached through 2^{levels} paths"); } + +/// Page content that stops decoding at its start (`z` inside an ASCII85 group) while the rest of +/// the stream draws hidden text. A tolerant decode reads nothing, counts nothing and keeps the +/// stream; sanitizing must refuse instead. +#[test] +fn hidden_text_in_content_that_stops_decoding_is_refused() { + use crate::sanitize::{Hidden, remove_hidden, sanitize}; + let encoded: &[u8] = br"!!z6<#'\7PQ#?0Ha>,+>Y->EZd(j+>PW)+B2ko-rWQ%;FOP6+B3(u78s~>"; + let fresh = || { + let mut doc = one_page(b"", "", vec![stream("/Filter /ASCII85Decode", encoded)]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Contents".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); + doc + }; + let mut doc = fresh(); + let out = remove_hidden(&mut doc, &[Hidden::HiddenText]); + assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}"); + let mut doc = fresh(); + let out = sanitize(&mut doc); + assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}"); +} From 3fe9fe218ecbc4fb6ab2f9eba346115001bf0368 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:38 +0000 Subject: [PATCH 073/191] fix(redact): remove the widgets flattening keeps when removing form fields 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. --- crates/redact/src/sanitize.rs | 11 ++++++++--- crates/redact/src/tests.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index f4c880e30b..41ef96cccf 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -341,7 +341,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0 { + let fields = which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0; + if fields { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { @@ -354,10 +355,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result bool { let s = a.name(b"Subtype").unwrap_or(b""); (which.contains(&Hidden::Comments) && is_comment(s)) + || (fields && s == b"Widget") || (which.contains(&Hidden::Attachments) && s == b"FileAttachment") || (which.contains(&Hidden::LinksActionsScripts) && s == b"Link") }; @@ -374,7 +377,9 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Object { + Object::Array(nums.iter().map(|&n| Object::Ref(ObjRef::new(n, 0))).collect()) +} + +/// An interactive form dictionary listing fields `nums`. +fn fields_dict(nums: &[u32]) -> Object { + let mut d = Dict::new(); + d.set(b"Fields".to_vec(), ref_array(nums)); + Object::Dict(d) +} + +/// Flattening keeps hidden widgets (they draw nothing); removing form fields must still take them +/// and their values, and their parent field's value, out of the file. +#[test] +fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = one_page( + b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET", + "", + vec![ + // 7 a hidden terminal field merged with its widget, 8 a parent field, 9 its hidden widget + b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (own) /V (LEAK_WIDGET_VALUE) /F 2 /Rect [10 200 150 220] /AP << /N 10 0 R >> >>".to_vec(), + b"<< /FT /Tx /T (parent) /V (LEAK_PARENT_VALUE) /Kids [9 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /F 32 /Rect [10 230 150 250] /AP << /N 10 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 140 20]", b"0 g"), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[7, 8]))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7, 9]))).unwrap(); + remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); +} From 750bbe27f1864bcb42b1cc1745256ae91d6db729 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:47:21 +0000 Subject: [PATCH 074/191] fix(redact): clean the structure tree after removing hidden content 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. --- crates/redact/src/sanitize.rs | 13 ++++++++++++- crates/redact/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 41ef96cccf..fea0449512 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -254,7 +254,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let mut cleaned_page = page.dict.clone(); cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); let (_, page_content) = page_streams(doc, &cleaned_page, pi)?; - let mut res = resources; + let mut res = resources.clone(); if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); // A full save keeps whatever is still referenced: unlink the originals this page no @@ -270,6 +270,17 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); })?; + // Tags must not describe or point at what the page no longer shows. + let (changed_ids, empty_ids) = crate::tags::touched( + doc, + &resources, + pdfcraft_content::Pieces::join(&data).data(), + pdfcraft_content::Pieces::join(&page_content).data(), + ); + crate::tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + for (old, new, changed, empty) in out.tag_changes { + crate::tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; } } // What a page no longer draws must not stay reachable through an inherited or shared diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 4d2cc76017..5c0eece859 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2549,3 +2549,31 @@ fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() assert_no_secret(&doc, b"LEAK_"); assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); } + +/// Removing hidden text cleans the structure tree as redaction does: the elements describing the +/// removed text lose their descriptions, and a reference into a rewritten Form points at the copy. +#[test] +fn removing_hidden_text_cleans_the_structure_tree() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = one_page( + b"/P <> BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC /Fm Do", + "/XObject << /Fm 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >>", + b"/P <> BDC BT /F1 10 Tf 3 Tr 10 150 Td (FORMSECRET) Tj ET EMC", + ), + b"<< /Type /StructTreeRoot /K 9 0 R >>".to_vec(), + b"<< /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (LEAK_PAGE_TAG) /K 0 >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /Alt (LEAK_FORM_TAG) /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >>".to_vec(), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"StructTreeRoot".to_vec(), Object::Ref(ObjRef::new(8, 0)))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"StructParents".to_vec(), Object::Int(0))).unwrap(); + remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText"), "untouched content keeps its tags"); +} From 822179ccf2334418c8a711b993528da31d0977ed Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:51:06 +0000 Subject: [PATCH 075/191] fix(redact): remove hidden data from every object that owns it 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. --- crates/redact/src/lib.rs | 4 + crates/redact/src/sanitize.rs | 193 +++++++++++++++++++++++++++++----- crates/redact/src/tests.rs | 94 +++++++++++++++++ 3 files changed, 265 insertions(+), 26 deletions(-) diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 364f7cc193..968773b91d 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -64,6 +64,10 @@ pub enum RedactError { "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused" )] Tags, + /// Objects written in place nest deeper than hidden information is followed, so its removal + /// could not be shown complete. + #[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")] + TooDeep, #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index fea0449512..d9edfbde95 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -15,7 +15,7 @@ use crate::{RedactError, Report, annots_of, listed_annotations, page_streams}; pub enum Hidden { /// Document information (`/Info`) and XMP metadata streams. Metadata, - /// Embedded files and file attachment annotations. + /// Embedded files, associated files (`/AF`) and file attachment annotations. Attachments, /// Comments and markup annotations (with their pop-ups). Comments, @@ -163,6 +163,148 @@ fn outline_len_seen( n } +/// Entries that hold a category's data wherever they appear, not only on the catalog and pages: +/// XMP on images, fonts and Forms, private data on Forms, associated files on pages and +/// annotations, additional actions on fields. +const OWNED: [(Hidden, &[u8]); 4] = + [(Hidden::Metadata, b"Metadata"), (Hidden::PrivateData, b"PieceInfo"), (Hidden::Attachments, b"AF"), (Hidden::LinksActionsScripts, b"AA")]; + +/// Keys whose dictionary values map names a producer chooses to objects (resources, glyph +/// procedures, named destinations, role and class maps, private data by application). A name +/// there may be spelt like an owned entry (a font called `/AF`), so these maps are walked but +/// never stripped. +const NAME_MAPS: [&[u8]; 14] = [ + b"Font", + b"XObject", + b"ExtGState", + b"ColorSpace", + b"Pattern", + b"Shading", + b"Properties", + b"CharProcs", + b"Dests", + b"RoleMap", + b"ClassMap", + b"Colorants", + b"Schema", + b"PieceInfo", +]; + +/// How deep the owner sweep follows dictionaries and arrays written in place. +const MAX_DIRECT_DEPTH: usize = 64; + +/// What a dictionary is to the owner sweep. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Held { + /// An object that may own an entry of [`OWNED`]. + Owner, + /// A [`NAME_MAPS`] map: its keys are names, its values owners. + Map, + /// An appearance dictionary (`/AP`): its dictionary values map appearance states. + Appearances, +} + +impl Held { + fn child(self, key: &[u8]) -> Held { + match self { + Held::Owner if NAME_MAPS.contains(&key) => Held::Map, + Held::Owner if key == b"AP" => Held::Appearances, + Held::Appearances => Held::Map, + _ => Held::Owner, + } + } +} + +/// The objects reachable from the catalog that hold one of `keys` outside a name map, and how +/// many dictionaries hold each key. Each object is read at most once per [`Held`] role, so a +/// shared or cyclic graph costs one step per object. +fn owners(doc: &Document, keys: &[&[u8]]) -> Result<(Vec, Vec<(ObjRef, Held)>), RedactError> { + let mut held = vec![0; keys.len()]; + let mut holders = Vec::new(); + let Some(root) = doc.root() else { return Ok((held, holders)) }; + let mut next = vec![(root, Held::Owner)]; + let mut seen = std::collections::HashSet::new(); + while let Some((r, role)) = next.pop() { + if !seen.insert((r, role)) { + continue; + } + if find_owned(&doc.get(r), role, keys, &mut held, &mut next, 0)? { + holders.push((r, role)); + } + } + Ok((held, holders)) +} + +/// Counts the dictionaries in `o` (and what it holds in place) that hold one of `keys`, and +/// queues the indirect objects it names. Returns whether any key was found. +fn find_owned(o: &Object, role: Held, keys: &[&[u8]], held: &mut [usize], next: &mut Vec<(ObjRef, Held)>, depth: usize) -> Result { + if depth > MAX_DIRECT_DEPTH { + return Err(RedactError::TooDeep); + } + let (d, role) = match o { + Object::Ref(r) => { + next.push((*r, role)); + return Ok(false); + } + Object::Array(a) => { + let mut found = false; + for v in a { + found |= find_owned(v, Held::Owner, keys, held, next, depth + 1)?; + } + return Ok(found); + } + Object::Dict(d) => (d, role), + // A stream's dictionary is never a name map. + Object::Stream(s) => (&s.dict, Held::Owner), + _ => return Ok(false), + }; + let mut found = false; + if role != Held::Map { + for (i, k) in keys.iter().enumerate() { + if d.contains(k) + && let Some(n) = held.get_mut(i) + { + *n = n.saturating_add(1); + found = true; + } + } + } + for (k, v) in d.iter() { + found |= find_owned(v, role.child(k), keys, held, next, depth + 1)?; + } + Ok(found) +} + +/// Removes `keys` from `o` and from what it holds in place, outside name maps. [`owners`] has +/// already refused anything nested past the depth limit. +fn strip_owned(o: &mut Object, role: Held, keys: &[&[u8]], depth: usize) { + if depth > MAX_DIRECT_DEPTH { + return; + } + let (d, role) = match o { + Object::Array(a) => { + for v in a { + strip_owned(v, Held::Owner, keys, depth + 1); + } + return; + } + Object::Dict(d) => (d, role), + Object::Stream(s) => (&mut s.dict, Held::Owner), + _ => return, + }; + if role != Held::Map { + for k in keys { + d.remove(k); + } + } + let names: Vec> = d.iter().filter(|(_, v)| matches!(v, Object::Array(_) | Object::Dict(_))).map(|(k, _)| k.clone()).collect(); + for k in names { + if let Some(v) = d.get_mut(&k) { + strip_owned(v, role.child(&k), keys, depth + 1); + } + } +} + /// A comment as the comments list sees it, less file attachments (removed with Attachments) and /// redaction marks. fn is_comment(subtype: &[u8]) -> bool { @@ -296,6 +438,11 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let annots: Vec = pages.iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect(); let count_sub = |s: &[u8]| annots.iter().filter(|a| a.name(b"Subtype") == Some(s)).count(); let names = sub(doc, &cat, b"Names").unwrap_or_default(); + let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect(); + // A graph nested too deeply to walk counts as holding everything, so the removal runs and + // refuses it. + let held = owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0); + let owned = |h: Hidden| OWNED.iter().position(|o| o.0 == h).and_then(|i| held.get(i)).copied().unwrap_or(0); let mut doc2 = doc.clone(); HIDDEN .into_iter() @@ -303,10 +450,13 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let n = match h { Hidden::Metadata => { let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().map(|d| d.len())).unwrap_or(0); - let xmp = usize::from(cat.contains(b"Metadata")) + pages.iter().filter(|p| p.dict.contains(b"Metadata")).count(); - info + xmp + info + owned(Hidden::Metadata) + } + Hidden::Attachments => { + sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + + count_sub(b"FileAttachment") + + owned(Hidden::Attachments) } - Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + count_sub(b"FileAttachment"), Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::FormFields => pdfcraft_forms::fields(doc).len(), Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), @@ -318,12 +468,11 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { Hidden::LinksActionsScripts => { count_sub(b"Link") + usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some())) - + usize::from(cat.contains(b"AA")) - + pages.iter().filter(|p| p.dict.contains(b"AA")).count() - + annots.iter().filter(|a| a.contains(b"AA") || (a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A"))).count() + + owned(Hidden::LinksActionsScripts) + + annots.iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) } - Hidden::PrivateData => usize::from(cat.contains(b"PieceInfo")) + pages.iter().filter(|p| p.dict.contains(b"PieceInfo")).count(), + Hidden::PrivateData => owned(Hidden::PrivateData), }; (h, n) }) @@ -406,24 +555,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Result { - cat.remove(b"Metadata"); doc.trailer_mut().remove(b"Info"); } Hidden::Attachments => { if let Some(n) = names.as_mut() { n.remove(b"EmbeddedFiles"); } - cat.remove(b"AF"); } Hidden::Bookmarks => { cat.remove(b"Outlines"); @@ -457,14 +592,10 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { - cat.remove(b"PieceInfo"); - } Hidden::HiddenLayers if !off.is_empty() => { // The off layers leave the configuration (their content is gone already). if let Some(mut oc) = sub(doc, &cat, b"OCProperties") { @@ -494,6 +625,16 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = OWNED.iter().filter(|o| which.contains(&o.0)).map(|o| o.1).collect(); + if !keys.is_empty() { + for (r, role) in owners(doc, &keys)?.1 { + let mut o = (*doc.get(r)).clone(); + strip_owned(&mut o, role, &keys, 0); + doc.set(r, o); + } + } if done.is_empty() { return Err(RedactError::NothingToApply); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 5c0eece859..603fede2c7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2577,3 +2577,97 @@ fn removing_hidden_text_cleans_the_structure_tree() { assert_no_secret(&doc, b"SECRET"); assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText"), "untouched content keeps its tags"); } + +/// One page drawing image /Im (object 7) and form /Fm (object 8), with `extra` objects from 9 on. +fn owners_fixture(image: &str, form: &str, extra: Vec>) -> Document { + let mut objs = vec![ + stream(&format!("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 {image}"), b"\x80"), + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 10 10] {form}"), b"0 0 5 5 re f"), + ]; + objs.extend(extra); + one_page(b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET q 10 0 0 10 0 0 cm /Im Do Q /Fm Do", "/XObject << /Im 7 0 R /Fm 8 0 R >>", objs) +} + +/// Removing one category and saving leaves none of `secret` in the file, and the scan counted it. +fn removes_from_every_owner(mut doc: Document, h: crate::sanitize::Hidden, secret: &[u8]) { + use crate::sanitize::{remove_hidden, scan}; + let counts: std::collections::HashMap<_, usize> = scan(&doc).into_iter().collect(); + assert!(counts[&h] > 0, "{h:?} is counted"); + remove_hidden(&mut doc, &[h]).unwrap(); + assert_no_secret(&doc, secret); + assert!(scan(&reopen(&doc)).into_iter().all(|(k, n)| k != h || n == 0 || h == crate::sanitize::Hidden::Metadata)); +} + +#[test] +fn private_data_of_a_form_xobject_is_removed() { + let doc = owners_fixture("", "/PieceInfo << /App << /Private (LEAK_PRIVATE) >> >>", vec![]); + removes_from_every_owner(doc, crate::sanitize::Hidden::PrivateData, b"LEAK_"); +} + +#[test] +fn metadata_of_an_image_is_removed() { + let doc = owners_fixture("/Metadata 9 0 R", "", vec![stream("/Type /Metadata /Subtype /XML", b"LEAK_XMP")]); + removes_from_every_owner(doc, crate::sanitize::Hidden::Metadata, b"LEAK_"); +} + +#[test] +fn a_pages_associated_files_are_removed_with_attachments() { + let mut doc = owners_fixture( + "", + "", + vec![b"<< /Type /Filespec /F (a.txt) /AFRelationship /Data /EF << /F 10 0 R >> >>".to_vec(), stream("/Type /EmbeddedFile", b"LEAK_ATTACHED")], + ); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"AF".to_vec(), ref_array(&[9]))).unwrap(); + removes_from_every_owner(doc, crate::sanitize::Hidden::Attachments, b"LEAK_"); +} + +#[test] +fn a_fields_own_actions_are_removed_with_links_actions_and_scripts() { + let mut doc = owners_fixture( + "", + "", + vec![ + b"<< /FT /Tx /T (total) /AA << /C << /S /JavaScript /JS (LEAK_SCRIPT) >> >> /Kids [10 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [10 200 150 220] >>".to_vec(), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[9]))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[10]))).unwrap(); + removes_from_every_owner(doc, crate::sanitize::Hidden::LinksActionsScripts, b"LEAK_"); +} + +/// Resource names are chosen by the producer: a font named like an owned entry stays. +#[test] +fn a_resource_named_like_an_owned_entry_is_kept() { + let mut doc = one_page( + b"BT /AF 10 Tf 10 100 Td (Visible) Tj ET", + "", + vec![b"<< /Type /Filespec /F (a.txt) /EF << /F 8 0 R >> >>".to_vec(), stream("/Type /EmbeddedFile", b"LEAK_ATTACHED")], + ); + doc.update_dict(ObjRef::new(3, 0), |d| { + let mut fonts = Dict::new(); + fonts.set(b"AF".to_vec(), Object::Ref(ObjRef::new(5, 0))); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + d.set(b"AF".to_vec(), ref_array(&[7])); + }) + .unwrap(); + removes_from_every_owner(doc.clone(), crate::sanitize::Hidden::Attachments, b"LEAK_"); + crate::sanitize::remove_hidden(&mut doc, &[crate::sanitize::Hidden::Attachments]).unwrap(); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + assert!(res.get(b"Font").and_then(|f| f.as_dict()).is_some_and(|f| f.contains(b"AF"))); + assert!(!page.dict.contains(b"AF")); +} + +/// Owners written in place deeper than the sweep follows are refused, not skipped. +#[test] +fn owners_nested_too_deeply_are_refused() { + use crate::sanitize::{Hidden, remove_hidden}; + let nested = format!("{}<< /AA << /O << /S /JavaScript /JS (LEAK_DEEP) >> >> >>{}", "[".repeat(100), "]".repeat(100)); + let mut doc = one_page(b"BT ET", "", vec![format!("<< /Deep {nested} >>").into_bytes()]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"PieceInfo".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); + let out = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]); + assert!(matches!(out, Err(RedactError::TooDeep)), "{out:?}"); +} From 94563649e604acbd3fb2a8c3cca0185b63dd2493 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:22:48 +0000 Subject: [PATCH 076/191] fix(redact): name the owner sweep's holder list for clippy --- crates/redact/src/sanitize.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index d9edfbde95..7349c433ae 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -215,10 +215,13 @@ impl Held { } } +/// Indirect objects that hold an owned entry, each with the role it was reached in. +type Holders = Vec<(ObjRef, Held)>; + /// The objects reachable from the catalog that hold one of `keys` outside a name map, and how /// many dictionaries hold each key. Each object is read at most once per [`Held`] role, so a /// shared or cyclic graph costs one step per object. -fn owners(doc: &Document, keys: &[&[u8]]) -> Result<(Vec, Vec<(ObjRef, Held)>), RedactError> { +fn owners(doc: &Document, keys: &[&[u8]]) -> Result<(Vec, Holders), RedactError> { let mut held = vec![0; keys.len()]; let mut holders = Vec::new(); let Some(root) = doc.root() else { return Ok((held, holders)) }; From 4d64ca8e2da819dff1d0e08c63939560d3b62a78 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:23:26 +0000 Subject: [PATCH 077/191] docs(redact): say which objects hidden-information removal reaches --- crates/redact/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index 9c2793e248..2caf067b5b 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -71,7 +71,11 @@ sanitize::sanitize(&mut doc)?; // all of them Categories: metadata (`/Info`, XMP), file attachments, comments, form fields (flattened so the values stay visible), hidden text (render modes 3 and 7, or wholly off the page), hidden layers (content of off optional-content groups, then the groups), bookmarks, links/actions/JavaScript, -and `/PieceInfo` private data. Both make the next save a full rewrite. +and `/PieceInfo` private data. XMP metadata, private data, associated files (`/AF`) and +additional actions (`/AA`) are removed from every object reachable from the catalog that owns +them (images, fonts, Forms, annotations and fields as well as the catalog and pages), never from +name maps such as resource dictionaries. Content is read strictly: a page whose content can't be +read in full is refused, not cleaned in part. Both make the next save a full rewrite. `patterns` finds phone numbers, e-mail addresses, credit card numbers (Luhn-checked), US Social Security numbers and dates for Search & Redact. From 241c5512d646e9be2964ccba96a69be5ab579bb5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:04:40 +0000 Subject: [PATCH 078/191] fix(redact): charge repeated name-tree kids to the node budget 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. --- crates/redact/src/sanitize.rs | 30 ++++++++++++++++++++---------- crates/redact/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 7349c433ae..5b2918f01c 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -92,28 +92,38 @@ const NAME_TREE_NODE_BUDGET: usize = 100_000; /// Entries of a name tree (`/Names` leaves, `/Kids` recursion). Each indirect node is read once, /// however many `/Kids` arrays list it, so shared or cyclic kids cost one step per node rather -/// than one per path; depth and a total node budget bound the rest. +/// than one per path. Every `/Kids` entry the walk does not descend into (a repeated reference or +/// a non-dictionary) costs a step as well, so a long `/Kids` array of repeats cannot outrun the +/// budget. Depth and a total step budget bound the rest. fn name_tree_len(doc: &Document, node: &Dict) -> usize { let (mut seen, mut visited) = (std::collections::HashSet::new(), 0); name_tree_len_seen(doc, node, 0, &mut seen, &mut visited) } fn name_tree_len_seen(doc: &Document, node: &Dict, depth: usize, seen: &mut std::collections::HashSet, visited: &mut usize) -> usize { - *visited += 1; + *visited = visited.saturating_add(1); if depth > 32 || *visited > NAME_TREE_NODE_BUDGET { return 0; } let leaves = node.get(b"Names").and_then(|n| doc.resolve(n).as_array().map(|a| a.len() / 2)).unwrap_or(0); - let kids = node.get(b"Kids").and_then(|k| doc.resolve(k).as_array().cloned()).unwrap_or_default(); let mut n = leaves; - for k in &kids { - if let Object::Ref(r) = k - && !seen.insert(*r) - { - continue; + let kids = node.get(b"Kids").map(|k| doc.resolve(k)); + for k in kids.as_deref().and_then(Object::as_array).map_or(&[][..], Vec::as_slice) { + if *visited > NAME_TREE_NODE_BUDGET { + break; } - if let Some(kid) = doc.resolve(k).as_dict() { - n = n.saturating_add(name_tree_len_seen(doc, kid, depth + 1, seen, visited)); + let held; + let kid = match k { + Object::Ref(r) if !seen.insert(*r) => None, + Object::Ref(r) => { + held = doc.get(*r); + held.as_dict() + } + other => other.as_dict(), + }; + match kid { + Some(kid) => n = n.saturating_add(name_tree_len_seen(doc, kid, depth + 1, seen, visited)), + None => *visited = visited.saturating_add(1), } } n diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 603fede2c7..a9902495df 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2671,3 +2671,23 @@ fn owners_nested_too_deeply_are_refused() { let out = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]); assert!(matches!(out, Err(RedactError::TooDeep)), "{out:?}"); } + +/// A name tree node that lists the same kid more times than the node budget: each repeat costs a +/// step, so the walk stops at the budget instead of scanning every repeat, and the distinct kid +/// listed after them is never reached. +#[test] +fn repeated_name_tree_kids_count_towards_the_node_budget() { + use crate::sanitize::{Hidden, scan}; + let repeats = "6 0 R ".repeat(100_010); + let doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 5 0 R >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"BT ET"), + format!("<< /Kids [{repeats}7 0 R] >>").into_bytes(), + b"<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".to_vec(), + b"<< /Names [(b.txt) << /Type /Filespec /F (b.txt) >>] >>".to_vec(), + ]); + let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::Attachments], 1, "the walk went past its node budget to the last kid"); +} From a374d16515b9aeec2dc6dbc2ba9dac1a648b02f0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:30:00 +0000 Subject: [PATCH 079/191] fix(cos): index the keys of a large dictionary 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. --- crates/cos/src/object.rs | 87 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 438b8d5f35..444fa37514 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -76,10 +76,35 @@ impl PdfString { /// A name object (without the leading `/`, with `#xx` escapes already decoded). pub type Name = Vec; +#[cfg(test)] +thread_local! { + /// Key comparisons and index lookups made by [`Dict`] on this thread. + static KEY_COMPARES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Entries from which a [`Dict`] keeps a key index. Smaller dictionaries (nearly all of them) +/// search their entries; a parsed file controls the key count, and a linear search per +/// inserted key would make a dictionary of many distinct keys quadratic to build. +const INDEX_FROM: usize = 32; + /// An ordered dictionary. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Default)] pub struct Dict { entries: Vec<(Name, Object)>, + /// Key to position in `entries`, kept once the dictionary reaches [`INDEX_FROM`] entries. + index: Option>>, +} + +impl PartialEq for Dict { + fn eq(&self, other: &Self) -> bool { + self.entries == other.entries + } +} + +impl std::fmt::Debug for Dict { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Dict").field("entries", &self.entries).finish() + } } impl Dict { @@ -87,26 +112,53 @@ impl Dict { Self::default() } + fn position(&self, key: &[u8]) -> Option { + if let Some(index) = &self.index { + #[cfg(test)] + KEY_COMPARES.with(|c| c.set(c.get() + 1)); + return index.get(key).copied(); + } + self.entries.iter().position(|(k, _)| { + #[cfg(test)] + KEY_COMPARES.with(|c| c.set(c.get() + 1)); + k == key + }) + } + pub fn get(&self, key: &[u8]) -> Option<&Object> { - self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v) + self.position(key).and_then(|i| self.entries.get(i)).map(|(_, v)| v) } pub fn get_mut(&mut self, key: &[u8]) -> Option<&mut Object> { - self.entries.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v) + let i = self.position(key)?; + self.entries.get_mut(i).map(|(_, v)| v) } /// Insert or replace, keeping the position of an existing key. pub fn set(&mut self, key: impl Into, value: impl Into) { let key = key.into(); let value = value.into(); - match self.entries.iter_mut().find(|(k, _)| *k == key) { + match self.position(&key).and_then(|i| self.entries.get_mut(i)) { Some((_, v)) => *v = value, - None => self.entries.push((key, value)), + None => { + let at = self.entries.len(); + if let Some(index) = &mut self.index { + index.insert(key.clone(), at); + } else if at + 1 >= INDEX_FROM { + let index = self.entries.iter().enumerate().map(|(i, (k, _))| (k.clone(), i)).chain([(key.clone(), at)]).collect(); + self.index = Some(Box::new(index)); + } + self.entries.push((key, value)); + } } } pub fn remove(&mut self, key: &[u8]) -> Option { - let i = self.entries.iter().position(|(k, _)| k == key)?; + let i = self.position(key)?; + if let Some(index) = &mut self.index { + index.remove(key); + index.values_mut().filter(|at| **at > i).for_each(|at| *at -= 1); + } Some(self.entries.remove(i).1) } @@ -382,6 +434,29 @@ impl From for Object { mod tests { use super::*; + #[test] + fn parsing_many_distinct_keys_is_not_quadratic() { + let n = 20_000; + let body: String = (0..n).map(|i| format!("/K{i} {i} ")).collect(); + let text = format!("<< {body}/K7 true >>"); + KEY_COMPARES.with(|c| c.set(0)); + let Ok(Object::Dict(d)) = crate::parser::Lexer::new(text.as_bytes(), 0).object() else { panic!("not a dict") }; + let compares = KEY_COMPARES.with(|c| c.get()); + assert!(compares < 8 * n, "{compares} key comparisons for {n} keys"); + // Order and replace-in-place semantics stay those of a plain ordered dictionary. + assert_eq!(d.len(), n); + assert_eq!(d.iter().next().map(|(k, _)| k.as_slice()), Some(&b"K0"[..])); + assert_eq!(d.get(b"K7"), Some(&Object::Bool(true))); + assert_eq!(d.iter().nth(7).map(|(_, v)| v), Some(&Object::Bool(true))); + let mut d = d; + assert_eq!(d.remove(b"K3"), Some(Object::Int(3))); + assert_eq!(d.get(b"K4"), Some(&Object::Int(4))); + assert_eq!(d.iter().nth(3).map(|(k, _)| k.as_slice()), Some(&b"K4"[..])); + d.set(b"K3".to_vec(), Object::Null); + assert_eq!(d.iter().last().map(|(k, _)| k.as_slice()), Some(&b"K3"[..])); + assert!(d.get(b"K20000").is_none()); + } + #[test] fn text_strings_round_trip() { for s in ["Plain ASCII", "Café — naïve", "日本語", "emoji 📄"] { From 58dc10c227e27a5bb5873c12894c8cf8ed61e76f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:33:20 +0000 Subject: [PATCH 080/191] fix(cos): read a damaged real's numeric prefix in one pass 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. --- crates/cos/src/parser.rs | 68 +++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 0458055961..a16ff10c37 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -324,18 +324,8 @@ impl<'a> Lexer<'a> { if s.contains('.') { // Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix. let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1); - let mut end = cleaned.len(); - while end > 0 { - // Only cut at character boundaries: a token can hold multi-byte UTF-8 (fuzzing - // found ".—", which panicked here). - if cleaned.is_char_boundary(end) - && let Ok(v) = cleaned[..end].parse::() - { - return Ok(Object::Real(v)); - } - end -= 1; - } - return Ok(Object::Real(0.0)); + let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::(p).ok()); + return Ok(Object::Real(v.unwrap_or(0.0))); } // Tolerate a doubled sign ("--280") like the real branch does. let t = s.trim_start_matches('+'); @@ -350,6 +340,44 @@ impl<'a> Lexer<'a> { } } +/// Length of the longest prefix of `s` that reads as a decimal number: a sign, digits with at +/// most one `.`, and an exponent with digits. Zero when that prefix has no digit. Found in one +/// pass so a long damaged token is converted once, not once per shorter prefix. +fn real_prefix_len(s: &[u8]) -> usize { + let digits = |from: usize| s.get(from..).map_or(0, |r| r.iter().take_while(|b| b.is_ascii_digit()).count()); + let mut at = usize::from(matches!(s.first(), Some(b'+' | b'-'))); + let int = digits(at); + at += int; + let mut frac = 0; + if s.get(at) == Some(&b'.') { + frac = digits(at + 1); + at += 1 + frac; + } + if int + frac == 0 { + return 0; + } + if matches!(s.get(at), Some(b'e' | b'E')) { + let sign = usize::from(matches!(s.get(at + 1), Some(b'+' | b'-'))); + let exp = digits(at + 1 + sign); + if exp > 0 { + at += 1 + sign + exp; + } + } + at +} + +#[cfg(test)] +thread_local! { + /// Bytes handed to number conversion on this thread. + static NUMBER_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn parse_counted(s: &str) -> Result { + #[cfg(test)] + NUMBER_BYTES.with(|c| c.set(c.get() + s.len())); + s.parse() +} + fn hexval(b: u8) -> Option { match b { b'0'..=b'9' => Some(b - b'0'), @@ -483,6 +511,22 @@ mod tests { assert_eq!(Lexer::new("1.5\u{2014}".as_bytes(), 0).object().unwrap(), Object::Real(1.5)); } + #[test] + fn a_damaged_real_is_converted_once() { + let token = format!("1.{}{}", "5".repeat(2_000), "x".repeat(20_000)); + NUMBER_BYTES.with(|c| c.set(0)); + let Ok(Object::Real(v)) = Lexer::new(token.as_bytes(), 0).object() else { panic!("not a real") }; + let converted = NUMBER_BYTES.with(|c| c.get()); + assert!(converted <= token.len(), "{converted} bytes converted for a {}-byte token", token.len()); + assert!((v - 1.5555).abs() < 1e-3); + // The longest numeric prefix wins, as before. + for (t, want) in + [("1.2.3", 1.2), ("--5.", -5.0), ("+.5x", 0.5), ("5.-", 5.0), ("1.5e3", 1500.0), ("1.5e+", 1.5), ("-.x", 0.0), (".", 0.0), ("-inf.", 0.0)] + { + assert_eq!(Lexer::new(t.as_bytes(), 0).object().ok(), Some(Object::Real(want)), "{t}"); + } + } + fn obj(s: &str) -> Object { Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}")) } From 3c64f9314ef2b6d749dfcfb4abe9652e226208b3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:34:53 +0000 Subject: [PATCH 081/191] fix(content): skip a long unreadable number whole 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. --- crates/content/src/lib.rs | 19 ++++++++++++++++--- crates/content/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index eb126ffa21..04bc1cfa95 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -74,6 +74,9 @@ fn is_delim(b: u8) -> bool { matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') } +/// Longest unreadable number token retried from its next byte. Real numbers are far shorter. +const MAX_RETRIED_TOKEN: usize = 64; + /// Parse a (decoded) content stream. pub fn parse(data: &[u8]) -> Parsed { parse_pieces(data, &[]) @@ -110,9 +113,19 @@ fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { lx.pos = data.len(); } Err(_) => { - // Skip the offending byte and carry on. - lx.pos = at + 1; - out.skipped += 1; + let read = lx.pos.saturating_sub(at); + #[cfg(test)] + tests::RESCANNED.with(|c| c.set(c.get() + read)); + let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit(); + if number && read > MAX_RETRIED_TOKEN { + // A long unreadable number is skipped whole: retrying from each of its + // bytes would read the rest of it again every time. + out.skipped += read; + } else { + // Skip the offending byte and carry on. + lx.pos = at + 1; + out.skipped += 1; + } } } continue; diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 094a0665df..f32ab3da50 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -1,5 +1,27 @@ use super::*; +thread_local! { + /// Bytes the lexer read before an operand failed, on this thread. + pub(crate) static RESCANNED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[test] +fn a_long_unreadable_number_is_read_once() { + for junk in ["+", "5"] { + let n = 20_000; + let src = format!("{}{} 1 0 0 1 0 0 cm", junk.repeat(n), if junk == "5" { "x" } else { "" }); + RESCANNED.with(|c| c.set(0)); + let p = parse(src.as_bytes()); + let rescanned = RESCANNED.with(|c| c.get()); + assert!(rescanned <= 2 * n, "{rescanned} bytes read for a {n}-byte token"); + assert_eq!(p.ops.last().and_then(|o| o.nums::<6>()), Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + } + // A short damaged number still recovers byte by byte. + let p = parse(b"-+5 0 Td"); + assert_eq!((p.ops.len(), p.skipped), (1, 1)); + assert_eq!(p.ops[0].nums::<2>(), Some([5.0, 0.0])); +} + #[test] fn operators_keep_operands_and_spans() { let src = b"q 1 0 0 1 72 720 cm BT /F1 12 Tf (Hello \\(world\\)) Tj [(A) -120 (B)] TJ ET Q"; From 07aea983d0b5df84b474f28d374b143254e5c41f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:35:25 +0000 Subject: [PATCH 082/191] fix(content): splice copies only the pieces a range touches 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. --- crates/content/src/lib.rs | 13 ++++++++++++- crates/content/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 04bc1cfa95..986bab495e 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -504,9 +504,20 @@ impl Pieces { /// for byte. Returns every piece's new bytes (unchanged pieces come back equal). pub fn splice(&self, edits: impl IntoIterator, Vec)>) -> Vec> { let mut out: Vec> = (0..self.len()).map(|i| Vec::with_capacity(self.range(i).len())).collect(); + // Only the pieces from the one holding `from` up to `to`: one call per edit must not + // visit every piece. let copy = |out: &mut Vec>, from: usize, to: usize| { - for (i, piece) in out.iter_mut().enumerate() { + if from >= to { + return; + } + let first = self.piece_of(from); + for (i, piece) in out.iter_mut().enumerate().skip(first) { + #[cfg(test)] + tests::PIECE_VISITS.with(|c| c.set(c.get() + 1)); let r = self.range(i); + if r.start >= to { + break; + } let (a, b) = (from.max(r.start), to.min(r.end)); if a < b { piece.extend_from_slice(self.data.get(a..b).unwrap_or_default()); diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index f32ab3da50..acf494320b 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -5,6 +5,30 @@ thread_local! { pub(crate) static RESCANNED: std::cell::Cell = const { std::cell::Cell::new(0) }; } +thread_local! { + /// Pieces [`Pieces::splice`] looked at while copying, on this thread. + pub(crate) static PIECE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[test] +fn splicing_visits_each_piece_a_few_times() { + let n = 5_000; + let pieces: Vec = (0..n).map(|i| format!("({i}) Tj")).collect(); + let joined = Pieces::join(&pieces); + let parsed = joined.parse(); + let edits: Vec<_> = parsed.ops.iter().map(|op| (op.span.clone(), b"(x) Tj".to_vec())).collect(); + assert_eq!(edits.len(), n); + PIECE_VISITS.with(|c| c.set(0)); + let out = joined.splice(edits); + let visits = PIECE_VISITS.with(|c| c.get()); + assert!(visits <= 4 * n, "{visits} piece visits for {n} pieces"); + assert!(out.iter().all(|p| p == b"(x) Tj")); + // A replacement spanning two pieces stays whole in the first and leaves the rest of the second. + let two = Pieces::join(&["q (a)", "Tj Q"]); + let tj = two.parse().ops.into_iter().find(|o| o.is("Tj")).map(|o| o.span).unwrap_or_default(); + assert_eq!(two.splice([(tj, b"(b) Tj".to_vec())]), [b"q (b) Tj".to_vec(), b" Q".to_vec()]); +} + #[test] fn a_long_unreadable_number_is_read_once() { for junk in ["+", "5"] { From e2c71b60e5e1f5f8aad7da8cbabcdf1626dbe89c Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:36:19 +0000 Subject: [PATCH 083/191] fix(filters): refuse a filter chain longer than eight 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. --- crates/filters/src/lib.rs | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 9a0124d859..87306166c4 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -137,8 +137,8 @@ pub(crate) type Step = Result, Failure>; /// Decodes `data` through `chain` (first element applied first). /// /// Image codecs, `Crypt` and unknown filters anywhere in the chain give -/// [`FilterError::Unsupported`] (callers keep such data encoded). `max_output` bounds -/// every intermediate buffer. +/// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than +/// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer. pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result, FilterError> { run(chain, data, max_output, true).map(|(v, _)| v) } @@ -150,13 +150,22 @@ pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usiz run(chain, data, max_output, false) } +/// Longest filter chain decoded. Real streams use one to three filters; every stage may produce +/// up to the output limit, so the chain length multiplies the work. +pub const MAX_CHAIN: usize = 8; + fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool) -> Result<(Vec, bool), FilterError> { + if chain.len() > MAX_CHAIN { + return Err(FilterError::Unsupported(format!("a chain of {} filters", chain.len()))); + } if let Some((f, _)) = chain.iter().find(|(f, _)| !f.is_decodable()) { return Err(FilterError::Unsupported(f.name().to_owned())); } let mut cur: Cow<[u8]> = Cow::Borrowed(data); let mut partial = false; for (filter, params) in chain { + #[cfg(test)] + STAGE_BYTES.with(|c| c.set(c.get() + cur.len())); cur = Cow::Owned(match decode_one(filter, params, &cur, max) { Ok(v) => v, Err(Failure { error: FilterError::Corrupt { .. }, partial: out }) if !strict => { @@ -218,8 +227,27 @@ pub(crate) fn is_pdf_whitespace(b: u8) -> bool { matches!(b, 0 | 9 | 10 | 12 | 13 | 32) } +#[cfg(test)] +thread_local! { + /// Bytes handed to decoding stages on this thread. + static STAGE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + #[cfg(test)] mod tests { + #[test] + fn a_long_filter_chain_is_refused_before_decoding() { + // `FF FF` run-length decodes to itself, so every stage of the chain repeats the work. + let data = vec![0xFF; 4096]; + let chain = |n: usize| vec![(super::Filter::RunLength, super::Params::default()); n]; + super::STAGE_BYTES.with(|c| c.set(0)); + let long = super::decode(&chain(1000), &data, 1 << 20); + let read = super::STAGE_BYTES.with(|c| c.get()); + assert!(read <= 16 * data.len(), "{read} bytes decoded through the chain"); + assert!(matches!(long, Err(super::FilterError::Unsupported(_))), "{long:?}"); + assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data)); + } + use super::*; #[test] From 92e713feb1ac2dac75b26bdc2d2952ed44d294ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:37:09 +0000 Subject: [PATCH 084/191] fix(cos): a /Filter that is not a name does not decode as unfiltered 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. --- crates/cos/src/object.rs | 45 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 444fa37514..6b6a4befe2 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -256,6 +256,21 @@ impl Stream { .collect() } + /// [`Stream::filters`], or an error when `/Filter` is present but is not a name or an + /// array of names (an indirect reference, say): the data is not unfiltered, so it must not + /// read as decoded. + fn readable_filters(&self) -> Result, CosError> { + let readable = match self.dict.get(b"Filter") { + None | Some(Object::Null | Object::Name(_)) => true, + Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some()), + Some(_) => false, + }; + if !readable { + return Err(CosError::Filter("/Filter is not a name or an array of names".into())); + } + Ok(self.filters()) + } + /// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data /// decoded before the damage is returned). Streams with image codecs fail with /// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage. @@ -266,7 +281,7 @@ impl Stream { /// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than /// [`MAX_DECODED`]: decoding past `max` bytes is an error. pub fn decoded_within(&self, max: usize) -> Result, CosError> { - let chain = self.filters(); + let chain = self.readable_filters()?; if chain.is_empty() { return self.raw_within(max); } @@ -275,7 +290,7 @@ impl Stream { /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. pub fn decoded_strict(&self) -> Result, CosError> { - let chain = self.filters(); + let chain = self.readable_filters()?; if chain.is_empty() { return self.raw_within(MAX_DECODED); } @@ -285,7 +300,7 @@ impl Stream { /// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded. /// Any corruption in that first filter is an error. pub fn decoded_first_filter_strict(&self, max: usize) -> Result, CosError> { - let mut chain = self.filters(); + let mut chain = self.readable_filters()?; chain.truncate(1); if chain.is_empty() { return self.raw_within(max); @@ -434,6 +449,30 @@ impl From for Object { mod tests { use super::*; + #[test] + fn a_filter_that_is_not_a_direct_name_is_not_decoded_as_absent() { + let stream = |filter: Object| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), filter); + Stream::from_raw(d, b"48 69>".to_vec()) + }; + let hex = Object::Name(b"ASCIIHexDecode".to_vec()); + assert_eq!(stream(hex.clone()).decoded_strict().ok(), Some(b"Hi".to_vec())); + assert_eq!(stream(Object::Array(vec![hex.clone()])).decoded().ok(), Some(b"Hi".to_vec())); + assert_eq!(stream(Object::Null).decoded_strict().ok(), Some(b"48 69>".to_vec())); + for filter in [ + Object::Ref(ObjRef::new(5, 0)), + Object::Array(vec![Object::Ref(ObjRef::new(5, 0))]), + Object::Int(3), + Object::Array(vec![hex, Object::Int(1)]), + ] { + let s = stream(filter.clone()); + assert!(s.decoded_strict().is_err(), "{filter:?}"); + assert!(s.decoded().is_err(), "{filter:?}"); + assert!(s.decoded_first_filter_strict(1 << 20).is_err(), "{filter:?}"); + } + } + #[test] fn parsing_many_distinct_keys_is_not_quadratic() { let n = 20_000; From 73d15cd8b25c38483bd6be7e9d6c0aa59bb92bf7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:38:04 +0000 Subject: [PATCH 085/191] fix(cos): count strings at every depth against the object caches 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. --- crates/cos/src/document.rs | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index c3f6c6b748..96975eb36e 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -494,12 +494,16 @@ impl ObjectReader { } } +/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object +/// is measured in full. +const OWNED_BYTES_DEPTH: usize = 256; + /// Bytes `object` holds of its own: string and stream data that is not a view into `data` -/// (the input file), nested a few levels deep (deeper data is rare and still bounded by the -/// reader's object count). +/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured +/// and counts as too large to keep. fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { - if depth > 4 { - return 0; + if depth > OWNED_BYTES_DEPTH { + return usize::MAX; } match object { Object::String(s) => s.bytes.len(), @@ -2993,6 +2997,29 @@ endobj } } + #[test] + fn editor_cache_counts_deeply_nested_strings() { + let mut nested = Object::String(crate::PdfString::literal(vec![b'x'; 1 << 20])); + for _ in 0..8 { + nested = Object::Array(vec![nested]); + } + let mut source = Document::new_empty(); + let objects: Vec<_> = (0..48).map(|_| Object::Ref(source.add(nested.clone()))).collect(); + source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); + let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); + let doc = Document::open(bytes).unwrap(); + assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20); + let root = doc.get(doc.root().unwrap()); + for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() { + let Object::Ref(reference) = reference else { panic!("indirect object") }; + drop(doc.get(*reference)); + let cache = doc.cache.lock().unwrap(); + let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0))); + let strings = cache.values().filter(|o| o.as_array().is_some()).count(); + assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted"); + } + } + #[test] fn editor_cache_stays_bounded() { let mut source = Document::new_empty(); From b1224557058aaf568a17f4c5063878d6131c89a2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:39:54 +0000 Subject: [PATCH 086/191] fix(cos): find endstream once per document, not once per stream 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. --- crates/cos/src/document.rs | 41 +++++++++++++++++++++++++++++++----- crates/cos/src/parser.rs | 43 ++++++++++++++++++++++++++++++++------ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 96975eb36e..b783a8245d 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex, Weak}; use crate::CosError; use crate::object::{Dict, ObjRef, Object}; -use crate::parser::{Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; +use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; thread_local! { /// The objects being loaded on this thread, innermost last (see `Document::try_get`). @@ -405,6 +405,8 @@ impl std::fmt::Debug for ReadState { #[derive(Clone)] pub struct Document { data: Arc>, + /// Where `endstream` occurs in `data`; see [`EndstreamIndex`]. + endstreams: Arc, source_identity: Arc<()>, entries: Arc, trailer: Dict, @@ -536,6 +538,7 @@ impl Document { pub fn new_empty() -> Self { let mut doc = Document { data: Arc::new(Vec::new()), + endstreams: Arc::default(), source_identity: Arc::new(()), entries: Arc::new(XrefIndex::empty()), trailer: Dict::new(), @@ -618,6 +621,7 @@ impl Document { }; let mut doc = Document { data, + endstreams: Arc::default(), source_identity: Arc::new(()), entries: Arc::new(XrefIndex::empty()), trailer: Dict::new(), @@ -982,7 +986,7 @@ impl Document { match self.xref_entry(num) { None | Some(XrefEntry::Free { .. }) => true, Some(XrefEntry::InFile { offset, .. }) => { - let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num); + let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None, &self.endstreams), Ok((id, _)) if id.num == num); // Offsets may count from a header that does not start the file. usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) } @@ -1180,7 +1184,7 @@ impl Document { length }; let off = offset as usize; - let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve) { + let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) { Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)), _ => None, }; @@ -1246,7 +1250,7 @@ impl Document { if p > 0 && !is_whitespace(data[p - 1]) { continue; } - if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None) + if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams) && id.num == num { found = Some((id, o)); // keep the last (newest) definition @@ -2055,7 +2059,7 @@ impl Document { if trailer.as_ref().is_some_and(|((catalog, p), _)| *catalog && *p > at) { continue; } - if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None) + if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None, &self.endstreams) && s.dict.name(b"Type") == Some(b"XRef") && let Some(r) = rank(at, &s.dict) && trailer.as_ref().is_none_or(|(best, _)| r > *best) @@ -2997,6 +3001,33 @@ endobj } } + #[test] + fn streams_without_length_share_one_endstream_search() { + let n = 2_000; + let mut file = b"%PDF-1.7\n".to_vec(); + for k in 1..=n { + file.extend_from_slice(format!("{k} 0 obj\n<< >>\nstream\nxx\n").as_bytes()); + } + file.extend_from_slice(b"endstream\nendobj\n"); + file.extend_from_slice( + format!( + "{0} 0 obj\n<< /Type /Catalog /Pages {1} 0 R >>\nendobj\n{1} 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n", + n + 1, + n + 2 + ) + .as_bytes(), + ); + file.extend_from_slice(format!("trailer\n<< /Root {} 0 R /Size {} >>\n%%EOF\n", n + 1, n + 3).as_bytes()); + let len = file.len(); + crate::parser::ENDSTREAM_SCAN.with(|c| c.set(0)); + let doc = Document::open(Arc::new(file)).unwrap(); + for k in [1, n / 2, n] { + assert!(matches!(doc.try_get(k).as_deref(), Ok(Object::Stream(_))), "object {k}"); + } + let scanned = crate::parser::ENDSTREAM_SCAN.with(|c| c.get()); + assert!(scanned <= 4 * len, "{scanned} bytes searched in a {len}-byte file"); + } + #[test] fn editor_cache_counts_deeply_nested_strings() { let mut nested = Object::String(crate::PdfString::literal(vec![b'x'; 1 << 20])); diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index a16ff10c37..b470cffa48 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -368,6 +368,8 @@ fn real_prefix_len(s: &[u8]) -> usize { #[cfg(test)] thread_local! { + /// Bytes searched for `endstream` on this thread. + pub(crate) static ENDSTREAM_SCAN: std::cell::Cell = const { std::cell::Cell::new(0) }; /// Bytes handed to number conversion on this thread. static NUMBER_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -390,17 +392,30 @@ fn hexval(b: u8) -> Option { /// Parse an indirect object at `offset`: `N G obj [stream…endstream] endobj`. /// `resolve_length` resolves an indirect `/Length`. Returns the object and its (num, gen). pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option) -> Result<(ObjRef, Object), CosError> { - parse_indirect_in(data, None, offset, resolve_length) + parse_indirect_in(data, None, offset, resolve_length, None) } /// [`parse_indirect`] for a document's own bytes: stream data points into `data` instead of /// being copied out of it. +/// `endstreams` is the document's [`EndstreamIndex`] for `data`. pub(crate) fn parse_indirect_shared( data: &Arc>, offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option, + endstreams: &EndstreamIndex, ) -> Result<(ObjRef, Object), CosError> { - parse_indirect_in(data, Some(data), offset, resolve_length) + parse_indirect_in(data, Some(data), offset, resolve_length, Some(endstreams)) +} + +/// Where `endstream` occurs in a document's bytes, found once, the first time a stream's +/// /Length is wrong or missing. Searching the rest of the file for each such stream instead +/// makes many of them ending at one late `endstream` quadratic to read. +pub(crate) type EndstreamIndex = std::sync::OnceLock>; + +fn endstream_offsets(data: &[u8]) -> Vec { + #[cfg(test)] + ENDSTREAM_SCAN.with(|c| c.set(c.get() + data.len())); + data.windows(9).enumerate().filter(|(_, w)| *w == b"endstream").map(|(i, _)| i).collect() } fn parse_indirect_in( @@ -408,6 +423,7 @@ fn parse_indirect_in( shared: Option<&Arc>>, offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option, + endstreams: Option<&EndstreamIndex>, ) -> Result<(ObjRef, Object), CosError> { let mut lx = Lexer::new(data, offset); lx.skip_ws(); @@ -467,7 +483,11 @@ fn parse_indirect_in( let len = match declared { Some(l) if valid(l) => l as usize, // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). - _ => find_endstream(data, start).ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? - start, + _ => { + find_endstream(data, start, endstreams) + .ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? + - start + } }; let range = start..start + len; let raw = match shared { @@ -483,9 +503,20 @@ fn parse_indirect_in( } /// Offset where stream data ends (before the EOL preceding "endstream"). -fn find_endstream(data: &[u8], from: usize) -> Option { - let hay = data.get(from..)?; - let at = hay.windows(9).position(|w| w == b"endstream")? + from; +fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) -> Option { + let at = match endstreams { + Some(index) => { + let index = index.get_or_init(|| endstream_offsets(data)); + index.get(index.partition_point(|&p| p < from)).copied()? + } + None => { + let hay = data.get(from..)?; + let found = hay.windows(9).position(|w| w == b"endstream"); + #[cfg(test)] + ENDSTREAM_SCAN.with(|c| c.set(c.get() + found.map_or(hay.len(), |p| p + 9))); + found? + from + } + }; let mut end = at; if end > from && data[end - 1] == b'\n' { end -= 1; From 196be22b64c39bb6315a0399db4392fa404e211e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:41:37 +0000 Subject: [PATCH 087/191] fix(cos): read a hybrid cross-reference stream once per chain 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. --- crates/cos/src/document.rs | 42 +++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index b783a8245d..470f395cdf 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -496,6 +496,12 @@ impl ObjectReader { } } +#[cfg(test)] +thread_local! { + /// Cross-reference sections read on this thread. + static SECTION_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object /// is measured in full. const OWNED_BYTES_DEPTH: usize = 256; @@ -1748,6 +1754,9 @@ impl Document { let mut revisions = Vec::new(); let mut oddities = Vec::new(); let mut visited = HashSet::new(); + // Hybrid streams already read: a newer section has merged every entry of one, so reading + // it again for each older section that names it adds nothing and repeats the decode. + let (mut hybrids, mut noted) = (HashSet::new(), HashSet::new()); let mut next = Some(first); while let Some(off) = next.take() { if !visited.insert(off) || visited.len() > 10_000 { @@ -1778,7 +1787,12 @@ impl Document { revisions.push(Revision { xref_offset: off, is_stream, header_relative }); // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the // free entries it gives objects only a cross-reference stream can locate. - if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { + let hybrid = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0); + let repeated = hybrid.is_some_and(|x| !hybrids.insert(x)); + if let Some(x) = hybrid.filter(|x| repeated && noted.insert(*x)) { + log.push(format!("hybrid /XRefStm at {x} named again by the section at {off}; read once")); + } + if let Some(x) = hybrid.filter(|_| !repeated) { let mut stm = BTreeMap::new(); match self.read_section(x as usize, &mut stm, &mut odd, log) { Ok(_) => { @@ -1822,6 +1836,8 @@ impl Document { odd: &mut Vec, log: &mut Vec, ) -> Result<(Dict, bool), CosError> { + #[cfg(test)] + SECTION_READS.with(|c| c.set(c.get() + 1)); let data = &self.data; let mut lx = Lexer::new(data, off); if lx.eat_keyword(b"xref") { @@ -3199,6 +3215,30 @@ endobj out } + /// Many revisions naming one hybrid /XRefStm read it once. + #[test] + fn a_hybrid_stream_named_by_many_revisions_is_read_once() { + let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0); + let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap(); + let mut prev = rfind(&out, b"\nxref\n", 0).unwrap() + 1; + let revisions = 200; + for _ in 0..revisions { + let at = out.len(); + out.extend_from_slice( + format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(), + ); + prev = at; + } + let doc = Document::open(Arc::new(out)).unwrap(); + SECTION_READS.with(|c| c.set(0)); + let (entries, ..) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); + let reads = SECTION_READS.with(|c| c.get()); + assert!(reads <= revisions + 2, "{reads} sections read for {} revisions", revisions + 1); + assert!(matches!(entries.get(&3), Some(XrefEntry::InStream { stream: 4, .. })), "{:?}", entries.get(&3)); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); + assert_compact_matches_materialized(&doc); + } + fn text_of(doc: &Document, num: u32, key: &[u8]) -> Option { doc.get(ObjRef::new(num, 0)).as_dict().and_then(|d| d.get(key).and_then(|v| v.as_string().map(|s| s.to_text()))) } From 1127a660310853520b21a2c0f98688aaf2afb4d0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:42:43 +0000 Subject: [PATCH 088/191] fix(crypt): resolve crypt filter methods once per handler 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. --- crates/crypt/src/lib.rs | 66 +++++++++++++++++++++++++++++---------- crates/crypt/src/tests.rs | 26 +++++++++++++++ 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 82b598688a..58e96e7d4f 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -138,9 +138,51 @@ pub struct SecurityHandler { key: Vec, auth: Auth, recovered_user: Option>, + methods: Methods, +} + +/// The crypt filter methods of a handler, resolved once: a document controls both the number of +/// filters and the number of strings, so a search of `/CF` per string would multiply them. +#[derive(Clone, Debug)] +struct Methods { + string: Method, + stream: Method, + embedded_file: Method, + named: std::collections::HashMap, Method>, +} + +impl Methods { + fn new(dict: &EncryptDict) -> Self { + let mut named = std::collections::HashMap::with_capacity(dict.crypt_filters.len()); + for (name, method) in &dict.crypt_filters { + #[cfg(test)] + tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1)); + // The first of two filters with one name wins, as a search would find it. + named.entry(name.clone()).or_insert(*method); + } + let mut methods = Methods { string: Method::Identity, stream: Method::Identity, embedded_file: Method::Identity, named }; + methods.string = methods.get(&dict.str_f); + methods.stream = methods.get(&dict.stm_f); + methods.embedded_file = methods.get(if dict.ef_f.is_empty() { &dict.stm_f } else { &dict.ef_f }); + methods + } + + fn get(&self, name: &[u8]) -> Method { + if name == b"Identity" || name.is_empty() { + return Method::Identity; + } + #[cfg(test)] + tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1)); + self.named.get(name).copied().unwrap_or(Method::Identity) + } } impl SecurityHandler { + fn new(dict: EncryptDict, key: Vec, auth: Auth, recovered_user: Option>) -> Self { + let methods = Methods::new(&dict); + Self { dict, key, auth, recovered_user, methods } + } + /// Authenticate with `password` (tried as owner password, then user password). `None` /// tries the empty password, which opens documents that only have an owner password. pub fn open(dict: EncryptDict, id0: &[u8], password: Option<&str>) -> Result { @@ -177,7 +219,7 @@ impl SecurityHandler { } r => return Err(CryptError::Unsupported(format!("revision {r}"))), }; - Ok(Self { dict, key, auth, recovered_user }) + Ok(Self::new(dict, key, auth, recovered_user)) } /// Upgrade to owner access with the owner (permissions) password, as when a restricted @@ -235,26 +277,16 @@ impl SecurityHandler { self.dict.encrypt_metadata } - fn method_named(&self, name: &[u8]) -> Method { - if name == b"Identity" || name.is_empty() { - return Method::Identity; - } - self.dict.crypt_filters.iter().find(|(n, _)| n == name).map(|(_, m)| *m).unwrap_or(Method::Identity) - } - /// The method for strings or a kind of stream. fn method(&self, stream: Option) -> Method { if self.dict.v < 4 { return Method::Rc4; } match stream { - None => self.method_named(&self.dict.str_f), - Some(StreamKind::Normal) => self.method_named(&self.dict.stm_f), - Some(StreamKind::EmbeddedFile) => { - let eff = if self.dict.ef_f.is_empty() { &self.dict.stm_f } else { &self.dict.ef_f }; - self.method_named(eff) - } - Some(StreamKind::Named(n)) => self.method_named(n), + None => self.methods.string, + Some(StreamKind::Normal) => self.methods.stream, + Some(StreamKind::EmbeddedFile) => self.methods.embedded_file, + Some(StreamKind::Named(n)) => self.methods.get(n), } } @@ -642,7 +674,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result { (d.v, d.r, d.length_bits) = (5, 6, 256); @@ -671,7 +703,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result = const { std::cell::Cell::new(0) }; +} + +#[test] +fn crypt_filters_are_looked_up_once_per_handler() { + let plain = new(Algorithm::Aes128, "", "o", -1); + let mut d = plain.dict().clone(); + let n = 5_000; + let mut filters: Vec<_> = (0..n).map(|i| (format!("F{i}").into_bytes(), Method::Identity)).collect(); + filters.append(&mut d.crypt_filters); + d.crypt_filters = filters; + FILTER_COMPARES.with(|c| c.set(0)); + let h = SecurityHandler::open(d, ID, None).unwrap(); + let strings = 1_000; + let enc = plain.encrypt_string(7, 0, b"secret"); + for _ in 0..strings { + assert_eq!(h.decrypt_string(7, 0, &enc), b"secret"); + assert_eq!(h.decrypt_stream(7, 0, &plain.encrypt_stream(7, 0, b"s", StreamKind::Normal), StreamKind::Normal), b"s"); + } + assert_eq!(h.decrypt_stream(7, 0, b"x", StreamKind::Named(b"F3")), b"x"); + let compares = FILTER_COMPARES.with(|c| c.get()); + assert!(compares <= 2 * n + 3 * strings, "{compares} filter name comparisons"); +} + #[test] fn identity_crypt_filters_leave_data_alone() { let mut d = new(Algorithm::Aes256, "", "o", -1).dict().clone(); From 6905acad2c12e3d7051a2835657c46d5da2b3001 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:44:26 +0000 Subject: [PATCH 089/191] fix(model): bound the inherited values a page walk copies 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. --- crates/model/src/lib.rs | 73 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 38bf36f222..886684c6d7 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -82,14 +82,36 @@ pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] { } /// Leaf pages in document order, with inherited attributes resolved. Cycles and absurdly deep -/// trees are cut off rather than followed. +/// trees are cut off rather than followed, as is a tree whose inherited values would take more +/// than [`MAX_INHERITED_COPIES`] bytes to copy into its leaves. pub fn pages(doc: &Document) -> Vec { PageTree::new(doc).collect() } +/// Approximate bytes of inherited values copied into leaf dictionaries by one page walk. +pub const MAX_INHERITED_COPIES: usize = 1 << 30; + +/// Approximate heap bytes of `o`: one [`Object`] per node plus string, name and key bytes. +/// Values nested deeper than the parser allows weigh as too large. +fn weight(o: &Object, depth: usize) -> usize { + if depth > 256 { + return usize::MAX; + } + let own = std::mem::size_of::(); + match o { + Object::String(s) => own.saturating_add(s.bytes.len()), + Object::Name(n) => own.saturating_add(n.len()), + Object::Array(a) => a.iter().fold(own, |n, v| n.saturating_add(weight(v, depth + 1))), + Object::Dict(d) => d.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))), + Object::Stream(s) => s.dict.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))), + _ => own, + } +} + // Retain the owner of each inherited value rather than deep-cloning direct Resources on -// every edge. Only the final Page dictionaries need owned copies of those values. -type Inherited = [Option>; 4]; +// every edge. Only the final Page dictionaries need owned copies of those values. Each owner +// carries the value's weight, measured once where the value is declared. +type Inherited = [Option<(std::sync::Arc, usize)>; 4]; struct Children { owner: std::sync::Arc, @@ -112,16 +134,23 @@ struct PageTree<'a> { // One cursor per ancestor, never one entry per child. This also preserves the first // depth-first occurrence's attributes when malformed trees share a child. pending: Vec, + // Weight left for copying inherited values into leaves. A large direct value on an + // ancestor of many leaves would otherwise be copied once per leaf. + budget: usize, } impl<'a> PageTree<'a> { fn new(doc: &'a Document) -> Self { + Self::with_budget(doc, MAX_INHERITED_COPIES) + } + + fn with_budget(doc: &'a Document, budget: usize) -> Self { let top = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))); let pending = top .into_iter() .map(|r| Children { owner: std::sync::Arc::new(Object::Array(vec![Object::Ref(r)])), next: 0, inherited: Default::default() }) .collect(); - Self { doc, seen: Default::default(), pending } + Self { doc, seen: Default::default(), pending, budget } } } @@ -149,8 +178,8 @@ impl Iterator for PageTree<'_> { let Some(d) = obj.as_dict() else { continue }; let mut inherited = parent.inherited.clone(); for (key, owner) in INHERITABLE.iter().zip(&mut inherited) { - if d.contains(key) { - *owner = Some(obj.clone()); + if let Some(value) = d.get(key) { + *owner = Some((obj.clone(), weight(value, 0))); } } let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); @@ -165,8 +194,15 @@ impl Iterator for PageTree<'_> { let mut dict = d.clone(); for (key, owner) in INHERITABLE.iter().zip(&inherited) { if !dict.contains(key) - && let Some(value) = owner.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(key)) + && let Some((owner, weight)) = owner + && let Some(value) = owner.as_dict().and_then(|d| d.get(key)) { + // Past the budget the walk is cut off, like a tree too large to follow. + let Some(left) = self.budget.checked_sub(*weight) else { + self.pending.clear(); + return None; + }; + self.budget = left; dict.set(key.to_vec(), value.clone()); } } @@ -209,6 +245,29 @@ mod tests { Document::open(Arc::new(out)).unwrap() } + #[test] + fn inherited_copies_stop_at_a_budget() { + let mut d = doc(0); + let root = ObjRef { num: 2, generation: 0 }; + let leaf = d.get(ObjRef { num: 3, generation: 0 }).as_dict().unwrap().clone(); + let kids: Vec<_> = (0..200).map(|_| Object::Ref(d.add(leaf.clone()))).collect(); + let mut resources = Dict::new(); + resources.set(b"Payload".to_vec(), Object::Array(vec![Object::Int(42); 8192])); + let resources = Object::Dict(resources); + let each = weight(&resources, 0); + d.update_dict(root, |dict| { + dict.set(b"Kids".to_vec(), Object::Array(kids)); + dict.set(b"Resources".to_vec(), resources); + }) + .unwrap(); + let budget = 1 << 20; + let pages: Vec = PageTree::with_budget(&d, budget).collect(); + let copied: usize = pages.iter().filter_map(|p| p.dict.get(b"Resources")).map(|r| weight(r, 0)).sum(); + assert!(copied <= budget, "{copied} bytes of inherited values copied into {} pages", pages.len()); + assert_eq!(pages.len(), budget / each); + assert_eq!(super::pages(&d).len(), 200); + } + #[test] fn repeated_children_do_not_queue_resource_copies() { let mut d = doc(0); From a0e82da864aa65e9386678feafd2f84c01a9159b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:48:08 +0000 Subject: [PATCH 090/191] fix(render): bound the replacement text copied into redraws 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. --- crates/render/src/text.rs | 44 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 45c87bfea7..11cde902f9 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -420,6 +420,15 @@ const MAX_CELLS: i64 = 4096; /// their unmarked redraws (the text reads twice, as it would without `/ActualText`). const MAX_REDRAW_WORK: usize = 1 << 22; +/// Most replacement text bytes the redraw dedupe in `TextDevice::finish` copies on one page. +const MAX_REDRAW_TEXT: usize = 1 << 22; + +#[cfg(test)] +thread_local! { + /// Replacement text bytes given to redraws on this thread. + static REDRAW_TEXT_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn cell(v: f32) -> i64 { // Glyph boxes are finite (`draw_glyph` and `merge` drop the rest); `as` saturates. (v / CELL).floor() as i64 @@ -564,6 +573,7 @@ impl TextDevice { redraws.sort_by_key(|(range, _)| range.start); let glyphs = std::mem::take(&mut self.glyphs); let mut redraws = redraws.into_iter().peekable(); + let mut text_left = MAX_REDRAW_TEXT; let mut i = 0; while let Some(g) = glyphs.get(i) { let Some((range, k)) = redraws.next_if(|(range, _)| range.start == i) else { @@ -572,7 +582,18 @@ impl TextDevice { continue; }; let run = glyphs.get(range.clone()).unwrap_or_default(); - let text = self.replaced.get(k).map(|r| r.text.clone()).unwrap_or_default(); + let replacement = self.replaced.get(k).map_or("", |r| r.text.as_str()); + // Each redraw takes its own copy of the replacement, and one long replacement can + // have many redraws: past the budget a redraw keeps its glyphs, as past the work budget. + let Some(left) = text_left.checked_sub(replacement.len()) else { + self.glyphs.extend_from_slice(run); + i = range.end.max(i + 1); + continue; + }; + text_left = left; + let text = replacement.to_owned(); + #[cfg(test)] + REDRAW_TEXT_BYTES.with(|c| c.set(c.get() + text.len())); if !text.chars().all(|c| c.is_control()) { match merge(run, text) { Some(glyph) => self.glyphs.push(glyph), @@ -1871,6 +1892,27 @@ mod tests { extract_page(pdf, page, &InterpreterSettings::default()).unwrap() } + #[test] + fn redraws_of_a_long_replacement_share_a_text_budget() { + let long = "0041".repeat(32 * 1024); + let redraws = 200; + let content = format!( + "BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <>> BDC (x) Tj EMC {} ET", + "1 0 0 1 100 150 Tm (x) Tj ".repeat(redraws) + ); + let pdf = marked_pages(&[&content]); + REDRAW_TEXT_BYTES.with(|c| c.set(0)); + let text = marked_text(&pdf, 0); + let given = REDRAW_TEXT_BYTES.with(|c| c.get()); + assert!(given <= MAX_REDRAW_TEXT, "{given} bytes of replacement text given to {redraws} redraws"); + assert!(text.plain_text().starts_with("AAAA")); + // A short replacement still covers its redraw. + let pdf = marked_pages(&["BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <> BDC (x) Tj EMC 1 0 0 1 100 150 Tm (x) Tj ET"]); + REDRAW_TEXT_BYTES.with(|c| c.set(0)); + assert_eq!(marked_text(&pdf, 0).plain_text(), "fi"); + assert_eq!(REDRAW_TEXT_BYTES.with(|c| c.get()), 2); + } + #[test] fn text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei() { // Everyday content both ways: a string with a byte above 0x7F, and a later word holding From 24701fe9591e93cf18662cc89d4f8f8d01a44fe3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:00:42 +0000 Subject: [PATCH 091/191] fix(print): list the source pages once per imposition 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. --- crates/print/src/lib.rs | 23 ++++++++++++++++++++--- crates/print/src/tests.rs | 11 +++++++++++ crates/print/src/tests/appearances.rs | 3 ++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 6839e5f6b0..5f81d1f93d 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -770,13 +770,16 @@ fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option, ) -> Result { - let page = pdfcraft_model::pages(doc).swap_remove(index); + let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; let crop = page.crop(doc); let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { decoded_contents(doc, &page.dict) }; @@ -841,10 +844,24 @@ fn page_form( Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) } +#[cfg(test)] +thread_local! { + /// Pages listed by [`source_pages`] on this thread. + static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The source document's pages, for printing. +fn source_pages(doc: &Document) -> Vec { + let pages = pdfcraft_model::pages(doc); + #[cfg(test)] + PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); + pages +} + /// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a /// printer or is the user's own copy); callers must check the print permission first. pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { - let pages = pdfcraft_model::pages(src); + let pages = source_pages(src); let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); let sheets = layout(&sizes, settings)?; let mut doc = src.clone(); @@ -861,7 +878,7 @@ pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError let form = match forms.get(&pl.page) { Some(f) => *f, None => { - let f = page_form(&mut doc, pl.page, settings.content, &mut promoted)?; + let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; forms.insert(pl.page, f); f } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 99634f1aa1..ce736762e4 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -192,6 +192,17 @@ fn custom_pages_per_sheet_never_overfill_the_grid() { assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), 3); } +#[test] +fn imposing_lists_the_pages_once() { + let n = 150; + let doc = fixture(n); + PAGES_LISTED.with(|c| c.set(0)); + let out = impose(&doc, &settings((0..n).collect(), Layout::multiple(2))).unwrap(); + let listed = PAGES_LISTED.with(|c| c.get()); + assert!(listed <= 2 * n, "{listed} pages listed to print {n}"); + assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), n / 2); +} + #[test] fn booklets_pair_pages_for_folding() { let sizes = vec![(200.0, 300.0); 6]; diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index 8d6f56ff6f..acf8ece6f1 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -147,7 +147,8 @@ fn promotion_preserves_streams_and_explicit_subtypes() { for subtype in [None, Some(Object::name("Form")), Some(Object::name("Image")), Some(Object::Null), Some(Object::Int(7))] { let (mut doc, ap, _) = widget_fixture(subtype.clone(), Some([2.0, 0.0, 0.0, 3.0, 5.0, 7.0])); let original = doc.get(ap); - let form = page_form(&mut doc, 0, Content::Document, &mut std::collections::HashMap::new()).unwrap(); + let pages = pdfcraft_model::pages(&doc); + let form = page_form(&mut doc, &pages, 0, Content::Document, &mut std::collections::HashMap::new()).unwrap(); assert_eq!(doc.get(ap), original, "original in the print clone"); let promoted = xobject(&doc, doc.get(form).as_dict().unwrap(), b"PCPrA0"); if subtype.is_some() { From 3a11f39ef0ef58169a10d95598df832e9a5ff319 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:47:29 +0000 Subject: [PATCH 092/191] chore(cos): allow the boxed dictionary key index under clippy The index is boxed so that it adds one pointer to every Dict, and so to every Object, rather than an inline map. --- crates/cos/src/object.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 6b6a4befe2..ee3c49a787 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -92,6 +92,8 @@ const INDEX_FROM: usize = 32; pub struct Dict { entries: Vec<(Name, Object)>, /// Key to position in `entries`, kept once the dictionary reaches [`INDEX_FROM`] entries. + /// Boxed so that every `Dict`, and so every `Object`, grows by one pointer, not by a map. + #[allow(clippy::box_collection)] index: Option>>, } From 735fb8e661f39293cd7417628ce6a541feb959bc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:47:29 +0000 Subject: [PATCH 093/191] docs(filters, model): record the filter chain and inherited copy limits --- crates/filters/README.md | 2 +- crates/model/README.md | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/filters/README.md b/crates/filters/README.md index df64e4b7c0..fc5306e322 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -23,7 +23,7 @@ encode_flate(data) -> Vec // zlib, level 6 ``` - `chain` is applied first to last, as in a `/Filter` array. -- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`. +- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. So does a chain longer than `MAX_CHAIN` (8) filters, before any stage runs. Decryption belongs to `crypt`. - `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding. - `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors. diff --git a/crates/model/README.md b/crates/model/README.md index bfc6e42e3b..09a0603913 100644 --- a/crates/model/README.md +++ b/crates/model/README.md @@ -17,6 +17,9 @@ L3/L4 crates need: The page-tree walk keeps one cursor per ancestor and shares inherited attribute owners. Repeated child references are skipped before copying attributes, with first depth-first occurrence order preserved. Returned page dictionaries still own their effective attributes. +A walk copies at most `MAX_INHERITED_COPIES` (1 GiB, approximate heap bytes) of inherited +values into the leaves; past it the walk stops, as it does past its leaf cap, and the pages +after that point are not listed. `organize`, `annot` and `forms` still carry their own small walkers; they move here as the model grows (ADR-0004). From 93436d41b050f7a199efdea1f5d2b84f0348a7de Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:43:04 +0000 Subject: [PATCH 094/191] fix(xfdf): escape paired line-ending names in exported XFDF 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. --- crates/xfdf/src/lib.rs | 2 +- crates/xfdf/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 219b0de81b..bc3ea01f40 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -354,7 +354,7 @@ fn xfdf_annots(doc: &Document, out: &mut String) { && le.len() == 2 { let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into()); - let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", name(&le[0]), name(&le[1])); + let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", esc(&name(&le[0])), esc(&name(&le[1]))); } // A callout: its leader path, text-box inset, intent and (single-name) line ending. let cl = nums_of(doc, d.get(b"CL")); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index c98fab06ac..a93f70c70e 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1758,3 +1758,26 @@ fn an_imported_redaction_rect_with_a_non_number_is_not_used() { let mark = imported_redact_mark(fdf); assert!(mark.get(b"QuadPoints").is_none(), "quads were drawn from a malformed /Rect: {mark:?}"); } + +/// Line-ending names are attribute values: a name holding a quote or angle bracket cannot add +/// attributes or elements to the exported XFDF. +#[test] +fn xfdf_line_ending_names_are_escaped() { + let mut doc = blank(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let hostile = b"a\" tail=\"x\">pwned Date: Thu, 22 Oct 2026 15:43:57 +0000 Subject: [PATCH 095/191] fix(xfdf): bound what an FDF import copies out of repeated references 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. --- crates/xfdf/src/lib.rs | 117 +++++++++++++++++++++++++++++++++------ crates/xfdf/src/tests.rs | 61 ++++++++++++++++++++ 2 files changed, 161 insertions(+), 17 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index bc3ea01f40..99470b58a1 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1198,18 +1198,66 @@ fn find_by_name(doc: &Document, nm: &str) -> Option { None } -/// Copy an object out of another document, resolving references (FDF objects are small). -fn deep(src: &Document, o: &Object, depth: usize) -> Object { +/// What one FDF import may copy out of the file. A field tree or comment that repeats a reference +/// level after level would otherwise expand a small file into millions of values. +const MAX_FDF_IMPORT_VALUES: usize = 1_000_000; + +struct ImportBudget { + values: usize, + bytes: usize, +} + +impl ImportBudget { + fn new() -> Self { + Self { values: MAX_FDF_IMPORT_VALUES, bytes: MAX_FDF_BYTES } + } + + fn spend(&mut self, values: usize, bytes: usize) -> Result<(), DataError> { + match (self.values.checked_sub(values), self.bytes.checked_sub(bytes)) { + (Some(v), Some(b)) => { + (self.values, self.bytes) = (v, b); + Ok(()) + } + _ => Err(DataError::Malformed(format!( + "the file expands to more than {MAX_FDF_IMPORT_VALUES} values or {} MiB of text", + MAX_FDF_BYTES >> 20 + ))), + } + } +} + +/// Copy an object out of another document, resolving references. A reference back to an object +/// being copied becomes null. +fn deep(src: &Document, o: &Object, depth: usize, path: &mut Vec, budget: &mut ImportBudget) -> Result { + budget.spend(1, 0)?; if depth > 16 { - return Object::Null; + return Ok(Object::Null); } - match o { - Object::Ref(r) => deep(src, &src.get(*r), depth + 1), - Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1)).collect()), - Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), deep(src, v, depth + 1))).collect()), + Ok(match o { + Object::Ref(r) => { + if path.contains(r) { + return Ok(Object::Null); + } + path.push(*r); + let copy = deep(src, &src.get(*r), depth + 1, path, budget); + path.pop(); + copy? + } + Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1, path, budget)).collect::>()?), + Object::Dict(d) => { + Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, v, depth + 1, path, budget)?))).collect::>()?) + } Object::Stream(_) => Object::Null, + Object::String(s) => { + budget.spend(0, s.bytes.len())?; + o.clone() + } + Object::Name(n) => { + budget.spend(0, n.len())?; + o.clone() + } other => other.clone(), - } + }) } fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) -> Result<(), DataError> { @@ -1233,27 +1281,59 @@ fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) { +/// Read an FDF field tree. A field listed again beneath itself is skipped. +fn walk_fdf( + fdf: &Document, + list: &[Object], + prefix: &str, + out: &mut Vec<(String, Vec)>, + depth: usize, + path: &mut Vec, + budget: &mut ImportBudget, +) -> Result<(), DataError> { if depth > 32 { - return; + return Ok(()); } for f in list { - let Some(d) = fdf.resolve(f).as_dict().cloned() else { continue }; + budget.spend(1, 0)?; + let held; + let field = match f { + Object::Ref(r) if path.contains(r) => continue, + Object::Ref(r) => { + held = fdf.get(*r); + &*held + } + direct => direct, + }; + let Some(d) = field.as_dict() else { continue }; let Some(t) = d.get(b"T").and_then(|t| fdf.resolve(t).as_string().map(|s| s.to_text())) else { continue }; let full = if prefix.is_empty() { t } else { format!("{prefix}.{t}") }; + budget.spend(0, full.len())?; if let Some(v) = d.get(b"V").map(|v| fdf.resolve(v)) { let vals: Vec = match &*v { Object::String(s) => vec![s.to_text()], Object::Name(n) => vec![pdfcraft_forms::name_text(n)], - Object::Array(a) => a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect(), + Object::Array(a) => { + budget.spend(a.len(), 0)?; + a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect() + } _ => Vec::new(), }; + budget.spend(vals.len(), vals.iter().map(String::len).fold(0, usize::saturating_add))?; out.push((full.clone(), vals)); } - if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)).and_then(|k| k.as_array().cloned()) { - walk_fdf(fdf, &kids, &full, out, depth + 1); + if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)) + && let Some(kids) = kids.as_array() + { + let entered = f.as_ref().map(|r| path.push(r)).is_some(); + let walked = walk_fdf(fdf, kids, &full, out, depth + 1, path, budget); + if entered { + path.pop(); + } + walked?; } } + Ok(()) } fn fdf_body(fdf: &Document) -> Result { @@ -1272,8 +1352,9 @@ pub fn data_values(bytes: &[u8]) -> Result)>, DataError let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; let body = fdf_body(&fdf)?; let mut values = Vec::new(); + let mut budget = ImportBudget::new(); if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) { - walk_fdf(&fdf, &list, "", &mut values, 0); + walk_fdf(&fdf, &list, "", &mut values, 0, &mut Vec::new(), &mut budget)?; } return Ok(values); } @@ -1346,10 +1427,11 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; let body = fdf_body(&fdf)?; let mut report = Report::default(); + let mut budget = ImportBudget::new(); // Fields (with /Kids for hierarchical names). let mut values = Vec::new(); if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) { - walk_fdf(&fdf, &list, "", &mut values, 0); + walk_fdf(&fdf, &list, "", &mut values, 0, &mut Vec::new(), &mut budget)?; } // Comments: parents first, then replies (whose /IRT needs the parent's new reference). let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); @@ -1360,7 +1442,8 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { // The parent is another comment: link it below rather than copying it in. let irt = src.get(b"IRT").cloned(); src.remove(b"IRT"); - let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0) else { continue }; + let mut path: Vec = a.as_ref().into_iter().collect(); + let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue }; let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else { continue; }; diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index a93f70c70e..cf9095bf32 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1759,6 +1759,67 @@ fn an_imported_redaction_rect_with_a_non_number_is_not_used() { assert!(mark.get(b"QuadPoints").is_none(), "quads were drawn from a malformed /Rect: {mark:?}"); } +/// An FDF body with `body` as its `/FDF` dictionary contents and `objects` (numbered from 2). +fn fdf_with(body: &str, objects: &[String]) -> Vec { + let mut out = format!("%FDF-1.2\n1 0 obj\n<< /FDF << {body} >> >>\nendobj\n"); + for (i, o) in objects.iter().enumerate() { + out.push_str(&format!("{} 0 obj\n{o}\nendobj\n", i + 2)); + } + out.push_str("trailer\n<< /Root 1 0 R >>\n%%EOF\n"); + out.into_bytes() +} + +/// A field that lists itself as a kid is read once. +#[test] +fn an_fdf_field_listing_itself_is_read_once() { + let cyclic = fdf_with("/Fields [2 0 R]", &["<< /T (a) /V (x) /Kids [2 0 R] >>".into()]); + assert_eq!(data_values(&cyclic).unwrap(), vec![("a".to_string(), vec!["x".to_string()])]); +} + +/// A field tree whose `/Kids` repeat one subtree level after level is refused rather than +/// expanded into millions of rows. +#[test] +fn fdf_field_trees_that_repeat_references_are_bounded() { + let levels = 19; + let mut objects: Vec = (0..levels).map(|i| format!("<< /T (f{i}) /V (x) /Kids [{n} 0 R {n} 0 R] >>", n = i + 3)).collect(); + objects.push("<< /T (leaf) /V (x) >>".into()); + let shared = fdf_with("/Fields [2 0 R]", &objects); + let read = data_values(&shared); + assert!(read.is_err(), "{} rows", read.map_or(0, |v| v.len())); + assert!(matches!(import(&mut blank(), &shared), Err(DataError::Malformed(_)))); +} + +fn fdf_note(data: &str) -> String { + format!("<< /Subtype /Text /Rect [0 0 9 9] /NM (n) /Page 0 /PCData {data} >>") +} + +/// A comment property that refers back to the comment is copied once, not nested level after +/// level. +#[test] +fn an_fdf_comment_referring_to_itself_is_copied_once() { + let cyclic = fdf_with("/Annots [2 0 R]", &[fdf_note("2 0 R")]); + let mut dst = blank(); + assert_eq!(import(&mut dst, &cyclic).unwrap().comments, 1); + let placed = find_by_name(&dst, "n").unwrap(); + let data = dst.get(placed).as_dict().unwrap().get(b"PCData").cloned(); + assert!(!matches!(data, Some(Object::Dict(_))), "{data:?}"); +} + +/// A comment whose properties repeat one reference level after level is refused rather than +/// copied into millions of values. +#[test] +fn fdf_comment_graphs_that_repeat_references_are_bounded() { + let note = fdf_note; + let width = 6; + let mut objects = vec![note("3 0 R")]; + for i in 0..8 { + objects.push(format!("[{}]", vec![format!("{} 0 R", i + 4); width].join(" "))); + } + objects.push("(leaf)".into()); + let shared = fdf_with("/Annots [2 0 R]", &objects); + assert!(matches!(import(&mut blank(), &shared), Err(DataError::Malformed(_)))); +} + /// Line-ending names are attribute values: a name holding a quote or angle bracket cannot add /// attributes or elements to the exported XFDF. #[test] From ae791d667bfaf2785979eaac34b45502dc66c320 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:47:11 +0000 Subject: [PATCH 096/191] fix(sign): a time-stamp response must carry the requested nonce and policy 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. --- crates/sign/src/timestamp.rs | 13 ++++++++++++- crates/sign/tests/it/pdf.rs | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/sign/src/timestamp.rs b/crates/sign/src/timestamp.rs index 58eda4f049..0eba6bb72c 100644 --- a/crates/sign/src/timestamp.rs +++ b/crates/sign/src/timestamp.rs @@ -62,6 +62,8 @@ pub struct TimestampToken { pub policy: String, pub serial: Vec, pub gen_time: Time, + /// The TSTInfo nonce, when it has one that fits a `u64` (the size requests use). + pub nonce: Option, } impl TimestampToken { @@ -140,6 +142,9 @@ pub fn parse_token(raw: &[u8]) -> Result { .to_vec(); let serial = fields.get(3).ok_or_else(|| bad("TSTInfo has no serial number"))?.uint_bytes().to_vec(); let gen_time = fields.get(4).ok_or_else(|| bad("TSTInfo has no generation time"))?.time()?; + // The nonce is the only INTEGER after genTime (RFC 3161 §2.4.2): accuracy is a SEQUENCE and + // ordering a BOOLEAN. + let nonce = fields.iter().skip(5).find(|f| f.tag == tag::INTEGER).and_then(|f| f.u64().ok()); // The signed messageDigest uses the SignerInfo's digest algorithm, which need not be the // imprint's (RFC 5652 §5.4). let content_digest = token.signer.digest.digest(&[info]); @@ -151,7 +156,7 @@ pub fn parse_token(raw: &[u8]) -> Result { if !token.verify_signature(cert, &content_digest)? { return Err(bad("TSA signature is invalid")); } - Ok(TimestampToken { raw: raw.to_vec(), digest, imprint, policy, serial, gen_time }) + Ok(TimestampToken { raw: raw.to_vec(), digest, imprint, policy, serial, gen_time, nonce }) } /// Parse and validate a `TimeStampResp`, including the CMS signature and message imprint. @@ -170,6 +175,12 @@ pub fn parse_response(bytes: &[u8], query: &TimestampQuery) -> Result, policy: Option<&str>| { + let mut q = TimestampQuery::new(alg, alg.digest(&[b"document"])).unwrap(); + (q.nonce, q.policy) = (nonce, policy.map(str::to_string)); + q + }; + let answer = |q: &TimestampQuery| respond(&tsa.key, &tsa.certificate, &tsa.chain, alg, q, "1.2.3.4", time, 9).unwrap(); + let with_nonce = answer(&query(Some(42), None)); + assert!(parse_response(&with_nonce, &query(Some(42), Some("1.2.3.4"))).is_ok()); + assert!(parse_response(&with_nonce, &query(None, None)).is_ok()); + assert!(parse_response(&with_nonce, &query(Some(43), None)).is_err(), "another request's nonce"); + assert!(parse_response(&with_nonce, &query(Some(42), Some("1.2.3.5"))).is_err(), "another policy"); + let without_nonce = answer(&query(None, None)); + assert!(parse_response(&without_nonce, &query(Some(42), None)).is_err(), "no nonce in the token"); +} + #[cfg(windows)] #[test] fn windows_store_enumeration_and_missing_identity() { From 0a30b4e08107ad1b5809afc83c1f9790a6ef26f8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:01:27 +0000 Subject: [PATCH 097/191] fix(preflight): read content colour one operator at a time 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. --- crates/content/src/lib.rs | 197 +++++++++++++++++++++------------- crates/preflight/src/lib.rs | 16 ++- crates/preflight/src/tests.rs | 13 +++ 3 files changed, 151 insertions(+), 75 deletions(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index eb126ffa21..3432f7ff32 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -79,90 +79,143 @@ pub fn parse(data: &[u8]) -> Parsed { parse_pieces(data, &[]) } +/// The operators of a (decoded) content stream, read one at a time, as [`parse`] reads them. A +/// caller that stops early or keeps only a summary never holds every operator of the stream. +pub fn ops(data: &[u8]) -> Ops<'_> { + Ops::new(data, &[]) +} + /// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment /// crosses. fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { - let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() }; - let mut boundary = 0; - let mut lx = Lexer::new(data, 0); - let mut operands: Vec = Vec::new(); - let mut start: Option = None; - loop { - let trivia = lx.pos; - lx.skip_ws(); - // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a - // string, a name, an array or dictionary operand or inline image data is not one. - if boundary < ends.len() { - comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges); + let mut reader = Ops::new(data, ends); + let ops = reader.by_ref().collect(); + Parsed { ops, skipped: reader.skipped, comment_edges: reader.comment_edges } +} + +/// The iterator [`ops`] returns. +pub struct Ops<'a> { + data: &'a [u8], + lx: Lexer<'a>, + operands: Vec, + start: Option, + skipped: usize, + ends: &'a [usize], + boundary: usize, + comment_edges: Vec<(bool, bool)>, +} + +impl<'a> Ops<'a> { + fn new(data: &'a [u8], ends: &'a [usize]) -> Self { + Ops { + data, + lx: Lexer::new(data, 0), + operands: Vec::new(), + start: None, + skipped: 0, + ends, + boundary: 0, + comment_edges: vec![(false, false); ends.len()], } - let Some(&b) = data.get(lx.pos) else { break }; - let at = lx.pos; - let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); - if operand_start { - match lx.object() { - Ok(o) => { + } + + /// Bytes read so far that could not be read as operands or operators. + pub fn skipped(&self) -> usize { + self.skipped + } +} + +impl Iterator for Ops<'_> { + type Item = Op; + + fn next(&mut self) -> Option { + let Ops { data, lx, operands, start, skipped, ends, boundary, comment_edges: edges } = self; + let (data, ends): (&[u8], &[usize]) = (data, ends); + loop { + let trivia = lx.pos; + lx.skip_ws(); + // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a + // string, a name, an array or dictionary operand or inline image data is not one. + if *boundary < ends.len() { + comment_edges(data, trivia..lx.pos, ends, boundary, edges); + } + let Some(&b) = data.get(lx.pos) else { + // Operands left without an operator. + *skipped += operands.len(); + operands.clear(); + return None; + }; + let at = lx.pos; + let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); + if operand_start { + match lx.object() { + Ok(o) => { + start.get_or_insert(at); + operands.push(o); + } + Err(_) if b == b'(' => { + // An unterminated string runs to the end of the stream. + *skipped += data.len() - at; + lx.pos = data.len(); + } + Err(_) => { + // Skip the offending byte and carry on. + lx.pos = at + 1; + *skipped += 1; + } + } + continue; + } + if is_delim(b) { + // A stray `)`, `>`, `]`, `{`, `}`. + lx.pos += 1; + *skipped += 1; + continue; + } + let kw = lx.token(); + if kw.is_empty() { + lx.pos = at + 1; + *skipped += 1; + continue; + } + match kw { + b"true" => { start.get_or_insert(at); - operands.push(o); + operands.push(Object::Bool(true)); } - Err(_) if b == b'(' => { - // An unterminated string runs to the end of the stream. - out.skipped += data.len() - at; - lx.pos = data.len(); + b"false" => { + start.get_or_insert(at); + operands.push(Object::Bool(false)); } - Err(_) => { - // Skip the offending byte and carry on. - lx.pos = at + 1; - out.skipped += 1; + b"null" => { + start.get_or_insert(at); + operands.push(Object::Null); } - } - continue; - } - if is_delim(b) { - // A stray `)`, `>`, `]`, `{`, `}`. - lx.pos += 1; - out.skipped += 1; - continue; - } - let kw = lx.token(); - if kw.is_empty() { - lx.pos = at + 1; - out.skipped += 1; - continue; - } - match kw { - b"true" => { - start.get_or_insert(at); - operands.push(Object::Bool(true)); - } - b"false" => { - start.get_or_insert(at); - operands.push(Object::Bool(false)); - } - b"null" => { - start.get_or_insert(at); - operands.push(Object::Null); - } - b"BI" => match inline_image(data, lx.pos) { - Ok((dict, img, end)) => { - lx.pos = end; - out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end }); - operands.clear(); + b"BI" => match inline_image(data, lx.pos) { + Ok((dict, img, end)) => { + lx.pos = end; + operands.clear(); + return Some(Op { + op: b"BI".to_vec(), + operands: Vec::new(), + inline: Some((dict, img)), + span: start.take().unwrap_or(at)..end, + }); + } + Err(end) => { + *skipped += end.saturating_sub(at); + lx.pos = end; + *start = None; + operands.clear(); + } + }, + _ => { + let s = start.take().unwrap_or(at); + return Some(Op { op: kw.to_vec(), operands: std::mem::take(operands), inline: None, span: s..lx.pos }); } - Err(end) => { - out.skipped += end.saturating_sub(at); - lx.pos = end; - start = None; - operands.clear(); - } - }, - _ => { - let s = start.take().unwrap_or(at); - out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos }); } } } - out.skipped += operands.len(); - out } /// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 2cda29fea1..4e5f7eb2a1 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -339,8 +339,17 @@ fn is_content(d: &Dict) -> bool { /// The device colour a content stream sets. It is read operator by operator, so the bytes of a /// string, a comment or an inline image are never taken for an operator, and an operator right /// before a delimiter (`1 k/GS0 gs`) is still found. -fn content_colour(data: &[u8], used: &mut DeviceColour) { - for op in pdfcraft_content::parse(data).ops { +/// +/// Operators are read one at a time and dropped, so a stream of millions of tiny operators never +/// becomes millions of parsed ones in memory, and reading stops once all three spaces are set. +/// Returns how many operators it read. +fn content_colour(data: &[u8], used: &mut DeviceColour) -> usize { + let mut read = 0; + for op in pdfcraft_content::ops(data) { + if used.gray && used.rgb && used.cmyk { + break; + } + read += 1; match op.op.as_slice() { b"g" | b"G" => used.gray = true, b"rg" | b"RG" => used.rgb = true, @@ -366,6 +375,7 @@ fn content_colour(data: &[u8], used: &mut DeviceColour) { _ => {} } } + read } /// Whether any content or object uses a device colour space. @@ -400,7 +410,7 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) && (referenced.contains(r) || is_content(&s.dict)) && let Ok(data) = s.decoded() { - content_colour(&data, &mut used); + let _ = content_colour(&data, &mut used); } } used diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 80b1c63c49..290c7695a0 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -417,3 +417,16 @@ fn oversized_xmp_is_refused_while_decoding() { assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress"); assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding"); } + +/// Content is read one operator at a time and no further than it needs: once gray, RGB and CMYK +/// are all set, the rest of the stream is not read. +#[test] +fn content_colour_reads_operators_one_at_a_time_and_stops_when_all_are_found() { + let mut data = b"0 g 0 0 0 rg 0 0 0 0 k\n".to_vec(); + data.extend(b"n\n".repeat(100_000)); + let mut used = DeviceColour::default(); + let read = content_colour(&data, &mut used); + assert!(used.gray && used.rgb && used.cmyk); + assert_eq!(read, 3); +} + From adab150a4558722503506617de545563308c2e2e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:01:51 +0000 Subject: [PATCH 098/191] fix(preflight): find annotation pages from one pass over the pages 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). --- crates/preflight/src/lib.rs | 31 ++++++++++++++++++++----------- crates/preflight/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 4e5f7eb2a1..731234aa6b 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -416,6 +416,12 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) used } +#[cfg(test)] +thread_local! { + /// Page `/Annots` entries read while matching annotations to their pages. + static ANNOT_ENTRIES_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Check `doc` against `level`. pub fn verify(doc: &Document, level: Level) -> Vec { let mut out = Vec::new(); @@ -486,17 +492,20 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } // Pages: annotations and page actions. let pages = pdfcraft_model::pages(doc); - let page_of = |r: ObjRef| { - pages.iter().position(|p| { - p.dict - .get(b"Annots") - .map(|a| doc.resolve(a)) - .and_then(|a| a.as_array().cloned()) - .unwrap_or_default() - .iter() - .any(|x| x.as_ref() == Some(r)) - }) - }; + // Each annotation's page: the first page that lists it. + let mut annot_pages: std::collections::HashMap = std::collections::HashMap::new(); + for (i, p) in pages.iter().enumerate() { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + #[cfg(test)] + ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len())); + for r in a.iter().filter_map(Object::as_ref) { + annot_pages.entry(r).or_insert(i); + } + } + } + let page_of = |r: ObjRef| annot_pages.get(&r).copied(); for (i, p) in pages.iter().enumerate() { if p.dict.get(b"AA").is_some() { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 290c7695a0..a44d47de15 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -430,3 +430,21 @@ fn content_colour_reads_operators_one_at_a_time_and_stops_when_all_are_found() { assert_eq!(read, 3); } +/// Each annotation's page is found from one pass over the pages' `/Annots`, not by searching +/// every page's list again for each annotation. +#[test] +fn annotation_pages_are_found_in_one_pass() { + let n = 2_000; + let annots: Vec = (0..n).map(|i| format!("{} 0 R", i + 4)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", annots.join(" ")); + let mut objs = + vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), page]; + objs.extend((0..n).map(|_| "<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R >>".to_string())); + let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let doc = build(&refs, ""); + ANNOT_ENTRIES_READ.with(|c| c.set(0)); + let issues = verify(&doc, Level::A2b); + assert!(issues.iter().any(|i| i.clause == "6.3.2" && i.page == Some(0)), "{issues:#?}"); + let read = ANNOT_ENTRIES_READ.with(std::cell::Cell::get); + assert!(read <= 2 * n, "{read} entries read for {n} annotations"); +} From 90982ba9a86233db45e674baea493fc26196151f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:03:50 +0000 Subject: [PATCH 099/191] fix(preflight): check the actions an allowed action chains to 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. --- crates/preflight/src/lib.rs | 63 +++++++++++++++++++++++++++-------- crates/preflight/src/tests.rs | 26 +++++++++++++++ 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 731234aa6b..905ba8cbf3 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -515,12 +515,14 @@ pub fn verify(doc: &Document, level: Level) -> Vec { let Some(d) = dict_of(o) else { continue }; let ty = d.name(b"Type"); let sub = d.name(b"Subtype"); - // Actions anywhere (structure elements also have /S: their role). - if let Some(s) = d.name(b"S") - && FORBIDDEN_ACTIONS.contains(&s) - && is_action(d) - { - issue("6.5.1", "A {} action isn't allowed", Some(String::from_utf8_lossy(s).into_owned()), None, true); + // Actions anywhere (structure elements also have /S: their role), with the actions they + // chain to and those written directly in an /A or /OpenAction. + let own = (d.get(b"S").is_some() && is_action(d)).then_some(d); + let direct = [b"A".as_slice(), b"OpenAction"].map(|k| d.get(k).and_then(|a| a.as_dict())); + for a in [own].into_iter().chain(direct).flatten() { + if let Some(s) = chain_forbidden(a) { + issue("6.5.1", "A {} action isn't allowed", Some(s), None, true); + } } // Annotations. if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) { @@ -652,10 +654,39 @@ fn is_action(d: &Dict) -> bool { } } -/// Whether `o` is (or refers to) a forbidden action, or to nothing. +/// How many actions one action and the `/Next` actions written inside it may hold before the +/// whole chain counts as forbidden, since it can't be checked. +const MAX_ACTION_CHAIN: usize = 1024; + +/// The first forbidden action type in the action `a` or in the actions written directly in its +/// `/Next` (ISO 32000-2 12.6.2: a dictionary or an array of them). An action `/Next` refers to +/// is an object of its own, checked and removed as one. +fn chain_forbidden(a: &Dict) -> Option { + let mut stack = vec![a]; + let mut read = 0; + while let Some(d) = stack.pop() { + read += 1; + if read > MAX_ACTION_CHAIN { + return Some("too deeply chained".into()); + } + if let Some(s) = d.name(b"S") + && FORBIDDEN_ACTIONS.contains(&s) + { + return Some(String::from_utf8_lossy(s).into_owned()); + } + match d.get(b"Next") { + Some(Object::Dict(next)) => stack.push(next), + Some(Object::Array(list)) => stack.extend(list.iter().filter_map(Object::as_dict)), + _ => {} + } + } + None +} + +/// Whether `o` is (or refers to) a forbidden action or one chaining to it, or to nothing. fn forbidden(doc: &Document, o: &Object) -> bool { match &*doc.resolve(o) { - Object::Dict(a) => a.name(b"S").is_some_and(|s| FORBIDDEN_ACTIONS.contains(&s)), + Object::Dict(a) => chain_forbidden(a).is_some(), Object::Null => true, _ => false, } @@ -801,14 +832,20 @@ pub fn convert(doc: &mut Document, level: Level) -> Result>"); + let open_action = format!("<< /Type /Catalog /Pages 2 0 R /OpenAction << /S /GoTo /D [3 0 R /Fit] /Next {js} >> >>"); + let goto = format!("<< /Type /Action /S /GoTo /D [3 0 R /Fit] /Next [{js}] >>"); + let cases = [ + build(&[&open_action, pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] >>")], ""), + build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("5 0 R"), &goto], ""), + build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link(&format!("<< /S /GoTo /D [3 0 R /Fit] /Next {js} >>"))], ""), + ]; + for (i, mut doc) in cases.into_iter().enumerate() { + let found = messages(&verify(&doc, Level::A2b)).contains(&"A JavaScript action isn't allowed"); + assert!(found, "case {i}: {:#?}", verify(&doc, Level::A2b)); + let r = convert(&mut doc, Level::A2b).unwrap(); + assert!(!r.remaining.iter().any(|x| x.clause == "6.5.1"), "case {i}: {:#?}", r.remaining); + let plain = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }; + let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap(); + assert!(!bytes.windows(11).any(|w| w == b"/JavaScript"), "case {i}: {}", String::from_utf8_lossy(&bytes)); + } +} + /// Each annotation's page is found from one pass over the pages' `/Annots`, not by searching /// every page's list again for each annotation. #[test] From 467c8556ec655be08e5b68b1f35ffbbc974415f7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:08:25 +0000 Subject: [PATCH 100/191] fix(engine): encode each image once for Office export 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. --- crates/engine/src/compare.rs | 50 +++++++++++++++---- crates/engine/src/tests.rs | 2 +- crates/engine/tests/it/main.rs | 1 + .../engine/tests/it/office_export_images.rs | 43 ++++++++++++++++ crates/export/src/lib.rs | 9 ++-- crates/export/src/pptx.rs | 10 +++- 6 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 crates/engine/tests/it/office_export_images.rs diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index c23db91f1f..095e666c14 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -1,8 +1,11 @@ //! Compare files: the text differences between two open documents, a report, and the //! differences marked as comments in the newer one. +use std::collections::HashMap; use std::sync::Arc; +use pdfcraft_cos::ObjRef; + pub use pdfcraft_compare::{Change, Comparison, Kind, Side}; // How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages // that draw more. @@ -10,6 +13,10 @@ pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape}; +/// Most bytes of encoded pictures one Office export prepares, counting each image object once +/// (the Word and PowerPoint writers keep at most 512 MiB of them as well). +pub const MAX_EXPORT_IMAGE_BYTES: usize = 512 << 20; + /// Acrobat's compare colours: replaced blue, inserted green, deleted red. pub fn colour(kind: Kind) -> crate::Rgb { match kind { @@ -163,23 +170,32 @@ pub struct OfficeExport { /// The exported file. pub bytes: Vec, /// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them - /// than [`MAX_FORM_VISITS`]: export stops reading there to stay bounded on hostile files, so - /// the text and images of those forms are missing from `bytes`. + /// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`]: export stops + /// reading there to stay bounded on hostile files, so the text and images of those forms, or + /// those pictures, are missing from `bytes`. pub truncated_pages: Vec, } +/// An image object prepared for Office export: its file extension and encoded bytes, or why it is +/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`]; `Err(false)`: it can't be encoded). +type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>; + impl crate::Document { /// The pages as paragraphs and images (for Word, HTML and RTF export), including what /// form XObjects draw. pub fn export_pages(&self) -> Vec { - self.export_pages_checked().0 + self.export_pages_checked(true).0 } - /// [`Self::export_pages`], and the pages whose forms were left partly unread - /// ([`OfficeExport::truncated_pages`]). - fn export_pages_checked(&self) -> (Vec, Vec) { + /// [`Self::export_pages`], and the pages whose forms were left partly unread or whose + /// pictures went past [`MAX_EXPORT_IMAGE_BYTES`] ([`OfficeExport::truncated_pages`]). Without + /// `images` no picture is prepared. + fn export_pages_checked(&self, images: bool) -> (Vec, Vec) { let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) }; let mut truncated = Vec::new(); + // Each image object is encoded once and shared by every place that draws it. + let mut encoded: HashMap = HashMap::new(); + let mut image_bytes = MAX_EXPORT_IMAGE_BYTES; let pages = self .info .pages @@ -217,13 +233,21 @@ impl crate::Document { } }) .collect(); - let images = pdfcraft_edit::reading_images(cos, i) - .unwrap_or_default() + let placed = if images { pdfcraft_edit::reading_images(cos, i).unwrap_or_default() } else { Vec::new() }; + let mut over_budget = false; + let images = placed .iter() .filter_map(|im| { - let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?; + let object = im.object?; + let prepared = encoded.entry(object).or_insert_with(|| { + let (ext, bytes) = pdfcraft_create::image_file(cos, object).map_err(|_| false)?; + image_bytes = image_bytes.checked_sub(bytes.len()).ok_or(true)?; + Ok((if ext == "jpg" { "jpg" } else { "png" }, Arc::from(bytes))) + }); + over_budget |= matches!(prepared, Err(true)); + let (ext, bytes) = prepared.clone().ok()?; Some(pdfcraft_export::Image { - ext: if ext == "jpg" { "jpg" } else { "png" }, + ext, bytes, rect: visible_rect(im.rect), size: [im.matrix[0].hypot(im.matrix[1]) * unit, im.matrix[2].hypot(im.matrix[3]) * unit], @@ -232,6 +256,9 @@ impl crate::Document { }) .collect(); // The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one. + if over_budget { + truncated.push(i); + } let background = pdfcraft_edit::page_background(cos, i).ok().flatten(); pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background } }) @@ -247,7 +274,8 @@ impl crate::Document { /// [`Self::export_office`], and the pages whose text it had to leave out /// ([`OfficeExport::truncated_pages`]). pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport { - let (pages, truncated_pages) = self.export_pages_checked(); + // RTF leaves pictures out. + let (pages, truncated_pages) = self.export_pages_checked(format != OfficeFormat::Rtf); let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string()); let bytes = match format { OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3d347e6492..47ad482d46 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6645,7 +6645,7 @@ fn exporting_a_jpeg_logo_to_word_keeps_the_image() { assert!(jpeg.starts_with(&[0xFF, 0xD8])); let docx = d.export_office(compare::OfficeFormat::Docx); assert!(docx.windows(21).any(|w| w == b"word/media/image1.jpg"), "overlap {overlaps}"); - assert!(docx.windows(jpeg.len()).any(|w| w == jpeg.as_slice()), "the JPEG is stored unchanged (overlap {overlaps})"); + assert!(docx.windows(jpeg.len()).any(|w| w == &jpeg[..]), "the JPEG is stored unchanged (overlap {overlaps})"); } } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 15e1a8548b..8d9026600d 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -8,5 +8,6 @@ mod document_fonts; mod form_rotation; mod marked_content_isolation; mod ocr_edit; +mod office_export_images; mod private_image_resize; mod private_text_glyphs; diff --git a/crates/engine/tests/it/office_export_images.rs b/crates/engine/tests/it/office_export_images.rs new file mode 100644 index 0000000000..4f2ff618fb --- /dev/null +++ b/crates/engine/tests/it/office_export_images.rs @@ -0,0 +1,43 @@ +//! Office export of pages that draw one image many times. + +use std::sync::Arc; + +use pdfcraft_engine::Session; + +fn pdf(objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +/// One image drawn many times is prepared once: every placement shares its encoded bytes. +#[test] +fn an_image_drawn_many_times_is_encoded_once() { + let placements = 500; + let content = "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(placements); + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{40}\u{80}\u{ff}\nendstream".into(), + ]); + let mut s = Session::new(); + let id = s.open("many.pdf", None, Arc::new(bytes), None).unwrap(); + let pages = s.get(id).unwrap().export_pages(); + let images = &pages[0].images; + assert_eq!(images.len(), placements); + let first = images[0].bytes.as_ptr(); + assert!(!images[0].bytes.is_empty()); + assert!(images.iter().all(|im| im.bytes.as_ptr() == first), "each placement holds its own copy of the image"); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index f3030ad58d..b4ea9e9652 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -35,7 +35,8 @@ pub struct Block { pub struct Image { /// "png" or "jpg". pub ext: &'static str, - pub bytes: Vec, + /// Shared: a picture drawn in many places is held once. + pub bytes: std::sync::Arc<[u8]>, pub rect: [f64; 4], /// Width and height before rotation, in points. pub size: [f64; 2], @@ -932,7 +933,7 @@ mod tests { ], images: vec![Image { ext: "png", - bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(), + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), rect: [72.0, 450.0, 272.0, 600.0], size: [200.0, 150.0], rotation: 0.0, @@ -1374,7 +1375,7 @@ mod tests { /// file in the package, with a content type; JPEG pictures too, inline or placed. #[test] fn word_pictures_resolve_to_media_in_the_package() { - let jpeg = Image { ext: "jpg", bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], ..page().images[0].clone() }; + let jpeg = Image { ext: "jpg", bytes: [0xFF, 0xD8, 0xFF, 0xD9].as_slice().into(), ..page().images[0].clone() }; for placed in [false, true] { let mut p = page(); p.images = vec![jpeg.clone()]; @@ -1401,7 +1402,7 @@ mod tests { fn word_export_bounds_the_number_of_pictures_and_skips_empty_ones() { let mut p = page(); let one = p.images[0].clone(); - p.images = vec![Image { bytes: Vec::new(), ..one.clone() }]; + p.images = vec![Image { bytes: [].as_slice().into(), ..one.clone() }]; p.images.extend(std::iter::repeat_n(one, MAX_MEDIA + 10)); let zip = docx(&[p], "T"); let rels = part(&zip, "word/_rels/document.xml.rels"); diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 1a7250d9e0..886a4bdbde 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -391,7 +391,7 @@ mod tests { ], images: vec![Image { ext: "png", - bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(), + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), rect: [72.0, 150.0, 300.0, 300.0], size: [228.0, 150.0], rotation: 0.0, @@ -619,7 +619,13 @@ mod tests { blocks.push(huge("inf", f64::INFINITY, 200.0 - k as f64)); } let images = (0..40) - .map(|k| Image { ext: "png", bytes: vec![0; 4], rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], size: [1.0, 1.0], rotation: 0.0 }) + .map(|k| Image { + ext: "png", + bytes: [0; 4].as_slice().into(), + rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], + size: [1.0, 1.0], + rotation: 0.0, + }) .collect(); let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None }; let bytes = pptx(&[p], "Hostile"); From f2f86455ddb0aa538bcb9c159b87effe0342aa08 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:09:45 +0000 Subject: [PATCH 101/191] fix(engine): read long underscore runs in one pass 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. --- crates/engine/src/js.rs | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index a2c444bd62..c80537fbad 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -289,6 +289,12 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende .collect() } +#[cfg(test)] +thread_local! { + /// Characters read to tell underscore runs from other words. + static UNDERSCORE_CHARS_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A page's words in reading order, their boxes mapped from view space by `map` (and ordered /// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them). pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> { @@ -296,6 +302,8 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32 let mut last_line = u32::MAX; // A space glyph ends the word before it. let mut gap = false; + // Whether the last word is an underscore run (a form's blank), which other glyphs don't join. + let mut last_under = false; for (i, g) in text.glyphs.iter().enumerate() { let line = text.line_of.get(i).copied().unwrap_or(0); if g.text.trim().is_empty() { @@ -303,10 +311,9 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32 continue; } let under = g.text.chars().all(|c| c == '_'); - let breaks = match words.last() { - None => true, - Some((w, _)) => gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || w.chars().all(|c| c == '_') != under, - }; + #[cfg(test)] + UNDERSCORE_CHARS_READ.with(|n| n.set(n.get() + g.text.len())); + let breaks = words.is_empty() || gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || last_under != under; gap = false; last_line = line; match words.last_mut() { @@ -317,7 +324,10 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32 r[2] = r[2].max(g.rect[2]); r[3] = r[3].max(g.rect[3]); } - _ => words.push((g.text.clone(), g.rect)), + _ => { + words.push((g.text.clone(), g.rect)); + last_under = under; + } } } words @@ -409,6 +419,27 @@ impl Session { mod review_tests { use super::*; + /// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with + /// the word so far without reading the word again. + #[test] + fn a_long_underscore_run_is_read_in_one_pass() { + let n = 10_000; + let glyph = |i: usize, t: &str| pdfcraft_render::TextGlyph { + text: t.into(), + rect: [i as f32, 0.0, i as f32 + 1.0, 10.0], + quad: [[0.0; 2]; 4], + direction: [1.0, 0.0], + }; + let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect(); + glyphs.push(glyph(n, "x")); + let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs }; + UNDERSCORE_CHARS_READ.with(|c| c.set(0)); + let words = words_with(&text, |x, y| [x, y]); + assert_eq!(words.iter().map(|(w, _)| w.len()).collect::>(), [n, 1]); + let read = UNDERSCORE_CHARS_READ.with(std::cell::Cell::get); + assert!(read <= 4 * n, "{read} characters read for {n} glyphs"); + } + #[test] fn duplicate_field_names_diff_against_first_original() { let first = pdfcraft_js::FieldState::new("duplicate", pdfcraft_js::FieldType::Text, vec!["first".into()]); From 2d5c5054e22b77e1bce18bd43cf8398dfc93f2f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:11:06 +0000 Subject: [PATCH 102/191] fix(forms): merge a calculation's read names with a set 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. --- crates/forms/src/calc_order.rs | 11 ++++++++++- crates/forms/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs index e75a05d475..428c1724d0 100644 --- a/crates/forms/src/calc_order.rs +++ b/crates/forms/src/calc_order.rs @@ -122,6 +122,12 @@ pub(crate) fn run_order(doc: &Document, fields: &[Field]) -> (Vec<(usize, bool)> (run, extra) } +#[cfg(test)] +thread_local! { + /// Read names compared while merging a field's reads. + pub(crate) static NAMES_COMPARED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// The form's calculation order (`fields` as [`fields`] reads them), with each calculated field's /// reads and issues, and Fix order's order. pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { @@ -132,8 +138,11 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { .map(|(f, _)| { let mut names = f.actions.calculate.reads(); if let Some(js) = &f.actions.scripts.calculate { + let mut known: HashSet = names.iter().cloned().collect(); for name in crate::af::calculation_reads(js) { - if !names.contains(&name) { + #[cfg(test)] + NAMES_COMPARED.with(|n| n.set(n.get() + 1)); + if known.insert(name.clone()) { names.push(name); } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b2e4994ecf..2b20428650 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2981,3 +2981,20 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { assert_eq!(walk.next(), None); assert_eq!(page_refs(&doc), [page]); } + +/// A Calculate script naming thousands of fields is merged with the field's other reads in one +/// pass, not by searching the names so far for each one. +#[test] +fn a_script_reading_many_fields_merges_its_reads_in_one_pass() { + use crate::calc_order::NAMES_COMPARED; + let mut doc = one_page(); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("total")).unwrap(); + let n = 2_000; + let script: String = (0..n).map(|i| format!("getField(\"f{i}\").value + ")).collect::() + "0"; + set_field_script(&mut doc, "total", "calculate", Some(&format!("event.value = {script};"))).unwrap(); + NAMES_COMPARED.with(|c| c.set(0)); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fields[0].reads.len(), n); + let compared = NAMES_COMPARED.with(std::cell::Cell::get); + assert!(compared <= 4 * n, "{compared} names compared for {n} reads"); +} From b6cb15177484df065f7aa87231cf2e4f63785127 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:12:06 +0000 Subject: [PATCH 103/191] fix(measure): read a stream's XObject resources once when snapping 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. --- crates/measure/src/snap.rs | 14 +++++++++++++- crates/measure/src/tests.rs | 11 +++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 3cf6bbd17e..37ee2975f7 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -250,6 +250,8 @@ impl Walker<'_> { let mut current = None; let mut closures = Vec::new(); let mut path = Geometry::default(); + // Read on the first `Do` and kept: resolving a direct dictionary copies it. + let mut xobjects = None; let point = |m: Matrix, x: f64, y: f64| { let (x, y) = m.apply(x, y); [x, y] @@ -384,7 +386,11 @@ impl Walker<'_> { } "Do" => { if let Some(name) = op.name(0) { - let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)); + let xobjects = xobjects.get_or_insert_with(|| { + #[cfg(test)] + XOBJECT_DICT_READS.with(|n| n.set(n.get() + 1)); + resources.get(b"XObject").map(|o| self.doc.resolve(o)) + }); if let Some(o) = xobjects.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(name)) { let r = o.as_ref(); if r.is_some_and(|r| self.seen.contains(&r)) { @@ -420,6 +426,12 @@ impl Walker<'_> { Ok(()) } } +#[cfg(test)] +thread_local! { + /// Times a content stream's `/XObject` resources were read. + pub(crate) static XOBJECT_DICT_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + pub fn geometry(doc: &Document, page_index: usize) -> Result { let p = crate::page(doc, page_index)?; let resources = p.dict.get(b"Resources").map(|o| doc.resolve(o)); diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 303f9b4386..06bc1e42db 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -403,3 +403,14 @@ fn a_scale_ratio_converts_its_page_side_to_points() { assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}"); } } +/// A content stream reads its `/XObject` resources once, not again (as a copy of a direct +/// dictionary) for every `Do`. +#[test] +fn xobject_resources_are_read_once_per_stream() { + let d = fixture(&"/Missing Do\n".repeat(10_000), "", &[]); + snap::XOBJECT_DICT_READS.with(|c| c.set(0)); + let paths = snap::geometry(&d, 0).unwrap(); + assert!(paths.segments.is_empty()); + let reads = snap::XOBJECT_DICT_READS.with(std::cell::Cell::get); + assert!(reads <= 1, "{reads} reads of the page's XObject resources"); +} From 240d051b4e9973c5d980560657f6377c63f0856b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:12:43 +0000 Subject: [PATCH 104/191] fix(measure): read viewports within bounds when finding a scale 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. --- crates/measure/src/lib.rs | 20 +++++++++++++++++--- crates/measure/src/tests.rs | 13 +++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index 72b6c4b07b..dda45a75ae 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -497,6 +497,15 @@ pub fn list(doc: &Document) -> Listing { } out } +/// Most viewports [`set_scale`] gives a page, and most [`scale_at`] reads. +const MAX_VIEWPORTS: usize = 1024; + +#[cfg(test)] +thread_local! { + /// Numbers read from viewport `/BBox` arrays. + pub(crate) static VIEWPORT_NUMBERS_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Last containing viewport wins, using the first point of the measurement (ISO 32000-2 §12.9). /// Outside every viewport the page measures true size in inches. pub fn scale_at(doc: &Document, page_index: usize, at: Point) -> Result { @@ -510,12 +519,17 @@ pub fn scale_at_or(doc: &Document, page_index: usize, at: Point, per_point: f64, if let Some(o) = p.dict.get(b"VP") { let o = doc.resolve(o); let arr = o.as_array().ok_or_else(|| invalid("invalid page viewports"))?; - for viewport in arr.iter().rev() { + // The last viewports win; a page holds at most MAX_VIEWPORTS of its own. + for viewport in arr.iter().rev().take(MAX_VIEWPORTS) { let o = doc.resolve(viewport); let Some(d) = o.as_dict() else { continue }; if let Some(bbox) = d.get(b"BBox") { let o = doc.resolve(bbox); - let b: Option> = o.as_array().and_then(|a| a.iter().map(|v| doc.resolve(v).as_f64()).collect()); + let b: Option> = o.as_array().filter(|a| a.len() == 4).and_then(|a| { + #[cfg(test)] + VIEWPORT_NUMBERS_READ.with(|n| n.set(n.get() + a.len())); + a.iter().map(|v| doc.resolve(v).as_f64()).collect() + }); if let Some(b) = b.filter(|b| b.len() == 4 && b.iter().all(|v| v.is_finite())) && at[0] >= b[0].min(b[2]) && at[0] <= b[0].max(b[2]) @@ -541,7 +555,7 @@ pub fn set_scale(doc: &mut Document, page_index: usize, bbox: [f64; 4], name: &s Some(o) => doc.resolve(o).as_array().cloned().ok_or_else(|| invalid("invalid page viewports"))?, None => Vec::new(), }; - if list.len() >= 1024 { + if list.len() >= MAX_VIEWPORTS { return Err(invalid("page has too many measurement viewports")); } let mut d = Dict::new(); diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 06bc1e42db..259caeb5f3 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -414,3 +414,16 @@ fn xobject_resources_are_read_once_per_stream() { let reads = snap::XOBJECT_DICT_READS.with(std::cell::Cell::get); assert!(reads <= 1, "{reads} reads of the page's XObject resources"); } +/// Finding the scale under a point reads only well-formed viewport boxes, and only as many +/// viewports as a page can be given, however many a file lists or however long their boxes. +#[test] +fn scale_lookup_reads_viewports_within_bounds() { + let vp = vec!["5 0 R"; 5_000].join(" "); + let long_box = vec!["0"; 2_000].join(" "); + let d = fixture("", &format!("/VP [{vp}]"), &["<< /Type /Viewport /BBox 6 0 R >>", &format!("[{long_box}]")]); + VIEWPORT_NUMBERS_READ.with(|c| c.set(0)); + let scale = scale_at(&d, 0, [10.0, 10.0]).unwrap(); + assert_eq!(scale.unit, "in"); + let read = VIEWPORT_NUMBERS_READ.with(std::cell::Cell::get); + assert!(read <= 4 * 1024, "{read} viewport numbers read"); +} From 05d09de1e337c3dc40bac6286721fe85239e6c8b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:13:45 +0000 Subject: [PATCH 105/191] fix(annot): read a shared page tree Kids array once 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. --- crates/annot/src/lib.rs | 21 ++++++++++++++++----- crates/annot/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 6edfbf9cb7..61dd5ff1cc 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -665,17 +665,20 @@ pub fn page_refs(doc: &Document) -> Result, AnnotError> { /// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, /// never one entry per child, so a child listed many times or shared between nodes is skipped by /// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). -/// Cycles end at the seen check and at most a million nodes are visited. +/// Cycles end at the seen check, a `/Kids` array object shared by several nodes is read once, +/// and at most a million nodes and four million `/Kids` entries are read. struct PageWalk<'a> { doc: &'a Document, seen: std::collections::HashSet, pending: Vec<(std::sync::Arc, usize)>, + /// `/Kids` entries read so far. + examined: usize, } impl<'a> PageWalk<'a> { fn new(doc: &'a Document, top: ObjRef) -> Self { let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); - Self { doc, seen: Default::default(), pending: vec![(top, 0)] } + Self { doc, seen: Default::default(), pending: vec![(top, 0)], examined: 0 } } } @@ -690,8 +693,9 @@ impl Iterator for PageWalk<'_> { }; let node = kid.as_ref(); *next += 1; + self.examined += 1; let Some(node) = node else { continue }; - if self.seen.len() >= 1_000_000 { + if self.seen.len() >= 1_000_000 || self.examined > 4_000_000 { self.pending.clear(); return None; } @@ -700,8 +704,15 @@ impl Iterator for PageWalk<'_> { } let obj = self.doc.get(node); let Some(d) = obj.as_dict() else { continue }; - match d.get(b"Kids").map(|k| self.doc.resolve(k)) { - Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)), + match d.get(b"Kids") { + // A `/Kids` array object shared by several nodes is read under the first: under + // the others it would only list nodes already seen. + Some(Object::Ref(array)) if d.name(b"Type") != Some(b"Page") => { + if self.seen.insert(*array) { + self.pending.push((self.doc.get(*array), 0)); + } + } + Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((self.doc.resolve(kids), 0)), _ => return Some(node), } } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a659966812..44328bbd06 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1504,3 +1504,25 @@ fn typed_signature_uses_nonzero_winding_fill() { assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}"); assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); } + +/// Many page tree nodes sharing one `/Kids` array read it once, not once per node. +#[test] +fn page_walk_reads_a_shared_kids_array_once() { + let mut doc = fixture(); + let root = ObjRef { num: 2, generation: 0 }; + let (first, second) = (ObjRef { num: 3, generation: 0 }, ObjRef { num: 4, generation: 0 }); + let shared = doc.add(Object::Array([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(2_000).collect())); + let nodes: Vec = (0..1_000) + .map(|_| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Pages")); + d.set(b"Kids".to_vec(), Object::Ref(shared)); + Object::Ref(doc.add(d)) + }) + .collect(); + doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(nodes))).unwrap(); + let mut walk = PageWalk::new(&doc, root); + let pages: Vec = walk.by_ref().collect(); + assert_eq!(pages, [first, second]); + assert!(walk.examined <= 10_000, "{} kids entries read", walk.examined); +} From c2e96de0b240f6d41ef340cc50c29d9e74198232 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:14:34 +0000 Subject: [PATCH 106/191] fix(annot): extend each page's annotation list once when adding many links 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. --- crates/annot/src/lib.rs | 11 ++++++++++- crates/annot/src/links.rs | 37 ++++++++++++++++++++++++++++++------- crates/annot/src/tests.rs | 15 +++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 61dd5ff1cc..d1d74ce410 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -728,7 +728,16 @@ fn page_ref(doc: &Document, page: usize) -> Result { fn annots(doc: &Document, page: ObjRef) -> Vec { let obj = doc.get(page); let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() }; - doc.resolve(a).as_array().cloned().unwrap_or_default() + let list = doc.resolve(a).as_array().cloned().unwrap_or_default(); + #[cfg(test)] + ANNOTS_ENTRIES_COPIED.with(|n| n.set(n.get() + list.len())); + list +} + +#[cfg(test)] +thread_local! { + /// Entries copied out of pages' `/Annots` arrays. + static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Replace a page's `/Annots`, writing into the shared array object when it is indirect. diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 4d3eef632b..f11eecbd83 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -232,6 +232,16 @@ fn checked_rect(rect: [f64; 4]) -> Result<[f64; 4], AnnotError> { /// Add a link over `rect` (user space). Returns its index in the page's `/Annots`. pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result { let p = page_ref(doc, page)?; + let obj = new_link(doc, p, rect, action, style)?; + let mut list = annots(doc, p); + list.push(Object::Ref(obj)); + let index = list.len() - 1; + set_annots(doc, p, list)?; + Ok(index) +} + +/// A link annotation object for page `p`, not yet on the page's `/Annots`. +fn new_link(doc: &mut Document, p: ObjRef, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result { let r = checked_rect(rect)?; if matches!(action, LinkAction::Other(_)) { return Err(AnnotError::Invalid("choose a page or a web address".into())); @@ -244,12 +254,7 @@ pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, d.set(b"F".to_vec(), Object::Int(4)); write_style(&mut d, style); write_action(doc, &mut d, action)?; - let obj = doc.add(Object::Dict(d)); - let mut list = annots(doc, p); - list.push(Object::Ref(obj)); - let index = list.len() - 1; - set_annots(doc, p, list)?; - Ok(index) + Ok(doc.add(Object::Dict(d))) } fn link_at(doc: &Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { @@ -414,12 +419,30 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range, String)> { /// Create links for found URLs: `(page, quads, uri)` with quads in user space (one link per /// line rectangle). Returns how many links were added. pub fn add_many(doc: &mut Document, links: &[(usize, Vec<[f64; 4]>, String)], style: &LinkStyle) -> Result { + let pages = page_refs(doc)?; + // Each page's `/Annots` is extended once, with its new links in the order given. + let mut added: Vec<(ObjRef, Vec)> = Vec::new(); + let mut slot: std::collections::HashMap = std::collections::HashMap::new(); let mut n = 0; for (page, rects, uri) in links { + let p = pages.get(*page).copied().ok_or(AnnotError::NoSuchPage(*page))?; + let action = LinkAction::Uri(uri.clone()); for r in rects { - add(doc, *page, *r, &LinkAction::Uri(uri.clone()), style)?; + let obj = new_link(doc, p, *r, &action, style)?; + let k = *slot.entry(p).or_insert_with(|| { + added.push((p, Vec::new())); + added.len() - 1 + }); + if let Some((_, list)) = added.get_mut(k) { + list.push(Object::Ref(obj)); + } n += 1; } } + for (p, new) in added { + let mut list = annots(doc, p); + list.extend(new); + set_annots(doc, p, list)?; + } Ok(n) } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 44328bbd06..cfd80093e1 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1526,3 +1526,18 @@ fn page_walk_reads_a_shared_kids_array_once() { assert_eq!(pages, [first, second]); assert!(walk.examined <= 10_000, "{} kids entries read", walk.examined); } + +/// Adding many links to a page extends its `/Annots` once, not once per link. +#[test] +fn many_links_extend_a_page_annotation_list_once() { + let mut doc = fixture(); + let before = annots(&doc, page_refs(&doc).unwrap()[0]).len(); + let n = 2_000; + let rects: Vec<[f64; 4]> = (0..n).map(|i| [10.0, i as f64 * 0.1, 50.0, i as f64 * 0.1 + 5.0]).collect(); + ANNOTS_ENTRIES_COPIED.with(|c| c.set(0)); + let added = links::add_many(&mut doc, &[(0, rects, "https://example.com".into())], &links::LinkStyle::default()).unwrap(); + let copied = ANNOTS_ENTRIES_COPIED.with(std::cell::Cell::get); + assert_eq!(added, n); + assert_eq!(annots(&doc, page_refs(&doc).unwrap()[0]).len(), before + n); + assert!(copied <= 4 * n, "{copied} annotation entries copied for {n} links"); +} From 8adf8e2d3b4a86aaa688f3347e07560f85d11530 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:58:47 +0000 Subject: [PATCH 107/191] fix(xfdf): read an indirect /F when deciding a comment is locked against import --- crates/xfdf/src/lib.rs | 7 ++++--- crates/xfdf/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 99470b58a1..5c0be52326 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1037,8 +1037,9 @@ const LOCKED: i64 = 128; /// An existing annotation an import must never replace, move or unlock: one the user locked, a /// redaction mark (replacing it would drop the region the user marked to remove), or a form /// widget or field (replacing it would set the field's value and policy around the form setter). -fn import_protected(x: &Dict) -> bool { - x.int(b"F").unwrap_or(0) & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT") +fn import_protected(doc: &Document, x: &Dict) -> bool { + let flags = x.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + flags & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT") } fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result { @@ -1089,7 +1090,7 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result"#; + assert_eq!(import(&mut doc, xfdf).unwrap().comments, 1); + let annots = doc.get(page).as_dict().and_then(|p| p.get(b"Annots").and_then(Object::as_array).cloned()).unwrap(); + assert!(annots.contains(&Object::Ref(note)), "the locked comment was replaced"); + assert_eq!(doc.get(note), before, "the locked comment was changed"); +} From 110b7c678a1658c8b880be1d5f6d255c8f2e2248 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:29:44 +0000 Subject: [PATCH 108/191] fix(a11y): read the enclosing marked content in one step per operator 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. --- crates/a11y/src/alt.rs | 17 ++++++++++++---- crates/a11y/src/headings.rs | 21 +++++++++++-------- crates/a11y/src/lib.rs | 14 +++++++++++++ crates/a11y/src/tests.rs | 40 +++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index e4755ab385..ed91f9e50f 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -183,16 +183,19 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { } /// Where the marked content `ids` is drawn on a page (images, forms and paths). -fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { +pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let props = page_properties(doc, page); let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; + // Whether each open marked-content sequence is one of `ids`, and how many of them are. let mut marks: Vec = Vec::new(); + let mut matching = 0usize; let mut bbox = None; let mut path: Vec<(f64, f64)> = Vec::new(); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { - let inside = marks.iter().any(|m| *m); + crate::count_mark_reads(1); + let inside = matching > 0; let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); match op.op.as_slice() { b"q" => ctm.push(top), @@ -207,9 +210,15 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; } } b"BMC" => marks.push(false), - b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))), + b"BDC" => { + let wanted = bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n)); + matching += usize::from(wanted); + marks.push(wanted); + } b"EMC" => { - marks.pop(); + if marks.pop() == Some(true) { + matching = matching.saturating_sub(1); + } } b"m" | b"l" => { if let Some([x, y]) = op.nums::<2>() { diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 537ed908d7..d3a478cb95 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -182,6 +182,7 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let res = sub(doc, page, b"Resources"); let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties")); let mut metrics: HashMap, Metrics> = HashMap::new(); + // The id each open marked-content sequence is in: its own, else the enclosing one's. let mut stack: Vec> = Vec::new(); let mut out: HashMap = HashMap::new(); // Where each id's last glyph ended, in user space. @@ -191,16 +192,20 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let mut unsaved = 0usize; let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { - let id = stack.iter().rev().find_map(|m| *m); + crate::count_mark_reads(1); + let id = stack.last().copied().flatten(); match op.op.as_slice() { - b"BMC" => stack.push(None), - b"BDC" => stack.push(match op.operands.get(1) { - Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int), - Some(Object::Name(n)) => { - props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))) + b"BMC" => stack.push(id), + b"BDC" => stack.push( + match op.operands.get(1) { + Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int), + Some(Object::Name(n)) => { + props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))) + } + _ => None, } - _ => None, - }), + .or(id), + ), b"EMC" => { stack.pop(); } diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index ff81cc4c97..22fac756ed 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -21,6 +21,20 @@ mod report; mod structure; mod tags; +#[cfg(test)] +thread_local! { + /// Marked-content stack entries read to decide which marks enclose an operator, for the tests + /// that keep that constant per operator. + static MARK_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` marked-content stack reads (tests only). +#[inline] +fn count_mark_reads(_n: usize) { + #[cfg(test)] + MARK_READS.with(|c| c.set(c.get().saturating_add(_n))); +} + pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt}; pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; pub use autotag_fields::tag_form_fields; diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 15ca643e83..b5eb547420 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1023,6 +1023,46 @@ fn glyph_by_tm(text: &str, size: f64, y: f64) -> String { ops + " ET" } +/// Marked-content stack reads while `f` runs. +fn mark_reads(f: impl FnOnce()) -> usize { + MARK_READS.with(|c| c.set(0)); + f(); + MARK_READS.with(|c| c.get()) +} + +/// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside. +fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict { + use pdfcraft_cos::{Dict, Stream}; + let mut data = "/Figure << /MCID 0 >> BDC ".repeat(1); + data.push_str(&"/X BMC ".repeat(depth)); + data.push_str(&"0 0 1 1 re f ".repeat(ops)); + data.push_str(&"EMC ".repeat(depth + 1)); + let content = doc.add(Stream::flate(Dict::new(), data.as_bytes())); + [(b"Contents".to_vec(), Object::Ref(content))].into_iter().collect() +} + +#[test] +fn marked_text_reads_its_enclosing_id_in_constant_steps() { + let mut doc = Document::new_empty(); + let page = deeply_nested_page(&mut doc, 5_000, 5_000); + let reads = mark_reads(|| { + crate::headings::mcid_texts(&doc, &page); + }); + // About 15 000 operators; a scan of the stack per operator reads millions of entries. + assert!(reads <= 4 * 15_000, "{reads} stack reads"); +} + +#[test] +fn figure_bounds_read_the_marked_content_state_in_constant_steps() { + let mut doc = Document::new_empty(); + let page = deeply_nested_page(&mut doc, 5_000, 5_000); + // No mark encloses figure 7, so a scan of the stack per operator reads all of it. + let reads = mark_reads(|| { + crate::alt::content_bbox(&doc, &page, &std::collections::HashSet::from([7])); + }); + assert!(reads <= 4 * 15_000, "{reads} stack reads"); +} + /// One tagged page whose H1 headings show `contents` (one marked-content sequence each). fn heading_page(contents: &[String]) -> Document { let marked: Vec = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect(); From 38211588c754da1a7319dc5fa0f2b0bf54069815 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:32:02 +0000 Subject: [PATCH 109/191] fix(a11y): find every figure's bounds on a page in one pass 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. --- crates/a11y/src/alt.rs | 86 ++++++++++++++++++++++++++++++---------- crates/a11y/src/lib.rs | 2 + crates/a11y/src/tests.rs | 23 ++++++++++- 3 files changed, 89 insertions(+), 22 deletions(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index ed91f9e50f..fe8eb292c7 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -182,16 +182,47 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { } } -/// Where the marked content `ids` is drawn on a page (images, forms and paths). -pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { +/// An open marked-content sequence while bounds are gathered: its id, when it is one of those asked +/// for, and the bounds of what has been drawn in it so far. +struct Mark { + id: Option, + drawn: Option<[f64; 4]>, +} + +/// Adds `b` to what the innermost open sequence has drawn. +fn draw(marks: &mut [Mark], b: [f64; 4]) { + if let Some(m) = marks.last_mut() { + m.drawn = Some(union(m.drawn, b)); + } +} + +/// Closes the innermost open sequence: what it drew counts for its id and for the sequence around it. +fn close(marks: &mut Vec, matching: &mut usize, out: &mut HashMap) { + let Some(m) = marks.pop() else { return }; + if m.id.is_some() { + *matching = matching.saturating_sub(1); + } + let Some(b) = m.drawn else { return }; + if let Some(id) = m.id { + let e = out.entry(id).or_insert(b); + *e = union(Some(*e), b); + } + draw(marks, b); +} + +/// Where each of the marked content `ids` is drawn on a page (images, forms and paths), in one pass +/// over the page. What a sequence draws counts for every sequence of `ids` around it too. +pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet) -> HashMap { let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let props = page_properties(doc, page); + #[cfg(test)] + crate::BOUNDS_PASSES.with(|c| c.set(c.get() + 1)); let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; - // Whether each open marked-content sequence is one of `ids`, and how many of them are. - let mut marks: Vec = Vec::new(); + // The open marked-content sequences, and how many of them are one of `ids`. + let mut marks: Vec = Vec::new(); let mut matching = 0usize; - let mut bbox = None; + let mut out = HashMap::new(); let mut path: Vec<(f64, f64)> = Vec::new(); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { crate::count_mark_reads(1); @@ -209,17 +240,13 @@ pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> O *t = m.then(&top); } } - b"BMC" => marks.push(false), + b"BMC" => marks.push(Mark { id: None, drawn: None }), b"BDC" => { - let wanted = bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n)); - matching += usize::from(wanted); - marks.push(wanted); - } - b"EMC" => { - if marks.pop() == Some(true) { - matching = matching.saturating_sub(1); - } + let id = bdc_mcid(doc, &props, &op).filter(|n| ids.contains(n)); + matching += usize::from(id.is_some()); + marks.push(Mark { id, drawn: None }); } + b"EMC" => close(&mut marks, &mut matching, &mut out), b"m" | b"l" => { if let Some([x, y]) = op.nums::<2>() { path.push(top.apply(x, y)); @@ -240,7 +267,7 @@ pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> O b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"n" => { if inside && !matches!(op.op.as_slice(), b"n") { for (x, y) in &path { - bbox = Some(union(bbox, [*x, *y, *x, *y])); + draw(&mut marks, [*x, *y, *x, *y]); } } path.clear(); @@ -260,13 +287,16 @@ pub(crate) fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> O } _ => top.bbox([0.0, 0.0, 1.0, 1.0]), }; - bbox = Some(union(bbox, unit)); + draw(&mut marks, unit); } - b"BI" if inside => bbox = Some(union(bbox, top.bbox([0.0, 0.0, 1.0, 1.0]))), + b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])), _ => {} } } - bbox + while !marks.is_empty() { + close(&mut marks, &mut matching, &mut out); + } + out } /// The figures, in document order. @@ -275,14 +305,28 @@ pub fn figures(doc: &Document) -> Vec
{ let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let pages = pdfcraft_model::pages(doc); let index: HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); - elements(doc) + let figures: Vec<(Element, Option)> = elements(doc) .into_iter() .filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure")) .map(|e| { let page = e.page.and_then(|p| index.get(&p).copied()); + (e, page) + }) + .collect(); + let mut wanted: HashMap> = HashMap::new(); + for (e, page) in &figures { + if let Some(p) = page { + wanted.entry(*p).or_default().extend(mcids(&e.dict)); + } + } + let bounds: HashMap> = + wanted.into_iter().filter_map(|(p, ids)| Some((p, content_bboxes(doc, &pages.get(p)?.dict, &ids)))).collect(); + figures + .into_iter() + .map(|(e, page)| { let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())); - let ids: HashSet = mcids(&e.dict).into_iter().collect(); - let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids)); + let on_page = page.and_then(|p| bounds.get(&p)); + let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b)))); Figure { obj: e.object, page, alt, bbox } }) .collect() diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 22fac756ed..21d8ea5820 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -26,6 +26,8 @@ thread_local! { /// Marked-content stack entries read to decide which marks enclose an operator, for the tests /// that keep that constant per operator. static MARK_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Page content passes made to find figure bounds. + static BOUNDS_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Counts `n` marked-content stack reads (tests only). diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index b5eb547420..a24f59edb2 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1058,11 +1058,32 @@ fn figure_bounds_read_the_marked_content_state_in_constant_steps() { let page = deeply_nested_page(&mut doc, 5_000, 5_000); // No mark encloses figure 7, so a scan of the stack per operator reads all of it. let reads = mark_reads(|| { - crate::alt::content_bbox(&doc, &page, &std::collections::HashSet::from([7])); + crate::alt::content_bboxes(&doc, &page, &std::collections::HashSet::from([7])); }); assert!(reads <= 4 * 15_000, "{reads} stack reads"); } +#[test] +fn figure_bounds_take_one_pass_over_each_page() { + let n = 50; + let content: Vec = (0..n).map(|i| format!("/Figure << /MCID {i} >> BDC {i} 0 1 1 re f EMC")).collect(); + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 6 + i)).collect(); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(), + stream(&content.join(" ")), + format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")), + ]; + objs.extend((0..n).map(|i| format!("<< /S /Figure /Pg 3 0 R /K {i} >>"))); + let doc = pdf(&objs, ""); + BOUNDS_PASSES.with(|c| c.set(0)); + let f = figures(&doc); + assert_eq!(BOUNDS_PASSES.with(|c| c.get()), 1, "page passes"); + assert_eq!(f.len(), n); + assert!(f.iter().enumerate().all(|(i, f)| f.bbox == Some([i as f64, 0.0, i as f64 + 1.0, 1.0]))); +} + /// One tagged page whose H1 headings show `contents` (one marked-content sequence each). fn heading_page(contents: &[String]) -> Document { let marked: Vec = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect(); From 1cc392e5b04ada01b130236c6d101636e8bb62da Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:33:28 +0000 Subject: [PATCH 110/191] fix(fonts): cut ToUnicode destinations to the CMap limit 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. --- crates/a11y/src/headings.rs | 12 ++++++------ crates/fonts/src/pdf.rs | 22 ++++++++++++++++++++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index d3a478cb95..41c749ed3c 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -285,17 +285,17 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { }; for (code, len) in m.codes(&s.bytes) { let trm = tm.then(&gs.ctm); + // A glyph's text is only built while its id still takes more. if let Some(id) = id + && out.get(&id).is_none_or(|t| t.len() < MAX_TITLE * 4) && let Some(glyph) = m.text_of(code) { let text = out.entry(id).or_default(); - if text.len() < MAX_TITLE * 4 { - let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale)); - if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') { - text.push(' '); - } - text.push_str(&glyph); + let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale)); + if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') { + text.push(' '); } + text.push_str(&glyph); } let advance = m.width(code) * gs.size + gs.char_spacing + if m.is_space(code, len) { gs.word_spacing } else { 0.0 }; tm = Matrix::translate(advance * gs.scale, 0.0).then(&tm); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0aaed6939e..0badcfc3a9 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -905,9 +905,13 @@ impl Metrics { } } -/// The UTF-16 code units of CMap hex bytes. +/// Most UTF-16 units kept from one ToUnicode destination: the CMap format allows destinations of up +/// to 512 bytes, and every glyph a sequential range covers builds its text from the whole of one. +const MAX_DESTINATION_UNITS: usize = 256; + +/// The UTF-16 code units of CMap hex bytes, at most [`MAX_DESTINATION_UNITS`] of them. fn code_units(b: &[u8]) -> Vec { - b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect() + b.chunks(2).take(MAX_DESTINATION_UNITS).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect() } /// UTF-16BE (with surrogates) from CMap hex bytes. @@ -1262,6 +1266,20 @@ mod tests { use super::*; + /// Every glyph of a sequential range builds its text from the destination, so a long one is cut + /// to the CMap limit once, when it is read. + #[test] + fn unicode_destinations_are_cut_to_the_cmap_limit() { + let long = "0041".repeat(4096); + let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar"); + let mut u = Unicode::default(); + parse_to_unicode(cmap.as_bytes(), &mut u); + for code in [0x10, 0x20, 0x30, 0x40] { + let text = u.text(code).unwrap_or_default(); + assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len()); + } + } + /// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one. #[test] fn low_code_tables_end_with_the_last_covered_code() { From 75c4275810edafaa3b607d136b18569d18c797cd Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:35:00 +0000 Subject: [PATCH 111/191] fix(fonts): look up CID and width ranges by binary search 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. --- crates/fonts/src/pdf.rs | 157 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 141 insertions(+), 16 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0badcfc3a9..4a118a07d3 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -33,9 +33,9 @@ pub struct Metrics { widths: Vec, /// Composite fonts: CID → width, and CID ranges with one width. cid_widths: HashMap, - cid_ranges: Vec<(u32, u32, f64)>, + cid_ranges: Spans, /// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`). - cid_map: Vec<(u32, u32, u32)>, + cid_map: Spans, default: f64, /// Composite fonts with an explicit `/DW`: `/W` may leave out the glyphs that have that /// width (Chrome and Skia do), so a CID missing from `/W` is not a missing glyph. @@ -598,8 +598,8 @@ impl Metrics { first: 0, widths: Vec::new(), cid_widths: HashMap::new(), - cid_ranges: Vec::new(), - cid_map: Vec::new(), + cid_ranges: Spans::default(), + cid_map: Spans::default(), default: 500.0, explicit_default: false, std14: Some(crate::Std14::Helvetica), @@ -685,7 +685,7 @@ impl Metrics { if !spaces.is_empty() { m.codes = Codes::Ranges(spaces); } - m.cid_map = cids; + m.cid_map = Spans::new(cids); } let desc = font .get(b"DescendantFonts") @@ -698,6 +698,7 @@ impl Metrics { m.explicit_default = true; } if let Some(w) = desc.get(b"W").map(|w| doc.resolve(w)).and_then(|w| w.as_array().cloned()) { + let mut ranges = Vec::new(); let mut i = 0; while i < w.len() { let Some(c0) = doc.resolve(&w[i]).as_f64().map(|v| v as u32) else { break }; @@ -714,13 +715,16 @@ impl Metrics { } Some(c1) => { if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) { - m.cid_ranges.push((c0, c1 as u32, v)); + if ranges.len() < MAX_CMAP_ENTRIES { + ranges.push((c0, c1 as u32, v)); + } } i += 3; } None => break, } } + m.cid_ranges = Spans::new(ranges); } descriptor = dict(doc, desc.get(b"FontDescriptor")); } else { @@ -791,14 +795,14 @@ impl Metrics { if self.cid_map.is_empty() { return code; } - self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo)) + self.cid_map.find(code).map_or(0, |(lo, _, c)| c.saturating_add(code.saturating_sub(*lo))) } /// The advance of `code` in text space per unit of font size. pub fn width(&self, code: u32) -> f64 { if self.composite { let cid = self.cid(code); - let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.iter().find(|(a, b, _)| cid >= *a && cid <= *b).map(|r| r.2)); + let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.find(cid).map(|r| r.2)); return w.unwrap_or(self.default) * self.scale; } if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) { @@ -858,7 +862,7 @@ impl Metrics { // A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for // those, only the subset's program says whether the glyph is there. return self.cid_widths.contains_key(&cid) - || self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b) + || self.cid_ranges.find(cid).is_some() || !self.subset || self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1) // No readable TrueType program to ask (e.g. CFF or missing): an explicit /DW @@ -958,16 +962,69 @@ fn add_range(out: &mut Unicode, budget: &mut usize, seq: &mut u32, lo: u32, hi: true } +#[cfg(test)] +thread_local! { + /// Code span records read by CID and width lookups, for the tests that keep a lookup logarithmic. + static SPAN_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts one span record read (tests only). +#[inline] +fn count_span_reads() { + #[cfg(test)] + SPAN_READS.with(|c| c.set(c.get().saturating_add(1))); +} + +/// Code spans `(lo, hi, value)` in declaration order, where the first-declared span covering a code +/// wins, indexed so that a lookup is one binary search. +#[derive(Clone, Debug, Default, PartialEq)] +struct Spans { + spans: Vec<(u32, u32, T)>, + /// The span covering each stretch of codes; see [`stretches`]. + cover: Vec<(u64, Option)>, +} + +impl Spans { + fn new(spans: Vec<(u32, u32, T)>) -> Self { + let bounds: Vec<(u32, u32)> = spans.iter().map(|s| (s.0, s.1)).collect(); + Spans { cover: stretches(&bounds, false), spans } + } + + fn is_empty(&self) -> bool { + self.spans.is_empty() + } + + /// The first-declared span covering `code`. + fn find(&self, code: u32) -> Option<&(u32, u32, T)> { + let step = self.cover.partition_point(|&(start, _)| { + count_span_reads(); + start <= u64::from(code) + }); + let &(_, record) = self.cover.get(step.checked_sub(1)?)?; + self.spans.get(record?) + } +} + /// For each stretch of codes, from its first code on, the latest-declared range covering it (`None` /// where no range does), sorted by code; a lookup is one binary search over it. fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option)> { + let spans: Vec<(u32, u32)> = ranges.iter().map(|r| (r.lo, r.hi)).collect(); + stretches(&spans, true) +} + +/// For each stretch of codes, from its first code on, the span `(lo, hi)` covering it (`None` where +/// none does), sorted by code: the latest-declared one when `later_wins`, else the first-declared. +/// At most `u32::MAX` spans are indexed. +fn stretches(spans: &[(u32, u32)], later_wins: bool) -> Vec<(u64, Option)> { + // Heap keys, largest for the winning record; the mapping is its own inverse. + let key = |record: u32| if later_wins { record } else { !record }; // Openings (first code, record) and closings (one past the last code), each in code order. The cover // can only change at one of their codes, so the sweep visits those codes in order. - let mut opens: Vec<(u32, u32)> = ranges.iter().enumerate().map(|(i, r)| (r.lo, i as u32)).collect(); + let mut opens: Vec<(u32, u32)> = spans.iter().zip(0..u32::MAX).map(|(&(lo, _), i)| (lo, i)).collect(); opens.sort_unstable(); - let mut closes: Vec = ranges.iter().map(|r| u64::from(r.hi) + 1).collect(); + let mut closes: Vec = spans.iter().take(opens.len()).map(|&(_, hi)| u64::from(hi) + 1).collect(); closes.sort_unstable(); - // Records that have opened and not yet closed; the top is the latest declared one. + // Keys of the records that have opened and not yet closed; the top is the winning one. let mut open: BinaryHeap = BinaryHeap::new(); let mut cover: Vec<(u64, Option)> = Vec::new(); let (mut next_open, mut next_close) = (0, 0); @@ -981,16 +1038,16 @@ fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option)> { while let Some(&(lo, record)) = opens.get(next_open) && u64::from(lo) <= p { - open.push(record); + open.push(key(record)); next_open += 1; } while closes.get(next_close).is_some_and(|&end| end <= p) { next_close += 1; } - while open.peek().is_some_and(|&top| ranges.get(top as usize).is_none_or(|r| u64::from(r.hi) < p)) { + while open.peek().is_some_and(|&top| spans.get(key(top) as usize).is_none_or(|&(_, hi)| u64::from(hi) < p)) { open.pop(); } - let winner = open.peek().map(|&top| top as usize); + let winner = open.peek().map(|&top| key(top) as usize); if cover.last().map(|&(_, w)| w) != Some(winner) { cover.push((p, winner)); } @@ -1266,6 +1323,74 @@ mod tests { use super::*; + /// Span records read while `f` runs. + fn span_reads(f: impl FnOnce()) -> usize { + SPAN_READS.with(|c| c.set(0)); + f(); + SPAN_READS.with(|c| c.get()) + } + + /// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`. + fn composite(doc: &mut Document, encoding: &str, w: Vec, to_unicode: &str) -> Dict { + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"W".to_vec(), Object::Array(w)); + let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes())))); + let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes())))); + font( + doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", enc), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", tu), + ], + ) + } + + /// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all. + #[test] + fn widths_of_a_code_no_range_covers_take_few_steps() { + let mut doc = Document::new_empty(); + let w: Vec = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect(); + let f = composite(&mut doc, "", w, ""); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.width(4), 0.5); + let reads = span_reads(|| { + for _ in 0..1000 { + assert_eq!(m.width(3), 1.0); + } + }); + assert!(reads <= 1000 * 64, "{reads} span reads"); + } + + /// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it. + #[test] + fn overlapping_spans_resolve_to_the_first_declared() { + let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]); + let found = |code| spans.find(code).map(|s| s.2); + let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))]; + for (code, value) in want { + assert_eq!(found(code), value, "code {code}"); + } + } + + /// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map. + #[test] + fn many_unicode_entries_look_up_their_cids_in_few_steps() { + let n = 2000; + let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect(); + let encoding = format!("1 begincodespacerange <0000> endcodespacerange {n} begincidchar {cidchars} endcidchar"); + let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect(); + let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar"); + let mut doc = Document::new_empty(); + let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode); + let reads = span_reads(|| { + Metrics::from_dict(&doc, &f); + }); + assert!(reads <= n * 64, "{reads} span reads"); + } + /// Every glyph of a sequential range builds its text from the destination, so a long one is cut /// to the CMap limit once, when it is read. #[test] @@ -1736,7 +1861,7 @@ mod tests { let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); let mut m = Metrics::fallback(); - (_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange"); + m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange").1); assert_eq!(m.cid(5), u32::MAX); let mut doc = Document::new_empty(); let mut enc = Dict::new(); From 5609bcbe37728d65186303b4c8817dbdb8e8c3b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:36:06 +0000 Subject: [PATCH 112/191] fix(fonts): wrap text measuring each word once 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. --- crates/fonts/src/lib.rs | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index ec103ed5f0..37a2ea74d8 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -88,17 +88,28 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec { } /// [`wrap`], measuring with `measure` (for example [`cjk_width`]). +/// +/// A line measures as the sum of its words, the spaces between them and the characters of a broken +/// word, each measured once, so the work stays linear in `text` however much fits on a line. pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec { let mut lines = Vec::new(); + let space = measure(" ", size); + let mut buf = [0u8; 4]; // A CRLF pair is one break, a lone CR or LF is one break each. for para in text.replace("\r\n", "\n").split(['\n', '\r']) { let mut line = String::new(); + let mut line_width = 0.0; // No word is on the line yet; an empty word (a leading space) still starts it. let mut fresh = true; for word in para.split(' ') { - let candidate = if fresh { word.to_string() } else { format!("{line} {word}") }; - if measure(&candidate, size) <= width || fresh && measure(word, size) <= width { - line = candidate; + let word_width = measure(word, size); + let candidate = if fresh { word_width } else { line_width + space + word_width }; + if candidate <= width { + if !fresh { + line.push(' '); + } + line.push_str(word); + line_width = candidate; fresh = false; continue; } @@ -108,11 +119,15 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) } else { lines.push(std::mem::take(&mut line)); } + line_width = 0.0; for ch in word.chars() { - if !line.is_empty() && measure(&format!("{line}{ch}"), size) > width { + let ch_width = measure(ch.encode_utf8(&mut buf), size); + if !line.is_empty() && line_width + ch_width > width { lines.push(std::mem::take(&mut line)); + line_width = 0.0; } line.push(ch); + line_width += ch_width; } fresh = line.is_empty(); } @@ -442,4 +457,20 @@ mod tests { let lines = wrap_with("日本語のテキストは文字単位で折り返されます", 10.0, 45.0, cjk_width); assert!(lines.len() > 3 && lines.iter().all(|l| cjk_width(l, 10.0) <= 45.0), "{lines:?}"); } + + /// Wrapping measures each word and character a bounded number of times, however many fit on a line. + #[test] + fn wrapping_measures_each_word_a_few_times() { + let measured = std::cell::Cell::new(0usize); + let measure = |s: &str, size: f64| { + measured.set(measured.get() + s.len()); + helvetica_width(s, size) + }; + let text = format!("{} {}", "word ".repeat(5_000), "x".repeat(5_000)); + let lines = wrap_with(&text, 1.0, 13_000.0, measure); + assert_eq!(lines.len(), 2, "the words fit on one line, the long word on another"); + assert!(measured.get() <= 4 * text.len(), "{} bytes measured", measured.get()); + let narrow = wrap_with(&text, 10.0, 100.0, helvetica_width); + assert!(narrow.iter().all(|l| helvetica_width(l, 10.0) <= 100.0), "{narrow:?}"); + } } From fcedb156893891c718fca03dd27c9a04ba5ae110 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:36:48 +0000 Subject: [PATCH 113/191] fix(export): find font size and colour slots through a map 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. --- crates/export/src/lib.rs | 75 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index f3030ad58d..8c3999712f 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -8,6 +8,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::collections::HashMap; + mod pptx; mod zip; @@ -85,14 +87,33 @@ enum Item<'a> { PageBreak, } +#[cfg(test)] +thread_local! { + /// Entries read to find a font size's or a colour's slot, for the tests that keep it constant. + static LOOKUP_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` entries read by a lookup (tests only). +#[inline] +fn count_lookup_steps(_n: usize) { + #[cfg(test)] + LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n))); +} + /// The body text size: the size most of the document's characters are set in. fn body_size(pages: &[Page]) -> f64 { + // Sizes in first-seen order (which breaks ties), and where each one is in it. let mut by: Vec<(i64, usize)> = Vec::new(); + let mut slot: HashMap = HashMap::new(); for b in pages.iter().flat_map(|p| &p.blocks) { let k = (b.size * 2.0).round() as i64; - match by.iter_mut().find(|e| e.0 == k) { - Some(e) => e.1 += b.text.len(), - None => by.push((k, b.text.len())), + count_lookup_steps(1); + let i = *slot.entry(k).or_insert_with(|| { + by.push((k, 0)); + by.len() - 1 + }); + if let Some(e) = by.get_mut(i) { + e.1 = e.1.saturating_add(b.text.len()); } } by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) @@ -831,9 +852,12 @@ pub fn rtf(pages: &[Page]) -> String { let items = items(pages); // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. let mut colors: Vec<[u8; 3]> = Vec::new(); + let mut slot: HashMap<[u8; 3], usize> = HashMap::new(); let mut note = |c: [f64; 3]| { let c = rgb8(c); - if c != [0, 0, 0] && !colors.contains(&c) { + count_lookup_steps(1); + if c != [0, 0, 0] && !slot.contains_key(&c) { + slot.insert(c, colors.len()); colors.push(c); } }; @@ -847,7 +871,8 @@ pub fn rtf(pages: &[Page]) -> String { // `\cfN` for a colour (nothing for black, which keeps the default). let cf = |c: [f64; 3]| { let c = rgb8(c); - colors.iter().position(|e| *e == c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) + count_lookup_steps(1); + slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) }; let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); if !colors.is_empty() { @@ -911,6 +936,46 @@ pub fn rtf(pages: &[Page]) -> String { mod tests { use super::*; + /// A page of `n` one-word blocks, each in its own size and colour. + fn varied_page(n: usize) -> Page { + let blocks = (0..n) + .map(|i| Block { + text: "word".into(), + rect: [72.0, i as f64, 100.0, i as f64 + 1.0], + size: 4.0 + i as f64 * 0.5, + bold: false, + italic: false, + color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5], + rotation: 0.0, + }) + .collect(); + Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None } + } + + fn lookup_steps(f: impl FnOnce()) -> usize { + LOOKUP_STEPS.with(|c| c.set(0)); + f(); + LOOKUP_STEPS.with(|c| c.get()) + } + + #[test] + fn many_distinct_sizes_are_counted_in_constant_steps_each() { + let pages = [varied_page(3000)]; + let steps = lookup_steps(|| { + body_size(&pages); + }); + assert!(steps <= 2 * 3000, "{steps} steps"); + } + + #[test] + fn many_distinct_colours_take_constant_steps_each() { + let pages = [varied_page(3000)]; + let mut out = String::new(); + let steps = lookup_steps(|| out = rtf(&pages)); + assert!(steps <= 4 * 3000, "{steps} steps"); + assert!(out.contains("\\cf3000 ") || out.contains("\\cf3000\\"), "the last colour has the last slot"); + } + fn page() -> Page { let b = |t: &str, y: f64, size: f64, bold: bool| Block { text: t.into(), From dba51fc8237dd04b5cd594d5fa56106e902d74b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:37:39 +0000 Subject: [PATCH 114/191] fix(optimize): refuse a JPEG whose own size differs from its dictionary's 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. --- crates/optimize/src/images.rs | 40 ++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 6182b08922..1d8a59e253 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -227,6 +227,19 @@ fn is_dct(s: &Stream) -> Option { } } +#[cfg(test)] +thread_local! { + /// Pixels decoded from JPEG data, for the test that keeps a mismatched JPEG from decoding. + static DECODED_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` decoded pixels (tests only). +#[inline] +fn count_decoded(_n: u64) { + #[cfg(test)] + DECODED_PIXELS.with(|c| c.set(c.get().saturating_add(_n))); +} + /// 8-bit samples of an image (`n` components), decoded. pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); @@ -235,7 +248,15 @@ pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { } let bytes = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)?; if is_dct(s)? { - let img = image::load_from_memory_with_format(&s.raw, image::ImageFormat::Jpeg).ok()?; + use image::ImageDecoder as _; + // The JPEG's own size is what gets decoded: one other than the dictionary's is refused from + // its header, before any pixels are. + let decoder = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&s.raw[..])).ok()?; + if decoder.dimensions() != (w, h) { + return None; + } + let img = image::DynamicImage::from_decoder(decoder).ok()?; + count_decoded(u64::from(img.width()) * u64::from(img.height())); // Adobe CMYK/YCCK JPEGs aren't handled (the colour space check excludes them). let v = if n == 1 { img.into_luma8().into_raw() } else { img.into_rgb8().into_raw() }; (v.len() == bytes).then_some(v) @@ -432,4 +453,21 @@ mod dimension_tests { let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]); assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4])); } + + /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. + #[test] + fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() { + let data = jpeg(&vec![128; 1000 * 1000], 1, 1000, 1000, 50).unwrap_or_default(); + let stream = |w: i64, h: i64| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(w)); + dict.set(b"Height".to_vec(), Object::Int(h)); + dict.set(b"Filter".to_vec(), Object::name("DCTDecode")); + Stream::from_raw(dict, data.clone()) + }; + DECODED_PIXELS.with(|c| c.set(0)); + assert!(pixels(&stream(10, 10), 1).is_none()); + assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded"); + assert_eq!(pixels(&stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000)); + } } From 6b62585339b351d1bf8827377aa01117d5a1b4f7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:39:09 +0000 Subject: [PATCH 115/191] fix(optimize): bound the search for fragment runs on a page 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. --- crates/optimize/src/fragments.rs | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index caf617f717..44e1655460 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -29,6 +29,19 @@ pub const MIN_FRAGMENTS: usize = 4; /// Most pixels a merged image may have (memory). const MAX_PIXELS: u64 = 80_000_000; +/// Fragment groups a page's search for runs may read per content operator, and at least. A run +/// that is not one picture is searched again from each of its groups, so without a limit images +/// drawn over one another take time quadratic in their number; past it, the rest of the page is +/// left as it is. +const SCAN_PER_OP: usize = 16; +const MIN_SCAN: usize = 1 << 16; + +#[cfg(test)] +thread_local! { + /// Fragment groups read, for the test that keeps a page's search linear in its content. + static GROUP_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// One fragment: its image, its box in the coordinates the run is drawn in, and its size. struct Fragment { image: ObjRef, @@ -40,6 +53,8 @@ struct Fragment { /// The ops `q`, one or more `cm`, `Do`, `Q` starting at `i`: the index after them, the matrix /// and the name drawn. fn group_at(ops: &[Op], i: usize) -> Option<(usize, Matrix, Vec)> { + #[cfg(test)] + GROUP_READS.with(|c| c.set(c.get().saturating_add(1))); if !ops.get(i)?.is("q") { return None; } @@ -226,7 +241,13 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & let ops = parsed.ops; let mut out: Vec = Vec::with_capacity(ops.len()); let (mut i, mut merged, mut k) = (0, 0, 0); + let budget = ops.len().saturating_mul(SCAN_PER_OP).max(MIN_SCAN); + let mut scanned = 0usize; while i < ops.len() { + if scanned > budget { + out.extend(ops.get(i..).unwrap_or_default().iter().cloned()); + break; + } // The longest run of fragment groups from here with one colour space. let mut run = Vec::new(); let mut kind: Option<(Object, usize)> = None; @@ -245,10 +266,12 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & run.push(Fragment { image, rect: [e, f, e + a, f + d], width, height }); j = next; } + scanned = scanned.saturating_add(run.len()).saturating_add(1); // Not one picture as a whole: the rows drawn one under the other from the start may be. if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) { run = drawn_in_sequence(&ops, i, &xobjects, doc); j = run_end(&ops, i, run.len()); + scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2); } let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else { // Not a run: keep this op and look again from the next one. @@ -304,6 +327,8 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, pro #[cfg(test)] mod tests { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + use super::*; #[test] @@ -318,4 +343,34 @@ mod tests { ]; assert!(merge(&Document::new_empty(), &run, 3).is_none()); } + + /// Many images drawn on top of each other are not one picture; looking for runs among them reads + /// a bounded number of groups per operator, not the rest of the page from every group. + #[test] + fn rejected_runs_read_a_bounded_number_of_groups() { + let mut doc = Document::new_empty(); + let mut image = Dict::new(); + for (k, v) in + [("Subtype", Object::name("Image")), ("Width", Object::Int(1)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))] + { + image.set(k.as_bytes().to_vec(), v); + } + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0]))); + let groups = 4000; + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(groups).as_bytes()))); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Contents".to_vec(), Object::Ref(content)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + let page = doc.add(Object::Dict(page)); + GROUP_READS.with(|c| c.set(0)); + let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true); + assert_eq!(merged.ok(), Some(0)); + let reads = GROUP_READS.with(|c| c.get()); + assert!(reads <= 40 * 4 * groups, "{reads} group reads"); + } } From 3e968c8aa24adfb729c6d45a3c98abf5fed07027 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:36:35 +0000 Subject: [PATCH 116/191] chore(a11y, fonts): satisfy clippy in the CID width ranges and a test helper --- crates/a11y/src/tests.rs | 2 +- crates/fonts/src/pdf.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index a24f59edb2..ad57477364 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1033,7 +1033,7 @@ fn mark_reads(f: impl FnOnce()) -> usize { /// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside. fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict { use pdfcraft_cos::{Dict, Stream}; - let mut data = "/Figure << /MCID 0 >> BDC ".repeat(1); + let mut data = "/Figure << /MCID 0 >> BDC ".to_string(); data.push_str(&"/X BMC ".repeat(depth)); data.push_str(&"0 0 1 1 re f ".repeat(ops)); data.push_str(&"EMC ".repeat(depth + 1)); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 4a118a07d3..3ddd16a9b4 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -714,10 +714,10 @@ impl Metrics { i += 2; } Some(c1) => { - if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) { - if ranges.len() < MAX_CMAP_ENTRIES { - ranges.push((c0, c1 as u32, v)); - } + if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) + && ranges.len() < MAX_CMAP_ENTRIES + { + ranges.push((c0, c1 as u32, v)); } i += 3; } From f42fb0ad49bc729fbfa8ec492dae592f8ec89bed Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:43:01 +0000 Subject: [PATCH 117/191] fix(forms): a deleted field leaves the calculation order 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. --- crates/forms/src/author.rs | 25 +++++++++++++++++ crates/redact/src/tests.rs | 1 + .../redact/src/tests/surviving_references.rs | 28 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 crates/redact/src/tests/surviving_references.rs diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index f5178a6879..544d0a7f8b 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1280,6 +1280,31 @@ pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { } } } + // Out of the calculation order too: a `/CO` entry would keep the field, its value and its + // widgets reachable for a full save. + drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) +} + +/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or +/// by reference. +fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { + let Some(root) = doc.root() else { return Ok(()) }; + let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; + let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) }; + let Some(co_obj) = af.get(b"CO").cloned() else { return Ok(()) }; + let co = doc.resolve(&co_obj).as_array().cloned().unwrap_or_default(); + let kept: Vec = co.iter().filter(|o| !o.as_ref().is_some_and(&gone)).cloned().collect(); + if kept.len() == co.len() { + return Ok(()); + } + match (co_obj.as_ref(), af_obj.as_ref()) { + (Some(r), _) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), + (_, Some(af_ref)) => doc.update_dict(af_ref, |d| d.set(b"CO".to_vec(), Object::Array(kept)))?, + (_, None) => { + af.set(b"CO".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } Ok(()) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index a9902495df..52d80eb26e 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2462,6 +2462,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } mod property_retirement; +mod surviving_references; /// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still /// draws only the one immediately before it (LASTARR, 7 glyphs, 35 pt wide from x = 10) -- not diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs new file mode 100644 index 0000000000..cfe6d87bbe --- /dev/null +++ b/crates/redact/src/tests/surviving_references.rs @@ -0,0 +1,28 @@ +//! Redacted content must leave the file: no other reference may keep it reachable by a full save. + +use super::*; + +/// Whether `secret` survives a full save of `doc`, in the file's bytes or in a decoded stream. +fn full_save_holds(doc: &Document, secret: &[u8]) -> bool { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let found = |data: &[u8]| data.windows(secret.len()).any(|w| w == secret); + if found(&bytes) { + return true; + } + let saved = Document::open(Arc::new(bytes)).unwrap(); + saved.scan_objects().into_iter().any(|(_, o)| matches!(&*o, Object::Stream(s) if s.decoded().is_ok_and(|d| found(&d)))) +} + +#[test] +fn a_redacted_field_listed_in_the_calculation_order_leaves_the_saved_file() { + let mut doc = one_page(b"", "", vec![]); + pdfcraft_forms::add_field(&mut doc, 0, [10.0, 50.0, 100.0, 70.0], &pdfcraft_forms::NewField::Text { multiline: false }, Some("ssn")).unwrap(); + pdfcraft_forms::set_value(&mut doc, "ssn", &pdfcraft_forms::FieldValue::Text("SSN-078-05-1120".into())).unwrap(); + let field = pdfcraft_forms::fields(&doc).swap_remove(0).obj; + let root = doc.root().unwrap(); + let af = doc.get(root).as_dict().unwrap().reference(b"AcroForm").unwrap(); + doc.update_dict(af, |d| d.set(b"CO".to_vec(), Object::Array(vec![Object::Ref(field)]))).unwrap(); + mark(&mut doc, 0, &[[0.0, 40.0, 120.0, 80.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().fields, 1); + assert!(!full_save_holds(&doc, b"SSN-078-05-1120"), "the field's value is still reachable"); +} From 6ca4431b50a21760c7c9c9378b9d01a05acf140f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:23 +0000 Subject: [PATCH 118/191] fix(redact): check every input content stream, whatever it calls itself 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. --- crates/edit/src/lib.rs | 15 +++++++++------ crates/engine/src/lib.rs | 15 ++++++++++----- crates/engine/src/tests.rs | 34 +++++++++++++++++++++++++++++++++- crates/redact/src/lib.rs | 16 ++++++++-------- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index d3db00fe30..bef515f981 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -17,7 +17,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, Object, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; #[derive(Debug, thiserror::Error, PartialEq)] @@ -601,7 +601,7 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation { /// Put a mark's content on a page: behind (with the behind-marks at the start) or on top /// (appended, after wrapping the original content in q/Q). fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> { - place_tagged(doc, page, kind.tag(), content, behind) + place_tagged(doc, page, kind.tag(), content, behind).map(drop) } /// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the @@ -609,7 +609,8 @@ fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, conten /// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of /// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content /// sequences are closed at the end too, so hidden optional content cannot hide the new mark. -fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result<(), EditError> { +/// Returns the new content stream. +fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result { let mut list = contents(doc, page)?; // The behind-marks at the start: backgrounds in the order added, then watermarks. let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()]; @@ -625,7 +626,8 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont Isolation::Unknown { unclosed, marked } => (behind_marks < list.len()).then_some((behind_marks, unclosed, marked)), } }; - let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content)))); + let mark_ref = doc.add(Object::Stream(Stream::flate(tagged(tag), &content))); + let mark = Object::Ref(mark_ref); if behind { // Right after the backgrounds already there, so a new background is painted over the // older ones (#3591) and a watermark over all of them. Not after the watermarks: on a @@ -646,7 +648,7 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont list.push(mark); } doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; - Ok(()) + Ok(mark_ref) } /// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original @@ -664,7 +666,8 @@ fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &s /// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica /// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes /// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes. -pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result<(), EditError> { +/// Returns the new content stream. +pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result { let all = page_list(doc); check(&[page], all.len())?; add_resources(doc, &all[page], None, Some(&content))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 817eb79a96..32d3a1f15b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -989,12 +989,13 @@ fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { /// that it reads differently), a mark drawn over the text as displayed lies over glyphs the /// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is /// what the user saw when placing the marks, so it is asked. `after` is written as a save would -/// write it, without the marks' own overlay (the box and its label are drawn on purpose), its +/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its /// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps /// the document as it was. fn shown_under_redactions( after: &pdfcraft_cos::Document, marks: &[pdfcraft_redact::Mark], + overlays: &[pdfcraft_cos::ObjRef], pages: &[pdfcraft_render::PageInfo], password: Option<&str>, ) -> Result<(), EditError> { @@ -1011,8 +1012,7 @@ fn shown_under_redactions( for &pi in &marked { let Some(page) = model.get(pi) else { continue }; let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; - let kept: Vec = - list.into_iter().filter(|o| shown.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")).collect(); + let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; } let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; @@ -2356,6 +2356,9 @@ struct EditCtx { /// Fields the edit renamed to keep imported names separate (old → new), for the repair /// log and the undo label. renamed_fields: Vec<(String, String)>, + /// The overlay streams the edit's applied redactions added, which alone the renderer's check + /// leaves out. + redaction_overlays: Vec, } impl EditCtx { @@ -2382,6 +2385,7 @@ impl EditCtx { seed, count: 0, renamed_fields: Vec::new(), + redaction_overlays: Vec::new(), } } @@ -2736,7 +2740,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; } Edit::ApplyRedactions { pages } => { - pdfcraft_redact::apply(doc, pages.as_deref())?; + cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; } Edit::ClearRedactions => { pdfcraft_redact::clear_marks(doc, None)?; @@ -3798,6 +3802,7 @@ impl Session { cx.js = Some(js::JsRunner::new(&next, &name)); } cx.renamed_fields.clear(); + cx.redaction_overlays.clear(); // `next` is a copy: if the edit fails or crashes, the document is unchanged. guard(|| run_edit(&mut next, &edit, &mut cx)) .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; @@ -3806,7 +3811,7 @@ impl Session { if let Edit::ApplyRedactions { pages } = &edit { let marks: Vec = pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &doc.info.pages, doc.password.as_deref())) + guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; } // Renamed fields are said, not slipped in: a repair-log note (with the caveat that diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3d347e6492..958204bc53 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2253,12 +2253,17 @@ fn setting_the_calculation_order_recalculates_and_undoes() { /// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what /// the page's /Resources holds. fn one_line_page(resources: &str, body: &str) -> Vec { + one_line_page_with(resources, "", body) +} + +/// [`one_line_page`] with `stream_entries` added to the content stream's dictionary. +fn one_line_page_with(resources: &str, stream_entries: &str, body: &str) -> Vec { let objs = [ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(), format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> /Contents 5 0 R >>"), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), - format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + format!("<< /Length {} {stream_entries} >>\nstream\n{body}\nendstream", body.len()), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -2316,6 +2321,33 @@ fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() { assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}"); } +/// Only the overlay a redaction adds is left out of the renderer's check. A page whose own content +/// stream calls itself a redaction overlay (`/PCMark /Redaction`) is checked like any other. +#[test] +fn a_content_stream_labelled_as_a_redaction_overlay_is_still_checked() { + let line = "Public words then SECRET42 and more"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let forged = one_line_page_with( + "/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", + "/PCMark /Redaction", + &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET"), + ); + let id = s.open("forged.pdf", None, Arc::new(forged), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + let applied = s.apply(id, Edit::ApplyRedactions { pages: None }); + if applied.is_ok() { + assert!(!page_texts(&s, id).concat().contains("SECRET42"), "the page still shows the redacted word"); + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" + ); + } +} + #[test] fn redaction_marks_apply_for_good_and_undo() { let (mut s, id) = session_with(2); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 968773b91d..97d5bcd9fe 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -143,6 +143,10 @@ pub struct Report { /// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field /// values, and from which a relayout could rebuild them) were removed. pub xfa_removed: bool, + /// The content streams this apply added to draw the marks' boxes and labels. They are the + /// only streams a check for text left under the marks may skip: an input stream labelled + /// like an overlay (`/PCMark /Redaction`) is checked like any other. + pub overlays: Vec, } fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { @@ -576,7 +580,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); let (list, data) = page_streams(doc, &page.dict, pi)?; - // The overlay stream (last) draws text of its own: the overlay label is allowed. - let original: Vec> = list - .iter() - .zip(data) - .filter(|(o, _)| doc.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")) - .map(|(_, d)| d) - .collect(); + // The overlay stream draws text of its own: the overlay label is allowed. + let original: Vec> = + list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect(); let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut scratch = Report::default(); let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch); From 22aa6d5f9afb7339a19fa55b792b96f7c7910c37 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:50:59 +0000 Subject: [PATCH 119/191] fix(redact): place glyphs with the inherited and ExtGState-set text state 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. --- crates/engine/src/lib.rs | 2 +- crates/engine/src/tests.rs | 78 +++++++++++++-------------- crates/redact/src/interp.rs | 51 ++++++++++++++++-- crates/redact/src/tests.rs | 1 + crates/redact/src/tests/text_state.rs | 27 ++++++++++ 5 files changed, 111 insertions(+), 48 deletions(-) create mode 100644 crates/redact/src/tests/text_state.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 32d3a1f15b..f257865d3a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -985,7 +985,7 @@ fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { /// /// Redaction decides what lies under a mark from the positions its own interpreter computes, and /// its verify pass reuses them. Where they differ from the renderer's (a standard font without -/// /Widths, whose advances the interpreter approximates; a CMap, or a font set outside `Tf`, +/// /Widths, whose advances the interpreter approximates; a CMap /// that it reads differently), a mark drawn over the text as displayed lies over glyphs the /// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is /// what the user saw when placing the marks, so it is asked. `after` is written as a save would diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 958204bc53..8937cf6780 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2294,58 +2294,52 @@ fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) { s.apply(id, mark).unwrap(); } -/// Redaction removes text by the positions its own interpreter works out. Here the font and size -/// come from an ExtGState /Font entry (`gs`), which the renderer honours and the interpreter does -/// not model, so it places no glyph under the mark the user drew over the word as displayed: it -/// used to report success and leave the word in the file. The renderer is asked now, and the edit -/// is refused. +/// Redaction removes text by the positions its own interpreter works out, and the renderer has +/// the last word: where the two disagree, a page that still shows a glyph mostly under a mark +/// fails the edit. The check is run here on a page nothing was removed from, as if the +/// interpreter had placed the word elsewhere. Only the overlay streams an apply added are left +/// out: a content stream of the input labelled like one (`/PCMark /Redaction`) is checked too. #[test] -fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() { +fn the_renderer_check_refuses_text_still_shown_under_a_mark() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page_with("/Font << /F1 4 0 R >>", "/PCMark /Redaction", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET"); + let id = s.open("shown.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + let doc = s.get(id).unwrap(); + let mut cos = doc.editor.as_ref().unwrap().cos.clone(); + let marks = pdfcraft_redact::marks(&cos); + let refused = |cos: &pdfcraft_cos::Document| { + let shown = shown_under_redactions(cos, &marks, &[], &doc.info.pages, None); + matches!(shown, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0) + }; + assert!(refused(&cos)); + // The page's contents as an array, as they are once an apply has added its overlay. + let page = pdfcraft_model::pages(&cos).swap_remove(0); + let contents = page.dict.get(b"Contents").cloned().unwrap(); + cos.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Array(vec![contents]))).unwrap(); + assert!(refused(&cos), "the labelled input stream was not checked"); +} + +/// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer +/// draws it, so a mark drawn over it as displayed takes it out of the file. +#[test] +fn a_word_in_a_font_set_by_an_extgstate_is_redacted() { let line = "Public words then SECRET42 and more"; let mut s = Session::new().with_clock(|| 1_700_000_000); let by_gs = one_line_page("/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET")); let id = s.open("gs.pdf", None, Arc::new(by_gs), None).unwrap(); mark_shown_text(&mut s, id, "SECRET42"); - let refused = s.apply(id, Edit::ApplyRedactions { pages: None }); - assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}"); - // Nothing was changed: the mark is still pending and the page still shows the line. - assert_eq!(s.get(id).unwrap().redaction_marks(), 1); - assert_eq!(page_texts(&s, id), [line]); - - // The same line with its font set by `Tf`: the mark is applied and the word is gone. - let by_tf = one_line_page("/Font << /F1 4 0 R >>", &format!("BT /F1 16 Tf 20 150 Td ({line}) Tj ET")); - let id = s.open("tf.pdf", None, Arc::new(by_tf), None).unwrap(); - mark_shown_text(&mut s, id, "SECRET42"); s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); let shown = page_texts(&s, id).swap_remove(0); assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}"); -} - -/// Only the overlay a redaction adds is left out of the renderer's check. A page whose own content -/// stream calls itself a redaction overlay (`/PCMark /Redaction`) is checked like any other. -#[test] -fn a_content_stream_labelled_as_a_redaction_overlay_is_still_checked() { - let line = "Public words then SECRET42 and more"; - let mut s = Session::new().with_clock(|| 1_700_000_000); - let forged = one_line_page_with( - "/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", - "/PCMark /Redaction", - &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET"), + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" ); - let id = s.open("forged.pdf", None, Arc::new(forged), None).unwrap(); - mark_shown_text(&mut s, id, "SECRET42"); - let applied = s.apply(id, Edit::ApplyRedactions { pages: None }); - if applied.is_ok() { - assert!(!page_texts(&s, id).concat().contains("SECRET42"), "the page still shows the redacted word"); - let saved = s.save_bytes(id).unwrap(); - let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42"); - assert!(!held(&saved), "the saved file holds the redacted word"); - let reopened = pdfcraft_cos::Document::open(saved).unwrap(); - assert!( - !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), - "a saved stream holds the redacted word" - ); - } } #[test] diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index a608568f38..14dedbb344 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -690,6 +690,34 @@ fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Ok(()) } +/// The font, writing direction and size an ExtGState's `/Font [font size]` entry sets, as `Tf` +/// would (ISO 32000-2 §8.4.5); `None` when `name` sets no font. The metrics are cached with the +/// fonts `Tf` names, under a key no resource name can take (a name cannot hold a null byte). +fn extgstate_font( + doc: &Document, + scope: &mut Scope<'_>, + ext_gstates: &Dict, + name: &[u8], + fallback: &Rc, +) -> Option<(Rc, bool, f64)> { + let state = doc.resolve(ext_gstates.get(name)?).as_dict().cloned()?; + let entry = doc.resolve(state.get(b"Font")?).as_array().cloned()?; + let [font, size] = entry.as_slice() else { return None }; + let size = doc.resolve(size).as_f64()?; + let font_dict = doc.resolve(font).as_dict().cloned(); + let vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + let key = [b"\0".as_slice(), name].concat(); + let metrics = match scope.fonts.get(&key) { + Some(m) => m.clone(), + None => { + let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); + scope.fonts.insert(key, m.clone()); + m + } + }; + Some((metrics, vertical, size)) +} + /// One glyph of a shown string. struct Glyph { bytes: std::ops::Range, @@ -700,11 +728,9 @@ struct Glyph { /// Process a scope's content streams (state flows from one stream to the next). pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, ctm: Matrix) -> Output { - let mut out = Output::default(); - let fallback = Rc::new(Metrics::fallback()); - let mut gs = Gs { + let gs = Gs { ctm, - font: fallback.clone(), + font: Rc::new(Metrics::fallback()), vertical: false, size: 0.0, char_spacing: 0.0, @@ -714,9 +740,19 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< leading: 0.0, rise: 0.0, }; + process_from(doc, scope, streams, resources, gs) +} + +/// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state +/// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed +/// with the caller's font, size and spacing, as the renderer places it. +fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, mut gs: Gs) -> Output { + let mut out = Output::default(); + let fallback = Rc::new(Metrics::fallback()); let mut stack: Vec = Vec::new(); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let fonts_res = res_dict(doc, resources, b"Font"); + let ext_gstates = res_dict(doc, resources, b"ExtGState"); let mut xobjects = res_dict(doc, resources, b"XObject"); // The current path: its operators, its bounding box in user space, and whether it clips. let mut path: Vec = Vec::new(); @@ -865,6 +901,11 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< }; } } + b"gs" => { + if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) { + (gs.font, gs.vertical, gs.size) = (font, vertical, size); + } + } b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, @@ -1223,7 +1264,7 @@ fn xobject( let res = own.clone().unwrap_or_else(|| resources.clone()); let saved_fonts = std::mem::take(&mut scope.fonts); scope.depth += 1; - let inner = process(doc, scope, std::slice::from_ref(&data), &res, fctm); + let inner = process_from(doc, scope, std::slice::from_ref(&data), &res, Gs { ctm: fctm, ..gs.clone() }); scope.depth -= 1; scope.fonts = saved_fonts; out.residue += inner.residue; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 52d80eb26e..882cc50aeb 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2463,6 +2463,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { mod property_retirement; mod surviving_references; +mod text_state; /// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still /// draws only the one immediately before it (LASTARR, 7 glyphs, 35 pt wide from x = 10) -- not diff --git a/crates/redact/src/tests/text_state.rs b/crates/redact/src/tests/text_state.rs new file mode 100644 index 0000000000..4e888f262c --- /dev/null +++ b/crates/redact/src/tests/text_state.rs @@ -0,0 +1,27 @@ +//! Glyphs are placed with the whole text state, wherever it was set. + +use super::*; + +const SECRET_AT_10_100: [f64; 4] = [5.0, 95.0, 45.0, 110.0]; + +#[test] +fn a_font_set_by_an_extgstate_places_its_glyphs() { + let mut doc = one_page(b"BT /GS1 gs 10 100 Td (SECRET) Tj ET", "/ExtGState << /GS1 << /Type /ExtGState /Font [5 0 R 10] >> >>", vec![]); + // "ET" spans x 30–40. + assert_eq!(under(&mut doc, 0, &[[30.0, 95.0, 40.0, 110.0]]), 2); + mark(&mut doc, 0, &[SECRET_AT_10_100], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +} + +#[test] +fn a_form_inherits_the_text_state_of_the_page_that_draws_it() { + // The form shows text with the font, size and spacing the page set before `Do`. + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT 10 100 Td (SECRET) Tj ET"); + let mut doc = one_page(b"/F1 10 Tf 1 Tc /Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + // Glyphs 5 pt wide, 1 pt apart: the last one spans x 40–45. + assert_eq!(under(&mut doc, 0, &[[40.0, 95.0, 46.0, 110.0]]), 1); + mark(&mut doc, 0, &[SECRET_AT_10_100], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.forms_rewritten), (6, 1), "{r:?}"); +} From be64a60c876402f43ffef893f1fd87ac77786981 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:52:12 +0000 Subject: [PATCH 120/191] fix(redact): unlink removed XObjects from surviving forms' resources 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. --- crates/redact/src/interp.rs | 34 +++++++++++++--- .../redact/src/tests/surviving_references.rs | 40 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 14dedbb344..fca3a1da88 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -529,8 +529,9 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_prope } } // A surviving form can own the same old resource dictionary as a rewritten page. Its - // unused descriptions remain reachable too, even after every page holder is cleaned. - unlink_form_properties(doc, &holder_nodes, retired_properties)?; + // unused descriptions and doomed XObjects remain reachable too, even after every page + // holder is cleaned. + unlink_from_forms(doc, &holder_nodes, &doomed, retired_properties)?; Ok(()) } @@ -567,8 +568,11 @@ fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], c Ok(pruned) } -fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Object]) -> Result<(), CosError> { - if retired.is_empty() { +/// Unlink retired properties and `doomed` XObjects from the resources of the forms reachable from +/// the page-tree `holders`, each unless the form's own content (or a form inheriting its +/// resources) still uses the name. Fails closed when that cannot be determined. +fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet, retired: &[Object]) -> Result<(), CosError> { + if retired.is_empty() && doomed.is_empty() { return Ok(()); } let mut pending: Vec = holders @@ -593,7 +597,27 @@ fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Obj let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; let names: Vec> = res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); - if !names.is_empty() && prune_retired_properties(doc, &mut res, &names, &[vec![form.decoded()?]])? { + let mut xo = res_dict(doc, &res, b"XObject"); + let doomed_names: Vec> = + xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && doomed_names.is_empty() { + pending.push(res); + continue; + } + let data = form.decoded()?; + let mut pruned = false; + for name in doomed_names { + match content_draws(doc, std::slice::from_ref(&data), &xo, &name, 0, &mut HashSet::new()) { + Some(true) => {} + Some(false) => pruned |= xo.remove(&name).is_some(), + None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), + } + } + if pruned { + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &names, &[vec![data]])?; + if pruned { doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; } pending.push(res); diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index cfe6d87bbe..2c8bdb09c2 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -26,3 +26,43 @@ fn a_redacted_field_listed_in_the_calculation_order_leaves_the_saved_file() { assert_eq!(apply(&mut doc, None).unwrap().fields, 1); assert!(!full_save_holds(&doc, b"SSN-078-05-1120"), "the field's value is still reachable"); } + +/// A surviving form shares the page's resource dictionary, which lists the image the mark +/// removes; the form never draws it. +#[test] +fn a_redacted_image_does_not_survive_in_a_surviving_forms_resources() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Font << /F1 5 0 R >> /XObject << /Im 8 0 R /Fm 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 7 0 R", b"BT /F1 10 Tf 10 200 Td (Public) Tj ET"), + ]); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(content(&doc, 0).contains("Do"), "the form is still drawn"); + assert!(!full_save_holds(&doc, b"IMAGESECRET"), "the removed image is still reachable"); +} + +/// The same form drawing the image itself keeps it: the page still shows it there. +#[test] +fn an_image_a_surviving_form_draws_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Font << /F1 5 0 R >> /XObject << /Im 8 0 R /Fm 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 7 0 R", b"q 50 0 0 50 200 200 cm /Im Do Q"), + ]); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark"); +} From cccd435a5308bd628976e0f0d9923f1eebf90e36 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:53:24 +0000 Subject: [PATCH 121/191] fix(redact): bound image decoding by the declared raster size 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. --- crates/redact/src/image.rs | 6 +++++- crates/redact/src/tests.rs | 1 + crates/redact/src/tests/limits.rs | 25 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 crates/redact/src/tests/limits.rs diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 9a19ba2f0b..601700f257 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -77,7 +77,11 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let bytes = row.checked_mul(h)?; let row_bits = row.checked_mul(8)?; bytes.checked_mul(8)?; - let mut data = s.decoded().ok()?; + // Decode no further than the raster can need: a filter stage may legitimately produce up to + // twice it (ASCIIHex text under Flate) plus a PNG predictor's byte per row. Data past that + // can only be a decompression bomb; the image is then removed whole. + let allowance = bytes.checked_add(h)?.checked_mul(2)?.checked_add(4096)?; + let mut data = s.decoded_within(allowance).ok()?; if data.len() < bytes { return None; } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 882cc50aeb..94081f9e35 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2461,6 +2461,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } } +mod limits; mod property_retirement; mod surviving_references; mod text_state; diff --git a/crates/redact/src/tests/limits.rs b/crates/redact/src/tests/limits.rs new file mode 100644 index 0000000000..6bee96aed4 --- /dev/null +++ b/crates/redact/src/tests/limits.rs @@ -0,0 +1,25 @@ +//! Work and memory stay bounded by what the page declares, whatever the file holds. + +use super::*; + +/// A half-covered 4×1 grey image whose Flate data decodes to `decoded`. +fn half_covered_image(decoded: &[u8]) -> Report { + let dict = "/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let mut doc = one_page(b"q 40 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, b"")]); + let Object::Stream(s) = &*doc.get(ObjRef::new(7, 0)) else { panic!() }; + doc.set(ObjRef::new(7, 0), Object::Stream(Stream::flate(s.dict.clone(), decoded))); + mark(&mut doc, 0, &[[95.0, 95.0, 120.0, 115.0]], ""); + apply(&mut doc, None).unwrap() +} + +#[test] +fn an_image_decoding_far_past_its_raster_is_removed_without_decoding_it_all() { + let r = half_covered_image(&[255; 4]); + assert_eq!((r.images_removed, r.images_cleared), (0, 1), "{r:?}"); + // 4 bytes of raster, then 4 MiB the image never uses: decoding stops at a bound derived from + // the raster, and the image goes whole. + let mut bomb = vec![255; 4]; + bomb.resize(4 << 20, 0); + let r = half_covered_image(&bomb); + assert_eq!((r.images_removed, r.images_cleared), (1, 0), "{r:?}"); +} From 2cdb39503ef303b5cc66703abda470d044896690 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:55:22 +0000 Subject: [PATCH 122/191] fix(redact): compare marked content without a copy per enclosing MCID 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. --- crates/redact/src/tags.rs | 88 +++++++++++++++++++++++++------ crates/redact/src/tests/limits.rs | 30 +++++++++++ 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index ab5735e065..8548810600 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -6,15 +6,49 @@ //! references to marked content that is now empty are dropped from the element. use std::collections::{HashMap, HashSet}; +use std::ops::Range; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::RedactError; -/// Each MCID's operators (serialized), and whether anything is still drawn inside it. -fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap, bool)> { - let mut out: HashMap, bool)> = HashMap::new(); - let mut stack: Vec> = Vec::new(); +/// The marked content of one content stream. Every operator inside some MCID is serialized once +/// into `ops`, and each MCID holds the ranges of `ops` its sequences span (marked-content +/// operators are not written, so a sequence is one range however deeply it nests), with whether +/// anything is drawn inside it. Copying the operators into every enclosing MCID instead would +/// hold `depth` copies of nested content. +#[derive(Default)] +struct Marked { + ops: Vec, + ids: HashMap>, bool)>, +} + +impl Marked { + /// The bytes of `ranges`, which are ranges of `ops`. + fn bytes<'a>(&'a self, ranges: &'a [Range]) -> impl Iterator + 'a { + ranges.iter().flat_map(|r| self.ops.get(r.clone()).unwrap_or_default()) + } +} + +/// Most bytes [`touched`] compares for one stream. Nested sequences share their operators, so the +/// comparisons can add up to the content's size times its nesting depth; past this, the MCIDs +/// left are taken as changed (their descriptions go) rather than compared. +const MAX_COMPARED: usize = 1 << 28; + +fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> Marked { + let mut out = Marked::default(); + // Open sequences: the MCID with where its content starts in `ops` and the drawing operators + // written before it, or `None` for a sequence without one. + let mut stack: Vec> = Vec::new(); + let (mut open_ids, mut drawn) = (0usize, 0usize); + let close = |out: &mut Marked, open: Option<(i64, usize, usize)>, drawn: usize| { + if let Some((id, start, drawn_before)) = open { + let end = out.ops.len(); + let e = out.ids.entry(id).or_default(); + e.0.push(start..end); + e.1 |= drawn > drawn_before; + } + }; for op in pdfcraft_content::parse(data).ops { match op.op.as_slice() { b"BDC" => { @@ -28,9 +62,10 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap None, }; if let Some(id) = id { - out.entry(id).or_default(); + out.ids.entry(id).or_default(); + open_ids += 1; } - stack.push(id); + stack.push(id.map(|id| (id, out.ops.len(), drawn))); continue; } b"BMC" => { @@ -38,11 +73,17 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap { - stack.pop(); + if let Some(open) = stack.pop() { + open_ids = open_ids.saturating_sub(usize::from(open.is_some())); + close(&mut out, open, drawn); + } continue; } _ => {} } + if open_ids == 0 { + continue; + } let draws = match op.op.as_slice() { b"Tj" | b"'" | b"\"" => op.operands.last().and_then(Object::as_string).is_some_and(|s| !s.bytes.is_empty()), b"TJ" => { @@ -51,13 +92,12 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap true, _ => false, }; - let mut bytes = Vec::new(); - pdfcraft_content::write_op(&op, &mut bytes); - for id in stack.iter().flatten() { - let e = out.entry(*id).or_default(); - e.0.extend_from_slice(&bytes); - e.1 |= draws; - } + pdfcraft_content::write_op(&op, &mut out.ops); + drawn += usize::from(draws); + } + // Sequences the stream leaves open run to its end. + while let Some(open) = stack.pop() { + close(&mut out, open, drawn); } out } @@ -67,10 +107,17 @@ pub(crate) fn touched(doc: &Document, resources: &Dict, before: &[u8], after: &[ let (b, a) = (by_mcid(doc, resources, before), by_mcid(doc, resources, after)); let mut changed = HashSet::new(); let mut empty = HashSet::new(); - for (id, (ops, _)) in &b { - match a.get(id) { + let mut budget = MAX_COMPARED; + let len = |ranges: &[Range]| ranges.iter().map(|r| r.len()).fold(0usize, usize::saturating_add); + for (id, (old, _)) in &b.ids { + match a.ids.get(id) { Some((new, draws)) => { - if new != ops { + let n = len(old); + let same = n == len(new) && n <= budget && { + budget -= n; + b.bytes(old).eq(a.bytes(new)) + }; + if !same { changed.insert(*id); } if !draws { @@ -413,3 +460,10 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet } Ok(n) } + +/// Bytes held for comparing marked content, for tests that bound it. +#[cfg(test)] +pub(crate) fn retained_for_comparison(doc: &Document, resources: &Dict, data: &[u8]) -> usize { + let marked = by_mcid(doc, resources, data); + marked.ops.len() + marked.ids.values().map(|(ranges, _)| ranges.len() * std::mem::size_of::>()).sum::() +} diff --git a/crates/redact/src/tests/limits.rs b/crates/redact/src/tests/limits.rs index 6bee96aed4..8519e08608 100644 --- a/crates/redact/src/tests/limits.rs +++ b/crates/redact/src/tests/limits.rs @@ -23,3 +23,33 @@ fn an_image_decoding_far_past_its_raster_is_removed_without_decoding_it_all() { let r = half_covered_image(&bomb); assert_eq!((r.images_removed, r.images_cleared), (1, 0), "{r:?}"); } + +/// Marked content nested `depth` deep, each level with its own MCID, around `depth` drawing +/// operators. +fn nested_marked_content(depth: usize) -> Vec { + let mut data = Vec::new(); + for id in 0..depth { + data.extend_from_slice(format!("/P <> BDC\n").as_bytes()); + } + data.extend(b"0 0 1 1 re f\n".repeat(depth)); + data.extend(b"EMC\n".repeat(depth)); + data +} + +#[test] +fn comparing_deeply_nested_marked_content_holds_each_operator_once() { + let doc = one_page(b"", "", vec![]); + let data = nested_marked_content(2000); + let held = tags::retained_for_comparison(&doc, &Dict::new(), &data); + assert!(held <= 2 * data.len(), "{held} bytes held for {} bytes of content", data.len()); +} + +#[test] +fn a_change_inside_nested_marked_content_touches_every_enclosing_mcid() { + let doc = one_page(b"", "", vec![]); + let before = b"/P <> BDC /P <> BDC BT (A) Tj ET EMC 0 0 1 1 re f EMC /P <> BDC BT (B) Tj ET EMC"; + let after = b"/P <> BDC /P <> BDC BT ET EMC 0 0 1 1 re f EMC /P <> BDC BT (B) Tj ET EMC"; + let (changed, empty) = tags::touched(&doc, &Dict::new(), before, after); + assert_eq!(changed, HashSet::from([0, 1])); + assert_eq!(empty, HashSet::from([1])); +} From 7db37911ed7f22f623f5a9416aa1e83ebe053adc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:57:39 +0000 Subject: [PATCH 123/191] fix(redact): clear descriptions above removed content and annotations 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. --- crates/redact/src/tags.rs | 135 ++++++++++++++---- crates/redact/src/tests.rs | 1 + crates/redact/src/tests/descriptions.rs | 64 +++++++++ .../redact/src/tests/surviving_references.rs | 2 +- 4 files changed, 177 insertions(+), 25 deletions(-) create mode 100644 crates/redact/src/tests/descriptions.rs diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index 8548810600..60c158b180 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -183,20 +183,21 @@ pub(crate) fn clean_within( // also from the parent tree and the ID tree: one listed only there still owns marked content // (#3302). The stack pops /K first, so an element reached through /K gets the page it inherits // from its ancestors before a tree reaches it without one. - let mut todo: Vec<(Object, Option)> = - [&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None)).collect(); + let mut todo: Vec<(Object, Option, Above)> = + [&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None, Above::default())).collect(); let mut seen = HashSet::new(); let mut pages = HashMap::new(); + let mut parents = Parents::new(); let mut hits: Vec<(ObjRef, Dict, Option, bool)> = Vec::new(); // Whether marked content `x` (a kid on page `pg`) is one this cleanup must change. let touches = |doc: &Document, x: &Object, pg: Option| { mcid_of(doc, x, pg, page, stream).is_some_and(|m| stream.is_some() || changed.contains(&m) || empty.contains(&m)) }; - while let Some((k, pg)) = todo.pop() { + while let Some((k, pg, above)) = todo.pop() { match k { - Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg))), + Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg, above))), // A number or name tree node written in place. - Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))), + Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg, above))), // An element written in place: its kids are followed, but if it owns marked content // this cleanup changes, only rewriting what holds it could change it. Object::Dict(d) => { @@ -208,9 +209,11 @@ pub(crate) fn clean_within( if kids.iter().any(|x| touches(doc, x, pg)) { return Err(RedactError::Tags); } - todo.extend(kids.into_iter().map(|x| (x, pg))); + let above = above.through(&d); + todo.extend(kids.into_iter().map(|x| (x, pg, above))); } Object::Ref(r) => { + parents.record(r, above); if !seen.insert(r) { continue; } @@ -219,13 +222,13 @@ pub(crate) fn clean_within( } let object = doc.get(r); if let Object::Array(a) = &*object { - todo.extend(a.iter().cloned().map(|x| (x, pg))); + todo.extend(a.iter().cloned().map(|x| (x, pg, above))); continue; } let Some(d) = object.as_dict().cloned() else { continue }; if !d.contains(b"S") { // A parent tree or ID tree node: its values may be elements. - todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))); + todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg, above))); continue; } let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) { @@ -238,11 +241,12 @@ pub(crate) fn clean_within( if sensitive || remap { hits.push((r, d.clone(), pg, sensitive)); } - todo.extend(kids.into_iter().map(|x| (x, pg))); + todo.extend(kids.into_iter().map(|x| (x, pg, Above::element(r)))); } _ => {} } } + let sensitive: Vec = hits.iter().filter(|h| h.3).map(|h| h.0).collect(); let mut n = 0; for (r, d, pg, sensitive) in hits { let kids = d.get(b"K").map(|k| doc.resolve(k)); @@ -291,9 +295,88 @@ pub(crate) fn clean_within( })?; n += usize::from(sensitive && doc.get(r).as_dict() != Some(&d)); } + // An element above may describe the changed content too (`/ActualText` on a span's parent + // covers the span, §14.9.4). + n += clear_ancestors(doc, &sensitive, &parents, limit)?; Ok(n) } +/// Where the walk reached an object from: the nearest element above it that is an object of its +/// own, and whether an element written in place between them carries a description (which only +/// rewriting the object holding it could remove). +#[derive(Clone, Copy, Default)] +struct Above { + element: Option, + described_in_place: bool, +} + +impl Above { + fn element(r: ObjRef) -> Self { + Self { element: Some(r), described_in_place: false } + } + + /// Below an element written in place. + fn through(self, d: &Dict) -> Self { + Self { described_in_place: self.described_in_place || DESCRIPTIONS.iter().any(|k| d.contains(k)), ..self } + } +} + +/// The keys of an element that describe its content. +const DESCRIPTIONS: [&[u8]; 3] = [b"Alt", b"ActualText", b"E"]; + +/// Each element's parents as the walk found them: through `/K`, which `/P` may not agree with. +struct Parents(HashMap>); + +impl Parents { + fn new() -> Self { + Self(HashMap::new()) + } + + fn record(&mut self, r: ObjRef, above: Above) { + if above.element.is_some() || above.described_in_place { + self.0.entry(r).or_default().push(above); + } + } +} + +/// Remove the descriptions of every element above `elements`, through both `/P` and the walk's +/// `parents`; returns how many elements changed. [`RedactError::Tags`] when an element written in +/// place above them carries one, or when there are more than `limit` ancestors. +fn clear_ancestors(doc: &mut Document, elements: &[ObjRef], parents: &Parents, limit: usize) -> Result { + let mut todo: Vec = elements.to_vec(); + let mut seen: HashSet = elements.iter().copied().collect(); + let mut cleared = 0; + while let Some(r) = todo.pop() { + let mut up: Vec = doc.get(r).as_dict().and_then(|d| d.get(b"P")).and_then(Object::as_ref).into_iter().collect(); + for above in parents.0.get(&r).into_iter().flatten() { + if above.described_in_place { + return Err(RedactError::Tags); + } + up.extend(above.element); + } + for a in up { + if !seen.insert(a) { + continue; + } + if seen.len() > limit { + return Err(RedactError::Tags); + } + // The structure tree root (no /S) ends the climb. + let Some(d) = doc.get(a).as_dict().filter(|d| d.contains(b"S")).cloned() else { continue }; + if DESCRIPTIONS.iter().any(|k| d.contains(k)) { + doc.update_dict(a, |e| { + for key in DESCRIPTIONS { + e.remove(key); + } + })?; + cleared += 1; + } + todo.push(a); + } + } + Ok(cleared) +} + /// The page an element inherits (§14.7.4.4): the nearest `/Pg` among its ancestors, following /// `/P`. `Ok(None)` when none has one; [`RedactError::Tags`] when the lookup gives up (more than /// [`MAX_PARENTS`] ancestors, or a loop), which isn't the same as having no page. `pages` keeps @@ -384,12 +467,14 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet }; // Elements are reached from the root's /K, and also from the parent tree and the ID tree: // one listed only there (left behind by an editor, say) still points at its annotation. - let mut todo: Vec = [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).collect(); + let mut todo: Vec<(Object, Above)> = + [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, Above::default())).collect(); let mut seen = HashSet::new(); + let mut parents = Parents::new(); let mut hits: Vec = Vec::new(); - while let Some(k) = todo.pop() { + while let Some((k, above)) = todo.pop() { match k { - Object::Array(a) => todo.extend(a), + Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, above))), // A structure element written in place: its kids are followed; one that tags a // removed annotation can't be changed here (see above). Object::Dict(d) if d.contains(b"S") => { @@ -397,11 +482,13 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet if kids.iter().any(|x| refers_to(doc, x, gone)) { return Err(RedactError::Tags); } - todo.extend(kids); + let above = above.through(&d); + todo.extend(kids.into_iter().map(|x| (x, above))); } // A number or name tree node written in place (§7.9.6, §7.9.7): its values and kids. - Object::Dict(d) => todo.extend(tree_node_entries(&d)), + Object::Dict(d) => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, above))), Object::Ref(r) => { + parents.record(r, above); if !seen.insert(r) { continue; } @@ -410,26 +497,26 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet } let object = doc.get(r); if let Object::Array(a) = &*object { - todo.extend(a.iter().cloned()); + todo.extend(a.iter().cloned().map(|x| (x, above))); continue; } let Some(d) = object.as_dict() else { continue }; if !d.contains(b"S") { // A parent tree or ID tree node: its values may be elements. - todo.extend(tree_node_entries(d)); + todo.extend(tree_node_entries(d).into_iter().map(|x| (x, above))); continue; } let kids = kids_of(doc, d); if kids.iter().any(|x| refers_to(doc, x, gone)) { hits.push(r); } - todo.extend(kids); + todo.extend(kids.into_iter().map(|x| (x, Above::element(r)))); } _ => {} } } let mut n = 0; - for r in hits { + for &r in &hits { let Some(d) = doc.get(r).as_dict().cloned() else { continue }; let kids = d.get(b"K").map(|k| doc.resolve(k)); let array = kids.as_ref().is_some_and(|k| matches!(&**k, Object::Array(_))); @@ -449,15 +536,15 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet } else { e.remove(b"K"); } - // An element left with nothing to tag keeps no text describing what it tagged: - // a link's alternate text often repeats the URL, an annotation's its comment. - if keep.is_empty() { - for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { - e.remove(key); - } + // An element that lost an annotation keeps no text describing it, even with other + // kids left: a link's alternate text often repeats the URL, an annotation's its + // comment. Elements above it may describe it too. + for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { + e.remove(key); } })?; } + clear_ancestors(doc, &hits, &parents, limit)?; Ok(n) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 94081f9e35..ebc95f4519 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2461,6 +2461,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } } +mod descriptions; mod limits; mod property_retirement; mod surviving_references; diff --git a/crates/redact/src/tests/descriptions.rs b/crates/redact/src/tests/descriptions.rs new file mode 100644 index 0000000000..df6b8555dc --- /dev/null +++ b/crates/redact/src/tests/descriptions.rs @@ -0,0 +1,64 @@ +//! Descriptions in the structure tree (alternate text, actual text, expansions) go with the +//! content they describe, on the element that owns it and on every element above it. + +use super::surviving_references::full_save_holds; +use super::*; + +/// A tagged page: "SECRET" (MCID 0) inside a section whose descriptions repeat it, and "Public" +/// (MCID 1). `paragraph_parent` is the secret paragraph's /P. +fn sectioned(paragraph_parent: &str) -> Document { + let content = b"/P <> BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 10 100 Td (Public) Tj ET EMC"; + pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R /MarkInfo << /Marked true >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", content), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Type /StructTreeRoot /K 8 0 R >>".to_vec(), + b"<< /S /Document /P 7 0 R /K [11 0 R 10 0 R] >>".to_vec(), + format!("<< /S /P /P {paragraph_parent} /Pg 3 0 R /K 0 >>").into_bytes(), + b"<< /S /P /P 8 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(), + b"<< /S /Sect /P 8 0 R /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /K 9 0 R >>".to_vec(), + ]) +} + +#[test] +fn an_ancestor_describing_redacted_content_loses_its_descriptions() { + // The paragraph names its section as parent, or (wrongly) the document: the section is found + // either way, by its /K. + for parent in ["11 0 R", "8 0 R"] { + let mut doc = sectioned(parent); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + assert!(!full_save_holds(&doc, b"LEAK_"), "a description of the redacted word survived (paragraph /P {parent})"); + let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); + assert!(public.contains(b"ActualText"), "an element beside it keeps its own"); + } +} + +#[test] +fn a_link_element_that_keeps_its_text_loses_the_description_of_its_removed_annotation() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] /StructParents 0 >>" + .to_vec(), + stream("", b"/Link << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"), + FONT.replace("95 0 R", "12 0 R").into_bytes(), + b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://example.org/) >> >>".to_vec(), + b"null".to_vec(), + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [11 0 R] 1 11 0 R] >> /ParentTreeNextKey 2 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K 10 0 R >>".to_vec(), + b"<< /Type /StructElem /S /P /P 9 0 R /ActualText (See LEAK_PARENT) /K 11 0 R >>".to_vec(), + // The link element tags its text and the annotation; its descriptions repeat the address. + b"<< /Type /StructElem /S /Link /P 10 0 R /Pg 3 0 R /Alt (LEAK_ALT) /ActualText (LEAK_ACTUAL) /E (LEAK_E) /T (LEAK_T) /K [0 << /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + widths(), + ]); + let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(); + assert!(done.contains(&(Hidden::LinksActionsScripts, 1)), "{done:?}"); + assert!(!full_save_holds(&doc, b"LEAK_"), "a description of the removed link survived"); + let link = doc.get(ObjRef::new(11, 0)).as_dict().cloned().unwrap(); + assert_eq!(link.get(b"K"), Some(&Object::Array(vec![Object::Int(0)])), "the element keeps its text"); +} diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 2c8bdb09c2..d3aa98af67 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -3,7 +3,7 @@ use super::*; /// Whether `secret` survives a full save of `doc`, in the file's bytes or in a decoded stream. -fn full_save_holds(doc: &Document, secret: &[u8]) -> bool { +pub(super) fn full_save_holds(doc: &Document, secret: &[u8]) -> bool { let bytes = write_full(doc, &SaveOptions::default()).unwrap(); let found = |data: &[u8]| data.windows(secret.len()).any(|w| w == secret); if found(&bytes) { From b72078871a307ad8d28b9e3f861885435fe10834 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:09:57 +0000 Subject: [PATCH 124/191] fix(content): end inline images where the renderer ends them 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. --- Cargo.lock | 1 + crates/content/Cargo.toml | 3 + crates/content/src/lib.rs | 120 ++++++++++++++++++----- crates/content/src/tests.rs | 58 +++++++++-- crates/engine/src/tests.rs | 24 +++++ crates/redact/src/interp.rs | 6 ++ crates/redact/src/lib.rs | 6 ++ crates/redact/src/tests.rs | 1 + crates/redact/src/tests/inline_images.rs | 34 +++++++ 9 files changed, 224 insertions(+), 29 deletions(-) create mode 100644 crates/redact/src/tests/inline_images.rs diff --git a/Cargo.lock b/Cargo.lock index a950149243..e318b7a77c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4042,6 +4042,7 @@ version = "0.11.0" name = "pdfcraft-content" version = "0.11.0" dependencies = [ + "hayro-syntax", "pdfcraft-cos", ] diff --git a/crates/content/Cargo.toml b/crates/content/Cargo.toml index c553df0b8c..e6b4c28a42 100644 --- a/crates/content/Cargo.toml +++ b/crates/content/Cargo.toml @@ -10,3 +10,6 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } +# The renderer's content tokenizer, already in the tree through pdfcraft-cos: inline images end +# where the renderer ends them. +hayro-syntax = { workspace = true } diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index eb126ffa21..097ec26897 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -60,6 +60,10 @@ pub struct Parsed { pub ops: Vec, /// Bytes that could not be read as operands or operators. pub skipped: usize, + /// Inline images the renderer's tokenizer reads no image for (it stops, or finds no `EI` it + /// accepts). Their end here is a guess, so the operators after them may not be the ones the + /// page draws. + pub ambiguous_images: usize, /// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before /// and whether one runs out of it into the next. Only boundaries are recorded, so the size /// depends on the number of pieces, not of comments. @@ -142,19 +146,27 @@ fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { start.get_or_insert(at); operands.push(Object::Null); } - b"BI" => match inline_image(data, lx.pos) { - Ok((dict, img, end)) => { - lx.pos = end; - out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end }); - operands.clear(); + b"BI" => { + let image = inline_image(data, at, lx.pos); + out.ambiguous_images += usize::from(image.ambiguous); + let end = image.end; + lx.pos = end; + match image.image { + Some((dict, img)) => { + out.ops.push(Op { + op: b"BI".to_vec(), + operands: Vec::new(), + inline: Some((dict, img)), + span: start.take().unwrap_or(at)..end, + }); + } + None => { + out.skipped += end.saturating_sub(at); + start = None; + } } - Err(end) => { - out.skipped += end.saturating_sub(at); - lx.pos = end; - start = None; - operands.clear(); - } - }, + operands.clear(); + } _ => { let s = start.take().unwrap_or(at); out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos }); @@ -198,11 +210,29 @@ fn comment_edges(data: &[u8], trivia: std::ops::Range, ends: &[usize], bo } } -/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds -/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read. -fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize> { +/// An inline image as [`inline_image`] reads it. +struct Inline { + /// Its parameters and data; `None` when the parameters can't be read. + image: Option<(Dict, Vec)>, + /// The position after its `EI` (or the end of the stream). + end: usize, + /// The renderer's tokenizer reads no image here, so `end` is this parser's guess. + ambiguous: bool, +} + +/// Read the inline image whose `BI` keyword starts at `bi` (its parameters start at `pos`). +/// +/// Filtered image data has no length, and `EI` can occur inside it, so where it ends is a +/// heuristic choice among `EI` candidates. The renderer's choice decides which operators the +/// page draws after the image, and redaction must see the same ones, so the end is taken from +/// the renderer's tokenizer (hayro-syntax) rather than worked out here by another rule. Only +/// when that tokenizer reads no image does this parser fall back to its own rules, and the +/// image is marked ambiguous. +fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline { let mut lx = Lexer::new(data, pos); let mut dict = Dict::new(); + let mut readable = true; + let mut stop = pos; loop { lx.skip_ws(); if lx.eat_keyword(b"ID") { @@ -216,22 +246,68 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize let Some((k, v)) = entry else { // A stream that ends inside the parameters is cut short, not unreadable: keep what was read. if at >= data.len() { - return Ok((dict, Vec::new(), data.len())); + return Inline { image: Some((dict, Vec::new())), end: data.len(), ambiguous: false }; } - // Without an `EI` after it, the image runs to the end of the stream. - return Err(end_of_image(data, at, b"").map_or(data.len(), |(_, end)| end)); + readable = false; + stop = at; + break; }; dict.set(k, v); } + if let Some((start, end_data, end)) = renderer_image_end(data, bi) { + // Where the renderer ends the data at an `EI` found by its heuristic, one whitespace byte + // before that `EI` separates it from the data. + let mut samples = data.get(start..end_data).unwrap_or_default(); + if data.get(end_data..).is_some_and(|rest| rest.starts_with(b"EI")) + && let Some((last, rest)) = samples.split_last() + && is_ws(*last) + { + samples = rest; + } + return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false }; + } + if !readable { + // Without an `EI` after it, the image runs to the end of the stream. + return Inline { image: None, end: end_of_image(data, stop, b"").map_or(data.len(), |(_, end)| end), ambiguous: true }; + } // One whitespace byte separates ID from the data. let begin = (lx.pos + 1).min(data.len()); let end = unfiltered_image_end(data, begin, &dict) .or_else(|| declared_image_end(data, begin, &dict)) .or_else(|| image_data_end(data, begin, filter_eod(&dict))); - Ok(match end { - Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end), - None => (dict, data[begin..].to_vec(), data.len()), - }) + let (image, end) = match end { + Some((end_data, end)) => (data.get(begin..end_data).unwrap_or_default().to_vec(), end), + None => (data.get(begin..).unwrap_or_default().to_vec(), data.len()), + }; + Inline { image: Some((dict, image)), end, ambiguous: true } +} + +/// Where the renderer's tokenizer ends the inline image whose `BI` starts at `bi`: the start and +/// end of its data and the position after its `EI`. `None` when it reads no image there (its +/// parameters don't read, or no `EI` passes its checks). +fn renderer_image_end(data: &[u8], bi: usize) -> Option<(usize, usize, usize)> { + use hayro_syntax::content::TypedIter; + use hayro_syntax::content::ops::{InlineImage, TypedInstruction}; + let tail = data.get(bi..)?; + let mut iter = TypedIter::new(tail); + let (start, len) = match iter.next()? { + TypedInstruction::InlineImage(InlineImage(stream)) => match stream.raw_data() { + // The data is a slice of `tail`: its offset is where it starts. + std::borrow::Cow::Borrowed(samples) => ((samples.as_ptr() as usize).checked_sub(tail.as_ptr() as usize)?, samples.len()), + std::borrow::Cow::Owned(_) => return None, + }, + _ => return None, + }; + let end_data = start.checked_add(len)?; + // `committed` is past the whitespace after `EI`; the image ends at the `EI`. + let mut end = iter.committed(); + while end > end_data && tail.get(end - 1).is_some_and(|b| is_ws(*b)) { + end -= 1; + } + if end < end_data.checked_add(2)? || tail.get(end - 2..end) != Some(b"EI") { + return None; + } + Some((bi.checked_add(start)?, bi.checked_add(end_data)?, bi.checked_add(end)?)) } /// The end-of-data marker (`>` for ASCIIHex, `~>` for ASCII85) of the outer stream filter, if any. diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 094a0665df..c9abef8349 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -102,10 +102,11 @@ fn unfiltered_inline_masks_default_to_one_bit_and_indexed_images_have_one_compon let parsed = inline_pixels(parameters.as_bytes(), b" EI "); assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI "); assert_eq!(parsed.ops.len(), 3); - // Indexed samples permit at most 8 bits, even though device spaces permit 16. + // Indexed samples permit at most 8 bits, even though device spaces permit 16, so the + // length is unknown, and the renderer takes the second `EI`. let invalid = parameters.replace("/W 4", "/W 2").replace("/BPC 8", "/BPC 16"); let parsed = inline_pixels(invalid.as_bytes(), b" EI "); - assert!(parsed.ops[1].inline.as_ref().unwrap().1.is_empty()); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI "); } } @@ -127,7 +128,7 @@ fn unfiltered_inline_image_terminator_must_follow_its_samples() { } #[test] -fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() { +fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them() { for parameters in [ b"/W 4 /H 1 /BPC 8 /CS /G /F /A85".as_slice(), b"/W 4 /H 1 /BPC 8 /CS /ResourceColor", @@ -142,12 +143,20 @@ fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() { b"/W 9223372036854775807 /H 9223372036854775807 /BPC 16 /CS /CMYK", b"/W 9223372036854775807 /H 2 /BPC 8 /CS /G", ] { + // What follows the first `EI` (`!!~>`) does not read as content, so the renderer takes + // the later one. let parsed = inline_pixels(parameters, b"! EI !!~>"); - assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"!", "{}", String::from_utf8_lossy(parameters)); + let label = String::from_utf8_lossy(parameters); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"! EI !!~>", "{label}"); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), [b"q".as_slice(), b"BI", b"Q"], "{label}"); + assert_eq!(parsed.ambiguous_images, 0, "{label}"); } - // A short payload still uses the tolerant recovery, including when there is no EI. - for src in [b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc"] { - assert_eq!(parse(src).ops[0].inline.as_ref().unwrap().1, b"abc"); + // A short payload is read whole; without an `EI`, the renderer reads no image and this + // parser's end is a guess. + for (src, ambiguous) in [(b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), 0), (b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc", 1)] { + let parsed = parse(src); + assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abc"); + assert_eq!(parsed.ambiguous_images, ambiguous); } } @@ -459,3 +468,38 @@ fn ascii85_inline_image_does_not_terminate_at_gt_ei_inside_payload() { assert_eq!(img, b"!!!!>EI !!~>"); } } + +/// The operators the renderer's tokenizer reads from `data`. +fn renderer_operators(data: &[u8]) -> Vec> { + let mut iter = hayro_syntax::content::UntypedIter::new(data); + let mut out = Vec::new(); + while let Some(instruction) = iter.next() { + out.push(instruction.operator.as_ref().to_vec()); + } + out +} + +/// Inline images end where the renderer ends them, so both read the same operators after one: +/// an `EI` without whitespace before it in the data, upper-case text ending in "EI" after the +/// image, and an `EI` inside compressed bytes (#3579). +#[test] +fn inline_images_end_where_the_renderer_ends_them() { + let flate: Vec = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect(); + let mut stored = b"q BI /W 64 /H 1 /BPC 8 /CS /G /F /Fl ID ".to_vec(); + stored.extend_from_slice(&flate); + stored.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET"); + let fixtures: [&[u8]; 6] = [ + b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", + b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F> EI BT /F1 10 Tf 10 100 Td (ZWEI) Tj (EIN FREI BEI) Tj ET", + b"q BI /W 2 /H 1 /CS /G /BPC 8 /F /AHx ID 7F7F> EI Q BT (DREI) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI", + b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID ! EI !!~>\nEI Q", + b"q BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q", + &stored, + ]; + for data in fixtures { + let parsed = parse(data); + let ours: Vec> = parsed.ops.iter().map(|op| op.op.clone()).collect(); + assert_eq!(ours, renderer_operators(data), "{}", String::from_utf8_lossy(data)); + assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data)); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8937cf6780..3012a1f3fe 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2320,6 +2320,30 @@ fn the_renderer_check_refuses_text_still_shown_under_a_mark() { assert!(refused(&cos), "the labelled input stream was not checked"); } +/// Inline image data `7FEI 1 0 0 1 200 0 cm` holds an `EI` the renderer ends the image at, so it +/// runs the `cm` and shows SECRET 200 pt to the right. Redaction reads the image the same way, so +/// a mark drawn over the word as shown takes it out of the file. +#[test] +fn text_after_an_inline_image_is_redacted_where_the_page_shows_it() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page( + "/Font << /F1 4 0 R >>", + "BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", + ); + let id = s.open("inline.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + assert!(!page_texts(&s, id).concat().contains("SECRET")); + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(6).any(|w| w == b"SECRET"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" + ); +} + /// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer /// draws it, so a mark drawn over it as displayed takes it out of the file. #[test] diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index fca3a1da88..b4186fd4e9 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -74,6 +74,10 @@ pub(crate) struct Output { /// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was /// in force: the operation must fail closed rather than leave that text under the mark. pub vertical: bool, + /// The content holds an inline image the renderer reads no image for + /// ([`pdfcraft_content::Parsed::ambiguous_images`]): what this scope placed after it may not + /// be what the page shows, so a redaction must not trust it. + pub ambiguous: bool, pub tag_changes: Vec<(ObjRef, ObjRef, HashSet, HashSet)>, pub error: Option, } @@ -789,6 +793,7 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); + out.ambiguous = parsed.ambiguous_images > 0; // A rewritten piece loses its comments, so keep where they began and ended: a comment that // ran on into the next piece still hides that piece's first line. let comment_edges = joined.comment_edges(&parsed); @@ -1293,6 +1298,7 @@ fn xobject( scope.fonts = saved_fonts; out.residue += inner.residue; out.vertical |= inner.vertical; + out.ambiguous |= inner.ambiguous; out.doomed.extend_from_slice(&inner.doomed); out.retired_properties.extend(inner.retired_properties); out.tag_changes.extend(inner.tag_changes); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 97d5bcd9fe..e67b373a11 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -411,6 +411,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", "", vec![]); + mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +} + +/// Without an `EI` the renderer accepts, where the operators resume is a guess: refused. +#[test] +fn a_page_whose_inline_image_the_renderer_cannot_end_is_refused() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F", "", vec![]); + mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); +} From 01aa6bcc17b09ad294777d7df49f6b52d6c275fe Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:31:38 +0000 Subject: [PATCH 125/191] docs(redact): describe the overlay, text state, description and inline image rules --- crates/content/README.md | 11 +++++++---- crates/redact/README.md | 22 ++++++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/crates/content/README.md b/crates/content/README.md index 6ab984dd49..fedeadc56b 100644 --- a/crates/content/README.md +++ b/crates/content/README.md @@ -13,7 +13,10 @@ The parser is tolerant (unreadable bytes are skipped and counted, never fatal) a panics. `redact` and (later) `edit` build their interpreters on it; the graphics-state tracker and the `Patch` API of architecture §6 grow here. -Unfiltered inline images with built-in or Indexed colour spaces, and image masks, use their -sample layout (including per-row byte padding) to locate `EI`. This preserves pixel bytes -that look like the terminator. Filtered images and resource-defined colour spaces retain -the tolerant delimiter heuristic; damaged or truncated layouts fall back to it too. +An inline image ends where the renderer's tokenizer (`hayro-syntax`) ends it, so the parser +and the renderer read the same operators after the image, and redaction removes what the page +actually draws. Only where that tokenizer reads no image (its parameters don't read, or no `EI` +passes its checks) does the parser locate `EI` by its own rules: unfiltered images with built-in +or Indexed colour spaces, and image masks, by their sample layout (including per-row byte +padding), other images by the tolerant delimiter heuristic. Such an image is counted in +`Parsed::ambiguous_images`, because the operators after it may not be the ones the page draws. diff --git a/crates/redact/README.md b/crates/redact/README.md index 2caf067b5b..cca3d51fdc 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -20,7 +20,8 @@ clear_marks(&mut doc, None)?; // remove marks without app - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx). Codecs PdfCraft can't re-encode (DCT, JPX, JBIG2, CCITT) are removed whole (fail-closed); inline images under an - area are removed. Images with separate `/SMask` or `/Mask` data are removed whole; + area are removed. Images with separate `/SMask` or `/Mask` data, and images whose data decodes + to more than twice their declared raster (plus a byte per row and 4 KiB), are removed whole; cleared copies drop `/Alternates` so alternate pixels cannot survive; - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; @@ -31,8 +32,13 @@ clear_marks(&mut doc, None)?; // remove marks without app page as a `/PCMark /Redaction` stream. A verification pass re-reads each redacted page and fails the operation if any glyph or inline -image is still under an area. Content that can't be decoded makes the operation fail rather than -leave it unredacted. A Redact annotation with neither a usable `/QuadPoints` array nor a usable +image is still under an area. It checks every content stream the page had, whatever its +dictionary calls it; only the overlay streams this `apply` added (`Report::overlays`) are skipped. +Text is placed with the font and size set by `Tf` or by an ExtGState's `/Font` through `gs`, and a +form XObject starts from the text state of the content that draws it. Content that can't be +decoded makes the operation fail rather than leave it unredacted, and so does an inline image the +renderer's tokenizer reads no image for (`RedactError::Unreadable`): where the page's operators +resume after it would be a guess. A Redact annotation with neither a usable `/QuadPoints` array nor a usable `/Rect` makes it fail with `RedactError::UnreadableMark` before anything changes, instead of skipping that mark. The count covers the marks on the pages being applied. @@ -50,15 +56,19 @@ as Sanitize drops them, so a full rewrite of a tagged PDF can't keep a removed c A tree where that can't be done (an element written in place, or one too large to walk) makes `apply` fail with `RedactError::Tags` rather than leave the annotation reachable. -Replacement descriptions are removed only from marked-content spans changed by cleanup. -Unchanged spans retain their descriptions; unused property entries are retired during redaction +Replacement descriptions (`/Alt`, `/ActualText`, `/E`) are removed from marked-content spans +changed by cleanup, from every structure element above them (found through both `/P` and `/K`), +and from elements that lose an annotation reference, which also lose `/T`. An element written in +place above one whose descriptions go makes `apply` fail with `RedactError::Tags`. Unchanged +spans retain their descriptions; unused property entries are retired during redaction and hidden-content cleanup, including aliases in shared/inherited resources. Descriptions still used by surviving pages or forms are kept. A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's `/Resources` is then retired unless another page still inherits it, so it cannot keep a removed -XObject reachable. Pages that aren't redacted are left untouched. +XObject reachable. Surviving form XObjects lose the resource entries for removed XObjects their own +content doesn't draw, for the same reason. Pages that aren't redacted are left untouched. ## Remove hidden information / Sanitize From 8555d6f159a7b2d7d420185642b8901719b457a6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:43:38 +0000 Subject: [PATCH 126/191] fix(parity): cite the renamed renderer-check test and drop fixed redaction gaps --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa776..45dda39822 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5709,8 +5709,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::redaction_is_refused_when_the_page_still_shows_text_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"] -notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text whose font is set through an ExtGState or taken by a form from its caller, some predefined CMaps and rotated Type 3 fonts are not removed yet, so those documents cannot be redacted. Removed images and forms are unlinked from page-tree resources (own, inherited or shared), not yet from a form's own resources, annotation appearances or patterns" +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"] +notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" [[feature]] id = "protect.redact-glyph-removal" From 747fb2dfd888c57d6da2d5d69e103b0c310f7e7b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:26:45 +0000 Subject: [PATCH 127/191] fix(audit): count each word box a line has against the comparison bound covers walks a line's word boxes, so a page with few lines and many words did more work than the line count charged. --- crates/audit/src/lib.rs | 17 +++++++++++++---- crates/audit/src/tests.rs | 7 ++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 4f1877ef67..3ef8f67209 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -112,7 +112,8 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> /// Work and output bounds for one audit. #[derive(Clone, Copy)] struct Limits { - /// Cover-against-line comparisons per page. + /// Cover-against-line comparisons per page, counting a line once per word box it has (at + /// least once), since [`covers`] walks them. comparisons: usize, /// Bytes of covered text across all findings. covered_text: usize, @@ -129,13 +130,21 @@ impl Limits { struct Budget { limits: Limits, comparisons: usize, + /// What comparing one cover with every line of the current page costs. + page_cost: usize, covered_text: usize, } impl Budget { + /// Resets the comparison count for a page with these `lines`. + fn start_page(&mut self, lines: &[TextLine]) { + self.comparisons = 0; + self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(1))); + } + /// The text of the `lines` that `hides`, joined with single spaces. fn hidden_text(&mut self, lines: &[TextLine], hides: impl Fn(&TextLine) -> bool) -> Result { - self.comparisons = self.comparisons.saturating_add(lines.len()); + self.comparisons = self.comparisons.saturating_add(self.page_cost); if self.comparisons > self.limits.comparisons { return Err(AuditError::TooManyComparisons); } @@ -157,11 +166,11 @@ impl Budget { fn audit(doc: &Document, limits: Limits) -> Result, AuditError> { let copying = doc.permissions().is_none_or(|p| p.copy()); - let mut budget = Budget { limits, comparisons: 0, covered_text: 0 }; + let mut budget = Budget { limits, comparisons: 0, page_cost: 0, covered_text: 0 }; let mut out = Vec::new(); for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() { let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?; - budget.comparisons = 0; + budget.start_page(&lines); // 1. Opaque filled rectangles over extractable text. for r in cover::filled_rects(doc, &page.dict)? { let covered = budget.hidden_text(&lines, |line| is_cover(&r, line))?; diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index de501d01a7..e0ec3888c2 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -659,7 +659,8 @@ fn covered_text_across_findings_is_bounded() { } /// Covers compared against lines are bounded per page, so content that is quadratic in its -/// covers and lines fails the audit instead of running for hours. +/// covers and lines fails the audit instead of running for hours. A line costs one comparison per +/// word: 20 covers against 20 two-word lines are 800. #[test] fn cover_and_line_comparisons_are_bounded() { let mut content = Vec::new(); @@ -670,9 +671,9 @@ fn cover_and_line_comparisons_are_bounded() { content.extend_from_slice(b"1 g 0 0 10 10 re f\n"); } let doc = open(one_page(&content, "")); - let limits = crate::Limits { comparisons: 399, covered_text: usize::MAX }; + let limits = crate::Limits { comparisons: 799, covered_text: usize::MAX }; assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); - let limits = crate::Limits { comparisons: 400, covered_text: usize::MAX }; + let limits = crate::Limits { comparisons: 800, covered_text: usize::MAX }; assert!(crate::audit(&doc, limits).expect("within the budget").is_empty()); } From 3e16c71a51be74adfb1d4343d25b42975671611f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:27:10 +0000 Subject: [PATCH 128/191] fix(audit): count only the covered text a finding reports against the output bound A copy-restricted document reports no text, so it no longer fails the bound on text it withholds. --- crates/audit/src/lib.rs | 24 +++++++++++++++--------- crates/audit/src/tests.rs | 7 +++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 3ef8f67209..509ae977eb 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -115,7 +115,7 @@ struct Limits { /// Cover-against-line comparisons per page, counting a line once per word box it has (at /// least once), since [`covers`] walks them. comparisons: usize, - /// Bytes of covered text across all findings. + /// Bytes of covered text the findings report. covered_text: usize, } @@ -150,18 +150,24 @@ impl Budget { } let mut covered = String::new(); for line in lines.iter().filter(|l| hides(l)) { - let text = line.text.trim(); - self.covered_text = self.covered_text.saturating_add(text.len() + 1); - if self.covered_text > self.limits.covered_text { - return Err(AuditError::TooMuchOutput); - } if !covered.is_empty() { covered.push(' '); } - covered.push_str(text); + covered.push_str(line.text.trim()); } Ok(covered) } + + /// The text a finding reports for `covered`: none when copying is withheld. Charges what it + /// reports against the output bound. + fn report_text(&mut self, covered: String, copying: bool) -> Result { + let reported = if copying { covered } else { String::new() }; + self.covered_text = self.covered_text.saturating_add(reported.len()); + if self.covered_text > self.limits.covered_text { + return Err(AuditError::TooMuchOutput); + } + Ok(reported) + } } fn audit(doc: &Document, limits: Limits) -> Result, AuditError> { @@ -175,7 +181,7 @@ fn audit(doc: &Document, limits: Limits) -> Result, AuditError for r in cover::filled_rects(doc, &page.dict)? { let covered = budget.hidden_text(&lines, |line| is_cover(&r, line))?; if !covered.trim().is_empty() { - let covered_text = if copying { covered } else { String::new() }; + let covered_text = budget.report_text(covered, copying)?; out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text }); } } @@ -190,7 +196,7 @@ fn audit(doc: &Document, limits: Limits) -> Result, AuditError AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect), AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect), }; - let covered_text = if copying { covered } else { String::new() }; + let covered_text = budget.report_text(covered, copying)?; out.push(AuditFinding { page: n, kind, rect, covered_text }); } } diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index e0ec3888c2..659f8de60f 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -697,8 +697,15 @@ fn copy_restricted_documents_withhold_covered_text() { let findings = audit_redactions(&user).expect("audit must run"); assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}"); assert_eq!(findings[0].covered_text, ""); + // The output bound counts the text reported, so withheld text costs nothing. + let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 }; + assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1); let owner = Document::open_with_password(bytes, Some("owner")).expect("open"); let findings = audit_redactions(&owner).expect("audit must run"); assert_eq!(findings.len(), 1, "{findings:?}"); assert_eq!(findings[0].covered_text, "SECRET DATA 123"); + let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() }; + assert!(crate::audit(&owner, exact).is_ok()); + let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact }; + assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput))); } From 68c14c9ddca47a5495d2a271a34370b6f1dcc3ab Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:27:10 +0000 Subject: [PATCH 129/191] docs(audit): the comparison bound counts words --- crates/audit/README.md | 2 +- parity/acrobat-features.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/audit/README.md b/crates/audit/README.md index ce2c47915f..cfa2979c21 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -33,7 +33,7 @@ Limits, by design and documented rather than hidden: less than 40% of its words (a sliver of a long value) is not flagged. It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper -than 32 or in a cycle, a walk past the operator bound, more than 50 million cover-against-line +than 32 or in a cycle, a walk past the operator bound, more than 50 million cover-against-word comparisons on a page, or more than 64 MiB of covered text in the report is an error, never "no findings". diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3d209c87f3..6a2d0ccf98 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5842,7 +5842,7 @@ status = "partial" commands = [] tools = ["doc_audit_redactions"] evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/it/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/it/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/it/audit.rs::audit_rectangles_match_the_tool_in_top_left_points", "crates/audit/src/tests.rs::rectangle_filled_after_restore_is_a_cover", "crates/audit/src/tests.rs::later_text_on_the_line_does_not_clear_an_earlier_cover", "crates/audit/src/tests.rs::indirect_subtype_is_resolved", "crates/audit/src/tests.rs::copy_restricted_documents_withhold_covered_text", "crates/audit/src/tests.rs::cover_and_line_comparisons_are_bounded", "crates/audit/src/tests.rs::covered_text_across_findings_is_bounded"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the start of the text line it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content, or more than 50 million cover-against-line comparisons on a page or 64 MiB of covered text in the report, is an error, never a clean bill. A document that withholds copying gets every finding with the covered text left out. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." +notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the start of the text line it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content, or more than 50 million cover-against-word comparisons on a page or 64 MiB of covered text in the report, is an error, never a clean bill. A document that withholds copying gets every finding with the covered text left out. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." [[feature]] id = "protect.remove-hidden-on-close" From 39844f06b58bc96bae72d3fb56faaa3b6201c945 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 15:39:48 -0400 Subject: [PATCH 130/191] Release 0.12.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 68 +++++++++++++++++++++++++-------------------------- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e318b7a77c..6518430017 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3936,7 +3936,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "craft-quit", @@ -3962,7 +3962,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3974,7 +3974,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3985,7 +3985,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3996,7 +3996,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4036,11 +4036,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.11.0" +version = "0.12.0" [[package]] name = "pdfcraft-content" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4048,7 +4048,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "hayro-syntax", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.11.0" +version = "0.12.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.11.0" +version = "0.12.0" dependencies = [ "aes", "md-5", @@ -4091,7 +4091,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4106,7 +4106,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "craft-docpath", @@ -4147,14 +4147,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", "proptest", @@ -4164,7 +4164,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.11.0" +version = "0.12.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4187,11 +4187,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.11.0" +version = "0.12.0" [[package]] name = "pdfcraft-js" -version = "0.11.0" +version = "0.12.0" dependencies = [ "boa_engine", "chrono", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4212,14 +4212,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.11.0" +version = "0.12.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4229,7 +4229,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "pdfcraft-annot", @@ -4242,11 +4242,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.11.0" +version = "0.12.0" [[package]] name = "pdfcraft-organize" -version = "0.11.0" +version = "0.12.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4257,7 +4257,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-channel", "futures-lite", @@ -4267,7 +4267,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4278,7 +4278,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.11.0" +version = "0.12.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4306,7 +4306,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.11.0" +version = "0.12.0" dependencies = [ "bytemuck", "flate2", @@ -4325,7 +4325,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.11.0" +version = "0.12.0" dependencies = [ "aes", "aws-lc-rs", @@ -4360,11 +4360,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.11.0" +version = "0.12.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.11.0" +version = "0.12.0" dependencies = [ "arboard", "base64 0.22.1", @@ -4409,7 +4409,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.11.0" +version = "0.12.0" dependencies = [ "eframe", "egui", @@ -4421,7 +4421,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "image", @@ -4435,7 +4435,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.11.0" +version = "0.12.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index 2012403144..5841b50c68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.11.0" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.11.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.11.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.11.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.11.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.11.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.11.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.11.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" } -pdfcraft-model = { path = "crates/model", version = "0.11.0" } -pdfcraft-content = { path = "crates/content", version = "0.11.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.11.0" } -pdfcraft-print = { path = "crates/print", version = "0.11.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.11.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" } -pdfcraft-js = { path = "crates/js", version = "0.11.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.11.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" } -pdfcraft-export = { path = "crates/export", version = "0.11.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.11.0" } -pdfcraft-create = { path = "crates/create", version = "0.11.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.11.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" } -pdfcraft-render = { path = "crates/render", version = "0.11.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.11.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.11.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.11.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.12.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.12.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.12.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.12.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.12.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.12.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.12.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.12.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.12.0" } +pdfcraft-model = { path = "crates/model", version = "0.12.0" } +pdfcraft-content = { path = "crates/content", version = "0.12.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.12.0" } +pdfcraft-print = { path = "crates/print", version = "0.12.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.12.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.12.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.12.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.12.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.12.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.12.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.12.0" } +pdfcraft-js = { path = "crates/js", version = "0.12.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.12.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.12.0" } +pdfcraft-export = { path = "crates/export", version = "0.12.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.12.0" } +pdfcraft-create = { path = "crates/create", version = "0.12.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.12.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.12.0" } +pdfcraft-render = { path = "crates/render", version = "0.12.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.12.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.12.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.12.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.12.0", default-features = false } # pdf hayro = "0.7.1" From 3dbdc13b1d8b2eaeca3b26619ef16790945f5df3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:46:11 +0000 Subject: [PATCH 131/191] Image resolution walk charges each form entry to the page's form allowance --- crates/optimize/src/images.rs | 4 ++++ crates/optimize/tests/optimize.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 0eb882ec2c..896542285d 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -127,6 +127,10 @@ impl Walk<'_> { } self.rewalks += 1; } + if !self.budget.enter_form() { + self.complete = false; + return; + } let Ok(data) = self.budget.decode(s) else { self.complete = false; return; diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index fd1ef3cd94..6cf53d4fc9 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1023,6 +1023,19 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() { assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); } +/// Each form entry counts against the page's form allowance, even for a form that decodes to +/// nothing and so costs no decoded bytes. +#[test] +fn images_are_not_resampled_when_a_page_enters_more_forms_than_allowed() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let empty = form(&mut doc, None, ""); + let draws = "/E Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1); + page(&mut doc, &[("E", empty), ("Im", img)], &format!("q 72 0 0 72 36 36 cm /Im Do Q {draws}")); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); +} + #[test] fn image_resolution_keeps_comments_across_content_pieces() { for ending in ["", "\n", "\r", "\r\n"] { From e0e7d4462eb127f850b01b0221227634295afb77 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:46:50 +0000 Subject: [PATCH 132/191] Cover audit charges each form entry to the page's form allowance --- crates/audit/src/cover.rs | 10 +++++++--- crates/audit/src/lib.rs | 2 ++ crates/audit/src/tests.rs | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 516bb193d2..93394ccfb0 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -109,7 +109,8 @@ struct Walker<'a> { pieces: &'a Pieces, out: Vec, ops_seen: usize, - /// Decoded bytes left for the page, its forms included: spending it fails the audit. + /// Decoded bytes and form entries left for the page, its forms included: spending either + /// fails the audit. budget: DecodeBudget, } @@ -273,6 +274,9 @@ impl<'a> Walker<'a> { }) .unwrap_or(Matrix::IDENTITY); let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + if !self.budget.enter_form() { + return Err(AuditError::TooManyForms); + } // A form stream that cannot be decoded fails the audit: it might hide a cover. let data = self.budget.decode_strict(stream)?; let ops = parse(&data).ops; @@ -306,8 +310,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { /// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint /// positions. A page's content streams are walked once, joined, with one graphics state, so /// state set in an earlier stream is still in force in the later ones. Any content stream that -/// cannot be decoded, or content and forms that decode past one page's [`DecodeBudget`] in all, -/// is an audit failure, never an empty result. +/// cannot be decoded, or content and forms that decode or enter past one page's [`DecodeBudget`] +/// in all, is an audit failure, never an empty result. pub fn filled_rects(doc: &Document, page: &Dict) -> Result, AuditError> { let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let contents: Vec = match page.get(b"Contents") { diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 365889d61a..8277cb0aa6 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -77,6 +77,8 @@ pub enum AuditError { TooManyOps, #[error("form XObjects nest deeper than the audit follows")] TooDeep, + #[error("content walk entered more form XObjects than the audit follows")] + TooManyForms, #[error("page has a malformed /Annots entry")] BadAnnots, #[error(transparent)] diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 8155c6dece..3d0c80e0b9 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -611,3 +611,17 @@ fn repeated_stream_references_share_one_decode_budget() { let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws"); } + +#[test] +fn form_entries_past_the_page_allowance_fail_the_audit() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + // An empty form decodes to no bytes, so only the count of entries bounds drawing it again. + let mut doc = Document::new_empty(); + let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::from_raw(form_dict, Vec::new())); + let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1).into_bytes())); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); + assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms))); +} From e2a67716419d7a6c186ce45e48542dbf444288f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:48:33 +0000 Subject: [PATCH 133/191] Redaction charges a page's content streams and its forms to one decode budget --- crates/redact/src/interp.rs | 11 +++++++---- crates/redact/src/lib.rs | 22 ++++++++++++++++----- crates/redact/src/sanitize.rs | 7 ++++--- crates/redact/src/tests.rs | 36 +++++++++++++++++++++++++++++++++-- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 0a3b730f60..9851c2e8c0 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -106,13 +106,16 @@ pub(crate) struct Scope<'a> { fonts: HashMap, Rc>, used_names: Vec>, depth: usize, - /// Form entries and decoded form bytes left for this page: shared forms drawn from other - /// shared forms would otherwise multiply the work with every level. + /// Form entries and decoded bytes left for this page, its content streams already charged: + /// shared forms drawn from other shared forms would otherwise multiply the work with every + /// level. budget: DecodeBudget, } impl<'a> Scope<'a> { - pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report) -> Self { + /// A scope whose form walk spends `budget`, what is left of the page's allowance after its + /// content streams were decoded. + pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self { Scope { rects, mode, @@ -127,7 +130,7 @@ impl<'a> Scope<'a> { fonts: HashMap::new(), used_names: Vec::new(), depth: 0, - budget: DecodeBudget::page(), + budget, } } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index f847977201..fce9f432a9 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -230,6 +230,17 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { /// A page's content streams: (the references or inline objects as listed, their decoded data), /// decoded within one page's [`pdfcraft_cos::DecodeBudget`]. pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec, Vec>), RedactError> { + page_streams_within(doc, page, index, &mut pdfcraft_cos::DecodeBudget::page()) +} + +/// [`page_streams`], decoded against `budget`, which the walk of the page's forms then goes on +/// spending. +pub(crate) fn page_streams_within( + doc: &Document, + page: &Dict, + index: usize, + budget: &mut pdfcraft_cos::DecodeBudget, +) -> Result<(Vec, Vec>), RedactError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -238,7 +249,6 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< }, }; let mut data = Vec::new(); - let mut budget = pdfcraft_cos::DecodeBudget::page(); for o in &list { match &*doc.resolve(o) { // Strict: a tolerant decode keeps what precedes corruption and drops the rest, which @@ -396,10 +406,11 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); // 1. Content. - let (list, data) = page_streams(doc, &page.dict, pi)?; + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let out = { - let mut scope = Scope::new(&rects, Mode::Apply, &mut report); + let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget); process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot @@ -601,13 +612,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - let (list, data) = page_streams(doc, &page.dict, pi)?; + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; // The overlay stream draws text of its own: the overlay label is allowed. let original: Vec> = list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect(); let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut scratch = Report::default(); - let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch); + let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch, budget); residue += process(doc, &mut scope, &original, &resources, pdfcraft_content::Matrix::IDENTITY).residue; } if residue > 0 { diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 5b2918f01c..0b6c04fdf0 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -8,7 +8,7 @@ use pdfcraft_content::Matrix; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::interp::{Mode, Scope, process}; -use crate::{RedactError, Report, annots_of, listed_annotations, page_streams}; +use crate::{RedactError, Report, annots_of, listed_annotations, page_streams, page_streams_within}; /// The categories of Acrobat's Remove Hidden Information panel that PdfCraft handles. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -356,7 +356,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let n = pdfcraft_model::pages(doc).len(); for pi in 0..n { let page = pdfcraft_model::pages(doc).swap_remove(pi); - let Ok((list, data)) = page_streams(doc, &page.dict, pi) else { + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let Ok((list, data)) = page_streams_within(doc, &page.dict, pi, &mut budget) else { if write { return Err(RedactError::Unreadable(pi + 1)); } @@ -368,7 +369,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut report = Report::default(); let mode = if write { Mode::Apply } else { Mode::Verify }; - let mut scope = Scope::new(&[], mode, &mut report); + let mut scope = Scope::new(&[], mode, &mut report, budget); if text { let c = page.crop(doc); scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 550903e494..2478e0b31b 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -48,10 +48,11 @@ fn content(doc: &Document, page: usize) -> String { /// Glyphs (and inline images) of page `page` under `rects` (the verifier's count). fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize { let p = pdfcraft_model::pages(doc).swap_remove(page); - let (_, data) = page_streams(doc, &p.dict, page).unwrap(); + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (_, data) = page_streams_within(doc, &p.dict, page, &mut budget).unwrap(); let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut rep = Report::default(); - let mut scope = Scope::new(rects, Mode::Verify, &mut rep); + let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget); process(doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY).residue } @@ -2713,3 +2714,34 @@ fn repeated_name_tree_kids_count_towards_the_node_budget() { let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); assert_eq!(counts[&Hidden::Attachments], 1, "the walk went past its node budget to the last kid"); } + +/// Content streams and the forms they draw spend one page allowance: 130 MiB of content and a +/// 130 MiB form are each under it, together past it. +#[test] +fn content_and_forms_share_one_budget_per_page() { + use pdfcraft_cos::{Dict, Stream}; + let mut doc = one_page(b"", "", vec![]); + let mut content = b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET /X Do\n%".to_vec(); + content.resize(130 << 20, b'x'); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), &content))); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + let mut body = b"%".to_vec(); + body.resize(130 << 20, b'x'); + let form = doc.add(Object::Stream(Stream::flate(form, &body))); + doc.update_dict(ObjRef::new(3, 0), |d| { + let mut xobjects = Dict::new(); + xobjects.set(b"X".to_vec(), Object::Ref(form)); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0))); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + d.set(b"Contents".to_vec(), Object::Ref(content)); + }) + .unwrap(); + mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], ""); + assert!(apply(&mut doc, None).is_err(), "the form past the page's allowance was read"); +} From f980b34ef3c5bc866809dba24f76a4a7f2b153a8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:49:40 +0000 Subject: [PATCH 134/191] Text and image reading charge a page's content and its forms to one decode budget --- crates/edit/src/images.rs | 5 +++-- crates/edit/src/tests.rs | 18 +++++++++++++++++ crates/edit/src/text.rs | 41 +++++++++++++++++++++++---------------- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 1f7773a52c..6f14271be4 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -377,8 +377,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; - let pieces = joined(&content_streams(doc, &p.dict)?); + let mut decode = pdfcraft_cos::DecodeBudget::page(); + let pieces = joined(&crate::text::content_streams_within(doc, &p.dict, &mut decode)?); + let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::new(decode) }; walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 61e5e958ed..1395fb1c35 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6430,3 +6430,21 @@ fn reading_images_stops_at_the_page_record_limit() { assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len()); } } + +#[test] +fn reading_spends_one_decode_budget_on_content_and_forms() { + // 130 MiB of content and a 130 MiB form (a kilobyte each on disk): each fits a page's budget, + // together they don't, so reading leaves the form out and says so. + let doc = page_with(|doc| { + let mut body = b"%".to_vec(); + body.resize(130 << 20, b'x'); + let form: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::flate(form, &body)); + let mut content = b"/X Do\n%".to_vec(); + content.resize(130 << 20, b'x'); + let content = doc.add(Stream::flate(Dict::new(), &content)); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + (Object::Ref(content), [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect()) + }); + assert!(crate::text::reading_blocks_checked(&doc, 0).unwrap().truncated); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 34768527b3..1f35bcd472 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -621,6 +621,12 @@ struct Ts { /// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading /// or rewriting a page from part of its content would lose the rest. pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result)>, EditError> { + content_streams_within(doc, page, &mut DecodeBudget::page()) +} + +/// [`content_streams`], decoded against `budget`, which a walk of the page's forms then goes on +/// spending. +pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result)>, EditError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -628,7 +634,6 @@ pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result vec![c.clone()], }, }; - let mut budget = DecodeBudget::page(); let mut out = Vec::with_capacity(list.len()); for o in list { let data = match &*doc.resolve(&o) { @@ -1500,24 +1505,25 @@ pub const MAX_FORM_DEPTH: usize = 50; pub const MAX_FORM_VISITS: usize = 50_000; /// One page's walk through the form XObjects it draws: how many it entered (the page's -/// [`MAX_FORM_VISITS`]), the bytes their content may decode to in all, and whether a limit left -/// a form unread. +/// [`MAX_FORM_VISITS`]), the bytes the page's content and forms may still decode to, and whether +/// a limit left a form unread. pub(crate) struct FormBudget { visits: usize, decode: DecodeBudget, - /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past - /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk. + /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`], comes past + /// [`MAX_FORM_VISITS`] or past the decode budget: what it draws is missing from the walk. pub(crate) truncated: bool, } +impl FormBudget { + /// The walk of a page's forms, spending what `decode` has left after the page's content. + pub(crate) fn new(decode: DecodeBudget) -> Self { + Self { visits: 0, decode, truncated: false } + } +} + /// A form XObject a page draws: its object, content, resources and its matrix composed with the /// CTM at the `Do`. -impl Default for FormBudget { - fn default() -> Self { - Self { visits: 0, decode: DecodeBudget::page(), truncated: false } - } -} - pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, @@ -1632,8 +1638,9 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E } let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict)?; - let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() }; + let mut decode = DecodeBudget::page(); + let streams = content_streams_within(doc, &p.dict, &mut decode)?; + let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::new(decode) }; let mut carry = Carry::new(); // Read the page's streams as the one stream they are, in one pass, as rendering and editing // do: a comment, an operator's operands or a marked-content sequence (its /ActualText too) @@ -2506,10 +2513,10 @@ pub fn reading_blocks(doc: &Document, page: usize) -> Result, Edi pub struct ReadingBlocks { /// The paragraphs, as [`reading_blocks`] returns them. pub blocks: Vec, - /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], or more of them than - /// [`MAX_FORM_VISITS`]. Reading leaves those out to stay bounded on hostile files, so their - /// text is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses - /// their images too. + /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], more of them than + /// [`MAX_FORM_VISITS`], or more content and forms than one page's decode budget. Reading + /// leaves those out to stay bounded on hostile files, so their text is missing from + /// `blocks`; [`crate::reading_images`] walks the same forms and misses their images too. pub truncated: bool, } From 089882f372ce0c6517be24ada8c80fe236ff14d3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:50:42 +0000 Subject: [PATCH 135/191] Marking a figure decorative refuses content past the page's decode budget --- crates/a11y/src/alt.rs | 13 ++++++++++++- crates/a11y/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 4d2dab646e..f22a25f733 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -23,6 +23,8 @@ pub enum AltError { Untagged, #[error("object {0} is not a figure")] NotAFigure(u32), + #[error("the page has more content than can be edited")] + TooLarge, #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } @@ -375,7 +377,16 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: let mut budget = DecodeBudget::page(); for entry in &entries { match &*doc.resolve(entry) { - Object::Stream(s) => streams.push(Some((s.clone(), budget.decode(s).unwrap_or_default()))), + Object::Stream(s) => { + let data = match budget.decode(s) { + Ok(data) => data, + // Content past the budget may hold more of the figure: editing the rest + // would leave that part marked as a figure the tree no longer has. + Err(_) if budget.exhausted() => return Err(AltError::TooLarge), + Err(_) => Vec::new(), + }; + streams.push(Some((s.clone(), data))); + } _ => streams.push(None), } } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 1c79caf3e7..7db743536a 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1262,3 +1262,19 @@ fn repeated_content_references_share_one_decode_budget() { let data = crate::alt::page_content(&doc, &page); assert!(data.len() <= MAX_PAGE_DECODED + 300, "{} bytes", data.len()); } + +/// A figure whose marked content goes on in a stream past the page's decode budget is refused, +/// not removed with that content still pointing at it. +#[test] +fn marking_decorative_refuses_content_past_the_decode_budget() { + use pdfcraft_cos::{Dict, Object, ObjRef, Stream}; + let mut doc = figures_doc(); + let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let last = doc.add(Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >> BDC 0 0 5 5 re f EMC".to_vec())); + let mut contents = vec![Object::Ref(ObjRef::new(4, 0))]; + contents.extend(std::iter::repeat_n(Object::Ref(filler), 256)); + contents.push(Object::Ref(last)); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap(); + assert!(mark_decorative(&mut doc, ObjRef::new(7, 0)).is_err()); + assert_eq!(figures(&doc).len(), 2, "the figure stays"); +} From 9d5bcc074590f69585f232e485808cc7ba4053b6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:51:47 +0000 Subject: [PATCH 136/191] Accessibility check asks for a manual check where page content ran past the decode budget --- crates/a11y/src/content.rs | 21 +++++++++++++++++++-- crates/a11y/src/lib.rs | 12 ++++++++++++ crates/a11y/src/tests.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index 66f83867e3..fd5f0d305f 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -20,6 +20,8 @@ pub(crate) struct Scan { pub untagged: BTreeMap, /// Page → fonts used. pub fonts: BTreeMap>, + /// Pages read only in part: content or forms past the page's decode budget are missing. + pub incomplete: BTreeSet, } const MAX_FORM_DEPTH: usize = 12; @@ -32,6 +34,9 @@ impl Scan { let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut budget = DecodeBudget::page(); let data = crate::alt::page_content_within(doc, &page.dict, &mut budget); + if budget.remaining() == 0 { + s.incomplete.insert(p); + } let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget }; w.content(&data, &res, false, 0); } @@ -100,7 +105,20 @@ impl Walk<'_> { self.scan.has_images = true; untagged += usize::from(!inside); } - Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) && self.budget.enter_form() => { + Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) => { + if !self.budget.enter_form() { + self.scan.incomplete.insert(self.page); + continue; + } + let data = match self.budget.decode(x) { + Ok(data) => data, + Err(_) => { + if self.budget.exhausted() { + self.scan.incomplete.insert(self.page); + } + continue; + } + }; self.forms.insert(r); let inner = x .dict @@ -108,7 +126,6 @@ impl Walk<'_> { .map(|o| self.doc.resolve(o)) .and_then(|o| o.as_dict().cloned()) .unwrap_or_else(|| res.clone()); - let data = self.budget.decode(x).unwrap_or_default(); self.content(&data, &inner, inside, depth + 1); self.forms.remove(&r); } diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 21d8ea5820..280f4e845f 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -681,6 +681,18 @@ pub fn check(doc: &Document, options: &Options) -> Report { } } } + // A page read only in part may hold what the scan didn't find: text that makes the + // document more than images, untagged content, or a font without Unicode mappings. + let unsure = !scan.incomplete.is_empty() + && match rule { + Rule::ImageOnly => !scan.has_text, + Rule::TaggedContent | Rule::CharacterEncoding => findings.is_empty(), + _ => false, + }; + if unsure { + results.push(RuleResult { rule, status: Status::Manual, findings: Vec::new() }); + continue; + } if total > MAX_FINDINGS { let more = total - MAX_FINDINGS; findings.truncate(MAX_FINDINGS); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 7db743536a..ed3a5146e2 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1278,3 +1278,31 @@ fn marking_decorative_refuses_content_past_the_decode_budget() { assert!(mark_decorative(&mut doc, ObjRef::new(7, 0)).is_err()); assert_eq!(figures(&doc).len(), 2, "the figure stays"); } + +/// Text past the page's decode budget is unread, so a page that shows images and no text so far +/// can't be called image-only, nor its content all tagged. +#[test] +fn content_past_the_decode_budget_needs_a_manual_check() { + use pdfcraft_cos::{Dict, Object, ObjRef, Stream}; + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>".into(), + stream("/Artifact BMC q 200 0 0 200 0 0 cm /Im Do Q EMC"), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" + .into(), + FONT.into(), + ], + "", + ); + let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let text = doc.add(Stream::from_raw(Dict::new(), b"BT /F1 12 Tf 10 10 Td (Hello) Tj ET".to_vec())); + let mut contents = vec![Object::Ref(ObjRef::new(4, 0))]; + contents.extend(std::iter::repeat_n(Object::Ref(filler), 256)); + contents.push(Object::Ref(text)); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap(); + let report = check(&doc, &Options::default()); + assert_eq!(status(&report, Rule::ImageOnly), Status::Manual); + assert_eq!(status(&report, Rule::TaggedContent), Status::Manual); +} From f02b998122731132cbf7c119a7f41630160908d6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 19:54:10 +0000 Subject: [PATCH 137/191] Format the accessibility tests --- crates/a11y/src/tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index ed3a5146e2..9332d29dfa 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1267,7 +1267,7 @@ fn repeated_content_references_share_one_decode_budget() { /// not removed with that content still pointing at it. #[test] fn marking_decorative_refuses_content_past_the_decode_budget() { - use pdfcraft_cos::{Dict, Object, ObjRef, Stream}; + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; let mut doc = figures_doc(); let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); let last = doc.add(Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >> BDC 0 0 5 5 re f EMC".to_vec())); @@ -1283,12 +1283,13 @@ fn marking_decorative_refuses_content_past_the_decode_budget() { /// can't be called image-only, nor its content all tagged. #[test] fn content_past_the_decode_budget_needs_a_manual_check() { - use pdfcraft_cos::{Dict, Object, ObjRef, Stream}; + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; let mut doc = pdf( &[ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>" + .into(), stream("/Artifact BMC q 200 0 0 200 0 0 cm /Im Do Q EMC"), "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" .into(), From 02f0f7396f6b823bc7736fcc0a909b382aed2651 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:04:04 +0000 Subject: [PATCH 138/191] fix(annot): count web-address text after its UTF-8 conversion 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. --- crates/annot/src/links.rs | 12 ++++++++---- crates/annot/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index b6a2c7575c..6574881377 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -78,7 +78,8 @@ pub struct LinkItem { pub style: LinkStyle, } -/// Web-address text one listing keeps; past it a link lists as [`LinkAction::Other`]. +/// Bytes of web-address text one listing keeps, counted after conversion to UTF-8; past it a link +/// lists as [`LinkAction::Other`]. pub(crate) const MAX_URI_BYTES: usize = 16 << 20; fn nums(doc: &Document, o: Option<&Object>) -> Vec { @@ -103,10 +104,13 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) Some(b"URI") => { let uri = a.get(b"URI").map(|u| doc.resolve(u)); let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]); - match uri_budget.checked_sub(bytes.len()) { - Some(left) => { + // The raw length first, so an address already over the budget is never converted; then the + // converted text, which is up to three times longer where the bytes are not UTF-8. + let text = (bytes.len() <= *uri_budget).then(|| String::from_utf8_lossy(bytes)); + match text.and_then(|t| Some((uri_budget.checked_sub(t.len())?, t))) { + Some((left, text)) => { *uri_budget = left; - LinkAction::Uri(String::from_utf8_lossy(bytes).into_owned()) + LinkAction::Uri(text.into_owned()) } None => LinkAction::Other("web address too long".into()), } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index c52732ea85..6eb94b8304 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1541,6 +1541,25 @@ fn link_listing_skips_repeated_refs_and_caps_web_address_text() { assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10)); } +#[test] +fn web_address_cap_counts_the_text_kept_after_conversion() { + // 200 links sharing a 64 KiB address of invalid UTF-8: 12.5 MiB of bytes, each byte kept as a + // three-byte replacement character. + let annots = "<< /Subtype /Link /Rect [0 0 1 1] /A 4 0 R >>".repeat(200); + let doc = doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + "<< /S /URI /URI 5 0 R >>".into(), + format!("<{}>", "FF".repeat(64 << 10)), + ]); + let all = links::list(&doc); + assert_eq!(all.len(), 200); + let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum(); + assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept"); + assert!(kept > 0); +} + #[test] fn cloud_outline_has_a_bump_budget() { // 50 long edges would each ask for 10,000 bumps of radius 1. From 071130d49868409f68066c55b74c0d7366e90699 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:05:05 +0000 Subject: [PATCH 139/191] fix(render): list links and attachments after the comment budget fills 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. --- crates/render/src/inspect.rs | 37 ++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 6c3bd2c0de..91be4de372 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -1166,6 +1166,9 @@ impl<'a> Inspector<'a> { // first of them (§12.5.2 allows one page per annotation). let mut seen = HashSet::new(); let mut kept = 0usize; + // Once the comment budget is spent, comments are no longer built, but links and attachments + // are still listed. + let mut comments_full = false; for (id, page) in pages { let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; if let Ok(Object::Reference(r)) = page_dict.get(b"Annots") @@ -1204,6 +1207,9 @@ impl<'a> Inspector<'a> { { continue; } + if comments_full { + continue; + } let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); let mut color = match d.get(b"C").map(|o| self.resolve(o)) { Ok(Object::Array(c)) if c.len() == 3 => { @@ -1265,13 +1271,11 @@ impl<'a> Inspector<'a> { }; kept = kept.saturating_add(retained(&annotation)); if kept > MAX_ANNOTATION_BYTES { - break; + comments_full = true; + continue; } info.annotations.push(annotation); } - if kept > MAX_ANNOTATION_BYTES { - break; - } } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); } @@ -2652,6 +2656,31 @@ trailer << /Root 1 0 R >> assert!(notes.iter().map(retained).sum::() <= MAX_ANNOTATION_BYTES); } + /// Links and attachments after the comment budget fills are still listed, and a JavaScript link + /// among them still marks the document. + #[test] + fn links_and_attachments_past_the_comment_budget_are_listed() { + let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 6 0 R >> ".repeat(1100); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{small} \ + << /Subtype /Link /Rect [0 0 1 1] /A << /S /JavaScript /JS (1) >> >>] >> endobj\n\ + 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ + << /Subtype /FileAttachment /Rect [0 0 1 1] /FS << /Type /Filespec /F (a.txt) >> >>] >> endobj\n\ + 6 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + "x".repeat(64 << 10), + ); + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + assert!(info.annotations.len() < 1100, "the comment budget filled"); + assert_eq!(info.links.len(), 1); + assert!(info.has_javascript); + assert_eq!(info.attachments.len(), 1); + } + /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. From a887405ea15a459c7f5867c025b26d3afe9f9429 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:07:41 +0000 Subject: [PATCH 140/191] fix(sign): don't report valid when a DSS cap hides revocation evidence 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. --- crates/sign/src/pdf.rs | 115 +++++++++++++++++++------ crates/sign/src/pdf_discovery_tests.rs | 12 ++- crates/sign/tests/it/pdf.rs | 31 +++++++ 3 files changed, 126 insertions(+), 32 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 04598e0333..f4fe2c601e 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -892,7 +892,8 @@ fn finish_validation( // Revocation evidence embedded in the document security store, if any. A verified // revocation is final: later blocks must not soften the verdict. let signer = info.chain.first().cloned(); - let revoked = check_dss_revocation(doc, signer.as_ref(), &at, info); + let revocation = check_dss_revocation(doc, signer.as_ref(), &at, info); + let revoked = revocation == DssRevocation::Revoked; // Changes after signing. info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) { Modification::None @@ -929,6 +930,12 @@ fn finish_validation( problems = true; } } + if revocation == DssRevocation::Incomplete { + if !problems { + info.status = Status::Unknown; + } + problems = true; + } if let Some(t) = info.timestamp_time.or(info.signing_time) && !cert.valid_at(t) { @@ -975,46 +982,86 @@ fn finish_validation( } } -/// Evidence streams read from one `/DSS` array. +/// Distinct evidence streams read from one `/DSS` array. const MAX_DSS_ENTRIES: usize = 1024; /// Decoded bytes read from one `/DSS` array; real CRLs and OCSP responses are kilobytes each. const MAX_DSS_BYTES: usize = 64 << 20; /// The decoded streams of the `/DSS` evidence array `key` (`/CRLs`, `/OCSPs`), one at a time so /// each is checked before the next decodes: each stream once and within the evidence size cap, -/// at most [`MAX_DSS_ENTRIES`] of them and [`MAX_DSS_BYTES`] together. -fn dss_evidence<'a>(doc: &'a Document, dss: &Dict, key: &[u8]) -> impl Iterator> + 'a { - let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let mut items = items.into_iter().take(MAX_DSS_ENTRIES); - let mut seen = HashSet::new(); - let mut left = MAX_DSS_BYTES; - std::iter::from_fn(move || { - for item in items.by_ref() { - if left == 0 { - return None; - } - if item.as_ref().is_some_and(|r| !seen.insert(r)) { +/// at most [`MAX_DSS_ENTRIES`] distinct ones and [`MAX_DSS_BYTES`] together. A stream past a cap +/// sets [`DssEvidence::cut`] and ends the iteration. +struct DssEvidence<'a> { + doc: &'a Document, + items: std::vec::IntoIter, + seen: HashSet, + taken: usize, + left: usize, + /// A cap stopped the array being read in full. + cut: bool, +} + +impl<'a> DssEvidence<'a> { + fn new(doc: &'a Document, dss: &Dict, key: &[u8]) -> Self { + let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + DssEvidence { doc, items: items.into_iter(), seen: HashSet::new(), taken: 0, left: MAX_DSS_BYTES, cut: false } + } +} + +impl Iterator for DssEvidence<'_> { + type Item = Vec; + + fn next(&mut self) -> Option> { + if self.cut { + return None; + } + // Repeats are skipped before the caps count anything, so repeating one stream cannot use + // them up; the scan of the array itself is bounded by the parser's cap on array length. + for item in self.items.by_ref() { + if item.as_ref().is_some_and(|r| !self.seen.insert(r)) { continue; } - let o = doc.resolve(&item); + let o = self.doc.resolve(&item); let Object::Stream(s) = o.as_ref() else { continue }; - let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE.min(left)) else { continue }; - left = left.saturating_sub(bytes.len()); + if self.taken >= MAX_DSS_ENTRIES { + self.cut = true; + return None; + } + self.taken += 1; + // A stream past the evidence cap would not parse as evidence either. + let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE) else { continue }; + let Some(left) = self.left.checked_sub(bytes.len()) else { + self.cut = true; + return None; + }; + self.left = left; return Some(bytes); } None - }) + } +} + +/// What the catalog's `/DSS` says about the signer's certificate. +#[derive(Clone, Copy, Debug, PartialEq)] +enum DssRevocation { + /// Verified evidence revokes it. + Revoked, + /// A cap stopped the evidence being read in full, and what was read does not revoke it. + Incomplete, + /// All of the evidence was read, and none of it revokes it. + NotRevoked, } /// Check the revocation evidence in the catalog's `/DSS` against the signer's chain at `at`. /// Only evidence whose signature verifies against the issuer counts; returns whether the -/// certificate is revoked (which invalidates the signature). -fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Option