diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index a4445b11d..7793c3a7b 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -18,6 +18,8 @@ mod tests; mod ticket; #[cfg(windows)] mod windows; +#[cfg(any(test, windows))] +mod xml; /// Native Windows driver settings, printable geometry and explicit RGB/GDI transport. /// Callers perform ICC conversion and compose page pixels/vector strokes. Other platforms diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 65a15ec01..849ec8472 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -199,13 +199,13 @@ pub(crate) fn document_duplex_only(xml: &str, duplex: Option) -> bool { Some(Duplex::LongEdge) => "TwoSidedLongEdge", Some(Duplex::ShortEdge) => "TwoSidedShortEdge", }; - let Ok(document) = roxmltree::Document::parse(xml) else { return false }; + let Ok(document) = crate::xml::parse(xml) else { return false }; !duplex_option(&document, "JobDuplexAllDocumentsContiguously", wanted) && duplex_option(&document, "DocumentDuplex", wanted) } /// Whether either duplex scope offers a supported standard binding; unreadable stays unknown. pub(crate) fn two_sided(xml: &str) -> Option { - let document = roxmltree::Document::parse(xml).ok()?; + let document = crate::xml::parse(xml).ok()?; Some( ["JobDuplexAllDocumentsContiguously", "DocumentDuplex"] .into_iter() @@ -227,7 +227,7 @@ fn integer(node: roxmltree::Node<'_, '_>, tag: &str, name: &str) -> Option /// width and height in micrometres as the driver lists it. Only those schema fields are read: /// some drivers return empty vendor parameters that a stricter parser would refuse. pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: bool) -> Result<(u32, u32), Error> { - let document = roxmltree::Document::parse(xml).map_err(spool_error)?; + let document = crate::xml::parse(xml).map_err(spool_error)?; let feature = |name: &str| document.root_element().children().find(|n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, name)); let media = feature("PageMediaSize").ok_or_else(|| spool_error("the printer's driver did not confirm the paper size; nothing was sent"))?; let size = integer(media, "ScoredProperty", "MediaSizeWidth") @@ -271,7 +271,7 @@ pub(crate) struct Offered { /// What these PrintCapabilities offer ([`Offered`]); all unknown when the document is not /// PrintCapabilities. pub(crate) fn offered(capabilities_xml: &str) -> Offered { - let Ok(document) = roxmltree::Document::parse(capabilities_xml) else { return Offered::default() }; + let Ok(document) = crate::xml::parse(capabilities_xml) else { return Offered::default() }; let root = document.root_element(); if !root.has_tag_name((NS_PSF, "PrintCapabilities")) { return Offered::default(); @@ -308,7 +308,7 @@ pub(crate) fn repeats_collated(job: &Job, pages: usize, offered: Offered) -> boo /// sheet, binding (booklets and offset stacking included) or stapling, which would treat every /// copy as one document. pub(crate) fn one_job_sides(xml: &str) -> Option { - let document = roxmltree::Document::parse(xml).ok()?; + let document = crate::xml::parse(xml).ok()?; let features = |names: &'static [&'static str]| { document.root_element().children().filter(move |n| n.has_tag_name((NS_PSF, "Feature")) && names.iter().any(|name| named(*n, name))) }; @@ -351,7 +351,7 @@ pub(crate) fn one_job_sides(xml: &str) -> Option { /// if both appear, both must match. The exception is an explicit one-sided request when the /// driver is known to have no duplex feature. Malformed XML and unretained options are errors. pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, offered: Offered) -> Result<(), Error> { - let document = roxmltree::Document::parse(xml).map_err(spool_error)?; + let document = crate::xml::parse(xml).map_err(spool_error)?; let root = document.root_element(); let option = |name: &str| { root.children() @@ -488,8 +488,8 @@ fn declared_prefixes(xml: &str, start: usize) -> Vec<(usize, String)> { /// A ticket is left as it is when it redeclares one of the prefixes below its root, or has an /// element or attribute named in a namespace whose prefix would change. pub(crate) fn respell(ticket: &str, driver: &str) -> Option { - let document = roxmltree::Document::parse(ticket).ok()?; - let own = vendor_prefixes(&roxmltree::Document::parse(driver).ok()?); + let document = crate::xml::parse(ticket).ok()?; + let own = vendor_prefixes(&crate::xml::parse(driver).ok()?); let declared = vendor_prefixes(&document); let renames: Vec<(&str, &str)> = declared .iter() @@ -546,7 +546,7 @@ pub(crate) fn respell(ticket: &str, driver: &str) -> Option { for a in node.attributes().filter(|a| (a.namespace().is_none() && matches!(a.name(), "name" | "constrained")) || a.namespace() == Some(NS_XSI)) { - edits.extend(respelled(a.range_value().start)); + edits.extend(value_start(ticket, a.range()).and_then(respelled)); } } else if node.is_text() && node.parent().is_some_and(is_qname_value) { // A pretty-printed ticket puts whitespace before the value; the prefix starts after it. @@ -566,7 +566,7 @@ pub(crate) fn respell(ticket: &str, driver: &str) -> Option { out.push_str(ticket.get(copied..)?); // A last check on the result: it must parse, declare each namespace under the prefix wanted and // resolve every prefix. - let check = roxmltree::Document::parse(&out).ok()?; + let check = crate::xml::parse(&out).ok()?; let mut now = vendor_prefixes(&check); let mut wanted: Vec<(String, String)> = declared.iter().map(|(old, uri)| (renamed(old).unwrap_or(old).to_string(), uri.clone())).collect(); now.sort_unstable(); @@ -574,6 +574,16 @@ pub(crate) fn respell(ticket: &str, driver: &str) -> Option { (now == wanted && prefixes_resolve(&check)).then_some(out) } +/// Where the value of the attribute spanning `attribute` starts: past the quote after its `=`. +/// Not `range_value`, which roxmltree derives from a name length kept in 16 bits and a count of +/// spaces around the `=` kept in 8, so it points elsewhere past a 64 KiB name or 255 spaces. +fn value_start(xml: &str, attribute: std::ops::Range) -> Option { + let text = xml.get(attribute.clone())?; + let equals = text.find('=')?; + let quote = text.get(equals..)?.find(['"', '\''])?; + Some(attribute.start + equals + quote + 1) +} + /// Each vendor namespace that `ticket` declares under a prefix the driver does not declare it with, /// as (namespace, prefix in the ticket, prefix of the driver); empty when the ticket is spelled the /// way the driver spells it. `driver` is the driver's own ticket ([`empty_ticket`] validated). @@ -583,8 +593,8 @@ pub(crate) fn respell(ticket: &str, driver: &str) -> Option { /// is also what it returns when it refused to change a ticket, which then stays as it is. #[cfg(test)] pub(crate) fn prefixes_unlike_the_drivers(ticket: &str, driver: &str) -> Option> { - let own = vendor_prefixes(&roxmltree::Document::parse(driver).ok()?); - let declared = vendor_prefixes(&roxmltree::Document::parse(ticket).ok()?); + let own = vendor_prefixes(&crate::xml::parse(driver).ok()?); + let declared = vendor_prefixes(&crate::xml::parse(ticket).ok()?); Some( declared .into_iter() @@ -601,7 +611,7 @@ pub(crate) fn prefixes_unlike_the_drivers(ticket: &str, driver: &str) -> Option< /// for the job's ticket (`PageImageableSize`). The largest of the four, so content centred inside /// it is clear of every edge whichever way the driver measures. `None` when the driver doesn't say. pub(crate) fn imageable_margin(capabilities_xml: &str) -> Option { - let document = roxmltree::Document::parse(capabilities_xml).ok()?; + let document = crate::xml::parse(capabilities_xml).ok()?; let imageable = document.root_element().children().find(|n| n.has_tag_name((NS_PSF, "Property")) && named(*n, "PageImageableSize"))?; let width = integer(imageable, "Property", "ImageableSizeWidth")?; let height = integer(imageable, "Property", "ImageableSizeHeight")?; @@ -708,6 +718,23 @@ mod tests { assert_eq!(respell(&spaced("ns0000"), &brother("brpsk")), Some(spaced("brpsk"))); } + /// A `name` with more than 255 spaces around its `=`, and an `xsi` attribute with a name over + /// 64 KiB, are respelled where their values are, not where roxmltree's `range_value` says. + #[test] + fn values_past_a_long_name_or_many_spaces_are_respelled_in_place() { + let spaced = |prefix: &str| { + let pad = " ".repeat(200); + brother(prefix).replace(&format!(r#"name="{prefix}:JobPrintSpeed""#), &format!(r#"name{pad}={pad}"{prefix}:JobPrintSpeed""#)) + }; + assert_eq!(respell(&spaced("ns0000"), &brother("brpsk")), Some(spaced("brpsk"))); + let long = |prefix: &str| { + let p = "x".repeat(65_536); + brother(prefix) + .replace("", &format!(r#""#)) + }; + assert_eq!(respell(&long("ns0000"), &brother("brpsk")), Some(long("brpsk"))); + } + /// The ticket Windows returns (`ns0000`) gets the driver's own `brpsk` back, in the declaration, the names and the QName values, but not in text. #[test] fn a_vendor_namespace_gets_the_prefix_the_driver_declares_it_with() { diff --git a/crates/print/src/xml.rs b/crates/print/src/xml.rs new file mode 100644 index 000000000..4a7faee37 --- /dev/null +++ b/crates/print/src/xml.rs @@ -0,0 +1,188 @@ +//! A bounded parse of the XML the driver hands back (PrintTicket, PrintCapabilities). roxmltree's +//! work grows with the square of an element's attributes and namespace declarations, so those, +//! the nesting depth, the node count and the size are checked before it runs. The limits sit far +//! above what a driver writes. + +use std::fmt; + +/// The largest document read, in bytes. A PrintCapabilities document is rarely over 1 MiB. +pub(crate) const MAX_BYTES: usize = 16 * 1024 * 1024; +/// The most nodes (elements, attributes' owners, text, comments) one document may hold. +pub(crate) const MAX_NODES: u32 = 1_000_000; +/// The most attributes on one element, namespace declarations included. +pub(crate) const MAX_ATTRIBUTES: usize = 256; +/// The most namespace declarations on one element. +pub(crate) const MAX_NAMESPACES: usize = 64; +/// The deepest nesting of elements. +pub(crate) const MAX_DEPTH: usize = 256; + +/// Why a document was not parsed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum XmlError { + /// A limit was passed: which one, and the limit. + Limit(&'static str, usize), + /// The document is not well-formed XML. + Parse(String), +} + +impl fmt::Display for XmlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Limit(what, limit) => write!(f, "printer XML has more than {limit} {what}"), + Self::Parse(e) => write!(f, "printer XML: {e}"), + } + } +} + +/// `text` parsed, once it is within every limit above. +pub(crate) fn parse(text: &str) -> Result, XmlError> { + if text.len() > MAX_BYTES { + return Err(XmlError::Limit("bytes", MAX_BYTES)); + } + scan(text.as_bytes())?; + let options = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() }; + roxmltree::Document::parse_with_options(text, options).map_err(|e| match e { + roxmltree::Error::NodesLimitReached => XmlError::Limit("nodes", MAX_NODES as usize), + e => XmlError::Parse(e.to_string()), + }) +} + +/// Checks the depth and each start tag's attributes, skipping comments, CDATA, processing +/// instructions and declarations. Malformed input is left for roxmltree to refuse. +fn scan(bytes: &[u8]) -> Result<(), XmlError> { + let mut depth = 0usize; + let mut at = 0usize; + while let Some(offset) = bytes.get(at..).and_then(|rest| rest.iter().position(|&b| b == b'<')) { + let start = at + offset; + let rest = bytes.get(start..).unwrap_or_default(); + at = if rest.starts_with(b"") + } else if rest.starts_with(b"") + } else if rest.starts_with(b"") + } else if rest.starts_with(b"") + } else if rest.starts_with(b"") + } else { + let (end, empty) = start_tag(bytes, start)?; + if !empty { + depth += 1; + if depth > MAX_DEPTH { + return Err(XmlError::Limit("levels of nesting", MAX_DEPTH)); + } + } + end + }; + } + Ok(()) +} + +/// The position just past `end` at or after `start`, or the end of the input. +fn skip_past(bytes: &[u8], start: usize, end: &[u8]) -> usize { + bytes.get(start..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(bytes.len(), |p| start + p + end.len()) +} + +/// Counts the attributes and namespace declarations of the start tag at `start`. Returns the +/// position past its `>` and whether it is an empty-element tag (`/>`). +fn start_tag(bytes: &[u8], start: usize) -> Result<(usize, bool), XmlError> { + let (mut attributes, mut namespaces) = (0usize, 0usize); + let mut quote = None; + let mut previous = b'<'; + for (i, &b) in bytes.iter().enumerate().skip(start + 1) { + match quote { + Some(q) if b == q => quote = None, + Some(_) => {} + None if b == b'"' || b == b'\'' => quote = Some(b), + None if b == b'>' => return Ok((i + 1, previous == b'/')), + None if b == b'=' => { + attributes += 1; + if attributes > MAX_ATTRIBUTES { + return Err(XmlError::Limit("attributes on an element", MAX_ATTRIBUTES)); + } + if attribute_name(bytes.get(start..i).unwrap_or_default()).starts_with(b"xmlns") { + namespaces += 1; + if namespaces > MAX_NAMESPACES { + return Err(XmlError::Limit("namespace declarations on an element", MAX_NAMESPACES)); + } + } + } + None => {} + } + previous = b; + } + Ok((bytes.len(), false)) +} + +/// The name before an `=` that ends `tag`: the last word, past any whitespace before the `=`. +fn attribute_name(tag: &[u8]) -> &[u8] { + let end = tag.iter().rposition(|b| !b.is_ascii_whitespace()).map_or(0, |p| p + 1); + let head = tag.get(..end).unwrap_or_default(); + let start = head.iter().rposition(u8::is_ascii_whitespace).map_or(0, |p| p + 1); + head.get(start..).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn with_attributes(count: usize, name: &str) -> String { + let attributes: String = (0..count).map(|i| format!(r#" {name}{i}="v""#)).collect(); + format!("") + } + + fn nested(depth: usize) -> String { + format!("{}{}", "".repeat(depth), "".repeat(depth)) + } + + #[test] + fn a_document_at_each_limit_parses() { + assert!(parse(&with_attributes(MAX_ATTRIBUTES, "a")).is_ok()); + assert!(parse(&with_attributes(MAX_NAMESPACES, "xmlns:p")).is_ok()); + assert!(parse(&nested(MAX_DEPTH)).is_ok()); + } + + #[test] + fn a_document_just_past_each_limit_is_refused() { + let limit = |text: &str| match parse(text) { + Err(XmlError::Limit(what, _)) => what, + other => panic!("expected a limit, got {other:?}"), + }; + assert_eq!(limit(&with_attributes(MAX_ATTRIBUTES + 1, "a")), "attributes on an element"); + assert_eq!(limit(&with_attributes(MAX_NAMESPACES + 1, "xmlns:p")), "namespace declarations on an element"); + let default = format!("", (1..=MAX_NAMESPACES).map(|i| format!(" xmlns:p{i}='u{i}'")).collect::()); + assert_eq!(limit(&default), "namespace declarations on an element", "the default namespace counts"); + let spaced: String = (0..=MAX_NAMESPACES).map(|i| format!(" xmlns:p{i} \t= 'u{i}'")).collect(); + assert_eq!(limit(&format!("")), "namespace declarations on an element"); + assert_eq!(limit(&nested(MAX_DEPTH + 1)), "levels of nesting"); + let nodes = format!("{}", "".repeat(MAX_NODES as usize)); + assert_eq!(limit(&nodes), "nodes"); + let big = format!("{}", " ".repeat(MAX_BYTES)); + assert_eq!(limit(&big), "bytes"); + } + + #[test] + fn markup_inside_comments_cdata_and_values_is_not_counted() { + let tag = with_attributes(MAX_ATTRIBUTES + 1, "a"); + let deep = "".repeat(MAX_DEPTH + 1); + let hidden = format!("", "a=b ".repeat(MAX_ATTRIBUTES + 1)); + assert!(parse(&hidden).is_ok()); + // A comment that opens with what looks like its own end still runs on. + assert!(parse(&format!(" {tag} -->")).is_ok()); + let quoted = format!("", (0..MAX_ATTRIBUTES).map(|i| format!(" a{i}='x>y'")).collect::()); + assert!(parse("ed).is_ok(), "a > inside a value"); + } + + #[test] + fn empty_and_closed_elements_do_not_deepen_the_nesting() { + let flat = format!("{}", "".repeat(MAX_DEPTH * 2)); + assert!(parse(&flat).is_ok()); + } + + #[test] + fn malformed_xml_is_a_parse_error() { + assert!(matches!(parse(""), Err(XmlError::Parse(_)))); + } +}