//! Hostile input: every case must return `Err` or a document, never panic. use std::io::Write; use proptest::prelude::*; use wordcraft_docx::{DocxError, read, write}; const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; const ROOT_RELS: &str = r#""#; fn zip(entries: &[(&str, &[u8])]) -> Vec { let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); for (name, bytes) in entries { zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); zw.write_all(bytes).unwrap(); } zw.finish().unwrap().into_inner() } fn doc_zip(document_xml: &[u8]) -> Vec { zip(&[("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", document_xml)]) } fn sample() -> Vec { let mut d = wordcraft_doc::Document::from_text("Hello world\nSecond paragraph with more text\nThird"); let t = wordcraft_doc::Table::new(2, 2, 200.0); d.body.insert(1, std::sync::Arc::new(wordcraft_doc::Block::Table(t))); write(&d).unwrap() } #[test] fn random_bytes_and_empty_input() { assert!(matches!(read(&[]), Err(DocxError::Zip(_)))); assert!(read(b"PK\x03\x04garbage").is_err()); assert!(read(&[0u8; 4096]).is_err()); let noise: Vec = (0..10_000u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect(); assert!(read(&noise).is_err()); } #[test] fn truncated_zip_never_panics() { let good = sample(); assert!(read(&good).is_ok()); for cut in (0..good.len()).step_by(37) { let _ = read(&good[..cut]); } // Flipped bytes throughout. for i in (0..good.len()).step_by(53) { let mut b = good.clone(); b[i] ^= 0xA5; let _ = read(&b); } } #[test] fn missing_or_wrong_main_part() { let z = zip(&[("_rels/.rels", ROOT_RELS.as_bytes()), ("word/other.xml", b"")]); assert!(matches!(read(&z), Err(DocxError::MissingPart(_)))); let z = doc_zip(b""); assert!(matches!(read(&z), Err(DocxError::NotWord(_)))); let z = doc_zip(b"ok").as_bytes())]); let d = read(&z).unwrap(); assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "ok"); } #[test] fn deep_nesting_is_rejected() { let mut x = format!(""); for _ in 0..5_000 { x.push_str(""); } x.push_str(""); assert!(matches!(read(&doc_zip(x.as_bytes())), Err(DocxError::Limit(_)))); // Nested tables just under the XML depth limit build without blowing the stack. let mut y = format!(""); for _ in 0..60 { y.push_str(""); } y.push_str("deep"); for _ in 0..60 { y.push_str(""); } y.push_str(""); let d = read(&doc_zip(y.as_bytes())).unwrap(); fn all_text(bl: &wordcraft_doc::Blocks, out: &mut String) { for b in bl { match &**b { wordcraft_doc::Block::Para(p) => out.push_str(&p.plain_text()), wordcraft_doc::Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).for_each(|c| all_text(&c.blocks, out)), } } } let mut s = String::new(); all_text(&d.body, &mut s); assert!(s.contains("deep"), "{s}"); write(&d).unwrap(); } #[test] fn broken_secondary_parts_are_ignored() { let rels = r#""#; let doc = format!("body"); let z = zip(&[ ("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes()), ("word/_rels/document.xml.rels", rels.as_bytes()), ("word/styles.xml", b"\xff\xfe"), ("word/numbering.xml", b""), ("word/comments.xml", b"<<<"), ]); let d = read(&z).unwrap(); assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "body"); // The unresolvable list still writes. write(&d).unwrap(); } #[test] fn many_fields_and_huge_attribute_values() { let mut x = format!(""); for _ in 0..200 { x.push_str(r#""#); } x.push_str(&format!(r#"x"#, "F".repeat(100_000))); x.push_str("later"); let d = read(&doc_zip(x.as_bytes())).unwrap(); assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("later")); } /// A small valid document.xml to mutate. fn base_xml() -> String { format!( r#"HilinkPAGE1"# ) } const SNIPPETS: &[&str] = &[ "", "", "", "", "", "", r#""#, r#""#, r#"ADDIN ZOTERO_ITEM {}"#, r#""#, r#""#, r#""#, r#""#, "&", "�", "￿", "", "\u{FFFC}", r#""#, r#""#, r#""#, "", ]; #[test] fn malformed_or_hostile_bibliography_never_panics() { let doc = format!("body"); let cases: &[&[u8]] = &[ b"", b"", b"", b",,,,", b"Ed1st99133220250230doi:10.0/0", b"Hostile1---///---", b"\xff\xfe<\0b\0:\0S\0", ]; let root_rels_bib = r#""#; for case in cases { let z = zip(&[("_rels/.rels", root_rels_bib.as_bytes()), ("word/document.xml", doc.as_bytes()), ("customXml/item1.xml", case)]); let res = read(&z); if let Ok(d) = res { let _ = write(&d); } } } /// A file with a huge number of bibliography sources (duplicates and distinct ones) reads in /// linear time and keeps at most 10,000 of them. #[test] fn hostile_bibliography_source_count_is_capped() { let doc = format!("body"); let mut sources = String::from(r#""#); for i in 0..60_000 { sources.push_str(&format!("T{i}Title {i}")); // Untagged duplicates all collapse into one source. sources.push_str("Same"); } sources.push_str(""); let root_rels = r#""#; let z = zip(&[("_rels/.rels", root_rels.as_bytes()), ("word/document.xml", doc.as_bytes()), ("customXml/item1.xml", sources.as_bytes())]); let start = std::time::Instant::now(); let d = read(&z).unwrap(); assert_eq!(d.sources.len(), 10_000); assert_eq!(d.sources.iter().filter(|s| s.tag.is_empty()).count(), 1, "untagged duplicates are kept once"); assert_eq!(d.settings.bib_style.as_deref(), Some("MLA")); assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed()); } proptest! { #![proptest_config(ProptestConfig { cases: 300, ..ProptestConfig::default() })] #[test] fn mutated_document_xml_never_panics(ops in proptest::collection::vec((0u8..4, any::(), any::(), 0usize..SNIPPETS.len()), 0..12)) { let mut x = base_xml().into_bytes(); for (k, idx, byte, snip) in ops { let at = idx.index(x.len() + 1); match k { 0 => { if at < x.len() { x[at] = byte; } } 1 => { x.truncate(at); } 2 => { let s = SNIPPETS[snip].as_bytes(); let at = at.min(x.len()); x.splice(at..at, s.iter().copied()); } _ => { if at < x.len() { x.remove(at); } } } } if let Ok(d) = read(&doc_zip(&x)) { // Whatever we managed to read must write back and read again. let out = write(&d).unwrap(); read(&out).unwrap(); } } #[test] fn random_bytes_never_panic(bytes in proptest::collection::vec(any::(), 0..2048)) { let _ = read(&bytes); let mut z = b"PK\x03\x04".to_vec(); z.extend(&bytes); let _ = read(&z); } } #[test] fn long_header_reference_chain_is_cut_off() { const HDR_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"; const RELS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; let rels_for = |next: usize| { let rel = format!(r#""#); format!(r#"{rel}"#) }; let sect = r#""#; let doc = format!("body{sect}"); // header0 -> header1 -> ... -> header59: every header's last paragraph holds a section that points at the next one. let mut entries: Vec<(String, Vec)> = vec![ ("_rels/.rels".into(), ROOT_RELS.as_bytes().to_vec()), ("word/document.xml".into(), doc.into_bytes()), ("word/_rels/document.xml.rels".into(), rels_for(0).into_bytes()), ]; for i in 0..60 { let hdr = format!("{sect}h{i}"); entries.push((format!("word/header{i}.xml"), hdr.into_bytes())); entries.push((format!("word/_rels/header{i}.xml.rels"), rels_for(i + 1).into_bytes())); } let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect(); let d = read(&zip(&refs)).unwrap(); assert!(!d.parts.is_empty()); assert!(d.parts.len() <= 8, "followed {} chained headers", d.parts.len()); }