diff --git a/rustez-py/Cargo.toml b/rustez-py/Cargo.toml index 8b15c89..74b44ca 100644 --- a/rustez-py/Cargo.toml +++ b/rustez-py/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustez-py" -version = "0.12.0" +version = "0.12.1" edition = "2021" authors = ["fastrevmd-lab"] description = "Python bindings for rustEZ — pip install rustez" diff --git a/rustez-py/pyproject.toml b/rustez-py/pyproject.toml index 8efa03a..55977d4 100644 --- a/rustez-py/pyproject.toml +++ b/rustez-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "rustez" -version = "0.12.0" +version = "0.12.1" description = "Python bindings for rustEZ — Junos device automation built on Rust" requires-python = ">=3.9" license = "MIT OR Apache-2.0" diff --git a/rustez/CHANGELOG.md b/rustez/CHANGELOG.md index f3a9581..d6cd079 100644 --- a/rustez/CHANGELOG.md +++ b/rustez/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to the `rustez` crate are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.1] — 2026-07-02 + +### Security + +- **Upgraded `quick-xml` `0.37` → `0.41`** — closes **RUSTSEC-2026-0194** + (quadratic duplicate-attribute-name scan) and **RUSTSEC-2026-0195** + (unbounded namespace-declaration allocation / memory-exhaustion DoS). Both + are reachable on the fact-parsing path, which decodes device-supplied XML. + +### Fixed + +- **Fact parsers no longer truncate values containing XML entities.** Since + quick-xml 0.38, entity references (`&`, `<`, `&`, …) stream as + separate `Event::GeneralRef` events instead of arriving inside `Text`. The + four fact-parser reader loops (`facts/mod.rs`, `chassis.rs`, `software.rs`, + `routing_engine.rs`) now accumulate `Text` + resolve `GeneralRef` and flush + on the closing tag, so a Junos value such as a description or config + fragment containing `&`/`<`/`>` round-trips correctly. Added entity + round-trip regression tests. `unwrap_multi_re` keeps entities verbatim in + reconstructed per-RE XML (and now escapes reconstructed attribute-value + quotes) so downstream re-parsing stays well-formed. + +### Changed + +- Bumped `rustnetconf` dependency to `0.12.3` (pulls its own quick-xml 0.41 + fix for the same advisories). +- **MSRV raised to 1.79** (required by quick-xml ≥ 0.40). + ## [0.12.0] — 2026-05-18 ### Added @@ -30,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 was `AcceptAll`. Since `rustnetconf 0.11` the default has been `RejectAll` (fail-closed); the docs now reflect this. +[0.12.1]: https://github.com/fastrevmd-lab/rustEZ/compare/v0.12.0...v0.12.1 [0.12.0]: https://github.com/fastrevmd-lab/rustEZ/compare/v0.11.0...v0.12.0 ## [0.11.0] — 2026-05-18 diff --git a/rustez/Cargo.toml b/rustez/Cargo.toml index eaa2ceb..7c73719 100644 --- a/rustez/Cargo.toml +++ b/rustez/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustez" -version = "0.12.0" +version = "0.12.1" edition = "2021" authors = ["fastrevmd-lab"] description = "A Rust replacement for Juniper PyEZ — async-first Junos automation built on rustnetconf" @@ -8,12 +8,12 @@ license = "MIT OR Apache-2.0" repository = "https://github.com/fastrevmd-lab/rustEZ" keywords = ["junos", "juniper", "netconf", "network", "automation"] categories = ["network-programming"] -rust-version = "1.75" +rust-version = "1.79" [dependencies] -rustnetconf = "0.12" +rustnetconf = "0.12.3" tokio = { version = "1", features = ["full"] } -quick-xml = "0.37" +quick-xml = "0.41" thiserror = "2" tracing = "0.1" serde = { version = "1", features = ["derive"] } diff --git a/rustez/src/config.rs b/rustez/src/config.rs index 5cba738..6fd0616 100644 --- a/rustez/src/config.rs +++ b/rustez/src/config.rs @@ -167,8 +167,7 @@ impl<'a> ConfigManager<'a> { /// ``` pub async fn diff_against(&mut self, rb_id: u32) -> Result, RustEzError> { let timeout = self.timeout; - let response: String = - timed(timeout, self.client.get_configuration_compare(rb_id)).await?; + let response: String = timed(timeout, self.client.get_configuration_compare(rb_id)).await?; let diff = parse_configuration_output(&response); if diff.is_empty() { @@ -268,7 +267,10 @@ impl<'a> ConfigManager<'a> { return; } let timeout = self.timeout; - if timed(timeout, self.client.close_configuration()).await.is_ok() { + if timed(timeout, self.client.close_configuration()) + .await + .is_ok() + { *self.config_db_open = false; } } diff --git a/rustez/src/facts/chassis.rs b/rustez/src/facts/chassis.rs index b9a8d66..dcb05c0 100644 --- a/rustez/src/facts/chassis.rs +++ b/rustez/src/facts/chassis.rs @@ -13,6 +13,7 @@ pub(crate) fn parse_serial_number(xml: &str) -> Option { let mut in_chassis = false; let mut in_serial = false; let mut depth: u32 = 0; + let mut serial = String::new(); loop { match reader.read_event_into(&mut buf) { @@ -33,10 +34,15 @@ pub(crate) fn parse_serial_number(xml: &str) -> Option { _ => {} } } + // Since quick-xml 0.38, a serial containing an entity (`&`) splits + // across Text/GeneralRef events, so accumulate and flush on the + // closing tag rather than returning on the first Text event. Ok(Event::Text(ref text)) if in_serial => { - let value = text.unescape().unwrap_or_default().trim().to_string(); - if !value.is_empty() { - return Some(value); + serial.push_str(&text.decode().unwrap_or_default()); + } + Ok(Event::GeneralRef(ref entity)) if in_serial => { + if let Some(resolved) = super::xml_entity::resolve_entity_ref(entity) { + serial.push_str(&resolved); } } Ok(Event::End(ref tag)) => { @@ -44,6 +50,11 @@ pub(crate) fn parse_serial_number(xml: &str) -> Option { let name = std::str::from_utf8(local.as_ref()).unwrap_or(""); if name == "serial-number" { in_serial = false; + let trimmed = serial.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + serial.clear(); } else if name == "chassis" { in_chassis = false; } else if in_chassis { @@ -85,6 +96,21 @@ mod tests { assert_eq!(serial.as_deref(), Some("CY0216AF0077")); } + #[test] + fn test_parse_serial_number_with_entities() { + // quick-xml 0.38+ streams entities as separate GeneralRef events; + // a serial containing `&`/`<` must be stitched back, not truncated. + let xml = r#" + + Chassis + CY&02<16 + +"#; + + let serial = parse_serial_number(xml); + assert_eq!(serial.as_deref(), Some("CY&02<16")); + } + #[test] fn test_parse_serial_number_missing() { let xml = r#" diff --git a/rustez/src/facts/mod.rs b/rustez/src/facts/mod.rs index a014c3b..de23a40 100644 --- a/rustez/src/facts/mod.rs +++ b/rustez/src/facts/mod.rs @@ -7,6 +7,7 @@ pub mod chassis; pub mod personality; pub mod routing_engine; pub mod software; +mod xml_entity; use std::time::Duration; @@ -180,7 +181,12 @@ pub fn unwrap_multi_re(xml: &str) -> Vec<(Option, String)> { item_content .push_str(std::str::from_utf8(attr.key.as_ref()).unwrap_or("")); item_content.push_str("=\""); - item_content.push_str(&String::from_utf8_lossy(&attr.value)); + // attr.value is the raw (already entity-escaped) + // wire value, so only the double-quote delimiter + // needs escaping — never re-escape `&`. + item_content.push_str( + &String::from_utf8_lossy(&attr.value).replace('"', """), + ); item_content.push('"'); } item_content.push('>'); @@ -188,13 +194,30 @@ pub fn unwrap_multi_re(xml: &str) -> Vec<(Option, String)> { } } Ok(Event::Text(ref text)) => { - let value = text.unescape().unwrap_or_default().to_string(); + // Since quick-xml 0.38, Text events never contain entity refs + // (those arrive as GeneralRef), so decode() handles encoding only. + let value = text.decode().unwrap_or_default(); if in_re_name { - current_re_name = Some(value); + current_re_name + .get_or_insert_with(String::new) + .push_str(&value); } else if capturing { item_content.push_str(&value); } } + Ok(Event::GeneralRef(ref entity)) if in_re_name => { + // re-name is a leaf value: resolve the entity to its text. + if let Some(resolved) = xml_entity::resolve_entity_ref(entity) { + current_re_name + .get_or_insert_with(String::new) + .push_str(&resolved); + } + } + Ok(Event::GeneralRef(ref entity)) if capturing => { + // item_content is reconstructed XML re-parsed downstream: keep the + // reference escaped verbatim so the fragment stays well-formed. + item_content.push_str(&xml_entity::raw_entity_ref(entity)); + } Ok(Event::Empty(ref tag)) if capturing => { let local = tag.local_name(); let name = std::str::from_utf8(local.as_ref()).unwrap_or(""); @@ -277,6 +300,31 @@ mod tests { assert!(items[1].1.contains("node1")); } + #[test] + fn test_unwrap_multi_re_preserves_entities() { + // The reconstructed per-RE content is re-parsed downstream, so entity + // refs must be kept verbatim (`&`) to stay well-formed; re-name is + // a leaf value, so its entity resolves. Regression for quick-xml 0.38+ + // GeneralRef handling. + let xml = r#" + + node&0 + + a&b<c + + +"#; + + let items = unwrap_multi_re(xml); + assert_eq!(items.len(), 1); + // re-name value is resolved. + assert_eq!(items[0].0.as_deref(), Some("node&0")); + // Inner XML keeps the entity escaped so it re-parses correctly. + assert!(items[0].1.contains("a&b<c")); + let info = software::parse_software_info(&items[0].1); + assert_eq!(info.hostname.as_deref(), Some("a&b diff --git a/rustez/src/facts/routing_engine.rs b/rustez/src/facts/routing_engine.rs index de4cca3..fb0a851 100644 --- a/rustez/src/facts/routing_engine.rs +++ b/rustez/src/facts/routing_engine.rs @@ -30,6 +30,7 @@ pub(crate) fn parse_route_engines(xml: &str) -> Vec { let mut current_engine: Option = None; let mut current_element = String::new(); let mut in_route_engine = false; + let mut text_buf = String::new(); loop { match reader.read_event_into(&mut buf) { @@ -54,26 +55,19 @@ pub(crate) fn parse_route_engines(xml: &str) -> Vec { current_engine = Some(engine); } else if in_route_engine { current_element = name; + // Reset per-element buffer so only this element's own text + // (not inter-element whitespace) is captured. + text_buf.clear(); } } + // Accumulate across Text/GeneralRef; since quick-xml 0.38 entity + // refs arrive as separate GeneralRef events. Flush on closing tag. Ok(Event::Text(ref text)) if in_route_engine => { - let value = text.unescape().unwrap_or_default().trim().to_string(); - if let Some(ref mut engine) = current_engine { - match current_element.as_str() { - "slot" => { - if let Ok(slot) = value.parse::() { - engine.slot = Some(slot); - } - } - "status" => engine.status = value, - "model" => engine.model = Some(value), - "mastership-state" => engine.mastership_state = Some(value), - "up-time" => engine.uptime = Some(value), - "memory-dram-size" | "memory-installed-size" => { - engine.memory_total = Some(value); - } - _ => {} - } + text_buf.push_str(&text.decode().unwrap_or_default()); + } + Ok(Event::GeneralRef(ref entity)) if in_route_engine => { + if let Some(resolved) = super::xml_entity::resolve_entity_ref(entity) { + text_buf.push_str(&resolved); } } Ok(Event::End(ref tag)) => { @@ -85,6 +79,24 @@ pub(crate) fn parse_route_engines(xml: &str) -> Vec { engines.push(engine); } } else if in_route_engine { + let value = std::mem::take(&mut text_buf).trim().to_string(); + if let Some(ref mut engine) = current_engine { + match current_element.as_str() { + "slot" => { + if let Ok(slot) = value.parse::() { + engine.slot = Some(slot); + } + } + "status" => engine.status = value, + "model" => engine.model = Some(value), + "mastership-state" => engine.mastership_state = Some(value), + "up-time" => engine.uptime = Some(value), + "memory-dram-size" | "memory-installed-size" => { + engine.memory_total = Some(value); + } + _ => {} + } + } current_element.clear(); } } @@ -164,4 +176,21 @@ mod tests { assert_eq!(find_master_re(&engines), Some(0)); } + + #[test] + fn test_route_engine_field_with_entities() { + // A field value containing entities must round-trip through the + // Text/GeneralRef split introduced in quick-xml 0.38. + let xml = r#" + + 0 + OK + RE&A<B + +"#; + + let engines = parse_route_engines(xml); + assert_eq!(engines.len(), 1); + assert_eq!(engines[0].model.as_deref(), Some("RE&A SoftwareInfo { let mut current_element = String::new(); let mut in_package_comment = false; let mut in_package_info = false; + let mut text_buf = String::new(); loop { match reader.read_event_into(&mut buf) { @@ -38,11 +39,28 @@ pub(crate) fn parse_software_info(xml: &str) -> SoftwareInfo { _ => {} } current_element = name; + // Reset the per-element text buffer at each Start so only the + // element's own character data (not inter-element whitespace) + // is captured. + text_buf.clear(); } + // Accumulate character data across Text/GeneralRef events. Since + // quick-xml 0.38, entity refs (`&`, `&`, …) arrive as + // separate GeneralRef events rather than inside Text; the value is + // flushed and dispatched on the element's closing tag. Ok(Event::Text(ref text)) => { - let value = text.unescape().unwrap_or_default().to_string(); - - match current_element.as_str() { + text_buf.push_str(&text.decode().unwrap_or_default()); + } + Ok(Event::GeneralRef(ref entity)) => { + if let Some(resolved) = super::xml_entity::resolve_entity_ref(entity) { + text_buf.push_str(&resolved); + } + } + Ok(Event::End(ref tag)) => { + let local = tag.local_name(); + let name = std::str::from_utf8(local.as_ref()).unwrap_or(""); + let value = std::mem::take(&mut text_buf); + match name { "host-name" => info.hostname = Some(value), "product-model" => info.model = Some(value), "junos-version" => info.version = Some(value), @@ -55,10 +73,6 @@ pub(crate) fn parse_software_info(xml: &str) -> SoftwareInfo { } _ => {} } - } - Ok(Event::End(ref tag)) => { - let local = tag.local_name(); - let name = std::str::from_utf8(local.as_ref()).unwrap_or(""); match name { "package-information" => { in_package_info = false; @@ -149,6 +163,22 @@ mod tests { assert_eq!(info.version.as_deref(), Some("12.3R12.4")); } + #[test] + fn test_software_info_with_entities() { + // A host-name / comment containing XML entities must round-trip: + // quick-xml 0.38+ splits entity refs into GeneralRef events, so a + // naive decode would truncate the value at the first `&`. + let xml = r#" + a&b<c + vSRX + 21.4R3.15 +"#; + + let info = parse_software_info(xml); + assert_eq!(info.hostname.as_deref(), Some("a&b diff --git a/rustez/src/facts/xml_entity.rs b/rustez/src/facts/xml_entity.rs new file mode 100644 index 0000000..97a7337 --- /dev/null +++ b/rustez/src/facts/xml_entity.rs @@ -0,0 +1,33 @@ +//! Helpers for quick-xml 0.38+ entity-reference events. +//! +//! Since quick-xml 0.38, entity references (`&`, `&`, …) no longer +//! arrive inside `Event::Text` — they stream as separate `Event::GeneralRef` +//! events. Fact-parser reader loops must resolve these and stitch them back +//! into the surrounding value, otherwise any Junos fact containing an entity +//! (descriptions, URLs, config text with `&`/`<`/`>`) is silently truncated. + +use quick_xml::events::BytesRef; + +/// Resolve a general entity reference to its decoded text value. +/// +/// Handles numeric character references (`&`, `&`) and the five +/// predefined XML entities (`amp`, `lt`, `gt`, `apos`, `quot`). Returns +/// `None` for unknown user-defined entities, which callers skip (matching +/// the old `unescape()` behavior of not inventing content). +pub(crate) fn resolve_entity_ref(entity: &BytesRef<'_>) -> Option { + if let Ok(Some(ch)) = entity.resolve_char_ref() { + return Some(ch.to_string()); + } + let name = entity.decode().ok()?; + quick_xml::escape::resolve_predefined_entity(&name).map(|s| s.to_string()) +} + +/// Reconstruct the raw wire form (`&name;`) of an entity reference. +/// +/// Used when rebuilding inner XML verbatim (e.g. per-RE content that is +/// re-parsed downstream): the reference is kept escaped so the reconstructed +/// fragment stays well-formed and round-trips exactly, including user-defined +/// entities we cannot resolve. +pub(crate) fn raw_entity_ref(entity: &BytesRef<'_>) -> String { + format!("&{};", entity.decode().unwrap_or_default()) +}