2 Commits

Author SHA1 Message Date
xiuting.xu
e01b5ab7f8 RC证书解析部分代码 2026-02-03 09:59:07 +08:00
xiuting.xu
da14f26acb RC证书解析部分代码 2026-02-03 09:57:24 +08:00
267 changed files with 1401 additions and 47562 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,2 @@
target/
Cargo.lock
perf.*

View File

@@ -3,33 +3,13 @@ name = "rpki"
version = "0.1.0"
edition = "2024"
[features]
default = ["full"]
# Full build used by the main RP implementation (includes RocksDB-backed storage).
full = ["dep:rocksdb"]
profile = ["dep:pprof", "dep:flate2"]
[dependencies]
asn1-rs = "0.7.1"
der-parser = { version = "10.0.0", features = ["serialize"] }
der-parser = "10.0.0"
hex = "0.4.3"
base64 = "0.22.1"
sha2 = "0.10.8"
thiserror = "2.0.18"
time = "0.3.45"
ring = "0.17.14"
x509-parser = { version = "0.18.0", features = ["verify"] }
url = "2.5.8"
serde = { version = "1.0.218", features = ["derive"] }
serde_json = "1.0.140"
toml = "0.8.20"
rocksdb = { version = "0.22.0", optional = true, default-features = false, features = ["lz4"] }
serde_cbor = "0.11.2"
roxmltree = "0.20.0"
uuid = { version = "1.7.0", features = ["v4"] }
reqwest = { version = "0.12.12", default-features = false, features = ["blocking", "rustls-tls"] }
pprof = { version = "0.14.1", optional = true, features = ["flamegraph", "prost-codec"] }
flate2 = { version = "1.0.35", optional = true }
[dev-dependencies]
tempfile = "3.16.0"
asn1-rs = "0.7.1"
asn1-rs-derive = "0.6.0"
asn1 = "0.23.0"

View File

@@ -9,20 +9,3 @@ cargo test
cargo test -- --nocapture
```
# 覆盖率(cargo-llvm-cov)
安装工具:
```
rustup component add llvm-tools-preview
cargo install cargo-llvm-cov --locked
```
统计行覆盖率并要求 >=90%:
```
./scripts/coverage.sh
# 或
cargo llvm-cov --fail-under-lines 90
```

View File

@@ -1,8 +0,0 @@
[package]
name = "ours-manifest-bench"
version = "0.1.0"
edition = "2024"
[dependencies]
rpki = { path = "../..", default-features = false }

View File

@@ -1,145 +0,0 @@
use rpki::data_model::manifest::ManifestObject;
use std::hint::black_box;
use std::path::PathBuf;
use std::time::Instant;
#[derive(Debug, Clone)]
struct Config {
sample: Option<String>,
manifest_path: Option<PathBuf>,
iterations: u64,
warmup_iterations: u64,
repeats: u32,
}
fn usage_and_exit() -> ! {
eprintln!(
"Usage:\n ours-manifest-bench (--sample <name> | --manifest <path>) [--iterations N] [--warmup-iterations N] [--repeats N]\n\nExamples:\n cargo run --release -- --sample small-01 --iterations 20000 --warmup-iterations 2000 --repeats 3\n cargo run --release -- --manifest ../../tests/benchmark/selected_der/small-01.mft"
);
std::process::exit(2);
}
fn parse_args() -> Config {
let mut sample: Option<String> = None;
let mut manifest_path: Option<PathBuf> = None;
let mut iterations: u64 = 20_000;
let mut warmup_iterations: u64 = 2_000;
let mut repeats: u32 = 3;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--sample" => sample = Some(args.next().unwrap_or_else(|| usage_and_exit())),
"--manifest" => {
manifest_path = Some(PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit())))
}
"--iterations" => {
iterations = args
.next()
.unwrap_or_else(|| usage_and_exit())
.parse()
.unwrap_or_else(|_| usage_and_exit())
}
"--warmup-iterations" => {
warmup_iterations = args
.next()
.unwrap_or_else(|| usage_and_exit())
.parse()
.unwrap_or_else(|_| usage_and_exit())
}
"--repeats" => {
repeats = args
.next()
.unwrap_or_else(|| usage_and_exit())
.parse()
.unwrap_or_else(|_| usage_and_exit())
}
"-h" | "--help" => usage_and_exit(),
_ => usage_and_exit(),
}
}
if sample.is_none() && manifest_path.is_none() {
usage_and_exit();
}
if sample.is_some() && manifest_path.is_some() {
usage_and_exit();
}
Config {
sample,
manifest_path,
iterations,
warmup_iterations,
repeats,
}
}
fn derive_manifest_path(sample: &str) -> PathBuf {
// Assumes current working directory is `rpki/benchmark/ours_manifest_bench`.
PathBuf::from(format!("../../tests/benchmark/selected_der/{sample}.mft"))
}
fn main() {
let cfg = parse_args();
let manifest_path = cfg
.manifest_path
.clone()
.unwrap_or_else(|| derive_manifest_path(cfg.sample.as_deref().unwrap()));
let bytes = std::fs::read(&manifest_path).unwrap_or_else(|e| {
eprintln!("read manifest fixture failed: {e}; path={}", manifest_path.display());
std::process::exit(1);
});
let decoded_once = ManifestObject::decode_der(&bytes).unwrap_or_else(|e| {
eprintln!("decode failed: {e}; path={}", manifest_path.display());
std::process::exit(1);
});
let file_count = decoded_once.manifest.file_count();
let mut round_ns_per_op: Vec<f64> = Vec::with_capacity(cfg.repeats as usize);
let mut round_ops_per_s: Vec<f64> = Vec::with_capacity(cfg.repeats as usize);
for _round in 0..cfg.repeats {
for _ in 0..cfg.warmup_iterations {
let obj = ManifestObject::decode_der(black_box(&bytes)).expect("warmup decode");
black_box(obj);
}
let start = Instant::now();
for _ in 0..cfg.iterations {
let obj = ManifestObject::decode_der(black_box(&bytes)).expect("timed decode");
black_box(obj);
}
let elapsed = start.elapsed();
let ns_per_op = (elapsed.as_secs_f64() * 1e9) / (cfg.iterations as f64);
let ops_per_s = (cfg.iterations as f64) / elapsed.as_secs_f64();
round_ns_per_op.push(ns_per_op);
round_ops_per_s.push(ops_per_s);
}
let avg_ns_per_op = round_ns_per_op.iter().sum::<f64>() / (round_ns_per_op.len() as f64);
let avg_ops_per_s = round_ops_per_s.iter().sum::<f64>() / (round_ops_per_s.len() as f64);
let sample_name = cfg.sample.clone().unwrap_or_else(|| {
manifest_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| manifest_path.display().to_string())
});
let sample_name = sample_name
.strip_suffix(".mft")
.unwrap_or(&sample_name)
.to_string();
println!("fixture: {}", manifest_path.display());
println!();
println!("| sample | avg ns/op | ops/s | file count |");
println!("|---|---:|---:|---:|");
println!(
"| {} | {:.2} | {:.2} | {} |",
sample_name, avg_ns_per_op, avg_ops_per_s, file_count
);
}

View File

@@ -1,8 +0,0 @@
[package]
name = "routinator-object-bench"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
rpki = { version = "=0.19.1", features = ["repository"] }

View File

@@ -1,552 +0,0 @@
use rpki::repository::cert::Cert;
use rpki::repository::crl::Crl;
use rpki::repository::manifest::Manifest;
use rpki::repository::roa::Roa;
use rpki::repository::aspa::Aspa;
use rpki::repository::resources::{AsResources, IpResources};
use std::hint::black_box;
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum ObjType {
Cer,
Crl,
Manifest,
Roa,
Aspa,
}
impl ObjType {
fn parse(s: &str) -> Result<Self, String> {
match s {
"cer" => Ok(Self::Cer),
"crl" => Ok(Self::Crl),
"manifest" => Ok(Self::Manifest),
"roa" => Ok(Self::Roa),
"aspa" => Ok(Self::Aspa),
_ => Err("type must be one of: cer, crl, manifest, roa, aspa".into()),
}
}
fn as_str(self) -> &'static str {
match self {
ObjType::Cer => "cer",
ObjType::Crl => "crl",
ObjType::Manifest => "manifest",
ObjType::Roa => "roa",
ObjType::Aspa => "aspa",
}
}
fn ext(self) -> &'static str {
match self {
ObjType::Cer => "cer",
ObjType::Crl => "crl",
ObjType::Manifest => "mft",
ObjType::Roa => "roa",
ObjType::Aspa => "asa",
}
}
}
#[derive(Clone, Debug)]
struct Sample {
obj_type: ObjType,
name: String,
path: PathBuf,
}
#[derive(Clone, Debug)]
struct Config {
dir: PathBuf,
type_filter: Option<ObjType>,
sample_filter: Option<String>,
fixed_iters: Option<u64>,
warmup_iters: u64,
rounds: u64,
min_round_ms: u64,
max_adaptive_iters: u64,
strict: bool,
cert_inspect: bool,
out_csv: Option<PathBuf>,
out_md: Option<PathBuf>,
}
fn usage_and_exit(err: Option<&str>) -> ! {
if let Some(err) = err {
eprintln!("error: {err}");
eprintln!();
}
eprintln!(
"Usage:\n\
cargo run --release --manifest-path rpki/benchmark/routinator_object_bench/Cargo.toml -- [OPTIONS]\n\
\n\
Options:\n\
--dir <PATH> Fixtures root dir (default: ../../tests/benchmark/selected_der_v2)\n\
--type <cer|crl|manifest|roa|aspa> Filter by type\n\
--sample <NAME> Filter by sample name (e.g. p50)\n\
--iters <N> Fixed iterations per round (optional; otherwise adaptive)\n\
--warmup-iters <N> Warmup iterations (default: 50)\n\
--rounds <N> Rounds (default: 5)\n\
--min-round-ms <MS> Adaptive: minimum round time (default: 200)\n\
--max-iters <N> Adaptive: maximum iters (default: 1_000_000)\n\
--strict <true|false> Strict DER where applicable (default: true)\n\
--cert-inspect Also run Cert::inspect_ca/inspect_ee where applicable (default: false)\n\
--out-csv <PATH> Write CSV output\n\
--out-md <PATH> Write Markdown output\n\
"
);
std::process::exit(2);
}
fn parse_bool(s: &str, name: &str) -> bool {
match s {
"1" | "true" | "TRUE" | "yes" | "YES" => true,
"0" | "false" | "FALSE" | "no" | "NO" => false,
_ => usage_and_exit(Some(&format!("{name} must be true/false"))),
}
}
fn parse_u64(s: &str, name: &str) -> u64 {
s.parse::<u64>()
.unwrap_or_else(|_| usage_and_exit(Some(&format!("{name} must be an integer"))))
}
fn default_samples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/benchmark/selected_der_v2")
}
fn parse_args() -> Config {
let mut dir: PathBuf = default_samples_dir();
let mut type_filter: Option<ObjType> = None;
let mut sample_filter: Option<String> = None;
let mut fixed_iters: Option<u64> = None;
let mut warmup_iters: u64 = 50;
let mut rounds: u64 = 5;
let mut min_round_ms: u64 = 200;
let mut max_adaptive_iters: u64 = 1_000_000;
let mut strict: bool = true;
let mut cert_inspect: bool = false;
let mut out_csv: Option<PathBuf> = None;
let mut out_md: Option<PathBuf> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--dir" => dir = PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit(None))),
"--type" => {
type_filter = Some(ObjType::parse(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
)
.unwrap_or_else(|e| usage_and_exit(Some(&e))))
}
"--sample" => {
sample_filter = Some(args.next().unwrap_or_else(|| usage_and_exit(None)))
}
"--iters" => {
fixed_iters = Some(parse_u64(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--iters",
))
}
"--warmup-iters" => {
warmup_iters = parse_u64(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--warmup-iters",
)
}
"--rounds" => {
rounds = parse_u64(&args.next().unwrap_or_else(|| usage_and_exit(None)), "--rounds")
}
"--min-round-ms" => {
min_round_ms = parse_u64(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--min-round-ms",
)
}
"--max-iters" => {
max_adaptive_iters = parse_u64(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--max-iters",
)
}
"--strict" => {
strict = parse_bool(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--strict",
)
}
"--cert-inspect" => cert_inspect = true,
"--out-csv" => out_csv = Some(PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit(None)))),
"--out-md" => out_md = Some(PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit(None)))),
"-h" | "--help" => usage_and_exit(None),
_ => usage_and_exit(Some(&format!("unknown argument: {arg}"))),
}
}
if warmup_iters == 0 {
usage_and_exit(Some("--warmup-iters must be > 0"));
}
if rounds == 0 {
usage_and_exit(Some("--rounds must be > 0"));
}
if min_round_ms == 0 {
usage_and_exit(Some("--min-round-ms must be > 0"));
}
if max_adaptive_iters == 0 {
usage_and_exit(Some("--max-iters must be > 0"));
}
if let Some(n) = fixed_iters {
if n == 0 {
usage_and_exit(Some("--iters must be > 0"));
}
}
Config {
dir,
type_filter,
sample_filter,
fixed_iters,
warmup_iters,
rounds,
min_round_ms,
max_adaptive_iters,
strict,
cert_inspect,
out_csv,
out_md,
}
}
fn read_samples(root: &Path) -> Vec<Sample> {
let mut out = Vec::new();
for obj_type in [
ObjType::Cer,
ObjType::Crl,
ObjType::Manifest,
ObjType::Roa,
ObjType::Aspa,
] {
let dir = root.join(obj_type.as_str());
let rd = match std::fs::read_dir(&dir) {
Ok(rd) => rd,
Err(_) => continue,
};
for ent in rd.flatten() {
let path = ent.path();
if path.extension().and_then(|s| s.to_str()) != Some(obj_type.ext()) {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
out.push(Sample { obj_type, name, path });
}
}
out.sort_by(|a, b| a.obj_type.cmp(&b.obj_type).then_with(|| a.name.cmp(&b.name)));
out
}
fn choose_iters_adaptive<F: FnMut()>(mut op: F, min_round_ms: u64, max_iters: u64) -> u64 {
let min_secs = (min_round_ms as f64) / 1e3;
let mut iters: u64 = 1;
loop {
let start = Instant::now();
for _ in 0..iters {
op();
}
let elapsed = start.elapsed().as_secs_f64();
if elapsed >= min_secs {
return iters;
}
if iters >= max_iters {
return iters;
}
iters = (iters.saturating_mul(2)).min(max_iters);
}
}
fn count_ip(res: &IpResources) -> u64 {
if res.is_inherited() {
return 1;
}
let Ok(blocks) = res.to_blocks() else {
return 0;
};
blocks.iter().count() as u64
}
fn count_as(res: &AsResources) -> u64 {
if res.is_inherited() {
return 1;
}
let Ok(blocks) = res.to_blocks() else {
return 0;
};
blocks.iter().count() as u64
}
fn complexity(obj_type: ObjType, bytes: &[u8], strict: bool, cert_inspect: bool) -> u64 {
match obj_type {
ObjType::Cer => {
let cert = Cert::decode(bytes).expect("decode cert");
if cert_inspect {
if cert.is_ca() {
cert.inspect_ca(strict).expect("inspect ca");
} else {
cert.inspect_ee(strict).expect("inspect ee");
}
}
count_ip(cert.v4_resources())
.saturating_add(count_ip(cert.v6_resources()))
.saturating_add(count_as(cert.as_resources()))
}
ObjType::Crl => {
let crl = Crl::decode(bytes).expect("decode crl");
crl.revoked_certs().iter().count() as u64
}
ObjType::Manifest => {
let mft = Manifest::decode(bytes, strict).expect("decode manifest");
if cert_inspect {
mft.cert().inspect_ee(strict).expect("inspect ee");
}
mft.content().len() as u64
}
ObjType::Roa => {
let roa = Roa::decode(bytes, strict).expect("decode roa");
if cert_inspect {
roa.cert().inspect_ee(strict).expect("inspect ee");
}
roa.content().iter().count() as u64
}
ObjType::Aspa => {
let asa = Aspa::decode(bytes, strict).expect("decode aspa");
if cert_inspect {
asa.cert().inspect_ee(strict).expect("inspect ee");
}
asa.content().provider_as_set().len() as u64
}
}
}
fn decode_profile(obj_type: ObjType, bytes: &[u8], strict: bool, cert_inspect: bool) {
match obj_type {
ObjType::Cer => {
let cert = Cert::decode(black_box(bytes)).expect("decode cert");
if cert_inspect {
if cert.is_ca() {
cert.inspect_ca(strict).expect("inspect ca");
} else {
cert.inspect_ee(strict).expect("inspect ee");
}
}
black_box(cert);
}
ObjType::Crl => {
let crl = Crl::decode(black_box(bytes)).expect("decode crl");
black_box(crl);
}
ObjType::Manifest => {
let mft = Manifest::decode(black_box(bytes), strict).expect("decode manifest");
if cert_inspect {
mft.cert().inspect_ee(strict).expect("inspect ee");
}
black_box(mft);
}
ObjType::Roa => {
let roa = Roa::decode(black_box(bytes), strict).expect("decode roa");
if cert_inspect {
roa.cert().inspect_ee(strict).expect("inspect ee");
}
black_box(roa);
}
ObjType::Aspa => {
let asa = Aspa::decode(black_box(bytes), strict).expect("decode aspa");
if cert_inspect {
asa.cert().inspect_ee(strict).expect("inspect ee");
}
black_box(asa);
}
}
}
#[derive(Clone, Debug)]
struct ResultRow {
obj_type: String,
sample: String,
size_bytes: usize,
complexity: u64,
avg_ns_per_op: f64,
ops_per_sec: f64,
}
fn render_markdown(title: &str, rows: &[ResultRow]) -> String {
let mut out = String::new();
out.push_str(&format!("# {title}\n\n"));
out.push_str("| type | sample | size_bytes | complexity | avg ns/op | ops/s |\n");
out.push_str("|---|---|---:|---:|---:|---:|\n");
for r in rows {
out.push_str(&format!(
"| {} | {} | {} | {} | {:.2} | {:.2} |\n",
r.obj_type, r.sample, r.size_bytes, r.complexity, r.avg_ns_per_op, r.ops_per_sec
));
}
out
}
fn render_csv(rows: &[ResultRow]) -> String {
let mut out = String::new();
out.push_str("type,sample,size_bytes,complexity,avg_ns_per_op,ops_per_sec\n");
for r in rows {
let sample = r.sample.replace('"', "\"\"");
out.push_str(&format!(
"{},{},{},{},{:.6},{:.6}\n",
r.obj_type,
format!("\"{}\"", sample),
r.size_bytes,
r.complexity,
r.avg_ns_per_op,
r.ops_per_sec
));
}
out
}
fn create_parent_dirs(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
panic!("create_dir_all {}: {e}", parent.display());
});
}
}
fn write_text_file(path: &Path, content: &str) {
create_parent_dirs(path);
std::fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
}
fn main() {
let cfg = parse_args();
let mut samples = read_samples(&cfg.dir);
if samples.is_empty() {
usage_and_exit(Some(&format!(
"no samples found under: {}",
cfg.dir.display()
)));
}
if let Some(t) = cfg.type_filter {
samples.retain(|s| s.obj_type == t);
if samples.is_empty() {
usage_and_exit(Some(&format!("no sample matched --type {}", t.as_str())));
}
}
if let Some(filter) = cfg.sample_filter.as_deref() {
samples.retain(|s| s.name == filter);
if samples.is_empty() {
usage_and_exit(Some(&format!("no sample matched --sample {filter}")));
}
}
println!("# Routinator baseline (rpki crate) decode benchmark (selected_der_v2)");
println!();
println!("- dir: {}", cfg.dir.display());
println!("- strict: {}", cfg.strict);
println!("- cert_inspect: {}", cfg.cert_inspect);
if let Some(t) = cfg.type_filter {
println!("- type: {}", t.as_str());
}
if let Some(s) = cfg.sample_filter.as_deref() {
println!("- sample: {}", s);
}
if let Some(n) = cfg.fixed_iters {
println!("- iters: {} (fixed)", n);
} else {
println!(
"- warmup: {} iters, rounds: {}, min_round: {}ms (adaptive iters, max {})",
cfg.warmup_iters, cfg.rounds, cfg.min_round_ms, cfg.max_adaptive_iters
);
}
if let Some(p) = cfg.out_csv.as_ref() {
println!("- out_csv: {}", p.display());
}
if let Some(p) = cfg.out_md.as_ref() {
println!("- out_md: {}", p.display());
}
println!();
println!("| type | sample | size_bytes | complexity | avg ns/op | ops/s |");
println!("|---|---|---:|---:|---:|---:|");
let mut rows: Vec<ResultRow> = Vec::with_capacity(samples.len());
for sample in &samples {
let bytes = std::fs::read(&sample.path)
.unwrap_or_else(|e| panic!("read {}: {e}", sample.path.display()));
let size_bytes = bytes.len();
let complexity = complexity(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect);
for _ in 0..cfg.warmup_iters {
decode_profile(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect);
}
let mut per_round_ns_per_op = Vec::with_capacity(cfg.rounds as usize);
for _round in 0..cfg.rounds {
let iters = if let Some(n) = cfg.fixed_iters {
n
} else {
choose_iters_adaptive(
|| decode_profile(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect),
cfg.min_round_ms,
cfg.max_adaptive_iters,
)
};
let start = Instant::now();
for _ in 0..iters {
decode_profile(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect);
}
let elapsed = start.elapsed();
let total_ns = elapsed.as_secs_f64() * 1e9;
per_round_ns_per_op.push(total_ns / (iters as f64));
}
let avg_ns = per_round_ns_per_op.iter().sum::<f64>() / (per_round_ns_per_op.len() as f64);
let ops_per_sec = 1e9_f64 / avg_ns;
println!(
"| {} | {} | {} | {} | {:.2} | {:.2} |",
sample.obj_type.as_str(),
sample.name,
size_bytes,
complexity,
avg_ns,
ops_per_sec
);
rows.push(ResultRow {
obj_type: sample.obj_type.as_str().to_string(),
sample: sample.name.clone(),
size_bytes,
complexity,
avg_ns_per_op: avg_ns,
ops_per_sec,
});
}
if let Some(path) = cfg.out_md.as_ref() {
let md = render_markdown(
"Routinator baseline (rpki crate) decode+inspect (selected_der_v2)",
&rows,
);
write_text_file(path, &md);
eprintln!("Wrote {}", path.display());
}
if let Some(path) = cfg.out_csv.as_ref() {
let csv = render_csv(&rows);
write_text_file(path, &csv);
eprintln!("Wrote {}", path.display());
}
}

2734
model.txt

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +0,0 @@
# RPKI Benchmarks (Stage2, selected_der_v2)
This directory contains a reproducible, one-click benchmark to measure **decode + profile validate**
performance for all supported object types and compare **OURS** against the **Routinator baseline**
(`rpki` crate `=0.19.1` with `repository` feature).
## What it measures
Dataset:
- Fixtures: `rpki/tests/benchmark/selected_der_v2/`
- Objects: `cer`, `crl`, `manifest` (`.mft`), `roa`, `aspa` (`.asa`)
- Samples: 10 quantiles per type (`min/p01/p10/p25/p50/p75/p90/p95/p99/max`) → 50 files total
Metrics:
- **decode+validate**: `decode_der` (parse + profile validate) for each object file
- **landing** (OURS only): `PackFile::from_bytes_compute_sha256` + CBOR encode + `RocksDB put_raw`
- **compare**: ratio `ours_ns/op ÷ rout_ns/op` for decode+validate
## Default benchmark settings
Both OURS and Routinator baseline use the same run settings:
- warmup: `10` iterations
- rounds: `3`
- adaptive loop target: `min_round_ms=200` (with an internal max of `1_000_000` iters)
- strict DER: `true` (baseline)
- cert inspect: `false` (baseline)
You can override the settings via environment variables in the runner script:
- `BENCH_WARMUP_ITERS` (default `10`)
- `BENCH_ROUNDS` (default `3`)
- `BENCH_MIN_ROUND_MS` (default `200`)
## One-click run (OURS + Routinator compare)
From the `rpki/` crate directory:
```bash
./scripts/benchmark/run_stage2_selected_der_v2_release.sh
```
Outputs are written under:
- `rpki/target/bench/`
- OURS decode+validate: `stage2_selected_der_v2_decode_release_<TS>.{md,csv}`
- OURS landing: `stage2_selected_der_v2_landing_release_<TS>.{md,csv}`
- Routinator: `stage2_selected_der_v2_routinator_decode_release_<TS>.{md,csv}`
- Compare: `stage2_selected_der_v2_compare_ours_vs_routinator_decode_release_<TS>.{md,csv}`
- Summary: `stage2_selected_der_v2_compare_summary_<TS>.md`
### Why decode and landing are separated
The underlying benchmark can run in `BENCH_MODE=both`, but the **landing** part writes to RocksDB
and may trigger background work (e.g., compactions) that can **skew subsequent decode timings**.
For a fair OURS-vs-Routinator comparison, the runner script:
- runs `BENCH_MODE=decode_validate` for comparison, and
- runs `BENCH_MODE=landing` separately for landing-only numbers.
## Notes
- The Routinator baseline benchmark is implemented in-repo under:
- `rpki/benchmark/routinator_object_bench/`
- It pins `rpki = "=0.19.1"` in its `Cargo.toml`.
- This benchmark is implemented as an `#[ignore]` integration test:
- `rpki/tests/bench_stage2_decode_profile_selected_der_v2.rs`
- The runner script invokes it with `cargo test --release ... -- --ignored --nocapture`.

View File

@@ -1,123 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Stage2 (selected_der_v2) decode+profile validate benchmark.
# Runs:
# 1) OURS decode+validate benchmark and writes MD/CSV.
# 2) OURS landing benchmark and writes MD/CSV.
# 3) Routinator baseline decode benchmark (rpki crate =0.19.1).
# 4) Produces a joined compare CSV/MD and a short geomean summary.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
OUT_DIR="$ROOT_DIR/target/bench"
mkdir -p "$OUT_DIR"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
WARMUP_ITERS="${BENCH_WARMUP_ITERS:-10}"
ROUNDS="${BENCH_ROUNDS:-3}"
MIN_ROUND_MS="${BENCH_MIN_ROUND_MS:-200}"
OURS_MD="$OUT_DIR/stage2_selected_der_v2_decode_release_${TS}.md"
OURS_CSV="$OUT_DIR/stage2_selected_der_v2_decode_release_${TS}.csv"
OURS_LANDING_MD="$OUT_DIR/stage2_selected_der_v2_landing_release_${TS}.md"
OURS_LANDING_CSV="$OUT_DIR/stage2_selected_der_v2_landing_release_${TS}.csv"
ROUT_MD="$OUT_DIR/stage2_selected_der_v2_routinator_decode_release_${TS}.md"
ROUT_CSV="$OUT_DIR/stage2_selected_der_v2_routinator_decode_release_${TS}.csv"
COMPARE_MD="$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release_${TS}.md"
COMPARE_CSV="$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release_${TS}.csv"
SUMMARY_MD="$OUT_DIR/stage2_selected_der_v2_compare_summary_${TS}.md"
echo "[1/4] OURS: decode+validate benchmark (release)..." >&2
BENCH_MODE="decode_validate" \
BENCH_WARMUP_ITERS="$WARMUP_ITERS" \
BENCH_ROUNDS="$ROUNDS" \
BENCH_MIN_ROUND_MS="$MIN_ROUND_MS" \
BENCH_OUT_MD="$OURS_MD" \
BENCH_OUT_CSV="$OURS_CSV" \
cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture >/dev/null
echo "[2/4] OURS: landing benchmark (release)..." >&2
BENCH_MODE="landing" \
BENCH_WARMUP_ITERS="$WARMUP_ITERS" \
BENCH_ROUNDS="$ROUNDS" \
BENCH_MIN_ROUND_MS="$MIN_ROUND_MS" \
BENCH_OUT_MD_LANDING="$OURS_LANDING_MD" \
BENCH_OUT_CSV_LANDING="$OURS_LANDING_CSV" \
cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture >/dev/null
echo "[3/4] Routinator baseline + compare join..." >&2
OURS_CSV="$OURS_CSV" \
ROUT_CSV="$ROUT_CSV" \
ROUT_MD="$ROUT_MD" \
COMPARE_CSV="$COMPARE_CSV" \
COMPARE_MD="$COMPARE_MD" \
WARMUP_ITERS="$WARMUP_ITERS" \
ROUNDS="$ROUNDS" \
MIN_ROUND_MS="$MIN_ROUND_MS" \
scripts/stage2_perf_compare_m4.sh >/dev/null
echo "[4/4] Summary (geomean ratios)..." >&2
python3 - "$COMPARE_CSV" "$SUMMARY_MD" <<'PY'
import csv
import math
import sys
from pathlib import Path
from datetime import datetime, timezone
in_csv = Path(sys.argv[1])
out_md = Path(sys.argv[2])
rows = list(csv.DictReader(in_csv.open(newline="")))
ratios = {}
for r in rows:
ratios.setdefault(r["type"], []).append(float(r["ratio_ours_over_rout"]))
def geomean(vals):
return math.exp(sum(math.log(v) for v in vals) / len(vals))
def p50(vals):
v = sorted(vals)
n = len(v)
if n % 2 == 1:
return v[n // 2]
return (v[n // 2 - 1] + v[n // 2]) / 2.0
all_vals = [float(r["ratio_ours_over_rout"]) for r in rows]
types = ["all"] + sorted(ratios.keys())
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
lines = []
lines.append("# Stage2 selected_der_v2 compare summary (release)\n\n")
lines.append(f"- recorded_at_utc: `{now}`\n")
lines.append(f"- inputs_csv: `{in_csv}`\n\n")
lines.append("| type | n | min | p50 | geomean | max | >1 count |\n")
lines.append("|---|---:|---:|---:|---:|---:|---:|\n")
for t in types:
vals = all_vals if t == "all" else ratios[t]
vals_sorted = sorted(vals)
lines.append(
f"| {t} | {len(vals_sorted)} | {vals_sorted[0]:.4f} | {p50(vals_sorted):.4f} | "
f"{geomean(vals_sorted):.4f} | {vals_sorted[-1]:.4f} | {sum(1 for v in vals_sorted if v>1.0)} |\n"
)
out_md.write_text("".join(lines), encoding="utf-8")
print(out_md)
PY
echo "Done." >&2
echo "- OURS decode MD: $OURS_MD" >&2
echo "- OURS decode CSV: $OURS_CSV" >&2
echo "- OURS landing MD: $OURS_LANDING_MD" >&2
echo "- OURS landing CSV: $OURS_LANDING_CSV" >&2
echo "- Routinator: $ROUT_MD" >&2
echo "- Compare MD: $COMPARE_MD" >&2
echo "- Compare CSV: $COMPARE_CSV" >&2
echo "- Summary MD: $SUMMARY_MD" >&2

View File

@@ -1,80 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Requires:
# rustup component add llvm-tools-preview
# cargo install cargo-llvm-cov --locked
run_out="$(mktemp)"
text_out="$(mktemp)"
html_out="$(mktemp)"
cleanup() {
rm -f "$run_out" "$text_out" "$html_out"
}
trap cleanup EXIT
# Preserve colored output even though we post-process output by running under a pseudo-TTY.
# We run tests only once, then generate both CLI text + HTML reports without rerunning tests.
set +e
cargo llvm-cov clean --workspace >/dev/null 2>&1
# 1) Run tests once to collect coverage data (no report).
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov --no-report" "$run_out" >/dev/null 2>&1
run_status="$?"
# 2) CLI summary report + fail-under gate (no test rerun).
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov report --fail-under-lines 90" "$text_out" >/dev/null 2>&1
text_status="$?"
# 3) HTML report (no test rerun).
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov report --html" "$html_out" >/dev/null 2>&1
html_status="$?"
set -e
strip_script_noise() {
tr -d '\r' | sed '/^Script \(started\|done\) on /d'
}
strip_ansi_for_parse() {
awk '
{
line = $0
gsub(/\033\[[0-9;]*[A-Za-z]/, "", line) # CSI escapes
gsub(/\033\([A-Za-z]/, "", line) # charset escapes (e.g., ESC(B)
gsub(/\r/, "", line)
print line
}
'
}
cat "$run_out" | strip_script_noise
cat "$text_out" | strip_script_noise
cat "$html_out" | strip_script_noise
cat "$run_out" | strip_ansi_for_parse | awk '
BEGIN {
passed=0; failed=0; ignored=0; measured=0; filtered=0;
}
/^test result: / {
if (match($0, /([0-9]+) passed; ([0-9]+) failed; ([0-9]+) ignored; ([0-9]+) measured; ([0-9]+) filtered out;/, m)) {
passed += m[1]; failed += m[2]; ignored += m[3]; measured += m[4]; filtered += m[5];
}
}
END {
executed = passed + failed;
total = passed + failed + ignored + measured;
printf("\nTEST SUMMARY (all suites): passed=%d failed=%d ignored=%d measured=%d filtered_out=%d executed=%d total=%d\n",
passed, failed, ignored, measured, filtered, executed, total);
}
'
echo
echo "HTML report: target/llvm-cov/html/index.html"
status="$text_status"
if [ "$run_status" -ne 0 ]; then status="$run_status"; fi
if [ "$html_status" -ne 0 ]; then status="$html_status"; fi
exit "$status"

View File

@@ -1,195 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# M2: Run per-sample decode+profile benchmark (Ours vs Routinator) on selected_der fixtures.
#
# Outputs:
# - specs/develop/20260224/data/m2_manifest_decode_profile_compare.csv
# - specs/develop/20260224/data/m2_raw.log
#
# Note: This script assumes Routinator benchmark repo exists at:
# /home/yuyr/dev/rust_playground/routinator/benchmark
#
# It also assumes fixtures exist under:
# rpki/tests/benchmark/selected_der/*.mft
# routinator/benchmark/fixtures/selected_der/*.mft
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RPKI_DIR="$ROOT_DIR"
OURS_BENCH_DIR="$RPKI_DIR/benchmark/ours_manifest_bench"
ROUT_BENCH_DIR="${ROUT_BENCH_DIR:-/home/yuyr/dev/rust_playground/routinator/benchmark}"
ROUT_BIN="$ROUT_BENCH_DIR/target/release/routinator-manifest-benchmark"
DATE_TAG="${DATE_TAG:-20260224}"
OUT_DIR="$RPKI_DIR/../specs/develop/${DATE_TAG}/data"
OUT_CSV="${OUT_CSV:-$OUT_DIR/m2_manifest_decode_profile_compare.csv}"
OUT_RAW="${OUT_RAW:-$OUT_DIR/m2_raw.log}"
REPEATS="${REPEATS:-3}"
# Iterations / warmups (kept moderate for interactive iteration).
ITER_SMALL="${ITER_SMALL:-20000}"
ITER_MEDIUM="${ITER_MEDIUM:-20000}"
ITER_LARGE="${ITER_LARGE:-20000}"
ITER_XLARGE="${ITER_XLARGE:-2000}"
WARM_SMALL="${WARM_SMALL:-2000}"
WARM_MEDIUM="${WARM_MEDIUM:-2000}"
WARM_LARGE="${WARM_LARGE:-2000}"
WARM_XLARGE="${WARM_XLARGE:-200}"
SAMPLES=(
small-01
small-02
medium-01
medium-02
large-01
large-02
xlarge-01
xlarge-02
)
mkdir -p "$OUT_DIR"
: > "$OUT_RAW"
echo "sample,bucket,manifest_file_count,ours_avg_ns_per_op,ours_ops_per_s,rout_avg_ns_per_op,rout_ops_per_s,ratio_ours_over_rout,iterations,repeats,warmup" > "$OUT_CSV"
echo "[1/3] Build ours benchmark (release)..." | tee -a "$OUT_RAW"
(cd "$OURS_BENCH_DIR" && cargo build --release -q)
OURS_BIN="$OURS_BENCH_DIR/target/release/ours-manifest-bench"
echo "[2/3] Build routinator benchmark (release)..." | tee -a "$OUT_RAW"
(cd "$ROUT_BENCH_DIR" && cargo build --release -q)
taskset_prefix=""
if command -v taskset >/dev/null 2>&1; then
if [[ -n "${TASKSET_CPU:-}" ]]; then
taskset_prefix="taskset -c ${TASKSET_CPU}"
fi
fi
bucket_for() {
local s="$1"
case "$s" in
small-*) echo "small" ;;
medium-*) echo "medium" ;;
large-*) echo "large" ;;
xlarge-*) echo "xlarge" ;;
*) echo "unknown" ;;
esac
}
iters_for() {
local b="$1"
case "$b" in
small) echo "$ITER_SMALL" ;;
medium) echo "$ITER_MEDIUM" ;;
large) echo "$ITER_LARGE" ;;
xlarge) echo "$ITER_XLARGE" ;;
*) echo "$ITER_MEDIUM" ;;
esac
}
warm_for() {
local b="$1"
case "$b" in
small) echo "$WARM_SMALL" ;;
medium) echo "$WARM_MEDIUM" ;;
large) echo "$WARM_LARGE" ;;
xlarge) echo "$WARM_XLARGE" ;;
*) echo "$WARM_MEDIUM" ;;
esac
}
run_ours() {
local sample="$1"
local iters="$2"
local warm="$3"
local ours_fixture="$RPKI_DIR/tests/benchmark/selected_der/${sample}.mft"
if [[ ! -f "$ours_fixture" ]]; then
echo "ours fixture not found: $ours_fixture" >&2
exit 1
fi
echo "### ours $sample" >> "$OUT_RAW"
local out
out=$($taskset_prefix "$OURS_BIN" --manifest "$ours_fixture" --iterations "$iters" --warmup-iterations "$warm" --repeats "$REPEATS")
echo "$out" >> "$OUT_RAW"
local line
line=$(echo "$out" | rg "^\\| ${sample} \\|" | tail -n 1)
if [[ -z "${line:-}" ]]; then
echo "failed to parse ours output for $sample" >&2
exit 1
fi
# Expected final row: | sample | avg ns/op | ops/s | file count |
local avg ops cnt
avg=$(echo "$line" | awk -F'|' '{gsub(/^ +| +$/,"",$3); print $3}')
ops=$(echo "$line" | awk -F'|' '{gsub(/^ +| +$/,"",$4); print $4}')
cnt=$(echo "$line" | awk -F'|' '{gsub(/^ +| +$/,"",$5); print $5}')
echo "$avg,$ops,$cnt"
}
run_rout() {
local sample="$1"
local iters="$2"
local warm="$3"
local rout_fixture="$ROUT_BENCH_DIR/fixtures/selected_der/${sample}.mft"
if [[ ! -f "$rout_fixture" ]]; then
echo "routinator fixture not found: $rout_fixture" >&2
exit 1
fi
echo "### routinator $sample" >> "$OUT_RAW"
local out
out=$(
$taskset_prefix "$ROUT_BIN" \
--target decode_only \
--manifest "$rout_fixture" \
--issuer "$ROUT_BENCH_DIR/fixtures/ta.cer" \
--iterations "$iters" \
--repeats "$REPEATS" \
--warmup-iterations "$warm" \
--strict false
)
echo "$out" >> "$OUT_RAW"
local avg_line cnt_line
avg_line=$(echo "$out" | rg "^ avg:")
cnt_line=$(echo "$out" | rg "^ manifest_file_count:")
local avg_ns ops_s cnt
avg_ns=$(echo "$avg_line" | awk '{print $2}')
ops_s=$(echo "$avg_line" | awk '{gsub(/[()]/,"",$4); print $4}')
cnt=$(echo "$cnt_line" | awk '{print $2}')
echo "$avg_ns,$ops_s,$cnt"
}
echo "[3/3] Run per-sample benchmarks..." | tee -a "$OUT_RAW"
for s in "${SAMPLES[@]}"; do
b=$(bucket_for "$s")
it=$(iters_for "$b")
warm=$(warm_for "$b")
IFS=, read -r ours_avg ours_ops ours_cnt < <(run_ours "$s" "$it" "$warm")
IFS=, read -r rout_avg rout_ops rout_cnt < <(run_rout "$s" "$it" "$warm")
if [[ "$ours_cnt" != "$rout_cnt" ]]; then
echo "WARNING: file count differs for $s (ours=$ours_cnt rout=$rout_cnt)" | tee -a "$OUT_RAW"
fi
ratio=$(python3 - <<PY
o=float("$ours_avg")
r=float("$rout_avg")
print(f"{(o/r):.4f}" if r != 0 else "inf")
PY
)
echo "$s,$b,$ours_cnt,$ours_avg,$ours_ops,$rout_avg,$rout_ops,$ratio,$it,$REPEATS,$warm" >> "$OUT_CSV"
echo >> "$OUT_RAW"
done
echo "Done."
echo "- CSV: $OUT_CSV"
echo "- Raw: $OUT_RAW"

View File

@@ -1,142 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# M3: Generate flamegraphs + top hotspots for Manifest decode+profile (Ours vs Routinator).
#
# Outputs under:
# specs/develop/20260224/flamegraph/
# specs/develop/20260224/hotspots/
# specs/develop/20260224/perf/
#
# Notes:
# - On WSL2, /usr/bin/perf is often a wrapper that fails. This script uses a real perf binary
# from /usr/lib/linux-tools/*/perf (if present).
# - Ours profiling uses perf + flamegraph --perfdata to avoid rebuilding the whole crate graph
# with RocksDB.
ROOT_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RPKI_DIR="$ROOT_REPO/rpki"
DATE_TAG="${DATE_TAG:-20260224}"
OUT_BASE="$ROOT_REPO/specs/develop/${DATE_TAG}"
OUT_FLAME="$OUT_BASE/flamegraph"
OUT_HOT="$OUT_BASE/hotspots"
OUT_PERF="$OUT_BASE/perf"
RUN_TAG="${RUN_TAG:-p2}"
OURS_BENCH_DIR="$RPKI_DIR/benchmark/ours_manifest_bench"
OURS_BIN="$OURS_BENCH_DIR/target/release/ours-manifest-bench"
ROUT_BENCH_DIR="${ROUT_BENCH_DIR:-/home/yuyr/dev/rust_playground/routinator/benchmark}"
ROUT_BIN="$ROUT_BENCH_DIR/target/release/routinator-manifest-benchmark"
ROUT_ISSUER="$ROUT_BENCH_DIR/fixtures/ta.cer"
PROFILE_HZ="${PROFILE_HZ:-99}"
mkdir -p "$OUT_FLAME" "$OUT_HOT" "$OUT_PERF"
PERF_WRAPPER_OUT="$(perf --version 2>&1 || true)"
PERF_REAL=""
if echo "${PERF_WRAPPER_OUT}" | grep -q "WARNING: perf not found for kernel"; then
PERF_REAL="$(ls -1 /usr/lib/linux-tools/*/perf 2>/dev/null | head -n 1 || true)"
else
PERF_REAL="$(command -v perf || true)"
fi
if [[ -z "${PERF_REAL}" ]]; then
echo "ERROR: usable perf binary not found (wrapper detected and no /usr/lib/linux-tools/*/perf)." >&2
exit 2
fi
SHIM_DIR="$RPKI_DIR/target/bench/tools"
mkdir -p "$SHIM_DIR"
cat > "$SHIM_DIR/perf" <<EOF
#!/usr/bin/env bash
exec "${PERF_REAL}" "\$@"
EOF
chmod +x "$SHIM_DIR/perf"
export PATH="$SHIM_DIR:$PATH"
echo "Using perf: $PERF_REAL"
echo "[1/3] Build ours benchmark with frame pointers..."
(cd "$OURS_BENCH_DIR" && RUSTFLAGS="-C force-frame-pointers=yes" cargo build --release -q)
echo "[2/3] Build routinator benchmark (release)..."
(cd "$ROUT_BENCH_DIR" && cargo build --release -q)
taskset_prefix=""
if command -v taskset >/dev/null 2>&1; then
taskset_prefix="taskset -c 0"
fi
profile_ours() {
local sample="$1"
local iters="$2"
local warm="$3"
local fixture="$RPKI_DIR/tests/benchmark/selected_der/${sample}.mft"
if [[ ! -f "$fixture" ]]; then
echo "ERROR: ours fixture not found: $fixture" >&2
exit 1
fi
local perfdata="$OUT_PERF/ours_${sample}_${RUN_TAG}.perf.data"
local svg="$OUT_FLAME/ours_${sample}_${RUN_TAG}.svg"
local tsv="$OUT_HOT/ours_${sample}_${RUN_TAG}.tsv"
echo "== ours $sample (iters=$iters warmup=$warm hz=$PROFILE_HZ)"
$taskset_prefix perf record -o "$perfdata" -F "$PROFILE_HZ" -g -- \
"$OURS_BIN" --manifest "$fixture" --iterations "$iters" --warmup-iterations "$warm" --repeats 1 >/dev/null
flamegraph --perfdata "$perfdata" --output "$svg" --title "ours ${sample} ManifestObject::decode_der" --deterministic >/dev/null
perf report -i "$perfdata" --stdio --no-children --sort symbol --percent-limit 0.5 \
| awk '/^[[:space:]]*[0-9.]+%/ {pct=$1; sub(/%/,"",pct); $1=""; sub(/^[[:space:]]+/,""); print pct "\t" $0}' \
> "$tsv"
}
profile_routinator() {
local sample="$1"
local iters="$2"
local warm="$3"
local fixture="$ROUT_BENCH_DIR/fixtures/selected_der/${sample}.mft"
if [[ ! -f "$fixture" ]]; then
echo "ERROR: routinator fixture not found: $fixture" >&2
exit 1
fi
local svg="$OUT_FLAME/routinator_${sample}_${RUN_TAG}.svg"
local tsv="$OUT_HOT/routinator_${sample}_${RUN_TAG}.tsv"
echo "== routinator $sample (iters=$iters warmup=$warm hz=$PROFILE_HZ)"
$taskset_prefix "$ROUT_BIN" \
--target decode_only \
--manifest "$fixture" \
--issuer "$ROUT_ISSUER" \
--iterations "$iters" \
--repeats 1 \
--warmup-iterations "$warm" \
--strict false \
--profile-hz "$PROFILE_HZ" \
--flamegraph "$svg" \
--hotspots "$tsv" \
>/dev/null
}
echo "[3/3] Profile samples..."
# Choose iterations so each capture runs ~10-20s serially.
profile_ours small-01 200000 0
profile_routinator small-01 200000 0
profile_ours large-02 50000 0
profile_routinator large-02 50000 0
profile_ours xlarge-02 5000 0
profile_routinator xlarge-02 5000 0
echo "Done."
echo "- Flamegraphs: $OUT_FLAME/"
echo "- Hotspots: $OUT_HOT/"
echo "- Perf data: $OUT_PERF/"

View File

@@ -1,94 +0,0 @@
# Manual RRDP sync (APNIC-focused)
This directory contains **manual, command-line** scripts to reproduce the workflow described in:
- `specs/develop/20260226/apnic_rrdp_delta_analysis_after_manifest_revalidation_fix_20260227T022606Z.md`
They are meant for **hands-on validation / acceptance runs**, not for CI.
## Prerequisites
- Rust toolchain (`cargo`)
- `rsync` available on PATH (for rsync fallback/objects)
- Network access (RRDP over HTTPS)
## What the scripts do
### `full_sync.sh`
- Creates a fresh RocksDB directory
- Runs a **full serial** validation from a TAL URL (default: APNIC RFC7730 TAL)
- Writes:
- run log
- audit report JSON
- run meta JSON (includes durations + download_stats)
- short summary Markdown (includes durations + download_stats)
- RocksDB key statistics (`db_stats --exact`)
- RRDP repo state dump (`rrdp_state_dump`)
### `delta_sync.sh`
- Copies an existing “baseline snapshot DB” to a new DB directory (so the baseline is not modified)
- Runs another validation against the copied DB (RRDP will prefer **delta** when available)
- Produces the same artifacts as `full_sync.sh`
- Additionally generates a Markdown **delta analysis** report by comparing:
- base vs delta report JSON
- base vs delta `rrdp_state_dump` TSV
- and includes a **duration comparison** (base vs delta) if the base meta JSON is available
- delta meta JSON includes download_stats copied from delta report JSON
## Audit report fields (report.json)
The `rpki` binary writes an audit report JSON with:
- `format_version: 2`
- `downloads`: per-download RRDP/rsync events (URI, timestamps, duration, ok/fail, error, bytes, objects stats)
- `download_stats`: aggregate counters (by kind)
These are useful for diagnosing why a run is slow (e.g. RRDP snapshot vs delta vs rsync fallback).
## Meta fields (meta.json)
The scripts generate `*_meta.json` next to `*_report.json` and include:
- `durations_secs`: wall-clock duration breakdown for the script steps
- `download_stats`: copied from `report_json.download_stats`
## Usage
Run from `rpki/`:
```bash
./scripts/manual_sync/full_sync.sh
```
After you have a baseline run, run delta against it:
```bash
./scripts/manual_sync/delta_sync.sh target/live/manual_sync/apnic_full_db_YYYYMMDDTHHMMSSZ \
target/live/manual_sync/apnic_full_report_YYYYMMDDTHHMMSSZ.json
```
If the baseline was produced by `full_sync.sh`, the delta script will auto-discover the base meta JSON
next to the base report (by replacing `_report.json` with `_meta.json`) and include base durations in
the delta analysis report.
## Configuration (env vars)
Both scripts accept overrides via env vars:
- `TAL_URL` (default: APNIC TAL URL)
- `HTTP_TIMEOUT_SECS` (default: 1800)
- `RSYNC_TIMEOUT_SECS` (default: 1800)
- `RSYNC_MIRROR_ROOT` (default: disabled; when set, passes `--rsync-mirror-root` to `rpki`)
- `VALIDATION_TIME` (RFC3339; default: now UTC)
- `OUT_DIR` (default: `rpki/target/live/manual_sync`)
- `RUN_NAME` (default: auto timestamped)
Example:
```bash
TAL_URL="https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal" \
HTTP_TIMEOUT_SECS=1800 RSYNC_TIMEOUT_SECS=1800 \
./scripts/manual_sync/full_sync.sh
```

View File

@@ -1,515 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Delta sync + validation starting from a baseline snapshot DB.
#
# This script:
# 1) Copies BASE_DB_DIR -> DELTA_DB_DIR (so baseline is not modified)
# 2) Runs rpki validation again (RRDP will prefer delta if available)
# 3) Writes artifacts + a markdown delta analysis report
#
# Usage:
# ./scripts/manual_sync/delta_sync.sh <base_db_dir> <base_report_json>
#
# Outputs under OUT_DIR (default: target/live/manual_sync):
# - *_delta_db_* copied RocksDB directory
# - *_delta_report_*.json audit report
# - *_delta_run_*.log stdout/stderr log (includes summary)
# - *_delta_db_stats_*.txt db_stats --exact output
# - *_delta_rrdp_state_*.tsv rrdp_state_dump output
# - *_delta_analysis_*.md base vs delta comparison report
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
BASE_DB_DIR="${1:-}"
BASE_REPORT_JSON="${2:-}"
if [[ -z "${BASE_DB_DIR}" || -z "${BASE_REPORT_JSON}" ]]; then
echo "Usage: $0 <base_db_dir> <base_report_json>" >&2
exit 2
fi
if [[ ! -d "${BASE_DB_DIR}" ]]; then
echo "ERROR: base_db_dir is not a directory: ${BASE_DB_DIR}" >&2
exit 2
fi
if [[ ! -f "${BASE_REPORT_JSON}" ]]; then
echo "ERROR: base_report_json does not exist: ${BASE_REPORT_JSON}" >&2
exit 2
fi
TAL_URL="${TAL_URL:-https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal}"
HTTP_TIMEOUT_SECS="${HTTP_TIMEOUT_SECS:-1800}"
RSYNC_TIMEOUT_SECS="${RSYNC_TIMEOUT_SECS:-1800}"
RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-}"
VALIDATION_TIME="${VALIDATION_TIME:-}"
OUT_DIR="${OUT_DIR:-$ROOT_DIR/target/live/manual_sync}"
mkdir -p "$OUT_DIR"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_NAME="${RUN_NAME:-apnic_delta_${TS}}"
DELTA_DB_DIR="${DELTA_DB_DIR:-$OUT_DIR/${RUN_NAME}_db}"
DELTA_REPORT_JSON="${DELTA_REPORT_JSON:-$OUT_DIR/${RUN_NAME}_report.json}"
DELTA_RUN_LOG="${DELTA_RUN_LOG:-$OUT_DIR/${RUN_NAME}_run.log}"
BASE_DB_STATS_TXT="${BASE_DB_STATS_TXT:-$OUT_DIR/${RUN_NAME}_base_db_stats.txt}"
DELTA_DB_STATS_TXT="${DELTA_DB_STATS_TXT:-$OUT_DIR/${RUN_NAME}_delta_db_stats.txt}"
BASE_RRDP_STATE_TSV="${BASE_RRDP_STATE_TSV:-$OUT_DIR/${RUN_NAME}_base_rrdp_state.tsv}"
DELTA_RRDP_STATE_TSV="${DELTA_RRDP_STATE_TSV:-$OUT_DIR/${RUN_NAME}_delta_rrdp_state.tsv}"
DELTA_ANALYSIS_MD="${DELTA_ANALYSIS_MD:-$OUT_DIR/${RUN_NAME}_delta_analysis.md}"
DELTA_META_JSON="${DELTA_META_JSON:-$OUT_DIR/${RUN_NAME}_meta.json}"
# Best-effort base meta discovery (produced by `full_sync.sh`).
BASE_META_JSON="${BASE_META_JSON:-}"
if [[ -z "${BASE_META_JSON}" ]]; then
guess="${BASE_REPORT_JSON%_report.json}_meta.json"
if [[ -f "${guess}" ]]; then
BASE_META_JSON="${guess}"
fi
fi
echo "== rpki manual delta sync ==" >&2
echo "tal_url=$TAL_URL" >&2
echo "base_db=$BASE_DB_DIR" >&2
echo "base_report=$BASE_REPORT_JSON" >&2
echo "delta_db=$DELTA_DB_DIR" >&2
echo "delta_report=$DELTA_REPORT_JSON" >&2
echo "== copying base DB (baseline is not modified) ==" >&2
cp -a "$BASE_DB_DIR" "$DELTA_DB_DIR"
script_start_s="$(date +%s)"
run_start_s="$(date +%s)"
cmd=(cargo run --release --bin rpki -- \
--db "$DELTA_DB_DIR" \
--tal-url "$TAL_URL" \
--http-timeout-secs "$HTTP_TIMEOUT_SECS" \
--rsync-timeout-secs "$RSYNC_TIMEOUT_SECS" \
--report-json "$DELTA_REPORT_JSON")
if [[ -n "${RSYNC_MIRROR_ROOT}" ]]; then
cmd+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
fi
if [[ -n "${VALIDATION_TIME}" ]]; then
cmd+=(--validation-time "$VALIDATION_TIME")
fi
(
echo "# command:"
printf '%q ' "${cmd[@]}"
echo
echo
"${cmd[@]}"
) 2>&1 | tee "$DELTA_RUN_LOG" >/dev/null
run_end_s="$(date +%s)"
run_duration_s="$((run_end_s - run_start_s))"
echo "== db_stats (exact) ==" >&2
db_stats_start_s="$(date +%s)"
cargo run --release --bin db_stats -- --db "$BASE_DB_DIR" --exact 2>&1 | tee "$BASE_DB_STATS_TXT" >/dev/null
cargo run --release --bin db_stats -- --db "$DELTA_DB_DIR" --exact 2>&1 | tee "$DELTA_DB_STATS_TXT" >/dev/null
db_stats_end_s="$(date +%s)"
db_stats_duration_s="$((db_stats_end_s - db_stats_start_s))"
echo "== rrdp_state_dump ==" >&2
state_start_s="$(date +%s)"
cargo run --release --bin rrdp_state_dump -- --db "$BASE_DB_DIR" >"$BASE_RRDP_STATE_TSV"
cargo run --release --bin rrdp_state_dump -- --db "$DELTA_DB_DIR" >"$DELTA_RRDP_STATE_TSV"
state_end_s="$(date +%s)"
state_duration_s="$((state_end_s - state_start_s))"
script_end_s="$(date +%s)"
total_duration_s="$((script_end_s - script_start_s))"
echo "== delta analysis report ==" >&2
TAL_URL="$TAL_URL" \
BASE_DB_DIR="$BASE_DB_DIR" \
DELTA_DB_DIR="$DELTA_DB_DIR" \
DELTA_RUN_LOG="$DELTA_RUN_LOG" \
VALIDATION_TIME_ARG="$VALIDATION_TIME" \
HTTP_TIMEOUT_SECS="$HTTP_TIMEOUT_SECS" \
RSYNC_TIMEOUT_SECS="$RSYNC_TIMEOUT_SECS" \
RUN_DURATION_S="$run_duration_s" \
DB_STATS_DURATION_S="$db_stats_duration_s" \
STATE_DURATION_S="$state_duration_s" \
TOTAL_DURATION_S="$total_duration_s" \
python3 - "$BASE_REPORT_JSON" "$DELTA_REPORT_JSON" "$BASE_RRDP_STATE_TSV" "$DELTA_RRDP_STATE_TSV" \
"$BASE_DB_STATS_TXT" "$DELTA_DB_STATS_TXT" "$BASE_META_JSON" "$DELTA_META_JSON" "$DELTA_ANALYSIS_MD" <<'PY'
import json
import os
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
base_report_path = Path(sys.argv[1])
delta_report_path = Path(sys.argv[2])
base_state_path = Path(sys.argv[3])
delta_state_path = Path(sys.argv[4])
base_db_stats_path = Path(sys.argv[5])
delta_db_stats_path = Path(sys.argv[6])
base_meta_path_s = sys.argv[7]
delta_meta_path = Path(sys.argv[8])
out_md_path = Path(sys.argv[9])
def load_json(p: Path):
s = p.read_text(encoding="utf-8")
try:
return json.loads(s)
except json.JSONDecodeError:
# Backwards-compat / robustness: tolerate accidental literal trailing "\\n".
s2 = s.strip()
if s2.endswith("\\n"):
s2 = s2[:-2].rstrip()
return json.loads(s2)
def load_optional_json(path_s: str):
if not path_s:
return None
p = Path(path_s)
if not p.exists():
return None
return load_json(p)
def parse_rrdp_state_tsv(p: Path):
# format: "<notify_uri>\t<serial>\t<session_id>"
out = {}
for line in p.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
parts = line.split("\t")
if len(parts) != 3:
raise SystemExit(f"invalid rrdp_state_dump line in {p}: {line!r}")
uri, serial, session = parts
out[uri] = (int(serial), session)
return out
def parse_db_stats(p: Path):
# lines: key=value
out = {}
for line in p.read_text(encoding="utf-8").splitlines():
if "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip()
if v.isdigit():
out[k] = int(v)
else:
out[k] = v
return out
def warnings_total(rep: dict) -> int:
return len(rep["tree"]["warnings"]) + sum(len(pp["warnings"]) for pp in rep["publication_points"])
def report_summary(rep: dict) -> dict:
return {
"validation_time": rep["meta"]["validation_time_rfc3339_utc"],
"publication_points_processed": rep["tree"]["instances_processed"],
"publication_points_failed": rep["tree"]["instances_failed"],
"rrdp_repos_unique": len({pp.get("rrdp_notification_uri") for pp in rep["publication_points"] if pp.get("rrdp_notification_uri")}),
"vrps": len(rep["vrps"]),
"aspas": len(rep["aspas"]),
"audit_publication_points": len(rep["publication_points"]),
"warnings_total": warnings_total(rep),
}
def count_repo_sync_failed(rep: dict) -> int:
# Best-effort heuristic (we don't currently expose a structured counter in the audit report).
# Keep the match conservative to avoid false positives.
def is_repo_sync_failed(msg: str) -> bool:
m = msg.lower()
return "repo sync failed" in m or "rrdp fetch failed" in m or "rsync fetch failed" in m
n = 0
for w in rep["tree"]["warnings"]:
if is_repo_sync_failed(w.get("message", "")):
n += 1
for pp in rep["publication_points"]:
for w in pp.get("warnings", []):
if is_repo_sync_failed(w.get("message", "")):
n += 1
return n
def pp_manifest_sha(pp: dict) -> str:
# In our audit format, the first object is the manifest (synthetic entry) with sha256 of manifest_bytes.
for o in pp["objects"]:
if o["kind"] == "manifest":
return o["sha256_hex"]
return ""
def pp_objects_by_uri(rep: dict):
m = {}
for pp in rep["publication_points"]:
for o in pp["objects"]:
m[o["rsync_uri"]] = (o["sha256_hex"], o["kind"])
return m
def vrp_set(rep: dict):
return {(v["asn"], v["prefix"], v["max_length"]) for v in rep["vrps"]}
def rfc_refs_str(w: dict) -> str:
refs = w.get("rfc_refs") or []
return ", ".join(refs) if refs else ""
base = load_json(base_report_path)
delta = load_json(delta_report_path)
base_sum = report_summary(base)
delta_sum = report_summary(delta)
base_db = parse_db_stats(base_db_stats_path)
delta_db = parse_db_stats(delta_db_stats_path)
base_state = parse_rrdp_state_tsv(base_state_path)
delta_state = parse_rrdp_state_tsv(delta_state_path)
base_meta = load_optional_json(base_meta_path_s)
download_stats = delta.get("download_stats") or {}
delta_meta = {
"recorded_at_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"tal_url": os.environ["TAL_URL"],
"base_db_dir": os.environ["BASE_DB_DIR"],
"delta_db_dir": os.environ["DELTA_DB_DIR"],
"base_report_json": str(base_report_path),
"delta_report_json": str(delta_report_path),
"delta_run_log": os.environ["DELTA_RUN_LOG"],
"validation_time_arg": os.environ.get("VALIDATION_TIME_ARG",""),
"http_timeout_secs": int(os.environ["HTTP_TIMEOUT_SECS"]),
"rsync_timeout_secs": int(os.environ["RSYNC_TIMEOUT_SECS"]),
"durations_secs": {
"rpki_run": int(os.environ["RUN_DURATION_S"]),
"db_stats_exact": int(os.environ["DB_STATS_DURATION_S"]),
"rrdp_state_dump": int(os.environ["STATE_DURATION_S"]),
"total_script": int(os.environ["TOTAL_DURATION_S"]),
},
"download_stats": download_stats,
}
delta_meta_path.write_text(json.dumps(delta_meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# RRDP state changes
serial_changed = 0
session_changed = 0
serial_deltas = []
for uri, (old_serial, old_sess) in base_state.items():
if uri not in delta_state:
continue
new_serial, new_sess = delta_state[uri]
if new_serial != old_serial:
serial_changed += 1
serial_deltas.append((uri, old_serial, new_serial, new_serial - old_serial))
if new_sess != old_sess:
session_changed += 1
serial_deltas.sort(key=lambda x: x[3], reverse=True)
# Publication point diffs
base_pp = {pp["manifest_rsync_uri"]: pp for pp in base["publication_points"]}
delta_pp = {pp["manifest_rsync_uri"]: pp for pp in delta["publication_points"]}
base_keys = set(base_pp.keys())
delta_keys = set(delta_pp.keys())
new_pp = sorted(delta_keys - base_keys)
missing_pp = sorted(base_keys - delta_keys)
updated_pp = 0
unchanged_pp = 0
for k in sorted(base_keys & delta_keys):
if pp_manifest_sha(base_pp[k]) != pp_manifest_sha(delta_pp[k]):
updated_pp += 1
else:
unchanged_pp += 1
# Cache usage + repo sync failure hints
def source_counts(rep: dict) -> Counter:
c = Counter()
for pp in rep["publication_points"]:
c[pp.get("source","")] += 1
return c
base_sources = source_counts(base)
delta_sources = source_counts(delta)
base_repo_sync_failed = count_repo_sync_failed(base)
delta_repo_sync_failed = count_repo_sync_failed(delta)
def cache_reason_counts(rep: dict) -> Counter:
c = Counter()
for pp in rep.get("publication_points", []):
if pp.get("source") != "fetch_cache_pp":
continue
# Use warning messages as "reason". If missing, emit a fallback bucket.
ws = pp.get("warnings", [])
if not ws:
c["(no warnings recorded)"] += 1
continue
for w in ws:
msg = w.get("message", "").strip() or "(empty warning message)"
c[msg] += 1
return c
base_cache_reasons = cache_reason_counts(base)
delta_cache_reasons = cache_reason_counts(delta)
# Object change stats (by rsync URI, sha256)
base_obj = pp_objects_by_uri(base)
delta_obj = pp_objects_by_uri(delta)
kind_stats = {k: {"added": 0, "changed": 0, "removed": 0} for k in ["manifest","crl","certificate","roa","aspa","other"]}
all_uris = set(base_obj.keys()) | set(delta_obj.keys())
for uri in all_uris:
b = base_obj.get(uri)
d = delta_obj.get(uri)
if b is None and d is not None:
kind_stats[d[1]]["added"] += 1
elif b is not None and d is None:
kind_stats[b[1]]["removed"] += 1
else:
if b[0] != d[0]:
kind_stats[d[1]]["changed"] += 1
# VRP diff
base_v = vrp_set(base)
delta_v = vrp_set(delta)
added_v = delta_v - base_v
removed_v = base_v - delta_v
def fmt_db_stats(db: dict) -> str:
keys = ["raw_objects","rrdp_object_index","fetch_cache_pp","rrdp_state","total"]
out = []
for k in keys:
if k in db:
out.append(f"- `{k}={db[k]}`")
return "\n".join(out) if out else "_(missing db_stats keys)_"
lines = []
lines.append("# APNIC RRDP 增量同步验收(manual_sync)\n\n")
lines.append(f"时间戳:`{now}`(UTC)\n\n")
lines.append("## 复现信息\n\n")
lines.append(f"- base_report:`{base_report_path}`\n")
lines.append(f"- delta_report:`{delta_report_path}`\n")
lines.append(f"- base_db_stats:`{base_db_stats_path}`\n")
lines.append(f"- delta_db_stats:`{delta_db_stats_path}`\n")
lines.append(f"- base_rrdp_state:`{base_state_path}`\n")
lines.append(f"- delta_rrdp_state:`{delta_state_path}`\n\n")
lines.append("## 运行结果概览\n\n")
lines.append("| metric | base | delta |\n")
lines.append("|---|---:|---:|\n")
for k in [
"validation_time",
"publication_points_processed",
"publication_points_failed",
"rrdp_repos_unique",
"vrps",
"aspas",
"audit_publication_points",
"warnings_total",
]:
lines.append(f"| {k} | {base_sum[k]} | {delta_sum[k]} |\n")
lines.append("\n")
def dur(meta: dict | None, key: str):
if not meta:
return None
return (meta.get("durations_secs") or {}).get(key)
base_rpki_run = dur(base_meta, "rpki_run")
delta_rpki_run = delta_meta["durations_secs"]["rpki_run"]
base_total = dur(base_meta, "total_script")
delta_total = delta_meta["durations_secs"]["total_script"]
lines.append("## 持续时间(seconds)\n\n")
lines.append("| step | base | delta |\n")
lines.append("|---|---:|---:|\n")
lines.append(f"| rpki_run | {base_rpki_run if base_rpki_run is not None else 'unknown'} | {delta_rpki_run} |\n")
lines.append(f"| total_script | {base_total if base_total is not None else 'unknown'} | {delta_total} |\n")
lines.append("\n")
if base_meta is None and base_meta_path_s:
lines.append(f"> 注:未能读取 base meta:`{base_meta_path_s}`(文件不存在或不可读)。建议用 `full_sync.sh` 生成 baseline 以获得 base 时长对比。\n\n")
lines.append("RocksDB KV(`db_stats --exact`):\n\n")
lines.append("### 基线(base)\n\n")
lines.append(fmt_db_stats(base_db) + "\n\n")
lines.append("### 增量(delta)\n\n")
lines.append(fmt_db_stats(delta_db) + "\n\n")
lines.append("## RRDP 增量是否发生(基于 `rrdp_state` 变化)\n\n")
lines.append(f"- repo_total(base)={len(base_state)}\n")
lines.append(f"- repo_total(delta)={len(delta_state)}\n")
lines.append(f"- serial_changed={serial_changed}\n")
lines.append(f"- session_changed={session_changed}\n\n")
if serial_deltas:
lines.append("serial 增长最大的 10 个 RRDP repo(old -> new):\n\n")
for uri, old, new, diff in serial_deltas[:10]:
lines.append(f"- `{uri}`:`{old} -> {new}`(+{diff})\n")
lines.append("\n")
lines.append("## 发布点(Publication Point)变化统计\n\n")
lines.append("以 `manifest_rsync_uri` 作为发布点 key,对比 base vs delta:\n\n")
lines.append(f"- base PP:`{len(base_keys)}`\n")
lines.append(f"- delta PP:`{len(delta_keys)}`\n")
lines.append(f"- `new_pp={len(new_pp)}`\n")
lines.append(f"- `missing_pp={len(missing_pp)}`\n")
lines.append(f"- `updated_pp={updated_pp}`\n")
lines.append(f"- `unchanged_pp={unchanged_pp}`\n\n")
lines.append("> 注:`new_pp/missing_pp/updated_pp` 会混入“遍历范围变化”的影响(例如 validation_time 不同、或 base 中存在更多失败 PP)。\n\n")
lines.append("## fail fetch / cache 使用情况\n\n")
lines.append(f"- repo sync failed(启发式:warning contains 'repo sync failed'/'rrdp fetch failed'/'rsync fetch failed')\n")
lines.append(f" - base:`{base_repo_sync_failed}`\n")
lines.append(f" - delta:`{delta_repo_sync_failed}`\n\n")
lines.append("- source 计数(按 `PublicationPointAudit.source`):\n\n")
lines.append(f" - base:`{dict(base_sources)}`\n")
lines.append(f" - delta:`{dict(delta_sources)}`\n\n")
def render_cache_reasons(title: str, c: Counter) -> str:
if not c:
return f"{title}:`0`(未使用 fetch_cache_pp)\n\n"
lines = []
total = sum(c.values())
lines.append(f"{title}:`{total}`\n\n")
lines.append("Top reasons(按 warning message 聚合,可能一条 PP 有多条 warning):\n\n")
for msg, n in c.most_common(10):
lines.append(f"- `{n}` × {msg}\n")
lines.append("\n")
return "".join(lines)
lines.append(render_cache_reasons("- base `source=fetch_cache_pp`", base_cache_reasons))
lines.append(render_cache_reasons("- delta `source=fetch_cache_pp`", delta_cache_reasons))
lines.append("## 文件变更统计(按对象类型)\n\n")
lines.append("按 `ObjectAuditEntry.sha256_hex` 对比(同一 rsync URI 前后 hash 变化记为 `~changed`):\n\n")
lines.append("| kind | added | changed | removed |\n")
lines.append("|---|---:|---:|---:|\n")
for kind in ["manifest","crl","certificate","roa","aspa","other"]:
st = kind_stats[kind]
lines.append(f"| {kind} | {st['added']} | {st['changed']} | {st['removed']} |\n")
lines.append("\n")
lines.append("## VRP 影响(去重后集合 diff)\n\n")
lines.append("以 `(asn, prefix, max_length)` 为 key:\n\n")
lines.append(f"- base unique VRP:`{len(base_v)}`\n")
lines.append(f"- delta unique VRP:`{len(delta_v)}`\n")
lines.append(f"- `added={len(added_v)}`\n")
lines.append(f"- `removed={len(removed_v)}`\n")
lines.append(f"- `net={len(added_v) - len(removed_v)}`\n\n")
out_md_path.write_text("".join(lines), encoding="utf-8")
print(out_md_path)
PY
echo "== done ==" >&2
echo "artifacts:" >&2
echo "- delta db: $DELTA_DB_DIR" >&2
echo "- delta report: $DELTA_REPORT_JSON" >&2
echo "- delta run log: $DELTA_RUN_LOG" >&2
echo "- delta meta json: $DELTA_META_JSON" >&2
echo "- analysis md: $DELTA_ANALYSIS_MD" >&2
echo "- base state tsv: $BASE_RRDP_STATE_TSV" >&2
echo "- delta state tsv: $DELTA_RRDP_STATE_TSV" >&2

View File

@@ -1,189 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Full sync + validation from a TAL URL (default: APNIC).
#
# Produces artifacts under OUT_DIR (default: target/live/manual_sync):
# - *_db_* RocksDB directory
# - *_report_*.json audit report
# - *_run_*.log stdout/stderr log (includes summary)
# - *_db_stats_*.txt db_stats --exact output
# - *_rrdp_state_*.tsv rrdp_state_dump output
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
TAL_URL="${TAL_URL:-https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal}"
HTTP_TIMEOUT_SECS="${HTTP_TIMEOUT_SECS:-1800}"
RSYNC_TIMEOUT_SECS="${RSYNC_TIMEOUT_SECS:-1800}"
RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-}"
VALIDATION_TIME="${VALIDATION_TIME:-}"
OUT_DIR="${OUT_DIR:-$ROOT_DIR/target/live/manual_sync}"
mkdir -p "$OUT_DIR"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_NAME="${RUN_NAME:-apnic_full_${TS}}"
DB_DIR="${DB_DIR:-$OUT_DIR/${RUN_NAME}_db}"
REPORT_JSON="${REPORT_JSON:-$OUT_DIR/${RUN_NAME}_report.json}"
RUN_LOG="${RUN_LOG:-$OUT_DIR/${RUN_NAME}_run.log}"
DB_STATS_TXT="${DB_STATS_TXT:-$OUT_DIR/${RUN_NAME}_db_stats.txt}"
RRDP_STATE_TSV="${RRDP_STATE_TSV:-$OUT_DIR/${RUN_NAME}_rrdp_state.tsv}"
RUN_META_JSON="${RUN_META_JSON:-$OUT_DIR/${RUN_NAME}_meta.json}"
SUMMARY_MD="${SUMMARY_MD:-$OUT_DIR/${RUN_NAME}_summary.md}"
echo "== rpki manual full sync ==" >&2
echo "tal_url=$TAL_URL" >&2
echo "db=$DB_DIR" >&2
echo "report_json=$REPORT_JSON" >&2
echo "out_dir=$OUT_DIR" >&2
cmd=(cargo run --release --bin rpki -- \
--db "$DB_DIR" \
--tal-url "$TAL_URL" \
--http-timeout-secs "$HTTP_TIMEOUT_SECS" \
--rsync-timeout-secs "$RSYNC_TIMEOUT_SECS" \
--report-json "$REPORT_JSON")
if [[ -n "${RSYNC_MIRROR_ROOT}" ]]; then
cmd+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
fi
if [[ -n "${VALIDATION_TIME}" ]]; then
cmd+=(--validation-time "$VALIDATION_TIME")
fi
script_start_s="$(date +%s)"
run_start_s="$(date +%s)"
(
echo "# command:"
printf '%q ' "${cmd[@]}"
echo
echo
"${cmd[@]}"
) 2>&1 | tee "$RUN_LOG" >/dev/null
run_end_s="$(date +%s)"
run_duration_s="$((run_end_s - run_start_s))"
echo "== db_stats (exact) ==" >&2
db_stats_start_s="$(date +%s)"
cargo run --release --bin db_stats -- --db "$DB_DIR" --exact 2>&1 | tee "$DB_STATS_TXT" >/dev/null
db_stats_end_s="$(date +%s)"
db_stats_duration_s="$((db_stats_end_s - db_stats_start_s))"
echo "== rrdp_state_dump ==" >&2
state_start_s="$(date +%s)"
cargo run --release --bin rrdp_state_dump -- --db "$DB_DIR" >"$RRDP_STATE_TSV"
state_end_s="$(date +%s)"
state_duration_s="$((state_end_s - state_start_s))"
script_end_s="$(date +%s)"
total_duration_s="$((script_end_s - script_start_s))"
echo "== write run meta + summary ==" >&2
TAL_URL="$TAL_URL" \
DB_DIR="$DB_DIR" \
REPORT_JSON="$REPORT_JSON" \
RUN_LOG="$RUN_LOG" \
HTTP_TIMEOUT_SECS="$HTTP_TIMEOUT_SECS" \
RSYNC_TIMEOUT_SECS="$RSYNC_TIMEOUT_SECS" \
VALIDATION_TIME_ARG="$VALIDATION_TIME" \
RUN_DURATION_S="$run_duration_s" \
DB_STATS_DURATION_S="$db_stats_duration_s" \
STATE_DURATION_S="$state_duration_s" \
TOTAL_DURATION_S="$total_duration_s" \
python3 - "$REPORT_JSON" "$RUN_META_JSON" "$SUMMARY_MD" <<'PY'
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
report_path = Path(sys.argv[1])
meta_path = Path(sys.argv[2])
summary_path = Path(sys.argv[3])
rep = json.loads(report_path.read_text(encoding="utf-8"))
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
download_stats = rep.get("download_stats") or {}
meta = {
"recorded_at_utc": now,
"tal_url": os.environ["TAL_URL"],
"db_dir": os.environ["DB_DIR"],
"report_json": os.environ["REPORT_JSON"],
"run_log": os.environ["RUN_LOG"],
"validation_time_rfc3339_utc": rep["meta"]["validation_time_rfc3339_utc"],
"http_timeout_secs": int(os.environ["HTTP_TIMEOUT_SECS"]),
"rsync_timeout_secs": int(os.environ["RSYNC_TIMEOUT_SECS"]),
"validation_time_arg": os.environ.get("VALIDATION_TIME_ARG",""),
"durations_secs": {
"rpki_run": int(os.environ["RUN_DURATION_S"]),
"db_stats_exact": int(os.environ["DB_STATS_DURATION_S"]),
"rrdp_state_dump": int(os.environ["STATE_DURATION_S"]),
"total_script": int(os.environ["TOTAL_DURATION_S"]),
},
"counts": {
"publication_points_processed": rep["tree"]["instances_processed"],
"publication_points_failed": rep["tree"]["instances_failed"],
"vrps": len(rep["vrps"]),
"aspas": len(rep["aspas"]),
"audit_publication_points": len(rep["publication_points"]),
},
"download_stats": download_stats,
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
lines = []
lines.append("# Manual full sync summary\\n\\n")
lines.append(f"- recorded_at_utc: `{now}`\\n")
lines.append(f"- tal_url: `{meta['tal_url']}`\\n")
lines.append(f"- db: `{meta['db_dir']}`\\n")
lines.append(f"- report_json: `{meta['report_json']}`\\n")
lines.append(f"- validation_time: `{meta['validation_time_rfc3339_utc']}`\\n\\n")
lines.append("## Results\\n\\n")
lines.append("| metric | value |\\n")
lines.append("|---|---:|\\n")
for k in ["publication_points_processed","publication_points_failed","vrps","aspas","audit_publication_points"]:
lines.append(f"| {k} | {meta['counts'][k]} |\\n")
lines.append("\\n")
lines.append("## Durations (seconds)\\n\\n")
lines.append("| step | seconds |\\n")
lines.append("|---|---:|\\n")
for k,v in meta["durations_secs"].items():
lines.append(f"| {k} | {v} |\\n")
lines.append("\\n")
lines.append("## Download Stats\\n\\n")
lines.append("- raw events: `report_json.downloads`\\n")
lines.append("- aggregated: `report_json.download_stats` (copied into meta.json)\\n\\n")
def fmt_u(v):
if v is None:
return ""
return str(v)
by_kind = download_stats.get("by_kind") or {}
lines.append("| kind | ok | fail | duration_ms_total | bytes_total | objects_count_total | objects_bytes_total |\\n")
lines.append("|---|---:|---:|---:|---:|---:|---:|\\n")
for kind in sorted(by_kind.keys()):
st = by_kind[kind] or {}
lines.append(
f"| {kind} | {st.get('ok_total',0)} | {st.get('fail_total',0)} | {st.get('duration_ms_total',0)} | {fmt_u(st.get('bytes_total'))} | {fmt_u(st.get('objects_count_total'))} | {fmt_u(st.get('objects_bytes_total'))} |\\n"
)
lines.append("\\n")
summary_path.write_text("".join(lines), encoding="utf-8")
print(summary_path)
PY
echo "== done ==" >&2
echo "artifacts:" >&2
echo "- db: $DB_DIR" >&2
echo "- report: $REPORT_JSON" >&2
echo "- run log: $RUN_LOG" >&2
echo "- db stats: $DB_STATS_TXT" >&2
echo "- rrdp state: $RRDP_STATE_TSV" >&2
echo "- meta json: $RUN_META_JSON" >&2
echo "- summary md: $SUMMARY_MD" >&2

View File

@@ -1,123 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# M4: Compare decode+profile (OURS) vs routinator baseline (rpki crate 0.19.1)
# on selected_der_v2 fixtures (cer/crl/manifest/roa/aspa).
#
# Outputs under:
# - rpki/target/bench/stage2_selected_der_v2_routinator_decode_release.{csv,md}
# - rpki/target/bench/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release.{csv,md}
#
# Notes:
# - OURS decode benchmark is produced by:
# `cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture`
# and writes `stage2_selected_der_v2_decode_release.csv` when BENCH_OUT_CSV is set.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RPKI_DIR="$ROOT_DIR"
OUT_DIR="$RPKI_DIR/target/bench"
mkdir -p "$OUT_DIR"
OURS_CSV="${OURS_CSV:-$OUT_DIR/stage2_selected_der_v2_decode_release.csv}"
ROUT_CSV="${ROUT_CSV:-$OUT_DIR/stage2_selected_der_v2_routinator_decode_release.csv}"
ROUT_MD="${ROUT_MD:-$OUT_DIR/stage2_selected_der_v2_routinator_decode_release.md}"
COMPARE_CSV="${COMPARE_CSV:-$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release.csv}"
COMPARE_MD="${COMPARE_MD:-$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release.md}"
WARMUP_ITERS="${WARMUP_ITERS:-10}"
ROUNDS="${ROUNDS:-3}"
MIN_ROUND_MS="${MIN_ROUND_MS:-200}"
if [[ ! -f "$OURS_CSV" ]]; then
echo "ERROR: missing OURS CSV: $OURS_CSV" >&2
echo "Hint: run:" >&2
echo " cd rpki && BENCH_WARMUP_ITERS=$WARMUP_ITERS BENCH_ROUNDS=$ROUNDS BENCH_MIN_ROUND_MS=$MIN_ROUND_MS \\" >&2
echo " BENCH_OUT_CSV=target/bench/stage2_selected_der_v2_decode_release.csv \\" >&2
echo " cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture" >&2
exit 2
fi
echo "[1/2] Run routinator baseline bench (release)..." >&2
(cd "$RPKI_DIR/benchmark/routinator_object_bench" && cargo run --release -q -- \
--dir "$RPKI_DIR/tests/benchmark/selected_der_v2" \
--warmup-iters "$WARMUP_ITERS" \
--rounds "$ROUNDS" \
--min-round-ms "$MIN_ROUND_MS" \
--out-csv "$ROUT_CSV" \
--out-md "$ROUT_MD")
echo "[2/2] Join CSVs + compute ratios..." >&2
python3 - "$OURS_CSV" "$ROUT_CSV" "$COMPARE_CSV" "$COMPARE_MD" <<'PY'
import csv
import sys
from pathlib import Path
ours_path = Path(sys.argv[1])
rout_path = Path(sys.argv[2])
out_csv_path = Path(sys.argv[3])
out_md_path = Path(sys.argv[4])
def read_csv(path: Path):
with path.open(newline="") as f:
return list(csv.DictReader(f))
ours_rows = read_csv(ours_path)
rout_rows = read_csv(rout_path)
rout_by_key = {(r["type"], r["sample"]): r for r in rout_rows}
out_rows = []
for r in ours_rows:
key = (r["type"], r["sample"])
rr = rout_by_key.get(key)
if rr is None:
raise SystemExit(f"missing routinator row for {key}")
ours_ns = float(r["avg_ns_per_op"])
rout_ns = float(rr["avg_ns_per_op"])
ratio = (ours_ns / rout_ns) if rout_ns != 0.0 else float("inf")
out_rows.append({
"type": r["type"],
"sample": r["sample"],
"size_bytes": r["size_bytes"],
"complexity": r["complexity"],
"ours_avg_ns_per_op": f"{ours_ns:.2f}",
"ours_ops_per_sec": f"{float(r['ops_per_sec']):.2f}",
"rout_avg_ns_per_op": f"{rout_ns:.2f}",
"rout_ops_per_sec": f"{float(rr['ops_per_sec']):.2f}",
"ratio_ours_over_rout": f"{ratio:.4f}",
})
out_rows.sort(key=lambda x: (x["type"], x["sample"]))
out_csv_path.parent.mkdir(parents=True, exist_ok=True)
with out_csv_path.open("w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(out_rows[0].keys()))
w.writeheader()
w.writerows(out_rows)
lines = []
lines.append("# Stage2 ours vs routinator (decode+profile, selected_der_v2)\n")
lines.append(f"- ours_csv: `{ours_path}`\n")
lines.append(f"- rout_csv: `{rout_path}`\n")
lines.append("\n")
lines.append("| type | sample | size_bytes | complexity | ours ns/op | rout ns/op | ratio |\n")
lines.append("|---|---|---:|---:|---:|---:|---:|\n")
for r in out_rows:
lines.append(
f"| {r['type']} | {r['sample']} | {r['size_bytes']} | {r['complexity']} | "
f"{r['ours_avg_ns_per_op']} | {r['rout_avg_ns_per_op']} | {r['ratio_ours_over_rout']} |\n"
)
out_md_path.parent.mkdir(parents=True, exist_ok=True)
out_md_path.write_text("".join(lines), encoding="utf-8")
PY
echo "Done." >&2
echo "- routinator CSV: $ROUT_CSV" >&2
echo "- compare CSV: $COMPARE_CSV" >&2
echo "- compare MD: $COMPARE_MD" >&2

View File

@@ -182,8 +182,6 @@ RFC 引用:RFC 5280 §4.2.2.1;RFC 5280 §4.2.2.2;RFC 5280 §4.2.1.6。
| `1.2.840.113549.1.9.5` | CMS signedAttrs: signing-time | RFC 9589 §4(更新 RFC 6488 §3(1f)/(1g)) |
| `1.2.840.113549.1.9.16.1.24` | ROA eContentType: id-ct-routeOriginAuthz | RFC 9582 §3 |
| `1.2.840.113549.1.9.16.1.26` | Manifest eContentType: id-ct-rpkiManifest | RFC 9286 §4.1 |
| `1.2.840.113549.1.9.16.1.35` | Ghostbusters eContentType: id-ct-rpkiGhostbusters | RFC 6493 §6;RFC 6493 §9.1 |
| `1.2.840.113549.1.9.16.1.49` | ASPA eContentType: id-ct-ASPA | `draft-ietf-sidrops-aspa-profile-21` §2 |
| `1.3.6.1.5.5.7.1.1` | X.509 v3 扩展:authorityInfoAccess | RFC 5280 §4.2.2.1 |
| `1.3.6.1.5.5.7.1.11` | X.509 v3 扩展:subjectInfoAccess | RFC 5280 §4.2.2.2;RPKI 约束见 RFC 6487 §4.8.8 |
| `1.3.6.1.5.5.7.48.2` | AIA accessMethod: id-ad-caIssuers | RFC 5280 §4.2.2.1 |

View File

@@ -1,94 +1,36 @@
# 01. TAL(Trust Anchor Locator)
# 01. Trust Anchor Locator (TAL)
## 1.1 对象定位
TAL是一个数据格式/配置文件,目的是告诉RP信任锚的公钥是什么,以及相关对象可以从哪里获取。
TAL(Trust Anchor Locator)用于向 RP 提供:
1) 可检索“当前 TA 证书”的一个或多个 URI;以及
2) 该 TA 证书的 `subjectPublicKeyInfo`(SPKI)期望值(用于绑定/防替换)。
RFC 8630 §2;RFC 8630 §2.3。
## 1.2 原始载体与编码
- 载体:文本文件(ASCII/UTF-8 兼容的行文本)。
- 行结束:允许 `CRLF` 或 `LF`。RFC 8630 §2.2。
- 结构:`[可选注释区] + URI 区 + 空行 + Base64(SPKI DER)`。RFC 8630 §2.2。
### 1.2.1 注释区
- 一行或多行,以 `#` 开头,后随人类可读 UTF-8 文本。RFC 8630 §2.2。
- 注释行文本需符合 RFC 5198 §2 的限制(RFC 8630 §2.2 引用)。
### 1.2.2 URI 区
- 一行或多行,每行一个 TA URI,按序排列。RFC 8630 §2.2。
- TA URI **MUST** 是 `rsync` 或 `https`。RFC 8630 §2.2。
### 1.2.3 空行分隔
- URI 区后必须有一个额外的换行(即空行),用于与 Base64 区分隔。RFC 8630 §2.2(第 3 点)。
### 1.2.4 SPKI(Base64)
- `subjectPublicKeyInfo` 以 DER 编码(ASN.1)后,再 Base64 编码表示。RFC 8630 §2.2(第 4 点)。
- 为避免长行,Base64 字符串中 **MAY** 插入换行。RFC 8630 §2.2。
- SPKI ASN.1 类型来自 X.509 / RFC 5280。RFC 8630 §2.2(第 4 点);RFC 5280 §4.1.2.7。
#### 1.2.4.1 `SubjectPublicKeyInfo` 的 ASN.1 定义(RFC 5280 §4.1)
TAL 中携带的是一个 X.509 `SubjectPublicKeyInfo` 的 DER 字节串(再 Base64)。其 ASN.1 定义如下:RFC 5280 §4.1。
```asn1
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier,
subjectPublicKey BIT STRING }
## 1.2 数据格式 (RFC 8630 §2.2)
TAL是一个配置文件,格式定义如下:
```
The TAL is an ordered sequence of:
1. an optional comment section consisting of one or more lines each starting with the "#" character, followed by human-readable informational UTF-8 text, conforming to the restrictions defined
in Section 2 of [RFC5198], and ending with a line break,
2. a URI section that is comprised of one or more ordered lines, each containing a TA URI, and ending with a line break,
3. a line break, and
4. a subjectPublicKeyInfo [RFC5280] in DER format [X.509], encoded in base64 (see Section 4 of [RFC4648]). To avoid long lines,
line breaks MAY be inserted into the base64-encoded string.
Note that line breaks in this file can use either "<CRLF>" or "<LF>".
```
其中 `algorithm`/`subjectPublicKey` 的取值受 RPKI 算法 profile 约束(例如 RSA 2048 + SHA-256 等;SKI/AKI 计算仍用 SHA-1)。RFC 5280 §4.1.2.7;RFC 7935 §2-§3.1;RFC 6487 §4.8.2-§4.8.3。
## 1.3 抽象数据模型
## 1.3 解析规则(语义层)
输入:`TalFileBytes: bytes`。
解析步骤:
1) 按 `LF` / `CRLF` 识别行。RFC 8630 §2.2。
2) 从文件开头读取所有以 `#` 开头的行,作为 `comments`(保留去掉 `#` 后的 UTF-8 文本或保留原始行均可,但需保持 UTF-8)。RFC 8630 §2.2。
3) 继续读取一行或多行非空行,作为 `ta_uris`(保持顺序)。RFC 8630 §2.2(第 2 点)。
4) 读取一个空行(必须存在)。RFC 8630 §2.2(第 3 点)。
5) 将剩余行拼接为 Base64 文本(移除行分隔),Base64 解码得到 `subject_public_key_info_der`。RFC 8630 §2.2(第 4 点)。
6) 可选:将 `subject_public_key_info_der` 解析为 X.509 `SubjectPublicKeyInfo` 结构(用于与 TA 证书比对)。RFC 8630 §2.3;RFC 5280 §4.1.2.7。
URI 解析与约束:
- `ta_uris[*]` 的 scheme **MUST** 为 `rsync` 或 `https`。RFC 8630 §2.2。
- 每个 `ta_uri` **MUST** 指向“单个对象”,且 **MUST NOT** 指向目录或集合。RFC 8630 §2.3。
## 1.4 抽象数据模型(接口)
### 1.4.1 `Tal`
### 1.3.1 TAL
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw` | `bytes` | TAL 原始文件字节 | 原样保留(可选但建议) | RFC 8630 §2.2 |
| `comments` | `list[Utf8Text]` | 注释行(按出现顺序) | 每行以 `#` 开头;文本为 UTF-8;内容限制见 RFC 5198 §2 | RFC 8630 §2.2 |
| `ta_uris` | `list[Uri]` | TA 证书位置列表 | 至少 1 个;按序;每个 scheme 必须是 `rsync` 或 `https` | RFC 8630 §2.2 |
| `subject_public_key_info_der` | `DerBytes` | TA 证书 SPKI 的期望 DER | Base64 解码所得 DER;Base64 中可有换行 | RFC 8630 §2.2 |
|----------|-------------|-------------------------|--------------------------------------------|---------------|
| uris | Vec<TalUri> | 指向TA的URI列表 | 允许rsync和https协议。 | RFC 8630 §2.1 |
| comment | Vec<String> | 注释(可选) | | RFC 8630 §2.2 |
| spki_der | Vec<u8> | 原始的subjectPublicKeyInfo | x.509 SubjectPublicKeyInfo DER编码,再base64编码 | RFC 8630 §2.2 |
### 1.4.2 `TaUri`(可选细化)
> 若你的实现希望对 URI 做更强类型化,可在 `Tal.ta_uris` 上进一步拆分为 `TaUri` 结构。
### 1.3.2 TalUri
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `uri` | `Uri` | 完整 URI 文本 | scheme 为 `rsync` 或 `https` | RFC 8630 §2.2 |
| `scheme` | `enum` | `rsync` / `https` | 从 `uri` 解析 | RFC 8630 §2.2 |
|-------|--------|---------|---------|---------------|
| Rsync | String | rsync地址 | | RFC 8630 §2.1 |
| Https | String | https地址 | | RFC 8630 §2.1 |
## 1.5 字段级约束清单(实现对照)
- TAL 由(可选)注释区 + URI 区 + 空行 + Base64(SPKI DER) 组成。RFC 8630 §2.2。
- URI 区至少 1 行,每行一个 TA URI,顺序有意义。RFC 8630 §2.2。
- TA URI 仅允许 `rsync` 或 `https`。RFC 8630 §2.2。
- Base64 区允许插入换行。RFC 8630 §2.2。
- 每个 TA URI 必须引用单个对象,不能指向目录/集合。RFC 8630 §2.3。

121
specs/02_ta.md Normal file
View File

@@ -0,0 +1,121 @@
# 02. Trust Anchor (TA)
## 2.1 对象定位
TA是一个自签名的CA证书。
## 2.2 原始载体与编码
- 载体:X.509 certificates.
- 编码:DER(遵循 RFC 5280 的 certificate 结构与字段语义,但受限于RFC 8630 §2.3)
## 2.3 抽象数据类型
### 2.3.1 TA
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|-------------------|------------------|---------------|---------|---------------|
| name | String | 标识该TA,如apnic等 | | |
| cert_der | Vec<u8> | 原始DER内容 | | |
| cert | X509Certificate | 基础X509证书 | | RFC 5280 §4.1 |
| resource | ResourceSet | 资源集合 | | |
| publication_point | Uri | 获取该TA的URI | | |
### 2.3.2 ResourceSet
资源集合是来自RFC 3779的IP地址块(§2)和AS号段(§3),受约束于RFC 8630 §2.3
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|------|----------------|--------|-------------|---------------------------|
| ips | IpResourceSet | IP地址集合 | 不能是inherit | RFC 3779 §2和RFC 8630 §2.3 |
| asns | AsnResourceSet | ASN集合 | 不能是inherit | RFC 3779 §3和RFC 8630 §2.3 |
[//]: # ()
[//]: # (### 2.3.3 IpResourceSet)
[//]: # (包括IPv4和IPv6的前缀表示)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|----|------------------------|----------|-------------|--------------|)
[//]: # (| v4 | PrefixSet<Ipv4Prefix> | IPv4前缀集合 | | RFC 3779 §2 |)
[//]: # (| v6 | PrefixSet<Ipv6Prefix> | IPv6前缀集合 | | RFC 3779 §2 |)
[//]: # ()
[//]: # (### 2.3.4 AsnResourceSet)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|-------|--------------------|-------|-------------|-------------|)
[//]: # (| range | RangeSet<AsnBlock> | ASN集合 | | RFC 3779 §3 |)
[//]: # ()
[//]: # (### 2.3.5 Ipv4Prefix)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|------|-----|-----|---------|-------------|)
[//]: # (| addr | u32 | 地址 | | RFC 3779 §2 |)
[//]: # (| len | u8 | 长度 | 0-32 | RFC 3779 §2 |)
[//]: # ()
[//]: # ()
[//]: # (### 2.3.6 Ipv6Prefix)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|------|------|-----|---------|-------------|)
[//]: # (| addr | u128 | 地址 | | RFC 3779 §2 |)
[//]: # (| len | u8 | 长度 | 0-128 | RFC 3779 §2 |)
[//]: # ()
[//]: # (### 2.3.7 AsnBlock)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|----------|----------|-------|---------|--------------|)
[//]: # (| asn | Asn | ASN | | RFC 3779 §3 |)
[//]: # (| asnRange | AsnRange | ASN范围 | | RFC 3779 §3 |)
[//]: # ()
[//]: # ()
[//]: # (### 2.3.8 Asn)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|-----|-----|-----|---------|-------------|)
[//]: # (| asn | u32 | ASN | | RFC 3779 §3 |)
[//]: # ()
[//]: # (### 2.3.8 AsnRange)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|-----|-----|-------|---------|--------------|)
[//]: # (| min | Asn | 最小ASN | | RFC 3779 §3 |)
[//]: # (| max | Asn | 最大ASN | | RFC 3779 §3 |)
# 2.4 TA校验流程(RFC 8630 §3)
1. 从TAL的URI列表中获取证书对象。(顺序访问,若前面失效,再访问后面的)
2. 验证证书格式,必须是当前、有效的自签名RPKI证书。
3. 验证公钥匹配。TAL中的SubjectPublicKeyInfo与下载证书的公钥一致。
4. 其他检查。
5. 更新本地存储库缓存。

View File

@@ -1,88 +0,0 @@
# 02. TA(Trust Anchor)自签名证书
## 2.1 对象定位
在 RP 侧,“信任锚(Trust Anchor, TA)”以一个**自签名 CA 资源证书**体现,其可获取位置与期望公钥由 TAL 提供。RFC 8630 §2.3。
本文件描述两个紧密相关的数据对象:
1) `TaCertificate`:TA 自签名资源证书本体(X.509 DER)
2) `TrustAnchor`:语义组合对象(`TAL` + `TaCertificate` 的绑定语义)
## 2.2 原始载体与编码
- 载体:X.509 证书(通常以 `.cer` 存放于仓库,但文件扩展名不作为语义依据)。
- 编码:DER。TA 证书必须符合 RPKI 资源证书 profile。RFC 8630 §2.3;RFC 6487 §4。
### 2.2.1 X.509 Certificate 的 ASN.1 定义(RFC 5280 §4.1;TA 与 RC 共享)
TA 证书与普通资源证书(RC)在编码层面都是 X.509 `Certificate`(DER)。其 ASN.1 定义如下:RFC 5280 §4.1。
```asn1
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING }
```
其中 `tbsCertificate.extensions`(v3 扩展)是 RPKI 语义的主要承载处(IP/AS 资源扩展、SIA/AIA/CRLDP 等)。RFC 5280 §4.1;RPKI 对字段/扩展存在性与关键性约束见 RFC 6487 §4。
> 说明:更完整的 RC 编码层结构(包括 Extension 外层“extnValue 二次 DER 解码”的套娃方式)在 `03_resource_certificate_rc.md` 与 `00_common_types.md` 中给出。
## 2.3 TA 证书的 RPKI 语义约束(在 RC profile 基础上额外强调)
### 2.3.1 自签名与 profile
- TA URI 指向的对象 **MUST** 是一个**自签名 CA 证书**,并且 **MUST** 符合 RPKI 证书 profile。RFC 8630 §2.3;RFC 6487 §4。
- 自签名证书在 RC profile 下的通用差异(例如 CRLDP/AIA 的省略规则、AKI 的规则)见 RFC 6487。RFC 6487 §4.8.3;RFC 6487 §4.8.6;RFC 6487 §4.8.7。
### 2.3.2 INR(IP/AS 资源扩展)在 TA 上的额外约束
- TA 的 INR 扩展(IP/AS 资源扩展,RFC 3779)**MUST** 是非空资源集合。RFC 8630 §2.3。
- TA 的 INR 扩展 **MUST NOT** 使用 `inherit` 形式。RFC 8630 §2.3。
- 说明:一般 RC profile 允许 `inherit`。RFC 6487 §4.8.10;RFC 6487 §4.8.11;RFC 3779 §2.2.3.5;RFC 3779 §3.2.3.3。
### 2.3.3 TAL ↔ TA 公钥绑定
- 用于验证 TA 的公钥(来自 TAL 中的 SPKI)**MUST** 与 TA 证书中的 `subjectPublicKeyInfo` 相同。RFC 8630 §2.3。
### 2.3.4 TA 稳定性语义(实现需建模为“约束/假设”,但不属于验证结果态)
- TA 公钥与 TAL 中公钥必须保持稳定(用于 RP 侧长期信任锚)。RFC 8630 §2.3。
### 2.3.5 TA 与 CRL/Manifest 的关系(语义)
- RFC 8630 指出:TA 为自签名证书,没有对应 CRL,且不会被 manifest 列出;TA 的获取/轮换由 TAL 控制。RFC 8630 §2.3。
> 注:这条更偏“发布/运维语义”,但对数据对象建模有影响:`TrustAnchor` 组合对象不应依赖 CRL/MFT 的存在。
## 2.4 抽象数据模型(接口)
### 2.4.1 `TaCertificate`
> 该对象在字段层面复用 `RC(CA)` 的语义模型(见 `03_resource_certificate_rc.md`),但增加 TA 特有约束。
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw_der` | `DerBytes` | TA 证书 DER | X.509 DER;证书 profile 约束见 RC 文档 | RFC 8630 §2.3;RFC 6487 §4 |
| `rc_ca` | `ResourceCaCertificate` | 以 RC(CA) 语义解析出的字段集合 | 必须满足“自签名 CA”分支约束;且 INR 必须非空且不允许 inherit | RFC 8630 §2.3;RFC 6487 §4;RFC 3779 §2/§3 |
### 2.4.2 `TrustAnchor`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `tal` | `Tal` | TAL 文件语义对象 | 见 `01_tal.md` | RFC 8630 §2.2 |
| `ta_certificate` | `TaCertificate` | TA 证书语义对象 | TA URI 指向的对象 | RFC 8630 §2.3 |
| `tal_spki_der` | `DerBytes` | 从 TAL 解析出的 SPKI DER | `tal.subject_public_key_info_der` | RFC 8630 §2.2 |
| `ta_spki_der` | `DerBytes` | 从 TA 证书抽取的 SPKI DER | `ta_certificate` 的 `subjectPublicKeyInfo` | RFC 8630 §2.3;RFC 5280 §4.1.2.7 |
**绑定约束(字段级)**
- `tal_spki_der` 必须与 `ta_spki_der` 完全相等(字节层面的 DER 等价)。RFC 8630 §2.3。
## 2.5 字段级约束清单(实现对照)
- TA URI 指向的对象必须是自签名 CA 证书,且符合 RPKI 证书 profile。RFC 8630 §2.3;RFC 6487 §4。
- TA 的 INR 扩展必须非空,且不得使用 inherit。RFC 8630 §2.3。
- TAL 中 SPKI 必须与 TA 证书的 `subjectPublicKeyInfo` 匹配。RFC 8630 §2.3。
- TA 不依赖 CRL/MFT(无对应 CRL,且不被 manifest 列出)。RFC 8630 §2.3。

314
specs/03_rc.md Normal file
View File

@@ -0,0 +1,314 @@
# 03. RC (Resource Certifications)
## 3.1 对象定位
RC是资源证书,包括CA和EE
## 3.2 原始载体与编码
- 载体:X.509 certificates.
- 编码:DER(遵循 RFC 5280 的 Certificate 结构与字段语义,但受 RPKI profile 限制)RFC 6487 §4
### 3.2.1 基本语法(RFC 5280 §4,RFC 6487 )
RC是遵循RFC5280定义的X.509Certificate语法(RFC 5280 §4),并且符合RFC 6487 §4的约束。只选取RFC 6487 §4章节列出来的字段。(Unless specifically noted as being OPTIONAL, all the fields listed
here MUST be present, and any other fields MUST NOT appear in a
conforming resource certificate.)
```
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING
}
TBSCertificate ::= SEQUENCE {
version [0] EXPLICIT Version MUST be v3,
serialNumber CertificateSerialNumber,
signature AlgorithmIdentifier,
issuer Name,
subject Name,
validity Validity,
subjectPublicKeyInfo SubjectPublicKeyInfo,
extensions [3] EXPLICIT Extensions OPTIONAL
-- If present, version MUST be v3
}
Version ::= INTEGER { v1(0), v2(1), v3(2) }
CertificateSerialNumber ::= INTEGER
Validity ::= SEQUENCE {
notBefore Time,
notAfter Time }
Time ::= CHOICE {
utcTime UTCTime,
generalTime GeneralizedTime }
UniqueIdentifier ::= BIT STRING
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier,
subjectPublicKey BIT STRING }
Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension
Extension ::= SEQUENCE {
extnID OBJECT IDENTIFIER,
critical BOOLEAN DEFAULT FALSE,
extnValue OCTET STRING
-- contains the DER encoding of an ASN.1 value
-- corresponding to the extension type identified
-- by extnID
}
```
> 其中`Name` "a valid X.501 distinguished name"(RFC 6487 §4.4)
### 3.2.2 证书扩展字段 (RFC 6487 §4.8)
RC的证书扩展字段按照RFC 6487 §4.8的规定,有以下几个扩展:
- Basic Constraints
- Subject Key Identifier
- Authority Key Identifier
- Key Usage
- Extended Key Usage(CA证书,以及验证RPKI对象的EE证书不能出现该字段。非RPKI对象的EE可以出现EKU,但必须为non-critical)
- CRL Distribution Points
- Authority Information Access
- Subject Information Access
- SIA for CA Certificates
- SIA for EE Certificates
- Certificate Policies
- IP Resources
- AS Resources
```
# Basic Constraints
id-ce-basicConstraints OBJECT IDENTIFIER ::= { id-ce 19 }
BasicConstraints ::= SEQUENCE {
cA BOOLEAN DEFAULT FALSE }
# Subject Key Identifier
id-ce-subjectKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 14 }
SubjectKeyIdentifier ::= KeyIdentifier
KeyIdentifier ::= OCTET STRING
# Authority Key Identifier
id-ce-authorityKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 35 }
AuthorityKeyIdentifier ::= SEQUENCE {
keyIdentifier [0] KeyIdentifier OPTIONAL }
# Key Usage
id-ce-keyUsage OBJECT IDENTIFIER ::= { id-ce 15 }
KeyUsage ::= BIT STRING {
digitalSignature (0),
nonRepudiation (1), -- recent editions of X.509 have
-- renamed this bit to contentCommitment
keyEncipherment (2),
dataEncipherment (3),
keyAgreement (4),
keyCertSign (5),
cRLSign (6),
encipherOnly (7),
decipherOnly (8) }
# Extended Key Usage
id-ce-extKeyUsage OBJECT IDENTIFIER ::= { id-ce 37 }
ExtKeyUsageSyntax ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId
KeyPurposeId ::= OBJECT IDENTIFIER
# CRL Distribution Points
id-ce-cRLDistributionPoints OBJECT IDENTIFIER ::= { id-ce 31 }
CRLDistributionPoints ::= SEQUENCE SIZE (1..MAX) OF DistributionPoint
DistributionPoint ::= SEQUENCE {
distributionPoint [0] DistributionPointName OPTIONAL }
DistributionPointName ::= CHOICE {
fullName [0] GeneralNames }
## Authority Information Access
id-pe-authorityInfoAccess OBJECT IDENTIFIER ::= { id-pe 1 }
AuthorityInfoAccessSyntax ::=
SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
# AccessDescription
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
# CA 证书发布位置
id-ad-caIssuers OBJECT IDENTIFIER ::= { id-ad 2 }
# OCSP 服务地址
id-ad-ocsp OBJECT IDENTIFIER ::= { id-ad 1 }
# Subject Information Access
id-pe-subjectInfoAccess OBJECT IDENTIFIER ::= { id-pe 11 }
SubjectInfoAccessSyntax ::= SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
## Subject Information Access for CA (RFC 6487 §4.8.8.1)
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-rpkiManifest OBJECT IDENTIFIER ::= { id-ad 10 }
必须存在一个accessMethod=id-ad-caRepository,accessLocation=rsyncURI。
必须存在一个accessMethod=id-ad-repiManifest, accessLocation=rsync URI,指向该CA的mft对象。
## Subject Information Access for EE (RFC 6487 §4.8.8.2)
id-ad-signedObject OBJECT IDENTIFIER ::= { id-ad 11 }
必须存在一个accessMethod=id-ad-signedObject, accessLocation=rsyncURI
不允许其他的accessMethod
# Certificate Policies
id-ce-certificatePolicies OBJECT IDENTIFIER ::= { id-ce 32 }
anyPolicy OBJECT IDENTIFIER ::= { id-ce-certificatePolicies 0 }
certificatePolicies ::= SEQUENCE SIZE (1..MAX) OF PolicyInformation
PolicyInformation ::= SEQUENCE {
policyIdentifier CertPolicyId,
policyQualifiers SEQUENCE SIZE (1..MAX) OF PolicyQualifierInfo OPTIONAL }
CertPolicyId ::= OBJECT IDENTIFIER
PolicyQualifierInfo ::= SEQUENCE {
policyQualifierId PolicyQualifierId,
qualifier ANY DEFINED BY policyQualifierId }
-- policyQualifierIds for Internet policy qualifiers
id-qt OBJECT IDENTIFIER ::= { id-pkix 2 }
id-qt-cps OBJECT IDENTIFIER ::= { id-qt 1 }
id-qt-unotice OBJECT IDENTIFIER ::= { id-qt 2 }
PolicyQualifierId ::= OBJECT IDENTIFIER ( id-qt-cps | id-qt-unotice )
Qualifier ::= CHOICE {
cPSuri CPSuri,
userNotice UserNotice }
CPSuri ::= IA5String
UserNotice ::= SEQUENCE {
noticeRef NoticeReference OPTIONAL,
explicitText DisplayText OPTIONAL }
NoticeReference ::= SEQUENCE {
organization DisplayText,
noticeNumbers SEQUENCE OF INTEGER }
DisplayText ::= CHOICE {
ia5String IA5String (SIZE (1..200)),
visibleString VisibleString (SIZE (1..200)),
bmpString BMPString (SIZE (1..200)),
utf8String UTF8String (SIZE (1..200)) }
# IP Resources
id-pe-ipAddrBlocks OBJECT IDENTIFIER ::= { id-pe 7 }
IPAddrBlocks ::= SEQUENCE OF IPAddressFamily
IPAddressFamily ::= SEQUENCE { -- AFI & optional SAFI --
addressFamily OCTET STRING (SIZE (2..3)),
ipAddressChoice IPAddressChoice }
IPAddressChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
addressesOrRanges SEQUENCE OF IPAddressOrRange }
IPAddressOrRange ::= CHOICE {
addressPrefix IPAddress,
addressRange IPAddressRange }
IPAddressRange ::= SEQUENCE {
min IPAddress,
max IPAddress }
IPAddress ::= BIT STRING
# AS Resources
id-pe-autonomousSysIds OBJECT IDENTIFIER ::= { id-pe 8 }
ASIdentifiers ::= SEQUENCE {
asnum [0] EXPLICIT ASIdentifierChoice OPTIONAL,
rdi [1] EXPLICIT ASIdentifierChoice OPTIONAL}
ASIdentifierChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
asIdsOrRanges SEQUENCE OF ASIdOrRange }
ASIdOrRange ::= CHOICE {
id ASId,
range ASRange }
ASRange ::= SEQUENCE {
min ASId,
max ASId }
ASId ::= INTEGER
```
# 3.3 抽象数据结构
采用X509 Certificate + Resource + 约束校验的方式组合
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|----------|---------------------|----------|---------|---------------|
| cert_der | Vec<u8> | 证书原始数据 | | |
| cert | X509Certificate | 基础X509证书 | | RFC 5280 §4.1 |
| resource | ResourceSet | 资源集合 | | |
# 3.4 约束规则
## 3.4.1 Cert约束校验规则
RFC 6487中规定的证书的字段参见[3.2.1 ](#321-基本语法rfc-5280-4rfc-6487-)
-
| 字段 | 语义 | 约束/解析规则 | RFC 引用 |
|-----------|-------|----------------------------------------------|--------------|
| version | 证书版本 | 必须是v3(值为2) | RFC6487 §4.1 |
| serial | 证书编号 | 同一个CA签发的证书编号必须唯一 | RFC6487 §4.2 |
| validity | 证书有效期 | notBefore:时间不能早于证书的生成时间。若时间段大于上级证书的有效期,也是有效的 | RFC6487 §4.6 |
## 3.4.2 Cert Extentions中字段的约束校验规则
RFC 6487中规定的扩展字段参见[3.2.2 ](#322-证书扩展字段-rfc-6487-48)
| 字段 | critical | 语义 | 约束/解析规则 | RFC 引用 |
|----------------------------|----------|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------|
| basicConstraints | Y | 证书类型 | CA证书:cA=TRUE; EE证书:cA=FALSE | RFC6487 §4.8.1 |
| subjectKeyIdentifier | N | 证书公钥 | SKI = SHA-1(DER-encoded SPKI bit string) | RFC6487 §4.8.2 |
| authorityKeyIdentifier | N | 父证书的公钥 | 字段只包含keyIdentifier,不能包含authorityCertIssuer和authorityCertSerialNumber;除了自签名CA外,其余证书必须出现。自签名CA若出现该字段,则等于SKI | RFC6487 §4.8.3 |
| keyUsage | Y | 证书公钥的用途权限 | CA证书:keyCertSign = TRUE, cRLSign = TRUE 其他都是FALSE。EE证书:digitalSignature = TRUE 其他都是FALSE | RFC6487 §4.8.4 |
| extendedKeyUsage | N | 扩展证书公钥的用途权限 | CA证书:不能出现EKU;验证 RPKI 对象的 EE 证书:不能出现EKU;非 RPKI 对象的 EE:可以出现EKU,但必须为non-critical. | RFC6487 §4.8.5 |
| cRLDistributionPoints | N | CRL的发布点位置 | 字段:distributionPoint,不能包含reasons、cRLIssuer。其中distributionPoint字段包含:fullName,不能包含nameRelativeToCRLIssuer。fullName的格式必须是URI。自签名证书禁止出现该字段。非自签名证书必须出现。一个CA只能有一个CRL。一个CRLDP只能包含一个distributionPoint。但一个distributionPoint字段中可以包含多于1个的URI,但必须包含rsync URI且必须是最新的。 | RFC6487 §4.8.6 |
| authorityInformationAccess | N | 签发者的发布点位置 | 除了自签名的CA,必须出现。自签名CA,禁止出现。推荐的URI访问方式是rsync,并且rsyncURI的话,必须指定accessMethod=id-ad-caIssuers | RFC6487 §4.8.7 |
| subjectInformationAccess | N | 发布点位置 | CA证书:必须存在。必须存在一个accessMethod=id-ad-caRepository,accessLocation=rsyncURI。必须存在一个accessMethod=id-ad-repiManifest,accessLocation=rsync URI,指向该CA的mft对象。 EE证书:必须存在。必须存在一个accessMethod=id-ad-signedObject,accessLocation=rsyncURI。不允许其他的accessMethod | RFC6487 §4.8.8 |
| certificatePolicies | Y | 证书策略 | 必须存在,并且只能存在一种策略:RFC 6484 — RPKI Certificate Policy (CP) | RFC6487 §4.8.9 |
| iPResources | Y | IP地址集合 | 所有的RPKI证书中必须包含IP Resources或者ASResources,或者两者都包含。 | RFC6487 §4.8.10 |
| aSResources | Y | ASN集合 | 所有的RPKI证书中必须包含IP Resources或者ASResources,或者两者都包含。 | RFC6487 §4.8.11 |

View File

@@ -1,460 +0,0 @@
# 03. RC(Resource Certificate:资源证书,CA/EE)
## 3.1 对象定位
资源证书(RC)是 X.509 v3 证书,遵循 PKIX profile(RFC 5280),并受 RPKI profile 进一步约束。RFC 6487 §4。
RC 在 RPKI 中至少分为两类语义用途:
- `CA 证书`:签发下级证书/CRL,并在 SIA 中声明发布点与 manifest。RFC 6487 §4.8.8.1。
- `EE 证书`:用于验证某个 RPKI Signed Object(如 ROA/MFT),在 SIA 中指向被验证对象。RFC 6487 §4.8.8.2。
## 3.2 原始载体与编码
- 载体:X.509 证书。
- 编码:DER。RFC 6487 §4(“valid X.509 public key certificate consistent with RFC 5280” + RPKI 限制)。
### 3.2.1 X.509 v3 证书基本语法(ASN.1;RFC 5280 §4.1)
资源证书在编码层面是 RFC 5280 定义的 X.509 v3 `Certificate`(DER),其中 `tbsCertificate` 携带主体字段与扩展集合(`Extensions`)。RFC 5280 §4.1。
```asn1
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING }
TBSCertificate ::= SEQUENCE {
version [0] EXPLICIT Version DEFAULT v1,
serialNumber CertificateSerialNumber,
signature AlgorithmIdentifier,
issuer Name,
validity Validity,
subject Name,
subjectPublicKeyInfo SubjectPublicKeyInfo,
issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL,
-- If present, version MUST be v2 or v3
subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL,
-- If present, version MUST be v2 or v3
extensions [3] EXPLICIT Extensions OPTIONAL
-- If present, version MUST be v3
}
Version ::= INTEGER { v1(0), v2(1), v3(2) }
CertificateSerialNumber ::= INTEGER
Validity ::= SEQUENCE {
notBefore Time,
notAfter Time }
Time ::= CHOICE {
utcTime UTCTime,
generalTime GeneralizedTime }
UniqueIdentifier ::= BIT STRING
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier,
subjectPublicKey BIT STRING }
Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension
Extension ::= SEQUENCE {
extnID OBJECT IDENTIFIER,
critical BOOLEAN DEFAULT FALSE,
extnValue OCTET STRING
-- contains the DER encoding of an ASN.1 value
-- corresponding to the extension type identified
-- by extnID
}
```
### 3.2.2 AlgorithmIdentifier(ASN.1;RFC 5280 §4.1.1.2)
```asn1
AlgorithmIdentifier ::= SEQUENCE {
algorithm OBJECT IDENTIFIER,
parameters ANY DEFINED BY algorithm OPTIONAL }
```
### 3.2.3 Name / DN 结构(ASN.1;RFC 5280 §4.1.2.4)
```asn1
Name ::= CHOICE { -- only one possibility for now --
rdnSequence RDNSequence }
RDNSequence ::= SEQUENCE OF RelativeDistinguishedName
RelativeDistinguishedName ::=
SET SIZE (1..MAX) OF AttributeTypeAndValue
AttributeTypeAndValue ::= SEQUENCE {
type AttributeType,
value AttributeValue }
AttributeType ::= OBJECT IDENTIFIER
AttributeValue ::= ANY -- DEFINED BY AttributeType
DirectoryString ::= CHOICE {
teletexString TeletexString (SIZE (1..MAX)),
printableString PrintableString (SIZE (1..MAX)),
universalString UniversalString (SIZE (1..MAX)),
utf8String UTF8String (SIZE (1..MAX)),
bmpString BMPString (SIZE (1..MAX)) }
```
### 3.2.4 GeneralNames / GeneralName(ASN.1;RFC 5280 §4.2.1.6)
> 说明:RPKI 的 AIA/SIA/CRLDP 等扩展通常把 URI 编码在 `uniformResourceIdentifier [6] IA5String` 分支中。RFC 5280 §4.2.1.6。
```asn1
GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName
GeneralName ::= CHOICE {
otherName [0] OtherName,
rfc822Name [1] IA5String,
dNSName [2] IA5String,
x400Address [3] ORAddress,
directoryName [4] Name,
ediPartyName [5] EDIPartyName,
uniformResourceIdentifier [6] IA5String,
iPAddress [7] OCTET STRING,
registeredID [8] OBJECT IDENTIFIER }
OtherName ::= SEQUENCE {
type-id OBJECT IDENTIFIER,
value [0] EXPLICIT ANY DEFINED BY type-id }
EDIPartyName ::= SEQUENCE {
nameAssigner [0] DirectoryString OPTIONAL,
partyName [1] DirectoryString }
```
### 3.2.5 AIA(Authority Information Access;ASN.1;RFC 5280 §4.2.2.1)
```asn1
id-pe-authorityInfoAccess OBJECT IDENTIFIER ::= { id-pe 1 }
AuthorityInfoAccessSyntax ::=
SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-caIssuers OBJECT IDENTIFIER ::= { id-ad 2 }
```
### 3.2.6 SIA(Subject Information Access;ASN.1;RFC 5280 §4.2.2.2)
```asn1
id-pe-subjectInfoAccess OBJECT IDENTIFIER ::= { id-pe 11 }
SubjectInfoAccessSyntax ::=
SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-caRepository OBJECT IDENTIFIER ::= { id-ad 5 }
```
### 3.2.7 RPKI 在 SIA 中新增/使用的 accessMethod OID(RFC 6487 §4.8.8.1 / §4.8.8.2;RFC 8182 §3.2)
> 说明:下列 OID 用于 `AccessDescription.accessMethod`,并放在 SIA 的 `extnValue` 内层结构中(其外层 extnID 仍为 SIA:`id-pe-subjectInfoAccess`)。RFC 6487 §4.8.8;RFC 8182 §3.2。
```asn1
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-rpkiManifest OBJECT IDENTIFIER ::= { id-ad 10 } -- 1.3.6.1.5.5.7.48.10
id-ad-signedObject OBJECT IDENTIFIER ::= { id-ad 11 } -- 1.3.6.1.5.5.7.48.11
id-ad-rpkiNotify OBJECT IDENTIFIER ::= { id-ad 13 } -- 1.3.6.1.5.5.7.48.13
```
### 3.2.8 CRLDistributionPoints(CRLDP;ASN.1;RFC 5280 §4.2.1.13)
```asn1
id-ce-cRLDistributionPoints OBJECT IDENTIFIER ::= { id-ce 31 }
CRLDistributionPoints ::= SEQUENCE SIZE (1..MAX) OF DistributionPoint
DistributionPoint ::= SEQUENCE {
distributionPoint [0] DistributionPointName OPTIONAL,
reasons [1] ReasonFlags OPTIONAL,
cRLIssuer [2] GeneralNames OPTIONAL }
DistributionPointName ::= CHOICE {
fullName [0] GeneralNames,
nameRelativeToCRLIssuer [1] RelativeDistinguishedName }
ReasonFlags ::= BIT STRING {
unused (0),
keyCompromise (1),
cACompromise (2),
affiliationChanged (3),
superseded (4),
cessationOfOperation (5),
certificateHold (6),
privilegeWithdrawn (7),
aACompromise (8) }
```
### 3.2.9 Certificate Policies(ASN.1;RFC 5280 §4.2.1.4)
```asn1
id-ce-certificatePolicies OBJECT IDENTIFIER ::= { id-ce 32 }
anyPolicy OBJECT IDENTIFIER ::= { id-ce-certificatePolicies 0 }
certificatePolicies ::= SEQUENCE SIZE (1..MAX) OF PolicyInformation
PolicyInformation ::= SEQUENCE {
policyIdentifier CertPolicyId,
policyQualifiers SEQUENCE SIZE (1..MAX) OF
PolicyQualifierInfo OPTIONAL }
CertPolicyId ::= OBJECT IDENTIFIER
PolicyQualifierInfo ::= SEQUENCE {
policyQualifierId PolicyQualifierId,
qualifier ANY DEFINED BY policyQualifierId }
-- policyQualifierIds for Internet policy qualifiers
id-qt OBJECT IDENTIFIER ::= { id-pkix 2 }
id-qt-cps OBJECT IDENTIFIER ::= { id-qt 1 }
id-qt-unotice OBJECT IDENTIFIER ::= { id-qt 2 }
PolicyQualifierId ::= OBJECT IDENTIFIER ( id-qt-cps | id-qt-unotice )
Qualifier ::= CHOICE {
cPSuri CPSuri,
userNotice UserNotice }
CPSuri ::= IA5String
```
### 3.2.10 RFC 3779 IP/AS 资源扩展(ASN.1;RFC 3779 §2.2.1-§2.2.3;RFC 3779 §3.2.1-§3.2.3)
> 说明:RFC 3779 给出两个扩展的 OID 与 ASN.1 语法;它们作为 X.509 v3 扩展出现在 `extensions` 中(外层 extnID 为下列 OID)。RPKI profile 进一步约束 criticality/SAFI/RDI 等,见 RFC 6487 §4.8.10-§4.8.11。
```asn1
-- IP Address Delegation Extension
id-pe-ipAddrBlocks OBJECT IDENTIFIER ::= { id-pe 7 }
IPAddrBlocks ::= SEQUENCE OF IPAddressFamily
IPAddressFamily ::= SEQUENCE { -- AFI & optional SAFI --
addressFamily OCTET STRING (SIZE (2..3)),
ipAddressChoice IPAddressChoice }
IPAddressChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
addressesOrRanges SEQUENCE OF IPAddressOrRange }
IPAddressOrRange ::= CHOICE {
addressPrefix IPAddress,
addressRange IPAddressRange }
IPAddressRange ::= SEQUENCE {
min IPAddress,
max IPAddress }
IPAddress ::= BIT STRING
-- Autonomous System Identifier Delegation Extension
id-pe-autonomousSysIds OBJECT IDENTIFIER ::= { id-pe 8 }
ASIdentifiers ::= SEQUENCE {
asnum [0] EXPLICIT ASIdentifierChoice OPTIONAL,
rdi [1] EXPLICIT ASIdentifierChoice OPTIONAL}
ASIdentifierChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
asIdsOrRanges SEQUENCE OF ASIdOrRange }
ASIdOrRange ::= CHOICE {
id ASId,
range ASRange }
ASRange ::= SEQUENCE {
min ASId,
max ASId }
ASId ::= INTEGER
```
### 3.2.11 其它 RPKI profile 相关扩展的 ASN.1 定义(RFC 5280 §4.2.1.1-§4.2.1.3;RFC 5280 §4.2.1.9;RFC 5280 §4.2.1.12)
> 说明:这些是 RPKI 资源证书 profile(RFC 6487 §4.8)所引用的通用 PKIX 扩展语法。RPKI 对其“必须/禁止/criticality/字段允许性”有额外限制(见本文件 3.3/3.4),但编码层的 ASN.1 类型来自 RFC 5280。
```asn1
id-ce-authorityKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 35 }
AuthorityKeyIdentifier ::= SEQUENCE {
keyIdentifier [0] KeyIdentifier OPTIONAL,
authorityCertIssuer [1] GeneralNames OPTIONAL,
authorityCertSerialNumber [2] CertificateSerialNumber OPTIONAL }
KeyIdentifier ::= OCTET STRING
id-ce-subjectKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 14 }
SubjectKeyIdentifier ::= KeyIdentifier
id-ce-keyUsage OBJECT IDENTIFIER ::= { id-ce 15 }
KeyUsage ::= BIT STRING {
digitalSignature (0),
nonRepudiation (1), -- recent editions of X.509 have
-- renamed this bit to contentCommitment
keyEncipherment (2),
dataEncipherment (3),
keyAgreement (4),
keyCertSign (5),
cRLSign (6),
encipherOnly (7),
decipherOnly (8) }
id-ce-basicConstraints OBJECT IDENTIFIER ::= { id-ce 19 }
BasicConstraints ::= SEQUENCE {
cA BOOLEAN DEFAULT FALSE,
pathLenConstraint INTEGER (0..MAX) OPTIONAL }
id-ce-extKeyUsage OBJECT IDENTIFIER ::= { id-ce 37 }
ExtKeyUsageSyntax ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId
KeyPurposeId ::= OBJECT IDENTIFIER
```
## 3.3 抽象数据模型(接口)
> 说明:本模型面向“语义化解析产物”。实现可保留 `raw_der` 作为可追溯入口。
### 3.3.1 顶层联合类型:`ResourceCertificate`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw_der` | `DerBytes` | 证书 DER | 原样保留(建议) | RFC 6487 §4 |
| `tbs` | `RpkixTbsCertificate` | 证书语义字段(见下) | 仅允许 RFC 6487 允许的字段/扩展;其他字段 MUST NOT 出现 | RFC 6487 §4 |
| `kind` | `enum { ca, ee }` | 语义分类 | 来自 BasicConstraints + 用途约束 | RFC 6487 §4.8.1;RFC 6487 §4.8.8 |
### 3.3.1.1 派生类型(用于字段类型标注)
为避免在其它对象文档里反复写“`ResourceCertificate` 且 `kind==...`”,这里定义两个派生/别名类型:
- `ResourceCaCertificate`:`ResourceCertificate` 且 `kind == ca`
- `ResourceEeCertificate`:`ResourceCertificate` 且 `kind == ee`
这些派生类型不引入新字段,只是对 `ResourceCertificate.kind` 的约束化视图。
### 3.3.2 `RpkixTbsCertificate`(语义字段集合)
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | X.509 版本 | MUST 为 v3(字段值为 2) | RFC 6487 §4.1 |
| `serial_number` | `int` | 序列号 | 正整数;对每 CA 签发唯一 | RFC 6487 §4.2 |
| `signature_algorithm` | `Oid` | 证书签名算法 | 必须为 `sha256WithRSAEncryption`(`1.2.840.113549.1.1.11`) | RFC 6487 §4.3;RFC 7935 §2(引用 RFC 4055) |
| `issuer_dn` | `RpkixDistinguishedName` | 颁发者 DN | 必含 1 个 CommonName;可含 1 个 serialNumber;CN 必须 PrintableString | RFC 6487 §4.4 |
| `subject_dn` | `RpkixDistinguishedName` | 主体 DN | 同 issuer 约束;且对同一 issuer 下“实体+公钥”唯一 | RFC 6487 §4.5 |
| `validity_not_before` | `UtcTime` | 有效期起 | X.509 `Time`(UTCTime/GeneralizedTime)解析为 UTC 时间点 | RFC 6487 §4.6.1;RFC 5280 §4.1.2.5 |
| `validity_not_after` | `UtcTime` | 有效期止 | X.509 `Time`(UTCTime/GeneralizedTime)解析为 UTC 时间点 | RFC 6487 §4.6.2;RFC 5280 §4.1.2.5 |
| `subject_public_key_info` | `DerBytes` | SPKI DER | 算法 profile 指定 | RFC 6487 §4.7;RFC 7935 §3.1 |
| `extensions` | `RpkixExtensions` | 扩展集合 | 见下表;criticality/存在性/内容受约束 | RFC 6487 §4.8 |
### 3.3.3 `RpkixDistinguishedName`(RPKI profile 下的 DN 语义)
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `common_name` | `string` | CommonName (CN) | MUST 存在且仅 1 个;类型为 PrintableString | RFC 6487 §4.4;RFC 6487 §4.5 |
| `serial_number` | `optional[string]` | serialNumber | MAY 存在且仅 1 个 | RFC 6487 §4.4;RFC 6487 §4.5 |
| `rfc4514` | `string` | DN 的 RFC4514 字符串表示 | 便于日志/索引(实现自选) | RFC 6487 §4.5(引用 RFC4514) |
### 3.3.4 `RpkixExtensions`(核心扩展语义)
> 表中 “存在性/criticality” 指 RPKI profile 下对该扩展的要求;实现应能区分 “字段缺失” 与 “字段存在但不符合约束”。
| 字段 | 类型 | 语义 | 存在性/criticality 与内容约束 | RFC 引用 |
|---|---|---|---|---|
| `basic_constraints` | `optional[BasicConstraints]` | CA 标志 | **extnID=`2.5.29.19`**;CA 证书:MUST present & critical;EE:MUST NOT present;pathLen MUST NOT present | RFC 6487 §4.8.1;RFC 5280 §4.2.1.9 |
| `subject_key_identifier` | `bytes` | SKI | **extnID=`2.5.29.14`**;MUST present & non-critical;值为 subjectPublicKey 的 DER bit string 的 SHA-1 哈希 | RFC 6487 §4.8.2(引用 RFC 5280 §4.2.1.2) |
| `authority_key_identifier` | `optional[AuthorityKeyIdentifier]` | AKI | **extnID=`2.5.29.35`**;自签名:MAY present 且可等于 SKI;非自签名:MUST present;authorityCertIssuer/authorityCertSerialNumber MUST NOT present;non-critical | RFC 6487 §4.8.3;RFC 5280 §4.2.1.1 |
| `key_usage` | `KeyUsage` | KeyUsage | **extnID=`2.5.29.15`**;MUST present & critical;CA:仅 `keyCertSign` 与 `cRLSign` 为 TRUE;EE:仅 `digitalSignature` 为 TRUE | RFC 6487 §4.8.4;RFC 5280 §4.2.1.3 |
| `extended_key_usage` | `optional[OidSet]` | EKU | **extnID=`2.5.29.37`**;CA:MUST NOT appear;用于验证 RPKI 对象的 EE:MUST NOT appear;若出现不得标 critical | RFC 6487 §4.8.5;RFC 5280 §4.2.1.12 |
| `crl_distribution_points` | `optional[CrlDistributionPoints]` | CRLDP | **extnID=`2.5.29.31`**;自签名:MUST be omitted;非自签名:MUST present & non-critical;仅 1 个 DistributionPoint;fullName URI;必须包含至少 1 个 `rsync://` | RFC 6487 §4.8.6;RFC 5280 §4.2.1.13 |
| `authority_info_access` | `optional[AuthorityInfoAccess]` | AIA | **extnID=`1.3.6.1.5.5.7.1.1`**;自签名:MUST be omitted;非自签名:MUST present & non-critical;必须含 accessMethod `id-ad-caIssuers`(**`1.3.6.1.5.5.7.48.2`**) 的 `rsync://` URI;可含同对象其它 URI | RFC 6487 §4.8.7;RFC 5280 §4.2.2.1 |
| `subject_info_access_ca` | `optional[SubjectInfoAccessCa]` | SIA(CA) | **extnID=`1.3.6.1.5.5.7.1.11`**;CA:MUST present & non-critical;必须含 accessMethod `id-ad-caRepository`(**`1.3.6.1.5.5.7.48.5`**)(`rsync://` 目录 URI)与 `id-ad-rpkiManifest`(**`1.3.6.1.5.5.7.48.10`**)(`rsync://` 对象 URI);若 CA 使用 RRDP,还会包含 `id-ad-rpkiNotify`(**`1.3.6.1.5.5.7.48.13`**)(HTTPS Notification URI) | RFC 6487 §4.8.8.1;RFC 5280 §4.2.2.2;RFC 8182 §3.2 |
| `subject_info_access_ee` | `optional[SubjectInfoAccessEe]` | SIA(EE) | **extnID=`1.3.6.1.5.5.7.1.11`**;EE:MUST present & non-critical;必须含 accessMethod `id-ad-signedObject`(**`1.3.6.1.5.5.7.48.11`**);URI **MUST include** `rsync://`;EE 的 SIA 不允许其它 AccessMethods | RFC 6487 §4.8.8.2;RFC 5280 §4.2.2.2 |
| `certificate_policies` | `CertificatePolicies` | 证书策略 | **extnID=`2.5.29.32`**;MUST present & critical;恰好 1 个 policy;并允许 0 或 1 个 CPS qualifier(若存在其 id 必为 `id-qt-cps`(**`1.3.6.1.5.5.7.2.1`**)) | RFC 6487 §4.8.9;RFC 7318 §2;RFC 5280 §4.2.1.4 |
| `ip_resources` | `optional[IpResourceSet]` | IP 资源扩展 | **extnID=`1.3.6.1.5.5.7.1.7`**;IP/AS 两者至少其一 MUST present;若 present MUST be critical;内容为 RFC 3779 语义;在公用互联网场景 SAFI MUST NOT 使用;且必须为非空或 inherit | RFC 6487 §4.8.10;RFC 3779 §2.2.1;RFC 3779 §2.2.2 |
| `as_resources` | `optional[AsResourceSet]` | AS 资源扩展 | **extnID=`1.3.6.1.5.5.7.1.8`**;IP/AS 两者至少其一 MUST present;若 present MUST be critical;内容为 RFC 3779 语义;RDI MUST NOT 使用;且必须为非空或 inherit | RFC 6487 §4.8.11;RFC 3779 §3.2.1;RFC 3779 §3.2.2 |
### 3.3.5 结构化子类型(建议)
#### `BasicConstraints`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `ca` | `bool` | 是否 CA | 由 issuer 决定;在 CA 证书中该扩展必须存在 | RFC 6487 §4.8.1 |
| `path_len_constraint` | `None` | pathLenConstraint | MUST NOT present(RPKI profile 不使用) | RFC 6487 §4.8.1 |
#### `AuthorityKeyIdentifier`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `key_identifier` | `bytes` | AKI.keyIdentifier | 使用 issuer 公钥的 SHA-1 哈希(按 RFC 5280 的定义) | RFC 6487 §4.8.3(引用 RFC 5280 §4.2.1.1) |
| `authority_cert_issuer` | `None` | authorityCertIssuer | MUST NOT present | RFC 6487 §4.8.3 |
| `authority_cert_serial_number` | `None` | authorityCertSerialNumber | MUST NOT present | RFC 6487 §4.8.3 |
#### `CrlDistributionPoints`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `distribution_point_uris` | `list[Uri]` | CRL 位置列表 | 仅 1 个 DistributionPoint;必须包含至少 1 个 `rsync://` URI 指向该 issuer 最新 CRL;可含其它 URI | RFC 6487 §4.8.6 |
#### `AuthorityInfoAccess`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `ca_issuers_uris` | `list[Uri]` | 上级 CA 证书位置 | accessMethod=`id-ad-caIssuers`(`1.3.6.1.5.5.7.48.2`);必含 `rsync://` URI;可含同对象其它 URI | RFC 6487 §4.8.7;RFC 5280 §4.2.2.1 |
#### `SubjectInfoAccessCa`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `ca_repository_uris` | `list[Uri]` | CA 发布点目录(repository publication point) | accessMethod=`id-ad-caRepository`(`1.3.6.1.5.5.7.48.5`);至少 1 个;必须包含 `rsync://`;也可包含其它机制(例如 `https://`)作为“同一目录”的替代访问方式;顺序表示 CA 偏好 | RFC 6487 §4.8.8.1;RFC 5280 §4.2.2.2 |
| `rpki_manifest_uris` | `list[Uri]` | 当前 manifest 对象 URI | accessMethod=`id-ad-rpkiManifest`(`1.3.6.1.5.5.7.48.10`);至少 1 个;必须包含 `rsync://`;也可包含其它机制(例如 `https://`)作为“同一对象”的替代访问方式 | RFC 6487 §4.8.8.1;RFC 5280 §4.2.2.2 |
| `rpki_notify_uris` | `optional[list[Uri]]` | RRDP Notification(Update Notification File)URI | accessMethod=`id-ad-rpkiNotify`(`1.3.6.1.5.5.7.48.13`);若存在则 accessLocation MUST 为 `https://` URI,指向 RRDP Notification 文件 | RFC 8182 §3.2;RFC 5280 §4.2.2.2 |
#### `SubjectInfoAccessEe`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `signed_object_uris` | `list[Uri]` | 被 EE 证书验证的签名对象位置 | accessMethod=`id-ad-signedObject`(`1.3.6.1.5.5.7.48.11`);必须包含 `rsync://`;其它 URI 可作为同对象替代机制;EE SIA 不允许其它 AccessMethods | RFC 6487 §4.8.8.2;RFC 5280 §4.2.2.2 |
#### `CertificatePolicies`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `policy_oid` | `Oid` | 唯一 policy OID | 恰好 1 个 policy;RPKI CP 分配的 OID 为 `id-cp-ipAddr-asNumber`(`1.3.6.1.5.5.7.14.2`) | RFC 6487 §4.8.9;RFC 6484 §1.2 |
| `cps_uri` | `optional[Uri]` | CPS policy qualifier URI | MAY 存在且最多 1 个;若存在其 `policyQualifierId` 必为 `id-qt-cps`;对该 URI 不施加处理要求 | RFC 7318 §2;RFC 5280 §4.2.1.4 |
## 3.4 字段级约束清单(实现对照)
- 仅允许 RFC 6487 §4 指定的字段/扩展;未列出字段 MUST NOT 出现。RFC 6487 §4。
- 证书版本必须为 v3。RFC 6487 §4.1。
- CA/EE 在 BasicConstraints 与 SIA 的约束不同。RFC 6487 §4.8.1;RFC 6487 §4.8.8.1;RFC 6487 §4.8.8.2。
- KeyUsage:CA 仅 `keyCertSign`/`cRLSign`;EE 仅 `digitalSignature`。RFC 6487 §4.8.4。
- CRLDP/AIA:自签名必须省略;非自签名必须存在并包含 `rsync://`。RFC 6487 §4.8.6;RFC 6487 §4.8.7。
- IP/AS 资源扩展:两者至少其一存在;若存在必须 critical;语义来自 RFC 3779;在公用互联网场景 SAFI 与 RDI 均不得使用。RFC 6487 §4.8.10;RFC 6487 §4.8.11;RFC 3779 §2.2.3;RFC 3779 §3.2.3。

View File

@@ -1,158 +0,0 @@
# 05. RPKI Signed Object(CMS SignedData 外壳)
## 5.1 对象定位
ROA、Manifest 等都属于 “RPKI Signed Object”,其外壳是 CMS SignedData,并受 RFC 6488 的 profile 约束;RFC 9589 进一步更新了 `signedAttrs` 的要求。RFC 6488 §2-§4;RFC 9589 §4。
本文件描述**通用外壳模型**(eContentType/eContent 由具体对象文档给出)。
## 5.2 原始载体与编码
- 载体:CMS `ContentInfo`,其中 `contentType` 为 SignedData。RFC 6488 §2;RFC 6488 §3(1a)。
- 编码:DER。RFC 6488 §2;RFC 6488 §3(1l)。
### 5.2.1 CMS 外壳:ContentInfo(ASN.1;RFC 5652 §3)
```asn1
ContentInfo ::= SEQUENCE {
contentType ContentType,
content [0] EXPLICIT ANY DEFINED BY contentType }
ContentType ::= OBJECT IDENTIFIER
```
### 5.2.2 CMS 外壳:SignedData(ASN.1;RFC 5652 §5.1)
```asn1
id-signedData OBJECT IDENTIFIER ::= { iso(1) member-body(2)
us(840) rsadsi(113549) pkcs(1) pkcs7(7) 2 }
SignedData ::= SEQUENCE {
version CMSVersion,
digestAlgorithms DigestAlgorithmIdentifiers,
encapContentInfo EncapsulatedContentInfo,
certificates [0] IMPLICIT CertificateSet OPTIONAL,
crls [1] IMPLICIT RevocationInfoChoices OPTIONAL,
signerInfos SignerInfos }
DigestAlgorithmIdentifiers ::= SET OF DigestAlgorithmIdentifier
SignerInfos ::= SET OF SignerInfo
```
### 5.2.3 CMS 外壳:EncapsulatedContentInfo(ASN.1;RFC 5652 §5.2)
```asn1
EncapsulatedContentInfo ::= SEQUENCE {
eContentType ContentType,
eContent [0] EXPLICIT OCTET STRING OPTIONAL }
ContentType ::= OBJECT IDENTIFIER
```
> 注:CMS 允许 `eContent` 不一定 DER 编码(RFC 5652 §5.2);但 RPKI signed object profile 要求**整个对象 DER 编码**(RFC 6488 §2;RFC 6488 §3(1l)),且 eContent(payload)由对象规范定义并通常为 DER(如 ROA:RFC 9582 §4;Manifest:RFC 9286 §4.2)。
### 5.2.4 CMS 外壳:SignerInfo 与 Attribute(ASN.1;RFC 5652 §5.3)
```asn1
SignerInfo ::= SEQUENCE {
version CMSVersion,
sid SignerIdentifier,
digestAlgorithm DigestAlgorithmIdentifier,
signedAttrs [0] IMPLICIT SignedAttributes OPTIONAL,
signatureAlgorithm SignatureAlgorithmIdentifier,
signature SignatureValue,
unsignedAttrs [1] IMPLICIT UnsignedAttributes OPTIONAL }
SignerIdentifier ::= CHOICE {
issuerAndSerialNumber IssuerAndSerialNumber,
subjectKeyIdentifier [0] SubjectKeyIdentifier }
SignedAttributes ::= SET SIZE (1..MAX) OF Attribute
UnsignedAttributes ::= SET SIZE (1..MAX) OF Attribute
Attribute ::= SEQUENCE {
attrType OBJECT IDENTIFIER,
attrValues SET OF AttributeValue }
AttributeValue ::= ANY
SignatureValue ::= OCTET STRING
```
### 5.2.5 RPKI 对 CMS 外壳字段的 profile 约束(RFC 6488 §2.1;RFC 6488 §3;更新:RFC 9589 §4)
> 说明:上面是 CMS 的通用 ASN.1;RPKI 进一步约束取值与允许出现的字段(例如 SignedData.version 必须为 3、crls 必须省略、signedAttrs 的内容限制等)。RFC 6488 §2-§3;RFC 9589 §4。
### 5.2.6 signedAttrs 中允许的属性与 attrType OID(RFC 6488 §2.1.6.4.1-§2.1.6.4.2;更新:RFC 9589 §4)
RPKI signed object profile 对 `SignerInfo.signedAttrs` 的 Attribute 集合施加限制(除 ASN.1 结构外,还包含“只允许哪些 attrType”的编码约束):
- `content-type`:attrType OID `1.2.840.113549.1.9.3`。RFC 6488 §2.1.6.4.1。
- `message-digest`:attrType OID `1.2.840.113549.1.9.4`。RFC 6488 §2.1.6.4.2。
- `signing-time`:attrType OID `1.2.840.113549.1.9.5`。RFC 9589 §4(更新 RFC 6488 的相关要求)。
并且:
- 每种属性在集合中只能出现一次;且 `attrValues` 虽然语法是 `SET OF`,但在 RPKI 中必须只含一个值。RFC 6488 §2.1.6.4。
## 5.3 抽象数据模型(接口)
### 5.3.1 `RpkiSignedObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw_der` | `DerBytes` | CMS DER | 原样保留(建议) | RFC 6488 §2;RFC 6488 §3(1l) |
| `content_info_content_type` | `Oid` | ContentInfo.contentType | MUST 为 SignedData:`1.2.840.113549.1.7.2` | RFC 6488 §3(1a) |
| `signed_data` | `SignedDataProfiled` | SignedData 语义字段 | 见下 | RFC 6488 §2.1;RFC 6488 §3 |
### 5.3.2 `SignedDataProfiled`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | SignedData.version | MUST 为 3 | RFC 6488 §3(1b);RFC 6488 §2.1.1 |
| `digest_algorithms` | `list[Oid]` | SignedData.digestAlgorithms | MUST contain exactly one digest algorithm,且必须为 `id-sha256`(`2.16.840.1.101.3.4.2.1`) | RFC 6488 §2.1.2;RFC 7935 §2(引用 RFC 5754) |
| `encap_content_info` | `EncapsulatedContentInfo` | EncapsulatedContentInfo | 见下;eContentType 由具体对象定义 | RFC 6488 §2.1.3 |
| `certificates` | `list[ResourceEeCertificate]` | SignedData.certificates | MUST present;且仅包含 1 个 EE 证书;该 EE 的 SKI 必须匹配 SignerInfo.sid | RFC 6488 §3(1c) |
| `crls` | `None` | SignedData.crls | MUST be omitted | RFC 6488 §3(1d) |
| `signer_infos` | `list[SignerInfoProfiled]` | SignedData.signerInfos | MUST contain exactly one SignerInfo | RFC 6488 §2.1;RFC 6488 §2.1(SignerInfos 约束段落) |
### 5.3.3 `EncapsulatedContentInfo`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `econtent_type` | `Oid` | eContentType | MUST 与 signedAttrs.content-type 的 attrValues 一致;具体值由对象定义(如 ROA/MFT) | RFC 6488 §3(1h);RFC 6488 §2.1.3.1 |
| `econtent_der` | `DerBytes` | eContent(对象 payload) | DER 编码的对象特定 ASN.1(ROA/MFT 文档定义);在 CMS 中以 OCTET STRING 承载 | RFC 6488 §2.1.3;RFC 9286 §4.2;RFC 9582 §4 |
### 5.3.4 `SignerInfoProfiled`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | SignerInfo.version | MUST 为 3 | RFC 6488 §3(1e) |
| `sid_ski` | `bytes` | sid(SubjectKeyIdentifier) | 必须与 EE 证书的 SKI 匹配 | RFC 6488 §3(1c) |
| `digest_algorithm` | `Oid` | SignerInfo.digestAlgorithm | 必须为 `id-sha256`(`2.16.840.1.101.3.4.2.1`) | RFC 6488 §3(1j);RFC 7935 §2(引用 RFC 5754) |
| `signature_algorithm` | `Oid` | SignerInfo.signatureAlgorithm | 生成时 MUST 为 `rsaEncryption`(`1.2.840.113549.1.1.1`);验证时实现必须接受 `rsaEncryption` 或 `sha256WithRSAEncryption`(`1.2.840.113549.1.1.11`) | RFC 6488 §3(1k);RFC 7935 §2 |
| `signed_attrs` | `SignedAttrsProfiled` | signedAttrs | MUST present;仅允许特定 3 个属性 | RFC 9589 §4(更新 RFC 6488 §3(1f)/(1g)) |
| `unsigned_attrs` | `None` | unsignedAttrs | MUST be omitted | RFC 6488 §3(1i) |
### 5.3.5 `SignedAttrsProfiled`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `content_type` | `Oid` | signedAttrs.content-type | attrType=`1.2.840.113549.1.9.3`;MUST present;attrValues 等于 eContentType | RFC 9589 §4;RFC 6488 §3(1h) |
| `message_digest` | `bytes` | signedAttrs.message-digest | attrType=`1.2.840.113549.1.9.4`;MUST present | RFC 9589 §4(更新 RFC 6488 §3(1f)) |
| `signing_time` | `UtcTime` | signedAttrs.signing-time | attrType=`1.2.840.113549.1.9.5`;MUST present(时间值正确性不用于安全假设) | RFC 9589 §4;RFC 9589 §5 |
| `other_attrs` | `None` | 其它 signed attributes | MUST NOT be included(binary-signing-time 也不允许) | RFC 9589 §4 |
## 5.4 字段级约束清单(实现对照)
- ContentInfo.contentType 必须为 SignedData(OID `1.2.840.113549.1.7.2`)。RFC 6488 §3(1a)。
- SignedData.version 必须为 3,且 SignerInfos 仅允许 1 个 SignerInfo。RFC 6488 §3(1b);RFC 6488 §2.1。
- SignedData.certificates 必须存在且仅含 1 个 EE 证书;该证书 SKI 必须匹配 SignerInfo.sid。RFC 6488 §3(1c)。
- SignedData.crls 必须省略。RFC 6488 §3(1d)。
- signedAttrs 必须存在,且仅允许 content-type/message-digest/signing-time;其它全部禁止。RFC 9589 §4。
- eContentType 必须与 content-type attribute 一致。RFC 6488 §3(1h)。
- unsignedAttrs 必须省略。RFC 6488 §3(1i)。
- digest/signature 算法必须符合算法 profile。RFC 6488 §3(1j)/(1k);RFC 7935 §2。
- 整个对象必须 DER 编码。RFC 6488 §3(1l)。

View File

@@ -1,110 +0,0 @@
# 06. Manifest(MFT)
## 6.1 对象定位
Manifest 是 CA 发布点内对象的“清单”(文件名 + hash),用于 RP 侧检测删除/替换/回放等不一致情况。RFC 9286 §1;RFC 9286 §6。
Manifest 是一种 RPKI Signed Object:CMS 外壳遵循 RFC 6488/9589,eContent 遵循 RFC 9286。RFC 9286 §4;RFC 6488 §4;RFC 9589 §4。
## 6.2 原始载体与编码
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 9286 §4;RFC 6488 §2。
- eContentType:`id-ct-rpkiManifest`,OID `1.2.840.113549.1.9.16.1.26`。RFC 9286 §4.1。
- eContent:DER 编码 ASN.1 `Manifest`。RFC 9286 §4.2。
### 6.2.1 eContentType 与 eContent 的 ASN.1 定义(RFC 9286 §4.1;RFC 9286 §4.2)
Manifest 是一种 RPKI signed object(CMS 外壳见 `05_signed_object_cms.md`)。其 `eContentType` 与 `eContent` 的 ASN.1 由 RFC 9286 明确定义。RFC 9286 §4。
**eContentType(OID)**:RFC 9286 §4.1。
```asn1
id-smime OBJECT IDENTIFIER ::= { iso(1) member-body(2) us(840)
rsadsi(113549) pkcs(1) pkcs9(9) 16 }
id-ct OBJECT IDENTIFIER ::= { id-smime 1 }
id-ct-rpkiManifest OBJECT IDENTIFIER ::= { id-ct 26 }
```
**eContent(Manifest 结构)**:RFC 9286 §4.2。
```asn1
Manifest ::= SEQUENCE {
version [0] INTEGER DEFAULT 0,
manifestNumber INTEGER (0..MAX),
thisUpdate GeneralizedTime,
nextUpdate GeneralizedTime,
fileHashAlg OBJECT IDENTIFIER,
fileList SEQUENCE SIZE (0..MAX) OF FileAndHash
}
FileAndHash ::= SEQUENCE {
file IA5String,
hash BIT STRING
}
```
解码要点:
- `fileHashAlg` 决定 `FileAndHash.hash` 的算法与输出长度(RPKI profile 要求 SHA-256)。RFC 9286 §4.2.1;RFC 7935 §2。
- `hash` 在 ASN.1 中是 BIT STRING,但 hash 输出是按字节的比特串,DER 编码时应为 “unused bits = 0” 的 octet-aligned BIT STRING(实现可据此做一致性检查)。RFC 9286 §4.2。
## 6.3 解析规则(eContent 语义层)
输入:`RpkiSignedObject`。
1) 先按通用 Signed Object 外壳解析得到 `encap_content_info.econtent_type` 与 `econtent_der`。RFC 6488 §3;RFC 9589 §4。
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.26`。RFC 9286 §4.1;RFC 9286 §4.4(1)。
3) 将 `econtent_der` 以 DER 解析为 `Manifest` ASN.1。RFC 9286 §4.2。
4) 将 `fileList` 映射为语义字段 `files: list[FileAndHash]`,其中 `hash` 为 `fileHashAlg` 对应算法的输出字节序列。RFC 9286 §4.2.1(fileHashAlg/fileList 定义)。
## 6.4 抽象数据模型(接口)
### 6.4.1 `ManifestObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 9286 §4;RFC 6488 §3;RFC 9589 §4 |
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.26` | RFC 9286 §4.1 |
| `manifest` | `ManifestEContent` | eContent 语义对象 | 见下 | RFC 9286 §4.2 |
### 6.4.2 `ManifestEContent`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | Manifest.version | MUST 为 0 | RFC 9286 §4.2.1(version) |
| `manifest_number` | `int` | manifestNumber | 0..MAX;可达 20 octets;issuer 必须单调递增;RP 必须可处理至 20 octets | RFC 9286 §4.2;RFC 9286 §4.2.1(manifestNumber) |
| `this_update` | `UtcTime` | thisUpdate | 由 ASN.1 `GeneralizedTime` 解析为 UTC 时间点;且必须比先前生成的 manifest 更新 | RFC 9286 §4.2;RFC 9286 §4.2.1(thisUpdate) |
| `next_update` | `UtcTime` | nextUpdate | 由 ASN.1 `GeneralizedTime` 解析为 UTC 时间点;且必须晚于 thisUpdate | RFC 9286 §4.2;RFC 9286 §4.2.1(nextUpdate) |
| `file_hash_alg` | `Oid` | fileHashAlg | 必须为 `id-sha256`(`2.16.840.1.101.3.4.2.1`) | RFC 9286 §4.2.1(fileHashAlg);RFC 7935 §2(引用 RFC 5754) |
| `files` | `list[FileAndHash]` | fileList | `SEQUENCE SIZE (0..MAX)`;每项含文件名与 hash | RFC 9286 §4.2;RFC 9286 §4.2.1(fileList) |
### 6.4.3 `FileAndHash`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `file_name` | `string` | 文件名(不含路径) | 字符集限制:`[a-zA-Z0-9-_]+` + `.` + 三字母扩展;扩展必须在 IANA “RPKI Repository Name Schemes” 注册表中 | RFC 9286 §4.2.2 |
| `hash_bytes` | `bytes` | 文件内容 hash | 由 `file_hash_alg` 指定算法计算 | RFC 9286 §4.2.1(fileHashAlg/fileList) |
## 6.5 字段级约束清单(实现对照)
- eContentType 必须为 `id-ct-rpkiManifest`(OID `1.2.840.113549.1.9.16.1.26`)。RFC 9286 §4.1。
- eContent 必须 DER 编码且符合 `Manifest` ASN.1。RFC 9286 §4.2。
- `version` 必须为 0。RFC 9286 §4.2.1。
- `manifestNumber` 由 issuer 单调递增;RP 必须能处理至 20 octets;issuer 不得超过 20 octets。RFC 9286 §4.2.1。
- `nextUpdate` 必须晚于 `thisUpdate`。RFC 9286 §4.2.1。
- `fileHashAlg` 必须符合算法 profile(SHA-256)。RFC 9286 §4.2.1;RFC 7935 §2。
- `fileList` 中 `file` 名称字符集与扩展名受限;实现需按 RFC 限制解析并保留大小写语义。RFC 9286 §4.2.2。
## 6.6 与 EE 证书的语义约束(为后续验证准备)
Manifest 使用“one-time-use EE certificate”进行签名验证,规范对该 EE 证书的使用方式给出约束:
- Manifest 相关 EE 证书应为 one-time-use(每次新 manifest 生成新密钥对/新 EE)。RFC 9286 §4(Section 4 前导段落)。
- 用于签名/验证 manifest 的 EE 证书在 RFC 3779 的资源扩展中描述 INRs 时,**MUST** 使用 `inherit`(而不是显式列出资源集合)。RFC 9286 §5.1(生成步骤 2)。
- 若证书包含 **IP Address Delegation Extension**(IP prefix delegation):内容必须为 `inherit`(不得显式列 prefix/range)。RFC 9286 §5.1;RFC 3779。
- 若证书包含 **AS Identifier Delegation Extension**(ASN delegation):内容必须为 `inherit`(不得显式列 ASID/range)。RFC 9286 §5.1;RFC 3779。
- 另外按资源证书 profile:资源证书 **MUST** 至少包含上述两类扩展之一(也可两者都有),且这些扩展 **MUST** 标记为 critical。RFC 6487 §2。
- 用于验证 manifest 的 EE 证书 **MUST** 具有与 `thisUpdate..nextUpdate` 区间一致的有效期,以避免 CRL 无谓增长。RFC 9286 §4.2.1(manifestNumber 段落前的说明)。
- 替换 manifest 时,CA 必须撤销旧 manifest 对应 EE 证书;且若新 manifest 早于旧 manifest 的 nextUpdate 发行,则 CA **MUST** 同时发行新 CRL 撤销旧 manifest EE。RFC 9286 §4.2.1(nextUpdate 段落末);RFC 9286 §5.1(生成步骤)。

View File

@@ -1,159 +0,0 @@
# 07. ROA(Route Origin Authorization)
## 7.1 对象定位
ROA 是一种 RPKI Signed Object,用于声明“某 AS 被授权起源某些前缀”。RFC 9582 §1;RFC 9582 §4。
ROA 由 CMS 外壳 + ROA eContent 组成:
- 外壳:RFC 6488(更新:RFC 9589)
- eContent:RFC 9582
## 7.2 原始载体与编码
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 9582 §1(引用 RFC 6488)。
- eContentType:`id-ct-routeOriginAuthz`,OID `1.2.840.113549.1.9.16.1.24`。RFC 9582 §3。
- eContent:DER 编码 ASN.1 `RouteOriginAttestation`。RFC 9582 §4。
### 7.2.1 eContentType 与 eContent 的 ASN.1 定义(RFC 9582 §3;RFC 9582 §4)
ROA 是一种 RPKI signed object(CMS 外壳见 `05_signed_object_cms.md`)。RFC 9582 定义了其 `eContentType` 以及 `eContent`(payload)的 ASN.1。RFC 9582 §3-§4。
**eContentType(OID)**:RFC 9582 §3。
```asn1
id-ct-routeOriginAuthz OBJECT IDENTIFIER ::=
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
pkcs-9(9) id-smime(16) id-ct(1) routeOriginAuthz(24) }
```
**eContent(ROA ASN.1 模块)**:RFC 9582 §4。
```asn1
RPKI-ROA-2023
{ iso(1) member-body(2) us(840) rsadsi(113549)
pkcs(1) pkcs9(9) smime(16) mod(0)
id-mod-rpkiROA-2023(75) }
DEFINITIONS EXPLICIT TAGS ::=
BEGIN
IMPORTS
CONTENT-TYPE
FROM CryptographicMessageSyntax-2010 -- in [RFC6268]
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
pkcs-9(9) smime(16) modules(0) id-mod-cms-2009(58) } ;
ct-routeOriginAttestation CONTENT-TYPE ::=
{ TYPE RouteOriginAttestation
IDENTIFIED BY id-ct-routeOriginAuthz }
id-ct-routeOriginAuthz OBJECT IDENTIFIER ::=
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
pkcs-9(9) id-smime(16) id-ct(1) routeOriginAuthz(24) }
RouteOriginAttestation ::= SEQUENCE {
version [0] INTEGER DEFAULT 0,
asID ASID,
ipAddrBlocks SEQUENCE (SIZE(1..2)) OF ROAIPAddressFamily }
ASID ::= INTEGER (0..4294967295)
ROAIPAddressFamily ::= SEQUENCE {
addressFamily ADDRESS-FAMILY.&afi ({AddressFamilySet}),
addresses ADDRESS-FAMILY.&Addresses
({AddressFamilySet}{@addressFamily}) }
ADDRESS-FAMILY ::= CLASS {
&afi OCTET STRING (SIZE(2)) UNIQUE,
&Addresses
} WITH SYNTAX { AFI &afi ADDRESSES &Addresses }
AddressFamilySet ADDRESS-FAMILY ::=
{ addressFamilyIPv4 | addressFamilyIPv6 }
addressFamilyIPv4 ADDRESS-FAMILY ::=
{ AFI afi-IPv4 ADDRESSES ROAAddressesIPv4 }
addressFamilyIPv6 ADDRESS-FAMILY ::=
{ AFI afi-IPv6 ADDRESSES ROAAddressesIPv6 }
afi-IPv4 OCTET STRING ::= '0001'H
afi-IPv6 OCTET STRING ::= '0002'H
ROAAddressesIPv4 ::= SEQUENCE (SIZE(1..MAX)) OF ROAIPAddress{ub-IPv4}
ROAAddressesIPv6 ::= SEQUENCE (SIZE(1..MAX)) OF ROAIPAddress{ub-IPv6}
ub-IPv4 INTEGER ::= 32
ub-IPv6 INTEGER ::= 128
ROAIPAddress {INTEGER: ub} ::= SEQUENCE {
address BIT STRING (SIZE(0..ub)),
maxLength INTEGER (0..ub) OPTIONAL }
END
```
编码/解码要点(与上面 ASN.1 结构直接对应):
- `addressFamily` 仅允许 IPv4/IPv6 两种 AFI,并且每个 AFI 最多出现一次。RFC 9582 §4.3.1。
- `address` 是 BIT STRING 表示的前缀,语义与 RFC 3779 的 `IPAddress` 一致(按前缀长度截断,DER unused bits 置零)。RFC 9582 §4.3.2.1(引用 RFC 3779 §2.2.3.8)。
- `maxLength` 为可选字段,出现与否会影响语义与编码规范约束(例如等于前缀长时不建议编码)。RFC 9582 §4.3.2.2。
## 7.3 解析规则(eContent 语义层)
输入:`RpkiSignedObject`。
1) 解析 CMS 外壳,得到 `econtent_type` 与 `econtent_der`。RFC 6488 §3;RFC 9589 §4。
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.24`。RFC 9582 §3。
3) 将 `econtent_der` 以 DER 解析为 `RouteOriginAttestation` ASN.1。RFC 9582 §4。
4) 将 `ipAddrBlocks` 解析为“前缀集合”的语义结构,并建议按 RFC 9582 给出的 canonicalization 过程做去重/排序/归一化(以便后续处理一致)。RFC 9582 §4.3.3。
## 7.4 抽象数据模型(接口)
### 7.4.1 `RoaObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 9582 §1;RFC 6488 §3;RFC 9589 §4 |
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.24` | RFC 9582 §3 |
| `roa` | `RoaEContent` | eContent 语义对象 | 见下 | RFC 9582 §4 |
### 7.4.2 `RoaEContent`(RouteOriginAttestation)
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | version | MUST 为 0 | RFC 9582 §4.1 |
| `as_id` | `int` | asID | 0..4294967295 | RFC 9582 §4(ASID 定义);RFC 9582 §4.2 |
| `ip_addr_blocks` | `list[RoaIpAddressFamily]` | ipAddrBlocks | `SIZE(1..2)`;最多 IPv4/IPv6 各一个;建议 canonicalize | RFC 9582 §4;RFC 9582 §4.3.1;RFC 9582 §4.3.3 |
### 7.4.3 `RoaIpAddressFamily`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `afi` | `enum { ipv4, ipv6 }` | Address Family | MUST 为 IPv4(0001) 或 IPv6(0002) | RFC 9582 §4.3.1 |
| `addresses` | `list[RoaIpAddress]` | 前缀列表 | `SIZE(1..MAX)`;每项为前缀 + 可选 maxLength | RFC 9582 §4(ROAAddressesIPv4/IPv6);RFC 9582 §4.3.2 |
### 7.4.4 `RoaIpAddress`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `prefix` | `IpPrefix` | 前缀 | address 以 BIT STRING 表示前缀(同 RFC 3779 IPAddress 表示) | RFC 9582 §4.3.2.1(引用 RFC 3779 §2.2.3.8) |
| `max_length` | `optional[int]` | 最大允许前缀长 | 若存在:必须 `>= prefix_len` 且 `<= 32/128`;并且 `maxLength == prefix_len` 时 **SHOULD NOT** 编码(未来 RP 可能视为编码错误) | RFC 9582 §4.3.2.2 |
## 7.5 字段级约束清单(实现对照)
- eContentType 必须为 `id-ct-routeOriginAuthz`(OID `1.2.840.113549.1.9.16.1.24`),且该 OID 必须同时出现在 eContentType 与 signedAttrs.content-type。RFC 9582 §3(引用 RFC 6488)。
- eContent 必须 DER 编码并符合 `RouteOriginAttestation` ASN.1。RFC 9582 §4。
- `version` 必须为 0。RFC 9582 §4.1。
- `ipAddrBlocks` 长度为 1..2;每种 AFI 最多出现一次;仅支持 IPv4/IPv6。RFC 9582 §4;RFC 9582 §4.3.1。
- `maxLength` 若存在必须在范围内,且不应出现“等于前缀长”的冗余编码。RFC 9582 §4.3.2.2。
- 建议按 canonical form 归一化/排序以利一致处理。RFC 9582 §4.3.3。
## 7.6 与 EE 证书的语义约束(为后续验证准备)
ROA 的外壳包含一个 EE 证书,用于验证 ROA 签名;RFC 对该 EE 证书与 ROA payload 的匹配关系提出要求:
- ROA 的 EE 证书必须是有效的 RPKI EE 证书(路径从 TA 到 EE 可建立),并用于验证 CMS 签名。RFC 9582 §1(引用 RFC 6488);RFC 6488 §3(2)-(3)。
- ROA EE 证书中的 IP 资源扩展必须存在且不得使用 inherit。RFC 9582 §5。
- ROA EE 证书中 AS 资源扩展不得出现。RFC 9582 §5。
- ROA payload 中每个前缀必须包含在 EE 证书的 IP 资源集合内(资源包含语义来自 RFC 3779)。RFC 9582 §5;RFC 3779 §2.3。

View File

@@ -1,100 +0,0 @@
# 08. ASPA(Autonomous System Provider Authorization)
## 8.1 对象定位
ASPA(Autonomous System Provider Authorization)是一种 RPKI Signed Object,用于由“客户 AS”(Customer AS, CAS)签名声明其上游“提供者 AS”(Provider AS, PAS)集合,以支持路由泄漏(route leak)检测/缓解。`draft-ietf-sidrops-aspa-profile-21`;`draft-ietf-sidrops-aspa-verification`。
ASPA 由 CMS 外壳 + ASPA eContent 组成:
- 外壳:RFC 6488(更新:RFC 9589)
- eContent:`draft-ietf-sidrops-aspa-profile-21`
## 8.2 原始载体与编码
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 6488 §2-§3;RFC 9589 §4。
- eContentType:`id-ct-ASPA`,OID `1.2.840.113549.1.9.16.1.49`。`draft-ietf-sidrops-aspa-profile-21` §2。
- eContent:DER 编码 ASN.1 `ASProviderAttestation`。`draft-ietf-sidrops-aspa-profile-21` §3。
### 8.2.1 eContentType 与 eContent 的 ASN.1 定义(`draft-ietf-sidrops-aspa-profile-21` §2-§3)
ASPA 是一种 RPKI signed object(CMS 外壳见 `05_signed_object_cms.md`)。其 `eContentType` 与 `eContent`(payload)的 ASN.1 定义见 `draft-ietf-sidrops-aspa-profile-21` §2-§3。
**eContentType(OID)**:`draft-ietf-sidrops-aspa-profile-21` §2。
```asn1
id-ct-ASPA OBJECT IDENTIFIER ::=
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
pkcs-9(9) id-smime(16) id-ct(1) aspa(49) }
```
**eContent(ASPA ASN.1 模块)**:`draft-ietf-sidrops-aspa-profile-21` §3。
```asn1
ASProviderAttestation ::= SEQUENCE {
version [0] INTEGER DEFAULT 0,
customerASID ASID,
providers ProviderASSet }
ProviderASSet ::= SEQUENCE (SIZE(1..MAX)) OF ASID
ASID ::= INTEGER (0..4294967295)
```
编码/解码要点(与上面 ASN.1 结构直接对应):
- `version`:规范要求 **MUST 为 1** 且 **MUST 显式编码**(不得依赖 DEFAULT 省略)。`draft-ietf-sidrops-aspa-profile-21` §3.1。
- `customerASID`:客户 AS 号(CAS)。`draft-ietf-sidrops-aspa-profile-21` §3.2。
- `providers`:授权的提供者 AS 集合(SPAS)。并对“自包含/排序/去重”施加额外约束(见下)。`draft-ietf-sidrops-aspa-profile-21` §3.3。
## 8.3 解析规则(eContent 语义层)
输入:`RpkiSignedObject`。
1) 解析 CMS 外壳,得到 `econtent_type` 与 `econtent_der`。RFC 6488 §3;RFC 9589 §4。
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.49`。`draft-ietf-sidrops-aspa-profile-21` §2。
3) 将 `econtent_der` 以 DER 解析为 `ASProviderAttestation` ASN.1。`draft-ietf-sidrops-aspa-profile-21` §3。
4) 将 `providers` 映射为语义字段 `provider_as_ids: list[int]`,并对其执行“约束检查/(可选)归一化”。`draft-ietf-sidrops-aspa-profile-21` §3.3。
## 8.4 抽象数据模型(接口)
### 8.4.1 `AspaObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | 规范引用 |
|---|---|---|---|---|
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 6488 §3;RFC 9589 §4 |
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.49` | `draft-ietf-sidrops-aspa-profile-21` §2 |
| `aspa` | `AspaEContent` | eContent 语义对象 | 见下 | `draft-ietf-sidrops-aspa-profile-21` §3 |
### 8.4.2 `AspaEContent`(ASProviderAttestation)
| 字段 | 类型 | 语义 | 约束/解析规则 | 规范引用 |
|---|---|---|---|---|
| `version` | `int` | ASPA 版本 | MUST 为 `1` 且 MUST 显式编码(字段不可省略) | `draft-ietf-sidrops-aspa-profile-21` §3.1 |
| `customer_as_id` | `int` | Customer ASID | 0..4294967295 | `draft-ietf-sidrops-aspa-profile-21` §3.2(ASID 定义) |
| `provider_as_ids` | `list[int]` | Provider ASID 列表(SPAS) | 长度 `>= 1`;且满足“不得包含 customer、升序、去重” | `draft-ietf-sidrops-aspa-profile-21` §3.3 |
## 8.5 字段级约束清单(实现对照)
- eContentType 必须为 `id-ct-ASPA`(OID `1.2.840.113549.1.9.16.1.49`),且该 OID 必须同时出现在 eContentType 与 signedAttrs.content-type。`draft-ietf-sidrops-aspa-profile-21` §2(引用 RFC 6488)。
- eContent 必须 DER 编码并符合 `ASProviderAttestation` ASN.1。`draft-ietf-sidrops-aspa-profile-21` §3。
- `version` 必须为 1,且必须显式编码(缺失视为不合规)。`draft-ietf-sidrops-aspa-profile-21` §3.1。
- `providers`(`provider_as_ids`)必须满足:
- `customer_as_id` **MUST NOT** 出现在 `provider_as_ids` 中;
- `provider_as_ids` 必须按数值 **升序排序**;
- `provider_as_ids` 中每个 ASID 必须 **唯一**。`draft-ietf-sidrops-aspa-profile-21` §3.3。
## 8.6 与 EE 证书的语义约束(为后续验证准备)
ASPA 的外壳包含一个 EE 证书,用于验证 ASPA 签名;规范对该 EE 证书与 ASPA payload 的匹配关系提出要求:
- EE 证书必须包含 AS 资源扩展(Autonomous System Identifier Delegation Extension),且 `customer_as_id` 必须与该扩展中的 ASId 匹配。`draft-ietf-sidrops-aspa-profile-21` §4(引用 RFC 3779)。
- EE 证书的 AS 资源扩展 **必须**:
- 恰好包含 1 个 `id` 元素;
- **不得**包含 `inherit` 元素;
- **不得**包含 `range` 元素。`draft-ietf-sidrops-aspa-profile-21` §4(引用 RFC 3779 §3.2.3.3 / §3.2.3.6 / §3.2.3.7)。
- EE 证书 **不得**包含 IP 资源扩展(IP Address Delegation Extension)。`draft-ietf-sidrops-aspa-profile-21` §4(引用 RFC 3779)。
## 8.7 实现建议(非规范约束)
`draft-ietf-sidrops-aspa-profile-21` 给出了一条 RP 侧建议:实现可对单个 `customer_as_id` 的 `provider_as_ids` 数量施加上界(例如 4,000~10,000),超过阈值时建议将该 `customer_as_id` 的所有 ASPA 视为无效并记录错误日志。`draft-ietf-sidrops-aspa-profile-21` §6。

View File

@@ -1,87 +0,0 @@
# 09. Ghostbusters Record(GBR)
## 9.1 对象定位
Ghostbusters Record(GBR)是一个可选的 RPKI Signed Object,用于承载“联系人信息”(人类可读的联系渠道),以便在证书过期、CRL 失效、密钥轮换等事件中能够联系到维护者。RFC 6493 §1。
GBR 由 CMS 外壳 + vCard 载荷组成:
- 外壳:RFC 6488(更新:RFC 9589)
- 载荷(payload):RFC 6493 定义的 vCard profile(基于 RFC 6350 vCard 4.0 的严格子集)。RFC 6493 §5。
## 9.2 原始载体与编码
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 6493 §6(引用 RFC 6488)。
- eContentType:`id-ct-rpkiGhostbusters`,OID `1.2.840.113549.1.9.16.1.35`。RFC 6493 §6;RFC 6493 §9.1。
- eContent:一个 OCTET STRING,其 octets 是 vCard 文本(vCard 4.0,且受 RFC 6493 的 profile 约束)。RFC 6493 §5;RFC 6493 §6。
> 说明:与 ROA/MFT 这类“eContent 内部再 DER 解码为 ASN.1 结构”的对象不同,GBR 的 eContent 语义上就是“vCard 文本内容本身”(由 Signed Object Template 的 `eContent OCTET STRING` 承载)。RFC 6493 §6。
## 9.3 vCard profile(RFC 6493 §5)
GBR 的 vCard payload 是 RFC 6350 vCard 4.0 的严格子集,仅允许以下属性(properties):
- `BEGIN`:必须为第一行,值必须为 `BEGIN:VCARD`。RFC 6493 §5。
- `VERSION`:必须为第二行,值必须为 `VERSION:4.0`。RFC 6493 §5(引用 RFC 6350 §3.7.9)。
- `FN`:联系人姓名或角色名。RFC 6493 §5(引用 RFC 6350 §6.2.1)。
- `ORG`:组织信息(可选)。RFC 6493 §5(引用 RFC 6350 §6.6.4)。
- `ADR`:邮寄地址(可选)。RFC 6493 §5(引用 RFC 6350 §6.3)。
- `TEL`:语音/传真电话(可选)。RFC 6493 §5(引用 RFC 6350 §6.4.1)。
- `EMAIL`:邮箱(可选)。RFC 6493 §5(引用 RFC 6350 §6.4.2)。
- `END`:必须为最后一行,值必须为 `END:VCARD`。RFC 6493 §5。
额外约束:
- `BEGIN`、`VERSION`、`FN`、`END` 必须包含。RFC 6493 §5。
- 为保证可用性,`ADR`/`TEL`/`EMAIL` 三者中至少一个必须包含。RFC 6493 §5。
- 除上述属性外,**其他属性 MUST NOT** 出现。RFC 6493 §5。
## 9.4 解析规则(payload 语义层)
输入:`RpkiSignedObject`。
1) 解析 CMS 外壳,得到 `econtent_type` 与 `econtent_bytes`。RFC 6488 §3;RFC 9589 §4。
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.35`。RFC 6493 §6。
3) 将 `econtent_bytes` 解析为 vCard 文本,并按 RFC 6493 §5 的 profile 校验(属性集合、必选项、行首/行尾约束)。RFC 6493 §5;RFC 6493 §7。
4) 通过校验后,将允许属性映射为 `GhostbustersVCard` 语义对象(见下)。
## 9.5 抽象数据模型(接口)
### 9.5.1 `GhostbustersObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 6493 §6;RFC 6488 §3;RFC 9589 §4 |
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.35` | RFC 6493 §6 |
| `vcard` | `GhostbustersVCard` | vCard 语义对象 | 由 eContent 文本解析并校验 profile | RFC 6493 §5;RFC 6493 §7 |
### 9.5.2 `GhostbustersVCard`(vCard 4.0 profile)
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw_text` | `string` | 原始 vCard 文本 | 由 eContent bytes 解码得到;用于保留原文/诊断 | RFC 6493 §5-§7 |
| `fn` | `string` | 联系人姓名/角色名 | `FN` 必须存在 | RFC 6493 §5 |
| `org` | `optional[string]` | 组织 | `ORG` 可选 | RFC 6493 §5 |
| `adrs` | `list[string]` | 邮寄地址(原始 ADR value) | 允许 0..N;至少满足“ADR/TEL/EMAIL 至少一项存在” | RFC 6493 §5 |
| `tels` | `list[Uri]` | 电话 URI(从 TEL 提取的 `tel:` 等 URI) | 允许 0..N;至少满足“ADR/TEL/EMAIL 至少一项存在” | RFC 6493 §5 |
| `emails` | `list[string]` | 邮箱地址 | 允许 0..N;至少满足“ADR/TEL/EMAIL 至少一项存在” | RFC 6493 §5 |
> 说明:RFC 6493 并未要求 RP 必须完整解析 vCard 参数(例如 `TYPE=WORK`、`VALUE=uri`),因此此处将 `ADR`/`TEL`/`EMAIL` 建模为“足以联络”的最小语义集合;实现可在保留 `raw_text` 的同时按 RFC 6350 扩展解析能力。
## 9.6 字段级约束清单(实现对照)
- eContentType 必须为 `id-ct-rpkiGhostbusters`(OID `1.2.840.113549.1.9.16.1.35`),且该 OID 必须同时出现在 eContentType 与 signedAttrs.content-type。RFC 6493 §6(引用 RFC 6488)。
- eContent 必须是 vCard 4.0 文本,且必须满足 RFC 6493 §5 的 profile:
- 第一行 `BEGIN:VCARD`;
- 第二行 `VERSION:4.0`;
- 末行 `END:VCARD`;
- 必须包含 `FN`;
- `ADR`/`TEL`/`EMAIL` 至少一个存在;
- 除允许集合外不得出现其他属性。RFC 6493 §5;RFC 6493 §7。
## 9.7 与 EE 证书的语义约束(为后续验证准备)
GBR 使用 CMS 外壳内的 EE 证书验证签名。RFC 6493 对该 EE 证书提出一条资源扩展约束:
- 用于验证 GBR 的 EE 证书在描述 Internet Number Resources 时,必须使用 `inherit`,而不是显式资源集合。RFC 6493 §6(引用 RFC 3779)。

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
pub mod timing;

View File

@@ -1,308 +0,0 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct TimingHandle {
inner: Arc<Mutex<TimingCollector>>,
}
impl TimingHandle {
pub fn new(meta: TimingMeta) -> Self {
Self {
inner: Arc::new(Mutex::new(TimingCollector::new(meta))),
}
}
pub fn span_phase(&self, phase: &'static str) -> TimingSpanGuard<'_> {
TimingSpanGuard {
handle: self.clone(),
kind: TimingSpanKind::Phase(phase),
start: Instant::now(),
}
}
pub fn span_rrdp_repo<'a>(&self, repo_uri: &'a str) -> TimingSpanGuard<'a> {
TimingSpanGuard {
handle: self.clone(),
kind: TimingSpanKind::RrdpRepo(repo_uri),
start: Instant::now(),
}
}
pub fn span_rrdp_repo_step<'a>(
&self,
repo_uri: &'a str,
step: &'static str,
) -> TimingSpanGuard<'a> {
TimingSpanGuard {
handle: self.clone(),
kind: TimingSpanKind::RrdpRepoStep { repo_uri, step },
start: Instant::now(),
}
}
pub fn span_publication_point<'a>(&self, manifest_rsync_uri: &'a str) -> TimingSpanGuard<'a> {
TimingSpanGuard {
handle: self.clone(),
kind: TimingSpanKind::PublicationPoint(manifest_rsync_uri),
start: Instant::now(),
}
}
pub fn set_meta(&self, update: TimingMetaUpdate<'_>) {
let mut g = self.inner.lock().expect("timing lock");
if let Some(v) = update.tal_url {
g.meta.tal_url = Some(v.to_string());
}
if let Some(v) = update.db_path {
g.meta.db_path = Some(v.to_string());
}
}
pub fn record_count(&self, key: &'static str, inc: u64) {
let mut g = self.inner.lock().expect("timing lock");
g.counts
.entry(key)
.and_modify(|v| *v = v.saturating_add(inc))
.or_insert(inc);
}
/// Record a phase duration directly in nanoseconds.
///
/// This is useful when aggregating sub-phase timings locally (to reduce lock contention)
/// and then emitting a single record per publication point.
pub fn record_phase_nanos(&self, phase: &'static str, nanos: u64) {
let mut g = self.inner.lock().expect("timing lock");
g.phases.record(phase, nanos);
}
pub fn write_json(&self, path: &Path, top_n: usize) -> Result<(), String> {
let report = {
let g = self.inner.lock().expect("timing lock");
g.to_report(top_n)
};
let f = std::fs::File::create(path)
.map_err(|e| format!("create timing json failed: {}: {e}", path.display()))?;
serde_json::to_writer_pretty(f, &report)
.map_err(|e| format!("write timing json failed: {e}"))?;
Ok(())
}
fn record_duration(&self, kind: TimingSpanKind<'_>, duration: Duration) {
let nanos_u64 = duration.as_nanos().min(u128::from(u64::MAX)) as u64;
let mut g = self.inner.lock().expect("timing lock");
match kind {
TimingSpanKind::Phase(name) => g.phases.record(name, nanos_u64),
TimingSpanKind::RrdpRepo(uri) => g.rrdp_repos.record(uri, nanos_u64),
TimingSpanKind::RrdpRepoStep { repo_uri, step } => g
.rrdp_repo_steps
.record(&format!("{repo_uri}::{step}"), nanos_u64),
TimingSpanKind::PublicationPoint(uri) => g.publication_points.record(uri, nanos_u64),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimingMeta {
pub recorded_at_utc_rfc3339: String,
pub validation_time_utc_rfc3339: String,
pub tal_url: Option<String>,
pub db_path: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct TimingMetaUpdate<'a> {
pub tal_url: Option<&'a str>,
pub db_path: Option<&'a str>,
}
pub struct TimingSpanGuard<'a> {
handle: TimingHandle,
kind: TimingSpanKind<'a>,
start: Instant,
}
impl Drop for TimingSpanGuard<'_> {
fn drop(&mut self) {
self.handle
.record_duration(self.kind.clone(), self.start.elapsed());
}
}
#[derive(Clone, Debug)]
enum TimingSpanKind<'a> {
Phase(&'static str),
RrdpRepo(&'a str),
RrdpRepoStep {
repo_uri: &'a str,
step: &'static str,
},
PublicationPoint(&'a str),
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct DurationStats {
pub count: u64,
pub total_nanos: u64,
}
impl DurationStats {
fn record(&mut self, nanos: u64) {
self.count = self.count.saturating_add(1);
self.total_nanos = self.total_nanos.saturating_add(nanos);
}
}
#[derive(Clone, Debug, Default)]
struct DurationStatsMap {
map: HashMap<String, DurationStats>,
}
impl DurationStatsMap {
fn record(&mut self, key: &str, nanos: u64) {
self.map.entry(key.to_string()).or_default().record(nanos);
}
fn top(&self, n: usize) -> Vec<TopDurationEntry> {
let mut v = self
.map
.iter()
.map(|(k, s)| TopDurationEntry {
key: k.clone(),
count: s.count,
total_nanos: s.total_nanos,
})
.collect::<Vec<_>>();
v.sort_by(|a, b| b.total_nanos.cmp(&a.total_nanos));
v.truncate(n);
v
}
}
struct TimingCollector {
meta: TimingMeta,
counts: HashMap<&'static str, u64>,
phases: DurationStatsMap,
rrdp_repos: DurationStatsMap,
rrdp_repo_steps: DurationStatsMap,
publication_points: DurationStatsMap,
}
impl TimingCollector {
fn new(meta: TimingMeta) -> Self {
Self {
meta,
counts: HashMap::new(),
phases: DurationStatsMap::default(),
rrdp_repos: DurationStatsMap::default(),
rrdp_repo_steps: DurationStatsMap::default(),
publication_points: DurationStatsMap::default(),
}
}
fn to_report(&self, top_n: usize) -> TimingReportV1 {
TimingReportV1 {
format_version: 1,
meta: self.meta.clone(),
counts: self
.counts
.iter()
.map(|(k, v)| ((*k).to_string(), *v))
.collect(),
phases: self
.phases
.map
.iter()
.map(|(k, s)| (k.clone(), s.clone()))
.collect(),
top_rrdp_repos: self.rrdp_repos.top(top_n),
top_rrdp_repo_steps: self.rrdp_repo_steps.top(top_n),
top_publication_points: self.publication_points.top(top_n),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TimingReportV1 {
pub format_version: u64,
pub meta: TimingMeta,
pub counts: HashMap<String, u64>,
pub phases: HashMap<String, DurationStats>,
pub top_rrdp_repos: Vec<TopDurationEntry>,
pub top_rrdp_repo_steps: Vec<TopDurationEntry>,
pub top_publication_points: Vec<TopDurationEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TopDurationEntry {
pub key: String,
pub count: u64,
pub total_nanos: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timing_handle_writes_json_with_phases_and_tops() {
let meta = TimingMeta {
recorded_at_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
validation_time_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
tal_url: Some("https://example.test/x.tal".to_string()),
db_path: Some("db".to_string()),
};
let h = TimingHandle::new(meta);
{
let _p = h.span_phase("tal_bootstrap");
}
{
let _r = h.span_rrdp_repo("https://rrdp.example.test/notification.xml");
}
{
let _s = h.span_rrdp_repo_step(
"https://rrdp.example.test/notification.xml",
"fetch_notification",
);
}
{
let _pp = h.span_publication_point("rsync://example.test/repo/manifest.mft");
}
h.record_count("vrps", 42);
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("timing.json");
h.write_json(&path, 10).expect("write_json");
let rep: TimingReportV1 =
serde_json::from_slice(&std::fs::read(&path).expect("read timing.json"))
.expect("parse timing.json");
assert_eq!(rep.format_version, 1);
assert!(rep.phases.contains_key("tal_bootstrap"));
assert_eq!(rep.counts.get("vrps").copied(), Some(42));
assert!(
rep.top_rrdp_repos
.iter()
.any(|e| e.key.contains("rrdp.example.test")),
"expected repo in top list"
);
assert!(
rep.top_rrdp_repo_steps
.iter()
.any(|e| e.key.contains("fetch_notification")),
"expected repo step in top list"
);
assert!(
rep.top_publication_points
.iter()
.any(|e| e.key.contains("manifest.mft")),
"expected PP in top list"
);
}
}

View File

@@ -1,224 +0,0 @@
use serde::Serialize;
use sha2::Digest;
use crate::policy::Policy;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditObjectKind {
Manifest,
Crl,
Certificate,
Roa,
Aspa,
Other,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditObjectResult {
Ok,
Skipped,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ObjectAuditEntry {
pub rsync_uri: String,
pub sha256_hex: String,
pub kind: AuditObjectKind,
pub result: AuditObjectResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditWarning {
pub message: String,
pub rfc_refs: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
}
impl From<&crate::report::Warning> for AuditWarning {
fn from(w: &crate::report::Warning) -> Self {
Self {
message: w.message.clone(),
rfc_refs: w.rfc_refs.iter().map(|r| r.0.to_string()).collect(),
context: w.context.clone(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct PublicationPointAudit {
/// Monotonic node ID assigned by the traversal engine.
///
/// Present when running via the Stage2 tree engine; may be absent in ad-hoc runs.
#[serde(skip_serializing_if = "Option::is_none")]
pub node_id: Option<u64>,
/// Parent node ID in the traversal tree.
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_node_id: Option<u64>,
/// Provenance metadata for non-root nodes (how this CA instance was discovered).
#[serde(skip_serializing_if = "Option::is_none")]
pub discovered_from: Option<DiscoveredFrom>,
pub rsync_base_uri: String,
pub manifest_rsync_uri: String,
pub publication_point_rsync_uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rrdp_notification_uri: Option<String>,
pub source: String,
pub this_update_rfc3339_utc: String,
pub next_update_rfc3339_utc: String,
pub verified_at_rfc3339_utc: String,
pub warnings: Vec<AuditWarning>,
pub objects: Vec<ObjectAuditEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct DiscoveredFrom {
pub parent_manifest_rsync_uri: String,
pub child_ca_certificate_rsync_uri: String,
pub child_ca_certificate_sha256_hex: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TreeSummary {
pub instances_processed: usize,
pub instances_failed: usize,
pub warnings: Vec<AuditWarning>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditRunMeta {
pub validation_time_rfc3339_utc: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditDownloadKind {
RrdpNotification,
RrdpSnapshot,
RrdpDelta,
Rsync,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AuditDownloadObjectsStat {
pub objects_count: u64,
pub objects_bytes_total: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditDownloadEvent {
pub kind: AuditDownloadKind,
pub uri: String,
pub started_at_rfc3339_utc: String,
pub finished_at_rfc3339_utc: String,
pub duration_ms: u64,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub objects: Option<AuditDownloadObjectsStat>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AuditDownloadKindStats {
pub ok_total: u64,
pub fail_total: u64,
pub duration_ms_total: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes_total: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub objects_count_total: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub objects_bytes_total: Option<u64>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AuditDownloadStats {
pub events_total: u64,
/// Statistics keyed by serialized `AuditDownloadKind` string (e.g. "rrdp_snapshot").
pub by_kind: std::collections::BTreeMap<String, AuditDownloadKindStats>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditReportV1 {
pub format_version: u32,
pub meta: AuditRunMeta,
pub policy: Policy,
pub tree: TreeSummary,
pub publication_points: Vec<PublicationPointAudit>,
pub vrps: Vec<VrpOutput>,
pub aspas: Vec<AspaOutput>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditReportV2 {
pub format_version: u32,
pub meta: AuditRunMeta,
pub policy: Policy,
pub tree: TreeSummary,
pub publication_points: Vec<PublicationPointAudit>,
pub vrps: Vec<VrpOutput>,
pub aspas: Vec<AspaOutput>,
pub downloads: Vec<AuditDownloadEvent>,
pub download_stats: AuditDownloadStats,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct VrpOutput {
pub asn: u32,
pub prefix: String,
pub max_length: u16,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AspaOutput {
pub customer_as_id: u32,
pub provider_as_ids: Vec<u32>,
}
pub fn sha256_hex_from_32(bytes: &[u8; 32]) -> String {
hex::encode(bytes)
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest = sha2::Sha256::digest(bytes);
hex::encode(digest)
}
pub fn format_roa_ip_prefix(p: &crate::data_model::roa::IpPrefix) -> String {
let addr = p.addr_bytes();
match p.afi {
crate::data_model::roa::RoaAfi::Ipv4 => {
format!(
"{}.{}.{}.{}{}",
addr[0],
addr[1],
addr[2],
addr[3],
format!("/{}", p.prefix_len)
)
}
crate::data_model::roa::RoaAfi::Ipv6 => {
let mut parts = Vec::with_capacity(8);
for i in 0..8 {
let hi = addr[i * 2] as u16;
let lo = addr[i * 2 + 1] as u16;
parts.push(format!("{:x}", (hi << 8) | lo));
}
format!("{}{}", parts.join(":"), format!("/{}", p.prefix_len))
}
}
}

View File

@@ -1,168 +0,0 @@
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::audit::{
AuditDownloadEvent, AuditDownloadKind, AuditDownloadKindStats, AuditDownloadObjectsStat,
AuditDownloadStats,
};
#[derive(Clone, Debug, Default)]
pub struct DownloadLogHandle {
inner: Arc<Mutex<Vec<AuditDownloadEvent>>>,
}
impl DownloadLogHandle {
pub fn new() -> Self {
Self::default()
}
pub fn record_event(&self, event: AuditDownloadEvent) {
self.inner.lock().expect("download log lock").push(event);
}
pub fn snapshot_events(&self) -> Vec<AuditDownloadEvent> {
self.inner.lock().expect("download log lock").clone()
}
pub fn stats_from_events(events: &[AuditDownloadEvent]) -> AuditDownloadStats {
let mut out = AuditDownloadStats {
events_total: events.len() as u64,
by_kind: BTreeMap::new(),
};
for e in events {
let kind_key = match e.kind {
AuditDownloadKind::RrdpNotification => "rrdp_notification",
AuditDownloadKind::RrdpSnapshot => "rrdp_snapshot",
AuditDownloadKind::RrdpDelta => "rrdp_delta",
AuditDownloadKind::Rsync => "rsync",
}
.to_string();
let st = out.by_kind.entry(kind_key).or_insert_with(|| AuditDownloadKindStats {
ok_total: 0,
fail_total: 0,
duration_ms_total: 0,
bytes_total: None,
objects_count_total: None,
objects_bytes_total: None,
});
if e.success {
st.ok_total = st.ok_total.saturating_add(1);
} else {
st.fail_total = st.fail_total.saturating_add(1);
}
st.duration_ms_total = st.duration_ms_total.saturating_add(e.duration_ms);
if let Some(b) = e.bytes {
st.bytes_total = Some(st.bytes_total.unwrap_or(0).saturating_add(b));
}
if let Some(objects) = &e.objects {
st.objects_count_total = Some(
st.objects_count_total
.unwrap_or(0)
.saturating_add(objects.objects_count),
);
st.objects_bytes_total = Some(
st.objects_bytes_total
.unwrap_or(0)
.saturating_add(objects.objects_bytes_total),
);
}
}
out
}
pub fn stats(&self) -> AuditDownloadStats {
let events = self.snapshot_events();
Self::stats_from_events(&events)
}
pub fn span_download<'a>(
&'a self,
kind: AuditDownloadKind,
uri: &'a str,
) -> DownloadSpanGuard<'a> {
DownloadSpanGuard {
handle: self,
kind,
uri,
start_instant: Instant::now(),
started_at: time::OffsetDateTime::now_utc(),
bytes: None,
objects: None,
error: None,
success: None,
}
}
}
pub struct DownloadSpanGuard<'a> {
handle: &'a DownloadLogHandle,
kind: AuditDownloadKind,
uri: &'a str,
start_instant: Instant,
started_at: time::OffsetDateTime,
bytes: Option<u64>,
objects: Option<AuditDownloadObjectsStat>,
error: Option<String>,
success: Option<bool>,
}
impl DownloadSpanGuard<'_> {
pub fn set_bytes(&mut self, bytes: u64) {
self.bytes = Some(bytes);
}
pub fn set_objects(&mut self, objects_count: u64, objects_bytes_total: u64) {
self.objects = Some(AuditDownloadObjectsStat {
objects_count,
objects_bytes_total,
});
}
pub fn set_ok(&mut self) {
self.success = Some(true);
}
pub fn set_err(&mut self, msg: impl Into<String>) {
self.success = Some(false);
self.error = Some(msg.into());
}
}
impl Drop for DownloadSpanGuard<'_> {
fn drop(&mut self) {
use time::format_description::well_known::Rfc3339;
let finished_at = time::OffsetDateTime::now_utc();
let dur = self.start_instant.elapsed();
let duration_ms = duration_to_ms(dur);
let started_at_rfc3339_utc = self
.started_at
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.unwrap_or_else(|_| "<format-error>".to_string());
let finished_at_rfc3339_utc = finished_at
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.unwrap_or_else(|_| "<format-error>".to_string());
let success = self.success.unwrap_or(false);
let event = AuditDownloadEvent {
kind: self.kind.clone(),
uri: self.uri.to_string(),
started_at_rfc3339_utc,
finished_at_rfc3339_utc,
duration_ms,
success,
error: if success { None } else { self.error.clone() },
bytes: self.bytes,
objects: self.objects.clone(),
};
self.handle.record_event(event);
}
}
fn duration_to_ms(d: Duration) -> u64 {
let ms = d.as_millis();
ms.min(u128::from(u64::MAX)) as u64
}

View File

@@ -1,125 +0,0 @@
use std::path::PathBuf;
use rocksdb::{ColumnFamilyDescriptor, DB, IteratorMode, Options};
const CF_RAW_OBJECTS: &str = "raw_objects";
const CF_FETCH_CACHE_PP: &str = "fetch_cache_pp";
const CF_RRDP_STATE: &str = "rrdp_state";
const CF_RRDP_OBJECT_INDEX: &str = "rrdp_object_index";
fn enable_blobdb_if_supported(opts: &mut Options) {
// Keep this in sync with `rpki::storage`:
// blob files are CF-level options; readers should open CFs with blob enabled too.
#[allow(dead_code)]
fn _set(opts: &mut Options) {
opts.set_enable_blob_files(true);
opts.set_min_blob_size(1024);
}
_set(opts);
}
fn usage() -> String {
let bin = "db_stats";
format!(
"\
Usage:
{bin} --db <path> [--exact]
Options:
--db <path> RocksDB directory
--exact Iterate to count keys (slower; default uses RocksDB estimates)
--help Show this help
"
)
}
fn cf_descriptors() -> Vec<ColumnFamilyDescriptor> {
let mut cf_opts = Options::default();
enable_blobdb_if_supported(&mut cf_opts);
vec![
ColumnFamilyDescriptor::new(CF_RAW_OBJECTS, cf_opts.clone()),
ColumnFamilyDescriptor::new(CF_FETCH_CACHE_PP, cf_opts.clone()),
ColumnFamilyDescriptor::new(CF_RRDP_STATE, cf_opts.clone()),
ColumnFamilyDescriptor::new(CF_RRDP_OBJECT_INDEX, cf_opts),
]
}
fn estimate_keys(db: &DB, cf_name: &str) -> Result<Option<u64>, Box<dyn std::error::Error>> {
let cf = db
.cf_handle(cf_name)
.ok_or_else(|| format!("missing column family: {cf_name}"))?;
Ok(db.property_int_value_cf(cf, "rocksdb.estimate-num-keys")?)
}
fn exact_keys(db: &DB, cf_name: &str) -> Result<u64, Box<dyn std::error::Error>> {
let cf = db
.cf_handle(cf_name)
.ok_or_else(|| format!("missing column family: {cf_name}"))?;
let mode = IteratorMode::Start;
let mut count = 0u64;
for res in db.iterator_cf(cf, mode) {
res?;
count += 1;
}
Ok(count)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<String> = std::env::args().collect();
if argv.iter().any(|a| a == "--help" || a == "-h") {
print!("{}", usage());
return Ok(());
}
let mut db_path: Option<PathBuf> = None;
let mut exact = false;
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--db" => {
i += 1;
let v = argv.get(i).ok_or("--db requires a value")?;
db_path = Some(PathBuf::from(v));
}
"--exact" => exact = true,
other => return Err(format!("unknown argument: {other}\n\n{}", usage()).into()),
}
i += 1;
}
let db_path = db_path.ok_or_else(|| format!("--db is required\n\n{}", usage()))?;
let mut opts = Options::default();
opts.create_if_missing(false);
opts.create_missing_column_families(false);
let db = DB::open_cf_descriptors(&opts, &db_path, cf_descriptors())?;
let cf_names = [
CF_RAW_OBJECTS,
CF_FETCH_CACHE_PP,
CF_RRDP_STATE,
CF_RRDP_OBJECT_INDEX,
];
println!("db={}", db_path.display());
println!("mode={}", if exact { "exact" } else { "estimate" });
let mut total: u64 = 0;
for name in cf_names {
let n = if exact {
exact_keys(&db, name)?
} else {
estimate_keys(&db, name)?.unwrap_or(0)
};
total = total.saturating_add(n);
println!("{name}={n}");
}
println!("total={total}");
// Also print # of SST files (useful sanity signal).
let live = db.live_files()?;
println!("sst_files={}", live.len());
Ok(())
}

View File

@@ -1,12 +0,0 @@
fn main() {
let argv: Vec<String> = std::env::args().collect();
if let Err(e) = rpki::cli::run(&argv) {
// `parse_args` uses `Err(usage())` for `--help`, so treat it as success.
if argv.iter().any(|a| a == "--help" || a == "-h") {
println!("{e}");
return;
}
eprintln!("{e}");
std::process::exit(2);
}
}

View File

@@ -1,85 +0,0 @@
use std::path::PathBuf;
use rocksdb::{ColumnFamilyDescriptor, DB, IteratorMode, Options};
fn enable_blobdb_if_supported(opts: &mut Options) {
// Keep this in sync with `rpki::storage`:
// blob files are CF-level options; readers should open CFs with blob enabled too.
#[allow(dead_code)]
fn _set(opts: &mut Options) {
opts.set_enable_blob_files(true);
opts.set_min_blob_size(1024);
}
_set(opts);
}
fn usage() -> String {
let bin = "rrdp_state_dump";
format!(
"\
Usage:
{bin} --db <path>
Options:
--db <path> RocksDB directory
--help Show this help
"
)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<String> = std::env::args().collect();
if argv.iter().any(|a| a == "--help" || a == "-h") {
print!("{}", usage());
return Ok(());
}
let mut db_path: Option<PathBuf> = None;
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--db" => {
i += 1;
let v = argv.get(i).ok_or("--db requires a value")?;
db_path = Some(PathBuf::from(v));
}
other => return Err(format!("unknown argument: {other}\n\n{}", usage()).into()),
}
i += 1;
}
let db_path = db_path.ok_or_else(|| format!("--db is required\n\n{}", usage()))?;
let mut opts = Options::default();
opts.create_if_missing(false);
opts.create_missing_column_families(false);
// Open only the column families we need.
let mut cf_opts = Options::default();
enable_blobdb_if_supported(&mut cf_opts);
let cfs = vec![
ColumnFamilyDescriptor::new("raw_objects", cf_opts.clone()),
ColumnFamilyDescriptor::new("fetch_cache_pp", cf_opts.clone()),
ColumnFamilyDescriptor::new("rrdp_state", cf_opts.clone()),
ColumnFamilyDescriptor::new("rrdp_object_index", cf_opts),
];
let db = DB::open_cf_descriptors(&opts, &db_path, cfs)?;
let cf = db
.cf_handle("rrdp_state")
.ok_or("missing column family: rrdp_state")?;
let mut out: Vec<(String, u64, String)> = Vec::new();
for res in db.iterator_cf(cf, IteratorMode::Start) {
let (k, v) = res?;
let k = String::from_utf8_lossy(&k).to_string();
let st = rpki::sync::rrdp::RrdpState::decode(&v)
.map_err(|e| format!("decode rrdp_state failed for {k}: {e}"))?;
out.push((k, st.serial, st.session_id));
}
out.sort_by(|a, b| a.0.cmp(&b.0));
for (k, serial, session) in out {
println!("{k}\t{serial}\t{session}");
}
Ok(())
}

View File

@@ -1,926 +0,0 @@
use std::path::{Path, PathBuf};
use crate::analysis::timing::{TimingHandle, TimingMeta, TimingMetaUpdate};
use crate::audit::{
AspaOutput, AuditReportV2, AuditRunMeta, AuditWarning, TreeSummary, VrpOutput,
format_roa_ip_prefix,
};
use crate::fetch::http::{BlockingHttpFetcher, HttpFetcherConfig};
use crate::fetch::rsync::LocalDirRsyncFetcher;
use crate::fetch::rsync_system::{SystemRsyncConfig, SystemRsyncFetcher};
use crate::policy::Policy;
use crate::storage::RocksStore;
use crate::validation::run_tree_from_tal::{
RunTreeFromTalAuditOutput, run_tree_from_tal_and_ta_der_serial_audit,
run_tree_from_tal_and_ta_der_serial_audit_with_timing, run_tree_from_tal_url_serial_audit,
run_tree_from_tal_url_serial_audit_with_timing,
};
use crate::validation::tree::TreeRunConfig;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CliArgs {
pub tal_url: Option<String>,
pub tal_path: Option<PathBuf>,
pub ta_path: Option<PathBuf>,
pub db_path: PathBuf,
pub policy_path: Option<PathBuf>,
pub report_json_path: Option<PathBuf>,
pub rsync_local_dir: Option<PathBuf>,
pub http_timeout_secs: u64,
pub rsync_timeout_secs: u64,
pub rsync_mirror_root: Option<PathBuf>,
pub max_depth: Option<usize>,
pub max_instances: Option<usize>,
pub validation_time: Option<time::OffsetDateTime>,
pub revalidate_only: bool,
pub analyze: bool,
pub profile_cpu: bool,
}
fn usage() -> String {
let bin = "rpki";
format!(
"\
Usage:
{bin} --db <path> --tal-url <url> [options]
{bin} --db <path> --tal-path <path> --ta-path <path> [options]
Options:
--db <path> RocksDB directory path (required)
--policy <path> Policy TOML path (optional)
--report-json <path> Write full audit report as JSON (optional)
--tal-url <url> TAL URL (downloads TAL + TA over HTTPS)
--tal-path <path> TAL file path (offline-friendly; requires --ta-path)
--ta-path <path> TA certificate DER file path (offline-friendly)
--rsync-local-dir <path> Use LocalDirRsyncFetcher rooted at this directory (offline tests)
--http-timeout-secs <n> HTTP fetch timeout seconds (default: 20)
--rsync-timeout-secs <n> rsync I/O timeout seconds (default: 60)
--rsync-mirror-root <path> Persist rsync mirrors under this directory (default: disabled)
--max-depth <n> Max CA instance depth (0 = root only)
--max-instances <n> Max number of CA instances to process
--validation-time <rfc3339> Validation time in RFC3339 (default: now UTC)
--revalidate-only Skip RRDP/rsync sync; re-validate from existing DB cache
--analyze Write timing analysis JSON under target/live/analyze/<timestamp>/
--profile-cpu (Requires build feature 'profile') Write CPU flamegraph under analyze dir
--help Show this help
"
)
}
pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
let mut tal_url: Option<String> = None;
let mut tal_path: Option<PathBuf> = None;
let mut ta_path: Option<PathBuf> = None;
let mut db_path: Option<PathBuf> = None;
let mut policy_path: Option<PathBuf> = None;
let mut report_json_path: Option<PathBuf> = None;
let mut rsync_local_dir: Option<PathBuf> = None;
let mut http_timeout_secs: u64 = 20;
let mut rsync_timeout_secs: u64 = 60;
let mut rsync_mirror_root: Option<PathBuf> = None;
let mut max_depth: Option<usize> = None;
let mut max_instances: Option<usize> = None;
let mut validation_time: Option<time::OffsetDateTime> = None;
let mut revalidate_only: bool = false;
let mut analyze: bool = false;
let mut profile_cpu: bool = false;
let mut i = 1usize;
while i < argv.len() {
let arg = argv[i].as_str();
match arg {
"--help" | "-h" => return Err(usage()),
"--tal-url" => {
i += 1;
let v = argv.get(i).ok_or("--tal-url requires a value")?;
tal_url = Some(v.clone());
}
"--tal-path" => {
i += 1;
let v = argv.get(i).ok_or("--tal-path requires a value")?;
tal_path = Some(PathBuf::from(v));
}
"--ta-path" => {
i += 1;
let v = argv.get(i).ok_or("--ta-path requires a value")?;
ta_path = Some(PathBuf::from(v));
}
"--db" => {
i += 1;
let v = argv.get(i).ok_or("--db requires a value")?;
db_path = Some(PathBuf::from(v));
}
"--policy" => {
i += 1;
let v = argv.get(i).ok_or("--policy requires a value")?;
policy_path = Some(PathBuf::from(v));
}
"--report-json" => {
i += 1;
let v = argv.get(i).ok_or("--report-json requires a value")?;
report_json_path = Some(PathBuf::from(v));
}
"--rsync-local-dir" => {
i += 1;
let v = argv.get(i).ok_or("--rsync-local-dir requires a value")?;
rsync_local_dir = Some(PathBuf::from(v));
}
"--http-timeout-secs" => {
i += 1;
let v = argv.get(i).ok_or("--http-timeout-secs requires a value")?;
http_timeout_secs = v
.parse::<u64>()
.map_err(|_| format!("invalid --http-timeout-secs: {v}"))?;
}
"--rsync-timeout-secs" => {
i += 1;
let v = argv.get(i).ok_or("--rsync-timeout-secs requires a value")?;
rsync_timeout_secs = v
.parse::<u64>()
.map_err(|_| format!("invalid --rsync-timeout-secs: {v}"))?;
}
"--rsync-mirror-root" => {
i += 1;
let v = argv.get(i).ok_or("--rsync-mirror-root requires a value")?;
rsync_mirror_root = Some(PathBuf::from(v));
}
"--max-depth" => {
i += 1;
let v = argv.get(i).ok_or("--max-depth requires a value")?;
max_depth = Some(
v.parse::<usize>()
.map_err(|_| format!("invalid --max-depth: {v}"))?,
);
}
"--max-instances" => {
i += 1;
let v = argv.get(i).ok_or("--max-instances requires a value")?;
max_instances = Some(
v.parse::<usize>()
.map_err(|_| format!("invalid --max-instances: {v}"))?,
);
}
"--validation-time" => {
i += 1;
let v = argv.get(i).ok_or("--validation-time requires a value")?;
use time::format_description::well_known::Rfc3339;
let t = time::OffsetDateTime::parse(v, &Rfc3339)
.map_err(|e| format!("invalid --validation-time (RFC3339 expected): {e}"))?;
validation_time = Some(t);
}
"--revalidate-only" => {
revalidate_only = true;
}
"--analyze" => {
analyze = true;
}
"--profile-cpu" => {
profile_cpu = true;
}
_ => return Err(format!("unknown argument: {arg}\n\n{}", usage())),
}
i += 1;
}
let db_path = db_path.ok_or_else(|| format!("--db is required\n\n{}", usage()))?;
let tal_mode_count = tal_url.is_some() as u8 + tal_path.is_some() as u8;
if tal_mode_count != 1 {
return Err(format!(
"must specify exactly one of --tal-url or --tal-path\n\n{}",
usage()
));
}
if tal_path.is_some() && ta_path.is_none() {
return Err(format!(
"--tal-path requires --ta-path (offline-friendly mode)\n\n{}",
usage()
));
}
Ok(CliArgs {
tal_url,
tal_path,
ta_path,
db_path,
policy_path,
report_json_path,
rsync_local_dir,
http_timeout_secs,
rsync_timeout_secs,
rsync_mirror_root,
max_depth,
max_instances,
validation_time,
revalidate_only,
analyze,
profile_cpu,
})
}
fn read_policy(path: Option<&Path>) -> Result<Policy, String> {
match path {
None => Ok(Policy::default()),
Some(p) => {
let s = std::fs::read_to_string(p)
.map_err(|e| format!("read policy file failed: {}: {e}", p.display()))?;
Policy::from_toml_str(&s).map_err(|e| e.to_string())
}
}
}
fn write_json(path: &Path, report: &AuditReportV2) -> Result<(), String> {
let f = std::fs::File::create(path)
.map_err(|e| format!("create report file failed: {}: {e}", path.display()))?;
serde_json::to_writer_pretty(f, report)
.map_err(|e| format!("write report json failed: {e}"))?;
Ok(())
}
fn unique_rrdp_repos(report: &AuditReportV2) -> usize {
use std::collections::HashSet;
let mut set: HashSet<&str> = HashSet::new();
for pp in &report.publication_points {
if let Some(u) = pp.rrdp_notification_uri.as_deref() {
set.insert(u);
}
}
set.len()
}
fn print_summary(report: &AuditReportV2) {
let rrdp_repos = unique_rrdp_repos(report);
println!("RPKI stage2 serial run summary");
println!(
"validation_time={}",
report.meta.validation_time_rfc3339_utc
);
println!(
"publication_points_processed={} publication_points_failed={}",
report.tree.instances_processed, report.tree.instances_failed
);
println!("rrdp_repos_unique={rrdp_repos}");
println!("vrps={}", report.vrps.len());
println!("aspas={}", report.aspas.len());
println!(
"audit_publication_points={}",
report.publication_points.len()
);
println!(
"warnings_total={}",
report.tree.warnings.len()
+ report
.publication_points
.iter()
.map(|pp| pp.warnings.len())
.sum::<usize>()
);
}
fn build_report(
policy: &Policy,
validation_time: time::OffsetDateTime,
out: RunTreeFromTalAuditOutput,
) -> AuditReportV2 {
use time::format_description::well_known::Rfc3339;
let validation_time_rfc3339_utc = validation_time
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.expect("format validation_time");
let vrps = out
.tree
.vrps
.iter()
.map(|v| VrpOutput {
asn: v.asn,
prefix: format_roa_ip_prefix(&v.prefix),
max_length: v.max_length,
})
.collect::<Vec<_>>();
let aspas = out
.tree
.aspas
.iter()
.map(|a| AspaOutput {
customer_as_id: a.customer_as_id,
provider_as_ids: a.provider_as_ids.clone(),
})
.collect::<Vec<_>>();
AuditReportV2 {
format_version: 2,
meta: AuditRunMeta {
validation_time_rfc3339_utc,
},
policy: policy.clone(),
tree: TreeSummary {
instances_processed: out.tree.instances_processed,
instances_failed: out.tree.instances_failed,
warnings: out.tree.warnings.iter().map(AuditWarning::from).collect(),
},
publication_points: out.publication_points,
vrps,
aspas,
downloads: out.downloads,
download_stats: out.download_stats,
}
}
pub fn run(argv: &[String]) -> Result<(), String> {
let args = parse_args(argv)?;
let policy = read_policy(args.policy_path.as_deref())?;
let validation_time = args
.validation_time
.unwrap_or_else(time::OffsetDateTime::now_utc);
let store = RocksStore::open(&args.db_path).map_err(|e| e.to_string())?;
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: std::time::Duration::from_secs(args.http_timeout_secs.max(1)),
..HttpFetcherConfig::default()
})
.map_err(|e| e.to_string())?;
let config = TreeRunConfig {
max_depth: args.max_depth,
max_instances: args.max_instances,
revalidate_only: args.revalidate_only,
};
use time::format_description::well_known::Rfc3339;
let mut timing: Option<(std::path::PathBuf, TimingHandle)> = None;
if args.analyze {
let recorded_at_utc_rfc3339 = time::OffsetDateTime::now_utc()
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.map_err(|e| format!("format recorded_at_utc failed: {e}"))?;
let validation_time_utc_rfc3339 = validation_time
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.map_err(|e| format!("format validation_time failed: {e}"))?;
let ts_compact = {
let fmt = time::format_description::parse("[year][month][day]T[hour][minute][second]Z")
.map_err(|e| format!("format description parse failed: {e}"))?;
time::OffsetDateTime::now_utc()
.format(&fmt)
.map_err(|e| format!("format timestamp failed: {e}"))?
};
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("live")
.join("analyze")
.join(ts_compact);
std::fs::create_dir_all(&out_dir)
.map_err(|e| format!("create analyze out dir failed: {}: {e}", out_dir.display()))?;
let handle = TimingHandle::new(TimingMeta {
recorded_at_utc_rfc3339,
validation_time_utc_rfc3339,
tal_url: None,
db_path: None,
});
handle.set_meta(TimingMetaUpdate {
tal_url: args.tal_url.as_deref(),
db_path: Some(args.db_path.to_string_lossy().as_ref()),
});
timing = Some((out_dir, handle));
}
if args.profile_cpu && !args.analyze {
return Err("--profile-cpu requires --analyze".to_string());
}
#[cfg(not(feature = "profile"))]
if args.profile_cpu {
return Err("CPU profiling requires building with: --features profile".to_string());
}
#[cfg(feature = "profile")]
let mut profiler_guard: Option<pprof::ProfilerGuard<'static>> = if args.profile_cpu {
Some(
pprof::ProfilerGuard::new(100)
.map_err(|e| format!("pprof ProfilerGuard init failed: {e}"))?,
)
} else {
None
};
let out = if let Some(dir) = args.rsync_local_dir.as_ref() {
let rsync = LocalDirRsyncFetcher::new(dir);
match (
args.tal_url.as_ref(),
args.tal_path.as_ref(),
args.ta_path.as_ref(),
) {
(Some(url), _, _) => {
if let Some((_, t)) = timing.as_ref() {
run_tree_from_tal_url_serial_audit_with_timing(
&store,
&policy,
url,
&http,
&rsync,
validation_time,
&config,
t,
)
.map_err(|e| e.to_string())?
} else {
run_tree_from_tal_url_serial_audit(
&store,
&policy,
url,
&http,
&rsync,
validation_time,
&config,
)
.map_err(|e| e.to_string())?
}
}
(None, Some(tal_path), Some(ta_path)) => {
let tal_bytes = std::fs::read(tal_path)
.map_err(|e| format!("read tal failed: {}: {e}", tal_path.display()))?;
let ta_der = std::fs::read(ta_path)
.map_err(|e| format!("read ta failed: {}: {e}", ta_path.display()))?;
if let Some((_, t)) = timing.as_ref() {
run_tree_from_tal_and_ta_der_serial_audit_with_timing(
&store,
&policy,
&tal_bytes,
&ta_der,
None,
&http,
&rsync,
validation_time,
&config,
t,
)
.map_err(|e| e.to_string())?
} else {
run_tree_from_tal_and_ta_der_serial_audit(
&store,
&policy,
&tal_bytes,
&ta_der,
None,
&http,
&rsync,
validation_time,
&config,
)
.map_err(|e| e.to_string())?
}
}
_ => unreachable!("validated by parse_args"),
}
} else {
let rsync = SystemRsyncFetcher::new(SystemRsyncConfig {
timeout: std::time::Duration::from_secs(args.rsync_timeout_secs.max(1)),
mirror_root: args.rsync_mirror_root.clone(),
..SystemRsyncConfig::default()
});
match (
args.tal_url.as_ref(),
args.tal_path.as_ref(),
args.ta_path.as_ref(),
) {
(Some(url), _, _) => {
if let Some((_, t)) = timing.as_ref() {
run_tree_from_tal_url_serial_audit_with_timing(
&store,
&policy,
url,
&http,
&rsync,
validation_time,
&config,
t,
)
.map_err(|e| e.to_string())?
} else {
run_tree_from_tal_url_serial_audit(
&store,
&policy,
url,
&http,
&rsync,
validation_time,
&config,
)
.map_err(|e| e.to_string())?
}
}
(None, Some(tal_path), Some(ta_path)) => {
let tal_bytes = std::fs::read(tal_path)
.map_err(|e| format!("read tal failed: {}: {e}", tal_path.display()))?;
let ta_der = std::fs::read(ta_path)
.map_err(|e| format!("read ta failed: {}: {e}", ta_path.display()))?;
if let Some((_, t)) = timing.as_ref() {
run_tree_from_tal_and_ta_der_serial_audit_with_timing(
&store,
&policy,
&tal_bytes,
&ta_der,
None,
&http,
&rsync,
validation_time,
&config,
t,
)
.map_err(|e| e.to_string())?
} else {
run_tree_from_tal_and_ta_der_serial_audit(
&store,
&policy,
&tal_bytes,
&ta_der,
None,
&http,
&rsync,
validation_time,
&config,
)
.map_err(|e| e.to_string())?
}
}
_ => unreachable!("validated by parse_args"),
}
};
if let Some((_out_dir, t)) = timing.as_ref() {
t.record_count("instances_processed", out.tree.instances_processed as u64);
t.record_count("instances_failed", out.tree.instances_failed as u64);
}
let report = build_report(&policy, validation_time, out);
if let Some(p) = args.report_json_path.as_deref() {
write_json(p, &report)?;
}
if let Some((out_dir, t)) = timing.as_ref() {
t.record_count("vrps", report.vrps.len() as u64);
t.record_count("aspas", report.aspas.len() as u64);
t.record_count(
"audit_publication_points",
report.publication_points.len() as u64,
);
let timing_json_path = out_dir.join("timing.json");
t.write_json(&timing_json_path, 20)?;
eprintln!("analysis: wrote {}", timing_json_path.display());
}
#[cfg(feature = "profile")]
if let (Some((out_dir, _)), Some(guard)) = (timing.as_ref(), profiler_guard.take()) {
let report = guard
.report()
.build()
.map_err(|e| format!("pprof report build failed: {e}"))?;
let svg_path = out_dir.join("flamegraph.svg");
let svg_file = std::fs::File::create(&svg_path)
.map_err(|e| format!("create flamegraph failed: {}: {e}", svg_path.display()))?;
report
.flamegraph(svg_file)
.map_err(|e| format!("write flamegraph failed: {e}"))?;
eprintln!("analysis: wrote {}", svg_path.display());
// Best-effort: write pprof protobuf as gzipped bytes.
let pb_path = out_dir.join("pprof.pb.gz");
let pprof_profile = report
.pprof()
.map_err(|e| format!("pprof export failed: {e}"))?;
use pprof::protos::Message;
let mut body = Vec::with_capacity(pprof_profile.encoded_len());
pprof_profile
.encode(&mut body)
.map_err(|e| format!("pprof encode failed: {e}"))?;
let gz = flate2::write::GzEncoder::new(
std::fs::File::create(&pb_path)
.map_err(|e| format!("create pprof.pb.gz failed: {}: {e}", pb_path.display()))?,
flate2::Compression::default(),
);
let mut gz = gz;
use std::io::Write;
gz.write_all(&body)
.map_err(|e| format!("write pprof.pb.gz failed: {e}"))?;
gz.finish()
.map_err(|e| format!("finish pprof.pb.gz failed: {e}"))?;
eprintln!("analysis: wrote {}", pb_path.display());
}
print_summary(&report);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_help_returns_usage() {
let argv = vec!["rpki".to_string(), "--help".to_string()];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("Usage:"), "{err}");
assert!(err.contains("--db"), "{err}");
assert!(err.contains("--rsync-mirror-root"), "{err}");
}
#[test]
fn parse_rejects_unknown_argument() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--nope".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("unknown argument"), "{err}");
}
#[test]
fn parse_rejects_both_tal_url_and_tal_path() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--tal-path".to_string(),
"x.tal".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(
err.contains("exactly one of --tal-url or --tal-path"),
"{err}"
);
}
#[test]
fn parse_rejects_invalid_max_depth() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--max-depth".to_string(),
"nope".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("invalid --max-depth"), "{err}");
}
#[test]
fn parse_rejects_invalid_validation_time() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--validation-time".to_string(),
"not-a-time".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("invalid --validation-time"), "{err}");
}
#[test]
fn parse_rejects_invalid_max_instances() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--max-instances".to_string(),
"nope".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("invalid --max-instances"), "{err}");
}
#[test]
fn parse_rejects_missing_value_for_db() {
let argv = vec!["rpki".to_string(), "--db".to_string()];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("--db requires a value"), "{err}");
}
#[test]
fn parse_rejects_missing_value_for_tal_url() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("--tal-url requires a value"), "{err}");
}
#[test]
fn parse_rejects_missing_db() {
let argv = vec!["rpki".to_string(), "--tal-url".to_string(), "x".to_string()];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("--db is required"), "{err}");
}
#[test]
fn parse_rejects_missing_tal_mode() {
let argv = vec!["rpki".to_string(), "--db".to_string(), "db".to_string()];
let err = parse_args(&argv).unwrap_err();
assert!(
err.contains("--tal-url") || err.contains("--tal-path"),
"{err}"
);
}
#[test]
fn parse_accepts_tal_url_mode() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(args.tal_url.as_deref(), Some("https://example.test/x.tal"));
assert!(args.tal_path.is_none());
assert!(args.ta_path.is_none());
}
#[test]
fn parse_accepts_offline_mode_requires_ta() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-path".to_string(),
"a.tal".to_string(),
"--ta-path".to_string(),
"ta.cer".to_string(),
"--max-depth".to_string(),
"0".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(args.tal_path.as_deref(), Some(Path::new("a.tal")));
assert_eq!(args.ta_path.as_deref(), Some(Path::new("ta.cer")));
assert_eq!(args.max_depth, Some(0));
}
#[test]
fn parse_accepts_validation_time_rfc3339() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--validation-time".to_string(),
"2026-01-01T00:00:00Z".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert!(args.validation_time.is_some());
}
#[test]
fn read_policy_accepts_valid_toml() {
let dir = tempfile::tempdir().expect("tmpdir");
let p = dir.path().join("policy.toml");
std::fs::write(
&p,
"signed_object_failure_policy = \"drop_publication_point\"\n",
)
.expect("write policy");
let policy = read_policy(Some(&p)).expect("parse policy");
assert_eq!(
policy.signed_object_failure_policy,
crate::policy::SignedObjectFailurePolicy::DropPublicationPoint
);
}
#[test]
fn read_policy_reports_missing_file() {
let dir = tempfile::tempdir().expect("tmpdir");
let p = dir.path().join("missing.toml");
let err = read_policy(Some(&p)).unwrap_err();
assert!(err.contains("read policy file failed"), "{err}");
}
#[test]
fn build_report_and_helpers_work_on_synthetic_output() {
let tal_bytes = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/tal/apnic-rfc7730-https.tal"),
)
.expect("read tal fixture");
let ta_der = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/ta/apnic-ta.cer"),
)
.expect("read ta fixture");
let discovery = crate::validation::from_tal::discover_root_ca_instance_from_tal_and_ta_der(
&tal_bytes, &ta_der, None,
)
.expect("discover root");
let tree = crate::validation::tree::TreeRunOutput {
instances_processed: 1,
instances_failed: 0,
warnings: vec![
crate::report::Warning::new("synthetic warning")
.with_rfc_refs(&[crate::report::RfcRef("RFC 6487 §4.8.8.1")])
.with_context("rsync://example.test/repo/pp/"),
],
vrps: vec![crate::validation::objects::Vrp {
asn: 64496,
prefix: crate::data_model::roa::IpPrefix {
afi: crate::data_model::roa::RoaAfi::Ipv4,
prefix_len: 24,
addr: [192, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
max_length: 24,
}],
aspas: vec![crate::validation::objects::AspaAttestation {
customer_as_id: 64496,
provider_as_ids: vec![64497, 64498],
}],
};
let mut pp1 = crate::audit::PublicationPointAudit::default();
pp1.rrdp_notification_uri = Some("https://example.test/n1.xml".to_string());
let mut pp2 = crate::audit::PublicationPointAudit::default();
pp2.rrdp_notification_uri = Some("https://example.test/n1.xml".to_string());
let mut pp3 = crate::audit::PublicationPointAudit::default();
pp3.rrdp_notification_uri = Some("https://example.test/n2.xml".to_string());
let out = crate::validation::run_tree_from_tal::RunTreeFromTalAuditOutput {
discovery,
tree,
publication_points: vec![pp1, pp2, pp3],
downloads: Vec::new(),
download_stats: crate::audit::AuditDownloadStats::default(),
};
let policy = Policy::default();
let validation_time = time::OffsetDateTime::now_utc();
let report = build_report(&policy, validation_time, out);
assert_eq!(unique_rrdp_repos(&report), 2);
assert_eq!(report.vrps.len(), 1);
assert_eq!(report.aspas.len(), 1);
print_summary(&report);
}
#[test]
fn write_json_writes_report() {
let report = AuditReportV2 {
format_version: 2,
meta: AuditRunMeta {
validation_time_rfc3339_utc: "2026-01-01T00:00:00Z".to_string(),
},
policy: Policy::default(),
tree: TreeSummary {
instances_processed: 0,
instances_failed: 0,
warnings: Vec::new(),
},
publication_points: Vec::new(),
vrps: Vec::new(),
aspas: Vec::new(),
downloads: Vec::new(),
download_stats: crate::audit::AuditDownloadStats::default(),
};
let dir = tempfile::tempdir().expect("tmpdir");
let p = dir.path().join("report.json");
write_json(&p, &report).expect("write json");
let s = std::fs::read_to_string(&p).expect("read report");
assert!(s.contains("\"format_version\""));
assert!(s.contains("\"policy\""));
}
}

View File

@@ -1,393 +0,0 @@
use crate::data_model::common::{DerReader, der_take_tlv};
use crate::data_model::oid::OID_CT_ASPA;
use crate::data_model::rc::ResourceCertificate;
use crate::data_model::signed_object::{
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectParseError, SignedObjectValidateError,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaObject {
pub signed_object: RpkiSignedObject,
pub econtent_type: String,
pub aspa: AspaEContent,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaObjectParsed {
pub signed_object: RpkiSignedObjectParsed,
pub econtent_type: String,
pub aspa: Option<AspaEContentParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaEContent {
pub version: u32,
pub customer_as_id: u32,
pub provider_as_ids: Vec<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaEContentParsed {
der: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum AspaParseError {
#[error("signed object parse error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectParseError),
#[error("ASPA parse error: {0} (draft-ietf-sidrops-aspa-profile-21 §3; DER)")]
Parse(String),
#[error("ASPA trailing bytes: {0} bytes (draft-ietf-sidrops-aspa-profile-21 §3; DER)")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum AspaProfileError {
#[error("signed object profile error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectValidateError),
#[error(
"ASPA eContentType must be {OID_CT_ASPA}, got {0} (draft-ietf-sidrops-aspa-profile-21 §2)"
)]
InvalidEContentType(String),
#[error("ASPA profile decode error: {0} (draft-ietf-sidrops-aspa-profile-21 §3; DER)")]
ProfileDecode(String),
#[error(
"ASProviderAttestation must be a SEQUENCE of 3 elements (draft-ietf-sidrops-aspa-profile-21 §3)"
)]
InvalidAttestationSequence,
#[error(
"ASPA version must be 1 and MUST be explicitly encoded (draft-ietf-sidrops-aspa-profile-21 §3.1)"
)]
VersionMustBeExplicitOne,
#[error(
"ASPA customerASID out of range (0..=4294967295), got {0} (draft-ietf-sidrops-aspa-profile-21 §3.2)"
)]
CustomerAsIdOutOfRange(u64),
#[error(
"ASPA providers must contain at least one ASID (draft-ietf-sidrops-aspa-profile-21 §3.3)"
)]
EmptyProviders,
#[error(
"ASPA provider ASID out of range (0..=4294967295), got {0} (draft-ietf-sidrops-aspa-profile-21 §3.3)"
)]
ProviderAsIdOutOfRange(u64),
#[error(
"ASPA providers must be in strictly increasing order (draft-ietf-sidrops-aspa-profile-21 §3.3)"
)]
ProvidersNotStrictlyIncreasing,
#[error(
"ASPA providers contains the customerASID ({0}) which is not allowed (draft-ietf-sidrops-aspa-profile-21 §3.3)"
)]
ProvidersContainCustomer(u32),
}
#[derive(Debug, thiserror::Error)]
pub enum AspaDecodeError {
#[error("{0}")]
Parse(#[from] AspaParseError),
#[error("{0}")]
Validate(#[from] AspaProfileError),
}
#[derive(Debug, thiserror::Error)]
pub enum AspaValidateError {
#[error(
"ASPA EE certificate must contain AS resources extension (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2)"
)]
EeAsResourcesMissing,
#[error(
"ASPA EE certificate AS resources must not use inherit (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2.3.3)"
)]
EeAsResourcesInherit,
#[error(
"ASPA EE certificate AS resources must not include ranges (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2.3.6-§3.2.3.7)"
)]
EeAsResourcesRangePresent,
#[error(
"ASPA EE certificate AS resources must not include RDI (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2.3.5; RFC 6487 §4.8.11)"
)]
EeAsResourcesRdiPresent,
#[error(
"ASPA EE certificate AS resources must contain exactly one ASID (id element) (draft-ietf-sidrops-aspa-profile-21 §4)"
)]
EeAsResourcesNotSingleId,
#[error(
"ASPA customerASID ({customer_as_id}) does not match EE AS resources ({ee_as_id}) (draft-ietf-sidrops-aspa-profile-21 §4)"
)]
CustomerAsIdMismatch { customer_as_id: u32, ee_as_id: u32 },
#[error(
"ASPA EE certificate must not contain IP resources extension (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §2.2)"
)]
EeIpResourcesPresent,
}
impl AspaObject {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<AspaObjectParsed, AspaParseError> {
let signed_object = RpkiSignedObject::parse_der(der)?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
let aspa = signed_object
.signed_data
.encap_content_info
.econtent
.as_deref()
.map(AspaEContent::parse_der)
.transpose()?;
Ok(AspaObjectParsed {
signed_object,
econtent_type,
aspa,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `AspaObject` is already profile-validated when constructed via `decode_der()` /
/// `AspaObjectParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), AspaProfileError> {
Ok(())
}
pub fn decode_der(der: &[u8]) -> Result<Self, AspaDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
pub fn from_signed_object(signed_object: RpkiSignedObject) -> Result<Self, AspaDecodeError> {
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_ASPA {
return Err(AspaProfileError::InvalidEContentType(econtent_type).into());
}
let aspa =
AspaEContent::decode_der(&signed_object.signed_data.encap_content_info.econtent)?;
Ok(Self {
aspa,
signed_object,
econtent_type: OID_CT_ASPA.to_string(),
})
}
/// Validate this ASPA's embedded EE certificate resources.
pub fn validate_embedded_ee_cert(&self) -> Result<(), AspaValidateError> {
let ee = &self.signed_object.signed_data.certificates[0].resource_cert;
self.aspa.validate_against_ee_cert(ee)
}
}
impl AspaEContent {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<AspaEContentParsed, AspaParseError> {
let (_tag, _value, rem) = der_take_tlv(der).map_err(AspaParseError::Parse)?;
if !rem.is_empty() {
return Err(AspaParseError::TrailingBytes(rem.len()));
}
Ok(AspaEContentParsed { der: der.to_vec() })
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `AspaEContent` is already profile-validated when constructed via `decode_der()` /
/// `AspaEContentParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), AspaProfileError> {
Ok(())
}
/// Decode the DER-encoded ASProviderAttestation defined in
/// draft-ietf-sidrops-aspa-profile-21 §3 (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, AspaDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
/// Validate ASPA payload against the embedded EE resource certificate.
///
/// This implements the EE/payload semantic checks described in
/// `draft-ietf-sidrops-aspa-profile-21` §4 (as summarized in `rpki/specs/08_aspa.md`).
pub fn validate_against_ee_cert(
&self,
ee: &ResourceCertificate,
) -> Result<(), AspaValidateError> {
if ee.tbs.extensions.ip_resources.is_some() {
return Err(AspaValidateError::EeIpResourcesPresent);
}
let asn = ee
.tbs
.extensions
.as_resources
.as_ref()
.ok_or(AspaValidateError::EeAsResourcesMissing)?;
if asn.rdi.is_some() {
return Err(AspaValidateError::EeAsResourcesRdiPresent);
}
if asn.is_asnum_inherit() {
return Err(AspaValidateError::EeAsResourcesInherit);
}
if asn.has_any_range() {
return Err(AspaValidateError::EeAsResourcesRangePresent);
}
let ee_as_id = asn
.asnum_single_id()
.ok_or(AspaValidateError::EeAsResourcesNotSingleId)?;
if ee_as_id != self.customer_as_id {
return Err(AspaValidateError::CustomerAsIdMismatch {
customer_as_id: self.customer_as_id,
ee_as_id,
});
}
Ok(())
}
}
impl AspaObjectParsed {
pub fn validate_profile(self) -> Result<AspaObject, AspaProfileError> {
let signed_object = self.signed_object.validate_profile()?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_ASPA {
return Err(AspaProfileError::InvalidEContentType(econtent_type));
}
let aspa = self
.aspa
.ok_or_else(|| AspaProfileError::ProfileDecode("ASPA.eContent missing".into()))?
.validate_profile()?;
Ok(AspaObject {
signed_object,
econtent_type: OID_CT_ASPA.to_string(),
aspa,
})
}
}
impl AspaEContentParsed {
pub fn validate_profile(self) -> Result<AspaEContent, AspaProfileError> {
fn count_elements(mut r: DerReader<'_>) -> Result<usize, String> {
let mut n = 0usize;
while !r.is_empty() {
r.skip_any()?;
n += 1;
}
Ok(n)
}
let mut r = DerReader::new(&self.der);
let mut seq = r
.take_sequence()
.map_err(|e| AspaProfileError::ProfileDecode(e))?;
if !r.is_empty() {
return Err(AspaProfileError::ProfileDecode(
"trailing bytes after ASProviderAttestation".into(),
));
}
let elem_count =
count_elements(seq).map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if elem_count != 3 {
return Err(AspaProfileError::InvalidAttestationSequence);
}
// version [0] EXPLICIT INTEGER MUST be present and MUST be 1.
if seq
.peek_tag()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?
!= 0xA0
{
return Err(AspaProfileError::VersionMustBeExplicitOne);
}
let (inner_tag, inner_val) = seq
.take_explicit(0xA0)
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if inner_tag != 0x02 {
return Err(AspaProfileError::VersionMustBeExplicitOne);
}
let v = crate::data_model::common::der_uint_from_bytes(inner_val)
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if v != 1 {
return Err(AspaProfileError::VersionMustBeExplicitOne);
}
let customer_u64 = seq
.take_uint_u64()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if customer_u64 > u32::MAX as u64 {
return Err(AspaProfileError::CustomerAsIdOutOfRange(customer_u64));
}
let customer_as_id = customer_u64 as u32;
let providers_seq = seq
.take_sequence()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if !seq.is_empty() {
return Err(AspaProfileError::InvalidAttestationSequence);
}
let providers = parse_providers_cursor(providers_seq, customer_as_id)?;
Ok(AspaEContent {
version: 1,
customer_as_id,
provider_as_ids: providers,
})
}
}
fn parse_providers_cursor(
mut seq: DerReader<'_>,
customer_as_id: u32,
) -> Result<Vec<u32>, AspaProfileError> {
if seq.is_empty() {
return Err(AspaProfileError::EmptyProviders);
}
let mut out: Vec<u32> = Vec::new();
let mut prev: Option<u32> = None;
while !seq.is_empty() {
let v = seq
.take_uint_u64()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if v > u32::MAX as u64 {
return Err(AspaProfileError::ProviderAsIdOutOfRange(v));
}
let asn = v as u32;
if asn == customer_as_id {
return Err(AspaProfileError::ProvidersContainCustomer(customer_as_id));
}
if let Some(p) = prev {
if asn <= p {
return Err(AspaProfileError::ProvidersNotStrictlyIncreasing);
}
}
prev = Some(asn);
out.push(asn);
}
Ok(out)
}

View File

@@ -1,302 +0,0 @@
use x509_parser::asn1_rs::Tag;
use x509_parser::prelude::FromDer;
use x509_parser::x509::AlgorithmIdentifier;
pub type UtcTime = time::OffsetDateTime;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Asn1TimeEncoding {
UtcTime,
GeneralizedTime,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Asn1TimeUtc {
pub utc: UtcTime,
pub encoding: Asn1TimeEncoding,
}
impl Asn1TimeUtc {
/// Validate Time encoding rules (RFC 5280): years 1950-2049 use UTCTime,
/// other years use GeneralizedTime.
pub fn validate_encoding_rfc5280(
&self,
field: &'static str,
) -> Result<(), InvalidTimeEncodingError> {
let year = self.utc.year();
let expected = if year <= 2049 {
Asn1TimeEncoding::UtcTime
} else {
Asn1TimeEncoding::GeneralizedTime
};
if self.encoding != expected {
return Err(InvalidTimeEncodingError {
field,
year,
encoding: self.encoding,
});
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BigUnsigned {
/// Minimal big-endian bytes. For zero, this is `[0]`.
pub bytes_be: Vec<u8>,
}
impl BigUnsigned {
pub fn from_biguint(n: &der_parser::num_bigint::BigUint) -> Self {
let mut bytes = n.to_bytes_be();
if bytes.is_empty() {
bytes.push(0);
}
Self { bytes_be: bytes }
}
pub fn to_hex_upper(&self) -> String {
hex::encode_upper(&self.bytes_be)
}
pub fn to_u64(&self) -> Option<u64> {
if self.bytes_be.len() > 8 {
return None;
}
let mut value: u64 = 0;
for &b in &self.bytes_be {
value = (value << 8) | (b as u64);
}
Some(value)
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error(
"{field} time encoding invalid for year {year}: got {encoding:?} (RFC 5280 §4.1.2.5; RFC 5280 §5.1.2.4-§5.1.2.6)"
)]
pub struct InvalidTimeEncodingError {
pub field: &'static str,
pub year: i32,
pub encoding: Asn1TimeEncoding,
}
pub fn asn1_time_to_model(t: x509_parser::time::ASN1Time) -> Asn1TimeUtc {
let encoding = if t.is_utctime() {
Asn1TimeEncoding::UtcTime
} else {
Asn1TimeEncoding::GeneralizedTime
};
Asn1TimeUtc {
utc: t.to_datetime(),
encoding,
}
}
pub fn algorithm_params_absent_or_null(sig: &AlgorithmIdentifier<'_>) -> bool {
match sig.parameters.as_ref() {
None => true,
Some(p) if p.tag() == Tag::Null => true,
Some(_p) => false,
}
}
/// Take a single DER TLV (Tag-Length-Value) from the start of `input`.
///
/// This helper supports:
/// - short- and long-form lengths (up to 8 length bytes)
/// - only low-tag-number form tags (no high-tag-number form)
/// - definite length only (DER forbids indefinite length)
///
/// Returns: `(tag_byte, value_bytes, remaining_bytes)`.
pub(crate) fn der_take_tlv(input: &[u8]) -> Result<(u8, &[u8], &[u8]), String> {
if input.len() < 2 {
return Err("truncated DER (need tag+len)".into());
}
let tag = input[0];
if (tag & 0x1F) == 0x1F {
return Err("high-tag-number form not supported".into());
}
let len0 = input[1];
if len0 == 0x80 {
return Err("indefinite length not allowed in DER".into());
}
let (len, hdr_len) = if len0 & 0x80 == 0 {
(len0 as usize, 2usize)
} else {
let n = (len0 & 0x7F) as usize;
if n == 0 || n > 8 {
return Err("invalid DER length".into());
}
if input.len() < 2 + n {
return Err("truncated DER (length bytes)".into());
}
let mut l: usize = 0;
for &b in &input[2..2 + n] {
l = (l << 8) | (b as usize);
}
(l, 2 + n)
};
if input.len() < hdr_len + len {
return Err("truncated DER (value bytes)".into());
}
let value = &input[hdr_len..hdr_len + len];
let rem = &input[hdr_len + len..];
Ok((tag, value, rem))
}
/// Minimal streaming DER reader built on `der_take_tlv`.
///
/// This is intentionally small and only supports the subset of DER needed by
/// RPKI object eContent decoders (ROA/ASPA), to avoid constructing a generic AST
/// (which is expensive on large objects such as ROAs with thousands of prefixes).
#[derive(Clone, Copy)]
pub(crate) struct DerReader<'a> {
buf: &'a [u8],
}
impl<'a> DerReader<'a> {
pub(crate) fn new(buf: &'a [u8]) -> Self {
Self { buf }
}
pub(crate) fn is_empty(&self) -> bool {
self.buf.is_empty()
}
pub(crate) fn remaining_len(&self) -> usize {
self.buf.len()
}
pub(crate) fn peek_tag(&self) -> Result<u8, String> {
self.buf
.first()
.copied()
.ok_or_else(|| "truncated DER".into())
}
pub(crate) fn take_any(&mut self) -> Result<(u8, &'a [u8]), String> {
let (tag, value, rem) = der_take_tlv(self.buf)?;
self.buf = rem;
Ok((tag, value))
}
pub(crate) fn take_any_full(&mut self) -> Result<(u8, &'a [u8], &'a [u8]), String> {
let (tag, value, rem) = der_take_tlv(self.buf)?;
let consumed = self.buf.len() - rem.len();
let full = &self.buf[..consumed];
self.buf = rem;
Ok((tag, full, value))
}
pub(crate) fn skip_any(&mut self) -> Result<(), String> {
let _ = self.take_any()?;
Ok(())
}
pub(crate) fn take_tag(&mut self, expected_tag: u8) -> Result<&'a [u8], String> {
let (tag, value) = self.take_any()?;
if tag != expected_tag {
return Err(format!(
"unexpected tag: got 0x{tag:02X}, expected 0x{expected_tag:02X}"
));
}
Ok(value)
}
pub(crate) fn take_sequence(&mut self) -> Result<DerReader<'a>, String> {
let value = self.take_tag(0x30)?;
Ok(DerReader::new(value))
}
pub(crate) fn take_octet_string(&mut self) -> Result<&'a [u8], String> {
self.take_tag(0x04)
}
pub(crate) fn take_bit_string(&mut self) -> Result<(u8, &'a [u8]), String> {
let v = self.take_tag(0x03)?;
if v.is_empty() {
return Err("BIT STRING content is empty".into());
}
Ok((v[0], &v[1..]))
}
pub(crate) fn take_uint_u64(&mut self) -> Result<u64, String> {
let v = self.take_tag(0x02)?;
der_uint_from_bytes(v)
}
pub(crate) fn take_explicit(
&mut self,
expected_outer_tag: u8,
) -> Result<(u8, &'a [u8]), String> {
let inner_der = self.take_tag(expected_outer_tag)?;
let (tag, value, rem) = der_take_tlv(inner_der)?;
if !rem.is_empty() {
return Err("trailing bytes inside EXPLICIT value".into());
}
Ok((tag, value))
}
pub(crate) fn take_explicit_der(&mut self, expected_outer_tag: u8) -> Result<&'a [u8], String> {
let inner_der = self.take_tag(expected_outer_tag)?;
let (_tag, _value, rem) = der_take_tlv(inner_der)?;
if !rem.is_empty() {
return Err("trailing bytes inside EXPLICIT value".into());
}
Ok(inner_der)
}
}
pub(crate) fn der_uint_from_bytes(bytes: &[u8]) -> Result<u64, String> {
if bytes.is_empty() {
return Err("INTEGER has empty content".into());
}
// Disallow negative values.
if (bytes[0] & 0x80) != 0 {
return Err("INTEGER is negative".into());
}
// DER requires minimal encoding for INTEGER.
if bytes.len() > 1 && bytes[0] == 0x00 && (bytes[1] & 0x80) == 0 {
return Err("INTEGER not minimally encoded".into());
}
if bytes.len() > 8 {
return Err("INTEGER does not fit u64".into());
}
let mut v: u64 = 0;
for &b in bytes {
v = (v << 8) | (b as u64);
}
Ok(v)
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct X509NameDer(pub Vec<u8>);
impl X509NameDer {
pub fn as_raw(&self) -> &[u8] {
&self.0
}
}
impl std::fmt::Display for X509NameDer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Ok((rem, name)) = x509_parser::x509::X509Name::from_der(&self.0) else {
return write!(f, "<invalid X.509 Name DER>");
};
if !rem.is_empty() {
return write!(f, "<invalid X.509 Name DER (trailing bytes)>");
}
write!(f, "{name}")
}
}
/// Filename extensions registered in IANA "RPKI Repository Name Schemes".
///
/// Source: <https://www.iana.org/assignments/rpki/rpki.xhtml>
/// Snapshot date: 2026-01-28.
///
/// Notes:
/// - Includes entries marked TEMPORARY/DEPRECATED by IANA (e.g., `asa`, `gbr`).
pub const IANA_RPKI_REPOSITORY_FILENAME_EXTENSIONS: &[&str] =
&["asa", "cer", "crl", "gbr", "mft", "roa", "sig", "tak"];

View File

@@ -1,15 +1,51 @@
pub use crate::data_model::common::{Asn1TimeEncoding, Asn1TimeUtc, BigUnsigned};
use crate::data_model::oid::{
OID_AUTHORITY_KEY_IDENTIFIER, OID_AUTHORITY_KEY_IDENTIFIER_RAW, OID_CRL_NUMBER,
OID_CRL_NUMBER_RAW, OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW,
OID_SUBJECT_KEY_IDENTIFIER_RAW,
};
use x509_parser::certificate::X509Certificate;
use x509_parser::extensions::{ParsedExtension, X509Extension};
use x509_parser::prelude::{FromDer, X509Version};
use x509_parser::extensions::{AuthorityKeyIdentifier, ParsedExtension, X509Extension};
use x509_parser::prelude::FromDer;
use x509_parser::prelude::X509Version;
use x509_parser::revocation_list::CertificateRevocationList;
use x509_parser::x509::{AlgorithmIdentifier, SubjectPublicKeyInfo};
use x509_parser::{asn1_rs::Class as Asn1Class, asn1_rs::Tag as Asn1Tag};
use x509_parser::asn1_rs::Tag;
use x509_parser::certificate::X509Certificate;
use x509_parser::x509::SubjectPublicKeyInfo;
use x509_parser::x509::AlgorithmIdentifier;
const OID_SHA256_WITH_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.11";
const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35";
const OID_CRL_NUMBER: &str = "2.5.29.20";
const OID_SUBJECT_KEY_IDENTIFIER: &str = "2.5.29.14";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Asn1TimeEncoding {
UtcTime,
GeneralizedTime,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Asn1TimeUtc {
pub utc: time::OffsetDateTime,
pub encoding: Asn1TimeEncoding,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BigUnsigned {
/// Minimal big-endian bytes. For zero, this is `[0]`.
pub bytes_be: Vec<u8>,
}
impl BigUnsigned {
pub fn to_hex_upper(&self) -> String {
hex::encode_upper(&self.bytes_be)
}
pub fn to_u64(&self) -> Option<u64> {
if self.bytes_be.len() > 8 {
return None;
}
let mut value: u64 = 0;
for &b in &self.bytes_be {
value = (value << 8) | (b as u64);
}
Some(value)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RevokedCert {
@@ -35,131 +71,54 @@ pub struct RpkixCrl {
pub extensions: CrlExtensions,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RpkixCrlParsed {
pub raw_der: Vec<u8>,
pub version: Option<X509Version>,
pub issuer_dn: String,
pub signature_algorithm: AlgorithmIdentifierValue,
pub tbs_signature_algorithm: AlgorithmIdentifierValue,
pub this_update: Asn1TimeUtc,
pub next_update: Option<Asn1TimeUtc>,
pub revoked_certs: Vec<RevokedCertParsed>,
pub extensions: Vec<CrlExtensionParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RevokedCertParsed {
pub serial_number: BigUnsigned,
pub revocation_date: Asn1TimeUtc,
pub has_extensions: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CrlExtensionParsed {
AuthorityKeyIdentifier {
key_identifier: Option<Vec<u8>>,
has_other_fields: bool,
critical: bool,
},
CrlNumber {
number: der_parser::num_bigint::BigUint,
critical: bool,
},
Other {
oid: String,
critical: bool,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlgorithmIdentifierValue {
pub oid: String,
pub parameters: Option<AlgorithmParametersValue>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlgorithmParametersValue {
pub class: Asn1Class,
pub tag: Asn1Tag,
pub data: Vec<u8>,
}
impl AlgorithmIdentifierValue {
pub fn params_absent_or_null(&self) -> bool {
match &self.parameters {
None => true,
Some(p) if p.class == Asn1Class::Universal && p.tag == Asn1Tag::Null => true,
Some(_p) => false,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum CrlParseError {
#[error("X.509 CRL parse error: {0} (RFC 5280 §5.1; RFC 6487 §5)")]
pub enum CrlDecodeError {
#[error("X.509 CRL parse error: {0}")]
Parse(String),
#[error("trailing bytes after CRL DER: {0} bytes (DER; RFC 5280 §5.1)")]
#[error("trailing bytes after CRL DER: {0} bytes")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum CrlProfileError {
#[error("CRL version must be v2, got {0:?} (RFC 5280 §5.1; RFC 6487 §5)")]
#[error("CRL version must be v2, got {0:?}")]
InvalidVersion(Option<u32>),
#[error("CRL signatureAlgorithm must match TBSCertList.signature (RFC 5280 §5.1)")]
SignatureAlgorithmMismatch,
#[error(
"CRL signatureAlgorithm must be sha256WithRSAEncryption ({OID_SHA256_WITH_RSA_ENCRYPTION}), got {0} (RFC 6487 §5; RFC 7935 §2)"
)]
#[error("CRL signatureAlgorithm must be sha256WithRSAEncryption ({OID_SHA256_WITH_RSA_ENCRYPTION}), got {0}")]
InvalidSignatureAlgorithm(String),
#[error(
"CRL signature algorithm parameters must be absent or NULL (RFC 5280 §4.1.1.2; RFC 7935 §2)"
)]
#[error("CRL signature algorithm parameters must be absent or NULL")]
InvalidSignatureAlgorithmParameters,
#[error("CRL extensions must be exactly two (AKI + CRLNumber), got {0} (RFC 9829 §3.1)")]
#[error("CRL signatureAlgorithm must match TBSCertList.signature")]
SignatureAlgorithmMismatch,
#[error("CRL extensions must be exactly two (AKI + CRLNumber), got {0}")]
InvalidExtensionsCount(usize),
#[error("unsupported CRL extension OID {0} (RFC 9829 §3.1)")]
#[error("unsupported CRL extension OID {0}")]
UnsupportedExtension(String),
#[error("duplicate CRL extension OID {0} (RFC 5280 §4.2; RFC 9829 §3.1)")]
#[error("duplicate CRL extension OID {0}")]
DuplicateExtension(String),
#[error("AuthorityKeyIdentifier CRL extension missing (RFC 9829 §3.1; RFC 5280 §5.2.1)")]
AkiMissing,
#[error("AuthorityKeyIdentifier must contain keyIdentifier (RFC 5280 §5.2.1; RFC 9829 §3.1)")]
#[error("AuthorityKeyIdentifier must contain keyIdentifier")]
AkiMissingKeyIdentifier,
#[error(
"AuthorityKeyIdentifier must not contain authorityCertIssuer or authorityCertSerialNumber (RFC 5280 §5.2.1; RFC 9829 §3.1)"
)]
#[error("AuthorityKeyIdentifier must not contain authorityCertIssuer or authorityCertSerialNumber")]
AkiHasOtherFields,
#[error("CRLNumber CRL extension missing (RFC 9829 §3.1; RFC 5280 §5.2.3)")]
CrlNumberMissing,
#[error("CRLNumber must be non-critical (RFC 9829 §3.1; RFC 5280 §5.2.3)")]
#[error("CRLNumber must be non-critical")]
CrlNumberCritical,
#[error("CRLNumber out of range (must fit in 0..2^159-1) (RFC 9829 §3.1)")]
#[error("CRLNumber out of range (must fit in 0..2^159-1)")]
CrlNumberOutOfRange,
#[error("CRL entry extensions must not be present (RFC 6487 §5; RFC 5280 §5.1)")]
#[error("CRL entry extensions must not be present")]
EntryExtensionsNotAllowed,
#[error("CRL nextUpdate must be present (RFC 5280 §5.1.2.5; RFC 6487 §5)")]
#[error("CRL nextUpdate must be present (RFC 5280 §5.1.2.5)")]
NextUpdateMissing,
#[error(
"{field} time encoding invalid for year {year}: got {encoding:?} (RFC 5280 §5.1.2.4-§5.1.2.6)"
)]
#[error("{field} time encoding invalid for year {year}: got {encoding:?}")]
InvalidTimeEncoding {
field: &'static str,
year: i32,
@@ -167,47 +126,64 @@ pub enum CrlProfileError {
},
}
#[derive(Debug, thiserror::Error)]
pub enum CrlDecodeError {
#[error("{0}")]
Parse(#[from] CrlParseError),
#[error("{0}")]
Validate(#[from] CrlProfileError),
}
impl RpkixCrl {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<RpkixCrlParsed, CrlParseError> {
/// Decode a DER-encoded X.509 v2 CRL and enforce the RPKI profile constraints from
/// `specs/prepare/data_models/04_crl.md` (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1).
pub fn decode_der(der: &[u8]) -> Result<Self, CrlDecodeError> {
let (rem, crl) = CertificateRevocationList::from_der(der)
.map_err(|e| CrlParseError::Parse(e.to_string()))?;
.map_err(|e| CrlDecodeError::Parse(e.to_string()))?;
if !rem.is_empty() {
return Err(CrlParseError::TrailingBytes(rem.len()));
return Err(CrlDecodeError::TrailingBytes(rem.len()));
}
let version = match crl.version() {
Some(X509Version::V2) => 2,
Some(v) => return Err(CrlDecodeError::InvalidVersion(Some(v.0))),
None => return Err(CrlDecodeError::InvalidVersion(None)),
};
let sig_oid = crl.signature_algorithm.algorithm.to_id_string();
let tbs_sig_oid = crl.tbs_cert_list.signature.algorithm.to_id_string();
if sig_oid != tbs_sig_oid {
return Err(CrlDecodeError::SignatureAlgorithmMismatch);
}
if sig_oid != OID_SHA256_WITH_RSA_ENCRYPTION {
return Err(CrlDecodeError::InvalidSignatureAlgorithm(sig_oid));
}
validate_sig_params(&crl.signature_algorithm)?;
validate_sig_params(&crl.tbs_cert_list.signature)?;
let extensions = parse_and_validate_extensions(crl.extensions())?;
let revoked_certs = crl
.iter_revoked_certificates()
.map(|rc| RevokedCertParsed {
serial_number: BigUnsigned::from_biguint(rc.serial()),
revocation_date: crate::data_model::common::asn1_time_to_model(rc.revocation_date),
has_extensions: !rc.extensions().is_empty(),
.map(|rc| {
if !rc.extensions().is_empty() {
return Err(CrlDecodeError::EntryExtensionsNotAllowed);
}
let revocation_date = asn1_time_to_model(rc.revocation_date);
validate_time_encoding("revocationDate", &revocation_date)?;
Ok(RevokedCert {
serial_number: biguint_to_big_unsigned(rc.serial()),
revocation_date,
})
.collect::<Vec<_>>();
})
.collect::<Result<Vec<_>, _>>()?;
let this_update = asn1_time_to_model(crl.last_update());
validate_time_encoding("thisUpdate", &this_update)?;
let this_update = crate::data_model::common::asn1_time_to_model(crl.last_update());
let next_update = crl
.next_update()
.map(crate::data_model::common::asn1_time_to_model);
.map(asn1_time_to_model)
.ok_or(CrlDecodeError::NextUpdateMissing)?;
validate_time_encoding("nextUpdate", &next_update)?;
let extensions =
parse_extensions_parse(crl.extensions()).map_err(|e| CrlParseError::Parse(e))?;
Ok(RpkixCrlParsed {
Ok(RpkixCrl {
raw_der: der.to_vec(),
version: crl.version(),
version,
issuer_dn: crl.issuer().to_string(),
signature_algorithm: algorithm_identifier_value(&crl.signature_algorithm),
tbs_signature_algorithm: algorithm_identifier_value(&crl.tbs_cert_list.signature),
signature_algorithm_oid: OID_SHA256_WITH_RSA_ENCRYPTION.to_string(),
this_update,
next_update,
revoked_certs,
@@ -215,20 +191,6 @@ impl RpkixCrl {
})
}
/// Decode a DER-encoded X.509 v2 CRL and enforce the RPKI profile constraints from
/// `specs/prepare/data_models/04_crl.md` (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1).
pub fn decode_der(der: &[u8]) -> Result<Self, CrlDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `RpkixCrl` is already profile-validated when constructed via `decode_der()` /
/// `RpkixCrlParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), CrlProfileError> {
Ok(())
}
/// Verify the cryptographic signature on this CRL using the issuer certificate.
///
/// Signature verification needs the issuer public key (RFC 5280 §6.3.3 (f)-(g)).
@@ -303,107 +265,55 @@ impl RpkixCrl {
}
}
impl RpkixCrlParsed {
/// Profile validate step of scheme A (`parse → validate → verify`).
pub fn validate_profile(self) -> Result<RpkixCrl, CrlProfileError> {
let version = match self.version {
Some(X509Version::V2) => 2,
Some(v) => return Err(CrlProfileError::InvalidVersion(Some(v.0))),
None => return Err(CrlProfileError::InvalidVersion(None)),
};
// signatureAlgorithm must match tbsCertList.signature
if self.signature_algorithm != self.tbs_signature_algorithm {
return Err(CrlProfileError::SignatureAlgorithmMismatch);
}
let sig_oid = self.signature_algorithm.oid.clone();
if sig_oid != OID_SHA256_WITH_RSA_ENCRYPTION {
return Err(CrlProfileError::InvalidSignatureAlgorithm(sig_oid));
}
if !self.signature_algorithm.params_absent_or_null() {
return Err(CrlProfileError::InvalidSignatureAlgorithmParameters);
}
let extensions = validate_extensions_profile(&self.extensions)?;
let mut revoked_out = Vec::with_capacity(self.revoked_certs.len());
for rc in self.revoked_certs {
if rc.has_extensions {
return Err(CrlProfileError::EntryExtensionsNotAllowed);
}
validate_time_encoding_rfc5280("revocationDate", &rc.revocation_date)?;
revoked_out.push(RevokedCert {
serial_number: rc.serial_number,
revocation_date: rc.revocation_date,
});
}
validate_time_encoding_rfc5280("thisUpdate", &self.this_update)?;
let next_update = self.next_update.ok_or(CrlProfileError::NextUpdateMissing)?;
validate_time_encoding_rfc5280("nextUpdate", &next_update)?;
Ok(RpkixCrl {
raw_der: self.raw_der,
version,
issuer_dn: self.issuer_dn,
signature_algorithm_oid: OID_SHA256_WITH_RSA_ENCRYPTION.to_string(),
this_update: self.this_update,
next_update,
revoked_certs: revoked_out,
extensions,
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum CrlVerifyError {
#[error("issuer certificate parse error: {0} (RFC 5280 §4.1; RFC 6487 §4)")]
#[error("issuer certificate parse error: {0}")]
IssuerCertificateParse(String),
#[error("trailing bytes after issuer certificate DER: {0} bytes (DER; RFC 5280 §4.1)")]
#[error("trailing bytes after issuer certificate DER: {0} bytes")]
IssuerCertificateTrailingBytes(usize),
#[error("issuer SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7)")]
#[error("issuer SubjectPublicKeyInfo parse error: {0}")]
IssuerSpkiParse(String),
#[error(
"trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 5280 §4.1.2.7)"
)]
#[error("trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes")]
IssuerSpkiTrailingBytes(usize),
#[error("CRL parse error: {0} (RFC 5280 §5.1; RFC 6487 §5)")]
#[error("CRL parse error: {0}")]
CrlParse(String),
#[error("trailing bytes after CRL DER: {0} bytes (DER; RFC 5280 §5.1)")]
#[error("trailing bytes after CRL DER: {0} bytes")]
CrlTrailingBytes(usize),
#[error(
"CRL issuer DN does not match issuer certificate subject (RFC 5280 §5.1; RFC 5280 §6.3.3(b))"
)]
#[error("CRL issuer DN does not match issuer certificate subject")]
IssuerSubjectMismatch {
crl_issuer_dn: String,
issuer_subject_dn: String,
},
#[error(
"issuer certificate keyUsage present but missing cRLSign (RFC 5280 §4.2.1.3; RFC 5280 §6.3.3(f))"
)]
#[error("issuer certificate keyUsage present but missing cRLSign")]
IssuerKeyUsageMissingCrlSign,
#[error(
"CRL AKI.keyIdentifier does not match issuer certificate SKI (RFC 5280 §4.2.1.1; RFC 5280 §4.2.1.2; RFC 5280 §6.3.3(c)/(f))"
)]
#[error("CRL AKI.keyIdentifier does not match issuer certificate SKI")]
AkiSkiMismatch,
#[error("CRL signature verification failed: {0} (RFC 5280 §6.3.3(g); RFC 7935 §2)")]
#[error("CRL signature verification failed: {0}")]
InvalidSignature(String),
}
fn validate_time_encoding_rfc5280(
field: &'static str,
t: &Asn1TimeUtc,
) -> Result<(), CrlProfileError> {
fn asn1_time_to_model(t: x509_parser::time::ASN1Time) -> Asn1TimeUtc {
let encoding = if t.is_utctime() {
Asn1TimeEncoding::UtcTime
} else {
Asn1TimeEncoding::GeneralizedTime
};
Asn1TimeUtc {
utc: t.to_datetime(),
encoding,
}
}
fn validate_time_encoding(field: &'static str, t: &Asn1TimeUtc) -> Result<(), CrlDecodeError> {
let year = t.utc.year();
let expected = if year <= 2049 {
Asn1TimeEncoding::UtcTime
@@ -411,7 +321,7 @@ fn validate_time_encoding_rfc5280(
Asn1TimeEncoding::GeneralizedTime
};
if t.encoding != expected {
return Err(CrlProfileError::InvalidTimeEncoding {
return Err(CrlDecodeError::InvalidTimeEncoding {
field,
year,
encoding: t.encoding,
@@ -420,117 +330,98 @@ fn validate_time_encoding_rfc5280(
Ok(())
}
fn algorithm_identifier_value(ai: &AlgorithmIdentifier<'_>) -> AlgorithmIdentifierValue {
let parameters = ai.parameters.as_ref().map(|p| AlgorithmParametersValue {
class: p.class(),
tag: p.tag(),
data: p.as_bytes().to_vec(),
});
// NOTE(perf): Avoid `to_id_string()` allocations for the signature algorithms we expect
// in RPKI CRLs. Fall back to `to_id_string()` for unexpected algorithms (mostly error paths).
let oid = if ai.algorithm.as_bytes() == OID_SHA256_WITH_RSA_ENCRYPTION_RAW {
OID_SHA256_WITH_RSA_ENCRYPTION.to_string()
} else {
ai.algorithm.to_id_string()
};
AlgorithmIdentifierValue { oid, parameters }
fn validate_sig_params(sig: &AlgorithmIdentifier<'_>) -> Result<(), CrlDecodeError> {
match sig.parameters.as_ref() {
None => Ok(()),
Some(p) if p.tag() == Tag::Null => Ok(()),
Some(_p) => Err(CrlDecodeError::InvalidSignatureAlgorithmParameters),
}
}
fn parse_extensions_parse(exts: &[X509Extension<'_>]) -> Result<Vec<CrlExtensionParsed>, String> {
let mut out = Vec::with_capacity(exts.len());
for ext in exts {
let oid = ext.oid.as_bytes();
if oid == OID_AUTHORITY_KEY_IDENTIFIER_RAW {
let ParsedExtension::AuthorityKeyIdentifier(aki) = ext.parsed_extension() else {
return Err("AKI extension parse failed".to_string());
};
out.push(CrlExtensionParsed::AuthorityKeyIdentifier {
key_identifier: aki.key_identifier.as_ref().map(|k| k.0.to_vec()),
has_other_fields: aki.authority_cert_issuer.is_some()
|| aki.authority_cert_serial.is_some(),
critical: ext.critical,
});
} else if oid == OID_CRL_NUMBER_RAW {
match ext.parsed_extension() {
ParsedExtension::CRLNumber(n) => out.push(CrlExtensionParsed::CrlNumber {
number: n.clone(),
critical: ext.critical,
}),
_ => return Err("CRLNumber extension parse failed".to_string()),
}
} else {
out.push(CrlExtensionParsed::Other {
oid: ext.oid.to_id_string(),
critical: ext.critical,
})
}
}
Ok(out)
}
fn validate_extensions_profile(
exts: &[CrlExtensionParsed],
) -> Result<CrlExtensions, CrlProfileError> {
fn parse_and_validate_extensions(exts: &[X509Extension<'_>]) -> Result<CrlExtensions, CrlDecodeError> {
if exts.len() != 2 {
return Err(CrlProfileError::InvalidExtensionsCount(exts.len()));
return Err(CrlDecodeError::InvalidExtensionsCount(exts.len()));
}
let mut seen: Vec<String> = Vec::new();
let mut authority_key_identifier: Option<Vec<u8>> = None;
let mut crl_number: Option<BigUnsigned> = None;
for ext in exts {
match ext {
CrlExtensionParsed::AuthorityKeyIdentifier {
key_identifier,
has_other_fields,
critical: _,
} => {
let oid = OID_AUTHORITY_KEY_IDENTIFIER.to_string();
if seen.iter().any(|s| s == &oid) {
return Err(CrlProfileError::DuplicateExtension(oid));
let oid = ext.oid.to_id_string();
match oid.as_str() {
OID_AUTHORITY_KEY_IDENTIFIER => {
if authority_key_identifier.is_some() {
return Err(CrlDecodeError::DuplicateExtension(oid));
}
seen.push(oid.clone());
if *has_other_fields {
return Err(CrlProfileError::AkiHasOtherFields);
let aki = parse_aki(ext)?;
authority_key_identifier = Some(aki);
}
let ki = key_identifier
.as_ref()
.ok_or(CrlProfileError::AkiMissingKeyIdentifier)?;
authority_key_identifier = Some(ki.clone());
OID_CRL_NUMBER => {
if crl_number.is_some() {
return Err(CrlDecodeError::DuplicateExtension(oid));
}
CrlExtensionParsed::CrlNumber { number, critical } => {
let oid = OID_CRL_NUMBER.to_string();
if seen.iter().any(|s| s == &oid) {
return Err(CrlProfileError::DuplicateExtension(oid));
if ext.critical {
return Err(CrlDecodeError::CrlNumberCritical);
}
seen.push(oid.clone());
if *critical {
return Err(CrlProfileError::CrlNumberCritical);
let n = parse_crl_number(ext)?;
if n.bits() > 159 {
return Err(CrlDecodeError::CrlNumberOutOfRange);
}
if number.bits() > 159 {
return Err(CrlProfileError::CrlNumberOutOfRange);
}
crl_number = Some(BigUnsigned::from_biguint(number));
}
CrlExtensionParsed::Other { oid, .. } => {
return Err(CrlProfileError::UnsupportedExtension(oid.clone()));
crl_number = Some(biguint_to_big_unsigned(&n));
}
_ => return Err(CrlDecodeError::UnsupportedExtension(oid)),
}
}
Ok(CrlExtensions {
authority_key_identifier: authority_key_identifier.ok_or(CrlProfileError::AkiMissing)?,
crl_number: crl_number.ok_or(CrlProfileError::CrlNumberMissing)?,
authority_key_identifier: authority_key_identifier.unwrap(),
crl_number: crl_number.unwrap(),
})
}
fn parse_aki(ext: &X509Extension<'_>) -> Result<Vec<u8>, CrlDecodeError> {
let ParsedExtension::AuthorityKeyIdentifier(aki) = ext.parsed_extension() else {
return Err(CrlDecodeError::Parse("AKI extension parse failed".into()));
};
validate_aki_profile(aki)?;
Ok(aki
.key_identifier
.as_ref()
.ok_or(CrlDecodeError::AkiMissingKeyIdentifier)?
.0
.to_vec())
}
fn validate_aki_profile(aki: &AuthorityKeyIdentifier<'_>) -> Result<(), CrlDecodeError> {
if aki.key_identifier.is_none() {
return Err(CrlDecodeError::AkiMissingKeyIdentifier);
}
if aki.authority_cert_issuer.is_some() || aki.authority_cert_serial.is_some() {
return Err(CrlDecodeError::AkiHasOtherFields);
}
Ok(())
}
fn parse_crl_number(ext: &X509Extension<'_>) -> Result<der_parser::num_bigint::BigUint, CrlDecodeError> {
match ext.parsed_extension() {
ParsedExtension::CRLNumber(n) => Ok(n.clone()),
ParsedExtension::ParseError { error } => Err(CrlDecodeError::Parse(error.to_string())),
_ => Err(CrlDecodeError::Parse("CRLNumber extension parse failed".into())),
}
}
fn biguint_to_big_unsigned(n: &der_parser::num_bigint::BigUint) -> BigUnsigned {
let mut bytes = n.to_bytes_be();
if bytes.is_empty() {
bytes.push(0);
}
BigUnsigned { bytes_be: bytes }
}
fn get_subject_key_identifier(cert: &X509Certificate<'_>) -> Option<Vec<u8>> {
cert.extensions()
.iter()
.find(|ext| ext.oid.as_bytes() == OID_SUBJECT_KEY_IDENTIFIER_RAW)
.find(|ext| ext.oid.to_id_string() == OID_SUBJECT_KEY_IDENTIFIER)
.and_then(|ext| match ext.parsed_extension() {
ParsedExtension::SubjectKeyIdentifier(ki) => Some(ki.0.to_vec()),
_ => None,

View File

@@ -1,954 +0,0 @@
use crate::data_model::common::der_take_tlv;
use crate::data_model::common::{BigUnsigned, UtcTime};
use crate::data_model::oid::{OID_CT_RPKI_MANIFEST, OID_SHA256};
use crate::data_model::rc::ResourceCertificate;
use crate::data_model::signed_object::{
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectParseError, SignedObjectValidateError,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestObject {
pub signed_object: RpkiSignedObject,
pub econtent_type: String,
pub manifest: ManifestEContent,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestObjectParsed {
pub signed_object: RpkiSignedObjectParsed,
pub econtent_type: String,
pub manifest: Option<ManifestEContentParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestEContent {
pub version: u32,
pub manifest_number: BigUnsigned,
pub this_update: UtcTime,
pub next_update: UtcTime,
pub file_hash_alg: String,
/// DER-encoded content bytes of `Manifest.fileList` (SEQUENCE OF FileAndHash).
pub file_list_der: Vec<u8>,
/// Count of FileAndHash entries in `fileList`.
pub file_count: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestEContentParsed {
der: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileAndHash {
pub file_name: String,
pub hash_bytes: [u8; 32],
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestParseError {
#[error("signed object parse error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectParseError),
#[error("DER parse error: {0} (RFC 9286 §4.2; DER)")]
Parse(String),
#[error("trailing bytes after DER object: {0} bytes (RFC 9286 §4.2; DER)")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestProfileError {
#[error("signed object profile error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectValidateError),
#[error(
"eContentType must be id-ct-rpkiManifest ({OID_CT_RPKI_MANIFEST}), got {0} (RFC 9286 §4.1; RFC 9286 §4.4(1))"
)]
InvalidEContentType(String),
#[error("manifest profile decode error: {0} (RFC 9286 §4.2; DER)")]
ProfileDecode(String),
#[error("Manifest must be a SEQUENCE of 5 or 6 elements, got {0} (RFC 9286 §4.2)")]
InvalidManifestSequenceLen(usize),
#[error("Manifest.version must be 0, got {0} (RFC 9286 §4.2.1)")]
InvalidManifestVersion(u64),
#[error(
"Manifest.manifestNumber must be non-negative INTEGER (RFC 9286 §4.2; RFC 9286 §4.2.1)"
)]
InvalidManifestNumber,
#[error("Manifest.manifestNumber longer than 20 octets (RFC 9286 §4.2.1)")]
ManifestNumberTooLong,
#[error("Manifest.thisUpdate must be GeneralizedTime (RFC 9286 §4.2)")]
InvalidThisUpdate,
#[error("Manifest.nextUpdate must be GeneralizedTime (RFC 9286 §4.2)")]
InvalidNextUpdate,
#[error("Manifest.nextUpdate must be later than thisUpdate (RFC 9286 §4.2.1)")]
NextUpdateNotLater,
#[error(
"Manifest.fileHashAlg must be id-sha256 ({OID_SHA256}), got {0} (RFC 9286 §4.2.1; RFC 7935 §2)"
)]
InvalidFileHashAlg(String),
#[error("Manifest.fileList must be a SEQUENCE (RFC 9286 §4.2)")]
InvalidFileList,
#[error("FileAndHash must be SEQUENCE of 2 (RFC 9286 §4.2)")]
InvalidFileAndHash,
#[error("fileList file name invalid: {0} (RFC 9286 §4.2.2)")]
InvalidFileName(String),
#[error("fileList hash must be BIT STRING (RFC 9286 §4.2)")]
InvalidHashType,
#[error(
"fileList hash BIT STRING must be octet-aligned (unused bits=0) (RFC 9286 §4.2.1; DER BIT STRING)"
)]
HashNotOctetAligned,
#[error(
"fileList hash length invalid for sha256: got {0} bytes (RFC 9286 §4.2.1; RFC 7935 §2)"
)]
InvalidHashLength(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestDecodeError {
#[error("{0}")]
Parse(#[from] ManifestParseError),
#[error("{0}")]
Validate(#[from] ManifestProfileError),
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestValidateError {
#[error(
"Manifest EE certificate MUST include at least one RFC 3779 resource extension (IP or AS) (RFC 6487 §4.8.10-§4.8.11; RFC 3779; RFC 9286 §5.1)"
)]
EeResourcesMissing,
#[error(
"Manifest EE certificate IP resources MUST use inherit only (RFC 9286 §5.1; RFC 3779 §2.2.3.5)"
)]
EeIpResourcesNotInherit,
#[error(
"Manifest EE certificate AS resources MUST use inherit only (RFC 9286 §5.1; RFC 3779 §3.2.3.3)"
)]
EeAsResourcesNotInherit,
#[error(
"Manifest EE certificate AS resources rdi MUST be absent (RFC 6487 §4.8.11; RFC 3779 §3.2.3.5)"
)]
EeAsResourcesRdiPresent,
}
impl ManifestObject {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<ManifestObjectParsed, ManifestParseError> {
let signed_object = RpkiSignedObject::parse_der(der)?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
let manifest = signed_object
.signed_data
.encap_content_info
.econtent
.as_deref()
.map(ManifestEContent::parse_der)
.transpose()?;
Ok(ManifestObjectParsed {
signed_object,
econtent_type,
manifest,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `ManifestObject` is already profile-validated when constructed via `decode_der()` /
/// `ManifestObjectParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), ManifestProfileError> {
Ok(())
}
pub fn decode_der(der: &[u8]) -> Result<Self, ManifestDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
pub fn from_signed_object(
signed_object: RpkiSignedObject,
) -> Result<Self, ManifestDecodeError> {
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_RPKI_MANIFEST {
return Err(ManifestProfileError::InvalidEContentType(econtent_type).into());
}
let manifest =
ManifestEContent::decode_der(&signed_object.signed_data.encap_content_info.econtent)?;
Ok(Self {
signed_object,
econtent_type: OID_CT_RPKI_MANIFEST.to_string(),
manifest,
})
}
/// Validate the embedded EE certificate resources against RFC 9286 §5.1.
///
/// This does **not** perform certificate path validation. It assumes `ee` is a parsed and
/// profile-validated RPKI EE resource certificate.
pub fn validate_against_ee_cert(
&self,
ee: &ResourceCertificate,
) -> Result<(), ManifestValidateError> {
let ip = ee.tbs.extensions.ip_resources.as_ref();
let asn = ee.tbs.extensions.as_resources.as_ref();
if ip.is_none() && asn.is_none() {
return Err(ManifestValidateError::EeResourcesMissing);
}
if let Some(ip) = ip {
if !ip.is_all_inherit() {
return Err(ManifestValidateError::EeIpResourcesNotInherit);
}
}
if let Some(asn) = asn {
if asn.rdi.is_some() {
return Err(ManifestValidateError::EeAsResourcesRdiPresent);
}
if !asn.is_asnum_inherit() {
return Err(ManifestValidateError::EeAsResourcesNotInherit);
}
}
Ok(())
}
/// Validate this manifest's embedded EE certificate resources.
pub fn validate_embedded_ee_cert(&self) -> Result<(), ManifestValidateError> {
let ee = &self.signed_object.signed_data.certificates[0].resource_cert;
self.validate_against_ee_cert(ee)
}
}
impl ManifestEContent {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<ManifestEContentParsed, ManifestParseError> {
let (_tag, _value, rem) = der_take_tlv(der).map_err(|e| ManifestParseError::Parse(e))?;
if !rem.is_empty() {
return Err(ManifestParseError::TrailingBytes(rem.len()));
}
Ok(ManifestEContentParsed { der: der.to_vec() })
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `ManifestEContent` is already profile-validated when constructed via `decode_der()` /
/// `ManifestEContentParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), ManifestProfileError> {
Ok(())
}
/// Decode the DER-encoded Manifest eContent defined in RFC 9286 §4.2 (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, ManifestDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
/// Parse and return the manifest fileList.
///
/// Note: `ManifestEContent` is profile-validated when produced via `decode_der()`, so this
/// should only fail due to internal inconsistencies (or if constructed manually).
pub fn parse_files(&self) -> Result<Vec<FileAndHash>, ManifestProfileError> {
parse_file_list_sha256_fast(&self.file_list_der)
}
pub fn file_count(&self) -> usize {
self.file_count
}
}
impl ManifestObjectParsed {
pub fn validate_profile(self) -> Result<ManifestObject, ManifestProfileError> {
let signed_object = self.signed_object.validate_profile()?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_RPKI_MANIFEST {
return Err(ManifestProfileError::InvalidEContentType(econtent_type));
}
let manifest = self
.manifest
.ok_or_else(|| ManifestProfileError::ProfileDecode("Manifest.eContent missing".into()))?
.validate_profile()?;
Ok(ManifestObject {
signed_object,
econtent_type: OID_CT_RPKI_MANIFEST.to_string(),
manifest,
})
}
}
impl ManifestEContentParsed {
pub fn validate_profile(self) -> Result<ManifestEContent, ManifestProfileError> {
decode_manifest_econtent_fast(&self.der)
}
}
fn validate_file_name_bytes(bytes: &[u8]) -> Result<(), ManifestProfileError> {
// RFC 9286 §4.2.2:
// 1+ chars from a-zA-Z0-9-_ , then '.', then 3-letter extension.
if bytes.len() < 5 {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
};
// "followed by a single . (DOT), followed by a three letter extension"
// -> the dot must be exactly 4 bytes from the end.
let dot_pos = bytes.len() - 4;
if bytes[dot_pos] != b'.' {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
}
#[inline(always)]
fn valid_base_char(b: u8) -> bool {
// RFC 9286 allowed set: a-zA-Z0-9-_
(b'0'..=b'9').contains(&b)
|| (b'a'..=b'z').contains(&b)
|| (b'A'..=b'Z').contains(&b)
|| b == b'-'
|| b == b'_'
}
for &b in &bytes[..dot_pos] {
if (b & 0x80) != 0 || !valid_base_char(b) {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
}
}
let e0 = bytes[dot_pos + 1];
let e1 = bytes[dot_pos + 2];
let e2 = bytes[dot_pos + 3];
if (e0 & 0x80) != 0 || (e1 & 0x80) != 0 || (e2 & 0x80) != 0 {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
}
#[inline(always)]
fn lower_if_alpha(b: u8) -> Option<u8> {
match b {
b'a'..=b'z' => Some(b),
b'A'..=b'Z' => Some(b + 32),
_ => None,
}
}
let Some(l0) = lower_if_alpha(e0) else {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
};
let Some(l1) = lower_if_alpha(e1) else {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
};
let Some(l2) = lower_if_alpha(e2) else {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
};
match [l0, l1, l2] {
// Full IANA list (see `common.rs`).
[b'a', b's', b'a']
| [b'c', b'e', b'r']
| [b'c', b'r', b'l']
| [b'g', b'b', b'r']
| [b'm', b'f', b't']
| [b'r', b'o', b'a']
| [b's', b'i', b'g']
| [b't', b'a', b'k'] => Ok(()),
_ => Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
)),
}
}
fn decode_manifest_econtent_fast(der: &[u8]) -> Result<ManifestEContent, ManifestProfileError> {
let (tag, mut seq_content, rem) = der_take_tlv(der)
.map_err(|e| ManifestProfileError::ProfileDecode(format!("DER decode error: {e}")))?;
if !rem.is_empty() {
return Err(ManifestProfileError::ProfileDecode(format!(
"trailing bytes after DER object: {} bytes",
rem.len()
)));
}
if tag != 0x30 {
return Err(ManifestProfileError::ProfileDecode(
"Manifest eContent must be SEQUENCE".into(),
));
}
let seq_len =
der_count_elements(seq_content).map_err(|e| ManifestProfileError::ProfileDecode(e))?;
if seq_len != 5 && seq_len != 6 {
return Err(ManifestProfileError::InvalidManifestSequenceLen(seq_len));
}
let mut version: u32 = 0;
if seq_len == 6 {
let Some(&first_tag) = seq_content.first() else {
return Err(ManifestProfileError::InvalidManifestSequenceLen(0));
};
if first_tag != 0xA0 {
return Err(ManifestProfileError::ProfileDecode(
"Manifest.version must be [0] EXPLICIT INTEGER".into(),
));
}
let (_cs_tag, cs_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.version decode error: {e}"))
})?;
seq_content = after;
let (inner_tag, inner_value, inner_rem) = der_take_tlv(cs_value).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.version inner decode error: {e}"))
})?;
if !inner_rem.is_empty() {
return Err(ManifestProfileError::ProfileDecode(
"trailing bytes inside Manifest.version".into(),
));
}
if inner_tag != 0x02 {
return Err(ManifestProfileError::ProfileDecode(
"Manifest.version must be [0] EXPLICIT INTEGER".into(),
));
}
let v = der_integer_to_u64(inner_value).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.version decode error: {e}"))
})?;
if v != 0 {
return Err(ManifestProfileError::InvalidManifestVersion(v));
}
version = 0;
}
let (mn_tag, mn_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.manifestNumber decode error: {e}"))
})?;
seq_content = after;
if mn_tag != 0x02 {
return Err(ManifestProfileError::InvalidManifestNumber);
}
let manifest_number = der_integer_to_bigunsigned(mn_value)?;
let (tu_tag, tu_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.thisUpdate decode error: {e}"))
})?;
seq_content = after;
if tu_tag != 0x18 {
return Err(ManifestProfileError::InvalidThisUpdate);
}
let this_update = parse_generalized_time_bytes(tu_value)
.map_err(|e| ManifestProfileError::ProfileDecode(e))?;
let (nu_tag, nu_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.nextUpdate decode error: {e}"))
})?;
seq_content = after;
if nu_tag != 0x18 {
return Err(ManifestProfileError::InvalidNextUpdate);
}
let next_update = parse_generalized_time_bytes(nu_value)
.map_err(|e| ManifestProfileError::ProfileDecode(e))?;
if next_update <= this_update {
return Err(ManifestProfileError::NextUpdateNotLater);
}
let (oid_tag, oid_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.fileHashAlg decode error: {e}"))
})?;
seq_content = after;
if oid_tag != 0x06 {
return Err(ManifestProfileError::ProfileDecode(
"Manifest.fileHashAlg must be OBJECT IDENTIFIER".into(),
));
}
if !oid_content_is_sha256(oid_value) {
return Err(ManifestProfileError::InvalidFileHashAlg(
oid_content_to_string(oid_value),
));
}
let (fl_tag, fl_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.fileList decode error: {e}"))
})?;
seq_content = after;
if fl_tag != 0x30 {
return Err(ManifestProfileError::InvalidFileList);
}
let file_count = validate_file_list_sha256_fast(fl_value)?;
let file_list_der = fl_value.to_vec();
if !seq_content.is_empty() {
return Err(ManifestProfileError::InvalidManifestSequenceLen(seq_len));
}
Ok(ManifestEContent {
version,
manifest_number,
this_update,
next_update,
file_hash_alg: OID_SHA256.to_string(),
file_list_der,
file_count,
})
}
fn validate_file_list_sha256_fast(content: &[u8]) -> Result<usize, ManifestProfileError> {
let mut cur = content;
let mut count: usize = 0;
while !cur.is_empty() {
let (tag, value, rem) = der_take_tlv(cur).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("fileList entry decode error: {e}"))
})?;
cur = rem;
if tag != 0x30 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
let mut entry = value;
let (fn_tag, fn_value, entry_rem) = der_take_tlv(entry).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("fileList fileName decode error: {e}"))
})?;
entry = entry_rem;
if fn_tag != 0x16 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
if entry.is_empty() {
return Err(ManifestProfileError::InvalidFileAndHash);
}
validate_file_name_bytes(fn_value)?;
let (hash_tag, hash_value, entry_rem) = der_take_tlv(entry).map_err(|_e| {
// Missing second element should map to "SEQUENCE of 2" shape error.
ManifestProfileError::InvalidFileAndHash
})?;
entry = entry_rem;
if !entry.is_empty() {
return Err(ManifestProfileError::InvalidFileAndHash);
}
if hash_tag != 0x03 {
return Err(ManifestProfileError::InvalidHashType);
}
if hash_value.is_empty() {
return Err(ManifestProfileError::InvalidHashLength(0));
}
let unused_bits = hash_value[0];
if unused_bits != 0 {
return Err(ManifestProfileError::HashNotOctetAligned);
}
let bits = &hash_value[1..];
if bits.len() != 32 {
return Err(ManifestProfileError::InvalidHashLength(bits.len()));
}
count += 1;
}
Ok(count)
}
fn parse_file_list_sha256_fast(content: &[u8]) -> Result<Vec<FileAndHash>, ManifestProfileError> {
// Heuristic initial capacity (avoid a full pre-scan, which is expensive for xlarge manifests).
// Each FileAndHash entry is typically tens of bytes; 80 is a conservative average.
let est = (content.len() / 80).clamp(16, 4096);
let mut cur = content;
let mut out: Vec<FileAndHash> = Vec::with_capacity(est);
while !cur.is_empty() {
let (tag, value, rem) = der_take_tlv(cur).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("fileList entry decode error: {e}"))
})?;
cur = rem;
if tag != 0x30 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
let mut entry = value;
let (fn_tag, fn_value, entry_rem) = der_take_tlv(entry).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("fileList fileName decode error: {e}"))
})?;
entry = entry_rem;
if fn_tag != 0x16 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
if entry.is_empty() {
return Err(ManifestProfileError::InvalidFileAndHash);
}
let file_name = validate_and_copy_file_name(fn_value)?;
let (hash_tag, hash_value, entry_rem) = der_take_tlv(entry).map_err(|_e| {
// Missing second element should map to "SEQUENCE of 2" shape error.
ManifestProfileError::InvalidFileAndHash
})?;
entry = entry_rem;
if !entry.is_empty() {
return Err(ManifestProfileError::InvalidFileAndHash);
}
if hash_tag != 0x03 {
return Err(ManifestProfileError::InvalidHashType);
}
if hash_value.is_empty() {
return Err(ManifestProfileError::InvalidHashLength(0));
}
let unused_bits = hash_value[0];
if unused_bits != 0 {
return Err(ManifestProfileError::HashNotOctetAligned);
}
let bits = &hash_value[1..];
if bits.len() != 32 {
return Err(ManifestProfileError::InvalidHashLength(bits.len()));
}
let mut hash_bytes = [0u8; 32];
hash_bytes.copy_from_slice(bits);
out.push(FileAndHash {
file_name,
hash_bytes,
});
}
Ok(out)
}
fn validate_and_copy_file_name(bytes: &[u8]) -> Result<String, ManifestProfileError> {
validate_file_name_bytes(bytes)?;
Ok(unsafe { String::from_utf8_unchecked(bytes.to_vec()) })
}
fn der_count_elements(mut input: &[u8]) -> Result<usize, String> {
let mut count: usize = 0;
while !input.is_empty() {
let (_tag, _value, rem) = der_take_tlv(input)?;
input = rem;
count += 1;
}
Ok(count)
}
fn der_integer_to_u64(bytes: &[u8]) -> Result<u64, String> {
if bytes.is_empty() {
return Err("INTEGER empty".into());
}
// Reject negative (two's complement).
if bytes[0] & 0x80 != 0 {
return Err("INTEGER is negative".into());
}
if bytes.len() > 8 {
return Err("INTEGER too large".into());
}
let mut v: u64 = 0;
for &b in bytes {
v = (v << 8) | (b as u64);
}
Ok(v)
}
fn der_integer_to_bigunsigned(bytes: &[u8]) -> Result<BigUnsigned, ManifestProfileError> {
if bytes.is_empty() {
return Err(ManifestProfileError::InvalidManifestNumber);
}
// Two's complement: for non-negative values, a leading 0x00 may be present.
if bytes[0] & 0x80 != 0 {
return Err(ManifestProfileError::InvalidManifestNumber);
}
let mut start = 0usize;
while start + 1 < bytes.len() && bytes[start] == 0 {
start += 1;
}
let mut minimal = bytes[start..].to_vec();
if minimal.is_empty() {
minimal.push(0);
}
if minimal.len() > 20 {
return Err(ManifestProfileError::ManifestNumberTooLong);
}
Ok(BigUnsigned { bytes_be: minimal })
}
fn parse_generalized_time_bytes(bytes: &[u8]) -> Result<UtcTime, String> {
// Accept "YYYYMMDDHHMMSSZ" and also allow optional fractional seconds (".fff...Z").
if !bytes.is_ascii() {
return Err("GeneralizedTime not ASCII".into());
}
let s = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
if !s.ends_with('Z') {
return Err("GeneralizedTime must end with 'Z'".into());
}
let core = &s[..s.len() - 1];
let (main, frac) = core
.split_once('.')
.map_or((core, None), |(a, b)| (a, Some(b)));
if main.len() != 14 || !main.bytes().all(|b| b.is_ascii_digit()) {
return Err("GeneralizedTime must be YYYYMMDDHHMMSS[.fff]Z".into());
}
let year: i32 = main[0..4].parse().map_err(|_| "bad year")?;
let month: u8 = main[4..6].parse().map_err(|_| "bad month")?;
let day: u8 = main[6..8].parse().map_err(|_| "bad day")?;
let hour: u8 = main[8..10].parse().map_err(|_| "bad hour")?;
let minute: u8 = main[10..12].parse().map_err(|_| "bad minute")?;
let second: u8 = main[12..14].parse().map_err(|_| "bad second")?;
let nanosecond: u32 = if let Some(frac) = frac {
if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
return Err("bad fractional seconds".into());
}
let mut ns: u32 = 0;
let mut scale: u32 = 1_000_000_000;
for (i, ch) in frac.bytes().enumerate() {
if i >= 9 {
break;
}
scale /= 10;
ns += ((ch - b'0') as u32) * scale;
}
ns
} else {
0
};
let date = time::Date::from_calendar_date(
year,
time::Month::try_from(month).map_err(|_| "bad month")?,
day,
)
.map_err(|e| e.to_string())?;
let t =
time::Time::from_hms_nano(hour, minute, second, nanosecond).map_err(|e| e.to_string())?;
Ok(date.with_time(t).assume_utc())
}
fn oid_content_is_sha256(bytes: &[u8]) -> bool {
// 2.16.840.1.101.3.4.2.1
let mut arcs = oid_content_iter(bytes);
const EXPECTED: &[u64] = &[2, 16, 840, 1, 101, 3, 4, 2, 1];
for &e in EXPECTED {
match arcs.next() {
Some(v) if v == e => {}
_ => return false,
}
}
arcs.next().is_none()
}
fn oid_content_to_string(bytes: &[u8]) -> String {
let arcs: Vec<u64> = oid_content_iter(bytes).collect();
if arcs.is_empty() {
return "<invalid oid>".to_string();
}
let mut s = String::new();
for (i, a) in arcs.iter().enumerate() {
if i > 0 {
s.push('.');
}
s.push_str(&a.to_string());
}
s
}
fn oid_content_iter(bytes: &[u8]) -> impl Iterator<Item = u64> + '_ {
struct It<'a> {
bytes: &'a [u8],
pos: usize,
first_done: bool,
first_a0: u64,
first_a1: u64,
emit_first_idx: u8,
}
impl<'a> Iterator for It<'a> {
type Item = u64;
fn next(&mut self) -> Option<u64> {
if !self.first_done {
if self.bytes.is_empty() {
self.first_done = true;
return None;
}
let first = self.bytes[0] as u64;
self.first_a0 = first / 40;
self.first_a1 = first % 40;
self.pos = 1;
self.first_done = true;
self.emit_first_idx = 0;
}
if self.emit_first_idx == 0 {
self.emit_first_idx = 1;
return Some(self.first_a0);
}
if self.emit_first_idx == 1 {
self.emit_first_idx = 2;
return Some(self.first_a1);
}
if self.pos >= self.bytes.len() {
return None;
}
let mut v: u64 = 0;
while self.pos < self.bytes.len() {
let b = self.bytes[self.pos];
self.pos += 1;
v = (v << 7) | ((b & 0x7F) as u64);
if b & 0x80 == 0 {
return Some(v);
}
}
None
}
}
It {
bytes,
pos: 0,
first_done: false,
first_a0: 0,
first_a1: 0,
emit_first_idx: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tlv(tag: u8, value: &[u8]) -> Vec<u8> {
assert!(value.len() < 128);
let mut out = Vec::with_capacity(2 + value.len());
out.push(tag);
out.push(value.len() as u8);
out.extend_from_slice(value);
out
}
fn tlv_long_len(tag: u8, len_bytes: &[u8], value: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(2 + len_bytes.len() + value.len());
out.push(tag);
out.push(0x80 | (len_bytes.len() as u8));
out.extend_from_slice(len_bytes);
out.extend_from_slice(value);
out
}
#[test]
fn der_take_tlv_supports_short_and_long_form_lengths_and_errors() {
let v = b"abc";
let der = tlv(0x04, v);
let (tag, val, rem) = der_take_tlv(&der).expect("short len");
assert_eq!(tag, 0x04);
assert_eq!(val, v);
assert!(rem.is_empty());
// Long-form length with 1 length byte (130).
let v = vec![b'x'; 130];
let der = tlv_long_len(0x04, &[0x82], &v);
let (tag, val, rem) = der_take_tlv(&der).expect("long len 1");
assert_eq!(tag, 0x04);
assert_eq!(val.len(), 130);
assert!(rem.is_empty());
// Long-form length with 2 length bytes (256).
let v = vec![b'y'; 256];
let der = tlv_long_len(0x04, &[0x01, 0x00], &v);
let (tag, val, rem) = der_take_tlv(&der).expect("long len 2");
assert_eq!(tag, 0x04);
assert_eq!(val.len(), 256);
assert!(rem.is_empty());
assert!(der_take_tlv(&[]).is_err());
assert!(der_take_tlv(&[0x04]).is_err());
// High-tag-number form not supported.
assert!(der_take_tlv(&[0x1F, 0x01, 0x00]).is_err());
// Indefinite length is not allowed in DER.
assert!(der_take_tlv(&[0x04, 0x80]).is_err());
// Invalid long-form length encoding.
assert!(der_take_tlv(&[0x04, 0x81]).is_err());
assert!(der_take_tlv(&[0x04, 0x89]).is_err());
}
#[test]
fn parse_generalized_time_bytes_accepts_fraction_and_rejects_invalid() {
let t = parse_generalized_time_bytes(b"20260101000000Z").expect("basic time");
assert_eq!(t.year(), 2026);
let t = parse_generalized_time_bytes(b"20260101000000.1Z").expect("fractional");
assert_eq!(t.nanosecond(), 100_000_000);
assert!(parse_generalized_time_bytes(b"20260101000000").is_err());
assert!(parse_generalized_time_bytes(b"20260101000000+00").is_err());
assert!(parse_generalized_time_bytes(b"2026010100000Z").is_err());
assert!(parse_generalized_time_bytes(b"20261301000000Z").is_err());
assert!(parse_generalized_time_bytes(b"20260132000000Z").is_err());
assert!(parse_generalized_time_bytes(&[0xFF]).is_err());
}
#[test]
fn oid_helpers_accept_sha256_and_format_invalid() {
// 2.16.840.1.101.3.4.2.1
let sha256_oid_content = [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01];
assert!(oid_content_is_sha256(&sha256_oid_content));
assert!(!oid_content_is_sha256(&[0x55, 0x04, 0x03])); // 2.5.4.3
assert_eq!(oid_content_to_string(&[]), "<invalid oid>".to_string());
}
#[test]
fn validate_file_list_sha256_fast_counts_and_rejects_bad_hash() {
fn file_and_hash(file: &str, digest: u8) -> Vec<u8> {
let mut hash = vec![0u8; 33];
hash[0] = 0; // unused bits
for b in &mut hash[1..] {
*b = digest;
}
let ia5 = tlv(0x16, file.as_bytes());
let bit = tlv(0x03, &hash);
let mut entry = Vec::new();
entry.extend_from_slice(&ia5);
entry.extend_from_slice(&bit);
tlv(0x30, &entry)
}
let mut list = Vec::new();
list.extend_from_slice(&file_and_hash("A.cer", 0xAA));
list.extend_from_slice(&file_and_hash("B.roa", 0xBB));
assert_eq!(validate_file_list_sha256_fast(&list).expect("count"), 2);
// Wrong hash length.
let mut bad = Vec::new();
let ia5 = tlv(0x16, b"A.cer");
let bit = tlv(0x03, &[0u8; 2]); // too short
let mut entry = Vec::new();
entry.extend_from_slice(&ia5);
entry.extend_from_slice(&bit);
bad.extend_from_slice(&tlv(0x30, &entry));
assert!(matches!(
validate_file_list_sha256_fast(&bad),
Err(ManifestProfileError::InvalidHashLength(_))
));
}
}

View File

@@ -1,10 +1,6 @@
pub mod aspa;
pub mod common;
pub mod crl;
pub mod manifest;
pub mod oid;
pub mod rc;
pub mod roa;
pub mod signed_object;
pub mod ta;
pub mod tal;
mod rc;
mod tal;
mod ta;
mod resources;
mod oids;

View File

@@ -1,70 +0,0 @@
pub const OID_SHA256: &str = "2.16.840.1.101.3.4.2.1";
pub const OID_SHA256_RAW: &[u8] = &asn1_rs::oid!(raw 2.16.840.1.101.3.4.2.1);
pub const OID_SIGNED_DATA: &str = "1.2.840.113549.1.7.2";
pub const OID_SIGNED_DATA_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.7.2);
pub const OID_CMS_ATTR_CONTENT_TYPE: &str = "1.2.840.113549.1.9.3";
pub const OID_CMS_ATTR_CONTENT_TYPE_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.3);
pub const OID_CMS_ATTR_MESSAGE_DIGEST: &str = "1.2.840.113549.1.9.4";
pub const OID_CMS_ATTR_MESSAGE_DIGEST_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.4);
pub const OID_CMS_ATTR_SIGNING_TIME: &str = "1.2.840.113549.1.9.5";
pub const OID_CMS_ATTR_SIGNING_TIME_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.5);
pub const OID_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.1";
pub const OID_RSA_ENCRYPTION_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.1.1);
pub const OID_SHA256_WITH_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.11";
pub const OID_SHA256_WITH_RSA_ENCRYPTION_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.1.11);
// X.509 extensions (RFC 5280 / RFC 6487)
pub const OID_BASIC_CONSTRAINTS: &str = "2.5.29.19";
pub const OID_BASIC_CONSTRAINTS_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.19);
pub const OID_KEY_USAGE: &str = "2.5.29.15";
pub const OID_KEY_USAGE_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.15);
pub const OID_EXTENDED_KEY_USAGE: &str = "2.5.29.37";
pub const OID_EXTENDED_KEY_USAGE_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.37);
pub const OID_CRL_DISTRIBUTION_POINTS: &str = "2.5.29.31";
pub const OID_CRL_DISTRIBUTION_POINTS_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.31);
pub const OID_AUTHORITY_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.1";
pub const OID_AUTHORITY_INFO_ACCESS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.1);
pub const OID_CERTIFICATE_POLICIES: &str = "2.5.29.32";
pub const OID_CERTIFICATE_POLICIES_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.32);
pub const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35";
pub const OID_AUTHORITY_KEY_IDENTIFIER_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.35);
pub const OID_CRL_NUMBER: &str = "2.5.29.20";
pub const OID_CRL_NUMBER_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.20);
pub const OID_SUBJECT_KEY_IDENTIFIER: &str = "2.5.29.14";
pub const OID_SUBJECT_KEY_IDENTIFIER_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.14);
pub const OID_CT_RPKI_MANIFEST: &str = "1.2.840.113549.1.9.16.1.26";
pub const OID_CT_RPKI_MANIFEST_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.16.1.26);
pub const OID_CT_ROUTE_ORIGIN_AUTHZ: &str = "1.2.840.113549.1.9.16.1.24";
pub const OID_CT_ROUTE_ORIGIN_AUTHZ_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.16.1.24);
pub const OID_CT_ASPA: &str = "1.2.840.113549.1.9.16.1.49";
pub const OID_CT_ASPA_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.16.1.49);
// X.509 extensions / access methods (RFC 5280 / RFC 6487)
pub const OID_SUBJECT_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.11";
pub const OID_SUBJECT_INFO_ACCESS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.11);
pub const OID_AD_SIGNED_OBJECT: &str = "1.3.6.1.5.5.7.48.11";
pub const OID_AD_SIGNED_OBJECT_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.11);
pub const OID_AD_CA_ISSUERS: &str = "1.3.6.1.5.5.7.48.2";
pub const OID_AD_CA_ISSUERS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.2);
pub const OID_AD_CA_REPOSITORY: &str = "1.3.6.1.5.5.7.48.5";
pub const OID_AD_CA_REPOSITORY_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.5);
pub const OID_AD_RPKI_MANIFEST: &str = "1.3.6.1.5.5.7.48.10";
pub const OID_AD_RPKI_MANIFEST_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.10);
pub const OID_AD_RPKI_NOTIFY: &str = "1.3.6.1.5.5.7.48.13";
pub const OID_AD_RPKI_NOTIFY_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.13);
// RFC 3779 resource extensions (RFC 6487 profile)
pub const OID_IP_ADDR_BLOCKS: &str = "1.3.6.1.5.5.7.1.7";
pub const OID_IP_ADDR_BLOCKS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.7);
pub const OID_AUTONOMOUS_SYS_IDS: &str = "1.3.6.1.5.5.7.1.8";
pub const OID_AUTONOMOUS_SYS_IDS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.8);
// RPKI CP (RFC 6484 / RFC 6487)
pub const OID_CP_IPADDR_ASNUMBER: &str = "1.3.6.1.5.5.7.14.2";
pub const OID_CP_IPADDR_ASNUMBER_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.14.2);

16
src/data_model/oids.rs Normal file
View File

@@ -0,0 +1,16 @@
pub const OID_BASIC_CONSTRAINTS: &str = "2.5.29.19";
pub const OID_SUBJECT_KEY_IDENTIFIER: &str = "2.5.29.14";
pub const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35";
pub const OID_KEY_USAGE: &str = "2.5.29.15";
pub const OID_EXTENDED_KEY_USAGE: &str = "2.5.29.37";
pub const OID_CRL_DISTRIBUTION_POINTS: &str = "2.5.29.31";
pub const OID_AUTHORITY_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.1";
pub const OID_ACCESS_DESCRIPTION: &str = "1.3.6.1.5.5.7.48";
pub const OID_AD_CA_ISSUERS: &str = "1.3.6.1.5.5.7.48.2";
pub const OID_AD_OCSP: &str = "1.3.6.1.5.5.7.48.1";
pub const OID_SUBJECT_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.11";
pub const OID_CERTIFICATE_POLICIES: &str = "2.5.29.32";
pub const OID_SHA256_WITH_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.11";
pub const OID_IP_ADDRESS_BLOCKS: &str = "1.3.6.1.5.5.7.1.7";
pub const OID_AS_IDENTIFIERS: &str = "1.3.6.1.5.5.7.1.8";
pub const OID_RPKI_CP: &str = "1.3.6.1.5.5.7.14.2";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ASIdentifiers {
pub asn: Vec<ASIdentifierChoice>
}
// ASN
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ASIdentifierChoice {
Inherit,
ASIDsOrRanges(Vec<ASIDOrRange>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ASIDOrRange {
Id(Asn),
AsRange(ASRange),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ASRange {
pub min: Asn,
pub max: Asn,
}
impl ASRange {
/// Creates a new AS number range from the smallest and largest number.
pub fn new(min: Asn, max: Asn) -> Self {
ASRange { min, max }
}
/// Returns an AS block covering all ASNs.
pub fn all() -> ASRange {
ASRange::new(Asn::MIN, Asn::MAX)
}
/// Returns the smallest AS number that is part of this range.
pub fn min(self) -> Asn {
self.min
}
/// Returns the largest AS number that is still part of this range.
pub fn max(self) -> Asn {
self.max
}
/// Returns the number of ASNs covered by this value.
pub fn asn_count(self) -> u32 {
u32::from(self.max) - u32::from(self.min) + 1
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct Asn(u32);
impl Asn {
pub const MIN: Asn = Asn(u32::MIN);
pub const MAX: Asn = Asn(u32::MAX);
/// Creates an AS number from a `u32`.
pub fn from_u32(value: u32) -> Self {
Asn(value)
}
/// Converts an AS number into a `u32`.
pub fn into_u32(self) -> u32 {
self.0
}
/// Converts an AS number into a network-order byte array.
pub fn to_raw(self) -> [u8; 4] {
self.0.to_be_bytes()
}
}
impl From<u32> for Asn {
fn from(id: u32) -> Self {
Asn(id)
}
}
impl From<Asn> for u32 {
fn from(id: Asn) -> Self {
id.0
}
}

View File

@@ -0,0 +1,47 @@
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IPAddrBlocks {
pub ips: Vec<IPAddressFamily>
}
// IP Address Family
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IPAddressFamily {
pub address_family: Afi,
pub ip_address_choice: IPAddressChoice,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Afi {
Ipv4,
Ipv6,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IPAddressChoice {
Inherit,
AddressOrRange(Vec<IPAddressOrRange>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IPAddressOrRange {
AddressPrefix(IPAddressPrefix),
AddressRange(IPAddressRange),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IPAddressPrefix {
pub address: IPAddress,
pub prefix_length: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IPAddressRange {
pub min: IPAddress,
pub max: IPAddress,
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct IPAddress(u128);

View File

@@ -0,0 +1,3 @@
pub(crate) mod ip_resources;
pub(crate) mod as_resources;
pub mod resource;

View File

@@ -0,0 +1,10 @@
use crate::data_model::resources::as_resources::ASIdentifiers;
use crate::data_model::resources::ip_resources::IPAddrBlocks;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResourceSet {
ip_addr_blocks: IPAddrBlocks,
as_identifiers: ASIdentifiers,
}

View File

@@ -1,610 +0,0 @@
use crate::data_model::common::{DerReader, der_take_tlv};
use crate::data_model::oid::OID_CT_ROUTE_ORIGIN_AUTHZ;
use crate::data_model::rc::{Afi as RcAfi, IpPrefix as RcIpPrefix, ResourceCertificate};
use crate::data_model::signed_object::{
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectParseError, SignedObjectValidateError,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaObject {
pub signed_object: RpkiSignedObject,
pub econtent_type: String,
pub roa: RoaEContent,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaObjectParsed {
pub signed_object: RpkiSignedObjectParsed,
pub econtent_type: String,
pub roa: Option<RoaEContentParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaEContent {
pub version: u32,
pub as_id: u32,
pub ip_addr_blocks: Vec<RoaIpAddressFamily>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaEContentParsed {
der: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum RoaParseError {
#[error("signed object parse error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectParseError),
#[error("ROA parse error: {0} (RFC 9582 §4; DER)")]
Parse(String),
#[error("ROA trailing bytes: {0} bytes (RFC 9582 §4; DER)")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum RoaProfileError {
#[error("signed object profile error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectValidateError),
#[error("ROA eContentType must be {OID_CT_ROUTE_ORIGIN_AUTHZ}, got {0} (RFC 9582 §3)")]
InvalidEContentType(String),
#[error("ROA profile decode error: {0} (RFC 9582 §4; DER)")]
ProfileDecode(String),
#[error("RouteOriginAttestation must be a SEQUENCE of 2 or 3 elements, got {0} (RFC 9582 §4)")]
InvalidAttestationSequenceLen(usize),
#[error("ROA version must be 0, got {0} (RFC 9582 §4.1)")]
InvalidVersion(u64),
#[error("ROA asID out of range (0..=4294967295), got {0} (RFC 9582 §4.2)")]
AsIdOutOfRange(u64),
#[error("ROA ipAddrBlocks must have length 1..2, got {0} (RFC 9582 §4; RFC 9582 §4.3.1)")]
InvalidIpAddrBlocksLen(usize),
#[error("ROAIPAddressFamily must be a SEQUENCE of 2 elements (RFC 9582 §4.3.1)")]
InvalidIpAddressFamily,
#[error("ROA addressFamily must be an OCTET STRING of 2 bytes (RFC 9582 §4.3.1)")]
InvalidAddressFamily,
#[error("ROA addressFamily AFI not supported: {0:02X?} (RFC 9582 §4.3.1)")]
UnsupportedAfi(Vec<u8>),
#[error("ROA contains duplicate AFI {0:?} (RFC 9582 §4.3.1)")]
DuplicateAfi(RoaAfi),
#[error("ROAAddresses must have at least one entry (RFC 9582 §4.3.2)")]
EmptyAddressList,
#[error("ROAIPAddress must be a SEQUENCE of 1..2 elements (RFC 9582 §4.3.2)")]
InvalidRoaIpAddress,
#[error("ROAIPAddress.address must be a BIT STRING (RFC 9582 §4.3.2.1; RFC 3779 §2.2.3.8)")]
InvalidPrefixBitString,
#[error(
"ROAIPAddress.address has invalid unused bits encoding (RFC 9582 §4.3.2.1; RFC 3779 §2.2.3.8)"
)]
InvalidPrefixUnusedBits,
#[error(
"ROAIPAddress.address prefix length {prefix_len} out of range for {afi:?} (RFC 9582 §4.3.2.1)"
)]
PrefixLenOutOfRange { afi: RoaAfi, prefix_len: u16 },
#[error(
"ROAIPAddress.maxLength out of range for {afi:?}: prefix_len={prefix_len}, max_len={max_len} (RFC 9582 §4.3.2.2)"
)]
InvalidMaxLength {
afi: RoaAfi,
prefix_len: u16,
max_len: u16,
},
}
#[derive(Debug, thiserror::Error)]
pub enum RoaDecodeError {
#[error("{0}")]
Parse(#[from] RoaParseError),
#[error("{0}")]
Validate(#[from] RoaProfileError),
}
#[derive(Debug, thiserror::Error)]
pub enum RoaValidateError {
#[error("ROA EE certificate must not contain AS resources extension (RFC 9582 §5)")]
EeAsResourcesPresent,
#[error("ROA EE certificate must contain IP resources extension (RFC 9582 §5)")]
EeIpResourcesMissing,
#[error("ROA EE certificate IP resources must not use inherit (RFC 9582 §5)")]
EeIpResourcesInherit,
#[error(
"ROA prefix not covered by EE certificate IP resources: {afi:?} {addr:?}/{prefix_len} (RFC 9582 §5; RFC 3779 §2.3)"
)]
PrefixNotInEeResources {
afi: RoaAfi,
addr: Vec<u8>,
prefix_len: u16,
},
}
impl RoaObject {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<RoaObjectParsed, RoaParseError> {
let signed_object = RpkiSignedObject::parse_der(der)?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
let roa = signed_object
.signed_data
.encap_content_info
.econtent
.as_deref()
.map(RoaEContent::parse_der)
.transpose()?;
Ok(RoaObjectParsed {
signed_object,
econtent_type,
roa,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `RoaObject` is already profile-validated when constructed via `decode_der()` /
/// `RoaObjectParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), RoaProfileError> {
Ok(())
}
pub fn decode_der(der: &[u8]) -> Result<Self, RoaDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
pub fn from_signed_object(signed_object: RpkiSignedObject) -> Result<Self, RoaDecodeError> {
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_ROUTE_ORIGIN_AUTHZ {
return Err(RoaProfileError::InvalidEContentType(econtent_type).into());
}
let roa = RoaEContent::decode_der(&signed_object.signed_data.encap_content_info.econtent)?;
Ok(Self {
roa,
signed_object,
econtent_type: OID_CT_ROUTE_ORIGIN_AUTHZ.to_string(),
})
}
/// Validate this ROA's embedded EE certificate resources.
pub fn validate_embedded_ee_cert(&self) -> Result<(), RoaValidateError> {
let ee = &self.signed_object.signed_data.certificates[0].resource_cert;
self.roa.validate_against_ee_cert(ee)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum RoaAfi {
Ipv4,
Ipv6,
}
impl RoaAfi {
fn ub(self) -> u16 {
match self {
RoaAfi::Ipv4 => 32,
RoaAfi::Ipv6 => 128,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaIpAddressFamily {
pub afi: RoaAfi,
pub addresses: Vec<RoaIpAddress>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct RoaIpAddress {
pub prefix: IpPrefix,
pub max_length: Option<u16>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct IpPrefix {
pub afi: RoaAfi,
/// Prefix length in bits.
pub prefix_len: u16,
/// Network order address bytes (always 16 bytes), with host bits cleared.
///
/// For IPv4 prefixes, only the first 4 bytes are used and the remaining 12 bytes are zero.
pub addr: [u8; 16],
}
impl IpPrefix {
pub fn addr_bytes(&self) -> &[u8] {
match self.afi {
RoaAfi::Ipv4 => &self.addr[..4],
RoaAfi::Ipv6 => &self.addr[..16],
}
}
}
impl RoaEContent {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<RoaEContentParsed, RoaParseError> {
let (_tag, _value, rem) = der_take_tlv(der).map_err(RoaParseError::Parse)?;
if !rem.is_empty() {
return Err(RoaParseError::TrailingBytes(rem.len()));
}
Ok(RoaEContentParsed { der: der.to_vec() })
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `RoaEContent` is already profile-validated when constructed via `decode_der()` /
/// `RoaEContentParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), RoaProfileError> {
Ok(())
}
/// Decode the DER-encoded RouteOriginAttestation defined in RFC 9582 §4 (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, RoaDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
pub fn canonicalize(&mut self) {
self.ip_addr_blocks.sort_by_key(|f| f.afi);
for fam in &mut self.ip_addr_blocks {
fam.addresses.sort();
fam.addresses.dedup();
}
}
/// Validate ROA payload against the embedded EE resource certificate (RFC 9582 §5).
///
/// This performs the EE/payload semantic checks that do not require certificate path
/// validation.
pub fn validate_against_ee_cert(
&self,
ee: &ResourceCertificate,
) -> Result<(), RoaValidateError> {
if ee.tbs.extensions.as_resources.is_some() {
return Err(RoaValidateError::EeAsResourcesPresent);
}
let ip = ee
.tbs
.extensions
.ip_resources
.as_ref()
.ok_or(RoaValidateError::EeIpResourcesMissing)?;
if ip.has_any_inherit() {
return Err(RoaValidateError::EeIpResourcesInherit);
}
for fam in &self.ip_addr_blocks {
for entry in &fam.addresses {
let rc_prefix = roa_prefix_to_rc(&entry.prefix);
if !ip.contains_prefix(&rc_prefix) {
return Err(RoaValidateError::PrefixNotInEeResources {
afi: entry.prefix.afi,
addr: entry.prefix.addr_bytes().to_vec(),
prefix_len: entry.prefix.prefix_len,
});
}
}
}
Ok(())
}
}
impl RoaObjectParsed {
pub fn validate_profile(self) -> Result<RoaObject, RoaProfileError> {
let signed_object = self.signed_object.validate_profile()?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_ROUTE_ORIGIN_AUTHZ {
return Err(RoaProfileError::InvalidEContentType(econtent_type));
}
let roa = self
.roa
.ok_or_else(|| RoaProfileError::ProfileDecode("ROA.eContent missing".into()))?
.validate_profile()?;
Ok(RoaObject {
signed_object,
econtent_type: OID_CT_ROUTE_ORIGIN_AUTHZ.to_string(),
roa,
})
}
}
impl RoaEContentParsed {
pub fn validate_profile(self) -> Result<RoaEContent, RoaProfileError> {
fn count_elements(mut r: DerReader<'_>) -> Result<usize, String> {
let mut n = 0usize;
while !r.is_empty() {
r.skip_any()?;
n += 1;
}
Ok(n)
}
let mut r = DerReader::new(&self.der);
let mut seq = r
.take_sequence()
.map_err(|e| RoaProfileError::ProfileDecode(e))?;
if !r.is_empty() {
return Err(RoaProfileError::ProfileDecode(
"trailing bytes after RouteOriginAttestation".into(),
));
}
let elem_count =
count_elements(seq).map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if elem_count != 2 && elem_count != 3 {
return Err(RoaProfileError::InvalidAttestationSequenceLen(elem_count));
}
let mut version: u32 = 0;
if elem_count == 3 {
if seq
.peek_tag()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?
!= 0xA0
{
return Err(RoaProfileError::ProfileDecode(
"RouteOriginAttestation.version must be [0] EXPLICIT INTEGER".into(),
));
}
let (inner_tag, inner_val) = seq
.take_explicit(0xA0)
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if inner_tag != 0x02 {
return Err(RoaProfileError::ProfileDecode(
"RouteOriginAttestation.version must be [0] EXPLICIT INTEGER".into(),
));
}
let v = crate::data_model::common::der_uint_from_bytes(inner_val)
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if v != 0 {
return Err(RoaProfileError::InvalidVersion(v));
}
version = 0;
}
let as_id_u64 = seq
.take_uint_u64()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if as_id_u64 > u32::MAX as u64 {
return Err(RoaProfileError::AsIdOutOfRange(as_id_u64));
}
let as_id = as_id_u64 as u32;
let ip_addr_blocks = parse_ip_addr_blocks_cursor(
seq.take_sequence()
.map_err(|e| RoaProfileError::ProfileDecode(format!("ipAddrBlocks: {e}")))?,
)?;
if !seq.is_empty() {
// Extra elements beyond the expected 2..3.
let extra =
count_elements(seq).map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
return Err(RoaProfileError::InvalidAttestationSequenceLen(
elem_count + extra,
));
}
let mut out = RoaEContent {
version,
as_id,
ip_addr_blocks,
};
out.canonicalize();
Ok(out)
}
}
fn roa_prefix_to_rc(p: &IpPrefix) -> RcIpPrefix {
let afi = match p.afi {
RoaAfi::Ipv4 => RcAfi::Ipv4,
RoaAfi::Ipv6 => RcAfi::Ipv6,
};
RcIpPrefix {
afi,
prefix_len: p.prefix_len,
addr: p.addr_bytes().to_vec(),
}
}
fn parse_ip_addr_blocks_cursor(
mut seq: DerReader<'_>,
) -> Result<Vec<RoaIpAddressFamily>, RoaProfileError> {
fn count_elements(mut r: DerReader<'_>) -> Result<usize, String> {
let mut n = 0usize;
while !r.is_empty() {
r.skip_any()?;
n += 1;
}
Ok(n)
}
let fam_count =
count_elements(seq).map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if fam_count == 0 || fam_count > 2 {
return Err(RoaProfileError::InvalidIpAddrBlocksLen(fam_count));
}
let mut out: Vec<RoaIpAddressFamily> = Vec::with_capacity(fam_count);
while !seq.is_empty() {
let family = parse_ip_address_family_cursor(
seq.take_sequence()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?,
)?;
if out.iter().any(|f| f.afi == family.afi) {
return Err(RoaProfileError::DuplicateAfi(family.afi));
}
out.push(family);
}
Ok(out)
}
fn parse_ip_address_family_cursor(
mut fam: DerReader<'_>,
) -> Result<RoaIpAddressFamily, RoaProfileError> {
let afi = {
let bytes = fam
.take_octet_string()
.map_err(|_e| RoaProfileError::InvalidAddressFamily)?;
if bytes.len() != 2 {
return Err(RoaProfileError::InvalidAddressFamily);
}
match bytes {
[0x00, 0x01] => RoaAfi::Ipv4,
[0x00, 0x02] => RoaAfi::Ipv6,
_ => return Err(RoaProfileError::UnsupportedAfi(bytes.to_vec())),
}
};
let mut addrs = fam
.take_sequence()
.map_err(|_e| RoaProfileError::InvalidIpAddressFamily)?;
if !fam.is_empty() {
return Err(RoaProfileError::InvalidIpAddressFamily);
}
if addrs.is_empty() {
return Err(RoaProfileError::EmptyAddressList);
}
let mut addresses: Vec<RoaIpAddress> = Vec::new();
while !addrs.is_empty() {
let entry = addrs
.take_sequence()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
addresses.push(parse_roa_ip_address_cursor(afi, entry)?);
}
Ok(RoaIpAddressFamily { afi, addresses })
}
fn parse_roa_ip_address_cursor(
afi: RoaAfi,
mut seq: DerReader<'_>,
) -> Result<RoaIpAddress, RoaProfileError> {
if seq.is_empty() {
return Err(RoaProfileError::InvalidRoaIpAddress);
}
let (unused_bits, bytes) = seq
.take_bit_string()
.map_err(|_e| RoaProfileError::InvalidPrefixBitString)?;
let prefix = parse_prefix_bits_bytes(afi, unused_bits, bytes)?;
let max_length = if !seq.is_empty() {
let v = seq
.take_uint_u64()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
let max_len: u16 = v
.try_into()
.map_err(|_e| RoaProfileError::InvalidMaxLength {
afi,
prefix_len: prefix.prefix_len,
max_len: u16::MAX,
})?;
Some(max_len)
} else {
None
};
if !seq.is_empty() {
return Err(RoaProfileError::InvalidRoaIpAddress);
}
if let Some(max_len) = max_length {
let ub = afi.ub();
if max_len > ub || max_len < prefix.prefix_len {
return Err(RoaProfileError::InvalidMaxLength {
afi,
prefix_len: prefix.prefix_len,
max_len,
});
}
}
Ok(RoaIpAddress { prefix, max_length })
}
fn parse_prefix_bits_bytes(
afi: RoaAfi,
unused_bits: u8,
bytes: &[u8],
) -> Result<IpPrefix, RoaProfileError> {
if unused_bits > 7 {
return Err(RoaProfileError::InvalidPrefixUnusedBits);
}
if bytes.is_empty() {
if unused_bits != 0 {
return Err(RoaProfileError::InvalidPrefixUnusedBits);
}
} else if unused_bits != 0 {
let mask = (1u8 << unused_bits) - 1;
if (bytes[bytes.len() - 1] & mask) != 0 {
return Err(RoaProfileError::InvalidPrefixUnusedBits);
}
}
let prefix_len = (bytes.len() * 8)
.checked_sub(unused_bits as usize)
.ok_or(RoaProfileError::InvalidPrefixUnusedBits)? as u16;
if prefix_len > afi.ub() {
return Err(RoaProfileError::PrefixLenOutOfRange { afi, prefix_len });
}
let addr = canonicalize_prefix_addr(afi, prefix_len, bytes);
Ok(IpPrefix {
afi,
prefix_len,
addr,
})
}
fn canonicalize_prefix_addr(afi: RoaAfi, prefix_len: u16, bytes: &[u8]) -> [u8; 16] {
let full_len = match afi {
RoaAfi::Ipv4 => 4,
RoaAfi::Ipv6 => 16,
};
let mut addr = [0u8; 16];
let copy_len = bytes.len().min(full_len);
addr[..copy_len].copy_from_slice(&bytes[..copy_len]);
if prefix_len == 0 {
return addr;
}
let last_prefix_bit = (prefix_len - 1) as usize;
let last_prefix_byte = last_prefix_bit / 8;
let rem = (prefix_len % 8) as u8;
if rem != 0 {
let mask: u8 = 0xFF << (8 - rem);
if last_prefix_byte < full_len {
addr[last_prefix_byte] &= mask;
}
}
addr
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,288 +1,27 @@
use url::Url;
use x509_parser::prelude::{FromDer, X509Certificate};
use x509_parser::prelude::*;
use crate::data_model::oid::OID_CP_IPADDR_ASNUMBER;
use crate::data_model::rc::{
AsIdentifierChoice, IpAddressChoice, ResourceCertKind, ResourceCertificate,
ResourceCertificateParseError, ResourceCertificateParsed, ResourceCertificateProfileError,
};
use crate::data_model::tal::Tal;
use crate::data_model::resources::resource::ResourceSet;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaCertificate {
pub raw_der: Vec<u8>,
pub rc_ca: ResourceCertificate,
}
#[derive(Debug, thiserror::Error)]
pub enum TaCertificateParseError {
#[error("TA certificate parse error: {0} (RFC 5280 §4.1; RFC 6487 §4; RFC 8630 §2.3)")]
ResourceCertificate(#[from] ResourceCertificateParseError),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaCertificateParsed {
pub rc_parsed: ResourceCertificateParsed,
}
#[derive(Debug, thiserror::Error)]
pub enum TaCertificateProfileError {
#[error("resource certificate profile error: {0} (RFC 5280 §4; RFC 6487 §4)")]
ResourceCertificate(#[from] ResourceCertificateProfileError),
#[error("TA certificate must be a CA certificate (RFC 8630 §2.3; RFC 6487 §4.8.1)")]
NotCa,
#[error(
"TA certificate must be self-signed (issuer DN must equal subject DN) (RFC 8630 §2.3; RFC 5280 §4.1.2.4)"
)]
NotSelfSignedIssuerSubject,
#[error(
"TA certificate must contain certificatePolicies ipAddr-asNumber ({OID_CP_IPADDR_ASNUMBER}) (RFC 6487 §4.8.9; RFC 8630 §2.3)"
)]
MissingOrInvalidCertificatePolicies,
#[error("TA certificate must contain SubjectKeyIdentifier (RFC 6487 §4.8.2; RFC 8630 §2.3)")]
MissingSubjectKeyIdentifier,
#[error(
"TA certificate must contain at least one RFC 3779 resource extension (IP or AS) (RFC 6487 §4.8.10-§4.8.11; RFC 8630 §2.3)"
)]
ResourcesMissing,
#[error("TA certificate resources must be non-empty (RFC 8630 §2.3)")]
ResourcesEmpty,
#[error(
"TA certificate MUST NOT use inherit in IP resources (RFC 8630 §2.3; RFC 3779 §2.2.3.5)"
)]
IpResourcesInherit,
#[error(
"TA certificate MUST NOT use inherit in AS resources (RFC 8630 §2.3; RFC 3779 §3.2.3.3)"
)]
AsResourcesInherit,
}
#[derive(Debug, thiserror::Error)]
pub enum TaCertificateDecodeError {
#[error("{0}")]
Parse(#[from] TaCertificateParseError),
#[error("{0}")]
Validate(#[from] TaCertificateProfileError),
}
/// Backwards-compatible name: TA certificate errors from parse+validate.
pub type TaCertificateError = TaCertificateDecodeError;
#[derive(Debug, thiserror::Error)]
pub enum TaCertificateVerifyError {
#[error("TA certificate parse error: {0} (RFC 5280 §4.1; RFC 8630 §2.3)")]
Parse(String),
#[error("trailing bytes after TA certificate DER: {0} bytes (DER; RFC 5280 §4.1)")]
TrailingBytes(usize),
#[error(
"TA certificate self-signature verification failed: {0} (RFC 8630 §2.3; RFC 5280 §6.1)"
)]
InvalidSelfSignature(String),
}
impl TaCertificate {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<TaCertificateParsed, TaCertificateParseError> {
Ok(TaCertificateParsed {
rc_parsed: ResourceCertificate::parse_der(der)?,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `TaCertificate` is already profile-validated when constructed via `decode_der()` /
/// `TaCertificateParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), TaCertificateProfileError> {
Ok(())
}
/// Decode a TA certificate (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, TaCertificateDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
/// Backwards-compatible helper (historical name).
pub fn from_der(der: &[u8]) -> Result<Self, TaCertificateError> {
Self::decode_der(der)
}
pub fn spki_der(&self) -> &[u8] {
&self.rc_ca.tbs.subject_public_key_info
}
/// Verify step of scheme A (`parse → validate → verify`).
pub fn verify_self_signature(&self) -> Result<(), TaCertificateVerifyError> {
let (rem, cert) = X509Certificate::from_der(&self.raw_der)
.map_err(|e| TaCertificateVerifyError::Parse(e.to_string()))?;
if !rem.is_empty() {
return Err(TaCertificateVerifyError::TrailingBytes(rem.len()));
}
cert.verify_signature(None)
.map_err(|e| TaCertificateVerifyError::InvalidSelfSignature(e.to_string()))?;
Ok(())
}
/// Validate TA-specific semantic constraints on a parsed Resource Certificate.
///
/// Note: this does not verify the X.509 signature; it is intended for higher-level logic and
/// for unit tests that exercise individual constraint branches.
pub fn validate_rc_constraints(
rc_ca: &ResourceCertificate,
) -> Result<(), TaCertificateProfileError> {
if rc_ca.kind != ResourceCertKind::Ca {
return Err(TaCertificateProfileError::NotCa);
}
if rc_ca.tbs.extensions.certificate_policies_oid.as_deref() != Some(OID_CP_IPADDR_ASNUMBER)
{
return Err(TaCertificateProfileError::MissingOrInvalidCertificatePolicies);
}
if rc_ca.tbs.extensions.subject_key_identifier.is_none() {
return Err(TaCertificateProfileError::MissingSubjectKeyIdentifier);
}
let ip = rc_ca.tbs.extensions.ip_resources.as_ref();
let asn = rc_ca.tbs.extensions.as_resources.as_ref();
if ip.is_none() && asn.is_none() {
return Err(TaCertificateProfileError::ResourcesMissing);
}
let mut has_any_resource = false;
if let Some(ip) = ip {
if ip.has_any_inherit() {
return Err(TaCertificateProfileError::IpResourcesInherit);
}
for fam in &ip.families {
match &fam.choice {
IpAddressChoice::Inherit => {
return Err(TaCertificateProfileError::IpResourcesInherit);
}
IpAddressChoice::AddressesOrRanges(items) => {
if !items.is_empty() {
has_any_resource = true;
}
}
}
}
}
if let Some(asn) = asn {
if matches!(asn.asnum, Some(AsIdentifierChoice::Inherit))
|| matches!(asn.rdi, Some(AsIdentifierChoice::Inherit))
{
return Err(TaCertificateProfileError::AsResourcesInherit);
}
if let Some(AsIdentifierChoice::AsIdsOrRanges(items)) = asn.asnum.as_ref() {
if !items.is_empty() {
has_any_resource = true;
}
}
if let Some(AsIdentifierChoice::AsIdsOrRanges(items)) = asn.rdi.as_ref() {
if !items.is_empty() {
has_any_resource = true;
}
}
}
if !has_any_resource {
return Err(TaCertificateProfileError::ResourcesEmpty);
}
Ok(())
}
}
impl TaCertificateParsed {
pub fn validate_profile(self) -> Result<TaCertificate, TaCertificateProfileError> {
let rc_ca = self.rc_parsed.validate_profile()?;
if rc_ca.kind != ResourceCertKind::Ca {
return Err(TaCertificateProfileError::NotCa);
}
if rc_ca.tbs.issuer_name != rc_ca.tbs.subject_name {
return Err(TaCertificateProfileError::NotSelfSignedIssuerSubject);
}
TaCertificate::validate_rc_constraints(&rc_ca)?;
Ok(TaCertificate {
raw_der: rc_ca.raw_der.clone(),
rc_ca,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TrustAnchor {
pub tal: Tal,
pub ta_certificate: TaCertificate,
pub resolved_ta_uri: Option<Url>,
}
#[derive(Debug, thiserror::Error)]
pub enum TrustAnchorError {
#[error("TA certificate error: {0} (RFC 8630 §2.3)")]
TaCertificate(#[from] TaCertificateDecodeError),
#[error("{0}")]
Bind(#[from] TrustAnchorBindError),
}
#[derive(Debug, thiserror::Error)]
pub enum TrustAnchorBindError {
#[error("resolved TA URI not listed in TAL: {0} (RFC 8630 §2.2-§2.3)")]
ResolvedUriNotInTal(String),
#[error(
"TAL SPKI does not match TA certificate SubjectPublicKeyInfo (RFC 8630 §2.3; RFC 5280 §4.1.2.7)"
)]
TalSpkiMismatch,
}
impl TrustAnchor {
/// Bind a TAL and a downloaded TA certificate.
///
/// This does not download anything; it only validates the binding rules from RFC 8630 §2.3.
pub fn bind_der(
tal: Tal,
ta_der: &[u8],
resolved_uri: Option<&Url>,
) -> Result<Self, TrustAnchorError> {
let ta_certificate = TaCertificate::decode_der(ta_der)?;
Ok(Self::bind(tal, ta_certificate, resolved_uri)?)
}
pub fn bind(
tal: Tal,
ta_certificate: TaCertificate,
resolved_uri: Option<&Url>,
) -> Result<Self, TrustAnchorBindError> {
if let Some(u) = resolved_uri {
if !tal.ta_uris.iter().any(|x| x == u) {
return Err(TrustAnchorBindError::ResolvedUriNotInTal(u.to_string()));
}
}
if tal.subject_public_key_info_der != ta_certificate.spki_der() {
return Err(TrustAnchorBindError::TalSpkiMismatch);
}
Ok(Self {
tal,
ta_certificate,
resolved_ta_uri: resolved_uri.cloned(),
})
}
}
//
// #[derive(Debug, Clone)]
// pub struct TrustAnchorCert {
// /// 信任锚证书名称
// pub name: String,
//
// /// 证书原始DER内容
// pub cert_der: Vec<u8>,
//
// /// 证书
// pub cert: X509Certificate<'static>,
//
// /// 资源集合
// pub resources: ResourceSet,
//
// ///发布点
// pub publication_point: Url,
// }
//
// impl TrustAnchorCert {
//
// }

View File

@@ -1,183 +1,18 @@
use base64::Engine;
use url::Url;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TalParsed {
pub raw: Vec<u8>,
/// Lines split by '\n' and normalized by stripping a trailing '\r' per line.
pub lines: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
/// TAL Model
#[derive(Clone, Debug)]
pub struct Tal {
pub raw: Vec<u8>,
/// Optional human-readable comments
pub comments: Vec<String>,
pub ta_uris: Vec<Url>,
pub subject_public_key_info_der: Vec<u8>,
/// Ordered list of URIs pointing to the TA certificate
pub uris: Vec<TalUri>,
/// SubjectPublicKeyInfo DER
pub spki_der: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum TalParseError {
#[error("TAL must be valid UTF-8 (RFC 8630 §2.2)")]
InvalidUtf8,
}
#[derive(Debug, thiserror::Error)]
pub enum TalProfileError {
#[error("TAL comments must appear only at the beginning (RFC 8630 §2.2)")]
CommentAfterHeader,
#[error("TAL must contain at least one TA URI line (RFC 8630 §2.2)")]
MissingTaUris,
#[error(
"TAL must contain an empty line separator between URI list and SPKI base64 (RFC 8630 §2.2)"
)]
MissingSeparatorEmptyLine,
#[error("TAL TA URI invalid: {0} (RFC 8630 §2.2)")]
InvalidUri(String),
#[error("TAL TA URI scheme must be rsync or https, got {0} (RFC 8630 §2.2)")]
UnsupportedUriScheme(String),
#[error(
"TAL TA URI must reference a single object (must not end with '/'): {0} (RFC 8630 §2.3)"
)]
UriIsDirectory(String),
#[error(
"TAL must contain base64-encoded SubjectPublicKeyInfo after the separator (RFC 8630 §2.2)"
)]
MissingSpki,
#[error("TAL SPKI base64 decode failed (RFC 8630 §2.2)")]
SpkiBase64Decode,
#[error("TAL SPKI DER is empty (RFC 8630 §2.2)")]
SpkiDerEmpty,
}
#[derive(Debug, thiserror::Error)]
pub enum TalDecodeError {
#[error("{0}")]
Parse(#[from] TalParseError),
#[error("{0}")]
Validate(#[from] TalProfileError),
}
impl Tal {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_bytes(input: &[u8]) -> Result<TalParsed, TalParseError> {
let raw = input.to_vec();
let text = std::str::from_utf8(input).map_err(|_| TalParseError::InvalidUtf8)?;
let lines: Vec<String> = text
.split('\n')
.map(|l| l.strip_suffix('\r').unwrap_or(l).to_string())
.collect();
Ok(TalParsed { raw, lines })
}
/// Validate step of scheme A (`parse → validate → verify`).
///
/// `Tal` is already profile-validated when constructed via `decode_bytes()` /
/// `TalParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), TalProfileError> {
Ok(())
}
pub fn decode_bytes(input: &[u8]) -> Result<Self, TalDecodeError> {
Ok(Self::parse_bytes(input)?.validate_profile()?)
}
}
impl TalParsed {
pub fn validate_profile(self) -> Result<Tal, TalProfileError> {
let mut idx = 0usize;
// 1) Leading comments.
let mut comments: Vec<String> = Vec::new();
while idx < self.lines.len() && self.lines[idx].starts_with('#') {
comments.push(self.lines[idx][1..].to_string());
idx += 1;
}
// 2) URI list (one or more non-empty lines).
let mut ta_uris: Vec<Url> = Vec::new();
while idx < self.lines.len() {
let line = self.lines[idx].trim();
if line.is_empty() {
break;
}
if line.starts_with('#') {
return Err(TalProfileError::CommentAfterHeader);
}
let url = match Url::parse(line) {
Ok(u) => u,
Err(_) => {
if !ta_uris.is_empty() {
return Err(TalProfileError::MissingSeparatorEmptyLine);
}
return Err(TalProfileError::InvalidUri(line.to_string()));
}
};
match url.scheme() {
"rsync" | "https" => {}
s => return Err(TalProfileError::UnsupportedUriScheme(s.to_string())),
}
if url.path().ends_with('/') {
return Err(TalProfileError::UriIsDirectory(line.to_string()));
}
if url
.path_segments()
.and_then(|mut s| s.next_back())
.unwrap_or("")
.is_empty()
{
return Err(TalProfileError::UriIsDirectory(line.to_string()));
}
ta_uris.push(url);
idx += 1;
}
if ta_uris.is_empty() {
return Err(TalProfileError::MissingTaUris);
}
// 3) Empty line separator (must exist).
if idx >= self.lines.len() || !self.lines[idx].trim().is_empty() {
return Err(TalProfileError::MissingSeparatorEmptyLine);
}
idx += 1;
// 4) Base64(SPKI DER) remainder; allow line wrapping.
let mut b64 = String::new();
while idx < self.lines.len() {
let line = self.lines[idx].trim();
if !line.is_empty() {
b64.push_str(line);
}
idx += 1;
}
if b64.is_empty() {
return Err(TalProfileError::MissingSpki);
}
let spki_der = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|_| TalProfileError::SpkiBase64Decode)?;
if spki_der.is_empty() {
return Err(TalProfileError::SpkiDerEmpty);
}
Ok(Tal {
raw: self.raw,
comments,
ta_uris,
subject_public_key_info_der: spki_der,
})
}
#[derive(Debug, Clone)]
pub enum TalUri {
Rsync(String),
Https(String),
}

View File

@@ -1,113 +0,0 @@
use std::time::Duration;
use reqwest::blocking::Client;
use crate::sync::rrdp::Fetcher;
#[derive(Clone, Debug)]
pub struct HttpFetcherConfig {
pub timeout: Duration,
pub user_agent: String,
}
impl Default for HttpFetcherConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(20),
user_agent: "rpki-dev/0.1 (stage2)".to_string(),
}
}
}
/// Minimal blocking HTTP(S) fetcher for stage2.
///
/// This is used for:
/// - downloading TAL / TA certificates (RFC 8630 §2)
/// - fetching RRDP notification/snapshot files (RFC 8182 §3.4)
#[derive(Clone, Debug)]
pub struct BlockingHttpFetcher {
client: Client,
}
impl BlockingHttpFetcher {
pub fn new(config: HttpFetcherConfig) -> Result<Self, String> {
let client = Client::builder()
.timeout(config.timeout)
.user_agent(config.user_agent)
.build()
.map_err(|e| e.to_string())?;
Ok(Self { client })
}
pub fn fetch_bytes(&self, uri: &str) -> Result<Vec<u8>, String> {
let resp = self
.client
.get(uri)
.send()
.map_err(|e| format!("http request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
return Err(format!("http status {status}"));
}
resp.bytes()
.map(|b| b.to_vec())
.map_err(|e| format!("http read body failed: {e}"))
}
}
impl Fetcher for BlockingHttpFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
self.fetch_bytes(uri)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
fn spawn_one_shot_http_server(status_line: &'static str, body: &'static [u8]) -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
let addr = listener.local_addr().expect("addr");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let hdr = format!(
"{status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(hdr.as_bytes()).expect("write hdr");
stream.write_all(body).expect("write body");
});
format!("http://{}/", addr)
}
#[test]
fn fetch_bytes_returns_body_on_success() {
let url = spawn_one_shot_http_server("HTTP/1.1 200 OK", b"hello");
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(2),
..HttpFetcherConfig::default()
})
.expect("http");
let got = http.fetch_bytes(&url).expect("fetch");
assert_eq!(got, b"hello");
}
#[test]
fn fetch_bytes_rejects_non_success_status() {
let url = spawn_one_shot_http_server("HTTP/1.1 404 Not Found", b"");
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(2),
..HttpFetcherConfig::default()
})
.expect("http");
let err = http.fetch_bytes(&url).unwrap_err();
assert!(err.contains("http status"), "{err}");
}
}

View File

@@ -1,3 +0,0 @@
pub mod http;
pub mod rsync;
pub mod rsync_system;

View File

@@ -1,118 +0,0 @@
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum RsyncFetchError {
#[error("rsync fetch error: {0}")]
Fetch(String),
}
pub type RsyncFetchResult<T> = Result<T, RsyncFetchError>;
/// Fetch repository objects from a publication point.
///
/// v1: this is intentionally abstract so unit tests can use a mock, and later we can
/// back it by calling the system `rsync` binary (RFC 6481 §5; RFC 8182 §3.4.5).
pub trait RsyncFetcher {
/// Return a list of objects as `(rsync_uri, bytes)` pairs.
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>>;
}
/// A simple "rsync" implementation backed by a local directory.
///
/// This is primarily meant for offline tests and fixtures. The key generation mimics rsync URIs:
/// `rsync_base_uri` + relative path (with `/` separators).
pub struct LocalDirRsyncFetcher {
pub root_dir: PathBuf,
}
impl LocalDirRsyncFetcher {
pub fn new(root_dir: impl Into<PathBuf>) -> Self {
Self {
root_dir: root_dir.into(),
}
}
}
impl RsyncFetcher for LocalDirRsyncFetcher {
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
let base = normalize_rsync_base_uri(rsync_base_uri);
let mut out = Vec::new();
walk_dir_collect(&self.root_dir, &self.root_dir, &base, &mut out)
.map_err(|e| RsyncFetchError::Fetch(e))?;
Ok(out)
}
}
fn walk_dir_collect(
root: &Path,
current: &Path,
rsync_base_uri: &str,
out: &mut Vec<(String, Vec<u8>)>,
) -> Result<(), String> {
let rd = std::fs::read_dir(current).map_err(|e| e.to_string())?;
for entry in rd {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let meta = entry.metadata().map_err(|e| e.to_string())?;
if meta.is_dir() {
walk_dir_collect(root, &path, rsync_base_uri, out)?;
continue;
}
if !meta.is_file() {
continue;
}
let rel = path
.strip_prefix(root)
.map_err(|e| e.to_string())?
.to_string_lossy()
.replace('\\', "/");
let uri = format!("{rsync_base_uri}{rel}");
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
out.push((uri, bytes));
}
Ok(())
}
fn normalize_rsync_base_uri(s: &str) -> String {
if s.ends_with('/') {
s.to_string()
} else {
format!("{s}/")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_dir_rsync_fetcher_collects_files_and_normalizes_base_uri() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join("nested")).expect("mkdir");
std::fs::write(tmp.path().join("a.mft"), b"a").expect("write");
std::fs::write(tmp.path().join("nested").join("b.roa"), b"b").expect("write");
let f = LocalDirRsyncFetcher::new(tmp.path());
let mut objects = f
.fetch_objects("rsync://example.net/repo")
.expect("fetch_objects");
objects.sort_by(|(a, _), (b, _)| a.cmp(b));
assert_eq!(objects.len(), 2);
assert_eq!(objects[0].0, "rsync://example.net/repo/a.mft");
assert_eq!(objects[0].1, b"a");
assert_eq!(objects[1].0, "rsync://example.net/repo/nested/b.roa");
assert_eq!(objects[1].1, b"b");
}
#[test]
fn local_dir_rsync_fetcher_reports_read_dir_errors() {
let tmp = tempfile::tempdir().expect("tempdir");
let missing = tmp.path().join("missing");
let f = LocalDirRsyncFetcher::new(missing);
let err = f.fetch_objects("rsync://example.net/repo").unwrap_err();
match err {
RsyncFetchError::Fetch(msg) => assert!(!msg.is_empty()),
}
}
}

View File

@@ -1,252 +0,0 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use sha2::Digest;
use uuid::Uuid;
use crate::fetch::rsync::{RsyncFetchError, RsyncFetchResult, RsyncFetcher};
#[derive(Clone, Debug)]
pub struct SystemRsyncConfig {
pub rsync_bin: PathBuf,
pub timeout: Duration,
pub extra_args: Vec<String>,
/// Optional root directory for persistent rsync mirrors.
///
/// When set, callers may choose to sync into stable subdirectories under this
/// root (instead of a temporary directory) to benefit from rsync's incremental
/// behavior across runs.
///
/// Note: actual mirror behavior is implemented separately from config wiring.
pub mirror_root: Option<PathBuf>,
}
impl Default for SystemRsyncConfig {
fn default() -> Self {
Self {
rsync_bin: PathBuf::from("rsync"),
timeout: Duration::from_secs(60),
extra_args: Vec::new(),
mirror_root: None,
}
}
}
/// A `RsyncFetcher` implementation backed by the system `rsync` binary.
///
/// This is intended for live stage2 runs. For unit tests and offline fixtures,
/// prefer `LocalDirRsyncFetcher`.
#[derive(Clone, Debug)]
pub struct SystemRsyncFetcher {
config: SystemRsyncConfig,
}
impl SystemRsyncFetcher {
pub fn new(config: SystemRsyncConfig) -> Self {
Self { config }
}
fn mirror_dst_dir(&self, normalized_rsync_base_uri: &str) -> Result<Option<PathBuf>, String> {
let Some(root) = self.config.mirror_root.as_ref() else {
return Ok(None);
};
std::fs::create_dir_all(root).map_err(|e| {
format!(
"create rsync mirror root failed: {}: {e}",
root.display()
)
})?;
let hash = hex::encode(sha2::Sha256::digest(normalized_rsync_base_uri.as_bytes()));
let dir = root.join(hash);
std::fs::create_dir_all(&dir).map_err(|e| {
format!(
"create rsync mirror directory failed: {}: {e}",
dir.display()
)
})?;
Ok(Some(dir))
}
fn run_rsync(&self, src: &str, dst: &Path) -> Result<(), String> {
// `--timeout` is I/O timeout in seconds (applies to network reads/writes).
let timeout_secs = self.config.timeout.as_secs().max(1).to_string();
let mut cmd = Command::new(&self.config.rsync_bin);
cmd.arg("-rt")
.arg("--delete")
.arg("--timeout")
.arg(timeout_secs)
.args(&self.config.extra_args)
.arg(src)
.arg(dst);
let out = cmd
.output()
.map_err(|e| format!("rsync spawn failed: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
return Err(format!(
"rsync failed: status={} stdout={} stderr={}",
out.status,
stdout.trim(),
stderr.trim()
));
}
Ok(())
}
}
impl RsyncFetcher for SystemRsyncFetcher {
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
let base = normalize_rsync_base_uri(rsync_base_uri);
if let Some(dst) = self
.mirror_dst_dir(&base)
.map_err(|e| RsyncFetchError::Fetch(e.to_string()))?
{
self.run_rsync(&base, &dst).map_err(RsyncFetchError::Fetch)?;
let mut out = Vec::new();
walk_dir_collect(&dst, &dst, &base, &mut out).map_err(RsyncFetchError::Fetch)?;
return Ok(out);
}
let tmp = TempDir::new().map_err(|e| RsyncFetchError::Fetch(e.to_string()))?;
self.run_rsync(&base, tmp.path())
.map_err(RsyncFetchError::Fetch)?;
let mut out = Vec::new();
walk_dir_collect(tmp.path(), tmp.path(), &base, &mut out)
.map_err(RsyncFetchError::Fetch)?;
Ok(out)
}
}
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new() -> Result<Self, String> {
let mut p = std::env::temp_dir();
p.push(format!("rpki-system-rsync-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).map_err(|e| e.to_string())?;
Ok(Self { path: p })
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn normalize_rsync_base_uri(s: &str) -> String {
if s.ends_with('/') {
s.to_string()
} else {
format!("{s}/")
}
}
fn walk_dir_collect(
root: &Path,
current: &Path,
rsync_base_uri: &str,
out: &mut Vec<(String, Vec<u8>)>,
) -> Result<(), String> {
let rd = std::fs::read_dir(current).map_err(|e| e.to_string())?;
for entry in rd {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let meta = entry.metadata().map_err(|e| e.to_string())?;
if meta.is_dir() {
walk_dir_collect(root, &path, rsync_base_uri, out)?;
continue;
}
if !meta.is_file() {
continue;
}
let rel = path
.strip_prefix(root)
.map_err(|e| e.to_string())?
.to_string_lossy()
.replace('\\', "/");
let uri = format!("{rsync_base_uri}{rel}");
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
out.push((uri, bytes));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_rsync_base_uri_appends_slash_when_missing() {
assert_eq!(
normalize_rsync_base_uri("rsync://example.net/repo"),
"rsync://example.net/repo/".to_string()
);
assert_eq!(
normalize_rsync_base_uri("rsync://example.net/repo/"),
"rsync://example.net/repo/".to_string()
);
}
#[test]
fn walk_dir_collect_collects_files_and_normalizes_backslashes_in_uri() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
std::fs::create_dir_all(root.join("sub")).expect("mkdir");
std::fs::write(root.join("sub").join("a.cer"), b"x").expect("write");
std::fs::write(root.join("b\\c.mft"), b"y").expect("write backslash file");
let mut out: Vec<(String, Vec<u8>)> = Vec::new();
walk_dir_collect(root, root, "rsync://example.net/repo/", &mut out).expect("walk");
out.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(out.len(), 2);
assert_eq!(out[0].0, "rsync://example.net/repo/b/c.mft");
assert_eq!(out[0].1, b"y");
assert_eq!(out[1].0, "rsync://example.net/repo/sub/a.cer");
assert_eq!(out[1].1, b"x");
}
#[test]
fn system_rsync_fetcher_reports_spawn_and_exit_errors() {
let dst = tempfile::tempdir().expect("tempdir");
// 1) Spawn error.
let f = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: PathBuf::from("/this/does/not/exist/rsync"),
timeout: Duration::from_secs(1),
extra_args: Vec::new(),
mirror_root: None,
});
let e = f
.run_rsync("rsync://example.net/repo/", dst.path())
.expect_err("spawn must fail");
assert!(e.contains("rsync spawn failed:"), "{e}");
// 2) Non-zero exit status.
let f = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: PathBuf::from("false"),
timeout: Duration::from_secs(1),
extra_args: Vec::new(),
mirror_root: None,
});
let e = f
.run_rsync("rsync://example.net/repo/", dst.path())
.expect_err("false must fail");
assert!(e.contains("rsync failed:"), "{e}");
assert!(e.contains("status="), "{e}");
}
}

View File

@@ -1,22 +1 @@
pub mod data_model;
#[cfg(feature = "full")]
pub mod analysis;
#[cfg(feature = "full")]
pub mod audit;
#[cfg(feature = "full")]
pub mod audit_downloads;
#[cfg(feature = "full")]
pub mod cli;
#[cfg(feature = "full")]
pub mod fetch;
#[cfg(feature = "full")]
pub mod policy;
#[cfg(feature = "full")]
pub mod report;
#[cfg(feature = "full")]
pub mod storage;
#[cfg(feature = "full")]
pub mod sync;
#[cfg(feature = "full")]
pub mod validation;

View File

@@ -1,70 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SyncPreference {
RrdpThenRsync,
RsyncOnly,
}
impl Default for SyncPreference {
fn default() -> Self {
Self::RrdpThenRsync
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CaFailedFetchPolicy {
UseFetchCachePp,
StopAllOutput,
}
impl Default for CaFailedFetchPolicy {
fn default() -> Self {
Self::UseFetchCachePp
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignedObjectFailurePolicy {
DropObject,
DropPublicationPoint,
}
impl Default for SignedObjectFailurePolicy {
fn default() -> Self {
Self::DropObject
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Policy {
pub sync_preference: SyncPreference,
pub ca_failed_fetch_policy: CaFailedFetchPolicy,
pub signed_object_failure_policy: SignedObjectFailurePolicy,
}
impl Default for Policy {
fn default() -> Self {
Self {
sync_preference: SyncPreference::default(),
ca_failed_fetch_policy: CaFailedFetchPolicy::default(),
signed_object_failure_policy: SignedObjectFailurePolicy::default(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum PolicyParseError {
#[error("policy TOML parse error: {0}")]
Toml(String),
}
impl Policy {
pub fn from_toml_str(s: &str) -> Result<Self, PolicyParseError> {
toml::from_str(s).map_err(|e| PolicyParseError::Toml(e.to_string()))
}
}

View File

@@ -1,29 +0,0 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RfcRef(pub &'static str);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Warning {
pub message: String,
pub rfc_refs: Vec<RfcRef>,
pub context: Option<String>,
}
impl Warning {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
rfc_refs: Vec::new(),
context: None,
}
}
pub fn with_rfc_refs(mut self, refs: &[RfcRef]) -> Self {
self.rfc_refs.extend_from_slice(refs);
self
}
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
}

View File

@@ -1,806 +0,0 @@
use std::path::Path;
use rocksdb::{
ColumnFamily, ColumnFamilyDescriptor, DB, DBCompressionType, Direction, IteratorMode, Options,
WriteBatch,
};
use serde::{Deserialize, Serialize};
use sha2::Digest;
use std::collections::HashSet;
const CF_RAW_OBJECTS: &str = "raw_objects";
const CF_FETCH_CACHE_PP: &str = "fetch_cache_pp";
const CF_RRDP_STATE: &str = "rrdp_state";
const CF_RRDP_OBJECT_INDEX: &str = "rrdp_object_index";
const RRDP_OBJECT_INDEX_PREFIX: &[u8] = b"rrdp_obj:";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FetchCachePpKey(String);
impl FetchCachePpKey {
pub fn from_manifest_rsync_uri(manifest_rsync_uri: &str) -> Self {
Self(format!("fetch_cache_pp:{manifest_rsync_uri}"))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("rocksdb error: {0}")]
RocksDb(String),
#[error("missing column family: {0}")]
MissingColumnFamily(&'static str),
#[error("fetch_cache_pp pack error: {0}")]
Pack(#[from] PackDecodeError),
}
pub type StorageResult<T> = Result<T, StorageError>;
pub struct RocksStore {
db: DB,
}
pub mod pack {
pub use super::{FetchCachePpPack, PackDecodeError, PackFile, PackTime};
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RrdpDeltaOp {
Upsert { rsync_uri: String, bytes: Vec<u8> },
Delete { rsync_uri: String },
}
impl RocksStore {
pub fn open(path: &Path) -> StorageResult<Self> {
let mut base_opts = Options::default();
base_opts.create_if_missing(true);
base_opts.create_missing_column_families(true);
// Prefer conservative compression; may be overridden later.
base_opts.set_compression_type(DBCompressionType::Lz4);
// Blob files / BlobDB enablement.
//
// IMPORTANT: `enable_blob_files` is a *column family option* in RocksDB. Setting it only
// on the DB's base options is not sufficient; every CF that stores values must enable it.
enable_blobdb_if_supported(&mut base_opts);
fn cf_opts() -> Options {
let mut opts = Options::default();
opts.set_compression_type(DBCompressionType::Lz4);
enable_blobdb_if_supported(&mut opts);
opts
}
let cfs = vec![
ColumnFamilyDescriptor::new(CF_RAW_OBJECTS, cf_opts()),
ColumnFamilyDescriptor::new(CF_FETCH_CACHE_PP, cf_opts()),
ColumnFamilyDescriptor::new(CF_RRDP_STATE, cf_opts()),
ColumnFamilyDescriptor::new(CF_RRDP_OBJECT_INDEX, cf_opts()),
];
let db = DB::open_cf_descriptors(&base_opts, path, cfs)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(Self { db })
}
fn cf(&self, name: &'static str) -> StorageResult<&ColumnFamily> {
self.db
.cf_handle(name)
.ok_or(StorageError::MissingColumnFamily(name))
}
pub fn put_raw(&self, rsync_uri: &str, bytes: &[u8]) -> StorageResult<()> {
let cf = self.cf(CF_RAW_OBJECTS)?;
self.db
.put_cf(cf, rsync_uri.as_bytes(), bytes)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
/// Store multiple raw objects in a single RocksDB write batch.
///
/// This is primarily used by rsync-based publication point sync to reduce write amplification
/// (many small `put_cf` calls) into one `DB::write()` call.
pub fn put_raw_batch(&self, objects: Vec<(String, Vec<u8>)>) -> StorageResult<usize> {
if objects.is_empty() {
return Ok(0);
}
let cf = self.cf(CF_RAW_OBJECTS)?;
let mut batch = WriteBatch::default();
for (rsync_uri, bytes) in &objects {
batch.put_cf(cf, rsync_uri.as_bytes(), bytes.as_slice());
}
self.write_batch(batch)?;
Ok(objects.len())
}
pub fn get_raw(&self, rsync_uri: &str) -> StorageResult<Option<Vec<u8>>> {
let cf = self.cf(CF_RAW_OBJECTS)?;
let v = self
.db
.get_cf(cf, rsync_uri.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(v)
}
pub fn delete_raw(&self, rsync_uri: &str) -> StorageResult<()> {
let cf = self.cf(CF_RAW_OBJECTS)?;
self.db
.delete_cf(cf, rsync_uri.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn put_fetch_cache_pp(&self, key: &FetchCachePpKey, bytes: &[u8]) -> StorageResult<()> {
let cf = self.cf(CF_FETCH_CACHE_PP)?;
self.db
.put_cf(cf, key.as_str().as_bytes(), bytes)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn get_fetch_cache_pp(&self, key: &FetchCachePpKey) -> StorageResult<Option<Vec<u8>>> {
let cf = self.cf(CF_FETCH_CACHE_PP)?;
let v = self
.db
.get_cf(cf, key.as_str().as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(v)
}
pub fn put_rrdp_state(&self, notification_uri: &str, bytes: &[u8]) -> StorageResult<()> {
let cf = self.cf(CF_RRDP_STATE)?;
self.db
.put_cf(cf, notification_uri.as_bytes(), bytes)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn get_rrdp_state(&self, notification_uri: &str) -> StorageResult<Option<Vec<u8>>> {
let cf = self.cf(CF_RRDP_STATE)?;
let v = self
.db
.get_cf(cf, notification_uri.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(v)
}
#[allow(dead_code)]
pub fn delete_rrdp_state(&self, notification_uri: &str) -> StorageResult<()> {
let cf = self.cf(CF_RRDP_STATE)?;
self.db
.delete_cf(cf, notification_uri.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
fn rrdp_object_index_key(notification_uri: &str, rsync_uri: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(
RRDP_OBJECT_INDEX_PREFIX.len() + notification_uri.len() + 1 + rsync_uri.len(),
);
out.extend_from_slice(RRDP_OBJECT_INDEX_PREFIX);
out.extend_from_slice(notification_uri.as_bytes());
out.push(0);
out.extend_from_slice(rsync_uri.as_bytes());
out
}
fn rrdp_object_index_prefix(notification_uri: &str) -> Vec<u8> {
let mut out =
Vec::with_capacity(RRDP_OBJECT_INDEX_PREFIX.len() + notification_uri.len() + 1);
out.extend_from_slice(RRDP_OBJECT_INDEX_PREFIX);
out.extend_from_slice(notification_uri.as_bytes());
out.push(0);
out
}
#[allow(dead_code)]
pub fn rrdp_object_index_contains(
&self,
notification_uri: &str,
rsync_uri: &str,
) -> StorageResult<bool> {
let cf = self.cf(CF_RRDP_OBJECT_INDEX)?;
let k = Self::rrdp_object_index_key(notification_uri, rsync_uri);
Ok(self
.db
.get_cf(cf, k)
.map_err(|e| StorageError::RocksDb(e.to_string()))?
.is_some())
}
#[allow(dead_code)]
pub fn rrdp_object_index_iter(
&self,
notification_uri: &str,
) -> StorageResult<impl Iterator<Item = String> + '_> {
let cf = self.cf(CF_RRDP_OBJECT_INDEX)?;
let prefix = Self::rrdp_object_index_prefix(notification_uri);
let prefix_len = prefix.len();
let mode = IteratorMode::From(prefix.as_slice(), Direction::Forward);
Ok(self
.db
.iterator_cf(cf, mode)
.take_while(move |res| match res {
Ok((k, _v)) => k.starts_with(prefix.as_slice()),
Err(_) => false,
})
.filter_map(move |res| {
let (k, _v) = res.ok()?;
let rsync_part = k.get(prefix_len..)?;
let s = std::str::from_utf8(rsync_part).ok()?;
Some(s.to_string())
}))
}
#[allow(dead_code)]
pub fn rrdp_object_index_clear(&self, notification_uri: &str) -> StorageResult<usize> {
let cf = self.cf(CF_RRDP_OBJECT_INDEX)?;
let prefix = Self::rrdp_object_index_prefix(notification_uri);
let mode = IteratorMode::From(prefix.as_slice(), Direction::Forward);
let keys: Vec<Box<[u8]>> = self
.db
.iterator_cf(cf, mode)
.take_while(|res| match res {
Ok((k, _v)) => k.starts_with(prefix.as_slice()),
Err(_) => false,
})
.filter_map(|res| res.ok().map(|(k, _v)| k))
.collect();
if keys.is_empty() {
return Ok(0);
}
let mut batch = WriteBatch::default();
for k in &keys {
batch.delete_cf(cf, k);
}
self.write_batch(batch)?;
Ok(keys.len())
}
/// Apply an RRDP snapshot as a complete repository state for this `notification_uri`.
///
/// This updates:
/// - `raw_objects` (publish all objects, delete objects that were present in the previous
/// snapshot state but absent from this snapshot)
/// - `rrdp_object_index` membership (used to scope deletes to this RRDP repository)
///
/// RFC 8182 §3.5.2.1: snapshots reflect the complete and current repository contents.
pub fn apply_rrdp_snapshot(
&self,
notification_uri: &str,
published: &[(String, Vec<u8>)],
) -> StorageResult<usize> {
let raw_cf = self.cf(CF_RAW_OBJECTS)?;
let idx_cf = self.cf(CF_RRDP_OBJECT_INDEX)?;
let mut new_set: HashSet<&str> = HashSet::with_capacity(published.len());
for (u, _b) in published {
new_set.insert(u.as_str());
}
let old_uris: Vec<String> = self.rrdp_object_index_iter(notification_uri)?.collect();
let mut batch = WriteBatch::default();
for old in &old_uris {
if !new_set.contains(old.as_str()) {
batch.delete_cf(raw_cf, old.as_bytes());
let k = Self::rrdp_object_index_key(notification_uri, old.as_str());
batch.delete_cf(idx_cf, k);
}
}
for (uri, bytes) in published {
batch.put_cf(raw_cf, uri.as_bytes(), bytes.as_slice());
let k = Self::rrdp_object_index_key(notification_uri, uri.as_str());
batch.put_cf(idx_cf, k, b"");
}
self.write_batch(batch)?;
Ok(published.len())
}
pub fn apply_rrdp_delta(
&self,
notification_uri: &str,
ops: &[RrdpDeltaOp],
) -> StorageResult<usize> {
if ops.is_empty() {
return Ok(0);
}
let raw_cf = self.cf(CF_RAW_OBJECTS)?;
let idx_cf = self.cf(CF_RRDP_OBJECT_INDEX)?;
let mut batch = WriteBatch::default();
for op in ops {
match op {
RrdpDeltaOp::Upsert { rsync_uri, bytes } => {
batch.put_cf(raw_cf, rsync_uri.as_bytes(), bytes.as_slice());
let k = Self::rrdp_object_index_key(notification_uri, rsync_uri.as_str());
batch.put_cf(idx_cf, k, b"");
}
RrdpDeltaOp::Delete { rsync_uri } => {
batch.delete_cf(raw_cf, rsync_uri.as_bytes());
let k = Self::rrdp_object_index_key(notification_uri, rsync_uri.as_str());
batch.delete_cf(idx_cf, k);
}
}
}
self.write_batch(batch)?;
Ok(ops.len())
}
#[allow(dead_code)]
pub fn raw_iter_prefix<'a>(
&'a self,
prefix: &'a [u8],
) -> StorageResult<impl Iterator<Item = (Box<[u8]>, Box<[u8]>)> + 'a> {
let cf = self.cf(CF_RAW_OBJECTS)?;
let mode = IteratorMode::From(prefix, Direction::Forward);
Ok(self
.db
.iterator_cf(cf, mode)
.take_while(move |res| match res {
Ok((k, _v)) => k.starts_with(prefix),
Err(_) => false,
})
.filter_map(|res| res.ok()))
}
#[allow(dead_code)]
pub fn raw_iter_all<'a>(
&'a self,
) -> StorageResult<impl Iterator<Item = (Box<[u8]>, Box<[u8]>)> + 'a> {
let cf = self.cf(CF_RAW_OBJECTS)?;
let mode = IteratorMode::Start;
Ok(self.db.iterator_cf(cf, mode).filter_map(|res| res.ok()))
}
#[allow(dead_code)]
pub fn fetch_cache_pp_iter_all<'a>(
&'a self,
) -> StorageResult<impl Iterator<Item = (Box<[u8]>, Box<[u8]>)> + 'a> {
let cf = self.cf(CF_FETCH_CACHE_PP)?;
let mode = IteratorMode::Start;
Ok(self.db.iterator_cf(cf, mode).filter_map(|res| res.ok()))
}
#[allow(dead_code)]
pub fn write_batch(&self, batch: WriteBatch) -> StorageResult<()> {
self.db
.write(batch)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
}
fn enable_blobdb_if_supported(opts: &mut Options) {
// Rust bindings may or may not expose BlobDB options depending on RocksDB build.
// We enable them in a best-effort way using method detection via trait bounds is
// not possible in stable Rust; so we keep this minimal.
//
// If the crate exposes `set_enable_blob_files`, use it; otherwise do nothing.
#[allow(unused_mut)]
let mut _enabled = false;
#[allow(dead_code)]
fn _set(opts: &mut Options) {
// If this method exists, this compiles and enables BlobDB.
opts.set_enable_blob_files(true);
opts.set_min_blob_size(1024);
}
// Call the helper in a way that fails to compile if methods don't exist.
// To keep compilation stable, this file is expected to compile with a rocksdb
// crate version that exposes these setters. If not, remove the call and use
// plain RocksDB (still compatible with later enabling).
_set(opts);
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FetchCachePpPack {
pub format_version: u32,
pub manifest_rsync_uri: String,
pub publication_point_rsync_uri: String,
/// Manifest manifestNumber value (RFC 9286 §4.2.1).
///
/// Minimal big-endian bytes. For zero, this is `[0]`.
pub manifest_number_be: Vec<u8>,
pub this_update: PackTime,
pub next_update: PackTime,
pub verified_at: PackTime,
pub manifest_bytes: Vec<u8>,
/// Objects listed in the Manifest fileList (RFC 9286 §4.2.1).
///
/// Note: the manifest object itself is *not* listed in fileList; it is stored separately
/// in `manifest_bytes`.
pub files: Vec<PackFile>,
}
impl FetchCachePpPack {
pub const FORMAT_VERSION_V1: u32 = 1;
pub fn encode(&self) -> StorageResult<Vec<u8>> {
serde_cbor::to_vec(self).map_err(|e| PackDecodeError::Encode(e.to_string()).into())
}
pub fn decode(bytes: &[u8]) -> StorageResult<Self> {
let pack: Self =
serde_cbor::from_slice(bytes).map_err(|e| PackDecodeError::Decode(e.to_string()))?;
pack.validate_internal()?;
Ok(pack)
}
pub fn validate_internal(&self) -> StorageResult<()> {
if self.format_version != Self::FORMAT_VERSION_V1 {
return Err(PackDecodeError::UnsupportedFormatVersion(self.format_version).into());
}
if self.manifest_rsync_uri.is_empty() {
return Err(PackDecodeError::MissingField("manifest_rsync_uri").into());
}
if self.publication_point_rsync_uri.is_empty() {
return Err(PackDecodeError::MissingField("publication_point_rsync_uri").into());
}
if self.manifest_number_be.is_empty() {
return Err(PackDecodeError::MissingField("manifest_number_be").into());
}
if self.manifest_number_be.len() > 20 {
return Err(PackDecodeError::InvalidField {
field: "manifest_number_be",
detail: "must be at most 20 octets (RFC 9286 §4.2.1)",
}
.into());
}
if self.manifest_number_be.len() > 1 && self.manifest_number_be[0] == 0 {
return Err(PackDecodeError::InvalidField {
field: "manifest_number_be",
detail: "must be minimal big-endian (no leading zeros)",
}
.into());
}
self.this_update
.parse()
.map_err(|e| PackDecodeError::InvalidTimeField {
field: "this_update",
detail: e,
})?;
self.next_update
.parse()
.map_err(|e| PackDecodeError::InvalidTimeField {
field: "next_update",
detail: e,
})?;
self.verified_at
.parse()
.map_err(|e| PackDecodeError::InvalidTimeField {
field: "verified_at",
detail: e,
})?;
if self.manifest_bytes.is_empty() {
return Err(PackDecodeError::MissingManifestBytes.into());
}
let mut seen: HashSet<&str> = HashSet::with_capacity(self.files.len());
for file in &self.files {
if file.rsync_uri.is_empty() {
return Err(PackDecodeError::MissingField("files[].rsync_uri").into());
}
if !seen.insert(file.rsync_uri.as_str()) {
return Err(PackDecodeError::DuplicateFileRsyncUri(file.rsync_uri.clone()).into());
}
if file.bytes.is_empty() {
return Err(PackDecodeError::EmptyFileBytes(file.rsync_uri.clone()).into());
}
let computed = file.compute_sha256();
if computed != file.sha256 {
return Err(PackDecodeError::FileHashMismatch {
rsync_uri: file.rsync_uri.clone(),
}
.into());
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackFile {
pub rsync_uri: String,
pub bytes: Vec<u8>,
pub sha256: [u8; 32],
}
impl PackFile {
pub fn new(rsync_uri: impl Into<String>, bytes: Vec<u8>, sha256: [u8; 32]) -> Self {
Self {
rsync_uri: rsync_uri.into(),
bytes,
sha256,
}
}
pub fn from_bytes_compute_sha256(rsync_uri: impl Into<String>, bytes: Vec<u8>) -> Self {
let sha256 = compute_sha256_32(&bytes);
Self::new(rsync_uri, bytes, sha256)
}
pub fn compute_sha256(&self) -> [u8; 32] {
compute_sha256_32(&self.bytes)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackTime {
/// RFC 3339 timestamp in UTC, e.g. "2026-02-06T00:00:00Z".
pub rfc3339_utc: String,
}
impl PackTime {
pub fn from_utc_offset_datetime(t: time::OffsetDateTime) -> Self {
use time::format_description::well_known::Rfc3339;
let utc = t.to_offset(time::UtcOffset::UTC);
let s = utc.format(&Rfc3339).expect("format RFC 3339 UTC time");
Self { rfc3339_utc: s }
}
pub fn parse(&self) -> Result<time::OffsetDateTime, String> {
use time::format_description::well_known::Rfc3339;
time::OffsetDateTime::parse(&self.rfc3339_utc, &Rfc3339).map_err(|e| e.to_string())
}
}
#[derive(Debug, thiserror::Error)]
pub enum PackDecodeError {
#[error("encode fetch_cache_pp pack failed: {0}")]
Encode(String),
#[error("decode fetch_cache_pp pack failed: {0}")]
Decode(String),
#[error("unsupported pack format_version: {0}")]
UnsupportedFormatVersion(u32),
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("invalid field {field}: {detail}")]
InvalidField {
field: &'static str,
detail: &'static str,
},
#[error("missing manifest_bytes in fetch_cache_pp pack")]
MissingManifestBytes,
#[error("duplicate file rsync uri in fetch_cache_pp pack: {0}")]
DuplicateFileRsyncUri(String),
#[error("empty file bytes in fetch_cache_pp pack: {0}")]
EmptyFileBytes(String),
#[error("file hash mismatch in fetch_cache_pp pack: {rsync_uri}")]
FileHashMismatch { rsync_uri: String },
#[error("invalid time field {field}: {detail}")]
InvalidTimeField { field: &'static str, detail: String },
}
fn compute_sha256_32(bytes: &[u8]) -> [u8; 32] {
let digest = sha2::Sha256::digest(bytes);
let mut out = [0u8; 32];
out.copy_from_slice(&digest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rrdp_object_index_and_snapshot_delta_helpers_work_end_to_end() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let notification_uri = "https://rrdp.example.test/notification.xml";
let u1 = "rsync://rpki.example.test/repo/obj1.cer".to_string();
let u2 = "rsync://rpki.example.test/repo/obj2.mft".to_string();
// Empty clear is a fast no-op.
assert_eq!(
store
.rrdp_object_index_clear(notification_uri)
.expect("clear empty"),
0
);
// Snapshot publishes two objects.
let published_v1 = vec![(u1.clone(), vec![1u8, 2, 3]), (u2.clone(), vec![9u8, 8, 7])];
let n = store
.apply_rrdp_snapshot(notification_uri, &published_v1)
.expect("apply snapshot v1");
assert_eq!(n, 2);
assert_eq!(
store.get_raw(&u1).expect("get_raw u1"),
Some(vec![1u8, 2, 3])
);
assert_eq!(
store.get_raw(&u2).expect("get_raw u2"),
Some(vec![9u8, 8, 7])
);
assert!(
store
.rrdp_object_index_contains(notification_uri, &u1)
.expect("contains u1")
);
assert!(
store
.rrdp_object_index_contains(notification_uri, &u2)
.expect("contains u2")
);
let mut listed: Vec<String> = store
.rrdp_object_index_iter(notification_uri)
.expect("iter")
.collect();
listed.sort();
assert_eq!(listed, vec![u1.clone(), u2.clone()]);
// Snapshot v2 removes u1 and updates u2.
let published_v2 = vec![(u2.clone(), vec![0u8, 1, 2, 3])];
store
.apply_rrdp_snapshot(notification_uri, &published_v2)
.expect("apply snapshot v2");
assert_eq!(store.get_raw(&u1).expect("get_raw removed"), None);
assert_eq!(
store.get_raw(&u2).expect("get_raw updated"),
Some(vec![0u8, 1, 2, 3])
);
// Delta can upsert and delete, and uses the index to scope membership.
let u3 = "rsync://rpki.example.test/repo/obj3.crl".to_string();
let ops = vec![
RrdpDeltaOp::Upsert {
rsync_uri: u3.clone(),
bytes: vec![4u8, 5, 6],
},
RrdpDeltaOp::Delete {
rsync_uri: u2.clone(),
},
];
let applied = store
.apply_rrdp_delta(notification_uri, &ops)
.expect("apply delta");
assert_eq!(applied, 2);
assert_eq!(store.get_raw(&u2).expect("get_raw deleted"), None);
assert_eq!(
store.get_raw(&u3).expect("get_raw u3"),
Some(vec![4u8, 5, 6])
);
// Prefix iterators yield only matching keys.
let prefix = b"rsync://rpki.example.test/repo/";
let mut got: Vec<String> = store
.raw_iter_prefix(prefix)
.expect("raw_iter_prefix")
.map(|(k, _v)| String::from_utf8(k.to_vec()).expect("utf8 key"))
.collect();
got.sort();
assert_eq!(got, vec![u3.clone()]);
let all: Vec<String> = store
.raw_iter_all()
.expect("raw_iter_all")
.map(|(k, _v)| String::from_utf8(k.to_vec()).expect("utf8 key"))
.collect();
assert!(all.contains(&u3));
// Clearing removes all index entries for this RRDP repository.
let cleared = store
.rrdp_object_index_clear(notification_uri)
.expect("clear");
assert!(cleared >= 1);
assert!(
!store
.rrdp_object_index_contains(notification_uri, &u3)
.expect("contains after clear")
);
}
fn minimal_valid_pack() -> FetchCachePpPack {
let now = time::OffsetDateTime::now_utc();
FetchCachePpPack {
format_version: FetchCachePpPack::FORMAT_VERSION_V1,
manifest_rsync_uri: "rsync://example.test/repo/pp/manifest.mft".to_string(),
publication_point_rsync_uri: "rsync://example.test/repo/pp/".to_string(),
manifest_number_be: vec![1],
this_update: PackTime::from_utc_offset_datetime(now),
next_update: PackTime::from_utc_offset_datetime(now + time::Duration::hours(1)),
verified_at: PackTime::from_utc_offset_datetime(now),
manifest_bytes: vec![0x01],
files: vec![PackFile::from_bytes_compute_sha256(
"rsync://example.test/repo/pp/a.roa",
vec![1u8, 2, 3],
)],
}
}
#[test]
fn apply_rrdp_delta_empty_ops_is_noop() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let n = store
.apply_rrdp_delta("https://rrdp.example.test/notification.xml", &[])
.expect("apply empty delta");
assert_eq!(n, 0);
}
#[test]
fn fetch_cache_pp_pack_rejects_invalid_time_fields_and_duplicates() {
// Invalid `next_update`.
let mut p = minimal_valid_pack();
p.next_update.rfc3339_utc = "not-a-time".to_string();
let e = p.validate_internal().unwrap_err().to_string();
assert!(e.contains("invalid time field next_update"), "{e}");
// Invalid `verified_at`.
let mut p = minimal_valid_pack();
p.verified_at.rfc3339_utc = "also-not-a-time".to_string();
let e = p.validate_internal().unwrap_err().to_string();
assert!(e.contains("invalid time field verified_at"), "{e}");
// Duplicate file rsync URI.
let mut p = minimal_valid_pack();
let f = p.files[0].clone();
p.files.push(f);
let e = p.validate_internal().unwrap_err().to_string();
assert!(e.contains("duplicate file rsync uri"), "{e}");
}
#[test]
fn fetch_cache_pp_iter_all_lists_keys() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let key =
FetchCachePpKey::from_manifest_rsync_uri("rsync://example.test/repo/pp/manifest.mft");
let bytes = minimal_valid_pack().encode().expect("encode pack");
store
.put_fetch_cache_pp(&key, &bytes)
.expect("put fetch_cache_pp");
let keys: Vec<String> = store
.fetch_cache_pp_iter_all()
.expect("iter all")
.map(|(k, _v)| String::from_utf8(k.to_vec()).expect("utf8 key"))
.collect();
assert!(
keys.iter().any(|k| k == key.as_str()),
"missing key in iterator"
);
}
}

View File

@@ -1,2 +0,0 @@
pub mod repo;
pub mod rrdp;

View File

@@ -1,827 +0,0 @@
use crate::analysis::timing::TimingHandle;
use crate::audit_downloads::DownloadLogHandle;
use crate::audit::{AuditDownloadKind};
use crate::fetch::rsync::{RsyncFetchError, RsyncFetcher};
use crate::policy::{Policy, SyncPreference};
use crate::report::{RfcRef, Warning};
use crate::storage::RocksStore;
use crate::sync::rrdp::sync_from_notification_with_timing_and_download_log;
use crate::sync::rrdp::{Fetcher as HttpFetcher, RrdpSyncError};
use std::thread;
use std::time::Duration;
const RRDP_RETRY_BACKOFFS_PROD: [Duration; 3] = [
Duration::from_millis(200),
Duration::from_millis(500),
Duration::from_secs(1),
];
const RRDP_RETRY_BACKOFFS_TEST: [Duration; 2] =
[Duration::from_millis(0), Duration::from_millis(0)];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RepoSyncSource {
Rrdp,
Rsync,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RepoSyncResult {
pub source: RepoSyncSource,
pub objects_written: usize,
pub warnings: Vec<Warning>,
}
#[derive(Debug, thiserror::Error)]
pub enum RepoSyncError {
#[error("RRDP sync failed: {0}")]
Rrdp(#[from] RrdpSyncError),
#[error("rsync fallback failed: {0}")]
Rsync(#[from] RsyncFetchError),
#[error("storage error: {0}")]
Storage(String),
}
/// Sync a publication point into `raw_objects`.
///
/// v1 behavior:
/// - If `rrdp_notification_uri` is present and `policy.sync_preference` is `rrdp_then_rsync`,
/// try RRDP snapshot sync first (RFC 8182 §3.4.1-§3.4.3).
/// - On RRDP failure, fall back to rsync (RFC 8182 §3.4.5).
/// - If `sync_preference` is `rsync_only` or there is no RRDP URI, use rsync.
pub fn sync_publication_point(
store: &RocksStore,
policy: &Policy,
rrdp_notification_uri: Option<&str>,
rsync_base_uri: &str,
http_fetcher: &dyn HttpFetcher,
rsync_fetcher: &dyn RsyncFetcher,
timing: Option<&TimingHandle>,
download_log: Option<&DownloadLogHandle>,
) -> Result<RepoSyncResult, RepoSyncError> {
match (policy.sync_preference, rrdp_notification_uri) {
(SyncPreference::RrdpThenRsync, Some(notification_uri)) => {
match try_rrdp_sync_with_retry(
store,
notification_uri,
http_fetcher,
timing,
download_log,
) {
Ok(written) => {
if let Some(t) = timing.as_ref() {
t.record_count("repo_sync_rrdp_ok_total", 1);
t.record_count("repo_sync_rrdp_objects_written_total", written as u64);
}
Ok(RepoSyncResult {
source: RepoSyncSource::Rrdp,
objects_written: written,
warnings: Vec::new(),
})
}
Err(err) => {
if let Some(t) = timing.as_ref() {
t.record_count("repo_sync_rrdp_failed_total", 1);
}
let warnings = vec![
Warning::new(format!("RRDP failed; falling back to rsync: {err}"))
.with_rfc_refs(&[RfcRef("RFC 8182 §3.4.5")])
.with_context(notification_uri),
];
let written = rsync_sync_into_raw_objects(
store,
rsync_base_uri,
rsync_fetcher,
timing,
download_log,
)?;
if let Some(t) = timing.as_ref() {
t.record_count("repo_sync_rsync_fallback_ok_total", 1);
t.record_count("repo_sync_rsync_objects_written_total", written as u64);
}
Ok(RepoSyncResult {
source: RepoSyncSource::Rsync,
objects_written: written,
warnings,
})
}
}
}
_ => {
let written = rsync_sync_into_raw_objects(
store,
rsync_base_uri,
rsync_fetcher,
timing,
download_log,
)?;
if let Some(t) = timing.as_ref() {
t.record_count("repo_sync_rsync_direct_total", 1);
t.record_count("repo_sync_rsync_objects_written_total", written as u64);
}
Ok(RepoSyncResult {
source: RepoSyncSource::Rsync,
objects_written: written,
warnings: Vec::new(),
})
}
}
}
fn try_rrdp_sync(
store: &RocksStore,
notification_uri: &str,
http_fetcher: &dyn HttpFetcher,
timing: Option<&TimingHandle>,
download_log: Option<&DownloadLogHandle>,
) -> Result<usize, RrdpSyncError> {
let notification_xml = {
let _step = timing
.as_ref()
.map(|t| t.span_rrdp_repo_step(notification_uri, "fetch_notification"));
let _total = timing
.as_ref()
.map(|t| t.span_phase("rrdp_fetch_notification_total"));
let mut dl_span = download_log.map(|dl| {
dl.span_download(AuditDownloadKind::RrdpNotification, notification_uri)
});
match http_fetcher.fetch(notification_uri) {
Ok(v) => {
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_notification_fetch_ok_total", 1);
}
if let Some(s) = dl_span.as_mut() {
s.set_bytes(v.len() as u64);
s.set_ok();
}
v
}
Err(e) => {
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_notification_fetch_fail_total", 1);
}
if let Some(s) = dl_span.as_mut() {
s.set_err(e.clone());
}
return Err(RrdpSyncError::Fetch(e));
}
}
};
if let Some(t) = timing.as_ref() {
t.record_count(
"rrdp_notification_bytes_total",
notification_xml.len() as u64,
);
}
sync_from_notification_with_timing_and_download_log(
store,
notification_uri,
&notification_xml,
http_fetcher,
timing,
download_log,
)
}
fn is_retryable_http_fetch_error(msg: &str) -> bool {
if msg.contains("http request failed:") || msg.contains("http read body failed:") {
return true;
}
let Some(rest) = msg.strip_prefix("http status ") else {
return false;
};
let code = rest
.trim()
.split_whitespace()
.next()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(0);
code == 408 || code == 429 || (500..600).contains(&code)
}
fn rrdp_retry_backoffs() -> &'static [Duration] {
if cfg!(test) {
&RRDP_RETRY_BACKOFFS_TEST
} else {
&RRDP_RETRY_BACKOFFS_PROD
}
}
fn try_rrdp_sync_with_retry(
store: &RocksStore,
notification_uri: &str,
http_fetcher: &dyn HttpFetcher,
timing: Option<&TimingHandle>,
download_log: Option<&DownloadLogHandle>,
) -> Result<usize, RrdpSyncError> {
let backoffs = rrdp_retry_backoffs();
let max_attempts = backoffs.len().saturating_add(1).max(1);
let mut attempt: usize = 0;
loop {
attempt += 1;
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_retry_attempt_total", 1);
}
match try_rrdp_sync(store, notification_uri, http_fetcher, timing, download_log) {
Ok(written) => {
if attempt > 1 {
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_retry_success_total", 1);
}
}
return Ok(written);
}
Err(err) => {
let retryable = match &err {
RrdpSyncError::Fetch(msg) => is_retryable_http_fetch_error(msg),
_ => false,
};
if retryable && attempt < max_attempts {
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_retry_sleep_total", 1);
}
let backoff = backoffs
.get(attempt.saturating_sub(1))
.copied()
.unwrap_or_else(|| Duration::from_secs(0));
if !backoff.is_zero() {
thread::sleep(backoff);
}
continue;
}
if let Some(t) = timing.as_ref() {
match &err {
RrdpSyncError::Fetch(_) => t.record_count("rrdp_failed_fetch_total", 1),
RrdpSyncError::Rrdp(_) => t.record_count("rrdp_failed_protocol_total", 1),
RrdpSyncError::Storage(_) => t.record_count("rrdp_failed_storage_total", 1),
}
if retryable && attempt >= max_attempts && attempt > 1 {
t.record_count("rrdp_retry_exhausted_total", 1);
}
}
return Err(err);
}
}
}
}
fn rsync_sync_into_raw_objects(
store: &RocksStore,
rsync_base_uri: &str,
rsync_fetcher: &dyn RsyncFetcher,
timing: Option<&TimingHandle>,
download_log: Option<&DownloadLogHandle>,
) -> Result<usize, RepoSyncError> {
let _s = timing
.as_ref()
.map(|t| t.span_rrdp_repo_step(rsync_base_uri, "rsync_fetch_objects"));
let _p = timing.as_ref().map(|t| t.span_phase("rsync_fetch_total"));
let mut dl_span =
download_log.map(|dl| dl.span_download(AuditDownloadKind::Rsync, rsync_base_uri));
let objects = match rsync_fetcher.fetch_objects(rsync_base_uri) {
Ok(v) => {
let bytes_total: u64 = v.iter().map(|(_u, b)| b.len() as u64).sum::<u64>();
if let Some(s) = dl_span.as_mut() {
s.set_objects(v.len() as u64, bytes_total);
s.set_bytes(bytes_total);
s.set_ok();
}
v
}
Err(e) => {
if let Some(s) = dl_span.as_mut() {
s.set_err(e.to_string());
}
return Err(e.into());
}
};
if let Some(t) = timing.as_ref() {
t.record_count("rsync_objects_fetched_total", objects.len() as u64);
let bytes_total: u64 = objects.iter().map(|(_u, b)| b.len() as u64).sum::<u64>();
t.record_count("rsync_objects_bytes_total", bytes_total);
}
drop(_p);
let _w = timing
.as_ref()
.map(|t| t.span_phase("rsync_write_raw_objects_total"));
store
.put_raw_batch(objects)
.map_err(|e| RepoSyncError::Storage(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis::timing::{TimingHandle, TimingMeta};
use crate::fetch::rsync::LocalDirRsyncFetcher;
use crate::sync::rrdp::Fetcher as HttpFetcher;
use crate::sync::rrdp::RrdpState;
use base64::Engine;
use sha2::Digest;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
struct DummyHttpFetcher;
impl HttpFetcher for DummyHttpFetcher {
fn fetch(&self, _url: &str) -> Result<Vec<u8>, String> {
panic!("http fetcher must not be used in rsync-only mode")
}
}
struct PanicRsyncFetcher;
impl RsyncFetcher for PanicRsyncFetcher {
fn fetch_objects(
&self,
_rsync_base_uri: &str,
) -> Result<Vec<(String, Vec<u8>)>, RsyncFetchError> {
panic!("rsync must not be used in this test")
}
}
struct MapFetcher {
map: HashMap<String, Vec<u8>>,
}
impl HttpFetcher for MapFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
self.map
.get(uri)
.cloned()
.ok_or_else(|| format!("not found: {uri}"))
}
}
fn notification_xml(
session_id: &str,
serial: u64,
snapshot_uri: &str,
snapshot_hash: &str,
) -> Vec<u8> {
format!(
r#"<notification xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{session_id}" serial="{serial}"><snapshot uri="{snapshot_uri}" hash="{snapshot_hash}"/></notification>"#
)
.into_bytes()
}
fn snapshot_xml(session_id: &str, serial: u64, published: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = format!(
r#"<snapshot xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{session_id}" serial="{serial}">"#
);
for (uri, bytes) in published {
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
out.push_str(&format!(r#"<publish uri="{uri}">{b64}</publish>"#));
}
out.push_str("</snapshot>");
out.into_bytes()
}
fn timing_to_json(temp_dir: &std::path::Path, timing: &TimingHandle) -> serde_json::Value {
let timing_path = temp_dir.join("timing_retry.json");
timing.write_json(&timing_path, 50).expect("write json");
serde_json::from_slice(&std::fs::read(&timing_path).expect("read json"))
.expect("parse json")
}
#[test]
fn rsync_sync_writes_raw_objects_with_batch_and_records_counts() {
let temp = tempfile::tempdir().expect("tempdir");
let repo_dir = temp.path().join("repo");
std::fs::create_dir_all(repo_dir.join("sub")).expect("mkdir");
std::fs::write(repo_dir.join("a.mft"), b"mft").expect("write");
std::fs::write(repo_dir.join("sub").join("b.roa"), b"roa").expect("write");
std::fs::write(repo_dir.join("sub").join("c.cer"), b"cer").expect("write");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let timing = TimingHandle::new(TimingMeta {
recorded_at_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
validation_time_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
tal_url: None,
db_path: Some(store_dir.to_string_lossy().into_owned()),
});
let policy = Policy {
sync_preference: SyncPreference::RsyncOnly,
..Policy::default()
};
let http = DummyHttpFetcher;
let rsync = LocalDirRsyncFetcher::new(&repo_dir);
let download_log = DownloadLogHandle::new();
let out = sync_publication_point(
&store,
&policy,
None,
"rsync://example.test/repo/",
&http,
&rsync,
Some(&timing),
Some(&download_log),
)
.expect("sync ok");
assert_eq!(out.source, RepoSyncSource::Rsync);
assert_eq!(out.objects_written, 3);
let events = download_log.snapshot_events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, AuditDownloadKind::Rsync);
assert!(events[0].success);
assert_eq!(events[0].bytes, Some(9));
let objects = events[0].objects.as_ref().expect("objects stat");
assert_eq!(objects.objects_count, 3);
assert_eq!(objects.objects_bytes_total, 9);
assert_eq!(
store.get_raw("rsync://example.test/repo/a.mft").unwrap(),
Some(b"mft".to_vec())
);
assert_eq!(
store
.get_raw("rsync://example.test/repo/sub/b.roa")
.unwrap(),
Some(b"roa".to_vec())
);
assert_eq!(
store
.get_raw("rsync://example.test/repo/sub/c.cer")
.unwrap(),
Some(b"cer".to_vec())
);
let timing_path = temp.path().join("timing.json");
timing.write_json(&timing_path, 5).expect("write json");
let v: serde_json::Value =
serde_json::from_slice(&std::fs::read(&timing_path).expect("read json"))
.expect("parse json");
let counts = v.get("counts").expect("counts");
assert_eq!(
counts
.get("rsync_objects_fetched_total")
.and_then(|v| v.as_u64()),
Some(3)
);
assert_eq!(
counts
.get("rsync_objects_bytes_total")
.and_then(|v| v.as_u64()),
Some(3 * 3)
);
}
#[test]
fn rrdp_retry_succeeds_without_rsync_when_notification_fetch_is_transient() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let timing = TimingHandle::new(TimingMeta {
recorded_at_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
validation_time_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
tal_url: None,
db_path: Some(store_dir.to_string_lossy().into_owned()),
});
let notification_uri = "https://example.test/notification.xml";
let snapshot_uri = "https://example.test/snapshot.xml";
let published_uri = "rsync://example.test/repo/a.mft";
let published_bytes = b"x";
let snapshot = snapshot_xml(
"9df4b597-af9e-4dca-bdda-719cce2c4e28",
1,
&[(published_uri, published_bytes)],
);
let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot));
let notif = notification_xml(
"9df4b597-af9e-4dca-bdda-719cce2c4e28",
1,
snapshot_uri,
&snapshot_hash,
);
let mut map = HashMap::new();
map.insert(notification_uri.to_string(), notif);
map.insert(snapshot_uri.to_string(), snapshot);
struct RetryThenMap {
inner: MapFetcher,
notification_uri: String,
fail_times: usize,
notification_calls: AtomicUsize,
}
impl HttpFetcher for RetryThenMap {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
if uri == self.notification_uri {
let n = self.notification_calls.fetch_add(1, Ordering::SeqCst);
if n < self.fail_times {
return Err("http request failed: simulated transient".to_string());
}
}
self.inner.fetch(uri)
}
}
let http = RetryThenMap {
inner: MapFetcher { map },
notification_uri: notification_uri.to_string(),
fail_times: 2,
notification_calls: AtomicUsize::new(0),
};
let policy = Policy {
sync_preference: SyncPreference::RrdpThenRsync,
..Policy::default()
};
let download_log = DownloadLogHandle::new();
let out = sync_publication_point(
&store,
&policy,
Some(notification_uri),
"rsync://example.test/repo/",
&http,
&PanicRsyncFetcher,
Some(&timing),
Some(&download_log),
)
.expect("sync ok");
assert_eq!(out.source, RepoSyncSource::Rrdp);
assert_eq!(
store.get_raw(published_uri).unwrap(),
Some(published_bytes.to_vec())
);
let events = download_log.snapshot_events();
assert_eq!(events.len(), 4, "expected 3x notification + 1x snapshot");
assert_eq!(
events
.iter()
.filter(|e| e.kind == AuditDownloadKind::RrdpNotification)
.count(),
3
);
assert_eq!(
events
.iter()
.filter(|e| e.kind == AuditDownloadKind::RrdpSnapshot)
.count(),
1
);
assert_eq!(
events
.iter()
.filter(|e| e.kind == AuditDownloadKind::RrdpNotification && !e.success)
.count(),
2
);
let v = timing_to_json(temp.path(), &timing);
let counts = v.get("counts").expect("counts");
assert_eq!(
counts
.get("rrdp_retry_attempt_total")
.and_then(|v| v.as_u64()),
Some(3)
);
assert_eq!(
counts
.get("rrdp_retry_success_total")
.and_then(|v| v.as_u64()),
Some(1)
);
assert_eq!(
counts.get("repo_sync_rrdp_ok_total").and_then(|v| v.as_u64()),
Some(1)
);
}
#[test]
fn rrdp_protocol_error_does_not_retry_and_falls_back_to_rsync() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let timing = TimingHandle::new(TimingMeta {
recorded_at_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
validation_time_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
tal_url: None,
db_path: Some(store_dir.to_string_lossy().into_owned()),
});
let notification_uri = "https://example.test/notification.xml";
let snapshot_uri = "https://example.test/snapshot.xml";
let published_uri = "rsync://example.test/repo/a.mft";
let published_bytes = b"x";
let snapshot = snapshot_xml(
"9df4b597-af9e-4dca-bdda-719cce2c4e28",
1,
&[(published_uri, published_bytes)],
);
// Intentionally wrong hash to trigger protocol error (SnapshotHashMismatch).
let wrong_hash = "00".repeat(32);
let notif = notification_xml(
"9df4b597-af9e-4dca-bdda-719cce2c4e28",
1,
snapshot_uri,
&wrong_hash,
);
let mut map = HashMap::new();
map.insert(notification_uri.to_string(), notif);
map.insert(snapshot_uri.to_string(), snapshot);
let http = MapFetcher { map };
struct EmptyRsyncFetcher;
impl RsyncFetcher for EmptyRsyncFetcher {
fn fetch_objects(
&self,
_rsync_base_uri: &str,
) -> Result<Vec<(String, Vec<u8>)>, RsyncFetchError> {
Ok(Vec::new())
}
}
let policy = Policy {
sync_preference: SyncPreference::RrdpThenRsync,
..Policy::default()
};
let download_log = DownloadLogHandle::new();
let out = sync_publication_point(
&store,
&policy,
Some(notification_uri),
"rsync://example.test/repo/",
&http,
&EmptyRsyncFetcher,
Some(&timing),
Some(&download_log),
)
.expect("sync ok");
assert_eq!(out.source, RepoSyncSource::Rsync);
assert!(
out.warnings
.iter()
.any(|w| w.message.contains("RRDP failed; falling back to rsync")),
"expected RRDP fallback warning"
);
let events = download_log.snapshot_events();
assert_eq!(events.len(), 3, "expected notification + snapshot + rsync fallback");
assert_eq!(events[0].kind, AuditDownloadKind::RrdpNotification);
assert!(events[0].success);
assert_eq!(events[1].kind, AuditDownloadKind::RrdpSnapshot);
assert!(events[1].success);
assert_eq!(events[2].kind, AuditDownloadKind::Rsync);
assert!(events[2].success);
let v = timing_to_json(temp.path(), &timing);
let counts = v.get("counts").expect("counts");
assert_eq!(
counts
.get("rrdp_retry_attempt_total")
.and_then(|v| v.as_u64()),
Some(1)
);
assert_eq!(
counts
.get("rrdp_failed_protocol_total")
.and_then(|v| v.as_u64()),
Some(1)
);
assert_eq!(
counts
.get("repo_sync_rrdp_failed_total")
.and_then(|v| v.as_u64()),
Some(1)
);
assert_eq!(
counts
.get("repo_sync_rsync_fallback_ok_total")
.and_then(|v| v.as_u64()),
Some(1)
);
}
#[test]
fn rrdp_delta_fetches_are_logged_even_if_snapshot_fallback_is_used() {
let temp = tempfile::tempdir().expect("tempdir");
let store_dir = temp.path().join("db");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let timing = TimingHandle::new(TimingMeta {
recorded_at_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
validation_time_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
tal_url: None,
db_path: Some(store_dir.to_string_lossy().into_owned()),
});
let notification_uri = "https://example.test/notification.xml";
let snapshot_uri = "https://example.test/snapshot.xml";
let delta_2_uri = "https://example.test/delta_2.xml";
let delta_3_uri = "https://example.test/delta_3.xml";
let published_uri = "rsync://example.test/repo/a.mft";
let published_bytes = b"x";
let sid = "9df4b597-af9e-4dca-bdda-719cce2c4e28";
// Seed old RRDP state so sync_from_notification tries deltas (RFC 8182 §3.4.1).
let state = RrdpState {
session_id: sid.to_string(),
serial: 1,
};
let state_bytes = state.encode().expect("encode state");
store
.put_rrdp_state(notification_uri, &state_bytes)
.expect("seed state");
let delta_2 = format!(
r#"<delta xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{sid}" serial="2"></delta>"#
)
.into_bytes();
let delta_3 = format!(
r#"<delta xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{sid}" serial="3"></delta>"#
)
.into_bytes();
let delta_2_hash = hex::encode(sha2::Sha256::digest(&delta_2));
let delta_3_hash = hex::encode(sha2::Sha256::digest(&delta_3));
let snapshot = snapshot_xml(sid, 3, &[(published_uri, published_bytes)]);
let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot));
let notif = format!(
r#"<notification xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{sid}" serial="3"><snapshot uri="{snapshot_uri}" hash="{snapshot_hash}"/><delta serial="2" uri="{delta_2_uri}" hash="{delta_2_hash}"/><delta serial="3" uri="{delta_3_uri}" hash="{delta_3_hash}"/></notification>"#
)
.into_bytes();
let mut map = HashMap::new();
map.insert(notification_uri.to_string(), notif);
map.insert(snapshot_uri.to_string(), snapshot);
map.insert(delta_2_uri.to_string(), delta_2);
map.insert(delta_3_uri.to_string(), delta_3);
let http = MapFetcher { map };
let policy = Policy {
sync_preference: SyncPreference::RrdpThenRsync,
..Policy::default()
};
let download_log = DownloadLogHandle::new();
let out = sync_publication_point(
&store,
&policy,
Some(notification_uri),
"rsync://example.test/repo/",
&http,
&PanicRsyncFetcher,
Some(&timing),
Some(&download_log),
)
.expect("sync ok");
assert_eq!(out.source, RepoSyncSource::Rrdp);
assert_eq!(out.objects_written, 1);
assert_eq!(
store.get_raw(published_uri).unwrap(),
Some(published_bytes.to_vec())
);
let events = download_log.snapshot_events();
assert_eq!(events.len(), 4);
assert_eq!(
events
.iter()
.filter(|e| e.kind == AuditDownloadKind::RrdpNotification)
.count(),
1
);
assert_eq!(
events
.iter()
.filter(|e| e.kind == AuditDownloadKind::RrdpDelta)
.count(),
2
);
assert_eq!(
events
.iter()
.filter(|e| e.kind == AuditDownloadKind::RrdpSnapshot)
.count(),
1
);
assert!(events.iter().all(|e| e.success));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,123 +0,0 @@
use crate::data_model::oid::{OID_AD_CA_REPOSITORY, OID_AD_RPKI_MANIFEST, OID_AD_RPKI_NOTIFY};
use crate::data_model::rc::{ResourceCertKind, ResourceCertificate, SubjectInfoAccess};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CaInstanceUris {
/// CA repository base rsync URI (must end with `/`).
pub rsync_base_uri: String,
/// rsync URI for the manifest object (`.mft`).
pub manifest_rsync_uri: String,
/// Publication point rsync URI (RFC 9286 terminology). In v1 this equals `rsync_base_uri`.
pub publication_point_rsync_uri: String,
/// Optional RRDP notification URI (https).
pub rrdp_notification_uri: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum CaInstanceUrisError {
#[error("certificate must be a CA certificate (RFC 6487 §4.8.1)")]
NotCa,
#[error(
"CA certificate must contain Subject Information Access extension (RFC 6487 §4.8.8; RFC 5280 §4.2.2.2)"
)]
MissingSia,
#[error("CA certificate SIA must contain id-ad-caRepository rsync URI (RFC 6487 §4.8.8.1)")]
MissingCaRepository,
#[error("CA certificate SIA must contain id-ad-rpkiManifest rsync URI (RFC 6487 §4.8.8.2)")]
MissingRpkiManifest,
#[error(
"SIA id-ad-caRepository accessLocation must be rsync:// URI, got {0} (RFC 6487 §4.8.8.1)"
)]
CaRepositoryNotRsync(String),
#[error(
"SIA id-ad-rpkiManifest accessLocation must be rsync:// URI, got {0} (RFC 6487 §4.8.8.2)"
)]
RpkiManifestNotRsync(String),
#[error(
"SIA id-ad-rpkiNotify accessLocation must be https:// URI, got {0} (RFC 8182 §3.4.1; RFC 6487 §4.8.8.3)"
)]
RpkiNotifyNotHttps(String),
#[error(
"manifest rsync URI must be under CA publication point: manifest={manifest_rsync_uri} publication_point={publication_point_rsync_uri} (RFC 9286 §6.1)"
)]
ManifestNotUnderPublicationPoint {
manifest_rsync_uri: String,
publication_point_rsync_uri: String,
},
}
pub fn ca_instance_uris_from_ca_certificate(
cert: &ResourceCertificate,
) -> Result<CaInstanceUris, CaInstanceUrisError> {
if cert.kind != ResourceCertKind::Ca {
return Err(CaInstanceUrisError::NotCa);
}
let sia = cert
.tbs
.extensions
.subject_info_access
.as_ref()
.ok_or(CaInstanceUrisError::MissingSia)?;
let access_descriptions = match sia {
SubjectInfoAccess::Ca(ca) => &ca.access_descriptions,
SubjectInfoAccess::Ee(_ee) => return Err(CaInstanceUrisError::MissingSia),
};
let mut ca_repo: Option<String> = None;
let mut manifest: Option<String> = None;
let mut notify: Option<String> = None;
for ad in access_descriptions {
if ad.access_method_oid == OID_AD_CA_REPOSITORY {
let u = ad.access_location.as_str();
if !u.starts_with("rsync://") {
return Err(CaInstanceUrisError::CaRepositoryNotRsync(u.to_string()));
}
ca_repo.get_or_insert(u.to_string());
} else if ad.access_method_oid == OID_AD_RPKI_MANIFEST {
let u = ad.access_location.as_str();
if !u.starts_with("rsync://") {
return Err(CaInstanceUrisError::RpkiManifestNotRsync(u.to_string()));
}
manifest.get_or_insert(u.to_string());
} else if ad.access_method_oid == OID_AD_RPKI_NOTIFY {
let u = ad.access_location.as_str();
if !u.starts_with("https://") {
return Err(CaInstanceUrisError::RpkiNotifyNotHttps(u.to_string()));
}
notify.get_or_insert(u.to_string());
}
}
let mut publication_point_rsync_uri =
ca_repo.ok_or(CaInstanceUrisError::MissingCaRepository)?;
if !publication_point_rsync_uri.ends_with('/') {
publication_point_rsync_uri.push('/');
}
let rsync_base_uri = publication_point_rsync_uri.clone();
let manifest_rsync_uri = manifest.ok_or(CaInstanceUrisError::MissingRpkiManifest)?;
if !manifest_rsync_uri.starts_with(&publication_point_rsync_uri) {
return Err(CaInstanceUrisError::ManifestNotUnderPublicationPoint {
manifest_rsync_uri,
publication_point_rsync_uri,
});
}
Ok(CaInstanceUris {
rsync_base_uri,
manifest_rsync_uri,
publication_point_rsync_uri,
rrdp_notification_uri: notify,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,567 +0,0 @@
use crate::data_model::common::BigUnsigned;
use crate::data_model::crl::{CrlDecodeError, CrlVerifyError, RpkixCrl};
use crate::data_model::rc::{
ResourceCertKind, ResourceCertificate, ResourceCertificateDecodeError,
};
use x509_parser::prelude::{FromDer, X509Certificate};
use crate::validation::x509_name::x509_names_equivalent;
use std::collections::HashSet;
use x509_parser::x509::SubjectPublicKeyInfo;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ValidatedEeCertPath {
pub ee: ResourceCertificate,
pub issuer_ca: ResourceCertificate,
pub issuer_crl: RpkixCrl,
}
#[derive(Debug, thiserror::Error)]
pub enum CertPathError {
#[error("EE certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
EeDecode(#[from] ResourceCertificateDecodeError),
#[error("issuer CA certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
IssuerDecode(ResourceCertificateDecodeError),
#[error("issuer CRL decode failed: {0} (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1)")]
CrlDecode(#[from] CrlDecodeError),
#[error(
"issuer certificate must be a CA resource certificate (RFC 6487 §4.8.1; RFC 5280 §4.2.1.9)"
)]
IssuerNotCa,
#[error("EE certificate must be an EE resource certificate (RFC 6487 §4)")]
EeNotEe,
#[error(
"EE issuer DN does not match issuer CA subject DN: ee.issuer={ee_issuer_dn} issuer.subject={issuer_subject_dn} (RFC 5280 §6.1)"
)]
IssuerSubjectMismatch {
ee_issuer_dn: String,
issuer_subject_dn: String,
},
#[error("EE certificate signature verification failed: {0} (RFC 5280 §6.1)")]
EeSignatureInvalid(String),
#[error("issuer SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7)")]
IssuerSpkiParse(String),
#[error(
"trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 5280 §4.1.2.7)"
)]
IssuerSpkiTrailingBytes(usize),
#[error("EE KeyUsage extension missing (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
KeyUsageMissing,
#[error("EE KeyUsage criticality must be critical (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
KeyUsageNotCritical,
#[error("EE KeyUsage must have only digitalSignature set (RFC 6487 §4.8.4)")]
KeyUsageInvalidBits,
#[error("issuer CA subjectKeyIdentifier missing (RFC 6487 §4.8.2)")]
IssuerSkiMissing,
#[error("EE authorityKeyIdentifier missing (RFC 6487 §4.8.3; RFC 5280 §4.2.1.1)")]
EeAkiMissing,
#[error(
"EE authorityKeyIdentifier does not match issuer subjectKeyIdentifier (RFC 6487 §4.8.3)"
)]
EeAkiMismatch,
#[error("EE authorityInfoAccess missing (RFC 6487 §4.8.7; RFC 5280 §4.2.2.1)")]
EeAiaMissing,
#[error(
"EE authorityInfoAccess does not reference issuer certificate rsync URI (RFC 6487 §4.8.7)"
)]
EeAiaIssuerUriMismatch,
#[error("EE CRLDistributionPoints missing (RFC 6487 §4.8.6; RFC 5280 §4.2.1.13)")]
EeCrlDpMissing,
#[error("EE CRLDistributionPoints does not reference issuer CRL rsync URI (RFC 6487 §4.8.6)")]
EeCrlDpUriMismatch,
#[error("certificate not valid at validation_time (RFC 5280 §4.1.2.5; RFC 5280 §6.1)")]
CertificateNotValidAtTime,
#[error(
"CRL signature/binding verification failed: {0} (RFC 5280 §6.3.3; RFC 6487 §5; RFC 9829 §3.1)"
)]
CrlVerify(#[from] CrlVerifyError),
#[error(
"CRL not valid at validation_time (RFC 5280 §6.3.3(g); RFC 5280 §5.1.2.4-§5.1.2.5; RFC 6487 §5)"
)]
CrlNotValidAtTime,
#[error("EE certificate is revoked by issuer CRL (RFC 5280 §6.3.3; RFC 6487 §5)")]
EeRevoked,
}
pub fn validate_ee_cert_path(
ee_cert_der: &[u8],
issuer_ca_der: &[u8],
issuer_crl_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<ValidatedEeCertPath, CertPathError> {
let ee = ResourceCertificate::decode_der(ee_cert_der)?;
if ee.kind != ResourceCertKind::Ee {
return Err(CertPathError::EeNotEe);
}
let issuer_ca =
ResourceCertificate::decode_der(issuer_ca_der).map_err(CertPathError::IssuerDecode)?;
if issuer_ca.kind != ResourceCertKind::Ca {
return Err(CertPathError::IssuerNotCa);
}
let issuer_spki = parse_subject_pki_from_der(&issuer_ca.tbs.subject_public_key_info)?;
if !x509_names_equivalent(&ee.tbs.issuer_name, &issuer_ca.tbs.subject_name) {
return Err(CertPathError::IssuerSubjectMismatch {
ee_issuer_dn: ee.tbs.issuer_name.to_string(),
issuer_subject_dn: issuer_ca.tbs.subject_name.to_string(),
});
}
validate_ee_aki_matches_issuer_ski(&ee, &issuer_ca)?;
if let Some(expected_issuer_uri) = issuer_ca_rsync_uri {
validate_ee_aia_points_to_issuer_uri(&ee, expected_issuer_uri)?;
}
if let Some(expected_crl_uri) = issuer_crl_rsync_uri {
validate_ee_crldp_contains_issuer_crl_uri(&ee, expected_crl_uri)?;
}
if !time_within_validity(
validation_time,
ee.tbs.validity_not_before,
ee.tbs.validity_not_after,
) || !time_within_validity(
validation_time,
issuer_ca.tbs.validity_not_before,
issuer_ca.tbs.validity_not_after,
) {
return Err(CertPathError::CertificateNotValidAtTime);
}
let ee_x509 = parse_x509_cert(ee_cert_der)?;
verify_ee_cert_signature(&ee_x509, &issuer_spki)?;
validate_ee_key_usage(&ee_x509)?;
let issuer_crl = RpkixCrl::decode_der(issuer_crl_der)?;
issuer_crl.verify_signature_with_issuer_certificate_der(issuer_ca_der)?;
if !crl_valid_at_time(&issuer_crl, validation_time) {
return Err(CertPathError::CrlNotValidAtTime);
}
if is_serial_revoked_by_crl(&ee, &issuer_crl) {
return Err(CertPathError::EeRevoked);
}
Ok(ValidatedEeCertPath {
ee,
issuer_ca,
issuer_crl,
})
}
/// Validate the EE certificate path using a *pre-decoded issuer CA* and a *pre-decoded and
/// pre-verified issuer CRL*.
///
/// This is a performance-oriented helper for stage2 serial runs: it avoids repeating issuer CA
/// decode and issuer CRL decode+signature verification for every signed object in a publication point.
///
/// The caller must ensure:
/// - `issuer_ca` corresponds to `issuer_ca_der`
/// - `issuer_crl` cryptographic signature/binding has already been verified against `issuer_ca_der`
pub fn validate_ee_cert_path_with_prevalidated_issuer(
ee_cert_der: &[u8],
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_crl: &RpkixCrl,
issuer_crl_revoked_serials: &HashSet<Vec<u8>>,
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<ResourceCertificate, CertPathError> {
let ee = ResourceCertificate::decode_der(ee_cert_der)?;
if ee.kind != ResourceCertKind::Ee {
return Err(CertPathError::EeNotEe);
}
if issuer_ca.kind != ResourceCertKind::Ca {
return Err(CertPathError::IssuerNotCa);
}
if !x509_names_equivalent(&ee.tbs.issuer_name, &issuer_ca.tbs.subject_name) {
return Err(CertPathError::IssuerSubjectMismatch {
ee_issuer_dn: ee.tbs.issuer_name.to_string(),
issuer_subject_dn: issuer_ca.tbs.subject_name.to_string(),
});
}
validate_ee_aki_matches_issuer_ski(&ee, issuer_ca)?;
if let Some(expected_issuer_uri) = issuer_ca_rsync_uri {
validate_ee_aia_points_to_issuer_uri(&ee, expected_issuer_uri)?;
}
if let Some(expected_crl_uri) = issuer_crl_rsync_uri {
validate_ee_crldp_contains_issuer_crl_uri(&ee, expected_crl_uri)?;
}
if !time_within_validity(
validation_time,
ee.tbs.validity_not_before,
ee.tbs.validity_not_after,
) || !time_within_validity(
validation_time,
issuer_ca.tbs.validity_not_before,
issuer_ca.tbs.validity_not_after,
) {
return Err(CertPathError::CertificateNotValidAtTime);
}
let ee_x509 = parse_x509_cert(ee_cert_der)?;
verify_ee_cert_signature(&ee_x509, issuer_spki)?;
validate_ee_key_usage(&ee_x509)?;
if !crl_valid_at_time(issuer_crl, validation_time) {
return Err(CertPathError::CrlNotValidAtTime);
}
let serial = BigUnsigned::from_biguint(&ee.tbs.serial_number);
if issuer_crl_revoked_serials.contains(&serial.bytes_be) {
return Err(CertPathError::EeRevoked);
}
Ok(ee)
}
fn parse_subject_pki_from_der(der: &[u8]) -> Result<SubjectPublicKeyInfo<'_>, CertPathError> {
let (rem, spki) = SubjectPublicKeyInfo::from_der(der)
.map_err(|e| CertPathError::IssuerSpkiParse(e.to_string()))?;
if !rem.is_empty() {
return Err(CertPathError::IssuerSpkiTrailingBytes(rem.len()));
}
Ok(spki)
}
fn parse_x509_cert(der: &[u8]) -> Result<X509Certificate<'_>, CertPathError> {
let (rem, cert) = X509Certificate::from_der(der)
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))?;
if !rem.is_empty() {
return Err(CertPathError::EeSignatureInvalid(
"trailing bytes after EE certificate".to_string(),
));
}
Ok(cert)
}
fn verify_ee_cert_signature(
ee: &X509Certificate<'_>,
issuer_spki: &SubjectPublicKeyInfo<'_>,
) -> Result<(), CertPathError> {
ee.verify_signature(Some(issuer_spki))
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
}
fn validate_ee_aki_matches_issuer_ski(
ee: &ResourceCertificate,
issuer_ca: &ResourceCertificate,
) -> Result<(), CertPathError> {
let Some(issuer_ski) = issuer_ca.tbs.extensions.subject_key_identifier.as_deref() else {
return Err(CertPathError::IssuerSkiMissing);
};
let Some(ee_aki) = ee.tbs.extensions.authority_key_identifier.as_deref() else {
return Err(CertPathError::EeAkiMissing);
};
if ee_aki != issuer_ski {
return Err(CertPathError::EeAkiMismatch);
}
Ok(())
}
fn validate_ee_aia_points_to_issuer_uri(
ee: &ResourceCertificate,
issuer_ca_rsync_uri: &str,
) -> Result<(), CertPathError> {
let Some(uris) = ee.tbs.extensions.ca_issuers_uris.as_ref() else {
return Err(CertPathError::EeAiaMissing);
};
if !uris.iter().any(|u| u.as_str() == issuer_ca_rsync_uri) {
return Err(CertPathError::EeAiaIssuerUriMismatch);
}
Ok(())
}
fn validate_ee_crldp_contains_issuer_crl_uri(
ee: &ResourceCertificate,
issuer_crl_rsync_uri: &str,
) -> Result<(), CertPathError> {
let Some(uris) = ee.tbs.extensions.crl_distribution_points_uris.as_ref() else {
return Err(CertPathError::EeCrlDpMissing);
};
if !uris.iter().any(|u| u.as_str() == issuer_crl_rsync_uri) {
return Err(CertPathError::EeCrlDpUriMismatch);
}
Ok(())
}
fn validate_ee_key_usage(cert: &X509Certificate<'_>) -> Result<(), CertPathError> {
let mut ku_critical: Option<bool> = None;
for ext in cert.extensions() {
if ext.oid.as_bytes() == crate::data_model::oid::OID_KEY_USAGE_RAW {
ku_critical = Some(ext.critical);
break;
}
}
let Some(critical) = ku_critical else {
return Err(CertPathError::KeyUsageMissing);
};
if !critical {
return Err(CertPathError::KeyUsageNotCritical);
}
let Some(ku) = cert
.key_usage()
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))?
else {
return Err(CertPathError::KeyUsageMissing);
};
let v = &ku.value;
let ok = v.digital_signature()
&& !v.key_cert_sign()
&& !v.crl_sign()
&& !v.non_repudiation()
&& !v.key_encipherment()
&& !v.data_encipherment()
&& !v.key_agreement()
&& !v.encipher_only()
&& !v.decipher_only();
if !ok {
return Err(CertPathError::KeyUsageInvalidBits);
}
Ok(())
}
fn time_within_validity(
t: time::OffsetDateTime,
not_before: time::OffsetDateTime,
not_after: time::OffsetDateTime,
) -> bool {
let t = t.to_offset(time::UtcOffset::UTC);
let not_before = not_before.to_offset(time::UtcOffset::UTC);
let not_after = not_after.to_offset(time::UtcOffset::UTC);
t >= not_before && t <= not_after
}
fn crl_valid_at_time(crl: &RpkixCrl, t: time::OffsetDateTime) -> bool {
let t = t.to_offset(time::UtcOffset::UTC);
let this_update = crl.this_update.utc.to_offset(time::UtcOffset::UTC);
let next_update = crl.next_update.utc.to_offset(time::UtcOffset::UTC);
t >= this_update && t < next_update
}
fn is_serial_revoked_by_crl(ee: &ResourceCertificate, crl: &RpkixCrl) -> bool {
let serial = BigUnsigned::from_biguint(&ee.tbs.serial_number);
crl.revoked_certs
.iter()
.any(|rc| rc.serial_number == serial)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_model::common::X509NameDer;
use crate::data_model::rc::{
RcExtensions, ResourceCertKind, ResourceCertificate, RpkixTbsCertificate,
};
use der_parser::num_bigint::BigUint;
fn dummy_cert(
kind: ResourceCertKind,
subject_dn: &str,
issuer_dn: &str,
ski: Option<Vec<u8>>,
aki: Option<Vec<u8>>,
aia: Option<Vec<&str>>,
crldp: Option<Vec<&str>>,
) -> ResourceCertificate {
let aia = aia.map(|v| v.into_iter().map(|s| s.to_string()).collect::<Vec<_>>());
let crldp = crldp.map(|v| v.into_iter().map(|s| s.to_string()).collect::<Vec<_>>());
ResourceCertificate {
raw_der: Vec::new(),
kind,
tbs: RpkixTbsCertificate {
version: 2,
serial_number: BigUint::from(1u8),
signature_algorithm: "1.2.840.113549.1.1.11".to_string(),
issuer_name: X509NameDer(issuer_dn.as_bytes().to_vec()),
subject_name: X509NameDer(subject_dn.as_bytes().to_vec()),
validity_not_before: time::OffsetDateTime::UNIX_EPOCH,
validity_not_after: time::OffsetDateTime::UNIX_EPOCH,
subject_public_key_info: Vec::new(),
extensions: RcExtensions {
basic_constraints_ca: kind == ResourceCertKind::Ca,
subject_key_identifier: ski,
authority_key_identifier: aki,
crl_distribution_points_uris: crldp,
ca_issuers_uris: aia,
subject_info_access: None,
certificate_policies_oid: None,
ip_resources: None,
as_resources: None,
},
},
}
}
#[test]
fn ee_aki_and_aia_and_crldp_binding_checks_report_errors() {
let issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let ee = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![9]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_ee_aki_matches_issuer_ski(&ee, &issuer).unwrap_err();
assert!(matches!(err, CertPathError::EeAkiMismatch), "{err}");
let issuer_missing_ski = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
None,
None,
None,
None,
);
let err = validate_ee_aki_matches_issuer_ski(&ee, &issuer_missing_ski).unwrap_err();
assert!(matches!(err, CertPathError::IssuerSkiMissing), "{err}");
let ee_missing_aki = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
None,
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_ee_aki_matches_issuer_ski(&ee_missing_aki, &issuer).unwrap_err();
assert!(matches!(err, CertPathError::EeAkiMissing), "{err}");
let ee_missing_aia = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
None,
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_ee_aia_points_to_issuer_uri(
&ee_missing_aia,
"rsync://example.test/issuer.cer",
)
.unwrap_err();
assert!(matches!(err, CertPathError::EeAiaMissing), "{err}");
let ee_wrong_aia = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/other.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err =
validate_ee_aia_points_to_issuer_uri(&ee_wrong_aia, "rsync://example.test/issuer.cer")
.unwrap_err();
assert!(
matches!(err, CertPathError::EeAiaIssuerUriMismatch),
"{err}"
);
let ee_missing_crldp = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
None,
);
let err = validate_ee_crldp_contains_issuer_crl_uri(
&ee_missing_crldp,
"rsync://example.test/issuer.crl",
)
.unwrap_err();
assert!(matches!(err, CertPathError::EeCrlDpMissing), "{err}");
let ee_wrong_crldp = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/other.crl"]),
);
let err = validate_ee_crldp_contains_issuer_crl_uri(
&ee_wrong_crldp,
"rsync://example.test/issuer.crl",
)
.unwrap_err();
assert!(matches!(err, CertPathError::EeCrlDpUriMismatch), "{err}");
}
#[test]
fn ee_binding_checks_accept_when_matching() {
let issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let ee = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
validate_ee_aki_matches_issuer_ski(&ee, &issuer).expect("aki ok");
validate_ee_aia_points_to_issuer_uri(&ee, "rsync://example.test/issuer.cer")
.expect("aia ok");
validate_ee_crldp_contains_issuer_crl_uri(&ee, "rsync://example.test/issuer.crl")
.expect("crldp ok");
}
}

View File

@@ -1,163 +0,0 @@
use url::Url;
use crate::data_model::ta::{TrustAnchor, TrustAnchorError};
use crate::data_model::tal::{Tal, TalDecodeError};
use crate::sync::rrdp::Fetcher;
use crate::validation::ca_instance::{
CaInstanceUris, CaInstanceUrisError, ca_instance_uris_from_ca_certificate,
};
use crate::validation::run::{RunError, RunOutput, run_publication_point_once};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiscoveredRootCaInstance {
pub tal_url: Option<String>,
pub trust_anchor: TrustAnchor,
pub ca_instance: CaInstanceUris,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunFromTalOutput {
pub discovery: DiscoveredRootCaInstance,
pub run: RunOutput,
}
#[derive(Debug, thiserror::Error)]
pub enum FromTalError {
#[error("TAL fetch failed: {0} (RFC 8630 §2.2)")]
TalFetch(String),
#[error("TAL decode failed: {0} (RFC 8630 §2.2)")]
TalDecode(#[from] TalDecodeError),
#[error("failed to fetch TA certificate from TAL: {0} (RFC 8630 §2.3)")]
TaFetch(String),
#[error("failed to bind TAL and TA certificate: {0} (RFC 8630 §2.3)")]
Bind(#[from] TrustAnchorError),
#[error("failed to discover CA instance URIs from TA certificate: {0}")]
CaInstanceUris(#[from] CaInstanceUrisError),
#[error("run failed: {0}")]
Run(#[from] RunError),
#[error("TAL contains no TA URIs (RFC 8630 §2.2)")]
NoTaUris,
}
pub fn discover_root_ca_instance_from_tal_url(
http_fetcher: &dyn Fetcher,
tal_url: &str,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
let tal_bytes = http_fetcher
.fetch(tal_url)
.map_err(FromTalError::TalFetch)?;
let tal = Tal::decode_bytes(&tal_bytes)?;
discover_root_ca_instance_from_tal(http_fetcher, tal, Some(tal_url.to_string()))
}
pub fn discover_root_ca_instance_from_tal(
http_fetcher: &dyn Fetcher,
tal: Tal,
tal_url: Option<String>,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
if tal.ta_uris.is_empty() {
return Err(FromTalError::NoTaUris);
}
let mut last_err: Option<String> = None;
for ta_uri in tal.ta_uris.iter() {
let ta_der = match http_fetcher.fetch(ta_uri.as_str()) {
Ok(b) => b,
Err(e) => {
last_err = Some(format!("fetch {ta_uri} failed: {e}"));
continue;
}
};
let trust_anchor = match TrustAnchor::bind_der(tal.clone(), &ta_der, Some(ta_uri)) {
Ok(ta) => ta,
Err(e) => {
last_err = Some(format!("bind {ta_uri} failed: {e}"));
continue;
}
};
let ca_instance =
match ca_instance_uris_from_ca_certificate(&trust_anchor.ta_certificate.rc_ca) {
Ok(v) => v,
Err(e) => {
last_err = Some(format!("CA instance discovery failed: {e}"));
continue;
}
};
return Ok(DiscoveredRootCaInstance {
tal_url,
trust_anchor,
ca_instance,
});
}
Err(FromTalError::TaFetch(last_err.unwrap_or_else(|| {
"unknown TA candidate error".to_string()
})))
}
pub fn discover_root_ca_instance_from_tal_and_ta_der(
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&Url>,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
let tal = Tal::decode_bytes(tal_bytes)?;
let trust_anchor = TrustAnchor::bind_der(tal, ta_der, resolved_ta_uri)?;
let ca_instance = ca_instance_uris_from_ca_certificate(&trust_anchor.ta_certificate.rc_ca)?;
Ok(DiscoveredRootCaInstance {
tal_url: None,
trust_anchor,
ca_instance,
})
}
pub fn run_root_from_tal_url_once(
store: &crate::storage::RocksStore,
policy: &crate::policy::Policy,
tal_url: &str,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
) -> Result<RunFromTalOutput, FromTalError> {
let discovery = discover_root_ca_instance_from_tal_url(http_fetcher, tal_url)?;
let run = run_publication_point_once(
store,
policy,
discovery.ca_instance.rrdp_notification_uri.as_deref(),
&discovery.ca_instance.rsync_base_uri,
&discovery.ca_instance.manifest_rsync_uri,
&discovery.ca_instance.publication_point_rsync_uri,
http_fetcher,
rsync_fetcher,
&discovery.trust_anchor.ta_certificate.raw_der,
None,
discovery
.trust_anchor
.ta_certificate
.rc_ca
.tbs
.extensions
.ip_resources
.as_ref(),
discovery
.trust_anchor
.ta_certificate
.rc_ca
.tbs
.extensions
.as_resources
.as_ref(),
validation_time,
)?;
Ok(RunFromTalOutput { discovery, run })
}

View File

@@ -1,575 +0,0 @@
use crate::data_model::manifest::{ManifestDecodeError, ManifestObject, ManifestValidateError};
use crate::data_model::signed_object::SignedObjectVerifyError;
use crate::policy::{CaFailedFetchPolicy, Policy};
use crate::report::{RfcRef, Warning};
use crate::storage::{FetchCachePpKey, FetchCachePpPack, RocksStore, StorageError};
use crate::validation::cert_path::{CertPathError, validate_ee_cert_path};
use sha2::Digest;
use std::cmp::Ordering;
use std::collections::HashMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PublicationPointSource {
Fresh,
FetchCachePp,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicationPointResult {
pub source: PublicationPointSource,
pub pack: FetchCachePpPack,
pub warnings: Vec<Warning>,
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestFreshError {
#[error("repo sync failed: {detail} (RFC 8182 §3.4.5; RFC 9286 §6.6)")]
RepoSyncFailed { detail: String },
#[error(
"manifest not found in raw_objects: {manifest_rsync_uri} (RFC 9286 §6.2; RFC 9286 §6.6)"
)]
MissingManifest { manifest_rsync_uri: String },
#[error("manifest decode failed: {0} (RFC 9286 §4; RFC 9286 §6.2; RFC 9286 §6.6)")]
Decode(#[from] ManifestDecodeError),
#[error(
"manifest embedded EE certificate resources invalid: {0} (RFC 9286 §5.1; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
EeResources(#[from] ManifestValidateError),
#[error(
"manifest CMS signature verification failed: {0} (RFC 6488 §3; RFC 9589 §4; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
Signature(#[from] SignedObjectVerifyError),
#[error(
"manifest embedded EE certificate path validation failed: {0} (RFC 6488 §3; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
EeCertPath(#[from] CertPathError),
#[error(
"manifest embedded EE certificate CRLDistributionPoints missing (cannot validate EE certificate) (RFC 6487 §4.8.6; RFC 6488 §3; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
EeCrlDpMissing,
#[error(
"publication point contains no CRL files (cannot validate manifest EE certificate) (RFC 9286 §7; RFC 6487 §4.8.6; RFC 6488 §3; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
NoCrlFiles,
#[error(
"CRL referenced by manifest embedded EE certificate CRLDistributionPoints not found at publication point: {0} (RFC 6487 §4.8.6; RFC 9286 §4.2.1; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
EeCrlNotFound(String),
#[error(
"manifest is not valid at validation_time: this_update={this_update_rfc3339_utc} next_update={next_update_rfc3339_utc} validation_time={validation_time_rfc3339_utc} (RFC 9286 §6.3; RFC 9286 §6.6)"
)]
StaleOrEarly {
this_update_rfc3339_utc: String,
next_update_rfc3339_utc: String,
validation_time_rfc3339_utc: String,
},
#[error(
"manifest must reside at the same publication point as id-ad-caRepository: manifest={manifest_rsync_uri} publication_point={publication_point_rsync_uri} (RFC 9286 §6.1; RFC 9286 §6.6)"
)]
ManifestOutsidePublicationPoint {
manifest_rsync_uri: String,
publication_point_rsync_uri: String,
},
#[error(
"manifestNumber not higher than previously validated manifest: old={old_hex} new={new_hex} (RFC 9286 §4.2.1; RFC 9286 §6.6)"
)]
ManifestNumberNotIncreasing { old_hex: String, new_hex: String },
#[error(
"thisUpdate not more recent than previously validated manifest: old={old_rfc3339_utc} new={new_rfc3339_utc} (RFC 9286 §4.2.1; RFC 9286 §6.6)"
)]
ThisUpdateNotIncreasing {
old_rfc3339_utc: String,
new_rfc3339_utc: String,
},
#[error(
"manifest referenced file missing in raw_objects: {rsync_uri} (RFC 9286 §6.4; RFC 9286 §6.6)"
)]
MissingFile { rsync_uri: String },
#[error("manifest file hash mismatch: {rsync_uri} (RFC 9286 §6.5; RFC 9286 §6.6)")]
HashMismatch { rsync_uri: String },
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestCachedError {
#[error("fetch_cache_pp entry missing: {0} (RFC 9286 §6.6)")]
MissingFetchCachePp(String),
#[error("fetch_cache_pp pack invalid: {0}")]
InvalidPack(#[from] StorageError),
#[error("cached manifest revalidation failed: {0}")]
CachedManifestFresh(#[from] ManifestFreshError),
#[error(
"cached fetch_cache_pp missing file referenced by manifest: {rsync_uri} (RFC 9286 §6.4; RFC 9286 §6.6)"
)]
CachedMissingFile { rsync_uri: String },
#[error("cached fetch_cache_pp file hash mismatch: {rsync_uri} (RFC 9286 §6.5; RFC 9286 §6.6)")]
CachedHashMismatch { rsync_uri: String },
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestProcessError {
#[error("manifest processing failed and cache use is disabled: {0}")]
StopAllOutput(#[from] ManifestFreshError),
#[error(
"manifest processing failed and no usable fetch_cache_pp is available: fresh={fresh}; cached={cached}"
)]
NoUsableCache {
fresh: ManifestFreshError,
cached: ManifestCachedError,
},
#[error("storage error during manifest processing: {0}")]
Storage(#[from] StorageError),
}
pub fn process_manifest_publication_point(
store: &RocksStore,
policy: &Policy,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<PublicationPointResult, ManifestProcessError> {
process_manifest_publication_point_after_repo_sync(
store,
policy,
manifest_rsync_uri,
publication_point_rsync_uri,
issuer_ca_der,
issuer_ca_rsync_uri,
validation_time,
true,
None,
)
}
pub fn process_manifest_publication_point_after_repo_sync(
store: &RocksStore,
policy: &Policy,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
repo_sync_ok: bool,
repo_sync_error: Option<&str>,
) -> Result<PublicationPointResult, ManifestProcessError> {
let fresh = if repo_sync_ok {
try_build_fresh_pack(
store,
manifest_rsync_uri,
publication_point_rsync_uri,
issuer_ca_der,
issuer_ca_rsync_uri,
validation_time,
)
} else {
Err(ManifestFreshError::RepoSyncFailed {
detail: repo_sync_error.unwrap_or("repo sync failed").to_string(),
})
};
match fresh {
Ok(pack) => {
let key = FetchCachePpKey::from_manifest_rsync_uri(manifest_rsync_uri);
let bytes = pack.encode()?;
store.put_fetch_cache_pp(&key, &bytes)?;
Ok(PublicationPointResult {
source: PublicationPointSource::Fresh,
pack,
warnings: Vec::new(),
})
}
Err(fresh_err) => match policy.ca_failed_fetch_policy {
CaFailedFetchPolicy::StopAllOutput => {
Err(ManifestProcessError::StopAllOutput(fresh_err))
}
CaFailedFetchPolicy::UseFetchCachePp => {
let mut warnings = vec![
Warning::new(format!("manifest failed fetch: {fresh_err}"))
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")])
.with_context(manifest_rsync_uri),
];
match load_and_revalidate_cached_pack(
store,
manifest_rsync_uri,
publication_point_rsync_uri,
issuer_ca_der,
issuer_ca_rsync_uri,
validation_time,
) {
Ok(pack) => {
warnings.push(
Warning::new("using fetch_cache_pp for publication point")
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")])
.with_context(manifest_rsync_uri),
);
Ok(PublicationPointResult {
source: PublicationPointSource::FetchCachePp,
pack,
warnings,
})
}
Err(cached_err) => Err(ManifestProcessError::NoUsableCache {
fresh: fresh_err,
cached: cached_err,
}),
}
}
},
}
}
pub fn load_and_revalidate_cached_pack(
store: &RocksStore,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<FetchCachePpPack, ManifestCachedError> {
let key = FetchCachePpKey::from_manifest_rsync_uri(manifest_rsync_uri);
let bytes = store
.get_fetch_cache_pp(&key)?
.ok_or_else(|| ManifestCachedError::MissingFetchCachePp(key.as_str().to_string()))?;
let pack = FetchCachePpPack::decode(&bytes)?;
if pack.manifest_rsync_uri != manifest_rsync_uri {
return Err(ManifestCachedError::InvalidPack(StorageError::RocksDb(
"cached pack manifest_rsync_uri does not match key".to_string(),
)));
}
if pack.publication_point_rsync_uri != publication_point_rsync_uri {
return Err(ManifestCachedError::InvalidPack(StorageError::RocksDb(
"cached pack publication_point_rsync_uri does not match expected".to_string(),
)));
}
revalidate_cached_pack_with_current_time(
&pack,
issuer_ca_der,
issuer_ca_rsync_uri,
validation_time,
)?;
Ok(pack)
}
fn revalidate_cached_pack_with_current_time(
pack: &FetchCachePpPack,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<(), ManifestCachedError> {
// First, re-validate the cached manifest itself with the current time.
let manifest =
decode_and_validate_manifest_with_current_time(&pack.manifest_bytes, validation_time)
.map_err(ManifestCachedError::from)?;
// Then, re-bind the manifest fileList to the cached pack contents, as per RFC 9286 §6.4-§6.5.
let by_uri: HashMap<&str, &crate::storage::PackFile> = pack
.files
.iter()
.map(|f| (f.rsync_uri.as_str(), f))
.collect();
let entries = manifest
.manifest
.parse_files()
.map_err(|e| ManifestFreshError::Decode(ManifestDecodeError::Validate(e)))?;
for entry in &entries {
let rsync_uri =
join_rsync_dir_and_file(&pack.publication_point_rsync_uri, entry.file_name.as_str());
let Some(file) = by_uri.get(rsync_uri.as_str()) else {
return Err(ManifestCachedError::CachedMissingFile { rsync_uri });
};
if file.sha256.as_slice() != entry.hash_bytes.as_ref() {
return Err(ManifestCachedError::CachedHashMismatch { rsync_uri });
}
}
// Finally, validate the manifest's embedded EE certificate path against the issuer CA + CRL.
// This enforces cert validity + CRL validity at `validation_time` for cached packs.
validate_manifest_embedded_ee_cert_path(
&manifest,
&pack.files,
issuer_ca_der,
issuer_ca_rsync_uri,
validation_time,
)
.map_err(ManifestCachedError::from)?;
Ok(())
}
fn decode_and_validate_manifest_with_current_time(
manifest_bytes: &[u8],
validation_time: time::OffsetDateTime,
) -> Result<ManifestObject, ManifestFreshError> {
let manifest = ManifestObject::decode_der(manifest_bytes)?;
manifest.validate_embedded_ee_cert()?;
manifest.signed_object.verify()?;
let this_update = manifest
.manifest
.this_update
.to_offset(time::UtcOffset::UTC);
let next_update = manifest
.manifest
.next_update
.to_offset(time::UtcOffset::UTC);
let now = validation_time.to_offset(time::UtcOffset::UTC);
if now < this_update || now > next_update {
return Err(ManifestFreshError::StaleOrEarly {
this_update_rfc3339_utc: this_update
.format(&time::format_description::well_known::Rfc3339)
.expect("format thisUpdate"),
next_update_rfc3339_utc: next_update
.format(&time::format_description::well_known::Rfc3339)
.expect("format nextUpdate"),
validation_time_rfc3339_utc: now
.format(&time::format_description::well_known::Rfc3339)
.expect("format validation_time"),
});
}
Ok(manifest)
}
fn try_build_fresh_pack(
store: &RocksStore,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<FetchCachePpPack, ManifestFreshError> {
if !rsync_uri_is_under_publication_point(manifest_rsync_uri, publication_point_rsync_uri) {
return Err(ManifestFreshError::ManifestOutsidePublicationPoint {
manifest_rsync_uri: manifest_rsync_uri.to_string(),
publication_point_rsync_uri: publication_point_rsync_uri.to_string(),
});
}
let manifest_bytes = store
.get_raw(manifest_rsync_uri)
.map_err(|e| ManifestFreshError::MissingManifest {
manifest_rsync_uri: format!("{manifest_rsync_uri} ({e})"),
})?
.ok_or_else(|| ManifestFreshError::MissingManifest {
manifest_rsync_uri: manifest_rsync_uri.to_string(),
})?;
let manifest =
decode_and_validate_manifest_with_current_time(&manifest_bytes, validation_time)?;
let this_update = manifest
.manifest
.this_update
.to_offset(time::UtcOffset::UTC);
let next_update = manifest
.manifest
.next_update
.to_offset(time::UtcOffset::UTC);
let now = validation_time.to_offset(time::UtcOffset::UTC);
// RFC 9286 §4.2.1: replay/rollback detection for manifestNumber and thisUpdate.
//
// Important nuance for revalidation across runs:
// - If the manifestNumber is equal to the previously validated manifestNumber *and* the
// manifest bytes are identical, then this is the same manifest being revalidated and MUST
// be accepted (otherwise, RPs would incorrectly treat stable repositories as "failed fetch"
// and fall back to fetch_cache_pp).
// - If manifestNumber is equal but the manifest bytes differ, treat this as invalid (a
// repository is not allowed to change the manifest while keeping the manifestNumber).
// - If manifestNumber is lower, treat as rollback and reject.
// - If manifestNumber is higher, require thisUpdate to be more recent than the previously
// validated thisUpdate.
let key = FetchCachePpKey::from_manifest_rsync_uri(manifest_rsync_uri);
if let Some(old_bytes) = store.get_fetch_cache_pp(&key).ok().flatten() {
if let Ok(old_pack) = FetchCachePpPack::decode(&old_bytes) {
if old_pack.manifest_rsync_uri == manifest_rsync_uri
&& old_pack.publication_point_rsync_uri == publication_point_rsync_uri
{
let new_num = manifest.manifest.manifest_number.bytes_be.as_slice();
let old_num = old_pack.manifest_number_be.as_slice();
match cmp_minimal_be_unsigned(new_num, old_num) {
Ordering::Greater => {
let old_this_update = old_pack
.this_update
.parse()
.expect("pack internal validation ensures this_update parses");
if this_update <= old_this_update {
use time::format_description::well_known::Rfc3339;
return Err(ManifestFreshError::ThisUpdateNotIncreasing {
old_rfc3339_utc: old_this_update
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.expect("format old thisUpdate"),
new_rfc3339_utc: this_update
.format(&Rfc3339)
.expect("format new thisUpdate"),
});
}
}
Ordering::Equal => {
if old_pack.manifest_bytes != manifest_bytes {
return Err(ManifestFreshError::ManifestNumberNotIncreasing {
old_hex: hex::encode_upper(old_num),
new_hex: hex::encode_upper(new_num),
});
}
}
Ordering::Less => {
return Err(ManifestFreshError::ManifestNumberNotIncreasing {
old_hex: hex::encode_upper(old_num),
new_hex: hex::encode_upper(new_num),
});
}
}
}
}
}
let entries = manifest
.manifest
.parse_files()
.map_err(ManifestDecodeError::Validate)?;
let mut files = Vec::with_capacity(manifest.manifest.file_count());
for entry in &entries {
let rsync_uri =
join_rsync_dir_and_file(publication_point_rsync_uri, entry.file_name.as_str());
let bytes = store
.get_raw(&rsync_uri)
.map_err(|_e| ManifestFreshError::MissingFile {
rsync_uri: rsync_uri.clone(),
})?
.ok_or_else(|| ManifestFreshError::MissingFile {
rsync_uri: rsync_uri.clone(),
})?;
let computed = sha2::Sha256::digest(&bytes);
if computed.as_slice() != entry.hash_bytes.as_ref() {
return Err(ManifestFreshError::HashMismatch { rsync_uri });
}
files.push(crate::storage::PackFile::from_bytes_compute_sha256(
rsync_uri, bytes,
));
}
// RFC 6488 §3: manifest (signed object) validity includes a valid EE cert path.
// We validate this after §6.4/§6.5 so the issuer CRL can be selected from the publication point.
validate_manifest_embedded_ee_cert_path(
&manifest,
&files,
issuer_ca_der,
issuer_ca_rsync_uri,
validation_time,
)?;
Ok(FetchCachePpPack {
format_version: FetchCachePpPack::FORMAT_VERSION_V1,
manifest_rsync_uri: manifest_rsync_uri.to_string(),
publication_point_rsync_uri: publication_point_rsync_uri.to_string(),
manifest_number_be: manifest.manifest.manifest_number.bytes_be.clone(),
this_update: crate::storage::PackTime::from_utc_offset_datetime(this_update),
next_update: crate::storage::PackTime::from_utc_offset_datetime(next_update),
verified_at: crate::storage::PackTime::from_utc_offset_datetime(now),
manifest_bytes,
files,
})
}
fn cmp_minimal_be_unsigned(a: &[u8], b: &[u8]) -> Ordering {
// Compare two minimal big-endian byte strings as unsigned integers.
// (Leading zeros are not expected; callers store minimal big-endian.)
a.len().cmp(&b.len()).then_with(|| a.cmp(b))
}
fn join_rsync_dir_and_file(base: &str, file_name: &str) -> String {
if base.ends_with('/') {
format!("{base}{file_name}")
} else {
format!("{base}/{file_name}")
}
}
fn rsync_uri_is_under_publication_point(uri: &str, publication_point_rsync_uri: &str) -> bool {
let pp = if publication_point_rsync_uri.ends_with('/') {
publication_point_rsync_uri.to_string()
} else {
format!("{publication_point_rsync_uri}/")
};
uri.starts_with(&pp)
}
fn validate_manifest_embedded_ee_cert_path(
manifest: &ManifestObject,
files: &[crate::storage::PackFile],
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<(), ManifestFreshError> {
let ee = &manifest.signed_object.signed_data.certificates[0];
let ee_der = ee.raw_der.as_slice();
let crl_files = files
.iter()
.filter(|f| f.rsync_uri.ends_with(".crl"))
.collect::<Vec<_>>();
if crl_files.is_empty() {
return Err(ManifestFreshError::NoCrlFiles);
}
let Some(crldp_uris) = ee
.resource_cert
.tbs
.extensions
.crl_distribution_points_uris
.as_ref()
else {
return Err(ManifestFreshError::EeCrlDpMissing);
};
for u in crldp_uris {
let s = u.as_str();
if let Some(f) = crl_files.iter().find(|f| f.rsync_uri == s) {
let _validated = validate_ee_cert_path(
ee_der,
issuer_ca_der,
f.bytes.as_slice(),
issuer_ca_rsync_uri,
Some(f.rsync_uri.as_str()),
validation_time,
)?;
return Ok(());
}
}
Err(ManifestFreshError::EeCrlNotFound(
crldp_uris
.iter()
.map(|u| u.as_str())
.collect::<Vec<_>>()
.join(", "),
))
}

View File

@@ -1,11 +0,0 @@
pub mod ca_instance;
pub mod ca_path;
pub mod cert_path;
pub mod from_tal;
pub mod manifest;
pub mod objects;
pub mod run;
pub mod run_tree_from_tal;
pub mod tree;
pub mod tree_runner;
pub mod x509_name;

File diff suppressed because it is too large Load Diff

View File

@@ -1,92 +0,0 @@
use crate::data_model::rc::{AsResourceSet, IpResourceSet};
use crate::fetch::rsync::RsyncFetcher;
use crate::policy::Policy;
use crate::storage::{FetchCachePpKey, RocksStore};
use crate::sync::repo::{RepoSyncResult, sync_publication_point};
use crate::sync::rrdp::Fetcher as HttpFetcher;
use crate::validation::manifest::{PublicationPointResult, process_manifest_publication_point};
use crate::validation::objects::{ObjectsOutput, process_fetch_cache_pp_pack_for_issuer};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunOutput {
pub repo_sync: RepoSyncResult,
pub publication_point: PublicationPointResult,
pub objects: ObjectsOutput,
}
#[derive(Debug, thiserror::Error)]
pub enum RunError {
#[error("repo sync failed: {0}")]
RepoSync(#[from] crate::sync::repo::RepoSyncError),
#[error("manifest processing failed: {0}")]
Manifest(#[from] crate::validation::manifest::ManifestProcessError),
}
/// v1 serial offline-friendly end-to-end execution for a single publication point.
///
/// This orchestrates:
/// 1) repo sync (RRDP or rsync fallback) into `raw_objects`
/// 2) manifest RP processing into a fetch_cache_pp pack (`fetch_cache_pp:<manifest-rsync-uri>`)
/// 3) signed object processing (ROA/ASPA) from the fetch_cache_pp pack
pub fn run_publication_point_once(
store: &RocksStore,
policy: &Policy,
rrdp_notification_uri: Option<&str>,
rsync_base_uri: &str,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
http_fetcher: &dyn HttpFetcher,
rsync_fetcher: &dyn RsyncFetcher,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&IpResourceSet>,
issuer_effective_as: Option<&AsResourceSet>,
validation_time: time::OffsetDateTime,
) -> Result<RunOutput, RunError> {
let repo_sync = sync_publication_point(
store,
policy,
rrdp_notification_uri,
rsync_base_uri,
http_fetcher,
rsync_fetcher,
None,
None,
)?;
let publication_point = process_manifest_publication_point(
store,
policy,
manifest_rsync_uri,
publication_point_rsync_uri,
issuer_ca_der,
issuer_ca_rsync_uri,
validation_time,
)?;
let objects = process_fetch_cache_pp_pack_for_issuer(
&publication_point.pack,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
None,
);
Ok(RunOutput {
repo_sync,
publication_point,
objects,
})
}
pub fn fetch_cache_pp_exists(store: &RocksStore, manifest_rsync_uri: &str) -> Result<bool, String> {
let key = FetchCachePpKey::from_manifest_rsync_uri(manifest_rsync_uri);
store
.get_fetch_cache_pp(&key)
.map(|v| v.is_some())
.map_err(|e| e.to_string())
}

View File

@@ -1,318 +0,0 @@
use url::Url;
use crate::analysis::timing::TimingHandle;
use crate::audit::PublicationPointAudit;
use crate::audit_downloads::DownloadLogHandle;
use crate::data_model::ta::TrustAnchor;
use crate::sync::rrdp::Fetcher;
use crate::validation::from_tal::{
DiscoveredRootCaInstance, FromTalError, discover_root_ca_instance_from_tal_and_ta_der,
discover_root_ca_instance_from_tal_url,
};
use crate::validation::tree::{
CaInstanceHandle, TreeRunAuditOutput, TreeRunConfig, TreeRunError, TreeRunOutput,
run_tree_serial, run_tree_serial_audit,
};
use crate::validation::tree_runner::Rpkiv1PublicationPointRunner;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunTreeFromTalOutput {
pub discovery: DiscoveredRootCaInstance,
pub tree: TreeRunOutput,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunTreeFromTalAuditOutput {
pub discovery: DiscoveredRootCaInstance,
pub tree: TreeRunOutput,
pub publication_points: Vec<PublicationPointAudit>,
pub downloads: Vec<crate::audit::AuditDownloadEvent>,
pub download_stats: crate::audit::AuditDownloadStats,
}
#[derive(Debug, thiserror::Error)]
pub enum RunTreeFromTalError {
#[error("{0}")]
FromTal(#[from] FromTalError),
#[error("{0}")]
Tree(#[from] TreeRunError),
}
pub fn root_handle_from_trust_anchor(
trust_anchor: &TrustAnchor,
ca_certificate_rsync_uri: Option<String>,
ca_instance: &crate::validation::ca_instance::CaInstanceUris,
) -> CaInstanceHandle {
let ta_rc = trust_anchor.ta_certificate.rc_ca.clone();
CaInstanceHandle {
depth: 0,
ca_certificate_der: trust_anchor.ta_certificate.raw_der.clone(),
ca_certificate_rsync_uri,
effective_ip_resources: ta_rc.tbs.extensions.ip_resources.clone(),
effective_as_resources: ta_rc.tbs.extensions.as_resources.clone(),
rsync_base_uri: ca_instance.rsync_base_uri.clone(),
manifest_rsync_uri: ca_instance.manifest_rsync_uri.clone(),
publication_point_rsync_uri: ca_instance.publication_point_rsync_uri.clone(),
rrdp_notification_uri: ca_instance.rrdp_notification_uri.clone(),
}
}
pub fn run_tree_from_tal_url_serial(
store: &crate::storage::RocksStore,
policy: &crate::policy::Policy,
tal_url: &str,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
) -> Result<RunTreeFromTalOutput, RunTreeFromTalError> {
let discovery = discover_root_ca_instance_from_tal_url(http_fetcher, tal_url)?;
let runner = Rpkiv1PublicationPointRunner {
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
timing: None,
download_log: None,
revalidate_only: config.revalidate_only,
rrdp_dedup: true,
rrdp_repo_cache: Mutex::new(HashMap::new()),
rsync_dedup: true,
rsync_repo_cache: Mutex::new(HashMap::new()),
};
let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance);
let tree = run_tree_serial(root, &runner, config)?;
Ok(RunTreeFromTalOutput { discovery, tree })
}
pub fn run_tree_from_tal_url_serial_audit(
store: &crate::storage::RocksStore,
policy: &crate::policy::Policy,
tal_url: &str,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError> {
let discovery = discover_root_ca_instance_from_tal_url(http_fetcher, tal_url)?;
let download_log = DownloadLogHandle::new();
let runner = Rpkiv1PublicationPointRunner {
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
timing: None,
download_log: Some(download_log.clone()),
revalidate_only: config.revalidate_only,
rrdp_dedup: true,
rrdp_repo_cache: Mutex::new(HashMap::new()),
rsync_dedup: true,
rsync_repo_cache: Mutex::new(HashMap::new()),
};
let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance);
let TreeRunAuditOutput {
tree,
publication_points,
} = run_tree_serial_audit(root, &runner, config)?;
let downloads = download_log.snapshot_events();
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
Ok(RunTreeFromTalAuditOutput {
discovery,
tree,
publication_points,
downloads,
download_stats,
})
}
pub fn run_tree_from_tal_url_serial_audit_with_timing(
store: &crate::storage::RocksStore,
policy: &crate::policy::Policy,
tal_url: &str,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
timing: &TimingHandle,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError> {
let _tal = timing.span_phase("tal_bootstrap");
let discovery = discover_root_ca_instance_from_tal_url(http_fetcher, tal_url)?;
drop(_tal);
let download_log = DownloadLogHandle::new();
let runner = Rpkiv1PublicationPointRunner {
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
timing: Some(timing.clone()),
download_log: Some(download_log.clone()),
revalidate_only: config.revalidate_only,
rrdp_dedup: true,
rrdp_repo_cache: Mutex::new(HashMap::new()),
rsync_dedup: true,
rsync_repo_cache: Mutex::new(HashMap::new()),
};
let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance);
let _tree = timing.span_phase("tree_run_total");
let TreeRunAuditOutput {
tree,
publication_points,
} = run_tree_serial_audit(root, &runner, config)?;
let downloads = download_log.snapshot_events();
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
Ok(RunTreeFromTalAuditOutput {
discovery,
tree,
publication_points,
downloads,
download_stats,
})
}
pub fn run_tree_from_tal_and_ta_der_serial(
store: &crate::storage::RocksStore,
policy: &crate::policy::Policy,
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&Url>,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
) -> Result<RunTreeFromTalOutput, RunTreeFromTalError> {
let discovery =
discover_root_ca_instance_from_tal_and_ta_der(tal_bytes, ta_der, resolved_ta_uri)?;
let runner = Rpkiv1PublicationPointRunner {
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
timing: None,
download_log: None,
revalidate_only: config.revalidate_only,
rrdp_dedup: true,
rrdp_repo_cache: Mutex::new(HashMap::new()),
rsync_dedup: true,
rsync_repo_cache: Mutex::new(HashMap::new()),
};
let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance);
let tree = run_tree_serial(root, &runner, config)?;
Ok(RunTreeFromTalOutput { discovery, tree })
}
pub fn run_tree_from_tal_and_ta_der_serial_audit(
store: &crate::storage::RocksStore,
policy: &crate::policy::Policy,
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&Url>,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError> {
let discovery =
discover_root_ca_instance_from_tal_and_ta_der(tal_bytes, ta_der, resolved_ta_uri)?;
let download_log = DownloadLogHandle::new();
let runner = Rpkiv1PublicationPointRunner {
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
timing: None,
download_log: Some(download_log.clone()),
revalidate_only: config.revalidate_only,
rrdp_dedup: true,
rrdp_repo_cache: Mutex::new(HashMap::new()),
rsync_dedup: true,
rsync_repo_cache: Mutex::new(HashMap::new()),
};
let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance);
let TreeRunAuditOutput {
tree,
publication_points,
} = run_tree_serial_audit(root, &runner, config)?;
let downloads = download_log.snapshot_events();
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
Ok(RunTreeFromTalAuditOutput {
discovery,
tree,
publication_points,
downloads,
download_stats,
})
}
pub fn run_tree_from_tal_and_ta_der_serial_audit_with_timing(
store: &crate::storage::RocksStore,
policy: &crate::policy::Policy,
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&Url>,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
timing: &TimingHandle,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError> {
let _tal = timing.span_phase("tal_bootstrap");
let discovery =
discover_root_ca_instance_from_tal_and_ta_der(tal_bytes, ta_der, resolved_ta_uri)?;
drop(_tal);
let download_log = DownloadLogHandle::new();
let runner = Rpkiv1PublicationPointRunner {
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
timing: Some(timing.clone()),
download_log: Some(download_log.clone()),
revalidate_only: config.revalidate_only,
rrdp_dedup: true,
rrdp_repo_cache: Mutex::new(HashMap::new()),
rsync_dedup: true,
rsync_repo_cache: Mutex::new(HashMap::new()),
};
let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance);
let _tree = timing.span_phase("tree_run_total");
let TreeRunAuditOutput {
tree,
publication_points,
} = run_tree_serial_audit(root, &runner, config)?;
let downloads = download_log.snapshot_events();
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
Ok(RunTreeFromTalAuditOutput {
discovery,
tree,
publication_points,
downloads,
download_stats,
})
}

View File

@@ -1,234 +0,0 @@
use crate::audit::DiscoveredFrom;
use crate::audit::PublicationPointAudit;
use crate::data_model::rc::{AsResourceSet, IpResourceSet};
use crate::report::{RfcRef, Warning};
use crate::storage::FetchCachePpPack;
use crate::validation::manifest::PublicationPointSource;
use crate::validation::objects::{AspaAttestation, ObjectsOutput, Vrp};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRunConfig {
/// Max CA instance depth to process (0 = root only).
pub max_depth: Option<usize>,
/// Max number of CA instances to process.
pub max_instances: Option<usize>,
/// Skip RRDP/rsync fetch and re-validate from existing `fetch_cache_pp` packs in the DB.
///
/// This is primarily intended for profiling/analysis runs to remove network noise.
pub revalidate_only: bool,
}
impl Default for TreeRunConfig {
fn default() -> Self {
Self {
max_depth: None,
max_instances: None,
revalidate_only: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CaInstanceHandle {
pub depth: usize,
/// DER bytes of the CA certificate for this CA instance.
pub ca_certificate_der: Vec<u8>,
/// rsync URI of this CA certificate object (where it is published).
///
/// This is used for strict AIA binding checks (RFC 6487 §4.8.7) when validating
/// subordinate CA and EE certificates. Trust anchor certificates downloaded from
/// TAL URIs may not have an rsync publication URI, so this can be `None`.
pub ca_certificate_rsync_uri: Option<String>,
/// Effective (fully resolved) resources for this CA instance, used for resource-path
/// validation of subordinate CA certificates.
pub effective_ip_resources: Option<IpResourceSet>,
pub effective_as_resources: Option<AsResourceSet>,
pub rsync_base_uri: String,
pub manifest_rsync_uri: String,
pub publication_point_rsync_uri: String,
pub rrdp_notification_uri: Option<String>,
}
impl CaInstanceHandle {
pub fn with_depth(mut self, depth: usize) -> Self {
self.depth = depth;
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicationPointRunResult {
pub source: PublicationPointSource,
pub pack: FetchCachePpPack,
pub warnings: Vec<Warning>,
pub objects: ObjectsOutput,
pub audit: PublicationPointAudit,
/// Candidate child CA instances discovered from this publication point.
///
/// RFC 9286 §6.6 restriction is enforced by the tree engine: if this
/// publication point used fetch_cache_pp due to failed fetch, children MUST NOT
/// be enqueued/processed in this run.
pub discovered_children: Vec<DiscoveredChildCaInstance>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiscoveredChildCaInstance {
pub handle: CaInstanceHandle,
pub discovered_from: DiscoveredFrom,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRunOutput {
pub instances_processed: usize,
pub instances_failed: usize,
pub warnings: Vec<Warning>,
pub vrps: Vec<Vrp>,
pub aspas: Vec<AspaAttestation>,
}
#[derive(Debug, thiserror::Error)]
pub enum TreeRunError {
#[error("publication point runner failed: {0}")]
Runner(String),
}
pub trait PublicationPointRunner {
fn run_publication_point(
&self,
ca: &CaInstanceHandle,
) -> Result<PublicationPointRunResult, String>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRunAuditOutput {
pub tree: TreeRunOutput,
pub publication_points: Vec<PublicationPointAudit>,
}
pub fn run_tree_serial(
root: CaInstanceHandle,
runner: &dyn PublicationPointRunner,
config: &TreeRunConfig,
) -> Result<TreeRunOutput, TreeRunError> {
Ok(run_tree_serial_audit(root, runner, config)?.tree)
}
pub fn run_tree_serial_audit(
root: CaInstanceHandle,
runner: &dyn PublicationPointRunner,
config: &TreeRunConfig,
) -> Result<TreeRunAuditOutput, TreeRunError> {
#[derive(Clone, Debug)]
struct QueuedCaInstance {
id: u64,
handle: CaInstanceHandle,
parent_id: Option<u64>,
discovered_from: Option<DiscoveredFrom>,
}
let mut next_id: u64 = 0;
let mut queue: std::collections::VecDeque<QueuedCaInstance> = std::collections::VecDeque::new();
queue.push_back(QueuedCaInstance {
id: next_id,
handle: root,
parent_id: None,
discovered_from: None,
});
next_id += 1;
let mut visited_manifest_uris: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut instances_processed = 0usize;
let mut instances_failed = 0usize;
let mut warnings: Vec<Warning> = Vec::new();
let mut vrps: Vec<Vrp> = Vec::new();
let mut aspas: Vec<AspaAttestation> = Vec::new();
let mut publication_points: Vec<PublicationPointAudit> = Vec::new();
while let Some(node) = queue.pop_front() {
let ca = &node.handle;
if !visited_manifest_uris.insert(ca.manifest_rsync_uri.clone()) {
continue;
}
if let Some(max_instances) = config.max_instances {
if instances_processed >= max_instances {
break;
}
}
if let Some(max_depth) = config.max_depth {
if ca.depth > max_depth {
continue;
}
}
let res = match runner.run_publication_point(ca) {
Ok(v) => v,
Err(e) => {
instances_failed += 1;
warnings.push(
Warning::new(format!("publication point failed: {e}"))
.with_context(&ca.manifest_rsync_uri),
);
continue;
}
};
instances_processed += 1;
warnings.extend(res.warnings.clone());
warnings.extend(res.objects.warnings.clone());
vrps.extend(res.objects.vrps.clone());
aspas.extend(res.objects.aspas.clone());
let mut audit = res.audit.clone();
audit.node_id = Some(node.id);
audit.parent_node_id = node.parent_id;
audit.discovered_from = node.discovered_from.clone();
publication_points.push(audit);
let enqueue_children =
res.source == PublicationPointSource::Fresh || config.revalidate_only;
if !enqueue_children && !res.discovered_children.is_empty() {
warnings.push(
Warning::new("skipping child CA discovery due to failed fetch cache use")
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")])
.with_context(&ca.manifest_rsync_uri),
);
}
if enqueue_children {
let mut children = res.discovered_children;
children.sort_by(|a, b| {
a.handle
.manifest_rsync_uri
.cmp(&b.handle.manifest_rsync_uri)
.then_with(|| {
a.discovered_from
.child_ca_certificate_rsync_uri
.cmp(&b.discovered_from.child_ca_certificate_rsync_uri)
})
});
for child in children {
queue.push_back(QueuedCaInstance {
id: next_id,
handle: child.handle.with_depth(ca.depth + 1),
parent_id: Some(node.id),
discovered_from: Some(child.discovered_from),
});
next_id += 1;
}
}
}
Ok(TreeRunAuditOutput {
tree: TreeRunOutput {
instances_processed,
instances_failed,
warnings,
vrps,
aspas,
},
publication_points,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,85 +0,0 @@
use crate::data_model::common::X509NameDer;
use x509_parser::prelude::FromDer;
fn canonicalize(name: &X509NameDer) -> Option<String> {
let (rem, parsed) = x509_parser::x509::X509Name::from_der(name.as_raw()).ok()?;
if !rem.is_empty() {
return None;
}
Some(parsed.to_string())
}
/// Compare two X.509 distinguished names using a tolerant semantic comparison.
///
/// RPKI repositories in the wild sometimes encode the same name using different
/// ASN.1 string types (e.g., PrintableString vs UTF8String) while remaining
/// semantically equivalent. RFC 5280 path validation requires name matching, but
/// DER byte equality is too strict for interoperability.
pub fn x509_names_equivalent(a: &X509NameDer, b: &X509NameDer) -> bool {
let Some(ca) = canonicalize(a) else {
return a == b;
};
let Some(cb) = canonicalize(b) else {
return a == b;
};
ca == cb
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn x509_names_equivalent_falls_back_to_der_equality_when_parse_fails() {
// Invalid tag (not a SEQUENCE) makes x509-parser fail and forces DER equality fallback.
let a = X509NameDer(vec![0x01, 0x00]);
let b = X509NameDer(vec![0x01, 0x00]);
assert!(x509_names_equivalent(&a, &b));
}
#[test]
fn x509_names_equivalent_compares_semantic_names_when_parse_succeeds() {
let cert_der = std::fs::read(
"tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer",
)
.expect("read certificate fixture");
let (_rem, cert) =
x509_parser::parse_x509_certificate(&cert_der).expect("parse certificate fixture");
let subject = X509NameDer(cert.tbs_certificate.subject.as_raw().to_vec());
let issuer = X509NameDer(cert.tbs_certificate.issuer.as_raw().to_vec());
assert!(x509_names_equivalent(&subject, &subject));
assert!(!x509_names_equivalent(&subject, &issuer));
}
#[test]
fn x509_names_equivalent_falls_back_when_name_has_trailing_bytes() {
// Use a real name DER and append a trailing byte so parsing yields leftover `rem`.
let cert_der = std::fs::read(
"tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer",
)
.expect("read certificate fixture");
let (_rem, cert) =
x509_parser::parse_x509_certificate(&cert_der).expect("parse certificate fixture");
let mut name = cert.tbs_certificate.subject.as_raw().to_vec();
name.push(0x00);
let a = X509NameDer(name.clone());
let b = X509NameDer(name);
assert!(x509_names_equivalent(&a, &b));
}
#[test]
fn x509_names_equivalent_falls_back_when_one_side_fails_to_parse() {
let cert_der = std::fs::read(
"tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer",
)
.expect("read certificate fixture");
let (_rem, cert) =
x509_parser::parse_x509_certificate(&cert_der).expect("parse certificate fixture");
let good = X509NameDer(cert.tbs_certificate.subject.as_raw().to_vec());
let bad = X509NameDer(vec![0x01, 0x00]);
assert!(!x509_names_equivalent(&good, &bad));
}
}

View File

@@ -1,316 +0,0 @@
use rpki::data_model::manifest::ManifestObject;
use std::path::{Path, PathBuf};
use std::time::Instant;
fn default_samples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/benchmark/selected_der")
}
fn read_samples(dir: &Path) -> Vec<Sample> {
let mut out = Vec::new();
let rd = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display()));
for ent in rd.flatten() {
let path = ent.path();
if path.extension().and_then(|s| s.to_str()) != Some("mft") {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
out.push(Sample { name, path });
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
#[derive(Clone, Debug)]
struct Sample {
name: String,
path: PathBuf,
}
fn env_u64(name: &str, default: u64) -> u64 {
std::env::var(name)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(default)
}
fn env_u64_opt(name: &str) -> Option<u64> {
std::env::var(name).ok().and_then(|s| s.parse::<u64>().ok())
}
fn env_bool(name: &str) -> bool {
matches!(
std::env::var(name).as_deref(),
Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES")
)
}
fn env_string(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.trim().is_empty())
}
fn escape_md(s: &str) -> String {
s.replace('|', "\\|").replace('\n', " ")
}
fn create_parent_dirs(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
panic!("create_dir_all {}: {e}", parent.display());
});
}
}
#[test]
#[ignore = "manual performance benchmark; prints Markdown table"]
fn manifest_decode_profile_benchmark_selected_der() {
let dir = env_string("BENCH_DIR")
.map(PathBuf::from)
.unwrap_or_else(default_samples_dir);
let sample_filter = env_string("BENCH_SAMPLE");
let fixed_iters = env_u64_opt("BENCH_ITERS");
let warmup_iters = env_u64("BENCH_WARMUP_ITERS", 100);
let rounds = env_u64("BENCH_ROUNDS", 5);
let min_round_ms = env_u64("BENCH_MIN_ROUND_MS", 200);
let max_adaptive_iters = env_u64("BENCH_MAX_ITERS", 1_000_000);
let verbose = env_bool("BENCH_VERBOSE");
let out_md = env_string("BENCH_OUT_MD").map(|p| PathBuf::from(p));
let out_json = env_string("BENCH_OUT_JSON").map(|p| PathBuf::from(p));
if let Some(n) = fixed_iters {
assert!(n >= 1, "BENCH_ITERS must be >= 1");
}
assert!(rounds >= 1, "BENCH_ROUNDS must be >= 1");
assert!(min_round_ms >= 1, "BENCH_MIN_ROUND_MS must be >= 1");
assert!(max_adaptive_iters >= 1, "BENCH_MAX_ITERS must be >= 1");
let mut samples = read_samples(&dir);
assert!(
!samples.is_empty(),
"no .mft files found under: {}",
dir.display()
);
if let Some(filter) = sample_filter.as_deref() {
samples.retain(|s| s.name == filter);
assert!(
!samples.is_empty(),
"no sample matched BENCH_SAMPLE={filter}"
);
}
println!("# Manifest decode + profile validate benchmark (debug build)");
println!();
println!("- dir: {}", dir.display());
if let Some(n) = fixed_iters {
println!("- iters: {} (fixed)", n);
} else {
println!(
"- warmup: {} iters, rounds: {}, min_round: {}ms (adaptive iters, max {})",
warmup_iters, rounds, min_round_ms, max_adaptive_iters
);
}
if let Some(filter) = sample_filter.as_deref() {
println!("- sample: {}", filter);
}
if verbose {
println!("- verbose: true");
}
if let Some(p) = out_md.as_ref() {
println!("- out_md: {}", p.display());
}
if let Some(p) = out_json.as_ref() {
println!("- out_json: {}", p.display());
}
println!();
println!("Samples:");
for s in &samples {
println!("- {}", s.name);
}
println!();
println!("| sample | file_count | avg ns/op | ops/s |");
println!("|---|---:|---:|---:|");
let mut rows: Vec<ResultRow> = Vec::with_capacity(samples.len());
for s in samples {
let bytes =
std::fs::read(&s.path).unwrap_or_else(|e| panic!("read {}: {e}", s.path.display()));
let file_count = ManifestObject::decode_der(bytes.as_slice())
.unwrap_or_else(|e| panic!("decode {}: {e}", s.name))
.manifest
.file_count();
// Warm-up: exercise the exact decode path but don't time it.
for _ in 0..warmup_iters {
let input = std::hint::black_box(bytes.as_slice());
let decoded = ManifestObject::decode_der(input).expect("decode");
std::hint::black_box(decoded);
}
let mut per_round_ns_per_op = Vec::with_capacity(rounds as usize);
for round in 0..rounds {
let iters = if let Some(n) = fixed_iters {
n
} else {
choose_iters_adaptive(bytes.as_slice(), min_round_ms, max_adaptive_iters)
};
let start = Instant::now();
for _ in 0..iters {
let input = std::hint::black_box(bytes.as_slice());
let decoded = ManifestObject::decode_der(input).expect("decode");
std::hint::black_box(decoded);
}
let elapsed = start.elapsed();
let total_ns = elapsed.as_secs_f64() * 1e9;
let ns_per_op = total_ns / (iters as f64);
per_round_ns_per_op.push(ns_per_op);
if verbose {
println!(
"# {} round {}: iters={} total_ms={:.2} ns/op={:.2}",
s.name,
round + 1,
iters,
elapsed.as_secs_f64() * 1e3,
ns_per_op
);
}
}
let avg_ns = per_round_ns_per_op.iter().sum::<f64>() / (per_round_ns_per_op.len() as f64);
let ops_per_sec = 1e9_f64 / avg_ns;
println!(
"| {} | {} | {:.2} | {:.2} |",
s.name, file_count, avg_ns, ops_per_sec
);
rows.push(ResultRow {
sample: s.name,
file_count,
avg_ns_per_op: avg_ns,
ops_per_sec,
});
}
if out_md.is_some() || out_json.is_some() {
let timestamp_utc = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "unknown".to_string());
let cfg = RunConfig {
dir: dir.display().to_string(),
sample: sample_filter,
fixed_iters,
warmup_iters,
rounds,
min_round_ms,
max_adaptive_iters,
timestamp_utc,
};
if let Some(path) = out_md {
let md = render_markdown(&cfg, &rows);
write_text_file(&path, &md);
eprintln!("Wrote {}", path.display());
}
if let Some(path) = out_json {
let json = serde_json::to_string_pretty(&BenchmarkOutput { config: cfg, rows })
.expect("serialize json");
write_text_file(&path, &json);
eprintln!("Wrote {}", path.display());
}
}
}
fn choose_iters_adaptive(bytes: &[u8], min_round_ms: u64, max_iters: u64) -> u64 {
let min_secs = (min_round_ms as f64) / 1e3;
let mut iters: u64 = 1;
loop {
let start = Instant::now();
for _ in 0..iters {
let input = std::hint::black_box(bytes);
let decoded = ManifestObject::decode_der(input).expect("decode");
std::hint::black_box(decoded);
}
let elapsed = start.elapsed().as_secs_f64();
if elapsed >= min_secs {
return iters;
}
if iters >= max_iters {
return iters;
}
iters = (iters.saturating_mul(2)).min(max_iters);
}
}
fn render_markdown(cfg: &RunConfig, rows: &[ResultRow]) -> String {
let mut out = String::new();
out.push_str("# Manifest decode + profile validate benchmark (debug build)\n\n");
out.push_str(&format!("- timestamp_utc: {}\n", cfg.timestamp_utc));
out.push_str(&format!("- dir: `{}`\n", cfg.dir));
if let Some(s) = cfg.sample.as_deref() {
out.push_str(&format!("- sample: `{}`\n", s));
}
if let Some(n) = cfg.fixed_iters {
out.push_str(&format!("- iters: {} (fixed)\n", n));
} else {
out.push_str(&format!(
"- warmup: {} iters, rounds: {}, min_round: {}ms (adaptive iters, max {})\n",
cfg.warmup_iters, cfg.rounds, cfg.min_round_ms, cfg.max_adaptive_iters
));
}
out.push('\n');
out.push_str("| sample | file_count | avg ns/op | ops/s |\n");
out.push_str("|---|---:|---:|---:|\n");
for r in rows {
out.push_str(&format!(
"| {} | {} | {:.2} | {:.2} |\n",
escape_md(&r.sample),
r.file_count,
r.avg_ns_per_op,
r.ops_per_sec
));
}
out
}
fn write_text_file(path: &Path, content: &str) {
create_parent_dirs(path);
std::fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
}
#[derive(Clone, Debug, serde::Serialize)]
struct RunConfig {
dir: String,
sample: Option<String>,
fixed_iters: Option<u64>,
warmup_iters: u64,
rounds: u64,
min_round_ms: u64,
max_adaptive_iters: u64,
timestamp_utc: String,
}
#[derive(Clone, Debug, serde::Serialize)]
struct ResultRow {
sample: String,
file_count: usize,
avg_ns_per_op: f64,
ops_per_sec: f64,
}
#[derive(Clone, Debug, serde::Serialize)]
struct BenchmarkOutput {
config: RunConfig,
rows: Vec<ResultRow>,
}

View File

@@ -1,942 +0,0 @@
use serde::Serialize;
use sha2::Digest;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use rpki::data_model::aspa::AspaObject;
use rpki::data_model::crl::RpkixCrl;
use rpki::data_model::manifest::ManifestObject;
use rpki::data_model::rc::{
AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpResourceSet, ResourceCertificate,
};
use rpki::data_model::roa::RoaObject;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "lowercase")]
enum ObjType {
Cer,
Crl,
Manifest,
Roa,
Aspa,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum Part {
PackManifest,
PackCrl,
StoredObject { index: u32 },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct CandidateRef {
pack_index: u32,
part: Part,
size_bytes: u32,
}
#[derive(Clone, Debug, Serialize)]
struct SampleMeta {
obj_type: ObjType,
label: String,
file_rel_path: String,
size_bytes: u32,
sha256_hex: String,
// Provenance (relative to BENCH_STORE_DIR)
pack_rel_path: String,
pack_update_time_rfc3339_utc: String,
manifest_uri: String,
object_uri: String,
// From StoredManifest (if available)
manifest_this_update_rfc3339_utc: String,
manifest_not_after_rfc3339_utc: String,
metrics: Metrics,
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Metrics {
Cer {
spki_len: u32,
ext_count: u32,
ip_resource_count: u32,
as_resource_count: u32,
},
Crl {
revoked_count: u32,
},
Manifest {
file_count: u32,
},
Roa {
addr_family_count: u32,
prefix_count: u32,
max_length_present: u32,
},
Aspa {
provider_count: u32,
},
}
#[derive(Clone, Debug, Serialize)]
struct SamplesManifest {
created_at_rfc3339_utc: String,
store_dir_hint: String,
per_type: BTreeMap<String, u32>,
samples: Vec<SampleMeta>,
}
fn env_string(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.trim().is_empty())
}
fn env_u64_opt(name: &str) -> Option<u64> {
std::env::var(name).ok().and_then(|s| s.parse::<u64>().ok())
}
fn env_bool(name: &str) -> bool {
matches!(
std::env::var(name).as_deref(),
Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES")
)
}
fn create_parent_dirs(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
panic!("create_dir_all {}: {e}", parent.display());
});
}
}
fn walk_collect_files(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
let meta = entry.metadata()?;
if meta.is_dir() {
walk_collect_files(&path, out)?;
continue;
}
if meta.is_file() {
out.push(path);
}
}
Ok(())
}
fn ext_from_uri_bytes(uri: &[u8]) -> Option<&[u8]> {
let mut last_dot = None;
for (i, &b) in uri.iter().enumerate() {
if b == b'.' {
last_dot = Some(i);
}
}
let dot = last_dot?;
if dot + 1 >= uri.len() {
return None;
}
Some(&uri[dot + 1..])
}
fn ext_matches_ascii_case_insensitive(ext: &[u8], needle_lower: &[u8]) -> bool {
if ext.len() != needle_lower.len() {
return false;
}
ext.iter()
.zip(needle_lower.iter())
.all(|(&a, &b)| a.to_ascii_lowercase() == b)
}
fn percentile_index(len: usize, q: f64) -> usize {
if len == 0 {
return 0;
}
if len == 1 {
return 0;
}
let q = q.clamp(0.0, 1.0);
(((len - 1) as f64) * q).round() as usize
}
fn quantile_labels() -> Vec<(&'static str, f64)> {
vec![
("min", 0.0),
("p01", 0.01),
("p10", 0.10),
("p25", 0.25),
("p50", 0.50),
("p75", 0.75),
("p90", 0.90),
("p95", 0.95),
("p99", 0.99),
("max", 1.0),
]
}
fn fmt_rfc3339_utc(t: time::OffsetDateTime) -> String {
use time::format_description::well_known::Rfc3339;
t.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.expect("format RFC3339")
}
fn scan_pack_candidates(
pack_index: u32,
bytes: &[u8],
out_cer: &mut Vec<CandidateRef>,
out_crl: &mut Vec<CandidateRef>,
out_manifest: &mut Vec<CandidateRef>,
out_roa: &mut Vec<CandidateRef>,
out_aspa: &mut Vec<CandidateRef>,
) -> Result<(), String> {
let mut cur = ByteCursor::new(bytes);
let _version = cur.read_u8()?;
cur.skip_string_u32()?; // manifest_uri
cur.skip_option_string_u32()?; // rpki_notify
let update_kind = cur.read_u8()?;
cur.skip_i64()?; // update time
if update_kind != 0 {
// LastAttempt: no StoredManifest + objects
return Ok(());
}
cur.skip_i64()?; // not_after
cur.skip(20)?; // manifest_number
cur.skip_i64()?; // this_update
cur.skip_string_u32()?; // ca_repository
let mft_len = cur.peek_u64_as_usize()?;
cur.skip_bytes_u64()?; // manifest DER
out_manifest.push(CandidateRef {
pack_index,
part: Part::PackManifest,
size_bytes: mft_len as u32,
});
cur.skip_string_u32()?; // crl_uri
let crl_len = cur.peek_u64_as_usize()?;
cur.skip_bytes_u64()?;
out_crl.push(CandidateRef {
pack_index,
part: Part::PackCrl,
size_bytes: crl_len as u32,
});
let mut object_index = 0u32;
while cur.remaining() > 0 {
let uri_bytes = cur.read_bytes_u32()?;
let ext = ext_from_uri_bytes(uri_bytes);
let hash_type = cur.read_u8()?;
if hash_type == 1 {
cur.skip(32)?;
} else if hash_type != 0 {
return Err(format!("unsupported stored object hash_type {hash_type}"));
}
let content_len = cur.peek_u64_as_usize()?;
cur.skip_bytes_u64()?;
let cand = CandidateRef {
pack_index,
part: Part::StoredObject {
index: object_index,
},
size_bytes: content_len as u32,
};
if let Some(ext) = ext {
if ext_matches_ascii_case_insensitive(ext, b"cer") {
out_cer.push(cand);
} else if ext_matches_ascii_case_insensitive(ext, b"roa") {
out_roa.push(cand);
} else if ext_matches_ascii_case_insensitive(ext, b"asa") {
out_aspa.push(cand);
}
}
object_index += 1;
}
Ok(())
}
struct Extracted {
pack_update_time_utc: time::OffsetDateTime,
manifest_uri: String,
manifest_this_update_utc: time::OffsetDateTime,
manifest_not_after_utc: time::OffsetDateTime,
object_uri: String,
der: Vec<u8>,
}
fn extract_candidate(pack_path: &Path, cand: CandidateRef) -> Result<Extracted, String> {
let bytes =
std::fs::read(pack_path).map_err(|e| format!("read {}: {e}", pack_path.display()))?;
let mut cur = ByteCursor::new(bytes.as_slice());
let _version = cur.read_u8()?;
let manifest_uri = cur.read_string_u32()?;
cur.skip_option_string_u32()?; // rpki_notify
let update_kind = cur.read_u8()?;
let pack_update_time_utc = cur.read_time_utc_i64_be()?;
if update_kind != 0 {
return Err("pack is LastAttempt".to_string());
}
let manifest_not_after_utc = cur.read_time_utc_i64_be()?;
cur.skip(20)?; // manifest_number
let manifest_this_update_utc = cur.read_time_utc_i64_be()?;
cur.skip_string_u32()?; // ca_repository
let manifest_der = cur.read_bytes_u64_vec()?; // manifest DER
if cand.part == Part::PackManifest {
return Ok(Extracted {
pack_update_time_utc,
manifest_uri: manifest_uri.clone(),
manifest_this_update_utc,
manifest_not_after_utc,
object_uri: manifest_uri,
der: manifest_der,
});
}
let crl_uri = cur.read_string_u32()?;
let crl_der = cur.read_bytes_u64_vec()?;
match cand.part {
Part::PackManifest => unreachable!("handled above"),
Part::PackCrl => Ok(Extracted {
pack_update_time_utc,
manifest_uri,
manifest_this_update_utc,
manifest_not_after_utc,
object_uri: crl_uri,
der: crl_der,
}),
Part::StoredObject { index } => {
let mut object_index = 0u32;
while cur.remaining() > 0 {
let uri = cur.read_string_u32()?;
let hash_type = cur.read_u8()?;
if hash_type == 1 {
cur.skip(32)?;
} else if hash_type != 0 {
return Err(format!("unsupported stored object hash_type {hash_type}"));
}
let content = cur.read_bytes_u64_vec()?;
if object_index == index {
return Ok(Extracted {
pack_update_time_utc,
manifest_uri,
manifest_this_update_utc,
manifest_not_after_utc,
object_uri: uri,
der: content,
});
}
object_index += 1;
}
Err(format!("stored object index {index} out of range"))
}
}
}
fn ip_resource_count(set: &IpResourceSet) -> u32 {
let mut n = 0u32;
for fam in &set.families {
match &fam.choice {
IpAddressChoice::Inherit => n = n.saturating_add(1),
IpAddressChoice::AddressesOrRanges(items) => n = n.saturating_add(items.len() as u32),
}
}
n
}
fn as_choice_count(choice: &AsIdentifierChoice) -> u32 {
match choice {
AsIdentifierChoice::Inherit => 1,
AsIdentifierChoice::AsIdsOrRanges(items) => items.len() as u32,
}
}
fn as_resource_count(set: &AsResourceSet) -> u32 {
let mut n = 0u32;
if let Some(c) = set.asnum.as_ref() {
n = n.saturating_add(as_choice_count(c));
}
if let Some(c) = set.rdi.as_ref() {
n = n.saturating_add(as_choice_count(c));
}
n
}
fn metrics_for(obj_type: ObjType, der: &[u8]) -> Result<Metrics, String> {
match obj_type {
ObjType::Cer => {
let parsed = ResourceCertificate::parse_der(der).map_err(|e| e.to_string())?;
let spki_len = parsed.subject_public_key_info.len() as u32;
let ext_count = (parsed.extensions.basic_constraints_ca.len()
+ parsed.extensions.subject_key_identifier.len()
+ parsed.extensions.authority_key_identifier.len()
+ parsed.extensions.crl_distribution_points.len()
+ parsed.extensions.authority_info_access.len()
+ parsed.extensions.subject_info_access.len()
+ parsed.extensions.certificate_policies.len()
+ parsed.extensions.ip_resources.len()
+ parsed.extensions.as_resources.len()) as u32;
let cert = parsed.validate_profile().map_err(|e| e.to_string())?;
let ip_resource_count = cert
.tbs
.extensions
.ip_resources
.as_ref()
.map(ip_resource_count)
.unwrap_or(0);
let as_resource_count = cert
.tbs
.extensions
.as_resources
.as_ref()
.map(as_resource_count)
.unwrap_or(0);
Ok(Metrics::Cer {
spki_len,
ext_count,
ip_resource_count,
as_resource_count,
})
}
ObjType::Crl => {
let parsed = RpkixCrl::parse_der(der).map_err(|e| e.to_string())?;
let crl = parsed.validate_profile().map_err(|e| e.to_string())?;
Ok(Metrics::Crl {
revoked_count: crl.revoked_certs.len() as u32,
})
}
ObjType::Manifest => {
let mft = ManifestObject::decode_der(der).map_err(|e| e.to_string())?;
Ok(Metrics::Manifest {
file_count: mft.manifest.file_count() as u32,
})
}
ObjType::Roa => {
let roa = RoaObject::decode_der(der).map_err(|e| e.to_string())?;
let addr_family_count = roa.roa.ip_addr_blocks.len() as u32;
let mut prefix_count = 0u32;
let mut max_length_present = 0u32;
for fam in &roa.roa.ip_addr_blocks {
for a in &fam.addresses {
prefix_count += 1;
if a.max_length.is_some() {
max_length_present += 1;
}
}
}
Ok(Metrics::Roa {
addr_family_count,
prefix_count,
max_length_present,
})
}
ObjType::Aspa => {
let asa = AspaObject::decode_der(der).map_err(|e| e.to_string())?;
Ok(Metrics::Aspa {
provider_count: asa.aspa.provider_as_ids.len() as u32,
})
}
}
}
fn write_sample(out_dir: &Path, obj_type: ObjType, label: &str, der: &[u8]) -> PathBuf {
let (subdir, ext) = match obj_type {
ObjType::Cer => ("cer", "cer"),
ObjType::Crl => ("crl", "crl"),
ObjType::Manifest => ("manifest", "mft"),
ObjType::Roa => ("roa", "roa"),
ObjType::Aspa => ("aspa", "asa"),
};
let path = out_dir.join(subdir).join(format!("{label}.{ext}"));
create_parent_dirs(&path);
std::fs::write(&path, der).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
path
}
#[test]
#[ignore = "manual: collect representative CER/CRL/MFT/ROA/ASA DER fixtures from routinator stored/ SAP packs"]
fn stage2_collect_selected_der_v2_from_routinator_store() {
let store_dir = env_string("BENCH_STORE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| panic!("set BENCH_STORE_DIR to routinator stored/ dir"));
let out_dir = env_string("BENCH_OUT_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/benchmark/selected_der_v2")
});
let max_packs = env_u64_opt("BENCH_MAX_PACKS");
let clean = env_bool("BENCH_CLEAN");
let dry_run = env_bool("BENCH_DRY_RUN");
let verbose = env_bool("BENCH_VERBOSE");
if clean && !dry_run && out_dir.exists() {
std::fs::remove_dir_all(&out_dir)
.unwrap_or_else(|e| panic!("remove_dir_all {}: {e}", out_dir.display()));
}
let mut pack_paths = Vec::new();
walk_collect_files(&store_dir, &mut pack_paths)
.unwrap_or_else(|e| panic!("walk {}: {e}", store_dir.display()));
pack_paths.retain(|p| p.extension().and_then(|s| s.to_str()) == Some("mft"));
pack_paths.sort();
if let Some(max) = max_packs {
pack_paths.truncate(max as usize);
}
println!("# Stage2 collect: selected_der_v2 (from routinator stored/ SAP packs)");
println!();
println!("- store_dir: {}", store_dir.display());
println!("- out_dir: {}", out_dir.display());
println!("- pack_files_found: {}", pack_paths.len());
if let Some(max) = max_packs {
println!("- max_packs: {} (truncating input list)", max);
}
println!("- clean: {}", clean);
println!("- dry_run: {}", dry_run);
println!();
let mut cer = Vec::<CandidateRef>::new();
let mut crl = Vec::<CandidateRef>::new();
let mut mft = Vec::<CandidateRef>::new();
let mut roa = Vec::<CandidateRef>::new();
let mut asa = Vec::<CandidateRef>::new();
for (i, p) in pack_paths.iter().enumerate() {
let bytes = match std::fs::read(p) {
Ok(v) => v,
Err(_) => continue,
};
if let Err(e) = scan_pack_candidates(
i as u32,
bytes.as_slice(),
&mut cer,
&mut crl,
&mut mft,
&mut roa,
&mut asa,
) {
if verbose {
eprintln!("# scan error {}: {}", p.display(), e);
}
}
}
let mut by_type: BTreeMap<ObjType, Vec<CandidateRef>> = BTreeMap::new();
by_type.insert(ObjType::Cer, cer);
by_type.insert(ObjType::Crl, crl);
by_type.insert(ObjType::Manifest, mft);
by_type.insert(ObjType::Roa, roa);
by_type.insert(ObjType::Aspa, asa);
let mut samples = Vec::<SampleMeta>::new();
let mut per_type_out = BTreeMap::<String, u32>::new();
for (obj_type, mut cands) in by_type {
cands.sort_by(|a, b| {
a.size_bytes
.cmp(&b.size_bytes)
.then_with(|| a.pack_index.cmp(&b.pack_index))
.then_with(|| match (a.part, b.part) {
(Part::PackManifest, Part::PackManifest) => std::cmp::Ordering::Equal,
(Part::PackManifest, _) => std::cmp::Ordering::Less,
(_, Part::PackManifest) => std::cmp::Ordering::Greater,
(Part::PackCrl, Part::PackCrl) => std::cmp::Ordering::Equal,
(Part::PackCrl, Part::StoredObject { .. }) => std::cmp::Ordering::Less,
(Part::StoredObject { .. }, Part::PackCrl) => std::cmp::Ordering::Greater,
(Part::StoredObject { index: ai }, Part::StoredObject { index: bi }) => {
ai.cmp(&bi)
}
})
});
println!("## {:?}", obj_type);
println!();
println!("- candidates: {}", cands.len());
if cands.is_empty() {
continue;
}
let mut selected_sha = HashSet::<String>::new();
let mut selected_ptrs = HashSet::<(u32, Part)>::new();
for (label, q) in quantile_labels() {
let target = percentile_index(cands.len(), q);
let mut found = None;
for off in 0.. {
let mut tried_any = false;
if off == 0 {
let j = target as i32;
if j < 0 || (j as usize) >= cands.len() {
tried_any = false;
} else {
tried_any = true;
let cand = cands[j as usize];
if selected_ptrs.contains(&(cand.pack_index, cand.part)) {
continue;
}
let pack_path = &pack_paths[cand.pack_index as usize];
let extracted = match extract_candidate(pack_path.as_path(), cand) {
Ok(v) => v,
Err(_) => continue,
};
if extracted.der.len() != cand.size_bytes as usize {
continue;
}
let sha256 = sha2::Sha256::digest(extracted.der.as_slice());
let sha256_hex = hex::encode(sha256);
if selected_sha.contains(&sha256_hex) {
continue;
}
let metrics = match metrics_for(obj_type, extracted.der.as_slice()) {
Ok(m) => m,
Err(_) => continue,
};
if !dry_run {
let p = write_sample(
out_dir.as_path(),
obj_type,
label,
extracted.der.as_slice(),
);
if verbose {
println!(
"- wrote {} -> {}",
label,
p.strip_prefix(PathBuf::from(env!("CARGO_MANIFEST_DIR")))
.unwrap_or(&p)
.display()
);
}
}
let pack_rel_path = pack_path
.strip_prefix(&store_dir)
.unwrap_or(pack_path.as_path())
.display()
.to_string();
let file_rel_path = format!(
"tests/benchmark/selected_der_v2/{}/{}.{}",
match obj_type {
ObjType::Cer => "cer",
ObjType::Crl => "crl",
ObjType::Manifest => "manifest",
ObjType::Roa => "roa",
ObjType::Aspa => "aspa",
},
label,
match obj_type {
ObjType::Cer => "cer",
ObjType::Crl => "crl",
ObjType::Manifest => "mft",
ObjType::Roa => "roa",
ObjType::Aspa => "asa",
}
);
let sha256_hex_for_set = sha256_hex.clone();
samples.push(SampleMeta {
obj_type,
label: label.to_string(),
file_rel_path,
size_bytes: cand.size_bytes,
sha256_hex,
pack_rel_path,
pack_update_time_rfc3339_utc: fmt_rfc3339_utc(
extracted.pack_update_time_utc,
),
manifest_uri: extracted.manifest_uri,
object_uri: extracted.object_uri,
manifest_this_update_rfc3339_utc: fmt_rfc3339_utc(
extracted.manifest_this_update_utc,
),
manifest_not_after_rfc3339_utc: fmt_rfc3339_utc(
extracted.manifest_not_after_utc,
),
metrics,
});
selected_ptrs.insert((cand.pack_index, cand.part));
selected_sha.insert(sha256_hex_for_set);
found = Some(());
break;
}
} else {
for delta in [off as i32, -(off as i32)] {
let j = target as i32 + delta;
if j < 0 || (j as usize) >= cands.len() {
continue;
}
tried_any = true;
let cand = cands[j as usize];
if selected_ptrs.contains(&(cand.pack_index, cand.part)) {
continue;
}
let pack_path = &pack_paths[cand.pack_index as usize];
let extracted = match extract_candidate(pack_path.as_path(), cand) {
Ok(v) => v,
Err(_) => continue,
};
if extracted.der.len() != cand.size_bytes as usize {
continue;
}
let sha256 = sha2::Sha256::digest(extracted.der.as_slice());
let sha256_hex = hex::encode(sha256);
if selected_sha.contains(&sha256_hex) {
continue;
}
let metrics = match metrics_for(obj_type, extracted.der.as_slice()) {
Ok(m) => m,
Err(_) => continue,
};
if !dry_run {
let p = write_sample(
out_dir.as_path(),
obj_type,
label,
extracted.der.as_slice(),
);
if verbose {
println!(
"- wrote {} -> {}",
label,
p.strip_prefix(PathBuf::from(env!("CARGO_MANIFEST_DIR")))
.unwrap_or(&p)
.display()
);
}
}
let pack_rel_path = pack_path
.strip_prefix(&store_dir)
.unwrap_or(pack_path.as_path())
.display()
.to_string();
let file_rel_path = format!(
"tests/benchmark/selected_der_v2/{}/{}.{}",
match obj_type {
ObjType::Cer => "cer",
ObjType::Crl => "crl",
ObjType::Manifest => "manifest",
ObjType::Roa => "roa",
ObjType::Aspa => "aspa",
},
label,
match obj_type {
ObjType::Cer => "cer",
ObjType::Crl => "crl",
ObjType::Manifest => "mft",
ObjType::Roa => "roa",
ObjType::Aspa => "asa",
}
);
let sha256_hex_for_set = sha256_hex.clone();
samples.push(SampleMeta {
obj_type,
label: label.to_string(),
file_rel_path,
size_bytes: cand.size_bytes,
sha256_hex,
pack_rel_path,
pack_update_time_rfc3339_utc: fmt_rfc3339_utc(
extracted.pack_update_time_utc,
),
manifest_uri: extracted.manifest_uri,
object_uri: extracted.object_uri,
manifest_this_update_rfc3339_utc: fmt_rfc3339_utc(
extracted.manifest_this_update_utc,
),
manifest_not_after_rfc3339_utc: fmt_rfc3339_utc(
extracted.manifest_not_after_utc,
),
metrics,
});
selected_ptrs.insert((cand.pack_index, cand.part));
selected_sha.insert(sha256_hex_for_set);
found = Some(());
break;
}
}
if found.is_some() {
break;
}
if !tried_any {
break;
}
}
}
per_type_out.insert(
format!("{:?}", obj_type).to_lowercase(),
selected_sha.len() as u32,
);
println!();
}
samples.sort_by(|a, b| {
a.obj_type
.cmp(&b.obj_type)
.then_with(|| a.label.cmp(&b.label))
});
let created_at_rfc3339_utc = fmt_rfc3339_utc(time::OffsetDateTime::now_utc());
let manifest = SamplesManifest {
created_at_rfc3339_utc,
store_dir_hint: store_dir.display().to_string(),
per_type: per_type_out,
samples,
};
if !dry_run {
let meta_path = out_dir.join("meta").join("samples.json");
create_parent_dirs(&meta_path);
let bytes = serde_json::to_vec_pretty(&manifest).expect("encode samples manifest");
std::fs::write(&meta_path, bytes)
.unwrap_or_else(|e| panic!("write {}: {e}", meta_path.display()));
println!("- wrote meta: {}", meta_path.display());
}
}
struct ByteCursor<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> ByteCursor<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, pos: 0 }
}
fn remaining(&self) -> usize {
self.bytes.len().saturating_sub(self.pos)
}
fn skip(&mut self, len: usize) -> Result<(), String> {
if len > self.remaining() {
return Err("truncated input".to_string());
}
self.pos += len;
Ok(())
}
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], String> {
if len > self.remaining() {
return Err(format!(
"truncated input: need {len} bytes, have {}",
self.remaining()
));
}
let start = self.pos;
self.pos += len;
Ok(&self.bytes[start..start + len])
}
fn read_u8(&mut self) -> Result<u8, String> {
Ok(self.read_exact(1)?[0])
}
fn read_u32_be(&mut self) -> Result<u32, String> {
let b = self.read_exact(4)?;
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
fn read_u64_be(&mut self) -> Result<u64, String> {
let b = self.read_exact(8)?;
Ok(u64::from_be_bytes([
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
]))
}
fn peek_u64_as_usize(&mut self) -> Result<usize, String> {
let start = self.pos;
let v = self.read_u64_be()?;
self.pos = start;
usize::try_from(v).map_err(|_| format!("length too large: {v}"))
}
fn skip_i64(&mut self) -> Result<(), String> {
self.skip(8)
}
fn read_i64_be(&mut self) -> Result<i64, String> {
let b = self.read_exact(8)?;
Ok(i64::from_be_bytes([
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
]))
}
fn read_time_utc_i64_be(&mut self) -> Result<time::OffsetDateTime, String> {
let ts = self.read_i64_be()?;
time::OffsetDateTime::from_unix_timestamp(ts)
.map_err(|e| format!("invalid unix timestamp {ts}: {e}"))
}
fn read_bytes_u32(&mut self) -> Result<&'a [u8], String> {
let len = self.read_u32_be()? as usize;
self.read_exact(len)
}
fn skip_string_u32(&mut self) -> Result<(), String> {
let len = self.read_u32_be()? as usize;
self.skip(len)
}
fn read_string_u32(&mut self) -> Result<String, String> {
let b = self.read_bytes_u32()?;
std::str::from_utf8(b)
.map(|s| s.to_string())
.map_err(|e| format!("invalid UTF-8 string: {e}"))
}
fn skip_option_string_u32(&mut self) -> Result<(), String> {
let len = self.read_u32_be()? as usize;
if len == 0 {
return Ok(());
}
self.skip(len)
}
fn skip_bytes_u64(&mut self) -> Result<(), String> {
let len_u64 = self.read_u64_be()?;
let len = usize::try_from(len_u64)
.map_err(|_| format!("data block too large for this system: {len_u64}"))?;
self.skip(len)
}
fn read_bytes_u64_vec(&mut self) -> Result<Vec<u8>, String> {
let len_u64 = self.read_u64_be()?;
let len = usize::try_from(len_u64)
.map_err(|_| format!("data block too large for this system: {len_u64}"))?;
Ok(self.read_exact(len)?.to_vec())
}
}

View File

@@ -1,717 +0,0 @@
use rpki::data_model::aspa::AspaObject;
use rpki::data_model::crl::RpkixCrl;
use rpki::data_model::manifest::ManifestObject;
use rpki::data_model::rc::{
AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpResourceSet, ResourceCertificate,
};
use rpki::data_model::roa::RoaObject;
use rpki::storage::RocksStore;
use rpki::storage::pack::PackFile;
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum ObjType {
Cer,
Crl,
Manifest,
Roa,
Aspa,
}
impl ObjType {
fn as_str(self) -> &'static str {
match self {
ObjType::Cer => "cer",
ObjType::Crl => "crl",
ObjType::Manifest => "manifest",
ObjType::Roa => "roa",
ObjType::Aspa => "aspa",
}
}
fn ext(self) -> &'static str {
match self {
ObjType::Cer => "cer",
ObjType::Crl => "crl",
ObjType::Manifest => "mft",
ObjType::Roa => "roa",
ObjType::Aspa => "asa",
}
}
}
#[derive(Clone, Debug)]
struct Sample {
obj_type: ObjType,
name: String,
path: PathBuf,
}
fn default_samples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/benchmark/selected_der_v2")
}
fn read_samples(root: &Path) -> Vec<Sample> {
let mut out = Vec::new();
for obj_type in [
ObjType::Cer,
ObjType::Crl,
ObjType::Manifest,
ObjType::Roa,
ObjType::Aspa,
] {
let dir = root.join(obj_type.as_str());
let rd = match std::fs::read_dir(&dir) {
Ok(rd) => rd,
Err(_) => continue,
};
for ent in rd.flatten() {
let path = ent.path();
if path.extension().and_then(|s| s.to_str()) != Some(obj_type.ext()) {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
out.push(Sample {
obj_type,
name,
path,
});
}
}
out.sort_by(|a, b| {
a.obj_type
.cmp(&b.obj_type)
.then_with(|| a.name.cmp(&b.name))
});
out
}
fn env_u64(name: &str, default: u64) -> u64 {
std::env::var(name)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(default)
}
fn env_u64_opt(name: &str) -> Option<u64> {
std::env::var(name).ok().and_then(|s| s.parse::<u64>().ok())
}
fn env_bool(name: &str) -> bool {
matches!(
std::env::var(name).as_deref(),
Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES")
)
}
fn env_string(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.trim().is_empty())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BenchMode {
Both,
DecodeValidate,
Landing,
}
impl BenchMode {
fn parse(s: &str) -> Result<Self, String> {
match s {
"" | "both" => Ok(Self::Both),
"decode" | "decode_validate" | "decode+validate" => Ok(Self::DecodeValidate),
"landing" => Ok(Self::Landing),
_ => Err(format!(
"invalid BENCH_MODE='{s}', expected one of: both, decode_validate, landing"
)),
}
}
fn do_decode(self) -> bool {
matches!(self, BenchMode::Both | BenchMode::DecodeValidate)
}
fn do_landing(self) -> bool {
matches!(self, BenchMode::Both | BenchMode::Landing)
}
fn as_str(self) -> &'static str {
match self {
BenchMode::Both => "both",
BenchMode::DecodeValidate => "decode_validate",
BenchMode::Landing => "landing",
}
}
}
fn escape_md(s: &str) -> String {
s.replace('|', "\\|").replace('\n', " ")
}
fn create_parent_dirs(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
panic!("create_dir_all {}: {e}", parent.display());
});
}
}
fn write_text_file(path: &Path, content: &str) {
create_parent_dirs(path);
std::fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
}
fn fmt_rfc3339_utc_now() -> String {
use time::format_description::well_known::Rfc3339;
time::OffsetDateTime::now_utc()
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.unwrap_or_else(|_| "unknown".to_string())
}
fn choose_iters_adaptive<F: FnMut()>(mut op: F, min_round_ms: u64, max_iters: u64) -> u64 {
let min_secs = (min_round_ms as f64) / 1e3;
let mut iters: u64 = 1;
loop {
let start = Instant::now();
for _ in 0..iters {
op();
}
let elapsed = start.elapsed().as_secs_f64();
if elapsed >= min_secs {
return iters;
}
if iters >= max_iters {
return iters;
}
iters = (iters.saturating_mul(2)).min(max_iters);
}
}
fn ip_resource_count(set: &IpResourceSet) -> u64 {
let mut n = 0u64;
for fam in &set.families {
match &fam.choice {
IpAddressChoice::Inherit => n = n.saturating_add(1),
IpAddressChoice::AddressesOrRanges(items) => n = n.saturating_add(items.len() as u64),
}
}
n
}
fn as_choice_count(choice: &AsIdentifierChoice) -> u64 {
match choice {
AsIdentifierChoice::Inherit => 1,
AsIdentifierChoice::AsIdsOrRanges(items) => items.len() as u64,
}
}
fn as_resource_count(set: &AsResourceSet) -> u64 {
let mut n = 0u64;
if let Some(c) = set.asnum.as_ref() {
n = n.saturating_add(as_choice_count(c));
}
if let Some(c) = set.rdi.as_ref() {
n = n.saturating_add(as_choice_count(c));
}
n
}
fn complexity_main(obj_type: ObjType, bytes: &[u8]) -> u64 {
match obj_type {
ObjType::Cer => {
let cert = ResourceCertificate::decode_der(bytes).expect("decode cert");
let ip = cert
.tbs
.extensions
.ip_resources
.as_ref()
.map(ip_resource_count)
.unwrap_or(0);
let asn = cert
.tbs
.extensions
.as_resources
.as_ref()
.map(as_resource_count)
.unwrap_or(0);
ip.saturating_add(asn)
}
ObjType::Crl => {
let crl = RpkixCrl::decode_der(bytes).expect("decode crl");
crl.revoked_certs.len() as u64
}
ObjType::Manifest => {
let mft = ManifestObject::decode_der(bytes).expect("decode manifest");
mft.manifest.file_count() as u64
}
ObjType::Roa => {
let roa = RoaObject::decode_der(bytes).expect("decode roa");
let mut n = 0u64;
for fam in &roa.roa.ip_addr_blocks {
n = n.saturating_add(fam.addresses.len() as u64);
}
n
}
ObjType::Aspa => {
let asa = AspaObject::decode_der(bytes).expect("decode aspa");
asa.aspa.provider_as_ids.len() as u64
}
}
}
fn decode_validate(obj_type: ObjType, bytes: &[u8]) {
match obj_type {
ObjType::Cer => {
let decoded =
ResourceCertificate::decode_der(std::hint::black_box(bytes)).expect("decode cert");
std::hint::black_box(decoded);
}
ObjType::Crl => {
let decoded = RpkixCrl::decode_der(std::hint::black_box(bytes)).expect("decode crl");
std::hint::black_box(decoded);
}
ObjType::Manifest => {
let decoded =
ManifestObject::decode_der(std::hint::black_box(bytes)).expect("decode manifest");
std::hint::black_box(decoded);
}
ObjType::Roa => {
let decoded = RoaObject::decode_der(std::hint::black_box(bytes)).expect("decode roa");
std::hint::black_box(decoded);
}
ObjType::Aspa => {
let decoded = AspaObject::decode_der(std::hint::black_box(bytes)).expect("decode aspa");
std::hint::black_box(decoded);
}
}
}
fn landing_packfile_cbor_put(store: &RocksStore, obj_type: ObjType, sample: &str, bytes: &[u8]) {
let rsync_uri = format!(
"rsync://bench.invalid/{}/{}.{}",
obj_type.as_str(),
sample,
obj_type.ext()
);
let pf = PackFile::from_bytes_compute_sha256(rsync_uri, bytes.to_vec());
let encoded = serde_cbor::to_vec(std::hint::black_box(&pf)).expect("cbor encode packfile");
let key = format!("bench:packfile:{}:{}", obj_type.as_str(), sample);
store.put_raw(&key, &encoded).expect("store put_raw");
}
#[derive(Clone, Debug, serde::Serialize)]
struct RunConfig {
dir: String,
mode: String,
obj_type: Option<String>,
sample: Option<String>,
fixed_iters: Option<u64>,
warmup_iters: u64,
rounds: u64,
min_round_ms: u64,
max_adaptive_iters: u64,
timestamp_utc: String,
}
#[derive(Clone, Debug, serde::Serialize)]
struct ResultRow {
obj_type: String,
sample: String,
size_bytes: usize,
complexity: u64,
avg_ns_per_op: f64,
ops_per_sec: f64,
}
#[derive(Clone, Debug, serde::Serialize)]
struct BenchmarkOutput {
config: RunConfig,
rows: Vec<ResultRow>,
}
fn render_markdown(title: &str, rows: &[ResultRow]) -> String {
let mut out = String::new();
out.push_str(&format!("# {title}\n\n"));
out.push_str("| type | sample | size_bytes | complexity | avg ns/op | ops/s |\n");
out.push_str("|---|---|---:|---:|---:|---:|\n");
for r in rows {
out.push_str(&format!(
"| {} | {} | {} | {} | {:.2} | {:.2} |\n",
escape_md(&r.obj_type),
escape_md(&r.sample),
r.size_bytes,
r.complexity,
r.avg_ns_per_op,
r.ops_per_sec
));
}
out
}
fn render_csv(rows: &[ResultRow]) -> String {
let mut out = String::new();
out.push_str("type,sample,size_bytes,complexity,avg_ns_per_op,ops_per_sec\n");
for r in rows {
// sample names are controlled by our fixtures; keep it simple but safe for commas/quotes.
let sample = r.sample.replace('"', "\"\"");
out.push_str(&format!(
"{},{},{},{},{:.6},{:.6}\n",
r.obj_type,
format!("\"{}\"", sample),
r.size_bytes,
r.complexity,
r.avg_ns_per_op,
r.ops_per_sec
));
}
out
}
#[test]
#[ignore = "manual performance benchmark; decode+validate and landing for selected_der_v2"]
fn stage2_decode_validate_and_landing_benchmark_selected_der_v2() {
let dir = env_string("BENCH_DIR")
.map(PathBuf::from)
.unwrap_or_else(default_samples_dir);
let mode = env_string("BENCH_MODE")
.as_deref()
.map(BenchMode::parse)
.transpose()
.unwrap_or_else(|e| panic!("{e}"))
.unwrap_or(BenchMode::Both);
let type_filter = env_string("BENCH_TYPE");
let sample_filter = env_string("BENCH_SAMPLE");
let fixed_iters = env_u64_opt("BENCH_ITERS");
let warmup_iters = env_u64("BENCH_WARMUP_ITERS", 50);
let rounds = env_u64("BENCH_ROUNDS", 5);
let min_round_ms = env_u64("BENCH_MIN_ROUND_MS", 200);
let max_adaptive_iters = env_u64("BENCH_MAX_ITERS", 1_000_000);
let verbose = env_bool("BENCH_VERBOSE");
let out_md = env_string("BENCH_OUT_MD").map(PathBuf::from);
let out_json = env_string("BENCH_OUT_JSON").map(PathBuf::from);
let out_csv = env_string("BENCH_OUT_CSV").map(PathBuf::from);
let out_md_landing = env_string("BENCH_OUT_MD_LANDING").map(PathBuf::from);
let out_json_landing = env_string("BENCH_OUT_JSON_LANDING").map(PathBuf::from);
let out_csv_landing = env_string("BENCH_OUT_CSV_LANDING").map(PathBuf::from);
if let Some(n) = fixed_iters {
assert!(n >= 1, "BENCH_ITERS must be >= 1");
}
assert!(rounds >= 1, "BENCH_ROUNDS must be >= 1");
assert!(min_round_ms >= 1, "BENCH_MIN_ROUND_MS must be >= 1");
assert!(max_adaptive_iters >= 1, "BENCH_MAX_ITERS must be >= 1");
let mut samples = read_samples(&dir);
assert!(
!samples.is_empty(),
"no samples found under: {}",
dir.display()
);
if let Some(t) = type_filter.as_deref() {
samples.retain(|s| s.obj_type.as_str() == t);
assert!(!samples.is_empty(), "no sample matched BENCH_TYPE={t}");
}
if let Some(filter) = sample_filter.as_deref() {
samples.retain(|s| s.name == filter);
assert!(
!samples.is_empty(),
"no sample matched BENCH_SAMPLE={filter}"
);
}
println!("# Stage2 decode+validate benchmark (selected_der_v2)");
println!();
println!("- dir: {}", dir.display());
println!("- mode: {}", mode.as_str());
if let Some(t) = type_filter.as_deref() {
println!("- type: {}", t);
}
if let Some(s) = sample_filter.as_deref() {
println!("- sample: {}", s);
}
if let Some(n) = fixed_iters {
println!("- iters: {} (fixed)", n);
} else {
println!(
"- warmup: {} iters, rounds: {}, min_round: {}ms (adaptive iters, max {})",
warmup_iters, rounds, min_round_ms, max_adaptive_iters
);
}
if verbose {
println!("- verbose: true");
}
if let Some(p) = out_md.as_ref() {
println!("- out_md: {}", p.display());
}
if let Some(p) = out_json.as_ref() {
println!("- out_json: {}", p.display());
}
if let Some(p) = out_csv.as_ref() {
println!("- out_csv: {}", p.display());
}
if let Some(p) = out_md_landing.as_ref() {
println!("- out_md_landing: {}", p.display());
}
if let Some(p) = out_json_landing.as_ref() {
println!("- out_json_landing: {}", p.display());
}
if let Some(p) = out_csv_landing.as_ref() {
println!("- out_csv_landing: {}", p.display());
}
println!();
if mode.do_decode() {
println!("## decode+validate");
println!();
println!("| type | sample | size_bytes | complexity | avg ns/op | ops/s |");
println!("|---|---|---:|---:|---:|---:|");
}
let mut decode_rows: Vec<ResultRow> = Vec::with_capacity(samples.len());
let temp = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(temp.path()).expect("open rocksdb");
let mut landing_rows: Vec<ResultRow> = Vec::with_capacity(samples.len());
for s in &samples {
let bytes =
std::fs::read(&s.path).unwrap_or_else(|e| panic!("read {}: {e}", s.path.display()));
let size_bytes = bytes.len();
let complexity = if mode.do_decode() {
complexity_main(s.obj_type, bytes.as_slice())
} else {
0
};
if mode.do_decode() {
// Warm-up decode+validate.
for _ in 0..warmup_iters {
decode_validate(s.obj_type, bytes.as_slice());
}
let mut per_round_ns_per_op = Vec::with_capacity(rounds as usize);
for round in 0..rounds {
let iters = if let Some(n) = fixed_iters {
n
} else {
choose_iters_adaptive(
|| decode_validate(s.obj_type, bytes.as_slice()),
min_round_ms,
max_adaptive_iters,
)
};
let start = Instant::now();
for _ in 0..iters {
decode_validate(s.obj_type, bytes.as_slice());
}
let elapsed = start.elapsed();
let total_ns = elapsed.as_secs_f64() * 1e9;
let ns_per_op = total_ns / (iters as f64);
per_round_ns_per_op.push(ns_per_op);
if verbose {
println!(
"# {}.{} round {}: iters={} total_ms={:.2} ns/op={:.2}",
s.obj_type.as_str(),
s.name,
round + 1,
iters,
elapsed.as_secs_f64() * 1e3,
ns_per_op
);
}
}
let avg_ns =
per_round_ns_per_op.iter().sum::<f64>() / (per_round_ns_per_op.len() as f64);
let ops_per_sec = 1e9_f64 / avg_ns;
println!(
"| {} | {} | {} | {} | {:.2} | {:.2} |",
s.obj_type.as_str(),
s.name,
size_bytes,
complexity,
avg_ns,
ops_per_sec
);
decode_rows.push(ResultRow {
obj_type: s.obj_type.as_str().to_string(),
sample: s.name.clone(),
size_bytes,
complexity,
avg_ns_per_op: avg_ns,
ops_per_sec,
});
}
if mode.do_landing() {
// Landing benchmark: PackFile(from_bytes_compute_sha256) + CBOR + RocksDB put_raw.
let mut per_round_ns_per_op = Vec::with_capacity(rounds as usize);
for _ in 0..warmup_iters {
landing_packfile_cbor_put(&store, s.obj_type, &s.name, bytes.as_slice());
}
for round in 0..rounds {
let iters = if let Some(n) = fixed_iters {
n
} else {
choose_iters_adaptive(
|| landing_packfile_cbor_put(&store, s.obj_type, &s.name, bytes.as_slice()),
min_round_ms,
max_adaptive_iters,
)
};
let start = Instant::now();
for _ in 0..iters {
landing_packfile_cbor_put(&store, s.obj_type, &s.name, bytes.as_slice());
}
let elapsed = start.elapsed();
let total_ns = elapsed.as_secs_f64() * 1e9;
let ns_per_op = total_ns / (iters as f64);
per_round_ns_per_op.push(ns_per_op);
if verbose {
println!(
"# landing {}.{} round {}: iters={} total_ms={:.2} ns/op={:.2}",
s.obj_type.as_str(),
s.name,
round + 1,
iters,
elapsed.as_secs_f64() * 1e3,
ns_per_op
);
}
}
let avg_ns =
per_round_ns_per_op.iter().sum::<f64>() / (per_round_ns_per_op.len() as f64);
let ops_per_sec = 1e9_f64 / avg_ns;
landing_rows.push(ResultRow {
obj_type: s.obj_type.as_str().to_string(),
sample: s.name.clone(),
size_bytes,
complexity,
avg_ns_per_op: avg_ns,
ops_per_sec,
});
}
}
if mode.do_decode() {
println!();
}
if mode.do_landing() {
println!("## landing (PackFile::from_bytes_compute_sha256 + CBOR + RocksDB put_raw)");
println!();
println!("| type | sample | size_bytes | complexity | avg ns/op | ops/s |");
println!("|---|---|---:|---:|---:|---:|");
for r in &landing_rows {
println!(
"| {} | {} | {} | {} | {:.2} | {:.2} |",
escape_md(&r.obj_type),
escape_md(&r.sample),
r.size_bytes,
r.complexity,
r.avg_ns_per_op,
r.ops_per_sec
);
}
println!();
}
if mode.do_decode() && (out_md.is_some() || out_json.is_some() || out_csv.is_some()) {
let timestamp_utc = fmt_rfc3339_utc_now();
let cfg = RunConfig {
dir: dir.display().to_string(),
mode: "decode_validate".to_string(),
obj_type: type_filter.clone(),
sample: sample_filter.clone(),
fixed_iters,
warmup_iters,
rounds,
min_round_ms,
max_adaptive_iters,
timestamp_utc,
};
if let Some(path) = out_md {
let md = render_markdown(
"Stage2 decode+validate benchmark (selected_der_v2)",
&decode_rows,
);
write_text_file(&path, &md);
eprintln!("Wrote {}", path.display());
}
if let Some(path) = out_json {
let json = serde_json::to_string_pretty(&BenchmarkOutput {
config: cfg,
rows: decode_rows.clone(),
})
.expect("serialize json");
write_text_file(&path, &json);
eprintln!("Wrote {}", path.display());
}
if let Some(path) = out_csv {
let csv = render_csv(&decode_rows);
write_text_file(&path, &csv);
eprintln!("Wrote {}", path.display());
}
}
if mode.do_landing()
&& (out_md_landing.is_some() || out_json_landing.is_some() || out_csv_landing.is_some())
{
let timestamp_utc = fmt_rfc3339_utc_now();
let cfg = RunConfig {
dir: dir.display().to_string(),
mode: "landing_packfile_cbor_put".to_string(),
obj_type: type_filter,
sample: sample_filter,
fixed_iters,
warmup_iters,
rounds,
min_round_ms,
max_adaptive_iters,
timestamp_utc,
};
if let Some(path) = out_md_landing {
let md = render_markdown(
"Stage2 landing benchmark (PackFile CBOR + RocksDB put_raw)",
&landing_rows,
);
write_text_file(&path, &md);
eprintln!("Wrote {}", path.display());
}
if let Some(path) = out_json_landing {
let json = serde_json::to_string_pretty(&BenchmarkOutput {
config: cfg,
rows: landing_rows.clone(),
})
.expect("serialize json");
write_text_file(&path, &json);
eprintln!("Wrote {}", path.display());
}
if let Some(path) = out_csv_landing {
let csv = render_csv(&landing_rows);
write_text_file(&path, &csv);
eprintln!("Wrote {}", path.display());
}
}
}

View File

@@ -1,233 +0,0 @@
#[path = "benchmark/sap.rs"]
mod sap;
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Default)]
struct Sizes {
vals: Vec<u64>,
total: u64,
}
impl Sizes {
fn push(&mut self, v: u64) {
self.vals.push(v);
self.total = self.total.saturating_add(v);
}
fn summarize(&mut self) -> Option<SizesSummary> {
if self.vals.is_empty() {
return None;
}
self.vals.sort_unstable();
let min = *self.vals.first().unwrap();
let max = *self.vals.last().unwrap();
let p50 = percentile(&self.vals, 0.50);
let p90 = percentile(&self.vals, 0.90);
let p99 = percentile(&self.vals, 0.99);
Some(SizesSummary {
count: self.vals.len() as u64,
total_bytes: self.total,
min,
p50,
p90,
p99,
max,
})
}
}
#[derive(Clone, Debug, Serialize)]
struct SizesSummary {
count: u64,
total_bytes: u64,
min: u64,
p50: u64,
p90: u64,
p99: u64,
max: u64,
}
#[derive(Clone, Debug, Serialize)]
struct InventoryReport {
store_dir: String,
pack_files_found: u64,
packs_success: u64,
packs_last_attempt: u64,
packs_decode_error: u64,
objects_total: u64,
by_type: BTreeMap<String, SizesSummary>,
}
fn percentile(sorted: &[u64], p: f64) -> u64 {
if sorted.is_empty() {
return 0;
}
if sorted.len() == 1 {
return sorted[0];
}
let p = p.clamp(0.0, 1.0);
let idx = ((sorted.len() - 1) as f64 * p).round() as usize;
sorted[idx]
}
fn env_string(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.trim().is_empty())
}
fn env_u64_opt(name: &str) -> Option<u64> {
std::env::var(name).ok().and_then(|s| s.parse::<u64>().ok())
}
fn create_parent_dirs(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
panic!("create_dir_all {}: {e}", parent.display());
});
}
}
fn walk_collect_files(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
let meta = entry.metadata()?;
if meta.is_dir() {
walk_collect_files(&path, out)?;
continue;
}
if !meta.is_file() {
continue;
}
out.push(path);
}
Ok(())
}
fn object_type_from_rsync_uri(uri: &str) -> String {
let file = uri.rsplit('/').next().unwrap_or(uri);
if let Some((_, ext)) = file.rsplit_once('.') {
return ext.to_ascii_lowercase();
}
"unknown".to_string()
}
#[test]
#[ignore = "manual inventory of routinator stored publication point packs (SAP)"]
fn stage2_inventory_routinator_sap_store() {
let store_dir = env_string("BENCH_STORE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
panic!(
"set BENCH_STORE_DIR to routinator stored/ dir, e.g. /home/yuyr/dev/rust_playground/routinator/bench/full/cache/stored"
)
});
let max_packs = env_u64_opt("BENCH_MAX_PACKS");
let out_json = env_string("BENCH_OUT_JSON").map(PathBuf::from);
let mut pack_paths = Vec::new();
walk_collect_files(&store_dir, &mut pack_paths)
.unwrap_or_else(|e| panic!("walk {}: {e}", store_dir.display()));
pack_paths.retain(|p| p.extension().and_then(|s| s.to_str()) == Some("mft"));
pack_paths.sort();
if let Some(max) = max_packs {
pack_paths.truncate(max as usize);
}
println!("# Stage2 inventory: routinator SAP packs");
println!();
println!("- store_dir: {}", store_dir.display());
println!("- pack_files_found: {}", pack_paths.len());
if let Some(max) = max_packs {
println!("- max_packs: {} (truncating input list)", max);
}
if let Some(p) = out_json.as_ref() {
println!("- out_json: {}", p.display());
}
println!();
let mut by_type: BTreeMap<String, Sizes> = BTreeMap::new();
let mut packs_success = 0u64;
let mut packs_last_attempt = 0u64;
let mut packs_decode_error = 0u64;
let mut objects_total = 0u64;
for p in &pack_paths {
let sap = match sap::SapPublicationPoint::decode_path(p.as_path()) {
Ok(v) => v,
Err(_e) => {
packs_decode_error += 1;
continue;
}
};
match sap.header.update_status {
sap::SapUpdateStatus::LastAttempt { .. } => {
packs_last_attempt += 1;
continue;
}
sap::SapUpdateStatus::Success { .. } => {
packs_success += 1;
}
}
let Some(m) = sap.manifest.as_ref() else {
packs_decode_error += 1;
continue;
};
by_type
.entry("mft".to_string())
.or_default()
.push(m.manifest_der.len() as u64);
by_type
.entry("crl".to_string())
.or_default()
.push(m.crl_der.len() as u64);
objects_total += 2;
for o in &sap.objects {
let typ = object_type_from_rsync_uri(&o.uri);
by_type
.entry(typ)
.or_default()
.push(o.content_der.len() as u64);
objects_total += 1;
}
}
let mut summaries: BTreeMap<String, SizesSummary> = BTreeMap::new();
for (typ, mut sizes) in by_type {
if let Some(summary) = sizes.summarize() {
summaries.insert(typ, summary);
}
}
let report = InventoryReport {
store_dir: store_dir.display().to_string(),
pack_files_found: pack_paths.len() as u64,
packs_success,
packs_last_attempt,
packs_decode_error,
objects_total,
by_type: summaries.clone(),
};
println!("| type | count | total_bytes | p50 | p90 | p99 | max |");
println!("|---|---:|---:|---:|---:|---:|---:|");
for (typ, s) in &summaries {
println!(
"| {} | {} | {} | {} | {} | {} | {} |",
typ, s.count, s.total_bytes, s.p50, s.p90, s.p99, s.max
);
}
if let Some(path) = out_json.as_ref() {
create_parent_dirs(path);
let bytes = serde_json::to_vec_pretty(&report).expect("encode json");
std::fs::write(path, bytes).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
}
}

View File

@@ -1,87 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Generates a flamegraph SVG for Manifest decode+profile validate.
#
# Prereqs:
# - `perf` installed and usable by current user
# - `cargo flamegraph` installed (`cargo install flamegraph`)
#
# Usage examples:
# BENCH_SAMPLE=large-02 ./tests/benchmark/flamegraph_manifest_decode_profile.sh
# BENCH_SAMPLE=large-02 BENCH_ITERS=5000 FLAMEGRAPH_PROFILE=dev ./tests/benchmark/flamegraph_manifest_decode_profile.sh
# BENCH_SAMPLE=large-02 BENCH_ITERS=2000 FLAMEGRAPH_PROFILE=release ./tests/benchmark/flamegraph_manifest_decode_profile.sh
PROFILE="${FLAMEGRAPH_PROFILE:-dev}" # dev|release
SAMPLE="${BENCH_SAMPLE:-large-02}"
ITERS="${BENCH_ITERS:-2000}"
FREQ="${FLAMEGRAPH_FREQ:-99}"
WARMUP="${BENCH_WARMUP_ITERS:-1}"
ROUNDS="${BENCH_ROUNDS:-1}"
MIN_ROUND_MS="${BENCH_MIN_ROUND_MS:-1}"
MAX_ITERS="${BENCH_MAX_ITERS:-100000}"
if ! command -v perf >/dev/null 2>&1; then
echo "ERROR: perf not found. Install linux-tools/perf first (may require sudo)." >&2
exit 2
fi
if ! command -v cargo-flamegraph >/dev/null 2>&1 && ! command -v flamegraph >/dev/null 2>&1; then
# cargo installs as `cargo flamegraph`, but the binary is `cargo-flamegraph`.
if ! command -v cargo-flamegraph >/dev/null 2>&1; then
echo "ERROR: cargo-flamegraph not found. Install with: cargo install flamegraph" >&2
exit 2
fi
fi
OUT="target/bench/flamegraph_mft_decode_profile_${SAMPLE}_${PROFILE}.svg"
echo "profile=${PROFILE} sample=${SAMPLE} iters=${ITERS} freq=${FREQ} out=${OUT}"
FLAGS=()
if [[ "${PROFILE}" == "release" ]]; then
FLAGS+=(--release)
else
FLAGS+=(--dev)
fi
# On WSL2, /usr/bin/perf is often a wrapper that exits 2 because there is no
# kernel-matched perf binary. In that case, prefer a real perf binary under
# /usr/lib/linux-tools/*/perf by putting a shim earlier in PATH.
PERF_WRAPPER_OK=1
PERF_VERSION_OUT="$(perf --version 2>&1 || true)"
if echo "${PERF_VERSION_OUT}" | grep -q "WARNING: perf not found for kernel"; then
PERF_WRAPPER_OK=0
fi
if [[ "${PERF_WRAPPER_OK}" == "0" ]]; then
PERF_REAL="$(ls -1 /usr/lib/linux-tools/*/perf 2>/dev/null | head -n 1 || true)"
if [[ -z "${PERF_REAL}" ]]; then
echo "ERROR: perf wrapper found, but no real perf binary under /usr/lib/linux-tools/*/perf" >&2
exit 2
fi
SHIM_DIR="target/bench/tools"
mkdir -p "${SHIM_DIR}"
cat > "${SHIM_DIR}/perf" <<EOF
#!/usr/bin/env bash
exec "${PERF_REAL}" "\$@"
EOF
chmod +x "${SHIM_DIR}/perf"
export PATH="$(pwd)/${SHIM_DIR}:${PATH}"
echo "Using perf shim -> ${PERF_REAL}"
fi
BENCH_SAMPLE="${SAMPLE}" \
BENCH_ITERS="${ITERS}" \
BENCH_WARMUP_ITERS="${WARMUP}" \
BENCH_ROUNDS="${ROUNDS}" \
BENCH_MIN_ROUND_MS="${MIN_ROUND_MS}" \
BENCH_MAX_ITERS="${MAX_ITERS}" \
cargo flamegraph "${FLAGS[@]}" \
-F "${FREQ}" \
--output "${OUT}" \
--test bench_manifest_decode_profile -- --ignored --nocapture
echo "Wrote ${OUT}"

View File

@@ -1,191 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# M5: Generate perf profiles + flamegraphs + hotspots for Stage2 objects.
# Default target is ROA max sample since it is currently the largest gap in
# ours-vs-routinator decode comparison.
#
# Outputs (under rpki/target/bench):
# - perf_stage2_ours_<type>_<sample>_decode.data
# - flamegraph_stage2_ours_<type>_<sample>_decode_release.svg
# - hotspots_stage2_ours_<type>_<sample>_decode_release.txt
# - perf_stage2_ours_<type>_<sample>_landing.data
# - flamegraph_stage2_ours_<type>_<sample>_landing_release.svg
# - hotspots_stage2_ours_<type>_<sample>_landing_release.txt
# - perf_stage2_routinator_<type>_<sample>_decode.data
# - flamegraph_stage2_routinator_<type>_<sample>_decode_release.svg
# - hotspots_stage2_routinator_<type>_<sample>_decode_release.txt
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT_DIR="$ROOT_DIR/target/bench"
mkdir -p "$OUT_DIR"
TYPE="${BENCH_TYPE:-roa}" # cer|crl|roa
SAMPLE="${BENCH_SAMPLE:-max}" # e.g. max|p99|p50
ITERS="${BENCH_ITERS:-2000}"
WARMUP="${BENCH_WARMUP_ITERS:-50}"
FREQ="${FLAMEGRAPH_FREQ:-99}"
PROFILE_LANDING="${PROFILE_LANDING:-1}" # 0|1
if ! command -v perf >/dev/null 2>&1; then
echo "ERROR: perf not found." >&2
exit 2
fi
if ! command -v flamegraph >/dev/null 2>&1; then
echo "ERROR: flamegraph not found. Install with: cargo install flamegraph" >&2
exit 2
fi
# On WSL2, /usr/bin/perf is often a wrapper that errors because there is no
# kernel-matched perf binary. In that case, prefer a real perf binary under
# /usr/lib/linux-tools/*/perf by putting a shim earlier in PATH.
PERF_WRAPPER_OK=1
PERF_VERSION_OUT="$(perf --version 2>&1 || true)"
if echo "${PERF_VERSION_OUT}" | grep -q "WARNING: perf not found for kernel"; then
PERF_WRAPPER_OK=0
fi
if [[ "${PERF_WRAPPER_OK}" == "0" ]]; then
PERF_REAL="$(ls -1 /usr/lib/linux-tools/*/perf 2>/dev/null | head -n 1 || true)"
if [[ -z "${PERF_REAL}" ]]; then
echo "ERROR: perf wrapper found, but no real perf binary under /usr/lib/linux-tools/*/perf" >&2
exit 2
fi
SHIM_DIR="$OUT_DIR/tools"
mkdir -p "${SHIM_DIR}"
cat > "${SHIM_DIR}/perf" <<EOF
#!/usr/bin/env bash
exec "${PERF_REAL}" "\$@"
EOF
chmod +x "${SHIM_DIR}/perf"
export PATH="${SHIM_DIR}:${PATH}"
echo "Using perf shim -> ${PERF_REAL}" >&2
fi
echo "type=${TYPE} sample=${SAMPLE} iters=${ITERS} warmup=${WARMUP} freq=${FREQ}" >&2
build_ours_test_bin() {
(cd "$ROOT_DIR" && cargo test --release --test bench_stage2_decode_profile_selected_der_v2 --no-run -q)
local bin
bin="$(find "$ROOT_DIR/target/release/deps" -maxdepth 1 -type f -executable -name 'bench_stage2_decode_profile_selected_der_v2-*' | head -n 1 || true)"
if [[ -z "${bin}" ]]; then
echo "ERROR: failed to locate ours test binary under target/release/deps" >&2
exit 2
fi
echo "${bin}"
}
build_routinator_bin() {
(cd "$ROOT_DIR/benchmark/routinator_object_bench" && cargo build --release -q)
local bin="$ROOT_DIR/benchmark/routinator_object_bench/target/release/routinator-object-bench"
if [[ ! -x "${bin}" ]]; then
echo "ERROR: failed to locate routinator-object-bench binary: ${bin}" >&2
exit 2
fi
echo "${bin}"
}
run_perf_profile() {
local perfdata="$1"
shift
echo "[perf record] $perfdata" >&2
perf record -o "$perfdata" -F "$FREQ" --call-graph dwarf -g -- "$@"
}
gen_flamegraph() {
local perfdata="$1"
local out_svg="$2"
local title="$3"
local subtitle="$4"
echo "[flamegraph] $out_svg" >&2
flamegraph --perfdata "$perfdata" --output "$out_svg" --title "$title" --subtitle "$subtitle" >/dev/null
}
gen_hotspots() {
local perfdata="$1"
local out_txt="$2"
echo "[perf report] $out_txt" >&2
# Keep it deterministic enough for diffing; avoid call-graph children view.
perf report --stdio -i "$perfdata" --no-children --sort overhead,symbol,dso > "$out_txt" || true
}
OURS_BIN="$(build_ours_test_bin)"
ROUT_BIN="$(build_routinator_bin)"
# OURS decode+validate
OURS_DECODE_PERF="$OUT_DIR/perf_stage2_ours_${TYPE}_${SAMPLE}_decode.data"
OURS_DECODE_SVG="$OUT_DIR/flamegraph_stage2_ours_${TYPE}_${SAMPLE}_decode_release.svg"
OURS_DECODE_HOT="$OUT_DIR/hotspots_stage2_ours_${TYPE}_${SAMPLE}_decode_release.txt"
run_perf_profile "$OURS_DECODE_PERF" \
env \
BENCH_MODE=decode_validate \
BENCH_TYPE="$TYPE" \
BENCH_SAMPLE="$SAMPLE" \
BENCH_ITERS="$ITERS" \
BENCH_ROUNDS=1 \
BENCH_WARMUP_ITERS="$WARMUP" \
"$OURS_BIN" --ignored --nocapture >/dev/null
gen_flamegraph "$OURS_DECODE_PERF" "$OURS_DECODE_SVG" \
"stage2 ours decode_validate ${TYPE}.${SAMPLE}" "iters=${ITERS} warmup=${WARMUP} freq=${FREQ}"
gen_hotspots "$OURS_DECODE_PERF" "$OURS_DECODE_HOT"
# OURS landing
if [[ "${PROFILE_LANDING}" == "1" ]]; then
OURS_LAND_PERF="$OUT_DIR/perf_stage2_ours_${TYPE}_${SAMPLE}_landing.data"
OURS_LAND_SVG="$OUT_DIR/flamegraph_stage2_ours_${TYPE}_${SAMPLE}_landing_release.svg"
OURS_LAND_HOT="$OUT_DIR/hotspots_stage2_ours_${TYPE}_${SAMPLE}_landing_release.txt"
run_perf_profile "$OURS_LAND_PERF" \
env \
BENCH_MODE=landing \
BENCH_TYPE="$TYPE" \
BENCH_SAMPLE="$SAMPLE" \
BENCH_ITERS="$ITERS" \
BENCH_ROUNDS=1 \
BENCH_WARMUP_ITERS="$WARMUP" \
"$OURS_BIN" --ignored --nocapture >/dev/null
gen_flamegraph "$OURS_LAND_PERF" "$OURS_LAND_SVG" \
"stage2 ours landing ${TYPE}.${SAMPLE}" "iters=${ITERS} warmup=${WARMUP} freq=${FREQ}"
gen_hotspots "$OURS_LAND_PERF" "$OURS_LAND_HOT"
fi
# Routinator baseline decode
ROUT_DECODE_PERF="$OUT_DIR/perf_stage2_routinator_${TYPE}_${SAMPLE}_decode.data"
ROUT_DECODE_SVG="$OUT_DIR/flamegraph_stage2_routinator_${TYPE}_${SAMPLE}_decode_release.svg"
ROUT_DECODE_HOT="$OUT_DIR/hotspots_stage2_routinator_${TYPE}_${SAMPLE}_decode_release.txt"
run_perf_profile "$ROUT_DECODE_PERF" \
"$ROUT_BIN" \
--dir "$ROOT_DIR/tests/benchmark/selected_der_v2" \
--type "$TYPE" \
--sample "$SAMPLE" \
--iters "$ITERS" \
--rounds 1 \
--warmup-iters "$WARMUP" \
--min-round-ms 1 \
--max-iters 1000000 \
--strict true \
--out-csv "$OUT_DIR/_tmp_routinator_profile.csv" \
--out-md "$OUT_DIR/_tmp_routinator_profile.md" \
>/dev/null
gen_flamegraph "$ROUT_DECODE_PERF" "$ROUT_DECODE_SVG" \
"stage2 routinator decode ${TYPE}.${SAMPLE}" "iters=${ITERS} warmup=${WARMUP} freq=${FREQ}"
gen_hotspots "$ROUT_DECODE_PERF" "$ROUT_DECODE_HOT"
echo "Done." >&2
echo "- ours decode flamegraph: $OURS_DECODE_SVG" >&2
echo "- ours decode hotspots: $OURS_DECODE_HOT" >&2
if [[ "${PROFILE_LANDING}" == "1" ]]; then
echo "- ours landing flamegraph: $OURS_LAND_SVG" >&2
echo "- ours landing hotspots: $OURS_LAND_HOT" >&2
fi
echo "- rout decode flamegraph: $ROUT_DECODE_SVG" >&2
echo "- rout decode hotspots: $ROUT_DECODE_HOT" >&2

View File

@@ -1,253 +0,0 @@
use std::path::Path;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SapPublicationPoint {
pub header: SapPointHeader,
pub manifest: Option<SapStoredManifest>,
pub objects: Vec<SapStoredObject>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SapPointHeader {
pub version: u8,
pub manifest_uri: String,
pub rpki_notify: Option<String>,
pub update_status: SapUpdateStatus,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SapUpdateStatus {
Success { time_utc: time::OffsetDateTime },
LastAttempt { time_utc: time::OffsetDateTime },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SapStoredManifest {
pub not_after_utc: time::OffsetDateTime,
pub manifest_number: [u8; 20],
pub this_update_utc: time::OffsetDateTime,
pub ca_repository: String,
pub manifest_der: Vec<u8>,
pub crl_uri: String,
pub crl_der: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SapStoredObject {
pub uri: String,
pub hash_sha256: Option<[u8; 32]>,
pub content_der: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum SapDecodeError {
#[error("I/O error reading {path}: {err}")]
Io { path: String, err: String },
#[error("SAP decode error: {0}")]
Decode(String),
}
impl SapPublicationPoint {
pub fn decode_path(path: &Path) -> Result<Self, SapDecodeError> {
let bytes = std::fs::read(path).map_err(|e| SapDecodeError::Io {
path: path.display().to_string(),
err: e.to_string(),
})?;
Self::decode_bytes(&bytes)
}
pub fn decode_bytes(bytes: &[u8]) -> Result<Self, SapDecodeError> {
let mut cur = Cursor::new(bytes);
let version = cur.read_u8()?;
let manifest_uri = cur.read_string_u32()?;
let rpki_notify = cur.read_option_string_u32()?;
let update_kind = cur.read_u8()?;
let update_time_utc = cur.read_time_utc_i64_be()?;
let update_status = match update_kind {
0 => SapUpdateStatus::Success {
time_utc: update_time_utc,
},
1 => SapUpdateStatus::LastAttempt {
time_utc: update_time_utc,
},
_ => {
return Err(SapDecodeError::Decode(format!(
"invalid update_status kind: {update_kind}"
)));
}
};
let header = SapPointHeader {
version,
manifest_uri,
rpki_notify,
update_status: update_status.clone(),
};
match update_status {
SapUpdateStatus::LastAttempt { .. } => Ok(SapPublicationPoint {
header,
manifest: None,
objects: Vec::new(),
}),
SapUpdateStatus::Success { .. } => {
let not_after_utc = cur.read_time_utc_i64_be()?;
let manifest_number = cur.read_array_20()?;
let this_update_utc = cur.read_time_utc_i64_be()?;
let ca_repository = cur.read_string_u32()?;
let manifest_der = cur.read_bytes_u64_vec()?;
let crl_uri = cur.read_string_u32()?;
let crl_der = cur.read_bytes_u64_vec()?;
let manifest = SapStoredManifest {
not_after_utc,
manifest_number,
this_update_utc,
ca_repository,
manifest_der,
crl_uri,
crl_der,
};
let mut objects = Vec::new();
while cur.remaining() > 0 {
let uri = match cur.try_read_string_u32() {
Ok(Some(v)) => v,
Ok(None) => break,
Err(e) => return Err(e),
};
let hash_type = cur.read_u8()?;
let hash_sha256 = match hash_type {
0 => None,
1 => Some(cur.read_array_32()?),
other => {
return Err(SapDecodeError::Decode(format!(
"unsupported hash_type {other} for stored object {uri}"
)));
}
};
let content_der = cur.read_bytes_u64_vec()?;
objects.push(SapStoredObject {
uri,
hash_sha256,
content_der,
});
}
Ok(SapPublicationPoint {
header,
manifest: Some(manifest),
objects,
})
}
}
}
}
struct Cursor<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Cursor<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, pos: 0 }
}
fn remaining(&self) -> usize {
self.bytes.len().saturating_sub(self.pos)
}
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], SapDecodeError> {
if len > self.remaining() {
return Err(SapDecodeError::Decode(format!(
"truncated input: need {len} bytes, have {}",
self.remaining()
)));
}
let start = self.pos;
self.pos += len;
Ok(&self.bytes[start..start + len])
}
fn read_u8(&mut self) -> Result<u8, SapDecodeError> {
Ok(self.read_exact(1)?[0])
}
fn read_u32_be(&mut self) -> Result<u32, SapDecodeError> {
let b = self.read_exact(4)?;
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
fn read_u64_be(&mut self) -> Result<u64, SapDecodeError> {
let b = self.read_exact(8)?;
Ok(u64::from_be_bytes([
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
]))
}
fn read_i64_be(&mut self) -> Result<i64, SapDecodeError> {
let b = self.read_exact(8)?;
Ok(i64::from_be_bytes([
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
]))
}
fn read_time_utc_i64_be(&mut self) -> Result<time::OffsetDateTime, SapDecodeError> {
let ts = self.read_i64_be()?;
time::OffsetDateTime::from_unix_timestamp(ts)
.map_err(|e| SapDecodeError::Decode(format!("invalid unix timestamp {ts}: {e}")))
}
fn read_string_u32(&mut self) -> Result<String, SapDecodeError> {
let len = self.read_u32_be()? as usize;
let b = self.read_exact(len)?;
std::str::from_utf8(b)
.map(|s| s.to_string())
.map_err(|e| SapDecodeError::Decode(format!("invalid UTF-8 string: {e}")))
}
fn try_read_string_u32(&mut self) -> Result<Option<String>, SapDecodeError> {
if self.remaining() == 0 {
return Ok(None);
}
Ok(Some(self.read_string_u32()?))
}
fn read_option_string_u32(&mut self) -> Result<Option<String>, SapDecodeError> {
let len = self.read_u32_be()? as usize;
if len == 0 {
return Ok(None);
}
let b = self.read_exact(len)?;
let s = std::str::from_utf8(b)
.map_err(|e| SapDecodeError::Decode(format!("invalid UTF-8 string: {e}")))?;
Ok(Some(s.to_string()))
}
fn read_bytes_u64_vec(&mut self) -> Result<Vec<u8>, SapDecodeError> {
let len_u64 = self.read_u64_be()?;
let len = usize::try_from(len_u64).map_err(|_| {
SapDecodeError::Decode(format!("data block too large for this system: {len_u64}"))
})?;
Ok(self.read_exact(len)?.to_vec())
}
fn read_array_20(&mut self) -> Result<[u8; 20], SapDecodeError> {
let b = self.read_exact(20)?;
let mut out = [0u8; 20];
out.copy_from_slice(b);
Ok(out)
}
fn read_array_32(&mut self) -> Result<[u8; 32], SapDecodeError> {
let b = self.read_exact(32)?;
let mut out = [0u8; 32];
out.copy_from_slice(b);
Ok(out)
}
}

View File

@@ -1,8 +0,0 @@
small-01 3160 1875 stored_point_container bench/full/cache/stored/rrdp/rrdp.twnic.tw/9e5ef8f1eb47c1bd46d2c578ebefa6a774fe140dbfbfeff1a018f1ebfc1de14b/rsync/rpkica.twnic.tw/rpki/TWNICCA/ATT/VRjTaiHoQb_taL2Agyl1Nd1wiwU.mft
small-02 3160 1875 stored_point_container bench/full/cache/stored/rrdp/rrdp.twnic.tw/9e5ef8f1eb47c1bd46d2c578ebefa6a774fe140dbfbfeff1a018f1ebfc1de14b/rsync/rpkica.twnic.tw/rpki/TWNICCA/BOT/Kn9vIkC0LSu8Df78l2yrIJHEfsc.mft
medium-01 16384 2681 stored_point_container bench/full/cache/stored/rrdp/rrdp.arin.net/e2c8cb4b372d841061fc231dcdc28a679c244f4f630d41cfe01c8c3aaa3a36f7/rsync/rpki.arin.net/repository/arin-rpki-ta/5e4a23ea-e80a-403e-b08c-2171da2157d3/69fd0156-bb1f-48b6-bf32-c9492286f195/a32aa006-ef22-4905-8c82-2651c19859b9/a32aa006-ef22-4905-8c82-2651c19859b9.mft
medium-02 16384 2681 stored_point_container bench/full/cache/stored/rrdp/rrdp.arin.net/e2c8cb4b372d841061fc231dcdc28a679c244f4f630d41cfe01c8c3aaa3a36f7/rsync/rpki.arin.net/repository/arin-rpki-ta/5e4a23ea-e80a-403e-b08c-2171da2157d3/6ead073c-b9f0-4523-b39e-a3c7dab97c25/dfc6770a-8775-4ba8-9d91-68facba79b33/dfc6770a-8775-4ba8-9d91-68facba79b33.mft
large-01 262869 2250 stored_point_container bench/full/cache/stored/rrdp/rpki-rrdp.us-east-2.amazonaws.com/c7cf30a5673053f1484f06ac72410809310e150ad73a8a7471cc95aad83b1e07/rsync/rpki-rsync.us-east-2.amazonaws.com/volume/16f1ffee-7461-4674-bb05-fddefa9a02c6/JmLOFOkF4Y68t1IvkrNoS8SGW00.mft
large-02 264331 10476 stored_point_container bench/full/cache/stored/rrdp/rrdp.ripe.net/2315d99a99627f34bc597569abc7c177ad45108a37f173f3d06dbabd64962f3c/rsync/rpki.ripe.net/repository/DEFAULT/fa/bc92e6-c8ee-48f0-ae7f-36ccb5a06195/1/tDgLm4wHBFftVLxF0S3d0kTgbVI.mft
xlarge-01 4522145 144968 stored_point_container bench/full/cache/stored/rrdp/rrdp.arin.net/e2c8cb4b372d841061fc231dcdc28a679c244f4f630d41cfe01c8c3aaa3a36f7/rsync/rpki.arin.net/repository/arin-rpki-ta/5e4a23ea-e80a-403e-b08c-2171da2157d3/fde169ed-d0d2-4165-8308-df2597e343f8/c2070b9e-789d-4f95-bca5-30e065e9a31d/c2070b9e-789d-4f95-bca5-30e065e9a31d.mft
xlarge-02 4615094 150261 stored_point_container bench/full/cache/stored/rrdp/rrdp.arin.net/e2c8cb4b372d841061fc231dcdc28a679c244f4f630d41cfe01c8c3aaa3a36f7/rsync/rpki.arin.net/repository/arin-rpki-ta/5e4a23ea-e80a-403e-b08c-2171da2157d3/f60c9f32-a87c-4339-a2f3-6299a3b02e29/b2f0a061-78dd-4d61-988a-266b192d9caa/b2f0a061-78dd-4d61-988a-266b192d9caa.mft
1 small-01 3160 1875 stored_point_container bench/full/cache/stored/rrdp/rrdp.twnic.tw/9e5ef8f1eb47c1bd46d2c578ebefa6a774fe140dbfbfeff1a018f1ebfc1de14b/rsync/rpkica.twnic.tw/rpki/TWNICCA/ATT/VRjTaiHoQb_taL2Agyl1Nd1wiwU.mft
2 small-02 3160 1875 stored_point_container bench/full/cache/stored/rrdp/rrdp.twnic.tw/9e5ef8f1eb47c1bd46d2c578ebefa6a774fe140dbfbfeff1a018f1ebfc1de14b/rsync/rpkica.twnic.tw/rpki/TWNICCA/BOT/Kn9vIkC0LSu8Df78l2yrIJHEfsc.mft
3 medium-01 16384 2681 stored_point_container bench/full/cache/stored/rrdp/rrdp.arin.net/e2c8cb4b372d841061fc231dcdc28a679c244f4f630d41cfe01c8c3aaa3a36f7/rsync/rpki.arin.net/repository/arin-rpki-ta/5e4a23ea-e80a-403e-b08c-2171da2157d3/69fd0156-bb1f-48b6-bf32-c9492286f195/a32aa006-ef22-4905-8c82-2651c19859b9/a32aa006-ef22-4905-8c82-2651c19859b9.mft
4 medium-02 16384 2681 stored_point_container bench/full/cache/stored/rrdp/rrdp.arin.net/e2c8cb4b372d841061fc231dcdc28a679c244f4f630d41cfe01c8c3aaa3a36f7/rsync/rpki.arin.net/repository/arin-rpki-ta/5e4a23ea-e80a-403e-b08c-2171da2157d3/6ead073c-b9f0-4523-b39e-a3c7dab97c25/dfc6770a-8775-4ba8-9d91-68facba79b33/dfc6770a-8775-4ba8-9d91-68facba79b33.mft
5 large-01 262869 2250 stored_point_container bench/full/cache/stored/rrdp/rpki-rrdp.us-east-2.amazonaws.com/c7cf30a5673053f1484f06ac72410809310e150ad73a8a7471cc95aad83b1e07/rsync/rpki-rsync.us-east-2.amazonaws.com/volume/16f1ffee-7461-4674-bb05-fddefa9a02c6/JmLOFOkF4Y68t1IvkrNoS8SGW00.mft
6 large-02 264331 10476 stored_point_container bench/full/cache/stored/rrdp/rrdp.ripe.net/2315d99a99627f34bc597569abc7c177ad45108a37f173f3d06dbabd64962f3c/rsync/rpki.ripe.net/repository/DEFAULT/fa/bc92e6-c8ee-48f0-ae7f-36ccb5a06195/1/tDgLm4wHBFftVLxF0S3d0kTgbVI.mft
7 xlarge-01 4522145 144968 stored_point_container bench/full/cache/stored/rrdp/rrdp.arin.net/e2c8cb4b372d841061fc231dcdc28a679c244f4f630d41cfe01c8c3aaa3a36f7/rsync/rpki.arin.net/repository/arin-rpki-ta/5e4a23ea-e80a-403e-b08c-2171da2157d3/fde169ed-d0d2-4165-8308-df2597e343f8/c2070b9e-789d-4f95-bca5-30e065e9a31d/c2070b9e-789d-4f95-bca5-30e065e9a31d.mft
8 xlarge-02 4615094 150261 stored_point_container bench/full/cache/stored/rrdp/rrdp.arin.net/e2c8cb4b372d841061fc231dcdc28a679c244f4f630d41cfe01c8c3aaa3a36f7/rsync/rpki.arin.net/repository/arin-rpki-ta/5e4a23ea-e80a-403e-b08c-2171da2157d3/f60c9f32-a87c-4339-a2f3-6299a3b02e29/b2f0a061-78dd-4d61-988a-266b192d9caa/b2f0a061-78dd-4d61-988a-266b192d9caa.mft

View File

@@ -1,9 +0,0 @@
name input_path der_path input_size der_size source_format
large-01 benchmark/fixtures/selected/large-01.mft benchmark/fixtures/selected_der/large-01.mft 262869 2250 stored_point_container
large-02 benchmark/fixtures/selected/large-02.mft benchmark/fixtures/selected_der/large-02.mft 264331 10476 stored_point_container
medium-01 benchmark/fixtures/selected/medium-01.mft benchmark/fixtures/selected_der/medium-01.mft 16384 2681 stored_point_container
medium-02 benchmark/fixtures/selected/medium-02.mft benchmark/fixtures/selected_der/medium-02.mft 16384 2681 stored_point_container
small-01 benchmark/fixtures/selected/small-01.mft benchmark/fixtures/selected_der/small-01.mft 3160 1875 stored_point_container
small-02 benchmark/fixtures/selected/small-02.mft benchmark/fixtures/selected_der/small-02.mft 3160 1875 stored_point_container
xlarge-01 benchmark/fixtures/selected/xlarge-01.mft benchmark/fixtures/selected_der/xlarge-01.mft 4522145 144968 stored_point_container
xlarge-02 benchmark/fixtures/selected/xlarge-02.mft benchmark/fixtures/selected_der/xlarge-02.mft 4615094 150261 stored_point_container
1 name input_path der_path input_size der_size source_format
2 large-01 benchmark/fixtures/selected/large-01.mft benchmark/fixtures/selected_der/large-01.mft 262869 2250 stored_point_container
3 large-02 benchmark/fixtures/selected/large-02.mft benchmark/fixtures/selected_der/large-02.mft 264331 10476 stored_point_container
4 medium-01 benchmark/fixtures/selected/medium-01.mft benchmark/fixtures/selected_der/medium-01.mft 16384 2681 stored_point_container
5 medium-02 benchmark/fixtures/selected/medium-02.mft benchmark/fixtures/selected_der/medium-02.mft 16384 2681 stored_point_container
6 small-01 benchmark/fixtures/selected/small-01.mft benchmark/fixtures/selected_der/small-01.mft 3160 1875 stored_point_container
7 small-02 benchmark/fixtures/selected/small-02.mft benchmark/fixtures/selected_der/small-02.mft 3160 1875 stored_point_container
8 xlarge-01 benchmark/fixtures/selected/xlarge-01.mft benchmark/fixtures/selected_der/xlarge-01.mft 4522145 144968 stored_point_container
9 xlarge-02 benchmark/fixtures/selected/xlarge-02.mft benchmark/fixtures/selected_der/xlarge-02.mft 4615094 150261 stored_point_container

Some files were not shown because too many files have changed in this diff Show More