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

181 lines
9.0 KiB
Rust

//! Corpus parity: every file hayro-syntax can open must also open with our reader, agree on the
//! page count, and survive an incremental save that touches the catalog (the appended revision
//! must be readable by both readers and leave the original bytes untouched).
//!
//! Runs over `corpus/pdfjs/test/pdfs` at the repository root when present (PdfCraft's
//! `cargo xtask corpus` fetches and verifies it; copy or link its `corpus/pdfjs`); when it is
//! absent these are a no-op, so CI without the corpus stays green. Run with
//! `--ignored --nocapture`.
//!
//! A corpus that is present but **unverified** is a failure, not a skip. `xtask corpus` writes
//! `corpus/pdfjs/.pdfcraft-corpus.json` only after the checkout matches the pinned commit and
//! manifest sha256, so these tests refuse to run against a tree nobody has checked. Without that,
//! a half-finished or tampered checkout would quietly produce green results.
use std::sync::Arc;
use craft_pdf::cos::{Document, Object, SaveOptions, write_full, write_incremental};
/// `corpus/pdfjs`, or `None` when nothing has been fetched.
///
/// Panics when the checkout exists without the stamp that `cargo xtask corpus` writes on a
/// successful verification: an unverified corpus must fail loudly rather than skip.
fn checkout() -> Option<std::path::PathBuf> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pdfjs");
if !dir.is_dir() {
return None;
}
let stamp = dir.join(".pdfcraft-corpus.json");
assert!(
stamp.is_file(),
"{} exists but {} does not: the corpus has not been verified against the pin. \
Run `cargo xtask corpus` (it fetches and verifies), or delete corpus/pdfjs to skip these tests.",
dir.display(),
stamp.display()
);
Some(dir)
}
/// Leaf pages reachable from the catalog (cycle-safe), like viewers count them.
fn page_count(doc: &Document) -> Option<usize> {
let root = doc.get(doc.root()?);
let mut stack = vec![root.as_dict()?.get(b"Pages")?.clone()];
let (mut seen, mut n) = (std::collections::HashSet::new(), 0);
while let Some(o) = stack.pop() {
if let Object::Ref(r) = o
&& !seen.insert(r)
{
continue;
}
let node = doc.resolve(&o);
let Some(d) = node.as_dict() else { continue };
match d.get(b"Kids").map(|k| doc.resolve(k)) {
Some(k) if d.name(b"Type") != Some(b"Page") => stack.extend(k.as_array().cloned().unwrap_or_default().into_iter().rev()),
_ => n += 1,
}
}
Some(n)
}
#[test]
#[ignore = "needs corpus/; run with --ignored"]
fn corpus_parity() {
let Some(root) = checkout() else {
eprintln!("corpus not fetched; skipping (cargo xtask corpus)");
return;
};
let dir = root.join("test/pdfs");
let entries = std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("verified corpus has no {}: {e}", dir.display()));
let mut files: Vec<_> = entries.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "pdf")).collect();
files.sort();
let (mut checked, mut encrypted, mut failures) = (0, 0, Vec::new());
for path in &files {
let bytes = Arc::new(std::fs::read(path).unwrap());
let Ok(reference) = std::panic::catch_unwind(|| hayro_syntax::Pdf::new(bytes.clone())) else { continue };
let Ok(reference) = reference else { continue }; // hayro can't open it either: not a parity case
let name = path.file_name().unwrap().to_string_lossy().into_owned();
if std::env::var("COS_ONLY").is_ok_and(|o| o != name) {
continue;
}
if std::env::var_os("COS_TRACE").is_some() {
eprintln!("{name}");
}
let expected = reference.pages().len();
let result = std::panic::catch_unwind(|| -> Result<(), String> {
let mut doc = match Document::open(bytes.clone()) {
Ok(d) => d,
Err(craft_pdf::cos::CosError::NeedsPassword) => return Err("encrypted".into()),
Err(e) => return Err(format!("open: {e}")),
};
// hayro may repair a broken /Count; compare against the leaf walk count loosely.
if page_count(&doc) != Some(expected) && expected > 0 {
return Err(format!("page count {:?} vs hayro {expected}", page_count(&doc)));
}
let root = doc.root().ok_or("no root")?;
doc.update_dict(root, |d| d.set(b"PdfCraftTest".to_vec(), Object::Bool(true))).map_err(|e| e.to_string())?;
let saved = write_incremental(&doc, &SaveOptions::default()).map_err(|e| format!("save: {e}"))?;
// Reconstructed files are rewritten in full; everything else must keep its bytes.
if !doc.revisions().is_empty() && saved.get(..bytes.len()) != Some(&bytes[..]) {
return Err("prefix modified".into());
}
let again = Document::open(Arc::new(saved.clone())).map_err(|e| format!("reopen: {e}"))?;
let flag = again.get(again.root().ok_or("no root after save")?).as_dict().and_then(|d| d.get(b"PdfCraftTest").cloned());
if flag != Some(Object::Bool(true)) {
return Err("edit lost after reopen".into());
}
let hay = hayro_syntax::Pdf::new(Arc::new(saved)).map_err(|e| format!("hayro reopen: {e:?}"))?;
if hay.pages().len() != expected {
return Err(format!("hayro sees {} pages after save, expected {expected}", hay.pages().len()));
}
let full = write_full(&doc, &SaveOptions::default()).map_err(|e| format!("full: {e}"))?;
let hay = hayro_syntax::Pdf::new(Arc::new(full)).map_err(|e| format!("hayro full: {e:?}"))?;
if hay.pages().len() != expected {
return Err(format!("hayro sees {} pages after full save, expected {expected}", hay.pages().len()));
}
Ok(())
});
match result {
Ok(Ok(())) => checked += 1,
Ok(Err(e)) if e == "encrypted" => encrypted += 1,
Ok(Err(e)) => failures.push(format!("{name}: {e}")),
Err(_) => failures.push(format!("{name}: PANIC")),
}
}
eprintln!("cos corpus parity: {checked} ok, {encrypted} encrypted (skipped), {} failures", failures.len());
for f in &failures {
eprintln!(" {f}");
}
assert!(failures.iter().all(|f| !f.ends_with("PANIC")), "no panics");
}
/// Password-protected corpus files (passwords from pdf.js's test manifest): the right password
/// opens them and the edit/save round trip still works; no or a wrong password is reported.
#[test]
#[ignore = "needs corpus/; run with --ignored"]
fn corpus_passwords() {
let Some(root) = checkout() else {
eprintln!("corpus not fetched; skipping (cargo xtask corpus)");
return;
};
let dir = root.join("test");
let manifest =
std::fs::read_to_string(dir.join("test_manifest.json")).unwrap_or_else(|e| panic!("verified corpus has no test_manifest.json: {e}"));
let entries: Vec<serde_json::Value> = serde_json::from_str(&manifest).expect("manifest is JSON");
let cases: Vec<(String, String)> =
entries.iter().filter_map(|e| Some((e.get("file")?.as_str()?.to_string(), e.get("password")?.as_str()?.to_string()))).collect();
assert!(cases.len() >= 5, "manifest parsed ({} cases)", cases.len());
let mut failures = Vec::new();
for (file, pw) in &cases {
let Ok(bytes) = std::fs::read(dir.join(file)) else { continue };
let bytes = Arc::new(bytes);
if !matches!(Document::open(bytes.clone()), Err(craft_pdf::cos::CosError::NeedsPassword)) {
failures.push(format!("{file}: opened without a password"));
}
if !matches!(Document::open_with_password(bytes.clone(), Some("definitely wrong")), Err(craft_pdf::cos::CosError::WrongPassword)) {
failures.push(format!("{file}: wrong password not rejected"));
}
match Document::open_with_password(bytes.clone(), Some(pw)) {
Ok(mut doc) => {
let pages = page_count(&doc);
if pages.unwrap_or(0) == 0 {
failures.push(format!("{file}: no pages after decryption"));
continue;
}
let root = doc.root().unwrap();
doc.update_dict(root, |d| d.set(b"PdfCraftTest".to_vec(), Object::Bool(true))).unwrap();
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
match Document::open_with_password(Arc::new(saved.clone()), Some(pw)) {
Ok(again) if page_count(&again) == pages => {}
other => failures.push(format!("{file}: reopen after save failed ({:?})", other.err())),
}
}
Err(e) => failures.push(format!("{file}: {e}")),
}
}
eprintln!("password corpus: {} cases, {} failures", cases.len(), failures.len());
for f in &failures {
eprintln!(" {f}");
}
assert!(failures.is_empty());
}