mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 21:17:55 -06:00
267 lines
11 KiB
Rust
267 lines
11 KiB
Rust
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 (`<field/>`) 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<usize> {
|
|
(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"<!--") {
|
|
i = find(b, i + 4, b"-->").map_or(b.len(), |p| p + 3);
|
|
} else if b[i..].starts_with(b"<![CDATA[") {
|
|
i = find(b, i + 9, b"]]>").map_or(b.len(), |p| p + 3);
|
|
} else if b[i..].starts_with(b"<?") {
|
|
i = find(b, i + 2, b"?>").map_or(b.len(), |p| p + 2);
|
|
} else if b[i..].starts_with(b"<!") {
|
|
i = find(b, i + 2, b">").map_or(b.len(), |p| p + 1);
|
|
} else if b[i..].starts_with(b"</") {
|
|
depth = depth.saturating_sub(1);
|
|
i = find(b, i + 2, 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::Document<'_>, 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<String> {
|
|
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<f64> {
|
|
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<String> {
|
|
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<f64> {
|
|
let v: Vec<f64> = 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<String>)>, 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<String> = 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 `<value>` 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 `<value-richtext>` 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(())
|
|
}
|