diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 8e051e4578..8bb79d4aaa 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -30,7 +30,7 @@ pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char, }; -pub use writer::{SaveOptions, pdf_date, serialize, write_full, write_incremental}; +pub use writer::{SaveOptions, new_file_id, pdf_date, serialize, write_full, write_incremental}; #[derive(Debug, thiserror::Error, Clone, PartialEq)] pub enum CosError { diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index dcfcb97997..b7a9d1c7ae 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -639,13 +639,19 @@ fn stamp_mod_date(doc: &mut Document, opts: &SaveOptions) { } } -/// Keep an existing `/ID`, or create one (two equal 16-byte strings, ยง14.4). +/// Keep an existing `/ID`, or create one with [`new_file_id`]. fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { if matches!(trailer.get(b"ID"), Some(Object::Array(a)) if a.len() == 2) { return; } + trailer.set(b"ID".to_vec(), new_file_id(content, opts.id_seed)); +} + +/// A new file identifier for a file whose bytes are `content` (ISO 32000-1 14.4): an array of +/// two equal 16-byte strings, the same for the same content and `seed`. +pub fn new_file_id(content: &[u8], seed: u64) -> Object { // FNV-1a over a sample of the content and the seed: stable, dependency-free. - let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ opts.id_seed; + let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ seed; for b in content.iter().step_by((content.len() / 4096).max(1)).chain(content.iter().rev().take(64)) { h ^= *b as u64; h = h.wrapping_mul(0x0100_0000_01b3); @@ -654,7 +660,7 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { id.extend_from_slice(&h.to_be_bytes()); id.extend_from_slice(&h.rotate_left(29).wrapping_mul(0x9E37_79B9_7F4A_7C15).to_be_bytes()); let s = Object::String(PdfString { bytes: id, hex: true }); - trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); + Object::Array(vec![s.clone(), s]) } /// A PDF date string for "now" given seconds since the Unix epoch (UTC). diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..d7ccced02c 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -22,6 +22,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod icc; +mod trailer; mod xmp; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -114,6 +115,8 @@ pub const WORDINGS: &[&str] = &[ "The metadata doesn't identify the file as {}", "The title in the metadata and the document information differ", // The document as a whole. + "The trailer has no file identifier (ID)", + "The file identifier (ID) in the trailer isn't two non-empty strings", "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", @@ -454,7 +457,14 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } } } - // 6.1.3: encryption. + // 6.1.3: the file identifier and encryption. + match trailer::check(doc) { + Some(trailer::IdProblem::Missing) => issue("6.1.3", "The trailer has no file identifier (ID)", None, None, true), + Some(trailer::IdProblem::Malformed) => { + issue("6.1.3", "The file identifier (ID) in the trailer isn't two non-empty strings", None, None, true) + } + None => {} + } if doc.trailer().get(b"Encrypt").is_some() { issue("6.1.3", "The document is encrypted", None, None, true); } @@ -798,6 +808,7 @@ pub fn convert(doc: &mut Document, level: Level) -> Result>", "<< /Type /Pages /Kids [] /Count 0 >>"]; + let id = |extra: &str| -> Vec { + let doc = build(&objs, extra); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.3").map(|i| i.message).collect() + }; + let missing = ["The trailer has no file identifier (ID)"]; + let malformed = ["The file identifier (ID) in the trailer isn't two non-empty strings"]; + assert!(id("/ID [<0011> (ab)]").is_empty()); + assert_eq!(id(""), missing); + assert_eq!(id("/ID null"), missing); + for wrong in ["/ID [<><>]", "/ID [<0011>]", "/ID [<0011> <0011> <0011>]", "/ID [1 2]", "/ID (ab)"] { + assert_eq!(id(wrong), malformed, "{wrong}"); + } + // An update without an /ID has none, whatever the trailer it replaces had. + let base = build(&objs, "/ID [<0011> <0011>]"); + let mut bytes = base.bytes().to_vec(); + let prev = bytes.windows(9).rposition(|w| w == b"startxref").unwrap(); + let prev: usize = String::from_utf8_lossy(&bytes[prev + 10..]).lines().next().unwrap().trim().parse().unwrap(); + let at = bytes.len(); + bytes.extend_from_slice( + format!("xref\n0 1\n0000000000 65535 f \ntrailer\n<< /Size 3 /Root 1 0 R /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(), + ); + let updated = Document::open(std::sync::Arc::new(bytes)).unwrap(); + assert!(verify(&updated, Level::A2b).iter().any(|i| i.message == missing[0])); + // Save as PDF/A adds one, which a save keeps. + for extra in ["", "/ID [<><>]"] { + let mut doc = build(&objs, extra); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Added a file identifier (ID)"), "{extra}"); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}"); + let saved = pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap(); + let saved = Document::open(std::sync::Arc::new(saved)).unwrap(); + assert!(verify(&saved, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}"); + } + // A good one is kept. + let mut doc = build(&objs, "/ID [<0011> <0022>]"); + assert!(!convert(&mut doc, Level::A2b).unwrap().fixed.iter().any(|f| f.contains("identifier"))); +} diff --git a/crates/preflight/src/trailer.rs b/crates/preflight/src/trailer.rs new file mode 100644 index 0000000000..8c75a54c75 --- /dev/null +++ b/crates/preflight/src/trailer.rs @@ -0,0 +1,36 @@ +//! PDF/A-2 6.1.3, the file trailer: it has an `/ID`, a file identifier as ISO 32000-1 14.4 +//! defines it, an array of two byte strings. +//! +//! The trailer read is the one the file's last `startxref` points to: the latest update's, or a +//! linearized file's first-page trailer (ISO 32000-1 F.3.4). Trailers earlier in the chain don't +//! stand in for it. + +use pdfcraft_cos::{Document, Object}; + +/// What is wrong with the file identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IdProblem { + /// The trailer has no `/ID`, or a null one. + Missing, + /// The `/ID` isn't an array of two non-empty strings. + Malformed, +} + +/// What 6.1.3 finds wrong with the file identifier of `doc`, if anything. +pub(crate) fn check(doc: &Document) -> Option { + let id = doc.trailer().get(b"ID").map(|id| doc.resolve(id)).filter(|id| !matches!(**id, Object::Null)); + let Some(id) = id else { return Some(IdProblem::Missing) }; + let strings = |parts: &[Object]| parts.iter().all(|p| doc.resolve(p).as_string().is_some_and(|s| !s.bytes.is_empty())); + match id.as_array() { + Some(parts) if parts.len() == 2 && strings(parts) => None, + _ => Some(IdProblem::Malformed), + } +} + +/// Gives `doc` a new file identifier when it lacks a good one; what it fixed, if anything. +pub(crate) fn fix(doc: &mut Document) -> Option { + check(doc)?; + let id = pdfcraft_cos::new_file_id(doc.bytes(), 0); + doc.trailer_mut().set(b"ID".to_vec(), id); + Some("Added a file identifier (ID)".into()) +}