//! Hostile input: every case must return `Err` or a document, never panic. use std::io::Write; use craft_docx::{DocxError, read, write}; use proptest::prelude::*; 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 = craft_wordmodel::Document::from_text("Hello world\nSecond paragraph with more text\nThird"); let t = craft_wordmodel::Table::new(2, 2, 200.0); d.body.insert(1, std::sync::Arc::new(craft_wordmodel::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(craft_wordmodel::StoryRef::Body), "ok"); } fn body_zip(body: &str) -> Vec { doc_zip(format!("{body}").as_bytes()) } #[test] fn a_malformed_main_part_is_an_error_not_a_truncated_document() { let cases = [ ("a less-than sign in a run", "before a <- b afterlast"), ("a paragraph left open", "firstsecondthird"), ("a mismatched end tag", "onetwo"), ]; for (what, body) in cases { assert!(matches!(read(&body_zip(body)), Err(DocxError::Xml(_))), "{what}"); } let whole = "a <- blast"; let d = read(&body_zip(whole)).unwrap(); assert_eq!(d.plain_text(craft_wordmodel::StoryRef::Body), "a <- b\nlast", "well-formed text still reads whole"); } #[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: &craft_wordmodel::Blocks, out: &mut String) { for b in bl { match &**b { craft_wordmodel::Block::Para(p) => out.push_str(&p.plain_text()), craft_wordmodel::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(craft_wordmodel::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(craft_wordmodel::StoryRef::Body).contains("later")); } /// A paragraph with 2,000,000 attributes deflates to ~15 KB. Opening it is refused with a /// limit error, or the extras are dropped and the text survives (#3702, WC-06). #[test] fn millions_of_attributes_on_one_paragraph_are_capped() { let attrs: String = (0..2_000_000).map(|i| format!(" a{i}=\"\"")).collect(); let x = format!("kept"); // Only the read is timed: deflating the test input in a debug build is not what is bounded. let z = doc_zip(x.as_bytes()); let start = std::time::Instant::now(); match read(&z) { Err(DocxError::Limit(_)) => {} Ok(d) => assert!(d.plain_text(craft_wordmodel::StoryRef::Body).contains("kept")), Err(e) => panic!("expected a Limit error or a document, got {e}"), } assert!(start.elapsed() < std::time::Duration::from_secs(10), "took {:?}", start.elapsed()); } /// 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); } } /// The streams of a Standard-encrypted package (gap 9), and a compound file holding them. fn standard_streams() -> (Vec, Vec) { let file = craft_docx::encrypt_standard_for_tests(&sample(), "pw", 128).unwrap(); let comp = craft_cfb::CompoundFile::open(&file).unwrap(); (comp.read_path("EncryptionInfo").unwrap(), comp.read_path("EncryptedPackage").unwrap()) } fn compound(info: &[u8], package: &[u8]) -> Vec { craft_cfb::write_streams(craft_cfb::Version::V3, &[("EncryptionInfo", info), ("EncryptedPackage", package)]).unwrap() } proptest! { // Each case that parses runs the 50,000-spin key derivation, so fewer cases. #![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })] #[test] fn mutated_standard_encryption_never_panics(ops in proptest::collection::vec((0u8..3, any::(), any::(), any::()), 1..8)) { let (mut info, mut package) = standard_streams(); for (k, idx, byte, in_package) in ops { let x = if in_package { &mut package } else { &mut info }; let at = idx.index(x.len() + 1); match k { 0 => { if at < x.len() { x[at] = byte; } } 1 => { x.truncate(at); } _ => { x.splice(at..at, std::iter::repeat_n(byte, usize::from(byte % 9))); } } } let file = compound(&info, &package); let _ = craft_docx::decrypt(&file, Some("pw")); let _ = craft_docx::read_with_password(&file, Some("pw")); prop_assert!(read(&file).is_err()); } } #[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()); } #[test] fn comments_with_non_numeric_ids_open_in_linear_time() { // Each generated id was checked against every id given so far, so a comments part with many // ids that aren't numbers took time quadratic in their count (#3692). const RELS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; const COMMENTS_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"; let n = 49_000; let mut comments = format!(""); for i in 0..n { comments.push_str(&format!(r#""#)); } comments.push_str(""); let doc = format!("body"); let rels = format!(r#""#); let z = zip(&[ ("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes()), ("word/_rels/document.xml.rels", rels.as_bytes()), ("word/comments.xml", comments.as_bytes()), ]); let d = read(&z).unwrap(); assert_eq!(d.comments.len(), n); let ids: std::collections::HashSet<_> = d.comments.keys().collect(); assert_eq!(ids.len(), n, "every comment has its own id"); }