use std::collections::BTreeMap; use std::collections::BTreeSet; use std::path::Path; use crate::audit::{AuditObjectResult, PublicationPointAudit}; use crate::cir::encode::{CirEncodeError, encode_cir}; use crate::cir::model::{ CIR_VERSION_V3, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirRejectedObject, CirTrustAnchor, compute_reject_list_sha256, }; use crate::cir::static_pool::{ CirStaticPoolError, CirStaticPoolExportSummary, export_hashes_from_store, }; use crate::current_repo_index::CurrentRepoObject; use crate::data_model::ta::TrustAnchor; use crate::storage::RocksStore; #[derive(Clone, Debug, PartialEq, Eq)] pub struct CirExportTiming { pub build_cir_ms: u64, pub write_cir_ms: u64, pub total_ms: u64, } #[derive(Debug, thiserror::Error)] pub enum CirExportError { #[error("CIR TAL URI must be http(s), got: {0}")] InvalidTalUri(String), #[error("TAL does not contain any rsync TA URI; CIR replay scheme A requires one")] MissingTaRsyncUri, #[error("CIR model validation failed: {0}")] Validate(String), #[error("CIR consumed audit has conflicting hashes for {rsync_uri}: {first} vs {second}")] ConflictingObjectHash { rsync_uri: String, first: String, second: String, }, #[error("encode CIR failed: {0}")] Encode(#[from] CirEncodeError), #[error("static pool export failed: {0}")] StaticPool(#[from] CirStaticPoolError), #[error("write CIR file failed: {0}: {1}")] Write(String, String), } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CirRawStoreExportSummary { pub unique_hashes: usize, pub written_entries: usize, pub reused_entries: usize, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CirExportSummary { pub object_count: usize, pub trust_anchor_count: usize, pub timing: CirExportTiming, } #[derive(Clone, Copy, Debug)] pub struct CirTrustAnchorBinding<'a> { pub trust_anchor: &'a TrustAnchor, pub tal_uri: &'a str, } fn is_sha256_hex(value: &str) -> bool { value.len() == 64 && value.as_bytes().iter().all(u8::is_ascii_hexdigit) } fn insert_consumed_object_hash( objects: &mut BTreeMap, rsync_uri: &str, sha256_hex: &str, ) -> Result<(), CirExportError> { if !rsync_uri.starts_with("rsync://") || !is_sha256_hex(sha256_hex) { return Ok(()); } let normalized = sha256_hex.to_ascii_lowercase(); if let Some(existing) = objects.get(rsync_uri) { if existing != &normalized { return Err(CirExportError::ConflictingObjectHash { rsync_uri: rsync_uri.to_string(), first: existing.clone(), second: normalized, }); } return Ok(()); } objects.insert(rsync_uri.to_string(), normalized); Ok(()) } fn collect_cir_objects_from_validation_audit( publication_points: &[PublicationPointAudit], ) -> Result, CirExportError> { let mut objects = BTreeMap::new(); for pp in publication_points { for obj in &pp.objects { if !matches!(obj.result, AuditObjectResult::Ok | AuditObjectResult::Error) { continue; } insert_consumed_object_hash(&mut objects, &obj.rsync_uri, &obj.sha256_hex)?; } } Ok(objects) } fn canonical_ta_rsync_uri(trust_anchor: &TrustAnchor) -> Result { if let Some(uri) = &trust_anchor.resolved_ta_uri && uri.scheme() == "rsync" { return Ok(uri.as_str().to_string()); } trust_anchor .tal .ta_uris .iter() .filter(|uri| uri.scheme() == "rsync") .map(|uri| uri.as_str().to_string()) .min() .ok_or(CirExportError::MissingTaRsyncUri) } pub fn build_cir_from_run( store: &RocksStore, trust_anchor: &TrustAnchor, tal_uri: &str, validation_time: time::OffsetDateTime, publication_points: &[PublicationPointAudit], ) -> Result { build_cir_from_run_multi( store, &[CirTrustAnchorBinding { trust_anchor, tal_uri, }], validation_time, publication_points, None, ) } pub fn build_cir_from_run_multi( _store: &RocksStore, tal_bindings: &[CirTrustAnchorBinding<'_>], validation_time: time::OffsetDateTime, publication_points: &[PublicationPointAudit], _current_repo_objects: Option<&[CurrentRepoObject]>, ) -> Result { for binding in tal_bindings { if !(binding.tal_uri.starts_with("https://") || binding.tal_uri.starts_with("http://")) { return Err(CirExportError::InvalidTalUri(binding.tal_uri.to_string())); } } let objects = collect_cir_objects_from_validation_audit(publication_points)?; let mut trust_anchors = Vec::with_capacity(tal_bindings.len()); for binding in tal_bindings { let ta_rsync_uri = canonical_ta_rsync_uri(binding.trust_anchor)?; let ta_certificate_der = binding.trust_anchor.ta_certificate.raw_der.clone(); trust_anchors.push(CirTrustAnchor { ta_rsync_uri, tal_uri: binding.tal_uri.to_string(), tal_bytes: binding.trust_anchor.tal.raw.clone(), ta_certificate_sha256: crate::cir::model::sha256(&ta_certificate_der), ta_certificate_der, }); } trust_anchors.sort_by(|a, b| a.ta_rsync_uri.cmp(&b.ta_rsync_uri)); let cir = CanonicalInputRepresentation { version: CIR_VERSION_V3, hash_alg: CirHashAlgorithm::Sha256, validation_time: validation_time.to_offset(time::UtcOffset::UTC), objects: objects .into_iter() .map(|(rsync_uri, sha256_hex)| CirObject { rsync_uri, sha256: hex::decode(sha256_hex).expect("validated hex"), }) .collect(), trust_anchors, reject_list_sha256: Vec::new(), rejected_objects: Vec::new(), }; let mut rejected_objects = publication_points .iter() .flat_map(|pp| pp.objects.iter()) .filter(|item| item.result == AuditObjectResult::Error) .filter(|item| item.rsync_uri.starts_with("rsync://")) .map(|item| CirRejectedObject { object_uri: item.rsync_uri.clone(), reason: item.detail.clone(), }) .collect::>(); rejected_objects.sort_by(|a, b| a.object_uri.cmp(&b.object_uri)); rejected_objects.dedup_by(|a, b| a.object_uri == b.object_uri); let cir = CanonicalInputRepresentation { reject_list_sha256: compute_reject_list_sha256( rejected_objects.iter().map(|item| item.object_uri.as_str()), ), rejected_objects, ..cir }; cir.validate().map_err(CirExportError::Validate)?; Ok(cir) } pub fn write_cir_file( path: &Path, cir: &CanonicalInputRepresentation, ) -> Result<(), CirExportError> { let der = encode_cir(cir)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| CirExportError::Write(path.display().to_string(), e.to_string()))?; } std::fs::write(path, der) .map_err(|e| CirExportError::Write(path.display().to_string(), e.to_string())) } pub fn export_cir_static_pool( store: &RocksStore, static_root: &Path, capture_date_utc: time::Date, cir: &CanonicalInputRepresentation, trust_anchors: &[&TrustAnchor], ) -> Result { let _ = trust_anchors; let hashes = cir .objects .iter() .map(|item| hex::encode(&item.sha256)) .collect::>(); export_hashes_from_store(store, static_root, capture_date_utc, &hashes).map_err(Into::into) } pub fn export_cir_raw_store( store: &RocksStore, raw_store_path: &Path, cir: &CanonicalInputRepresentation, trust_anchors: &[&TrustAnchor], ) -> Result { let _ = trust_anchors; let unique: BTreeSet = cir .objects .iter() .map(|item| hex::encode(&item.sha256)) .collect(); let written_entries = 0usize; let mut reused_entries = 0usize; for sha256_hex in &unique { if store .get_blob_bytes(sha256_hex) .map_err(|e| { CirExportError::Write(raw_store_path.display().to_string(), e.to_string()) })? .is_some() { reused_entries += 1; continue; } return Err(CirExportError::Write( raw_store_path.display().to_string(), format!("raw store missing object for sha256={sha256_hex}"), )); } Ok(CirRawStoreExportSummary { unique_hashes: unique.len(), written_entries, reused_entries, }) } pub fn export_cir_from_run( store: &RocksStore, trust_anchor: &TrustAnchor, tal_uri: &str, validation_time: time::OffsetDateTime, publication_points: &[PublicationPointAudit], cir_out: &Path, capture_date_utc: time::Date, ) -> Result { export_cir_from_run_multi( store, &[CirTrustAnchorBinding { trust_anchor, tal_uri, }], validation_time, publication_points, cir_out, capture_date_utc, None, ) } pub fn export_cir_from_run_multi( store: &RocksStore, tal_bindings: &[CirTrustAnchorBinding<'_>], validation_time: time::OffsetDateTime, publication_points: &[PublicationPointAudit], cir_out: &Path, capture_date_utc: time::Date, current_repo_objects: Option<&[CurrentRepoObject]>, ) -> Result { let _ = capture_date_utc; let total_started = std::time::Instant::now(); let started = std::time::Instant::now(); let cir = build_cir_from_run_multi( store, tal_bindings, validation_time, publication_points, current_repo_objects, )?; let build_cir_ms = started.elapsed().as_millis() as u64; let _ = store; let started = std::time::Instant::now(); write_cir_file(cir_out, &cir)?; let write_cir_ms = started.elapsed().as_millis() as u64; Ok(CirExportSummary { object_count: cir.objects.len(), trust_anchor_count: cir.trust_anchors.len(), timing: CirExportTiming { build_cir_ms, write_cir_ms, total_ms: total_started.elapsed().as_millis() as u64, }, }) } #[cfg(test)] mod tests { use super::*; use crate::cir::decode::decode_cir; use crate::data_model::ta::TrustAnchor; use crate::data_model::tal::Tal; use crate::storage::{RawByHashEntry, RocksStore}; fn sample_time() -> time::OffsetDateTime { time::OffsetDateTime::parse( "2026-04-07T12:34:56Z", &time::format_description::well_known::Rfc3339, ) .unwrap() } fn sample_date() -> time::Date { time::Date::from_calendar_date(2026, time::Month::April, 7).unwrap() } fn sample_trust_anchor() -> TrustAnchor { let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); let tal_bytes = std::fs::read(base.join("tests/fixtures/tal/apnic-rfc7730-https.tal")).unwrap(); let ta_der = std::fs::read(base.join("tests/fixtures/ta/apnic-ta.cer")).unwrap(); let tal = Tal::decode_bytes(&tal_bytes).unwrap(); TrustAnchor::bind_der(tal, &ta_der, None).unwrap() } fn sample_arin_trust_anchor() -> TrustAnchor { let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); let tal_bytes = std::fs::read(base.join("tests/fixtures/tal/arin.tal")).unwrap(); let ta_der = std::fs::read(base.join("tests/fixtures/ta/arin-ta.cer")).unwrap(); let tal = Tal::decode_bytes(&tal_bytes).unwrap(); TrustAnchor::bind_der(tal, &ta_der, None).unwrap() } fn sample_trust_anchor_without_rsync_uri() -> TrustAnchor { let mut ta = sample_trust_anchor(); ta.tal.ta_uris.retain(|uri| uri.scheme() != "rsync"); ta } fn sha256_hex(bytes: &[u8]) -> String { use sha2::{Digest, Sha256}; hex::encode(Sha256::digest(bytes)) } fn audit_entry( uri: &str, hash: &str, kind: crate::audit::AuditObjectKind, result: crate::audit::AuditObjectResult, detail: Option<&str>, ) -> crate::audit::ObjectAuditEntry { crate::audit::ObjectAuditEntry { rsync_uri: uri.to_string(), sha256_hex: hash.to_string(), kind, result, detail: detail.map(ToString::to_string), } } #[test] fn build_cir_from_run_collects_consumed_audit_objects_and_tal() { let td = tempfile::tempdir().unwrap(); let store = RocksStore::open(td.path()).unwrap(); let bytes = b"object-a".to_vec(); let hash = sha256_hex(&bytes); let publication_points = vec![PublicationPointAudit { objects: vec![audit_entry( "rsync://example.test/repo/a.cer", &hash, crate::audit::AuditObjectKind::Certificate, crate::audit::AuditObjectResult::Ok, None, )], ..PublicationPointAudit::default() }]; let ta = sample_trust_anchor(); let cir = build_cir_from_run( &store, &ta, "https://example.test/root.tal", sample_time(), &publication_points, ) .expect("build cir"); assert_eq!(cir.version, CIR_VERSION_V3); assert_eq!(cir.trust_anchors.len(), 1); assert_eq!( cir.trust_anchors[0].tal_uri, "https://example.test/root.tal" ); assert!( cir.objects .iter() .any(|item| item.rsync_uri == "rsync://example.test/repo/a.cer") ); assert!( !cir.objects .iter() .any(|item| item.rsync_uri == cir.trust_anchors[0].ta_rsync_uri) ); assert!(!cir.trust_anchors[0].ta_certificate_der.is_empty()); } #[test] fn export_cir_from_run_writes_der_and_static_pool() { let td = tempfile::tempdir().unwrap(); let store_dir = td.path().join("db"); let out_dir = td.path().join("out"); let _static_root = td.path().join("static"); let store = RocksStore::open(&store_dir).unwrap(); let bytes = b"object-b".to_vec(); let hash = sha256_hex(&bytes); let mut raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone()); raw.origin_uris .push("rsync://example.test/repo/b.roa".into()); store.put_raw_by_hash_entry(&raw).unwrap(); let publication_points = vec![PublicationPointAudit { objects: vec![audit_entry( "rsync://example.test/repo/b.roa", &hash, crate::audit::AuditObjectKind::Roa, crate::audit::AuditObjectResult::Ok, None, )], ..PublicationPointAudit::default() }]; let ta = sample_trust_anchor(); let cir_path = out_dir.join("example.cir"); let summary = export_cir_from_run( &store, &ta, "https://example.test/root.tal", sample_time(), &publication_points, &cir_path, sample_date(), ) .expect("export cir"); assert_eq!(summary.trust_anchor_count, 1); assert_eq!(summary.object_count, 1); assert!(summary.timing.total_ms >= summary.timing.build_cir_ms); let der = std::fs::read(&cir_path).unwrap(); let cir = decode_cir(&der).unwrap(); assert_eq!( cir.trust_anchors[0].tal_uri, "https://example.test/root.tal" ); } #[test] fn export_cir_from_run_uses_raw_store_backend_without_pool_export() { let td = tempfile::tempdir().unwrap(); let store_dir = td.path().join("db"); let raw_store = td.path().join("raw-store.db"); let out_dir = td.path().join("out"); let store = RocksStore::open_with_external_raw_store(&store_dir, &raw_store).unwrap(); let bytes = b"object-d".to_vec(); let hash = sha256_hex(&bytes); let mut raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone()); raw.origin_uris .push("rsync://example.test/repo/d.roa".into()); store.put_raw_by_hash_entry(&raw).unwrap(); let publication_points = vec![PublicationPointAudit { objects: vec![audit_entry( "rsync://example.test/repo/d.roa", &hash, crate::audit::AuditObjectKind::Roa, crate::audit::AuditObjectResult::Ok, None, )], ..PublicationPointAudit::default() }]; let ta = sample_trust_anchor(); let cir_path = out_dir.join("example.cir"); let summary = export_cir_from_run( &store, &ta, "https://example.test/root.tal", sample_time(), &publication_points, &cir_path, sample_date(), ) .expect("export cir"); assert_eq!(summary.object_count, 1); assert!(raw_store.exists()); assert!(cir_path.exists()); } #[test] fn export_cir_from_run_does_not_write_ta_bytes_to_repo_bytes_store() { let td = tempfile::tempdir().unwrap(); let store = RocksStore::open_with_external_repo_bytes( &td.path().join("db"), &td.path().join("repo-bytes.db"), ) .unwrap(); let ta = sample_trust_anchor(); let ta_hash = sha256_hex(&ta.ta_certificate.raw_der); let cir_path = td.path().join("out").join("example.cir"); export_cir_from_run( &store, &ta, "https://example.test/root.tal", sample_time(), &[], &cir_path, sample_date(), ) .expect("export cir"); assert_eq!(store.get_blob_bytes(&ta_hash).unwrap(), None); } #[test] fn build_cir_from_run_includes_consumed_vcir_current_instance_objects_from_audit() { let td = tempfile::tempdir().unwrap(); let store = RocksStore::open(td.path()).unwrap(); let ta = sample_trust_anchor(); let mut pp = PublicationPointAudit { source: "vcir_current_instance".to_string(), ..PublicationPointAudit::default() }; pp.objects.push(crate::audit::ObjectAuditEntry { rsync_uri: "rsync://example.test/repo/fallback.mft".to_string(), sha256_hex: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" .to_string(), kind: crate::audit::AuditObjectKind::Manifest, result: crate::audit::AuditObjectResult::Ok, detail: None, }); pp.objects.push(crate::audit::ObjectAuditEntry { rsync_uri: "rsync://example.test/repo/fallback.roa".to_string(), sha256_hex: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" .to_string(), kind: crate::audit::AuditObjectKind::Roa, result: crate::audit::AuditObjectResult::Ok, detail: None, }); let cir = build_cir_from_run( &store, &ta, "https://example.test/root.tal", sample_time(), &[pp], ) .expect("build cir"); assert!( cir.objects .iter() .any(|item| item.rsync_uri == "rsync://example.test/repo/fallback.mft") ); assert!( cir.objects .iter() .any(|item| item.rsync_uri == "rsync://example.test/repo/fallback.roa") ); } #[test] fn build_cir_from_run_multi_ignores_current_repo_superfluous_objects() { let td = tempfile::tempdir().unwrap(); let store = RocksStore::open(td.path()).unwrap(); let ta1 = sample_trust_anchor(); let ta2 = sample_arin_trust_anchor(); let current_repo_objects = vec![crate::current_repo_index::CurrentRepoObject { rsync_uri: "rsync://example.test/repo/superfluous.roa".to_string(), current_hash_hex: "11".repeat(32), repository_source: "https://rrdp.example.test/notification.xml".to_string(), object_type: Some("roa".to_string()), }]; let publication_points = vec![PublicationPointAudit { objects: vec![audit_entry( "rsync://example.test/repo/consumed.roa", &"22".repeat(32), crate::audit::AuditObjectKind::Roa, crate::audit::AuditObjectResult::Ok, None, )], ..PublicationPointAudit::default() }]; let cir = build_cir_from_run_multi( &store, &[ CirTrustAnchorBinding { trust_anchor: &ta1, tal_uri: "https://example.test/apnic.tal", }, CirTrustAnchorBinding { trust_anchor: &ta2, tal_uri: "https://example.test/arin.tal", }, ], sample_time(), &publication_points, Some(¤t_repo_objects), ) .expect("build cir from consumed audit objects"); assert_eq!(cir.trust_anchors.len(), 2); assert!( cir.objects .iter() .any(|item| item.rsync_uri == "rsync://example.test/repo/consumed.roa") ); assert!( !cir.objects .iter() .any(|item| item.rsync_uri == "rsync://example.test/repo/superfluous.roa"), "current repo objects must not be included unless validation consumed them", ); for trust_anchor in &cir.trust_anchors { assert!( !cir.objects .iter() .any(|item| item.rsync_uri == trust_anchor.ta_rsync_uri), "trust anchor rsync objects must not be included in CIR.objects", ); } } #[test] fn build_cir_from_run_multi_sorts_trust_anchors_by_ta_rsync_uri() { let td = tempfile::tempdir().unwrap(); let store = RocksStore::open(td.path()).unwrap(); let apnic = sample_trust_anchor(); let arin = sample_arin_trust_anchor(); let cir = build_cir_from_run_multi( &store, &[ CirTrustAnchorBinding { trust_anchor: &apnic, tal_uri: "https://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer", }, CirTrustAnchorBinding { trust_anchor: &arin, tal_uri: "https://rrdp.arin.net/arin-rpki-ta.cer", }, ], sample_time(), &[], Some(&[]), ) .expect("build cir with unsorted input bindings"); assert_eq!( cir.trust_anchors .iter() .map(|trust_anchor| trust_anchor.ta_rsync_uri.as_str()) .collect::>(), vec![ "rsync://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer", "rsync://rpki.arin.net/repository/arin-rpki-ta.cer", ] ); } #[test] fn build_cir_from_run_multi_exports_rejected_objects_from_error_audit_only() { let td = tempfile::tempdir().unwrap(); let store = RocksStore::open(td.path()).unwrap(); let ta = sample_trust_anchor(); let publication_points = vec![PublicationPointAudit { objects: vec![ crate::audit::ObjectAuditEntry { rsync_uri: "rsync://example.test/repo/a.roa".to_string(), sha256_hex: "11".repeat(32), kind: crate::audit::AuditObjectKind::Roa, result: crate::audit::AuditObjectResult::Error, detail: Some("invalid roa".to_string()), }, crate::audit::ObjectAuditEntry { rsync_uri: "rsync://example.test/repo/b.asa".to_string(), sha256_hex: "22".repeat(32), kind: crate::audit::AuditObjectKind::Aspa, result: crate::audit::AuditObjectResult::Skipped, detail: Some("skipped".to_string()), }, crate::audit::ObjectAuditEntry { rsync_uri: "rsync://example.test/repo/c.roa".to_string(), sha256_hex: "33".repeat(32), kind: crate::audit::AuditObjectKind::Roa, result: crate::audit::AuditObjectResult::Error, detail: Some("second rejected roa".to_string()), }, ], ..PublicationPointAudit::default() }]; let cir = build_cir_from_run_multi( &store, &[CirTrustAnchorBinding { trust_anchor: &ta, tal_uri: "https://example.test/root.tal", }], sample_time(), &publication_points, None, ) .expect("build cir"); assert_eq!(cir.rejected_objects.len(), 2); assert_eq!( cir.rejected_objects[0].object_uri, "rsync://example.test/repo/a.roa" ); assert_eq!( cir.rejected_objects[0].reason.as_deref(), Some("invalid roa") ); assert_eq!( cir.rejected_objects[1].object_uri, "rsync://example.test/repo/c.roa" ); assert!( cir.objects .iter() .any(|item| item.rsync_uri == "rsync://example.test/repo/a.roa"), "rejected audit objects were still consumed as validation input", ); assert!( cir.objects .iter() .any(|item| item.rsync_uri == "rsync://example.test/repo/c.roa"), "rejected audit objects were still consumed as validation input", ); assert!( !cir.objects .iter() .any(|item| item.rsync_uri == "rsync://example.test/repo/b.asa"), "skipped audit objects are not considered consumed input", ); } #[test] fn build_cir_from_run_multi_records_crl_expired_manifest_in_objects_and_rejects() { let td = tempfile::tempdir().unwrap(); let store = RocksStore::open(td.path()).unwrap(); let ta = sample_trust_anchor(); let manifest_uri = "rsync://example.test/repo/expired-crl.mft"; let reject_reason = "manifest embedded EE certificate path validation failed: CRL not valid at validation_time (RFC 5280 §6.3.3(g); RFC 5280 §5.1.2.4-§5.1.2.5; RFC 6487 §5)"; let publication_points = vec![PublicationPointAudit { objects: vec![crate::audit::ObjectAuditEntry { rsync_uri: manifest_uri.to_string(), sha256_hex: "44".repeat(32), kind: crate::audit::AuditObjectKind::Manifest, result: crate::audit::AuditObjectResult::Error, detail: Some(reject_reason.to_string()), }], ..PublicationPointAudit::default() }]; let cir = build_cir_from_run_multi( &store, &[CirTrustAnchorBinding { trust_anchor: &ta, tal_uri: "https://example.test/root.tal", }], sample_time(), &publication_points, None, ) .expect("build cir"); assert!( cir.objects .iter() .any(|item| item.rsync_uri == manifest_uri), "manifest rejected because issuer CRL is expired was still read as validation input", ); assert_eq!(cir.rejected_objects.len(), 1); assert_eq!(cir.rejected_objects[0].object_uri, manifest_uri); assert_eq!( cir.rejected_objects[0].reason.as_deref(), Some(reject_reason) ); assert_eq!( cir.reject_list_sha256, compute_reject_list_sha256([manifest_uri].into_iter()) ); } #[test] fn build_cir_from_run_multi_excludes_manifest_locked_files_when_manifest_is_rejected() { let td = tempfile::tempdir().unwrap(); let store = RocksStore::open(td.path()).unwrap(); let ta = sample_trust_anchor(); let manifest_uri = "rsync://example.test/repo/rejected.mft"; let roa_uri = "rsync://example.test/repo/listed.roa"; let crl_uri = "rsync://example.test/repo/listed.crl"; let publication_points = vec![PublicationPointAudit { objects: vec![ crate::audit::ObjectAuditEntry { rsync_uri: manifest_uri.to_string(), sha256_hex: "44".repeat(32), kind: crate::audit::AuditObjectKind::Manifest, result: crate::audit::AuditObjectResult::Error, detail: Some("manifest EE cert path rejected".to_string()), }, crate::audit::ObjectAuditEntry { rsync_uri: roa_uri.to_string(), sha256_hex: "55".repeat(32), kind: crate::audit::AuditObjectKind::Roa, result: crate::audit::AuditObjectResult::Skipped, detail: Some("manifest rejected before locked object validation".to_string()), }, crate::audit::ObjectAuditEntry { rsync_uri: crl_uri.to_string(), sha256_hex: "66".repeat(32), kind: crate::audit::AuditObjectKind::Crl, result: crate::audit::AuditObjectResult::Skipped, detail: Some("manifest rejected before locked object validation".to_string()), }, ], ..PublicationPointAudit::default() }]; let cir = build_cir_from_run_multi( &store, &[CirTrustAnchorBinding { trust_anchor: &ta, tal_uri: "https://example.test/root.tal", }], sample_time(), &publication_points, None, ) .expect("build cir"); assert!( cir.objects .iter() .any(|item| item.rsync_uri == manifest_uri), "rejected manifest is still a current-run validation input", ); assert!( !cir.objects.iter().any(|item| item.rsync_uri == roa_uri), "ROA listed by a rejected manifest must not enter CIR objects", ); assert!( !cir.objects.iter().any(|item| item.rsync_uri == crl_uri), "CRL listed by a rejected manifest must not enter CIR objects", ); assert_eq!(cir.rejected_objects.len(), 1); assert_eq!(cir.rejected_objects[0].object_uri, manifest_uri); } #[test] fn build_cir_from_run_multi_reject_digest_ignores_reason_text() { let td = tempfile::tempdir().unwrap(); let store = RocksStore::open(td.path()).unwrap(); let ta = sample_trust_anchor(); let mk_pp = |detail: &str| PublicationPointAudit { objects: vec![crate::audit::ObjectAuditEntry { rsync_uri: "rsync://example.test/repo/a.roa".to_string(), sha256_hex: "11".repeat(32), kind: crate::audit::AuditObjectKind::Roa, result: crate::audit::AuditObjectResult::Error, detail: Some(detail.to_string()), }], ..PublicationPointAudit::default() }; let cir_a = build_cir_from_run_multi( &store, &[CirTrustAnchorBinding { trust_anchor: &ta, tal_uri: "https://example.test/root.tal", }], sample_time(), &[mk_pp("reason-a")], None, ) .expect("build cir a"); let cir_b = build_cir_from_run_multi( &store, &[CirTrustAnchorBinding { trust_anchor: &ta, tal_uri: "https://example.test/root.tal", }], sample_time(), &[mk_pp("reason-b")], None, ) .expect("build cir b"); assert_eq!(cir_a.reject_list_sha256, cir_b.reject_list_sha256); assert_ne!( cir_a.rejected_objects[0].reason, cir_b.rejected_objects[0].reason ); } #[test] fn build_cir_from_run_multi_rejects_invalid_tal_uri_and_missing_rsync_ta_uri() { let td = tempfile::tempdir().unwrap(); let store = RocksStore::open(td.path()).unwrap(); let err = build_cir_from_run_multi( &store, &[CirTrustAnchorBinding { trust_anchor: &sample_trust_anchor(), tal_uri: "file:///not-supported.tal", }], sample_time(), &[], None, ) .expect_err("non-http tal uri must fail"); assert!(matches!(err, CirExportError::InvalidTalUri(_)), "{err}"); let err = build_cir_from_run_multi( &store, &[CirTrustAnchorBinding { trust_anchor: &sample_trust_anchor_without_rsync_uri(), tal_uri: "https://example.test/root.tal", }], sample_time(), &[], None, ) .expect_err("missing rsync ta uri must fail"); assert!(matches!(err, CirExportError::MissingTaRsyncUri), "{err}"); } #[test] fn export_cir_static_pool_writes_repository_objects_only() { let td = tempfile::tempdir().unwrap(); let store = RocksStore::open(&td.path().join("db")).unwrap(); let static_root = td.path().join("static"); let ta1 = sample_trust_anchor(); let ta2 = sample_arin_trust_anchor(); let object_bytes = b"object-z".to_vec(); let hash = sha256_hex(&object_bytes); let mut raw = RawByHashEntry::from_bytes(hash.clone(), object_bytes.clone()); raw.origin_uris .push("rsync://example.test/repo/z.roa".into()); store.put_raw_by_hash_entry(&raw).unwrap(); let publication_points = vec![PublicationPointAudit { objects: vec![audit_entry( "rsync://example.test/repo/z.roa", &hash, crate::audit::AuditObjectKind::Roa, crate::audit::AuditObjectResult::Ok, None, )], ..PublicationPointAudit::default() }]; let cir = build_cir_from_run_multi( &store, &[ CirTrustAnchorBinding { trust_anchor: &ta1, tal_uri: "https://example.test/apnic.tal", }, CirTrustAnchorBinding { trust_anchor: &ta2, tal_uri: "https://example.test/arin.tal", }, ], sample_time(), &publication_points, None, ) .expect("build cir"); let summary = export_cir_static_pool(&store, &static_root, sample_date(), &cir, &[&ta1, &ta2]) .expect("export static pool"); assert_eq!(summary.unique_hashes, 1); assert_eq!(summary.written_files, 1); for trust_anchor in &cir.trust_anchors { let ta_hash = hex::encode(&trust_anchor.ta_certificate_sha256); assert!( !crate::cir::static_pool::static_pool_path(&static_root, sample_date(), &ta_hash) .expect("static pool ta path") .exists() ); } } #[test] fn export_cir_raw_store_reports_missing_non_ta_object_only() { let td = tempfile::tempdir().unwrap(); let raw_store_path = td.path().join("raw-store.db"); let store = RocksStore::open_with_external_raw_store(&td.path().join("db"), &raw_store_path) .unwrap(); let ta1 = sample_trust_anchor(); let ta2 = sample_arin_trust_anchor(); let cir_only_tas = build_cir_from_run_multi( &store, &[ CirTrustAnchorBinding { trust_anchor: &ta1, tal_uri: "https://example.test/apnic.tal", }, CirTrustAnchorBinding { trust_anchor: &ta2, tal_uri: "https://example.test/arin.tal", }, ], sample_time(), &[], Some(&[]), ) .expect("build cir with tas only"); let summary = export_cir_raw_store(&store, &raw_store_path, &cir_only_tas, &[&ta1, &ta2]) .expect("export raw store"); assert_eq!(summary.unique_hashes, 0); assert_eq!(summary.written_entries, 0); assert_eq!(summary.reused_entries, 0); let mut cir_missing_object = cir_only_tas.clone(); cir_missing_object.objects.push(CirObject { rsync_uri: "rsync://example.test/repo/missing.roa".to_string(), sha256: vec![0x44; 32], }); let err = export_cir_raw_store(&store, &raw_store_path, &cir_missing_object, &[&ta1, &ta2]) .expect_err("missing non-ta object must fail"); assert!(matches!(err, CirExportError::Write(_, _)), "{err}"); } }