834 lines
33 KiB
Rust
834 lines
33 KiB
Rust
use rpki::bundle::{
|
|
BundleManifest, BundleManifestEntry, RirBundleMetadata, build_vap_compare_rows,
|
|
build_vrp_compare_rows, decode_ccr_compare_views, write_vap_csv, write_vrp_csv,
|
|
};
|
|
use rpki::ccr::{build_ccr_from_run, decode_content_info, verify_content_info, write_ccr_file};
|
|
use rpki::policy::Policy;
|
|
use rpki::storage::RocksStore;
|
|
use rpki::validation::run_tree_from_tal::{
|
|
run_tree_from_tal_and_ta_der_payload_delta_replay_serial_audit,
|
|
run_tree_from_tal_and_ta_der_payload_replay_serial_audit,
|
|
};
|
|
use rpki::validation::tree::TreeRunConfig;
|
|
use sha2::Digest;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Instant;
|
|
use time::format_description::well_known::Rfc3339;
|
|
|
|
#[derive(Debug, Default, PartialEq, Eq)]
|
|
struct Args {
|
|
rir: Option<String>,
|
|
out_dir: Option<PathBuf>,
|
|
tal_path: Option<PathBuf>,
|
|
ta_path: Option<PathBuf>,
|
|
payload_replay_archive: Option<PathBuf>,
|
|
payload_replay_locks: Option<PathBuf>,
|
|
payload_delta_archive: Option<PathBuf>,
|
|
payload_delta_locks: Option<PathBuf>,
|
|
validation_time: Option<time::OffsetDateTime>,
|
|
max_depth: Option<usize>,
|
|
max_instances: Option<usize>,
|
|
trust_anchor: Option<String>,
|
|
}
|
|
|
|
fn usage() -> &'static str {
|
|
"Usage: replay_bundle_record --rir <name> --out-dir <path> --tal-path <path> --ta-path <path> --payload-replay-archive <path> --payload-replay-locks <path> [--payload-delta-archive <path> --payload-delta-locks <path>] [--validation-time <rfc3339>] [--max-depth <n>] [--max-instances <n>] [--trust-anchor <name>]"
|
|
}
|
|
|
|
fn parse_args(argv: &[String]) -> Result<Args, String> {
|
|
let mut args = Args::default();
|
|
let mut i = 1usize;
|
|
while i < argv.len() {
|
|
match argv[i].as_str() {
|
|
"--help" | "-h" => return Err(usage().to_string()),
|
|
"--rir" => {
|
|
i += 1;
|
|
args.rir = Some(argv.get(i).ok_or("--rir requires a value")?.clone());
|
|
}
|
|
"--out-dir" => {
|
|
i += 1;
|
|
args.out_dir = Some(PathBuf::from(
|
|
argv.get(i).ok_or("--out-dir requires a value")?,
|
|
));
|
|
}
|
|
"--tal-path" => {
|
|
i += 1;
|
|
args.tal_path = Some(PathBuf::from(
|
|
argv.get(i).ok_or("--tal-path requires a value")?,
|
|
));
|
|
}
|
|
"--ta-path" => {
|
|
i += 1;
|
|
args.ta_path = Some(PathBuf::from(
|
|
argv.get(i).ok_or("--ta-path requires a value")?,
|
|
));
|
|
}
|
|
"--payload-replay-archive" => {
|
|
i += 1;
|
|
args.payload_replay_archive = Some(PathBuf::from(
|
|
argv.get(i)
|
|
.ok_or("--payload-replay-archive requires a value")?,
|
|
));
|
|
}
|
|
"--payload-replay-locks" => {
|
|
i += 1;
|
|
args.payload_replay_locks = Some(PathBuf::from(
|
|
argv.get(i)
|
|
.ok_or("--payload-replay-locks requires a value")?,
|
|
));
|
|
}
|
|
"--payload-delta-archive" => {
|
|
i += 1;
|
|
args.payload_delta_archive = Some(PathBuf::from(
|
|
argv.get(i)
|
|
.ok_or("--payload-delta-archive requires a value")?,
|
|
));
|
|
}
|
|
"--payload-delta-locks" => {
|
|
i += 1;
|
|
args.payload_delta_locks = Some(PathBuf::from(
|
|
argv.get(i)
|
|
.ok_or("--payload-delta-locks requires a value")?,
|
|
));
|
|
}
|
|
"--validation-time" => {
|
|
i += 1;
|
|
let value = argv.get(i).ok_or("--validation-time requires a value")?;
|
|
args.validation_time = Some(
|
|
time::OffsetDateTime::parse(value, &Rfc3339)
|
|
.map_err(|e| format!("invalid --validation-time: {e}"))?,
|
|
);
|
|
}
|
|
"--max-depth" => {
|
|
i += 1;
|
|
args.max_depth = Some(
|
|
argv.get(i)
|
|
.ok_or("--max-depth requires a value")?
|
|
.parse()
|
|
.map_err(|e| format!("invalid --max-depth: {e}"))?,
|
|
);
|
|
}
|
|
"--max-instances" => {
|
|
i += 1;
|
|
args.max_instances = Some(
|
|
argv.get(i)
|
|
.ok_or("--max-instances requires a value")?
|
|
.parse()
|
|
.map_err(|e| format!("invalid --max-instances: {e}"))?,
|
|
);
|
|
}
|
|
"--trust-anchor" => {
|
|
i += 1;
|
|
args.trust_anchor = Some(
|
|
argv.get(i)
|
|
.ok_or("--trust-anchor requires a value")?
|
|
.clone(),
|
|
);
|
|
}
|
|
other => return Err(format!("unknown argument: {other}\n{}", usage())),
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
if args.rir.is_none() {
|
|
return Err(format!("--rir is required\n{}", usage()));
|
|
}
|
|
if args.out_dir.is_none() {
|
|
return Err(format!("--out-dir is required\n{}", usage()));
|
|
}
|
|
if args.tal_path.is_none() {
|
|
return Err(format!("--tal-path is required\n{}", usage()));
|
|
}
|
|
if args.ta_path.is_none() {
|
|
return Err(format!("--ta-path is required\n{}", usage()));
|
|
}
|
|
if args.payload_replay_archive.is_none() {
|
|
return Err(format!("--payload-replay-archive is required\n{}", usage()));
|
|
}
|
|
if args.payload_replay_locks.is_none() {
|
|
return Err(format!("--payload-replay-locks is required\n{}", usage()));
|
|
}
|
|
Ok(args)
|
|
}
|
|
|
|
fn load_validation_time(path: &Path) -> Result<time::OffsetDateTime, String> {
|
|
let json: serde_json::Value = serde_json::from_slice(
|
|
&fs::read(path).map_err(|e| format!("read locks failed: {}: {e}", path.display()))?,
|
|
)
|
|
.map_err(|e| format!("parse locks failed: {}: {e}", path.display()))?;
|
|
let value = json
|
|
.get("validationTime")
|
|
.or_else(|| json.get("validation_time"))
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| format!("validationTime missing in {}", path.display()))?;
|
|
time::OffsetDateTime::parse(value, &Rfc3339)
|
|
.map_err(|e| format!("invalid validationTime in {}: {e}", path.display()))
|
|
}
|
|
|
|
fn sha256_hex(bytes: &[u8]) -> String {
|
|
hex::encode(sha2::Sha256::digest(bytes))
|
|
}
|
|
|
|
fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), String> {
|
|
fs::create_dir_all(dst)
|
|
.map_err(|e| format!("create directory failed: {}: {e}", dst.display()))?;
|
|
for entry in
|
|
fs::read_dir(src).map_err(|e| format!("read_dir failed: {}: {e}", src.display()))?
|
|
{
|
|
let entry = entry.map_err(|e| format!("read_dir entry failed: {}: {e}", src.display()))?;
|
|
let ty = entry
|
|
.file_type()
|
|
.map_err(|e| format!("file_type failed: {}: {e}", entry.path().display()))?;
|
|
let to = dst.join(entry.file_name());
|
|
if ty.is_dir() {
|
|
copy_dir_all(&entry.path(), &to)?;
|
|
} else if ty.is_file() {
|
|
if let Some(parent) = to.parent() {
|
|
fs::create_dir_all(parent)
|
|
.map_err(|e| format!("create parent failed: {}: {e}", parent.display()))?;
|
|
}
|
|
fs::copy(entry.path(), &to).map_err(|e| {
|
|
format!(
|
|
"copy failed: {} -> {}: {e}",
|
|
entry.path().display(),
|
|
to.display()
|
|
)
|
|
})?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn write_json(path: &Path, value: &impl serde::Serialize) -> Result<(), String> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.map_err(|e| format!("create parent failed: {}: {e}", parent.display()))?;
|
|
}
|
|
let bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
|
|
fs::write(path, bytes).map_err(|e| format!("write json failed: {}: {e}", path.display()))
|
|
}
|
|
|
|
fn write_top_readme(path: &Path, rir: &str) -> Result<(), String> {
|
|
fs::write(
|
|
path,
|
|
format!(
|
|
"# Ours Replay Bundle\n\nThis run contains one per-RIR bundle generated by `ours`.\n\n- RIR: `{rir}`\n- Reference result format: `CCR`\n"
|
|
),
|
|
)
|
|
.map_err(|e| format!("write readme failed: {}: {e}", path.display()))
|
|
}
|
|
|
|
fn write_rir_readme(path: &Path, rir: &str, base_validation_time: &str) -> Result<(), String> {
|
|
fs::write(
|
|
path,
|
|
format!(
|
|
"# {rir} replay bundle\n\n- `tal.tal` and `ta.cer` are the direct replay inputs.\n- `base-locks.json.validationTime` = `{base_validation_time}`.\n- `base.ccr` is the authoritative reference result.\n- `base-vrps.csv` and `base-vaps.csv` are compare views derived from `base.ccr`.\n"
|
|
),
|
|
)
|
|
.map_err(|e| format!("write rir readme failed: {}: {e}", path.display()))
|
|
}
|
|
|
|
fn write_timing_json(
|
|
path: &Path,
|
|
mode: &str,
|
|
validation_time: &time::OffsetDateTime,
|
|
duration: std::time::Duration,
|
|
) -> Result<(), String> {
|
|
write_json(
|
|
path,
|
|
&serde_json::json!({
|
|
"mode": mode,
|
|
"validationTime": validation_time
|
|
.format(&Rfc3339)
|
|
.map_err(|e| format!("format validation time failed: {e}"))?,
|
|
"durationSeconds": duration.as_secs_f64(),
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn rewrite_delta_base_locks_sha(
|
|
delta_root: &Path,
|
|
emitted_base_locks_sha256: &str,
|
|
) -> Result<(), String> {
|
|
let delta_locks = delta_root.join("locks-delta.json");
|
|
if delta_locks.is_file() {
|
|
let mut json: serde_json::Value = serde_json::from_slice(
|
|
&fs::read(&delta_locks)
|
|
.map_err(|e| format!("read delta locks failed: {}: {e}", delta_locks.display()))?,
|
|
)
|
|
.map_err(|e| format!("parse delta locks failed: {}: {e}", delta_locks.display()))?;
|
|
json.as_object_mut()
|
|
.ok_or_else(|| format!("delta locks must be object: {}", delta_locks.display()))?
|
|
.insert(
|
|
"baseLocksSha256".to_string(),
|
|
serde_json::Value::String(emitted_base_locks_sha256.to_string()),
|
|
);
|
|
write_json(&delta_locks, &json)?;
|
|
}
|
|
|
|
let archive_root = delta_root.join("payload-delta-archive");
|
|
if archive_root.is_dir() {
|
|
for path in walk_json_files_named(&archive_root, "base.json")? {
|
|
let mut json: serde_json::Value = serde_json::from_slice(
|
|
&fs::read(&path)
|
|
.map_err(|e| format!("read base.json failed: {}: {e}", path.display()))?,
|
|
)
|
|
.map_err(|e| format!("parse base.json failed: {}: {e}", path.display()))?;
|
|
json.as_object_mut()
|
|
.ok_or_else(|| format!("base.json must be object: {}", path.display()))?
|
|
.insert(
|
|
"baseLocksSha256".to_string(),
|
|
serde_json::Value::String(emitted_base_locks_sha256.to_string()),
|
|
);
|
|
write_json(&path, &json)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn walk_json_files_named(root: &Path, name: &str) -> Result<Vec<PathBuf>, String> {
|
|
let mut out = Vec::new();
|
|
if !root.is_dir() {
|
|
return Ok(out);
|
|
}
|
|
let mut stack = vec![root.to_path_buf()];
|
|
while let Some(dir) = stack.pop() {
|
|
for entry in
|
|
fs::read_dir(&dir).map_err(|e| format!("read_dir failed: {}: {e}", dir.display()))?
|
|
{
|
|
let entry =
|
|
entry.map_err(|e| format!("read_dir entry failed: {}: {e}", dir.display()))?;
|
|
let path = entry.path();
|
|
let ty = entry
|
|
.file_type()
|
|
.map_err(|e| format!("file_type failed: {}: {e}", path.display()))?;
|
|
if ty.is_dir() {
|
|
stack.push(path);
|
|
} else if ty.is_file() && entry.file_name() == name {
|
|
out.push(path);
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn run(args: Args) -> Result<PathBuf, String> {
|
|
let rir = args.rir.as_ref().unwrap();
|
|
let rir_normalized = rir.to_ascii_lowercase();
|
|
let out_root = args.out_dir.as_ref().unwrap();
|
|
let tal_path = args.tal_path.as_ref().unwrap();
|
|
let ta_path = args.ta_path.as_ref().unwrap();
|
|
let replay_archive = args.payload_replay_archive.as_ref().unwrap();
|
|
let replay_locks = args.payload_replay_locks.as_ref().unwrap();
|
|
let trust_anchor = args
|
|
.trust_anchor
|
|
.clone()
|
|
.unwrap_or_else(|| rir_normalized.clone());
|
|
|
|
let base_validation_time = match args.validation_time {
|
|
Some(value) => value,
|
|
None => load_validation_time(replay_locks)?,
|
|
};
|
|
let delta_validation_time = match args.payload_delta_locks.as_ref() {
|
|
Some(path) => Some(load_validation_time(path)?),
|
|
None => None,
|
|
};
|
|
|
|
let run_root = out_root;
|
|
let rir_dir = run_root.join(&rir_normalized);
|
|
fs::create_dir_all(&rir_dir)
|
|
.map_err(|e| format!("create rir dir failed: {}: {e}", rir_dir.display()))?;
|
|
|
|
let tal_bytes =
|
|
fs::read(tal_path).map_err(|e| format!("read tal failed: {}: {e}", tal_path.display()))?;
|
|
let ta_bytes =
|
|
fs::read(ta_path).map_err(|e| format!("read ta failed: {}: {e}", ta_path.display()))?;
|
|
|
|
let db_dir = run_root.join(".tmp").join(format!("{rir}-base-db"));
|
|
if db_dir.exists() {
|
|
fs::remove_dir_all(&db_dir)
|
|
.map_err(|e| format!("remove old db failed: {}: {e}", db_dir.display()))?;
|
|
}
|
|
if let Some(parent) = db_dir.parent() {
|
|
fs::create_dir_all(parent)
|
|
.map_err(|e| format!("create db parent failed: {}: {e}", parent.display()))?;
|
|
}
|
|
let store = RocksStore::open(&db_dir).map_err(|e| format!("open rocksdb failed: {e}"))?;
|
|
|
|
let base_started = Instant::now();
|
|
let out = run_tree_from_tal_and_ta_der_payload_replay_serial_audit(
|
|
&store,
|
|
&Policy::default(),
|
|
&tal_bytes,
|
|
&ta_bytes,
|
|
None,
|
|
replay_archive,
|
|
replay_locks,
|
|
base_validation_time,
|
|
&TreeRunConfig {
|
|
max_depth: args.max_depth,
|
|
max_instances: args.max_instances,
|
|
compact_audit: false,
|
|
persist_vcir: true,
|
|
build_ccr_accumulator: true,
|
|
},
|
|
)
|
|
.map_err(|e| format!("base replay failed: {e}"))?;
|
|
let base_duration = base_started.elapsed();
|
|
|
|
let ccr = build_ccr_from_run(
|
|
&store,
|
|
&[out.discovery.trust_anchor.clone()],
|
|
&out.tree.vrps,
|
|
&out.tree.aspas,
|
|
&out.tree.router_keys,
|
|
base_validation_time,
|
|
)
|
|
.map_err(|e| format!("build ccr failed: {e}"))?;
|
|
|
|
let base_ccr_path = rir_dir.join("base.ccr");
|
|
write_ccr_file(&base_ccr_path, &ccr).map_err(|e| format!("write ccr failed: {e}"))?;
|
|
let ccr_bytes = fs::read(&base_ccr_path)
|
|
.map_err(|e| format!("read written ccr failed: {}: {e}", base_ccr_path.display()))?;
|
|
let decoded =
|
|
decode_content_info(&ccr_bytes).map_err(|e| format!("decode written ccr failed: {e}"))?;
|
|
let verify = verify_content_info(&decoded).map_err(|e| format!("verify ccr failed: {e}"))?;
|
|
|
|
let vrp_rows = build_vrp_compare_rows(&out.tree.vrps, &trust_anchor);
|
|
let vap_rows = build_vap_compare_rows(&out.tree.aspas, &trust_anchor);
|
|
let (ccr_vrps, ccr_vaps) = decode_ccr_compare_views(&decoded, &trust_anchor)?;
|
|
if vrp_rows != ccr_vrps {
|
|
return Err("base-vrps compare view does not match base.ccr".to_string());
|
|
}
|
|
if vap_rows != ccr_vaps {
|
|
return Err("base-vaps compare view does not match base.ccr".to_string());
|
|
}
|
|
|
|
let base_vrps_csv = rir_dir.join("base-vrps.csv");
|
|
let base_vaps_csv = rir_dir.join("base-vaps.csv");
|
|
write_vrp_csv(&base_vrps_csv, &vrp_rows)?;
|
|
write_vap_csv(&base_vaps_csv, &vap_rows)?;
|
|
|
|
copy_dir_all(replay_archive, &rir_dir.join("base-payload-archive"))?;
|
|
let mut base_locks_json: serde_json::Value = serde_json::from_slice(
|
|
&fs::read(replay_locks)
|
|
.map_err(|e| format!("read base locks failed: {}: {e}", replay_locks.display()))?,
|
|
)
|
|
.map_err(|e| format!("parse base locks failed: {}: {e}", replay_locks.display()))?;
|
|
base_locks_json["validationTime"] = serde_json::Value::String(
|
|
base_validation_time
|
|
.format(&Rfc3339)
|
|
.map_err(|e| format!("format validation time failed: {e}"))?,
|
|
);
|
|
let emitted_base_locks_path = rir_dir.join("base-locks.json");
|
|
write_json(&emitted_base_locks_path, &base_locks_json)?;
|
|
let emitted_base_locks_sha256 =
|
|
sha256_hex(&fs::read(&emitted_base_locks_path).map_err(|e| {
|
|
format!(
|
|
"read emitted base locks failed: {}: {e}",
|
|
emitted_base_locks_path.display()
|
|
)
|
|
})?);
|
|
|
|
if let Some(delta_archive) = args.payload_delta_archive.as_ref() {
|
|
copy_dir_all(delta_archive, &rir_dir.join("payload-delta-archive"))?;
|
|
}
|
|
if let Some(delta_locks) = args.payload_delta_locks.as_ref() {
|
|
let mut delta_json: serde_json::Value = serde_json::from_slice(
|
|
&fs::read(delta_locks)
|
|
.map_err(|e| format!("read delta locks failed: {}: {e}", delta_locks.display()))?,
|
|
)
|
|
.map_err(|e| format!("parse delta locks failed: {}: {e}", delta_locks.display()))?;
|
|
if let Some(delta_time) = delta_validation_time.as_ref() {
|
|
delta_json
|
|
.as_object_mut()
|
|
.ok_or_else(|| "delta locks json must be an object".to_string())?
|
|
.insert(
|
|
"validationTime".to_string(),
|
|
serde_json::Value::String(
|
|
delta_time
|
|
.format(&Rfc3339)
|
|
.map_err(|e| format!("format delta validation time failed: {e}"))?,
|
|
),
|
|
);
|
|
}
|
|
write_json(&rir_dir.join("locks-delta.json"), &delta_json)?;
|
|
}
|
|
if args.payload_delta_archive.is_some() && args.payload_delta_locks.is_some() {
|
|
rewrite_delta_base_locks_sha(&rir_dir, &emitted_base_locks_sha256)?;
|
|
}
|
|
|
|
fs::write(rir_dir.join("tal.tal"), &tal_bytes).map_err(|e| format!("write tal failed: {e}"))?;
|
|
fs::write(rir_dir.join("ta.cer"), &ta_bytes).map_err(|e| format!("write ta failed: {e}"))?;
|
|
|
|
let mut metadata = RirBundleMetadata {
|
|
schema_version: "20260330-v1".to_string(),
|
|
bundle_producer: "ours".to_string(),
|
|
rir: rir_normalized.clone(),
|
|
base_validation_time: base_validation_time
|
|
.format(&Rfc3339)
|
|
.map_err(|e| format!("format validation time failed: {e}"))?,
|
|
delta_validation_time: delta_validation_time.as_ref().map(|value| {
|
|
value
|
|
.format(&Rfc3339)
|
|
.expect("delta validation time must format")
|
|
}),
|
|
tal_sha256: sha256_hex(&tal_bytes),
|
|
ta_cert_sha256: sha256_hex(&ta_bytes),
|
|
base_ccr_sha256: sha256_hex(&ccr_bytes),
|
|
delta_ccr_sha256: None,
|
|
has_aspa: !vap_rows.is_empty(),
|
|
has_router_key: verify.router_key_count > 0,
|
|
base_vrp_count: vrp_rows.len(),
|
|
base_vap_count: vap_rows.len(),
|
|
delta_vrp_count: None,
|
|
delta_vap_count: None,
|
|
};
|
|
|
|
fs::create_dir_all(rir_dir.join("timings"))
|
|
.map_err(|e| format!("create timings dir failed: {e}"))?;
|
|
write_timing_json(
|
|
&rir_dir.join("timings").join("base-produce.json"),
|
|
"base",
|
|
&base_validation_time,
|
|
base_duration,
|
|
)?;
|
|
|
|
let mut verification = serde_json::json!({
|
|
"base": {
|
|
"validationTime": metadata.base_validation_time,
|
|
"ccr": {
|
|
"path": "base.ccr",
|
|
"sha256": metadata.base_ccr_sha256,
|
|
"stateHashesOk": verify.state_hashes_ok,
|
|
"manifestInstances": verify.manifest_instances,
|
|
"roaVrpCount": verify.roa_vrp_count,
|
|
"aspaPayloadSets": verify.aspa_payload_sets,
|
|
"routerKeyCount": verify.router_key_count,
|
|
},
|
|
"compareViews": {
|
|
"vrpsSelfMatch": true,
|
|
"vapsSelfMatch": true,
|
|
"baseVrpCount": metadata.base_vrp_count,
|
|
"baseVapCount": metadata.base_vap_count,
|
|
}
|
|
}
|
|
});
|
|
|
|
if let (Some(delta_archive), Some(delta_locks), Some(delta_time)) = (
|
|
args.payload_delta_archive.as_ref(),
|
|
args.payload_delta_locks.as_ref(),
|
|
delta_validation_time.as_ref(),
|
|
) {
|
|
let delta_db_dir = run_root.join(".tmp").join(format!("{rir}-delta-db"));
|
|
if delta_db_dir.exists() {
|
|
fs::remove_dir_all(&delta_db_dir).map_err(|e| {
|
|
format!(
|
|
"remove old delta db failed: {}: {e}",
|
|
delta_db_dir.display()
|
|
)
|
|
})?;
|
|
}
|
|
if let Some(parent) = delta_db_dir.parent() {
|
|
fs::create_dir_all(parent)
|
|
.map_err(|e| format!("create delta db parent failed: {}: {e}", parent.display()))?;
|
|
}
|
|
let delta_store = RocksStore::open(&delta_db_dir)
|
|
.map_err(|e| format!("open delta rocksdb failed: {e}"))?;
|
|
let delta_started = Instant::now();
|
|
let delta_out = run_tree_from_tal_and_ta_der_payload_delta_replay_serial_audit(
|
|
&delta_store,
|
|
&Policy::default(),
|
|
&tal_bytes,
|
|
&ta_bytes,
|
|
None,
|
|
replay_archive,
|
|
replay_locks,
|
|
delta_archive,
|
|
delta_locks,
|
|
base_validation_time,
|
|
*delta_time,
|
|
&TreeRunConfig {
|
|
max_depth: args.max_depth,
|
|
max_instances: args.max_instances,
|
|
compact_audit: false,
|
|
persist_vcir: true,
|
|
build_ccr_accumulator: true,
|
|
},
|
|
)
|
|
.map_err(|e| format!("delta replay failed: {e}"))?;
|
|
let delta_duration = delta_started.elapsed();
|
|
|
|
let delta_ccr = build_ccr_from_run(
|
|
&delta_store,
|
|
&[delta_out.discovery.trust_anchor.clone()],
|
|
&delta_out.tree.vrps,
|
|
&delta_out.tree.aspas,
|
|
&delta_out.tree.router_keys,
|
|
*delta_time,
|
|
)
|
|
.map_err(|e| format!("build delta ccr failed: {e}"))?;
|
|
|
|
let delta_ccr_path = rir_dir.join("delta.ccr");
|
|
write_ccr_file(&delta_ccr_path, &delta_ccr)
|
|
.map_err(|e| format!("write delta ccr failed: {e}"))?;
|
|
let delta_ccr_bytes = fs::read(&delta_ccr_path).map_err(|e| {
|
|
format!(
|
|
"read written delta ccr failed: {}: {e}",
|
|
delta_ccr_path.display()
|
|
)
|
|
})?;
|
|
let delta_decoded = decode_content_info(&delta_ccr_bytes)
|
|
.map_err(|e| format!("decode written delta ccr failed: {e}"))?;
|
|
let delta_verify = verify_content_info(&delta_decoded)
|
|
.map_err(|e| format!("verify delta ccr failed: {e}"))?;
|
|
|
|
let delta_vrp_rows = build_vrp_compare_rows(&delta_out.tree.vrps, &trust_anchor);
|
|
let delta_vap_rows = build_vap_compare_rows(&delta_out.tree.aspas, &trust_anchor);
|
|
let (delta_ccr_vrps, delta_ccr_vaps) =
|
|
decode_ccr_compare_views(&delta_decoded, &trust_anchor)?;
|
|
if delta_vrp_rows != delta_ccr_vrps {
|
|
return Err("record-delta.csv compare view does not match delta.ccr".to_string());
|
|
}
|
|
if delta_vap_rows != delta_ccr_vaps {
|
|
return Err("record-delta-vaps.csv compare view does not match delta.ccr".to_string());
|
|
}
|
|
write_vrp_csv(&rir_dir.join("record-delta.csv"), &delta_vrp_rows)?;
|
|
write_vap_csv(&rir_dir.join("record-delta-vaps.csv"), &delta_vap_rows)?;
|
|
write_timing_json(
|
|
&rir_dir.join("timings").join("delta-produce.json"),
|
|
"delta",
|
|
delta_time,
|
|
delta_duration,
|
|
)?;
|
|
|
|
metadata.delta_ccr_sha256 = Some(sha256_hex(&delta_ccr_bytes));
|
|
metadata.delta_vrp_count = Some(delta_vrp_rows.len());
|
|
metadata.delta_vap_count = Some(delta_vap_rows.len());
|
|
metadata.has_aspa = metadata.has_aspa || !delta_vap_rows.is_empty();
|
|
metadata.has_router_key = metadata.has_router_key || delta_verify.router_key_count > 0;
|
|
|
|
verification["delta"] = serde_json::json!({
|
|
"validationTime": delta_time
|
|
.format(&Rfc3339)
|
|
.map_err(|e| format!("format delta validation time failed: {e}"))?,
|
|
"ccr": {
|
|
"path": "delta.ccr",
|
|
"sha256": metadata.delta_ccr_sha256.clone().expect("delta sha must exist"),
|
|
"stateHashesOk": delta_verify.state_hashes_ok,
|
|
"manifestInstances": delta_verify.manifest_instances,
|
|
"roaVrpCount": delta_verify.roa_vrp_count,
|
|
"aspaPayloadSets": delta_verify.aspa_payload_sets,
|
|
"routerKeyCount": delta_verify.router_key_count,
|
|
},
|
|
"compareViews": {
|
|
"vrpsSelfMatch": true,
|
|
"vapsSelfMatch": true,
|
|
"deltaVrpCount": metadata.delta_vrp_count,
|
|
"deltaVapCount": metadata.delta_vap_count,
|
|
}
|
|
});
|
|
|
|
let _ = fs::remove_dir_all(&delta_db_dir);
|
|
}
|
|
|
|
write_json(&rir_dir.join("bundle.json"), &metadata)?;
|
|
write_json(&rir_dir.join("verification.json"), &verification)?;
|
|
write_top_readme(&run_root.join("README.md"), rir)?;
|
|
write_rir_readme(
|
|
&rir_dir.join("README.md"),
|
|
rir,
|
|
&metadata.base_validation_time,
|
|
)?;
|
|
|
|
let bundle_manifest = BundleManifest {
|
|
schema_version: "20260330-v1".to_string(),
|
|
bundle_producer: "ours".to_string(),
|
|
recorded_at_rfc3339_utc: time::OffsetDateTime::now_utc()
|
|
.format(&Rfc3339)
|
|
.map_err(|e| format!("format recorded_at failed: {e}"))?,
|
|
rirs: vec![rir_normalized.clone()],
|
|
per_rir_bundles: vec![BundleManifestEntry {
|
|
rir: rir_normalized.clone(),
|
|
relative_path: rir_normalized,
|
|
base_validation_time: metadata.base_validation_time.clone(),
|
|
delta_validation_time: metadata.delta_validation_time.clone(),
|
|
has_aspa: metadata.has_aspa,
|
|
}],
|
|
};
|
|
write_json(&run_root.join("bundle-manifest.json"), &bundle_manifest)?;
|
|
|
|
let _ = fs::remove_dir_all(&db_dir);
|
|
|
|
Ok(run_root.clone())
|
|
}
|
|
|
|
fn main() -> Result<(), String> {
|
|
let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
|
|
let out = run(args)?;
|
|
println!("{}", out.display());
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
fn skip_heavy_blackbox_test() -> bool {
|
|
std::env::var_os("RPKI_SKIP_HEAVY_BLACKBOX_TESTS").is_some()
|
|
}
|
|
|
|
#[test]
|
|
fn parse_args_requires_required_flags() {
|
|
let argv = vec![
|
|
"replay_bundle_record".to_string(),
|
|
"--rir".to_string(),
|
|
"apnic".to_string(),
|
|
"--out-dir".to_string(),
|
|
"out".to_string(),
|
|
"--tal-path".to_string(),
|
|
"tal".to_string(),
|
|
"--ta-path".to_string(),
|
|
"ta".to_string(),
|
|
"--payload-replay-archive".to_string(),
|
|
"archive".to_string(),
|
|
"--payload-replay-locks".to_string(),
|
|
"locks.json".to_string(),
|
|
];
|
|
let args = parse_args(&argv).expect("parse");
|
|
assert_eq!(args.rir.as_deref(), Some("apnic"));
|
|
assert_eq!(args.out_dir.as_deref(), Some(Path::new("out")));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_args_rejects_missing_requireds() {
|
|
let argv = vec!["replay_bundle_record".to_string()];
|
|
let err = parse_args(&argv).unwrap_err();
|
|
assert!(err.contains("--rir is required"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn load_validation_time_reads_top_level_validation_time() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let path = dir.path().join("locks.json");
|
|
std::fs::write(&path, r#"{"validationTime":"2026-03-16T11:49:15+08:00"}"#)
|
|
.expect("write locks");
|
|
let got = load_validation_time(&path).expect("load validation time");
|
|
assert_eq!(
|
|
got.format(&Rfc3339).expect("format"),
|
|
"2026-03-16T11:49:15+08:00"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_dir_all_copies_nested_tree() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let src = dir.path().join("src");
|
|
let dst = dir.path().join("dst");
|
|
std::fs::create_dir_all(src.join("sub")).expect("mkdir");
|
|
std::fs::write(src.join("a.txt"), b"a").expect("write a");
|
|
std::fs::write(src.join("sub").join("b.txt"), b"b").expect("write b");
|
|
copy_dir_all(&src, &dst).expect("copy dir");
|
|
assert_eq!(std::fs::read(dst.join("a.txt")).expect("read a"), b"a");
|
|
assert_eq!(
|
|
std::fs::read(dst.join("sub").join("b.txt")).expect("read b"),
|
|
b"b"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn run_base_bundle_record_smoke_root_only_apnic() {
|
|
if skip_heavy_blackbox_test() {
|
|
return;
|
|
}
|
|
let tal_path = PathBuf::from("tests/fixtures/tal/apnic-rfc7730-https.tal");
|
|
let ta_path = PathBuf::from("tests/fixtures/ta/apnic-ta.cer");
|
|
let replay_archive = PathBuf::from(
|
|
"/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3/apnic/base-payload-archive",
|
|
);
|
|
let replay_locks = PathBuf::from(
|
|
"/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3/apnic/base-locks.json",
|
|
);
|
|
let delta_archive = PathBuf::from(
|
|
"/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3/apnic/payload-delta-archive",
|
|
);
|
|
let delta_locks = PathBuf::from(
|
|
"/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3/apnic/locks-delta.json",
|
|
);
|
|
let required = [
|
|
tal_path.as_path(),
|
|
ta_path.as_path(),
|
|
replay_archive.as_path(),
|
|
replay_locks.as_path(),
|
|
delta_archive.as_path(),
|
|
delta_locks.as_path(),
|
|
];
|
|
if let Some(missing) = required.iter().find(|path| !path.exists()) {
|
|
eprintln!(
|
|
"skipping replay_bundle_record smoke test; fixture missing: {}",
|
|
missing.display()
|
|
);
|
|
return;
|
|
}
|
|
|
|
let dir = tempdir().expect("tempdir");
|
|
let out_dir = dir.path().join("bundle");
|
|
let out = run(Args {
|
|
rir: Some("apnic".to_string()),
|
|
out_dir: Some(out_dir.clone()),
|
|
tal_path: Some(tal_path),
|
|
ta_path: Some(ta_path),
|
|
payload_replay_archive: Some(replay_archive),
|
|
payload_replay_locks: Some(replay_locks),
|
|
payload_delta_archive: Some(delta_archive),
|
|
payload_delta_locks: Some(delta_locks),
|
|
validation_time: None,
|
|
max_depth: Some(0),
|
|
max_instances: Some(1),
|
|
trust_anchor: Some("apnic".to_string()),
|
|
})
|
|
.expect("run bundle record");
|
|
assert_eq!(out, out_dir);
|
|
assert!(out_dir.join("bundle-manifest.json").is_file());
|
|
assert!(out_dir.join("README.md").is_file());
|
|
assert!(out_dir.join("apnic").join("bundle.json").is_file());
|
|
assert!(out_dir.join("apnic").join("tal.tal").is_file());
|
|
assert!(out_dir.join("apnic").join("ta.cer").is_file());
|
|
assert!(out_dir.join("apnic").join("base-payload-archive").is_dir());
|
|
assert!(out_dir.join("apnic").join("base-locks.json").is_file());
|
|
assert!(out_dir.join("apnic").join("base.ccr").is_file());
|
|
assert!(out_dir.join("apnic").join("base-vrps.csv").is_file());
|
|
assert!(out_dir.join("apnic").join("base-vaps.csv").is_file());
|
|
assert!(out_dir.join("apnic").join("delta.ccr").is_file());
|
|
assert!(out_dir.join("apnic").join("record-delta.csv").is_file());
|
|
assert!(
|
|
out_dir
|
|
.join("apnic")
|
|
.join("record-delta-vaps.csv")
|
|
.is_file()
|
|
);
|
|
assert!(out_dir.join("apnic").join("verification.json").is_file());
|
|
let bundle_json: serde_json::Value = serde_json::from_slice(
|
|
&std::fs::read(out_dir.join("apnic").join("bundle.json")).expect("read bundle.json"),
|
|
)
|
|
.expect("parse bundle.json");
|
|
assert_eq!(bundle_json["bundleProducer"], "ours");
|
|
assert_eq!(bundle_json["rir"], "apnic");
|
|
assert!(bundle_json.get("baseVrpCount").is_some());
|
|
assert!(bundle_json.get("baseCcrSha256").is_some());
|
|
assert!(bundle_json.get("deltaVrpCount").is_some());
|
|
assert!(bundle_json.get("deltaCcrSha256").is_some());
|
|
let base_locks_bytes = std::fs::read(out_dir.join("apnic").join("base-locks.json"))
|
|
.expect("read emitted base locks");
|
|
let expected_base_locks_sha = sha256_hex(&base_locks_bytes);
|
|
let delta_locks_json: serde_json::Value = serde_json::from_slice(
|
|
&std::fs::read(out_dir.join("apnic").join("locks-delta.json"))
|
|
.expect("read delta locks"),
|
|
)
|
|
.expect("parse delta locks");
|
|
assert_eq!(delta_locks_json["baseLocksSha256"], expected_base_locks_sha);
|
|
}
|
|
}
|