From 752e746b9755214a31111130e9910be8689c2800 Mon Sep 17 00:00:00 2001 From: yuyr Date: Wed, 6 May 2026 20:53:57 +0800 Subject: [PATCH] =?UTF-8?q?20260506=5F2=20=E4=B8=BACIR=E5=A2=9E=E5=8A=A0re?= =?UTF-8?q?ject=20list=E5=9F=BA=E7=A1=80=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bin/cir_dump_reject_list.rs | 95 +++++++++++++ src/bin/cir_ta_only_fixture.rs | 7 +- src/cir/decode.rs | 52 ++++++- src/cir/encode.rs | 21 ++- src/cir/export.rs | 158 +++++++++++++++++++++- src/cir/materialize.rs | 29 +++- src/cir/mod.rs | 80 +++++++++-- src/cir/model.rs | 60 +++++++- tests/test_cir_delta_export_m1.rs | 11 +- tests/test_cir_drop_report_m5.rs | 7 +- tests/test_cir_matrix_m9.rs | 8 +- tests/test_cir_peer_replay_m8.rs | 8 +- tests/test_cir_sequence_m2.rs | 11 +- tests/test_cir_sequence_peer_replay_m4.rs | 8 +- tests/test_cir_sequence_replay_m3.rs | 8 +- 15 files changed, 510 insertions(+), 53 deletions(-) create mode 100644 src/bin/cir_dump_reject_list.rs diff --git a/src/bin/cir_dump_reject_list.rs b/src/bin/cir_dump_reject_list.rs new file mode 100644 index 0000000..09f0e97 --- /dev/null +++ b/src/bin/cir_dump_reject_list.rs @@ -0,0 +1,95 @@ +use std::path::PathBuf; + +#[derive(Debug, Default, PartialEq, Eq)] +struct Args { + cir_path: Option, + limit: usize, +} + +fn usage() -> &'static str { + "Usage: cir_dump_reject_list --cir [--limit ]" +} + +fn parse_args(argv: &[String]) -> Result { + let mut args = Args { + cir_path: None, + limit: 10, + }; + let mut index = 1usize; + while index < argv.len() { + match argv[index].as_str() { + "--cir" => { + index += 1; + let value = argv.get(index).ok_or("--cir requires a value")?; + args.cir_path = Some(PathBuf::from(value)); + } + "--limit" => { + index += 1; + let value = argv.get(index).ok_or("--limit requires a value")?; + args.limit = value + .parse::() + .map_err(|_| format!("invalid --limit: {value}"))?; + } + "-h" | "--help" => return Err(usage().to_string()), + other => return Err(format!("unknown argument: {other}\n{}", usage())), + } + index += 1; + } + if args.cir_path.is_none() { + return Err(format!("--cir is required\n{}", usage())); + } + Ok(args) +} + +fn main() { + if let Err(err) = real_main() { + eprintln!("{err}"); + std::process::exit(1); + } +} + +fn real_main() -> Result<(), String> { + let argv: Vec = std::env::args().collect(); + let args = parse_args(&argv)?; + let cir_path = args.cir_path.expect("validated"); + let bytes = std::fs::read(&cir_path) + .map_err(|e| format!("read cir failed: {}: {e}", cir_path.display()))?; + let cir = rpki::cir::decode_cir(&bytes).map_err(|e| format!("decode cir failed: {e}"))?; + + println!("reject_list_sha256={}", hex::encode(&cir.reject_list_sha256)); + println!("reject_count={}", cir.rejected_objects.len()); + for (index, item) in cir.rejected_objects.iter().take(args.limit).enumerate() { + println!( + "{:04} uri={} reason={}", + index + 1, + item.object_uri, + item.reason.as_deref().unwrap_or("") + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_requires_cir_path() { + let err = parse_args(&["cir_dump_reject_list".to_string()]).expect_err("missing cir"); + assert!(err.contains("--cir is required"), "{err}"); + } + + #[test] + fn parse_accepts_limit() { + let args = parse_args(&[ + "cir_dump_reject_list".to_string(), + "--cir".to_string(), + "input.cir".to_string(), + "--limit".to_string(), + "5".to_string(), + ]) + .expect("parse args"); + assert_eq!(args.cir_path.as_deref(), Some(std::path::Path::new("input.cir"))); + assert_eq!(args.limit, 5); + } +} diff --git a/src/bin/cir_ta_only_fixture.rs b/src/bin/cir_ta_only_fixture.rs index 45a9c47..c51d5ca 100644 --- a/src/bin/cir_ta_only_fixture.rs +++ b/src/bin/cir_ta_only_fixture.rs @@ -2,7 +2,8 @@ use std::path::PathBuf; use rpki::blob_store::ExternalRepoBytesDb; use rpki::cir::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, encode_cir, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + compute_reject_list_sha256, encode_cir, }; use sha2::Digest; @@ -108,7 +109,7 @@ fn main() -> Result<(), String> { .map_err(|e| format!("write repo bytes db failed: {e}"))?; let cir = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time, objects: vec![CirObject { @@ -116,6 +117,8 @@ fn main() -> Result<(), String> { sha256: sha.to_vec(), }], tals: vec![CirTal { tal_uri, tal_bytes }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let der = encode_cir(&cir).map_err(|e| format!("encode cir failed: {e}"))?; if let Some(parent) = cir_out.parent() { diff --git a/src/cir/decode.rs b/src/cir/decode.rs index b03b076..47bd5d2 100644 --- a/src/cir/decode.rs +++ b/src/cir/decode.rs @@ -1,5 +1,6 @@ use crate::cir::model::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirRejectedObject, + CirTal, }; use crate::data_model::common::DerReader; use crate::data_model::oid::{OID_SHA256, OID_SHA256_RAW}; @@ -31,9 +32,9 @@ pub fn decode_cir(der: &[u8]) -> Result Result Result Result { Ok(CirTal { tal_uri, tal_bytes }) } +fn decode_rejected_object(der: &[u8]) -> Result { + let mut top = DerReader::new(der); + let mut seq = top.take_sequence().map_err(CirDecodeError::Parse)?; + if !top.is_empty() { + return Err(CirDecodeError::Parse( + "trailing bytes after CirRejectedObject".into(), + )); + } + let object_uri = std::str::from_utf8(seq.take_tag(0x16).map_err(CirDecodeError::Parse)?) + .map_err(|e| CirDecodeError::Parse(e.to_string()))? + .to_string(); + let reason = if seq.is_empty() { + None + } else { + Some( + std::str::from_utf8(seq.take_octet_string().map_err(CirDecodeError::Parse)?) + .map_err(|e| CirDecodeError::Parse(e.to_string()))? + .to_string(), + ) + }; + if !seq.is_empty() { + return Err(CirDecodeError::Parse( + "trailing fields in CirRejectedObject".into(), + )); + } + Ok(CirRejectedObject { object_uri, reason }) +} + fn oid_string(raw_body: &[u8]) -> Result { let der = { let mut out = Vec::with_capacity(raw_body.len() + 2); diff --git a/src/cir/encode.rs b/src/cir/encode.rs index 44da304..5fefd46 100644 --- a/src/cir/encode.rs +++ b/src/cir/encode.rs @@ -1,5 +1,6 @@ use crate::cir::model::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirRejectedObject, + CirTal, }; use crate::data_model::oid::OID_SHA256_RAW; @@ -29,6 +30,13 @@ pub fn encode_cir(cir: &CanonicalInputRepresentation) -> Result, CirEnco .map(encode_tal) .collect::, _>>()?, ), + encode_octet_string(&cir.reject_list_sha256), + encode_sequence( + &cir.rejected_objects + .iter() + .map(encode_rejected_object) + .collect::, _>>()?, + ), ])) } @@ -48,6 +56,15 @@ fn encode_tal(tal: &CirTal) -> Result, CirEncodeError> { ])) } +fn encode_rejected_object(item: &CirRejectedObject) -> Result, CirEncodeError> { + item.validate().map_err(CirEncodeError::Validate)?; + let mut fields = vec![encode_ia5_string(item.object_uri.as_bytes())]; + if let Some(reason) = &item.reason { + fields.push(encode_octet_string(reason.as_bytes())); + } + Ok(encode_sequence(&fields)) +} + fn encode_generalized_time(t: time::OffsetDateTime) -> Vec { let t = t.to_offset(time::UtcOffset::UTC); let s = format!( @@ -143,5 +160,5 @@ fn encode_len_into(len: usize, out: &mut Vec) { #[allow(dead_code)] const _: () = { - let _ = CIR_VERSION_V1; + let _ = CIR_VERSION_V2; }; diff --git a/src/cir/export.rs b/src/cir/export.rs index 4770bd9..59711d4 100644 --- a/src/cir/export.rs +++ b/src/cir/export.rs @@ -5,7 +5,8 @@ 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, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirRejectedObject, + CirTal, compute_reject_list_sha256, }; use crate::cir::static_pool::{ CirStaticPoolError, CirStaticPoolExportSummary, export_hashes_from_store, @@ -181,7 +182,7 @@ pub fn build_cir_from_run_multi( tals.sort_by(|a, b| a.tal_uri.cmp(&b.tal_uri)); let cir = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: validation_time.to_offset(time::UtcOffset::UTC), objects: objects @@ -192,6 +193,35 @@ pub fn build_cir_from_run_multi( }) .collect(), tals, + reject_list_sha256: Vec::new(), + rejected_objects: Vec::new(), + }; + let object_uri_set = cir + .objects + .iter() + .map(|item| item.rsync_uri.as_str()) + .collect::>(); + let mut rejected_objects = publication_points + .iter() + .flat_map(|pp| pp.objects.iter()) + .filter(|item| { + item.result == AuditObjectResult::Error + && object_uri_set.contains(item.rsync_uri.as_str()) + }) + .map(|item| CirRejectedObject { + object_uri: item.rsync_uri.clone(), + reason: item.detail.clone(), + }) + .collect::>(); + rejected_objects.sort_by(|a, b| a.object_uri.cmp(&b.object_uri)); + rejected_objects.dedup_by(|a, b| a.object_uri == b.object_uri); + + let cir = CanonicalInputRepresentation { + reject_list_sha256: compute_reject_list_sha256( + rejected_objects.iter().map(|item| item.object_uri.as_str()), + ), + rejected_objects, + ..cir }; cir.validate().map_err(CirExportError::Validate)?; Ok(cir) @@ -464,7 +494,7 @@ mod tests { &[], ) .expect("build cir"); - assert_eq!(cir.version, CIR_VERSION_V1); + assert_eq!(cir.version, CIR_VERSION_V2); assert_eq!(cir.tals.len(), 1); assert_eq!(cir.tals[0].tal_uri, "https://example.test/root.tal"); assert!( @@ -738,6 +768,128 @@ mod tests { ); } + #[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 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.asa".to_string(), + current_hash_hex: "22".repeat(32), + repository_source: "https://rrdp.example.test/notification.xml".to_string(), + object_type: Some("aspa".to_string()), + }, + ]; + 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("not in cir object set".to_string()), + }, + ], + ..PublicationPointAudit::default() + }]; + + let cir = build_cir_from_run_multi( + &store, + &[CirTalBinding { + trust_anchor: &ta, + tal_uri: "https://example.test/root.tal", + }], + sample_time(), + &publication_points, + Some(¤t_repo_objects), + ) + .expect("build cir"); + + assert_eq!(cir.rejected_objects.len(), 1); + 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") + ); + } + + #[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 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()), + }]; + + 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, + &[CirTalBinding { + trust_anchor: &ta, + tal_uri: "https://example.test/root.tal", + }], + sample_time(), + &[mk_pp("reason-a")], + Some(¤t_repo_objects), + ) + .expect("build cir a"); + let cir_b = build_cir_from_run_multi( + &store, + &[CirTalBinding { + trust_anchor: &ta, + tal_uri: "https://example.test/root.tal", + }], + sample_time(), + &[mk_pp("reason-b")], + Some(¤t_repo_objects), + ) + .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(); diff --git a/src/cir/materialize.rs b/src/cir/materialize.rs index 72b103f..02d4aac 100644 --- a/src/cir/materialize.rs +++ b/src/cir/materialize.rs @@ -415,7 +415,8 @@ mod tests { }; use crate::blob_store::{ExternalRawStoreDb, ExternalRepoBytesDb}; use crate::cir::model::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, + CirRejectedObject, CirTal, compute_reject_list_sha256, }; use sha2::Digest; use std::path::{Path, PathBuf}; @@ -429,8 +430,12 @@ mod tests { } fn sample_cir() -> CanonicalInputRepresentation { + let rejected_objects = vec![CirRejectedObject { + object_uri: "rsync://example.net/repo/rejected-a.roa".to_string(), + reason: Some("invalid roa".to_string()), + }]; CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: vec![ @@ -453,12 +458,16 @@ mod tests { tal_uri: "https://tal.example.net/root.tal".to_string(), tal_bytes: b"x".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256( + rejected_objects.iter().map(|item| item.object_uri.as_str()), + ), + rejected_objects, } } fn cir_with_real_hashes(a: &[u8], b: &[u8]) -> CanonicalInputRepresentation { CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: vec![ @@ -475,6 +484,8 @@ mod tests { tal_uri: "https://tal.example.net/root.tal".to_string(), tal_bytes: b"x".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), } } @@ -618,7 +629,7 @@ mod tests { let a = b"a".to_vec(); let b = b"b".to_vec(); let cir = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: vec![ @@ -635,6 +646,8 @@ mod tests { tal_uri: "https://tal.example.net/root.tal".to_string(), tal_bytes: b"x".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; { @@ -739,7 +752,7 @@ mod tests { let raw_store_path = td.path().join("raw-store.db"); let mirror_root = td.path().join("mirror"); let cir = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: vec![CirObject { @@ -753,6 +766,8 @@ mod tests { tal_uri: "https://tal.example.net/root.tal".to_string(), tal_bytes: b"x".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; { let raw_store = ExternalRawStoreDb::open(&raw_store_path).unwrap(); @@ -789,7 +804,7 @@ mod tests { let a = b"a".to_vec(); let b = b"b".to_vec(); let cir = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: vec![ @@ -806,6 +821,8 @@ mod tests { tal_uri: "https://tal.example.net/root.tal".to_string(), tal_bytes: b"x".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; { diff --git a/src/cir/mod.rs b/src/cir/mod.rs index 6e47a50..4e7b51e 100644 --- a/src/cir/mod.rs +++ b/src/cir/mod.rs @@ -20,7 +20,8 @@ pub use materialize::{ materialize_cir_from_repo_bytes, mirror_relative_path_for_rsync_uri, resolve_static_pool_file, }; pub use model::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + CIR_VERSION_V1, CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, + CirRejectedObject, CirTal, compute_reject_list_sha256, }; pub use sequence::{CirSequenceManifest, CirSequenceStep, CirSequenceStepKind}; #[cfg(feature = "full")] @@ -33,8 +34,8 @@ pub use static_pool::{ #[cfg(test)] mod tests { use super::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, - decode_cir, encode_cir, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, + CirRejectedObject, CirTal, compute_reject_list_sha256, decode_cir, encode_cir, }; fn sample_time() -> time::OffsetDateTime { @@ -46,8 +47,18 @@ mod tests { } fn sample_cir() -> CanonicalInputRepresentation { + let rejected_objects = vec![ + CirRejectedObject { + object_uri: "rsync://example.net/repo/rejected-a.roa".to_string(), + reason: Some("invalid roa".to_string()), + }, + CirRejectedObject { + object_uri: "rsync://example.net/repo/rejected-b.asa".to_string(), + reason: None, + }, + ]; CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: vec![ @@ -65,6 +76,10 @@ mod tests { tal_bytes: b"https://tal.example.net/ta.cer\nrsync://example.net/repo/ta.cer\nMIIB" .to_vec(), }], + reject_list_sha256: compute_reject_list_sha256( + rejected_objects.iter().map(|item| item.object_uri.as_str()), + ), + rejected_objects, } } @@ -99,7 +114,7 @@ mod tests { #[test] fn cir_roundtrip_minimal_succeeds() { let cir = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: Vec::new(), @@ -107,6 +122,8 @@ mod tests { tal_uri: "https://tal.example.net/minimal.tal".to_string(), tal_bytes: b"rsync://example.net/repo/ta.cer\nMIIB".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let der = encode_cir(&cir).expect("encode minimal cir"); let decoded = decode_cir(&der).expect("decode minimal cir"); @@ -116,7 +133,7 @@ mod tests { #[test] fn cir_model_rejects_unsorted_duplicate_objects() { let cir = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: vec![ @@ -133,6 +150,8 @@ mod tests { tal_uri: "https://tal.example.net/root.tal".to_string(), tal_bytes: b"x".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let err = encode_cir(&cir).expect_err("unsorted objects must fail"); assert!(err.to_string().contains("CIR.objects"), "{err}"); @@ -141,7 +160,7 @@ mod tests { #[test] fn cir_model_rejects_duplicate_tals() { let cir = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: Vec::new(), @@ -155,6 +174,8 @@ mod tests { tal_bytes: b"b".to_vec(), }, ], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let err = encode_cir(&cir).expect_err("duplicate tals must fail"); assert!(err.to_string().contains("CIR.tals"), "{err}"); @@ -165,9 +186,13 @@ mod tests { let mut der = encode_cir(&sample_cir()).expect("encode cir"); let pos = der .windows(3) - .position(|window| window == [0x02, 0x01, CIR_VERSION_V1 as u8]) + .position(|window| window == [0x02, 0x01, CIR_VERSION_V2 as u8]) + .or_else(|| { + der.windows(3) + .position(|window| window == [0x02, 0x01, CIR_VERSION_V2 as u8]) + }) .expect("find version integer"); - der[pos + 2] = 2; + der[pos + 2] = 3; let err = decode_cir(&der).expect_err("wrong version must fail"); assert!(err.to_string().contains("unexpected CIR version"), "{err}"); } @@ -203,7 +228,7 @@ mod tests { #[test] fn cir_model_rejects_non_rsync_object_uri_and_empty_tals() { let bad_object = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: vec![CirObject { @@ -214,16 +239,20 @@ mod tests { tal_uri: "https://tal.example.net/root.tal".to_string(), tal_bytes: b"x".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let err = encode_cir(&bad_object).expect_err("non-rsync object uri must fail"); assert!(err.to_string().contains("rsync://"), "{err}"); let no_tals = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: Vec::new(), tals: Vec::new(), + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let err = encode_cir(&no_tals).expect_err("empty tals must fail"); assert!( @@ -235,7 +264,7 @@ mod tests { #[test] fn cir_model_rejects_non_utc_time_bad_hash_len_and_non_http_tal_uri() { let bad_time = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time().to_offset(time::UtcOffset::from_hms(8, 0, 0).unwrap()), objects: Vec::new(), @@ -243,12 +272,14 @@ mod tests { tal_uri: "https://tal.example.net/root.tal".to_string(), tal_bytes: b"x".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let err = encode_cir(&bad_time).expect_err("non-utc validation time must fail"); assert!(err.to_string().contains("UTC"), "{err}"); let bad_hash = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: vec![CirObject { @@ -259,12 +290,14 @@ mod tests { tal_uri: "https://tal.example.net/root.tal".to_string(), tal_bytes: b"x".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let err = encode_cir(&bad_hash).expect_err("bad digest len must fail"); assert!(err.to_string().contains("32 bytes"), "{err}"); let bad_tal_uri = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: sample_time(), objects: Vec::new(), @@ -272,6 +305,8 @@ mod tests { tal_uri: "ftp://tal.example.net/root.tal".to_string(), tal_bytes: b"x".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let err = encode_cir(&bad_tal_uri).expect_err("bad tal uri must fail"); assert!(err.to_string().contains("http:// or https://"), "{err}"); @@ -308,11 +343,13 @@ mod tests { let bad = test_encode_tlv( 0x30, &[ - test_encode_tlv(0x02, &[CIR_VERSION_V1 as u8]), + test_encode_tlv(0x02, &[CIR_VERSION_V2 as u8]), test_encode_tlv(0x06, crate::data_model::oid::OID_SHA256_RAW), test_encode_tlv(0x18, b"20260407123456Z"), test_encode_tlv(0x30, &object), test_encode_tlv(0x30, &tal), + test_encode_tlv(0x04, &[0x33; 32]), + test_encode_tlv(0x30, &[]), ] .concat(), ); @@ -347,4 +384,17 @@ mod tests { let err = decode_cir(&der).expect_err("invalid utf8 tal uri must fail"); assert!(err.to_string().contains("utf-8"), "{err}"); } + + #[test] + fn cir_model_rejects_unsorted_rejected_objects_and_bad_digest() { + let mut cir = sample_cir(); + cir.rejected_objects.swap(0, 1); + let err = encode_cir(&cir).expect_err("unsorted rejected objects must fail"); + assert!(err.to_string().contains("CIR.rejectedObjects"), "{err}"); + + let mut cir = sample_cir(); + cir.reject_list_sha256 = vec![0x55; 32]; + let err = encode_cir(&cir).expect_err("bad reject list digest must fail"); + assert!(err.to_string().contains("rejectListSha256"), "{err}"); + } } diff --git a/src/cir/model.rs b/src/cir/model.rs index 67def12..47ba11e 100644 --- a/src/cir/model.rs +++ b/src/cir/model.rs @@ -1,6 +1,7 @@ use crate::data_model::oid::OID_SHA256; pub const CIR_VERSION_V1: u32 = 1; +pub const CIR_VERSION_V2: u32 = 2; pub const DIGEST_LEN_SHA256: usize = 32; #[derive(Clone, Debug, PartialEq, Eq)] @@ -23,13 +24,15 @@ pub struct CanonicalInputRepresentation { pub validation_time: time::OffsetDateTime, pub objects: Vec, pub tals: Vec, + pub reject_list_sha256: Vec, + pub rejected_objects: Vec, } impl CanonicalInputRepresentation { pub fn validate(&self) -> Result<(), String> { - if self.version != CIR_VERSION_V1 { + if self.version != CIR_VERSION_V2 { return Err(format!( - "CIR version must be {CIR_VERSION_V1}, got {}", + "CIR version must be {CIR_VERSION_V2}, got {}", self.version )); } @@ -47,15 +50,38 @@ impl CanonicalInputRepresentation { self.tals.iter().map(|item| item.tal_uri.as_str()), "CIR.tals must be sorted by talUri and unique", )?; + validate_sorted_unique_strings( + self.rejected_objects + .iter() + .map(|item| item.object_uri.as_str()), + "CIR.rejectedObjects must be sorted by objectUri and unique", + )?; if self.tals.is_empty() { return Err("CIR.tals must be non-empty".into()); } + if self.reject_list_sha256.len() != DIGEST_LEN_SHA256 { + return Err(format!( + "CIR.rejectListSha256 must be {DIGEST_LEN_SHA256} bytes, got {}", + self.reject_list_sha256.len() + )); + } for object in &self.objects { object.validate()?; } for tal in &self.tals { tal.validate()?; } + for item in &self.rejected_objects { + item.validate()?; + } + let expected_digest = compute_reject_list_sha256( + self.rejected_objects + .iter() + .map(|item| item.object_uri.as_str()), + ); + if self.reject_list_sha256 != expected_digest { + return Err("CIR.rejectListSha256 does not match rejectedObjects".into()); + } Ok(()) } } @@ -105,6 +131,36 @@ impl CirTal { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CirRejectedObject { + pub object_uri: String, + pub reason: Option, +} + +impl CirRejectedObject { + pub fn validate(&self) -> Result<(), String> { + if !self.object_uri.starts_with("rsync://") { + return Err(format!( + "CirRejectedObject.object_uri must start with rsync://, got {}", + self.object_uri + )); + } + Ok(()) + } +} + +pub fn compute_reject_list_sha256<'a>(uris: impl IntoIterator) -> Vec { + use sha2::Digest; + + let mut body = Vec::new(); + for uri in uris { + let bytes = uri.as_bytes(); + body.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + body.extend_from_slice(bytes); + } + sha2::Sha256::digest(body).to_vec() +} + fn validate_sorted_unique_strings<'a>( items: impl IntoIterator, message: &str, diff --git a/tests/test_cir_delta_export_m1.rs b/tests/test_cir_delta_export_m1.rs index 4973491..1b70980 100644 --- a/tests/test_cir_delta_export_m1.rs +++ b/tests/test_cir_delta_export_m1.rs @@ -6,7 +6,8 @@ use rpki::ccr::{ encode_content_info, }; use rpki::cir::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, encode_cir, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + compute_reject_list_sha256, encode_cir, }; fn skip_heavy_blackbox_test() -> bool { @@ -43,7 +44,7 @@ fn cir_full_and_delta_pair_reuses_shared_repo_bytes_db() { }; let full_cir = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: time::OffsetDateTime::parse( "2026-03-16T11:49:15Z", @@ -58,9 +59,11 @@ fn cir_full_and_delta_pair_reuses_shared_repo_bytes_db() { tal_uri: "https://rpki.apnic.net/tal/apnic-rfc7730-https.tal".to_string(), tal_bytes: b"rsync://example.net/repo/root.cer\nMIIB".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let delta_cir = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: time::OffsetDateTime::parse( "2026-03-16T11:50:15Z", @@ -82,6 +85,8 @@ fn cir_full_and_delta_pair_reuses_shared_repo_bytes_db() { objects }, tals: full_cir.tals.clone(), + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let empty_ccr = CcrContentInfo::new(RpkiCanonicalCacheRepresentation { version: 0, diff --git a/tests/test_cir_drop_report_m5.rs b/tests/test_cir_drop_report_m5.rs index 582529c..e18913b 100644 --- a/tests/test_cir_drop_report_m5.rs +++ b/tests/test_cir_drop_report_m5.rs @@ -7,7 +7,8 @@ use rpki::ccr::{ encode_content_info, }; use rpki::cir::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, encode_cir, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + compute_reject_list_sha256, encode_cir, }; #[test] @@ -33,7 +34,7 @@ fn cir_drop_report_counts_dropped_roa_objects_and_vrps() { .expect("write repo bytes"); let cir = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: time::OffsetDateTime::parse( "2026-04-09T00:00:00Z", @@ -48,6 +49,8 @@ fn cir_drop_report_counts_dropped_roa_objects_and_vrps() { tal_uri: "https://example.test/root.tal".to_string(), tal_bytes: b"rsync://example.net/repo/root.cer\nMIIB".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; std::fs::write(&cir_path, encode_cir(&cir).unwrap()).unwrap(); diff --git a/tests/test_cir_matrix_m9.rs b/tests/test_cir_matrix_m9.rs index d047696..dc4a0ae 100644 --- a/tests/test_cir_matrix_m9.rs +++ b/tests/test_cir_matrix_m9.rs @@ -3,8 +3,8 @@ use std::process::Command; use rpki::blob_store::ExternalRepoBytesDb; use rpki::cir::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, encode_cir, - materialize_cir_from_repo_bytes, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + compute_reject_list_sha256, encode_cir, materialize_cir_from_repo_bytes, }; fn skip_heavy_script_replay_test() -> bool { @@ -36,7 +36,7 @@ fn build_ta_only_cir() -> (CanonicalInputRepresentation, Vec) { }; ( CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: time::OffsetDateTime::parse( "2026-04-07T00:00:00Z", @@ -51,6 +51,8 @@ fn build_ta_only_cir() -> (CanonicalInputRepresentation, Vec) { tal_uri: "https://example.test/root.tal".to_string(), tal_bytes, }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }, ta_bytes, ) diff --git a/tests/test_cir_peer_replay_m8.rs b/tests/test_cir_peer_replay_m8.rs index 1b49fd4..5ce7ef4 100644 --- a/tests/test_cir_peer_replay_m8.rs +++ b/tests/test_cir_peer_replay_m8.rs @@ -3,8 +3,8 @@ use std::process::Command; use rpki::blob_store::ExternalRepoBytesDb; use rpki::cir::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, encode_cir, - materialize_cir_from_repo_bytes, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + compute_reject_list_sha256, encode_cir, materialize_cir_from_repo_bytes, }; fn skip_heavy_script_replay_test() -> bool { @@ -36,7 +36,7 @@ fn build_ta_only_cir() -> (CanonicalInputRepresentation, Vec) { }; ( CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: time::OffsetDateTime::parse( "2026-04-07T00:00:00Z", @@ -51,6 +51,8 @@ fn build_ta_only_cir() -> (CanonicalInputRepresentation, Vec) { tal_uri: "https://example.test/root.tal".to_string(), tal_bytes, }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }, ta_bytes, ) diff --git a/tests/test_cir_sequence_m2.rs b/tests/test_cir_sequence_m2.rs index c459331..f591c20 100644 --- a/tests/test_cir_sequence_m2.rs +++ b/tests/test_cir_sequence_m2.rs @@ -6,7 +6,8 @@ use rpki::ccr::{ encode_content_info, }; use rpki::cir::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, encode_cir, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + compute_reject_list_sha256, encode_cir, }; fn skip_heavy_blackbox_test() -> bool { @@ -35,7 +36,7 @@ fn cir_offline_sequence_writes_parseable_sequence_json_and_steps() { .unwrap(); let mk_cir = |uri: &str, hash_hex: &str, vt: &str| CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: time::OffsetDateTime::parse( vt, @@ -50,6 +51,8 @@ fn cir_offline_sequence_writes_parseable_sequence_json_and_steps() { tal_uri: "https://rpki.apnic.net/tal/apnic-rfc7730-https.tal".to_string(), tal_bytes: b"rsync://example.net/repo/root.cer\nMIIB".to_vec(), }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let full_hash = { use sha2::{Digest, Sha256}; @@ -65,7 +68,7 @@ fn cir_offline_sequence_writes_parseable_sequence_json_and_steps() { "2026-03-16T11:49:15Z", ); let delta_cir = CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: time::OffsetDateTime::parse( "2026-03-16T11:50:15Z", @@ -84,6 +87,8 @@ fn cir_offline_sequence_writes_parseable_sequence_json_and_steps() { objects }, tals: full_cir.tals.clone(), + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }; let empty_ccr = CcrContentInfo::new(RpkiCanonicalCacheRepresentation { version: 0, diff --git a/tests/test_cir_sequence_peer_replay_m4.rs b/tests/test_cir_sequence_peer_replay_m4.rs index 4cc6967..e3f5f58 100644 --- a/tests/test_cir_sequence_peer_replay_m4.rs +++ b/tests/test_cir_sequence_peer_replay_m4.rs @@ -3,8 +3,8 @@ use std::process::Command; use rpki::blob_store::ExternalRepoBytesDb; use rpki::cir::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, encode_cir, - materialize_cir_from_repo_bytes, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + compute_reject_list_sha256, encode_cir, materialize_cir_from_repo_bytes, }; fn skip_heavy_script_replay_test() -> bool { @@ -36,7 +36,7 @@ fn build_ta_only_cir() -> (CanonicalInputRepresentation, Vec) { }; ( CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: time::OffsetDateTime::parse( "2026-04-07T00:00:00Z", @@ -51,6 +51,8 @@ fn build_ta_only_cir() -> (CanonicalInputRepresentation, Vec) { tal_uri: "https://example.test/root.tal".to_string(), tal_bytes, }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }, ta_bytes, ) diff --git a/tests/test_cir_sequence_replay_m3.rs b/tests/test_cir_sequence_replay_m3.rs index 2216358..f3d08ee 100644 --- a/tests/test_cir_sequence_replay_m3.rs +++ b/tests/test_cir_sequence_replay_m3.rs @@ -3,8 +3,8 @@ use std::process::Command; use rpki::blob_store::ExternalRepoBytesDb; use rpki::cir::{ - CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, encode_cir, - materialize_cir_from_repo_bytes, + CIR_VERSION_V2, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, + compute_reject_list_sha256, encode_cir, materialize_cir_from_repo_bytes, }; fn skip_heavy_script_replay_test() -> bool { @@ -36,7 +36,7 @@ fn build_ta_only_cir() -> (CanonicalInputRepresentation, Vec) { }; ( CanonicalInputRepresentation { - version: CIR_VERSION_V1, + version: CIR_VERSION_V2, hash_alg: CirHashAlgorithm::Sha256, validation_time: time::OffsetDateTime::parse( "2026-04-07T00:00:00Z", @@ -51,6 +51,8 @@ fn build_ta_only_cir() -> (CanonicalInputRepresentation, Vec) { tal_uri: "https://example.test/root.tal".to_string(), tal_bytes, }], + reject_list_sha256: compute_reject_list_sha256(std::iter::empty::<&str>()), + rejected_objects: Vec::new(), }, ta_bytes, )