print: bound the printer XML parse, and respell values where they are

* print: bound the printer XML parse; respell values where they are

Every non-test parse of the driver's PrintTicket and PrintCapabilities in
ticket.rs goes through one bounded parse (new xml.rs): at most 16 MiB,
1,000,000 nodes, 256 attributes and 64 namespace declarations on an
element, and 256 levels of nesting. roxmltree's work grows with the square
of an element's attributes and namespaces, so a broken or hostile driver
document could stall Print. The attribute, namespace and depth limits are
checked by a scan that skips comments, CDATA, processing instructions and
quoted values; the node limit is roxmltree's own nodes_limit. A document
past a limit is refused the way an unparseable one already was.

The PrintTicket respell now finds each attribute's value from the
attribute's full range (the quote after its `=`) instead of
`range_value()`, which roxmltree computes from a name length kept in 16
bits and a space count kept in 8, so it pointed elsewhere past a 64 KiB
name or 255 spaces around `=`.

Closes #630. Closes #631.

* print: bound the two parses #411 added

---------

Co-authored-by: KenM76 <KenM76>
This commit is contained in:
Ken MantleandKenM76 authored and GitHub committed 2026-11-12 09:51:56 -05:00
1 parent 1c5c8a4e14
commit 83d5ad516c
3 files changed
+230 -13

No files matched your search

+2
View File
@@ -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
+40 -13
View File
@@ -199,13 +199,13 @@ pub(crate) fn document_duplex_only(xml: &str, duplex: Option<Duplex>) -> 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<bool> {
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<u32>
/// 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<bool> {
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<bool> {
/// 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<String> {
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<String> {
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<String> {
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<String> {
(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<usize>) -> Option<usize> {
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<String> {
/// 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<Vec<(String, String, String)>> {
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<u32> {
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("</psf:PrintTicket>", &format!(r#"<psf:Value xmlns:{p}="{NS_XSI}" {p}:type="{prefix}:Custom"/></psf:PrintTicket>"#))
};
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() {
+188
View File
@@ -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<roxmltree::Document<'_>, 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"<!--") {
skip_past(bytes, start + 4, b"-->")
} else if rest.starts_with(b"<![CDATA[") {
skip_past(bytes, start + 9, b"]]>")
} else if rest.starts_with(b"<?") {
skip_past(bytes, start + 2, b"?>")
} else if rest.starts_with(b"</") {
depth = depth.saturating_sub(1);
skip_past(bytes, start, b">")
} else if rest.starts_with(b"<!") {
skip_past(bytes, start, 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!("<root{attributes}/>")
}
fn nested(depth: usize) -> String {
format!("{}{}", "<a>".repeat(depth), "</a>".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!("<root xmlns='u'{}/>", (1..=MAX_NAMESPACES).map(|i| format!(" xmlns:p{i}='u{i}'")).collect::<String>());
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!("<root{spaced}/>")), "namespace declarations on an element");
assert_eq!(limit(&nested(MAX_DEPTH + 1)), "levels of nesting");
let nodes = format!("<r>{}</r>", "<a/>".repeat(MAX_NODES as usize));
assert_eq!(limit(&nodes), "nodes");
let big = format!("<r>{}</r>", " ".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 = "<a>".repeat(MAX_DEPTH + 1);
let hidden = format!("<r><!-- {tag} {deep} --><![CDATA[{tag}{deep}]]><?pi {tag}?><e v='{}'/></r>", "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!("<r><!--> {tag} --></r>")).is_ok());
let quoted = format!("<root{}/>", (0..MAX_ATTRIBUTES).map(|i| format!(" a{i}='x>y'")).collect::<String>());
assert!(parse(&quoted).is_ok(), "a > inside a value");
}
#[test]
fn empty_and_closed_elements_do_not_deepen_the_nesting() {
let flat = format!("<r>{}</r>", "<a/><b></b>".repeat(MAX_DEPTH * 2));
assert!(parse(&flat).is_ok());
}
#[test]
fn malformed_xml_is_a_parse_error() {
assert!(matches!(parse("<a><b></a>"), Err(XmlError::Parse(_))));
}
}