pub mod filter;
pub mod operations;
use crate::error::{ProtocolError, RpcError};
use crate::rpc::operations::escape_xml_text;
use crate::types::{ErrorSeverity, ErrorTag, RpcErrorType};
pub fn validate_xml_fragment(xml: &str) -> Result<(), ProtocolError> {
if xml.is_empty() {
return Ok(());
}
use quick_xml::events::Event;
use quick_xml::Reader;
let wrapped = format!("<_>{xml}</_>");
let mut reader = Reader::from_str(&wrapped);
reader.config_mut().check_end_names = true;
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Eof) => break,
Err(e) => {
return Err(ProtocolError::Xml(format!(
"XML fragment is not well-formed: {e}"
)));
}
_ => {}
}
buf.clear();
}
Ok(())
}
#[cfg(test)]
mod xml_validate_tests {
use super::*;
#[test]
fn test_valid_xml_fragment() {
assert!(validate_xml_fragment(
"<interfaces><interface><name>ge-0/0/0</name></interface></interfaces>"
)
.is_ok());
}
#[test]
fn test_valid_self_closing() {
assert!(validate_xml_fragment("<filter/>").is_ok());
}
#[test]
fn test_valid_multiple_siblings() {
assert!(validate_xml_fragment("<a/><b/><c/>").is_ok());
}
#[test]
fn test_empty_string_is_valid() {
assert!(validate_xml_fragment("").is_ok());
}
#[test]
fn test_unclosed_tag_is_invalid() {
let result = validate_xml_fragment("<unclosed>");
assert!(result.is_err(), "unclosed tag should fail validation");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("not well-formed"),
"error should mention not well-formed: {err}"
);
}
#[test]
fn test_mismatched_tags_is_invalid() {
let result = validate_xml_fragment("<a></b>");
assert!(result.is_err(), "mismatched tags should fail validation");
}
#[test]
fn test_malformed_attribute_is_invalid() {
let result = validate_xml_fragment("<a b=broken>");
assert!(
result.is_err(),
"malformed attribute should fail validation"
);
}
}
#[derive(Debug)]
pub enum RpcReply {
Data(String),
DataWithWarnings(String, Vec<RpcErrorInfo>),
Ok,
OkWithWarnings(Vec<RpcErrorInfo>),
}
#[derive(Debug, Clone)]
pub struct RpcErrorInfo {
pub error_type: Option<RpcErrorType>,
pub tag: ErrorTag,
pub severity: Option<ErrorSeverity>,
pub app_tag: Option<String>,
pub path: Option<String>,
pub message: String,
pub info: Option<String>,
}
fn reescape_attr_value(raw: &str) -> String {
let decoded = quick_xml::escape::unescape(raw)
.map(|cow| cow.into_owned())
.unwrap_or_else(|_| raw.to_string());
crate::rpc::operations::escape_xml_attr(&decoded)
}
fn local_name_of(qname: &[u8]) -> &[u8] {
match qname.iter().position(|&byte| byte == b':') {
Some(colon_idx) => &qname[colon_idx + 1..],
None => qname,
}
}
fn repair_unclosed_routing_engine(xml: &str) -> Option<String> {
use quick_xml::events::{BytesEnd, Event};
use quick_xml::Reader;
use quick_xml::Writer;
let mut reader = Reader::from_str(xml);
reader.config_mut().check_end_names = false;
let mut writer = Writer::new(Vec::new());
let mut stack: Vec<Vec<u8>> = Vec::new();
loop {
match reader.read_event() {
Ok(Event::Start(ref tag)) => {
let tag_name = tag.name();
let local_name = tag_name.local_name();
if local_name.as_ref() == b"routing-engine" {
if let Some(top_qname) = stack.last() {
if local_name_of(top_qname) == b"routing-engine" {
if writer
.write_event(Event::End(BytesEnd::new(
std::str::from_utf8(top_qname).unwrap_or("routing-engine"),
)))
.is_err()
{
return None;
}
stack.pop();
}
}
}
stack.push(tag_name.as_ref().to_vec());
if writer.write_event(Event::Start(tag.clone())).is_err() {
return None;
}
}
Ok(Event::End(ref tag)) => {
let tag_name = tag.name();
let local_name = tag_name.local_name();
while let Some(top_qname) = stack.last() {
let top_local = local_name_of(top_qname);
if top_local != local_name.as_ref() && top_local == b"routing-engine" {
if writer
.write_event(Event::End(BytesEnd::new(
std::str::from_utf8(top_qname).unwrap_or("routing-engine"),
)))
.is_err()
{
return None;
}
stack.pop();
} else {
break;
}
}
let top_qname = stack.last()?;
if local_name_of(top_qname) == local_name.as_ref() {
stack.pop();
if writer.write_event(Event::End(tag.clone())).is_err() {
return None;
}
} else {
return None;
}
}
Ok(Event::Empty(ref tag)) => {
if writer.write_event(Event::Empty(tag.clone())).is_err() {
return None;
}
}
Ok(Event::Text(ref text)) => {
if writer.write_event(Event::Text(text.clone())).is_err() {
return None;
}
}
Ok(Event::CData(ref cdata)) => {
if writer.write_event(Event::CData(cdata.clone())).is_err() {
return None;
}
}
Ok(Event::GeneralRef(ref entity)) => {
if writer
.write_event(Event::GeneralRef(entity.clone()))
.is_err()
{
return None;
}
}
Ok(Event::Comment(ref comment)) => {
if writer.write_event(Event::Comment(comment.clone())).is_err() {
return None;
}
}
Ok(Event::Decl(ref decl)) => {
if writer.write_event(Event::Decl(decl.clone())).is_err() {
return None;
}
}
Ok(Event::PI(ref pi)) => {
if writer.write_event(Event::PI(pi.clone())).is_err() {
return None;
}
}
Ok(Event::DocType(ref doctype)) => {
if writer.write_event(Event::DocType(doctype.clone())).is_err() {
return None;
}
}
Ok(Event::Eof) => {
if !stack.is_empty() {
return None;
}
break;
}
Err(_) => return None,
}
}
String::from_utf8(writer.into_inner()).ok()
}
fn parse_rpc_reply_strict(xml: &str, expected_message_id: &str) -> Result<RpcReply, RpcError> {
use crate::xml_entity::{raw_entity_ref, resolve_entity_ref};
use quick_xml::events::Event;
use quick_xml::Reader;
let mut reader = Reader::from_str(xml);
let mut buf = Vec::new();
let mut found_message_id: Option<String> = None;
let mut found_ok = false;
let mut data_content: Option<String> = None;
let mut errors: Vec<RpcErrorInfo> = Vec::new();
let mut in_rpc_error = false;
let mut in_rpc_reply = false;
let mut in_data = false;
let mut data_depth: u32 = 0;
let mut data_xml = String::new();
let mut current_error: Option<RpcErrorBuilder> = None;
let mut current_field: Option<ErrorField> = None;
let mut field_text = String::new();
let mut in_error_info = false;
let mut _error_info_depth: u32 = 0;
let mut error_info_xml = String::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref tag)) => {
let local = tag.local_name();
let name = std::str::from_utf8(local.as_ref()).unwrap_or("");
match name {
"rpc-reply" => {
in_rpc_reply = true;
for attr in tag.attributes().flatten() {
if attr.key.local_name().as_ref() == b"message-id" {
found_message_id =
Some(String::from_utf8_lossy(&attr.value).to_string());
}
}
}
"data" if in_rpc_reply && !in_rpc_error => {
in_data = true;
data_depth = 1;
data_xml.clear();
}
"rpc-error" if in_rpc_reply => {
in_rpc_error = true;
current_error = Some(RpcErrorBuilder::new());
}
_ if in_data => {
data_depth += 1;
let tag_name = tag.name();
let qname = std::str::from_utf8(tag_name.as_ref()).unwrap_or(name);
data_xml.push('<');
data_xml.push_str(qname);
for attr in tag.attributes().flatten() {
data_xml.push(' ');
data_xml.push_str(std::str::from_utf8(attr.key.as_ref()).unwrap_or(""));
data_xml.push_str("=\"");
data_xml.push_str(&reescape_attr_value(&String::from_utf8_lossy(
&attr.value,
)));
data_xml.push('"');
}
data_xml.push('>');
}
_ if in_error_info => {
_error_info_depth += 1;
let tag_name = tag.name();
let qname = std::str::from_utf8(tag_name.as_ref()).unwrap_or(name);
error_info_xml.push('<');
error_info_xml.push_str(qname);
error_info_xml.push('>');
}
_ if in_rpc_error => {
if name == "error-info" {
in_error_info = true;
_error_info_depth = 1;
error_info_xml.clear();
} else {
current_field = ErrorField::from_name(name);
field_text.clear();
}
}
_ => {}
}
}
Ok(Event::Empty(ref tag)) => {
let local = tag.local_name();
let name = std::str::from_utf8(local.as_ref()).unwrap_or("");
if name == "ok" && in_rpc_reply {
found_ok = true;
} else if in_data {
let tag_name = tag.name();
let qname = std::str::from_utf8(tag_name.as_ref()).unwrap_or(name);
data_xml.push('<');
data_xml.push_str(qname);
for attr in tag.attributes().flatten() {
data_xml.push(' ');
data_xml.push_str(std::str::from_utf8(attr.key.as_ref()).unwrap_or(""));
data_xml.push_str("=\"");
data_xml
.push_str(&reescape_attr_value(&String::from_utf8_lossy(&attr.value)));
data_xml.push('"');
}
data_xml.push_str("/>");
} else if in_error_info {
let tag_name = tag.name();
let qname = std::str::from_utf8(tag_name.as_ref()).unwrap_or(name);
error_info_xml.push('<');
error_info_xml.push_str(qname);
error_info_xml.push_str("/>");
}
}
Ok(Event::CData(ref cdata)) => {
let value = cdata.decode().unwrap_or_default();
if in_data {
data_xml.push_str(&escape_xml_text(&value));
} else if in_error_info {
error_info_xml.push_str(&escape_xml_text(&value));
} else if in_rpc_error && current_field.is_some() {
field_text.push_str(&value);
}
}
Ok(Event::Text(ref text)) => {
let value = text.decode().unwrap_or_default();
if in_data {
data_xml.push_str(&escape_xml_text(&value));
} else if in_error_info {
error_info_xml.push_str(&escape_xml_text(&value));
} else if in_rpc_error && current_field.is_some() {
field_text.push_str(&value);
}
}
Ok(Event::GeneralRef(ref entity)) => {
if in_data {
data_xml.push_str(&raw_entity_ref(entity));
} else if in_error_info {
error_info_xml.push_str(&raw_entity_ref(entity));
} else if in_rpc_error && current_field.is_some() {
if let Some(resolved) = resolve_entity_ref(entity) {
field_text.push_str(&resolved);
}
}
}
Ok(Event::End(ref tag)) => {
let local = tag.local_name();
let name = std::str::from_utf8(local.as_ref()).unwrap_or("");
match name {
"rpc-reply" => {
in_rpc_reply = false;
}
"data" if in_data && data_depth == 1 => {
in_data = false;
data_content = Some(data_xml.clone());
}
"rpc-error" => {
in_rpc_error = false;
if let Some(builder) = current_error.take() {
errors.push(builder.build());
}
}
_ if in_data => {
data_depth -= 1;
let tag_name = tag.name();
let qname = std::str::from_utf8(tag_name.as_ref()).unwrap_or(name);
data_xml.push_str("</");
data_xml.push_str(qname);
data_xml.push('>');
}
"error-info" if in_error_info => {
in_error_info = false;
if let Some(ref mut builder) = current_error {
let trimmed = error_info_xml.trim().to_string();
if !trimmed.is_empty() {
builder.info = Some(trimmed);
}
}
}
_ if in_error_info => {
_error_info_depth -= 1;
let tag_name = tag.name();
let qname = std::str::from_utf8(tag_name.as_ref()).unwrap_or(name);
error_info_xml.push_str("</");
error_info_xml.push_str(qname);
error_info_xml.push('>');
}
_ if in_rpc_error => {
if let (Some(ref mut builder), Some(field)) =
(&mut current_error, current_field.take())
{
builder.set_field(&field, &field_text);
}
field_text.clear();
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(RpcError::ParseError(format!("XML parse error: {e}"))),
_ => {}
}
buf.clear();
}
if let Some(ref msg_id) = found_message_id {
if msg_id != expected_message_id {
return Err(RpcError::MessageIdMismatch {
expected: expected_message_id.to_string(),
actual: msg_id.clone(),
});
}
}
let (hard_errors, warnings): (Vec<_>, Vec<_>) = errors
.into_iter()
.partition(|e| e.severity != Some(ErrorSeverity::Warning));
if let Some(first_error) = hard_errors.into_iter().next() {
return Err(RpcError::ServerError {
error_type: first_error.error_type,
tag: first_error.tag,
severity: first_error.severity,
app_tag: first_error.app_tag,
path: first_error.path,
message: first_error.message,
info: first_error.info,
});
}
if !warnings.is_empty() {
for w in &warnings {
tracing::warn!(tag = ?w.tag, message = %w.message, "device returned RPC warning");
}
}
if let Some(data) = data_content {
if warnings.is_empty() {
return Ok(RpcReply::Data(data));
}
return Ok(RpcReply::DataWithWarnings(data, warnings));
}
if found_ok {
if warnings.is_empty() {
return Ok(RpcReply::Ok);
}
return Ok(RpcReply::OkWithWarnings(warnings));
}
if in_rpc_reply || found_message_id.is_some() {
if let Some(inner) = extract_rpc_reply_inner_content(xml) {
return Ok(RpcReply::Data(inner));
}
}
if in_rpc_reply || found_message_id.is_some() {
return Ok(RpcReply::Ok);
}
Err(RpcError::ParseError(
"rpc-reply contained no <ok/>, <data>, or <rpc-error>".to_string(),
))
}
pub fn parse_rpc_reply(xml: &str, expected_message_id: &str) -> Result<RpcReply, RpcError> {
match parse_rpc_reply_strict(xml, expected_message_id) {
Ok(reply) => Ok(reply),
Err(RpcError::ParseError(ref e)) if xml.contains("routing-engine") => {
if let Some(repaired) = repair_unclosed_routing_engine(xml) {
parse_rpc_reply_strict(&repaired, expected_message_id)
} else {
Err(RpcError::ParseError(e.clone()))
}
}
Err(other) => Err(other),
}
}
#[allow(clippy::enum_variant_names)]
enum ErrorField {
ErrorType,
ErrorTag,
ErrorSeverity,
ErrorAppTag,
ErrorPath,
ErrorMessage,
ErrorInfo,
}
impl ErrorField {
fn from_name(name: &str) -> Option<Self> {
match name {
"error-type" => Some(ErrorField::ErrorType),
"error-tag" => Some(ErrorField::ErrorTag),
"error-severity" => Some(ErrorField::ErrorSeverity),
"error-app-tag" => Some(ErrorField::ErrorAppTag),
"error-path" => Some(ErrorField::ErrorPath),
"error-message" => Some(ErrorField::ErrorMessage),
"error-info" => Some(ErrorField::ErrorInfo),
_ => None,
}
}
}
struct RpcErrorBuilder {
error_type: Option<RpcErrorType>,
tag: Option<ErrorTag>,
severity: Option<ErrorSeverity>,
app_tag: Option<String>,
path: Option<String>,
message: Option<String>,
info: Option<String>,
}
impl RpcErrorBuilder {
fn new() -> Self {
Self {
error_type: None,
tag: None,
severity: None,
app_tag: None,
path: None,
message: None,
info: None,
}
}
fn set_field(&mut self, field: &ErrorField, value: &str) {
match field {
ErrorField::ErrorType => {
self.error_type = Some(match value {
"transport" => RpcErrorType::Transport,
"rpc" => RpcErrorType::Rpc,
"protocol" => RpcErrorType::Protocol,
"application" => RpcErrorType::Application,
_ => RpcErrorType::Application,
});
}
ErrorField::ErrorTag => {
self.tag = Some(value.parse().unwrap_or(ErrorTag::Other(value.to_string())));
}
ErrorField::ErrorSeverity => {
self.severity = Some(match value {
"warning" => ErrorSeverity::Warning,
_ => ErrorSeverity::Error,
});
}
ErrorField::ErrorAppTag => {
self.app_tag = Some(value.to_string());
}
ErrorField::ErrorPath => {
self.path = Some(value.to_string());
}
ErrorField::ErrorMessage => {
self.message = Some(value.to_string());
}
ErrorField::ErrorInfo => {
self.info = Some(value.to_string());
}
}
}
fn build(self) -> RpcErrorInfo {
RpcErrorInfo {
error_type: self.error_type,
tag: self.tag.unwrap_or(ErrorTag::OperationFailed),
severity: self.severity,
app_tag: self.app_tag,
path: self.path,
message: self.message.unwrap_or_else(|| "unknown error".to_string()),
info: self.info,
}
}
}
fn extract_rpc_reply_inner_content(xml: &str) -> Option<String> {
use crate::xml_entity::raw_entity_ref;
use quick_xml::events::Event;
use quick_xml::Reader;
let mut reader = Reader::from_str(xml);
let mut buf = Vec::new();
let mut in_rpc_reply = false;
let mut depth: u32 = 0;
let mut content = String::new();
let mut has_content = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref tag)) => {
let local = tag.local_name();
let name = std::str::from_utf8(local.as_ref()).unwrap_or("");
if name == "rpc-reply" {
in_rpc_reply = true;
} else if in_rpc_reply && (depth > 0 || (name != "ok" && name != "rpc-error")) {
if depth == 0 {
has_content = true;
}
depth += 1;
let tag_name = tag.name();
let qname = std::str::from_utf8(tag_name.as_ref()).unwrap_or(name);
content.push('<');
content.push_str(qname);
for attr in tag.attributes().flatten() {
content.push(' ');
content.push_str(std::str::from_utf8(attr.key.as_ref()).unwrap_or(""));
content.push_str("=\"");
content
.push_str(&reescape_attr_value(&String::from_utf8_lossy(&attr.value)));
content.push('"');
}
content.push('>');
}
}
Ok(Event::Empty(ref tag)) if in_rpc_reply => {
let local = tag.local_name();
let name = std::str::from_utf8(local.as_ref()).unwrap_or("");
if depth > 0 || (name != "ok" && name != "rpc-error") {
if depth == 0 {
has_content = true;
}
let tag_name = tag.name();
let qname = std::str::from_utf8(tag_name.as_ref()).unwrap_or(name);
content.push('<');
content.push_str(qname);
for attr in tag.attributes().flatten() {
content.push(' ');
content.push_str(std::str::from_utf8(attr.key.as_ref()).unwrap_or(""));
content.push_str("=\"");
content
.push_str(&reescape_attr_value(&String::from_utf8_lossy(&attr.value)));
content.push('"');
}
content.push_str("/>");
}
}
Ok(Event::Text(ref text)) if in_rpc_reply && depth > 0 => {
let value = text.decode().unwrap_or_default();
content.push_str(&escape_xml_text(&value));
}
Ok(Event::GeneralRef(ref entity)) if in_rpc_reply && depth > 0 => {
content.push_str(&raw_entity_ref(entity));
}
Ok(Event::CData(ref cdata)) if in_rpc_reply && depth > 0 => {
let value = cdata.decode().unwrap_or_default();
content.push_str(&escape_xml_text(&value));
}
Ok(Event::End(ref tag)) => {
let local = tag.local_name();
let name = std::str::from_utf8(local.as_ref()).unwrap_or("");
if name == "rpc-reply" {
break;
}
if in_rpc_reply && depth > 0 {
depth -= 1;
let tag_name = tag.name();
let qname = std::str::from_utf8(tag_name.as_ref()).unwrap_or(name);
content.push_str("</");
content.push_str(qname);
content.push('>');
}
}
Ok(Event::Eof) => break,
Err(_) => return None,
_ => {}
}
buf.clear();
}
if has_content {
Some(content)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ok_reply() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="1">
<ok/>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "1").unwrap();
assert!(matches!(result, RpcReply::Ok));
}
#[test]
fn test_parse_data_reply() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="2">
<data>
<configuration><interfaces><interface><name>ge-0/0/0</name></interface></interfaces></configuration>
</data>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "2").unwrap();
match result {
RpcReply::Data(data) => {
assert!(data.contains("ge-0/0/0"));
assert!(data.contains("<configuration>"));
}
_ => panic!("expected Data reply"),
}
}
#[test]
fn test_parse_rpc_error() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="3">
<rpc-error>
<error-type>application</error-type>
<error-tag>invalid-value</error-tag>
<error-severity>error</error-severity>
<error-path>/configuration/interfaces/interface[name='ge-0/0/0']</error-path>
<error-message>invalid interface name</error-message>
</rpc-error>
</rpc-reply>"#;
let err = parse_rpc_reply(xml, "3").unwrap_err();
match err {
RpcError::ServerError {
tag, message, path, ..
} => {
assert_eq!(tag, ErrorTag::InvalidValue);
assert_eq!(message, "invalid interface name");
assert!(path.unwrap().contains("ge-0/0/0"));
}
_ => panic!("expected ServerError, got {err:?}"),
}
}
#[test]
fn test_parse_message_id_mismatch() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="99">
<ok/>
</rpc-reply>"#;
let err = parse_rpc_reply(xml, "1").unwrap_err();
assert!(matches!(err, RpcError::MessageIdMismatch { .. }));
}
#[test]
fn test_parse_lock_denied_error() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="5">
<rpc-error>
<error-type>protocol</error-type>
<error-tag>lock-denied</error-tag>
<error-severity>error</error-severity>
<error-message>Lock failed, lock is already held</error-message>
<error-info>session-id: 42</error-info>
</rpc-error>
</rpc-reply>"#;
let err = parse_rpc_reply(xml, "5").unwrap_err();
match err {
RpcError::ServerError {
tag, info, message, ..
} => {
assert_eq!(tag, ErrorTag::LockDenied);
assert!(message.contains("Lock failed"));
assert!(info.unwrap().contains("42"));
}
_ => panic!("expected ServerError"),
}
}
#[test]
fn test_parse_junos_custom_rpc_reply() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="7">
<software-information>
<host-name>vsrx1</host-name>
<product-model>vSRX</product-model>
<product-name>vsrx</product-name>
<junos-version>21.4R3.15</junos-version>
</software-information>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "7").unwrap();
match result {
RpcReply::Data(data) => {
assert!(data.contains("<software-information>"));
assert!(data.contains("vsrx1"));
assert!(data.contains("21.4R3.15"));
}
_ => panic!("expected Data reply for Junos custom RPC"),
}
}
#[test]
fn test_parse_junos_multi_re_reply() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="8">
<multi-routing-engine-results>
<multi-routing-engine-item>
<re-name>node0</re-name>
<software-information>
<host-name>vsrx-node0</host-name>
</software-information>
</multi-routing-engine-item>
</multi-routing-engine-results>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "8").unwrap();
match result {
RpcReply::Data(data) => {
assert!(data.contains("<multi-routing-engine-results>"));
assert!(data.contains("node0"));
}
_ => panic!("expected Data reply for multi-RE response"),
}
}
#[test]
fn test_parse_warning_with_ok() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="10">
<rpc-error>
<error-type>application</error-type>
<error-tag>operation-failed</error-tag>
<error-severity>warning</error-severity>
<error-message>statement not found</error-message>
</rpc-error>
<ok/>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "10").unwrap();
match result {
RpcReply::OkWithWarnings(warnings) => {
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].severity, Some(ErrorSeverity::Warning));
assert!(warnings[0].message.contains("statement not found"));
}
_ => panic!("expected OkWithWarnings, got {result:?}"),
}
}
#[test]
fn test_parse_warning_with_data() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="11">
<rpc-error>
<error-type>application</error-type>
<error-tag>operation-failed</error-tag>
<error-severity>warning</error-severity>
<error-message>some warning</error-message>
</rpc-error>
<data><configuration><system/></configuration></data>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "11").unwrap();
match result {
RpcReply::DataWithWarnings(data, warnings) => {
assert!(data.contains("<configuration>"));
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("some warning"));
}
_ => panic!("expected DataWithWarnings, got {result:?}"),
}
}
#[test]
fn test_parse_mixed_warning_and_error() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="12">
<rpc-error>
<error-type>application</error-type>
<error-tag>operation-failed</error-tag>
<error-severity>warning</error-severity>
<error-message>just a warning</error-message>
</rpc-error>
<rpc-error>
<error-type>application</error-type>
<error-tag>invalid-value</error-tag>
<error-severity>error</error-severity>
<error-message>real error</error-message>
</rpc-error>
</rpc-reply>"#;
let err = parse_rpc_reply(xml, "12").unwrap_err();
match err {
RpcError::ServerError { tag, message, .. } => {
assert_eq!(tag, ErrorTag::InvalidValue);
assert_eq!(message, "real error");
}
_ => panic!("expected ServerError for hard error, got {err:?}"),
}
}
#[test]
fn test_parse_empty_rpc_reply_returns_ok() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="42">
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "42").unwrap();
assert!(matches!(result, RpcReply::Ok));
}
#[test]
fn test_reconstructed_data_reescapes_special_chars() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="1">
<data><description>a & b < c</description></data>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "1").unwrap();
let data = match result {
RpcReply::Data(data) => data,
other => panic!("expected Data, got {other:?}"),
};
assert!(
data.contains("a & b < c"),
"special chars must be re-escaped: {data}"
);
validate_xml_fragment(&data).expect("reconstructed data must be well-formed");
}
#[test]
fn test_reconstructed_junos_inner_reescapes_special_chars() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="2">
<output>value with & ampersand</output>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "2").unwrap();
let data = match result {
RpcReply::Data(data) => data,
other => panic!("expected Data, got {other:?}"),
};
assert!(
data.contains("&"),
"ampersand must stay escaped: {data}"
);
validate_xml_fragment(&data).expect("reconstructed inner content must be well-formed");
}
#[test]
fn test_error_message_with_entities_is_fully_decoded() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="5">
<rpc-error>
<error-type>protocol</error-type>
<error-tag>operation-failed</error-tag>
<error-severity>error</error-severity>
<error-message>syntax error before <get> & after</error-message>
</rpc-error>
</rpc-reply>"#;
let err = parse_rpc_reply(xml, "5").unwrap_err();
match err {
RpcError::ServerError { message, .. } => {
assert_eq!(message, "syntax error before <get> & after");
}
other => panic!("expected ServerError, got {other:?}"),
}
}
#[test]
fn test_data_text_with_char_ref_roundtrips() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="6">
<data><description>A & B < C</description></data>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "6").unwrap();
let data = match result {
RpcReply::Data(data) => data,
other => panic!("expected Data, got {other:?}"),
};
validate_xml_fragment(&data).expect("reconstructed data must be well-formed");
let decoded = quick_xml::escape::unescape(&data).expect("must unescape");
assert!(
decoded.contains("A & B < C"),
"char refs must round-trip: {decoded}"
);
}
#[test]
fn test_error_info_with_entities_stays_well_formed() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="7">
<rpc-error>
<error-type>application</error-type>
<error-tag>operation-failed</error-tag>
<error-severity>error</error-severity>
<error-message>bad element</error-message>
<error-info><bad-element>a & b</bad-element></error-info>
</rpc-error>
</rpc-reply>"#;
let err = parse_rpc_reply(xml, "7").unwrap_err();
match err {
RpcError::ServerError {
info: Some(info), ..
} => {
validate_xml_fragment(&info).expect("error-info must stay well-formed");
let decoded = quick_xml::escape::unescape(&info).expect("must unescape");
assert!(
decoded.contains("a & b"),
"entity in error-info must round-trip: {decoded}"
);
}
other => panic!("expected ServerError with info, got {other:?}"),
}
}
#[test]
fn test_data_preserves_namespace_prefixes() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="8">
<data><if:interfaces xmlns:if="urn:ietf:params:xml:ns:yang:ietf-interfaces"><if:interface/></if:interfaces></data>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "8").unwrap();
let data = match result {
RpcReply::Data(data) => data,
other => panic!("expected Data, got {other:?}"),
};
assert!(
data.contains("<if:interfaces") && data.contains("</if:interfaces>"),
"namespace prefix must be preserved: {data}"
);
assert!(
data.contains("<if:interface/>"),
"prefix on empty element must be preserved: {data}"
);
}
#[test]
fn test_data_preserves_cdata_content() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="9">
<data><description><![CDATA[uplink & transit]]></description></data>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "9").unwrap();
let data = match result {
RpcReply::Data(data) => data,
other => panic!("expected Data, got {other:?}"),
};
validate_xml_fragment(&data).expect("reconstructed data must be well-formed");
let decoded = quick_xml::escape::unescape(&data).expect("must unescape");
assert!(
decoded.contains("uplink & transit"),
"CDATA content must not be dropped: {decoded}"
);
}
#[test]
fn test_error_message_cdata_is_captured() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="10">
<rpc-error>
<error-type>application</error-type>
<error-tag>operation-failed</error-tag>
<error-severity>error</error-severity>
<error-message><![CDATA[bad & input]]></error-message>
</rpc-error>
</rpc-reply>"#;
let err = parse_rpc_reply(xml, "10").unwrap_err();
match err {
RpcError::ServerError { message, .. } => {
assert_eq!(message, "bad & input");
}
other => panic!("expected ServerError, got {other:?}"),
}
}
#[test]
fn test_data_escapes_double_quote_in_attribute() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="11">
<data><x note='he said "up"'/></data>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "11").unwrap();
let data = match result {
RpcReply::Data(data) => data,
other => panic!("expected Data, got {other:?}"),
};
validate_xml_fragment(&data).expect("reconstructed data must be well-formed");
assert!(
data.contains(""up""),
"double quotes in attribute values must be escaped: {data}"
);
}
#[test]
fn test_top_level_empty_element_is_data() {
let xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="12">
<software-information/>
</rpc-reply>"#;
let result = parse_rpc_reply(xml, "12").unwrap();
match result {
RpcReply::Data(data) => {
assert!(
data.contains("<software-information/>"),
"empty element must be captured: {data}"
);
}
other => panic!("expected Data, got {other:?}"),
}
}
#[test]
fn test_parse_cluster_validate_unclosed_routing_engine() {
let xml = include_str!("../../tests/fixtures/commit_check/cluster_validate_success.xml");
let result = parse_rpc_reply(xml, "101").unwrap();
assert!(
matches!(result, RpcReply::Ok | RpcReply::OkWithWarnings(_)),
"cluster validate should parse as Ok, got {result:?}"
);
}
#[test]
fn test_repair_two_node_unclosed_routing_engine() {
let xml = r#"<nc:rpc-reply xmlns:junos="http://xml.juniper.net/junos/25.4R1.12/junos" xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="101">
<routing-engine junos:style="show-name">
<name>node0</name>
<commit-check-success/>
<routing-engine junos:style="show-name">
<name>node1</name>
<commit-check-success/>
<nc:ok/>
</nc:rpc-reply>"#;
let result = parse_rpc_reply(xml, "101").unwrap();
assert!(
matches!(result, RpcReply::Ok | RpcReply::OkWithWarnings(_)),
"two-node cluster validate should parse as Ok, got {result:?}"
);
}
#[test]
fn test_repair_cluster_commit_check_error() {
let xml = r#"<nc:rpc-reply xmlns:junos="http://xml.juniper.net/junos/25.4R1.12/junos" xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="101">
<routing-engine junos:style="show-name">
<name>node0</name>
<nc:rpc-error>
<nc:error-type>application</nc:error-type>
<nc:error-tag>operation-failed</nc:error-tag>
<nc:error-severity>error</nc:error-severity>
<nc:error-message>configuration check-out failed</nc:error-message>
</nc:rpc-error>
</nc:rpc-reply>"#;
let err = parse_rpc_reply(xml, "101").unwrap_err();
match err {
RpcError::ServerError { tag, message, .. } => {
assert_eq!(tag, ErrorTag::OperationFailed);
assert!(
message.contains("configuration check-out failed"),
"error message should be preserved, got: {message}"
);
}
other => panic!("expected ServerError, got {other:?}"),
}
}
#[test]
fn test_well_formed_reply_unaffected_by_repair_path() {
let ok_xml = r#"<nc:rpc-reply xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="1">
<nc:ok/>
</nc:rpc-reply>"#;
let result = parse_rpc_reply(ok_xml, "1").unwrap();
assert!(matches!(result, RpcReply::Ok));
let multi_re_xml = r#"<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="8">
<multi-routing-engine-results>
<multi-routing-engine-item>
<re-name>node0</re-name>
<software-information>
<host-name>vsrx-node0</host-name>
</software-information>
</multi-routing-engine-item>
</multi-routing-engine-results>
</rpc-reply>"#;
let result = parse_rpc_reply(multi_re_xml, "8").unwrap();
assert!(matches!(result, RpcReply::Data(_)));
}
#[test]
fn test_truncated_reply_not_masked_as_success() {
let truncated = r#"<nc:rpc-reply xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="1"><outer><routing-engine><nc:ok/></outer>"#;
let result = parse_rpc_reply(truncated, "1");
assert!(
result.is_err(),
"truncated reply must not be repaired into success, got {result:?}"
);
}
}