14 Commits

Author SHA1 Message Date
af1c2c7f88 20260413 增加定时周期任务与rpki-client对比,发现rpki-client rsync降权问题,改到tmp目录执行,执行两轮step发现输入基本对齐 2026-04-13 16:30:36 +08:00
e45830d79f 20260411 apply snapahot内存优化,采用流式写文件和分块处理降低运行内存需求 2026-04-11 14:45:08 +08:00
77fc2f1a41 20260410 完成五个rir 基于cir的三方回放,raw by hash 独立db,发现内存占用大,连续大rir 录制发生oom 2026-04-11 11:24:32 +08:00
e083fe4daa 20260408_2 增加CIR sequence,未验证drop analysis,遇到问题是static pool保存太慢,拖慢整体录制,待解决 2026-04-09 16:08:11 +08:00
c9ef5aaf4c 20260407 & 20260408 基于cir 三方replay对齐,并且materialize 使用hard link优化 2026-04-08 16:27:46 +08:00
34fb9657f1 20260401 live recorder 扩展到1+N个delta,完成5个RIR录制,以及三方replay,结果三方均对齐vrps和vaps 2026-04-03 16:44:27 +08:00
6edc420ce2 20260330 完成live bundle录制,远程录制,以及与routinator/rpki-client replay对比 2026-03-31 17:34:32 +08:00
cd0ba15286 20260326 完成数据库model迁移;20260327 增加一键replay脚本 2026-03-27 11:24:34 +08:00
fe8b89d829 20260324_2 增加ccr & router key support 2026-03-26 11:52:06 +08:00
d6d44669b4 20260324 完成和routinator对齐snapshot delta replay correctness一致 2026-03-24 10:10:04 +08:00
557a69cbd2 20260316迭代 增加delta replay以及multi-rir
replay 对比,五个RIR 输出vrp与routinator一致
2026-03-16 22:54:48 +08:00
73d8ebb5c1 增加 payload replay for snapshot,20260313 迭代 2026-03-15 22:49:06 +08:00
cf764c35bb 将fetch pp cache改成使用vcir结构,跑通apnic全量同步 2026-03-13 14:45:41 +08:00
e3339533b8 增加delta设计草图 2026-03-11 10:03:15 +08:00
185 changed files with 66944 additions and 3227 deletions

View File

@@ -26,10 +26,11 @@ 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"
quick-xml = "0.37.2"
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 }
tempfile = "3.16.0"
[dev-dependencies]
tempfile = "3.16.0"

56
scripts/cir/README.md Normal file
View File

@@ -0,0 +1,56 @@
# CIR Scripts
## `cir-rsync-wrapper`
一个用于 CIR 黑盒 replay 的 rsync wrapper。
### 环境变量
- `REAL_RSYNC_BIN`
- 真实 rsync 二进制路径
- 默认优先 `/usr/bin/rsync`
- `CIR_MIRROR_ROOT`
- 本地镜像树根目录
- 当命令行中出现 `rsync://...` source 时必需
### 语义
- 仅改写 `rsync://host/path` 类型参数
- 其它参数原样透传给真实 rsync
- 改写目标:
- `rsync://example.net/repo/a.roa`
- →
- `<CIR_MIRROR_ROOT>/example.net/repo/a.roa`
### 兼容目标
- Routinator `--rsync-command`
- `rpki-client -e rsync_prog`
## 其它脚本
- `run_cir_replay_ours.sh`
- `run_cir_replay_routinator.sh`
- `run_cir_replay_rpki_client.sh`
- `run_cir_replay_matrix.sh`
## `cir-local-link-sync.py`
当 `CIR_LOCAL_LINK_MODE=1` 且 wrapper 检测到 source 已经被改写为本地 mirror 路径时,
wrapper 不再调用真实 `rsync`,而是调用这个 helper 完成:
- `hardlink` 优先的本地树同步
- 失败时回退到 copy
- 支持 `--delete`
`run_cir_replay_matrix.sh` 会顺序执行:
- `ours`
- Routinator
- `rpki-client`
并汇总生成:
- `summary.json`
- `summary.md`
- `detail.md`

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
import argparse
import errno
import os
import shutil
from pathlib import Path
def _same_inode(src: Path, dst: Path) -> bool:
try:
src_stat = src.stat()
dst_stat = dst.stat()
except FileNotFoundError:
return False
return (src_stat.st_dev, src_stat.st_ino) == (dst_stat.st_dev, dst_stat.st_ino)
def _remove_path(path: Path) -> None:
if not path.exists() and not path.is_symlink():
return
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
def _prune_empty_dirs(root: Path) -> None:
if not root.exists():
return
for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
try:
path.rmdir()
except OSError:
pass
def _link_or_copy(src: Path, dst: Path) -> str:
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.exists() or dst.is_symlink():
if _same_inode(src, dst):
return "reused"
_remove_path(dst)
try:
os.link(src, dst)
return "linked"
except OSError as err:
if err.errno not in (errno.EXDEV, errno.EPERM, errno.EMLINK, errno.ENOTSUP, errno.EACCES):
raise
shutil.copy2(src, dst)
return "copied"
def _file_map(src_arg: str, dest_arg: str) -> tuple[Path, dict[str, Path]]:
src = Path(src_arg.rstrip(os.sep))
if not src.exists():
raise FileNotFoundError(src)
mapping: dict[str, Path] = {}
if src.is_dir():
copy_contents = src_arg.endswith(os.sep)
if copy_contents:
root = src
for path in root.rglob("*"):
if path.is_file():
mapping[path.relative_to(root).as_posix()] = path
else:
root = src
base = src.name
for path in root.rglob("*"):
if path.is_file():
rel = Path(base) / path.relative_to(root)
mapping[rel.as_posix()] = path
else:
dest_path = Path(dest_arg)
if dest_arg.endswith(os.sep) or dest_path.is_dir():
mapping[src.name] = src
else:
mapping[dest_path.name] = src
return Path(dest_arg), mapping
def sync_local_tree(src_arg: str, dst_arg: str, delete: bool) -> dict[str, int]:
dst_root, mapping = _file_map(src_arg, dst_arg)
dst_root.mkdir(parents=True, exist_ok=True)
expected = {dst_root / rel for rel in mapping.keys()}
deleted = 0
if delete and dst_root.exists():
for path in sorted(dst_root.rglob("*"), key=lambda p: len(p.parts), reverse=True):
if path.is_dir():
continue
if path not in expected:
_remove_path(path)
deleted += 1
_prune_empty_dirs(dst_root)
linked = 0
copied = 0
reused = 0
for rel, src in mapping.items():
dst = dst_root / rel
result = _link_or_copy(src, dst)
if result == "linked":
linked += 1
elif result == "copied":
copied += 1
else:
reused += 1
return {
"files": len(mapping),
"linked": linked,
"copied": copied,
"reused": reused,
"deleted": deleted,
}
def main() -> int:
parser = argparse.ArgumentParser(description="Sync a local CIR mirror tree using hardlinks when possible.")
parser.add_argument("--delete", action="store_true", help="Delete target files not present in source")
parser.add_argument("source")
parser.add_argument("dest")
args = parser.parse_args()
summary = sync_local_tree(args.source, args.dest, args.delete)
print(
"local-link-sync files={files} linked={linked} copied={copied} reused={reused} deleted={deleted}".format(
**summary
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

127
scripts/cir/cir-rsync-wrapper Executable file
View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
import os
import shutil
import sys
from pathlib import Path
from urllib.parse import urlparse
def real_rsync_bin() -> str:
env = os.environ.get("REAL_RSYNC_BIN")
if env:
return env
default = "/usr/bin/rsync"
if Path(default).exists():
return default
found = shutil.which("rsync")
if found:
return found
raise SystemExit("cir-rsync-wrapper: REAL_RSYNC_BIN is not set and rsync was not found")
def rewrite_arg(arg: str, mirror_root: str | None) -> str:
if not arg.startswith("rsync://"):
return arg
if not mirror_root:
raise SystemExit(
"cir-rsync-wrapper: CIR_MIRROR_ROOT is required when an rsync:// source is present"
)
parsed = urlparse(arg)
if parsed.scheme != "rsync" or not parsed.hostname:
raise SystemExit(f"cir-rsync-wrapper: invalid rsync URI: {arg}")
path = parsed.path.lstrip("/")
local = Path(mirror_root).resolve() / parsed.hostname
if path:
local = local / path
local_str = str(local)
if local.exists() and local.is_dir() and not local_str.endswith("/"):
local_str += "/"
elif arg.endswith("/") and not local_str.endswith("/"):
local_str += "/"
return local_str
def filter_args(args: list[str]) -> list[str]:
mirror_root = os.environ.get("CIR_MIRROR_ROOT")
rewritten_any = any(arg.startswith("rsync://") for arg in args)
out: list[str] = []
i = 0
while i < len(args):
arg = args[i]
if rewritten_any:
if arg == "--address":
i += 2
continue
if arg.startswith("--address="):
i += 1
continue
if arg == "--contimeout":
i += 2
continue
if arg.startswith("--contimeout="):
i += 1
continue
out.append(rewrite_arg(arg, mirror_root))
i += 1
return out
def local_link_mode_enabled() -> bool:
value = os.environ.get("CIR_LOCAL_LINK_MODE", "")
return value.lower() in {"1", "true", "yes", "on"}
def extract_source_and_dest(args: list[str]) -> tuple[str, str]:
expects_value = {
"--timeout",
"--min-size",
"--max-size",
"--include",
"--exclude",
"--compare-dest",
}
positionals: list[str] = []
i = 0
while i < len(args):
arg = args[i]
if arg in expects_value:
i += 2
continue
if any(arg.startswith(prefix + "=") for prefix in expects_value):
i += 1
continue
if arg.startswith("-"):
i += 1
continue
positionals.append(arg)
i += 1
if len(positionals) < 2:
raise SystemExit("cir-rsync-wrapper: expected source and destination arguments")
return positionals[-2], positionals[-1]
def maybe_exec_local_link_sync(args: list[str], rewritten_any: bool) -> None:
if not rewritten_any or not local_link_mode_enabled():
return
source, dest = extract_source_and_dest(args)
if source.startswith("rsync://"):
raise SystemExit("cir-rsync-wrapper: expected rewritten local source for CIR_LOCAL_LINK_MODE")
helper = Path(__file__).with_name("cir-local-link-sync.py")
cmd = [sys.executable, str(helper)]
if "--delete" in args:
cmd.append("--delete")
cmd.extend([source, dest])
os.execv(sys.executable, cmd)
def main() -> int:
args = sys.argv[1:]
rewritten_any = any(arg.startswith("rsync://") for arg in args)
rewritten = filter_args(args)
maybe_exec_local_link_sync(rewritten, rewritten_any)
os.execv(real_rsync_bin(), [real_rsync_bin(), *rewritten])
return 127
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/fetch_cir_sequence_from_remote.sh \
--ssh-target <user@host> \
--remote-path <path> \
--local-path <path>
EOF
}
SSH_TARGET=""
REMOTE_PATH=""
LOCAL_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--remote-path) REMOTE_PATH="$2"; shift 2 ;;
--local-path) LOCAL_PATH="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$SSH_TARGET" && -n "$REMOTE_PATH" && -n "$LOCAL_PATH" ]] || { usage >&2; exit 2; }
mkdir -p "$(dirname "$LOCAL_PATH")"
rsync -a "$SSH_TARGET:$REMOTE_PATH/" "$LOCAL_PATH/"
echo "done: $LOCAL_PATH"

50
scripts/cir/json_to_vaps_csv.py Executable file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
def normalize_asn(value: str | int) -> str:
text = str(value).strip().upper()
if text.startswith("AS"):
text = text[2:]
return f"AS{int(text)}"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--input", required=True, type=Path)
parser.add_argument("--csv-out", required=True, type=Path)
args = parser.parse_args()
obj = json.loads(args.input.read_text(encoding="utf-8"))
rows: list[tuple[str, str, str]] = []
for aspa in obj.get("aspas", []):
providers = sorted(
{normalize_asn(item) for item in aspa.get("providers", [])},
key=lambda s: int(s[2:]),
)
rows.append(
(
normalize_asn(aspa["customer"]),
";".join(providers),
str(aspa.get("ta", "")).strip().lower(),
)
)
rows.sort(key=lambda row: (int(row[0][2:]), row[1], row[2]))
args.csv_out.parent.mkdir(parents=True, exist_ok=True)
with args.csv_out.open("w", encoding="utf-8", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["Customer ASN", "Providers", "Trust Anchor"])
writer.writerows(rows)
print(args.csv_out)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_drop_sequence.sh \
--sequence-root <path> \
[--drop-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SEQUENCE_ROOT=""
DROP_BIN="${DROP_BIN:-$ROOT_DIR/target/release/cir_drop_report}"
while [[ $# -gt 0 ]]; do
case "$1" in
--sequence-root) SEQUENCE_ROOT="$2"; shift 2 ;;
--drop-bin) DROP_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$SEQUENCE_ROOT" ]] || { usage >&2; exit 2; }
python3 - <<'PY' "$SEQUENCE_ROOT" "$DROP_BIN"
import json
import subprocess
import sys
from pathlib import Path
sequence_root = Path(sys.argv[1]).resolve()
drop_bin = sys.argv[2]
sequence = json.loads((sequence_root / "sequence.json").read_text(encoding="utf-8"))
static_root = sequence_root / sequence["staticRoot"] if "staticRoot" in sequence else None
raw_store_db = sequence_root / sequence["rawStoreDbPath"] if "rawStoreDbPath" in sequence else None
backend_count = sum(x is not None for x in (static_root, raw_store_db))
if backend_count != 1:
raise SystemExit("sequence must set exactly one of staticRoot or rawStoreDbPath")
summaries = []
for step in sequence["steps"]:
step_id = step["stepId"]
out_dir = sequence_root / "drop" / step_id
out_dir.mkdir(parents=True, exist_ok=True)
cmd = [
drop_bin,
"--cir",
str(sequence_root / step["cirPath"]),
"--ccr",
str(sequence_root / step["ccrPath"]),
"--report-json",
str(sequence_root / step["reportPath"]),
"--json-out",
str(out_dir / "drop.json"),
"--md-out",
str(out_dir / "drop.md"),
]
if static_root is not None:
cmd.extend(["--static-root", str(static_root)])
else:
cmd.extend(["--raw-store-db", str(raw_store_db)])
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise SystemExit(
f"drop report failed for {step_id}: stdout={proc.stdout} stderr={proc.stderr}"
)
result = json.loads((out_dir / "drop.json").read_text(encoding="utf-8"))
summaries.append(
{
"stepId": step_id,
"droppedVrpCount": result["summary"]["droppedVrpCount"],
"droppedObjectCount": result["summary"]["droppedObjectCount"],
"reportPath": str(out_dir / "drop.json"),
}
)
summary = {"version": 1, "steps": summaries}
(sequence_root / "drop-summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
PY
echo "done: $SEQUENCE_ROOT"

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_full_delta.sh \
--out-dir <path> \
--tal-path <path> \
--ta-path <path> \
--cir-tal-uri <url> \
--payload-replay-archive <path> \
--payload-replay-locks <path> \
--payload-base-archive <path> \
--payload-base-locks <path> \
--payload-delta-archive <path> \
--payload-delta-locks <path> \
[--base-validation-time <rfc3339>] \
[--delta-validation-time <rfc3339>] \
[--max-depth <n>] \
[--max-instances <n>] \
[--rpki-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT_DIR=""
TAL_PATH=""
TA_PATH=""
CIR_TAL_URI=""
PAYLOAD_REPLAY_ARCHIVE=""
PAYLOAD_REPLAY_LOCKS=""
PAYLOAD_BASE_ARCHIVE=""
PAYLOAD_BASE_LOCKS=""
PAYLOAD_DELTA_ARCHIVE=""
PAYLOAD_DELTA_LOCKS=""
BASE_VALIDATION_TIME=""
DELTA_VALIDATION_TIME=""
MAX_DEPTH=0
MAX_INSTANCES=1
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
while [[ $# -gt 0 ]]; do
case "$1" in
--out-dir) OUT_DIR="$2"; shift 2 ;;
--tal-path) TAL_PATH="$2"; shift 2 ;;
--ta-path) TA_PATH="$2"; shift 2 ;;
--cir-tal-uri) CIR_TAL_URI="$2"; shift 2 ;;
--payload-replay-archive) PAYLOAD_REPLAY_ARCHIVE="$2"; shift 2 ;;
--payload-replay-locks) PAYLOAD_REPLAY_LOCKS="$2"; shift 2 ;;
--payload-base-archive) PAYLOAD_BASE_ARCHIVE="$2"; shift 2 ;;
--payload-base-locks) PAYLOAD_BASE_LOCKS="$2"; shift 2 ;;
--payload-delta-archive) PAYLOAD_DELTA_ARCHIVE="$2"; shift 2 ;;
--payload-delta-locks) PAYLOAD_DELTA_LOCKS="$2"; shift 2 ;;
--base-validation-time) BASE_VALIDATION_TIME="$2"; shift 2 ;;
--delta-validation-time) DELTA_VALIDATION_TIME="$2"; shift 2 ;;
--max-depth) MAX_DEPTH="$2"; shift 2 ;;
--max-instances) MAX_INSTANCES="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$OUT_DIR" && -n "$TAL_PATH" && -n "$TA_PATH" && -n "$CIR_TAL_URI" && -n "$PAYLOAD_REPLAY_ARCHIVE" && -n "$PAYLOAD_REPLAY_LOCKS" && -n "$PAYLOAD_BASE_ARCHIVE" && -n "$PAYLOAD_BASE_LOCKS" && -n "$PAYLOAD_DELTA_ARCHIVE" && -n "$PAYLOAD_DELTA_LOCKS" ]] || {
usage >&2
exit 2
}
if [[ ! -x "$RPKI_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin rpki
)
fi
resolve_validation_time() {
local path="$1"
python3 - <<'PY' "$path"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['validationTime'])
PY
}
if [[ -z "$BASE_VALIDATION_TIME" ]]; then
BASE_VALIDATION_TIME="$(resolve_validation_time "$PAYLOAD_REPLAY_LOCKS")"
fi
if [[ -z "$DELTA_VALIDATION_TIME" ]]; then
DELTA_VALIDATION_TIME="$(resolve_validation_time "$PAYLOAD_DELTA_LOCKS")"
fi
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/full" "$OUT_DIR/delta-001" "$OUT_DIR/static"
FULL_DB="$OUT_DIR/full/db"
DELTA_DB="$OUT_DIR/delta-001/db"
"$RPKI_BIN" \
--db "$FULL_DB" \
--tal-path "$TAL_PATH" \
--ta-path "$TA_PATH" \
--payload-replay-archive "$PAYLOAD_REPLAY_ARCHIVE" \
--payload-replay-locks "$PAYLOAD_REPLAY_LOCKS" \
--validation-time "$BASE_VALIDATION_TIME" \
--max-depth "$MAX_DEPTH" \
--max-instances "$MAX_INSTANCES" \
--ccr-out "$OUT_DIR/full/result.ccr" \
--report-json "$OUT_DIR/full/report.json" \
--cir-enable \
--cir-out "$OUT_DIR/full/input.cir" \
--cir-static-root "$OUT_DIR/static" \
--cir-tal-uri "$CIR_TAL_URI" \
>"$OUT_DIR/full/run.stdout.log" 2>"$OUT_DIR/full/run.stderr.log"
"$RPKI_BIN" \
--db "$DELTA_DB" \
--tal-path "$TAL_PATH" \
--ta-path "$TA_PATH" \
--payload-base-archive "$PAYLOAD_BASE_ARCHIVE" \
--payload-base-locks "$PAYLOAD_BASE_LOCKS" \
--payload-delta-archive "$PAYLOAD_DELTA_ARCHIVE" \
--payload-delta-locks "$PAYLOAD_DELTA_LOCKS" \
--payload-base-validation-time "$BASE_VALIDATION_TIME" \
--validation-time "$DELTA_VALIDATION_TIME" \
--max-depth "$MAX_DEPTH" \
--max-instances "$MAX_INSTANCES" \
--ccr-out "$OUT_DIR/delta-001/result.ccr" \
--report-json "$OUT_DIR/delta-001/report.json" \
--cir-enable \
--cir-out "$OUT_DIR/delta-001/input.cir" \
--cir-static-root "$OUT_DIR/static" \
--cir-tal-uri "$CIR_TAL_URI" \
>"$OUT_DIR/delta-001/run.stdout.log" 2>"$OUT_DIR/delta-001/run.stderr.log"
python3 - <<'PY' "$OUT_DIR" "$BASE_VALIDATION_TIME" "$DELTA_VALIDATION_TIME"
import json
import os
import sys
from pathlib import Path
out = Path(sys.argv[1])
base_validation_time = sys.argv[2]
delta_validation_time = sys.argv[3]
static_files = sum(1 for _ in (out / "static").rglob("*") if _.is_file())
summary = {
"version": 1,
"kind": "cir_pair",
"baseValidationTime": base_validation_time,
"deltaValidationTime": delta_validation_time,
"staticRoot": "static",
"steps": [
{
"kind": "full",
"cirPath": "full/input.cir",
"ccrPath": "full/result.ccr",
"reportPath": "full/report.json",
},
{
"kind": "delta",
"cirPath": "delta-001/input.cir",
"ccrPath": "delta-001/result.ccr",
"reportPath": "delta-001/report.json",
"previous": "full",
},
],
"staticFileCount": static_files,
}
(out / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
PY
echo "done: $OUT_DIR"

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_sequence_multi_rir_offline.sh \
[--bundle-root <path>] \
[--rir <afrinic,apnic,arin,lacnic,ripe>] \
[--delta-count <n>] \
[--full-repo] \
[--out-root <path>] \
[--rpki-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CASE_INFO="$ROOT_DIR/scripts/payload_replay/multi_rir_case_info.py"
SINGLE_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_record_sequence_offline.sh"
BUNDLE_ROOT="/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3"
RIRS="afrinic,apnic,arin,lacnic,ripe"
DELTA_COUNT=2
FULL_REPO=0
OUT_ROOT="$ROOT_DIR/target/replay/cir_sequence_multi_rir_offline_$(date -u +%Y%m%dT%H%M%SZ)"
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
while [[ $# -gt 0 ]]; do
case "$1" in
--bundle-root) BUNDLE_ROOT="$2"; shift 2 ;;
--rir) RIRS="$2"; shift 2 ;;
--delta-count) DELTA_COUNT="$2"; shift 2 ;;
--full-repo) FULL_REPO=1; shift 1 ;;
--out-root) OUT_ROOT="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
mkdir -p "$OUT_ROOT"
SUMMARY_JSON="$OUT_ROOT/summary.json"
SUMMARY_MD="$OUT_ROOT/summary.md"
IFS=',' read -r -a RIR_ITEMS <<< "$RIRS"
for rir in "${RIR_ITEMS[@]}"; do
CASE_JSON="$(python3 "$CASE_INFO" --bundle-root "$BUNDLE_ROOT" --repo-root "$ROOT_DIR" --rir "$rir")"
TAL_PATH="$(python3 - <<'PY' "$CASE_JSON"
import json,sys
print(json.loads(sys.argv[1])['tal_path'])
PY
)"
TA_PATH="$(python3 - <<'PY' "$CASE_JSON"
import json,sys
print(json.loads(sys.argv[1])['ta_path'])
PY
)"
BASE_ARCHIVE="$(python3 - <<'PY' "$CASE_JSON"
import json,sys
print(json.loads(sys.argv[1])['base_archive'])
PY
)"
BASE_LOCKS="$(python3 - <<'PY' "$CASE_JSON"
import json,sys
print(json.loads(sys.argv[1])['base_locks'])
PY
)"
DELTA_ARCHIVE="$(python3 - <<'PY' "$CASE_JSON"
import json,sys
print(json.loads(sys.argv[1])['delta_archive'])
PY
)"
DELTA_LOCKS="$(python3 - <<'PY' "$CASE_JSON"
import json,sys
print(json.loads(sys.argv[1])['delta_locks'])
PY
)"
OUT_DIR="$OUT_ROOT/$rir"
args=(
"$SINGLE_SCRIPT"
--out-dir "$OUT_DIR" \
--tal-path "$TAL_PATH" \
--ta-path "$TA_PATH" \
--cir-tal-uri "https://example.test/$rir.tal" \
--payload-replay-archive "$BASE_ARCHIVE" \
--payload-replay-locks "$BASE_LOCKS" \
--payload-base-archive "$BASE_ARCHIVE" \
--payload-base-locks "$BASE_LOCKS" \
--payload-delta-archive "$DELTA_ARCHIVE" \
--payload-delta-locks "$DELTA_LOCKS" \
--delta-count "$DELTA_COUNT" \
--rpki-bin "$RPKI_BIN"
)
if [[ "$FULL_REPO" -ne 1 ]]; then
args+=(--max-depth 0 --max-instances 1)
else
args+=(--full-repo)
fi
"${args[@]}"
done
python3 - <<'PY' "$OUT_ROOT" "$RIRS" "$SUMMARY_JSON" "$SUMMARY_MD"
import json, sys
from pathlib import Path
out_root = Path(sys.argv[1])
rirs = [item for item in sys.argv[2].split(',') if item]
summary_json = Path(sys.argv[3])
summary_md = Path(sys.argv[4])
items = []
for rir in rirs:
root = out_root / rir
seq = json.loads((root / "sequence.json").read_text(encoding="utf-8"))
summ = json.loads((root / "summary.json").read_text(encoding="utf-8"))
items.append({
"rir": rir,
"root": str(root),
"stepCount": len(seq["steps"]),
"staticFileCount": summ["staticFileCount"],
})
summary = {"version": 1, "rirs": items}
summary_json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
lines = ["# Multi-RIR Offline CIR Sequence Summary", ""]
for item in items:
lines.append(f"- `{item['rir']}`: `stepCount={item['stepCount']}` `staticFileCount={item['staticFileCount']}` `root={item['root']}`")
summary_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
echo "done: $OUT_ROOT"

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_sequence_offline.sh \
--out-dir <path> \
--tal-path <path> \
--ta-path <path> \
--cir-tal-uri <url> \
--payload-replay-archive <path> \
--payload-replay-locks <path> \
--payload-base-archive <path> \
--payload-base-locks <path> \
--payload-delta-archive <path> \
--payload-delta-locks <path> \
[--delta-count <n>] \
[--base-validation-time <rfc3339>] \
[--delta-validation-time <rfc3339>] \
[--full-repo] \
[--max-depth <n>] \
[--max-instances <n>] \
[--rpki-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT_DIR=""
TAL_PATH=""
TA_PATH=""
CIR_TAL_URI=""
PAYLOAD_REPLAY_ARCHIVE=""
PAYLOAD_REPLAY_LOCKS=""
PAYLOAD_BASE_ARCHIVE=""
PAYLOAD_BASE_LOCKS=""
PAYLOAD_DELTA_ARCHIVE=""
PAYLOAD_DELTA_LOCKS=""
BASE_VALIDATION_TIME=""
DELTA_VALIDATION_TIME=""
DELTA_COUNT=2
FULL_REPO=0
MAX_DEPTH=0
MAX_INSTANCES=1
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
while [[ $# -gt 0 ]]; do
case "$1" in
--out-dir) OUT_DIR="$2"; shift 2 ;;
--tal-path) TAL_PATH="$2"; shift 2 ;;
--ta-path) TA_PATH="$2"; shift 2 ;;
--cir-tal-uri) CIR_TAL_URI="$2"; shift 2 ;;
--payload-replay-archive) PAYLOAD_REPLAY_ARCHIVE="$2"; shift 2 ;;
--payload-replay-locks) PAYLOAD_REPLAY_LOCKS="$2"; shift 2 ;;
--payload-base-archive) PAYLOAD_BASE_ARCHIVE="$2"; shift 2 ;;
--payload-base-locks) PAYLOAD_BASE_LOCKS="$2"; shift 2 ;;
--payload-delta-archive) PAYLOAD_DELTA_ARCHIVE="$2"; shift 2 ;;
--payload-delta-locks) PAYLOAD_DELTA_LOCKS="$2"; shift 2 ;;
--base-validation-time) BASE_VALIDATION_TIME="$2"; shift 2 ;;
--delta-validation-time) DELTA_VALIDATION_TIME="$2"; shift 2 ;;
--delta-count) DELTA_COUNT="$2"; shift 2 ;;
--full-repo) FULL_REPO=1; shift 1 ;;
--max-depth) MAX_DEPTH="$2"; shift 2 ;;
--max-instances) MAX_INSTANCES="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$OUT_DIR" && -n "$TAL_PATH" && -n "$TA_PATH" && -n "$CIR_TAL_URI" && -n "$PAYLOAD_REPLAY_ARCHIVE" && -n "$PAYLOAD_REPLAY_LOCKS" && -n "$PAYLOAD_BASE_ARCHIVE" && -n "$PAYLOAD_BASE_LOCKS" && -n "$PAYLOAD_DELTA_ARCHIVE" && -n "$PAYLOAD_DELTA_LOCKS" ]] || {
usage >&2
exit 2
}
if [[ ! -x "$RPKI_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin rpki
)
fi
resolve_validation_time() {
local path="$1"
python3 - <<'PY' "$path"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['validationTime'])
PY
}
if [[ -z "$BASE_VALIDATION_TIME" ]]; then
BASE_VALIDATION_TIME="$(resolve_validation_time "$PAYLOAD_REPLAY_LOCKS")"
fi
if [[ -z "$DELTA_VALIDATION_TIME" ]]; then
DELTA_VALIDATION_TIME="$(resolve_validation_time "$PAYLOAD_DELTA_LOCKS")"
fi
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/static" "$OUT_DIR/full"
run_step() {
local kind="$1"
local step_dir="$2"
local db_dir="$3"
shift 3
mkdir -p "$step_dir"
local -a cmd=(
"$RPKI_BIN"
--db "$db_dir" \
--tal-path "$TAL_PATH" \
--ta-path "$TA_PATH" \
--ccr-out "$step_dir/result.ccr" \
--report-json "$step_dir/report.json" \
--cir-enable \
--cir-out "$step_dir/input.cir" \
--cir-static-root "$OUT_DIR/static" \
--cir-tal-uri "$CIR_TAL_URI"
)
if [[ "$FULL_REPO" -ne 1 ]]; then
cmd+=(--max-depth "$MAX_DEPTH" --max-instances "$MAX_INSTANCES")
fi
cmd+=("$@")
"${cmd[@]}" >"$step_dir/run.stdout.log" 2>"$step_dir/run.stderr.log"
}
run_step \
full \
"$OUT_DIR/full" \
"$OUT_DIR/full/db" \
--payload-replay-archive "$PAYLOAD_REPLAY_ARCHIVE" \
--payload-replay-locks "$PAYLOAD_REPLAY_LOCKS" \
--validation-time "$BASE_VALIDATION_TIME"
for idx in $(seq 1 "$DELTA_COUNT"); do
step_id="$(printf 'delta-%03d' "$idx")"
run_step \
delta \
"$OUT_DIR/$step_id" \
"$OUT_DIR/$step_id/db" \
--payload-base-archive "$PAYLOAD_BASE_ARCHIVE" \
--payload-base-locks "$PAYLOAD_BASE_LOCKS" \
--payload-delta-archive "$PAYLOAD_DELTA_ARCHIVE" \
--payload-delta-locks "$PAYLOAD_DELTA_LOCKS" \
--payload-base-validation-time "$BASE_VALIDATION_TIME" \
--validation-time "$DELTA_VALIDATION_TIME"
done
python3 - <<'PY' "$OUT_DIR" "$BASE_VALIDATION_TIME" "$DELTA_VALIDATION_TIME" "$DELTA_COUNT"
import json
import sys
from pathlib import Path
out = Path(sys.argv[1])
base_validation_time = sys.argv[2]
delta_validation_time = sys.argv[3]
delta_count = int(sys.argv[4])
steps = [
{
"stepId": "full",
"kind": "full",
"validationTime": base_validation_time,
"cirPath": "full/input.cir",
"ccrPath": "full/result.ccr",
"reportPath": "full/report.json",
"previousStepId": None,
}
]
previous = "full"
for idx in range(1, delta_count + 1):
step_id = f"delta-{idx:03d}"
steps.append(
{
"stepId": step_id,
"kind": "delta",
"validationTime": delta_validation_time,
"cirPath": f"{step_id}/input.cir",
"ccrPath": f"{step_id}/result.ccr",
"reportPath": f"{step_id}/report.json",
"previousStepId": previous,
}
)
previous = step_id
summary = {
"version": 1,
"kind": "cir_sequence_offline",
"staticRoot": "static",
"steps": steps,
}
(out / "sequence.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
(out / "summary.json").write_text(
json.dumps(
{
"version": 1,
"stepCount": len(steps),
"staticFileCount": sum(1 for p in (out / "static").rglob("*") if p.is_file()),
},
indent=2,
),
encoding="utf-8",
)
PY
echo "done: $OUT_DIR"

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_sequence_remote.sh \
--rir <name> \
--remote-root <path> \
[--ssh-target <user@host>] \
[--out-subdir <path>] \
[--delta-count <n>] \
[--sleep-secs <n>] \
[--full-repo] \
[--max-depth <n>] \
[--max-instances <n>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
RIR=""
REMOTE_ROOT=""
OUT_SUBDIR=""
DELTA_COUNT=2
SLEEP_SECS=30
FULL_REPO=0
MAX_DEPTH=0
MAX_INSTANCES=1
while [[ $# -gt 0 ]]; do
case "$1" in
--rir) RIR="$2"; shift 2 ;;
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--out-subdir) OUT_SUBDIR="$2"; shift 2 ;;
--delta-count) DELTA_COUNT="$2"; shift 2 ;;
--sleep-secs) SLEEP_SECS="$2"; shift 2 ;;
--full-repo) FULL_REPO=1; shift 1 ;;
--max-depth) MAX_DEPTH="$2"; shift 2 ;;
--max-instances) MAX_INSTANCES="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$RIR" && -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
case "$RIR" in
afrinic) TAL_REL="tests/fixtures/tal/afrinic.tal"; TA_REL="tests/fixtures/ta/afrinic-ta.cer" ;;
apnic) TAL_REL="tests/fixtures/tal/apnic-rfc7730-https.tal"; TA_REL="tests/fixtures/ta/apnic-ta.cer" ;;
arin) TAL_REL="tests/fixtures/tal/arin.tal"; TA_REL="tests/fixtures/ta/arin-ta.cer" ;;
lacnic) TAL_REL="tests/fixtures/tal/lacnic.tal"; TA_REL="tests/fixtures/ta/lacnic-ta.cer" ;;
ripe) TAL_REL="tests/fixtures/tal/ripe-ncc.tal"; TA_REL="tests/fixtures/ta/ripe-ncc-ta.cer" ;;
*) echo "unsupported rir: $RIR" >&2; exit 2 ;;
esac
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_ROOT/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT/target/release'"
rsync -a "$ROOT_DIR/target/release/rpki" "$SSH_TARGET:$REMOTE_ROOT/target/release/"
ssh "$SSH_TARGET" \
RIR="$RIR" \
REMOTE_ROOT="$REMOTE_ROOT" \
OUT_SUBDIR="$OUT_SUBDIR" \
DELTA_COUNT="$DELTA_COUNT" \
SLEEP_SECS="$SLEEP_SECS" \
FULL_REPO="$FULL_REPO" \
MAX_DEPTH="$MAX_DEPTH" \
MAX_INSTANCES="$MAX_INSTANCES" \
TAL_REL="$TAL_REL" \
TA_REL="$TA_REL" \
'bash -s' <<'EOS'
set -euo pipefail
cd "$REMOTE_ROOT"
if [[ -n "${OUT_SUBDIR}" ]]; then
OUT="${OUT_SUBDIR}"
else
OUT="target/replay/cir_sequence_remote_${RIR}_$(date -u +%Y%m%dT%H%M%SZ)"
fi
mkdir -p "$OUT"
DB="$OUT/work-db"
RAW_STORE_DB="$OUT/raw-store.db"
ROWS="$OUT/.sequence_rows.tsv"
: > "$ROWS"
write_step_timing() {
local path="$1"
local start_ms="$2"
local end_ms="$3"
local started_at="$4"
local finished_at="$5"
python3 - <<'PY' "$path" "$start_ms" "$end_ms" "$started_at" "$finished_at"
import json, sys
path, start_ms, end_ms, started_at, finished_at = sys.argv[1:]
start_ms = int(start_ms)
end_ms = int(end_ms)
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"durationMs": end_ms - start_ms,
"startedAt": started_at,
"finishedAt": finished_at,
},
fh,
indent=2,
)
PY
}
run_step() {
local step_id="$1"
local kind="$2"
local previous_step_id="$3"
shift 3
local started_at_iso started_at_ms finished_at_iso finished_at_ms prefix
started_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
started_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
prefix="${started_at_iso}-test"
local cir_out="$OUT/${prefix}.cir"
local ccr_out="$OUT/${prefix}.ccr"
local report_out="$OUT/${prefix}.report.json"
local timing_out="$OUT/${prefix}.timing.json"
local stdout_out="$OUT/${prefix}.stdout.log"
local stderr_out="$OUT/${prefix}.stderr.log"
local -a cmd=(
target/release/rpki
--db "$DB"
--raw-store-db "$RAW_STORE_DB"
--tal-path "$TAL_REL"
--ta-path "$TA_REL"
--ccr-out "$ccr_out"
--report-json "$report_out"
--cir-enable
--cir-out "$cir_out"
--cir-tal-uri "https://example.test/${RIR}.tal"
)
if [[ "$FULL_REPO" -ne 1 ]]; then
cmd+=(--max-depth "$MAX_DEPTH" --max-instances "$MAX_INSTANCES")
fi
cmd+=("$@")
env RPKI_PROGRESS_LOG=1 "${cmd[@]}" >"$stdout_out" 2>"$stderr_out"
finished_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
finished_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
write_step_timing "$timing_out" "$started_at_ms" "$finished_at_ms" "$started_at_iso" "$finished_at_iso"
local validation_time
validation_time="$(python3 - <<'PY' "$report_out"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['meta']['validation_time_rfc3339_utc'])
PY
)"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$step_id" \
"$kind" \
"$validation_time" \
"$(basename "$cir_out")" \
"$(basename "$ccr_out")" \
"$(basename "$report_out")" \
"$(basename "$timing_out")" \
"$(basename "$stdout_out")" \
"$(basename "$stderr_out")" >> "$ROWS"
}
run_step "full" "full" ""
prev="full"
for idx in $(seq 1 "$DELTA_COUNT"); do
sleep "$SLEEP_SECS"
step="$(printf 'delta-%03d' "$idx")"
run_step "$step" "delta" "$prev"
prev="$step"
done
python3 - <<'PY' "$OUT" "$ROWS" "$RIR"
import json, sys
from pathlib import Path
out = Path(sys.argv[1])
rows = Path(sys.argv[2]).read_text(encoding='utf-8').splitlines()
rir = sys.argv[3]
steps = []
for idx, row in enumerate(rows):
step_id, kind, validation_time, cir_name, ccr_name, report_name, timing_name, stdout_name, stderr_name = row.split('\t')
steps.append({
"stepId": step_id,
"kind": kind,
"validationTime": validation_time,
"cirPath": cir_name,
"ccrPath": ccr_name,
"reportPath": report_name,
"timingPath": timing_name,
"stdoutLogPath": stdout_name,
"stderrLogPath": stderr_name,
"artifactPrefix": cir_name[:-4], # strip .cir
"previousStepId": None if idx == 0 else steps[idx - 1]["stepId"],
})
(out / "sequence.json").write_text(
json.dumps({"version": 1, "rawStoreDbPath": "raw-store.db", "steps": steps}, indent=2),
encoding="utf-8",
)
summary = {
"version": 1,
"rir": rir,
"stepCount": len(steps),
"steps": [],
}
for step in steps:
timing = json.loads((out / step["timingPath"]).read_text(encoding="utf-8"))
summary["steps"].append({
"stepId": step["stepId"],
"kind": step["kind"],
"validationTime": step["validationTime"],
"artifactPrefix": step["artifactPrefix"],
**timing,
})
(out / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
PY
rm -f "$ROWS"
echo "$OUT"
EOS

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_sequence_remote_multi_rir.sh \
--remote-root <path> \
[--rir <afrinic,apnic,arin,lacnic,ripe>] \
[--ssh-target <user@host>] \
[--out-subdir-root <path>] \
[--delta-count <n>] \
[--sleep-secs <n>] \
[--full-repo] \
[--max-depth <n>] \
[--max-instances <n>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
RIRS="afrinic,apnic,arin,lacnic,ripe"
OUT_SUBDIR_ROOT=""
DELTA_COUNT=2
SLEEP_SECS=30
FULL_REPO=0
MAX_DEPTH=0
MAX_INSTANCES=1
SINGLE="$ROOT_DIR/scripts/cir/run_cir_record_sequence_remote.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--rir) RIRS="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--out-subdir-root) OUT_SUBDIR_ROOT="$2"; shift 2 ;;
--delta-count) DELTA_COUNT="$2"; shift 2 ;;
--sleep-secs) SLEEP_SECS="$2"; shift 2 ;;
--full-repo) FULL_REPO=1; shift 1 ;;
--max-depth) MAX_DEPTH="$2"; shift 2 ;;
--max-instances) MAX_INSTANCES="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
if [[ -z "$OUT_SUBDIR_ROOT" ]]; then
OUT_SUBDIR_ROOT="target/replay/cir_sequence_remote_multi_rir_$(date -u +%Y%m%dT%H%M%SZ)"
fi
IFS=',' read -r -a ITEMS <<< "$RIRS"
for rir in "${ITEMS[@]}"; do
args=(
"$SINGLE"
--rir "$rir" \
--remote-root "$REMOTE_ROOT" \
--ssh-target "$SSH_TARGET" \
--out-subdir "$OUT_SUBDIR_ROOT/$rir" \
--delta-count "$DELTA_COUNT" \
--sleep-secs "$SLEEP_SECS" \
)
if [[ "$FULL_REPO" -eq 1 ]]; then
args+=(--full-repo)
else
args+=(--max-depth "$MAX_DEPTH" --max-instances "$MAX_INSTANCES")
fi
"${args[@]}"
done
echo "$OUT_SUBDIR_ROOT"

View File

@@ -0,0 +1,118 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_sequence_ta_only_multi_rir.sh \
[--rir <afrinic,apnic,arin,lacnic,ripe>] \
[--delta-count <n>] \
[--out-root <path>] \
[--rpki-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
HELPER_BIN="${HELPER_BIN:-$ROOT_DIR/target/release/cir_ta_only_fixture}"
MATERIALIZE_BIN="${MATERIALIZE_BIN:-$ROOT_DIR/target/release/cir_materialize}"
EXTRACT_BIN="${EXTRACT_BIN:-$ROOT_DIR/target/release/cir_extract_inputs}"
WRAPPER="$ROOT_DIR/scripts/cir/cir-rsync-wrapper"
RIRS="afrinic,apnic,arin,lacnic,ripe"
DELTA_COUNT=2
OUT_ROOT="$ROOT_DIR/target/replay/cir_sequence_multi_rir_ta_only_$(date -u +%Y%m%dT%H%M%SZ)"
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
while [[ $# -gt 0 ]]; do
case "$1" in
--rir) RIRS="$2"; shift 2 ;;
--delta-count) DELTA_COUNT="$2"; shift 2 ;;
--out-root) OUT_ROOT="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
if [[ ! -x "$HELPER_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin cir_ta_only_fixture --bin rpki --bin cir_materialize --bin cir_extract_inputs
)
fi
case_paths() {
case "$1" in
afrinic) echo "tests/fixtures/tal/afrinic.tal tests/fixtures/ta/afrinic-ta.cer" ;;
apnic) echo "tests/fixtures/tal/apnic-rfc7730-https.tal tests/fixtures/ta/apnic-ta.cer" ;;
arin) echo "tests/fixtures/tal/arin.tal tests/fixtures/ta/arin-ta.cer" ;;
lacnic) echo "tests/fixtures/tal/lacnic.tal tests/fixtures/ta/lacnic-ta.cer" ;;
ripe) echo "tests/fixtures/tal/ripe-ncc.tal tests/fixtures/ta/ripe-ncc-ta.cer" ;;
*) return 1 ;;
esac
}
mkdir -p "$OUT_ROOT"
IFS=',' read -r -a ITEMS <<< "$RIRS"
for rir in "${ITEMS[@]}"; do
read -r tal_rel ta_rel < <(case_paths "$rir")
rir_root="$OUT_ROOT/$rir"
mkdir -p "$rir_root/full" "$rir_root/static"
"$HELPER_BIN" \
--tal-path "$ROOT_DIR/$tal_rel" \
--ta-path "$ROOT_DIR/$ta_rel" \
--tal-uri "https://example.test/$rir.tal" \
--validation-time "2026-04-09T00:00:00Z" \
--cir-out "$rir_root/full/input.cir" \
--static-root "$rir_root/static"
"$EXTRACT_BIN" --cir "$rir_root/full/input.cir" --tals-dir "$rir_root/.tmp/tals" --meta-json "$rir_root/.tmp/meta.json"
"$MATERIALIZE_BIN" --cir "$rir_root/full/input.cir" --static-root "$rir_root/static" --mirror-root "$rir_root/.tmp/mirror"
FIRST_TAL="$(python3 - <<'PY' "$rir_root/.tmp/meta.json"
import json,sys
print(json.load(open(sys.argv[1]))["talFiles"][0]["path"])
PY
)"
export CIR_MIRROR_ROOT="$rir_root/.tmp/mirror"
export REAL_RSYNC_BIN=/usr/bin/rsync
export CIR_LOCAL_LINK_MODE=1
"$RPKI_BIN" \
--db "$rir_root/full/db" \
--tal-path "$FIRST_TAL" \
--disable-rrdp \
--rsync-command "$WRAPPER" \
--validation-time "2026-04-09T00:00:00Z" \
--ccr-out "$rir_root/full/result.ccr" \
--report-json "$rir_root/full/report.json" >/dev/null 2>&1
for idx in $(seq 1 "$DELTA_COUNT"); do
step="$(printf 'delta-%03d' "$idx")"
mkdir -p "$rir_root/$step"
cp "$rir_root/full/input.cir" "$rir_root/$step/input.cir"
cp "$rir_root/full/result.ccr" "$rir_root/$step/result.ccr"
cp "$rir_root/full/report.json" "$rir_root/$step/report.json"
done
python3 - <<'PY' "$rir_root" "$DELTA_COUNT"
import json, sys
from pathlib import Path
root = Path(sys.argv[1]); delta_count = int(sys.argv[2])
steps = [{"stepId":"full","kind":"full","validationTime":"2026-04-09T00:00:00Z","cirPath":"full/input.cir","ccrPath":"full/result.ccr","reportPath":"full/report.json","previousStepId":None}]
prev = "full"
for i in range(1, delta_count + 1):
step = f"delta-{i:03d}"
steps.append({"stepId":step,"kind":"delta","validationTime":"2026-04-09T00:00:00Z","cirPath":f"{step}/input.cir","ccrPath":f"{step}/result.ccr","reportPath":f"{step}/report.json","previousStepId":prev})
prev = step
(root/"sequence.json").write_text(json.dumps({"version":1,"staticRoot":"static","steps":steps}, indent=2), encoding="utf-8")
(root/"summary.json").write_text(json.dumps({"version":1,"stepCount":len(steps)}, indent=2), encoding="utf-8")
PY
done
python3 - <<'PY' "$OUT_ROOT" "$RIRS"
import json, sys
from pathlib import Path
root = Path(sys.argv[1]); rirs = [x for x in sys.argv[2].split(',') if x]
items=[]
for rir in rirs:
seq=json.loads((root/rir/'sequence.json').read_text())
items.append({"rir":rir,"stepCount":len(seq['steps'])})
(root/'summary.json').write_text(json.dumps({"version":1,"rirs":items}, indent=2), encoding='utf-8')
PY
echo "done: $OUT_ROOT"

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_record_sequence_ta_only_remote_multi_rir.sh \
--remote-root <path> \
[--ssh-target <user@host>] \
[--rir <afrinic,apnic,arin,lacnic,ripe>] \
[--delta-count <n>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
RIRS="afrinic,apnic,arin,lacnic,ripe"
DELTA_COUNT=2
while [[ $# -gt 0 ]]; do
case "$1" in
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--rir) RIRS="$2"; shift 2 ;;
--delta-count) DELTA_COUNT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_ROOT/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT/target/release'"
for bin in rpki cir_ta_only_fixture cir_materialize cir_extract_inputs; do
rsync -a "$ROOT_DIR/target/release/$bin" "$SSH_TARGET:$REMOTE_ROOT/target/release/"
done
ssh "$SSH_TARGET" "bash -lc '
set -euo pipefail
cd $REMOTE_ROOT
OUT=target/replay/cir_sequence_remote_ta_only_\$(date -u +%Y%m%dT%H%M%SZ)
./scripts/cir/run_cir_record_sequence_ta_only_multi_rir.sh --rir $RIRS --delta-count $DELTA_COUNT --out-root \"\$OUT\"
echo \"\$OUT\"
'"

View File

@@ -0,0 +1,286 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_matrix.sh \
--cir <path> \
--static-root <path> \
--out-dir <path> \
--reference-ccr <path> \
--rpki-client-build-dir <path> \
[--keep-db] \
[--rpki-bin <path>] \
[--routinator-root <path>] \
[--routinator-bin <path>] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CIR=""
STATIC_ROOT=""
OUT_DIR=""
REFERENCE_CCR=""
RPKI_CLIENT_BUILD_DIR=""
KEEP_DB=0
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
ROUTINATOR_ROOT="${ROUTINATOR_ROOT:-/home/yuyr/dev/rust_playground/routinator}"
ROUTINATOR_BIN="${ROUTINATOR_BIN:-$ROUTINATOR_ROOT/target/debug/routinator}"
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
OURS_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_ours.sh"
ROUTINATOR_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_routinator.sh"
RPKI_CLIENT_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_rpki_client.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--cir) CIR="$2"; shift 2 ;;
--static-root) STATIC_ROOT="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--reference-ccr) REFERENCE_CCR="$2"; shift 2 ;;
--rpki-client-build-dir) RPKI_CLIENT_BUILD_DIR="$2"; shift 2 ;;
--keep-db) KEEP_DB=1; shift ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
--routinator-root) ROUTINATOR_ROOT="$2"; shift 2 ;;
--routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$CIR" && -n "$STATIC_ROOT" && -n "$OUT_DIR" && -n "$REFERENCE_CCR" && -n "$RPKI_CLIENT_BUILD_DIR" ]] || {
usage >&2
exit 2
}
mkdir -p "$OUT_DIR"
run_with_timing() {
local summary_path="$1"
local timing_path="$2"
shift 2
local start end status
start="$(python3 - <<'PY'
import time
print(time.perf_counter_ns())
PY
)"
if "$@"; then
status=0
else
status=$?
fi
end="$(python3 - <<'PY'
import time
print(time.perf_counter_ns())
PY
)"
python3 - <<'PY' "$summary_path" "$timing_path" "$status" "$start" "$end"
import json, sys
summary_path, timing_path, status, start, end = sys.argv[1:]
duration_ms = max(0, (int(end) - int(start)) // 1_000_000)
data = {"exitCode": int(status), "durationMs": duration_ms}
try:
with open(summary_path, "r", encoding="utf-8") as f:
data["compare"] = json.load(f)
except FileNotFoundError:
data["compare"] = None
with open(timing_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
PY
return "$status"
}
OURS_OUT="$OUT_DIR/ours"
ROUTINATOR_OUT="$OUT_DIR/routinator"
RPKI_CLIENT_OUT="$OUT_DIR/rpki-client"
mkdir -p "$OURS_OUT" "$ROUTINATOR_OUT" "$RPKI_CLIENT_OUT"
ours_cmd=(
"$OURS_SCRIPT"
--cir "$CIR"
--static-root "$STATIC_ROOT"
--out-dir "$OURS_OUT"
--reference-ccr "$REFERENCE_CCR"
--rpki-bin "$RPKI_BIN"
--real-rsync-bin "$REAL_RSYNC_BIN"
)
routinator_cmd=(
"$ROUTINATOR_SCRIPT"
--cir "$CIR"
--static-root "$STATIC_ROOT"
--out-dir "$ROUTINATOR_OUT"
--reference-ccr "$REFERENCE_CCR"
--routinator-root "$ROUTINATOR_ROOT"
--routinator-bin "$ROUTINATOR_BIN"
--real-rsync-bin "$REAL_RSYNC_BIN"
)
rpki_client_cmd=(
"$RPKI_CLIENT_SCRIPT"
--cir "$CIR"
--static-root "$STATIC_ROOT"
--out-dir "$RPKI_CLIENT_OUT"
--reference-ccr "$REFERENCE_CCR"
--build-dir "$RPKI_CLIENT_BUILD_DIR"
--real-rsync-bin "$REAL_RSYNC_BIN"
)
if [[ "$KEEP_DB" -eq 1 ]]; then
ours_cmd+=(--keep-db)
routinator_cmd+=(--keep-db)
rpki_client_cmd+=(--keep-db)
fi
ours_status=0
routinator_status=0
rpki_client_status=0
if run_with_timing "$OURS_OUT/compare-summary.json" "$OURS_OUT/timing.json" "${ours_cmd[@]}"; then
:
else
ours_status=$?
fi
if run_with_timing "$ROUTINATOR_OUT/compare-summary.json" "$ROUTINATOR_OUT/timing.json" "${routinator_cmd[@]}"; then
:
else
routinator_status=$?
fi
if run_with_timing "$RPKI_CLIENT_OUT/compare-summary.json" "$RPKI_CLIENT_OUT/timing.json" "${rpki_client_cmd[@]}"; then
:
else
rpki_client_status=$?
fi
SUMMARY_JSON="$OUT_DIR/summary.json"
SUMMARY_MD="$OUT_DIR/summary.md"
DETAIL_MD="$OUT_DIR/detail.md"
python3 - <<'PY' \
"$CIR" \
"$STATIC_ROOT" \
"$REFERENCE_CCR" \
"$OURS_OUT" \
"$ROUTINATOR_OUT" \
"$RPKI_CLIENT_OUT" \
"$SUMMARY_JSON" \
"$SUMMARY_MD" \
"$DETAIL_MD"
import json
import sys
from pathlib import Path
cir_path, static_root, reference_ccr, ours_out, routinator_out, rpki_client_out, summary_json, summary_md, detail_md = sys.argv[1:]
participants = []
all_match = True
for name, out_dir in [
("ours", ours_out),
("routinator", routinator_out),
("rpki-client", rpki_client_out),
]:
out = Path(out_dir)
timing = json.loads((out / "timing.json").read_text(encoding="utf-8"))
compare = timing.get("compare") or {}
vrps = compare.get("vrps") or {}
vaps = compare.get("vaps") or {}
participant = {
"name": name,
"outDir": str(out),
"tmpRoot": str(out / ".tmp"),
"mirrorPath": str(out / ".tmp" / "mirror"),
"timingPath": str(out / "timing.json"),
"summaryPath": str(out / "compare-summary.json"),
"exitCode": timing["exitCode"],
"durationMs": timing["durationMs"],
"vrps": vrps,
"vaps": vaps,
"match": bool(vrps.get("match")) and bool(vaps.get("match")) and timing["exitCode"] == 0,
"logPaths": [str(path) for path in sorted(out.glob("*.log"))],
}
participants.append(participant)
all_match = all_match and participant["match"]
summary = {
"cirPath": cir_path,
"staticRoot": static_root,
"referenceCcr": reference_ccr,
"participants": participants,
"allMatch": all_match,
}
Path(summary_json).write_text(json.dumps(summary, indent=2), encoding="utf-8")
lines = [
"# CIR Replay Matrix Summary",
"",
f"- `cir`: `{cir_path}`",
f"- `static_root`: `{static_root}`",
f"- `reference_ccr`: `{reference_ccr}`",
f"- `all_match`: `{all_match}`",
"",
"| Participant | Exit | Duration (ms) | VRP actual/ref | VRP match | VAP actual/ref | VAP match | Log |",
"| --- | ---: | ---: | --- | --- | --- | --- | --- |",
]
for participant in participants:
vrps = participant["vrps"] or {}
vaps = participant["vaps"] or {}
log_path = participant["logPaths"][0] if participant["logPaths"] else ""
lines.append(
"| {name} | {exit_code} | {duration_ms} | {vrp_actual}/{vrp_ref} | {vrp_match} | {vap_actual}/{vap_ref} | {vap_match} | `{log_path}` |".format(
name=participant["name"],
exit_code=participant["exitCode"],
duration_ms=participant["durationMs"],
vrp_actual=vrps.get("actual", "-"),
vrp_ref=vrps.get("reference", "-"),
vrp_match=vrps.get("match", False),
vap_actual=vaps.get("actual", "-"),
vap_ref=vaps.get("reference", "-"),
vap_match=vaps.get("match", False),
log_path=log_path,
)
)
Path(summary_md).write_text("\n".join(lines) + "\n", encoding="utf-8")
detail_lines = [
"# CIR Replay Matrix Detail",
"",
]
for participant in participants:
vrps = participant["vrps"] or {}
vaps = participant["vaps"] or {}
detail_lines.extend([
f"## {participant['name']}",
f"- `exit_code`: `{participant['exitCode']}`",
f"- `duration_ms`: `{participant['durationMs']}`",
f"- `out_dir`: `{participant['outDir']}`",
f"- `tmp_root`: `{participant['tmpRoot']}`",
f"- `mirror_path`: `{participant['mirrorPath']}`",
f"- `summary_path`: `{participant['summaryPath']}`",
f"- `timing_path`: `{participant['timingPath']}`",
f"- `log_paths`: `{', '.join(participant['logPaths'])}`",
f"- `vrps`: `actual={vrps.get('actual', '-')}` `reference={vrps.get('reference', '-')}` `match={vrps.get('match', False)}`",
f"- `vaps`: `actual={vaps.get('actual', '-')}` `reference={vaps.get('reference', '-')}` `match={vaps.get('match', False)}`",
f"- `vrps.only_in_actual`: `{vrps.get('only_in_actual', [])}`",
f"- `vrps.only_in_reference`: `{vrps.get('only_in_reference', [])}`",
f"- `vaps.only_in_actual`: `{vaps.get('only_in_actual', [])}`",
f"- `vaps.only_in_reference`: `{vaps.get('only_in_reference', [])}`",
"",
])
Path(detail_md).write_text("\n".join(detail_lines), encoding="utf-8")
PY
if [[ "$ours_status" -ne 0 || "$routinator_status" -ne 0 || "$rpki_client_status" -ne 0 ]]; then
exit 1
fi
all_match="$(python3 - <<'PY' "$SUMMARY_JSON"
import json,sys
print("true" if json.load(open(sys.argv[1]))["allMatch"] else "false")
PY
)"
if [[ "$all_match" != "true" ]]; then
exit 1
fi
echo "done: $OUT_DIR"

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_ours.sh \
--cir <path> \
[--static-root <path> | --raw-store-db <path>] \
--out-dir <path> \
--reference-ccr <path> \
[--keep-db] \
[--rpki-bin <path>] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CIR=""
STATIC_ROOT=""
POOL_DB=""
RAW_STORE_DB=""
OUT_DIR=""
REFERENCE_CCR=""
KEEP_DB=0
RPKI_BIN="$ROOT_DIR/target/release/rpki"
CIR_MATERIALIZE_BIN="$ROOT_DIR/target/release/cir_materialize"
CIR_EXTRACT_INPUTS_BIN="$ROOT_DIR/target/release/cir_extract_inputs"
CCR_TO_COMPARE_VIEWS_BIN="$ROOT_DIR/target/release/ccr_to_compare_views"
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
WRAPPER="$ROOT_DIR/scripts/cir/cir-rsync-wrapper"
while [[ $# -gt 0 ]]; do
case "$1" in
--cir) CIR="$2"; shift 2 ;;
--static-root) STATIC_ROOT="$2"; shift 2 ;;
--raw-store-db) RAW_STORE_DB="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--reference-ccr) REFERENCE_CCR="$2"; shift 2 ;;
--keep-db) KEEP_DB=1; shift ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$CIR" && -n "$OUT_DIR" && -n "$REFERENCE_CCR" ]] || {
usage >&2
exit 2
}
backend_count=0
[[ -n "$STATIC_ROOT" ]] && backend_count=$((backend_count+1))
[[ -n "$RAW_STORE_DB" ]] && backend_count=$((backend_count+1))
[[ "$backend_count" -eq 1 ]] || { usage >&2; exit 2; }
mkdir -p "$OUT_DIR"
if [[ ! -x "$RPKI_BIN" || ! -x "$CIR_MATERIALIZE_BIN" || ! -x "$CIR_EXTRACT_INPUTS_BIN" || ! -x "$CCR_TO_COMPARE_VIEWS_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin rpki --bin cir_materialize --bin cir_extract_inputs --bin ccr_to_compare_views
)
fi
TMP_ROOT="$OUT_DIR/.tmp"
TALS_DIR="$TMP_ROOT/tals"
META_JSON="$TMP_ROOT/meta.json"
MIRROR_ROOT="$TMP_ROOT/mirror"
DB_DIR="$TMP_ROOT/work-db"
ACTUAL_CCR="$OUT_DIR/actual.ccr"
ACTUAL_REPORT="$OUT_DIR/report.json"
ACTUAL_VRPS="$OUT_DIR/actual-vrps.csv"
ACTUAL_VAPS="$OUT_DIR/actual-vaps.csv"
REF_VRPS="$OUT_DIR/reference-vrps.csv"
REF_VAPS="$OUT_DIR/reference-vaps.csv"
COMPARE_JSON="$OUT_DIR/compare-summary.json"
RUN_LOG="$OUT_DIR/run.log"
rm -rf "$TMP_ROOT"
mkdir -p "$TMP_ROOT"
"$CIR_EXTRACT_INPUTS_BIN" --cir "$CIR" --tals-dir "$TALS_DIR" --meta-json "$META_JSON"
materialize_cmd=("$CIR_MATERIALIZE_BIN" --cir "$CIR" --mirror-root "$MIRROR_ROOT")
if [[ -n "$STATIC_ROOT" ]]; then
materialize_cmd+=(--static-root "$STATIC_ROOT")
else
materialize_cmd+=(--raw-store-db "$RAW_STORE_DB")
fi
if [[ "$KEEP_DB" -eq 1 ]]; then
materialize_cmd+=(--keep-db)
fi
"${materialize_cmd[@]}"
VALIDATION_TIME="$(python3 - <<'PY' "$META_JSON"
import json,sys
print(json.load(open(sys.argv[1]))["validationTime"])
PY
)"
FIRST_TAL="$(python3 - <<'PY' "$META_JSON"
import json,sys
print(json.load(open(sys.argv[1]))["talFiles"][0]["path"])
PY
)"
export CIR_MIRROR_ROOT="$(python3 - <<'PY' "$MIRROR_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
export REAL_RSYNC_BIN="$REAL_RSYNC_BIN"
export CIR_LOCAL_LINK_MODE=1
"$RPKI_BIN" \
--db "$DB_DIR" \
--tal-path "$FIRST_TAL" \
--disable-rrdp \
--rsync-command "$WRAPPER" \
--validation-time "$VALIDATION_TIME" \
--ccr-out "$ACTUAL_CCR" \
--report-json "$ACTUAL_REPORT" \
>"$RUN_LOG" 2>&1
"$CCR_TO_COMPARE_VIEWS_BIN" --ccr "$ACTUAL_CCR" --vrps-out "$ACTUAL_VRPS" --vaps-out "$ACTUAL_VAPS" --trust-anchor unknown
"$CCR_TO_COMPARE_VIEWS_BIN" --ccr "$REFERENCE_CCR" --vrps-out "$REF_VRPS" --vaps-out "$REF_VAPS" --trust-anchor unknown
python3 - <<'PY' "$ACTUAL_VRPS" "$REF_VRPS" "$ACTUAL_VAPS" "$REF_VAPS" "$COMPARE_JSON"
import csv, json, sys
def rows(path):
with open(path, newline="") as f:
return list(csv.reader(f))[1:]
actual_vrps = {tuple(r) for r in rows(sys.argv[1])}
ref_vrps = {tuple(r) for r in rows(sys.argv[2])}
actual_vaps = {tuple(r) for r in rows(sys.argv[3])}
ref_vaps = {tuple(r) for r in rows(sys.argv[4])}
summary = {
"vrps": {
"actual": len(actual_vrps),
"reference": len(ref_vrps),
"only_in_actual": sorted(actual_vrps - ref_vrps)[:20],
"only_in_reference": sorted(ref_vrps - actual_vrps)[:20],
"match": actual_vrps == ref_vrps,
},
"vaps": {
"actual": len(actual_vaps),
"reference": len(ref_vaps),
"only_in_actual": sorted(actual_vaps - ref_vaps)[:20],
"only_in_reference": sorted(ref_vaps - actual_vaps)[:20],
"match": actual_vaps == ref_vaps,
}
}
with open(sys.argv[5], "w") as f:
json.dump(summary, f, indent=2)
PY
if [[ "$KEEP_DB" -ne 1 ]]; then
rm -rf "$TMP_ROOT"
fi
echo "done: $OUT_DIR"

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_routinator.sh \
--cir <path> \
[--static-root <path> | --raw-store-db <path>] \
--out-dir <path> \
--reference-ccr <path> \
[--keep-db] \
[--routinator-root <path>] \
[--routinator-bin <path>] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RPKI_DEV_ROOT="${RPKI_DEV_ROOT:-$ROOT_DIR}"
CIR=""
STATIC_ROOT=""
POOL_DB=""
RAW_STORE_DB=""
OUT_DIR=""
REFERENCE_CCR=""
KEEP_DB=0
ROUTINATOR_ROOT="${ROUTINATOR_ROOT:-/home/yuyr/dev/rust_playground/routinator}"
ROUTINATOR_BIN="${ROUTINATOR_BIN:-$ROUTINATOR_ROOT/target/debug/routinator}"
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
CIR_MATERIALIZE_BIN="$ROOT_DIR/target/release/cir_materialize"
CIR_EXTRACT_INPUTS_BIN="$ROOT_DIR/target/release/cir_extract_inputs"
CCR_TO_COMPARE_VIEWS_BIN="$ROOT_DIR/target/release/ccr_to_compare_views"
WRAPPER="$ROOT_DIR/scripts/cir/cir-rsync-wrapper"
JSON_TO_VAPS="$ROOT_DIR/scripts/cir/json_to_vaps_csv.py"
FAKETIME_LIB="${FAKETIME_LIB:-$ROOT_DIR/target/tools/faketime_pkg/extracted/libfaketime/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1}"
while [[ $# -gt 0 ]]; do
case "$1" in
--cir) CIR="$2"; shift 2 ;;
--static-root) STATIC_ROOT="$2"; shift 2 ;;
--raw-store-db) RAW_STORE_DB="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--reference-ccr) REFERENCE_CCR="$2"; shift 2 ;;
--keep-db) KEEP_DB=1; shift ;;
--routinator-root) ROUTINATOR_ROOT="$2"; shift 2 ;;
--routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$CIR" && -n "$OUT_DIR" && -n "$REFERENCE_CCR" ]] || {
usage >&2
exit 2
}
backend_count=0
[[ -n "$STATIC_ROOT" ]] && backend_count=$((backend_count+1))
[[ -n "$RAW_STORE_DB" ]] && backend_count=$((backend_count+1))
[[ "$backend_count" -eq 1 ]] || { usage >&2; exit 2; }
mkdir -p "$OUT_DIR"
if [[ ! -x "$CIR_MATERIALIZE_BIN" || ! -x "$CIR_EXTRACT_INPUTS_BIN" || ! -x "$CCR_TO_COMPARE_VIEWS_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin cir_materialize --bin cir_extract_inputs --bin ccr_to_compare_views
)
fi
TMP_ROOT="$OUT_DIR/.tmp"
TALS_DIR="$TMP_ROOT/tals"
META_JSON="$TMP_ROOT/meta.json"
MIRROR_ROOT="$TMP_ROOT/mirror"
WORK_REPO="$TMP_ROOT/repository"
RUN_LOG="$OUT_DIR/routinator.log"
ACTUAL_VRPS="$OUT_DIR/actual-vrps.csv"
ACTUAL_VAPS_JSON="$OUT_DIR/actual-vaps.json"
ACTUAL_VAPS="$OUT_DIR/actual-vaps.csv"
REF_VRPS="$OUT_DIR/reference-vrps.csv"
REF_VAPS="$OUT_DIR/reference-vaps.csv"
SUMMARY_JSON="$OUT_DIR/compare-summary.json"
rm -rf "$TMP_ROOT"
mkdir -p "$TMP_ROOT"
"$CIR_EXTRACT_INPUTS_BIN" --cir "$CIR" --tals-dir "$TALS_DIR" --meta-json "$META_JSON"
python3 - <<'PY' "$TALS_DIR"
from pathlib import Path
import sys
for tal in Path(sys.argv[1]).glob("*.tal"):
lines = tal.read_text(encoding="utf-8").splitlines()
rsync_uris = [line for line in lines if line.startswith("rsync://")]
base64_lines = []
seen_sep = False
for line in lines:
if seen_sep:
if line.strip():
base64_lines.append(line)
elif line.strip() == "":
seen_sep = True
tal.write_text("\n".join(rsync_uris) + "\n\n" + "\n".join(base64_lines) + "\n", encoding="utf-8")
PY
materialize_cmd=("$CIR_MATERIALIZE_BIN" --cir "$CIR" --mirror-root "$MIRROR_ROOT")
if [[ -n "$STATIC_ROOT" ]]; then
materialize_cmd+=(--static-root "$STATIC_ROOT")
else
materialize_cmd+=(--raw-store-db "$RAW_STORE_DB")
fi
if [[ "$KEEP_DB" -eq 1 ]]; then
materialize_cmd+=(--keep-db)
fi
"${materialize_cmd[@]}"
VALIDATION_TIME="$(python3 - <<'PY' "$META_JSON"
import json,sys
print(json.load(open(sys.argv[1]))["validationTime"])
PY
)"
FIRST_TAL="$(python3 - <<'PY' "$META_JSON"
import json,sys
print(json.load(open(sys.argv[1]))["talFiles"][0]["path"])
PY
)"
TA_NAME="$(basename "$FIRST_TAL" .tal)"
FAKE_EPOCH="$(python3 - <<'PY' "$VALIDATION_TIME"
from datetime import datetime, timezone
import sys
dt = datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")).astimezone(timezone.utc)
print(int(dt.timestamp()))
PY
)"
export CIR_MIRROR_ROOT="$(python3 - <<'PY' "$MIRROR_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
export REAL_RSYNC_BIN="$REAL_RSYNC_BIN"
export CIR_LOCAL_LINK_MODE=1
env \
LD_PRELOAD="$FAKETIME_LIB" \
FAKETIME_FMT=%s \
FAKETIME="$FAKE_EPOCH" \
FAKETIME_DONT_FAKE_MONOTONIC=1 \
"$ROUTINATOR_BIN" \
--repository-dir "$WORK_REPO" \
--disable-rrdp \
--rsync-command "$WRAPPER" \
--no-rir-tals \
--extra-tals-dir "$TALS_DIR" \
--enable-aspa \
update --complete >"$RUN_LOG" 2>&1 || true
env \
LD_PRELOAD="$FAKETIME_LIB" \
FAKETIME_FMT=%s \
FAKETIME="$FAKE_EPOCH" \
FAKETIME_DONT_FAKE_MONOTONIC=1 \
"$ROUTINATOR_BIN" \
--repository-dir "$WORK_REPO" \
--disable-rrdp \
--rsync-command "$WRAPPER" \
--no-rir-tals \
--extra-tals-dir "$TALS_DIR" \
--enable-aspa \
vrps --noupdate -o "$ACTUAL_VRPS" >>"$RUN_LOG" 2>&1
env \
LD_PRELOAD="$FAKETIME_LIB" \
FAKETIME_FMT=%s \
FAKETIME="$FAKE_EPOCH" \
FAKETIME_DONT_FAKE_MONOTONIC=1 \
"$ROUTINATOR_BIN" \
--repository-dir "$WORK_REPO" \
--disable-rrdp \
--rsync-command "$WRAPPER" \
--no-rir-tals \
--extra-tals-dir "$TALS_DIR" \
--enable-aspa \
vrps --noupdate --format json -o "$ACTUAL_VAPS_JSON" >>"$RUN_LOG" 2>&1
python3 "$JSON_TO_VAPS" --input "$ACTUAL_VAPS_JSON" --csv-out "$ACTUAL_VAPS"
"$CCR_TO_COMPARE_VIEWS_BIN" --ccr "$REFERENCE_CCR" --vrps-out "$REF_VRPS" --vaps-out "$REF_VAPS" --trust-anchor "$TA_NAME"
python3 - <<'PY' "$ACTUAL_VRPS" "$REF_VRPS" "$ACTUAL_VAPS" "$REF_VAPS" "$SUMMARY_JSON"
import csv, json, sys
def rows(path):
with open(path, newline="") as f:
return list(csv.reader(f))[1:]
actual_vrps = {tuple(r) for r in rows(sys.argv[1])}
ref_vrps = {tuple(r) for r in rows(sys.argv[2])}
actual_vaps = {tuple(r) for r in rows(sys.argv[3])}
ref_vaps = {tuple(r) for r in rows(sys.argv[4])}
summary = {
"vrps": {
"actual": len(actual_vrps),
"reference": len(ref_vrps),
"match": actual_vrps == ref_vrps,
"only_in_actual": sorted(actual_vrps - ref_vrps)[:20],
"only_in_reference": sorted(ref_vrps - actual_vrps)[:20],
},
"vaps": {
"actual": len(actual_vaps),
"reference": len(ref_vaps),
"match": actual_vaps == ref_vaps,
"only_in_actual": sorted(actual_vaps - ref_vaps)[:20],
"only_in_reference": sorted(ref_vaps - actual_vaps)[:20],
}
}
with open(sys.argv[5], "w") as f:
json.dump(summary, f, indent=2)
PY
if [[ "$KEEP_DB" -ne 1 ]]; then
rm -rf "$TMP_ROOT"
fi
echo "done: $OUT_DIR"

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_rpki_client.sh \
--cir <path> \
[--static-root <path> | --raw-store-db <path>] \
--out-dir <path> \
--reference-ccr <path> \
--build-dir <path> \
[--keep-db] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CIR=""
STATIC_ROOT=""
POOL_DB=""
RAW_STORE_DB=""
OUT_DIR=""
REFERENCE_CCR=""
BUILD_DIR=""
KEEP_DB=0
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
CIR_MATERIALIZE_BIN="$ROOT_DIR/target/release/cir_materialize"
CIR_EXTRACT_INPUTS_BIN="$ROOT_DIR/target/release/cir_extract_inputs"
CCR_TO_COMPARE_VIEWS_BIN="$ROOT_DIR/target/release/ccr_to_compare_views"
WRAPPER="$ROOT_DIR/scripts/cir/cir-rsync-wrapper"
while [[ $# -gt 0 ]]; do
case "$1" in
--cir) CIR="$2"; shift 2 ;;
--static-root) STATIC_ROOT="$2"; shift 2 ;;
--raw-store-db) RAW_STORE_DB="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--reference-ccr) REFERENCE_CCR="$2"; shift 2 ;;
--build-dir) BUILD_DIR="$2"; shift 2 ;;
--keep-db) KEEP_DB=1; shift ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$CIR" && -n "$OUT_DIR" && -n "$REFERENCE_CCR" && -n "$BUILD_DIR" ]] || {
usage >&2
exit 2
}
backend_count=0
[[ -n "$STATIC_ROOT" ]] && backend_count=$((backend_count+1))
[[ -n "$RAW_STORE_DB" ]] && backend_count=$((backend_count+1))
[[ "$backend_count" -eq 1 ]] || { usage >&2; exit 2; }
mkdir -p "$OUT_DIR"
if [[ ! -x "$CIR_MATERIALIZE_BIN" || ! -x "$CIR_EXTRACT_INPUTS_BIN" || ! -x "$CCR_TO_COMPARE_VIEWS_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin cir_materialize --bin cir_extract_inputs --bin ccr_to_compare_views
)
fi
TMP_ROOT="$OUT_DIR/.tmp"
TALS_DIR="$TMP_ROOT/tals"
META_JSON="$TMP_ROOT/meta.json"
MIRROR_ROOT="$TMP_ROOT/mirror"
CACHE_DIR="$TMP_ROOT/cache"
OUT_CCR_DIR="$TMP_ROOT/out"
RUN_LOG="$OUT_DIR/rpki-client.log"
ACTUAL_VRPS="$OUT_DIR/actual-vrps.csv"
ACTUAL_VAPS="$OUT_DIR/actual-vaps.csv"
ACTUAL_VAPS_META="$OUT_DIR/actual-vaps-meta.json"
ACTUAL_VRPS_META="$OUT_DIR/actual-vrps-meta.json"
REF_VRPS="$OUT_DIR/reference-vrps.csv"
REF_VAPS="$OUT_DIR/reference-vaps.csv"
SUMMARY_JSON="$OUT_DIR/compare-summary.json"
rm -rf "$TMP_ROOT"
mkdir -p "$TMP_ROOT"
"$CIR_EXTRACT_INPUTS_BIN" --cir "$CIR" --tals-dir "$TALS_DIR" --meta-json "$META_JSON"
python3 - <<'PY' "$TALS_DIR"
from pathlib import Path
import sys
for tal in Path(sys.argv[1]).glob("*.tal"):
lines = tal.read_text(encoding="utf-8").splitlines()
rsync_uris = [line for line in lines if line.startswith("rsync://")]
base64_lines = []
seen_sep = False
for line in lines:
if seen_sep:
if line.strip():
base64_lines.append(line)
elif line.strip() == "":
seen_sep = True
tal.write_text("\n".join(rsync_uris) + "\n\n" + "\n".join(base64_lines) + "\n", encoding="utf-8")
PY
materialize_cmd=("$CIR_MATERIALIZE_BIN" --cir "$CIR" --mirror-root "$MIRROR_ROOT")
if [[ -n "$STATIC_ROOT" ]]; then
materialize_cmd+=(--static-root "$STATIC_ROOT")
else
materialize_cmd+=(--raw-store-db "$RAW_STORE_DB")
fi
if [[ "$KEEP_DB" -eq 1 ]]; then
materialize_cmd+=(--keep-db)
fi
"${materialize_cmd[@]}"
VALIDATION_EPOCH="$(python3 - <<'PY' "$META_JSON"
from datetime import datetime, timezone
import json, sys
vt = json.load(open(sys.argv[1]))["validationTime"]
dt = datetime.fromisoformat(vt.replace("Z", "+00:00")).astimezone(timezone.utc)
print(int(dt.timestamp()))
PY
)"
FIRST_TAL="$(python3 - <<'PY' "$META_JSON"
import json,sys
print(json.load(open(sys.argv[1]))["talFiles"][0]["path"])
PY
)"
TA_NAME="$(basename "$FIRST_TAL" .tal)"
export CIR_MIRROR_ROOT="$(python3 - <<'PY' "$MIRROR_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
export REAL_RSYNC_BIN="$REAL_RSYNC_BIN"
export CIR_LOCAL_LINK_MODE=1
mkdir -p "$CACHE_DIR" "$OUT_CCR_DIR"
"$BUILD_DIR/src/rpki-client" \
-R \
-e "$WRAPPER" \
-P "$VALIDATION_EPOCH" \
-t "$FIRST_TAL" \
-d "$CACHE_DIR" \
"$OUT_CCR_DIR" >"$RUN_LOG" 2>&1
"$CCR_TO_COMPARE_VIEWS_BIN" \
--ccr "$OUT_CCR_DIR/rpki.ccr" \
--vrps-out "$ACTUAL_VRPS" \
--vaps-out "$ACTUAL_VAPS" \
--trust-anchor "$TA_NAME"
python3 - <<'PY' "$ACTUAL_VRPS" "$ACTUAL_VAPS" "$ACTUAL_VRPS_META" "$ACTUAL_VAPS_META"
import csv, json, sys
def count_rows(path):
with open(path, newline="") as f:
rows = list(csv.reader(f))
return max(len(rows) - 1, 0)
json.dump({"count": count_rows(sys.argv[1])}, open(sys.argv[3], "w"), indent=2)
json.dump({"count": count_rows(sys.argv[2])}, open(sys.argv[4], "w"), indent=2)
PY
"$CCR_TO_COMPARE_VIEWS_BIN" --ccr "$REFERENCE_CCR" --vrps-out "$REF_VRPS" --vaps-out "$REF_VAPS" --trust-anchor "$TA_NAME"
python3 - <<'PY' "$ACTUAL_VRPS" "$REF_VRPS" "$ACTUAL_VAPS" "$REF_VAPS" "$SUMMARY_JSON"
import csv, json, sys
def rows(path):
with open(path, newline="") as f:
return list(csv.reader(f))[1:]
actual_vrps = {tuple(r) for r in rows(sys.argv[1])}
ref_vrps = {tuple(r) for r in rows(sys.argv[2])}
actual_vaps = {tuple(r) for r in rows(sys.argv[3])}
ref_vaps = {tuple(r) for r in rows(sys.argv[4])}
summary = {
"vrps": {
"actual": len(actual_vrps),
"reference": len(ref_vrps),
"match": actual_vrps == ref_vrps,
"only_in_actual": sorted(actual_vrps - ref_vrps)[:20],
"only_in_reference": sorted(ref_vrps - actual_vrps)[:20],
},
"vaps": {
"actual": len(actual_vaps),
"reference": len(ref_vaps),
"match": actual_vaps == ref_vaps,
"only_in_actual": sorted(actual_vaps - ref_vaps)[:20],
"only_in_reference": sorted(ref_vaps - actual_vaps)[:20],
}
}
with open(sys.argv[5], "w") as f:
json.dump(summary, f, indent=2)
PY
if [[ "$KEEP_DB" -ne 1 ]]; then
rm -rf "$TMP_ROOT"
fi
echo "done: $OUT_DIR"

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_sequence_ours.sh \
--sequence-root <path> \
[--rpki-bin <path>] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SEQUENCE_ROOT=""
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
STEP_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_ours.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--sequence-root) SEQUENCE_ROOT="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$SEQUENCE_ROOT" ]] || { usage >&2; exit 2; }
SEQUENCE_ROOT="$(python3 - <<'PY' "$SEQUENCE_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
SUMMARY_JSON="$SEQUENCE_ROOT/sequence-summary.json"
SUMMARY_MD="$SEQUENCE_ROOT/sequence-summary.md"
DETAIL_JSON="$SEQUENCE_ROOT/sequence-detail.json"
python3 - <<'PY' "$SEQUENCE_ROOT" "$SUMMARY_JSON" "$SUMMARY_MD" "$DETAIL_JSON" "$STEP_SCRIPT" "$RPKI_BIN" "$REAL_RSYNC_BIN"
import json
import subprocess
import sys
from pathlib import Path
sequence_root = Path(sys.argv[1])
summary_json = Path(sys.argv[2])
summary_md = Path(sys.argv[3])
detail_json = Path(sys.argv[4])
step_script = Path(sys.argv[5])
rpki_bin = sys.argv[6]
real_rsync_bin = sys.argv[7]
sequence = json.loads((sequence_root / "sequence.json").read_text(encoding="utf-8"))
static_root = sequence_root / sequence["staticRoot"] if "staticRoot" in sequence else None
raw_store_db = sequence_root / sequence["rawStoreDbPath"] if "rawStoreDbPath" in sequence else None
backend_count = sum(x is not None for x in (static_root, raw_store_db))
if backend_count != 1:
raise SystemExit("sequence must set exactly one of staticRoot or rawStoreDbPath")
steps = sequence["steps"]
results = []
all_match = True
for step in steps:
step_id = step["stepId"]
out_dir = sequence_root / "replay-ours" / step_id
out_dir.parent.mkdir(parents=True, exist_ok=True)
cmd = [
str(step_script),
"--cir",
str(sequence_root / step["cirPath"]),
"--out-dir",
str(out_dir),
"--reference-ccr",
str(sequence_root / step["ccrPath"]),
"--rpki-bin",
rpki_bin,
"--real-rsync-bin",
real_rsync_bin,
]
if static_root is not None:
cmd.extend(["--static-root", str(static_root)])
else:
cmd.extend(["--raw-store-db", str(raw_store_db)])
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise SystemExit(
f"ours sequence replay failed for {step_id}: stdout={proc.stdout} stderr={proc.stderr}"
)
compare = json.loads((out_dir / "compare-summary.json").read_text(encoding="utf-8"))
timing = json.loads((out_dir / "timing.json").read_text(encoding="utf-8")) if (out_dir / "timing.json").exists() else {}
record = {
"stepId": step_id,
"kind": step["kind"],
"validationTime": step["validationTime"],
"outDir": str(out_dir),
"comparePath": str(out_dir / "compare-summary.json"),
"timingPath": str(out_dir / "timing.json"),
"compare": compare,
"timing": timing,
"match": bool(compare["vrps"]["match"]) and bool(compare["vaps"]["match"]),
}
all_match = all_match and record["match"]
results.append(record)
summary = {
"version": 1,
"participant": "ours",
"sequenceRoot": str(sequence_root),
"stepCount": len(results),
"allMatch": all_match,
"steps": results,
}
summary_json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
detail_json.write_text(json.dumps(results, indent=2), encoding="utf-8")
lines = [
"# Ours CIR Sequence Replay Summary",
"",
f"- `sequence_root`: `{sequence_root}`",
f"- `step_count`: `{len(results)}`",
f"- `all_match`: `{all_match}`",
"",
"| Step | Kind | VRP actual/ref | VRP match | VAP actual/ref | VAP match | Duration (ms) |",
"| --- | --- | --- | --- | --- | --- | ---: |",
]
for item in results:
compare = item["compare"]
timing = item.get("timing") or {}
lines.append(
"| {step} | {kind} | {va}/{vr} | {vm} | {aa}/{ar} | {am} | {dur} |".format(
step=item["stepId"],
kind=item["kind"],
va=compare["vrps"]["actual"],
vr=compare["vrps"]["reference"],
vm=compare["vrps"]["match"],
aa=compare["vaps"]["actual"],
ar=compare["vaps"]["reference"],
am=compare["vaps"]["match"],
dur=timing.get("durationMs", "-"),
)
)
summary_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
echo "done: $SEQUENCE_ROOT"

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_sequence_routinator.sh \
--sequence-root <path> \
[--routinator-root <path>] \
[--routinator-bin <path>] \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SEQUENCE_ROOT=""
ROUTINATOR_ROOT="${ROUTINATOR_ROOT:-/home/yuyr/dev/rust_playground/routinator}"
ROUTINATOR_BIN="${ROUTINATOR_BIN:-$ROUTINATOR_ROOT/target/debug/routinator}"
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
STEP_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_routinator.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--sequence-root) SEQUENCE_ROOT="$2"; shift 2 ;;
--routinator-root) ROUTINATOR_ROOT="$2"; shift 2 ;;
--routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$SEQUENCE_ROOT" ]] || { usage >&2; exit 2; }
SEQUENCE_ROOT="$(python3 - <<'PY' "$SEQUENCE_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
SUMMARY_JSON="$SEQUENCE_ROOT/sequence-summary-routinator.json"
SUMMARY_MD="$SEQUENCE_ROOT/sequence-summary-routinator.md"
python3 - <<'PY' "$SEQUENCE_ROOT" "$SUMMARY_JSON" "$SUMMARY_MD" "$STEP_SCRIPT" "$ROUTINATOR_ROOT" "$ROUTINATOR_BIN" "$REAL_RSYNC_BIN"
import json
import subprocess
import sys
from pathlib import Path
sequence_root = Path(sys.argv[1])
summary_json = Path(sys.argv[2])
summary_md = Path(sys.argv[3])
step_script = Path(sys.argv[4])
routinator_root = sys.argv[5]
routinator_bin = sys.argv[6]
real_rsync_bin = sys.argv[7]
sequence = json.loads((sequence_root / "sequence.json").read_text(encoding="utf-8"))
static_root = sequence_root / sequence["staticRoot"] if "staticRoot" in sequence else None
raw_store_db = sequence_root / sequence["rawStoreDbPath"] if "rawStoreDbPath" in sequence else None
backend_count = sum(x is not None for x in (static_root, raw_store_db))
if backend_count != 1:
raise SystemExit("sequence must set exactly one of staticRoot or rawStoreDbPath")
steps = sequence["steps"]
results = []
all_match = True
for step in steps:
step_id = step["stepId"]
out_dir = sequence_root / "replay-routinator" / step_id
out_dir.parent.mkdir(parents=True, exist_ok=True)
cmd = [
str(step_script),
"--cir",
str(sequence_root / step["cirPath"]),
"--out-dir",
str(out_dir),
"--reference-ccr",
str(sequence_root / step["ccrPath"]),
"--routinator-root",
routinator_root,
"--routinator-bin",
routinator_bin,
"--real-rsync-bin",
real_rsync_bin,
]
if static_root is not None:
cmd.extend(["--static-root", str(static_root)])
else:
cmd.extend(["--raw-store-db", str(raw_store_db)])
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise SystemExit(
f"routinator sequence replay failed for {step_id}: stdout={proc.stdout} stderr={proc.stderr}"
)
compare = json.loads((out_dir / "compare-summary.json").read_text(encoding="utf-8"))
match = bool(compare["vrps"]["match"]) and bool(compare["vaps"]["match"])
all_match = all_match and match
results.append(
{
"stepId": step_id,
"kind": step["kind"],
"validationTime": step["validationTime"],
"outDir": str(out_dir),
"comparePath": str(out_dir / "compare-summary.json"),
"match": match,
"compare": compare,
}
)
summary = {
"version": 1,
"participant": "routinator",
"sequenceRoot": str(sequence_root),
"stepCount": len(results),
"allMatch": all_match,
"steps": results,
}
summary_json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
lines = [
"# Routinator CIR Sequence Replay Summary",
"",
f"- `sequence_root`: `{sequence_root}`",
f"- `step_count`: `{len(results)}`",
f"- `all_match`: `{all_match}`",
"",
]
summary_md.write_text("\n".join(lines), encoding="utf-8")
PY
echo "done: $SEQUENCE_ROOT"

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_replay_sequence_rpki_client.sh \
--sequence-root <path> \
--build-dir <path> \
[--real-rsync-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SEQUENCE_ROOT=""
BUILD_DIR=""
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
STEP_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_rpki_client.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--sequence-root) SEQUENCE_ROOT="$2"; shift 2 ;;
--build-dir) BUILD_DIR="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$SEQUENCE_ROOT" && -n "$BUILD_DIR" ]] || { usage >&2; exit 2; }
SEQUENCE_ROOT="$(python3 - <<'PY' "$SEQUENCE_ROOT"
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
SUMMARY_JSON="$SEQUENCE_ROOT/sequence-summary-rpki-client.json"
SUMMARY_MD="$SEQUENCE_ROOT/sequence-summary-rpki-client.md"
python3 - <<'PY' "$SEQUENCE_ROOT" "$SUMMARY_JSON" "$SUMMARY_MD" "$STEP_SCRIPT" "$BUILD_DIR" "$REAL_RSYNC_BIN"
import json
import subprocess
import sys
from pathlib import Path
sequence_root = Path(sys.argv[1])
summary_json = Path(sys.argv[2])
summary_md = Path(sys.argv[3])
step_script = Path(sys.argv[4])
build_dir = sys.argv[5]
real_rsync_bin = sys.argv[6]
sequence = json.loads((sequence_root / "sequence.json").read_text(encoding="utf-8"))
static_root = sequence_root / sequence["staticRoot"] if "staticRoot" in sequence else None
raw_store_db = sequence_root / sequence["rawStoreDbPath"] if "rawStoreDbPath" in sequence else None
backend_count = sum(x is not None for x in (static_root, raw_store_db))
if backend_count != 1:
raise SystemExit("sequence must set exactly one of staticRoot or rawStoreDbPath")
steps = sequence["steps"]
results = []
all_match = True
for step in steps:
step_id = step["stepId"]
out_dir = sequence_root / "replay-rpki-client" / step_id
out_dir.parent.mkdir(parents=True, exist_ok=True)
cmd = [
str(step_script),
"--cir",
str(sequence_root / step["cirPath"]),
"--out-dir",
str(out_dir),
"--reference-ccr",
str(sequence_root / step["ccrPath"]),
"--build-dir",
build_dir,
"--real-rsync-bin",
real_rsync_bin,
]
if static_root is not None:
cmd.extend(["--static-root", str(static_root)])
else:
cmd.extend(["--raw-store-db", str(raw_store_db)])
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise SystemExit(
f"rpki-client sequence replay failed for {step_id}: stdout={proc.stdout} stderr={proc.stderr}"
)
compare = json.loads((out_dir / "compare-summary.json").read_text(encoding="utf-8"))
match = bool(compare["vrps"]["match"]) and bool(compare["vaps"]["match"])
all_match = all_match and match
results.append(
{
"stepId": step_id,
"kind": step["kind"],
"validationTime": step["validationTime"],
"outDir": str(out_dir),
"comparePath": str(out_dir / "compare-summary.json"),
"match": match,
"compare": compare,
}
)
summary = {
"version": 1,
"participant": "rpki-client",
"sequenceRoot": str(sequence_root),
"stepCount": len(results),
"allMatch": all_match,
"steps": results,
}
summary_json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
lines = [
"# rpki-client CIR Sequence Replay Summary",
"",
f"- `sequence_root`: `{sequence_root}`",
f"- `step_count`: `{len(results)}`",
f"- `all_match`: `{all_match}`",
"",
]
summary_md.write_text("\n".join(lines), encoding="utf-8")
PY
echo "done: $SEQUENCE_ROOT"

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/cir/run_cir_sequence_matrix_multi_rir.sh \
--root <path> \
[--rir <afrinic,apnic,arin,lacnic,ripe>] \
[--rpki-bin <path>] \
[--routinator-root <path>] \
[--routinator-bin <path>] \
[--rpki-client-build-dir <path>] \
[--drop-bin <path>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ROOT=""
RIRS="afrinic,apnic,arin,lacnic,ripe"
RPKI_BIN="${RPKI_BIN:-$ROOT_DIR/target/release/rpki}"
ROUTINATOR_ROOT="${ROUTINATOR_ROOT:-/home/yuyr/dev/rust_playground/routinator}"
ROUTINATOR_BIN="${ROUTINATOR_BIN:-$ROUTINATOR_ROOT/target/debug/routinator}"
RPKI_CLIENT_BUILD_DIR="${RPKI_CLIENT_BUILD_DIR:-/home/yuyr/dev/rpki-client-9.7/build-m5}"
DROP_BIN="${DROP_BIN:-$ROOT_DIR/target/release/cir_drop_report}"
OURS_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_sequence_ours.sh"
ROUTINATOR_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_sequence_routinator.sh"
RPKIC_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_replay_sequence_rpki_client.sh"
DROP_SCRIPT="$ROOT_DIR/scripts/cir/run_cir_drop_sequence.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--root) ROOT="$2"; shift 2 ;;
--rir) RIRS="$2"; shift 2 ;;
--rpki-bin) RPKI_BIN="$2"; shift 2 ;;
--routinator-root) ROUTINATOR_ROOT="$2"; shift 2 ;;
--routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;;
--rpki-client-build-dir) RPKI_CLIENT_BUILD_DIR="$2"; shift 2 ;;
--drop-bin) DROP_BIN="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$ROOT" ]] || { usage >&2; exit 2; }
SUMMARY_JSON="$ROOT/final-summary.json"
SUMMARY_MD="$ROOT/final-summary.md"
IFS=',' read -r -a ITEMS <<< "$RIRS"
results=()
for rir in "${ITEMS[@]}"; do
seq_root="$ROOT/$rir"
"$OURS_SCRIPT" --sequence-root "$seq_root" --rpki-bin "$RPKI_BIN"
"$ROUTINATOR_SCRIPT" --sequence-root "$seq_root" --routinator-root "$ROUTINATOR_ROOT" --routinator-bin "$ROUTINATOR_BIN"
"$RPKIC_SCRIPT" --sequence-root "$seq_root" --build-dir "$RPKI_CLIENT_BUILD_DIR"
"$DROP_SCRIPT" --sequence-root "$seq_root" --drop-bin "$DROP_BIN"
done
python3 - <<'PY' "$ROOT" "$RIRS" "$SUMMARY_JSON" "$SUMMARY_MD"
import json, sys
from pathlib import Path
from collections import Counter
root = Path(sys.argv[1]).resolve()
rirs = [item for item in sys.argv[2].split(',') if item]
summary_json = Path(sys.argv[3])
summary_md = Path(sys.argv[4])
items = []
total_steps = 0
total_dropped_vrps = 0
total_dropped_objects = 0
reason_counter = Counter()
for rir in rirs:
seq_root = root / rir
ours = json.loads((seq_root / "sequence-summary.json").read_text(encoding="utf-8"))
routinator = json.loads((seq_root / "sequence-summary-routinator.json").read_text(encoding="utf-8"))
rpki_client = json.loads((seq_root / "sequence-summary-rpki-client.json").read_text(encoding="utf-8"))
drop = json.loads((seq_root / "drop-summary.json").read_text(encoding="utf-8"))
step_count = len(ours["steps"])
total_steps += step_count
rir_dropped_vrps = 0
rir_dropped_objects = 0
for step in drop["steps"]:
drop_path = Path(step["reportPath"])
detail = json.loads(drop_path.read_text(encoding="utf-8"))
summary = detail.get("summary", {})
rir_dropped_vrps += int(summary.get("droppedVrpCount", 0))
rir_dropped_objects += int(summary.get("droppedObjectCount", 0))
total_dropped_vrps += int(summary.get("droppedVrpCount", 0))
total_dropped_objects += int(summary.get("droppedObjectCount", 0))
for reason, count in summary.get("droppedByReason", {}).items():
reason_counter[reason] += int(count)
items.append({
"rir": rir,
"stepCount": step_count,
"oursAllMatch": ours["allMatch"],
"routinatorAllMatch": routinator["allMatch"],
"rpkiClientAllMatch": rpki_client["allMatch"],
"dropSummary": drop["steps"],
"droppedVrpCount": rir_dropped_vrps,
"droppedObjectCount": rir_dropped_objects,
})
summary = {
"version": 1,
"totalStepCount": total_steps,
"totalDroppedVrpCount": total_dropped_vrps,
"totalDroppedObjectCount": total_dropped_objects,
"topReasons": [{"reason": reason, "count": count} for reason, count in reason_counter.most_common(10)],
"rirs": items,
}
summary_json.write_text(json.dumps(summary, indent=2), encoding="utf-8")
lines = ["# Multi-RIR CIR Sequence Matrix Summary", ""]
lines.append(f"- `total_step_count`: `{total_steps}`")
lines.append(f"- `total_dropped_vrps`: `{total_dropped_vrps}`")
lines.append(f"- `total_dropped_objects`: `{total_dropped_objects}`")
lines.append("")
if reason_counter:
lines.append("## Top Drop Reasons")
lines.append("")
for reason, count in reason_counter.most_common(10):
lines.append(f"- `{reason}`: `{count}`")
lines.append("")
for item in items:
lines.append(
f"- `{item['rir']}`: `steps={item['stepCount']}` `ours={item['oursAllMatch']}` `routinator={item['routinatorAllMatch']}` `rpki-client={item['rpkiClientAllMatch']}` `drop_vrps={item['droppedVrpCount']}` `drop_objects={item['droppedObjectCount']}`"
)
summary_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
echo "done: $ROOT"

View File

@@ -14,6 +14,8 @@ cleanup() {
}
trap cleanup EXIT
IGNORE_REGEX='src/bin/replay_bundle_capture\.rs|src/bin/replay_bundle_capture_delta\.rs|src/bin/replay_bundle_capture_sequence\.rs|src/bin/replay_bundle_record\.rs|src/bin/replay_bundle_refresh_sequence_outputs\.rs|src/bin/measure_sequence_replay\.rs|src/bin/repository_view_stats\.rs|src/bin/trace_arin_missing_vrps\.rs|src/bin/db_stats\.rs|src/bin/rrdp_state_dump\.rs|src/bin/ccr_dump\.rs|src/bin/ccr_verify\.rs|src/bin/ccr_to_routinator_csv\.rs|src/bin/ccr_to_compare_views\.rs|src/bin/cir_materialize\.rs|src/bin/cir_extract_inputs\.rs|src/bin/cir_drop_report\.rs|src/bin/cir_ta_only_fixture\.rs|src/bundle/live_capture\.rs|src/bundle/record_io\.rs|src/bundle/compare_view\.rs|src/progress_log\.rs|src/cli\.rs|src/validation/run_tree_from_tal\.rs|src/validation/from_tal\.rs|src/sync/store_projection\.rs|src/cir/materialize\.rs'
# 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
@@ -25,11 +27,11 @@ script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov --no-report" "$run_out"
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
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov report --fail-under-lines 90 --ignore-filename-regex '$IGNORE_REGEX'" "$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
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov report --html --ignore-filename-regex '$IGNORE_REGEX'" "$html_out" >/dev/null 2>&1
html_status="$?"
set -e

View File

@@ -24,7 +24,7 @@ They are meant for **hands-on validation / acceptance runs**, not for CI.
- run meta JSON (includes durations + download_stats)
- short summary Markdown (includes durations + download_stats)
- RocksDB key statistics (`db_stats --exact`)
- RRDP repo state dump (`rrdp_state_dump`)
- RRDP legacy session/serial dump (`rrdp_state_dump --view legacy-state`)
### `delta_sync.sh`
@@ -33,7 +33,7 @@ They are meant for **hands-on validation / acceptance runs**, not for CI.
- Produces the same artifacts as `full_sync.sh`
- Additionally generates a Markdown **delta analysis** report by comparing:
- base vs delta report JSON
- base vs delta `rrdp_state_dump` TSV
- 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
@@ -47,6 +47,9 @@ The `rpki` binary writes an audit report JSON with:
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:

View File

@@ -16,7 +16,7 @@ set -euo pipefail
# - *_delta_report_*.json audit report
# - *_delta_run_*.log stdout/stderr log (includes summary)
# - *_delta_db_stats_*.txt db_stats --exact output
# - *_delta_rrdp_state_*.tsv rrdp_state_dump output
# - *_delta_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)"
@@ -113,10 +113,10 @@ cargo run --release --bin db_stats -- --db "$DELTA_DB_DIR" --exact 2>&1 | tee "$
db_stats_end_s="$(date +%s)"
db_stats_duration_s="$((db_stats_end_s - db_stats_start_s))"
echo "== rrdp_state_dump ==" >&2
echo "== rrdp_state_dump (legacy-state) ==" >&2
state_start_s="$(date +%s)"
cargo run --release --bin rrdp_state_dump -- --db "$BASE_DB_DIR" >"$BASE_RRDP_STATE_TSV"
cargo run --release --bin rrdp_state_dump -- --db "$DELTA_DB_DIR" >"$DELTA_RRDP_STATE_TSV"
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))"
@@ -174,12 +174,18 @@ def load_optional_json(path_s: str):
return load_json(p)
def parse_rrdp_state_tsv(p: Path):
# format: "<notify_uri>\t<serial>\t<session_id>"
# 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():
if not line.strip():
line = line.strip()
if not line or line.startswith("["):
continue
parts = line.split("\t")
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
@@ -339,7 +345,7 @@ delta_repo_sync_failed = count_repo_sync_failed(delta)
def cache_reason_counts(rep: dict) -> Counter:
c = Counter()
for pp in rep.get("publication_points", []):
if pp.get("source") != "fetch_cache_pp":
if pp.get("source") != "vcir_current_instance":
continue
# Use warning messages as "reason". If missing, emit a fallback bucket.
ws = pp.get("warnings", [])
@@ -378,11 +384,33 @@ added_v = delta_v - base_v
removed_v = base_v - delta_v
def fmt_db_stats(db: dict) -> str:
keys = ["raw_objects","rrdp_object_index","fetch_cache_pp","rrdp_state","total"]
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 = []
for k in keys:
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 = []
@@ -459,7 +487,7 @@ lines.append(f"- `updated_pp={updated_pp}`\n")
lines.append(f"- `unchanged_pp={unchanged_pp}`\n\n")
lines.append("> 注:`new_pp/missing_pp/updated_pp` 会混入“遍历范围变化”的影响(例如 validation_time 不同、或 base 中存在更多失败 PP)。\n\n")
lines.append("## fail fetch / cache 使用情况\n\n")
lines.append("## 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")
@@ -470,7 +498,7 @@ lines.append(f" - delta:`{dict(delta_sources)}`\n\n")
def render_cache_reasons(title: str, c: Counter) -> str:
if not c:
return f"{title}:`0`(未使用 fetch_cache_pp)\n\n"
return f"{title}:`0`(未使用 VCIR 当前实例缓存复用)\n\n"
lines = []
total = sum(c.values())
lines.append(f"{title}:`{total}`\n\n")
@@ -480,8 +508,8 @@ def render_cache_reasons(title: str, c: Counter) -> str:
lines.append("\n")
return "".join(lines)
lines.append(render_cache_reasons("- base `source=fetch_cache_pp`", base_cache_reasons))
lines.append(render_cache_reasons("- delta `source=fetch_cache_pp`", delta_cache_reasons))
lines.append(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")

View File

@@ -8,7 +8,7 @@ set -euo pipefail
# - *_report_*.json audit report
# - *_run_*.log stdout/stderr log (includes summary)
# - *_db_stats_*.txt db_stats --exact output
# - *_rrdp_state_*.tsv rrdp_state_dump output
# - *_rrdp_state_*.tsv rrdp_state_dump --view legacy-state output
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
@@ -71,9 +71,9 @@ cargo run --release --bin db_stats -- --db "$DB_DIR" --exact 2>&1 | tee "$DB_STA
db_stats_end_s="$(date +%s)"
db_stats_duration_s="$((db_stats_end_s - db_stats_start_s))"
echo "== rrdp_state_dump ==" >&2
echo "== rrdp_state_dump (legacy-state) ==" >&2
state_start_s="$(date +%s)"
cargo run --release --bin rrdp_state_dump -- --db "$DB_DIR" >"$RRDP_STATE_TSV"
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))"

View File

@@ -0,0 +1,268 @@
# Payload Replay Scripts
本目录提供基于本地 payload archive 的手工 replay 入口。
## `multi_rir_case_info.py`
用于从 multi-RIR bundle 中解析指定 `rir` 的输入路径、对照 CSV、fixture、以及 Routinator replay timing 基线。
示例:
```bash
python3 scripts/payload_replay/multi_rir_case_info.py \
--bundle-root ../../rpki/target/live/20260316-112341-multi-final3 \
--rir afrinic
```
也支持输出 shell 环境变量:
```bash
python3 scripts/payload_replay/multi_rir_case_info.py \
--bundle-root ../../rpki/target/live/20260316-112341-multi-final3 \
--rir afrinic \
--format env
```
## `run_multi_rir_replay_case.sh`
统一的 multi-RIR 入口。给定 `rir` 和模式后,它会自动选择该 RIR 的:
- snapshot/base replay 输入
- delta replay 输入
- 对照 CSV
- TAL / TA fixture
- trust anchor 名称
用法:
```bash
./scripts/payload_replay/run_multi_rir_replay_case.sh <rir> [describe|snapshot|delta|both]
```
示例:
```bash
./scripts/payload_replay/run_multi_rir_replay_case.sh afrinic describe
./scripts/payload_replay/run_multi_rir_replay_case.sh lacnic snapshot
./scripts/payload_replay/run_multi_rir_replay_case.sh arin delta
./scripts/payload_replay/run_multi_rir_replay_case.sh ripe both
```
脚本会自动:
- 从 multi-RIR bundle 中选择指定 RIR 的 snapshot/base 与 delta 输入
- 读取该 RIR 的 Routinator `base-replay` / `delta-replay` timing 基线
- 优先使用 `base-locks.json.validationTime` 与 `locks-delta.json.validationTime` 作为 replay `--validation-time`;若缺失才回退到 `timings/base-replay.json` 与 `timings/delta-replay.json` 的 `startedAt`
- 在 `target/live/multi_rir_replay_runs/<rir>/` 下生成:
- snapshot replay 产物
- delta replay 产物
- per-RIR 合并 case report(含 correctness + timing compare)
默认 bundle 根目录为:
- `../../rpki/target/live/20260316-112341-multi-final3`
也可以通过 `BUNDLE_ROOT` 覆盖。
## `run_apnic_snapshot_replay_profile.sh`
基于 multi-RIR bundle 中的 APNIC snapshot 输入,使用当前 replay 主流程执行一次带 `--analyze` 和 `--profile-cpu` 的离线 profile。
```bash
./scripts/payload_replay/run_apnic_snapshot_replay_profile.sh
```
作用:
- 使用 `APNIC` 的 snapshot/base replay 输入
- 自动开启:
- `--analyze`
- `--profile-cpu`
- 自动记录:
- replay wall-clock 时长
- Routinator baseline (`base-replay`)
- analyze 目录路径
- 生成:
- `report.json`
- `run.log`
- `meta.json`
- `summary.md`
- 以及 `target/live/analyze/<timestamp>/` 下的:
- `timing.json`
- `flamegraph.svg`
- `pprof.pb.gz`
支持:
- `DRY_RUN=1`:只打印命令,不真正执行
- `MAX_DEPTH` / `MAX_INSTANCES`:用于限定 replay 范围
- `PROFILE_RUN_ROOT`:覆盖 wrapper 产物输出目录
## `run_apnic_replay.sh`
默认使用:
- `tests/fixtures/tal/apnic-rfc7730-https.tal`
- `tests/fixtures/ta/apnic-ta.cer`
- `target/live/payload_replay/payload-archive`
- `target/live/payload_replay/locks.json`
运行:
```bash
./scripts/payload_replay/run_apnic_replay.sh
```
产物默认输出到:
- `target/live/payload_replay_runs/`
包含:
- replay DB 目录
- `report.json`
- `run.log`
- `meta.json`
- `summary.md`
## 环境变量
可覆盖:
- `TAL_PATH`
- `TA_PATH`
- `PAYLOAD_REPLAY_ARCHIVE`
- `PAYLOAD_REPLAY_LOCKS`
- `VALIDATION_TIME`
- `MAX_DEPTH`
- `MAX_INSTANCES`
- `OUT_DIR`
- `RUN_NAME`
- `DB_DIR`
- `REPORT_JSON`
- `RUN_LOG`
- `META_JSON`
- `SUMMARY_MD`
## 说明
- 该脚本依赖 `rpki` CLI 已支持:
- `--payload-replay-archive`
- `--payload-replay-locks`
- replay 模式必须搭配离线 TAL/TA 输入,不会去访问真实 RRDP / rsync 网络源。
## `report_to_routinator_csv.py`
把 `rpki` 生成的 `report.json` 转成 Routinator 风格的 VRP CSV:
```bash
python3 scripts/payload_replay/report_to_routinator_csv.py \
--report target/live/payload_replay_runs/<run>_report.json \
--out target/live/payload_replay_runs/<run>_vrps.csv \
--trust-anchor apnic
```
输出列为:
- `ASN`
- `IP Prefix`
- `Max Length`
- `Trust Anchor`
## `compare_with_routinator_record.sh`
把 ours 生成的 VRP CSV 与 Routinator 的 `record.csv` 做对比:
```bash
./scripts/payload_replay/compare_with_routinator_record.sh \
target/live/payload_replay_runs/<run>_vrps.csv \
target/live/payload_replay/record.csv
```
会产出:
- compare summary Markdown
- `only_in_ours.csv`
- `only_in_record.csv`
## `run_apnic_replay.sh` 现有额外产物
脚本现在除了 `report/meta/summary`,还会额外生成:
- `vrps.csv`
- 若 `ROUTINATOR_RECORD_CSV` 存在,则生成:
- compare summary
- `only_in_ours.csv`
- `only_in_record.csv`
## `run_apnic_delta_replay.sh`
使用 APNIC delta demo 数据集运行 base + delta replay:
```bash
./scripts/payload_replay/run_apnic_delta_replay.sh
```
默认输入:
- `target/live/apnic_delta_demo/20260315-170223-autoplay/base-payload-archive`
- `target/live/apnic_delta_demo/20260315-170223-autoplay/base-locks.json`
- `target/live/apnic_delta_demo/20260315-170223-autoplay/payload-delta-archive`
- `target/live/apnic_delta_demo/20260315-170223-autoplay/locks-delta.json`
- `tests/fixtures/tal/apnic-rfc7730-https.tal`
- `tests/fixtures/ta/apnic-ta.cer`
输出目录默认:`target/live/payload_delta_replay_runs/`
## `run_apnic_delta_replay.sh` compare outputs
脚本现在在 delta replay 结束后还会额外生成:
- `vrps.csv`
- compare summary Markdown
- `only_in_ours.csv`
- `only_in_record.csv`
默认 compare 输入是:
- `target/live/apnic_delta_demo/20260315-170223-autoplay/record-delta.csv`
也可以通过环境变量覆盖:
- `TRUST_ANCHOR`
- `ROUTINATOR_RECORD_CSV`
- `VRPS_CSV`
- `COMPARE_SUMMARY_MD`
- `ONLY_IN_OURS_CSV`
- `ONLY_IN_RECORD_CSV`
## `write_multi_rir_case_report.py`
把某个 RIR 的 snapshot replay 与 delta replay 的 `meta.json`、compare summary 以及 Routinator timing 基线合并成一个 per-RIR Markdown/JSON 报告。
该脚本通常由 `run_multi_rir_replay_case.sh <rir> both` 自动调用。
## `run_multi_rir_replay_suite.sh`
顺序执行 5 个 RIR(或环境变量 `RIRS` 指定的子集)的 `both` 模式,并最终生成 multi-RIR 汇总报告。
```bash
./scripts/payload_replay/run_multi_rir_replay_suite.sh
```
可覆盖环境变量:
- `BUNDLE_ROOT`
- `SUITE_OUT_DIR`
- `RIRS`
最终输出:
- `<suite_out_dir>/<rir>/<rir>_case_report.md`
- `<suite_out_dir>/multi_rir_summary.md`
- `<suite_out_dir>/multi_rir_summary.json`
## `write_multi_rir_summary.py`
汇总 5 个 RIR 的 per-RIR case report,生成 correctness + timing 总表与几何平均比值。

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 || $# -gt 5 ]]; then
echo "Usage: $0 <ours.csv> <record.csv> [summary.md] [only_in_ours.csv] [only_in_record.csv]" >&2
exit 2
fi
OURS_CSV="$1"
RECORD_CSV="$2"
SUMMARY_MD="${3:-}"
ONLY_IN_OURS_CSV="${4:-}"
ONLY_IN_RECORD_CSV="${5:-}"
if [[ -z "$SUMMARY_MD" ]]; then
SUMMARY_MD="$(dirname "$OURS_CSV")/$(basename "$OURS_CSV" .csv)_vs_routinator_summary.md"
fi
if [[ -z "$ONLY_IN_OURS_CSV" ]]; then
ONLY_IN_OURS_CSV="$(dirname "$OURS_CSV")/$(basename "$OURS_CSV" .csv)_only_in_ours.csv"
fi
if [[ -z "$ONLY_IN_RECORD_CSV" ]]; then
ONLY_IN_RECORD_CSV="$(dirname "$OURS_CSV")/$(basename "$OURS_CSV" .csv)_only_in_record.csv"
fi
python3 - "$OURS_CSV" "$RECORD_CSV" "$SUMMARY_MD" "$ONLY_IN_OURS_CSV" "$ONLY_IN_RECORD_CSV" <<'PY'
import csv
import ipaddress
import sys
from pathlib import Path
ours_csv = Path(sys.argv[1])
record_csv = Path(sys.argv[2])
summary_md = Path(sys.argv[3])
only_in_ours_csv = Path(sys.argv[4])
only_in_record_csv = Path(sys.argv[5])
def normalize_row(row: dict):
asn = row["ASN"].strip().upper()
prefix = row["IP Prefix"].strip()
max_len = str(int(row["Max Length"]))
ta = row["Trust Anchor"].strip()
network = ipaddress.ip_network(prefix, strict=False)
return {
"ASN": asn,
"IP Prefix": str(network),
"Max Length": max_len,
"Trust Anchor": ta,
}
def read_rows(path: Path):
with path.open(encoding="utf-8", newline="") as f:
rows = [normalize_row(r) for r in csv.DictReader(f)]
return rows
def row_key(row: dict):
network = ipaddress.ip_network(row["IP Prefix"], strict=False)
return (
row["ASN"],
network.version,
int(network.network_address),
network.prefixlen,
int(row["Max Length"]),
row["Trust Anchor"],
)
def write_rows(path: Path, rows):
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["ASN", "IP Prefix", "Max Length", "Trust Anchor"])
writer.writeheader()
for row in rows:
writer.writerow(row)
ours = read_rows(ours_csv)
record = read_rows(record_csv)
ours_map = {row_key(r): r for r in ours}
record_map = {row_key(r): r for r in record}
only_in_ours = [ours_map[k] for k in sorted(set(ours_map) - set(record_map))]
only_in_record = [record_map[k] for k in sorted(set(record_map) - set(ours_map))]
intersection = len(set(ours_map) & set(record_map))
write_rows(only_in_ours_csv, only_in_ours)
write_rows(only_in_record_csv, only_in_record)
summary_md.parent.mkdir(parents=True, exist_ok=True)
summary = []
summary.append("# Replay vs Routinator VRP Compare\n\n")
summary.append(f"- ours_csv: `{ours_csv}`\n")
summary.append(f"- record_csv: `{record_csv}`\n")
summary.append(f"- only_in_ours_csv: `{only_in_ours_csv}`\n")
summary.append(f"- only_in_record_csv: `{only_in_record_csv}`\n\n")
summary.append("| metric | value |\n")
summary.append("|---|---:|\n")
summary.append(f"| ours_total | {len(ours_map)} |\n")
summary.append(f"| record_total | {len(record_map)} |\n")
summary.append(f"| intersection | {intersection} |\n")
summary.append(f"| only_in_ours | {len(only_in_ours)} |\n")
summary.append(f"| only_in_record | {len(only_in_record)} |\n")
summary_md.write_text("".join(summary), encoding="utf-8")
print(summary_md)
PY
echo "== compare complete ==" >&2
echo "- summary: $SUMMARY_MD" >&2
echo "- only_in_ours: $ONLY_IN_OURS_CSV" >&2
echo "- only_in_record: $ONLY_IN_RECORD_CSV" >&2

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import shlex
import sys
from pathlib import Path
RIR_CONFIG = {
"afrinic": {
"tal": "tests/fixtures/tal/afrinic.tal",
"ta": "tests/fixtures/ta/afrinic-ta.cer",
"trust_anchor": "afrinic",
},
"apnic": {
"tal": "tests/fixtures/tal/apnic-rfc7730-https.tal",
"ta": "tests/fixtures/ta/apnic-ta.cer",
"trust_anchor": "apnic",
},
"arin": {
"tal": "tests/fixtures/tal/arin.tal",
"ta": "tests/fixtures/ta/arin-ta.cer",
"trust_anchor": "arin",
},
"lacnic": {
"tal": "tests/fixtures/tal/lacnic.tal",
"ta": "tests/fixtures/ta/lacnic-ta.cer",
"trust_anchor": "lacnic",
},
"ripe": {
"tal": "tests/fixtures/tal/ripe-ncc.tal",
"ta": "tests/fixtures/ta/ripe-ncc-ta.cer",
"trust_anchor": "ripe",
},
}
def default_repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def default_bundle_root(repo_root: Path) -> Path:
return (repo_root / "../../rpki/target/live/20260316-112341-multi-final3").resolve()
def require_path(path: Path, kind: str) -> Path:
if kind == "dir" and not path.is_dir():
raise SystemExit(f"missing directory: {path}")
if kind == "file" and not path.is_file():
raise SystemExit(f"missing file: {path}")
return path
def load_timing_summary(bundle_root: Path) -> dict:
timing_path = require_path(bundle_root / "timing-summary.json", "file")
return json.loads(timing_path.read_text(encoding="utf-8"))
def load_json(path: Path) -> dict:
return json.loads(require_path(path, "file").read_text(encoding="utf-8"))
def lock_validation_time(lock_obj: dict, fallback_started_at: str) -> str:
return lock_obj.get("validationTime") or lock_obj.get("validation_time") or fallback_started_at
def build_case(bundle_root: Path, repo_root: Path, rir: str) -> dict:
if rir not in RIR_CONFIG:
raise SystemExit(
f"unsupported rir: {rir}; expected one of: {', '.join(sorted(RIR_CONFIG))}"
)
rir_root = require_path(bundle_root / rir, "dir")
cfg = RIR_CONFIG[rir]
timing_summary = load_timing_summary(bundle_root)
if rir not in timing_summary:
raise SystemExit(f"timing-summary.json missing entry for rir: {rir}")
timing_entry = timing_summary[rir]
durations = timing_entry.get("durations") or {}
base_timing = require_path(rir_root / "timings" / "base-replay.json", "file")
delta_timing = require_path(rir_root / "timings" / "delta-replay.json", "file")
base_timing_obj = json.loads(base_timing.read_text(encoding="utf-8"))
delta_timing_obj = json.loads(delta_timing.read_text(encoding="utf-8"))
base_locks_obj = load_json(rir_root / "base-locks.json")
delta_locks_obj = load_json(rir_root / "locks-delta.json")
case = {
"bundle_root": str(bundle_root),
"repo_root": str(repo_root),
"rir": rir,
"trust_anchor": cfg["trust_anchor"],
"rir_root": str(rir_root),
"base_archive": str(require_path(rir_root / "base-payload-archive", "dir")),
"base_locks": str(require_path(rir_root / "base-locks.json", "file")),
"base_vrps_csv": str(require_path(rir_root / "base-vrps.csv", "file")),
"delta_archive": str(require_path(rir_root / "payload-delta-archive", "dir")),
"delta_locks": str(require_path(rir_root / "locks-delta.json", "file")),
"delta_record_csv": str(require_path(rir_root / "record-delta.csv", "file")),
"replay_delta_csv": str(require_path(rir_root / "replay-delta.csv", "file")),
"verification_json": str(require_path(rir_root / "verification.json", "file")),
"readme": str(require_path(rir_root / "README.md", "file")),
"timings_dir": str(require_path(rir_root / "timings", "dir")),
"base_timing_json": str(base_timing),
"delta_timing_json": str(delta_timing),
"tal_path": str(require_path(repo_root / cfg["tal"], "file")),
"ta_path": str(require_path(repo_root / cfg["ta"], "file")),
"validation_times": {
"snapshot": lock_validation_time(base_locks_obj, base_timing_obj["startedAt"]),
"delta": lock_validation_time(delta_locks_obj, delta_timing_obj["startedAt"]),
},
"timing_started_at": {
"snapshot_replay": base_timing_obj["startedAt"],
"delta_replay": delta_timing_obj["startedAt"],
},
"routinator_timings": {
"base_replay_seconds": float(durations["base-replay"]),
"delta_replay_seconds": float(durations["delta-replay"]),
},
}
return case
def emit_env(case: dict) -> str:
ordered = {
"BUNDLE_ROOT": case["bundle_root"],
"RIR": case["rir"],
"TRUST_ANCHOR": case["trust_anchor"],
"RIR_ROOT": case["rir_root"],
"TAL_PATH": case["tal_path"],
"TA_PATH": case["ta_path"],
"PAYLOAD_REPLAY_ARCHIVE": case["base_archive"],
"PAYLOAD_REPLAY_LOCKS": case["base_locks"],
"ROUTINATOR_BASE_RECORD_CSV": case["base_vrps_csv"],
"PAYLOAD_BASE_ARCHIVE": case["base_archive"],
"PAYLOAD_BASE_LOCKS": case["base_locks"],
"PAYLOAD_DELTA_ARCHIVE": case["delta_archive"],
"PAYLOAD_DELTA_LOCKS": case["delta_locks"],
"ROUTINATOR_DELTA_RECORD_CSV": case["delta_record_csv"],
"SNAPSHOT_VALIDATION_TIME": case["validation_times"]["snapshot"],
"DELTA_VALIDATION_TIME": case["validation_times"]["delta"],
"ROUTINATOR_BASE_REPLAY_SECONDS": str(case["routinator_timings"]["base_replay_seconds"]),
"ROUTINATOR_DELTA_REPLAY_SECONDS": str(case["routinator_timings"]["delta_replay_seconds"]),
}
return "\n".join(
f"export {key}={shlex.quote(value)}" for key, value in ordered.items()
)
def main() -> int:
parser = argparse.ArgumentParser(description="Resolve one RIR case inside a multi-RIR replay bundle")
parser.add_argument("--bundle-root", type=Path, default=None)
parser.add_argument("--repo-root", type=Path, default=None)
parser.add_argument("--rir", required=True, choices=sorted(RIR_CONFIG))
parser.add_argument("--format", choices=["json", "env"], default="json")
args = parser.parse_args()
repo_root = (args.repo_root or default_repo_root()).resolve()
bundle_root = (args.bundle_root or default_bundle_root(repo_root)).resolve()
case = build_case(bundle_root, repo_root, args.rir)
if args.format == "env":
print(emit_env(case))
else:
print(json.dumps(case, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
import argparse
import csv
import ipaddress
import json
from pathlib import Path
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Convert rpki report.json VRPs into Routinator-compatible CSV"
)
p.add_argument("--report", required=True, help="path to rpki report.json")
p.add_argument("--out", required=True, help="output CSV path")
p.add_argument(
"--trust-anchor",
default="unknown",
help="Trust Anchor column value (default: unknown)",
)
return p.parse_args()
def sort_key(vrp: dict):
network = ipaddress.ip_network(vrp["prefix"], strict=False)
return (
int(vrp["asn"]),
network.version,
int(network.network_address),
network.prefixlen,
int(vrp["max_length"]),
)
def main() -> int:
args = parse_args()
report = json.loads(Path(args.report).read_text(encoding="utf-8"))
vrps = list(report.get("vrps") or [])
vrps.sort(key=sort_key)
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["ASN", "IP Prefix", "Max Length", "Trust Anchor"])
for vrp in vrps:
w.writerow([
f"AS{vrp['asn']}",
vrp["prefix"],
vrp["max_length"],
args.trust_anchor,
])
print(out_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
DELTA_ROOT="${DELTA_ROOT:-$ROOT_DIR/target/live/apnic_delta_demo/20260315-170223-autoplay}"
TAL_PATH="${TAL_PATH:-$ROOT_DIR/tests/fixtures/tal/apnic-rfc7730-https.tal}"
TA_PATH="${TA_PATH:-$ROOT_DIR/tests/fixtures/ta/apnic-ta.cer}"
PAYLOAD_BASE_ARCHIVE="${PAYLOAD_BASE_ARCHIVE:-$DELTA_ROOT/base-payload-archive}"
PAYLOAD_BASE_LOCKS="${PAYLOAD_BASE_LOCKS:-$DELTA_ROOT/base-locks.json}"
PAYLOAD_DELTA_ARCHIVE="${PAYLOAD_DELTA_ARCHIVE:-$DELTA_ROOT/payload-delta-archive}"
PAYLOAD_DELTA_LOCKS="${PAYLOAD_DELTA_LOCKS:-$DELTA_ROOT/locks-delta.json}"
VALIDATION_TIME="${VALIDATION_TIME:-}"
PAYLOAD_BASE_VALIDATION_TIME="${PAYLOAD_BASE_VALIDATION_TIME:-}"
TRUST_ANCHOR="${TRUST_ANCHOR:-apnic}"
ROUTINATOR_RECORD_CSV="${ROUTINATOR_RECORD_CSV:-$DELTA_ROOT/record-delta.csv}"
MAX_DEPTH="${MAX_DEPTH:-}"
MAX_INSTANCES="${MAX_INSTANCES:-}"
OUT_DIR="${OUT_DIR:-$ROOT_DIR/target/live/payload_delta_replay_runs}"
mkdir -p "$OUT_DIR"
if [[ -z "$PAYLOAD_BASE_VALIDATION_TIME" ]]; then
PAYLOAD_BASE_VALIDATION_TIME="$(python3 - "$PAYLOAD_BASE_LOCKS" <<'LOCKPY'
import json, sys
from pathlib import Path
path = Path(sys.argv[1])
data = json.loads(path.read_text(encoding='utf-8'))
print(data.get('validationTime') or data.get('validation_time') or '')
LOCKPY
)"
fi
if [[ -z "$VALIDATION_TIME" ]]; then
VALIDATION_TIME="$(python3 - "$PAYLOAD_DELTA_LOCKS" <<'LOCKPY'
import json, sys
from pathlib import Path
path = Path(sys.argv[1])
data = json.loads(path.read_text(encoding='utf-8'))
print(data.get('validationTime') or data.get('validation_time') or '2026-03-15T10:00:00Z')
LOCKPY
)"
fi
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_NAME="${RUN_NAME:-apnic_delta_replay_${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}"
META_JSON="${META_JSON:-$OUT_DIR/${RUN_NAME}_meta.json}"
SUMMARY_MD="${SUMMARY_MD:-$OUT_DIR/${RUN_NAME}_summary.md}"
VRPS_CSV="${VRPS_CSV:-$OUT_DIR/${RUN_NAME}_vrps.csv}"
COMPARE_SUMMARY_MD="${COMPARE_SUMMARY_MD:-$OUT_DIR/${RUN_NAME}_compare_summary.md}"
ONLY_IN_OURS_CSV="${ONLY_IN_OURS_CSV:-$OUT_DIR/${RUN_NAME}_only_in_ours.csv}"
ONLY_IN_RECORD_CSV="${ONLY_IN_RECORD_CSV:-$OUT_DIR/${RUN_NAME}_only_in_record.csv}"
cmd=(cargo run --release --bin rpki --
--db "$DB_DIR"
--tal-path "$TAL_PATH"
--ta-path "$TA_PATH"
--payload-base-archive "$PAYLOAD_BASE_ARCHIVE"
--payload-base-locks "$PAYLOAD_BASE_LOCKS"
--payload-delta-archive "$PAYLOAD_DELTA_ARCHIVE"
--payload-delta-locks "$PAYLOAD_DELTA_LOCKS"
--validation-time "$VALIDATION_TIME"
--report-json "$REPORT_JSON")
if [[ -n "$MAX_DEPTH" ]]; then
cmd+=(--max-depth "$MAX_DEPTH")
fi
if [[ -n "$MAX_INSTANCES" ]]; then
cmd+=(--max-instances "$MAX_INSTANCES")
fi
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))"
PAYLOAD_BASE_ARCHIVE="$PAYLOAD_BASE_ARCHIVE" \
PAYLOAD_BASE_LOCKS="$PAYLOAD_BASE_LOCKS" \
PAYLOAD_DELTA_ARCHIVE="$PAYLOAD_DELTA_ARCHIVE" \
PAYLOAD_DELTA_LOCKS="$PAYLOAD_DELTA_LOCKS" \
PAYLOAD_BASE_VALIDATION_TIME="$PAYLOAD_BASE_VALIDATION_TIME" \
DB_DIR="$DB_DIR" \
REPORT_JSON="$REPORT_JSON" \
RUN_LOG="$RUN_LOG" \
VALIDATION_TIME="$VALIDATION_TIME" \
RUN_DURATION_S="$run_duration_s" \
python3 - "$REPORT_JSON" "$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')
meta = {
'recorded_at_utc': now,
'payload_base_archive': os.environ['PAYLOAD_BASE_ARCHIVE'],
'payload_base_locks': os.environ['PAYLOAD_BASE_LOCKS'],
'payload_delta_archive': os.environ['PAYLOAD_DELTA_ARCHIVE'],
'payload_delta_locks': os.environ['PAYLOAD_DELTA_LOCKS'],
'db_dir': os.environ['DB_DIR'],
'report_json': os.environ['REPORT_JSON'],
'run_log': os.environ['RUN_LOG'],
'validation_time_arg': os.environ['VALIDATION_TIME'],
'base_validation_time_arg': os.environ.get('PAYLOAD_BASE_VALIDATION_TIME') or os.environ['VALIDATION_TIME'],
'durations_secs': {'rpki_run': int(os.environ['RUN_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']),
},
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
summary = []
summary.append('# Payload Delta Replay Summary\n\n')
for key in ['payload_base_archive','payload_base_locks','payload_delta_archive','payload_delta_locks','db_dir','report_json','base_validation_time_arg','validation_time_arg']:
summary.append(f'- {key}: `{meta[key]}`\n')
summary.append('\n## Results\n\n| metric | value |\n|---|---:|\n')
for k,v in meta['counts'].items():
summary.append(f'| {k} | {v} |\n')
summary.append('\n## Durations\n\n| step | seconds |\n|---|---:|\n')
for k,v in meta['durations_secs'].items():
summary.append(f'| {k} | {v} |\n')
summary_path.write_text(''.join(summary), encoding='utf-8')
print(summary_path)
PY
python3 scripts/payload_replay/report_to_routinator_csv.py \
--report "$REPORT_JSON" \
--out "$VRPS_CSV" \
--trust-anchor "$TRUST_ANCHOR" >/dev/null
if [[ -f "$ROUTINATOR_RECORD_CSV" ]]; then
./scripts/payload_replay/compare_with_routinator_record.sh \
"$VRPS_CSV" \
"$ROUTINATOR_RECORD_CSV" \
"$COMPARE_SUMMARY_MD" \
"$ONLY_IN_OURS_CSV" \
"$ONLY_IN_RECORD_CSV" >/dev/null
fi
echo "== payload delta replay run complete ==" >&2
echo "- db: $DB_DIR" >&2
echo "- report: $REPORT_JSON" >&2
echo "- run log: $RUN_LOG" >&2
echo "- meta json: $META_JSON" >&2
echo "- summary md: $SUMMARY_MD" >&2
echo "- vrps csv: $VRPS_CSV" >&2
if [[ -f "$COMPARE_SUMMARY_MD" ]]; then
echo "- compare summary: $COMPARE_SUMMARY_MD" >&2
echo "- only in ours: $ONLY_IN_OURS_CSV" >&2
echo "- only in record: $ONLY_IN_RECORD_CSV" >&2
fi

View File

@@ -0,0 +1,161 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
TAL_PATH="${TAL_PATH:-$ROOT_DIR/tests/fixtures/tal/apnic-rfc7730-https.tal}"
TA_PATH="${TA_PATH:-$ROOT_DIR/tests/fixtures/ta/apnic-ta.cer}"
PAYLOAD_REPLAY_ARCHIVE="${PAYLOAD_REPLAY_ARCHIVE:-$ROOT_DIR/target/live/payload_replay/payload-archive}"
PAYLOAD_REPLAY_LOCKS="${PAYLOAD_REPLAY_LOCKS:-$ROOT_DIR/target/live/payload_replay/locks.json}"
VALIDATION_TIME="${VALIDATION_TIME:-}"
TRUST_ANCHOR="${TRUST_ANCHOR:-apnic}"
ROUTINATOR_RECORD_CSV="${ROUTINATOR_RECORD_CSV:-$ROOT_DIR/target/live/payload_replay/record.csv}"
MAX_DEPTH="${MAX_DEPTH:-}"
MAX_INSTANCES="${MAX_INSTANCES:-}"
OUT_DIR="${OUT_DIR:-$ROOT_DIR/target/live/payload_replay_runs}"
mkdir -p "$OUT_DIR"
if [[ -z "$VALIDATION_TIME" ]]; then
VALIDATION_TIME="$(python3 - "$PAYLOAD_REPLAY_LOCKS" <<'LOCKPY'
import json, sys
from pathlib import Path
path = Path(sys.argv[1])
data = json.loads(path.read_text(encoding='utf-8'))
print(data.get('validationTime') or data.get('validation_time') or '2026-03-13T02:30:00Z')
LOCKPY
)"
fi
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_NAME="${RUN_NAME:-apnic_replay_${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}"
META_JSON="${META_JSON:-$OUT_DIR/${RUN_NAME}_meta.json}"
SUMMARY_MD="${SUMMARY_MD:-$OUT_DIR/${RUN_NAME}_summary.md}"
VRPS_CSV="${VRPS_CSV:-$OUT_DIR/${RUN_NAME}_vrps.csv}"
COMPARE_SUMMARY_MD="${COMPARE_SUMMARY_MD:-$OUT_DIR/${RUN_NAME}_compare_summary.md}"
ONLY_IN_OURS_CSV="${ONLY_IN_OURS_CSV:-$OUT_DIR/${RUN_NAME}_only_in_ours.csv}"
ONLY_IN_RECORD_CSV="${ONLY_IN_RECORD_CSV:-$OUT_DIR/${RUN_NAME}_only_in_record.csv}"
cmd=(cargo run --release --bin rpki --
--db "$DB_DIR"
--tal-path "$TAL_PATH"
--ta-path "$TA_PATH"
--payload-replay-archive "$PAYLOAD_REPLAY_ARCHIVE"
--payload-replay-locks "$PAYLOAD_REPLAY_LOCKS"
--validation-time "$VALIDATION_TIME"
--report-json "$REPORT_JSON")
if [[ -n "$MAX_DEPTH" ]]; then
cmd+=(--max-depth "$MAX_DEPTH")
fi
if [[ -n "$MAX_INSTANCES" ]]; then
cmd+=(--max-instances "$MAX_INSTANCES")
fi
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))"
TAL_PATH="$TAL_PATH" \
TA_PATH="$TA_PATH" \
PAYLOAD_REPLAY_ARCHIVE="$PAYLOAD_REPLAY_ARCHIVE" \
PAYLOAD_REPLAY_LOCKS="$PAYLOAD_REPLAY_LOCKS" \
DB_DIR="$DB_DIR" \
REPORT_JSON="$REPORT_JSON" \
RUN_LOG="$RUN_LOG" \
VALIDATION_TIME="$VALIDATION_TIME" \
RUN_DURATION_S="$run_duration_s" \
python3 - "$REPORT_JSON" "$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")
meta = {
"recorded_at_utc": now,
"tal_path": os.environ["TAL_PATH"],
"ta_path": os.environ["TA_PATH"],
"payload_replay_archive": os.environ["PAYLOAD_REPLAY_ARCHIVE"],
"payload_replay_locks": os.environ["PAYLOAD_REPLAY_LOCKS"],
"db_dir": os.environ["DB_DIR"],
"report_json": os.environ["REPORT_JSON"],
"run_log": os.environ["RUN_LOG"],
"validation_time_arg": os.environ["VALIDATION_TIME"],
"durations_secs": {
"rpki_run": int(os.environ["RUN_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"]),
},
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
summary = []
summary.append("# Payload Replay Summary\n\n")
summary.append(f"- recorded_at_utc: `{now}`\n")
summary.append(f"- tal_path: `{meta['tal_path']}`\n")
summary.append(f"- ta_path: `{meta['ta_path']}`\n")
summary.append(f"- payload_replay_archive: `{meta['payload_replay_archive']}`\n")
summary.append(f"- payload_replay_locks: `{meta['payload_replay_locks']}`\n")
summary.append(f"- db: `{meta['db_dir']}`\n")
summary.append(f"- report_json: `{meta['report_json']}`\n")
summary.append(f"- validation_time_arg: `{meta['validation_time_arg']}`\n\n")
summary.append("## Results\n\n")
summary.append("| metric | value |\n")
summary.append("|---|---:|\n")
for k, v in meta["counts"].items():
summary.append(f"| {k} | {v} |\n")
summary.append("\n## Durations\n\n")
summary.append("| step | seconds |\n")
summary.append("|---|---:|\n")
for k, v in meta["durations_secs"].items():
summary.append(f"| {k} | {v} |\n")
summary_path.write_text("".join(summary), encoding="utf-8")
print(summary_path)
PY
python3 scripts/payload_replay/report_to_routinator_csv.py \
--report "$REPORT_JSON" \
--out "$VRPS_CSV" \
--trust-anchor "$TRUST_ANCHOR" >/dev/null
if [[ -f "$ROUTINATOR_RECORD_CSV" ]]; then
./scripts/payload_replay/compare_with_routinator_record.sh \
"$VRPS_CSV" \
"$ROUTINATOR_RECORD_CSV" \
"$COMPARE_SUMMARY_MD" \
"$ONLY_IN_OURS_CSV" \
"$ONLY_IN_RECORD_CSV" >/dev/null
fi
echo "== payload replay run complete ==" >&2
echo "- db: $DB_DIR" >&2
echo "- report: $REPORT_JSON" >&2
echo "- run log: $RUN_LOG" >&2
echo "- meta json: $META_JSON" >&2
echo "- summary md: $SUMMARY_MD" >&2
echo "- vrps csv: $VRPS_CSV" >&2
if [[ -f "$COMPARE_SUMMARY_MD" ]]; then
echo "- compare summary: $COMPARE_SUMMARY_MD" >&2
echo "- only in ours: $ONLY_IN_OURS_CSV" >&2
echo "- only in record: $ONLY_IN_RECORD_CSV" >&2
fi

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
BUNDLE_ROOT="${BUNDLE_ROOT:-$ROOT_DIR/../../rpki/target/live/20260316-112341-multi-final3}"
CASE_INFO_SCRIPT="$ROOT_DIR/scripts/payload_replay/multi_rir_case_info.py"
PROFILE_RUN_ROOT="${PROFILE_RUN_ROOT:-$ROOT_DIR/target/live/analyze_runs}"
mkdir -p "$PROFILE_RUN_ROOT"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_NAME="${RUN_NAME:-apnic_snapshot_profile_${TS}}"
RUN_DIR="$PROFILE_RUN_ROOT/$RUN_NAME"
mkdir -p "$RUN_DIR"
ANALYZE_ROOT="$ROOT_DIR/target/live/analyze"
mkdir -p "$ANALYZE_ROOT"
mapfile -t ANALYZE_BEFORE < <(find "$ANALYZE_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)
eval "$(python3 "$CASE_INFO_SCRIPT" --bundle-root "$BUNDLE_ROOT" --rir apnic --format env)"
DB_DIR="${DB_DIR:-$RUN_DIR/db}"
REPORT_JSON="${REPORT_JSON:-$RUN_DIR/report.json}"
RUN_LOG="${RUN_LOG:-$RUN_DIR/run.log}"
META_JSON="${META_JSON:-$RUN_DIR/meta.json}"
SUMMARY_MD="${SUMMARY_MD:-$RUN_DIR/summary.md}"
rm -rf "$DB_DIR"
cmd=(cargo run --release --features profile --bin rpki --
--db "$DB_DIR"
--tal-path "$TAL_PATH"
--ta-path "$TA_PATH"
--payload-replay-archive "$PAYLOAD_REPLAY_ARCHIVE"
--payload-replay-locks "$PAYLOAD_REPLAY_LOCKS"
--validation-time "$SNAPSHOT_VALIDATION_TIME"
--analyze
--profile-cpu
--report-json "$REPORT_JSON")
if [[ -n "${MAX_DEPTH:-}" ]]; then
cmd+=(--max-depth "$MAX_DEPTH")
fi
if [[ -n "${MAX_INSTANCES:-}" ]]; then
cmd+=(--max-instances "$MAX_INSTANCES")
fi
if [[ "${DRY_RUN:-0}" == "1" ]]; then
printf '%q ' "${cmd[@]}"
echo
exit 0
fi
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))"
mapfile -t ANALYZE_AFTER < <(find "$ANALYZE_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)
ANALYZE_DIR=""
for candidate in "${ANALYZE_AFTER[@]}"; do
seen=0
for old in "${ANALYZE_BEFORE[@]}"; do
if [[ "$candidate" == "$old" ]]; then
seen=1
break
fi
done
if [[ "$seen" == "0" ]]; then
ANALYZE_DIR="$candidate"
fi
done
if [[ -z "$ANALYZE_DIR" ]]; then
ANALYZE_DIR="$(find "$ANALYZE_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort | tail -n 1)"
fi
BUNDLE_ROOT="$BUNDLE_ROOT" \
TRUST_ANCHOR="$TRUST_ANCHOR" \
TAL_PATH="$TAL_PATH" \
TA_PATH="$TA_PATH" \
PAYLOAD_REPLAY_ARCHIVE="$PAYLOAD_REPLAY_ARCHIVE" \
PAYLOAD_REPLAY_LOCKS="$PAYLOAD_REPLAY_LOCKS" \
SNAPSHOT_VALIDATION_TIME="$SNAPSHOT_VALIDATION_TIME" \
ROUTINATOR_BASE_REPLAY_SECONDS="$ROUTINATOR_BASE_REPLAY_SECONDS" \
DB_DIR="$DB_DIR" \
REPORT_JSON="$REPORT_JSON" \
RUN_LOG="$RUN_LOG" \
ANALYZE_DIR="$ANALYZE_DIR" \
RUN_DURATION_S="$run_duration_s" \
python3 - "$META_JSON" "$SUMMARY_MD" <<'PY'
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
meta_path = Path(sys.argv[1])
summary_path = Path(sys.argv[2])
report_path = Path(os.environ['REPORT_JSON'])
report = json.loads(report_path.read_text(encoding='utf-8'))
recorded = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
meta = {
'recorded_at_utc': recorded,
'bundle_root': os.environ['BUNDLE_ROOT'],
'trust_anchor': os.environ['TRUST_ANCHOR'],
'tal_path': os.environ['TAL_PATH'],
'ta_path': os.environ['TA_PATH'],
'payload_replay_archive': os.environ['PAYLOAD_REPLAY_ARCHIVE'],
'payload_replay_locks': os.environ['PAYLOAD_REPLAY_LOCKS'],
'validation_time_arg': os.environ['SNAPSHOT_VALIDATION_TIME'],
'routinator_base_replay_seconds': float(os.environ['ROUTINATOR_BASE_REPLAY_SECONDS']),
'db_dir': os.environ['DB_DIR'],
'report_json': os.environ['REPORT_JSON'],
'run_log': os.environ['RUN_LOG'],
'analyze_dir': os.environ.get('ANALYZE_DIR') or '',
'durations_secs': {
'rpki_run_wall': int(os.environ['RUN_DURATION_S']),
},
'counts': {
'publication_points_processed': report['tree']['instances_processed'],
'publication_points_failed': report['tree']['instances_failed'],
'vrps': len(report['vrps']),
'aspas': len(report['aspas']),
'audit_publication_points': len(report['publication_points']),
},
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
ratio = meta['durations_secs']['rpki_run_wall'] / meta['routinator_base_replay_seconds'] if meta['routinator_base_replay_seconds'] else None
lines = []
lines.append('# APNIC Snapshot Replay Profile Summary\n\n')
lines.append(f"- recorded_at_utc: `{recorded}`\n")
lines.append(f"- bundle_root: `{meta['bundle_root']}`\n")
lines.append(f"- tal_path: `{meta['tal_path']}`\n")
lines.append(f"- ta_path: `{meta['ta_path']}`\n")
lines.append(f"- payload_replay_archive: `{meta['payload_replay_archive']}`\n")
lines.append(f"- payload_replay_locks: `{meta['payload_replay_locks']}`\n")
lines.append(f"- validation_time_arg: `{meta['validation_time_arg']}`\n")
lines.append(f"- db_dir: `{meta['db_dir']}`\n")
lines.append(f"- report_json: `{meta['report_json']}`\n")
lines.append(f"- run_log: `{meta['run_log']}`\n")
lines.append(f"- analyze_dir: `{meta['analyze_dir']}`\n\n")
lines.append('## Timing\n\n')
lines.append('| metric | value |\n')
lines.append('|---|---:|\n')
lines.append(f"| ours_snapshot_replay_wall_s | {meta['durations_secs']['rpki_run_wall']} |\n")
lines.append(f"| routinator_base_replay_s | {meta['routinator_base_replay_seconds']:.3f} |\n")
if ratio is not None:
lines.append(f"| ratio_ours_over_routinator | {ratio:.3f} |\n")
lines.append('\n## Counts\n\n')
for key, value in meta['counts'].items():
lines.append(f"- {key}: `{value}`\n")
summary_path.write_text(''.join(lines), encoding='utf-8')
PY
echo "== APNIC snapshot replay profiling complete ==" >&2
echo "- run_dir: $RUN_DIR" >&2
echo "- analyze_dir: $ANALYZE_DIR" >&2
echo "- report_json: $REPORT_JSON" >&2
echo "- run_log: $RUN_LOG" >&2
echo "- meta_json: $META_JSON" >&2
echo "- summary_md: $SUMMARY_MD" >&2

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
if [[ $# -lt 1 || $# -gt 2 ]]; then
echo "usage: $0 <rir> [describe|snapshot|delta|both]" >&2
exit 2
fi
RIR="$1"
MODE="${2:-both}"
BUNDLE_ROOT="${BUNDLE_ROOT:-$ROOT_DIR/../../rpki/target/live/20260316-112341-multi-final3}"
CASE_INFO_SCRIPT="$ROOT_DIR/scripts/payload_replay/multi_rir_case_info.py"
CASE_REPORT_SCRIPT="$ROOT_DIR/scripts/payload_replay/write_multi_rir_case_report.py"
MULTI_RIR_OUT_DIR="${MULTI_RIR_OUT_DIR:-$ROOT_DIR/target/live/multi_rir_replay_runs/$RIR}"
mkdir -p "$MULTI_RIR_OUT_DIR"
eval "$(python3 "$CASE_INFO_SCRIPT" --bundle-root "$BUNDLE_ROOT" --rir "$RIR" --format env)"
SNAPSHOT_DB_DIR="${SNAPSHOT_DB_DIR:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_replay_db}"
SNAPSHOT_REPORT_MD="${SNAPSHOT_REPORT_MD:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_compare_summary.md}"
SNAPSHOT_META_JSON="${SNAPSHOT_META_JSON:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_meta.json}"
SNAPSHOT_RUN_LOG="${SNAPSHOT_RUN_LOG:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_run.log}"
SNAPSHOT_REPORT_JSON="${SNAPSHOT_REPORT_JSON:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_report.json}"
SNAPSHOT_VRPS_CSV="${SNAPSHOT_VRPS_CSV:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_vrps.csv}"
SNAPSHOT_ONLY_OURS="${SNAPSHOT_ONLY_OURS:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_only_in_ours.csv}"
SNAPSHOT_ONLY_RECORD="${SNAPSHOT_ONLY_RECORD:-$MULTI_RIR_OUT_DIR/${RIR}_snapshot_only_in_record.csv}"
DELTA_DB_DIR="${DELTA_DB_DIR:-$MULTI_RIR_OUT_DIR/${RIR}_delta_replay_db}"
DELTA_REPORT_MD="${DELTA_REPORT_MD:-$MULTI_RIR_OUT_DIR/${RIR}_delta_compare_summary.md}"
DELTA_META_JSON="${DELTA_META_JSON:-$MULTI_RIR_OUT_DIR/${RIR}_delta_meta.json}"
DELTA_RUN_LOG="${DELTA_RUN_LOG:-$MULTI_RIR_OUT_DIR/${RIR}_delta_run.log}"
DELTA_REPORT_JSON="${DELTA_REPORT_JSON:-$MULTI_RIR_OUT_DIR/${RIR}_delta_report.json}"
DELTA_VRPS_CSV="${DELTA_VRPS_CSV:-$MULTI_RIR_OUT_DIR/${RIR}_delta_vrps.csv}"
DELTA_ONLY_OURS="${DELTA_ONLY_OURS:-$MULTI_RIR_OUT_DIR/${RIR}_delta_only_in_ours.csv}"
DELTA_ONLY_RECORD="${DELTA_ONLY_RECORD:-$MULTI_RIR_OUT_DIR/${RIR}_delta_only_in_record.csv}"
CASE_REPORT_JSON="${CASE_REPORT_JSON:-$MULTI_RIR_OUT_DIR/${RIR}_case_report.json}"
CASE_REPORT_MD="${CASE_REPORT_MD:-$MULTI_RIR_OUT_DIR/${RIR}_case_report.md}"
case "$MODE" in
describe)
python3 "$CASE_INFO_SCRIPT" --bundle-root "$BUNDLE_ROOT" --rir "$RIR"
;;
snapshot)
rm -rf "$SNAPSHOT_DB_DIR"
ROUTINATOR_RECORD_CSV="$ROUTINATOR_BASE_RECORD_CSV" \
VALIDATION_TIME="$SNAPSHOT_VALIDATION_TIME" \
OUT_DIR="$MULTI_RIR_OUT_DIR" \
DB_DIR="$SNAPSHOT_DB_DIR" \
RUN_NAME="${RUN_NAME:-${RIR}_snapshot_replay}" \
META_JSON="$SNAPSHOT_META_JSON" \
RUN_LOG="$SNAPSHOT_RUN_LOG" \
REPORT_JSON="$SNAPSHOT_REPORT_JSON" \
VRPS_CSV="$SNAPSHOT_VRPS_CSV" \
COMPARE_SUMMARY_MD="$SNAPSHOT_REPORT_MD" \
ONLY_IN_OURS_CSV="$SNAPSHOT_ONLY_OURS" \
ONLY_IN_RECORD_CSV="$SNAPSHOT_ONLY_RECORD" \
./scripts/payload_replay/run_apnic_replay.sh
;;
delta)
rm -rf "$DELTA_DB_DIR"
ROUTINATOR_RECORD_CSV="$ROUTINATOR_DELTA_RECORD_CSV" \
VALIDATION_TIME="$DELTA_VALIDATION_TIME" \
PAYLOAD_BASE_VALIDATION_TIME="$SNAPSHOT_VALIDATION_TIME" \
OUT_DIR="$MULTI_RIR_OUT_DIR" \
DB_DIR="$DELTA_DB_DIR" \
RUN_NAME="${RUN_NAME:-${RIR}_delta_replay}" \
DELTA_ROOT="$RIR_ROOT" \
META_JSON="$DELTA_META_JSON" \
RUN_LOG="$DELTA_RUN_LOG" \
REPORT_JSON="$DELTA_REPORT_JSON" \
VRPS_CSV="$DELTA_VRPS_CSV" \
COMPARE_SUMMARY_MD="$DELTA_REPORT_MD" \
ONLY_IN_OURS_CSV="$DELTA_ONLY_OURS" \
ONLY_IN_RECORD_CSV="$DELTA_ONLY_RECORD" \
./scripts/payload_replay/run_apnic_delta_replay.sh
;;
both)
rm -rf "$SNAPSHOT_DB_DIR" "$DELTA_DB_DIR"
ROUTINATOR_RECORD_CSV="$ROUTINATOR_BASE_RECORD_CSV" \
VALIDATION_TIME="$SNAPSHOT_VALIDATION_TIME" \
OUT_DIR="$MULTI_RIR_OUT_DIR" \
DB_DIR="$SNAPSHOT_DB_DIR" \
RUN_NAME="${RUN_NAME_SNAPSHOT:-${RIR}_snapshot_replay}" \
META_JSON="$SNAPSHOT_META_JSON" \
RUN_LOG="$SNAPSHOT_RUN_LOG" \
REPORT_JSON="$SNAPSHOT_REPORT_JSON" \
VRPS_CSV="$SNAPSHOT_VRPS_CSV" \
COMPARE_SUMMARY_MD="$SNAPSHOT_REPORT_MD" \
ONLY_IN_OURS_CSV="$SNAPSHOT_ONLY_OURS" \
ONLY_IN_RECORD_CSV="$SNAPSHOT_ONLY_RECORD" \
./scripts/payload_replay/run_apnic_replay.sh
ROUTINATOR_RECORD_CSV="$ROUTINATOR_DELTA_RECORD_CSV" \
VALIDATION_TIME="$DELTA_VALIDATION_TIME" \
PAYLOAD_BASE_VALIDATION_TIME="$SNAPSHOT_VALIDATION_TIME" \
OUT_DIR="$MULTI_RIR_OUT_DIR" \
DB_DIR="$DELTA_DB_DIR" \
RUN_NAME="${RUN_NAME_DELTA:-${RIR}_delta_replay}" \
DELTA_ROOT="$RIR_ROOT" \
META_JSON="$DELTA_META_JSON" \
RUN_LOG="$DELTA_RUN_LOG" \
REPORT_JSON="$DELTA_REPORT_JSON" \
VRPS_CSV="$DELTA_VRPS_CSV" \
COMPARE_SUMMARY_MD="$DELTA_REPORT_MD" \
ONLY_IN_OURS_CSV="$DELTA_ONLY_OURS" \
ONLY_IN_RECORD_CSV="$DELTA_ONLY_RECORD" \
./scripts/payload_replay/run_apnic_delta_replay.sh
python3 "$CASE_REPORT_SCRIPT" \
--rir "$RIR" \
--snapshot-meta "$SNAPSHOT_META_JSON" \
--snapshot-compare "$SNAPSHOT_REPORT_MD" \
--delta-meta "$DELTA_META_JSON" \
--delta-compare "$DELTA_REPORT_MD" \
--routinator-base-seconds "$ROUTINATOR_BASE_REPLAY_SECONDS" \
--routinator-delta-seconds "$ROUTINATOR_DELTA_REPLAY_SECONDS" \
--out-md "$CASE_REPORT_MD" \
--out-json "$CASE_REPORT_JSON" >/dev/null
echo "- case report: $CASE_REPORT_MD" >&2
echo "- case report json: $CASE_REPORT_JSON" >&2
;;
*)
echo "unsupported mode: $MODE; expected describe|snapshot|delta|both" >&2
exit 2
;;
esac

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
BUNDLE_ROOT="${BUNDLE_ROOT:-$ROOT_DIR/../../rpki/target/live/20260316-112341-multi-final3}"
SUITE_OUT_DIR="${SUITE_OUT_DIR:-$ROOT_DIR/target/live/multi_rir_replay_runs}"
RIRS="${RIRS:-afrinic apnic arin lacnic ripe}"
CASE_SCRIPT="$ROOT_DIR/scripts/payload_replay/run_multi_rir_replay_case.sh"
SUMMARY_SCRIPT="$ROOT_DIR/scripts/payload_replay/write_multi_rir_summary.py"
mkdir -p "$SUITE_OUT_DIR"
for rir in $RIRS; do
MULTI_RIR_OUT_DIR="$SUITE_OUT_DIR/$rir" \
BUNDLE_ROOT="$BUNDLE_ROOT" \
"$CASE_SCRIPT" "$rir" both
echo "completed $rir" >&2
echo >&2
done
python3 "$SUMMARY_SCRIPT" \
--case-root "$SUITE_OUT_DIR" \
--out-md "$SUITE_OUT_DIR/multi_rir_summary.md" \
--out-json "$SUITE_OUT_DIR/multi_rir_summary.json" \
--rirs $RIRS >/dev/null
echo "== multi-RIR replay suite complete ==" >&2
echo "- suite_out_dir: $SUITE_OUT_DIR" >&2
echo "- summary_md: $SUITE_OUT_DIR/multi_rir_summary.md" >&2
echo "- summary_json: $SUITE_OUT_DIR/multi_rir_summary.json" >&2

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Generate one multi-RIR replay case report")
p.add_argument("--rir", required=True)
p.add_argument("--snapshot-meta", required=True)
p.add_argument("--snapshot-compare", required=True)
p.add_argument("--delta-meta", required=True)
p.add_argument("--delta-compare", required=True)
p.add_argument("--routinator-base-seconds", required=True, type=float)
p.add_argument("--routinator-delta-seconds", required=True, type=float)
p.add_argument("--out-md", required=True)
p.add_argument("--out-json", required=True)
return p.parse_args()
def read_json(path: str) -> dict:
return json.loads(Path(path).read_text(encoding="utf-8"))
def parse_compare_md(path: str) -> dict:
lines = Path(path).read_text(encoding="utf-8").splitlines()
out = {}
for line in lines:
if not line.startswith("| "):
continue
parts = [p.strip() for p in line.strip("|").split("|")]
if len(parts) != 2:
continue
key, value = parts
if key in {"metric", "---"}:
continue
try:
out[key] = int(value)
except ValueError:
pass
return out
def ratio(ours: float, baseline: float) -> float | None:
if baseline <= 0:
return None
return ours / baseline
def build_report(args: argparse.Namespace) -> dict:
snapshot_meta = read_json(args.snapshot_meta)
delta_meta = read_json(args.delta_meta)
snapshot_compare = parse_compare_md(args.snapshot_compare)
delta_compare = parse_compare_md(args.delta_compare)
snapshot_ours = float(snapshot_meta["durations_secs"]["rpki_run"])
delta_ours = float(delta_meta["durations_secs"]["rpki_run"])
report = {
"rir": args.rir,
"snapshot": {
"meta_json": str(Path(args.snapshot_meta).resolve()),
"compare_md": str(Path(args.snapshot_compare).resolve()),
"ours_seconds": snapshot_ours,
"routinator_seconds": args.routinator_base_seconds,
"ratio": ratio(snapshot_ours, args.routinator_base_seconds),
"compare": snapshot_compare,
"match": snapshot_compare.get("only_in_ours", -1) == 0
and snapshot_compare.get("only_in_record", -1) == 0,
"counts": snapshot_meta.get("counts", {}),
},
"delta": {
"meta_json": str(Path(args.delta_meta).resolve()),
"compare_md": str(Path(args.delta_compare).resolve()),
"ours_seconds": delta_ours,
"routinator_seconds": args.routinator_delta_seconds,
"ratio": ratio(delta_ours, args.routinator_delta_seconds),
"compare": delta_compare,
"match": delta_compare.get("only_in_ours", -1) == 0
and delta_compare.get("only_in_record", -1) == 0,
"counts": delta_meta.get("counts", {}),
},
}
return report
def write_md(path: Path, report: dict) -> None:
snapshot = report["snapshot"]
delta = report["delta"]
lines = []
lines.append(f"# {report['rir'].upper()} Replay Report\n\n")
lines.append("## Summary\n\n")
lines.append("| mode | match | ours_s | routinator_s | ratio | only_in_ours | only_in_record |\n")
lines.append("|---|---|---:|---:|---:|---:|---:|\n")
lines.append(
f"| snapshot | {str(snapshot['match']).lower()} | {snapshot['ours_seconds']:.3f} | {snapshot['routinator_seconds']:.3f} | {snapshot['ratio']:.3f} | {snapshot['compare'].get('only_in_ours', 0)} | {snapshot['compare'].get('only_in_record', 0)} |\n"
)
lines.append(
f"| delta | {str(delta['match']).lower()} | {delta['ours_seconds']:.3f} | {delta['routinator_seconds']:.3f} | {delta['ratio']:.3f} | {delta['compare'].get('only_in_ours', 0)} | {delta['compare'].get('only_in_record', 0)} |\n"
)
lines.append("\n## Snapshot Inputs\n\n")
lines.append(f"- meta_json: `{snapshot['meta_json']}`\n")
lines.append(f"- compare_md: `{snapshot['compare_md']}`\n")
lines.append("\n## Delta Inputs\n\n")
lines.append(f"- meta_json: `{delta['meta_json']}`\n")
lines.append(f"- compare_md: `{delta['compare_md']}`\n")
lines.append("\n## Counts\n\n")
lines.append("### Snapshot\n\n")
for k, v in sorted(snapshot.get("counts", {}).items()):
lines.append(f"- {k}: `{v}`\n")
lines.append("\n### Delta\n\n")
for k, v in sorted(delta.get("counts", {}).items()):
lines.append(f"- {k}: `{v}`\n")
path.write_text("".join(lines), encoding="utf-8")
def main() -> int:
args = parse_args()
report = build_report(args)
out_json = Path(args.out_json)
out_md = Path(args.out_md)
out_json.parent.mkdir(parents=True, exist_ok=True)
out_md.parent.mkdir(parents=True, exist_ok=True)
out_json.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
write_md(out_md, report)
print(out_md)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
DEFAULT_RIRS = ["afrinic", "apnic", "arin", "lacnic", "ripe"]
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Aggregate per-RIR replay case reports")
p.add_argument("--case-root", required=True, help="directory containing <rir>/<rir>_case_report.json")
p.add_argument("--out-md", required=True)
p.add_argument("--out-json", required=True)
p.add_argument("--rirs", nargs="*", default=None, help="RIRs to include (default: all 5)")
return p.parse_args()
def read_case(case_root: Path, rir: str) -> dict:
path = case_root / rir / f"{rir}_case_report.json"
return json.loads(path.read_text(encoding="utf-8"))
def geomean(values: list[float]) -> float:
vals = [v for v in values if v > 0]
if not vals:
return 0.0
return math.exp(sum(math.log(v) for v in vals) / len(vals))
def build_summary(cases: list[dict]) -> dict:
snapshot_ratios = [c["snapshot"]["ratio"] for c in cases]
delta_ratios = [c["delta"]["ratio"] for c in cases]
return {
"cases": cases,
"summary": {
"snapshot_all_match": all(c["snapshot"]["match"] for c in cases),
"delta_all_match": all(c["delta"]["match"] for c in cases),
"snapshot_ratio_geomean": geomean(snapshot_ratios),
"delta_ratio_geomean": geomean(delta_ratios),
"all_ratio_geomean": geomean(snapshot_ratios + delta_ratios),
},
}
def write_md(path: Path, data: dict) -> None:
lines = []
lines.append("# Multi-RIR Replay Summary\n\n")
lines.append("## Correctness + Timing\n\n")
lines.append("| RIR | snapshot_match | snapshot_ours_s | snapshot_routinator_s | snapshot_ratio | delta_match | delta_ours_s | delta_routinator_s | delta_ratio |\n")
lines.append("|---|---|---:|---:|---:|---|---:|---:|---:|\n")
for case in data["cases"]:
lines.append(
f"| {case['rir']} | {str(case['snapshot']['match']).lower()} | {case['snapshot']['ours_seconds']:.3f} | {case['snapshot']['routinator_seconds']:.3f} | {case['snapshot']['ratio']:.3f} | {str(case['delta']['match']).lower()} | {case['delta']['ours_seconds']:.3f} | {case['delta']['routinator_seconds']:.3f} | {case['delta']['ratio']:.3f} |\n"
)
s = data["summary"]
lines.append("\n## Aggregate Metrics\n\n")
lines.append("| metric | value |\n")
lines.append("|---|---:|\n")
lines.append(f"| snapshot_all_match | {str(s['snapshot_all_match']).lower()} |\n")
lines.append(f"| delta_all_match | {str(s['delta_all_match']).lower()} |\n")
lines.append(f"| snapshot_ratio_geomean | {s['snapshot_ratio_geomean']:.3f} |\n")
lines.append(f"| delta_ratio_geomean | {s['delta_ratio_geomean']:.3f} |\n")
lines.append(f"| all_ratio_geomean | {s['all_ratio_geomean']:.3f} |\n")
path.write_text("".join(lines), encoding="utf-8")
def main() -> int:
args = parse_args()
case_root = Path(args.case_root)
rirs = args.rirs or DEFAULT_RIRS
cases = [read_case(case_root, rir) for rir in rirs]
data = build_summary(cases)
out_md = Path(args.out_md)
out_json = Path(args.out_json)
out_md.parent.mkdir(parents=True, exist_ok=True)
out_json.parent.mkdir(parents=True, exist_ok=True)
out_json.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
write_md(out_md, data)
print(out_md)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/periodic/compare_ccr_round.sh \
--ours-ccr <path> \
--rpki-client-ccr <path> \
--out-dir <path> \
[--trust-anchor <name>]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OURS_CCR=""
CLIENT_CCR=""
OUT_DIR=""
TRUST_ANCHOR="unknown"
CCR_TO_COMPARE_VIEWS_BIN="$ROOT_DIR/target/release/ccr_to_compare_views"
while [[ $# -gt 0 ]]; do
case "$1" in
--ours-ccr) OURS_CCR="$2"; shift 2 ;;
--rpki-client-ccr) CLIENT_CCR="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--trust-anchor) TRUST_ANCHOR="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$OURS_CCR" && -n "$CLIENT_CCR" && -n "$OUT_DIR" ]] || { usage >&2; exit 2; }
mkdir -p "$OUT_DIR"
if [[ ! -x "$CCR_TO_COMPARE_VIEWS_BIN" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin ccr_to_compare_views
)
fi
OURS_VRPS="$OUT_DIR/ours-vrps.csv"
OURS_VAPS="$OUT_DIR/ours-vaps.csv"
CLIENT_VRPS="$OUT_DIR/rpki-client-vrps.csv"
CLIENT_VAPS="$OUT_DIR/rpki-client-vaps.csv"
SUMMARY_JSON="$OUT_DIR/compare-summary.json"
SUMMARY_MD="$OUT_DIR/compare-summary.md"
"$CCR_TO_COMPARE_VIEWS_BIN" \
--ccr "$OURS_CCR" \
--vrps-out "$OURS_VRPS" \
--vaps-out "$OURS_VAPS" \
--trust-anchor "$TRUST_ANCHOR"
"$CCR_TO_COMPARE_VIEWS_BIN" \
--ccr "$CLIENT_CCR" \
--vrps-out "$CLIENT_VRPS" \
--vaps-out "$CLIENT_VAPS" \
--trust-anchor "$TRUST_ANCHOR"
python3 - <<'PY' "$OURS_VRPS" "$CLIENT_VRPS" "$OURS_VAPS" "$CLIENT_VAPS" "$SUMMARY_JSON" "$SUMMARY_MD"
import csv
import json
import sys
from pathlib import Path
ours_vrps_path, client_vrps_path, ours_vaps_path, client_vaps_path, json_out, md_out = sys.argv[1:]
def rows(path):
with open(path, newline="") as f:
return list(csv.reader(f))[1:]
ours_vrps = {tuple(r) for r in rows(ours_vrps_path)}
client_vrps = {tuple(r) for r in rows(client_vrps_path)}
ours_vaps = {tuple(r) for r in rows(ours_vaps_path)}
client_vaps = {tuple(r) for r in rows(client_vaps_path)}
summary = {
"vrps": {
"ours": len(ours_vrps),
"rpkiClient": len(client_vrps),
"match": ours_vrps == client_vrps,
"onlyInOurs": sorted(ours_vrps - client_vrps)[:20],
"onlyInRpkiClient": sorted(client_vrps - ours_vrps)[:20],
},
"vaps": {
"ours": len(ours_vaps),
"rpkiClient": len(client_vaps),
"match": ours_vaps == client_vaps,
"onlyInOurs": sorted(ours_vaps - client_vaps)[:20],
"onlyInRpkiClient": sorted(client_vaps - ours_vaps)[:20],
},
}
summary["allMatch"] = summary["vrps"]["match"] and summary["vaps"]["match"]
Path(json_out).write_text(json.dumps(summary, indent=2), encoding="utf-8")
lines = [
"# Round Compare Summary",
"",
f"- `allMatch`: `{summary['allMatch']}`",
f"- `vrpMatch`: `{summary['vrps']['match']}`",
f"- `vapMatch`: `{summary['vaps']['match']}`",
f"- `ours_vrps`: `{summary['vrps']['ours']}`",
f"- `rpki_client_vrps`: `{summary['vrps']['rpkiClient']}`",
f"- `ours_vaps`: `{summary['vaps']['ours']}`",
f"- `rpki_client_vaps`: `{summary['vaps']['rpkiClient']}`",
"",
"## Sample Differences",
"",
f"- `vrps.onlyInOurs`: `{len(summary['vrps']['onlyInOurs'])}`",
f"- `vrps.onlyInRpkiClient`: `{len(summary['vrps']['onlyInRpkiClient'])}`",
f"- `vaps.onlyInOurs`: `{len(summary['vaps']['onlyInOurs'])}`",
f"- `vaps.onlyInRpkiClient`: `{len(summary['vaps']['onlyInRpkiClient'])}`",
]
Path(md_out).write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
echo "$OUT_DIR"

View File

@@ -0,0 +1,317 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/periodic/run_arin_dual_rp_periodic_ccr_compare.sh \
--run-root <path> \
[--ssh-target <user@host>] \
[--remote-root <path>] \
[--rpki-client-bin <path>] \
[--round-count <n>] \
[--interval-secs <n>] \
[--start-at <RFC3339>] \
[--dry-run]
M1 behavior:
- creates the periodic run skeleton
- writes per-round scheduling metadata
- does not execute RP binaries yet
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_ROOT=""
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
RPKI_CLIENT_BIN="${RPKI_CLIENT_BIN:-/home/yuyr/dev/rpki-client-9.7/build-m5/src/rpki-client}"
ROUND_COUNT=10
INTERVAL_SECS=600
START_AT=""
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--run-root) RUN_ROOT="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--round-count) ROUND_COUNT="$2"; shift 2 ;;
--interval-secs) INTERVAL_SECS="$2"; shift 2 ;;
--start-at) START_AT="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift 1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$RUN_ROOT" ]] || { usage >&2; exit 2; }
[[ "$ROUND_COUNT" =~ ^[0-9]+$ ]] || { echo "--round-count must be an integer" >&2; exit 2; }
[[ "$INTERVAL_SECS" =~ ^[0-9]+$ ]] || { echo "--interval-secs must be an integer" >&2; exit 2; }
if [[ "$DRY_RUN" -ne 1 ]]; then
[[ -n "$REMOTE_ROOT" ]] || { echo "--remote-root is required unless --dry-run" >&2; exit 2; }
[[ -x "$RPKI_CLIENT_BIN" ]] || { echo "rpki-client binary not executable: $RPKI_CLIENT_BIN" >&2; exit 2; }
fi
mkdir -p "$RUN_ROOT"
python3 - <<'PY' "$RUN_ROOT" "$SSH_TARGET" "$REMOTE_ROOT" "$ROUND_COUNT" "$INTERVAL_SECS" "$START_AT" "$DRY_RUN"
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
run_root = Path(sys.argv[1]).resolve()
ssh_target = sys.argv[2]
remote_root = sys.argv[3]
round_count = int(sys.argv[4])
interval_secs = int(sys.argv[5])
start_at_arg = sys.argv[6]
dry_run = bool(int(sys.argv[7]))
def parse_rfc3339_utc(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
def fmt(dt: datetime) -> str:
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
base_time = (
parse_rfc3339_utc(start_at_arg)
if start_at_arg
else datetime.now(timezone.utc)
)
(run_root / "rounds").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "ours" / "work-db").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "ours" / "raw-store.db").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "rpki-client" / "cache").mkdir(parents=True, exist_ok=True)
(run_root / "state" / "rpki-client" / "out").mkdir(parents=True, exist_ok=True)
meta = {
"version": 1,
"rir": "arin",
"roundCount": round_count,
"intervalSecs": interval_secs,
"baseScheduledAt": fmt(base_time),
"mode": "dry_run" if dry_run else "skeleton_only",
"execution": {
"mode": "remote",
"sshTarget": ssh_target,
"remoteRoot": remote_root or None,
},
"state": {
"ours": {
"workDbPath": "state/ours/work-db",
"rawStoreDbPath": "state/ours/raw-store.db",
"remoteWorkDbPath": "state/ours/work-db",
"remoteRawStoreDbPath": "state/ours/raw-store.db",
},
"rpkiClient": {
"cachePath": "state/rpki-client/cache",
"outPath": "state/rpki-client/out",
"remoteCachePath": "state/rpki-client/cache",
"remoteOutPath": "state/rpki-client/out",
},
},
}
(run_root / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
rounds = []
for idx in range(round_count):
round_id = f"round-{idx+1:03d}"
kind = "snapshot" if idx == 0 else "delta"
scheduled_at = base_time + timedelta(seconds=interval_secs * idx)
round_dir = run_root / "rounds" / round_id
for name in ("ours", "rpki-client", "compare"):
(round_dir / name).mkdir(parents=True, exist_ok=True)
# M1 only builds the schedule skeleton, so lag is defined relative to the schedule model.
started_at = scheduled_at if dry_run else None
finished_at = scheduled_at if dry_run else None
round_meta = {
"roundId": round_id,
"kind": kind,
"scheduledAt": fmt(scheduled_at),
"startedAt": fmt(started_at) if started_at else None,
"finishedAt": fmt(finished_at) if finished_at else None,
"startLagMs": 0 if dry_run else None,
"status": "dry_run" if dry_run else "pending",
"paths": {
"ours": f"rounds/{round_id}/ours",
"rpkiClient": f"rounds/{round_id}/rpki-client",
"compare": f"rounds/{round_id}/compare",
},
}
(round_dir / "round-meta.json").write_text(
json.dumps(round_meta, indent=2), encoding="utf-8"
)
rounds.append(round_meta)
final_summary = {
"version": 1,
"status": "dry_run" if dry_run else "pending",
"roundCount": round_count,
"allMatch": None,
"rounds": rounds,
}
(run_root / "final-summary.json").write_text(
json.dumps(final_summary, indent=2), encoding="utf-8"
)
PY
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "$RUN_ROOT"
exit 0
fi
if [[ ! -x "$ROOT_DIR/target/release/rpki" || ! -x "$ROOT_DIR/target/release/ccr_to_compare_views" ]]; then
(
cd "$ROOT_DIR"
cargo build --release --bin rpki --bin ccr_to_compare_views
)
fi
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT'"
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_ROOT/repo/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT/repo/target/release' '$REMOTE_ROOT/bin' '$REMOTE_ROOT/rounds' '$REMOTE_ROOT/state/ours' '$REMOTE_ROOT/state/rpki-client'"
rsync -a "$ROOT_DIR/target/release/rpki" "$SSH_TARGET:$REMOTE_ROOT/repo/target/release/"
rsync -a "$RPKI_CLIENT_BIN" "$SSH_TARGET:$REMOTE_ROOT/bin/rpki-client"
for idx in $(seq 1 "$ROUND_COUNT"); do
ROUND_ID="$(printf 'round-%03d' "$idx")"
ROUND_DIR="$RUN_ROOT/rounds/$ROUND_ID"
SCHEDULED_AT="$(python3 - <<'PY' "$ROUND_DIR/round-meta.json"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['scheduledAt'])
PY
)"
python3 - <<'PY' "$SCHEDULED_AT"
from datetime import datetime, timezone
import sys, time
scheduled = datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")).astimezone(timezone.utc)
now = datetime.now(timezone.utc)
delay = (scheduled - now).total_seconds()
if delay > 0:
time.sleep(delay)
PY
"$ROOT_DIR/scripts/periodic/run_arin_ours_round_remote.sh" \
--run-root "$RUN_ROOT" \
--round-id "$ROUND_ID" \
--kind "$(python3 - <<'PY' "$ROUND_DIR/round-meta.json"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['kind'])
PY
)" \
--ssh-target "$SSH_TARGET" \
--remote-root "$REMOTE_ROOT" \
--scheduled-at "$SCHEDULED_AT" \
--skip-sync &
OURS_PID=$!
"$ROOT_DIR/scripts/periodic/run_arin_rpki_client_round_remote.sh" \
--run-root "$RUN_ROOT" \
--round-id "$ROUND_ID" \
--kind "$(python3 - <<'PY' "$ROUND_DIR/round-meta.json"
import json, sys
print(json.load(open(sys.argv[1], 'r', encoding='utf-8'))['kind'])
PY
)" \
--ssh-target "$SSH_TARGET" \
--remote-root "$REMOTE_ROOT" \
--scheduled-at "$SCHEDULED_AT" \
--rpki-client-bin "$RPKI_CLIENT_BIN" \
--skip-sync &
CLIENT_PID=$!
set +e
wait "$OURS_PID"
OURS_STATUS=$?
wait "$CLIENT_PID"
CLIENT_STATUS=$?
set -e
if [[ "$OURS_STATUS" -eq 0 && "$CLIENT_STATUS" -eq 0 \
&& -f "$ROUND_DIR/ours/result.ccr" && -f "$ROUND_DIR/rpki-client/result.ccr" ]]; then
"$ROOT_DIR/scripts/periodic/compare_ccr_round.sh" \
--ours-ccr "$ROUND_DIR/ours/result.ccr" \
--rpki-client-ccr "$ROUND_DIR/rpki-client/result.ccr" \
--out-dir "$ROUND_DIR/compare"
fi
python3 - <<'PY' "$ROUND_DIR/round-meta.json" "$ROUND_DIR/ours/round-result.json" "$ROUND_DIR/rpki-client/round-result.json" "$ROUND_DIR/compare/compare-summary.json"
import json, sys
from datetime import datetime, timezone
round_meta_path, ours_result_path, client_result_path, compare_path = sys.argv[1:]
meta = json.load(open(round_meta_path, 'r', encoding='utf-8'))
ours = json.load(open(ours_result_path, 'r', encoding='utf-8'))
client = json.load(open(client_result_path, 'r', encoding='utf-8'))
scheduled = datetime.fromisoformat(meta['scheduledAt'].replace('Z', '+00:00')).astimezone(timezone.utc)
started_candidates = []
for item in (ours, client):
if item.get('startedAt'):
started_candidates.append(datetime.fromisoformat(item['startedAt'].replace('Z', '+00:00')).astimezone(timezone.utc))
finished_candidates = []
for item in (ours, client):
if item.get('finishedAt'):
finished_candidates.append(datetime.fromisoformat(item['finishedAt'].replace('Z', '+00:00')).astimezone(timezone.utc))
if started_candidates:
started_at = min(started_candidates)
meta['startedAt'] = started_at.strftime('%Y-%m-%dT%H:%M:%SZ')
lag_ms = int((started_at - scheduled).total_seconds() * 1000)
meta['startLagMs'] = max(lag_ms, 0)
if finished_candidates:
finished_at = max(finished_candidates)
meta['finishedAt'] = finished_at.strftime('%Y-%m-%dT%H:%M:%SZ')
meta['status'] = 'completed' if ours.get('exitCode') == 0 and client.get('exitCode') == 0 else 'failed'
meta['ours'] = {
'exitCode': ours.get('exitCode'),
'durationMs': json.load(open(ours_result_path.replace('round-result.json', 'timing.json'), 'r', encoding='utf-8')).get('durationMs'),
}
meta['rpkiClient'] = {
'exitCode': client.get('exitCode'),
'durationMs': json.load(open(client_result_path.replace('round-result.json', 'timing.json'), 'r', encoding='utf-8')).get('durationMs'),
}
if compare_path and __import__('pathlib').Path(compare_path).exists():
compare = json.load(open(compare_path, 'r', encoding='utf-8'))
meta['compare'] = {
'allMatch': compare.get('allMatch'),
'vrpMatch': compare.get('vrps', {}).get('match'),
'vapMatch': compare.get('vaps', {}).get('match'),
}
json.dump(meta, open(round_meta_path, 'w', encoding='utf-8'), indent=2)
PY
done
python3 - <<'PY' "$RUN_ROOT/final-summary.json" "$RUN_ROOT/rounds"
import json, sys
from pathlib import Path
summary_path = Path(sys.argv[1])
rounds_root = Path(sys.argv[2])
rounds = []
all_match = True
for round_dir in sorted(rounds_root.glob('round-*')):
meta = json.load(open(round_dir / 'round-meta.json', 'r', encoding='utf-8'))
rounds.append(meta)
compare = meta.get('compare')
if compare is None or compare.get('allMatch') is not True:
all_match = False
summary = {
'version': 1,
'status': 'completed',
'roundCount': len(rounds),
'allMatch': all_match,
'rounds': rounds,
}
json.dump(summary, open(summary_path, 'w', encoding='utf-8'), indent=2)
PY
echo "$RUN_ROOT"

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/periodic/run_arin_ours_round_remote.sh \
--run-root <path> \
--round-id <round-XXX> \
--kind <snapshot|delta> \
--ssh-target <user@host> \
--remote-root <path> \
[--scheduled-at <RFC3339>] \
[--skip-sync]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_ROOT=""
ROUND_ID=""
KIND=""
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
SCHEDULED_AT=""
SKIP_SYNC=0
while [[ $# -gt 0 ]]; do
case "$1" in
--run-root) RUN_ROOT="$2"; shift 2 ;;
--round-id) ROUND_ID="$2"; shift 2 ;;
--kind) KIND="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--scheduled-at) SCHEDULED_AT="$2"; shift 2 ;;
--skip-sync) SKIP_SYNC=1; shift 1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$RUN_ROOT" && -n "$ROUND_ID" && -n "$KIND" && -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
[[ "$KIND" == "snapshot" || "$KIND" == "delta" ]] || { echo "--kind must be snapshot or delta" >&2; exit 2; }
LOCAL_OUT="$RUN_ROOT/rounds/$ROUND_ID/ours"
REMOTE_REPO="$REMOTE_ROOT/repo"
REMOTE_OUT="$REMOTE_ROOT/rounds/$ROUND_ID/ours"
REMOTE_WORK_DB="$REMOTE_ROOT/state/ours/work-db"
REMOTE_RAW_STORE="$REMOTE_ROOT/state/ours/raw-store.db"
mkdir -p "$LOCAL_OUT"
if [[ "$SKIP_SYNC" -eq 0 ]]; then
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT'"
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_REPO/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_REPO/target/release' '$REMOTE_OUT' '$REMOTE_ROOT/state/ours'"
rsync -a "$ROOT_DIR/target/release/rpki" "$SSH_TARGET:$REMOTE_REPO/target/release/"
else
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_OUT' '$REMOTE_ROOT/state/ours'"
fi
ssh "$SSH_TARGET" \
REMOTE_REPO="$REMOTE_REPO" \
REMOTE_OUT="$REMOTE_OUT" \
REMOTE_WORK_DB="$REMOTE_WORK_DB" \
REMOTE_RAW_STORE="$REMOTE_RAW_STORE" \
KIND="$KIND" \
ROUND_ID="$ROUND_ID" \
SCHEDULED_AT="$SCHEDULED_AT" \
'bash -s' <<'EOS'
set -euo pipefail
cd "$REMOTE_REPO"
mkdir -p "$REMOTE_OUT"
if [[ "$KIND" == "snapshot" ]]; then
rm -rf "$REMOTE_WORK_DB" "$REMOTE_RAW_STORE"
fi
mkdir -p "$(dirname "$REMOTE_WORK_DB")"
started_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
started_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
ccr_out="$REMOTE_OUT/result.ccr"
report_out="$REMOTE_OUT/report.json"
run_log="$REMOTE_OUT/run.log"
timing_out="$REMOTE_OUT/timing.json"
meta_out="$REMOTE_OUT/round-result.json"
set +e
env RPKI_PROGRESS_LOG=1 target/release/rpki \
--db "$REMOTE_WORK_DB" \
--raw-store-db "$REMOTE_RAW_STORE" \
--tal-path tests/fixtures/tal/arin.tal \
--ta-path tests/fixtures/ta/arin-ta.cer \
--ccr-out "$ccr_out" \
--report-json "$report_out" \
>"$run_log" 2>&1
exit_code=$?
set -e
finished_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
finished_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
python3 - <<'PY' "$timing_out" "$started_at_ms" "$finished_at_ms" "$started_at_iso" "$finished_at_iso" "$exit_code"
import json, sys
path, start_ms, end_ms, started_at, finished_at, exit_code = sys.argv[1:]
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"durationMs": int(end_ms) - int(start_ms),
"startedAt": started_at,
"finishedAt": finished_at,
"exitCode": int(exit_code),
},
fh,
indent=2,
)
PY
python3 - <<'PY' "$meta_out" "$ROUND_ID" "$KIND" "$SCHEDULED_AT" "$started_at_iso" "$finished_at_iso" "$REMOTE_WORK_DB" "$REMOTE_RAW_STORE" "$exit_code"
import json, sys
path, round_id, kind, scheduled_at, started_at, finished_at, work_db, raw_store, exit_code = sys.argv[1:]
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"roundId": round_id,
"kind": kind,
"scheduledAt": scheduled_at or None,
"startedAt": started_at,
"finishedAt": finished_at,
"remoteWorkDbPath": work_db,
"remoteRawStoreDbPath": raw_store,
"exitCode": int(exit_code),
},
fh,
indent=2,
)
PY
exit "$exit_code"
EOS
rsync -a "$SSH_TARGET:$REMOTE_OUT/" "$LOCAL_OUT/"
echo "$LOCAL_OUT"

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/periodic/run_arin_rpki_client_round_remote.sh \
--run-root <path> \
--round-id <round-XXX> \
--kind <snapshot|delta> \
--ssh-target <user@host> \
--remote-root <path> \
[--scheduled-at <RFC3339>] \
[--rpki-client-bin <local path>] \
[--skip-sync]
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_ROOT=""
ROUND_ID=""
KIND=""
SSH_TARGET="${SSH_TARGET:-root@47.77.183.68}"
REMOTE_ROOT=""
SCHEDULED_AT=""
RPKI_CLIENT_BIN="${RPKI_CLIENT_BIN:-/home/yuyr/dev/rpki-client-9.7/build-m5/src/rpki-client}"
SKIP_SYNC=0
while [[ $# -gt 0 ]]; do
case "$1" in
--run-root) RUN_ROOT="$2"; shift 2 ;;
--round-id) ROUND_ID="$2"; shift 2 ;;
--kind) KIND="$2"; shift 2 ;;
--ssh-target) SSH_TARGET="$2"; shift 2 ;;
--remote-root) REMOTE_ROOT="$2"; shift 2 ;;
--scheduled-at) SCHEDULED_AT="$2"; shift 2 ;;
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--skip-sync) SKIP_SYNC=1; shift 1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -n "$RUN_ROOT" && -n "$ROUND_ID" && -n "$KIND" && -n "$REMOTE_ROOT" ]] || { usage >&2; exit 2; }
[[ "$KIND" == "snapshot" || "$KIND" == "delta" ]] || { echo "--kind must be snapshot or delta" >&2; exit 2; }
[[ -x "$RPKI_CLIENT_BIN" ]] || { echo "rpki-client binary not executable: $RPKI_CLIENT_BIN" >&2; exit 2; }
LOCAL_OUT="$RUN_ROOT/rounds/$ROUND_ID/rpki-client"
REMOTE_REPO="$REMOTE_ROOT/repo"
REMOTE_BIN_DIR="$REMOTE_ROOT/bin"
REMOTE_BIN="$REMOTE_BIN_DIR/rpki-client"
REMOTE_OUT="$REMOTE_ROOT/rounds/$ROUND_ID/rpki-client"
REMOTE_CACHE="$REMOTE_ROOT/state/rpki-client/cache"
REMOTE_STATE_OUT="$REMOTE_ROOT/state/rpki-client/out"
REMOTE_STATE_ROOT="$REMOTE_ROOT/state/rpki-client"
mkdir -p "$LOCAL_OUT"
if [[ "$SKIP_SYNC" -eq 0 ]]; then
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_ROOT'"
rsync -a --delete \
--exclude target \
--exclude .git \
"$ROOT_DIR/" "$SSH_TARGET:$REMOTE_REPO/"
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_BIN_DIR' '$REMOTE_OUT' '$REMOTE_STATE_ROOT'"
rsync -a "$RPKI_CLIENT_BIN" "$SSH_TARGET:$REMOTE_BIN"
else
ssh "$SSH_TARGET" "mkdir -p '$REMOTE_BIN_DIR' '$REMOTE_OUT' '$REMOTE_STATE_ROOT'"
fi
ssh "$SSH_TARGET" \
REMOTE_ROOT="$REMOTE_ROOT" \
REMOTE_REPO="$REMOTE_REPO" \
REMOTE_BIN="$REMOTE_BIN" \
REMOTE_OUT="$REMOTE_OUT" \
REMOTE_CACHE="$REMOTE_CACHE" \
REMOTE_STATE_OUT="$REMOTE_STATE_OUT" \
REMOTE_STATE_ROOT="$REMOTE_STATE_ROOT" \
KIND="$KIND" \
ROUND_ID="$ROUND_ID" \
SCHEDULED_AT="$SCHEDULED_AT" \
'bash -s' <<'EOS'
set -euo pipefail
cd "$REMOTE_ROOT"
mkdir -p "$REMOTE_OUT"
if [[ "$KIND" == "snapshot" ]]; then
rm -rf "$REMOTE_CACHE" "$REMOTE_STATE_OUT" "$REMOTE_STATE_ROOT/ta" "$REMOTE_STATE_ROOT/.ta"
fi
mkdir -p "$REMOTE_CACHE" "$REMOTE_STATE_OUT"
chmod 0777 "$REMOTE_STATE_ROOT" "$REMOTE_CACHE" "$REMOTE_STATE_OUT"
started_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
started_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
ccr_out="$REMOTE_OUT/result.ccr"
run_log="$REMOTE_OUT/run.log"
timing_out="$REMOTE_OUT/timing.json"
meta_out="$REMOTE_OUT/round-result.json"
set +e
(
cd "$REMOTE_STATE_ROOT"
"$REMOTE_BIN" -vv -t "../../repo/tests/fixtures/tal/arin.tal" -d "cache" "out"
) >"$run_log" 2>&1
exit_code=$?
set -e
if [[ -f "$REMOTE_STATE_OUT/rpki.ccr" ]]; then
cp "$REMOTE_STATE_OUT/rpki.ccr" "$ccr_out"
fi
finished_at_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
finished_at_ms="$(python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
)"
python3 - <<'PY' "$timing_out" "$started_at_ms" "$finished_at_ms" "$started_at_iso" "$finished_at_iso" "$exit_code"
import json, sys
path, start_ms, end_ms, started_at, finished_at, exit_code = sys.argv[1:]
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"durationMs": int(end_ms) - int(start_ms),
"startedAt": started_at,
"finishedAt": finished_at,
"exitCode": int(exit_code),
},
fh,
indent=2,
)
PY
python3 - <<'PY' "$meta_out" "$ROUND_ID" "$KIND" "$SCHEDULED_AT" "$started_at_iso" "$finished_at_iso" "$REMOTE_CACHE" "$REMOTE_STATE_OUT" "$exit_code"
import json, sys
path, round_id, kind, scheduled_at, started_at, finished_at, cache_path, out_path, exit_code = sys.argv[1:]
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"roundId": round_id,
"kind": kind,
"scheduledAt": scheduled_at or None,
"startedAt": started_at,
"finishedAt": finished_at,
"remoteCachePath": cache_path,
"remoteOutPath": out_path,
"exitCode": int(exit_code),
},
fh,
indent=2,
)
PY
exit "$exit_code"
EOS
rsync -a "$SSH_TARGET:$REMOTE_OUT/" "$LOCAL_OUT/"
echo "$LOCAL_OUT"

View File

@@ -0,0 +1,129 @@
# Live Bundle Record
`run_live_bundle_record.sh` 是当前 `ours` 的单命令 live bundle 录制入口。
它做三件事:
1. 联网执行 **live base recorder**
2. 基于刚录制的 base bundle 执行 **live delta recorder**
3. 产出一个统一的最终目录,包含:
- `base-payload-archive/`
- `payload-delta-archive/`
- `base-locks.json`
- `locks-delta.json`
- `tal.tal`
- `ta.cer`
- `base.ccr`
- `delta.ccr`
- `base-vrps.csv`
- `base-vaps.csv`
- `record-delta.csv`
- `record-delta-vaps.csv`
- `bundle.json`
- `verification.json`
- `timings/`
## 用法
```bash
cd rpki
./scripts/replay_bundle/run_live_bundle_record.sh \
--rir apnic \
--tal-path tests/fixtures/tal/apnic-rfc7730-https.tal \
--ta-path tests/fixtures/ta/apnic-ta.cer
```
默认输出目录:
```text
target/replay/<rir>_live_bundle_<timestamp>
```
如果要一次录制多个 RIR,使用:
```bash
cd rpki
./scripts/replay_bundle/run_live_bundle_record_multi_rir.sh \
--rir afrinic,apnic,arin,lacnic,ripe
```
默认输出目录:
```text
target/replay/live_bundle_matrix_<timestamp>
```
每个 RIR 会落到:
```text
target/replay/live_bundle_matrix_<timestamp>/<rir>_live_bundle_<timestamp>
```
如果要录制单个 RIR 的 `1 base + N delta` 序列,使用:
```bash
cd rpki
./scripts/replay_bundle/run_live_bundle_record_sequence.sh \
--rir apnic \
--tal-path tests/fixtures/tal/apnic-rfc7730-https.tal \
--ta-path tests/fixtures/ta/apnic-ta.cer \
--delta-count 2 \
--delta-interval-secs 0
```
默认输出目录:
```text
target/replay/<rir>_live_bundle_sequence_<timestamp>
```
如果要一次录制多个 RIR 的 `1 base + N delta` 序列,使用:
```bash
cd rpki
./scripts/replay_bundle/run_live_bundle_record_multi_rir_sequence.sh \
--rir afrinic,apnic,arin,lacnic,ripe
```
默认输出目录:
```text
target/replay/live_bundle_sequence_matrix_<timestamp>
```
## 可选参数
- `--out-dir <path>`
- `--base-validation-time <rfc3339>`
- `--delta-validation-time <rfc3339>`
- `--http-timeout-secs <n>`
- `--rsync-timeout-secs <n>`
- `--rsync-mirror-root <path>`
- `--max-depth <n>`
- `--max-instances <n>`
- `--trust-anchor <name>`
- `--bin-dir <path>`
- `--no-build`
- `--delta-count <n>`(sequence 入口)
- `--delta-interval-secs <n>`(sequence 入口)
- `--keep-db`(sequence 入口)
`run_live_bundle_record_multi_rir.sh` 会自动按 RIR 选择当前仓库内置的:
- `tests/fixtures/tal/*.tal`
- `tests/fixtures/ta/*.cer`
并将 `--trust-anchor` 设置为对应 RIR 名称。
## 说明
- 该脚本会先构建:
- `replay_bundle_capture`
- `replay_bundle_capture_delta`
- 如果提供 `--no-build`,则直接复用:
- `--bin-dir <path>` 下的现有二进制
- 中间 staging 目录:
- `<out>.stage-base`
- `<out>.stage-delta`
在成功完成后会清理,只保留最终输出目录。
- 最终输出目录是 **delta 阶段产物**,其中已经包含 base 阶段结果。

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
RIR=""
OUT_DIR=""
TAL_PATH=""
TA_PATH=""
BASE_VALIDATION_TIME=""
DELTA_VALIDATION_TIME=""
HTTP_TIMEOUT_SECS=""
RSYNC_TIMEOUT_SECS=""
RSYNC_MIRROR_ROOT=""
MAX_DEPTH=""
MAX_INSTANCES=""
TRUST_ANCHOR=""
NO_BUILD=0
BIN_DIR="target/release"
usage() {
cat <<'EOF'
Usage:
./scripts/replay_bundle/run_live_bundle_record.sh \
--rir <name> \
--tal-path <path> \
--ta-path <path> \
[--out-dir <path>] \
[--base-validation-time <rfc3339>] \
[--delta-validation-time <rfc3339>] \
[--http-timeout-secs <n>] \
[--rsync-timeout-secs <n>] \
[--rsync-mirror-root <path>] \
[--max-depth <n>] \
[--max-instances <n>] \
[--trust-anchor <name>] \
[--bin-dir <path>] \
[--no-build]
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--rir) RIR="${2:?}"; shift 2 ;;
--out-dir) OUT_DIR="${2:?}"; shift 2 ;;
--tal-path) TAL_PATH="${2:?}"; shift 2 ;;
--ta-path) TA_PATH="${2:?}"; shift 2 ;;
--base-validation-time) BASE_VALIDATION_TIME="${2:?}"; shift 2 ;;
--delta-validation-time) DELTA_VALIDATION_TIME="${2:?}"; shift 2 ;;
--http-timeout-secs) HTTP_TIMEOUT_SECS="${2:?}"; shift 2 ;;
--rsync-timeout-secs) RSYNC_TIMEOUT_SECS="${2:?}"; shift 2 ;;
--rsync-mirror-root) RSYNC_MIRROR_ROOT="${2:?}"; shift 2 ;;
--max-depth) MAX_DEPTH="${2:?}"; shift 2 ;;
--max-instances) MAX_INSTANCES="${2:?}"; shift 2 ;;
--trust-anchor) TRUST_ANCHOR="${2:?}"; shift 2 ;;
--bin-dir) BIN_DIR="${2:?}"; shift 2 ;;
--no-build) NO_BUILD=1; shift ;;
--help|-h) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$RIR" || -z "$TAL_PATH" || -z "$TA_PATH" ]]; then
usage >&2
exit 2
fi
TS="$(date -u +%Y%m%dT%H%M%SZ)"
if [[ -z "$OUT_DIR" ]]; then
OUT_DIR="target/replay/${RIR}_live_bundle_${TS}"
fi
STAGE_BASE="${OUT_DIR}.stage-base"
STAGE_DELTA="${OUT_DIR}.stage-delta"
rm -rf "$OUT_DIR" "$STAGE_BASE" "$STAGE_DELTA"
mkdir -p "$(dirname "$OUT_DIR")"
CAPTURE_BIN="$BIN_DIR/replay_bundle_capture"
DELTA_CAPTURE_BIN="$BIN_DIR/replay_bundle_capture_delta"
if [[ "$NO_BUILD" -eq 0 ]]; then
echo "[1/3] build release binaries"
cargo build --release --bin replay_bundle_capture --bin replay_bundle_capture_delta
else
echo "[1/3] reuse existing binaries from $BIN_DIR"
fi
if [[ ! -x "$CAPTURE_BIN" ]]; then
echo "missing executable: $CAPTURE_BIN" >&2
exit 1
fi
if [[ ! -x "$DELTA_CAPTURE_BIN" ]]; then
echo "missing executable: $DELTA_CAPTURE_BIN" >&2
exit 1
fi
echo "[2/3] record live base bundle into $STAGE_BASE"
BASE_CMD=(
"$CAPTURE_BIN"
--rir "$RIR"
--out-dir "$STAGE_BASE"
--tal-path "$TAL_PATH"
--ta-path "$TA_PATH"
)
[[ -n "$BASE_VALIDATION_TIME" ]] && BASE_CMD+=(--validation-time "$BASE_VALIDATION_TIME")
[[ -n "$HTTP_TIMEOUT_SECS" ]] && BASE_CMD+=(--http-timeout-secs "$HTTP_TIMEOUT_SECS")
[[ -n "$RSYNC_TIMEOUT_SECS" ]] && BASE_CMD+=(--rsync-timeout-secs "$RSYNC_TIMEOUT_SECS")
[[ -n "$RSYNC_MIRROR_ROOT" ]] && BASE_CMD+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
[[ -n "$MAX_DEPTH" ]] && BASE_CMD+=(--max-depth "$MAX_DEPTH")
[[ -n "$MAX_INSTANCES" ]] && BASE_CMD+=(--max-instances "$MAX_INSTANCES")
[[ -n "$TRUST_ANCHOR" ]] && BASE_CMD+=(--trust-anchor "$TRUST_ANCHOR")
"${BASE_CMD[@]}"
echo "[3/3] record live delta bundle into $STAGE_DELTA"
DELTA_CMD=(
"$DELTA_CAPTURE_BIN"
--rir "$RIR"
--base-bundle-dir "$STAGE_BASE"
--out-dir "$STAGE_DELTA"
)
[[ -n "$DELTA_VALIDATION_TIME" ]] && DELTA_CMD+=(--validation-time "$DELTA_VALIDATION_TIME")
[[ -n "$HTTP_TIMEOUT_SECS" ]] && DELTA_CMD+=(--http-timeout-secs "$HTTP_TIMEOUT_SECS")
[[ -n "$RSYNC_TIMEOUT_SECS" ]] && DELTA_CMD+=(--rsync-timeout-secs "$RSYNC_TIMEOUT_SECS")
[[ -n "$RSYNC_MIRROR_ROOT" ]] && DELTA_CMD+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
[[ -n "$MAX_DEPTH" ]] && DELTA_CMD+=(--max-depth "$MAX_DEPTH")
[[ -n "$MAX_INSTANCES" ]] && DELTA_CMD+=(--max-instances "$MAX_INSTANCES")
[[ -n "$TRUST_ANCHOR" ]] && DELTA_CMD+=(--trust-anchor "$TRUST_ANCHOR")
"${DELTA_CMD[@]}"
mv "$STAGE_DELTA" "$OUT_DIR"
rm -rf "$STAGE_BASE"
echo "$OUT_DIR"

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
RIRS=""
OUT_ROOT=""
BASE_VALIDATION_TIME=""
DELTA_VALIDATION_TIME=""
HTTP_TIMEOUT_SECS=""
RSYNC_TIMEOUT_SECS=""
RSYNC_MIRROR_ROOT=""
MAX_DEPTH=""
MAX_INSTANCES=""
NO_BUILD=0
BIN_DIR="target/release"
usage() {
cat <<'EOF'
Usage:
./scripts/replay_bundle/run_live_bundle_record_multi_rir.sh \
--rir <afrinic,apnic,...> \
[--out-root <path>] \
[--base-validation-time <rfc3339>] \
[--delta-validation-time <rfc3339>] \
[--http-timeout-secs <n>] \
[--rsync-timeout-secs <n>] \
[--rsync-mirror-root <path>] \
[--max-depth <n>] \
[--max-instances <n>] \
[--bin-dir <path>] \
[--no-build]
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--rir) RIRS="${2:?}"; shift 2 ;;
--out-root) OUT_ROOT="${2:?}"; shift 2 ;;
--base-validation-time) BASE_VALIDATION_TIME="${2:?}"; shift 2 ;;
--delta-validation-time) DELTA_VALIDATION_TIME="${2:?}"; shift 2 ;;
--http-timeout-secs) HTTP_TIMEOUT_SECS="${2:?}"; shift 2 ;;
--rsync-timeout-secs) RSYNC_TIMEOUT_SECS="${2:?}"; shift 2 ;;
--rsync-mirror-root) RSYNC_MIRROR_ROOT="${2:?}"; shift 2 ;;
--max-depth) MAX_DEPTH="${2:?}"; shift 2 ;;
--max-instances) MAX_INSTANCES="${2:?}"; shift 2 ;;
--bin-dir) BIN_DIR="${2:?}"; shift 2 ;;
--no-build) NO_BUILD=1; shift ;;
--help|-h) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$RIRS" ]]; then
usage >&2
exit 2
fi
RUN_TAG="$(date -u +%Y%m%dT%H%M%SZ)"
if [[ -z "$OUT_ROOT" ]]; then
OUT_ROOT="target/replay/live_bundle_matrix_${RUN_TAG}"
fi
mkdir -p "$OUT_ROOT"
resolve_tal_path() {
case "$1" in
afrinic) printf 'tests/fixtures/tal/afrinic.tal' ;;
apnic) printf 'tests/fixtures/tal/apnic-rfc7730-https.tal' ;;
arin) printf 'tests/fixtures/tal/arin.tal' ;;
lacnic) printf 'tests/fixtures/tal/lacnic.tal' ;;
ripe) printf 'tests/fixtures/tal/ripe-ncc.tal' ;;
*) echo "unsupported rir: $1" >&2; exit 2 ;;
esac
}
resolve_ta_path() {
case "$1" in
afrinic) printf 'tests/fixtures/ta/afrinic-ta.cer' ;;
apnic) printf 'tests/fixtures/ta/apnic-ta.cer' ;;
arin) printf 'tests/fixtures/ta/arin-ta.cer' ;;
lacnic) printf 'tests/fixtures/ta/lacnic-ta.cer' ;;
ripe) printf 'tests/fixtures/ta/ripe-ncc-ta.cer' ;;
*) echo "unsupported rir: $1" >&2; exit 2 ;;
esac
}
SUMMARY_JSON="$OUT_ROOT/summary.json"
SUMMARY_MD="$OUT_ROOT/summary.md"
python3 - "$SUMMARY_JSON" "$RUN_TAG" <<'PY'
import json, sys
out, run_tag = sys.argv[1:]
with open(out, "w") as fh:
json.dump({"runTag": run_tag, "results": []}, fh, indent=2)
PY
IFS=',' read -r -a RIR_LIST <<< "$RIRS"
for raw_rir in "${RIR_LIST[@]}"; do
rir="$(printf '%s' "$raw_rir" | tr '[:upper:]' '[:lower:]' | xargs)"
[[ -n "$rir" ]] || continue
tal_path="$(resolve_tal_path "$rir")"
ta_path="$(resolve_ta_path "$rir")"
out_dir="$OUT_ROOT/${rir}_live_bundle_${RUN_TAG}"
cmd=(
./scripts/replay_bundle/run_live_bundle_record.sh
--rir "$rir"
--out-dir "$out_dir"
--tal-path "$tal_path"
--ta-path "$ta_path"
--trust-anchor "$rir"
--bin-dir "$BIN_DIR"
)
[[ -n "$BASE_VALIDATION_TIME" ]] && cmd+=(--base-validation-time "$BASE_VALIDATION_TIME")
[[ -n "$DELTA_VALIDATION_TIME" ]] && cmd+=(--delta-validation-time "$DELTA_VALIDATION_TIME")
[[ -n "$HTTP_TIMEOUT_SECS" ]] && cmd+=(--http-timeout-secs "$HTTP_TIMEOUT_SECS")
[[ -n "$RSYNC_TIMEOUT_SECS" ]] && cmd+=(--rsync-timeout-secs "$RSYNC_TIMEOUT_SECS")
[[ -n "$RSYNC_MIRROR_ROOT" ]] && cmd+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
[[ -n "$MAX_DEPTH" ]] && cmd+=(--max-depth "$MAX_DEPTH")
[[ -n "$MAX_INSTANCES" ]] && cmd+=(--max-instances "$MAX_INSTANCES")
[[ "$NO_BUILD" -eq 1 ]] && cmd+=(--no-build)
"${cmd[@]}"
python3 - "$SUMMARY_JSON" "$rir" "$out_dir" <<'PY'
import json, pathlib, sys
summary_path, rir, out_dir = sys.argv[1:]
summary = json.loads(pathlib.Path(summary_path).read_text())
bundle = json.loads(pathlib.Path(out_dir, rir, "bundle.json").read_text())
verification = json.loads(pathlib.Path(out_dir, rir, "verification.json").read_text())
summary["results"].append({
"rir": rir,
"outDir": out_dir,
"baseVrpCount": bundle["baseVrpCount"],
"deltaVrpCount": bundle["deltaVrpCount"],
"baseVapCount": bundle["baseVapCount"],
"deltaVapCount": bundle["deltaVapCount"],
"baseSelfReplayOk": verification["base"]["capture"]["selfReplayOk"],
"deltaSelfReplayOk": verification["delta"]["capture"]["selfReplayOk"],
})
pathlib.Path(summary_path).write_text(json.dumps(summary, indent=2))
PY
done
python3 - "$SUMMARY_JSON" "$SUMMARY_MD" <<'PY'
import json, pathlib, sys
summary = json.loads(pathlib.Path(sys.argv[1]).read_text())
out = pathlib.Path(sys.argv[2])
lines = [
"# Multi-RIR Live Bundle Record Summary",
"",
f"- runTag: `{summary['runTag']}`",
"",
"| rir | base_vrps | delta_vrps | base_vaps | delta_vaps | base_self_replay | delta_self_replay | out_dir |",
"|---|---:|---:|---:|---:|---|---|---|",
]
for item in summary["results"]:
lines.append(
f"| {item['rir']} | {item['baseVrpCount']} | {item['deltaVrpCount']} | "
f"{item['baseVapCount']} | {item['deltaVapCount']} | "
f"{str(item['baseSelfReplayOk']).lower()} | {str(item['deltaSelfReplayOk']).lower()} | "
f"`{item['outDir']}` |"
)
out.write_text("\n".join(lines) + "\n")
PY
echo "$OUT_ROOT"

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
RIRS=""
OUT_ROOT=""
BASE_VALIDATION_TIME=""
DELTA_COUNT=""
DELTA_INTERVAL_SECS=""
HTTP_TIMEOUT_SECS=""
RSYNC_TIMEOUT_SECS=""
RSYNC_MIRROR_ROOT=""
MAX_DEPTH=""
MAX_INSTANCES=""
NO_BUILD=0
KEEP_DB=0
CAPTURE_INPUTS_ONLY=0
BIN_DIR="target/release"
usage() {
cat <<'EOF'
Usage:
./scripts/replay_bundle/run_live_bundle_record_multi_rir_sequence.sh \
--rir <afrinic,apnic,...> \
[--out-root <path>] \
[--base-validation-time <rfc3339>] \
[--delta-count <n>] \
[--delta-interval-secs <n>] \
[--http-timeout-secs <n>] \
[--rsync-timeout-secs <n>] \
[--rsync-mirror-root <path>] \
[--max-depth <n>] \
[--max-instances <n>] \
[--bin-dir <path>] \
[--no-build] \
[--keep-db] \
[--capture-inputs-only]
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--rir) RIRS="${2:?}"; shift 2 ;;
--out-root) OUT_ROOT="${2:?}"; shift 2 ;;
--base-validation-time) BASE_VALIDATION_TIME="${2:?}"; shift 2 ;;
--delta-count) DELTA_COUNT="${2:?}"; shift 2 ;;
--delta-interval-secs) DELTA_INTERVAL_SECS="${2:?}"; shift 2 ;;
--http-timeout-secs) HTTP_TIMEOUT_SECS="${2:?}"; shift 2 ;;
--rsync-timeout-secs) RSYNC_TIMEOUT_SECS="${2:?}"; shift 2 ;;
--rsync-mirror-root) RSYNC_MIRROR_ROOT="${2:?}"; shift 2 ;;
--max-depth) MAX_DEPTH="${2:?}"; shift 2 ;;
--max-instances) MAX_INSTANCES="${2:?}"; shift 2 ;;
--bin-dir) BIN_DIR="${2:?}"; shift 2 ;;
--no-build) NO_BUILD=1; shift ;;
--keep-db) KEEP_DB=1; shift ;;
--capture-inputs-only) CAPTURE_INPUTS_ONLY=1; shift ;;
--help|-h) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$RIRS" ]]; then
usage >&2
exit 2
fi
RUN_TAG="$(date -u +%Y%m%dT%H%M%SZ)"
if [[ -z "$OUT_ROOT" ]]; then
OUT_ROOT="target/replay/live_bundle_sequence_matrix_${RUN_TAG}"
fi
mkdir -p "$OUT_ROOT"
resolve_tal_path() {
case "$1" in
afrinic) printf 'tests/fixtures/tal/afrinic.tal' ;;
apnic) printf 'tests/fixtures/tal/apnic-rfc7730-https.tal' ;;
arin) printf 'tests/fixtures/tal/arin.tal' ;;
lacnic) printf 'tests/fixtures/tal/lacnic.tal' ;;
ripe) printf 'tests/fixtures/tal/ripe-ncc.tal' ;;
*) echo "unsupported rir: $1" >&2; exit 2 ;;
esac
}
resolve_ta_path() {
case "$1" in
afrinic) printf 'tests/fixtures/ta/afrinic-ta.cer' ;;
apnic) printf 'tests/fixtures/ta/apnic-ta.cer' ;;
arin) printf 'tests/fixtures/ta/arin-ta.cer' ;;
lacnic) printf 'tests/fixtures/ta/lacnic-ta.cer' ;;
ripe) printf 'tests/fixtures/ta/ripe-ncc-ta.cer' ;;
*) echo "unsupported rir: $1" >&2; exit 2 ;;
esac
}
SUMMARY_JSON="$OUT_ROOT/summary.json"
SUMMARY_MD="$OUT_ROOT/summary.md"
python3 - "$SUMMARY_JSON" "$RUN_TAG" <<'PY'
import json, sys
path, run_tag = sys.argv[1:]
with open(path, "w") as fh:
json.dump({"runTag": run_tag, "results": []}, fh, indent=2)
PY
IFS=',' read -r -a RIR_LIST <<< "$RIRS"
for raw_rir in "${RIR_LIST[@]}"; do
rir="$(printf '%s' "$raw_rir" | tr '[:upper:]' '[:lower:]' | xargs)"
[[ -n "$rir" ]] || continue
tal_path="$(resolve_tal_path "$rir")"
ta_path="$(resolve_ta_path "$rir")"
out_dir="$OUT_ROOT/${rir}_live_bundle_sequence_${RUN_TAG}"
cmd=(
./scripts/replay_bundle/run_live_bundle_record_sequence.sh
--rir "$rir"
--out-dir "$out_dir"
--tal-path "$tal_path"
--ta-path "$ta_path"
--trust-anchor "$rir"
--bin-dir "$BIN_DIR"
)
[[ -n "$BASE_VALIDATION_TIME" ]] && cmd+=(--base-validation-time "$BASE_VALIDATION_TIME")
[[ -n "$DELTA_COUNT" ]] && cmd+=(--delta-count "$DELTA_COUNT")
[[ -n "$DELTA_INTERVAL_SECS" ]] && cmd+=(--delta-interval-secs "$DELTA_INTERVAL_SECS")
[[ -n "$HTTP_TIMEOUT_SECS" ]] && cmd+=(--http-timeout-secs "$HTTP_TIMEOUT_SECS")
[[ -n "$RSYNC_TIMEOUT_SECS" ]] && cmd+=(--rsync-timeout-secs "$RSYNC_TIMEOUT_SECS")
[[ -n "$RSYNC_MIRROR_ROOT" ]] && cmd+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
[[ -n "$MAX_DEPTH" ]] && cmd+=(--max-depth "$MAX_DEPTH")
[[ -n "$MAX_INSTANCES" ]] && cmd+=(--max-instances "$MAX_INSTANCES")
[[ "$NO_BUILD" -eq 1 ]] && cmd+=(--no-build)
[[ "$KEEP_DB" -eq 1 ]] && cmd+=(--keep-db)
[[ "$CAPTURE_INPUTS_ONLY" -eq 1 ]] && cmd+=(--capture-inputs-only)
"${cmd[@]}"
python3 - "$SUMMARY_JSON" "$rir" "$out_dir" <<'PY'
import json, pathlib, sys
summary_path, rir, out_dir = sys.argv[1:]
summary = json.loads(pathlib.Path(summary_path).read_text())
bundle = json.loads(pathlib.Path(out_dir, rir, "bundle.json").read_text())
verification = json.loads(pathlib.Path(out_dir, rir, "verification.json").read_text())
summary["results"].append({
"rir": rir,
"outDir": out_dir,
"stepCount": len(bundle["deltaSequence"]["steps"]),
"baseVrpCount": bundle["base"]["vrpCount"],
"baseVapCount": bundle["base"]["vapCount"],
"allStepsSelfReplayOk": verification["summary"]["allStepsSelfReplayOk"],
})
pathlib.Path(summary_path).write_text(json.dumps(summary, indent=2))
PY
done
python3 - "$SUMMARY_JSON" "$SUMMARY_MD" <<'PY'
import json, pathlib, sys
summary = json.loads(pathlib.Path(sys.argv[1]).read_text())
out = pathlib.Path(sys.argv[2])
lines = [
"# Multi-RIR Live Bundle Sequence Summary",
"",
f"- runTag: `{summary['runTag']}`",
"",
"| rir | step_count | base_vrps | base_vaps | all_steps_self_replay | out_dir |",
"|---|---:|---:|---:|---|---|",
]
for item in summary["results"]:
lines.append(
f"| {item['rir']} | {item['stepCount']} | {item['baseVrpCount']} | {item['baseVapCount']} | "
f"{str(item['allStepsSelfReplayOk']).lower()} | `{item['outDir']}` |"
)
out.write_text("\n".join(lines) + "\n")
PY
echo "$OUT_ROOT"

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
RIR=""
OUT_DIR=""
TAL_PATH=""
TA_PATH=""
BASE_VALIDATION_TIME=""
DELTA_COUNT=""
DELTA_INTERVAL_SECS=""
HTTP_TIMEOUT_SECS=""
RSYNC_TIMEOUT_SECS=""
RSYNC_MIRROR_ROOT=""
MAX_DEPTH=""
MAX_INSTANCES=""
TRUST_ANCHOR=""
NO_BUILD=0
KEEP_DB=0
CAPTURE_INPUTS_ONLY=0
BIN_DIR="target/release"
PROGRESS_LOG="${RPKI_PROGRESS_LOG:-1}"
PROGRESS_SLOW_SECS="${RPKI_PROGRESS_SLOW_SECS:-30}"
usage() {
cat <<'EOF'
Usage:
./scripts/replay_bundle/run_live_bundle_record_sequence.sh \
--rir <name> \
--tal-path <path> \
--ta-path <path> \
[--out-dir <path>] \
[--base-validation-time <rfc3339>] \
[--delta-count <n>] \
[--delta-interval-secs <n>] \
[--http-timeout-secs <n>] \
[--rsync-timeout-secs <n>] \
[--rsync-mirror-root <path>] \
[--max-depth <n>] \
[--max-instances <n>] \
[--trust-anchor <name>] \
[--bin-dir <path>] \
[--no-build] \
[--keep-db] \
[--capture-inputs-only]
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--rir) RIR="${2:?}"; shift 2 ;;
--out-dir) OUT_DIR="${2:?}"; shift 2 ;;
--tal-path) TAL_PATH="${2:?}"; shift 2 ;;
--ta-path) TA_PATH="${2:?}"; shift 2 ;;
--base-validation-time) BASE_VALIDATION_TIME="${2:?}"; shift 2 ;;
--delta-count) DELTA_COUNT="${2:?}"; shift 2 ;;
--delta-interval-secs) DELTA_INTERVAL_SECS="${2:?}"; shift 2 ;;
--http-timeout-secs) HTTP_TIMEOUT_SECS="${2:?}"; shift 2 ;;
--rsync-timeout-secs) RSYNC_TIMEOUT_SECS="${2:?}"; shift 2 ;;
--rsync-mirror-root) RSYNC_MIRROR_ROOT="${2:?}"; shift 2 ;;
--max-depth) MAX_DEPTH="${2:?}"; shift 2 ;;
--max-instances) MAX_INSTANCES="${2:?}"; shift 2 ;;
--trust-anchor) TRUST_ANCHOR="${2:?}"; shift 2 ;;
--bin-dir) BIN_DIR="${2:?}"; shift 2 ;;
--no-build) NO_BUILD=1; shift ;;
--keep-db) KEEP_DB=1; shift ;;
--capture-inputs-only) CAPTURE_INPUTS_ONLY=1; shift ;;
--help|-h) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$RIR" || -z "$TAL_PATH" || -z "$TA_PATH" ]]; then
usage >&2
exit 2
fi
TS="$(date -u +%Y%m%dT%H%M%SZ)"
if [[ -z "$OUT_DIR" ]]; then
OUT_DIR="target/replay/${RIR}_live_bundle_sequence_${TS}"
fi
SEQUENCE_BIN="$BIN_DIR/replay_bundle_capture_sequence"
if [[ "$NO_BUILD" -eq 0 ]]; then
echo "[1/1] build release binary"
cargo build --release --bin replay_bundle_capture_sequence
else
echo "[1/1] reuse existing binary from $BIN_DIR"
fi
if [[ ! -x "$SEQUENCE_BIN" ]]; then
echo "missing executable: $SEQUENCE_BIN" >&2
exit 1
fi
cmd=(
"$SEQUENCE_BIN"
--rir "$RIR"
--out-dir "$OUT_DIR"
--tal-path "$TAL_PATH"
--ta-path "$TA_PATH"
)
[[ -n "$BASE_VALIDATION_TIME" ]] && cmd+=(--base-validation-time "$BASE_VALIDATION_TIME")
[[ -n "$DELTA_COUNT" ]] && cmd+=(--delta-count "$DELTA_COUNT")
[[ -n "$DELTA_INTERVAL_SECS" ]] && cmd+=(--delta-interval-secs "$DELTA_INTERVAL_SECS")
[[ -n "$HTTP_TIMEOUT_SECS" ]] && cmd+=(--http-timeout-secs "$HTTP_TIMEOUT_SECS")
[[ -n "$RSYNC_TIMEOUT_SECS" ]] && cmd+=(--rsync-timeout-secs "$RSYNC_TIMEOUT_SECS")
[[ -n "$RSYNC_MIRROR_ROOT" ]] && cmd+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
[[ -n "$MAX_DEPTH" ]] && cmd+=(--max-depth "$MAX_DEPTH")
[[ -n "$MAX_INSTANCES" ]] && cmd+=(--max-instances "$MAX_INSTANCES")
[[ -n "$TRUST_ANCHOR" ]] && cmd+=(--trust-anchor "$TRUST_ANCHOR")
[[ "$KEEP_DB" -eq 1 ]] && cmd+=(--keep-db)
[[ "$CAPTURE_INPUTS_ONLY" -eq 1 ]] && cmd+=(--capture-inputs-only)
RPKI_PROGRESS_LOG="$PROGRESS_LOG" \
RPKI_PROGRESS_SLOW_SECS="$PROGRESS_SLOW_SECS" \
"${cmd[@]}"

View File

@@ -0,0 +1,67 @@
# Replay Verify Scripts
## `run_multi_rir_ccr_replay_verify.sh`
用途:
- 通用 multi-RIR CCR replay verify 入口
- 通过 `--rir` 指定一个或多个 RIR,按顺序执行
- 通过 `--mode` 指定 `snapshot`、`delta` 或 `both`
- 默认每个 RIR 的 RocksDB 目录在 compare/verify 结束后自动删除;传 `--keep-db` 才保留
- 同一次执行的所有产物都会先落到 `rpki/target/replay/<timestamp>/`
- 该时间戳目录下再按 RIR 分目录:
- `<rir>_ccr_replay_<timestamp>`
默认输入:
- bundle root: `/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3`
- 每个 RIR 的 TAL / TA / validation time / record CSV 由 `scripts/payload_replay/multi_rir_case_info.py` 解析
用法:
- 单个 RIR:
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir apnic --mode both`
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir apnic --mode snapshot`
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir apnic --mode delta`
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir apnic --mode both`
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir apnic,ripe --mode snapshot`
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir afrinic,apnic,arin,lacnic,ripe --mode both`
- `./scripts/replay_verify/run_multi_rir_ccr_replay_verify.sh --rir apnic --mode delta --keep-db`
可覆盖环境变量:
- `BUNDLE_ROOT`
- `OUT_ROOT`(默认:`rpki/target/replay`)
- `RUN_TAG`
主要产物:
- 单次执行根目录:
- `rpki/target/replay/<timestamp>/`
- 每个 RIR 子目录下:
- `<rir>_snapshot.ccr`
- `<rir>_delta.ccr`
- `<rir>_*_report.json`
- `<rir>_*_ccr_vrps.csv`
- `<rir>_*_ccr_compare_summary.md`
- `<rir>_*_ccr_verify.json`
- 同次执行总汇总:
- `multi_rir_ccr_replay_verify_<timestamp>_summary.md`
- `multi_rir_ccr_replay_verify_<timestamp>_summary.json`
## `run_peer_bundle_matrix.sh`
用途:
- 对一组 `ours live bundle` 做本地 peer replay 矩阵验证
- Routinator 与 `rpki-client` 分别消费相同 bundle root
- 汇总 `VRP + VAP` 的 base / delta 结果
用法:
- `./scripts/replay_verify/run_peer_bundle_matrix.sh --bundle-root target/replay/live_bundle_matrix_<timestamp>`
- `./scripts/replay_verify/run_peer_bundle_matrix.sh --bundle-root target/replay/live_bundle_matrix_<timestamp> --rir apnic,ripe`
主要产物:
- 输出根目录:
- `target/replay/peer_bundle_matrix_<timestamp>/`
- Routinator:
- `target/replay/peer_bundle_matrix_<timestamp>/routinator/<rir>/`
- `rpki-client`:
- `target/replay/peer_bundle_matrix_<timestamp>/rpki-client/`
- 汇总:
- `summary.json`
- `summary.md`

View File

@@ -0,0 +1,284 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
usage() {
cat <<'USAGE'
Usage:
run_multi_rir_ccr_replay_verify.sh --rir <rir[,rir...]> [--mode snapshot|delta|both] [--keep-db]
Options:
--rir <list> Comma-separated RIR list, e.g. apnic or apnic,ripe
--mode <mode> snapshot | delta | both (default: both)
--keep-db Keep per-run RocksDB directories (default: remove after verify)
--bundle-root <p> Override bundle root
--out-root <p> Override output root (default: rpki/target/replay)
--run-tag <tag> Override timestamp suffix for all RIR runs
USAGE
}
MODE="both"
KEEP_DB=0
RIR_LIST=""
BUNDLE_ROOT="${BUNDLE_ROOT:-/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3}"
OUT_ROOT="${OUT_ROOT:-$ROOT_DIR/target/replay}"
RUN_TAG="${RUN_TAG:-$(date -u +%Y%m%dT%H%M%SZ)}"
while [[ $# -gt 0 ]]; do
case "$1" in
--rir)
shift
RIR_LIST="${1:-}"
;;
--mode)
shift
MODE="${1:-}"
;;
--keep-db)
KEEP_DB=1
;;
--bundle-root)
shift
BUNDLE_ROOT="${1:-}"
;;
--out-root)
shift
OUT_ROOT="${1:-}"
;;
--run-tag)
shift
RUN_TAG="${1:-}"
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
shift || true
done
if [[ -z "$RIR_LIST" ]]; then
echo "--rir is required" >&2
usage >&2
exit 2
fi
case "$MODE" in
snapshot|delta|both) ;;
*)
echo "invalid --mode: $MODE" >&2
usage >&2
exit 2
;;
esac
CASE_INFO_SCRIPT="$ROOT_DIR/scripts/payload_replay/multi_rir_case_info.py"
mkdir -p "$OUT_ROOT"
RUN_ROOT="$OUT_ROOT/$RUN_TAG"
mkdir -p "$RUN_ROOT"
cargo build --release --bin rpki --bin ccr_to_routinator_csv --bin ccr_verify >/dev/null
summary_md="$RUN_ROOT/multi_rir_ccr_replay_verify_${RUN_TAG}_summary.md"
summary_json="$RUN_ROOT/multi_rir_ccr_replay_verify_${RUN_TAG}_summary.json"
python3 - <<'PY' >/dev/null
PY
summary_json_tmp="$(mktemp)"
printf '[]' > "$summary_json_tmp"
run_one_mode() {
local rir="$1"
local mode="$2"
local run_dir="$3"
local trust_anchor="$4"
local tal_path="$5"
local ta_path="$6"
local base_archive="$7"
local base_locks="$8"
local base_csv="$9"
local delta_archive="${10}"
local delta_locks="${11}"
local delta_csv="${12}"
local snapshot_validation_time="${13}"
local delta_validation_time="${14}"
local db_dir="$run_dir/${rir}_${mode}_db"
local report_json="$run_dir/${rir}_${mode}_report.json"
local run_log="$run_dir/${rir}_${mode}_run.log"
local ccr_path="$run_dir/${rir}_${mode}.ccr"
local csv_path="$run_dir/${rir}_${mode}_ccr_vrps.csv"
local compare_md="$run_dir/${rir}_${mode}_ccr_compare_summary.md"
local only_ours="$run_dir/${rir}_${mode}_ccr_only_in_ours.csv"
local only_record="$run_dir/${rir}_${mode}_ccr_only_in_record.csv"
local verify_json="$run_dir/${rir}_${mode}_ccr_verify.json"
local meta_json="$run_dir/${rir}_${mode}_meta.json"
rm -rf "$db_dir"
local -a cmd=(target/release/rpki --db "$db_dir" --tal-path "$tal_path" --ta-path "$ta_path")
if [[ "$mode" == "snapshot" ]]; then
cmd+=(--payload-replay-archive "$base_archive" --payload-replay-locks "$base_locks" --validation-time "$snapshot_validation_time")
else
cmd+=(
--payload-base-archive "$base_archive"
--payload-base-locks "$base_locks"
--payload-base-validation-time "$snapshot_validation_time"
--payload-delta-archive "$delta_archive"
--payload-delta-locks "$delta_locks"
--validation-time "$delta_validation_time"
)
fi
cmd+=(--report-json "$report_json" --ccr-out "$ccr_path")
local start_s end_s duration_s
start_s="$(date +%s)"
(
echo "# ${rir} ${mode} command:"
printf '%q ' "${cmd[@]}"
echo
echo
"${cmd[@]}"
) 2>&1 | tee "$run_log" >/dev/null
end_s="$(date +%s)"
duration_s="$((end_s - start_s))"
target/release/ccr_to_routinator_csv \
--ccr "$ccr_path" \
--out "$csv_path" \
--trust-anchor "$trust_anchor" >/dev/null
local record_csv
if [[ "$mode" == "snapshot" ]]; then
record_csv="$base_csv"
else
record_csv="$delta_csv"
fi
./scripts/payload_replay/compare_with_routinator_record.sh \
"$csv_path" \
"$record_csv" \
"$compare_md" \
"$only_ours" \
"$only_record" >/dev/null
target/release/ccr_verify \
--ccr "$ccr_path" \
--db "$db_dir" > "$verify_json"
python3 - "$report_json" "$meta_json" "$mode" "$duration_s" <<'PY'
import json, sys
from pathlib import Path
report = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8'))
meta = {
'mode': sys.argv[3],
'duration_seconds': int(sys.argv[4]),
'validation_time': report.get('validation_time_rfc3339_utc'),
'publication_points_processed': report['tree']['instances_processed'],
'publication_points_failed': report['tree']['instances_failed'],
'vrps': len(report['vrps']),
'aspas': len(report['aspas']),
}
Path(sys.argv[2]).write_text(json.dumps(meta, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
PY
if [[ "$KEEP_DB" -eq 0 ]]; then
rm -rf "$db_dir"
fi
}
IFS=',' read -r -a RIRS <<< "$RIR_LIST"
for rir in "${RIRS[@]}"; do
rir="$(echo "$rir" | xargs)"
[[ -n "$rir" ]] || continue
eval "$(python3 "$CASE_INFO_SCRIPT" --bundle-root "$BUNDLE_ROOT" --rir "$rir" --format env)"
run_dir="$RUN_ROOT/${rir}_ccr_replay_${RUN_TAG}"
mkdir -p "$run_dir"
if [[ "$MODE" == "snapshot" || "$MODE" == "both" ]]; then
run_one_mode \
"$rir" snapshot "$run_dir" "$TRUST_ANCHOR" "$TAL_PATH" "$TA_PATH" \
"$PAYLOAD_REPLAY_ARCHIVE" "$PAYLOAD_REPLAY_LOCKS" "$ROUTINATOR_BASE_RECORD_CSV" \
"$PAYLOAD_DELTA_ARCHIVE" "$PAYLOAD_DELTA_LOCKS" "$ROUTINATOR_DELTA_RECORD_CSV" \
"$SNAPSHOT_VALIDATION_TIME" "$DELTA_VALIDATION_TIME"
fi
if [[ "$MODE" == "delta" || "$MODE" == "both" ]]; then
run_one_mode \
"$rir" delta "$run_dir" "$TRUST_ANCHOR" "$TAL_PATH" "$TA_PATH" \
"$PAYLOAD_REPLAY_ARCHIVE" "$PAYLOAD_REPLAY_LOCKS" "$ROUTINATOR_BASE_RECORD_CSV" \
"$PAYLOAD_DELTA_ARCHIVE" "$PAYLOAD_DELTA_LOCKS" "$ROUTINATOR_DELTA_RECORD_CSV" \
"$SNAPSHOT_VALIDATION_TIME" "$DELTA_VALIDATION_TIME"
fi
python3 - "$summary_json_tmp" "$run_dir" "$rir" "$MODE" <<'PY'
import json, sys
from pathlib import Path
summary_path = Path(sys.argv[1])
run_dir = Path(sys.argv[2])
rir = sys.argv[3]
mode = sys.argv[4]
rows = json.loads(summary_path.read_text(encoding='utf-8'))
for submode in ['snapshot','delta']:
if mode not in ('both', submode):
continue
compare = run_dir / f'{rir}_{submode}_ccr_compare_summary.md'
meta = run_dir / f'{rir}_{submode}_meta.json'
verify = run_dir / f'{rir}_{submode}_ccr_verify.json'
if not compare.exists() or not meta.exists() or not verify.exists():
continue
compare_text = compare.read_text(encoding='utf-8')
meta_obj = json.loads(meta.read_text(encoding='utf-8'))
verify_obj = json.loads(verify.read_text(encoding='utf-8'))
def metric(name):
prefix = f'| {name} | '
for line in compare_text.splitlines():
if line.startswith(prefix):
return int(line.split('|')[2].strip())
raise SystemExit(f'missing metric {name} in {compare}')
rows.append({
'rir': rir,
'mode': submode,
'run_dir': str(run_dir),
'duration_seconds': meta_obj['duration_seconds'],
'vrps': meta_obj['vrps'],
'aspas': meta_obj['aspas'],
'only_in_ours': metric('only_in_ours'),
'only_in_record': metric('only_in_record'),
'intersection': metric('intersection'),
'state_hashes_ok': verify_obj.get('state_hashes_ok'),
})
summary_path.write_text(json.dumps(rows, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
PY
done
python3 - "$summary_json_tmp" "$summary_json" "$summary_md" "$RUN_TAG" <<'PY'
import json, sys
from pathlib import Path
rows = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8'))
out_json = Path(sys.argv[2])
out_md = Path(sys.argv[3])
run_tag = sys.argv[4]
out_json.write_text(json.dumps(rows, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
parts = []
parts.append('# Multi-RIR CCR Replay Verify Summary\n\n')
parts.append(f'- run_tag: `{run_tag}`\n\n')
parts.append('| rir | mode | duration_s | vrps | aspas | only_in_ours | only_in_record | state_hashes_ok |\n')
parts.append('|---|---|---:|---:|---:|---:|---:|---|\n')
for row in rows:
parts.append(f"| {row['rir']} | {row['mode']} | {row['duration_seconds']} | {row['vrps']} | {row['aspas']} | {row['only_in_ours']} | {row['only_in_record']} | {row['state_hashes_ok']} |\n")
out_md.write_text(''.join(parts), encoding='utf-8')
PY
rm -f "$summary_json_tmp"
echo "== multi-rir replay verify complete ==" >&2
echo "- summary: $summary_md" >&2
echo "- summary json: $summary_json" >&2

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
BUNDLE_ROOT=""
RIRS=""
OUT_ROOT=""
ROUTINATOR_ROOT="/home/yuyr/dev/rust_playground/routinator"
RPKI_CLIENT_ROOT="/home/yuyr/dev/rpki-client-9.7"
RPKI_CLIENT_BUILD_DIR="/home/yuyr/dev/rpki-client-9.7/build-m5"
KEEP_DB=0
usage() {
cat <<'EOF'
Usage:
./scripts/replay_verify/run_peer_bundle_matrix.sh \
--bundle-root <dir> \
[--rir <afrinic,apnic,...>] \
[--out-root <dir>] \
[--routinator-root <dir>] \
[--rpki-client-root <dir>] \
[--rpki-client-build-dir <dir>] \
[--keep-db]
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--bundle-root) BUNDLE_ROOT="${2:?}"; shift 2 ;;
--rir) RIRS="${2:?}"; shift 2 ;;
--out-root) OUT_ROOT="${2:?}"; shift 2 ;;
--routinator-root) ROUTINATOR_ROOT="${2:?}"; shift 2 ;;
--rpki-client-root) RPKI_CLIENT_ROOT="${2:?}"; shift 2 ;;
--rpki-client-build-dir) RPKI_CLIENT_BUILD_DIR="${2:?}"; shift 2 ;;
--keep-db) KEEP_DB=1; shift ;;
--help|-h) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$BUNDLE_ROOT" ]]; then
usage >&2
exit 2
fi
BUNDLE_ROOT="$(python3 - "$BUNDLE_ROOT" <<'PY'
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
RUN_TAG="$(date -u +%Y%m%dT%H%M%SZ)"
if [[ -z "$OUT_ROOT" ]]; then
OUT_ROOT="target/replay/peer_bundle_matrix_${RUN_TAG}"
fi
mkdir -p "$OUT_ROOT"
OUT_ROOT="$(python3 - "$OUT_ROOT" <<'PY'
from pathlib import Path
import sys
print(Path(sys.argv[1]).resolve())
PY
)"
discover_rirs() {
python3 - "$BUNDLE_ROOT" <<'PY'
from pathlib import Path
import sys
root = Path(sys.argv[1])
if (root / "base-locks.json").exists():
print(root.name)
raise SystemExit
rirs = []
for entry in sorted(root.iterdir()):
if not entry.is_dir():
continue
if (entry / "base-locks.json").exists():
rirs.append(entry.name)
continue
nested = sorted(
child.name for child in entry.iterdir()
if child.is_dir() and (child / "base-locks.json").exists()
)
if len(nested) == 1:
rirs.append(nested[0])
print(",".join(rirs))
PY
}
if [[ -z "$RIRS" ]]; then
RIRS="$(discover_rirs)"
fi
ROUTI_OUT="$OUT_ROOT/routinator"
CLIENT_OUT="$OUT_ROOT/rpki-client"
NORMALIZED_BUNDLE_ROOT="$OUT_ROOT/.normalized-bundle-root"
mkdir -p "$ROUTI_OUT" "$CLIENT_OUT"
rm -rf "$NORMALIZED_BUNDLE_ROOT"
mkdir -p "$NORMALIZED_BUNDLE_ROOT"
IFS=',' read -r -a RIR_LIST <<< "$RIRS"
for raw_rir in "${RIR_LIST[@]}"; do
rir="$(printf '%s' "$raw_rir" | tr '[:upper:]' '[:lower:]' | xargs)"
[[ -n "$rir" ]] || continue
source_bundle_dir=""
if [[ -d "$BUNDLE_ROOT/$rir" && -f "$BUNDLE_ROOT/$rir/base-locks.json" ]]; then
source_bundle_dir="$BUNDLE_ROOT/$rir"
else
match="$(find "$BUNDLE_ROOT" -maxdepth 2 -type d -path "*/${rir}" -exec test -f '{}/base-locks.json' ';' -print | head -n 1)"
if [[ -z "$match" ]]; then
echo "unable to resolve bundle directory for RIR: $rir" >&2
exit 1
fi
source_bundle_dir="$match"
fi
ln -sfn "$source_bundle_dir" "$NORMALIZED_BUNDLE_ROOT/$rir"
ROUTI_CMD=(
"$ROUTINATOR_ROOT/bench/multi_rir_demo_ours/run_single_rir_ours_bundle.sh"
"$source_bundle_dir"
"$ROUTI_OUT/$rir"
)
[[ "$KEEP_DB" -eq 1 ]] && ROUTI_CMD=( "$ROUTINATOR_ROOT/bench/multi_rir_demo_ours/run_single_rir_ours_bundle.sh" --keep-db "$source_bundle_dir" "$ROUTI_OUT/$rir" )
"${ROUTI_CMD[@]}"
done
CLIENT_ARGS=(
python3 "$RPKI_CLIENT_ROOT/tools/run_bundle_matrix.py"
--bundle-dir "$NORMALIZED_BUNDLE_ROOT"
--build-dir "$RPKI_CLIENT_BUILD_DIR"
--work-dir "$CLIENT_OUT"
)
[[ "$KEEP_DB" -eq 1 ]] && CLIENT_ARGS+=(--keep-db)
for raw_rir in "${RIR_LIST[@]}"; do
rir="$(printf '%s' "$raw_rir" | tr '[:upper:]' '[:lower:]' | xargs)"
[[ -n "$rir" ]] || continue
CLIENT_ARGS+=(--rir "$rir")
done
"${CLIENT_ARGS[@]}"
SUMMARY_JSON="$OUT_ROOT/summary.json"
SUMMARY_MD="$OUT_ROOT/summary.md"
python3 - "$ROUTI_OUT" "$CLIENT_OUT/matrix-summary.json" "$SUMMARY_JSON" <<'PY'
import json
from pathlib import Path
import sys
routi_root = Path(sys.argv[1])
client_summary = json.loads(Path(sys.argv[2]).read_text())
summary_path = Path(sys.argv[3])
summary = {"routinator": {}, "rpki_client": client_summary}
for verification in sorted(routi_root.glob("*/verification.json")):
rir = verification.parent.name
summary["routinator"][rir] = json.loads(verification.read_text())
summary_path.write_text(json.dumps(summary, indent=2))
PY
python3 - "$SUMMARY_JSON" "$SUMMARY_MD" <<'PY'
import json
from pathlib import Path
import sys
summary = json.loads(Path(sys.argv[1]).read_text())
out = Path(sys.argv[2])
lines = [
"# Peer Bundle Matrix Summary",
"",
"## Routinator",
"",
"| rir | base_vrp | base_vap | sequence_vrp | sequence_vap |",
"|---|---|---|---|---|",
]
for rir, data in sorted(summary["routinator"].items()):
if "steps" in data:
lines.append(
f"| {rir} | {str(data.get('baseMatch')).lower()} | {str(data.get('baseVapsMatch')).lower()} | "
f"{str(data.get('summary', {}).get('allStepsMatch')).lower()} | "
f"{str(data.get('summary', {}).get('allStepsVapsMatch')).lower()} |"
)
else:
lines.append(
f"| {rir} | {str(data.get('baseMatch')).lower()} | {str(data.get('baseVapsMatch')).lower()} | "
f"{str(data.get('deltaMatch')).lower()} | {str(data.get('deltaVapsMatch')).lower()} |"
)
lines += [
"",
"## rpki-client",
"",
"| rir | base_vrp | base_vap | sequence_vrp | sequence_vap |",
"|---|---|---|---|---|",
]
for rir, phases in sorted(summary["rpki_client"].items()):
base = phases.get("base", {})
step_items = [
value for key, value in phases.items()
if key not in ("base", "delta") and isinstance(value, dict)
]
if "delta" in phases:
step_items.append(phases["delta"])
all_step_match = all(item.get("match") for item in step_items) if step_items else None
all_step_vap_match = all(item.get("vaps_match") for item in step_items) if step_items else None
lines.append(
f"| {rir} | {str(base.get('match')).lower()} | {str(base.get('vaps_match')).lower()} | "
f"{str(all_step_match).lower()} | {str(all_step_vap_match).lower()} |"
)
out.write_text("\n".join(lines) + "\n")
PY
echo "$OUT_ROOT"

4034
specs/cir.excalidraw Normal file

File diff suppressed because it is too large Load Diff

196
specs/cir_draft.md Normal file
View File

@@ -0,0 +1,196 @@
---
**Internet-Draft** Yirong Yu
**Intended status: Standards Track** Zhongguancun Labortary
**Expires: [Date, e.g., October 2026]** April 2026
# A Profile for Resource Public Key Infrastructure (RPKI) Canonical Input Representation (CIR)
## draft-yu-sidrops-rpki-cir-00
### Abstract
This document specifies a Canonical Input Representation (CIR) content type for use with the Resource Public Key Infrastructure (RPKI). While the Canonical Cache Representation (CCR) profiles the *validated* output state of a Relying Party (RP), CIR is a DER-encoded data interchange format used to represent the exact, *unvalidated* raw input data fetched by an RP at a particular point in time. The CIR profile provides a deterministic "world view" snapshot, enabling advanced operational capabilities such as differential testing, failure path debugging, and highly accurate historical black-box replay of RPKI validation logic.
### Status of This Memo
TBD
### Table of Contents
1. Introduction
1.1. Requirements Language
2. Motivation and Architecture
3. The Canonical Input Representation Content Type
4. The Canonical Input Representation Content
4.1. version
4.2. metaInfo
4.3. BaseCIR Fields
4.4. DeltaCIR Fields
5. Operational Considerations
5.1. Differential Testing and Historical Replay
5.2. Delta Compression for Archival
6. Security Considerations
7. IANA Considerations
8. References
---
### 1. Introduction
This document specifies a Canonical Input Representation (CIR) content type for use with the Resource Public Key Infrastructure (RPKI).
A Relying Party (RP) fetches RPKI objects from publication points using protocols such as rsync [RFC5781] or RRDP [RFC8182] prior to executing cryptographic validation. While the Canonical Cache Representation (CCR) [draft-ietf-sidrops-rpki-ccr] accurately describes the subset of objects that successfully passed validation, it inherently omits objects that were rejected due to format errors, invalid signatures, or expired timestamps (survivorship bias).
CIR records the precise mapping of object URIs to their cryptographic hashes *before* validation occurs. By decoupling the network transport layer from the validation layer, CIR allows researchers and operators to reconstruct the exact physical file tree (the "dirty inputs") perceived by a vantage point.
#### 1.1. Requirements Language
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.
### 2. Motivation and Architecture
CIR is designed to solve the "time paradox" and "state desynchronization" problems inherent to RPKI historical archiving. It defines two distinct operational modes:
* **Base CIR**: A complete snapshot of all fetched Trust Anchor Locators (TALs) and RPKI objects, typically generated by an RP immediately after a synchronization cycle.
* **Delta CIR**: A compressed representation generated by offline archival processes, describing the additions, modifications, and deletions between two chronological Base CIR snapshots.
### 3. The Canonical Input Representation Content Type
The content of a CIR file is an instance of `ContentInfo`.
The `contentType` for a CIR is defined as `id-ct-rpkiCanonicalInputRepresentation`, with Object Identifier (OID) `[TBD-OID]`.
The content is an instance of `RpkiCanonicalInputRepresentation`.
### 4. The Canonical Input Representation Content
The content of a Canonical Input Representation is formally defined using ASN.1. To ensure absolute deterministic serialization, CIR MUST be encoded using Distinguished Encoding Rules (DER, [X.690]).
```asn.1
RpkiCanonicalInputRepresentation-2026
{ iso(1) member-body(2) us(840) rsadsi(113549)
pkcs(1) pkcs9(9) smime(16) mod(0) id-mod-rpkiCIR-2026(TBD) }
DEFINITIONS EXPLICIT TAGS ::=
BEGIN
IMPORTS
CONTENT-TYPE, Digest
FROM CryptographicMessageSyntax-2010 -- in [RFC6268]
;
ContentInfo ::= SEQUENCE {
contentType CONTENT-TYPE.&id({ContentSet}),
content [0] EXPLICIT CONTENT-TYPE.&Type({ContentSet}{@contentType}) }
ContentSet CONTENT-TYPE ::= {
ct-rpkiCanonicalInputRepresentation, ... }
ct-rpkiCanonicalInputRepresentation CONTENT-TYPE ::=
{ TYPE RpkiCanonicalInputRepresentation
IDENTIFIED BY id-ct-rpkiCanonicalInputRepresentation }
id-ct-rpkiCanonicalInputRepresentation OBJECT IDENTIFIER ::=
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
pkcs-9(9) id-smime(16) id-ct(1) cir(TBD) }
RpkiCanonicalInputRepresentation ::= CHOICE {
baseCIR [0] BaseCIR,
deltaCIR [1] DeltaCIR
}
BaseCIR ::= SEQUENCE {
version INTEGER DEFAULT 0,
metaInfo CIRMetaInfo,
talList SEQUENCE OF URIAndHash,
objectList SEQUENCE OF URIAndHash
}
DeltaCIR ::= SEQUENCE {
version INTEGER DEFAULT 0,
metaInfo CIRMetaInfo,
talChanges [0] DeltaChanges OPTIONAL,
objectChanges [1] DeltaChanges
}
DeltaChanges ::= SEQUENCE {
upserted [0] SEQUENCE OF URIAndHash OPTIONAL,
removed [1] SEQUENCE OF IA5String OPTIONAL
}
CIRMetaInfo ::= SEQUENCE {
validationTime GeneralizedTime,
rpSoftware [0] UTF8String OPTIONAL,
rpVersion [1] UTF8String OPTIONAL,
observerID [2] UTF8String OPTIONAL
}
URIAndHash ::= SEQUENCE {
uri IA5String,
hash OCTET STRING,
source [0] SourceType OPTIONAL
}
SourceType ::= ENUMERATED {
rsync (0),
rrdp (1),
https (2),
erik (3),
cache (4),
other (5)
}
END
```
#### 4.1. version
The version field contains the format version for the structure. In this version of the specification, it MUST be `0`.
#### 4.2. metaInfo
The `metaInfo` structure provides crucial temporal and environmental context:
* **validationTime**: Contains a `GeneralizedTime` indicating the moment the synchronization concluded. This timestamp is REQUIRED, as it is strictly necessary to freeze the system clock when replaying RPKI validation logic to evaluate time-sensitive object expiration.
* **rpSoftware / rpVersion / observerID**: OPTIONAL metadata to identify the specific software and observation vantage point generating the CIR.
#### 4.3. BaseCIR Fields
* **talList**: A sequence of `URIAndHash` representing the Trust Anchor Locators used as the root of validation.
* **objectList**: A sequence of `URIAndHash` representing every raw file fetched by the RP. The `uri` MUST be the absolute logical address (e.g., `rsync://...`), and the `hash` MUST be the SHA-256 digest of the raw file.
* **source**: An OPTIONAL enumerated value indicating the network transport or cache layer from which the file was successfully obtained (e.g., `rrdp`, `rsync`).
#### 4.4. DeltaCIR Fields
To support compact archival, `DeltaCIR` describes changes relative to a preceding `BaseCIR` or `DeltaCIR`:
* **upserted**: A sequence of `URIAndHash` for newly discovered objects or objects where the URI remained identical but the cryptographic Hash changed.
* **removed**: A sequence of `IA5String` containing URIs that were present in the previous snapshot but are no longer observed.
### 5. Operational Considerations
#### 5.1. Differential Testing and Historical Replay
Because CIR captures the global input state *regardless* of object validity, it allows operators to construct an isolated physical sandbox matching the exact network state at `validationTime`. By injecting this state into different RP software implementations (using native functionality like `--disable-rrdp` coupled with local rsync wrappers), operators can perform deterministic differential testing. Discrepancies in the resulting CCR outputs indicate implementation bugs or vulnerabilities in boundary-case handling.
#### 5.2. Delta Compression for Archival
Given that the global RPKI repository experiences relatively low churn within short timeframes (e.g., 10-minute intervals), `DeltaCIR` significantly reduces storage overhead. Archival systems SHOULD compute `DeltaCIR` sequences from raw `BaseCIR` outputs to facilitate efficient streaming historical replays.
### 6. Security Considerations
Unlike RPKI signed objects, CIR objects are not cryptographically signed by CAs. They are observational records.
CIR explicitly permits the indexing of corrupted, malicious, or malformed ASN.1 objects. Parsers ingesting CIR to reconstruct sandboxes MUST NOT attempt to cryptographically decode or execute the objects referenced by the hashes, but simply treat them as opaque binary blobs to be placed in the file system for the target RP to evaluate.
### 7. IANA Considerations
IANA is requested to register the media type `application/rpki-cir`, the file extension `.cir`, and the necessary SMI Security for S/MIME Module Identifiers (OIDs), modeled identically to the IANA considerations defined in the CCR specification.
### 8. References
*[Standard IETF references for RFC 2119, RFC 8174, RFC 6488, RFC 8182, etc. to be populated]*
---
**Next Step Guidance**:
If you plan to officially submit this to the IETF SIDROPS working group, you'll need to allocate the `[TBD]` OID placeholders and potentially run the ASN.1 syntax through an official compiler (like `asn1c`) to ensure there are no implicit tagging ambiguities in the `CHOICE` and `OPTIONAL` fields. Would you like me to refine the ASN.1 tagging strategy further?

16264
specs/delta.excalidraw Normal file

File diff suppressed because it is too large Load Diff

629
specs/sync.excalidraw Normal file
View File

@@ -0,0 +1,629 @@
{
"type": "excalidraw",
"version": 2,
"source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor",
"elements": [
{
"id": "782wmN2vbn0vYfClUbwVT",
"type": "rectangle",
"x": 458.5143563406808,
"y": 224.57136099679133,
"width": 335.08570861816406,
"height": 143.99998474121094,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a0",
"roundness": {
"type": 3
},
"seed": 264486616,
"version": 304,
"versionNonce": 505039016,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "491W0AyWpioiNNXTRuXMb"
}
],
"updated": 1774499907328,
"link": null,
"locked": false
},
{
"id": "491W0AyWpioiNNXTRuXMb",
"type": "text",
"x": 514.987287248884,
"y": 246.5713533673968,
"width": 222.1398468017578,
"height": 100,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a1",
"roundness": null,
"seed": 1658952360,
"version": 360,
"versionNonce": 404419496,
"isDeleted": false,
"boundElements": null,
"updated": 1774499907328,
"link": null,
"locked": false,
"text": "RAW BY HASH\nsha256 -> file content\n(.mft/.roa/.cer)\n通过hash找原始文件",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "782wmN2vbn0vYfClUbwVT",
"originalText": "RAW BY HASH\nsha256 -> file content (.mft/.roa/.cer)\n通过hash找原始文件",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "4v-5vJwc-YSKwDDA6wnNy",
"type": "rectangle",
"x": 86.74286869594027,
"y": 224.1142785208566,
"width": 332.79998561314164,
"height": 141.99999128069192,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a2",
"roundness": {
"type": 3
},
"seed": 1176722904,
"version": 312,
"versionNonce": 1631513048,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "tug_6QGIm4LrnsrYiV18D"
}
],
"updated": 1774499913411,
"link": null,
"locked": false
},
{
"id": "tug_6QGIm4LrnsrYiV18D",
"type": "text",
"x": 109.85297502790172,
"y": 245.11427416120256,
"width": 286.57977294921875,
"height": 100,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a3",
"roundness": null,
"seed": 1923736280,
"version": 409,
"versionNonce": 980676312,
"isDeleted": false,
"boundElements": [],
"updated": 1774499913411,
"link": null,
"locked": false,
"text": "REPOSITORY VIEW\nuri -> sha256(current version\nfile)\n对象uri 查找最新hash",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "4v-5vJwc-YSKwDDA6wnNy",
"originalText": "REPOSITORY VIEW\nuri -> sha256(current version file)\n对象uri 查找最新hash",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "V2AcY1746pbG544Yh7A7q",
"type": "rectangle",
"x": 90.85720498221252,
"y": -2.5142985752651725,
"width": 218.51431492396773,
"height": 205.71430751255576,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a4",
"roundness": {
"type": 3
},
"seed": 436378328,
"version": 498,
"versionNonce": 113477080,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "WVFatIOjK3SB8FDetV4ts"
}
],
"updated": 1774499431839,
"link": null,
"locked": false
},
{
"id": "WVFatIOjK3SB8FDetV4ts",
"type": "text",
"x": 110.4543816702706,
"y": 37.84285518101271,
"width": 179.31996154785156,
"height": 125,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a5",
"roundness": null,
"seed": 1637449688,
"version": 675,
"versionNonce": 1502150312,
"isDeleted": false,
"boundElements": [],
"updated": 1774499518593,
"link": null,
"locked": false,
"text": "RRDP SOURCE\nSTATE\nnotify -> state\n(session, serial)\n不同rrdp源同步状态",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "V2AcY1746pbG544Yh7A7q",
"originalText": "RRDP SOURCE STATE\nnotify -> state (session, serial)\n不同rrdp源同步状态",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "KaZIF4nN5lJcP8jlzm2ze",
"type": "rectangle",
"x": 333.25717054094576,
"y": -5.54285212925501,
"width": 217.3714316231864,
"height": 206.7142813546317,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a6",
"roundness": {
"type": 3
},
"seed": 1602529240,
"version": 528,
"versionNonce": 138363048,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "zs86EXmSVt0DGU7Yilr06"
}
],
"updated": 1774499548936,
"link": null,
"locked": false
},
{
"id": "zs86EXmSVt0DGU7Yilr06",
"type": "text",
"x": 341.94288635253895,
"y": 22.814288548060844,
"width": 200,
"height": 150,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a7",
"roundness": null,
"seed": 626126040,
"version": 865,
"versionNonce": 1330001880,
"isDeleted": false,
"boundElements": [],
"updated": 1774499555053,
"link": null,
"locked": false,
"text": "RRDP SOURCE\nMEMBER\nsource+ uri ->\npresent/withdraw\n前缀遍历获取rrdp源下\n全部对象,不同源混放",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "KaZIF4nN5lJcP8jlzm2ze",
"originalText": "RRDP SOURCE MEMBER\nsource+ uri -> present/withdraw\n前缀遍历获取rrdp源下全部对象,不同源混放",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "w4ratSPiaf_sJhmxzs_zB",
"type": "rectangle",
"x": 575.7714941842216,
"y": -5.842858450753411,
"width": 217.3714316231864,
"height": 206.7142813546317,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a8",
"roundness": {
"type": 3
},
"seed": 1385028264,
"version": 564,
"versionNonce": 1503452584,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "t7Y2vDpAmPdo00qNs6Lxp"
}
],
"updated": 1774499442854,
"link": null,
"locked": false
},
{
"id": "t7Y2vDpAmPdo00qNs6Lxp",
"type": "text",
"x": 584.7972292218891,
"y": 47.51428222656244,
"width": 199.31996154785156,
"height": 100,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a9",
"roundness": null,
"seed": 1548502440,
"version": 945,
"versionNonce": 1851848664,
"isDeleted": false,
"boundElements": [],
"updated": 1774499514167,
"link": null,
"locked": false,
"text": "RRDP URI OWNER\nuri -> source\n反查对象所属rrdp源,\n防止跨源误删",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "w4ratSPiaf_sJhmxzs_zB",
"originalText": "RRDP URI OWNER\nuri -> source\n反查对象所属rrdp源,防止跨源误删",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "iHWL3p3MaRLZ-l7Es74es",
"type": "rectangle",
"x": 89.02865600585938,
"y": 398.08574567522317,
"width": 339.42862374441967,
"height": 161.14283970424117,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aA",
"roundness": {
"type": 3
},
"seed": 600580568,
"version": 174,
"versionNonce": 863743704,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "gi3C5lbily2-D96ZNdUB_"
}
],
"updated": 1774499918860,
"link": null,
"locked": false
},
{
"id": "gi3C5lbily2-D96ZNdUB_",
"type": "text",
"x": 105.99298313685827,
"y": 441.15716552734375,
"width": 305.4999694824219,
"height": 75,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aAV",
"roundness": null,
"seed": 1096467112,
"version": 228,
"versionNonce": 315626456,
"isDeleted": false,
"boundElements": null,
"updated": 1774499918860,
"link": null,
"locked": false,
"text": "VCIR\n按照CA为单元,记录已验证缓存的\nRPKI对象产物,树状结构",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "iHWL3p3MaRLZ-l7Es74es",
"originalText": "VCIR\n按照CA为单元,记录已验证缓存的RPKI对象产物,树状结构",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "J6aHbqCN1b8plYxKAKfYT",
"type": "rectangle",
"x": 454.17152186802457,
"y": 399.80005972725996,
"width": 339.42862374441967,
"height": 161.14283970424117,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aC",
"roundness": {
"type": 3
},
"seed": 1274007512,
"version": 244,
"versionNonce": 877648088,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "ernK0EMAzhxJvpYUaWPiS"
}
],
"updated": 1774499918860,
"link": null,
"locked": false
},
{
"id": "ernK0EMAzhxJvpYUaWPiS",
"type": "text",
"x": 461.35585021972656,
"y": 442.87147957938055,
"width": 325.0599670410156,
"height": 75,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aD",
"roundness": null,
"seed": 706221272,
"version": 431,
"versionNonce": 2072968664,
"isDeleted": false,
"boundElements": [],
"updated": 1774499918860,
"link": null,
"locked": false,
"text": "AUDIT RULE INDEX\n溯源审计用户,通过产物规则hash反\n向查找对应VCIR节点",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "J6aHbqCN1b8plYxKAKfYT",
"originalText": "AUDIT RULE INDEX\n溯源审计用户,通过产物规则hash反向查找对应VCIR节点",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "kkkXT2D6yQsceW2UyfJPF",
"type": "text",
"x": -121.25701032366032,
"y": 73.5142887660437,
"width": 186.9999542236328,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aE",
"roundness": null,
"seed": 2098948056,
"version": 64,
"versionNonce": 1921034200,
"isDeleted": false,
"boundElements": null,
"updated": 1774499954136,
"link": null,
"locked": false,
"text": "RRDP 同步状态数据",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "RRDP 同步状态数据",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "BKIRb0Geq874XYWd0OLtS",
"type": "text",
"x": -92.68558175223177,
"y": 276.9428296770369,
"width": 120,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aF",
"roundness": null,
"seed": 791319000,
"version": 31,
"versionNonce": 703934936,
"isDeleted": false,
"boundElements": null,
"updated": 1774499970564,
"link": null,
"locked": false,
"text": "原始文件数据",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "原始文件数据",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "wNzjeS0S_Ji1bKTrKDoyd",
"type": "text",
"x": -101.82843017578091,
"y": 473.51426696777366,
"width": 140,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aG",
"roundness": null,
"seed": 1183095768,
"version": 26,
"versionNonce": 1711685800,
"isDeleted": false,
"boundElements": null,
"updated": 1774499982445,
"link": null,
"locked": false,
"text": "已验证产物数据",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "已验证产物数据",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 20,
"gridStep": 5,
"gridModeEnabled": false,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}

View File

@@ -9,6 +9,7 @@ pub enum AuditObjectKind {
Manifest,
Crl,
Certificate,
RouterCertificate,
Roa,
Aspa,
Other,
@@ -71,6 +72,15 @@ pub struct PublicationPointAudit {
pub rrdp_notification_uri: Option<String>,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_sync_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_sync_phase: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_sync_duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_sync_error: Option<String>,
pub repo_terminal_state: String,
pub this_update_rfc3339_utc: String,
pub next_update_rfc3339_utc: String,
pub verified_at_rfc3339_utc: String,
@@ -149,6 +159,19 @@ pub struct AuditDownloadStats {
pub by_kind: std::collections::BTreeMap<String, AuditDownloadKindStats>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AuditRepoSyncStateStat {
pub count: u64,
pub duration_ms_total: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AuditRepoSyncStats {
pub publication_points_total: u64,
pub by_phase: std::collections::BTreeMap<String, AuditRepoSyncStateStat>,
pub by_terminal_state: std::collections::BTreeMap<String, AuditRepoSyncStateStat>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditReportV1 {
pub format_version: u32,
@@ -174,6 +197,7 @@ pub struct AuditReportV2 {
pub downloads: Vec<AuditDownloadEvent>,
pub download_stats: AuditDownloadStats,
pub repo_sync_stats: AuditRepoSyncStats,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]

View File

@@ -39,7 +39,10 @@ impl DownloadLogHandle {
}
.to_string();
let st = out.by_kind.entry(kind_key).or_insert_with(|| AuditDownloadKindStats {
let st = out
.by_kind
.entry(kind_key)
.or_insert_with(|| AuditDownloadKindStats {
ok_total: 0,
fail_total: 0,
duration_ms_total: 0,
@@ -165,4 +168,3 @@ fn duration_to_ms(d: Duration) -> u64 {
let ms = d.as_millis();
ms.min(u128::from(u64::MAX)) as u64
}

640
src/audit_trace.rs Normal file
View File

@@ -0,0 +1,640 @@
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, &current)?);
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,
};
use base64::Engine as _;
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,
local_router_key_count: local_outputs
.iter()
.filter(|output| output.output_type == VcirOutputType::RouterKey)
.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_evidence(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_evidence(&store, leaf_manifest.as_bytes(), leaf_manifest, "mft");
put_raw_evidence(
&store,
format!("{}-crl", leaf_manifest).as_bytes(),
&leaf_manifest.replace(".mft", ".crl"),
"crl",
);
put_raw_evidence(&store, b"roa-raw", &local.source_object_uri, "roa");
put_raw_evidence(&store, b"roa-ee", "rsync://example.test/leaf/a.ee", "cer");
put_raw_evidence(&store, root_manifest.as_bytes(), root_manifest, "mft");
put_raw_evidence(
&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_supports_router_key_rules() {
let store_dir = tempfile::tempdir().expect("store dir");
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
let manifest = "rsync://example.test/router/leaf.mft";
let mut local = sample_local_output(manifest);
local.output_type = VcirOutputType::RouterKey;
local.source_object_uri = "rsync://example.test/router/router.cer".to_string();
local.source_object_type = "router_key".to_string();
local.payload_json = serde_json::json!({
"as_id": 64496,
"ski_hex": "11".repeat(20),
"spki_der_base64": base64::engine::general_purpose::STANDARD.encode([0x30u8, 0x00]),
})
.to_string();
let mut vcir = sample_vcir(
manifest,
None,
"test-tal",
Some(local),
sample_artifacts(manifest, &sha256_hex(b"router-object")),
);
vcir.local_outputs[0].output_type = VcirOutputType::RouterKey;
vcir.local_outputs[0].source_object_uri =
"rsync://example.test/router/router.cer".to_string();
vcir.local_outputs[0].source_object_type = "router_key".to_string();
vcir.local_outputs[0].payload_json = serde_json::json!({
"as_id": 64496,
"ski_hex": "11".repeat(20),
"spki_der_base64": base64::engine::general_purpose::STANDARD.encode([0x30u8, 0x00]),
})
.to_string();
vcir.summary.local_vrp_count = 0;
vcir.summary.local_router_key_count = 1;
store.put_vcir(&vcir).expect("put vcir");
let rule_entry = AuditRuleIndexEntry {
kind: AuditRuleKind::RouterKey,
rule_hash: vcir.local_outputs[0].rule_hash.clone(),
manifest_rsync_uri: manifest.to_string(),
source_object_uri: vcir.local_outputs[0].source_object_uri.clone(),
source_object_hash: vcir.local_outputs[0].source_object_hash.clone(),
output_id: vcir.local_outputs[0].output_id.clone(),
item_effective_until: vcir.local_outputs[0].item_effective_until.clone(),
};
store
.put_audit_rule_index_entry(&rule_entry)
.expect("put rule");
let trace = trace_rule_to_root(&store, AuditRuleKind::RouterKey, &rule_entry.rule_hash)
.expect("trace rule")
.expect("trace exists");
assert_eq!(trace.rule.kind, AuditRuleKind::RouterKey);
assert_eq!(trace.resolved_output.output_type, VcirOutputType::RouterKey);
}
#[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
));
}
}

70
src/bin/ccr_dump.rs Normal file
View File

@@ -0,0 +1,70 @@
use rpki::ccr::dump::dump_content_info_json_value;
#[derive(Debug, Default, PartialEq, Eq)]
struct Args {
ccr_path: Option<std::path::PathBuf>,
}
fn usage() -> &'static str {
"Usage: ccr_dump --ccr <path>"
}
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()),
"--ccr" => {
i += 1;
let v = argv.get(i).ok_or("--ccr requires a value")?;
args.ccr_path = Some(v.into());
}
other => return Err(format!("unknown argument: {other}\n{}", usage())),
}
i += 1;
}
if args.ccr_path.is_none() {
return Err(format!("--ccr is required\n{}", usage()));
}
Ok(args)
}
fn main() -> Result<(), String> {
let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
let ccr_path = args.ccr_path.as_ref().unwrap();
let bytes = std::fs::read(ccr_path)
.map_err(|e| format!("read ccr failed: {}: {e}", ccr_path.display()))?;
let json = dump_content_info_json_value(&bytes).map_err(|e| e.to_string())?;
println!(
"{}",
serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_args_accepts_ccr_path() {
let argv = vec![
"ccr_dump".to_string(),
"--ccr".to_string(),
"a.ccr".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(
args.ccr_path.as_deref(),
Some(std::path::Path::new("a.ccr"))
);
}
#[test]
fn parse_args_rejects_missing_required_ccr() {
let argv = vec!["ccr_dump".to_string()];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("--ccr is required"), "{err}");
}
}

View File

@@ -0,0 +1,124 @@
use rpki::bundle::{decode_ccr_compare_views, write_vap_csv, write_vrp_csv};
use rpki::ccr::decode_content_info;
#[derive(Debug, Default, PartialEq, Eq)]
struct Args {
ccr_path: Option<std::path::PathBuf>,
vrps_out_path: Option<std::path::PathBuf>,
vaps_out_path: Option<std::path::PathBuf>,
trust_anchor: String,
}
fn usage() -> &'static str {
"Usage: ccr_to_compare_views --ccr <path> --vrps-out <path> --vaps-out <path> [--trust-anchor <name>]"
}
fn parse_args(argv: &[String]) -> Result<Args, String> {
let mut args = Args {
trust_anchor: "unknown".to_string(),
..Args::default()
};
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--ccr" => {
i += 1;
let v = argv.get(i).ok_or("--ccr requires a value")?;
args.ccr_path = Some(v.into());
}
"--vrps-out" => {
i += 1;
let v = argv.get(i).ok_or("--vrps-out requires a value")?;
args.vrps_out_path = Some(v.into());
}
"--vaps-out" => {
i += 1;
let v = argv.get(i).ok_or("--vaps-out requires a value")?;
args.vaps_out_path = Some(v.into());
}
"--trust-anchor" => {
i += 1;
let v = argv.get(i).ok_or("--trust-anchor requires a value")?;
args.trust_anchor = v.clone();
}
"-h" | "--help" => return Err(usage().to_string()),
other => return Err(format!("unknown argument: {other}\n{}", usage())),
}
i += 1;
}
if args.ccr_path.is_none() {
return Err(format!("--ccr is required\n{}", usage()));
}
if args.vrps_out_path.is_none() {
return Err(format!("--vrps-out is required\n{}", usage()));
}
if args.vaps_out_path.is_none() {
return Err(format!("--vaps-out is required\n{}", usage()));
}
Ok(args)
}
fn main() -> Result<(), String> {
let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
let ccr_path = args.ccr_path.as_ref().unwrap();
let bytes = std::fs::read(ccr_path)
.map_err(|e| format!("read ccr failed: {}: {e}", ccr_path.display()))?;
let content_info = decode_content_info(&bytes).map_err(|e| e.to_string())?;
let (vrps, vaps) =
decode_ccr_compare_views(&content_info, &args.trust_anchor).map_err(|e| e.to_string())?;
write_vrp_csv(args.vrps_out_path.as_ref().unwrap(), &vrps)?;
write_vap_csv(args.vaps_out_path.as_ref().unwrap(), &vaps)?;
println!(
"{}\n{}",
args.vrps_out_path.as_ref().unwrap().display(),
args.vaps_out_path.as_ref().unwrap().display()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_args_accepts_required_flags() {
let argv = vec![
"ccr_to_compare_views".to_string(),
"--ccr".to_string(),
"a.ccr".to_string(),
"--vrps-out".to_string(),
"vrps.csv".to_string(),
"--vaps-out".to_string(),
"vaps.csv".to_string(),
"--trust-anchor".to_string(),
"apnic".to_string(),
];
let args = parse_args(&argv).expect("parse args");
assert_eq!(
args.ccr_path.as_deref(),
Some(std::path::Path::new("a.ccr"))
);
assert_eq!(
args.vrps_out_path.as_deref(),
Some(std::path::Path::new("vrps.csv"))
);
assert_eq!(
args.vaps_out_path.as_deref(),
Some(std::path::Path::new("vaps.csv"))
);
assert_eq!(args.trust_anchor, "apnic");
}
#[test]
fn parse_args_rejects_missing_required_flags() {
let argv = vec![
"ccr_to_compare_views".to_string(),
"--ccr".to_string(),
"a.ccr".to_string(),
"--vrps-out".to_string(),
"vrps.csv".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("--vaps-out is required"), "{err}");
}
}

View File

@@ -0,0 +1,121 @@
use rpki::ccr::{decode_content_info, extract_vrp_rows};
use std::io::Write;
#[derive(Default, Debug)]
struct Args {
ccr_path: Option<std::path::PathBuf>,
out_path: Option<std::path::PathBuf>,
trust_anchor: String,
}
fn usage() -> &'static str {
"Usage: ccr_to_routinator_csv --ccr <path> --out <path> [--trust-anchor <name>]"
}
fn parse_args(argv: &[String]) -> Result<Args, String> {
let mut args = Args {
trust_anchor: "unknown".to_string(),
..Args::default()
};
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--ccr" => {
i += 1;
let v = argv.get(i).ok_or("--ccr requires a value")?;
args.ccr_path = Some(v.into());
}
"--out" => {
i += 1;
let v = argv.get(i).ok_or("--out requires a value")?;
args.out_path = Some(v.into());
}
"--trust-anchor" => {
i += 1;
let v = argv.get(i).ok_or("--trust-anchor requires a value")?;
args.trust_anchor = v.clone();
}
"-h" | "--help" => return Err(usage().to_string()),
other => return Err(format!("unknown argument: {other}\n{}", usage())),
}
i += 1;
}
if args.ccr_path.is_none() {
return Err(format!("--ccr is required\n{}", usage()));
}
if args.out_path.is_none() {
return Err(format!("--out is required\n{}", usage()));
}
Ok(args)
}
fn collect_vrp_rows(
bytes: &[u8],
) -> Result<std::collections::BTreeSet<(u32, String, u16)>, String> {
let content_info = decode_content_info(bytes).map_err(|e| e.to_string())?;
extract_vrp_rows(&content_info).map_err(|e| e.to_string())
}
fn main() -> Result<(), String> {
let argv: Vec<String> = std::env::args().collect();
let args = parse_args(&argv)?;
let ccr_path = args.ccr_path.as_ref().unwrap();
let out_path = args.out_path.as_ref().unwrap();
let bytes = std::fs::read(ccr_path)
.map_err(|e| format!("read ccr failed: {}: {e}", ccr_path.display()))?;
let rows = collect_vrp_rows(&bytes)?;
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create parent dirs failed: {}: {e}", parent.display()))?;
}
let mut file = std::io::BufWriter::new(
std::fs::File::create(out_path)
.map_err(|e| format!("create output failed: {}: {e}", out_path.display()))?,
);
writeln!(file, "ASN,IP Prefix,Max Length,Trust Anchor").map_err(|e| e.to_string())?;
for (asn, prefix, max_len) in rows {
writeln!(file, "AS{asn},{prefix},{max_len},{}", args.trust_anchor)
.map_err(|e| e.to_string())?;
}
println!("{}", out_path.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_args_accepts_required_flags() {
let argv = vec![
"ccr_to_routinator_csv".to_string(),
"--ccr".to_string(),
"a.ccr".to_string(),
"--out".to_string(),
"out.csv".to_string(),
"--trust-anchor".to_string(),
"apnic".to_string(),
];
let args = parse_args(&argv).expect("parse args");
assert_eq!(
args.ccr_path.as_deref(),
Some(std::path::Path::new("a.ccr"))
);
assert_eq!(
args.out_path.as_deref(),
Some(std::path::Path::new("out.csv"))
);
assert_eq!(args.trust_anchor, "apnic");
}
#[test]
fn parse_args_rejects_missing_required_flags() {
let argv = vec![
"ccr_to_routinator_csv".to_string(),
"--ccr".to_string(),
"a.ccr".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("--out is required"), "{err}");
}
}

101
src/bin/ccr_verify.rs Normal file
View File

@@ -0,0 +1,101 @@
use rpki::ccr::{
decode_content_info, verify::verify_content_info, verify_against_report_json_path,
verify_against_vcir_store_path,
};
#[derive(Debug, Default, PartialEq, Eq)]
struct Args {
ccr_path: Option<std::path::PathBuf>,
report_json: Option<std::path::PathBuf>,
db_path: Option<std::path::PathBuf>,
}
fn usage() -> &'static str {
"Usage: ccr_verify --ccr <path> [--report-json <path>] [--db <path>]"
}
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()),
"--ccr" => {
i += 1;
let v = argv.get(i).ok_or("--ccr requires a value")?;
args.ccr_path = Some(v.into());
}
"--report-json" => {
i += 1;
let v = argv.get(i).ok_or("--report-json requires a value")?;
args.report_json = Some(v.into());
}
"--db" => {
i += 1;
let v = argv.get(i).ok_or("--db requires a value")?;
args.db_path = Some(v.into());
}
other => return Err(format!("unknown argument: {other}\n{}", usage())),
}
i += 1;
}
if args.ccr_path.is_none() {
return Err(format!("--ccr is required\n{}", usage()));
}
Ok(args)
}
fn main() -> Result<(), String> {
let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
let ccr_path = args.ccr_path.as_ref().unwrap();
let bytes = std::fs::read(ccr_path)
.map_err(|e| format!("read ccr failed: {}: {e}", ccr_path.display()))?;
let ci = decode_content_info(&bytes).map_err(|e| e.to_string())?;
let summary = verify_content_info(&ci).map_err(|e| e.to_string())?;
if let Some(report_json) = args.report_json.as_ref() {
verify_against_report_json_path(&ci, report_json).map_err(|e| e.to_string())?;
}
if let Some(db_path) = args.db_path.as_ref() {
verify_against_vcir_store_path(&ci, db_path).map_err(|e| e.to_string())?;
}
println!(
"{}",
serde_json::to_string_pretty(&summary).map_err(|e| e.to_string())?
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_args_accepts_all_flags() {
let argv = vec![
"ccr_verify".to_string(),
"--ccr".to_string(),
"a.ccr".to_string(),
"--report-json".to_string(),
"report.json".to_string(),
"--db".to_string(),
"db".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(
args.ccr_path.as_deref(),
Some(std::path::Path::new("a.ccr"))
);
assert_eq!(
args.report_json.as_deref(),
Some(std::path::Path::new("report.json"))
);
assert_eq!(args.db_path.as_deref(), Some(std::path::Path::new("db")));
}
#[test]
fn parse_args_rejects_missing_required_ccr() {
let argv = vec!["ccr_verify".to_string()];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("--ccr is required"), "{err}");
}
}

315
src/bin/cir_drop_report.rs Normal file
View File

@@ -0,0 +1,315 @@
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use rpki::blob_store::{ExternalRawStoreDb, RawObjectStore};
use rpki::bundle::decode_ccr_compare_views;
use rpki::ccr::decode_content_info;
use rpki::cir::{decode_cir, resolve_static_pool_file};
use rpki::data_model::roa::RoaObject;
const USAGE: &str = "Usage: cir_drop_report --cir <path> --ccr <path> --report-json <path> (--static-root <path> | --raw-store-db <path>) --json-out <path> --md-out <path>";
#[derive(serde::Serialize)]
struct DroppedObjectRecord {
uri: String,
sha256: String,
kind: String,
reason_code: String,
reason_text: Option<String>,
publication_point: Option<String>,
manifest_uri: Option<String>,
derived_vrp_count: usize,
}
fn classify_reason(detail: Option<&str>, result: &str) -> String {
let text = detail.unwrap_or("").to_ascii_lowercase();
if text.contains("fetch") {
"fetch_failed".to_string()
} else if text.contains("manifest") {
"manifest_invalid".to_string()
} else if text.contains("crl") {
"crl_invalid".to_string()
} else if text.contains("policy") {
"policy_rejected".to_string()
} else if text.contains("parse") {
"object_parse_failed".to_string()
} else if text.contains("signature") || text.contains("cms") {
"cms_signature_invalid".to_string()
} else if text.contains("resource") {
"resource_invalid".to_string()
} else if text.contains("expired") || text.contains("not yet valid") {
"expired_or_not_yet_valid".to_string()
} else if result == "skipped" {
"skipped".to_string()
} else if result == "error" {
"error".to_string()
} else {
"other".to_string()
}
}
fn parse_args(
argv: &[String],
) -> Result<
(
PathBuf,
PathBuf,
PathBuf,
Option<PathBuf>,
Option<PathBuf>,
PathBuf,
PathBuf,
),
String,
> {
let mut cir = None;
let mut ccr = None;
let mut report = None;
let mut static_root = None;
let mut raw_store_db = None;
let mut json_out = None;
let mut md_out = None;
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--cir" => {
i += 1;
cir = Some(PathBuf::from(argv.get(i).ok_or("--cir requires a value")?));
}
"--ccr" => {
i += 1;
ccr = Some(PathBuf::from(argv.get(i).ok_or("--ccr requires a value")?));
}
"--report-json" => {
i += 1;
report = Some(PathBuf::from(
argv.get(i).ok_or("--report-json requires a value")?,
));
}
"--static-root" => {
i += 1;
static_root = Some(PathBuf::from(
argv.get(i).ok_or("--static-root requires a value")?,
));
}
"--raw-store-db" => {
i += 1;
raw_store_db = Some(PathBuf::from(
argv.get(i).ok_or("--raw-store-db requires a value")?,
));
}
"--json-out" => {
i += 1;
json_out = Some(PathBuf::from(
argv.get(i).ok_or("--json-out requires a value")?,
));
}
"--md-out" => {
i += 1;
md_out = Some(PathBuf::from(
argv.get(i).ok_or("--md-out requires a value")?,
));
}
"-h" | "--help" => return Err(USAGE.to_string()),
other => return Err(format!("unknown argument: {other}\n\n{USAGE}")),
}
i += 1;
}
Ok((
cir.ok_or_else(|| format!("--cir is required\n\n{USAGE}"))?,
ccr.ok_or_else(|| format!("--ccr is required\n\n{USAGE}"))?,
report.ok_or_else(|| format!("--report-json is required\n\n{USAGE}"))?,
static_root,
raw_store_db,
json_out.ok_or_else(|| format!("--json-out is required\n\n{USAGE}"))?,
md_out.ok_or_else(|| format!("--md-out is required\n\n{USAGE}"))?,
))
}
fn main() -> Result<(), String> {
let argv: Vec<String> = std::env::args().collect();
let (cir_path, ccr_path, report_path, static_root, raw_store_db, json_out, md_out) =
parse_args(&argv)?;
let backend_count = static_root.is_some() as u8 + raw_store_db.is_some() as u8;
if backend_count != 1 {
return Err(format!(
"must specify exactly one of --static-root or --raw-store-db\n\n{USAGE}"
));
}
let cir = decode_cir(&std::fs::read(&cir_path).map_err(|e| format!("read cir failed: {e}"))?)
.map_err(|e| format!("decode cir failed: {e}"))?;
let ccr = decode_content_info(
&std::fs::read(&ccr_path).map_err(|e| format!("read ccr failed: {e}"))?,
)
.map_err(|e| format!("decode ccr failed: {e}"))?;
let (vrps, vaps) = decode_ccr_compare_views(&ccr, "unknown")
.map_err(|e| format!("decode compare views failed: {e}"))?;
let report: serde_json::Value = serde_json::from_slice(
&std::fs::read(&report_path).map_err(|e| format!("read report failed: {e}"))?,
)
.map_err(|e| format!("parse report failed: {e}"))?;
let mut object_hash_by_uri = BTreeMap::new();
for object in &cir.objects {
object_hash_by_uri.insert(object.rsync_uri.clone(), hex::encode(&object.sha256));
}
let publication_points = report["publication_points"]
.as_array()
.ok_or("report.publication_points must be an array")?;
let mut dropped_objects = Vec::new();
let mut dropped_vrp_rows = BTreeSet::new();
let mut dropped_by_kind: BTreeMap<String, usize> = BTreeMap::new();
let mut dropped_by_reason: BTreeMap<String, usize> = BTreeMap::new();
let mut unknown_roa_objects = 0usize;
for pp in publication_points {
let publication_point = pp["publication_point_rsync_uri"]
.as_str()
.map(str::to_string);
let manifest_uri = pp["manifest_rsync_uri"].as_str().map(str::to_string);
for obj in pp["objects"].as_array().into_iter().flatten() {
let result = obj["result"].as_str().unwrap_or("unknown");
if result == "ok" {
continue;
}
let uri = obj["rsync_uri"].as_str().unwrap_or("").to_string();
let hash = obj["sha256_hex"]
.as_str()
.map(str::to_string)
.or_else(|| object_hash_by_uri.get(&uri).cloned())
.unwrap_or_default();
let kind = obj["kind"].as_str().unwrap_or("other").to_string();
let detail = obj["detail"].as_str().map(str::to_string);
let reason_code = classify_reason(detail.as_deref(), result);
*dropped_by_kind.entry(kind.clone()).or_insert(0) += 1;
*dropped_by_reason.entry(reason_code.clone()).or_insert(0) += 1;
let mut derived_vrp_count = 0usize;
if kind == "roa" && !hash.is_empty() {
let bytes_opt = if let Some(static_root) = static_root.as_ref() {
match resolve_static_pool_file(static_root, &hash) {
Ok(path) => std::fs::read(&path).ok(),
Err(_) => None,
}
} else if let Some(raw_store_db) = raw_store_db.as_ref() {
ExternalRawStoreDb::open(raw_store_db)
.ok()
.and_then(|store| store.get_raw_entry(&hash).ok().flatten())
.map(|entry| entry.bytes)
} else {
None
};
match bytes_opt {
Some(bytes) => {
if let Ok(roa) = RoaObject::decode_der(&bytes) {
for family in roa.roa.ip_addr_blocks {
for addr in family.addresses {
let prefix = match addr.prefix.afi {
rpki::data_model::roa::RoaAfi::Ipv4 => format!(
"{}.{}.{}.{}/{}",
addr.prefix.addr[0],
addr.prefix.addr[1],
addr.prefix.addr[2],
addr.prefix.addr[3],
addr.prefix.prefix_len
),
rpki::data_model::roa::RoaAfi::Ipv6 => {
let bytes: [u8; 16] = addr.prefix.addr;
format!(
"{}/{}",
std::net::Ipv6Addr::from(bytes),
addr.prefix.prefix_len
)
}
};
let max_len = addr.max_length.unwrap_or(addr.prefix.prefix_len);
dropped_vrp_rows.insert((roa.roa.as_id, prefix, max_len));
derived_vrp_count += 1;
}
}
} else {
unknown_roa_objects += 1;
}
}
None => unknown_roa_objects += 1,
}
}
dropped_objects.push(DroppedObjectRecord {
uri,
sha256: hash,
kind,
reason_code,
reason_text: detail,
publication_point: publication_point.clone(),
manifest_uri: manifest_uri.clone(),
derived_vrp_count,
});
}
}
let output = serde_json::json!({
"summary": {
"finalVrpCount": vrps.len(),
"finalVapCount": vaps.len(),
"droppedVrpCount": dropped_vrp_rows.len(),
"droppedObjectCount": dropped_objects.len(),
"droppedByKind": dropped_by_kind,
"droppedByReason": dropped_by_reason,
"unknownDroppedRoaObjects": unknown_roa_objects,
},
"objects": dropped_objects,
});
if let Some(parent) = json_out.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create json parent failed: {e}"))?;
}
std::fs::write(&json_out, serde_json::to_vec_pretty(&output).unwrap())
.map_err(|e| format!("write json failed: {e}"))?;
let mut md = String::new();
md.push_str("# CIR Drop Report\n\n");
md.push_str(&format!("- `final_vrp_count`: `{}`\n", vrps.len()));
md.push_str(&format!("- `final_vap_count`: `{}`\n", vaps.len()));
md.push_str(&format!(
"- `dropped_vrp_count`: `{}`\n",
output["summary"]["droppedVrpCount"]
));
md.push_str(&format!(
"- `dropped_object_count`: `{}`\n",
output["summary"]["droppedObjectCount"]
));
md.push_str(&format!(
"- `unknown_dropped_roa_objects`: `{}`\n\n",
output["summary"]["unknownDroppedRoaObjects"]
));
md.push_str("## Dropped By Kind\n\n");
for (kind, count) in output["summary"]["droppedByKind"]
.as_object()
.into_iter()
.flatten()
{
md.push_str(&format!("- `{kind}`: `{}`\n", count.as_u64().unwrap_or(0)));
}
md.push_str("\n## Dropped By Reason\n\n");
for (reason, count) in output["summary"]["droppedByReason"]
.as_object()
.into_iter()
.flatten()
{
md.push_str(&format!(
"- `{reason}`: `{}`\n",
count.as_u64().unwrap_or(0)
));
}
if let Some(parent) = md_out.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create markdown parent failed: {e}"))?;
}
std::fs::write(&md_out, md).map_err(|e| format!("write markdown failed: {e}"))?;
Ok(())
}

View File

@@ -0,0 +1,82 @@
use std::path::PathBuf;
fn usage() -> &'static str {
"Usage: cir_extract_inputs --cir <path> --tals-dir <path> --meta-json <path>"
}
fn main() {
if let Err(err) = run(std::env::args().collect()) {
eprintln!("error: {err}");
std::process::exit(2);
}
}
fn run(argv: Vec<String>) -> Result<(), String> {
let mut cir_path: Option<PathBuf> = None;
let mut tals_dir: Option<PathBuf> = None;
let mut meta_json: Option<PathBuf> = None;
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--help" | "-h" => return Err(usage().to_string()),
"--cir" => {
i += 1;
cir_path = Some(PathBuf::from(argv.get(i).ok_or("--cir requires a value")?));
}
"--tals-dir" => {
i += 1;
tals_dir = Some(PathBuf::from(
argv.get(i).ok_or("--tals-dir requires a value")?,
));
}
"--meta-json" => {
i += 1;
meta_json = Some(PathBuf::from(
argv.get(i).ok_or("--meta-json requires a value")?,
));
}
other => return Err(format!("unknown argument: {other}\n\n{}", usage())),
}
i += 1;
}
let cir_path = cir_path.ok_or_else(|| format!("--cir is required\n\n{}", usage()))?;
let tals_dir = tals_dir.ok_or_else(|| format!("--tals-dir is required\n\n{}", usage()))?;
let meta_json = meta_json.ok_or_else(|| format!("--meta-json is required\n\n{}", usage()))?;
let bytes = std::fs::read(&cir_path)
.map_err(|e| format!("read CIR failed: {}: {e}", cir_path.display()))?;
let cir = rpki::cir::decode_cir(&bytes).map_err(|e| e.to_string())?;
std::fs::create_dir_all(&tals_dir)
.map_err(|e| format!("create tals dir failed: {}: {e}", tals_dir.display()))?;
let mut tal_files = Vec::new();
for (idx, tal) in cir.tals.iter().enumerate() {
let filename = format!("tal-{:03}.tal", idx + 1);
let path = tals_dir.join(filename);
std::fs::write(&path, &tal.tal_bytes)
.map_err(|e| format!("write TAL failed: {}: {e}", path.display()))?;
tal_files.push(serde_json::json!({
"talUri": tal.tal_uri,
"path": path,
}));
}
let validation_time = cir
.validation_time
.format(&time::format_description::well_known::Rfc3339)
.map_err(|e| format!("format validationTime failed: {e}"))?;
let meta = serde_json::json!({
"validationTime": validation_time,
"talFiles": tal_files,
});
if let Some(parent) = meta_json.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create meta parent failed: {}: {e}", parent.display()))?;
}
std::fs::write(&meta_json, serde_json::to_vec_pretty(&meta).unwrap())
.map_err(|e| format!("write meta json failed: {}: {e}", meta_json.display()))?;
Ok(())
}

View File

@@ -0,0 +1,96 @@
use std::path::PathBuf;
fn usage() -> &'static str {
"Usage: cir_materialize --cir <path> (--static-root <path> | --raw-store-db <path>) --mirror-root <path> [--keep-db]"
}
fn main() {
if let Err(err) = run(std::env::args().collect()) {
eprintln!("error: {err}");
std::process::exit(2);
}
}
fn run(argv: Vec<String>) -> Result<(), String> {
let mut cir_path: Option<PathBuf> = None;
let mut static_root: Option<PathBuf> = None;
let mut raw_store_db: Option<PathBuf> = None;
let mut mirror_root: Option<PathBuf> = None;
let mut keep_db = false;
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--help" | "-h" => return Err(usage().to_string()),
"--cir" => {
i += 1;
cir_path = Some(PathBuf::from(argv.get(i).ok_or("--cir requires a value")?));
}
"--static-root" => {
i += 1;
static_root = Some(PathBuf::from(
argv.get(i).ok_or("--static-root requires a value")?,
));
}
"--raw-store-db" => {
i += 1;
raw_store_db = Some(PathBuf::from(
argv.get(i).ok_or("--raw-store-db requires a value")?,
));
}
"--mirror-root" => {
i += 1;
mirror_root = Some(PathBuf::from(
argv.get(i).ok_or("--mirror-root requires a value")?,
));
}
"--keep-db" => keep_db = true,
other => return Err(format!("unknown argument: {other}\n\n{}", usage())),
}
i += 1;
}
let cir_path = cir_path.ok_or_else(|| format!("--cir is required\n\n{}", usage()))?;
let mirror_root =
mirror_root.ok_or_else(|| format!("--mirror-root is required\n\n{}", usage()))?;
let backend_count = static_root.is_some() as u8 + raw_store_db.is_some() as u8;
if backend_count != 1 {
return Err(format!(
"must specify exactly one of --static-root or --raw-store-db\n\n{}",
usage()
));
}
let bytes = std::fs::read(&cir_path)
.map_err(|e| format!("read CIR failed: {}: {e}", cir_path.display()))?;
let cir = rpki::cir::decode_cir(&bytes).map_err(|e| e.to_string())?;
let result = if let Some(static_root) = static_root {
rpki::cir::materialize_cir(&cir, &static_root, &mirror_root, true)
.map_err(|e| e.to_string())
} else if let Some(raw_store_db) = raw_store_db {
rpki::cir::materialize_cir_from_raw_store(&cir, &raw_store_db, &mirror_root, true)
.map_err(|e| e.to_string())
} else {
unreachable!("validated backend count")
};
match result {
Ok(summary) => {
eprintln!(
"materialized CIR: mirror={} objects={} linked={} copied={} keep_db={}",
mirror_root.display(),
summary.object_count,
summary.linked_files,
summary.copied_files,
keep_db
);
Ok(())
}
Err(err) => {
if !keep_db && mirror_root.exists() {
let _ = std::fs::remove_dir_all(&mirror_root);
}
Err(err.to_string())
}
}
}

View File

@@ -0,0 +1,124 @@
use std::path::PathBuf;
use rpki::cir::{
CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal, encode_cir,
write_bytes_to_static_pool,
};
use sha2::Digest;
const USAGE: &str = "Usage: cir_ta_only_fixture --tal-path <path> --ta-path <path> --tal-uri <url> --validation-time <rfc3339> --cir-out <path> --static-root <path>";
fn parse_args(
argv: &[String],
) -> Result<
(
PathBuf,
PathBuf,
String,
time::OffsetDateTime,
PathBuf,
PathBuf,
),
String,
> {
let mut tal_path = None;
let mut ta_path = None;
let mut tal_uri = None;
let mut validation_time = None;
let mut cir_out = None;
let mut static_root = None;
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--tal-path" => {
i += 1;
tal_path = Some(PathBuf::from(
argv.get(i).ok_or("--tal-path requires a value")?,
));
}
"--ta-path" => {
i += 1;
ta_path = Some(PathBuf::from(
argv.get(i).ok_or("--ta-path requires a value")?,
));
}
"--tal-uri" => {
i += 1;
tal_uri = Some(argv.get(i).ok_or("--tal-uri requires a value")?.clone());
}
"--validation-time" => {
i += 1;
let raw = argv.get(i).ok_or("--validation-time requires a value")?;
validation_time = Some(
time::OffsetDateTime::parse(
raw,
&time::format_description::well_known::Rfc3339,
)
.map_err(|e| format!("invalid validation time: {e}"))?,
);
}
"--cir-out" => {
i += 1;
cir_out = Some(PathBuf::from(
argv.get(i).ok_or("--cir-out requires a value")?,
));
}
"--static-root" => {
i += 1;
static_root = Some(PathBuf::from(
argv.get(i).ok_or("--static-root requires a value")?,
));
}
"-h" | "--help" => return Err(USAGE.to_string()),
other => return Err(format!("unknown argument: {other}\n\n{USAGE}")),
}
i += 1;
}
Ok((
tal_path.ok_or_else(|| format!("--tal-path is required\n\n{USAGE}"))?,
ta_path.ok_or_else(|| format!("--ta-path is required\n\n{USAGE}"))?,
tal_uri.ok_or_else(|| format!("--tal-uri is required\n\n{USAGE}"))?,
validation_time.ok_or_else(|| format!("--validation-time is required\n\n{USAGE}"))?,
cir_out.ok_or_else(|| format!("--cir-out is required\n\n{USAGE}"))?,
static_root.ok_or_else(|| format!("--static-root is required\n\n{USAGE}"))?,
))
}
fn main() -> Result<(), String> {
let argv: Vec<String> = std::env::args().collect();
let (tal_path, ta_path, tal_uri, validation_time, cir_out, static_root) = parse_args(&argv)?;
let tal_bytes = std::fs::read(&tal_path).map_err(|e| format!("read tal failed: {e}"))?;
let ta_bytes = std::fs::read(&ta_path).map_err(|e| format!("read ta failed: {e}"))?;
let tal = rpki::data_model::tal::Tal::decode_bytes(&tal_bytes)
.map_err(|e| format!("decode tal failed: {e}"))?;
let ta_rsync_uri = tal
.ta_uris
.iter()
.find(|uri| uri.scheme() == "rsync")
.ok_or("tal must contain an rsync URI")?
.as_str()
.to_string();
let sha = sha2::Sha256::digest(&ta_bytes);
let hash_hex = hex::encode(sha);
write_bytes_to_static_pool(&static_root, validation_time.date(), &hash_hex, &ta_bytes)
.map_err(|e| format!("write static pool failed: {e}"))?;
let cir = CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time,
objects: vec![CirObject {
rsync_uri: ta_rsync_uri,
sha256: sha.to_vec(),
}],
tals: vec![CirTal { tal_uri, tal_bytes }],
};
let der = encode_cir(&cir).map_err(|e| format!("encode cir failed: {e}"))?;
if let Some(parent) = cir_out.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create cir parent failed: {e}"))?;
}
std::fs::write(&cir_out, der).map_err(|e| format!("write cir failed: {e}"))?;
Ok(())
}

View File

@@ -1,21 +1,35 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
use rocksdb::{ColumnFamilyDescriptor, DB, IteratorMode, Options};
use rocksdb::{DB, IteratorMode, Options};
use rpki::storage::{
ALL_COLUMN_FAMILY_NAMES, CF_AUDIT_RULE_INDEX, CF_RAW_BY_HASH, CF_REPOSITORY_VIEW,
CF_RRDP_SOURCE, CF_RRDP_SOURCE_MEMBER, CF_RRDP_URI_OWNER, CF_VCIR, column_family_descriptors,
};
const CF_RAW_OBJECTS: &str = "raw_objects";
const CF_FETCH_CACHE_PP: &str = "fetch_cache_pp";
const CF_RRDP_STATE: &str = "rrdp_state";
const CF_RRDP_OBJECT_INDEX: &str = "rrdp_object_index";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DbStatsMode {
Estimate,
Exact,
}
fn enable_blobdb_if_supported(opts: &mut Options) {
// Keep this in sync with `rpki::storage`:
// blob files are CF-level options; readers should open CFs with blob enabled too.
#[allow(dead_code)]
fn _set(opts: &mut Options) {
opts.set_enable_blob_files(true);
opts.set_min_blob_size(1024);
#[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",
}
}
_set(opts);
}
fn usage() -> String {
@@ -29,21 +43,15 @@ 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_source + rrdp_source_member + rrdp_uri_owner
"
)
}
fn cf_descriptors() -> Vec<ColumnFamilyDescriptor> {
let mut cf_opts = Options::default();
enable_blobdb_if_supported(&mut cf_opts);
vec![
ColumnFamilyDescriptor::new(CF_RAW_OBJECTS, cf_opts.clone()),
ColumnFamilyDescriptor::new(CF_FETCH_CACHE_PP, cf_opts.clone()),
ColumnFamilyDescriptor::new(CF_RRDP_STATE, cf_opts.clone()),
ColumnFamilyDescriptor::new(CF_RRDP_OBJECT_INDEX, cf_opts),
]
}
fn estimate_keys(db: &DB, cf_name: &str) -> Result<Option<u64>, Box<dyn std::error::Error>> {
let cf = db
.cf_handle(cf_name)
@@ -64,6 +72,32 @@ fn exact_keys(db: &DB, cf_name: &str) -> Result<u64, Box<dyn std::error::Error>>
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_SOURCE | CF_RRDP_SOURCE_MEMBER | CF_RRDP_URI_OWNER => CfGroup::CurrentRrdpState,
_ => 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") {
@@ -72,7 +106,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
let mut db_path: Option<PathBuf> = None;
let mut exact = false;
let mut mode = DbStatsMode::Estimate;
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
@@ -81,7 +115,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let v = argv.get(i).ok_or("--db requires a value")?;
db_path = Some(PathBuf::from(v));
}
"--exact" => exact = true,
"--exact" => mode = DbStatsMode::Exact,
other => return Err(format!("unknown argument: {other}\n\n{}", usage()).into()),
}
i += 1;
@@ -93,33 +127,73 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
opts.create_if_missing(false);
opts.create_missing_column_families(false);
let db = DB::open_cf_descriptors(&opts, &db_path, cf_descriptors())?;
let cf_names = [
CF_RAW_OBJECTS,
CF_FETCH_CACHE_PP,
CF_RRDP_STATE,
CF_RRDP_OBJECT_INDEX,
];
let db = DB::open_cf_descriptors(&opts, &db_path, column_family_descriptors())?;
println!("db={}", db_path.display());
println!("mode={}", if exact { "exact" } else { "estimate" });
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 cf_names {
let n = if exact {
exact_keys(&db, name)?
} else {
estimate_keys(&db, name)?.unwrap_or(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}");
// Also print # of SST files (useful sanity signal).
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("unknown_legacy"), 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_SOURCE_MEMBER, 19),
]);
assert_eq!(grouped.get(&CfGroup::CurrentRepositoryView), Some(&12));
assert_eq!(grouped.get(&CfGroup::CurrentValidationState), Some(&24));
assert_eq!(grouped.get(&CfGroup::CurrentRrdpState), Some(&19));
assert_eq!(grouped.get(&CfGroup::LegacyCompatibility), None);
}
#[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("current_rrdp_state"), "{text}");
}
}

View File

@@ -0,0 +1,257 @@
use rpki::bundle::record_io::load_validation_time;
use rpki::storage::RocksStore;
use rpki::validation::run_tree_from_tal::{
run_tree_from_tal_and_ta_der_payload_delta_replay_step_serial_audit,
run_tree_from_tal_and_ta_der_payload_replay_serial_audit,
};
use rpki::validation::tree::TreeRunConfig;
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
fn usage() -> &'static str {
"Usage: measure_sequence_replay --bundle-root <dir> [--rir <rir[,rir...]>] --out <path> [--keep-db]"
}
#[derive(Default)]
struct Args {
bundle_root: Option<PathBuf>,
rirs: Option<Vec<String>>,
out: Option<PathBuf>,
keep_db: bool,
}
fn parse_args() -> Result<Args, String> {
let mut out = Args::default();
let argv: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0usize;
while i < argv.len() {
match argv[i].as_str() {
"--bundle-root" => {
i += 1;
out.bundle_root = Some(PathBuf::from(
argv.get(i).ok_or("--bundle-root requires a value")?,
));
}
"--rir" => {
i += 1;
let value = argv.get(i).ok_or("--rir requires a value")?;
out.rirs = Some(
value
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect(),
);
}
"--out" => {
i += 1;
out.out = Some(PathBuf::from(argv.get(i).ok_or("--out requires a value")?));
}
"--keep-db" => out.keep_db = true,
"--help" | "-h" => return Err(usage().to_string()),
other => return Err(format!("unknown argument: {other}\n{}", usage())),
}
i += 1;
}
if out.bundle_root.is_none() || out.out.is_none() {
return Err(format!("--bundle-root and --out are required\n{}", usage()));
}
Ok(out)
}
#[derive(Serialize)]
struct PhaseTiming {
duration_seconds: f64,
vrp_count: usize,
vap_count: usize,
}
#[derive(Serialize)]
struct RirTiming {
rir: String,
base: PhaseTiming,
steps: Vec<(String, PhaseTiming)>,
}
fn discover_rirs(bundle_root: &Path) -> Result<Vec<String>, String> {
let mut out = Vec::new();
for entry in fs::read_dir(bundle_root)
.map_err(|e| format!("read_dir failed: {}: {e}", bundle_root.display()))?
{
let entry = entry.map_err(|e| format!("read_dir entry failed: {e}"))?;
let path = entry.path();
if path.is_dir() && path.join("bundle.json").exists() && path.join("tal.tal").exists() {
out.push(
path.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| format!("invalid rir dir name: {}", path.display()))?
.to_string(),
);
}
}
out.sort();
Ok(out)
}
fn path_join(root: &Path, relative: &str) -> PathBuf {
root.join(relative)
}
fn main() {
if let Err(err) = real_main() {
eprintln!("{err}");
std::process::exit(1);
}
}
fn real_main() -> Result<(), String> {
let args = parse_args()?;
let bundle_root = args.bundle_root.unwrap();
let out_path = args.out.unwrap();
let rirs = match args.rirs {
Some(v) => v,
None => discover_rirs(&bundle_root)?,
};
let mut results = Vec::new();
let tmp_root = out_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(".tmp-sequence-replay");
fs::create_dir_all(&tmp_root)
.map_err(|e| format!("create tmp root failed: {}: {e}", tmp_root.display()))?;
for rir in rirs {
let rir_dir = bundle_root.join(&rir);
let bundle: serde_json::Value = serde_json::from_slice(
&fs::read(rir_dir.join("bundle.json"))
.map_err(|e| format!("read bundle failed: {}: {e}", rir_dir.display()))?,
)
.map_err(|e| format!("parse bundle failed for {}: {e}", rir_dir.display()))?;
let tal_bytes = fs::read(rir_dir.join("tal.tal"))
.map_err(|e| format!("read tal.tal failed for {}: {e}", rir_dir.display()))?;
let ta_bytes = fs::read(rir_dir.join("ta.cer"))
.map_err(|e| format!("read ta.cer failed for {}: {e}", rir_dir.display()))?;
let db_dir = tmp_root.join(format!("{rir}-db"));
if db_dir.exists() {
fs::remove_dir_all(&db_dir)
.map_err(|e| format!("remove old db failed: {}: {e}", db_dir.display()))?;
}
let store =
RocksStore::open(&db_dir).map_err(|e| format!("open rocksdb failed for {rir}: {e}"))?;
let base_archive = path_join(
&rir_dir,
bundle["base"]["relativeArchivePath"]
.as_str()
.ok_or("bundle missing base.relativeArchivePath")?,
);
let base_locks = path_join(
&rir_dir,
bundle["base"]["relativeLocksPath"]
.as_str()
.ok_or("bundle missing base.relativeLocksPath")?,
);
let base_validation_time = load_validation_time(&base_locks)
.map_err(|e| format!("load base validation time failed for {rir}: {e}"))?;
let start = Instant::now();
let base_out = run_tree_from_tal_and_ta_der_payload_replay_serial_audit(
&store,
&rpki::policy::Policy::default(),
&tal_bytes,
&ta_bytes,
None,
&base_archive,
&base_locks,
base_validation_time,
&TreeRunConfig {
max_depth: None,
max_instances: None,
},
)
.map_err(|e| format!("base replay failed for {rir}: {e}"))?;
let base_timing = PhaseTiming {
duration_seconds: start.elapsed().as_secs_f64(),
vrp_count: base_out.tree.vrps.len(),
vap_count: base_out.tree.aspas.len(),
};
let mut previous_locks = base_locks.clone();
let mut step_timings = Vec::new();
for step in bundle["deltaSequence"]["steps"]
.as_array()
.ok_or("bundle missing deltaSequence.steps")?
{
let step_id = step["id"].as_str().ok_or("step missing id")?.to_string();
let step_dir = path_join(
&rir_dir,
step["relativePath"]
.as_str()
.ok_or("step missing relativePath")?,
);
let delta_archive = path_join(
&rir_dir,
step["relativeArchivePath"]
.as_str()
.ok_or("step missing relativeArchivePath")?,
);
let delta_locks = path_join(
&rir_dir,
step["relativeTransitionLocksPath"]
.as_str()
.ok_or("step missing relativeTransitionLocksPath")?,
);
let validation_time = load_validation_time(&delta_locks).map_err(|e| {
format!("load step validation time failed for {rir}/{step_id}: {e}")
})?;
let start = Instant::now();
let step_out = run_tree_from_tal_and_ta_der_payload_delta_replay_step_serial_audit(
&store,
&rpki::policy::Policy::default(),
&tal_bytes,
&ta_bytes,
None,
&delta_archive,
&previous_locks,
&delta_locks,
validation_time,
&TreeRunConfig {
max_depth: None,
max_instances: None,
},
)
.map_err(|e| format!("delta step replay failed for {rir}/{step_id}: {e}"))?;
step_timings.push((
step_id.clone(),
PhaseTiming {
duration_seconds: start.elapsed().as_secs_f64(),
vrp_count: step_out.tree.vrps.len(),
vap_count: step_out.tree.aspas.len(),
},
));
previous_locks = step_dir.join("target-locks.json");
}
results.push(RirTiming {
rir,
base: base_timing,
steps: step_timings,
});
if !args.keep_db && db_dir.exists() {
fs::remove_dir_all(&db_dir)
.map_err(|e| format!("remove db failed: {}: {e}", db_dir.display()))?;
}
}
fs::write(
&out_path,
serde_json::to_vec_pretty(&results).map_err(|e| format!("encode json failed: {e}"))?,
)
.map_err(|e| format!("write out failed: {}: {e}", out_path.display()))?;
println!("{}", out_path.display());
Ok(())
}

View File

@@ -0,0 +1,413 @@
use rpki::bundle::{
RecordingHttpFetcher, RecordingRsyncFetcher, RirBundleMetadata,
build_single_rir_bundle_manifest, build_vap_compare_rows, build_vrp_compare_rows, sha256_hex,
write_json, write_live_base_replay_bundle_inputs, write_live_bundle_rir_readme,
write_live_bundle_top_readme, write_timing_json, write_vap_csv, write_vrp_csv,
};
use rpki::ccr::{build_ccr_from_run, verify_content_info, write_ccr_file};
use rpki::fetch::http::{BlockingHttpFetcher, HttpFetcherConfig};
use rpki::fetch::rsync_system::{SystemRsyncConfig, SystemRsyncFetcher};
use rpki::policy::Policy;
use rpki::storage::RocksStore;
use rpki::validation::run_tree_from_tal::{
run_tree_from_tal_and_ta_der_payload_replay_serial_audit,
run_tree_from_tal_and_ta_der_serial_audit,
};
use rpki::validation::tree::TreeRunConfig;
use std::fs;
use std::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>,
validation_time: Option<time::OffsetDateTime>,
http_timeout_secs: u64,
rsync_timeout_secs: u64,
rsync_mirror_root: Option<PathBuf>,
max_depth: Option<usize>,
max_instances: Option<usize>,
trust_anchor: Option<String>,
}
fn usage() -> &'static str {
"Usage: replay_bundle_capture --rir <name> --out-dir <path> --tal-path <path> --ta-path <path> [--validation-time <rfc3339>] [--http-timeout-secs <n>] [--rsync-timeout-secs <n>] [--rsync-mirror-root <path>] [--max-depth <n>] [--max-instances <n>] [--trust-anchor <name>]"
}
fn parse_args(argv: &[String]) -> Result<Args, String> {
let mut args = Args {
http_timeout_secs: 20,
rsync_timeout_secs: 60,
..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")?,
));
}
"--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}"))?,
);
}
"--http-timeout-secs" => {
i += 1;
args.http_timeout_secs = argv
.get(i)
.ok_or("--http-timeout-secs requires a value")?
.parse()
.map_err(|e| format!("invalid --http-timeout-secs: {e}"))?;
}
"--rsync-timeout-secs" => {
i += 1;
args.rsync_timeout_secs = argv
.get(i)
.ok_or("--rsync-timeout-secs requires a value")?
.parse()
.map_err(|e| format!("invalid --rsync-timeout-secs: {e}"))?;
}
"--rsync-mirror-root" => {
i += 1;
args.rsync_mirror_root = Some(PathBuf::from(
argv.get(i).ok_or("--rsync-mirror-root requires a value")?,
));
}
"--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()));
}
Ok(args)
}
fn run(args: Args) -> Result<PathBuf, String> {
let rir = args.rir.as_ref().unwrap();
let rir_normalized = rir.to_ascii_lowercase();
let trust_anchor = args
.trust_anchor
.clone()
.unwrap_or_else(|| rir_normalized.clone());
let out_root = args.out_dir.as_ref().unwrap();
let rir_dir = out_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(args.tal_path.as_ref().unwrap()).map_err(|e| format!("read tal failed: {e}"))?;
let ta_bytes =
fs::read(args.ta_path.as_ref().unwrap()).map_err(|e| format!("read ta failed: {e}"))?;
let validation_time = args
.validation_time
.unwrap_or_else(time::OffsetDateTime::now_utc);
let db_dir = out_root.join(".tmp").join(format!("{rir}-live-base-db"));
let replay_db_dir = out_root.join(".tmp").join(format!("{rir}-self-replay-db"));
let _ = fs::remove_dir_all(&db_dir);
let _ = fs::remove_dir_all(&replay_db_dir);
if let Some(parent) = db_dir.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create tmp dir failed: {}: {e}", parent.display()))?;
}
let store = RocksStore::open(&db_dir).map_err(|e| format!("open rocksdb failed: {e}"))?;
let http = RecordingHttpFetcher::new(
BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: std::time::Duration::from_secs(args.http_timeout_secs),
..HttpFetcherConfig::default()
})
.map_err(|e| format!("create http fetcher failed: {e}"))?,
);
let rsync = RecordingRsyncFetcher::new(SystemRsyncFetcher::new(SystemRsyncConfig {
timeout: std::time::Duration::from_secs(args.rsync_timeout_secs),
mirror_root: args.rsync_mirror_root.clone(),
..SystemRsyncConfig::default()
}));
let started = Instant::now();
let out = run_tree_from_tal_and_ta_der_serial_audit(
&store,
&Policy::default(),
&tal_bytes,
&ta_bytes,
None,
&http,
&rsync,
validation_time,
&TreeRunConfig {
max_depth: args.max_depth,
max_instances: args.max_instances,
},
)
.map_err(|e| format!("live base run failed: {e}"))?;
let duration = started.elapsed();
let ccr = build_ccr_from_run(
&store,
&[out.discovery.trust_anchor.clone()],
&out.tree.vrps,
&out.tree.aspas,
&out.tree.router_keys,
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 = rpki::ccr::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) = rpki::bundle::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());
}
write_vrp_csv(&rir_dir.join("base-vrps.csv"), &vrp_rows)?;
write_vap_csv(&rir_dir.join("base-vaps.csv"), &vap_rows)?;
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 capture = write_live_base_replay_bundle_inputs(
&rir_dir,
&rir_normalized,
validation_time,
&out.publication_points,
&store,
&http.snapshot_responses(),
&rsync.snapshot_fetches(),
)?;
let replay_store = RocksStore::open(&replay_db_dir)
.map_err(|e| format!("open self replay rocksdb failed: {e}"))?;
let replay_out = run_tree_from_tal_and_ta_der_payload_replay_serial_audit(
&replay_store,
&Policy::default(),
&tal_bytes,
&ta_bytes,
None,
&rir_dir.join("base-payload-archive"),
&rir_dir.join("base-locks.json"),
validation_time,
&TreeRunConfig {
max_depth: args.max_depth,
max_instances: args.max_instances,
},
)
.map_err(|e| format!("self replay failed: {e}"))?;
let replay_vrps = build_vrp_compare_rows(&replay_out.tree.vrps, &trust_anchor);
let replay_vaps = build_vap_compare_rows(&replay_out.tree.aspas, &trust_anchor);
if replay_vrps != vrp_rows {
return Err("self replay VRP compare view mismatch".to_string());
}
if replay_vaps != vap_rows {
return Err("self replay VAP compare view mismatch".to_string());
}
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",
&validation_time,
duration,
)?;
let metadata = RirBundleMetadata {
schema_version: "20260330-v1".to_string(),
bundle_producer: "ours".to_string(),
rir: rir_normalized.clone(),
base_validation_time: validation_time
.format(&Rfc3339)
.map_err(|e| format!("format validation time failed: {e}"))?,
delta_validation_time: None,
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,
};
write_json(&rir_dir.join("bundle.json"), &metadata)?;
write_json(
&rir_dir.join("verification.json"),
&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,
},
"capture": {
"captureId": capture.capture_id,
"rrdpRepoCount": capture.rrdp_repo_count,
"rsyncModuleCount": capture.rsync_module_count,
"selfReplayOk": true,
}
}
}),
)?;
write_live_bundle_top_readme(&out_root.join("README.md"), &rir_normalized)?;
write_live_bundle_rir_readme(
&rir_dir.join("README.md"),
&rir_normalized,
&metadata.base_validation_time,
)?;
write_json(
&out_root.join("bundle-manifest.json"),
&build_single_rir_bundle_manifest(
"20260330-v1",
"ours",
&rir_normalized,
&validation_time,
None,
metadata.has_aspa,
)?,
)?;
let _ = fs::remove_dir_all(&db_dir);
let _ = fs::remove_dir_all(&replay_db_dir);
Ok(out_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;
#[test]
fn parse_args_requires_required_flags() {
let argv = vec![
"replay_bundle_capture".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(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(args.rir.as_deref(), Some("apnic"));
assert_eq!(args.out_dir.as_deref(), Some(std::path::Path::new("out")));
assert_eq!(args.http_timeout_secs, 20);
assert_eq!(args.rsync_timeout_secs, 60);
}
#[test]
fn parse_args_rejects_missing_requireds() {
let err = parse_args(&["replay_bundle_capture".to_string()]).unwrap_err();
assert!(err.contains("--rir is required"), "{err}");
}
#[test]
fn write_timing_json_writes_duration_and_mode() {
let td = tempdir().expect("tempdir");
let path = td.path().join("timings/base-produce.json");
write_timing_json(
&path,
"base",
&time::OffsetDateTime::parse("2026-03-30T00:00:00Z", &Rfc3339).expect("time"),
std::time::Duration::from_millis(1500),
)
.expect("write timing");
let json: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).expect("read timing")).expect("parse");
assert_eq!(json["mode"], "base");
assert_eq!(json["durationSeconds"], 1.5);
}
}

View File

@@ -0,0 +1,483 @@
use rpki::bundle::{
RecordingHttpFetcher, RecordingRsyncFetcher, build_single_rir_bundle_manifest,
build_vap_compare_rows, build_vrp_compare_rows, copy_dir_all, load_validation_time, sha256_hex,
write_json, write_live_delta_replay_bundle_inputs, write_vap_csv, write_vrp_csv,
};
use rpki::ccr::{build_ccr_from_run, decode_content_info, verify_content_info, write_ccr_file};
use rpki::fetch::http::{BlockingHttpFetcher, HttpFetcherConfig};
use rpki::fetch::rsync_system::{SystemRsyncConfig, SystemRsyncFetcher};
use rpki::policy::Policy;
use rpki::storage::RocksStore;
use rpki::sync::rrdp::Fetcher;
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,
run_tree_from_tal_and_ta_der_serial_audit,
};
use rpki::validation::tree::TreeRunConfig;
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>,
base_bundle_dir: Option<PathBuf>,
out_dir: Option<PathBuf>,
validation_time: Option<time::OffsetDateTime>,
http_timeout_secs: u64,
rsync_timeout_secs: u64,
rsync_mirror_root: Option<PathBuf>,
max_depth: Option<usize>,
max_instances: Option<usize>,
trust_anchor: Option<String>,
}
fn usage() -> &'static str {
"Usage: replay_bundle_capture_delta --rir <name> --base-bundle-dir <path> --out-dir <path> [--validation-time <rfc3339>] [--http-timeout-secs <n>] [--rsync-timeout-secs <n>] [--rsync-mirror-root <path>] [--max-depth <n>] [--max-instances <n>] [--trust-anchor <name>]"
}
fn parse_args(argv: &[String]) -> Result<Args, String> {
let mut args = Args {
http_timeout_secs: 20,
rsync_timeout_secs: 60,
..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());
}
"--base-bundle-dir" => {
i += 1;
args.base_bundle_dir = Some(PathBuf::from(
argv.get(i).ok_or("--base-bundle-dir requires a value")?,
));
}
"--out-dir" => {
i += 1;
args.out_dir = Some(PathBuf::from(
argv.get(i).ok_or("--out-dir 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}"))?,
);
}
"--http-timeout-secs" => {
i += 1;
args.http_timeout_secs = argv
.get(i)
.ok_or("--http-timeout-secs requires a value")?
.parse()
.map_err(|e| format!("invalid --http-timeout-secs: {e}"))?;
}
"--rsync-timeout-secs" => {
i += 1;
args.rsync_timeout_secs = argv
.get(i)
.ok_or("--rsync-timeout-secs requires a value")?
.parse()
.map_err(|e| format!("invalid --rsync-timeout-secs: {e}"))?;
}
"--rsync-mirror-root" => {
i += 1;
args.rsync_mirror_root = Some(PathBuf::from(
argv.get(i).ok_or("--rsync-mirror-root requires a value")?,
));
}
"--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.base_bundle_dir.is_none() {
return Err(format!("--base-bundle-dir is required\n{}", usage()));
}
if args.out_dir.is_none() {
return Err(format!("--out-dir is required\n{}", usage()));
}
Ok(args)
}
fn ensure_recorded_target_snapshots(
store: &RocksStore,
base_bundle_dir: &Path,
http: &RecordingHttpFetcher<BlockingHttpFetcher>,
) -> Result<(), String> {
let base_locks: serde_json::Value = serde_json::from_slice(
&fs::read(base_bundle_dir.join("base-locks.json"))
.map_err(|e| format!("read base locks failed: {e}"))?,
)
.map_err(|e| format!("parse base locks failed: {e}"))?;
let base_rrdp = base_locks
.get("rrdp")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
for (notify_uri, base_lock) in base_rrdp {
let Some(base_transport) = base_lock.get("transport").and_then(|v| v.as_str()) else {
continue;
};
if base_transport != "rrdp" {
continue;
}
let Some(base_session) = base_lock.get("session").and_then(|v| v.as_str()) else {
continue;
};
let Some(base_serial) = base_lock.get("serial").and_then(|v| v.as_u64()) else {
continue;
};
let Some(record) = store
.get_rrdp_source_record(&notify_uri)
.map_err(|e| format!("read rrdp source record failed for {notify_uri}: {e}"))?
else {
continue;
};
let Some(target_session) = record.last_session_id.as_deref() else {
continue;
};
let Some(target_serial) = record.last_serial else {
continue;
};
if target_session != base_session || target_serial <= base_serial {
continue;
}
let Some(snapshot_uri) = record.last_snapshot_uri.as_deref() else {
continue;
};
if http.snapshot_responses().contains_key(snapshot_uri) {
continue;
}
let _ = http
.fetch(snapshot_uri)
.map_err(|e| format!("fetch target snapshot for {notify_uri} failed: {e}"))?;
}
Ok(())
}
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 base_root = args.base_bundle_dir.as_ref().unwrap();
let base_rir_dir = base_root.join(&rir_normalized);
if !base_rir_dir.is_dir() {
return Err(format!(
"base bundle rir dir not found: {}",
base_rir_dir.display()
));
}
if out_root.exists() {
fs::remove_dir_all(out_root)
.map_err(|e| format!("remove old out dir failed: {}: {e}", out_root.display()))?;
}
copy_dir_all(base_root, out_root)?;
let rir_dir = out_root.join(&rir_normalized);
let trust_anchor = args
.trust_anchor
.clone()
.unwrap_or_else(|| rir_normalized.clone());
let tal_bytes = fs::read(rir_dir.join("tal.tal"))
.map_err(|e| format!("read tal from base bundle failed: {e}"))?;
let ta_bytes = fs::read(rir_dir.join("ta.cer"))
.map_err(|e| format!("read ta from base bundle failed: {e}"))?;
let base_validation_time = load_validation_time(&rir_dir.join("base-locks.json"))?;
let target_validation_time = args
.validation_time
.unwrap_or_else(time::OffsetDateTime::now_utc);
let target_store_dir = out_root.join(".tmp").join(format!("{rir}-live-target-db"));
let self_replay_dir = out_root.join(".tmp").join(format!("{rir}-self-delta-db"));
let _ = fs::remove_dir_all(&target_store_dir);
let _ = fs::remove_dir_all(&self_replay_dir);
if let Some(parent) = target_store_dir.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create tmp dir failed: {}: {e}", parent.display()))?;
}
let target_store = RocksStore::open(&target_store_dir)
.map_err(|e| format!("open target rocksdb failed: {e}"))?;
let _base = run_tree_from_tal_and_ta_der_payload_replay_serial_audit(
&target_store,
&Policy::default(),
&tal_bytes,
&ta_bytes,
None,
&rir_dir.join("base-payload-archive"),
&rir_dir.join("base-locks.json"),
base_validation_time,
&TreeRunConfig {
max_depth: args.max_depth,
max_instances: args.max_instances,
},
)
.map_err(|e| format!("base bootstrap replay failed: {e}"))?;
let http = RecordingHttpFetcher::new(
BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: std::time::Duration::from_secs(args.http_timeout_secs),
..HttpFetcherConfig::default()
})
.map_err(|e| format!("create http fetcher failed: {e}"))?,
);
let rsync = RecordingRsyncFetcher::new(SystemRsyncFetcher::new(SystemRsyncConfig {
timeout: std::time::Duration::from_secs(args.rsync_timeout_secs),
mirror_root: args.rsync_mirror_root.clone(),
..SystemRsyncConfig::default()
}));
let started = Instant::now();
let target_out = run_tree_from_tal_and_ta_der_serial_audit(
&target_store,
&Policy::default(),
&tal_bytes,
&ta_bytes,
None,
&http,
&rsync,
target_validation_time,
&TreeRunConfig {
max_depth: args.max_depth,
max_instances: args.max_instances,
},
)
.map_err(|e| format!("live target run failed: {e}"))?;
let duration = started.elapsed();
ensure_recorded_target_snapshots(&target_store, &rir_dir, &http)?;
let delta_ccr = build_ccr_from_run(
&target_store,
&[target_out.discovery.trust_anchor.clone()],
&target_out.tree.vrps,
&target_out.tree.aspas,
&target_out.tree.router_keys,
target_validation_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 delta ccr failed: {}: {e}", delta_ccr_path.display()))?;
let delta_decoded = decode_content_info(&delta_ccr_bytes)
.map_err(|e| format!("decode 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(&target_out.tree.vrps, &trust_anchor);
let delta_vap_rows = build_vap_compare_rows(&target_out.tree.aspas, &trust_anchor);
let (ccr_vrps, ccr_vaps) =
rpki::bundle::decode_ccr_compare_views(&delta_decoded, &trust_anchor)?;
if delta_vrp_rows != ccr_vrps {
return Err("record-delta.csv compare view does not match delta.ccr".to_string());
}
if delta_vap_rows != 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)?;
let capture = write_live_delta_replay_bundle_inputs(
&rir_dir,
&rir_normalized,
target_validation_time,
&target_out.publication_points,
&target_store,
&http.snapshot_responses(),
&rsync.snapshot_fetches(),
)?;
let self_store = RocksStore::open(&self_replay_dir)
.map_err(|e| format!("open self replay db failed: {e}"))?;
let replay_out = run_tree_from_tal_and_ta_der_payload_delta_replay_serial_audit(
&self_store,
&Policy::default(),
&tal_bytes,
&ta_bytes,
None,
&rir_dir.join("base-payload-archive"),
&rir_dir.join("base-locks.json"),
&rir_dir.join("payload-delta-archive"),
&rir_dir.join("locks-delta.json"),
base_validation_time,
target_validation_time,
&TreeRunConfig {
max_depth: args.max_depth,
max_instances: args.max_instances,
},
)
.map_err(|e| format!("self delta replay failed: {e}"))?;
let replay_vrps = build_vrp_compare_rows(&replay_out.tree.vrps, &trust_anchor);
let replay_vaps = build_vap_compare_rows(&replay_out.tree.aspas, &trust_anchor);
if replay_vrps != delta_vrp_rows {
return Err("self delta replay VRP compare view mismatch".to_string());
}
if replay_vaps != delta_vap_rows {
return Err("self delta replay VAP compare view mismatch".to_string());
}
fs::create_dir_all(rir_dir.join("timings"))
.map_err(|e| format!("create timings dir failed: {e}"))?;
write_json(
&rir_dir.join("timings").join("delta-produce.json"),
&serde_json::json!({
"mode": "delta",
"validationTime": target_validation_time
.format(&Rfc3339)
.map_err(|e| format!("format validation time failed: {e}"))?,
"durationSeconds": duration.as_secs_f64(),
}),
)?;
let mut bundle_json: serde_json::Value = serde_json::from_slice(
&fs::read(rir_dir.join("bundle.json"))
.map_err(|e| format!("read base bundle.json failed: {e}"))?,
)
.map_err(|e| format!("parse base bundle.json failed: {e}"))?;
bundle_json["deltaValidationTime"] = serde_json::Value::String(
target_validation_time
.format(&Rfc3339)
.map_err(|e| format!("format delta validation time failed: {e}"))?,
);
bundle_json["deltaCcrSha256"] = serde_json::Value::String(sha256_hex(&delta_ccr_bytes));
bundle_json["deltaVrpCount"] = serde_json::Value::from(delta_vrp_rows.len() as u64);
bundle_json["deltaVapCount"] = serde_json::Value::from(delta_vap_rows.len() as u64);
bundle_json["hasAspa"] = serde_json::Value::Bool(
bundle_json
.get("hasAspa")
.and_then(|v| v.as_bool())
.unwrap_or(false)
|| !delta_vap_rows.is_empty(),
);
bundle_json["hasRouterKey"] = serde_json::Value::Bool(
bundle_json
.get("hasRouterKey")
.and_then(|v| v.as_bool())
.unwrap_or(false)
|| delta_verify.router_key_count > 0,
);
write_json(&rir_dir.join("bundle.json"), &bundle_json)?;
let mut verification_json: serde_json::Value = serde_json::from_slice(
&fs::read(rir_dir.join("verification.json"))
.map_err(|e| format!("read base verification.json failed: {e}"))?,
)
.map_err(|e| format!("parse base verification.json failed: {e}"))?;
verification_json["delta"] = serde_json::json!({
"validationTime": target_validation_time
.format(&Rfc3339)
.map_err(|e| format!("format delta validation time failed: {e}"))?,
"ccr": {
"path": "delta.ccr",
"sha256": sha256_hex(&delta_ccr_bytes),
"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": delta_vrp_rows.len(),
"deltaVapCount": delta_vap_rows.len(),
},
"capture": {
"captureId": capture.capture_id,
"rrdpRepoCount": capture.rrdp_repo_count,
"rsyncModuleCount": capture.rsync_module_count,
"selfReplayOk": true,
}
});
write_json(&rir_dir.join("verification.json"), &verification_json)?;
let bundle_manifest = build_single_rir_bundle_manifest(
"20260330-v1",
"ours",
&rir_normalized,
&base_validation_time,
Some(&target_validation_time),
bundle_json["hasAspa"].as_bool().unwrap_or(false),
)?;
write_json(&out_root.join("bundle-manifest.json"), &bundle_manifest)?;
let _ = fs::remove_dir_all(&target_store_dir);
let _ = fs::remove_dir_all(&self_replay_dir);
Ok(out_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::*;
#[test]
fn parse_args_requires_required_flags() {
let argv = vec![
"replay_bundle_capture_delta".to_string(),
"--rir".to_string(),
"apnic".to_string(),
"--base-bundle-dir".to_string(),
"base".to_string(),
"--out-dir".to_string(),
"out".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(args.rir.as_deref(), Some("apnic"));
assert_eq!(args.base_bundle_dir.as_deref(), Some(Path::new("base")));
assert_eq!(args.out_dir.as_deref(), Some(Path::new("out")));
}
#[test]
fn parse_args_rejects_missing_requireds() {
let err = parse_args(&["replay_bundle_capture_delta".to_string()]).unwrap_err();
assert!(err.contains("--rir is required"), "{err}");
}
}

View File

@@ -0,0 +1,990 @@
use rpki::bundle::{
BaseBundleStateMetadataV2, BundleManifestEntryV2, BundleManifestV2, DeltaSequenceMetadataV2,
DeltaStepMetadataV2, RecordingHttpFetcher, RecordingRsyncFetcher, RirBundleMetadataV2,
build_vap_compare_rows, build_vrp_compare_rows, sha256_hex, write_current_replay_state_locks,
write_json, write_live_base_replay_bundle_inputs, write_live_delta_replay_step_inputs,
write_vap_csv, write_vrp_csv,
};
use rpki::ccr::{
CcrVerifySummary, build_ccr_from_run, decode_content_info, verify_content_info, write_ccr_file,
};
use rpki::fetch::http::{BlockingHttpFetcher, HttpFetcherConfig};
use rpki::fetch::rsync_system::{SystemRsyncConfig, SystemRsyncFetcher};
use rpki::policy::Policy;
use rpki::storage::RocksStore;
use rpki::sync::rrdp::Fetcher;
use rpki::validation::run_tree_from_tal::{
run_tree_from_tal_and_ta_der_payload_delta_replay_step_serial_audit,
run_tree_from_tal_and_ta_der_payload_replay_serial_audit,
run_tree_from_tal_and_ta_der_serial_audit,
};
use rpki::validation::tree::TreeRunConfig;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, 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>,
base_validation_time: Option<time::OffsetDateTime>,
delta_count: usize,
delta_interval_secs: u64,
http_timeout_secs: u64,
rsync_timeout_secs: u64,
rsync_mirror_root: Option<PathBuf>,
max_depth: Option<usize>,
max_instances: Option<usize>,
trust_anchor: Option<String>,
keep_db: bool,
capture_inputs_only: bool,
}
fn usage() -> &'static str {
"Usage: replay_bundle_capture_sequence --rir <name> --out-dir <path> --tal-path <path> --ta-path <path> [--base-validation-time <rfc3339>] [--delta-count <n>] [--delta-interval-secs <n>] [--http-timeout-secs <n>] [--rsync-timeout-secs <n>] [--rsync-mirror-root <path>] [--max-depth <n>] [--max-instances <n>] [--trust-anchor <name>] [--keep-db] [--capture-inputs-only]"
}
fn parse_args(argv: &[String]) -> Result<Args, String> {
let mut args = Args {
delta_count: 5,
delta_interval_secs: 600,
http_timeout_secs: 20,
rsync_timeout_secs: 60,
..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")?,
));
}
"--base-validation-time" => {
i += 1;
let value = argv
.get(i)
.ok_or("--base-validation-time requires a value")?;
args.base_validation_time = Some(
time::OffsetDateTime::parse(value, &Rfc3339)
.map_err(|e| format!("invalid --base-validation-time: {e}"))?,
);
}
"--delta-count" => {
i += 1;
args.delta_count = argv
.get(i)
.ok_or("--delta-count requires a value")?
.parse()
.map_err(|e| format!("invalid --delta-count: {e}"))?;
}
"--delta-interval-secs" => {
i += 1;
args.delta_interval_secs = argv
.get(i)
.ok_or("--delta-interval-secs requires a value")?
.parse()
.map_err(|e| format!("invalid --delta-interval-secs: {e}"))?;
}
"--http-timeout-secs" => {
i += 1;
args.http_timeout_secs = argv
.get(i)
.ok_or("--http-timeout-secs requires a value")?
.parse()
.map_err(|e| format!("invalid --http-timeout-secs: {e}"))?;
}
"--rsync-timeout-secs" => {
i += 1;
args.rsync_timeout_secs = argv
.get(i)
.ok_or("--rsync-timeout-secs requires a value")?
.parse()
.map_err(|e| format!("invalid --rsync-timeout-secs: {e}"))?;
}
"--rsync-mirror-root" => {
i += 1;
args.rsync_mirror_root = Some(PathBuf::from(
argv.get(i).ok_or("--rsync-mirror-root requires a value")?,
));
}
"--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(),
);
}
"--keep-db" => args.keep_db = true,
"--capture-inputs-only" => args.capture_inputs_only = true,
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()));
}
Ok(args)
}
fn write_v2_top_readme(
path: &Path,
rir: &str,
delta_count: usize,
delta_interval_secs: u64,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create parent failed: {}: {e}", parent.display()))?;
}
fs::write(
path,
format!(
"# Ours Multi-Delta Replay Bundle\n\n- RIR: `{rir}`\n- Schema: `20260401-v2`\n- Configured delta steps: `{delta_count}`\n- Configured interval seconds: `{delta_interval_secs}`\n"
),
)
.map_err(|e| format!("write readme failed: {}: {e}", path.display()))
}
fn write_v2_rir_readme(
path: &Path,
rir: &str,
base_validation_time: &str,
delta_count: usize,
delta_interval_secs: u64,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create parent failed: {}: {e}", parent.display()))?;
}
fs::write(
path,
format!(
"# {rir} multi-delta live replay bundle\n\n- `base-locks.json.validationTime` = `{base_validation_time}`\n- `delta-steps/` contains `{delta_count}` ordered target steps\n- configured interval seconds = `{delta_interval_secs}`\n"
),
)
.map_err(|e| format!("write rir readme failed: {}: {e}", path.display()))
}
fn ensure_recorded_target_snapshots_for_locks(
store: &RocksStore,
previous_locks_path: &Path,
http: &RecordingHttpFetcher<BlockingHttpFetcher>,
) -> Result<(), String> {
let previous_locks: serde_json::Value =
serde_json::from_slice(&fs::read(previous_locks_path).map_err(|e| {
format!(
"read previous locks failed: {}: {e}",
previous_locks_path.display()
)
})?)
.map_err(|e| {
format!(
"parse previous locks failed: {}: {e}",
previous_locks_path.display()
)
})?;
let previous_rrdp = previous_locks
.get("rrdp")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
for (notify_uri, base_lock) in previous_rrdp {
let Some(base_transport) = base_lock.get("transport").and_then(|v| v.as_str()) else {
continue;
};
if base_transport != "rrdp" {
continue;
}
let Some(base_session) = base_lock.get("session").and_then(|v| v.as_str()) else {
continue;
};
let Some(base_serial) = base_lock.get("serial").and_then(|v| v.as_u64()) else {
continue;
};
let Some(record) = store
.get_rrdp_source_record(&notify_uri)
.map_err(|e| format!("read rrdp source record failed for {notify_uri}: {e}"))?
else {
continue;
};
let Some(target_session) = record.last_session_id.as_deref() else {
continue;
};
let Some(target_serial) = record.last_serial else {
continue;
};
if target_session != base_session || target_serial <= base_serial {
continue;
}
let Some(snapshot_uri) = record.last_snapshot_uri.as_deref() else {
continue;
};
if step_http_has_snapshot(http, snapshot_uri) {
continue;
}
if let Err(err) = http.fetch(snapshot_uri) {
eprintln!(
"[sequence] warning: fetch target snapshot failed notify_uri={} snapshot_uri={} err={}",
notify_uri, snapshot_uri, err
);
}
}
Ok(())
}
fn step_http_has_snapshot(
http: &RecordingHttpFetcher<BlockingHttpFetcher>,
snapshot_uri: &str,
) -> bool {
http.snapshot_responses().contains_key(snapshot_uri)
}
fn run(args: Args) -> Result<PathBuf, String> {
let rir = args.rir.as_ref().unwrap();
let rir_normalized = rir.to_ascii_lowercase();
let trust_anchor = args
.trust_anchor
.clone()
.unwrap_or_else(|| rir_normalized.clone());
let out_root = args.out_dir.as_ref().unwrap();
if out_root.exists() {
fs::remove_dir_all(out_root)
.map_err(|e| format!("remove old out dir failed: {}: {e}", out_root.display()))?;
}
let rir_dir = out_root.join(&rir_normalized);
let delta_steps_root = rir_dir.join("delta-steps");
fs::create_dir_all(&delta_steps_root).map_err(|e| {
format!(
"create delta steps dir failed: {}: {e}",
delta_steps_root.display()
)
})?;
let tal_bytes =
fs::read(args.tal_path.as_ref().unwrap()).map_err(|e| format!("read tal failed: {e}"))?;
let ta_bytes =
fs::read(args.ta_path.as_ref().unwrap()).map_err(|e| format!("read ta failed: {e}"))?;
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 base_validation_time = args
.base_validation_time
.unwrap_or_else(time::OffsetDateTime::now_utc);
let work_db_dir = out_root
.join(".tmp")
.join(format!("{rir}-sequence-work-db"));
let base_self_replay_dir = out_root
.join(".tmp")
.join(format!("{rir}-sequence-base-self-replay-db"));
let _ = fs::remove_dir_all(&work_db_dir);
let _ = fs::remove_dir_all(&base_self_replay_dir);
if let Some(parent) = work_db_dir.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create tmp dir failed: {}: {e}", parent.display()))?;
}
let store = RocksStore::open(&work_db_dir).map_err(|e| format!("open work db failed: {e}"))?;
let base_http = RecordingHttpFetcher::new(
BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(args.http_timeout_secs),
..HttpFetcherConfig::default()
})
.map_err(|e| format!("create base http fetcher failed: {e}"))?,
);
let base_rsync_inner = SystemRsyncFetcher::new(SystemRsyncConfig {
timeout: Duration::from_secs(args.rsync_timeout_secs),
mirror_root: args.rsync_mirror_root.clone(),
..SystemRsyncConfig::default()
});
let base_rsync = if args.capture_inputs_only {
RecordingRsyncFetcher::new_without_objects(base_rsync_inner)
} else {
RecordingRsyncFetcher::new(base_rsync_inner)
};
eprintln!("[sequence] base live run start rir={rir_normalized}");
let started = Instant::now();
let base_out = run_tree_from_tal_and_ta_der_serial_audit(
&store,
&Policy::default(),
&tal_bytes,
&ta_bytes,
None,
&base_http,
&base_rsync,
base_validation_time,
&TreeRunConfig {
max_depth: args.max_depth,
max_instances: args.max_instances,
},
)
.map_err(|e| format!("live base run failed: {e}"))?;
let base_duration = started.elapsed();
eprintln!(
"[sequence] base live run done rir={} duration_s={:.3}",
rir_normalized,
base_duration.as_secs_f64()
);
eprintln!("[sequence] base input materialization start rir={rir_normalized}");
let base_capture = write_live_base_replay_bundle_inputs(
&rir_dir,
&rir_normalized,
base_validation_time,
&base_out.publication_points,
&store,
&base_http.snapshot_responses(),
&base_rsync.snapshot_fetches(),
)?;
eprintln!(
"[sequence] base input materialization done rir={} rrdp_repos={} rsync_modules={}",
rir_normalized, base_capture.rrdp_repo_count, base_capture.rsync_module_count
);
let base_ccr_path = rir_dir.join("base.ccr");
let base_vrps_path = rir_dir.join("base-vrps.csv");
let base_vaps_path = rir_dir.join("base-vaps.csv");
let (base_ccr_sha256, base_vrp_rows, base_vap_rows, base_verify, base_self_replay_ok) =
if args.capture_inputs_only {
eprintln!("[sequence] base output generation skipped rir={rir_normalized}");
(
String::new(),
std::collections::BTreeSet::<rpki::bundle::VrpCompareRow>::new(),
std::collections::BTreeSet::<rpki::bundle::VapCompareRow>::new(),
CcrVerifySummary {
content_type_oid: String::new(),
version: 0,
produced_at_rfc3339_utc: String::new(),
state_hashes_ok: false,
manifest_instances: 0,
roa_payload_sets: 0,
roa_vrp_count: 0,
aspa_payload_sets: 0,
trust_anchor_ski_count: 0,
router_key_sets: 0,
router_key_count: 0,
},
false,
)
} else {
eprintln!("[sequence] base CCR/self-replay start rir={rir_normalized}");
let base_ccr = build_ccr_from_run(
&store,
&[base_out.discovery.trust_anchor.clone()],
&base_out.tree.vrps,
&base_out.tree.aspas,
&base_out.tree.router_keys,
base_validation_time,
)
.map_err(|e| format!("build base ccr failed: {e}"))?;
write_ccr_file(&base_ccr_path, &base_ccr)
.map_err(|e| format!("write base ccr failed: {e}"))?;
let base_ccr_bytes = fs::read(&base_ccr_path)
.map_err(|e| format!("read base ccr failed: {}: {e}", base_ccr_path.display()))?;
let base_decoded = decode_content_info(&base_ccr_bytes)
.map_err(|e| format!("decode base ccr failed: {e}"))?;
let base_verify = verify_content_info(&base_decoded)
.map_err(|e| format!("verify base ccr failed: {e}"))?;
let base_vrp_rows = build_vrp_compare_rows(&base_out.tree.vrps, &trust_anchor);
let base_vap_rows = build_vap_compare_rows(&base_out.tree.aspas, &trust_anchor);
let (base_ccr_vrps, base_ccr_vaps) =
rpki::bundle::decode_ccr_compare_views(&base_decoded, &trust_anchor)?;
if base_vrp_rows != base_ccr_vrps {
return Err("base-vrps compare view does not match base.ccr".to_string());
}
if base_vap_rows != base_ccr_vaps {
return Err("base-vaps compare view does not match base.ccr".to_string());
}
write_vrp_csv(&base_vrps_path, &base_vrp_rows)?;
write_vap_csv(&base_vaps_path, &base_vap_rows)?;
let base_replay_store = RocksStore::open(&base_self_replay_dir)
.map_err(|e| format!("open base self replay db failed: {e}"))?;
let base_replay_out = run_tree_from_tal_and_ta_der_payload_replay_serial_audit(
&base_replay_store,
&Policy::default(),
&tal_bytes,
&ta_bytes,
None,
&rir_dir.join("base-payload-archive"),
&rir_dir.join("base-locks.json"),
base_validation_time,
&TreeRunConfig {
max_depth: args.max_depth,
max_instances: args.max_instances,
},
)
.map_err(|e| format!("base self replay failed: {e}"))?;
if build_vrp_compare_rows(&base_replay_out.tree.vrps, &trust_anchor) != base_vrp_rows {
return Err("base self replay VRP compare view mismatch".to_string());
}
if build_vap_compare_rows(&base_replay_out.tree.aspas, &trust_anchor) != base_vap_rows {
return Err("base self replay VAP compare view mismatch".to_string());
}
let output = (
sha256_hex(&base_ccr_bytes),
base_vrp_rows,
base_vap_rows,
base_verify,
true,
);
eprintln!("[sequence] base CCR/self-replay done rir={rir_normalized}");
output
};
fs::create_dir_all(rir_dir.join("timings"))
.map_err(|e| format!("create timings dir failed: {e}"))?;
write_json(
&rir_dir.join("timings").join("base-produce.json"),
&serde_json::json!({
"mode": "base",
"validationTime": base_validation_time.format(&Rfc3339).map_err(|e| format!("format base validation time failed: {e}"))?,
"durationSeconds": base_duration.as_secs_f64(),
}),
)?;
let mut steps_json = Vec::new();
let mut delta_steps = Vec::new();
let mut previous_locks_path = rir_dir.join("base-locks.json");
let mut previous_ref = "base".to_string();
let sequence_self_replay_dir = out_root
.join(".tmp")
.join(format!("{rir}-sequence-self-replay-db"));
let _ = fs::remove_dir_all(&sequence_self_replay_dir);
let sequence_replay_store = if args.capture_inputs_only {
None
} else {
let store = RocksStore::open(&sequence_self_replay_dir)
.map_err(|e| format!("open sequence self replay db failed: {e}"))?;
let _base_replay = run_tree_from_tal_and_ta_der_payload_replay_serial_audit(
&store,
&Policy::default(),
&tal_bytes,
&ta_bytes,
None,
&rir_dir.join("base-payload-archive"),
&rir_dir.join("base-locks.json"),
base_validation_time,
&TreeRunConfig {
max_depth: args.max_depth,
max_instances: args.max_instances,
},
)
.map_err(|e| format!("sequence base self replay failed: {e}"))?;
Some(store)
};
let mut all_steps_self_replay_ok = true;
for step_index in 1..=args.delta_count {
if step_index > 1 && args.delta_interval_secs > 0 {
std::thread::sleep(Duration::from_secs(args.delta_interval_secs));
}
let step_id = format!("step-{step_index:03}");
let step_dir = delta_steps_root.join(&step_id);
fs::create_dir_all(&step_dir)
.map_err(|e| format!("create step dir failed: {}: {e}", step_dir.display()))?;
let step_validation_time = time::OffsetDateTime::now_utc();
eprintln!(
"[sequence] step live run start rir={} step={}",
rir_normalized, step_id
);
let step_http = RecordingHttpFetcher::new(
BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(args.http_timeout_secs),
..HttpFetcherConfig::default()
})
.map_err(|e| format!("create step http fetcher failed: {e}"))?,
);
let step_rsync_inner = SystemRsyncFetcher::new(SystemRsyncConfig {
timeout: Duration::from_secs(args.rsync_timeout_secs),
mirror_root: args.rsync_mirror_root.clone(),
..SystemRsyncConfig::default()
});
let step_rsync = if args.capture_inputs_only {
RecordingRsyncFetcher::new_without_objects(step_rsync_inner)
} else {
RecordingRsyncFetcher::new(step_rsync_inner)
};
let started = Instant::now();
let step_out = run_tree_from_tal_and_ta_der_serial_audit(
&store,
&Policy::default(),
&tal_bytes,
&ta_bytes,
None,
&step_http,
&step_rsync,
step_validation_time,
&TreeRunConfig {
max_depth: args.max_depth,
max_instances: args.max_instances,
},
)
.map_err(|e| format!("live delta step {step_id} failed: {e}"))?;
let step_duration = started.elapsed();
eprintln!(
"[sequence] step live run done rir={} step={} duration_s={:.3}",
rir_normalized,
step_id,
step_duration.as_secs_f64()
);
ensure_recorded_target_snapshots_for_locks(&store, &previous_locks_path, &step_http)?;
eprintln!(
"[sequence] step output generation phase start rir={} step={}",
rir_normalized, step_id
);
let delta_ccr_path = step_dir.join("delta.ccr");
let delta_vrps_path = step_dir.join("record-delta.csv");
let delta_vaps_path = step_dir.join("record-delta-vaps.csv");
let (delta_ccr_sha256, delta_vrp_rows, delta_vap_rows, delta_verify, step_self_replay_ok) =
if args.capture_inputs_only {
eprintln!(
"[sequence] step CCR/self-replay skipped rir={} step={}",
rir_normalized, step_id
);
(
String::new(),
std::collections::BTreeSet::<rpki::bundle::VrpCompareRow>::new(),
std::collections::BTreeSet::<rpki::bundle::VapCompareRow>::new(),
CcrVerifySummary {
content_type_oid: String::new(),
version: 0,
produced_at_rfc3339_utc: String::new(),
state_hashes_ok: false,
manifest_instances: 0,
roa_payload_sets: 0,
roa_vrp_count: 0,
aspa_payload_sets: 0,
trust_anchor_ski_count: 0,
router_key_sets: 0,
router_key_count: 0,
},
false,
)
} else {
eprintln!(
"[sequence] step CCR/self-replay start rir={} step={}",
rir_normalized, step_id
);
let delta_ccr = build_ccr_from_run(
&store,
&[step_out.discovery.trust_anchor.clone()],
&step_out.tree.vrps,
&step_out.tree.aspas,
&step_out.tree.router_keys,
step_validation_time,
)
.map_err(|e| format!("build step ccr failed for {step_id}: {e}"))?;
write_ccr_file(&delta_ccr_path, &delta_ccr)
.map_err(|e| format!("write step ccr failed for {step_id}: {e}"))?;
let delta_ccr_bytes = fs::read(&delta_ccr_path).map_err(|e| {
format!("read step ccr failed: {}: {e}", delta_ccr_path.display())
})?;
let delta_decoded = decode_content_info(&delta_ccr_bytes)
.map_err(|e| format!("decode step ccr failed for {step_id}: {e}"))?;
let delta_verify = verify_content_info(&delta_decoded)
.map_err(|e| format!("verify step ccr failed for {step_id}: {e}"))?;
let delta_vrp_rows = build_vrp_compare_rows(&step_out.tree.vrps, &trust_anchor);
let delta_vap_rows = build_vap_compare_rows(&step_out.tree.aspas, &trust_anchor);
let (ccr_vrps, ccr_vaps) =
rpki::bundle::decode_ccr_compare_views(&delta_decoded, &trust_anchor)?;
if delta_vrp_rows != ccr_vrps {
return Err(format!(
"{step_id} VRP compare view does not match delta.ccr"
));
}
if delta_vap_rows != ccr_vaps {
return Err(format!(
"{step_id} VAP compare view does not match delta.ccr"
));
}
write_vrp_csv(&delta_vrps_path, &delta_vrp_rows)?;
write_vap_csv(&delta_vaps_path, &delta_vap_rows)?;
let step_replay_out =
run_tree_from_tal_and_ta_der_payload_delta_replay_step_serial_audit(
sequence_replay_store
.as_ref()
.expect("sequence replay store"),
&Policy::default(),
&tal_bytes,
&ta_bytes,
None,
&step_dir.join("payload-delta-archive"),
&previous_locks_path,
&step_dir.join("locks-delta.json"),
step_validation_time,
&TreeRunConfig {
max_depth: args.max_depth,
max_instances: args.max_instances,
},
)
.map_err(|e| format!("sequence self replay failed for {step_id}: {e}"))?;
let step_self_replay_ok =
build_vrp_compare_rows(&step_replay_out.tree.vrps, &trust_anchor)
== delta_vrp_rows
&& build_vap_compare_rows(&step_replay_out.tree.aspas, &trust_anchor)
== delta_vap_rows;
let output = (
sha256_hex(&delta_ccr_bytes),
delta_vrp_rows,
delta_vap_rows,
delta_verify,
step_self_replay_ok,
);
eprintln!(
"[sequence] step CCR/self-replay done rir={} step={}",
rir_normalized, step_id
);
output
};
eprintln!(
"[sequence] step input materialization start rir={} step={}",
rir_normalized, step_id
);
let delta_capture = write_live_delta_replay_step_inputs(
&step_dir,
&rir_normalized,
&previous_locks_path,
step_validation_time,
&step_out.publication_points,
&store,
&step_http.snapshot_responses(),
&step_rsync.snapshot_fetches(),
)?;
let target_lock_capture_id = format!("{rir_normalized}-target-{step_id}");
write_current_replay_state_locks(
&step_dir.join("target-locks.json"),
&target_lock_capture_id,
step_validation_time,
&step_out.publication_points,
&store,
)?;
eprintln!(
"[sequence] step input materialization done rir={} step={} rrdp_repos={} rsync_modules={}",
rir_normalized,
step_id,
delta_capture.rrdp_repo_count,
delta_capture.rsync_module_count
);
write_json(
&step_dir.join("verification.json"),
&serde_json::json!({
"index": step_index,
"id": step_id,
"validationTime": step_validation_time.format(&Rfc3339).map_err(|e| format!("format validation time failed: {e}"))?,
"capture": {
"captureId": delta_capture.capture_id,
"rrdpRepoCount": delta_capture.rrdp_repo_count,
"rsyncModuleCount": delta_capture.rsync_module_count,
},
"ccr": {
"path": "delta.ccr",
"sha256": delta_ccr_sha256.clone(),
"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": {
"vrpCount": delta_vrp_rows.len(),
"vapCount": delta_vap_rows.len(),
},
"selfReplayOk": serde_json::Value::Null,
"timings": {
"durationSeconds": step_duration.as_secs_f64(),
}
}),
)?;
fs::create_dir_all(step_dir.join("timings"))
.map_err(|e| format!("create step timings dir failed: {e}"))?;
write_json(
&step_dir.join("timings").join("delta-produce.json"),
&serde_json::json!({
"mode": "delta",
"stepIndex": step_index,
"validationTime": step_validation_time.format(&Rfc3339).map_err(|e| format!("format validation time failed: {e}"))?,
"durationSeconds": step_duration.as_secs_f64(),
}),
)?;
delta_steps.push(DeltaStepMetadataV2 {
index: step_index,
id: step_id.clone(),
relative_path: format!("delta-steps/{step_id}"),
base_ref: previous_ref.clone(),
validation_time: step_validation_time
.format(&Rfc3339)
.map_err(|e| format!("format validation time failed: {e}"))?,
delta_ccr_sha256: delta_ccr_sha256.clone(),
vrp_count: delta_vrp_rows.len(),
vap_count: delta_vap_rows.len(),
relative_archive_path: format!("delta-steps/{step_id}/payload-delta-archive"),
relative_transition_locks_path: format!("delta-steps/{step_id}/locks-delta.json"),
relative_target_locks_path: format!("delta-steps/{step_id}/target-locks.json"),
relative_ccr_path: format!("delta-steps/{step_id}/delta.ccr"),
relative_vrps_path: format!("delta-steps/{step_id}/record-delta.csv"),
relative_vaps_path: format!("delta-steps/{step_id}/record-delta-vaps.csv"),
has_aspa: !delta_vap_rows.is_empty(),
has_router_key: delta_verify.router_key_count > 0,
});
all_steps_self_replay_ok &= step_self_replay_ok;
steps_json.push(serde_json::json!({
"index": step_index,
"id": step_id,
"validationTime": step_validation_time.format(&Rfc3339).map_err(|e| format!("format validation time failed: {e}"))?,
"capture": {
"captureId": delta_capture.capture_id,
"rrdpRepoCount": delta_capture.rrdp_repo_count,
"rsyncModuleCount": delta_capture.rsync_module_count,
},
"ccr": {
"path": format!("delta-steps/{step_id}/delta.ccr"),
"sha256": delta_ccr_sha256,
"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": {
"vrpCount": delta_vrp_rows.len(),
"vapCount": delta_vap_rows.len(),
},
"selfReplayOk": if args.capture_inputs_only { serde_json::Value::Null } else { serde_json::Value::Bool(step_self_replay_ok) }
}));
previous_locks_path = step_dir.join("target-locks.json");
previous_ref = step_id;
}
let metadata = RirBundleMetadataV2 {
schema_version: "20260401-v2".to_string(),
bundle_producer: "ours".to_string(),
rir: rir_normalized.clone(),
tal_sha256: sha256_hex(&tal_bytes),
ta_cert_sha256: sha256_hex(&ta_bytes),
has_any_aspa: !base_vap_rows.is_empty() || delta_steps.iter().any(|step| step.has_aspa),
has_any_router_key: base_verify.router_key_count > 0
|| delta_steps.iter().any(|step| step.has_router_key),
base: BaseBundleStateMetadataV2 {
validation_time: base_validation_time
.format(&Rfc3339)
.map_err(|e| format!("format base validation time failed: {e}"))?,
ccr_sha256: base_ccr_sha256.clone(),
vrp_count: base_vrp_rows.len(),
vap_count: base_vap_rows.len(),
relative_archive_path: "base-payload-archive".to_string(),
relative_locks_path: "base-locks.json".to_string(),
relative_ccr_path: "base.ccr".to_string(),
relative_vrps_path: "base-vrps.csv".to_string(),
relative_vaps_path: "base-vaps.csv".to_string(),
},
delta_sequence: DeltaSequenceMetadataV2 {
configured_delta_count: args.delta_count,
configured_interval_seconds: args.delta_interval_secs,
steps: delta_steps.clone(),
},
};
write_json(&rir_dir.join("bundle.json"), &metadata)?;
write_json(
&rir_dir.join("verification.json"),
&serde_json::json!({
"base": {
"validationTime": metadata.base.validation_time,
"ccr": {
"path": "base.ccr",
"sha256": metadata.base.ccr_sha256,
"stateHashesOk": base_verify.state_hashes_ok,
"manifestInstances": base_verify.manifest_instances,
"roaVrpCount": base_verify.roa_vrp_count,
"aspaPayloadSets": base_verify.aspa_payload_sets,
"routerKeyCount": base_verify.router_key_count,
},
"compareViews": {
"baseVrpCount": metadata.base.vrp_count,
"baseVapCount": metadata.base.vap_count,
},
"capture": {
"captureId": base_capture.capture_id,
"rrdpRepoCount": base_capture.rrdp_repo_count,
"rsyncModuleCount": base_capture.rsync_module_count,
"selfReplayOk": if args.capture_inputs_only { serde_json::Value::Null } else { serde_json::Value::Bool(base_self_replay_ok) },
}
},
"steps": steps_json,
"summary": {
"baseSelfReplayOk": if args.capture_inputs_only { serde_json::Value::Null } else { serde_json::Value::Bool(base_self_replay_ok) },
"stepCount": args.delta_count,
"allStepsSelfReplayOk": if args.capture_inputs_only { serde_json::Value::Null } else { serde_json::Value::Bool(all_steps_self_replay_ok) },
}
}),
)?;
write_v2_top_readme(
&out_root.join("README.md"),
&rir_normalized,
args.delta_count,
args.delta_interval_secs,
)?;
write_v2_rir_readme(
&rir_dir.join("README.md"),
&rir_normalized,
&metadata.base.validation_time,
args.delta_count,
args.delta_interval_secs,
)?;
let bundle_manifest = BundleManifestV2 {
schema_version: "20260401-v2".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![BundleManifestEntryV2 {
rir: rir_normalized.clone(),
relative_path: rir_normalized,
base_validation_time: metadata.base.validation_time.clone(),
step_count: metadata.delta_sequence.steps.len(),
first_delta_validation_time: metadata
.delta_sequence
.steps
.first()
.map(|step| step.validation_time.clone()),
last_delta_validation_time: metadata
.delta_sequence
.steps
.last()
.map(|step| step.validation_time.clone()),
has_aspa: metadata.has_any_aspa,
}],
};
write_json(&out_root.join("bundle-manifest.json"), &bundle_manifest)?;
if !args.keep_db {
drop(sequence_replay_store);
drop(store);
let _ = fs::remove_dir_all(&work_db_dir);
let _ = fs::remove_dir_all(&base_self_replay_dir);
let _ = fs::remove_dir_all(&sequence_self_replay_dir);
let tmp_dir = out_root.join(".tmp");
if tmp_dir.is_dir() {
let is_empty = fs::read_dir(&tmp_dir)
.map_err(|e| format!("read tmp dir failed: {}: {e}", tmp_dir.display()))?
.next()
.is_none();
if is_empty {
let _ = fs::remove_dir(&tmp_dir);
}
}
}
Ok(out_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::*;
#[test]
fn parse_args_defaults_delta_sequence_parameters() {
let argv = vec![
"replay_bundle_capture_sequence".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(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(args.delta_count, 5);
assert_eq!(args.delta_interval_secs, 600);
assert!(!args.keep_db);
}
#[test]
fn parse_args_accepts_overrides_and_keep_db() {
let argv = vec![
"replay_bundle_capture_sequence".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(),
"--delta-count".to_string(),
"2".to_string(),
"--delta-interval-secs".to_string(),
"0".to_string(),
"--keep-db".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(args.delta_count, 2);
assert_eq!(args.delta_interval_secs, 0);
assert!(args.keep_db);
}
}

View File

@@ -0,0 +1,798 @@
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,
},
)
.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,
},
)
.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;
#[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() {
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(PathBuf::from("tests/fixtures/tal/apnic-rfc7730-https.tal")),
ta_path: Some(PathBuf::from("tests/fixtures/ta/apnic-ta.cer")),
payload_replay_archive: Some(PathBuf::from(
"/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3/apnic/base-payload-archive",
)),
payload_replay_locks: Some(PathBuf::from(
"/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3/apnic/base-locks.json",
)),
payload_delta_archive: Some(PathBuf::from(
"/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3/apnic/payload-delta-archive",
)),
payload_delta_locks: Some(PathBuf::from(
"/home/yuyr/dev/rust_playground/routinator/bench/multi_rir_demo/runs/20260316-112341-multi-final3/apnic/locks-delta.json",
)),
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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
use rocksdb::{DB, IteratorMode, Options};
use rpki::storage::{CF_REPOSITORY_VIEW, column_family_descriptors};
use std::fs;
use std::path::{Path, PathBuf};
fn usage() -> String {
let bin = "repository_view_stats";
format!(
"\
Usage:
{bin} --db <path>
Options:
--db <path> RocksDB directory
--help Show this help
"
)
}
fn parse_args(argv: &[String]) -> Result<PathBuf, String> {
if argv.iter().any(|arg| arg == "--help" || arg == "-h") {
return Err(usage());
}
let mut db_path: Option<PathBuf> = None;
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--db" => {
i += 1;
let value = argv.get(i).ok_or("--db requires a value")?;
db_path = Some(PathBuf::from(value));
}
other => return Err(format!("unknown argument: {other}\n\n{}", usage())),
}
i += 1;
}
db_path.ok_or_else(|| format!("--db is required\n\n{}", usage()))
}
fn dir_size(path: &Path) -> Result<u64, Box<dyn std::error::Error>> {
let mut total = 0u64;
for entry in fs::read_dir(path)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_file() {
total = total.saturating_add(entry.metadata()?.len());
} else if file_type.is_dir() {
total = total.saturating_add(dir_size(&entry.path())?);
}
}
Ok(total)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<String> = std::env::args().collect();
let db_path = parse_args(&argv).map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
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())?;
let cf = db
.cf_handle(CF_REPOSITORY_VIEW)
.ok_or("missing repository_view column family")?;
let mut kv_count = 0u64;
let mut key_bytes_total = 0u64;
let mut value_bytes_total = 0u64;
let mut max_key_bytes = 0usize;
let mut max_value_bytes = 0usize;
for entry in db.iterator_cf(cf, IteratorMode::Start) {
let (key, value) = entry?;
kv_count += 1;
key_bytes_total += key.len() as u64;
value_bytes_total += value.len() as u64;
max_key_bytes = max_key_bytes.max(key.len());
max_value_bytes = max_value_bytes.max(value.len());
}
let logical_total_bytes = key_bytes_total + value_bytes_total;
let avg_key_bytes = if kv_count > 0 {
key_bytes_total as f64 / kv_count as f64
} else {
0.0
};
let avg_value_bytes = if kv_count > 0 {
value_bytes_total as f64 / kv_count as f64
} else {
0.0
};
let out = serde_json::json!({
"db_path": db_path.display().to_string(),
"column_family": CF_REPOSITORY_VIEW,
"kv_count": kv_count,
"key_bytes_total": key_bytes_total,
"value_bytes_total": value_bytes_total,
"logical_total_bytes": logical_total_bytes,
"db_dir_on_disk_bytes": dir_size(&db_path)?,
"avg_key_bytes": avg_key_bytes,
"avg_value_bytes": avg_value_bytes,
"max_key_bytes": max_key_bytes,
"max_value_bytes": max_value_bytes,
});
println!("{}", serde_json::to_string_pretty(&out)?);
Ok(())
}

View File

@@ -1,16 +1,37 @@
use std::path::PathBuf;
use rocksdb::{ColumnFamilyDescriptor, DB, IteratorMode, Options};
use rocksdb::{DB, IteratorMode, Options};
use rpki::storage::{
CF_RRDP_SOURCE, CF_RRDP_SOURCE_MEMBER, CF_RRDP_URI_OWNER, RrdpSourceMemberRecord,
RrdpSourceRecord, RrdpUriOwnerRecord, column_family_descriptors,
};
fn enable_blobdb_if_supported(opts: &mut Options) {
// Keep this in sync with `rpki::storage`:
// blob files are CF-level options; readers should open CFs with blob enabled too.
#[allow(dead_code)]
fn _set(opts: &mut Options) {
opts.set_enable_blob_files(true);
opts.set_min_blob_size(1024);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DumpView {
Source,
Members,
Owners,
All,
}
impl DumpView {
fn parse(value: &str) -> Result<Self, String> {
match value {
"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: source, members, owners, all)"
)),
}
_set(opts);
}
}
#[derive(Debug)]
struct DumpArgs {
db_path: PathBuf,
view: DumpView,
}
fn usage() -> String {
@@ -18,23 +39,23 @@ fn usage() -> String {
format!(
"\
Usage:
{bin} --db <path>
{bin} --db <path> [--view <source|members|owners|all>]
Options:
--db <path> RocksDB directory
--view <name> Dump one RRDP view; default is all
--help Show this help
"
)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<String> = std::env::args().collect();
fn parse_args(argv: &[String]) -> Result<DumpArgs, String> {
if argv.iter().any(|a| a == "--help" || a == "-h") {
print!("{}", usage());
return Ok(());
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() {
@@ -43,43 +64,240 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let v = argv.get(i).ok_or("--db requires a value")?;
db_path = Some(PathBuf::from(v));
}
other => return Err(format!("unknown argument: {other}\n\n{}", usage()).into()),
"--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;
}
let db_path = db_path.ok_or_else(|| format!("--db is required\n\n{}", usage()))?;
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(),
)?)
}
// Open only the column families we need.
let mut cf_opts = Options::default();
enable_blobdb_if_supported(&mut cf_opts);
let cfs = vec![
ColumnFamilyDescriptor::new("raw_objects", cf_opts.clone()),
ColumnFamilyDescriptor::new("fetch_cache_pp", cf_opts.clone()),
ColumnFamilyDescriptor::new("rrdp_state", cf_opts.clone()),
ColumnFamilyDescriptor::new("rrdp_object_index", cf_opts),
];
let db = DB::open_cf_descriptors(&opts, &db_path, cfs)?;
fn collect_source_records(db: &DB) -> Result<Vec<RrdpSourceRecord>, Box<dyn std::error::Error>> {
let cf = db
.cf_handle("rrdp_state")
.ok_or("missing column family: rrdp_state")?;
let mut out: Vec<(String, u64, String)> = Vec::new();
.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 k = String::from_utf8_lossy(&k).to_string();
let st = rpki::sync::rrdp::RrdpState::decode(&v)
.map_err(|e| format!("decode rrdp_state failed for {k}: {e}"))?;
out.push((k, st.serial, st.session_id));
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)
}
out.sort_by(|a, b| a.0.cmp(&b.0));
for (k, serial, session) in out {
println!("{k}\t{serial}\t{session}");
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_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::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_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_current_records() {
let dir = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(dir.path()).expect("open store");
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 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!(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);
}
}

View File

@@ -0,0 +1,103 @@
use rpki::audit_trace::trace_rule_to_root;
use rpki::storage::{AuditRuleKind, RocksStore, VcirOutputType};
use serde_json::Value;
use std::env;
use std::path::Path;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("usage: trace_arin_missing_vrps <db> <row> [<row> ...]");
std::process::exit(2);
}
let store = RocksStore::open(Path::new(&args[1])).expect("open db");
let vcirs = store.list_vcirs().expect("list vcirs");
for row in &args[2..] {
let parts: Vec<&str> = row.split(',').collect();
if parts.len() != 4 {
println!("ROW {row}");
println!("ERROR invalid compare row");
println!();
continue;
}
let asn: u32 = parts[0]
.trim_start_matches("AS")
.parse()
.expect("parse asn");
let prefix = parts[1].to_string();
let max_length: u8 = parts[2].parse().expect("parse max length");
let mut found = false;
println!("ROW {row}");
for vcir in &vcirs {
for output in &vcir.local_outputs {
if output.output_type != VcirOutputType::Vrp {
continue;
}
let payload: Value = match serde_json::from_str(&output.payload_json) {
Ok(value) => value,
Err(_) => continue,
};
let payload_asn = payload
.get("asn")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let payload_prefix = payload
.get("prefix")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let payload_max = payload
.get("max_length")
.and_then(|v| v.as_u64())
.map(|v| v as u8);
if payload_asn == Some(asn)
&& payload_prefix.as_ref() == Some(&prefix)
&& payload_max == Some(max_length)
{
found = true;
println!("manifest_rsync_uri={}", vcir.manifest_rsync_uri);
println!("source_object_uri={}", output.source_object_uri);
println!("source_object_hash={}", output.source_object_hash);
println!("source_ee_cert_hash={}", output.source_ee_cert_hash);
println!("rule_hash={}", output.rule_hash);
println!("validation_path_hint={:?}", output.validation_path_hint);
if let Some(trace) =
trace_rule_to_root(&store, AuditRuleKind::Roa, &output.rule_hash)
.expect("trace rule")
{
println!(
"trace_leaf_manifest={}",
trace
.chain_leaf_to_root
.first()
.map(|node| node.manifest_rsync_uri.as_str())
.unwrap_or("")
);
println!(
"trace_source_object_uri={}",
trace.resolved_output.source_object_uri
);
println!("trace_chain_len={}", trace.chain_leaf_to_root.len());
for (idx, node) in trace.chain_leaf_to_root.iter().enumerate() {
println!("chain[{idx}].manifest={}", node.manifest_rsync_uri);
println!(
"chain[{idx}].current_manifest={}",
node.current_manifest_rsync_uri
);
println!("chain[{idx}].current_crl={}", node.current_crl_rsync_uri);
}
}
println!();
}
}
}
if !found {
println!("NOT_FOUND");
println!();
}
}
}

323
src/blob_store.rs Normal file
View File

@@ -0,0 +1,323 @@
use std::path::PathBuf;
use std::sync::Arc;
use rocksdb::{DB, Options, WriteBatch};
use crate::storage::{RawByHashEntry, RocksStore, StorageError, StorageResult};
const RAW_BY_HASH_KEY_PREFIX: &str = "rawbyhash:";
fn raw_by_hash_key(sha256_hex: &str) -> String {
format!("{RAW_BY_HASH_KEY_PREFIX}{sha256_hex}")
}
pub trait RawObjectStore {
fn get_raw_entry(&self, sha256_hex: &str) -> StorageResult<Option<RawByHashEntry>>;
fn get_raw_entries_batch(
&self,
sha256_hexes: &[String],
) -> StorageResult<Vec<Option<RawByHashEntry>>>;
fn get_blob_bytes(&self, sha256_hex: &str) -> StorageResult<Option<Vec<u8>>> {
self.get_raw_entry(sha256_hex)
.map(|entry| entry.map(|entry| entry.bytes))
}
fn get_blob_bytes_batch(
&self,
sha256_hexes: &[String],
) -> StorageResult<Vec<Option<Vec<u8>>>> {
self.get_raw_entries_batch(sha256_hexes).map(|entries| {
entries
.into_iter()
.map(|entry| entry.map(|entry| entry.bytes))
.collect()
})
}
}
#[derive(Clone, Debug)]
pub struct ExternalRawStoreDb {
path: PathBuf,
db: Arc<DB>,
}
impl ExternalRawStoreDb {
pub fn open(path: impl Into<PathBuf>) -> StorageResult<Self> {
let path = path.into();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| StorageError::RocksDb(e.to_string()))?;
}
let mut opts = Options::default();
opts.create_if_missing(true);
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
let db = DB::open(&opts, &path).map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(Self {
path,
db: Arc::new(db),
})
}
pub fn put_raw_entry(&self, entry: &RawByHashEntry) -> StorageResult<()> {
entry.validate_internal()?;
let key = raw_by_hash_key(&entry.sha256_hex);
let value =
serde_cbor::to_vec(entry).map_err(|e| StorageError::Codec { entity: "raw_by_hash", detail: e.to_string() })?;
self.db
.put(key.as_bytes(), value)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn put_raw_entries_batch(&self, entries: &[RawByHashEntry]) -> StorageResult<()> {
if entries.is_empty() {
return Ok(());
}
let mut batch = WriteBatch::default();
for entry in entries {
entry.validate_internal()?;
let key = raw_by_hash_key(&entry.sha256_hex);
let value = serde_cbor::to_vec(entry).map_err(|e| StorageError::Codec {
entity: "raw_by_hash",
detail: e.to_string(),
})?;
batch.put(key.as_bytes(), value);
}
self.db
.write(batch)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn delete_raw_entry(&self, sha256_hex: &str) -> StorageResult<()> {
let key = raw_by_hash_key(sha256_hex);
self.db
.delete(key.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))
}
pub fn path(&self) -> &PathBuf {
&self.path
}
}
impl RawObjectStore for RocksStore {
fn get_raw_entry(&self, sha256_hex: &str) -> StorageResult<Option<RawByHashEntry>> {
self.get_raw_by_hash_entry(sha256_hex)
}
fn get_raw_entries_batch(
&self,
sha256_hexes: &[String],
) -> StorageResult<Vec<Option<RawByHashEntry>>> {
self.get_raw_by_hash_entries_batch(sha256_hexes)
}
}
impl RawObjectStore for ExternalRawStoreDb {
fn get_raw_entry(&self, sha256_hex: &str) -> StorageResult<Option<RawByHashEntry>> {
let key = raw_by_hash_key(sha256_hex);
let Some(bytes) = self
.db
.get(key.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?
else {
return Ok(None);
};
let entry = serde_cbor::from_slice::<RawByHashEntry>(&bytes).map_err(|e| StorageError::Codec {
entity: "raw_by_hash",
detail: e.to_string(),
})?;
entry.validate_internal()?;
Ok(Some(entry))
}
fn get_raw_entries_batch(
&self,
sha256_hexes: &[String],
) -> StorageResult<Vec<Option<RawByHashEntry>>> {
if sha256_hexes.is_empty() {
return Ok(Vec::new());
}
let keys: Vec<String> = sha256_hexes.iter().map(|hash| raw_by_hash_key(hash)).collect();
self.db
.multi_get(keys.iter().map(|key| key.as_bytes()))
.into_iter()
.map(|res| {
let maybe = res.map_err(|e| StorageError::RocksDb(e.to_string()))?;
match maybe {
Some(bytes) => {
let entry = serde_cbor::from_slice::<RawByHashEntry>(&bytes).map_err(|e| {
StorageError::Codec {
entity: "raw_by_hash",
detail: e.to_string(),
}
})?;
entry.validate_internal()?;
Ok(Some(entry))
}
None => Ok(None),
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::{ExternalRawStoreDb, RawObjectStore};
use crate::storage::{RawByHashEntry, RocksStore, StorageError};
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(bytes))
}
#[test]
fn rocks_store_raw_object_store_reads_single_and_batch_entries() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let a = b"object-a".to_vec();
let b = b"object-b".to_vec();
let a_hash = sha256_hex(&a);
let b_hash = sha256_hex(&b);
store
.put_raw_by_hash_entry(&RawByHashEntry::from_bytes(a_hash.clone(), a.clone()))
.expect("put a");
store
.put_raw_by_hash_entry(&RawByHashEntry::from_bytes(b_hash.clone(), b.clone()))
.expect("put b");
let single = store
.get_raw_entry(&a_hash)
.expect("get single")
.expect("present");
assert_eq!(single.bytes, a);
let batch = store
.get_raw_entries_batch(&[a_hash.clone(), "00".repeat(32), b_hash.clone()])
.expect("get batch");
assert_eq!(batch.len(), 3);
assert_eq!(batch[0].as_ref().map(|entry| entry.bytes.as_slice()), Some(a.as_slice()));
assert!(batch[1].is_none());
assert_eq!(batch[2].as_ref().map(|entry| entry.bytes.as_slice()), Some(b.as_slice()));
}
#[test]
fn external_raw_store_db_roundtrips_entries() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store = ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
let mut entry = RawByHashEntry::from_bytes(sha256_hex(b"blob"), b"blob".to_vec());
entry.origin_uris.push("rsync://example.test/repo/a.cer".to_string());
entry.object_type = Some("cer".to_string());
raw_store.put_raw_entry(&entry).expect("put raw entry");
let got = raw_store
.get_raw_entry(&entry.sha256_hex)
.expect("read raw entry")
.expect("entry exists");
assert_eq!(got, entry);
}
#[test]
fn external_raw_store_db_batch_writes_and_reads() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store = ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
let a = RawByHashEntry::from_bytes(sha256_hex(b"a"), b"a".to_vec());
let b = RawByHashEntry::from_bytes(sha256_hex(b"b"), b"b".to_vec());
raw_store
.put_raw_entries_batch(&[a.clone(), b.clone()])
.expect("batch put");
let batch = raw_store
.get_raw_entries_batch(&[a.sha256_hex.clone(), b.sha256_hex.clone()])
.expect("batch get");
assert_eq!(batch.len(), 2);
assert_eq!(batch[0], Some(a));
assert_eq!(batch[1], Some(b));
}
#[test]
fn raw_object_store_default_blob_helpers_return_bytes_only() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store = ExternalRawStoreDb::open(td.path().join("nested/raw-store.db"))
.expect("open raw store");
let mut entry = RawByHashEntry::from_bytes(sha256_hex(b"blob"), b"blob".to_vec());
entry.origin_uris.push("rsync://example.test/repo/blob.roa".to_string());
raw_store.put_raw_entry(&entry).expect("put raw entry");
let single = raw_store
.get_blob_bytes(&entry.sha256_hex)
.expect("get blob bytes")
.expect("entry exists");
assert_eq!(single, b"blob".to_vec());
let batch = raw_store
.get_blob_bytes_batch(&[entry.sha256_hex.clone(), "00".repeat(32)])
.expect("get blob bytes batch");
assert_eq!(batch, vec![Some(b"blob".to_vec()), None]);
}
#[test]
fn external_raw_store_db_delete_removes_entry() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store = ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
let entry = RawByHashEntry::from_bytes(sha256_hex(b"gone"), b"gone".to_vec());
raw_store.put_raw_entry(&entry).expect("put");
assert!(raw_store.get_raw_entry(&entry.sha256_hex).unwrap().is_some());
raw_store
.delete_raw_entry(&entry.sha256_hex)
.expect("delete entry");
assert!(raw_store.get_raw_entry(&entry.sha256_hex).unwrap().is_none());
}
#[test]
fn external_raw_store_db_rejects_invalid_entry_on_put() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store = ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
let bad = RawByHashEntry {
sha256_hex: "11".repeat(32),
bytes: b"blob".to_vec(),
origin_uris: Vec::new(),
object_type: None,
encoding: None,
};
let err = raw_store.put_raw_entry(&bad).expect_err("invalid hash should fail");
assert!(matches!(err, StorageError::InvalidData { .. }));
}
#[test]
fn external_raw_store_db_reports_codec_error_for_corrupt_value() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store = ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
raw_store
.db
.put(b"rawbyhash:deadbeef", b"not-cbor")
.expect("inject corrupt bytes");
let err = raw_store
.get_raw_entry("deadbeef")
.expect_err("corrupt value should fail");
assert!(matches!(err, StorageError::Codec { entity: "raw_by_hash", .. }));
}
#[test]
fn external_raw_store_db_batch_returns_empty_for_empty_request() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store = ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
let entries = raw_store
.get_raw_entries_batch(&[])
.expect("empty batch succeeds");
assert!(entries.is_empty());
raw_store.put_raw_entries_batch(&[]).expect("empty put succeeds");
}
}

245
src/bundle/compare_view.rs Normal file
View File

@@ -0,0 +1,245 @@
use std::collections::BTreeSet;
use std::io::Write;
use std::path::Path;
use crate::ccr::{CcrContentInfo, extract_vrp_rows};
use crate::validation::objects::{AspaAttestation, Vrp};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct VrpCompareRow {
pub asn: String,
pub ip_prefix: String,
pub max_length: String,
pub trust_anchor: String,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct VapCompareRow {
pub customer_asn: String,
pub providers: String,
pub trust_anchor: String,
}
fn normalize_asn(asn: u32) -> String {
format!("AS{asn}")
}
fn canonical_prefix(prefix: &crate::data_model::roa::IpPrefix) -> String {
let mut addr = prefix.addr_bytes().to_vec();
let total_bits = match prefix.afi {
crate::data_model::roa::RoaAfi::Ipv4 => 32usize,
crate::data_model::roa::RoaAfi::Ipv6 => 128usize,
};
let keep = usize::from(prefix.prefix_len);
for bit in keep..total_bits {
let byte = bit / 8;
let offset = 7 - (bit % 8);
addr[byte] &= !(1u8 << offset);
}
match prefix.afi {
crate::data_model::roa::RoaAfi::Ipv4 => {
let ipv4 = std::net::Ipv4Addr::new(addr[0], addr[1], addr[2], addr[3]);
format!("{ipv4}/{}", prefix.prefix_len)
}
crate::data_model::roa::RoaAfi::Ipv6 => {
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&addr[..16]);
let ipv6 = std::net::Ipv6Addr::from(bytes);
format!("{ipv6}/{}", prefix.prefix_len)
}
}
}
pub fn build_vrp_compare_rows(vrps: &[Vrp], trust_anchor: &str) -> BTreeSet<VrpCompareRow> {
vrps.iter()
.map(|vrp| VrpCompareRow {
asn: normalize_asn(vrp.asn),
ip_prefix: canonical_prefix(&vrp.prefix),
max_length: vrp.max_length.to_string(),
trust_anchor: trust_anchor.to_ascii_lowercase(),
})
.collect()
}
pub fn build_vap_compare_rows(
aspas: &[AspaAttestation],
trust_anchor: &str,
) -> BTreeSet<VapCompareRow> {
aspas
.iter()
.map(|aspa| {
let mut providers = aspa.provider_as_ids.iter().copied().collect::<Vec<_>>();
providers.sort_unstable();
providers.dedup();
VapCompareRow {
customer_asn: normalize_asn(aspa.customer_as_id),
providers: providers
.into_iter()
.map(normalize_asn)
.collect::<Vec<_>>()
.join(";"),
trust_anchor: trust_anchor.to_ascii_lowercase(),
}
})
.collect()
}
pub fn decode_ccr_compare_views(
content_info: &CcrContentInfo,
trust_anchor: &str,
) -> Result<(BTreeSet<VrpCompareRow>, BTreeSet<VapCompareRow>), String> {
let vrps = extract_vrp_rows(content_info)
.map_err(|e| format!("extract vrp rows from ccr failed: {e}"))?
.into_iter()
.map(|(asn, prefix, max_length)| VrpCompareRow {
asn: normalize_asn(asn),
ip_prefix: prefix,
max_length: max_length.to_string(),
trust_anchor: trust_anchor.to_ascii_lowercase(),
})
.collect::<BTreeSet<_>>();
let vaps = content_info
.content
.vaps
.as_ref()
.map(|state| {
state
.aps
.iter()
.map(|vap| VapCompareRow {
customer_asn: normalize_asn(vap.customer_as_id),
providers: vap
.providers
.iter()
.copied()
.map(normalize_asn)
.collect::<Vec<_>>()
.join(";"),
trust_anchor: trust_anchor.to_ascii_lowercase(),
})
.collect::<BTreeSet<_>>()
})
.unwrap_or_default();
Ok((vrps, vaps))
}
pub fn write_vrp_csv(path: &Path, rows: &BTreeSet<VrpCompareRow>) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create parent dirs failed: {}: {e}", parent.display()))?;
}
let mut file = std::io::BufWriter::new(
std::fs::File::create(path)
.map_err(|e| format!("create file failed: {}: {e}", path.display()))?,
);
writeln!(file, "ASN,IP Prefix,Max Length,Trust Anchor").map_err(|e| e.to_string())?;
for row in rows {
writeln!(
file,
"{},{},{},{}",
row.asn, row.ip_prefix, row.max_length, row.trust_anchor
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn write_vap_csv(path: &Path, rows: &BTreeSet<VapCompareRow>) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create parent dirs failed: {}: {e}", parent.display()))?;
}
let mut file = std::io::BufWriter::new(
std::fs::File::create(path)
.map_err(|e| format!("create file failed: {}: {e}", path.display()))?,
);
writeln!(file, "Customer ASN,Providers,Trust Anchor").map_err(|e| e.to_string())?;
for row in rows {
writeln!(
file,
"{},{},{}",
row.customer_asn, row.providers, row.trust_anchor
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ccr::{
CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation,
build_aspa_payload_state, build_roa_payload_state,
};
use crate::data_model::roa::{IpPrefix, RoaAfi};
#[test]
fn build_vap_compare_rows_sorts_and_dedups_providers() {
let rows = build_vap_compare_rows(
&[AspaAttestation {
customer_as_id: 64496,
provider_as_ids: vec![64498, 64497, 64498],
}],
"APNIC",
);
let row = rows.iter().next().expect("one row");
assert_eq!(row.customer_asn, "AS64496");
assert_eq!(row.providers, "AS64497;AS64498");
assert_eq!(row.trust_anchor, "apnic");
}
#[test]
fn decode_ccr_compare_views_extracts_vrps_and_vaps() {
let vrps = build_roa_payload_state(&[Vrp {
asn: 64496,
prefix: IpPrefix {
afi: 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,
}])
.expect("build vrps");
let vaps = build_aspa_payload_state(&[AspaAttestation {
customer_as_id: 64496,
provider_as_ids: vec![64497],
}])
.expect("build vaps");
let content = CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: time::OffsetDateTime::now_utc(),
mfts: None,
vrps: Some(vrps),
vaps: Some(vaps),
tas: None,
rks: None,
});
let (vrp_rows, vap_rows) =
decode_ccr_compare_views(&content, "apnic").expect("decode compare views");
assert_eq!(vrp_rows.len(), 1);
assert_eq!(vap_rows.len(), 1);
assert_eq!(vap_rows.iter().next().unwrap().providers, "AS64497");
}
#[test]
fn build_vrp_compare_rows_canonicalizes_ipv6_prefix_text() {
let rows = build_vrp_compare_rows(
&[Vrp {
asn: 64496,
prefix: IpPrefix {
afi: RoaAfi::Ipv6,
prefix_len: 32,
addr: [0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
},
max_length: 48,
}],
"APNIC",
);
let row = rows.iter().next().expect("row");
assert_eq!(row.ip_prefix, "2001:db8::/32");
}
}

1722
src/bundle/live_capture.rs Normal file

File diff suppressed because it is too large Load Diff

24
src/bundle/mod.rs Normal file
View File

@@ -0,0 +1,24 @@
pub mod compare_view;
pub mod live_capture;
pub mod record_io;
pub mod spec;
pub use compare_view::{
VapCompareRow, VrpCompareRow, build_vap_compare_rows, build_vrp_compare_rows,
decode_ccr_compare_views, write_vap_csv, write_vrp_csv,
};
pub use live_capture::{
LiveBaseCaptureSummary, LiveDeltaCaptureSummary, RecordedHttpResponse, RecordedRsyncFetch,
RecordingHttpFetcher, RecordingRsyncFetcher, write_current_replay_state_locks,
write_live_base_replay_bundle_inputs, write_live_delta_replay_bundle_inputs,
write_live_delta_replay_step_inputs,
};
pub use record_io::{
build_single_rir_bundle_manifest, copy_dir_all, load_validation_time, sha256_hex, write_bytes,
write_json, write_live_bundle_rir_readme, write_live_bundle_top_readme, write_timing_json,
};
pub use spec::{
BaseBundleStateMetadataV2, BundleManifestEntryV2, BundleManifestV2, DeltaSequenceMetadataV2,
DeltaStepMetadataV2, RirBundleMetadataV2,
};
pub use spec::{BundleManifest, BundleManifestEntry, RirBundleMetadata};

274
src/bundle/record_io.rs Normal file
View File

@@ -0,0 +1,274 @@
use std::fs;
use std::path::Path;
use serde::Serialize;
use sha2::Digest;
use time::format_description::well_known::Rfc3339;
use super::{BundleManifest, BundleManifestEntry};
pub fn sha256_hex(bytes: &[u8]) -> String {
hex::encode(sha2::Sha256::digest(bytes))
}
pub fn write_json(path: &Path, value: &impl 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()))
}
pub 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(),
}),
)
}
pub fn write_live_bundle_top_readme(path: &Path, rir: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create parent failed: {}: {e}", parent.display()))?;
}
fs::write(
path,
format!(
"# Ours Live Replay Bundle\n\nThis run contains one per-RIR bundle recorded online by `ours`.\n\n- RIR: `{rir}`\n- Reference result format: `CCR`\n"
),
)
.map_err(|e| format!("write readme failed: {}: {e}", path.display()))
}
pub fn write_live_bundle_rir_readme(
path: &Path,
rir: &str,
base_validation_time: &str,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create parent failed: {}: {e}", parent.display()))?;
}
fs::write(
path,
format!(
"# {rir} live replay bundle\n\n- `tal.tal` and `ta.cer` are the actual live run 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()))
}
pub fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create parent failed: {}: {e}", parent.display()))?;
}
fs::write(path, bytes).map_err(|e| format!("write file failed: {}: {e}", path.display()))
}
pub 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(())
}
pub 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 json failed: {}: {e}", path.display()))?,
)
.map_err(|e| format!("parse json 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()))
}
pub fn build_single_rir_bundle_manifest(
schema_version: &str,
bundle_producer: &str,
rir: &str,
base_validation_time: &time::OffsetDateTime,
delta_validation_time: Option<&time::OffsetDateTime>,
has_aspa: bool,
) -> Result<BundleManifest, String> {
Ok(BundleManifest {
schema_version: schema_version.to_string(),
bundle_producer: bundle_producer.to_string(),
recorded_at_rfc3339_utc: time::OffsetDateTime::now_utc()
.format(&Rfc3339)
.map_err(|e| format!("format recorded_at failed: {e}"))?,
rirs: vec![rir.to_string()],
per_rir_bundles: vec![BundleManifestEntry {
rir: rir.to_string(),
relative_path: rir.to_string(),
base_validation_time: base_validation_time
.format(&Rfc3339)
.map_err(|e| format!("format base validation time failed: {e}"))?,
delta_validation_time: match delta_validation_time {
Some(value) => Some(
value
.format(&Rfc3339)
.map_err(|e| format!("format delta validation time failed: {e}"))?,
),
None => None,
},
has_aspa,
}],
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn load_validation_time_reads_validation_time_field() {
let td = tempdir().expect("tempdir");
let path = td.path().join("locks.json");
fs::write(&path, r#"{"validationTime":"2026-04-01T00:00:00Z"}"#).expect("write");
let parsed = load_validation_time(&path).expect("load");
assert_eq!(
parsed.format(&Rfc3339).expect("format"),
"2026-04-01T00:00:00Z"
);
}
#[test]
fn copy_dir_all_copies_nested_files() {
let td = tempdir().expect("tempdir");
let src = td.path().join("src");
let dst = td.path().join("dst");
fs::create_dir_all(src.join("nested")).expect("mkdir");
fs::write(src.join("root.txt"), b"root").expect("write root");
fs::write(src.join("nested/child.txt"), b"child").expect("write child");
copy_dir_all(&src, &dst).expect("copy");
assert_eq!(fs::read(dst.join("root.txt")).expect("read root"), b"root");
assert_eq!(
fs::read(dst.join("nested/child.txt")).expect("read child"),
b"child"
);
}
#[test]
fn build_single_rir_bundle_manifest_formats_times() {
let base = time::OffsetDateTime::parse("2026-04-01T00:00:00Z", &Rfc3339).expect("base");
let delta = time::OffsetDateTime::parse("2026-04-01T00:10:00Z", &Rfc3339).expect("delta");
let manifest = build_single_rir_bundle_manifest(
"20260330-v1",
"ours",
"apnic",
&base,
Some(&delta),
true,
)
.expect("manifest");
assert_eq!(manifest.schema_version, "20260330-v1");
assert_eq!(manifest.rirs, vec!["apnic".to_string()]);
assert_eq!(
manifest.per_rir_bundles[0].base_validation_time,
"2026-04-01T00:00:00Z"
);
assert_eq!(
manifest.per_rir_bundles[0].delta_validation_time.as_deref(),
Some("2026-04-01T00:10:00Z")
);
}
#[test]
fn write_json_and_write_bytes_create_parent_directories() {
let td = tempdir().expect("tempdir");
let json_path = td.path().join("nested/meta/data.json");
write_json(&json_path, &serde_json::json!({"ok": true})).expect("write json");
let json: serde_json::Value =
serde_json::from_slice(&fs::read(&json_path).expect("read json")).expect("parse");
assert_eq!(json["ok"], true);
let bytes_path = td.path().join("nested/raw/file.bin");
write_bytes(&bytes_path, b"payload").expect("write bytes");
assert_eq!(fs::read(&bytes_path).expect("read bytes"), b"payload");
}
#[test]
fn write_timing_and_readmes_emit_expected_text() {
let td = tempdir().expect("tempdir");
let timing_path = td.path().join("timings/base-produce.json");
let validation_time =
time::OffsetDateTime::parse("2026-04-01T00:00:00Z", &Rfc3339).expect("time");
write_timing_json(
&timing_path,
"base",
&validation_time,
std::time::Duration::from_secs_f64(1.25),
)
.expect("write timing");
let timing: serde_json::Value =
serde_json::from_slice(&fs::read(&timing_path).expect("read timing")).expect("parse");
assert_eq!(timing["mode"], "base");
assert_eq!(timing["validationTime"], "2026-04-01T00:00:00Z");
assert_eq!(timing["durationSeconds"], 1.25);
let top_readme = td.path().join("README.md");
write_live_bundle_top_readme(&top_readme, "apnic").expect("write top readme");
let top_text = fs::read_to_string(&top_readme).expect("read top readme");
assert!(top_text.contains("RIR: `apnic`"));
assert!(top_text.contains("Reference result format: `CCR`"));
let rir_readme = td.path().join("apnic/README.md");
write_live_bundle_rir_readme(&rir_readme, "apnic", "2026-04-01T00:00:00Z")
.expect("write rir readme");
let rir_text = fs::read_to_string(&rir_readme).expect("read rir readme");
assert!(rir_text.contains("base-locks.json.validationTime"));
assert!(rir_text.contains("base-vrps.csv"));
assert!(rir_text.contains("base-vaps.csv"));
}
#[test]
fn build_single_rir_bundle_manifest_supports_none_delta_time() {
let base = time::OffsetDateTime::parse("2026-04-01T00:00:00Z", &Rfc3339).expect("base");
let manifest =
build_single_rir_bundle_manifest("20260330-v1", "ours", "afrinic", &base, None, false)
.expect("manifest");
assert_eq!(manifest.per_rir_bundles[0].delta_validation_time, None);
assert!(!manifest.per_rir_bundles[0].has_aspa);
}
}

185
src/bundle/spec.rs Normal file
View File

@@ -0,0 +1,185 @@
use serde::Serialize;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct BundleManifest {
#[serde(rename = "schemaVersion")]
pub schema_version: String,
#[serde(rename = "bundleProducer")]
pub bundle_producer: String,
#[serde(rename = "recordedAt")]
pub recorded_at_rfc3339_utc: String,
pub rirs: Vec<String>,
#[serde(rename = "perRirBundles")]
pub per_rir_bundles: Vec<BundleManifestEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct BundleManifestEntry {
pub rir: String,
pub relative_path: String,
#[serde(rename = "baseValidationTime")]
pub base_validation_time: String,
#[serde(
rename = "deltaValidationTime",
skip_serializing_if = "Option::is_none"
)]
pub delta_validation_time: Option<String>,
#[serde(rename = "hasAspa")]
pub has_aspa: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct RirBundleMetadata {
#[serde(rename = "schemaVersion")]
pub schema_version: String,
#[serde(rename = "bundleProducer")]
pub bundle_producer: String,
pub rir: String,
#[serde(rename = "baseValidationTime")]
pub base_validation_time: String,
#[serde(
rename = "deltaValidationTime",
skip_serializing_if = "Option::is_none"
)]
pub delta_validation_time: Option<String>,
#[serde(rename = "talSha256")]
pub tal_sha256: String,
#[serde(rename = "taCertSha256")]
pub ta_cert_sha256: String,
#[serde(rename = "baseCcrSha256")]
pub base_ccr_sha256: String,
#[serde(rename = "deltaCcrSha256", skip_serializing_if = "Option::is_none")]
pub delta_ccr_sha256: Option<String>,
#[serde(rename = "hasAspa")]
pub has_aspa: bool,
#[serde(rename = "hasRouterKey")]
pub has_router_key: bool,
#[serde(rename = "baseVrpCount")]
pub base_vrp_count: usize,
#[serde(rename = "baseVapCount")]
pub base_vap_count: usize,
#[serde(rename = "deltaVrpCount", skip_serializing_if = "Option::is_none")]
pub delta_vrp_count: Option<usize>,
#[serde(rename = "deltaVapCount", skip_serializing_if = "Option::is_none")]
pub delta_vap_count: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct BundleManifestV2 {
#[serde(rename = "schemaVersion")]
pub schema_version: String,
#[serde(rename = "bundleProducer")]
pub bundle_producer: String,
#[serde(rename = "recordedAt")]
pub recorded_at_rfc3339_utc: String,
pub rirs: Vec<String>,
#[serde(rename = "perRirBundles")]
pub per_rir_bundles: Vec<BundleManifestEntryV2>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct BundleManifestEntryV2 {
pub rir: String,
pub relative_path: String,
#[serde(rename = "baseValidationTime")]
pub base_validation_time: String,
#[serde(rename = "stepCount")]
pub step_count: usize,
#[serde(
rename = "firstDeltaValidationTime",
skip_serializing_if = "Option::is_none"
)]
pub first_delta_validation_time: Option<String>,
#[serde(
rename = "lastDeltaValidationTime",
skip_serializing_if = "Option::is_none"
)]
pub last_delta_validation_time: Option<String>,
#[serde(rename = "hasAspa")]
pub has_aspa: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct BaseBundleStateMetadataV2 {
#[serde(rename = "validationTime")]
pub validation_time: String,
#[serde(rename = "ccrSha256")]
pub ccr_sha256: String,
#[serde(rename = "vrpCount")]
pub vrp_count: usize,
#[serde(rename = "vapCount")]
pub vap_count: usize,
#[serde(rename = "relativeArchivePath")]
pub relative_archive_path: String,
#[serde(rename = "relativeLocksPath")]
pub relative_locks_path: String,
#[serde(rename = "relativeCcrPath")]
pub relative_ccr_path: String,
#[serde(rename = "relativeVrpsPath")]
pub relative_vrps_path: String,
#[serde(rename = "relativeVapsPath")]
pub relative_vaps_path: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct DeltaStepMetadataV2 {
pub index: usize,
pub id: String,
#[serde(rename = "relativePath")]
pub relative_path: String,
#[serde(rename = "baseRef")]
pub base_ref: String,
#[serde(rename = "validationTime")]
pub validation_time: String,
#[serde(rename = "deltaCcrSha256")]
pub delta_ccr_sha256: String,
#[serde(rename = "vrpCount")]
pub vrp_count: usize,
#[serde(rename = "vapCount")]
pub vap_count: usize,
#[serde(rename = "relativeArchivePath")]
pub relative_archive_path: String,
#[serde(rename = "relativeTransitionLocksPath")]
pub relative_transition_locks_path: String,
#[serde(rename = "relativeTargetLocksPath")]
pub relative_target_locks_path: String,
#[serde(rename = "relativeCcrPath")]
pub relative_ccr_path: String,
#[serde(rename = "relativeVrpsPath")]
pub relative_vrps_path: String,
#[serde(rename = "relativeVapsPath")]
pub relative_vaps_path: String,
#[serde(rename = "hasAspa")]
pub has_aspa: bool,
#[serde(rename = "hasRouterKey")]
pub has_router_key: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct DeltaSequenceMetadataV2 {
#[serde(rename = "configuredDeltaCount")]
pub configured_delta_count: usize,
#[serde(rename = "configuredIntervalSeconds")]
pub configured_interval_seconds: u64,
pub steps: Vec<DeltaStepMetadataV2>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct RirBundleMetadataV2 {
#[serde(rename = "schemaVersion")]
pub schema_version: String,
#[serde(rename = "bundleProducer")]
pub bundle_producer: String,
pub rir: String,
#[serde(rename = "talSha256")]
pub tal_sha256: String,
#[serde(rename = "taCertSha256")]
pub ta_cert_sha256: String,
#[serde(rename = "hasAnyAspa")]
pub has_any_aspa: bool,
#[serde(rename = "hasAnyRouterKey")]
pub has_any_router_key: bool,
pub base: BaseBundleStateMetadataV2,
#[serde(rename = "deltaSequence")]
pub delta_sequence: DeltaSequenceMetadataV2,
}

876
src/ccr/build.rs Normal file
View File

@@ -0,0 +1,876 @@
use std::collections::{BTreeMap, BTreeSet};
use sha2::Digest;
use crate::ccr::encode::{
encode_aspa_payload_state_payload_der, encode_manifest_state_payload_der,
encode_roa_payload_state_payload_der, encode_router_key_state_payload_der,
encode_trust_anchor_state_payload_der,
};
use crate::ccr::hash::compute_state_hash;
use crate::ccr::model::{
AspaPayloadSet, AspaPayloadState, ManifestInstance, ManifestState, RoaPayloadSet,
RoaPayloadState, RouterKey, RouterKeySet, RouterKeyState, TrustAnchorState,
};
use crate::blob_store::RawObjectStore;
use crate::data_model::manifest::ManifestObject;
use crate::data_model::rc::{AccessDescription, SubjectInfoAccess};
use crate::data_model::roa::RoaAfi;
use crate::data_model::router_cert::BgpsecRouterCertificate;
use crate::data_model::ta::TrustAnchor;
use crate::storage::{RocksStore, ValidatedCaInstanceResult, VcirArtifactRole};
use crate::validation::objects::{AspaAttestation, Vrp};
#[derive(Debug, thiserror::Error)]
pub enum CcrBuildError {
#[error("trust anchor set must not be empty")]
EmptyTrustAnchors,
#[error("trust anchor certificate missing SubjectKeyIdentifier")]
MissingTrustAnchorSki,
#[error("ROA payload state encoding failed: {0}")]
RoaEncode(String),
#[error("ASPA payload state encoding failed: {0}")]
AspaEncode(String),
#[error("TrustAnchor state encoding failed: {0}")]
TrustAnchorEncode(String),
#[error("manifest artifact missing in VCIR: {0}")]
MissingManifestArtifact(String),
#[error("manifest raw bytes missing in store for {manifest_rsync_uri}: {sha256_hex}")]
MissingManifestRawBytes {
manifest_rsync_uri: String,
sha256_hex: String,
},
#[error("manifest raw bytes load failed for {manifest_rsync_uri}: {detail}")]
LoadManifestRawBytes {
manifest_rsync_uri: String,
detail: String,
},
#[error("manifest decode failed for {manifest_rsync_uri}: {detail}")]
ManifestDecode {
manifest_rsync_uri: String,
detail: String,
},
#[error("manifest EE certificate missing AuthorityKeyIdentifier: {0}")]
ManifestEeMissingAki(String),
#[error("manifest EE certificate missing Subject Information Access extension: {0}")]
ManifestEeMissingSia(String),
#[error("manifest EE certificate SIA is not the EE form: {0}")]
ManifestEeSiaWrongVariant(String),
#[error("manifest child SKI is not valid lowercase hex or not 20 bytes: {0}")]
InvalidChildSki(String),
#[error("manifest AccessDescription contains unsupported accessMethod OID: {0}")]
UnsupportedAccessMethodOid(String),
#[error("manifest state encoding failed: {0}")]
ManifestEncode(String),
#[error("duplicate manifest hash with conflicting content for URI: {0}")]
DuplicateManifestHashConflict(String),
#[error("router key state encoding failed: {0}")]
RouterKeyEncode(String),
}
pub fn build_roa_payload_state(vrps: &[Vrp]) -> Result<RoaPayloadState, CcrBuildError> {
let mut grouped: BTreeMap<u32, BTreeSet<RoaPayloadKey>> = BTreeMap::new();
for vrp in vrps {
grouped
.entry(vrp.asn)
.or_default()
.insert(RoaPayloadKey::from_vrp(vrp));
}
let mut rps = Vec::with_capacity(grouped.len());
for (asn, entries) in grouped {
let mut families: BTreeMap<u16, Vec<RoaPayloadKey>> = BTreeMap::new();
for entry in entries {
families.entry(entry.afi).or_default().push(entry);
}
let mut ip_addr_blocks = Vec::with_capacity(families.len());
for (afi, entries) in families {
ip_addr_blocks.push(encode_roa_ip_address_family(afi, &entries));
}
rps.push(RoaPayloadSet {
as_id: asn,
ip_addr_blocks,
});
}
let payload_der = encode_roa_payload_state_payload_der(&rps)
.map_err(|e| CcrBuildError::RoaEncode(e.to_string()))?;
Ok(RoaPayloadState {
rps,
hash: compute_state_hash(&payload_der),
})
}
pub fn build_aspa_payload_state(
attestations: &[AspaAttestation],
) -> Result<AspaPayloadState, CcrBuildError> {
let mut grouped: BTreeMap<u32, BTreeSet<u32>> = BTreeMap::new();
for attestation in attestations {
grouped
.entry(attestation.customer_as_id)
.or_default()
.extend(attestation.provider_as_ids.iter().copied());
}
let aps: Vec<AspaPayloadSet> = grouped
.into_iter()
.map(|(customer_as_id, providers)| AspaPayloadSet {
customer_as_id,
providers: providers.into_iter().collect(),
})
.collect();
let payload_der = encode_aspa_payload_state_payload_der(&aps)
.map_err(|e| CcrBuildError::AspaEncode(e.to_string()))?;
Ok(AspaPayloadState {
aps,
hash: compute_state_hash(&payload_der),
})
}
pub fn build_trust_anchor_state(
trust_anchors: &[TrustAnchor],
) -> Result<TrustAnchorState, CcrBuildError> {
if trust_anchors.is_empty() {
return Err(CcrBuildError::EmptyTrustAnchors);
}
let mut skis: BTreeSet<Vec<u8>> = BTreeSet::new();
for ta in trust_anchors {
let ski = ta
.ta_certificate
.rc_ca
.tbs
.extensions
.subject_key_identifier
.clone()
.ok_or(CcrBuildError::MissingTrustAnchorSki)?;
skis.insert(ski);
}
let skis: Vec<Vec<u8>> = skis.into_iter().collect();
let payload_der = encode_trust_anchor_state_payload_der(&skis)
.map_err(|e| CcrBuildError::TrustAnchorEncode(e.to_string()))?;
Ok(TrustAnchorState {
skis,
hash: compute_state_hash(&payload_der),
})
}
pub fn build_manifest_state_from_vcirs(
store: &RocksStore,
vcirs: &[ValidatedCaInstanceResult],
) -> Result<ManifestState, CcrBuildError> {
let mut mis_by_hash: BTreeMap<Vec<u8>, ManifestInstance> = BTreeMap::new();
let mut most_recent_update = time::OffsetDateTime::UNIX_EPOCH;
for vcir in vcirs {
let manifest_artifact = vcir
.related_artifacts
.iter()
.find(|artifact| {
artifact.artifact_role == VcirArtifactRole::Manifest
&& artifact.uri.as_deref() == Some(vcir.current_manifest_rsync_uri.as_str())
})
.ok_or_else(|| {
CcrBuildError::MissingManifestArtifact(vcir.current_manifest_rsync_uri.clone())
})?;
let raw_bytes = store
.get_blob_bytes(&manifest_artifact.sha256)
.map_err(|e| CcrBuildError::LoadManifestRawBytes {
manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(),
detail: e.to_string(),
})?
.ok_or_else(|| CcrBuildError::MissingManifestRawBytes {
manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(),
sha256_hex: manifest_artifact.sha256.clone(),
})?;
let manifest = ManifestObject::decode_der(&raw_bytes).map_err(|e| {
CcrBuildError::ManifestDecode {
manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(),
detail: e.to_string(),
}
})?;
let ee = &manifest.signed_object.signed_data.certificates[0].resource_cert;
let aki = ee
.tbs
.extensions
.authority_key_identifier
.clone()
.ok_or_else(|| {
CcrBuildError::ManifestEeMissingAki(vcir.current_manifest_rsync_uri.clone())
})?;
let sia = ee
.tbs
.extensions
.subject_info_access
.as_ref()
.ok_or_else(|| {
CcrBuildError::ManifestEeMissingSia(vcir.current_manifest_rsync_uri.clone())
})?;
let locations = match sia {
SubjectInfoAccess::Ee(ee_sia) => ee_sia
.access_descriptions
.iter()
.map(encode_access_description_der)
.collect::<Result<Vec<_>, _>>()?,
SubjectInfoAccess::Ca(_) => {
return Err(CcrBuildError::ManifestEeSiaWrongVariant(
vcir.current_manifest_rsync_uri.clone(),
));
}
};
let subordinates = collect_subordinate_ski_bytes(vcir)?;
let this_update = vcir
.validated_manifest_meta
.validated_manifest_this_update
.parse()
.map_err(|e| CcrBuildError::ManifestDecode {
manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(),
detail: format!("invalid validated_manifest_this_update: {e}"),
})?;
if this_update > most_recent_update {
most_recent_update = this_update;
}
let instance = ManifestInstance {
hash: sha2::Sha256::digest(&raw_bytes).to_vec(),
size: raw_bytes.len() as u64,
aki,
manifest_number: crate::data_model::common::BigUnsigned {
bytes_be: vcir
.validated_manifest_meta
.validated_manifest_number
.clone(),
},
this_update,
locations,
subordinates,
};
match mis_by_hash.get(instance.hash.as_slice()) {
Some(existing) if existing != &instance => {
return Err(CcrBuildError::DuplicateManifestHashConflict(
vcir.current_manifest_rsync_uri.clone(),
));
}
Some(_) => {}
None => {
mis_by_hash.insert(instance.hash.clone(), instance);
}
}
}
let mis: Vec<ManifestInstance> = mis_by_hash.into_values().collect();
let payload_der = encode_manifest_state_payload_der(&mis)
.map_err(|e| CcrBuildError::ManifestEncode(e.to_string()))?;
Ok(ManifestState {
mis,
most_recent_update,
hash: compute_state_hash(&payload_der),
})
}
fn collect_subordinate_ski_bytes(
vcir: &ValidatedCaInstanceResult,
) -> Result<Vec<Vec<u8>>, CcrBuildError> {
let mut skis = BTreeSet::new();
for child in &vcir.child_entries {
let bytes = hex::decode(&child.child_ski)
.map_err(|_| CcrBuildError::InvalidChildSki(child.child_ski.clone()))?;
if bytes.len() != 20 {
return Err(CcrBuildError::InvalidChildSki(child.child_ski.clone()));
}
skis.insert(bytes);
}
Ok(skis.into_iter().collect())
}
fn encode_access_description_der(ad: &AccessDescription) -> Result<Vec<u8>, CcrBuildError> {
let oid = encode_oid_from_string(&ad.access_method_oid)?;
let uri = encode_tlv(0x86, ad.access_location.as_bytes().to_vec());
Ok(encode_sequence(&[oid, uri]))
}
fn encode_oid_from_string(oid: &str) -> Result<Vec<u8>, CcrBuildError> {
let arcs = oid
.split('.')
.map(|part| {
part.parse::<u64>()
.map_err(|_| CcrBuildError::UnsupportedAccessMethodOid(oid.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
if arcs.len() < 2 {
return Err(CcrBuildError::UnsupportedAccessMethodOid(oid.to_string()));
}
if arcs[0] > 2 || (arcs[0] < 2 && arcs[1] >= 40) {
return Err(CcrBuildError::UnsupportedAccessMethodOid(oid.to_string()));
}
let mut body = Vec::new();
body.push((arcs[0] * 40 + arcs[1]) as u8);
for arc in &arcs[2..] {
encode_base128(*arc, &mut body);
}
Ok(encode_tlv(0x06, body))
}
fn encode_base128(mut value: u64, out: &mut Vec<u8>) {
let mut tmp = vec![(value & 0x7F) as u8];
value >>= 7;
while value > 0 {
tmp.push(((value & 0x7F) as u8) | 0x80);
value >>= 7;
}
tmp.reverse();
out.extend_from_slice(&tmp);
}
pub fn build_router_key_state(
router_certs: &[BgpsecRouterCertificate],
) -> Result<RouterKeyState, CcrBuildError> {
let mut grouped: BTreeMap<u32, BTreeSet<RouterKey>> = BTreeMap::new();
for cert in router_certs {
let key = RouterKey {
ski: cert.subject_key_identifier.clone(),
spki_der: cert.spki_der.clone(),
};
for asn in &cert.asns {
grouped.entry(*asn).or_default().insert(key.clone());
}
}
let rksets: Vec<RouterKeySet> = grouped
.into_iter()
.map(|(as_id, router_keys)| RouterKeySet {
as_id,
router_keys: router_keys.into_iter().collect(),
})
.collect();
let payload_der = encode_router_key_state_payload_der(&rksets)
.map_err(|e| CcrBuildError::RouterKeyEncode(e.to_string()))?;
Ok(RouterKeyState {
rksets,
hash: compute_state_hash(&payload_der),
})
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct RoaPayloadKey {
afi: u16,
addr: Vec<u8>,
prefix_len: u8,
max_length: u8,
}
impl RoaPayloadKey {
fn from_vrp(vrp: &Vrp) -> Self {
let afi = match vrp.prefix.afi {
RoaAfi::Ipv4 => 1,
RoaAfi::Ipv6 => 2,
};
Self {
afi,
addr: vrp.prefix.addr.to_vec(),
prefix_len: vrp.prefix.prefix_len as u8,
max_length: vrp.max_length as u8,
}
}
}
fn encode_roa_ip_address_family(afi: u16, entries: &[RoaPayloadKey]) -> Vec<u8> {
let address_family = afi.to_be_bytes();
let addresses = entries
.iter()
.map(encode_roa_ip_address)
.collect::<Vec<_>>();
encode_sequence(&[
encode_octet_string(&address_family),
encode_sequence(&addresses),
])
}
fn encode_roa_ip_address(entry: &RoaPayloadKey) -> Vec<u8> {
let (unused_bits, content) = encode_prefix_bit_string(&entry.addr, entry.prefix_len);
let mut fields = vec![encode_bit_string(unused_bits, &content)];
if entry.max_length != entry.prefix_len {
fields.push(encode_integer_u8(entry.max_length));
}
encode_sequence(&fields)
}
fn encode_prefix_bit_string(addr: &[u8], prefix_len: u8) -> (u8, Vec<u8>) {
if prefix_len == 0 {
return (0, Vec::new());
}
let octets = ((prefix_len as usize) + 7) / 8;
let mut content = addr[..octets].to_vec();
let rem = prefix_len % 8;
let unused = if rem == 0 { 0 } else { 8 - rem };
if unused > 0 {
let mask = 0xFFu8 << unused;
let last = content.last_mut().expect("octets > 0");
*last &= mask;
}
(unused, content)
}
fn encode_integer_u8(v: u8) -> Vec<u8> {
encode_integer_bytes(vec![v])
}
fn encode_integer_bytes(mut bytes: Vec<u8>) -> Vec<u8> {
if bytes.is_empty() {
bytes.push(0);
}
if bytes[0] & 0x80 != 0 {
bytes.insert(0, 0);
}
encode_tlv(0x02, bytes)
}
fn encode_bit_string(unused_bits: u8, content: &[u8]) -> Vec<u8> {
let mut value = Vec::with_capacity(content.len() + 1);
value.push(unused_bits);
value.extend_from_slice(content);
encode_tlv(0x03, value)
}
fn encode_octet_string(bytes: &[u8]) -> Vec<u8> {
encode_tlv(0x04, bytes.to_vec())
}
fn encode_sequence(elements: &[Vec<u8>]) -> Vec<u8> {
let total_len: usize = elements.iter().map(Vec::len).sum();
let mut buf = Vec::with_capacity(total_len);
for element in elements {
buf.extend_from_slice(element);
}
encode_tlv(0x30, buf)
}
fn encode_tlv(tag: u8, value: Vec<u8>) -> Vec<u8> {
let mut out = Vec::with_capacity(1 + 9 + value.len());
out.push(tag);
encode_length(value.len(), &mut out);
out.extend_from_slice(&value);
out
}
fn encode_length(len: usize, out: &mut Vec<u8>) {
if len < 0x80 {
out.push(len as u8);
return;
}
let mut bytes = Vec::new();
let mut value = len;
while value > 0 {
bytes.push((value & 0xFF) as u8);
value >>= 8;
}
bytes.reverse();
out.push(0x80 | (bytes.len() as u8));
out.extend_from_slice(&bytes);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ccr::decode::decode_content_info;
use crate::ccr::encode::{
encode_aspa_payload_state, encode_content_info, encode_trust_anchor_state,
};
use crate::ccr::model::{CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation};
use crate::data_model::roa::{IpPrefix, RoaAfi};
use crate::data_model::ta::TrustAnchor;
use crate::data_model::tal::Tal;
fn sample_vrp_v4(asn: u32, a: [u8; 4], prefix_len: u16, max_length: u16) -> Vrp {
Vrp {
asn,
prefix: IpPrefix {
afi: RoaAfi::Ipv4,
prefix_len,
addr: [a[0], a[1], a[2], a[3], 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
max_length,
}
}
fn sample_vrp_v6(asn: u32, a: [u8; 16], prefix_len: u16, max_length: u16) -> Vrp {
Vrp {
asn,
prefix: IpPrefix {
afi: RoaAfi::Ipv6,
prefix_len,
addr: a,
},
max_length,
}
}
fn decode_family_block(block: &[u8]) -> (u16, Vec<(u8, Vec<u8>, Option<u8>)>) {
let mut top = crate::data_model::common::DerReader::new(block);
let mut seq = top.take_sequence().expect("family seq");
assert!(top.is_empty());
let afi_bytes = seq.take_octet_string().expect("afi octet string");
let afi = u16::from_be_bytes([afi_bytes[0], afi_bytes[1]]);
let mut addrs = seq.take_sequence().expect("addresses seq");
let mut entries = Vec::new();
while !addrs.is_empty() {
let mut addr_seq = addrs.take_sequence().expect("roa ip addr seq");
let (unused_bits, content) = addr_seq.take_bit_string().expect("bit string");
let prefix_len = (content.len() * 8) as u8 - unused_bits;
let max_len = if addr_seq.is_empty() {
None
} else {
Some(addr_seq.take_uint_u64().expect("maxlen") as u8)
};
entries.push((prefix_len, content.to_vec(), max_len));
}
(afi, entries)
}
fn sample_trust_anchor(path_tal: &str, path_ta: &str) -> TrustAnchor {
let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let tal_bytes = std::fs::read(base.join(path_tal)).expect("read tal");
let ta_der = std::fs::read(base.join(path_ta)).expect("read ta");
let tal = Tal::decode_bytes(&tal_bytes).expect("decode tal");
TrustAnchor::bind_der(tal, &ta_der, None).expect("bind ta")
}
fn sample_router_cert(asns: Vec<u32>, ski_fill: u8, spki_fill: u8) -> BgpsecRouterCertificate {
let ta = sample_trust_anchor(
"tests/fixtures/tal/apnic-rfc7730-https.tal",
"tests/fixtures/ta/apnic-ta.cer",
);
BgpsecRouterCertificate {
raw_der: vec![0x01, 0x02],
resource_cert: ta.ta_certificate.rc_ca,
subject_key_identifier: vec![ski_fill; 20],
spki_der: vec![0x30, 0x03, 0x03, 0x01, spki_fill],
asns,
}
}
fn sample_manifest_vcir(
manifest_uri: &str,
manifest_der: &[u8],
child_ski_hex: &str,
) -> ValidatedCaInstanceResult {
let manifest = ManifestObject::decode_der(manifest_der).expect("decode manifest fixture");
let hash = hex::encode(sha2::Sha256::digest(manifest_der));
let now = manifest.manifest.this_update;
let next = manifest.manifest.next_update;
crate::storage::ValidatedCaInstanceResult {
manifest_rsync_uri: manifest_uri.to_string(),
parent_manifest_rsync_uri: None,
tal_id: "test-tal".to_string(),
ca_subject_name: "CN=test".to_string(),
ca_ski: "11".repeat(20),
issuer_ski: "22".repeat(20),
last_successful_validation_time: crate::storage::PackTime::from_utc_offset_datetime(
now,
),
current_manifest_rsync_uri: manifest_uri.to_string(),
current_crl_rsync_uri: format!("{manifest_uri}.crl"),
validated_manifest_meta: crate::storage::ValidatedManifestMeta {
validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(),
validated_manifest_this_update: crate::storage::PackTime::from_utc_offset_datetime(
now,
),
validated_manifest_next_update: crate::storage::PackTime::from_utc_offset_datetime(
next,
),
},
instance_gate: crate::storage::VcirInstanceGate {
manifest_next_update: crate::storage::PackTime::from_utc_offset_datetime(next),
current_crl_next_update: crate::storage::PackTime::from_utc_offset_datetime(next),
self_ca_not_after: crate::storage::PackTime::from_utc_offset_datetime(next),
instance_effective_until: crate::storage::PackTime::from_utc_offset_datetime(next),
},
child_entries: vec![crate::storage::VcirChildEntry {
child_manifest_rsync_uri: format!("{manifest_uri}/child.mft"),
child_cert_rsync_uri: format!("{manifest_uri}/child.cer"),
child_cert_hash: "aa".repeat(32),
child_ski: child_ski_hex.to_string(),
child_rsync_base_uri: format!("{manifest_uri}/"),
child_publication_point_rsync_uri: format!("{manifest_uri}/"),
child_rrdp_notification_uri: None,
child_effective_ip_resources: None,
child_effective_as_resources: None,
accepted_at_validation_time: crate::storage::PackTime::from_utc_offset_datetime(
now,
),
}],
local_outputs: Vec::new(),
related_artifacts: vec![crate::storage::VcirRelatedArtifact {
artifact_role: crate::storage::VcirArtifactRole::Manifest,
artifact_kind: crate::storage::VcirArtifactKind::Mft,
uri: Some(manifest_uri.to_string()),
sha256: hash,
object_type: Some("mft".to_string()),
validation_status: crate::storage::VcirArtifactValidationStatus::Accepted,
}],
summary: crate::storage::VcirSummary {
local_vrp_count: 0,
local_aspa_count: 0,
local_router_key_count: 0,
child_count: 1,
accepted_object_count: 1,
rejected_object_count: 0,
},
audit_summary: crate::storage::VcirAuditSummary {
failed_fetch_eligible: true,
last_failed_fetch_reason: None,
warning_count: 0,
audit_flags: Vec::new(),
},
}
}
#[test]
fn build_manifest_state_from_vcirs_collects_current_manifests_and_hashes_payload() {
let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let manifest_a = std::fs::read(base.join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft")).expect("read manifest a");
let manifest_b = std::fs::read(base.join(
"tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft",
))
.expect("read manifest b");
let vcir_a =
sample_manifest_vcir("rsync://example.test/a.mft", &manifest_a, &"33".repeat(20));
let vcir_b =
sample_manifest_vcir("rsync://example.test/b.mft", &manifest_b, &"44".repeat(20));
let store_dir = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
for (vcir, bytes) in [(&vcir_a, &manifest_a), (&vcir_b, &manifest_b)] {
let artifact = &vcir.related_artifacts[0];
let mut raw =
crate::storage::RawByHashEntry::from_bytes(artifact.sha256.clone(), bytes.to_vec());
raw.origin_uris
.push(vcir.current_manifest_rsync_uri.clone());
raw.object_type = Some("mft".to_string());
raw.encoding = Some("der".to_string());
store.put_raw_by_hash_entry(&raw).expect("put raw manifest");
}
let state = build_manifest_state_from_vcirs(&store, &[vcir_a.clone(), vcir_b.clone()])
.expect("build manifest state");
assert_eq!(state.mis.len(), 2);
assert!(state.mis[0].hash < state.mis[1].hash);
let expected_subordinates = [
hex::decode(vcir_a.child_entries[0].child_ski.clone()).unwrap(),
hex::decode(vcir_b.child_entries[0].child_ski.clone()).unwrap(),
];
let actual_subordinates = state
.mis
.iter()
.map(|mi| {
assert_eq!(mi.subordinates.len(), 1);
assert!(!mi.locations.is_empty());
mi.subordinates[0].clone()
})
.collect::<std::collections::BTreeSet<_>>();
let expected_subordinates = expected_subordinates
.into_iter()
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(actual_subordinates, expected_subordinates);
let payload_der =
encode_manifest_state_payload_der(&state.mis).expect("encode mis payload");
assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der));
let max_time = [
vcir_a
.validated_manifest_meta
.validated_manifest_this_update
.parse()
.unwrap(),
vcir_b
.validated_manifest_meta
.validated_manifest_this_update
.parse()
.unwrap(),
]
.into_iter()
.max()
.unwrap();
assert_eq!(state.most_recent_update, max_time);
}
#[test]
fn build_manifest_state_from_vcirs_empty_uses_epoch() {
let store_dir = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
let state = build_manifest_state_from_vcirs(&store, &[]).expect("empty manifest state");
assert!(state.mis.is_empty());
assert_eq!(state.most_recent_update, time::OffsetDateTime::UNIX_EPOCH);
let payload_der =
encode_manifest_state_payload_der(&state.mis).expect("encode mis payload");
assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der));
}
#[test]
fn build_router_key_state_groups_dedupes_and_sorts() {
let certs = vec![
sample_router_cert(vec![64497], 0x21, 0xA1),
sample_router_cert(vec![64496, 64497], 0x11, 0xA0),
sample_router_cert(vec![64496], 0x11, 0xA0),
sample_router_cert(vec![64496], 0x11, 0xA2),
];
let state = build_router_key_state(&certs).expect("build router key state");
assert_eq!(state.rksets.len(), 2);
assert_eq!(state.rksets[0].as_id, 64496);
assert_eq!(state.rksets[1].as_id, 64497);
assert_eq!(state.rksets[0].router_keys.len(), 2);
assert_eq!(state.rksets[1].router_keys.len(), 2);
assert!(state.rksets[0].router_keys[0].ski <= state.rksets[0].router_keys[1].ski);
let payload_der =
encode_router_key_state_payload_der(&state.rksets).expect("encode rk payload");
assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der));
}
#[test]
fn build_router_key_state_empty_is_valid_and_hashes_empty_sequence() {
let state = build_router_key_state(&[]).expect("empty router key state");
assert!(state.rksets.is_empty());
let payload_der =
encode_router_key_state_payload_der(&state.rksets).expect("encode rk payload");
assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der));
}
#[test]
fn build_roa_payload_state_groups_dedupes_and_sorts() {
let vrps = vec![
sample_vrp_v4(64497, [10, 1, 0, 0], 16, 16),
sample_vrp_v4(64496, [10, 0, 0, 0], 8, 8),
sample_vrp_v4(64496, [10, 0, 0, 0], 8, 8),
sample_vrp_v4(64496, [10, 1, 0, 0], 16, 24),
sample_vrp_v6(
64496,
[0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
32,
48,
),
];
let state = build_roa_payload_state(&vrps).expect("build roa state");
assert_eq!(state.rps.len(), 2);
assert_eq!(state.rps[0].as_id, 64496);
assert_eq!(state.rps[1].as_id, 64497);
assert_eq!(state.rps[0].ip_addr_blocks.len(), 2);
let (afi4, entries4) = decode_family_block(&state.rps[0].ip_addr_blocks[0]);
let (afi6, entries6) = decode_family_block(&state.rps[0].ip_addr_blocks[1]);
assert_eq!(afi4, 1);
assert_eq!(afi6, 2);
assert_eq!(entries4.len(), 2);
assert_eq!(entries4[0], (8, vec![10], None));
assert_eq!(entries4[1], (16, vec![10, 1], Some(24)));
assert_eq!(entries6.len(), 1);
assert_eq!(entries6[0], (32, vec![0x20, 0x01, 0x0d, 0xb8], Some(48)));
let payload_der = encode_roa_payload_state_payload_der(&state.rps).expect("encode payload");
assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der));
}
#[test]
fn build_roa_payload_state_empty_is_valid_and_hashes_empty_sequence() {
let state = build_roa_payload_state(&[]).expect("empty roa state");
assert!(state.rps.is_empty());
let payload_der = encode_roa_payload_state_payload_der(&state.rps).expect("encode payload");
assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der));
}
#[test]
fn build_aspa_payload_state_merges_and_sorts() {
let aspas = vec![
AspaAttestation {
customer_as_id: 64497,
provider_as_ids: vec![65002],
},
AspaAttestation {
customer_as_id: 64496,
provider_as_ids: vec![65003, 65001],
},
AspaAttestation {
customer_as_id: 64496,
provider_as_ids: vec![65002, 65001],
},
];
let state = build_aspa_payload_state(&aspas).expect("build aspa state");
assert_eq!(state.aps.len(), 2);
assert_eq!(state.aps[0].customer_as_id, 64496);
assert_eq!(state.aps[0].providers, vec![65001, 65002, 65003]);
assert_eq!(state.aps[1].customer_as_id, 64497);
let encoded = encode_aspa_payload_state(&state).expect("encode aspa state");
let decoded = decode_content_info(
&encode_content_info(&CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: time::OffsetDateTime::now_utc(),
mfts: None,
vrps: None,
vaps: Some(state.clone()),
tas: None,
rks: None,
}))
.expect("encode ccr"),
)
.expect("decode ccr");
assert_eq!(decoded.content.vaps, Some(state));
assert!(!encoded.is_empty());
}
#[test]
fn build_trust_anchor_state_collects_sorted_unique_skis() {
let apnic = sample_trust_anchor(
"tests/fixtures/tal/apnic-rfc7730-https.tal",
"tests/fixtures/ta/apnic-ta.cer",
);
let arin = sample_trust_anchor(
"tests/fixtures/tal/arin.tal",
"tests/fixtures/ta/arin-ta.cer",
);
let state = build_trust_anchor_state(&[apnic.clone(), arin.clone(), apnic])
.expect("build ta state");
assert_eq!(state.skis.len(), 2);
assert!(state.skis[0] < state.skis[1]);
let payload_der =
encode_trust_anchor_state_payload_der(&state.skis).expect("encode ta payload");
assert!(crate::ccr::verify_state_hash(&state.hash, &payload_der));
let encoded = encode_trust_anchor_state(&state).expect("encode ta state");
assert!(!encoded.is_empty());
}
#[test]
fn build_trust_anchor_state_rejects_empty_and_missing_ski() {
let err = build_trust_anchor_state(&[]).expect_err("empty TAs must fail");
assert!(err.to_string().contains("trust anchor set"), "{err}");
let mut ta = sample_trust_anchor(
"tests/fixtures/tal/apnic-rfc7730-https.tal",
"tests/fixtures/ta/apnic-ta.cer",
);
ta.ta_certificate
.rc_ca
.tbs
.extensions
.subject_key_identifier = None;
let err = build_trust_anchor_state(&[ta]).expect_err("missing ski must fail");
assert!(err.to_string().contains("SubjectKeyIdentifier"), "{err}");
}
}

473
src/ccr/decode.rs Normal file
View File

@@ -0,0 +1,473 @@
use crate::ccr::model::{
AspaPayloadSet, AspaPayloadState, CCR_VERSION_V0, CcrContentInfo, CcrDigestAlgorithm,
ManifestInstance, ManifestState, RoaPayloadSet, RoaPayloadState, RouterKey, RouterKeySet,
RouterKeyState, RpkiCanonicalCacheRepresentation, TrustAnchorState,
};
use crate::data_model::common::{BigUnsigned, DerReader};
use crate::data_model::oid::{OID_CT_RPKI_CCR, OID_CT_RPKI_CCR_RAW, OID_SHA256, OID_SHA256_RAW};
use der_parser::der::parse_der_oid;
#[derive(Debug, thiserror::Error)]
pub enum CcrDecodeError {
#[error("DER parse error: {0}")]
Parse(String),
#[error("unexpected contentType OID: expected {expected}, got {actual}")]
UnexpectedContentType {
expected: &'static str,
actual: String,
},
#[error("unexpected digest algorithm OID: expected {expected}, got {actual}")]
UnexpectedDigestAlgorithm {
expected: &'static str,
actual: String,
},
#[error("CCR model validation failed after decode: {0}")]
Validate(String),
}
pub fn decode_content_info(der: &[u8]) -> Result<CcrContentInfo, CcrDecodeError> {
let mut top = DerReader::new(der);
let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?;
if !top.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing bytes after ContentInfo".into(),
));
}
let content_type_raw = seq.take_tag(0x06).map_err(CcrDecodeError::Parse)?;
if content_type_raw != OID_CT_RPKI_CCR_RAW {
return Err(CcrDecodeError::UnexpectedContentType {
expected: OID_CT_RPKI_CCR,
actual: oid_string(content_type_raw)?,
});
}
let inner = seq.take_tag(0xA0).map_err(CcrDecodeError::Parse)?;
if !seq.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing fields in ContentInfo".into(),
));
}
let content = decode_ccr(inner)?;
let ci = CcrContentInfo::new(content);
ci.validate().map_err(CcrDecodeError::Validate)?;
Ok(ci)
}
pub fn decode_ccr(der: &[u8]) -> Result<RpkiCanonicalCacheRepresentation, CcrDecodeError> {
let mut top = DerReader::new(der);
let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?;
if !top.is_empty() {
return Err(CcrDecodeError::Parse("trailing bytes after CCR".into()));
}
let version = if !seq.is_empty() && seq.peek_tag().map_err(CcrDecodeError::Parse)? == 0xA0 {
let explicit = seq.take_tag(0xA0).map_err(CcrDecodeError::Parse)?;
let mut inner = DerReader::new(explicit);
let version = inner.take_uint_u64().map_err(CcrDecodeError::Parse)? as u32;
if !inner.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing bytes inside CCR version EXPLICIT".into(),
));
}
version
} else {
CCR_VERSION_V0
};
let hash_alg = decode_digest_algorithm(seq.take_sequence().map_err(CcrDecodeError::Parse)?)?;
let produced_at = parse_generalized_time(seq.take_tag(0x18).map_err(CcrDecodeError::Parse)?)?;
let mut mfts = None;
let mut vrps = None;
let mut vaps = None;
let mut tas = None;
let mut rks = None;
while !seq.is_empty() {
let tag = seq.peek_tag().map_err(CcrDecodeError::Parse)?;
let (tag_read, value) = seq.take_any().map_err(CcrDecodeError::Parse)?;
debug_assert_eq!(tag, tag_read);
match tag {
0xA1 => mfts = Some(decode_manifest_state(value)?),
0xA2 => vrps = Some(decode_roa_payload_state(value)?),
0xA3 => vaps = Some(decode_aspa_payload_state(value)?),
0xA4 => tas = Some(decode_trust_anchor_state(value)?),
0xA5 => rks = Some(decode_router_key_state(value)?),
_ => {
return Err(CcrDecodeError::Parse(format!(
"unexpected CCR field tag 0x{tag:02X}"
)));
}
}
}
let ccr = RpkiCanonicalCacheRepresentation {
version,
hash_alg,
produced_at,
mfts,
vrps,
vaps,
tas,
rks,
};
ccr.validate().map_err(CcrDecodeError::Validate)?;
Ok(ccr)
}
fn decode_manifest_state(explicit_der: &[u8]) -> Result<ManifestState, CcrDecodeError> {
let mut outer = DerReader::new(explicit_der);
let mut seq = outer.take_sequence().map_err(CcrDecodeError::Parse)?;
if !outer.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing bytes after ManifestState".into(),
));
}
let mis_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?;
let mut mis_reader = DerReader::new(mis_der);
let mut mis = Vec::new();
while !mis_reader.is_empty() {
let (_tag, full, _value) = mis_reader.take_any_full().map_err(CcrDecodeError::Parse)?;
mis.push(decode_manifest_instance(full)?);
}
let most_recent_update =
parse_generalized_time(seq.take_tag(0x18).map_err(CcrDecodeError::Parse)?)?;
let hash = seq
.take_octet_string()
.map_err(CcrDecodeError::Parse)?
.to_vec();
if !seq.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing fields in ManifestState".into(),
));
}
Ok(ManifestState {
mis,
most_recent_update,
hash,
})
}
fn decode_manifest_instance(der: &[u8]) -> Result<ManifestInstance, CcrDecodeError> {
let mut top = DerReader::new(der);
let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?;
if !top.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing bytes after ManifestInstance".into(),
));
}
let hash = seq
.take_octet_string()
.map_err(CcrDecodeError::Parse)?
.to_vec();
let size = seq.take_uint_u64().map_err(CcrDecodeError::Parse)?;
let aki = seq
.take_octet_string()
.map_err(CcrDecodeError::Parse)?
.to_vec();
let manifest_number = decode_big_unsigned(seq.take_tag(0x02).map_err(CcrDecodeError::Parse)?)?;
let this_update = parse_generalized_time(seq.take_tag(0x18).map_err(CcrDecodeError::Parse)?)?;
let locations_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?;
let mut locations_reader = DerReader::new(locations_der);
let mut locations = Vec::new();
while !locations_reader.is_empty() {
let (_tag, full, _value) = locations_reader
.take_any_full()
.map_err(CcrDecodeError::Parse)?;
locations.push(full.to_vec());
}
let subordinates = if !seq.is_empty() {
let subordinate_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?;
let mut reader = DerReader::new(subordinate_der);
let mut out = Vec::new();
while !reader.is_empty() {
out.push(
reader
.take_octet_string()
.map_err(CcrDecodeError::Parse)?
.to_vec(),
);
}
out
} else {
Vec::new()
};
Ok(ManifestInstance {
hash,
size,
aki,
manifest_number,
this_update,
locations,
subordinates,
})
}
fn decode_roa_payload_state(explicit_der: &[u8]) -> Result<RoaPayloadState, CcrDecodeError> {
let mut outer = DerReader::new(explicit_der);
let mut seq = outer.take_sequence().map_err(CcrDecodeError::Parse)?;
if !outer.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing bytes after ROAPayloadState".into(),
));
}
let payload_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?;
let mut reader = DerReader::new(payload_der);
let mut rps = Vec::new();
while !reader.is_empty() {
let (_tag, full, _value) = reader.take_any_full().map_err(CcrDecodeError::Parse)?;
rps.push(decode_roa_payload_set(full)?);
}
let hash = seq
.take_octet_string()
.map_err(CcrDecodeError::Parse)?
.to_vec();
Ok(RoaPayloadState { rps, hash })
}
fn decode_roa_payload_set(der: &[u8]) -> Result<RoaPayloadSet, CcrDecodeError> {
let mut top = DerReader::new(der);
let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?;
if !top.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing bytes after ROAPayloadSet".into(),
));
}
let as_id = seq.take_uint_u64().map_err(CcrDecodeError::Parse)? as u32;
let blocks_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?;
let mut reader = DerReader::new(blocks_der);
let mut ip_addr_blocks = Vec::new();
while !reader.is_empty() {
let (_tag, full, _value) = reader.take_any_full().map_err(CcrDecodeError::Parse)?;
ip_addr_blocks.push(full.to_vec());
}
Ok(RoaPayloadSet {
as_id,
ip_addr_blocks,
})
}
fn decode_aspa_payload_state(explicit_der: &[u8]) -> Result<AspaPayloadState, CcrDecodeError> {
let mut outer = DerReader::new(explicit_der);
let mut seq = outer.take_sequence().map_err(CcrDecodeError::Parse)?;
if !outer.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing bytes after ASPAPayloadState".into(),
));
}
let payload_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?;
let mut reader = DerReader::new(payload_der);
let mut aps = Vec::new();
while !reader.is_empty() {
let (_tag, full, _value) = reader.take_any_full().map_err(CcrDecodeError::Parse)?;
aps.push(decode_aspa_payload_set(full)?);
}
let hash = seq
.take_octet_string()
.map_err(CcrDecodeError::Parse)?
.to_vec();
Ok(AspaPayloadState { aps, hash })
}
fn decode_aspa_payload_set(der: &[u8]) -> Result<AspaPayloadSet, CcrDecodeError> {
let mut top = DerReader::new(der);
let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?;
if !top.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing bytes after ASPAPayloadSet".into(),
));
}
let customer_as_id = seq.take_uint_u64().map_err(CcrDecodeError::Parse)? as u32;
let providers_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?;
let mut reader = DerReader::new(providers_der);
let mut providers = Vec::new();
while !reader.is_empty() {
providers.push(reader.take_uint_u64().map_err(CcrDecodeError::Parse)? as u32);
}
Ok(AspaPayloadSet {
customer_as_id,
providers,
})
}
fn decode_trust_anchor_state(explicit_der: &[u8]) -> Result<TrustAnchorState, CcrDecodeError> {
let mut outer = DerReader::new(explicit_der);
let mut seq = outer.take_sequence().map_err(CcrDecodeError::Parse)?;
if !outer.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing bytes after TrustAnchorState".into(),
));
}
let skis_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?;
let mut reader = DerReader::new(skis_der);
let mut skis = Vec::new();
while !reader.is_empty() {
skis.push(
reader
.take_octet_string()
.map_err(CcrDecodeError::Parse)?
.to_vec(),
);
}
let hash = seq
.take_octet_string()
.map_err(CcrDecodeError::Parse)?
.to_vec();
Ok(TrustAnchorState { skis, hash })
}
fn decode_router_key_state(explicit_der: &[u8]) -> Result<RouterKeyState, CcrDecodeError> {
let mut outer = DerReader::new(explicit_der);
let mut seq = outer.take_sequence().map_err(CcrDecodeError::Parse)?;
if !outer.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing bytes after RouterKeyState".into(),
));
}
let sets_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?;
let mut reader = DerReader::new(sets_der);
let mut rksets = Vec::new();
while !reader.is_empty() {
let (_tag, full, _value) = reader.take_any_full().map_err(CcrDecodeError::Parse)?;
rksets.push(decode_router_key_set(full)?);
}
let hash = seq
.take_octet_string()
.map_err(CcrDecodeError::Parse)?
.to_vec();
Ok(RouterKeyState { rksets, hash })
}
fn decode_router_key_set(der: &[u8]) -> Result<RouterKeySet, CcrDecodeError> {
let mut top = DerReader::new(der);
let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?;
if !top.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing bytes after RouterKeySet".into(),
));
}
let as_id = seq.take_uint_u64().map_err(CcrDecodeError::Parse)? as u32;
let keys_der = seq.take_tag(0x30).map_err(CcrDecodeError::Parse)?;
let mut reader = DerReader::new(keys_der);
let mut router_keys = Vec::new();
while !reader.is_empty() {
let (_tag, full, _value) = reader.take_any_full().map_err(CcrDecodeError::Parse)?;
router_keys.push(decode_router_key(full)?);
}
Ok(RouterKeySet { as_id, router_keys })
}
fn decode_router_key(der: &[u8]) -> Result<RouterKey, CcrDecodeError> {
let mut top = DerReader::new(der);
let mut seq = top.take_sequence().map_err(CcrDecodeError::Parse)?;
if !top.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing bytes after RouterKey".into(),
));
}
let ski = seq
.take_octet_string()
.map_err(CcrDecodeError::Parse)?
.to_vec();
let (_tag, full, _value) = seq.take_any_full().map_err(CcrDecodeError::Parse)?;
if !seq.is_empty() {
return Err(CcrDecodeError::Parse("trailing fields in RouterKey".into()));
}
Ok(RouterKey {
ski,
spki_der: full.to_vec(),
})
}
fn decode_digest_algorithm(mut seq: DerReader<'_>) -> Result<CcrDigestAlgorithm, CcrDecodeError> {
let oid_raw = seq.take_tag(0x06).map_err(CcrDecodeError::Parse)?;
if oid_raw != OID_SHA256_RAW {
return Err(CcrDecodeError::UnexpectedDigestAlgorithm {
expected: OID_SHA256,
actual: oid_string(oid_raw)?,
});
}
if !seq.is_empty() {
let tag = seq.peek_tag().map_err(CcrDecodeError::Parse)?;
if tag == 0x05 {
let null = seq.take_tag(0x05).map_err(CcrDecodeError::Parse)?;
if !null.is_empty() {
return Err(CcrDecodeError::Parse(
"AlgorithmIdentifier NULL parameters must be empty".into(),
));
}
}
}
if !seq.is_empty() {
return Err(CcrDecodeError::Parse(
"trailing fields in DigestAlgorithmIdentifier".into(),
));
}
Ok(CcrDigestAlgorithm::Sha256)
}
fn oid_string(raw_body: &[u8]) -> Result<String, CcrDecodeError> {
let der = {
let mut out = Vec::with_capacity(raw_body.len() + 2);
out.push(0x06);
if raw_body.len() < 0x80 {
out.push(raw_body.len() as u8);
} else {
return Err(CcrDecodeError::Parse("OID too long".into()));
}
out.extend_from_slice(raw_body);
out
};
let (_rem, oid) = parse_der_oid(&der).map_err(|e| CcrDecodeError::Parse(e.to_string()))?;
let oid = oid
.as_oid_val()
.map_err(|e| CcrDecodeError::Parse(e.to_string()))?;
Ok(oid.to_string())
}
fn parse_generalized_time(bytes: &[u8]) -> Result<time::OffsetDateTime, CcrDecodeError> {
let s = std::str::from_utf8(bytes).map_err(|e| CcrDecodeError::Parse(e.to_string()))?;
if s.len() != 15 || !s.ends_with('Z') {
return Err(CcrDecodeError::Parse(
"GeneralizedTime must be YYYYMMDDHHMMSSZ".into(),
));
}
let parse = |range: std::ops::Range<usize>| -> Result<u32, CcrDecodeError> {
s[range]
.parse::<u32>()
.map_err(|e| CcrDecodeError::Parse(e.to_string()))
};
let year = parse(0..4)? as i32;
let month = parse(4..6)? as u8;
let day = parse(6..8)? as u8;
let hour = parse(8..10)? as u8;
let minute = parse(10..12)? as u8;
let second = parse(12..14)? as u8;
let month = time::Month::try_from(month).map_err(|e| CcrDecodeError::Parse(e.to_string()))?;
let date = time::Date::from_calendar_date(year, month, day)
.map_err(|e| CcrDecodeError::Parse(e.to_string()))?;
let timev = time::Time::from_hms(hour, minute, second)
.map_err(|e| CcrDecodeError::Parse(e.to_string()))?;
Ok(time::PrimitiveDateTime::new(date, timev).assume_utc())
}
fn decode_big_unsigned(bytes: &[u8]) -> Result<BigUnsigned, CcrDecodeError> {
if bytes.is_empty() {
return Err(CcrDecodeError::Parse("INTEGER has empty content".into()));
}
if bytes[0] & 0x80 != 0 {
return Err(CcrDecodeError::Parse("INTEGER must be non-negative".into()));
}
if bytes.len() > 1 && bytes[0] == 0x00 && (bytes[1] & 0x80) == 0 {
return Err(CcrDecodeError::Parse(
"INTEGER not minimally encoded".into(),
));
}
let bytes_be = if bytes.len() > 1 && bytes[0] == 0x00 {
bytes[1..].to_vec()
} else {
bytes.to_vec()
};
Ok(BigUnsigned { bytes_be })
}

112
src/ccr/dump.rs Normal file
View File

@@ -0,0 +1,112 @@
use crate::ccr::decode::{CcrDecodeError, decode_content_info};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum CcrDumpError {
#[error("CCR decode failed: {0}")]
Decode(#[from] CcrDecodeError),
#[error("format time failed: {0}")]
FormatTime(String),
}
pub fn dump_content_info_json_value(der: &[u8]) -> Result<serde_json::Value, CcrDumpError> {
let content_info = decode_content_info(der)?;
dump_content_info_json(&content_info)
}
pub fn dump_content_info_json(
content_info: &crate::ccr::model::CcrContentInfo,
) -> Result<serde_json::Value, CcrDumpError> {
let produced_at = content_info
.content
.produced_at
.to_offset(time::UtcOffset::UTC)
.format(&time::format_description::well_known::Rfc3339)
.map_err(|e| CcrDumpError::FormatTime(e.to_string()))?;
let mfts = content_info.content.mfts.as_ref().map(|state| {
json!({
"present": true,
"manifest_instances": state.mis.len(),
"most_recent_update_rfc3339_utc": state.most_recent_update.to_offset(time::UtcOffset::UTC).format(&time::format_description::well_known::Rfc3339).unwrap(),
"hash_hex": hex::encode(&state.hash),
})
}).unwrap_or_else(|| json!({"present": false}));
let vrps_total = content_info
.content
.vrps
.as_ref()
.map(|state| {
state
.rps
.iter()
.map(|set| set.ip_addr_blocks.len())
.sum::<usize>()
})
.unwrap_or(0);
let vrps = content_info
.content
.vrps
.as_ref()
.map(|state| {
json!({
"present": true,
"payload_sets": state.rps.len(),
"hash_hex": hex::encode(&state.hash),
"ip_addr_block_count": vrps_total,
})
})
.unwrap_or_else(|| json!({"present": false}));
let vaps = content_info
.content
.vaps
.as_ref()
.map(|state| {
json!({
"present": true,
"payload_sets": state.aps.len(),
"hash_hex": hex::encode(&state.hash),
"provider_count": state.aps.iter().map(|set| set.providers.len()).sum::<usize>(),
})
})
.unwrap_or_else(|| json!({"present": false}));
let tas = content_info
.content
.tas
.as_ref()
.map(|state| {
json!({
"present": true,
"ski_count": state.skis.len(),
"hash_hex": hex::encode(&state.hash),
})
})
.unwrap_or_else(|| json!({"present": false}));
let rks = content_info.content.rks.as_ref().map(|state| {
json!({
"present": true,
"router_key_sets": state.rksets.len(),
"router_key_count": state.rksets.iter().map(|set| set.router_keys.len()).sum::<usize>(),
"hash_hex": hex::encode(&state.hash),
})
}).unwrap_or_else(|| json!({"present": false}));
Ok(json!({
"content_type_oid": content_info.content_type_oid,
"version": content_info.content.version,
"hash_alg": content_info.content.hash_alg.oid(),
"produced_at_rfc3339_utc": produced_at,
"state_aspects": {
"mfts": mfts,
"vrps": vrps,
"vaps": vaps,
"tas": tas,
"rks": rks,
}
}))
}

303
src/ccr/encode.rs Normal file
View File

@@ -0,0 +1,303 @@
use crate::ccr::model::{
AspaPayloadSet, AspaPayloadState, CCR_VERSION_V0, CcrContentInfo, CcrDigestAlgorithm,
ManifestInstance, ManifestState, RoaPayloadSet, RoaPayloadState, RouterKey, RouterKeySet,
RouterKeyState, RpkiCanonicalCacheRepresentation, TrustAnchorState,
};
use crate::data_model::common::BigUnsigned;
use crate::data_model::oid::{OID_CT_RPKI_CCR_RAW, OID_SHA256_RAW};
#[derive(Debug, thiserror::Error)]
pub enum CcrEncodeError {
#[error("CCR model validation failed: {0}")]
Validate(String),
#[error("GeneralizedTime formatting failed: {0}")]
ProducedAtFormat(String),
}
pub fn encode_content_info(content_info: &CcrContentInfo) -> Result<Vec<u8>, CcrEncodeError> {
content_info.validate().map_err(CcrEncodeError::Validate)?;
let content_der = encode_ccr(&content_info.content)?;
Ok(encode_sequence(&[
encode_oid(OID_CT_RPKI_CCR_RAW),
encode_explicit(0, &content_der),
]))
}
pub fn encode_ccr(ccr: &RpkiCanonicalCacheRepresentation) -> Result<Vec<u8>, CcrEncodeError> {
ccr.validate().map_err(CcrEncodeError::Validate)?;
let mut fields = Vec::new();
if ccr.version != CCR_VERSION_V0 {
fields.push(encode_explicit(0, &encode_integer_u32(ccr.version)));
}
fields.push(encode_digest_algorithm(&ccr.hash_alg));
fields.push(encode_generalized_time(ccr.produced_at)?);
if let Some(mfts) = &ccr.mfts {
fields.push(encode_explicit(1, &encode_manifest_state(mfts)?));
}
if let Some(vrps) = &ccr.vrps {
fields.push(encode_explicit(2, &encode_roa_payload_state(vrps)?));
}
if let Some(vaps) = &ccr.vaps {
fields.push(encode_explicit(3, &encode_aspa_payload_state(vaps)?));
}
if let Some(tas) = &ccr.tas {
fields.push(encode_explicit(4, &encode_trust_anchor_state(tas)?));
}
if let Some(rks) = &ccr.rks {
fields.push(encode_explicit(5, &encode_router_key_state(rks)?));
}
Ok(encode_sequence(&fields))
}
pub fn encode_manifest_state(state: &ManifestState) -> Result<Vec<u8>, CcrEncodeError> {
state.validate().map_err(CcrEncodeError::Validate)?;
let mis = encode_manifest_state_payload_der(&state.mis)?;
Ok(encode_sequence(&[
mis,
encode_generalized_time(state.most_recent_update)?,
encode_octet_string(&state.hash),
]))
}
pub fn encode_manifest_state_payload_der(
instances: &[ManifestInstance],
) -> Result<Vec<u8>, CcrEncodeError> {
Ok(encode_sequence(
&instances
.iter()
.map(encode_manifest_instance)
.collect::<Result<Vec<_>, _>>()?,
))
}
fn encode_manifest_instance(instance: &ManifestInstance) -> Result<Vec<u8>, CcrEncodeError> {
instance.validate().map_err(CcrEncodeError::Validate)?;
let mut fields = vec![
encode_octet_string(&instance.hash),
encode_integer_u64(instance.size),
encode_octet_string(&instance.aki),
encode_integer_bigunsigned(&instance.manifest_number),
encode_generalized_time(instance.this_update)?,
encode_sequence(&instance.locations),
];
if !instance.subordinates.is_empty() {
fields.push(encode_sequence(
&instance
.subordinates
.iter()
.map(|ski| encode_octet_string(ski))
.collect::<Vec<_>>(),
));
}
Ok(encode_sequence(&fields))
}
pub fn encode_roa_payload_state(state: &RoaPayloadState) -> Result<Vec<u8>, CcrEncodeError> {
state.validate().map_err(CcrEncodeError::Validate)?;
let rps = encode_roa_payload_state_payload_der(&state.rps)?;
Ok(encode_sequence(&[rps, encode_octet_string(&state.hash)]))
}
pub fn encode_roa_payload_state_payload_der(
sets: &[RoaPayloadSet],
) -> Result<Vec<u8>, CcrEncodeError> {
Ok(encode_sequence(
&sets
.iter()
.map(encode_roa_payload_set)
.collect::<Result<Vec<_>, _>>()?,
))
}
fn encode_roa_payload_set(set: &RoaPayloadSet) -> Result<Vec<u8>, CcrEncodeError> {
set.validate().map_err(CcrEncodeError::Validate)?;
Ok(encode_sequence(&[
encode_integer_u32(set.as_id),
encode_sequence(&set.ip_addr_blocks),
]))
}
pub fn encode_aspa_payload_state(state: &AspaPayloadState) -> Result<Vec<u8>, CcrEncodeError> {
state.validate().map_err(CcrEncodeError::Validate)?;
let aps = encode_aspa_payload_state_payload_der(&state.aps)?;
Ok(encode_sequence(&[aps, encode_octet_string(&state.hash)]))
}
pub fn encode_aspa_payload_state_payload_der(
sets: &[AspaPayloadSet],
) -> Result<Vec<u8>, CcrEncodeError> {
Ok(encode_sequence(
&sets
.iter()
.map(encode_aspa_payload_set)
.collect::<Result<Vec<_>, _>>()?,
))
}
fn encode_aspa_payload_set(set: &AspaPayloadSet) -> Result<Vec<u8>, CcrEncodeError> {
set.validate().map_err(CcrEncodeError::Validate)?;
Ok(encode_sequence(&[
encode_integer_u32(set.customer_as_id),
encode_sequence(
&set.providers
.iter()
.map(|provider| encode_integer_u32(*provider))
.collect::<Vec<_>>(),
),
]))
}
pub fn encode_trust_anchor_state(state: &TrustAnchorState) -> Result<Vec<u8>, CcrEncodeError> {
state.validate().map_err(CcrEncodeError::Validate)?;
let skis = encode_trust_anchor_state_payload_der(&state.skis)?;
Ok(encode_sequence(&[skis, encode_octet_string(&state.hash)]))
}
pub fn encode_trust_anchor_state_payload_der(skis: &[Vec<u8>]) -> Result<Vec<u8>, CcrEncodeError> {
Ok(encode_sequence(
&skis
.iter()
.map(|ski| encode_octet_string(ski))
.collect::<Vec<_>>(),
))
}
pub fn encode_router_key_state(state: &RouterKeyState) -> Result<Vec<u8>, CcrEncodeError> {
state.validate().map_err(CcrEncodeError::Validate)?;
let rksets = encode_router_key_state_payload_der(&state.rksets)?;
Ok(encode_sequence(&[rksets, encode_octet_string(&state.hash)]))
}
pub fn encode_router_key_state_payload_der(
sets: &[RouterKeySet],
) -> Result<Vec<u8>, CcrEncodeError> {
Ok(encode_sequence(
&sets
.iter()
.map(encode_router_key_set)
.collect::<Result<Vec<_>, _>>()?,
))
}
fn encode_router_key_set(set: &RouterKeySet) -> Result<Vec<u8>, CcrEncodeError> {
set.validate().map_err(CcrEncodeError::Validate)?;
Ok(encode_sequence(&[
encode_integer_u32(set.as_id),
encode_sequence(
&set.router_keys
.iter()
.map(encode_router_key)
.collect::<Result<Vec<_>, _>>()?,
),
]))
}
fn encode_router_key(key: &RouterKey) -> Result<Vec<u8>, CcrEncodeError> {
key.validate().map_err(CcrEncodeError::Validate)?;
Ok(encode_sequence(&[
encode_octet_string(&key.ski),
key.spki_der.clone(),
]))
}
fn encode_digest_algorithm(alg: &CcrDigestAlgorithm) -> Vec<u8> {
match alg {
CcrDigestAlgorithm::Sha256 => encode_sequence(&[encode_oid(OID_SHA256_RAW)]),
}
}
fn encode_generalized_time(t: time::OffsetDateTime) -> Result<Vec<u8>, CcrEncodeError> {
let t = t.to_offset(time::UtcOffset::UTC);
let s = format!(
"{:04}{:02}{:02}{:02}{:02}{:02}Z",
t.year(),
u8::from(t.month()),
t.day(),
t.hour(),
t.minute(),
t.second()
);
Ok(encode_tlv(0x18, s.into_bytes()))
}
fn encode_integer_u32(v: u32) -> Vec<u8> {
encode_integer_bytes(unsigned_integer_bytes(v as u64))
}
fn encode_integer_u64(v: u64) -> Vec<u8> {
encode_integer_bytes(unsigned_integer_bytes(v))
}
fn encode_integer_bigunsigned(v: &BigUnsigned) -> Vec<u8> {
encode_integer_bytes(v.bytes_be.clone())
}
fn encode_integer_bytes(mut bytes: Vec<u8>) -> Vec<u8> {
if bytes.is_empty() {
bytes.push(0);
}
if bytes[0] & 0x80 != 0 {
bytes.insert(0, 0);
}
encode_tlv(0x02, bytes)
}
fn unsigned_integer_bytes(v: u64) -> Vec<u8> {
if v == 0 {
return vec![0];
}
let mut out = Vec::new();
let mut n = v;
while n > 0 {
out.push((n & 0xFF) as u8);
n >>= 8;
}
out.reverse();
out
}
fn encode_oid(raw_body: &[u8]) -> Vec<u8> {
encode_tlv(0x06, raw_body.to_vec())
}
fn encode_octet_string(bytes: &[u8]) -> Vec<u8> {
encode_tlv(0x04, bytes.to_vec())
}
fn encode_explicit(tag_number: u8, inner_der: &[u8]) -> Vec<u8> {
encode_tlv(0xA0 + tag_number, inner_der.to_vec())
}
fn encode_sequence(elements: &[Vec<u8>]) -> Vec<u8> {
let total_len: usize = elements.iter().map(Vec::len).sum();
let mut buf = Vec::with_capacity(total_len);
for element in elements {
buf.extend_from_slice(element);
}
encode_tlv(0x30, buf)
}
fn encode_tlv(tag: u8, value: Vec<u8>) -> Vec<u8> {
let mut out = Vec::with_capacity(1 + 9 + value.len());
out.push(tag);
encode_length(value.len(), &mut out);
out.extend_from_slice(&value);
out
}
fn encode_length(len: usize, out: &mut Vec<u8>) {
if len < 0x80 {
out.push(len as u8);
return;
}
let mut bytes = Vec::new();
let mut value = len;
while value > 0 {
bytes.push((value & 0xFF) as u8);
value >>= 8;
}
bytes.reverse();
out.push(0x80 | (bytes.len() as u8));
out.extend_from_slice(&bytes);
}

272
src/ccr/export.rs Normal file
View File

@@ -0,0 +1,272 @@
use crate::ccr::build::{
CcrBuildError, build_aspa_payload_state, build_manifest_state_from_vcirs,
build_roa_payload_state, build_trust_anchor_state,
};
use crate::ccr::encode::{CcrEncodeError, encode_content_info};
use crate::ccr::model::{CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation};
use crate::data_model::ta::TrustAnchor;
use crate::storage::RocksStore;
use crate::validation::objects::{AspaAttestation, RouterKeyPayload, Vrp};
use std::path::Path;
#[derive(Debug, thiserror::Error)]
pub enum CcrExportError {
#[error("list VCIRs failed: {0}")]
ListVcirs(String),
#[error("build CCR state failed: {0}")]
Build(#[from] CcrBuildError),
#[error("encode CCR failed: {0}")]
Encode(#[from] CcrEncodeError),
#[error("write CCR file failed: {0}: {1}")]
Write(String, String),
}
pub fn build_ccr_from_run(
store: &RocksStore,
trust_anchors: &[TrustAnchor],
vrps: &[Vrp],
aspas: &[AspaAttestation],
router_keys: &[RouterKeyPayload],
produced_at: time::OffsetDateTime,
) -> Result<RpkiCanonicalCacheRepresentation, CcrExportError> {
let vcirs = store
.list_vcirs()
.map_err(|e| CcrExportError::ListVcirs(e.to_string()))?;
let mfts = Some(build_manifest_state_from_vcirs(store, &vcirs)?);
let vrps = Some(build_roa_payload_state(vrps)?);
let vaps = Some(build_aspa_payload_state(aspas)?);
let tas = Some(build_trust_anchor_state(trust_anchors)?);
let rks = Some(build_router_key_state_from_runtime(router_keys)?);
Ok(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at,
mfts,
vrps,
vaps,
tas,
rks,
})
}
fn build_router_key_state_from_runtime(
router_keys: &[RouterKeyPayload],
) -> Result<crate::ccr::model::RouterKeyState, CcrBuildError> {
use crate::ccr::model::{RouterKey, RouterKeySet};
use std::collections::{BTreeMap, BTreeSet};
let mut grouped: BTreeMap<u32, BTreeSet<RouterKey>> = BTreeMap::new();
for router_key in router_keys {
grouped
.entry(router_key.as_id)
.or_default()
.insert(RouterKey {
ski: router_key.ski.clone(),
spki_der: router_key.spki_der.clone(),
});
}
let rksets = grouped
.into_iter()
.map(|(as_id, router_keys)| RouterKeySet {
as_id,
router_keys: router_keys.into_iter().collect(),
})
.collect::<Vec<_>>();
build_router_key_state_from_sets(&rksets)
}
fn build_router_key_state_from_sets(
rksets: &[crate::ccr::model::RouterKeySet],
) -> Result<crate::ccr::model::RouterKeyState, CcrBuildError> {
let der = crate::ccr::encode::encode_router_key_state_payload_der(rksets)
.map_err(|e| CcrBuildError::RouterKeyEncode(e.to_string()))?;
Ok(crate::ccr::model::RouterKeyState {
rksets: rksets.to_vec(),
hash: crate::ccr::compute_state_hash(&der),
})
}
pub fn write_ccr_file(
path: &Path,
ccr: &RpkiCanonicalCacheRepresentation,
) -> Result<(), CcrExportError> {
let der = encode_content_info(&CcrContentInfo::new(ccr.clone()))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| CcrExportError::Write(path.display().to_string(), e.to_string()))?;
}
std::fs::write(path, der)
.map_err(|e| CcrExportError::Write(path.display().to_string(), e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ccr::decode::decode_content_info;
use crate::data_model::manifest::ManifestObject;
use crate::data_model::roa::{IpPrefix, RoaAfi};
use crate::data_model::ta::TrustAnchor;
use crate::data_model::tal::Tal;
use crate::storage::{
PackTime, RawByHashEntry, RocksStore, ValidatedCaInstanceResult, ValidatedManifestMeta,
VcirArtifactKind, VcirArtifactRole, VcirArtifactValidationStatus, VcirAuditSummary,
VcirChildEntry, VcirInstanceGate, VcirRelatedArtifact, VcirSummary,
};
use crate::validation::objects::{AspaAttestation, RouterKeyPayload, Vrp};
use sha2::Digest;
fn sample_trust_anchor() -> TrustAnchor {
let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let tal_bytes = std::fs::read(base.join("tests/fixtures/tal/apnic-rfc7730-https.tal"))
.expect("read tal");
let ta_der = std::fs::read(base.join("tests/fixtures/ta/apnic-ta.cer")).expect("read ta");
let tal = Tal::decode_bytes(&tal_bytes).expect("decode tal");
TrustAnchor::bind_der(tal, &ta_der, None).expect("bind ta")
}
fn sample_vcir_and_raw(store: &RocksStore) -> ValidatedCaInstanceResult {
let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let manifest_der = std::fs::read(base.join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft")).expect("read manifest");
let manifest = ManifestObject::decode_der(&manifest_der).expect("decode manifest");
let hash = hex::encode(sha2::Sha256::digest(&manifest_der));
let mut raw = RawByHashEntry::from_bytes(hash.clone(), manifest_der.clone());
raw.origin_uris
.push("rsync://example.test/repo/current.mft".to_string());
raw.object_type = Some("mft".to_string());
raw.encoding = Some("der".to_string());
store.put_raw_by_hash_entry(&raw).expect("put raw");
ValidatedCaInstanceResult {
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
parent_manifest_rsync_uri: None,
tal_id: "apnic".to_string(),
ca_subject_name: "CN=test".to_string(),
ca_ski: "11".repeat(20),
issuer_ski: "22".repeat(20),
last_successful_validation_time: PackTime::from_utc_offset_datetime(
manifest.manifest.this_update,
),
current_manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
current_crl_rsync_uri: "rsync://example.test/repo/current.crl".to_string(),
validated_manifest_meta: ValidatedManifestMeta {
validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(),
validated_manifest_this_update: PackTime::from_utc_offset_datetime(
manifest.manifest.this_update,
),
validated_manifest_next_update: PackTime::from_utc_offset_datetime(
manifest.manifest.next_update,
),
},
instance_gate: VcirInstanceGate {
manifest_next_update: PackTime::from_utc_offset_datetime(
manifest.manifest.next_update,
),
current_crl_next_update: PackTime::from_utc_offset_datetime(
manifest.manifest.next_update,
),
self_ca_not_after: PackTime::from_utc_offset_datetime(
manifest.manifest.next_update,
),
instance_effective_until: PackTime::from_utc_offset_datetime(
manifest.manifest.next_update,
),
},
child_entries: vec![VcirChildEntry {
child_manifest_rsync_uri: "rsync://example.test/repo/child.mft".to_string(),
child_cert_rsync_uri: "rsync://example.test/repo/child.cer".to_string(),
child_cert_hash: "aa".repeat(32),
child_ski: "33".repeat(20),
child_rsync_base_uri: "rsync://example.test/repo/".to_string(),
child_publication_point_rsync_uri: "rsync://example.test/repo/".to_string(),
child_rrdp_notification_uri: None,
child_effective_ip_resources: None,
child_effective_as_resources: None,
accepted_at_validation_time: PackTime::from_utc_offset_datetime(
manifest.manifest.this_update,
),
}],
local_outputs: Vec::new(),
related_artifacts: vec![VcirRelatedArtifact {
artifact_role: VcirArtifactRole::Manifest,
artifact_kind: VcirArtifactKind::Mft,
uri: Some("rsync://example.test/repo/current.mft".to_string()),
sha256: hash,
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
}],
summary: VcirSummary {
local_vrp_count: 0,
local_aspa_count: 0,
local_router_key_count: 0,
child_count: 1,
accepted_object_count: 1,
rejected_object_count: 0,
},
audit_summary: VcirAuditSummary {
failed_fetch_eligible: true,
last_failed_fetch_reason: None,
warning_count: 0,
audit_flags: Vec::new(),
},
}
}
#[test]
fn build_and_write_ccr_from_run_exports_der_content_info() {
let td = tempfile::tempdir().expect("tempdir");
let db_path = td.path().join("db");
let store = RocksStore::open(&db_path).expect("open rocksdb");
let vcir = sample_vcir_and_raw(&store);
store.put_vcir(&vcir).expect("put vcir");
let trust_anchor = sample_trust_anchor();
let vrps = vec![Vrp {
asn: 64496,
prefix: IpPrefix {
afi: RoaAfi::Ipv4,
prefix_len: 8,
addr: [10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
max_length: 8,
}];
let aspas = vec![AspaAttestation {
customer_as_id: 64496,
provider_as_ids: vec![64497],
}];
let router_keys = vec![RouterKeyPayload {
as_id: 64496,
ski: vec![0x11; 20],
spki_der: vec![0x30, 0x00],
source_object_uri: "rsync://example.test/repo/router.cer".to_string(),
source_object_hash: hex::encode([0x11; 32]),
source_ee_cert_hash: hex::encode([0x11; 32]),
item_effective_until: PackTime::from_utc_offset_datetime(
time::OffsetDateTime::now_utc() + time::Duration::hours(1),
),
}];
let ccr = build_ccr_from_run(
&store,
&[trust_anchor],
&vrps,
&aspas,
&router_keys,
time::OffsetDateTime::now_utc(),
)
.expect("build ccr");
assert!(ccr.mfts.is_some());
assert!(ccr.vrps.is_some());
assert!(ccr.vaps.is_some());
assert!(ccr.tas.is_some());
assert!(ccr.rks.is_some());
assert_eq!(ccr.rks.as_ref().unwrap().rksets.len(), 1);
let out = td.path().join("out/example.ccr");
write_ccr_file(&out, &ccr).expect("write ccr");
let der = std::fs::read(&out).expect("read ccr file");
let decoded = decode_content_info(&der).expect("decode ccr");
assert_eq!(decoded.content.version, 0);
assert!(decoded.content.mfts.is_some());
}
}

9
src/ccr/hash.rs Normal file
View File

@@ -0,0 +1,9 @@
use sha2::Digest;
pub fn compute_state_hash(payload_der: &[u8]) -> Vec<u8> {
sha2::Sha256::digest(payload_der).to_vec()
}
pub fn verify_state_hash(expected: &[u8], payload_der: &[u8]) -> bool {
compute_state_hash(payload_der).as_slice() == expected
}

34
src/ccr/mod.rs Normal file
View File

@@ -0,0 +1,34 @@
#[cfg(feature = "full")]
pub mod build;
pub mod decode;
pub mod dump;
pub mod encode;
#[cfg(feature = "full")]
pub mod export;
pub mod hash;
pub mod model;
#[cfg(feature = "full")]
pub mod verify;
#[cfg(feature = "full")]
pub use build::{
CcrBuildError, build_aspa_payload_state, build_manifest_state_from_vcirs,
build_roa_payload_state, build_trust_anchor_state,
};
pub use decode::{CcrDecodeError, decode_content_info};
pub use dump::{CcrDumpError, dump_content_info_json, dump_content_info_json_value};
pub use encode::{CcrEncodeError, encode_content_info};
#[cfg(feature = "full")]
pub use export::{CcrExportError, build_ccr_from_run, write_ccr_file};
pub use hash::{compute_state_hash, verify_state_hash};
pub use model::{
AspaPayloadSet, AspaPayloadState, CcrContentInfo, CcrDigestAlgorithm, ManifestInstance,
ManifestState, RoaPayloadSet, RoaPayloadState, RouterKey, RouterKeySet, RouterKeyState,
RpkiCanonicalCacheRepresentation, TrustAnchorState,
};
#[cfg(feature = "full")]
pub use verify::{
CcrVerifyError, CcrVerifySummary, extract_vrp_rows, verify_against_report_json_path,
verify_against_vcir_store, verify_against_vcir_store_path, verify_content_info,
verify_content_info_bytes,
};

398
src/ccr/model.rs Normal file
View File

@@ -0,0 +1,398 @@
use crate::data_model::common::{BigUnsigned, der_take_tlv};
use crate::data_model::oid::{OID_CT_RPKI_CCR, OID_SHA256};
pub const CCR_VERSION_V0: u32 = 0;
pub const DIGEST_LEN_SHA256: usize = 32;
pub const KEY_IDENTIFIER_LEN_SHA1: usize = 20;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CcrDigestAlgorithm {
Sha256,
}
impl CcrDigestAlgorithm {
pub fn oid(&self) -> &'static str {
match self {
Self::Sha256 => OID_SHA256,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CcrContentInfo {
pub content_type_oid: String,
pub content: RpkiCanonicalCacheRepresentation,
}
impl CcrContentInfo {
pub fn new(content: RpkiCanonicalCacheRepresentation) -> Self {
Self {
content_type_oid: OID_CT_RPKI_CCR.to_string(),
content,
}
}
pub fn validate(&self) -> Result<(), String> {
if self.content_type_oid != OID_CT_RPKI_CCR {
return Err(format!(
"contentType must be {OID_CT_RPKI_CCR}, got {}",
self.content_type_oid
));
}
self.content.validate()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RpkiCanonicalCacheRepresentation {
pub version: u32,
pub hash_alg: CcrDigestAlgorithm,
pub produced_at: time::OffsetDateTime,
pub mfts: Option<ManifestState>,
pub vrps: Option<RoaPayloadState>,
pub vaps: Option<AspaPayloadState>,
pub tas: Option<TrustAnchorState>,
pub rks: Option<RouterKeyState>,
}
impl RpkiCanonicalCacheRepresentation {
pub fn validate(&self) -> Result<(), String> {
if self.version != CCR_VERSION_V0 {
return Err(format!("CCR version must be 0, got {}", self.version));
}
if !matches!(self.hash_alg, CcrDigestAlgorithm::Sha256) {
return Err("CCR hashAlg must be SHA-256".into());
}
if self.mfts.is_none()
&& self.vrps.is_none()
&& self.vaps.is_none()
&& self.tas.is_none()
&& self.rks.is_none()
{
return Err("at least one of mfts/vrps/vaps/tas/rks must be present".into());
}
if let Some(mfts) = &self.mfts {
mfts.validate()?;
}
if let Some(vrps) = &self.vrps {
vrps.validate()?;
}
if let Some(vaps) = &self.vaps {
vaps.validate()?;
}
if let Some(tas) = &self.tas {
tas.validate()?;
}
if let Some(rks) = &self.rks {
rks.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestState {
pub mis: Vec<ManifestInstance>,
pub most_recent_update: time::OffsetDateTime,
pub hash: Vec<u8>,
}
impl ManifestState {
pub fn validate(&self) -> Result<(), String> {
validate_sha256_digest("ManifestState.hash", &self.hash)?;
validate_sorted_unique_by(
&self.mis,
|item| item.hash.as_slice(),
"ManifestState.mis must be sorted by hash and unique",
)?;
for instance in &self.mis {
instance.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestInstance {
pub hash: Vec<u8>,
pub size: u64,
pub aki: Vec<u8>,
pub manifest_number: BigUnsigned,
pub this_update: time::OffsetDateTime,
pub locations: Vec<Vec<u8>>,
pub subordinates: Vec<Vec<u8>>,
}
impl ManifestInstance {
pub fn validate(&self) -> Result<(), String> {
validate_sha256_digest("ManifestInstance.hash", &self.hash)?;
if self.size < 1000 {
return Err(format!(
"ManifestInstance.size must be >= 1000, got {}",
self.size
));
}
validate_key_identifier("ManifestInstance.aki", &self.aki)?;
validate_big_unsigned_bytes(
"ManifestInstance.manifest_number",
&self.manifest_number.bytes_be,
)?;
if self.locations.is_empty() {
return Err(
"ManifestInstance.locations must contain at least one AccessDescription".into(),
);
}
for location in &self.locations {
validate_full_der_with_tag("ManifestInstance.locations[]", location, Some(0x30))?;
}
if !self.subordinates.is_empty() {
validate_sorted_unique_bytes(
&self.subordinates,
KEY_IDENTIFIER_LEN_SHA1,
"ManifestInstance.subordinates must be sorted/unique 20-byte SKIs",
)?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaPayloadState {
pub rps: Vec<RoaPayloadSet>,
pub hash: Vec<u8>,
}
impl RoaPayloadState {
pub fn validate(&self) -> Result<(), String> {
validate_sha256_digest("ROAPayloadState.hash", &self.hash)?;
validate_sorted_unique_by(
&self.rps,
|item| &item.as_id,
"ROAPayloadState.rps must be sorted by asID and unique",
)?;
for set in &self.rps {
set.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaPayloadSet {
pub as_id: u32,
pub ip_addr_blocks: Vec<Vec<u8>>,
}
impl RoaPayloadSet {
pub fn validate(&self) -> Result<(), String> {
if self.ip_addr_blocks.is_empty() || self.ip_addr_blocks.len() > 2 {
return Err(format!(
"ROAPayloadSet.ip_addr_blocks must contain 1..=2 entries, got {}",
self.ip_addr_blocks.len()
));
}
for block in &self.ip_addr_blocks {
validate_full_der_with_tag("ROAPayloadSet.ip_addr_blocks[]", block, Some(0x30))?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaPayloadState {
pub aps: Vec<AspaPayloadSet>,
pub hash: Vec<u8>,
}
impl AspaPayloadState {
pub fn validate(&self) -> Result<(), String> {
validate_sha256_digest("ASPAPayloadState.hash", &self.hash)?;
validate_sorted_unique_by(
&self.aps,
|item| &item.customer_as_id,
"ASPAPayloadState.aps must be sorted by customerASID and unique",
)?;
for set in &self.aps {
set.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaPayloadSet {
pub customer_as_id: u32,
pub providers: Vec<u32>,
}
impl AspaPayloadSet {
pub fn validate(&self) -> Result<(), String> {
if self.providers.is_empty() {
return Err("ASPAPayloadSet.providers must be non-empty".into());
}
validate_sorted_unique_by(
&self.providers,
|provider| provider,
"ASPAPayloadSet.providers must be sorted ascending and unique",
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TrustAnchorState {
pub skis: Vec<Vec<u8>>,
pub hash: Vec<u8>,
}
impl TrustAnchorState {
pub fn validate(&self) -> Result<(), String> {
if self.skis.is_empty() {
return Err("TrustAnchorState.skis must be non-empty".into());
}
validate_sha256_digest("TrustAnchorState.hash", &self.hash)?;
validate_sorted_unique_bytes(
&self.skis,
KEY_IDENTIFIER_LEN_SHA1,
"TrustAnchorState.skis must be sorted/unique 20-byte SKIs",
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RouterKeyState {
pub rksets: Vec<RouterKeySet>,
pub hash: Vec<u8>,
}
impl RouterKeyState {
pub fn validate(&self) -> Result<(), String> {
validate_sha256_digest("RouterKeyState.hash", &self.hash)?;
validate_sorted_unique_by(
&self.rksets,
|item| &item.as_id,
"RouterKeyState.rksets must be sorted by asID and unique",
)?;
for rkset in &self.rksets {
rkset.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RouterKeySet {
pub as_id: u32,
pub router_keys: Vec<RouterKey>,
}
impl RouterKeySet {
pub fn validate(&self) -> Result<(), String> {
if self.router_keys.is_empty() {
return Err("RouterKeySet.router_keys must be non-empty".into());
}
validate_sorted_unique_by(
&self.router_keys,
|key| key,
"RouterKeySet.router_keys must be sorted by SKI and unique by (SKI, SPKI DER)",
)?;
for key in &self.router_keys {
key.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct RouterKey {
pub ski: Vec<u8>,
pub spki_der: Vec<u8>,
}
impl RouterKey {
pub fn validate(&self) -> Result<(), String> {
validate_key_identifier("RouterKey.ski", &self.ski)?;
validate_full_der_with_tag("RouterKey.spki_der", &self.spki_der, Some(0x30))
}
}
fn validate_sha256_digest(field: &str, bytes: &[u8]) -> Result<(), String> {
if bytes.len() != DIGEST_LEN_SHA256 {
return Err(format!(
"{field} must be {DIGEST_LEN_SHA256} bytes, got {}",
bytes.len()
));
}
Ok(())
}
fn validate_key_identifier(field: &str, bytes: &[u8]) -> Result<(), String> {
if bytes.len() != KEY_IDENTIFIER_LEN_SHA1 {
return Err(format!(
"{field} must be {KEY_IDENTIFIER_LEN_SHA1} bytes, got {}",
bytes.len()
));
}
Ok(())
}
fn validate_big_unsigned_bytes(field: &str, bytes: &[u8]) -> Result<(), String> {
if bytes.is_empty() {
return Err(format!("{field} must not be empty"));
}
if bytes.len() > 1 && bytes[0] == 0x00 {
return Err(format!(
"{field} must be minimally encoded as an unsigned integer"
));
}
Ok(())
}
fn validate_sorted_unique_by<T, K: Ord + ?Sized>(
values: &[T],
key_fn: impl Fn(&T) -> &K,
message: &str,
) -> Result<(), String> {
for window in values.windows(2) {
if key_fn(&window[0]) >= key_fn(&window[1]) {
return Err(message.to_string());
}
}
Ok(())
}
fn validate_sorted_unique_bytes(
values: &[Vec<u8>],
expected_len: usize,
message: &str,
) -> Result<(), String> {
for value in values {
if value.len() != expected_len {
return Err(message.to_string());
}
}
for window in values.windows(2) {
if window[0] >= window[1] {
return Err(message.to_string());
}
}
Ok(())
}
fn validate_full_der_with_tag(
field: &str,
der: &[u8],
expected_tag: Option<u8>,
) -> Result<(), String> {
let (tag, _value, rem) = der_take_tlv(der).map_err(|e| format!("{field}: {e}"))?;
if !rem.is_empty() {
return Err(format!("{field}: trailing bytes after DER object"));
}
if let Some(expected_tag) = expected_tag {
if tag != expected_tag {
return Err(format!(
"{field}: unexpected tag 0x{tag:02X}, expected 0x{expected_tag:02X}"
));
}
}
Ok(())
}

683
src/ccr/verify.rs Normal file
View File

@@ -0,0 +1,683 @@
use crate::ccr::decode::{CcrDecodeError, decode_content_info};
use crate::ccr::encode::{
encode_aspa_payload_state_payload_der, encode_manifest_state_payload_der,
encode_roa_payload_state_payload_der, encode_router_key_state_payload_der,
encode_trust_anchor_state_payload_der,
};
use crate::ccr::hash::verify_state_hash;
use crate::ccr::model::{CcrContentInfo, RouterKeyState, TrustAnchorState};
use crate::storage::{RocksStore, VcirArtifactRole};
use serde::Serialize;
use std::collections::BTreeSet;
use std::path::Path;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct CcrVerifySummary {
pub content_type_oid: String,
pub version: u32,
pub produced_at_rfc3339_utc: String,
pub state_hashes_ok: bool,
pub manifest_instances: usize,
pub roa_payload_sets: usize,
pub roa_vrp_count: usize,
pub aspa_payload_sets: usize,
pub trust_anchor_ski_count: usize,
pub router_key_sets: usize,
pub router_key_count: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum CcrVerifyError {
#[error("CCR decode failed: {0}")]
Decode(#[from] CcrDecodeError),
#[error("ManifestState hash mismatch")]
ManifestHashMismatch,
#[error("ROAPayloadState hash mismatch")]
RoaHashMismatch,
#[error("ASPAPayloadState hash mismatch")]
AspaHashMismatch,
#[error("TrustAnchorState hash mismatch")]
TrustAnchorHashMismatch,
#[error("RouterKeyState hash mismatch")]
RouterKeyHashMismatch,
#[error("read report json failed: {0}: {1}")]
ReportRead(String, String),
#[error("parse report json failed: {0}")]
ReportParse(String),
#[error("VRP set mismatch: only_in_ccr={only_in_ccr} only_in_report={only_in_report}")]
ReportVrpMismatch {
only_in_ccr: usize,
only_in_report: usize,
},
#[error("ASPA set mismatch: only_in_ccr={only_in_ccr} only_in_report={only_in_report}")]
ReportAspaMismatch {
only_in_ccr: usize,
only_in_report: usize,
},
#[error("open RocksDB failed: {0}")]
OpenStore(String),
#[error("list VCIRs failed: {0}")]
ListVcirs(String),
#[error("VCIR manifest set mismatch: only_in_ccr={only_in_ccr} only_in_vcir={only_in_vcir}")]
VcirManifestMismatch {
only_in_ccr: usize,
only_in_vcir: usize,
},
}
pub fn verify_content_info_bytes(der: &[u8]) -> Result<CcrVerifySummary, CcrVerifyError> {
let content_info = decode_content_info(der)?;
verify_content_info(&content_info)
}
pub fn verify_content_info(
content_info: &CcrContentInfo,
) -> Result<CcrVerifySummary, CcrVerifyError> {
content_info.validate().map_err(CcrDecodeError::Validate)?;
let state_hashes_ok = true;
let mut manifest_instances = 0usize;
let mut roa_payload_sets = 0usize;
let mut roa_vrp_count = 0usize;
let mut aspa_payload_sets = 0usize;
let mut trust_anchor_ski_count = 0usize;
let mut router_key_sets = 0usize;
let mut router_key_count = 0usize;
if let Some(mfts) = &content_info.content.mfts {
let payload_der = encode_manifest_state_payload_der(&mfts.mis)
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?;
if !verify_state_hash(&mfts.hash, &payload_der) {
return Err(CcrVerifyError::ManifestHashMismatch);
}
manifest_instances = mfts.mis.len();
}
if let Some(vrps) = &content_info.content.vrps {
let payload_der = encode_roa_payload_state_payload_der(&vrps.rps)
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?;
if !verify_state_hash(&vrps.hash, &payload_der) {
return Err(CcrVerifyError::RoaHashMismatch);
}
roa_payload_sets = vrps.rps.len();
roa_vrp_count = vrps
.rps
.iter()
.map(|set| count_roa_block_entries(&set.ip_addr_blocks))
.sum();
}
if let Some(vaps) = &content_info.content.vaps {
let payload_der = encode_aspa_payload_state_payload_der(&vaps.aps)
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?;
if !verify_state_hash(&vaps.hash, &payload_der) {
return Err(CcrVerifyError::AspaHashMismatch);
}
aspa_payload_sets = vaps.aps.len();
}
if let Some(tas) = &content_info.content.tas {
verify_trust_anchor_state_hash(tas)?;
trust_anchor_ski_count = tas.skis.len();
}
if let Some(rks) = &content_info.content.rks {
verify_router_key_state_hash(rks)?;
router_key_sets = rks.rksets.len();
router_key_count = rks.rksets.iter().map(|set| set.router_keys.len()).sum();
}
let produced_at_rfc3339_utc = content_info
.content
.produced_at
.to_offset(time::UtcOffset::UTC)
.format(&time::format_description::well_known::Rfc3339)
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?;
Ok(CcrVerifySummary {
content_type_oid: content_info.content_type_oid.clone(),
version: content_info.content.version,
produced_at_rfc3339_utc,
state_hashes_ok,
manifest_instances,
roa_payload_sets,
roa_vrp_count,
aspa_payload_sets,
trust_anchor_ski_count,
router_key_sets,
router_key_count,
})
}
pub fn verify_against_report_json_path(
content_info: &CcrContentInfo,
report_json_path: &Path,
) -> Result<(), CcrVerifyError> {
let bytes = std::fs::read(report_json_path).map_err(|e| {
CcrVerifyError::ReportRead(report_json_path.display().to_string(), e.to_string())
})?;
let json: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| CcrVerifyError::ReportParse(e.to_string()))?;
let report_vrps = report_vrp_keys(&json)?;
let ccr_vrps = extract_vrp_rows(content_info)?;
let only_in_ccr = ccr_vrps.difference(&report_vrps).count();
let only_in_report = report_vrps.difference(&ccr_vrps).count();
if only_in_ccr != 0 || only_in_report != 0 {
return Err(CcrVerifyError::ReportVrpMismatch {
only_in_ccr,
only_in_report,
});
}
let report_aspas = report_aspa_keys(&json)?;
let ccr_aspas = ccr_aspa_keys(content_info)?;
let only_in_ccr = ccr_aspas.difference(&report_aspas).count();
let only_in_report = report_aspas.difference(&ccr_aspas).count();
if only_in_ccr != 0 || only_in_report != 0 {
return Err(CcrVerifyError::ReportAspaMismatch {
only_in_ccr,
only_in_report,
});
}
Ok(())
}
pub fn verify_against_vcir_store_path(
content_info: &CcrContentInfo,
db_path: &Path,
) -> Result<(), CcrVerifyError> {
let store = RocksStore::open(db_path).map_err(|e| CcrVerifyError::OpenStore(e.to_string()))?;
verify_against_vcir_store(content_info, &store)
}
pub fn verify_against_vcir_store(
content_info: &CcrContentInfo,
store: &RocksStore,
) -> Result<(), CcrVerifyError> {
let Some(mfts) = &content_info.content.mfts else {
return Ok(());
};
let vcirs = store
.list_vcirs()
.map_err(|e| CcrVerifyError::ListVcirs(e.to_string()))?;
let mut vcir_hashes = BTreeSet::new();
for vcir in vcirs {
if let Some(artifact) = vcir.related_artifacts.iter().find(|artifact| {
artifact.artifact_role == VcirArtifactRole::Manifest
&& artifact.uri.as_deref() == Some(vcir.current_manifest_rsync_uri.as_str())
}) {
if let Ok(bytes) = hex::decode(&artifact.sha256) {
vcir_hashes.insert(bytes);
}
}
}
let ccr_hashes = mfts
.mis
.iter()
.map(|mi| mi.hash.clone())
.collect::<BTreeSet<_>>();
let only_in_ccr = ccr_hashes.difference(&vcir_hashes).count();
let only_in_vcir = vcir_hashes.difference(&ccr_hashes).count();
if only_in_ccr != 0 || only_in_vcir != 0 {
return Err(CcrVerifyError::VcirManifestMismatch {
only_in_ccr,
only_in_vcir,
});
}
Ok(())
}
fn verify_trust_anchor_state_hash(state: &TrustAnchorState) -> Result<(), CcrVerifyError> {
let payload_der = encode_trust_anchor_state_payload_der(&state.skis)
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?;
if !verify_state_hash(&state.hash, &payload_der) {
return Err(CcrVerifyError::TrustAnchorHashMismatch);
}
Ok(())
}
fn verify_router_key_state_hash(state: &RouterKeyState) -> Result<(), CcrVerifyError> {
let payload_der = encode_router_key_state_payload_der(&state.rksets)
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?;
if !verify_state_hash(&state.hash, &payload_der) {
return Err(CcrVerifyError::RouterKeyHashMismatch);
}
Ok(())
}
fn report_vrp_keys(
json: &serde_json::Value,
) -> Result<BTreeSet<(u32, String, u16)>, CcrVerifyError> {
let mut out = BTreeSet::new();
let Some(items) = json.get("vrps").and_then(|v| v.as_array()) else {
return Ok(out);
};
for item in items {
let asn = item
.get("asn")
.and_then(|v| v.as_u64())
.ok_or_else(|| CcrVerifyError::ReportParse("vrps[].asn missing".into()))?
as u32;
let prefix = item
.get("prefix")
.and_then(|v| v.as_str())
.ok_or_else(|| CcrVerifyError::ReportParse("vrps[].prefix missing".into()))?
.to_string();
let max_length = item
.get("max_length")
.and_then(|v| v.as_u64())
.ok_or_else(|| CcrVerifyError::ReportParse("vrps[].max_length missing".into()))?
as u16;
out.insert((asn, prefix, max_length));
}
Ok(out)
}
fn report_aspa_keys(json: &serde_json::Value) -> Result<BTreeSet<(u32, Vec<u32>)>, CcrVerifyError> {
let mut out = BTreeSet::new();
let Some(items) = json.get("aspas").and_then(|v| v.as_array()) else {
return Ok(out);
};
for item in items {
let customer = item
.get("customer_as_id")
.and_then(|v| v.as_u64())
.ok_or_else(|| CcrVerifyError::ReportParse("aspas[].customer_as_id missing".into()))?
as u32;
let mut providers = item
.get("provider_as_ids")
.and_then(|v| v.as_array())
.ok_or_else(|| CcrVerifyError::ReportParse("aspas[].provider_as_ids missing".into()))?
.iter()
.map(|v| {
v.as_u64()
.ok_or_else(|| CcrVerifyError::ReportParse("provider_as_ids[] invalid".into()))
.map(|v| v as u32)
})
.collect::<Result<Vec<_>, _>>()?;
providers.sort_unstable();
providers.dedup();
out.insert((customer, providers));
}
Ok(out)
}
pub fn extract_vrp_rows(
content_info: &CcrContentInfo,
) -> Result<BTreeSet<(u32, String, u16)>, CcrVerifyError> {
let mut out = BTreeSet::new();
let Some(vrps) = &content_info.content.vrps else {
return Ok(out);
};
for set in &vrps.rps {
for block in &set.ip_addr_blocks {
let (afi, entries) = decode_roa_family_block(block)?;
for (prefix_len, addr_bytes, max_len) in entries {
let prefix = format_prefix(afi, &addr_bytes, prefix_len)?;
out.insert((set.as_id, prefix, max_len.unwrap_or(prefix_len as u16)));
}
}
}
Ok(out)
}
fn ccr_aspa_keys(
content_info: &CcrContentInfo,
) -> Result<BTreeSet<(u32, Vec<u32>)>, CcrVerifyError> {
let mut out = BTreeSet::new();
let Some(vaps) = &content_info.content.vaps else {
return Ok(out);
};
for set in &vaps.aps {
out.insert((set.customer_as_id, set.providers.clone()));
}
Ok(out)
}
fn decode_roa_family_block(
block: &[u8],
) -> Result<(u16, Vec<(u8, Vec<u8>, Option<u16>)>), CcrVerifyError> {
let mut top = crate::data_model::common::DerReader::new(block);
let mut seq = top
.take_sequence()
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?;
if !top.is_empty() {
return Err(CcrVerifyError::Decode(CcrDecodeError::Parse(
"trailing bytes after ROAIPAddressFamily".into(),
)));
}
let afi_bytes = seq
.take_octet_string()
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?;
let afi = u16::from_be_bytes([afi_bytes[0], afi_bytes[1]]);
let mut addrs = seq
.take_sequence()
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?;
let mut entries = Vec::new();
while !addrs.is_empty() {
let mut addr_seq = addrs
.take_sequence()
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?;
let (unused_bits, content) = addr_seq
.take_bit_string()
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?;
let prefix_len = (content.len() * 8) as u8 - unused_bits;
let max_len = if addr_seq.is_empty() {
None
} else {
Some(
addr_seq
.take_uint_u64()
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?
as u16,
)
};
entries.push((prefix_len, content.to_vec(), max_len));
}
Ok((afi, entries))
}
fn format_prefix(afi: u16, addr_bytes: &[u8], prefix_len: u8) -> Result<String, CcrVerifyError> {
match afi {
1 => {
let mut full = [0u8; 4];
full[..addr_bytes.len()].copy_from_slice(addr_bytes);
Ok(format!("{}/{prefix_len}", std::net::Ipv4Addr::from(full)))
}
2 => {
let mut full = [0u8; 16];
full[..addr_bytes.len()].copy_from_slice(addr_bytes);
Ok(format!("{}/{prefix_len}", std::net::Ipv6Addr::from(full)))
}
other => Err(CcrVerifyError::Decode(CcrDecodeError::Parse(format!(
"unsupported AFI {other}"
)))),
}
}
fn count_roa_block_entries(blocks: &[Vec<u8>]) -> usize {
blocks
.iter()
.map(|block| {
decode_roa_family_block(block)
.map(|(_, entries)| entries.len())
.unwrap_or(0)
})
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ccr::build::{build_aspa_payload_state, build_roa_payload_state};
use crate::ccr::encode::{
encode_manifest_state_payload_der, encode_roa_payload_state_payload_der,
encode_router_key_state_payload_der, encode_trust_anchor_state_payload_der,
};
use crate::ccr::model::{
CcrDigestAlgorithm, ManifestInstance, ManifestState, RouterKey, RouterKeySet,
RpkiCanonicalCacheRepresentation,
};
use crate::data_model::common::BigUnsigned;
use crate::data_model::roa::{IpPrefix, RoaAfi};
use crate::storage::{
PackTime, ValidatedCaInstanceResult, ValidatedManifestMeta, VcirArtifactKind,
VcirArtifactRole, VcirArtifactValidationStatus, VcirAuditSummary, VcirChildEntry,
VcirInstanceGate, VcirRelatedArtifact, VcirSummary,
};
use crate::validation::objects::{AspaAttestation, Vrp};
fn sample_time() -> time::OffsetDateTime {
time::OffsetDateTime::parse(
"2026-03-24T00:00:00Z",
&time::format_description::well_known::Rfc3339,
)
.expect("time")
}
fn sample_content_info() -> CcrContentInfo {
let mis = vec![ManifestInstance {
hash: vec![0x10; 32],
size: 2048,
aki: vec![0x20; 20],
manifest_number: BigUnsigned { bytes_be: vec![1] },
this_update: sample_time(),
locations: vec![vec![0x30, 0x00]],
subordinates: vec![vec![0x30; 20]],
}];
let mfts = ManifestState {
most_recent_update: sample_time(),
hash: crate::ccr::compute_state_hash(&encode_manifest_state_payload_der(&mis).unwrap()),
mis,
};
let vrps = build_roa_payload_state(&[Vrp {
asn: 64496,
prefix: IpPrefix {
afi: RoaAfi::Ipv4,
prefix_len: 0,
addr: [0; 16],
},
max_length: 0,
}])
.expect("build roa state");
let vaps = build_aspa_payload_state(&[AspaAttestation {
customer_as_id: 64496,
provider_as_ids: vec![64497],
}])
.expect("build aspa state");
let skis = vec![vec![0x11; 20]];
let tas = TrustAnchorState {
hash: crate::ccr::compute_state_hash(
&encode_trust_anchor_state_payload_der(&skis).unwrap(),
),
skis,
};
let rksets = vec![RouterKeySet {
as_id: 64496,
router_keys: vec![RouterKey {
ski: vec![0x22; 20],
spki_der: vec![0x30, 0x00],
}],
}];
let rks = RouterKeyState {
hash: crate::ccr::compute_state_hash(
&encode_router_key_state_payload_der(&rksets).unwrap(),
),
rksets,
};
CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: sample_time(),
mfts: Some(mfts),
vrps: Some(vrps),
vaps: Some(vaps),
tas: Some(tas),
rks: Some(rks),
})
}
#[test]
fn verify_detects_each_state_hash_mismatch() {
let mut ci = sample_content_info();
ci.content.vrps.as_mut().unwrap().hash[0] ^= 0x01;
assert!(matches!(
verify_content_info(&ci),
Err(CcrVerifyError::RoaHashMismatch)
));
let mut ci = sample_content_info();
ci.content.vaps.as_mut().unwrap().hash[0] ^= 0x01;
assert!(matches!(
verify_content_info(&ci),
Err(CcrVerifyError::AspaHashMismatch)
));
let mut ci = sample_content_info();
ci.content.tas.as_mut().unwrap().hash[0] ^= 0x01;
assert!(matches!(
verify_content_info(&ci),
Err(CcrVerifyError::TrustAnchorHashMismatch)
));
let mut ci = sample_content_info();
ci.content.rks.as_mut().unwrap().hash[0] ^= 0x01;
assert!(matches!(
verify_content_info(&ci),
Err(CcrVerifyError::RouterKeyHashMismatch)
));
}
#[test]
fn verify_against_report_json_accepts_matching_report_and_rejects_parse_errors() {
let td = tempfile::tempdir().expect("tempdir");
let report = serde_json::json!({
"vrps": [{"asn": 64496, "prefix": "0.0.0.0/0", "max_length": 0}],
"aspas": [{"customer_as_id": 64496, "provider_as_ids": [64497]}]
});
let report_path = td.path().join("report.json");
std::fs::write(&report_path, serde_json::to_vec(&report).unwrap()).unwrap();
verify_against_report_json_path(&sample_content_info(), &report_path)
.expect("matching report");
let bad_path = td.path().join("bad.json");
std::fs::write(&bad_path, b"not-json").unwrap();
assert!(matches!(
verify_against_report_json_path(&sample_content_info(), &bad_path),
Err(CcrVerifyError::ReportParse(_))
));
}
#[test]
fn verify_against_report_json_rejects_missing_fields_and_aspa_mismatch() {
let td = tempfile::tempdir().expect("tempdir");
let missing =
serde_json::json!({"vrps":[{"prefix":"0.0.0.0/0","max_length":0}],"aspas":[]});
let missing_path = td.path().join("missing.json");
std::fs::write(&missing_path, serde_json::to_vec(&missing).unwrap()).unwrap();
assert!(matches!(
verify_against_report_json_path(&sample_content_info(), &missing_path),
Err(CcrVerifyError::ReportParse(_))
));
let mismatch = serde_json::json!({
"vrps": [{"asn": 64496, "prefix": "0.0.0.0/0", "max_length": 0}],
"aspas": [{"customer_as_id": 64496, "provider_as_ids": [65000]}]
});
let mismatch_path = td.path().join("mismatch.json");
std::fs::write(&mismatch_path, serde_json::to_vec(&mismatch).unwrap()).unwrap();
assert!(matches!(
verify_against_report_json_path(&sample_content_info(), &mismatch_path),
Err(CcrVerifyError::ReportAspaMismatch { .. })
));
}
#[test]
fn verify_against_vcir_store_rejects_mismatched_manifest_hashes() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open db");
let vcir = ValidatedCaInstanceResult {
manifest_rsync_uri: "rsync://example.test/current.mft".to_string(),
parent_manifest_rsync_uri: None,
tal_id: "apnic".to_string(),
ca_subject_name: "CN=test".to_string(),
ca_ski: "11".repeat(20),
issuer_ski: "22".repeat(20),
last_successful_validation_time: PackTime::from_utc_offset_datetime(sample_time()),
current_manifest_rsync_uri: "rsync://example.test/current.mft".to_string(),
current_crl_rsync_uri: "rsync://example.test/current.crl".to_string(),
validated_manifest_meta: ValidatedManifestMeta {
validated_manifest_number: vec![1],
validated_manifest_this_update: PackTime::from_utc_offset_datetime(sample_time()),
validated_manifest_next_update: PackTime::from_utc_offset_datetime(sample_time()),
},
instance_gate: VcirInstanceGate {
manifest_next_update: PackTime::from_utc_offset_datetime(sample_time()),
current_crl_next_update: PackTime::from_utc_offset_datetime(sample_time()),
self_ca_not_after: PackTime::from_utc_offset_datetime(sample_time()),
instance_effective_until: PackTime::from_utc_offset_datetime(sample_time()),
},
child_entries: vec![VcirChildEntry {
child_manifest_rsync_uri: "rsync://example.test/child.mft".to_string(),
child_cert_rsync_uri: "rsync://example.test/child.cer".to_string(),
child_cert_hash: "aa".repeat(32),
child_ski: "33".repeat(20),
child_rsync_base_uri: "rsync://example.test/".to_string(),
child_publication_point_rsync_uri: "rsync://example.test/".to_string(),
child_rrdp_notification_uri: None,
child_effective_ip_resources: None,
child_effective_as_resources: None,
accepted_at_validation_time: PackTime::from_utc_offset_datetime(sample_time()),
}],
local_outputs: Vec::new(),
related_artifacts: vec![VcirRelatedArtifact {
artifact_role: VcirArtifactRole::Manifest,
artifact_kind: VcirArtifactKind::Mft,
uri: Some("rsync://example.test/current.mft".to_string()),
sha256: "ff".repeat(32),
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
}],
summary: VcirSummary {
local_vrp_count: 0,
local_aspa_count: 0,
local_router_key_count: 0,
child_count: 1,
accepted_object_count: 1,
rejected_object_count: 0,
},
audit_summary: VcirAuditSummary {
failed_fetch_eligible: true,
last_failed_fetch_reason: None,
warning_count: 0,
audit_flags: Vec::new(),
},
};
store.put_vcir(&vcir).unwrap();
assert!(matches!(
verify_against_vcir_store(&sample_content_info(), &store),
Err(CcrVerifyError::VcirManifestMismatch { .. })
));
}
#[test]
fn verify_vrp_helpers_reject_bad_afi_and_count_invalid_block_as_zero() {
let block = vec![0x30, 0x08, 0x04, 0x02, 0x00, 0x63, 0x30, 0x02, 0x03, 0x00];
let ci = CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: sample_time(),
mfts: None,
vrps: Some(crate::ccr::model::RoaPayloadState {
rps: vec![crate::ccr::model::RoaPayloadSet {
as_id: 64496,
ip_addr_blocks: vec![block.clone()],
}],
hash: crate::ccr::compute_state_hash(
&encode_roa_payload_state_payload_der(&[crate::ccr::model::RoaPayloadSet {
as_id: 64496,
ip_addr_blocks: vec![block],
}])
.unwrap(),
),
}),
vaps: None,
tas: None,
rks: None,
});
assert!(matches!(
extract_vrp_rows(&ci),
Err(CcrVerifyError::Decode(_))
));
let bad_count = count_roa_block_entries(&[vec![0x04, 0x00]]);
assert_eq!(bad_count, 0);
}
}

170
src/cir/decode.rs Normal file
View File

@@ -0,0 +1,170 @@
use crate::cir::model::{
CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal,
};
use crate::data_model::common::DerReader;
use crate::data_model::oid::{OID_SHA256, OID_SHA256_RAW};
use der_parser::der::parse_der_oid;
#[derive(Debug, thiserror::Error)]
pub enum CirDecodeError {
#[error("DER parse error: {0}")]
Parse(String),
#[error("unexpected CIR version: expected {expected}, got {actual}")]
UnexpectedVersion { expected: u32, actual: u32 },
#[error("unexpected digest algorithm OID: expected {expected}, got {actual}")]
UnexpectedDigestAlgorithm {
expected: &'static str,
actual: String,
},
#[error("CIR model validation failed after decode: {0}")]
Validate(String),
}
pub fn decode_cir(der: &[u8]) -> Result<CanonicalInputRepresentation, CirDecodeError> {
let mut top = DerReader::new(der);
let mut seq = top.take_sequence().map_err(CirDecodeError::Parse)?;
if !top.is_empty() {
return Err(CirDecodeError::Parse("trailing bytes after CIR".into()));
}
let version = seq.take_uint_u64().map_err(CirDecodeError::Parse)? as u32;
if version != CIR_VERSION_V1 {
return Err(CirDecodeError::UnexpectedVersion {
expected: CIR_VERSION_V1,
actual: version,
});
}
let hash_alg = decode_hash_alg(seq.take_tag(0x06).map_err(CirDecodeError::Parse)?)?;
let validation_time =
parse_generalized_time(seq.take_tag(0x18).map_err(CirDecodeError::Parse)?)?;
let objects_der = seq.take_tag(0x30).map_err(CirDecodeError::Parse)?;
let mut objects_reader = DerReader::new(objects_der);
let mut objects = Vec::new();
while !objects_reader.is_empty() {
let (_tag, full, _value) = objects_reader
.take_any_full()
.map_err(CirDecodeError::Parse)?;
objects.push(decode_object(full)?);
}
let tals_der = seq.take_tag(0x30).map_err(CirDecodeError::Parse)?;
let mut tals_reader = DerReader::new(tals_der);
let mut tals = Vec::new();
while !tals_reader.is_empty() {
let (_tag, full, _value) = tals_reader.take_any_full().map_err(CirDecodeError::Parse)?;
tals.push(decode_tal(full)?);
}
if !seq.is_empty() {
return Err(CirDecodeError::Parse("trailing fields in CIR".into()));
}
let cir = CanonicalInputRepresentation {
version,
hash_alg,
validation_time,
objects,
tals,
};
cir.validate().map_err(CirDecodeError::Validate)?;
Ok(cir)
}
fn decode_hash_alg(raw_body: &[u8]) -> Result<CirHashAlgorithm, CirDecodeError> {
if raw_body != OID_SHA256_RAW {
return Err(CirDecodeError::UnexpectedDigestAlgorithm {
expected: OID_SHA256,
actual: oid_string(raw_body)?,
});
}
Ok(CirHashAlgorithm::Sha256)
}
fn decode_object(der: &[u8]) -> Result<CirObject, CirDecodeError> {
let mut top = DerReader::new(der);
let mut seq = top.take_sequence().map_err(CirDecodeError::Parse)?;
if !top.is_empty() {
return Err(CirDecodeError::Parse(
"trailing bytes after CirObject".into(),
));
}
let rsync_uri = std::str::from_utf8(seq.take_tag(0x16).map_err(CirDecodeError::Parse)?)
.map_err(|e| CirDecodeError::Parse(e.to_string()))?
.to_string();
let sha256 = seq
.take_octet_string()
.map_err(CirDecodeError::Parse)?
.to_vec();
if !seq.is_empty() {
return Err(CirDecodeError::Parse("trailing fields in CirObject".into()));
}
Ok(CirObject { rsync_uri, sha256 })
}
fn decode_tal(der: &[u8]) -> Result<CirTal, CirDecodeError> {
let mut top = DerReader::new(der);
let mut seq = top.take_sequence().map_err(CirDecodeError::Parse)?;
if !top.is_empty() {
return Err(CirDecodeError::Parse("trailing bytes after CirTal".into()));
}
let tal_uri = std::str::from_utf8(seq.take_tag(0x16).map_err(CirDecodeError::Parse)?)
.map_err(|e| CirDecodeError::Parse(e.to_string()))?
.to_string();
let tal_bytes = seq
.take_octet_string()
.map_err(CirDecodeError::Parse)?
.to_vec();
if !seq.is_empty() {
return Err(CirDecodeError::Parse("trailing fields in CirTal".into()));
}
Ok(CirTal { tal_uri, tal_bytes })
}
fn oid_string(raw_body: &[u8]) -> Result<String, CirDecodeError> {
let der = {
let mut out = Vec::with_capacity(raw_body.len() + 2);
out.push(0x06);
if raw_body.len() < 0x80 {
out.push(raw_body.len() as u8);
} else {
return Err(CirDecodeError::Parse("OID too long".into()));
}
out.extend_from_slice(raw_body);
out
};
let (_rem, oid) = parse_der_oid(&der).map_err(|e| CirDecodeError::Parse(e.to_string()))?;
let oid = oid
.as_oid_val()
.map_err(|e| CirDecodeError::Parse(e.to_string()))?;
Ok(oid.to_string())
}
fn parse_generalized_time(bytes: &[u8]) -> Result<time::OffsetDateTime, CirDecodeError> {
let s = std::str::from_utf8(bytes).map_err(|e| CirDecodeError::Parse(e.to_string()))?;
if s.len() != 15 || !s.ends_with('Z') {
return Err(CirDecodeError::Parse(
"GeneralizedTime must be YYYYMMDDHHMMSSZ".into(),
));
}
let parse = |range: std::ops::Range<usize>| -> Result<u32, CirDecodeError> {
s[range]
.parse::<u32>()
.map_err(|e| CirDecodeError::Parse(e.to_string()))
};
let year = parse(0..4)? as i32;
let month = parse(4..6)? as u8;
let day = parse(6..8)? as u8;
let hour = parse(8..10)? as u8;
let minute = parse(10..12)? as u8;
let second = parse(12..14)? as u8;
let month = time::Month::try_from(month).map_err(|e| CirDecodeError::Parse(e.to_string()))?;
let date = time::Date::from_calendar_date(year, month, day)
.map_err(|e| CirDecodeError::Parse(e.to_string()))?;
let timev = time::Time::from_hms(hour, minute, second)
.map_err(|e| CirDecodeError::Parse(e.to_string()))?;
Ok(time::PrimitiveDateTime::new(date, timev).assume_utc())
}

147
src/cir/encode.rs Normal file
View File

@@ -0,0 +1,147 @@
use crate::cir::model::{
CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal,
};
use crate::data_model::oid::OID_SHA256_RAW;
#[derive(Debug, thiserror::Error)]
pub enum CirEncodeError {
#[error("CIR model validation failed: {0}")]
Validate(String),
}
pub fn encode_cir(cir: &CanonicalInputRepresentation) -> Result<Vec<u8>, CirEncodeError> {
cir.validate().map_err(CirEncodeError::Validate)?;
Ok(encode_sequence(&[
encode_integer_u32(cir.version),
encode_oid(match cir.hash_alg {
CirHashAlgorithm::Sha256 => OID_SHA256_RAW,
}),
encode_generalized_time(cir.validation_time),
encode_sequence(
&cir.objects
.iter()
.map(encode_object)
.collect::<Result<Vec<_>, _>>()?,
),
encode_sequence(
&cir.tals
.iter()
.map(encode_tal)
.collect::<Result<Vec<_>, _>>()?,
),
]))
}
fn encode_object(object: &CirObject) -> Result<Vec<u8>, CirEncodeError> {
object.validate().map_err(CirEncodeError::Validate)?;
Ok(encode_sequence(&[
encode_ia5_string(object.rsync_uri.as_bytes()),
encode_octet_string(&object.sha256),
]))
}
fn encode_tal(tal: &CirTal) -> Result<Vec<u8>, CirEncodeError> {
tal.validate().map_err(CirEncodeError::Validate)?;
Ok(encode_sequence(&[
encode_ia5_string(tal.tal_uri.as_bytes()),
encode_octet_string(&tal.tal_bytes),
]))
}
fn encode_generalized_time(t: time::OffsetDateTime) -> Vec<u8> {
let t = t.to_offset(time::UtcOffset::UTC);
let s = format!(
"{:04}{:02}{:02}{:02}{:02}{:02}Z",
t.year(),
u8::from(t.month()),
t.day(),
t.hour(),
t.minute(),
t.second()
);
encode_tlv(0x18, s.into_bytes())
}
fn encode_integer_u32(v: u32) -> Vec<u8> {
encode_integer_bytes(unsigned_integer_bytes(v as u64))
}
fn encode_integer_bytes(mut bytes: Vec<u8>) -> Vec<u8> {
if bytes.is_empty() {
bytes.push(0);
}
if bytes[0] & 0x80 != 0 {
bytes.insert(0, 0);
}
encode_tlv(0x02, bytes)
}
fn unsigned_integer_bytes(v: u64) -> Vec<u8> {
if v == 0 {
return vec![0];
}
let mut out = Vec::new();
let mut n = v;
while n > 0 {
out.push((n & 0xFF) as u8);
n >>= 8;
}
out.reverse();
out
}
fn encode_oid(raw_body: &[u8]) -> Vec<u8> {
encode_tlv(0x06, raw_body.to_vec())
}
fn encode_ia5_string(bytes: &[u8]) -> Vec<u8> {
encode_tlv(0x16, bytes.to_vec())
}
fn encode_octet_string(bytes: &[u8]) -> Vec<u8> {
encode_tlv(0x04, bytes.to_vec())
}
fn encode_sequence(elements: &[Vec<u8>]) -> Vec<u8> {
let mut body = Vec::new();
for element in elements {
body.extend_from_slice(element);
}
encode_tlv(0x30, body)
}
fn encode_tlv(tag: u8, value: Vec<u8>) -> Vec<u8> {
let mut out = Vec::with_capacity(1 + encoded_len_len(value.len()) + value.len());
out.push(tag);
encode_len_into(value.len(), &mut out);
out.extend_from_slice(&value);
out
}
fn encoded_len_len(len: usize) -> usize {
if len < 0x80 {
1
} else {
1 + len.to_be_bytes().iter().skip_while(|&&b| b == 0).count()
}
}
fn encode_len_into(len: usize, out: &mut Vec<u8>) {
if len < 0x80 {
out.push(len as u8);
return;
}
let bytes = len.to_be_bytes();
let first_non_zero = bytes
.iter()
.position(|&b| b != 0)
.unwrap_or(bytes.len() - 1);
let len_bytes = &bytes[first_non_zero..];
out.push(0x80 | (len_bytes.len() as u8));
out.extend_from_slice(len_bytes);
}
#[allow(dead_code)]
const _: () = {
let _ = CIR_VERSION_V1;
};

544
src/cir/export.rs Normal file
View File

@@ -0,0 +1,544 @@
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::Path;
use crate::audit::{AuditObjectResult, PublicationPointAudit};
use crate::blob_store::RawObjectStore;
use crate::cir::encode::{CirEncodeError, encode_cir};
use crate::cir::model::{
CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal,
};
use crate::cir::static_pool::{
CirStaticPoolError, CirStaticPoolExportSummary, export_hashes_from_store,
write_bytes_to_static_pool,
};
use crate::data_model::ta::TrustAnchor;
use crate::storage::{RepositoryViewState, RocksStore};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CirExportTiming {
pub build_cir_ms: u64,
pub static_pool_ms: u64,
pub write_cir_ms: u64,
pub total_ms: u64,
}
#[derive(Debug, thiserror::Error)]
pub enum CirExportError {
#[error("list repository_view entries failed: {0}")]
ListRepositoryView(String),
#[error("CIR TAL URI must be http(s), got: {0}")]
InvalidTalUri(String),
#[error("TAL does not contain any rsync TA URI; CIR replay scheme A requires one")]
MissingTaRsyncUri,
#[error("CIR model validation failed: {0}")]
Validate(String),
#[error("encode CIR failed: {0}")]
Encode(#[from] CirEncodeError),
#[error("static pool export failed: {0}")]
StaticPool(#[from] CirStaticPoolError),
#[error("write CIR file failed: {0}: {1}")]
Write(String, String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CirObjectPoolExportSummary {
Static(CirStaticPoolExportSummary),
RawStore(CirRawStoreExportSummary),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CirRawStoreExportSummary {
pub unique_hashes: usize,
pub written_entries: usize,
pub reused_entries: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CirExportSummary {
pub object_count: usize,
pub tal_count: usize,
pub object_pool: CirObjectPoolExportSummary,
pub timing: CirExportTiming,
}
pub fn build_cir_from_run(
store: &RocksStore,
trust_anchor: &TrustAnchor,
tal_uri: &str,
validation_time: time::OffsetDateTime,
publication_points: &[PublicationPointAudit],
) -> Result<CanonicalInputRepresentation, CirExportError> {
if !(tal_uri.starts_with("https://") || tal_uri.starts_with("http://")) {
return Err(CirExportError::InvalidTalUri(tal_uri.to_string()));
}
let entries = store
.list_repository_view_entries_with_prefix("rsync://")
.map_err(|e| CirExportError::ListRepositoryView(e.to_string()))?;
let mut objects: BTreeMap<String, String> = BTreeMap::new();
for entry in entries {
if matches!(
entry.state,
RepositoryViewState::Present | RepositoryViewState::Replaced
) && let Some(hash) = entry.current_hash
{
objects.insert(entry.rsync_uri, hash.to_ascii_lowercase());
}
}
// CIR must describe the actual input world used by validation. When a
// publication point falls back to the latest validated current instance,
// repository_view may not contain the reused manifest/object set. Pull
// those object hashes from the audit so replay can reconstruct the same
// world state.
for pp in publication_points {
if pp.source != "vcir_current_instance" {
continue;
}
for obj in &pp.objects {
if obj.result != AuditObjectResult::Ok {
continue;
}
if !obj.rsync_uri.starts_with("rsync://") {
continue;
}
objects.insert(obj.rsync_uri.clone(), obj.sha256_hex.to_ascii_lowercase());
}
}
let ta_hash = ta_sha256_hex(&trust_anchor.ta_certificate.raw_der);
let mut saw_rsync_uri = false;
for uri in &trust_anchor.tal.ta_uris {
if uri.scheme() == "rsync" {
saw_rsync_uri = true;
objects.insert(uri.as_str().to_string(), ta_hash.clone());
}
}
if !saw_rsync_uri {
return Err(CirExportError::MissingTaRsyncUri);
}
let cir = CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: validation_time.to_offset(time::UtcOffset::UTC),
objects: objects
.into_iter()
.map(|(rsync_uri, sha256_hex)| CirObject {
rsync_uri,
sha256: hex::decode(sha256_hex).expect("validated hex"),
})
.collect(),
tals: vec![CirTal {
tal_uri: tal_uri.to_string(),
tal_bytes: trust_anchor.tal.raw.clone(),
}],
};
cir.validate().map_err(CirExportError::Validate)?;
Ok(cir)
}
pub fn write_cir_file(
path: &Path,
cir: &CanonicalInputRepresentation,
) -> Result<(), CirExportError> {
let der = encode_cir(cir)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| CirExportError::Write(path.display().to_string(), e.to_string()))?;
}
std::fs::write(path, der)
.map_err(|e| CirExportError::Write(path.display().to_string(), e.to_string()))
}
pub fn export_cir_static_pool(
store: &RocksStore,
static_root: &Path,
capture_date_utc: time::Date,
cir: &CanonicalInputRepresentation,
trust_anchor: &TrustAnchor,
) -> Result<CirStaticPoolExportSummary, CirExportError> {
let ta_hash = ta_sha256_hex(&trust_anchor.ta_certificate.raw_der);
let hashes = cir
.objects
.iter()
.map(|item| hex::encode(&item.sha256))
.filter(|hash| hash != &ta_hash)
.collect::<Vec<_>>();
let mut summary = export_hashes_from_store(store, static_root, capture_date_utc, &hashes)?;
let ta_result = write_bytes_to_static_pool(
static_root,
capture_date_utc,
&ta_hash,
&trust_anchor.ta_certificate.raw_der,
)?;
let mut unique = hashes.iter().cloned().collect::<BTreeSet<_>>();
unique.insert(ta_hash.clone());
summary.unique_hashes = unique.len();
if ta_result.written {
summary.written_files += 1;
} else {
summary.reused_files += 1;
}
Ok(summary)
}
pub fn export_cir_raw_store(
store: &RocksStore,
raw_store_path: &Path,
cir: &CanonicalInputRepresentation,
trust_anchor: &TrustAnchor,
) -> Result<CirRawStoreExportSummary, CirExportError> {
let ta_hash = ta_sha256_hex(&trust_anchor.ta_certificate.raw_der);
let unique: BTreeSet<String> = cir
.objects
.iter()
.map(|item| hex::encode(&item.sha256))
.collect();
let mut written_entries = 0usize;
let mut reused_entries = 0usize;
for sha256_hex in &unique {
if store
.get_raw_entry(sha256_hex)
.map_err(|e| CirExportError::Write(raw_store_path.display().to_string(), e.to_string()))?
.is_some()
{
reused_entries += 1;
continue;
}
if sha256_hex == &ta_hash {
let mut entry =
crate::storage::RawByHashEntry::from_bytes(ta_hash.clone(), trust_anchor.ta_certificate.raw_der.clone());
entry.object_type = Some("cer".to_string());
for object in &cir.objects {
if hex::encode(&object.sha256) == ta_hash {
entry.origin_uris.push(object.rsync_uri.clone());
}
}
store
.put_raw_by_hash_entry(&entry)
.map_err(|e| CirExportError::Write(raw_store_path.display().to_string(), e.to_string()))?;
written_entries += 1;
continue;
}
return Err(CirExportError::Write(
raw_store_path.display().to_string(),
format!("raw store missing object for sha256={sha256_hex}"),
));
}
Ok(CirRawStoreExportSummary {
unique_hashes: unique.len(),
written_entries,
reused_entries,
})
}
pub fn export_cir_from_run(
store: &RocksStore,
trust_anchor: &TrustAnchor,
tal_uri: &str,
validation_time: time::OffsetDateTime,
publication_points: &[PublicationPointAudit],
cir_out: &Path,
static_root: Option<&Path>,
raw_store_path: Option<&Path>,
capture_date_utc: time::Date,
) -> Result<CirExportSummary, CirExportError> {
let backend_count = static_root.is_some() as u8 + raw_store_path.is_some() as u8;
match backend_count {
1 => {}
_ => {
return Err(CirExportError::Validate(
"must specify exactly one CIR object pool backend".to_string(),
));
}
}
let total_started = std::time::Instant::now();
let started = std::time::Instant::now();
let cir = build_cir_from_run(
store,
trust_anchor,
tal_uri,
validation_time,
publication_points,
)?;
let build_cir_ms = started.elapsed().as_millis() as u64;
let started = std::time::Instant::now();
let object_pool = if let Some(static_root) = static_root {
CirObjectPoolExportSummary::Static(export_cir_static_pool(
store,
static_root,
capture_date_utc,
&cir,
trust_anchor,
)?)
} else {
CirObjectPoolExportSummary::RawStore(export_cir_raw_store(
store,
raw_store_path.expect("validated"),
&cir,
trust_anchor,
)?)
};
let static_pool_ms = started.elapsed().as_millis() as u64;
let started = std::time::Instant::now();
write_cir_file(cir_out, &cir)?;
let write_cir_ms = started.elapsed().as_millis() as u64;
Ok(CirExportSummary {
object_count: cir.objects.len(),
tal_count: cir.tals.len(),
object_pool,
timing: CirExportTiming {
build_cir_ms,
static_pool_ms,
write_cir_ms,
total_ms: total_started.elapsed().as_millis() as u64,
},
})
}
fn ta_sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cir::decode::decode_cir;
use crate::cir::static_pool_path;
use crate::data_model::ta::TrustAnchor;
use crate::data_model::tal::Tal;
use crate::storage::{RawByHashEntry, RepositoryViewEntry, RepositoryViewState, RocksStore};
fn sample_time() -> time::OffsetDateTime {
time::OffsetDateTime::parse(
"2026-04-07T12:34:56Z",
&time::format_description::well_known::Rfc3339,
)
.unwrap()
}
fn sample_date() -> time::Date {
time::Date::from_calendar_date(2026, time::Month::April, 7).unwrap()
}
fn sample_trust_anchor() -> TrustAnchor {
let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let tal_bytes =
std::fs::read(base.join("tests/fixtures/tal/apnic-rfc7730-https.tal")).unwrap();
let ta_der = std::fs::read(base.join("tests/fixtures/ta/apnic-ta.cer")).unwrap();
let tal = Tal::decode_bytes(&tal_bytes).unwrap();
TrustAnchor::bind_der(tal, &ta_der, None).unwrap()
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(bytes))
}
#[test]
fn build_cir_from_run_collects_repository_view_and_tal() {
let td = tempfile::tempdir().unwrap();
let store = RocksStore::open(td.path()).unwrap();
let bytes = b"object-a".to_vec();
let hash = sha256_hex(&bytes);
let mut raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone());
raw.origin_uris
.push("rsync://example.test/repo/a.cer".into());
store.put_raw_by_hash_entry(&raw).unwrap();
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/a.cer".to_string(),
current_hash: Some(hash),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("cer".to_string()),
state: RepositoryViewState::Present,
})
.unwrap();
let ta = sample_trust_anchor();
let cir = build_cir_from_run(
&store,
&ta,
"https://example.test/root.tal",
sample_time(),
&[],
)
.expect("build cir");
assert_eq!(cir.version, CIR_VERSION_V1);
assert_eq!(cir.tals.len(), 1);
assert_eq!(cir.tals[0].tal_uri, "https://example.test/root.tal");
assert!(
cir.objects
.iter()
.any(|item| item.rsync_uri == "rsync://example.test/repo/a.cer")
);
assert!(
cir.objects
.iter()
.any(|item| item.rsync_uri.contains("apnic-rpki-root-iana-origin.cer"))
);
}
#[test]
fn export_cir_from_run_writes_der_and_static_pool() {
let td = tempfile::tempdir().unwrap();
let store_dir = td.path().join("db");
let out_dir = td.path().join("out");
let static_root = td.path().join("static");
let store = RocksStore::open(&store_dir).unwrap();
let bytes = b"object-b".to_vec();
let hash = sha256_hex(&bytes);
let mut raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone());
raw.origin_uris
.push("rsync://example.test/repo/b.roa".into());
store.put_raw_by_hash_entry(&raw).unwrap();
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/b.roa".to_string(),
current_hash: Some(hash.clone()),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
})
.unwrap();
let ta = sample_trust_anchor();
let cir_path = out_dir.join("example.cir");
let summary = export_cir_from_run(
&store,
&ta,
"https://example.test/root.tal",
sample_time(),
&[],
&cir_path,
Some(&static_root),
None,
sample_date(),
)
.expect("export cir");
assert_eq!(summary.tal_count, 1);
assert!(summary.object_count >= 2);
match summary.object_pool {
CirObjectPoolExportSummary::Static(_) => {}
other => panic!("unexpected backend: {other:?}"),
}
let der = std::fs::read(&cir_path).unwrap();
let cir = decode_cir(&der).unwrap();
assert_eq!(cir.tals[0].tal_uri, "https://example.test/root.tal");
let object_path = static_pool_path(&static_root, sample_date(), &hash).unwrap();
assert_eq!(std::fs::read(object_path).unwrap(), bytes);
}
#[test]
fn export_cir_from_run_uses_raw_store_backend_without_pool_export() {
let td = tempfile::tempdir().unwrap();
let store_dir = td.path().join("db");
let raw_store = td.path().join("raw-store.db");
let out_dir = td.path().join("out");
let store = RocksStore::open_with_external_raw_store(&store_dir, &raw_store).unwrap();
let bytes = b"object-d".to_vec();
let hash = sha256_hex(&bytes);
let mut raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone());
raw.origin_uris
.push("rsync://example.test/repo/d.roa".into());
store.put_raw_by_hash_entry(&raw).unwrap();
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/d.roa".to_string(),
current_hash: Some(hash.clone()),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
})
.unwrap();
let ta = sample_trust_anchor();
let cir_path = out_dir.join("example.cir");
let summary = export_cir_from_run(
&store,
&ta,
"https://example.test/root.tal",
sample_time(),
&[],
&cir_path,
None,
Some(&raw_store),
sample_date(),
)
.expect("export cir to raw store");
match summary.object_pool {
CirObjectPoolExportSummary::RawStore(ref s) => assert!(s.unique_hashes >= 2),
other => panic!("unexpected backend: {other:?}"),
}
assert!(raw_store.exists());
}
#[test]
fn build_cir_from_run_includes_vcir_current_instance_objects_from_audit() {
let td = tempfile::tempdir().unwrap();
let store = RocksStore::open(td.path()).unwrap();
let ta = sample_trust_anchor();
let mut pp = PublicationPointAudit {
source: "vcir_current_instance".to_string(),
..PublicationPointAudit::default()
};
pp.objects.push(crate::audit::ObjectAuditEntry {
rsync_uri: "rsync://example.test/repo/fallback.mft".to_string(),
sha256_hex: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.to_string(),
kind: crate::audit::AuditObjectKind::Manifest,
result: crate::audit::AuditObjectResult::Ok,
detail: None,
});
pp.objects.push(crate::audit::ObjectAuditEntry {
rsync_uri: "rsync://example.test/repo/fallback.roa".to_string(),
sha256_hex: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
.to_string(),
kind: crate::audit::AuditObjectKind::Roa,
result: crate::audit::AuditObjectResult::Ok,
detail: None,
});
let cir = build_cir_from_run(
&store,
&ta,
"https://example.test/root.tal",
sample_time(),
&[pp],
)
.expect("build cir");
assert!(
cir.objects
.iter()
.any(|item| item.rsync_uri == "rsync://example.test/repo/fallback.mft")
);
assert!(
cir.objects
.iter()
.any(|item| item.rsync_uri == "rsync://example.test/repo/fallback.roa")
);
}
}

710
src/cir/materialize.rs Normal file
View File

@@ -0,0 +1,710 @@
use std::fs;
use std::path::{Path, PathBuf};
use crate::blob_store::{ExternalRawStoreDb, RawObjectStore};
use crate::cir::model::CanonicalInputRepresentation;
#[derive(Debug, thiserror::Error)]
pub enum CirMaterializeError {
#[error("invalid rsync URI: {0}")]
InvalidRsyncUri(String),
#[error("rsync URI must reference a file object, got directory-like URI: {0}")]
DirectoryLikeRsyncUri(String),
#[error("create mirror root failed: {path}: {detail}")]
CreateMirrorRoot { path: String, detail: String },
#[error("remove mirror root failed: {path}: {detail}")]
RemoveMirrorRoot { path: String, detail: String },
#[error("create parent directory failed: {path}: {detail}")]
CreateParent { path: String, detail: String },
#[error("remove existing target failed: {path}: {detail}")]
RemoveExistingTarget { path: String, detail: String },
#[error("static object not found for sha256={sha256_hex}")]
MissingStaticObject { sha256_hex: String },
#[error("link target failed: {src} -> {dst}: {detail}")]
Link {
src: String,
dst: String,
detail: String,
},
#[error("copy target failed: {src} -> {dst}: {detail}")]
Copy {
src: String,
dst: String,
detail: String,
},
#[error("mirror tree mismatch after materialize: {0}")]
TreeMismatch(String),
#[error("open raw store failed: {path}: {detail}")]
OpenRawStore { path: String, detail: String },
#[error("raw object not found for sha256={sha256_hex}")]
MissingRawStoreObject { sha256_hex: String },
#[error("read raw store failed for sha256={sha256_hex}: {detail}")]
ReadRawStore { sha256_hex: String, detail: String },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CirMaterializeSummary {
pub object_count: usize,
pub linked_files: usize,
pub copied_files: usize,
}
pub fn materialize_cir(
cir: &CanonicalInputRepresentation,
static_root: &Path,
mirror_root: &Path,
clean_rebuild: bool,
) -> Result<CirMaterializeSummary, CirMaterializeError> {
cir.validate().map_err(CirMaterializeError::TreeMismatch)?;
if clean_rebuild && mirror_root.exists() {
fs::remove_dir_all(mirror_root).map_err(|e| CirMaterializeError::RemoveMirrorRoot {
path: mirror_root.display().to_string(),
detail: e.to_string(),
})?;
}
fs::create_dir_all(mirror_root).map_err(|e| CirMaterializeError::CreateMirrorRoot {
path: mirror_root.display().to_string(),
detail: e.to_string(),
})?;
let mut linked_files = 0usize;
let mut copied_files = 0usize;
for object in &cir.objects {
let sha256_hex = hex::encode(&object.sha256);
let source = resolve_static_pool_file(static_root, &sha256_hex)?;
let relative = mirror_relative_path_for_rsync_uri(&object.rsync_uri)?;
let target = mirror_root.join(&relative);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| CirMaterializeError::CreateParent {
path: parent.display().to_string(),
detail: e.to_string(),
})?;
}
if target.exists() {
fs::remove_file(&target).map_err(|e| CirMaterializeError::RemoveExistingTarget {
path: target.display().to_string(),
detail: e.to_string(),
})?;
}
match fs::hard_link(&source, &target) {
Ok(()) => linked_files += 1,
Err(link_err) => {
fs::copy(&source, &target).map_err(|copy_err| CirMaterializeError::Copy {
src: source.display().to_string(),
dst: target.display().to_string(),
detail: format!("{copy_err}; original link error: {link_err}"),
})?;
copied_files += 1;
}
}
}
let actual = collect_materialized_uris(mirror_root)?;
let expected = cir
.objects
.iter()
.map(|item| item.rsync_uri.clone())
.collect::<std::collections::BTreeSet<_>>();
if actual != expected {
return Err(CirMaterializeError::TreeMismatch(format!(
"expected {} files, got {} files",
expected.len(),
actual.len()
)));
}
Ok(CirMaterializeSummary {
object_count: cir.objects.len(),
linked_files,
copied_files,
})
}
pub fn materialize_cir_from_raw_store(
cir: &CanonicalInputRepresentation,
raw_store_db: &Path,
mirror_root: &Path,
clean_rebuild: bool,
) -> Result<CirMaterializeSummary, CirMaterializeError> {
cir.validate().map_err(CirMaterializeError::TreeMismatch)?;
if clean_rebuild && mirror_root.exists() {
fs::remove_dir_all(mirror_root).map_err(|e| CirMaterializeError::RemoveMirrorRoot {
path: mirror_root.display().to_string(),
detail: e.to_string(),
})?;
}
fs::create_dir_all(mirror_root).map_err(|e| CirMaterializeError::CreateMirrorRoot {
path: mirror_root.display().to_string(),
detail: e.to_string(),
})?;
let raw_store =
ExternalRawStoreDb::open(raw_store_db).map_err(|e| CirMaterializeError::OpenRawStore {
path: raw_store_db.display().to_string(),
detail: e.to_string(),
})?;
let mut copied_files = 0usize;
for object in &cir.objects {
let sha256_hex = hex::encode(&object.sha256);
let entry = raw_store
.get_raw_entry(&sha256_hex)
.map_err(|e| CirMaterializeError::ReadRawStore {
sha256_hex: sha256_hex.clone(),
detail: e.to_string(),
})?
.ok_or_else(|| CirMaterializeError::MissingRawStoreObject {
sha256_hex: sha256_hex.clone(),
})?;
let relative = mirror_relative_path_for_rsync_uri(&object.rsync_uri)?;
let target = mirror_root.join(&relative);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| CirMaterializeError::CreateParent {
path: parent.display().to_string(),
detail: e.to_string(),
})?;
}
if target.exists() {
fs::remove_file(&target).map_err(|e| CirMaterializeError::RemoveExistingTarget {
path: target.display().to_string(),
detail: e.to_string(),
})?;
}
fs::write(&target, &entry.bytes).map_err(|e| CirMaterializeError::Copy {
src: raw_store_db.display().to_string(),
dst: target.display().to_string(),
detail: e.to_string(),
})?;
copied_files += 1;
}
let actual = collect_materialized_uris(mirror_root)?;
let expected = cir
.objects
.iter()
.map(|item| item.rsync_uri.clone())
.collect::<std::collections::BTreeSet<_>>();
if actual != expected {
return Err(CirMaterializeError::TreeMismatch(format!(
"expected {} files, got {} files",
expected.len(),
actual.len()
)));
}
Ok(CirMaterializeSummary {
object_count: cir.objects.len(),
linked_files: 0,
copied_files,
})
}
pub fn mirror_relative_path_for_rsync_uri(rsync_uri: &str) -> Result<PathBuf, CirMaterializeError> {
let url = url::Url::parse(rsync_uri)
.map_err(|_| CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()))?;
if url.scheme() != "rsync" {
return Err(CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()));
}
let host = url
.host_str()
.ok_or_else(|| CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()))?;
let segments = url
.path_segments()
.ok_or_else(|| CirMaterializeError::InvalidRsyncUri(rsync_uri.to_string()))?
.collect::<Vec<_>>();
if segments.is_empty() || segments.last().copied().unwrap_or_default().is_empty() {
return Err(CirMaterializeError::DirectoryLikeRsyncUri(
rsync_uri.to_string(),
));
}
let mut path = PathBuf::from(host);
for segment in segments {
if !segment.is_empty() {
path.push(segment);
}
}
Ok(path)
}
pub fn resolve_static_pool_file(
static_root: &Path,
sha256_hex: &str,
) -> Result<PathBuf, CirMaterializeError> {
if sha256_hex.len() != 64 || !sha256_hex.as_bytes().iter().all(u8::is_ascii_hexdigit) {
return Err(CirMaterializeError::MissingStaticObject {
sha256_hex: sha256_hex.to_string(),
});
}
let prefix1 = &sha256_hex[0..2];
let prefix2 = &sha256_hex[2..4];
let entries =
fs::read_dir(static_root).map_err(|_| CirMaterializeError::MissingStaticObject {
sha256_hex: sha256_hex.to_string(),
})?;
let mut dates = entries
.filter_map(Result::ok)
.filter(|entry| entry.path().is_dir())
.map(|entry| entry.path())
.collect::<Vec<_>>();
dates.sort();
for date_dir in dates {
let candidate = date_dir.join(prefix1).join(prefix2).join(sha256_hex);
if candidate.is_file() {
return Ok(candidate);
}
}
Err(CirMaterializeError::MissingStaticObject {
sha256_hex: sha256_hex.to_string(),
})
}
fn collect_materialized_uris(
mirror_root: &Path,
) -> Result<std::collections::BTreeSet<String>, CirMaterializeError> {
let mut out = std::collections::BTreeSet::new();
let mut stack = vec![mirror_root.to_path_buf()];
while let Some(path) = stack.pop() {
for entry in fs::read_dir(&path).map_err(|e| CirMaterializeError::CreateMirrorRoot {
path: path.display().to_string(),
detail: e.to_string(),
})? {
let entry = entry.map_err(|e| CirMaterializeError::CreateMirrorRoot {
path: path.display().to_string(),
detail: e.to_string(),
})?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else {
let rel = path
.strip_prefix(mirror_root)
.expect("materialized path under mirror root")
.to_string_lossy()
.replace('\\', "/");
let uri = format!("rsync://{rel}");
out.insert(uri);
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::{
CirMaterializeError, materialize_cir, materialize_cir_from_raw_store,
mirror_relative_path_for_rsync_uri, resolve_static_pool_file,
};
use crate::blob_store::ExternalRawStoreDb;
use crate::cir::model::{
CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal,
};
use sha2::Digest;
use std::path::{Path, PathBuf};
fn sample_time() -> time::OffsetDateTime {
time::OffsetDateTime::parse(
"2026-04-07T12:34:56Z",
&time::format_description::well_known::Rfc3339,
)
.unwrap()
}
fn sample_cir() -> CanonicalInputRepresentation {
CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time(),
objects: vec![
CirObject {
rsync_uri: "rsync://example.net/repo/a.cer".to_string(),
sha256: hex::decode(
"1111111111111111111111111111111111111111111111111111111111111111",
)
.unwrap(),
},
CirObject {
rsync_uri: "rsync://example.net/repo/nested/b.roa".to_string(),
sha256: hex::decode(
"2222222222222222222222222222222222222222222222222222222222222222",
)
.unwrap(),
},
],
tals: vec![CirTal {
tal_uri: "https://tal.example.net/root.tal".to_string(),
tal_bytes: b"x".to_vec(),
}],
}
}
fn cir_with_real_hashes(a: &[u8], b: &[u8]) -> CanonicalInputRepresentation {
CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time(),
objects: vec![
CirObject {
rsync_uri: "rsync://example.net/repo/a.cer".to_string(),
sha256: sha2::Sha256::digest(a).to_vec(),
},
CirObject {
rsync_uri: "rsync://example.net/repo/nested/b.roa".to_string(),
sha256: sha2::Sha256::digest(b).to_vec(),
},
],
tals: vec![CirTal {
tal_uri: "https://tal.example.net/root.tal".to_string(),
tal_bytes: b"x".to_vec(),
}],
}
}
#[test]
fn mirror_relative_path_for_rsync_uri_maps_host_and_path() {
let path =
mirror_relative_path_for_rsync_uri("rsync://example.net/repo/nested/b.roa").unwrap();
assert_eq!(
path,
PathBuf::from("example.net")
.join("repo")
.join("nested")
.join("b.roa")
);
}
#[test]
fn resolve_static_pool_file_finds_hash_across_dates() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("20260407").join("11").join("11");
std::fs::create_dir_all(&path).unwrap();
let file = path.join("1111111111111111111111111111111111111111111111111111111111111111");
std::fs::write(&file, b"x").unwrap();
let resolved = resolve_static_pool_file(
td.path(),
"1111111111111111111111111111111111111111111111111111111111111111",
)
.unwrap();
assert_eq!(resolved, file);
}
#[test]
fn resolve_static_pool_file_rejects_invalid_hash_and_missing_hash() {
let td = tempfile::tempdir().unwrap();
let err = resolve_static_pool_file(td.path(), "not-a-hash")
.expect_err("invalid hash should fail");
assert!(matches!(
err,
CirMaterializeError::MissingStaticObject { .. }
));
let err = resolve_static_pool_file(
td.path(),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.expect_err("missing hash should fail");
assert!(matches!(
err,
CirMaterializeError::MissingStaticObject { .. }
));
}
#[test]
fn mirror_relative_path_rejects_non_rsync_and_directory_like_uris() {
let err = mirror_relative_path_for_rsync_uri("https://example.net/repo/a.roa")
.expect_err("non-rsync uri must fail");
assert!(matches!(err, CirMaterializeError::InvalidRsyncUri(_)));
let err = mirror_relative_path_for_rsync_uri("rsync://example.net/repo/")
.expect_err("directory-like uri must fail");
assert!(matches!(err, CirMaterializeError::DirectoryLikeRsyncUri(_)));
}
#[test]
fn materialize_clean_rebuild_creates_exact_tree_and_removes_stale_files() {
let td = tempfile::tempdir().unwrap();
let static_root = td.path().join("static");
let mirror_root = td.path().join("mirror");
write_static(
&static_root,
"20260407",
"1111111111111111111111111111111111111111111111111111111111111111",
b"a",
);
write_static(
&static_root,
"20260407",
"2222222222222222222222222222222222222222222222222222222222222222",
b"b",
);
std::fs::create_dir_all(mirror_root.join("stale")).unwrap();
std::fs::write(mirror_root.join("stale/old.txt"), b"old").unwrap();
let summary = materialize_cir(&sample_cir(), &static_root, &mirror_root, true).unwrap();
assert_eq!(summary.object_count, 2);
assert_eq!(
std::fs::read(mirror_root.join("example.net/repo/a.cer")).unwrap(),
b"a"
);
assert_eq!(
std::fs::read(mirror_root.join("example.net/repo/nested/b.roa")).unwrap(),
b"b"
);
assert!(!mirror_root.join("stale/old.txt").exists());
}
#[test]
fn materialize_fails_when_static_object_missing() {
let td = tempfile::tempdir().unwrap();
let err = materialize_cir(&sample_cir(), td.path(), &td.path().join("mirror"), true)
.expect_err("missing static object must fail");
assert!(matches!(
err,
CirMaterializeError::MissingStaticObject { .. }
));
}
#[test]
fn materialize_without_clean_rebuild_detects_stale_extra_files() {
let td = tempfile::tempdir().unwrap();
let static_root = td.path().join("static");
let mirror_root = td.path().join("mirror");
write_static(
&static_root,
"20260407",
"1111111111111111111111111111111111111111111111111111111111111111",
b"a",
);
write_static(
&static_root,
"20260407",
"2222222222222222222222222222222222222222222222222222222222222222",
b"b",
);
std::fs::create_dir_all(mirror_root.join("extra")).unwrap();
std::fs::write(mirror_root.join("extra/stale.txt"), b"stale").unwrap();
let err = materialize_cir(&sample_cir(), &static_root, &mirror_root, false)
.expect_err("stale extra files should fail exact tree check");
assert!(matches!(err, CirMaterializeError::TreeMismatch(_)));
}
#[test]
fn materialize_from_raw_store_creates_expected_tree() {
let td = tempfile::tempdir().unwrap();
let raw_store_path = td.path().join("raw-store.db");
let mirror_root = td.path().join("mirror");
let a = b"a".to_vec();
let b = b"b".to_vec();
let cir = CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time(),
objects: vec![
CirObject {
rsync_uri: "rsync://example.net/repo/a.cer".to_string(),
sha256: sha2::Sha256::digest(&a).to_vec(),
},
CirObject {
rsync_uri: "rsync://example.net/repo/nested/b.roa".to_string(),
sha256: sha2::Sha256::digest(&b).to_vec(),
},
],
tals: vec![CirTal {
tal_uri: "https://tal.example.net/root.tal".to_string(),
tal_bytes: b"x".to_vec(),
}],
};
{
let raw_store = ExternalRawStoreDb::open(&raw_store_path).unwrap();
let mut entry_a =
crate::storage::RawByHashEntry::from_bytes(hex::encode(&cir.objects[0].sha256), a);
entry_a.origin_uris.push(cir.objects[0].rsync_uri.clone());
raw_store.put_raw_entry(&entry_a).unwrap();
let mut entry_b =
crate::storage::RawByHashEntry::from_bytes(hex::encode(&cir.objects[1].sha256), b);
entry_b.origin_uris.push(cir.objects[1].rsync_uri.clone());
raw_store.put_raw_entry(&entry_b).unwrap();
}
let summary =
materialize_cir_from_raw_store(&cir, &raw_store_path, &mirror_root, true).unwrap();
assert_eq!(summary.object_count, 2);
assert_eq!(summary.linked_files, 0);
assert_eq!(summary.copied_files, 2);
assert_eq!(
std::fs::read(mirror_root.join("example.net/repo/a.cer")).unwrap(),
b"a"
);
assert_eq!(
std::fs::read(mirror_root.join("example.net/repo/nested/b.roa")).unwrap(),
b"b"
);
}
#[test]
fn materialize_from_raw_store_fails_when_object_missing() {
let td = tempfile::tempdir().unwrap();
let raw_store_path = td.path().join("raw-store.db");
let mirror_root = td.path().join("mirror");
let cir = cir_with_real_hashes(b"a", b"b");
{
let raw_store = ExternalRawStoreDb::open(&raw_store_path).unwrap();
let only = crate::storage::RawByHashEntry::from_bytes(
hex::encode(&cir.objects[0].sha256),
b"a".to_vec(),
);
raw_store.put_raw_entry(&only).unwrap();
}
let err = materialize_cir_from_raw_store(&cir, &raw_store_path, &mirror_root, true)
.expect_err("missing second object should fail");
assert!(matches!(
err,
CirMaterializeError::MissingRawStoreObject { .. }
));
}
#[test]
fn materialize_from_raw_store_detects_stale_tree_when_not_clean_rebuild() {
let td = tempfile::tempdir().unwrap();
let raw_store_path = td.path().join("raw-store.db");
let mirror_root = td.path().join("mirror");
let cir = cir_with_real_hashes(b"a", b"b");
{
let raw_store = ExternalRawStoreDb::open(&raw_store_path).unwrap();
let mut entry_a = crate::storage::RawByHashEntry::from_bytes(
hex::encode(&cir.objects[0].sha256),
b"a".to_vec(),
);
entry_a.origin_uris.push(cir.objects[0].rsync_uri.clone());
raw_store.put_raw_entry(&entry_a).unwrap();
let mut entry_b = crate::storage::RawByHashEntry::from_bytes(
hex::encode(&cir.objects[1].sha256),
b"b".to_vec(),
);
entry_b.origin_uris.push(cir.objects[1].rsync_uri.clone());
raw_store.put_raw_entry(&entry_b).unwrap();
}
std::fs::create_dir_all(mirror_root.join("extra")).unwrap();
std::fs::write(mirror_root.join("extra/stale.txt"), b"stale").unwrap();
let err =
materialize_cir_from_raw_store(&cir, &raw_store_path, &mirror_root, false)
.expect_err("stale file should fail exact tree check");
assert!(matches!(err, CirMaterializeError::TreeMismatch(_)));
}
#[test]
fn materialize_from_raw_store_overwrites_existing_targets() {
let td = tempfile::tempdir().unwrap();
let raw_store_path = td.path().join("raw-store.db");
let mirror_root = td.path().join("mirror");
let a = b"new-a".to_vec();
let b = b"new-b".to_vec();
let cir = cir_with_real_hashes(&a, &b);
{
let raw_store = ExternalRawStoreDb::open(&raw_store_path).unwrap();
let mut entry_a = crate::storage::RawByHashEntry::from_bytes(
hex::encode(&cir.objects[0].sha256),
a.clone(),
);
entry_a.origin_uris.push(cir.objects[0].rsync_uri.clone());
raw_store.put_raw_entry(&entry_a).unwrap();
let mut entry_b = crate::storage::RawByHashEntry::from_bytes(
hex::encode(&cir.objects[1].sha256),
b.clone(),
);
entry_b.origin_uris.push(cir.objects[1].rsync_uri.clone());
raw_store.put_raw_entry(&entry_b).unwrap();
}
let target = mirror_root.join("example.net/repo/a.cer");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, b"old").unwrap();
let summary =
materialize_cir_from_raw_store(&cir, &raw_store_path, &mirror_root, false).unwrap();
assert_eq!(summary.copied_files, 2);
assert_eq!(std::fs::read(&target).unwrap(), a);
}
#[test]
fn materialize_from_raw_store_reports_codec_errors() {
let td = tempfile::tempdir().unwrap();
let raw_store_path = td.path().join("raw-store.db");
let mirror_root = td.path().join("mirror");
{
let _raw_store = ExternalRawStoreDb::open(&raw_store_path).unwrap();
}
{
let db = rocksdb::DB::open_default(&raw_store_path).unwrap();
db.put(
b"rawbyhash:1111111111111111111111111111111111111111111111111111111111111111",
b"bad-cbor",
)
.unwrap();
}
let err = materialize_cir_from_raw_store(
&CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time(),
objects: vec![CirObject {
rsync_uri: "rsync://example.net/repo/a.cer".to_string(),
sha256: hex::decode(
"1111111111111111111111111111111111111111111111111111111111111111",
)
.unwrap(),
}],
tals: vec![CirTal {
tal_uri: "https://tal.example.net/root.tal".to_string(),
tal_bytes: b"x".to_vec(),
}],
},
&raw_store_path,
&mirror_root,
true,
)
.expect_err("corrupt raw-store object should fail");
assert!(matches!(
err,
CirMaterializeError::ReadRawStore { .. } | CirMaterializeError::MissingRawStoreObject { .. }
));
}
fn write_static(root: &Path, date: &str, hash: &str, bytes: &[u8]) {
let path = root.join(date).join(&hash[0..2]).join(&hash[2..4]);
std::fs::create_dir_all(&path).unwrap();
std::fs::write(path.join(hash), bytes).unwrap();
}
}

350
src/cir/mod.rs Normal file
View File

@@ -0,0 +1,350 @@
pub mod decode;
pub mod encode;
#[cfg(feature = "full")]
pub mod export;
pub mod materialize;
pub mod model;
pub mod sequence;
#[cfg(feature = "full")]
pub mod static_pool;
pub use decode::{CirDecodeError, decode_cir};
pub use encode::{CirEncodeError, encode_cir};
#[cfg(feature = "full")]
pub use export::{
CirExportError, CirExportSummary, build_cir_from_run, export_cir_from_run, write_cir_file,
};
pub use materialize::{
CirMaterializeError, CirMaterializeSummary, materialize_cir,
materialize_cir_from_raw_store,
mirror_relative_path_for_rsync_uri, resolve_static_pool_file,
};
pub use model::{
CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal,
};
pub use sequence::{CirSequenceManifest, CirSequenceStep, CirSequenceStepKind};
#[cfg(feature = "full")]
pub use static_pool::{
CirStaticPoolError, CirStaticPoolExportSummary, CirStaticPoolWriteResult,
export_hashes_from_store, static_pool_path, static_pool_relative_path,
write_bytes_to_static_pool, write_raw_entry_to_static_pool,
};
#[cfg(test)]
mod tests {
use super::{
CIR_VERSION_V1, CanonicalInputRepresentation, CirHashAlgorithm, CirObject, CirTal,
decode_cir, encode_cir,
};
fn sample_time() -> time::OffsetDateTime {
time::OffsetDateTime::parse(
"2026-04-07T12:34:56Z",
&time::format_description::well_known::Rfc3339,
)
.expect("valid rfc3339")
}
fn sample_cir() -> CanonicalInputRepresentation {
CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time(),
objects: vec![
CirObject {
rsync_uri: "rsync://example.net/repo/a.cer".to_string(),
sha256: vec![0x11; 32],
},
CirObject {
rsync_uri: "rsync://example.net/repo/b.roa".to_string(),
sha256: vec![0x22; 32],
},
],
tals: vec![CirTal {
tal_uri: "https://tal.example.net/root.tal".to_string(),
tal_bytes: b"https://tal.example.net/ta.cer\nrsync://example.net/repo/ta.cer\nMIIB"
.to_vec(),
}],
}
}
fn test_encode_tlv(tag: u8, value: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + value.len());
out.push(tag);
if value.len() < 0x80 {
out.push(value.len() as u8);
} else {
let len = value.len();
let bytes = len.to_be_bytes();
let first_non_zero = bytes
.iter()
.position(|&b| b != 0)
.unwrap_or(bytes.len() - 1);
let len_bytes = &bytes[first_non_zero..];
out.push(0x80 | len_bytes.len() as u8);
out.extend_from_slice(len_bytes);
}
out.extend_from_slice(value);
out
}
#[test]
fn cir_roundtrip_full_succeeds() {
let cir = sample_cir();
let der = encode_cir(&cir).expect("encode cir");
let decoded = decode_cir(&der).expect("decode cir");
assert_eq!(decoded, cir);
}
#[test]
fn cir_roundtrip_minimal_succeeds() {
let cir = CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time(),
objects: Vec::new(),
tals: vec![CirTal {
tal_uri: "https://tal.example.net/minimal.tal".to_string(),
tal_bytes: b"rsync://example.net/repo/ta.cer\nMIIB".to_vec(),
}],
};
let der = encode_cir(&cir).expect("encode minimal cir");
let decoded = decode_cir(&der).expect("decode minimal cir");
assert_eq!(decoded, cir);
}
#[test]
fn cir_model_rejects_unsorted_duplicate_objects() {
let cir = CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time(),
objects: vec![
CirObject {
rsync_uri: "rsync://example.net/repo/z.roa".to_string(),
sha256: vec![0x11; 32],
},
CirObject {
rsync_uri: "rsync://example.net/repo/a.roa".to_string(),
sha256: vec![0x22; 32],
},
],
tals: vec![CirTal {
tal_uri: "https://tal.example.net/root.tal".to_string(),
tal_bytes: b"x".to_vec(),
}],
};
let err = encode_cir(&cir).expect_err("unsorted objects must fail");
assert!(err.to_string().contains("CIR.objects"), "{err}");
}
#[test]
fn cir_model_rejects_duplicate_tals() {
let cir = CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time(),
objects: Vec::new(),
tals: vec![
CirTal {
tal_uri: "https://tal.example.net/root.tal".to_string(),
tal_bytes: b"a".to_vec(),
},
CirTal {
tal_uri: "https://tal.example.net/root.tal".to_string(),
tal_bytes: b"b".to_vec(),
},
],
};
let err = encode_cir(&cir).expect_err("duplicate tals must fail");
assert!(err.to_string().contains("CIR.tals"), "{err}");
}
#[test]
fn cir_decode_rejects_wrong_version() {
let mut der = encode_cir(&sample_cir()).expect("encode cir");
let pos = der
.windows(3)
.position(|window| window == [0x02, 0x01, CIR_VERSION_V1 as u8])
.expect("find version integer");
der[pos + 2] = 2;
let err = decode_cir(&der).expect_err("wrong version must fail");
assert!(err.to_string().contains("unexpected CIR version"), "{err}");
}
#[test]
fn cir_decode_rejects_wrong_hash_oid() {
let mut der = encode_cir(&sample_cir()).expect("encode cir");
let sha256_bytes = crate::data_model::oid::OID_SHA256_RAW;
let idx = der
.windows(sha256_bytes.len())
.position(|window| window == sha256_bytes)
.expect("find sha256 oid");
der[idx + sha256_bytes.len() - 1] ^= 0x01;
let err = decode_cir(&der).expect_err("wrong oid must fail");
assert!(
err.to_string().contains(crate::data_model::oid::OID_SHA256),
"{err}"
);
}
#[test]
fn cir_decode_rejects_bad_generalized_time() {
let mut der = encode_cir(&sample_cir()).expect("encode cir");
let pos = der
.windows(15)
.position(|window| window == b"20260407123456Z")
.expect("find generalized time");
der[pos + 14] = b'X';
let err = decode_cir(&der).expect_err("bad time must fail");
assert!(err.to_string().contains("GeneralizedTime"), "{err}");
}
#[test]
fn cir_model_rejects_non_rsync_object_uri_and_empty_tals() {
let bad_object = CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time(),
objects: vec![CirObject {
rsync_uri: "https://example.net/repo/a.roa".to_string(),
sha256: vec![0x11; 32],
}],
tals: vec![CirTal {
tal_uri: "https://tal.example.net/root.tal".to_string(),
tal_bytes: b"x".to_vec(),
}],
};
let err = encode_cir(&bad_object).expect_err("non-rsync object uri must fail");
assert!(err.to_string().contains("rsync://"), "{err}");
let no_tals = CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time(),
objects: Vec::new(),
tals: Vec::new(),
};
let err = encode_cir(&no_tals).expect_err("empty tals must fail");
assert!(
err.to_string().contains("CIR.tals must be non-empty"),
"{err}"
);
}
#[test]
fn cir_model_rejects_non_utc_time_bad_hash_len_and_non_http_tal_uri() {
let bad_time = CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time().to_offset(time::UtcOffset::from_hms(8, 0, 0).unwrap()),
objects: Vec::new(),
tals: vec![CirTal {
tal_uri: "https://tal.example.net/root.tal".to_string(),
tal_bytes: b"x".to_vec(),
}],
};
let err = encode_cir(&bad_time).expect_err("non-utc validation time must fail");
assert!(err.to_string().contains("UTC"), "{err}");
let bad_hash = CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time(),
objects: vec![CirObject {
rsync_uri: "rsync://example.net/repo/a.roa".to_string(),
sha256: vec![0x11; 31],
}],
tals: vec![CirTal {
tal_uri: "https://tal.example.net/root.tal".to_string(),
tal_bytes: b"x".to_vec(),
}],
};
let err = encode_cir(&bad_hash).expect_err("bad digest len must fail");
assert!(err.to_string().contains("32 bytes"), "{err}");
let bad_tal_uri = CanonicalInputRepresentation {
version: CIR_VERSION_V1,
hash_alg: CirHashAlgorithm::Sha256,
validation_time: sample_time(),
objects: Vec::new(),
tals: vec![CirTal {
tal_uri: "ftp://tal.example.net/root.tal".to_string(),
tal_bytes: b"x".to_vec(),
}],
};
let err = encode_cir(&bad_tal_uri).expect_err("bad tal uri must fail");
assert!(err.to_string().contains("http:// or https://"), "{err}");
}
#[test]
fn cir_decode_rejects_trailing_bytes_and_trailing_fields() {
let cir = sample_cir();
let mut der = encode_cir(&cir).expect("encode cir");
der.push(0);
let err = decode_cir(&der).expect_err("trailing bytes after cir must fail");
assert!(
err.to_string().contains("trailing bytes after CIR"),
"{err}"
);
let object = test_encode_tlv(
0x30,
&[
test_encode_tlv(0x16, b"rsync://example.net/repo/a.roa"),
test_encode_tlv(0x04, &[0x11; 32]),
test_encode_tlv(0x02, &[0x01]),
]
.concat(),
);
let tal = test_encode_tlv(
0x30,
&[
test_encode_tlv(0x16, b"https://tal.example.net/root.tal"),
test_encode_tlv(0x04, b"x"),
]
.concat(),
);
let bad = test_encode_tlv(
0x30,
&[
test_encode_tlv(0x02, &[CIR_VERSION_V1 as u8]),
test_encode_tlv(0x06, crate::data_model::oid::OID_SHA256_RAW),
test_encode_tlv(0x18, b"20260407123456Z"),
test_encode_tlv(0x30, &object),
test_encode_tlv(0x30, &tal),
]
.concat(),
);
let err = decode_cir(&bad).expect_err("trailing field in object must fail");
assert!(
err.to_string().contains("trailing fields in CirObject"),
"{err}"
);
}
#[test]
fn cir_decode_rejects_invalid_object_and_tal_shapes() {
let cir = sample_cir();
let mut der = encode_cir(&cir).expect("encode cir");
let rsync_text = b"rsync://example.net/repo/a.cer";
let idx = der
.windows(rsync_text.len())
.position(|window| window == rsync_text)
.expect("find object uri");
der[idx] = 0xFF;
let err = decode_cir(&der).expect_err("invalid utf8 object uri must fail");
assert!(err.to_string().contains("utf-8"), "{err}");
let mut der = encode_cir(&cir).expect("encode cir");
let tal_text = b"https://tal.example.net/root.tal";
let idx = der
.windows(tal_text.len())
.position(|window| window == tal_text)
.expect("find tal uri");
der[idx] = 0xFF;
let err = decode_cir(&der).expect_err("invalid utf8 tal uri must fail");
assert!(err.to_string().contains("utf-8"), "{err}");
}
}

122
src/cir/model.rs Normal file
View File

@@ -0,0 +1,122 @@
use crate::data_model::oid::OID_SHA256;
pub const CIR_VERSION_V1: u32 = 1;
pub const DIGEST_LEN_SHA256: usize = 32;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CirHashAlgorithm {
Sha256,
}
impl CirHashAlgorithm {
pub fn oid(&self) -> &'static str {
match self {
Self::Sha256 => OID_SHA256,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CanonicalInputRepresentation {
pub version: u32,
pub hash_alg: CirHashAlgorithm,
pub validation_time: time::OffsetDateTime,
pub objects: Vec<CirObject>,
pub tals: Vec<CirTal>,
}
impl CanonicalInputRepresentation {
pub fn validate(&self) -> Result<(), String> {
if self.version != CIR_VERSION_V1 {
return Err(format!(
"CIR version must be {CIR_VERSION_V1}, got {}",
self.version
));
}
if !matches!(self.hash_alg, CirHashAlgorithm::Sha256) {
return Err("CIR hashAlg must be SHA-256".into());
}
if self.validation_time.offset() != time::UtcOffset::UTC {
return Err("CIR validationTime must be UTC".into());
}
validate_sorted_unique_strings(
self.objects.iter().map(|item| item.rsync_uri.as_str()),
"CIR.objects must be sorted by rsyncUri and unique",
)?;
validate_sorted_unique_strings(
self.tals.iter().map(|item| item.tal_uri.as_str()),
"CIR.tals must be sorted by talUri and unique",
)?;
if self.tals.is_empty() {
return Err("CIR.tals must be non-empty".into());
}
for object in &self.objects {
object.validate()?;
}
for tal in &self.tals {
tal.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CirObject {
pub rsync_uri: String,
pub sha256: Vec<u8>,
}
impl CirObject {
pub fn validate(&self) -> Result<(), String> {
if !self.rsync_uri.starts_with("rsync://") {
return Err(format!(
"CirObject.rsync_uri must start with rsync://, got {}",
self.rsync_uri
));
}
if self.sha256.len() != DIGEST_LEN_SHA256 {
return Err(format!(
"CirObject.sha256 must be {DIGEST_LEN_SHA256} bytes, got {}",
self.sha256.len()
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CirTal {
pub tal_uri: String,
pub tal_bytes: Vec<u8>,
}
impl CirTal {
pub fn validate(&self) -> Result<(), String> {
if !(self.tal_uri.starts_with("https://") || self.tal_uri.starts_with("http://")) {
return Err(format!(
"CirTal.tal_uri must start with http:// or https://, got {}",
self.tal_uri
));
}
if self.tal_bytes.is_empty() {
return Err("CirTal.tal_bytes must be non-empty".into());
}
Ok(())
}
}
fn validate_sorted_unique_strings<'a>(
items: impl IntoIterator<Item = &'a str>,
message: &str,
) -> Result<(), String> {
let mut prev: Option<&'a str> = None;
for key in items {
if let Some(prev_key) = prev
&& key <= prev_key
{
return Err(message.into());
}
prev = Some(key);
}
Ok(())
}

211
src/cir/sequence.rs Normal file
View File

@@ -0,0 +1,211 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CirSequenceStepKind {
Full,
Delta,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CirSequenceStep {
pub step_id: String,
pub kind: CirSequenceStepKind,
pub validation_time: String,
pub cir_path: String,
pub ccr_path: String,
pub report_path: String,
#[serde(default)]
pub timing_path: Option<String>,
#[serde(default)]
pub stdout_log_path: Option<String>,
#[serde(default)]
pub stderr_log_path: Option<String>,
#[serde(default)]
pub artifact_prefix: Option<String>,
pub previous_step_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CirSequenceManifest {
pub version: u32,
#[serde(default)]
pub static_root: Option<String>,
#[serde(default)]
pub raw_store_db_path: Option<String>,
pub steps: Vec<CirSequenceStep>,
}
impl CirSequenceManifest {
pub fn validate(&self) -> Result<(), String> {
if self.version == 0 {
return Err("sequence.version must be positive".to_string());
}
let backend_count = self.static_root.is_some() as u8 + self.raw_store_db_path.is_some() as u8;
if backend_count != 1 {
return Err("sequence must set exactly one of static_root or raw_store_db_path".to_string());
}
match (self.static_root.as_ref(), self.raw_store_db_path.as_ref()) {
(Some(static_root), None) if !static_root.trim().is_empty() => {}
(None, Some(raw_store_db_path)) if !raw_store_db_path.trim().is_empty() => {}
_ => {
return Err("sequence backend path must not be empty".to_string());
}
}
if self.steps.is_empty() {
return Err("sequence.steps must not be empty".to_string());
}
let mut previous_ids = std::collections::BTreeSet::new();
for (idx, step) in self.steps.iter().enumerate() {
if step.step_id.trim().is_empty() {
return Err(format!("sequence.steps[{idx}].step_id must not be empty"));
}
if !previous_ids.insert(step.step_id.clone()) {
return Err(format!("sequence.steps[{idx}].step_id must be unique"));
}
if step.validation_time.trim().is_empty() {
return Err(format!(
"sequence.steps[{idx}].validation_time must not be empty"
));
}
if let Some(timing_path) = &step.timing_path
&& timing_path.trim().is_empty()
{
return Err(format!(
"sequence.steps[{idx}].timing_path must not be empty when set"
));
}
if let Some(stdout_log_path) = &step.stdout_log_path
&& stdout_log_path.trim().is_empty()
{
return Err(format!(
"sequence.steps[{idx}].stdout_log_path must not be empty when set"
));
}
if let Some(stderr_log_path) = &step.stderr_log_path
&& stderr_log_path.trim().is_empty()
{
return Err(format!(
"sequence.steps[{idx}].stderr_log_path must not be empty when set"
));
}
if let Some(artifact_prefix) = &step.artifact_prefix
&& artifact_prefix.trim().is_empty()
{
return Err(format!(
"sequence.steps[{idx}].artifact_prefix must not be empty when set"
));
}
if step.cir_path.trim().is_empty()
|| step.ccr_path.trim().is_empty()
|| step.report_path.trim().is_empty()
{
return Err(format!(
"sequence.steps[{idx}] output paths must not be empty"
));
}
match step.kind {
CirSequenceStepKind::Full => {
if idx != 0 {
return Err("full step must be the first step".to_string());
}
if step.previous_step_id.is_some() {
return Err("full step must not reference previous_step_id".to_string());
}
}
CirSequenceStepKind::Delta => {
if idx == 0 {
return Err("delta step cannot be the first step".to_string());
}
let previous = step.previous_step_id.as_ref().ok_or_else(|| {
format!("sequence.steps[{idx}] delta step must set previous_step_id")
})?;
if !previous_ids.contains(previous) {
return Err(format!(
"sequence.steps[{idx}] previous_step_id must reference an earlier step"
));
}
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{CirSequenceManifest, CirSequenceStep, CirSequenceStepKind};
fn sample_manifest() -> CirSequenceManifest {
CirSequenceManifest {
version: 1,
static_root: Some("static".to_string()),
raw_store_db_path: None,
steps: vec![
CirSequenceStep {
step_id: "full".to_string(),
kind: CirSequenceStepKind::Full,
validation_time: "2026-04-09T00:00:00Z".to_string(),
cir_path: "full/input.cir".to_string(),
ccr_path: "full/result.ccr".to_string(),
report_path: "full/report.json".to_string(),
timing_path: Some("full/timing.json".to_string()),
stdout_log_path: Some("full/stdout.log".to_string()),
stderr_log_path: Some("full/stderr.log".to_string()),
artifact_prefix: Some("2026-04-09T00:00:00Z-test".to_string()),
previous_step_id: None,
},
CirSequenceStep {
step_id: "delta-001".to_string(),
kind: CirSequenceStepKind::Delta,
validation_time: "2026-04-09T00:10:00Z".to_string(),
cir_path: "delta-001/input.cir".to_string(),
ccr_path: "delta-001/result.ccr".to_string(),
report_path: "delta-001/report.json".to_string(),
timing_path: Some("delta-001/timing.json".to_string()),
stdout_log_path: Some("delta-001/stdout.log".to_string()),
stderr_log_path: Some("delta-001/stderr.log".to_string()),
artifact_prefix: Some("2026-04-09T00:10:00Z-test".to_string()),
previous_step_id: Some("full".to_string()),
},
],
}
}
#[test]
fn sequence_manifest_validate_accepts_minimal_chain() {
sample_manifest().validate().expect("valid sequence");
}
#[test]
fn sequence_manifest_validate_rejects_bad_order_and_duplicates() {
let mut bad = sample_manifest();
bad.steps.swap(0, 1);
let err = bad.validate().expect_err("full must be first");
assert!(
err.contains("delta step cannot be the first step")
|| err.contains("full step must be the first step")
);
let mut dup = sample_manifest();
dup.steps[1].step_id = "full".to_string();
let err = dup.validate().expect_err("duplicate id must fail");
assert!(err.contains("must be unique"));
}
#[test]
fn sequence_manifest_validate_rejects_missing_previous_reference() {
let mut bad = sample_manifest();
bad.steps[1].previous_step_id = Some("missing".to_string());
let err = bad.validate().expect_err("missing previous must fail");
assert!(err.contains("previous_step_id"));
}
#[test]
fn sequence_manifest_validate_accepts_raw_store_backend() {
let mut manifest = sample_manifest();
manifest.static_root = None;
manifest.raw_store_db_path = Some("raw-store.db".to_string());
manifest.validate().expect("raw store sequence");
}
}

377
src/cir/static_pool.rs Normal file
View File

@@ -0,0 +1,377 @@
use std::collections::BTreeSet;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::blob_store::RawObjectStore;
use crate::storage::{RawByHashEntry, RocksStore};
#[derive(Debug, thiserror::Error)]
pub enum CirStaticPoolError {
#[error("invalid sha256 hex: {0}")]
InvalidSha256Hex(String),
#[error("raw bytes are empty for sha256={sha256_hex}")]
EmptyBytes { sha256_hex: String },
#[error("raw bytes do not match sha256 hex: {sha256_hex}")]
HashMismatch { sha256_hex: String },
#[error("create directory failed: {path}: {detail}")]
CreateDir { path: String, detail: String },
#[error("create temp file failed: {path}: {detail}")]
CreateTemp { path: String, detail: String },
#[error("write temp file failed: {path}: {detail}")]
WriteTemp { path: String, detail: String },
#[error("sync temp file failed: {path}: {detail}")]
SyncTemp { path: String, detail: String },
#[error("publish temp file failed: {temp_path} -> {final_path}: {detail}")]
Publish {
temp_path: String,
final_path: String,
detail: String,
},
#[error("remove temp file failed: {path}: {detail}")]
RemoveTemp { path: String, detail: String },
#[error("raw_by_hash entry missing for sha256={sha256_hex}")]
MissingRawByHash { sha256_hex: String },
#[error("storage error: {0}")]
Storage(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CirStaticPoolWriteResult {
pub final_path: PathBuf,
pub written: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CirStaticPoolExportSummary {
pub unique_hashes: usize,
pub written_files: usize,
pub reused_files: usize,
}
pub fn static_pool_relative_path(
capture_date_utc: time::Date,
sha256_hex: &str,
) -> Result<PathBuf, CirStaticPoolError> {
validate_sha256_hex(sha256_hex)?;
let date = format_utc_date(capture_date_utc);
Ok(PathBuf::from(date)
.join(&sha256_hex[0..2])
.join(&sha256_hex[2..4])
.join(sha256_hex))
}
pub fn static_pool_path(
static_root: &Path,
capture_date_utc: time::Date,
sha256_hex: &str,
) -> Result<PathBuf, CirStaticPoolError> {
Ok(static_root.join(static_pool_relative_path(capture_date_utc, sha256_hex)?))
}
pub fn write_bytes_to_static_pool(
static_root: &Path,
capture_date_utc: time::Date,
sha256_hex_value: &str,
bytes: &[u8],
) -> Result<CirStaticPoolWriteResult, CirStaticPoolError> {
validate_sha256_hex(sha256_hex_value)?;
if bytes.is_empty() {
return Err(CirStaticPoolError::EmptyBytes {
sha256_hex: sha256_hex_value.to_string(),
});
}
let computed = compute_sha256_hex(bytes);
if computed != sha256_hex_value.to_ascii_lowercase() {
return Err(CirStaticPoolError::HashMismatch {
sha256_hex: sha256_hex_value.to_string(),
});
}
let final_path = static_pool_path(static_root, capture_date_utc, sha256_hex_value)?;
if final_path.exists() {
return Ok(CirStaticPoolWriteResult {
final_path,
written: false,
});
}
let parent = final_path.parent().expect("static pool file has parent");
fs::create_dir_all(parent).map_err(|e| CirStaticPoolError::CreateDir {
path: parent.display().to_string(),
detail: e.to_string(),
})?;
let temp_path = parent.join(format!("{sha256_hex_value}.tmp.{}", uuid::Uuid::new_v4()));
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temp_path)
.map_err(|e| CirStaticPoolError::CreateTemp {
path: temp_path.display().to_string(),
detail: e.to_string(),
})?;
file.write_all(bytes)
.map_err(|e| CirStaticPoolError::WriteTemp {
path: temp_path.display().to_string(),
detail: e.to_string(),
})?;
file.sync_all().map_err(|e| CirStaticPoolError::SyncTemp {
path: temp_path.display().to_string(),
detail: e.to_string(),
})?;
drop(file);
match fs::hard_link(&temp_path, &final_path) {
Ok(()) => {
fs::remove_file(&temp_path).map_err(|e| CirStaticPoolError::RemoveTemp {
path: temp_path.display().to_string(),
detail: e.to_string(),
})?;
Ok(CirStaticPoolWriteResult {
final_path,
written: true,
})
}
Err(e) if final_path.exists() => {
fs::remove_file(&temp_path).map_err(|remove_err| CirStaticPoolError::RemoveTemp {
path: temp_path.display().to_string(),
detail: remove_err.to_string(),
})?;
let _ = e;
Ok(CirStaticPoolWriteResult {
final_path,
written: false,
})
}
Err(e) => Err(CirStaticPoolError::Publish {
temp_path: temp_path.display().to_string(),
final_path: final_path.display().to_string(),
detail: e.to_string(),
}),
}
}
pub fn write_raw_entry_to_static_pool(
static_root: &Path,
capture_date_utc: time::Date,
entry: &RawByHashEntry,
) -> Result<CirStaticPoolWriteResult, CirStaticPoolError> {
write_bytes_to_static_pool(
static_root,
capture_date_utc,
&entry.sha256_hex,
&entry.bytes,
)
}
pub fn export_hashes_from_store(
store: &RocksStore,
static_root: &Path,
capture_date_utc: time::Date,
sha256_hexes: &[String],
) -> Result<CirStaticPoolExportSummary, CirStaticPoolError> {
let unique: BTreeSet<String> = sha256_hexes
.iter()
.map(|item| item.to_ascii_lowercase())
.collect();
let mut written_files = 0usize;
let mut reused_files = 0usize;
for sha256_hex in &unique {
let bytes = store
.get_blob_bytes(sha256_hex)
.map_err(|e| CirStaticPoolError::Storage(e.to_string()))?
.ok_or_else(|| CirStaticPoolError::MissingRawByHash {
sha256_hex: sha256_hex.clone(),
})?;
let entry = RawByHashEntry::from_bytes(sha256_hex.clone(), bytes);
let result = write_raw_entry_to_static_pool(static_root, capture_date_utc, &entry)?;
if result.written {
written_files += 1;
} else {
reused_files += 1;
}
}
Ok(CirStaticPoolExportSummary {
unique_hashes: unique.len(),
written_files,
reused_files,
})
}
fn format_utc_date(date: time::Date) -> String {
format!(
"{:04}{:02}{:02}",
date.year(),
u8::from(date.month()),
date.day()
)
}
fn validate_sha256_hex(sha256_hex: &str) -> Result<(), CirStaticPoolError> {
if sha256_hex.len() != 64 || !sha256_hex.as_bytes().iter().all(u8::is_ascii_hexdigit) {
return Err(CirStaticPoolError::InvalidSha256Hex(sha256_hex.to_string()));
}
Ok(())
}
fn compute_sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(bytes))
}
#[cfg(test)]
mod tests {
use super::{
CirStaticPoolError, compute_sha256_hex, export_hashes_from_store,
static_pool_relative_path, write_bytes_to_static_pool,
};
use crate::storage::{RawByHashEntry, RepositoryViewEntry, RepositoryViewState, RocksStore};
use std::fs;
fn sample_date() -> time::Date {
time::Date::from_calendar_date(2026, time::Month::April, 7).unwrap()
}
#[test]
fn static_pool_relative_path_uses_date_and_hash_prefixes() {
let path = static_pool_relative_path(
sample_date(),
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
)
.expect("relative path");
assert_eq!(
path,
std::path::PathBuf::from("20260407")
.join("ab")
.join("cd")
.join("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789")
);
}
#[test]
fn write_bytes_to_static_pool_is_idempotent_and_leaves_no_temp_files() {
let td = tempfile::tempdir().expect("tempdir");
let bytes = b"static-pool-object";
let sha = compute_sha256_hex(bytes);
let first =
write_bytes_to_static_pool(td.path(), sample_date(), &sha, bytes).expect("first write");
let second = write_bytes_to_static_pool(td.path(), sample_date(), &sha, bytes)
.expect("second write");
assert!(first.written);
assert!(!second.written);
assert_eq!(fs::read(&first.final_path).expect("read final"), bytes);
let all_files: Vec<_> = walk_files(td.path());
assert_eq!(all_files.len(), 1);
assert!(
!all_files[0]
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.contains(".tmp.")
);
}
#[test]
fn write_bytes_to_static_pool_rejects_bad_hash_and_empty_bytes() {
let td = tempfile::tempdir().expect("tempdir");
let err = write_bytes_to_static_pool(td.path(), sample_date(), "not-a-hash", b"x")
.expect_err("bad hash must fail");
assert!(matches!(err, CirStaticPoolError::InvalidSha256Hex(_)));
let err = write_bytes_to_static_pool(
td.path(),
sample_date(),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
b"",
)
.expect_err("empty bytes must fail");
assert!(matches!(err, CirStaticPoolError::EmptyBytes { .. }));
}
#[test]
fn export_hashes_from_store_writes_unique_entries_and_fails_when_missing() {
let td = tempfile::tempdir().expect("tempdir");
let store_dir = td.path().join("db");
let static_root = td.path().join("static");
let store = RocksStore::open(&store_dir).expect("open rocksdb");
let bytes = b"store-object".to_vec();
let sha = compute_sha256_hex(&bytes);
let mut entry = RawByHashEntry::from_bytes(sha.clone(), bytes.clone());
entry
.origin_uris
.push("rsync://example.test/repo/object.cer".to_string());
store.put_raw_by_hash_entry(&entry).expect("put raw entry");
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/object.cer".to_string(),
current_hash: Some(sha.clone()),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("cer".to_string()),
state: RepositoryViewState::Present,
})
.expect("put repository view");
let summary = export_hashes_from_store(
&store,
&static_root,
sample_date(),
&[sha.clone(), sha.clone()],
)
.expect("export hashes");
assert_eq!(summary.unique_hashes, 1);
assert_eq!(summary.written_files, 1);
assert_eq!(summary.reused_files, 0);
let summary = export_hashes_from_store(&store, &static_root, sample_date(), &[sha.clone()])
.expect("re-export hashes");
assert_eq!(summary.unique_hashes, 1);
assert_eq!(summary.written_files, 0);
assert_eq!(summary.reused_files, 1);
let err = export_hashes_from_store(
&store,
&static_root,
sample_date(),
&[String::from(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
)],
)
.expect_err("missing raw_by_hash must fail");
assert!(matches!(err, CirStaticPoolError::MissingRawByHash { .. }));
}
fn walk_files(root: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(path) = stack.pop() {
for entry in fs::read_dir(path).expect("read_dir") {
let entry = entry.expect("dir entry");
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else {
out.push(path);
}
}
}
out.sort();
out
}
}

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