20260506_2 为CIR增加reject list基础能力

This commit is contained in:
yuyr 2026-05-06 20:53:57 +08:00
parent 51663a9410
commit 752e746b97
15 changed files with 510 additions and 53 deletions

View File

@ -0,0 +1,95 @@
use std::path::PathBuf;
#[derive(Debug, Default, PartialEq, Eq)]
struct Args {
cir_path: Option<PathBuf>,
limit: usize,
}
fn usage() -> &'static str {
"Usage: cir_dump_reject_list --cir <path> [--limit <n>]"
}
fn parse_args(argv: &[String]) -> Result<Args, String> {
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::<usize>()
.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<String> = 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("<none>")
);
}
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);
}
}

View File

@ -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() {

View File

@ -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<CanonicalInputRepresentation, CirDecodeE
}
let version = seq.take_uint_u64().map_err(CirDecodeError::Parse)? as u32;
if version != CIR_VERSION_V1 {
if version != CIR_VERSION_V2 {
return Err(CirDecodeError::UnexpectedVersion {
expected: CIR_VERSION_V1,
expected: CIR_VERSION_V2,
actual: version,
});
}
@ -59,6 +60,21 @@ pub fn decode_cir(der: &[u8]) -> Result<CanonicalInputRepresentation, CirDecodeE
tals.push(decode_tal(full)?);
}
let reject_list_sha256 = seq
.take_octet_string()
.map_err(CirDecodeError::Parse)?
.to_vec();
let rejected_der = seq.take_tag(0x30).map_err(CirDecodeError::Parse)?;
let mut rejected_reader = DerReader::new(rejected_der);
let mut rejected_objects = Vec::new();
while !rejected_reader.is_empty() {
let (_tag, full, _value) = rejected_reader
.take_any_full()
.map_err(CirDecodeError::Parse)?;
rejected_objects.push(decode_rejected_object(full)?);
}
if !seq.is_empty() {
return Err(CirDecodeError::Parse("trailing fields in CIR".into()));
}
@ -69,6 +85,8 @@ pub fn decode_cir(der: &[u8]) -> Result<CanonicalInputRepresentation, CirDecodeE
validation_time,
objects,
tals,
reject_list_sha256,
rejected_objects,
};
cir.validate().map_err(CirDecodeError::Validate)?;
Ok(cir)
@ -124,6 +142,34 @@ fn decode_tal(der: &[u8]) -> Result<CirTal, CirDecodeError> {
Ok(CirTal { tal_uri, tal_bytes })
}
fn decode_rejected_object(der: &[u8]) -> Result<CirRejectedObject, CirDecodeError> {
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<String, CirDecodeError> {
let der = {
let mut out = Vec::with_capacity(raw_body.len() + 2);

View File

@ -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<Vec<u8>, CirEnco
.map(encode_tal)
.collect::<Result<Vec<_>, _>>()?,
),
encode_octet_string(&cir.reject_list_sha256),
encode_sequence(
&cir.rejected_objects
.iter()
.map(encode_rejected_object)
.collect::<Result<Vec<_>, _>>()?,
),
]))
}
@ -48,6 +56,15 @@ fn encode_tal(tal: &CirTal) -> Result<Vec<u8>, CirEncodeError> {
]))
}
fn encode_rejected_object(item: &CirRejectedObject) -> Result<Vec<u8>, 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<u8> {
let t = t.to_offset(time::UtcOffset::UTC);
let s = format!(
@ -143,5 +160,5 @@ fn encode_len_into(len: usize, out: &mut Vec<u8>) {
#[allow(dead_code)]
const _: () = {
let _ = CIR_VERSION_V1;
let _ = CIR_VERSION_V2;
};

View File

@ -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::<BTreeSet<_>>();
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::<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)
@ -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(&current_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(&current_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(&current_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();

View File

@ -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(),
};
{

View File

@ -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}");
}
}

View File

@ -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<CirObject>,
pub tals: Vec<CirTal>,
pub reject_list_sha256: Vec<u8>,
pub rejected_objects: Vec<CirRejectedObject>,
}
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<String>,
}
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<Item = &'a str>) -> Vec<u8> {
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<Item = &'a str>,
message: &str,

View File

@ -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,

View File

@ -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();

View File

@ -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<u8>) {
};
(
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<u8>) {
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,
)

View File

@ -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<u8>) {
};
(
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<u8>) {
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,
)

View File

@ -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,

View File

@ -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<u8>) {
};
(
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<u8>) {
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,
)

View File

@ -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<u8>) {
};
(
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<u8>) {
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,
)