mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-01 06:16:58 -06:00
* M8.1: Bibliography sources DOCX round-trip Store bibliography sources in package-root custom XML (customXml/item1.xml) with datastore item properties (customXml/itemProps1.xml), relationships, root customXml relationship (_rels/.rels), and content type overrides per ECMA-376 §15.2. Map internal source types to Word b:SourceType values (Book, BookSection, JournalArticle, ArticleInAPeriodical, InternetSite, DocumentFromInternetSite, ElectronicSource, Report, ConferenceProceedings, Case, Art, Film, Interview, Patent, Performance, SoundRecording, Standard, Thesis, Misc) and parse them back. Write SelectedStyle and StyleName attributes on b:Sources. Use safe slice indexing for author names parsing. Scan customXml relationships and customXml/item*.xml on read, extracting sources from b:Sources in the bibliography namespace. Add roundtrip integration tests in crates/docx/tests/roundtrip.rs verifying all parts, relationships, and attributes, update second-generation kitchen sink stability test, add hostile-input tests in crates/docx/tests/malformed.rs, and verify integration with citation styles in engine citations test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes for #532: bounded source reading, real citation style - Read at most 10,000 bibliography sources and dedupe them with hash sets, so a hostile file with ~100k b:Source elements can't make opening quadratic; each custom XML part is read at most once (64 parts at most). Test: hostile_bibliography_source_count_is_capped. - Drop the rt::BIBLIOGRAPHY relationship branch (not an OPC relationship type) and the stray comment in write_as. - Write the document's citation style (References > Style) in b:Sources/@StyleName and @SelectedStyle instead of always APA: Settings::bib_style records it, the style command sets it, opening a file reads it back into the session. - The b: namespace goes with the other non-body namespaces (it isn't declared on document.xml), matching #612. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the custom XML properties relationship apart from customXml So #612, which adds the same rt::CUSTOM_XML line, merges with this branch without a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * clippy: pass parse_persons by value (needless_borrows_for_generic_args) * rustfmt after merging main --------- Co-authored-by: yefan911 <yefan911@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
241 lines
12 KiB
Rust
241 lines
12 KiB
Rust
//! 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#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
|
|
|
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
|
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<u8> {
|
|
zip(&[("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", document_xml)])
|
|
}
|
|
|
|
fn sample() -> Vec<u8> {
|
|
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<u8> = (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"<x/>")]);
|
|
assert!(matches!(read(&z), Err(DocxError::MissingPart(_))));
|
|
let z = doc_zip(b"<notword/>");
|
|
assert!(matches!(read(&z), Err(DocxError::NotWord(_))));
|
|
let z = doc_zip(b"<w:document xmlns:w=\"x\"><unclosed");
|
|
let _ = read(&z);
|
|
let z = doc_zip(b"\xff\xfe<\0w\0");
|
|
assert!(read(&z).is_err());
|
|
// No root rels at all: falls back to word/document.xml.
|
|
let z = zip(&[("word/document.xml", format!("<w:document {W_NS}><w:body><w:p><w:r><w:t>ok</w:t></w:r></w:p></w:body></w:document>").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!("<w:document {W_NS}><w:body>");
|
|
for _ in 0..5_000 {
|
|
x.push_str("<w:sdt><w:sdtContent>");
|
|
}
|
|
x.push_str("</w:body></w:document>");
|
|
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!("<w:document {W_NS}><w:body>");
|
|
for _ in 0..60 {
|
|
y.push_str("<w:tbl><w:tr><w:tc>");
|
|
}
|
|
y.push_str("<w:p><w:r><w:t>deep</w:t></w:r></w:p>");
|
|
for _ in 0..60 {
|
|
y.push_str("</w:tc></w:tr></w:tbl>");
|
|
}
|
|
y.push_str("</w:body></w:document>");
|
|
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#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="a" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="b" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/><Relationship Id="c" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/></Relationships>"#;
|
|
let doc = format!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
|
|
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"<w:numbering><w:num w:numId=\"1\"><w:abstractNumId w:val=\"999\"/></w:num></w:numbering>"),
|
|
("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!("<w:document {W_NS}><w:body><w:p>");
|
|
for _ in 0..200 {
|
|
x.push_str(r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r>"#);
|
|
}
|
|
x.push_str(&format!(r#"<w:r><w:rPr><w:rFonts w:ascii="{}"/></w:rPr><w:t>x</w:t></w:r>"#, "F".repeat(100_000)));
|
|
x.push_str("</w:p><w:p><w:r><w:t>later</w:t></w:r></w:p></w:body></w:document>");
|
|
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#"<w:document {W_NS}><w:body><w:p><w:pPr><w:pStyle w:val="Heading1"/><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:rPr><w:b/><w:sz w:val="24"/></w:rPr><w:t>Hi</w:t></w:r><w:hyperlink w:anchor="x"><w:r><w:t>link</w:t></w:r></w:hyperlink><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>PAGE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>1</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r><w:bookmarkStart w:id="0" w:name="x"/><w:bookmarkEnd w:id="0"/></w:p><w:tbl><w:tblPr><w:tblLook w:val="04A0"/></w:tblPr><w:tblGrid><w:gridCol w:w="100"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:gridSpan w:val="2"/><w:vMerge/></w:tcPr><w:p/></w:tc></w:tr></w:tbl><w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:cols w:num="2"/></w:sectPr></w:body></w:document>"#
|
|
)
|
|
}
|
|
|
|
const SNIPPETS: &[&str] = &[
|
|
"<w:p>",
|
|
"</w:p>",
|
|
"<w:tbl>",
|
|
"<w:tr>",
|
|
"<w:tc>",
|
|
"</w:tc>",
|
|
r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
|
|
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r>"#,
|
|
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>ADDIN ZOTERO_ITEM {}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
|
|
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
|
|
r#"<w:br w:type="page"/>"#,
|
|
r#"<w:sz w:val="-99999999999"/>"#,
|
|
r#"<w:gridSpan w:val="0"/>"#,
|
|
"&",
|
|
"�",
|
|
"",
|
|
"<![CDATA[x]]>",
|
|
"\u{FFFC}",
|
|
r#"<w:footnoteReference w:id="1"/>"#,
|
|
r#"<w:drawing><wp:inline/></w:drawing>"#,
|
|
r#"<mc:AlternateContent><mc:Choice/></mc:AlternateContent>"#,
|
|
"<w:sectPr/>",
|
|
];
|
|
|
|
#[test]
|
|
fn malformed_or_hostile_bibliography_never_panics() {
|
|
let doc = format!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
|
|
let cases: &[&[u8]] = &[
|
|
b"<not_sources/>",
|
|
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><unclosed",
|
|
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source/></b:Sources>",
|
|
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Author><b:NameList><b:Person/></b:NameList></b:Author></b:Source></b:Sources>",
|
|
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Tag></b:Tag><b:SourceType></b:SourceType><b:Author><b:NameList><b:Person><b:Last>,,,,</b:Last><b:First></b:First></b:Person></b:NameList></b:Author></b:Source></b:Sources>",
|
|
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Author><b:Editor><b:NameList><b:Person><b:Last>Ed</b:Last></b:Person></b:NameList></b:Editor></b:Author><b:Edition>1st</b:Edition><b:Issue>99</b:Issue><b:Month>13</b:Month><b:Day>32</b:Day><b:YearAccessed>2025</b:YearAccessed><b:MonthAccessed>02</b:MonthAccessed><b:DayAccessed>30</b:DayAccessed><b:StandardNumber>doi:10.0/0</b:StandardNumber></b:Source></b:Sources>",
|
|
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Tag>Hostile1</b:Tag><b:Editor><b:Person/></b:Editor><b:Accessed>---///---</b:Accessed></b:Source></b:Sources>",
|
|
b"\xff\xfe<\0b\0:\0S\0",
|
|
];
|
|
let root_rels_bib = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="customXml/item1.xml"/></Relationships>"#;
|
|
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!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
|
|
let mut sources = String::from(r#"<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography" StyleName="MLA">"#);
|
|
for i in 0..60_000 {
|
|
sources.push_str(&format!("<b:Source><b:Tag>T{i}</b:Tag><b:Title>Title {i}</b:Title></b:Source>"));
|
|
// Untagged duplicates all collapse into one source.
|
|
sources.push_str("<b:Source><b:Title>Same</b:Title></b:Source>");
|
|
}
|
|
sources.push_str("</b:Sources>");
|
|
let root_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="customXml/item1.xml"/></Relationships>"#;
|
|
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::<prop::sample::Index>(), any::<u8>(), 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::<u8>(), 0..2048)) {
|
|
let _ = read(&bytes);
|
|
let mut z = b"PK\x03\x04".to_vec();
|
|
z.extend(&bytes);
|
|
let _ = read(&z);
|
|
}
|
|
}
|