Compare commits
1 Commits
cf764c35bb
...
dev_1.0_xu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
767923cea7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,2 @@
|
|||||||
target/
|
target/
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
perf.*
|
|
||||||
|
|||||||
25
Cargo.toml
25
Cargo.toml
@@ -3,33 +3,10 @@ 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]
|
||||||
asn1-rs = "0.7.1"
|
der-parser = "10.0.0"
|
||||||
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"
|
||||||
serde = { version = "1.0.218", features = ["derive"] }
|
|
||||||
serde_json = "1.0.140"
|
|
||||||
toml = "0.8.20"
|
|
||||||
rocksdb = { version = "0.22.0", optional = true, default-features = false, features = ["lz4"] }
|
|
||||||
serde_cbor = "0.11.2"
|
|
||||||
roxmltree = "0.20.0"
|
|
||||||
uuid = { version = "1.7.0", features = ["v4"] }
|
|
||||||
reqwest = { version = "0.12.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
|
||||||
pprof = { version = "0.14.1", optional = true, features = ["flamegraph", "prost-codec"] }
|
|
||||||
flate2 = { version = "1.0.35", optional = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3.16.0"
|
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -9,20 +9,3 @@ 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
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "ours-manifest-bench"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
rpki = { path = "../..", default-features = false }
|
|
||||||
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
use rpki::data_model::manifest::ManifestObject;
|
|
||||||
use std::hint::black_box;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct Config {
|
|
||||||
sample: Option<String>,
|
|
||||||
manifest_path: Option<PathBuf>,
|
|
||||||
iterations: u64,
|
|
||||||
warmup_iterations: u64,
|
|
||||||
repeats: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn usage_and_exit() -> ! {
|
|
||||||
eprintln!(
|
|
||||||
"Usage:\n ours-manifest-bench (--sample <name> | --manifest <path>) [--iterations N] [--warmup-iterations N] [--repeats N]\n\nExamples:\n cargo run --release -- --sample small-01 --iterations 20000 --warmup-iterations 2000 --repeats 3\n cargo run --release -- --manifest ../../tests/benchmark/selected_der/small-01.mft"
|
|
||||||
);
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_args() -> Config {
|
|
||||||
let mut sample: Option<String> = None;
|
|
||||||
let mut manifest_path: Option<PathBuf> = None;
|
|
||||||
let mut iterations: u64 = 20_000;
|
|
||||||
let mut warmup_iterations: u64 = 2_000;
|
|
||||||
let mut repeats: u32 = 3;
|
|
||||||
|
|
||||||
let mut args = std::env::args().skip(1);
|
|
||||||
while let Some(arg) = args.next() {
|
|
||||||
match arg.as_str() {
|
|
||||||
"--sample" => sample = Some(args.next().unwrap_or_else(|| usage_and_exit())),
|
|
||||||
"--manifest" => {
|
|
||||||
manifest_path = Some(PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit())))
|
|
||||||
}
|
|
||||||
"--iterations" => {
|
|
||||||
iterations = args
|
|
||||||
.next()
|
|
||||||
.unwrap_or_else(|| usage_and_exit())
|
|
||||||
.parse()
|
|
||||||
.unwrap_or_else(|_| usage_and_exit())
|
|
||||||
}
|
|
||||||
"--warmup-iterations" => {
|
|
||||||
warmup_iterations = args
|
|
||||||
.next()
|
|
||||||
.unwrap_or_else(|| usage_and_exit())
|
|
||||||
.parse()
|
|
||||||
.unwrap_or_else(|_| usage_and_exit())
|
|
||||||
}
|
|
||||||
"--repeats" => {
|
|
||||||
repeats = args
|
|
||||||
.next()
|
|
||||||
.unwrap_or_else(|| usage_and_exit())
|
|
||||||
.parse()
|
|
||||||
.unwrap_or_else(|_| usage_and_exit())
|
|
||||||
}
|
|
||||||
"-h" | "--help" => usage_and_exit(),
|
|
||||||
_ => usage_and_exit(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sample.is_none() && manifest_path.is_none() {
|
|
||||||
usage_and_exit();
|
|
||||||
}
|
|
||||||
if sample.is_some() && manifest_path.is_some() {
|
|
||||||
usage_and_exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
Config {
|
|
||||||
sample,
|
|
||||||
manifest_path,
|
|
||||||
iterations,
|
|
||||||
warmup_iterations,
|
|
||||||
repeats,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn derive_manifest_path(sample: &str) -> PathBuf {
|
|
||||||
// Assumes current working directory is `rpki/benchmark/ours_manifest_bench`.
|
|
||||||
PathBuf::from(format!("../../tests/benchmark/selected_der/{sample}.mft"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let cfg = parse_args();
|
|
||||||
let manifest_path = cfg
|
|
||||||
.manifest_path
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| derive_manifest_path(cfg.sample.as_deref().unwrap()));
|
|
||||||
|
|
||||||
let bytes = std::fs::read(&manifest_path).unwrap_or_else(|e| {
|
|
||||||
eprintln!("read manifest fixture failed: {e}; path={}", manifest_path.display());
|
|
||||||
std::process::exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
let decoded_once = ManifestObject::decode_der(&bytes).unwrap_or_else(|e| {
|
|
||||||
eprintln!("decode failed: {e}; path={}", manifest_path.display());
|
|
||||||
std::process::exit(1);
|
|
||||||
});
|
|
||||||
let file_count = decoded_once.manifest.file_count();
|
|
||||||
|
|
||||||
let mut round_ns_per_op: Vec<f64> = Vec::with_capacity(cfg.repeats as usize);
|
|
||||||
let mut round_ops_per_s: Vec<f64> = Vec::with_capacity(cfg.repeats as usize);
|
|
||||||
|
|
||||||
for _round in 0..cfg.repeats {
|
|
||||||
for _ in 0..cfg.warmup_iterations {
|
|
||||||
let obj = ManifestObject::decode_der(black_box(&bytes)).expect("warmup decode");
|
|
||||||
black_box(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
let start = Instant::now();
|
|
||||||
for _ in 0..cfg.iterations {
|
|
||||||
let obj = ManifestObject::decode_der(black_box(&bytes)).expect("timed decode");
|
|
||||||
black_box(obj);
|
|
||||||
}
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
|
|
||||||
let ns_per_op = (elapsed.as_secs_f64() * 1e9) / (cfg.iterations as f64);
|
|
||||||
let ops_per_s = (cfg.iterations as f64) / elapsed.as_secs_f64();
|
|
||||||
round_ns_per_op.push(ns_per_op);
|
|
||||||
round_ops_per_s.push(ops_per_s);
|
|
||||||
}
|
|
||||||
|
|
||||||
let avg_ns_per_op = round_ns_per_op.iter().sum::<f64>() / (round_ns_per_op.len() as f64);
|
|
||||||
let avg_ops_per_s = round_ops_per_s.iter().sum::<f64>() / (round_ops_per_s.len() as f64);
|
|
||||||
|
|
||||||
let sample_name = cfg.sample.clone().unwrap_or_else(|| {
|
|
||||||
manifest_path
|
|
||||||
.file_name()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| manifest_path.display().to_string())
|
|
||||||
});
|
|
||||||
let sample_name = sample_name
|
|
||||||
.strip_suffix(".mft")
|
|
||||||
.unwrap_or(&sample_name)
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
println!("fixture: {}", manifest_path.display());
|
|
||||||
println!();
|
|
||||||
println!("| sample | avg ns/op | ops/s | file count |");
|
|
||||||
println!("|---|---:|---:|---:|");
|
|
||||||
println!(
|
|
||||||
"| {} | {:.2} | {:.2} | {} |",
|
|
||||||
sample_name, avg_ns_per_op, avg_ops_per_s, file_count
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "routinator-object-bench"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
publish = false
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
rpki = { version = "=0.19.1", features = ["repository"] }
|
|
||||||
@@ -1,552 +0,0 @@
|
|||||||
use rpki::repository::cert::Cert;
|
|
||||||
use rpki::repository::crl::Crl;
|
|
||||||
use rpki::repository::manifest::Manifest;
|
|
||||||
use rpki::repository::roa::Roa;
|
|
||||||
use rpki::repository::aspa::Aspa;
|
|
||||||
use rpki::repository::resources::{AsResources, IpResources};
|
|
||||||
|
|
||||||
use std::hint::black_box;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
enum ObjType {
|
|
||||||
Cer,
|
|
||||||
Crl,
|
|
||||||
Manifest,
|
|
||||||
Roa,
|
|
||||||
Aspa,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ObjType {
|
|
||||||
fn parse(s: &str) -> Result<Self, String> {
|
|
||||||
match s {
|
|
||||||
"cer" => Ok(Self::Cer),
|
|
||||||
"crl" => Ok(Self::Crl),
|
|
||||||
"manifest" => Ok(Self::Manifest),
|
|
||||||
"roa" => Ok(Self::Roa),
|
|
||||||
"aspa" => Ok(Self::Aspa),
|
|
||||||
_ => Err("type must be one of: cer, crl, manifest, roa, aspa".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
ObjType::Cer => "cer",
|
|
||||||
ObjType::Crl => "crl",
|
|
||||||
ObjType::Manifest => "manifest",
|
|
||||||
ObjType::Roa => "roa",
|
|
||||||
ObjType::Aspa => "aspa",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ext(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
ObjType::Cer => "cer",
|
|
||||||
ObjType::Crl => "crl",
|
|
||||||
ObjType::Manifest => "mft",
|
|
||||||
ObjType::Roa => "roa",
|
|
||||||
ObjType::Aspa => "asa",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct Sample {
|
|
||||||
obj_type: ObjType,
|
|
||||||
name: String,
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct Config {
|
|
||||||
dir: PathBuf,
|
|
||||||
type_filter: Option<ObjType>,
|
|
||||||
sample_filter: Option<String>,
|
|
||||||
fixed_iters: Option<u64>,
|
|
||||||
warmup_iters: u64,
|
|
||||||
rounds: u64,
|
|
||||||
min_round_ms: u64,
|
|
||||||
max_adaptive_iters: u64,
|
|
||||||
strict: bool,
|
|
||||||
cert_inspect: bool,
|
|
||||||
out_csv: Option<PathBuf>,
|
|
||||||
out_md: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn usage_and_exit(err: Option<&str>) -> ! {
|
|
||||||
if let Some(err) = err {
|
|
||||||
eprintln!("error: {err}");
|
|
||||||
eprintln!();
|
|
||||||
}
|
|
||||||
eprintln!(
|
|
||||||
"Usage:\n\
|
|
||||||
cargo run --release --manifest-path rpki/benchmark/routinator_object_bench/Cargo.toml -- [OPTIONS]\n\
|
|
||||||
\n\
|
|
||||||
Options:\n\
|
|
||||||
--dir <PATH> Fixtures root dir (default: ../../tests/benchmark/selected_der_v2)\n\
|
|
||||||
--type <cer|crl|manifest|roa|aspa> Filter by type\n\
|
|
||||||
--sample <NAME> Filter by sample name (e.g. p50)\n\
|
|
||||||
--iters <N> Fixed iterations per round (optional; otherwise adaptive)\n\
|
|
||||||
--warmup-iters <N> Warmup iterations (default: 50)\n\
|
|
||||||
--rounds <N> Rounds (default: 5)\n\
|
|
||||||
--min-round-ms <MS> Adaptive: minimum round time (default: 200)\n\
|
|
||||||
--max-iters <N> Adaptive: maximum iters (default: 1_000_000)\n\
|
|
||||||
--strict <true|false> Strict DER where applicable (default: true)\n\
|
|
||||||
--cert-inspect Also run Cert::inspect_ca/inspect_ee where applicable (default: false)\n\
|
|
||||||
--out-csv <PATH> Write CSV output\n\
|
|
||||||
--out-md <PATH> Write Markdown output\n\
|
|
||||||
"
|
|
||||||
);
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_bool(s: &str, name: &str) -> bool {
|
|
||||||
match s {
|
|
||||||
"1" | "true" | "TRUE" | "yes" | "YES" => true,
|
|
||||||
"0" | "false" | "FALSE" | "no" | "NO" => false,
|
|
||||||
_ => usage_and_exit(Some(&format!("{name} must be true/false"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_u64(s: &str, name: &str) -> u64 {
|
|
||||||
s.parse::<u64>()
|
|
||||||
.unwrap_or_else(|_| usage_and_exit(Some(&format!("{name} must be an integer"))))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_samples_dir() -> PathBuf {
|
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/benchmark/selected_der_v2")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_args() -> Config {
|
|
||||||
let mut dir: PathBuf = default_samples_dir();
|
|
||||||
let mut type_filter: Option<ObjType> = None;
|
|
||||||
let mut sample_filter: Option<String> = None;
|
|
||||||
let mut fixed_iters: Option<u64> = None;
|
|
||||||
let mut warmup_iters: u64 = 50;
|
|
||||||
let mut rounds: u64 = 5;
|
|
||||||
let mut min_round_ms: u64 = 200;
|
|
||||||
let mut max_adaptive_iters: u64 = 1_000_000;
|
|
||||||
let mut strict: bool = true;
|
|
||||||
let mut cert_inspect: bool = false;
|
|
||||||
let mut out_csv: Option<PathBuf> = None;
|
|
||||||
let mut out_md: Option<PathBuf> = None;
|
|
||||||
|
|
||||||
let mut args = std::env::args().skip(1);
|
|
||||||
while let Some(arg) = args.next() {
|
|
||||||
match arg.as_str() {
|
|
||||||
"--dir" => dir = PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit(None))),
|
|
||||||
"--type" => {
|
|
||||||
type_filter = Some(ObjType::parse(
|
|
||||||
&args.next().unwrap_or_else(|| usage_and_exit(None)),
|
|
||||||
)
|
|
||||||
.unwrap_or_else(|e| usage_and_exit(Some(&e))))
|
|
||||||
}
|
|
||||||
"--sample" => {
|
|
||||||
sample_filter = Some(args.next().unwrap_or_else(|| usage_and_exit(None)))
|
|
||||||
}
|
|
||||||
"--iters" => {
|
|
||||||
fixed_iters = Some(parse_u64(
|
|
||||||
&args.next().unwrap_or_else(|| usage_and_exit(None)),
|
|
||||||
"--iters",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
"--warmup-iters" => {
|
|
||||||
warmup_iters = parse_u64(
|
|
||||||
&args.next().unwrap_or_else(|| usage_and_exit(None)),
|
|
||||||
"--warmup-iters",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"--rounds" => {
|
|
||||||
rounds = parse_u64(&args.next().unwrap_or_else(|| usage_and_exit(None)), "--rounds")
|
|
||||||
}
|
|
||||||
"--min-round-ms" => {
|
|
||||||
min_round_ms = parse_u64(
|
|
||||||
&args.next().unwrap_or_else(|| usage_and_exit(None)),
|
|
||||||
"--min-round-ms",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"--max-iters" => {
|
|
||||||
max_adaptive_iters = parse_u64(
|
|
||||||
&args.next().unwrap_or_else(|| usage_and_exit(None)),
|
|
||||||
"--max-iters",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"--strict" => {
|
|
||||||
strict = parse_bool(
|
|
||||||
&args.next().unwrap_or_else(|| usage_and_exit(None)),
|
|
||||||
"--strict",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"--cert-inspect" => cert_inspect = true,
|
|
||||||
"--out-csv" => out_csv = Some(PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit(None)))),
|
|
||||||
"--out-md" => out_md = Some(PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit(None)))),
|
|
||||||
"-h" | "--help" => usage_and_exit(None),
|
|
||||||
_ => usage_and_exit(Some(&format!("unknown argument: {arg}"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if warmup_iters == 0 {
|
|
||||||
usage_and_exit(Some("--warmup-iters must be > 0"));
|
|
||||||
}
|
|
||||||
if rounds == 0 {
|
|
||||||
usage_and_exit(Some("--rounds must be > 0"));
|
|
||||||
}
|
|
||||||
if min_round_ms == 0 {
|
|
||||||
usage_and_exit(Some("--min-round-ms must be > 0"));
|
|
||||||
}
|
|
||||||
if max_adaptive_iters == 0 {
|
|
||||||
usage_and_exit(Some("--max-iters must be > 0"));
|
|
||||||
}
|
|
||||||
if let Some(n) = fixed_iters {
|
|
||||||
if n == 0 {
|
|
||||||
usage_and_exit(Some("--iters must be > 0"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Config {
|
|
||||||
dir,
|
|
||||||
type_filter,
|
|
||||||
sample_filter,
|
|
||||||
fixed_iters,
|
|
||||||
warmup_iters,
|
|
||||||
rounds,
|
|
||||||
min_round_ms,
|
|
||||||
max_adaptive_iters,
|
|
||||||
strict,
|
|
||||||
cert_inspect,
|
|
||||||
out_csv,
|
|
||||||
out_md,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_samples(root: &Path) -> Vec<Sample> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for obj_type in [
|
|
||||||
ObjType::Cer,
|
|
||||||
ObjType::Crl,
|
|
||||||
ObjType::Manifest,
|
|
||||||
ObjType::Roa,
|
|
||||||
ObjType::Aspa,
|
|
||||||
] {
|
|
||||||
let dir = root.join(obj_type.as_str());
|
|
||||||
let rd = match std::fs::read_dir(&dir) {
|
|
||||||
Ok(rd) => rd,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
for ent in rd.flatten() {
|
|
||||||
let path = ent.path();
|
|
||||||
if path.extension().and_then(|s| s.to_str()) != Some(obj_type.ext()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
out.push(Sample { obj_type, name, path });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.sort_by(|a, b| a.obj_type.cmp(&b.obj_type).then_with(|| a.name.cmp(&b.name)));
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn choose_iters_adaptive<F: FnMut()>(mut op: F, min_round_ms: u64, max_iters: u64) -> u64 {
|
|
||||||
let min_secs = (min_round_ms as f64) / 1e3;
|
|
||||||
let mut iters: u64 = 1;
|
|
||||||
loop {
|
|
||||||
let start = Instant::now();
|
|
||||||
for _ in 0..iters {
|
|
||||||
op();
|
|
||||||
}
|
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
|
||||||
if elapsed >= min_secs {
|
|
||||||
return iters;
|
|
||||||
}
|
|
||||||
if iters >= max_iters {
|
|
||||||
return iters;
|
|
||||||
}
|
|
||||||
iters = (iters.saturating_mul(2)).min(max_iters);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn count_ip(res: &IpResources) -> u64 {
|
|
||||||
if res.is_inherited() {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
let Ok(blocks) = res.to_blocks() else {
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
blocks.iter().count() as u64
|
|
||||||
}
|
|
||||||
|
|
||||||
fn count_as(res: &AsResources) -> u64 {
|
|
||||||
if res.is_inherited() {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
let Ok(blocks) = res.to_blocks() else {
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
blocks.iter().count() as u64
|
|
||||||
}
|
|
||||||
|
|
||||||
fn complexity(obj_type: ObjType, bytes: &[u8], strict: bool, cert_inspect: bool) -> u64 {
|
|
||||||
match obj_type {
|
|
||||||
ObjType::Cer => {
|
|
||||||
let cert = Cert::decode(bytes).expect("decode cert");
|
|
||||||
if cert_inspect {
|
|
||||||
if cert.is_ca() {
|
|
||||||
cert.inspect_ca(strict).expect("inspect ca");
|
|
||||||
} else {
|
|
||||||
cert.inspect_ee(strict).expect("inspect ee");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
count_ip(cert.v4_resources())
|
|
||||||
.saturating_add(count_ip(cert.v6_resources()))
|
|
||||||
.saturating_add(count_as(cert.as_resources()))
|
|
||||||
}
|
|
||||||
ObjType::Crl => {
|
|
||||||
let crl = Crl::decode(bytes).expect("decode crl");
|
|
||||||
crl.revoked_certs().iter().count() as u64
|
|
||||||
}
|
|
||||||
ObjType::Manifest => {
|
|
||||||
let mft = Manifest::decode(bytes, strict).expect("decode manifest");
|
|
||||||
if cert_inspect {
|
|
||||||
mft.cert().inspect_ee(strict).expect("inspect ee");
|
|
||||||
}
|
|
||||||
mft.content().len() as u64
|
|
||||||
}
|
|
||||||
ObjType::Roa => {
|
|
||||||
let roa = Roa::decode(bytes, strict).expect("decode roa");
|
|
||||||
if cert_inspect {
|
|
||||||
roa.cert().inspect_ee(strict).expect("inspect ee");
|
|
||||||
}
|
|
||||||
roa.content().iter().count() as u64
|
|
||||||
}
|
|
||||||
ObjType::Aspa => {
|
|
||||||
let asa = Aspa::decode(bytes, strict).expect("decode aspa");
|
|
||||||
if cert_inspect {
|
|
||||||
asa.cert().inspect_ee(strict).expect("inspect ee");
|
|
||||||
}
|
|
||||||
asa.content().provider_as_set().len() as u64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_profile(obj_type: ObjType, bytes: &[u8], strict: bool, cert_inspect: bool) {
|
|
||||||
match obj_type {
|
|
||||||
ObjType::Cer => {
|
|
||||||
let cert = Cert::decode(black_box(bytes)).expect("decode cert");
|
|
||||||
if cert_inspect {
|
|
||||||
if cert.is_ca() {
|
|
||||||
cert.inspect_ca(strict).expect("inspect ca");
|
|
||||||
} else {
|
|
||||||
cert.inspect_ee(strict).expect("inspect ee");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
black_box(cert);
|
|
||||||
}
|
|
||||||
ObjType::Crl => {
|
|
||||||
let crl = Crl::decode(black_box(bytes)).expect("decode crl");
|
|
||||||
black_box(crl);
|
|
||||||
}
|
|
||||||
ObjType::Manifest => {
|
|
||||||
let mft = Manifest::decode(black_box(bytes), strict).expect("decode manifest");
|
|
||||||
if cert_inspect {
|
|
||||||
mft.cert().inspect_ee(strict).expect("inspect ee");
|
|
||||||
}
|
|
||||||
black_box(mft);
|
|
||||||
}
|
|
||||||
ObjType::Roa => {
|
|
||||||
let roa = Roa::decode(black_box(bytes), strict).expect("decode roa");
|
|
||||||
if cert_inspect {
|
|
||||||
roa.cert().inspect_ee(strict).expect("inspect ee");
|
|
||||||
}
|
|
||||||
black_box(roa);
|
|
||||||
}
|
|
||||||
ObjType::Aspa => {
|
|
||||||
let asa = Aspa::decode(black_box(bytes), strict).expect("decode aspa");
|
|
||||||
if cert_inspect {
|
|
||||||
asa.cert().inspect_ee(strict).expect("inspect ee");
|
|
||||||
}
|
|
||||||
black_box(asa);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct ResultRow {
|
|
||||||
obj_type: String,
|
|
||||||
sample: String,
|
|
||||||
size_bytes: usize,
|
|
||||||
complexity: u64,
|
|
||||||
avg_ns_per_op: f64,
|
|
||||||
ops_per_sec: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_markdown(title: &str, rows: &[ResultRow]) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
out.push_str(&format!("# {title}\n\n"));
|
|
||||||
out.push_str("| type | sample | size_bytes | complexity | avg ns/op | ops/s |\n");
|
|
||||||
out.push_str("|---|---|---:|---:|---:|---:|\n");
|
|
||||||
for r in rows {
|
|
||||||
out.push_str(&format!(
|
|
||||||
"| {} | {} | {} | {} | {:.2} | {:.2} |\n",
|
|
||||||
r.obj_type, r.sample, r.size_bytes, r.complexity, r.avg_ns_per_op, r.ops_per_sec
|
|
||||||
));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_csv(rows: &[ResultRow]) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
out.push_str("type,sample,size_bytes,complexity,avg_ns_per_op,ops_per_sec\n");
|
|
||||||
for r in rows {
|
|
||||||
let sample = r.sample.replace('"', "\"\"");
|
|
||||||
out.push_str(&format!(
|
|
||||||
"{},{},{},{},{:.6},{:.6}\n",
|
|
||||||
r.obj_type,
|
|
||||||
format!("\"{}\"", sample),
|
|
||||||
r.size_bytes,
|
|
||||||
r.complexity,
|
|
||||||
r.avg_ns_per_op,
|
|
||||||
r.ops_per_sec
|
|
||||||
));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_parent_dirs(path: &Path) {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
|
|
||||||
panic!("create_dir_all {}: {e}", parent.display());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_text_file(path: &Path, content: &str) {
|
|
||||||
create_parent_dirs(path);
|
|
||||||
std::fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let cfg = parse_args();
|
|
||||||
let mut samples = read_samples(&cfg.dir);
|
|
||||||
if samples.is_empty() {
|
|
||||||
usage_and_exit(Some(&format!(
|
|
||||||
"no samples found under: {}",
|
|
||||||
cfg.dir.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(t) = cfg.type_filter {
|
|
||||||
samples.retain(|s| s.obj_type == t);
|
|
||||||
if samples.is_empty() {
|
|
||||||
usage_and_exit(Some(&format!("no sample matched --type {}", t.as_str())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(filter) = cfg.sample_filter.as_deref() {
|
|
||||||
samples.retain(|s| s.name == filter);
|
|
||||||
if samples.is_empty() {
|
|
||||||
usage_and_exit(Some(&format!("no sample matched --sample {filter}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("# Routinator baseline (rpki crate) decode benchmark (selected_der_v2)");
|
|
||||||
println!();
|
|
||||||
println!("- dir: {}", cfg.dir.display());
|
|
||||||
println!("- strict: {}", cfg.strict);
|
|
||||||
println!("- cert_inspect: {}", cfg.cert_inspect);
|
|
||||||
if let Some(t) = cfg.type_filter {
|
|
||||||
println!("- type: {}", t.as_str());
|
|
||||||
}
|
|
||||||
if let Some(s) = cfg.sample_filter.as_deref() {
|
|
||||||
println!("- sample: {}", s);
|
|
||||||
}
|
|
||||||
if let Some(n) = cfg.fixed_iters {
|
|
||||||
println!("- iters: {} (fixed)", n);
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"- warmup: {} iters, rounds: {}, min_round: {}ms (adaptive iters, max {})",
|
|
||||||
cfg.warmup_iters, cfg.rounds, cfg.min_round_ms, cfg.max_adaptive_iters
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(p) = cfg.out_csv.as_ref() {
|
|
||||||
println!("- out_csv: {}", p.display());
|
|
||||||
}
|
|
||||||
if let Some(p) = cfg.out_md.as_ref() {
|
|
||||||
println!("- out_md: {}", p.display());
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
println!("| type | sample | size_bytes | complexity | avg ns/op | ops/s |");
|
|
||||||
println!("|---|---|---:|---:|---:|---:|");
|
|
||||||
|
|
||||||
let mut rows: Vec<ResultRow> = Vec::with_capacity(samples.len());
|
|
||||||
for sample in &samples {
|
|
||||||
let bytes = std::fs::read(&sample.path)
|
|
||||||
.unwrap_or_else(|e| panic!("read {}: {e}", sample.path.display()));
|
|
||||||
let size_bytes = bytes.len();
|
|
||||||
let complexity = complexity(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect);
|
|
||||||
|
|
||||||
for _ in 0..cfg.warmup_iters {
|
|
||||||
decode_profile(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut per_round_ns_per_op = Vec::with_capacity(cfg.rounds as usize);
|
|
||||||
for _round in 0..cfg.rounds {
|
|
||||||
let iters = if let Some(n) = cfg.fixed_iters {
|
|
||||||
n
|
|
||||||
} else {
|
|
||||||
choose_iters_adaptive(
|
|
||||||
|| decode_profile(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect),
|
|
||||||
cfg.min_round_ms,
|
|
||||||
cfg.max_adaptive_iters,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let start = Instant::now();
|
|
||||||
for _ in 0..iters {
|
|
||||||
decode_profile(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect);
|
|
||||||
}
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
let total_ns = elapsed.as_secs_f64() * 1e9;
|
|
||||||
per_round_ns_per_op.push(total_ns / (iters as f64));
|
|
||||||
}
|
|
||||||
|
|
||||||
let avg_ns = per_round_ns_per_op.iter().sum::<f64>() / (per_round_ns_per_op.len() as f64);
|
|
||||||
let ops_per_sec = 1e9_f64 / avg_ns;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"| {} | {} | {} | {} | {:.2} | {:.2} |",
|
|
||||||
sample.obj_type.as_str(),
|
|
||||||
sample.name,
|
|
||||||
size_bytes,
|
|
||||||
complexity,
|
|
||||||
avg_ns,
|
|
||||||
ops_per_sec
|
|
||||||
);
|
|
||||||
|
|
||||||
rows.push(ResultRow {
|
|
||||||
obj_type: sample.obj_type.as_str().to_string(),
|
|
||||||
sample: sample.name.clone(),
|
|
||||||
size_bytes,
|
|
||||||
complexity,
|
|
||||||
avg_ns_per_op: avg_ns,
|
|
||||||
ops_per_sec,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(path) = cfg.out_md.as_ref() {
|
|
||||||
let md = render_markdown(
|
|
||||||
"Routinator baseline (rpki crate) decode+inspect (selected_der_v2)",
|
|
||||||
&rows,
|
|
||||||
);
|
|
||||||
write_text_file(path, &md);
|
|
||||||
eprintln!("Wrote {}", path.display());
|
|
||||||
}
|
|
||||||
if let Some(path) = cfg.out_csv.as_ref() {
|
|
||||||
let csv = render_csv(&rows);
|
|
||||||
write_text_file(path, &csv);
|
|
||||||
eprintln!("Wrote {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# RPKI Benchmarks (Stage2, selected_der_v2)
|
|
||||||
|
|
||||||
This directory contains a reproducible, one-click benchmark to measure **decode + profile validate**
|
|
||||||
performance for all supported object types and compare **OURS** against the **Routinator baseline**
|
|
||||||
(`rpki` crate `=0.19.1` with `repository` feature).
|
|
||||||
|
|
||||||
## What it measures
|
|
||||||
|
|
||||||
Dataset:
|
|
||||||
|
|
||||||
- Fixtures: `rpki/tests/benchmark/selected_der_v2/`
|
|
||||||
- Objects: `cer`, `crl`, `manifest` (`.mft`), `roa`, `aspa` (`.asa`)
|
|
||||||
- Samples: 10 quantiles per type (`min/p01/p10/p25/p50/p75/p90/p95/p99/max`) → 50 files total
|
|
||||||
|
|
||||||
Metrics:
|
|
||||||
|
|
||||||
- **decode+validate**: `decode_der` (parse + profile validate) for each object file
|
|
||||||
- **landing** (OURS only): `PackFile::from_bytes_compute_sha256` + CBOR encode + `RocksDB put_raw`
|
|
||||||
- **compare**: ratio `ours_ns/op ÷ rout_ns/op` for decode+validate
|
|
||||||
|
|
||||||
## Default benchmark settings
|
|
||||||
|
|
||||||
Both OURS and Routinator baseline use the same run settings:
|
|
||||||
|
|
||||||
- warmup: `10` iterations
|
|
||||||
- rounds: `3`
|
|
||||||
- adaptive loop target: `min_round_ms=200` (with an internal max of `1_000_000` iters)
|
|
||||||
- strict DER: `true` (baseline)
|
|
||||||
- cert inspect: `false` (baseline)
|
|
||||||
|
|
||||||
You can override the settings via environment variables in the runner script:
|
|
||||||
|
|
||||||
- `BENCH_WARMUP_ITERS` (default `10`)
|
|
||||||
- `BENCH_ROUNDS` (default `3`)
|
|
||||||
- `BENCH_MIN_ROUND_MS` (default `200`)
|
|
||||||
|
|
||||||
## One-click run (OURS + Routinator compare)
|
|
||||||
|
|
||||||
From the `rpki/` crate directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/benchmark/run_stage2_selected_der_v2_release.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Outputs are written under:
|
|
||||||
|
|
||||||
- `rpki/target/bench/`
|
|
||||||
- OURS decode+validate: `stage2_selected_der_v2_decode_release_<TS>.{md,csv}`
|
|
||||||
- OURS landing: `stage2_selected_der_v2_landing_release_<TS>.{md,csv}`
|
|
||||||
- Routinator: `stage2_selected_der_v2_routinator_decode_release_<TS>.{md,csv}`
|
|
||||||
- Compare: `stage2_selected_der_v2_compare_ours_vs_routinator_decode_release_<TS>.{md,csv}`
|
|
||||||
- Summary: `stage2_selected_der_v2_compare_summary_<TS>.md`
|
|
||||||
|
|
||||||
### Why decode and landing are separated
|
|
||||||
|
|
||||||
The underlying benchmark can run in `BENCH_MODE=both`, but the **landing** part writes to RocksDB
|
|
||||||
and may trigger background work (e.g., compactions) that can **skew subsequent decode timings**.
|
|
||||||
For a fair OURS-vs-Routinator comparison, the runner script:
|
|
||||||
|
|
||||||
- runs `BENCH_MODE=decode_validate` for comparison, and
|
|
||||||
- runs `BENCH_MODE=landing` separately for landing-only numbers.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The Routinator baseline benchmark is implemented in-repo under:
|
|
||||||
- `rpki/benchmark/routinator_object_bench/`
|
|
||||||
- It pins `rpki = "=0.19.1"` in its `Cargo.toml`.
|
|
||||||
- This benchmark is implemented as an `#[ignore]` integration test:
|
|
||||||
- `rpki/tests/bench_stage2_decode_profile_selected_der_v2.rs`
|
|
||||||
- The runner script invokes it with `cargo test --release ... -- --ignored --nocapture`.
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Stage2 (selected_der_v2) decode+profile validate benchmark.
|
|
||||||
# Runs:
|
|
||||||
# 1) OURS decode+validate benchmark and writes MD/CSV.
|
|
||||||
# 2) OURS landing benchmark and writes MD/CSV.
|
|
||||||
# 3) Routinator baseline decode benchmark (rpki crate =0.19.1).
|
|
||||||
# 4) Produces a joined compare CSV/MD and a short geomean summary.
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
||||||
cd "$ROOT_DIR"
|
|
||||||
|
|
||||||
OUT_DIR="$ROOT_DIR/target/bench"
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
|
|
||||||
TS="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
||||||
|
|
||||||
WARMUP_ITERS="${BENCH_WARMUP_ITERS:-10}"
|
|
||||||
ROUNDS="${BENCH_ROUNDS:-3}"
|
|
||||||
MIN_ROUND_MS="${BENCH_MIN_ROUND_MS:-200}"
|
|
||||||
|
|
||||||
OURS_MD="$OUT_DIR/stage2_selected_der_v2_decode_release_${TS}.md"
|
|
||||||
OURS_CSV="$OUT_DIR/stage2_selected_der_v2_decode_release_${TS}.csv"
|
|
||||||
|
|
||||||
OURS_LANDING_MD="$OUT_DIR/stage2_selected_der_v2_landing_release_${TS}.md"
|
|
||||||
OURS_LANDING_CSV="$OUT_DIR/stage2_selected_der_v2_landing_release_${TS}.csv"
|
|
||||||
|
|
||||||
ROUT_MD="$OUT_DIR/stage2_selected_der_v2_routinator_decode_release_${TS}.md"
|
|
||||||
ROUT_CSV="$OUT_DIR/stage2_selected_der_v2_routinator_decode_release_${TS}.csv"
|
|
||||||
|
|
||||||
COMPARE_MD="$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release_${TS}.md"
|
|
||||||
COMPARE_CSV="$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release_${TS}.csv"
|
|
||||||
|
|
||||||
SUMMARY_MD="$OUT_DIR/stage2_selected_der_v2_compare_summary_${TS}.md"
|
|
||||||
|
|
||||||
echo "[1/4] OURS: decode+validate benchmark (release)..." >&2
|
|
||||||
BENCH_MODE="decode_validate" \
|
|
||||||
BENCH_WARMUP_ITERS="$WARMUP_ITERS" \
|
|
||||||
BENCH_ROUNDS="$ROUNDS" \
|
|
||||||
BENCH_MIN_ROUND_MS="$MIN_ROUND_MS" \
|
|
||||||
BENCH_OUT_MD="$OURS_MD" \
|
|
||||||
BENCH_OUT_CSV="$OURS_CSV" \
|
|
||||||
cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture >/dev/null
|
|
||||||
|
|
||||||
echo "[2/4] OURS: landing benchmark (release)..." >&2
|
|
||||||
BENCH_MODE="landing" \
|
|
||||||
BENCH_WARMUP_ITERS="$WARMUP_ITERS" \
|
|
||||||
BENCH_ROUNDS="$ROUNDS" \
|
|
||||||
BENCH_MIN_ROUND_MS="$MIN_ROUND_MS" \
|
|
||||||
BENCH_OUT_MD_LANDING="$OURS_LANDING_MD" \
|
|
||||||
BENCH_OUT_CSV_LANDING="$OURS_LANDING_CSV" \
|
|
||||||
cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture >/dev/null
|
|
||||||
|
|
||||||
echo "[3/4] Routinator baseline + compare join..." >&2
|
|
||||||
OURS_CSV="$OURS_CSV" \
|
|
||||||
ROUT_CSV="$ROUT_CSV" \
|
|
||||||
ROUT_MD="$ROUT_MD" \
|
|
||||||
COMPARE_CSV="$COMPARE_CSV" \
|
|
||||||
COMPARE_MD="$COMPARE_MD" \
|
|
||||||
WARMUP_ITERS="$WARMUP_ITERS" \
|
|
||||||
ROUNDS="$ROUNDS" \
|
|
||||||
MIN_ROUND_MS="$MIN_ROUND_MS" \
|
|
||||||
scripts/stage2_perf_compare_m4.sh >/dev/null
|
|
||||||
|
|
||||||
echo "[4/4] Summary (geomean ratios)..." >&2
|
|
||||||
python3 - "$COMPARE_CSV" "$SUMMARY_MD" <<'PY'
|
|
||||||
import csv
|
|
||||||
import math
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
in_csv = Path(sys.argv[1])
|
|
||||||
out_md = Path(sys.argv[2])
|
|
||||||
|
|
||||||
rows = list(csv.DictReader(in_csv.open(newline="")))
|
|
||||||
ratios = {}
|
|
||||||
for r in rows:
|
|
||||||
ratios.setdefault(r["type"], []).append(float(r["ratio_ours_over_rout"]))
|
|
||||||
|
|
||||||
def geomean(vals):
|
|
||||||
return math.exp(sum(math.log(v) for v in vals) / len(vals))
|
|
||||||
|
|
||||||
def p50(vals):
|
|
||||||
v = sorted(vals)
|
|
||||||
n = len(v)
|
|
||||||
if n % 2 == 1:
|
|
||||||
return v[n // 2]
|
|
||||||
return (v[n // 2 - 1] + v[n // 2]) / 2.0
|
|
||||||
|
|
||||||
all_vals = [float(r["ratio_ours_over_rout"]) for r in rows]
|
|
||||||
types = ["all"] + sorted(ratios.keys())
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
lines = []
|
|
||||||
lines.append("# Stage2 selected_der_v2 compare summary (release)\n\n")
|
|
||||||
lines.append(f"- recorded_at_utc: `{now}`\n")
|
|
||||||
lines.append(f"- inputs_csv: `{in_csv}`\n\n")
|
|
||||||
lines.append("| type | n | min | p50 | geomean | max | >1 count |\n")
|
|
||||||
lines.append("|---|---:|---:|---:|---:|---:|---:|\n")
|
|
||||||
|
|
||||||
for t in types:
|
|
||||||
vals = all_vals if t == "all" else ratios[t]
|
|
||||||
vals_sorted = sorted(vals)
|
|
||||||
lines.append(
|
|
||||||
f"| {t} | {len(vals_sorted)} | {vals_sorted[0]:.4f} | {p50(vals_sorted):.4f} | "
|
|
||||||
f"{geomean(vals_sorted):.4f} | {vals_sorted[-1]:.4f} | {sum(1 for v in vals_sorted if v>1.0)} |\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
out_md.write_text("".join(lines), encoding="utf-8")
|
|
||||||
print(out_md)
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "Done." >&2
|
|
||||||
echo "- OURS decode MD: $OURS_MD" >&2
|
|
||||||
echo "- OURS decode CSV: $OURS_CSV" >&2
|
|
||||||
echo "- OURS landing MD: $OURS_LANDING_MD" >&2
|
|
||||||
echo "- OURS landing CSV: $OURS_LANDING_CSV" >&2
|
|
||||||
echo "- Routinator: $ROUT_MD" >&2
|
|
||||||
echo "- Compare MD: $COMPARE_MD" >&2
|
|
||||||
echo "- Compare CSV: $COMPARE_CSV" >&2
|
|
||||||
echo "- Summary MD: $SUMMARY_MD" >&2
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Requires:
|
|
||||||
# rustup component add llvm-tools-preview
|
|
||||||
# cargo install cargo-llvm-cov --locked
|
|
||||||
|
|
||||||
run_out="$(mktemp)"
|
|
||||||
text_out="$(mktemp)"
|
|
||||||
html_out="$(mktemp)"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -f "$run_out" "$text_out" "$html_out"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
# Preserve colored output even though we post-process output by running under a pseudo-TTY.
|
|
||||||
# We run tests only once, then generate both CLI text + HTML reports without rerunning tests.
|
|
||||||
set +e
|
|
||||||
|
|
||||||
cargo llvm-cov clean --workspace >/dev/null 2>&1
|
|
||||||
|
|
||||||
# 1) Run tests once to collect coverage data (no report).
|
|
||||||
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov --no-report" "$run_out" >/dev/null 2>&1
|
|
||||||
run_status="$?"
|
|
||||||
|
|
||||||
# 2) CLI summary report + fail-under gate (no test rerun).
|
|
||||||
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov report --fail-under-lines 90" "$text_out" >/dev/null 2>&1
|
|
||||||
text_status="$?"
|
|
||||||
|
|
||||||
# 3) HTML report (no test rerun).
|
|
||||||
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov report --html" "$html_out" >/dev/null 2>&1
|
|
||||||
html_status="$?"
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
strip_script_noise() {
|
|
||||||
tr -d '\r' | sed '/^Script \(started\|done\) on /d'
|
|
||||||
}
|
|
||||||
|
|
||||||
strip_ansi_for_parse() {
|
|
||||||
awk '
|
|
||||||
{
|
|
||||||
line = $0
|
|
||||||
gsub(/\033\[[0-9;]*[A-Za-z]/, "", line) # CSI escapes
|
|
||||||
gsub(/\033\([A-Za-z]/, "", line) # charset escapes (e.g., ESC(B)
|
|
||||||
gsub(/\r/, "", line)
|
|
||||||
print line
|
|
||||||
}
|
|
||||||
'
|
|
||||||
}
|
|
||||||
|
|
||||||
cat "$run_out" | strip_script_noise
|
|
||||||
cat "$text_out" | strip_script_noise
|
|
||||||
cat "$html_out" | strip_script_noise
|
|
||||||
|
|
||||||
cat "$run_out" | strip_ansi_for_parse | awk '
|
|
||||||
BEGIN {
|
|
||||||
passed=0; failed=0; ignored=0; measured=0; filtered=0;
|
|
||||||
}
|
|
||||||
/^test result: / {
|
|
||||||
if (match($0, /([0-9]+) passed; ([0-9]+) failed; ([0-9]+) ignored; ([0-9]+) measured; ([0-9]+) filtered out;/, m)) {
|
|
||||||
passed += m[1]; failed += m[2]; ignored += m[3]; measured += m[4]; filtered += m[5];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
END {
|
|
||||||
executed = passed + failed;
|
|
||||||
total = passed + failed + ignored + measured;
|
|
||||||
printf("\nTEST SUMMARY (all suites): passed=%d failed=%d ignored=%d measured=%d filtered_out=%d executed=%d total=%d\n",
|
|
||||||
passed, failed, ignored, measured, filtered, executed, total);
|
|
||||||
}
|
|
||||||
'
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "HTML report: target/llvm-cov/html/index.html"
|
|
||||||
|
|
||||||
status="$text_status"
|
|
||||||
if [ "$run_status" -ne 0 ]; then status="$run_status"; fi
|
|
||||||
if [ "$html_status" -ne 0 ]; then status="$html_status"; fi
|
|
||||||
exit "$status"
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# M2: Run per-sample decode+profile benchmark (Ours vs Routinator) on selected_der fixtures.
|
|
||||||
#
|
|
||||||
# Outputs:
|
|
||||||
# - specs/develop/20260224/data/m2_manifest_decode_profile_compare.csv
|
|
||||||
# - specs/develop/20260224/data/m2_raw.log
|
|
||||||
#
|
|
||||||
# Note: This script assumes Routinator benchmark repo exists at:
|
|
||||||
# /home/yuyr/dev/rust_playground/routinator/benchmark
|
|
||||||
#
|
|
||||||
# It also assumes fixtures exist under:
|
|
||||||
# rpki/tests/benchmark/selected_der/*.mft
|
|
||||||
# routinator/benchmark/fixtures/selected_der/*.mft
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
RPKI_DIR="$ROOT_DIR"
|
|
||||||
OURS_BENCH_DIR="$RPKI_DIR/benchmark/ours_manifest_bench"
|
|
||||||
|
|
||||||
ROUT_BENCH_DIR="${ROUT_BENCH_DIR:-/home/yuyr/dev/rust_playground/routinator/benchmark}"
|
|
||||||
ROUT_BIN="$ROUT_BENCH_DIR/target/release/routinator-manifest-benchmark"
|
|
||||||
|
|
||||||
DATE_TAG="${DATE_TAG:-20260224}"
|
|
||||||
OUT_DIR="$RPKI_DIR/../specs/develop/${DATE_TAG}/data"
|
|
||||||
OUT_CSV="${OUT_CSV:-$OUT_DIR/m2_manifest_decode_profile_compare.csv}"
|
|
||||||
OUT_RAW="${OUT_RAW:-$OUT_DIR/m2_raw.log}"
|
|
||||||
|
|
||||||
REPEATS="${REPEATS:-3}"
|
|
||||||
|
|
||||||
# Iterations / warmups (kept moderate for interactive iteration).
|
|
||||||
ITER_SMALL="${ITER_SMALL:-20000}"
|
|
||||||
ITER_MEDIUM="${ITER_MEDIUM:-20000}"
|
|
||||||
ITER_LARGE="${ITER_LARGE:-20000}"
|
|
||||||
ITER_XLARGE="${ITER_XLARGE:-2000}"
|
|
||||||
|
|
||||||
WARM_SMALL="${WARM_SMALL:-2000}"
|
|
||||||
WARM_MEDIUM="${WARM_MEDIUM:-2000}"
|
|
||||||
WARM_LARGE="${WARM_LARGE:-2000}"
|
|
||||||
WARM_XLARGE="${WARM_XLARGE:-200}"
|
|
||||||
|
|
||||||
SAMPLES=(
|
|
||||||
small-01
|
|
||||||
small-02
|
|
||||||
medium-01
|
|
||||||
medium-02
|
|
||||||
large-01
|
|
||||||
large-02
|
|
||||||
xlarge-01
|
|
||||||
xlarge-02
|
|
||||||
)
|
|
||||||
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
: > "$OUT_RAW"
|
|
||||||
|
|
||||||
echo "sample,bucket,manifest_file_count,ours_avg_ns_per_op,ours_ops_per_s,rout_avg_ns_per_op,rout_ops_per_s,ratio_ours_over_rout,iterations,repeats,warmup" > "$OUT_CSV"
|
|
||||||
|
|
||||||
echo "[1/3] Build ours benchmark (release)..." | tee -a "$OUT_RAW"
|
|
||||||
(cd "$OURS_BENCH_DIR" && cargo build --release -q)
|
|
||||||
OURS_BIN="$OURS_BENCH_DIR/target/release/ours-manifest-bench"
|
|
||||||
|
|
||||||
echo "[2/3] Build routinator benchmark (release)..." | tee -a "$OUT_RAW"
|
|
||||||
(cd "$ROUT_BENCH_DIR" && cargo build --release -q)
|
|
||||||
|
|
||||||
taskset_prefix=""
|
|
||||||
if command -v taskset >/dev/null 2>&1; then
|
|
||||||
if [[ -n "${TASKSET_CPU:-}" ]]; then
|
|
||||||
taskset_prefix="taskset -c ${TASKSET_CPU}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
bucket_for() {
|
|
||||||
local s="$1"
|
|
||||||
case "$s" in
|
|
||||||
small-*) echo "small" ;;
|
|
||||||
medium-*) echo "medium" ;;
|
|
||||||
large-*) echo "large" ;;
|
|
||||||
xlarge-*) echo "xlarge" ;;
|
|
||||||
*) echo "unknown" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
iters_for() {
|
|
||||||
local b="$1"
|
|
||||||
case "$b" in
|
|
||||||
small) echo "$ITER_SMALL" ;;
|
|
||||||
medium) echo "$ITER_MEDIUM" ;;
|
|
||||||
large) echo "$ITER_LARGE" ;;
|
|
||||||
xlarge) echo "$ITER_XLARGE" ;;
|
|
||||||
*) echo "$ITER_MEDIUM" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
warm_for() {
|
|
||||||
local b="$1"
|
|
||||||
case "$b" in
|
|
||||||
small) echo "$WARM_SMALL" ;;
|
|
||||||
medium) echo "$WARM_MEDIUM" ;;
|
|
||||||
large) echo "$WARM_LARGE" ;;
|
|
||||||
xlarge) echo "$WARM_XLARGE" ;;
|
|
||||||
*) echo "$WARM_MEDIUM" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
run_ours() {
|
|
||||||
local sample="$1"
|
|
||||||
local iters="$2"
|
|
||||||
local warm="$3"
|
|
||||||
local ours_fixture="$RPKI_DIR/tests/benchmark/selected_der/${sample}.mft"
|
|
||||||
if [[ ! -f "$ours_fixture" ]]; then
|
|
||||||
echo "ours fixture not found: $ours_fixture" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "### ours $sample" >> "$OUT_RAW"
|
|
||||||
local out
|
|
||||||
out=$($taskset_prefix "$OURS_BIN" --manifest "$ours_fixture" --iterations "$iters" --warmup-iterations "$warm" --repeats "$REPEATS")
|
|
||||||
echo "$out" >> "$OUT_RAW"
|
|
||||||
|
|
||||||
local line
|
|
||||||
line=$(echo "$out" | rg "^\\| ${sample} \\|" | tail -n 1)
|
|
||||||
if [[ -z "${line:-}" ]]; then
|
|
||||||
echo "failed to parse ours output for $sample" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# Expected final row: | sample | avg ns/op | ops/s | file count |
|
|
||||||
local avg ops cnt
|
|
||||||
avg=$(echo "$line" | awk -F'|' '{gsub(/^ +| +$/,"",$3); print $3}')
|
|
||||||
ops=$(echo "$line" | awk -F'|' '{gsub(/^ +| +$/,"",$4); print $4}')
|
|
||||||
cnt=$(echo "$line" | awk -F'|' '{gsub(/^ +| +$/,"",$5); print $5}')
|
|
||||||
echo "$avg,$ops,$cnt"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_rout() {
|
|
||||||
local sample="$1"
|
|
||||||
local iters="$2"
|
|
||||||
local warm="$3"
|
|
||||||
local rout_fixture="$ROUT_BENCH_DIR/fixtures/selected_der/${sample}.mft"
|
|
||||||
if [[ ! -f "$rout_fixture" ]]; then
|
|
||||||
echo "routinator fixture not found: $rout_fixture" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "### routinator $sample" >> "$OUT_RAW"
|
|
||||||
local out
|
|
||||||
out=$(
|
|
||||||
$taskset_prefix "$ROUT_BIN" \
|
|
||||||
--target decode_only \
|
|
||||||
--manifest "$rout_fixture" \
|
|
||||||
--issuer "$ROUT_BENCH_DIR/fixtures/ta.cer" \
|
|
||||||
--iterations "$iters" \
|
|
||||||
--repeats "$REPEATS" \
|
|
||||||
--warmup-iterations "$warm" \
|
|
||||||
--strict false
|
|
||||||
)
|
|
||||||
echo "$out" >> "$OUT_RAW"
|
|
||||||
|
|
||||||
local avg_line cnt_line
|
|
||||||
avg_line=$(echo "$out" | rg "^ avg:")
|
|
||||||
cnt_line=$(echo "$out" | rg "^ manifest_file_count:")
|
|
||||||
|
|
||||||
local avg_ns ops_s cnt
|
|
||||||
avg_ns=$(echo "$avg_line" | awk '{print $2}')
|
|
||||||
ops_s=$(echo "$avg_line" | awk '{gsub(/[()]/,"",$4); print $4}')
|
|
||||||
cnt=$(echo "$cnt_line" | awk '{print $2}')
|
|
||||||
echo "$avg_ns,$ops_s,$cnt"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[3/3] Run per-sample benchmarks..." | tee -a "$OUT_RAW"
|
|
||||||
for s in "${SAMPLES[@]}"; do
|
|
||||||
b=$(bucket_for "$s")
|
|
||||||
it=$(iters_for "$b")
|
|
||||||
warm=$(warm_for "$b")
|
|
||||||
|
|
||||||
IFS=, read -r ours_avg ours_ops ours_cnt < <(run_ours "$s" "$it" "$warm")
|
|
||||||
IFS=, read -r rout_avg rout_ops rout_cnt < <(run_rout "$s" "$it" "$warm")
|
|
||||||
|
|
||||||
if [[ "$ours_cnt" != "$rout_cnt" ]]; then
|
|
||||||
echo "WARNING: file count differs for $s (ours=$ours_cnt rout=$rout_cnt)" | tee -a "$OUT_RAW"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ratio=$(python3 - <<PY
|
|
||||||
o=float("$ours_avg")
|
|
||||||
r=float("$rout_avg")
|
|
||||||
print(f"{(o/r):.4f}" if r != 0 else "inf")
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
|
|
||||||
echo "$s,$b,$ours_cnt,$ours_avg,$ours_ops,$rout_avg,$rout_ops,$ratio,$it,$REPEATS,$warm" >> "$OUT_CSV"
|
|
||||||
echo >> "$OUT_RAW"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Done."
|
|
||||||
echo "- CSV: $OUT_CSV"
|
|
||||||
echo "- Raw: $OUT_RAW"
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# M3: Generate flamegraphs + top hotspots for Manifest decode+profile (Ours vs Routinator).
|
|
||||||
#
|
|
||||||
# Outputs under:
|
|
||||||
# specs/develop/20260224/flamegraph/
|
|
||||||
# specs/develop/20260224/hotspots/
|
|
||||||
# specs/develop/20260224/perf/
|
|
||||||
#
|
|
||||||
# Notes:
|
|
||||||
# - On WSL2, /usr/bin/perf is often a wrapper that fails. This script uses a real perf binary
|
|
||||||
# from /usr/lib/linux-tools/*/perf (if present).
|
|
||||||
# - Ours profiling uses perf + flamegraph --perfdata to avoid rebuilding the whole crate graph
|
|
||||||
# with RocksDB.
|
|
||||||
|
|
||||||
ROOT_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
||||||
RPKI_DIR="$ROOT_REPO/rpki"
|
|
||||||
|
|
||||||
DATE_TAG="${DATE_TAG:-20260224}"
|
|
||||||
OUT_BASE="$ROOT_REPO/specs/develop/${DATE_TAG}"
|
|
||||||
OUT_FLAME="$OUT_BASE/flamegraph"
|
|
||||||
OUT_HOT="$OUT_BASE/hotspots"
|
|
||||||
OUT_PERF="$OUT_BASE/perf"
|
|
||||||
|
|
||||||
RUN_TAG="${RUN_TAG:-p2}"
|
|
||||||
|
|
||||||
OURS_BENCH_DIR="$RPKI_DIR/benchmark/ours_manifest_bench"
|
|
||||||
OURS_BIN="$OURS_BENCH_DIR/target/release/ours-manifest-bench"
|
|
||||||
|
|
||||||
ROUT_BENCH_DIR="${ROUT_BENCH_DIR:-/home/yuyr/dev/rust_playground/routinator/benchmark}"
|
|
||||||
ROUT_BIN="$ROUT_BENCH_DIR/target/release/routinator-manifest-benchmark"
|
|
||||||
ROUT_ISSUER="$ROUT_BENCH_DIR/fixtures/ta.cer"
|
|
||||||
|
|
||||||
PROFILE_HZ="${PROFILE_HZ:-99}"
|
|
||||||
|
|
||||||
mkdir -p "$OUT_FLAME" "$OUT_HOT" "$OUT_PERF"
|
|
||||||
|
|
||||||
PERF_WRAPPER_OUT="$(perf --version 2>&1 || true)"
|
|
||||||
PERF_REAL=""
|
|
||||||
if echo "${PERF_WRAPPER_OUT}" | grep -q "WARNING: perf not found for kernel"; then
|
|
||||||
PERF_REAL="$(ls -1 /usr/lib/linux-tools/*/perf 2>/dev/null | head -n 1 || true)"
|
|
||||||
else
|
|
||||||
PERF_REAL="$(command -v perf || true)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${PERF_REAL}" ]]; then
|
|
||||||
echo "ERROR: usable perf binary not found (wrapper detected and no /usr/lib/linux-tools/*/perf)." >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
SHIM_DIR="$RPKI_DIR/target/bench/tools"
|
|
||||||
mkdir -p "$SHIM_DIR"
|
|
||||||
cat > "$SHIM_DIR/perf" <<EOF
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
exec "${PERF_REAL}" "\$@"
|
|
||||||
EOF
|
|
||||||
chmod +x "$SHIM_DIR/perf"
|
|
||||||
export PATH="$SHIM_DIR:$PATH"
|
|
||||||
|
|
||||||
echo "Using perf: $PERF_REAL"
|
|
||||||
|
|
||||||
echo "[1/3] Build ours benchmark with frame pointers..."
|
|
||||||
(cd "$OURS_BENCH_DIR" && RUSTFLAGS="-C force-frame-pointers=yes" cargo build --release -q)
|
|
||||||
|
|
||||||
echo "[2/3] Build routinator benchmark (release)..."
|
|
||||||
(cd "$ROUT_BENCH_DIR" && cargo build --release -q)
|
|
||||||
|
|
||||||
taskset_prefix=""
|
|
||||||
if command -v taskset >/dev/null 2>&1; then
|
|
||||||
taskset_prefix="taskset -c 0"
|
|
||||||
fi
|
|
||||||
|
|
||||||
profile_ours() {
|
|
||||||
local sample="$1"
|
|
||||||
local iters="$2"
|
|
||||||
local warm="$3"
|
|
||||||
local fixture="$RPKI_DIR/tests/benchmark/selected_der/${sample}.mft"
|
|
||||||
if [[ ! -f "$fixture" ]]; then
|
|
||||||
echo "ERROR: ours fixture not found: $fixture" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local perfdata="$OUT_PERF/ours_${sample}_${RUN_TAG}.perf.data"
|
|
||||||
local svg="$OUT_FLAME/ours_${sample}_${RUN_TAG}.svg"
|
|
||||||
local tsv="$OUT_HOT/ours_${sample}_${RUN_TAG}.tsv"
|
|
||||||
|
|
||||||
echo "== ours $sample (iters=$iters warmup=$warm hz=$PROFILE_HZ)"
|
|
||||||
$taskset_prefix perf record -o "$perfdata" -F "$PROFILE_HZ" -g -- \
|
|
||||||
"$OURS_BIN" --manifest "$fixture" --iterations "$iters" --warmup-iterations "$warm" --repeats 1 >/dev/null
|
|
||||||
|
|
||||||
flamegraph --perfdata "$perfdata" --output "$svg" --title "ours ${sample} ManifestObject::decode_der" --deterministic >/dev/null
|
|
||||||
|
|
||||||
perf report -i "$perfdata" --stdio --no-children --sort symbol --percent-limit 0.5 \
|
|
||||||
| awk '/^[[:space:]]*[0-9.]+%/ {pct=$1; sub(/%/,"",pct); $1=""; sub(/^[[:space:]]+/,""); print pct "\t" $0}' \
|
|
||||||
> "$tsv"
|
|
||||||
}
|
|
||||||
|
|
||||||
profile_routinator() {
|
|
||||||
local sample="$1"
|
|
||||||
local iters="$2"
|
|
||||||
local warm="$3"
|
|
||||||
local fixture="$ROUT_BENCH_DIR/fixtures/selected_der/${sample}.mft"
|
|
||||||
if [[ ! -f "$fixture" ]]; then
|
|
||||||
echo "ERROR: routinator fixture not found: $fixture" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local svg="$OUT_FLAME/routinator_${sample}_${RUN_TAG}.svg"
|
|
||||||
local tsv="$OUT_HOT/routinator_${sample}_${RUN_TAG}.tsv"
|
|
||||||
|
|
||||||
echo "== routinator $sample (iters=$iters warmup=$warm hz=$PROFILE_HZ)"
|
|
||||||
$taskset_prefix "$ROUT_BIN" \
|
|
||||||
--target decode_only \
|
|
||||||
--manifest "$fixture" \
|
|
||||||
--issuer "$ROUT_ISSUER" \
|
|
||||||
--iterations "$iters" \
|
|
||||||
--repeats 1 \
|
|
||||||
--warmup-iterations "$warm" \
|
|
||||||
--strict false \
|
|
||||||
--profile-hz "$PROFILE_HZ" \
|
|
||||||
--flamegraph "$svg" \
|
|
||||||
--hotspots "$tsv" \
|
|
||||||
>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[3/3] Profile samples..."
|
|
||||||
|
|
||||||
# Choose iterations so each capture runs ~10-20s serially.
|
|
||||||
profile_ours small-01 200000 0
|
|
||||||
profile_routinator small-01 200000 0
|
|
||||||
|
|
||||||
profile_ours large-02 50000 0
|
|
||||||
profile_routinator large-02 50000 0
|
|
||||||
|
|
||||||
profile_ours xlarge-02 5000 0
|
|
||||||
profile_routinator xlarge-02 5000 0
|
|
||||||
|
|
||||||
echo "Done."
|
|
||||||
echo "- Flamegraphs: $OUT_FLAME/"
|
|
||||||
echo "- Hotspots: $OUT_HOT/"
|
|
||||||
echo "- Perf data: $OUT_PERF/"
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# Manual RRDP sync (APNIC-focused)
|
|
||||||
|
|
||||||
This directory contains **manual, command-line** scripts to reproduce the workflow described in:
|
|
||||||
|
|
||||||
- `specs/develop/20260226/apnic_rrdp_delta_analysis_after_manifest_revalidation_fix_20260227T022606Z.md`
|
|
||||||
|
|
||||||
They are meant for **hands-on validation / acceptance runs**, not for CI.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Rust toolchain (`cargo`)
|
|
||||||
- `rsync` available on PATH (for rsync fallback/objects)
|
|
||||||
- Network access (RRDP over HTTPS)
|
|
||||||
|
|
||||||
## What the scripts do
|
|
||||||
|
|
||||||
### `full_sync.sh`
|
|
||||||
|
|
||||||
- Creates a fresh RocksDB directory
|
|
||||||
- Runs a **full serial** validation from a TAL URL (default: APNIC RFC7730 TAL)
|
|
||||||
- Writes:
|
|
||||||
- run log
|
|
||||||
- audit report JSON
|
|
||||||
- run meta JSON (includes durations + download_stats)
|
|
||||||
- short summary Markdown (includes durations + download_stats)
|
|
||||||
- RocksDB key statistics (`db_stats --exact`)
|
|
||||||
- RRDP 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
|
|
||||||
```
|
|
||||||
@@ -1,543 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Delta sync + validation starting from a baseline snapshot DB.
|
|
||||||
#
|
|
||||||
# This script:
|
|
||||||
# 1) Copies BASE_DB_DIR -> DELTA_DB_DIR (so baseline is not modified)
|
|
||||||
# 2) Runs rpki validation again (RRDP will prefer delta if available)
|
|
||||||
# 3) Writes artifacts + a markdown delta analysis report
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./scripts/manual_sync/delta_sync.sh <base_db_dir> <base_report_json>
|
|
||||||
#
|
|
||||||
# Outputs under OUT_DIR (default: target/live/manual_sync):
|
|
||||||
# - *_delta_db_* copied RocksDB directory
|
|
||||||
# - *_delta_report_*.json audit report
|
|
||||||
# - *_delta_run_*.log stdout/stderr log (includes summary)
|
|
||||||
# - *_delta_db_stats_*.txt db_stats --exact output
|
|
||||||
# - *_delta_rrdp_state_*.tsv rrdp_state_dump --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
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Full sync + validation from a TAL URL (default: APNIC).
|
|
||||||
#
|
|
||||||
# Produces artifacts under OUT_DIR (default: target/live/manual_sync):
|
|
||||||
# - *_db_* RocksDB directory
|
|
||||||
# - *_report_*.json audit report
|
|
||||||
# - *_run_*.log stdout/stderr log (includes summary)
|
|
||||||
# - *_db_stats_*.txt db_stats --exact output
|
|
||||||
# - *_rrdp_state_*.tsv rrdp_state_dump --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
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# M4: Compare decode+profile (OURS) vs routinator baseline (rpki crate 0.19.1)
|
|
||||||
# on selected_der_v2 fixtures (cer/crl/manifest/roa/aspa).
|
|
||||||
#
|
|
||||||
# Outputs under:
|
|
||||||
# - rpki/target/bench/stage2_selected_der_v2_routinator_decode_release.{csv,md}
|
|
||||||
# - rpki/target/bench/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release.{csv,md}
|
|
||||||
#
|
|
||||||
# Notes:
|
|
||||||
# - OURS decode benchmark is produced by:
|
|
||||||
# `cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture`
|
|
||||||
# and writes `stage2_selected_der_v2_decode_release.csv` when BENCH_OUT_CSV is set.
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
RPKI_DIR="$ROOT_DIR"
|
|
||||||
|
|
||||||
OUT_DIR="$RPKI_DIR/target/bench"
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
|
|
||||||
OURS_CSV="${OURS_CSV:-$OUT_DIR/stage2_selected_der_v2_decode_release.csv}"
|
|
||||||
|
|
||||||
ROUT_CSV="${ROUT_CSV:-$OUT_DIR/stage2_selected_der_v2_routinator_decode_release.csv}"
|
|
||||||
ROUT_MD="${ROUT_MD:-$OUT_DIR/stage2_selected_der_v2_routinator_decode_release.md}"
|
|
||||||
|
|
||||||
COMPARE_CSV="${COMPARE_CSV:-$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release.csv}"
|
|
||||||
COMPARE_MD="${COMPARE_MD:-$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release.md}"
|
|
||||||
|
|
||||||
WARMUP_ITERS="${WARMUP_ITERS:-10}"
|
|
||||||
ROUNDS="${ROUNDS:-3}"
|
|
||||||
MIN_ROUND_MS="${MIN_ROUND_MS:-200}"
|
|
||||||
|
|
||||||
if [[ ! -f "$OURS_CSV" ]]; then
|
|
||||||
echo "ERROR: missing OURS CSV: $OURS_CSV" >&2
|
|
||||||
echo "Hint: run:" >&2
|
|
||||||
echo " cd rpki && BENCH_WARMUP_ITERS=$WARMUP_ITERS BENCH_ROUNDS=$ROUNDS BENCH_MIN_ROUND_MS=$MIN_ROUND_MS \\" >&2
|
|
||||||
echo " BENCH_OUT_CSV=target/bench/stage2_selected_der_v2_decode_release.csv \\" >&2
|
|
||||||
echo " cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[1/2] Run routinator baseline bench (release)..." >&2
|
|
||||||
(cd "$RPKI_DIR/benchmark/routinator_object_bench" && cargo run --release -q -- \
|
|
||||||
--dir "$RPKI_DIR/tests/benchmark/selected_der_v2" \
|
|
||||||
--warmup-iters "$WARMUP_ITERS" \
|
|
||||||
--rounds "$ROUNDS" \
|
|
||||||
--min-round-ms "$MIN_ROUND_MS" \
|
|
||||||
--out-csv "$ROUT_CSV" \
|
|
||||||
--out-md "$ROUT_MD")
|
|
||||||
|
|
||||||
echo "[2/2] Join CSVs + compute ratios..." >&2
|
|
||||||
python3 - "$OURS_CSV" "$ROUT_CSV" "$COMPARE_CSV" "$COMPARE_MD" <<'PY'
|
|
||||||
import csv
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ours_path = Path(sys.argv[1])
|
|
||||||
rout_path = Path(sys.argv[2])
|
|
||||||
out_csv_path = Path(sys.argv[3])
|
|
||||||
out_md_path = Path(sys.argv[4])
|
|
||||||
|
|
||||||
def read_csv(path: Path):
|
|
||||||
with path.open(newline="") as f:
|
|
||||||
return list(csv.DictReader(f))
|
|
||||||
|
|
||||||
ours_rows = read_csv(ours_path)
|
|
||||||
rout_rows = read_csv(rout_path)
|
|
||||||
|
|
||||||
rout_by_key = {(r["type"], r["sample"]): r for r in rout_rows}
|
|
||||||
|
|
||||||
out_rows = []
|
|
||||||
for r in ours_rows:
|
|
||||||
key = (r["type"], r["sample"])
|
|
||||||
rr = rout_by_key.get(key)
|
|
||||||
if rr is None:
|
|
||||||
raise SystemExit(f"missing routinator row for {key}")
|
|
||||||
|
|
||||||
ours_ns = float(r["avg_ns_per_op"])
|
|
||||||
rout_ns = float(rr["avg_ns_per_op"])
|
|
||||||
ratio = (ours_ns / rout_ns) if rout_ns != 0.0 else float("inf")
|
|
||||||
|
|
||||||
out_rows.append({
|
|
||||||
"type": r["type"],
|
|
||||||
"sample": r["sample"],
|
|
||||||
"size_bytes": r["size_bytes"],
|
|
||||||
"complexity": r["complexity"],
|
|
||||||
"ours_avg_ns_per_op": f"{ours_ns:.2f}",
|
|
||||||
"ours_ops_per_sec": f"{float(r['ops_per_sec']):.2f}",
|
|
||||||
"rout_avg_ns_per_op": f"{rout_ns:.2f}",
|
|
||||||
"rout_ops_per_sec": f"{float(rr['ops_per_sec']):.2f}",
|
|
||||||
"ratio_ours_over_rout": f"{ratio:.4f}",
|
|
||||||
})
|
|
||||||
|
|
||||||
out_rows.sort(key=lambda x: (x["type"], x["sample"]))
|
|
||||||
|
|
||||||
out_csv_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with out_csv_path.open("w", newline="") as f:
|
|
||||||
w = csv.DictWriter(f, fieldnames=list(out_rows[0].keys()))
|
|
||||||
w.writeheader()
|
|
||||||
w.writerows(out_rows)
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
lines.append("# Stage2 ours vs routinator (decode+profile, selected_der_v2)\n")
|
|
||||||
lines.append(f"- ours_csv: `{ours_path}`\n")
|
|
||||||
lines.append(f"- rout_csv: `{rout_path}`\n")
|
|
||||||
lines.append("\n")
|
|
||||||
lines.append("| type | sample | size_bytes | complexity | ours ns/op | rout ns/op | ratio |\n")
|
|
||||||
lines.append("|---|---|---:|---:|---:|---:|---:|\n")
|
|
||||||
for r in out_rows:
|
|
||||||
lines.append(
|
|
||||||
f"| {r['type']} | {r['sample']} | {r['size_bytes']} | {r['complexity']} | "
|
|
||||||
f"{r['ours_avg_ns_per_op']} | {r['rout_avg_ns_per_op']} | {r['ratio_ours_over_rout']} |\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
out_md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
out_md_path.write_text("".join(lines), encoding="utf-8")
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "Done." >&2
|
|
||||||
echo "- routinator CSV: $ROUT_CSV" >&2
|
|
||||||
echo "- compare CSV: $COMPARE_CSV" >&2
|
|
||||||
echo "- compare MD: $COMPARE_MD" >&2
|
|
||||||
@@ -182,8 +182,6 @@ RFC 引用:RFC 5280 §4.2.2.1;RFC 5280 §4.2.2.2;RFC 5280 §4.2.1.6。
|
|||||||
| `1.2.840.113549.1.9.5` | CMS signedAttrs: signing-time | RFC 9589 §4(更新 RFC 6488 §3(1f)/(1g)) |
|
| `1.2.840.113549.1.9.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 |
|
||||||
|
|||||||
104
specs/01_tal.md
104
specs/01_tal.md
@@ -1,94 +1,36 @@
|
|||||||
# 01. TAL(Trust Anchor Locator)
|
# 01. Trust Anchor Locator (TAL)
|
||||||
|
|
||||||
## 1.1 对象定位
|
## 1.1 对象定位
|
||||||
|
TAL是一个数据格式/配置文件,目的是告诉RP信任锚的公钥是什么,以及相关对象可以从哪里获取。
|
||||||
|
|
||||||
TAL(Trust Anchor Locator)用于向 RP 提供:
|
## 1.2 数据格式 (RFC 8630 §2.2)
|
||||||
|
TAL是一个配置文件,格式定义如下:
|
||||||
1) 可检索“当前 TA 证书”的一个或多个 URI;以及
|
```
|
||||||
2) 该 TA 证书的 `subjectPublicKeyInfo`(SPKI)期望值(用于绑定/防替换)。
|
The TAL is an ordered sequence of:
|
||||||
|
1. an optional comment section consisting of one or more lines each starting with the "#" character, followed by human-readable informational UTF-8 text, conforming to the restrictions defined
|
||||||
RFC 8630 §2;RFC 8630 §2.3。
|
in Section 2 of [RFC5198], and ending with a line break,
|
||||||
|
2. a URI section that is comprised of one or more ordered lines, each containing a TA URI, and ending with a line break,
|
||||||
## 1.2 原始载体与编码
|
3. a line break, and
|
||||||
|
4. a subjectPublicKeyInfo [RFC5280] in DER format [X.509], encoded in base64 (see Section 4 of [RFC4648]). To avoid long lines,
|
||||||
- 载体:文本文件(ASCII/UTF-8 兼容的行文本)。
|
line breaks MAY be inserted into the base64-encoded string.
|
||||||
- 行结束:允许 `CRLF` 或 `LF`。RFC 8630 §2.2。
|
Note that line breaks in this file can use either "<CRLF>" or "<LF>".
|
||||||
- 结构:`[可选注释区] + 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 }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
其中 `algorithm`/`subjectPublicKey` 的取值受 RPKI 算法 profile 约束(例如 RSA 2048 + SHA-256 等;SKI/AKI 计算仍用 SHA-1)。RFC 5280 §4.1.2.7;RFC 7935 §2-§3.1;RFC 6487 §4.8.2-§4.8.3。
|
## 1.3 抽象数据模型
|
||||||
|
|
||||||
## 1.3 解析规则(语义层)
|
### 1.3.1 TAL
|
||||||
|
|
||||||
输入:`TalFileBytes: bytes`。
|
|
||||||
|
|
||||||
解析步骤:
|
|
||||||
|
|
||||||
1) 按 `LF` / `CRLF` 识别行。RFC 8630 §2.2。
|
|
||||||
2) 从文件开头读取所有以 `#` 开头的行,作为 `comments`(保留去掉 `#` 后的 UTF-8 文本或保留原始行均可,但需保持 UTF-8)。RFC 8630 §2.2。
|
|
||||||
3) 继续读取一行或多行非空行,作为 `ta_uris`(保持顺序)。RFC 8630 §2.2(第 2 点)。
|
|
||||||
4) 读取一个空行(必须存在)。RFC 8630 §2.2(第 3 点)。
|
|
||||||
5) 将剩余行拼接为 Base64 文本(移除行分隔),Base64 解码得到 `subject_public_key_info_der`。RFC 8630 §2.2(第 4 点)。
|
|
||||||
6) 可选:将 `subject_public_key_info_der` 解析为 X.509 `SubjectPublicKeyInfo` 结构(用于与 TA 证书比对)。RFC 8630 §2.3;RFC 5280 §4.1.2.7。
|
|
||||||
|
|
||||||
URI 解析与约束:
|
|
||||||
|
|
||||||
- `ta_uris[*]` 的 scheme **MUST** 为 `rsync` 或 `https`。RFC 8630 §2.2。
|
|
||||||
- 每个 `ta_uri` **MUST** 指向“单个对象”,且 **MUST NOT** 指向目录或集合。RFC 8630 §2.3。
|
|
||||||
|
|
||||||
## 1.4 抽象数据模型(接口)
|
|
||||||
|
|
||||||
### 1.4.1 `Tal`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
||||||
|---|---|---|---|---|
|
|----------|-------------|-------------------------|--------------------------------------------|---------------|
|
||||||
| `raw` | `bytes` | TAL 原始文件字节 | 原样保留(可选但建议) | RFC 8630 §2.2 |
|
| uris | Vec<TalUri> | 指向TA的URI列表 | 允许rsync和https协议。 | RFC 8630 §2.1 |
|
||||||
| `comments` | `list[Utf8Text]` | 注释行(按出现顺序) | 每行以 `#` 开头;文本为 UTF-8;内容限制见 RFC 5198 §2 | RFC 8630 §2.2 |
|
| comment | Vec<String> | 注释(可选) | | RFC 8630 §2.2 |
|
||||||
| `ta_uris` | `list[Uri]` | TA 证书位置列表 | 至少 1 个;按序;每个 scheme 必须是 `rsync` 或 `https` | RFC 8630 §2.2 |
|
| spki_der | Vec<u8> | 原始的subjectPublicKeyInfo | x.509 SubjectPublicKeyInfo DER编码,再base64编码 | 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` 结构。
|
### 1.3.2 TalUri
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
||||||
|---|---|---|---|---|
|
|-------|--------|---------|---------|---------------|
|
||||||
| `uri` | `Uri` | 完整 URI 文本 | scheme 为 `rsync` 或 `https` | RFC 8630 §2.2 |
|
| Rsync | String | rsync地址 | | RFC 8630 §2.1 |
|
||||||
| `scheme` | `enum` | `rsync` / `https` | 从 `uri` 解析 | RFC 8630 §2.2 |
|
| Https | String | https地址 | | RFC 8630 §2.1 |
|
||||||
|
|
||||||
## 1.5 字段级约束清单(实现对照)
|
|
||||||
|
|
||||||
- TAL 由(可选)注释区 + URI 区 + 空行 + Base64(SPKI DER) 组成。RFC 8630 §2.2。
|
|
||||||
- URI 区至少 1 行,每行一个 TA URI,顺序有意义。RFC 8630 §2.2。
|
|
||||||
- TA URI 仅允许 `rsync` 或 `https`。RFC 8630 §2.2。
|
|
||||||
- Base64 区允许插入换行。RFC 8630 §2.2。
|
|
||||||
- 每个 TA URI 必须引用单个对象,不能指向目录/集合。RFC 8630 §2.3。
|
|
||||||
|
|||||||
121
specs/02_ta.md
Normal file
121
specs/02_ta.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# 02. Trust Anchor (TA)
|
||||||
|
|
||||||
|
## 2.1 对象定位
|
||||||
|
TA是一个自签名的CA证书。
|
||||||
|
|
||||||
|
## 2.2 原始载体与编码
|
||||||
|
|
||||||
|
- 载体:X.509 certificates.
|
||||||
|
- 编码:DER(遵循 RFC 5280 的 certificate 结构与字段语义,但受限于RFC 8630 §2.3)
|
||||||
|
|
||||||
|
|
||||||
|
## 2.3 抽象数据类型
|
||||||
|
|
||||||
|
### 2.3.1 TA
|
||||||
|
|
||||||
|
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
||||||
|
|-------------------|------------------|---------------|---------|---------------|
|
||||||
|
| name | String | 标识该TA,如apnic等 | | |
|
||||||
|
| cert_der | Vec<u8> | 原始DER内容 | | |
|
||||||
|
| cert | X509Certificate | 基础X509证书 | | RFC 5280 §4.1 |
|
||||||
|
| resource | ResourceSet | 资源集合 | | |
|
||||||
|
| publication_point | Uri | 获取该TA的URI | | |
|
||||||
|
|
||||||
|
### 2.3.2 ResourceSet
|
||||||
|
资源集合是来自RFC 3779的IP地址块(§2)和AS号段(§3),受约束于RFC 8630 §2.3
|
||||||
|
|
||||||
|
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
||||||
|
|------|----------------|--------|-------------|---------------------------|
|
||||||
|
| ips | IpResourceSet | IP地址集合 | 不能是inherit | RFC 3779 §2和RFC 8630 §2.3 |
|
||||||
|
| asns | AsnResourceSet | ASN集合 | 不能是inherit | RFC 3779 §3和RFC 8630 §2.3 |
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (### 2.3.3 IpResourceSet)
|
||||||
|
|
||||||
|
[//]: # (包括IPv4和IPv6的前缀表示)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
|
||||||
|
|
||||||
|
[//]: # (|----|------------------------|----------|-------------|--------------|)
|
||||||
|
|
||||||
|
[//]: # (| v4 | PrefixSet<Ipv4Prefix> | IPv4前缀集合 | | RFC 3779 §2 |)
|
||||||
|
|
||||||
|
[//]: # (| v6 | PrefixSet<Ipv6Prefix> | IPv6前缀集合 | | RFC 3779 §2 |)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (### 2.3.4 AsnResourceSet)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
|
||||||
|
|
||||||
|
[//]: # (|-------|--------------------|-------|-------------|-------------|)
|
||||||
|
|
||||||
|
[//]: # (| range | RangeSet<AsnBlock> | ASN集合 | | RFC 3779 §3 |)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (### 2.3.5 Ipv4Prefix)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
|
||||||
|
|
||||||
|
[//]: # (|------|-----|-----|---------|-------------|)
|
||||||
|
|
||||||
|
[//]: # (| addr | u32 | 地址 | | RFC 3779 §2 |)
|
||||||
|
|
||||||
|
[//]: # (| len | u8 | 长度 | 0-32 | RFC 3779 §2 |)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (### 2.3.6 Ipv6Prefix)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
|
||||||
|
|
||||||
|
[//]: # (|------|------|-----|---------|-------------|)
|
||||||
|
|
||||||
|
[//]: # (| addr | u128 | 地址 | | RFC 3779 §2 |)
|
||||||
|
|
||||||
|
[//]: # (| len | u8 | 长度 | 0-128 | RFC 3779 §2 |)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (### 2.3.7 AsnBlock)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
|
||||||
|
|
||||||
|
[//]: # (|----------|----------|-------|---------|--------------|)
|
||||||
|
|
||||||
|
[//]: # (| asn | Asn | ASN | | RFC 3779 §3 |)
|
||||||
|
|
||||||
|
[//]: # (| asnRange | AsnRange | ASN范围 | | RFC 3779 §3 |)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (### 2.3.8 Asn)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
|
||||||
|
|
||||||
|
[//]: # (|-----|-----|-----|---------|-------------|)
|
||||||
|
|
||||||
|
[//]: # (| asn | u32 | ASN | | RFC 3779 §3 |)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (### 2.3.8 AsnRange)
|
||||||
|
|
||||||
|
[//]: # ()
|
||||||
|
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
|
||||||
|
|
||||||
|
[//]: # (|-----|-----|-------|---------|--------------|)
|
||||||
|
|
||||||
|
[//]: # (| min | Asn | 最小ASN | | RFC 3779 §3 |)
|
||||||
|
|
||||||
|
[//]: # (| max | Asn | 最大ASN | | RFC 3779 §3 |)
|
||||||
|
|
||||||
|
# 2.4 TA校验流程(RFC 8630 §3)
|
||||||
|
1. 从TAL的URI列表中获取证书对象。(顺序访问,若前面失效,再访问后面的)
|
||||||
|
2. 验证证书格式,必须是当前、有效的自签名RPKI证书。
|
||||||
|
3. 验证公钥匹配。TAL中的SubjectPublicKeyInfo与下载证书的公钥一致。
|
||||||
|
4. 其他检查。
|
||||||
|
5. 更新本地存储库缓存。
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# 02. TA(Trust Anchor)自签名证书
|
|
||||||
|
|
||||||
## 2.1 对象定位
|
|
||||||
|
|
||||||
在 RP 侧,“信任锚(Trust Anchor, TA)”以一个**自签名 CA 资源证书**体现,其可获取位置与期望公钥由 TAL 提供。RFC 8630 §2.3。
|
|
||||||
|
|
||||||
本文件描述两个紧密相关的数据对象:
|
|
||||||
|
|
||||||
1) `TaCertificate`:TA 自签名资源证书本体(X.509 DER)
|
|
||||||
2) `TrustAnchor`:语义组合对象(`TAL` + `TaCertificate` 的绑定语义)
|
|
||||||
|
|
||||||
## 2.2 原始载体与编码
|
|
||||||
|
|
||||||
- 载体:X.509 证书(通常以 `.cer` 存放于仓库,但文件扩展名不作为语义依据)。
|
|
||||||
- 编码:DER。TA 证书必须符合 RPKI 资源证书 profile。RFC 8630 §2.3;RFC 6487 §4。
|
|
||||||
|
|
||||||
### 2.2.1 X.509 Certificate 的 ASN.1 定义(RFC 5280 §4.1;TA 与 RC 共享)
|
|
||||||
|
|
||||||
TA 证书与普通资源证书(RC)在编码层面都是 X.509 `Certificate`(DER)。其 ASN.1 定义如下:RFC 5280 §4.1。
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
Certificate ::= SEQUENCE {
|
|
||||||
tbsCertificate TBSCertificate,
|
|
||||||
signatureAlgorithm AlgorithmIdentifier,
|
|
||||||
signatureValue BIT STRING }
|
|
||||||
```
|
|
||||||
|
|
||||||
其中 `tbsCertificate.extensions`(v3 扩展)是 RPKI 语义的主要承载处(IP/AS 资源扩展、SIA/AIA/CRLDP 等)。RFC 5280 §4.1;RPKI 对字段/扩展存在性与关键性约束见 RFC 6487 §4。
|
|
||||||
|
|
||||||
> 说明:更完整的 RC 编码层结构(包括 Extension 外层“extnValue 二次 DER 解码”的套娃方式)在 `03_resource_certificate_rc.md` 与 `00_common_types.md` 中给出。
|
|
||||||
|
|
||||||
## 2.3 TA 证书的 RPKI 语义约束(在 RC profile 基础上额外强调)
|
|
||||||
|
|
||||||
### 2.3.1 自签名与 profile
|
|
||||||
|
|
||||||
- TA URI 指向的对象 **MUST** 是一个**自签名 CA 证书**,并且 **MUST** 符合 RPKI 证书 profile。RFC 8630 §2.3;RFC 6487 §4。
|
|
||||||
- 自签名证书在 RC profile 下的通用差异(例如 CRLDP/AIA 的省略规则、AKI 的规则)见 RFC 6487。RFC 6487 §4.8.3;RFC 6487 §4.8.6;RFC 6487 §4.8.7。
|
|
||||||
|
|
||||||
### 2.3.2 INR(IP/AS 资源扩展)在 TA 上的额外约束
|
|
||||||
|
|
||||||
- TA 的 INR 扩展(IP/AS 资源扩展,RFC 3779)**MUST** 是非空资源集合。RFC 8630 §2.3。
|
|
||||||
- TA 的 INR 扩展 **MUST NOT** 使用 `inherit` 形式。RFC 8630 §2.3。
|
|
||||||
- 说明:一般 RC profile 允许 `inherit`。RFC 6487 §4.8.10;RFC 6487 §4.8.11;RFC 3779 §2.2.3.5;RFC 3779 §3.2.3.3。
|
|
||||||
|
|
||||||
### 2.3.3 TAL ↔ TA 公钥绑定
|
|
||||||
|
|
||||||
- 用于验证 TA 的公钥(来自 TAL 中的 SPKI)**MUST** 与 TA 证书中的 `subjectPublicKeyInfo` 相同。RFC 8630 §2.3。
|
|
||||||
|
|
||||||
### 2.3.4 TA 稳定性语义(实现需建模为“约束/假设”,但不属于验证结果态)
|
|
||||||
|
|
||||||
- TA 公钥与 TAL 中公钥必须保持稳定(用于 RP 侧长期信任锚)。RFC 8630 §2.3。
|
|
||||||
|
|
||||||
### 2.3.5 TA 与 CRL/Manifest 的关系(语义)
|
|
||||||
|
|
||||||
- RFC 8630 指出:TA 为自签名证书,没有对应 CRL,且不会被 manifest 列出;TA 的获取/轮换由 TAL 控制。RFC 8630 §2.3。
|
|
||||||
|
|
||||||
> 注:这条更偏“发布/运维语义”,但对数据对象建模有影响:`TrustAnchor` 组合对象不应依赖 CRL/MFT 的存在。
|
|
||||||
|
|
||||||
## 2.4 抽象数据模型(接口)
|
|
||||||
|
|
||||||
### 2.4.1 `TaCertificate`
|
|
||||||
|
|
||||||
> 该对象在字段层面复用 `RC(CA)` 的语义模型(见 `03_resource_certificate_rc.md`),但增加 TA 特有约束。
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `raw_der` | `DerBytes` | TA 证书 DER | X.509 DER;证书 profile 约束见 RC 文档 | RFC 8630 §2.3;RFC 6487 §4 |
|
|
||||||
| `rc_ca` | `ResourceCaCertificate` | 以 RC(CA) 语义解析出的字段集合 | 必须满足“自签名 CA”分支约束;且 INR 必须非空且不允许 inherit | RFC 8630 §2.3;RFC 6487 §4;RFC 3779 §2/§3 |
|
|
||||||
|
|
||||||
### 2.4.2 `TrustAnchor`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `tal` | `Tal` | TAL 文件语义对象 | 见 `01_tal.md` | RFC 8630 §2.2 |
|
|
||||||
| `ta_certificate` | `TaCertificate` | TA 证书语义对象 | TA URI 指向的对象 | RFC 8630 §2.3 |
|
|
||||||
| `tal_spki_der` | `DerBytes` | 从 TAL 解析出的 SPKI DER | `tal.subject_public_key_info_der` | RFC 8630 §2.2 |
|
|
||||||
| `ta_spki_der` | `DerBytes` | 从 TA 证书抽取的 SPKI DER | `ta_certificate` 的 `subjectPublicKeyInfo` | RFC 8630 §2.3;RFC 5280 §4.1.2.7 |
|
|
||||||
|
|
||||||
**绑定约束(字段级)**
|
|
||||||
|
|
||||||
- `tal_spki_der` 必须与 `ta_spki_der` 完全相等(字节层面的 DER 等价)。RFC 8630 §2.3。
|
|
||||||
|
|
||||||
## 2.5 字段级约束清单(实现对照)
|
|
||||||
|
|
||||||
- TA URI 指向的对象必须是自签名 CA 证书,且符合 RPKI 证书 profile。RFC 8630 §2.3;RFC 6487 §4。
|
|
||||||
- TA 的 INR 扩展必须非空,且不得使用 inherit。RFC 8630 §2.3。
|
|
||||||
- TAL 中 SPKI 必须与 TA 证书的 `subjectPublicKeyInfo` 匹配。RFC 8630 §2.3。
|
|
||||||
- TA 不依赖 CRL/MFT(无对应 CRL,且不被 manifest 列出)。RFC 8630 §2.3。
|
|
||||||
314
specs/03_rc.md
Normal file
314
specs/03_rc.md
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
# 03. RC (Resource Certifications)
|
||||||
|
|
||||||
|
## 3.1 对象定位
|
||||||
|
RC是资源证书,包括CA和EE
|
||||||
|
|
||||||
|
## 3.2 原始载体与编码
|
||||||
|
|
||||||
|
- 载体:X.509 certificates.
|
||||||
|
- 编码:DER(遵循 RFC 5280 的 Certificate 结构与字段语义,但受 RPKI profile 限制)RFC 6487 §4
|
||||||
|
|
||||||
|
### 3.2.1 基本语法(RFC 5280 §4,RFC 6487 )
|
||||||
|
|
||||||
|
RC是遵循RFC5280定义的X.509Certificate语法(RFC 5280 §4),并且符合RFC 6487 §4的约束。只选取RFC 6487 §4章节列出来的字段。(Unless specifically noted as being OPTIONAL, all the fields listed
|
||||||
|
here MUST be present, and any other fields MUST NOT appear in a
|
||||||
|
conforming resource certificate.)
|
||||||
|
|
||||||
|
```
|
||||||
|
Certificate ::= SEQUENCE {
|
||||||
|
tbsCertificate TBSCertificate,
|
||||||
|
signatureAlgorithm AlgorithmIdentifier,
|
||||||
|
signatureValue BIT STRING
|
||||||
|
}
|
||||||
|
|
||||||
|
TBSCertificate ::= SEQUENCE {
|
||||||
|
version [0] EXPLICIT Version MUST be v3,
|
||||||
|
serialNumber CertificateSerialNumber,
|
||||||
|
signature AlgorithmIdentifier,
|
||||||
|
issuer Name,
|
||||||
|
subject Name,
|
||||||
|
validity Validity,
|
||||||
|
subjectPublicKeyInfo SubjectPublicKeyInfo,
|
||||||
|
extensions [3] EXPLICIT Extensions OPTIONAL
|
||||||
|
-- If present, version MUST be v3
|
||||||
|
}
|
||||||
|
|
||||||
|
Version ::= INTEGER { v1(0), v2(1), v3(2) }
|
||||||
|
|
||||||
|
CertificateSerialNumber ::= INTEGER
|
||||||
|
|
||||||
|
Validity ::= SEQUENCE {
|
||||||
|
notBefore Time,
|
||||||
|
notAfter Time }
|
||||||
|
|
||||||
|
Time ::= CHOICE {
|
||||||
|
utcTime UTCTime,
|
||||||
|
generalTime GeneralizedTime }
|
||||||
|
|
||||||
|
UniqueIdentifier ::= BIT STRING
|
||||||
|
|
||||||
|
SubjectPublicKeyInfo ::= SEQUENCE {
|
||||||
|
algorithm AlgorithmIdentifier,
|
||||||
|
subjectPublicKey BIT STRING }
|
||||||
|
|
||||||
|
Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension
|
||||||
|
|
||||||
|
Extension ::= SEQUENCE {
|
||||||
|
extnID OBJECT IDENTIFIER,
|
||||||
|
critical BOOLEAN DEFAULT FALSE,
|
||||||
|
extnValue OCTET STRING
|
||||||
|
-- contains the DER encoding of an ASN.1 value
|
||||||
|
-- corresponding to the extension type identified
|
||||||
|
-- by extnID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 其中`Name` "a valid X.501 distinguished name"(RFC 6487 §4.4)
|
||||||
|
|
||||||
|
### 3.2.2 证书扩展字段 (RFC 6487 §4.8)
|
||||||
|
|
||||||
|
RC的证书扩展字段按照RFC 6487 §4.8的规定,有以下几个扩展:
|
||||||
|
|
||||||
|
- Basic Constraints
|
||||||
|
- Subject Key Identifier
|
||||||
|
- Authority Key Identifier
|
||||||
|
- Key Usage
|
||||||
|
- Extended Key Usage(CA证书,以及验证RPKI对象的EE证书不能出现该字段。非RPKI对象的EE可以出现EKU,但必须为non-critical)
|
||||||
|
- CRL Distribution Points
|
||||||
|
- Authority Information Access
|
||||||
|
- Subject Information Access
|
||||||
|
- SIA for CA Certificates
|
||||||
|
- SIA for EE Certificates
|
||||||
|
- Certificate Policies
|
||||||
|
- IP Resources
|
||||||
|
- AS Resources
|
||||||
|
|
||||||
|
```
|
||||||
|
# Basic Constraints
|
||||||
|
id-ce-basicConstraints OBJECT IDENTIFIER ::= { id-ce 19 }
|
||||||
|
|
||||||
|
BasicConstraints ::= SEQUENCE {
|
||||||
|
cA BOOLEAN DEFAULT FALSE }
|
||||||
|
|
||||||
|
|
||||||
|
# Subject Key Identifier
|
||||||
|
id-ce-subjectKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 14 }
|
||||||
|
|
||||||
|
SubjectKeyIdentifier ::= KeyIdentifier
|
||||||
|
|
||||||
|
KeyIdentifier ::= OCTET STRING
|
||||||
|
|
||||||
|
|
||||||
|
# Authority Key Identifier
|
||||||
|
id-ce-authorityKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 35 }
|
||||||
|
|
||||||
|
AuthorityKeyIdentifier ::= SEQUENCE {
|
||||||
|
keyIdentifier [0] KeyIdentifier OPTIONAL }
|
||||||
|
|
||||||
|
|
||||||
|
# Key Usage
|
||||||
|
id-ce-keyUsage OBJECT IDENTIFIER ::= { id-ce 15 }
|
||||||
|
|
||||||
|
KeyUsage ::= BIT STRING {
|
||||||
|
digitalSignature (0),
|
||||||
|
nonRepudiation (1), -- recent editions of X.509 have
|
||||||
|
-- renamed this bit to contentCommitment
|
||||||
|
keyEncipherment (2),
|
||||||
|
dataEncipherment (3),
|
||||||
|
keyAgreement (4),
|
||||||
|
keyCertSign (5),
|
||||||
|
cRLSign (6),
|
||||||
|
encipherOnly (7),
|
||||||
|
decipherOnly (8) }
|
||||||
|
|
||||||
|
|
||||||
|
# Extended Key Usage
|
||||||
|
id-ce-extKeyUsage OBJECT IDENTIFIER ::= { id-ce 37 }
|
||||||
|
|
||||||
|
ExtKeyUsageSyntax ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId
|
||||||
|
|
||||||
|
KeyPurposeId ::= OBJECT IDENTIFIER
|
||||||
|
|
||||||
|
|
||||||
|
# CRL Distribution Points
|
||||||
|
id-ce-cRLDistributionPoints OBJECT IDENTIFIER ::= { id-ce 31 }
|
||||||
|
|
||||||
|
CRLDistributionPoints ::= SEQUENCE SIZE (1..MAX) OF DistributionPoint
|
||||||
|
|
||||||
|
DistributionPoint ::= SEQUENCE {
|
||||||
|
distributionPoint [0] DistributionPointName OPTIONAL }
|
||||||
|
|
||||||
|
DistributionPointName ::= CHOICE {
|
||||||
|
fullName [0] GeneralNames }
|
||||||
|
|
||||||
|
|
||||||
|
## Authority Information Access
|
||||||
|
id-pe-authorityInfoAccess OBJECT IDENTIFIER ::= { id-pe 1 }
|
||||||
|
|
||||||
|
AuthorityInfoAccessSyntax ::=
|
||||||
|
SEQUENCE SIZE (1..MAX) OF AccessDescription
|
||||||
|
|
||||||
|
AccessDescription ::= SEQUENCE {
|
||||||
|
accessMethod OBJECT IDENTIFIER,
|
||||||
|
accessLocation GeneralName }
|
||||||
|
|
||||||
|
# AccessDescription
|
||||||
|
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
|
||||||
|
# CA 证书发布位置
|
||||||
|
id-ad-caIssuers OBJECT IDENTIFIER ::= { id-ad 2 }
|
||||||
|
# OCSP 服务地址
|
||||||
|
id-ad-ocsp OBJECT IDENTIFIER ::= { id-ad 1 }
|
||||||
|
|
||||||
|
|
||||||
|
# Subject Information Access
|
||||||
|
id-pe-subjectInfoAccess OBJECT IDENTIFIER ::= { id-pe 11 }
|
||||||
|
|
||||||
|
SubjectInfoAccessSyntax ::= SEQUENCE SIZE (1..MAX) OF AccessDescription
|
||||||
|
AccessDescription ::= SEQUENCE {
|
||||||
|
accessMethod OBJECT IDENTIFIER,
|
||||||
|
accessLocation GeneralName }
|
||||||
|
|
||||||
|
## Subject Information Access for CA (RFC 6487 §4.8.8.1)
|
||||||
|
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
|
||||||
|
id-ad-rpkiManifest OBJECT IDENTIFIER ::= { id-ad 10 }
|
||||||
|
|
||||||
|
必须存在一个accessMethod=id-ad-caRepository,accessLocation=rsyncURI。
|
||||||
|
必须存在一个accessMethod=id-ad-repiManifest, accessLocation=rsync URI,指向该CA的mft对象。
|
||||||
|
|
||||||
|
## Subject Information Access for EE (RFC 6487 §4.8.8.2)
|
||||||
|
id-ad-signedObject OBJECT IDENTIFIER ::= { id-ad 11 }
|
||||||
|
|
||||||
|
必须存在一个accessMethod=id-ad-signedObject, accessLocation=rsyncURI
|
||||||
|
不允许其他的accessMethod
|
||||||
|
|
||||||
|
|
||||||
|
# Certificate Policies
|
||||||
|
id-ce-certificatePolicies OBJECT IDENTIFIER ::= { id-ce 32 }
|
||||||
|
anyPolicy OBJECT IDENTIFIER ::= { id-ce-certificatePolicies 0 }
|
||||||
|
|
||||||
|
certificatePolicies ::= SEQUENCE SIZE (1..MAX) OF PolicyInformation
|
||||||
|
|
||||||
|
PolicyInformation ::= SEQUENCE {
|
||||||
|
policyIdentifier CertPolicyId,
|
||||||
|
policyQualifiers SEQUENCE SIZE (1..MAX) OF PolicyQualifierInfo OPTIONAL }
|
||||||
|
|
||||||
|
CertPolicyId ::= OBJECT IDENTIFIER
|
||||||
|
|
||||||
|
PolicyQualifierInfo ::= SEQUENCE {
|
||||||
|
policyQualifierId PolicyQualifierId,
|
||||||
|
qualifier ANY DEFINED BY policyQualifierId }
|
||||||
|
|
||||||
|
-- policyQualifierIds for Internet policy qualifiers
|
||||||
|
id-qt OBJECT IDENTIFIER ::= { id-pkix 2 }
|
||||||
|
id-qt-cps OBJECT IDENTIFIER ::= { id-qt 1 }
|
||||||
|
id-qt-unotice OBJECT IDENTIFIER ::= { id-qt 2 }
|
||||||
|
|
||||||
|
PolicyQualifierId ::= OBJECT IDENTIFIER ( id-qt-cps | id-qt-unotice )
|
||||||
|
|
||||||
|
Qualifier ::= CHOICE {
|
||||||
|
cPSuri CPSuri,
|
||||||
|
userNotice UserNotice }
|
||||||
|
|
||||||
|
CPSuri ::= IA5String
|
||||||
|
|
||||||
|
UserNotice ::= SEQUENCE {
|
||||||
|
noticeRef NoticeReference OPTIONAL,
|
||||||
|
explicitText DisplayText OPTIONAL }
|
||||||
|
|
||||||
|
NoticeReference ::= SEQUENCE {
|
||||||
|
organization DisplayText,
|
||||||
|
noticeNumbers SEQUENCE OF INTEGER }
|
||||||
|
|
||||||
|
DisplayText ::= CHOICE {
|
||||||
|
ia5String IA5String (SIZE (1..200)),
|
||||||
|
visibleString VisibleString (SIZE (1..200)),
|
||||||
|
bmpString BMPString (SIZE (1..200)),
|
||||||
|
utf8String UTF8String (SIZE (1..200)) }
|
||||||
|
|
||||||
|
|
||||||
|
# IP Resources
|
||||||
|
id-pe-ipAddrBlocks OBJECT IDENTIFIER ::= { id-pe 7 }
|
||||||
|
|
||||||
|
IPAddrBlocks ::= SEQUENCE OF IPAddressFamily
|
||||||
|
|
||||||
|
IPAddressFamily ::= SEQUENCE { -- AFI & optional SAFI --
|
||||||
|
addressFamily OCTET STRING (SIZE (2..3)),
|
||||||
|
ipAddressChoice IPAddressChoice }
|
||||||
|
|
||||||
|
IPAddressChoice ::= CHOICE {
|
||||||
|
inherit NULL, -- inherit from issuer --
|
||||||
|
addressesOrRanges SEQUENCE OF IPAddressOrRange }
|
||||||
|
|
||||||
|
IPAddressOrRange ::= CHOICE {
|
||||||
|
addressPrefix IPAddress,
|
||||||
|
addressRange IPAddressRange }
|
||||||
|
|
||||||
|
IPAddressRange ::= SEQUENCE {
|
||||||
|
min IPAddress,
|
||||||
|
max IPAddress }
|
||||||
|
|
||||||
|
IPAddress ::= BIT STRING
|
||||||
|
|
||||||
|
|
||||||
|
# AS Resources
|
||||||
|
id-pe-autonomousSysIds OBJECT IDENTIFIER ::= { id-pe 8 }
|
||||||
|
ASIdentifiers ::= SEQUENCE {
|
||||||
|
asnum [0] EXPLICIT ASIdentifierChoice OPTIONAL,
|
||||||
|
rdi [1] EXPLICIT ASIdentifierChoice OPTIONAL}
|
||||||
|
|
||||||
|
ASIdentifierChoice ::= CHOICE {
|
||||||
|
inherit NULL, -- inherit from issuer --
|
||||||
|
asIdsOrRanges SEQUENCE OF ASIdOrRange }
|
||||||
|
|
||||||
|
ASIdOrRange ::= CHOICE {
|
||||||
|
id ASId,
|
||||||
|
range ASRange }
|
||||||
|
|
||||||
|
ASRange ::= SEQUENCE {
|
||||||
|
min ASId,
|
||||||
|
max ASId }
|
||||||
|
|
||||||
|
ASId ::= INTEGER
|
||||||
|
```
|
||||||
|
|
||||||
|
# 3.3 抽象数据结构
|
||||||
|
采用X509 Certificate + Resource + 约束校验的方式组合
|
||||||
|
|
||||||
|
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
||||||
|
|----------|---------------------|----------|---------|---------------|
|
||||||
|
| cert_der | Vec<u8> | 证书原始数据 | | |
|
||||||
|
| cert | X509Certificate | 基础X509证书 | | RFC 5280 §4.1 |
|
||||||
|
| resource | ResourceSet | 资源集合 | | |
|
||||||
|
|
||||||
|
|
||||||
|
# 3.4 约束规则
|
||||||
|
|
||||||
|
## 3.4.1 Cert约束校验规则
|
||||||
|
RFC 6487中规定的证书的字段参见[3.2.1 ](#321-基本语法rfc-5280-4rfc-6487-)
|
||||||
|
-
|
||||||
|
|
||||||
|
| 字段 | 语义 | 约束/解析规则 | RFC 引用 |
|
||||||
|
|-----------|-------|----------------------------------------------|--------------|
|
||||||
|
| version | 证书版本 | 必须是v3(值为2) | RFC6487 §4.1 |
|
||||||
|
| serial | 证书编号 | 同一个CA签发的证书编号必须唯一 | RFC6487 §4.2 |
|
||||||
|
| validity | 证书有效期 | notBefore:时间不能早于证书的生成时间。若时间段大于上级证书的有效期,也是有效的 | RFC6487 §4.6 |
|
||||||
|
|
||||||
|
|
||||||
|
## 3.4.2 Cert Extentions中字段的约束校验规则
|
||||||
|
RFC 6487中规定的扩展字段参见[3.2.2 ](#322-证书扩展字段-rfc-6487-48)
|
||||||
|
|
||||||
|
| 字段 | critical | 语义 | 约束/解析规则 | RFC 引用 |
|
||||||
|
|----------------------------|----------|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------|
|
||||||
|
| basicConstraints | Y | 证书类型 | CA证书:cA=TRUE; EE证书:cA=FALSE | RFC6487 §4.8.1 |
|
||||||
|
| subjectKeyIdentifier | N | 证书公钥 | SKI = SHA-1(DER-encoded SPKI bit string) | RFC6487 §4.8.2 |
|
||||||
|
| authorityKeyIdentifier | N | 父证书的公钥 | 字段只包含keyIdentifier,不能包含authorityCertIssuer和authorityCertSerialNumber;除了自签名CA外,其余证书必须出现。自签名CA若出现该字段,则等于SKI | RFC6487 §4.8.3 |
|
||||||
|
| keyUsage | Y | 证书公钥的用途权限 | CA证书:keyCertSign = TRUE, cRLSign = TRUE 其他都是FALSE。EE证书:digitalSignature = TRUE 其他都是FALSE | RFC6487 §4.8.4 |
|
||||||
|
| extendedKeyUsage | N | 扩展证书公钥的用途权限 | CA证书:不能出现EKU;验证 RPKI 对象的 EE 证书:不能出现EKU;非 RPKI 对象的 EE:可以出现EKU,但必须为non-critical. | RFC6487 §4.8.5 |
|
||||||
|
| cRLDistributionPoints | N | CRL的发布点位置 | 字段:distributionPoint,不能包含reasons、cRLIssuer。其中distributionPoint字段包含:fullName,不能包含nameRelativeToCRLIssuer。fullName的格式必须是URI。自签名证书禁止出现该字段。非自签名证书必须出现。一个CA只能有一个CRL。一个CRLDP只能包含一个distributionPoint。但一个distributionPoint字段中可以包含多于1个的URI,但必须包含rsync URI且必须是最新的。 | RFC6487 §4.8.6 |
|
||||||
|
| authorityInformationAccess | N | 签发者的发布点位置 | 除了自签名的CA,必须出现。自签名CA,禁止出现。推荐的URI访问方式是rsync,并且rsyncURI的话,必须指定accessMethod=id-ad-caIssuers | RFC6487 §4.8.7 |
|
||||||
|
| subjectInformationAccess | N | 发布点位置 | CA证书:必须存在。必须存在一个accessMethod=id-ad-caRepository,accessLocation=rsyncURI。必须存在一个accessMethod=id-ad-repiManifest,accessLocation=rsync URI,指向该CA的mft对象。 EE证书:必须存在。必须存在一个accessMethod=id-ad-signedObject,accessLocation=rsyncURI。不允许其他的accessMethod | RFC6487 §4.8.8 |
|
||||||
|
| certificatePolicies | Y | 证书策略 | 必须存在,并且只能存在一种策略:RFC 6484 — RPKI Certificate Policy (CP) | RFC6487 §4.8.9 |
|
||||||
|
| iPResources | Y | IP地址集合 | 所有的RPKI证书中必须包含IP Resources或者ASResources,或者两者都包含。 | RFC6487 §4.8.10 |
|
||||||
|
| aSResources | Y | ASN集合 | 所有的RPKI证书中必须包含IP Resources或者ASResources,或者两者都包含。 | RFC6487 §4.8.11 |
|
||||||
|
|
||||||
|
|
||||||
@@ -1,460 +0,0 @@
|
|||||||
# 03. RC(Resource Certificate:资源证书,CA/EE)
|
|
||||||
|
|
||||||
## 3.1 对象定位
|
|
||||||
|
|
||||||
资源证书(RC)是 X.509 v3 证书,遵循 PKIX profile(RFC 5280),并受 RPKI profile 进一步约束。RFC 6487 §4。
|
|
||||||
|
|
||||||
RC 在 RPKI 中至少分为两类语义用途:
|
|
||||||
|
|
||||||
- `CA 证书`:签发下级证书/CRL,并在 SIA 中声明发布点与 manifest。RFC 6487 §4.8.8.1。
|
|
||||||
- `EE 证书`:用于验证某个 RPKI Signed Object(如 ROA/MFT),在 SIA 中指向被验证对象。RFC 6487 §4.8.8.2。
|
|
||||||
|
|
||||||
## 3.2 原始载体与编码
|
|
||||||
|
|
||||||
- 载体:X.509 证书。
|
|
||||||
- 编码:DER。RFC 6487 §4(“valid X.509 public key certificate consistent with RFC 5280” + RPKI 限制)。
|
|
||||||
|
|
||||||
### 3.2.1 X.509 v3 证书基本语法(ASN.1;RFC 5280 §4.1)
|
|
||||||
|
|
||||||
资源证书在编码层面是 RFC 5280 定义的 X.509 v3 `Certificate`(DER),其中 `tbsCertificate` 携带主体字段与扩展集合(`Extensions`)。RFC 5280 §4.1。
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
Certificate ::= SEQUENCE {
|
|
||||||
tbsCertificate TBSCertificate,
|
|
||||||
signatureAlgorithm AlgorithmIdentifier,
|
|
||||||
signatureValue BIT STRING }
|
|
||||||
|
|
||||||
TBSCertificate ::= SEQUENCE {
|
|
||||||
version [0] EXPLICIT Version DEFAULT v1,
|
|
||||||
serialNumber CertificateSerialNumber,
|
|
||||||
signature AlgorithmIdentifier,
|
|
||||||
issuer Name,
|
|
||||||
validity Validity,
|
|
||||||
subject Name,
|
|
||||||
subjectPublicKeyInfo SubjectPublicKeyInfo,
|
|
||||||
issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL,
|
|
||||||
-- If present, version MUST be v2 or v3
|
|
||||||
subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL,
|
|
||||||
-- If present, version MUST be v2 or v3
|
|
||||||
extensions [3] EXPLICIT Extensions OPTIONAL
|
|
||||||
-- If present, version MUST be v3
|
|
||||||
}
|
|
||||||
|
|
||||||
Version ::= INTEGER { v1(0), v2(1), v3(2) }
|
|
||||||
|
|
||||||
CertificateSerialNumber ::= INTEGER
|
|
||||||
|
|
||||||
Validity ::= SEQUENCE {
|
|
||||||
notBefore Time,
|
|
||||||
notAfter Time }
|
|
||||||
|
|
||||||
Time ::= CHOICE {
|
|
||||||
utcTime UTCTime,
|
|
||||||
generalTime GeneralizedTime }
|
|
||||||
|
|
||||||
UniqueIdentifier ::= BIT STRING
|
|
||||||
|
|
||||||
SubjectPublicKeyInfo ::= SEQUENCE {
|
|
||||||
algorithm AlgorithmIdentifier,
|
|
||||||
subjectPublicKey BIT STRING }
|
|
||||||
|
|
||||||
Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension
|
|
||||||
|
|
||||||
Extension ::= SEQUENCE {
|
|
||||||
extnID OBJECT IDENTIFIER,
|
|
||||||
critical BOOLEAN DEFAULT FALSE,
|
|
||||||
extnValue OCTET STRING
|
|
||||||
-- contains the DER encoding of an ASN.1 value
|
|
||||||
-- corresponding to the extension type identified
|
|
||||||
-- by extnID
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2.2 AlgorithmIdentifier(ASN.1;RFC 5280 §4.1.1.2)
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
AlgorithmIdentifier ::= SEQUENCE {
|
|
||||||
algorithm OBJECT IDENTIFIER,
|
|
||||||
parameters ANY DEFINED BY algorithm OPTIONAL }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2.3 Name / DN 结构(ASN.1;RFC 5280 §4.1.2.4)
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
Name ::= CHOICE { -- only one possibility for now --
|
|
||||||
rdnSequence RDNSequence }
|
|
||||||
|
|
||||||
RDNSequence ::= SEQUENCE OF RelativeDistinguishedName
|
|
||||||
|
|
||||||
RelativeDistinguishedName ::=
|
|
||||||
SET SIZE (1..MAX) OF AttributeTypeAndValue
|
|
||||||
|
|
||||||
AttributeTypeAndValue ::= SEQUENCE {
|
|
||||||
type AttributeType,
|
|
||||||
value AttributeValue }
|
|
||||||
|
|
||||||
AttributeType ::= OBJECT IDENTIFIER
|
|
||||||
|
|
||||||
AttributeValue ::= ANY -- DEFINED BY AttributeType
|
|
||||||
|
|
||||||
DirectoryString ::= CHOICE {
|
|
||||||
teletexString TeletexString (SIZE (1..MAX)),
|
|
||||||
printableString PrintableString (SIZE (1..MAX)),
|
|
||||||
universalString UniversalString (SIZE (1..MAX)),
|
|
||||||
utf8String UTF8String (SIZE (1..MAX)),
|
|
||||||
bmpString BMPString (SIZE (1..MAX)) }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2.4 GeneralNames / GeneralName(ASN.1;RFC 5280 §4.2.1.6)
|
|
||||||
|
|
||||||
> 说明:RPKI 的 AIA/SIA/CRLDP 等扩展通常把 URI 编码在 `uniformResourceIdentifier [6] IA5String` 分支中。RFC 5280 §4.2.1.6。
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName
|
|
||||||
|
|
||||||
GeneralName ::= CHOICE {
|
|
||||||
otherName [0] OtherName,
|
|
||||||
rfc822Name [1] IA5String,
|
|
||||||
dNSName [2] IA5String,
|
|
||||||
x400Address [3] ORAddress,
|
|
||||||
directoryName [4] Name,
|
|
||||||
ediPartyName [5] EDIPartyName,
|
|
||||||
uniformResourceIdentifier [6] IA5String,
|
|
||||||
iPAddress [7] OCTET STRING,
|
|
||||||
registeredID [8] OBJECT IDENTIFIER }
|
|
||||||
|
|
||||||
OtherName ::= SEQUENCE {
|
|
||||||
type-id OBJECT IDENTIFIER,
|
|
||||||
value [0] EXPLICIT ANY DEFINED BY type-id }
|
|
||||||
|
|
||||||
EDIPartyName ::= SEQUENCE {
|
|
||||||
nameAssigner [0] DirectoryString OPTIONAL,
|
|
||||||
partyName [1] DirectoryString }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2.5 AIA(Authority Information Access;ASN.1;RFC 5280 §4.2.2.1)
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
id-pe-authorityInfoAccess OBJECT IDENTIFIER ::= { id-pe 1 }
|
|
||||||
|
|
||||||
AuthorityInfoAccessSyntax ::=
|
|
||||||
SEQUENCE SIZE (1..MAX) OF AccessDescription
|
|
||||||
|
|
||||||
AccessDescription ::= SEQUENCE {
|
|
||||||
accessMethod OBJECT IDENTIFIER,
|
|
||||||
accessLocation GeneralName }
|
|
||||||
|
|
||||||
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
|
|
||||||
|
|
||||||
id-ad-caIssuers OBJECT IDENTIFIER ::= { id-ad 2 }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2.6 SIA(Subject Information Access;ASN.1;RFC 5280 §4.2.2.2)
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
id-pe-subjectInfoAccess OBJECT IDENTIFIER ::= { id-pe 11 }
|
|
||||||
|
|
||||||
SubjectInfoAccessSyntax ::=
|
|
||||||
SEQUENCE SIZE (1..MAX) OF AccessDescription
|
|
||||||
|
|
||||||
AccessDescription ::= SEQUENCE {
|
|
||||||
accessMethod OBJECT IDENTIFIER,
|
|
||||||
accessLocation GeneralName }
|
|
||||||
|
|
||||||
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
|
|
||||||
|
|
||||||
id-ad-caRepository OBJECT IDENTIFIER ::= { id-ad 5 }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2.7 RPKI 在 SIA 中新增/使用的 accessMethod OID(RFC 6487 §4.8.8.1 / §4.8.8.2;RFC 8182 §3.2)
|
|
||||||
|
|
||||||
> 说明:下列 OID 用于 `AccessDescription.accessMethod`,并放在 SIA 的 `extnValue` 内层结构中(其外层 extnID 仍为 SIA:`id-pe-subjectInfoAccess`)。RFC 6487 §4.8.8;RFC 8182 §3.2。
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
|
|
||||||
|
|
||||||
id-ad-rpkiManifest OBJECT IDENTIFIER ::= { id-ad 10 } -- 1.3.6.1.5.5.7.48.10
|
|
||||||
|
|
||||||
id-ad-signedObject OBJECT IDENTIFIER ::= { id-ad 11 } -- 1.3.6.1.5.5.7.48.11
|
|
||||||
|
|
||||||
id-ad-rpkiNotify OBJECT IDENTIFIER ::= { id-ad 13 } -- 1.3.6.1.5.5.7.48.13
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2.8 CRLDistributionPoints(CRLDP;ASN.1;RFC 5280 §4.2.1.13)
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
id-ce-cRLDistributionPoints OBJECT IDENTIFIER ::= { id-ce 31 }
|
|
||||||
|
|
||||||
CRLDistributionPoints ::= SEQUENCE SIZE (1..MAX) OF DistributionPoint
|
|
||||||
|
|
||||||
DistributionPoint ::= SEQUENCE {
|
|
||||||
distributionPoint [0] DistributionPointName OPTIONAL,
|
|
||||||
reasons [1] ReasonFlags OPTIONAL,
|
|
||||||
cRLIssuer [2] GeneralNames OPTIONAL }
|
|
||||||
|
|
||||||
DistributionPointName ::= CHOICE {
|
|
||||||
fullName [0] GeneralNames,
|
|
||||||
nameRelativeToCRLIssuer [1] RelativeDistinguishedName }
|
|
||||||
|
|
||||||
ReasonFlags ::= BIT STRING {
|
|
||||||
unused (0),
|
|
||||||
keyCompromise (1),
|
|
||||||
cACompromise (2),
|
|
||||||
affiliationChanged (3),
|
|
||||||
superseded (4),
|
|
||||||
cessationOfOperation (5),
|
|
||||||
certificateHold (6),
|
|
||||||
privilegeWithdrawn (7),
|
|
||||||
aACompromise (8) }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2.9 Certificate Policies(ASN.1;RFC 5280 §4.2.1.4)
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
id-ce-certificatePolicies OBJECT IDENTIFIER ::= { id-ce 32 }
|
|
||||||
|
|
||||||
anyPolicy OBJECT IDENTIFIER ::= { id-ce-certificatePolicies 0 }
|
|
||||||
|
|
||||||
certificatePolicies ::= SEQUENCE SIZE (1..MAX) OF PolicyInformation
|
|
||||||
|
|
||||||
PolicyInformation ::= SEQUENCE {
|
|
||||||
policyIdentifier CertPolicyId,
|
|
||||||
policyQualifiers SEQUENCE SIZE (1..MAX) OF
|
|
||||||
PolicyQualifierInfo OPTIONAL }
|
|
||||||
|
|
||||||
CertPolicyId ::= OBJECT IDENTIFIER
|
|
||||||
|
|
||||||
PolicyQualifierInfo ::= SEQUENCE {
|
|
||||||
policyQualifierId PolicyQualifierId,
|
|
||||||
qualifier ANY DEFINED BY policyQualifierId }
|
|
||||||
|
|
||||||
-- policyQualifierIds for Internet policy qualifiers
|
|
||||||
|
|
||||||
id-qt OBJECT IDENTIFIER ::= { id-pkix 2 }
|
|
||||||
id-qt-cps OBJECT IDENTIFIER ::= { id-qt 1 }
|
|
||||||
id-qt-unotice OBJECT IDENTIFIER ::= { id-qt 2 }
|
|
||||||
|
|
||||||
PolicyQualifierId ::= OBJECT IDENTIFIER ( id-qt-cps | id-qt-unotice )
|
|
||||||
|
|
||||||
Qualifier ::= CHOICE {
|
|
||||||
cPSuri CPSuri,
|
|
||||||
userNotice UserNotice }
|
|
||||||
|
|
||||||
CPSuri ::= IA5String
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2.10 RFC 3779 IP/AS 资源扩展(ASN.1;RFC 3779 §2.2.1-§2.2.3;RFC 3779 §3.2.1-§3.2.3)
|
|
||||||
|
|
||||||
> 说明:RFC 3779 给出两个扩展的 OID 与 ASN.1 语法;它们作为 X.509 v3 扩展出现在 `extensions` 中(外层 extnID 为下列 OID)。RPKI profile 进一步约束 criticality/SAFI/RDI 等,见 RFC 6487 §4.8.10-§4.8.11。
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
-- IP Address Delegation Extension
|
|
||||||
id-pe-ipAddrBlocks OBJECT IDENTIFIER ::= { id-pe 7 }
|
|
||||||
|
|
||||||
IPAddrBlocks ::= SEQUENCE OF IPAddressFamily
|
|
||||||
|
|
||||||
IPAddressFamily ::= SEQUENCE { -- AFI & optional SAFI --
|
|
||||||
addressFamily OCTET STRING (SIZE (2..3)),
|
|
||||||
ipAddressChoice IPAddressChoice }
|
|
||||||
|
|
||||||
IPAddressChoice ::= CHOICE {
|
|
||||||
inherit NULL, -- inherit from issuer --
|
|
||||||
addressesOrRanges SEQUENCE OF IPAddressOrRange }
|
|
||||||
|
|
||||||
IPAddressOrRange ::= CHOICE {
|
|
||||||
addressPrefix IPAddress,
|
|
||||||
addressRange IPAddressRange }
|
|
||||||
|
|
||||||
IPAddressRange ::= SEQUENCE {
|
|
||||||
min IPAddress,
|
|
||||||
max IPAddress }
|
|
||||||
|
|
||||||
IPAddress ::= BIT STRING
|
|
||||||
|
|
||||||
-- Autonomous System Identifier Delegation Extension
|
|
||||||
id-pe-autonomousSysIds OBJECT IDENTIFIER ::= { id-pe 8 }
|
|
||||||
|
|
||||||
ASIdentifiers ::= SEQUENCE {
|
|
||||||
asnum [0] EXPLICIT ASIdentifierChoice OPTIONAL,
|
|
||||||
rdi [1] EXPLICIT ASIdentifierChoice OPTIONAL}
|
|
||||||
|
|
||||||
ASIdentifierChoice ::= CHOICE {
|
|
||||||
inherit NULL, -- inherit from issuer --
|
|
||||||
asIdsOrRanges SEQUENCE OF ASIdOrRange }
|
|
||||||
|
|
||||||
ASIdOrRange ::= CHOICE {
|
|
||||||
id ASId,
|
|
||||||
range ASRange }
|
|
||||||
|
|
||||||
ASRange ::= SEQUENCE {
|
|
||||||
min ASId,
|
|
||||||
max ASId }
|
|
||||||
|
|
||||||
ASId ::= INTEGER
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2.11 其它 RPKI profile 相关扩展的 ASN.1 定义(RFC 5280 §4.2.1.1-§4.2.1.3;RFC 5280 §4.2.1.9;RFC 5280 §4.2.1.12)
|
|
||||||
|
|
||||||
> 说明:这些是 RPKI 资源证书 profile(RFC 6487 §4.8)所引用的通用 PKIX 扩展语法。RPKI 对其“必须/禁止/criticality/字段允许性”有额外限制(见本文件 3.3/3.4),但编码层的 ASN.1 类型来自 RFC 5280。
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
id-ce-authorityKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 35 }
|
|
||||||
|
|
||||||
AuthorityKeyIdentifier ::= SEQUENCE {
|
|
||||||
keyIdentifier [0] KeyIdentifier OPTIONAL,
|
|
||||||
authorityCertIssuer [1] GeneralNames OPTIONAL,
|
|
||||||
authorityCertSerialNumber [2] CertificateSerialNumber OPTIONAL }
|
|
||||||
|
|
||||||
KeyIdentifier ::= OCTET STRING
|
|
||||||
|
|
||||||
id-ce-subjectKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 14 }
|
|
||||||
|
|
||||||
SubjectKeyIdentifier ::= KeyIdentifier
|
|
||||||
|
|
||||||
id-ce-keyUsage OBJECT IDENTIFIER ::= { id-ce 15 }
|
|
||||||
|
|
||||||
KeyUsage ::= BIT STRING {
|
|
||||||
digitalSignature (0),
|
|
||||||
nonRepudiation (1), -- recent editions of X.509 have
|
|
||||||
-- renamed this bit to contentCommitment
|
|
||||||
keyEncipherment (2),
|
|
||||||
dataEncipherment (3),
|
|
||||||
keyAgreement (4),
|
|
||||||
keyCertSign (5),
|
|
||||||
cRLSign (6),
|
|
||||||
encipherOnly (7),
|
|
||||||
decipherOnly (8) }
|
|
||||||
|
|
||||||
id-ce-basicConstraints OBJECT IDENTIFIER ::= { id-ce 19 }
|
|
||||||
|
|
||||||
BasicConstraints ::= SEQUENCE {
|
|
||||||
cA BOOLEAN DEFAULT FALSE,
|
|
||||||
pathLenConstraint INTEGER (0..MAX) OPTIONAL }
|
|
||||||
|
|
||||||
id-ce-extKeyUsage OBJECT IDENTIFIER ::= { id-ce 37 }
|
|
||||||
|
|
||||||
ExtKeyUsageSyntax ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId
|
|
||||||
|
|
||||||
KeyPurposeId ::= OBJECT IDENTIFIER
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3.3 抽象数据模型(接口)
|
|
||||||
|
|
||||||
> 说明:本模型面向“语义化解析产物”。实现可保留 `raw_der` 作为可追溯入口。
|
|
||||||
|
|
||||||
### 3.3.1 顶层联合类型:`ResourceCertificate`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `raw_der` | `DerBytes` | 证书 DER | 原样保留(建议) | RFC 6487 §4 |
|
|
||||||
| `tbs` | `RpkixTbsCertificate` | 证书语义字段(见下) | 仅允许 RFC 6487 允许的字段/扩展;其他字段 MUST NOT 出现 | RFC 6487 §4 |
|
|
||||||
| `kind` | `enum { ca, ee }` | 语义分类 | 来自 BasicConstraints + 用途约束 | RFC 6487 §4.8.1;RFC 6487 §4.8.8 |
|
|
||||||
|
|
||||||
### 3.3.1.1 派生类型(用于字段类型标注)
|
|
||||||
|
|
||||||
为避免在其它对象文档里反复写“`ResourceCertificate` 且 `kind==...`”,这里定义两个派生/别名类型:
|
|
||||||
|
|
||||||
- `ResourceCaCertificate`:`ResourceCertificate` 且 `kind == ca`
|
|
||||||
- `ResourceEeCertificate`:`ResourceCertificate` 且 `kind == ee`
|
|
||||||
|
|
||||||
这些派生类型不引入新字段,只是对 `ResourceCertificate.kind` 的约束化视图。
|
|
||||||
|
|
||||||
### 3.3.2 `RpkixTbsCertificate`(语义字段集合)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `version` | `int` | X.509 版本 | MUST 为 v3(字段值为 2) | RFC 6487 §4.1 |
|
|
||||||
| `serial_number` | `int` | 序列号 | 正整数;对每 CA 签发唯一 | RFC 6487 §4.2 |
|
|
||||||
| `signature_algorithm` | `Oid` | 证书签名算法 | 必须为 `sha256WithRSAEncryption`(`1.2.840.113549.1.1.11`) | RFC 6487 §4.3;RFC 7935 §2(引用 RFC 4055) |
|
|
||||||
| `issuer_dn` | `RpkixDistinguishedName` | 颁发者 DN | 必含 1 个 CommonName;可含 1 个 serialNumber;CN 必须 PrintableString | RFC 6487 §4.4 |
|
|
||||||
| `subject_dn` | `RpkixDistinguishedName` | 主体 DN | 同 issuer 约束;且对同一 issuer 下“实体+公钥”唯一 | RFC 6487 §4.5 |
|
|
||||||
| `validity_not_before` | `UtcTime` | 有效期起 | X.509 `Time`(UTCTime/GeneralizedTime)解析为 UTC 时间点 | RFC 6487 §4.6.1;RFC 5280 §4.1.2.5 |
|
|
||||||
| `validity_not_after` | `UtcTime` | 有效期止 | X.509 `Time`(UTCTime/GeneralizedTime)解析为 UTC 时间点 | RFC 6487 §4.6.2;RFC 5280 §4.1.2.5 |
|
|
||||||
| `subject_public_key_info` | `DerBytes` | SPKI DER | 算法 profile 指定 | RFC 6487 §4.7;RFC 7935 §3.1 |
|
|
||||||
| `extensions` | `RpkixExtensions` | 扩展集合 | 见下表;criticality/存在性/内容受约束 | RFC 6487 §4.8 |
|
|
||||||
|
|
||||||
### 3.3.3 `RpkixDistinguishedName`(RPKI profile 下的 DN 语义)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `common_name` | `string` | CommonName (CN) | MUST 存在且仅 1 个;类型为 PrintableString | RFC 6487 §4.4;RFC 6487 §4.5 |
|
|
||||||
| `serial_number` | `optional[string]` | serialNumber | MAY 存在且仅 1 个 | RFC 6487 §4.4;RFC 6487 §4.5 |
|
|
||||||
| `rfc4514` | `string` | DN 的 RFC4514 字符串表示 | 便于日志/索引(实现自选) | RFC 6487 §4.5(引用 RFC4514) |
|
|
||||||
|
|
||||||
### 3.3.4 `RpkixExtensions`(核心扩展语义)
|
|
||||||
|
|
||||||
> 表中 “存在性/criticality” 指 RPKI profile 下对该扩展的要求;实现应能区分 “字段缺失” 与 “字段存在但不符合约束”。
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 存在性/criticality 与内容约束 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `basic_constraints` | `optional[BasicConstraints]` | CA 标志 | **extnID=`2.5.29.19`**;CA 证书:MUST present & critical;EE:MUST NOT present;pathLen MUST NOT present | RFC 6487 §4.8.1;RFC 5280 §4.2.1.9 |
|
|
||||||
| `subject_key_identifier` | `bytes` | SKI | **extnID=`2.5.29.14`**;MUST present & non-critical;值为 subjectPublicKey 的 DER bit string 的 SHA-1 哈希 | RFC 6487 §4.8.2(引用 RFC 5280 §4.2.1.2) |
|
|
||||||
| `authority_key_identifier` | `optional[AuthorityKeyIdentifier]` | AKI | **extnID=`2.5.29.35`**;自签名:MAY present 且可等于 SKI;非自签名:MUST present;authorityCertIssuer/authorityCertSerialNumber MUST NOT present;non-critical | RFC 6487 §4.8.3;RFC 5280 §4.2.1.1 |
|
|
||||||
| `key_usage` | `KeyUsage` | KeyUsage | **extnID=`2.5.29.15`**;MUST present & critical;CA:仅 `keyCertSign` 与 `cRLSign` 为 TRUE;EE:仅 `digitalSignature` 为 TRUE | RFC 6487 §4.8.4;RFC 5280 §4.2.1.3 |
|
|
||||||
| `extended_key_usage` | `optional[OidSet]` | EKU | **extnID=`2.5.29.37`**;CA:MUST NOT appear;用于验证 RPKI 对象的 EE:MUST NOT appear;若出现不得标 critical | RFC 6487 §4.8.5;RFC 5280 §4.2.1.12 |
|
|
||||||
| `crl_distribution_points` | `optional[CrlDistributionPoints]` | CRLDP | **extnID=`2.5.29.31`**;自签名:MUST be omitted;非自签名:MUST present & non-critical;仅 1 个 DistributionPoint;fullName URI;必须包含至少 1 个 `rsync://` | RFC 6487 §4.8.6;RFC 5280 §4.2.1.13 |
|
|
||||||
| `authority_info_access` | `optional[AuthorityInfoAccess]` | AIA | **extnID=`1.3.6.1.5.5.7.1.1`**;自签名:MUST be omitted;非自签名:MUST present & non-critical;必须含 accessMethod `id-ad-caIssuers`(**`1.3.6.1.5.5.7.48.2`**) 的 `rsync://` URI;可含同对象其它 URI | RFC 6487 §4.8.7;RFC 5280 §4.2.2.1 |
|
|
||||||
| `subject_info_access_ca` | `optional[SubjectInfoAccessCa]` | SIA(CA) | **extnID=`1.3.6.1.5.5.7.1.11`**;CA:MUST present & non-critical;必须含 accessMethod `id-ad-caRepository`(**`1.3.6.1.5.5.7.48.5`**)(`rsync://` 目录 URI)与 `id-ad-rpkiManifest`(**`1.3.6.1.5.5.7.48.10`**)(`rsync://` 对象 URI);若 CA 使用 RRDP,还会包含 `id-ad-rpkiNotify`(**`1.3.6.1.5.5.7.48.13`**)(HTTPS Notification URI) | RFC 6487 §4.8.8.1;RFC 5280 §4.2.2.2;RFC 8182 §3.2 |
|
|
||||||
| `subject_info_access_ee` | `optional[SubjectInfoAccessEe]` | SIA(EE) | **extnID=`1.3.6.1.5.5.7.1.11`**;EE:MUST present & non-critical;必须含 accessMethod `id-ad-signedObject`(**`1.3.6.1.5.5.7.48.11`**);URI **MUST include** `rsync://`;EE 的 SIA 不允许其它 AccessMethods | RFC 6487 §4.8.8.2;RFC 5280 §4.2.2.2 |
|
|
||||||
| `certificate_policies` | `CertificatePolicies` | 证书策略 | **extnID=`2.5.29.32`**;MUST present & critical;恰好 1 个 policy;并允许 0 或 1 个 CPS qualifier(若存在其 id 必为 `id-qt-cps`(**`1.3.6.1.5.5.7.2.1`**)) | RFC 6487 §4.8.9;RFC 7318 §2;RFC 5280 §4.2.1.4 |
|
|
||||||
| `ip_resources` | `optional[IpResourceSet]` | IP 资源扩展 | **extnID=`1.3.6.1.5.5.7.1.7`**;IP/AS 两者至少其一 MUST present;若 present MUST be critical;内容为 RFC 3779 语义;在公用互联网场景 SAFI MUST NOT 使用;且必须为非空或 inherit | RFC 6487 §4.8.10;RFC 3779 §2.2.1;RFC 3779 §2.2.2 |
|
|
||||||
| `as_resources` | `optional[AsResourceSet]` | AS 资源扩展 | **extnID=`1.3.6.1.5.5.7.1.8`**;IP/AS 两者至少其一 MUST present;若 present MUST be critical;内容为 RFC 3779 语义;RDI MUST NOT 使用;且必须为非空或 inherit | RFC 6487 §4.8.11;RFC 3779 §3.2.1;RFC 3779 §3.2.2 |
|
|
||||||
|
|
||||||
### 3.3.5 结构化子类型(建议)
|
|
||||||
|
|
||||||
#### `BasicConstraints`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `ca` | `bool` | 是否 CA | 由 issuer 决定;在 CA 证书中该扩展必须存在 | RFC 6487 §4.8.1 |
|
|
||||||
| `path_len_constraint` | `None` | pathLenConstraint | MUST NOT present(RPKI profile 不使用) | RFC 6487 §4.8.1 |
|
|
||||||
|
|
||||||
#### `AuthorityKeyIdentifier`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `key_identifier` | `bytes` | AKI.keyIdentifier | 使用 issuer 公钥的 SHA-1 哈希(按 RFC 5280 的定义) | RFC 6487 §4.8.3(引用 RFC 5280 §4.2.1.1) |
|
|
||||||
| `authority_cert_issuer` | `None` | authorityCertIssuer | MUST NOT present | RFC 6487 §4.8.3 |
|
|
||||||
| `authority_cert_serial_number` | `None` | authorityCertSerialNumber | MUST NOT present | RFC 6487 §4.8.3 |
|
|
||||||
|
|
||||||
#### `CrlDistributionPoints`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `distribution_point_uris` | `list[Uri]` | CRL 位置列表 | 仅 1 个 DistributionPoint;必须包含至少 1 个 `rsync://` URI 指向该 issuer 最新 CRL;可含其它 URI | RFC 6487 §4.8.6 |
|
|
||||||
|
|
||||||
#### `AuthorityInfoAccess`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `ca_issuers_uris` | `list[Uri]` | 上级 CA 证书位置 | accessMethod=`id-ad-caIssuers`(`1.3.6.1.5.5.7.48.2`);必含 `rsync://` URI;可含同对象其它 URI | RFC 6487 §4.8.7;RFC 5280 §4.2.2.1 |
|
|
||||||
|
|
||||||
#### `SubjectInfoAccessCa`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `ca_repository_uris` | `list[Uri]` | CA 发布点目录(repository publication point) | accessMethod=`id-ad-caRepository`(`1.3.6.1.5.5.7.48.5`);至少 1 个;必须包含 `rsync://`;也可包含其它机制(例如 `https://`)作为“同一目录”的替代访问方式;顺序表示 CA 偏好 | RFC 6487 §4.8.8.1;RFC 5280 §4.2.2.2 |
|
|
||||||
| `rpki_manifest_uris` | `list[Uri]` | 当前 manifest 对象 URI | accessMethod=`id-ad-rpkiManifest`(`1.3.6.1.5.5.7.48.10`);至少 1 个;必须包含 `rsync://`;也可包含其它机制(例如 `https://`)作为“同一对象”的替代访问方式 | RFC 6487 §4.8.8.1;RFC 5280 §4.2.2.2 |
|
|
||||||
| `rpki_notify_uris` | `optional[list[Uri]]` | RRDP Notification(Update Notification File)URI | accessMethod=`id-ad-rpkiNotify`(`1.3.6.1.5.5.7.48.13`);若存在则 accessLocation MUST 为 `https://` URI,指向 RRDP Notification 文件 | RFC 8182 §3.2;RFC 5280 §4.2.2.2 |
|
|
||||||
|
|
||||||
#### `SubjectInfoAccessEe`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `signed_object_uris` | `list[Uri]` | 被 EE 证书验证的签名对象位置 | accessMethod=`id-ad-signedObject`(`1.3.6.1.5.5.7.48.11`);必须包含 `rsync://`;其它 URI 可作为同对象替代机制;EE SIA 不允许其它 AccessMethods | RFC 6487 §4.8.8.2;RFC 5280 §4.2.2.2 |
|
|
||||||
|
|
||||||
#### `CertificatePolicies`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `policy_oid` | `Oid` | 唯一 policy OID | 恰好 1 个 policy;RPKI CP 分配的 OID 为 `id-cp-ipAddr-asNumber`(`1.3.6.1.5.5.7.14.2`) | RFC 6487 §4.8.9;RFC 6484 §1.2 |
|
|
||||||
| `cps_uri` | `optional[Uri]` | CPS policy qualifier URI | MAY 存在且最多 1 个;若存在其 `policyQualifierId` 必为 `id-qt-cps`;对该 URI 不施加处理要求 | RFC 7318 §2;RFC 5280 §4.2.1.4 |
|
|
||||||
|
|
||||||
## 3.4 字段级约束清单(实现对照)
|
|
||||||
|
|
||||||
- 仅允许 RFC 6487 §4 指定的字段/扩展;未列出字段 MUST NOT 出现。RFC 6487 §4。
|
|
||||||
- 证书版本必须为 v3。RFC 6487 §4.1。
|
|
||||||
- CA/EE 在 BasicConstraints 与 SIA 的约束不同。RFC 6487 §4.8.1;RFC 6487 §4.8.8.1;RFC 6487 §4.8.8.2。
|
|
||||||
- KeyUsage:CA 仅 `keyCertSign`/`cRLSign`;EE 仅 `digitalSignature`。RFC 6487 §4.8.4。
|
|
||||||
- CRLDP/AIA:自签名必须省略;非自签名必须存在并包含 `rsync://`。RFC 6487 §4.8.6;RFC 6487 §4.8.7。
|
|
||||||
- IP/AS 资源扩展:两者至少其一存在;若存在必须 critical;语义来自 RFC 3779;在公用互联网场景 SAFI 与 RDI 均不得使用。RFC 6487 §4.8.10;RFC 6487 §4.8.11;RFC 3779 §2.2.3;RFC 3779 §3.2.3。
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# 05. RPKI Signed Object(CMS SignedData 外壳)
|
|
||||||
|
|
||||||
## 5.1 对象定位
|
|
||||||
|
|
||||||
ROA、Manifest 等都属于 “RPKI Signed Object”,其外壳是 CMS SignedData,并受 RFC 6488 的 profile 约束;RFC 9589 进一步更新了 `signedAttrs` 的要求。RFC 6488 §2-§4;RFC 9589 §4。
|
|
||||||
|
|
||||||
本文件描述**通用外壳模型**(eContentType/eContent 由具体对象文档给出)。
|
|
||||||
|
|
||||||
## 5.2 原始载体与编码
|
|
||||||
|
|
||||||
- 载体:CMS `ContentInfo`,其中 `contentType` 为 SignedData。RFC 6488 §2;RFC 6488 §3(1a)。
|
|
||||||
- 编码:DER。RFC 6488 §2;RFC 6488 §3(1l)。
|
|
||||||
|
|
||||||
### 5.2.1 CMS 外壳:ContentInfo(ASN.1;RFC 5652 §3)
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
ContentInfo ::= SEQUENCE {
|
|
||||||
contentType ContentType,
|
|
||||||
content [0] EXPLICIT ANY DEFINED BY contentType }
|
|
||||||
|
|
||||||
ContentType ::= OBJECT IDENTIFIER
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2.2 CMS 外壳:SignedData(ASN.1;RFC 5652 §5.1)
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
id-signedData OBJECT IDENTIFIER ::= { iso(1) member-body(2)
|
|
||||||
us(840) rsadsi(113549) pkcs(1) pkcs7(7) 2 }
|
|
||||||
|
|
||||||
SignedData ::= SEQUENCE {
|
|
||||||
version CMSVersion,
|
|
||||||
digestAlgorithms DigestAlgorithmIdentifiers,
|
|
||||||
encapContentInfo EncapsulatedContentInfo,
|
|
||||||
certificates [0] IMPLICIT CertificateSet OPTIONAL,
|
|
||||||
crls [1] IMPLICIT RevocationInfoChoices OPTIONAL,
|
|
||||||
signerInfos SignerInfos }
|
|
||||||
|
|
||||||
DigestAlgorithmIdentifiers ::= SET OF DigestAlgorithmIdentifier
|
|
||||||
|
|
||||||
SignerInfos ::= SET OF SignerInfo
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2.3 CMS 外壳:EncapsulatedContentInfo(ASN.1;RFC 5652 §5.2)
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
EncapsulatedContentInfo ::= SEQUENCE {
|
|
||||||
eContentType ContentType,
|
|
||||||
eContent [0] EXPLICIT OCTET STRING OPTIONAL }
|
|
||||||
|
|
||||||
ContentType ::= OBJECT IDENTIFIER
|
|
||||||
```
|
|
||||||
|
|
||||||
> 注:CMS 允许 `eContent` 不一定 DER 编码(RFC 5652 §5.2);但 RPKI signed object profile 要求**整个对象 DER 编码**(RFC 6488 §2;RFC 6488 §3(1l)),且 eContent(payload)由对象规范定义并通常为 DER(如 ROA:RFC 9582 §4;Manifest:RFC 9286 §4.2)。
|
|
||||||
|
|
||||||
### 5.2.4 CMS 外壳:SignerInfo 与 Attribute(ASN.1;RFC 5652 §5.3)
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
SignerInfo ::= SEQUENCE {
|
|
||||||
version CMSVersion,
|
|
||||||
sid SignerIdentifier,
|
|
||||||
digestAlgorithm DigestAlgorithmIdentifier,
|
|
||||||
signedAttrs [0] IMPLICIT SignedAttributes OPTIONAL,
|
|
||||||
signatureAlgorithm SignatureAlgorithmIdentifier,
|
|
||||||
signature SignatureValue,
|
|
||||||
unsignedAttrs [1] IMPLICIT UnsignedAttributes OPTIONAL }
|
|
||||||
|
|
||||||
SignerIdentifier ::= CHOICE {
|
|
||||||
issuerAndSerialNumber IssuerAndSerialNumber,
|
|
||||||
subjectKeyIdentifier [0] SubjectKeyIdentifier }
|
|
||||||
|
|
||||||
SignedAttributes ::= SET SIZE (1..MAX) OF Attribute
|
|
||||||
|
|
||||||
UnsignedAttributes ::= SET SIZE (1..MAX) OF Attribute
|
|
||||||
|
|
||||||
Attribute ::= SEQUENCE {
|
|
||||||
attrType OBJECT IDENTIFIER,
|
|
||||||
attrValues SET OF AttributeValue }
|
|
||||||
|
|
||||||
AttributeValue ::= ANY
|
|
||||||
|
|
||||||
SignatureValue ::= OCTET STRING
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2.5 RPKI 对 CMS 外壳字段的 profile 约束(RFC 6488 §2.1;RFC 6488 §3;更新:RFC 9589 §4)
|
|
||||||
|
|
||||||
> 说明:上面是 CMS 的通用 ASN.1;RPKI 进一步约束取值与允许出现的字段(例如 SignedData.version 必须为 3、crls 必须省略、signedAttrs 的内容限制等)。RFC 6488 §2-§3;RFC 9589 §4。
|
|
||||||
|
|
||||||
### 5.2.6 signedAttrs 中允许的属性与 attrType OID(RFC 6488 §2.1.6.4.1-§2.1.6.4.2;更新:RFC 9589 §4)
|
|
||||||
|
|
||||||
RPKI signed object profile 对 `SignerInfo.signedAttrs` 的 Attribute 集合施加限制(除 ASN.1 结构外,还包含“只允许哪些 attrType”的编码约束):
|
|
||||||
|
|
||||||
- `content-type`:attrType OID `1.2.840.113549.1.9.3`。RFC 6488 §2.1.6.4.1。
|
|
||||||
- `message-digest`:attrType OID `1.2.840.113549.1.9.4`。RFC 6488 §2.1.6.4.2。
|
|
||||||
- `signing-time`:attrType OID `1.2.840.113549.1.9.5`。RFC 9589 §4(更新 RFC 6488 的相关要求)。
|
|
||||||
|
|
||||||
并且:
|
|
||||||
|
|
||||||
- 每种属性在集合中只能出现一次;且 `attrValues` 虽然语法是 `SET OF`,但在 RPKI 中必须只含一个值。RFC 6488 §2.1.6.4。
|
|
||||||
|
|
||||||
## 5.3 抽象数据模型(接口)
|
|
||||||
|
|
||||||
### 5.3.1 `RpkiSignedObject`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `raw_der` | `DerBytes` | CMS DER | 原样保留(建议) | RFC 6488 §2;RFC 6488 §3(1l) |
|
|
||||||
| `content_info_content_type` | `Oid` | ContentInfo.contentType | MUST 为 SignedData:`1.2.840.113549.1.7.2` | RFC 6488 §3(1a) |
|
|
||||||
| `signed_data` | `SignedDataProfiled` | SignedData 语义字段 | 见下 | RFC 6488 §2.1;RFC 6488 §3 |
|
|
||||||
|
|
||||||
### 5.3.2 `SignedDataProfiled`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `version` | `int` | SignedData.version | MUST 为 3 | RFC 6488 §3(1b);RFC 6488 §2.1.1 |
|
|
||||||
| `digest_algorithms` | `list[Oid]` | SignedData.digestAlgorithms | MUST contain exactly one digest algorithm,且必须为 `id-sha256`(`2.16.840.1.101.3.4.2.1`) | RFC 6488 §2.1.2;RFC 7935 §2(引用 RFC 5754) |
|
|
||||||
| `encap_content_info` | `EncapsulatedContentInfo` | EncapsulatedContentInfo | 见下;eContentType 由具体对象定义 | RFC 6488 §2.1.3 |
|
|
||||||
| `certificates` | `list[ResourceEeCertificate]` | SignedData.certificates | MUST present;且仅包含 1 个 EE 证书;该 EE 的 SKI 必须匹配 SignerInfo.sid | RFC 6488 §3(1c) |
|
|
||||||
| `crls` | `None` | SignedData.crls | MUST be omitted | RFC 6488 §3(1d) |
|
|
||||||
| `signer_infos` | `list[SignerInfoProfiled]` | SignedData.signerInfos | MUST contain exactly one SignerInfo | RFC 6488 §2.1;RFC 6488 §2.1(SignerInfos 约束段落) |
|
|
||||||
|
|
||||||
### 5.3.3 `EncapsulatedContentInfo`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `econtent_type` | `Oid` | eContentType | MUST 与 signedAttrs.content-type 的 attrValues 一致;具体值由对象定义(如 ROA/MFT) | RFC 6488 §3(1h);RFC 6488 §2.1.3.1 |
|
|
||||||
| `econtent_der` | `DerBytes` | eContent(对象 payload) | DER 编码的对象特定 ASN.1(ROA/MFT 文档定义);在 CMS 中以 OCTET STRING 承载 | RFC 6488 §2.1.3;RFC 9286 §4.2;RFC 9582 §4 |
|
|
||||||
|
|
||||||
### 5.3.4 `SignerInfoProfiled`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `version` | `int` | SignerInfo.version | MUST 为 3 | RFC 6488 §3(1e) |
|
|
||||||
| `sid_ski` | `bytes` | sid(SubjectKeyIdentifier) | 必须与 EE 证书的 SKI 匹配 | RFC 6488 §3(1c) |
|
|
||||||
| `digest_algorithm` | `Oid` | SignerInfo.digestAlgorithm | 必须为 `id-sha256`(`2.16.840.1.101.3.4.2.1`) | RFC 6488 §3(1j);RFC 7935 §2(引用 RFC 5754) |
|
|
||||||
| `signature_algorithm` | `Oid` | SignerInfo.signatureAlgorithm | 生成时 MUST 为 `rsaEncryption`(`1.2.840.113549.1.1.1`);验证时实现必须接受 `rsaEncryption` 或 `sha256WithRSAEncryption`(`1.2.840.113549.1.1.11`) | RFC 6488 §3(1k);RFC 7935 §2 |
|
|
||||||
| `signed_attrs` | `SignedAttrsProfiled` | signedAttrs | MUST present;仅允许特定 3 个属性 | RFC 9589 §4(更新 RFC 6488 §3(1f)/(1g)) |
|
|
||||||
| `unsigned_attrs` | `None` | unsignedAttrs | MUST be omitted | RFC 6488 §3(1i) |
|
|
||||||
|
|
||||||
### 5.3.5 `SignedAttrsProfiled`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `content_type` | `Oid` | signedAttrs.content-type | attrType=`1.2.840.113549.1.9.3`;MUST present;attrValues 等于 eContentType | RFC 9589 §4;RFC 6488 §3(1h) |
|
|
||||||
| `message_digest` | `bytes` | signedAttrs.message-digest | attrType=`1.2.840.113549.1.9.4`;MUST present | RFC 9589 §4(更新 RFC 6488 §3(1f)) |
|
|
||||||
| `signing_time` | `UtcTime` | signedAttrs.signing-time | attrType=`1.2.840.113549.1.9.5`;MUST present(时间值正确性不用于安全假设) | RFC 9589 §4;RFC 9589 §5 |
|
|
||||||
| `other_attrs` | `None` | 其它 signed attributes | MUST NOT be included(binary-signing-time 也不允许) | RFC 9589 §4 |
|
|
||||||
|
|
||||||
## 5.4 字段级约束清单(实现对照)
|
|
||||||
|
|
||||||
- ContentInfo.contentType 必须为 SignedData(OID `1.2.840.113549.1.7.2`)。RFC 6488 §3(1a)。
|
|
||||||
- SignedData.version 必须为 3,且 SignerInfos 仅允许 1 个 SignerInfo。RFC 6488 §3(1b);RFC 6488 §2.1。
|
|
||||||
- SignedData.certificates 必须存在且仅含 1 个 EE 证书;该证书 SKI 必须匹配 SignerInfo.sid。RFC 6488 §3(1c)。
|
|
||||||
- SignedData.crls 必须省略。RFC 6488 §3(1d)。
|
|
||||||
- signedAttrs 必须存在,且仅允许 content-type/message-digest/signing-time;其它全部禁止。RFC 9589 §4。
|
|
||||||
- eContentType 必须与 content-type attribute 一致。RFC 6488 §3(1h)。
|
|
||||||
- unsignedAttrs 必须省略。RFC 6488 §3(1i)。
|
|
||||||
- digest/signature 算法必须符合算法 profile。RFC 6488 §3(1j)/(1k);RFC 7935 §2。
|
|
||||||
- 整个对象必须 DER 编码。RFC 6488 §3(1l)。
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# 06. Manifest(MFT)
|
|
||||||
|
|
||||||
## 6.1 对象定位
|
|
||||||
|
|
||||||
Manifest 是 CA 发布点内对象的“清单”(文件名 + hash),用于 RP 侧检测删除/替换/回放等不一致情况。RFC 9286 §1;RFC 9286 §6。
|
|
||||||
|
|
||||||
Manifest 是一种 RPKI Signed Object:CMS 外壳遵循 RFC 6488/9589,eContent 遵循 RFC 9286。RFC 9286 §4;RFC 6488 §4;RFC 9589 §4。
|
|
||||||
|
|
||||||
## 6.2 原始载体与编码
|
|
||||||
|
|
||||||
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 9286 §4;RFC 6488 §2。
|
|
||||||
- eContentType:`id-ct-rpkiManifest`,OID `1.2.840.113549.1.9.16.1.26`。RFC 9286 §4.1。
|
|
||||||
- eContent:DER 编码 ASN.1 `Manifest`。RFC 9286 §4.2。
|
|
||||||
|
|
||||||
### 6.2.1 eContentType 与 eContent 的 ASN.1 定义(RFC 9286 §4.1;RFC 9286 §4.2)
|
|
||||||
|
|
||||||
Manifest 是一种 RPKI signed object(CMS 外壳见 `05_signed_object_cms.md`)。其 `eContentType` 与 `eContent` 的 ASN.1 由 RFC 9286 明确定义。RFC 9286 §4。
|
|
||||||
|
|
||||||
**eContentType(OID)**:RFC 9286 §4.1。
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
id-smime OBJECT IDENTIFIER ::= { iso(1) member-body(2) us(840)
|
|
||||||
rsadsi(113549) pkcs(1) pkcs9(9) 16 }
|
|
||||||
|
|
||||||
id-ct OBJECT IDENTIFIER ::= { id-smime 1 }
|
|
||||||
|
|
||||||
id-ct-rpkiManifest OBJECT IDENTIFIER ::= { id-ct 26 }
|
|
||||||
```
|
|
||||||
|
|
||||||
**eContent(Manifest 结构)**:RFC 9286 §4.2。
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
Manifest ::= SEQUENCE {
|
|
||||||
version [0] INTEGER DEFAULT 0,
|
|
||||||
manifestNumber INTEGER (0..MAX),
|
|
||||||
thisUpdate GeneralizedTime,
|
|
||||||
nextUpdate GeneralizedTime,
|
|
||||||
fileHashAlg OBJECT IDENTIFIER,
|
|
||||||
fileList SEQUENCE SIZE (0..MAX) OF FileAndHash
|
|
||||||
}
|
|
||||||
|
|
||||||
FileAndHash ::= SEQUENCE {
|
|
||||||
file IA5String,
|
|
||||||
hash BIT STRING
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
解码要点:
|
|
||||||
|
|
||||||
- `fileHashAlg` 决定 `FileAndHash.hash` 的算法与输出长度(RPKI profile 要求 SHA-256)。RFC 9286 §4.2.1;RFC 7935 §2。
|
|
||||||
- `hash` 在 ASN.1 中是 BIT STRING,但 hash 输出是按字节的比特串,DER 编码时应为 “unused bits = 0” 的 octet-aligned BIT STRING(实现可据此做一致性检查)。RFC 9286 §4.2。
|
|
||||||
|
|
||||||
## 6.3 解析规则(eContent 语义层)
|
|
||||||
|
|
||||||
输入:`RpkiSignedObject`。
|
|
||||||
|
|
||||||
1) 先按通用 Signed Object 外壳解析得到 `encap_content_info.econtent_type` 与 `econtent_der`。RFC 6488 §3;RFC 9589 §4。
|
|
||||||
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.26`。RFC 9286 §4.1;RFC 9286 §4.4(1)。
|
|
||||||
3) 将 `econtent_der` 以 DER 解析为 `Manifest` ASN.1。RFC 9286 §4.2。
|
|
||||||
4) 将 `fileList` 映射为语义字段 `files: list[FileAndHash]`,其中 `hash` 为 `fileHashAlg` 对应算法的输出字节序列。RFC 9286 §4.2.1(fileHashAlg/fileList 定义)。
|
|
||||||
|
|
||||||
## 6.4 抽象数据模型(接口)
|
|
||||||
|
|
||||||
### 6.4.1 `ManifestObject`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 9286 §4;RFC 6488 §3;RFC 9589 §4 |
|
|
||||||
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.26` | RFC 9286 §4.1 |
|
|
||||||
| `manifest` | `ManifestEContent` | eContent 语义对象 | 见下 | RFC 9286 §4.2 |
|
|
||||||
|
|
||||||
### 6.4.2 `ManifestEContent`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `version` | `int` | Manifest.version | MUST 为 0 | RFC 9286 §4.2.1(version) |
|
|
||||||
| `manifest_number` | `int` | manifestNumber | 0..MAX;可达 20 octets;issuer 必须单调递增;RP 必须可处理至 20 octets | RFC 9286 §4.2;RFC 9286 §4.2.1(manifestNumber) |
|
|
||||||
| `this_update` | `UtcTime` | thisUpdate | 由 ASN.1 `GeneralizedTime` 解析为 UTC 时间点;且必须比先前生成的 manifest 更新 | RFC 9286 §4.2;RFC 9286 §4.2.1(thisUpdate) |
|
|
||||||
| `next_update` | `UtcTime` | nextUpdate | 由 ASN.1 `GeneralizedTime` 解析为 UTC 时间点;且必须晚于 thisUpdate | RFC 9286 §4.2;RFC 9286 §4.2.1(nextUpdate) |
|
|
||||||
| `file_hash_alg` | `Oid` | fileHashAlg | 必须为 `id-sha256`(`2.16.840.1.101.3.4.2.1`) | RFC 9286 §4.2.1(fileHashAlg);RFC 7935 §2(引用 RFC 5754) |
|
|
||||||
| `files` | `list[FileAndHash]` | fileList | `SEQUENCE SIZE (0..MAX)`;每项含文件名与 hash | RFC 9286 §4.2;RFC 9286 §4.2.1(fileList) |
|
|
||||||
|
|
||||||
### 6.4.3 `FileAndHash`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `file_name` | `string` | 文件名(不含路径) | 字符集限制:`[a-zA-Z0-9-_]+` + `.` + 三字母扩展;扩展必须在 IANA “RPKI Repository Name Schemes” 注册表中 | RFC 9286 §4.2.2 |
|
|
||||||
| `hash_bytes` | `bytes` | 文件内容 hash | 由 `file_hash_alg` 指定算法计算 | RFC 9286 §4.2.1(fileHashAlg/fileList) |
|
|
||||||
|
|
||||||
## 6.5 字段级约束清单(实现对照)
|
|
||||||
|
|
||||||
- eContentType 必须为 `id-ct-rpkiManifest`(OID `1.2.840.113549.1.9.16.1.26`)。RFC 9286 §4.1。
|
|
||||||
- eContent 必须 DER 编码且符合 `Manifest` ASN.1。RFC 9286 §4.2。
|
|
||||||
- `version` 必须为 0。RFC 9286 §4.2.1。
|
|
||||||
- `manifestNumber` 由 issuer 单调递增;RP 必须能处理至 20 octets;issuer 不得超过 20 octets。RFC 9286 §4.2.1。
|
|
||||||
- `nextUpdate` 必须晚于 `thisUpdate`。RFC 9286 §4.2.1。
|
|
||||||
- `fileHashAlg` 必须符合算法 profile(SHA-256)。RFC 9286 §4.2.1;RFC 7935 §2。
|
|
||||||
- `fileList` 中 `file` 名称字符集与扩展名受限;实现需按 RFC 限制解析并保留大小写语义。RFC 9286 §4.2.2。
|
|
||||||
|
|
||||||
## 6.6 与 EE 证书的语义约束(为后续验证准备)
|
|
||||||
|
|
||||||
Manifest 使用“one-time-use EE certificate”进行签名验证,规范对该 EE 证书的使用方式给出约束:
|
|
||||||
|
|
||||||
- Manifest 相关 EE 证书应为 one-time-use(每次新 manifest 生成新密钥对/新 EE)。RFC 9286 §4(Section 4 前导段落)。
|
|
||||||
- 用于签名/验证 manifest 的 EE 证书在 RFC 3779 的资源扩展中描述 INRs 时,**MUST** 使用 `inherit`(而不是显式列出资源集合)。RFC 9286 §5.1(生成步骤 2)。
|
|
||||||
- 若证书包含 **IP Address Delegation Extension**(IP prefix delegation):内容必须为 `inherit`(不得显式列 prefix/range)。RFC 9286 §5.1;RFC 3779。
|
|
||||||
- 若证书包含 **AS Identifier Delegation Extension**(ASN delegation):内容必须为 `inherit`(不得显式列 ASID/range)。RFC 9286 §5.1;RFC 3779。
|
|
||||||
- 另外按资源证书 profile:资源证书 **MUST** 至少包含上述两类扩展之一(也可两者都有),且这些扩展 **MUST** 标记为 critical。RFC 6487 §2。
|
|
||||||
- 用于验证 manifest 的 EE 证书 **MUST** 具有与 `thisUpdate..nextUpdate` 区间一致的有效期,以避免 CRL 无谓增长。RFC 9286 §4.2.1(manifestNumber 段落前的说明)。
|
|
||||||
- 替换 manifest 时,CA 必须撤销旧 manifest 对应 EE 证书;且若新 manifest 早于旧 manifest 的 nextUpdate 发行,则 CA **MUST** 同时发行新 CRL 撤销旧 manifest EE。RFC 9286 §4.2.1(nextUpdate 段落末);RFC 9286 §5.1(生成步骤)。
|
|
||||||
159
specs/07_roa.md
159
specs/07_roa.md
@@ -1,159 +0,0 @@
|
|||||||
# 07. ROA(Route Origin Authorization)
|
|
||||||
|
|
||||||
## 7.1 对象定位
|
|
||||||
|
|
||||||
ROA 是一种 RPKI Signed Object,用于声明“某 AS 被授权起源某些前缀”。RFC 9582 §1;RFC 9582 §4。
|
|
||||||
|
|
||||||
ROA 由 CMS 外壳 + ROA eContent 组成:
|
|
||||||
|
|
||||||
- 外壳:RFC 6488(更新:RFC 9589)
|
|
||||||
- eContent:RFC 9582
|
|
||||||
|
|
||||||
## 7.2 原始载体与编码
|
|
||||||
|
|
||||||
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 9582 §1(引用 RFC 6488)。
|
|
||||||
- eContentType:`id-ct-routeOriginAuthz`,OID `1.2.840.113549.1.9.16.1.24`。RFC 9582 §3。
|
|
||||||
- eContent:DER 编码 ASN.1 `RouteOriginAttestation`。RFC 9582 §4。
|
|
||||||
|
|
||||||
### 7.2.1 eContentType 与 eContent 的 ASN.1 定义(RFC 9582 §3;RFC 9582 §4)
|
|
||||||
|
|
||||||
ROA 是一种 RPKI signed object(CMS 外壳见 `05_signed_object_cms.md`)。RFC 9582 定义了其 `eContentType` 以及 `eContent`(payload)的 ASN.1。RFC 9582 §3-§4。
|
|
||||||
|
|
||||||
**eContentType(OID)**:RFC 9582 §3。
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
id-ct-routeOriginAuthz OBJECT IDENTIFIER ::=
|
|
||||||
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
|
|
||||||
pkcs-9(9) id-smime(16) id-ct(1) routeOriginAuthz(24) }
|
|
||||||
```
|
|
||||||
|
|
||||||
**eContent(ROA ASN.1 模块)**:RFC 9582 §4。
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
RPKI-ROA-2023
|
|
||||||
{ iso(1) member-body(2) us(840) rsadsi(113549)
|
|
||||||
pkcs(1) pkcs9(9) smime(16) mod(0)
|
|
||||||
id-mod-rpkiROA-2023(75) }
|
|
||||||
|
|
||||||
DEFINITIONS EXPLICIT TAGS ::=
|
|
||||||
BEGIN
|
|
||||||
|
|
||||||
IMPORTS
|
|
||||||
CONTENT-TYPE
|
|
||||||
FROM CryptographicMessageSyntax-2010 -- in [RFC6268]
|
|
||||||
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
|
|
||||||
pkcs-9(9) smime(16) modules(0) id-mod-cms-2009(58) } ;
|
|
||||||
|
|
||||||
ct-routeOriginAttestation CONTENT-TYPE ::=
|
|
||||||
{ TYPE RouteOriginAttestation
|
|
||||||
IDENTIFIED BY id-ct-routeOriginAuthz }
|
|
||||||
|
|
||||||
id-ct-routeOriginAuthz OBJECT IDENTIFIER ::=
|
|
||||||
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
|
|
||||||
pkcs-9(9) id-smime(16) id-ct(1) routeOriginAuthz(24) }
|
|
||||||
|
|
||||||
RouteOriginAttestation ::= SEQUENCE {
|
|
||||||
version [0] INTEGER DEFAULT 0,
|
|
||||||
asID ASID,
|
|
||||||
ipAddrBlocks SEQUENCE (SIZE(1..2)) OF ROAIPAddressFamily }
|
|
||||||
|
|
||||||
ASID ::= INTEGER (0..4294967295)
|
|
||||||
|
|
||||||
ROAIPAddressFamily ::= SEQUENCE {
|
|
||||||
addressFamily ADDRESS-FAMILY.&afi ({AddressFamilySet}),
|
|
||||||
addresses ADDRESS-FAMILY.&Addresses
|
|
||||||
({AddressFamilySet}{@addressFamily}) }
|
|
||||||
|
|
||||||
ADDRESS-FAMILY ::= CLASS {
|
|
||||||
&afi OCTET STRING (SIZE(2)) UNIQUE,
|
|
||||||
&Addresses
|
|
||||||
} WITH SYNTAX { AFI &afi ADDRESSES &Addresses }
|
|
||||||
|
|
||||||
AddressFamilySet ADDRESS-FAMILY ::=
|
|
||||||
{ addressFamilyIPv4 | addressFamilyIPv6 }
|
|
||||||
|
|
||||||
addressFamilyIPv4 ADDRESS-FAMILY ::=
|
|
||||||
{ AFI afi-IPv4 ADDRESSES ROAAddressesIPv4 }
|
|
||||||
addressFamilyIPv6 ADDRESS-FAMILY ::=
|
|
||||||
{ AFI afi-IPv6 ADDRESSES ROAAddressesIPv6 }
|
|
||||||
|
|
||||||
afi-IPv4 OCTET STRING ::= '0001'H
|
|
||||||
afi-IPv6 OCTET STRING ::= '0002'H
|
|
||||||
|
|
||||||
ROAAddressesIPv4 ::= SEQUENCE (SIZE(1..MAX)) OF ROAIPAddress{ub-IPv4}
|
|
||||||
ROAAddressesIPv6 ::= SEQUENCE (SIZE(1..MAX)) OF ROAIPAddress{ub-IPv6}
|
|
||||||
|
|
||||||
ub-IPv4 INTEGER ::= 32
|
|
||||||
ub-IPv6 INTEGER ::= 128
|
|
||||||
|
|
||||||
ROAIPAddress {INTEGER: ub} ::= SEQUENCE {
|
|
||||||
address BIT STRING (SIZE(0..ub)),
|
|
||||||
maxLength INTEGER (0..ub) OPTIONAL }
|
|
||||||
|
|
||||||
END
|
|
||||||
```
|
|
||||||
|
|
||||||
编码/解码要点(与上面 ASN.1 结构直接对应):
|
|
||||||
|
|
||||||
- `addressFamily` 仅允许 IPv4/IPv6 两种 AFI,并且每个 AFI 最多出现一次。RFC 9582 §4.3.1。
|
|
||||||
- `address` 是 BIT STRING 表示的前缀,语义与 RFC 3779 的 `IPAddress` 一致(按前缀长度截断,DER unused bits 置零)。RFC 9582 §4.3.2.1(引用 RFC 3779 §2.2.3.8)。
|
|
||||||
- `maxLength` 为可选字段,出现与否会影响语义与编码规范约束(例如等于前缀长时不建议编码)。RFC 9582 §4.3.2.2。
|
|
||||||
|
|
||||||
## 7.3 解析规则(eContent 语义层)
|
|
||||||
|
|
||||||
输入:`RpkiSignedObject`。
|
|
||||||
|
|
||||||
1) 解析 CMS 外壳,得到 `econtent_type` 与 `econtent_der`。RFC 6488 §3;RFC 9589 §4。
|
|
||||||
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.24`。RFC 9582 §3。
|
|
||||||
3) 将 `econtent_der` 以 DER 解析为 `RouteOriginAttestation` ASN.1。RFC 9582 §4。
|
|
||||||
4) 将 `ipAddrBlocks` 解析为“前缀集合”的语义结构,并建议按 RFC 9582 给出的 canonicalization 过程做去重/排序/归一化(以便后续处理一致)。RFC 9582 §4.3.3。
|
|
||||||
|
|
||||||
## 7.4 抽象数据模型(接口)
|
|
||||||
|
|
||||||
### 7.4.1 `RoaObject`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 9582 §1;RFC 6488 §3;RFC 9589 §4 |
|
|
||||||
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.24` | RFC 9582 §3 |
|
|
||||||
| `roa` | `RoaEContent` | eContent 语义对象 | 见下 | RFC 9582 §4 |
|
|
||||||
|
|
||||||
### 7.4.2 `RoaEContent`(RouteOriginAttestation)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `version` | `int` | version | MUST 为 0 | RFC 9582 §4.1 |
|
|
||||||
| `as_id` | `int` | asID | 0..4294967295 | RFC 9582 §4(ASID 定义);RFC 9582 §4.2 |
|
|
||||||
| `ip_addr_blocks` | `list[RoaIpAddressFamily]` | ipAddrBlocks | `SIZE(1..2)`;最多 IPv4/IPv6 各一个;建议 canonicalize | RFC 9582 §4;RFC 9582 §4.3.1;RFC 9582 §4.3.3 |
|
|
||||||
|
|
||||||
### 7.4.3 `RoaIpAddressFamily`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `afi` | `enum { ipv4, ipv6 }` | Address Family | MUST 为 IPv4(0001) 或 IPv6(0002) | RFC 9582 §4.3.1 |
|
|
||||||
| `addresses` | `list[RoaIpAddress]` | 前缀列表 | `SIZE(1..MAX)`;每项为前缀 + 可选 maxLength | RFC 9582 §4(ROAAddressesIPv4/IPv6);RFC 9582 §4.3.2 |
|
|
||||||
|
|
||||||
### 7.4.4 `RoaIpAddress`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `prefix` | `IpPrefix` | 前缀 | address 以 BIT STRING 表示前缀(同 RFC 3779 IPAddress 表示) | RFC 9582 §4.3.2.1(引用 RFC 3779 §2.2.3.8) |
|
|
||||||
| `max_length` | `optional[int]` | 最大允许前缀长 | 若存在:必须 `>= prefix_len` 且 `<= 32/128`;并且 `maxLength == prefix_len` 时 **SHOULD NOT** 编码(未来 RP 可能视为编码错误) | RFC 9582 §4.3.2.2 |
|
|
||||||
|
|
||||||
## 7.5 字段级约束清单(实现对照)
|
|
||||||
|
|
||||||
- eContentType 必须为 `id-ct-routeOriginAuthz`(OID `1.2.840.113549.1.9.16.1.24`),且该 OID 必须同时出现在 eContentType 与 signedAttrs.content-type。RFC 9582 §3(引用 RFC 6488)。
|
|
||||||
- eContent 必须 DER 编码并符合 `RouteOriginAttestation` ASN.1。RFC 9582 §4。
|
|
||||||
- `version` 必须为 0。RFC 9582 §4.1。
|
|
||||||
- `ipAddrBlocks` 长度为 1..2;每种 AFI 最多出现一次;仅支持 IPv4/IPv6。RFC 9582 §4;RFC 9582 §4.3.1。
|
|
||||||
- `maxLength` 若存在必须在范围内,且不应出现“等于前缀长”的冗余编码。RFC 9582 §4.3.2.2。
|
|
||||||
- 建议按 canonical form 归一化/排序以利一致处理。RFC 9582 §4.3.3。
|
|
||||||
|
|
||||||
## 7.6 与 EE 证书的语义约束(为后续验证准备)
|
|
||||||
|
|
||||||
ROA 的外壳包含一个 EE 证书,用于验证 ROA 签名;RFC 对该 EE 证书与 ROA payload 的匹配关系提出要求:
|
|
||||||
|
|
||||||
- ROA 的 EE 证书必须是有效的 RPKI EE 证书(路径从 TA 到 EE 可建立),并用于验证 CMS 签名。RFC 9582 §1(引用 RFC 6488);RFC 6488 §3(2)-(3)。
|
|
||||||
- ROA EE 证书中的 IP 资源扩展必须存在且不得使用 inherit。RFC 9582 §5。
|
|
||||||
- ROA EE 证书中 AS 资源扩展不得出现。RFC 9582 §5。
|
|
||||||
- ROA payload 中每个前缀必须包含在 EE 证书的 IP 资源集合内(资源包含语义来自 RFC 3779)。RFC 9582 §5;RFC 3779 §2.3。
|
|
||||||
100
specs/08_aspa.md
100
specs/08_aspa.md
@@ -1,100 +0,0 @@
|
|||||||
# 08. ASPA(Autonomous System Provider Authorization)
|
|
||||||
|
|
||||||
## 8.1 对象定位
|
|
||||||
|
|
||||||
ASPA(Autonomous System Provider Authorization)是一种 RPKI Signed Object,用于由“客户 AS”(Customer AS, CAS)签名声明其上游“提供者 AS”(Provider AS, PAS)集合,以支持路由泄漏(route leak)检测/缓解。`draft-ietf-sidrops-aspa-profile-21`;`draft-ietf-sidrops-aspa-verification`。
|
|
||||||
|
|
||||||
ASPA 由 CMS 外壳 + ASPA eContent 组成:
|
|
||||||
|
|
||||||
- 外壳:RFC 6488(更新:RFC 9589)
|
|
||||||
- eContent:`draft-ietf-sidrops-aspa-profile-21`
|
|
||||||
|
|
||||||
## 8.2 原始载体与编码
|
|
||||||
|
|
||||||
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 6488 §2-§3;RFC 9589 §4。
|
|
||||||
- eContentType:`id-ct-ASPA`,OID `1.2.840.113549.1.9.16.1.49`。`draft-ietf-sidrops-aspa-profile-21` §2。
|
|
||||||
- eContent:DER 编码 ASN.1 `ASProviderAttestation`。`draft-ietf-sidrops-aspa-profile-21` §3。
|
|
||||||
|
|
||||||
### 8.2.1 eContentType 与 eContent 的 ASN.1 定义(`draft-ietf-sidrops-aspa-profile-21` §2-§3)
|
|
||||||
|
|
||||||
ASPA 是一种 RPKI signed object(CMS 外壳见 `05_signed_object_cms.md`)。其 `eContentType` 与 `eContent`(payload)的 ASN.1 定义见 `draft-ietf-sidrops-aspa-profile-21` §2-§3。
|
|
||||||
|
|
||||||
**eContentType(OID)**:`draft-ietf-sidrops-aspa-profile-21` §2。
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
id-ct-ASPA OBJECT IDENTIFIER ::=
|
|
||||||
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
|
|
||||||
pkcs-9(9) id-smime(16) id-ct(1) aspa(49) }
|
|
||||||
```
|
|
||||||
|
|
||||||
**eContent(ASPA ASN.1 模块)**:`draft-ietf-sidrops-aspa-profile-21` §3。
|
|
||||||
|
|
||||||
```asn1
|
|
||||||
ASProviderAttestation ::= SEQUENCE {
|
|
||||||
version [0] INTEGER DEFAULT 0,
|
|
||||||
customerASID ASID,
|
|
||||||
providers ProviderASSet }
|
|
||||||
|
|
||||||
ProviderASSet ::= SEQUENCE (SIZE(1..MAX)) OF ASID
|
|
||||||
|
|
||||||
ASID ::= INTEGER (0..4294967295)
|
|
||||||
```
|
|
||||||
|
|
||||||
编码/解码要点(与上面 ASN.1 结构直接对应):
|
|
||||||
|
|
||||||
- `version`:规范要求 **MUST 为 1** 且 **MUST 显式编码**(不得依赖 DEFAULT 省略)。`draft-ietf-sidrops-aspa-profile-21` §3.1。
|
|
||||||
- `customerASID`:客户 AS 号(CAS)。`draft-ietf-sidrops-aspa-profile-21` §3.2。
|
|
||||||
- `providers`:授权的提供者 AS 集合(SPAS)。并对“自包含/排序/去重”施加额外约束(见下)。`draft-ietf-sidrops-aspa-profile-21` §3.3。
|
|
||||||
|
|
||||||
## 8.3 解析规则(eContent 语义层)
|
|
||||||
|
|
||||||
输入:`RpkiSignedObject`。
|
|
||||||
|
|
||||||
1) 解析 CMS 外壳,得到 `econtent_type` 与 `econtent_der`。RFC 6488 §3;RFC 9589 §4。
|
|
||||||
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.49`。`draft-ietf-sidrops-aspa-profile-21` §2。
|
|
||||||
3) 将 `econtent_der` 以 DER 解析为 `ASProviderAttestation` ASN.1。`draft-ietf-sidrops-aspa-profile-21` §3。
|
|
||||||
4) 将 `providers` 映射为语义字段 `provider_as_ids: list[int]`,并对其执行“约束检查/(可选)归一化”。`draft-ietf-sidrops-aspa-profile-21` §3.3。
|
|
||||||
|
|
||||||
## 8.4 抽象数据模型(接口)
|
|
||||||
|
|
||||||
### 8.4.1 `AspaObject`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | 规范引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 6488 §3;RFC 9589 §4 |
|
|
||||||
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.49` | `draft-ietf-sidrops-aspa-profile-21` §2 |
|
|
||||||
| `aspa` | `AspaEContent` | eContent 语义对象 | 见下 | `draft-ietf-sidrops-aspa-profile-21` §3 |
|
|
||||||
|
|
||||||
### 8.4.2 `AspaEContent`(ASProviderAttestation)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | 规范引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `version` | `int` | ASPA 版本 | MUST 为 `1` 且 MUST 显式编码(字段不可省略) | `draft-ietf-sidrops-aspa-profile-21` §3.1 |
|
|
||||||
| `customer_as_id` | `int` | Customer ASID | 0..4294967295 | `draft-ietf-sidrops-aspa-profile-21` §3.2(ASID 定义) |
|
|
||||||
| `provider_as_ids` | `list[int]` | Provider ASID 列表(SPAS) | 长度 `>= 1`;且满足“不得包含 customer、升序、去重” | `draft-ietf-sidrops-aspa-profile-21` §3.3 |
|
|
||||||
|
|
||||||
## 8.5 字段级约束清单(实现对照)
|
|
||||||
|
|
||||||
- eContentType 必须为 `id-ct-ASPA`(OID `1.2.840.113549.1.9.16.1.49`),且该 OID 必须同时出现在 eContentType 与 signedAttrs.content-type。`draft-ietf-sidrops-aspa-profile-21` §2(引用 RFC 6488)。
|
|
||||||
- eContent 必须 DER 编码并符合 `ASProviderAttestation` ASN.1。`draft-ietf-sidrops-aspa-profile-21` §3。
|
|
||||||
- `version` 必须为 1,且必须显式编码(缺失视为不合规)。`draft-ietf-sidrops-aspa-profile-21` §3.1。
|
|
||||||
- `providers`(`provider_as_ids`)必须满足:
|
|
||||||
- `customer_as_id` **MUST NOT** 出现在 `provider_as_ids` 中;
|
|
||||||
- `provider_as_ids` 必须按数值 **升序排序**;
|
|
||||||
- `provider_as_ids` 中每个 ASID 必须 **唯一**。`draft-ietf-sidrops-aspa-profile-21` §3.3。
|
|
||||||
|
|
||||||
## 8.6 与 EE 证书的语义约束(为后续验证准备)
|
|
||||||
|
|
||||||
ASPA 的外壳包含一个 EE 证书,用于验证 ASPA 签名;规范对该 EE 证书与 ASPA payload 的匹配关系提出要求:
|
|
||||||
|
|
||||||
- EE 证书必须包含 AS 资源扩展(Autonomous System Identifier Delegation Extension),且 `customer_as_id` 必须与该扩展中的 ASId 匹配。`draft-ietf-sidrops-aspa-profile-21` §4(引用 RFC 3779)。
|
|
||||||
- EE 证书的 AS 资源扩展 **必须**:
|
|
||||||
- 恰好包含 1 个 `id` 元素;
|
|
||||||
- **不得**包含 `inherit` 元素;
|
|
||||||
- **不得**包含 `range` 元素。`draft-ietf-sidrops-aspa-profile-21` §4(引用 RFC 3779 §3.2.3.3 / §3.2.3.6 / §3.2.3.7)。
|
|
||||||
- EE 证书 **不得**包含 IP 资源扩展(IP Address Delegation Extension)。`draft-ietf-sidrops-aspa-profile-21` §4(引用 RFC 3779)。
|
|
||||||
|
|
||||||
## 8.7 实现建议(非规范约束)
|
|
||||||
|
|
||||||
`draft-ietf-sidrops-aspa-profile-21` 给出了一条 RP 侧建议:实现可对单个 `customer_as_id` 的 `provider_as_ids` 数量施加上界(例如 4,000~10,000),超过阈值时建议将该 `customer_as_id` 的所有 ASPA 视为无效并记录错误日志。`draft-ietf-sidrops-aspa-profile-21` §6。
|
|
||||||
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# 09. Ghostbusters Record(GBR)
|
|
||||||
|
|
||||||
## 9.1 对象定位
|
|
||||||
|
|
||||||
Ghostbusters Record(GBR)是一个可选的 RPKI Signed Object,用于承载“联系人信息”(人类可读的联系渠道),以便在证书过期、CRL 失效、密钥轮换等事件中能够联系到维护者。RFC 6493 §1。
|
|
||||||
|
|
||||||
GBR 由 CMS 外壳 + vCard 载荷组成:
|
|
||||||
|
|
||||||
- 外壳:RFC 6488(更新:RFC 9589)
|
|
||||||
- 载荷(payload):RFC 6493 定义的 vCard profile(基于 RFC 6350 vCard 4.0 的严格子集)。RFC 6493 §5。
|
|
||||||
|
|
||||||
## 9.2 原始载体与编码
|
|
||||||
|
|
||||||
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 6493 §6(引用 RFC 6488)。
|
|
||||||
- eContentType:`id-ct-rpkiGhostbusters`,OID `1.2.840.113549.1.9.16.1.35`。RFC 6493 §6;RFC 6493 §9.1。
|
|
||||||
- eContent:一个 OCTET STRING,其 octets 是 vCard 文本(vCard 4.0,且受 RFC 6493 的 profile 约束)。RFC 6493 §5;RFC 6493 §6。
|
|
||||||
|
|
||||||
> 说明:与 ROA/MFT 这类“eContent 内部再 DER 解码为 ASN.1 结构”的对象不同,GBR 的 eContent 语义上就是“vCard 文本内容本身”(由 Signed Object Template 的 `eContent OCTET STRING` 承载)。RFC 6493 §6。
|
|
||||||
|
|
||||||
## 9.3 vCard profile(RFC 6493 §5)
|
|
||||||
|
|
||||||
GBR 的 vCard payload 是 RFC 6350 vCard 4.0 的严格子集,仅允许以下属性(properties):
|
|
||||||
|
|
||||||
- `BEGIN`:必须为第一行,值必须为 `BEGIN:VCARD`。RFC 6493 §5。
|
|
||||||
- `VERSION`:必须为第二行,值必须为 `VERSION:4.0`。RFC 6493 §5(引用 RFC 6350 §3.7.9)。
|
|
||||||
- `FN`:联系人姓名或角色名。RFC 6493 §5(引用 RFC 6350 §6.2.1)。
|
|
||||||
- `ORG`:组织信息(可选)。RFC 6493 §5(引用 RFC 6350 §6.6.4)。
|
|
||||||
- `ADR`:邮寄地址(可选)。RFC 6493 §5(引用 RFC 6350 §6.3)。
|
|
||||||
- `TEL`:语音/传真电话(可选)。RFC 6493 §5(引用 RFC 6350 §6.4.1)。
|
|
||||||
- `EMAIL`:邮箱(可选)。RFC 6493 §5(引用 RFC 6350 §6.4.2)。
|
|
||||||
- `END`:必须为最后一行,值必须为 `END:VCARD`。RFC 6493 §5。
|
|
||||||
|
|
||||||
额外约束:
|
|
||||||
|
|
||||||
- `BEGIN`、`VERSION`、`FN`、`END` 必须包含。RFC 6493 §5。
|
|
||||||
- 为保证可用性,`ADR`/`TEL`/`EMAIL` 三者中至少一个必须包含。RFC 6493 §5。
|
|
||||||
- 除上述属性外,**其他属性 MUST NOT** 出现。RFC 6493 §5。
|
|
||||||
|
|
||||||
## 9.4 解析规则(payload 语义层)
|
|
||||||
|
|
||||||
输入:`RpkiSignedObject`。
|
|
||||||
|
|
||||||
1) 解析 CMS 外壳,得到 `econtent_type` 与 `econtent_bytes`。RFC 6488 §3;RFC 9589 §4。
|
|
||||||
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.35`。RFC 6493 §6。
|
|
||||||
3) 将 `econtent_bytes` 解析为 vCard 文本,并按 RFC 6493 §5 的 profile 校验(属性集合、必选项、行首/行尾约束)。RFC 6493 §5;RFC 6493 §7。
|
|
||||||
4) 通过校验后,将允许属性映射为 `GhostbustersVCard` 语义对象(见下)。
|
|
||||||
|
|
||||||
## 9.5 抽象数据模型(接口)
|
|
||||||
|
|
||||||
### 9.5.1 `GhostbustersObject`
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 6493 §6;RFC 6488 §3;RFC 9589 §4 |
|
|
||||||
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.35` | RFC 6493 §6 |
|
|
||||||
| `vcard` | `GhostbustersVCard` | vCard 语义对象 | 由 eContent 文本解析并校验 profile | RFC 6493 §5;RFC 6493 §7 |
|
|
||||||
|
|
||||||
### 9.5.2 `GhostbustersVCard`(vCard 4.0 profile)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `raw_text` | `string` | 原始 vCard 文本 | 由 eContent bytes 解码得到;用于保留原文/诊断 | RFC 6493 §5-§7 |
|
|
||||||
| `fn` | `string` | 联系人姓名/角色名 | `FN` 必须存在 | RFC 6493 §5 |
|
|
||||||
| `org` | `optional[string]` | 组织 | `ORG` 可选 | RFC 6493 §5 |
|
|
||||||
| `adrs` | `list[string]` | 邮寄地址(原始 ADR value) | 允许 0..N;至少满足“ADR/TEL/EMAIL 至少一项存在” | RFC 6493 §5 |
|
|
||||||
| `tels` | `list[Uri]` | 电话 URI(从 TEL 提取的 `tel:` 等 URI) | 允许 0..N;至少满足“ADR/TEL/EMAIL 至少一项存在” | RFC 6493 §5 |
|
|
||||||
| `emails` | `list[string]` | 邮箱地址 | 允许 0..N;至少满足“ADR/TEL/EMAIL 至少一项存在” | RFC 6493 §5 |
|
|
||||||
|
|
||||||
> 说明:RFC 6493 并未要求 RP 必须完整解析 vCard 参数(例如 `TYPE=WORK`、`VALUE=uri`),因此此处将 `ADR`/`TEL`/`EMAIL` 建模为“足以联络”的最小语义集合;实现可在保留 `raw_text` 的同时按 RFC 6350 扩展解析能力。
|
|
||||||
|
|
||||||
## 9.6 字段级约束清单(实现对照)
|
|
||||||
|
|
||||||
- eContentType 必须为 `id-ct-rpkiGhostbusters`(OID `1.2.840.113549.1.9.16.1.35`),且该 OID 必须同时出现在 eContentType 与 signedAttrs.content-type。RFC 6493 §6(引用 RFC 6488)。
|
|
||||||
- eContent 必须是 vCard 4.0 文本,且必须满足 RFC 6493 §5 的 profile:
|
|
||||||
- 第一行 `BEGIN:VCARD`;
|
|
||||||
- 第二行 `VERSION:4.0`;
|
|
||||||
- 末行 `END:VCARD`;
|
|
||||||
- 必须包含 `FN`;
|
|
||||||
- `ADR`/`TEL`/`EMAIL` 至少一个存在;
|
|
||||||
- 除允许集合外不得出现其他属性。RFC 6493 §5;RFC 6493 §7。
|
|
||||||
|
|
||||||
## 9.7 与 EE 证书的语义约束(为后续验证准备)
|
|
||||||
|
|
||||||
GBR 使用 CMS 外壳内的 EE 证书验证签名。RFC 6493 对该 EE 证书提出一条资源扩展约束:
|
|
||||||
|
|
||||||
- 用于验证 GBR 的 EE 证书在描述 Internet Number Resources 时,必须使用 `inherit`,而不是显式资源集合。RFC 6493 §6(引用 RFC 3779)。
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
16200
specs/delta.excalidraw
16200
specs/delta.excalidraw
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
pub mod timing;
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TimingHandle {
|
|
||||||
inner: Arc<Mutex<TimingCollector>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TimingHandle {
|
|
||||||
pub fn new(meta: TimingMeta) -> Self {
|
|
||||||
Self {
|
|
||||||
inner: Arc::new(Mutex::new(TimingCollector::new(meta))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn span_phase(&self, phase: &'static str) -> TimingSpanGuard<'_> {
|
|
||||||
TimingSpanGuard {
|
|
||||||
handle: self.clone(),
|
|
||||||
kind: TimingSpanKind::Phase(phase),
|
|
||||||
start: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn span_rrdp_repo<'a>(&self, repo_uri: &'a str) -> TimingSpanGuard<'a> {
|
|
||||||
TimingSpanGuard {
|
|
||||||
handle: self.clone(),
|
|
||||||
kind: TimingSpanKind::RrdpRepo(repo_uri),
|
|
||||||
start: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn span_rrdp_repo_step<'a>(
|
|
||||||
&self,
|
|
||||||
repo_uri: &'a str,
|
|
||||||
step: &'static str,
|
|
||||||
) -> TimingSpanGuard<'a> {
|
|
||||||
TimingSpanGuard {
|
|
||||||
handle: self.clone(),
|
|
||||||
kind: TimingSpanKind::RrdpRepoStep { repo_uri, step },
|
|
||||||
start: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn span_publication_point<'a>(&self, manifest_rsync_uri: &'a str) -> TimingSpanGuard<'a> {
|
|
||||||
TimingSpanGuard {
|
|
||||||
handle: self.clone(),
|
|
||||||
kind: TimingSpanKind::PublicationPoint(manifest_rsync_uri),
|
|
||||||
start: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_meta(&self, update: TimingMetaUpdate<'_>) {
|
|
||||||
let mut g = self.inner.lock().expect("timing lock");
|
|
||||||
if let Some(v) = update.tal_url {
|
|
||||||
g.meta.tal_url = Some(v.to_string());
|
|
||||||
}
|
|
||||||
if let Some(v) = update.db_path {
|
|
||||||
g.meta.db_path = Some(v.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_count(&self, key: &'static str, inc: u64) {
|
|
||||||
let mut g = self.inner.lock().expect("timing lock");
|
|
||||||
g.counts
|
|
||||||
.entry(key)
|
|
||||||
.and_modify(|v| *v = v.saturating_add(inc))
|
|
||||||
.or_insert(inc);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a phase duration directly in nanoseconds.
|
|
||||||
///
|
|
||||||
/// This is useful when aggregating sub-phase timings locally (to reduce lock contention)
|
|
||||||
/// and then emitting a single record per publication point.
|
|
||||||
pub fn record_phase_nanos(&self, phase: &'static str, nanos: u64) {
|
|
||||||
let mut g = self.inner.lock().expect("timing lock");
|
|
||||||
g.phases.record(phase, nanos);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_json(&self, path: &Path, top_n: usize) -> Result<(), String> {
|
|
||||||
let report = {
|
|
||||||
let g = self.inner.lock().expect("timing lock");
|
|
||||||
g.to_report(top_n)
|
|
||||||
};
|
|
||||||
|
|
||||||
let f = std::fs::File::create(path)
|
|
||||||
.map_err(|e| format!("create timing json failed: {}: {e}", path.display()))?;
|
|
||||||
serde_json::to_writer_pretty(f, &report)
|
|
||||||
.map_err(|e| format!("write timing json failed: {e}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_duration(&self, kind: TimingSpanKind<'_>, duration: Duration) {
|
|
||||||
let nanos_u64 = duration.as_nanos().min(u128::from(u64::MAX)) as u64;
|
|
||||||
let mut g = self.inner.lock().expect("timing lock");
|
|
||||||
match kind {
|
|
||||||
TimingSpanKind::Phase(name) => g.phases.record(name, nanos_u64),
|
|
||||||
TimingSpanKind::RrdpRepo(uri) => g.rrdp_repos.record(uri, nanos_u64),
|
|
||||||
TimingSpanKind::RrdpRepoStep { repo_uri, step } => g
|
|
||||||
.rrdp_repo_steps
|
|
||||||
.record(&format!("{repo_uri}::{step}"), nanos_u64),
|
|
||||||
TimingSpanKind::PublicationPoint(uri) => g.publication_points.record(uri, nanos_u64),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct TimingMeta {
|
|
||||||
pub recorded_at_utc_rfc3339: String,
|
|
||||||
pub validation_time_utc_rfc3339: String,
|
|
||||||
pub tal_url: Option<String>,
|
|
||||||
pub db_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct TimingMetaUpdate<'a> {
|
|
||||||
pub tal_url: Option<&'a str>,
|
|
||||||
pub db_path: Option<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TimingSpanGuard<'a> {
|
|
||||||
handle: TimingHandle,
|
|
||||||
kind: TimingSpanKind<'a>,
|
|
||||||
start: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TimingSpanGuard<'_> {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.handle
|
|
||||||
.record_duration(self.kind.clone(), self.start.elapsed());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
enum TimingSpanKind<'a> {
|
|
||||||
Phase(&'static str),
|
|
||||||
RrdpRepo(&'a str),
|
|
||||||
RrdpRepoStep {
|
|
||||||
repo_uri: &'a str,
|
|
||||||
step: &'static str,
|
|
||||||
},
|
|
||||||
PublicationPoint(&'a str),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
||||||
pub struct DurationStats {
|
|
||||||
pub count: u64,
|
|
||||||
pub total_nanos: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DurationStats {
|
|
||||||
fn record(&mut self, nanos: u64) {
|
|
||||||
self.count = self.count.saturating_add(1);
|
|
||||||
self.total_nanos = self.total_nanos.saturating_add(nanos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
struct DurationStatsMap {
|
|
||||||
map: HashMap<String, DurationStats>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DurationStatsMap {
|
|
||||||
fn record(&mut self, key: &str, nanos: u64) {
|
|
||||||
self.map.entry(key.to_string()).or_default().record(nanos);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn top(&self, n: usize) -> Vec<TopDurationEntry> {
|
|
||||||
let mut v = self
|
|
||||||
.map
|
|
||||||
.iter()
|
|
||||||
.map(|(k, s)| TopDurationEntry {
|
|
||||||
key: k.clone(),
|
|
||||||
count: s.count,
|
|
||||||
total_nanos: s.total_nanos,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
v.sort_by(|a, b| b.total_nanos.cmp(&a.total_nanos));
|
|
||||||
v.truncate(n);
|
|
||||||
v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TimingCollector {
|
|
||||||
meta: TimingMeta,
|
|
||||||
counts: HashMap<&'static str, u64>,
|
|
||||||
phases: DurationStatsMap,
|
|
||||||
rrdp_repos: DurationStatsMap,
|
|
||||||
rrdp_repo_steps: DurationStatsMap,
|
|
||||||
publication_points: DurationStatsMap,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TimingCollector {
|
|
||||||
fn new(meta: TimingMeta) -> Self {
|
|
||||||
Self {
|
|
||||||
meta,
|
|
||||||
counts: HashMap::new(),
|
|
||||||
phases: DurationStatsMap::default(),
|
|
||||||
rrdp_repos: DurationStatsMap::default(),
|
|
||||||
rrdp_repo_steps: DurationStatsMap::default(),
|
|
||||||
publication_points: DurationStatsMap::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_report(&self, top_n: usize) -> TimingReportV1 {
|
|
||||||
TimingReportV1 {
|
|
||||||
format_version: 1,
|
|
||||||
meta: self.meta.clone(),
|
|
||||||
counts: self
|
|
||||||
.counts
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| ((*k).to_string(), *v))
|
|
||||||
.collect(),
|
|
||||||
phases: self
|
|
||||||
.phases
|
|
||||||
.map
|
|
||||||
.iter()
|
|
||||||
.map(|(k, s)| (k.clone(), s.clone()))
|
|
||||||
.collect(),
|
|
||||||
top_rrdp_repos: self.rrdp_repos.top(top_n),
|
|
||||||
top_rrdp_repo_steps: self.rrdp_repo_steps.top(top_n),
|
|
||||||
top_publication_points: self.publication_points.top(top_n),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
pub struct TimingReportV1 {
|
|
||||||
pub format_version: u64,
|
|
||||||
pub meta: TimingMeta,
|
|
||||||
pub counts: HashMap<String, u64>,
|
|
||||||
pub phases: HashMap<String, DurationStats>,
|
|
||||||
pub top_rrdp_repos: Vec<TopDurationEntry>,
|
|
||||||
pub top_rrdp_repo_steps: Vec<TopDurationEntry>,
|
|
||||||
pub top_publication_points: Vec<TopDurationEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
pub struct TopDurationEntry {
|
|
||||||
pub key: String,
|
|
||||||
pub count: u64,
|
|
||||||
pub total_nanos: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn timing_handle_writes_json_with_phases_and_tops() {
|
|
||||||
let meta = TimingMeta {
|
|
||||||
recorded_at_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
|
|
||||||
validation_time_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
|
|
||||||
tal_url: Some("https://example.test/x.tal".to_string()),
|
|
||||||
db_path: Some("db".to_string()),
|
|
||||||
};
|
|
||||||
let h = TimingHandle::new(meta);
|
|
||||||
|
|
||||||
{
|
|
||||||
let _p = h.span_phase("tal_bootstrap");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let _r = h.span_rrdp_repo("https://rrdp.example.test/notification.xml");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let _s = h.span_rrdp_repo_step(
|
|
||||||
"https://rrdp.example.test/notification.xml",
|
|
||||||
"fetch_notification",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let _pp = h.span_publication_point("rsync://example.test/repo/manifest.mft");
|
|
||||||
}
|
|
||||||
h.record_count("vrps", 42);
|
|
||||||
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("timing.json");
|
|
||||||
h.write_json(&path, 10).expect("write_json");
|
|
||||||
|
|
||||||
let rep: TimingReportV1 =
|
|
||||||
serde_json::from_slice(&std::fs::read(&path).expect("read timing.json"))
|
|
||||||
.expect("parse timing.json");
|
|
||||||
assert_eq!(rep.format_version, 1);
|
|
||||||
assert!(rep.phases.contains_key("tal_bootstrap"));
|
|
||||||
assert_eq!(rep.counts.get("vrps").copied(), Some(42));
|
|
||||||
assert!(
|
|
||||||
rep.top_rrdp_repos
|
|
||||||
.iter()
|
|
||||||
.any(|e| e.key.contains("rrdp.example.test")),
|
|
||||||
"expected repo in top list"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
rep.top_rrdp_repo_steps
|
|
||||||
.iter()
|
|
||||||
.any(|e| e.key.contains("fetch_notification")),
|
|
||||||
"expected repo step in top list"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
rep.top_publication_points
|
|
||||||
.iter()
|
|
||||||
.any(|e| e.key.contains("manifest.mft")),
|
|
||||||
"expected PP in top list"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
224
src/audit.rs
224
src/audit.rs
@@ -1,224 +0,0 @@
|
|||||||
use serde::Serialize;
|
|
||||||
use sha2::Digest;
|
|
||||||
|
|
||||||
use crate::policy::Policy;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum AuditObjectKind {
|
|
||||||
Manifest,
|
|
||||||
Crl,
|
|
||||||
Certificate,
|
|
||||||
Roa,
|
|
||||||
Aspa,
|
|
||||||
Other,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum AuditObjectResult {
|
|
||||||
Ok,
|
|
||||||
Skipped,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct ObjectAuditEntry {
|
|
||||||
pub rsync_uri: String,
|
|
||||||
pub sha256_hex: String,
|
|
||||||
pub kind: AuditObjectKind,
|
|
||||||
pub result: AuditObjectResult,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub detail: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct AuditWarning {
|
|
||||||
pub message: String,
|
|
||||||
pub rfc_refs: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub context: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&crate::report::Warning> for AuditWarning {
|
|
||||||
fn from(w: &crate::report::Warning) -> Self {
|
|
||||||
Self {
|
|
||||||
message: w.message.clone(),
|
|
||||||
rfc_refs: w.rfc_refs.iter().map(|r| r.0.to_string()).collect(),
|
|
||||||
context: w.context.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct PublicationPointAudit {
|
|
||||||
/// Monotonic node ID assigned by the traversal engine.
|
|
||||||
///
|
|
||||||
/// Present when running via the Stage2 tree engine; may be absent in ad-hoc runs.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub node_id: Option<u64>,
|
|
||||||
/// Parent node ID in the traversal tree.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub parent_node_id: Option<u64>,
|
|
||||||
/// Provenance metadata for non-root nodes (how this CA instance was discovered).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub discovered_from: Option<DiscoveredFrom>,
|
|
||||||
|
|
||||||
pub rsync_base_uri: String,
|
|
||||||
pub manifest_rsync_uri: String,
|
|
||||||
pub publication_point_rsync_uri: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub rrdp_notification_uri: Option<String>,
|
|
||||||
|
|
||||||
pub source: String,
|
|
||||||
pub this_update_rfc3339_utc: String,
|
|
||||||
pub next_update_rfc3339_utc: String,
|
|
||||||
pub verified_at_rfc3339_utc: String,
|
|
||||||
|
|
||||||
pub warnings: Vec<AuditWarning>,
|
|
||||||
pub objects: Vec<ObjectAuditEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct DiscoveredFrom {
|
|
||||||
pub parent_manifest_rsync_uri: String,
|
|
||||||
pub child_ca_certificate_rsync_uri: String,
|
|
||||||
pub child_ca_certificate_sha256_hex: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct TreeSummary {
|
|
||||||
pub instances_processed: usize,
|
|
||||||
pub instances_failed: usize,
|
|
||||||
pub warnings: Vec<AuditWarning>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct AuditRunMeta {
|
|
||||||
pub validation_time_rfc3339_utc: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum AuditDownloadKind {
|
|
||||||
RrdpNotification,
|
|
||||||
RrdpSnapshot,
|
|
||||||
RrdpDelta,
|
|
||||||
Rsync,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct AuditDownloadObjectsStat {
|
|
||||||
pub objects_count: u64,
|
|
||||||
pub objects_bytes_total: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct AuditDownloadEvent {
|
|
||||||
pub kind: AuditDownloadKind,
|
|
||||||
pub uri: String,
|
|
||||||
pub started_at_rfc3339_utc: String,
|
|
||||||
pub finished_at_rfc3339_utc: String,
|
|
||||||
pub duration_ms: u64,
|
|
||||||
pub success: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub error: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub bytes: Option<u64>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub objects: Option<AuditDownloadObjectsStat>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct AuditDownloadKindStats {
|
|
||||||
pub ok_total: u64,
|
|
||||||
pub fail_total: u64,
|
|
||||||
pub duration_ms_total: u64,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub bytes_total: Option<u64>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub objects_count_total: Option<u64>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub objects_bytes_total: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct AuditDownloadStats {
|
|
||||||
pub events_total: u64,
|
|
||||||
/// Statistics keyed by serialized `AuditDownloadKind` string (e.g. "rrdp_snapshot").
|
|
||||||
pub by_kind: std::collections::BTreeMap<String, AuditDownloadKindStats>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct AuditReportV1 {
|
|
||||||
pub format_version: u32,
|
|
||||||
pub meta: AuditRunMeta,
|
|
||||||
pub policy: Policy,
|
|
||||||
pub tree: TreeSummary,
|
|
||||||
pub publication_points: Vec<PublicationPointAudit>,
|
|
||||||
|
|
||||||
pub vrps: Vec<VrpOutput>,
|
|
||||||
pub aspas: Vec<AspaOutput>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct AuditReportV2 {
|
|
||||||
pub format_version: u32,
|
|
||||||
pub meta: AuditRunMeta,
|
|
||||||
pub policy: Policy,
|
|
||||||
pub tree: TreeSummary,
|
|
||||||
pub publication_points: Vec<PublicationPointAudit>,
|
|
||||||
|
|
||||||
pub vrps: Vec<VrpOutput>,
|
|
||||||
pub aspas: Vec<AspaOutput>,
|
|
||||||
|
|
||||||
pub downloads: Vec<AuditDownloadEvent>,
|
|
||||||
pub download_stats: AuditDownloadStats,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct VrpOutput {
|
|
||||||
pub asn: u32,
|
|
||||||
pub prefix: String,
|
|
||||||
pub max_length: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct AspaOutput {
|
|
||||||
pub customer_as_id: u32,
|
|
||||||
pub provider_as_ids: Vec<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sha256_hex_from_32(bytes: &[u8; 32]) -> String {
|
|
||||||
hex::encode(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
|
||||||
let digest = sha2::Sha256::digest(bytes);
|
|
||||||
hex::encode(digest)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_roa_ip_prefix(p: &crate::data_model::roa::IpPrefix) -> String {
|
|
||||||
let addr = p.addr_bytes();
|
|
||||||
match p.afi {
|
|
||||||
crate::data_model::roa::RoaAfi::Ipv4 => {
|
|
||||||
format!(
|
|
||||||
"{}.{}.{}.{}{}",
|
|
||||||
addr[0],
|
|
||||||
addr[1],
|
|
||||||
addr[2],
|
|
||||||
addr[3],
|
|
||||||
format!("/{}", p.prefix_len)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
crate::data_model::roa::RoaAfi::Ipv6 => {
|
|
||||||
let mut parts = Vec::with_capacity(8);
|
|
||||||
for i in 0..8 {
|
|
||||||
let hi = addr[i * 2] as u16;
|
|
||||||
let lo = addr[i * 2 + 1] as u16;
|
|
||||||
parts.push(format!("{:x}", (hi << 8) | lo));
|
|
||||||
}
|
|
||||||
format!("{}{}", parts.join(":"), format!("/{}", p.prefix_len))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use crate::audit::{
|
|
||||||
AuditDownloadEvent, AuditDownloadKind, AuditDownloadKindStats, AuditDownloadObjectsStat,
|
|
||||||
AuditDownloadStats,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct DownloadLogHandle {
|
|
||||||
inner: Arc<Mutex<Vec<AuditDownloadEvent>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DownloadLogHandle {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_event(&self, event: AuditDownloadEvent) {
|
|
||||||
self.inner.lock().expect("download log lock").push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn snapshot_events(&self) -> Vec<AuditDownloadEvent> {
|
|
||||||
self.inner.lock().expect("download log lock").clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stats_from_events(events: &[AuditDownloadEvent]) -> AuditDownloadStats {
|
|
||||||
let mut out = AuditDownloadStats {
|
|
||||||
events_total: events.len() as u64,
|
|
||||||
by_kind: BTreeMap::new(),
|
|
||||||
};
|
|
||||||
for e in events {
|
|
||||||
let kind_key = match e.kind {
|
|
||||||
AuditDownloadKind::RrdpNotification => "rrdp_notification",
|
|
||||||
AuditDownloadKind::RrdpSnapshot => "rrdp_snapshot",
|
|
||||||
AuditDownloadKind::RrdpDelta => "rrdp_delta",
|
|
||||||
AuditDownloadKind::Rsync => "rsync",
|
|
||||||
}
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let st = out
|
|
||||||
.by_kind
|
|
||||||
.entry(kind_key)
|
|
||||||
.or_insert_with(|| AuditDownloadKindStats {
|
|
||||||
ok_total: 0,
|
|
||||||
fail_total: 0,
|
|
||||||
duration_ms_total: 0,
|
|
||||||
bytes_total: None,
|
|
||||||
objects_count_total: None,
|
|
||||||
objects_bytes_total: None,
|
|
||||||
});
|
|
||||||
if e.success {
|
|
||||||
st.ok_total = st.ok_total.saturating_add(1);
|
|
||||||
} else {
|
|
||||||
st.fail_total = st.fail_total.saturating_add(1);
|
|
||||||
}
|
|
||||||
st.duration_ms_total = st.duration_ms_total.saturating_add(e.duration_ms);
|
|
||||||
if let Some(b) = e.bytes {
|
|
||||||
st.bytes_total = Some(st.bytes_total.unwrap_or(0).saturating_add(b));
|
|
||||||
}
|
|
||||||
if let Some(objects) = &e.objects {
|
|
||||||
st.objects_count_total = Some(
|
|
||||||
st.objects_count_total
|
|
||||||
.unwrap_or(0)
|
|
||||||
.saturating_add(objects.objects_count),
|
|
||||||
);
|
|
||||||
st.objects_bytes_total = Some(
|
|
||||||
st.objects_bytes_total
|
|
||||||
.unwrap_or(0)
|
|
||||||
.saturating_add(objects.objects_bytes_total),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stats(&self) -> AuditDownloadStats {
|
|
||||||
let events = self.snapshot_events();
|
|
||||||
Self::stats_from_events(&events)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn span_download<'a>(
|
|
||||||
&'a self,
|
|
||||||
kind: AuditDownloadKind,
|
|
||||||
uri: &'a str,
|
|
||||||
) -> DownloadSpanGuard<'a> {
|
|
||||||
DownloadSpanGuard {
|
|
||||||
handle: self,
|
|
||||||
kind,
|
|
||||||
uri,
|
|
||||||
start_instant: Instant::now(),
|
|
||||||
started_at: time::OffsetDateTime::now_utc(),
|
|
||||||
bytes: None,
|
|
||||||
objects: None,
|
|
||||||
error: None,
|
|
||||||
success: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DownloadSpanGuard<'a> {
|
|
||||||
handle: &'a DownloadLogHandle,
|
|
||||||
kind: AuditDownloadKind,
|
|
||||||
uri: &'a str,
|
|
||||||
start_instant: Instant,
|
|
||||||
started_at: time::OffsetDateTime,
|
|
||||||
bytes: Option<u64>,
|
|
||||||
objects: Option<AuditDownloadObjectsStat>,
|
|
||||||
error: Option<String>,
|
|
||||||
success: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DownloadSpanGuard<'_> {
|
|
||||||
pub fn set_bytes(&mut self, bytes: u64) {
|
|
||||||
self.bytes = Some(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_objects(&mut self, objects_count: u64, objects_bytes_total: u64) {
|
|
||||||
self.objects = Some(AuditDownloadObjectsStat {
|
|
||||||
objects_count,
|
|
||||||
objects_bytes_total,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_ok(&mut self) {
|
|
||||||
self.success = Some(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_err(&mut self, msg: impl Into<String>) {
|
|
||||||
self.success = Some(false);
|
|
||||||
self.error = Some(msg.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DownloadSpanGuard<'_> {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
use time::format_description::well_known::Rfc3339;
|
|
||||||
let finished_at = time::OffsetDateTime::now_utc();
|
|
||||||
let dur = self.start_instant.elapsed();
|
|
||||||
let duration_ms = duration_to_ms(dur);
|
|
||||||
let started_at_rfc3339_utc = self
|
|
||||||
.started_at
|
|
||||||
.to_offset(time::UtcOffset::UTC)
|
|
||||||
.format(&Rfc3339)
|
|
||||||
.unwrap_or_else(|_| "<format-error>".to_string());
|
|
||||||
let finished_at_rfc3339_utc = finished_at
|
|
||||||
.to_offset(time::UtcOffset::UTC)
|
|
||||||
.format(&Rfc3339)
|
|
||||||
.unwrap_or_else(|_| "<format-error>".to_string());
|
|
||||||
let success = self.success.unwrap_or(false);
|
|
||||||
let event = AuditDownloadEvent {
|
|
||||||
kind: self.kind.clone(),
|
|
||||||
uri: self.uri.to_string(),
|
|
||||||
started_at_rfc3339_utc,
|
|
||||||
finished_at_rfc3339_utc,
|
|
||||||
duration_ms,
|
|
||||||
success,
|
|
||||||
error: if success { None } else { self.error.clone() },
|
|
||||||
bytes: self.bytes,
|
|
||||||
objects: self.objects.clone(),
|
|
||||||
};
|
|
||||||
self.handle.record_event(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn duration_to_ms(d: Duration) -> u64 {
|
|
||||||
let ms = d.as_millis();
|
|
||||||
ms.min(u128::from(u64::MAX)) as u64
|
|
||||||
}
|
|
||||||
@@ -1,581 +0,0 @@
|
|||||||
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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
let argv: Vec<String> = std::env::args().collect();
|
|
||||||
if let Err(e) = rpki::cli::run(&argv) {
|
|
||||||
// `parse_args` uses `Err(usage())` for `--help`, so treat it as success.
|
|
||||||
if argv.iter().any(|a| a == "--help" || a == "-h") {
|
|
||||||
println!("{e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
eprintln!("{e}");
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
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
932
src/cli.rs
@@ -1,932 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use crate::analysis::timing::{TimingHandle, TimingMeta, TimingMetaUpdate};
|
|
||||||
use crate::audit::{
|
|
||||||
AspaOutput, AuditReportV2, AuditRunMeta, AuditWarning, TreeSummary, VrpOutput,
|
|
||||||
format_roa_ip_prefix,
|
|
||||||
};
|
|
||||||
use crate::fetch::http::{BlockingHttpFetcher, HttpFetcherConfig};
|
|
||||||
use crate::fetch::rsync::LocalDirRsyncFetcher;
|
|
||||||
use crate::fetch::rsync_system::{SystemRsyncConfig, SystemRsyncFetcher};
|
|
||||||
use crate::policy::Policy;
|
|
||||||
use crate::storage::RocksStore;
|
|
||||||
use crate::validation::run_tree_from_tal::{
|
|
||||||
RunTreeFromTalAuditOutput, run_tree_from_tal_and_ta_der_serial_audit,
|
|
||||||
run_tree_from_tal_and_ta_der_serial_audit_with_timing, run_tree_from_tal_url_serial_audit,
|
|
||||||
run_tree_from_tal_url_serial_audit_with_timing,
|
|
||||||
};
|
|
||||||
use crate::validation::tree::TreeRunConfig;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CliArgs {
|
|
||||||
pub tal_url: Option<String>,
|
|
||||||
pub tal_path: Option<PathBuf>,
|
|
||||||
pub ta_path: Option<PathBuf>,
|
|
||||||
|
|
||||||
pub db_path: PathBuf,
|
|
||||||
pub policy_path: Option<PathBuf>,
|
|
||||||
pub report_json_path: Option<PathBuf>,
|
|
||||||
|
|
||||||
pub rsync_local_dir: Option<PathBuf>,
|
|
||||||
|
|
||||||
pub http_timeout_secs: u64,
|
|
||||||
pub rsync_timeout_secs: u64,
|
|
||||||
pub rsync_mirror_root: Option<PathBuf>,
|
|
||||||
|
|
||||||
pub max_depth: Option<usize>,
|
|
||||||
pub max_instances: Option<usize>,
|
|
||||||
pub validation_time: Option<time::OffsetDateTime>,
|
|
||||||
|
|
||||||
pub 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\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
use crate::data_model::common::{DerReader, der_take_tlv};
|
|
||||||
use crate::data_model::oid::OID_CT_ASPA;
|
|
||||||
use crate::data_model::rc::ResourceCertificate;
|
|
||||||
use crate::data_model::signed_object::{
|
|
||||||
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectParseError, SignedObjectValidateError,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct AspaObject {
|
|
||||||
pub signed_object: RpkiSignedObject,
|
|
||||||
pub econtent_type: String,
|
|
||||||
pub aspa: AspaEContent,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct AspaObjectParsed {
|
|
||||||
pub signed_object: RpkiSignedObjectParsed,
|
|
||||||
pub econtent_type: String,
|
|
||||||
pub aspa: Option<AspaEContentParsed>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct AspaEContent {
|
|
||||||
pub version: u32,
|
|
||||||
pub customer_as_id: u32,
|
|
||||||
pub provider_as_ids: Vec<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct AspaEContentParsed {
|
|
||||||
der: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum AspaParseError {
|
|
||||||
#[error("signed object parse error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
|
|
||||||
SignedObject(#[from] SignedObjectParseError),
|
|
||||||
#[error("ASPA parse error: {0} (draft-ietf-sidrops-aspa-profile-21 §3; DER)")]
|
|
||||||
Parse(String),
|
|
||||||
|
|
||||||
#[error("ASPA trailing bytes: {0} bytes (draft-ietf-sidrops-aspa-profile-21 §3; DER)")]
|
|
||||||
TrailingBytes(usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum AspaProfileError {
|
|
||||||
#[error("signed object profile error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
|
|
||||||
SignedObject(#[from] SignedObjectValidateError),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA eContentType must be {OID_CT_ASPA}, got {0} (draft-ietf-sidrops-aspa-profile-21 §2)"
|
|
||||||
)]
|
|
||||||
InvalidEContentType(String),
|
|
||||||
|
|
||||||
#[error("ASPA profile decode error: {0} (draft-ietf-sidrops-aspa-profile-21 §3; DER)")]
|
|
||||||
ProfileDecode(String),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASProviderAttestation must be a SEQUENCE of 3 elements (draft-ietf-sidrops-aspa-profile-21 §3)"
|
|
||||||
)]
|
|
||||||
InvalidAttestationSequence,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA version must be 1 and MUST be explicitly encoded (draft-ietf-sidrops-aspa-profile-21 §3.1)"
|
|
||||||
)]
|
|
||||||
VersionMustBeExplicitOne,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA customerASID out of range (0..=4294967295), got {0} (draft-ietf-sidrops-aspa-profile-21 §3.2)"
|
|
||||||
)]
|
|
||||||
CustomerAsIdOutOfRange(u64),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA providers must contain at least one ASID (draft-ietf-sidrops-aspa-profile-21 §3.3)"
|
|
||||||
)]
|
|
||||||
EmptyProviders,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA provider ASID out of range (0..=4294967295), got {0} (draft-ietf-sidrops-aspa-profile-21 §3.3)"
|
|
||||||
)]
|
|
||||||
ProviderAsIdOutOfRange(u64),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA providers must be in strictly increasing order (draft-ietf-sidrops-aspa-profile-21 §3.3)"
|
|
||||||
)]
|
|
||||||
ProvidersNotStrictlyIncreasing,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA providers contains the customerASID ({0}) which is not allowed (draft-ietf-sidrops-aspa-profile-21 §3.3)"
|
|
||||||
)]
|
|
||||||
ProvidersContainCustomer(u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum AspaDecodeError {
|
|
||||||
#[error("{0}")]
|
|
||||||
Parse(#[from] AspaParseError),
|
|
||||||
|
|
||||||
#[error("{0}")]
|
|
||||||
Validate(#[from] AspaProfileError),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum AspaValidateError {
|
|
||||||
#[error(
|
|
||||||
"ASPA EE certificate must contain AS resources extension (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2)"
|
|
||||||
)]
|
|
||||||
EeAsResourcesMissing,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA EE certificate AS resources must not use inherit (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2.3.3)"
|
|
||||||
)]
|
|
||||||
EeAsResourcesInherit,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA EE certificate AS resources must not include ranges (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2.3.6-§3.2.3.7)"
|
|
||||||
)]
|
|
||||||
EeAsResourcesRangePresent,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA EE certificate AS resources must not include RDI (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2.3.5; RFC 6487 §4.8.11)"
|
|
||||||
)]
|
|
||||||
EeAsResourcesRdiPresent,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA EE certificate AS resources must contain exactly one ASID (id element) (draft-ietf-sidrops-aspa-profile-21 §4)"
|
|
||||||
)]
|
|
||||||
EeAsResourcesNotSingleId,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA customerASID ({customer_as_id}) does not match EE AS resources ({ee_as_id}) (draft-ietf-sidrops-aspa-profile-21 §4)"
|
|
||||||
)]
|
|
||||||
CustomerAsIdMismatch { customer_as_id: u32, ee_as_id: u32 },
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ASPA EE certificate must not contain IP resources extension (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §2.2)"
|
|
||||||
)]
|
|
||||||
EeIpResourcesPresent,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AspaObject {
|
|
||||||
/// Parse step of scheme A (`parse → validate → verify`).
|
|
||||||
pub fn parse_der(der: &[u8]) -> Result<AspaObjectParsed, AspaParseError> {
|
|
||||||
let signed_object = RpkiSignedObject::parse_der(der)?;
|
|
||||||
let econtent_type = signed_object
|
|
||||||
.signed_data
|
|
||||||
.encap_content_info
|
|
||||||
.econtent_type
|
|
||||||
.clone();
|
|
||||||
let aspa = signed_object
|
|
||||||
.signed_data
|
|
||||||
.encap_content_info
|
|
||||||
.econtent
|
|
||||||
.as_deref()
|
|
||||||
.map(AspaEContent::parse_der)
|
|
||||||
.transpose()?;
|
|
||||||
Ok(AspaObjectParsed {
|
|
||||||
signed_object,
|
|
||||||
econtent_type,
|
|
||||||
aspa,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Profile validate step of scheme A (`parse → validate → verify`).
|
|
||||||
///
|
|
||||||
/// `AspaObject` is already profile-validated when constructed via `decode_der()` /
|
|
||||||
/// `AspaObjectParsed::validate_profile()`.
|
|
||||||
pub fn validate_profile(&self) -> Result<(), AspaProfileError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_der(der: &[u8]) -> Result<Self, AspaDecodeError> {
|
|
||||||
Ok(Self::parse_der(der)?.validate_profile()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_signed_object(signed_object: RpkiSignedObject) -> Result<Self, AspaDecodeError> {
|
|
||||||
let econtent_type = signed_object
|
|
||||||
.signed_data
|
|
||||||
.encap_content_info
|
|
||||||
.econtent_type
|
|
||||||
.clone();
|
|
||||||
if econtent_type != OID_CT_ASPA {
|
|
||||||
return Err(AspaProfileError::InvalidEContentType(econtent_type).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let aspa =
|
|
||||||
AspaEContent::decode_der(&signed_object.signed_data.encap_content_info.econtent)?;
|
|
||||||
Ok(Self {
|
|
||||||
aspa,
|
|
||||||
signed_object,
|
|
||||||
econtent_type: OID_CT_ASPA.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate this ASPA's embedded EE certificate resources.
|
|
||||||
pub fn validate_embedded_ee_cert(&self) -> Result<(), AspaValidateError> {
|
|
||||||
let ee = &self.signed_object.signed_data.certificates[0].resource_cert;
|
|
||||||
self.aspa.validate_against_ee_cert(ee)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AspaEContent {
|
|
||||||
/// Parse step of scheme A (`parse → validate → verify`).
|
|
||||||
pub fn parse_der(der: &[u8]) -> Result<AspaEContentParsed, AspaParseError> {
|
|
||||||
let (_tag, _value, rem) = der_take_tlv(der).map_err(AspaParseError::Parse)?;
|
|
||||||
if !rem.is_empty() {
|
|
||||||
return Err(AspaParseError::TrailingBytes(rem.len()));
|
|
||||||
}
|
|
||||||
Ok(AspaEContentParsed { der: der.to_vec() })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Profile validate step of scheme A (`parse → validate → verify`).
|
|
||||||
///
|
|
||||||
/// `AspaEContent` is already profile-validated when constructed via `decode_der()` /
|
|
||||||
/// `AspaEContentParsed::validate_profile()`.
|
|
||||||
pub fn validate_profile(&self) -> Result<(), AspaProfileError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode the DER-encoded ASProviderAttestation defined in
|
|
||||||
/// draft-ietf-sidrops-aspa-profile-21 §3 (`parse + validate`).
|
|
||||||
pub fn decode_der(der: &[u8]) -> Result<Self, AspaDecodeError> {
|
|
||||||
Ok(Self::parse_der(der)?.validate_profile()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate ASPA payload against the embedded EE resource certificate.
|
|
||||||
///
|
|
||||||
/// This implements the EE/payload semantic checks described in
|
|
||||||
/// `draft-ietf-sidrops-aspa-profile-21` §4 (as summarized in `rpki/specs/08_aspa.md`).
|
|
||||||
pub fn validate_against_ee_cert(
|
|
||||||
&self,
|
|
||||||
ee: &ResourceCertificate,
|
|
||||||
) -> Result<(), AspaValidateError> {
|
|
||||||
if ee.tbs.extensions.ip_resources.is_some() {
|
|
||||||
return Err(AspaValidateError::EeIpResourcesPresent);
|
|
||||||
}
|
|
||||||
|
|
||||||
let asn = ee
|
|
||||||
.tbs
|
|
||||||
.extensions
|
|
||||||
.as_resources
|
|
||||||
.as_ref()
|
|
||||||
.ok_or(AspaValidateError::EeAsResourcesMissing)?;
|
|
||||||
|
|
||||||
if asn.rdi.is_some() {
|
|
||||||
return Err(AspaValidateError::EeAsResourcesRdiPresent);
|
|
||||||
}
|
|
||||||
if asn.is_asnum_inherit() {
|
|
||||||
return Err(AspaValidateError::EeAsResourcesInherit);
|
|
||||||
}
|
|
||||||
if asn.has_any_range() {
|
|
||||||
return Err(AspaValidateError::EeAsResourcesRangePresent);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ee_as_id = asn
|
|
||||||
.asnum_single_id()
|
|
||||||
.ok_or(AspaValidateError::EeAsResourcesNotSingleId)?;
|
|
||||||
if ee_as_id != self.customer_as_id {
|
|
||||||
return Err(AspaValidateError::CustomerAsIdMismatch {
|
|
||||||
customer_as_id: self.customer_as_id,
|
|
||||||
ee_as_id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AspaObjectParsed {
|
|
||||||
pub fn validate_profile(self) -> Result<AspaObject, AspaProfileError> {
|
|
||||||
let signed_object = self.signed_object.validate_profile()?;
|
|
||||||
let econtent_type = signed_object
|
|
||||||
.signed_data
|
|
||||||
.encap_content_info
|
|
||||||
.econtent_type
|
|
||||||
.clone();
|
|
||||||
if econtent_type != OID_CT_ASPA {
|
|
||||||
return Err(AspaProfileError::InvalidEContentType(econtent_type));
|
|
||||||
}
|
|
||||||
let aspa = self
|
|
||||||
.aspa
|
|
||||||
.ok_or_else(|| AspaProfileError::ProfileDecode("ASPA.eContent missing".into()))?
|
|
||||||
.validate_profile()?;
|
|
||||||
Ok(AspaObject {
|
|
||||||
signed_object,
|
|
||||||
econtent_type: OID_CT_ASPA.to_string(),
|
|
||||||
aspa,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AspaEContentParsed {
|
|
||||||
pub fn validate_profile(self) -> Result<AspaEContent, AspaProfileError> {
|
|
||||||
fn count_elements(mut r: DerReader<'_>) -> Result<usize, String> {
|
|
||||||
let mut n = 0usize;
|
|
||||||
while !r.is_empty() {
|
|
||||||
r.skip_any()?;
|
|
||||||
n += 1;
|
|
||||||
}
|
|
||||||
Ok(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut r = DerReader::new(&self.der);
|
|
||||||
let mut seq = r
|
|
||||||
.take_sequence()
|
|
||||||
.map_err(|e| AspaProfileError::ProfileDecode(e))?;
|
|
||||||
if !r.is_empty() {
|
|
||||||
return Err(AspaProfileError::ProfileDecode(
|
|
||||||
"trailing bytes after ASProviderAttestation".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let elem_count =
|
|
||||||
count_elements(seq).map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
if elem_count != 3 {
|
|
||||||
return Err(AspaProfileError::InvalidAttestationSequence);
|
|
||||||
}
|
|
||||||
|
|
||||||
// version [0] EXPLICIT INTEGER MUST be present and MUST be 1.
|
|
||||||
if seq
|
|
||||||
.peek_tag()
|
|
||||||
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?
|
|
||||||
!= 0xA0
|
|
||||||
{
|
|
||||||
return Err(AspaProfileError::VersionMustBeExplicitOne);
|
|
||||||
}
|
|
||||||
let (inner_tag, inner_val) = seq
|
|
||||||
.take_explicit(0xA0)
|
|
||||||
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
if inner_tag != 0x02 {
|
|
||||||
return Err(AspaProfileError::VersionMustBeExplicitOne);
|
|
||||||
}
|
|
||||||
let v = crate::data_model::common::der_uint_from_bytes(inner_val)
|
|
||||||
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
if v != 1 {
|
|
||||||
return Err(AspaProfileError::VersionMustBeExplicitOne);
|
|
||||||
}
|
|
||||||
|
|
||||||
let customer_u64 = seq
|
|
||||||
.take_uint_u64()
|
|
||||||
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
if customer_u64 > u32::MAX as u64 {
|
|
||||||
return Err(AspaProfileError::CustomerAsIdOutOfRange(customer_u64));
|
|
||||||
}
|
|
||||||
let customer_as_id = customer_u64 as u32;
|
|
||||||
|
|
||||||
let providers_seq = seq
|
|
||||||
.take_sequence()
|
|
||||||
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
if !seq.is_empty() {
|
|
||||||
return Err(AspaProfileError::InvalidAttestationSequence);
|
|
||||||
}
|
|
||||||
let providers = parse_providers_cursor(providers_seq, customer_as_id)?;
|
|
||||||
|
|
||||||
Ok(AspaEContent {
|
|
||||||
version: 1,
|
|
||||||
customer_as_id,
|
|
||||||
provider_as_ids: providers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_providers_cursor(
|
|
||||||
mut seq: DerReader<'_>,
|
|
||||||
customer_as_id: u32,
|
|
||||||
) -> Result<Vec<u32>, AspaProfileError> {
|
|
||||||
if seq.is_empty() {
|
|
||||||
return Err(AspaProfileError::EmptyProviders);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out: Vec<u32> = Vec::new();
|
|
||||||
let mut prev: Option<u32> = None;
|
|
||||||
while !seq.is_empty() {
|
|
||||||
let v = seq
|
|
||||||
.take_uint_u64()
|
|
||||||
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
if v > u32::MAX as u64 {
|
|
||||||
return Err(AspaProfileError::ProviderAsIdOutOfRange(v));
|
|
||||||
}
|
|
||||||
let asn = v as u32;
|
|
||||||
if asn == customer_as_id {
|
|
||||||
return Err(AspaProfileError::ProvidersContainCustomer(customer_as_id));
|
|
||||||
}
|
|
||||||
if let Some(p) = prev {
|
|
||||||
if asn <= p {
|
|
||||||
return Err(AspaProfileError::ProvidersNotStrictlyIncreasing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prev = Some(asn);
|
|
||||||
out.push(asn);
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
use x509_parser::asn1_rs::Tag;
|
|
||||||
use x509_parser::prelude::FromDer;
|
|
||||||
use x509_parser::x509::AlgorithmIdentifier;
|
|
||||||
|
|
||||||
pub type UtcTime = time::OffsetDateTime;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum Asn1TimeEncoding {
|
|
||||||
UtcTime,
|
|
||||||
GeneralizedTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct Asn1TimeUtc {
|
|
||||||
pub utc: UtcTime,
|
|
||||||
pub encoding: Asn1TimeEncoding,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Asn1TimeUtc {
|
|
||||||
/// Validate Time encoding rules (RFC 5280): years 1950-2049 use UTCTime,
|
|
||||||
/// other years use GeneralizedTime.
|
|
||||||
pub fn validate_encoding_rfc5280(
|
|
||||||
&self,
|
|
||||||
field: &'static str,
|
|
||||||
) -> Result<(), InvalidTimeEncodingError> {
|
|
||||||
let year = self.utc.year();
|
|
||||||
let expected = if year <= 2049 {
|
|
||||||
Asn1TimeEncoding::UtcTime
|
|
||||||
} else {
|
|
||||||
Asn1TimeEncoding::GeneralizedTime
|
|
||||||
};
|
|
||||||
if self.encoding != expected {
|
|
||||||
return Err(InvalidTimeEncodingError {
|
|
||||||
field,
|
|
||||||
year,
|
|
||||||
encoding: self.encoding,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct BigUnsigned {
|
|
||||||
/// Minimal big-endian bytes. For zero, this is `[0]`.
|
|
||||||
pub bytes_be: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BigUnsigned {
|
|
||||||
pub fn from_biguint(n: &der_parser::num_bigint::BigUint) -> Self {
|
|
||||||
let mut bytes = n.to_bytes_be();
|
|
||||||
if bytes.is_empty() {
|
|
||||||
bytes.push(0);
|
|
||||||
}
|
|
||||||
Self { bytes_be: bytes }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_hex_upper(&self) -> String {
|
|
||||||
hex::encode_upper(&self.bytes_be)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_u64(&self) -> Option<u64> {
|
|
||||||
if self.bytes_be.len() > 8 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut value: u64 = 0;
|
|
||||||
for &b in &self.bytes_be {
|
|
||||||
value = (value << 8) | (b as u64);
|
|
||||||
}
|
|
||||||
Some(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
|
||||||
#[error(
|
|
||||||
"{field} time encoding invalid for year {year}: got {encoding:?} (RFC 5280 §4.1.2.5; RFC 5280 §5.1.2.4-§5.1.2.6)"
|
|
||||||
)]
|
|
||||||
pub struct InvalidTimeEncodingError {
|
|
||||||
pub field: &'static str,
|
|
||||||
pub year: i32,
|
|
||||||
pub encoding: Asn1TimeEncoding,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn asn1_time_to_model(t: x509_parser::time::ASN1Time) -> Asn1TimeUtc {
|
|
||||||
let encoding = if t.is_utctime() {
|
|
||||||
Asn1TimeEncoding::UtcTime
|
|
||||||
} else {
|
|
||||||
Asn1TimeEncoding::GeneralizedTime
|
|
||||||
};
|
|
||||||
Asn1TimeUtc {
|
|
||||||
utc: t.to_datetime(),
|
|
||||||
encoding,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn algorithm_params_absent_or_null(sig: &AlgorithmIdentifier<'_>) -> bool {
|
|
||||||
match sig.parameters.as_ref() {
|
|
||||||
None => true,
|
|
||||||
Some(p) if p.tag() == Tag::Null => true,
|
|
||||||
Some(_p) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Take a single DER TLV (Tag-Length-Value) from the start of `input`.
|
|
||||||
///
|
|
||||||
/// This helper supports:
|
|
||||||
/// - short- and long-form lengths (up to 8 length bytes)
|
|
||||||
/// - only low-tag-number form tags (no high-tag-number form)
|
|
||||||
/// - definite length only (DER forbids indefinite length)
|
|
||||||
///
|
|
||||||
/// Returns: `(tag_byte, value_bytes, remaining_bytes)`.
|
|
||||||
pub(crate) fn der_take_tlv(input: &[u8]) -> Result<(u8, &[u8], &[u8]), String> {
|
|
||||||
if input.len() < 2 {
|
|
||||||
return Err("truncated DER (need tag+len)".into());
|
|
||||||
}
|
|
||||||
let tag = input[0];
|
|
||||||
if (tag & 0x1F) == 0x1F {
|
|
||||||
return Err("high-tag-number form not supported".into());
|
|
||||||
}
|
|
||||||
let len0 = input[1];
|
|
||||||
if len0 == 0x80 {
|
|
||||||
return Err("indefinite length not allowed in DER".into());
|
|
||||||
}
|
|
||||||
let (len, hdr_len) = if len0 & 0x80 == 0 {
|
|
||||||
(len0 as usize, 2usize)
|
|
||||||
} else {
|
|
||||||
let n = (len0 & 0x7F) as usize;
|
|
||||||
if n == 0 || n > 8 {
|
|
||||||
return Err("invalid DER length".into());
|
|
||||||
}
|
|
||||||
if input.len() < 2 + n {
|
|
||||||
return Err("truncated DER (length bytes)".into());
|
|
||||||
}
|
|
||||||
let mut l: usize = 0;
|
|
||||||
for &b in &input[2..2 + n] {
|
|
||||||
l = (l << 8) | (b as usize);
|
|
||||||
}
|
|
||||||
(l, 2 + n)
|
|
||||||
};
|
|
||||||
if input.len() < hdr_len + len {
|
|
||||||
return Err("truncated DER (value bytes)".into());
|
|
||||||
}
|
|
||||||
let value = &input[hdr_len..hdr_len + len];
|
|
||||||
let rem = &input[hdr_len + len..];
|
|
||||||
Ok((tag, value, rem))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal streaming DER reader built on `der_take_tlv`.
|
|
||||||
///
|
|
||||||
/// This is intentionally small and only supports the subset of DER needed by
|
|
||||||
/// RPKI object eContent decoders (ROA/ASPA), to avoid constructing a generic AST
|
|
||||||
/// (which is expensive on large objects such as ROAs with thousands of prefixes).
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub(crate) struct DerReader<'a> {
|
|
||||||
buf: &'a [u8],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> DerReader<'a> {
|
|
||||||
pub(crate) fn new(buf: &'a [u8]) -> Self {
|
|
||||||
Self { buf }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn is_empty(&self) -> bool {
|
|
||||||
self.buf.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn remaining_len(&self) -> usize {
|
|
||||||
self.buf.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn peek_tag(&self) -> Result<u8, String> {
|
|
||||||
self.buf
|
|
||||||
.first()
|
|
||||||
.copied()
|
|
||||||
.ok_or_else(|| "truncated DER".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn take_any(&mut self) -> Result<(u8, &'a [u8]), String> {
|
|
||||||
let (tag, value, rem) = der_take_tlv(self.buf)?;
|
|
||||||
self.buf = rem;
|
|
||||||
Ok((tag, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn take_any_full(&mut self) -> Result<(u8, &'a [u8], &'a [u8]), String> {
|
|
||||||
let (tag, value, rem) = der_take_tlv(self.buf)?;
|
|
||||||
let consumed = self.buf.len() - rem.len();
|
|
||||||
let full = &self.buf[..consumed];
|
|
||||||
self.buf = rem;
|
|
||||||
Ok((tag, full, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn skip_any(&mut self) -> Result<(), String> {
|
|
||||||
let _ = self.take_any()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn take_tag(&mut self, expected_tag: u8) -> Result<&'a [u8], String> {
|
|
||||||
let (tag, value) = self.take_any()?;
|
|
||||||
if tag != expected_tag {
|
|
||||||
return Err(format!(
|
|
||||||
"unexpected tag: got 0x{tag:02X}, expected 0x{expected_tag:02X}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn take_sequence(&mut self) -> Result<DerReader<'a>, String> {
|
|
||||||
let value = self.take_tag(0x30)?;
|
|
||||||
Ok(DerReader::new(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn take_octet_string(&mut self) -> Result<&'a [u8], String> {
|
|
||||||
self.take_tag(0x04)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn take_bit_string(&mut self) -> Result<(u8, &'a [u8]), String> {
|
|
||||||
let v = self.take_tag(0x03)?;
|
|
||||||
if v.is_empty() {
|
|
||||||
return Err("BIT STRING content is empty".into());
|
|
||||||
}
|
|
||||||
Ok((v[0], &v[1..]))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn take_uint_u64(&mut self) -> Result<u64, String> {
|
|
||||||
let v = self.take_tag(0x02)?;
|
|
||||||
der_uint_from_bytes(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn take_explicit(
|
|
||||||
&mut self,
|
|
||||||
expected_outer_tag: u8,
|
|
||||||
) -> Result<(u8, &'a [u8]), String> {
|
|
||||||
let inner_der = self.take_tag(expected_outer_tag)?;
|
|
||||||
let (tag, value, rem) = der_take_tlv(inner_der)?;
|
|
||||||
if !rem.is_empty() {
|
|
||||||
return Err("trailing bytes inside EXPLICIT value".into());
|
|
||||||
}
|
|
||||||
Ok((tag, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn take_explicit_der(&mut self, expected_outer_tag: u8) -> Result<&'a [u8], String> {
|
|
||||||
let inner_der = self.take_tag(expected_outer_tag)?;
|
|
||||||
let (_tag, _value, rem) = der_take_tlv(inner_der)?;
|
|
||||||
if !rem.is_empty() {
|
|
||||||
return Err("trailing bytes inside EXPLICIT value".into());
|
|
||||||
}
|
|
||||||
Ok(inner_der)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn der_uint_from_bytes(bytes: &[u8]) -> Result<u64, String> {
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return Err("INTEGER has empty content".into());
|
|
||||||
}
|
|
||||||
// Disallow negative values.
|
|
||||||
if (bytes[0] & 0x80) != 0 {
|
|
||||||
return Err("INTEGER is negative".into());
|
|
||||||
}
|
|
||||||
// DER requires minimal encoding for INTEGER.
|
|
||||||
if bytes.len() > 1 && bytes[0] == 0x00 && (bytes[1] & 0x80) == 0 {
|
|
||||||
return Err("INTEGER not minimally encoded".into());
|
|
||||||
}
|
|
||||||
if bytes.len() > 8 {
|
|
||||||
return Err("INTEGER does not fit u64".into());
|
|
||||||
}
|
|
||||||
let mut v: u64 = 0;
|
|
||||||
for &b in bytes {
|
|
||||||
v = (v << 8) | (b as u64);
|
|
||||||
}
|
|
||||||
Ok(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
|
||||||
pub struct X509NameDer(pub Vec<u8>);
|
|
||||||
|
|
||||||
impl X509NameDer {
|
|
||||||
pub fn as_raw(&self) -> &[u8] {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for X509NameDer {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
let Ok((rem, name)) = x509_parser::x509::X509Name::from_der(&self.0) else {
|
|
||||||
return write!(f, "<invalid X.509 Name DER>");
|
|
||||||
};
|
|
||||||
if !rem.is_empty() {
|
|
||||||
return write!(f, "<invalid X.509 Name DER (trailing bytes)>");
|
|
||||||
}
|
|
||||||
write!(f, "{name}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Filename extensions registered in IANA "RPKI Repository Name Schemes".
|
|
||||||
///
|
|
||||||
/// Source: <https://www.iana.org/assignments/rpki/rpki.xhtml>
|
|
||||||
/// Snapshot date: 2026-01-28.
|
|
||||||
///
|
|
||||||
/// Notes:
|
|
||||||
/// - Includes entries marked TEMPORARY/DEPRECATED by IANA (e.g., `asa`, `gbr`).
|
|
||||||
pub const IANA_RPKI_REPOSITORY_FILENAME_EXTENSIONS: &[&str] =
|
|
||||||
&["asa", "cer", "crl", "gbr", "mft", "roa", "sig", "tak"];
|
|
||||||
@@ -1,15 +1,51 @@
|
|||||||
pub use crate::data_model::common::{Asn1TimeEncoding, Asn1TimeUtc, BigUnsigned};
|
use x509_parser::extensions::{AuthorityKeyIdentifier, ParsedExtension, X509Extension};
|
||||||
use crate::data_model::oid::{
|
use x509_parser::prelude::FromDer;
|
||||||
OID_AUTHORITY_KEY_IDENTIFIER, OID_AUTHORITY_KEY_IDENTIFIER_RAW, OID_CRL_NUMBER,
|
use x509_parser::prelude::X509Version;
|
||||||
OID_CRL_NUMBER_RAW, OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW,
|
|
||||||
OID_SUBJECT_KEY_IDENTIFIER_RAW,
|
|
||||||
};
|
|
||||||
use x509_parser::certificate::X509Certificate;
|
|
||||||
use x509_parser::extensions::{ParsedExtension, X509Extension};
|
|
||||||
use x509_parser::prelude::{FromDer, X509Version};
|
|
||||||
use x509_parser::revocation_list::CertificateRevocationList;
|
use x509_parser::revocation_list::CertificateRevocationList;
|
||||||
use x509_parser::x509::{AlgorithmIdentifier, SubjectPublicKeyInfo};
|
use x509_parser::asn1_rs::Tag;
|
||||||
use x509_parser::{asn1_rs::Class as Asn1Class, asn1_rs::Tag as Asn1Tag};
|
use x509_parser::certificate::X509Certificate;
|
||||||
|
use x509_parser::x509::SubjectPublicKeyInfo;
|
||||||
|
use x509_parser::x509::AlgorithmIdentifier;
|
||||||
|
|
||||||
|
const OID_SHA256_WITH_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.11";
|
||||||
|
const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35";
|
||||||
|
const OID_CRL_NUMBER: &str = "2.5.29.20";
|
||||||
|
const OID_SUBJECT_KEY_IDENTIFIER: &str = "2.5.29.14";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Asn1TimeEncoding {
|
||||||
|
UtcTime,
|
||||||
|
GeneralizedTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Asn1TimeUtc {
|
||||||
|
pub utc: time::OffsetDateTime,
|
||||||
|
pub encoding: Asn1TimeEncoding,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct BigUnsigned {
|
||||||
|
/// Minimal big-endian bytes. For zero, this is `[0]`.
|
||||||
|
pub bytes_be: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BigUnsigned {
|
||||||
|
pub fn to_hex_upper(&self) -> String {
|
||||||
|
hex::encode_upper(&self.bytes_be)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_u64(&self) -> Option<u64> {
|
||||||
|
if self.bytes_be.len() > 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut value: u64 = 0;
|
||||||
|
for &b in &self.bytes_be {
|
||||||
|
value = (value << 8) | (b as u64);
|
||||||
|
}
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct RevokedCert {
|
pub struct RevokedCert {
|
||||||
@@ -35,131 +71,54 @@ 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 CrlParseError {
|
pub enum CrlDecodeError {
|
||||||
#[error("X.509 CRL parse error: {0} (RFC 5280 §5.1; RFC 6487 §5)")]
|
#[error("X.509 CRL parse error: {0}")]
|
||||||
Parse(String),
|
Parse(String),
|
||||||
|
|
||||||
#[error("trailing bytes after CRL DER: {0} bytes (DER; RFC 5280 §5.1)")]
|
#[error("trailing bytes after CRL DER: {0} bytes")]
|
||||||
TrailingBytes(usize),
|
TrailingBytes(usize),
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[error("CRL version must be v2, got {0:?}")]
|
||||||
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 match TBSCertList.signature (RFC 5280 §5.1)")]
|
#[error("CRL signatureAlgorithm must be sha256WithRSAEncryption ({OID_SHA256_WITH_RSA_ENCRYPTION}), got {0}")]
|
||||||
SignatureAlgorithmMismatch,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"CRL signatureAlgorithm must be sha256WithRSAEncryption ({OID_SHA256_WITH_RSA_ENCRYPTION}), got {0} (RFC 6487 §5; RFC 7935 §2)"
|
|
||||||
)]
|
|
||||||
InvalidSignatureAlgorithm(String),
|
InvalidSignatureAlgorithm(String),
|
||||||
|
|
||||||
#[error(
|
#[error("CRL signature algorithm parameters must be absent or NULL")]
|
||||||
"CRL signature algorithm parameters must be absent or NULL (RFC 5280 §4.1.1.2; RFC 7935 §2)"
|
|
||||||
)]
|
|
||||||
InvalidSignatureAlgorithmParameters,
|
InvalidSignatureAlgorithmParameters,
|
||||||
|
|
||||||
#[error("CRL extensions must be exactly two (AKI + CRLNumber), got {0} (RFC 9829 §3.1)")]
|
#[error("CRL signatureAlgorithm must match TBSCertList.signature")]
|
||||||
|
SignatureAlgorithmMismatch,
|
||||||
|
|
||||||
|
#[error("CRL extensions must be exactly two (AKI + CRLNumber), got {0}")]
|
||||||
InvalidExtensionsCount(usize),
|
InvalidExtensionsCount(usize),
|
||||||
|
|
||||||
#[error("unsupported CRL extension OID {0} (RFC 9829 §3.1)")]
|
#[error("unsupported CRL extension OID {0}")]
|
||||||
UnsupportedExtension(String),
|
UnsupportedExtension(String),
|
||||||
|
|
||||||
#[error("duplicate CRL extension OID {0} (RFC 5280 §4.2; RFC 9829 §3.1)")]
|
#[error("duplicate CRL extension OID {0}")]
|
||||||
DuplicateExtension(String),
|
DuplicateExtension(String),
|
||||||
|
|
||||||
#[error("AuthorityKeyIdentifier CRL extension missing (RFC 9829 §3.1; RFC 5280 §5.2.1)")]
|
#[error("AuthorityKeyIdentifier must contain keyIdentifier")]
|
||||||
AkiMissing,
|
|
||||||
|
|
||||||
#[error("AuthorityKeyIdentifier must contain keyIdentifier (RFC 5280 §5.2.1; RFC 9829 §3.1)")]
|
|
||||||
AkiMissingKeyIdentifier,
|
AkiMissingKeyIdentifier,
|
||||||
|
|
||||||
#[error(
|
#[error("AuthorityKeyIdentifier must not contain authorityCertIssuer or authorityCertSerialNumber")]
|
||||||
"AuthorityKeyIdentifier must not contain authorityCertIssuer or authorityCertSerialNumber (RFC 5280 §5.2.1; RFC 9829 §3.1)"
|
|
||||||
)]
|
|
||||||
AkiHasOtherFields,
|
AkiHasOtherFields,
|
||||||
|
|
||||||
#[error("CRLNumber CRL extension missing (RFC 9829 §3.1; RFC 5280 §5.2.3)")]
|
#[error("CRLNumber must be non-critical")]
|
||||||
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) (RFC 9829 §3.1)")]
|
#[error("CRLNumber out of range (must fit in 0..2^159-1)")]
|
||||||
CrlNumberOutOfRange,
|
CrlNumberOutOfRange,
|
||||||
|
|
||||||
#[error("CRL entry extensions must not be present (RFC 6487 §5; RFC 5280 §5.1)")]
|
#[error("CRL entry extensions must not be present")]
|
||||||
EntryExtensionsNotAllowed,
|
EntryExtensionsNotAllowed,
|
||||||
|
|
||||||
#[error("CRL nextUpdate must be present (RFC 5280 §5.1.2.5; RFC 6487 §5)")]
|
#[error("CRL nextUpdate must be present (RFC 5280 §5.1.2.5)")]
|
||||||
NextUpdateMissing,
|
NextUpdateMissing,
|
||||||
|
|
||||||
#[error(
|
#[error("{field} time encoding invalid for year {year}: got {encoding:?}")]
|
||||||
"{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,
|
||||||
@@ -167,47 +126,64 @@ pub enum CrlProfileError {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum CrlDecodeError {
|
|
||||||
#[error("{0}")]
|
|
||||||
Parse(#[from] CrlParseError),
|
|
||||||
|
|
||||||
#[error("{0}")]
|
|
||||||
Validate(#[from] CrlProfileError),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RpkixCrl {
|
impl RpkixCrl {
|
||||||
/// Parse step of scheme A (`parse → validate → verify`).
|
/// Decode a DER-encoded X.509 v2 CRL and enforce the RPKI profile constraints from
|
||||||
pub fn parse_der(der: &[u8]) -> Result<RpkixCrlParsed, CrlParseError> {
|
/// `specs/prepare/data_models/04_crl.md` (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1).
|
||||||
|
pub fn decode_der(der: &[u8]) -> Result<Self, CrlDecodeError> {
|
||||||
let (rem, crl) = CertificateRevocationList::from_der(der)
|
let (rem, crl) = CertificateRevocationList::from_der(der)
|
||||||
.map_err(|e| CrlParseError::Parse(e.to_string()))?;
|
.map_err(|e| CrlDecodeError::Parse(e.to_string()))?;
|
||||||
if !rem.is_empty() {
|
if !rem.is_empty() {
|
||||||
return Err(CrlParseError::TrailingBytes(rem.len()));
|
return Err(CrlDecodeError::TrailingBytes(rem.len()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let version = match crl.version() {
|
||||||
|
Some(X509Version::V2) => 2,
|
||||||
|
Some(v) => return Err(CrlDecodeError::InvalidVersion(Some(v.0))),
|
||||||
|
None => return Err(CrlDecodeError::InvalidVersion(None)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sig_oid = crl.signature_algorithm.algorithm.to_id_string();
|
||||||
|
let tbs_sig_oid = crl.tbs_cert_list.signature.algorithm.to_id_string();
|
||||||
|
if sig_oid != tbs_sig_oid {
|
||||||
|
return Err(CrlDecodeError::SignatureAlgorithmMismatch);
|
||||||
|
}
|
||||||
|
if sig_oid != OID_SHA256_WITH_RSA_ENCRYPTION {
|
||||||
|
return Err(CrlDecodeError::InvalidSignatureAlgorithm(sig_oid));
|
||||||
|
}
|
||||||
|
validate_sig_params(&crl.signature_algorithm)?;
|
||||||
|
validate_sig_params(&crl.tbs_cert_list.signature)?;
|
||||||
|
|
||||||
|
let extensions = parse_and_validate_extensions(crl.extensions())?;
|
||||||
|
|
||||||
let revoked_certs = crl
|
let revoked_certs = crl
|
||||||
.iter_revoked_certificates()
|
.iter_revoked_certificates()
|
||||||
.map(|rc| RevokedCertParsed {
|
.map(|rc| {
|
||||||
serial_number: BigUnsigned::from_biguint(rc.serial()),
|
if !rc.extensions().is_empty() {
|
||||||
revocation_date: crate::data_model::common::asn1_time_to_model(rc.revocation_date),
|
return Err(CrlDecodeError::EntryExtensionsNotAllowed);
|
||||||
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::<Vec<_>>();
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
let this_update = asn1_time_to_model(crl.last_update());
|
||||||
|
validate_time_encoding("thisUpdate", &this_update)?;
|
||||||
|
|
||||||
let this_update = crate::data_model::common::asn1_time_to_model(crl.last_update());
|
|
||||||
let next_update = crl
|
let next_update = crl
|
||||||
.next_update()
|
.next_update()
|
||||||
.map(crate::data_model::common::asn1_time_to_model);
|
.map(asn1_time_to_model)
|
||||||
|
.ok_or(CrlDecodeError::NextUpdateMissing)?;
|
||||||
|
validate_time_encoding("nextUpdate", &next_update)?;
|
||||||
|
|
||||||
let extensions =
|
Ok(RpkixCrl {
|
||||||
parse_extensions_parse(crl.extensions()).map_err(|e| CrlParseError::Parse(e))?;
|
|
||||||
|
|
||||||
Ok(RpkixCrlParsed {
|
|
||||||
raw_der: der.to_vec(),
|
raw_der: der.to_vec(),
|
||||||
version: crl.version(),
|
version,
|
||||||
issuer_dn: crl.issuer().to_string(),
|
issuer_dn: crl.issuer().to_string(),
|
||||||
signature_algorithm: algorithm_identifier_value(&crl.signature_algorithm),
|
signature_algorithm_oid: OID_SHA256_WITH_RSA_ENCRYPTION.to_string(),
|
||||||
tbs_signature_algorithm: algorithm_identifier_value(&crl.tbs_cert_list.signature),
|
|
||||||
this_update,
|
this_update,
|
||||||
next_update,
|
next_update,
|
||||||
revoked_certs,
|
revoked_certs,
|
||||||
@@ -215,20 +191,6 @@ impl RpkixCrl {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode a DER-encoded X.509 v2 CRL and enforce the RPKI profile constraints from
|
|
||||||
/// `specs/prepare/data_models/04_crl.md` (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1).
|
|
||||||
pub fn decode_der(der: &[u8]) -> Result<Self, CrlDecodeError> {
|
|
||||||
Ok(Self::parse_der(der)?.validate_profile()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Profile validate step of scheme A (`parse → validate → verify`).
|
|
||||||
///
|
|
||||||
/// `RpkixCrl` is already profile-validated when constructed via `decode_der()` /
|
|
||||||
/// `RpkixCrlParsed::validate_profile()`.
|
|
||||||
pub fn validate_profile(&self) -> Result<(), CrlProfileError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify the cryptographic signature on this CRL using the issuer certificate.
|
/// 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)).
|
||||||
@@ -303,107 +265,55 @@ impl RpkixCrl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RpkixCrlParsed {
|
|
||||||
/// Profile validate step of scheme A (`parse → validate → verify`).
|
|
||||||
pub fn validate_profile(self) -> Result<RpkixCrl, CrlProfileError> {
|
|
||||||
let version = match self.version {
|
|
||||||
Some(X509Version::V2) => 2,
|
|
||||||
Some(v) => return Err(CrlProfileError::InvalidVersion(Some(v.0))),
|
|
||||||
None => return Err(CrlProfileError::InvalidVersion(None)),
|
|
||||||
};
|
|
||||||
|
|
||||||
// signatureAlgorithm must match tbsCertList.signature
|
|
||||||
if self.signature_algorithm != self.tbs_signature_algorithm {
|
|
||||||
return Err(CrlProfileError::SignatureAlgorithmMismatch);
|
|
||||||
}
|
|
||||||
let sig_oid = self.signature_algorithm.oid.clone();
|
|
||||||
if sig_oid != OID_SHA256_WITH_RSA_ENCRYPTION {
|
|
||||||
return Err(CrlProfileError::InvalidSignatureAlgorithm(sig_oid));
|
|
||||||
}
|
|
||||||
if !self.signature_algorithm.params_absent_or_null() {
|
|
||||||
return Err(CrlProfileError::InvalidSignatureAlgorithmParameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
let extensions = validate_extensions_profile(&self.extensions)?;
|
|
||||||
|
|
||||||
let mut revoked_out = Vec::with_capacity(self.revoked_certs.len());
|
|
||||||
for rc in self.revoked_certs {
|
|
||||||
if rc.has_extensions {
|
|
||||||
return Err(CrlProfileError::EntryExtensionsNotAllowed);
|
|
||||||
}
|
|
||||||
validate_time_encoding_rfc5280("revocationDate", &rc.revocation_date)?;
|
|
||||||
revoked_out.push(RevokedCert {
|
|
||||||
serial_number: rc.serial_number,
|
|
||||||
revocation_date: rc.revocation_date,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_time_encoding_rfc5280("thisUpdate", &self.this_update)?;
|
|
||||||
|
|
||||||
let next_update = self.next_update.ok_or(CrlProfileError::NextUpdateMissing)?;
|
|
||||||
validate_time_encoding_rfc5280("nextUpdate", &next_update)?;
|
|
||||||
|
|
||||||
Ok(RpkixCrl {
|
|
||||||
raw_der: self.raw_der,
|
|
||||||
version,
|
|
||||||
issuer_dn: self.issuer_dn,
|
|
||||||
signature_algorithm_oid: OID_SHA256_WITH_RSA_ENCRYPTION.to_string(),
|
|
||||||
this_update: self.this_update,
|
|
||||||
next_update,
|
|
||||||
revoked_certs: revoked_out,
|
|
||||||
extensions,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum CrlVerifyError {
|
pub enum CrlVerifyError {
|
||||||
#[error("issuer certificate parse error: {0} (RFC 5280 §4.1; RFC 6487 §4)")]
|
#[error("issuer certificate parse error: {0}")]
|
||||||
IssuerCertificateParse(String),
|
IssuerCertificateParse(String),
|
||||||
|
|
||||||
#[error("trailing bytes after issuer certificate DER: {0} bytes (DER; RFC 5280 §4.1)")]
|
#[error("trailing bytes after issuer certificate DER: {0} bytes")]
|
||||||
IssuerCertificateTrailingBytes(usize),
|
IssuerCertificateTrailingBytes(usize),
|
||||||
|
|
||||||
#[error("issuer SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7)")]
|
#[error("issuer SubjectPublicKeyInfo parse error: {0}")]
|
||||||
IssuerSpkiParse(String),
|
IssuerSpkiParse(String),
|
||||||
|
|
||||||
#[error(
|
#[error("trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes")]
|
||||||
"trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 5280 §4.1.2.7)"
|
|
||||||
)]
|
|
||||||
IssuerSpkiTrailingBytes(usize),
|
IssuerSpkiTrailingBytes(usize),
|
||||||
|
|
||||||
#[error("CRL parse error: {0} (RFC 5280 §5.1; RFC 6487 §5)")]
|
#[error("CRL parse error: {0}")]
|
||||||
CrlParse(String),
|
CrlParse(String),
|
||||||
|
|
||||||
#[error("trailing bytes after CRL DER: {0} bytes (DER; RFC 5280 §5.1)")]
|
#[error("trailing bytes after CRL DER: {0} bytes")]
|
||||||
CrlTrailingBytes(usize),
|
CrlTrailingBytes(usize),
|
||||||
|
|
||||||
#[error(
|
#[error("CRL issuer DN does not match issuer certificate subject")]
|
||||||
"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(
|
#[error("issuer certificate keyUsage present but missing cRLSign")]
|
||||||
"issuer certificate keyUsage present but missing cRLSign (RFC 5280 §4.2.1.3; RFC 5280 §6.3.3(f))"
|
|
||||||
)]
|
|
||||||
IssuerKeyUsageMissingCrlSign,
|
IssuerKeyUsageMissingCrlSign,
|
||||||
|
|
||||||
#[error(
|
#[error("CRL AKI.keyIdentifier does not match issuer certificate SKI")]
|
||||||
"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} (RFC 5280 §6.3.3(g); RFC 7935 §2)")]
|
#[error("CRL signature verification failed: {0}")]
|
||||||
InvalidSignature(String),
|
InvalidSignature(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_time_encoding_rfc5280(
|
fn asn1_time_to_model(t: x509_parser::time::ASN1Time) -> Asn1TimeUtc {
|
||||||
field: &'static str,
|
let encoding = if t.is_utctime() {
|
||||||
t: &Asn1TimeUtc,
|
Asn1TimeEncoding::UtcTime
|
||||||
) -> Result<(), CrlProfileError> {
|
} else {
|
||||||
|
Asn1TimeEncoding::GeneralizedTime
|
||||||
|
};
|
||||||
|
Asn1TimeUtc {
|
||||||
|
utc: t.to_datetime(),
|
||||||
|
encoding,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_time_encoding(field: &'static str, t: &Asn1TimeUtc) -> Result<(), CrlDecodeError> {
|
||||||
let year = t.utc.year();
|
let year = t.utc.year();
|
||||||
let expected = if year <= 2049 {
|
let expected = if year <= 2049 {
|
||||||
Asn1TimeEncoding::UtcTime
|
Asn1TimeEncoding::UtcTime
|
||||||
@@ -411,7 +321,7 @@ fn validate_time_encoding_rfc5280(
|
|||||||
Asn1TimeEncoding::GeneralizedTime
|
Asn1TimeEncoding::GeneralizedTime
|
||||||
};
|
};
|
||||||
if t.encoding != expected {
|
if t.encoding != expected {
|
||||||
return Err(CrlProfileError::InvalidTimeEncoding {
|
return Err(CrlDecodeError::InvalidTimeEncoding {
|
||||||
field,
|
field,
|
||||||
year,
|
year,
|
||||||
encoding: t.encoding,
|
encoding: t.encoding,
|
||||||
@@ -420,117 +330,98 @@ fn validate_time_encoding_rfc5280(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn algorithm_identifier_value(ai: &AlgorithmIdentifier<'_>) -> AlgorithmIdentifierValue {
|
fn validate_sig_params(sig: &AlgorithmIdentifier<'_>) -> Result<(), CrlDecodeError> {
|
||||||
let parameters = ai.parameters.as_ref().map(|p| AlgorithmParametersValue {
|
match sig.parameters.as_ref() {
|
||||||
class: p.class(),
|
None => Ok(()),
|
||||||
tag: p.tag(),
|
Some(p) if p.tag() == Tag::Null => Ok(()),
|
||||||
data: p.as_bytes().to_vec(),
|
Some(_p) => Err(CrlDecodeError::InvalidSignatureAlgorithmParameters),
|
||||||
});
|
}
|
||||||
// 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_extensions_parse(exts: &[X509Extension<'_>]) -> Result<Vec<CrlExtensionParsed>, String> {
|
fn parse_and_validate_extensions(exts: &[X509Extension<'_>]) -> Result<CrlExtensions, CrlDecodeError> {
|
||||||
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(CrlProfileError::InvalidExtensionsCount(exts.len()));
|
return Err(CrlDecodeError::InvalidExtensionsCount(exts.len()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut seen: Vec<String> = Vec::new();
|
|
||||||
let mut authority_key_identifier: Option<Vec<u8>> = None;
|
let mut 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 {
|
||||||
match ext {
|
let oid = ext.oid.to_id_string();
|
||||||
CrlExtensionParsed::AuthorityKeyIdentifier {
|
match oid.as_str() {
|
||||||
key_identifier,
|
OID_AUTHORITY_KEY_IDENTIFIER => {
|
||||||
has_other_fields,
|
if authority_key_identifier.is_some() {
|
||||||
critical: _,
|
return Err(CrlDecodeError::DuplicateExtension(oid));
|
||||||
} => {
|
|
||||||
let oid = OID_AUTHORITY_KEY_IDENTIFIER.to_string();
|
|
||||||
if seen.iter().any(|s| s == &oid) {
|
|
||||||
return Err(CrlProfileError::DuplicateExtension(oid));
|
|
||||||
}
|
}
|
||||||
seen.push(oid.clone());
|
let aki = parse_aki(ext)?;
|
||||||
|
authority_key_identifier = Some(aki);
|
||||||
if *has_other_fields {
|
|
||||||
return Err(CrlProfileError::AkiHasOtherFields);
|
|
||||||
}
|
}
|
||||||
let ki = key_identifier
|
OID_CRL_NUMBER => {
|
||||||
.as_ref()
|
if crl_number.is_some() {
|
||||||
.ok_or(CrlProfileError::AkiMissingKeyIdentifier)?;
|
return Err(CrlDecodeError::DuplicateExtension(oid));
|
||||||
authority_key_identifier = Some(ki.clone());
|
|
||||||
}
|
}
|
||||||
CrlExtensionParsed::CrlNumber { number, critical } => {
|
if ext.critical {
|
||||||
let oid = OID_CRL_NUMBER.to_string();
|
return Err(CrlDecodeError::CrlNumberCritical);
|
||||||
if seen.iter().any(|s| s == &oid) {
|
|
||||||
return Err(CrlProfileError::DuplicateExtension(oid));
|
|
||||||
}
|
}
|
||||||
seen.push(oid.clone());
|
let n = parse_crl_number(ext)?;
|
||||||
|
if n.bits() > 159 {
|
||||||
if *critical {
|
return Err(CrlDecodeError::CrlNumberOutOfRange);
|
||||||
return Err(CrlProfileError::CrlNumberCritical);
|
|
||||||
}
|
}
|
||||||
if number.bits() > 159 {
|
crl_number = Some(biguint_to_big_unsigned(&n));
|
||||||
return Err(CrlProfileError::CrlNumberOutOfRange);
|
|
||||||
}
|
|
||||||
crl_number = Some(BigUnsigned::from_biguint(number));
|
|
||||||
}
|
|
||||||
CrlExtensionParsed::Other { oid, .. } => {
|
|
||||||
return Err(CrlProfileError::UnsupportedExtension(oid.clone()));
|
|
||||||
}
|
}
|
||||||
|
_ => return Err(CrlDecodeError::UnsupportedExtension(oid)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(CrlExtensions {
|
Ok(CrlExtensions {
|
||||||
authority_key_identifier: authority_key_identifier.ok_or(CrlProfileError::AkiMissing)?,
|
authority_key_identifier: authority_key_identifier.unwrap(),
|
||||||
crl_number: crl_number.ok_or(CrlProfileError::CrlNumberMissing)?,
|
crl_number: crl_number.unwrap(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_aki(ext: &X509Extension<'_>) -> Result<Vec<u8>, CrlDecodeError> {
|
||||||
|
let ParsedExtension::AuthorityKeyIdentifier(aki) = ext.parsed_extension() else {
|
||||||
|
return Err(CrlDecodeError::Parse("AKI extension parse failed".into()));
|
||||||
|
};
|
||||||
|
validate_aki_profile(aki)?;
|
||||||
|
Ok(aki
|
||||||
|
.key_identifier
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(CrlDecodeError::AkiMissingKeyIdentifier)?
|
||||||
|
.0
|
||||||
|
.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_aki_profile(aki: &AuthorityKeyIdentifier<'_>) -> Result<(), CrlDecodeError> {
|
||||||
|
if aki.key_identifier.is_none() {
|
||||||
|
return Err(CrlDecodeError::AkiMissingKeyIdentifier);
|
||||||
|
}
|
||||||
|
if aki.authority_cert_issuer.is_some() || aki.authority_cert_serial.is_some() {
|
||||||
|
return Err(CrlDecodeError::AkiHasOtherFields);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_crl_number(ext: &X509Extension<'_>) -> Result<der_parser::num_bigint::BigUint, CrlDecodeError> {
|
||||||
|
match ext.parsed_extension() {
|
||||||
|
ParsedExtension::CRLNumber(n) => Ok(n.clone()),
|
||||||
|
ParsedExtension::ParseError { error } => Err(CrlDecodeError::Parse(error.to_string())),
|
||||||
|
_ => Err(CrlDecodeError::Parse("CRLNumber extension parse failed".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn biguint_to_big_unsigned(n: &der_parser::num_bigint::BigUint) -> BigUnsigned {
|
||||||
|
let mut bytes = n.to_bytes_be();
|
||||||
|
if bytes.is_empty() {
|
||||||
|
bytes.push(0);
|
||||||
|
}
|
||||||
|
BigUnsigned { bytes_be: bytes }
|
||||||
|
}
|
||||||
|
|
||||||
fn get_subject_key_identifier(cert: &X509Certificate<'_>) -> Option<Vec<u8>> {
|
fn get_subject_key_identifier(cert: &X509Certificate<'_>) -> Option<Vec<u8>> {
|
||||||
cert.extensions()
|
cert.extensions()
|
||||||
.iter()
|
.iter()
|
||||||
.find(|ext| ext.oid.as_bytes() == OID_SUBJECT_KEY_IDENTIFIER_RAW)
|
.find(|ext| ext.oid.to_id_string() == OID_SUBJECT_KEY_IDENTIFIER)
|
||||||
.and_then(|ext| match ext.parsed_extension() {
|
.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,
|
||||||
|
|||||||
@@ -1,954 +0,0 @@
|
|||||||
use crate::data_model::common::der_take_tlv;
|
|
||||||
use crate::data_model::common::{BigUnsigned, UtcTime};
|
|
||||||
use crate::data_model::oid::{OID_CT_RPKI_MANIFEST, OID_SHA256};
|
|
||||||
use crate::data_model::rc::ResourceCertificate;
|
|
||||||
use crate::data_model::signed_object::{
|
|
||||||
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectParseError, SignedObjectValidateError,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ManifestObject {
|
|
||||||
pub signed_object: RpkiSignedObject,
|
|
||||||
pub econtent_type: String,
|
|
||||||
pub manifest: ManifestEContent,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ManifestObjectParsed {
|
|
||||||
pub signed_object: RpkiSignedObjectParsed,
|
|
||||||
pub econtent_type: String,
|
|
||||||
pub manifest: Option<ManifestEContentParsed>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ManifestEContent {
|
|
||||||
pub version: u32,
|
|
||||||
pub manifest_number: BigUnsigned,
|
|
||||||
pub this_update: UtcTime,
|
|
||||||
pub next_update: UtcTime,
|
|
||||||
pub file_hash_alg: String,
|
|
||||||
/// DER-encoded content bytes of `Manifest.fileList` (SEQUENCE OF FileAndHash).
|
|
||||||
pub file_list_der: Vec<u8>,
|
|
||||||
/// Count of FileAndHash entries in `fileList`.
|
|
||||||
pub file_count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ManifestEContentParsed {
|
|
||||||
der: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct FileAndHash {
|
|
||||||
pub file_name: String,
|
|
||||||
pub hash_bytes: [u8; 32],
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum ManifestParseError {
|
|
||||||
#[error("signed object parse error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
|
|
||||||
SignedObject(#[from] SignedObjectParseError),
|
|
||||||
#[error("DER parse error: {0} (RFC 9286 §4.2; DER)")]
|
|
||||||
Parse(String),
|
|
||||||
|
|
||||||
#[error("trailing bytes after DER object: {0} bytes (RFC 9286 §4.2; DER)")]
|
|
||||||
TrailingBytes(usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum ManifestProfileError {
|
|
||||||
#[error("signed object profile error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
|
|
||||||
SignedObject(#[from] SignedObjectValidateError),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"eContentType must be id-ct-rpkiManifest ({OID_CT_RPKI_MANIFEST}), got {0} (RFC 9286 §4.1; RFC 9286 §4.4(1))"
|
|
||||||
)]
|
|
||||||
InvalidEContentType(String),
|
|
||||||
|
|
||||||
#[error("manifest profile decode error: {0} (RFC 9286 §4.2; DER)")]
|
|
||||||
ProfileDecode(String),
|
|
||||||
|
|
||||||
#[error("Manifest must be a SEQUENCE of 5 or 6 elements, got {0} (RFC 9286 §4.2)")]
|
|
||||||
InvalidManifestSequenceLen(usize),
|
|
||||||
|
|
||||||
#[error("Manifest.version must be 0, got {0} (RFC 9286 §4.2.1)")]
|
|
||||||
InvalidManifestVersion(u64),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"Manifest.manifestNumber must be non-negative INTEGER (RFC 9286 §4.2; RFC 9286 §4.2.1)"
|
|
||||||
)]
|
|
||||||
InvalidManifestNumber,
|
|
||||||
|
|
||||||
#[error("Manifest.manifestNumber longer than 20 octets (RFC 9286 §4.2.1)")]
|
|
||||||
ManifestNumberTooLong,
|
|
||||||
|
|
||||||
#[error("Manifest.thisUpdate must be GeneralizedTime (RFC 9286 §4.2)")]
|
|
||||||
InvalidThisUpdate,
|
|
||||||
|
|
||||||
#[error("Manifest.nextUpdate must be GeneralizedTime (RFC 9286 §4.2)")]
|
|
||||||
InvalidNextUpdate,
|
|
||||||
|
|
||||||
#[error("Manifest.nextUpdate must be later than thisUpdate (RFC 9286 §4.2.1)")]
|
|
||||||
NextUpdateNotLater,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"Manifest.fileHashAlg must be id-sha256 ({OID_SHA256}), got {0} (RFC 9286 §4.2.1; RFC 7935 §2)"
|
|
||||||
)]
|
|
||||||
InvalidFileHashAlg(String),
|
|
||||||
|
|
||||||
#[error("Manifest.fileList must be a SEQUENCE (RFC 9286 §4.2)")]
|
|
||||||
InvalidFileList,
|
|
||||||
|
|
||||||
#[error("FileAndHash must be SEQUENCE of 2 (RFC 9286 §4.2)")]
|
|
||||||
InvalidFileAndHash,
|
|
||||||
|
|
||||||
#[error("fileList file name invalid: {0} (RFC 9286 §4.2.2)")]
|
|
||||||
InvalidFileName(String),
|
|
||||||
|
|
||||||
#[error("fileList hash must be BIT STRING (RFC 9286 §4.2)")]
|
|
||||||
InvalidHashType,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"fileList hash BIT STRING must be octet-aligned (unused bits=0) (RFC 9286 §4.2.1; DER BIT STRING)"
|
|
||||||
)]
|
|
||||||
HashNotOctetAligned,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"fileList hash length invalid for sha256: got {0} bytes (RFC 9286 §4.2.1; RFC 7935 §2)"
|
|
||||||
)]
|
|
||||||
InvalidHashLength(usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum ManifestDecodeError {
|
|
||||||
#[error("{0}")]
|
|
||||||
Parse(#[from] ManifestParseError),
|
|
||||||
|
|
||||||
#[error("{0}")]
|
|
||||||
Validate(#[from] ManifestProfileError),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum ManifestValidateError {
|
|
||||||
#[error(
|
|
||||||
"Manifest EE certificate MUST include at least one RFC 3779 resource extension (IP or AS) (RFC 6487 §4.8.10-§4.8.11; RFC 3779; RFC 9286 §5.1)"
|
|
||||||
)]
|
|
||||||
EeResourcesMissing,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"Manifest EE certificate IP resources MUST use inherit only (RFC 9286 §5.1; RFC 3779 §2.2.3.5)"
|
|
||||||
)]
|
|
||||||
EeIpResourcesNotInherit,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"Manifest EE certificate AS resources MUST use inherit only (RFC 9286 §5.1; RFC 3779 §3.2.3.3)"
|
|
||||||
)]
|
|
||||||
EeAsResourcesNotInherit,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"Manifest EE certificate AS resources rdi MUST be absent (RFC 6487 §4.8.11; RFC 3779 §3.2.3.5)"
|
|
||||||
)]
|
|
||||||
EeAsResourcesRdiPresent,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ManifestObject {
|
|
||||||
/// Parse step of scheme A (`parse → validate → verify`).
|
|
||||||
pub fn parse_der(der: &[u8]) -> Result<ManifestObjectParsed, ManifestParseError> {
|
|
||||||
let signed_object = RpkiSignedObject::parse_der(der)?;
|
|
||||||
let econtent_type = signed_object
|
|
||||||
.signed_data
|
|
||||||
.encap_content_info
|
|
||||||
.econtent_type
|
|
||||||
.clone();
|
|
||||||
let manifest = signed_object
|
|
||||||
.signed_data
|
|
||||||
.encap_content_info
|
|
||||||
.econtent
|
|
||||||
.as_deref()
|
|
||||||
.map(ManifestEContent::parse_der)
|
|
||||||
.transpose()?;
|
|
||||||
Ok(ManifestObjectParsed {
|
|
||||||
signed_object,
|
|
||||||
econtent_type,
|
|
||||||
manifest,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Profile validate step of scheme A (`parse → validate → verify`).
|
|
||||||
///
|
|
||||||
/// `ManifestObject` is already profile-validated when constructed via `decode_der()` /
|
|
||||||
/// `ManifestObjectParsed::validate_profile()`.
|
|
||||||
pub fn validate_profile(&self) -> Result<(), ManifestProfileError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_der(der: &[u8]) -> Result<Self, ManifestDecodeError> {
|
|
||||||
Ok(Self::parse_der(der)?.validate_profile()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_signed_object(
|
|
||||||
signed_object: RpkiSignedObject,
|
|
||||||
) -> Result<Self, ManifestDecodeError> {
|
|
||||||
let econtent_type = signed_object
|
|
||||||
.signed_data
|
|
||||||
.encap_content_info
|
|
||||||
.econtent_type
|
|
||||||
.clone();
|
|
||||||
if econtent_type != OID_CT_RPKI_MANIFEST {
|
|
||||||
return Err(ManifestProfileError::InvalidEContentType(econtent_type).into());
|
|
||||||
}
|
|
||||||
let manifest =
|
|
||||||
ManifestEContent::decode_der(&signed_object.signed_data.encap_content_info.econtent)?;
|
|
||||||
Ok(Self {
|
|
||||||
signed_object,
|
|
||||||
econtent_type: OID_CT_RPKI_MANIFEST.to_string(),
|
|
||||||
manifest,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate the embedded EE certificate resources against RFC 9286 §5.1.
|
|
||||||
///
|
|
||||||
/// This does **not** perform certificate path validation. It assumes `ee` is a parsed and
|
|
||||||
/// profile-validated RPKI EE resource certificate.
|
|
||||||
pub fn validate_against_ee_cert(
|
|
||||||
&self,
|
|
||||||
ee: &ResourceCertificate,
|
|
||||||
) -> Result<(), ManifestValidateError> {
|
|
||||||
let ip = ee.tbs.extensions.ip_resources.as_ref();
|
|
||||||
let asn = ee.tbs.extensions.as_resources.as_ref();
|
|
||||||
if ip.is_none() && asn.is_none() {
|
|
||||||
return Err(ManifestValidateError::EeResourcesMissing);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ip) = ip {
|
|
||||||
if !ip.is_all_inherit() {
|
|
||||||
return Err(ManifestValidateError::EeIpResourcesNotInherit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(asn) = asn {
|
|
||||||
if asn.rdi.is_some() {
|
|
||||||
return Err(ManifestValidateError::EeAsResourcesRdiPresent);
|
|
||||||
}
|
|
||||||
if !asn.is_asnum_inherit() {
|
|
||||||
return Err(ManifestValidateError::EeAsResourcesNotInherit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate this manifest's embedded EE certificate resources.
|
|
||||||
pub fn validate_embedded_ee_cert(&self) -> Result<(), ManifestValidateError> {
|
|
||||||
let ee = &self.signed_object.signed_data.certificates[0].resource_cert;
|
|
||||||
self.validate_against_ee_cert(ee)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ManifestEContent {
|
|
||||||
/// Parse step of scheme A (`parse → validate → verify`).
|
|
||||||
pub fn parse_der(der: &[u8]) -> Result<ManifestEContentParsed, ManifestParseError> {
|
|
||||||
let (_tag, _value, rem) = der_take_tlv(der).map_err(|e| ManifestParseError::Parse(e))?;
|
|
||||||
if !rem.is_empty() {
|
|
||||||
return Err(ManifestParseError::TrailingBytes(rem.len()));
|
|
||||||
}
|
|
||||||
Ok(ManifestEContentParsed { der: der.to_vec() })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Profile validate step of scheme A (`parse → validate → verify`).
|
|
||||||
///
|
|
||||||
/// `ManifestEContent` is already profile-validated when constructed via `decode_der()` /
|
|
||||||
/// `ManifestEContentParsed::validate_profile()`.
|
|
||||||
pub fn validate_profile(&self) -> Result<(), ManifestProfileError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode the DER-encoded Manifest eContent defined in RFC 9286 §4.2 (`parse + validate`).
|
|
||||||
pub fn decode_der(der: &[u8]) -> Result<Self, ManifestDecodeError> {
|
|
||||||
Ok(Self::parse_der(der)?.validate_profile()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse and return the manifest fileList.
|
|
||||||
///
|
|
||||||
/// Note: `ManifestEContent` is profile-validated when produced via `decode_der()`, so this
|
|
||||||
/// should only fail due to internal inconsistencies (or if constructed manually).
|
|
||||||
pub fn parse_files(&self) -> Result<Vec<FileAndHash>, ManifestProfileError> {
|
|
||||||
parse_file_list_sha256_fast(&self.file_list_der)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn file_count(&self) -> usize {
|
|
||||||
self.file_count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ManifestObjectParsed {
|
|
||||||
pub fn validate_profile(self) -> Result<ManifestObject, ManifestProfileError> {
|
|
||||||
let signed_object = self.signed_object.validate_profile()?;
|
|
||||||
let econtent_type = signed_object
|
|
||||||
.signed_data
|
|
||||||
.encap_content_info
|
|
||||||
.econtent_type
|
|
||||||
.clone();
|
|
||||||
if econtent_type != OID_CT_RPKI_MANIFEST {
|
|
||||||
return Err(ManifestProfileError::InvalidEContentType(econtent_type));
|
|
||||||
}
|
|
||||||
let manifest = self
|
|
||||||
.manifest
|
|
||||||
.ok_or_else(|| ManifestProfileError::ProfileDecode("Manifest.eContent missing".into()))?
|
|
||||||
.validate_profile()?;
|
|
||||||
Ok(ManifestObject {
|
|
||||||
signed_object,
|
|
||||||
econtent_type: OID_CT_RPKI_MANIFEST.to_string(),
|
|
||||||
manifest,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ManifestEContentParsed {
|
|
||||||
pub fn validate_profile(self) -> Result<ManifestEContent, ManifestProfileError> {
|
|
||||||
decode_manifest_econtent_fast(&self.der)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_file_name_bytes(bytes: &[u8]) -> Result<(), ManifestProfileError> {
|
|
||||||
// RFC 9286 §4.2.2:
|
|
||||||
// 1+ chars from a-zA-Z0-9-_ , then '.', then 3-letter extension.
|
|
||||||
if bytes.len() < 5 {
|
|
||||||
return Err(ManifestProfileError::InvalidFileName(
|
|
||||||
String::from_utf8_lossy(bytes).into_owned(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
// "followed by a single . (DOT), followed by a three letter extension"
|
|
||||||
// -> the dot must be exactly 4 bytes from the end.
|
|
||||||
let dot_pos = bytes.len() - 4;
|
|
||||||
if bytes[dot_pos] != b'.' {
|
|
||||||
return Err(ManifestProfileError::InvalidFileName(
|
|
||||||
String::from_utf8_lossy(bytes).into_owned(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline(always)]
|
|
||||||
fn valid_base_char(b: u8) -> bool {
|
|
||||||
// RFC 9286 allowed set: a-zA-Z0-9-_
|
|
||||||
(b'0'..=b'9').contains(&b)
|
|
||||||
|| (b'a'..=b'z').contains(&b)
|
|
||||||
|| (b'A'..=b'Z').contains(&b)
|
|
||||||
|| b == b'-'
|
|
||||||
|| b == b'_'
|
|
||||||
}
|
|
||||||
|
|
||||||
for &b in &bytes[..dot_pos] {
|
|
||||||
if (b & 0x80) != 0 || !valid_base_char(b) {
|
|
||||||
return Err(ManifestProfileError::InvalidFileName(
|
|
||||||
String::from_utf8_lossy(bytes).into_owned(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let e0 = bytes[dot_pos + 1];
|
|
||||||
let e1 = bytes[dot_pos + 2];
|
|
||||||
let e2 = bytes[dot_pos + 3];
|
|
||||||
if (e0 & 0x80) != 0 || (e1 & 0x80) != 0 || (e2 & 0x80) != 0 {
|
|
||||||
return Err(ManifestProfileError::InvalidFileName(
|
|
||||||
String::from_utf8_lossy(bytes).into_owned(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline(always)]
|
|
||||||
fn lower_if_alpha(b: u8) -> Option<u8> {
|
|
||||||
match b {
|
|
||||||
b'a'..=b'z' => Some(b),
|
|
||||||
b'A'..=b'Z' => Some(b + 32),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(l0) = lower_if_alpha(e0) else {
|
|
||||||
return Err(ManifestProfileError::InvalidFileName(
|
|
||||||
String::from_utf8_lossy(bytes).into_owned(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
let Some(l1) = lower_if_alpha(e1) else {
|
|
||||||
return Err(ManifestProfileError::InvalidFileName(
|
|
||||||
String::from_utf8_lossy(bytes).into_owned(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
let Some(l2) = lower_if_alpha(e2) else {
|
|
||||||
return Err(ManifestProfileError::InvalidFileName(
|
|
||||||
String::from_utf8_lossy(bytes).into_owned(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
match [l0, l1, l2] {
|
|
||||||
// Full IANA list (see `common.rs`).
|
|
||||||
[b'a', b's', b'a']
|
|
||||||
| [b'c', b'e', b'r']
|
|
||||||
| [b'c', b'r', b'l']
|
|
||||||
| [b'g', b'b', b'r']
|
|
||||||
| [b'm', b'f', b't']
|
|
||||||
| [b'r', b'o', b'a']
|
|
||||||
| [b's', b'i', b'g']
|
|
||||||
| [b't', b'a', b'k'] => Ok(()),
|
|
||||||
_ => Err(ManifestProfileError::InvalidFileName(
|
|
||||||
String::from_utf8_lossy(bytes).into_owned(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_manifest_econtent_fast(der: &[u8]) -> Result<ManifestEContent, ManifestProfileError> {
|
|
||||||
let (tag, mut seq_content, rem) = der_take_tlv(der)
|
|
||||||
.map_err(|e| ManifestProfileError::ProfileDecode(format!("DER decode error: {e}")))?;
|
|
||||||
if !rem.is_empty() {
|
|
||||||
return Err(ManifestProfileError::ProfileDecode(format!(
|
|
||||||
"trailing bytes after DER object: {} bytes",
|
|
||||||
rem.len()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if tag != 0x30 {
|
|
||||||
return Err(ManifestProfileError::ProfileDecode(
|
|
||||||
"Manifest eContent must be SEQUENCE".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let seq_len =
|
|
||||||
der_count_elements(seq_content).map_err(|e| ManifestProfileError::ProfileDecode(e))?;
|
|
||||||
if seq_len != 5 && seq_len != 6 {
|
|
||||||
return Err(ManifestProfileError::InvalidManifestSequenceLen(seq_len));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut version: u32 = 0;
|
|
||||||
if seq_len == 6 {
|
|
||||||
let Some(&first_tag) = seq_content.first() else {
|
|
||||||
return Err(ManifestProfileError::InvalidManifestSequenceLen(0));
|
|
||||||
};
|
|
||||||
if first_tag != 0xA0 {
|
|
||||||
return Err(ManifestProfileError::ProfileDecode(
|
|
||||||
"Manifest.version must be [0] EXPLICIT INTEGER".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (_cs_tag, cs_value, after) = der_take_tlv(seq_content).map_err(|e| {
|
|
||||||
ManifestProfileError::ProfileDecode(format!("Manifest.version decode error: {e}"))
|
|
||||||
})?;
|
|
||||||
seq_content = after;
|
|
||||||
|
|
||||||
let (inner_tag, inner_value, inner_rem) = der_take_tlv(cs_value).map_err(|e| {
|
|
||||||
ManifestProfileError::ProfileDecode(format!("Manifest.version inner decode error: {e}"))
|
|
||||||
})?;
|
|
||||||
if !inner_rem.is_empty() {
|
|
||||||
return Err(ManifestProfileError::ProfileDecode(
|
|
||||||
"trailing bytes inside Manifest.version".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if inner_tag != 0x02 {
|
|
||||||
return Err(ManifestProfileError::ProfileDecode(
|
|
||||||
"Manifest.version must be [0] EXPLICIT INTEGER".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let v = der_integer_to_u64(inner_value).map_err(|e| {
|
|
||||||
ManifestProfileError::ProfileDecode(format!("Manifest.version decode error: {e}"))
|
|
||||||
})?;
|
|
||||||
if v != 0 {
|
|
||||||
return Err(ManifestProfileError::InvalidManifestVersion(v));
|
|
||||||
}
|
|
||||||
version = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (mn_tag, mn_value, after) = der_take_tlv(seq_content).map_err(|e| {
|
|
||||||
ManifestProfileError::ProfileDecode(format!("Manifest.manifestNumber decode error: {e}"))
|
|
||||||
})?;
|
|
||||||
seq_content = after;
|
|
||||||
if mn_tag != 0x02 {
|
|
||||||
return Err(ManifestProfileError::InvalidManifestNumber);
|
|
||||||
}
|
|
||||||
let manifest_number = der_integer_to_bigunsigned(mn_value)?;
|
|
||||||
|
|
||||||
let (tu_tag, tu_value, after) = der_take_tlv(seq_content).map_err(|e| {
|
|
||||||
ManifestProfileError::ProfileDecode(format!("Manifest.thisUpdate decode error: {e}"))
|
|
||||||
})?;
|
|
||||||
seq_content = after;
|
|
||||||
if tu_tag != 0x18 {
|
|
||||||
return Err(ManifestProfileError::InvalidThisUpdate);
|
|
||||||
}
|
|
||||||
let this_update = parse_generalized_time_bytes(tu_value)
|
|
||||||
.map_err(|e| ManifestProfileError::ProfileDecode(e))?;
|
|
||||||
|
|
||||||
let (nu_tag, nu_value, after) = der_take_tlv(seq_content).map_err(|e| {
|
|
||||||
ManifestProfileError::ProfileDecode(format!("Manifest.nextUpdate decode error: {e}"))
|
|
||||||
})?;
|
|
||||||
seq_content = after;
|
|
||||||
if nu_tag != 0x18 {
|
|
||||||
return Err(ManifestProfileError::InvalidNextUpdate);
|
|
||||||
}
|
|
||||||
let next_update = parse_generalized_time_bytes(nu_value)
|
|
||||||
.map_err(|e| ManifestProfileError::ProfileDecode(e))?;
|
|
||||||
if next_update <= this_update {
|
|
||||||
return Err(ManifestProfileError::NextUpdateNotLater);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (oid_tag, oid_value, after) = der_take_tlv(seq_content).map_err(|e| {
|
|
||||||
ManifestProfileError::ProfileDecode(format!("Manifest.fileHashAlg decode error: {e}"))
|
|
||||||
})?;
|
|
||||||
seq_content = after;
|
|
||||||
if oid_tag != 0x06 {
|
|
||||||
return Err(ManifestProfileError::ProfileDecode(
|
|
||||||
"Manifest.fileHashAlg must be OBJECT IDENTIFIER".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !oid_content_is_sha256(oid_value) {
|
|
||||||
return Err(ManifestProfileError::InvalidFileHashAlg(
|
|
||||||
oid_content_to_string(oid_value),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (fl_tag, fl_value, after) = der_take_tlv(seq_content).map_err(|e| {
|
|
||||||
ManifestProfileError::ProfileDecode(format!("Manifest.fileList decode error: {e}"))
|
|
||||||
})?;
|
|
||||||
seq_content = after;
|
|
||||||
if fl_tag != 0x30 {
|
|
||||||
return Err(ManifestProfileError::InvalidFileList);
|
|
||||||
}
|
|
||||||
let file_count = validate_file_list_sha256_fast(fl_value)?;
|
|
||||||
let file_list_der = fl_value.to_vec();
|
|
||||||
|
|
||||||
if !seq_content.is_empty() {
|
|
||||||
return Err(ManifestProfileError::InvalidManifestSequenceLen(seq_len));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ManifestEContent {
|
|
||||||
version,
|
|
||||||
manifest_number,
|
|
||||||
this_update,
|
|
||||||
next_update,
|
|
||||||
file_hash_alg: OID_SHA256.to_string(),
|
|
||||||
file_list_der,
|
|
||||||
file_count,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_file_list_sha256_fast(content: &[u8]) -> Result<usize, ManifestProfileError> {
|
|
||||||
let mut cur = content;
|
|
||||||
let mut count: usize = 0;
|
|
||||||
while !cur.is_empty() {
|
|
||||||
let (tag, value, rem) = der_take_tlv(cur).map_err(|e| {
|
|
||||||
ManifestProfileError::ProfileDecode(format!("fileList entry decode error: {e}"))
|
|
||||||
})?;
|
|
||||||
cur = rem;
|
|
||||||
if tag != 0x30 {
|
|
||||||
return Err(ManifestProfileError::InvalidFileAndHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut entry = value;
|
|
||||||
let (fn_tag, fn_value, entry_rem) = der_take_tlv(entry).map_err(|e| {
|
|
||||||
ManifestProfileError::ProfileDecode(format!("fileList fileName decode error: {e}"))
|
|
||||||
})?;
|
|
||||||
entry = entry_rem;
|
|
||||||
if fn_tag != 0x16 {
|
|
||||||
return Err(ManifestProfileError::InvalidFileAndHash);
|
|
||||||
}
|
|
||||||
if entry.is_empty() {
|
|
||||||
return Err(ManifestProfileError::InvalidFileAndHash);
|
|
||||||
}
|
|
||||||
validate_file_name_bytes(fn_value)?;
|
|
||||||
|
|
||||||
let (hash_tag, hash_value, entry_rem) = der_take_tlv(entry).map_err(|_e| {
|
|
||||||
// Missing second element should map to "SEQUENCE of 2" shape error.
|
|
||||||
ManifestProfileError::InvalidFileAndHash
|
|
||||||
})?;
|
|
||||||
entry = entry_rem;
|
|
||||||
if !entry.is_empty() {
|
|
||||||
return Err(ManifestProfileError::InvalidFileAndHash);
|
|
||||||
}
|
|
||||||
if hash_tag != 0x03 {
|
|
||||||
return Err(ManifestProfileError::InvalidHashType);
|
|
||||||
}
|
|
||||||
if hash_value.is_empty() {
|
|
||||||
return Err(ManifestProfileError::InvalidHashLength(0));
|
|
||||||
}
|
|
||||||
let unused_bits = hash_value[0];
|
|
||||||
if unused_bits != 0 {
|
|
||||||
return Err(ManifestProfileError::HashNotOctetAligned);
|
|
||||||
}
|
|
||||||
let bits = &hash_value[1..];
|
|
||||||
if bits.len() != 32 {
|
|
||||||
return Err(ManifestProfileError::InvalidHashLength(bits.len()));
|
|
||||||
}
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
Ok(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_file_list_sha256_fast(content: &[u8]) -> Result<Vec<FileAndHash>, ManifestProfileError> {
|
|
||||||
// Heuristic initial capacity (avoid a full pre-scan, which is expensive for xlarge manifests).
|
|
||||||
// Each FileAndHash entry is typically tens of bytes; 80 is a conservative average.
|
|
||||||
let est = (content.len() / 80).clamp(16, 4096);
|
|
||||||
let mut cur = content;
|
|
||||||
let mut out: Vec<FileAndHash> = Vec::with_capacity(est);
|
|
||||||
while !cur.is_empty() {
|
|
||||||
let (tag, value, rem) = der_take_tlv(cur).map_err(|e| {
|
|
||||||
ManifestProfileError::ProfileDecode(format!("fileList entry decode error: {e}"))
|
|
||||||
})?;
|
|
||||||
cur = rem;
|
|
||||||
if tag != 0x30 {
|
|
||||||
return Err(ManifestProfileError::InvalidFileAndHash);
|
|
||||||
}
|
|
||||||
let mut entry = value;
|
|
||||||
let (fn_tag, fn_value, entry_rem) = der_take_tlv(entry).map_err(|e| {
|
|
||||||
ManifestProfileError::ProfileDecode(format!("fileList fileName decode error: {e}"))
|
|
||||||
})?;
|
|
||||||
entry = entry_rem;
|
|
||||||
if fn_tag != 0x16 {
|
|
||||||
return Err(ManifestProfileError::InvalidFileAndHash);
|
|
||||||
}
|
|
||||||
if entry.is_empty() {
|
|
||||||
return Err(ManifestProfileError::InvalidFileAndHash);
|
|
||||||
}
|
|
||||||
let file_name = validate_and_copy_file_name(fn_value)?;
|
|
||||||
|
|
||||||
let (hash_tag, hash_value, entry_rem) = der_take_tlv(entry).map_err(|_e| {
|
|
||||||
// Missing second element should map to "SEQUENCE of 2" shape error.
|
|
||||||
ManifestProfileError::InvalidFileAndHash
|
|
||||||
})?;
|
|
||||||
entry = entry_rem;
|
|
||||||
if !entry.is_empty() {
|
|
||||||
return Err(ManifestProfileError::InvalidFileAndHash);
|
|
||||||
}
|
|
||||||
if hash_tag != 0x03 {
|
|
||||||
return Err(ManifestProfileError::InvalidHashType);
|
|
||||||
}
|
|
||||||
if hash_value.is_empty() {
|
|
||||||
return Err(ManifestProfileError::InvalidHashLength(0));
|
|
||||||
}
|
|
||||||
let unused_bits = hash_value[0];
|
|
||||||
if unused_bits != 0 {
|
|
||||||
return Err(ManifestProfileError::HashNotOctetAligned);
|
|
||||||
}
|
|
||||||
let bits = &hash_value[1..];
|
|
||||||
if bits.len() != 32 {
|
|
||||||
return Err(ManifestProfileError::InvalidHashLength(bits.len()));
|
|
||||||
}
|
|
||||||
let mut hash_bytes = [0u8; 32];
|
|
||||||
hash_bytes.copy_from_slice(bits);
|
|
||||||
out.push(FileAndHash {
|
|
||||||
file_name,
|
|
||||||
hash_bytes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_and_copy_file_name(bytes: &[u8]) -> Result<String, ManifestProfileError> {
|
|
||||||
validate_file_name_bytes(bytes)?;
|
|
||||||
Ok(unsafe { String::from_utf8_unchecked(bytes.to_vec()) })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn der_count_elements(mut input: &[u8]) -> Result<usize, String> {
|
|
||||||
let mut count: usize = 0;
|
|
||||||
while !input.is_empty() {
|
|
||||||
let (_tag, _value, rem) = der_take_tlv(input)?;
|
|
||||||
input = rem;
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
Ok(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn der_integer_to_u64(bytes: &[u8]) -> Result<u64, String> {
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return Err("INTEGER empty".into());
|
|
||||||
}
|
|
||||||
// Reject negative (two's complement).
|
|
||||||
if bytes[0] & 0x80 != 0 {
|
|
||||||
return Err("INTEGER is negative".into());
|
|
||||||
}
|
|
||||||
if bytes.len() > 8 {
|
|
||||||
return Err("INTEGER too large".into());
|
|
||||||
}
|
|
||||||
let mut v: u64 = 0;
|
|
||||||
for &b in bytes {
|
|
||||||
v = (v << 8) | (b as u64);
|
|
||||||
}
|
|
||||||
Ok(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn der_integer_to_bigunsigned(bytes: &[u8]) -> Result<BigUnsigned, ManifestProfileError> {
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return Err(ManifestProfileError::InvalidManifestNumber);
|
|
||||||
}
|
|
||||||
// Two's complement: for non-negative values, a leading 0x00 may be present.
|
|
||||||
if bytes[0] & 0x80 != 0 {
|
|
||||||
return Err(ManifestProfileError::InvalidManifestNumber);
|
|
||||||
}
|
|
||||||
let mut start = 0usize;
|
|
||||||
while start + 1 < bytes.len() && bytes[start] == 0 {
|
|
||||||
start += 1;
|
|
||||||
}
|
|
||||||
let mut minimal = bytes[start..].to_vec();
|
|
||||||
if minimal.is_empty() {
|
|
||||||
minimal.push(0);
|
|
||||||
}
|
|
||||||
if minimal.len() > 20 {
|
|
||||||
return Err(ManifestProfileError::ManifestNumberTooLong);
|
|
||||||
}
|
|
||||||
Ok(BigUnsigned { bytes_be: minimal })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_generalized_time_bytes(bytes: &[u8]) -> Result<UtcTime, String> {
|
|
||||||
// Accept "YYYYMMDDHHMMSSZ" and also allow optional fractional seconds (".fff...Z").
|
|
||||||
if !bytes.is_ascii() {
|
|
||||||
return Err("GeneralizedTime not ASCII".into());
|
|
||||||
}
|
|
||||||
let s = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
|
|
||||||
if !s.ends_with('Z') {
|
|
||||||
return Err("GeneralizedTime must end with 'Z'".into());
|
|
||||||
}
|
|
||||||
let core = &s[..s.len() - 1];
|
|
||||||
let (main, frac) = core
|
|
||||||
.split_once('.')
|
|
||||||
.map_or((core, None), |(a, b)| (a, Some(b)));
|
|
||||||
if main.len() != 14 || !main.bytes().all(|b| b.is_ascii_digit()) {
|
|
||||||
return Err("GeneralizedTime must be YYYYMMDDHHMMSS[.fff]Z".into());
|
|
||||||
}
|
|
||||||
let year: i32 = main[0..4].parse().map_err(|_| "bad year")?;
|
|
||||||
let month: u8 = main[4..6].parse().map_err(|_| "bad month")?;
|
|
||||||
let day: u8 = main[6..8].parse().map_err(|_| "bad day")?;
|
|
||||||
let hour: u8 = main[8..10].parse().map_err(|_| "bad hour")?;
|
|
||||||
let minute: u8 = main[10..12].parse().map_err(|_| "bad minute")?;
|
|
||||||
let second: u8 = main[12..14].parse().map_err(|_| "bad second")?;
|
|
||||||
|
|
||||||
let nanosecond: u32 = if let Some(frac) = frac {
|
|
||||||
if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
|
|
||||||
return Err("bad fractional seconds".into());
|
|
||||||
}
|
|
||||||
let mut ns: u32 = 0;
|
|
||||||
let mut scale: u32 = 1_000_000_000;
|
|
||||||
for (i, ch) in frac.bytes().enumerate() {
|
|
||||||
if i >= 9 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
scale /= 10;
|
|
||||||
ns += ((ch - b'0') as u32) * scale;
|
|
||||||
}
|
|
||||||
ns
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let date = time::Date::from_calendar_date(
|
|
||||||
year,
|
|
||||||
time::Month::try_from(month).map_err(|_| "bad month")?,
|
|
||||||
day,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
let t =
|
|
||||||
time::Time::from_hms_nano(hour, minute, second, nanosecond).map_err(|e| e.to_string())?;
|
|
||||||
Ok(date.with_time(t).assume_utc())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn oid_content_is_sha256(bytes: &[u8]) -> bool {
|
|
||||||
// 2.16.840.1.101.3.4.2.1
|
|
||||||
let mut arcs = oid_content_iter(bytes);
|
|
||||||
const EXPECTED: &[u64] = &[2, 16, 840, 1, 101, 3, 4, 2, 1];
|
|
||||||
for &e in EXPECTED {
|
|
||||||
match arcs.next() {
|
|
||||||
Some(v) if v == e => {}
|
|
||||||
_ => return false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
arcs.next().is_none()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn oid_content_to_string(bytes: &[u8]) -> String {
|
|
||||||
let arcs: Vec<u64> = oid_content_iter(bytes).collect();
|
|
||||||
if arcs.is_empty() {
|
|
||||||
return "<invalid oid>".to_string();
|
|
||||||
}
|
|
||||||
let mut s = String::new();
|
|
||||||
for (i, a) in arcs.iter().enumerate() {
|
|
||||||
if i > 0 {
|
|
||||||
s.push('.');
|
|
||||||
}
|
|
||||||
s.push_str(&a.to_string());
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
fn oid_content_iter(bytes: &[u8]) -> impl Iterator<Item = u64> + '_ {
|
|
||||||
struct It<'a> {
|
|
||||||
bytes: &'a [u8],
|
|
||||||
pos: usize,
|
|
||||||
first_done: bool,
|
|
||||||
first_a0: u64,
|
|
||||||
first_a1: u64,
|
|
||||||
emit_first_idx: u8,
|
|
||||||
}
|
|
||||||
impl<'a> Iterator for It<'a> {
|
|
||||||
type Item = u64;
|
|
||||||
fn next(&mut self) -> Option<u64> {
|
|
||||||
if !self.first_done {
|
|
||||||
if self.bytes.is_empty() {
|
|
||||||
self.first_done = true;
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let first = self.bytes[0] as u64;
|
|
||||||
self.first_a0 = first / 40;
|
|
||||||
self.first_a1 = first % 40;
|
|
||||||
self.pos = 1;
|
|
||||||
self.first_done = true;
|
|
||||||
self.emit_first_idx = 0;
|
|
||||||
}
|
|
||||||
if self.emit_first_idx == 0 {
|
|
||||||
self.emit_first_idx = 1;
|
|
||||||
return Some(self.first_a0);
|
|
||||||
}
|
|
||||||
if self.emit_first_idx == 1 {
|
|
||||||
self.emit_first_idx = 2;
|
|
||||||
return Some(self.first_a1);
|
|
||||||
}
|
|
||||||
if self.pos >= self.bytes.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut v: u64 = 0;
|
|
||||||
while self.pos < self.bytes.len() {
|
|
||||||
let b = self.bytes[self.pos];
|
|
||||||
self.pos += 1;
|
|
||||||
v = (v << 7) | ((b & 0x7F) as u64);
|
|
||||||
if b & 0x80 == 0 {
|
|
||||||
return Some(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
It {
|
|
||||||
bytes,
|
|
||||||
pos: 0,
|
|
||||||
first_done: false,
|
|
||||||
first_a0: 0,
|
|
||||||
first_a1: 0,
|
|
||||||
emit_first_idx: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn tlv(tag: u8, value: &[u8]) -> Vec<u8> {
|
|
||||||
assert!(value.len() < 128);
|
|
||||||
let mut out = Vec::with_capacity(2 + value.len());
|
|
||||||
out.push(tag);
|
|
||||||
out.push(value.len() as u8);
|
|
||||||
out.extend_from_slice(value);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tlv_long_len(tag: u8, len_bytes: &[u8], value: &[u8]) -> Vec<u8> {
|
|
||||||
let mut out = Vec::with_capacity(2 + len_bytes.len() + value.len());
|
|
||||||
out.push(tag);
|
|
||||||
out.push(0x80 | (len_bytes.len() as u8));
|
|
||||||
out.extend_from_slice(len_bytes);
|
|
||||||
out.extend_from_slice(value);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn der_take_tlv_supports_short_and_long_form_lengths_and_errors() {
|
|
||||||
let v = b"abc";
|
|
||||||
let der = tlv(0x04, v);
|
|
||||||
let (tag, val, rem) = der_take_tlv(&der).expect("short len");
|
|
||||||
assert_eq!(tag, 0x04);
|
|
||||||
assert_eq!(val, v);
|
|
||||||
assert!(rem.is_empty());
|
|
||||||
|
|
||||||
// Long-form length with 1 length byte (130).
|
|
||||||
let v = vec![b'x'; 130];
|
|
||||||
let der = tlv_long_len(0x04, &[0x82], &v);
|
|
||||||
let (tag, val, rem) = der_take_tlv(&der).expect("long len 1");
|
|
||||||
assert_eq!(tag, 0x04);
|
|
||||||
assert_eq!(val.len(), 130);
|
|
||||||
assert!(rem.is_empty());
|
|
||||||
|
|
||||||
// Long-form length with 2 length bytes (256).
|
|
||||||
let v = vec![b'y'; 256];
|
|
||||||
let der = tlv_long_len(0x04, &[0x01, 0x00], &v);
|
|
||||||
let (tag, val, rem) = der_take_tlv(&der).expect("long len 2");
|
|
||||||
assert_eq!(tag, 0x04);
|
|
||||||
assert_eq!(val.len(), 256);
|
|
||||||
assert!(rem.is_empty());
|
|
||||||
|
|
||||||
assert!(der_take_tlv(&[]).is_err());
|
|
||||||
assert!(der_take_tlv(&[0x04]).is_err());
|
|
||||||
|
|
||||||
// High-tag-number form not supported.
|
|
||||||
assert!(der_take_tlv(&[0x1F, 0x01, 0x00]).is_err());
|
|
||||||
|
|
||||||
// Indefinite length is not allowed in DER.
|
|
||||||
assert!(der_take_tlv(&[0x04, 0x80]).is_err());
|
|
||||||
|
|
||||||
// Invalid long-form length encoding.
|
|
||||||
assert!(der_take_tlv(&[0x04, 0x81]).is_err());
|
|
||||||
assert!(der_take_tlv(&[0x04, 0x89]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_generalized_time_bytes_accepts_fraction_and_rejects_invalid() {
|
|
||||||
let t = parse_generalized_time_bytes(b"20260101000000Z").expect("basic time");
|
|
||||||
assert_eq!(t.year(), 2026);
|
|
||||||
|
|
||||||
let t = parse_generalized_time_bytes(b"20260101000000.1Z").expect("fractional");
|
|
||||||
assert_eq!(t.nanosecond(), 100_000_000);
|
|
||||||
|
|
||||||
assert!(parse_generalized_time_bytes(b"20260101000000").is_err());
|
|
||||||
assert!(parse_generalized_time_bytes(b"20260101000000+00").is_err());
|
|
||||||
assert!(parse_generalized_time_bytes(b"2026010100000Z").is_err());
|
|
||||||
assert!(parse_generalized_time_bytes(b"20261301000000Z").is_err());
|
|
||||||
assert!(parse_generalized_time_bytes(b"20260132000000Z").is_err());
|
|
||||||
assert!(parse_generalized_time_bytes(&[0xFF]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn oid_helpers_accept_sha256_and_format_invalid() {
|
|
||||||
// 2.16.840.1.101.3.4.2.1
|
|
||||||
let sha256_oid_content = [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01];
|
|
||||||
assert!(oid_content_is_sha256(&sha256_oid_content));
|
|
||||||
assert!(!oid_content_is_sha256(&[0x55, 0x04, 0x03])); // 2.5.4.3
|
|
||||||
|
|
||||||
assert_eq!(oid_content_to_string(&[]), "<invalid oid>".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_file_list_sha256_fast_counts_and_rejects_bad_hash() {
|
|
||||||
fn file_and_hash(file: &str, digest: u8) -> Vec<u8> {
|
|
||||||
let mut hash = vec![0u8; 33];
|
|
||||||
hash[0] = 0; // unused bits
|
|
||||||
for b in &mut hash[1..] {
|
|
||||||
*b = digest;
|
|
||||||
}
|
|
||||||
let ia5 = tlv(0x16, file.as_bytes());
|
|
||||||
let bit = tlv(0x03, &hash);
|
|
||||||
let mut entry = Vec::new();
|
|
||||||
entry.extend_from_slice(&ia5);
|
|
||||||
entry.extend_from_slice(&bit);
|
|
||||||
tlv(0x30, &entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut list = Vec::new();
|
|
||||||
list.extend_from_slice(&file_and_hash("A.cer", 0xAA));
|
|
||||||
list.extend_from_slice(&file_and_hash("B.roa", 0xBB));
|
|
||||||
assert_eq!(validate_file_list_sha256_fast(&list).expect("count"), 2);
|
|
||||||
|
|
||||||
// Wrong hash length.
|
|
||||||
let mut bad = Vec::new();
|
|
||||||
let ia5 = tlv(0x16, b"A.cer");
|
|
||||||
let bit = tlv(0x03, &[0u8; 2]); // too short
|
|
||||||
let mut entry = Vec::new();
|
|
||||||
entry.extend_from_slice(&ia5);
|
|
||||||
entry.extend_from_slice(&bit);
|
|
||||||
bad.extend_from_slice(&tlv(0x30, &entry));
|
|
||||||
assert!(matches!(
|
|
||||||
validate_file_list_sha256_fast(&bad),
|
|
||||||
Err(ManifestProfileError::InvalidHashLength(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
pub mod aspa;
|
|
||||||
pub mod common;
|
|
||||||
pub mod crl;
|
pub mod crl;
|
||||||
pub mod manifest;
|
mod rc;
|
||||||
pub mod oid;
|
mod tal;
|
||||||
pub mod rc;
|
mod ta;
|
||||||
pub mod roa;
|
mod resources;
|
||||||
pub mod signed_object;
|
mod oids;
|
||||||
pub mod ta;
|
|
||||||
pub mod tal;
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
pub const OID_SHA256: &str = "2.16.840.1.101.3.4.2.1";
|
|
||||||
pub const OID_SHA256_RAW: &[u8] = &asn1_rs::oid!(raw 2.16.840.1.101.3.4.2.1);
|
|
||||||
|
|
||||||
pub const OID_SIGNED_DATA: &str = "1.2.840.113549.1.7.2";
|
|
||||||
pub const OID_SIGNED_DATA_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.7.2);
|
|
||||||
|
|
||||||
pub const OID_CMS_ATTR_CONTENT_TYPE: &str = "1.2.840.113549.1.9.3";
|
|
||||||
pub const OID_CMS_ATTR_CONTENT_TYPE_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.3);
|
|
||||||
pub const OID_CMS_ATTR_MESSAGE_DIGEST: &str = "1.2.840.113549.1.9.4";
|
|
||||||
pub const OID_CMS_ATTR_MESSAGE_DIGEST_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.4);
|
|
||||||
pub const OID_CMS_ATTR_SIGNING_TIME: &str = "1.2.840.113549.1.9.5";
|
|
||||||
pub const OID_CMS_ATTR_SIGNING_TIME_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.5);
|
|
||||||
|
|
||||||
pub const OID_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.1";
|
|
||||||
pub const OID_RSA_ENCRYPTION_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.1.1);
|
|
||||||
pub const OID_SHA256_WITH_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.11";
|
|
||||||
pub const OID_SHA256_WITH_RSA_ENCRYPTION_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.1.11);
|
|
||||||
|
|
||||||
// X.509 extensions (RFC 5280 / RFC 6487)
|
|
||||||
pub const OID_BASIC_CONSTRAINTS: &str = "2.5.29.19";
|
|
||||||
pub const OID_BASIC_CONSTRAINTS_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.19);
|
|
||||||
pub const OID_KEY_USAGE: &str = "2.5.29.15";
|
|
||||||
pub const OID_KEY_USAGE_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.15);
|
|
||||||
pub const OID_EXTENDED_KEY_USAGE: &str = "2.5.29.37";
|
|
||||||
pub const OID_EXTENDED_KEY_USAGE_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.37);
|
|
||||||
pub const OID_CRL_DISTRIBUTION_POINTS: &str = "2.5.29.31";
|
|
||||||
pub const OID_CRL_DISTRIBUTION_POINTS_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.31);
|
|
||||||
pub const OID_AUTHORITY_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.1";
|
|
||||||
pub const OID_AUTHORITY_INFO_ACCESS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.1);
|
|
||||||
pub const OID_CERTIFICATE_POLICIES: &str = "2.5.29.32";
|
|
||||||
pub const OID_CERTIFICATE_POLICIES_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.32);
|
|
||||||
|
|
||||||
pub const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35";
|
|
||||||
pub const OID_AUTHORITY_KEY_IDENTIFIER_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.35);
|
|
||||||
pub const OID_CRL_NUMBER: &str = "2.5.29.20";
|
|
||||||
pub const OID_CRL_NUMBER_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.20);
|
|
||||||
pub const OID_SUBJECT_KEY_IDENTIFIER: &str = "2.5.29.14";
|
|
||||||
pub const OID_SUBJECT_KEY_IDENTIFIER_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.14);
|
|
||||||
|
|
||||||
pub const OID_CT_RPKI_MANIFEST: &str = "1.2.840.113549.1.9.16.1.26";
|
|
||||||
pub const OID_CT_RPKI_MANIFEST_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.16.1.26);
|
|
||||||
pub const OID_CT_ROUTE_ORIGIN_AUTHZ: &str = "1.2.840.113549.1.9.16.1.24";
|
|
||||||
pub const OID_CT_ROUTE_ORIGIN_AUTHZ_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.16.1.24);
|
|
||||||
pub const OID_CT_ASPA: &str = "1.2.840.113549.1.9.16.1.49";
|
|
||||||
pub const OID_CT_ASPA_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.16.1.49);
|
|
||||||
|
|
||||||
// X.509 extensions / access methods (RFC 5280 / RFC 6487)
|
|
||||||
pub const OID_SUBJECT_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.11";
|
|
||||||
pub const OID_SUBJECT_INFO_ACCESS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.11);
|
|
||||||
pub const OID_AD_SIGNED_OBJECT: &str = "1.3.6.1.5.5.7.48.11";
|
|
||||||
pub const OID_AD_SIGNED_OBJECT_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.11);
|
|
||||||
|
|
||||||
pub const OID_AD_CA_ISSUERS: &str = "1.3.6.1.5.5.7.48.2";
|
|
||||||
pub const OID_AD_CA_ISSUERS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.2);
|
|
||||||
pub const OID_AD_CA_REPOSITORY: &str = "1.3.6.1.5.5.7.48.5";
|
|
||||||
pub const OID_AD_CA_REPOSITORY_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.5);
|
|
||||||
pub const OID_AD_RPKI_MANIFEST: &str = "1.3.6.1.5.5.7.48.10";
|
|
||||||
pub const OID_AD_RPKI_MANIFEST_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.10);
|
|
||||||
pub const OID_AD_RPKI_NOTIFY: &str = "1.3.6.1.5.5.7.48.13";
|
|
||||||
pub const OID_AD_RPKI_NOTIFY_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.13);
|
|
||||||
|
|
||||||
// RFC 3779 resource extensions (RFC 6487 profile)
|
|
||||||
pub const OID_IP_ADDR_BLOCKS: &str = "1.3.6.1.5.5.7.1.7";
|
|
||||||
pub const OID_IP_ADDR_BLOCKS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.7);
|
|
||||||
pub const OID_AUTONOMOUS_SYS_IDS: &str = "1.3.6.1.5.5.7.1.8";
|
|
||||||
pub const OID_AUTONOMOUS_SYS_IDS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.8);
|
|
||||||
|
|
||||||
// RPKI CP (RFC 6484 / RFC 6487)
|
|
||||||
pub const OID_CP_IPADDR_ASNUMBER: &str = "1.3.6.1.5.5.7.14.2";
|
|
||||||
pub const OID_CP_IPADDR_ASNUMBER_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.14.2);
|
|
||||||
13
src/data_model/oids.rs
Normal file
13
src/data_model/oids.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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";
|
||||||
1943
src/data_model/rc.rs
1943
src/data_model/rc.rs
File diff suppressed because it is too large
Load Diff
90
src/data_model/resources/as_resources.rs
Normal file
90
src/data_model/resources/as_resources.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ASIdentifiers {
|
||||||
|
pub asn: Vec<ASIdentifierChoice>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ASN
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ASIdentifierChoice {
|
||||||
|
Inherit,
|
||||||
|
ASIDsOrRanges(Vec<ASIDOrRange>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ASIDOrRange {
|
||||||
|
Id(Asn),
|
||||||
|
AsRange(ASRange),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ASRange {
|
||||||
|
pub min: Asn,
|
||||||
|
pub max: Asn,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ASRange {
|
||||||
|
/// Creates a new AS number range from the smallest and largest number.
|
||||||
|
pub fn new(min: Asn, max: Asn) -> Self {
|
||||||
|
ASRange { min, max }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an AS block covering all ASNs.
|
||||||
|
pub fn all() -> ASRange {
|
||||||
|
ASRange::new(Asn::MIN, Asn::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the smallest AS number that is part of this range.
|
||||||
|
pub fn min(self) -> Asn {
|
||||||
|
self.min
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the largest AS number that is still part of this range.
|
||||||
|
pub fn max(self) -> Asn {
|
||||||
|
self.max
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of ASNs covered by this value.
|
||||||
|
pub fn asn_count(self) -> u32 {
|
||||||
|
u32::from(self.max) - u32::from(self.min) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct Asn(u32);
|
||||||
|
|
||||||
|
impl Asn {
|
||||||
|
pub const MIN: Asn = Asn(u32::MIN);
|
||||||
|
pub const MAX: Asn = Asn(u32::MAX);
|
||||||
|
|
||||||
|
/// Creates an AS number from a `u32`.
|
||||||
|
pub fn from_u32(value: u32) -> Self {
|
||||||
|
Asn(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts an AS number into a `u32`.
|
||||||
|
pub fn into_u32(self) -> u32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts an AS number into a network-order byte array.
|
||||||
|
pub fn to_raw(self) -> [u8; 4] {
|
||||||
|
self.0.to_be_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u32> for Asn {
|
||||||
|
fn from(id: u32) -> Self {
|
||||||
|
Asn(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Asn> for u32 {
|
||||||
|
fn from(id: Asn) -> Self {
|
||||||
|
id.0
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/data_model/resources/ip_resources.rs
Normal file
46
src/data_model/resources/ip_resources.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct IPAddrBlocks {
|
||||||
|
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);
|
||||||
3
src/data_model/resources/mod.rs
Normal file
3
src/data_model/resources/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub(crate) mod ip_resources;
|
||||||
|
pub(crate) mod as_resources;
|
||||||
|
pub mod resource;
|
||||||
10
src/data_model/resources/resource.rs
Normal file
10
src/data_model/resources/resource.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use crate::data_model::resources::as_resources::ASIdentifiers;
|
||||||
|
use crate::data_model::resources::ip_resources::IPAddrBlocks;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ResourceSet {
|
||||||
|
ip_addr_blocks: IPAddrBlocks,
|
||||||
|
as_identifiers: ASIdentifiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,610 +0,0 @@
|
|||||||
use crate::data_model::common::{DerReader, der_take_tlv};
|
|
||||||
use crate::data_model::oid::OID_CT_ROUTE_ORIGIN_AUTHZ;
|
|
||||||
use crate::data_model::rc::{Afi as RcAfi, IpPrefix as RcIpPrefix, ResourceCertificate};
|
|
||||||
use crate::data_model::signed_object::{
|
|
||||||
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectParseError, SignedObjectValidateError,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct RoaObject {
|
|
||||||
pub signed_object: RpkiSignedObject,
|
|
||||||
pub econtent_type: String,
|
|
||||||
pub roa: RoaEContent,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct RoaObjectParsed {
|
|
||||||
pub signed_object: RpkiSignedObjectParsed,
|
|
||||||
pub econtent_type: String,
|
|
||||||
pub roa: Option<RoaEContentParsed>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct RoaEContent {
|
|
||||||
pub version: u32,
|
|
||||||
pub as_id: u32,
|
|
||||||
pub ip_addr_blocks: Vec<RoaIpAddressFamily>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct RoaEContentParsed {
|
|
||||||
der: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum RoaParseError {
|
|
||||||
#[error("signed object parse error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
|
|
||||||
SignedObject(#[from] SignedObjectParseError),
|
|
||||||
#[error("ROA parse error: {0} (RFC 9582 §4; DER)")]
|
|
||||||
Parse(String),
|
|
||||||
|
|
||||||
#[error("ROA trailing bytes: {0} bytes (RFC 9582 §4; DER)")]
|
|
||||||
TrailingBytes(usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum RoaProfileError {
|
|
||||||
#[error("signed object profile error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
|
|
||||||
SignedObject(#[from] SignedObjectValidateError),
|
|
||||||
|
|
||||||
#[error("ROA eContentType must be {OID_CT_ROUTE_ORIGIN_AUTHZ}, got {0} (RFC 9582 §3)")]
|
|
||||||
InvalidEContentType(String),
|
|
||||||
|
|
||||||
#[error("ROA profile decode error: {0} (RFC 9582 §4; DER)")]
|
|
||||||
ProfileDecode(String),
|
|
||||||
|
|
||||||
#[error("RouteOriginAttestation must be a SEQUENCE of 2 or 3 elements, got {0} (RFC 9582 §4)")]
|
|
||||||
InvalidAttestationSequenceLen(usize),
|
|
||||||
|
|
||||||
#[error("ROA version must be 0, got {0} (RFC 9582 §4.1)")]
|
|
||||||
InvalidVersion(u64),
|
|
||||||
|
|
||||||
#[error("ROA asID out of range (0..=4294967295), got {0} (RFC 9582 §4.2)")]
|
|
||||||
AsIdOutOfRange(u64),
|
|
||||||
|
|
||||||
#[error("ROA ipAddrBlocks must have length 1..2, got {0} (RFC 9582 §4; RFC 9582 §4.3.1)")]
|
|
||||||
InvalidIpAddrBlocksLen(usize),
|
|
||||||
|
|
||||||
#[error("ROAIPAddressFamily must be a SEQUENCE of 2 elements (RFC 9582 §4.3.1)")]
|
|
||||||
InvalidIpAddressFamily,
|
|
||||||
|
|
||||||
#[error("ROA addressFamily must be an OCTET STRING of 2 bytes (RFC 9582 §4.3.1)")]
|
|
||||||
InvalidAddressFamily,
|
|
||||||
|
|
||||||
#[error("ROA addressFamily AFI not supported: {0:02X?} (RFC 9582 §4.3.1)")]
|
|
||||||
UnsupportedAfi(Vec<u8>),
|
|
||||||
|
|
||||||
#[error("ROA contains duplicate AFI {0:?} (RFC 9582 §4.3.1)")]
|
|
||||||
DuplicateAfi(RoaAfi),
|
|
||||||
|
|
||||||
#[error("ROAAddresses must have at least one entry (RFC 9582 §4.3.2)")]
|
|
||||||
EmptyAddressList,
|
|
||||||
|
|
||||||
#[error("ROAIPAddress must be a SEQUENCE of 1..2 elements (RFC 9582 §4.3.2)")]
|
|
||||||
InvalidRoaIpAddress,
|
|
||||||
|
|
||||||
#[error("ROAIPAddress.address must be a BIT STRING (RFC 9582 §4.3.2.1; RFC 3779 §2.2.3.8)")]
|
|
||||||
InvalidPrefixBitString,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ROAIPAddress.address has invalid unused bits encoding (RFC 9582 §4.3.2.1; RFC 3779 §2.2.3.8)"
|
|
||||||
)]
|
|
||||||
InvalidPrefixUnusedBits,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ROAIPAddress.address prefix length {prefix_len} out of range for {afi:?} (RFC 9582 §4.3.2.1)"
|
|
||||||
)]
|
|
||||||
PrefixLenOutOfRange { afi: RoaAfi, prefix_len: u16 },
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ROAIPAddress.maxLength out of range for {afi:?}: prefix_len={prefix_len}, max_len={max_len} (RFC 9582 §4.3.2.2)"
|
|
||||||
)]
|
|
||||||
InvalidMaxLength {
|
|
||||||
afi: RoaAfi,
|
|
||||||
prefix_len: u16,
|
|
||||||
max_len: u16,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum RoaDecodeError {
|
|
||||||
#[error("{0}")]
|
|
||||||
Parse(#[from] RoaParseError),
|
|
||||||
|
|
||||||
#[error("{0}")]
|
|
||||||
Validate(#[from] RoaProfileError),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum RoaValidateError {
|
|
||||||
#[error("ROA EE certificate must not contain AS resources extension (RFC 9582 §5)")]
|
|
||||||
EeAsResourcesPresent,
|
|
||||||
|
|
||||||
#[error("ROA EE certificate must contain IP resources extension (RFC 9582 §5)")]
|
|
||||||
EeIpResourcesMissing,
|
|
||||||
|
|
||||||
#[error("ROA EE certificate IP resources must not use inherit (RFC 9582 §5)")]
|
|
||||||
EeIpResourcesInherit,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"ROA prefix not covered by EE certificate IP resources: {afi:?} {addr:?}/{prefix_len} (RFC 9582 §5; RFC 3779 §2.3)"
|
|
||||||
)]
|
|
||||||
PrefixNotInEeResources {
|
|
||||||
afi: RoaAfi,
|
|
||||||
addr: Vec<u8>,
|
|
||||||
prefix_len: u16,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RoaObject {
|
|
||||||
/// Parse step of scheme A (`parse → validate → verify`).
|
|
||||||
pub fn parse_der(der: &[u8]) -> Result<RoaObjectParsed, RoaParseError> {
|
|
||||||
let signed_object = RpkiSignedObject::parse_der(der)?;
|
|
||||||
let econtent_type = signed_object
|
|
||||||
.signed_data
|
|
||||||
.encap_content_info
|
|
||||||
.econtent_type
|
|
||||||
.clone();
|
|
||||||
let roa = signed_object
|
|
||||||
.signed_data
|
|
||||||
.encap_content_info
|
|
||||||
.econtent
|
|
||||||
.as_deref()
|
|
||||||
.map(RoaEContent::parse_der)
|
|
||||||
.transpose()?;
|
|
||||||
Ok(RoaObjectParsed {
|
|
||||||
signed_object,
|
|
||||||
econtent_type,
|
|
||||||
roa,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Profile validate step of scheme A (`parse → validate → verify`).
|
|
||||||
///
|
|
||||||
/// `RoaObject` is already profile-validated when constructed via `decode_der()` /
|
|
||||||
/// `RoaObjectParsed::validate_profile()`.
|
|
||||||
pub fn validate_profile(&self) -> Result<(), RoaProfileError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_der(der: &[u8]) -> Result<Self, RoaDecodeError> {
|
|
||||||
Ok(Self::parse_der(der)?.validate_profile()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_signed_object(signed_object: RpkiSignedObject) -> Result<Self, RoaDecodeError> {
|
|
||||||
let econtent_type = signed_object
|
|
||||||
.signed_data
|
|
||||||
.encap_content_info
|
|
||||||
.econtent_type
|
|
||||||
.clone();
|
|
||||||
if econtent_type != OID_CT_ROUTE_ORIGIN_AUTHZ {
|
|
||||||
return Err(RoaProfileError::InvalidEContentType(econtent_type).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let roa = RoaEContent::decode_der(&signed_object.signed_data.encap_content_info.econtent)?;
|
|
||||||
Ok(Self {
|
|
||||||
roa,
|
|
||||||
signed_object,
|
|
||||||
econtent_type: OID_CT_ROUTE_ORIGIN_AUTHZ.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate this ROA's embedded EE certificate resources.
|
|
||||||
pub fn validate_embedded_ee_cert(&self) -> Result<(), RoaValidateError> {
|
|
||||||
let ee = &self.signed_object.signed_data.certificates[0].resource_cert;
|
|
||||||
self.roa.validate_against_ee_cert(ee)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
||||||
pub enum RoaAfi {
|
|
||||||
Ipv4,
|
|
||||||
Ipv6,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RoaAfi {
|
|
||||||
fn ub(self) -> u16 {
|
|
||||||
match self {
|
|
||||||
RoaAfi::Ipv4 => 32,
|
|
||||||
RoaAfi::Ipv6 => 128,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct RoaIpAddressFamily {
|
|
||||||
pub afi: RoaAfi,
|
|
||||||
pub addresses: Vec<RoaIpAddress>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub struct RoaIpAddress {
|
|
||||||
pub prefix: IpPrefix,
|
|
||||||
pub max_length: Option<u16>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub struct IpPrefix {
|
|
||||||
pub afi: RoaAfi,
|
|
||||||
/// Prefix length in bits.
|
|
||||||
pub prefix_len: u16,
|
|
||||||
/// Network order address bytes (always 16 bytes), with host bits cleared.
|
|
||||||
///
|
|
||||||
/// For IPv4 prefixes, only the first 4 bytes are used and the remaining 12 bytes are zero.
|
|
||||||
pub addr: [u8; 16],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IpPrefix {
|
|
||||||
pub fn addr_bytes(&self) -> &[u8] {
|
|
||||||
match self.afi {
|
|
||||||
RoaAfi::Ipv4 => &self.addr[..4],
|
|
||||||
RoaAfi::Ipv6 => &self.addr[..16],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RoaEContent {
|
|
||||||
/// Parse step of scheme A (`parse → validate → verify`).
|
|
||||||
pub fn parse_der(der: &[u8]) -> Result<RoaEContentParsed, RoaParseError> {
|
|
||||||
let (_tag, _value, rem) = der_take_tlv(der).map_err(RoaParseError::Parse)?;
|
|
||||||
if !rem.is_empty() {
|
|
||||||
return Err(RoaParseError::TrailingBytes(rem.len()));
|
|
||||||
}
|
|
||||||
Ok(RoaEContentParsed { der: der.to_vec() })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Profile validate step of scheme A (`parse → validate → verify`).
|
|
||||||
///
|
|
||||||
/// `RoaEContent` is already profile-validated when constructed via `decode_der()` /
|
|
||||||
/// `RoaEContentParsed::validate_profile()`.
|
|
||||||
pub fn validate_profile(&self) -> Result<(), RoaProfileError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode the DER-encoded RouteOriginAttestation defined in RFC 9582 §4 (`parse + validate`).
|
|
||||||
pub fn decode_der(der: &[u8]) -> Result<Self, RoaDecodeError> {
|
|
||||||
Ok(Self::parse_der(der)?.validate_profile()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn canonicalize(&mut self) {
|
|
||||||
self.ip_addr_blocks.sort_by_key(|f| f.afi);
|
|
||||||
for fam in &mut self.ip_addr_blocks {
|
|
||||||
fam.addresses.sort();
|
|
||||||
fam.addresses.dedup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate ROA payload against the embedded EE resource certificate (RFC 9582 §5).
|
|
||||||
///
|
|
||||||
/// This performs the EE/payload semantic checks that do not require certificate path
|
|
||||||
/// validation.
|
|
||||||
pub fn validate_against_ee_cert(
|
|
||||||
&self,
|
|
||||||
ee: &ResourceCertificate,
|
|
||||||
) -> Result<(), RoaValidateError> {
|
|
||||||
if ee.tbs.extensions.as_resources.is_some() {
|
|
||||||
return Err(RoaValidateError::EeAsResourcesPresent);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ip = ee
|
|
||||||
.tbs
|
|
||||||
.extensions
|
|
||||||
.ip_resources
|
|
||||||
.as_ref()
|
|
||||||
.ok_or(RoaValidateError::EeIpResourcesMissing)?;
|
|
||||||
|
|
||||||
if ip.has_any_inherit() {
|
|
||||||
return Err(RoaValidateError::EeIpResourcesInherit);
|
|
||||||
}
|
|
||||||
|
|
||||||
for fam in &self.ip_addr_blocks {
|
|
||||||
for entry in &fam.addresses {
|
|
||||||
let rc_prefix = roa_prefix_to_rc(&entry.prefix);
|
|
||||||
if !ip.contains_prefix(&rc_prefix) {
|
|
||||||
return Err(RoaValidateError::PrefixNotInEeResources {
|
|
||||||
afi: entry.prefix.afi,
|
|
||||||
addr: entry.prefix.addr_bytes().to_vec(),
|
|
||||||
prefix_len: entry.prefix.prefix_len,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RoaObjectParsed {
|
|
||||||
pub fn validate_profile(self) -> Result<RoaObject, RoaProfileError> {
|
|
||||||
let signed_object = self.signed_object.validate_profile()?;
|
|
||||||
let econtent_type = signed_object
|
|
||||||
.signed_data
|
|
||||||
.encap_content_info
|
|
||||||
.econtent_type
|
|
||||||
.clone();
|
|
||||||
if econtent_type != OID_CT_ROUTE_ORIGIN_AUTHZ {
|
|
||||||
return Err(RoaProfileError::InvalidEContentType(econtent_type));
|
|
||||||
}
|
|
||||||
let roa = self
|
|
||||||
.roa
|
|
||||||
.ok_or_else(|| RoaProfileError::ProfileDecode("ROA.eContent missing".into()))?
|
|
||||||
.validate_profile()?;
|
|
||||||
Ok(RoaObject {
|
|
||||||
signed_object,
|
|
||||||
econtent_type: OID_CT_ROUTE_ORIGIN_AUTHZ.to_string(),
|
|
||||||
roa,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RoaEContentParsed {
|
|
||||||
pub fn validate_profile(self) -> Result<RoaEContent, RoaProfileError> {
|
|
||||||
fn count_elements(mut r: DerReader<'_>) -> Result<usize, String> {
|
|
||||||
let mut n = 0usize;
|
|
||||||
while !r.is_empty() {
|
|
||||||
r.skip_any()?;
|
|
||||||
n += 1;
|
|
||||||
}
|
|
||||||
Ok(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut r = DerReader::new(&self.der);
|
|
||||||
let mut seq = r
|
|
||||||
.take_sequence()
|
|
||||||
.map_err(|e| RoaProfileError::ProfileDecode(e))?;
|
|
||||||
if !r.is_empty() {
|
|
||||||
return Err(RoaProfileError::ProfileDecode(
|
|
||||||
"trailing bytes after RouteOriginAttestation".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let elem_count =
|
|
||||||
count_elements(seq).map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
if elem_count != 2 && elem_count != 3 {
|
|
||||||
return Err(RoaProfileError::InvalidAttestationSequenceLen(elem_count));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut version: u32 = 0;
|
|
||||||
if elem_count == 3 {
|
|
||||||
if seq
|
|
||||||
.peek_tag()
|
|
||||||
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?
|
|
||||||
!= 0xA0
|
|
||||||
{
|
|
||||||
return Err(RoaProfileError::ProfileDecode(
|
|
||||||
"RouteOriginAttestation.version must be [0] EXPLICIT INTEGER".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let (inner_tag, inner_val) = seq
|
|
||||||
.take_explicit(0xA0)
|
|
||||||
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
if inner_tag != 0x02 {
|
|
||||||
return Err(RoaProfileError::ProfileDecode(
|
|
||||||
"RouteOriginAttestation.version must be [0] EXPLICIT INTEGER".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let v = crate::data_model::common::der_uint_from_bytes(inner_val)
|
|
||||||
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
if v != 0 {
|
|
||||||
return Err(RoaProfileError::InvalidVersion(v));
|
|
||||||
}
|
|
||||||
version = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let as_id_u64 = seq
|
|
||||||
.take_uint_u64()
|
|
||||||
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
if as_id_u64 > u32::MAX as u64 {
|
|
||||||
return Err(RoaProfileError::AsIdOutOfRange(as_id_u64));
|
|
||||||
}
|
|
||||||
let as_id = as_id_u64 as u32;
|
|
||||||
let ip_addr_blocks = parse_ip_addr_blocks_cursor(
|
|
||||||
seq.take_sequence()
|
|
||||||
.map_err(|e| RoaProfileError::ProfileDecode(format!("ipAddrBlocks: {e}")))?,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
if !seq.is_empty() {
|
|
||||||
// Extra elements beyond the expected 2..3.
|
|
||||||
let extra =
|
|
||||||
count_elements(seq).map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
return Err(RoaProfileError::InvalidAttestationSequenceLen(
|
|
||||||
elem_count + extra,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = RoaEContent {
|
|
||||||
version,
|
|
||||||
as_id,
|
|
||||||
ip_addr_blocks,
|
|
||||||
};
|
|
||||||
out.canonicalize();
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn roa_prefix_to_rc(p: &IpPrefix) -> RcIpPrefix {
|
|
||||||
let afi = match p.afi {
|
|
||||||
RoaAfi::Ipv4 => RcAfi::Ipv4,
|
|
||||||
RoaAfi::Ipv6 => RcAfi::Ipv6,
|
|
||||||
};
|
|
||||||
RcIpPrefix {
|
|
||||||
afi,
|
|
||||||
prefix_len: p.prefix_len,
|
|
||||||
addr: p.addr_bytes().to_vec(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_ip_addr_blocks_cursor(
|
|
||||||
mut seq: DerReader<'_>,
|
|
||||||
) -> Result<Vec<RoaIpAddressFamily>, RoaProfileError> {
|
|
||||||
fn count_elements(mut r: DerReader<'_>) -> Result<usize, String> {
|
|
||||||
let mut n = 0usize;
|
|
||||||
while !r.is_empty() {
|
|
||||||
r.skip_any()?;
|
|
||||||
n += 1;
|
|
||||||
}
|
|
||||||
Ok(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
let fam_count =
|
|
||||||
count_elements(seq).map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
if fam_count == 0 || fam_count > 2 {
|
|
||||||
return Err(RoaProfileError::InvalidIpAddrBlocksLen(fam_count));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out: Vec<RoaIpAddressFamily> = Vec::with_capacity(fam_count);
|
|
||||||
while !seq.is_empty() {
|
|
||||||
let family = parse_ip_address_family_cursor(
|
|
||||||
seq.take_sequence()
|
|
||||||
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?,
|
|
||||||
)?;
|
|
||||||
if out.iter().any(|f| f.afi == family.afi) {
|
|
||||||
return Err(RoaProfileError::DuplicateAfi(family.afi));
|
|
||||||
}
|
|
||||||
out.push(family);
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_ip_address_family_cursor(
|
|
||||||
mut fam: DerReader<'_>,
|
|
||||||
) -> Result<RoaIpAddressFamily, RoaProfileError> {
|
|
||||||
let afi = {
|
|
||||||
let bytes = fam
|
|
||||||
.take_octet_string()
|
|
||||||
.map_err(|_e| RoaProfileError::InvalidAddressFamily)?;
|
|
||||||
if bytes.len() != 2 {
|
|
||||||
return Err(RoaProfileError::InvalidAddressFamily);
|
|
||||||
}
|
|
||||||
match bytes {
|
|
||||||
[0x00, 0x01] => RoaAfi::Ipv4,
|
|
||||||
[0x00, 0x02] => RoaAfi::Ipv6,
|
|
||||||
_ => return Err(RoaProfileError::UnsupportedAfi(bytes.to_vec())),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut addrs = fam
|
|
||||||
.take_sequence()
|
|
||||||
.map_err(|_e| RoaProfileError::InvalidIpAddressFamily)?;
|
|
||||||
if !fam.is_empty() {
|
|
||||||
return Err(RoaProfileError::InvalidIpAddressFamily);
|
|
||||||
}
|
|
||||||
|
|
||||||
if addrs.is_empty() {
|
|
||||||
return Err(RoaProfileError::EmptyAddressList);
|
|
||||||
}
|
|
||||||
let mut addresses: Vec<RoaIpAddress> = Vec::new();
|
|
||||||
while !addrs.is_empty() {
|
|
||||||
let entry = addrs
|
|
||||||
.take_sequence()
|
|
||||||
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
addresses.push(parse_roa_ip_address_cursor(afi, entry)?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(RoaIpAddressFamily { afi, addresses })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_roa_ip_address_cursor(
|
|
||||||
afi: RoaAfi,
|
|
||||||
mut seq: DerReader<'_>,
|
|
||||||
) -> Result<RoaIpAddress, RoaProfileError> {
|
|
||||||
if seq.is_empty() {
|
|
||||||
return Err(RoaProfileError::InvalidRoaIpAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (unused_bits, bytes) = seq
|
|
||||||
.take_bit_string()
|
|
||||||
.map_err(|_e| RoaProfileError::InvalidPrefixBitString)?;
|
|
||||||
let prefix = parse_prefix_bits_bytes(afi, unused_bits, bytes)?;
|
|
||||||
|
|
||||||
let max_length = if !seq.is_empty() {
|
|
||||||
let v = seq
|
|
||||||
.take_uint_u64()
|
|
||||||
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
|
|
||||||
let max_len: u16 = v
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_e| RoaProfileError::InvalidMaxLength {
|
|
||||||
afi,
|
|
||||||
prefix_len: prefix.prefix_len,
|
|
||||||
max_len: u16::MAX,
|
|
||||||
})?;
|
|
||||||
Some(max_len)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if !seq.is_empty() {
|
|
||||||
return Err(RoaProfileError::InvalidRoaIpAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(max_len) = max_length {
|
|
||||||
let ub = afi.ub();
|
|
||||||
if max_len > ub || max_len < prefix.prefix_len {
|
|
||||||
return Err(RoaProfileError::InvalidMaxLength {
|
|
||||||
afi,
|
|
||||||
prefix_len: prefix.prefix_len,
|
|
||||||
max_len,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(RoaIpAddress { prefix, max_length })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_prefix_bits_bytes(
|
|
||||||
afi: RoaAfi,
|
|
||||||
unused_bits: u8,
|
|
||||||
bytes: &[u8],
|
|
||||||
) -> Result<IpPrefix, RoaProfileError> {
|
|
||||||
if unused_bits > 7 {
|
|
||||||
return Err(RoaProfileError::InvalidPrefixUnusedBits);
|
|
||||||
}
|
|
||||||
if bytes.is_empty() {
|
|
||||||
if unused_bits != 0 {
|
|
||||||
return Err(RoaProfileError::InvalidPrefixUnusedBits);
|
|
||||||
}
|
|
||||||
} else if unused_bits != 0 {
|
|
||||||
let mask = (1u8 << unused_bits) - 1;
|
|
||||||
if (bytes[bytes.len() - 1] & mask) != 0 {
|
|
||||||
return Err(RoaProfileError::InvalidPrefixUnusedBits);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let prefix_len = (bytes.len() * 8)
|
|
||||||
.checked_sub(unused_bits as usize)
|
|
||||||
.ok_or(RoaProfileError::InvalidPrefixUnusedBits)? as u16;
|
|
||||||
if prefix_len > afi.ub() {
|
|
||||||
return Err(RoaProfileError::PrefixLenOutOfRange { afi, prefix_len });
|
|
||||||
}
|
|
||||||
|
|
||||||
let addr = canonicalize_prefix_addr(afi, prefix_len, bytes);
|
|
||||||
Ok(IpPrefix {
|
|
||||||
afi,
|
|
||||||
prefix_len,
|
|
||||||
addr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn canonicalize_prefix_addr(afi: RoaAfi, prefix_len: u16, bytes: &[u8]) -> [u8; 16] {
|
|
||||||
let full_len = match afi {
|
|
||||||
RoaAfi::Ipv4 => 4,
|
|
||||||
RoaAfi::Ipv6 => 16,
|
|
||||||
};
|
|
||||||
let mut addr = [0u8; 16];
|
|
||||||
let copy_len = bytes.len().min(full_len);
|
|
||||||
addr[..copy_len].copy_from_slice(&bytes[..copy_len]);
|
|
||||||
|
|
||||||
if prefix_len == 0 {
|
|
||||||
return addr;
|
|
||||||
}
|
|
||||||
|
|
||||||
let last_prefix_bit = (prefix_len - 1) as usize;
|
|
||||||
let last_prefix_byte = last_prefix_bit / 8;
|
|
||||||
let rem = (prefix_len % 8) as u8;
|
|
||||||
if rem != 0 {
|
|
||||||
let mask: u8 = 0xFF << (8 - rem);
|
|
||||||
if last_prefix_byte < full_len {
|
|
||||||
addr[last_prefix_byte] &= mask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
addr
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,288 +1,27 @@
|
|||||||
use url::Url;
|
use url::Url;
|
||||||
use x509_parser::prelude::{FromDer, X509Certificate};
|
use x509_parser::prelude::*;
|
||||||
|
|
||||||
use crate::data_model::oid::OID_CP_IPADDR_ASNUMBER;
|
use crate::data_model::resources::resource::ResourceSet;
|
||||||
use crate::data_model::rc::{
|
|
||||||
AsIdentifierChoice, IpAddressChoice, ResourceCertKind, ResourceCertificate,
|
|
||||||
ResourceCertificateParseError, ResourceCertificateParsed, ResourceCertificateProfileError,
|
|
||||||
};
|
|
||||||
use crate::data_model::tal::Tal;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
//
|
||||||
pub struct TaCertificate {
|
// #[derive(Debug, Clone)]
|
||||||
pub raw_der: Vec<u8>,
|
// pub struct TrustAnchorCert {
|
||||||
pub rc_ca: ResourceCertificate,
|
// /// 信任锚证书名称
|
||||||
}
|
// pub name: String,
|
||||||
|
//
|
||||||
#[derive(Debug, thiserror::Error)]
|
// /// 证书原始DER内容
|
||||||
pub enum TaCertificateParseError {
|
// pub cert_der: Vec<u8>,
|
||||||
#[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 struct TaCertificateParsed {
|
// pub resources: ResourceSet,
|
||||||
pub rc_parsed: ResourceCertificateParsed,
|
//
|
||||||
}
|
// ///发布点
|
||||||
|
// pub publication_point: Url,
|
||||||
#[derive(Debug, thiserror::Error)]
|
// }
|
||||||
pub enum TaCertificateProfileError {
|
//
|
||||||
#[error("resource certificate profile error: {0} (RFC 5280 §4; RFC 6487 §4)")]
|
// impl TrustAnchorCert {
|
||||||
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,183 +1,18 @@
|
|||||||
use base64::Engine;
|
/// TAL Model
|
||||||
use url::Url;
|
#[derive(Clone, Debug)]
|
||||||
|
|
||||||
#[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 {
|
||||||
pub raw: Vec<u8>,
|
/// Optional human-readable comments
|
||||||
pub comments: Vec<String>,
|
pub comments: Vec<String>,
|
||||||
pub ta_uris: Vec<Url>,
|
|
||||||
pub subject_public_key_info_der: Vec<u8>,
|
/// Ordered list of URIs pointing to the TA certificate
|
||||||
|
pub uris: Vec<TalUri>,
|
||||||
|
|
||||||
|
/// SubjectPublicKeyInfo DER
|
||||||
|
pub spki_der: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum TalParseError {
|
pub enum TalUri {
|
||||||
#[error("TAL must be valid UTF-8 (RFC 8630 §2.2)")]
|
Rsync(String),
|
||||||
InvalidUtf8,
|
Https(String),
|
||||||
}
|
|
||||||
|
|
||||||
#[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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use reqwest::blocking::Client;
|
|
||||||
|
|
||||||
use crate::sync::rrdp::Fetcher;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct HttpFetcherConfig {
|
|
||||||
pub timeout: Duration,
|
|
||||||
pub user_agent: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for HttpFetcherConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
timeout: Duration::from_secs(20),
|
|
||||||
user_agent: "rpki-dev/0.1 (stage2)".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal blocking HTTP(S) fetcher for stage2.
|
|
||||||
///
|
|
||||||
/// This is used for:
|
|
||||||
/// - downloading TAL / TA certificates (RFC 8630 §2)
|
|
||||||
/// - fetching RRDP notification/snapshot files (RFC 8182 §3.4)
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct BlockingHttpFetcher {
|
|
||||||
client: Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BlockingHttpFetcher {
|
|
||||||
pub fn new(config: HttpFetcherConfig) -> Result<Self, String> {
|
|
||||||
let client = Client::builder()
|
|
||||||
.timeout(config.timeout)
|
|
||||||
.user_agent(config.user_agent)
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(Self { client })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fetch_bytes(&self, uri: &str) -> Result<Vec<u8>, String> {
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.get(uri)
|
|
||||||
.send()
|
|
||||||
.map_err(|e| format!("http request failed: {e}"))?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(format!("http status {status}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
resp.bytes()
|
|
||||||
.map(|b| b.to_vec())
|
|
||||||
.map_err(|e| format!("http read body failed: {e}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Fetcher for BlockingHttpFetcher {
|
|
||||||
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
|
|
||||||
self.fetch_bytes(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::net::TcpListener;
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
fn spawn_one_shot_http_server(status_line: &'static str, body: &'static [u8]) -> String {
|
|
||||||
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
|
|
||||||
let addr = listener.local_addr().expect("addr");
|
|
||||||
thread::spawn(move || {
|
|
||||||
let (mut stream, _) = listener.accept().expect("accept");
|
|
||||||
let mut buf = [0u8; 1024];
|
|
||||||
let _ = stream.read(&mut buf);
|
|
||||||
let hdr = format!(
|
|
||||||
"{status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
||||||
body.len()
|
|
||||||
);
|
|
||||||
stream.write_all(hdr.as_bytes()).expect("write hdr");
|
|
||||||
stream.write_all(body).expect("write body");
|
|
||||||
});
|
|
||||||
format!("http://{}/", addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fetch_bytes_returns_body_on_success() {
|
|
||||||
let url = spawn_one_shot_http_server("HTTP/1.1 200 OK", b"hello");
|
|
||||||
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
|
|
||||||
timeout: Duration::from_secs(2),
|
|
||||||
..HttpFetcherConfig::default()
|
|
||||||
})
|
|
||||||
.expect("http");
|
|
||||||
let got = http.fetch_bytes(&url).expect("fetch");
|
|
||||||
assert_eq!(got, b"hello");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fetch_bytes_rejects_non_success_status() {
|
|
||||||
let url = spawn_one_shot_http_server("HTTP/1.1 404 Not Found", b"");
|
|
||||||
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
|
|
||||||
timeout: Duration::from_secs(2),
|
|
||||||
..HttpFetcherConfig::default()
|
|
||||||
})
|
|
||||||
.expect("http");
|
|
||||||
let err = http.fetch_bytes(&url).unwrap_err();
|
|
||||||
assert!(err.contains("http status"), "{err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod http;
|
|
||||||
pub mod rsync;
|
|
||||||
pub mod rsync_system;
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum RsyncFetchError {
|
|
||||||
#[error("rsync fetch error: {0}")]
|
|
||||||
Fetch(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type RsyncFetchResult<T> = Result<T, RsyncFetchError>;
|
|
||||||
|
|
||||||
/// Fetch repository objects from a publication point.
|
|
||||||
///
|
|
||||||
/// v1: this is intentionally abstract so unit tests can use a mock, and later we can
|
|
||||||
/// back it by calling the system `rsync` binary (RFC 6481 §5; RFC 8182 §3.4.5).
|
|
||||||
pub trait RsyncFetcher {
|
|
||||||
/// Return a list of objects as `(rsync_uri, bytes)` pairs.
|
|
||||||
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A simple "rsync" implementation backed by a local directory.
|
|
||||||
///
|
|
||||||
/// This is primarily meant for offline tests and fixtures. The key generation mimics rsync URIs:
|
|
||||||
/// `rsync_base_uri` + relative path (with `/` separators).
|
|
||||||
pub struct LocalDirRsyncFetcher {
|
|
||||||
pub root_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LocalDirRsyncFetcher {
|
|
||||||
pub fn new(root_dir: impl Into<PathBuf>) -> Self {
|
|
||||||
Self {
|
|
||||||
root_dir: root_dir.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RsyncFetcher for LocalDirRsyncFetcher {
|
|
||||||
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
|
|
||||||
let base = normalize_rsync_base_uri(rsync_base_uri);
|
|
||||||
let mut out = Vec::new();
|
|
||||||
walk_dir_collect(&self.root_dir, &self.root_dir, &base, &mut out)
|
|
||||||
.map_err(|e| RsyncFetchError::Fetch(e))?;
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn walk_dir_collect(
|
|
||||||
root: &Path,
|
|
||||||
current: &Path,
|
|
||||||
rsync_base_uri: &str,
|
|
||||||
out: &mut Vec<(String, Vec<u8>)>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let rd = std::fs::read_dir(current).map_err(|e| e.to_string())?;
|
|
||||||
for entry in rd {
|
|
||||||
let entry = entry.map_err(|e| e.to_string())?;
|
|
||||||
let path = entry.path();
|
|
||||||
let meta = entry.metadata().map_err(|e| e.to_string())?;
|
|
||||||
if meta.is_dir() {
|
|
||||||
walk_dir_collect(root, &path, rsync_base_uri, out)?;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !meta.is_file() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let rel = path
|
|
||||||
.strip_prefix(root)
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.to_string_lossy()
|
|
||||||
.replace('\\', "/");
|
|
||||||
let uri = format!("{rsync_base_uri}{rel}");
|
|
||||||
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
|
||||||
out.push((uri, bytes));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_rsync_base_uri(s: &str) -> String {
|
|
||||||
if s.ends_with('/') {
|
|
||||||
s.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{s}/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn local_dir_rsync_fetcher_collects_files_and_normalizes_base_uri() {
|
|
||||||
let tmp = tempfile::tempdir().expect("tempdir");
|
|
||||||
std::fs::create_dir_all(tmp.path().join("nested")).expect("mkdir");
|
|
||||||
std::fs::write(tmp.path().join("a.mft"), b"a").expect("write");
|
|
||||||
std::fs::write(tmp.path().join("nested").join("b.roa"), b"b").expect("write");
|
|
||||||
|
|
||||||
let f = LocalDirRsyncFetcher::new(tmp.path());
|
|
||||||
let mut objects = f
|
|
||||||
.fetch_objects("rsync://example.net/repo")
|
|
||||||
.expect("fetch_objects");
|
|
||||||
objects.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
||||||
|
|
||||||
assert_eq!(objects.len(), 2);
|
|
||||||
assert_eq!(objects[0].0, "rsync://example.net/repo/a.mft");
|
|
||||||
assert_eq!(objects[0].1, b"a");
|
|
||||||
assert_eq!(objects[1].0, "rsync://example.net/repo/nested/b.roa");
|
|
||||||
assert_eq!(objects[1].1, b"b");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn local_dir_rsync_fetcher_reports_read_dir_errors() {
|
|
||||||
let tmp = tempfile::tempdir().expect("tempdir");
|
|
||||||
let missing = tmp.path().join("missing");
|
|
||||||
let f = LocalDirRsyncFetcher::new(missing);
|
|
||||||
let err = f.fetch_objects("rsync://example.net/repo").unwrap_err();
|
|
||||||
match err {
|
|
||||||
RsyncFetchError::Fetch(msg) => assert!(!msg.is_empty()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use sha2::Digest;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::fetch::rsync::{RsyncFetchError, RsyncFetchResult, RsyncFetcher};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct SystemRsyncConfig {
|
|
||||||
pub rsync_bin: PathBuf,
|
|
||||||
pub timeout: Duration,
|
|
||||||
pub extra_args: Vec<String>,
|
|
||||||
/// Optional root directory for persistent rsync mirrors.
|
|
||||||
///
|
|
||||||
/// When set, callers may choose to sync into stable subdirectories under this
|
|
||||||
/// root (instead of a temporary directory) to benefit from rsync's incremental
|
|
||||||
/// behavior across runs.
|
|
||||||
///
|
|
||||||
/// Note: actual mirror behavior is implemented separately from config wiring.
|
|
||||||
pub mirror_root: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SystemRsyncConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
rsync_bin: PathBuf::from("rsync"),
|
|
||||||
timeout: Duration::from_secs(60),
|
|
||||||
extra_args: Vec::new(),
|
|
||||||
mirror_root: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `RsyncFetcher` implementation backed by the system `rsync` binary.
|
|
||||||
///
|
|
||||||
/// This is intended for live stage2 runs. For unit tests and offline fixtures,
|
|
||||||
/// prefer `LocalDirRsyncFetcher`.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct SystemRsyncFetcher {
|
|
||||||
config: SystemRsyncConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SystemRsyncFetcher {
|
|
||||||
pub fn new(config: SystemRsyncConfig) -> Self {
|
|
||||||
Self { config }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mirror_dst_dir(&self, normalized_rsync_base_uri: &str) -> Result<Option<PathBuf>, String> {
|
|
||||||
let Some(root) = self.config.mirror_root.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
std::fs::create_dir_all(root)
|
|
||||||
.map_err(|e| format!("create rsync mirror root failed: {}: {e}", root.display()))?;
|
|
||||||
|
|
||||||
let hash = hex::encode(sha2::Sha256::digest(normalized_rsync_base_uri.as_bytes()));
|
|
||||||
let dir = root.join(hash);
|
|
||||||
std::fs::create_dir_all(&dir).map_err(|e| {
|
|
||||||
format!(
|
|
||||||
"create rsync mirror directory failed: {}: {e}",
|
|
||||||
dir.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(Some(dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_rsync(&self, src: &str, dst: &Path) -> Result<(), String> {
|
|
||||||
// `--timeout` is I/O timeout in seconds (applies to network reads/writes).
|
|
||||||
let timeout_secs = self.config.timeout.as_secs().max(1).to_string();
|
|
||||||
|
|
||||||
let mut cmd = Command::new(&self.config.rsync_bin);
|
|
||||||
cmd.arg("-rt")
|
|
||||||
.arg("--delete")
|
|
||||||
.arg("--timeout")
|
|
||||||
.arg(timeout_secs)
|
|
||||||
.args(&self.config.extra_args)
|
|
||||||
.arg(src)
|
|
||||||
.arg(dst);
|
|
||||||
|
|
||||||
let out = cmd
|
|
||||||
.output()
|
|
||||||
.map_err(|e| format!("rsync spawn failed: {e}"))?;
|
|
||||||
if !out.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
||||||
return Err(format!(
|
|
||||||
"rsync failed: status={} stdout={} stderr={}",
|
|
||||||
out.status,
|
|
||||||
stdout.trim(),
|
|
||||||
stderr.trim()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RsyncFetcher for SystemRsyncFetcher {
|
|
||||||
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
|
|
||||||
let base = normalize_rsync_base_uri(rsync_base_uri);
|
|
||||||
if let Some(dst) = self
|
|
||||||
.mirror_dst_dir(&base)
|
|
||||||
.map_err(|e| RsyncFetchError::Fetch(e.to_string()))?
|
|
||||||
{
|
|
||||||
self.run_rsync(&base, &dst)
|
|
||||||
.map_err(RsyncFetchError::Fetch)?;
|
|
||||||
let mut out = Vec::new();
|
|
||||||
walk_dir_collect(&dst, &dst, &base, &mut out).map_err(RsyncFetchError::Fetch)?;
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmp = TempDir::new().map_err(|e| RsyncFetchError::Fetch(e.to_string()))?;
|
|
||||||
self.run_rsync(&base, tmp.path())
|
|
||||||
.map_err(RsyncFetchError::Fetch)?;
|
|
||||||
|
|
||||||
let mut out = Vec::new();
|
|
||||||
walk_dir_collect(tmp.path(), tmp.path(), &base, &mut out)
|
|
||||||
.map_err(RsyncFetchError::Fetch)?;
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TempDir {
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TempDir {
|
|
||||||
fn new() -> Result<Self, String> {
|
|
||||||
let mut p = std::env::temp_dir();
|
|
||||||
p.push(format!("rpki-system-rsync-{}", Uuid::new_v4()));
|
|
||||||
std::fs::create_dir_all(&p).map_err(|e| e.to_string())?;
|
|
||||||
Ok(Self { path: p })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path(&self) -> &Path {
|
|
||||||
&self.path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TempDir {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = std::fs::remove_dir_all(&self.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_rsync_base_uri(s: &str) -> String {
|
|
||||||
if s.ends_with('/') {
|
|
||||||
s.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{s}/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn walk_dir_collect(
|
|
||||||
root: &Path,
|
|
||||||
current: &Path,
|
|
||||||
rsync_base_uri: &str,
|
|
||||||
out: &mut Vec<(String, Vec<u8>)>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let rd = std::fs::read_dir(current).map_err(|e| e.to_string())?;
|
|
||||||
for entry in rd {
|
|
||||||
let entry = entry.map_err(|e| e.to_string())?;
|
|
||||||
let path = entry.path();
|
|
||||||
let meta = entry.metadata().map_err(|e| e.to_string())?;
|
|
||||||
if meta.is_dir() {
|
|
||||||
walk_dir_collect(root, &path, rsync_base_uri, out)?;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !meta.is_file() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let rel = path
|
|
||||||
.strip_prefix(root)
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.to_string_lossy()
|
|
||||||
.replace('\\', "/");
|
|
||||||
let uri = format!("{rsync_base_uri}{rel}");
|
|
||||||
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
|
||||||
out.push((uri, bytes));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_rsync_base_uri_appends_slash_when_missing() {
|
|
||||||
assert_eq!(
|
|
||||||
normalize_rsync_base_uri("rsync://example.net/repo"),
|
|
||||||
"rsync://example.net/repo/".to_string()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalize_rsync_base_uri("rsync://example.net/repo/"),
|
|
||||||
"rsync://example.net/repo/".to_string()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn walk_dir_collect_collects_files_and_normalizes_backslashes_in_uri() {
|
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
|
||||||
let root = temp.path();
|
|
||||||
std::fs::create_dir_all(root.join("sub")).expect("mkdir");
|
|
||||||
std::fs::write(root.join("sub").join("a.cer"), b"x").expect("write");
|
|
||||||
std::fs::write(root.join("b\\c.mft"), b"y").expect("write backslash file");
|
|
||||||
|
|
||||||
let mut out: Vec<(String, Vec<u8>)> = Vec::new();
|
|
||||||
walk_dir_collect(root, root, "rsync://example.net/repo/", &mut out).expect("walk");
|
|
||||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
|
||||||
|
|
||||||
assert_eq!(out.len(), 2);
|
|
||||||
assert_eq!(out[0].0, "rsync://example.net/repo/b/c.mft");
|
|
||||||
assert_eq!(out[0].1, b"y");
|
|
||||||
assert_eq!(out[1].0, "rsync://example.net/repo/sub/a.cer");
|
|
||||||
assert_eq!(out[1].1, b"x");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn system_rsync_fetcher_reports_spawn_and_exit_errors() {
|
|
||||||
let dst = tempfile::tempdir().expect("tempdir");
|
|
||||||
|
|
||||||
// 1) Spawn error.
|
|
||||||
let f = SystemRsyncFetcher::new(SystemRsyncConfig {
|
|
||||||
rsync_bin: PathBuf::from("/this/does/not/exist/rsync"),
|
|
||||||
timeout: Duration::from_secs(1),
|
|
||||||
extra_args: Vec::new(),
|
|
||||||
mirror_root: None,
|
|
||||||
});
|
|
||||||
let e = f
|
|
||||||
.run_rsync("rsync://example.net/repo/", dst.path())
|
|
||||||
.expect_err("spawn must fail");
|
|
||||||
assert!(e.contains("rsync spawn failed:"), "{e}");
|
|
||||||
|
|
||||||
// 2) Non-zero exit status.
|
|
||||||
let f = SystemRsyncFetcher::new(SystemRsyncConfig {
|
|
||||||
rsync_bin: PathBuf::from("false"),
|
|
||||||
timeout: Duration::from_secs(1),
|
|
||||||
extra_args: Vec::new(),
|
|
||||||
mirror_root: None,
|
|
||||||
});
|
|
||||||
let e = f
|
|
||||||
.run_rsync("rsync://example.net/repo/", dst.path())
|
|
||||||
.expect_err("false must fail");
|
|
||||||
assert!(e.contains("rsync failed:"), "{e}");
|
|
||||||
assert!(e.contains("status="), "{e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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,24 +1 @@
|
|||||||
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;
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum SyncPreference {
|
|
||||||
RrdpThenRsync,
|
|
||||||
RsyncOnly,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SyncPreference {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::RrdpThenRsync
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum CaFailedFetchPolicy {
|
|
||||||
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()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct RfcRef(pub &'static str);
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct Warning {
|
|
||||||
pub message: String,
|
|
||||||
pub rfc_refs: Vec<RfcRef>,
|
|
||||||
pub context: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Warning {
|
|
||||||
pub fn new(message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
message: message.into(),
|
|
||||||
rfc_refs: Vec::new(),
|
|
||||||
context: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_rfc_refs(mut self, refs: &[RfcRef]) -> Self {
|
|
||||||
self.rfc_refs.extend_from_slice(refs);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_context(mut self, context: impl Into<String>) -> Self {
|
|
||||||
self.context = Some(context.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2165
src/storage.rs
2165
src/storage.rs
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
pub mod repo;
|
|
||||||
pub mod rrdp;
|
|
||||||
pub(crate) mod store_projection;
|
|
||||||
955
src/sync/repo.rs
955
src/sync/repo.rs
@@ -1,955 +0,0 @@
|
|||||||
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
2415
src/sync/rrdp.rs
File diff suppressed because it is too large
Load Diff
@@ -1,262 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
use crate::data_model::oid::{OID_AD_CA_REPOSITORY, OID_AD_RPKI_MANIFEST, OID_AD_RPKI_NOTIFY};
|
|
||||||
use crate::data_model::rc::{ResourceCertKind, ResourceCertificate, SubjectInfoAccess};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CaInstanceUris {
|
|
||||||
/// CA repository base rsync URI (must end with `/`).
|
|
||||||
pub rsync_base_uri: String,
|
|
||||||
/// rsync URI for the manifest object (`.mft`).
|
|
||||||
pub manifest_rsync_uri: String,
|
|
||||||
/// Publication point rsync URI (RFC 9286 terminology). In v1 this equals `rsync_base_uri`.
|
|
||||||
pub publication_point_rsync_uri: String,
|
|
||||||
/// Optional RRDP notification URI (https).
|
|
||||||
pub rrdp_notification_uri: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum CaInstanceUrisError {
|
|
||||||
#[error("certificate must be a CA certificate (RFC 6487 §4.8.1)")]
|
|
||||||
NotCa,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"CA certificate must contain Subject Information Access extension (RFC 6487 §4.8.8; RFC 5280 §4.2.2.2)"
|
|
||||||
)]
|
|
||||||
MissingSia,
|
|
||||||
|
|
||||||
#[error("CA certificate SIA must contain id-ad-caRepository rsync URI (RFC 6487 §4.8.8.1)")]
|
|
||||||
MissingCaRepository,
|
|
||||||
|
|
||||||
#[error("CA certificate SIA must contain id-ad-rpkiManifest rsync URI (RFC 6487 §4.8.8.2)")]
|
|
||||||
MissingRpkiManifest,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"SIA id-ad-caRepository accessLocation must be rsync:// URI, got {0} (RFC 6487 §4.8.8.1)"
|
|
||||||
)]
|
|
||||||
CaRepositoryNotRsync(String),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"SIA id-ad-rpkiManifest accessLocation must be rsync:// URI, got {0} (RFC 6487 §4.8.8.2)"
|
|
||||||
)]
|
|
||||||
RpkiManifestNotRsync(String),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"SIA id-ad-rpkiNotify accessLocation must be https:// URI, got {0} (RFC 8182 §3.4.1; RFC 6487 §4.8.8.3)"
|
|
||||||
)]
|
|
||||||
RpkiNotifyNotHttps(String),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"manifest rsync URI must be under CA publication point: manifest={manifest_rsync_uri} publication_point={publication_point_rsync_uri} (RFC 9286 §6.1)"
|
|
||||||
)]
|
|
||||||
ManifestNotUnderPublicationPoint {
|
|
||||||
manifest_rsync_uri: String,
|
|
||||||
publication_point_rsync_uri: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ca_instance_uris_from_ca_certificate(
|
|
||||||
cert: &ResourceCertificate,
|
|
||||||
) -> Result<CaInstanceUris, CaInstanceUrisError> {
|
|
||||||
if cert.kind != ResourceCertKind::Ca {
|
|
||||||
return Err(CaInstanceUrisError::NotCa);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sia = cert
|
|
||||||
.tbs
|
|
||||||
.extensions
|
|
||||||
.subject_info_access
|
|
||||||
.as_ref()
|
|
||||||
.ok_or(CaInstanceUrisError::MissingSia)?;
|
|
||||||
|
|
||||||
let access_descriptions = match sia {
|
|
||||||
SubjectInfoAccess::Ca(ca) => &ca.access_descriptions,
|
|
||||||
SubjectInfoAccess::Ee(_ee) => return Err(CaInstanceUrisError::MissingSia),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut ca_repo: Option<String> = None;
|
|
||||||
let mut manifest: Option<String> = None;
|
|
||||||
let mut notify: Option<String> = None;
|
|
||||||
|
|
||||||
for ad in access_descriptions {
|
|
||||||
if ad.access_method_oid == OID_AD_CA_REPOSITORY {
|
|
||||||
let u = ad.access_location.as_str();
|
|
||||||
if !u.starts_with("rsync://") {
|
|
||||||
return Err(CaInstanceUrisError::CaRepositoryNotRsync(u.to_string()));
|
|
||||||
}
|
|
||||||
ca_repo.get_or_insert(u.to_string());
|
|
||||||
} else if ad.access_method_oid == OID_AD_RPKI_MANIFEST {
|
|
||||||
let u = ad.access_location.as_str();
|
|
||||||
if !u.starts_with("rsync://") {
|
|
||||||
return Err(CaInstanceUrisError::RpkiManifestNotRsync(u.to_string()));
|
|
||||||
}
|
|
||||||
manifest.get_or_insert(u.to_string());
|
|
||||||
} else if ad.access_method_oid == OID_AD_RPKI_NOTIFY {
|
|
||||||
let u = ad.access_location.as_str();
|
|
||||||
if !u.starts_with("https://") {
|
|
||||||
return Err(CaInstanceUrisError::RpkiNotifyNotHttps(u.to_string()));
|
|
||||||
}
|
|
||||||
notify.get_or_insert(u.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut publication_point_rsync_uri =
|
|
||||||
ca_repo.ok_or(CaInstanceUrisError::MissingCaRepository)?;
|
|
||||||
if !publication_point_rsync_uri.ends_with('/') {
|
|
||||||
publication_point_rsync_uri.push('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
let rsync_base_uri = publication_point_rsync_uri.clone();
|
|
||||||
|
|
||||||
let manifest_rsync_uri = manifest.ok_or(CaInstanceUrisError::MissingRpkiManifest)?;
|
|
||||||
if !manifest_rsync_uri.starts_with(&publication_point_rsync_uri) {
|
|
||||||
return Err(CaInstanceUrisError::ManifestNotUnderPublicationPoint {
|
|
||||||
manifest_rsync_uri,
|
|
||||||
publication_point_rsync_uri,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(CaInstanceUris {
|
|
||||||
rsync_base_uri,
|
|
||||||
manifest_rsync_uri,
|
|
||||||
publication_point_rsync_uri,
|
|
||||||
rrdp_notification_uri: notify,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,567 +0,0 @@
|
|||||||
use crate::data_model::common::BigUnsigned;
|
|
||||||
use crate::data_model::crl::{CrlDecodeError, CrlVerifyError, RpkixCrl};
|
|
||||||
use crate::data_model::rc::{
|
|
||||||
ResourceCertKind, ResourceCertificate, ResourceCertificateDecodeError,
|
|
||||||
};
|
|
||||||
use x509_parser::prelude::{FromDer, X509Certificate};
|
|
||||||
|
|
||||||
use crate::validation::x509_name::x509_names_equivalent;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use x509_parser::x509::SubjectPublicKeyInfo;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ValidatedEeCertPath {
|
|
||||||
pub ee: ResourceCertificate,
|
|
||||||
pub issuer_ca: ResourceCertificate,
|
|
||||||
pub issuer_crl: RpkixCrl,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum CertPathError {
|
|
||||||
#[error("EE certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
|
|
||||||
EeDecode(#[from] ResourceCertificateDecodeError),
|
|
||||||
|
|
||||||
#[error("issuer CA certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
|
|
||||||
IssuerDecode(ResourceCertificateDecodeError),
|
|
||||||
|
|
||||||
#[error("issuer CRL decode failed: {0} (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1)")]
|
|
||||||
CrlDecode(#[from] CrlDecodeError),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"issuer certificate must be a CA resource certificate (RFC 6487 §4.8.1; RFC 5280 §4.2.1.9)"
|
|
||||||
)]
|
|
||||||
IssuerNotCa,
|
|
||||||
|
|
||||||
#[error("EE certificate must be an EE resource certificate (RFC 6487 §4)")]
|
|
||||||
EeNotEe,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"EE issuer DN does not match issuer CA subject DN: ee.issuer={ee_issuer_dn} issuer.subject={issuer_subject_dn} (RFC 5280 §6.1)"
|
|
||||||
)]
|
|
||||||
IssuerSubjectMismatch {
|
|
||||||
ee_issuer_dn: String,
|
|
||||||
issuer_subject_dn: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
#[error("EE certificate signature verification failed: {0} (RFC 5280 §6.1)")]
|
|
||||||
EeSignatureInvalid(String),
|
|
||||||
|
|
||||||
#[error("issuer SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7)")]
|
|
||||||
IssuerSpkiParse(String),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 5280 §4.1.2.7)"
|
|
||||||
)]
|
|
||||||
IssuerSpkiTrailingBytes(usize),
|
|
||||||
|
|
||||||
#[error("EE KeyUsage extension missing (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
|
|
||||||
KeyUsageMissing,
|
|
||||||
|
|
||||||
#[error("EE KeyUsage criticality must be critical (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
|
|
||||||
KeyUsageNotCritical,
|
|
||||||
|
|
||||||
#[error("EE KeyUsage must have only digitalSignature set (RFC 6487 §4.8.4)")]
|
|
||||||
KeyUsageInvalidBits,
|
|
||||||
|
|
||||||
#[error("issuer CA subjectKeyIdentifier missing (RFC 6487 §4.8.2)")]
|
|
||||||
IssuerSkiMissing,
|
|
||||||
|
|
||||||
#[error("EE authorityKeyIdentifier missing (RFC 6487 §4.8.3; RFC 5280 §4.2.1.1)")]
|
|
||||||
EeAkiMissing,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"EE authorityKeyIdentifier does not match issuer subjectKeyIdentifier (RFC 6487 §4.8.3)"
|
|
||||||
)]
|
|
||||||
EeAkiMismatch,
|
|
||||||
|
|
||||||
#[error("EE authorityInfoAccess missing (RFC 6487 §4.8.7; RFC 5280 §4.2.2.1)")]
|
|
||||||
EeAiaMissing,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"EE authorityInfoAccess does not reference issuer certificate rsync URI (RFC 6487 §4.8.7)"
|
|
||||||
)]
|
|
||||||
EeAiaIssuerUriMismatch,
|
|
||||||
|
|
||||||
#[error("EE CRLDistributionPoints missing (RFC 6487 §4.8.6; RFC 5280 §4.2.1.13)")]
|
|
||||||
EeCrlDpMissing,
|
|
||||||
|
|
||||||
#[error("EE CRLDistributionPoints does not reference issuer CRL rsync URI (RFC 6487 §4.8.6)")]
|
|
||||||
EeCrlDpUriMismatch,
|
|
||||||
|
|
||||||
#[error("certificate not valid at validation_time (RFC 5280 §4.1.2.5; RFC 5280 §6.1)")]
|
|
||||||
CertificateNotValidAtTime,
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"CRL signature/binding verification failed: {0} (RFC 5280 §6.3.3; RFC 6487 §5; RFC 9829 §3.1)"
|
|
||||||
)]
|
|
||||||
CrlVerify(#[from] CrlVerifyError),
|
|
||||||
|
|
||||||
#[error(
|
|
||||||
"CRL not valid at validation_time (RFC 5280 §6.3.3(g); RFC 5280 §5.1.2.4-§5.1.2.5; RFC 6487 §5)"
|
|
||||||
)]
|
|
||||||
CrlNotValidAtTime,
|
|
||||||
|
|
||||||
#[error("EE certificate is revoked by issuer CRL (RFC 5280 §6.3.3; RFC 6487 §5)")]
|
|
||||||
EeRevoked,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate_ee_cert_path(
|
|
||||||
ee_cert_der: &[u8],
|
|
||||||
issuer_ca_der: &[u8],
|
|
||||||
issuer_crl_der: &[u8],
|
|
||||||
issuer_ca_rsync_uri: Option<&str>,
|
|
||||||
issuer_crl_rsync_uri: Option<&str>,
|
|
||||||
validation_time: time::OffsetDateTime,
|
|
||||||
) -> Result<ValidatedEeCertPath, CertPathError> {
|
|
||||||
let ee = ResourceCertificate::decode_der(ee_cert_der)?;
|
|
||||||
if ee.kind != ResourceCertKind::Ee {
|
|
||||||
return Err(CertPathError::EeNotEe);
|
|
||||||
}
|
|
||||||
|
|
||||||
let issuer_ca =
|
|
||||||
ResourceCertificate::decode_der(issuer_ca_der).map_err(CertPathError::IssuerDecode)?;
|
|
||||||
if issuer_ca.kind != ResourceCertKind::Ca {
|
|
||||||
return Err(CertPathError::IssuerNotCa);
|
|
||||||
}
|
|
||||||
let issuer_spki = parse_subject_pki_from_der(&issuer_ca.tbs.subject_public_key_info)?;
|
|
||||||
|
|
||||||
if !x509_names_equivalent(&ee.tbs.issuer_name, &issuer_ca.tbs.subject_name) {
|
|
||||||
return Err(CertPathError::IssuerSubjectMismatch {
|
|
||||||
ee_issuer_dn: ee.tbs.issuer_name.to_string(),
|
|
||||||
issuer_subject_dn: issuer_ca.tbs.subject_name.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_ee_aki_matches_issuer_ski(&ee, &issuer_ca)?;
|
|
||||||
if let Some(expected_issuer_uri) = issuer_ca_rsync_uri {
|
|
||||||
validate_ee_aia_points_to_issuer_uri(&ee, expected_issuer_uri)?;
|
|
||||||
}
|
|
||||||
if let Some(expected_crl_uri) = issuer_crl_rsync_uri {
|
|
||||||
validate_ee_crldp_contains_issuer_crl_uri(&ee, expected_crl_uri)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !time_within_validity(
|
|
||||||
validation_time,
|
|
||||||
ee.tbs.validity_not_before,
|
|
||||||
ee.tbs.validity_not_after,
|
|
||||||
) || !time_within_validity(
|
|
||||||
validation_time,
|
|
||||||
issuer_ca.tbs.validity_not_before,
|
|
||||||
issuer_ca.tbs.validity_not_after,
|
|
||||||
) {
|
|
||||||
return Err(CertPathError::CertificateNotValidAtTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ee_x509 = parse_x509_cert(ee_cert_der)?;
|
|
||||||
verify_ee_cert_signature(&ee_x509, &issuer_spki)?;
|
|
||||||
validate_ee_key_usage(&ee_x509)?;
|
|
||||||
|
|
||||||
let issuer_crl = RpkixCrl::decode_der(issuer_crl_der)?;
|
|
||||||
issuer_crl.verify_signature_with_issuer_certificate_der(issuer_ca_der)?;
|
|
||||||
if !crl_valid_at_time(&issuer_crl, validation_time) {
|
|
||||||
return Err(CertPathError::CrlNotValidAtTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_serial_revoked_by_crl(&ee, &issuer_crl) {
|
|
||||||
return Err(CertPathError::EeRevoked);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ValidatedEeCertPath {
|
|
||||||
ee,
|
|
||||||
issuer_ca,
|
|
||||||
issuer_crl,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate the EE certificate path using a *pre-decoded issuer CA* and a *pre-decoded and
|
|
||||||
/// pre-verified issuer CRL*.
|
|
||||||
///
|
|
||||||
/// This is a performance-oriented helper for stage2 serial runs: it avoids repeating issuer CA
|
|
||||||
/// decode and issuer CRL decode+signature verification for every signed object in a publication point.
|
|
||||||
///
|
|
||||||
/// The caller must ensure:
|
|
||||||
/// - `issuer_ca` corresponds to `issuer_ca_der`
|
|
||||||
/// - `issuer_crl` cryptographic signature/binding has already been verified against `issuer_ca_der`
|
|
||||||
pub fn validate_ee_cert_path_with_prevalidated_issuer(
|
|
||||||
ee_cert_der: &[u8],
|
|
||||||
issuer_ca: &ResourceCertificate,
|
|
||||||
issuer_spki: &SubjectPublicKeyInfo<'_>,
|
|
||||||
issuer_crl: &RpkixCrl,
|
|
||||||
issuer_crl_revoked_serials: &HashSet<Vec<u8>>,
|
|
||||||
issuer_ca_rsync_uri: Option<&str>,
|
|
||||||
issuer_crl_rsync_uri: Option<&str>,
|
|
||||||
validation_time: time::OffsetDateTime,
|
|
||||||
) -> Result<ResourceCertificate, CertPathError> {
|
|
||||||
let ee = ResourceCertificate::decode_der(ee_cert_der)?;
|
|
||||||
if ee.kind != ResourceCertKind::Ee {
|
|
||||||
return Err(CertPathError::EeNotEe);
|
|
||||||
}
|
|
||||||
if issuer_ca.kind != ResourceCertKind::Ca {
|
|
||||||
return Err(CertPathError::IssuerNotCa);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !x509_names_equivalent(&ee.tbs.issuer_name, &issuer_ca.tbs.subject_name) {
|
|
||||||
return Err(CertPathError::IssuerSubjectMismatch {
|
|
||||||
ee_issuer_dn: ee.tbs.issuer_name.to_string(),
|
|
||||||
issuer_subject_dn: issuer_ca.tbs.subject_name.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_ee_aki_matches_issuer_ski(&ee, issuer_ca)?;
|
|
||||||
if let Some(expected_issuer_uri) = issuer_ca_rsync_uri {
|
|
||||||
validate_ee_aia_points_to_issuer_uri(&ee, expected_issuer_uri)?;
|
|
||||||
}
|
|
||||||
if let Some(expected_crl_uri) = issuer_crl_rsync_uri {
|
|
||||||
validate_ee_crldp_contains_issuer_crl_uri(&ee, expected_crl_uri)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !time_within_validity(
|
|
||||||
validation_time,
|
|
||||||
ee.tbs.validity_not_before,
|
|
||||||
ee.tbs.validity_not_after,
|
|
||||||
) || !time_within_validity(
|
|
||||||
validation_time,
|
|
||||||
issuer_ca.tbs.validity_not_before,
|
|
||||||
issuer_ca.tbs.validity_not_after,
|
|
||||||
) {
|
|
||||||
return Err(CertPathError::CertificateNotValidAtTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ee_x509 = parse_x509_cert(ee_cert_der)?;
|
|
||||||
verify_ee_cert_signature(&ee_x509, issuer_spki)?;
|
|
||||||
validate_ee_key_usage(&ee_x509)?;
|
|
||||||
|
|
||||||
if !crl_valid_at_time(issuer_crl, validation_time) {
|
|
||||||
return Err(CertPathError::CrlNotValidAtTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
let serial = BigUnsigned::from_biguint(&ee.tbs.serial_number);
|
|
||||||
if issuer_crl_revoked_serials.contains(&serial.bytes_be) {
|
|
||||||
return Err(CertPathError::EeRevoked);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ee)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_subject_pki_from_der(der: &[u8]) -> Result<SubjectPublicKeyInfo<'_>, CertPathError> {
|
|
||||||
let (rem, spki) = SubjectPublicKeyInfo::from_der(der)
|
|
||||||
.map_err(|e| CertPathError::IssuerSpkiParse(e.to_string()))?;
|
|
||||||
if !rem.is_empty() {
|
|
||||||
return Err(CertPathError::IssuerSpkiTrailingBytes(rem.len()));
|
|
||||||
}
|
|
||||||
Ok(spki)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_x509_cert(der: &[u8]) -> Result<X509Certificate<'_>, CertPathError> {
|
|
||||||
let (rem, cert) = X509Certificate::from_der(der)
|
|
||||||
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))?;
|
|
||||||
if !rem.is_empty() {
|
|
||||||
return Err(CertPathError::EeSignatureInvalid(
|
|
||||||
"trailing bytes after EE certificate".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(cert)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_ee_cert_signature(
|
|
||||||
ee: &X509Certificate<'_>,
|
|
||||||
issuer_spki: &SubjectPublicKeyInfo<'_>,
|
|
||||||
) -> Result<(), CertPathError> {
|
|
||||||
ee.verify_signature(Some(issuer_spki))
|
|
||||||
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_ee_aki_matches_issuer_ski(
|
|
||||||
ee: &ResourceCertificate,
|
|
||||||
issuer_ca: &ResourceCertificate,
|
|
||||||
) -> Result<(), CertPathError> {
|
|
||||||
let Some(issuer_ski) = issuer_ca.tbs.extensions.subject_key_identifier.as_deref() else {
|
|
||||||
return Err(CertPathError::IssuerSkiMissing);
|
|
||||||
};
|
|
||||||
let Some(ee_aki) = ee.tbs.extensions.authority_key_identifier.as_deref() else {
|
|
||||||
return Err(CertPathError::EeAkiMissing);
|
|
||||||
};
|
|
||||||
if ee_aki != issuer_ski {
|
|
||||||
return Err(CertPathError::EeAkiMismatch);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_ee_aia_points_to_issuer_uri(
|
|
||||||
ee: &ResourceCertificate,
|
|
||||||
issuer_ca_rsync_uri: &str,
|
|
||||||
) -> Result<(), CertPathError> {
|
|
||||||
let Some(uris) = ee.tbs.extensions.ca_issuers_uris.as_ref() else {
|
|
||||||
return Err(CertPathError::EeAiaMissing);
|
|
||||||
};
|
|
||||||
if !uris.iter().any(|u| u.as_str() == issuer_ca_rsync_uri) {
|
|
||||||
return Err(CertPathError::EeAiaIssuerUriMismatch);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_ee_crldp_contains_issuer_crl_uri(
|
|
||||||
ee: &ResourceCertificate,
|
|
||||||
issuer_crl_rsync_uri: &str,
|
|
||||||
) -> Result<(), CertPathError> {
|
|
||||||
let Some(uris) = ee.tbs.extensions.crl_distribution_points_uris.as_ref() else {
|
|
||||||
return Err(CertPathError::EeCrlDpMissing);
|
|
||||||
};
|
|
||||||
if !uris.iter().any(|u| u.as_str() == issuer_crl_rsync_uri) {
|
|
||||||
return Err(CertPathError::EeCrlDpUriMismatch);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_ee_key_usage(cert: &X509Certificate<'_>) -> Result<(), CertPathError> {
|
|
||||||
let mut ku_critical: Option<bool> = None;
|
|
||||||
for ext in cert.extensions() {
|
|
||||||
if ext.oid.as_bytes() == crate::data_model::oid::OID_KEY_USAGE_RAW {
|
|
||||||
ku_critical = Some(ext.critical);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(critical) = ku_critical else {
|
|
||||||
return Err(CertPathError::KeyUsageMissing);
|
|
||||||
};
|
|
||||||
if !critical {
|
|
||||||
return Err(CertPathError::KeyUsageNotCritical);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(ku) = cert
|
|
||||||
.key_usage()
|
|
||||||
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))?
|
|
||||||
else {
|
|
||||||
return Err(CertPathError::KeyUsageMissing);
|
|
||||||
};
|
|
||||||
|
|
||||||
let v = &ku.value;
|
|
||||||
let ok = v.digital_signature()
|
|
||||||
&& !v.key_cert_sign()
|
|
||||||
&& !v.crl_sign()
|
|
||||||
&& !v.non_repudiation()
|
|
||||||
&& !v.key_encipherment()
|
|
||||||
&& !v.data_encipherment()
|
|
||||||
&& !v.key_agreement()
|
|
||||||
&& !v.encipher_only()
|
|
||||||
&& !v.decipher_only();
|
|
||||||
if !ok {
|
|
||||||
return Err(CertPathError::KeyUsageInvalidBits);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn time_within_validity(
|
|
||||||
t: time::OffsetDateTime,
|
|
||||||
not_before: time::OffsetDateTime,
|
|
||||||
not_after: time::OffsetDateTime,
|
|
||||||
) -> bool {
|
|
||||||
let t = t.to_offset(time::UtcOffset::UTC);
|
|
||||||
let not_before = not_before.to_offset(time::UtcOffset::UTC);
|
|
||||||
let not_after = not_after.to_offset(time::UtcOffset::UTC);
|
|
||||||
t >= not_before && t <= not_after
|
|
||||||
}
|
|
||||||
|
|
||||||
fn crl_valid_at_time(crl: &RpkixCrl, t: time::OffsetDateTime) -> bool {
|
|
||||||
let t = t.to_offset(time::UtcOffset::UTC);
|
|
||||||
let this_update = crl.this_update.utc.to_offset(time::UtcOffset::UTC);
|
|
||||||
let next_update = crl.next_update.utc.to_offset(time::UtcOffset::UTC);
|
|
||||||
t >= this_update && t < next_update
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_serial_revoked_by_crl(ee: &ResourceCertificate, crl: &RpkixCrl) -> bool {
|
|
||||||
let serial = BigUnsigned::from_biguint(&ee.tbs.serial_number);
|
|
||||||
crl.revoked_certs
|
|
||||||
.iter()
|
|
||||||
.any(|rc| rc.serial_number == serial)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::data_model::common::X509NameDer;
|
|
||||||
use crate::data_model::rc::{
|
|
||||||
RcExtensions, ResourceCertKind, ResourceCertificate, RpkixTbsCertificate,
|
|
||||||
};
|
|
||||||
use der_parser::num_bigint::BigUint;
|
|
||||||
|
|
||||||
fn dummy_cert(
|
|
||||||
kind: ResourceCertKind,
|
|
||||||
subject_dn: &str,
|
|
||||||
issuer_dn: &str,
|
|
||||||
ski: Option<Vec<u8>>,
|
|
||||||
aki: Option<Vec<u8>>,
|
|
||||||
aia: Option<Vec<&str>>,
|
|
||||||
crldp: Option<Vec<&str>>,
|
|
||||||
) -> ResourceCertificate {
|
|
||||||
let aia = aia.map(|v| v.into_iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
|
||||||
let crldp = crldp.map(|v| v.into_iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
|
||||||
ResourceCertificate {
|
|
||||||
raw_der: Vec::new(),
|
|
||||||
kind,
|
|
||||||
tbs: RpkixTbsCertificate {
|
|
||||||
version: 2,
|
|
||||||
serial_number: BigUint::from(1u8),
|
|
||||||
signature_algorithm: "1.2.840.113549.1.1.11".to_string(),
|
|
||||||
issuer_name: X509NameDer(issuer_dn.as_bytes().to_vec()),
|
|
||||||
subject_name: X509NameDer(subject_dn.as_bytes().to_vec()),
|
|
||||||
validity_not_before: time::OffsetDateTime::UNIX_EPOCH,
|
|
||||||
validity_not_after: time::OffsetDateTime::UNIX_EPOCH,
|
|
||||||
subject_public_key_info: Vec::new(),
|
|
||||||
extensions: RcExtensions {
|
|
||||||
basic_constraints_ca: kind == ResourceCertKind::Ca,
|
|
||||||
subject_key_identifier: ski,
|
|
||||||
authority_key_identifier: aki,
|
|
||||||
crl_distribution_points_uris: crldp,
|
|
||||||
ca_issuers_uris: aia,
|
|
||||||
subject_info_access: None,
|
|
||||||
certificate_policies_oid: None,
|
|
||||||
ip_resources: None,
|
|
||||||
as_resources: None,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ee_aki_and_aia_and_crldp_binding_checks_report_errors() {
|
|
||||||
let issuer = dummy_cert(
|
|
||||||
ResourceCertKind::Ca,
|
|
||||||
"CN=issuer",
|
|
||||||
"CN=issuer",
|
|
||||||
Some(vec![1]),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let ee = dummy_cert(
|
|
||||||
ResourceCertKind::Ee,
|
|
||||||
"CN=ee",
|
|
||||||
"CN=issuer",
|
|
||||||
Some(vec![2]),
|
|
||||||
Some(vec![9]),
|
|
||||||
Some(vec!["rsync://example.test/issuer.cer"]),
|
|
||||||
Some(vec!["rsync://example.test/issuer.crl"]),
|
|
||||||
);
|
|
||||||
let err = validate_ee_aki_matches_issuer_ski(&ee, &issuer).unwrap_err();
|
|
||||||
assert!(matches!(err, CertPathError::EeAkiMismatch), "{err}");
|
|
||||||
|
|
||||||
let issuer_missing_ski = dummy_cert(
|
|
||||||
ResourceCertKind::Ca,
|
|
||||||
"CN=issuer",
|
|
||||||
"CN=issuer",
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let err = validate_ee_aki_matches_issuer_ski(&ee, &issuer_missing_ski).unwrap_err();
|
|
||||||
assert!(matches!(err, CertPathError::IssuerSkiMissing), "{err}");
|
|
||||||
|
|
||||||
let ee_missing_aki = dummy_cert(
|
|
||||||
ResourceCertKind::Ee,
|
|
||||||
"CN=ee",
|
|
||||||
"CN=issuer",
|
|
||||||
Some(vec![2]),
|
|
||||||
None,
|
|
||||||
Some(vec!["rsync://example.test/issuer.cer"]),
|
|
||||||
Some(vec!["rsync://example.test/issuer.crl"]),
|
|
||||||
);
|
|
||||||
let err = validate_ee_aki_matches_issuer_ski(&ee_missing_aki, &issuer).unwrap_err();
|
|
||||||
assert!(matches!(err, CertPathError::EeAkiMissing), "{err}");
|
|
||||||
|
|
||||||
let ee_missing_aia = dummy_cert(
|
|
||||||
ResourceCertKind::Ee,
|
|
||||||
"CN=ee",
|
|
||||||
"CN=issuer",
|
|
||||||
Some(vec![2]),
|
|
||||||
Some(vec![1]),
|
|
||||||
None,
|
|
||||||
Some(vec!["rsync://example.test/issuer.crl"]),
|
|
||||||
);
|
|
||||||
let err = validate_ee_aia_points_to_issuer_uri(
|
|
||||||
&ee_missing_aia,
|
|
||||||
"rsync://example.test/issuer.cer",
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, CertPathError::EeAiaMissing), "{err}");
|
|
||||||
|
|
||||||
let ee_wrong_aia = dummy_cert(
|
|
||||||
ResourceCertKind::Ee,
|
|
||||||
"CN=ee",
|
|
||||||
"CN=issuer",
|
|
||||||
Some(vec![2]),
|
|
||||||
Some(vec![1]),
|
|
||||||
Some(vec!["rsync://example.test/other.cer"]),
|
|
||||||
Some(vec!["rsync://example.test/issuer.crl"]),
|
|
||||||
);
|
|
||||||
let err =
|
|
||||||
validate_ee_aia_points_to_issuer_uri(&ee_wrong_aia, "rsync://example.test/issuer.cer")
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(
|
|
||||||
matches!(err, CertPathError::EeAiaIssuerUriMismatch),
|
|
||||||
"{err}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let ee_missing_crldp = dummy_cert(
|
|
||||||
ResourceCertKind::Ee,
|
|
||||||
"CN=ee",
|
|
||||||
"CN=issuer",
|
|
||||||
Some(vec![2]),
|
|
||||||
Some(vec![1]),
|
|
||||||
Some(vec!["rsync://example.test/issuer.cer"]),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let err = validate_ee_crldp_contains_issuer_crl_uri(
|
|
||||||
&ee_missing_crldp,
|
|
||||||
"rsync://example.test/issuer.crl",
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, CertPathError::EeCrlDpMissing), "{err}");
|
|
||||||
|
|
||||||
let ee_wrong_crldp = dummy_cert(
|
|
||||||
ResourceCertKind::Ee,
|
|
||||||
"CN=ee",
|
|
||||||
"CN=issuer",
|
|
||||||
Some(vec![2]),
|
|
||||||
Some(vec![1]),
|
|
||||||
Some(vec!["rsync://example.test/issuer.cer"]),
|
|
||||||
Some(vec!["rsync://example.test/other.crl"]),
|
|
||||||
);
|
|
||||||
let err = validate_ee_crldp_contains_issuer_crl_uri(
|
|
||||||
&ee_wrong_crldp,
|
|
||||||
"rsync://example.test/issuer.crl",
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, CertPathError::EeCrlDpUriMismatch), "{err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ee_binding_checks_accept_when_matching() {
|
|
||||||
let issuer = dummy_cert(
|
|
||||||
ResourceCertKind::Ca,
|
|
||||||
"CN=issuer",
|
|
||||||
"CN=issuer",
|
|
||||||
Some(vec![1]),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let ee = dummy_cert(
|
|
||||||
ResourceCertKind::Ee,
|
|
||||||
"CN=ee",
|
|
||||||
"CN=issuer",
|
|
||||||
Some(vec![2]),
|
|
||||||
Some(vec![1]),
|
|
||||||
Some(vec!["rsync://example.test/issuer.cer"]),
|
|
||||||
Some(vec!["rsync://example.test/issuer.crl"]),
|
|
||||||
);
|
|
||||||
validate_ee_aki_matches_issuer_ski(&ee, &issuer).expect("aki ok");
|
|
||||||
validate_ee_aia_points_to_issuer_uri(&ee, "rsync://example.test/issuer.cer")
|
|
||||||
.expect("aia ok");
|
|
||||||
validate_ee_crldp_contains_issuer_crl_uri(&ee, "rsync://example.test/issuer.crl")
|
|
||||||
.expect("crldp ok");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::data_model::ta::{TrustAnchor, TrustAnchorError};
|
|
||||||
use crate::data_model::tal::{Tal, TalDecodeError};
|
|
||||||
use crate::sync::rrdp::Fetcher;
|
|
||||||
use crate::validation::ca_instance::{
|
|
||||||
CaInstanceUris, CaInstanceUrisError, ca_instance_uris_from_ca_certificate,
|
|
||||||
};
|
|
||||||
use crate::validation::run::{RunError, RunOutput, run_publication_point_once};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct DiscoveredRootCaInstance {
|
|
||||||
pub tal_url: Option<String>,
|
|
||||||
pub trust_anchor: TrustAnchor,
|
|
||||||
pub ca_instance: CaInstanceUris,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct RunFromTalOutput {
|
|
||||||
pub discovery: DiscoveredRootCaInstance,
|
|
||||||
pub run: RunOutput,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum FromTalError {
|
|
||||||
#[error("TAL fetch failed: {0} (RFC 8630 §2.2)")]
|
|
||||||
TalFetch(String),
|
|
||||||
|
|
||||||
#[error("TAL decode failed: {0} (RFC 8630 §2.2)")]
|
|
||||||
TalDecode(#[from] TalDecodeError),
|
|
||||||
|
|
||||||
#[error("failed to fetch TA certificate from TAL: {0} (RFC 8630 §2.3)")]
|
|
||||||
TaFetch(String),
|
|
||||||
|
|
||||||
#[error("failed to bind TAL and TA certificate: {0} (RFC 8630 §2.3)")]
|
|
||||||
Bind(#[from] TrustAnchorError),
|
|
||||||
|
|
||||||
#[error("failed to discover CA instance URIs from TA certificate: {0}")]
|
|
||||||
CaInstanceUris(#[from] CaInstanceUrisError),
|
|
||||||
|
|
||||||
#[error("run failed: {0}")]
|
|
||||||
Run(#[from] RunError),
|
|
||||||
|
|
||||||
#[error("TAL contains no TA URIs (RFC 8630 §2.2)")]
|
|
||||||
NoTaUris,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn discover_root_ca_instance_from_tal_url(
|
|
||||||
http_fetcher: &dyn Fetcher,
|
|
||||||
tal_url: &str,
|
|
||||||
) -> Result<DiscoveredRootCaInstance, FromTalError> {
|
|
||||||
let tal_bytes = http_fetcher
|
|
||||||
.fetch(tal_url)
|
|
||||||
.map_err(FromTalError::TalFetch)?;
|
|
||||||
let tal = Tal::decode_bytes(&tal_bytes)?;
|
|
||||||
discover_root_ca_instance_from_tal(http_fetcher, tal, Some(tal_url.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn discover_root_ca_instance_from_tal(
|
|
||||||
http_fetcher: &dyn Fetcher,
|
|
||||||
tal: Tal,
|
|
||||||
tal_url: Option<String>,
|
|
||||||
) -> Result<DiscoveredRootCaInstance, FromTalError> {
|
|
||||||
if tal.ta_uris.is_empty() {
|
|
||||||
return Err(FromTalError::NoTaUris);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut last_err: Option<String> = None;
|
|
||||||
for ta_uri in tal.ta_uris.iter() {
|
|
||||||
let ta_der = match http_fetcher.fetch(ta_uri.as_str()) {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => {
|
|
||||||
last_err = Some(format!("fetch {ta_uri} failed: {e}"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let trust_anchor = match TrustAnchor::bind_der(tal.clone(), &ta_der, Some(ta_uri)) {
|
|
||||||
Ok(ta) => ta,
|
|
||||||
Err(e) => {
|
|
||||||
last_err = Some(format!("bind {ta_uri} failed: {e}"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let ca_instance =
|
|
||||||
match ca_instance_uris_from_ca_certificate(&trust_anchor.ta_certificate.rc_ca) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
last_err = Some(format!("CA instance discovery failed: {e}"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return Ok(DiscoveredRootCaInstance {
|
|
||||||
tal_url,
|
|
||||||
trust_anchor,
|
|
||||||
ca_instance,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(FromTalError::TaFetch(last_err.unwrap_or_else(|| {
|
|
||||||
"unknown TA candidate error".to_string()
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn discover_root_ca_instance_from_tal_and_ta_der(
|
|
||||||
tal_bytes: &[u8],
|
|
||||||
ta_der: &[u8],
|
|
||||||
resolved_ta_uri: Option<&Url>,
|
|
||||||
) -> Result<DiscoveredRootCaInstance, FromTalError> {
|
|
||||||
let tal = Tal::decode_bytes(tal_bytes)?;
|
|
||||||
let trust_anchor = TrustAnchor::bind_der(tal, ta_der, resolved_ta_uri)?;
|
|
||||||
let ca_instance = ca_instance_uris_from_ca_certificate(&trust_anchor.ta_certificate.rc_ca)?;
|
|
||||||
Ok(DiscoveredRootCaInstance {
|
|
||||||
tal_url: None,
|
|
||||||
trust_anchor,
|
|
||||||
ca_instance,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_root_from_tal_url_once(
|
|
||||||
store: &crate::storage::RocksStore,
|
|
||||||
policy: &crate::policy::Policy,
|
|
||||||
tal_url: &str,
|
|
||||||
http_fetcher: &dyn Fetcher,
|
|
||||||
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
|
|
||||||
validation_time: time::OffsetDateTime,
|
|
||||||
) -> Result<RunFromTalOutput, FromTalError> {
|
|
||||||
let discovery = discover_root_ca_instance_from_tal_url(http_fetcher, tal_url)?;
|
|
||||||
|
|
||||||
let run = run_publication_point_once(
|
|
||||||
store,
|
|
||||||
policy,
|
|
||||||
discovery.ca_instance.rrdp_notification_uri.as_deref(),
|
|
||||||
&discovery.ca_instance.rsync_base_uri,
|
|
||||||
&discovery.ca_instance.manifest_rsync_uri,
|
|
||||||
&discovery.ca_instance.publication_point_rsync_uri,
|
|
||||||
http_fetcher,
|
|
||||||
rsync_fetcher,
|
|
||||||
&discovery.trust_anchor.ta_certificate.raw_der,
|
|
||||||
None,
|
|
||||||
discovery
|
|
||||||
.trust_anchor
|
|
||||||
.ta_certificate
|
|
||||||
.rc_ca
|
|
||||||
.tbs
|
|
||||||
.extensions
|
|
||||||
.ip_resources
|
|
||||||
.as_ref(),
|
|
||||||
discovery
|
|
||||||
.trust_anchor
|
|
||||||
.ta_certificate
|
|
||||||
.rc_ca
|
|
||||||
.tbs
|
|
||||||
.extensions
|
|
||||||
.as_resources
|
|
||||||
.as_ref(),
|
|
||||||
validation_time,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(RunFromTalOutput { discovery, run })
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
|||||||
pub mod ca_instance;
|
|
||||||
pub mod ca_path;
|
|
||||||
pub mod cert_path;
|
|
||||||
pub mod from_tal;
|
|
||||||
pub mod manifest;
|
|
||||||
pub mod objects;
|
|
||||||
pub mod publication_point;
|
|
||||||
pub mod run;
|
|
||||||
pub mod run_tree_from_tal;
|
|
||||||
pub mod tree;
|
|
||||||
pub mod tree_runner;
|
|
||||||
pub mod x509_name;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::analysis::timing::TimingHandle;
|
|
||||||
use crate::audit::PublicationPointAudit;
|
|
||||||
use crate::audit_downloads::DownloadLogHandle;
|
|
||||||
use crate::data_model::ta::TrustAnchor;
|
|
||||||
use crate::sync::rrdp::Fetcher;
|
|
||||||
use crate::validation::from_tal::{
|
|
||||||
DiscoveredRootCaInstance, FromTalError, discover_root_ca_instance_from_tal_and_ta_der,
|
|
||||||
discover_root_ca_instance_from_tal_url,
|
|
||||||
};
|
|
||||||
use crate::validation::tree::{
|
|
||||||
CaInstanceHandle, TreeRunAuditOutput, TreeRunConfig, TreeRunError, TreeRunOutput,
|
|
||||||
run_tree_serial, run_tree_serial_audit,
|
|
||||||
};
|
|
||||||
use crate::validation::tree_runner::Rpkiv1PublicationPointRunner;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
|||||||
use crate::data_model::common::X509NameDer;
|
|
||||||
use x509_parser::prelude::FromDer;
|
|
||||||
|
|
||||||
fn canonicalize(name: &X509NameDer) -> Option<String> {
|
|
||||||
let (rem, parsed) = x509_parser::x509::X509Name::from_der(name.as_raw()).ok()?;
|
|
||||||
if !rem.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(parsed.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compare two X.509 distinguished names using a tolerant semantic comparison.
|
|
||||||
///
|
|
||||||
/// RPKI repositories in the wild sometimes encode the same name using different
|
|
||||||
/// ASN.1 string types (e.g., PrintableString vs UTF8String) while remaining
|
|
||||||
/// semantically equivalent. RFC 5280 path validation requires name matching, but
|
|
||||||
/// DER byte equality is too strict for interoperability.
|
|
||||||
pub fn x509_names_equivalent(a: &X509NameDer, b: &X509NameDer) -> bool {
|
|
||||||
let Some(ca) = canonicalize(a) else {
|
|
||||||
return a == b;
|
|
||||||
};
|
|
||||||
let Some(cb) = canonicalize(b) else {
|
|
||||||
return a == b;
|
|
||||||
};
|
|
||||||
ca == cb
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn x509_names_equivalent_falls_back_to_der_equality_when_parse_fails() {
|
|
||||||
// Invalid tag (not a SEQUENCE) makes x509-parser fail and forces DER equality fallback.
|
|
||||||
let a = X509NameDer(vec![0x01, 0x00]);
|
|
||||||
let b = X509NameDer(vec![0x01, 0x00]);
|
|
||||||
assert!(x509_names_equivalent(&a, &b));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn x509_names_equivalent_compares_semantic_names_when_parse_succeeds() {
|
|
||||||
let cert_der = std::fs::read(
|
|
||||||
"tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer",
|
|
||||||
)
|
|
||||||
.expect("read certificate fixture");
|
|
||||||
|
|
||||||
let (_rem, cert) =
|
|
||||||
x509_parser::parse_x509_certificate(&cert_der).expect("parse certificate fixture");
|
|
||||||
|
|
||||||
let subject = X509NameDer(cert.tbs_certificate.subject.as_raw().to_vec());
|
|
||||||
let issuer = X509NameDer(cert.tbs_certificate.issuer.as_raw().to_vec());
|
|
||||||
|
|
||||||
assert!(x509_names_equivalent(&subject, &subject));
|
|
||||||
assert!(!x509_names_equivalent(&subject, &issuer));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn x509_names_equivalent_falls_back_when_name_has_trailing_bytes() {
|
|
||||||
// Use a real name DER and append a trailing byte so parsing yields leftover `rem`.
|
|
||||||
let cert_der = std::fs::read(
|
|
||||||
"tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer",
|
|
||||||
)
|
|
||||||
.expect("read certificate fixture");
|
|
||||||
let (_rem, cert) =
|
|
||||||
x509_parser::parse_x509_certificate(&cert_der).expect("parse certificate fixture");
|
|
||||||
let mut name = cert.tbs_certificate.subject.as_raw().to_vec();
|
|
||||||
name.push(0x00);
|
|
||||||
let a = X509NameDer(name.clone());
|
|
||||||
let b = X509NameDer(name);
|
|
||||||
assert!(x509_names_equivalent(&a, &b));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn x509_names_equivalent_falls_back_when_one_side_fails_to_parse() {
|
|
||||||
let cert_der = std::fs::read(
|
|
||||||
"tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer",
|
|
||||||
)
|
|
||||||
.expect("read certificate fixture");
|
|
||||||
let (_rem, cert) =
|
|
||||||
x509_parser::parse_x509_certificate(&cert_der).expect("parse certificate fixture");
|
|
||||||
let good = X509NameDer(cert.tbs_certificate.subject.as_raw().to_vec());
|
|
||||||
let bad = X509NameDer(vec![0x01, 0x00]);
|
|
||||||
assert!(!x509_names_equivalent(&good, &bad));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
use rpki::data_model::manifest::ManifestObject;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
fn default_samples_dir() -> PathBuf {
|
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/benchmark/selected_der")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_samples(dir: &Path) -> Vec<Sample> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let rd = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display()));
|
|
||||||
for ent in rd.flatten() {
|
|
||||||
let path = ent.path();
|
|
||||||
if path.extension().and_then(|s| s.to_str()) != Some("mft") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
out.push(Sample { name, path });
|
|
||||||
}
|
|
||||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct Sample {
|
|
||||||
name: String,
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_u64(name: &str, default: u64) -> u64 {
|
|
||||||
std::env::var(name)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
|
||||||
.unwrap_or(default)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_u64_opt(name: &str) -> Option<u64> {
|
|
||||||
std::env::var(name).ok().and_then(|s| s.parse::<u64>().ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_bool(name: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
std::env::var(name).as_deref(),
|
|
||||||
Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_string(name: &str) -> Option<String> {
|
|
||||||
std::env::var(name).ok().filter(|s| !s.trim().is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn escape_md(s: &str) -> String {
|
|
||||||
s.replace('|', "\\|").replace('\n', " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_parent_dirs(path: &Path) {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
|
|
||||||
panic!("create_dir_all {}: {e}", parent.display());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "manual performance benchmark; prints Markdown table"]
|
|
||||||
fn manifest_decode_profile_benchmark_selected_der() {
|
|
||||||
let dir = env_string("BENCH_DIR")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(default_samples_dir);
|
|
||||||
|
|
||||||
let sample_filter = env_string("BENCH_SAMPLE");
|
|
||||||
let fixed_iters = env_u64_opt("BENCH_ITERS");
|
|
||||||
let warmup_iters = env_u64("BENCH_WARMUP_ITERS", 100);
|
|
||||||
let rounds = env_u64("BENCH_ROUNDS", 5);
|
|
||||||
let min_round_ms = env_u64("BENCH_MIN_ROUND_MS", 200);
|
|
||||||
let max_adaptive_iters = env_u64("BENCH_MAX_ITERS", 1_000_000);
|
|
||||||
let verbose = env_bool("BENCH_VERBOSE");
|
|
||||||
let out_md = env_string("BENCH_OUT_MD").map(|p| PathBuf::from(p));
|
|
||||||
let out_json = env_string("BENCH_OUT_JSON").map(|p| PathBuf::from(p));
|
|
||||||
|
|
||||||
if let Some(n) = fixed_iters {
|
|
||||||
assert!(n >= 1, "BENCH_ITERS must be >= 1");
|
|
||||||
}
|
|
||||||
assert!(rounds >= 1, "BENCH_ROUNDS must be >= 1");
|
|
||||||
assert!(min_round_ms >= 1, "BENCH_MIN_ROUND_MS must be >= 1");
|
|
||||||
assert!(max_adaptive_iters >= 1, "BENCH_MAX_ITERS must be >= 1");
|
|
||||||
|
|
||||||
let mut samples = read_samples(&dir);
|
|
||||||
assert!(
|
|
||||||
!samples.is_empty(),
|
|
||||||
"no .mft files found under: {}",
|
|
||||||
dir.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(filter) = sample_filter.as_deref() {
|
|
||||||
samples.retain(|s| s.name == filter);
|
|
||||||
assert!(
|
|
||||||
!samples.is_empty(),
|
|
||||||
"no sample matched BENCH_SAMPLE={filter}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("# Manifest decode + profile validate benchmark (debug build)");
|
|
||||||
println!();
|
|
||||||
println!("- dir: {}", dir.display());
|
|
||||||
if let Some(n) = fixed_iters {
|
|
||||||
println!("- iters: {} (fixed)", n);
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"- warmup: {} iters, rounds: {}, min_round: {}ms (adaptive iters, max {})",
|
|
||||||
warmup_iters, rounds, min_round_ms, max_adaptive_iters
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(filter) = sample_filter.as_deref() {
|
|
||||||
println!("- sample: {}", filter);
|
|
||||||
}
|
|
||||||
if verbose {
|
|
||||||
println!("- verbose: true");
|
|
||||||
}
|
|
||||||
if let Some(p) = out_md.as_ref() {
|
|
||||||
println!("- out_md: {}", p.display());
|
|
||||||
}
|
|
||||||
if let Some(p) = out_json.as_ref() {
|
|
||||||
println!("- out_json: {}", p.display());
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
println!("Samples:");
|
|
||||||
for s in &samples {
|
|
||||||
println!("- {}", s.name);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
println!("| sample | file_count | avg ns/op | ops/s |");
|
|
||||||
println!("|---|---:|---:|---:|");
|
|
||||||
|
|
||||||
let mut rows: Vec<ResultRow> = Vec::with_capacity(samples.len());
|
|
||||||
|
|
||||||
for s in samples {
|
|
||||||
let bytes =
|
|
||||||
std::fs::read(&s.path).unwrap_or_else(|e| panic!("read {}: {e}", s.path.display()));
|
|
||||||
|
|
||||||
let file_count = ManifestObject::decode_der(bytes.as_slice())
|
|
||||||
.unwrap_or_else(|e| panic!("decode {}: {e}", s.name))
|
|
||||||
.manifest
|
|
||||||
.file_count();
|
|
||||||
|
|
||||||
// Warm-up: exercise the exact decode path but don't time it.
|
|
||||||
for _ in 0..warmup_iters {
|
|
||||||
let input = std::hint::black_box(bytes.as_slice());
|
|
||||||
let decoded = ManifestObject::decode_der(input).expect("decode");
|
|
||||||
std::hint::black_box(decoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut per_round_ns_per_op = Vec::with_capacity(rounds as usize);
|
|
||||||
for round in 0..rounds {
|
|
||||||
let iters = if let Some(n) = fixed_iters {
|
|
||||||
n
|
|
||||||
} else {
|
|
||||||
choose_iters_adaptive(bytes.as_slice(), min_round_ms, max_adaptive_iters)
|
|
||||||
};
|
|
||||||
|
|
||||||
let start = Instant::now();
|
|
||||||
for _ in 0..iters {
|
|
||||||
let input = std::hint::black_box(bytes.as_slice());
|
|
||||||
let decoded = ManifestObject::decode_der(input).expect("decode");
|
|
||||||
std::hint::black_box(decoded);
|
|
||||||
}
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
let total_ns = elapsed.as_secs_f64() * 1e9;
|
|
||||||
let ns_per_op = total_ns / (iters as f64);
|
|
||||||
per_round_ns_per_op.push(ns_per_op);
|
|
||||||
|
|
||||||
if verbose {
|
|
||||||
println!(
|
|
||||||
"# {} round {}: iters={} total_ms={:.2} ns/op={:.2}",
|
|
||||||
s.name,
|
|
||||||
round + 1,
|
|
||||||
iters,
|
|
||||||
elapsed.as_secs_f64() * 1e3,
|
|
||||||
ns_per_op
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let avg_ns = per_round_ns_per_op.iter().sum::<f64>() / (per_round_ns_per_op.len() as f64);
|
|
||||||
let ops_per_sec = 1e9_f64 / avg_ns;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"| {} | {} | {:.2} | {:.2} |",
|
|
||||||
s.name, file_count, avg_ns, ops_per_sec
|
|
||||||
);
|
|
||||||
|
|
||||||
rows.push(ResultRow {
|
|
||||||
sample: s.name,
|
|
||||||
file_count,
|
|
||||||
avg_ns_per_op: avg_ns,
|
|
||||||
ops_per_sec,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if out_md.is_some() || out_json.is_some() {
|
|
||||||
let timestamp_utc = time::OffsetDateTime::now_utc()
|
|
||||||
.format(&time::format_description::well_known::Rfc3339)
|
|
||||||
.unwrap_or_else(|_| "unknown".to_string());
|
|
||||||
let cfg = RunConfig {
|
|
||||||
dir: dir.display().to_string(),
|
|
||||||
sample: sample_filter,
|
|
||||||
fixed_iters,
|
|
||||||
warmup_iters,
|
|
||||||
rounds,
|
|
||||||
min_round_ms,
|
|
||||||
max_adaptive_iters,
|
|
||||||
timestamp_utc,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(path) = out_md {
|
|
||||||
let md = render_markdown(&cfg, &rows);
|
|
||||||
write_text_file(&path, &md);
|
|
||||||
eprintln!("Wrote {}", path.display());
|
|
||||||
}
|
|
||||||
if let Some(path) = out_json {
|
|
||||||
let json = serde_json::to_string_pretty(&BenchmarkOutput { config: cfg, rows })
|
|
||||||
.expect("serialize json");
|
|
||||||
write_text_file(&path, &json);
|
|
||||||
eprintln!("Wrote {}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn choose_iters_adaptive(bytes: &[u8], min_round_ms: u64, max_iters: u64) -> u64 {
|
|
||||||
let min_secs = (min_round_ms as f64) / 1e3;
|
|
||||||
let mut iters: u64 = 1;
|
|
||||||
loop {
|
|
||||||
let start = Instant::now();
|
|
||||||
for _ in 0..iters {
|
|
||||||
let input = std::hint::black_box(bytes);
|
|
||||||
let decoded = ManifestObject::decode_der(input).expect("decode");
|
|
||||||
std::hint::black_box(decoded);
|
|
||||||
}
|
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
|
||||||
if elapsed >= min_secs {
|
|
||||||
return iters;
|
|
||||||
}
|
|
||||||
if iters >= max_iters {
|
|
||||||
return iters;
|
|
||||||
}
|
|
||||||
iters = (iters.saturating_mul(2)).min(max_iters);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_markdown(cfg: &RunConfig, rows: &[ResultRow]) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
out.push_str("# Manifest decode + profile validate benchmark (debug build)\n\n");
|
|
||||||
out.push_str(&format!("- timestamp_utc: {}\n", cfg.timestamp_utc));
|
|
||||||
out.push_str(&format!("- dir: `{}`\n", cfg.dir));
|
|
||||||
if let Some(s) = cfg.sample.as_deref() {
|
|
||||||
out.push_str(&format!("- sample: `{}`\n", s));
|
|
||||||
}
|
|
||||||
if let Some(n) = cfg.fixed_iters {
|
|
||||||
out.push_str(&format!("- iters: {} (fixed)\n", n));
|
|
||||||
} else {
|
|
||||||
out.push_str(&format!(
|
|
||||||
"- warmup: {} iters, rounds: {}, min_round: {}ms (adaptive iters, max {})\n",
|
|
||||||
cfg.warmup_iters, cfg.rounds, cfg.min_round_ms, cfg.max_adaptive_iters
|
|
||||||
));
|
|
||||||
}
|
|
||||||
out.push('\n');
|
|
||||||
out.push_str("| sample | file_count | avg ns/op | ops/s |\n");
|
|
||||||
out.push_str("|---|---:|---:|---:|\n");
|
|
||||||
for r in rows {
|
|
||||||
out.push_str(&format!(
|
|
||||||
"| {} | {} | {:.2} | {:.2} |\n",
|
|
||||||
escape_md(&r.sample),
|
|
||||||
r.file_count,
|
|
||||||
r.avg_ns_per_op,
|
|
||||||
r.ops_per_sec
|
|
||||||
));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_text_file(path: &Path, content: &str) {
|
|
||||||
create_parent_dirs(path);
|
|
||||||
std::fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, serde::Serialize)]
|
|
||||||
struct RunConfig {
|
|
||||||
dir: String,
|
|
||||||
sample: Option<String>,
|
|
||||||
fixed_iters: Option<u64>,
|
|
||||||
warmup_iters: u64,
|
|
||||||
rounds: u64,
|
|
||||||
min_round_ms: u64,
|
|
||||||
max_adaptive_iters: u64,
|
|
||||||
timestamp_utc: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, serde::Serialize)]
|
|
||||||
struct ResultRow {
|
|
||||||
sample: String,
|
|
||||||
file_count: usize,
|
|
||||||
avg_ns_per_op: f64,
|
|
||||||
ops_per_sec: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, serde::Serialize)]
|
|
||||||
struct BenchmarkOutput {
|
|
||||||
config: RunConfig,
|
|
||||||
rows: Vec<ResultRow>,
|
|
||||||
}
|
|
||||||
@@ -1,942 +0,0 @@
|
|||||||
use serde::Serialize;
|
|
||||||
use sha2::Digest;
|
|
||||||
use std::collections::{BTreeMap, HashSet};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use rpki::data_model::aspa::AspaObject;
|
|
||||||
use rpki::data_model::crl::RpkixCrl;
|
|
||||||
use rpki::data_model::manifest::ManifestObject;
|
|
||||||
use rpki::data_model::rc::{
|
|
||||||
AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpResourceSet, ResourceCertificate,
|
|
||||||
};
|
|
||||||
use rpki::data_model::roa::RoaObject;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
enum ObjType {
|
|
||||||
Cer,
|
|
||||||
Crl,
|
|
||||||
Manifest,
|
|
||||||
Roa,
|
|
||||||
Aspa,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
||||||
enum Part {
|
|
||||||
PackManifest,
|
|
||||||
PackCrl,
|
|
||||||
StoredObject { index: u32 },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
struct CandidateRef {
|
|
||||||
pack_index: u32,
|
|
||||||
part: Part,
|
|
||||||
size_bytes: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
struct SampleMeta {
|
|
||||||
obj_type: ObjType,
|
|
||||||
label: String,
|
|
||||||
file_rel_path: String,
|
|
||||||
size_bytes: u32,
|
|
||||||
sha256_hex: String,
|
|
||||||
|
|
||||||
// Provenance (relative to BENCH_STORE_DIR)
|
|
||||||
pack_rel_path: String,
|
|
||||||
pack_update_time_rfc3339_utc: String,
|
|
||||||
manifest_uri: String,
|
|
||||||
object_uri: String,
|
|
||||||
|
|
||||||
// From StoredManifest (if available)
|
|
||||||
manifest_this_update_rfc3339_utc: String,
|
|
||||||
manifest_not_after_rfc3339_utc: String,
|
|
||||||
|
|
||||||
metrics: Metrics,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
enum Metrics {
|
|
||||||
Cer {
|
|
||||||
spki_len: u32,
|
|
||||||
ext_count: u32,
|
|
||||||
ip_resource_count: u32,
|
|
||||||
as_resource_count: u32,
|
|
||||||
},
|
|
||||||
Crl {
|
|
||||||
revoked_count: u32,
|
|
||||||
},
|
|
||||||
Manifest {
|
|
||||||
file_count: u32,
|
|
||||||
},
|
|
||||||
Roa {
|
|
||||||
addr_family_count: u32,
|
|
||||||
prefix_count: u32,
|
|
||||||
max_length_present: u32,
|
|
||||||
},
|
|
||||||
Aspa {
|
|
||||||
provider_count: u32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
struct SamplesManifest {
|
|
||||||
created_at_rfc3339_utc: String,
|
|
||||||
store_dir_hint: String,
|
|
||||||
per_type: BTreeMap<String, u32>,
|
|
||||||
samples: Vec<SampleMeta>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_string(name: &str) -> Option<String> {
|
|
||||||
std::env::var(name).ok().filter(|s| !s.trim().is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_u64_opt(name: &str) -> Option<u64> {
|
|
||||||
std::env::var(name).ok().and_then(|s| s.parse::<u64>().ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_bool(name: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
std::env::var(name).as_deref(),
|
|
||||||
Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_parent_dirs(path: &Path) {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
|
|
||||||
panic!("create_dir_all {}: {e}", parent.display());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn walk_collect_files(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
|
|
||||||
for entry in std::fs::read_dir(root)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
let meta = entry.metadata()?;
|
|
||||||
if meta.is_dir() {
|
|
||||||
walk_collect_files(&path, out)?;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if meta.is_file() {
|
|
||||||
out.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ext_from_uri_bytes(uri: &[u8]) -> Option<&[u8]> {
|
|
||||||
let mut last_dot = None;
|
|
||||||
for (i, &b) in uri.iter().enumerate() {
|
|
||||||
if b == b'.' {
|
|
||||||
last_dot = Some(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let dot = last_dot?;
|
|
||||||
if dot + 1 >= uri.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(&uri[dot + 1..])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ext_matches_ascii_case_insensitive(ext: &[u8], needle_lower: &[u8]) -> bool {
|
|
||||||
if ext.len() != needle_lower.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
ext.iter()
|
|
||||||
.zip(needle_lower.iter())
|
|
||||||
.all(|(&a, &b)| a.to_ascii_lowercase() == b)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn percentile_index(len: usize, q: f64) -> usize {
|
|
||||||
if len == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if len == 1 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let q = q.clamp(0.0, 1.0);
|
|
||||||
(((len - 1) as f64) * q).round() as usize
|
|
||||||
}
|
|
||||||
|
|
||||||
fn quantile_labels() -> Vec<(&'static str, f64)> {
|
|
||||||
vec![
|
|
||||||
("min", 0.0),
|
|
||||||
("p01", 0.01),
|
|
||||||
("p10", 0.10),
|
|
||||||
("p25", 0.25),
|
|
||||||
("p50", 0.50),
|
|
||||||
("p75", 0.75),
|
|
||||||
("p90", 0.90),
|
|
||||||
("p95", 0.95),
|
|
||||||
("p99", 0.99),
|
|
||||||
("max", 1.0),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fmt_rfc3339_utc(t: time::OffsetDateTime) -> String {
|
|
||||||
use time::format_description::well_known::Rfc3339;
|
|
||||||
t.to_offset(time::UtcOffset::UTC)
|
|
||||||
.format(&Rfc3339)
|
|
||||||
.expect("format RFC3339")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scan_pack_candidates(
|
|
||||||
pack_index: u32,
|
|
||||||
bytes: &[u8],
|
|
||||||
out_cer: &mut Vec<CandidateRef>,
|
|
||||||
out_crl: &mut Vec<CandidateRef>,
|
|
||||||
out_manifest: &mut Vec<CandidateRef>,
|
|
||||||
out_roa: &mut Vec<CandidateRef>,
|
|
||||||
out_aspa: &mut Vec<CandidateRef>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let mut cur = ByteCursor::new(bytes);
|
|
||||||
|
|
||||||
let _version = cur.read_u8()?;
|
|
||||||
cur.skip_string_u32()?; // manifest_uri
|
|
||||||
cur.skip_option_string_u32()?; // rpki_notify
|
|
||||||
|
|
||||||
let update_kind = cur.read_u8()?;
|
|
||||||
cur.skip_i64()?; // update time
|
|
||||||
if update_kind != 0 {
|
|
||||||
// LastAttempt: no StoredManifest + objects
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
cur.skip_i64()?; // not_after
|
|
||||||
cur.skip(20)?; // manifest_number
|
|
||||||
cur.skip_i64()?; // this_update
|
|
||||||
cur.skip_string_u32()?; // ca_repository
|
|
||||||
|
|
||||||
let mft_len = cur.peek_u64_as_usize()?;
|
|
||||||
cur.skip_bytes_u64()?; // manifest DER
|
|
||||||
out_manifest.push(CandidateRef {
|
|
||||||
pack_index,
|
|
||||||
part: Part::PackManifest,
|
|
||||||
size_bytes: mft_len as u32,
|
|
||||||
});
|
|
||||||
|
|
||||||
cur.skip_string_u32()?; // crl_uri
|
|
||||||
let crl_len = cur.peek_u64_as_usize()?;
|
|
||||||
cur.skip_bytes_u64()?;
|
|
||||||
out_crl.push(CandidateRef {
|
|
||||||
pack_index,
|
|
||||||
part: Part::PackCrl,
|
|
||||||
size_bytes: crl_len as u32,
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut object_index = 0u32;
|
|
||||||
while cur.remaining() > 0 {
|
|
||||||
let uri_bytes = cur.read_bytes_u32()?;
|
|
||||||
let ext = ext_from_uri_bytes(uri_bytes);
|
|
||||||
|
|
||||||
let hash_type = cur.read_u8()?;
|
|
||||||
if hash_type == 1 {
|
|
||||||
cur.skip(32)?;
|
|
||||||
} else if hash_type != 0 {
|
|
||||||
return Err(format!("unsupported stored object hash_type {hash_type}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_len = cur.peek_u64_as_usize()?;
|
|
||||||
cur.skip_bytes_u64()?;
|
|
||||||
|
|
||||||
let cand = CandidateRef {
|
|
||||||
pack_index,
|
|
||||||
part: Part::StoredObject {
|
|
||||||
index: object_index,
|
|
||||||
},
|
|
||||||
size_bytes: content_len as u32,
|
|
||||||
};
|
|
||||||
if let Some(ext) = ext {
|
|
||||||
if ext_matches_ascii_case_insensitive(ext, b"cer") {
|
|
||||||
out_cer.push(cand);
|
|
||||||
} else if ext_matches_ascii_case_insensitive(ext, b"roa") {
|
|
||||||
out_roa.push(cand);
|
|
||||||
} else if ext_matches_ascii_case_insensitive(ext, b"asa") {
|
|
||||||
out_aspa.push(cand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object_index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Extracted {
|
|
||||||
pack_update_time_utc: time::OffsetDateTime,
|
|
||||||
manifest_uri: String,
|
|
||||||
manifest_this_update_utc: time::OffsetDateTime,
|
|
||||||
manifest_not_after_utc: time::OffsetDateTime,
|
|
||||||
object_uri: String,
|
|
||||||
der: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_candidate(pack_path: &Path, cand: CandidateRef) -> Result<Extracted, String> {
|
|
||||||
let bytes =
|
|
||||||
std::fs::read(pack_path).map_err(|e| format!("read {}: {e}", pack_path.display()))?;
|
|
||||||
let mut cur = ByteCursor::new(bytes.as_slice());
|
|
||||||
|
|
||||||
let _version = cur.read_u8()?;
|
|
||||||
let manifest_uri = cur.read_string_u32()?;
|
|
||||||
cur.skip_option_string_u32()?; // rpki_notify
|
|
||||||
|
|
||||||
let update_kind = cur.read_u8()?;
|
|
||||||
let pack_update_time_utc = cur.read_time_utc_i64_be()?;
|
|
||||||
if update_kind != 0 {
|
|
||||||
return Err("pack is LastAttempt".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let manifest_not_after_utc = cur.read_time_utc_i64_be()?;
|
|
||||||
cur.skip(20)?; // manifest_number
|
|
||||||
let manifest_this_update_utc = cur.read_time_utc_i64_be()?;
|
|
||||||
cur.skip_string_u32()?; // ca_repository
|
|
||||||
|
|
||||||
let manifest_der = cur.read_bytes_u64_vec()?; // manifest DER
|
|
||||||
|
|
||||||
if cand.part == Part::PackManifest {
|
|
||||||
return Ok(Extracted {
|
|
||||||
pack_update_time_utc,
|
|
||||||
manifest_uri: manifest_uri.clone(),
|
|
||||||
manifest_this_update_utc,
|
|
||||||
manifest_not_after_utc,
|
|
||||||
object_uri: manifest_uri,
|
|
||||||
der: manifest_der,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let crl_uri = cur.read_string_u32()?;
|
|
||||||
let crl_der = cur.read_bytes_u64_vec()?;
|
|
||||||
|
|
||||||
match cand.part {
|
|
||||||
Part::PackManifest => unreachable!("handled above"),
|
|
||||||
Part::PackCrl => Ok(Extracted {
|
|
||||||
pack_update_time_utc,
|
|
||||||
manifest_uri,
|
|
||||||
manifest_this_update_utc,
|
|
||||||
manifest_not_after_utc,
|
|
||||||
object_uri: crl_uri,
|
|
||||||
der: crl_der,
|
|
||||||
}),
|
|
||||||
Part::StoredObject { index } => {
|
|
||||||
let mut object_index = 0u32;
|
|
||||||
while cur.remaining() > 0 {
|
|
||||||
let uri = cur.read_string_u32()?;
|
|
||||||
let hash_type = cur.read_u8()?;
|
|
||||||
if hash_type == 1 {
|
|
||||||
cur.skip(32)?;
|
|
||||||
} else if hash_type != 0 {
|
|
||||||
return Err(format!("unsupported stored object hash_type {hash_type}"));
|
|
||||||
}
|
|
||||||
let content = cur.read_bytes_u64_vec()?;
|
|
||||||
if object_index == index {
|
|
||||||
return Ok(Extracted {
|
|
||||||
pack_update_time_utc,
|
|
||||||
manifest_uri,
|
|
||||||
manifest_this_update_utc,
|
|
||||||
manifest_not_after_utc,
|
|
||||||
object_uri: uri,
|
|
||||||
der: content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
object_index += 1;
|
|
||||||
}
|
|
||||||
Err(format!("stored object index {index} out of range"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ip_resource_count(set: &IpResourceSet) -> u32 {
|
|
||||||
let mut n = 0u32;
|
|
||||||
for fam in &set.families {
|
|
||||||
match &fam.choice {
|
|
||||||
IpAddressChoice::Inherit => n = n.saturating_add(1),
|
|
||||||
IpAddressChoice::AddressesOrRanges(items) => n = n.saturating_add(items.len() as u32),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
n
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_choice_count(choice: &AsIdentifierChoice) -> u32 {
|
|
||||||
match choice {
|
|
||||||
AsIdentifierChoice::Inherit => 1,
|
|
||||||
AsIdentifierChoice::AsIdsOrRanges(items) => items.len() as u32,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_resource_count(set: &AsResourceSet) -> u32 {
|
|
||||||
let mut n = 0u32;
|
|
||||||
if let Some(c) = set.asnum.as_ref() {
|
|
||||||
n = n.saturating_add(as_choice_count(c));
|
|
||||||
}
|
|
||||||
if let Some(c) = set.rdi.as_ref() {
|
|
||||||
n = n.saturating_add(as_choice_count(c));
|
|
||||||
}
|
|
||||||
n
|
|
||||||
}
|
|
||||||
|
|
||||||
fn metrics_for(obj_type: ObjType, der: &[u8]) -> Result<Metrics, String> {
|
|
||||||
match obj_type {
|
|
||||||
ObjType::Cer => {
|
|
||||||
let parsed = ResourceCertificate::parse_der(der).map_err(|e| e.to_string())?;
|
|
||||||
let spki_len = parsed.subject_public_key_info.len() as u32;
|
|
||||||
let ext_count = (parsed.extensions.basic_constraints_ca.len()
|
|
||||||
+ parsed.extensions.subject_key_identifier.len()
|
|
||||||
+ parsed.extensions.authority_key_identifier.len()
|
|
||||||
+ parsed.extensions.crl_distribution_points.len()
|
|
||||||
+ parsed.extensions.authority_info_access.len()
|
|
||||||
+ parsed.extensions.subject_info_access.len()
|
|
||||||
+ parsed.extensions.certificate_policies.len()
|
|
||||||
+ parsed.extensions.ip_resources.len()
|
|
||||||
+ parsed.extensions.as_resources.len()) as u32;
|
|
||||||
let cert = parsed.validate_profile().map_err(|e| e.to_string())?;
|
|
||||||
let ip_resource_count = cert
|
|
||||||
.tbs
|
|
||||||
.extensions
|
|
||||||
.ip_resources
|
|
||||||
.as_ref()
|
|
||||||
.map(ip_resource_count)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let as_resource_count = cert
|
|
||||||
.tbs
|
|
||||||
.extensions
|
|
||||||
.as_resources
|
|
||||||
.as_ref()
|
|
||||||
.map(as_resource_count)
|
|
||||||
.unwrap_or(0);
|
|
||||||
Ok(Metrics::Cer {
|
|
||||||
spki_len,
|
|
||||||
ext_count,
|
|
||||||
ip_resource_count,
|
|
||||||
as_resource_count,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ObjType::Crl => {
|
|
||||||
let parsed = RpkixCrl::parse_der(der).map_err(|e| e.to_string())?;
|
|
||||||
let crl = parsed.validate_profile().map_err(|e| e.to_string())?;
|
|
||||||
Ok(Metrics::Crl {
|
|
||||||
revoked_count: crl.revoked_certs.len() as u32,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ObjType::Manifest => {
|
|
||||||
let mft = ManifestObject::decode_der(der).map_err(|e| e.to_string())?;
|
|
||||||
Ok(Metrics::Manifest {
|
|
||||||
file_count: mft.manifest.file_count() as u32,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ObjType::Roa => {
|
|
||||||
let roa = RoaObject::decode_der(der).map_err(|e| e.to_string())?;
|
|
||||||
let addr_family_count = roa.roa.ip_addr_blocks.len() as u32;
|
|
||||||
let mut prefix_count = 0u32;
|
|
||||||
let mut max_length_present = 0u32;
|
|
||||||
for fam in &roa.roa.ip_addr_blocks {
|
|
||||||
for a in &fam.addresses {
|
|
||||||
prefix_count += 1;
|
|
||||||
if a.max_length.is_some() {
|
|
||||||
max_length_present += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Metrics::Roa {
|
|
||||||
addr_family_count,
|
|
||||||
prefix_count,
|
|
||||||
max_length_present,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ObjType::Aspa => {
|
|
||||||
let asa = AspaObject::decode_der(der).map_err(|e| e.to_string())?;
|
|
||||||
Ok(Metrics::Aspa {
|
|
||||||
provider_count: asa.aspa.provider_as_ids.len() as u32,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_sample(out_dir: &Path, obj_type: ObjType, label: &str, der: &[u8]) -> PathBuf {
|
|
||||||
let (subdir, ext) = match obj_type {
|
|
||||||
ObjType::Cer => ("cer", "cer"),
|
|
||||||
ObjType::Crl => ("crl", "crl"),
|
|
||||||
ObjType::Manifest => ("manifest", "mft"),
|
|
||||||
ObjType::Roa => ("roa", "roa"),
|
|
||||||
ObjType::Aspa => ("aspa", "asa"),
|
|
||||||
};
|
|
||||||
let path = out_dir.join(subdir).join(format!("{label}.{ext}"));
|
|
||||||
create_parent_dirs(&path);
|
|
||||||
std::fs::write(&path, der).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "manual: collect representative CER/CRL/MFT/ROA/ASA DER fixtures from routinator stored/ SAP packs"]
|
|
||||||
fn stage2_collect_selected_der_v2_from_routinator_store() {
|
|
||||||
let store_dir = env_string("BENCH_STORE_DIR")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| panic!("set BENCH_STORE_DIR to routinator stored/ dir"));
|
|
||||||
let out_dir = env_string("BENCH_OUT_DIR")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/benchmark/selected_der_v2")
|
|
||||||
});
|
|
||||||
let max_packs = env_u64_opt("BENCH_MAX_PACKS");
|
|
||||||
let clean = env_bool("BENCH_CLEAN");
|
|
||||||
let dry_run = env_bool("BENCH_DRY_RUN");
|
|
||||||
let verbose = env_bool("BENCH_VERBOSE");
|
|
||||||
|
|
||||||
if clean && !dry_run && out_dir.exists() {
|
|
||||||
std::fs::remove_dir_all(&out_dir)
|
|
||||||
.unwrap_or_else(|e| panic!("remove_dir_all {}: {e}", out_dir.display()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pack_paths = Vec::new();
|
|
||||||
walk_collect_files(&store_dir, &mut pack_paths)
|
|
||||||
.unwrap_or_else(|e| panic!("walk {}: {e}", store_dir.display()));
|
|
||||||
pack_paths.retain(|p| p.extension().and_then(|s| s.to_str()) == Some("mft"));
|
|
||||||
pack_paths.sort();
|
|
||||||
if let Some(max) = max_packs {
|
|
||||||
pack_paths.truncate(max as usize);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("# Stage2 collect: selected_der_v2 (from routinator stored/ SAP packs)");
|
|
||||||
println!();
|
|
||||||
println!("- store_dir: {}", store_dir.display());
|
|
||||||
println!("- out_dir: {}", out_dir.display());
|
|
||||||
println!("- pack_files_found: {}", pack_paths.len());
|
|
||||||
if let Some(max) = max_packs {
|
|
||||||
println!("- max_packs: {} (truncating input list)", max);
|
|
||||||
}
|
|
||||||
println!("- clean: {}", clean);
|
|
||||||
println!("- dry_run: {}", dry_run);
|
|
||||||
println!();
|
|
||||||
|
|
||||||
let mut cer = Vec::<CandidateRef>::new();
|
|
||||||
let mut crl = Vec::<CandidateRef>::new();
|
|
||||||
let mut mft = Vec::<CandidateRef>::new();
|
|
||||||
let mut roa = Vec::<CandidateRef>::new();
|
|
||||||
let mut asa = Vec::<CandidateRef>::new();
|
|
||||||
|
|
||||||
for (i, p) in pack_paths.iter().enumerate() {
|
|
||||||
let bytes = match std::fs::read(p) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
if let Err(e) = scan_pack_candidates(
|
|
||||||
i as u32,
|
|
||||||
bytes.as_slice(),
|
|
||||||
&mut cer,
|
|
||||||
&mut crl,
|
|
||||||
&mut mft,
|
|
||||||
&mut roa,
|
|
||||||
&mut asa,
|
|
||||||
) {
|
|
||||||
if verbose {
|
|
||||||
eprintln!("# scan error {}: {}", p.display(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut by_type: BTreeMap<ObjType, Vec<CandidateRef>> = BTreeMap::new();
|
|
||||||
by_type.insert(ObjType::Cer, cer);
|
|
||||||
by_type.insert(ObjType::Crl, crl);
|
|
||||||
by_type.insert(ObjType::Manifest, mft);
|
|
||||||
by_type.insert(ObjType::Roa, roa);
|
|
||||||
by_type.insert(ObjType::Aspa, asa);
|
|
||||||
|
|
||||||
let mut samples = Vec::<SampleMeta>::new();
|
|
||||||
let mut per_type_out = BTreeMap::<String, u32>::new();
|
|
||||||
|
|
||||||
for (obj_type, mut cands) in by_type {
|
|
||||||
cands.sort_by(|a, b| {
|
|
||||||
a.size_bytes
|
|
||||||
.cmp(&b.size_bytes)
|
|
||||||
.then_with(|| a.pack_index.cmp(&b.pack_index))
|
|
||||||
.then_with(|| match (a.part, b.part) {
|
|
||||||
(Part::PackManifest, Part::PackManifest) => std::cmp::Ordering::Equal,
|
|
||||||
(Part::PackManifest, _) => std::cmp::Ordering::Less,
|
|
||||||
(_, Part::PackManifest) => std::cmp::Ordering::Greater,
|
|
||||||
(Part::PackCrl, Part::PackCrl) => std::cmp::Ordering::Equal,
|
|
||||||
(Part::PackCrl, Part::StoredObject { .. }) => std::cmp::Ordering::Less,
|
|
||||||
(Part::StoredObject { .. }, Part::PackCrl) => std::cmp::Ordering::Greater,
|
|
||||||
(Part::StoredObject { index: ai }, Part::StoredObject { index: bi }) => {
|
|
||||||
ai.cmp(&bi)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
println!("## {:?}", obj_type);
|
|
||||||
println!();
|
|
||||||
println!("- candidates: {}", cands.len());
|
|
||||||
if cands.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut selected_sha = HashSet::<String>::new();
|
|
||||||
let mut selected_ptrs = HashSet::<(u32, Part)>::new();
|
|
||||||
|
|
||||||
for (label, q) in quantile_labels() {
|
|
||||||
let target = percentile_index(cands.len(), q);
|
|
||||||
|
|
||||||
let mut found = None;
|
|
||||||
for off in 0.. {
|
|
||||||
let mut tried_any = false;
|
|
||||||
if off == 0 {
|
|
||||||
let j = target as i32;
|
|
||||||
if j < 0 || (j as usize) >= cands.len() {
|
|
||||||
tried_any = false;
|
|
||||||
} else {
|
|
||||||
tried_any = true;
|
|
||||||
let cand = cands[j as usize];
|
|
||||||
if selected_ptrs.contains(&(cand.pack_index, cand.part)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pack_path = &pack_paths[cand.pack_index as usize];
|
|
||||||
let extracted = match extract_candidate(pack_path.as_path(), cand) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
if extracted.der.len() != cand.size_bytes as usize {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sha256 = sha2::Sha256::digest(extracted.der.as_slice());
|
|
||||||
let sha256_hex = hex::encode(sha256);
|
|
||||||
if selected_sha.contains(&sha256_hex) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let metrics = match metrics_for(obj_type, extracted.der.as_slice()) {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !dry_run {
|
|
||||||
let p = write_sample(
|
|
||||||
out_dir.as_path(),
|
|
||||||
obj_type,
|
|
||||||
label,
|
|
||||||
extracted.der.as_slice(),
|
|
||||||
);
|
|
||||||
if verbose {
|
|
||||||
println!(
|
|
||||||
"- wrote {} -> {}",
|
|
||||||
label,
|
|
||||||
p.strip_prefix(PathBuf::from(env!("CARGO_MANIFEST_DIR")))
|
|
||||||
.unwrap_or(&p)
|
|
||||||
.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let pack_rel_path = pack_path
|
|
||||||
.strip_prefix(&store_dir)
|
|
||||||
.unwrap_or(pack_path.as_path())
|
|
||||||
.display()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let file_rel_path = format!(
|
|
||||||
"tests/benchmark/selected_der_v2/{}/{}.{}",
|
|
||||||
match obj_type {
|
|
||||||
ObjType::Cer => "cer",
|
|
||||||
ObjType::Crl => "crl",
|
|
||||||
ObjType::Manifest => "manifest",
|
|
||||||
ObjType::Roa => "roa",
|
|
||||||
ObjType::Aspa => "aspa",
|
|
||||||
},
|
|
||||||
label,
|
|
||||||
match obj_type {
|
|
||||||
ObjType::Cer => "cer",
|
|
||||||
ObjType::Crl => "crl",
|
|
||||||
ObjType::Manifest => "mft",
|
|
||||||
ObjType::Roa => "roa",
|
|
||||||
ObjType::Aspa => "asa",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let sha256_hex_for_set = sha256_hex.clone();
|
|
||||||
samples.push(SampleMeta {
|
|
||||||
obj_type,
|
|
||||||
label: label.to_string(),
|
|
||||||
file_rel_path,
|
|
||||||
size_bytes: cand.size_bytes,
|
|
||||||
sha256_hex,
|
|
||||||
pack_rel_path,
|
|
||||||
pack_update_time_rfc3339_utc: fmt_rfc3339_utc(
|
|
||||||
extracted.pack_update_time_utc,
|
|
||||||
),
|
|
||||||
manifest_uri: extracted.manifest_uri,
|
|
||||||
object_uri: extracted.object_uri,
|
|
||||||
manifest_this_update_rfc3339_utc: fmt_rfc3339_utc(
|
|
||||||
extracted.manifest_this_update_utc,
|
|
||||||
),
|
|
||||||
manifest_not_after_rfc3339_utc: fmt_rfc3339_utc(
|
|
||||||
extracted.manifest_not_after_utc,
|
|
||||||
),
|
|
||||||
metrics,
|
|
||||||
});
|
|
||||||
|
|
||||||
selected_ptrs.insert((cand.pack_index, cand.part));
|
|
||||||
selected_sha.insert(sha256_hex_for_set);
|
|
||||||
found = Some(());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for delta in [off as i32, -(off as i32)] {
|
|
||||||
let j = target as i32 + delta;
|
|
||||||
if j < 0 || (j as usize) >= cands.len() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
tried_any = true;
|
|
||||||
let cand = cands[j as usize];
|
|
||||||
if selected_ptrs.contains(&(cand.pack_index, cand.part)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pack_path = &pack_paths[cand.pack_index as usize];
|
|
||||||
let extracted = match extract_candidate(pack_path.as_path(), cand) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
if extracted.der.len() != cand.size_bytes as usize {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sha256 = sha2::Sha256::digest(extracted.der.as_slice());
|
|
||||||
let sha256_hex = hex::encode(sha256);
|
|
||||||
if selected_sha.contains(&sha256_hex) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let metrics = match metrics_for(obj_type, extracted.der.as_slice()) {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !dry_run {
|
|
||||||
let p = write_sample(
|
|
||||||
out_dir.as_path(),
|
|
||||||
obj_type,
|
|
||||||
label,
|
|
||||||
extracted.der.as_slice(),
|
|
||||||
);
|
|
||||||
if verbose {
|
|
||||||
println!(
|
|
||||||
"- wrote {} -> {}",
|
|
||||||
label,
|
|
||||||
p.strip_prefix(PathBuf::from(env!("CARGO_MANIFEST_DIR")))
|
|
||||||
.unwrap_or(&p)
|
|
||||||
.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let pack_rel_path = pack_path
|
|
||||||
.strip_prefix(&store_dir)
|
|
||||||
.unwrap_or(pack_path.as_path())
|
|
||||||
.display()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let file_rel_path = format!(
|
|
||||||
"tests/benchmark/selected_der_v2/{}/{}.{}",
|
|
||||||
match obj_type {
|
|
||||||
ObjType::Cer => "cer",
|
|
||||||
ObjType::Crl => "crl",
|
|
||||||
ObjType::Manifest => "manifest",
|
|
||||||
ObjType::Roa => "roa",
|
|
||||||
ObjType::Aspa => "aspa",
|
|
||||||
},
|
|
||||||
label,
|
|
||||||
match obj_type {
|
|
||||||
ObjType::Cer => "cer",
|
|
||||||
ObjType::Crl => "crl",
|
|
||||||
ObjType::Manifest => "mft",
|
|
||||||
ObjType::Roa => "roa",
|
|
||||||
ObjType::Aspa => "asa",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let sha256_hex_for_set = sha256_hex.clone();
|
|
||||||
samples.push(SampleMeta {
|
|
||||||
obj_type,
|
|
||||||
label: label.to_string(),
|
|
||||||
file_rel_path,
|
|
||||||
size_bytes: cand.size_bytes,
|
|
||||||
sha256_hex,
|
|
||||||
pack_rel_path,
|
|
||||||
pack_update_time_rfc3339_utc: fmt_rfc3339_utc(
|
|
||||||
extracted.pack_update_time_utc,
|
|
||||||
),
|
|
||||||
manifest_uri: extracted.manifest_uri,
|
|
||||||
object_uri: extracted.object_uri,
|
|
||||||
manifest_this_update_rfc3339_utc: fmt_rfc3339_utc(
|
|
||||||
extracted.manifest_this_update_utc,
|
|
||||||
),
|
|
||||||
manifest_not_after_rfc3339_utc: fmt_rfc3339_utc(
|
|
||||||
extracted.manifest_not_after_utc,
|
|
||||||
),
|
|
||||||
metrics,
|
|
||||||
});
|
|
||||||
|
|
||||||
selected_ptrs.insert((cand.pack_index, cand.part));
|
|
||||||
selected_sha.insert(sha256_hex_for_set);
|
|
||||||
found = Some(());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if found.is_some() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if !tried_any {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
per_type_out.insert(
|
|
||||||
format!("{:?}", obj_type).to_lowercase(),
|
|
||||||
selected_sha.len() as u32,
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
samples.sort_by(|a, b| {
|
|
||||||
a.obj_type
|
|
||||||
.cmp(&b.obj_type)
|
|
||||||
.then_with(|| a.label.cmp(&b.label))
|
|
||||||
});
|
|
||||||
|
|
||||||
let created_at_rfc3339_utc = fmt_rfc3339_utc(time::OffsetDateTime::now_utc());
|
|
||||||
let manifest = SamplesManifest {
|
|
||||||
created_at_rfc3339_utc,
|
|
||||||
store_dir_hint: store_dir.display().to_string(),
|
|
||||||
per_type: per_type_out,
|
|
||||||
samples,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !dry_run {
|
|
||||||
let meta_path = out_dir.join("meta").join("samples.json");
|
|
||||||
create_parent_dirs(&meta_path);
|
|
||||||
let bytes = serde_json::to_vec_pretty(&manifest).expect("encode samples manifest");
|
|
||||||
std::fs::write(&meta_path, bytes)
|
|
||||||
.unwrap_or_else(|e| panic!("write {}: {e}", meta_path.display()));
|
|
||||||
println!("- wrote meta: {}", meta_path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ByteCursor<'a> {
|
|
||||||
bytes: &'a [u8],
|
|
||||||
pos: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> ByteCursor<'a> {
|
|
||||||
fn new(bytes: &'a [u8]) -> Self {
|
|
||||||
Self { bytes, pos: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remaining(&self) -> usize {
|
|
||||||
self.bytes.len().saturating_sub(self.pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn skip(&mut self, len: usize) -> Result<(), String> {
|
|
||||||
if len > self.remaining() {
|
|
||||||
return Err("truncated input".to_string());
|
|
||||||
}
|
|
||||||
self.pos += len;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], String> {
|
|
||||||
if len > self.remaining() {
|
|
||||||
return Err(format!(
|
|
||||||
"truncated input: need {len} bytes, have {}",
|
|
||||||
self.remaining()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let start = self.pos;
|
|
||||||
self.pos += len;
|
|
||||||
Ok(&self.bytes[start..start + len])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_u8(&mut self) -> Result<u8, String> {
|
|
||||||
Ok(self.read_exact(1)?[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_u32_be(&mut self) -> Result<u32, String> {
|
|
||||||
let b = self.read_exact(4)?;
|
|
||||||
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_u64_be(&mut self) -> Result<u64, String> {
|
|
||||||
let b = self.read_exact(8)?;
|
|
||||||
Ok(u64::from_be_bytes([
|
|
||||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn peek_u64_as_usize(&mut self) -> Result<usize, String> {
|
|
||||||
let start = self.pos;
|
|
||||||
let v = self.read_u64_be()?;
|
|
||||||
self.pos = start;
|
|
||||||
usize::try_from(v).map_err(|_| format!("length too large: {v}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn skip_i64(&mut self) -> Result<(), String> {
|
|
||||||
self.skip(8)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_i64_be(&mut self) -> Result<i64, String> {
|
|
||||||
let b = self.read_exact(8)?;
|
|
||||||
Ok(i64::from_be_bytes([
|
|
||||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_time_utc_i64_be(&mut self) -> Result<time::OffsetDateTime, String> {
|
|
||||||
let ts = self.read_i64_be()?;
|
|
||||||
time::OffsetDateTime::from_unix_timestamp(ts)
|
|
||||||
.map_err(|e| format!("invalid unix timestamp {ts}: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_bytes_u32(&mut self) -> Result<&'a [u8], String> {
|
|
||||||
let len = self.read_u32_be()? as usize;
|
|
||||||
self.read_exact(len)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn skip_string_u32(&mut self) -> Result<(), String> {
|
|
||||||
let len = self.read_u32_be()? as usize;
|
|
||||||
self.skip(len)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_string_u32(&mut self) -> Result<String, String> {
|
|
||||||
let b = self.read_bytes_u32()?;
|
|
||||||
std::str::from_utf8(b)
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.map_err(|e| format!("invalid UTF-8 string: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn skip_option_string_u32(&mut self) -> Result<(), String> {
|
|
||||||
let len = self.read_u32_be()? as usize;
|
|
||||||
if len == 0 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
self.skip(len)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn skip_bytes_u64(&mut self) -> Result<(), String> {
|
|
||||||
let len_u64 = self.read_u64_be()?;
|
|
||||||
let len = usize::try_from(len_u64)
|
|
||||||
.map_err(|_| format!("data block too large for this system: {len_u64}"))?;
|
|
||||||
self.skip(len)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_bytes_u64_vec(&mut self) -> Result<Vec<u8>, String> {
|
|
||||||
let len_u64 = self.read_u64_be()?;
|
|
||||||
let len = usize::try_from(len_u64)
|
|
||||||
.map_err(|_| format!("data block too large for this system: {len_u64}"))?;
|
|
||||||
Ok(self.read_exact(len)?.to_vec())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,717 +0,0 @@
|
|||||||
use rpki::data_model::aspa::AspaObject;
|
|
||||||
use rpki::data_model::crl::RpkixCrl;
|
|
||||||
use rpki::data_model::manifest::ManifestObject;
|
|
||||||
use rpki::data_model::rc::{
|
|
||||||
AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpResourceSet, ResourceCertificate,
|
|
||||||
};
|
|
||||||
use rpki::data_model::roa::RoaObject;
|
|
||||||
|
|
||||||
use rpki::storage::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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
#[path = "benchmark/sap.rs"]
|
|
||||||
mod sap;
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
struct Sizes {
|
|
||||||
vals: Vec<u64>,
|
|
||||||
total: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sizes {
|
|
||||||
fn push(&mut self, v: u64) {
|
|
||||||
self.vals.push(v);
|
|
||||||
self.total = self.total.saturating_add(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn summarize(&mut self) -> Option<SizesSummary> {
|
|
||||||
if self.vals.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.vals.sort_unstable();
|
|
||||||
let min = *self.vals.first().unwrap();
|
|
||||||
let max = *self.vals.last().unwrap();
|
|
||||||
let p50 = percentile(&self.vals, 0.50);
|
|
||||||
let p90 = percentile(&self.vals, 0.90);
|
|
||||||
let p99 = percentile(&self.vals, 0.99);
|
|
||||||
Some(SizesSummary {
|
|
||||||
count: self.vals.len() as u64,
|
|
||||||
total_bytes: self.total,
|
|
||||||
min,
|
|
||||||
p50,
|
|
||||||
p90,
|
|
||||||
p99,
|
|
||||||
max,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
struct SizesSummary {
|
|
||||||
count: u64,
|
|
||||||
total_bytes: u64,
|
|
||||||
min: u64,
|
|
||||||
p50: u64,
|
|
||||||
p90: u64,
|
|
||||||
p99: u64,
|
|
||||||
max: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
struct InventoryReport {
|
|
||||||
store_dir: String,
|
|
||||||
pack_files_found: u64,
|
|
||||||
packs_success: u64,
|
|
||||||
packs_last_attempt: u64,
|
|
||||||
packs_decode_error: u64,
|
|
||||||
objects_total: u64,
|
|
||||||
by_type: BTreeMap<String, SizesSummary>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn percentile(sorted: &[u64], p: f64) -> u64 {
|
|
||||||
if sorted.is_empty() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if sorted.len() == 1 {
|
|
||||||
return sorted[0];
|
|
||||||
}
|
|
||||||
let p = p.clamp(0.0, 1.0);
|
|
||||||
let idx = ((sorted.len() - 1) as f64 * p).round() as usize;
|
|
||||||
sorted[idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_string(name: &str) -> Option<String> {
|
|
||||||
std::env::var(name).ok().filter(|s| !s.trim().is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_u64_opt(name: &str) -> Option<u64> {
|
|
||||||
std::env::var(name).ok().and_then(|s| s.parse::<u64>().ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_parent_dirs(path: &Path) {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
|
|
||||||
panic!("create_dir_all {}: {e}", parent.display());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn walk_collect_files(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
|
|
||||||
for entry in std::fs::read_dir(root)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
let meta = entry.metadata()?;
|
|
||||||
if meta.is_dir() {
|
|
||||||
walk_collect_files(&path, out)?;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !meta.is_file() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push(path);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn object_type_from_rsync_uri(uri: &str) -> String {
|
|
||||||
let file = uri.rsplit('/').next().unwrap_or(uri);
|
|
||||||
if let Some((_, ext)) = file.rsplit_once('.') {
|
|
||||||
return ext.to_ascii_lowercase();
|
|
||||||
}
|
|
||||||
"unknown".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "manual inventory of routinator stored publication point packs (SAP)"]
|
|
||||||
fn stage2_inventory_routinator_sap_store() {
|
|
||||||
let store_dir = env_string("BENCH_STORE_DIR")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
panic!(
|
|
||||||
"set BENCH_STORE_DIR to routinator stored/ dir, e.g. /home/yuyr/dev/rust_playground/routinator/bench/full/cache/stored"
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let max_packs = env_u64_opt("BENCH_MAX_PACKS");
|
|
||||||
let out_json = env_string("BENCH_OUT_JSON").map(PathBuf::from);
|
|
||||||
|
|
||||||
let mut pack_paths = Vec::new();
|
|
||||||
walk_collect_files(&store_dir, &mut pack_paths)
|
|
||||||
.unwrap_or_else(|e| panic!("walk {}: {e}", store_dir.display()));
|
|
||||||
pack_paths.retain(|p| p.extension().and_then(|s| s.to_str()) == Some("mft"));
|
|
||||||
pack_paths.sort();
|
|
||||||
|
|
||||||
if let Some(max) = max_packs {
|
|
||||||
pack_paths.truncate(max as usize);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("# Stage2 inventory: routinator SAP packs");
|
|
||||||
println!();
|
|
||||||
println!("- store_dir: {}", store_dir.display());
|
|
||||||
println!("- pack_files_found: {}", pack_paths.len());
|
|
||||||
if let Some(max) = max_packs {
|
|
||||||
println!("- max_packs: {} (truncating input list)", max);
|
|
||||||
}
|
|
||||||
if let Some(p) = out_json.as_ref() {
|
|
||||||
println!("- out_json: {}", p.display());
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
let mut by_type: BTreeMap<String, Sizes> = BTreeMap::new();
|
|
||||||
let mut packs_success = 0u64;
|
|
||||||
let mut packs_last_attempt = 0u64;
|
|
||||||
let mut packs_decode_error = 0u64;
|
|
||||||
let mut objects_total = 0u64;
|
|
||||||
|
|
||||||
for p in &pack_paths {
|
|
||||||
let sap = match sap::SapPublicationPoint::decode_path(p.as_path()) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(_e) => {
|
|
||||||
packs_decode_error += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match sap.header.update_status {
|
|
||||||
sap::SapUpdateStatus::LastAttempt { .. } => {
|
|
||||||
packs_last_attempt += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
sap::SapUpdateStatus::Success { .. } => {
|
|
||||||
packs_success += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(m) = sap.manifest.as_ref() else {
|
|
||||||
packs_decode_error += 1;
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
by_type
|
|
||||||
.entry("mft".to_string())
|
|
||||||
.or_default()
|
|
||||||
.push(m.manifest_der.len() as u64);
|
|
||||||
by_type
|
|
||||||
.entry("crl".to_string())
|
|
||||||
.or_default()
|
|
||||||
.push(m.crl_der.len() as u64);
|
|
||||||
objects_total += 2;
|
|
||||||
|
|
||||||
for o in &sap.objects {
|
|
||||||
let typ = object_type_from_rsync_uri(&o.uri);
|
|
||||||
by_type
|
|
||||||
.entry(typ)
|
|
||||||
.or_default()
|
|
||||||
.push(o.content_der.len() as u64);
|
|
||||||
objects_total += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut summaries: BTreeMap<String, SizesSummary> = BTreeMap::new();
|
|
||||||
for (typ, mut sizes) in by_type {
|
|
||||||
if let Some(summary) = sizes.summarize() {
|
|
||||||
summaries.insert(typ, summary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let report = InventoryReport {
|
|
||||||
store_dir: store_dir.display().to_string(),
|
|
||||||
pack_files_found: pack_paths.len() as u64,
|
|
||||||
packs_success,
|
|
||||||
packs_last_attempt,
|
|
||||||
packs_decode_error,
|
|
||||||
objects_total,
|
|
||||||
by_type: summaries.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("| type | count | total_bytes | p50 | p90 | p99 | max |");
|
|
||||||
println!("|---|---:|---:|---:|---:|---:|---:|");
|
|
||||||
for (typ, s) in &summaries {
|
|
||||||
println!(
|
|
||||||
"| {} | {} | {} | {} | {} | {} | {} |",
|
|
||||||
typ, s.count, s.total_bytes, s.p50, s.p90, s.p99, s.max
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(path) = out_json.as_ref() {
|
|
||||||
create_parent_dirs(path);
|
|
||||||
let bytes = serde_json::to_vec_pretty(&report).expect("encode json");
|
|
||||||
std::fs::write(path, bytes).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Generates a flamegraph SVG for Manifest decode+profile validate.
|
|
||||||
#
|
|
||||||
# Prereqs:
|
|
||||||
# - `perf` installed and usable by current user
|
|
||||||
# - `cargo flamegraph` installed (`cargo install flamegraph`)
|
|
||||||
#
|
|
||||||
# Usage examples:
|
|
||||||
# BENCH_SAMPLE=large-02 ./tests/benchmark/flamegraph_manifest_decode_profile.sh
|
|
||||||
# BENCH_SAMPLE=large-02 BENCH_ITERS=5000 FLAMEGRAPH_PROFILE=dev ./tests/benchmark/flamegraph_manifest_decode_profile.sh
|
|
||||||
# BENCH_SAMPLE=large-02 BENCH_ITERS=2000 FLAMEGRAPH_PROFILE=release ./tests/benchmark/flamegraph_manifest_decode_profile.sh
|
|
||||||
|
|
||||||
PROFILE="${FLAMEGRAPH_PROFILE:-dev}" # dev|release
|
|
||||||
SAMPLE="${BENCH_SAMPLE:-large-02}"
|
|
||||||
|
|
||||||
ITERS="${BENCH_ITERS:-2000}"
|
|
||||||
FREQ="${FLAMEGRAPH_FREQ:-99}"
|
|
||||||
WARMUP="${BENCH_WARMUP_ITERS:-1}"
|
|
||||||
ROUNDS="${BENCH_ROUNDS:-1}"
|
|
||||||
MIN_ROUND_MS="${BENCH_MIN_ROUND_MS:-1}"
|
|
||||||
MAX_ITERS="${BENCH_MAX_ITERS:-100000}"
|
|
||||||
|
|
||||||
if ! command -v perf >/dev/null 2>&1; then
|
|
||||||
echo "ERROR: perf not found. Install linux-tools/perf first (may require sudo)." >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v cargo-flamegraph >/dev/null 2>&1 && ! command -v flamegraph >/dev/null 2>&1; then
|
|
||||||
# cargo installs as `cargo flamegraph`, but the binary is `cargo-flamegraph`.
|
|
||||||
if ! command -v cargo-flamegraph >/dev/null 2>&1; then
|
|
||||||
echo "ERROR: cargo-flamegraph not found. Install with: cargo install flamegraph" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
OUT="target/bench/flamegraph_mft_decode_profile_${SAMPLE}_${PROFILE}.svg"
|
|
||||||
|
|
||||||
echo "profile=${PROFILE} sample=${SAMPLE} iters=${ITERS} freq=${FREQ} out=${OUT}"
|
|
||||||
|
|
||||||
FLAGS=()
|
|
||||||
if [[ "${PROFILE}" == "release" ]]; then
|
|
||||||
FLAGS+=(--release)
|
|
||||||
else
|
|
||||||
FLAGS+=(--dev)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# On WSL2, /usr/bin/perf is often a wrapper that exits 2 because there is no
|
|
||||||
# kernel-matched perf binary. In that case, prefer a real perf binary under
|
|
||||||
# /usr/lib/linux-tools/*/perf by putting a shim earlier in PATH.
|
|
||||||
PERF_WRAPPER_OK=1
|
|
||||||
PERF_VERSION_OUT="$(perf --version 2>&1 || true)"
|
|
||||||
if echo "${PERF_VERSION_OUT}" | grep -q "WARNING: perf not found for kernel"; then
|
|
||||||
PERF_WRAPPER_OK=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${PERF_WRAPPER_OK}" == "0" ]]; then
|
|
||||||
PERF_REAL="$(ls -1 /usr/lib/linux-tools/*/perf 2>/dev/null | head -n 1 || true)"
|
|
||||||
if [[ -z "${PERF_REAL}" ]]; then
|
|
||||||
echo "ERROR: perf wrapper found, but no real perf binary under /usr/lib/linux-tools/*/perf" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
SHIM_DIR="target/bench/tools"
|
|
||||||
mkdir -p "${SHIM_DIR}"
|
|
||||||
cat > "${SHIM_DIR}/perf" <<EOF
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
exec "${PERF_REAL}" "\$@"
|
|
||||||
EOF
|
|
||||||
chmod +x "${SHIM_DIR}/perf"
|
|
||||||
export PATH="$(pwd)/${SHIM_DIR}:${PATH}"
|
|
||||||
echo "Using perf shim -> ${PERF_REAL}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
BENCH_SAMPLE="${SAMPLE}" \
|
|
||||||
BENCH_ITERS="${ITERS}" \
|
|
||||||
BENCH_WARMUP_ITERS="${WARMUP}" \
|
|
||||||
BENCH_ROUNDS="${ROUNDS}" \
|
|
||||||
BENCH_MIN_ROUND_MS="${MIN_ROUND_MS}" \
|
|
||||||
BENCH_MAX_ITERS="${MAX_ITERS}" \
|
|
||||||
cargo flamegraph "${FLAGS[@]}" \
|
|
||||||
-F "${FREQ}" \
|
|
||||||
--output "${OUT}" \
|
|
||||||
--test bench_manifest_decode_profile -- --ignored --nocapture
|
|
||||||
|
|
||||||
echo "Wrote ${OUT}"
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# M5: Generate perf profiles + flamegraphs + hotspots for Stage2 objects.
|
|
||||||
# Default target is ROA max sample since it is currently the largest gap in
|
|
||||||
# ours-vs-routinator decode comparison.
|
|
||||||
#
|
|
||||||
# Outputs (under rpki/target/bench):
|
|
||||||
# - perf_stage2_ours_<type>_<sample>_decode.data
|
|
||||||
# - flamegraph_stage2_ours_<type>_<sample>_decode_release.svg
|
|
||||||
# - hotspots_stage2_ours_<type>_<sample>_decode_release.txt
|
|
||||||
# - perf_stage2_ours_<type>_<sample>_landing.data
|
|
||||||
# - flamegraph_stage2_ours_<type>_<sample>_landing_release.svg
|
|
||||||
# - hotspots_stage2_ours_<type>_<sample>_landing_release.txt
|
|
||||||
# - perf_stage2_routinator_<type>_<sample>_decode.data
|
|
||||||
# - flamegraph_stage2_routinator_<type>_<sample>_decode_release.svg
|
|
||||||
# - hotspots_stage2_routinator_<type>_<sample>_decode_release.txt
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
||||||
OUT_DIR="$ROOT_DIR/target/bench"
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
|
|
||||||
TYPE="${BENCH_TYPE:-roa}" # cer|crl|roa
|
|
||||||
SAMPLE="${BENCH_SAMPLE:-max}" # e.g. max|p99|p50
|
|
||||||
|
|
||||||
ITERS="${BENCH_ITERS:-2000}"
|
|
||||||
WARMUP="${BENCH_WARMUP_ITERS:-50}"
|
|
||||||
FREQ="${FLAMEGRAPH_FREQ:-99}"
|
|
||||||
|
|
||||||
PROFILE_LANDING="${PROFILE_LANDING:-1}" # 0|1
|
|
||||||
|
|
||||||
if ! command -v perf >/dev/null 2>&1; then
|
|
||||||
echo "ERROR: perf not found." >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if ! command -v flamegraph >/dev/null 2>&1; then
|
|
||||||
echo "ERROR: flamegraph not found. Install with: cargo install flamegraph" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# On WSL2, /usr/bin/perf is often a wrapper that errors because there is no
|
|
||||||
# kernel-matched perf binary. In that case, prefer a real perf binary under
|
|
||||||
# /usr/lib/linux-tools/*/perf by putting a shim earlier in PATH.
|
|
||||||
PERF_WRAPPER_OK=1
|
|
||||||
PERF_VERSION_OUT="$(perf --version 2>&1 || true)"
|
|
||||||
if echo "${PERF_VERSION_OUT}" | grep -q "WARNING: perf not found for kernel"; then
|
|
||||||
PERF_WRAPPER_OK=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${PERF_WRAPPER_OK}" == "0" ]]; then
|
|
||||||
PERF_REAL="$(ls -1 /usr/lib/linux-tools/*/perf 2>/dev/null | head -n 1 || true)"
|
|
||||||
if [[ -z "${PERF_REAL}" ]]; then
|
|
||||||
echo "ERROR: perf wrapper found, but no real perf binary under /usr/lib/linux-tools/*/perf" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
SHIM_DIR="$OUT_DIR/tools"
|
|
||||||
mkdir -p "${SHIM_DIR}"
|
|
||||||
cat > "${SHIM_DIR}/perf" <<EOF
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
exec "${PERF_REAL}" "\$@"
|
|
||||||
EOF
|
|
||||||
chmod +x "${SHIM_DIR}/perf"
|
|
||||||
export PATH="${SHIM_DIR}:${PATH}"
|
|
||||||
echo "Using perf shim -> ${PERF_REAL}" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "type=${TYPE} sample=${SAMPLE} iters=${ITERS} warmup=${WARMUP} freq=${FREQ}" >&2
|
|
||||||
|
|
||||||
build_ours_test_bin() {
|
|
||||||
(cd "$ROOT_DIR" && cargo test --release --test bench_stage2_decode_profile_selected_der_v2 --no-run -q)
|
|
||||||
local bin
|
|
||||||
bin="$(find "$ROOT_DIR/target/release/deps" -maxdepth 1 -type f -executable -name 'bench_stage2_decode_profile_selected_der_v2-*' | head -n 1 || true)"
|
|
||||||
if [[ -z "${bin}" ]]; then
|
|
||||||
echo "ERROR: failed to locate ours test binary under target/release/deps" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
echo "${bin}"
|
|
||||||
}
|
|
||||||
|
|
||||||
build_routinator_bin() {
|
|
||||||
(cd "$ROOT_DIR/benchmark/routinator_object_bench" && cargo build --release -q)
|
|
||||||
local bin="$ROOT_DIR/benchmark/routinator_object_bench/target/release/routinator-object-bench"
|
|
||||||
if [[ ! -x "${bin}" ]]; then
|
|
||||||
echo "ERROR: failed to locate routinator-object-bench binary: ${bin}" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
echo "${bin}"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_perf_profile() {
|
|
||||||
local perfdata="$1"
|
|
||||||
shift
|
|
||||||
echo "[perf record] $perfdata" >&2
|
|
||||||
perf record -o "$perfdata" -F "$FREQ" --call-graph dwarf -g -- "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
gen_flamegraph() {
|
|
||||||
local perfdata="$1"
|
|
||||||
local out_svg="$2"
|
|
||||||
local title="$3"
|
|
||||||
local subtitle="$4"
|
|
||||||
echo "[flamegraph] $out_svg" >&2
|
|
||||||
flamegraph --perfdata "$perfdata" --output "$out_svg" --title "$title" --subtitle "$subtitle" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
gen_hotspots() {
|
|
||||||
local perfdata="$1"
|
|
||||||
local out_txt="$2"
|
|
||||||
echo "[perf report] $out_txt" >&2
|
|
||||||
# Keep it deterministic enough for diffing; avoid call-graph children view.
|
|
||||||
perf report --stdio -i "$perfdata" --no-children --sort overhead,symbol,dso > "$out_txt" || true
|
|
||||||
}
|
|
||||||
|
|
||||||
OURS_BIN="$(build_ours_test_bin)"
|
|
||||||
ROUT_BIN="$(build_routinator_bin)"
|
|
||||||
|
|
||||||
# OURS decode+validate
|
|
||||||
OURS_DECODE_PERF="$OUT_DIR/perf_stage2_ours_${TYPE}_${SAMPLE}_decode.data"
|
|
||||||
OURS_DECODE_SVG="$OUT_DIR/flamegraph_stage2_ours_${TYPE}_${SAMPLE}_decode_release.svg"
|
|
||||||
OURS_DECODE_HOT="$OUT_DIR/hotspots_stage2_ours_${TYPE}_${SAMPLE}_decode_release.txt"
|
|
||||||
|
|
||||||
run_perf_profile "$OURS_DECODE_PERF" \
|
|
||||||
env \
|
|
||||||
BENCH_MODE=decode_validate \
|
|
||||||
BENCH_TYPE="$TYPE" \
|
|
||||||
BENCH_SAMPLE="$SAMPLE" \
|
|
||||||
BENCH_ITERS="$ITERS" \
|
|
||||||
BENCH_ROUNDS=1 \
|
|
||||||
BENCH_WARMUP_ITERS="$WARMUP" \
|
|
||||||
"$OURS_BIN" --ignored --nocapture >/dev/null
|
|
||||||
|
|
||||||
gen_flamegraph "$OURS_DECODE_PERF" "$OURS_DECODE_SVG" \
|
|
||||||
"stage2 ours decode_validate ${TYPE}.${SAMPLE}" "iters=${ITERS} warmup=${WARMUP} freq=${FREQ}"
|
|
||||||
gen_hotspots "$OURS_DECODE_PERF" "$OURS_DECODE_HOT"
|
|
||||||
|
|
||||||
# OURS landing
|
|
||||||
if [[ "${PROFILE_LANDING}" == "1" ]]; then
|
|
||||||
OURS_LAND_PERF="$OUT_DIR/perf_stage2_ours_${TYPE}_${SAMPLE}_landing.data"
|
|
||||||
OURS_LAND_SVG="$OUT_DIR/flamegraph_stage2_ours_${TYPE}_${SAMPLE}_landing_release.svg"
|
|
||||||
OURS_LAND_HOT="$OUT_DIR/hotspots_stage2_ours_${TYPE}_${SAMPLE}_landing_release.txt"
|
|
||||||
|
|
||||||
run_perf_profile "$OURS_LAND_PERF" \
|
|
||||||
env \
|
|
||||||
BENCH_MODE=landing \
|
|
||||||
BENCH_TYPE="$TYPE" \
|
|
||||||
BENCH_SAMPLE="$SAMPLE" \
|
|
||||||
BENCH_ITERS="$ITERS" \
|
|
||||||
BENCH_ROUNDS=1 \
|
|
||||||
BENCH_WARMUP_ITERS="$WARMUP" \
|
|
||||||
"$OURS_BIN" --ignored --nocapture >/dev/null
|
|
||||||
|
|
||||||
gen_flamegraph "$OURS_LAND_PERF" "$OURS_LAND_SVG" \
|
|
||||||
"stage2 ours landing ${TYPE}.${SAMPLE}" "iters=${ITERS} warmup=${WARMUP} freq=${FREQ}"
|
|
||||||
gen_hotspots "$OURS_LAND_PERF" "$OURS_LAND_HOT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Routinator baseline decode
|
|
||||||
ROUT_DECODE_PERF="$OUT_DIR/perf_stage2_routinator_${TYPE}_${SAMPLE}_decode.data"
|
|
||||||
ROUT_DECODE_SVG="$OUT_DIR/flamegraph_stage2_routinator_${TYPE}_${SAMPLE}_decode_release.svg"
|
|
||||||
ROUT_DECODE_HOT="$OUT_DIR/hotspots_stage2_routinator_${TYPE}_${SAMPLE}_decode_release.txt"
|
|
||||||
|
|
||||||
run_perf_profile "$ROUT_DECODE_PERF" \
|
|
||||||
"$ROUT_BIN" \
|
|
||||||
--dir "$ROOT_DIR/tests/benchmark/selected_der_v2" \
|
|
||||||
--type "$TYPE" \
|
|
||||||
--sample "$SAMPLE" \
|
|
||||||
--iters "$ITERS" \
|
|
||||||
--rounds 1 \
|
|
||||||
--warmup-iters "$WARMUP" \
|
|
||||||
--min-round-ms 1 \
|
|
||||||
--max-iters 1000000 \
|
|
||||||
--strict true \
|
|
||||||
--out-csv "$OUT_DIR/_tmp_routinator_profile.csv" \
|
|
||||||
--out-md "$OUT_DIR/_tmp_routinator_profile.md" \
|
|
||||||
>/dev/null
|
|
||||||
|
|
||||||
gen_flamegraph "$ROUT_DECODE_PERF" "$ROUT_DECODE_SVG" \
|
|
||||||
"stage2 routinator decode ${TYPE}.${SAMPLE}" "iters=${ITERS} warmup=${WARMUP} freq=${FREQ}"
|
|
||||||
gen_hotspots "$ROUT_DECODE_PERF" "$ROUT_DECODE_HOT"
|
|
||||||
|
|
||||||
echo "Done." >&2
|
|
||||||
echo "- ours decode flamegraph: $OURS_DECODE_SVG" >&2
|
|
||||||
echo "- ours decode hotspots: $OURS_DECODE_HOT" >&2
|
|
||||||
if [[ "${PROFILE_LANDING}" == "1" ]]; then
|
|
||||||
echo "- ours landing flamegraph: $OURS_LAND_SVG" >&2
|
|
||||||
echo "- ours landing hotspots: $OURS_LAND_HOT" >&2
|
|
||||||
fi
|
|
||||||
echo "- rout decode flamegraph: $ROUT_DECODE_SVG" >&2
|
|
||||||
echo "- rout decode hotspots: $ROUT_DECODE_HOT" >&2
|
|
||||||
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct SapPublicationPoint {
|
|
||||||
pub header: SapPointHeader,
|
|
||||||
pub manifest: Option<SapStoredManifest>,
|
|
||||||
pub objects: Vec<SapStoredObject>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct SapPointHeader {
|
|
||||||
pub version: u8,
|
|
||||||
pub manifest_uri: String,
|
|
||||||
pub rpki_notify: Option<String>,
|
|
||||||
pub update_status: SapUpdateStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum SapUpdateStatus {
|
|
||||||
Success { time_utc: time::OffsetDateTime },
|
|
||||||
LastAttempt { time_utc: time::OffsetDateTime },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct SapStoredManifest {
|
|
||||||
pub not_after_utc: time::OffsetDateTime,
|
|
||||||
pub manifest_number: [u8; 20],
|
|
||||||
pub this_update_utc: time::OffsetDateTime,
|
|
||||||
pub ca_repository: String,
|
|
||||||
pub manifest_der: Vec<u8>,
|
|
||||||
pub crl_uri: String,
|
|
||||||
pub crl_der: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct SapStoredObject {
|
|
||||||
pub uri: String,
|
|
||||||
pub hash_sha256: Option<[u8; 32]>,
|
|
||||||
pub content_der: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum SapDecodeError {
|
|
||||||
#[error("I/O error reading {path}: {err}")]
|
|
||||||
Io { path: String, err: String },
|
|
||||||
|
|
||||||
#[error("SAP decode error: {0}")]
|
|
||||||
Decode(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SapPublicationPoint {
|
|
||||||
pub fn decode_path(path: &Path) -> Result<Self, SapDecodeError> {
|
|
||||||
let bytes = std::fs::read(path).map_err(|e| SapDecodeError::Io {
|
|
||||||
path: path.display().to_string(),
|
|
||||||
err: e.to_string(),
|
|
||||||
})?;
|
|
||||||
Self::decode_bytes(&bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_bytes(bytes: &[u8]) -> Result<Self, SapDecodeError> {
|
|
||||||
let mut cur = Cursor::new(bytes);
|
|
||||||
|
|
||||||
let version = cur.read_u8()?;
|
|
||||||
let manifest_uri = cur.read_string_u32()?;
|
|
||||||
let rpki_notify = cur.read_option_string_u32()?;
|
|
||||||
|
|
||||||
let update_kind = cur.read_u8()?;
|
|
||||||
let update_time_utc = cur.read_time_utc_i64_be()?;
|
|
||||||
let update_status = match update_kind {
|
|
||||||
0 => SapUpdateStatus::Success {
|
|
||||||
time_utc: update_time_utc,
|
|
||||||
},
|
|
||||||
1 => SapUpdateStatus::LastAttempt {
|
|
||||||
time_utc: update_time_utc,
|
|
||||||
},
|
|
||||||
_ => {
|
|
||||||
return Err(SapDecodeError::Decode(format!(
|
|
||||||
"invalid update_status kind: {update_kind}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let header = SapPointHeader {
|
|
||||||
version,
|
|
||||||
manifest_uri,
|
|
||||||
rpki_notify,
|
|
||||||
update_status: update_status.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match update_status {
|
|
||||||
SapUpdateStatus::LastAttempt { .. } => Ok(SapPublicationPoint {
|
|
||||||
header,
|
|
||||||
manifest: None,
|
|
||||||
objects: Vec::new(),
|
|
||||||
}),
|
|
||||||
SapUpdateStatus::Success { .. } => {
|
|
||||||
let not_after_utc = cur.read_time_utc_i64_be()?;
|
|
||||||
let manifest_number = cur.read_array_20()?;
|
|
||||||
let this_update_utc = cur.read_time_utc_i64_be()?;
|
|
||||||
let ca_repository = cur.read_string_u32()?;
|
|
||||||
let manifest_der = cur.read_bytes_u64_vec()?;
|
|
||||||
let crl_uri = cur.read_string_u32()?;
|
|
||||||
let crl_der = cur.read_bytes_u64_vec()?;
|
|
||||||
|
|
||||||
let manifest = SapStoredManifest {
|
|
||||||
not_after_utc,
|
|
||||||
manifest_number,
|
|
||||||
this_update_utc,
|
|
||||||
ca_repository,
|
|
||||||
manifest_der,
|
|
||||||
crl_uri,
|
|
||||||
crl_der,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut objects = Vec::new();
|
|
||||||
while cur.remaining() > 0 {
|
|
||||||
let uri = match cur.try_read_string_u32() {
|
|
||||||
Ok(Some(v)) => v,
|
|
||||||
Ok(None) => break,
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
};
|
|
||||||
|
|
||||||
let hash_type = cur.read_u8()?;
|
|
||||||
let hash_sha256 = match hash_type {
|
|
||||||
0 => None,
|
|
||||||
1 => Some(cur.read_array_32()?),
|
|
||||||
other => {
|
|
||||||
return Err(SapDecodeError::Decode(format!(
|
|
||||||
"unsupported hash_type {other} for stored object {uri}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let content_der = cur.read_bytes_u64_vec()?;
|
|
||||||
objects.push(SapStoredObject {
|
|
||||||
uri,
|
|
||||||
hash_sha256,
|
|
||||||
content_der,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(SapPublicationPoint {
|
|
||||||
header,
|
|
||||||
manifest: Some(manifest),
|
|
||||||
objects,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Cursor<'a> {
|
|
||||||
bytes: &'a [u8],
|
|
||||||
pos: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Cursor<'a> {
|
|
||||||
fn new(bytes: &'a [u8]) -> Self {
|
|
||||||
Self { bytes, pos: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remaining(&self) -> usize {
|
|
||||||
self.bytes.len().saturating_sub(self.pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], SapDecodeError> {
|
|
||||||
if len > self.remaining() {
|
|
||||||
return Err(SapDecodeError::Decode(format!(
|
|
||||||
"truncated input: need {len} bytes, have {}",
|
|
||||||
self.remaining()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let start = self.pos;
|
|
||||||
self.pos += len;
|
|
||||||
Ok(&self.bytes[start..start + len])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_u8(&mut self) -> Result<u8, SapDecodeError> {
|
|
||||||
Ok(self.read_exact(1)?[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_u32_be(&mut self) -> Result<u32, SapDecodeError> {
|
|
||||||
let b = self.read_exact(4)?;
|
|
||||||
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_u64_be(&mut self) -> Result<u64, SapDecodeError> {
|
|
||||||
let b = self.read_exact(8)?;
|
|
||||||
Ok(u64::from_be_bytes([
|
|
||||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_i64_be(&mut self) -> Result<i64, SapDecodeError> {
|
|
||||||
let b = self.read_exact(8)?;
|
|
||||||
Ok(i64::from_be_bytes([
|
|
||||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_time_utc_i64_be(&mut self) -> Result<time::OffsetDateTime, SapDecodeError> {
|
|
||||||
let ts = self.read_i64_be()?;
|
|
||||||
time::OffsetDateTime::from_unix_timestamp(ts)
|
|
||||||
.map_err(|e| SapDecodeError::Decode(format!("invalid unix timestamp {ts}: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_string_u32(&mut self) -> Result<String, SapDecodeError> {
|
|
||||||
let len = self.read_u32_be()? as usize;
|
|
||||||
let b = self.read_exact(len)?;
|
|
||||||
std::str::from_utf8(b)
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.map_err(|e| SapDecodeError::Decode(format!("invalid UTF-8 string: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_read_string_u32(&mut self) -> Result<Option<String>, SapDecodeError> {
|
|
||||||
if self.remaining() == 0 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
Ok(Some(self.read_string_u32()?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_option_string_u32(&mut self) -> Result<Option<String>, SapDecodeError> {
|
|
||||||
let len = self.read_u32_be()? as usize;
|
|
||||||
if len == 0 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let b = self.read_exact(len)?;
|
|
||||||
let s = std::str::from_utf8(b)
|
|
||||||
.map_err(|e| SapDecodeError::Decode(format!("invalid UTF-8 string: {e}")))?;
|
|
||||||
Ok(Some(s.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_bytes_u64_vec(&mut self) -> Result<Vec<u8>, SapDecodeError> {
|
|
||||||
let len_u64 = self.read_u64_be()?;
|
|
||||||
let len = usize::try_from(len_u64).map_err(|_| {
|
|
||||||
SapDecodeError::Decode(format!("data block too large for this system: {len_u64}"))
|
|
||||||
})?;
|
|
||||||
Ok(self.read_exact(len)?.to_vec())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_array_20(&mut self) -> Result<[u8; 20], SapDecodeError> {
|
|
||||||
let b = self.read_exact(20)?;
|
|
||||||
let mut out = [0u8; 20];
|
|
||||||
out.copy_from_slice(b);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_array_32(&mut self) -> Result<[u8; 32], SapDecodeError> {
|
|
||||||
let b = self.read_exact(32)?;
|
|
||||||
let mut out = [0u8; 32];
|
|
||||||
out.copy_from_slice(b);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,8 +0,0 @@
|
|||||||
small-01 3160 1875 stored_point_container bench/full/cache/stored/rrdp/rrdp.twnic.tw/9e5ef8f1eb47c1bd46d2c578ebefa6a774fe140dbfbfeff1a018f1ebfc1de14b/rsync/rpkica.twnic.tw/rpki/TWNICCA/ATT/VRjTaiHoQb_taL2Agyl1Nd1wiwU.mft
|
|
||||||
small-02 3160 1875 stored_point_container bench/full/cache/stored/rrdp/rrdp.twnic.tw/9e5ef8f1eb47c1bd46d2c578ebefa6a774fe140dbfbfeff1a018f1ebfc1de14b/rsync/rpkica.twnic.tw/rpki/TWNICCA/BOT/Kn9vIkC0LSu8Df78l2yrIJHEfsc.mft
|
|
||||||
medium-01 16384 2681 stored_point_container bench/full/cache/stored/rrdp/rrdp.arin.net/e2c8cb4b372d841061fc231dcdc28a679c244f4f630d41cfe01c8c3aaa3a36f7/rsync/rpki.arin.net/repository/arin-rpki-ta/5e4a23ea-e80a-403e-b08c-2171da2157d3/69fd0156-bb1f-48b6-bf32-c9492286f195/a32aa006-ef22-4905-8c82-2651c19859b9/a32aa006-ef22-4905-8c82-2651c19859b9.mft
|
|
||||||
medium-02 16384 2681 stored_point_container bench/full/cache/stored/rrdp/rrdp.arin.net/e2c8cb4b372d841061fc231dcdc28a679c244f4f630d41cfe01c8c3aaa3a36f7/rsync/rpki.arin.net/repository/arin-rpki-ta/5e4a23ea-e80a-403e-b08c-2171da2157d3/6ead073c-b9f0-4523-b39e-a3c7dab97c25/dfc6770a-8775-4ba8-9d91-68facba79b33/dfc6770a-8775-4ba8-9d91-68facba79b33.mft
|
|
||||||
large-01 262869 2250 stored_point_container bench/full/cache/stored/rrdp/rpki-rrdp.us-east-2.amazonaws.com/c7cf30a5673053f1484f06ac72410809310e150ad73a8a7471cc95aad83b1e07/rsync/rpki-rsync.us-east-2.amazonaws.com/volume/16f1ffee-7461-4674-bb05-fddefa9a02c6/JmLOFOkF4Y68t1IvkrNoS8SGW00.mft
|
|
||||||
large-02 264331 10476 stored_point_container bench/full/cache/stored/rrdp/rrdp.ripe.net/2315d99a99627f34bc597569abc7c177ad45108a37f173f3d06dbabd64962f3c/rsync/rpki.ripe.net/repository/DEFAULT/fa/bc92e6-c8ee-48f0-ae7f-36ccb5a06195/1/tDgLm4wHBFftVLxF0S3d0kTgbVI.mft
|
|
||||||
xlarge-01 4522145 144968 stored_point_container bench/full/cache/stored/rrdp/rrdp.arin.net/e2c8cb4b372d841061fc231dcdc28a679c244f4f630d41cfe01c8c3aaa3a36f7/rsync/rpki.arin.net/repository/arin-rpki-ta/5e4a23ea-e80a-403e-b08c-2171da2157d3/fde169ed-d0d2-4165-8308-df2597e343f8/c2070b9e-789d-4f95-bca5-30e065e9a31d/c2070b9e-789d-4f95-bca5-30e065e9a31d.mft
|
|
||||||
xlarge-02 4615094 150261 stored_point_container bench/full/cache/stored/rrdp/rrdp.arin.net/e2c8cb4b372d841061fc231dcdc28a679c244f4f630d41cfe01c8c3aaa3a36f7/rsync/rpki.arin.net/repository/arin-rpki-ta/5e4a23ea-e80a-403e-b08c-2171da2157d3/f60c9f32-a87c-4339-a2f3-6299a3b02e29/b2f0a061-78dd-4d61-988a-266b192d9caa/b2f0a061-78dd-4d61-988a-266b192d9caa.mft
|
|
||||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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