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_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, }; use crate::cir::static_pool::{ CirStaticPoolError, CirStaticPoolExportSummary, export_hashes_from_store, write_bytes_to_static_pool, }; use crate::current_repo_index::CurrentRepoObject; use crate::data_model::ta::TrustAnchor; use crate::storage::{RepositoryViewState, 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("list repository_view entries failed: {0}")] ListRepositoryView(String), #[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("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), #[error("write CIR trust anchor bytes to repo store failed: {0}")] WriteRepoBytes(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 tal_count: usize, pub timing: CirExportTiming, } #[derive(Clone, Copy, Debug)] pub struct CirTalBinding<'a> { pub trust_anchor: &'a TrustAnchor, pub tal_uri: &'a str, } fn collect_cir_objects_from_current_repo( current_repo_objects: &[CurrentRepoObject], ) -> BTreeMap { let mut objects = BTreeMap::new(); for entry in current_repo_objects { objects.insert( entry.rsync_uri.clone(), entry.current_hash_hex.to_ascii_lowercase(), ); } objects } fn collect_cir_objects_from_repository_view( store: &RocksStore, ) -> Result, CirExportError> { let entries = store .list_repository_view_entries_with_prefix("rsync://") .map_err(|e| CirExportError::ListRepositoryView(e.to_string()))?; let mut objects = BTreeMap::new(); for entry in entries { if matches!( entry.state, RepositoryViewState::Present | RepositoryViewState::Replaced ) && let Some(hash) = entry.current_hash { objects.insert(entry.rsync_uri, hash.to_ascii_lowercase()); } } Ok(objects) } 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, &[CirTalBinding { trust_anchor, tal_uri, }], validation_time, publication_points, None, ) } pub fn build_cir_from_run_multi( store: &RocksStore, tal_bindings: &[CirTalBinding<'_>], 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 mut objects = if let Some(current_repo_objects) = current_repo_objects { collect_cir_objects_from_current_repo(current_repo_objects) } else { collect_cir_objects_from_repository_view(store)? }; // CIR must describe the actual input world used by validation. When a // publication point falls back to the latest validated current instance, // repository_view may not contain the reused manifest/object set. Pull // those object hashes from the audit so replay can reconstruct the same // world state. for pp in publication_points { if pp.source != "vcir_current_instance" { continue; } for obj in &pp.objects { if obj.result != AuditObjectResult::Ok { continue; } if !obj.rsync_uri.starts_with("rsync://") { continue; } objects.insert(obj.rsync_uri.clone(), obj.sha256_hex.to_ascii_lowercase()); } } let mut tals = Vec::with_capacity(tal_bindings.len()); for binding in tal_bindings { let ta_hash = ta_sha256_hex(&binding.trust_anchor.ta_certificate.raw_der); let mut saw_rsync_uri = false; for uri in &binding.trust_anchor.tal.ta_uris { if uri.scheme() == "rsync" { saw_rsync_uri = true; objects.insert(uri.as_str().to_string(), ta_hash.clone()); } } if !saw_rsync_uri { return Err(CirExportError::MissingTaRsyncUri); } tals.push(CirTal { tal_uri: binding.tal_uri.to_string(), tal_bytes: binding.trust_anchor.tal.raw.clone(), }); } tals.sort_by(|a, b| a.tal_uri.cmp(&b.tal_uri)); let cir = CanonicalInputRepresentation { version: CIR_VERSION_V1, 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(), tals, }; 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 ta_hashes = trust_anchors .iter() .map(|ta| ta_sha256_hex(&ta.ta_certificate.raw_der)) .collect::>(); let hashes = cir .objects .iter() .map(|item| hex::encode(&item.sha256)) .filter(|hash| !ta_hashes.contains(hash)) .collect::>(); let mut summary = export_hashes_from_store(store, static_root, capture_date_utc, &hashes)?; let mut unique = hashes.iter().cloned().collect::>(); for trust_anchor in trust_anchors { let ta_hash = ta_sha256_hex(&trust_anchor.ta_certificate.raw_der); let ta_result = write_bytes_to_static_pool( static_root, capture_date_utc, &ta_hash, &trust_anchor.ta_certificate.raw_der, )?; unique.insert(ta_hash); if ta_result.written { summary.written_files += 1; } else { summary.reused_files += 1; } } summary.unique_hashes = unique.len(); Ok(summary) } pub fn export_cir_raw_store( store: &RocksStore, raw_store_path: &Path, cir: &CanonicalInputRepresentation, trust_anchors: &[&TrustAnchor], ) -> Result { let ta_by_hash = trust_anchors .iter() .map(|ta| (ta_sha256_hex(&ta.ta_certificate.raw_der), *ta)) .collect::>(); let unique: BTreeSet = cir .objects .iter() .map(|item| hex::encode(&item.sha256)) .collect(); let mut 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; } if let Some(trust_anchor) = ta_by_hash.get(sha256_hex) { let mut entry = crate::storage::RawByHashEntry::from_bytes( sha256_hex.clone(), trust_anchor.ta_certificate.raw_der.clone(), ); entry.object_type = Some("cer".to_string()); for object in &cir.objects { if hex::encode(&object.sha256) == *sha256_hex { entry.origin_uris.push(object.rsync_uri.clone()); } } store.put_raw_by_hash_entry(&entry).map_err(|e| { CirExportError::Write(raw_store_path.display().to_string(), e.to_string()) })?; written_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, &[CirTalBinding { 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: &[CirTalBinding<'_>], 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 ta_blobs = tal_bindings .iter() .map(|binding| { ( ta_sha256_hex(&binding.trust_anchor.ta_certificate.raw_der), binding.trust_anchor.ta_certificate.raw_der.clone(), ) }) .collect::>(); store .put_blob_bytes_batch(&ta_blobs) .map_err(|e| CirExportError::WriteRepoBytes(e.to_string()))?; 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(), tal_count: cir.tals.len(), timing: CirExportTiming { build_cir_ms, write_cir_ms, total_ms: total_started.elapsed().as_millis() as u64, }, }) } fn ta_sha256_hex(bytes: &[u8]) -> String { use sha2::{Digest, Sha256}; hex::encode(Sha256::digest(bytes)) } #[cfg(test)] mod tests { use super::*; use crate::cir::decode::decode_cir; use crate::current_repo_index::CurrentRepoObject; use crate::data_model::ta::TrustAnchor; use crate::data_model::tal::Tal; use crate::storage::{RawByHashEntry, RepositoryViewEntry, RepositoryViewState, 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)) } #[test] fn build_cir_from_run_collects_repository_view_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 mut raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone()); raw.origin_uris .push("rsync://example.test/repo/a.cer".into()); store.put_raw_by_hash_entry(&raw).unwrap(); store .put_repository_view_entry(&RepositoryViewEntry { rsync_uri: "rsync://example.test/repo/a.cer".to_string(), current_hash: Some(hash), repository_source: Some("https://rrdp.example.test/notification.xml".to_string()), object_type: Some("cer".to_string()), state: RepositoryViewState::Present, }) .unwrap(); let ta = sample_trust_anchor(); let cir = build_cir_from_run( &store, &ta, "https://example.test/root.tal", sample_time(), &[], ) .expect("build cir"); assert_eq!(cir.version, CIR_VERSION_V1); assert_eq!(cir.tals.len(), 1); assert_eq!(cir.tals[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.contains("apnic-rpki-root-iana-origin.cer")) ); } #[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(); store .put_repository_view_entry(&RepositoryViewEntry { rsync_uri: "rsync://example.test/repo/b.roa".to_string(), current_hash: Some(hash.clone()), repository_source: Some("https://rrdp.example.test/notification.xml".to_string()), object_type: Some("roa".to_string()), state: RepositoryViewState::Present, }) .unwrap(); 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(), &[], &cir_path, sample_date(), ) .expect("export cir"); assert_eq!(summary.tal_count, 1); assert!(summary.object_count >= 2); 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.tals[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(); store .put_repository_view_entry(&RepositoryViewEntry { rsync_uri: "rsync://example.test/repo/d.roa".to_string(), current_hash: Some(hash.clone()), repository_source: Some("https://rrdp.example.test/notification.xml".to_string()), object_type: Some("roa".to_string()), state: RepositoryViewState::Present, }) .unwrap(); 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(), &[], &cir_path, sample_date(), ) .expect("export cir"); assert!(summary.object_count >= 2); assert!(raw_store.exists()); assert!(cir_path.exists()); } #[test] fn export_cir_from_run_writes_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(), Some(ta.ta_certificate.raw_der.clone()) ); } #[test] fn build_cir_from_run_includes_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_uses_current_repo_objects_without_repository_view() { 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![ CurrentRepoObject { rsync_uri: "rsync://example.test/repo/a.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()), }, CurrentRepoObject { rsync_uri: "rsync://example.test/repo/b.cer".to_string(), current_hash_hex: "22".repeat(32), repository_source: "https://rrdp.example.test/notification.xml".to_string(), object_type: Some("cer".to_string()), }, ]; let cir = build_cir_from_run_multi( &store, &[ CirTalBinding { trust_anchor: &ta1, tal_uri: "https://example.test/apnic.tal", }, CirTalBinding { trust_anchor: &ta2, tal_uri: "https://example.test/arin.tal", }, ], sample_time(), &[], Some(¤t_repo_objects), ) .expect("build cir from current repo objects"); assert_eq!(cir.tals.len(), 2); assert!( cir.objects .iter() .any(|item| item.rsync_uri == "rsync://example.test/repo/a.roa") ); assert!( cir.objects .iter() .any(|item| item.rsync_uri == "rsync://example.test/repo/b.cer") ); assert!( cir.objects.iter().any(|item| { item.rsync_uri.contains("apnic-rpki-root-iana-origin.cer") || item.rsync_uri.contains("arin-rpki-ta.cer") }), "trust anchor rsync objects must be included", ); } #[test] fn build_cir_from_run_multi_sorts_tals_by_tal_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, &[ CirTalBinding { trust_anchor: &apnic, tal_uri: "https://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer", }, CirTalBinding { 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.tals .iter() .map(|tal| tal.tal_uri.as_str()) .collect::>(), vec![ "https://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer", "https://rrdp.arin.net/arin-rpki-ta.cer", ] ); } #[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, &[CirTalBinding { 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, &[CirTalBinding { 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_objects_and_multiple_tas() { 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(); store .put_repository_view_entry(&RepositoryViewEntry { rsync_uri: "rsync://example.test/repo/z.roa".to_string(), current_hash: Some(hash.clone()), repository_source: Some("https://rrdp.example.test/notification.xml".to_string()), object_type: Some("roa".to_string()), state: RepositoryViewState::Present, }) .unwrap(); let cir = build_cir_from_run_multi( &store, &[ CirTalBinding { trust_anchor: &ta1, tal_uri: "https://example.test/apnic.tal", }, CirTalBinding { trust_anchor: &ta2, tal_uri: "https://example.test/arin.tal", }, ], sample_time(), &[], None, ) .expect("build cir"); let summary = export_cir_static_pool(&store, &static_root, sample_date(), &cir, &[&ta1, &ta2]) .expect("export static pool"); assert!(summary.unique_hashes >= 3); assert!(summary.written_files >= 3); } #[test] fn export_cir_raw_store_reports_missing_non_ta_object_and_writes_ta_entries() { 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, &[ CirTalBinding { trust_anchor: &ta1, tal_uri: "https://example.test/apnic.tal", }, CirTalBinding { 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!(summary.unique_hashes >= 2); assert!(summary.written_entries >= 2 || summary.reused_entries >= 2); 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}"); } }