diff --git a/crates/netconf-proto/src/lib.rs b/crates/netconf-proto/src/lib.rs index bc969738..f74fb62d 100644 --- a/crates/netconf-proto/src/lib.rs +++ b/crates/netconf-proto/src/lib.rs @@ -31,6 +31,7 @@ pub mod client; pub mod codec; pub mod protocol; pub mod xml_utils; +pub mod xpath; pub mod yang_module_cache; pub mod yang_push; pub mod yanglib; diff --git a/crates/netconf-proto/src/xml_utils.rs b/crates/netconf-proto/src/xml_utils.rs index d2e1a465..c739b56e 100644 --- a/crates/netconf-proto/src/xml_utils.rs +++ b/crates/netconf-proto/src/xml_utils.rs @@ -765,7 +765,7 @@ impl<'a, R: io::BufRead> XmlParser<'a, R> { all_namespaces.insert(prefix, String::from_utf8_lossy(ns).into_owned()); } let path = self.tag_string()?; - let used_namespaces = find_xpath_prefixes(&path); + let used_namespaces = crate::xpath::find_xpath_prefixes(&path); let namespaces: IndexMap = all_namespaces .into_iter() .filter(|(prefix, _)| used_namespaces.contains(prefix)) @@ -814,61 +814,6 @@ impl<'a, R: io::BufRead> XmlParser<'a, R> { } } -/// Find the prefixes used within an Xpath expression (e.g. the `if` in -/// `/if:interfaces/if:interface`). String literals are skipped and axis -/// specifiers (`child::`) are not treated as prefixes. -pub(crate) fn find_xpath_prefixes(xpath: &str) -> HashSet { - let mut prefixes = HashSet::new(); - let mut chars = xpath.char_indices().peekable(); - let mut in_single = false; - let mut in_double = false; - - while let Some((i, c)) = chars.next() { - // Skip over string literals — colons inside them aren't prefixes. - if in_single { - if c == '\'' { - in_single = false; - } - continue; - } - if in_double { - if c == '"' { - in_double = false; - } - continue; - } - match c { - '\'' => in_single = true, - '"' => in_double = true, - c if c.is_ascii_alphabetic() || c == '_' => { - let start = i; - let mut end = i + c.len_utf8(); - while let Some(&(_, nc)) = chars.peek() { - if nc.is_ascii_alphanumeric() || nc == '_' || nc == '-' || nc == '.' { - chars.next(); - end += nc.len_utf8(); - } else { - break; - } - } - // A prefix is an NCName followed by exactly one ':' - // (two colons = axis specifier like `child::`). - if let Some(&(_, ':')) = chars.peek() { - let mut look = chars.clone(); - look.next(); - let is_axis = matches!(look.peek(), Some(&(_, ':'))); - if !is_axis { - prefixes.insert(xpath[start..end].to_string()); - chars.next(); // consume the ':' - } - } - } - _ => {} - } - } - prefixes -} - /// Format a `DateTime` as YANG `date-and-time` (RFC 3339, UTC). pub fn format_datetime(ts: &DateTime) -> String { format!( @@ -1471,176 +1416,6 @@ mod tests { assert!(xml_writer.ns_applied); } - fn set(items: [&str; N]) -> HashSet { - items.iter().map(|s| s.to_string()).collect() - } - - fn assert_prefixes(expr: &str, expected: HashSet) { - assert_eq!( - find_xpath_prefixes(expr), - expected, - "unexpected prefix set for: {expr}" - ); - } - - #[test] - fn test_find_xpath_prefixes_yields_empty_when_no_qnames_present() { - // Empty/whitespace input, unprefixed paths, pure numeric/operator - // expressions, the `current()` function, and bare node tests all - // contain no QNames — so nothing should be reported. - for expr in [ - "", - " \n\t", - "/interfaces/interface/name", - "1 + 2.5 - 3 <= 4 and 5 != 6", - "current()", - "node() | text() | comment() | processing-instruction()", - ] { - assert_prefixes(expr, HashSet::new()); - } - } - - #[test] - fn test_find_xpath_prefixes_test_extracts_prefixes_from_simple_location_paths() { - // Motivating Huawei debug case, RFC 8641 Figure 12 (`/ex:foo`), - // the subscribed-notifications `/int:interfaces` example, - // prefix deduplication, and multi-prefix paths. - let cases: &[(&str, HashSet)] = &[ - ( - "/debug:debug/debug:board-resouce-states/debug:board-resouce-state", - set(["debug"]), - ), - ("/ex:foo", set(["ex"])), - ("/int:interfaces", set(["int"])), - ("/if:interfaces/if:interface/if:name", set(["if"])), - ("/a:x/b:y/c:z", set(["a", "b", "c"])), - ]; - for (expr, expected) in cases { - assert_prefixes(expr, expected.clone()); - } - } - - #[test] - fn test_find_xpath_prefixes_recognizes_full_ncname_charset_in_prefixes() { - // NCName permits letters, digits, `_`, `-`, `.` - // (the last three may not start the name). - let cases: &[(&str, HashSet)] = &[ - // Hyphenated — common in OpenConfig. - ( - "/oc-if:interfaces/oc-if:interface[oc-if:name='eth0']", - set(["oc-if"]), - ), - // Dot in the middle (legal NCName, rare in practice). - ("/a.b:c", set(["a.b"])), - // Underscore-leading. - ("/_ns:leaf", set(["_ns"])), - ]; - for (expr, expected) in cases { - assert_prefixes(expr, expected.clone()); - } - } - - #[test] - fn test_find_xpath_prefixes_handles_prefixed_wildcards_and_attributes() { - let cases: &[(&str, HashSet)] = &[ - ("/ex:*", set(["ex"])), - ("//@ex:id", set(["ex"])), - ("/if:interface[@nc:operation='delete']", set(["if", "nc"])), - ]; - for (expr, expected) in cases { - assert_prefixes(expr, expected.clone()); - } - } - - #[test] - fn test_find_xpath_prefixes_xpath_axes_are_never_reported_as_prefixes() { - // Every XPath 1.0 axis name followed by `::` must be skipped, - // since the `::` is an axis separator rather than a prefix colon. - const AXES: &[&str] = &[ - "ancestor", - "ancestor-or-self", - "attribute", - "child", - "descendant", - "descendant-or-self", - "following", - "following-sibling", - "namespace", - "parent", - "preceding", - "preceding-sibling", - "self", - ]; - for axis in AXES { - assert_prefixes(&format!("{axis}::node()"), HashSet::new()); - } - // Axes can still coexist with real prefixes in the same expression. - assert_prefixes("descendant::if:interface/child::if:name", set(["if"])); - } - - #[test] - fn test_find_xpath_prefixes_skips_colons_inside_string_literals() { - // Single-quoted identityref comparisons (RFC 7950 §9.10), - // double-quoted variants, and mixed-quote expressions. - let cases: &[(&str, HashSet)] = &[ - ("../crypto = 'mc:aes'", HashSet::new()), - ("name() = \"ns:bogus\"", HashSet::new()), - ("@a:x = 'p:q' or @b:y = \"r:s\"", set(["a", "b"])), - ]; - for (expr, expected) in cases { - assert_prefixes(expr, expected.clone()); - } - } - - #[test] - fn test_find_xpath_prefixes_handles_compound_expressions() { - // Function calls, leafref-style predicates with current(), - // unions, boolean ops across modules, and nested predicates. - let cases: &[(&str, HashSet)] = &[ - ("ex:size(@id)", set(["ex"])), - ( - "/if:interfaces/if:interface[if:name = current()/../if:name]", - set(["if"]), - ), - ("/a:foo | /b:bar", set(["a", "b"])), - ( - "(/if:interfaces/if:interface/if:enabled = 'true') \ - and count(/rt:routing/rt:routes) > 0", - set(["if", "rt"]), - ), - ("/a:x[a:y[b:z = '1']/a:w = c:fn()]", set(["a", "b", "c"])), - ]; - for (expr, expected) in cases { - assert_prefixes(expr, expected.clone()); - } - } - - #[test] - fn test_find_xpath_prefixes_real_world_yang_expressions() { - // ietf-interfaces-style `must`: only `if:` is a live prefix; - // the `ianaift:*` tokens are identityref values inside string - // literals and must not be reported. - let must_expr = "(/if:interfaces/if:interface[if:name=current()]/if:type \ - = 'ianaift:ethernetCsmacd') \ - or \ - (/if:interfaces/if:interface[if:name=current()]/if:type \ - = 'ianaift:ieee8023adLag')"; - assert_prefixes(must_expr, set(["if"])); - - // Multi-module subscriber filter for yp:datastore-xpath-filter. - let filter_expr = "/if:interfaces/if:interface[if:name='eth0'] \ - | /rt:routing/rt:ribs/rt:rib[rt:name=current()/ref:rib]"; - assert_prefixes(filter_expr, set(["if", "rt", "ref"])); - } - - #[test] - fn test_find_xpath_prefixes_whitespace_between_ncname_and_colon_breaks_qname() { - // In XPath 1.0 a QName is lexically `NCName ':' NCName` with no - // whitespace. `if : interfaces` is three tokens, so `if` must not - // be reported as a prefix. This behavior is intentional. - assert_prefixes(" / if : interfaces ", HashSet::new()); - } - #[test] fn test_read_xpath_with_namespaces_basic_filter() { // One prefix declared on the filter element, used in the path. @@ -1796,11 +1571,11 @@ mod tests { #[test] fn test_read_xpath_with_namespaces_multiple_modules_and_literal_prefixes() { - // Real-world `must`-style filter mixing live prefixes with prefixed - // identityref values inside string literals. The `t:` token appears - // only inside quotes, so even though it's declared, it shouldn't end - // up in the namespace map (find_xpath_prefixes correctly skips it). - // Conversely `if:` is live and must be kept. + // `t:` appears only inside a predicate string literal shaped like a + // whole QName; its binding must still be kept so the fetcher can + // resolve the module and normalize_path can rewrite the literal + // (see find_xpath_prefixes). `if:` is a live node-name prefix and + // must be kept too. let xml = r#", + /// Seen only inside a string literal shaped like one whole QName (e.g. + /// `hw-hwt` in `'hw-hwt:ethernetCsmacd-xcvr-link'`). Only meaningful + /// with a declared `xmlns` binding (RFC 7950 §9.10.3); undeclared + /// entries are likely incidental text and should be dropped, not + /// resolved. + pub(crate) literal_only: HashSet, +} + +impl XpathPrefixes { + /// Whether `prefix` was referenced anywhere, regardless of category. + pub(crate) fn contains(&self, prefix: &str) -> bool { + self.structural.contains(prefix) || self.literal_only.contains(prefix) + } +} + +/// Find the prefixes used within an XPath expression (e.g. the `if` in +/// `/if:interfaces/if:interface`), split into [`XpathPrefixes::structural`] +/// (node/attribute-name references) and [`XpathPrefixes::literal_only`] +/// (whole-string QName-shaped literal values, e.g. `ianaift` in +/// `'ianaift:ethernetCsmacd'`). Axis specifiers (`child::`) are never +/// treated as prefixes. A literal that isn't shaped like a whole QName is +/// opaque data and contributes nothing. +pub(crate) fn find_xpath_prefixes(xpath: &str) -> XpathPrefixes { + let mut prefixes = XpathPrefixes::default(); + let mut chars = xpath.char_indices().peekable(); + + while let Some((i, c)) = chars.next() { + match c { + quote @ ('\'' | '"') => { + let content_start = i + quote.len_utf8(); + let Some(rel_end) = xpath[content_start..].find(quote) else { + break; // unterminated literal (malformed xpath) + }; + let content_end = content_start + rel_end; + let literal = &xpath[content_start..content_end]; + if literal == literal.trim() + && let Some((Some(prefix), _)) = parse_node_test(literal) + { + prefixes.literal_only.insert(prefix.to_string()); + } + while chars.next_if(|&(idx, _)| idx < content_end).is_some() {} + chars.next(); // consume the closing quote + } + c if c.is_ascii_alphabetic() || c == '_' => { + let start = i; + let mut end = i + c.len_utf8(); + while let Some(&(_, nc)) = chars.peek() { + if nc.is_ascii_alphanumeric() || nc == '_' || nc == '-' || nc == '.' { + chars.next(); + end += nc.len_utf8(); + } else { + break; + } + } + // A prefix is an NCName followed by exactly one ':' + // (two colons = axis specifier like `child::`). + if let Some(&(_, ':')) = chars.peek() { + let mut look = chars.clone(); + look.next(); + let is_axis = matches!(look.peek(), Some(&(_, ':'))); + if !is_axis { + prefixes.structural.insert(xpath[start..end].to_string()); + chars.next(); // consume the ':' + } + } + } + _ => {} + } + } + prefixes +} + +/// Split an xpath location path on `/` at bracket depth 0 and outside string +/// literals. Returns `None` if quotes or brackets are unbalanced. +pub(crate) fn split_location_path(path: &str) -> Option> { + let mut segments = Vec::new(); + let mut depth: i32 = 0; + let mut in_single = false; + let mut in_double = false; + let mut start = 0usize; + for (i, c) in path.char_indices() { + match c { + '\'' if !in_double => in_single = !in_single, + '"' if !in_single => in_double = !in_double, + '[' if !in_single && !in_double => depth += 1, + ']' if !in_single && !in_double => { + depth -= 1; + if depth < 0 { + return None; + } + } + '/' if depth == 0 && !in_single && !in_double => { + segments.push(&path[start..i]); + start = i + 1; + } + _ => {} + } + } + if depth != 0 || in_single || in_double { + return None; + } + segments.push(&path[start..]); + Some(segments) +} + +/// Parse a node test of the form `(prefix ':')? (NCName | '*')`, returning +/// `(prefix, local)`. Returns `None` for anything else (functions, axes, `@`, +/// `.`/`..`, embedded whitespace), which signals an unsupported path. +pub(crate) fn parse_node_test(head: &str) -> Option<(Option<&str>, &str)> { + let head = head.trim(); + if head.is_empty() { + return None; + } + let (prefix, local) = match head.split_once(':') { + Some((p, l)) => (Some(p), l), + None => (None, head), + }; + if let Some(p) = prefix + && !is_ncname(p) + { + return None; + } + if local != "*" && !is_ncname(local) { + return None; + } + Some((prefix, local)) +} + +/// Whether `s` is a YANG/XML NCName: a leading letter or `_`, followed by +/// letters, digits, `_`, `-`, or `.`. +fn is_ncname(s: &str) -> bool { + let mut chars = s.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') +} + +/// Reduce `provided` vs `canonical` to the char index where they first +/// diverge, plus the substrings unique to each side (with the common +/// prefix/suffix stripped off), so small differences (e.g. a missing +/// leading slash) are obvious without scanning both full paths. +pub fn xpath_diff(provided: &str, canonical: &str) -> (usize, String, String) { + let prefix_chars = provided + .chars() + .zip(canonical.chars()) + .take_while(|(a, b)| a == b) + .count(); + let provided_chars = provided.chars().count(); + let canonical_chars = canonical.chars().count(); + let max_suffix = (provided_chars - prefix_chars).min(canonical_chars - prefix_chars); + let suffix_chars = provided + .chars() + .rev() + .zip(canonical.chars().rev()) + .take_while(|(a, b)| a == b) + .count() + .min(max_suffix); + + let byte_offset = |s: &str, chars: usize| -> usize { + s.char_indices() + .nth(chars) + .map(|(i, _)| i) + .unwrap_or(s.len()) + }; + let provided_unique = &provided + [byte_offset(provided, prefix_chars)..byte_offset(provided, provided_chars - suffix_chars)]; + let canonical_unique = &canonical[byte_offset(canonical, prefix_chars) + ..byte_offset(canonical, canonical_chars - suffix_chars)]; + + ( + prefix_chars, + provided_unique.to_string(), + canonical_unique.to_string(), + ) +} + +/// Remove XPath predicate groups (`[...]`) from a location path, honoring +/// quoted strings and nested brackets so predicate contents (including a +/// `]` inside a string literal) are not miscounted. +pub fn strip_xpath_predicates(path: &str) -> String { + let mut out = String::with_capacity(path.len()); + let mut depth: u32 = 0; + let mut in_single = false; + let mut in_double = false; + for c in path.chars() { + if depth == 0 { + if c == '[' { + depth = 1; + } else { + out.push(c); + } + } else { + match c { + '\'' if !in_double => in_single = !in_single, + '"' if !in_single => in_double = !in_double, + '[' if !in_single && !in_double => depth += 1, + ']' if !in_single && !in_double => depth -= 1, + _ => {} + } + } + } + // Unbalanced brackets/quotes: return the original rather than a + // silently truncated path. + if depth != 0 || in_single || in_double { + return path.to_string(); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_location_path_splits_on_slash_outside_brackets_and_quotes() { + assert_eq!( + split_location_path("/a/b[c='/']/d"), + Some(vec!["", "a", "b[c='/']", "d"]) + ); + } + + #[test] + fn test_split_location_path_rejects_unbalanced_brackets_or_quotes() { + assert_eq!(split_location_path("/a[b"), None); + assert_eq!(split_location_path("/a]"), None); + assert_eq!(split_location_path("/a[b='c]"), None); + } + + #[test] + fn test_parse_node_test_accepts_prefixed_names_and_wildcards() { + assert_eq!( + parse_node_test("if:interface"), + Some((Some("if"), "interface")) + ); + assert_eq!(parse_node_test("*"), Some((None, "*"))); + assert_eq!(parse_node_test("if:*"), Some((Some("if"), "*"))); + } + + #[test] + fn test_parse_node_test_rejects_functions_axes_and_special_steps() { + for head in ["current()", "node()", "@id", ".", "..", "if : interface"] { + assert_eq!(parse_node_test(head), None, "should reject `{head}`"); + } + } + + #[test] + fn test_is_ncname_accepts_valid_and_rejects_invalid_names() { + for valid in ["if", "_ns", "oc-if", "a.b", "a1"] { + assert!(is_ncname(valid), "should accept `{valid}`"); + } + for invalid in ["", "1if", "-if", ".if", "if:name", "if name"] { + assert!(!is_ncname(invalid), "should reject `{invalid}`"); + } + } + + fn set(items: [&str; N]) -> HashSet { + items.iter().map(|s| s.to_string()).collect() + } + + fn assert_prefixes(expr: &str, expected: HashSet) { + let found = find_xpath_prefixes(expr); + let all: HashSet = found + .structural + .into_iter() + .chain(found.literal_only) + .collect(); + assert_eq!(all, expected, "unexpected prefix set for: {expr}"); + } + + #[test] + fn test_find_xpath_prefixes_yields_empty_when_no_qnames_present() { + // Empty/whitespace input, unprefixed paths, pure numeric/operator + // expressions, the `current()` function, and bare node tests all + // contain no QNames — so nothing should be reported. + for expr in [ + "", + " \n\t", + "/interfaces/interface/name", + "1 + 2.5 - 3 <= 4 and 5 != 6", + "current()", + "node() | text() | comment() | processing-instruction()", + ] { + assert_prefixes(expr, HashSet::new()); + } + } + + #[test] + fn test_find_xpath_prefixes_test_extracts_prefixes_from_simple_location_paths() { + // Motivating Huawei debug case, RFC 8641 Figure 12 (`/ex:foo`), + // the subscribed-notifications `/int:interfaces` example, + // prefix deduplication, and multi-prefix paths. + let cases: &[(&str, HashSet)] = &[ + ( + "/debug:debug/debug:board-resouce-states/debug:board-resouce-state", + set(["debug"]), + ), + ("/ex:foo", set(["ex"])), + ("/int:interfaces", set(["int"])), + ("/if:interfaces/if:interface/if:name", set(["if"])), + ("/a:x/b:y/c:z", set(["a", "b", "c"])), + ]; + for (expr, expected) in cases { + assert_prefixes(expr, expected.clone()); + } + } + + #[test] + fn test_find_xpath_prefixes_recognizes_full_ncname_charset_in_prefixes() { + // NCName permits letters, digits, `_`, `-`, `.` + // (the last three may not start the name). + let cases: &[(&str, HashSet)] = &[ + // Hyphenated — common in OpenConfig. + ( + "/oc-if:interfaces/oc-if:interface[oc-if:name='eth0']", + set(["oc-if"]), + ), + // Dot in the middle (legal NCName, rare in practice). + ("/a.b:c", set(["a.b"])), + // Underscore-leading. + ("/_ns:leaf", set(["_ns"])), + ]; + for (expr, expected) in cases { + assert_prefixes(expr, expected.clone()); + } + } + + #[test] + fn test_find_xpath_prefixes_handles_prefixed_wildcards_and_attributes() { + let cases: &[(&str, HashSet)] = &[ + ("/ex:*", set(["ex"])), + ("//@ex:id", set(["ex"])), + ("/if:interface[@nc:operation='delete']", set(["if", "nc"])), + ]; + for (expr, expected) in cases { + assert_prefixes(expr, expected.clone()); + } + } + + #[test] + fn test_find_xpath_prefixes_xpath_axes_are_never_reported_as_prefixes() { + // Every XPath 1.0 axis name followed by `::` must be skipped, + // since the `::` is an axis separator rather than a prefix colon. + const AXES: &[&str] = &[ + "ancestor", + "ancestor-or-self", + "attribute", + "child", + "descendant", + "descendant-or-self", + "following", + "following-sibling", + "namespace", + "parent", + "preceding", + "preceding-sibling", + "self", + ]; + for axis in AXES { + assert_prefixes(&format!("{axis}::node()"), HashSet::new()); + } + // Axes can still coexist with real prefixes in the same expression. + assert_prefixes("descendant::if:interface/child::if:name", set(["if"])); + } + + #[test] + fn test_find_xpath_prefixes_only_whole_qname_literals_are_reported() { + // A colon inside a literal is only a prefix reference when the + // *entire* literal is shaped like one QName (RFC 7950 §9.10 + // identityref lexical form) - a colon buried in a larger string, or + // one with surrounding whitespace/extra separators, is just data. + let cases: &[(&str, HashSet)] = &[ + // Whole-literal QNames: reported, same as any other reference. + ("../crypto = 'mc:aes'", set(["mc"])), + ("name() = \"ns:bogus\"", set(["ns"])), + ("@a:x = 'p:q' or @b:y = \"r:s\"", set(["a", "b", "p", "r"])), + // Not a whole QName: colon is part of a larger string, skipped. + ("@a:x = 'http://example.com:8080'", set(["a"])), + ("contains(., 'note: value')", HashSet::new()), + ("@a:x = 'a:b:c'", set(["a"])), + ]; + for (expr, expected) in cases { + assert_prefixes(expr, expected.clone()); + } + } + + #[test] + fn test_find_xpath_prefixes_reproduces_whole_qname_literal_subscriptions() { + // `hw-hwt` appears only inside the literal value, never as a + // node-name reference, but its module must still be fetched to + // validate the comparison. + assert_prefixes( + "/hw:hardware/hw:component[hw-hw:sub-class='hw-hwt:ethernetCsmacd-xcvr-link']/bbf-hw-xcvr:transceiver-link", + set(["hw", "hw-hw", "hw-hwt", "bbf-hw-xcvr"]), + ); + + // Two predicates in the same path, one with a whole-QName literal + // (`ianahw`) and one with a plain literal (`'rpm'`, no colon) that + // contributes nothing. + assert_prefixes( + "/hw:hardware/hw:component[hw:class='ianahw:sensor']/hw:sensor-data[hw:value-type='rpm']", + set(["hw", "ianahw"]), + ); + + // The whole-QName literal predicate is the last step in the path, + // with nothing after the closing `]`. + assert_prefixes( + "/if:interfaces/if:interface[if:type='ianaift:gpon']", + set(["if", "ianaift"]), + ); + } + + /// A prefix used as a node name lands in `structural`, a prefix seen + /// only inside a whole-QName-shaped literal lands in `literal_only`. + /// Whether the latter also has a declared binding is not this + /// function's concern — it has no visibility into declared bindings — + /// that gate lives with callers (e.g. + /// `DatastoreXPathFilter::path_prefixes`). + #[test] + fn test_find_xpath_prefixes_splits_structural_from_literal_only() { + let found = find_xpath_prefixes( + "/hw:hardware/hw:component[hw-hw:sub-class='hw-hwt:ethernetCsmacd-xcvr-link']", + ); + assert_eq!(found.structural, set(["hw", "hw-hw"])); + assert_eq!(found.literal_only, set(["hw-hwt"])); + } + + #[test] + fn test_find_xpath_prefixes_handles_compound_expressions() { + // Function calls, leafref-style predicates with current(), + // unions, boolean ops across modules, and nested predicates. + let cases: &[(&str, HashSet)] = &[ + ("ex:size(@id)", set(["ex"])), + ( + "/if:interfaces/if:interface[if:name = current()/../if:name]", + set(["if"]), + ), + ("/a:foo | /b:bar", set(["a", "b"])), + ( + "(/if:interfaces/if:interface/if:enabled = 'true') \ + and count(/rt:routing/rt:routes) > 0", + set(["if", "rt"]), + ), + ("/a:x[a:y[b:z = '1']/a:w = c:fn()]", set(["a", "b", "c"])), + ]; + for (expr, expected) in cases { + assert_prefixes(expr, expected.clone()); + } + } + + #[test] + fn test_find_xpath_prefixes_real_world_yang_expressions() { + // ietf-interfaces-style `must`: `if:` is a live prefix, and the + // `ianaift:*` tokens are whole-literal identityref QNames, so their + // prefix is reported too (its module must be resolvable to validate + // the comparison). + let must_expr = "(/if:interfaces/if:interface[if:name=current()]/if:type \ + = 'ianaift:ethernetCsmacd') \ + or \ + (/if:interfaces/if:interface[if:name=current()]/if:type \ + = 'ianaift:ieee8023adLag')"; + assert_prefixes(must_expr, set(["if", "ianaift"])); + + // Multi-module subscriber filter for yp:datastore-xpath-filter. + let filter_expr = "/if:interfaces/if:interface[if:name='eth0'] \ + | /rt:routing/rt:ribs/rt:rib[rt:name=current()/ref:rib]"; + assert_prefixes(filter_expr, set(["if", "rt", "ref"])); + } + + #[test] + fn test_find_xpath_prefixes_whitespace_between_ncname_and_colon_breaks_qname() { + // In XPath 1.0 a QName is lexically `NCName ':' NCName` with no + // whitespace. `if : interfaces` is three tokens, so `if` must not + // be reported as a prefix. This behavior is intentional. + assert_prefixes(" / if : interfaces ", HashSet::new()); + } + + #[test] + fn test_strip_xpath_predicates_removes_single_and_multiple_predicates() { + assert_eq!( + strip_xpath_predicates("/if:interfaces/if:interface[if:name='eth0']/if:oper-status"), + "/if:interfaces/if:interface/if:oper-status" + ); + assert_eq!( + strip_xpath_predicates("/a:x[1]/a:y[a:z='w'][@id='2']"), + "/a:x/a:y" + ); + } + + #[test] + fn test_strip_xpath_predicates_ignores_brackets_inside_string_literals() { + // A `]` inside a quoted predicate value must not be mistaken for the + // end of the predicate. + assert_eq!( + strip_xpath_predicates(r#"/a:x[a:y='[literal]']/a:z"#), + "/a:x/a:z" + ); + } + + #[test] + fn test_strip_xpath_predicates_noop_without_predicates() { + assert_eq!( + strip_xpath_predicates("/if:interfaces/if:interface"), + "/if:interfaces/if:interface" + ); + } + + /// Unbalanced brackets/quotes must return the original path, not a + /// silently truncated one. + #[test] + fn test_strip_xpath_predicates_unbalanced_input_returns_original() { + for path in [ + "/if:interfaces/if:interface[if:name='eth0'", + "/if:interfaces/if:interface[if:name='eth0]", + "/a:x[a:y=\"unterminated]/a:z", + ] { + assert_eq!(strip_xpath_predicates(path), path); + } + } + + #[test] + fn test_xpath_diff_reports_common_prefix_and_unique_suffixes() { + let (diverges_at, provided_unique, canonical_unique) = + xpath_diff("if:interfaces/interface", "/if:interfaces/interface"); + assert_eq!(diverges_at, 0); + assert_eq!(provided_unique, ""); + assert_eq!(canonical_unique, "/"); + } + + #[test] + fn test_xpath_diff_isolates_a_single_differing_segment() { + let (diverges_at, provided_unique, canonical_unique) = xpath_diff( + "/if:interfaces/if:interface/oper-status", + "/if:interfaces/interface/oper-status", + ); + assert_eq!(diverges_at, "/if:interfaces/i".chars().count()); + assert_eq!(provided_unique, "f:i"); + assert_eq!(canonical_unique, ""); + } + + #[test] + fn test_xpath_diff_identical_paths_yield_no_unique_substrings() { + let (diverges_at, provided_unique, canonical_unique) = + xpath_diff("/if:interfaces/interface", "/if:interfaces/interface"); + assert_eq!(diverges_at, "/if:interfaces/interface".chars().count()); + assert!(provided_unique.is_empty()); + assert!(canonical_unique.is_empty()); + } + + #[test] + fn test_xpath_diff_clamps_overlapping_prefix_and_suffix() { + // When one string is a repetition of the other's chars, the common + // prefix and common suffix would overlap; the clamp must stop them + // from double-counting (and from underflowing the unique slices). + assert_eq!(xpath_diff("aaa", "aa"), (2, "a".to_string(), String::new())); + assert_eq!(xpath_diff("x", "xxx"), (1, String::new(), "xx".to_string())); + // A shared char at both ends around a single insertion. + assert_eq!(xpath_diff("aba", "aa"), (1, "b".to_string(), String::new()),); + } + + #[test] + fn test_xpath_diff_handles_empty_and_one_empty_inputs() { + assert_eq!(xpath_diff("", ""), (0, String::new(), String::new())); + assert_eq!(xpath_diff("", "/a"), (0, String::new(), "/a".to_string())); + assert_eq!(xpath_diff("/a", ""), (0, "/a".to_string(), String::new())); + } + + #[test] + fn test_xpath_diff_is_char_boundary_safe_with_multibyte_input() { + // The common prefix/suffix and unique slices must be computed on char + // boundaries, never splitting a multibyte code point. `divergence` is + // a char index, not a byte offset. + let (diverges_at, provided_unique, canonical_unique) = xpath_diff("/αβ:x/y", "/αβ:x/z"); + assert_eq!(diverges_at, "/αβ:x/".chars().count()); + assert_eq!(provided_unique, "y"); + assert_eq!(canonical_unique, "z"); + + // Divergence right after a multibyte common prefix, differing tails. + let (diverges_at, provided_unique, canonical_unique) = xpath_diff("café", "cafétx"); + assert_eq!(diverges_at, 4); + assert_eq!(provided_unique, ""); + assert_eq!(canonical_unique, "tx"); + } + + #[test] + fn test_split_location_path_handles_nested_brackets() { + // A predicate containing a nested predicate must be kept as one + // segment (the inner `/`-free case is trivial; this guards depth > 1). + assert_eq!( + split_location_path("/a:x[a:y[a:z='w']]/a:q"), + Some(vec!["", "a:x[a:y[a:z='w']]", "a:q"]), + ); + } + + #[test] + fn test_split_location_path_honors_double_quoted_separators() { + // A `/`, `[`, or `]` inside a double-quoted literal must not be + // treated as a separator or bracket (the single-quote case is covered + // separately). + assert_eq!( + split_location_path(r#"/a[b="/[]"]/c"#), + Some(vec!["", r#"a[b="/[]"]"#, "c"]), + ); + } + + #[test] + fn test_strip_xpath_predicates_removes_nested_predicates() { + // A predicate nested inside another must be removed wholesale, not + // leave a dangling inner `]`. + assert_eq!(strip_xpath_predicates("/a:x[a:y[a:z='w']]/a:q"), "/a:x/a:q",); + } + + #[test] + fn test_strip_xpath_predicates_ignores_brackets_inside_double_quoted_literals() { + // A `]` inside a double-quoted predicate value must not close the + // predicate early. + assert_eq!(strip_xpath_predicates(r#"/a:x[a:y="]"]/a:z"#), "/a:x/a:z"); + } + + #[test] + fn test_find_xpath_prefixes_captures_prefixes_before_unterminated_literal() { + // An unterminated string literal is malformed, but any structural + // prefixes seen before it must still be reported (the scan breaks at + // the bad literal rather than discarding earlier findings). + let found = find_xpath_prefixes("/a:b[c:d='x"); + assert_eq!(found.structural, set(["a", "c"])); + assert!(found.literal_only.is_empty()); + } + + #[test] + fn test_parse_node_test_accepts_unprefixed_ncname() { + assert_eq!(parse_node_test("interface"), Some((None, "interface"))); + } + + #[test] + fn test_parse_node_test_rejects_empty_and_malformed_qnames() { + // Empty input, empty prefix or local half, and non-NCName halves. + for head in ["", " ", "if:", ":name", "1if:name", "if:1name"] { + assert_eq!(parse_node_test(head), None, "should reject `{head}`"); + } + } +} diff --git a/crates/netconf-proto/src/yang_push/filters.rs b/crates/netconf-proto/src/yang_push/filters.rs index c0b7c992..86b9bf56 100644 --- a/crates/netconf-proto/src/yang_push/filters.rs +++ b/crates/netconf-proto/src/yang_push/filters.rs @@ -24,6 +24,7 @@ //! XML round-tripping. use crate::xml_utils::{ParsingError, XmlDeserialize, XmlParser, XmlSerialize, XmlWriter}; +use crate::xpath::{find_xpath_prefixes, parse_node_test, split_location_path}; use crate::yang_push::{SUBSCRIBED_NOTIFICATIONS_NS, YANG_PUSH_NS}; use indexmap::map::IndexMap; use quick_xml::events::{BytesText, Event}; @@ -355,16 +356,274 @@ pub struct DatastoreXPathFilter { } impl DatastoreXPathFilter { - /// Prefixes used in the xpath `path` (e.g. the `if` in - /// `/if:interfaces/if:interface`), sorted for determinism. Axis specifiers - /// and string literals are not treated as prefixes. + /// Prefixes required to resolve `path`'s target modules: every prefix + /// used as a node name (e.g. the `if` in `/if:interfaces/if:interface`), + /// plus any prefix seen only inside a whole-QName-shaped predicate + /// literal that also has a declared `xmlns` binding on this filter. A + /// literal-shaped prefix with no declared binding is dropped rather than + /// treated as required pub fn path_prefixes(&self) -> Vec { - let mut prefixes: Vec = crate::xml_utils::find_xpath_prefixes(&self.path) - .into_iter() - .collect(); + let found = find_xpath_prefixes(&self.path); + let mut prefixes: Vec = found.structural.into_iter().collect(); + prefixes.extend( + found + .literal_only + .into_iter() + .filter(|p| self.namespace_uri(p).is_some()), + ); prefixes.sort_unstable(); + prefixes.dedup(); prefixes } + + /// Look up the namespace URI declared for `prefix` on this filter. + fn namespace_uri(&self, prefix: &str) -> Option<&str> { + self.namespaces + .iter() + .find(|(p, _)| p.as_ref() == prefix) + .map(|(_, uri)| uri.as_ref()) + } + + /// Normalize `path` to RFC 8641's base XPath context: + /// module-name-qualified, prefix emitted only on module change (matches + /// libyang's canonical schema path format). E.g. + /// `/debug:debug/debug:board-resouce-state` (xmlns-prefixed) and + /// `/huawei-debug:debug/board-resouce-state` (module-name prefixed) + /// both normalize to the latter. + /// + /// How a step's module is determined, in order: if its prefix has a + /// declared `xmlns` binding, that namespace URI is resolved to a module + /// name via `resolve_module`; if the prefix is undeclared, the prefix + /// text is itself already the module name; if the step has no prefix at + /// all, it inherits the module of the preceding step. Whatever module is + /// found is only written back out as a prefix when it differs from the + /// previous step's — that's the "prefix on change" part. Predicates + /// (`[...]`) get the same per-token treatment for any `prefix:name` found + /// in their text (string literals are left alone), except the prefix is + /// dropped instead of kept when it matches the *enclosing* step's module. + /// + /// Only a single, plain location path with implicit `child`-axis steps is + /// supported (not the full XPath 1.0 grammar — no functions, unions, or + /// explicit axes). The result always starts with `/` (inserted if + /// missing): per RFC 8641 the context node is always the datastore root. + /// + /// Returns `None` — keep the original path — for unsupported constructs + /// or a declared prefix `resolve_module` can't map. Idempotent. + pub fn normalize_path(&self, resolve_module: F) -> Option + where + F: Fn(&str) -> Option>, + { + let path = self.path.trim(); + if path.is_empty() { + return None; + } + let segments = split_location_path(path)?; + let mut out = String::with_capacity(path.len() + 1); + let mut current_module: Option> = None; + for (i, seg) in segments.iter().enumerate() { + if i > 0 { + out.push('/'); + } + // Empty segment: leading '/' (absolute) is expected only at + // i == 0. Any other empty segment implies '//' (descendant + // axis) or a trailing '/', neither of which this child-axis-only + // normalizer supports. + if seg.is_empty() { + if i == 0 { + continue; + } + return None; + } + // Split the node test from any trailing predicate(s). + let head_end = seg.find('[').unwrap_or(seg.len()); + let (prefix, local) = parse_node_test(&seg[..head_end])?; + let predicates = &seg[head_end..]; + + let module: Option> = match prefix { + Some(p) => { + if let Some(uri) = self.namespace_uri(p) { + Some(resolve_module(uri)?) + } else { + Some(p.into()) + } + } + // A bare wildcard can't be module-qualified and its matched + // node's module is unknown, so tracking is reset: a + // following unprefixed step must not inherit the module from + // before the wildcard. + None if local == "*" => { + current_module = None; + None + } + None => current_module.clone(), + }; + match module { + Some(m) => { + if current_module.as_deref() != Some(m.as_ref()) { + out.push_str(&m); + out.push(':'); + current_module = Some(m); + } + out.push_str(local); + } + None => out.push_str(local), + } + if predicates.is_empty() { + continue; + } + let rewritten = self.rewrite_predicate_prefixes( + predicates, + current_module.as_deref(), + &resolve_module, + )?; + out.push_str(&rewritten); + } + if !out.starts_with('/') { + out.insert(0, '/'); + } + Some(out) + } + + /// Rewrite `prefix:name` tokens found in predicate text (`[...]`), + /// mirroring [`Self::normalize_path`]'s step resolution: a declared + /// prefix maps to its module via `resolve_module`, an undeclared one is + /// the module name, and the prefix is dropped when it matches + /// `enclosing_module`. + /// + /// A string literal is opaque data, unless its entire content is itself + /// shaped like one QName (e.g. `'hw-hwt:ethernetCsmacd-xcvr-link'`), in + /// which case its prefix is resolved and rewritten too, but never + /// dropped (its module is unrelated to `enclosing_module`). + /// + /// Returns `None` (bail, keep the original) if a `:` outside a literal + /// isn't part of a well-formed prefixed name or axis specifier, or if a + /// declared prefix can't be resolved to a module. + fn rewrite_predicate_prefixes( + &self, + predicate: &str, + enclosing_module: Option<&str>, + resolve_module: &F, + ) -> Option + where + F: Fn(&str) -> Option>, + { + let mut out = String::with_capacity(predicate.len()); + let mut chars = predicate.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + match c { + quote @ ('\'' | '"') => { + let content_start = i + quote.len_utf8(); + let Some(rel_end) = predicate[content_start..].find(quote) else { + // Unterminated literal (malformed xpath) - copy + // verbatim. + out.push(quote); + for (_, rc) in chars.by_ref() { + out.push(rc); + } + break; + }; + let content_end = content_start + rel_end; + let literal = &predicate[content_start..content_end]; + while chars.next_if(|&(idx, _)| idx < content_end).is_some() {} + chars.next(); // consume the closing quote + out.push(quote); + match self.resolve_literal_qname(literal, resolve_module)? { + Some(rewritten) => out.push_str(&rewritten), + None => out.push_str(literal), + } + out.push(quote); + } + c if c.is_ascii_alphabetic() || c == '_' => { + let start = i; + let mut end = i + c.len_utf8(); + while let Some(&(_, nc)) = chars.peek() { + if nc.is_ascii_alphanumeric() || nc == '_' || nc == '-' || nc == '.' { + chars.next(); + end += nc.len_utf8(); + } else { + break; + } + } + let ident = &predicate[start..end]; + // A prefix is an NCName followed by exactly one ':' + // (two colons = axis specifier like `child::`). + let Some(&(_, ':')) = chars.peek() else { + out.push_str(ident); + continue; + }; + let mut look = chars.clone(); + look.next(); + if matches!(look.peek(), Some(&(_, ':'))) { + out.push_str(ident); + continue; + } + chars.next(); // consume the ':' + let (local_start, mut local_end) = match chars.peek() { + Some(&(li, '*')) => { + chars.next(); + (li, li + 1) + } + Some(&(li, lc)) if lc.is_ascii_alphabetic() || lc == '_' => { + chars.next(); + (li, li + lc.len_utf8()) + } + // ':' not followed by a valid NCName or '*' — unsupported. + _ => return None, + }; + // Wildcard local names (`prefix:*`) have no further + // characters to scan; NCName locals continue below. + if !predicate[local_start..].starts_with('*') { + while let Some(&(_, nc)) = chars.peek() { + if nc.is_ascii_alphanumeric() || nc == '_' || nc == '-' || nc == '.' { + chars.next(); + local_end += nc.len_utf8(); + } else { + break; + } + } + } + let local = &predicate[local_start..local_end]; + let module: Box = if let Some(uri) = self.namespace_uri(ident) { + resolve_module(uri)? + } else { + ident.into() + }; + if enclosing_module == Some(module.as_ref()) { + out.push_str(local); + } else { + out.push_str(&module); + out.push(':'); + out.push_str(local); + } + } + _ => out.push(c), + } + } + Some(out) + } + + /// If `literal`'s entire content is one `prefix:name` QName (e.g. + /// `hw-hwt:ethernetCsmacd-xcvr-link`) and `prefix` has a declared `xmlns` + /// binding, resolve and rewrite it to `module:name` (never dropped, since + /// its module is unrelated to the enclosing step). Returns `Some(None)` + /// unchanged otherwise, or `None` (bail) if `prefix` is declared but + /// `resolve_module` can't map it. + fn resolve_literal_qname(&self, literal: &str, resolve_module: &F) -> Option> + where + F: Fn(&str) -> Option>, + { + if literal != literal.trim() { + return Some(None); + } + let Some((Some(prefix), local)) = parse_node_test(literal) else { + return Some(None); + }; + let Some(uri) = self.namespace_uri(prefix) else { + return Some(None); + }; + let module = resolve_module(uri)?; + Some(Some(format!("{module}:{local}"))) + } } impl XmlSerialize for DatastoreXPathFilter { diff --git a/crates/netconf-proto/src/yang_push/tests.rs b/crates/netconf-proto/src/yang_push/tests.rs index fd28b193..bd1ec760 100644 --- a/crates/netconf-proto/src/yang_push/tests.rs +++ b/crates/netconf-proto/src/yang_push/tests.rs @@ -785,29 +785,791 @@ fn test_yang_push_module_version_json_serde() { assert_eq!(deserialized, modeled); } +/// A prefix with no `xmlns` binding declared equals the module name. #[test] -fn test_datastore_xpath_filter_path_prefixes() { - // Cisco IOS-XR: prefix equals the module name, no xmlns binding declared. - let cisco = DatastoreXPathFilter { +fn test_datastore_xpath_filter_path_prefixes_module_name_prefix() { + let filter = DatastoreXPathFilter { namespaces: Box::new([]), - path: "Cisco-IOS-XR-procmem-oper:processes-memory/nodes/node/process-ids/process-id".into(), + path: "example-procmem-oper:processes-memory/nodes/node/process-ids/process-id".into(), }; - assert_eq!(cisco.path_prefixes(), vec!["Cisco-IOS-XR-procmem-oper"]); + assert_eq!(filter.path_prefixes(), vec!["example-procmem-oper"]); +} - // Multi-module xpath (Huawei-style), distinct prefixes (sorted). - let multi = DatastoreXPathFilter { +/// Distinct prefixes across a multi-module path are returned sorted. +#[test] +fn test_datastore_xpath_filter_path_prefixes_multi_module_are_sorted() { + let filter = DatastoreXPathFilter { namespaces: Box::new([ - ("devm".into(), "urn:huawei:yang:huawei-devm".into()), - ("driver".into(), "urn:huawei:yang:huawei-driver".into()), + ("a".into(), "urn:example:yang:example-a".into()), + ("b".into(), "urn:example:yang:example-b".into()), ]), - path: "/devm:devm/devm:chassiss/devm:chassis/driver:power-supply-attribute".into(), + path: "/b:root/b:child/b:leaf/a:sibling".into(), }; - assert_eq!(multi.path_prefixes(), vec!["devm", "driver"]); + assert_eq!(filter.path_prefixes(), vec!["a", "b"]); +} - // Unprefixed (default-namespace) steps contribute no prefixes. - let unprefixed = DatastoreXPathFilter { +/// Unprefixed (default-namespace) steps contribute no prefixes. +#[test] +fn test_datastore_xpath_filter_path_prefixes_unprefixed_steps_are_empty() { + let filter = DatastoreXPathFilter { namespaces: Box::new([]), path: "/interfaces/interface/state/counters".into(), }; - assert!(unprefixed.path_prefixes().is_empty()); + assert!(filter.path_prefixes().is_empty()); +} + +/// A literal-shaped prefix (e.g. `ge` in `'ge:0'`) is only kept when it also +/// has a declared `xmlns` binding; undeclared ones are dropped. +#[test] +fn test_datastore_xpath_filter_path_prefixes_undeclared_literal_is_dropped() { + let declared = DatastoreXPathFilter { + namespaces: Box::new([ + ("hw".into(), "urn:example:yang:hw".into()), + ("hw-hwt".into(), "urn:example:yang:hw-hwt".into()), + ]), + path: "/hw:hardware/hw:component[hw:sub-class='hw-hwt:ethernetCsmacd-xcvr-link']".into(), + }; + assert_eq!(declared.path_prefixes(), vec!["hw", "hw-hwt"]); + + let undeclared = DatastoreXPathFilter { + namespaces: Box::new([("hw".into(), "urn:example:yang:hw".into())]), + path: "/hw:hardware/hw:interface[hw:name='ge:0']".into(), + }; + assert_eq!(undeclared.path_prefixes(), vec!["hw"]); +} + +/// A declared `xmlns` binding and an undeclared, module-name-as-prefix +/// encoding of the same target must converge onto the same canonical form. +#[test] +fn test_datastore_xpath_filter_normalize_path_converges_declared_and_undeclared_prefixes() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:example:yang:example-debug" => Some("example-debug".into()), + _ => None, + } + }; + + let declared = DatastoreXPathFilter { + namespaces: Box::new([("debug".into(), "urn:example:yang:example-debug".into())]), + path: "/debug:debug/debug:board-state".into(), + }; + let undeclared = DatastoreXPathFilter { + namespaces: Box::new([]), + path: "/example-debug:debug/board-state".into(), + }; + let canonical = "/example-debug:debug/board-state"; + assert_eq!(declared.normalize_path(resolve).as_deref(), Some(canonical)); + assert_eq!( + undeclared.normalize_path(resolve).as_deref(), + Some(canonical), + ); +} + +/// Normalizing an already-canonical form yields itself. +#[test] +fn test_datastore_xpath_filter_normalize_path_is_idempotent() { + let resolve = |_: &str| -> Option> { None }; + let canonical = "/example-debug:debug/board-state"; + let already = DatastoreXPathFilter { + namespaces: Box::new([]), + path: canonical.into(), + }; + assert_eq!(already.normalize_path(resolve).as_deref(), Some(canonical)); +} + +/// A module-qualifying prefix is only emitted when the module changes. +#[test] +fn test_datastore_xpath_filter_normalize_path_multi_module_emits_prefix_only_on_change() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:example:yang:example-a" => Some("example-a".into()), + "urn:example:yang:example-b" => Some("example-b".into()), + _ => None, + } + }; + + let multi = DatastoreXPathFilter { + namespaces: Box::new([ + ("a".into(), "urn:example:yang:example-a".into()), + ("b".into(), "urn:example:yang:example-b".into()), + ]), + path: "/a:root/a:child/a:leaf/b:sibling".into(), + }; + assert_eq!( + multi.normalize_path(resolve).as_deref(), + Some("/example-a:root/child/leaf/example-b:sibling"), + ); +} + +/// A bare wildcard's matched node has an unknown module, so the tracked +/// module is reset: an explicit prefix on the following step must not be +/// dropped as redundant, even if it happens to match the pre-wildcard +/// module. +#[test] +fn test_datastore_xpath_filter_normalize_path_wildcard_resets_tracked_module() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:example:yang:example-a" => Some("example-a".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([("a".into(), "urn:example:yang:example-a".into())]), + path: "/a:root/*/a:leaf".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some("/example-a:root/*/example-a:leaf"), + ); +} + +/// With no `xmlns` binding declared, an undeclared prefix is itself the +/// module name (the base XPath context), and a relative path is made +/// absolute. +#[test] +fn test_datastore_xpath_filter_normalize_path_module_name_prefix_is_made_absolute() { + let resolve = |_: &str| -> Option> { None }; + + let relative = DatastoreXPathFilter { + namespaces: Box::new([]), + path: "example-oper:processes/process/pids/pid".into(), + }; + let canonical = "/example-oper:processes/process/pids/pid"; + assert_eq!(relative.normalize_path(resolve).as_deref(), Some(canonical)); + + // An already-absolute equivalent normalizes identically. + let absolute = DatastoreXPathFilter { + namespaces: Box::new([]), + path: canonical.into(), + }; + assert_eq!(absolute.normalize_path(resolve).as_deref(), Some(canonical)); +} + +/// A predicate's own prefix is resolved the same as a step's: dropped +/// entirely when it matches the enclosing step's module (redundant, per RFC +/// 7950 §6.4.1 unprefixed-name-inherits-context-node rule), and a '/' inside +/// the predicate's value must not be mistaken for a path separator. +#[test] +fn test_datastore_xpath_filter_normalize_path_drops_redundant_predicate_prefix() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:example:yang:example-debug" => Some("example-debug".into()), + _ => None, + } + }; + + let with_pred = DatastoreXPathFilter { + namespaces: Box::new([("debug".into(), "urn:example:yang:example-debug".into())]), + path: "/debug:debug/debug:board-state[debug:id='1/2']".into(), + }; + assert_eq!( + with_pred.normalize_path(resolve).as_deref(), + Some("/example-debug:debug/board-state[id='1/2']"), + ); +} + +/// A predicate prefix that resolves to a *different* module than the +/// enclosing step is canonicalized to the resolved module name, not dropped. +#[test] +fn test_datastore_xpath_filter_normalize_path_predicate_prefix_different_module_is_canonicalized() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-interfaces" => Some("ietf-interfaces".into()), + "urn:example:oper-ext" => Some("example-oper-ext".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([ + ( + "if".into(), + "urn:ietf:params:xml:ns:yang:ietf-interfaces".into(), + ), + ("ext".into(), "urn:example:oper-ext".into()), + ]), + path: "/if:interfaces/if:interface[ext:tag='x']".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some("/ietf-interfaces:interfaces/interface[example-oper-ext:tag='x']"), + ); +} + +/// A predicate prefix with a declared `xmlns` binding whose namespace cannot +/// be resolved to a module bails (`None`), same as an unresolvable step +/// prefix. +#[test] +fn test_datastore_xpath_filter_normalize_path_unresolvable_predicate_prefix_bails() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-interfaces" => Some("ietf-interfaces".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([ + ( + "if".into(), + "urn:ietf:params:xml:ns:yang:ietf-interfaces".into(), + ), + ("unknown".into(), "urn:unresolvable".into()), + ]), + path: "/if:interfaces/if:interface[unknown:name='eth0']".into(), + }; + assert_eq!(filter.normalize_path(resolve), None); +} + +/// A fully-prefix-qualified, multi-step path where the predicate's prefix +/// is the same module as the enclosing step and must be dropped, same as +/// the outer steps' redundant prefixes are dropped. +#[test] +fn test_datastore_xpath_filter_normalize_path_sub_303_redundant_predicate_prefix() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:6wind:vrouter" => Some("vrouter".into()), + "urn:6wind:vrouter/interface" => Some("vrouter-interface".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([ + ("vrouter".into(), "urn:6wind:vrouter".into()), + ("vrouter-interface".into(), "urn:6wind:vrouter/interface".into()), + ]), + path: "/vrouter:state/vrouter:vrf/vrouter-interface:interface/vrouter-interface:physical[vrouter-interface:name='ens192']/vrouter-interface:oper-status".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some("/vrouter:state/vrf/vrouter-interface:interface/physical[name='ens192']/oper-status"), + ); +} + +/// A declared prefix whose namespace cannot be resolved bails (`None`). +#[test] +fn test_datastore_xpath_filter_normalize_path_unresolvable_prefix_bails() { + let resolve = |_: &str| -> Option> { None }; + + let unresolvable = DatastoreXPathFilter { + namespaces: Box::new([("x".into(), "urn:unknown".into())]), + path: "/x:foo/x:bar".into(), + }; + assert_eq!(unresolvable.normalize_path(resolve), None); +} + +/// Unsupported XPath constructs bail (`None`); the caller keeps the original. +#[test] +fn test_datastore_xpath_filter_normalize_path_unsupported_constructs_bail() { + let resolve = |_: &str| -> Option> { None }; + + // some unsupported xpath examples + for path in [ + "count(/if:interfaces) > 0", + "/a:x | /b:y", + "descendant::if:name", + "/a:x//a:y", + "/a:x/", + ] { + let f = DatastoreXPathFilter { + namespaces: Box::new([]), + path: path.into(), + }; + assert_eq!(f.normalize_path(resolve), None, "should bail for `{path}`"); + } +} + +/// Instantiated (real key value) predicate containing '/' must not be +/// mistaken for a path separator. +#[test] +fn test_datastore_xpath_filter_normalize_path_instantiated_predicate_with_slash() { + let resolve = |_: &str| -> Option> { None }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([]), + path: "openconfig-interfaces:interfaces/interface[name='TenGigE0/0/0/14']".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some("/openconfig-interfaces:interfaces/interface[name='TenGigE0/0/0/14']"), + ); +} + +/// A nested (multi-step) predicate path with a module-qualified identityref +/// value; both are preserved verbatim. +#[test] +fn test_datastore_xpath_filter_normalize_path_instantiated_predicate_with_nested_path() { + let resolve = |_: &str| -> Option> { None }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([]), + path: + "openconfig-interfaces:interfaces/interface[state/type='iana-if-type:ethernetCsmacd']" + .into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some( + "/openconfig-interfaces:interfaces/interface[state/type='iana-if-type:ethernetCsmacd']" + ), + ); +} + +/// A declared `xmlns` binding combined with an instantiated predicate value: +/// the predicate's own `if:` prefix resolves to the same module +/// (`ietf-interfaces`) as the enclosing `interface` step, so it's dropped. +#[test] +fn test_datastore_xpath_filter_normalize_path_declared_namespace_with_instantiated_predicate() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-interfaces" => Some("ietf-interfaces".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([( + "if".into(), + "urn:ietf:params:xml:ns:yang:ietf-interfaces".into(), + )]), + path: "/if:interfaces/if:interface[if:name='GigabitEthernet0/0/0']".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some("/ietf-interfaces:interfaces/interface[name='GigabitEthernet0/0/0']"), + ); +} + +/// A binding declared only for a predicate literal's whole-QName value +/// doesn't affect the location path's module tracking, but the literal is +/// still resolved/rewritten independently. The predicate's own, non-literal +/// `if:type` prefix is resolved/dropped as usual. +#[test] +fn test_datastore_xpath_filter_normalize_path_declared_namespace_binding_in_predicate_literal() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-interfaces" => Some("ietf-interfaces".into()), + "urn:ietf:params:xml:ns:yang:iana-if-type" => Some("iana-if-type".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([ + ( + "if".into(), + "urn:ietf:params:xml:ns:yang:ietf-interfaces".into(), + ), + ( + "iana-if-type".into(), + "urn:ietf:params:xml:ns:yang:iana-if-type".into(), + ), + ]), + path: "/if:interfaces/if:interface[if:type='iana-if-type:ethernetCsmacd']".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some("/ietf-interfaces:interfaces/interface[type='iana-if-type:ethernetCsmacd']"), + ); +} + +/// A whole-QName predicate literal whose prefix differs from the resolved +/// module name (`hw-hwt` → `huawei-hardware-types`) is rewritten to the full +/// module name, not left as the original prefix. +#[test] +fn test_datastore_xpath_filter_normalize_path_predicate_literal_prefix_differs_from_module() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-hardware" => Some("ietf-hardware".into()), + "urn:huawei:yang:huawei-hardware" => Some("huawei-hardware".into()), + "urn:huawei:yang:huawei-hardware-types" => Some("huawei-hardware-types".into()), + "urn:bbf:yang:bbf-hardware-transceivers" => Some("bbf-hardware-transceivers".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([ + ("hw".into(), "urn:ietf:params:xml:ns:yang:ietf-hardware".into()), + ("hw-hw".into(), "urn:huawei:yang:huawei-hardware".into()), + ("hw-hwt".into(), "urn:huawei:yang:huawei-hardware-types".into()), + ( + "bbf-hw-xcvr".into(), + "urn:bbf:yang:bbf-hardware-transceivers".into(), + ), + ]), + path: "/hw:hardware/hw:component[hw-hw:sub-class='hw-hwt:ethernetCsmacd-xcvr-link']/bbf-hw-xcvr:transceiver-link".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some( + "/ietf-hardware:hardware/component[huawei-hardware:sub-class='huawei-hardware-types:ethernetCsmacd-xcvr-link']/bbf-hardware-transceivers:transceiver-link" + ), + ); +} + +/// A whole-QName predicate literal at the very end of the path (no +/// trailing step after `]`) is still resolved. +#[test] +fn test_datastore_xpath_filter_normalize_path_predicate_literal_at_end_of_path() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-interfaces" => Some("ietf-interfaces".into()), + "urn:ietf:params:xml:ns:yang:iana-if-type" => Some("iana-if-type".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([ + ( + "if".into(), + "urn:ietf:params:xml:ns:yang:ietf-interfaces".into(), + ), + ( + "ianaift".into(), + "urn:ietf:params:xml:ns:yang:iana-if-type".into(), + ), + ]), + path: "/if:interfaces/if:interface[if:type='ianaift:gpon']".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some("/ietf-interfaces:interfaces/interface[type='iana-if-type:gpon']"), + ); +} + +/// Two predicates in the same path. The first has a whole-QName literal +/// that gets rewritten (`ianahw:sensor` → `iana-hardware:sensor`); the +/// second is a plain literal with no ':' (`'rpm'`) and stays untouched. +/// Both predicates' node-name prefixes resolve to their enclosing step's +/// module and are dropped. +#[test] +fn test_datastore_xpath_filter_normalize_path_multiple_predicates_mixed_literal_and_plain() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-hardware" => Some("ietf-hardware".into()), + "urn:ietf:params:xml:ns:yang:iana-hardware" => Some("iana-hardware".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([ + ("hw".into(), "urn:ietf:params:xml:ns:yang:ietf-hardware".into()), + ( + "ianahw".into(), + "urn:ietf:params:xml:ns:yang:iana-hardware".into(), + ), + ]), + path: "/hw:hardware/hw:component[hw:class='ianahw:sensor']/hw:sensor-data[hw:value-type='rpm']" + .into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some( + "/ietf-hardware:hardware/component[class='iana-hardware:sensor']/sensor-data[value-type='rpm']" + ), + ); +} + +/// A multi-module path (a step from an augmenting module mid-path) combined +/// with an instantiated predicate value containing both '/' and ':'; the +/// predicate's `if:` prefix resolves to the same module as the enclosing +/// `interface` step and is dropped, while the following `ext:` step (a +/// different module) keeps its prefix as usual. +#[test] +fn test_datastore_xpath_filter_normalize_path_multi_module_with_instantiated_predicate() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-interfaces" => Some("ietf-interfaces".into()), + "urn:example:oper-ext" => Some("example-oper-ext".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([ + ( + "if".into(), + "urn:ietf:params:xml:ns:yang:ietf-interfaces".into(), + ), + ("ext".into(), "urn:example:oper-ext".into()), + ]), + path: "/if:interfaces/if:interface[if:name='TenGigE0/0/0/14']/ext:oper-status-detail" + .into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some( + "/ietf-interfaces:interfaces/interface[name='TenGigE0/0/0/14']/example-oper-ext:oper-status-detail" + ), + ); +} + +/// Idempotency is a load-bearing property: the fetcher normalizes a target, +/// replaces `path`, and empties `namespaces`; a re-normalization (or the +/// diagnostic canonical-form check) must see a stable result. Verify that +/// normalizing the *output* of a first pass — with the namespace table +/// emptied and the resolver made useless, exactly mirroring the fetcher's +/// post-state (see `NetconfYangLibraryFetcher`) — yields the same string, for +/// every non-trivial shape (multi-module, redundant predicate prefix, +/// whole-QName literal, wildcard reset). +#[test] +fn test_datastore_xpath_filter_normalize_path_idempotent_after_namespaces_emptied() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-hardware" => Some("ietf-hardware".into()), + "urn:huawei:yang:huawei-hardware" => Some("huawei-hardware".into()), + "urn:huawei:yang:huawei-hardware-types" => Some("huawei-hardware-types".into()), + "urn:bbf:yang:bbf-hardware-transceivers" => Some("bbf-hardware-transceivers".into()), + "urn:example:yang:example-a" => Some("example-a".into()), + _ => None, + } + }; + + let cases = [ + // Multi-module, redundant predicate prefix, and a whole-QName literal + // that itself gets rewritten. + DatastoreXPathFilter { + namespaces: Box::new([ + ("hw".into(), "urn:ietf:params:xml:ns:yang:ietf-hardware".into()), + ("hw-hw".into(), "urn:huawei:yang:huawei-hardware".into()), + ( + "hw-hwt".into(), + "urn:huawei:yang:huawei-hardware-types".into(), + ), + ( + "bbf-hw-xcvr".into(), + "urn:bbf:yang:bbf-hardware-transceivers".into(), + ), + ]), + path: "/hw:hardware/hw:component[hw-hw:sub-class='hw-hwt:ethernetCsmacd-xcvr-link']/bbf-hw-xcvr:transceiver-link".into(), + }, + // Wildcard reset in the middle of the path. + DatastoreXPathFilter { + namespaces: Box::new([("a".into(), "urn:example:yang:example-a".into())]), + path: "/a:root/*/a:leaf".into(), + }, + ]; + + for filter in &cases { + let first = filter + .normalize_path(resolve) + .unwrap_or_else(|| panic!("first pass must normalize `{}`", filter.path)); + + // Mirror the fetcher's post-state: path replaced, namespaces emptied, + // and the resolver can no longer map anything (module names are now + // bare prefixes with no xmlns binding). + let second_filter = DatastoreXPathFilter { + namespaces: Box::new([]), + path: first.clone().into(), + }; + let second = second_filter.normalize_path(|_| None); + assert_eq!( + second.as_deref(), + Some(first.as_str()), + "normalize_path not idempotent for `{}`", + filter.path, + ); + } +} + +/// A predicate value that merely *looks* like a QName (e.g. an interface name +/// `'ge:0'`) but whose prefix has no declared `xmlns` binding is opaque data +/// and must be emitted verbatim — never mistaken for a module reference. The +/// node-name `if:` prefixes still collapse as usual. +#[test] +fn test_datastore_xpath_filter_normalize_path_undeclared_literal_value_is_preserved() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-interfaces" => Some("ietf-interfaces".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([( + "if".into(), + "urn:ietf:params:xml:ns:yang:ietf-interfaces".into(), + )]), + path: "/if:interfaces/if:interface[if:name='ge:0']".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some("/ietf-interfaces:interfaces/interface[name='ge:0']"), + ); +} + +/// A whole-QName predicate *literal* whose prefix has a declared `xmlns` +/// binding that `resolve_module` can't map must bail (`None`), the same way an +/// unresolvable node-name prefix does — never emit a half-resolved literal. +#[test] +fn test_datastore_xpath_filter_normalize_path_unresolvable_literal_prefix_bails() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-interfaces" => Some("ietf-interfaces".into()), + _ => None, + } + }; + + // `if:` resolves fine; the literal's declared `bad:` prefix does not. + let filter = DatastoreXPathFilter { + namespaces: Box::new([ + ( + "if".into(), + "urn:ietf:params:xml:ns:yang:ietf-interfaces".into(), + ), + ("bad".into(), "urn:unresolvable".into()), + ]), + path: "/if:interfaces/if:interface[if:type='bad:someType']".into(), + }; + assert_eq!(filter.normalize_path(resolve), None); +} + +/// An explicit axis specifier inside a predicate (`child::`) is not a prefix: +/// the `::` must be preserved and the following `prefix:name` still resolved +/// against the enclosing module (here dropped, same module as the step). +/// Unlike a *step*-level axis (which bails), an axis inside a predicate +/// sub-expression is passed through. +#[test] +fn test_datastore_xpath_filter_normalize_path_axis_specifier_inside_predicate() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-interfaces" => Some("ietf-interfaces".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([( + "if".into(), + "urn:ietf:params:xml:ns:yang:ietf-interfaces".into(), + )]), + path: "/if:interfaces/if:interface[if:name=current()/child::if:name]".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some("/ietf-interfaces:interfaces/interface[name=current()/child::name]"), + ); +} + +/// A prefixed wildcard as a step (`if:*`) is module-qualified from its prefix, +/// and — unlike a bare `*` — does not reset module tracking, so a following +/// unprefixed step inherits its module. +#[test] +fn test_datastore_xpath_filter_normalize_path_prefixed_wildcard_step() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:example:yang:example-a" => Some("example-a".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([("a".into(), "urn:example:yang:example-a".into())]), + path: "/a:root/a:*/a:leaf".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some("/example-a:root/*/leaf"), + ); +} + +/// A prefixed wildcard local name inside a predicate (`if:*`) is rewritten +/// like any other `prefix:name`: the prefix drops when it matches the +/// enclosing module. +#[test] +fn test_datastore_xpath_filter_normalize_path_prefixed_wildcard_in_predicate() { + let resolve = |uri: &str| -> Option> { + match uri { + "urn:ietf:params:xml:ns:yang:ietf-interfaces" => Some("ietf-interfaces".into()), + _ => None, + } + }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([( + "if".into(), + "urn:ietf:params:xml:ns:yang:ietf-interfaces".into(), + )]), + path: "/if:interfaces/if:interface[count(if:*)>1]".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some("/ietf-interfaces:interfaces/interface[count(*)>1]"), + ); +} + +/// A malformed `prefix:` in a predicate — a colon not followed by a valid +/// NCName or `*` (here followed by a digit) — is unsupported and bails. +#[test] +fn test_datastore_xpath_filter_normalize_path_malformed_predicate_prefix_bails() { + let resolve = |_: &str| -> Option> { None }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([]), + path: "/a:x[a:1='y']".into(), + }; + assert_eq!(filter.normalize_path(resolve), None); +} + +/// A predicate attached to a bare-wildcard step: with no enclosing module, +/// a prefixed predicate name keeps its module prefix (nothing to be redundant +/// with). +#[test] +fn test_datastore_xpath_filter_normalize_path_predicate_on_wildcard_step_keeps_prefix() { + let resolve = |_: &str| -> Option> { None }; + + let filter = DatastoreXPathFilter { + namespaces: Box::new([]), + path: "/example-a:root/*[example-b:tag='x']".into(), + }; + assert_eq!( + filter.normalize_path(resolve).as_deref(), + Some("/example-a:root/*[example-b:tag='x']"), + ); +} + +/// An empty (or whitespace-only) path bails rather than emitting a lone `/`. +#[test] +fn test_datastore_xpath_filter_normalize_path_empty_path_bails() { + let resolve = |_: &str| -> Option> { None }; + + for path in ["", " ", "\t\n"] { + let filter = DatastoreXPathFilter { + namespaces: Box::new([]), + path: path.into(), + }; + assert_eq!( + filter.normalize_path(resolve), + None, + "should bail for empty path `{path:?}`", + ); + } +} + +/// Steps that aren't plain child-axis node tests — an attribute step (`@`), +/// a self/parent step (`.`/`..`) — are unsupported and bail. +#[test] +fn test_datastore_xpath_filter_normalize_path_non_child_axis_steps_bail() { + let resolve = |_: &str| -> Option> { None }; + + for path in [ + "/if:interface/@if:name", + "/if:interfaces/./if:interface", + "/if:interfaces/../if:interface", + ] { + let filter = DatastoreXPathFilter { + namespaces: Box::new([]), + path: path.into(), + }; + assert_eq!( + filter.normalize_path(resolve), + None, + "should bail for `{path}`", + ); + } } diff --git a/crates/yang-push/src/cache/fetcher.rs b/crates/yang-push/src/cache/fetcher.rs index 7cbc50a6..336a95bb 100644 --- a/crates/yang-push/src/cache/fetcher.rs +++ b/crates/yang-push/src/cache/fetcher.rs @@ -393,7 +393,7 @@ impl NetconfYangLibraryFetcher { } }; - let subscription = client + let mut subscription = client .get_yang_push_subscription_by_id(subscription_id) .await .map_err(|err| Box::new((empty.clone(), err.into())))?; @@ -516,6 +516,39 @@ impl NetconfYangLibraryFetcher { warn!(host=%host, error=%err, "Timeout while closing SSH connection") } } + // Normalize the target xpath filter to the canonical module-name, + // prefix-on-change form so the downstream pipeline sees one encoding + // regardless of how the device reported it (xmlns-declared prefixes vs + // module-name base context). + if let Target::Datastore(datastore_target) = &mut subscription.target + && let DatastoreSelectionFilterObjects::WithInSubscription(DatastoreFilterSpec::Xpath( + xpath, + )) = &mut datastore_target.selection + { + match xpath.normalize_path(|uri| { + router_yang_library + .find_module_by_datastore_and_ns(&datastore_target.datastore, uri) + .map(|m| m.name().into()) + }) { + Some(normalized) => { + if normalized.as_str() != xpath.path.as_ref() { + debug!( + subscription_id, + from = %xpath.path, + to = %normalized, + "normalized target xpath filter", + ); + } + xpath.path = normalized.into_boxed_str(); + xpath.namespaces = Box::new([]); + } + None => warn!( + subscription_id, + path = %xpath.path, + "could not normalize target xpath filter, keeping original", + ), + } + } let subscription_target = subscription.target.try_into().map_err(|err| { Box::new(( empty, @@ -1154,6 +1187,90 @@ mod resolve_tests { assert_eq!(result[0].name(), "huawei-devm"); } + /// `hw-hwt` appears only inside the predicate literal, never as a + /// node-name prefix; its module must still resolve into the fetch list. + #[test] + fn test_resolve_by_xpath_resolves_module_referenced_only_in_predicate_literal() { + let yang_lib = make_yang_library( + DatastoreName::Operational, + &[ + ("ietf-hardware", "urn:ietf:params:xml:ns:yang:ietf-hardware"), + ("huawei-hardware", "urn:huawei:yang:huawei-hardware"), + ( + "huawei-hardware-types", + "urn:huawei:yang:huawei-hardware-types", + ), + ( + "bbf-hardware-transceivers", + "urn:bbf:yang:bbf-hardware-transceivers", + ), + ], + ); + let filter = xpath_filter( + &[ + ("hw", "urn:ietf:params:xml:ns:yang:ietf-hardware"), + ("hw-hw", "urn:huawei:yang:huawei-hardware"), + ("hw-hwt", "urn:huawei:yang:huawei-hardware-types"), + ("bbf-hw-xcvr", "urn:bbf:yang:bbf-hardware-transceivers"), + ], + "/hw:hardware/hw:component[hw-hw:sub-class='hw-hwt:ethernetCsmacd-xcvr-link']/bbf-hw-xcvr:transceiver-link", + ); + + let mut result = resolve_by_xpath( + &yang_lib, + &DatastoreName::Operational, + &filter, + &empty_info(), + ) + .expect("all modules, including the predicate-literal one, should resolve") + .into_iter() + .map(|m| m.name().to_string()) + .collect::>(); + result.sort_unstable(); + + assert_eq!( + result, + vec![ + "bbf-hardware-transceivers", + "huawei-hardware", + "huawei-hardware-types", + "ietf-hardware", + ] + ); + } + + /// A literal that merely looks like a QName (e.g. an interface name + /// `'ge:0'`) but has no declared `xmlns` binding must not be treated as + /// a required module, or resolution fails just because no `ge` module + /// exists. + #[test] + fn test_resolve_by_xpath_ignores_undeclared_literal_shaped_value() { + let yang_lib = make_yang_library( + DatastoreName::Operational, + &[( + "ietf-interfaces", + "urn:ietf:params:xml:ns:yang:ietf-interfaces", + )], + ); + let filter = xpath_filter( + &[("if", "urn:ietf:params:xml:ns:yang:ietf-interfaces")], + "/if:interfaces/if:interface[if:name='ge:0']", + ); + + let result = resolve_by_xpath( + &yang_lib, + &DatastoreName::Operational, + &filter, + &empty_info(), + ) + .expect("undeclared literal-shaped value must not fail resolution") + .into_iter() + .map(|m| m.name().to_string()) + .collect::>(); + + assert_eq!(result, vec!["ietf-interfaces"]); + } + #[test] fn test_resolve_by_xpath_multiple_distinct_prefixes_resolve_independently() { let yang_lib = make_yang_library( diff --git a/crates/yang-push/src/validation/mod.rs b/crates/yang-push/src/validation/mod.rs index 02f65787..33384af0 100644 --- a/crates/yang-push/src/validation/mod.rs +++ b/crates/yang-push/src/validation/mod.rs @@ -126,10 +126,14 @@ use crate::{ ContentId, OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY, OTL_YANG_PUSH_SUBSCRIPTION_ROUTER_CONTENT_ID_KEY, OTL_YANG_PUSH_SUBSCRIPTION_TARGET_KEY, }; +use netcalyx_netconf_proto::xpath::{strip_xpath_predicates, xpath_diff}; +use netcalyx_netconf_proto::yang_push::filters::DatastoreXPathFilter; use netcalyx_netconf_proto::yang_push::subscription::YangPushModuleVersion; use netcalyx_netconf_proto::yang_push::types::SubscriptionId; use netcalyx_udp_notif_pkt::decoded::{UdpNotifPacketDecoded, UdpNotifPayload}; -use netcalyx_udp_notif_pkt::notification::{NotificationVariant, SubscriptionStartedModified}; +use netcalyx_udp_notif_pkt::notification::{ + NotificationVariant, SubscriptionStartedModified, Target, +}; use netcalyx_udp_notif_pkt::raw::UdpNotifPacket; use netcalyx_udp_notif_service::{OTL_UDP_NOTIF_PUBLISHER_ID_KEY, SessionInfo, UdpNotifRequest}; use rustc_hash::FxHashMap; @@ -140,6 +144,7 @@ use strum::VariantNames; use tokio::sync::mpsc; use tracing::{debug, info, trace, warn}; use yang5::data::{DataFormat, DataOperation, DataParserFlags, DataValidationFlags}; +use yang5::schema::SchemaPathFormat; // Attribute key shared by the `dropped` and `skipped` counters. const REASON_KEY: &str = "reason"; @@ -1269,7 +1274,6 @@ impl ValidationActor { }; // Update subscription info in the cache - subscription_cache.subscription_info = subscription_info.clone(); if let Some(yang_lib_ref) = yang_lib_ref { let search_dir = yang_lib_ref.search_dir(); let yang_ctx_result = yang5::context::Context::new_from_yang_library_file( @@ -1297,6 +1301,12 @@ impl ValidationActor { None } }; + // Sanity-check the subscription target resolves against the schema + // and warn if it is missing or not in canonical form. Diagnostic + // only: the target is never modified here. + if let Some(ctx) = yang_ctx.as_ref() { + Self::check_xpath_target_resolves(&subscription_info, ctx); + } subscription_cache.cached_content_id = cached_content_id.clone(); subscription_cache.yang_ctx = yang_ctx; } else { @@ -1304,6 +1314,9 @@ impl ValidationActor { subscription_cache.cached_content_id = None; subscription_cache.yang_ctx = None; } + + // Store the subscription info in the cache. + subscription_cache.subscription_info = subscription_info.clone(); subscription_cache.schema_fetch_pending = false; let buffered_packets = std::mem::take(&mut subscription_cache.buffered_packets); let drained = buffered_packets.len(); @@ -1352,6 +1365,148 @@ impl ValidationActor { Ok(()) } + /// Resolve the inline `datastore-xpath-filter` target against the loaded + /// schema and warn if it does not resolve or is not in libyang's canonical + /// form (`LYSC_PATH_DATA`). Purely diagnostic: the target is never + /// modified. A well-behaved publisher should always send a resolvable, + /// canonical path, so a mismatch flags a bad xpath from the router (or + /// a gap in the fetcher-side normalization). Skipped for targets without a + /// datastore xpath filter. + /// + /// Predicates (`[...]`) are stripped before the equality check: libyang's + /// schema path never carries them, so we compare the structural + /// location path and still detect real prefix/structure differences + /// without a spurious mismatch from the predicate itself. + fn check_xpath_target_resolves( + subscription_info: &SubscriptionInfo, + yang_ctx: &yang5::context::Context, + ) { + let subscription_id = subscription_info.id(); + let peer = subscription_info.peer_ip(); + + let Some(original) = subscription_info.target.datastore_xpath_filter.as_deref() else { + return; + }; + + // find_xpath is evaluated against the original query (predicates + // included) so a malformed predicate/typo'd leaf name still surfaces + // as a real evaluation error. + let nodes = match yang_ctx.find_xpath(original) { + Ok(set) => set.collect::>(), + Err(err) => { + warn!( + %peer, + subscription_id, + path = %original, + error = %err, + "target xpath failed to evaluate against the schema (bad xpath from the publisher?)", + ); + return; + } + }; + + match nodes.as_slice() { + [node] => { + let canonical = node.path(SchemaPathFormat::DATA); + // Schema paths are absolute and never carry predicates; strip + // predicates from the provided path and tolerate a + // device-omitted leading slash before comparing, so only + // genuine structural/prefix differences are reported. + let stripped = strip_xpath_predicates(original); + let comparison = if stripped.starts_with('/') { + stripped + } else { + format!("/{stripped}") + }; + if canonical != comparison { + let (diverges_at, provided_unique, canonical_unique) = + xpath_diff(&comparison, &canonical); + warn!( + %peer, + subscription_id, + provided = %original, + canonical = %canonical, + diverges_at, + provided_unique, + canonical_unique, + "target xpath differs from the schema canonical form", + ); + } else { + trace!( + %peer, + subscription_id, + path = %original, + "target xpath resolves to a schema node and is canonical", + ); + } + } + [] => warn!( + %peer, + subscription_id, + path = %original, + "target xpath does not resolve to any schema node (bad xpath from the publisher?)", + ), + many => trace!( + %peer, + subscription_id, + path = %original, + node_count = many.len(), + "target xpath resolves to multiple schema nodes", + ), + } + } + + /// Normalize the inline `datastore-xpath-filter` carried by a JSON + /// `SubscriptionStarted`/`SubscriptionModified` notification to the same + /// module-name-qualified, prefix-on-change canonical form the fetcher + /// applies to NETCONF/XML-sourced targets (see + /// `DatastoreXPathFilter::normalize_path`). + /// + /// Unlike the XML case, JSON xpath strings never carry `xmlns` prefix + /// declarations — per RFC 7951/8641 a prefix in the path text is already + /// the module name itself. So this is a pure string transform: wrapping + /// the path in a `DatastoreXPathFilter` with an empty namespace table + /// makes every prefix fall into `normalize_path`'s "undeclared prefix is + /// the module name" branch, meaning `resolve_module` is never invoked and + /// no schema/YANG-library access is needed here. + /// + /// No-op if the target has no datastore xpath filter. Leaves the path + /// untouched (but logs a warning) if it can't be confidently normalized + /// (e.g. functions, unions, or other unsupported XPath 1.0 constructs). + fn normalize_json_target_xpath( + peer: SocketAddr, + subscription_id: SubscriptionId, + target: &mut Target, + ) { + let Some(original) = target.datastore_xpath_filter.as_deref() else { + return; + }; + let filter = DatastoreXPathFilter { + namespaces: Box::new([]), + path: original.into(), + }; + match filter.normalize_path(|_uri| None) { + Some(normalized) => { + if normalized != original { + debug!( + %peer, + subscription_id, + from = %original, + to = %normalized, + "normalized target xpath filter", + ); + } + target.datastore_xpath_filter = Some(normalized); + } + None => warn!( + %peer, + subscription_id, + path = %original, + "could not normalize target xpath filter, keeping original", + ), + } + } + /// Construct a `SubscriptionInfo` from a `SubscriptionStarted/Modified` /// notification. Returns `None` if module-version is absent. fn build_subscription_info( @@ -1384,10 +1539,13 @@ impl ValidationActor { } }; + let mut target = sub_started.target().clone(); + Self::normalize_json_target_xpath(peer, sub_started.id(), &mut target); + Some(SubscriptionInfo::new( peer.ip(), sub_started.id(), - sub_started.target().clone(), + target, sub_started.stop_time().cloned(), sub_started.transport().cloned(), sub_started.encoding().cloned(), @@ -1544,6 +1702,7 @@ mod tests { use super::*; use crate::cache::actor::tests::setup_actor_with_empty_cache; use bytes::Bytes; + use netcalyx_netconf_proto::yang_push::identities::{Encoding, Transport}; use netcalyx_udp_notif_pkt::raw::MediaType; use std::collections::HashMap; use std::time::Duration; @@ -3063,4 +3222,194 @@ mod tests { caching_handle.shutdown().await.unwrap(); caching_join_handle.await.unwrap().unwrap(); } + + /// A JSON `datastore-xpath-filter` with a redundant module prefix on + /// every step must be collapsed to the prefix-on-change canonical form, + /// same as the fetcher-side XML normalization. + #[test] + fn test_normalize_json_target_xpath_collapses_redundant_prefixes() { + let mut target = Target::new_datastore( + "ietf-datastores:operational".to_string(), + either::Right( + "/ietf-interfaces:interfaces/ietf-interfaces:interface[ietf-interfaces:name='eth0']/ietf-interfaces:oper-status" + .to_string(), + ), + ); + ValidationActor::normalize_json_target_xpath( + SocketAddr::from(([127, 0, 0, 1], 0)), + 1, + &mut target, + ); + assert_eq!( + target.datastore_xpath_filter.as_deref(), + Some("/ietf-interfaces:interfaces/interface[name='eth0']/oper-status") + ); + } + + /// An already-canonical JSON xpath must be left unchanged + /// (idempotent transform). + #[test] + fn test_normalize_json_target_xpath_idempotent_on_canonical_path() { + let mut target = Target::new_datastore( + "ietf-datastores:operational".to_string(), + either::Right("/ietf-interfaces:interfaces/interface".to_string()), + ); + ValidationActor::normalize_json_target_xpath( + SocketAddr::from(([127, 0, 0, 1], 0)), + 1, + &mut target, + ); + assert_eq!( + target.datastore_xpath_filter.as_deref(), + Some("/ietf-interfaces:interfaces/interface") + ); + } + + /// A target without a datastore xpath filter (e.g. a stream target) must + /// be left untouched. + #[test] + fn test_normalize_json_target_xpath_noop_without_xpath_filter() { + let mut target = Target::new_stream( + "NETCONF".to_string(), + None, + either::Left(serde_json::Value::Null), + ); + let before = target.clone(); + ValidationActor::normalize_json_target_xpath( + SocketAddr::from(([127, 0, 0, 1], 0)), + 1, + &mut target, + ); + assert_eq!(target, before); + } + + /// Loads a `yang5::context::Context` from the bundled `ietf-interfaces` + /// test schema (the same assets used by the cache actor tests), for + /// tests that need a real schema to resolve xpaths against. + fn load_test_yang_ctx() -> yang5::context::Context { + yang5::context::Context::new_from_yang_library_file( + std::path::Path::new("../../assets/yang/ietf-interfaces/yang-lib.xml"), + DataFormat::XML, + std::path::Path::new("../../assets/yang/ietf-interfaces/modules"), + yang5::context::ContextFlags::empty(), + ) + .expect("Failed to load test YANG context") + } + + fn test_subscription_info_with_target(target: Target) -> SubscriptionInfo { + SubscriptionInfo::new( + IpAddr::from([127, 0, 0, 1]), + 1, + target, + None, + Some(Transport::UDPNotif), + Some(Encoding::Json), + None, + None, + Box::new([]), + ContentId::from("test-content-id".to_string()), + ) + } + + /// A target xpath that resolves to exactly one schema node and is + /// already in canonical form must not produce any warning. + #[test] + #[tracing_test::traced_test] + fn test_check_xpath_target_resolves_canonical_path_is_silent() { + let yang_ctx = load_test_yang_ctx(); + let subscription_info = test_subscription_info_with_target(Target::new_datastore( + "ietf-datastores:operational".to_string(), + either::Right("/ietf-interfaces:interfaces/interface/oper-status".to_string()), + )); + ValidationActor::check_xpath_target_resolves(&subscription_info, &yang_ctx); + assert!(!logs_contain( + "target xpath differs from the schema canonical form" + )); + assert!(!logs_contain("target xpath does not resolve")); + assert!(!logs_contain("target xpath failed to evaluate")); + } + + /// A target xpath that resolves but is not in libyang's canonical + /// (prefix-on-change) form must warn with the divergence details. + #[test] + #[tracing_test::traced_test] + fn test_check_xpath_target_resolves_non_canonical_path_warns() { + let yang_ctx = load_test_yang_ctx(); + let subscription_info = test_subscription_info_with_target(Target::new_datastore( + "ietf-datastores:operational".to_string(), + either::Right( + "/ietf-interfaces:interfaces/ietf-interfaces:interface/ietf-interfaces:oper-status" + .to_string(), + ), + )); + ValidationActor::check_xpath_target_resolves(&subscription_info, &yang_ctx); + assert!(logs_contain( + "target xpath differs from the schema canonical form" + )); + } + + /// Predicates must be stripped before the canonical-form comparison, so + /// an instantiated key predicate alone does not trigger a false warning. + #[test] + #[tracing_test::traced_test] + fn test_check_xpath_target_resolves_ignores_predicates_when_comparing() { + let yang_ctx = load_test_yang_ctx(); + let subscription_info = test_subscription_info_with_target(Target::new_datastore( + "ietf-datastores:operational".to_string(), + either::Right( + "/ietf-interfaces:interfaces/interface[name='eth0']/oper-status".to_string(), + ), + )); + ValidationActor::check_xpath_target_resolves(&subscription_info, &yang_ctx); + assert!(!logs_contain( + "target xpath differs from the schema canonical form" + )); + } + + /// An xpath referring to a node that doesn't exist in the schema must + /// warn that it does not resolve to any schema node. + #[test] + #[tracing_test::traced_test] + fn test_check_xpath_target_resolves_missing_node_warns() { + let yang_ctx = load_test_yang_ctx(); + let subscription_info = test_subscription_info_with_target(Target::new_datastore( + "ietf-datastores:operational".to_string(), + either::Right("/ietf-interfaces:interfaces/interface/no-such-leaf".to_string()), + )); + ValidationActor::check_xpath_target_resolves(&subscription_info, &yang_ctx); + assert!(logs_contain( + "target xpath does not resolve to any schema node" + )); + } + + /// A syntactically invalid xpath must warn that it failed to evaluate, + /// rather than panicking or silently passing. + #[test] + #[tracing_test::traced_test] + fn test_check_xpath_target_resolves_invalid_xpath_warns() { + let yang_ctx = load_test_yang_ctx(); + let subscription_info = test_subscription_info_with_target(Target::new_datastore( + "ietf-datastores:operational".to_string(), + either::Right("/ietf-interfaces:interfaces[".to_string()), + )); + ValidationActor::check_xpath_target_resolves(&subscription_info, &yang_ctx); + assert!(logs_contain( + "target xpath failed to evaluate against the schema" + )); + } + + /// A target without a datastore xpath filter (e.g. a stream target) + /// must be a no-op: no warnings, no panics. + #[test] + #[tracing_test::traced_test] + fn test_check_xpath_target_resolves_noop_without_xpath_filter() { + let yang_ctx = load_test_yang_ctx(); + let subscription_info = test_subscription_info_with_target(Target::new_stream( + "NETCONF".to_string(), + None, + either::Left(serde_json::Value::Null), + )); + ValidationActor::check_xpath_target_resolves(&subscription_info, &yang_ctx); + assert!(!logs_contain("target xpath")); + } }