use std::collections::HashMap; use rpki::report::Warning; use rpki::storage::{PackFile, PackTime, VerifiedPublicationPointPack}; use rpki::validation::manifest::PublicationPointSource; use rpki::validation::tree::{ CaInstanceHandle, PublicationPointRunResult, PublicationPointRunner, TreeRunConfig, run_tree_serial, }; use rpki::validation::objects::{ObjectsOutput, ObjectsStats}; use rpki::audit::PublicationPointAudit; #[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) -> VerifiedPublicationPointPack { VerifiedPublicationPointPack { format_version: 1, publication_point_rsync_uri: pp_uri.to_string(), manifest_rsync_uri: manifest_uri.to_string(), 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, } } #[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![ca_handle(child1_manifest), ca_handle(child2_manifest)]; let child1_children = vec![ca_handle(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::VerifiedCache, 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![ca_handle(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); }