diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index a6018b71..92274b62 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -34,8 +34,14 @@ pub enum PptxError { } /// Does this look like an OOXML package (zip with `[Content_Types].xml`)? +/// +/// The part is looked up in the zip's central directory, not in the leading bytes: writers may +/// store it anywhere in the archive (recent PowerPoint builds put it last, megabytes in). pub fn sniff(bytes: &[u8]) -> bool { - bytes.starts_with(b"PK") && bytes.windows(19).take(4096).any(|w| w == b"[Content_Types].xml") + if !bytes.starts_with(b"PK") { + return false; + } + zip::ZipArchive::new(std::io::Cursor::new(bytes)).map(|z| z.index_for_name("[Content_Types].xml").is_some()).unwrap_or(false) } /// Read a `.pptx` / `.potx` / `.ppsx` package. diff --git a/crates/pptx/tests/sniff.rs b/crates/pptx/tests/sniff.rs new file mode 100644 index 00000000..69d9c961 --- /dev/null +++ b/crates/pptx/tests/sniff.rs @@ -0,0 +1,58 @@ +//! `sniff` must recognise a package wherever the writer put `[Content_Types].xml`: recent +//! PowerPoint builds store it as the last zip entry, well past the first few kilobytes. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_engine::Session; +use zip::write::SimpleFileOptions; + +fn sample_pptx() -> Vec { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).expect("sample"); + let p = s.doc().expect("doc").doc.as_ref().clone(); + deckcraft_pptx::export(&p).expect("export") +} + +/// Re-pack `bytes` with `[Content_Types].xml` moved to the end and a padding part in front, so +/// the content-types name first appears far beyond any fixed-size prefix. +fn content_types_last(bytes: &[u8]) -> Vec { + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + out.start_file("ppt/media/padding.bin", stored).expect("start"); + out.write_all(&vec![0u8; 64 * 1024]).expect("padding"); + let names: Vec = (0..src.len()).map(|i| src.by_index(i).expect("entry").name().to_string()).collect(); + for name in names.iter().filter(|n| *n != "[Content_Types].xml").chain(std::iter::once(&"[Content_Types].xml".to_string())) { + let mut data = Vec::new(); + src.by_name(name).expect("by_name").read_to_end(&mut data).expect("read"); + out.start_file(name, SimpleFileOptions::default()).expect("start"); + out.write_all(&data).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +#[test] +fn sniff_accepts_content_types_anywhere_in_the_archive() { + let bytes = sample_pptx(); + assert!(deckcraft_pptx::sniff(&bytes)); + + let moved = content_types_last(&bytes); + let first_4k = &moved[..4096.min(moved.len())]; + assert!(!first_4k.windows(19).any(|w| w == b"[Content_Types].xml"), "test setup: name must not be in the prefix"); + assert!(deckcraft_pptx::sniff(&moved)); + let p = deckcraft_pptx::import(&moved).expect("import"); + assert!(!p.slides.is_empty()); +} + +#[test] +fn sniff_rejects_non_packages() { + assert!(!deckcraft_pptx::sniff(b"")); + assert!(!deckcraft_pptx::sniff(b"PK")); + assert!(!deckcraft_pptx::sniff(b"%PDF-1.7 [Content_Types].xml")); + + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + out.start_file("hello.txt", SimpleFileOptions::default()).expect("start"); + out.write_all(b"not an office package").expect("write"); + let plain_zip = out.finish().expect("finish").into_inner(); + assert!(!deckcraft_pptx::sniff(&plain_zip)); +}