use crate::analysis::timing::TimingHandle; use crate::audit::{AuditObjectKind, AuditObjectResult, ObjectAuditEntry, sha256_hex_from_32}; use crate::data_model::aspa::{AspaDecodeError, AspaObject, AspaValidateError}; use crate::data_model::manifest::ManifestObject; use crate::data_model::rc::{ AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpAddressOrRange, IpPrefix as RcIpPrefix, ResourceCertificate, }; use crate::data_model::roa::{IpPrefix, RoaAfi, RoaDecodeError, RoaObject, RoaValidateError}; use crate::data_model::signed_object::SignedObjectVerifyError; use crate::parallel::config::ParallelPhase2Config; use crate::parallel::object_worker::{ ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError, }; use crate::policy::{Policy, SignedObjectFailurePolicy}; use crate::report::{RfcRef, Warning}; use crate::storage::{ PackFile, PackTime, RoaCacheProjection, ValidatedCaInstanceResult, VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, VcirLocalOutput, VcirLocalOutputPayload, VcirOutputType, VcirSourceObjectType, }; use crate::validation::cert_path::{CertPathError, validate_signed_object_ee_cert_path_fast}; use crate::validation::manifest::PublicationPointData; use crate::validation::publication_point::PublicationPointSnapshot; use sha2::Digest; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use x509_parser::prelude::FromDer; use x509_parser::x509::SubjectPublicKeyInfo; const RFC_NONE: &[RfcRef] = &[]; const RFC_CRLDP: &[RfcRef] = &[RfcRef("RFC 6487 §4.8.6")]; const RFC_CRLDP_AND_LOCKED_PACK: &[RfcRef] = &[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §4.2.1")]; fn sha256_hex_to_32(hex_value: &str) -> [u8; 32] { let bytes = hex::decode(hex_value).expect("internal sha256 hex should decode"); let mut out = [0u8; 32]; out.copy_from_slice(&bytes); out } fn sha256_hex(bytes: &[u8]) -> String { hex::encode(sha2::Sha256::digest(bytes)) } fn decode_resource_certificate_with_policy( der: &[u8], policy: &Policy, ) -> Result { if policy.strict.name { ResourceCertificate::decode_der_with_strict_name(der) } else { ResourceCertificate::decode_der(der) } } #[derive(Clone, Debug)] pub(crate) struct VerifiedIssuerCrl { crl: crate::data_model::crl::RpkixCrl, revoked_serials: std::collections::HashSet>, } #[derive(Clone, Debug)] pub(crate) enum CachedIssuerCrl { Pending(Vec), Ok(Arc), } #[derive(Clone, Debug, Default)] pub(crate) struct IssuerResourcesIndex { ip_v4: Option, Vec)>>, ip_v6: Option, Vec)>>, asnum: Option>, rdi: Option>, } fn extra_rfc_refs_for_crl_selection(e: &ObjectValidateError) -> &'static [RfcRef] { match e { ObjectValidateError::MissingCrlDpUris => RFC_CRLDP, ObjectValidateError::CrlNotFound(_) => RFC_CRLDP_AND_LOCKED_PACK, _ => RFC_NONE, } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Vrp { pub asn: u32, pub prefix: IpPrefix, pub max_length: u16, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct AspaAttestation { pub customer_as_id: u32, pub provider_as_ids: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct RouterKeyPayload { pub as_id: u32, pub ski: Vec, pub spki_der: Vec, pub source_object_uri: String, pub source_object_hash: String, pub source_ee_cert_hash: String, pub item_effective_until: PackTime, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ObjectsOutput { pub vrps: Vec, pub aspas: Vec, pub router_keys: Vec, pub local_outputs_cache: Vec, pub warnings: Vec, pub stats: ObjectsStats, pub audit: Vec, pub roa_cache_stats: RoaValidationCacheStats, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ObjectsStats { pub roa_total: usize, pub roa_ok: usize, pub aspa_total: usize, pub aspa_ok: usize, /// Whether this publication point was dropped due to an unrecoverable objects-processing error /// (e.g., missing issuer CRL in the pack, or `signed_object_failure_policy=drop_publication_point`). pub publication_point_dropped: bool, } #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] pub struct RoaValidationCacheStats { pub enabled_publication_points: usize, pub vcir_hit_publication_points: usize, pub vcir_miss_publication_points: usize, pub hit_roas: usize, pub miss_roas: usize, pub blocked_roas: usize, pub fresh_roas: usize, pub context_gate_nanos: u64, pub lookup_nanos: u64, } #[derive(Clone, Copy, Debug)] pub struct RoaValidationCacheInput<'a> { enabled: bool, view: Option<&'a RoaValidationCacheView>, } impl<'a> RoaValidationCacheInput<'a> { pub fn disabled() -> Self { Self { enabled: false, view: None, } } pub fn enabled(view: Option<&'a RoaValidationCacheView>) -> Self { Self { enabled: true, view, } } } impl RoaValidationCacheStats { pub fn add_assign(&mut self, other: &Self) { self.enabled_publication_points += other.enabled_publication_points; self.vcir_hit_publication_points += other.vcir_hit_publication_points; self.vcir_miss_publication_points += other.vcir_miss_publication_points; self.hit_roas += other.hit_roas; self.miss_roas += other.miss_roas; self.blocked_roas += other.blocked_roas; self.fresh_roas += other.fresh_roas; self.context_gate_nanos = self .context_gate_nanos .saturating_add(other.context_gate_nanos); self.lookup_nanos = self.lookup_nanos.saturating_add(other.lookup_nanos); } fn for_input(input: RoaValidationCacheInput<'_>, roa_total: usize) -> Self { let mut stats = Self::default(); if !input.enabled { return stats; } stats.enabled_publication_points = 1; if input.view.is_some() { stats.vcir_hit_publication_points = 1; } else { stats.vcir_miss_publication_points = 1; stats.miss_roas = roa_total; stats.fresh_roas = roa_total; } stats } fn record_to_timing(&self, timing: Option<&TimingHandle>) { let Some(timing) = timing else { return; }; record_non_zero( timing, "roa_validation_cache_enabled_publication_points", self.enabled_publication_points, ); record_non_zero( timing, "roa_validation_cache_vcir_hit_publication_points", self.vcir_hit_publication_points, ); record_non_zero( timing, "roa_validation_cache_vcir_miss_publication_points", self.vcir_miss_publication_points, ); record_non_zero(timing, "roa_validation_cache_hit_roas", self.hit_roas); record_non_zero(timing, "roa_validation_cache_miss_roas", self.miss_roas); record_non_zero( timing, "roa_validation_cache_blocked_roas", self.blocked_roas, ); record_non_zero(timing, "roa_validation_cache_fresh_roas", self.fresh_roas); record_non_zero( timing, "roa_validation_cache_context_gate_nanos", self.context_gate_nanos as usize, ); record_non_zero( timing, "roa_validation_cache_lookup_nanos", self.lookup_nanos as usize, ); timing.record_phase_nanos( "roa_validation_cache_context_gate_total", self.context_gate_nanos, ); timing.record_phase_nanos("roa_validation_cache_lookup_total", self.lookup_nanos); } } fn record_non_zero(timing: &TimingHandle, key: &'static str, value: usize) { if value > 0 { timing.record_count(key, value as u64); } } #[derive(Clone, Debug)] pub struct RoaValidationCacheView { entries_by_uri: HashMap, issuer_ca_sha256_hex: Option, crl_sha256_by_uri: HashMap, blocked: bool, } #[derive(Clone, Debug)] pub struct CachedRoaValidationResult { source_object_hash: [u8; 32], outputs: Vec, } impl RoaValidationCacheView { pub fn from_projection( projection: &RoaCacheProjection, validation_time: time::OffsetDateTime, ) -> Self { let mut entries_by_uri: HashMap = HashMap::with_capacity(projection.entries.len()); let issuer_ca_sha256_hex = projection.issuer_ca_sha256_hex.clone(); let crl_sha256_by_uri = projection .crl_sha256_by_uri .iter() .map(|crl| (crl.uri.clone(), crl.sha256.clone())) .collect::>(); let blocked = projection .instance_effective_until .parse() .map(|effective_until| effective_until <= validation_time) .unwrap_or(true); if blocked { return Self { entries_by_uri, issuer_ca_sha256_hex, crl_sha256_by_uri, blocked, }; } for entry in &projection.entries { let outputs = entry .outputs .iter() .map(|output| VcirLocalOutput { output_type: VcirOutputType::Vrp, item_effective_until: output.item_effective_until.clone(), source_object_uri: entry.source_object_uri.clone(), source_object_type: VcirSourceObjectType::Roa, source_object_hash: entry.source_object_hash, source_ee_cert_hash: output.source_ee_cert_hash, payload: output.payload.clone(), rule_hash: output.rule_hash, }) .collect::>(); entries_by_uri.insert( entry.source_object_uri.clone(), CachedRoaValidationResult { source_object_hash: entry.source_object_hash, outputs, }, ); } Self { entries_by_uri, issuer_ca_sha256_hex, crl_sha256_by_uri, blocked, } } pub fn from_vcir( vcir: &ValidatedCaInstanceResult, validation_time: time::OffsetDateTime, ) -> Self { let mut entries_by_uri: HashMap = HashMap::new(); let mut issuer_ca_sha256_hex: Option = None; let mut crl_sha256_by_uri: HashMap = HashMap::new(); let blocked = vcir .instance_gate .instance_effective_until .parse() .map(|effective_until| effective_until <= validation_time) .unwrap_or(true); if blocked { return Self { entries_by_uri, issuer_ca_sha256_hex, crl_sha256_by_uri, blocked, }; } for artifact in &vcir.related_artifacts { if artifact.validation_status != VcirArtifactValidationStatus::Accepted { continue; } match (artifact.artifact_role, artifact.artifact_kind) { ( VcirArtifactRole::IssuerCert | VcirArtifactRole::TrustAnchorCert, VcirArtifactKind::Cer, ) => { issuer_ca_sha256_hex = Some(artifact.sha256.clone()); } (_, VcirArtifactKind::Crl) => { if let Some(uri) = artifact.uri.as_ref() { crl_sha256_by_uri.insert(uri.clone(), artifact.sha256.clone()); } } _ => {} } } for output in &vcir.local_outputs { if output.output_type != VcirOutputType::Vrp || output.source_object_type != VcirSourceObjectType::Roa { continue; } entries_by_uri .entry(output.source_object_uri.clone()) .or_insert_with(|| CachedRoaValidationResult { source_object_hash: output.source_object_hash, outputs: Vec::new(), }) .outputs .push(output.clone()); } Self { entries_by_uri, issuer_ca_sha256_hex, crl_sha256_by_uri, blocked, } } fn matches_current_context(&self, issuer_ca_der: &[u8], locked_files: &[PackFile]) -> bool { if self.blocked { return false; } let Some(expected_issuer_hash) = self.issuer_ca_sha256_hex.as_ref() else { return false; }; if expected_issuer_hash != &sha256_hex(issuer_ca_der) { return false; } let current_crl_hashes = locked_files .iter() .filter(|file| file.rsync_uri.ends_with(".crl")) .map(|file| (file.rsync_uri.clone(), sha256_hex_from_32(&file.sha256))) .collect::>(); self.crl_sha256_by_uri == current_crl_hashes } fn lookup( &self, file: &PackFile, validation_time: time::OffsetDateTime, ) -> RoaCacheLookupResult { if self.blocked { return RoaCacheLookupResult::Blocked; } let Some(cached) = self.entries_by_uri.get(file.rsync_uri.as_str()) else { return RoaCacheLookupResult::Miss; }; if cached.source_object_hash != file.sha256 { return RoaCacheLookupResult::Blocked; } if cached.outputs.is_empty() { return RoaCacheLookupResult::Miss; } if cached.outputs.iter().any(|output| { output .item_effective_until .parse() .map_or(true, |t| t <= validation_time) }) { return RoaCacheLookupResult::Blocked; } let mut vrps = Vec::with_capacity(cached.outputs.len()); for output in &cached.outputs { let VcirLocalOutputPayload::Vrp { asn, afi, prefix_len, addr, max_length, } = &output.payload else { return RoaCacheLookupResult::Blocked; }; vrps.push(Vrp { asn: *asn, prefix: IpPrefix { afi: *afi, prefix_len: *prefix_len, addr: *addr, }, max_length: *max_length, }); } RoaCacheLookupResult::Hit(RoaTaskOk { vrps, local_outputs: cached.outputs.clone(), reused_from_cache: true, }) } } fn active_roa_cache_view<'a>( roa_cache: RoaValidationCacheInput<'a>, issuer_ca_der: &[u8], locked_files: &[PackFile], stats: &mut RoaValidationCacheStats, roa_total: usize, ) -> Option<&'a RoaValidationCacheView> { let view = roa_cache.view?; let gate_started = Instant::now(); if view.matches_current_context(issuer_ca_der, locked_files) { stats.context_gate_nanos = stats .context_gate_nanos .saturating_add(gate_started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64); Some(view) } else { stats.context_gate_nanos = stats .context_gate_nanos .saturating_add(gate_started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64); stats.blocked_roas += roa_total; stats.fresh_roas += roa_total; None } } #[derive(Debug)] enum RoaCacheLookupResult { Hit(RoaTaskOk), Miss, Blocked, } #[derive(Clone, Copy)] pub(crate) struct RoaTask<'a> { pub(crate) index: usize, pub(crate) file: &'a PackFile, } #[derive(Debug)] pub(crate) struct RoaTaskOk { pub(crate) vrps: Vec, pub(crate) local_outputs: Vec, pub(crate) reused_from_cache: bool, } #[derive(Debug)] pub(crate) struct RoaTaskResult { pub(crate) publication_point_id: u64, pub(crate) index: usize, pub(crate) worker_index: usize, pub(crate) queue_wait_ms: u64, pub(crate) worker_ms: u64, pub(crate) outcome: Result, } /// Process objects from a publication point snapshot using a known issuer CA certificate /// and its effective resources (resolved via the resource-path, RFC 6487 §7.2). pub fn process_publication_point_for_issuer( publication_point: &P, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, ) -> ObjectsOutput { process_publication_point_for_issuer_with_options( publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, true, ) } pub fn process_publication_point_for_issuer_with_options( publication_point: &P, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, collect_vcir_local_outputs: bool, ) -> ObjectsOutput { process_publication_point_for_issuer_with_cache_options( publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, collect_vcir_local_outputs, RoaValidationCacheInput::disabled(), ) } pub fn process_publication_point_for_issuer_with_cache_options( publication_point: &P, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, collect_vcir_local_outputs: bool, roa_cache: RoaValidationCacheInput<'_>, ) -> ObjectsOutput { let manifest_rsync_uri = publication_point.manifest_rsync_uri(); let manifest_bytes = publication_point.manifest_bytes(); let locked_files = publication_point.files(); let mut warnings: Vec = Vec::new(); let mut stats = ObjectsStats::default(); stats.roa_total = locked_files .iter() .filter(|f| f.rsync_uri.ends_with(".roa")) .count(); stats.aspa_total = locked_files .iter() .filter(|f| f.rsync_uri.ends_with(".asa")) .count(); let mut roa_cache_stats = RoaValidationCacheStats::for_input(roa_cache, stats.roa_total); let mut audit: Vec = Vec::new(); // Enforce that `manifest_bytes` is actually a manifest object. let _manifest = match ManifestObject::decode_der_with_strict_options( manifest_bytes, policy.strict.cms_der, policy.strict.name, ) { Ok(manifest) => manifest, Err(e) => { stats.publication_point_dropped = true; warnings.push( Warning::new(format!( "dropping publication point: manifest decode failed: {e}" )) .with_rfc_refs(&[ RfcRef("RFC 9286 §4"), RfcRef("RFC 9286 §6.2"), RfcRef("RFC 9286 §6.6"), ]) .with_context(manifest_rsync_uri), ); for f in locked_files { if f.rsync_uri.ends_with(".roa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Skipped, detail: Some("skipped: manifest decode failed".to_string()), }); } else if f.rsync_uri.ends_with(".asa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Skipped, detail: Some("skipped: manifest decode failed".to_string()), }); } } return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, audit, roa_cache_stats, }; } }; // Decode issuer CA once; if it fails we cannot validate ROA/ASPA EE certificates. let issuer_ca = match decode_resource_certificate_with_policy(issuer_ca_der, policy) { Ok(v) => v, Err(e) => { stats.publication_point_dropped = true; warnings.push( Warning::new(format!( "dropping publication point: issuer CA decode failed: {e}" )) .with_rfc_refs(&[RfcRef("RFC 6487 §7.2"), RfcRef("RFC 5280 §6.1")]) .with_context(manifest_rsync_uri), ); for f in locked_files { if f.rsync_uri.ends_with(".roa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Skipped, detail: Some("skipped: issuer CA decode failed".to_string()), }); } else if f.rsync_uri.ends_with(".asa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Skipped, detail: Some("skipped: issuer CA decode failed".to_string()), }); } } return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, audit, roa_cache_stats, }; } }; // Parse issuer SubjectPublicKeyInfo once and reuse for all EE certificate signature checks. let issuer_spki = match SubjectPublicKeyInfo::from_der(&issuer_ca.tbs.subject_public_key_info) { Ok((rem, spki)) if rem.is_empty() => spki, Ok((rem, _)) => { stats.publication_point_dropped = true; warnings.push( Warning::new(format!( "dropping publication point: trailing bytes after issuer SPKI DER: {} bytes", rem.len() )) .with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")]) .with_context(manifest_rsync_uri), ); return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, audit, roa_cache_stats, }; } Err(e) => { stats.publication_point_dropped = true; warnings.push( Warning::new(format!( "dropping publication point: issuer SPKI parse failed: {e}" )) .with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")]) .with_context(manifest_rsync_uri), ); return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, audit, roa_cache_stats, }; } }; let mut crl_cache: std::collections::HashMap = locked_files .iter() .filter(|f| f.rsync_uri.ends_with(".crl")) .map(|f| { let bytes = f .bytes_cloned() .expect("snapshot CRL bytes must be loadable"); (f.rsync_uri.clone(), CachedIssuerCrl::Pending(bytes)) }) .collect(); let issuer_resources_index = build_issuer_resources_index(issuer_effective_ip, issuer_effective_as); // If the snapshot has signed objects but no CRLs at all, we cannot validate any embedded EE // certificate paths deterministically (EE CRLDP must reference an rsync URI in the snapshot). if crl_cache.is_empty() && (stats.roa_total > 0 || stats.aspa_total > 0) { stats.publication_point_dropped = true; warnings.push( Warning::new("dropping publication point: no CRL files in validated publication point") .with_rfc_refs(&[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §7")]) .with_context(manifest_rsync_uri), ); for f in locked_files { if f.rsync_uri.ends_with(".roa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Skipped, detail: Some( "skipped due to missing CRL files in validated publication point" .to_string(), ), }); } else if f.rsync_uri.ends_with(".asa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Skipped, detail: Some( "skipped due to missing CRL files in validated publication point" .to_string(), ), }); } } return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, audit, roa_cache_stats, }; } let mut vrps: Vec = Vec::new(); let mut aspas: Vec = Vec::new(); let mut local_outputs_cache: Vec = Vec::new(); let active_cache_view = active_roa_cache_view( roa_cache, issuer_ca_der, locked_files, &mut roa_cache_stats, stats.roa_total, ); for (idx, file) in locked_files.iter().enumerate() { if file.rsync_uri.ends_with(".roa") { let result = if let Some(cache_view) = active_cache_view { let lookup_started = Instant::now(); let lookup_result = cache_view.lookup(file, validation_time); roa_cache_stats.lookup_nanos = roa_cache_stats.lookup_nanos.saturating_add( lookup_started .elapsed() .as_nanos() .min(u128::from(u64::MAX)) as u64, ); match lookup_result { RoaCacheLookupResult::Hit(ok) => { roa_cache_stats.hit_roas += 1; RoaTaskResult { publication_point_id: 0, index: idx, worker_index: 0, queue_wait_ms: 0, worker_ms: 0, outcome: Ok(ok), } } RoaCacheLookupResult::Miss => { roa_cache_stats.miss_roas += 1; roa_cache_stats.fresh_roas += 1; let task = RoaTask { index: idx, file }; let _t = timing.as_ref().map(|t| t.span_phase("objects_roa_total")); validate_roa_task_serial( task, manifest_rsync_uri, issuer_ca_der, &issuer_ca, &issuer_spki, issuer_ca_rsync_uri, &mut crl_cache, &issuer_resources_index, issuer_effective_ip, issuer_effective_as, validation_time, timing, collect_vcir_local_outputs, policy.strict.cms_der, policy.strict.name, ) } RoaCacheLookupResult::Blocked => { roa_cache_stats.blocked_roas += 1; roa_cache_stats.fresh_roas += 1; let task = RoaTask { index: idx, file }; let _t = timing.as_ref().map(|t| t.span_phase("objects_roa_total")); validate_roa_task_serial( task, manifest_rsync_uri, issuer_ca_der, &issuer_ca, &issuer_spki, issuer_ca_rsync_uri, &mut crl_cache, &issuer_resources_index, issuer_effective_ip, issuer_effective_as, validation_time, timing, collect_vcir_local_outputs, policy.strict.cms_der, policy.strict.name, ) } } } else { let task = RoaTask { index: idx, file }; let _t = timing.as_ref().map(|t| t.span_phase("objects_roa_total")); validate_roa_task_serial( task, manifest_rsync_uri, issuer_ca_der, &issuer_ca, &issuer_spki, issuer_ca_rsync_uri, &mut crl_cache, &issuer_resources_index, issuer_effective_ip, issuer_effective_as, validation_time, timing, collect_vcir_local_outputs, policy.strict.cms_der, policy.strict.name, ) }; match result.outcome { Ok(mut ok) => { stats.roa_ok += 1; vrps.append(&mut ok.vrps); if collect_vcir_local_outputs || ok.reused_from_cache { local_outputs_cache.extend(ok.local_outputs); } audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Ok, detail: None, }); } Err(e) => match policy.signed_object_failure_policy { SignedObjectFailurePolicy::DropObject => { audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Error, detail: Some(e.to_string()), }); let mut refs = vec![RfcRef("RFC 6488 §3"), RfcRef("RFC 9582 §4-§5")]; refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e)); warnings.push( Warning::new(format!("dropping invalid ROA: {}: {e}", file.rsync_uri)) .with_rfc_refs(&refs) .with_context(&file.rsync_uri), ) } SignedObjectFailurePolicy::DropPublicationPoint => { stats.publication_point_dropped = true; audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Error, detail: Some(e.to_string()), }); for f in locked_files.iter().skip(idx + 1) { if f.rsync_uri.ends_with(".roa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Skipped, detail: Some( "skipped due to policy=signed_object_failure_policy=drop_publication_point" .to_string(), ), }); } else if f.rsync_uri.ends_with(".asa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Skipped, detail: Some( "skipped due to policy=signed_object_failure_policy=drop_publication_point" .to_string(), ), }); } } let mut refs = vec![RfcRef("RFC 6488 §3"), RfcRef("RFC 9582 §4-§5")]; refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e)); warnings.push( Warning::new(format!( "dropping publication point due to invalid ROA: {}: {e}", file.rsync_uri )) .with_rfc_refs(&refs) .with_context(manifest_rsync_uri), ); return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, audit, roa_cache_stats, }; } }, } } else if file.rsync_uri.ends_with(".asa") { let _t = timing.as_ref().map(|t| t.span_phase("objects_aspa_total")); match process_aspa_with_issuer( file, manifest_rsync_uri, issuer_ca_der, &issuer_ca, &issuer_spki, issuer_ca_rsync_uri, &mut crl_cache, &issuer_resources_index, issuer_effective_ip, issuer_effective_as, validation_time, timing, collect_vcir_local_outputs, policy.strict.cms_der, policy.strict.name, ) { Ok((att, local_output)) => { stats.aspa_ok += 1; aspas.push(att); if let Some(local_output) = local_output { local_outputs_cache.push(local_output); } audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Ok, detail: None, }); } Err(e) => match policy.signed_object_failure_policy { SignedObjectFailurePolicy::DropObject => { audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Error, detail: Some(e.to_string()), }); let mut refs = vec![RfcRef("RFC 6488 §3")]; refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e)); warnings.push( Warning::new(format!("dropping invalid ASPA: {}: {e}", file.rsync_uri)) .with_rfc_refs(&refs) .with_context(&file.rsync_uri), ) } SignedObjectFailurePolicy::DropPublicationPoint => { stats.publication_point_dropped = true; audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Error, detail: Some(e.to_string()), }); for f in locked_files.iter().skip(idx + 1) { if f.rsync_uri.ends_with(".roa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Skipped, detail: Some( "skipped due to policy=signed_object_failure_policy=drop_publication_point" .to_string(), ), }); } else if f.rsync_uri.ends_with(".asa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Skipped, detail: Some( "skipped due to policy=signed_object_failure_policy=drop_publication_point" .to_string(), ), }); } } let mut refs = vec![RfcRef("RFC 6488 §3")]; refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e)); warnings.push( Warning::new(format!( "dropping publication point due to invalid ASPA: {}: {e}", file.rsync_uri )) .with_rfc_refs(&refs) .with_context(manifest_rsync_uri), ); return ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, audit, roa_cache_stats, }; } }, } } } roa_cache_stats.record_to_timing(timing); ObjectsOutput { vrps, aspas, router_keys: Vec::new(), local_outputs_cache, warnings, stats, audit, roa_cache_stats, } } pub fn process_publication_point_for_issuer_parallel_roa( publication_point: &P, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, config: &ParallelPhase2Config, ) -> ObjectsOutput { process_publication_point_for_issuer_parallel_roa_with_options( publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, config, true, ) } pub fn process_publication_point_for_issuer_parallel_roa_with_options( publication_point: &P, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, config: &ParallelPhase2Config, collect_vcir_local_outputs: bool, ) -> ObjectsOutput { process_publication_point_for_issuer_parallel_roa_with_cache_options( publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, config, collect_vcir_local_outputs, RoaValidationCacheInput::disabled(), ) } pub fn process_publication_point_for_issuer_parallel_roa_with_cache_options< P: PublicationPointData, >( publication_point: &P, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, config: &ParallelPhase2Config, collect_vcir_local_outputs: bool, roa_cache: RoaValidationCacheInput<'_>, ) -> ObjectsOutput { if config.object_workers <= 1 || policy.signed_object_failure_policy == SignedObjectFailurePolicy::DropPublicationPoint { return process_publication_point_for_issuer_with_cache_options( publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, collect_vcir_local_outputs, roa_cache, ); } let pool = match ParallelRoaWorkerPool::new(config) { Ok(pool) => pool, Err(_) => { return process_publication_point_for_issuer_with_cache_options( publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, collect_vcir_local_outputs, roa_cache, ); } }; process_publication_point_for_issuer_parallel_roa_with_pool_cache_options( publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, &pool, collect_vcir_local_outputs, roa_cache, ) } pub fn process_publication_point_for_issuer_parallel_roa_with_pool( publication_point: &P, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, pool: &ParallelRoaWorkerPool, ) -> ObjectsOutput { process_publication_point_for_issuer_parallel_roa_with_pool_options( publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, pool, true, ) } pub fn process_publication_point_for_issuer_parallel_roa_with_pool_options< P: PublicationPointData, >( publication_point: &P, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, pool: &ParallelRoaWorkerPool, collect_vcir_local_outputs: bool, ) -> ObjectsOutput { process_publication_point_for_issuer_parallel_roa_with_pool_cache_options( publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, pool, collect_vcir_local_outputs, RoaValidationCacheInput::disabled(), ) } pub fn process_publication_point_for_issuer_parallel_roa_with_pool_cache_options< P: PublicationPointData, >( publication_point: &P, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, pool: &ParallelRoaWorkerPool, collect_vcir_local_outputs: bool, roa_cache: RoaValidationCacheInput<'_>, ) -> ObjectsOutput { if policy.signed_object_failure_policy == SignedObjectFailurePolicy::DropPublicationPoint { return process_publication_point_for_issuer_with_options( publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, collect_vcir_local_outputs, ); } process_publication_point_for_issuer_parallel_roa_inner( publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, pool, collect_vcir_local_outputs, roa_cache, ) .unwrap_or_else(|_| { process_publication_point_for_issuer_with_cache_options( publication_point, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, collect_vcir_local_outputs, roa_cache, ) }) } #[derive(Clone)] pub(crate) struct RoaTaskShared { locked_files: Arc<[PackFile]>, manifest_rsync_uri: Arc, issuer_ca_der: Arc<[u8]>, issuer_ca: Arc, issuer_spki_der: Arc<[u8]>, issuer_ca_rsync_uri: Option>, crl_cache: Arc>>, issuer_resources_index: Arc, issuer_effective_ip: Option>, issuer_effective_as: Option>, } #[derive(Clone)] pub(crate) struct OwnedRoaTask { pub(crate) publication_point_id: u64, index: usize, shared: Arc, validation_time: time::OffsetDateTime, collect_vcir_local_outputs: bool, strict_cms_der: bool, strict_name: bool, pub(crate) submitted_at: Option, } #[derive(Clone)] struct RoaTaskExecutor; impl ObjectTaskExecutor for RoaTaskExecutor { fn execute(&self, worker_index: usize, task: OwnedRoaTask) -> RoaTaskResult { validate_owned_roa_task(worker_index, task) } } pub struct ParallelRoaWorkerPool { pool: Mutex>, } impl ParallelRoaWorkerPool { pub fn new(config: &ParallelPhase2Config) -> Result { if config.object_workers <= 1 { return Err("parallel ROA worker pool requires object_workers > 1".to_string()); } Ok(Self { pool: Mutex::new(ObjectWorkerPool::new( config.object_workers, config.worker_queue_capacity, RoaTaskExecutor, )?), }) } pub(crate) fn try_submit_round_robin( &self, task: OwnedRoaTask, ) -> Result> { self.pool .lock() .expect("parallel ROA worker pool lock") .try_submit_round_robin(task) } pub(crate) fn recv_result_timeout( &self, timeout: Duration, ) -> Result, String> { self.pool .lock() .expect("parallel ROA worker pool lock") .recv_result_timeout(timeout) } } fn validate_owned_roa_task(worker_index: usize, task: OwnedRoaTask) -> RoaTaskResult { let worker_started = Instant::now(); let queue_wait_ms = task .submitted_at .map(|submitted_at| worker_started.saturating_duration_since(submitted_at)) .map(|duration| duration.as_millis() as u64) .unwrap_or(0); let shared = task.shared.as_ref(); let file = task .shared .locked_files .get(task.index) .expect("ROA task index must reference locked file"); let issuer_spki = match SubjectPublicKeyInfo::from_der(shared.issuer_spki_der.as_ref()) { Ok((rem, spki)) if rem.is_empty() => spki, Ok((rem, _)) => { return RoaTaskResult { publication_point_id: task.publication_point_id, index: task.index, worker_index, queue_wait_ms, worker_ms: worker_started.elapsed().as_millis() as u64, outcome: Err(ObjectValidateError::CertPath( CertPathError::IssuerSpkiTrailingBytes(rem.len()), )), }; } Err(e) => { return RoaTaskResult { publication_point_id: task.publication_point_id, index: task.index, worker_index, queue_wait_ms, worker_ms: worker_started.elapsed().as_millis() as u64, outcome: Err(ObjectValidateError::CertPath( CertPathError::IssuerSpkiParse(e.to_string()), )), }; } }; let outcome = process_roa_with_issuer_parallel_cached( file, shared.manifest_rsync_uri.as_ref(), shared.issuer_ca_der.as_ref(), shared.issuer_ca.as_ref(), &issuer_spki, shared.issuer_ca_rsync_uri.as_deref(), shared.crl_cache.as_ref(), shared.issuer_resources_index.as_ref(), shared.issuer_effective_ip.as_deref(), shared.issuer_effective_as.as_deref(), task.validation_time, None, task.collect_vcir_local_outputs, task.strict_cms_der, task.strict_name, ) .map(|(vrps, local_outputs)| RoaTaskOk { vrps, local_outputs, reused_from_cache: false, }); RoaTaskResult { publication_point_id: task.publication_point_id, index: task.index, worker_index, queue_wait_ms, worker_ms: worker_started.elapsed().as_millis() as u64, outcome, } } pub(crate) enum ParallelObjectsPrepare { Complete(ObjectsOutput), Staged(ParallelObjectsStage), } pub(crate) struct ParallelObjectsStage { pub(crate) publication_point_id: u64, shared: Arc, validation_time: time::OffsetDateTime, collect_vcir_local_outputs: bool, strict_cms_der: bool, strict_name: bool, roa_task_indices: Vec, cached_roa_results: Vec, roa_cache_stats: RoaValidationCacheStats, warnings: Vec, stats: ObjectsStats, audit: Vec, } impl ParallelObjectsStage { #[cfg(test)] pub(crate) fn build_roa_tasks(&self) -> Vec { let mut tasks = Vec::with_capacity(self.roa_task_count()); self.extend_roa_tasks(|task| tasks.push(task)); tasks } pub(crate) fn append_roa_tasks_to( &self, pending: &mut std::collections::VecDeque, ) { self.extend_roa_tasks(|task| pending.push_back(task)); } fn extend_roa_tasks(&self, mut push: F) where F: FnMut(OwnedRoaTask), { let shared = self.shared.clone(); self.roa_task_indices.iter().for_each(|index| { push(OwnedRoaTask { publication_point_id: self.publication_point_id, index: *index, shared: shared.clone(), validation_time: self.validation_time, collect_vcir_local_outputs: self.collect_vcir_local_outputs, strict_cms_der: self.strict_cms_der, strict_name: self.strict_name, submitted_at: None, }); }); } pub(crate) fn roa_task_count(&self) -> usize { self.roa_task_indices.len() } pub(crate) fn aspa_task_count(&self) -> usize { self.stats.aspa_total } pub(crate) fn locked_file_count(&self) -> usize { self.shared.locked_files.len() } } pub(crate) fn prepare_publication_point_for_parallel_roa_with_cache( publication_point_id: u64, publication_point: &P, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, collect_vcir_local_outputs: bool, roa_cache: RoaValidationCacheInput<'_>, ) -> ParallelObjectsPrepare { let manifest_rsync_uri = publication_point.manifest_rsync_uri(); let manifest_bytes = publication_point.manifest_bytes(); let locked_files = publication_point.files(); let mut warnings: Vec = Vec::new(); let mut stats = ObjectsStats::default(); stats.roa_total = locked_files .iter() .filter(|f| f.rsync_uri.ends_with(".roa")) .count(); stats.aspa_total = locked_files .iter() .filter(|f| f.rsync_uri.ends_with(".asa")) .count(); let mut roa_cache_stats = RoaValidationCacheStats::for_input(roa_cache, stats.roa_total); let mut audit: Vec = Vec::new(); let _manifest = match ManifestObject::decode_der_with_strict_options( manifest_bytes, policy.strict.cms_der, policy.strict.name, ) { Ok(manifest) => manifest, Err(e) => { stats.publication_point_dropped = true; warnings.push( Warning::new(format!( "dropping publication point: manifest decode failed: {e}" )) .with_rfc_refs(&[ RfcRef("RFC 9286 §4"), RfcRef("RFC 9286 §6.2"), RfcRef("RFC 9286 §6.6"), ]) .with_context(manifest_rsync_uri), ); for f in locked_files { if f.rsync_uri.ends_with(".roa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Skipped, detail: Some("skipped: manifest decode failed".to_string()), }); } else if f.rsync_uri.ends_with(".asa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Skipped, detail: Some("skipped: manifest decode failed".to_string()), }); } } return ParallelObjectsPrepare::Complete(ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, audit, roa_cache_stats, }); } }; let issuer_ca = match decode_resource_certificate_with_policy(issuer_ca_der, policy) { Ok(v) => v, Err(e) => { stats.publication_point_dropped = true; warnings.push( Warning::new(format!( "dropping publication point: issuer CA decode failed: {e}" )) .with_rfc_refs(&[RfcRef("RFC 6487 §7.2"), RfcRef("RFC 5280 §6.1")]) .with_context(manifest_rsync_uri), ); for f in locked_files { if f.rsync_uri.ends_with(".roa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Skipped, detail: Some("skipped: issuer CA decode failed".to_string()), }); } else if f.rsync_uri.ends_with(".asa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Skipped, detail: Some("skipped: issuer CA decode failed".to_string()), }); } } return ParallelObjectsPrepare::Complete(ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, audit, roa_cache_stats, }); } }; match SubjectPublicKeyInfo::from_der(&issuer_ca.tbs.subject_public_key_info) { Ok((rem, _)) if rem.is_empty() => {} Ok((rem, _)) => { stats.publication_point_dropped = true; warnings.push( Warning::new(format!( "dropping publication point: trailing bytes after issuer SPKI DER: {} bytes", rem.len() )) .with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")]) .with_context(manifest_rsync_uri), ); return ParallelObjectsPrepare::Complete(ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, audit, roa_cache_stats, }); } Err(e) => { stats.publication_point_dropped = true; warnings.push( Warning::new(format!( "dropping publication point: issuer SPKI parse failed: {e}" )) .with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")]) .with_context(manifest_rsync_uri), ); return ParallelObjectsPrepare::Complete(ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, audit, roa_cache_stats, }); } } let crl_cache: std::collections::HashMap = locked_files .iter() .filter(|f| f.rsync_uri.ends_with(".crl")) .map(|f| { let bytes = f .bytes_cloned() .expect("snapshot CRL bytes must be loadable"); (f.rsync_uri.clone(), CachedIssuerCrl::Pending(bytes)) }) .collect(); if crl_cache.is_empty() && (stats.roa_total > 0 || stats.aspa_total > 0) { stats.publication_point_dropped = true; warnings.push( Warning::new("dropping publication point: no CRL files in validated publication point") .with_rfc_refs(&[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §7")]) .with_context(manifest_rsync_uri), ); for f in locked_files { if f.rsync_uri.ends_with(".roa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Skipped, detail: Some( "skipped due to missing CRL files in validated publication point" .to_string(), ), }); } else if f.rsync_uri.ends_with(".asa") { audit.push(ObjectAuditEntry { rsync_uri: f.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&f.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Skipped, detail: Some( "skipped due to missing CRL files in validated publication point" .to_string(), ), }); } } return ParallelObjectsPrepare::Complete(ObjectsOutput { vrps: Vec::new(), aspas: Vec::new(), router_keys: Vec::new(), local_outputs_cache: Vec::new(), warnings, stats, audit, roa_cache_stats, }); } let active_cache_view = active_roa_cache_view( roa_cache, issuer_ca_der, locked_files, &mut roa_cache_stats, stats.roa_total, ); let mut roa_task_indices = Vec::new(); let mut cached_roa_results = Vec::new(); for (index, file) in locked_files.iter().enumerate() { if !file.rsync_uri.ends_with(".roa") { continue; } if let Some(cache_view) = active_cache_view { let lookup_started = Instant::now(); let lookup_result = cache_view.lookup(file, validation_time); roa_cache_stats.lookup_nanos = roa_cache_stats.lookup_nanos.saturating_add( lookup_started .elapsed() .as_nanos() .min(u128::from(u64::MAX)) as u64, ); match lookup_result { RoaCacheLookupResult::Hit(ok) => { roa_cache_stats.hit_roas += 1; cached_roa_results.push(RoaTaskResult { publication_point_id, index, worker_index: usize::MAX, queue_wait_ms: 0, worker_ms: 0, outcome: Ok(ok), }); } RoaCacheLookupResult::Miss => { roa_cache_stats.miss_roas += 1; roa_cache_stats.fresh_roas += 1; roa_task_indices.push(index); } RoaCacheLookupResult::Blocked => { roa_cache_stats.blocked_roas += 1; roa_cache_stats.fresh_roas += 1; roa_task_indices.push(index); } } } else { roa_task_indices.push(index); } } ParallelObjectsPrepare::Staged(ParallelObjectsStage { publication_point_id, shared: Arc::new(RoaTaskShared { locked_files: Arc::<[PackFile]>::from(locked_files.to_vec()), manifest_rsync_uri: Arc::::from(manifest_rsync_uri), issuer_ca_der: Arc::<[u8]>::from(issuer_ca_der.to_vec()), issuer_spki_der: Arc::<[u8]>::from(issuer_ca.tbs.subject_public_key_info.clone()), issuer_ca: Arc::new(issuer_ca), issuer_ca_rsync_uri: issuer_ca_rsync_uri.map(Arc::::from), crl_cache: Arc::new(Mutex::new(crl_cache)), issuer_resources_index: Arc::new(build_issuer_resources_index( issuer_effective_ip, issuer_effective_as, )), issuer_effective_ip: issuer_effective_ip.cloned().map(Arc::new), issuer_effective_as: issuer_effective_as.cloned().map(Arc::new), }), validation_time, collect_vcir_local_outputs, strict_cms_der: policy.strict.cms_der, strict_name: policy.strict.name, roa_task_indices, cached_roa_results, roa_cache_stats, warnings, stats, audit, }) } pub(crate) fn reduce_parallel_roa_stage( stage: ParallelObjectsStage, mut roa_results: Vec, timing: Option<&TimingHandle>, ) -> Result { roa_results.extend(stage.cached_roa_results); roa_results.sort_by_key(|result| result.index); let mut roa_results = roa_results.into_iter().peekable(); let shared = stage.shared.clone(); let mut aspa_crl_cache = shared .crl_cache .lock() .expect("parallel ROA CRL cache lock") .clone(); let issuer_spki = SubjectPublicKeyInfo::from_der(shared.issuer_spki_der.as_ref()) .map_err(|e| e.to_string())? .1; let collect_vcir_local_outputs = stage.collect_vcir_local_outputs; let validation_time = stage.validation_time; let strict_cms_der = stage.strict_cms_der; let strict_name = stage.strict_name; let roa_cache_stats = stage.roa_cache_stats; let mut stats = stage.stats; let mut warnings = stage.warnings; let mut audit = stage.audit; let mut vrps: Vec = Vec::new(); let mut aspas: Vec = Vec::new(); let mut local_outputs_cache: Vec = Vec::new(); for (idx, file) in shared.locked_files.iter().enumerate() { if file.rsync_uri.ends_with(".roa") { let result = match roa_results.peek() { Some(result) if result.index == idx => roa_results .next() .expect("peeked ROA task result must be present"), Some(result) => { return Err(format!( "unexpected ROA task result index {} while reducing {} at index {}", result.index, file.rsync_uri, idx )); } None => { return Err(format!("missing ROA task result for {}", file.rsync_uri)); } }; match result.outcome { Ok(mut ok) => { stats.roa_ok += 1; vrps.append(&mut ok.vrps); if collect_vcir_local_outputs || ok.reused_from_cache { local_outputs_cache.extend(ok.local_outputs); } audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Ok, detail: None, }); } Err(e) => { audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Roa, result: AuditObjectResult::Error, detail: Some(e.to_string()), }); let mut refs = vec![RfcRef("RFC 6488 §3"), RfcRef("RFC 9582 §4-§5")]; refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e)); warnings.push( Warning::new(format!("dropping invalid ROA: {}: {e}", file.rsync_uri)) .with_rfc_refs(&refs) .with_context(&file.rsync_uri), ) } } } else if file.rsync_uri.ends_with(".asa") { let _t = timing.as_ref().map(|t| t.span_phase("objects_aspa_total")); match process_aspa_with_issuer( file, shared.manifest_rsync_uri.as_ref(), shared.issuer_ca_der.as_ref(), shared.issuer_ca.as_ref(), &issuer_spki, shared.issuer_ca_rsync_uri.as_deref(), &mut aspa_crl_cache, shared.issuer_resources_index.as_ref(), shared.issuer_effective_ip.as_deref(), shared.issuer_effective_as.as_deref(), validation_time, timing, collect_vcir_local_outputs, strict_cms_der, strict_name, ) { Ok((att, local_output)) => { stats.aspa_ok += 1; aspas.push(att); if let Some(local_output) = local_output { local_outputs_cache.push(local_output); } audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Ok, detail: None, }); } Err(e) => { audit.push(ObjectAuditEntry { rsync_uri: file.rsync_uri.clone(), sha256_hex: sha256_hex_from_32(&file.sha256), kind: AuditObjectKind::Aspa, result: AuditObjectResult::Error, detail: Some(e.to_string()), }); let mut refs = vec![RfcRef("RFC 6488 §3")]; refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e)); warnings.push( Warning::new(format!("dropping invalid ASPA: {}: {e}", file.rsync_uri)) .with_rfc_refs(&refs) .with_context(&file.rsync_uri), ) } } } } if let Some(result) = roa_results.next() { return Err(format!( "unexpected trailing ROA task result at index {}", result.index )); } roa_cache_stats.record_to_timing(timing); Ok(ObjectsOutput { vrps, aspas, router_keys: Vec::new(), local_outputs_cache, warnings, stats, audit, roa_cache_stats, }) } fn process_publication_point_for_issuer_parallel_roa_inner( publication_point: &P, _policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, pool: &ParallelRoaWorkerPool, collect_vcir_local_outputs: bool, roa_cache: RoaValidationCacheInput<'_>, ) -> Result { let stage = match prepare_publication_point_for_parallel_roa_with_cache( 0, publication_point, _policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, collect_vcir_local_outputs, roa_cache, ) { ParallelObjectsPrepare::Complete(out) => return Ok(out), ParallelObjectsPrepare::Staged(stage) => stage, }; let roa_task_count = stage.roa_task_count(); let mut pending = std::collections::VecDeque::with_capacity(roa_task_count); stage.append_roa_tasks_to(&mut pending); let mut worker_pool = pool .pool .lock() .map_err(|_| "parallel ROA worker pool lock poisoned".to_string())?; while let Some(task) = pending.pop_front() { match worker_pool.try_submit_round_robin(task) { Ok(_) => {} Err(ObjectWorkerSubmitError::QueueFull { task, .. }) => { pending.push_front(task); std::thread::yield_now(); } Err(ObjectWorkerSubmitError::Disconnected { .. }) => { return Err("parallel ROA worker queue disconnected".to_string()); } } } let mut roa_results = Vec::with_capacity(roa_task_count); while roa_results.len() < roa_task_count { let Some(result) = worker_pool.recv_result_timeout(Duration::from_secs(30))? else { return Err("parallel ROA worker timed out".to_string()); }; roa_results.push(result); } drop(worker_pool); reduce_parallel_roa_stage(stage, roa_results, timing) } /// Compatibility wrapper that processes a publication point snapshot. pub fn process_publication_point_snapshot_for_issuer( pack: &PublicationPointSnapshot, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, ) -> ObjectsOutput { process_publication_point_for_issuer( pack, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, ) } pub fn process_publication_point_snapshot_for_issuer_parallel_roa( pack: &PublicationPointSnapshot, policy: &Policy, issuer_ca_der: &[u8], issuer_ca_rsync_uri: Option<&str>, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, config: &ParallelPhase2Config, ) -> ObjectsOutput { process_publication_point_for_issuer_parallel_roa( pack, policy, issuer_ca_der, issuer_ca_rsync_uri, issuer_effective_ip, issuer_effective_as, validation_time, timing, config, ) } #[derive(Debug, thiserror::Error)] pub(crate) enum ObjectValidateError { #[error("object bytes load failed: {0}")] BytesLoad(String), #[error("ROA decode failed: {0}")] RoaDecode(#[from] RoaDecodeError), #[error("ROA embedded EE resource validation failed: {0}")] RoaEeResources(#[from] RoaValidateError), #[error("ASPA decode failed: {0}")] AspaDecode(#[from] AspaDecodeError), #[error("ASPA embedded EE resource validation failed: {0}")] AspaEeResources(#[from] AspaValidateError), #[error("CMS signature verification failed: {0}")] Signature(#[from] SignedObjectVerifyError), #[error("EE certificate path validation failed: {0}")] CertPath(#[from] CertPathError), #[error( "certificate CRLDistributionPoints URIs missing (cannot select issuer CRL) (RFC 6487 §4.8.6)" )] MissingCrlDpUris, #[error( "no CRL available in publication point snapshot (cannot validate certificates) (RFC 9286 §7; RFC 6487 §4.8.6)" )] MissingCrlInPack, #[error( "CRL referenced by CRLDistributionPoints not found in publication point snapshot: {0} (RFC 6487 §4.8.6; RFC 9286 §4.2.1)" )] CrlNotFound(String), #[error( "issuer effective IP resources missing (cannot validate EE IP resources subset) (RFC 6487 §7.2; RFC 3779 §2.3)" )] MissingIssuerEffectiveIp, #[error( "issuer effective AS resources missing (cannot validate EE AS resources subset) (RFC 6487 §7.2; RFC 3779 §3.3)" )] MissingIssuerEffectiveAs, #[error( "EE certificate resources are not a subset of issuer effective resources (RFC 6487 §7.2; RFC 3779)" )] EeResourcesNotSubset, } pub(crate) fn validate_roa_task_serial( task: RoaTask<'_>, manifest_rsync_uri: &str, issuer_ca_der: &[u8], issuer_ca: &ResourceCertificate, issuer_spki: &SubjectPublicKeyInfo<'_>, issuer_ca_rsync_uri: Option<&str>, crl_cache: &mut std::collections::HashMap, issuer_resources_index: &IssuerResourcesIndex, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, collect_vcir_local_outputs: bool, strict_cms_der: bool, strict_name: bool, ) -> RoaTaskResult { let outcome = process_roa_with_issuer( task.file, manifest_rsync_uri, issuer_ca_der, issuer_ca, issuer_spki, issuer_ca_rsync_uri, crl_cache, issuer_resources_index, issuer_effective_ip, issuer_effective_as, validation_time, timing, collect_vcir_local_outputs, strict_cms_der, strict_name, ) .map(|(vrps, local_outputs)| RoaTaskOk { vrps, local_outputs, reused_from_cache: false, }); RoaTaskResult { publication_point_id: 0, index: task.index, worker_index: 0, queue_wait_ms: 0, worker_ms: 0, outcome, } } fn process_roa_with_issuer( file: &PackFile, _manifest_rsync_uri: &str, issuer_ca_der: &[u8], issuer_ca: &ResourceCertificate, issuer_spki: &SubjectPublicKeyInfo<'_>, issuer_ca_rsync_uri: Option<&str>, crl_cache: &mut std::collections::HashMap, issuer_resources_index: &IssuerResourcesIndex, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, collect_vcir_local_outputs: bool, strict_cms_der: bool, strict_name: bool, ) -> Result<(Vec, Vec), ObjectValidateError> { let _decode = timing .as_ref() .map(|t| t.span_phase("objects_roa_decode_and_validate_total")); let roa = RoaObject::decode_der_with_strict_options( file.bytes().map_err(ObjectValidateError::BytesLoad)?, strict_cms_der, strict_name, )?; drop(_decode); let _ee_profile = timing .as_ref() .map(|t| t.span_phase("objects_roa_validate_embedded_ee_total")); roa.validate_embedded_ee_cert()?; drop(_ee_profile); let _verify = timing .as_ref() .map(|t| t.span_phase("objects_roa_verify_signature_total")); roa.signed_object.verify()?; drop(_verify); let ee = &roa.signed_object.signed_data.certificates[0]; let ee_crldp_uris = ee .resource_cert .tbs .extensions .crl_distribution_points_uris .as_ref(); let issuer_crl_rsync_uri = choose_crl_uri_for_certificate(ee_crldp_uris, crl_cache)?; let verified_crl = ensure_issuer_crl_verified(issuer_crl_rsync_uri, crl_cache, issuer_ca_der)?; let _cert_path = timing .as_ref() .map(|t| t.span_phase("objects_roa_validate_ee_cert_path_total")); validate_signed_object_ee_cert_path_fast( ee, issuer_ca, issuer_spki, &verified_crl.crl, &verified_crl.revoked_serials, issuer_ca_rsync_uri, Some(issuer_crl_rsync_uri), validation_time, )?; drop(_cert_path); let _subset = timing .as_ref() .map(|t| t.span_phase("objects_roa_validate_ee_resources_subset_total")); validate_ee_resources_subset( &ee.resource_cert, issuer_effective_ip, issuer_effective_as, issuer_resources_index, )?; drop(_subset); let vrps = roa_to_vrps(&roa); if !collect_vcir_local_outputs { return Ok((vrps, Vec::new())); } let source_object_hash = sha256_hex_from_32(&file.sha256); let source_ee_cert_hash = crate::audit::sha256_hex(ee.raw_der.as_slice()); let item_effective_until = PackTime::from_utc_offset_datetime(ee.resource_cert.tbs.validity_not_after); let local_outputs = vrps .iter() .map(|vrp| { let prefix = vrp_prefix_to_string(vrp); let rule_hash = crate::audit::sha256_hex( format!( "roa-rule:{}:{}:{}:{}", source_object_hash, vrp.asn, prefix, vrp.max_length ) .as_bytes(), ); VcirLocalOutput { output_type: VcirOutputType::Vrp, item_effective_until: item_effective_until.clone(), source_object_uri: file.rsync_uri.clone(), source_object_type: VcirSourceObjectType::Roa, source_object_hash: file.sha256, source_ee_cert_hash: sha256_hex_to_32(&source_ee_cert_hash), payload: VcirLocalOutputPayload::Vrp { asn: vrp.asn, afi: vrp.prefix.afi, prefix_len: vrp.prefix.prefix_len, addr: vrp.prefix.addr, max_length: vrp.max_length, }, rule_hash: sha256_hex_to_32(&rule_hash), } }) .collect(); Ok((vrps, local_outputs)) } fn process_roa_with_issuer_parallel_cached( file: &PackFile, _manifest_rsync_uri: &str, issuer_ca_der: &[u8], issuer_ca: &ResourceCertificate, issuer_spki: &SubjectPublicKeyInfo<'_>, issuer_ca_rsync_uri: Option<&str>, crl_cache: &Mutex>, issuer_resources_index: &IssuerResourcesIndex, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, collect_vcir_local_outputs: bool, strict_cms_der: bool, strict_name: bool, ) -> Result<(Vec, Vec), ObjectValidateError> { let _decode = timing .as_ref() .map(|t| t.span_phase("objects_roa_decode_and_validate_total")); let roa = RoaObject::decode_der_with_strict_options( file.bytes().map_err(ObjectValidateError::BytesLoad)?, strict_cms_der, strict_name, )?; drop(_decode); let _ee_profile = timing .as_ref() .map(|t| t.span_phase("objects_roa_validate_embedded_ee_total")); roa.validate_embedded_ee_cert()?; drop(_ee_profile); let _verify = timing .as_ref() .map(|t| t.span_phase("objects_roa_verify_signature_total")); roa.signed_object.verify()?; drop(_verify); let ee = &roa.signed_object.signed_data.certificates[0]; let ee_crldp_uris = ee .resource_cert .tbs .extensions .crl_distribution_points_uris .as_ref(); let (issuer_crl_rsync_uri, verified_crl) = { let mut crl_cache = crl_cache.lock().expect("parallel ROA CRL cache lock"); let issuer_crl_rsync_uri = choose_crl_uri_for_certificate(ee_crldp_uris, &crl_cache)?.to_string(); let verified_crl = ensure_issuer_crl_verified(&issuer_crl_rsync_uri, &mut crl_cache, issuer_ca_der)?; (issuer_crl_rsync_uri, verified_crl) }; let _cert_path = timing .as_ref() .map(|t| t.span_phase("objects_roa_validate_ee_cert_path_total")); validate_signed_object_ee_cert_path_fast( ee, issuer_ca, issuer_spki, &verified_crl.crl, &verified_crl.revoked_serials, issuer_ca_rsync_uri, Some(issuer_crl_rsync_uri.as_str()), validation_time, )?; drop(_cert_path); let _subset = timing .as_ref() .map(|t| t.span_phase("objects_roa_validate_ee_resources_subset_total")); validate_ee_resources_subset( &ee.resource_cert, issuer_effective_ip, issuer_effective_as, issuer_resources_index, )?; drop(_subset); let vrps = roa_to_vrps(&roa); if !collect_vcir_local_outputs { return Ok((vrps, Vec::new())); } let source_object_hash = sha256_hex_from_32(&file.sha256); let source_ee_cert_hash = crate::audit::sha256_hex(ee.raw_der.as_slice()); let item_effective_until = PackTime::from_utc_offset_datetime(ee.resource_cert.tbs.validity_not_after); let local_outputs = vrps .iter() .map(|vrp| { let prefix = vrp_prefix_to_string(vrp); let rule_hash = crate::audit::sha256_hex( format!( "roa-rule:{}:{}:{}:{}", source_object_hash, vrp.asn, prefix, vrp.max_length ) .as_bytes(), ); VcirLocalOutput { output_type: VcirOutputType::Vrp, item_effective_until: item_effective_until.clone(), source_object_uri: file.rsync_uri.clone(), source_object_type: VcirSourceObjectType::Roa, source_object_hash: file.sha256, source_ee_cert_hash: sha256_hex_to_32(&source_ee_cert_hash), payload: VcirLocalOutputPayload::Vrp { asn: vrp.asn, afi: vrp.prefix.afi, prefix_len: vrp.prefix.prefix_len, addr: vrp.prefix.addr, max_length: vrp.max_length, }, rule_hash: sha256_hex_to_32(&rule_hash), } }) .collect(); Ok((vrps, local_outputs)) } fn process_aspa_with_issuer( file: &PackFile, _manifest_rsync_uri: &str, issuer_ca_der: &[u8], issuer_ca: &ResourceCertificate, issuer_spki: &SubjectPublicKeyInfo<'_>, issuer_ca_rsync_uri: Option<&str>, crl_cache: &mut std::collections::HashMap, issuer_resources_index: &IssuerResourcesIndex, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, validation_time: time::OffsetDateTime, timing: Option<&TimingHandle>, collect_vcir_local_outputs: bool, strict_cms_der: bool, strict_name: bool, ) -> Result<(AspaAttestation, Option), ObjectValidateError> { let _decode = timing .as_ref() .map(|t| t.span_phase("objects_aspa_decode_and_validate_total")); let aspa = AspaObject::decode_der_with_strict_options( file.bytes().map_err(ObjectValidateError::BytesLoad)?, strict_cms_der, strict_name, )?; drop(_decode); let _ee_profile = timing .as_ref() .map(|t| t.span_phase("objects_aspa_validate_embedded_ee_total")); aspa.validate_embedded_ee_cert()?; drop(_ee_profile); let _verify = timing .as_ref() .map(|t| t.span_phase("objects_aspa_verify_signature_total")); aspa.signed_object.verify()?; drop(_verify); let ee = &aspa.signed_object.signed_data.certificates[0]; let ee_crldp_uris = ee .resource_cert .tbs .extensions .crl_distribution_points_uris .as_ref(); let issuer_crl_rsync_uri = choose_crl_uri_for_certificate(ee_crldp_uris, crl_cache)?; let verified_crl = ensure_issuer_crl_verified(issuer_crl_rsync_uri, crl_cache, issuer_ca_der)?; let _cert_path = timing .as_ref() .map(|t| t.span_phase("objects_aspa_validate_ee_cert_path_total")); validate_signed_object_ee_cert_path_fast( ee, issuer_ca, issuer_spki, &verified_crl.crl, &verified_crl.revoked_serials, issuer_ca_rsync_uri, Some(issuer_crl_rsync_uri), validation_time, )?; drop(_cert_path); let _subset = timing .as_ref() .map(|t| t.span_phase("objects_aspa_validate_ee_resources_subset_total")); validate_ee_resources_subset( &ee.resource_cert, issuer_effective_ip, issuer_effective_as, issuer_resources_index, )?; drop(_subset); let attestation = AspaAttestation { customer_as_id: aspa.aspa.customer_as_id, provider_as_ids: aspa.aspa.provider_as_ids.clone(), }; if !collect_vcir_local_outputs { return Ok((attestation, None)); } let source_object_hash = sha256_hex_from_32(&file.sha256); let source_ee_cert_hash = crate::audit::sha256_hex(ee.raw_der.as_slice()); let item_effective_until = PackTime::from_utc_offset_datetime(ee.resource_cert.tbs.validity_not_after); let providers = attestation .provider_as_ids .iter() .map(u32::to_string) .collect::>() .join(","); let rule_hash = crate::audit::sha256_hex( format!( "aspa-rule:{}:{}:{}", source_object_hash, attestation.customer_as_id, providers ) .as_bytes(), ); let local_output = VcirLocalOutput { output_type: VcirOutputType::Aspa, item_effective_until, source_object_uri: file.rsync_uri.clone(), source_object_type: VcirSourceObjectType::Aspa, source_object_hash: file.sha256, source_ee_cert_hash: sha256_hex_to_32(&source_ee_cert_hash), payload: VcirLocalOutputPayload::Aspa { customer_as_id: attestation.customer_as_id, provider_as_ids: attestation.provider_as_ids.clone(), }, rule_hash: sha256_hex_to_32(&rule_hash), }; Ok((attestation, Some(local_output))) } fn vrp_prefix_to_string(vrp: &Vrp) -> String { let prefix = &vrp.prefix; match prefix.afi { RoaAfi::Ipv4 => { let addr = std::net::Ipv4Addr::new( prefix.addr[0], prefix.addr[1], prefix.addr[2], prefix.addr[3], ); format!("{addr}/{}", prefix.prefix_len) } RoaAfi::Ipv6 => { let mut octets = [0u8; 16]; octets.copy_from_slice(&prefix.addr[..16]); let addr = std::net::Ipv6Addr::from(octets); format!("{addr}/{}", prefix.prefix_len) } } } fn choose_crl_uri_for_certificate<'a>( crldp_uris: Option<&'a Vec>, crl_cache: &std::collections::HashMap, ) -> Result<&'a str, ObjectValidateError> { if crl_cache.is_empty() { return Err(ObjectValidateError::MissingCrlInPack); } let Some(crldp_uris) = crldp_uris else { return Err(ObjectValidateError::MissingCrlDpUris); }; for u in crldp_uris { let s = u.as_str(); if crl_cache.contains_key(s) { return Ok(s); } } Err(ObjectValidateError::CrlNotFound( crldp_uris .iter() .map(|u| u.as_str()) .collect::>() .join(", "), )) } fn ensure_issuer_crl_verified<'a>( crl_rsync_uri: &str, crl_cache: &'a mut std::collections::HashMap, issuer_ca_der: &[u8], ) -> Result, CertPathError> { let entry = crl_cache .get_mut(crl_rsync_uri) .expect("CRL must exist in cache"); match entry { CachedIssuerCrl::Ok(v) => Ok(Arc::clone(v)), CachedIssuerCrl::Pending(bytes) => { let der = std::mem::take(bytes); let crl = crate::data_model::crl::RpkixCrl::decode_der(&der) .map_err(CertPathError::CrlDecode)?; crl.verify_signature_with_issuer_certificate_der(issuer_ca_der) .map_err(CertPathError::CrlVerify)?; let mut revoked_serials: std::collections::HashSet> = std::collections::HashSet::with_capacity(crl.revoked_certs.len()); for rc in &crl.revoked_certs { revoked_serials.insert(rc.serial_number.bytes_be.clone()); } *entry = CachedIssuerCrl::Ok(Arc::new(VerifiedIssuerCrl { crl, revoked_serials, })); match entry { CachedIssuerCrl::Ok(v) => Ok(Arc::clone(v)), _ => unreachable!(), } } } } fn validate_ee_resources_subset( ee: &ResourceCertificate, issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, issuer_resources_index: &IssuerResourcesIndex, ) -> Result<(), ObjectValidateError> { if let Some(child_ip) = ee.tbs.extensions.ip_resources.as_ref() { let Some(parent_ip) = issuer_effective_ip else { return Err(ObjectValidateError::MissingIssuerEffectiveIp); }; if !ip_resources_is_subset_indexed(child_ip, parent_ip, issuer_resources_index) { return Err(ObjectValidateError::EeResourcesNotSubset); } } if let Some(child_as) = ee.tbs.extensions.as_resources.as_ref() { let Some(parent_as) = issuer_effective_as else { return Err(ObjectValidateError::MissingIssuerEffectiveAs); }; if !as_resources_is_subset_indexed(child_as, parent_as, issuer_resources_index) { return Err(ObjectValidateError::EeResourcesNotSubset); } } Ok(()) } fn as_resources_is_subset(child: &AsResourceSet, parent: &AsResourceSet) -> bool { as_choice_subset(child.asnum.as_ref(), parent.asnum.as_ref()) && as_choice_subset(child.rdi.as_ref(), parent.rdi.as_ref()) } fn as_resources_is_subset_indexed( child: &AsResourceSet, parent: &AsResourceSet, idx: &IssuerResourcesIndex, ) -> bool { let _ = parent; as_choice_subset_indexed(child.asnum.as_ref(), idx.asnum.as_deref()) && as_choice_subset_indexed(child.rdi.as_ref(), idx.rdi.as_deref()) } fn as_choice_subset_indexed( child: Option<&AsIdentifierChoice>, parent_intervals: Option<&[(u32, u32)]>, ) -> bool { let Some(child) = child else { return true; }; let Some(parent_intervals) = parent_intervals else { return false; }; if matches!(child, AsIdentifierChoice::Inherit) { return false; } let child_intervals = as_choice_to_merged_intervals(child); for (cmin, cmax) in &child_intervals { if !as_interval_is_covered(parent_intervals, *cmin, *cmax) { return false; } } true } fn as_choice_subset( child: Option<&AsIdentifierChoice>, parent: Option<&AsIdentifierChoice>, ) -> bool { let Some(child) = child else { return true; }; let Some(parent) = parent else { return false; }; match (child, parent) { (AsIdentifierChoice::Inherit, _) => return false, (_, AsIdentifierChoice::Inherit) => return false, _ => {} } let child_intervals = as_choice_to_merged_intervals(child); let parent_intervals = as_choice_to_merged_intervals(parent); for (cmin, cmax) in &child_intervals { if !as_interval_is_covered(&parent_intervals, *cmin, *cmax) { return false; } } true } fn as_choice_to_merged_intervals(choice: &AsIdentifierChoice) -> Vec<(u32, u32)> { let mut v = Vec::new(); match choice { AsIdentifierChoice::Inherit => {} AsIdentifierChoice::AsIdsOrRanges(items) => { for item in items { match item { crate::data_model::rc::AsIdOrRange::Id(id) => v.push((*id, *id)), crate::data_model::rc::AsIdOrRange::Range { min, max } => v.push((*min, *max)), } } } } v.sort_by_key(|(a, _)| *a); merge_as_intervals(&v) } fn merge_as_intervals(v: &[(u32, u32)]) -> Vec<(u32, u32)> { let mut out: Vec<(u32, u32)> = Vec::new(); for (min, max) in v { let Some(last) = out.last_mut() else { out.push((*min, *max)); continue; }; if *min <= last.1.saturating_add(1) { last.1 = last.1.max(*max); continue; } out.push((*min, *max)); } out } fn as_interval_is_covered(parent: &[(u32, u32)], min: u32, max: u32) -> bool { for (pmin, pmax) in parent { if *pmin <= min && max <= *pmax { return true; } if *pmin > min { break; } } false } fn ip_resources_is_subset( child: &crate::data_model::rc::IpResourceSet, parent: &crate::data_model::rc::IpResourceSet, ) -> bool { let parent_by_afi = ip_resources_to_merged_intervals(parent); let child_by_afi = match ip_resources_to_merged_intervals_strict(child) { Ok(v) => v, Err(()) => return false, }; for (afi, child_intervals) in child_by_afi { let Some(parent_intervals) = parent_by_afi.get(&afi) else { return false; }; for (cmin, cmax) in &child_intervals { if !interval_is_covered(parent_intervals, cmin, cmax) { return false; } } } true } fn ip_resources_is_subset_indexed( child: &crate::data_model::rc::IpResourceSet, parent: &crate::data_model::rc::IpResourceSet, idx: &IssuerResourcesIndex, ) -> bool { let _ = parent; for fam in &child.families { let parent_intervals = match fam.afi { crate::data_model::rc::Afi::Ipv4 => idx.ip_v4.as_deref(), crate::data_model::rc::Afi::Ipv6 => idx.ip_v6.as_deref(), }; let Some(parent_intervals) = parent_intervals else { return false; }; let items = match &fam.choice { IpAddressChoice::Inherit => return false, IpAddressChoice::AddressesOrRanges(items) => items, }; let mut child_intervals: Vec<(Vec, Vec)> = Vec::new(); for item in items { match item { IpAddressOrRange::Prefix(p) => child_intervals.push(prefix_to_range(p)), IpAddressOrRange::Range(r) => child_intervals.push((r.min.clone(), r.max.clone())), } } if child_intervals.is_empty() { continue; } child_intervals.sort_by(|(a, _), (b, _)| a.cmp(b)); merge_ip_intervals_in_place(&mut child_intervals); if !intervals_are_covered(parent_intervals, &child_intervals) { return false; } } true } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] enum AfiKey { V4, V6, } fn ip_resources_to_merged_intervals( set: &crate::data_model::rc::IpResourceSet, ) -> std::collections::HashMap, Vec)>> { let mut m: std::collections::HashMap, Vec)>> = std::collections::HashMap::new(); for fam in &set.families { let afi = match fam.afi { crate::data_model::rc::Afi::Ipv4 => AfiKey::V4, crate::data_model::rc::Afi::Ipv6 => AfiKey::V6, }; match &fam.choice { IpAddressChoice::Inherit => { // Effective resource sets should not contain inherit, but if they do we treat it // as "unknown" by leaving it empty here (subset checks will fail). } IpAddressChoice::AddressesOrRanges(items) => { let ent = m.entry(afi).or_default(); for item in items { match item { IpAddressOrRange::Prefix(p) => ent.push(prefix_to_range(p)), IpAddressOrRange::Range(r) => ent.push((r.min.clone(), r.max.clone())), } } } } } for (_afi, v) in m.iter_mut() { v.sort_by(|(a, _), (b, _)| a.cmp(b)); merge_ip_intervals_in_place(v); } m } fn ip_resources_to_merged_intervals_strict( set: &crate::data_model::rc::IpResourceSet, ) -> Result, Vec)>>, ()> { let mut m: std::collections::HashMap, Vec)>> = std::collections::HashMap::new(); for fam in &set.families { let afi = match fam.afi { crate::data_model::rc::Afi::Ipv4 => AfiKey::V4, crate::data_model::rc::Afi::Ipv6 => AfiKey::V6, }; match &fam.choice { IpAddressChoice::Inherit => return Err(()), IpAddressChoice::AddressesOrRanges(items) => { let ent = m.entry(afi).or_default(); for item in items { match item { IpAddressOrRange::Prefix(p) => ent.push(prefix_to_range(p)), IpAddressOrRange::Range(r) => ent.push((r.min.clone(), r.max.clone())), } } } } } for (_afi, v) in m.iter_mut() { v.sort_by(|(a, _), (b, _)| a.cmp(b)); merge_ip_intervals_in_place(v); } Ok(m) } fn merge_ip_intervals_in_place(v: &mut Vec<(Vec, Vec)>) { if v.is_empty() { return; } let mut out: Vec<(Vec, Vec)> = Vec::with_capacity(v.len()); for (min, max) in v.drain(..) { let Some(last) = out.last_mut() else { out.push((min, max)); continue; }; if bytes_leq(&min, &last.1) || bytes_is_next(&min, &last.1) { if bytes_leq(&last.1, &max) { last.1 = max; } continue; } out.push((min, max)); } *v = out; } fn interval_is_covered(parent: &[(Vec, Vec)], min: &[u8], max: &[u8]) -> bool { for (pmin, pmax) in parent { if bytes_leq(pmin, min) && bytes_leq(max, pmax) { return true; } if pmin.as_slice() > min { break; } } false } fn intervals_are_covered(parent: &[(Vec, Vec)], child: &[(Vec, Vec)]) -> bool { let mut i = 0usize; for (cmin, cmax) in child { while i < parent.len() && parent[i].1.as_slice() < cmin.as_slice() { i += 1; } if i >= parent.len() { return false; } let (pmin, pmax) = &parent[i]; if !bytes_leq(pmin, cmin) || !bytes_leq(cmax, pmax) { return false; } } true } fn prefix_to_range(prefix: &RcIpPrefix) -> (Vec, Vec) { let mut min = prefix.addr.clone(); let mut max = prefix.addr.clone(); let bitlen = prefix.afi.ub(); let plen = prefix.prefix_len.min(bitlen); for bit in plen..bitlen { let byte = (bit / 8) as usize; let offset = 7 - (bit % 8); let mask = 1u8 << offset; min[byte] &= !mask; max[byte] |= mask; } (min, max) } fn bytes_leq(a: &[u8], b: &[u8]) -> bool { a <= b } fn increment_bytes(v: &[u8]) -> Vec { let mut out = v.to_vec(); for i in (0..out.len()).rev() { if out[i] != 0xFF { out[i] += 1; for j in i + 1..out.len() { out[j] = 0; } return out; } } vec![0u8; out.len()] } fn bytes_is_next(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } let mut carry: u16 = 1; for i in (0..b.len()).rev() { let sum = (b[i] as u16) + carry; let expected = (sum & 0xFF) as u8; carry = sum >> 8; if a[i] != expected { return false; } } true } fn build_issuer_resources_index( issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, ) -> IssuerResourcesIndex { let mut idx = IssuerResourcesIndex::default(); if let Some(ip) = issuer_effective_ip { let mut v4: Vec<(Vec, Vec)> = Vec::new(); let mut v6: Vec<(Vec, Vec)> = Vec::new(); for fam in &ip.families { let ent = match fam.afi { crate::data_model::rc::Afi::Ipv4 => &mut v4, crate::data_model::rc::Afi::Ipv6 => &mut v6, }; match &fam.choice { IpAddressChoice::Inherit => { // Effective resources should not contain inherit; leave empty so subset fails. } IpAddressChoice::AddressesOrRanges(items) => { for item in items { match item { IpAddressOrRange::Prefix(p) => ent.push(prefix_to_range(p)), IpAddressOrRange::Range(r) => ent.push((r.min.clone(), r.max.clone())), } } } } } if !v4.is_empty() { v4.sort_by(|(a, _), (b, _)| a.cmp(b)); merge_ip_intervals_in_place(&mut v4); idx.ip_v4 = Some(v4); } if !v6.is_empty() { v6.sort_by(|(a, _), (b, _)| a.cmp(b)); merge_ip_intervals_in_place(&mut v6); idx.ip_v6 = Some(v6); } } if let Some(asr) = issuer_effective_as { if let Some(choice) = asr.asnum.as_ref() { if !matches!(choice, AsIdentifierChoice::Inherit) { idx.asnum = Some(as_choice_to_merged_intervals(choice)); } } if let Some(choice) = asr.rdi.as_ref() { if !matches!(choice, AsIdentifierChoice::Inherit) { idx.rdi = Some(as_choice_to_merged_intervals(choice)); } } } idx } fn roa_to_vrps(roa: &RoaObject) -> Vec { let asn = roa.roa.as_id; let mut out = Vec::new(); for fam in &roa.roa.ip_addr_blocks { for entry in &fam.addresses { let max_length = entry.max_length.unwrap_or(entry.prefix.prefix_len); out.push(Vrp { asn, prefix: entry.prefix.clone(), max_length, }); } } out } #[allow(dead_code)] fn roa_afi_to_string(afi: RoaAfi) -> &'static str { match afi { RoaAfi::Ipv4 => "ipv4", RoaAfi::Ipv6 => "ipv6", } } #[cfg(test)] mod tests { use super::*; use crate::analysis::timing::{TimingHandle, TimingMeta}; use crate::data_model::rc::{ Afi, AsIdOrRange, AsIdentifierChoice, IpAddressFamily, IpAddressOrRange, IpAddressRange, IpPrefix, IpResourceSet, }; use crate::policy::Policy; use crate::storage::{ PackTime, RoaCacheProjection, ValidatedManifestMeta, VcirAuditSummary, VcirCcrManifestProjection, VcirInstanceGate, VcirRelatedArtifact, VcirSummary, }; use crate::validation::publication_point::PublicationPointSnapshot; use std::collections::HashMap; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; fn fixture_bytes(path: &str) -> Vec { std::fs::read(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path)) .unwrap_or_else(|e| panic!("read fixture {path}: {e}")) } fn fixed_time(value: &str) -> OffsetDateTime { OffsetDateTime::parse(value, &Rfc3339).expect("parse fixed test time") } fn sample_roa_cache_vcir( issuer_der: &[u8], crl_hash: [u8; 32], roa_hash: [u8; 32], item_effective_until: OffsetDateTime, instance_effective_until: OffsetDateTime, ) -> ValidatedCaInstanceResult { let manifest_time = PackTime::from_utc_offset_datetime(fixed_time("2026-06-04T00:00:00Z")); let effective_until = PackTime::from_utc_offset_datetime(instance_effective_until); ValidatedCaInstanceResult { manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), parent_manifest_rsync_uri: Some("rsync://example.test/repo/parent.mft".to_string()), tal_id: "test-tal".to_string(), ca_subject_name: "CN=example".to_string(), ca_ski: "001122".to_string(), issuer_ski: "334455".to_string(), last_successful_validation_time: manifest_time.clone(), current_manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), current_crl_rsync_uri: "rsync://example.test/repo/current.crl".to_string(), validated_manifest_meta: ValidatedManifestMeta { validated_manifest_number: vec![1], validated_manifest_this_update: manifest_time.clone(), validated_manifest_next_update: effective_until.clone(), }, ccr_manifest_projection: VcirCcrManifestProjection { manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), manifest_sha256: vec![0xaa; 32], manifest_size: 2048, manifest_ee_aki: vec![0xbb; 20], manifest_number_be: vec![1], manifest_this_update: manifest_time.clone(), manifest_sia_locations_der: vec![vec![0x30, 0x00]], subordinate_skis: Vec::new(), }, instance_gate: VcirInstanceGate { manifest_next_update: effective_until.clone(), current_crl_next_update: effective_until.clone(), self_ca_not_after: effective_until.clone(), instance_effective_until: effective_until, }, child_entries: Vec::new(), local_outputs: vec![VcirLocalOutput { output_type: VcirOutputType::Vrp, item_effective_until: PackTime::from_utc_offset_datetime(item_effective_until), source_object_uri: "rsync://example.test/repo/a.roa".to_string(), source_object_type: VcirSourceObjectType::Roa, source_object_hash: roa_hash, source_ee_cert_hash: [0xcc; 32], payload: VcirLocalOutputPayload::Vrp { asn: 64500, afi: RoaAfi::Ipv4, prefix_len: 24, addr: [192, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], max_length: 24, }, rule_hash: [0xdd; 32], }], related_artifacts: vec![ VcirRelatedArtifact { artifact_role: VcirArtifactRole::IssuerCert, artifact_kind: VcirArtifactKind::Cer, uri: Some("rsync://example.test/repo/ca.cer".to_string()), sha256: sha256_hex(issuer_der), object_type: Some("cer".to_string()), validation_status: VcirArtifactValidationStatus::Accepted, }, VcirRelatedArtifact { artifact_role: VcirArtifactRole::CurrentCrl, artifact_kind: VcirArtifactKind::Crl, uri: Some("rsync://example.test/repo/current.crl".to_string()), sha256: sha256_hex_from_32(&crl_hash), object_type: Some("crl".to_string()), validation_status: VcirArtifactValidationStatus::Accepted, }, ], summary: VcirSummary { local_vrp_count: 1, local_aspa_count: 0, local_router_key_count: 0, child_count: 0, accepted_object_count: 2, rejected_object_count: 0, }, audit_summary: VcirAuditSummary { failed_fetch_eligible: true, last_failed_fetch_reason: None, warning_count: 0, audit_flags: Vec::new(), }, } } #[test] fn roa_validation_cache_view_hits_when_context_and_hash_match() { let validation_time = fixed_time("2026-06-05T00:00:00Z"); let issuer_der = b"issuer-ca"; let crl_hash = [0x22; 32]; let roa_hash = [0x11; 32]; let vcir = sample_roa_cache_vcir( issuer_der, crl_hash, roa_hash, fixed_time("2026-06-07T00:00:00Z"), fixed_time("2026-06-08T00:00:00Z"), ); let view = RoaValidationCacheView::from_vcir(&vcir, validation_time); let files = vec![ PackFile::from_bytes_with_sha256( "rsync://example.test/repo/a.roa", vec![0x01], roa_hash, ), PackFile::from_bytes_with_sha256( "rsync://example.test/repo/current.crl", vec![0x02], crl_hash, ), ]; assert!(view.matches_current_context(issuer_der, &files)); let hit = view.lookup(&files[0], validation_time); let RoaCacheLookupResult::Hit(ok) = hit else { panic!("expected cache hit, got {hit:?}"); }; assert!(ok.reused_from_cache); assert_eq!(ok.vrps.len(), 1); assert_eq!(ok.vrps[0].asn, 64500); assert_eq!(ok.local_outputs.len(), 1); } #[test] fn roa_validation_cache_view_from_projection_matches_vcir_view() { let validation_time = fixed_time("2026-06-05T00:00:00Z"); let issuer_der = b"issuer-ca"; let crl_hash = [0x22; 32]; let roa_hash = [0x11; 32]; let vcir = sample_roa_cache_vcir( issuer_der, crl_hash, roa_hash, fixed_time("2026-06-07T00:00:00Z"), fixed_time("2026-06-08T00:00:00Z"), ); let projection = RoaCacheProjection::from_vcir(&vcir) .expect("build projection") .expect("projection exists"); let view = RoaValidationCacheView::from_projection(&projection, validation_time); let files = vec![ PackFile::from_bytes_with_sha256( "rsync://example.test/repo/a.roa", vec![0x01], roa_hash, ), PackFile::from_bytes_with_sha256( "rsync://example.test/repo/current.crl", vec![0x02], crl_hash, ), ]; assert!(view.matches_current_context(issuer_der, &files)); let hit = view.lookup(&files[0], validation_time); let RoaCacheLookupResult::Hit(ok) = hit else { panic!("expected projection cache hit, got {hit:?}"); }; assert!(ok.reused_from_cache); assert_eq!(ok.vrps.len(), 1); assert_eq!(ok.vrps[0].asn, 64500); assert_eq!(ok.local_outputs.len(), 1); assert_eq!( ok.local_outputs[0].source_object_uri, "rsync://example.test/repo/a.roa" ); } #[test] fn roa_validation_cache_view_from_projection_blocks_on_gates() { let validation_time = fixed_time("2026-06-05T00:00:00Z"); let issuer_der = b"issuer-ca"; let crl_hash = [0x22; 32]; let roa_hash = [0x11; 32]; let vcir = sample_roa_cache_vcir( issuer_der, crl_hash, roa_hash, fixed_time("2026-06-07T00:00:00Z"), fixed_time("2026-06-08T00:00:00Z"), ); let mut projection = RoaCacheProjection::from_vcir(&vcir) .expect("build projection") .expect("projection exists"); let files = vec![ PackFile::from_bytes_with_sha256( "rsync://example.test/repo/a.roa", vec![0x01], roa_hash, ), PackFile::from_bytes_with_sha256( "rsync://example.test/repo/current.crl", vec![0x02], crl_hash, ), ]; projection.issuer_ca_sha256_hex = Some("00".repeat(32)); let issuer_changed = RoaValidationCacheView::from_projection(&projection, validation_time); assert!(!issuer_changed.matches_current_context(issuer_der, &files)); let mut projection = RoaCacheProjection::from_vcir(&vcir) .expect("build projection") .expect("projection exists"); projection.crl_sha256_by_uri[0].sha256 = "11".repeat(32); let crl_changed = RoaValidationCacheView::from_projection(&projection, validation_time); assert!(!crl_changed.matches_current_context(issuer_der, &files)); let mut projection = RoaCacheProjection::from_vcir(&vcir) .expect("build projection") .expect("projection exists"); projection.entries[0].source_object_hash = [0xff; 32]; let roa_changed = RoaValidationCacheView::from_projection(&projection, validation_time); assert!(matches!( roa_changed.lookup(&files[0], validation_time), RoaCacheLookupResult::Blocked )); let expired_vcir = sample_roa_cache_vcir( issuer_der, crl_hash, roa_hash, fixed_time("2026-06-07T00:00:00Z"), fixed_time("2026-06-04T00:00:00Z"), ); let expired_projection = RoaCacheProjection::from_vcir(&expired_vcir) .expect("build projection") .expect("projection exists"); let expired_view = RoaValidationCacheView::from_projection(&expired_projection, validation_time); assert!(!expired_view.matches_current_context(issuer_der, &files)); assert!(matches!( expired_view.lookup(&files[0], validation_time), RoaCacheLookupResult::Blocked )); } #[test] fn roa_validation_cache_view_blocks_when_context_changes() { let validation_time = fixed_time("2026-06-05T00:00:00Z"); let issuer_der = b"issuer-ca"; let crl_hash = [0x22; 32]; let roa_hash = [0x11; 32]; let vcir = sample_roa_cache_vcir( issuer_der, crl_hash, roa_hash, fixed_time("2026-06-07T00:00:00Z"), fixed_time("2026-06-08T00:00:00Z"), ); let view = RoaValidationCacheView::from_vcir(&vcir, validation_time); let files = vec![ PackFile::from_bytes_with_sha256( "rsync://example.test/repo/a.roa", vec![0x01], roa_hash, ), PackFile::from_bytes_with_sha256( "rsync://example.test/repo/current.crl", vec![0x02], [0x33; 32], ), ]; let mut stats = RoaValidationCacheStats::for_input(RoaValidationCacheInput::enabled(Some(&view)), 1); assert!(!view.matches_current_context(issuer_der, &files)); assert!( active_roa_cache_view( RoaValidationCacheInput::enabled(Some(&view)), issuer_der, &files, &mut stats, 1, ) .is_none() ); assert_eq!(stats.blocked_roas, 1); assert_eq!(stats.fresh_roas, 1); } #[test] fn roa_validation_cache_view_blocks_expired_output() { let validation_time = fixed_time("2026-06-05T00:00:00Z"); let issuer_der = b"issuer-ca"; let crl_hash = [0x22; 32]; let roa_hash = [0x11; 32]; let vcir = sample_roa_cache_vcir( issuer_der, crl_hash, roa_hash, fixed_time("2026-06-04T00:00:00Z"), fixed_time("2026-06-08T00:00:00Z"), ); let view = RoaValidationCacheView::from_vcir(&vcir, validation_time); let file = PackFile::from_bytes_with_sha256( "rsync://example.test/repo/a.roa", vec![0x01], roa_hash, ); assert!(matches!( view.lookup(&file, validation_time), RoaCacheLookupResult::Blocked )); } #[test] fn roa_validation_cache_stats_records_vcir_miss_to_timing() { let stats = RoaValidationCacheStats::for_input(RoaValidationCacheInput::enabled(None), 3); assert_eq!(stats.enabled_publication_points, 1); assert_eq!(stats.vcir_miss_publication_points, 1); assert_eq!(stats.miss_roas, 3); assert_eq!(stats.fresh_roas, 3); let timing = TimingHandle::new(TimingMeta { recorded_at_utc_rfc3339: "2026-06-05T00:00:00Z".to_string(), validation_time_utc_rfc3339: "2026-06-05T00:00:00Z".to_string(), tal_url: None, db_path: None, }); stats.record_to_timing(Some(&timing)); let dir = tempfile::tempdir().expect("timing dir"); let path = dir.path().join("timing.json"); timing.write_json(&path, 10).expect("write timing"); let report: serde_json::Value = serde_json::from_slice(&std::fs::read(path).expect("read timing")) .expect("parse timing"); assert_eq!( report["counts"]["roa_validation_cache_enabled_publication_points"], 1 ); assert_eq!( report["counts"]["roa_validation_cache_vcir_miss_publication_points"], 1 ); assert_eq!(report["counts"]["roa_validation_cache_miss_roas"], 3); assert_eq!(report["counts"]["roa_validation_cache_fresh_roas"], 3); } #[test] fn roa_validation_cache_view_blocks_expired_instance_gate() { let validation_time = fixed_time("2026-06-05T00:00:00Z"); let issuer_der = b"issuer-ca"; let crl_hash = [0x22; 32]; let roa_hash = [0x11; 32]; let vcir = sample_roa_cache_vcir( issuer_der, crl_hash, roa_hash, fixed_time("2026-06-07T00:00:00Z"), fixed_time("2026-06-04T00:00:00Z"), ); let view = RoaValidationCacheView::from_vcir(&vcir, validation_time); let file = PackFile::from_bytes_with_sha256( "rsync://example.test/repo/a.roa", vec![0x01], roa_hash, ); assert!(!view.matches_current_context(issuer_der, &[])); assert!(matches!( view.lookup(&file, validation_time), RoaCacheLookupResult::Blocked )); } #[test] fn roa_validation_cache_view_ignores_rejected_artifacts_and_non_roa_outputs() { let validation_time = fixed_time("2026-06-05T00:00:00Z"); let issuer_der = b"issuer-ca"; let crl_hash = [0x22; 32]; let roa_hash = [0x11; 32]; let mut vcir = sample_roa_cache_vcir( issuer_der, crl_hash, roa_hash, fixed_time("2026-06-07T00:00:00Z"), fixed_time("2026-06-08T00:00:00Z"), ); vcir.related_artifacts.insert( 0, VcirRelatedArtifact { artifact_role: VcirArtifactRole::IssuerCert, artifact_kind: VcirArtifactKind::Cer, uri: Some("rsync://example.test/repo/rejected.cer".to_string()), sha256: "00".repeat(32), object_type: Some("cer".to_string()), validation_status: VcirArtifactValidationStatus::Rejected, }, ); vcir.local_outputs.push(VcirLocalOutput { output_type: VcirOutputType::Aspa, item_effective_until: PackTime::from_utc_offset_datetime(fixed_time( "2026-06-07T00:00:00Z", )), source_object_uri: "rsync://example.test/repo/a.asa".to_string(), source_object_type: VcirSourceObjectType::Aspa, source_object_hash: [0x44; 32], source_ee_cert_hash: [0x55; 32], payload: VcirLocalOutputPayload::Aspa { customer_as_id: 64500, provider_as_ids: vec![64501], }, rule_hash: [0x66; 32], }); let view = RoaValidationCacheView::from_vcir(&vcir, validation_time); let files = vec![ PackFile::from_bytes_with_sha256( "rsync://example.test/repo/a.roa", vec![0x01], roa_hash, ), PackFile::from_bytes_with_sha256( "rsync://example.test/repo/current.crl", vec![0x02], crl_hash, ), ]; assert!(view.matches_current_context(issuer_der, &files)); assert!(matches!( view.lookup(&files[0], validation_time), RoaCacheLookupResult::Hit(_) )); assert!(matches!( view.lookup( &PackFile::from_bytes_with_sha256( "rsync://example.test/repo/a.asa", vec![0x03], [0x44; 32], ), validation_time ), RoaCacheLookupResult::Miss )); } #[test] fn roa_validation_cache_context_rejects_missing_and_changed_issuer() { let mut view = RoaValidationCacheView { entries_by_uri: HashMap::new(), issuer_ca_sha256_hex: None, crl_sha256_by_uri: HashMap::new(), blocked: false, }; assert!(!view.matches_current_context(b"issuer-ca", &[])); view.issuer_ca_sha256_hex = Some("00".repeat(32)); assert!(!view.matches_current_context(b"issuer-ca", &[])); view.issuer_ca_sha256_hex = Some(sha256_hex(b"issuer-ca")); assert!(view.matches_current_context(b"issuer-ca", &[])); } #[test] fn roa_validation_cache_lookup_classifies_hash_empty_payload_and_missing_uri() { let validation_time = fixed_time("2026-06-05T00:00:00Z"); let roa_hash = [0x11; 32]; let file = PackFile::from_bytes_with_sha256( "rsync://example.test/repo/a.roa", vec![0x01], roa_hash, ); let mut output = sample_roa_cache_vcir( b"issuer-ca", [0x22; 32], roa_hash, fixed_time("2026-06-07T00:00:00Z"), fixed_time("2026-06-08T00:00:00Z"), ) .local_outputs .remove(0); let mut view = RoaValidationCacheView { entries_by_uri: HashMap::new(), issuer_ca_sha256_hex: Some(sha256_hex(b"issuer-ca")), crl_sha256_by_uri: HashMap::new(), blocked: false, }; assert!(matches!( view.lookup(&file, validation_time), RoaCacheLookupResult::Miss )); view.entries_by_uri.insert( file.rsync_uri.clone(), CachedRoaValidationResult { source_object_hash: [0xff; 32], outputs: vec![output.clone()], }, ); assert!(matches!( view.lookup(&file, validation_time), RoaCacheLookupResult::Blocked )); view.entries_by_uri.insert( file.rsync_uri.clone(), CachedRoaValidationResult { source_object_hash: roa_hash, outputs: Vec::new(), }, ); assert!(matches!( view.lookup(&file, validation_time), RoaCacheLookupResult::Miss )); output.payload = VcirLocalOutputPayload::Aspa { customer_as_id: 64500, provider_as_ids: vec![64501], }; view.entries_by_uri.insert( file.rsync_uri.clone(), CachedRoaValidationResult { source_object_hash: roa_hash, outputs: vec![output], }, ); assert!(matches!( view.lookup(&file, validation_time), RoaCacheLookupResult::Blocked )); } #[test] fn parallel_roa_cache_blocked_falls_back_to_single_fresh_task() { let manifest_bytes = fixture_bytes( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ); let issuer_ca_der = fixture_bytes( "tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer", ); let crl_hash = [0x22; 32]; let actual_roa_hash = [0x11; 32]; let cached_roa_hash = [0x33; 32]; let validation_time = fixed_time("2026-06-05T00:00:00Z"); let publication_point = PublicationPointSnapshot { format_version: PublicationPointSnapshot::FORMAT_VERSION_V1, manifest_rsync_uri: "rsync://rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft" .to_string(), publication_point_rsync_uri: "rsync://rpki.cernet.net/repo/cernet/0/".to_string(), manifest_number_be: vec![1], this_update: PackTime::from_utc_offset_datetime(validation_time), next_update: PackTime::from_utc_offset_datetime( validation_time + time::Duration::days(1), ), verified_at: PackTime::from_utc_offset_datetime(validation_time), manifest_bytes, files: vec![ PackFile::from_bytes_with_sha256( "rsync://example.test/repo/current.crl", vec![0x02], crl_hash, ), PackFile::from_bytes_with_sha256( "rsync://example.test/repo/a.roa", vec![0x01], actual_roa_hash, ), ], }; let vcir = sample_roa_cache_vcir( &issuer_ca_der, crl_hash, cached_roa_hash, fixed_time("2026-06-07T00:00:00Z"), fixed_time("2026-06-08T00:00:00Z"), ); let view = RoaValidationCacheView::from_vcir(&vcir, validation_time); let stage = match prepare_publication_point_for_parallel_roa_with_cache( 7, &publication_point, &Policy::default(), &issuer_ca_der, None, None, None, validation_time, false, RoaValidationCacheInput::enabled(Some(&view)), ) { ParallelObjectsPrepare::Staged(stage) => stage, ParallelObjectsPrepare::Complete(_) => panic!("expected staged ROA fallback"), }; assert_eq!(stage.roa_cache_stats.blocked_roas, 1); assert_eq!(stage.roa_cache_stats.fresh_roas, 1); assert_eq!(stage.roa_task_indices, vec![1]); assert_eq!(stage.roa_task_count(), 1); } #[test] fn merge_as_intervals_merges_overlapping_and_adjacent() { let v = vec![(1, 2), (3, 5), (10, 10), (11, 12)]; let merged = merge_as_intervals(&v); assert_eq!(merged, vec![(1, 5), (10, 12)]); } #[test] fn as_choice_subset_rejects_inherit() { let child = Some(&AsIdentifierChoice::Inherit); let parent = Some(&AsIdentifierChoice::AsIdsOrRanges(vec![ AsIdOrRange::Range { min: 1, max: 10 }, ])); assert!(!as_choice_subset(child, parent)); } #[test] fn as_choice_subset_checks_ranges() { let child = Some(&AsIdentifierChoice::AsIdsOrRanges(vec![ AsIdOrRange::Id(5), AsIdOrRange::Range { min: 7, max: 9 }, ])); let parent = Some(&AsIdentifierChoice::AsIdsOrRanges(vec![ AsIdOrRange::Range { min: 1, max: 10 }, ])); assert!(as_choice_subset(child, parent)); } #[test] fn ip_resources_is_subset_accepts_prefixes_and_ranges() { let parent = IpResourceSet { families: vec![IpAddressFamily { afi: Afi::Ipv4, choice: IpAddressChoice::AddressesOrRanges(vec![ IpAddressOrRange::Prefix(IpPrefix { afi: Afi::Ipv4, prefix_len: 8, addr: vec![10, 0, 0, 0], }), IpAddressOrRange::Range(IpAddressRange { min: vec![192, 0, 2, 0], max: vec![192, 0, 2, 255], }), ]), }], }; let child = IpResourceSet { families: vec![IpAddressFamily { afi: Afi::Ipv4, choice: IpAddressChoice::AddressesOrRanges(vec![ IpAddressOrRange::Prefix(IpPrefix { afi: Afi::Ipv4, prefix_len: 16, addr: vec![10, 1, 0, 0], }), IpAddressOrRange::Range(IpAddressRange { min: vec![192, 0, 2, 10], max: vec![192, 0, 2, 20], }), ]), }], }; assert!(ip_resources_is_subset(&child, &parent)); } #[test] fn ip_resources_is_subset_rejects_inherit_in_child() { let parent = IpResourceSet { families: vec![IpAddressFamily { afi: Afi::Ipv6, choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix( IpPrefix { afi: Afi::Ipv6, prefix_len: 32, addr: vec![0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], }, )]), }], }; let child = IpResourceSet { families: vec![IpAddressFamily { afi: Afi::Ipv6, choice: IpAddressChoice::Inherit, }], }; assert!(!ip_resources_is_subset(&child, &parent)); } #[test] fn increment_bytes_wraps_all_ff_to_zero() { assert_eq!(increment_bytes(&[0xFF, 0xFF]), vec![0x00, 0x00]); } #[test] fn merge_ip_intervals_merges_contiguous() { let mut v = vec![ (vec![0, 0, 0, 0], vec![0, 0, 0, 10]), (vec![0, 0, 0, 11], vec![0, 0, 0, 20]), ]; merge_ip_intervals_in_place(&mut v); assert_eq!(v, vec![(vec![0, 0, 0, 0], vec![0, 0, 0, 20])]); } #[test] fn choose_crl_for_certificate_reports_missing_crl_in_snapshot() { let roa_der = fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa"); let roa = RoaObject::decode_der(&roa_der).expect("decode roa"); let ee_crldp_uris = roa.signed_object.signed_data.certificates[0] .resource_cert .tbs .extensions .crl_distribution_points_uris .as_ref(); let crl_cache: HashMap = HashMap::new(); let err = choose_crl_uri_for_certificate(ee_crldp_uris, &crl_cache).unwrap_err(); assert!(matches!(err, ObjectValidateError::MissingCrlInPack)); } #[test] fn choose_crl_for_certificate_reports_missing_crldp_uris() { let mut crl_cache: HashMap = HashMap::new(); crl_cache.insert( "rsync://example.test/a.crl".to_string(), CachedIssuerCrl::Pending(vec![0x01]), ); let err = choose_crl_uri_for_certificate(None, &crl_cache).unwrap_err(); assert!(matches!(err, ObjectValidateError::MissingCrlDpUris)); } #[test] fn choose_crl_for_certificate_prefers_matching_crldp_uri_in_order() { let roa_der = fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa"); let roa = RoaObject::decode_der(&roa_der).expect("decode roa"); let ee_crldp_uris = roa.signed_object.signed_data.certificates[0] .resource_cert .tbs .extensions .crl_distribution_points_uris .as_ref() .expect("fixture ee has crldp"); let matching_uri = ee_crldp_uris[0].as_str().to_string(); let mut crl_cache: HashMap = HashMap::new(); crl_cache.insert( "rsync://example.test/other.crl".to_string(), CachedIssuerCrl::Pending(vec![0x00]), ); crl_cache.insert(matching_uri.clone(), CachedIssuerCrl::Pending(vec![0x01])); let uri = choose_crl_uri_for_certificate(Some(ee_crldp_uris), &crl_cache).unwrap(); assert_eq!(uri, matching_uri); } #[test] fn choose_crl_for_certificate_reports_not_found_when_crldp_does_not_match_snapshot() { let roa_der = fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa"); let roa = RoaObject::decode_der(&roa_der).expect("decode roa"); let ee_crldp_uris = roa.signed_object.signed_data.certificates[0] .resource_cert .tbs .extensions .crl_distribution_points_uris .as_ref(); let mut crl_cache: HashMap = HashMap::new(); crl_cache.insert( "rsync://example.test/other.crl".to_string(), CachedIssuerCrl::Pending(vec![0x01]), ); let err = choose_crl_uri_for_certificate(ee_crldp_uris, &crl_cache).unwrap_err(); assert!(matches!(err, ObjectValidateError::CrlNotFound(_))); } #[test] fn validate_ee_resources_subset_reports_missing_issuer_effective_ip() { let roa_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa"); let roa_der = std::fs::read(roa_path).expect("read roa"); let roa = RoaObject::decode_der(&roa_der).expect("decode roa"); let ee = &roa.signed_object.signed_data.certificates[0].resource_cert; let idx = IssuerResourcesIndex::default(); let err = validate_ee_resources_subset(ee, None, None, &idx).unwrap_err(); assert!(matches!(err, ObjectValidateError::MissingIssuerEffectiveIp)); } #[test] fn validate_ee_resources_subset_reports_missing_issuer_effective_as() { let aspa_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( "tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa", ); let aspa_der = std::fs::read(aspa_path).expect("read aspa"); let aspa = AspaObject::decode_der(&aspa_der).expect("decode aspa"); let ee = &aspa.signed_object.signed_data.certificates[0].resource_cert; let issuer_ip = IpResourceSet { families: vec![IpAddressFamily { afi: Afi::Ipv6, choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix( IpPrefix { afi: Afi::Ipv6, prefix_len: 32, addr: vec![0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], }, )]), }], }; let idx = build_issuer_resources_index(Some(&issuer_ip), None); let err = validate_ee_resources_subset(ee, Some(&issuer_ip), None, &idx).unwrap_err(); assert!(matches!(err, ObjectValidateError::MissingIssuerEffectiveAs)); } #[test] fn validate_ee_resources_subset_reports_not_subset() { let roa_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa"); let roa_der = std::fs::read(roa_path).expect("read roa"); let roa = RoaObject::decode_der(&roa_der).expect("decode roa"); let ee = &roa.signed_object.signed_data.certificates[0].resource_cert; // Unrelated parent resources. let issuer_ip = IpResourceSet { families: vec![IpAddressFamily { afi: Afi::Ipv6, choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix( IpPrefix { afi: Afi::Ipv6, prefix_len: 32, addr: vec![0x26, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], }, )]), }], }; let idx = build_issuer_resources_index(Some(&issuer_ip), None); let err = validate_ee_resources_subset(ee, Some(&issuer_ip), None, &idx).unwrap_err(); assert!(matches!(err, ObjectValidateError::EeResourcesNotSubset)); } #[test] fn extra_rfc_refs_for_crl_selection_distinguishes_crl_errors() { assert_eq!( extra_rfc_refs_for_crl_selection(&ObjectValidateError::MissingCrlDpUris), RFC_CRLDP ); assert_eq!( extra_rfc_refs_for_crl_selection(&ObjectValidateError::CrlNotFound( "rsync://example.test/x.crl".to_string(), )), RFC_CRLDP_AND_LOCKED_PACK ); assert!( extra_rfc_refs_for_crl_selection(&ObjectValidateError::MissingCrlInPack).is_empty() ); } #[test] fn as_subset_helpers_cover_success_and_failure_paths() { let child = AsIdentifierChoice::AsIdsOrRanges(vec![ AsIdOrRange::Id(5), AsIdOrRange::Range { min: 7, max: 9 }, ]); let parent = AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Range { min: 1, max: 10 }]); let parent_intervals = [(1, 10)]; assert!(as_choice_subset(None, Some(&parent))); assert!(!as_choice_subset(Some(&child), None)); assert!(!as_choice_subset( Some(&AsIdentifierChoice::Inherit), Some(&parent) )); assert!(!as_choice_subset( Some(&child), Some(&AsIdentifierChoice::Inherit) )); assert!(as_choice_subset(Some(&child), Some(&parent))); assert!(as_choice_subset_indexed(None, Some(&parent_intervals))); assert!(!as_choice_subset_indexed(Some(&child), None)); assert!(!as_choice_subset_indexed( Some(&AsIdentifierChoice::Inherit), Some(&parent_intervals), )); assert!(as_choice_subset_indexed( Some(&child), Some(&parent_intervals) )); assert!(!as_choice_subset_indexed( Some(&AsIdentifierChoice::AsIdsOrRanges(vec![ AsIdOrRange::Range { min: 11, max: 12 } ])), Some(&parent_intervals), )); let child_set = AsResourceSet { asnum: Some(child.clone()), rdi: Some(AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Id(42)])), }; let parent_set = AsResourceSet { asnum: Some(parent.clone()), rdi: Some(AsIdentifierChoice::AsIdsOrRanges(vec![ AsIdOrRange::Range { min: 40, max: 50 }, ])), }; assert!(as_resources_is_subset(&child_set, &parent_set)); assert!(as_resources_is_subset_indexed( &child_set, &parent_set, &IssuerResourcesIndex { asnum: Some(vec![(1, 10)]), rdi: Some(vec![(40, 50)]), ..IssuerResourcesIndex::default() }, )); } #[test] fn ip_subset_helpers_cover_strict_and_indexed_paths() { let parent = IpResourceSet { families: vec![ IpAddressFamily { afi: Afi::Ipv4, choice: IpAddressChoice::AddressesOrRanges(vec![ IpAddressOrRange::Prefix(IpPrefix { afi: Afi::Ipv4, prefix_len: 8, addr: vec![10, 0, 0, 0], }), IpAddressOrRange::Range(IpAddressRange { min: vec![192, 0, 2, 0], max: vec![192, 0, 2, 255], }), ]), }, IpAddressFamily { afi: Afi::Ipv6, choice: IpAddressChoice::Inherit, }, ], }; let child = IpResourceSet { families: vec![IpAddressFamily { afi: Afi::Ipv4, choice: IpAddressChoice::AddressesOrRanges(vec![ IpAddressOrRange::Prefix(IpPrefix { afi: Afi::Ipv4, prefix_len: 16, addr: vec![10, 1, 0, 0], }), IpAddressOrRange::Range(IpAddressRange { min: vec![192, 0, 2, 10], max: vec![192, 0, 2, 20], }), ]), }], }; let strict_bad = IpResourceSet { families: vec![IpAddressFamily { afi: Afi::Ipv4, choice: IpAddressChoice::Inherit, }], }; assert!(ip_resources_is_subset(&child, &parent)); assert!(ip_resources_to_merged_intervals(&parent).contains_key(&AfiKey::V4)); assert!(ip_resources_to_merged_intervals_strict(&child).is_ok()); assert!(ip_resources_to_merged_intervals_strict(&strict_bad).is_err()); let idx = build_issuer_resources_index( Some(&parent), Some(&AsResourceSet { asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![ AsIdOrRange::Range { min: 64500, max: 64510, }, ])), rdi: Some(AsIdentifierChoice::Inherit), }), ); assert!(idx.ip_v4.is_some()); assert!(idx.ip_v6.is_none()); assert!(idx.asnum.is_some()); assert!(idx.rdi.is_none()); assert!(ip_resources_is_subset_indexed(&child, &parent, &idx)); assert!(!ip_resources_is_subset_indexed(&strict_bad, &parent, &idx)); } #[test] fn interval_and_byte_helpers_cover_edge_cases() { let parent = vec![(vec![0, 0, 0, 0], vec![0, 0, 0, 10])]; assert!(interval_is_covered(&parent, &[0, 0, 0, 1], &[0, 0, 0, 2])); assert!(!interval_is_covered( &parent, &[0, 0, 0, 11], &[0, 0, 0, 12] )); assert!(intervals_are_covered( &parent, &[(vec![0, 0, 0, 1], vec![0, 0, 0, 2])] )); assert!(!intervals_are_covered( &parent, &[(vec![0, 0, 0, 9], vec![0, 0, 0, 11])], )); let prefix = RcIpPrefix { afi: Afi::Ipv4, prefix_len: 24, addr: vec![203, 0, 113, 7], }; assert_eq!( prefix_to_range(&prefix), (vec![203, 0, 113, 0], vec![203, 0, 113, 255]) ); assert_eq!(increment_bytes(&[0, 0, 0, 255]), vec![0, 0, 1, 0]); assert!(bytes_is_next(&[0, 0, 1, 0], &[0, 0, 0, 255])); assert!(!bytes_is_next(&[1, 2], &[1])); } #[test] fn merged_interval_helpers_cover_empty_and_break_paths() { let mut empty: Vec<(Vec, Vec)> = Vec::new(); merge_ip_intervals_in_place(&mut empty); assert!(empty.is_empty()); let mut v = vec![ (vec![0, 0, 0, 20], vec![0, 0, 0, 30]), (vec![0, 0, 0, 0], vec![0, 0, 0, 10]), (vec![0, 0, 0, 11], vec![0, 0, 0, 19]), ]; v.sort_by(|(a, _), (b, _)| a.cmp(b)); merge_ip_intervals_in_place(&mut v); assert_eq!(v, vec![(vec![0, 0, 0, 0], vec![0, 0, 0, 30])]); assert_eq!( as_choice_to_merged_intervals(&AsIdentifierChoice::AsIdsOrRanges(vec![ AsIdOrRange::Id(1), AsIdOrRange::Range { min: 2, max: 3 }, AsIdOrRange::Range { min: 7, max: 9 }, ])), vec![(1, 3), (7, 9)] ); assert!(as_interval_is_covered(&[(1, 3), (7, 9)], 2, 3)); assert!(!as_interval_is_covered(&[(7, 9)], 2, 3)); } #[test] fn roa_output_helpers_cover_vrps_and_afi_strings() { let roa_der = fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa"); let roa = RoaObject::decode_der(&roa_der).expect("decode roa"); let vrps = roa_to_vrps(&roa); assert!(!vrps.is_empty()); assert!(vrps.iter().all(|vrp| vrp.asn == roa.roa.as_id)); assert_eq!(roa_afi_to_string(RoaAfi::Ipv4), "ipv4"); assert_eq!(roa_afi_to_string(RoaAfi::Ipv6), "ipv6"); } #[test] fn parallel_stage_roa_tasks_share_stage_owned_payloads() { let stage = ParallelObjectsStage { publication_point_id: 7, shared: Arc::new(RoaTaskShared { locked_files: Arc::<[PackFile]>::from(vec![ PackFile::from_bytes_with_sha256( "rsync://example.test/repo/a.roa", vec![1, 2, 3], [1u8; 32], ), PackFile::from_bytes_with_sha256( "rsync://example.test/repo/b.roa", vec![4, 5, 6], [2u8; 32], ), ]), manifest_rsync_uri: Arc::::from("rsync://example.test/repo/manifest.mft"), issuer_ca_der: Arc::from([0x01u8].as_slice()), issuer_ca: Arc::new( ResourceCertificate::decode_der(&fixture_bytes( "tests/fixtures/ta/apnic-ta.cer", )) .expect("decode fixture CA certificate"), ), issuer_spki_der: Arc::from([0x02u8].as_slice()), issuer_ca_rsync_uri: Some(Arc::::from("rsync://example.test/repo/ca.cer")), crl_cache: Arc::new(Mutex::new(HashMap::new())), issuer_resources_index: Arc::new(IssuerResourcesIndex::default()), issuer_effective_ip: None, issuer_effective_as: None, }), validation_time: OffsetDateTime::now_utc(), collect_vcir_local_outputs: false, strict_cms_der: false, strict_name: false, roa_task_indices: vec![0, 1], cached_roa_results: Vec::new(), roa_cache_stats: RoaValidationCacheStats::default(), warnings: Vec::new(), stats: ObjectsStats { roa_total: 2, ..ObjectsStats::default() }, audit: Vec::new(), }; let tasks = stage.build_roa_tasks(); assert_eq!(tasks.len(), 2); assert!(Arc::ptr_eq(&tasks[0].shared, &tasks[1].shared)); } #[test] fn strict_name_manifest_decode_failure_drops_publication_point() { let publication_point = PublicationPointSnapshot { format_version: PublicationPointSnapshot::FORMAT_VERSION_V1, manifest_rsync_uri: "rsync://example.test/repo/manifest.mft".to_string(), publication_point_rsync_uri: "rsync://example.test/repo/".to_string(), manifest_number_be: vec![0x01], this_update: PackTime::from_utc_offset_datetime(OffsetDateTime::now_utc()), next_update: PackTime::from_utc_offset_datetime(OffsetDateTime::now_utc()), verified_at: PackTime::from_utc_offset_datetime(OffsetDateTime::now_utc()), manifest_bytes: vec![0x01, 0x02, 0x03], files: vec![], }; let policy = Policy::default(); let output = process_publication_point_for_issuer_with_options( &publication_point, &policy, &[], None, None, None, OffsetDateTime::now_utc(), None, false, ); assert!(output.stats.publication_point_dropped); assert!(output.vrps.is_empty()); assert!( output .warnings .iter() .any(|warning| warning.message.contains("manifest decode failed")), "{:?}", output.warnings ); } }