Skip to main content

document_svg/document/
junit.rs

1//! Bounded JUnit-compatible XML test report previews.
2//!
3//! JUnit XML is a de-facto interchange format used by Ant, Maven Surefire,
4//! JUnit Platform and CI servers. This adapter summarizes suites and testcase
5//! outcomes while omitting failure logs, stdout/stderr, properties and other
6//! potentially sensitive payloads. It never runs tests or resolves external XML.
7
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::html::{HtmlBlock, render_blocks_to_pages};
12use crate::error::{Error, Result};
13use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
14use crate::table::{TableAlign, TableData};
15
16const MAX_JUNIT_BYTES: u64 = 16 * 1024 * 1024;
17const MAX_JUNIT_EVENTS: usize = 500_000;
18const MAX_JUNIT_NODES: usize = 300_000;
19const MAX_JUNIT_DEPTH: usize = 80;
20const MAX_JUNIT_TEXT_BYTES: usize = 2 * 1024 * 1024;
21const MAX_JUNIT_SUITES: usize = 100_000;
22const MAX_JUNIT_STRING_BYTES: usize = 512 * 1024;
23
24pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
25    let mut reader = quick_xml::Reader::from_reader(std::io::Cursor::new(bytes));
26    reader.config_mut().trim_text(true);
27    let mut buffer = Vec::new();
28    loop {
29        match reader.read_event_into(&mut buffer) {
30            Ok(quick_xml::events::Event::Start(element))
31            | Ok(quick_xml::events::Event::Empty(element)) => {
32                let element_name = element.name();
33                let name = crate::ooxml::local_name(element_name.as_ref());
34                return name == b"testsuite" || name == b"testsuites";
35            }
36            Ok(quick_xml::events::Event::DocType(_)) => return false,
37            Ok(quick_xml::events::Event::Eof) | Err(_) => return false,
38            _ => buffer.clear(),
39        }
40        buffer.clear();
41    }
42}
43
44struct JunitPageSink<'a> {
45    inner: &'a mut dyn PageConsumer,
46    warnings: &'a [String],
47}
48
49impl PageConsumer for JunitPageSink<'_> {
50    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
51        page.source_format = "junit".into();
52        if page.title.is_empty() {
53            page.title = "JUnit test report".into();
54        }
55        page.description =
56            "JUnit-compatible test suites are rendered as inert result metadata; test execution and log payloads are not accessed".into();
57        for warning in self.warnings {
58            page.warn(warning.clone());
59        }
60        self.inner.consume(page)
61    }
62}
63
64pub(crate) fn convert(
65    path: &Path,
66    options: &ConvertOptions,
67    sink: &mut dyn PageConsumer,
68) -> Result<Vec<String>> {
69    let bytes = read_limited_file(
70        path,
71        options.max_input_bytes.min(MAX_JUNIT_BYTES),
72        "JUnit XML input",
73    )?;
74    let root = parse_report(&bytes)?;
75    let (table, metadata, warnings) = summarize(&root)?;
76    let blocks = vec![
77        HtmlBlock::Heading {
78            level: 1,
79            text: "JUnit test report".into(),
80        },
81        HtmlBlock::Paragraph { text: metadata },
82        HtmlBlock::Table(table),
83    ];
84    let mut page_sink = JunitPageSink {
85        inner: sink,
86        warnings: &warnings,
87    };
88    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
89    Ok(warnings)
90}
91
92fn parse_report(bytes: &[u8]) -> Result<XmlElement> {
93    if bytes.len() as u64 > MAX_JUNIT_BYTES {
94        return Err(Error::LimitExceeded(format!(
95            "JUnit XML exceeds {MAX_JUNIT_BYTES} bytes"
96        )));
97    }
98    let root = parse_xml_tree(
99        bytes,
100        &XmlLimits {
101            max_events: MAX_JUNIT_EVENTS,
102            max_nodes: MAX_JUNIT_NODES,
103            max_depth: MAX_JUNIT_DEPTH,
104            max_text_bytes: MAX_JUNIT_TEXT_BYTES,
105        },
106        "JUnit",
107    )?;
108    if root.name != "testsuite" && root.name != "testsuites" {
109        return Err(Error::InvalidInput(
110            "JUnit XML root must be testsuite or testsuites".into(),
111        ));
112    }
113    Ok(root)
114}
115
116fn summarize(root: &XmlElement) -> Result<(TableData, String, Vec<String>)> {
117    let mut suites = Vec::new();
118    collect_suites(root, &mut suites)?;
119    if suites.is_empty() {
120        return Err(Error::InvalidInput(
121            "JUnit XML contains no testsuite elements".into(),
122        ));
123    }
124    let mut rows = Vec::with_capacity(suites.len());
125    let mut totals = Counts::default();
126    let mut total_time = 0.0f64;
127    for suite in suites {
128        let counts = suite_counts(suite);
129        totals.add(counts);
130        total_time += parse_time(suite.attribute("time"));
131        let name = suite
132            .attribute("name")
133            .filter(|value| !value.is_empty())
134            .unwrap_or("(unnamed suite)");
135        let time = suite.attribute("time").map_or_else(|| "—".into(), truncate);
136        rows.push(vec![
137            truncate(name),
138            counts.tests.to_string(),
139            counts.passed.to_string(),
140            counts.failures.to_string(),
141            format!("{} / {}", counts.errors, counts.skipped),
142            time,
143        ]);
144    }
145    let mut warnings = vec![
146        "JUnit testcase failure/error details, stdout/stderr, properties, system output and attachment payloads are omitted; tests, actions and external resources are never executed or fetched".into(),
147        "JUnit XML is treated as a de-facto report format; producer-specific attributes and aggregate values are summarized conservatively".into(),
148    ];
149    if totals.failures > 0 || totals.errors > 0 {
150        warnings.push(format!(
151            "{} failing and {} error testcase(s) are summarized without their log payloads",
152            totals.failures, totals.errors
153        ));
154    }
155    let metadata = format!(
156        "Suites: {}\nTests: {}\nPassed: {}\nFailures: {}\nErrors: {}\nSkipped: {}\nTime: {:.3}s",
157        rows.len(),
158        totals.tests,
159        totals.passed,
160        totals.failures,
161        totals.errors,
162        totals.skipped,
163        total_time
164    );
165    Ok((
166        TableData {
167            headers: vec![
168                "Suite".into(),
169                "Tests".into(),
170                "Pass".into(),
171                "Fail".into(),
172                "Error / skip".into(),
173                "Time".into(),
174            ],
175            rows,
176            alignments: vec![TableAlign::Left; 6],
177            raw_source: String::new(),
178        },
179        metadata,
180        warnings,
181    ))
182}
183
184#[derive(Clone, Copy, Default)]
185struct Counts {
186    tests: usize,
187    passed: usize,
188    failures: usize,
189    errors: usize,
190    skipped: usize,
191}
192
193impl Counts {
194    fn add(&mut self, other: Self) {
195        self.tests = self.tests.saturating_add(other.tests);
196        self.passed = self.passed.saturating_add(other.passed);
197        self.failures = self.failures.saturating_add(other.failures);
198        self.errors = self.errors.saturating_add(other.errors);
199        self.skipped = self.skipped.saturating_add(other.skipped);
200    }
201}
202
203fn collect_suites<'a>(element: &'a XmlElement, suites: &mut Vec<&'a XmlElement>) -> Result<()> {
204    if element.name == "testsuite" {
205        if suites.len() >= MAX_JUNIT_SUITES {
206            return Err(Error::LimitExceeded(format!(
207                "JUnit suites exceed {MAX_JUNIT_SUITES}"
208            )));
209        }
210        suites.push(element);
211    }
212    for child in &element.children {
213        collect_suites(child, suites)?;
214    }
215    Ok(())
216}
217
218fn suite_counts(suite: &XmlElement) -> Counts {
219    let mut counts = Counts::default();
220    for testcase in suite.children_named("testcase") {
221        counts.tests = counts.tests.saturating_add(1);
222        if testcase
223            .children
224            .iter()
225            .any(|child| child.name == "failure")
226        {
227            counts.failures = counts.failures.saturating_add(1);
228        } else if testcase.children.iter().any(|child| child.name == "error") {
229            counts.errors = counts.errors.saturating_add(1);
230        } else if testcase
231            .children
232            .iter()
233            .any(|child| child.name == "skipped")
234        {
235            counts.skipped = counts.skipped.saturating_add(1);
236        } else {
237            counts.passed = counts.passed.saturating_add(1);
238        }
239    }
240    if counts.tests == 0 {
241        counts.tests = parse_usize(suite.attribute("tests"));
242        counts.failures = parse_usize(suite.attribute("failures"));
243        counts.errors = parse_usize(suite.attribute("errors"));
244        counts.skipped = parse_usize(suite.attribute("skipped"));
245        counts.passed = counts
246            .tests
247            .saturating_sub(counts.failures)
248            .saturating_sub(counts.errors)
249            .saturating_sub(counts.skipped);
250    }
251    counts
252}
253
254fn parse_usize(value: Option<&str>) -> usize {
255    value
256        .and_then(|value| value.parse::<usize>().ok())
257        .unwrap_or(0)
258}
259
260fn parse_time(value: Option<&str>) -> f64 {
261    value
262        .and_then(|value| value.parse::<f64>().ok())
263        .filter(|value| value.is_finite() && *value >= 0.0)
264        .unwrap_or(0.0)
265}
266
267fn truncate(value: &str) -> String {
268    if value.len() <= MAX_JUNIT_STRING_BYTES {
269        return value.to_owned();
270    }
271    let mut end = MAX_JUNIT_STRING_BYTES;
272    while !value.is_char_boundary(end) {
273        end -= 1;
274    }
275    format!("{}…", &value[..end])
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn recognizes_junit_roots() {
284        assert!(looks_like_prefix(b"<?xml version=\"1.0\"?><testsuites/>"));
285        assert!(looks_like_prefix(b"<testsuite name=\"unit\"/>"));
286        assert!(!looks_like_prefix(
287            b"<configuration><testsuite/></configuration>"
288        ));
289    }
290
291    #[test]
292    fn summarizes_outcomes_without_logs() {
293        let root = parse_report(
294            br#"<testsuites><testsuite name="unit" tests="3" failures="1" errors="0" skipped="1" time="1.25"><testcase classname="A" name="ok" time="0.1"/><testcase classname="A" name="bad"><failure message="very-secret"/></testcase><testcase classname="A" name="skip"><skipped/></testcase><system-out>secret output</system-out></testsuite></testsuites>"#,
295        )
296        .unwrap();
297        let (table, metadata, warnings) = summarize(&root).unwrap();
298        assert!(metadata.contains("Tests: 3"));
299        assert_eq!(table.rows[0][2], "1");
300        assert_eq!(table.rows[0][3], "1");
301        assert_eq!(table.rows[0][4], "0 / 1");
302        assert!(
303            !table
304                .rows
305                .iter()
306                .flatten()
307                .any(|value| value.contains("secret"))
308        );
309        assert!(warnings.iter().any(|warning| warning.contains("omitted")));
310    }
311
312    #[test]
313    fn rejects_external_doctype() {
314        assert!(
315            parse_report(
316                br#"<!DOCTYPE testsuite SYSTEM "file:///etc/passwd"><testsuite name="x"/>"#,
317            )
318            .is_err()
319        );
320    }
321}