use crate::audit::{DiscoveredFrom, ObjectAuditEntry, PublicationPointAudit}; use crate::cir::{CirInputAccumulator, CirInputSnapshot}; use crate::data_model::rc::{AsResourceSet, IpResourceSet}; use crate::report::Warning; use crate::validation::manifest::PublicationPointSource; use crate::validation::objects::{ AspaAttestation, ObjectsOutput, RoaValidationCacheStats, RouterKeyPayload, Vrp, }; use crate::validation::publication_point::PublicationPointSnapshot; use std::borrow::Cow; use std::sync::{Arc, OnceLock}; pub const DEFAULT_MAX_CA_DEPTH: usize = 32; #[derive(Clone, Debug, PartialEq, Eq)] pub struct TreeRunConfig { /// Max CA instance depth to process (0 = root only). pub max_depth: Option, /// Max number of CA instances to process. pub max_instances: Option, /// Drop per-object audit payload when the caller only needs validation outputs. pub compact_audit: bool, /// Persist per-CA VCIR state for future reuse/delta runs. pub persist_vcir: bool, /// Build online CCR manifest projections during phase2 validation. pub build_ccr_accumulator: bool, /// Reuse accepted ROA validation outputs from previous VCIR when explicitly enabled. pub enable_roa_validation_cache: bool, /// Reuse validated child certificate discovery results when explicitly enabled. pub enable_child_certificate_validation_cache: bool, /// Evaluate publication-point cache eligibility without changing validation results. pub publication_point_cache_observe_only: bool, /// Reuse complete publication-point validation projections when explicitly enabled. pub enable_publication_point_validation_cache: bool, /// Prefetch transport requests from the previous run before normal tree traversal. pub enable_transport_request_prefetch: bool, } impl Default for TreeRunConfig { fn default() -> Self { Self { max_depth: Some(DEFAULT_MAX_CA_DEPTH), max_instances: None, compact_audit: false, persist_vcir: true, build_ccr_accumulator: true, enable_roa_validation_cache: false, enable_child_certificate_validation_cache: false, publication_point_cache_observe_only: false, enable_publication_point_validation_cache: false, enable_transport_request_prefetch: false, } } } pub(crate) fn ca_depth_is_allowed(config: &TreeRunConfig, depth: usize) -> bool { config.max_depth.is_none_or(|max_depth| depth <= max_depth) } pub(crate) fn next_allowed_ca_depth(config: &TreeRunConfig, parent_depth: usize) -> Option { let child_depth = parent_depth.checked_add(1)?; ca_depth_is_allowed(config, child_depth).then_some(child_depth) } #[derive(Clone, Debug)] pub enum CaCertificateRef { InlineDer(Vec), RepoBytes { sha256_hex: String, cached_der: Arc>>, }, } impl PartialEq for CaCertificateRef { fn eq(&self, other: &Self) -> bool { match (self, other) { (Self::InlineDer(left), Self::InlineDer(right)) => left == right, ( Self::RepoBytes { sha256_hex: left, .. }, Self::RepoBytes { sha256_hex: right, .. }, ) => left == right, _ => false, } } } impl Eq for CaCertificateRef {} impl CaCertificateRef { pub fn inline_der(bytes: Vec) -> Self { Self::InlineDer(bytes) } pub fn repo_bytes(sha256_hex: String) -> Self { Self::RepoBytes { sha256_hex, cached_der: Arc::new(OnceLock::new()), } } pub fn sha256_hex(&self) -> Option<&str> { match self { Self::InlineDer(_) => None, Self::RepoBytes { sha256_hex, .. } => Some(sha256_hex.as_str()), } } pub fn der<'a>(&'a self, store: &crate::storage::RocksStore) -> Result, String> { match self { Self::InlineDer(bytes) => Ok(Cow::Borrowed(bytes.as_slice())), Self::RepoBytes { sha256_hex, cached_der, } => { if cached_der.get().is_none() { let bytes = store .get_blob_bytes(sha256_hex) .map_err(|e| format!("load CA certificate bytes failed: {e}"))? .ok_or_else(|| { format!("missing CA certificate repo bytes for sha256={sha256_hex}") })?; let _ = cached_der.set(Arc::from(bytes)); } let bytes = cached_der.get().ok_or_else(|| { format!("missing cached CA certificate bytes for sha256={sha256_hex}") })?; Ok(Cow::Borrowed(bytes.as_ref())) } } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CaInstanceHandle { pub depth: usize, pub tal_id: String, pub parent_manifest_rsync_uri: Option, /// CA certificate bytes or a lazy repo-bytes reference for this CA instance. pub ca_certificate: CaCertificateRef, /// rsync URI of this CA certificate object (where it is published). /// /// This is used for strict AIA binding checks (RFC 6487 ยง4.8.7) when validating /// subordinate CA and EE certificates. Trust anchor certificates downloaded from /// TAL URIs may not have an rsync publication URI, so this can be `None`. pub ca_certificate_rsync_uri: Option, /// Effective (fully resolved) resources for this CA instance, used for resource-path /// validation of subordinate CA certificates. pub effective_ip_resources: Option, pub effective_as_resources: Option, pub rsync_base_uri: String, pub manifest_rsync_uri: String, pub publication_point_rsync_uri: String, pub rrdp_notification_uri: Option, } impl CaInstanceHandle { pub fn with_depth(mut self, depth: usize) -> Self { self.depth = depth; self } pub fn ca_certificate_der<'a>( &'a self, store: &crate::storage::RocksStore, ) -> Result, String> { self.ca_certificate.der(store) } pub fn ca_certificate_sha256_hex(&self) -> Option<&str> { self.ca_certificate.sha256_hex() } pub fn ca_certificate_sha256_32(&self) -> Option<[u8; 32]> { match &self.ca_certificate { CaCertificateRef::InlineDer(bytes) => { use sha2::Digest as _; let digest = sha2::Sha256::digest(bytes); Some(digest.into()) } CaCertificateRef::RepoBytes { sha256_hex, .. } => { let mut out = [0u8; 32]; hex::decode_to_slice(sha256_hex, &mut out).ok()?; Some(out) } } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PublicationPointRunResult { pub source: PublicationPointSource, pub snapshot: Option, pub warnings: Vec, pub objects: ObjectsOutput, pub audit: PublicationPointAudit, pub cir_fresh_objects: Vec, pub cir_cached_objects: Vec, /// Candidate child CA instances to enqueue after this publication point completes. /// /// - For `Fresh`, these are discovered from the current validated publication point. /// - For `VcirCurrentInstance`, these are restored from the current instance VCIR and then /// processed fresh-first when their own turn arrives. pub discovered_children: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct DiscoveredChildEntryProjection { pub child_ski: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct DiscoveredChildCaInstance { pub handle: CaInstanceHandle, pub discovered_from: DiscoveredFrom, pub child_entry_projection: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct TreeRunOutput { pub instances_processed: usize, pub instances_failed: usize, pub warnings: Vec, pub vrps: Vec, pub aspas: Vec, pub router_keys: Vec, } #[derive(Debug, thiserror::Error)] pub enum TreeRunError { #[error("publication point runner failed: {0}")] Runner(String), } pub trait PublicationPointRunner { fn run_publication_point( &self, ca: &CaInstanceHandle, ) -> Result; fn prefetch_discovered_children( &self, _children: &[DiscoveredChildCaInstance], ) -> Result<(), String> { Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct TreeRunAuditOutput { pub tree: TreeRunOutput, pub publication_points: Vec, pub roa_cache_stats: RoaValidationCacheStats, pub cir_input: CirInputSnapshot, } pub(crate) fn submit_publication_point_cir_input( cir_input: &mut CirInputAccumulator, source: PublicationPointSource, audit: &PublicationPointAudit, cir_fresh_objects: &[ObjectAuditEntry], cir_cached_objects: &[ObjectAuditEntry], ) -> Result<(), TreeRunError> { match source { PublicationPointSource::Fresh => { cir_input .submit_audit_entries(crate::cir::CirInputSection::Fresh, &audit.objects) .map_err(|e| TreeRunError::Runner(e.to_string()))?; } PublicationPointSource::FailedFetchNoCache => { let entries = if cir_fresh_objects.is_empty() { &audit.objects } else { cir_fresh_objects }; cir_input .submit_audit_entries(crate::cir::CirInputSection::Fresh, entries) .map_err(|e| TreeRunError::Runner(e.to_string()))?; } PublicationPointSource::VcirCurrentInstance | PublicationPointSource::PublicationPointCache => { cir_input .submit_audit_entries(crate::cir::CirInputSection::Fresh, cir_fresh_objects) .map_err(|e| TreeRunError::Runner(e.to_string()))?; cir_input .submit_audit_entries(crate::cir::CirInputSection::Cached, cir_cached_objects) .map_err(|e| TreeRunError::Runner(e.to_string()))?; } } Ok(()) } pub fn run_tree_serial( root: CaInstanceHandle, runner: &dyn PublicationPointRunner, config: &TreeRunConfig, ) -> Result { Ok(run_tree_serial_audit(root, runner, config)?.tree) } pub fn run_tree_serial_audit( root: CaInstanceHandle, runner: &dyn PublicationPointRunner, config: &TreeRunConfig, ) -> Result { run_tree_serial_audit_multi_root(vec![root], runner, config) } pub fn run_tree_serial_audit_multi_root( roots: Vec, runner: &dyn PublicationPointRunner, config: &TreeRunConfig, ) -> Result { #[derive(Clone, Debug)] struct QueuedCaInstance { id: u64, handle: CaInstanceHandle, parent_id: Option, discovered_from: Option, } let mut next_id: u64 = 0; let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); for root in roots { queue.push_back(QueuedCaInstance { id: next_id, handle: root, parent_id: None, discovered_from: None, }); next_id += 1; } let mut visited_manifest_uris: std::collections::HashSet = std::collections::HashSet::new(); let mut instances_processed = 0usize; let mut instances_failed = 0usize; let mut warnings: Vec = Vec::new(); let mut vrps: Vec = Vec::new(); let mut aspas: Vec = Vec::new(); let mut router_keys: Vec = Vec::new(); let mut publication_points: Vec = Vec::new(); let mut roa_cache_stats = RoaValidationCacheStats::default(); let mut cir_input = CirInputAccumulator::default(); while let Some(node) = queue.pop_front() { let ca = &node.handle; if !visited_manifest_uris.insert(ca.manifest_rsync_uri.clone()) { continue; } if let Some(max_instances) = config.max_instances { if instances_processed >= max_instances { break; } } if !ca_depth_is_allowed(config, ca.depth) { continue; } let res = match runner.run_publication_point(ca) { Ok(v) => v, Err(e) => { instances_failed += 1; warnings.push( Warning::new(format!("publication point failed: {e}")) .with_context(&ca.manifest_rsync_uri), ); continue; } }; instances_processed += 1; warnings.extend(res.warnings); warnings.extend(res.objects.warnings); roa_cache_stats.add_assign(&res.objects.roa_cache_stats); vrps.extend(res.objects.vrps); aspas.extend(res.objects.aspas); router_keys.extend(res.objects.router_keys); let source = res.source; let cir_fresh_objects = res.cir_fresh_objects; let cir_cached_objects = res.cir_cached_objects; let mut audit = res.audit; audit.node_id = Some(node.id); audit.parent_node_id = node.parent_id; audit.discovered_from = node.discovered_from.clone(); submit_publication_point_cir_input( &mut cir_input, source, &audit, &cir_fresh_objects, &cir_cached_objects, )?; if config.compact_audit { audit.objects.clear(); audit.warnings.clear(); } publication_points.push(audit); let Some(child_depth) = next_allowed_ca_depth(config, ca.depth) else { continue; }; let mut children = res.discovered_children; children.sort_by(|a, b| { a.handle .manifest_rsync_uri .cmp(&b.handle.manifest_rsync_uri) .then_with(|| { a.discovered_from .child_ca_certificate_rsync_uri .cmp(&b.discovered_from.child_ca_certificate_rsync_uri) }) }); if let Err(e) = runner.prefetch_discovered_children(&children) { warnings.push( Warning::new(format!("prefetch discovered children failed: {e}")) .with_context(&ca.manifest_rsync_uri), ); } for child in children { queue.push_back(QueuedCaInstance { id: next_id, handle: child.handle.with_depth(child_depth), parent_id: Some(node.id), discovered_from: Some(child.discovered_from), }); next_id += 1; } } Ok(TreeRunAuditOutput { tree: TreeRunOutput { instances_processed, instances_failed, warnings, vrps, aspas, router_keys, }, publication_points, roa_cache_stats, cir_input: cir_input.finalize(), }) } #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; use crate::audit::{ AuditObjectKind, AuditObjectResult, DiscoveredFrom, ObjectAuditEntry, PublicationPointAudit, }; use crate::validation::objects::{ObjectsOutput, ObjectsStats}; use super::{ CaCertificateRef, CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner, TreeRunConfig, run_tree_serial_audit, run_tree_serial_audit_multi_root, }; fn sample_handle(manifest: &str) -> CaInstanceHandle { CaInstanceHandle { depth: 0, tal_id: "arin".to_string(), parent_manifest_rsync_uri: None, ca_certificate: CaCertificateRef::inline_der(vec![1, 2, 3]), 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.to_string(), publication_point_rsync_uri: "rsync://example.test/repo/".to_string(), rrdp_notification_uri: None, } } struct PrefetchRecordingRunner { seen_prefetch_children: Arc>>, } impl PublicationPointRunner for PrefetchRecordingRunner { fn prefetch_discovered_children( &self, children: &[DiscoveredChildCaInstance], ) -> Result<(), String> { let mut seen = self .seen_prefetch_children .lock() .expect("prefetch lock poisoned"); seen.extend( children .iter() .map(|child| child.handle.manifest_rsync_uri.clone()), ); Ok(()) } fn run_publication_point( &self, ca: &CaInstanceHandle, ) -> Result { let children = if ca.manifest_rsync_uri.ends_with("root.mft") { vec![DiscoveredChildCaInstance { handle: sample_handle("rsync://example.test/repo/child.mft"), discovered_from: DiscoveredFrom { parent_manifest_rsync_uri: ca.manifest_rsync_uri.clone(), child_ca_certificate_rsync_uri: "rsync://example.test/repo/child.cer" .to_string(), child_ca_certificate_sha256_hex: "00".repeat(32), }, child_entry_projection: None, }] } else { Vec::new() }; Ok(PublicationPointRunResult { source: crate::validation::manifest::PublicationPointSource::Fresh, snapshot: None, warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), audit: Vec::new(), roa_cache_stats: crate::validation::objects::RoaValidationCacheStats::default(), roa_cache_object_meta: Vec::new(), }, audit: PublicationPointAudit::default(), cir_fresh_objects: Vec::new(), cir_cached_objects: Vec::new(), discovered_children: children, }) } } #[test] fn run_tree_serial_audit_invokes_prefetch_hook_for_discovered_children() { let seen = Arc::new(Mutex::new(Vec::new())); let runner = PrefetchRecordingRunner { seen_prefetch_children: Arc::clone(&seen), }; let out = run_tree_serial_audit( sample_handle("rsync://example.test/repo/root.mft"), &runner, &TreeRunConfig::default(), ) .expect("tree run"); assert_eq!(out.tree.instances_processed, 2); assert_eq!( seen.lock().expect("lock").as_slice(), ["rsync://example.test/repo/child.mft"] ); } #[test] fn run_tree_serial_audit_multi_root_processes_all_roots() { let seen = Arc::new(Mutex::new(Vec::new())); let runner = PrefetchRecordingRunner { seen_prefetch_children: Arc::clone(&seen), }; let out = run_tree_serial_audit_multi_root( vec![ sample_handle("rsync://example.test/repo/root-a.mft"), sample_handle("rsync://example.test/repo/root-b.mft"), ], &runner, &TreeRunConfig::default(), ) .expect("tree run"); assert_eq!(out.tree.instances_processed, 2); assert_eq!(out.publication_points.len(), 2); } #[test] fn run_tree_serial_audit_accumulates_fresh_and_cached_cir_input() { struct CirRunner; impl PublicationPointRunner for CirRunner { fn run_publication_point( &self, ca: &CaInstanceHandle, ) -> Result { if ca.manifest_rsync_uri.ends_with("cached.mft") { let fresh_reject = ObjectAuditEntry { rsync_uri: "rsync://example.test/repo/cached/current.mft".to_string(), sha256_hex: "11".repeat(32), kind: AuditObjectKind::Manifest, result: AuditObjectResult::Error, detail: Some("fresh manifest rejected".to_string()), }; let cached = ObjectAuditEntry { rsync_uri: "rsync://example.test/repo/cached/old.roa".to_string(), sha256_hex: "22".repeat(32), kind: AuditObjectKind::Roa, result: AuditObjectResult::Ok, detail: None, }; return Ok(PublicationPointRunResult { source: crate::validation::manifest::PublicationPointSource::VcirCurrentInstance, snapshot: None, warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), audit: Vec::new(), roa_cache_stats: crate::validation::objects::RoaValidationCacheStats::default(), roa_cache_object_meta: Vec::new(), }, audit: PublicationPointAudit { objects: vec![fresh_reject.clone(), cached.clone()], ..PublicationPointAudit::default() }, cir_fresh_objects: vec![fresh_reject], cir_cached_objects: vec![cached], discovered_children: Vec::new(), }); } let fresh = ObjectAuditEntry { rsync_uri: "rsync://example.test/repo/fresh/a.roa".to_string(), sha256_hex: "33".repeat(32), kind: AuditObjectKind::Roa, result: AuditObjectResult::Ok, detail: None, }; Ok(PublicationPointRunResult { source: crate::validation::manifest::PublicationPointSource::Fresh, snapshot: None, warnings: Vec::new(), objects: ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings: Vec::new(), stats: ObjectsStats::default(), audit: Vec::new(), roa_cache_stats: crate::validation::objects::RoaValidationCacheStats::default(), roa_cache_object_meta: Vec::new(), }, audit: PublicationPointAudit { objects: vec![fresh], ..PublicationPointAudit::default() }, cir_fresh_objects: Vec::new(), cir_cached_objects: Vec::new(), discovered_children: Vec::new(), }) } } let out = run_tree_serial_audit_multi_root( vec![ sample_handle("rsync://example.test/repo/fresh/root.mft"), sample_handle("rsync://example.test/repo/cached/cached.mft"), ], &CirRunner, &TreeRunConfig::default(), ) .expect("tree run"); assert_eq!(out.cir_input.fresh_validated_objects.len(), 2); assert_eq!(out.cir_input.cached_validated_objects.len(), 1); assert_eq!(out.cir_input.fresh_rejected_objects.len(), 1); assert!( out.cir_input .fresh_validated_objects .iter() .any(|item| item.rsync_uri == "rsync://example.test/repo/fresh/a.roa") ); assert!( out.cir_input .cached_validated_objects .iter() .any(|item| item.rsync_uri == "rsync://example.test/repo/cached/old.roa") ); } }