use super::{DataError, Dict, Document, Object}; // ── names ─────────────────────────────────────────────────────────────────────────────────── /// Form data files are attacker-supplied. Refuse a DTD and bound the node count, exactly as the /// XFA template parser does (`crates/xfa/src/parse.rs`), and bound how deep a nested field tree /// may go: the walk below recurses once per nesting level. const MAX_NODES: u32 = 2_000_000; pub(crate) const MAX_DEPTH: usize = 64; /// The deepest element nesting accepted before the text reaches the XML parser. /// /// roxmltree parses by recursive descent, so its stack use grows with the nesting depth: ten /// thousand levels overflow the main thread's stack and abort the process. A stack overflow is /// `abort()`, so `catch_unwind` cannot intercept it and the whole application dies - which is why /// the bound has to be applied before parsing, not while walking the result. Around three thousand /// levels still parse on the default stack, so this stays clear of the cliff while leaving far more /// room than the walks below, which cut off at 64. pub(crate) const MAX_XML_DEPTH: usize = 1_024; /// Element nesting depth of `text`, skipping comments, CDATA, processing instructions, the DOCTYPE /// and quoted attribute values. A tag that closes itself (``) is one level while it is read /// and does not stay open, so a long run of them cannot accumulate into the bound. It cannot /// under-count a real element; a stray `<` in text is invalid XML and only makes it over-count, /// which errs towards refusing the file. pub(crate) fn xml_nesting_depth(text: &str) -> usize { fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option { (from..hay.len()).find(|&p| hay[p..].starts_with(needle)) } let b = text.as_bytes(); let (mut i, mut depth, mut max) = (0usize, 0usize, 0usize); while i < b.len() { if b[i] != b'<' { i += 1; } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 2); } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); } else { depth += 1; max = max.max(depth); // Skip the rest of the start tag, over any quoted attribute value, and remember its last // significant byte: a tag that ends in `/` closes itself, so it is one level deep while it // is read rather than a level left open. i += 1; let mut quote = 0u8; let mut last = 0u8; while i < b.len() { let c = b[i]; if quote != 0 { if c == quote { quote = 0; } } else if c == b'"' || c == b'\'' { quote = c; } else if c == b'>' { i += 1; break; } else if !c.is_ascii_whitespace() { last = c; } i += 1; } if last == b'/' { depth -= 1; } } } max } pub(crate) fn check_form_xml_depth(text: &str) -> Result<(), DataError> { let depth = xml_nesting_depth(text); if depth > MAX_XML_DEPTH { return Err(DataError::Malformed(format!("the file nests XML elements {depth} deep, past the {MAX_XML_DEPTH} the reader accepts"))); } Ok(()) } pub(crate) fn parse_form_xml(text: &str) -> Result, roxmltree::Error> { roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }) } pub(crate) const SUBTYPES: [(&str, &str); 17] = [ ("Text", "text"), ("FreeText", "freetext"), ("Line", "line"), ("Square", "square"), ("Circle", "circle"), ("Polygon", "polygon"), ("PolyLine", "polyline"), ("Highlight", "highlight"), ("Underline", "underline"), ("Squiggly", "squiggly"), ("StrikeOut", "strikeout"), ("Stamp", "stamp"), ("Caret", "caret"), ("Ink", "ink"), ("FileAttachment", "fileattachment"), ("Sound", "sound"), ("Redact", "redact"), ]; pub(crate) const FLAGS: [(i64, &str); 9] = [ (1, "invisible"), (2, "hidden"), (4, "print"), (8, "nozoom"), (16, "norotate"), (32, "noview"), (64, "readonly"), (128, "locked"), (256, "togglenoview"), ]; pub(crate) fn esc(s: &str) -> String { let mut o = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => o.push_str("&"), '<' => o.push_str("<"), '>' => o.push_str(">"), '"' => o.push_str("""), // XML 1.0 §3.3.3 normalizes literal attribute whitespace, but not references. '\t' => o.push_str(" "), '\n' => o.push_str(" "), '\r' => o.push_str(" "), c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {} // XML 1.0 noncharacters are not allowed even as raw text. '\u{FFFE}' | '\u{FFFF}' => {} c => o.push(c), } } o } pub(crate) fn n(v: f64) -> String { let s = format!("{v:.4}"); let s = s.trim_end_matches('0').trim_end_matches('.'); if s == "-0" { "0".into() } else { s.into() } } pub(crate) fn hex(c: &[f64]) -> Option { let rgb = match c.len() { 1 => [c[0]; 3], 3 => [c[0], c[1], c[2]], 4 => [(1.0 - c[0]) * (1.0 - c[3]), (1.0 - c[1]) * (1.0 - c[3]), (1.0 - c[2]) * (1.0 - c[3])], _ => return None, }; Some(format!( "#{:02X}{:02X}{:02X}", (rgb[0].clamp(0.0, 1.0) * 255.0).round() as u8, (rgb[1].clamp(0.0, 1.0) * 255.0).round() as u8, (rgb[2].clamp(0.0, 1.0) * 255.0).round() as u8 )) } pub(crate) fn parse_hex(s: &str) -> Option<[f64; 3]> { let h = s.trim().trim_start_matches('#'); if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let p = |i: usize| h.get(i..i + 2).and_then(|s| u8::from_str_radix(s, 16).ok()).map(|v| f64::from(v) / 255.0); Some([p(0)?, p(2)?, p(4)?]) } pub(crate) fn nums_of(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } /// A `/Rect` of exactly four finite numbers; any other entry makes the whole rectangle unusable. pub(crate) fn rect_strict(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { let num = |x: &Object| doc.resolve(x).as_f64().filter(|v| v.is_finite()); match doc.resolve(o?).as_array()?.as_slice() { [x0, y0, x1, y1] => Some([num(x0)?, num(y0)?, num(x1)?, num(y1)?]), _ => None, } } /// Whether a quad's four corners enclose any area: some three of them are not collinear. pub(crate) fn quad_has_area(q: &[f64; 8]) -> bool { let [ax, ay, bx, by, cx, cy, dx, dy] = *q; let cross = |(px, py): (f64, f64), (qx, qy): (f64, f64), (rx, ry): (f64, f64)| (qx - px) * (ry - py) - (qy - py) * (rx - px); let (a, b, c, d) = ((ax, ay), (bx, by), (cx, cy), (dx, dy)); [cross(a, b, c), cross(a, b, d), cross(a, c, d), cross(b, c, d)].iter().any(|v| v.abs() > 0.0) } pub(crate) fn text_of(doc: &Document, d: &Dict, k: &[u8]) -> Option { d.get(k).and_then(|o| match &*doc.resolve(o) { Object::String(s) => Some(s.to_text()), Object::Name(n) => Some(pdfcraft_forms::name_text(n)), _ => None, }) } /// The numbers of a list; empty when any entry is non-finite (`nan`, `inf`), so the list is unusable as a whole. pub(crate) fn csv(list: &str) -> Vec { let v: Vec = list.split([',', ';', ' ']).filter(|t| !t.is_empty()).filter_map(|t| t.trim().parse().ok()).collect(); if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() } } pub(crate) fn arr(v: &[f64]) -> Object { Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) } /// XML 1.0 fifth-edition NameStartChar, excluding `:` for an unprefixed NCName. /// https://www.w3.org/TR/2008/REC-xml-20081126/#NT-NameStartChar fn xml_name_start(c: char) -> bool { matches!( c, '_' | 'A'..='Z' | 'a'..='z' | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' | '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | '\u{10000}'..='\u{EFFFF}' ) } fn xml_name_char(c: char) -> bool { xml_name_start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}') } /// Keep the existing letters/digits/`_`/`-`/`.` normalization, constrained to valid NCNames. pub(crate) fn xml_name(name: &str) -> String { let mut s: String = name.chars().map(|c| if (c.is_alphanumeric() && xml_name_char(c)) || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); if !s.chars().next().is_some_and(|c| (c.is_alphabetic() && xml_name_start(c)) || c == '_') { s.insert(0, '_'); } s } pub(crate) fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) -> Result<(), DataError> { if depth > MAX_DEPTH { return Err(DataError::Malformed(format!("XFDF fields nest more than {MAX_DEPTH} deep"))); } for f in node.children().filter(|c| c.has_tag_name("field")) { let Some(name) = f.attribute("name") else { continue }; let full = if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") }; let vals: Vec = f .children() .filter(|c| c.has_tag_name("value")) // Comments and processing instructions can split a value into several text nodes. .map(|v| v.children().filter(|n| n.is_text()).filter_map(|n| n.text()).collect()) .collect(); // A leaf with no `` is an empty field (the export writes one that way), so it // clears the field like FDF's `/V ()`; a field with kids only names their parent. A // leaf holding only `` is not read yet, so it leaves the field alone. let names_kids_or_rich = f.children().any(|c| c.has_tag_name("field") || c.has_tag_name("value-richtext")); if !vals.is_empty() || !names_kids_or_rich { out.push((full.clone(), vals)); } walk_xfdf(f, &full, out, depth + 1)?; } Ok(()) }