Skip to main content

document_svg/document/
xml.rs

1//! Bounded, inert previews for generic XML documents.
2
3use std::collections::{HashMap, hash_map::Entry};
4use std::path::Path;
5
6use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
7use crate::document::html::{HtmlBlock, render_blocks_to_pages};
8use crate::error::{Error, Result};
9use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
10
11const MAX_XML_BYTES: u64 = 16 * 1024 * 1024;
12const MAX_XML_LINES: usize = 100_000;
13const MAX_XML_LINE_BYTES: usize = 1024 * 1024;
14const MAX_XML_EVENTS: usize = 400_000;
15const MAX_XML_NODES: usize = 200_000;
16const MAX_XML_DEPTH: usize = 80;
17const MAX_XML_PATH_BYTES: usize = 4 * 1024;
18const MAX_XML_OUTPUT_BLOCKS: usize = 200_000;
19const MAX_XML_RENDERED_TEXT_BYTES: usize = 32 * 1024 * 1024;
20
21struct XmlPageSink<'a> {
22    inner: &'a mut dyn PageConsumer,
23    warnings: &'a [String],
24}
25
26impl PageConsumer for XmlPageSink<'_> {
27    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
28        page.source_format = "xml".into();
29        if page.title.is_empty() {
30            page.title = "XML document".into();
31        }
32        for warning in self.warnings {
33            page.warn(warning.clone());
34        }
35        self.inner.consume(page)
36    }
37}
38
39#[derive(Default)]
40struct RenderState {
41    blocks: Vec<HtmlBlock>,
42    text_bytes: usize,
43    truncated_paths: usize,
44}
45
46#[derive(Default)]
47struct NamespaceMap {
48    prefixes: std::collections::HashMap<String, String>,
49    entries: Vec<(String, String)>,
50}
51
52impl NamespaceMap {
53    fn collect(root: &XmlElement) -> Self {
54        let mut namespaces = Self::default();
55        namespaces.visit(root);
56        namespaces
57    }
58
59    fn visit(&mut self, element: &XmlElement) {
60        if let Some(namespace) = &element.namespace
61            && !self.prefixes.contains_key(namespace)
62        {
63            let prefix = format!("ns{}", self.entries.len() + 1);
64            self.prefixes.insert(namespace.clone(), prefix.clone());
65            self.entries.push((prefix, namespace.clone()));
66        }
67        for child in &element.children {
68            self.visit(child);
69        }
70    }
71
72    fn prefix(&self, namespace: &str) -> &str {
73        self.prefixes
74            .get(namespace)
75            .map(String::as_str)
76            .unwrap_or("ns?")
77    }
78}
79
80pub(crate) fn convert(
81    path: &Path,
82    options: &ConvertOptions,
83    sink: &mut dyn PageConsumer,
84) -> Result<Vec<String>> {
85    let bytes = read_limited_file(
86        path,
87        options.max_input_bytes.min(MAX_XML_BYTES),
88        "XML input",
89    )?;
90    check_line_limits(&bytes)?;
91    let root = parse_xml_tree(
92        &bytes,
93        &XmlLimits {
94            max_events: MAX_XML_EVENTS,
95            max_nodes: MAX_XML_NODES,
96            max_depth: MAX_XML_DEPTH,
97            max_text_bytes: MAX_XML_RENDERED_TEXT_BYTES,
98        },
99        "XML",
100    )?;
101    let (blocks, warnings) = render_xml_blocks(&root)?;
102    if options.max_pages == 0 {
103        return Err(Error::LimitExceeded(
104            "XML conversion requires at least one page; max_pages is zero".into(),
105        ));
106    }
107    let mut page_sink = XmlPageSink {
108        inner: sink,
109        warnings: &warnings,
110    };
111    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
112    Ok(warnings)
113}
114
115pub(crate) fn looks_like_xml_prefix(bytes: &[u8]) -> bool {
116    let text = String::from_utf8_lossy(bytes);
117    let text = text.trim_start_matches('\u{feff}').trim_start();
118    if text.starts_with("<?xml") || text.starts_with("<!--") || text.starts_with("<!") {
119        return true;
120    }
121    let Some(name) = text
122        .strip_prefix('<')
123        .and_then(|value| value.chars().next())
124    else {
125        return false;
126    };
127    name.is_ascii_alphabetic() || name == '_' || name == ':'
128}
129
130fn check_line_limits(bytes: &[u8]) -> Result<()> {
131    let mut lines = 0usize;
132    for line in bytes.split(|byte| *byte == b'\n') {
133        lines = lines.saturating_add(1);
134        if lines > MAX_XML_LINES {
135            return Err(Error::LimitExceeded(format!(
136                "XML input exceeds {MAX_XML_LINES} lines"
137            )));
138        }
139        if line.len() > MAX_XML_LINE_BYTES {
140            return Err(Error::LimitExceeded(format!(
141                "XML line exceeds {MAX_XML_LINE_BYTES} bytes"
142            )));
143        }
144    }
145    Ok(())
146}
147
148fn render_xml_blocks(root: &XmlElement) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
149    let mut state = RenderState::default();
150    let namespaces = NamespaceMap::collect(root);
151    state.blocks.push(HtmlBlock::Heading {
152        level: 1,
153        text: "XML document".into(),
154    });
155    for (prefix, namespace) in &namespaces.entries {
156        let encoded_namespace = serde_json::to_string(namespace)?;
157        add_line(
158            &mut state,
159            format!("namespace {prefix} = {encoded_namespace}"),
160        )?;
161    }
162    visit_element(root, "", None, None, &namespaces, 0, &mut state)?;
163    let warnings = if state.truncated_paths == 0 {
164        Vec::new()
165    } else {
166        vec![format!(
167            "{} XML element path(s) were truncated to {MAX_XML_PATH_BYTES} bytes for display",
168            state.truncated_paths
169        )]
170    };
171    Ok((state.blocks, warnings))
172}
173
174fn visit_element(
175    element: &XmlElement,
176    parent_path: &str,
177    parent_namespace: Option<&str>,
178    sibling_index: Option<usize>,
179    namespaces: &NamespaceMap,
180    depth: usize,
181    state: &mut RenderState,
182) -> Result<()> {
183    if depth > MAX_XML_DEPTH {
184        return Err(Error::LimitExceeded(format!(
185            "XML nesting exceeds {MAX_XML_DEPTH} levels"
186        )));
187    }
188    let component = path_component(element, parent_namespace, namespaces);
189    let mut path = if parent_path.is_empty() {
190        format!("/{component}")
191    } else {
192        format!("{parent_path}/{component}")
193    };
194    if let Some(index) = sibling_index {
195        path.push_str(&format!("[{index}]"));
196    }
197    if path.len() > MAX_XML_PATH_BYTES {
198        let mut end = MAX_XML_PATH_BYTES;
199        while !path.is_char_boundary(end) {
200            end -= 1;
201        }
202        path.truncate(end);
203        state.truncated_paths = state.truncated_paths.saturating_add(1);
204    }
205    add_line(state, format!("{path} (element)"))?;
206
207    let mut attributes = element.attributes.iter().collect::<Vec<_>>();
208    attributes.sort_by(|left, right| left.0.cmp(right.0));
209    for (name, value) in attributes {
210        if name == "xmlns" || name.starts_with("xmlns:") {
211            continue;
212        }
213        let encoded_value = serde_json::to_string(value)?;
214        add_line(state, format!("{path}/@{name} = {encoded_value}"))?;
215    }
216    if !element.text.trim().is_empty() {
217        let encoded_text = serde_json::to_string(&element.text)?;
218        add_line(state, format!("{path}/text = {encoded_text}"))?;
219    }
220    let mut sibling_counts = HashMap::<String, usize>::new();
221    for child in &element.children {
222        let child_component = path_component(child, element.namespace.as_deref(), namespaces);
223        let occurrence = match sibling_counts.entry(child_component) {
224            Entry::Occupied(mut entry) => {
225                let next = entry.get().saturating_add(1);
226                *entry.get_mut() = next;
227                next
228            }
229            Entry::Vacant(entry) => {
230                entry.insert(1);
231                1
232            }
233        };
234        visit_element(
235            child,
236            &path,
237            element.namespace.as_deref(),
238            Some(occurrence),
239            namespaces,
240            depth + 1,
241            state,
242        )?;
243    }
244    Ok(())
245}
246
247fn path_component(
248    element: &XmlElement,
249    parent_namespace: Option<&str>,
250    namespaces: &NamespaceMap,
251) -> String {
252    match &element.namespace {
253        Some(namespace) if Some(namespace.as_str()) != parent_namespace => {
254            format!("{}:{}", namespaces.prefix(namespace), element.name)
255        }
256        _ => element.name.clone(),
257    }
258}
259
260fn add_line(state: &mut RenderState, line: String) -> Result<()> {
261    if state.blocks.len() >= MAX_XML_OUTPUT_BLOCKS {
262        return Err(Error::LimitExceeded(format!(
263            "XML preview exceeds {MAX_XML_OUTPUT_BLOCKS} rendered rows"
264        )));
265    }
266    let new_size = state.text_bytes.saturating_add(line.len());
267    if new_size > MAX_XML_RENDERED_TEXT_BYTES {
268        return Err(Error::LimitExceeded(format!(
269            "XML preview text exceeds {MAX_XML_RENDERED_TEXT_BYTES} bytes"
270        )));
271    }
272    state.text_bytes = new_size;
273    state.blocks.push(HtmlBlock::Paragraph { text: line });
274    Ok(())
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn parse(source: &str) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
282        let root = parse_xml_tree(
283            source.as_bytes(),
284            &XmlLimits {
285                max_events: MAX_XML_EVENTS,
286                max_nodes: MAX_XML_NODES,
287                max_depth: MAX_XML_DEPTH,
288                max_text_bytes: MAX_XML_RENDERED_TEXT_BYTES,
289            },
290            "XML",
291        )?;
292        render_xml_blocks(&root)
293    }
294
295    #[test]
296    fn renders_nested_xml_text_attributes_and_namespaces_as_inert_rows() {
297        let (blocks, warnings) = parse(
298            r#"<configuration xmlns="urn:example:config" version="1">
299              <service id="catalog">A&amp;B<![CDATA[ <raw> ]]></service>
300              <service id="worker"><enabled>true</enabled></service>
301            </configuration>"#,
302        )
303        .unwrap();
304        let text = blocks
305            .iter()
306            .filter_map(|block| match block {
307                HtmlBlock::Heading { text, .. } | HtmlBlock::Paragraph { text } => {
308                    Some(text.as_str())
309                }
310                _ => None,
311            })
312            .collect::<Vec<_>>();
313        assert!(text.contains(&"namespace ns1 = \"urn:example:config\""));
314        assert!(text.contains(&"/ns1:configuration (element)"));
315        assert!(text.contains(&"/ns1:configuration/@version = \"1\""));
316        assert!(text.contains(&"/ns1:configuration/service[1]/@id = \"catalog\""));
317        assert!(text.iter().any(|line| line.contains("A&B <raw>")));
318        assert!(text.iter().any(|line| line.contains("worker")));
319        assert!(warnings.is_empty());
320    }
321
322    #[test]
323    fn rejects_doctypes_and_malformed_xml() {
324        assert!(
325            parse("<!DOCTYPE config SYSTEM \"https://example.invalid/config.dtd\"><config/>")
326                .is_err()
327        );
328        assert!(parse("<config><value></config>").is_err());
329    }
330
331    #[test]
332    fn enforces_xml_line_and_depth_limits_and_warns_on_long_paths() {
333        let long_line = format!("<config>{}</config>", "x".repeat(MAX_XML_LINE_BYTES));
334        assert!(matches!(
335            check_line_limits(long_line.as_bytes()),
336            Err(Error::LimitExceeded(_))
337        ));
338
339        let mut nested = "<n>".repeat(MAX_XML_DEPTH + 1);
340        nested.push_str(&"</n>".repeat(MAX_XML_DEPTH + 1));
341        assert!(matches!(parse(&nested), Err(Error::LimitExceeded(_))));
342
343        let long_name = "x".repeat(MAX_XML_PATH_BYTES + 32);
344        let (_, warnings) = parse(&format!("<{long_name}/>")).unwrap();
345        assert!(warnings.iter().any(|warning| warning.contains("truncated")));
346    }
347
348    #[test]
349    fn detects_xml_prefixes_without_claiming_plain_text() {
350        assert!(looks_like_xml_prefix(
351            b"\xef\xbb\xbf <?xml version=\"1.0\"?><config/>"
352        ));
353        assert!(looks_like_xml_prefix(b" <!-- note --> <config/>"));
354        assert!(looks_like_xml_prefix(b"<config/>"));
355        assert!(!looks_like_xml_prefix(b"not XML <config/>"));
356    }
357}