use std::collections::HashMap; use rpki::audit::{DiscoveredFrom, PublicationPointAudit}; use rpki::report::Warning; use rpki::storage::{FetchCachePpPack, PackFile, PackTime}; use rpki::validation::manifest::PublicationPointSource; use rpki::validation::objects::{ObjectsOutput, ObjectsStats}; use rpki::validation::tree::{ CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner, TreeRunConfig, run_tree_serial, run_tree_serial_audit, }; #[derive(Default)] struct MockRunner { by_manifest: HashMap, calls: std::sync::Mutex>, } impl MockRunner { fn with(mut self, manifest: &str, res: PublicationPointRunResult) -> Self { self.by_manifest.insert(manifest.to_string(), res); self } fn called(&self) -> Vec { self.calls.lock().unwrap().clone() } } impl PublicationPointRunner for MockRunner { fn run_publication_point( &self, ca: &CaInstanceHandle, ) -> Result { self.calls .lock() .unwrap() .push(ca.manifest_rsync_uri.clone()); self.by_manifest .get(&ca.manifest_rsync_uri) .cloned() .ok_or_else(|| format!("no mock for {}", ca.manifest_rsync_uri)) } } fn empty_pack(manifest_uri: &str, pp_uri: &str) -> FetchCachePpPack { FetchCachePpPack { format_version: 1, publication_point_rsync_uri: pp_uri.to_string(), manifest_rsync_uri: manifest_uri.to_string(), manifest_number_be: vec![1], this_update: PackTime { rfc3339_utc: "2026-01-01T00:00:00Z".to_string(), }, next_update: PackTime { rfc3339_utc: "2026-12-31T00:00:00Z".to_string(), }, verified_at: PackTime { rfc3339_utc: "2026-02-06T00:00:00Z".to_string(), }, manifest_bytes: vec![1, 2, 3], files: vec![PackFile::from_bytes_compute_sha256(manifest_uri, vec![1])], } } fn ca_handle(manifest_uri: &str) -> CaInstanceHandle { CaInstanceHandle { depth: 0, ca_certificate_der: Vec::new(), ca_certificate_rsync_uri: None, effective_ip_resources: None, effective_as_resources: None, rsync_base_uri: "rsync://example.test/repo/".to_string(), manifest_rsync_uri: manifest_uri.to_string(), publication_point_rsync_uri: "rsync://example.test/repo/".to_string(), rrdp_notification_uri: None, } } fn discovered_child( parent_manifest_uri: &str, child_manifest_uri: &str, ) -> DiscoveredChildCaInstance { let name = child_manifest_uri .rsplit('/') .next() .unwrap_or("child.mft") .trim_end_matches(".mft"); DiscoveredChildCaInstance { handle: ca_handle(child_manifest_uri), discovered_from: DiscoveredFrom { parent_manifest_rsync_uri: parent_manifest_uri.to_string(), child_ca_certificate_rsync_uri: format!("rsync://example.test/repo/{name}.cer"), child_ca_certificate_sha256_hex: "00".repeat(32), }, } } #[test] fn tree_enqueues_children_only_for_fresh_publication_points() { let root_manifest = "rsync://example.test/repo/root.mft"; let child1_manifest = "rsync://example.test/repo/child1.mft"; let child2_manifest = "rsync://example.test/repo/child2.mft"; let grandchild_manifest = "rsync://example.test/repo/grandchild.mft"; let root_children = vec![ discovered_child(root_manifest, child1_manifest), discovered_child(root_manifest, child2_manifest), ]; let child1_children = vec![discovered_child(child1_manifest, grandchild_manifest)]; let runner = MockRunner::default() .with( root_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, pack: empty_pack(root_manifest, "rsync://example.test/repo/"), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), audit: Vec::new(), }, audit: PublicationPointAudit::default(), discovered_children: root_children, }, ) .with( child1_manifest, PublicationPointRunResult { source: PublicationPointSource::FetchCachePp, pack: empty_pack(child1_manifest, "rsync://example.test/repo/child1/"), warnings: vec![Warning::new("child1 warning")], objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), audit: Vec::new(), }, audit: PublicationPointAudit::default(), discovered_children: child1_children, }, ) .with( child2_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, pack: empty_pack(child2_manifest, "rsync://example.test/repo/child2/"), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), audit: Vec::new(), }, audit: PublicationPointAudit::default(), discovered_children: Vec::new(), }, ); let out = run_tree_serial(ca_handle(root_manifest), &runner, &TreeRunConfig::default()) .expect("run tree"); // root + child1 + child2. grandchild must NOT be processed because child1 used cache. assert_eq!(out.instances_processed, 3); assert_eq!(out.instances_failed, 0); let called = runner.called(); assert_eq!( called, vec![root_manifest, child1_manifest, child2_manifest] ); assert!( out.warnings .iter() .any(|w| w.message.contains("child1 warning")), "expected child1 warning propagated" ); assert!( out.warnings .iter() .any(|w| w.message.contains("skipping child CA discovery")), "expected RFC 9286 ยง6.6 enforcement warning" ); } #[test] fn tree_respects_max_depth_and_max_instances() { let root_manifest = "rsync://example.test/repo/root.mft"; let child_manifest = "rsync://example.test/repo/child.mft"; let runner = MockRunner::default() .with( root_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, pack: empty_pack(root_manifest, "rsync://example.test/repo/"), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), audit: Vec::new(), }, audit: PublicationPointAudit::default(), discovered_children: vec![discovered_child(root_manifest, child_manifest)], }, ) .with( child_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, pack: empty_pack(child_manifest, "rsync://example.test/repo/child/"), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), audit: Vec::new(), }, audit: PublicationPointAudit::default(), discovered_children: Vec::new(), }, ); let out = run_tree_serial( ca_handle(root_manifest), &runner, &TreeRunConfig { max_depth: Some(0), max_instances: None, }, ) .expect("run tree depth-limited"); assert_eq!(out.instances_processed, 1); assert_eq!(out.instances_failed, 0); let out = run_tree_serial( ca_handle(root_manifest), &runner, &TreeRunConfig { max_depth: None, max_instances: Some(1), }, ) .expect("run tree instance-limited"); assert_eq!(out.instances_processed, 1); assert_eq!(out.instances_failed, 0); } #[test] fn tree_audit_includes_parent_and_discovered_from_for_non_root_nodes() { let root_manifest = "rsync://example.test/repo/root.mft"; let child_manifest = "rsync://example.test/repo/child.mft"; let runner = MockRunner::default() .with( root_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, pack: empty_pack(root_manifest, "rsync://example.test/repo/"), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), audit: Vec::new(), }, audit: PublicationPointAudit::default(), discovered_children: vec![discovered_child(root_manifest, child_manifest)], }, ) .with( child_manifest, PublicationPointRunResult { source: PublicationPointSource::Fresh, pack: empty_pack(child_manifest, "rsync://example.test/repo/child/"), warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), audit: Vec::new(), }, audit: PublicationPointAudit::default(), discovered_children: Vec::new(), }, ); let out = run_tree_serial_audit(ca_handle(root_manifest), &runner, &TreeRunConfig::default()) .expect("run tree audit"); assert_eq!(out.tree.instances_processed, 2); assert_eq!(out.publication_points.len(), 2); let root_audit = &out.publication_points[0]; assert_eq!(root_audit.node_id, Some(0)); assert_eq!(root_audit.parent_node_id, None); assert!(root_audit.discovered_from.is_none()); let child_audit = &out.publication_points[1]; assert_eq!(child_audit.node_id, Some(1)); assert_eq!(child_audit.parent_node_id, Some(0)); let df = child_audit .discovered_from .as_ref() .expect("child discovered_from"); assert_eq!(df.parent_manifest_rsync_uri, root_manifest); }