Skip to main content

usb_forensic/
pdf.rs

1//! Native PDF export — the court report as a multi-page PDF with **no dependency**.
2//!
3//! A minimal PDF is a set of numbered objects, a byte-offset cross-reference table, and
4//! a trailer. This writes the report ([`render_report`]) as
5//! monospaced (Courier) text, paginated, with a correct `xref` — no font embedding, no
6//! compression. The report text comes from [`render_report`]; validated against an
7//! independent oracle (`pypdf` reads the pages back).
8
9use crate::render_report;
10use crate::DeviceHistory;
11use forensicnomicon::report::Finding;
12use std::fmt::Write as _;
13
14const LINES_PER_PAGE: usize = 60;
15const FONT_SIZE: i32 = 9;
16const LEADING: i32 = 12;
17const TOP: i32 = 760;
18const LEFT: i32 = 40;
19
20/// Fold to the Courier (Latin-1) glyphs the base font can render, then escape the three
21/// characters special inside a PDF literal string. Common Unicode punctuation maps to its
22/// ASCII form; any other non-ASCII becomes `?` so the byte stream stays well-formed.
23fn pdf_escape(text: &str) -> String {
24    let folded: String = text
25        .chars()
26        .map(|c| match c {
27            '\u{2014}' | '\u{2013}' => '-',  // em / en dash
28            '\u{201C}' | '\u{201D}' => '"',  // curly double quotes
29            '\u{2018}' | '\u{2019}' => '\'', // curly single quotes
30            c if c.is_ascii() => c,
31            _ => '?',
32        })
33        .collect();
34    folded
35        .replace('\\', r"\\")
36        .replace('(', r"\(")
37        .replace(')', r"\)")
38}
39
40/// Render the forensic report as a multi-page PDF byte stream.
41#[must_use]
42pub fn render_pdf(histories: &[DeviceHistory], findings: &[Finding]) -> Vec<u8> {
43    let report = render_report(histories, findings);
44    // render_report always emits the title + summary + methodology, so there is at least
45    // one line and thus at least one page.
46    let lines: Vec<&str> = report.lines().collect();
47    let pages: Vec<&[&str]> = lines.chunks(LINES_PER_PAGE).collect();
48    let n = pages.len();
49
50    // Object numbering: 1 Catalog, 2 Pages, 3 Font, 4..4+n content streams, then n Page objects.
51    let content_base = 4;
52    let page_base = content_base + n;
53    let mut bodies: Vec<String> = Vec::with_capacity(3 + 2 * n);
54
55    bodies.push("<< /Type /Catalog /Pages 2 0 R >>".to_string());
56    let mut kids = String::new();
57    for i in 0..n {
58        let _ = write!(kids, "{} 0 R ", page_base + i);
59    }
60    bodies.push(format!("<< /Type /Pages /Kids [ {kids}] /Count {n} >>"));
61    bodies.push("<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".to_string());
62
63    for page in &pages {
64        let mut stream = String::new();
65        let _ = write!(stream, "BT /F0 {FONT_SIZE} Tf {LEADING} TL {LEFT} {TOP} Td");
66        for (row, line) in page.iter().enumerate() {
67            // First line is positioned by Td; each subsequent line advances with T*.
68            if row == 0 {
69                let _ = write!(stream, " ({}) Tj", pdf_escape(line));
70            } else {
71                let _ = write!(stream, " T* ({}) Tj", pdf_escape(line));
72            }
73        }
74        stream.push_str(" ET");
75        bodies.push(format!(
76            "<< /Length {} >>\nstream\n{stream}\nendstream",
77            stream.len()
78        ));
79    }
80    for i in 0..n {
81        bodies.push(format!(
82            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
83             /Resources << /Font << /F0 3 0 R >> >> /Contents {} 0 R >>",
84            content_base + i
85        ));
86    }
87
88    // Assemble, tracking each object's byte offset for the xref table.
89    let mut out = String::from("%PDF-1.4\n");
90    let mut offsets = Vec::with_capacity(bodies.len());
91    for (idx, body) in bodies.iter().enumerate() {
92        offsets.push(out.len());
93        let _ = write!(out, "{} 0 obj\n{body}\nendobj\n", idx + 1);
94    }
95    let xref_at = out.len();
96    let count = bodies.len() + 1;
97    let _ = write!(out, "xref\n0 {count}\n0000000000 65535 f \n");
98    for off in &offsets {
99        let _ = writeln!(out, "{off:010} 00000 n ");
100    }
101    let _ = write!(
102        out,
103        "trailer\n<< /Size {count} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n"
104    );
105    out.into_bytes()
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::model::{Attribute, DeviceKey, Provenance, SourceKind, Value};
112    use crate::{audit, correlate, Claim};
113
114    #[test]
115    fn pdf_escape_folds_unicode_and_escapes_specials() {
116        assert_eq!(pdf_escape(r"a(b)c\d"), r"a\(b\)c\\d");
117        // em/en dash → '-', curly quotes → straight, other non-ASCII → '?'.
118        assert_eq!(
119            pdf_escape("x\u{2014}y\u{2013}\u{201C}q\u{201D}\u{2018}r\u{2019}\u{1F600}z"),
120            "x-y-\"q\"'r'?z"
121        );
122    }
123
124    #[test]
125    fn pdf_is_well_formed_and_paginated() {
126        // Enough devices to force more than one page.
127        let claims: Vec<Claim> = (0..40)
128            .map(|i| Claim {
129                device: DeviceKey(format!("SN{i}")),
130                attribute: Attribute::FirstConnected,
131                value: Value::Timestamp(1_700_000_000 + i),
132                provenance: Provenance {
133                    source: SourceKind::SetupApi,
134                    locator: "log(1)".into(), // exercises pdf_escape on '(' / ')'
135                },
136            })
137            .collect();
138        let histories = correlate(&claims);
139        let pdf = render_pdf(&histories, &audit(&histories));
140        let text = String::from_utf8_lossy(&pdf);
141        assert!(text.starts_with("%PDF-1.4"));
142        assert!(text.trim_end().ends_with("%%EOF"));
143        assert!(text.contains("/Type /Catalog"));
144        assert!(text.contains("startxref"));
145        assert!(
146            text.matches("/Type /Page ").count() >= 2,
147            "paginated across ≥2 pages"
148        );
149        assert!(text.contains(r"log\(1\)"), "parens escaped in the stream");
150    }
151
152    #[test]
153    fn empty_input_still_produces_one_valid_page() {
154        let pdf = render_pdf(&[], &[]);
155        let text = String::from_utf8_lossy(&pdf);
156        assert!(text.starts_with("%PDF-1.4"));
157        assert_eq!(text.matches("/Type /Page ").count(), 1);
158    }
159}