Compare commits
16 Commits
dev_1.1_xu
...
cf764c35bb
| Author | SHA1 | Date | |
|---|---|---|---|
| cf764c35bb | |||
| e3339533b8 | |||
| 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/
|
target/
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
|
perf.*
|
||||||
|
|||||||
28
Cargo.toml
28
Cargo.toml
@@ -3,13 +3,33 @@ name = "rpki"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
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]
|
[dependencies]
|
||||||
der-parser = "10.0.0"
|
asn1-rs = "0.7.1"
|
||||||
|
der-parser = { version = "10.0.0", features = ["serialize"] }
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
sha2 = "0.10.8"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
time = "0.3.45"
|
time = "0.3.45"
|
||||||
|
ring = "0.17.14"
|
||||||
x509-parser = { version = "0.18.0", features = ["verify"] }
|
x509-parser = { version = "0.18.0", features = ["verify"] }
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
asn1-rs = "0.7.1"
|
serde = { version = "1.0.218", features = ["derive"] }
|
||||||
asn1-rs-derive = "0.6.0"
|
serde_json = "1.0.140"
|
||||||
asn1 = "0.23.0"
|
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 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/"
|
||||||
97
scripts/manual_sync/README.md
Normal file
97
scripts/manual_sync/README.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# 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 legacy session/serial dump (`rrdp_state_dump --view legacy-state`)
|
||||||
|
|
||||||
|
### `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 --view legacy-state` 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).
|
||||||
|
|
||||||
|
The standalone `rrdp_state_dump` tool also supports `source`, `members`, `owners`, and `all` views.
|
||||||
|
The manual sync scripts intentionally call `--view legacy-state` so delta analysis keeps using a stable session/serial TSV format.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
543
scripts/manual_sync/delta_sync.sh
Executable file
543
scripts/manual_sync/delta_sync.sh
Executable file
@@ -0,0 +1,543 @@
|
|||||||
|
#!/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 --view legacy-state 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 (legacy-state) ==" >&2
|
||||||
|
state_start_s="$(date +%s)"
|
||||||
|
cargo run --release --bin rrdp_state_dump -- --db "$BASE_DB_DIR" --view legacy-state >"$BASE_RRDP_STATE_TSV"
|
||||||
|
cargo run --release --bin rrdp_state_dump -- --db "$DELTA_DB_DIR" --view legacy-state >"$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 from `rrdp_state_dump --view legacy-state`:
|
||||||
|
# [legacy-state]
|
||||||
|
# notify_uri serial session_id
|
||||||
|
# <notify_uri> <serial> <session_id>
|
||||||
|
out = {}
|
||||||
|
for line in p.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("["):
|
||||||
|
continue
|
||||||
|
if line == "notify_uri serial session_id":
|
||||||
|
continue
|
||||||
|
parts = line.split(" ")
|
||||||
|
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") != "vcir_current_instance":
|
||||||
|
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:
|
||||||
|
ordered = [
|
||||||
|
"mode",
|
||||||
|
"repository_view",
|
||||||
|
"raw_by_hash",
|
||||||
|
"vcir",
|
||||||
|
"audit_rule_index",
|
||||||
|
"rrdp_source",
|
||||||
|
"rrdp_source_member",
|
||||||
|
"rrdp_uri_owner",
|
||||||
|
"rrdp_state",
|
||||||
|
"raw_objects",
|
||||||
|
"rrdp_object_index",
|
||||||
|
"group_current_repository_view",
|
||||||
|
"group_current_validation_state",
|
||||||
|
"group_current_rrdp_state",
|
||||||
|
"group_legacy_compatibility",
|
||||||
|
"total",
|
||||||
|
"sst_files",
|
||||||
|
]
|
||||||
|
out = []
|
||||||
|
seen = set()
|
||||||
|
for k in ordered:
|
||||||
|
if k in db:
|
||||||
|
out.append(f"- `{k}={db[k]}`")
|
||||||
|
seen.add(k)
|
||||||
|
for k in sorted(set(db) - seen):
|
||||||
|
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 / VCIR 当前实例缓存复用情况\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`(未使用 VCIR 当前实例缓存复用)\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=vcir_current_instance`", base_cache_reasons))
|
||||||
|
lines.append(render_cache_reasons("- delta `source=vcir_current_instance`", 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 --view legacy-state 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 (legacy-state) ==" >&2
|
||||||
|
state_start_s="$(date +%s)"
|
||||||
|
cargo run --release --bin rrdp_state_dump -- --db "$DB_DIR" --view legacy-state >"$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.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.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.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.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.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 |
|
| `1.3.6.1.5.5.7.48.2` | AIA accessMethod: id-ad-caIssuers | RFC 5280 §4.2.2.1 |
|
||||||
|
|||||||
108
specs/01_tal.md
108
specs/01_tal.md
@@ -1,36 +1,94 @@
|
|||||||
# 01. Trust Anchor Locator (TAL)
|
# 01. TAL(Trust Anchor Locator)
|
||||||
|
|
||||||
## 1.1 对象定位
|
## 1.1 对象定位
|
||||||
TAL是一个数据格式/配置文件,目的是告诉RP信任锚的公钥是什么,以及相关对象可以从哪里获取。
|
|
||||||
|
|
||||||
## 1.2 数据格式 (RFC 8630 §2.2)
|
TAL(Trust Anchor Locator)用于向 RP 提供:
|
||||||
TAL是一个配置文件,格式定义如下:
|
|
||||||
```
|
1) 可检索“当前 TA 证书”的一个或多个 URI;以及
|
||||||
The TAL is an ordered sequence of:
|
2) 该 TA 证书的 `subjectPublicKeyInfo`(SPKI)期望值(用于绑定/防替换)。
|
||||||
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,
|
RFC 8630 §2;RFC 8630 §2.3。
|
||||||
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
|
## 1.2 原始载体与编码
|
||||||
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.
|
- 载体:文本文件(ASCII/UTF-8 兼容的行文本)。
|
||||||
Note that line breaks in this file can use either "<CRLF>" or "<LF>".
|
- 行结束:允许 `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 解析规则(语义层)
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
输入:`TalFileBytes: bytes`。
|
||||||
|----------|-------------|-------------------------|--------------------------------------------|---------------|
|
|
||||||
| uris | Vec<TalUri> | 指向TA的URI列表 | 允许rsync和https协议。 | RFC 8630 §2.1 |
|
|
||||||
| comment | Vec<String> | 注释(可选) | | RFC 8630 §2.2 |
|
|
||||||
| spki_der | Vec<u8> | 原始的subjectPublicKeyInfo | x.509 SubjectPublicKeyInfo DER编码,再base64编码 | RFC 8630 §2.2 |
|
|
||||||
|
|
||||||
|
解析步骤:
|
||||||
|
|
||||||
### 1.3.2 TalUri
|
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。
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
URI 解析与约束:
|
||||||
|-------|--------|---------|---------|---------------|
|
|
||||||
| Rsync | String | rsync地址 | | RFC 8630 §2.1 |
|
|
||||||
| Https | String | https地址 | | RFC 8630 §2.1 |
|
|
||||||
|
|
||||||
|
- `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 引用 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `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`(可选细化)
|
||||||
|
|
||||||
|
> 若你的实现希望对 URI 做更强类型化,可在 `Tal.ta_uris` 上进一步拆分为 `TaUri` 结构。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `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
16200
specs/delta.excalidraw
Normal file
16200
specs/delta.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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/audit_downloads.rs
Normal file
170
src/audit_downloads.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
581
src/audit_trace.rs
Normal file
581
src/audit_trace.rs
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
use crate::storage::{
|
||||||
|
AuditRuleIndexEntry, AuditRuleKind, RawByHashEntry, RocksStore, ValidatedCaInstanceResult,
|
||||||
|
VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, VcirLocalOutput,
|
||||||
|
VcirOutputType,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuditTraceError {
|
||||||
|
#[error("storage error: {0}")]
|
||||||
|
Storage(#[from] crate::storage::StorageError),
|
||||||
|
|
||||||
|
#[error("audit rule index points to missing VCIR: {manifest_rsync_uri}")]
|
||||||
|
MissingVcir { manifest_rsync_uri: String },
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"audit rule index points to missing local output: rule_hash={rule_hash}, output_id={output_id}, manifest={manifest_rsync_uri}"
|
||||||
|
)]
|
||||||
|
MissingLocalOutput {
|
||||||
|
rule_hash: String,
|
||||||
|
output_id: String,
|
||||||
|
manifest_rsync_uri: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("detected VCIR parent cycle at {manifest_rsync_uri}")]
|
||||||
|
ParentCycle { manifest_rsync_uri: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct AuditTraceRawRef {
|
||||||
|
pub sha256_hex: String,
|
||||||
|
pub raw_present: bool,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
pub origin_uris: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub object_type: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub byte_len: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct AuditTraceArtifact {
|
||||||
|
pub artifact_role: VcirArtifactRole,
|
||||||
|
pub artifact_kind: VcirArtifactKind,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub uri: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub object_type: Option<String>,
|
||||||
|
pub validation_status: VcirArtifactValidationStatus,
|
||||||
|
pub raw: AuditTraceRawRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct AuditTraceChainNode {
|
||||||
|
pub manifest_rsync_uri: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_manifest_rsync_uri: Option<String>,
|
||||||
|
pub tal_id: String,
|
||||||
|
pub ca_subject_name: String,
|
||||||
|
pub ca_ski: String,
|
||||||
|
pub issuer_ski: String,
|
||||||
|
pub current_manifest_rsync_uri: String,
|
||||||
|
pub current_crl_rsync_uri: String,
|
||||||
|
pub last_successful_validation_time_rfc3339_utc: String,
|
||||||
|
pub local_output_count: usize,
|
||||||
|
pub child_count: usize,
|
||||||
|
pub related_artifacts: Vec<AuditTraceArtifact>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct AuditTraceResolvedOutput {
|
||||||
|
pub output_id: String,
|
||||||
|
pub output_type: VcirOutputType,
|
||||||
|
pub rule_hash: String,
|
||||||
|
pub source_object_uri: String,
|
||||||
|
pub source_object_type: String,
|
||||||
|
pub source_object_hash: String,
|
||||||
|
pub source_ee_cert_hash: String,
|
||||||
|
pub item_effective_until_rfc3339_utc: String,
|
||||||
|
pub payload_json: String,
|
||||||
|
pub validation_path_hint: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct AuditRuleTrace {
|
||||||
|
pub rule: AuditRuleIndexEntry,
|
||||||
|
pub resolved_output: AuditTraceResolvedOutput,
|
||||||
|
pub source_object_raw: AuditTraceRawRef,
|
||||||
|
pub source_ee_cert_raw: AuditTraceRawRef,
|
||||||
|
pub chain_leaf_to_root: Vec<AuditTraceChainNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trace_rule_to_root(
|
||||||
|
store: &RocksStore,
|
||||||
|
kind: AuditRuleKind,
|
||||||
|
rule_hash: &str,
|
||||||
|
) -> Result<Option<AuditRuleTrace>, AuditTraceError> {
|
||||||
|
let Some(rule) = store.get_audit_rule_index_entry(kind, rule_hash)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(leaf_vcir) = store.get_vcir(&rule.manifest_rsync_uri)? else {
|
||||||
|
return Err(AuditTraceError::MissingVcir {
|
||||||
|
manifest_rsync_uri: rule.manifest_rsync_uri.clone(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(local_output) = leaf_vcir
|
||||||
|
.local_outputs
|
||||||
|
.iter()
|
||||||
|
.find(|output| output.output_id == rule.output_id && output.rule_hash == rule.rule_hash)
|
||||||
|
.or_else(|| {
|
||||||
|
leaf_vcir
|
||||||
|
.local_outputs
|
||||||
|
.iter()
|
||||||
|
.find(|output| output.rule_hash == rule.rule_hash)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
else {
|
||||||
|
return Err(AuditTraceError::MissingLocalOutput {
|
||||||
|
rule_hash: rule.rule_hash.clone(),
|
||||||
|
output_id: rule.output_id.clone(),
|
||||||
|
manifest_rsync_uri: rule.manifest_rsync_uri.clone(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let chain = trace_vcir_chain_to_root(store, &leaf_vcir.manifest_rsync_uri)?
|
||||||
|
.expect("leaf VCIR already loaded must exist");
|
||||||
|
|
||||||
|
Ok(Some(AuditRuleTrace {
|
||||||
|
rule,
|
||||||
|
resolved_output: resolved_output_from_local(&local_output),
|
||||||
|
source_object_raw: resolve_raw_ref(store, &local_output.source_object_hash)?,
|
||||||
|
source_ee_cert_raw: resolve_raw_ref(store, &local_output.source_ee_cert_hash)?,
|
||||||
|
chain_leaf_to_root: chain,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trace_vcir_chain_to_root(
|
||||||
|
store: &RocksStore,
|
||||||
|
manifest_rsync_uri: &str,
|
||||||
|
) -> Result<Option<Vec<AuditTraceChainNode>>, AuditTraceError> {
|
||||||
|
let Some(mut current) = store.get_vcir(manifest_rsync_uri)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut chain = Vec::new();
|
||||||
|
loop {
|
||||||
|
if !seen.insert(current.manifest_rsync_uri.clone()) {
|
||||||
|
return Err(AuditTraceError::ParentCycle {
|
||||||
|
manifest_rsync_uri: current.manifest_rsync_uri,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let parent = current.parent_manifest_rsync_uri.clone();
|
||||||
|
chain.push(trace_chain_node(store, ¤t)?);
|
||||||
|
let Some(parent_manifest_rsync_uri) = parent else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Some(parent_vcir) = store.get_vcir(&parent_manifest_rsync_uri)? else {
|
||||||
|
return Err(AuditTraceError::MissingVcir {
|
||||||
|
manifest_rsync_uri: parent_manifest_rsync_uri,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
current = parent_vcir;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(chain))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trace_chain_node(
|
||||||
|
store: &RocksStore,
|
||||||
|
vcir: &ValidatedCaInstanceResult,
|
||||||
|
) -> Result<AuditTraceChainNode, AuditTraceError> {
|
||||||
|
let mut related_artifacts = Vec::with_capacity(vcir.related_artifacts.len());
|
||||||
|
for artifact in &vcir.related_artifacts {
|
||||||
|
related_artifacts.push(AuditTraceArtifact {
|
||||||
|
artifact_role: artifact.artifact_role,
|
||||||
|
artifact_kind: artifact.artifact_kind,
|
||||||
|
uri: artifact.uri.clone(),
|
||||||
|
object_type: artifact.object_type.clone(),
|
||||||
|
validation_status: artifact.validation_status,
|
||||||
|
raw: resolve_raw_ref(store, &artifact.sha256)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AuditTraceChainNode {
|
||||||
|
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
|
||||||
|
parent_manifest_rsync_uri: vcir.parent_manifest_rsync_uri.clone(),
|
||||||
|
tal_id: vcir.tal_id.clone(),
|
||||||
|
ca_subject_name: vcir.ca_subject_name.clone(),
|
||||||
|
ca_ski: vcir.ca_ski.clone(),
|
||||||
|
issuer_ski: vcir.issuer_ski.clone(),
|
||||||
|
current_manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(),
|
||||||
|
current_crl_rsync_uri: vcir.current_crl_rsync_uri.clone(),
|
||||||
|
last_successful_validation_time_rfc3339_utc: vcir
|
||||||
|
.last_successful_validation_time
|
||||||
|
.rfc3339_utc
|
||||||
|
.clone(),
|
||||||
|
local_output_count: vcir.local_outputs.len(),
|
||||||
|
child_count: vcir.child_entries.len(),
|
||||||
|
related_artifacts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved_output_from_local(local: &VcirLocalOutput) -> AuditTraceResolvedOutput {
|
||||||
|
AuditTraceResolvedOutput {
|
||||||
|
output_id: local.output_id.clone(),
|
||||||
|
output_type: local.output_type,
|
||||||
|
rule_hash: local.rule_hash.clone(),
|
||||||
|
source_object_uri: local.source_object_uri.clone(),
|
||||||
|
source_object_type: local.source_object_type.clone(),
|
||||||
|
source_object_hash: local.source_object_hash.clone(),
|
||||||
|
source_ee_cert_hash: local.source_ee_cert_hash.clone(),
|
||||||
|
item_effective_until_rfc3339_utc: local.item_effective_until.rfc3339_utc.clone(),
|
||||||
|
payload_json: local.payload_json.clone(),
|
||||||
|
validation_path_hint: local.validation_path_hint.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_raw_ref(
|
||||||
|
store: &RocksStore,
|
||||||
|
sha256_hex: &str,
|
||||||
|
) -> Result<AuditTraceRawRef, AuditTraceError> {
|
||||||
|
let raw = store.get_raw_by_hash_entry(sha256_hex)?;
|
||||||
|
Ok(raw_ref_from_entry(sha256_hex, raw.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_ref_from_entry(sha256_hex: &str, entry: Option<&RawByHashEntry>) -> AuditTraceRawRef {
|
||||||
|
match entry {
|
||||||
|
Some(entry) => AuditTraceRawRef {
|
||||||
|
sha256_hex: sha256_hex.to_string(),
|
||||||
|
raw_present: true,
|
||||||
|
origin_uris: entry.origin_uris.clone(),
|
||||||
|
object_type: entry.object_type.clone(),
|
||||||
|
byte_len: Some(entry.bytes.len()),
|
||||||
|
},
|
||||||
|
None => AuditTraceRawRef {
|
||||||
|
sha256_hex: sha256_hex.to_string(),
|
||||||
|
raw_present: false,
|
||||||
|
origin_uris: Vec::new(),
|
||||||
|
object_type: None,
|
||||||
|
byte_len: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::audit::sha256_hex;
|
||||||
|
use crate::storage::{
|
||||||
|
PackTime, ValidatedManifestMeta, VcirAuditSummary, VcirChildEntry, VcirInstanceGate,
|
||||||
|
VcirRelatedArtifact, VcirSummary,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn sample_vcir(
|
||||||
|
manifest_rsync_uri: &str,
|
||||||
|
parent_manifest_rsync_uri: Option<&str>,
|
||||||
|
tal_id: &str,
|
||||||
|
local_output: Option<VcirLocalOutput>,
|
||||||
|
related_artifacts: Vec<VcirRelatedArtifact>,
|
||||||
|
) -> ValidatedCaInstanceResult {
|
||||||
|
let now = time::OffsetDateTime::now_utc();
|
||||||
|
let next = PackTime::from_utc_offset_datetime(now + time::Duration::hours(1));
|
||||||
|
let local_outputs: Vec<VcirLocalOutput> = local_output.into_iter().collect();
|
||||||
|
ValidatedCaInstanceResult {
|
||||||
|
manifest_rsync_uri: manifest_rsync_uri.to_string(),
|
||||||
|
parent_manifest_rsync_uri: parent_manifest_rsync_uri.map(str::to_string),
|
||||||
|
tal_id: tal_id.to_string(),
|
||||||
|
ca_subject_name: format!("CN={manifest_rsync_uri}"),
|
||||||
|
ca_ski: "11".repeat(20),
|
||||||
|
issuer_ski: "22".repeat(20),
|
||||||
|
last_successful_validation_time: PackTime::from_utc_offset_datetime(now),
|
||||||
|
current_manifest_rsync_uri: manifest_rsync_uri.to_string(),
|
||||||
|
current_crl_rsync_uri: manifest_rsync_uri.replace(".mft", ".crl"),
|
||||||
|
validated_manifest_meta: ValidatedManifestMeta {
|
||||||
|
validated_manifest_number: vec![1],
|
||||||
|
validated_manifest_this_update: PackTime::from_utc_offset_datetime(now),
|
||||||
|
validated_manifest_next_update: next.clone(),
|
||||||
|
},
|
||||||
|
instance_gate: VcirInstanceGate {
|
||||||
|
manifest_next_update: next.clone(),
|
||||||
|
current_crl_next_update: next.clone(),
|
||||||
|
self_ca_not_after: PackTime::from_utc_offset_datetime(
|
||||||
|
now + time::Duration::hours(2),
|
||||||
|
),
|
||||||
|
instance_effective_until: next,
|
||||||
|
},
|
||||||
|
child_entries: vec![VcirChildEntry {
|
||||||
|
child_manifest_rsync_uri: "rsync://example.test/child/child.mft".to_string(),
|
||||||
|
child_cert_rsync_uri: "rsync://example.test/parent/child.cer".to_string(),
|
||||||
|
child_cert_hash: sha256_hex(b"child-cert"),
|
||||||
|
child_ski: "33".repeat(20),
|
||||||
|
child_rsync_base_uri: "rsync://example.test/child/".to_string(),
|
||||||
|
child_publication_point_rsync_uri: "rsync://example.test/child/".to_string(),
|
||||||
|
child_rrdp_notification_uri: Some(
|
||||||
|
"https://example.test/child/notify.xml".to_string(),
|
||||||
|
),
|
||||||
|
child_effective_ip_resources: None,
|
||||||
|
child_effective_as_resources: None,
|
||||||
|
accepted_at_validation_time: PackTime::from_utc_offset_datetime(now),
|
||||||
|
}],
|
||||||
|
summary: VcirSummary {
|
||||||
|
local_vrp_count: local_outputs
|
||||||
|
.iter()
|
||||||
|
.filter(|output| output.output_type == VcirOutputType::Vrp)
|
||||||
|
.count() as u32,
|
||||||
|
local_aspa_count: local_outputs
|
||||||
|
.iter()
|
||||||
|
.filter(|output| output.output_type == VcirOutputType::Aspa)
|
||||||
|
.count() as u32,
|
||||||
|
child_count: 1,
|
||||||
|
accepted_object_count: related_artifacts.len() as u32,
|
||||||
|
rejected_object_count: 0,
|
||||||
|
},
|
||||||
|
local_outputs,
|
||||||
|
related_artifacts,
|
||||||
|
audit_summary: VcirAuditSummary {
|
||||||
|
failed_fetch_eligible: true,
|
||||||
|
last_failed_fetch_reason: None,
|
||||||
|
warning_count: 0,
|
||||||
|
audit_flags: Vec::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_local_output(manifest_rsync_uri: &str) -> VcirLocalOutput {
|
||||||
|
let now = time::OffsetDateTime::now_utc();
|
||||||
|
VcirLocalOutput {
|
||||||
|
output_id: sha256_hex(b"vrp-output"),
|
||||||
|
output_type: VcirOutputType::Vrp,
|
||||||
|
item_effective_until: PackTime::from_utc_offset_datetime(
|
||||||
|
now + time::Duration::minutes(30),
|
||||||
|
),
|
||||||
|
source_object_uri: "rsync://example.test/leaf/a.roa".to_string(),
|
||||||
|
source_object_type: "roa".to_string(),
|
||||||
|
source_object_hash: sha256_hex(b"roa-raw"),
|
||||||
|
source_ee_cert_hash: sha256_hex(b"roa-ee"),
|
||||||
|
payload_json:
|
||||||
|
serde_json::json!({"asn": 64496, "prefix": "203.0.113.0/24", "max_length": 24})
|
||||||
|
.to_string(),
|
||||||
|
rule_hash: sha256_hex(b"roa-rule"),
|
||||||
|
validation_path_hint: vec![
|
||||||
|
manifest_rsync_uri.to_string(),
|
||||||
|
"rsync://example.test/leaf/a.roa".to_string(),
|
||||||
|
sha256_hex(b"roa-raw"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_artifacts(manifest_rsync_uri: &str, roa_hash: &str) -> Vec<VcirRelatedArtifact> {
|
||||||
|
vec![
|
||||||
|
VcirRelatedArtifact {
|
||||||
|
artifact_role: VcirArtifactRole::Manifest,
|
||||||
|
artifact_kind: VcirArtifactKind::Mft,
|
||||||
|
uri: Some(manifest_rsync_uri.to_string()),
|
||||||
|
sha256: sha256_hex(manifest_rsync_uri.as_bytes()),
|
||||||
|
object_type: Some("mft".to_string()),
|
||||||
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
},
|
||||||
|
VcirRelatedArtifact {
|
||||||
|
artifact_role: VcirArtifactRole::CurrentCrl,
|
||||||
|
artifact_kind: VcirArtifactKind::Crl,
|
||||||
|
uri: Some(manifest_rsync_uri.replace(".mft", ".crl")),
|
||||||
|
sha256: sha256_hex(format!("{}-crl", manifest_rsync_uri).as_bytes()),
|
||||||
|
object_type: Some("crl".to_string()),
|
||||||
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
},
|
||||||
|
VcirRelatedArtifact {
|
||||||
|
artifact_role: VcirArtifactRole::SignedObject,
|
||||||
|
artifact_kind: VcirArtifactKind::Roa,
|
||||||
|
uri: Some("rsync://example.test/leaf/a.roa".to_string()),
|
||||||
|
sha256: roa_hash.to_string(),
|
||||||
|
object_type: Some("roa".to_string()),
|
||||||
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_raw(store: &RocksStore, bytes: &[u8], uri: &str, object_type: &str) {
|
||||||
|
let mut entry = RawByHashEntry::from_bytes(sha256_hex(bytes), bytes.to_vec());
|
||||||
|
entry.origin_uris.push(uri.to_string());
|
||||||
|
entry.object_type = Some(object_type.to_string());
|
||||||
|
entry.encoding = Some("der".to_string());
|
||||||
|
store
|
||||||
|
.put_raw_by_hash_entry(&entry)
|
||||||
|
.expect("put raw evidence");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trace_rule_to_root_returns_leaf_to_root_chain_and_evidence_refs() {
|
||||||
|
let store_dir = tempfile::tempdir().expect("store dir");
|
||||||
|
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
|
||||||
|
|
||||||
|
let root_manifest = "rsync://example.test/root/root.mft";
|
||||||
|
let leaf_manifest = "rsync://example.test/leaf/leaf.mft";
|
||||||
|
let local = sample_local_output(leaf_manifest);
|
||||||
|
let leaf_vcir = sample_vcir(
|
||||||
|
leaf_manifest,
|
||||||
|
Some(root_manifest),
|
||||||
|
"test-tal",
|
||||||
|
Some(local.clone()),
|
||||||
|
sample_artifacts(leaf_manifest, &local.source_object_hash),
|
||||||
|
);
|
||||||
|
let root_vcir = sample_vcir(
|
||||||
|
root_manifest,
|
||||||
|
None,
|
||||||
|
"test-tal",
|
||||||
|
None,
|
||||||
|
sample_artifacts(root_manifest, &sha256_hex(b"root-object")),
|
||||||
|
);
|
||||||
|
store.put_vcir(&leaf_vcir).expect("put leaf vcir");
|
||||||
|
store.put_vcir(&root_vcir).expect("put root vcir");
|
||||||
|
|
||||||
|
let rule_entry = AuditRuleIndexEntry {
|
||||||
|
kind: AuditRuleKind::Roa,
|
||||||
|
rule_hash: local.rule_hash.clone(),
|
||||||
|
manifest_rsync_uri: leaf_manifest.to_string(),
|
||||||
|
source_object_uri: local.source_object_uri.clone(),
|
||||||
|
source_object_hash: local.source_object_hash.clone(),
|
||||||
|
output_id: local.output_id.clone(),
|
||||||
|
item_effective_until: local.item_effective_until.clone(),
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.put_audit_rule_index_entry(&rule_entry)
|
||||||
|
.expect("put rule index");
|
||||||
|
|
||||||
|
put_raw(&store, leaf_manifest.as_bytes(), leaf_manifest, "mft");
|
||||||
|
put_raw(
|
||||||
|
&store,
|
||||||
|
format!("{}-crl", leaf_manifest).as_bytes(),
|
||||||
|
&leaf_manifest.replace(".mft", ".crl"),
|
||||||
|
"crl",
|
||||||
|
);
|
||||||
|
put_raw(&store, b"roa-raw", &local.source_object_uri, "roa");
|
||||||
|
put_raw(&store, b"roa-ee", "rsync://example.test/leaf/a.ee", "cer");
|
||||||
|
put_raw(&store, root_manifest.as_bytes(), root_manifest, "mft");
|
||||||
|
put_raw(
|
||||||
|
&store,
|
||||||
|
format!("{}-crl", root_manifest).as_bytes(),
|
||||||
|
&root_manifest.replace(".mft", ".crl"),
|
||||||
|
"crl",
|
||||||
|
);
|
||||||
|
|
||||||
|
let trace = trace_rule_to_root(&store, AuditRuleKind::Roa, &local.rule_hash)
|
||||||
|
.expect("trace rule")
|
||||||
|
.expect("trace exists");
|
||||||
|
|
||||||
|
assert_eq!(trace.rule, rule_entry);
|
||||||
|
assert_eq!(trace.resolved_output.output_id, local.output_id);
|
||||||
|
assert_eq!(trace.chain_leaf_to_root.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
trace.chain_leaf_to_root[0].manifest_rsync_uri,
|
||||||
|
leaf_manifest
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
trace.chain_leaf_to_root[1].manifest_rsync_uri,
|
||||||
|
root_manifest
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
trace.chain_leaf_to_root[0]
|
||||||
|
.parent_manifest_rsync_uri
|
||||||
|
.as_deref(),
|
||||||
|
Some(root_manifest)
|
||||||
|
);
|
||||||
|
assert!(trace.source_object_raw.raw_present);
|
||||||
|
assert!(trace.source_ee_cert_raw.raw_present);
|
||||||
|
assert!(
|
||||||
|
trace.chain_leaf_to_root[0]
|
||||||
|
.related_artifacts
|
||||||
|
.iter()
|
||||||
|
.any(|artifact| {
|
||||||
|
artifact.uri.as_deref() == Some(leaf_manifest) && artifact.raw.raw_present
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trace_rule_to_root_returns_none_for_missing_rule_index() {
|
||||||
|
let store_dir = tempfile::tempdir().expect("store dir");
|
||||||
|
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
trace_rule_to_root(&store, AuditRuleKind::Roa, &sha256_hex(b"missing"))
|
||||||
|
.expect("missing trace ok")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trace_rule_to_root_errors_when_index_points_to_missing_vcir() {
|
||||||
|
let store_dir = tempfile::tempdir().expect("store dir");
|
||||||
|
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
|
||||||
|
let rule_hash = sha256_hex(b"missing-vcir-rule");
|
||||||
|
store
|
||||||
|
.put_audit_rule_index_entry(&AuditRuleIndexEntry {
|
||||||
|
kind: AuditRuleKind::Roa,
|
||||||
|
rule_hash: rule_hash.clone(),
|
||||||
|
manifest_rsync_uri: "rsync://example.test/missing.mft".to_string(),
|
||||||
|
source_object_uri: "rsync://example.test/missing.roa".to_string(),
|
||||||
|
source_object_hash: sha256_hex(b"missing-source"),
|
||||||
|
output_id: sha256_hex(b"missing-output"),
|
||||||
|
item_effective_until: PackTime::from_utc_offset_datetime(
|
||||||
|
time::OffsetDateTime::now_utc() + time::Duration::minutes(1),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.expect("put rule index");
|
||||||
|
|
||||||
|
let err = trace_rule_to_root(&store, AuditRuleKind::Roa, &rule_hash).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
AuditTraceError::MissingVcir { manifest_rsync_uri }
|
||||||
|
if manifest_rsync_uri == "rsync://example.test/missing.mft"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trace_rule_to_root_errors_when_vcir_local_output_is_missing() {
|
||||||
|
let store_dir = tempfile::tempdir().expect("store dir");
|
||||||
|
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
|
||||||
|
let manifest = "rsync://example.test/leaf/leaf.mft";
|
||||||
|
let vcir = sample_vcir(
|
||||||
|
manifest,
|
||||||
|
None,
|
||||||
|
"test-tal",
|
||||||
|
None,
|
||||||
|
sample_artifacts(manifest, &sha256_hex(b"leaf-object")),
|
||||||
|
);
|
||||||
|
store.put_vcir(&vcir).expect("put vcir");
|
||||||
|
let rule_hash = sha256_hex(b"missing-output-rule");
|
||||||
|
store
|
||||||
|
.put_audit_rule_index_entry(&AuditRuleIndexEntry {
|
||||||
|
kind: AuditRuleKind::Roa,
|
||||||
|
rule_hash: rule_hash.clone(),
|
||||||
|
manifest_rsync_uri: manifest.to_string(),
|
||||||
|
source_object_uri: "rsync://example.test/leaf/a.roa".to_string(),
|
||||||
|
source_object_hash: sha256_hex(b"leaf-object"),
|
||||||
|
output_id: sha256_hex(b"missing-output"),
|
||||||
|
item_effective_until: PackTime::from_utc_offset_datetime(
|
||||||
|
time::OffsetDateTime::now_utc() + time::Duration::minutes(1),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.expect("put rule index");
|
||||||
|
|
||||||
|
let err = trace_rule_to_root(&store, AuditRuleKind::Roa, &rule_hash).unwrap_err();
|
||||||
|
assert!(matches!(err, AuditTraceError::MissingLocalOutput { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trace_vcir_chain_to_root_detects_parent_cycle() {
|
||||||
|
let store_dir = tempfile::tempdir().expect("store dir");
|
||||||
|
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
|
||||||
|
let a_manifest = "rsync://example.test/a.mft";
|
||||||
|
let b_manifest = "rsync://example.test/b.mft";
|
||||||
|
let a_vcir = sample_vcir(
|
||||||
|
a_manifest,
|
||||||
|
Some(b_manifest),
|
||||||
|
"test-tal",
|
||||||
|
None,
|
||||||
|
sample_artifacts(a_manifest, &sha256_hex(b"a-object")),
|
||||||
|
);
|
||||||
|
let b_vcir = sample_vcir(
|
||||||
|
b_manifest,
|
||||||
|
Some(a_manifest),
|
||||||
|
"test-tal",
|
||||||
|
None,
|
||||||
|
sample_artifacts(b_manifest, &sha256_hex(b"b-object")),
|
||||||
|
);
|
||||||
|
store.put_vcir(&a_vcir).expect("put a");
|
||||||
|
store.put_vcir(&b_vcir).expect("put b");
|
||||||
|
|
||||||
|
let err = trace_vcir_chain_to_root(&store, a_manifest).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
AuditTraceError::ParentCycle { manifest_rsync_uri }
|
||||||
|
if manifest_rsync_uri == a_manifest
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
206
src/bin/db_stats.rs
Normal file
206
src/bin/db_stats.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use rocksdb::{DB, IteratorMode, Options};
|
||||||
|
use rpki::storage::{
|
||||||
|
ALL_COLUMN_FAMILY_NAMES, CF_AUDIT_RULE_INDEX, CF_RAW_BY_HASH, CF_RAW_OBJECTS,
|
||||||
|
CF_REPOSITORY_VIEW, CF_RRDP_OBJECT_INDEX, CF_RRDP_SOURCE, CF_RRDP_SOURCE_MEMBER, CF_RRDP_STATE,
|
||||||
|
CF_RRDP_URI_OWNER, CF_VCIR, column_family_descriptors,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum DbStatsMode {
|
||||||
|
Estimate,
|
||||||
|
Exact,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
enum CfGroup {
|
||||||
|
CurrentRepositoryView,
|
||||||
|
CurrentValidationState,
|
||||||
|
CurrentRrdpState,
|
||||||
|
LegacyCompatibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CfGroup {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::CurrentRepositoryView => "current_repository_view",
|
||||||
|
Self::CurrentValidationState => "current_validation_state",
|
||||||
|
Self::CurrentRrdpState => "current_rrdp_state",
|
||||||
|
Self::LegacyCompatibility => "legacy_compatibility",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Output groups:
|
||||||
|
- current_repository_view: repository_view + raw_by_hash
|
||||||
|
- current_validation_state: vcir + audit_rule_index
|
||||||
|
- current_rrdp_state: rrdp_state + rrdp_source + rrdp_source_member + rrdp_uri_owner
|
||||||
|
- legacy_compatibility: raw_objects + rrdp_object_index
|
||||||
|
"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cf_group(cf_name: &str) -> CfGroup {
|
||||||
|
match cf_name {
|
||||||
|
CF_REPOSITORY_VIEW | CF_RAW_BY_HASH => CfGroup::CurrentRepositoryView,
|
||||||
|
CF_VCIR | CF_AUDIT_RULE_INDEX => CfGroup::CurrentValidationState,
|
||||||
|
CF_RRDP_STATE | CF_RRDP_SOURCE | CF_RRDP_SOURCE_MEMBER | CF_RRDP_URI_OWNER => {
|
||||||
|
CfGroup::CurrentRrdpState
|
||||||
|
}
|
||||||
|
CF_RAW_OBJECTS | CF_RRDP_OBJECT_INDEX => CfGroup::LegacyCompatibility,
|
||||||
|
_ => CfGroup::LegacyCompatibility,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_counts<'a>(
|
||||||
|
counts: impl IntoIterator<Item = (&'a str, u64)>,
|
||||||
|
) -> BTreeMap<CfGroup, u64> {
|
||||||
|
let mut grouped = BTreeMap::new();
|
||||||
|
for (cf_name, count) in counts {
|
||||||
|
*grouped.entry(cf_group(cf_name)).or_insert(0) += count;
|
||||||
|
}
|
||||||
|
grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mode_label(mode: DbStatsMode) -> &'static str {
|
||||||
|
match mode {
|
||||||
|
DbStatsMode::Estimate => "estimate",
|
||||||
|
DbStatsMode::Exact => "exact",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mode = DbStatsMode::Estimate;
|
||||||
|
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" => mode = DbStatsMode::Exact,
|
||||||
|
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, column_family_descriptors())?;
|
||||||
|
|
||||||
|
println!("db={}", db_path.display());
|
||||||
|
println!("mode={}", mode_label(mode));
|
||||||
|
|
||||||
|
let mut per_cf = Vec::with_capacity(ALL_COLUMN_FAMILY_NAMES.len());
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
for &name in ALL_COLUMN_FAMILY_NAMES {
|
||||||
|
let n = match mode {
|
||||||
|
DbStatsMode::Exact => exact_keys(&db, name)?,
|
||||||
|
DbStatsMode::Estimate => estimate_keys(&db, name)?.unwrap_or(0),
|
||||||
|
};
|
||||||
|
total = total.saturating_add(n);
|
||||||
|
per_cf.push((name, n));
|
||||||
|
println!("{name}={n}");
|
||||||
|
}
|
||||||
|
println!("total={total}");
|
||||||
|
|
||||||
|
for (group, count) in summarize_counts(per_cf.iter().copied()) {
|
||||||
|
println!("group_{}={count}", group.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
let live = db.live_files()?;
|
||||||
|
println!("sst_files={}", live.len());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cf_group_classifies_current_and_legacy_keyspaces() {
|
||||||
|
assert_eq!(cf_group(CF_REPOSITORY_VIEW), CfGroup::CurrentRepositoryView);
|
||||||
|
assert_eq!(cf_group(CF_RAW_BY_HASH), CfGroup::CurrentRepositoryView);
|
||||||
|
assert_eq!(cf_group(CF_VCIR), CfGroup::CurrentValidationState);
|
||||||
|
assert_eq!(
|
||||||
|
cf_group(CF_AUDIT_RULE_INDEX),
|
||||||
|
CfGroup::CurrentValidationState
|
||||||
|
);
|
||||||
|
assert_eq!(cf_group(CF_RRDP_SOURCE), CfGroup::CurrentRrdpState);
|
||||||
|
assert_eq!(cf_group(CF_RRDP_URI_OWNER), CfGroup::CurrentRrdpState);
|
||||||
|
assert_eq!(cf_group(CF_RAW_OBJECTS), CfGroup::LegacyCompatibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarize_counts_accumulates_by_group() {
|
||||||
|
let grouped = summarize_counts([
|
||||||
|
(CF_REPOSITORY_VIEW, 5),
|
||||||
|
(CF_RAW_BY_HASH, 7),
|
||||||
|
(CF_VCIR, 11),
|
||||||
|
(CF_AUDIT_RULE_INDEX, 13),
|
||||||
|
(CF_RRDP_STATE, 17),
|
||||||
|
(CF_RRDP_SOURCE_MEMBER, 19),
|
||||||
|
(CF_RRDP_OBJECT_INDEX, 29),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(grouped.get(&CfGroup::CurrentRepositoryView), Some(&12));
|
||||||
|
assert_eq!(grouped.get(&CfGroup::CurrentValidationState), Some(&24));
|
||||||
|
assert_eq!(grouped.get(&CfGroup::CurrentRrdpState), Some(&36));
|
||||||
|
assert_eq!(grouped.get(&CfGroup::LegacyCompatibility), Some(&29));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage_mentions_grouped_output_and_exact_mode() {
|
||||||
|
let text = usage();
|
||||||
|
assert!(text.contains("--exact"), "{text}");
|
||||||
|
assert!(text.contains("current_validation_state"), "{text}");
|
||||||
|
assert!(text.contains("legacy_compatibility"), "{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
346
src/bin/rrdp_state_dump.rs
Normal file
346
src/bin/rrdp_state_dump.rs
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use rocksdb::{DB, IteratorMode, Options};
|
||||||
|
use rpki::storage::{
|
||||||
|
CF_RRDP_SOURCE, CF_RRDP_SOURCE_MEMBER, CF_RRDP_STATE, CF_RRDP_URI_OWNER,
|
||||||
|
RrdpSourceMemberRecord, RrdpSourceRecord, RrdpUriOwnerRecord, column_family_descriptors,
|
||||||
|
};
|
||||||
|
use rpki::sync::rrdp::RrdpState;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum DumpView {
|
||||||
|
LegacyState,
|
||||||
|
Source,
|
||||||
|
Members,
|
||||||
|
Owners,
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DumpView {
|
||||||
|
fn parse(value: &str) -> Result<Self, String> {
|
||||||
|
match value {
|
||||||
|
"legacy-state" => Ok(Self::LegacyState),
|
||||||
|
"source" => Ok(Self::Source),
|
||||||
|
"members" => Ok(Self::Members),
|
||||||
|
"owners" => Ok(Self::Owners),
|
||||||
|
"all" => Ok(Self::All),
|
||||||
|
other => Err(format!(
|
||||||
|
"invalid --view: {other} (expected one of: legacy-state, source, members, owners, all)"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct DumpArgs {
|
||||||
|
db_path: PathBuf,
|
||||||
|
view: DumpView,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage() -> String {
|
||||||
|
let bin = "rrdp_state_dump";
|
||||||
|
format!(
|
||||||
|
"\
|
||||||
|
Usage:
|
||||||
|
{bin} --db <path> [--view <legacy-state|source|members|owners|all>]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--db <path> RocksDB directory
|
||||||
|
--view <name> Dump one RRDP view; default is all
|
||||||
|
--help Show this help
|
||||||
|
"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args(argv: &[String]) -> Result<DumpArgs, String> {
|
||||||
|
if argv.iter().any(|a| a == "--help" || a == "-h") {
|
||||||
|
return Err(usage());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut db_path: Option<PathBuf> = None;
|
||||||
|
let mut view = DumpView::All;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
"--view" => {
|
||||||
|
i += 1;
|
||||||
|
let v = argv.get(i).ok_or("--view requires a value")?;
|
||||||
|
view = DumpView::parse(v)?;
|
||||||
|
}
|
||||||
|
other => return Err(format!("unknown argument: {other}\n\n{}", usage())),
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DumpArgs {
|
||||||
|
db_path: db_path.ok_or_else(|| format!("--db is required\n\n{}", usage()))?,
|
||||||
|
view,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_db(path: &std::path::Path) -> Result<DB, Box<dyn std::error::Error>> {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
opts.create_if_missing(false);
|
||||||
|
opts.create_missing_column_families(false);
|
||||||
|
Ok(DB::open_cf_descriptors(
|
||||||
|
&opts,
|
||||||
|
path,
|
||||||
|
column_family_descriptors(),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_legacy_state(db: &DB) -> Result<Vec<(String, RrdpState)>, Box<dyn std::error::Error>> {
|
||||||
|
let cf = db
|
||||||
|
.cf_handle(CF_RRDP_STATE)
|
||||||
|
.ok_or("missing column family: rrdp_state")?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for res in db.iterator_cf(cf, IteratorMode::Start) {
|
||||||
|
let (k, v) = res?;
|
||||||
|
let notify_uri = String::from_utf8_lossy(&k).to_string();
|
||||||
|
let state = RrdpState::decode(&v)
|
||||||
|
.map_err(|e| format!("decode rrdp_state failed for {notify_uri}: {e}"))?;
|
||||||
|
out.push((notify_uri, state));
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_source_records(db: &DB) -> Result<Vec<RrdpSourceRecord>, Box<dyn std::error::Error>> {
|
||||||
|
let cf = db
|
||||||
|
.cf_handle(CF_RRDP_SOURCE)
|
||||||
|
.ok_or("missing column family: rrdp_source")?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for res in db.iterator_cf(cf, IteratorMode::Start) {
|
||||||
|
let (_k, v) = res?;
|
||||||
|
let record: RrdpSourceRecord = serde_cbor::from_slice(&v)?;
|
||||||
|
record.validate_internal()?;
|
||||||
|
out.push(record);
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.notify_uri.cmp(&b.notify_uri));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_source_member_records(
|
||||||
|
db: &DB,
|
||||||
|
) -> Result<Vec<RrdpSourceMemberRecord>, Box<dyn std::error::Error>> {
|
||||||
|
let cf = db
|
||||||
|
.cf_handle(CF_RRDP_SOURCE_MEMBER)
|
||||||
|
.ok_or("missing column family: rrdp_source_member")?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for res in db.iterator_cf(cf, IteratorMode::Start) {
|
||||||
|
let (_k, v) = res?;
|
||||||
|
let record: RrdpSourceMemberRecord = serde_cbor::from_slice(&v)?;
|
||||||
|
record.validate_internal()?;
|
||||||
|
out.push(record);
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| {
|
||||||
|
a.notify_uri
|
||||||
|
.cmp(&b.notify_uri)
|
||||||
|
.then(a.rsync_uri.cmp(&b.rsync_uri))
|
||||||
|
});
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_uri_owner_records(
|
||||||
|
db: &DB,
|
||||||
|
) -> Result<Vec<RrdpUriOwnerRecord>, Box<dyn std::error::Error>> {
|
||||||
|
let cf = db
|
||||||
|
.cf_handle(CF_RRDP_URI_OWNER)
|
||||||
|
.ok_or("missing column family: rrdp_uri_owner")?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for res in db.iterator_cf(cf, IteratorMode::Start) {
|
||||||
|
let (_k, v) = res?;
|
||||||
|
let record: RrdpUriOwnerRecord = serde_cbor::from_slice(&v)?;
|
||||||
|
record.validate_internal()?;
|
||||||
|
out.push(record);
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.rsync_uri.cmp(&b.rsync_uri));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_legacy_state(entries: &[(String, RrdpState)]) {
|
||||||
|
println!("[legacy-state]");
|
||||||
|
println!("notify_uri\tserial\tsession_id");
|
||||||
|
for (notify_uri, state) in entries {
|
||||||
|
println!("{notify_uri}\t{}\t{}", state.serial, state.session_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_source_records(entries: &[RrdpSourceRecord]) {
|
||||||
|
println!("[source]");
|
||||||
|
println!("notify_uri\tlast_serial\tlast_session_id\tsync_state\tlast_snapshot_uri\tlast_error");
|
||||||
|
for record in entries {
|
||||||
|
println!(
|
||||||
|
"{}\t{}\t{}\t{:?}\t{}\t{}",
|
||||||
|
record.notify_uri,
|
||||||
|
record
|
||||||
|
.last_serial
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
record.last_session_id.as_deref().unwrap_or("-"),
|
||||||
|
record.sync_state,
|
||||||
|
record.last_snapshot_uri.as_deref().unwrap_or("-"),
|
||||||
|
record.last_error.as_deref().unwrap_or("-"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_source_member_records(entries: &[RrdpSourceMemberRecord]) {
|
||||||
|
println!("[members]");
|
||||||
|
println!("notify_uri\trsync_uri\tpresent\thash\tsession_id\tserial");
|
||||||
|
for record in entries {
|
||||||
|
println!(
|
||||||
|
"{}\t{}\t{}\t{}\t{}\t{}",
|
||||||
|
record.notify_uri,
|
||||||
|
record.rsync_uri,
|
||||||
|
record.present,
|
||||||
|
record.current_hash.as_deref().unwrap_or("-"),
|
||||||
|
record.last_confirmed_session_id,
|
||||||
|
record.last_confirmed_serial,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_uri_owner_records(entries: &[RrdpUriOwnerRecord]) {
|
||||||
|
println!("[owners]");
|
||||||
|
println!("rsync_uri\tnotify_uri\towner_state\thash\tsession_id\tserial");
|
||||||
|
for record in entries {
|
||||||
|
println!(
|
||||||
|
"{}\t{}\t{:?}\t{}\t{}\t{}",
|
||||||
|
record.rsync_uri,
|
||||||
|
record.notify_uri,
|
||||||
|
record.owner_state,
|
||||||
|
record.current_hash.as_deref().unwrap_or("-"),
|
||||||
|
record.last_confirmed_session_id,
|
||||||
|
record.last_confirmed_serial,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let argv: Vec<String> = std::env::args().collect();
|
||||||
|
let args = parse_args(&argv).map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
|
||||||
|
let db = open_db(&args.db_path)?;
|
||||||
|
|
||||||
|
match args.view {
|
||||||
|
DumpView::LegacyState => print_legacy_state(&collect_legacy_state(&db)?),
|
||||||
|
DumpView::Source => print_source_records(&collect_source_records(&db)?),
|
||||||
|
DumpView::Members => print_source_member_records(&collect_source_member_records(&db)?),
|
||||||
|
DumpView::Owners => print_uri_owner_records(&collect_uri_owner_records(&db)?),
|
||||||
|
DumpView::All => {
|
||||||
|
print_legacy_state(&collect_legacy_state(&db)?);
|
||||||
|
print_source_records(&collect_source_records(&db)?);
|
||||||
|
print_source_member_records(&collect_source_member_records(&db)?);
|
||||||
|
print_uri_owner_records(&collect_uri_owner_records(&db)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rpki::storage::{PackTime, RocksStore, RrdpSourceSyncState, RrdpUriOwnerState};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_args_accepts_view_and_db() {
|
||||||
|
let args = parse_args(&[
|
||||||
|
"rrdp_state_dump".to_string(),
|
||||||
|
"--db".to_string(),
|
||||||
|
"db".to_string(),
|
||||||
|
"--view".to_string(),
|
||||||
|
"owners".to_string(),
|
||||||
|
])
|
||||||
|
.expect("parse args");
|
||||||
|
assert_eq!(args.db_path, PathBuf::from("db"));
|
||||||
|
assert_eq!(args.view, DumpView::Owners);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_args_rejects_invalid_view() {
|
||||||
|
let err = parse_args(&[
|
||||||
|
"rrdp_state_dump".to_string(),
|
||||||
|
"--db".to_string(),
|
||||||
|
"db".to_string(),
|
||||||
|
"--view".to_string(),
|
||||||
|
"nope".to_string(),
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("invalid --view"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_rrdp_views_reads_legacy_and_current_records() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = RocksStore::open(dir.path()).expect("open store");
|
||||||
|
let legacy_state = RrdpState {
|
||||||
|
session_id: "session-1".to_string(),
|
||||||
|
serial: 42,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.put_rrdp_state(
|
||||||
|
"https://example.test/notify.xml",
|
||||||
|
&legacy_state.encode().expect("encode legacy state"),
|
||||||
|
)
|
||||||
|
.expect("put legacy state");
|
||||||
|
store
|
||||||
|
.put_rrdp_source_record(&RrdpSourceRecord {
|
||||||
|
notify_uri: "https://example.test/notify.xml".to_string(),
|
||||||
|
last_session_id: Some("session-1".to_string()),
|
||||||
|
last_serial: Some(42),
|
||||||
|
first_seen_at: PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc()),
|
||||||
|
last_seen_at: PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc()),
|
||||||
|
last_sync_at: None,
|
||||||
|
sync_state: RrdpSourceSyncState::DeltaReady,
|
||||||
|
last_snapshot_uri: Some("https://example.test/snapshot.xml".to_string()),
|
||||||
|
last_snapshot_hash: None,
|
||||||
|
last_error: None,
|
||||||
|
})
|
||||||
|
.expect("put source record");
|
||||||
|
store
|
||||||
|
.put_rrdp_source_member_record(&RrdpSourceMemberRecord {
|
||||||
|
notify_uri: "https://example.test/notify.xml".to_string(),
|
||||||
|
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
|
||||||
|
current_hash: Some("11".repeat(32)),
|
||||||
|
object_type: Some("roa".to_string()),
|
||||||
|
present: true,
|
||||||
|
last_confirmed_session_id: "session-1".to_string(),
|
||||||
|
last_confirmed_serial: 42,
|
||||||
|
last_changed_at: PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc()),
|
||||||
|
})
|
||||||
|
.expect("put member record");
|
||||||
|
store
|
||||||
|
.put_rrdp_uri_owner_record(&RrdpUriOwnerRecord {
|
||||||
|
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
|
||||||
|
notify_uri: "https://example.test/notify.xml".to_string(),
|
||||||
|
current_hash: Some("11".repeat(32)),
|
||||||
|
last_confirmed_session_id: "session-1".to_string(),
|
||||||
|
last_confirmed_serial: 42,
|
||||||
|
last_changed_at: PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc()),
|
||||||
|
owner_state: RrdpUriOwnerState::Active,
|
||||||
|
})
|
||||||
|
.expect("put owner record");
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let db = open_db(dir.path()).expect("open db");
|
||||||
|
let legacy = collect_legacy_state(&db).expect("legacy dump");
|
||||||
|
let sources = collect_source_records(&db).expect("source dump");
|
||||||
|
let members = collect_source_member_records(&db).expect("members dump");
|
||||||
|
let owners = collect_uri_owner_records(&db).expect("owners dump");
|
||||||
|
|
||||||
|
assert_eq!(legacy.len(), 1);
|
||||||
|
assert_eq!(legacy[0].0, "https://example.test/notify.xml");
|
||||||
|
assert_eq!(legacy[0].1.serial, 42);
|
||||||
|
assert_eq!(sources.len(), 1);
|
||||||
|
assert_eq!(sources[0].sync_state, RrdpSourceSyncState::DeltaReady);
|
||||||
|
assert_eq!(members.len(), 1);
|
||||||
|
assert_eq!(members[0].rsync_uri, "rsync://example.test/repo/a.roa");
|
||||||
|
assert_eq!(owners.len(), 1);
|
||||||
|
assert_eq!(owners[0].owner_state, RrdpUriOwnerState::Active);
|
||||||
|
}
|
||||||
|
}
|
||||||
932
src/cli.rs
Normal file
932
src/cli.rs
Normal file
@@ -0,0 +1,932 @@
|
|||||||
|
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 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)
|
||||||
|
--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 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);
|
||||||
|
}
|
||||||
|
"--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,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 parse_rejects_removed_revalidate_only_flag() {
|
||||||
|
let argv = vec![
|
||||||
|
"rpki".to_string(),
|
||||||
|
"--db".to_string(),
|
||||||
|
"db".to_string(),
|
||||||
|
"--tal-url".to_string(),
|
||||||
|
"https://example.test/x.tal".to_string(),
|
||||||
|
"--revalidate-only".to_string(),
|
||||||
|
];
|
||||||
|
let err = parse_args(&argv).unwrap_err();
|
||||||
|
assert!(err.contains("unknown argument: --revalidate-only"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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};
|
pub use crate::data_model::common::{Asn1TimeEncoding, Asn1TimeUtc, BigUnsigned};
|
||||||
use x509_parser::prelude::FromDer;
|
use crate::data_model::oid::{
|
||||||
use x509_parser::prelude::X509Version;
|
OID_AUTHORITY_KEY_IDENTIFIER, OID_AUTHORITY_KEY_IDENTIFIER_RAW, OID_CRL_NUMBER,
|
||||||
use x509_parser::revocation_list::CertificateRevocationList;
|
OID_CRL_NUMBER_RAW, OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW,
|
||||||
use x509_parser::asn1_rs::Tag;
|
OID_SUBJECT_KEY_IDENTIFIER_RAW,
|
||||||
|
};
|
||||||
use x509_parser::certificate::X509Certificate;
|
use x509_parser::certificate::X509Certificate;
|
||||||
use x509_parser::x509::SubjectPublicKeyInfo;
|
use x509_parser::extensions::{ParsedExtension, X509Extension};
|
||||||
use x509_parser::x509::AlgorithmIdentifier;
|
use x509_parser::prelude::{FromDer, X509Version};
|
||||||
|
use x509_parser::revocation_list::CertificateRevocationList;
|
||||||
const OID_SHA256_WITH_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.11";
|
use x509_parser::x509::{AlgorithmIdentifier, SubjectPublicKeyInfo};
|
||||||
const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35";
|
use x509_parser::{asn1_rs::Class as Asn1Class, asn1_rs::Tag as Asn1Tag};
|
||||||
const OID_CRL_NUMBER: &str = "2.5.29.20";
|
|
||||||
const OID_SUBJECT_KEY_IDENTIFIER: &str = "2.5.29.14";
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum Asn1TimeEncoding {
|
|
||||||
UtcTime,
|
|
||||||
GeneralizedTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct Asn1TimeUtc {
|
|
||||||
pub utc: time::OffsetDateTime,
|
|
||||||
pub encoding: Asn1TimeEncoding,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct BigUnsigned {
|
|
||||||
/// Minimal big-endian bytes. For zero, this is `[0]`.
|
|
||||||
pub bytes_be: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BigUnsigned {
|
|
||||||
pub fn to_hex_upper(&self) -> String {
|
|
||||||
hex::encode_upper(&self.bytes_be)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_u64(&self) -> Option<u64> {
|
|
||||||
if self.bytes_be.len() > 8 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut value: u64 = 0;
|
|
||||||
for &b in &self.bytes_be {
|
|
||||||
value = (value << 8) | (b as u64);
|
|
||||||
}
|
|
||||||
Some(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct RevokedCert {
|
pub struct RevokedCert {
|
||||||
@@ -71,54 +35,131 @@ pub struct RpkixCrl {
|
|||||||
pub extensions: CrlExtensions,
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum CrlDecodeError {
|
pub enum CrlParseError {
|
||||||
#[error("X.509 CRL parse error: {0}")]
|
#[error("X.509 CRL parse error: {0} (RFC 5280 §5.1; RFC 6487 §5)")]
|
||||||
Parse(String),
|
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),
|
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>),
|
InvalidVersion(Option<u32>),
|
||||||
|
|
||||||
#[error("CRL signatureAlgorithm must be sha256WithRSAEncryption ({OID_SHA256_WITH_RSA_ENCRYPTION}), got {0}")]
|
#[error("CRL signatureAlgorithm must match TBSCertList.signature (RFC 5280 §5.1)")]
|
||||||
InvalidSignatureAlgorithm(String),
|
|
||||||
|
|
||||||
#[error("CRL signature algorithm parameters must be absent or NULL")]
|
|
||||||
InvalidSignatureAlgorithmParameters,
|
|
||||||
|
|
||||||
#[error("CRL signatureAlgorithm must match TBSCertList.signature")]
|
|
||||||
SignatureAlgorithmMismatch,
|
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),
|
InvalidExtensionsCount(usize),
|
||||||
|
|
||||||
#[error("unsupported CRL extension OID {0}")]
|
#[error("unsupported CRL extension OID {0} (RFC 9829 §3.1)")]
|
||||||
UnsupportedExtension(String),
|
UnsupportedExtension(String),
|
||||||
|
|
||||||
#[error("duplicate CRL extension OID {0}")]
|
#[error("duplicate CRL extension OID {0} (RFC 5280 §4.2; RFC 9829 §3.1)")]
|
||||||
DuplicateExtension(String),
|
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,
|
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,
|
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,
|
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,
|
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,
|
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,
|
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 {
|
InvalidTimeEncoding {
|
||||||
field: &'static str,
|
field: &'static str,
|
||||||
year: i32,
|
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 {
|
impl RpkixCrl {
|
||||||
/// Decode a DER-encoded X.509 v2 CRL and enforce the RPKI profile constraints from
|
/// Parse step of scheme A (`parse → validate → verify`).
|
||||||
/// `specs/prepare/data_models/04_crl.md` (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1).
|
pub fn parse_der(der: &[u8]) -> Result<RpkixCrlParsed, CrlParseError> {
|
||||||
pub fn decode_der(der: &[u8]) -> Result<Self, CrlDecodeError> {
|
|
||||||
let (rem, crl) = CertificateRevocationList::from_der(der)
|
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() {
|
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
|
let revoked_certs = crl
|
||||||
.iter_revoked_certificates()
|
.iter_revoked_certificates()
|
||||||
.map(|rc| {
|
.map(|rc| RevokedCertParsed {
|
||||||
if !rc.extensions().is_empty() {
|
serial_number: BigUnsigned::from_biguint(rc.serial()),
|
||||||
return Err(CrlDecodeError::EntryExtensionsNotAllowed);
|
revocation_date: crate::data_model::common::asn1_time_to_model(rc.revocation_date),
|
||||||
}
|
has_extensions: !rc.extensions().is_empty(),
|
||||||
let revocation_date = asn1_time_to_model(rc.revocation_date);
|
|
||||||
validate_time_encoding("revocationDate", &revocation_date)?;
|
|
||||||
Ok(RevokedCert {
|
|
||||||
serial_number: biguint_to_big_unsigned(rc.serial()),
|
|
||||||
revocation_date,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let this_update = asn1_time_to_model(crl.last_update());
|
|
||||||
validate_time_encoding("thisUpdate", &this_update)?;
|
|
||||||
|
|
||||||
|
let this_update = crate::data_model::common::asn1_time_to_model(crl.last_update());
|
||||||
let next_update = crl
|
let next_update = crl
|
||||||
.next_update()
|
.next_update()
|
||||||
.map(asn1_time_to_model)
|
.map(crate::data_model::common::asn1_time_to_model);
|
||||||
.ok_or(CrlDecodeError::NextUpdateMissing)?;
|
|
||||||
validate_time_encoding("nextUpdate", &next_update)?;
|
|
||||||
|
|
||||||
Ok(RpkixCrl {
|
let extensions =
|
||||||
|
parse_extensions_parse(crl.extensions()).map_err(|e| CrlParseError::Parse(e))?;
|
||||||
|
|
||||||
|
Ok(RpkixCrlParsed {
|
||||||
raw_der: der.to_vec(),
|
raw_der: der.to_vec(),
|
||||||
version,
|
version: crl.version(),
|
||||||
issuer_dn: crl.issuer().to_string(),
|
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,
|
this_update,
|
||||||
next_update,
|
next_update,
|
||||||
revoked_certs,
|
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.
|
/// 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)).
|
/// 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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum CrlVerifyError {
|
pub enum CrlVerifyError {
|
||||||
#[error("issuer certificate parse error: {0}")]
|
#[error("issuer certificate parse error: {0} (RFC 5280 §4.1; RFC 6487 §4)")]
|
||||||
IssuerCertificateParse(String),
|
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),
|
IssuerCertificateTrailingBytes(usize),
|
||||||
|
|
||||||
#[error("issuer SubjectPublicKeyInfo parse error: {0}")]
|
#[error("issuer SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7)")]
|
||||||
IssuerSpkiParse(String),
|
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),
|
IssuerSpkiTrailingBytes(usize),
|
||||||
|
|
||||||
#[error("CRL parse error: {0}")]
|
#[error("CRL parse error: {0} (RFC 5280 §5.1; RFC 6487 §5)")]
|
||||||
CrlParse(String),
|
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),
|
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 {
|
IssuerSubjectMismatch {
|
||||||
crl_issuer_dn: String,
|
crl_issuer_dn: String,
|
||||||
issuer_subject_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,
|
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,
|
AkiSkiMismatch,
|
||||||
|
|
||||||
#[error("CRL signature verification failed: {0}")]
|
#[error("CRL signature verification failed: {0} (RFC 5280 §6.3.3(g); RFC 7935 §2)")]
|
||||||
InvalidSignature(String),
|
InvalidSignature(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn asn1_time_to_model(t: x509_parser::time::ASN1Time) -> Asn1TimeUtc {
|
fn validate_time_encoding_rfc5280(
|
||||||
let encoding = if t.is_utctime() {
|
field: &'static str,
|
||||||
Asn1TimeEncoding::UtcTime
|
t: &Asn1TimeUtc,
|
||||||
} else {
|
) -> Result<(), CrlProfileError> {
|
||||||
Asn1TimeEncoding::GeneralizedTime
|
|
||||||
};
|
|
||||||
Asn1TimeUtc {
|
|
||||||
utc: t.to_datetime(),
|
|
||||||
encoding,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_time_encoding(field: &'static str, t: &Asn1TimeUtc) -> Result<(), CrlDecodeError> {
|
|
||||||
let year = t.utc.year();
|
let year = t.utc.year();
|
||||||
let expected = if year <= 2049 {
|
let expected = if year <= 2049 {
|
||||||
Asn1TimeEncoding::UtcTime
|
Asn1TimeEncoding::UtcTime
|
||||||
@@ -321,7 +411,7 @@ fn validate_time_encoding(field: &'static str, t: &Asn1TimeUtc) -> Result<(), Cr
|
|||||||
Asn1TimeEncoding::GeneralizedTime
|
Asn1TimeEncoding::GeneralizedTime
|
||||||
};
|
};
|
||||||
if t.encoding != expected {
|
if t.encoding != expected {
|
||||||
return Err(CrlDecodeError::InvalidTimeEncoding {
|
return Err(CrlProfileError::InvalidTimeEncoding {
|
||||||
field,
|
field,
|
||||||
year,
|
year,
|
||||||
encoding: t.encoding,
|
encoding: t.encoding,
|
||||||
@@ -330,98 +420,117 @@ fn validate_time_encoding(field: &'static str, t: &Asn1TimeUtc) -> Result<(), Cr
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_sig_params(sig: &AlgorithmIdentifier<'_>) -> Result<(), CrlDecodeError> {
|
fn algorithm_identifier_value(ai: &AlgorithmIdentifier<'_>) -> AlgorithmIdentifierValue {
|
||||||
match sig.parameters.as_ref() {
|
let parameters = ai.parameters.as_ref().map(|p| AlgorithmParametersValue {
|
||||||
None => Ok(()),
|
class: p.class(),
|
||||||
Some(p) if p.tag() == Tag::Null => Ok(()),
|
tag: p.tag(),
|
||||||
Some(_p) => Err(CrlDecodeError::InvalidSignatureAlgorithmParameters),
|
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 {
|
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 authority_key_identifier: Option<Vec<u8>> = None;
|
||||||
let mut crl_number: Option<BigUnsigned> = None;
|
let mut crl_number: Option<BigUnsigned> = None;
|
||||||
|
|
||||||
for ext in exts {
|
for ext in exts {
|
||||||
let oid = ext.oid.to_id_string();
|
match ext {
|
||||||
match oid.as_str() {
|
CrlExtensionParsed::AuthorityKeyIdentifier {
|
||||||
OID_AUTHORITY_KEY_IDENTIFIER => {
|
key_identifier,
|
||||||
if authority_key_identifier.is_some() {
|
has_other_fields,
|
||||||
return Err(CrlDecodeError::DuplicateExtension(oid));
|
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)?;
|
seen.push(oid.clone());
|
||||||
authority_key_identifier = Some(aki);
|
|
||||||
|
if *has_other_fields {
|
||||||
|
return Err(CrlProfileError::AkiHasOtherFields);
|
||||||
|
}
|
||||||
|
let ki = key_identifier
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(CrlProfileError::AkiMissingKeyIdentifier)?;
|
||||||
|
authority_key_identifier = Some(ki.clone());
|
||||||
}
|
}
|
||||||
OID_CRL_NUMBER => {
|
CrlExtensionParsed::CrlNumber { number, critical } => {
|
||||||
if crl_number.is_some() {
|
let oid = OID_CRL_NUMBER.to_string();
|
||||||
return Err(CrlDecodeError::DuplicateExtension(oid));
|
if seen.iter().any(|s| s == &oid) {
|
||||||
|
return Err(CrlProfileError::DuplicateExtension(oid));
|
||||||
}
|
}
|
||||||
if ext.critical {
|
seen.push(oid.clone());
|
||||||
return Err(CrlDecodeError::CrlNumberCritical);
|
|
||||||
|
if *critical {
|
||||||
|
return Err(CrlProfileError::CrlNumberCritical);
|
||||||
}
|
}
|
||||||
let n = parse_crl_number(ext)?;
|
if number.bits() > 159 {
|
||||||
if n.bits() > 159 {
|
return Err(CrlProfileError::CrlNumberOutOfRange);
|
||||||
return Err(CrlDecodeError::CrlNumberOutOfRange);
|
|
||||||
}
|
}
|
||||||
crl_number = Some(biguint_to_big_unsigned(&n));
|
crl_number = Some(BigUnsigned::from_biguint(number));
|
||||||
|
}
|
||||||
|
CrlExtensionParsed::Other { oid, .. } => {
|
||||||
|
return Err(CrlProfileError::UnsupportedExtension(oid.clone()));
|
||||||
}
|
}
|
||||||
_ => return Err(CrlDecodeError::UnsupportedExtension(oid)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(CrlExtensions {
|
Ok(CrlExtensions {
|
||||||
authority_key_identifier: authority_key_identifier.unwrap(),
|
authority_key_identifier: authority_key_identifier.ok_or(CrlProfileError::AkiMissing)?,
|
||||||
crl_number: crl_number.unwrap(),
|
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>> {
|
fn get_subject_key_identifier(cert: &X509Certificate<'_>) -> Option<Vec<u8>> {
|
||||||
cert.extensions()
|
cert.extensions()
|
||||||
.iter()
|
.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() {
|
.and_then(|ext| match ext.parsed_extension() {
|
||||||
ParsedExtension::SubjectKeyIdentifier(ki) => Some(ki.0.to_vec()),
|
ParsedExtension::SubjectKeyIdentifier(ki) => Some(ki.0.to_vec()),
|
||||||
_ => None,
|
_ => 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;
|
pub mod crl;
|
||||||
mod rc;
|
pub mod manifest;
|
||||||
mod tal;
|
pub mod oid;
|
||||||
mod ta;
|
pub mod rc;
|
||||||
mod resources;
|
pub mod roa;
|
||||||
mod oids;
|
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";
|
|
||||||
1889
src/data_model/rc.rs
1889
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 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(Clone, Debug, PartialEq, Eq)]
|
||||||
// #[derive(Debug, Clone)]
|
pub struct TaCertificate {
|
||||||
// pub struct TrustAnchorCert {
|
pub raw_der: Vec<u8>,
|
||||||
// /// 信任锚证书名称
|
pub rc_ca: ResourceCertificate,
|
||||||
// pub name: String,
|
}
|
||||||
//
|
|
||||||
// /// 证书原始DER内容
|
#[derive(Debug, thiserror::Error)]
|
||||||
// pub cert_der: Vec<u8>,
|
pub enum TaCertificateParseError {
|
||||||
//
|
#[error("TA certificate parse error: {0} (RFC 5280 §4.1; RFC 6487 §4; RFC 8630 §2.3)")]
|
||||||
// /// 证书
|
ResourceCertificate(#[from] ResourceCertificateParseError),
|
||||||
// pub cert: X509Certificate<'static>,
|
}
|
||||||
//
|
|
||||||
// /// 资源集合
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
// pub resources: ResourceSet,
|
pub struct TaCertificateParsed {
|
||||||
//
|
pub rc_parsed: ResourceCertificateParsed,
|
||||||
// ///发布点
|
}
|
||||||
// pub publication_point: Url,
|
|
||||||
// }
|
#[derive(Debug, thiserror::Error)]
|
||||||
//
|
pub enum TaCertificateProfileError {
|
||||||
// impl TrustAnchorCert {
|
#[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
|
use base64::Engine;
|
||||||
#[derive(Clone, Debug)]
|
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 {
|
pub struct Tal {
|
||||||
/// Optional human-readable comments
|
pub raw: Vec<u8>,
|
||||||
pub comments: Vec<String>,
|
pub comments: Vec<String>,
|
||||||
|
pub ta_uris: Vec<Url>,
|
||||||
/// Ordered list of URIs pointing to the TA certificate
|
pub subject_public_key_info_der: Vec<u8>,
|
||||||
pub uris: Vec<TalUri>,
|
|
||||||
|
|
||||||
/// SubjectPublicKeyInfo DER
|
|
||||||
pub spki_der: Vec<u8>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum TalUri {
|
pub enum TalParseError {
|
||||||
Rsync(String),
|
#[error("TAL must be valid UTF-8 (RFC 8630 §2.2)")]
|
||||||
Https(String),
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
319
src/fetch/rsync_system.rs
Normal file
319
src/fetch/rsync_system.rs
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mirror_dst_dir_reports_root_creation_error() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let root_file = temp.path().join("mirror-root-file");
|
||||||
|
std::fs::write(&root_file, b"not a directory").expect("write root file");
|
||||||
|
|
||||||
|
let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig {
|
||||||
|
rsync_bin: PathBuf::from("rsync"),
|
||||||
|
timeout: Duration::from_secs(1),
|
||||||
|
extra_args: Vec::new(),
|
||||||
|
mirror_root: Some(root_file.clone()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let err = fetcher
|
||||||
|
.mirror_dst_dir("rsync://example.net/repo/")
|
||||||
|
.expect_err("file mirror root must fail");
|
||||||
|
assert!(err.contains("create rsync mirror root failed"), "{err}");
|
||||||
|
assert!(err.contains(&root_file.display().to_string()), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn mirror_dst_dir_reports_directory_creation_error_inside_root() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let root = temp.path().join("mirror");
|
||||||
|
std::fs::create_dir_all(&root).expect("mkdir root");
|
||||||
|
let mut perms = std::fs::metadata(&root).expect("metadata").permissions();
|
||||||
|
perms.set_mode(0o555);
|
||||||
|
std::fs::set_permissions(&root, perms).expect("chmod root readonly");
|
||||||
|
|
||||||
|
let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig {
|
||||||
|
rsync_bin: PathBuf::from("rsync"),
|
||||||
|
timeout: Duration::from_secs(1),
|
||||||
|
extra_args: Vec::new(),
|
||||||
|
mirror_root: Some(root.clone()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let err = fetcher
|
||||||
|
.mirror_dst_dir("rsync://example.net/repo/")
|
||||||
|
.expect_err("readonly mirror root must fail");
|
||||||
|
assert!(
|
||||||
|
err.contains("create rsync mirror directory failed"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut perms = std::fs::metadata(&root).expect("metadata").permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(&root, perms).expect("restore perms");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn walk_dir_collect_ignores_non_file_entries() {
|
||||||
|
use std::os::unix::net::UnixListener;
|
||||||
|
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let root = temp.path();
|
||||||
|
std::fs::write(root.join("a.cer"), b"x").expect("write file");
|
||||||
|
let socket_path = root.join("skip.sock");
|
||||||
|
let _listener = UnixListener::bind(&socket_path).expect("bind socket");
|
||||||
|
|
||||||
|
let mut out: Vec<(String, Vec<u8>)> = Vec::new();
|
||||||
|
walk_dir_collect(root, root, "rsync://example.net/repo/", &mut out).expect("walk");
|
||||||
|
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].0, "rsync://example.net/repo/a.cer");
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/lib.rs
23
src/lib.rs
@@ -1 +1,24 @@
|
|||||||
pub mod data_model;
|
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 audit_trace;
|
||||||
|
#[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 {
|
||||||
|
ReuseCurrentInstanceVcir,
|
||||||
|
StopAllOutput,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CaFailedFetchPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::ReuseCurrentInstanceVcir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
}
|
||||||
|
}
|
||||||
2165
src/storage.rs
Normal file
2165
src/storage.rs
Normal file
File diff suppressed because it is too large
Load Diff
3
src/sync/mod.rs
Normal file
3
src/sync/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod repo;
|
||||||
|
pub mod rrdp;
|
||||||
|
pub(crate) mod store_projection;
|
||||||
955
src/sync/repo.rs
Normal file
955
src/sync/repo.rs
Normal file
@@ -0,0 +1,955 @@
|
|||||||
|
use crate::analysis::timing::TimingHandle;
|
||||||
|
use crate::audit::AuditDownloadKind;
|
||||||
|
use crate::audit_downloads::DownloadLogHandle;
|
||||||
|
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 crate::sync::store_projection::{
|
||||||
|
put_repository_view_present, put_repository_view_withdrawn, upsert_raw_by_hash_evidence,
|
||||||
|
};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
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 existing_view = store
|
||||||
|
.list_repository_view_entries_with_prefix(rsync_base_uri)
|
||||||
|
.map_err(|e| RepoSyncError::Storage(e.to_string()))?;
|
||||||
|
let new_set: HashSet<&str> = objects.iter().map(|(uri, _)| uri.as_str()).collect();
|
||||||
|
|
||||||
|
let _w = timing
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| t.span_phase("rsync_write_raw_objects_total"));
|
||||||
|
store
|
||||||
|
.put_raw_batch(objects.clone())
|
||||||
|
.map_err(|e| RepoSyncError::Storage(e.to_string()))?;
|
||||||
|
drop(_w);
|
||||||
|
|
||||||
|
let _proj = timing
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| t.span_phase("rsync_write_repository_view_total"));
|
||||||
|
for entry in existing_view {
|
||||||
|
if !new_set.contains(entry.rsync_uri.as_str()) {
|
||||||
|
put_repository_view_withdrawn(
|
||||||
|
store,
|
||||||
|
rsync_base_uri,
|
||||||
|
&entry.rsync_uri,
|
||||||
|
entry.current_hash,
|
||||||
|
)
|
||||||
|
.map_err(RepoSyncError::Storage)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (uri, bytes) in &objects {
|
||||||
|
let current_hash =
|
||||||
|
upsert_raw_by_hash_evidence(store, uri, bytes).map_err(RepoSyncError::Storage)?;
|
||||||
|
put_repository_view_present(store, rsync_base_uri, uri, ¤t_hash)
|
||||||
|
.map_err(RepoSyncError::Storage)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(objects.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 view = store
|
||||||
|
.get_repository_view_entry("rsync://example.test/repo/a.mft")
|
||||||
|
.expect("get repository view")
|
||||||
|
.expect("repository view entry present");
|
||||||
|
assert_eq!(
|
||||||
|
view.current_hash.as_deref(),
|
||||||
|
Some(hex::encode(sha2::Sha256::digest(b"mft")).as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
view.repository_source.as_deref(),
|
||||||
|
Some("rsync://example.test/repo/")
|
||||||
|
);
|
||||||
|
|
||||||
|
let raw = store
|
||||||
|
.get_raw_by_hash_entry(hex::encode(sha2::Sha256::digest(b"roa")).as_str())
|
||||||
|
.expect("get raw_by_hash")
|
||||||
|
.expect("raw_by_hash entry present");
|
||||||
|
assert!(
|
||||||
|
raw.origin_uris
|
||||||
|
.iter()
|
||||||
|
.any(|uri| uri == "rsync://example.test/repo/sub/b.roa")
|
||||||
|
);
|
||||||
|
|
||||||
|
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 rsync_second_sync_marks_missing_repository_view_entries_withdrawn() {
|
||||||
|
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-v1").expect("write a");
|
||||||
|
std::fs::write(repo_dir.join("sub").join("b.roa"), b"roa-v1").expect("write b");
|
||||||
|
|
||||||
|
let store_dir = temp.path().join("db");
|
||||||
|
let store = RocksStore::open(&store_dir).expect("open rocksdb");
|
||||||
|
let policy = Policy {
|
||||||
|
sync_preference: SyncPreference::RsyncOnly,
|
||||||
|
..Policy::default()
|
||||||
|
};
|
||||||
|
let http = DummyHttpFetcher;
|
||||||
|
let rsync = LocalDirRsyncFetcher::new(&repo_dir);
|
||||||
|
|
||||||
|
sync_publication_point(
|
||||||
|
&store,
|
||||||
|
&policy,
|
||||||
|
None,
|
||||||
|
"rsync://example.test/repo/",
|
||||||
|
&http,
|
||||||
|
&rsync,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("first sync ok");
|
||||||
|
|
||||||
|
std::fs::remove_file(repo_dir.join("sub").join("b.roa")).expect("remove b");
|
||||||
|
std::fs::write(repo_dir.join("c.crl"), b"crl-v2").expect("write c");
|
||||||
|
|
||||||
|
sync_publication_point(
|
||||||
|
&store,
|
||||||
|
&policy,
|
||||||
|
None,
|
||||||
|
"rsync://example.test/repo/",
|
||||||
|
&http,
|
||||||
|
&rsync,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("second sync ok");
|
||||||
|
|
||||||
|
let withdrawn = store
|
||||||
|
.get_repository_view_entry("rsync://example.test/repo/sub/b.roa")
|
||||||
|
.expect("get withdrawn repo view")
|
||||||
|
.expect("withdrawn entry exists");
|
||||||
|
assert_eq!(
|
||||||
|
withdrawn.state,
|
||||||
|
crate::storage::RepositoryViewState::Withdrawn
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
withdrawn.repository_source.as_deref(),
|
||||||
|
Some("rsync://example.test/repo/")
|
||||||
|
);
|
||||||
|
|
||||||
|
let added = store
|
||||||
|
.get_repository_view_entry("rsync://example.test/repo/c.crl")
|
||||||
|
.expect("get added repo view")
|
||||||
|
.expect("added entry exists");
|
||||||
|
assert_eq!(added.state, crate::storage::RepositoryViewState::Present);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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));
|
||||||
|
}
|
||||||
|
}
|
||||||
2415
src/sync/rrdp.rs
Normal file
2415
src/sync/rrdp.rs
Normal file
File diff suppressed because it is too large
Load Diff
262
src/sync/store_projection.rs
Normal file
262
src/sync/store_projection.rs
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use crate::storage::{
|
||||||
|
PackTime, RawByHashEntry, RepositoryViewEntry, RepositoryViewState, RocksStore,
|
||||||
|
RrdpSourceMemberRecord, RrdpSourceRecord, RrdpSourceSyncState, RrdpUriOwnerRecord,
|
||||||
|
RrdpUriOwnerState,
|
||||||
|
};
|
||||||
|
use sha2::Digest;
|
||||||
|
|
||||||
|
pub fn infer_object_type_from_uri(uri: &str) -> Option<String> {
|
||||||
|
let ext = uri.rsplit('.').next()?;
|
||||||
|
let ext = ext.to_ascii_lowercase();
|
||||||
|
match ext.as_str() {
|
||||||
|
"cer" | "crl" | "mft" | "roa" | "asa" | "gbr" | "tal" | "xml" => Some(ext),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_raw_by_hash_evidence(
|
||||||
|
store: &RocksStore,
|
||||||
|
rsync_uri: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let sha256_hex = compute_sha256_hex(bytes);
|
||||||
|
let mut entry = match store
|
||||||
|
.get_raw_by_hash_entry(&sha256_hex)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
{
|
||||||
|
Some(existing) => existing,
|
||||||
|
None => RawByHashEntry::from_bytes(sha256_hex.clone(), bytes.to_vec()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if entry.bytes != bytes {
|
||||||
|
return Err(format!(
|
||||||
|
"raw_by_hash collision for {rsync_uri}: same sha256 maps to different bytes"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut origins: BTreeSet<String> = entry.origin_uris.into_iter().collect();
|
||||||
|
origins.insert(rsync_uri.to_string());
|
||||||
|
entry.origin_uris = origins.into_iter().collect();
|
||||||
|
if entry.object_type.is_none() {
|
||||||
|
entry.object_type = infer_object_type_from_uri(rsync_uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
store
|
||||||
|
.put_raw_by_hash_entry(&entry)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(sha256_hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_repository_view_present(
|
||||||
|
store: &RocksStore,
|
||||||
|
repository_source: &str,
|
||||||
|
rsync_uri: &str,
|
||||||
|
current_hash: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let entry = RepositoryViewEntry {
|
||||||
|
rsync_uri: rsync_uri.to_string(),
|
||||||
|
current_hash: Some(current_hash.to_string()),
|
||||||
|
repository_source: Some(repository_source.to_string()),
|
||||||
|
object_type: infer_object_type_from_uri(rsync_uri),
|
||||||
|
state: RepositoryViewState::Present,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.put_repository_view_entry(&entry)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_repository_view_withdrawn(
|
||||||
|
store: &RocksStore,
|
||||||
|
repository_source: &str,
|
||||||
|
rsync_uri: &str,
|
||||||
|
current_hash: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let entry = RepositoryViewEntry {
|
||||||
|
rsync_uri: rsync_uri.to_string(),
|
||||||
|
current_hash,
|
||||||
|
repository_source: Some(repository_source.to_string()),
|
||||||
|
object_type: infer_object_type_from_uri(rsync_uri),
|
||||||
|
state: RepositoryViewState::Withdrawn,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.put_repository_view_entry(&entry)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_rrdp_uri_can_be_owned_by(
|
||||||
|
store: &RocksStore,
|
||||||
|
notification_uri: &str,
|
||||||
|
rsync_uri: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let Some(owner) = store
|
||||||
|
.get_rrdp_uri_owner_record(rsync_uri)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if owner.notify_uri != notification_uri && owner.owner_state == RrdpUriOwnerState::Active {
|
||||||
|
return Err(format!(
|
||||||
|
"RRDP source owner conflict for {rsync_uri}: current owner {} but incoming source {}",
|
||||||
|
owner.notify_uri, notification_uri
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_rrdp_owner_is(
|
||||||
|
store: &RocksStore,
|
||||||
|
notification_uri: &str,
|
||||||
|
rsync_uri: &str,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
Ok(matches!(
|
||||||
|
store
|
||||||
|
.get_rrdp_uri_owner_record(rsync_uri)
|
||||||
|
.map_err(|e| e.to_string())?,
|
||||||
|
Some(owner)
|
||||||
|
if owner.notify_uri == notification_uri && owner.owner_state == RrdpUriOwnerState::Active
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_rrdp_source_member_present(
|
||||||
|
store: &RocksStore,
|
||||||
|
notification_uri: &str,
|
||||||
|
session_id: &str,
|
||||||
|
serial: u64,
|
||||||
|
rsync_uri: &str,
|
||||||
|
current_hash: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let record = RrdpSourceMemberRecord {
|
||||||
|
notify_uri: notification_uri.to_string(),
|
||||||
|
rsync_uri: rsync_uri.to_string(),
|
||||||
|
current_hash: Some(current_hash.to_string()),
|
||||||
|
object_type: infer_object_type_from_uri(rsync_uri),
|
||||||
|
present: true,
|
||||||
|
last_confirmed_session_id: session_id.to_string(),
|
||||||
|
last_confirmed_serial: serial,
|
||||||
|
last_changed_at: now_pack_time(),
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.put_rrdp_source_member_record(&record)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_rrdp_source_member_withdrawn(
|
||||||
|
store: &RocksStore,
|
||||||
|
notification_uri: &str,
|
||||||
|
session_id: &str,
|
||||||
|
serial: u64,
|
||||||
|
rsync_uri: &str,
|
||||||
|
current_hash: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let record = RrdpSourceMemberRecord {
|
||||||
|
notify_uri: notification_uri.to_string(),
|
||||||
|
rsync_uri: rsync_uri.to_string(),
|
||||||
|
current_hash,
|
||||||
|
object_type: infer_object_type_from_uri(rsync_uri),
|
||||||
|
present: false,
|
||||||
|
last_confirmed_session_id: session_id.to_string(),
|
||||||
|
last_confirmed_serial: serial,
|
||||||
|
last_changed_at: now_pack_time(),
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.put_rrdp_source_member_record(&record)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_rrdp_uri_owner_active(
|
||||||
|
store: &RocksStore,
|
||||||
|
notification_uri: &str,
|
||||||
|
session_id: &str,
|
||||||
|
serial: u64,
|
||||||
|
rsync_uri: &str,
|
||||||
|
current_hash: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let record = RrdpUriOwnerRecord {
|
||||||
|
rsync_uri: rsync_uri.to_string(),
|
||||||
|
notify_uri: notification_uri.to_string(),
|
||||||
|
current_hash: Some(current_hash.to_string()),
|
||||||
|
last_confirmed_session_id: session_id.to_string(),
|
||||||
|
last_confirmed_serial: serial,
|
||||||
|
last_changed_at: now_pack_time(),
|
||||||
|
owner_state: RrdpUriOwnerState::Active,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.put_rrdp_uri_owner_record(&record)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_rrdp_uri_owner_withdrawn(
|
||||||
|
store: &RocksStore,
|
||||||
|
notification_uri: &str,
|
||||||
|
session_id: &str,
|
||||||
|
serial: u64,
|
||||||
|
rsync_uri: &str,
|
||||||
|
current_hash: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let record = RrdpUriOwnerRecord {
|
||||||
|
rsync_uri: rsync_uri.to_string(),
|
||||||
|
notify_uri: notification_uri.to_string(),
|
||||||
|
current_hash,
|
||||||
|
last_confirmed_session_id: session_id.to_string(),
|
||||||
|
last_confirmed_serial: serial,
|
||||||
|
last_changed_at: now_pack_time(),
|
||||||
|
owner_state: RrdpUriOwnerState::Withdrawn,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.put_rrdp_uri_owner_record(&record)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_rrdp_source_record_on_success(
|
||||||
|
store: &RocksStore,
|
||||||
|
notification_uri: &str,
|
||||||
|
session_id: &str,
|
||||||
|
serial: u64,
|
||||||
|
sync_state: RrdpSourceSyncState,
|
||||||
|
last_snapshot_uri: Option<&str>,
|
||||||
|
last_snapshot_hash_hex: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let now = now_pack_time();
|
||||||
|
let mut record = match store
|
||||||
|
.get_rrdp_source_record(notification_uri)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
{
|
||||||
|
Some(existing) => existing,
|
||||||
|
None => RrdpSourceRecord {
|
||||||
|
notify_uri: notification_uri.to_string(),
|
||||||
|
last_session_id: None,
|
||||||
|
last_serial: None,
|
||||||
|
first_seen_at: now.clone(),
|
||||||
|
last_seen_at: now.clone(),
|
||||||
|
last_sync_at: None,
|
||||||
|
sync_state,
|
||||||
|
last_snapshot_uri: None,
|
||||||
|
last_snapshot_hash: None,
|
||||||
|
last_error: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
record.last_session_id = Some(session_id.to_string());
|
||||||
|
record.last_serial = Some(serial);
|
||||||
|
record.last_seen_at = now.clone();
|
||||||
|
record.last_sync_at = Some(now);
|
||||||
|
record.sync_state = sync_state;
|
||||||
|
record.last_snapshot_uri = last_snapshot_uri.map(str::to_string);
|
||||||
|
record.last_snapshot_hash = last_snapshot_hash_hex.map(str::to_string);
|
||||||
|
record.last_error = None;
|
||||||
|
|
||||||
|
store
|
||||||
|
.put_rrdp_source_record(&record)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_sha256_hex(bytes: &[u8]) -> String {
|
||||||
|
hex::encode(sha2::Sha256::digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_pack_time() -> PackTime {
|
||||||
|
PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc())
|
||||||
|
}
|
||||||
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 })
|
||||||
|
}
|
||||||
1253
src/validation/manifest.rs
Normal file
1253
src/validation/manifest.rs
Normal file
File diff suppressed because it is too large
Load Diff
12
src/validation/mod.rs
Normal file
12
src/validation/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
pub mod ca_instance;
|
||||||
|
pub mod ca_path;
|
||||||
|
pub mod cert_path;
|
||||||
|
pub mod from_tal;
|
||||||
|
pub mod manifest;
|
||||||
|
pub mod objects;
|
||||||
|
pub mod publication_point;
|
||||||
|
pub mod run;
|
||||||
|
pub mod run_tree_from_tal;
|
||||||
|
pub mod tree;
|
||||||
|
pub mod tree_runner;
|
||||||
|
pub mod x509_name;
|
||||||
1675
src/validation/objects.rs
Normal file
1675
src/validation/objects.rs
Normal file
File diff suppressed because it is too large
Load Diff
18
src/validation/publication_point.rs
Normal file
18
src/validation/publication_point.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use crate::storage::{PackFile, PackTime};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PublicationPointSnapshot {
|
||||||
|
pub format_version: u32,
|
||||||
|
pub manifest_rsync_uri: String,
|
||||||
|
pub publication_point_rsync_uri: String,
|
||||||
|
pub manifest_number_be: Vec<u8>,
|
||||||
|
pub this_update: PackTime,
|
||||||
|
pub next_update: PackTime,
|
||||||
|
pub verified_at: PackTime,
|
||||||
|
pub manifest_bytes: Vec<u8>,
|
||||||
|
pub files: Vec<PackFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PublicationPointSnapshot {
|
||||||
|
pub const FORMAT_VERSION_V1: u32 = 1;
|
||||||
|
}
|
||||||
85
src/validation/run.rs
Normal file
85
src/validation/run.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
use crate::data_model::rc::{AsResourceSet, IpResourceSet};
|
||||||
|
use crate::fetch::rsync::RsyncFetcher;
|
||||||
|
use crate::policy::Policy;
|
||||||
|
use crate::report::Warning;
|
||||||
|
use crate::storage::RocksStore;
|
||||||
|
use crate::sync::rrdp::Fetcher as HttpFetcher;
|
||||||
|
use crate::validation::manifest::PublicationPointSource;
|
||||||
|
use crate::validation::objects::ObjectsOutput;
|
||||||
|
use crate::validation::tree::{CaInstanceHandle, PublicationPointRunner};
|
||||||
|
use crate::validation::tree_runner::Rpkiv1PublicationPointRunner;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RunOutput {
|
||||||
|
pub publication_point_source: PublicationPointSource,
|
||||||
|
pub publication_point_warnings: Vec<Warning>,
|
||||||
|
pub objects: ObjectsOutput,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum RunError {
|
||||||
|
#[error("publication point runner failed: {0}")]
|
||||||
|
Runner(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offline-friendly end-to-end execution for a single publication point.
|
||||||
|
///
|
||||||
|
/// This reuses the same fresh-first runtime path as the tree runner:
|
||||||
|
/// 1) repository sync (RRDP or rsync fallback) into the current repository view
|
||||||
|
/// 2) manifest processing into a validated publication point result
|
||||||
|
/// 3) signed object processing (ROA/ASPA) and latest-VCIR persistence
|
||||||
|
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 handle = CaInstanceHandle {
|
||||||
|
depth: 0,
|
||||||
|
tal_id: "single-publication-point".to_string(),
|
||||||
|
parent_manifest_rsync_uri: None,
|
||||||
|
ca_certificate_der: issuer_ca_der.to_vec(),
|
||||||
|
ca_certificate_rsync_uri: issuer_ca_rsync_uri.map(str::to_string),
|
||||||
|
effective_ip_resources: issuer_effective_ip.cloned(),
|
||||||
|
effective_as_resources: issuer_effective_as.cloned(),
|
||||||
|
rsync_base_uri: rsync_base_uri.to_string(),
|
||||||
|
manifest_rsync_uri: manifest_rsync_uri.to_string(),
|
||||||
|
publication_point_rsync_uri: publication_point_rsync_uri.to_string(),
|
||||||
|
rrdp_notification_uri: rrdp_notification_uri.map(str::to_string),
|
||||||
|
};
|
||||||
|
|
||||||
|
let runner = Rpkiv1PublicationPointRunner {
|
||||||
|
store,
|
||||||
|
policy,
|
||||||
|
http_fetcher,
|
||||||
|
rsync_fetcher,
|
||||||
|
validation_time,
|
||||||
|
timing: None,
|
||||||
|
download_log: None,
|
||||||
|
rrdp_dedup: false,
|
||||||
|
rrdp_repo_cache: Mutex::new(HashMap::new()),
|
||||||
|
rsync_dedup: false,
|
||||||
|
rsync_repo_cache: Mutex::new(HashMap::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = runner
|
||||||
|
.run_publication_point(&handle)
|
||||||
|
.map_err(RunError::Runner)?;
|
||||||
|
|
||||||
|
Ok(RunOutput {
|
||||||
|
publication_point_source: result.source,
|
||||||
|
publication_point_warnings: result.warnings,
|
||||||
|
objects: result.objects,
|
||||||
|
})
|
||||||
|
}
|
||||||
383
src/validation/run_tree_from_tal.rs
Normal file
383
src/validation/run_tree_from_tal.rs
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
fn tal_id_from_url_like(s: &str) -> Option<String> {
|
||||||
|
let url = Url::parse(s).ok()?;
|
||||||
|
if let Some(last) = url
|
||||||
|
.path_segments()
|
||||||
|
.and_then(|segments| segments.filter(|seg| !seg.is_empty()).next_back())
|
||||||
|
{
|
||||||
|
let stem = last.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(last);
|
||||||
|
let trimmed = stem.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url.host_str().map(|host| host.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_tal_id(discovery: &DiscoveredRootCaInstance) -> String {
|
||||||
|
discovery
|
||||||
|
.tal_url
|
||||||
|
.as_deref()
|
||||||
|
.and_then(tal_id_from_url_like)
|
||||||
|
.or_else(|| {
|
||||||
|
discovery
|
||||||
|
.trust_anchor
|
||||||
|
.resolved_ta_uri
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|uri| tal_id_from_url_like(uri.as_str()))
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
discovery
|
||||||
|
.trust_anchor
|
||||||
|
.tal
|
||||||
|
.ta_uris
|
||||||
|
.first()
|
||||||
|
.and_then(|uri| tal_id_from_url_like(uri.as_str()))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown-tal".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
tal_id: String,
|
||||||
|
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,
|
||||||
|
tal_id,
|
||||||
|
parent_manifest_rsync_uri: None,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
derive_tal_id(&discovery),
|
||||||
|
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()),
|
||||||
|
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,
|
||||||
|
derive_tal_id(&discovery),
|
||||||
|
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()),
|
||||||
|
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,
|
||||||
|
derive_tal_id(&discovery),
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
derive_tal_id(&discovery),
|
||||||
|
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()),
|
||||||
|
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,
|
||||||
|
derive_tal_id(&discovery),
|
||||||
|
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()),
|
||||||
|
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,
|
||||||
|
derive_tal_id(&discovery),
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
219
src/validation/tree.rs
Normal file
219
src/validation/tree.rs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
use crate::audit::DiscoveredFrom;
|
||||||
|
use crate::audit::PublicationPointAudit;
|
||||||
|
use crate::data_model::rc::{AsResourceSet, IpResourceSet};
|
||||||
|
use crate::report::Warning;
|
||||||
|
use crate::validation::manifest::PublicationPointSource;
|
||||||
|
use crate::validation::objects::{AspaAttestation, ObjectsOutput, Vrp};
|
||||||
|
use crate::validation::publication_point::PublicationPointSnapshot;
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TreeRunConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_depth: None,
|
||||||
|
max_instances: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct CaInstanceHandle {
|
||||||
|
pub depth: usize,
|
||||||
|
pub tal_id: String,
|
||||||
|
pub parent_manifest_rsync_uri: Option<String>,
|
||||||
|
/// 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 snapshot: Option<PublicationPointSnapshot>,
|
||||||
|
pub warnings: Vec<Warning>,
|
||||||
|
pub objects: ObjectsOutput,
|
||||||
|
pub audit: PublicationPointAudit,
|
||||||
|
/// Candidate child CA instances to enqueue after this publication point completes.
|
||||||
|
///
|
||||||
|
/// - For `Fresh`, these are discovered from the current validated publication point.
|
||||||
|
/// - For `VcirCurrentInstance`, these are restored from the current instance VCIR and then
|
||||||
|
/// processed fresh-first when their own turn arrives.
|
||||||
|
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 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
4154
src/validation/tree_runner.rs
Normal file
4154
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::PackFile;
|
||||||
|
use rpki::storage::RocksStore;
|
||||||
|
|
||||||
|
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user