spectre_pdf 1.0.0

Native Rust PDF extraction engine: text, markdown for RAG, AcroForm widgets, image decoding, and encrypted PDFs. Lazy parser, persistent Document handle, no C dependencies.
Documentation
//! Round-trip tests for every Standard Security Handler revision.
//! Fixtures in `tests/fixtures/encrypted/` are generated by qpdf from
//! an IRS W-9 (US-government work, public domain). Regenerate via
//! `scripts/bench_encrypted_vs_pymupdf.py` (it calls qpdf).

use spectre_pdf::Document;

fn fixture(name: &str) -> Vec<u8> {
    let path = format!("tests/fixtures/encrypted/{name}");
    std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
}

fn baseline_text() -> String {
    let bytes = fixture("source.pdf");
    let doc = Document::open(&bytes).expect("open unencrypted source");
    doc.text().expect("extract source text").trim().to_owned()
}

fn roundtrip(name: &str, password: &[u8]) {
    let bytes = fixture(name);
    let doc = Document::open_with_password(&bytes, password)
        .unwrap_or_else(|e| panic!("open {name} with password: {e}"));
    let got = doc.text().expect("extract text").trim().to_owned();
    let want = baseline_text();
    assert_eq!(got, want, "{name}: decrypted text differs from baseline");
}

#[test]
fn v1_r2_rc4_40_roundtrip() {
    roundtrip("v1_rc4_40.pdf", b"secret");
}

#[test]
fn v2_r3_rc4_128_roundtrip() {
    roundtrip("v2_rc4_128.pdf", b"secret");
}

#[test]
fn v4_r4_aes128_roundtrip() {
    roundtrip("v4_aes_128.pdf", b"secret");
}

#[test]
fn v4_r4_aes128_empty_user_roundtrip() {
    roundtrip("v4_aes_128_empty_user.pdf", b"");
}

#[test]
fn v5_r5_aes256_roundtrip() {
    // R=5 is the withdrawn Acrobat 9 revision (security flaw, Adobe
    // pulled it in 2010). qpdf still generates it via --force-R5.
    roundtrip("v5_aes_256_r5.pdf", b"secret");
}

#[test]
fn v5_r6_aes256_roundtrip() {
    roundtrip("v5_aes_256.pdf", b"secret");
}

#[test]
fn v5_r6_aes256_empty_user_roundtrip() {
    roundtrip("v5_aes_256_empty_user.pdf", b"");
}

fn assert_rejects(name: &str) {
    let bytes = fixture(name);
    match Document::open_with_password(&bytes, b"wrong-password") {
        Ok(_) => panic!("{name}: wrong password incorrectly accepted"),
        Err(e) => {
            let msg = format!("{e:?}");
            assert!(
                msg.contains("Encrypted"),
                "{name}: expected ExtractError::Encrypted, got {msg}"
            );
        }
    }
}

#[test]
fn wrong_password_rejected_aes128() {
    assert_rejects("v4_aes_128.pdf");
}

#[test]
fn wrong_password_rejected_aes256() {
    assert_rejects("v5_aes_256.pdf");
}

#[test]
fn user_password_pdf_surfaces_encrypted_via_free_function() {
    // Pane finding #2: free positional functions used to map this to
    // LoadFailed("password did not authenticate"). They must surface
    // ExtractError::Encrypted just like the Document handle path.
    let bytes = fixture("v4_aes_128.pdf");
    let err = spectre_pdf::extract_text(&bytes).expect_err("user-password PDF must error");
    let msg = format!("{err:?}");
    assert!(
        msg.contains("Encrypted"),
        "free extract_text on user-password PDF should give Encrypted, got {msg}"
    );

    let err = spectre_pdf::extract_words(&bytes, None).expect_err("user-password PDF must error");
    let msg = format!("{err:?}");
    assert!(
        msg.contains("Encrypted"),
        "free extract_words on user-password PDF should give Encrypted, got {msg}"
    );

    let err = spectre_pdf::extract_blocks(&bytes, None).expect_err("user-password PDF must error");
    let msg = format!("{err:?}");
    assert!(
        msg.contains("Encrypted"),
        "free extract_blocks on user-password PDF should give Encrypted, got {msg}"
    );
}