oxml 0.0.11

A pure Rust XML toolkit with zero unsafe code: parsing, an ergonomic tree, and XPath
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2026 oxml. All rights reserved.

//! Resource bounds.
//!
//! Every limit is exercised three ways: input just under it is
//! accepted, input just over it is rejected, and the rejection carries
//! the *specific* [`ErrorKind`] for that bound rather than a generic
//! failure. A limit that rejects with the wrong kind is as good as
//! absent — the caller cannot tell what to change.

use std::fmt::Write as _;

use oxml::{ErrorKind, Limits, parse, parse_with};

/// `n` distinct attributes, as a single string.
fn many_attributes(n: usize) -> String {
    (0..n).fold(String::new(), |mut acc, i| {
        let _ = write!(acc, " a{i}=\"v\"");
        acc
    })
}

fn err_kind(src: &str, limits: Limits) -> ErrorKind {
    parse_with(src, limits)
        .expect_err("should exceed a limit")
        .kind
}

#[test]
fn the_defaults_accept_an_ordinary_document() {
    let doc = "<library><book id=\"1\" lang=\"en\">\
               <title>Dune</title></book></library>";
    assert!(parse(doc).is_ok());
    assert!(parse_with(doc, Limits::default()).is_ok());
    assert!(parse_with(doc, Limits::strict()).is_ok());
    assert!(parse_with(doc, Limits::permissive()).is_ok());
}

#[test]
fn parse_and_parse_with_default_agree() {
    for src in ["<a/>", "<a><b>x</b></a>", "<a x=\"1\"/>", "<a><!--c--></a>"] {
        assert_eq!(
            parse(src).is_ok(),
            parse_with(src, Limits::default()).is_ok(),
            "{src}"
        );
    }
}

#[test]
fn max_depth_is_enforced_at_its_boundary() {
    let mut l = Limits::default();
    l.max_depth = 8;

    let ok = format!("{}{}", "<a>".repeat(8), "</a>".repeat(8));
    assert!(parse_with(&ok, l).is_ok(), "8 deep with a limit of 8");

    let over = format!("{}{}", "<a>".repeat(9), "</a>".repeat(9));
    assert_eq!(err_kind(&over, l), ErrorKind::DepthLimitExceeded);
}

#[test]
fn max_attributes_per_element_is_enforced() {
    let mut l = Limits::default();
    l.max_attributes_per_element = 4;

    let attrs = |n: usize| format!("<e{}/>", many_attributes(n));

    assert!(parse_with(&attrs(4), l).is_ok(), "4 with a limit of 4");
    assert_eq!(err_kind(&attrs(5), l), ErrorKind::TooManyAttributes);
}

#[test]
fn max_attribute_size_is_enforced() {
    let mut l = Limits::default();
    l.max_attribute_size = 16;

    let ok = format!("<e a=\"{}\"/>", "x".repeat(16));
    assert!(parse_with(&ok, l).is_ok());

    let over = format!("<e a=\"{}\"/>", "x".repeat(17));
    assert_eq!(err_kind(&over, l), ErrorKind::AttributeTooLarge);
}

#[test]
fn max_name_length_is_enforced_on_elements_and_attributes() {
    let mut l = Limits::default();
    l.max_name_length = 8;

    assert!(parse_with(&format!("<{}/>", "e".repeat(8)), l).is_ok());
    assert_eq!(
        err_kind(&format!("<{}/>", "e".repeat(9)), l),
        ErrorKind::NameTooLong,
        "element name"
    );
    assert_eq!(
        err_kind(&format!("<e {}=\"v\"/>", "a".repeat(9)), l),
        ErrorKind::NameTooLong,
        "attribute name"
    );
}

#[test]
fn max_nodes_is_enforced() {
    let mut l = Limits::default();
    l.max_nodes = Some(10);

    assert!(parse_with("<a><b/></a>", l).is_ok());

    let many = format!("<a>{}</a>", "<b/>".repeat(1000));
    assert_eq!(err_kind(&many, l), ErrorKind::TooManyNodes);
}

#[test]
fn max_text_length_is_enforced() {
    let mut l = Limits::default();
    l.max_text_length = Some(16);

    assert!(parse_with(&format!("<a>{}</a>", "x".repeat(16)), l).is_ok());
    assert_eq!(
        err_kind(&format!("<a>{}</a>", "x".repeat(17)), l),
        ErrorKind::TextTooLong
    );
}

#[test]
fn text_length_counts_the_whole_coalesced_run() {
    // Adjacent text and entity references merge into one node, so a
    // limit applied per-fragment would be trivially bypassed by
    // splitting the payload with `&amp;`.
    let mut l = Limits::default();
    l.max_text_length = Some(16);

    let split = format!("<a>{}&amp;{}</a>", "x".repeat(10), "y".repeat(10));
    assert_eq!(err_kind(&split, l), ErrorKind::TextTooLong);
}

#[test]
fn strict_limits_reject_what_defaults_accept() {
    // Otherwise `strict()` is decorative.
    let deep = format!("{}{}", "<a>".repeat(100), "</a>".repeat(100));
    assert!(parse_with(&deep, Limits::default()).is_ok());
    assert_eq!(
        err_kind(&deep, Limits::strict()),
        ErrorKind::DepthLimitExceeded
    );
}

#[test]
fn permissive_limits_accept_what_defaults_reject() {
    // Not depth — that one is bounded by the stack, not by policy.
    let wide = (0..5_000).fold(String::from("<e"), |mut acc, i| {
        let _ = write!(acc, " a{i}=\"v\"");
        acc
    }) + "/>";
    assert_eq!(
        err_kind(&wide, Limits::default()),
        ErrorKind::TooManyAttributes
    );
    assert!(parse_with(&wide, Limits::permissive()).is_ok());
}

#[test]
fn permissive_does_not_raise_the_depth_limit() {
    // Raising it would hand out a stack overflow, which aborts the
    // process rather than returning an error — the opposite of what a
    // limit is for. Measured ceiling in a debug build on a 2 MiB test
    // thread is ~280 levels, so anything generous here is a crash.
    assert_eq!(
        Limits::permissive().max_depth,
        Limits::default().max_depth,
        "permissive() must not raise max_depth past the stack ceiling"
    );
}

#[test]
fn the_default_depth_limit_stays_under_the_debug_stack_ceiling() {
    // A debug build on a 2 MiB thread (what `cargo test` provides)
    // survives ~280 levels at ~7.5 KiB per frame. If a change grows the
    // frame, this fails here rather than aborting the test process.
    assert!(
        Limits::default().max_depth <= 280,
        "default max_depth {} exceeds the measured debug ceiling",
        Limits::default().max_depth
    );
    let at_limit = format!(
        "{}{}",
        "<a>".repeat(Limits::default().max_depth),
        "</a>".repeat(Limits::default().max_depth)
    );
    assert!(parse(&at_limit).is_ok(), "the default must be reachable");
}

#[test]
fn limits_are_copy_and_adjustable_from_default() {
    // The type is `#[non_exhaustive]`, so this is the supported way to
    // build one. If `Copy` were lost this stops compiling.
    let mut l = Limits::default();
    l.max_depth = 4;
    let copy = l;
    assert_eq!(copy.max_depth, 4);
    assert_eq!(l, copy);
}

#[test]
fn a_hostile_document_fails_fast_under_strict_limits() {
    // The shapes that only appear in attacks.
    let cases = [
        format!("{}{}", "<a>".repeat(100_000), "</a>".repeat(100_000)),
        format!("<e{}/>", many_attributes(100_000)),
        format!("<a>{}</a>", "<b/>".repeat(1_000_000)),
        format!("<a>{}</a>", "x".repeat(50_000_000)),
    ];
    for (i, src) in cases.iter().enumerate() {
        assert!(
            parse_with(src, Limits::strict()).is_err(),
            "case {i} was accepted"
        );
    }
}

#[test]
fn the_two_xml_1_0_editions_disagree_and_both_are_available() {
    // U+0E5C begins a legal name in the 5th edition and not in the
    // 4th. The W3C suite ships 309 tests that exist only to probe this
    // difference; a parser fixed to one edition cannot be scored
    // against the other's tests at all.
    let src = "<a\u{0E5C}/>";
    assert!(parse(src).is_ok(), "5th edition accepts it");

    let mut fourth = Limits::default();
    fourth.edition = oxml::Edition::Fourth;
    assert!(parse_with(src, fourth).is_err(), "4th edition rejects it");

    // And a name legal in both is accepted by both.
    assert!(parse("<abc/>").is_ok());
    assert!(parse_with("<abc/>", fourth).is_ok());
}

#[test]
fn the_fifth_edition_is_the_default() {
    assert_eq!(Limits::default().edition, oxml::Edition::Fifth);
    assert_eq!(Limits::strict().edition, oxml::Edition::Fifth);
    assert_eq!(Limits::permissive().edition, oxml::Edition::Fifth);
}

/// A parameter-entity width bomb: three levels, a hundred references
/// each, so `%c;` produces ten thousand copies of a kilobyte from
/// under a kilobyte of declarations.
///
/// Depth alone does not catch it -- three is well inside any depth
/// bound -- so the replacement text produced is charged against
/// `max_pe_expansion`, exactly as general entities are charged against
/// `max_entity_expansion`.
#[test]
fn a_parameter_entity_width_bomb_is_rejected_by_the_budget() {
    let a = format!("<!-- {} -->", "x".repeat(1024));
    let doc = format!(
        "<!DOCTYPE d SYSTEM \"d.dtd\" [\
         <!ENTITY % a \"{a}\">\
         <!ENTITY % b \"{}\">\
         <!ENTITY % c \"{}\">\
         %c;\
         ]><d/>",
        "%a;".repeat(100),
        "%b;".repeat(100),
    );
    assert_eq!(
        err_kind(&doc, Limits::default()),
        ErrorKind::EntityLimitExceeded
    );
}

#[test]
fn a_name_declared_in_the_dtd_is_bounded_by_max_name_length() {
    let long = "n".repeat(Limits::default().max_name_length + 1);
    for decl in [
        format!("<!ELEMENT {long} ANY>"),
        format!("<!ATTLIST d {long} CDATA #IMPLIED>"),
        format!("<!ENTITY {long} \"v\">"),
        format!("<!NOTATION {long} SYSTEM \"s\">"),
    ] {
        let doc = format!("<!DOCTYPE d [{decl}]><d/>");
        assert_eq!(
            err_kind(&doc, Limits::default()),
            ErrorKind::NameTooLong,
            "{}",
            &decl[..12]
        );
    }
    let fine = format!(
        "<!DOCTYPE d [<!ELEMENT {} ANY>]><d/>",
        "n".repeat(Limits::default().max_name_length)
    );
    assert!(parse(&fine).is_ok(), "a name at the limit is accepted");
}

#[test]
fn an_attlist_default_is_bounded_by_max_attribute_size() {
    let value = "v".repeat(600 * 1024);
    for default in [format!("\"{value}\""), format!("#FIXED \"{value}\"")] {
        let doc = format!("<!DOCTYPE d [<!ATTLIST d a CDATA {default}>]><d/>");
        assert_eq!(
            err_kind(&doc, Limits::default()),
            ErrorKind::AttributeTooLarge
        );
    }
}

#[test]
fn max_dtd_bytes_bounds_the_internal_subset() {
    // Seventeen megabytes of comments: harmless to parse, and over
    // the default budget for a doctype.
    let comment = format!("<!-- {} -->", "c".repeat(1_000));
    let subset = comment.repeat(17 * 1024 * 1024 / comment.len() + 1);
    let doc = format!("<!DOCTYPE d [{subset}]><d/>");
    assert_eq!(err_kind(&doc, Limits::default()), ErrorKind::DtdTooLarge);
}

/// The doctype bounds follow the profile handed to `parse_with`, not
/// the defaults.
#[test]
fn permissive_limits_do_not_bound_the_doctype() {
    let comment = format!("<!-- {} -->", "c".repeat(1_000));
    let subset = comment.repeat(17 * 1024 * 1024 / comment.len() + 1);
    let doc = format!("<!DOCTYPE d [{subset}]><d/>");
    assert!(
        parse_with(&doc, Limits::permissive()).is_ok(),
        "permissive() leaves max_dtd_bytes unbounded"
    );
    let mut tight = Limits::default();
    tight.max_pe_expansion = 64;
    let doc = "<!DOCTYPE d SYSTEM \"d.dtd\" [\
               <!ENTITY % a \"<!-- thirty-two bytes of comment -->\">\
               %a;%a;%a;]><d/>";
    assert_eq!(err_kind(doc, tight), ErrorKind::EntityLimitExceeded);
    assert!(parse(doc).is_ok(), "and the default budget is ample");
}

#[test]
fn the_profiles_order_the_dtd_and_stream_bounds() {
    let (s, d, p) = (Limits::strict(), Limits::default(), Limits::permissive());
    assert!(s.max_pe_expansion < d.max_pe_expansion);
    assert!(d.max_pe_expansion < p.max_pe_expansion);
    assert!(s.max_dtd_bytes < d.max_dtd_bytes);
    assert_eq!(p.max_dtd_bytes, None);
    assert!(s.max_construct_bytes < d.max_construct_bytes);
    assert_eq!(p.max_construct_bytes, None);
}