Skip to content
Merged
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
2 changes: 1 addition & 1 deletion rustez-py/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion rustez-py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 29 additions & 0 deletions rustez/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions rustez/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
[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"
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"] }
Expand Down
8 changes: 5 additions & 3 deletions rustez/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,7 @@ impl<'a> ConfigManager<'a> {
/// ```
pub async fn diff_against(&mut self, rb_id: u32) -> Result<Option<String>, 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() {
Expand Down Expand Up @@ -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;
}
}
Expand Down
32 changes: 29 additions & 3 deletions rustez/src/facts/chassis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub(crate) fn parse_serial_number(xml: &str) -> Option<String> {
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) {
Expand All @@ -33,17 +34,27 @@ pub(crate) fn parse_serial_number(xml: &str) -> Option<String> {
_ => {}
}
}
// 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)) => {
let local = tag.local_name();
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 {
Expand Down Expand Up @@ -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-inventory>
<chassis>
<name>Chassis</name>
<serial-number>CY&amp;02&lt;16</serial-number>
</chassis>
</chassis-inventory>"#;

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#"<chassis-inventory>
Expand Down
54 changes: 51 additions & 3 deletions rustez/src/facts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod chassis;
pub mod personality;
pub mod routing_engine;
pub mod software;
mod xml_entity;

use std::time::Duration;

Expand Down Expand Up @@ -180,21 +181,43 @@ pub fn unwrap_multi_re(xml: &str) -> Vec<(Option<String>, 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('"', "&quot;"),
);
item_content.push('"');
}
item_content.push('>');
}
}
}
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("");
Expand Down Expand Up @@ -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 (`&amp;`) 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#"<multi-routing-engine-results>
<multi-routing-engine-item>
<re-name>node&amp;0</re-name>
<software-information>
<host-name>a&amp;b&lt;c</host-name>
</software-information>
</multi-routing-engine-item>
</multi-routing-engine-results>"#;

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&amp;b&lt;c"));
let info = software::parse_software_info(&items[0].1);
assert_eq!(info.hostname.as_deref(), Some("a&b<c"));
}

#[test]
fn test_unwrap_multi_re_without_wrapper() {
let xml = r#"<software-information>
Expand Down
63 changes: 46 additions & 17 deletions rustez/src/facts/routing_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub(crate) fn parse_route_engines(xml: &str) -> Vec<RouteEngine> {
let mut current_engine: Option<RouteEngine> = 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) {
Expand All @@ -54,26 +55,19 @@ pub(crate) fn parse_route_engines(xml: &str) -> Vec<RouteEngine> {
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::<u32>() {
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)) => {
Expand All @@ -85,6 +79,24 @@ pub(crate) fn parse_route_engines(xml: &str) -> Vec<RouteEngine> {
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::<u32>() {
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();
}
}
Expand Down Expand Up @@ -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#"<route-engine-information>
<route-engine>
<slot>0</slot>
<status>OK</status>
<model>RE&amp;A&lt;B</model>
</route-engine>
</route-engine-information>"#;

let engines = parse_route_engines(xml);
assert_eq!(engines.len(), 1);
assert_eq!(engines[0].model.as_deref(), Some("RE&A<B"));
}
}
Loading
Loading