1use 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&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}