mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 02:29:31 -06:00
Fuzzed inputs drove one check-one process to 2-6 GB (the 16 GB CI runner running two of them was killed every night). Three causes, each with a test: - lopdf (bootstrap inspector) sized PNG predictor rows from /Columns before reading data: /Columns 4294967295 allocated two 4 GiB rows. Vendored lopdf 0.45.0 refuses rows longer than the data (and uses saturating row widths); its load-time stream decoding also gets a 256 MiB limit (it had none). - hayro sized a tiling pattern's cell pixmap from /XStep x scale, bounded only by u16: a step far beyond /BBox allocated 2 GB. The vendored patch keeps the cell within 3000 px a side, lowering the resolution instead. - cos decoded cross-reference streams up to the general 1 GiB limit; they now stop at what 8,388,607 objects' rows can hold (~200 MB at most), and the section is reconstructed (logged) instead. On the nightly's seed (41) the worst captured input fell from 6.2 GB to 6 MB, and a 300 s fuzz run had no process above 300 MB (before: many at 2-5 GB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
336 lines
13 KiB
Rust
336 lines
13 KiB
Rust
use lopdf::Document;
|
|
|
|
#[test]
|
|
fn test_metadata_extraction_basic() {
|
|
let buffer = std::fs::read("assets/example.pdf").unwrap();
|
|
let metadata = Document::load_metadata_mem(&buffer).unwrap();
|
|
|
|
assert_eq!(metadata.version, "1.5");
|
|
assert!(metadata.page_count > 0);
|
|
assert!(!metadata.encrypted);
|
|
}
|
|
|
|
#[test]
|
|
fn test_metadata_extraction_page_count() {
|
|
let buffer = std::fs::read("assets/example.pdf").unwrap();
|
|
let metadata = Document::load_metadata_mem(&buffer).unwrap();
|
|
assert!(metadata.page_count > 0);
|
|
|
|
let buffer = std::fs::read("assets/example.pdf").unwrap();
|
|
let doc = Document::load_mem(&buffer).unwrap();
|
|
let pages = doc.get_pages();
|
|
assert_eq!(metadata.page_count, pages.len() as u32);
|
|
}
|
|
|
|
#[test]
|
|
fn test_metadata_extraction_unicode() {
|
|
let buffer = std::fs::read("assets/unicode.pdf").unwrap();
|
|
let metadata = Document::load_metadata_mem(&buffer).unwrap();
|
|
assert!(metadata.page_count > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_metadata_extraction_from_memory() {
|
|
let buffer = std::fs::read("assets/example.pdf").unwrap();
|
|
let metadata = Document::load_metadata_mem(&buffer).unwrap();
|
|
|
|
assert_eq!(metadata.version, "1.5");
|
|
assert!(metadata.page_count > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_metadata_extraction_incremental() {
|
|
let buffer = std::fs::read("assets/Incremental.pdf").unwrap();
|
|
let metadata = Document::load_metadata_mem(&buffer).unwrap();
|
|
assert!(metadata.page_count > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_metadata_extraction_annotation_demo() {
|
|
let buffer = std::fs::read("assets/AnnotationDemo.pdf").unwrap();
|
|
let metadata = Document::load_metadata_mem(&buffer).unwrap();
|
|
assert!(metadata.page_count > 0);
|
|
}
|
|
|
|
#[cfg(not(feature = "async"))]
|
|
#[test]
|
|
fn test_metadata_extraction_encrypted_empty_password() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
let id1 = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
|
|
let id2 = vec![16u8, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
|
|
doc.trailer.set(
|
|
"ID",
|
|
lopdf::Object::Array(vec![
|
|
lopdf::Object::String(id1, lopdf::StringFormat::Literal),
|
|
lopdf::Object::String(id2, lopdf::StringFormat::Literal),
|
|
]),
|
|
);
|
|
|
|
let catalog_dict = lopdf::dictionary! {
|
|
"Type" => "Catalog",
|
|
"Pages" => lopdf::Object::Reference((2, 0))
|
|
};
|
|
let catalog_id = doc.add_object(catalog_dict);
|
|
doc.trailer.set("Root", lopdf::Object::Reference(catalog_id));
|
|
|
|
let pages_dict = lopdf::dictionary! {
|
|
"Type" => "Pages",
|
|
"Kids" => vec![lopdf::Object::Reference((3, 0))],
|
|
"Count" => 1
|
|
};
|
|
doc.objects.insert((2, 0), lopdf::Object::Dictionary(pages_dict));
|
|
|
|
let page_dict = lopdf::dictionary! {
|
|
"Type" => "Page",
|
|
"Parent" => lopdf::Object::Reference((2, 0)),
|
|
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()]
|
|
};
|
|
doc.objects.insert((3, 0), lopdf::Object::Dictionary(page_dict));
|
|
|
|
let info_dict = lopdf::dictionary! {
|
|
"Title" => lopdf::Object::String(b"Test Encrypted PDF".to_vec(), lopdf::StringFormat::Literal),
|
|
"Author" => lopdf::Object::String(b"Test Author".to_vec(), lopdf::StringFormat::Literal),
|
|
"Subject" => lopdf::Object::String(b"Test Subject".to_vec(), lopdf::StringFormat::Literal)
|
|
};
|
|
let info_id = doc.add_object(info_dict);
|
|
doc.trailer.set("Info", lopdf::Object::Reference(info_id));
|
|
|
|
let encryption_version = lopdf::EncryptionVersion::V2 {
|
|
document: &doc,
|
|
owner_password: "",
|
|
user_password: "",
|
|
key_length: 128,
|
|
permissions: lopdf::Permissions::all(),
|
|
};
|
|
|
|
let encryption_state = lopdf::EncryptionState::try_from(encryption_version).unwrap();
|
|
doc.encrypt(&encryption_state).unwrap();
|
|
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let encrypted_path = temp_dir.path().join("test_encrypted_metadata.pdf");
|
|
doc.save(&encrypted_path).unwrap();
|
|
|
|
let metadata = Document::load_metadata(&encrypted_path).unwrap();
|
|
assert_eq!(metadata.title, Some("Test Encrypted PDF".to_string()));
|
|
assert_eq!(metadata.author, Some("Test Author".to_string()));
|
|
assert_eq!(metadata.subject, Some("Test Subject".to_string()));
|
|
assert_eq!(metadata.page_count, 1);
|
|
assert!(metadata.encrypted);
|
|
}
|
|
|
|
#[cfg(not(feature = "async"))]
|
|
#[test]
|
|
fn test_metadata_extraction_encrypted_with_password() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
let id1 = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
|
|
let id2 = vec![16u8, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
|
|
doc.trailer.set(
|
|
"ID",
|
|
lopdf::Object::Array(vec![
|
|
lopdf::Object::String(id1, lopdf::StringFormat::Literal),
|
|
lopdf::Object::String(id2, lopdf::StringFormat::Literal),
|
|
]),
|
|
);
|
|
|
|
let catalog_dict = lopdf::dictionary! {
|
|
"Type" => "Catalog",
|
|
"Pages" => lopdf::Object::Reference((2, 0))
|
|
};
|
|
let catalog_id = doc.add_object(catalog_dict);
|
|
doc.trailer.set("Root", lopdf::Object::Reference(catalog_id));
|
|
|
|
let pages_dict = lopdf::dictionary! {
|
|
"Type" => "Pages",
|
|
"Kids" => vec![lopdf::Object::Reference((3, 0))],
|
|
"Count" => 1
|
|
};
|
|
doc.objects.insert((2, 0), lopdf::Object::Dictionary(pages_dict));
|
|
|
|
let page_dict = lopdf::dictionary! {
|
|
"Type" => "Page",
|
|
"Parent" => lopdf::Object::Reference((2, 0)),
|
|
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()]
|
|
};
|
|
doc.objects.insert((3, 0), lopdf::Object::Dictionary(page_dict));
|
|
|
|
let info_dict = lopdf::dictionary! {
|
|
"Title" => lopdf::Object::String(b"Password Protected PDF".to_vec(), lopdf::StringFormat::Literal),
|
|
"Author" => lopdf::Object::String(b"Protected Author".to_vec(), lopdf::StringFormat::Literal)
|
|
};
|
|
let info_id = doc.add_object(info_dict);
|
|
doc.trailer.set("Info", lopdf::Object::Reference(info_id));
|
|
|
|
let encryption_version = lopdf::EncryptionVersion::V2 {
|
|
document: &doc,
|
|
owner_password: "owner_pass",
|
|
user_password: "user_pass",
|
|
key_length: 128,
|
|
permissions: lopdf::Permissions::all(),
|
|
};
|
|
|
|
let encryption_state = lopdf::EncryptionState::try_from(encryption_version).unwrap();
|
|
doc.encrypt(&encryption_state).unwrap();
|
|
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let encrypted_path = temp_dir.path().join("test_password_metadata.pdf");
|
|
doc.save(&encrypted_path).unwrap();
|
|
|
|
let metadata = Document::load_metadata_with_password(&encrypted_path, "user_pass").unwrap();
|
|
assert_eq!(metadata.title, Some("Password Protected PDF".to_string()));
|
|
assert_eq!(metadata.author, Some("Protected Author".to_string()));
|
|
assert_eq!(metadata.page_count, 1);
|
|
|
|
let buffer = std::fs::read(&encrypted_path).unwrap();
|
|
let metadata_mem = Document::load_metadata_mem_with_password(&buffer, "user_pass").unwrap();
|
|
assert_eq!(metadata_mem.title, Some("Password Protected PDF".to_string()));
|
|
assert_eq!(metadata_mem.author, Some("Protected Author".to_string()));
|
|
assert_eq!(metadata_mem.page_count, 1);
|
|
assert!(metadata.encrypted);
|
|
assert!(metadata_mem.encrypted);
|
|
|
|
// Without the password, the loader no longer fails: it reports `encrypted`
|
|
// and omits the (undecryptable) Info fields rather than erroring.
|
|
let metadata_no_pw = Document::load_metadata_mem(&buffer).unwrap();
|
|
assert!(metadata_no_pw.encrypted);
|
|
assert_eq!(metadata_no_pw.title, None);
|
|
assert_eq!(metadata_no_pw.author, None);
|
|
}
|
|
|
|
#[cfg(not(feature = "async"))]
|
|
#[test]
|
|
fn test_metadata_extraction_encrypted_wrong_password() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
let id1 = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
|
|
let id2 = vec![16u8, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
|
|
doc.trailer.set(
|
|
"ID",
|
|
lopdf::Object::Array(vec![
|
|
lopdf::Object::String(id1, lopdf::StringFormat::Literal),
|
|
lopdf::Object::String(id2, lopdf::StringFormat::Literal),
|
|
]),
|
|
);
|
|
|
|
let catalog_dict = lopdf::dictionary! {
|
|
"Type" => "Catalog",
|
|
"Pages" => lopdf::Object::Reference((2, 0))
|
|
};
|
|
let catalog_id = doc.add_object(catalog_dict);
|
|
doc.trailer.set("Root", lopdf::Object::Reference(catalog_id));
|
|
|
|
let pages_dict = lopdf::dictionary! {
|
|
"Type" => "Pages",
|
|
"Kids" => vec![lopdf::Object::Reference((3, 0))],
|
|
"Count" => 1
|
|
};
|
|
doc.objects.insert((2, 0), lopdf::Object::Dictionary(pages_dict));
|
|
|
|
let page_dict = lopdf::dictionary! {
|
|
"Type" => "Page",
|
|
"Parent" => lopdf::Object::Reference((2, 0)),
|
|
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()]
|
|
};
|
|
doc.objects.insert((3, 0), lopdf::Object::Dictionary(page_dict));
|
|
|
|
let encryption_version = lopdf::EncryptionVersion::V2 {
|
|
document: &doc,
|
|
owner_password: "owner",
|
|
user_password: "user",
|
|
key_length: 128,
|
|
permissions: lopdf::Permissions::all(),
|
|
};
|
|
|
|
let encryption_state = lopdf::EncryptionState::try_from(encryption_version).unwrap();
|
|
doc.encrypt(&encryption_state).unwrap();
|
|
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let encrypted_path = temp_dir.path().join("test_wrong_password.pdf");
|
|
doc.save(&encrypted_path).unwrap();
|
|
|
|
let result = Document::load_metadata_with_password(&encrypted_path, "wrong_password");
|
|
assert!(result.is_err());
|
|
assert!(matches!(result.unwrap_err(), lopdf::Error::InvalidPassword));
|
|
}
|
|
|
|
/// Verifies that custom (non-standard) Info dictionary entries are preserved on
|
|
/// `PdfMetadata::custom`, while the standard fields remain available through
|
|
/// their typed accessors. This is the path used to extract producer-specific
|
|
/// metadata such as Microsoft Information Protection labels
|
|
/// (`MSIP_Label_{GUID}_{Property}`) without loading the full document.
|
|
#[test]
|
|
fn test_metadata_extraction_preserves_custom_info_entries() {
|
|
let mut doc = Document::with_version("1.5");
|
|
|
|
let catalog_dict = lopdf::dictionary! {
|
|
"Type" => "Catalog",
|
|
"Pages" => lopdf::Object::Reference((2, 0))
|
|
};
|
|
let catalog_id = doc.add_object(catalog_dict);
|
|
doc.trailer.set("Root", lopdf::Object::Reference(catalog_id));
|
|
|
|
let pages_dict = lopdf::dictionary! {
|
|
"Type" => "Pages",
|
|
"Kids" => vec![lopdf::Object::Reference((3, 0))],
|
|
"Count" => 1
|
|
};
|
|
doc.objects.insert((2, 0), lopdf::Object::Dictionary(pages_dict));
|
|
|
|
let page_dict = lopdf::dictionary! {
|
|
"Type" => "Page",
|
|
"Parent" => lopdf::Object::Reference((2, 0)),
|
|
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()]
|
|
};
|
|
doc.objects.insert((3, 0), lopdf::Object::Dictionary(page_dict));
|
|
|
|
let mip_guid_enabled = "MSIP_Label_754d9351-9ade-4bd5-893a-95071572330d_Enabled";
|
|
let mip_guid_name = "MSIP_Label_754d9351-9ade-4bd5-893a-95071572330d_Name";
|
|
|
|
let info_dict = lopdf::dictionary! {
|
|
"Title" => lopdf::Object::String(b"Doc with custom info".to_vec(), lopdf::StringFormat::Literal),
|
|
"Producer" => lopdf::Object::String(b"Test Suite".to_vec(), lopdf::StringFormat::Literal),
|
|
mip_guid_enabled => lopdf::Object::String(b"True".to_vec(), lopdf::StringFormat::Literal),
|
|
mip_guid_name => lopdf::Object::String(b"Confidential".to_vec(), lopdf::StringFormat::Literal),
|
|
"AppCustomCounter" => lopdf::Object::Integer(42)
|
|
};
|
|
let info_id = doc.add_object(info_dict);
|
|
doc.trailer.set("Info", lopdf::Object::Reference(info_id));
|
|
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let path = temp_dir.path().join("test_custom_info.pdf");
|
|
doc.save(&path).unwrap();
|
|
|
|
let buffer = std::fs::read(&path).unwrap();
|
|
let metadata = Document::load_metadata_mem(&buffer).unwrap();
|
|
|
|
// Standard fields still populated.
|
|
assert_eq!(metadata.title, Some("Doc with custom info".to_string()));
|
|
assert_eq!(metadata.producer, Some("Test Suite".to_string()));
|
|
|
|
// Custom fields preserved on the new `custom` map.
|
|
assert_eq!(metadata.custom.len(), 3);
|
|
|
|
let enabled = metadata
|
|
.custom
|
|
.get(mip_guid_enabled.as_bytes())
|
|
.expect("MIP Enabled entry preserved");
|
|
assert!(matches!(enabled, lopdf::Object::String(bytes, _) if bytes == b"True"));
|
|
|
|
let name = metadata
|
|
.custom
|
|
.get(mip_guid_name.as_bytes())
|
|
.expect("MIP Name entry preserved");
|
|
assert!(matches!(name, lopdf::Object::String(bytes, _) if bytes == b"Confidential"));
|
|
|
|
let counter = metadata
|
|
.custom
|
|
.get(&b"AppCustomCounter"[..])
|
|
.expect("non-string custom entry preserved");
|
|
assert!(matches!(counter, lopdf::Object::Integer(42)));
|
|
|
|
// Standard keys must NOT leak into `custom`.
|
|
assert!(!metadata.custom.contains_key(&b"Title"[..]));
|
|
assert!(!metadata.custom.contains_key(&b"Producer"[..]));
|
|
}
|