20260324_2 增加ccr & router key support

This commit is contained in:
2026-03-26 11:52:06 +08:00
parent d6d44669b4
commit fe8b89d829
33 changed files with 5337 additions and 27 deletions

337
tests/test_ccr_m1.rs Normal file
View File

@@ -0,0 +1,337 @@
use rpki::ccr::{
AspaPayloadSet, AspaPayloadState, CcrContentInfo, CcrDigestAlgorithm, ManifestInstance,
ManifestState, RoaPayloadSet, RoaPayloadState, RouterKey, RouterKeySet, RouterKeyState,
RpkiCanonicalCacheRepresentation, TrustAnchorState, compute_state_hash,
decode_content_info, encode::{
encode_manifest_state_payload_der, encode_router_key_state_payload_der,
encode_trust_anchor_state_payload_der,
},
encode_content_info, verify_state_hash,
};
use rpki::data_model::common::BigUnsigned;
use rpki::data_model::oid::{OID_CT_RPKI_CCR, OID_CT_RPKI_CCR_RAW};
fn sample_time() -> time::OffsetDateTime {
time::OffsetDateTime::parse(
"2026-03-24T00:00:00Z",
&time::format_description::well_known::Rfc3339,
)
.expect("valid rfc3339")
}
#[test]
fn minimal_trust_anchor_ccr_roundtrips() {
let skis = vec![vec![0x11; 20], vec![0x22; 20]];
let skis_der = encode_trust_anchor_state_payload_der(&skis).expect("encode trust anchor payload");
let state = TrustAnchorState {
skis,
hash: compute_state_hash(&skis_der),
};
let ccr = RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: sample_time(),
mfts: None,
vrps: None,
vaps: None,
tas: Some(state),
rks: None,
};
let content_info = CcrContentInfo::new(ccr.clone());
let der = encode_content_info(&content_info).expect("encode ccr");
let decoded = decode_content_info(&der).expect("decode ccr");
assert_eq!(decoded, content_info);
assert_eq!(decoded.content_type_oid, OID_CT_RPKI_CCR);
}
#[test]
fn decode_rejects_wrong_content_type_oid() {
let skis = vec![vec![0x11; 20]];
let skis_der = encode_trust_anchor_state_payload_der(&skis).expect("encode trust anchor payload");
let content_info = CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: sample_time(),
mfts: None,
vrps: None,
vaps: None,
tas: Some(TrustAnchorState {
skis,
hash: compute_state_hash(&skis_der),
}),
rks: None,
});
let mut der = encode_content_info(&content_info).expect("encode ccr");
let needle = OID_CT_RPKI_CCR_RAW;
let pos = der
.windows(needle.len())
.position(|w| w == needle)
.expect("oid present");
der[pos + needle.len() - 1] ^= 0x01;
let err = decode_content_info(&der).expect_err("wrong content type must fail");
assert!(err.to_string().contains("unexpected contentType OID"), "{err}");
}
#[test]
fn ccr_requires_at_least_one_state_aspect() {
let ccr = CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: sample_time(),
mfts: None,
vrps: None,
vaps: None,
tas: None,
rks: None,
});
let err = encode_content_info(&ccr).expect_err("empty state aspects must fail");
assert!(err.to_string().contains("at least one of mfts/vrps/vaps/tas/rks"));
}
#[test]
fn state_hash_helpers_accept_matching_and_reject_tampered_payload() {
let skis = vec![vec![0x11; 20]];
let payload_der = encode_trust_anchor_state_payload_der(&skis).expect("encode trust anchor payload");
let hash = compute_state_hash(&payload_der);
assert!(verify_state_hash(&hash, &payload_der));
let mut tampered = payload_der.clone();
*tampered.last_mut().expect("non-empty der") ^= 0x01;
assert!(!verify_state_hash(&hash, &tampered));
}
#[test]
fn manifest_and_router_key_skeletons_encode_payloads_and_validate_sorting() {
let manifest_instances = vec![ManifestInstance {
hash: vec![0x33; 32],
size: 2048,
aki: vec![0x44; 20],
manifest_number: BigUnsigned { bytes_be: vec![0x01] },
this_update: sample_time(),
locations: vec![vec![0x30, 0x00]],
subordinates: vec![vec![0x55; 20]],
}];
let mis_der = encode_manifest_state_payload_der(&manifest_instances).expect("encode manifest state payload");
let manifest_state = ManifestState {
mis: manifest_instances,
most_recent_update: sample_time(),
hash: compute_state_hash(&mis_der),
};
manifest_state.validate().expect("manifest state validate");
let rksets = vec![RouterKeySet {
as_id: 64496,
router_keys: vec![RouterKey {
ski: vec![0x66; 20],
spki_der: vec![0x30, 0x00],
}],
}];
let rk_der = encode_router_key_state_payload_der(&rksets).expect("encode router key payload");
let rks = RouterKeyState {
rksets,
hash: compute_state_hash(&rk_der),
};
rks.validate().expect("router key state validate");
}
fn sample_manifest_state() -> ManifestState {
let mis = vec![ManifestInstance {
hash: vec![0x10; 32],
size: 2048,
aki: vec![0x20; 20],
manifest_number: BigUnsigned { bytes_be: vec![1] },
this_update: sample_time(),
locations: vec![vec![0x30, 0x00]],
subordinates: vec![vec![0x30; 20], vec![0x40; 20]],
}];
let mis_der = encode_manifest_state_payload_der(&mis).expect("encode mis");
ManifestState {
mis,
most_recent_update: sample_time(),
hash: compute_state_hash(&mis_der),
}
}
fn sample_roa_state() -> RoaPayloadState {
let rps = vec![RoaPayloadSet {
as_id: 64496,
ip_addr_blocks: vec![vec![0x30, 0x00]],
}];
let der = rpki::ccr::encode::encode_roa_payload_state_payload_der(&rps).expect("encode rps");
RoaPayloadState {
rps,
hash: compute_state_hash(&der),
}
}
fn sample_aspa_state() -> AspaPayloadState {
let aps = vec![AspaPayloadSet {
customer_as_id: 64496,
providers: vec![64497, 64498],
}];
let der = rpki::ccr::encode::encode_aspa_payload_state_payload_der(&aps).expect("encode aps");
AspaPayloadState {
aps,
hash: compute_state_hash(&der),
}
}
fn sample_ta_state() -> TrustAnchorState {
let skis = vec![vec![0x11; 20], vec![0x22; 20]];
let der = encode_trust_anchor_state_payload_der(&skis).expect("encode skis");
TrustAnchorState {
skis,
hash: compute_state_hash(&der),
}
}
fn sample_router_key_state() -> RouterKeyState {
let rksets = vec![
RouterKeySet {
as_id: 64496,
router_keys: vec![RouterKey {
ski: vec![0x41; 20],
spki_der: vec![0x30, 0x00],
}],
},
RouterKeySet {
as_id: 64497,
router_keys: vec![RouterKey {
ski: vec![0x42; 20],
spki_der: vec![0x30, 0x00],
}],
},
];
let der = encode_router_key_state_payload_der(&rksets).expect("encode rksets");
RouterKeyState {
rksets,
hash: compute_state_hash(&der),
}
}
#[test]
fn full_ccr_roundtrips_all_supported_state_aspects() {
let ccr = RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: sample_time(),
mfts: Some(sample_manifest_state()),
vrps: Some(sample_roa_state()),
vaps: Some(sample_aspa_state()),
tas: Some(sample_ta_state()),
rks: Some(sample_router_key_state()),
};
let encoded = encode_content_info(&CcrContentInfo::new(ccr.clone())).expect("encode full ccr");
let decoded = decode_content_info(&encoded).expect("decode full ccr");
assert_eq!(decoded.content, ccr);
}
#[test]
fn decode_rejects_wrong_digest_algorithm_oid() {
let ccr = CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: sample_time(),
mfts: None,
vrps: None,
vaps: None,
tas: Some(sample_ta_state()),
rks: None,
});
let mut der = encode_content_info(&ccr).expect("encode ccr");
let oid = rpki::data_model::oid::OID_SHA256_RAW;
let pos = der.windows(oid.len()).position(|w| w == oid).expect("sha256 oid present");
der[pos + oid.len() - 1] ^= 0x01;
let err = decode_content_info(&der).expect_err("decode must reject wrong digest oid");
assert!(err.to_string().contains("unexpected digest algorithm OID"), "{err}");
}
#[test]
fn decode_rejects_bad_generalized_time() {
let ccr = CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: sample_time(),
mfts: None,
vrps: None,
vaps: None,
tas: Some(sample_ta_state()),
rks: None,
});
let mut der = encode_content_info(&ccr).expect("encode ccr");
let pos = der.windows(15).position(|w| w == b"20260324000000Z").expect("time present");
der[pos + 14] = b'X';
let err = decode_content_info(&der).expect_err("bad time must fail");
assert!(err.to_string().contains("GeneralizedTime"), "{err}");
}
#[test]
fn manifest_state_validate_rejects_unsorted_subordinates() {
let mut state = sample_manifest_state();
state.mis[0].subordinates = vec![vec![0x40; 20], vec![0x30; 20]];
let err = state.validate().expect_err("unsorted subordinates must fail");
assert!(err.to_string().contains("subordinates"), "{err}");
}
#[test]
fn roa_payload_state_validate_rejects_duplicate_asn_sets() {
let state = RoaPayloadState {
rps: vec![
RoaPayloadSet { as_id: 64496, ip_addr_blocks: vec![vec![0x30, 0x00]] },
RoaPayloadSet { as_id: 64496, ip_addr_blocks: vec![vec![0x30, 0x00]] },
],
hash: vec![0u8; 32],
};
let err = state.validate().expect_err("duplicate roa sets must fail");
assert!(err.to_string().contains("ROAPayloadState.rps"), "{err}");
}
#[test]
fn aspa_payload_state_validate_rejects_unsorted_providers() {
let state = AspaPayloadState {
aps: vec![AspaPayloadSet { customer_as_id: 64496, providers: vec![64498, 64497] }],
hash: vec![0u8; 32],
};
let err = state.validate().expect_err("unsorted providers must fail");
assert!(err.to_string().contains("providers"), "{err}");
}
#[test]
fn trust_anchor_state_validate_rejects_invalid_ski_length() {
let state = TrustAnchorState {
skis: vec![vec![0x11; 19]],
hash: vec![0u8; 32],
};
let err = state.validate().expect_err("bad ski len must fail");
assert!(err.to_string().contains("TrustAnchorState.skis"), "{err}");
}
#[test]
fn router_key_state_validate_rejects_unsorted_router_keys() {
let state = RouterKeyState {
rksets: vec![RouterKeySet {
as_id: 64496,
router_keys: vec![
RouterKey { ski: vec![0x42; 20], spki_der: vec![0x30, 0x00] },
RouterKey { ski: vec![0x41; 20], spki_der: vec![0x30, 0x00] },
],
}],
hash: vec![0u8; 32],
};
let err = state.validate().expect_err("unsorted router keys must fail");
assert!(err.to_string().contains("router_keys"), "{err}");
}
#[test]
fn manifest_instance_validate_rejects_bad_location_tag() {
let instance = ManifestInstance {
hash: vec![0x10; 32],
size: 2048,
aki: vec![0x20; 20],
manifest_number: BigUnsigned { bytes_be: vec![1] },
this_update: sample_time(),
locations: vec![vec![0x04, 0x00]],
subordinates: vec![],
};
let err = instance.validate().expect_err("bad AccessDescription tag must fail");
assert!(err.to_string().contains("unexpected tag"), "{err}");
}

202
tests/test_ccr_m7.rs Normal file
View File

@@ -0,0 +1,202 @@
use rpki::ccr::{
CcrContentInfo, CcrDigestAlgorithm, ManifestInstance, ManifestState, RoaPayloadSet,
RoaPayloadState, RouterKey, RouterKeySet, RouterKeyState, TrustAnchorState,
compute_state_hash, decode_content_info, dump_content_info_json_value,
encode::{
encode_aspa_payload_state_payload_der, encode_content_info,
encode_manifest_state_payload_der, encode_roa_payload_state_payload_der,
encode_router_key_state_payload_der, encode_trust_anchor_state_payload_der,
},
verify::{verify_against_report_json_path, verify_against_vcir_store, verify_content_info_bytes},
};
use rpki::data_model::common::BigUnsigned;
use rpki::storage::{
PackTime, RocksStore, ValidatedCaInstanceResult, ValidatedManifestMeta,
VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, VcirAuditSummary,
VcirChildEntry, VcirInstanceGate, VcirRelatedArtifact, VcirSummary,
};
fn sample_time() -> time::OffsetDateTime {
time::OffsetDateTime::parse(
"2026-03-24T00:00:00Z",
&time::format_description::well_known::Rfc3339,
)
.expect("valid rfc3339")
}
fn sample_manifest_state() -> ManifestState {
let mis = vec![ManifestInstance {
hash: vec![0x10; 32],
size: 2048,
aki: vec![0x20; 20],
manifest_number: BigUnsigned { bytes_be: vec![1] },
this_update: sample_time(),
locations: vec![vec![0x30, 0x00]],
subordinates: vec![vec![0x30; 20]],
}];
let der = encode_manifest_state_payload_der(&mis).expect("encode mis");
ManifestState {
mis,
most_recent_update: sample_time(),
hash: compute_state_hash(&der),
}
}
fn sample_roa_state() -> RoaPayloadState {
let rps = vec![RoaPayloadSet {
as_id: 64496,
ip_addr_blocks: vec![vec![0x30, 0x08, 0x04, 0x02, 0x00, 0x01, 0x30, 0x02, 0x03, 0x00]],
}];
let der = encode_roa_payload_state_payload_der(&rps).expect("encode rps");
RoaPayloadState { rps, hash: compute_state_hash(&der) }
}
fn sample_aspa_state() -> rpki::ccr::AspaPayloadState {
let aps = vec![rpki::ccr::AspaPayloadSet { customer_as_id: 64496, providers: vec![64497] }];
let der = encode_aspa_payload_state_payload_der(&aps).expect("encode aps");
rpki::ccr::AspaPayloadState { aps, hash: compute_state_hash(&der) }
}
fn sample_ta_state() -> TrustAnchorState {
let skis = vec![vec![0x11; 20]];
let der = encode_trust_anchor_state_payload_der(&skis).expect("encode skis");
TrustAnchorState { skis, hash: compute_state_hash(&der) }
}
fn sample_rks() -> RouterKeyState {
let rksets = vec![RouterKeySet { as_id: 64496, router_keys: vec![RouterKey { ski: vec![0x22; 20], spki_der: vec![0x30, 0x00] }] }];
let der = encode_router_key_state_payload_der(&rksets).expect("encode rk");
RouterKeyState { rksets, hash: compute_state_hash(&der) }
}
fn sample_ccr() -> Vec<u8> {
let ccr = rpki::ccr::RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: sample_time(),
mfts: Some(sample_manifest_state()),
vrps: Some(sample_roa_state()),
vaps: Some(sample_aspa_state()),
tas: Some(sample_ta_state()),
rks: Some(sample_rks()),
};
encode_content_info(&CcrContentInfo::new(ccr)).expect("encode ccr")
}
#[test]
fn verify_content_info_bytes_succeeds_for_valid_ccr() {
let der = sample_ccr();
let summary = verify_content_info_bytes(&der).expect("verify ccr");
assert!(summary.state_hashes_ok);
assert_eq!(summary.manifest_instances, 1);
assert_eq!(summary.roa_payload_sets, 1);
assert_eq!(summary.aspa_payload_sets, 1);
assert_eq!(summary.trust_anchor_ski_count, 1);
assert_eq!(summary.router_key_sets, 1);
assert_eq!(summary.router_key_count, 1);
}
#[test]
fn verify_content_info_bytes_rejects_tampered_manifest_hash() {
let mut content_info = decode_content_info(&sample_ccr()).expect("decode ccr");
content_info.content.mfts.as_mut().unwrap().hash[0] ^= 0x01;
let der = encode_content_info(&content_info).expect("encode tampered ccr");
let err = verify_content_info_bytes(&der).expect_err("tampered hash must fail");
assert!(err.to_string().contains("ManifestState hash mismatch"), "{err}");
}
#[test]
fn dump_content_info_json_value_contains_expected_counts() {
let json = dump_content_info_json_value(&sample_ccr()).expect("dump ccr");
assert_eq!(json["version"], 0);
assert_eq!(json["state_aspects"]["mfts"]["manifest_instances"], 1);
assert_eq!(json["state_aspects"]["vrps"]["payload_sets"], 1);
assert_eq!(json["state_aspects"]["vaps"]["payload_sets"], 1);
assert_eq!(json["state_aspects"]["tas"]["ski_count"], 1);
assert_eq!(json["state_aspects"]["rks"]["router_key_sets"], 1);
}
#[test]
fn verify_against_report_json_path_rejects_mismatching_report() {
let td = tempfile::tempdir().expect("tempdir");
let report = serde_json::json!({
"vrps": [{"asn": 64496, "prefix": "0.0.0.0/0", "max_length": 0}],
"aspas": [{"customer_as_id": 64496, "provider_as_ids": [64497]}]
});
let report_path = td.path().join("report.json");
std::fs::write(&report_path, serde_json::to_vec(&report).unwrap()).expect("write report");
let mut ci = decode_content_info(&sample_ccr()).expect("decode ccr");
ci.content.vrps = Some(RoaPayloadState {
rps: vec![RoaPayloadSet { as_id: 64496, ip_addr_blocks: vec![vec![0x30, 0x08, 0x04, 0x02, 0x00, 0x01, 0x30, 0x02, 0x03, 0x00]] }],
hash: compute_state_hash(&encode_roa_payload_state_payload_der(&[RoaPayloadSet { as_id: 64496, ip_addr_blocks: vec![vec![0x30, 0x08, 0x04, 0x02, 0x00, 0x01, 0x30, 0x02, 0x03, 0x00]] }]).unwrap()),
});
verify_against_report_json_path(&ci, &report_path).expect_err("report mismatch expected");
}
#[test]
fn verify_against_vcir_store_matches_manifest_hashes() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let manifest_hash = hex::encode(vec![0x10; 32]);
let vcir = ValidatedCaInstanceResult {
manifest_rsync_uri: "rsync://example.test/current.mft".to_string(),
parent_manifest_rsync_uri: None,
tal_id: "apnic".to_string(),
ca_subject_name: "CN=test".to_string(),
ca_ski: "11".repeat(20),
issuer_ski: "22".repeat(20),
last_successful_validation_time: PackTime::from_utc_offset_datetime(sample_time()),
current_manifest_rsync_uri: "rsync://example.test/current.mft".to_string(),
current_crl_rsync_uri: "rsync://example.test/current.crl".to_string(),
validated_manifest_meta: ValidatedManifestMeta {
validated_manifest_number: vec![1],
validated_manifest_this_update: PackTime::from_utc_offset_datetime(sample_time()),
validated_manifest_next_update: PackTime::from_utc_offset_datetime(sample_time()),
},
instance_gate: VcirInstanceGate {
manifest_next_update: PackTime::from_utc_offset_datetime(sample_time()),
current_crl_next_update: PackTime::from_utc_offset_datetime(sample_time()),
self_ca_not_after: PackTime::from_utc_offset_datetime(sample_time()),
instance_effective_until: PackTime::from_utc_offset_datetime(sample_time()),
},
child_entries: vec![VcirChildEntry {
child_manifest_rsync_uri: "rsync://example.test/child.mft".to_string(),
child_cert_rsync_uri: "rsync://example.test/child.cer".to_string(),
child_cert_hash: "aa".repeat(32),
child_ski: "33".repeat(20),
child_rsync_base_uri: "rsync://example.test/".to_string(),
child_publication_point_rsync_uri: "rsync://example.test/".to_string(),
child_rrdp_notification_uri: None,
child_effective_ip_resources: None,
child_effective_as_resources: None,
accepted_at_validation_time: PackTime::from_utc_offset_datetime(sample_time()),
}],
local_outputs: Vec::new(),
related_artifacts: vec![VcirRelatedArtifact {
artifact_role: VcirArtifactRole::Manifest,
artifact_kind: VcirArtifactKind::Mft,
uri: Some("rsync://example.test/current.mft".to_string()),
sha256: manifest_hash.clone(),
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
}],
summary: VcirSummary {
local_vrp_count: 0,
local_aspa_count: 0,
local_router_key_count: 0,
child_count: 1,
accepted_object_count: 1,
rejected_object_count: 0,
},
audit_summary: VcirAuditSummary {
failed_fetch_eligible: true,
last_failed_fetch_reason: None,
warning_count: 0,
audit_flags: Vec::new(),
},
};
store.put_vcir(&vcir).expect("put vcir");
let ci = decode_content_info(&sample_ccr()).expect("decode ccr");
verify_against_vcir_store(&ci, &store).expect("vcir cross-check");
}

114
tests/test_ccr_tools_m7.rs Normal file
View File

@@ -0,0 +1,114 @@
use rpki::ccr::{
CcrContentInfo, CcrDigestAlgorithm, TrustAnchorState, compute_state_hash,
encode::{encode_content_info, encode_trust_anchor_state_payload_der},
};
use std::process::Command;
fn sample_ccr_file() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let skis = vec![vec![0x11; 20]];
let skis_der = encode_trust_anchor_state_payload_der(&skis).expect("encode skis");
let ccr = CcrContentInfo::new(rpki::ccr::RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: time::OffsetDateTime::parse(
"2026-03-24T00:00:00Z",
&time::format_description::well_known::Rfc3339,
)
.expect("time"),
mfts: None,
vrps: None,
vaps: None,
tas: Some(TrustAnchorState { skis, hash: compute_state_hash(&skis_der) }),
rks: None,
});
let path = dir.path().join("sample.ccr");
std::fs::write(&path, encode_content_info(&ccr).expect("encode ccr")).expect("write ccr");
(dir, path)
}
#[test]
fn ccr_dump_binary_prints_json_summary() {
let (_dir, ccr_path) = sample_ccr_file();
let bin = env!("CARGO_BIN_EXE_ccr_dump");
let out = Command::new(bin)
.args(["--ccr", ccr_path.to_string_lossy().as_ref()])
.output()
.expect("run ccr_dump");
assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr));
let json: serde_json::Value = serde_json::from_slice(&out.stdout).expect("parse json");
assert_eq!(json["version"], 0);
assert_eq!(json["state_aspects"]["tas"]["ski_count"], 1);
}
#[test]
fn ccr_verify_binary_prints_summary() {
let (_dir, ccr_path) = sample_ccr_file();
let bin = env!("CARGO_BIN_EXE_ccr_verify");
let out = Command::new(bin)
.args(["--ccr", ccr_path.to_string_lossy().as_ref()])
.output()
.expect("run ccr_verify");
assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr));
let json: serde_json::Value = serde_json::from_slice(&out.stdout).expect("parse json");
assert_eq!(json["version"], 0);
assert_eq!(json["trust_anchor_ski_count"], 1);
assert_eq!(json["state_hashes_ok"], true);
}
#[test]
fn ccr_to_routinator_csv_binary_writes_vrp_csv() {
use rpki::ccr::{
CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation,
build_roa_payload_state, encode::encode_content_info,
};
use rpki::validation::objects::Vrp;
use rpki::data_model::roa::{IpPrefix, RoaAfi};
let dir = tempfile::tempdir().expect("tempdir");
let ccr_path = dir.path().join("vrp.ccr");
let csv_path = dir.path().join("out.csv");
let vrps = vec![Vrp {
asn: 64496,
prefix: IpPrefix {
afi: RoaAfi::Ipv4,
prefix_len: 24,
addr: [203, 0, 113, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
max_length: 24,
}];
let roa_state = build_roa_payload_state(&vrps).expect("build roa state");
let ccr = CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: time::OffsetDateTime::parse(
"2026-03-25T00:00:00Z",
&time::format_description::well_known::Rfc3339,
)
.expect("time"),
mfts: None,
vrps: Some(roa_state),
vaps: None,
tas: None,
rks: None,
});
std::fs::write(&ccr_path, encode_content_info(&ccr).expect("encode ccr")).expect("write ccr");
let bin = env!("CARGO_BIN_EXE_ccr_to_routinator_csv");
let out = Command::new(bin)
.args([
"--ccr",
ccr_path.to_string_lossy().as_ref(),
"--out",
csv_path.to_string_lossy().as_ref(),
"--trust-anchor",
"apnic",
])
.output()
.expect("run ccr_to_routinator_csv");
assert!(out.status.success(), "stderr={}", String::from_utf8_lossy(&out.stderr));
let csv = std::fs::read_to_string(csv_path).expect("read csv");
assert!(csv.contains("ASN,IP Prefix,Max Length,Trust Anchor"));
assert!(csv.contains("AS64496,203.0.113.0/24,24,apnic"));
}

View File

@@ -1,9 +1,10 @@
#[test]
fn cli_run_offline_mode_executes_and_writes_json() {
fn cli_run_offline_mode_executes_and_writes_json_and_ccr() {
let db_dir = tempfile::tempdir().expect("db tempdir");
let repo_dir = tempfile::tempdir().expect("repo tempdir");
let out_dir = tempfile::tempdir().expect("out tempdir");
let report_path = out_dir.path().join("report.json");
let ccr_path = out_dir.path().join("result.ccr");
let policy_path = out_dir.path().join("policy.toml");
std::fs::write(&policy_path, "sync_preference = \"rsync_only\"\n").expect("write policy");
@@ -31,6 +32,8 @@ fn cli_run_offline_mode_executes_and_writes_json() {
"1".to_string(),
"--report-json".to_string(),
report_path.to_string_lossy().to_string(),
"--ccr-out".to_string(),
ccr_path.to_string_lossy().to_string(),
];
rpki::cli::run(&argv).expect("cli run");
@@ -39,3 +42,41 @@ fn cli_run_offline_mode_executes_and_writes_json() {
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("parse report json");
assert_eq!(v["format_version"], 2);
}
#[test]
fn cli_run_offline_mode_writes_decodable_ccr() {
let db_dir = tempfile::tempdir().expect("db tempdir");
let repo_dir = tempfile::tempdir().expect("repo tempdir");
let out_dir = tempfile::tempdir().expect("out tempdir");
let ccr_path = out_dir.path().join("result.ccr");
let tal_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/tal/apnic-rfc7730-https.tal");
let ta_path =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ta/apnic-ta.cer");
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
db_dir.path().to_string_lossy().to_string(),
"--tal-path".to_string(),
tal_path.to_string_lossy().to_string(),
"--ta-path".to_string(),
ta_path.to_string_lossy().to_string(),
"--rsync-local-dir".to_string(),
repo_dir.path().to_string_lossy().to_string(),
"--max-depth".to_string(),
"0".to_string(),
"--max-instances".to_string(),
"1".to_string(),
"--ccr-out".to_string(),
ccr_path.to_string_lossy().to_string(),
];
rpki::cli::run(&argv).expect("cli run");
let bytes = std::fs::read(&ccr_path).expect("read ccr");
let ccr = rpki::ccr::decode_content_info(&bytes).expect("decode ccr");
assert!(ccr.content.tas.is_some());
}

View File

@@ -98,6 +98,7 @@ fn store_validated_manifest_baseline(
summary: VcirSummary {
local_vrp_count: 0,
local_aspa_count: 0,
local_router_key_count: 0,
child_count: 0,
accepted_object_count: 1,
rejected_object_count: 0,

View File

@@ -101,6 +101,7 @@ fn store_validated_manifest_baseline(
summary: VcirSummary {
local_vrp_count: 0,
local_aspa_count: 0,
local_router_key_count: 0,
child_count: 0,
accepted_object_count: 1,
rejected_object_count: 0,

View File

@@ -0,0 +1,366 @@
use std::process::Command;
use rpki::data_model::rc::{ResourceCertificate, ResourceCertKind};
use rpki::data_model::router_cert::{
BgpsecRouterCertificate, BgpsecRouterCertificateDecodeError,
BgpsecRouterCertificatePathError, BgpsecRouterCertificateProfileError,
};
fn openssl_available() -> bool {
Command::new("openssl")
.arg("version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn run(cmd: &mut Command) {
let out = cmd.output().expect("run command");
if !out.status.success() {
panic!(
"command failed: {:?}\nstdout={}\nstderr={}",
cmd,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
}
struct Generated {
issuer_ca_der: Vec<u8>,
router_der: Vec<u8>,
issuer_crl_der: Vec<u8>,
wrong_issuer_der: Vec<u8>,
}
fn generate_router_cert_with_variant(
key_spec: &str,
include_eku: bool,
extra_ext: &str,
) -> Generated {
assert!(openssl_available(), "openssl is required for this test");
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path();
std::fs::create_dir_all(dir.join("newcerts")).expect("newcerts");
std::fs::write(dir.join("index.txt"), b"").expect("index");
std::fs::write(dir.join("serial"), b"1000\n").expect("serial");
std::fs::write(dir.join("crlnumber"), b"1000\n").expect("crlnumber");
let cnf = format!(
r#"
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = {dir}
database = $dir/index.txt
new_certs_dir = $dir/newcerts
certificate = $dir/issuer.pem
private_key = $dir/issuer.key
serial = $dir/serial
crlnumber = $dir/crlnumber
default_md = sha256
default_days = 365
default_crl_days = 1
policy = policy_any
x509_extensions = v3_issuer_ca
crl_extensions = crl_ext
unique_subject = no
copy_extensions = none
[ policy_any ]
commonName = supplied
[ req ]
prompt = no
distinguished_name = dn
[ dn ]
CN = Test Issuer CA
[ v3_issuer_ca ]
basicConstraints = critical,CA:true
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
certificatePolicies = critical, 1.3.6.1.5.5.7.14.2
subjectInfoAccess = caRepository;URI:rsync://example.test/repo/issuer/, rpkiManifest;URI:rsync://example.test/repo/issuer/issuer.mft, rpkiNotify;URI:https://example.test/notification.xml
sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/8
sbgp-autonomousSysNum = critical, AS:64496-64511
[ v3_router ]
keyUsage = critical, digitalSignature
{eku_line}
authorityKeyIdentifier = keyid:always
crlDistributionPoints = URI:rsync://example.test/repo/issuer/issuer.crl
authorityInfoAccess = caIssuers;URI:rsync://example.test/repo/issuer/issuer.cer
certificatePolicies = critical, 1.3.6.1.5.5.7.14.2
sbgp-autonomousSysNum = critical, AS:64496
{eku_line}
{extra_ext}
[ crl_ext ]
authorityKeyIdentifier = keyid:always
"#,
dir = dir.display(),
eku_line = if include_eku { "extendedKeyUsage = 1.3.6.1.5.5.7.3.30" } else { "" },
extra_ext = extra_ext
);
std::fs::write(dir.join("openssl.cnf"), cnf.as_bytes()).expect("write cnf");
run(Command::new("openssl")
.arg("genrsa")
.arg("-out")
.arg(dir.join("issuer.key"))
.arg("2048"));
run(Command::new("openssl")
.arg("req")
.arg("-new")
.arg("-x509")
.arg("-sha256")
.arg("-days")
.arg("365")
.arg("-key")
.arg(dir.join("issuer.key"))
.arg("-config")
.arg(dir.join("openssl.cnf"))
.arg("-extensions")
.arg("v3_issuer_ca")
.arg("-out")
.arg(dir.join("issuer.pem")));
match key_spec {
"ec-p256" => {
run(Command::new("openssl")
.arg("ecparam")
.arg("-name")
.arg("prime256v1")
.arg("-genkey")
.arg("-noout")
.arg("-out")
.arg(dir.join("router.key")));
}
"ec-p384" => {
run(Command::new("openssl")
.arg("ecparam")
.arg("-name")
.arg("secp384r1")
.arg("-genkey")
.arg("-noout")
.arg("-out")
.arg(dir.join("router.key")));
}
"rsa" => {
run(Command::new("openssl")
.arg("genrsa")
.arg("-out")
.arg(dir.join("router.key"))
.arg("2048"));
}
other => panic!("unsupported key_spec {other}"),
}
run(Command::new("openssl")
.arg("req")
.arg("-new")
.arg("-key")
.arg(dir.join("router.key"))
.arg("-subj")
.arg("/CN=ROUTER-0000FC10/serialNumber=01020304")
.arg("-out")
.arg(dir.join("router.csr")));
run(Command::new("openssl")
.arg("ca")
.arg("-batch")
.arg("-config")
.arg(dir.join("openssl.cnf"))
.arg("-in")
.arg(dir.join("router.csr"))
.arg("-extensions")
.arg("v3_router")
.arg("-out")
.arg(dir.join("router.pem")));
run(Command::new("openssl")
.arg("x509")
.arg("-in")
.arg(dir.join("issuer.pem"))
.arg("-outform")
.arg("DER")
.arg("-out")
.arg(dir.join("issuer.cer")));
run(Command::new("openssl")
.arg("x509")
.arg("-in")
.arg(dir.join("router.pem"))
.arg("-outform")
.arg("DER")
.arg("-out")
.arg(dir.join("router.cer")));
run(Command::new("openssl")
.arg("ca")
.arg("-gencrl")
.arg("-config")
.arg(dir.join("openssl.cnf"))
.arg("-out")
.arg(dir.join("issuer.crl.pem")));
run(Command::new("openssl")
.arg("crl")
.arg("-in")
.arg(dir.join("issuer.crl.pem"))
.arg("-outform")
.arg("DER")
.arg("-out")
.arg(dir.join("issuer.crl")));
// Wrong issuer for path failure.
run(Command::new("openssl")
.arg("genrsa")
.arg("-out")
.arg(dir.join("other.key"))
.arg("2048"));
run(Command::new("openssl")
.arg("req")
.arg("-new")
.arg("-x509")
.arg("-sha256")
.arg("-days")
.arg("365")
.arg("-key")
.arg(dir.join("other.key"))
.arg("-subj")
.arg("/CN=Other Issuer")
.arg("-out")
.arg(dir.join("other.pem")));
run(Command::new("openssl")
.arg("x509")
.arg("-in")
.arg(dir.join("other.pem"))
.arg("-outform")
.arg("DER")
.arg("-out")
.arg(dir.join("other.cer")));
Generated {
issuer_ca_der: std::fs::read(dir.join("issuer.cer")).expect("read issuer der"),
router_der: std::fs::read(dir.join("router.cer")).expect("read router der"),
issuer_crl_der: std::fs::read(dir.join("issuer.crl")).expect("read crl der"),
wrong_issuer_der: std::fs::read(dir.join("other.cer")).expect("read other issuer der"),
}
}
#[test]
fn decode_bgpsec_router_certificate_fixture_smoke() {
let g = generate_router_cert_with_variant("ec-p256", true, "");
let cert = BgpsecRouterCertificate::decode_der(&g.router_der).expect("decode router cert");
assert_eq!(cert.resource_cert.kind, ResourceCertKind::Ee);
assert_eq!(cert.asns, vec![64496]);
assert_eq!(cert.subject_key_identifier.len(), 20);
assert_eq!(cert.spki_der[0], 0x30);
}
#[test]
fn router_certificate_profile_rejects_missing_eku() {
let g = generate_router_cert_with_variant("ec-p256", false, "");
let err = BgpsecRouterCertificate::decode_der(&g.router_der).unwrap_err();
assert!(matches!(err, BgpsecRouterCertificateDecodeError::Validate(BgpsecRouterCertificateProfileError::MissingBgpsecRouterEku | BgpsecRouterCertificateProfileError::MissingExtendedKeyUsage)), "{err}");
}
#[test]
fn router_certificate_profile_rejects_sia_and_ip_resources_and_ranges() {
let g = generate_router_cert_with_variant(
"ec-p256",
true,
"subjectInfoAccess = caRepository;URI:rsync://example.test/repo/router/\n",
);
let err = BgpsecRouterCertificate::decode_der(&g.router_der).unwrap_err();
assert!(matches!(err, BgpsecRouterCertificateDecodeError::Validate(BgpsecRouterCertificateProfileError::SubjectInfoAccessPresent)), "{err}");
let g = generate_router_cert_with_variant(
"ec-p256",
true,
"sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/8\n",
);
let err = BgpsecRouterCertificate::decode_der(&g.router_der).unwrap_err();
assert!(matches!(err, BgpsecRouterCertificateDecodeError::Validate(BgpsecRouterCertificateProfileError::IpResourcesPresent)), "{err}");
let g = generate_router_cert_with_variant(
"ec-p256",
true,
"sbgp-autonomousSysNum = critical, AS:64496-64500\n",
);
let err = BgpsecRouterCertificate::decode_der(&g.router_der).unwrap_err();
assert!(matches!(err, BgpsecRouterCertificateDecodeError::Validate(BgpsecRouterCertificateProfileError::AsResourcesRangeNotAllowed)), "{err}");
}
#[test]
fn router_certificate_profile_rejects_wrong_spki_algorithm_or_curve() {
let g = generate_router_cert_with_variant("rsa", true, "");
let err = BgpsecRouterCertificate::decode_der(&g.router_der).unwrap_err();
assert!(matches!(err, BgpsecRouterCertificateDecodeError::Validate(BgpsecRouterCertificateProfileError::SpkiAlgorithmNotEcPublicKey)), "{err}");
let g = generate_router_cert_with_variant("ec-p384", true, "");
let err = BgpsecRouterCertificate::decode_der(&g.router_der).unwrap_err();
assert!(matches!(err, BgpsecRouterCertificateDecodeError::Validate(BgpsecRouterCertificateProfileError::SpkiWrongCurve | BgpsecRouterCertificateProfileError::SpkiEcPointNotUncompressedP256)), "{err}");
}
#[test]
fn router_certificate_path_validation_accepts_valid_and_rejects_wrong_issuer() {
use rpki::data_model::common::BigUnsigned;
use rpki::data_model::crl::RpkixCrl;
use std::collections::HashSet;
use x509_parser::prelude::FromDer;
use x509_parser::x509::SubjectPublicKeyInfo;
let g = generate_router_cert_with_variant("ec-p256", true, "");
let issuer = ResourceCertificate::decode_der(&g.issuer_ca_der).expect("decode issuer");
let wrong_issuer = ResourceCertificate::decode_der(&g.wrong_issuer_der).expect("decode wrong issuer");
let issuer_crl = RpkixCrl::decode_der(&g.issuer_crl_der).expect("decode crl");
let (rem, issuer_spki) = SubjectPublicKeyInfo::from_der(&issuer.tbs.subject_public_key_info).expect("issuer spki");
assert!(rem.is_empty());
let (rem, wrong_spki) = SubjectPublicKeyInfo::from_der(&wrong_issuer.tbs.subject_public_key_info).expect("wrong issuer spki");
assert!(rem.is_empty());
let now = time::OffsetDateTime::now_utc();
let cert = BgpsecRouterCertificate::validate_path_with_prevalidated_issuer(
&g.router_der,
&issuer,
&issuer_spki,
&issuer_crl,
&HashSet::new(),
Some("rsync://example.test/repo/issuer/issuer.cer"),
Some("rsync://example.test/repo/issuer/issuer.crl"),
now,
).expect("router path valid");
assert_eq!(cert.asns, vec![64496]);
let err = BgpsecRouterCertificate::validate_path_with_prevalidated_issuer(
&g.router_der,
&wrong_issuer,
&wrong_spki,
&issuer_crl,
&HashSet::new(),
None,
None,
now,
).unwrap_err();
assert!(matches!(err, BgpsecRouterCertificatePathError::CertPath(_)), "{err}");
let rc = ResourceCertificate::decode_der(&g.router_der).expect("decode router rc");
let mut revoked = HashSet::new();
revoked.insert(BigUnsigned::from_biguint(&rc.tbs.serial_number).bytes_be);
let err = BgpsecRouterCertificate::validate_path_with_prevalidated_issuer(
&g.router_der,
&issuer,
&issuer_spki,
&issuer_crl,
&revoked,
Some("rsync://example.test/repo/issuer/issuer.cer"),
Some("rsync://example.test/repo/issuer/issuer.crl"),
now,
).unwrap_err();
assert!(matches!(err, BgpsecRouterCertificatePathError::CertPath(_)), "{err}");
}

View File

@@ -112,6 +112,7 @@ fn tree_continues_when_a_publication_point_fails() {
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),
@@ -137,6 +138,7 @@ fn tree_continues_when_a_publication_point_fails() {
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),

View File

@@ -4,7 +4,7 @@ use rpki::audit::{DiscoveredFrom, PublicationPointAudit};
use rpki::report::Warning;
use rpki::storage::{PackFile, PackTime};
use rpki::validation::manifest::PublicationPointSource;
use rpki::validation::objects::{ObjectsOutput, ObjectsStats};
use rpki::validation::objects::{ObjectsOutput, ObjectsStats, RouterKeyPayload};
use rpki::validation::publication_point::PublicationPointSnapshot;
use rpki::validation::tree::{
CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner,
@@ -122,6 +122,7 @@ fn tree_enqueues_children_for_fresh_and_current_instance_vcir_results() {
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),
@@ -143,6 +144,7 @@ fn tree_enqueues_children_for_fresh_and_current_instance_vcir_results() {
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),
@@ -164,6 +166,7 @@ fn tree_enqueues_children_for_fresh_and_current_instance_vcir_results() {
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),
@@ -185,6 +188,7 @@ fn tree_enqueues_children_for_fresh_and_current_instance_vcir_results() {
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),
@@ -243,6 +247,7 @@ fn tree_respects_max_depth_and_max_instances() {
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),
@@ -264,6 +269,7 @@ fn tree_respects_max_depth_and_max_instances() {
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),
@@ -314,6 +320,7 @@ fn tree_audit_includes_parent_and_discovered_from_for_non_root_nodes() {
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),
@@ -335,6 +342,7 @@ fn tree_audit_includes_parent_and_discovered_from_for_non_root_nodes() {
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),
@@ -366,6 +374,57 @@ fn tree_audit_includes_parent_and_discovered_from_for_non_root_nodes() {
assert_eq!(df.parent_manifest_rsync_uri, root_manifest);
}
#[test]
fn tree_aggregates_router_keys_from_publication_point_results() {
let root_manifest = "rsync://example.test/repo/root.mft";
let runner = MockRunner::default().with(
root_manifest,
PublicationPointRunResult {
source: PublicationPointSource::Fresh,
snapshot: Some(empty_snapshot(root_manifest, "rsync://example.test/repo/")),
warnings: Vec::new(),
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: vec![
RouterKeyPayload {
as_id: 64496,
ski: vec![0x11; 20],
spki_der: vec![0x30, 0x00],
source_object_uri: "rsync://example.test/repo/router1.cer".to_string(),
source_object_hash: "11".repeat(32),
source_ee_cert_hash: "11".repeat(32),
item_effective_until: PackTime { rfc3339_utc: "2026-12-31T00:00:00Z".to_string() },
},
RouterKeyPayload {
as_id: 64497,
ski: vec![0x22; 20],
spki_der: vec![0x30, 0x01],
source_object_uri: "rsync://example.test/repo/router2.cer".to_string(),
source_object_hash: "22".repeat(32),
source_ee_cert_hash: "22".repeat(32),
item_effective_until: PackTime { rfc3339_utc: "2026-12-31T00:00:00Z".to_string() },
},
],
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),
audit: Vec::new(),
},
audit: PublicationPointAudit::default(),
discovered_children: Vec::new(),
},
);
let out = run_tree_serial(ca_handle(root_manifest), &runner, &TreeRunConfig::default())
.expect("run tree");
assert_eq!(out.router_keys.len(), 2);
assert_eq!(out.router_keys[0].as_id, 64496);
assert_eq!(out.router_keys[1].as_id, 64497);
}
#[test]
fn tree_prefers_lexicographically_first_discovery_when_duplicate_manifest_is_queued() {
let root_manifest = "rsync://example.test/repo/root.mft";
@@ -388,6 +447,7 @@ fn tree_prefers_lexicographically_first_discovery_when_duplicate_manifest_is_que
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),
@@ -409,6 +469,7 @@ fn tree_prefers_lexicographically_first_discovery_when_duplicate_manifest_is_que
objects: ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: ObjectsStats::default(),