Skip to main content

usb_forensic/
docx.rs

1//! Native DOCX export — the court-ready report as an Office Open XML `.docx`, with **no
2//! dependency**: a `.docx` is a ZIP of a few XML parts, so this writes a minimal
3//! stored (uncompressed) ZIP by hand. The report text comes from
4//! [`render_report`]; each line becomes a Word paragraph.
5//!
6//! The output is validated against an independent oracle (python-docx opens it and reads
7//! the paragraphs back) — see `docs/validation.md`.
8
9use crate::render_report;
10use crate::DeviceHistory;
11use forensicnomicon::report::Finding;
12
13/// CRC-32 (ISO-HDLC / ZIP polynomial `0xEDB88320`), the checksum each ZIP entry needs.
14fn crc32(data: &[u8]) -> u32 {
15    let mut crc: u32 = 0xFFFF_FFFF;
16    for &byte in data {
17        crc ^= u32::from(byte);
18        for _ in 0..8 {
19            let mask = (crc & 1).wrapping_neg(); // 0xFFFF_FFFF when the low bit is set
20            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
21        }
22    }
23    !crc
24}
25
26/// Escape the five XML predefined entities so arbitrary evidence text is safe in XML.
27fn xml_escape(text: &str) -> String {
28    text.replace('&', "&")
29        .replace('<', "&lt;")
30        .replace('>', "&gt;")
31        .replace('"', "&quot;")
32        .replace('\'', "&apos;")
33}
34
35/// Package parts into a minimal **stored** (method 0) ZIP — enough for a valid `.docx`.
36fn zip_stored(parts: &[(&str, Vec<u8>)]) -> Vec<u8> {
37    let mut out = Vec::new();
38    let mut central = Vec::new();
39    for (name, data) in parts {
40        let crc = crc32(data);
41        let size = data.len() as u32;
42        let offset = out.len() as u32;
43        // Local file header (PK\x03\x04).
44        out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
45        out.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // ver, flags, method 0, time, date
46        out.extend_from_slice(&crc.to_le_bytes());
47        out.extend_from_slice(&size.to_le_bytes()); // compressed == uncompressed
48        out.extend_from_slice(&size.to_le_bytes());
49        out.extend_from_slice(&(name.len() as u16).to_le_bytes());
50        out.extend_from_slice(&0u16.to_le_bytes()); // extra len
51        out.extend_from_slice(name.as_bytes());
52        out.extend_from_slice(data);
53        // Central-directory record (PK\x01\x02).
54        central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
55        central.extend_from_slice(&[20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // made-by, needed, flags, method, time, date
56        central.extend_from_slice(&crc.to_le_bytes());
57        central.extend_from_slice(&size.to_le_bytes());
58        central.extend_from_slice(&size.to_le_bytes());
59        central.extend_from_slice(&(name.len() as u16).to_le_bytes());
60        central.extend_from_slice(&[0u8; 8]); // extra, comment, disk#, internal attrs
61        central.extend_from_slice(&0u32.to_le_bytes()); // external attrs
62        central.extend_from_slice(&offset.to_le_bytes());
63        central.extend_from_slice(name.as_bytes());
64    }
65    let cd_offset = out.len() as u32;
66    let cd_len = central.len() as u32;
67    let count = parts.len() as u16;
68    out.extend_from_slice(&central);
69    // End of central directory (PK\x05\x06).
70    out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
71    out.extend_from_slice(&[0, 0, 0, 0]); // this disk, cd-start disk
72    out.extend_from_slice(&count.to_le_bytes());
73    out.extend_from_slice(&count.to_le_bytes());
74    out.extend_from_slice(&cd_len.to_le_bytes());
75    out.extend_from_slice(&cd_offset.to_le_bytes());
76    out.extend_from_slice(&0u16.to_le_bytes()); // comment len
77    out
78}
79
80const CONTENT_TYPES: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
81<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
82
83const RELS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
84<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>"#;
85
86/// Render the forensic report as a `.docx` byte stream.
87#[must_use]
88pub fn render_docx(histories: &[DeviceHistory], findings: &[Finding]) -> Vec<u8> {
89    let mut body = String::from(
90        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
91<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>"#,
92    );
93    for line in render_report(histories, findings).lines() {
94        body.push_str("<w:p><w:r><w:t xml:space=\"preserve\">");
95        body.push_str(&xml_escape(line));
96        body.push_str("</w:t></w:r></w:p>");
97    }
98    body.push_str("</w:body></w:document>");
99
100    zip_stored(&[
101        ("[Content_Types].xml", CONTENT_TYPES.as_bytes().to_vec()),
102        ("_rels/.rels", RELS.as_bytes().to_vec()),
103        ("word/document.xml", body.into_bytes()),
104    ])
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::model::{Attribute, DeviceKey, Provenance, SourceKind, Value};
111    use crate::{audit, correlate, Claim};
112
113    #[test]
114    fn crc32_matches_the_standard_test_vector() {
115        assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
116    }
117
118    #[test]
119    fn xml_escape_covers_all_five_entities() {
120        assert_eq!(
121            xml_escape("a&b<c>d\"e'f"),
122            "a&amp;b&lt;c&gt;d&quot;e&apos;f"
123        );
124    }
125
126    #[test]
127    fn docx_is_a_valid_zip_carrying_the_document_part() {
128        let claims = [Claim {
129            device: DeviceKey("SN1".into()),
130            attribute: Attribute::VolumeName,
131            value: Value::Text("A&B<C>".into()), // forces xml escaping in the body
132            provenance: Provenance {
133                source: SourceKind::MountedDevices,
134                locator: "m".into(),
135            },
136        }];
137        let histories = correlate(&claims);
138        let docx = render_docx(&histories, &audit(&histories));
139        assert_eq!(&docx[..4], b"PK\x03\x04", "starts with a ZIP local header");
140        let as_text = String::from_utf8_lossy(&docx);
141        assert!(
142            as_text.contains("word/document.xml"),
143            "carries the document part"
144        );
145        assert!(as_text.contains("[Content_Types].xml"));
146        assert!(
147            as_text.contains("A&amp;B&lt;C&gt;"),
148            "evidence text is XML-escaped"
149        );
150        assert_eq!(
151            &docx[docx.len() - 22..docx.len() - 18],
152            b"PK\x05\x06",
153            "EOCD present"
154        );
155    }
156}