rpki/src/cir/export.rs

865 lines
29 KiB
Rust

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<String, String> {
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<BTreeMap<String, String>, 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<CanonicalInputRepresentation, CirExportError> {
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<CanonicalInputRepresentation, CirExportError> {
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<CirStaticPoolExportSummary, CirExportError> {
let ta_hashes = trust_anchors
.iter()
.map(|ta| ta_sha256_hex(&ta.ta_certificate.raw_der))
.collect::<BTreeSet<_>>();
let hashes = cir
.objects
.iter()
.map(|item| hex::encode(&item.sha256))
.filter(|hash| !ta_hashes.contains(hash))
.collect::<Vec<_>>();
let mut summary = export_hashes_from_store(store, static_root, capture_date_utc, &hashes)?;
let mut unique = hashes.iter().cloned().collect::<BTreeSet<_>>();
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<CirRawStoreExportSummary, CirExportError> {
let ta_by_hash = trust_anchors
.iter()
.map(|ta| (ta_sha256_hex(&ta.ta_certificate.raw_der), *ta))
.collect::<BTreeMap<_, _>>();
let unique: BTreeSet<String> = 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<CirExportSummary, CirExportError> {
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<CirExportSummary, CirExportError> {
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::<Vec<_>>();
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(&current_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<_>>(),
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}");
}
}