rpki/src/cir/export.rs

1095 lines
37 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_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<String, String>,
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<BTreeMap<String, String>, 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<String, CirExportError> {
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<CanonicalInputRepresentation, CirExportError> {
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<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 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::<Vec<_>>();
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<CirStaticPoolExportSummary, CirExportError> {
let _ = trust_anchors;
let hashes = cir
.objects
.iter()
.map(|item| hex::encode(&item.sha256))
.collect::<Vec<_>>();
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<CirRawStoreExportSummary, CirExportError> {
let _ = trust_anchors;
let unique: BTreeSet<String> = 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<CirExportSummary, CirExportError> {
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<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 _ = 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(&current_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<_>>(),
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}");
}
}