Compare commits
14 Commits
main
...
afc50364f8
| Author | SHA1 | Date | |
|---|---|---|---|
| afc50364f8 | |||
| 6276d13814 | |||
| 0f3d65254e | |||
| 13516c4f73 | |||
| 68cbd3c500 | |||
| 1cc3351bef | |||
| 2a6a963ecd | |||
| 6e135b9d7a | |||
| afc31c02ab | |||
| 7be865d7f1 | |||
| a58e507f92 | |||
| cc9f3f21de | |||
| 56ae2ca4fc | |||
| bcd4829486 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
target/
|
||||
Cargo.lock
|
||||
perf.*
|
||||
|
||||
28
Cargo.toml
28
Cargo.toml
@@ -3,13 +3,33 @@ 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]
|
||||
der-parser = "10.0.0"
|
||||
asn1-rs = "0.7.1"
|
||||
der-parser = { version = "10.0.0", features = ["serialize"] }
|
||||
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"
|
||||
asn1-rs = "0.7.1"
|
||||
asn1-rs-derive = "0.6.0"
|
||||
asn1 = "0.23.0"
|
||||
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"
|
||||
|
||||
17
README.md
17
README.md
@@ -9,3 +9,20 @@ 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
|
||||
```
|
||||
|
||||
|
||||
8
benchmark/ours_manifest_bench/Cargo.toml
Normal file
8
benchmark/ours_manifest_bench/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "ours-manifest-bench"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rpki = { path = "../..", default-features = false }
|
||||
|
||||
145
benchmark/ours_manifest_bench/src/main.rs
Normal file
145
benchmark/ours_manifest_bench/src/main.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
8
benchmark/routinator_object_bench/Cargo.toml
Normal file
8
benchmark/routinator_object_bench/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "routinator-object-bench"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
rpki = { version = "=0.19.1", features = ["repository"] }
|
||||
552
benchmark/routinator_object_bench/src/main.rs
Normal file
552
benchmark/routinator_object_bench/src/main.rs
Normal file
@@ -0,0 +1,552 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
70
scripts/benchmark/README.md
Normal file
70
scripts/benchmark/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 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`.
|
||||
123
scripts/benchmark/run_stage2_selected_der_v2_release.sh
Executable file
123
scripts/benchmark/run_stage2_selected_der_v2_release.sh
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/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
|
||||
80
scripts/coverage.sh
Executable file
80
scripts/coverage.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/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"
|
||||
195
scripts/manifest_perf_compare_m2.sh
Executable file
195
scripts/manifest_perf_compare_m2.sh
Executable file
@@ -0,0 +1,195 @@
|
||||
#!/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"
|
||||
142
scripts/manifest_perf_profile_m3.sh
Executable file
142
scripts/manifest_perf_profile_m3.sh
Executable file
@@ -0,0 +1,142 @@
|
||||
#!/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/"
|
||||
94
scripts/manual_sync/README.md
Normal file
94
scripts/manual_sync/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 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
|
||||
```
|
||||
515
scripts/manual_sync/delta_sync.sh
Executable file
515
scripts/manual_sync/delta_sync.sh
Executable file
@@ -0,0 +1,515 @@
|
||||
#!/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
|
||||
189
scripts/manual_sync/full_sync.sh
Executable file
189
scripts/manual_sync/full_sync.sh
Executable file
@@ -0,0 +1,189 @@
|
||||
#!/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
|
||||
123
scripts/stage2_perf_compare_m4.sh
Executable file
123
scripts/stage2_perf_compare_m4.sh
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/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
|
||||
@@ -182,6 +182,8 @@ 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 |
|
||||
|
||||
104
specs/01_tal.md
104
specs/01_tal.md
@@ -1,36 +1,94 @@
|
||||
# 01. Trust Anchor Locator (TAL)
|
||||
# 01. TAL(Trust Anchor Locator)
|
||||
|
||||
## 1.1 对象定位
|
||||
TAL是一个数据格式/配置文件,目的是告诉RP信任锚的公钥是什么,以及相关对象可以从哪里获取。
|
||||
|
||||
## 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>".
|
||||
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.3 抽象数据模型
|
||||
其中 `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 TAL
|
||||
## 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`
|
||||
|
||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
||||
|----------|-------------|-------------------------|--------------------------------------------|---------------|
|
||||
| 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 |
|
||||
|---|---|---|---|---|
|
||||
| `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 |
|
||||
|
||||
### 1.4.2 `TaUri`(可选细化)
|
||||
|
||||
### 1.3.2 TalUri
|
||||
> 若你的实现希望对 URI 做更强类型化,可在 `Tal.ta_uris` 上进一步拆分为 `TaUri` 结构。
|
||||
|
||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
||||
|-------|--------|---------|---------|---------------|
|
||||
| Rsync | String | rsync地址 | | RFC 8630 §2.1 |
|
||||
| Https | String | https地址 | | RFC 8630 §2.1 |
|
||||
|---|---|---|---|---|
|
||||
| `uri` | `Uri` | 完整 URI 文本 | scheme 为 `rsync` 或 `https` | RFC 8630 §2.2 |
|
||||
| `scheme` | `enum` | `rsync` / `https` | 从 `uri` 解析 | RFC 8630 §2.2 |
|
||||
|
||||
## 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
121
specs/02_ta.md
@@ -1,121 +0,0 @@
|
||||
# 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. 更新本地存储库缓存。
|
||||
88
specs/02_ta_certificate.md
Normal file
88
specs/02_ta_certificate.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 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
314
specs/03_rc.md
@@ -1,314 +0,0 @@
|
||||
# 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 |
|
||||
|
||||
|
||||
460
specs/03_resource_certificate_rc.md
Normal file
460
specs/03_resource_certificate_rc.md
Normal file
@@ -0,0 +1,460 @@
|
||||
# 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。
|
||||
158
specs/05_signed_object_cms.md
Normal file
158
specs/05_signed_object_cms.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 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)。
|
||||
110
specs/06_manifest_mft.md
Normal file
110
specs/06_manifest_mft.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 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(生成步骤)。
|
||||
159
specs/07_roa.md
Normal file
159
specs/07_roa.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# 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。
|
||||
100
specs/08_aspa.md
Normal file
100
specs/08_aspa.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 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。
|
||||
|
||||
87
specs/09_ghostbusters_gbr.md
Normal file
87
specs/09_ghostbusters_gbr.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 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)。
|
||||
|
||||
5532
specs/arch.excalidraw
Normal file
5532
specs/arch.excalidraw
Normal file
File diff suppressed because it is too large
Load Diff
1
src/analysis/mod.rs
Normal file
1
src/analysis/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod timing;
|
||||
308
src/analysis/timing.rs
Normal file
308
src/analysis/timing.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
224
src/audit.rs
Normal file
224
src/audit.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
168
src/audit_downloads.rs
Normal file
168
src/audit_downloads.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
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
|
||||
}
|
||||
|
||||
125
src/bin/db_stats.rs
Normal file
125
src/bin/db_stats.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
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(())
|
||||
}
|
||||
12
src/bin/rpki.rs
Normal file
12
src/bin/rpki.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
85
src/bin/rrdp_state_dump.rs
Normal file
85
src/bin/rrdp_state_dump.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
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(())
|
||||
}
|
||||
926
src/cli.rs
Normal file
926
src/cli.rs
Normal file
@@ -0,0 +1,926 @@
|
||||
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\""));
|
||||
}
|
||||
}
|
||||
393
src/data_model/aspa.rs
Normal file
393
src/data_model/aspa.rs
Normal file
@@ -0,0 +1,393 @@
|
||||
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)
|
||||
}
|
||||
302
src/data_model/common.rs
Normal file
302
src/data_model/common.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
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"];
|
||||
@@ -1,51 +1,15 @@
|
||||
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::asn1_rs::Tag;
|
||||
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::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)
|
||||
}
|
||||
}
|
||||
use x509_parser::extensions::{ParsedExtension, X509Extension};
|
||||
use x509_parser::prelude::{FromDer, 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};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RevokedCert {
|
||||
@@ -71,54 +35,131 @@ 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 CrlDecodeError {
|
||||
#[error("X.509 CRL parse error: {0}")]
|
||||
pub enum CrlParseError {
|
||||
#[error("X.509 CRL parse error: {0} (RFC 5280 §5.1; RFC 6487 §5)")]
|
||||
Parse(String),
|
||||
|
||||
#[error("trailing bytes after CRL DER: {0} bytes")]
|
||||
#[error("trailing bytes after CRL DER: {0} bytes (DER; RFC 5280 §5.1)")]
|
||||
TrailingBytes(usize),
|
||||
}
|
||||
|
||||
#[error("CRL version must be v2, got {0:?}")]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CrlProfileError {
|
||||
#[error("CRL version must be v2, got {0:?} (RFC 5280 §5.1; RFC 6487 §5)")]
|
||||
InvalidVersion(Option<u32>),
|
||||
|
||||
#[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")]
|
||||
InvalidSignatureAlgorithmParameters,
|
||||
|
||||
#[error("CRL signatureAlgorithm must match TBSCertList.signature")]
|
||||
#[error("CRL signatureAlgorithm must match TBSCertList.signature (RFC 5280 §5.1)")]
|
||||
SignatureAlgorithmMismatch,
|
||||
|
||||
#[error("CRL extensions must be exactly two (AKI + CRLNumber), got {0}")]
|
||||
#[error(
|
||||
"CRL signatureAlgorithm must be sha256WithRSAEncryption ({OID_SHA256_WITH_RSA_ENCRYPTION}), got {0} (RFC 6487 §5; RFC 7935 §2)"
|
||||
)]
|
||||
InvalidSignatureAlgorithm(String),
|
||||
|
||||
#[error(
|
||||
"CRL signature algorithm parameters must be absent or NULL (RFC 5280 §4.1.1.2; RFC 7935 §2)"
|
||||
)]
|
||||
InvalidSignatureAlgorithmParameters,
|
||||
|
||||
#[error("CRL extensions must be exactly two (AKI + CRLNumber), got {0} (RFC 9829 §3.1)")]
|
||||
InvalidExtensionsCount(usize),
|
||||
|
||||
#[error("unsupported CRL extension OID {0}")]
|
||||
#[error("unsupported CRL extension OID {0} (RFC 9829 §3.1)")]
|
||||
UnsupportedExtension(String),
|
||||
|
||||
#[error("duplicate CRL extension OID {0}")]
|
||||
#[error("duplicate CRL extension OID {0} (RFC 5280 §4.2; RFC 9829 §3.1)")]
|
||||
DuplicateExtension(String),
|
||||
|
||||
#[error("AuthorityKeyIdentifier must contain keyIdentifier")]
|
||||
#[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)")]
|
||||
AkiMissingKeyIdentifier,
|
||||
|
||||
#[error("AuthorityKeyIdentifier must not contain authorityCertIssuer or authorityCertSerialNumber")]
|
||||
#[error(
|
||||
"AuthorityKeyIdentifier must not contain authorityCertIssuer or authorityCertSerialNumber (RFC 5280 §5.2.1; RFC 9829 §3.1)"
|
||||
)]
|
||||
AkiHasOtherFields,
|
||||
|
||||
#[error("CRLNumber must be non-critical")]
|
||||
#[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)")]
|
||||
CrlNumberCritical,
|
||||
|
||||
#[error("CRLNumber out of range (must fit in 0..2^159-1)")]
|
||||
#[error("CRLNumber out of range (must fit in 0..2^159-1) (RFC 9829 §3.1)")]
|
||||
CrlNumberOutOfRange,
|
||||
|
||||
#[error("CRL entry extensions must not be present")]
|
||||
#[error("CRL entry extensions must not be present (RFC 6487 §5; RFC 5280 §5.1)")]
|
||||
EntryExtensionsNotAllowed,
|
||||
|
||||
#[error("CRL nextUpdate must be present (RFC 5280 §5.1.2.5)")]
|
||||
#[error("CRL nextUpdate must be present (RFC 5280 §5.1.2.5; RFC 6487 §5)")]
|
||||
NextUpdateMissing,
|
||||
|
||||
#[error("{field} time encoding invalid for year {year}: got {encoding:?}")]
|
||||
#[error(
|
||||
"{field} time encoding invalid for year {year}: got {encoding:?} (RFC 5280 §5.1.2.4-§5.1.2.6)"
|
||||
)]
|
||||
InvalidTimeEncoding {
|
||||
field: &'static str,
|
||||
year: i32,
|
||||
@@ -126,64 +167,47 @@ pub enum CrlDecodeError {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CrlDecodeError {
|
||||
#[error("{0}")]
|
||||
Parse(#[from] CrlParseError),
|
||||
|
||||
#[error("{0}")]
|
||||
Validate(#[from] CrlProfileError),
|
||||
}
|
||||
|
||||
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> {
|
||||
/// Parse step of scheme A (`parse → validate → verify`).
|
||||
pub fn parse_der(der: &[u8]) -> Result<RpkixCrlParsed, CrlParseError> {
|
||||
let (rem, crl) = CertificateRevocationList::from_der(der)
|
||||
.map_err(|e| CrlDecodeError::Parse(e.to_string()))?;
|
||||
.map_err(|e| CrlParseError::Parse(e.to_string()))?;
|
||||
if !rem.is_empty() {
|
||||
return Err(CrlDecodeError::TrailingBytes(rem.len()));
|
||||
return Err(CrlParseError::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| {
|
||||
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,
|
||||
.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(),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let this_update = asn1_time_to_model(crl.last_update());
|
||||
validate_time_encoding("thisUpdate", &this_update)?;
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let this_update = crate::data_model::common::asn1_time_to_model(crl.last_update());
|
||||
let next_update = crl
|
||||
.next_update()
|
||||
.map(asn1_time_to_model)
|
||||
.ok_or(CrlDecodeError::NextUpdateMissing)?;
|
||||
validate_time_encoding("nextUpdate", &next_update)?;
|
||||
.map(crate::data_model::common::asn1_time_to_model);
|
||||
|
||||
Ok(RpkixCrl {
|
||||
let extensions =
|
||||
parse_extensions_parse(crl.extensions()).map_err(|e| CrlParseError::Parse(e))?;
|
||||
|
||||
Ok(RpkixCrlParsed {
|
||||
raw_der: der.to_vec(),
|
||||
version,
|
||||
version: crl.version(),
|
||||
issuer_dn: crl.issuer().to_string(),
|
||||
signature_algorithm_oid: OID_SHA256_WITH_RSA_ENCRYPTION.to_string(),
|
||||
signature_algorithm: algorithm_identifier_value(&crl.signature_algorithm),
|
||||
tbs_signature_algorithm: algorithm_identifier_value(&crl.tbs_cert_list.signature),
|
||||
this_update,
|
||||
next_update,
|
||||
revoked_certs,
|
||||
@@ -191,6 +215,20 @@ 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)).
|
||||
@@ -265,55 +303,107 @@ 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}")]
|
||||
#[error("issuer certificate parse error: {0} (RFC 5280 §4.1; RFC 6487 §4)")]
|
||||
IssuerCertificateParse(String),
|
||||
|
||||
#[error("trailing bytes after issuer certificate DER: {0} bytes")]
|
||||
#[error("trailing bytes after issuer certificate DER: {0} bytes (DER; RFC 5280 §4.1)")]
|
||||
IssuerCertificateTrailingBytes(usize),
|
||||
|
||||
#[error("issuer SubjectPublicKeyInfo parse error: {0}")]
|
||||
#[error("issuer SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7)")]
|
||||
IssuerSpkiParse(String),
|
||||
|
||||
#[error("trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes")]
|
||||
#[error(
|
||||
"trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 5280 §4.1.2.7)"
|
||||
)]
|
||||
IssuerSpkiTrailingBytes(usize),
|
||||
|
||||
#[error("CRL parse error: {0}")]
|
||||
#[error("CRL parse error: {0} (RFC 5280 §5.1; RFC 6487 §5)")]
|
||||
CrlParse(String),
|
||||
|
||||
#[error("trailing bytes after CRL DER: {0} bytes")]
|
||||
#[error("trailing bytes after CRL DER: {0} bytes (DER; RFC 5280 §5.1)")]
|
||||
CrlTrailingBytes(usize),
|
||||
|
||||
#[error("CRL issuer DN does not match issuer certificate subject")]
|
||||
#[error(
|
||||
"CRL issuer DN does not match issuer certificate subject (RFC 5280 §5.1; RFC 5280 §6.3.3(b))"
|
||||
)]
|
||||
IssuerSubjectMismatch {
|
||||
crl_issuer_dn: String,
|
||||
issuer_subject_dn: String,
|
||||
},
|
||||
|
||||
#[error("issuer certificate keyUsage present but missing cRLSign")]
|
||||
#[error(
|
||||
"issuer certificate keyUsage present but missing cRLSign (RFC 5280 §4.2.1.3; RFC 5280 §6.3.3(f))"
|
||||
)]
|
||||
IssuerKeyUsageMissingCrlSign,
|
||||
|
||||
#[error("CRL AKI.keyIdentifier does not match issuer certificate SKI")]
|
||||
#[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))"
|
||||
)]
|
||||
AkiSkiMismatch,
|
||||
|
||||
#[error("CRL signature verification failed: {0}")]
|
||||
#[error("CRL signature verification failed: {0} (RFC 5280 §6.3.3(g); RFC 7935 §2)")]
|
||||
InvalidSignature(String),
|
||||
}
|
||||
|
||||
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> {
|
||||
fn validate_time_encoding_rfc5280(
|
||||
field: &'static str,
|
||||
t: &Asn1TimeUtc,
|
||||
) -> Result<(), CrlProfileError> {
|
||||
let year = t.utc.year();
|
||||
let expected = if year <= 2049 {
|
||||
Asn1TimeEncoding::UtcTime
|
||||
@@ -321,7 +411,7 @@ fn validate_time_encoding(field: &'static str, t: &Asn1TimeUtc) -> Result<(), Cr
|
||||
Asn1TimeEncoding::GeneralizedTime
|
||||
};
|
||||
if t.encoding != expected {
|
||||
return Err(CrlDecodeError::InvalidTimeEncoding {
|
||||
return Err(CrlProfileError::InvalidTimeEncoding {
|
||||
field,
|
||||
year,
|
||||
encoding: t.encoding,
|
||||
@@ -330,98 +420,117 @@ fn validate_time_encoding(field: &'static str, t: &Asn1TimeUtc) -> Result<(), Cr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 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 parse_and_validate_extensions(exts: &[X509Extension<'_>]) -> Result<CrlExtensions, CrlDecodeError> {
|
||||
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> {
|
||||
if exts.len() != 2 {
|
||||
return Err(CrlDecodeError::InvalidExtensionsCount(exts.len()));
|
||||
return Err(CrlProfileError::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 {
|
||||
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));
|
||||
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 aki = parse_aki(ext)?;
|
||||
authority_key_identifier = Some(aki);
|
||||
seen.push(oid.clone());
|
||||
|
||||
if *has_other_fields {
|
||||
return Err(CrlProfileError::AkiHasOtherFields);
|
||||
}
|
||||
OID_CRL_NUMBER => {
|
||||
if crl_number.is_some() {
|
||||
return Err(CrlDecodeError::DuplicateExtension(oid));
|
||||
let ki = key_identifier
|
||||
.as_ref()
|
||||
.ok_or(CrlProfileError::AkiMissingKeyIdentifier)?;
|
||||
authority_key_identifier = Some(ki.clone());
|
||||
}
|
||||
if ext.critical {
|
||||
return Err(CrlDecodeError::CrlNumberCritical);
|
||||
CrlExtensionParsed::CrlNumber { number, critical } => {
|
||||
let oid = OID_CRL_NUMBER.to_string();
|
||||
if seen.iter().any(|s| s == &oid) {
|
||||
return Err(CrlProfileError::DuplicateExtension(oid));
|
||||
}
|
||||
let n = parse_crl_number(ext)?;
|
||||
if n.bits() > 159 {
|
||||
return Err(CrlDecodeError::CrlNumberOutOfRange);
|
||||
seen.push(oid.clone());
|
||||
|
||||
if *critical {
|
||||
return Err(CrlProfileError::CrlNumberCritical);
|
||||
}
|
||||
crl_number = Some(biguint_to_big_unsigned(&n));
|
||||
if number.bits() > 159 {
|
||||
return Err(CrlProfileError::CrlNumberOutOfRange);
|
||||
}
|
||||
crl_number = Some(BigUnsigned::from_biguint(number));
|
||||
}
|
||||
CrlExtensionParsed::Other { oid, .. } => {
|
||||
return Err(CrlProfileError::UnsupportedExtension(oid.clone()));
|
||||
}
|
||||
_ => return Err(CrlDecodeError::UnsupportedExtension(oid)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CrlExtensions {
|
||||
authority_key_identifier: authority_key_identifier.unwrap(),
|
||||
crl_number: crl_number.unwrap(),
|
||||
authority_key_identifier: authority_key_identifier.ok_or(CrlProfileError::AkiMissing)?,
|
||||
crl_number: crl_number.ok_or(CrlProfileError::CrlNumberMissing)?,
|
||||
})
|
||||
}
|
||||
|
||||
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.to_id_string() == OID_SUBJECT_KEY_IDENTIFIER)
|
||||
.find(|ext| ext.oid.as_bytes() == OID_SUBJECT_KEY_IDENTIFIER_RAW)
|
||||
.and_then(|ext| match ext.parsed_extension() {
|
||||
ParsedExtension::SubjectKeyIdentifier(ki) => Some(ki.0.to_vec()),
|
||||
_ => None,
|
||||
|
||||
954
src/data_model/manifest.rs
Normal file
954
src/data_model/manifest.rs
Normal file
@@ -0,0 +1,954 @@
|
||||
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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
pub mod aspa;
|
||||
pub mod common;
|
||||
pub mod crl;
|
||||
mod rc;
|
||||
mod tal;
|
||||
mod ta;
|
||||
mod resources;
|
||||
mod oids;
|
||||
pub mod manifest;
|
||||
pub mod oid;
|
||||
pub mod rc;
|
||||
pub mod roa;
|
||||
pub mod signed_object;
|
||||
pub mod ta;
|
||||
pub mod tal;
|
||||
|
||||
70
src/data_model/oid.rs
Normal file
70
src/data_model/oid.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
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);
|
||||
@@ -1,16 +0,0 @@
|
||||
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";
|
||||
1903
src/data_model/rc.rs
1903
src/data_model/rc.rs
File diff suppressed because it is too large
Load Diff
@@ -1,90 +0,0 @@
|
||||
|
||||
#[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
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
|
||||
#[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);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
pub(crate) mod ip_resources;
|
||||
pub(crate) mod as_resources;
|
||||
pub mod resource;
|
||||
@@ -1,10 +0,0 @@
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
610
src/data_model/roa.rs
Normal file
610
src/data_model/roa.rs
Normal file
@@ -0,0 +1,610 @@
|
||||
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
|
||||
}
|
||||
1181
src/data_model/signed_object.rs
Normal file
1181
src/data_model/signed_object.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,288 @@
|
||||
use url::Url;
|
||||
use x509_parser::prelude::*;
|
||||
use x509_parser::prelude::{FromDer, X509Certificate};
|
||||
|
||||
use crate::data_model::resources::resource::ResourceSet;
|
||||
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;
|
||||
|
||||
//
|
||||
// #[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 {
|
||||
//
|
||||
// }
|
||||
#[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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,183 @@
|
||||
/// TAL Model
|
||||
#[derive(Clone, Debug)]
|
||||
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)]
|
||||
pub struct Tal {
|
||||
/// Optional human-readable comments
|
||||
pub raw: Vec<u8>,
|
||||
pub comments: Vec<String>,
|
||||
|
||||
/// Ordered list of URIs pointing to the TA certificate
|
||||
pub uris: Vec<TalUri>,
|
||||
|
||||
/// SubjectPublicKeyInfo DER
|
||||
pub spki_der: Vec<u8>,
|
||||
pub ta_uris: Vec<Url>,
|
||||
pub subject_public_key_info_der: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TalUri {
|
||||
Rsync(String),
|
||||
Https(String),
|
||||
#[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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
113
src/fetch/http.rs
Normal file
113
src/fetch/http.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
3
src/fetch/mod.rs
Normal file
3
src/fetch/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod http;
|
||||
pub mod rsync;
|
||||
pub mod rsync_system;
|
||||
118
src/fetch/rsync.rs
Normal file
118
src/fetch/rsync.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
252
src/fetch/rsync_system.rs
Normal file
252
src/fetch/rsync_system.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
21
src/lib.rs
21
src/lib.rs
@@ -1 +1,22 @@
|
||||
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;
|
||||
|
||||
70
src/policy.rs
Normal file
70
src/policy.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
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()))
|
||||
}
|
||||
}
|
||||
29
src/report.rs
Normal file
29
src/report.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
#[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
|
||||
}
|
||||
}
|
||||
806
src/storage.rs
Normal file
806
src/storage.rs
Normal file
@@ -0,0 +1,806 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
2
src/sync/mod.rs
Normal file
2
src/sync/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod repo;
|
||||
pub mod rrdp;
|
||||
827
src/sync/repo.rs
Normal file
827
src/sync/repo.rs
Normal file
@@ -0,0 +1,827 @@
|
||||
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,
|
||||
¬ification_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));
|
||||
}
|
||||
}
|
||||
2059
src/sync/rrdp.rs
Normal file
2059
src/sync/rrdp.rs
Normal file
File diff suppressed because it is too large
Load Diff
123
src/validation/ca_instance.rs
Normal file
123
src/validation/ca_instance.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
1380
src/validation/ca_path.rs
Normal file
1380
src/validation/ca_path.rs
Normal file
File diff suppressed because it is too large
Load Diff
567
src/validation/cert_path.rs
Normal file
567
src/validation/cert_path.rs
Normal file
@@ -0,0 +1,567 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
163
src/validation/from_tal.rs
Normal file
163
src/validation/from_tal.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
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 })
|
||||
}
|
||||
575
src/validation/manifest.rs
Normal file
575
src/validation/manifest.rs
Normal file
@@ -0,0 +1,575 @@
|
||||
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(", "),
|
||||
))
|
||||
}
|
||||
11
src/validation/mod.rs
Normal file
11
src/validation/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
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;
|
||||
1424
src/validation/objects.rs
Normal file
1424
src/validation/objects.rs
Normal file
File diff suppressed because it is too large
Load Diff
92
src/validation/run.rs
Normal file
92
src/validation/run.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
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())
|
||||
}
|
||||
318
src/validation/run_tree_from_tal.rs
Normal file
318
src/validation/run_tree_from_tal.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
234
src/validation/tree.rs
Normal file
234
src/validation/tree.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
1775
src/validation/tree_runner.rs
Normal file
1775
src/validation/tree_runner.rs
Normal file
File diff suppressed because it is too large
Load Diff
85
src/validation/x509_name.rs
Normal file
85
src/validation/x509_name.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
316
tests/bench_manifest_decode_profile.rs
Normal file
316
tests/bench_manifest_decode_profile.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
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>,
|
||||
}
|
||||
942
tests/bench_stage2_collect_selected_der_v2.rs
Normal file
942
tests/bench_stage2_collect_selected_der_v2.rs
Normal file
@@ -0,0 +1,942 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
717
tests/bench_stage2_decode_profile_selected_der_v2.rs
Normal file
717
tests/bench_stage2_decode_profile_selected_der_v2.rs
Normal file
@@ -0,0 +1,717 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
233
tests/bench_stage2_inventory_sap.rs
Normal file
233
tests/bench_stage2_inventory_sap.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
#[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()));
|
||||
}
|
||||
}
|
||||
87
tests/benchmark/flamegraph_manifest_decode_profile.sh
Executable file
87
tests/benchmark/flamegraph_manifest_decode_profile.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/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}"
|
||||
191
tests/benchmark/profile_stage2_m5_hotspots.sh
Executable file
191
tests/benchmark/profile_stage2_m5_hotspots.sh
Executable file
@@ -0,0 +1,191 @@
|
||||
#!/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
|
||||
|
||||
253
tests/benchmark/sap.rs
Normal file
253
tests/benchmark/sap.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
BIN
tests/benchmark/selected/large-01.mft
Normal file
BIN
tests/benchmark/selected/large-01.mft
Normal file
Binary file not shown.
BIN
tests/benchmark/selected/large-02.mft
Normal file
BIN
tests/benchmark/selected/large-02.mft
Normal file
Binary file not shown.
BIN
tests/benchmark/selected/medium-01.mft
Normal file
BIN
tests/benchmark/selected/medium-01.mft
Normal file
Binary file not shown.
BIN
tests/benchmark/selected/medium-02.mft
Normal file
BIN
tests/benchmark/selected/medium-02.mft
Normal file
Binary file not shown.
BIN
tests/benchmark/selected/small-01.mft
Normal file
BIN
tests/benchmark/selected/small-01.mft
Normal file
Binary file not shown.
BIN
tests/benchmark/selected/small-02.mft
Normal file
BIN
tests/benchmark/selected/small-02.mft
Normal file
Binary file not shown.
8
tests/benchmark/selected/sources.tsv
Normal file
8
tests/benchmark/selected/sources.tsv
Normal file
@@ -0,0 +1,8 @@
|
||||
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
|
||||
|
BIN
tests/benchmark/selected/xlarge-01.mft
Normal file
BIN
tests/benchmark/selected/xlarge-01.mft
Normal file
Binary file not shown.
BIN
tests/benchmark/selected/xlarge-02.mft
Normal file
BIN
tests/benchmark/selected/xlarge-02.mft
Normal file
Binary file not shown.
BIN
tests/benchmark/selected_der/large-01.mft
Normal file
BIN
tests/benchmark/selected_der/large-01.mft
Normal file
Binary file not shown.
BIN
tests/benchmark/selected_der/large-02.mft
Normal file
BIN
tests/benchmark/selected_der/large-02.mft
Normal file
Binary file not shown.
BIN
tests/benchmark/selected_der/medium-01.mft
Normal file
BIN
tests/benchmark/selected_der/medium-01.mft
Normal file
Binary file not shown.
BIN
tests/benchmark/selected_der/medium-02.mft
Normal file
BIN
tests/benchmark/selected_der/medium-02.mft
Normal file
Binary file not shown.
BIN
tests/benchmark/selected_der/small-01.mft
Normal file
BIN
tests/benchmark/selected_der/small-01.mft
Normal file
Binary file not shown.
BIN
tests/benchmark/selected_der/small-02.mft
Normal file
BIN
tests/benchmark/selected_der/small-02.mft
Normal file
Binary file not shown.
9
tests/benchmark/selected_der/sources.tsv
Normal file
9
tests/benchmark/selected_der/sources.tsv
Normal file
@@ -0,0 +1,9 @@
|
||||
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
|
||||
|
BIN
tests/benchmark/selected_der/xlarge-01.mft
Normal file
BIN
tests/benchmark/selected_der/xlarge-01.mft
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user