1use crate::ContentId;
96use chrono::{DateTime, Utc};
97use netgauze_netconf_proto::xml_utils::{XmlDeserialize, XmlSerialize, XmlWriter};
98use netgauze_netconf_proto::yang_push::identities::{Encoding, Transport};
99use netgauze_netconf_proto::yang_push::subscription::{UpdateTrigger, YangPushModuleVersion};
100use netgauze_netconf_proto::yang_push::types::SubscriptionId;
101use netgauze_netconf_proto::yanglib::{SchemaLoadingError, YangLibrary};
102use netgauze_udp_notif_pkt::notification::Target;
103use quick_xml::NsReader;
104use rustc_hash::FxHashMap;
105use serde::{Deserialize, Serialize};
106use std::collections::{HashMap, HashSet};
107use std::io::Write;
108use std::net::{IpAddr, SocketAddr};
109use std::path::{Path, PathBuf};
110use std::sync::Arc;
111use tracing::{debug, info, trace, warn};
112
113const MODULE_STATE: &str = r#"
117<modules-state xmlns="urn:ietf:params:xml:ns:yang:ietf-yang-library">
118 <module-set-id>ALL</module-set-id>
119</modules-state>
120"#;
121
122const YANG_LIBRARY_FILE_NAME: &str = "yang-lib.xml";
124
125const SUBSCRIPTIONS_INFO_FILE_NAME: &str = "subscriptions-info.json";
127
128#[derive(Debug, strum_macros::Display)]
133pub enum YangLibraryCacheError {
134 #[strum(to_string = "yang library is not found at path '{0:?}'")]
135 YangLibraryPathNotFound(PathBuf),
136
137 #[strum(to_string = "yang library at path '{0:?}' is not a file")]
138 YangLibraryPathIsNotValid(PathBuf),
139
140 #[strum(to_string = "yang library search path '{0:?}' is not found")]
141 YangLibrarySearchPathNotFound(PathBuf),
142
143 #[strum(to_string = "yang library search path '{0:?}' is not a directory")]
144 YangLibrarySearchPathIsNotValid(PathBuf),
145
146 #[strum(to_string = "duplicate yang library at path '{0:?}', check content id to be unique")]
147 DuplicateYangLibrary(PathBuf),
148
149 #[strum(to_string = "xml parsing error while parsing the yang library: {0}")]
150 ParsingError(netgauze_netconf_proto::xml_utils::ParsingError),
151
152 #[strum(to_string = "schema files loading error: {0}")]
153 SchemaLoadingError(SchemaLoadingError),
154
155 #[strum(to_string = "io error: {0}")]
156 IoError(std::io::Error),
157
158 #[strum(to_string = "quick-xml error: {0}")]
159 QuickXmlError(quick_xml::Error),
160
161 #[strum(to_string = "serde json error: {0}")]
162 SerdeJsonError(serde_json::Error),
163
164 #[strum(to_string = "failed to connect to netconf server: {0}")]
165 NetConfClientError(netgauze_netconf_proto::client::NetConfSshClientError),
166}
167
168impl std::error::Error for YangLibraryCacheError {}
169
170impl From<netgauze_netconf_proto::xml_utils::ParsingError> for YangLibraryCacheError {
171 fn from(err: netgauze_netconf_proto::xml_utils::ParsingError) -> Self {
172 YangLibraryCacheError::ParsingError(err)
173 }
174}
175
176impl From<std::io::Error> for YangLibraryCacheError {
177 fn from(err: std::io::Error) -> Self {
178 YangLibraryCacheError::IoError(err)
179 }
180}
181
182impl From<quick_xml::Error> for YangLibraryCacheError {
183 fn from(err: quick_xml::Error) -> Self {
184 YangLibraryCacheError::QuickXmlError(err)
185 }
186}
187
188impl From<serde_json::Error> for YangLibraryCacheError {
189 fn from(err: serde_json::Error) -> Self {
190 YangLibraryCacheError::SerdeJsonError(err)
191 }
192}
193
194impl From<SchemaLoadingError> for YangLibraryCacheError {
195 fn from(err: SchemaLoadingError) -> Self {
196 YangLibraryCacheError::SchemaLoadingError(err)
197 }
198}
199
200impl From<netgauze_netconf_proto::client::NetConfSshClientError> for YangLibraryCacheError {
201 fn from(err: netgauze_netconf_proto::client::NetConfSshClientError) -> Self {
202 YangLibraryCacheError::NetConfClientError(err)
203 }
204}
205
206impl From<tokio::time::error::Elapsed> for YangLibraryCacheError {
207 fn from(_err: tokio::time::error::Elapsed) -> Self {
208 YangLibraryCacheError::IoError(std::io::Error::new(
209 std::io::ErrorKind::TimedOut,
210 "timeout while waiting for yang library cache lock",
211 ))
212 }
213}
214
215#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
285pub struct YangLibraryReference {
286 content_id: ContentId,
287 yang_library_path: PathBuf,
288 yang_lib_ref_path: PathBuf,
289}
290
291impl YangLibraryReference {
292 pub fn load_from_disk(
301 yang_library_path: PathBuf,
302 yang_lib_ref_path: PathBuf,
303 ) -> Result<Self, YangLibraryCacheError> {
304 if !yang_library_path.exists() {
305 warn!(
306 yang_library_path = %yang_library_path.display(),
307 "YANG library not found at path"
308 );
309 return Err(YangLibraryCacheError::YangLibraryPathNotFound(
310 yang_library_path,
311 ));
312 }
313 if !yang_library_path.is_file() {
314 warn!(
315 yang_library_path = %yang_library_path.display(),
316 "YANG library path is not a file"
317 );
318 return Err(YangLibraryCacheError::YangLibraryPathIsNotValid(
319 yang_library_path,
320 ));
321 }
322 if !yang_lib_ref_path.exists() {
323 warn!(
324 yang_lib_ref_path=%yang_lib_ref_path.display(),
325 "YANG library reference path not found"
326 );
327 return Err(YangLibraryCacheError::YangLibrarySearchPathNotFound(
328 yang_lib_ref_path,
329 ));
330 }
331 if !yang_lib_ref_path.is_dir() {
332 warn!(
333 yang_lib_ref_path=%yang_lib_ref_path.display(),
334 "YANG library reference path is not a directory"
335 );
336 return Err(YangLibraryCacheError::YangLibrarySearchPathIsNotValid(
337 yang_lib_ref_path,
338 ));
339 }
340 debug!(
341 yang_library_path=%yang_library_path.display(),
342 yang_lib_ref_path= %yang_lib_ref_path.display(),
343 "loading YANG library reference",
344 );
345 let subscriptions_info_path = yang_lib_ref_path.join(SUBSCRIPTIONS_INFO_FILE_NAME);
346 let subscriptions_info_file = std::fs::File::open(subscriptions_info_path.as_path()).map_err(|error| {
347 warn!(
348 subscriptions_info_path=%subscriptions_info_path.display(), "failed to open subscriptions info file");
349 std::io::Error::other(format!(
350 "failed to open subscriptions info file {}: {error}",
351 subscriptions_info_path.display()
352 ))
353 })?;
354 let subscriptions_info: Vec<SubscriptionInfo> = serde_json::from_reader(subscriptions_info_file).map_err(|error| {
355 warn!(subscriptions_info_path=%subscriptions_info_path.display(), error=%error, "failed to parse subscriptions info file");
356 YangLibraryCacheError::SerdeJsonError(error)
357 })?;
358 let yang_library = Self::load_yang_library(&yang_library_path)?;
359 let content_id = yang_library.content_id().into();
360 info!(
361 yang_library_path=%yang_library_path.display(),
362 yang_lib_ref_path=%yang_lib_ref_path.display(),
363 content_id,
364 subscriptions_count=subscriptions_info.len(),
365 "loaded YANG library reference",
366 );
367 Ok(Self {
368 content_id,
369 yang_library_path,
370 yang_lib_ref_path,
371 })
372 }
373
374 pub fn save_to_disk(
386 yang_lib_ref_path: PathBuf,
387 yang_library: &YangLibrary,
388 schemas: HashMap<Box<str>, Box<str>>,
389 subscriptions_info: &[SubscriptionInfo],
390 ) -> Result<Self, YangLibraryCacheError> {
391 if !yang_lib_ref_path.exists() {
392 std::fs::create_dir_all(&yang_lib_ref_path).map_err(|error| {
393 warn!(
394 yang_lib_ref_path=%yang_lib_ref_path.display(),
395 content_id=yang_library.content_id(),
396 "failed to create YANG Library Reference dir");
397 std::io::Error::other(format!(
398 "failed to create YANG Library Reference {} dir {}: {error}",
399 yang_library.content_id(),
400 yang_lib_ref_path.display()
401 ))
402 })?
403 }
404 if !yang_lib_ref_path.is_dir() {
405 warn!(
406 yang_lib_ref_path=%yang_lib_ref_path.display(),
407 "YANG library reference path is not a directory"
408 );
409 return Err(YangLibraryCacheError::YangLibrarySearchPathIsNotValid(
410 yang_lib_ref_path,
411 ));
412 }
413 let yang_library_path = yang_lib_ref_path.join(YANG_LIBRARY_FILE_NAME);
414 if yang_library_path.exists() {
415 if !yang_library_path.is_file() {
416 warn!(
417 yang_library_path = %yang_library_path.display(),
418 "YANG library path is not a file"
419 );
420 return Err(YangLibraryCacheError::YangLibraryPathIsNotValid(
421 yang_library_path,
422 ));
423 }
424 let loaded_yang_lib = Self::load_yang_library(yang_library_path.as_path())?;
425 if &loaded_yang_lib != yang_library {
426 warn!(
427 yang_library_path = %yang_library_path.display(),
428 "Different YANG library found, cannot override"
429 );
430 return Err(YangLibraryCacheError::DuplicateYangLibrary(
431 yang_library_path.clone(),
432 ));
433 }
434 } else {
435 let file = std::fs::File::create(&yang_library_path)?;
436 let writer = std::io::BufWriter::new(file);
437 let quick_xml_writer = quick_xml::writer::Writer::new_with_indent(writer, 32, 2);
438 let mut xml_writer = XmlWriter::new(quick_xml_writer);
439 yang_library.xml_serialize(&mut xml_writer)?;
440 let mut inner = xml_writer.into_inner();
441 inner.write_all(MODULE_STATE.as_bytes())?;
442 inner.flush()?;
443 }
444
445 let subscriptions_info_path = yang_lib_ref_path.join(SUBSCRIPTIONS_INFO_FILE_NAME);
446 let subscriptions_info = if subscriptions_info_path.is_file() {
447 debug!(
448 subscriptions_info_path=%subscriptions_info_path.display(),
449 content_id=yang_library.content_id(),
450 "subscriptions info file already exists, extending it with new subscriptions"
451 );
452 let subscriptions_info_file = std::fs::File::open(subscriptions_info_path.as_path())
453 .map_err(|error| {
454 tracing::error!(
455 subscriptions_info_path=%subscriptions_info_path.display(),
456 error=%error,
457 "failed to open subscriptions info file");
458 std::io::Error::other(format!(
459 "failed to open subscriptions info file {}: {error}",
460 subscriptions_info_path.display()
461 ))
462 })?;
463 let mut read_subscriptions_info: HashSet<SubscriptionInfo> =
464 serde_json::from_reader(subscriptions_info_file).map_err(|error| {
465 tracing::error!(
466 subscriptions_info_path=%subscriptions_info_path.display(),
467 error=%error,
468 "failed to parse subscriptions info file"
469 );
470 error
471 })?;
472 read_subscriptions_info.extend(subscriptions_info.iter().cloned());
473 &read_subscriptions_info
474 .into_iter()
475 .collect::<Vec<SubscriptionInfo>>()
476 } else {
477 subscriptions_info
478 };
479
480 let subscriptions_info_file = std::fs::File::create(subscriptions_info_path.as_path())
481 .map_err(|error| {
482 warn!(
483 subscriptions_info_path=%subscriptions_info_path.display(),
484 error=%error,
485 "failed to open subscriptions info file");
486 std::io::Error::other(format!(
487 "failed to open subscriptions info file {}: {error}",
488 subscriptions_info_path.display()
489 ))
490 })?;
491 serde_json::to_writer_pretty(subscriptions_info_file, subscriptions_info)?;
492
493 let modules_path = yang_lib_ref_path.join("modules");
494 std::fs::create_dir_all(&modules_path)?;
495 for (name, schema) in &schemas {
496 let mut revision = None;
497 if let Some(module) = yang_library.find_module(name.as_ref()) {
498 revision = module.revision();
499 } else if let Some(import_only_modules) = yang_library.find_import_module(name.as_ref())
500 {
501 if let Some(import_only_module) = import_only_modules.into_iter().next() {
502 revision = import_only_module.revision();
503 }
504 } else if let Some(submodule) = yang_library.find_submodule(name.as_ref()) {
505 revision = submodule.revision();
506 }
507 let filename = if let Some(revision) = revision {
508 format!("{name}@{revision}.yang")
509 } else {
510 name.to_string()
511 };
512 let yang_module_path = modules_path.join(&filename);
513 std::fs::write(&yang_module_path, schema.as_ref())?;
514 debug!(yang_module_path = %yang_module_path.display(), "saved yang module to disk");
515 }
516 info!(
517 content_id=yang_library.content_id(),
518 modules_count = schemas.len(),
519 modules_path = %modules_path.display(),
520 "saved YANG modules to disk"
521 );
522
523 Ok(Self {
524 content_id: yang_library.content_id().into(),
525 yang_library_path,
526 yang_lib_ref_path,
527 })
528 }
529
530 fn load_yang_library(yang_library_path: &Path) -> Result<YangLibrary, YangLibraryCacheError> {
532 let reader = NsReader::from_file(yang_library_path)
533 .map_err(|error| {
534 warn!(yang_library_path=%yang_library_path.display(), error=%error, "failed to open yang library file");
535 YangLibraryCacheError::ParsingError(error.into())
536 })?;
537 trace!(yang_library_path=%yang_library_path.display(), "parsing yang library from disk");
538 let mut xml_reader = netgauze_netconf_proto::xml_utils::XmlParser::new(reader)?;
539 let yang_library = YangLibrary::xml_deserialize(&mut xml_reader)?;
540 Ok(yang_library)
541 }
542 pub const fn content_id(&self) -> &ContentId {
544 &self.content_id
545 }
546
547 pub fn yang_library_path(&self) -> &Path {
549 self.yang_library_path.as_path()
550 }
551
552 pub fn yang_library(&self) -> Result<YangLibrary, YangLibraryCacheError> {
554 Self::load_yang_library(self.yang_library_path.as_path())
555 }
556
557 pub fn yang_lib_ref_path(&self) -> &Path {
559 self.yang_lib_ref_path.as_path()
560 }
561
562 pub fn search_dir(&self) -> PathBuf {
564 self.yang_lib_ref_path().join("modules")
565 }
566
567 pub fn load_schemas(&self) -> Result<HashMap<Box<str>, Box<str>>, YangLibraryCacheError> {
569 let yang_library = Self::load_yang_library(self.yang_library_path.as_path())?;
570 let search_dir = self.search_dir();
571 info!(
572 search_dir = %search_dir.display(),
573 "loading yang schemas from search directory",
574 );
575
576 match yang_library.load_schemas_from_search_path(search_dir.as_path()) {
577 Ok(schemas) => {
578 debug!(
579 schemas_count = schemas.len(),
580 "loaded yang schemas successfully"
581 );
582 Ok(schemas)
583 }
584 Err(err) => {
585 warn!(
586 search_dir = %search_dir.display(),
587 error = %err,
588 "failed to load yang schemas from search directory"
589 );
590 Err(err.into())
591 }
592 }
593 }
594
595 pub fn subscription_info_path(&self) -> PathBuf {
596 self.yang_lib_ref_path.join(SUBSCRIPTIONS_INFO_FILE_NAME)
597 }
598
599 pub fn subscriptions_info(&self) -> Result<Vec<SubscriptionInfo>, YangLibraryCacheError> {
600 let subscriptions_info_path = self.subscription_info_path();
601 debug!(
602 subscriptions_info_path = %subscriptions_info_path.display(),
603 content_id=self.content_id,
604 "loading subscription info from disk"
605 );
606 let subscriptions_info_file = std::fs::File::open(subscriptions_info_path.as_path())
607 .map_err(|error| {
608 std::io::Error::other(format!(
609 "failed to read subscriptions info from {}: {error}",
610 subscriptions_info_path.display()
611 ))
612 })?;
613 let subscriptions_info = serde_json::from_reader(subscriptions_info_file)?;
614 Ok(subscriptions_info)
615 }
616
617 pub fn append_subscription_info(
630 &self,
631 subscription_info: SubscriptionInfo,
632 ) -> Result<(), YangLibraryCacheError> {
633 let subscriptions_info_path = self.subscription_info_path();
634
635 let file = std::fs::OpenOptions::new()
636 .read(true)
637 .write(true)
638 .create(true)
639 .truncate(false)
640 .open(&subscriptions_info_path)?;
641
642 file.lock().map_err(|e| {
643 YangLibraryCacheError::IoError(std::io::Error::other(format!(
644 "failed to acquire lock on {}: {e}",
645 subscriptions_info_path.display()
646 )))
647 })?;
648
649 let result = (|| {
651 let mut subscriptions_info: Vec<SubscriptionInfo> = serde_json::from_reader(&file)?;
652
653 if subscriptions_info
654 .iter()
655 .any(|info| info == &subscription_info)
656 {
657 return Ok(());
658 }
659
660 subscriptions_info.push(subscription_info);
661
662 let temp_path = subscriptions_info_path.with_extension(".tmp");
664 let temp_file = std::fs::File::create(&temp_path)?;
665 serde_json::to_writer_pretty(temp_file, &subscriptions_info)?;
666 std::fs::rename(temp_path, &subscriptions_info_path)?;
667
668 Ok(())
669 })();
670
671 file.unlock()?;
672 result
673 }
674
675 pub fn remove_subscription_info(
680 &self,
681 subscription_info: &SubscriptionInfo,
682 ) -> Result<Option<SubscriptionInfo>, YangLibraryCacheError> {
683 let subscriptions_info_path = self.subscription_info_path();
684
685 let file = std::fs::OpenOptions::new()
686 .read(true)
687 .write(true)
688 .open(&subscriptions_info_path)?;
689
690 file.lock().map_err(|e| {
691 YangLibraryCacheError::IoError(std::io::Error::other(format!(
692 "failed to acquire lock on {}: {e}",
693 subscriptions_info_path.display()
694 )))
695 })?;
696
697 let result = (|| {
698 let mut subscriptions_info: Vec<SubscriptionInfo> = serde_json::from_reader(&file)?;
699
700 let original_len = subscriptions_info.len();
701 subscriptions_info.retain(|info| info != subscription_info);
702
703 if subscriptions_info.len() == original_len {
704 return Ok(None);
705 }
706
707 let temp_path = subscriptions_info_path.with_extension(".tmp");
709 let temp_file = std::fs::File::create(&temp_path)?;
710 serde_json::to_writer_pretty(temp_file, &subscriptions_info)?;
711 std::fs::rename(temp_path, &subscriptions_info_path)?;
712
713 Ok(Some(subscription_info.clone()))
714 })();
715
716 file.unlock()?;
717 result
718 }
719}
720
721#[derive(Clone, Debug, Eq, serde::Serialize, serde::Deserialize)]
784pub struct SubscriptionInfo {
785 pub collector: SocketAddr,
786 pub interface: Option<String>,
787 pub peer: SocketAddr,
788 pub id: SubscriptionId,
789 pub target: Target,
790
791 #[serde(skip_serializing_if = "Option::is_none")]
792 pub stop_time: Option<DateTime<Utc>>,
793
794 #[serde(skip_serializing_if = "Option::is_none")]
795 pub transport: Option<Transport>,
796
797 #[serde(skip_serializing_if = "Option::is_none")]
798 pub encoding: Option<Encoding>,
799
800 #[serde(skip_serializing_if = "Option::is_none")]
801 pub purpose: Option<Box<str>>,
802
803 #[serde(skip_serializing_if = "Option::is_none")]
804 pub update_trigger: Option<UpdateTrigger>,
805
806 pub models: Box<[YangPushModuleVersion]>,
807
808 pub content_id: ContentId,
809}
810
811impl std::hash::Hash for SubscriptionInfo {
812 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
813 self.peer.ip().hash(state);
814 self.id.hash(state);
815 self.target.hash(state);
816 self.stop_time.hash(state);
817 self.transport.hash(state);
818 self.encoding.hash(state);
819 self.purpose.hash(state);
820 self.update_trigger.hash(state);
821 self.models.hash(state);
822 self.content_id.hash(state);
823 }
824}
825
826impl PartialEq for SubscriptionInfo {
827 fn eq(&self, other: &Self) -> bool {
828 self.peer.ip() == other.peer.ip()
829 && self.id == other.id
830 && self.target == other.target
831 && self.stop_time == other.stop_time
832 && self.transport == other.transport
833 && self.encoding == other.encoding
834 && self.purpose == other.purpose
835 && self.update_trigger == other.update_trigger
836 && self.models == other.models
837 && self.content_id == other.content_id
838 }
839}
840
841impl SubscriptionInfo {
842 #[allow(clippy::too_many_arguments)]
843 pub fn new(
844 collector: SocketAddr,
845 interface: Option<String>,
846 peer: SocketAddr,
847 id: SubscriptionId,
848 target: Target,
849 stop_time: Option<DateTime<Utc>>,
850 transport: Option<Transport>,
851 encoding: Option<Encoding>,
852 purpose: Option<Box<str>>,
853 update_trigger: Option<UpdateTrigger>,
854 models: Box<[YangPushModuleVersion]>,
855 content_id: ContentId,
856 ) -> Self {
857 Self {
858 collector,
859 interface,
860 peer,
861 id,
862 target,
863 stop_time,
864 transport,
865 encoding,
866 purpose,
867 update_trigger,
868 models,
869 content_id,
870 }
871 }
872
873 pub fn new_empty(
877 collector: SocketAddr,
878 interface: Option<String>,
879 peer: SocketAddr,
880 id: SubscriptionId,
881 ) -> Self {
882 Self {
883 collector,
884 interface,
885 peer,
886 id,
887 target: Target::new_datastore("EMPTY".into(), either::Right("EMPTY".into())),
888 stop_time: None,
889 transport: None,
890 encoding: None,
891 purpose: None,
892 models: Box::new([]),
893 update_trigger: None,
894 content_id: "EMPTY".to_string(),
895 }
896 }
897
898 pub fn is_empty(&self) -> bool {
899 self.content_id == "EMPTY"
900 }
901
902 pub const fn peer(&self) -> SocketAddr {
905 self.peer
906 }
907
908 pub const fn collector(&self) -> SocketAddr {
910 self.collector
911 }
912
913 pub fn interface(&self) -> Option<String> {
915 self.interface.clone()
916 }
917
918 pub const fn id(&self) -> SubscriptionId {
921 self.id
922 }
923
924 pub const fn stop_time(&self) -> Option<DateTime<Utc>> {
925 self.stop_time
926 }
927
928 pub const fn transport(&self) -> Option<Transport> {
929 self.transport
930 }
931
932 pub const fn encoding(&self) -> Option<Encoding> {
933 self.encoding
934 }
935
936 pub fn purpose(&self) -> Option<&str> {
937 self.purpose.as_deref()
938 }
939
940 pub const fn content_id(&self) -> &ContentId {
943 &self.content_id
944 }
945
946 pub const fn target(&self) -> &Target {
948 &self.target
949 }
950
951 pub const fn update_trigger(&self) -> Option<&UpdateTrigger> {
952 self.update_trigger.as_ref()
953 }
954
955 pub fn models(&self) -> &[YangPushModuleVersion] {
957 &self.models
958 }
959}
960
961impl std::fmt::Display for SubscriptionInfo {
962 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
963 write!(
964 f,
965 "SubscriptionInfo {{ peer: {}, content_id: {}, target: {}, models: {:?} }}",
966 self.peer, self.content_id, self.target, self.models
967 )
968 }
969}
970
971pub struct YangLibraryCache {
990 cache_by_content_id: FxHashMap<ContentId, Arc<YangLibraryReference>>,
991 cache_by_subscription_info: FxHashMap<SubscriptionInfo, Arc<YangLibraryReference>>,
992 cache_by_subscription_id: FxHashMap<IpAddr, FxHashMap<SubscriptionId, SubscriptionInfo>>,
993 cache_root_path: PathBuf,
994}
995
996impl YangLibraryCache {
997 pub fn new(cache_root_path: PathBuf) -> Self {
999 Self {
1000 cache_by_content_id: FxHashMap::default(),
1001 cache_by_subscription_info: FxHashMap::default(),
1002 cache_by_subscription_id: FxHashMap::default(),
1003 cache_root_path,
1004 }
1005 }
1006
1007 pub fn from_disk(cache_root_path: PathBuf) -> Result<Self, YangLibraryCacheError> {
1014 debug!(
1015 cache_root_path = %cache_root_path.display(),
1016 "loading YANG Library references cache",
1017 );
1018 let mut cache_by_content_id = FxHashMap::default();
1019 let mut cache_by_subscription_info = FxHashMap::default();
1020 let mut cache_by_subscription_id: FxHashMap<
1021 IpAddr,
1022 FxHashMap<SubscriptionId, SubscriptionInfo>,
1023 > = FxHashMap::default();
1024 let read_dir = std::fs::read_dir(cache_root_path.as_path()).map_err(|error| {
1025 std::io::Error::other(format!(
1026 "failed to read cached root path {}: {error}",
1027 cache_root_path.display()
1028 ))
1029 })?;
1030 for dir in read_dir {
1031 let entry = dir.map_err(|error| {
1032 std::io::Error::other(format!(
1033 "failed to read cached entry in root path {}: {error}",
1034 cache_root_path.display()
1035 ))
1036 })?;
1037 let path = entry.path();
1038 if !path.is_dir() {
1039 continue;
1040 }
1041 let yang_lib_path = path.join(YANG_LIBRARY_FILE_NAME);
1042 let yang_lib_ref = YangLibraryReference::load_from_disk(yang_lib_path, path.clone())?;
1043 let yang_lib_ref = Arc::new(yang_lib_ref);
1044 _ = yang_lib_ref.load_schemas()?;
1046 let content_id = yang_lib_ref.content_id().clone();
1047 let subscriptions_info = yang_lib_ref.subscriptions_info()?;
1048 info!(
1049 yang_library_path = %path.display(),
1050 subscriptions_info = ?subscriptions_info,
1051 "loaded yang library reference ",
1052 );
1053 cache_by_content_id.insert(content_id, Arc::clone(&yang_lib_ref));
1054 for subscription_info in subscriptions_info {
1055 cache_by_subscription_id
1056 .entry(subscription_info.peer().ip())
1057 .or_default()
1058 .insert(subscription_info.id(), subscription_info.clone());
1059 cache_by_subscription_info.insert(subscription_info, Arc::clone(&yang_lib_ref));
1060 }
1061 }
1062 info!(
1063 total_entries = %cache_by_content_id.len(),
1064 "loaded YANG Library references cache",
1065 );
1066 Ok(Self {
1067 cache_by_content_id,
1068 cache_by_subscription_info,
1069 cache_by_subscription_id,
1070 cache_root_path,
1071 })
1072 }
1073
1074 pub fn put_yang_library(
1081 &mut self,
1082 subscription_info: SubscriptionInfo,
1083 yang_lib: YangLibrary,
1084 schemas: HashMap<Box<str>, Box<str>>,
1085 ) -> Result<Arc<YangLibraryReference>, YangLibraryCacheError> {
1086 let content_id = yang_lib.content_id();
1087 if let Some(existing_ref) = self.cache_by_content_id.get(content_id) {
1089 if !self
1090 .cache_by_subscription_info
1091 .contains_key(&subscription_info)
1092 {
1093 existing_ref.append_subscription_info(subscription_info.clone())?;
1094 self.cache_by_subscription_info
1095 .insert(subscription_info.clone(), Arc::clone(existing_ref));
1096 self.cache_by_subscription_id
1097 .entry(subscription_info.peer().ip())
1098 .or_default()
1099 .insert(subscription_info.id(), subscription_info);
1100 }
1101 return Ok(Arc::clone(existing_ref));
1102 }
1103 let entry_path = self.cache_root_path.join(yang_lib.content_id());
1105 let yang_lib_ref = YangLibraryReference::save_to_disk(
1106 entry_path,
1107 &yang_lib,
1108 schemas,
1109 std::slice::from_ref(&subscription_info),
1110 )?;
1111 let content_id = yang_lib_ref.content_id().clone();
1112 let yang_lib_ref = Arc::new(yang_lib_ref);
1113 self.cache_by_subscription_info
1114 .insert(subscription_info.clone(), Arc::clone(&yang_lib_ref));
1115 self.cache_by_content_id
1116 .insert(content_id, Arc::clone(&yang_lib_ref));
1117 self.cache_by_subscription_id
1118 .entry(subscription_info.peer().ip())
1119 .or_default()
1120 .insert(subscription_info.id(), subscription_info);
1121 Ok(yang_lib_ref)
1122 }
1123
1124 pub fn remove_by_content_id(
1125 &mut self,
1126 content_id: &ContentId,
1127 ) -> Result<(), YangLibraryCacheError> {
1128 info!(content_id, "removing yang library for content from cache");
1129 if let Some(yang_lib_ref) = self.cache_by_content_id.remove(content_id) {
1130 self.cache_by_subscription_info
1131 .retain(|_, v| v.content_id() != content_id);
1132 self.cache_by_subscription_id.retain(|_, sub_map| {
1133 sub_map.retain(|_, sub_info| sub_info.content_id() != content_id);
1134 !sub_map.is_empty()
1135 });
1136 std::fs::remove_file(yang_lib_ref.yang_library_path())?;
1137 std::fs::remove_dir_all(yang_lib_ref.yang_lib_ref_path())?;
1138 info!(
1139 content_id,
1140 "removed yang library with content id from cache"
1141 );
1142 } else {
1143 info!(
1144 content_id,
1145 "no yang library reference found for content id in cache"
1146 );
1147 }
1148 Ok(())
1149 }
1150
1151 pub fn remove_by_subscription_info(
1152 &mut self,
1153 subscription_info: &SubscriptionInfo,
1154 ) -> Result<Option<SubscriptionInfo>, YangLibraryCacheError> {
1155 let yang_lib_ref = match self.cache_by_subscription_info.remove(subscription_info) {
1157 Some(yang_lib_ref) => yang_lib_ref,
1158 None => return Ok(None),
1159 };
1160
1161 if let Some(sub_map) = self
1163 .cache_by_subscription_id
1164 .get_mut(&subscription_info.peer().ip())
1165 {
1166 sub_map.remove(&subscription_info.id());
1167 if sub_map.is_empty() {
1168 self.cache_by_subscription_id
1169 .remove(&subscription_info.peer().ip());
1170 }
1171 }
1172
1173 let content_id_still_in_use = self
1175 .cache_by_subscription_info
1176 .values()
1177 .any(|lib_ref| lib_ref.content_id() == yang_lib_ref.content_id());
1178
1179 if !content_id_still_in_use {
1182 if let Some(yang_lib_ref) = self.cache_by_content_id.remove(yang_lib_ref.content_id()) {
1183 yang_lib_ref.remove_subscription_info(subscription_info)?;
1185
1186 let content_dir = self.cache_root_path.join(yang_lib_ref.content_id());
1188 if content_dir.exists() {
1189 std::fs::remove_dir_all(&content_dir).map_err(|error| {
1190 YangLibraryCacheError::IoError(std::io::Error::other(format!(
1191 "Failed to remove directory {}: {error}",
1192 content_dir.display(),
1193 )))
1194 })?;
1195 }
1196 }
1197 } else {
1198 if let Some(yang_lib_ref) = self.cache_by_content_id.get_mut(yang_lib_ref.content_id())
1201 {
1202 yang_lib_ref.remove_subscription_info(subscription_info)?;
1203 }
1204 }
1205 Ok(Some(subscription_info.clone()))
1206 }
1207
1208 pub fn get_by_subscription_info(
1209 &self,
1210 subscription_info: &SubscriptionInfo,
1211 ) -> Option<Arc<YangLibraryReference>> {
1212 let result = self
1213 .cache_by_subscription_info
1214 .get(subscription_info)
1215 .map(Arc::clone);
1216 debug!(
1217 peer=%subscription_info.peer(),
1218 subscription_id=subscription_info.id(),
1219 router_content_id=subscription_info.content_id(),
1220 target=%subscription_info.target(),
1221 hit = result.is_some(),
1222 "cache lookup by subscription info"
1223 );
1224 result
1225 }
1226
1227 pub fn get_by_content_id(
1228 &mut self,
1229 content_id: &ContentId,
1230 ) -> Option<Arc<YangLibraryReference>> {
1231 let result = self.cache_by_content_id.get(content_id).map(Arc::clone);
1232 debug!(
1233 content_id,
1234 hit = result.is_some(),
1235 "cache lookup by content id"
1236 );
1237 result
1238 }
1239
1240 pub fn get_by_subscription_id(
1241 &self,
1242 peer_ip: IpAddr,
1243 subscription_id: SubscriptionId,
1244 ) -> Option<(SubscriptionInfo, Option<Arc<YangLibraryReference>>)> {
1245 let result = self
1246 .cache_by_subscription_id
1247 .get(&peer_ip)
1248 .and_then(|x| x.get(&subscription_id))
1249 .cloned()
1250 .map(|subscription_info| {
1251 let yang_lib_ref = self
1252 .cache_by_subscription_info
1253 .get(&subscription_info)
1254 .map(Arc::clone);
1255 (subscription_info, yang_lib_ref)
1256 });
1257 debug!(
1258 peer_ip=%peer_ip,
1259 subscription_id,
1260 hit = result.is_some(),
1261 "cache lookup by subscription id"
1262 );
1263 result
1264 }
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269 use super::*;
1270 use netgauze_netconf_proto::yang_push::types::CentiSeconds;
1271 use std::net::{IpAddr, Ipv4Addr};
1272 use tempfile::TempDir;
1273
1274 fn create_test_subscription_info(content_id: &str) -> SubscriptionInfo {
1275 SubscriptionInfo::new(
1276 SocketAddr::from(([192, 168, 1, 100], 10000)),
1277 None,
1278 SocketAddr::from(([192, 168, 1, 101], 830)),
1279 1,
1280 Target::new_datastore(
1281 "ds:operational".into(),
1282 either::Right("/ietf-interfaces:interfaces/ietf-interfaces:interface[ietf-interfaces:name='eth0']/statistics".into()),
1283 ),
1284 None,
1285 Some(Transport::UDPNotif),
1286 Some(Encoding::Json),
1287 Some("test subscription".into()),
1288 Some(UpdateTrigger::OnChange {
1289 dampening_period: None,
1290 sync_on_start: Some(true),
1291 excluded_change: None,
1292 }),
1293 Box::new([
1294 YangPushModuleVersion::new("ietf-interfaces".into(), Some("2018-02-20".into()), None),
1295 YangPushModuleVersion::new("ietf-ip".into(), None, None),
1296 ]),
1297 ContentId::from(content_id.to_string()),
1298 )
1299 }
1300
1301 fn create_minimal_yang_library_xml(content_id: &str) -> String {
1302 format!(
1303 r#"<?xml version="1.0" encoding="UTF-8"?>
1304<yang-library xmlns="urn:ietf:params:xml:ns:yang:ietf-yang-library">
1305 <module-set>
1306 <name>test-module-set</name>
1307 <module>
1308 <name>test-module</name>
1309 <revision>2025-12-12</revision>
1310 <namespace>urn:test:test-module</namespace>
1311 </module>
1312 </module-set>
1313 <schema>
1314 <name>test-schema</name>
1315 <module-set>test-module-set</module-set>
1316 </schema>
1317 <datastore>
1318 <name xmlns:ds="urn:ietf:params:xml:ns:yang:ietf-datastores">ds:running</name>
1319 <schema>test-schema</schema>
1320 </datastore>
1321 <content-id>{content_id}</content-id>
1322</yang-library>"#
1323 )
1324 }
1325
1326 fn create_test_yang_module() -> &'static str {
1327 r#"module test-module {
1328 namespace "urn:test:test-module";
1329 prefix tm;
1330
1331 revision 2025-12-12;
1332
1333 container test {
1334 leaf name {
1335 type string;
1336 }
1337 }
1338}"#
1339 }
1340
1341 fn setup_yang_library_on_disk(
1342 temp_dir: &TempDir,
1343 content_id: &str,
1344 subscriptions_info: &[SubscriptionInfo],
1345 ) -> Result<PathBuf, YangLibraryCacheError> {
1346 let entry_path = temp_dir.path().join(content_id);
1347 std::fs::create_dir_all(&entry_path)?;
1348
1349 let yang_lib_path = entry_path.join(YANG_LIBRARY_FILE_NAME);
1351 let yang_lib_xml = create_minimal_yang_library_xml(content_id);
1352 std::fs::write(&yang_lib_path, yang_lib_xml)?;
1353
1354 let subscriptions_info_path = entry_path.join(SUBSCRIPTIONS_INFO_FILE_NAME);
1356 let subscriptions_info_file = std::fs::File::create(&subscriptions_info_path)?;
1357 serde_json::to_writer_pretty(subscriptions_info_file, subscriptions_info)?;
1358
1359 let modules_path = entry_path.join("modules");
1361 std::fs::create_dir_all(&modules_path)?;
1362 let module_path = modules_path.join("test-module@2025-12-12.yang");
1363 debug!("creating test module at {module_path:?}");
1364 std::fs::write(&module_path, create_test_yang_module())?;
1365
1366 Ok(entry_path)
1367 }
1368
1369 #[test]
1370 #[tracing_test::traced_test]
1371 fn test_yang_library_cache_new() {
1372 let temp_dir = TempDir::new().unwrap();
1373 let cache = YangLibraryCache::new(temp_dir.path().to_path_buf());
1374
1375 assert!(cache.cache_by_content_id.is_empty());
1376 assert!(cache.cache_by_subscription_info.is_empty());
1377 assert_eq!(cache.cache_root_path, temp_dir.path());
1378 }
1379
1380 #[test]
1381 #[tracing_test::traced_test]
1382 fn test_yang_library_reference_new_file_not_found() {
1383 let temp_dir = TempDir::new().unwrap();
1384 let yang_lib_path = temp_dir.path().join("nonexistent.xml");
1385 let search_dir = temp_dir.path().to_path_buf();
1386
1387 let result = YangLibraryReference::load_from_disk(yang_lib_path.clone(), search_dir);
1388 assert!(matches!(
1389 result,
1390 Err(YangLibraryCacheError::YangLibraryPathNotFound(_))
1391 ));
1392 }
1393
1394 #[test]
1395 #[tracing_test::traced_test]
1396 fn test_yang_library_reference_new_path_is_directory() {
1397 let temp_dir = TempDir::new().unwrap();
1398 let yang_lib_path = temp_dir.path().to_path_buf();
1399 let search_dir = temp_dir.path().to_path_buf();
1400
1401 let result = YangLibraryReference::load_from_disk(yang_lib_path.clone(), search_dir);
1402 assert!(matches!(
1403 result,
1404 Err(YangLibraryCacheError::YangLibraryPathIsNotValid(_))
1405 ));
1406 }
1407
1408 #[test]
1409 #[tracing_test::traced_test]
1410 fn test_yang_library_reference_new_search_dir_not_found() {
1411 let temp_dir = TempDir::new().unwrap();
1412 let yang_lib_path = temp_dir.path().join("yang-lib.xml");
1413 std::fs::write(&yang_lib_path, create_minimal_yang_library_xml("test-id")).unwrap();
1414 let search_dir = temp_dir.path().join("nonexistent");
1415
1416 let result = YangLibraryReference::load_from_disk(yang_lib_path, search_dir.clone());
1417 assert!(matches!(
1418 result,
1419 Err(YangLibraryCacheError::YangLibrarySearchPathNotFound(_))
1420 ));
1421 }
1422
1423 #[test]
1424 #[tracing_test::traced_test]
1425 fn test_yang_library_reference_new_search_dir_is_file() {
1426 let temp_dir = TempDir::new().unwrap();
1427 let yang_lib_path = temp_dir.path().join("yang-lib.xml");
1428 std::fs::write(&yang_lib_path, create_minimal_yang_library_xml("test-id")).unwrap();
1429 let search_dir = temp_dir.path().join("file.txt");
1430 std::fs::write(&search_dir, "test").unwrap();
1431
1432 let result = YangLibraryReference::load_from_disk(yang_lib_path, search_dir.clone());
1433 assert!(matches!(
1434 result,
1435 Err(YangLibraryCacheError::YangLibrarySearchPathIsNotValid(_))
1436 ));
1437 }
1438
1439 #[test]
1440 #[tracing_test::traced_test]
1441 fn test_yang_library_reference_new_success() {
1442 let temp_dir = TempDir::new().unwrap();
1443 let content_id = "test-content-id-123";
1444 let subscription_info = create_test_subscription_info(content_id);
1445 let entry_path = setup_yang_library_on_disk(
1446 &temp_dir,
1447 content_id,
1448 std::slice::from_ref(&subscription_info),
1449 )
1450 .unwrap();
1451 let yang_lib_path = entry_path.join(YANG_LIBRARY_FILE_NAME);
1452
1453 let result =
1454 YangLibraryReference::load_from_disk(yang_lib_path.clone(), entry_path.clone());
1455 assert!(result.is_ok());
1456
1457 let yang_lib_ref = result.unwrap();
1458 assert_eq!(yang_lib_ref.content_id(), content_id);
1459 assert_eq!(yang_lib_ref.yang_library_path(), yang_lib_path);
1460 assert_eq!(yang_lib_ref.yang_lib_ref_path(), entry_path);
1461 }
1462
1463 #[test]
1464 #[tracing_test::traced_test]
1465 fn test_yang_library_reference_yang_library() {
1466 let temp_dir = TempDir::new().unwrap();
1467 let content_id = "test-content-id-456";
1468 let subscription_info = create_test_subscription_info(content_id);
1469 let entry_path = setup_yang_library_on_disk(
1470 &temp_dir,
1471 content_id,
1472 std::slice::from_ref(&subscription_info),
1473 )
1474 .unwrap();
1475 let yang_lib_path = entry_path.join(YANG_LIBRARY_FILE_NAME);
1476
1477 let yang_lib_ref = YangLibraryReference::load_from_disk(yang_lib_path, entry_path).unwrap();
1478 let yang_lib = yang_lib_ref.yang_library().unwrap();
1479
1480 assert_eq!(yang_lib.content_id(), content_id);
1481 }
1482
1483 #[test]
1484 #[tracing_test::traced_test]
1485 fn test_subscription_info_new() {
1486 let collector = SocketAddr::from(([192, 168, 1, 100], 10000));
1487 let peer = SocketAddr::from(([192, 168, 1, 101], 12345));
1488 let content_id = ContentId::from("content-123");
1489 let target = Target::new_datastore(
1490 "ds:operational".into(),
1491 either::Right("/ietf-interfaces:interfaces/ietf-interfaces:interface[ietf-interfaces:name='eth0']/statistics".into()),
1492 );
1493
1494 let models = Box::new([
1495 YangPushModuleVersion::new("model1".into(), None, None),
1496 YangPushModuleVersion::new("model2".into(), None, None),
1497 ]);
1498 let info = SubscriptionInfo::new(
1499 collector,
1500 None,
1501 peer,
1502 1,
1503 Target::new_datastore(
1504 "ds:operational".into(),
1505 either::Right("/ietf-interfaces:interfaces/ietf-interfaces:interface[ietf-interfaces:name='eth0']/statistics".into()),
1506 ),
1507 None,
1508 Some(Transport::UDPNotif),
1509 Some(Encoding::Json),
1510 Some("test subscription".into()),
1511 Some(UpdateTrigger::OnChange {
1512 dampening_period: None,
1513 sync_on_start: Some(true),
1514 excluded_change: None,
1515 }),
1516 models.clone(),
1517 content_id.clone(),
1518 );
1519
1520 assert_eq!(info.peer(), peer);
1521 assert_eq!(info.content_id(), &content_id);
1522 assert_eq!(info.target(), &target);
1523 assert_eq!(info.models(), models.as_slice());
1524 }
1525
1526 #[test]
1527 #[tracing_test::traced_test]
1528 fn test_subscription_info_display() {
1529 let info = create_test_subscription_info("test-id");
1530 let display = format!("{info}");
1531
1532 assert!(display.contains("192.168.1.101:830"));
1533 assert!(display.contains("test-id"));
1534 assert!(display.contains("/ietf-interfaces:interfaces/ietf-interfaces:interface[ietf-interfaces:name='eth0']/statistics"));
1535 assert!(display.contains("ietf-interfaces"));
1536 }
1537
1538 #[test]
1539 #[tracing_test::traced_test]
1540 fn test_subscription_info_serialization() {
1541 let info = create_test_subscription_info("serialize-test");
1542
1543 let json = serde_json::to_string(&info).unwrap();
1544 let deserialized: SubscriptionInfo = serde_json::from_str(&json).unwrap();
1545
1546 assert_eq!(info, deserialized);
1547 }
1548
1549 #[test]
1550 #[tracing_test::traced_test]
1551 fn test_yang_library_cache_from_disk_empty() {
1552 let temp_dir = TempDir::new().unwrap();
1553
1554 let cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1555
1556 assert!(cache.cache_by_content_id.is_empty());
1557 assert!(cache.cache_by_subscription_info.is_empty());
1558 }
1559
1560 #[test]
1561 #[tracing_test::traced_test]
1562 fn test_yang_library_cache_from_disk_with_entries() {
1563 let temp_dir = TempDir::new().unwrap();
1564 let content_id = "disk-test-id";
1565 let subscription_info = create_test_subscription_info(content_id);
1566 setup_yang_library_on_disk(
1567 &temp_dir,
1568 content_id,
1569 std::slice::from_ref(&subscription_info),
1570 )
1571 .unwrap();
1572
1573 let cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1574
1575 assert_eq!(cache.cache_by_content_id.len(), 1);
1576 assert_eq!(cache.cache_by_subscription_info.len(), 1);
1577
1578 let yang_lib_ref = cache
1579 .cache_by_content_id
1580 .get(&ContentId::from(content_id.to_string()))
1581 .unwrap();
1582 assert_eq!(yang_lib_ref.content_id(), content_id);
1583 }
1584
1585 #[test]
1586 #[tracing_test::traced_test]
1587 fn test_yang_library_cache_get_by_content_id() {
1588 let temp_dir = TempDir::new().unwrap();
1589 let content_id = "get-by-id-test";
1590
1591 let subscription_info = create_test_subscription_info(content_id);
1592 setup_yang_library_on_disk(
1593 &temp_dir,
1594 content_id,
1595 std::slice::from_ref(&subscription_info),
1596 )
1597 .unwrap();
1598
1599 let mut cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1600
1601 let result = cache.get_by_content_id(&ContentId::from(content_id.to_string()));
1602 assert!(result.is_some());
1603 assert_eq!(result.unwrap().content_id(), content_id);
1604
1605 let not_found = cache.get_by_content_id(&ContentId::from("nonexistent".to_string()));
1606 assert!(not_found.is_none());
1607 }
1608
1609 #[test]
1610 #[tracing_test::traced_test]
1611 fn test_yang_library_cache_get_by_subscription_info() {
1612 let temp_dir = TempDir::new().unwrap();
1613 let content_id = "get-by-sub-test";
1614
1615 let subscription_info = create_test_subscription_info(content_id);
1616 setup_yang_library_on_disk(
1617 &temp_dir,
1618 content_id,
1619 std::slice::from_ref(&subscription_info),
1620 )
1621 .unwrap();
1622
1623 let cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1624
1625 let subscription_info = create_test_subscription_info(content_id);
1626 let result = cache.get_by_subscription_info(&subscription_info);
1627 assert!(result.is_some());
1628 assert_eq!(result.unwrap().content_id(), content_id);
1629
1630 let other_info = create_test_subscription_info("other-id");
1631 let not_found = cache.get_by_subscription_info(&other_info);
1632 assert!(not_found.is_none());
1633 }
1634
1635 #[test]
1636 #[tracing_test::traced_test]
1637 fn test_yang_library_cache_get_by_subscription_id() {
1638 let temp_dir = TempDir::new().unwrap();
1639 let content_id = "get-by-sub-id-test";
1640
1641 let subscription_info = create_test_subscription_info(content_id);
1642 setup_yang_library_on_disk(
1643 &temp_dir,
1644 content_id,
1645 std::slice::from_ref(&subscription_info),
1646 )
1647 .unwrap();
1648
1649 let cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1650
1651 let subscription_info = create_test_subscription_info(content_id);
1652 let peer_ip = subscription_info.peer().ip();
1653 let subscription_id = subscription_info.id();
1654
1655 let result = cache.get_by_subscription_id(peer_ip, subscription_id);
1657 assert!(result.is_some());
1658 let (retrieved_info, yang_lib_ref) = result.unwrap();
1659 assert_eq!(retrieved_info, subscription_info);
1660 assert!(yang_lib_ref.is_some());
1661 assert_eq!(yang_lib_ref.unwrap().content_id(), content_id);
1662
1663 let not_found = cache.get_by_subscription_id(peer_ip, 9999);
1665 assert!(not_found.is_none());
1666
1667 let wrong_ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
1669 let not_found = cache.get_by_subscription_id(wrong_ip, subscription_id);
1670 assert!(not_found.is_none());
1671 }
1672
1673 #[test]
1674 #[tracing_test::traced_test]
1675 fn test_yang_library_cache_remove_by_content_id() {
1676 let temp_dir = TempDir::new().unwrap();
1677 let content_id = "remove-by-id-test";
1678
1679 let subscription_info = create_test_subscription_info(content_id);
1680 setup_yang_library_on_disk(
1681 &temp_dir,
1682 content_id,
1683 std::slice::from_ref(&subscription_info),
1684 )
1685 .unwrap();
1686
1687 let mut cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1688 assert_eq!(cache.cache_by_content_id.len(), 1);
1689
1690 let content_id_key = ContentId::from(content_id.to_string());
1691 cache.remove_by_content_id(&content_id_key).unwrap();
1692
1693 assert!(cache.cache_by_content_id.is_empty());
1694 assert!(cache.cache_by_subscription_info.is_empty());
1695 }
1696
1697 #[test]
1698 #[tracing_test::traced_test]
1699 fn test_yang_library_cache_remove_by_subscription_info() {
1700 let temp_dir = TempDir::new().unwrap();
1701 let content_id = "remove-by-sub-test";
1702
1703 let subscription_info = create_test_subscription_info(content_id);
1704 setup_yang_library_on_disk(
1705 &temp_dir,
1706 content_id,
1707 std::slice::from_ref(&subscription_info),
1708 )
1709 .unwrap();
1710
1711 let mut cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1712 assert_eq!(cache.cache_by_subscription_info.len(), 1);
1713
1714 let subscription_info = create_test_subscription_info(content_id);
1715 cache
1716 .remove_by_subscription_info(&subscription_info)
1717 .unwrap();
1718
1719 assert!(cache.cache_by_content_id.is_empty());
1720 assert!(cache.cache_by_subscription_info.is_empty());
1721 }
1722
1723 #[test]
1724 #[tracing_test::traced_test]
1725 fn test_yang_library_cache_remove_nonexistent() {
1726 let temp_dir = TempDir::new().unwrap();
1727 let mut cache = YangLibraryCache::new(temp_dir.path().to_path_buf());
1728
1729 let result = cache.remove_by_content_id(&ContentId::from("nonexistent".to_string()));
1731 assert!(result.is_ok());
1732
1733 let subscription_info = create_test_subscription_info("nonexistent");
1734 let result = cache.remove_by_subscription_info(&subscription_info);
1735 assert!(result.is_ok());
1736 }
1737
1738 #[test]
1739 #[tracing_test::traced_test]
1740 fn test_yang_library_cache_from_disk_skips_files() {
1741 let temp_dir = TempDir::new().unwrap();
1742
1743 let file_path = temp_dir.path().join("some-file.txt");
1745 std::fs::write(&file_path, "this is a file, not a directory").unwrap();
1746
1747 let cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1748 assert!(cache.cache_by_content_id.is_empty());
1749 }
1750
1751 #[test]
1752 #[tracing_test::traced_test]
1753 fn test_yang_library_cache_multiple_entries() {
1754 let temp_dir = TempDir::new().unwrap();
1755
1756 let subscription_info1 = create_test_subscription_info("content_id");
1757 let subscription_info2 = SubscriptionInfo::new(
1758 SocketAddr::from(([192, 168, 1, 100], 10000)),
1759 None,
1760 SocketAddr::from(([192, 168, 1, 102], 830)),
1761 2,
1762 Target::new_datastore(
1763 "ds:operational".into(),
1764 either::Right("/ietf-routing:routing/routing-protocols".into()),
1765 ),
1766 None,
1767 Some(Transport::UDPNotif),
1768 Some(Encoding::Json),
1769 Some("test subscription".into()),
1770 Some(UpdateTrigger::OnChange {
1771 dampening_period: None,
1772 sync_on_start: Some(true),
1773 excluded_change: None,
1774 }),
1775 Box::new([YangPushModuleVersion::new(
1776 "ietf-routing".into(),
1777 None,
1778 None,
1779 )]),
1780 ContentId::from("content_id2".to_string()),
1781 );
1782 let subscription_info3 = SubscriptionInfo::new(
1783 SocketAddr::from(([192, 168, 1, 100], 10000)),
1784 None,
1785 SocketAddr::from(([192, 168, 1, 103], 830)),
1786 2,
1787 Target::new_datastore(
1788 "ds:operational".into(),
1789 either::Right("/ietf-routing:routing/routing-protocols".into()),
1790 ),
1791 None,
1792 Some(Transport::UDPNotif),
1793 Some(Encoding::Json),
1794 Some("test subscription".into()),
1795 Some(UpdateTrigger::Periodic {
1796 period: Some(CentiSeconds::new(300)),
1797 anchor_time: None,
1798 }),
1799 Box::new([YangPushModuleVersion::new(
1800 "ietf-routing".into(),
1801 None,
1802 None,
1803 )]),
1804 ContentId::from("content_id3".to_string()),
1805 );
1806 setup_yang_library_on_disk(
1807 &temp_dir,
1808 "content-id-1",
1809 std::slice::from_ref(&subscription_info1),
1810 )
1811 .unwrap();
1812 setup_yang_library_on_disk(
1813 &temp_dir,
1814 "content-id-2",
1815 std::slice::from_ref(&subscription_info2),
1816 )
1817 .unwrap();
1818 setup_yang_library_on_disk(
1819 &temp_dir,
1820 "content-id-3",
1821 std::slice::from_ref(&subscription_info3),
1822 )
1823 .unwrap();
1824
1825 let cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1826
1827 assert_eq!(cache.cache_by_content_id.len(), 3);
1828 assert_eq!(cache.cache_by_subscription_info.len(), 3);
1829 }
1830
1831 #[test]
1832 #[tracing_test::traced_test]
1833 fn test_yang_library_reference_append_subscription_info() {
1834 let temp_dir = TempDir::new().unwrap();
1835 let content_id = "append-sub-test";
1836
1837 let subscription_info1 = create_test_subscription_info(content_id);
1839 setup_yang_library_on_disk(
1840 &temp_dir,
1841 content_id,
1842 std::slice::from_ref(&subscription_info1),
1843 )
1844 .unwrap();
1845
1846 let entry_path = temp_dir.path().join(content_id);
1847 let yang_lib_path = entry_path.join(YANG_LIBRARY_FILE_NAME);
1848 let yang_lib_ref = YangLibraryReference::load_from_disk(yang_lib_path, entry_path).unwrap();
1849
1850 let initial_subscriptions = yang_lib_ref.subscriptions_info().unwrap();
1852 assert_eq!(initial_subscriptions, vec![subscription_info1.clone()]);
1853
1854 yang_lib_ref
1856 .append_subscription_info(subscription_info1.clone())
1857 .unwrap();
1858 let subscriptions_after_duplicate = yang_lib_ref.subscriptions_info().unwrap();
1859 assert_eq!(
1860 subscriptions_after_duplicate,
1861 vec![subscription_info1.clone()]
1862 );
1863
1864 let subscription_info2 = SubscriptionInfo::new(
1866 SocketAddr::from(([192, 168, 1, 100], 10000)),
1867 None,
1868 SocketAddr::from(([192, 168, 1, 102], 830)),
1869 2,
1870 Target::new_datastore(
1871 "ds:operational".into(),
1872 either::Right("/ietf-routing:routing/routing-protocols".into()),
1873 ),
1874 None,
1875 Some(Transport::UDPNotif),
1876 Some(Encoding::Json),
1877 Some("test subscription".into()),
1878 Some(UpdateTrigger::OnChange {
1879 dampening_period: None,
1880 sync_on_start: Some(true),
1881 excluded_change: None,
1882 }),
1883 Box::new([YangPushModuleVersion::new(
1884 "ietf-routing".into(),
1885 None,
1886 None,
1887 )]),
1888 ContentId::from(content_id.to_string()),
1889 );
1890
1891 yang_lib_ref
1893 .append_subscription_info(subscription_info2.clone())
1894 .unwrap();
1895 let subscriptions_after_new = yang_lib_ref.subscriptions_info().unwrap();
1896 assert_eq!(
1897 HashSet::from_iter(subscriptions_after_new.into_iter()),
1898 HashSet::from([subscription_info1.clone(), subscription_info2.clone()])
1899 );
1900
1901 let reloaded_yang_lib_ref = YangLibraryReference::load_from_disk(
1903 yang_lib_ref.yang_library_path().to_path_buf(),
1904 yang_lib_ref.yang_lib_ref_path().to_path_buf(),
1905 )
1906 .unwrap();
1907 let persisted_subscriptions = reloaded_yang_lib_ref.subscriptions_info().unwrap();
1908 assert_eq!(
1909 HashSet::from_iter(persisted_subscriptions.into_iter()),
1910 HashSet::from([subscription_info1, subscription_info2])
1911 );
1912 }
1913
1914 #[test]
1915 #[tracing_test::traced_test]
1916 fn test_yang_library_cache_put_with_same_content_id_different_subscription() {
1917 let cache_dir = TempDir::new().unwrap();
1918 let content_id = "shared-content-id";
1919 let mut cache = YangLibraryCache::new(cache_dir.path().to_path_buf());
1920
1921 let subscription_info1 = SubscriptionInfo::new(
1923 SocketAddr::from(([192, 168, 1, 100], 10000)),
1924 None,
1925 SocketAddr::from(([192, 168, 1, 101], 830)),
1926 1,
1927 Target::new_datastore(
1928 "ds:operational".into(),
1929 either::Right("/ietf-interfaces:interfaces/ietf-interfaces:interface[ietf-interfaces:name='eth0']/statistics".into()),
1930 ),
1931 None,
1932 Some(Transport::UDPNotif),
1933 Some(Encoding::Json),
1934 Some("test subscription".into()),
1935 Some(UpdateTrigger::OnChange {
1936 dampening_period: None,
1937 sync_on_start: Some(true),
1938 excluded_change: None,
1939 }),
1940 Box::new([
1941 YangPushModuleVersion::new("ietf-interfaces".into(), Some("2018-02-20".into()), None),
1942 YangPushModuleVersion::new("ietf-ip".into(), None, None),
1943 ]),
1944 ContentId::from(content_id.to_string()),
1945 );
1946
1947 let yang_lib_xml = create_minimal_yang_library_xml(content_id);
1949 let reader = NsReader::from_str(&yang_lib_xml);
1950 let mut xml_reader = netgauze_netconf_proto::xml_utils::XmlParser::new(reader).unwrap();
1951 let yang_library = YangLibrary::xml_deserialize(&mut xml_reader).unwrap();
1952
1953 let mut schemas = HashMap::new();
1954 schemas.insert(
1955 Box::from("test-module"),
1956 Box::from(create_test_yang_module()),
1957 );
1958
1959 let yang_lib_ref1 = cache
1961 .put_yang_library(
1962 subscription_info1.clone(),
1963 yang_library.clone(),
1964 schemas.clone(),
1965 )
1966 .unwrap();
1967
1968 assert_eq!(cache.cache_by_content_id.len(), 1);
1970 assert_eq!(cache.cache_by_subscription_info.len(), 1);
1971 assert_eq!(cache.cache_by_subscription_id.len(), 1);
1972
1973 let subscription_info2 = SubscriptionInfo::new(
1976 SocketAddr::from(([192, 168, 1, 100], 10000)),
1977 None,
1978 SocketAddr::from(([192, 168, 1, 102], 830)),
1979 2,
1980 Target::new_datastore(
1981 "ds:operational".into(),
1982 either::Right("/ietf-routing:routing/routing-protocols".into()),
1983 ),
1984 None,
1985 Some(Transport::UDPNotif),
1986 Some(Encoding::Json),
1987 Some("test subscription".into()),
1988 Some(UpdateTrigger::OnChange {
1989 dampening_period: None,
1990 sync_on_start: Some(true),
1991 excluded_change: None,
1992 }),
1993 Box::new([YangPushModuleVersion::new(
1994 "ietf-routing".into(),
1995 None,
1996 None,
1997 )]),
1998 ContentId::from(content_id.to_string()),
1999 );
2000
2001 let yang_lib_ref2 = cache
2003 .put_yang_library(
2004 subscription_info2.clone(),
2005 yang_library.clone(),
2006 schemas.clone(),
2007 )
2008 .unwrap();
2009
2010 assert_eq!(yang_lib_ref1.content_id(), yang_lib_ref2.content_id());
2012 assert_eq!(cache.cache_by_content_id.len(), 1);
2013 assert_eq!(cache.cache_by_subscription_info.len(), 2);
2014 assert_eq!(cache.cache_by_subscription_id.len(), 2);
2015
2016 let retrieved1 = cache.get_by_subscription_info(&subscription_info1);
2018 assert!(retrieved1.is_some());
2019 assert_eq!(retrieved1.unwrap().content_id(), content_id);
2020
2021 let retrieved2 = cache.get_by_subscription_info(&subscription_info2);
2022 assert!(retrieved2.is_some());
2023 assert_eq!(retrieved2.unwrap().content_id(), content_id);
2024
2025 let (sub1_info, sub1_ref) = cache
2027 .get_by_subscription_id(subscription_info1.peer().ip(), subscription_info1.id())
2028 .unwrap();
2029 assert_eq!(sub1_info, subscription_info1);
2030 assert!(sub1_ref.is_some());
2031
2032 let (sub2_info, sub2_ref) = cache
2033 .get_by_subscription_id(subscription_info2.peer().ip(), subscription_info2.id())
2034 .unwrap();
2035 assert_eq!(sub2_info, subscription_info2);
2036 assert!(sub2_ref.is_some());
2037
2038 let subscriptions = yang_lib_ref2.subscriptions_info().unwrap();
2040 assert_eq!(subscriptions.len(), 2);
2041 assert!(subscriptions.contains(&subscription_info1));
2042 assert!(subscriptions.contains(&subscription_info2));
2043 }
2044
2045 #[test]
2049 fn test_subscription_info_identity_ignores_peer_port() {
2050 let info = create_test_subscription_info("content-id-1");
2051
2052 let mut reconnected = info.clone();
2054 reconnected.peer = SocketAddr::new(info.peer.ip(), info.peer.port() + 1);
2055
2056 assert_eq!(
2057 info, reconnected,
2058 "subscriptions differing only by peer port must compare equal"
2059 );
2060
2061 let hash = |x: &SubscriptionInfo| {
2063 use std::hash::{Hash, Hasher};
2064 let mut h = std::collections::hash_map::DefaultHasher::new();
2065 x.hash(&mut h);
2066 h.finish()
2067 };
2068 assert_eq!(
2069 hash(&info),
2070 hash(&reconnected),
2071 "equal subscriptions must hash equally"
2072 );
2073
2074 let mut cache = FxHashMap::default();
2077 cache.insert(info.clone(), "yang-library");
2078 assert_eq!(
2079 cache.get(&reconnected),
2080 Some(&"yang-library"),
2081 "cache lookup must hit after the peer reconnects from a new port"
2082 );
2083 }
2084
2085 #[test]
2088 fn test_subscription_info_identity_still_distinguishes_peer_ip() {
2089 let info = create_test_subscription_info("content-id-1");
2090
2091 let mut other_device = info.clone();
2092 other_device.peer = SocketAddr::new(
2093 IpAddr::V4(Ipv4Addr::new(192, 168, 1, 200)),
2094 info.peer.port(),
2095 );
2096 assert_ne!(
2097 info, other_device,
2098 "subscriptions from different peer IPs must not compare equal"
2099 );
2100
2101 let mut other_id = info.clone();
2102 other_id.id = info.id + 1;
2103 assert_ne!(
2104 info, other_id,
2105 "different subscription IDs must not compare equal"
2106 );
2107
2108 let mut other_content = info.clone();
2109 other_content.content_id = ContentId::from("content-id-2".to_string());
2110 assert_ne!(
2111 info, other_content,
2112 "different content IDs must not compare equal"
2113 );
2114 }
2115}