Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .agents/skills/polyxml-core-engine/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,11 @@ retain whitespace-token rejection, escaping and empty-list behavior.

For measured changes, use
[polyxml-runtime-investigation](../polyxml-runtime-investigation/SKILL.md).

Namespace declarations are XML attribute values too: unescape predefined and
numeric references exactly once before storing their URI in NamespaceScope.
Propagate failures through document entry, nested Start/Empty handling and
XmlItemStream inheritance. Compare decoded URIs for strict-root and xsi:type
matching; escaped source text is not the namespace name. Preserve inherited
already-decoded bindings and test a literal amp-entity spelling against double
decoding.
36 changes: 19 additions & 17 deletions crates/polyxml-core/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,21 +171,23 @@ fn is_nil_element(e: &BytesStart) -> bool {

/// Extend inherited namespace bindings for this element. Elements without
/// declarations share the existing map.
fn namespace_scope(parent: &NamespaceScope, e: &BytesStart) -> NamespaceScope {
fn namespace_scope(parent: &NamespaceScope, e: &BytesStart) -> Result<NamespaceScope> {
let mut scope = None;
for attr in e.attributes().flatten() {
let key = attr.key.as_ref();
if key == "xmlns" {
scope
.get_or_insert_with(|| (**parent).clone())
.insert(String::new(), attr.value.as_ref().to_string());
scope.get_or_insert_with(|| (**parent).clone()).insert(
String::new(),
quick_xml::escape::unescape(attr.value.as_ref())?.into_owned(),
);
} else if let Some(prefix) = key.strip_prefix("xmlns:") {
scope
.get_or_insert_with(|| (**parent).clone())
.insert(prefix.to_string(), attr.value.as_ref().to_string());
scope.get_or_insert_with(|| (**parent).clone()).insert(
prefix.to_string(),
quick_xml::escape::unescape(attr.value.as_ref())?.into_owned(),
);
}
}
scope.map(Arc::new).unwrap_or_else(|| Arc::clone(parent))
Ok(scope.map(Arc::new).unwrap_or_else(|| Arc::clone(parent)))
}

/// Raw unescaped value of the type attribute in the XML Schema Instance
Expand Down Expand Up @@ -342,7 +344,7 @@ pub const DEFAULT_MAX_DEPTH: usize = 256;
impl XmlDeserializer {
fn check_root(start: &BytesStart, schema: &ModelSchema) -> Result<()> {
if schema.strict_root {
let scope = namespace_scope(&Arc::new(HashMap::new()), start);
let scope = namespace_scope(&Arc::new(HashMap::new()), start)?;
let (ns, local) = resolve_element_qname(start, &scope);
if local.as_bytes() != schema.xml_name
|| ns.as_deref().filter(|s| !s.is_empty())
Expand Down Expand Up @@ -384,7 +386,7 @@ impl XmlDeserializer {
}
Ok(Event::Empty(ref e)) => {
Self::check_root(e, &root_schema)?;
let scope = namespace_scope(&Arc::new(HashMap::new()), e);
let scope = namespace_scope(&Arc::new(HashMap::new()), e)?;
let schema = resolve_record_schema(&root_schema, e, &scope)?;
let mut frame = StackFrame::new(schema);
Self::parse_attributes(e, &mut frame)?;
Expand Down Expand Up @@ -415,7 +417,7 @@ impl XmlDeserializer {
inherited_scope: &NamespaceScope,
) -> Result<PolyValue> {
let mut stack: Vec<StackFrame> = Vec::with_capacity(16);
let mut namespace_stack = vec![namespace_scope(inherited_scope, root_start)];
let mut namespace_stack = vec![namespace_scope(inherited_scope, root_start)?];
let root_schema =
resolve_record_schema(&root_schema, root_start, namespace_stack.last().unwrap())?;
let mut root_frame = StackFrame::new(root_schema);
Expand All @@ -431,7 +433,7 @@ impl XmlDeserializer {
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
namespace_stack.push(namespace_scope(namespace_stack.last().unwrap(), e));
namespace_stack.push(namespace_scope(namespace_stack.last().unwrap(), e)?);
if unknown_depth > 0 {
unknown_depth += 1;
continue;
Expand Down Expand Up @@ -626,11 +628,11 @@ impl XmlDeserializer {
continue;
}
if any_stack.is_empty() {
let scope = namespace_scope(namespace_stack.last().unwrap(), e);
let scope = namespace_scope(namespace_stack.last().unwrap(), e)?;
record_content_token(stack.last_mut().unwrap(), e, &scope);
}
if !any_stack.is_empty() {
let scope = namespace_scope(namespace_stack.last().unwrap(), e);
let scope = namespace_scope(namespace_stack.last().unwrap(), e)?;
let (ns, local) = resolve_element_qname(e, &scope);
let qname = if let Some(uri) = ns {
format!("{{{}}}{}", uri, local)
Expand All @@ -651,7 +653,7 @@ impl XmlDeserializer {
continue;
}
let local_name = e.local_name();
let scope = namespace_scope(namespace_stack.last().unwrap(), e);
let scope = namespace_scope(namespace_stack.last().unwrap(), e)?;
let is_nil = is_nil_element(e);

let current_schema = Arc::clone(&stack.last().unwrap().schema);
Expand Down Expand Up @@ -1085,7 +1087,7 @@ impl<R: std::io::BufRead> XmlItemStream<R> {
.last()
.cloned()
.unwrap_or_else(|| Arc::new(HashMap::new()));
self.namespace_stack.push(namespace_scope(&inherited, e));
self.namespace_stack.push(namespace_scope(&inherited, e)?);
}
Ok(Event::Empty(ref e)) => {
let local = e.local_name();
Expand All @@ -1097,7 +1099,7 @@ impl<R: std::io::BufRead> XmlItemStream<R> {
.last()
.cloned()
.unwrap_or_else(|| Arc::new(HashMap::new()));
let scope = namespace_scope(&inherited, e);
let scope = namespace_scope(&inherited, e)?;
let schema = resolve_record_schema(&self.schema, e, &scope)?;
let mut frame = StackFrame::new(schema);
XmlDeserializer::parse_attributes(e, &mut frame)?;
Expand Down
90 changes: 90 additions & 0 deletions crates/polyxml-core/tests/test_namespace_entities.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use polyxml::schema::{FieldKind, FieldSchema, ModelSchema, ScalarType, ValueType};
use polyxml::{deserialize, serialize, PolyValue, XmlItemStream};
use std::{io::Cursor, sync::Arc};

const NS: &str = "urn:polyxml:A&B";
fn value_field() -> FieldSchema {
FieldSchema::new(
"value",
b"Value",
FieldKind::Element,
ValueType::Scalar(ScalarType::Int),
)
}

#[test]
fn escaped_namespace_bindings_match_strict_root_and_round_trip() {
let schema = ModelSchema::builder("Root")
.namespace(NS)
.strict_root(true)
.field(value_field())
.build();
for xml in [
"<Root xmlns='urn:polyxml:A&amp;B'><Value>7</Value></Root>",
"<p:Root xmlns:p='urn:polyxml:A&#38;B'><p:Value>7</p:Value></p:Root>",
"<p:Root xmlns:p='urn:polyxml:A&#x26;B'><p:Value>7</p:Value></p:Root>",
] {
let value = deserialize(xml.as_bytes(), Arc::clone(&schema)).unwrap();
assert_eq!(value.get("value"), Some(&PolyValue::Int(7)));
let output = serialize("Root", &value, &schema, None).unwrap();
assert_eq!(value, deserialize(&output, Arc::clone(&schema)).unwrap());
}
}

#[test]
fn namespace_entity_values_are_decoded_once() {
let schema = ModelSchema::builder("Root")
.namespace("urn:polyxml:literal&amp;")
.strict_root(true)
.build();
let xml = b"<Root xmlns='urn:polyxml:literal&amp;amp;'/>";
assert!(deserialize(xml, schema).is_ok());
}

fn abstract_schema() -> Arc<ModelSchema> {
let base = ModelSchema::builder("Root").is_abstract(true).build();
let concrete = ModelSchema::builder("Derived")
.namespace(NS)
.field(value_field())
.build();
base.set_variants(vec![concrete]);
base
}

#[test]
fn entity_decoding_applies_to_inherited_and_local_xsi_type_bindings() {
let base = abstract_schema();
for xml in [
"<Root xmlns:t='urn:polyxml:A&amp;B' xmlns:i='http://www.w3.org/2001/XMLSchema-instance' i:type='t:Derived'><Value>9</Value></Root>",
"<Root xmlns:t='urn:polyxml:A&#x26;B' xmlns:i='http://www.w3.org/2001/XMLSchema-instance' i:type='t:Derived'/>",
] {
let value=deserialize(xml.as_bytes(),Arc::clone(&base)).unwrap();
let PolyValue::Record{schema,..}=value else{panic!("expected concrete record")};
assert_eq!(schema.name,"Derived");
}
let xml="<Envelope xmlns:t='urn:polyxml:A&amp;B' xmlns:i='http://www.w3.org/2001/XMLSchema-instance'><Root i:type='t:Derived'><Value>9</Value></Root><Root i:type='t:Derived'/></Envelope>";
let mut stream = XmlItemStream::new(Cursor::new(xml.as_bytes()), base, b"Root");
for _ in 0..2 {
let value = stream.next_item().unwrap().unwrap();
let PolyValue::Record { schema, .. } = value else {
panic!("expected concrete record")
};
assert_eq!(schema.name, "Derived");
}
assert!(stream.next_item().unwrap().is_none());
}

#[test]
fn undefined_entities_cannot_form_namespace_bindings() {
let schema = ModelSchema::builder("Root").build();
for xml in [
"<Root xmlns='urn:polyxml:&undefined;'/>",
"<Root xmlns:unused='urn:polyxml:&undefined;'></Root>",
"<Root><Unknown xmlns:u='urn:&undefined;'></Unknown></Root>",
] {
assert!(
deserialize(xml.as_bytes(), Arc::clone(&schema)).is_err(),
"{xml}"
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
eabcc902c35bfd262c2a1009a50e06f82f24a792be3c605068cd3735a9b267a6 metadata.json
df1ef8b4289ef8587a49e3df9e7ad98322894a41c22c1100d8f4085fac4e4f90 namespace-entities-baseline-tests.log
139cd3ae6bc1c7399bf99d7d0144004277ebddd8bdeb9388539cd1b9edffd3af namespace-entities-gate.log
8258e57b86805096a3e08fd9b63e5493d1f18d90e7d2ee5a0721d36c66b56824 namespace-entities-tests.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"baseline": "1819c91",
"implementation": "ad9282e",
"rust_tests": 325,
"workspace_target": "/home/bailey/github/PolyXML/target/followup-namespace-verification",
"command": "PATH=/tmp/polyxml-corpus-tools/node_modules/.bin:$PATH CARGO_TARGET_DIR=/home/bailey/github/PolyXML/target/followup-namespace-verification POLYXML_MEMCAP_BACKEND=systemd CARGO_BUILD_JOBS=1 DOTNET_ROLL_FORWARD=Major scripts/memcap.sh scripts/gate.sh",
"rustc": "rustc 1.99.0 (b940084d7 2026-09-28)\nbinary: rustc\ncommit-hash: b940084d7eb6a299eb4bfeb8e34901bc051e7ac4\ncommit-date: 2026-09-28\nhost: x86_64-unknown-linux-gnu\nrelease: 1.99.0\nLLVM version: 23.1.1\n"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
memcap: <=7762 MiB (60% of 12937 MiB available) [systemd MemoryMax, swap off]
Compiling polyxml v0.34.6 (/home/bailey/github/polyxml-namespace-entities/crates/polyxml-core)
Finished `test` profile [unoptimized + debuginfo] target(s) in 6.78s
Running tests/test_namespace_entities.rs (/home/bailey/github/PolyXML/target/followup-borrow-verification/debug/deps/test_namespace_entities-4bd83b729ca4f37e)

running 3 tests
test escaped_namespace_bindings_match_strict_root_and_round_trip ... FAILED
test undefined_entities_cannot_form_namespace_bindings ... FAILED
test entity_decoding_applies_to_inherited_and_local_xsi_type_bindings ... FAILED

failures:

---- escaped_namespace_bindings_match_strict_root_and_round_trip stdout ----

thread 'escaped_namespace_bindings_match_strict_root_and_round_trip' (502620) panicked at crates/polyxml-core/tests/test_namespace_entities.rs:27:70:
called `Result::unwrap()` on an `Err` value: SchemaError("Unexpected root element {urn:polyxml:A&amp;B}Root")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

---- undefined_entities_cannot_form_namespace_bindings stdout ----

thread 'undefined_entities_cannot_form_namespace_bindings' (502621) panicked at crates/polyxml-core/tests/test_namespace_entities.rs:75:9:
<Root xmlns='urn:polyxml:&undefined;'/>

---- entity_decoding_applies_to_inherited_and_local_xsi_type_bindings stdout ----

thread 'entity_decoding_applies_to_inherited_and_local_xsi_type_bindings' (502619) panicked at crates/polyxml-core/tests/test_namespace_entities.rs:51:59:
called `Result::unwrap()` on an `Err` value: SchemaError("xsi:type=\"t:Derived\" does not match any known derivation of 'Root' (known: Derived)")


failures:
entity_decoding_applies_to_inherited_and_local_xsi_type_bindings
escaped_namespace_bindings_match_strict_root_and_round_trip
undefined_entities_cannot_form_namespace_bindings

test result: FAILED. 0 passed; 3 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `-p polyxml --test test_namespace_entities`
Loading