diff --git a/crates/netconf-proto/src/client.rs b/crates/netconf-proto/src/client.rs index 479a1c44..a8c4b51c 100644 --- a/crates/netconf-proto/src/client.rs +++ b/crates/netconf-proto/src/client.rs @@ -755,10 +755,14 @@ impl NetConfSshClient { if let Some(RpcResponse::WellKnown(WellKnownRpcResponse::Data(data))) = rpc_reply.reply().responses() { + trace!("[{}] Raw response for filters: `{data}`", self.peer); + let mut reader = NsReader::from_str(data); reader.config_mut().trim_text(true); let mut parser = crate::xml_utils::XmlParser::new(reader)?; let filters = Filters::xml_deserialize(&mut parser)?; + + trace!("[{}] Parsed filters: {filters:?}", self.peer); return Ok(filters); } Err(NetConfSshClientError::UnexpectedMessage { @@ -792,6 +796,10 @@ impl NetConfSshClient { if let Some(RpcResponse::WellKnown(WellKnownRpcResponse::Data(data))) = rpc_reply.reply().responses() { + trace!( + "[{}] Raw response for subscription {id}: `{data}`", + self.peer + ); // Parse the response streams if any returned, filters if any returned // and then the subscription details let mut reader = NsReader::from_str(data); @@ -801,6 +809,10 @@ impl NetConfSshClient { { parser.open(Some(SUBSCRIBED_NOTIFICATIONS_NS), "subscriptions")?; let mut subscription = Subscription::xml_deserialize(&mut parser)?; + trace!( + "[{}] Parsed subscription {id} before target-by-reference resolution: {subscription:?}", + self.peer + ); if let Target::Datastore(datastore_target) = &mut subscription.target && let DatastoreSelectionFilterObjects::ByReference(name) = &datastore_target.selection @@ -818,6 +830,13 @@ impl NetConfSshClient { } } parser.close()?; + trace!( + "[{}] Final subscription {id}: target={:?} module_version={:?} yang_library_content_id={:?}", + self.peer, + subscription.target, + subscription.module_version, + subscription.yang_library_content_id + ); subscription } else { return Err(NetConfSshClientError::ParsingError( diff --git a/crates/netconf-proto/src/xml_utils.rs b/crates/netconf-proto/src/xml_utils.rs index b6a066b1..7d671923 100644 --- a/crates/netconf-proto/src/xml_utils.rs +++ b/crates/netconf-proto/src/xml_utils.rs @@ -764,7 +764,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 = Self::find_xpath_prefixes(&path); + let used_namespaces = find_xpath_prefixes(&path); let namespaces: IndexMap = all_namespaces .into_iter() .filter(|(prefix, _)| used_namespaces.contains(prefix)) @@ -811,59 +811,61 @@ impl<'a, R: io::BufRead> XmlParser<'a, R> { } } } +} - /// Find prefixes used within an Xpath expression - 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; +/// 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; } - if in_double { - if c == '"' { - in_double = false; - } - continue; + continue; + } + if in_double { + if c == '"' { + in_double = false; } - 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; - } + 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 ':' - } + } + // 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 } + prefixes } /// Format a `DateTime` as YANG `date-and-time` (RFC 3339, UTC). @@ -1473,7 +1475,7 @@ mod tests { fn assert_prefixes(expr: &str, expected: HashSet) { assert_eq!( - XmlParser::>::find_xpath_prefixes(expr), + find_xpath_prefixes(expr), expected, "unexpected prefix set for: {expr}" ); diff --git a/crates/netconf-proto/src/yang_push/filters.rs b/crates/netconf-proto/src/yang_push/filters.rs index 2d982505..f2b226dd 100644 --- a/crates/netconf-proto/src/yang_push/filters.rs +++ b/crates/netconf-proto/src/yang_push/filters.rs @@ -1,3 +1,4 @@ +// Copyright (C) 2026-present The NetCalyx Authors. // Copyright (C) 2026-present The NetGauze Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -353,6 +354,19 @@ pub struct DatastoreXPathFilter { pub path: Box, } +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. + pub fn path_prefixes(&self) -> Vec { + let mut prefixes: Vec = crate::xml_utils::find_xpath_prefixes(&self.path) + .into_iter() + .collect(); + prefixes.sort_unstable(); + prefixes + } +} + impl XmlSerialize for DatastoreXPathFilter { fn xml_serialize( &self, diff --git a/crates/netconf-proto/src/yang_push/tests.rs b/crates/netconf-proto/src/yang_push/tests.rs index 8e1a407c..7eed95d7 100644 --- a/crates/netconf-proto/src/yang_push/tests.rs +++ b/crates/netconf-proto/src/yang_push/tests.rs @@ -1,3 +1,4 @@ +// Copyright (C) 2026-present The NetCalyx Authors. // Copyright (C) 2026-present The NetGauze Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -782,3 +783,30 @@ fn test_yang_push_module_version_json_serde() { let deserialized: YangPushModuleVersion = serde_json::from_value(json_value).unwrap(); assert_eq!(deserialized, modeled); } + +#[test] +fn test_datastore_xpath_filter_path_prefixes() { + // Cisco IOS-XR: prefix equals the module name, no xmlns binding declared. + let cisco = DatastoreXPathFilter { + namespaces: Box::new([]), + path: "Cisco-IOS-XR-procmem-oper:processes-memory/nodes/node/process-ids/process-id".into(), + }; + assert_eq!(cisco.path_prefixes(), vec!["Cisco-IOS-XR-procmem-oper"]); + + // Multi-module xpath (Huawei-style), distinct prefixes (sorted). + let multi = DatastoreXPathFilter { + namespaces: Box::new([ + ("devm".into(), "urn:huawei:yang:huawei-devm".into()), + ("driver".into(), "urn:huawei:yang:huawei-driver".into()), + ]), + path: "/devm:devm/devm:chassiss/devm:chassis/driver:power-supply-attribute".into(), + }; + assert_eq!(multi.path_prefixes(), vec!["devm", "driver"]); + + // Unprefixed (default-namespace) steps contribute no prefixes. + let unprefixed = DatastoreXPathFilter { + namespaces: Box::new([]), + path: "/interfaces/interface/state/counters".into(), + }; + assert!(unprefixed.path_prefixes().is_empty()); +} diff --git a/crates/netconf-proto/src/yanglib.rs b/crates/netconf-proto/src/yanglib.rs index f65d7dcc..f31d8ff5 100644 --- a/crates/netconf-proto/src/yanglib.rs +++ b/crates/netconf-proto/src/yanglib.rs @@ -1,3 +1,19 @@ +// Copyright (C) 2026-present The NetCalyx Authors. +// Copyright (C) 2025-present The NetGauze Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. +// See the License for the specific language governing permissions and +// limitations under the License. + use crate::xml_utils::{ParsingError, XmlDeserialize, XmlParser, XmlSerialize, XmlWriter}; use crate::yangparser::{YangDependencies, extract_yang_dependencies}; use crate::{YANG_DATASTORES_NS_STR, YANG_LIBRARY_AUGMENTED_BY_NS, YANG_LIBRARY_NS}; @@ -110,6 +126,14 @@ impl YangLibrary { None } + /// Find a module by namespace, scoped to the module sets referenced by + /// `datastore_name`'s schema (RFC 8525 datastore -> schema -> module-set). + /// + /// Unlike [Self::find_module], this only considers module sets reachable + /// from the given datastore, since different datastores can be backed by + /// different schemas/module-sets and may pin different revisions of the + /// same module. Returns `None` if the datastore, its schema, or a + /// matching module cannot be found. pub fn find_module_by_datastore_and_ns( &self, datastore_name: &DatastoreName, @@ -130,6 +154,30 @@ impl YangLibrary { None } + /// Find a module by name, scoped to the module sets referenced by + /// `datastore_name`'s schema (RFC 8525 datastore -> schema -> module-set). + /// + /// Unlike [Self::find_module], this only considers module sets reachable + /// from the given datastore, since different datastores can be backed by + /// different schemas/module-sets and may pin different revisions of the + /// same module name. Returns `None` if the datastore, its schema, or a + /// matching module cannot be found. + pub fn find_module_by_datastore_and_name( + &self, + datastore_name: &DatastoreName, + name: &str, + ) -> Option<&Module> { + let datastore = self.datastores().get(datastore_name)?; + let schema = self.schemas().get(datastore.schema())?; + for module_set_name in schema.modules_sets() { + let module_set = self.module_sets().get(module_set_name)?; + if let Some(module) = module_set.modules().get(name) { + return Some(module); + } + } + None + } + /// Register the YANG Lib to in the Confluent Schema Registry. /// /// `root_schema_name` is the name of the root module to register. diff --git a/crates/yang-push/src/cache/fetcher.rs b/crates/yang-push/src/cache/fetcher.rs index 6c30570e..60af0b25 100644 --- a/crates/yang-push/src/cache/fetcher.rs +++ b/crates/yang-push/src/cache/fetcher.rs @@ -29,7 +29,9 @@ use crate::cache::storage::{SubscriptionInfo, YangLibraryCacheError}; use netcalyx_netconf_proto::capabilities::{Capability, NetconfVersion}; use netcalyx_netconf_proto::client::{NetconfSshConnectConfig, SshAuth, SshHandler, connect}; -use netcalyx_netconf_proto::yang_push::filters::StreamSelectionFilterObjects; +use netcalyx_netconf_proto::yang_push::filters::{ + DatastoreFilterSpec, DatastoreXPathFilter, StreamSelectionFilterObjects, +}; use netcalyx_netconf_proto::yang_push::subscription::{ DatastoreSelectionFilterObjects, Target, YangPushModuleVersion, }; @@ -49,6 +51,127 @@ pub type FetcherResult = Result< Box<(SubscriptionInfo, YangLibraryCacheError)>, >; +/// Append `module` to `modules` as a [`YangPushModuleVersion`], skipping it if +/// a module with the same name is already present. +fn push_module( + modules: &mut Vec, + module: &netcalyx_netconf_proto::yanglib::Module, +) { + if modules.iter().any(|m| m.name() == module.name()) { + return; + } + modules.push(YangPushModuleVersion::new( + module.name().into(), + module.revision().map(|x| x.into()), + None, + )); +} + +/// Resolve every module referenced by a namespace binding against the device +/// YANG Library. Used for subtree filters and stream filters, where modules +/// are identified by the root element namespace. +fn resolve_by_namespaces( + router_yang_library: &YangLibrary, + ds_name: &DatastoreName, + namespaces: &[(Box, Box)], + empty: &SubscriptionInfo, +) -> Result, Box<(SubscriptionInfo, YangLibraryCacheError)>> { + let mut ret = Vec::with_capacity(namespaces.len()); + for (_prefix, namespace) in namespaces { + let module = router_yang_library + .find_module_by_datastore_and_ns(ds_name, namespace) + .ok_or_else(|| { + error!( + %namespace, + %ds_name, + "target module not found in device YANG Library by namespace", + ); + Box::new(( + empty.clone(), + YangLibraryCacheError::ModuleNamespaceNotFound { + namespace: namespace.clone(), + datastore: ds_name.to_string().into_boxed_str(), + }, + )) + })?; + trace!(namespace=%namespace, module=%module.name(), "resolved target module by namespace"); + push_module(&mut ret, module); + } + Ok(ret) +} + +/// Resolve xpath-filter modules per the RFC 8641 XPath context for +/// `datastore-xpath-filter`: prefixes declared via `xmlns` take precedence +/// (e.g. Huawei), otherwise the prefix is the YANG module name from the +/// server's base context (e.g. Cisco IOS-XR). Both are conformant; we try the +/// declared binding first, then the module name. +fn resolve_by_xpath( + router_yang_library: &YangLibrary, + ds_name: &DatastoreName, + xpath: &DatastoreXPathFilter, + empty: &SubscriptionInfo, +) -> Result, Box<(SubscriptionInfo, YangLibraryCacheError)>> { + let declared: HashMap<&str, &str> = xpath + .namespaces + .iter() + .map(|(prefix, ns)| (prefix.as_ref(), ns.as_ref())) + .collect(); + let mut ret = Vec::new(); + let prefixes = xpath.path_prefixes(); + trace!( + %ds_name, + path = %xpath.path, + ?prefixes, + declared_prefixes = declared.len(), + "resolving target modules from xpath filter", + ); + for prefix in prefixes { + let module = if let Some(namespace) = declared.get(prefix.as_str()) { + router_yang_library + .find_module_by_datastore_and_ns(ds_name, namespace) + .ok_or_else(|| { + error!( + %prefix, + namespace, + %ds_name, + "target module not found for declared xpath prefix namespace", + ); + Box::new(( + empty.clone(), + YangLibraryCacheError::ModuleNamespaceNotFound { + namespace: (*namespace).into(), + datastore: ds_name.to_string().into_boxed_str(), + }, + )) + })? + } else { + // No xmlns binding: per RFC 8641 the prefix is the YANG module + // name in the server's base XPath context. + debug!( + %prefix, + "xpath prefix has no declared namespace binding, resolving it as a module name", + ); + router_yang_library + .find_module_by_datastore_and_name(ds_name, &prefix) + .ok_or_else(|| { + error!( + %prefix, + "target module not found when resolving xpath prefix as a module name", + ); + Box::new(( + empty.clone(), + YangLibraryCacheError::ModulePrefixNotFound( + prefix.clone().into_boxed_str(), + ), + )) + })? + }; + trace!(%prefix, module=%module.name(), "resolved target module from xpath prefix"); + push_module(&mut ret, module); + } + Ok(ret) +} + /// Fetch YANG Library and schemas from an external source pub trait YangLibraryFetcher { /// A non-blocking version which returns a [JoinHandle] @@ -275,89 +398,91 @@ impl NetconfYangLibraryFetcher { let modules = if let Some(modules) = &subscription.module_version { debug!( - peer_ip=%peer_ip, + host=%host, subscription_id, - modules=?modules, - "using module-version reported by device for subscription", + module_count = modules.len(), + "device reported module-version for subscription, using it to resolve target modules", ); modules.clone().to_vec() } else { - let (ds_name, namespaces) = match &subscription.target { - Target::Stream(stream_target) => { - match &stream_target.filter { - StreamSelectionFilterObjects::ByReference(name) => { - // references are resolved in the NETCONF client, - // if we reach this point, there must be a misconfigured router, - return Err(Box::new(( - empty, - YangLibraryCacheError::IoError(std::io::Error::other(format!( - "cannot fetch YANG Library for stream selection filter by reference for {name}" - ))), - ))); - } - StreamSelectionFilterObjects::WithInSubscription(filter) => { - (DatastoreName::Running, filter.namespaces()) - } + let (ds_name, modules) = match &subscription.target { + Target::Stream(stream_target) => match &stream_target.filter { + StreamSelectionFilterObjects::ByReference(name) => { + // references are resolved in the NETCONF client, + // if we reach this point, there must be a misconfigured router, + error!( + %name, + subscription_id, + "stream selection filter reached fetcher unresolved by reference, likely a misconfigured router", + ); + return Err(Box::new(( + empty, + YangLibraryCacheError::UnresolvedFilterReference(name.clone()), + ))); } - } + StreamSelectionFilterObjects::WithInSubscription(filter) => { + let ds_name = DatastoreName::Running; + let modules = resolve_by_namespaces( + &router_yang_library, + &ds_name, + filter.namespaces(), + &empty, + )?; + (ds_name, modules) + } + }, Target::Datastore(datastore_target) => match &datastore_target.selection { DatastoreSelectionFilterObjects::ByReference(name) => { + error!( + %name, + subscription_id, + "datastore selection filter reached fetcher unresolved by reference, likely a misconfigured router", + ); return Err(Box::new(( empty, - YangLibraryCacheError::IoError(std::io::Error::other(format!( - "cannot fetch YANG Library for datastore selection filter by reference for {name}" - ))), + YangLibraryCacheError::UnresolvedFilterReference(name.clone()), ))); } DatastoreSelectionFilterObjects::WithInSubscription(filter) => { - (datastore_target.datastore.clone(), filter.namespaces()) + let ds_name = datastore_target.datastore.clone(); + let modules = match filter { + DatastoreFilterSpec::Xpath(xpath) => { + resolve_by_xpath(&router_yang_library, &ds_name, xpath, &empty)? + } + DatastoreFilterSpec::Subtree(subtree) => resolve_by_namespaces( + &router_yang_library, + &ds_name, + &subtree.namespaces, + &empty, + )?, + }; + (ds_name, modules) } }, }; - debug!( - peer_ip=%peer_ip, - subscription_id, - ds_name=?ds_name, - namespaces=?namespaces, - target=?subscription.target, - "no module-version reported by device, resolving target namespaces against YANG Library instead", - ); - let mut ret = Vec::with_capacity(namespaces.len()); - for (prefix, namespace) in namespaces { - let module = router_yang_library.find_module_by_datastore_and_ns(&ds_name, namespace).ok_or_else(|| { - warn!( - peer_ip=%peer_ip, - subscription_id, - ds_name=?ds_name, - prefix, - namespace, - "module with namespace not found in YANG Library for datastore", - ); - Box::new((empty.clone(), YangLibraryCacheError::IoError(std::io::Error::other(format!("module with namespace {namespace} not found in YANG Library for datastore {ds_name}"))))) - })?; - trace!( - peer_ip=%peer_ip, + if modules.is_empty() { + error!( + host=%host, subscription_id, - prefix, - namespace, - module_name=module.name(), - "resolved xpath-filter prefix to module via namespace", - ); - ret.push(YangPushModuleVersion::new( - module.name().into(), - module.revision().map(|x| x.into()), - None, - )); - } - if ret.is_empty() { - warn!( - peer_ip=%peer_ip, - subscription_id, - target=?subscription.target, - "target namespaces resolution produced no modules; the target's YANG module(s) will not be fetched", + %ds_name, + "no target modules could be resolved from subscription filter", ); + return Err(Box::new(( + empty, + YangLibraryCacheError::NoTargetModulesResolved { + subscription_id, + datastore: ds_name.to_string().into_boxed_str(), + }, + ))); } - ret + debug!( + host=%host, + subscription_id, + %ds_name, + modules = ?modules.iter().map(|m| m.name()).collect::>(), + "resolved target modules from subscription filter", + ); + modules }; let mut module_names = modules.iter().map(|x| x.name()).collect::>(); @@ -387,9 +512,7 @@ impl NetconfYangLibraryFetcher { let subscription_target = subscription.target.try_into().map_err(|err| { Box::new(( empty, - YangLibraryCacheError::IoError(std::io::Error::other(format!( - "invalid subscription target: {err}" - ))), + YangLibraryCacheError::InvalidSubscriptionTarget(format!("{err}").into_boxed_str()), )) })?; let subscription_info = SubscriptionInfo::new( @@ -818,3 +941,265 @@ mod retry_tests { ); } } + +#[cfg(test)] +mod resolve_tests { + use super::*; + use netcalyx_netconf_proto::yang_push::filters::DatastoreXPathFilter; + use netcalyx_netconf_proto::yanglib::{Datastore, Module, ModuleSet, Schema, YangLibrary}; + + fn empty_info() -> SubscriptionInfo { + SubscriptionInfo::new_empty("127.0.0.1".parse().unwrap(), 1) + } + + /// A single-datastore, single-module-set YANG Library fixture. + /// `modules` are `(name, namespace)` pairs. + fn make_yang_library(ds_name: DatastoreName, modules: &[(&str, &str)]) -> YangLibrary { + let modules = modules + .iter() + .map(|(name, ns)| { + Module::new( + (*name).into(), + None, + (*ns).into(), + Box::new([]), + Box::new([]), + Box::new([]), + Box::new([]), + Box::new([]), + ) + }) + .collect(); + YangLibrary::new( + "test-content-id".into(), + vec![ModuleSet::new("modules".into(), modules, vec![])], + vec![Schema::new("schema".into(), Box::new(["modules".into()]))], + vec![Datastore::new(ds_name, "schema".into())], + ) + } + + fn xpath_filter(namespaces: &[(&str, &str)], path: &str) -> DatastoreXPathFilter { + DatastoreXPathFilter { + namespaces: namespaces + .iter() + .map(|(p, ns)| ((*p).into(), (*ns).into())) + .collect(), + path: path.into(), + } + } + + /// Two datastores, each with its own schema/module-set pinning a + /// different revision of the same module name. + fn make_multi_datastore_yang_library() -> YangLibrary { + let module = |revision: &str| { + Module::new( + "foo-mod".into(), + Some(revision.into()), + "urn:example:foo".into(), + Box::new([]), + Box::new([]), + Box::new([]), + Box::new([]), + Box::new([]), + ) + }; + YangLibrary::new( + "test-content-id".into(), + vec![ + ModuleSet::new("operational-set".into(), vec![module("2020-01-01")], vec![]), + ModuleSet::new("running-set".into(), vec![module("2023-01-01")], vec![]), + ], + vec![ + Schema::new("op-schema".into(), Box::new(["operational-set".into()])), + Schema::new("run-schema".into(), Box::new(["running-set".into()])), + ], + vec![ + Datastore::new(DatastoreName::Operational, "op-schema".into()), + Datastore::new(DatastoreName::Running, "run-schema".into()), + ], + ) + } + + #[test] + fn test_resolve_by_namespaces_resolves_each_namespace_to_a_module() { + let yang_lib = make_yang_library( + DatastoreName::Running, + &[("if-mod", "urn:example:interfaces")], + ); + let namespaces: Box<[(Box, Box)]> = + Box::new([("if".into(), "urn:example:interfaces".into())]); + + let result = resolve_by_namespaces( + &yang_lib, + &DatastoreName::Running, + &namespaces, + &empty_info(), + ) + .expect("namespace should resolve to a module"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].name(), "if-mod"); + } + + #[test] + fn test_resolve_by_namespaces_dedups_same_module_seen_twice() { + // Two distinct namespace bindings resolving to the same module must + // only appear once in the result (push_module dedup). + let yang_lib = make_yang_library( + DatastoreName::Running, + &[("if-mod", "urn:example:interfaces")], + ); + let namespaces: Box<[(Box, Box)]> = Box::new([ + ("a".into(), "urn:example:interfaces".into()), + ("b".into(), "urn:example:interfaces".into()), + ]); + + let result = resolve_by_namespaces( + &yang_lib, + &DatastoreName::Running, + &namespaces, + &empty_info(), + ) + .expect("namespaces should resolve"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].name(), "if-mod"); + } + + #[test] + fn test_resolve_by_namespaces_unknown_namespace_is_a_hard_error() { + let yang_lib = make_yang_library(DatastoreName::Running, &[]); + let namespaces: Box<[(Box, Box)]> = + Box::new([("if".into(), "urn:example:unknown".into())]); + + let err = resolve_by_namespaces( + &yang_lib, + &DatastoreName::Running, + &namespaces, + &empty_info(), + ) + .expect_err("unknown namespace must not resolve"); + + assert!(matches!( + err.1, + YangLibraryCacheError::ModuleNamespaceNotFound { .. } + )); + } + + /// Cisco IOS-XR style: no `xmlns` binding declared, prefix equals the + /// YANG module name directly (RFC 8641 base XPath context). + #[test] + fn test_resolve_by_xpath_falls_back_to_module_name_when_undeclared() { + let yang_lib = make_yang_library( + DatastoreName::Running, + &[( + "Cisco-IOS-XR-procmem-oper", + "urn:cisco:params:xml:ns:yang:procmem-oper", + )], + ); + let filter = xpath_filter( + &[], + "/Cisco-IOS-XR-procmem-oper:processes-memory/nodes/node", + ); + + let result = resolve_by_xpath(&yang_lib, &DatastoreName::Running, &filter, &empty_info()) + .expect("undeclared prefix should resolve as a module name"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].name(), "Cisco-IOS-XR-procmem-oper"); + } + + /// The module-name fallback must scope its lookup to the target + /// datastore, the same as the declared-namespace path does — not return + /// whichever module-set happens to be first in the library. Regression + /// test for a module name present, at different revisions, in two + /// datastores' module sets. + #[test] + fn test_resolve_by_xpath_module_name_fallback_is_scoped_by_datastore() { + let yang_lib = make_multi_datastore_yang_library(); + let filter = xpath_filter(&[], "/foo-mod:thing"); + + let result = resolve_by_xpath(&yang_lib, &DatastoreName::Running, &filter, &empty_info()) + .expect("module name should resolve within the running datastore"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].name(), "foo-mod"); + assert_eq!(result[0].revision(), Some("2023-01-01")); + } + + /// Huawei style: `xmlns` binding declared on the filter takes precedence + /// over treating the prefix as a module name. + #[test] + fn test_resolve_by_xpath_prefers_declared_namespace_binding() { + let yang_lib = make_yang_library( + DatastoreName::Running, + &[("huawei-devm", "urn:huawei:yang:huawei-devm")], + ); + let filter = xpath_filter( + &[("devm", "urn:huawei:yang:huawei-devm")], + "/devm:devm/devm:chassis", + ); + + let result = resolve_by_xpath(&yang_lib, &DatastoreName::Running, &filter, &empty_info()) + .expect("declared xmlns binding should resolve the module"); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].name(), "huawei-devm"); + } + + #[test] + fn test_resolve_by_xpath_multiple_distinct_prefixes_resolve_independently() { + let yang_lib = make_yang_library( + DatastoreName::Running, + &[ + ("if-mod", "urn:example:interfaces"), + ("rt-mod", "urn:example:routing"), + ], + ); + let filter = xpath_filter( + &[ + ("if", "urn:example:interfaces"), + ("rt", "urn:example:routing"), + ], + "/if:interfaces/if:interface | /rt:routing/rt:ribs", + ); + + let mut result = + resolve_by_xpath(&yang_lib, &DatastoreName::Running, &filter, &empty_info()) + .expect("both prefixes should resolve") + .into_iter() + .map(|m| m.name().to_string()) + .collect::>(); + result.sort_unstable(); + + assert_eq!(result, vec!["if-mod", "rt-mod"]); + } + + #[test] + fn test_resolve_by_xpath_declared_namespace_not_in_library_is_a_hard_error() { + let yang_lib = make_yang_library(DatastoreName::Running, &[]); + let filter = xpath_filter(&[("devm", "urn:huawei:yang:huawei-devm")], "/devm:devm"); + + let err = resolve_by_xpath(&yang_lib, &DatastoreName::Running, &filter, &empty_info()) + .expect_err("declared namespace missing from library must fail"); + + assert!(matches!( + err.1, + YangLibraryCacheError::ModuleNamespaceNotFound { .. } + )); + } + + #[test] + fn test_resolve_by_xpath_undeclared_prefix_not_a_module_name_is_a_hard_error() { + let yang_lib = make_yang_library(DatastoreName::Running, &[]); + let filter = xpath_filter(&[], "/bogus:interfaces"); + + let err = resolve_by_xpath(&yang_lib, &DatastoreName::Running, &filter, &empty_info()) + .expect_err("prefix with no binding and no matching module must fail"); + + assert!(matches!( + err.1, + YangLibraryCacheError::ModulePrefixNotFound(ref p) if p.as_ref() == "bogus" + )); + } +} diff --git a/crates/yang-push/src/cache/storage.rs b/crates/yang-push/src/cache/storage.rs index c00ad316..5c0331bd 100644 --- a/crates/yang-push/src/cache/storage.rs +++ b/crates/yang-push/src/cache/storage.rs @@ -164,6 +164,31 @@ pub enum YangLibraryCacheError { #[strum(to_string = "failed to connect to netconf server: {0}")] NetConfClientError(netcalyx_netconf_proto::client::NetConfSshClientError), + + #[strum(to_string = "cannot fetch YANG Library for selection filter by reference '{0}'")] + UnresolvedFilterReference(Box), + + #[strum( + to_string = "module with namespace '{namespace}' not found in YANG Library for datastore '{datastore}'" + )] + ModuleNamespaceNotFound { + namespace: Box, + datastore: Box, + }, + + #[strum(to_string = "module '{0}' (used as xpath prefix) not found in YANG Library")] + ModulePrefixNotFound(Box), + + #[strum( + to_string = "no target modules could be resolved from subscription {subscription_id} filter for datastore '{datastore}'" + )] + NoTargetModulesResolved { + subscription_id: SubscriptionId, + datastore: Box, + }, + + #[strum(to_string = "invalid subscription target: {0}")] + InvalidSubscriptionTarget(Box), } impl std::error::Error for YangLibraryCacheError {}