Files
craft-libs/crates/pdf/tests/cos/encryption.rs
T
Dylan M. Taylor fa389c7883 craft-pdf: the PDF object layer from PdfCraft (cos), on filters and crypt
craft-pdf: the PDF object layer from PdfCraft (`cos`), on filters and crypt

* hayro-jbig2: bound a stream's total decode work and what a page retains; no i32 overflow

Port of pdfcraft 85c10051 (patches 5-9), 82439525 (patch 10) and 10107d94 (patch 12).

* hayro-jbig2: PdfCraft's JBIG2 follow-ups (refinement fixes, file header, work accounting, dictionary pixel cap)

Ports pdfcraft 72e763ad, b7ec3669, e3bc4e85, 01d3479f, b9e068aa, 858ce960, 07f44038, e38b9d18, 8717a085, 58c30af3 and the hayro-jbig2 part of e2464e14 (with the hayro-jbig2 part of 0ed33990 they build on).

* Add craft-stream-codecs: the codec modules of PdfCraft's stream filters

LZW, ASCII85, ASCIIHex, RunLength, the TIFF and PNG predictors, bounded CCITT
fax decoding (through hayro-ccitt) with Group 4 encoding (through fax), and
the lossless JBIG2 encoder with its MQ coder, from pdfcraft-filters at
PdfCraft revision 80e26e674d1aa33078e83c8139875047f3289eb6.

The crate sits below craft-pdf and craft-tiff: it knows no container. The
PDF filter chain, Flate and the bounded JBIG2 decoding entry are not part of
it.

* craft-pdf: the PDF filter chain from PdfCraft (`filters`)

Adds the `filters` feature: the /Filter chain of pdfcraft-filters (PdfCraft
revision 80e26e674d1aa33078e83c8139875047f3289eb6) as craft_pdf::filters,
over the codecs in craft-stream-codecs. It adds Flate, strict and tolerant
chain decoding, byte accounting and a shared work allowance, and encoding,
with the chain's unit tests, its property tests and its fuzz target.

The code is unchanged apart from the imports of the moved codecs; FilterError
is craft-stream-codecs' CodecError under its old name.

* craft-stream-codecs: bounded JBIG2 decoding (decode_jbig2_within)

The JBIG2 decoding entry of pdfcraft-filters (PdfCraft revision
80e26e674d1aa33078e83c8139875047f3289eb6, src/jbig2/decode.rs) with its tests.
It spends from the caller's work allowance through hayro-jbig2's
Image::decode_within and charge_reading, which come with
fix/sec-hayro-jbig2-followups (#886).

* vendor/README.craft-reader.md: one #831 paragraph, the follow-ups after it

* craft-pdf: filters re-exports decode_jbig2_within (and lib.rs module order)

* craft-pdf: the object layer from PdfCraft (`cos`)

Adds the `cos` feature: PdfCraft's pdfcraft-cos crate (revision
1d2af9907ba772b368f6b54811359805fa5428e9) as craft_pdf::cos, on crypt and
filters, with its unit tests, integration tests and examples. Its test helpers
come with it: pdfcraft-testkit as src/cos/testkit.rs (cfg(test)) and the qpdf
runner of pdfcraft-oracle as tests/cos/oracle.rs.

The code is unchanged apart from module paths and doc comments. The child test
of file_identifier is named by its module path, since the integration tests
are one binary.

* craft-pdf: cos unit tests build for wasm

The property tests in src/cos/writer.rs need proptest, a dev-dependency of
the non-wasm targets only, and one test in src/cos/document/tests.rs times a
load on a thread, which wasm32-unknown-unknown does not have. Both are
compiled for non-wasm targets only; the other unit tests still build for wasm.
2026-11-11 22:44:59 -05:00

1320 lines
68 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Encryption end to end: protect a document with every algorithm, save it, and read it back
//! 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 craft_pdf::cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, Object, PdfString, SaveOptions, write_full, write_incremental};
const SECRET: &str = "BT /F1 18 Tf 20 100 Td (Secret page) Tj ET";
fn fixture() -> Vec<u8> {
fixture_with("")
}
/// The fixture with `extra` added to the content stream's dictionary.
fn fixture_with(extra: &str) -> Vec<u8> {
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 {}{extra} >>\nstream\n{SECRET}\nendstream", SECRET.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
"<< /Type /Metadata /Subtype /XML /Length 22 >>\nstream\n<x:xmpmeta>meta</x:xmpmeta>\nendstream".replace("22", "26"),
"<< /Title (Quarterly numbers) >>".to_string(),
];
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 /Info 7 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn protect(alg: Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
doc.set_encryption(&NewEncryption {
algorithm: alg,
user_password: user,
owner_password: owner,
permissions,
encrypt_metadata: true,
seed: [9; 32],
})
.unwrap();
write_full(&doc, &SaveOptions::default()).unwrap()
}
fn content(doc: &Document) -> String {
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();
match doc.resolve(page.get(b"Contents").unwrap()).as_ref() {
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).trim().to_string(),
_ => panic!("no content stream"),
}
}
fn title(doc: &Document) -> Option<String> {
let info = doc.resolve(doc.trailer().get(b"Info")?);
info.as_dict()?.get(b"Title").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text()))
}
const ALL: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256];
#[test]
fn every_algorithm_round_trips_and_hides_the_content() {
for alg in ALL {
let bytes = protect(alg, "user", "owner", -1);
let raw = String::from_utf8_lossy(&bytes);
assert!(!raw.contains("Secret page") && !raw.contains("Quarterly"), "{alg:?}: plaintext leaked");
let bytes = Arc::new(bytes);
assert!(matches!(Document::open(bytes.clone()), Err(CosError::NeedsPassword)), "{alg:?}");
assert!(matches!(Document::open_with_password(bytes.clone(), Some("guess")), Err(CosError::WrongPassword)), "{alg:?}");
let user = Document::open_with_password(bytes.clone(), Some("user")).unwrap();
assert_eq!(user.security().unwrap().auth(), Auth::User);
assert_eq!(content(&user), SECRET, "{alg:?}");
assert_eq!(title(&user).as_deref(), Some("Quarterly numbers"), "{alg:?}");
let owner = Document::open_with_password(bytes.clone(), Some("owner")).unwrap();
assert_eq!(owner.security().unwrap().auth(), Auth::Owner);
// Independent check: hayro decrypts our output. hayro-syntax 0.7 has no owner-password
// authentication for R2–R4 (Algorithm 7), so for those qpdf is the owner-password oracle
// (`qpdf_agrees`); hayro checks every user password and R6 owner passwords.
for pw in if alg == Algorithm::Aes256 { &["user", "owner"][..] } else { &["user"][..] } {
let hay = hayro_syntax::Pdf::new_with_password(bytes.as_ref().clone(), pw).unwrap_or_else(|e| panic!("{alg:?}/{pw}: {e:?}"));
let page = hay.pages().first().unwrap().page_stream().map(|s| String::from_utf8_lossy(s).trim().to_string());
assert_eq!(page.as_deref(), Some(SECRET), "{alg:?}/{pw}");
}
assert!(hayro_syntax::Pdf::new(bytes.as_ref().clone()).is_err(), "{alg:?}: hayro must not open it without a password");
}
}
#[test]
fn empty_user_password_opens_but_reports_restrictions() {
let bytes = Arc::new(protect(Algorithm::Aes256, "", "owner", 0b0100)); // print only
let doc = Document::open(bytes.clone()).unwrap();
let p = doc.permissions().unwrap();
assert!(p.print() && !p.modify() && !p.copy() && !p.assemble());
let owner = Document::open_with_password(bytes, Some("owner")).unwrap();
assert!(owner.permissions().unwrap().modify(), "the owner may do everything");
assert!(Document::open(Arc::new(fixture())).unwrap().permissions().is_none(), "unencrypted: no restrictions");
}
#[test]
fn the_permissions_password_unlocks_an_open_document() {
for alg in ALL {
// Only a permissions password: opens without asking, restricted (#995).
let mut doc = Document::open(Arc::new(protect(alg, "", "perms", 0b0100))).unwrap();
// Read something before unlocking: it must stay readable afterwards.
assert_eq!(content(&doc), SECRET, "{alg:?}");
assert_eq!(doc.unlock("").unwrap_err(), CosError::WrongPassword, "{alg:?}");
assert_eq!(doc.unlock("nope").unwrap_err(), CosError::WrongPassword, "{alg:?}");
assert!(!doc.permissions().unwrap().modify(), "{alg:?}: still restricted");
doc.unlock("perms").unwrap();
assert_eq!(doc.security().unwrap().auth(), Auth::Owner, "{alg:?}");
assert!(doc.permissions().unwrap().modify(), "{alg:?}");
assert_eq!(content(&doc), SECRET, "{alg:?}");
assert_eq!(title(&doc).as_deref(), Some("Quarterly numbers"), "{alg:?}");
// Unlocked, security can be removed.
doc.remove_encryption();
let plain = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(Document::open(Arc::new(plain)).unwrap().security().is_none(), "{alg:?}");
}
// Opened with a separate open password: that password doesn't unlock.
let mut doc = Document::open_with_password(Arc::new(protect(Algorithm::Aes256, "open", "perms", 0b0100)), Some("open")).unwrap();
assert_eq!(doc.unlock("open").unwrap_err(), CosError::WrongPassword);
doc.unlock("perms").unwrap();
assert!(doc.permissions().unwrap().modify());
// Nothing to unlock: unencrypted, or already the owner.
Document::open(Arc::new(fixture())).unwrap().unlock("anything").unwrap();
doc.unlock("anything").unwrap();
}
#[test]
fn incremental_edits_of_encrypted_documents_stay_encrypted() {
for alg in ALL {
let original = protect(alg, "user", "owner", -1);
let mut doc = Document::open_with_password(Arc::new(original.clone()), Some("user")).unwrap();
let info = doc.trailer().reference(b"Info").unwrap();
doc.update_dict(info, |d| d.set(b"Title".to_vec(), Object::String(PdfString::text("Revised figures")))).unwrap();
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert_eq!(&saved[..original.len()], &original[..], "{alg:?}: incremental");
assert!(!String::from_utf8_lossy(&saved).contains("Revised"), "{alg:?}: new string encrypted");
let again = Document::open_with_password(Arc::new(saved.clone()), Some("user")).unwrap();
assert_eq!(title(&again).as_deref(), Some("Revised figures"), "{alg:?}");
assert_eq!(content(&again), SECRET);
assert!(hayro_syntax::Pdf::new_with_password(saved, "user").is_ok(), "{alg:?}");
}
}
#[test]
fn changing_and_removing_passwords() {
let bytes = protect(Algorithm::Rc4_128, "old", "old-owner", -1);
// Change: decrypt with the old handler, encrypt with the new one.
let mut doc = Document::open_with_password(Arc::new(bytes), Some("old-owner")).unwrap();
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes256,
user_password: "new",
owner_password: "new-owner",
permissions: -1,
encrypt_metadata: true,
seed: [3; 32],
})
.unwrap();
let changed = Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap()); // falls back to a full save
assert!(Document::open_with_password(changed.clone(), Some("old")).is_err());
let doc = Document::open_with_password(changed.clone(), Some("new")).unwrap();
assert_eq!(content(&doc), SECRET);
assert_eq!(doc.security().unwrap().dict().r, 6);
// Remove.
let mut doc = Document::open_with_password(changed, Some("new-owner")).unwrap();
doc.remove_encryption();
let plain = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(String::from_utf8_lossy(&plain).contains("Secret page"));
let doc = Document::open(Arc::new(plain.clone())).unwrap();
assert!(doc.security().is_none() && !doc.trailer().contains(b"Encrypt"));
assert_eq!(title(&doc).as_deref(), Some("Quarterly numbers"));
assert!(hayro_syntax::Pdf::new(plain).is_ok());
}
#[test]
fn unencrypted_metadata_is_left_readable() {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes256,
user_password: "",
owner_password: "o",
permissions: -1,
encrypt_metadata: false,
seed: [5; 32],
})
.unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(String::from_utf8_lossy(&bytes).contains("<x:xmpmeta>meta</x:xmpmeta>"), "search engines can read XMP");
assert!(!String::from_utf8_lossy(&bytes).contains("Secret page"));
let doc = Document::open(Arc::new(bytes)).unwrap();
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
let meta = doc.get(catalog.reference(b"Metadata").unwrap());
let Object::Stream(s) = meta.as_ref() else { panic!() };
assert_eq!(s.decoded().unwrap(), b"<x:xmpmeta>meta</x:xmpmeta>");
let _ = ObjRef::new(1, 0);
}
/// The second independent oracle: qpdf must accept both passwords, identify the owner password,
/// and find no structural errors. For R2-R4 this is the *only* check of owner-password
/// authentication (Algorithm 7) that does not go through our own code, so it is the one place a
/// silent skip would cost the most -- see `oracle.rs`, which makes absence loud.
#[test]
fn qpdf_agrees() {
let qpdf = crate::oracle::or_skip!(crate::oracle::qpdf());
let dir = private_temp_dir("pdfcraft-enc");
for alg in ALL {
let path = dir.join(format!("{alg:?}.pdf"));
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);
assert!(text.contains(&format!("Supplied password is {expect}")), "{alg:?}/{pw}: {text}");
let check = qpdf.command().arg(format!("--password={pw}")).arg("--check").arg(&path).output().unwrap();
assert!(check.status.success(), "{alg:?}/{pw}: {}", String::from_utf8_lossy(&check.stdout));
}
}
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()));
#[cfg(unix)]
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
}
/// 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.
#[test]
fn signature_contents_stay_unencrypted() {
for alg in ALL {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
let hex = |b: &[u8]| Object::String(PdfString { bytes: b.to_vec(), hex: true });
let byte_range = Object::Array([0, 10, 20, 30].into_iter().map(Object::Int).collect());
let mut sig = craft_pdf::cos::Dict::new();
sig.set(b"Type".to_vec(), Object::name("Sig"));
sig.set(b"Contents".to_vec(), hex(&[0x30, 0x82, 0xDE, 0xAD]));
sig.set(b"ByteRange".to_vec(), byte_range.clone());
sig.set(b"Name".to_vec(), PdfString::literal(b"Signer Name".to_vec()));
let sig = doc.add(Object::Dict(sig));
// No /Type: still a signature dictionary by its /Contents and /ByteRange.
let mut untyped = craft_pdf::cos::Dict::new();
untyped.set(b"Contents".to_vec(), hex(&[0x30, 0x82, 0xBE, 0xEF]));
untyped.set(b"ByteRange".to_vec(), byte_range);
let untyped = doc.add(Object::Dict(untyped));
let mut note = craft_pdf::cos::Dict::new();
note.set(b"Type".to_vec(), Object::name("Annot"));
note.set(b"Contents".to_vec(), PdfString::literal(b"Private note".to_vec()));
let note = doc.add(Object::Dict(note));
doc.update_dict(doc.root().unwrap(), |c| {
c.set(b"Test".to_vec(), Object::Array(vec![Object::Ref(sig), Object::Ref(untyped), Object::Ref(note)]))
})
.unwrap();
doc.set_encryption(&NewEncryption {
algorithm: alg,
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();
let raw = String::from_utf8_lossy(&bytes);
assert!(raw.contains("<3082DEAD>") && raw.contains("<3082BEEF>"), "{alg:?}: signature /Contents written as is");
assert!(!raw.contains("Signer Name") && !raw.contains("Private note"), "{alg:?}: other strings are encrypted");
let back = Document::open(Arc::new(bytes)).unwrap();
let test = back.get(back.root().unwrap()).as_dict().unwrap().get(b"Test").unwrap().as_array().unwrap().clone();
let string = |i: usize, key: &[u8]| {
let o = back.resolve(&test[i]);
o.as_dict().unwrap().get(key).unwrap().as_string().unwrap().bytes.clone()
};
assert_eq!(string(0, b"Contents"), [0x30, 0x82, 0xDE, 0xAD], "{alg:?}: read back without decrypting");
assert_eq!(string(0, b"Name"), b"Signer Name", "{alg:?}");
assert_eq!(string(1, b"Contents"), [0x30, 0x82, 0xBE, 0xEF], "{alg:?}");
assert_eq!(string(2, b"Contents"), b"Private note", "{alg:?}");
}
}
/// 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 = craft_pdf::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");
}
}
/// 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
// meaningless and is read as if absent (128 bits), not multiplied into an overflow (#1816).
let original = protect(Algorithm::Aes128, "", "owner", -1);
for length in [i64::MIN, i64::MIN / 8 - 1, i64::MIN / 8, -1, 0, 16, 128] {
let mut doc = Document::open(Arc::new(original.clone())).unwrap();
let encrypt = doc.trailer().reference(b"Encrypt").unwrap();
doc.update_dict(encrypt, |e| {
let Some(Object::Dict(cf)) = e.get(b"CF") else { panic!("no /CF") };
let Some(Object::Dict(std_cf)) = cf.get(b"StdCF") else { panic!("no /StdCF") };
let (mut cf, mut std_cf) = (cf.clone(), std_cf.clone());
std_cf.set(b"Length".to_vec(), Object::Int(length));
cf.set(b"StdCF".to_vec(), Object::Dict(std_cf));
e.set(b"CF".to_vec(), Object::Dict(cf));
})
.unwrap();
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let again = Document::open(Arc::new(saved)).unwrap();
assert_eq!(content(&again), SECRET, "/Length {length}");
}
}
#[test]
fn an_earlier_revision_opens_with_the_documents_own_credentials() {
// The bytes a signature covers are an earlier revision of the file: the document that is
// open can read them with its own key, so no password has to be kept or asked for again.
for alg in ALL {
let original = protect(alg, "user", "owner", -1);
let mut doc = Document::open_with_password(Arc::new(original.clone()), Some("user")).unwrap();
let info = doc.trailer().reference(b"Info").unwrap();
doc.update_dict(info, |d| d.set(b"Title".to_vec(), Object::String(PdfString::text("Revised")))).unwrap();
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let current = Document::open_with_password(Arc::new(saved), Some("user")).unwrap();
assert!(matches!(Document::open(Arc::new(original.clone())), Err(CosError::NeedsPassword)), "{alg:?}: not without a password");
let earlier = current.open_with_same_credentials(Arc::new(original.clone())).unwrap();
assert_eq!(content(&earlier), SECRET, "{alg:?}: the earlier revision is readable");
assert_eq!(title(&earlier).as_deref(), Some("Quarterly numbers"), "{alg:?}: and it is the earlier one");
// Another file, encrypted with other keys: the document's key is no use, and it says so.
let other = {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
doc.set_encryption(&NewEncryption {
algorithm: alg,
user_password: "user",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [3; 32],
})
.unwrap();
write_full(&doc, &SaveOptions::default()).unwrap()
};
assert!(matches!(current.open_with_same_credentials(Arc::new(other)), Err(CosError::NeedsPassword)), "{alg:?}");
// An unencrypted file opens as it always does.
assert_eq!(content(&current.open_with_same_credentials(Arc::new(fixture())).unwrap()), SECRET, "{alg:?}");
}
}
/// `protect`, with the `/Encrypt` dictionary written inline in the trailer, as MuPDF writes it,
/// instead of as an indirect object.
fn protect_inline(alg: Algorithm, user: &str, owner: &str) -> Vec<u8> {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
doc.set_encryption(&NewEncryption {
algorithm: alg,
user_password: user,
owner_password: owner,
permissions: -1,
encrypt_metadata: true,
seed: [9; 32],
})
.unwrap();
let dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap();
doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict));
write_full(&doc, &SaveOptions::default()).unwrap()
}
/// MuPDF writes `/Encrypt` inline in the trailer, so removing it changes no object. It is still
/// an unsaved change, and saving must write a file that opens without a password (#2661).
#[test]
fn removing_an_inline_encrypt_dictionary_leaves_an_unencrypted_file() {
for alg in ALL {
let bytes = Arc::new(protect_inline(alg, "user", "owner"));
assert!(matches!(Document::open(bytes.clone()), Err(CosError::NeedsPassword)), "{alg:?}");
let mut doc = Document::open_with_password(bytes, Some("owner")).unwrap();
assert!(matches!(doc.trailer().get(b"Encrypt"), Some(Object::Dict(_))), "{alg:?}: inline");
assert!(!doc.is_modified(), "{alg:?}");
doc.remove_encryption();
assert!(doc.is_modified(), "{alg:?}: removing security is an unsaved change");
let plain = write_incremental(&doc, &SaveOptions::default()).unwrap();
let again = Document::open(Arc::new(plain.clone())).unwrap_or_else(|e| panic!("{alg:?}: {e:?}"));
assert!(again.security().is_none() && !again.trailer().contains(b"Encrypt"), "{alg:?}");
assert_eq!(content(&again), SECRET, "{alg:?}");
assert!(hayro_syntax::Pdf::new(plain).is_ok(), "{alg:?}");
}
}
/// A content stream that calls itself `/Type /XRef` is still encrypted: only reading trusts the label.
#[test]
fn a_stream_labelled_xref_is_encrypted_when_saved() {
let mut doc = Document::open(Arc::new(fixture_with(" /Type /XRef"))).unwrap();
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes256,
user_password: "u",
owner_password: "o",
permissions: -4,
encrypt_metadata: true,
seed: [9; 32],
})
.unwrap();
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
assert!(!bytes.windows(SECRET.len()).any(|w| w == SECRET.as_bytes()), "the labelled stream was written in the clear");
}
/// The content stream's reference.
fn contents_of(doc: &Document) -> ObjRef {
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();
page.reference(b"Contents").unwrap()
}
/// The content stream's data as loaded, whatever its filters say.
fn raw_content(doc: &Document) -> String {
match &*doc.get(contents_of(doc)) {
Object::Stream(s) => String::from_utf8_lossy(&s.raw).trim().to_string(),
_ => panic!("no content stream"),
}
}
/// Gives the content stream the `/Filter` and `/DecodeParms` of `selector`.
fn select(doc: &mut Document, (filter, parms): (Object, Object)) {
let contents = contents_of(doc);
let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") };
s.dict.set(b"Filter".to_vec(), filter);
s.dict.set(b"DecodeParms".to_vec(), parms);
doc.set(contents, Object::Stream(s));
}
/// `/Crypt` naming `name`, in the `form`th spelling: all direct, then each part by reference.
/// `None` past the last.
fn selector(doc: &mut Document, form: usize, name: &str) -> Option<(Object, Object)> {
let crypt = Object::name("Crypt");
let parms = |n: Object| {
let mut d = craft_pdf::cos::Dict::new();
d.set(b"Name".to_vec(), n);
Object::Dict(d)
};
let named = parms(Object::name(name));
Some(match form {
0 => (crypt, named),
1 => (crypt, Object::Ref(doc.add(named))),
2 => (Object::Array(vec![crypt]), Object::Array(vec![Object::Ref(doc.add(named))])),
3 => (Object::Array(vec![crypt]), Object::Ref(doc.add(Object::Array(vec![named])))),
4 => (crypt, parms(Object::Ref(doc.add(Object::name(name))))),
5 => (Object::Ref(doc.add(crypt)), named),
6 => (Object::Ref(doc.add(Object::Array(vec![crypt]))), Object::Array(vec![named])),
7 => (Object::Array(vec![Object::Ref(doc.add(crypt))]), Object::Array(vec![named])),
// Behind a filter given by reference: places in the two arrays pair up all the same.
8 => (Object::Array(vec![Object::Ref(doc.add(Object::name("ASCIIHexDecode"))), crypt]), Object::Array(vec![Object::Null, named])),
// The two entries in different forms, which is how a lone filter is usually spelled.
9 => (Object::Array(vec![crypt]), named),
10 => (crypt, Object::Array(vec![named])),
// The filter twice, which names the first's.
11 => (Object::Array(vec![crypt.clone(), crypt]), Object::Array(vec![named.clone(), named])),
// A reference to a reference.
12 => {
let name = doc.add(Object::name(name));
let hop = doc.add(Object::Ref(name));
(crypt, parms(Object::Ref(hop)))
}
_ => return None,
})
}
fn holds(bytes: &[u8], text: &str) -> bool {
String::from_utf8_lossy(bytes).contains(text)
}
/// An object `/Encrypt` names is written as stored only while it is as stored. A file whose
/// `/Encrypt` names its `/Info` reads that dictionary as stored; once the title is edited, both
/// saves encrypt it rather than writing the new title in the clear.
#[test]
fn an_edited_object_that_encrypt_names_is_encrypted() {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes128,
user_password: "",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [9; 32],
})
.unwrap();
let info = doc.trailer().reference(b"Info").unwrap();
let mut dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap();
dict.set(b"Junk".to_vec(), Object::Ref(info));
doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict));
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
let mut doc = Document::open(Arc::new(bytes)).unwrap();
let info = doc.trailer().reference(b"Info").unwrap();
doc.update_dict(info, |d| d.set(b"Title".to_vec(), PdfString::text("Merger plan"))).unwrap();
for (save, saved) in [("incremental", write_incremental(&doc, &SaveOptions::default())), ("full", write_full(&doc, &SaveOptions::default()))] {
assert!(!holds(&saved.unwrap(), "Merger plan"), "{save}: the edited title was written in the clear");
}
}
/// A stream can select its crypt filter by reference at every step: the chain, an entry of it, the
/// parameters, an entry of those, the name (ISO 32000-2 §7.3.10, §7.4.10). Read as absent, the
/// filter was `/Identity`, so such a stream was saved in the clear and, in a file from elsewhere,
/// its ciphertext read as data. A full save renumbers: what it follows has the document's numbers.
#[test]
fn a_crypt_filter_selected_by_reference_is_used() {
for alg in [Algorithm::Aes128, Algorithm::Aes256] {
for form in 0.. {
let mut doc = Document::open(Arc::new(protect(alg, "", "owner", -1))).unwrap();
let Some(selected) = selector(&mut doc, form, "StdCF") else { break };
select(&mut doc, selected);
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(!holds(&saved, "Secret page"), "{alg:?} form {form}: saved encrypted");
let back = Document::open(Arc::new(saved)).unwrap();
assert_eq!(raw_content(&back), SECRET, "{alg:?} form {form}: read back");
let full = write_full(&back, &SaveOptions::default()).unwrap();
assert!(!holds(&full, "Secret page"), "{alg:?} form {form}: renumbered, encrypted");
assert_eq!(raw_content(&Document::open(Arc::new(full)).unwrap()), SECRET, "{alg:?} form {form}: renumbered");
}
}
}
/// A reference to a stream that takes `follows` references to read cold, this one included: its
/// nine filters are all read, the last leading to the next stream.
fn spends(doc: &mut Document, follows: usize) -> Object {
let left = follows - 1;
let mut filters: Vec<Object> = (0..left.min(8)).map(|i| Object::Ref(doc.add(Object::Int(i as i64)))).collect();
if left > 8 {
filters.push(spends(doc, left - 8));
}
let mut dict = craft_pdf::cos::Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(filters));
Object::Ref(doc.add(Object::Stream(craft_pdf::cos::Stream::from_raw(dict, Vec::new()))))
}
/// Gives the content stream a `/Type` that takes `follows` references to read, ahead of its filter.
fn spend_first(doc: &mut Document, follows: usize) {
let spent = spends(doc, follows);
let contents = contents_of(doc);
let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") };
s.dict.set(b"Type".to_vec(), spent);
doc.set(contents, Object::Stream(s));
}
/// Past what one read may follow a reference reads as absent, and a crypt filter with no `/Name` is
/// `/Identity`. Together those would read this stream's ciphertext as data, so what can't be told
/// is the document's own filter, as a `/Filter` that can't be read is.
#[test]
fn a_crypt_filter_past_what_a_read_may_follow_is_the_documents_own() {
let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap();
let selected = selector(&mut doc, 1, "StdCF").unwrap();
select(&mut doc, selected);
spend_first(&mut doc, 40);
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(!holds(&saved, "Secret page"), "saved encrypted");
let back = Document::open(Arc::new(saved)).unwrap();
assert_eq!(raw_content(&back), SECRET);
let full = write_full(&back, &SaveOptions::default()).unwrap();
assert!(!holds(&full, "Secret page"), "renumbered, encrypted");
assert_eq!(raw_content(&Document::open(Arc::new(full)).unwrap()), SECRET, "renumbered");
}
/// A read that takes all it may follow has refused nothing: a crypt filter with no `/Name` after it
/// is still `/Identity`, and the stream is in the clear.
#[test]
fn a_crypt_filter_with_no_name_after_all_a_read_may_follow_is_identity() {
let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap();
select(&mut doc, (Object::name("Crypt"), Object::Dict(craft_pdf::cos::Dict::new())));
spend_first(&mut doc, 32);
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(holds(&saved, "Secret page"), "written as the stream asks");
assert_eq!(raw_content(&Document::open(Arc::new(saved)).unwrap()), SECRET);
}
/// `/Identity` selected by reference keeps a stream in the clear in a document that encrypts the
/// rest, in every spelling, on both saves.
#[test]
fn an_identity_crypt_filter_selected_by_reference_leaves_a_stream_alone() {
for form in 0.. {
let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap();
let Some(selected) = selector(&mut doc, form, "Identity") else { break };
select(&mut doc, selected);
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(holds(&saved, "Secret page"), "form {form}: written as the stream asks");
let back = Document::open(Arc::new(saved)).unwrap();
assert_eq!(raw_content(&back), SECRET, "form {form}");
let full = write_full(&back, &SaveOptions::default()).unwrap();
assert!(holds(&full, "Secret page"), "form {form}: renumbered");
assert_eq!(raw_content(&Document::open(Arc::new(full)).unwrap()), SECRET, "form {form}: renumbered");
}
}
/// New protection drops a crypt filter it doesn't define, whichever way the stream selected it. The
/// chain comes out direct. Parameters in the stream's own dictionary stay, encrypted with it; of an
/// array in an object of its own, which every stream can share, only what decoders read is copied.
#[test]
fn protecting_drops_a_crypt_filter_selected_by_reference() {
let protected = |doc: &mut Document| {
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
write_full(doc, &SaveOptions::default()).unwrap()
};
for form in 0.. {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
let Some(selected) = selector(&mut doc, form, "MyCF") else { break };
select(&mut doc, selected);
let bytes = protected(&mut doc);
assert!(!holds(&bytes, "Secret page"), "form {form}: encrypted");
assert!(!holds(&bytes, "MyCF"), "form {form}: no selector left in the stream");
let back = Document::open(Arc::new(bytes.clone())).unwrap();
assert_eq!(raw_content(&back), SECRET, "form {form}");
let contents = back.get(contents_of(&back));
let filter = contents.as_dict().unwrap().get(b"Filter");
match form {
8 => assert!(
matches!(filter, Some(Object::Array(a)) if matches!(a.as_slice(), [Object::Ref(_)])),
"form 8: the other filter stays: {filter:?}"
),
_ => assert_eq!(filter, None, "form {form}"),
}
assert!(hayro_syntax::Pdf::new(bytes).is_ok(), "form {form}");
}
for shared in [false, true] {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
let mut named = craft_pdf::cos::Dict::new();
named.set(b"Name".to_vec(), Object::name("MyCF"));
let mut other = craft_pdf::cos::Dict::new();
other.set(b"Note".to_vec(), Object::String(PdfString::text("Private note")));
other.set(b"Predictor".to_vec(), Object::Int(1));
let parms = Object::Array(vec![Object::Dict(named), Object::Dict(other)]);
let parms = if shared { Object::Ref(doc.add(parms)) } else { parms };
select(&mut doc, (Object::Array(vec![Object::name("Crypt"), Object::name("ASCIIHexDecode")]), parms));
let bytes = protected(&mut doc);
assert!(!holds(&bytes, "Private note"), "shared: {shared}");
let back = Document::open(Arc::new(bytes)).unwrap();
let contents = back.get(contents_of(&back));
let kept = contents.as_dict().unwrap().get(b"DecodeParms").unwrap().as_array().unwrap();
assert_eq!(kept.len(), 1, "one filter left, one entry");
let kept = kept[0].as_dict().unwrap();
assert_eq!(kept.get(b"Predictor"), Some(&Object::Int(1)), "shared: {shared}");
assert_eq!(kept.get(b"Note").is_some(), !shared, "shared: {shared}");
}
}
/// Every stream of a file can share one large object as its `/DecodeParms` or `/Filter`, which
/// costs nothing until new protection drops a crypt filter from each. Copied whole, a 1 MB file
/// came out at 1 GB.
#[test]
fn protecting_copies_no_large_object_into_every_stream() {
let pad = Object::Array(vec![Object::Int(1); 100_000]);
let mut doc = Document::open(Arc::new(fixture())).unwrap();
let mut large = craft_pdf::cos::Dict::new();
large.set(b"Pad".to_vec(), pad.clone());
let parms = doc.add(Object::Array(vec![Object::Null, Object::Dict(large)]));
let filter = doc.add(Object::Array(vec![Object::name("Crypt"), Object::Name(vec![b'A'; 100_000]), pad]));
let mut streams = Vec::new();
for _ in 0..200 {
let mut by_parms = craft_pdf::cos::Dict::new();
by_parms.set(b"Filter".to_vec(), Object::Array(vec![Object::name("Crypt"), Object::name("ASCIIHexDecode")]));
by_parms.set(b"DecodeParms".to_vec(), Object::Ref(parms));
streams.push(Object::Ref(doc.add(Object::Stream(craft_pdf::cos::Stream::from_raw(by_parms, b"41>".to_vec())))));
let mut by_filter = craft_pdf::cos::Dict::new();
by_filter.set(b"Filter".to_vec(), Object::Ref(filter));
streams.push(Object::Ref(doc.add(Object::Stream(craft_pdf::cos::Stream::from_raw(by_filter, b"41>".to_vec())))));
}
doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Array(streams))).unwrap();
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 { object_streams: false, ..SaveOptions::default() }).unwrap();
assert!(bytes.len() < 1 << 20, "{} bytes", bytes.len());
}
/// A file can name one large string thousands of times in `/ID`, which holds two. An encrypted file
/// keeps its `/ID`, so each copy would be in the file a save writes.
#[test]
fn a_file_identifier_of_many_references_is_two_strings() {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes128,
user_password: "",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [9; 32],
})
.unwrap();
let large = doc.add(Object::String(PdfString { bytes: vec![0xAB; 100_000], hex: true }));
doc.trailer_mut().set(b"ID".to_vec(), Object::Array(vec![Object::Ref(large); 500]));
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
assert!(bytes.len() < 1 << 20, "{} bytes", bytes.len());
}
/// What a file can do with those references: point a stream's selector at the stream itself, or at
/// nothing. They read as absent, so a stream with `/Crypt` is `/Identity` (ISO 32000-2 §7.4.10),
/// and one that names no filter follows the document's. Loading ends either way.
#[test]
fn a_crypt_filter_reference_to_itself_or_nothing_reads_as_absent() {
let named = |n: Object| {
let mut d = craft_pdf::cos::Dict::new();
d.set(b"Name".to_vec(), n);
Object::Dict(d)
};
for (what, encrypted) in [
("/Filter to itself", true),
("/Filter nowhere", true),
("/DecodeParms to itself", false),
("/DecodeParms nowhere", false),
("/Name to itself", false),
("/Name nowhere", false),
] {
let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap();
let me = Object::Ref(contents_of(&doc));
let nowhere = Object::Ref(ObjRef::new(9_999, 0));
let crypt = Object::name("Crypt");
select(
&mut doc,
match what {
"/Filter to itself" => (me, Object::Null),
"/Filter nowhere" => (nowhere, Object::Null),
"/DecodeParms to itself" => (crypt, me),
"/DecodeParms nowhere" => (crypt, nowhere),
"/Name to itself" => (crypt, named(me)),
_ => (crypt, named(nowhere)),
},
);
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert_eq!(!holds(&saved, "Secret page"), encrypted, "{what}");
assert_eq!(raw_content(&Document::open(Arc::new(saved)).unwrap()), SECRET, "{what}");
}
}
/// A lone parameter dictionary belongs to the first filter, so `/Crypt` behind another filter has
/// none of its own and is `/Identity` (ISO 32000-2 §7.4.1), whatever that dictionary says.
#[test]
fn a_lone_parameter_dictionary_is_the_first_filters() {
let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap();
let mut parms = craft_pdf::cos::Dict::new();
parms.set(b"Name".to_vec(), Object::name("StdCF"));
let first = Object::Array(vec![Object::name("Crypt"), Object::name("ASCIIHexDecode")]);
let second = Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("Crypt")]);
select(&mut doc, (first, Object::Dict(parms.clone())));
assert!(!holds(&write_incremental(&doc, &SaveOptions::default()).unwrap(), "Secret page"), "first: it names StdCF");
select(&mut doc, (second, Object::Dict(parms)));
assert!(holds(&write_incremental(&doc, &SaveOptions::default()).unwrap(), "Secret page"), "second: it names nothing");
}
/// A lone parameter dictionary is the first filter's, so dropping a `/Crypt` behind that filter
/// leaves it, and dropping one in front of it takes it away.
#[test]
fn protecting_keeps_the_parameters_of_the_filter_before_a_crypt_filter() {
let protected = |filters: Vec<Object>| {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
let mut parms = craft_pdf::cos::Dict::new();
parms.set(b"Name".to_vec(), Object::name("MyCF"));
select(&mut doc, (Object::Array(filters), Object::Dict(parms)));
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
let back = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
back.get(contents_of(&back)).as_dict().unwrap().contains(b"DecodeParms")
};
let (hex, crypt) = (Object::name("ASCIIHexDecode"), Object::name("Crypt"));
assert!(protected(vec![hex.clone(), crypt.clone()]), "the first filter's, which stays");
assert!(!protected(vec![crypt, hex]), "/Crypt's, which goes");
}
/// Dropping `/Crypt` takes its entry out of `/DecodeParms`, and an array too short to have one is
/// left as it is, given directly or by reference.
#[test]
fn protecting_leaves_a_parameter_array_too_short_for_the_crypt_filter() {
for by_reference in [false, true] {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
let short = Object::Array(vec![Object::Null]);
let parms = if by_reference { Object::Ref(doc.add(short)) } else { short };
select(&mut doc, (Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("Crypt")]), parms));
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!(!holds(&bytes, "Secret page"), "by reference: {by_reference}");
let back = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(raw_content(&back), SECRET, "by reference: {by_reference}");
let contents = back.get(contents_of(&back));
let filter = contents.as_dict().unwrap().get(b"Filter").unwrap().as_array().unwrap();
assert_eq!(filter.len(), 1, "only /Crypt goes");
assert_eq!(filter[0].as_name(), Some(b"ASCIIHexDecode".as_slice()), "the other filter stays as it was");
}
}
/// A chain of up to [`craft_pdf::filters::MAX_CHAIN`] filters and `/Crypt` is read; a longer one
/// names no crypt filter, so its entries aren't followed for every stream sharing it.
#[test]
fn a_chain_longer_than_any_decoder_takes_names_no_crypt_filter() {
let chain = |others: usize| {
let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap();
let mut filters = vec![Object::name("ASCIIHexDecode"); others];
filters.push(Object::name("Crypt"));
let array = doc.add(Object::Array(filters));
select(&mut doc, (Object::Ref(array), Object::Null));
holds(&write_incremental(&doc, &SaveOptions::default()).unwrap(), "Secret page")
};
assert!(chain(craft_pdf::filters::MAX_CHAIN), "the longest chain read: /Identity, in the clear");
assert!(!chain(craft_pdf::filters::MAX_CHAIN + 1), "one more: the document's filter");
}
/// A stream's `/Type` by reference picks its handling like one given directly: metadata left
/// readable under `/EncryptMetadata false`, an embedded file under `/EFF`.
#[test]
fn a_stream_type_given_by_reference_picks_its_crypt_filter() {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
let meta = catalog.reference(b"Metadata").unwrap();
let ty = doc.add(Object::name("Metadata"));
let Object::Stream(mut s) = doc.get(meta).as_ref().clone() else { panic!("no metadata stream") };
s.dict.set(b"Type".to_vec(), Object::Ref(ty));
doc.set(meta, Object::Stream(s));
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes256,
user_password: "",
owner_password: "o",
permissions: -1,
encrypt_metadata: false,
seed: [5; 32],
})
.unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(holds(&bytes, "<x:xmpmeta>meta</x:xmpmeta>"), "search engines can read XMP");
let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap();
let encrypt = doc.trailer().reference(b"Encrypt").unwrap();
doc.update_dict(encrypt, |e| e.set(b"EFF".to_vec(), Object::name("Identity"))).unwrap();
let mut doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
let contents = contents_of(&doc);
let ty = doc.add(Object::name("EmbeddedFile"));
let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") };
s.dict.set(b"Type".to_vec(), Object::Ref(ty));
doc.set(contents, Object::Stream(s));
assert!(holds(&write_incremental(&doc, &SaveOptions::default()).unwrap(), "Secret page"), "/EFF /Identity leaves it alone");
}
/// What makes a dictionary a signature's can be given by reference, and what is null or refers to
/// nothing is absent: `/Contents` stays as written in each of these.
#[test]
fn a_signature_dictionary_is_known_in_every_form() {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
let ty = doc.add(Object::name("Sig"));
let range = Object::Array([0, 10, 20, 30].into_iter().map(Object::Int).collect());
let by_reference = doc.add(range.clone());
let nowhere = Object::Ref(ObjRef::new(9_999, 0));
let forms: [(Vec<(&str, Object)>, &str); 5] = [
(vec![("Type", Object::Ref(ty))], "DEAD"),
(vec![("ByteRange", Object::Ref(by_reference))], "BEEF"),
(vec![("Type", Object::Null), ("ByteRange", range.clone())], "CAFE"),
(vec![("Type", nowhere.clone()), ("ByteRange", range.clone())], "F00D"),
(vec![("Subtype", nowhere), ("ByteRange", range)], "D00D"),
];
let mut signatures = Vec::new();
for (entries, contents) in forms {
let mut sig = craft_pdf::cos::Dict::new();
for (key, value) in entries {
sig.set(key.as_bytes().to_vec(), value);
}
let bytes = (0..contents.len()).step_by(2).map(|i| u8::from_str_radix(&contents[i..i + 2], 16).unwrap()).collect();
sig.set(b"Contents".to_vec(), Object::String(PdfString { bytes, hex: true }));
signatures.push(Object::Ref(doc.add(Object::Dict(sig))));
}
// Nested in another object, it follows nothing, but known directly it is one all the same.
let mut nested = craft_pdf::cos::Dict::new();
nested.set(b"Type".to_vec(), Object::name("Sig"));
nested.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0xAB, 0xBA], hex: true }));
let mut field = craft_pdf::cos::Dict::new();
field.set(b"V".to_vec(), Object::Dict(nested));
signatures.push(Object::Ref(doc.add(Object::Dict(field))));
// Neither of what makes one: a string /Contents alone is anybody's, so it's encrypted.
let mut other = craft_pdf::cos::Dict::new();
other.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0xAB, 0xCD], hex: true }));
signatures.push(Object::Ref(doc.add(Object::Dict(other))));
doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Array(signatures))).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();
for contents in ["DEAD", "BEEF", "CAFE", "F00D", "D00D", "ABBA"] {
assert!(holds(&bytes, &format!("<{contents}>")), "{contents}: written as is");
}
assert!(!holds(&bytes, "<ABCD>"), "without a /ByteRange it is no signature");
}
/// What a test needs to write an update of a classic file protected with AES-128.
struct Classic {
bytes: Vec<u8>,
root: ObjRef,
pages: ObjRef,
info: ObjRef,
encrypt: ObjRef,
id: Vec<u8>,
dict: craft_pdf::cos::Dict,
}
fn classic_file() -> Classic {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes128,
user_password: "",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [9; 32],
})
.unwrap();
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
let encrypt = doc.trailer().reference(b"Encrypt").unwrap();
let id = doc.trailer().get(b"ID").unwrap().as_array().unwrap()[0].as_string().unwrap().bytes.clone();
let dict = doc.get(encrypt).as_dict().cloned().unwrap();
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
Classic { root: doc.root().unwrap(), pages, info: doc.trailer().reference(b"Info").unwrap(), bytes, encrypt, id, dict }
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02X}")).collect()
}
/// `bytes` with an update appended: `objects`, and a trailer of `trailer` entries, in which `@n`
/// stands for the offset of object `n`. An object with nothing in it gets an entry of the
/// cross-reference table that points at nothing.
fn updated(bytes: &[u8], objects: &[(u32, Vec<u8>)], trailer: &str) -> Vec<u8> {
let at = bytes.windows(9).rposition(|w| w == b"startxref").unwrap();
let prev: usize = String::from_utf8_lossy(&bytes[at + 9..]).split_whitespace().next().unwrap().parse().unwrap();
let mut out = bytes.to_vec();
let mut table = String::from("xref\n");
let mut trailer = trailer.to_string();
for (num, body) in objects {
if body.is_empty() {
table.push_str(&format!("{num} 1\n0000000010 00000 n \n"));
continue;
}
trailer = trailer.replace(&format!("@{num}"), &out.len().to_string());
table.push_str(&format!("{num} 1\n{:010} 00000 n \n", out.len()));
out.extend_from_slice(format!("{num} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(table.as_bytes());
out.extend_from_slice(format!("trailer\n<< {trailer} /Size 2000 /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
out
}
/// The `/Encrypt` dictionary of `file`, with `/O`, `/StmF` and `/StrF` as given.
fn encrypt_with(file: &Classic, o: &str, filter: &str) -> Vec<u8> {
let string = |key: &[u8]| hex(&file.dict.get(key).unwrap().as_string().unwrap().bytes);
format!(
"<< /Filter /Standard /V 4 /R 4 /Length 128 /P {} /O {o} /U <{}> /CF << /StdCF << /CFM /AESV2 /Length 16 /AuthEvent /DocOpen >> >> /StmF /{filter} /StrF /{filter} >>",
file.dict.int(b"P").unwrap(),
string(b"U")
)
.into_bytes()
}
/// An object stream can have its parameters by reference, and a stream's crypt filter can be
/// selected by a member of it: legal together, though each is rare. What reads the stream first
/// goes through the object stream, and members must come out of it as they do read directly. They
/// didn't, while a read made for a selector wasn't allowed to follow its own: the object stream was
/// kept undecoded, its members lost for good, and a full save wrote nulls without saying so.
#[test]
fn an_object_stream_reached_from_a_selector_keeps_its_members() {
let file = classic_file();
let parms = "[<< /Name /Identity >> null]";
let header = format!("902 0 903 {} ", parms.len() + 1);
let members = format!("{header}{parms} << /Keep (yes) >>");
let (mut rows, mut before) = (vec![1u8], 0u8);
for b in members.bytes() {
rows.push(b.wrapping_sub(before));
before = b;
}
let coded = craft_pdf::cos::Stream::flate(craft_pdf::cos::Dict::new(), &rows).raw;
let mut object_stream =
format!("<< /Type /ObjStm /N 2 /First {} /Filter /FlateDecode /DecodeParms 911 0 R /Length {} >>\nstream\n", header.len(), coded.len())
.into_bytes();
object_stream.extend_from_slice(&coded);
object_stream.extend_from_slice(b"\nendstream");
let data = [2u8, 0x03, 0x8E, 0, 2, 0x03, 0x8E, 1];
let mut xref = format!("<< /Type /XRef /Size 2000 /W [1 2 1] /Index [902 2] /Length {} >>\nstream\n", data.len()).into_bytes();
xref.extend_from_slice(&data);
xref.extend_from_slice(b"\nendstream");
let string = |key: &[u8]| format!("<{}>", hex(&file.dict.get(key).unwrap().as_string().unwrap().bytes));
let objects = [
(file.root.num, format!("<< /Type /Catalog /Pages {} 0 R /A 900 0 R /B 903 0 R >>", file.pages.num).into_bytes()),
(file.encrypt.num, encrypt_with(&file, &string(b"O"), "Identity")),
(900, b"<< /Filter [/Crypt /ASCIIHexDecode] /DecodeParms 902 0 R /Length 1 >>\nstream\n>\nendstream".to_vec()),
(910, object_stream),
(911, format!("<< /Predictor 11 /Columns {} >>", members.len()).into_bytes()),
(912, xref),
];
let trailer = format!("/Root {} 0 R /Encrypt {} 0 R /ID [<{id}> <{id}>] /XRefStm @912", file.root.num, file.encrypt.num, id = hex(&file.id));
let bytes = updated(&file.bytes, &objects, &trailer);
let keep =
|doc: &Document, r: &Object| doc.resolve(r).as_dict().and_then(|d| d.get(b"Keep")).and_then(Object::as_string).map(|s| s.bytes.clone());
let member = Object::Ref(ObjRef::new(903, 0));
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert_eq!(keep(&doc, &member).as_deref(), Some(b"yes".as_slice()), "read first");
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
let _ = doc.get(ObjRef::new(900, 0));
assert_eq!(keep(&doc, &member).as_deref(), Some(b"yes".as_slice()), "read after the stream selecting from it");
let saved = write_full(&Document::open(Arc::new(bytes)).unwrap(), &SaveOptions::default()).unwrap();
let back = Document::open(Arc::new(saved)).unwrap();
let catalog = back.get(back.root().unwrap());
assert_eq!(keep(&back, catalog.as_dict().unwrap().get(b"B").unwrap()).as_deref(), Some(b"yes".as_slice()), "after a full save");
}
/// `walk` goes as deep as an object nests, so following a reference from down there adds that depth
/// again for each object it loads: this file, under 10 KB, overflowed a 2 MiB stack and took the
/// process with it. Only the object itself follows references, and it does so in very little stack.
#[test]
fn nested_dictionaries_load_nothing_on_a_small_stack() {
let file = classic_file();
let first = 960u32;
let objects: Vec<(u32, Vec<u8>)> = (first..first + 40)
.map(|num| {
let open = "[".repeat(120);
let close = "]".repeat(120);
(num, format!("{open}<< /Contents () /Type {} 0 R >>{close}", num + 1).into_bytes())
})
.collect();
let trailer = format!("/Root {} 0 R /Encrypt {} 0 R /ID [<{id}> <{id}>]", file.root.num, file.encrypt.num, id = hex(&file.id));
let bytes = updated(&file.bytes, &objects, &trailer);
let reading = std::thread::Builder::new().stack_size(1 << 20).spawn(move || {
let doc = Document::open(Arc::new(bytes)).unwrap();
doc.get(ObjRef::new(first, 0)).as_array().is_some()
});
assert_eq!(reading.unwrap().join().ok(), Some(true));
}
/// The object itself can follow references, a ladder of them as long as loading may nest, and that
/// fits a small stack too.
#[test]
fn a_ladder_of_signature_checks_fits_a_small_stack() {
let file = classic_file();
let first = 960u32;
let objects: Vec<(u32, Vec<u8>)> =
(first..first + 40).map(|num| (num, format!("<< /Contents () /Type {} 0 R >>", num + 1).into_bytes())).collect();
let trailer = format!("/Root {} 0 R /Encrypt {} 0 R /ID [<{id}> <{id}>]", file.root.num, file.encrypt.num, id = hex(&file.id));
let bytes = updated(&file.bytes, &objects, &trailer);
let reading = std::thread::Builder::new().stack_size(1 << 20).spawn(move || {
let doc = Document::open(Arc::new(bytes)).unwrap();
doc.get(ObjRef::new(first, 0)).as_dict().is_some()
});
assert_eq!(reading.unwrap().join().ok(), Some(true));
}
/// The strings of `/Encrypt` are never encrypted (ISO 32000-2 §7.6.2), and can be objects of their
/// own. Read as ordinary strings they were decrypted, and a full save encrypted them again under
/// another number, so the file it wrote no longer opened with its own password.
#[test]
fn encrypt_strings_given_by_reference_stay_as_stored() {
let file = classic_file();
let string = |key: &[u8]| hex(&file.dict.get(key).unwrap().as_string().unwrap().bytes);
let replaced = format!(
"<< /Filter /Standard /V 4 /R 4 /Length 128 /P {} /O 900 0 R /U 901 0 R /CF << /StdCF << /CFM /AESV2 /Length 16 /AuthEvent /DocOpen >> >> /StmF /StdCF /StrF /StdCF >>",
file.dict.int(b"P").unwrap()
);
let objects = [
(file.encrypt.num, replaced.into_bytes()),
(900, format!("<{}>", string(b"O")).into_bytes()),
(901, format!("<{}>", string(b"U")).into_bytes()),
];
let trailer =
format!("/Root {} 0 R /Info {} 0 R /Encrypt {} 0 R /ID [<{id}> <{id}>]", file.root.num, file.info.num, file.encrypt.num, id = hex(&file.id));
let source = Document::open(Arc::new(updated(&file.bytes, &objects, &trailer))).unwrap();
assert_eq!(content(&source), SECRET, "the file reads");
assert_eq!(title(&source).as_deref(), Some("Quarterly numbers"));
for object_streams in [false, true] {
let saved = write_full(&source, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap();
let back = Document::open(Arc::new(saved)).unwrap();
assert_eq!(content(&back), SECRET, "object streams: {object_streams}");
assert_eq!(title(&back).as_deref(), Some("Quarterly numbers"), "object streams: {object_streams}");
}
}
/// A save keeps what a file is protected with: its `/Encrypt`, which holds the handler and the
/// permissions, its `/ID`, and so its file key, under every algorithm and by either kind of save.
#[test]
fn a_save_keeps_the_handler_the_key_and_the_permissions() {
for alg in ALL {
let doc = Document::open_with_password(Arc::new(protect(alg, "user", "owner", -1 ^ 0b1_0000)), Some("user")).unwrap();
let held = |d: &Document| {
let encrypt = d.resolve(d.trailer().get(b"Encrypt").unwrap()).as_dict().unwrap().clone();
(encrypt, d.trailer().get(b"ID").cloned(), d.security().unwrap().file_key().to_vec(), format!("{:?}", d.permissions()))
};
let before = held(&doc);
let mut edited = doc.clone();
let _ = edited.add(Object::Int(1));
for (save, saved) in [
("incremental", write_incremental(&edited, &SaveOptions::default()).unwrap()),
("full", write_full(&doc, &SaveOptions::default()).unwrap()),
] {
let back = Document::open_with_password(Arc::new(saved), Some("user")).unwrap();
assert!(held(&back) == before, "{alg:?}: a {save} save");
assert_eq!(content(&back), SECRET, "{alg:?}: a {save} save");
}
}
}
/// `/ID` has to be direct in an encrypted file, but one that isn't has its key made from the same
/// bytes. A full save writes it direct, and an incremental one leaves it as it was.
#[test]
fn a_file_identifier_given_by_reference_is_read() {
let file = classic_file();
let id = hex(&file.id);
let reference = |entries: &str, objects: &[(u32, Vec<u8>)]| {
let trailer = format!("/Root {} 0 R /Encrypt {} 0 R /ID {entries}", file.root.num, file.encrypt.num);
updated(&file.bytes, objects, &trailer)
};
let whole = reference("900 0 R", &[(900, format!("[<{id}> <{id}>]").into_bytes())]);
let entries = reference("[900 0 R 901 0 R]", &[(900, format!("<{id}>").into_bytes()), (901, format!("<{id}>").into_bytes())]);
for file in [whole, entries] {
let source = Document::open(Arc::new(file)).unwrap();
assert_eq!(content(&source), SECRET);
let back = Document::open(Arc::new(write_full(&source, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(content(&back), SECRET);
assert!(matches!(back.trailer().get(b"ID"), Some(Object::Array(_))), "written direct");
let mut edited = source.clone();
let _ = edited.add(Object::Int(1));
let back = Document::open(Arc::new(write_incremental(&edited, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(content(&back), SECRET, "an incremental save");
assert_eq!(back.trailer().get(b"ID"), edited.trailer().get(b"ID"), "and leaves it as it was");
}
}
/// A direct `/Encrypt` can refer to objects. A full save dropped those it found only there, and kept
/// its references as they were under the old numbers, so nothing in the file read afterwards.
#[test]
fn a_direct_encrypt_dictionary_keeps_what_it_refers_to() {
for object_streams in [false, true] {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
doc.set_encryption(&NewEncryption {
algorithm: Algorithm::Aes128,
user_password: "",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [9; 32],
})
.unwrap();
let mut dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap();
let filter = doc.add(Object::name("StdCF"));
let filters = doc.add(dict.get(b"CF").unwrap().clone());
dict.set(b"StmF".to_vec(), Object::Ref(filter));
dict.set(b"CF".to_vec(), Object::Ref(filters));
doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict));
let options = SaveOptions { object_streams, ..SaveOptions::default() };
let saved = write_full(&doc, &options).unwrap();
let back = Document::open(Arc::new(saved)).unwrap();
assert_eq!(content(&back), SECRET, "object streams: {object_streams}");
let again = write_full(&back, &options).unwrap();
assert_eq!(content(&Document::open(Arc::new(again)).unwrap()), SECRET, "object streams: {object_streams}, saved again");
}
}
/// How a stream is treated can be given by reference, and what that loads can be a stream too. A
/// file can make a ladder of those, where each rung reads the rung below, then two trees wider than
/// the object cache, which push that rung out of it, then the rung below again. Each rung would
/// take twice what the one below does, if one read could follow any number of references.
#[test]
fn selectors_that_lead_to_selectors_load_at_once() {
let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap();
let stream_of = |doc: &mut Document, filters: Vec<Object>| {
let mut dict = craft_pdf::cos::Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(filters));
doc.add(Object::Stream(craft_pdf::cos::Stream::from_raw(dict, Vec::new())))
};
let tree = |doc: &mut Document| {
let mut level: Vec<ObjRef> = (0..6_561).map(|i| doc.add(Object::Int(i))).collect();
while level.len() > 1 {
let chunks: Vec<Vec<Object>> = level.chunks(9).map(|c| c.iter().map(|r| Object::Ref(*r)).collect()).collect();
level = chunks.into_iter().map(|c| stream_of(doc, c)).collect();
}
Object::Ref(level[0])
};
let (first, second) = (tree(&mut doc), tree(&mut doc));
let mut top = doc.add(Object::Int(0));
// Twelve rungs of two, and the trees' five levels, stay inside the 32 that loading may nest.
for _ in 0..12 {
let (a, b) = (stream_of(&mut doc, vec![Object::Ref(top)]), stream_of(&mut doc, vec![Object::Ref(top)]));
top = stream_of(&mut doc, vec![Object::Ref(a), first.clone(), second.clone(), Object::Ref(b)]);
}
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let (done, finished) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let doc = Document::open(Arc::new(saved)).unwrap();
let _ = done.send(doc.get(top).as_dict().is_some());
});
// With the budget it takes milliseconds, and without it longer than anyone would wait, so a
// margin this wide can't fail on a busy machine.
assert_eq!(finished.recv_timeout(std::time::Duration::from_secs(120)), Ok(true), "loading must not fan out");
}