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

278 lines
13 KiB
Rust

//! The file identifier (`/ID`, ISO 32000-2 §14.4) of a full save. An ordinary save keeps the
//! identifier the file has and writes the same bytes twice. A rewrite the document asked for (after
//! redaction or sanitizing) gives the file a new pair. An encrypted file keeps its first string,
//! because its file key is derived from it.
use std::sync::Arc;
use crate::testkit::Pdf;
use craft_pdf::cos::{Algorithm, Document, NewEncryption, Object, PdfString, SaveOptions, write_full, write_incremental};
const ID_HEX: &str = "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]";
const ID_BYTES: [u8; 16] = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
/// One page whose content holds a secret, with `entry` added to the trailer (for example an `/ID`).
fn source_with(entry: Option<&str>) -> Vec<u8> {
let pdf = Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>")
.obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>")
.stream("", "BT /F1 12 Tf 20 100 Td (TOPSECRET) Tj ET")
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
match entry {
Some(entry) => pdf.trailer_entry(entry).build(),
None => pdf.build(),
}
}
/// [`source_with`] with the standard identifier, or none.
fn source(with_id: bool) -> Vec<u8> {
source_with(with_id.then_some(ID_HEX))
}
/// A one-page file whose 70,000-byte content stream ends in `last`. Two of these built with
/// different last bytes have the same length and differ only past the first 64 KiB.
fn large_source(last: u8) -> Vec<u8> {
let mut content = vec![b' '; 70_000];
content.push(last);
Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>")
.obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>")
.stream("", content)
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
.build()
}
fn reopen(bytes: Vec<u8>) -> Document {
Document::open(Arc::new(bytes)).unwrap()
}
/// The strings of the trailer's `/ID` (empty when there is none).
fn id_strings(doc: &Document) -> Vec<Vec<u8>> {
match doc.trailer().get(b"ID") {
Some(Object::Array(a)) => a.iter().map(|s| s.as_string().map(|s| s.bytes.clone()).unwrap_or_default()).collect(),
_ => Vec::new(),
}
}
#[test]
fn an_ordinary_save_keeps_the_identifier_and_writes_the_same_bytes() {
let doc = reopen(source(true));
let opts = SaveOptions::default();
let saved = write_full(&doc, &opts).unwrap();
assert_eq!(saved, write_full(&doc, &opts).unwrap(), "unchanged inputs produce identical bytes");
assert_eq!(id_strings(&reopen(saved)), vec![ID_BYTES.to_vec(), ID_BYTES.to_vec()]);
let appended = reopen(write_incremental(&doc, &opts).unwrap());
assert_eq!(id_strings(&appended), vec![ID_BYTES.to_vec(), ID_BYTES.to_vec()]);
}
#[test]
fn an_ordinary_save_of_a_file_without_an_identifier_creates_the_same_one_every_time() {
let doc = reopen(source(false));
let opts = SaveOptions::default();
let saved = write_full(&doc, &opts).unwrap();
assert_eq!(saved, write_full(&doc, &opts).unwrap(), "unchanged inputs produce identical bytes");
let ids = id_strings(&reopen(saved));
assert_eq!(ids.len(), 2);
assert!(ids.iter().all(|s| s.len() == 16), "{ids:?}");
}
#[test]
fn a_rewrite_the_document_asked_for_gets_a_new_identifier() {
let mut doc = reopen(source(true));
doc.require_full_save_with_new_id();
assert!(doc.full_save_required() && doc.new_id_required());
let opts = SaveOptions::default();
for saved in [write_incremental(&doc, &opts).unwrap(), write_full(&doc, &opts).unwrap()] {
let ids = id_strings(&reopen(saved));
assert_eq!(ids.len(), 2);
for id in &ids {
assert_eq!(id.len(), 16);
assert_ne!(id.as_slice(), ID_BYTES.as_slice(), "the copy must not claim the source's identifier");
}
}
// Every save is a new identity, not the same one again.
let first = id_strings(&reopen(write_full(&doc, &opts).unwrap()));
let second = id_strings(&reopen(write_full(&doc, &opts).unwrap()));
assert_ne!(first, second);
}
#[test]
fn a_rewrite_without_an_identifier_still_gets_one() {
let mut doc = reopen(source(false));
doc.require_full_save_with_new_id();
let ids = id_strings(&reopen(write_full(&doc, &SaveOptions::default()).unwrap()));
assert_eq!(ids.len(), 2);
assert!(ids.iter().all(|s| s.len() == 16), "{ids:?}");
}
#[test]
fn caller_entropy_makes_the_new_identifier_reproducible() {
let mut doc = reopen(source(true));
doc.require_full_save_with_new_id();
let with = |entropy: u8| SaveOptions { id_entropy: Some([entropy; 16]), ..SaveOptions::default() };
let first = write_full(&doc, &with(1)).unwrap();
assert_eq!(first, write_full(&doc, &with(1)).unwrap(), "the same entropy gives the same bytes");
let other = write_full(&doc, &with(2)).unwrap();
assert_ne!(id_strings(&reopen(first)), id_strings(&reopen(other)), "different entropy gives a different identifier");
}
#[test]
fn an_encrypted_rewrite_keeps_the_first_identifier_string_its_key_depends_on() {
for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] {
let mut doc = reopen(source(true));
doc.set_encryption(&NewEncryption {
algorithm,
user_password: "user",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [5; 32],
})
.unwrap();
let protected = write_full(&doc, &SaveOptions::default()).unwrap();
let before = Document::open_with_password(Arc::new(protected), Some("user")).unwrap();
let mut doc = before.clone();
doc.require_full_save_with_new_id();
let out = write_incremental(&doc, &SaveOptions::default()).unwrap();
// The output opens with the same password only if its first identifier string is unchanged.
let back = Document::open_with_password(Arc::new(out), Some("user")).unwrap();
let (old, new) = (id_strings(&before), id_strings(&back));
assert_eq!(new.len(), 2, "{algorithm:?}");
assert_eq!(new.first(), old.first(), "{algorithm:?}: the key depends on the first string");
assert_ne!(new.get(1), old.get(1), "{algorithm:?}: the second string is new");
}
}
/// The `/ID` shapes whose key the reader takes as the empty string: none at all, one entry, and a
/// first entry that is not a string.
fn unusable_ids() -> Vec<(&'static str, Option<Object>)> {
let empty = Object::String(PdfString::literal(Vec::new()));
vec![
("absent", None),
("one entry", Some(Object::Array(vec![empty.clone()]))),
("first entry not a string", Some(Object::Array(vec![Object::Int(7), empty]))),
]
}
/// An encrypted file whose key is derived from the empty string, with its trailer `/ID` replaced by `id`.
fn protected_with_id(algorithm: Algorithm, id: Option<Object>) -> Vec<u8> {
let mut doc = reopen(source_with(Some("/ID [<> <>]")));
doc.set_encryption(&NewEncryption {
algorithm,
user_password: "user",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [5; 32],
})
.unwrap();
match id {
Some(id) => doc.trailer_mut().set(b"ID".to_vec(), id),
None => {
doc.trailer_mut().remove(b"ID");
}
}
write_full(&doc, &SaveOptions::default()).unwrap()
}
#[test]
fn an_encrypted_rewrite_without_a_usable_identifier_still_gets_a_new_one() {
// The key is derived from the first string of /ID, and a missing /ID, a one-entry /ID and a
// first entry that is not a string all count as the empty string. The rewrite keeps that
// (empty) first string, so the file still opens with its password, and writes a new second one.
for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] {
for (shape, id) in unusable_ids() {
let before = Document::open_with_password(Arc::new(protected_with_id(algorithm, id)), Some("user")).unwrap();
let mut doc = before.clone();
doc.require_full_save_with_new_id();
let out = write_incremental(&doc, &SaveOptions::default()).unwrap();
let back = Document::open_with_password(Arc::new(out), Some("user")).unwrap();
let ids = id_strings(&back);
let previous = id_strings(&before);
assert_eq!(ids.len(), 2, "{algorithm:?}, {shape}: the rewrite writes a pair");
assert!(ids.first().is_some_and(|s| s.is_empty()), "{algorithm:?}, {shape}: the first string is the one the key uses");
assert_eq!(ids.get(1).map(Vec::len), Some(16), "{algorithm:?}, {shape}");
assert_ne!(ids.get(1), previous.get(1), "{algorithm:?}, {shape}: the second string is new");
}
}
}
/// The standard fixture rewritten for a new identifier, with `id_entropy` of 0x42.
fn fixed_entropy_rewrite() -> Vec<u8> {
let mut doc = reopen(source(true));
doc.require_full_save_with_new_id();
write_full(&doc, &SaveOptions { id_entropy: Some([0x42; 16]), ..SaveOptions::default() }).unwrap()
}
#[test]
fn a_fixed_entropy_gives_the_same_identifier_every_time() {
// The formula itself is pinned on fixed bytes in the writer's tests. The written file is not the
// same on every platform, so no identifier of a rewrite can be pinned here.
let first = id_strings(&reopen(fixed_entropy_rewrite()));
assert_eq!(first.len(), 2);
assert!(first.iter().all(|s| s.len() == 16), "{first:?}");
assert_eq!(first, id_strings(&reopen(fixed_entropy_rewrite())));
}
#[test]
fn the_identifier_depends_on_the_whole_rewritten_file() {
// Same caller entropy and same length; the two rewrites differ only in one byte of a stream
// that lies past the first 64 KiB of the output. Their identifiers must still differ.
let opts = SaveOptions { id_entropy: Some([9; 16]), ..SaveOptions::default() };
let rewrite = |last: u8| {
let mut doc = reopen(large_source(last));
doc.require_full_save_with_new_id();
write_full(&doc, &opts).unwrap()
};
let (a, b) = (rewrite(b'A'), rewrite(b'B'));
assert_eq!(a.len(), b.len(), "the two rewrites have the same length");
let first_difference = a.iter().zip(&b).position(|(x, y)| x != y).unwrap_or(usize::MAX);
assert!(first_difference > 64 * 1024, "the rewrites first differ at byte {first_difference}, inside the first 64 KiB");
assert_ne!(id_strings(&reopen(a)), id_strings(&reopen(b)), "content past 64 KiB must change the identifier");
}
/// Where [`stand_in_for_a_fresh_process`] writes its rewrite.
const ID_PROBE: &str = "CRAFT_PDF_TEST_ID_PROBE";
/// The child half of [`separate_fresh_processes_get_different_identifiers`]: it runs alone, in its
/// own process, and writes a default-path rewrite of the standard fixture to the file the parent
/// named. Without the variable it is a plain no-op.
#[test]
fn stand_in_for_a_fresh_process() {
let Some(path) = std::env::var_os(ID_PROBE) else { return };
let mut doc = reopen(source(true));
doc.require_full_save_with_new_id();
std::fs::write(path, write_full(&doc, &SaveOptions::default()).unwrap()).unwrap();
}
#[test]
fn separate_fresh_processes_get_different_identifiers() {
// The default identifier is meant to differ between fresh instances of the whole save pipeline,
// not only between two calls in one process (which the counter already separates). On the native
// test target a fresh instance is a fresh process, whose hash keys the operating system seeds;
// the fix under test runs on WebAssembly, where no browser test harness exists — there the
// browser's `crypto.getRandomValues` stands in for this seeding, and this test cannot see it.
let exe = std::env::current_exe().unwrap();
let probe = |name: &str| {
let path = std::env::temp_dir().join(format!("craft-pdf-id-probe-{}-{name}.pdf", std::process::id()));
let out = std::process::Command::new(&exe)
// The test binary holds every `cos` test module, so the child test is named by its path.
.args(["--exact", "file_identifier::stand_in_for_a_fresh_process", "--nocapture", "--"])
.env(ID_PROBE, &path)
.output()
.expect("run this test's own binary as a child");
assert!(out.status.success(), "{name}: the child failed: {}", String::from_utf8_lossy(&out.stderr));
let saved = std::fs::read(&path).unwrap_or_else(|e| panic!("{name}: the child wrote its file: {e}"));
let ids = id_strings(&reopen(saved));
assert_eq!(ids.len(), 2, "{name}");
assert!(ids.iter().all(|s| s.len() == 16), "{name}: {ids:?}");
let _ = std::fs::remove_file(&path);
ids
};
let (first, second) = (probe("a"), probe("b"));
assert_ne!(first, second, "two fresh processes must not share an identifier");
}