Files
craft-libs/crates/pdf/tests/cos/efopen.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

225 lines
11 KiB
Rust

//! A document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent
//! /EFOpen`, ISO 32000-2 §7.6.6, Table 25): it opens without a password, its pages read as stored,
//! and its embedded files keep their encrypted bytes, flagged as such, through every save.
use std::sync::Arc;
use craft_pdf::cos::{Algorithm, CosError, Document, NewEncryption, ObjRef, Object, SaveOptions, write_full, write_incremental};
use craft_pdf::crypt::StreamKind;
const PAGE: &str = "BT /F1 12 Tf 20 100 Td (Visible page) Tj ET";
const ATTACHED: &[u8] = b"the attached file's own words";
const PASSWORD: &str = "attach pw";
const EMBEDDED: ObjRef = ObjRef { num: 6, generation: 0 };
const CONTENT: ObjRef = ObjRef { num: 4, generation: 0 };
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02X}")).collect()
}
/// One page in the clear and one attachment encrypted with `/StdCF`, whose `/AuthEvent` is
/// `auth_event`: AES-256 (V5, R6) or AES-128 (V4, R4), `/StmF` and `/StrF` `/Identity`.
fn fixture(algorithm: Algorithm, auth_event: &str) -> Vec<u8> {
fixture_with(algorithm, auth_event, false)
}
/// [`fixture`], the attachment naming `/StdCF` in a `/Crypt` filter of its own when `named`, with
/// no `/EFF` (ISO 32000-2 §7.4.10).
fn fixture_with(algorithm: Algorithm, auth_event: &str, named: bool) -> Vec<u8> {
let id0 = [7u8; 16];
let params =
NewEncryption { algorithm, user_password: PASSWORD, owner_password: "owner pw", permissions: -4, encrypt_metadata: true, seed: [3; 32] };
let handler = craft_pdf::crypt::create(&params, &id0).unwrap();
let d = handler.dict();
let kind = if named { StreamKind::Named(b"StdCF") } else { StreamKind::EmbeddedFile };
let cipher = handler.encrypt_stream(EMBEDDED.num, 0, ATTACHED, kind);
let (eff, crypt) = if named { ("", " /Filter [/Crypt] /DecodeParms [<< /Name /StdCF >>]") } else { (" /EFF /StdCF", "") };
let (version, cfm, r6) = match algorithm {
Algorithm::Aes256 => ("/V 5 /R 6", "/AESV3 /Length 32", format!("/OE <{}> /UE <{}> /Perms <{}>", hex(&d.oe), hex(&d.ue), hex(&d.perms))),
_ => ("/V 4 /R 4 /Length 128", "/AESV2 /Length 16", String::new()),
};
let encrypt = format!(
"<< /Filter /Standard {version} /O <{}> /U <{}> {r6} /P {} /CF << /StdCF << /AuthEvent /{auth_event} /CFM {cfm} >> >> /StmF /Identity /StrF /Identity{eff} >>",
hex(&d.o),
hex(&d.u),
d.p
);
let mut objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 5 0 R] >> >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(),
format!("<< /Length {} >>\nstream\n{PAGE}\nendstream", PAGE.len()).into_bytes(),
b"<< /Type /Filespec /F (note.txt) /UF (note.txt) /EF << /F 6 0 R >> >>".to_vec(),
];
let mut stream = format!("<< /Type /EmbeddedFile{crypt} /Length {} >>\nstream\n", cipher.len()).into_bytes();
stream.extend_from_slice(&cipher);
stream.extend_from_slice(b"\nendstream");
objs.push(stream);
objs.push(encrypt.into_bytes());
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", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
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());
}
let trailer = format!(
"trailer\n<< /Size {} /Root 1 0 R /Encrypt 7 0 R /ID [<{}> <{}>] >>\nstartxref\n{xref}\n%%EOF\n",
objs.len() + 1,
hex(&id0),
hex(&id0)
);
out.extend_from_slice(trailer.as_bytes());
out
}
fn stream(doc: &Document, r: ObjRef) -> craft_pdf::cos::Stream {
match &*doc.get(r) {
Object::Stream(s) => s.clone(),
other => panic!("object {} isn't a stream: {other:?}", r.num),
}
}
/// The attachment as the password reads it from `bytes`.
fn attachment_with_password(bytes: Vec<u8>) -> Vec<u8> {
let doc = Document::open_with_password(Arc::new(bytes), Some(PASSWORD)).unwrap();
assert!(!doc.embedded_files_locked());
let embedded = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap();
assert!(embedded.get(b"Names").is_some());
stream(&doc, EMBEDDED).decoded().unwrap()
}
#[test]
fn a_document_encrypting_only_its_embedded_files_opens_without_a_password() {
let bytes = fixture(Algorithm::Aes256, "EFOpen");
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert!(doc.embedded_files_locked());
// Not a repair: the specification has it open so.
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
assert!(doc.trailer().get(b"Encrypt").is_some());
// The page reads as stored; the attachment keeps its encrypted bytes and says so.
let content = stream(&doc, CONTENT);
assert_eq!(content.decoded().unwrap(), PAGE.as_bytes());
assert!(!doc.reads_as_stored(&content));
let embedded = stream(&doc, EMBEDDED);
assert!(doc.reads_as_stored(&embedded));
assert_ne!(embedded.decoded().unwrap(), ATTACHED);
// The password still opens it, and reads the attachment.
assert_eq!(attachment_with_password(bytes.clone()), ATTACHED);
// A wrong password is still refused.
assert!(matches!(Document::open_with_password(Arc::new(bytes), Some("nope")), Err(CosError::WrongPassword)));
}
#[test]
fn a_crypt_filter_authorised_when_the_document_opens_still_needs_the_password() {
let bytes = fixture(Algorithm::Aes256, "DocOpen");
assert!(matches!(Document::open(Arc::new(bytes.clone())), Err(CosError::NeedsPassword)));
assert_eq!(attachment_with_password(bytes), ATTACHED);
}
#[test]
fn saves_keep_the_encrypted_embedded_files_as_stored() {
for algorithm in [Algorithm::Aes256, Algorithm::Aes128] {
let mut doc = Document::open(Arc::new(fixture(algorithm, "EFOpen"))).unwrap();
assert!(doc.embedded_files_locked(), "{algorithm:?}");
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"PageMode".to_vec(), Object::name("UseAttachments"))).unwrap();
// An incremental save keeps /Encrypt, and the attachment decrypts with the password.
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert_eq!(attachment_with_password(saved), ATTACHED, "{algorithm:?}");
// A full save renumbers: AES-256 keys don't depend on the number, AES-128 keys do.
match algorithm {
Algorithm::Aes256 => {
let full = write_full(&doc, &SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(full.clone())).unwrap();
assert!(reopened.embedded_files_locked());
let doc = Document::open_with_password(Arc::new(full), Some(PASSWORD)).unwrap();
let names = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap();
let tree = doc.resolve(names.get(b"Names").unwrap()).as_dict().cloned().unwrap();
let files = doc.resolve(tree.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
let list = doc.resolve(files.get(b"Names").unwrap()).as_array().cloned().unwrap();
let spec = doc.resolve(&list[1]).as_dict().cloned().unwrap();
let ef = doc.resolve(spec.get(b"EF").unwrap()).as_dict().cloned().unwrap();
let file = match &*doc.resolve(ef.get(b"F").unwrap()) {
Object::Stream(s) => s.decoded().unwrap(),
other => panic!("{other:?}"),
};
assert_eq!(file, ATTACHED);
}
_ => assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))), "{algorithm:?}"),
}
}
}
#[test]
fn security_cannot_change_without_the_embedded_files_password() {
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
doc.remove_encryption();
assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
}
/// From the review of the EFOpen change: the empty password opens it too, and the credentials of a
/// document opened without the key open no other.
#[test]
fn the_empty_password_opens_it_and_keyless_credentials_open_nothing_more() {
let bytes = fixture(Algorithm::Aes256, "EFOpen");
let doc = Document::open_with_password(Arc::new(bytes.clone()), Some("")).unwrap();
assert!(doc.embedded_files_locked());
assert!(doc.open_with_same_credentials(Arc::new(bytes)).unwrap().embedded_files_locked());
// The same /Encrypt values and /ID, authorised when the document opens.
let doc_open = fixture(Algorithm::Aes256, "DocOpen");
assert!(matches!(doc.open_with_same_credentials(Arc::new(doc_open.clone())), Err(CosError::NeedsPassword)));
assert!(matches!(Document::open_with_password(Arc::new(doc_open), Some("")), Err(CosError::WrongPassword)));
}
/// An embedded file that names an EFOpen crypt filter of its own, with no `/EFF`, opens the same way.
#[test]
fn an_embedded_file_naming_its_own_crypt_filter_opens_without_a_password() {
let bytes = fixture_with(Algorithm::Aes256, "EFOpen", true);
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert!(doc.embedded_files_locked());
assert!(doc.reads_as_stored(&stream(&doc, EMBEDDED)));
assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes());
assert_eq!(attachment_with_password(bytes), ATTACHED);
}
/// A save can't encrypt without the key: removing `/Encrypt` through the trailer, or putting in an
/// embedded file, is refused rather than written in the clear.
#[test]
fn saves_refuse_what_would_need_the_key() {
let open = || Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
let mut doc = open();
doc.trailer_mut().remove(b"Encrypt");
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
let mut doc = open();
let mut replaced = stream(&doc, EMBEDDED);
replaced.raw = b"new words".to_vec().into();
doc.set(EMBEDDED, Object::Stream(replaced));
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
}
/// The password given after opening unlocks the embedded files; a wrong one doesn't.
#[test]
fn the_password_given_later_unlocks_the_embedded_files() {
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
assert!(matches!(doc.unlock("nope"), Err(CosError::WrongPassword)));
assert!(doc.embedded_files_locked());
doc.unlock(PASSWORD).unwrap();
assert!(!doc.embedded_files_locked());
assert_eq!(stream(&doc, EMBEDDED).decoded().unwrap(), ATTACHED);
assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes());
// Not while an embedded file read as stored has been edited.
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
let edited = stream(&doc, EMBEDDED);
doc.set(EMBEDDED, Object::Stream(edited));
assert!(matches!(doc.unlock(PASSWORD), Err(CosError::Security(_))));
}