18 Commits

Author SHA1 Message Date
05650b23bf 20260903 加固 RRDP RFC9674 同源边界 2026-09-03 12:10:45 +08:00
74cbebbd33 20260828 收口Feature151 TA constraints优化与验证 2026-08-28 17:42:04 +08:00
b59adc65a8 20260826 收口Feature149实验工具 2026-08-26 14:49:31 +08:00
ea3f0d5dde 20260826 增加BER兼容告警与分类指标 2026-08-26 13:23:33 +08:00
46fb5e5b99 20260818 fe148 ours RP镜像统一panda-rpki命名:VERSION单一版本来源v1.3.0,镜像tag语义化,release出包强制HEAD已打同版git tag(#148) 2026-08-18 11:53:47 +08:00
fafe96ceb3 20260817 fe147 installer原生支持custom TAL模式:RIRS=custom+TAL_INPUT_MODE=custom-file-with-ta+CUSTOM_*全套env,compose挂载fixtures目录,start.sh支持CUSTOM_REPO_NETWORK无竞态接入 2026-08-17 15:31:20 +08:00
49172b57d3 20260817 同步层HTTP User-Agent改为env可配置RPKI_HTTP_USER_AGENT,默认panda-rpki/0.2 2026-08-17 11:39:22 +08:00
e98eb6d68f 20260803 死repo传输层黑名单+daemon健康检查摘除(#141) 2026-08-03 21:17:19 +08:00
f37fe8c4cb 20260802 delta wall优化:输出段全并行+归约尾段分片并行与打点(#139) 2026-08-02 13:24:46 +08:00
3d7e18a238 20260801 并行控制面:stage worker池+CurrentRepoIndex RwLock化,--control-plane-stage-workers开关默认关闭(#138) 2026-08-01 20:37:53 +08:00
f6a2d3a593 20260731 完善verification-only 回放 (#118/#137) 2026-07-31 23:04:16 +08:00
f7f80fe684 20260728_2 修复rpki-explorer体验问题7项(#134):证书资源可展开、CMS对象新增EE Parsed Tab、Overview增加repo数KPI/修复Object types标签/Top repositories按objects排序(后端sort参数)/reject reasons按前缀聚类可下钻、Run选择器30s轮询 2026-07-28 13:21:58 +08:00
0d6bbec9f3 20260728 修复Explorer Reject reasons统计语义:reason_counts仅统计error对象detail(#133) 2026-07-28 10:32:18 +08:00
7d5454a729 20260727 修复缓存回放丢失reject reason(#132):缓存artifact增加reject_reason字段,fresh验证记录真实原因,VCIR/PP cache回放还原,旧缓存给后备文案 2026-07-27 17:29:26 +08:00
cf68abf642 20260726 修复rpki-explorer体验问题32项(#130):RocksDB max_open_files+compose ulimits、objects列表流式早停+scope前置(每页2.85s->10ms级)、未知/过期run统一404、runs倒序、导出文件名Content-Disposition、路径段percent_decode、nginx /api/v1代理与400回SPA;前端serialNumber schema、30s请求超时、reason筛选、组合VRP查询、导出反馈与repo/PP入口、键盘可达、cursor进URL、分页/对比度/skip-link等体验项 2026-07-27 13:05:56 +08:00
001f13209f 20260723 修复 explorer 新发布对象 parsed 不可用(#128 M8):repo-bytes.db 只读打开冻结于启动时刻,watch 模式改用 rocksdb secondary 并在 watcher 循环周期 catch-up;blob_store 新增 open_as_secondary/try_catch_up_with_primary 及 live primary 场景单测 2026-07-24 11:15:16 +08:00
b5c033426e 20260723 新增 rpki-explorer 单容器部署资产并修复 parsed 投影渲染(#128):deploy/rpki-explorer 全套独立 compose 部署资产(对接 231 在跑 soak、retain 10 runs、SSH 转发访问);ProjectionView 按真实 API 结构解包 projection.object、证书走 resourceCertificate+extensions、CRL 读顶层字段并新增 IP/AS 资源格式化;e2e fixtures 对齐真实嵌套结构并新增证书/CRL parsed 覆盖 2026-07-23 17:59:11 +08:00
3d772f748c 20260722 新增密码学签名验证缓存(#126):5个验签收口点接入独立缓存,observe-only/enable双开关默认关闭,消融脚本新增crypto-sig等case set,verification contract补crypto_signature字段 2026-07-22 12:14:13 +08:00
132 changed files with 16013 additions and 900 deletions

1
VERSION Normal file
View File

@@ -0,0 +1 @@
v1.3.0

View File

@@ -39,14 +39,36 @@ RETAIN_RUNS=100
# TAL/TA input mode:
# file-with-ta: use packaged fixture TAL + TA only.
# file-live-ta: use packaged fixture TAL; snapshot waits for live TA refresh, delta refreshes TA in background.
# custom-file-with-ta: use the custom TAL/TA below (requires exactly RIRS=custom);
# for the local test repo compose component or a third-party test TA.
# url: pass TAL URL to child process.
TAL_INPUT_MODE=file-live-ta
LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1
LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS=15
LIVE_TA_REFRESH_MAX_TIME_SECS=120
# Custom TAL/TA mode (only active with RIRS=custom + TAL_INPUT_MODE=custom-file-with-ta).
# CUSTOM_FIXTURE_HOST_DIR is the host directory bind-mounted to /opt/ours-rp/custom-fixtures.
# CUSTOM_TAL_URI is recorded in CIR as the TAL origin URI.
# HTTP_ROOT_CERT_PATHS lists HTTPS TLS root certificates (space-separated, container
# paths) for self-signed RRDP/TAL servers; it is independent of the RPKI TA.
#RIRS=custom
#TAL_INPUT_MODE=custom-file-with-ta
#CUSTOM_FIXTURE_HOST_DIR=../custom-fixtures
#CUSTOM_TAL_PATH=/opt/ours-rp/custom-fixtures/tal/custom.tal
#CUSTOM_TA_PATH=/opt/ours-rp/custom-fixtures/ta/custom-ta.cer
#CUSTOM_TAL_URI=https://rpki-local-rrdp:8443/tal/custom.tal
#HTTP_ROOT_CERT_PATHS=/opt/ours-rp/custom-fixtures/certs/rrdp-ca.pem
# Optional shared docker network between the soak container and the stack local
# test repository component. When set, scripts/start.sh attaches the soak
# container to this external network before starting it.
#CUSTOM_REPO_NETWORK=rpki-local-repo-net
# Sync and runtime behavior.
RSYNC_SCOPE=module-root
# HTTP User-Agent for all outgoing HTTP requests (RRDP / TAL / TA / dead-repo probes).
# Unset, empty, or header-illegal values fall back to panda-rpki/0.2.
#RPKI_HTTP_USER_AGENT=panda-rpki/0.2
DISABLE_COMPETING_RPS=0
RUN_ROOT=/var/lib/ours-rp
DB_DIR=/var/lib/ours-rp/state/db

View File

@@ -11,8 +11,10 @@ services:
RUN_ROOT: /var/lib/ours-rp
BIN_DIR: /opt/ours-rp/bin
FIXTURE_DIR: /opt/ours-rp/fixtures
CUSTOM_FIXTURE_DIR: /opt/ours-rp/custom-fixtures
volumes:
- ../.env:/opt/ours-rp/.env:ro
- ${CUSTOM_FIXTURE_HOST_DIR:-../custom-fixtures}:/opt/ours-rp/custom-fixtures:ro
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/state:/var/lib/ours-rp/state
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/runs:/var/lib/ours-rp/runs
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/logs:/var/lib/ours-rp/logs

View File

@@ -1108,6 +1108,85 @@
],
"title": "CCR Format Check Results",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"decimals": 0,
"min": 0,
"unit": "none"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "ber_compatible_cms_encoding"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "orange",
"mode": "fixed"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "unclassified"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "gray",
"mode": "fixed"
}
}
]
}
]
},
"gridPos": {
"x": 0,
"y": 68,
"w": 24,
"h": 8
},
"id": 24,
"options": {
"legend": {
"calcs": [
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "sum by (warning_category) (ours_rp_warnings_by_category)",
"legendFormat": "{{warning_category}}",
"refId": "A"
}
],
"title": "Warnings by Category",
"type": "timeseries"
}
],
"refresh": "5s",
@@ -1128,6 +1207,6 @@
"timezone": "browser",
"title": "Ours RP Soak Overview",
"uid": "ours-rp-soak-overview",
"version": 4,
"version": 5,
"weekStart": ""
}

View File

@@ -0,0 +1,19 @@
# custom-fixtures 占位目录
默认空目录,仅作为 `compose/docker-compose.yml` 中
`${CUSTOM_FIXTURE_HOST_DIR:-../custom-fixtures}` 挂载的缺省目标,保证
RIR 默认模式下 compose 不因缺目录而失败。
启用 custom TAL 模式(`RIRS=custom` + `TAL_INPUT_MODE=custom-file-with-ta`)时,
把 `CUSTOM_FIXTURE_HOST_DIR` 指向包含以下结构的主机目录:
```text
<fixture-root>/
├── tal/custom.tal # 自定义 TAL
├── ta/custom-ta.cer # 自定义 TA DER
└── certs/rrdp-ca.pem # 自签 HTTPS 服务的 TLS root cert(如需要)
```
本仓库的 `rpki_local_repo_installer` 组件会在其 `$HOST_DATA_DIR/fixtures`
下生成完全兼容的目录结构;stack installer 的 `STACK_TA_MODE=local` 会自动
完成该指向。

View File

@@ -54,6 +54,12 @@ Defaults:
- `ALLOW_CROSS_ARCH=0`
- `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`, which mounts an empty fallback directory by default; after deploying a separate RTR service, register its same-host report directory through `scripts/register_rtr_monitor.sh`
To integrate a local or third-party test TA, use the custom TAL mode
(`RIRS=custom` + `TAL_INPUT_MODE=custom-file-with-ta` together with
`CUSTOM_TAL_PATH` / `CUSTOM_TA_PATH` / `CUSTOM_TAL_URI` /
`HTTP_ROOT_CERT_PATHS` and the `CUSTOM_FIXTURE_HOST_DIR` mount); see the
commented section in `.env.example` for details.
## First Start Semantics
If there is no successful run under `HOST_DATA_DIR/runs`, `start.sh` starts the core `ours-rp-soak` service first and waits for the first snapshot to succeed before starting metrics, Prometheus and Grafana.

View File

@@ -54,6 +54,11 @@ vim .env
- `ALLOW_CROSS_ARCH=0`
- `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`,默认挂载空目录;独立 RTR 服务部署完成后,使用 `scripts/register_rtr_monitor.sh` 注册同机 report 目录
如需对接本地或第三方测试 TA,可使用 custom TAL 模式(`RIRS=custom` +
`TAL_INPUT_MODE=custom-file-with-ta`,配合 `CUSTOM_TAL_PATH` / `CUSTOM_TA_PATH` /
`CUSTOM_TAL_URI` / `HTTP_ROOT_CERT_PATHS` 与 `CUSTOM_FIXTURE_HOST_DIR` 挂载),
配置项说明见 `.env.example` 中的注释段。
## 首次启动语义
如果 `HOST_DATA_DIR/runs` 下没有成功 run,`start.sh` 会先启动核心 `ours-rp-soak`,等待第一轮 snapshot 成功后再启动 metrics、Prometheus 和 Grafana。

View File

@@ -47,7 +47,21 @@ if has_success_run; then
fi
log "starting core soak service"
compose_cmd --profile core up -d ours-rp-soak
if [[ -n "${CUSTOM_REPO_NETWORK:-}" ]]; then
# Custom TAL mode against the stack local test repository: create the soak
# container without starting it, attach the shared external network first,
# then start it. This avoids the first snapshot racing the network connect.
compose_cmd --profile core up -d --no-start ours-rp-soak 2>/dev/null \
|| compose_cmd --profile core create ours-rp-soak
soak_container="${COMPOSE_PROJECT_NAME:-ours-rp-package-installer}-soak"
if ! docker inspect -f '{{json .NetworkSettings.Networks}}' "$soak_container" | grep -q "\"$CUSTOM_REPO_NETWORK\""; then
log "attaching soak container to custom repo network: $CUSTOM_REPO_NETWORK"
docker network connect "$CUSTOM_REPO_NETWORK" "$soak_container"
fi
compose_cmd --profile core start ours-rp-soak
else
compose_cmd --profile core up -d ours-rp-soak
fi
if [[ "$had_success" == "0" && "$WAIT_FIRST_RUN" == "1" ]]; then
log "no previous successful run found; waiting for first run timeout=${timeout_secs}s"

View File

@@ -0,0 +1,22 @@
# rpki-explorer standalone stack configuration.
# Copy to .env and adjust paths for the target host.
# Soak outputs on the host (mounted read-only).
SOAK_RUNS_DIR=/root/rpki096_x86_rtr_installer/data/runs
SOAK_DB_DIR=/root/rpki096_x86_rtr_installer/data/state/db
# Host directory where the query-db index is persisted (reused across restarts).
QUERY_DB_DIR=/root/rpki128_explorer/query-db
# Host loopback port the explorer is published on (SSH port-forward access).
LISTEN_PORT=9517
# Keep only the latest N indexed runs; first boot backfills the latest N runs.
RETAIN_RUNS=10
# Watch poll interval for newly completed runs.
WATCH_INTERVAL_SECS=10
# Image coordinates (built locally, transferred via docker save/load).
IMAGE_NAME=rpki-explorer
IMAGE_TAG=local

View File

@@ -0,0 +1,68 @@
# rpki-explorer all-in-one image:
# - rpki_query_service (watch mode) + rpki_query_indexer
# - nginx serving the explorer SPA and proxying /api/v1/ to the query service
#
# Build context must be the repository root (rpki_2/rpki):
# docker build -f deploy/rpki-explorer/Dockerfile -t rpki-explorer:<tag> .
#
# Note: no `# syntax=` directive on purpose — the default images below are all
# expected to be present locally, and pulling the dockerfile frontend from
# docker.io may not be possible on offline build hosts.
ARG RUST_BUILDER_IMAGE=rust:1-bookworm
ARG NODE_BUILDER_IMAGE=node:slim
ARG RUNTIME_IMAGE=nginx:1.27-bookworm
FROM ${RUST_BUILDER_IMAGE} AS rust-builder
WORKDIR /src
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
clang \
cmake \
git \
libclang-dev \
make \
perl \
pkg-config \
python3 \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/src/target \
cargo build --release --bin rpki_query_service --bin rpki_query_indexer \
&& cp target/release/rpki_query_service target/release/rpki_query_indexer /usr/local/bin/
FROM ${NODE_BUILDER_IMAGE} AS ui-builder
WORKDIR /ui
COPY ui/rpki-explorer/package.json ui/rpki-explorer/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY ui/rpki-explorer/ ./
RUN npm run build
FROM ${RUNTIME_IMAGE}
ARG GIT_REV=unknown
LABEL org.opencontainers.image.title="rpki-explorer" \
org.opencontainers.image.revision="${GIT_REV}"
COPY --from=rust-builder /usr/local/bin/rpki_query_service /usr/local/bin/rpki_query_service
COPY --from=rust-builder /usr/local/bin/rpki_query_indexer /usr/local/bin/rpki_query_indexer
COPY --from=ui-builder /ui/dist /usr/share/nginx/html
COPY deploy/rpki-explorer/nginx.conf /etc/nginx/conf.d/default.conf
COPY deploy/rpki-explorer/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && mkdir -p /data
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,76 @@
# rpki-explorer standalone stack
One container running the RPKI Explorer web UI plus the query backend
(`rpki_query_service` in watch mode, spawning `rpki_query_indexer`), deployed
next to a running ours-RP soak. It mounts the soak outputs **read-only** and
keeps only the latest `RETAIN_RUNS` indexed runs.
```text
browser ──ssh -L──► 127.0.0.1:9517 ──► container
├─ nginx :8080 (dist + /api/v1/ proxy)
└─ rpki_query_service :9557
├─ /data/query-db (host bind mount, rw, reused on restart)
├─ /soak/db/repo-bytes.db (ro)
└─ /soak/runs (ro, watched)
```
## Prerequisites
- Docker with the compose plugin (`docker compose version`) on the target host.
- A running soak whose `runs/` directories contain `report.json` (and
`run-summary.json` once finished) — only completed runs get indexed.
## Build (local machine, same arch as the target)
```bash
deploy/rpki-explorer/build_image.sh
# tarball + provenance land in target/rpki-explorer-image/
```
The build runs entirely inside Docker (rust release binaries + vite build),
so no local toolchain version requirements apply.
## Transfer and deploy (remote host)
```bash
cat target/rpki-explorer-image/rpki-explorer-<tag>.tar.gz | ssh root@<host> 'gunzip | docker load'
ssh root@<host>
mkdir -p /root/rpki128_explorer
cd /root/rpki128_explorer
# place docker-compose.yml and .env here (see .env.example)
docker compose up -d
docker logs -f rpki-explorer
```
On first boot the entrypoint computes
`--watch-min-run-seq = latest_completed - RETAIN_RUNS + 1` so exactly the
latest `RETAIN_RUNS` completed runs are backfilled. On later boots the
existing query-db drives continuation — the index built earlier is reused, no
re-backfill happens.
## Access
```bash
ssh -L 9517:127.0.0.1:9517 root@<host>
# open http://127.0.0.1:9517
```
The stack binds loopback only; there is no authentication or TLS.
## Upgrade
Rebuild the image with a new tag, transfer and `docker load`, update
`IMAGE_TAG` in `.env`, then `docker compose up -d`. The query-db on the host
is preserved.
## Rollback / removal
```bash
docker compose down
# keep /root/rpki128_explorer/query-db to reuse the index later, or delete it
# to force a fresh backfill of the latest RETAIN_RUNS runs.
```
The soak stack is never touched; removing the explorer stack has no effect on
it.

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Build the rpki-explorer all-in-one image locally and export it as a tarball
# ready to transfer to a remote host (docker load on the other side).
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/target/rpki-explorer-image}"
IMAGE_NAME="${IMAGE_NAME:-rpki-explorer}"
GIT_REV="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
IMAGE_TAG="${IMAGE_TAG:-$GIT_REV}"
dirty_count="$(git -C "$REPO_ROOT" status --porcelain | wc -l)"
echo "[build] repo=$REPO_ROOT rev=$GIT_REV dirty_files=$dirty_count tag=$IMAGE_TAG"
docker build \
--file "$REPO_ROOT/deploy/rpki-explorer/Dockerfile" \
--build-arg "GIT_REV=$GIT_REV" \
--tag "$IMAGE_NAME:$IMAGE_TAG" \
"$REPO_ROOT"
mkdir -p "$OUT_DIR"
TARBALL="$OUT_DIR/rpki-explorer-$IMAGE_TAG.tar.gz"
docker save "$IMAGE_NAME:$IMAGE_TAG" | gzip > "$TARBALL"
IMAGE_ID="$(docker image inspect --format '{{.Id}}' "$IMAGE_NAME:$IMAGE_TAG")"
cat > "$OUT_DIR/rpki-explorer-$IMAGE_TAG.provenance.txt" <<EOF
git_rev=$GIT_REV
dirty_files=$dirty_count
build_time_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)
image=$IMAGE_NAME:$IMAGE_TAG
image_id=$IMAGE_ID
EOF
echo "[build] image tarball: $TARBALL"
echo "[build] provenance: $OUT_DIR/rpki-explorer-$IMAGE_TAG.provenance.txt"
echo "[build] transfer: cat '$TARBALL' | ssh root@<host> 'gunzip | docker load'"

View File

@@ -0,0 +1,34 @@
name: rpki-explorer
# Standalone explorer stack: one container with nginx + rpki_query_service
# (+ rpki_query_indexer spawned by the service watcher). It mounts the soak
# run root and state db read-only and persists its query-db on the host so
# container/host restarts reuse the already-built index.
services:
rpki-explorer:
image: ${IMAGE_NAME:-rpki-explorer}:${IMAGE_TAG:-local}
build:
context: ../..
dockerfile: deploy/rpki-explorer/Dockerfile
args:
GIT_REV: ${GIT_REV:-unknown}
container_name: rpki-explorer
restart: unless-stopped
# RocksDB keeps table files open across 13 column families; the default
# 1024 nofile limit is too tight under concurrent reads.
ulimits:
nofile:
soft: 65536
hard: 65536
ports:
# Loopback only: access via SSH port forwarding, e.g.
# ssh -L 9517:127.0.0.1:9517 root@<host>
- "127.0.0.1:${LISTEN_PORT:-9517}:8080"
environment:
RETAIN_RUNS: "${RETAIN_RUNS:-10}"
WATCH_INTERVAL_SECS: "${WATCH_INTERVAL_SECS:-10}"
volumes:
- "${SOAK_RUNS_DIR:?set SOAK_RUNS_DIR in .env}:/soak/runs:ro"
- "${SOAK_DB_DIR:?set SOAK_DB_DIR in .env}:/soak/db:ro"
- "${QUERY_DB_DIR:-./query-db}:/data"

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Container entrypoint: run rpki_query_service (watch mode) and nginx in one
# container. nginx serves the explorer SPA and proxies /api/v1/ to the query
# service. If either process exits, the container exits and the compose
# restart policy brings it back.
set -euo pipefail
RUNS_DIR="${RUNS_DIR:-/soak/runs}"
REPO_BYTES_DB="${REPO_BYTES_DB:-/soak/db/repo-bytes.db}"
QUERY_DB="${QUERY_DB:-/data/query-db}"
RETAIN_RUNS="${RETAIN_RUNS:-10}"
WATCH_INTERVAL_SECS="${WATCH_INTERVAL_SECS:-10}"
QUERY_LISTEN="${QUERY_LISTEN:-127.0.0.1:9557}"
log() { echo "[entrypoint] $*"; }
latest_completed_seq() {
local latest=0 dir base n
shopt -s nullglob
for dir in "$RUNS_DIR"/run_*/; do
[ -f "${dir}report.json" ] || continue
base="${dir%/}"
n="${base##*/run_}"
case "$n" in
*[!0-9]* | "") continue ;;
esac
if [ "$n" -gt "$latest" ]; then
latest="$n"
fi
done
echo "$latest"
}
mkdir -p "$(dirname "$QUERY_DB")"
ARGS=(
--query-db "$QUERY_DB"
--listen "$QUERY_LISTEN"
--watch-run-root "$RUNS_DIR"
--watch-interval-secs "$WATCH_INTERVAL_SECS"
--retain-indexed-runs "$RETAIN_RUNS"
)
if [ -e "$REPO_BYTES_DB" ]; then
ARGS+=(--repo-bytes-db "$REPO_BYTES_DB")
else
log "WARNING: repo-bytes db not found at $REPO_BYTES_DB; parsed/raw/export features will be unavailable"
fi
# Decide the watcher start point:
# - explicit WATCH_MIN_RUN_SEQ always wins;
# - on first boot (query-db does not exist yet) backfill the latest
# RETAIN_RUNS completed runs;
# - on later boots the existing query-db drives continuation
# (max(min_run_seq, latest_ready+1)), so no flag is needed.
if [ -n "${WATCH_MIN_RUN_SEQ:-}" ]; then
ARGS+=(--watch-min-run-seq "$WATCH_MIN_RUN_SEQ")
log "using explicit WATCH_MIN_RUN_SEQ=$WATCH_MIN_RUN_SEQ"
elif [ ! -e "$QUERY_DB" ]; then
latest="$(latest_completed_seq)"
if [ "$latest" -gt "$RETAIN_RUNS" ]; then
min_seq=$((latest - RETAIN_RUNS + 1))
else
min_seq=1
fi
log "first boot: latest completed run seq=$latest -> --watch-min-run-seq $min_seq (backfill $RETAIN_RUNS runs)"
ARGS+=(--watch-min-run-seq "$min_seq")
else
log "existing query-db at $QUERY_DB; resuming from indexed state"
fi
log "starting rpki_query_service ${ARGS[*]}"
rpki_query_service "${ARGS[@]}" &
QS_PID=$!
log "starting nginx"
nginx -g 'daemon off;' &
NGINX_PID=$!
shutdown() {
log "shutting down (qs=$QS_PID nginx=$NGINX_PID)"
kill -TERM "$QS_PID" 2>/dev/null || true
kill -TERM "$NGINX_PID" 2>/dev/null || true
}
trap shutdown TERM INT
wait -n "$QS_PID" "$NGINX_PID"
EXIT_CODE=$?
log "a process exited (code=$EXIT_CODE); stopping the remaining one"
shutdown
wait "$QS_PID" "$NGINX_PID" 2>/dev/null || true
exit "$EXIT_CODE"

View File

@@ -0,0 +1,48 @@
server {
listen 8080;
server_name _;
access_log /dev/stdout;
error_log /dev/stderr;
root /usr/share/nginx/html;
index index.html;
# Keep API/proxy redirects relative so port-forwarded access (ssh -L) is
# not bounced to an unreachable absolute URL.
absolute_redirect off;
port_in_redirect off;
# Hand-typed API URLs that fail to parse (e.g. raw % in a path segment)
# should land on the SPA instead of nginx's default 400 page.
error_page 400 /index.html;
# `/api/v1` without trailing slash must reach the service info endpoint
# too, not the SPA fallback.
location = /api/v1 {
proxy_pass http://127.0.0.1:9557;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# query service has no CORS headers and no authentication: the API must be
# served same-origin with the UI.
location /api/v1/ {
proxy_pass http://127.0.0.1:9557;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# SPA fallback: routing is client-side.
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -139,7 +139,7 @@ ARG SOURCE_DIRTY=unknown
ARG BUILD_TIMESTAMP_UTC=unknown
ARG RPKI_CLIENT_VERSION=9.8
LABEL org.opencontainers.image.title="ours-rp-metrics" \
LABEL org.opencontainers.image.title="panda-rpki-metrics" \
org.opencontainers.image.description="Ours RP artifact metrics image for multi-arch Docker Compose deployment" \
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \

View File

@@ -81,15 +81,24 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
;; \
esac; \
fi; \
# The public crate CDN occasionally rejects a burst of parallel fetches
# from a BuildKit worker. Keep the release build resilient without
# changing the produced binaries or their dependency graph.
export \
CARGO_NET_RETRY=10 \
CARGO_HTTP_TIMEOUT=120 \
CARGO_HTTP_MULTIPLEXING=false; \
cargo build --release --target "$target_triple" \
--bin rpki \
--bin rpki_daemon \
--bin db_stats; \
--bin db_stats \
--bin rpki_der_ber_audit; \
mkdir -p /build-out/bin; \
cp \
"target/$target_triple/release/rpki" \
"target/$target_triple/release/rpki_daemon" \
"target/$target_triple/release/db_stats" \
"target/$target_triple/release/rpki_der_ber_audit" \
/build-out/bin/
FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS runtime
@@ -124,7 +133,7 @@ ARG SOURCE_COMMIT=unknown
ARG SOURCE_DIRTY=unknown
ARG BUILD_TIMESTAMP_UTC=unknown
LABEL org.opencontainers.image.title="ours-rp-runtime" \
LABEL org.opencontainers.image.title="panda-rpki-rp" \
org.opencontainers.image.description="Ours RP runtime image for multi-arch Docker Compose deployment" \
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \

View File

@@ -5,6 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
TARGET_ARCH="${TARGET_ARCH:-}"
VERSION="${VERSION:-}"
IMAGE_TAG=""
IMAGE_TAR=""
METRICS_IMAGE=""
@@ -25,6 +26,10 @@ Usage:
Options:
--arch <arch> Target architecture: amd64|arm64
--version <vX.Y.Z> Component version for image tags and the package name.
Default: contents of the VERSION file at the repo root.
Release builds (without --allow-dirty) require a git tag
matching this version pointing at HEAD.
--prometheus-image <tag>
Prometheus image tag to record and package.
--prometheus-image-tar <path>
@@ -42,7 +47,7 @@ Options:
-h, --help Show help.
Every package rebuilds runtime and metrics images from the current HEAD. Their
tags are ours-rp-runtime-<arch>:<git8> and ours-rp-metrics-<arch>:<git8>.
tags are panda-rpki-rp-<arch>:<version> and panda-rpki-metrics-<arch>:<version>.
External runtime/metrics image tags and tar archives are intentionally rejected.
USAGE
}
@@ -72,17 +77,17 @@ safe_tag_name() {
runtime_image_tag() {
local arch="$1"
local revision="$2"
printf 'ours-rp-runtime-%s:%s\n' "$arch" "$revision"
printf 'panda-rpki-rp-%s:%s\n' "$arch" "$revision"
}
metrics_image_tag() {
local arch="$1"
local revision="$2"
printf 'ours-rp-metrics-%s:%s\n' "$arch" "$revision"
printf 'panda-rpki-metrics-%s:%s\n' "$arch" "$revision"
}
default_package_prefix() {
printf 'ours-rp-installer-%s\n' "$1"
printf 'panda-rpki-rp-installer-%s\n' "$1"
}
default_host_data_dir() {
@@ -180,6 +185,10 @@ while [[ $# -gt 0 ]]; do
}
shift 2
;;
--version)
VERSION="$2"
shift 2
;;
--image|--image-tar|--metrics-image|--metrics-image-tar)
echo "$1 is no longer supported: installer packages always rebuild runtime and metrics images from current HEAD" >&2
exit 2
@@ -254,8 +263,36 @@ will be explicitly marked dirty.
EOF
exit 2
fi
# 版本来源:--version 覆盖,默认读仓库根 VERSION 文件。release 构建要求
# HEAD 已打同版 git tag(自研组件打包前打 tag,与容器镜像 tag 保持一致)。
if [[ -z "$VERSION" ]]; then
[[ -f "$REPO_ROOT/VERSION" ]] || {
echo "missing VERSION file at $REPO_ROOT/VERSION; pass --version <vX.Y.Z> or create it" >&2
exit 2
}
VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION")"
fi
[[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "invalid version '$VERSION' (expected vX.Y.Z)" >&2
exit 2
}
GIT_TAG_AT_HEAD=""
if [[ "$ALLOW_DIRTY" != "1" ]]; then
GIT_TAG_AT_HEAD="$(git -C "$REPO_ROOT" tag --points-at HEAD | grep -Fx "$VERSION" || true)"
[[ -n "$GIT_TAG_AT_HEAD" ]] || {
cat >&2 <<EOF
refusing to build a release installer: HEAD is not tagged with '$VERSION'.
Create the tag first so the git tag matches the container image tag:
git -C $REPO_ROOT tag -a $VERSION -m "release $VERSION"
Use --allow-dirty only for development verification (skips this check).
EOF
exit 2
}
fi
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
REVISION_TOKEN="$SOURCE_COMMIT_SHORT"
REVISION_TOKEN="$VERSION"
if [[ "$SOURCE_DIRTY" == "true" ]]; then
REVISION_TOKEN="${REVISION_TOKEN}-dirty"
fi
@@ -367,6 +404,8 @@ package_schema_version=2
package_name=$package_name
created_at_utc=$TIMESTAMP
build_timestamp_utc=$TIMESTAMP
version=$VERSION
git_tag=${GIT_TAG_AT_HEAD:-}
source_commit=$SOURCE_COMMIT_FULL
source_commit_short=$SOURCE_COMMIT_SHORT
source_dirty=$SOURCE_DIRTY
@@ -419,6 +458,8 @@ EOF
cat > "$stage/PACKAGE-SUMMARY.txt" <<EOF
package_name: $package_name
version: $VERSION
git_tag: ${GIT_TAG_AT_HEAD:-}
source_commit: $SOURCE_COMMIT_FULL
source_commit_short: $SOURCE_COMMIT_SHORT
source_dirty: $SOURCE_DIRTY

View File

@@ -69,7 +69,7 @@ if [[ -z "$IMAGE_TAG" ]]; then
if [[ -n "$(git -C "$REPO_ROOT" status --short 2>/dev/null)" ]]; then
SOURCE_COMMIT_SHORT="${SOURCE_COMMIT_SHORT}-dirty"
fi
IMAGE_TAG="ours-rp-metrics-${TARGET_ARCH}:${SOURCE_COMMIT_SHORT}"
IMAGE_TAG="panda-rpki-metrics-${TARGET_ARCH}:${SOURCE_COMMIT_SHORT}"
fi
if [[ "$dockerfile_explicit" != "1" ]]; then

View File

@@ -14,6 +14,10 @@ BUILDER_NAME="${BUILDER_NAME:-default}"
INSTALL_BINFMT="${INSTALL_BINFMT:-1}"
SAVE_IMAGE="${SAVE_IMAGE:-1}"
LOAD_IMAGE="${LOAD_IMAGE:-1}"
# BuildKit's default bridge network can differ from the Docker daemon proxy
# configuration. Set BUILD_NETWORK=host when the build container needs the
# host's known-good registry connectivity.
BUILD_NETWORK="${BUILD_NETWORK:-default}"
usage() {
cat <<'USAGE'
@@ -22,7 +26,7 @@ Usage:
Options:
--arch <arch> Target architecture: amd64|arm64
--image <tag> Docker image tag (default: ours-rp-runtime-<arch>:<git8>)
--image <tag> Docker image tag (default: panda-rpki-rp-<arch>:<git8>)
--out-dir <path> Directory for docker save tar.gz (default: target/<arch>-docker)
--dockerfile <path> Dockerfile path
--builder <name> buildx builder name
@@ -69,7 +73,7 @@ platform_for_arch() {
default_image_tag() {
local arch="$1"
local commit_short="$2"
printf 'ours-rp-runtime-%s:%s\n' "$arch" "$commit_short"
printf 'panda-rpki-rp-%s:%s\n' "$arch" "$commit_short"
}
require_command() {
@@ -216,6 +220,7 @@ echo "builder_source_image: $builder_source_image"
echo "runtime_source_image: $runtime_source_image"
echo "builder_local_image: $BUILDER_IMAGE"
echo "runtime_local_image: $RUNTIME_IMAGE"
echo "build_network: $BUILD_NETWORK"
echo "source_commit: $SOURCE_COMMIT_FULL"
echo "source_dirty: $SOURCE_DIRTY"
echo "build_timestamp_utc: $BUILD_TIMESTAMP_UTC"
@@ -225,6 +230,7 @@ if [[ "$LOAD_IMAGE" == "1" ]]; then
docker buildx build \
--platform "$target_platform" \
--builder "$BUILDER_NAME" \
--network "$BUILD_NETWORK" \
--pull=false \
--load \
--build-arg BUILDKIT_INLINE_CACHE=1 \
@@ -242,6 +248,7 @@ else
docker buildx build \
--platform "$target_platform" \
--builder "$BUILDER_NAME" \
--network "$BUILD_NETWORK" \
--pull=false \
--output "type=docker,dest=$raw_tar_path" \
--build-arg BUILDKIT_INLINE_CACHE=1 \

View File

@@ -88,6 +88,9 @@ mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/fixtures" "$STAGE_DIR/scripts" "$STAGE_DIR
install -m 0755 "$SCRIPT_DIR/run_soak.sh" "$STAGE_DIR/run_soak.sh"
install -m 0755 "$SCRIPT_DIR/run_24h_soak_with_metrics.sh" "$STAGE_DIR/run_24h_soak_with_metrics.sh"
install -m 0755 "$SCRIPT_DIR/run_cache_ablation_experiment.sh" "$STAGE_DIR/scripts/soak/run_cache_ablation_experiment.sh"
install -m 0755 "$SCRIPT_DIR/run_feature149_cohort.sh" "$STAGE_DIR/scripts/soak/run_feature149_cohort.sh"
install -m 0755 "$SCRIPT_DIR/summarize_feature149_cohort.py" "$STAGE_DIR/scripts/soak/summarize_feature149_cohort.py"
install -m 0755 "$SCRIPT_DIR/render_feature149_report.py" "$STAGE_DIR/scripts/soak/render_feature149_report.py"
install -m 0755 "$SCRIPT_DIR/fixed_phase_loop.sh" "$STAGE_DIR/scripts/soak/fixed_phase_loop.sh"
install -m 0755 "$SCRIPT_DIR/hourly_soak_report.py" "$STAGE_DIR/scripts/soak/hourly_soak_report.py"
install -m 0755 "$SCRIPT_DIR/publish_remote231.sh" "$STAGE_DIR/scripts/soak/publish_remote231.sh"
@@ -118,6 +121,7 @@ done
cp -a "$REPO_ROOT/tests/fixtures/tal" "$STAGE_DIR/fixtures/"
cp -a "$REPO_ROOT/tests/fixtures/ta" "$STAGE_DIR/fixtures/"
cp -a "$REPO_ROOT/tests/fixtures/ta_constraints" "$STAGE_DIR/fixtures/"
cp -a "$REPO_ROOT/scripts/periodic" "$STAGE_DIR/scripts/"
cp -a "$REPO_ROOT/scripts/cir" "$STAGE_DIR/scripts/"
cp -a "$REPO_ROOT/scripts/inter_rp" "$STAGE_DIR/scripts/"

View File

@@ -66,6 +66,14 @@ PERIODIC_SNAPSHOT_MAX_DELTAS=100
# 每隔多少轮执行一次 db_stats --exact。设置为空或 0 表示关闭 exact 统计。
DB_STATS_EXACT_EVERY=3
# 死 repo 传输层黑名单(#141,默认关)。设置状态文件路径即开启:
# 连续 N 轮传输层抓取失败的 repo 会被拉黑并在后续轮次跳过其传输尝试;
# daemon 会按间隔对黑名单条目做健康探针,恢复后自动摘除。
# 留空表示完全关闭(行为与旧版本一致)。
#DEAD_REPO_BLACKLIST_PATH=/var/lib/ours-rp/state/dead-repo-blacklist.json
#DEAD_REPO_BLACKLIST_FAIL_THRESHOLD=3
#DEAD_REPO_HEALTH_CHECK_INTERVAL_SECS=600
# 是否开启 ours RP progress log。1 表示开启。
RPKI_PROGRESS_LOG=1
@@ -92,8 +100,21 @@ ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE=0
# 传给 rpki 子进程的额外参数。多个参数用空格分隔。
# 示例:RPKI_EXTRA_ARGS="--enable-roa-validation-cache"
# 实验性 transport 预热:RPKI_EXTRA_ARGS="--enable-transport-request-prefetch --enable-roa-validation-cache"
# TA constraints 的 RIPE 单 TA soak 示例(policy 并非默认启用):
# RIRS=ripe
# RPKI_EXTRA_ARGS="--ta-constraints ripe-ncc=${PACKAGE_ROOT}/fixtures/ta_constraints/ripe-ncc-afrinic-deny.constraints"
# all5 性能 A/B 的完整 AFRINIC IPv4 deny 示例(仅四个非 AFRINIC TA):
# RIRS=afrinic,apnic,arin,lacnic,ripe
# RPKI_EXTRA_ARGS="--ta-constraints apnic-rfc7730-https=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-full-ipv4-deny.constraints --ta-constraints arin=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-full-ipv4-deny.constraints --ta-constraints lacnic=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-full-ipv4-deny.constraints --ta-constraints ripe-ncc=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-full-ipv4-deny.constraints"
# M7 current-only AFRINIC holdings(delegated status=allocated/assigned;不含 legacy/ERX):
# RIRS=afrinic,apnic,arin,lacnic,ripe
# RPKI_EXTRA_ARGS="--ta-constraints apnic-rfc7730-https=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-current-ipv4-deny.constraints --ta-constraints arin=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-current-ipv4-deny.constraints --ta-constraints lacnic=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-current-ipv4-deny.constraints --ta-constraints ripe-ncc=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-current-ipv4-deny.constraints"
RPKI_EXTRA_ARGS=""
# 所有出站 HTTP 请求(RRDP / TAL / TA / 死 repo 探针)的 User-Agent。
# 不设置、为空或含非法 header 字符时回退默认 panda-rpki/0.2。
#RPKI_HTTP_USER_AGENT=panda-rpki/0.2
# 是否为每轮输出 timing profile 到 runs/run_xxxx/analyze/timing.json。
# 性能 profile 或打点验证时设置为 1;普通 soak 建议保持 0,避免额外开销。
RPKI_ANALYZE=0

View File

@@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""Render a self-contained final report for Feature #149."""
from __future__ import annotations
import argparse
import html
import json
import statistics
from pathlib import Path
from typing import Any
EXPECTED_COHORTS = (
"prefetch_pp_object_system",
"prefetch_pp_object_data",
"pp_object_system",
"pp_object_data",
)
def number(value: Any, digits: int = 0) -> str:
if not isinstance(value, (int, float)):
return "n/a"
return f"{value:,.{digits}f}"
def load(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"{path} is not an object")
return value
def latest(record: dict[str, Any], key: str) -> Any:
return (record.get("latestCounts") or {}).get(key)
def stats(record: dict[str, Any], key: str) -> dict[str, Any]:
value = record.get(key) or {}
return value if isinstance(value, dict) else {}
def delta_median(record: dict[str, Any]) -> float | None:
value = stats(record, "deltaWallMs").get("median")
return float(value) if isinstance(value, (int, float)) else None
def stable_median(record: dict[str, Any]) -> float | None:
value = stats(record, "stableDeltaWallMs").get("median")
return float(value) if isinstance(value, (int, float)) else None
def delta_mean(record: dict[str, Any]) -> float | None:
value = stats(record, "deltaWallMs").get("mean")
return float(value) if isinstance(value, (int, float)) else None
def stable_mean(record: dict[str, Any]) -> float | None:
value = stats(record, "stableDeltaWallMs").get("mean")
return float(value) if isinstance(value, (int, float)) else None
def percent_change(baseline: float | None, value: float | None) -> float | None:
if baseline in (None, 0) or value is None:
return None
return (value - baseline) / baseline * 100
def percent_text(baseline: float | None, value: float | None) -> str:
change = percent_change(baseline, value)
return "n/a" if change is None else f"{change:+.1f}%"
def stat_value(record: dict[str, Any], field: str, key: str) -> float | None:
value = stats(record, field).get(key)
return float(value) if isinstance(value, (int, float)) else None
def svg_chart(records: list[dict[str, Any]]) -> str:
values = [delta_median(record) or 0 for record in records]
maximum = max(values) if values else 1
width, height, left, baseline = 860, 250, 62, 212
bar_width, gap = 112, 56
bars: list[str] = []
for index, (record, value) in enumerate(zip(records, values)):
x = left + index * (bar_width + gap)
bar_height = 0 if maximum == 0 else value / maximum * 164
y = baseline - bar_height
colour = "#0f766e" if record.get("disk") == "data" else "#2563eb"
label = html.escape(str(record.get("cohortId")))
bars.append(
f'<rect x="{x}" y="{y:.1f}" width="{bar_width}" height="{bar_height:.1f}" rx="7" fill="{colour}"/>'
f'<text x="{x + bar_width / 2:.1f}" y="{y - 8:.1f}" text-anchor="middle" class="value">{number(value / 1000, 2)}s</text>'
f'<text x="{x + bar_width / 2:.1f}" y="{baseline + 20}" text-anchor="middle" class="label">{label}</text>'
)
return f'''<svg viewBox="0 0 {width} {height}" role="img" aria-label="Delta median wall clock by cohort">
<line x1="{left}" y1="{baseline}" x2="{width - 28}" y2="{baseline}" stroke="#94a3b8"/>
<text x="8" y="24" class="axis">delta median wall clock</text>
{''.join(bars)}
</svg>'''
def timeline_svg(records: list[dict[str, Any]]) -> str:
"""Render the fixed serial order and the six run slots in each cohort."""
width, row_height, top = 930, 48, 36
height = top + row_height * len(records) + 18
parts = [
f'<svg viewBox="0 0 {width} {height}" role="img" aria-label="Serial cohort timeline">',
'<text x="8" y="18" class="axis">each row: snapshot (S) then five delta runs (D1–D5); adjacent starts are 600 s apart</text>',
]
for row, record in enumerate(records):
y = top + row * row_height
label = html.escape(str(record.get("cohortId")))
parts.append(f'<text x="8" y="{y + 5}" class="label">{label}</text>')
parts.append(f'<line x1="310" y1="{y}" x2="890" y2="{y}" stroke="#cbd5e1" stroke-width="2"/>')
for index in range(6):
x = 310 + index * 116
mode = "S" if index == 0 else f"D{index}"
fill = "#2563eb" if index == 0 else "#0f766e"
parts.append(f'<circle cx="{x}" cy="{y}" r="11" fill="{fill}"/>')
parts.append(f'<text x="{x}" y="{y + 4}" text-anchor="middle" class="circle-label">{mode}</text>')
parts.append("</svg>")
return "".join(parts)
def milestone_svg() -> str:
labels = (
("M1", "data disk init"),
("M2", "package + dry-run"),
("M3", "prefetch cohorts"),
("M4", "no-prefetch cohorts"),
("M5", "evidence + report"),
)
width, y = 900, 44
parts = [f'<svg viewBox="0 0 {width} 90" role="img" aria-label="Milestone timeline"><line x1="72" y1="{y}" x2="828" y2="{y}" stroke="#cbd5e1" stroke-width="3"/>']
for index, (milestone, label) in enumerate(labels):
x = 72 + index * 189
parts.append(f'<circle cx="{x}" cy="{y}" r="17" fill="#0f766e"/><text x="{x}" y="{y + 5}" text-anchor="middle" class="circle-label">{milestone}</text><text x="{x}" y="78" text-anchor="middle" class="label">{label}</text>')
parts.append("</svg>")
return "".join(parts)
def paired_rows(records: dict[str, dict[str, Any]]) -> list[tuple[str, dict[str, Any], dict[str, Any]]]:
return [
("prefetch + PP + object", records["prefetch_pp_object_system"], records["prefetch_pp_object_data"]),
("PP + object", records["pp_object_system"], records["pp_object_data"]),
]
def prefetch_rows(records: dict[str, dict[str, Any]]) -> list[tuple[str, dict[str, Any], dict[str, Any]]]:
return [
("system disk", records["pp_object_system"], records["prefetch_pp_object_system"]),
("data disk", records["pp_object_data"], records["prefetch_pp_object_data"]),
]
def run_rows(records: list[dict[str, Any]]) -> list[tuple[str, dict[str, Any]]]:
return [(str(record.get("cohortId")), run) for record in records for run in (record.get("runs") or [])]
def all_delta_stats(records: list[dict[str, Any]]) -> dict[str, float]:
values = [
float(run["wallMs"])
for _, run in run_rows(records)
if run.get("syncMode") == "delta" and isinstance(run.get("wallMs"), (int, float))
]
if not values:
return {}
return {
"count": float(len(values)),
"min": min(values),
"max": max(values),
"mean": statistics.mean(values),
"median": statistics.median(values),
"p90": statistics.quantiles(values, n=10, method="inclusive")[8] if len(values) > 1 else values[0],
}
def conclusion_lines(indexed: dict[str, dict[str, Any]]) -> list[str]:
prefetch_system = indexed["prefetch_pp_object_system"]
prefetch_data = indexed["prefetch_pp_object_data"]
no_prefetch_system = indexed["pp_object_system"]
no_prefetch_data = indexed["pp_object_data"]
return [
f"两种磁盘的差异没有在两个 profile 上呈现同一方向:prefetch profile 的稳定 delta median 为 system {number(stable_median(prefetch_system) / 1000 if stable_median(prefetch_system) else None, 2)}s、data {number(stable_median(prefetch_data) / 1000 if stable_median(prefetch_data) else None, 2)}s(data vs system {percent_text(stable_median(prefetch_system), stable_median(prefetch_data))});无 prefetch profile 为 system {number(stable_median(no_prefetch_system) / 1000 if stable_median(no_prefetch_system) else None, 2)}s、data {number(stable_median(no_prefetch_data) / 1000 if stable_median(no_prefetch_data) else None, 2)}s({percent_text(stable_median(no_prefetch_system), stable_median(no_prefetch_data))})。",
f"在同一磁盘内,开启 transport prefetch 相对关闭 prefetch 的稳定 delta median 下降:system {percent_text(stable_median(no_prefetch_system), stable_median(prefetch_system))},data {percent_text(stable_median(no_prefetch_data), stable_median(prefetch_data))};这是本实验最稳定的方向性信号。",
f"snapshot 不与 delta 同向:system 开启 prefetch 后为 {percent_text((no_prefetch_system.get('snapshot') or {}).get('wallMs'), (prefetch_system.get('snapshot') or {}).get('wallMs'))},data 为 {percent_text((no_prefetch_data.get('snapshot') or {}).get('wallMs'), (prefetch_data.get('snapshot') or {}).get('wallMs'))};因此不能把单次 snapshot wall 解释为纯磁盘因果。",
"VRP/VAP/PP 产物量级和 warnings 随 live 发布窗口小幅变化,全部 run 成功且没有 recovery snapshot;网络同步长尾与 live 数据演进是主要限制。",
]
def write_markdown(records: list[dict[str, Any]], source_commit: str, package_sha256: str, remote_host: str, input_paths: list[Path], path: Path) -> None:
indexed = {str(record.get("cohortId")): record for record in records}
all_stats = all_delta_stats(records)
lines = [
"# Feature #149 远端系统盘/数据盘性能实验报告",
"",
f"- 远端:`{remote_host}`;源码:`{source_commit}`;portable archive SHA-256:`{package_sha256}`",
"- 结论口径:snapshot 单样本;全部 delta 为 delta1–delta5;稳定 delta 为 delta2–delta5。",
"",
"## 结论摘要",
"",
]
lines.extend(f"- {line}" for line in conclusion_lines(indexed))
lines += [
"",
"## Cohort 摘要",
"",
"| cohort | disk | profile | total elapsed | snapshot wall | all delta min/mean/median/p90/max | stable median | max RSS | latest VRP/VAP/PP/warnings |",
"| --- | --- | --- | ---: | --- | ---: | ---: | ---: | --- |",
]
for record in records:
delta = stats(record, "deltaWallMs")
lines.append(
"| {id} | {disk} | {profile} | {elapsed}s | {snapshot}ms | {min}/{mean}/{median}/{p90}/{max}ms | {stable}ms | {rss} KiB | {vrp}/{vap}/{pp}/{warnings} |".format(
id=record.get("cohortId"), disk=record.get("disk"), profile=record.get("profile"), elapsed=record.get("elapsedSeconds"),
snapshot=number((record.get("snapshot") or {}).get("wallMs"), 1), min=number(delta.get("min"), 1), mean=number(delta.get("mean"), 1),
median=number(delta.get("median"), 1), p90=number(delta.get("p90"), 1), max=number(delta.get("max"), 1), stable=number(stable_median(record), 1),
rss=number(record.get("maxRssKb")), vrp=latest(record, "vrps"), vap=latest(record, "vaps"), pp=latest(record, "publicationPoints"), warnings=latest(record, "warnings"),
)
)
lines += [
"",
"## 全部 delta 汇总",
"",
f"20 个 delta 样本:min/mean/median/p90/max = {number(all_stats.get('min'), 1)}/{number(all_stats.get('mean'), 1)}/{number(all_stats.get('median'), 1)}/{number(all_stats.get('p90'), 1)}/{number(all_stats.get('max'), 1)} ms。",
"",
"## 磁盘配对与 prefetch 对比",
"",
"| profile | system all/stable median | data all/stable median | data vs system(all / stable) |",
"| --- | ---: | ---: | ---: |",
]
for label, system, data in paired_rows(indexed):
lines.append(f"| {label} | {number(delta_median(system), 1)} / {number(stable_median(system), 1)} ms | {number(delta_median(data), 1)} / {number(stable_median(data), 1)} ms | {percent_text(delta_median(system), delta_median(data))} / {percent_text(stable_median(system), stable_median(data))} |")
lines += [
"",
"| profile | system all/stable mean | data all/stable mean | data vs system(all / stable) |",
"| --- | ---: | ---: | ---: |",
]
for label, system, data in paired_rows(indexed):
lines.append(f"| {label} | {number(delta_mean(system), 1)} / {number(stable_mean(system), 1)} ms | {number(delta_mean(data), 1)} / {number(stable_mean(data), 1)} ms | {percent_text(delta_mean(system), delta_mean(data))} / {percent_text(stable_mean(system), stable_mean(data))} |")
lines += [
"",
"| disk | no-prefetch stable median | prefetch stable median | prefetch vs no-prefetch |",
"| --- | ---: | ---: | ---: |",
]
for label, no_prefetch, prefetch in prefetch_rows(indexed):
lines.append(f"| {label} | {number(stable_median(no_prefetch), 1)} ms | {number(stable_median(prefetch), 1)} ms | {percent_text(stable_median(no_prefetch), stable_median(prefetch))} |")
lines += [
"",
"| disk | no-prefetch stable mean | prefetch stable mean | prefetch vs no-prefetch |",
"| --- | ---: | ---: | ---: |",
]
for label, no_prefetch, prefetch in prefetch_rows(indexed):
lines.append(f"| {label} | {number(stable_mean(no_prefetch), 1)} ms | {number(stable_mean(prefetch), 1)} ms | {percent_text(stable_mean(no_prefetch), stable_mean(prefetch))} |")
lines += ["", "## 逐 run 证据", "", "| cohort | run | mode | actual start UTC | wall ms | max RSS KiB | schedule lag ms | VRP/VAP/PP/warnings | artifacts |", "| --- | --- | --- | --- | ---: | ---: | ---: | --- | --- |"]
for cohort, run in run_rows(records):
lines.append(f"| {cohort} | {run.get('runId')} | {run.get('syncMode')} | {run.get('actualStartRfc3339Utc')} | {number(run.get('wallMs'), 1)} | {number(run.get('maxRssKb'))} | {number(run.get('scheduleLagMs'), 1)} | {run.get('vrps')}/{run.get('vaps')}/{run.get('publicationPoints')}/{run.get('warnings')} | {'complete' if run.get('artifactsComplete') and not run.get('missingArtifacts') else 'INCOMPLETE'} |")
lines += [
"",
"## 验收、异常与证据",
"",
"- 4 个 cohort 均为 6/6 success、首轮 snapshot 后连续五轮 delta,实际相邻启动节拍为 600 秒,`scheduleLagMs=0`;没有 recovery snapshot 或缺失核心产物。",
"- warnings 的最新值范围为 524–540(各 run 约 518–544),VRP/VAP/PP 在 live 窗口内小幅变化;这些属于可解释的 live/网络窗口变化,不是失败状态。",
"- 完整 run root 保留在远端对应系统盘或 `/data`;本地只回传四份 `cohort-summary.json/.md` 轻量证据。",
"",
"本报告由四份 `cohort-summary.json` 自动生成;由于 live RPKI 数据、网络和运行时间窗口不同,磁盘结果仅作方向性比较。",
"",
"### 本地证据",
"",
]
lines.extend(f"- `{path}`" for path in input_paths)
lines += ["", "### 远端原始证据根", ""]
for record in records:
package_root = record.get("packageRoot", "n/a")
cohort = record.get("cohortId", "unknown")
lines.append(f"- `{cohort}` package/run root:`{package_root}`;cohort summary:`{package_root}/experiments/feature149-{cohort}/cohort-summary.json`")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--cohort-summary", action="append", required=True, type=Path)
parser.add_argument("--out-html", required=True, type=Path)
parser.add_argument("--out-markdown", required=True, type=Path)
parser.add_argument("--remote-host", required=True)
parser.add_argument("--source-commit", required=True)
parser.add_argument("--package-sha256", default="see M2 package report")
args = parser.parse_args()
input_paths = list(args.cohort_summary)
records = [load(path) for path in input_paths]
indexed = {str(record.get("cohortId")): record for record in records}
if set(indexed) != set(EXPECTED_COHORTS):
raise ValueError(f"expected cohorts {EXPECTED_COHORTS}, got {tuple(indexed)}")
ordered = [indexed[name] for name in EXPECTED_COHORTS]
for record in ordered:
runs = record.get("runs") or []
if record.get("successCount") != 6 or record.get("runCount") != 6 or record.get("artifactStatus") != "complete" or len(runs) != 6:
raise ValueError(f"incomplete cohort {record.get('cohortId')}")
if runs[0].get("syncMode") != "snapshot" or any(run.get("syncMode") != "delta" for run in runs[1:]):
raise ValueError(f"unexpected sync modes in {record.get('cohortId')}")
all_stats = all_delta_stats(ordered)
cards = [
("Cohorts", "4", "all complete"),
("Runs", "24 / 24", "snapshot + 5 delta per cohort"),
("All delta median", f"{number(all_stats.get('median', 0) / 1000, 2)}s", "20 live delta samples"),
("Source", html.escape(args.source_commit), html.escape(args.remote_host)),
]
card_html = "".join(f'<article class="card"><div>{title}</div><strong>{value}</strong><small>{detail}</small></article>' for title, value, detail in cards)
cohort_rows_html = "".join(
"<tr><td>{id}</td><td>{disk}</td><td>{profile}</td><td>{elapsed}s</td><td>{snapshot} ms</td><td>{min}/{mean}/{median}/{p90}/{max} ms</td><td>{stable} ms</td><td>{rss} KiB</td><td>{vrp}</td><td>{vap}</td><td>{pp}</td><td>{warnings}</td></tr>".format(
id=html.escape(str(record.get("cohortId"))), disk=html.escape(str(record.get("disk"))), profile=html.escape(str(record.get("profile"))), elapsed=number(record.get("elapsedSeconds")),
snapshot=number((record.get("snapshot") or {}).get("wallMs"), 1), min=number(stat_value(record, "deltaWallMs", "min"), 1), mean=number(stat_value(record, "deltaWallMs", "mean"), 1),
median=number(stat_value(record, "deltaWallMs", "median"), 1), p90=number(stat_value(record, "deltaWallMs", "p90"), 1), max=number(stat_value(record, "deltaWallMs", "max"), 1), stable=number(stable_median(record), 1),
rss=number(record.get("maxRssKb")), vrp=number(latest(record, "vrps")), vap=number(latest(record, "vaps")), pp=number(latest(record, "publicationPoints")), warnings=number(latest(record, "warnings")),
)
for record in ordered
)
paired_html = "".join(
"<tr><td>{label}</td><td>{system_all} / {system_stable}</td><td>{data_all} / {data_stable}</td><td>{all_change} / {stable_change}</td></tr>".format(
label=html.escape(label), system_all=number(delta_median(system), 1), system_stable=number(stable_median(system), 1), data_all=number(delta_median(data), 1), data_stable=number(stable_median(data), 1),
all_change=percent_text(delta_median(system), delta_median(data)), stable_change=percent_text(stable_median(system), stable_median(data)),
)
for label, system, data in paired_rows(indexed)
)
paired_mean_html = "".join(
"<tr><td>{label}</td><td>{system_all} / {system_stable}</td><td>{data_all} / {data_stable}</td><td>{all_change} / {stable_change}</td></tr>".format(
label=html.escape(label), system_all=number(delta_mean(system), 1), system_stable=number(stable_mean(system), 1), data_all=number(delta_mean(data), 1), data_stable=number(stable_mean(data), 1),
all_change=percent_text(delta_mean(system), delta_mean(data)), stable_change=percent_text(stable_mean(system), stable_mean(data)),
)
for label, system, data in paired_rows(indexed)
)
prefetch_html = "".join(
"<tr><td>{label}</td><td>{no_prefetch}</td><td>{prefetch}</td><td>{change}</td></tr>".format(
label=html.escape(label), no_prefetch=number(stable_median(no_prefetch), 1), prefetch=number(stable_median(prefetch), 1), change=percent_text(stable_median(no_prefetch), stable_median(prefetch)),
)
for label, no_prefetch, prefetch in prefetch_rows(indexed)
)
prefetch_mean_html = "".join(
"<tr><td>{label}</td><td>{no_prefetch}</td><td>{prefetch}</td><td>{change}</td></tr>".format(
label=html.escape(label), no_prefetch=number(stable_mean(no_prefetch), 1), prefetch=number(stable_mean(prefetch), 1), change=percent_text(stable_mean(no_prefetch), stable_mean(prefetch)),
)
for label, no_prefetch, prefetch in prefetch_rows(indexed)
)
run_rows_html = "".join(
"<tr><td>{cohort}</td><td>{run}</td><td>{mode}</td><td>{start}</td><td>{wall}</td><td>{rss}</td><td>{lag}</td><td>{vrp}/{vap}/{pp}/{warnings}</td><td>{artifact}</td></tr>".format(
cohort=html.escape(cohort), run=html.escape(str(run.get("runId"))), mode=html.escape(str(run.get("syncMode"))), start=html.escape(str(run.get("actualStartRfc3339Utc"))), wall=number(run.get("wallMs"), 1), rss=number(run.get("maxRssKb")), lag=number(run.get("scheduleLagMs"), 1),
vrp=number(run.get("vrps")), vap=number(run.get("vaps")), pp=number(run.get("publicationPoints")), warnings=number(run.get("warnings")), artifact="complete" if run.get("artifactsComplete") and not run.get("missingArtifacts") else "INCOMPLETE",
)
for cohort, run in run_rows(ordered)
)
evidence_rows_html = "".join(
"<tr><td>{cohort}</td><td><code>{root}</code></td><td><code>{summary}</code></td></tr>".format(
cohort=html.escape(str(record.get("cohortId"))),
root=html.escape(str(record.get("packageRoot", "n/a"))),
summary=html.escape(f"{record.get('packageRoot', 'n/a')}/experiments/feature149-{record.get('cohortId')}/cohort-summary.json"),
)
for record in ordered
)
warning_values = [latest(record, "warnings") for record in ordered if isinstance(latest(record, "warnings"), (int, float))]
max_delta = max((run.get("wallMs", 0) for _, run in run_rows(ordered) if run.get("syncMode") == "delta"), default=0)
report_html = f'''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>Feature #149 性能实验报告</title>
<style>
body{{font-family:Inter,system-ui,sans-serif;margin:0;background:#f8fafc;color:#0f172a}}main{{max-width:1380px;margin:auto;padding:34px}}h1{{margin-bottom:4px}}.sub{{color:#475569}}.cards{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:26px 0}}.card{{background:white;border:1px solid #e2e8f0;border-radius:12px;padding:16px}}.card strong{{display:block;font-size:25px;margin:8px 0}}.card small{{color:#64748b}}section{{background:white;border:1px solid #e2e8f0;border-radius:12px;padding:20px;margin-top:18px;overflow:auto}}table{{border-collapse:collapse;width:100%;font-size:13px}}th,td{{padding:9px;border-bottom:1px solid #e2e8f0;text-align:left;white-space:nowrap}}th{{background:#f1f5f9}}svg{{width:100%;height:auto;min-width:760px}}.axis,.label{{font-size:12px;fill:#475569}}.value{{font-size:13px;font-weight:700;fill:#0f172a}}.circle-label{{font-size:10px;fill:#fff;font-weight:700}}.note{{color:#475569;line-height:1.6}}li{{margin:7px 0;line-height:1.5}}code{{background:#f1f5f9;padding:2px 5px;border-radius:4px}}
</style>
<main><h1>Feature #149:系统盘 / 数据盘缓存性能实验</h1><p class="sub">远端 {html.escape(args.remote_host)} · source {html.escape(args.source_commit)} · archive SHA-256 {html.escape(args.package_sha256)} · 4 cohorts / 24 serial all5 runs</p><div class="cards">{card_html}</div>
<section><h2>实验拓扑与时序</h2><p class="note">amd64 远端主机的 system cohort 将 portable package、state、数据和日志全部写在系统盘;data cohort 将对应内容全部写在 <code>/data</code>。四个 cohort 按 A-system → A-data → B-system → B-data 串行执行,每个 row 是 1 snapshot + 5 delta,相邻启动节拍 600 秒。</p>{timeline_svg(ordered)}</section>
<section><h2>Milestone timeline</h2>{milestone_svg()}<p class="note">M1–M5 均已通过:磁盘初始化、同 checksum package 部署与 dry-run、A/B 两组正式运行、证据归档和报告收口。</p></section>
<section><h2>主机、磁盘与 provenance</h2><p class="note"><code>{html.escape(args.remote_host)}</code>;system 为远端根文件系统(/dev/vda3),data 为 /data(/dev/vdb1、ext4、rpki-data)。同一 archive checksum 为 <code>{html.escape(args.package_sha256)}</code>,源码 commit 为 <code>{html.escape(args.source_commit)}</code>。对象 cache 固定包含 ROA 与 child-certificate validation cache;两 profile 的唯一 cache 差异是 transport prefetch。</p></section>
<section><h2>结论摘要</h2><ul>{''.join(f'<li>{html.escape(line)}</li>' for line in conclusion_lines(indexed))}</ul></section>
<section><h2>Delta median wall clock</h2>{svg_chart(ordered)}<p class="note">颜色:蓝色 system,青绿色 data。全体 delta 的 median 为 {number(all_stats.get('median'), 1)} ms;最大 delta 为 {number(max_delta, 1)} ms,长尾包含网络同步窗口。</p></section>
<section><h2>逐 cohort KPI</h2><table><tr><th>cohort</th><th>disk</th><th>profile</th><th>elapsed</th><th>snapshot</th><th>delta min/mean/median/p90/max</th><th>stable median</th><th>max RSS</th><th>VRP</th><th>VAP</th><th>PP</th><th>warnings</th></tr>{cohort_rows_html}</table></section>
<section><h2>磁盘配对(all delta / stable delta median)</h2><table><tr><th>profile</th><th>system ms</th><th>data ms</th><th>data vs system(all / stable)</th></tr>{paired_html}</table><p class="note">负值表示 data median 更快,正值表示更慢;snapshot 单样本另行列在 cohort KPI 表中。</p></section>
<section><h2>磁盘配对(all delta / stable delta mean)</h2><table><tr><th>profile</th><th>system ms</th><th>data ms</th><th>data vs system(all / stable)</th></tr>{paired_mean_html}</table><p class="note">这里的 mean 是对应 cohort 的 deltaWallMs 平均值;负值表示 data mean 更快。</p></section>
<section><h2>Prefetch 开关对比(stable delta median)</h2><table><tr><th>disk</th><th>no prefetch ms</th><th>prefetch ms</th><th>prefetch vs no prefetch</th></tr>{prefetch_html}</table></section>
<section><h2>Prefetch 开关对比(stable delta mean)</h2><table><tr><th>disk</th><th>no prefetch ms</th><th>prefetch ms</th><th>prefetch vs no prefetch</th></tr>{prefetch_mean_html}</table></section>
<section><h2>逐 run 原始指标</h2><table><tr><th>cohort</th><th>run</th><th>mode</th><th>actual start UTC</th><th>wall ms</th><th>max RSS KiB</th><th>schedule lag ms</th><th>VRP/VAP/PP/warnings</th><th>artifacts</th></tr>{run_rows_html}</table></section>
<section><h2>验收与异常诊断</h2><ul><li>4 个 cohort 均为 6/6 success、首轮 snapshot 后连续五轮 delta;summary 中每 run 的核心产物均完整,实际启动间隔校验为 600 秒。</li><li>最新 warnings 范围为 {number(min(warning_values) if warning_values else None)}–{number(max(warning_values) if warning_values else None)};VRP/VAP/PP 在 live 窗口内小幅变化,未出现输出塌缩或 recovery snapshot。</li><li>结果受 live RPKI 发布变化、RRDP/rsync 网络长尾和顺序时间窗口影响;不能仅凭四组 wall 值声称 IOPS 的严格因果效应。</li></ul><table><tr><th>cohort</th><th>远端 package/run root</th><th>远端 cohort summary</th></tr>{evidence_rows_html}</table><p class="note">本地输入证据为:{'; '.join(f'<code>{html.escape(str(path))}</code>' for path in input_paths)}。</p></section>
<section><h2>统计口径</h2><p class="note">snapshot 统计只包含每个 cohort 的首轮;all delta 统计包含 delta1–delta5 共 20 个样本;stable delta 统计包含 delta2–delta5 共 16 个样本。详细 cache 命中、stage timing、命令 argv、run-meta/run-summary 和完整产物仍在远端 run root。</p></section></main></html>'''
args.out_html.parent.mkdir(parents=True, exist_ok=True)
args.out_markdown.parent.mkdir(parents=True, exist_ok=True)
args.out_html.write_text(report_html, encoding="utf-8")
write_markdown(ordered, args.source_commit, args.package_sha256, args.remote_host, input_paths, args.out_markdown)
if __name__ == "__main__":
main()

View File

@@ -38,7 +38,8 @@ Environment:
EXPERIMENT_RUN_ROOT shared run root/state root; default PACKAGE_ROOT
EXPERIMENT_DIR experiment metadata output directory
CASE_RUNS delta runs per case; default 10
EXPERIMENT_CASE_SET default or cache-only; default runs the original 4-case matrix
EXPERIMENT_CASE_SET default, cache-only, crypto-sig, sig-pp-compare,
feature149-prefetch or feature149-no-prefetch
RUN_START_INTERVAL_SECS fixed start cadence for all runs; default 600
FIRST_RUN_DELAY_SECS delay before the first scheduled run; default 0
SNAPSHOT_EXTRA_ARGS extra rpki args for snapshot warmup
@@ -95,7 +96,11 @@ case_count() {
case "$EXPERIMENT_CASE_SET" in
default) printf '%s' 4 ;;
cache-only) printf '%s' 3 ;;
*) die "EXPERIMENT_CASE_SET must be default or cache-only: $EXPERIMENT_CASE_SET" ;;
crypto-sig) printf '%s' 4 ;;
sig-pp-compare) printf '%s' 2 ;;
baseline-combo) printf '%s' 2 ;;
feature149-prefetch|feature149-no-prefetch) printf '%s' 1 ;;
*) die "unknown EXPERIMENT_CASE_SET: $EXPERIMENT_CASE_SET" ;;
esac
}
@@ -108,6 +113,16 @@ case_id_for_index() {
cache-only:1) printf '%s' "pp-only" ;;
cache-only:2) printf '%s' "object-only" ;;
cache-only:3) printf '%s' "pp-object-only" ;;
crypto-sig:1) printf '%s' "baseline" ;;
crypto-sig:2) printf '%s' "sig-only" ;;
crypto-sig:3) printf '%s' "pp-object" ;;
crypto-sig:4) printf '%s' "pp-object-sig" ;;
sig-pp-compare:1) printf '%s' "sig-only" ;;
sig-pp-compare:2) printf '%s' "pp-only" ;;
baseline-combo:1) printf '%s' "baseline" ;;
baseline-combo:2) printf '%s' "pp-object-sig" ;;
feature149-prefetch:1) printf '%s' "prefetch-pp-object" ;;
feature149-no-prefetch:1) printf '%s' "pp-object" ;;
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
esac
}
@@ -121,6 +136,16 @@ case_name_for_index() {
cache-only:1) printf '%s' "pp-cache-only" ;;
cache-only:2) printf '%s' "object-cache-only" ;;
cache-only:3) printf '%s' "pp-cache-object-cache-only" ;;
crypto-sig:1) printf '%s' "all-cache-off" ;;
crypto-sig:2) printf '%s' "crypto-sig-cache-only" ;;
crypto-sig:3) printf '%s' "pp-cache-object-cache" ;;
crypto-sig:4) printf '%s' "pp-cache-object-crypto-sig-cache" ;;
sig-pp-compare:1) printf '%s' "crypto-sig-cache-only" ;;
sig-pp-compare:2) printf '%s' "pp-cache-only" ;;
baseline-combo:1) printf '%s' "all-cache-off" ;;
baseline-combo:2) printf '%s' "pp-cache-object-crypto-sig-cache" ;;
feature149-prefetch:1) printf '%s' "prefetch-pp-object-cache" ;;
feature149-no-prefetch:1) printf '%s' "pp-object-cache" ;;
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
esac
}
@@ -134,14 +159,25 @@ case_extra_args_for_index() {
cache-only:1) printf '%s' "--enable-publication-point-validation-cache" ;;
cache-only:2) printf '%s' "--enable-roa-validation-cache" ;;
cache-only:3) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache" ;;
crypto-sig:1) printf '%s' "" ;;
crypto-sig:2) printf '%s' "--enable-crypto-signature-cache" ;;
crypto-sig:3) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache" ;;
crypto-sig:4) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache --enable-crypto-signature-cache" ;;
sig-pp-compare:1) printf '%s' "--enable-crypto-signature-cache" ;;
sig-pp-compare:2) printf '%s' "--enable-publication-point-validation-cache" ;;
baseline-combo:1) printf '%s' "" ;;
baseline-combo:2) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache --enable-crypto-signature-cache" ;;
feature149-prefetch:1) printf '%s' "--enable-transport-request-prefetch --enable-publication-point-validation-cache --enable-roa-validation-cache --parallel-max-repo-sync-workers-global 4 --parallel-phase2-object-workers 4 --memory-trim-after-validation" ;;
feature149-no-prefetch:1) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache --parallel-max-repo-sync-workers-global 4 --parallel-phase2-object-workers 4 --memory-trim-after-validation" ;;
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
esac
}
case_child_cert_cache_for_index() {
case "$EXPERIMENT_CASE_SET:$1" in
default:4|cache-only:2|cache-only:3) printf '%s' "1" ;;
default:1|default:2|default:3|cache-only:1) printf '%s' "0" ;;
default:4|cache-only:2|cache-only:3|crypto-sig:3|crypto-sig:4|baseline-combo:2) printf '%s' "1" ;;
feature149-prefetch:1|feature149-no-prefetch:1) printf '%s' "1" ;;
default:1|default:2|default:3|cache-only:1|crypto-sig:1|crypto-sig:2|sig-pp-compare:1|sig-pp-compare:2|baseline-combo:1) printf '%s' "0" ;;
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
esac
}
@@ -163,6 +199,31 @@ elif case_set == "cache-only":
{"caseId": "object-only", "caseName": "object-cache-only", "extraArgs": "--enable-roa-validation-cache", "enableChildCertificateValidationCache": True},
{"caseId": "pp-object-only", "caseName": "pp-cache-object-cache-only", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache", "enableChildCertificateValidationCache": True},
]
elif case_set == "crypto-sig":
cases = [
{"caseId": "baseline", "caseName": "all-cache-off", "extraArgs": "", "enableChildCertificateValidationCache": False},
{"caseId": "sig-only", "caseName": "crypto-sig-cache-only", "extraArgs": "--enable-crypto-signature-cache", "enableChildCertificateValidationCache": False},
{"caseId": "pp-object", "caseName": "pp-cache-object-cache", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache", "enableChildCertificateValidationCache": True},
{"caseId": "pp-object-sig", "caseName": "pp-cache-object-crypto-sig-cache", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache --enable-crypto-signature-cache", "enableChildCertificateValidationCache": True},
]
elif case_set == "sig-pp-compare":
cases = [
{"caseId": "sig-only", "caseName": "crypto-sig-cache-only", "extraArgs": "--enable-crypto-signature-cache", "enableChildCertificateValidationCache": False},
{"caseId": "pp-only", "caseName": "pp-cache-only", "extraArgs": "--enable-publication-point-validation-cache", "enableChildCertificateValidationCache": False},
]
elif case_set == "baseline-combo":
cases = [
{"caseId": "baseline", "caseName": "all-cache-off", "extraArgs": "", "enableChildCertificateValidationCache": False},
{"caseId": "pp-object-sig", "caseName": "pp-cache-object-crypto-sig-cache", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache --enable-crypto-signature-cache", "enableChildCertificateValidationCache": True},
]
elif case_set == "feature149-prefetch":
cases = [
{"caseId": "prefetch-pp-object", "caseName": "prefetch-pp-object-cache", "extraArgs": "--enable-transport-request-prefetch --enable-publication-point-validation-cache --enable-roa-validation-cache --parallel-max-repo-sync-workers-global 4 --parallel-phase2-object-workers 4 --memory-trim-after-validation", "enableChildCertificateValidationCache": True},
]
elif case_set == "feature149-no-prefetch":
cases = [
{"caseId": "pp-object", "caseName": "pp-object-cache", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache --parallel-max-repo-sync-workers-global 4 --parallel-phase2-object-workers 4 --memory-trim-after-validation", "enableChildCertificateValidationCache": True},
]
else:
raise SystemExit(f"unknown case set: {case_set}")
print(json.dumps(cases, ensure_ascii=False, indent=8))
@@ -345,6 +406,7 @@ else:
"publication_point_cache_index_load",
"publication_point_cache_index_refresh",
"roa_validation_cache",
"crypto_signature_cache_observe",
]:
if key in stage:
record[key] = stage.get(key)
@@ -360,6 +422,11 @@ else:
"child_certificate_cache_miss_not_found",
"fresh_publication_points",
"fresh_manifest_files_total",
"crypto_signature_cache_observe_calls",
"crypto_signature_cache_observe_would_hit",
"crypto_signature_cache_observe_new_keys",
"crypto_signature_cache_verify_executed",
"crypto_signature_cache_verify_skipped",
]
record["cacheCounts"] = {k: analysis_counts.get(k) for k in interesting if k in analysis_counts}
record["repoSyncStats"] = summary.get("repoSyncStats")

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PACKAGE_ROOT="${PACKAGE_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
COHORT_ID="${COHORT_ID:?COHORT_ID is required}"
COHORT_DISK="${COHORT_DISK:?COHORT_DISK is required (system or data)}"
COHORT_PROFILE="${COHORT_PROFILE:?COHORT_PROFILE is required (prefetch-pp-object or pp-object)}"
RUN_START_INTERVAL_SECS="${RUN_START_INTERVAL_SECS:-600}"
CASE_RUNS="${CASE_RUNS:-5}"
DRY_RUN="${DRY_RUN:-0}"
FIRST_RUN_NOT_BEFORE_EPOCH="${FIRST_RUN_NOT_BEFORE_EPOCH:-0}"
die() {
echo "error: $*" >&2
exit 2
}
is_true() {
case "${1:-}" in
1|true|TRUE|yes|YES|on|ON) return 0 ;;
*) return 1 ;;
esac
}
case "$COHORT_DISK" in
system|data) ;;
*) die "COHORT_DISK must be system or data: $COHORT_DISK" ;;
esac
case "$COHORT_PROFILE" in
prefetch-pp-object) case_set="feature149-prefetch" ;;
pp-object) case_set="feature149-no-prefetch" ;;
*) die "COHORT_PROFILE must be prefetch-pp-object or pp-object: $COHORT_PROFILE" ;;
esac
[[ "$CASE_RUNS" == "5" ]] || die "Feature #149 requires CASE_RUNS=5, got $CASE_RUNS"
[[ "$RUN_START_INTERVAL_SECS" == "600" ]] || die "Feature #149 requires RUN_START_INTERVAL_SECS=600, got $RUN_START_INTERVAL_SECS"
[[ -x "$SCRIPT_DIR/run_cache_ablation_experiment.sh" ]] || die "missing cache ablation driver"
[[ -x "$SCRIPT_DIR/summarize_feature149_cohort.py" ]] || die "missing Feature #149 summarizer"
[[ -f "$PACKAGE_ROOT/.env" ]] || die "missing package environment: $PACKAGE_ROOT/.env"
experiment_dir="$PACKAGE_ROOT/experiments/feature149-$COHORT_ID"
summary_jsonl="$experiment_dir/experiment-summary.jsonl"
if (( FIRST_RUN_NOT_BEFORE_EPOCH > 0 )) && ! is_true "$DRY_RUN"; then
now_epoch="$(date +%s)"
if (( now_epoch < FIRST_RUN_NOT_BEFORE_EPOCH )); then
wait_secs=$(( FIRST_RUN_NOT_BEFORE_EPOCH - now_epoch ))
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] waiting ${wait_secs}s before cohort=$COHORT_ID" >&2
sleep "$wait_secs"
fi
fi
env \
PACKAGE_ROOT="$PACKAGE_ROOT" \
ENV_FILE="$PACKAGE_ROOT/.env" \
EXPERIMENT_RUN_ROOT="$PACKAGE_ROOT" \
EXPERIMENT_DIR="$experiment_dir" \
EXPERIMENT_CASE_SET="$case_set" \
CASE_RUNS="$CASE_RUNS" \
RUN_SNAPSHOT=1 \
RUN_START_INTERVAL_SECS="$RUN_START_INTERVAL_SECS" \
FIRST_RUN_DELAY_SECS=0 \
RETAIN_RUNS=6 \
DRY_RUN="$DRY_RUN" \
"$SCRIPT_DIR/run_cache_ablation_experiment.sh"
if ! is_true "$DRY_RUN"; then
"$SCRIPT_DIR/summarize_feature149_cohort.py" \
--cohort-id "$COHORT_ID" \
--cohort-disk "$COHORT_DISK" \
--cohort-profile "$COHORT_PROFILE" \
--package-root "$PACKAGE_ROOT" \
--summary-jsonl "$summary_jsonl" \
--out-dir "$experiment_dir"
fi
printf 'feature149_cohort_dir=%s\n' "$experiment_dir"

View File

@@ -36,6 +36,10 @@ DISABLE_COMPETING_RPS="${DISABLE_COMPETING_RPS:-1}"
ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE="${ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE:-0}"
RPKI_EXTRA_ARGS="${RPKI_EXTRA_ARGS:-}"
RPKI_ANALYZE="${RPKI_ANALYZE:-0}"
CUSTOM_TAL_PATH="${CUSTOM_TAL_PATH:-$PACKAGE_ROOT/custom-fixtures/tal/custom.tal}"
CUSTOM_TA_PATH="${CUSTOM_TA_PATH:-$PACKAGE_ROOT/custom-fixtures/ta/custom-ta.cer}"
CUSTOM_TAL_URI="${CUSTOM_TAL_URI:-}"
HTTP_ROOT_CERT_PATHS="${HTTP_ROOT_CERT_PATHS:-}"
BIN_DIR="${BIN_DIR:-$PACKAGE_ROOT/bin}"
FIXTURE_DIR="${FIXTURE_DIR:-$PACKAGE_ROOT/fixtures}"
@@ -119,10 +123,10 @@ validate_rsync_scope() {
validate_tal_input_mode() {
case "$TAL_INPUT_MODE" in
file-with-ta|file-live-ta|url)
file-with-ta|file-live-ta|custom-file-with-ta|url)
;;
*)
die "TAL_INPUT_MODE must be file-with-ta, file-live-ta or url: $TAL_INPUT_MODE"
die "TAL_INPUT_MODE must be file-with-ta, file-live-ta, custom-file-with-ta or url: $TAL_INPUT_MODE"
;;
esac
}
@@ -156,14 +160,46 @@ parse_rirs() {
afrinic|apnic|arin|lacnic|ripe)
RIR_LIST+=("$normalized")
;;
custom)
[[ "$TAL_INPUT_MODE" == "custom-file-with-ta" ]] \
|| die "RIRS=custom requires TAL_INPUT_MODE=custom-file-with-ta"
RIR_LIST+=("$normalized")
;;
*)
die "invalid RIRS entry: $raw_token; allowed: afrinic,apnic,arin,lacnic,ripe"
die "invalid RIRS entry: $raw_token; allowed: afrinic,apnic,arin,lacnic,ripe,custom (custom mode only)"
;;
esac
done
[[ "${#RIR_LIST[@]}" -gt 0 ]] || die "RIRS must contain at least one RIR"
}
validate_custom_inputs() {
local root_cert_path
local -a root_cert_paths=()
if [[ "$TAL_INPUT_MODE" == "custom-file-with-ta" ]]; then
[[ "${#RIR_LIST[@]}" -eq 1 && "${RIR_LIST[0]}" == "custom" ]] \
|| die "custom-file-with-ta requires exactly RIRS=custom"
[[ -s "$CUSTOM_TAL_PATH" ]] \
|| die "missing custom TAL: $CUSTOM_TAL_PATH"
[[ -s "$CUSTOM_TA_PATH" ]] \
|| die "missing custom TA DER: $CUSTOM_TA_PATH"
[[ -n "$CUSTOM_TAL_URI" ]] \
|| die "CUSTOM_TAL_URI is required in custom-file-with-ta mode"
elif [[ "$RIRS" == *custom* ]]; then
die "RIRS=custom requires TAL_INPUT_MODE=custom-file-with-ta"
fi
if [[ -n "$HTTP_ROOT_CERT_PATHS" ]]; then
# shellcheck disable=SC2206
root_cert_paths=( $HTTP_ROOT_CERT_PATHS )
for root_cert_path in "${root_cert_paths[@]}"; do
[[ -s "$root_cert_path" ]] \
|| die "missing HTTP root certificate: $root_cert_path"
done
fi
}
tal_file_for_rir() {
case "$1" in
afrinic) printf '%s' "$FIXTURE_DIR/tal/afrinic.tal" ;;
@@ -171,6 +207,7 @@ tal_file_for_rir() {
arin) printf '%s' "$FIXTURE_DIR/tal/arin.tal" ;;
lacnic) printf '%s' "$FIXTURE_DIR/tal/lacnic.tal" ;;
ripe) printf '%s' "$FIXTURE_DIR/tal/ripe-ncc.tal" ;;
custom) printf '%s' "$CUSTOM_TAL_PATH" ;;
*) die "unknown RIR: $1" ;;
esac
}
@@ -182,6 +219,7 @@ ta_file_for_rir() {
arin) printf '%s' "$FIXTURE_DIR/ta/arin-ta.cer" ;;
lacnic) printf '%s' "$FIXTURE_DIR/ta/lacnic-ta.cer" ;;
ripe) printf '%s' "$FIXTURE_DIR/ta/ripe-ncc-ta.cer" ;;
custom) printf '%s' "$CUSTOM_TA_PATH" ;;
*) die "unknown RIR: $1" ;;
esac
}
@@ -193,6 +231,7 @@ tal_url_for_rir() {
arin) printf '%s' "https://www.arin.net/resources/manage/rpki/arin.tal" ;;
lacnic) printf '%s' "https://www.lacnic.net/innovaportal/file/4983/1/lacnic.tal" ;;
ripe) printf '%s' "https://tal.rpki.ripe.net/ripe-ncc.tal" ;;
custom) printf '%s' "$CUSTOM_TAL_URI" ;;
*) die "unknown RIR: $1" ;;
esac
}
@@ -1058,6 +1097,17 @@ build_child_args() {
CHILD_ARGS+=(--rsync-mirror-root "$TMP_DIR/rsync-mirror-{run_id}")
fi
local root_cert_path
local -a root_cert_paths=()
if [[ -n "$HTTP_ROOT_CERT_PATHS" ]]; then
# HTTPS trust roots for RRDP/TAL fetches (independent of the RPKI TA).
# shellcheck disable=SC2206
root_cert_paths=( $HTTP_ROOT_CERT_PATHS )
for root_cert_path in "${root_cert_paths[@]}"; do
CHILD_ARGS+=(--http-root-cert "$root_cert_path")
done
fi
CHILD_ARGS+=(
--parallel-phase2-ready-batch-size 256
--parallel-phase2-ready-batch-wall-time-budget-ms 100
@@ -1331,6 +1381,17 @@ run_one_round() {
daemon_args+=(--db-stats-exact-every "$DB_STATS_EXACT_EVERY")
fi
fi
# Dead-repo transport blacklist (#141): opt-in via env; the daemon forwards
# the enable flag and threshold to the child unless already present there.
if [[ -n "${DEAD_REPO_BLACKLIST_PATH:-}" ]]; then
daemon_args+=(--dead-repo-blacklist "$DEAD_REPO_BLACKLIST_PATH")
if [[ -n "${DEAD_REPO_BLACKLIST_FAIL_THRESHOLD:-}" ]]; then
daemon_args+=(--dead-repo-blacklist-fail-threshold "$DEAD_REPO_BLACKLIST_FAIL_THRESHOLD")
fi
if [[ -n "${DEAD_REPO_HEALTH_CHECK_INTERVAL_SECS:-}" ]]; then
daemon_args+=(--dead-repo-health-check-interval-secs "$DEAD_REPO_HEALTH_CHECK_INTERVAL_SECS")
fi
fi
set +e
env \
@@ -1406,6 +1467,7 @@ main() {
validate_positive_int "DB_STATS_EXACT_EVERY" "$DB_STATS_EXACT_EVERY"
fi
parse_rirs
validate_custom_inputs
[[ -x "$RPKI_BIN" ]] || die "missing executable: $RPKI_BIN"
[[ -x "$RPKI_DAEMON_BIN" ]] || die "missing executable: $RPKI_DAEMON_BIN"

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Validate and summarize one Feature #149 six-run cohort."""
from __future__ import annotations
import argparse
import json
import statistics
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
REQUIRED_ARTIFACTS = {
"run-meta.json",
"run-summary.json",
"input.cir",
"result.ccr",
"report.json",
"vrps.csv",
"vaps.csv",
"stage-timing.json",
"process-time.txt",
}
def load_jsonl(path: Path) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if line.strip():
value = json.loads(line)
if not isinstance(value, dict):
raise ValueError(f"{path}:{line_number} is not an object")
records.append(value)
return records
def require_success(records: list[dict[str, Any]], package_root: Path) -> list[dict[str, Any]]:
if len(records) != 6:
raise ValueError(f"expected 6 records (snapshot + 5 delta), found {len(records)}")
for index, record in enumerate(records):
expected_mode = "snapshot" if index == 0 else "delta"
if record.get("status") != "success":
raise ValueError(f"run {index + 1} status is {record.get('status')!r}")
if record.get("syncMode") != expected_mode:
raise ValueError(
f"run {index + 1} syncMode is {record.get('syncMode')!r}, expected {expected_mode!r}"
)
run_dir = package_root / "runs" / f"run_{int(record['maxRunIndexAfter']):04d}"
missing = sorted(name for name in REQUIRED_ARTIFACTS if not (run_dir / name).is_file())
record["runDir"] = str(run_dir)
record["artifactsComplete"] = not missing
record["missingArtifacts"] = missing
if missing:
raise ValueError(f"run {index + 1} missing required artifacts: {', '.join(missing)}")
return records
def percentile(values: list[float], fraction: float) -> float | None:
if not values:
return None
ordered = sorted(values)
position = (len(ordered) - 1) * fraction
lower = int(position)
upper = min(lower + 1, len(ordered) - 1)
return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
def stats(values: list[int | float | None]) -> dict[str, float | None]:
nums = [float(value) for value in values if isinstance(value, (int, float))]
if not nums:
return {"count": 0, "min": None, "median": None, "p90": None, "max": None, "mean": None}
return {
"count": len(nums),
"min": min(nums),
"median": statistics.median(nums),
"p90": percentile(nums, 0.9),
"max": max(nums),
"mean": statistics.fmean(nums),
}
def iso(epoch: Any) -> str | None:
if not isinstance(epoch, (int, float)):
return None
return datetime.fromtimestamp(epoch, timezone.utc).isoformat().replace("+00:00", "Z")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--cohort-id", required=True)
parser.add_argument("--cohort-disk", required=True, choices=("system", "data"))
parser.add_argument("--cohort-profile", required=True, choices=("prefetch-pp-object", "pp-object"))
parser.add_argument("--package-root", required=True, type=Path)
parser.add_argument("--summary-jsonl", required=True, type=Path)
parser.add_argument("--out-dir", required=True, type=Path)
args = parser.parse_args()
records = require_success(load_jsonl(args.summary_jsonl), args.package_root)
delta = records[1:]
elapsed_seconds = records[-1]["completedEpoch"] - records[0]["actualStartEpoch"]
report = {
"feature": "149",
"createdAtUtc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"cohortId": args.cohort_id,
"disk": args.cohort_disk,
"profile": args.cohort_profile,
"packageRoot": str(args.package_root),
"runCount": len(records),
"successCount": len(records),
"artifactStatus": "complete",
"elapsedSeconds": elapsed_seconds,
"actualStartedAtUtc": iso(records[0].get("actualStartEpoch")),
"completedAtUtc": iso(records[-1].get("completedEpoch")),
"maxRssKb": max(int(record["maxRssKb"]) for record in records if isinstance(record.get("maxRssKb"), (int, float))),
"snapshot": {"wallMs": records[0].get("wallMs"), "maxRssKb": records[0].get("maxRssKb")},
"deltaWallMs": stats([record.get("wallMs") for record in delta]),
"stableDeltaWallMs": stats([record.get("wallMs") for record in delta[1:]]),
"latestCounts": {
key: records[-1].get(key)
for key in ("vrps", "vaps", "publicationPoints", "warnings")
},
"runs": records,
}
args.out_dir.mkdir(parents=True, exist_ok=True)
json_path = args.out_dir / "cohort-summary.json"
markdown_path = args.out_dir / "cohort-summary.md"
json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
latest = report["latestCounts"]
markdown_path.write_text(
"\n".join(
[
f"# Feature #149 cohort: {args.cohort_id}",
"",
f"- 磁盘:`{args.cohort_disk}`",
f"- profile:`{args.cohort_profile}`",
f"- 状态:`success`,6/6 run 成功,必要产物完整",
f"- 总执行耗时:`{elapsed_seconds}s`",
f"- 最高 RSS:`{report['maxRssKb']} KiB`",
f"- snapshot wall:`{records[0].get('wallMs')} ms`",
f"- delta median / p90:`{report['deltaWallMs']['median']} ms` / `{report['deltaWallMs']['p90']} ms`",
f"- 最新产物:VRP `{latest['vrps']}`、VAP `{latest['vaps']}`、PP `{latest['publicationPoints']}`、warnings `{latest['warnings']}`",
"",
"| run | mode | wall ms | max RSS KiB | VRP | VAP | PP | warnings | artifacts |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
*[
"| {run} | {mode} | {wall} | {rss} | {vrps} | {vaps} | {pps} | {warnings} | {artifacts} |".format(
run=index + 1,
mode=record.get("syncMode"),
wall=record.get("wallMs"),
rss=record.get("maxRssKb"),
vrps=record.get("vrps"),
vaps=record.get("vaps"),
pps=record.get("publicationPoints"),
warnings=record.get("warnings"),
artifacts="complete" if record.get("artifactsComplete") else "missing",
)
for index, record in enumerate(records)
],
"",
]
),
encoding="utf-8",
)
print(json.dumps({"cohortSummaryJson": str(json_path), "cohortSummaryMarkdown": str(markdown_path), "elapsedSeconds": elapsed_seconds, "maxRssKb": report["maxRssKb"]}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Generate a current-only AFRINIC IPv4 holdings constraints fixture.
The AFRINIC delegated extended format contains one IPv4 allocation/assignment
per line. This tool keeps only active registry holdings (``allocated`` and
``assigned``), computes their exact union, and emits an allow-all policy with
the union as IPv4 deny entries. ``reserved`` and ``available`` records are
intentionally excluded.
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import ipaddress
import pathlib
import sys
import urllib.error
import urllib.request
from collections import Counter
from typing import Iterable
DEFAULT_SOURCE_URL = (
"https://ftp.afrinic.net/pub/stats/afrinic/"
"delegated-afrinic-extended-latest"
)
ACTIVE_STATUSES = {"allocated", "assigned"}
def merge_intervals(
intervals: Iterable[tuple[int, int]],
) -> list[tuple[int, int]]:
merged: list[tuple[int, int]] = []
for start, end in sorted(intervals):
if merged and start <= merged[-1][1] + 1:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
return merged
def parse_source(raw: bytes) -> tuple[list[tuple[int, int]], Counter[str], Counter[str]]:
status_counts: Counter[str] = Counter()
ipv4_status_counts: Counter[str] = Counter()
active: list[tuple[int, int]] = []
for line_number, raw_line in enumerate(raw.decode("utf-8", "replace").splitlines(), 1):
if not raw_line or raw_line.startswith("#"):
continue
fields = raw_line.split("|")
if len(fields) < 7 or fields[2] != "ipv4":
continue
status = fields[6]
status_counts[status] += 1
ipv4_status_counts[status] += 1
if status not in ACTIVE_STATUSES:
continue
try:
start = int(ipaddress.IPv4Address(fields[3]))
count = int(fields[4])
except (ValueError, TypeError) as error:
raise ValueError(f"line {line_number}: invalid IPv4 record: {raw_line}") from error
if count <= 0 or start + count > 1 << 32:
raise ValueError(f"line {line_number}: invalid IPv4 range: {raw_line}")
active.append((start, start + count - 1))
return active, status_counts, ipv4_status_counts
def render(
source_url: str,
retrieved: str,
source_sha256: str,
source_ipv4_count: int,
status_counts: Counter[str],
active_source: list[tuple[int, int]],
) -> tuple[str, dict[str, int]]:
merged = merge_intervals(active_source)
networks = [
network
for start, end in merged
for network in ipaddress.summarize_address_range(
ipaddress.IPv4Address(start), ipaddress.IPv4Address(end)
)
]
networks.sort(key=lambda network: int(network.network_address))
active_addresses = sum(end - start + 1 for start, end in merged)
counts = {
"source_ipv4_records": source_ipv4_count,
"allocated": status_counts["allocated"],
"assigned": status_counts["assigned"],
"reserved": status_counts["reserved"],
"available": status_counts["available"],
"selected_source_records": len(active_source),
"selected_addresses": active_addresses,
"merged_intervals": len(merged),
"cidr_rules": len(networks),
}
lines = [
"# Feature #151 M7 current AFRINIC IPv4 holdings deny fixture.",
"#",
"# Source: AFRINIC delegated extended latest.",
f"# URL: {source_url}",
f"# Retrieved: {retrieved}",
f"# Source SHA-256: {source_sha256}",
"#",
"# Selection: IPv4 records with status allocated or assigned only.",
"# Excluded statuses: reserved and available (not current registry holdings).",
(
"# Source IPv4 records: {source_ipv4_records}; allocated={allocated}; "
"assigned={assigned}; reserved={reserved}; available={available}."
).format(**counts),
"# Selected source records: {selected_source_records}; selected addresses: {selected_addresses}.".format(
**counts
),
"# Overlap/adjacency union intervals: {merged_intervals}; exact CIDR deny rules: {cidr_rules}.".format(
**counts
),
"# IPv6 and ASN remain allow-all to isolate the current AFRINIC IPv4 policy.",
"allow 0.0.0.0/0",
"allow ::/0",
"allow 0 - 4294967295",
"",
]
lines.extend(f"deny {network}" for network in networks)
return "\n".join(lines) + "\n", counts
def fetch_source(url: str, timeout: float) -> bytes:
request = urllib.request.Request(
url,
headers={"User-Agent": "rpki-dev-feature151-afrinic-holdings/1.0"},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group()
source.add_argument("--url", default=DEFAULT_SOURCE_URL, help="AFRINIC source URL")
source.add_argument("--input", type=pathlib.Path, help="local delegated file for replay")
parser.add_argument("--output", type=pathlib.Path, required=True)
parser.add_argument("--retrieved", help="UTC date recorded in the fixture header")
parser.add_argument("--timeout", type=float, default=60.0)
args = parser.parse_args()
source_url = args.url if args.input is None else f"file://{args.input.resolve()}"
raw = args.input.read_bytes() if args.input is not None else fetch_source(args.url, args.timeout)
retrieved = args.retrieved or dt.datetime.now(dt.timezone.utc).date().isoformat()
active, status_counts, ipv4_status_counts = parse_source(raw)
rendered, counts = render(
source_url,
retrieved,
hashlib.sha256(raw).hexdigest(),
sum(ipv4_status_counts.values()),
status_counts,
active,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(rendered, encoding="utf-8")
print(
"generated {output}: {cidr_rules} CIDRs from {selected_source_records} active "
"records ({selected_addresses} addresses); source_sha256={sha256}".format(
output=args.output,
sha256=hashlib.sha256(raw).hexdigest(),
**counts,
)
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, urllib.error.URLError) as error:
print(f"error: {error}", file=sys.stderr)
raise SystemExit(2)

View File

@@ -29,7 +29,7 @@
- `vrps.csv`、`vaps.csv`
- `stage-timing.json`、`validation-events.jsonl`
- `validation-contract.json`、`verification-meta.json`
- `compare/ccr-state-compare.json`、`compare/ccr-state-compare.md`
- `compare/ccr-state-digest.json`、`compare/ccr-state-digest.md`
- `stdout.log`、`stderr.log`、`process-time.txt`、`wrapper-timing.json`
通过条件是 compare JSON 中 `stateDigestMatch=true`。`producedAt` 不参与比较。

View File

@@ -0,0 +1,563 @@
#!/usr/bin/env bash
set -euo pipefail
# Observe a Docker-managed soak from the host without joining its compose project.
# The verifier binary must be copied byte-for-byte from the running runtime image;
# validation-contract.json rejects a different binary SHA-256.
usage() {
cat <<'USAGE'
Usage:
observe_remote231_verification_only.sh \
--runs-root <normal_runs_dir> \
--state-root <normal_state_dir> \
--rpki-bin <host_side_rpki> \
--work-root <observer_root> \
--webhook-url-file <0600_file> \
[--target-runs 20] [--delay-secs 30] [--poll-secs 5] \
[--verification-timeout-secs 480] [--normal-interval-secs 600] \
[--normal-run-safety-secs 60] [--verification-object-workers 4] \
[--verification-child-path <path>]
This is a host-side, finite observer. On its first start it records the latest
already-terminal normal run and observes only later terminal runs. For each one
it waits DELAY seconds, then uses a non-blocking state lock. It never waits for
or blocks the Docker soak. Successful normal runs are verified with a copied
host-side rpki binary; failed, stale, or lock-busy runs are recorded as skipped.
The verification timeout is additionally bounded by the source run completion
time plus NORMAL_INTERVAL_SECS minus NORMAL_RUN_SAFETY_SECS. This preserves a
normal-run safety window even after an observer restart or delayed scan.
Only compact JSON results are retained under WORK_ROOT/results/. The large
verification output and any retained scratch DB are always deleted afterwards.
When --verification-child-path is supplied, it replaces PATH only for the
verification binary. Passing a directory without rsync (for example
/nonexistent) is an adversarial acceptance check: an accidental SystemRsync
fallback must fail instead of accessing the network.
USAGE
}
RUNS_ROOT=""
STATE_ROOT=""
RPKI_BIN=""
WORK_ROOT=""
WEBHOOK_URL_FILE=""
TARGET_RUNS=20
DELAY_SECS=30
POLL_SECS=5
VERIFICATION_TIMEOUT_SECS=480
NORMAL_INTERVAL_SECS=600
NORMAL_RUN_SAFETY_SECS=60
VERIFICATION_OBJECT_WORKERS=4
VERIFICATION_CHILD_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--runs-root) RUNS_ROOT="${2:?missing value}"; shift 2 ;;
--state-root) STATE_ROOT="${2:?missing value}"; shift 2 ;;
--rpki-bin) RPKI_BIN="${2:?missing value}"; shift 2 ;;
--work-root) WORK_ROOT="${2:?missing value}"; shift 2 ;;
--webhook-url-file) WEBHOOK_URL_FILE="${2:?missing value}"; shift 2 ;;
--target-runs) TARGET_RUNS="${2:?missing value}"; shift 2 ;;
--delay-secs) DELAY_SECS="${2:?missing value}"; shift 2 ;;
--poll-secs) POLL_SECS="${2:?missing value}"; shift 2 ;;
--verification-timeout-secs) VERIFICATION_TIMEOUT_SECS="${2:?missing value}"; shift 2 ;;
--normal-interval-secs) NORMAL_INTERVAL_SECS="${2:?missing value}"; shift 2 ;;
--normal-run-safety-secs) NORMAL_RUN_SAFETY_SECS="${2:?missing value}"; shift 2 ;;
--verification-object-workers) VERIFICATION_OBJECT_WORKERS="${2:?missing value}"; shift 2 ;;
--verification-child-path) VERIFICATION_CHILD_PATH="${2:?missing value}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
for value_name in RUNS_ROOT STATE_ROOT RPKI_BIN WORK_ROOT WEBHOOK_URL_FILE; do
[[ -n "${!value_name}" ]] || { echo "$value_name is required" >&2; exit 2; }
done
for integer_name in TARGET_RUNS DELAY_SECS POLL_SECS VERIFICATION_TIMEOUT_SECS NORMAL_INTERVAL_SECS VERIFICATION_OBJECT_WORKERS; do
[[ "${!integer_name}" =~ ^[0-9]+$ ]] && [[ "${!integer_name}" != 0 ]] || {
echo "$integer_name must be a positive integer" >&2
exit 2
}
done
[[ "$NORMAL_RUN_SAFETY_SECS" =~ ^[0-9]+$ ]] || {
echo "NORMAL_RUN_SAFETY_SECS must be a non-negative integer" >&2
exit 2
}
(( NORMAL_RUN_SAFETY_SECS < NORMAL_INTERVAL_SECS )) || {
echo "NORMAL_RUN_SAFETY_SECS must be smaller than NORMAL_INTERVAL_SECS" >&2
exit 2
}
[[ -d "$RUNS_ROOT" ]] || { echo "runs root is not a directory: $RUNS_ROOT" >&2; exit 2; }
[[ -d "$STATE_ROOT" ]] || { echo "state root is not a directory: $STATE_ROOT" >&2; exit 2; }
[[ -x "$RPKI_BIN" ]] || { echo "rpki binary is not executable: $RPKI_BIN" >&2; exit 2; }
[[ -f "$WEBHOOK_URL_FILE" ]] || { echo "webhook URL file is absent: $WEBHOOK_URL_FILE" >&2; exit 2; }
for required_command in curl flock ionice jq nice python3 sha256sum timeout; do
command -v "$required_command" >/dev/null || {
echo "missing required command: $required_command" >&2
exit 2
}
done
mkdir -p "$WORK_ROOT/results" "$WORK_ROOT/scratch"
STATE_FILE="$WORK_ROOT/observer-state.json"
OBSERVER_LOCK="$WORK_ROOT/observer.lock"
RESULTS_DIR="$WORK_ROOT/results"
SCRATCH_ROOT="$WORK_ROOT/scratch"
WEBHOOK_URL="$(tr -d '\r\n' < "$WEBHOOK_URL_FILE")"
[[ "$WEBHOOK_URL" == https://open.feishu.cn/open-apis/bot/v2/hook/* ]] || {
echo "webhook URL does not have the expected Feishu incoming-webhook form" >&2
exit 2
}
now_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
now_ms() { python3 -c 'import time; print(time.time_ns() // 1_000_000)'; }
remaining_verification_window_secs() {
local completed_at="$1"
python3 - "$completed_at" "$NORMAL_INTERVAL_SECS" "$NORMAL_RUN_SAFETY_SECS" <<'PY'
import datetime
import sys
import time
completed = datetime.datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00"))
interval = int(sys.argv[2])
safety = int(sys.argv[3])
print(int(completed.timestamp()) + interval - safety - int(time.time()))
PY
}
atomic_replace() {
local destination="$1"
local temporary="${destination}.tmp.$$"
cat > "$temporary"
mv "$temporary" "$destination"
}
terminal_run_ids() {
local run_dir meta
for run_dir in "$RUNS_ROOT"/run_[0-9]*; do
[[ -d "$run_dir" ]] || continue
meta="$run_dir/run-meta.json"
[[ -f "$meta" ]] || continue
jq -e '(.completed_at_rfc3339_utc? // .completedAt? // null) != null' "$meta" >/dev/null 2>&1 || continue
basename "$run_dir"
done | sort -t_ -k2,2n
}
run_number() {
local run_id="$1"
printf '%d\n' "$((10#${run_id#run_}))"
}
latest_terminal_run_id() {
terminal_run_ids | tail -n 1
}
result_path() {
printf '%s/%s.json\n' "$RESULTS_DIR" "$1"
}
read_json_or_empty() {
local path="$1"
if [[ -f "$path" ]] && jq -e . "$path" >/dev/null 2>&1; then
jq -c . "$path"
else
printf '{}'
fi
}
update_cumulative() {
local observed normal_success passes mismatches errors skipped pending latest
local -a result_files
shopt -s nullglob
result_files=("$RESULTS_DIR"/run_*.json)
shopt -u nullglob
observed="${#result_files[@]}"
if (( observed == 0 )); then
normal_success=0
passes=0
mismatches=0
errors=0
skipped=0
pending=0
else
normal_success="$(jq -r 'select(.normalRun.status == "success") | 1' "${result_files[@]}" | wc -l)"
passes="$(jq -r 'select(.verification.status == "pass") | 1' "${result_files[@]}" | wc -l)"
mismatches="$(jq -r 'select(.verification.status == "mismatch") | 1' "${result_files[@]}" | wc -l)"
errors="$(jq -r 'select(.verification.status == "error") | 1' "${result_files[@]}" | wc -l)"
skipped="$(jq -r 'select(.verification.status | startswith("skipped_")) | 1' "${result_files[@]}" | wc -l)"
pending="$(jq -r 'select(.notification.status != "sent") | 1' "${result_files[@]}" | wc -l)"
fi
latest="$(find "$RESULTS_DIR" -maxdepth 1 -type f -name 'run_*.json' -printf '%f\n' | sed 's/.json$//' | sort -t_ -k2,2n | tail -n 1)"
jq -n \
--arg updated_at "$(now_utc)" \
--arg initial_run_id "$(jq -r '.initialLatestTerminalRunId' "$STATE_FILE")" \
--arg latest_run_id "$latest" \
--argjson target_runs "$TARGET_RUNS" \
--argjson observed_runs "$observed" \
--argjson normal_success_runs "$normal_success" \
--argjson verification_passes "$passes" \
--argjson verification_mismatches "$mismatches" \
--argjson verification_errors "$errors" \
--argjson verification_skipped "$skipped" \
--argjson notification_pending "$pending" \
'{schemaVersion: 1, updatedAt: $updated_at, initialLatestTerminalRunId: $initial_run_id,
latestProcessedRunId: $latest_run_id, targetRuns: $target_runs, observedRuns: $observed_runs,
normalSuccessRuns: $normal_success_runs, verificationPasses: $verification_passes,
verificationMismatches: $verification_mismatches, verificationErrors: $verification_errors,
verificationSkipped: $verification_skipped, notificationPending: $notification_pending}' \
| atomic_replace "$RESULTS_DIR/cumulative.json"
}
notify_result() {
local path="$1" run_id status observed target passes mismatches errors skipped analysis_available top_phase message payload response
run_id="$(jq -r '.runId' "$path")"
status="$(jq -r '.verification.status' "$path")"
observed="$(jq -r '.observedRuns' "$RESULTS_DIR/cumulative.json")"
target="$(jq -r '.targetRuns' "$RESULTS_DIR/cumulative.json")"
passes="$(jq -r '.verificationPasses' "$RESULTS_DIR/cumulative.json")"
mismatches="$(jq -r '.verificationMismatches' "$RESULTS_DIR/cumulative.json")"
errors="$(jq -r '.verificationErrors' "$RESULTS_DIR/cumulative.json")"
skipped="$(jq -r '.verificationSkipped' "$RESULTS_DIR/cumulative.json")"
analysis_available="$(jq -r '.verification.analysis.available // false' "$path")"
top_phase="$(jq -r '(.verification.analysis.phaseTotals // [])[0] | if . == null then "n/a" else "\(.name)=\(.totalMs)ms" end' "$path")"
message="From codex: #137 远端231 verification-only 旁路复核\n${run_id}: ${status}\n"
message+="CCR state digest match=$(jq -r 'if .verification.comparison | has("stateDigestMatch") then (.verification.comparison.stateDigestMatch | tostring) else "n/a" end' "$path"); fallback replay match=$(jq -r 'if .verification.comparison.fallbackReplaySelection | has("matches") then (.verification.comparison.fallbackReplaySelection.matches | tostring) else "n/a" end' "$path"); verificationProcessMs=$(jq -r '.verification.timing.verificationProcessMs // .verification.wallMs // "n/a"' "$path"); preValidationPreparationMs=$(jq -r '.verification.timing.preValidationPreparationMs // "n/a"' "$path"); lockWaitMs=$(jq -r '.verification.lockWaitMs // "n/a"' "$path")\n"
message+="analysisAvailable=${analysis_available}; topPhase=${top_phase}\n"
message+="累计 ${observed}/${target}: pass=${passes}, mismatch=${mismatches}, error=${errors}, skipped=${skipped}\n"
message+="结果: ${path}"
payload="$(jq -n --arg text "$message" '{msg_type: "text", content: {text: $text}}')"
if response="$(curl --fail-with-body --silent --show-error --connect-timeout 10 --max-time 30 \
-H 'Content-Type: application/json' --data "$payload" "$WEBHOOK_URL" 2>&1)"; then
if jq -e '((.code // .StatusCode // 0) == 0)' <<< "$response" >/dev/null 2>&1; then
jq --arg sent_at "$(now_utc)" --arg response "$response" \
'.notification = {status: "sent", sentAt: $sent_at, response: $response}' "$path" \
| atomic_replace "$path"
return 0
fi
response="Feishu rejected payload: $response"
fi
jq --arg attempted_at "$(now_utc)" --arg error "$response" \
'.notification = {status: "pending", lastAttemptAt: $attempted_at, lastError: $error}' "$path" \
| atomic_replace "$path"
return 1
}
deliver_pending_notifications() {
local path
for path in "$RESULTS_DIR"/run_*.json; do
[[ -f "$path" ]] || continue
[[ "$(jq -r '.notification.status // "pending"' "$path")" == "sent" ]] && continue
update_cumulative
notify_result "$path" || true
done
update_cumulative
}
write_result() {
local run_id="$1" normal_json="$2" verification_json="$3" result
result="$(result_path "$run_id")"
jq -n \
--arg recorded_at "$(now_utc)" \
--arg run_id "$run_id" \
--argjson normal_run "$normal_json" \
--argjson verification "$verification_json" \
'{schemaVersion: 1, recordedAt: $recorded_at, runId: $run_id, normalRun: $normal_run,
verification: $verification, notification: {status: "pending"}}' \
| atomic_replace "$result"
update_cumulative
notify_result "$result" || true
update_cumulative
}
normal_run_summary() {
local run_dir="$1"
if [[ ! -f "$run_dir/run-meta.json" ]]; then
printf '{"status":"invalid-meta"}'
return
fi
jq -cn --slurpfile meta "$run_dir/run-meta.json" --slurpfile summary "$run_dir/run-summary.json" \
'{status: ($meta[0].status // "unknown"), syncMode: ($meta[0].sync_mode // $meta[0].syncMode // "unknown"),
runIndex: ($meta[0].run_index // $meta[0].runIndex // null),
completedAt: ($meta[0].completed_at_rfc3339_utc // $meta[0].completedAt // null),
wallMs: ($summary[0].wall_ms // $summary[0].wallMs // null),
exitCode: ($summary[0].exit_code // $summary[0].exitCode // $meta[0].daemon_exit_code // null)}' 2>/dev/null \
|| printf '{"status":"invalid-meta"}'
}
process_terminal_run() {
local run_id="$1" run_dir="$RUNS_ROOT/$run_id" normal_json status temporary_dir state_lock_fd lock_started_ms lock_acquired_ms
local binding_run_id binary_sha source_sha run_started_ms run_finished_ms exit_code comparison_json comparison_state_digest_match fallback_replay_comparison_json fallback_replay_selection_match verification_meta_json stage_timing_json analysis_json analysis_summary_json error_tail max_rss_kb verification_status verification_json timeout_secs completed_at
local -a verification_child_exec
local observer_started_ms delay_finished_ms preparation_started_ms binding_started_ms binding_finished_ms binary_check_started_ms binary_check_finished_ms window_started_ms window_finished_ms scratch_cleanup_started_ms scratch_cleanup_finished_ms post_validation_started_ms post_validation_collection_finished_ms result_cleanup_started_ms result_cleanup_finished_ms
normal_json="$(normal_run_summary "$run_dir")"
status="$(jq -r '.status' <<< "$normal_json")"
observer_started_ms="$(now_ms)"
sleep "$DELAY_SECS"
delay_finished_ms="$(now_ms)"
if [[ "$status" != "success" ]]; then
write_result "$run_id" "$normal_json" '{"status":"skipped_normal_run_not_success"}'
return
fi
preparation_started_ms="$delay_finished_ms"
mkdir -p "$STATE_ROOT/locks"
exec {state_lock_fd}>"$STATE_ROOT/locks/state-execution.lock"
lock_started_ms="$(now_ms)"
if ! flock -n "$state_lock_fd"; then
exec {state_lock_fd}>&-
write_result "$run_id" "$normal_json" '{"status":"skipped_state_lock_busy"}'
return
fi
lock_acquired_ms="$(now_ms)"
binding_started_ms="$(now_ms)"
binding_run_id="$(jq -r '.currentRunId // empty' "$STATE_ROOT/meta/current-state-binding.json" 2>/dev/null || true)"
binding_finished_ms="$(now_ms)"
if [[ "$binding_run_id" != "$run_id" ]]; then
flock -u "$state_lock_fd"
exec {state_lock_fd}>&-
verification_json="$(jq -n --arg bound_run "$binding_run_id" --argjson lock_wait_ms "$((lock_acquired_ms - lock_started_ms))" \
'{status: "skipped_source_no_longer_current", boundRunId: $bound_run, lockWaitMs: $lock_wait_ms}')"
write_result "$run_id" "$normal_json" "$verification_json"
return
fi
binary_check_started_ms="$(now_ms)"
source_sha="$(jq -r '.binarySha256 // empty' "$run_dir/validation-contract.json" 2>/dev/null || true)"
binary_sha="$(sha256sum "$RPKI_BIN" | awk '{print $1}')"
binary_check_finished_ms="$(now_ms)"
if [[ -z "$source_sha" || "$source_sha" != "$binary_sha" ]]; then
flock -u "$state_lock_fd"
exec {state_lock_fd}>&-
verification_json="$(jq -n --arg expected "$source_sha" --arg actual "$binary_sha" --argjson lock_wait_ms "$((lock_acquired_ms - lock_started_ms))" \
'{status: "error_binary_sha256_mismatch", expectedBinarySha256: $expected, actualBinarySha256: $actual, lockWaitMs: $lock_wait_ms}')"
write_result "$run_id" "$normal_json" "$verification_json"
return
fi
window_started_ms="$(now_ms)"
completed_at="$(jq -r '.completedAt // empty' <<< "$normal_json")"
timeout_secs="$(remaining_verification_window_secs "$completed_at")"
window_finished_ms="$(now_ms)"
if (( timeout_secs <= 0 )); then
flock -u "$state_lock_fd"
exec {state_lock_fd}>&-
verification_json="$(jq -n --arg completed_at "$completed_at" --argjson lock_wait_ms "$((lock_acquired_ms - lock_started_ms))" \
'{status: "skipped_insufficient_interval", sourceCompletedAt: $completed_at, lockWaitMs: $lock_wait_ms}')"
write_result "$run_id" "$normal_json" "$verification_json"
return
fi
if (( timeout_secs > VERIFICATION_TIMEOUT_SECS )); then
timeout_secs="$VERIFICATION_TIMEOUT_SECS"
fi
temporary_dir="$SCRATCH_ROOT/$run_id"
scratch_cleanup_started_ms="$(now_ms)"
rm -rf "$temporary_dir"
scratch_cleanup_finished_ms="$(now_ms)"
run_started_ms="$(now_ms)"
verification_child_exec=()
if [[ -n "$VERIFICATION_CHILD_PATH" ]]; then
verification_child_exec=(/usr/bin/env "PATH=$VERIFICATION_CHILD_PATH")
fi
set +e
RPKI_STATE_EXECUTION_LOCK_HELD=1 /usr/bin/time -v -o "$temporary_dir.process-time" -- \
/usr/bin/timeout --foreground --signal=TERM --kill-after=30s "$timeout_secs" \
/usr/bin/ionice -c2 -n0 "${verification_child_exec[@]}" "$RPKI_BIN" \
--verification-only --verification-source-run "$run_dir" \
--verification-source-state "$STATE_ROOT" --verification-out "$temporary_dir" \
--analyze --analysis-out "$temporary_dir/analyze" \
--parallel-phase2-object-workers "$VERIFICATION_OBJECT_WORKERS" \
>"$temporary_dir.stdout" 2>"$temporary_dir.stderr"
exit_code=$?
set -e
run_finished_ms="$(now_ms)"
post_validation_started_ms="$run_finished_ms"
flock -u "$state_lock_fd"
exec {state_lock_fd}>&-
comparison_json="$(read_json_or_empty "$temporary_dir/compare/ccr-state-digest.json")"
fallback_replay_comparison_json="$(read_json_or_empty "$temporary_dir/compare/fallback-replay-selection.json")"
verification_meta_json="$(read_json_or_empty "$temporary_dir/verification-meta.json")"
stage_timing_json="$(read_json_or_empty "$temporary_dir/stage-timing.json")"
analysis_json="$(read_json_or_empty "$temporary_dir/analyze/timing.json")"
analysis_summary_json="$(jq -cn --argjson stage "$stage_timing_json" --argjson analysis "$analysis_json" '
{enabled: true,
available: (($analysis | type) == "object" and ($analysis | length) > 0),
stageTiming: {
totalMs: ($stage.total_ms // null),
validationMs: ($stage.validation_ms // null),
repoSyncMsTotal: ($stage.repo_sync_ms_total // null),
publicationPointRepoSyncMsTotal: ($stage.publication_point_repo_sync_ms_total // null),
rrdpDownloadMsTotal: ($stage.rrdp_download_ms_total // null),
rsyncDownloadMsTotal: ($stage.rsync_download_ms_total // null),
reportBuildMs: ($stage.report_build_ms // null),
reportWriteMs: ($stage.report_write_ms // null),
ccrBuildMs: ($stage.ccr_build_ms // null),
ccrWriteMs: ($stage.ccr_write_ms // null),
cirTotalMs: ($stage.cir_total_ms // null)
},
phaseTotals: (($analysis.phases // {})
| to_entries
| map({name: .key, count: (.value.count // 0), totalMs: (((.value.total_nanos // 0) / 1000000) | floor)})
| sort_by(-.totalMs)
| .[0:20]),
topPublicationPoints: (($analysis.top_publication_points // [])[0:10]),
topPublicationPointSteps: (($analysis.top_publication_point_steps // [])[0:10]),
topRrdpRepos: (($analysis.top_rrdp_repos // [])[0:10]),
topRrdpRepoSteps: (($analysis.top_rrdp_repo_steps // [])[0:10])}')"
error_tail="$(tail -n 20 "$temporary_dir.stderr" 2>/dev/null || true)"
max_rss_kb="$(awk -F: '/Maximum resident set size/{gsub(/^[[:space:]]+/, "", $2); print $2}' "$temporary_dir.process-time" 2>/dev/null || true)"
comparison_state_digest_match="$(jq -r 'if has("stateDigestMatch") then (.stateDigestMatch | tostring) else "missing" end' <<< "$comparison_json")"
fallback_replay_selection_match="$(jq -r 'if has("matches") then (.matches | tostring) else "missing" end' <<< "$fallback_replay_comparison_json")"
if [[ "$comparison_state_digest_match" == "true" && "$fallback_replay_selection_match" == "true" && "$exit_code" -eq 0 ]]; then
verification_status="pass"
elif [[ "$comparison_state_digest_match" == "false" || "$fallback_replay_selection_match" == "false" ]]; then
verification_status="mismatch"
else
verification_status="error"
fi
post_validation_collection_finished_ms="$(now_ms)"
result_cleanup_started_ms="$(now_ms)"
rm -rf "$temporary_dir" "$temporary_dir.stdout" "$temporary_dir.stderr" "$temporary_dir.process-time"
result_cleanup_finished_ms="$(now_ms)"
verification_json="$(jq -n \
--arg status "$verification_status" \
--arg verification_child_path "$VERIFICATION_CHILD_PATH" \
--argjson exit_code "$exit_code" \
--argjson wall_ms "$((run_finished_ms - run_started_ms))" \
--argjson lock_wait_ms "$((lock_acquired_ms - lock_started_ms))" \
--argjson timeout_limit_secs "$timeout_secs" \
--argjson object_workers "$VERIFICATION_OBJECT_WORKERS" \
--arg max_rss_kb "${max_rss_kb:-}" \
--arg error_tail "$error_tail" \
--argjson comparison "$comparison_json" \
--argjson fallback_replay_comparison "$fallback_replay_comparison_json" \
--argjson meta "$verification_meta_json" \
--argjson analysis "$analysis_summary_json" \
--argjson post_run_delay_ms "$((delay_finished_ms - observer_started_ms))" \
--argjson observer_to_verification_start_ms "$((run_started_ms - observer_started_ms))" \
--argjson pre_validation_preparation_ms "$((run_started_ms - preparation_started_ms))" \
--argjson binding_check_ms "$((binding_finished_ms - binding_started_ms))" \
--argjson binary_contract_check_ms "$((binary_check_finished_ms - binary_check_started_ms))" \
--argjson interval_window_calculation_ms "$((window_finished_ms - window_started_ms))" \
--argjson scratch_preparation_cleanup_ms "$((scratch_cleanup_finished_ms - scratch_cleanup_started_ms))" \
--argjson verification_process_ms "$((run_finished_ms - run_started_ms))" \
--argjson post_validation_collection_ms "$((post_validation_collection_finished_ms - post_validation_started_ms))" \
--argjson result_cleanup_ms "$((result_cleanup_finished_ms - result_cleanup_started_ms))" \
'{status: $status, exitCode: $exit_code, wallMs: $wall_ms, lockWaitMs: $lock_wait_ms,
timeoutLimitSecs: $timeout_limit_secs, objectWorkers: $object_workers,
verificationChildPath: (if $verification_child_path == "" then null else $verification_child_path end),
timing: {postRunDelayMs: $post_run_delay_ms,
observerToVerificationStartMs: $observer_to_verification_start_ms,
preValidationPreparationMs: $pre_validation_preparation_ms,
preparationBreakdownMs: {stateLockWaitMs: $lock_wait_ms,
bindingCheckMs: $binding_check_ms,
binaryContractCheckMs: $binary_contract_check_ms,
intervalWindowCalculationMs: $interval_window_calculation_ms,
scratchPreparationCleanupMs: $scratch_preparation_cleanup_ms},
verificationProcessMs: $verification_process_ms,
postValidationCollectionMs: $post_validation_collection_ms,
resultCleanupMs: $result_cleanup_ms},
maxRssKb: (if $max_rss_kb == "" then null else ($max_rss_kb | tonumber) end),
comparison: {stateDigestMatch: ($comparison | if has("stateDigestMatch") then .stateDigestMatch else null end),
mismatchedStates: ($comparison.mismatchedStates // []),
states: (($comparison.states // []) | map({name, sourcePresent, verificationPresent,
sourceHashHex, verificationHashHex, matches})),
fallbackReplaySelection: {
matches: ($fallback_replay_comparison | if has("matches") then .matches else null end),
expectedCount: (($fallback_replay_comparison.expected // []) | length),
actualCount: (($fallback_replay_comparison.actual // []) | length),
missing: ($fallback_replay_comparison.missing // []),
unexpected: ($fallback_replay_comparison.unexpected // [])
}},
verificationMeta: {status: ($meta.status // null), wallMs: ($meta.wallMs // null),
repositoryObjectsChecked: ($meta.repositoryObjectsChecked // null),
repositoryBytesChecked: ($meta.repositoryBytesChecked // null)},
analysis: $analysis,
errorTail: $error_tail}')"
write_result "$run_id" "$normal_json" "$verification_json"
}
exec {observer_lock_fd}>"$OBSERVER_LOCK"
if ! flock -n "$observer_lock_fd"; then
echo "another observer already owns $OBSERVER_LOCK" >&2
exit 3
fi
find "$SCRATCH_ROOT" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
if [[ ! -f "$STATE_FILE" ]]; then
initial_run_id="$(latest_terminal_run_id)"
[[ -n "$initial_run_id" ]] || { echo "no terminal normal run found under $RUNS_ROOT" >&2; exit 2; }
jq -n \
--arg started_at "$(now_utc)" \
--arg initial_run_id "$initial_run_id" \
--arg runs_root "$RUNS_ROOT" \
--arg state_root "$STATE_ROOT" \
--arg rpki_sha256 "$(sha256sum "$RPKI_BIN" | awk '{print $1}')" \
--argjson target_runs "$TARGET_RUNS" \
--argjson delay_secs "$DELAY_SECS" \
--argjson verification_timeout_secs "$VERIFICATION_TIMEOUT_SECS" \
--argjson normal_interval_secs "$NORMAL_INTERVAL_SECS" \
--argjson normal_run_safety_secs "$NORMAL_RUN_SAFETY_SECS" \
--argjson verification_object_workers "$VERIFICATION_OBJECT_WORKERS" \
--arg verification_child_path "$VERIFICATION_CHILD_PATH" \
'{schemaVersion: 1, startedAt: $started_at, initialLatestTerminalRunId: $initial_run_id,
runsRoot: $runs_root, stateRoot: $state_root, verifierBinarySha256: $rpki_sha256,
targetRuns: $target_runs, delaySecs: $delay_secs, verificationTimeoutSecs: $verification_timeout_secs,
normalIntervalSecs: $normal_interval_secs, normalRunSafetySecs: $normal_run_safety_secs,
verificationObjectWorkers: $verification_object_workers,
verificationChildPath: (if $verification_child_path == "" then null else $verification_child_path end),
verificationAnalyze: true}' \
| atomic_replace "$STATE_FILE"
update_cumulative
fi
# Preserve the original baseline run while recording the live configuration after
# an observer restart. This is particularly important for the normal-run safety
# window, whose length depends on the soak interval.
jq --arg updated_at "$(now_utc)" \
--argjson target_runs "$TARGET_RUNS" \
--argjson delay_secs "$DELAY_SECS" \
--argjson verification_timeout_secs "$VERIFICATION_TIMEOUT_SECS" \
--argjson normal_interval_secs "$NORMAL_INTERVAL_SECS" \
--argjson normal_run_safety_secs "$NORMAL_RUN_SAFETY_SECS" \
--argjson verification_object_workers "$VERIFICATION_OBJECT_WORKERS" \
--arg verification_child_path "$VERIFICATION_CHILD_PATH" \
'.updatedAt = $updated_at
| .targetRuns = $target_runs
| .delaySecs = $delay_secs
| .verificationTimeoutSecs = $verification_timeout_secs
| .normalIntervalSecs = $normal_interval_secs
| .normalRunSafetySecs = $normal_run_safety_secs
| .verificationObjectWorkers = $verification_object_workers
| .verificationChildPath = (if $verification_child_path == "" then null else $verification_child_path end)
| .verificationAnalyze = true' "$STATE_FILE" \
| atomic_replace "$STATE_FILE"
initial_run_number="$(run_number "$(jq -r '.initialLatestTerminalRunId' "$STATE_FILE")")"
while true; do
deliver_pending_notifications
observed_count="$(find "$RESULTS_DIR" -maxdepth 1 -type f -name 'run_*.json' -printf '%f\n' | wc -l)"
pending_count="$(jq -r '.notificationPending' "$RESULTS_DIR/cumulative.json")"
if (( observed_count >= TARGET_RUNS && pending_count == 0 )); then
break
fi
if (( observed_count < TARGET_RUNS )); then
while IFS= read -r run_id; do
[[ -n "$run_id" ]] || continue
(( $(run_number "$run_id") > initial_run_number )) || continue
[[ -f "$(result_path "$run_id")" ]] && continue
process_terminal_run "$run_id"
observed_count="$(find "$RESULTS_DIR" -maxdepth 1 -type f -name 'run_*.json' -printf '%f\n' | wc -l)"
(( observed_count < TARGET_RUNS )) || break
done < <(terminal_run_ids)
fi
sleep "$POLL_SECS"
done
update_cumulative
echo "completed $(jq -r '.observedRuns' "$RESULTS_DIR/cumulative.json") observed runs; results: $RESULTS_DIR/cumulative.json"

View File

@@ -36,6 +36,7 @@ pub struct ObjectAuditEntry {
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditWarning {
pub message: String,
pub category: String,
pub rfc_refs: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
@@ -102,6 +103,7 @@ impl From<&crate::report::Warning> for AuditWarning {
fn from(w: &crate::report::Warning) -> Self {
Self {
message: w.message.clone(),
category: w.category.as_str().to_string(),
rfc_refs: w.rfc_refs.iter().map(|r| r.0.to_string()).collect(),
context: w.context.clone(),
}
@@ -238,6 +240,15 @@ mod tests {
assert!(value.get("cir_fresh_objects").is_none());
assert!(value.get("cir_cached_objects").is_none());
}
#[test]
fn warning_audit_keeps_the_warning_category() {
let warning = crate::report::Warning::new("BER-compatible CMS accepted")
.with_category(crate::report::WarningCategory::BerCompatibleCmsEncoding);
let audit_warning = AuditWarning::from(&warning);
assert_eq!(audit_warning.category, "ber_compatible_cms_encoding");
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]

View File

@@ -0,0 +1,437 @@
use rocksdb::{DB, IteratorMode, Options};
use rpki::data_model::crl::RpkixCrl;
use rpki::data_model::rc::ResourceCertificate;
use rpki::data_model::signed_object::RpkiSignedObject;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Default)]
struct Args {
input: Option<PathBuf>,
repo_bytes_db: Option<PathBuf>,
output: Option<PathBuf>,
pretty: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
enum EncodingStatus {
Der,
BerCompatibleOnly,
ParseFailed,
NotRpki,
}
#[derive(Clone, Debug, Serialize)]
struct ObjectRecord {
sha256: String,
object_type: String,
encoding_status: EncodingStatus,
bytes: u64,
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
}
#[derive(Debug, Serialize)]
struct AuditReport {
generated_by: &'static str,
input: String,
files_seen: u64,
recognized_rpki_objects: u64,
duplicate_content_files: u64,
non_rpki_entries: u64,
scan_errors: Vec<String>,
status_counts: BTreeMap<String, u64>,
object_type_status_counts: BTreeMap<String, BTreeMap<String, u64>>,
objects: Vec<ObjectRecord>,
}
fn usage() -> &'static str {
"Usage: rpki_der_ber_audit (--input <raw-rpki-object-directory>|--repo-bytes-db <repo-bytes.db>) [--output <audit.json>] [--compact]"
}
fn main() {
if let Err(err) = real_main() {
eprintln!("{err}");
std::process::exit(1);
}
}
fn real_main() -> Result<(), String> {
let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
let input_label = args
.input
.as_ref()
.or(args.repo_bytes_db.as_ref())
.expect("validated input");
let mut report = AuditReport {
generated_by: "rpki_der_ber_audit",
input: input_label.display().to_string(),
files_seen: 0,
recognized_rpki_objects: 0,
duplicate_content_files: 0,
non_rpki_entries: 0,
scan_errors: Vec::new(),
status_counts: BTreeMap::new(),
object_type_status_counts: BTreeMap::new(),
objects: Vec::new(),
};
let mut seen_hashes = BTreeSet::new();
if let Some(input) = args.input.as_ref() {
scan_directory(input, &mut report, &mut seen_hashes)?;
} else if let Some(repo_bytes_db) = args.repo_bytes_db.as_ref() {
scan_repo_bytes_db(repo_bytes_db, &mut report, &mut seen_hashes)?;
}
report
.objects
.sort_by(|left, right| left.sha256.cmp(&right.sha256));
let rendered = if args.pretty {
serde_json::to_string_pretty(&report)
} else {
serde_json::to_string(&report)
}
.map_err(|err| format!("serialize audit report: {err}"))?;
if let Some(output) = args.output {
if let Some(parent) = output.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| format!("create output directory {}: {err}", parent.display()))?;
}
std::fs::write(&output, rendered)
.map_err(|err| format!("write audit report {}: {err}", output.display()))?;
} else {
println!("{rendered}");
}
Ok(())
}
fn parse_args(argv: &[String]) -> Result<Args, String> {
let mut args = Args {
pretty: true,
..Args::default()
};
let mut index = 1usize;
while index < argv.len() {
match argv[index].as_str() {
"--input" | "--in" => {
index += 1;
args.input = Some(PathBuf::from(
argv.get(index).ok_or("--input requires a value")?,
));
}
"--repo-bytes-db" => {
index += 1;
args.repo_bytes_db = Some(PathBuf::from(
argv.get(index).ok_or("--repo-bytes-db requires a value")?,
));
}
"--output" | "--out" => {
index += 1;
args.output = Some(PathBuf::from(
argv.get(index).ok_or("--output requires a value")?,
));
}
"--compact" => args.pretty = false,
"--pretty" => args.pretty = true,
"-h" | "--help" => return Err(usage().to_string()),
other => return Err(format!("unknown argument: {other}\n{}", usage())),
}
index += 1;
}
if args.input.is_some() == args.repo_bytes_db.is_some() {
return Err(format!("select exactly one input source\n{}", usage()));
}
if let Some(input) = args.input.as_ref()
&& !input.is_dir()
{
return Err(format!("--input must be a directory: {}", input.display()));
}
if let Some(repo_bytes_db) = args.repo_bytes_db.as_ref()
&& !repo_bytes_db.is_dir()
{
return Err(format!(
"--repo-bytes-db must be a RocksDB directory: {}",
repo_bytes_db.display()
));
}
Ok(args)
}
fn scan_directory(
directory: &Path,
report: &mut AuditReport,
seen_hashes: &mut BTreeSet<String>,
) -> Result<(), String> {
let entries = std::fs::read_dir(directory)
.map_err(|err| format!("read directory {}: {err}", directory.display()))?;
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
report.scan_errors.push(format!(
"read directory entry under {}: {err}",
directory.display()
));
continue;
}
};
let path = entry.path();
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(err) => {
report
.scan_errors
.push(format!("read file type {}: {err}", path.display()));
continue;
}
};
if file_type.is_dir() {
scan_directory(&path, report, seen_hashes)?;
} else if file_type.is_file() {
scan_file(&path, report, seen_hashes);
}
}
Ok(())
}
fn scan_file(path: &Path, report: &mut AuditReport, seen_hashes: &mut BTreeSet<String>) {
report.files_seen += 1;
let Some(object_type) = object_type_from_path(path) else {
return;
};
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(err) => {
report
.scan_errors
.push(format!("read {}: {err}", path.display()));
return;
}
};
let sha256 = hex::encode(Sha256::digest(&bytes));
let (encoding_status, detail) = classify_object(&object_type, &bytes);
record_object(
report,
seen_hashes,
sha256,
object_type,
encoding_status,
bytes.len() as u64,
path.display().to_string(),
detail,
);
}
fn scan_repo_bytes_db(
repo_bytes_db: &Path,
report: &mut AuditReport,
seen_hashes: &mut BTreeSet<String>,
) -> Result<(), String> {
let mut options = Options::default();
options.create_if_missing(false);
let db = DB::open_for_read_only(&options, repo_bytes_db, false)
.map_err(|err| format!("open repo-bytes DB {}: {err}", repo_bytes_db.display()))?;
for entry in db.iterator(IteratorMode::Start) {
let (key, bytes) = entry.map_err(|err| format!("iterate repo-bytes DB: {err}"))?;
let Ok(key) = std::str::from_utf8(&key) else {
continue;
};
let Some(sha256) = key.strip_prefix("sha256:") else {
continue;
};
report.files_seen += 1;
let (object_type, encoding_status, detail) = classify_repo_blob(&bytes);
record_object(
report,
seen_hashes,
sha256.to_string(),
object_type,
encoding_status,
bytes.len() as u64,
format!("rocksdb://{}/{}", repo_bytes_db.display(), key),
detail,
);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn record_object(
report: &mut AuditReport,
seen_hashes: &mut BTreeSet<String>,
sha256: String,
object_type: String,
encoding_status: EncodingStatus,
bytes: u64,
path: String,
detail: Option<String>,
) {
if matches!(encoding_status, EncodingStatus::NotRpki) {
report.non_rpki_entries += 1;
} else {
report.recognized_rpki_objects += 1;
}
if !seen_hashes.insert(sha256.clone()) {
report.duplicate_content_files += 1;
return;
}
let status_name = encoding_status_name(&encoding_status).to_string();
*report.status_counts.entry(status_name.clone()).or_default() += 1;
*report
.object_type_status_counts
.entry(object_type.clone())
.or_default()
.entry(status_name)
.or_default() += 1;
report.objects.push(ObjectRecord {
sha256,
object_type,
encoding_status,
bytes,
path,
detail,
});
}
fn object_type_from_path(path: &Path) -> Option<String> {
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
match extension.as_str() {
"mft" | "roa" | "asa" | "aspa" | "gbr" | "cer" | "crl" => Some(extension),
_ => None,
}
}
fn classify_object(object_type: &str, bytes: &[u8]) -> (EncodingStatus, Option<String>) {
match object_type {
"mft" | "roa" | "asa" | "aspa" | "gbr" => match RpkiSignedObject::parse_der(bytes) {
Ok(_) => match RpkiSignedObject::strict_cms_der_error(bytes) {
Some(err) => (EncodingStatus::BerCompatibleOnly, Some(err.to_string())),
None => (EncodingStatus::Der, None),
},
Err(err) => (EncodingStatus::ParseFailed, Some(err.to_string())),
},
"cer" => match ResourceCertificate::parse_der(bytes) {
Ok(_) => (EncodingStatus::Der, None),
Err(err) => (EncodingStatus::ParseFailed, Some(err.to_string())),
},
"crl" => match RpkixCrl::parse_der(bytes) {
Ok(_) => (EncodingStatus::Der, None),
Err(err) => (EncodingStatus::ParseFailed, Some(err.to_string())),
},
_ => unreachable!("object type was filtered before classification"),
}
}
fn classify_repo_blob(bytes: &[u8]) -> (String, EncodingStatus, Option<String>) {
if looks_like_tal_metadata(bytes) {
return (
"tal".to_string(),
EncodingStatus::NotRpki,
Some("TAL text metadata, not an RPKI object".to_string()),
);
}
if RpkiSignedObject::parse_der(bytes).is_ok() {
return match RpkiSignedObject::strict_cms_der_error(bytes) {
Some(err) => (
"cms_signed_object".to_string(),
EncodingStatus::BerCompatibleOnly,
Some(err.to_string()),
),
None => ("cms_signed_object".to_string(), EncodingStatus::Der, None),
};
}
if ResourceCertificate::parse_der(bytes).is_ok() {
return ("cer".to_string(), EncodingStatus::Der, None);
}
if RpkixCrl::parse_der(bytes).is_ok() {
return ("crl".to_string(), EncodingStatus::Der, None);
}
(
"unknown".to_string(),
EncodingStatus::ParseFailed,
Some("not parsed as CMS, RPKI resource certificate, or CRL".to_string()),
)
}
fn encoding_status_name(status: &EncodingStatus) -> &'static str {
match status {
EncodingStatus::Der => "der",
EncodingStatus::BerCompatibleOnly => "ber_compatible_only",
EncodingStatus::ParseFailed => "parse_failed",
EncodingStatus::NotRpki => "not_rpki",
}
}
/// Return true for the TAL text format persisted by the live-TA refresh.
///
/// A TAL is intentionally not DER: it contains an rsync URI, an HTTPS URI,
/// a blank separator, and the base64-encoded trust-anchor certificate. It
/// can share the repo-bytes DB with downloaded RPKI objects, so the audit must
/// keep it out of the object encoding verdict rather than report a false
/// parse failure.
fn looks_like_tal_metadata(bytes: &[u8]) -> bool {
let Ok(text) = std::str::from_utf8(bytes) else {
return false;
};
let mut lines = text.lines();
let Some(rsync_uri) = lines.next() else {
return false;
};
let Some(https_uri) = lines.next() else {
return false;
};
if !rsync_uri.starts_with("rsync://") || !https_uri.starts_with("https://") {
return false;
}
// `str::lines` removes the blank line, so inspect the raw prefix to make
// sure the URI pair is followed by the TAL separator.
let Some(separator) = text.find("\n\n") else {
return false;
};
let base64 = &text[separator + 2..];
!base64.is_empty()
&& base64.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=' | b'\n' | b'\r')
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_known_der_manifest_fixture() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(
"tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft",
);
let bytes = std::fs::read(path).expect("read manifest fixture");
assert!(matches!(
classify_object("mft", &bytes).0,
EncodingStatus::Der
));
}
#[test]
fn recognizes_supported_object_extensions() {
assert_eq!(
object_type_from_path(Path::new("a.roa")),
Some("roa".to_string())
);
assert_eq!(object_type_from_path(Path::new("a.txt")), None);
}
#[test]
fn classifies_tal_metadata_as_not_rpki() {
let tal = b"rsync://rpki.example/ta.cer\nhttps://rpki.example/ta.cer\n\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\n";
let (object_type, status, detail) = classify_repo_blob(tal);
assert_eq!(object_type, "tal");
assert!(matches!(status, EncodingStatus::NotRpki));
assert_eq!(
detail.as_deref(),
Some("TAL text metadata, not an RPKI object")
);
}
}

View File

@@ -11,7 +11,7 @@ use rpki::query::report_stream::{ObjectFilter, ObjectScope};
use rpki::query::vrp::VrpLookup;
use rpki::query_db::{
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb,
QueryDbError, ValidationExplainRecord,
QueryDbError, RepoSortOrder, ValidationExplainRecord,
};
use serde::Serialize;
use serde_json::{Value, json};
@@ -150,9 +150,20 @@ fn real_main() -> Result<(), String> {
let db = Arc::new(open_query_db_for_service(&args).map_err(|e| e.to_string())?);
let export_jobs = Arc::new(Mutex::new(BTreeMap::new()));
let repo_bytes = match args.repo_bytes_db.as_ref() {
Some(path) => Some(Arc::new(
ExternalRepoBytesDb::open_read_only(path).map_err(|e| e.to_string())?,
)),
Some(path) => {
let opened = if args.watch_run_root.is_some() {
// Watch mode tracks a live soak that keeps writing new object
// bytes. A plain read-only open is frozen at open time (new
// manifests/CRLs/objects become unparsable until restart), so
// use a secondary instance and catch up in the watcher loop.
let secondary_path = args.query_db.with_extension("repo-bytes.secondary");
ExternalRepoBytesDb::open_as_secondary(path, &secondary_path)
.map_err(|e| e.to_string())?
} else {
ExternalRepoBytesDb::open_read_only(path).map_err(|e| e.to_string())?
};
Some(Arc::new(opened))
}
None => None,
};
if let Some(run_root) = args.watch_run_root.clone() {
@@ -163,6 +174,7 @@ fn real_main() -> Result<(), String> {
)?;
spawn_indexer_watcher(
Arc::clone(&db),
repo_bytes.clone(),
args.query_db.clone(),
resolve_indexer_bin(args.indexer_bin.clone())?,
run_root,
@@ -229,6 +241,7 @@ fn resolve_effective_watch_min_run_seq(
fn spawn_indexer_watcher(
db: Arc<QueryDb>,
repo_bytes: Option<Arc<ExternalRepoBytesDb>>,
query_db_path: PathBuf,
indexer_bin: PathBuf,
run_root: PathBuf,
@@ -250,6 +263,9 @@ fn spawn_indexer_watcher(
) {
Ok(message) => {
let _ = db.try_catch_up_with_primary();
if let Some(repo_bytes) = repo_bytes.as_ref() {
let _ = repo_bytes.try_catch_up_with_primary();
}
eprintln!("query watcher indexed {}: {message}", run_dir.display());
}
Err(err) => eprintln!(
@@ -260,6 +276,9 @@ fn spawn_indexer_watcher(
}
Ok(None) => {
let _ = db.try_catch_up_with_primary();
if let Some(repo_bytes) = repo_bytes.as_ref() {
let _ = repo_bytes.try_catch_up_with_primary();
}
}
Err(err) => eprintln!("query watcher index failed: {err}"),
}
@@ -523,7 +542,18 @@ impl ApiError {
impl From<QueryDbError> for ApiError {
fn from(value: QueryDbError) -> Self {
Self::new(500, value.to_string())
match value {
// The run is still indexed but its report.json was removed by
// retention cleanup: this is a client-visible "gone", not a
// server fault.
QueryDbError::RunDataGone(run_id) => Self::new(
404,
format!(
"report data for run {run_id} has expired and was cleaned up by retention"
),
),
other => Self::new(500, other.to_string()),
}
}
}
@@ -536,11 +566,10 @@ fn route_request(
let (path, query) = split_target(target);
let query = parse_query(query);
let path = path.trim_end_matches('/');
let segments = path
.trim_start_matches("/api/v1")
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
let decoded_segments = decode_path_segments(path);
let segments = decoded_segments
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
if segments.is_empty() {
return Ok(json!({"data":{"service":"rpki_query_service","version":1}}));
@@ -617,10 +646,11 @@ fn route_request(
}
["runs", raw_run_id, "repos"] => {
let run_id = resolve_run(db, raw_run_id)?;
page_response(
db.list_repos(&run_id, limit(&query), cursor(&query))?,
Some(run_id),
)
let page = match repo_sort_from_query(&query)? {
Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?,
None => db.list_repos(&run_id, limit(&query), cursor(&query))?,
};
page_response(page, Some(run_id))
}
["runs", raw_run_id, "repos", repo_id] => {
let run_id = resolve_run(db, raw_run_id)?;
@@ -907,10 +937,11 @@ fn route_request(
}
["runs", raw_run_id, "stats", "repos"] => {
let run_id = resolve_run(db, raw_run_id)?;
page_response(
db.list_repos(&run_id, limit(&query), cursor(&query))?,
Some(run_id),
)
let page = match repo_sort_from_query(&query)? {
Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?,
None => db.list_repos(&run_id, limit(&query), cursor(&query))?,
};
page_response(page, Some(run_id))
}
["runs", raw_run_id, "stats", "publication-points"] => {
let run_id = resolve_run(db, raw_run_id)?;
@@ -983,11 +1014,10 @@ fn route_post_request(
) -> Result<Value, ApiError> {
let (path, _) = split_target(target);
let path = path.trim_end_matches('/');
let segments = path
.trim_start_matches("/api/v1")
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
let decoded_segments = decode_path_segments(path);
let segments = decoded_segments
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
match segments.as_slice() {
["runs", raw_run_id, "exports"] => {
@@ -1140,11 +1170,10 @@ fn route_raw_request(
) -> Option<Result<Vec<u8>, ApiError>> {
let (path, _) = split_target(target);
let path = path.trim_end_matches('/');
let segments = path
.trim_start_matches("/api/v1")
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
let decoded_segments = decode_path_segments(path);
let segments = decoded_segments
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
match segments.as_slice() {
["runs", raw_run_id, "objects", object_instance_id, "raw"] => Some(raw_object_response(
@@ -1205,7 +1234,20 @@ fn export_download_response(
.as_ref()
.ok_or_else(|| ApiError::new(404, "export output path not found"))?;
let bytes = fs::read(path).map_err(|err| ApiError::new(404, err.to_string()))?;
Ok(binary_response(200, "application/x-tar", bytes))
let filename = format!(
"rpki-export-{}-{}.tar",
sanitize_filename_token(&run_id),
sanitize_filename_token(job_id)
);
Ok(binary_response_with_headers(
200,
"application/x-tar",
&[(
"Content-Disposition".to_string(),
format!("attachment; filename=\"{filename}\""),
)],
bytes,
))
}
fn export_job_store_key(run_id: &str, job_id: &str) -> String {
@@ -2079,6 +2121,18 @@ fn hex_value(byte: u8) -> Option<u8> {
}
}
/// Split an API path into percent-decoded segments. Real-world ids are plain
/// hex, so decoding is a no-op for them; it only matters for hand-built URLs
/// (e.g. an id containing a literal `%2F`).
fn decode_path_segments(path: &str) -> Vec<String> {
path.trim_start_matches("/api/v1")
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.map(|segment| percent_decode(segment).unwrap_or_else(|| segment.to_string()))
.collect()
}
fn limit(query: &BTreeMap<String, String>) -> usize {
query
.get("limit")
@@ -2103,6 +2157,18 @@ fn exports_limit(query: &BTreeMap<String, String>) -> usize {
.clamp(1, 200)
}
fn repo_sort_from_query(query: &BTreeMap<String, String>) -> Result<Option<RepoSortOrder>, ApiError> {
match query.get("sort").map(|value| value.as_str()) {
None => Ok(None),
Some("objects") => match query.get("order").map(|value| value.as_str()) {
None | Some("desc") => Ok(Some(RepoSortOrder::ObjectsDesc)),
Some("asc") => Ok(Some(RepoSortOrder::ObjectsAsc)),
Some(other) => Err(ApiError::new(400, format!("invalid repos order: {other}"))),
},
Some(other) => Err(ApiError::new(400, format!("invalid repos sort: {other}"))),
}
}
fn object_filter_from_query(query: &BTreeMap<String, String>) -> Result<ObjectFilter, ApiError> {
let mut filter = ObjectFilter::default();
if let Some(value) = query.get("type") {
@@ -2229,13 +2295,26 @@ fn json_response(status: u16, value: &Value) -> Vec<u8> {
}
fn binary_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
binary_response_with_headers(status, content_type, &[], body)
}
fn binary_response_with_headers(
status: u16,
content_type: &str,
extra_headers: &[(String, String)],
body: Vec<u8>,
) -> Vec<u8> {
let reason = match status {
200 => "OK",
404 => "Not Found",
_ => "Internal Server Error",
};
let mut head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n");
for (name, value) in extra_headers {
head.push_str(&format!("{name}: {value}\r\n"));
}
let mut response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
"{head}Content-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.into_bytes();
@@ -2243,6 +2322,15 @@ fn binary_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
response
}
/// Header-safe token for use inside a `Content-Disposition` filename: strips
/// anything that could smuggle CRLF or quotes into the response head.
fn sanitize_filename_token(value: &str) -> String {
value
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
.collect()
}
fn is_sha256_hex(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
@@ -2607,6 +2695,94 @@ mod tests {
);
}
fn index_sample_run(temp: &tempfile::TempDir) -> QueryDb {
let run_dir = temp.path().join("runs/run_0001");
fs::create_dir_all(&run_dir).expect("run dir");
let object_bytes =
fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa")
.expect("fixture roa");
let object_sha = hex::encode(Sha256::digest(&object_bytes));
write_sample_run(&run_dir, &object_sha);
let query_db_path = temp.path().join("query-db");
index_artifacts(&ArtifactIndexerConfig {
query_db_path: query_db_path.clone(),
run_root: Some(temp.path().to_path_buf()),
run_dir: None,
repo_bytes_db_path: None,
projection_entry_limit: 5,
min_run_seq: None,
retain_indexed_runs: None,
})
.expect("index artifacts");
QueryDb::open(&query_db_path).expect("query db")
}
#[test]
fn unknown_run_id_returns_404_instead_of_empty_payload() {
let temp = tempfile::tempdir().expect("tempdir");
let db = index_sample_run(&temp);
for target in [
"/api/v1/runs/run_9999",
"/api/v1/runs/run_9999/summary",
"/api/v1/runs/run_9999/objects",
"/api/v1/runs/run_9999/issues",
"/api/v1/runs/run_9999/stats/overview",
"/api/v1/runs/run_9999/stats/validation-events?name=manifest",
] {
let err = match test_route_request(&db, None, target) {
Ok(_) => panic!("expected 404 for {target}"),
Err(err) => err,
};
assert_eq!(err.status, 404, "{target}");
}
// Existing runs and "latest" keep working.
assert!(
test_route_request(&db, None, "/api/v1/runs/run_0001/stats/overview").is_ok()
);
assert!(test_route_request(&db, None, "/api/v1/runs/latest/objects").is_ok());
}
#[test]
fn expired_run_report_returns_404_run_data_gone() {
let temp = tempfile::tempdir().expect("tempdir");
let db = index_sample_run(&temp);
assert!(test_route_request(&db, None, "/api/v1/runs/run_0001/objects").is_ok());
// Retention cleanup removes the run directory while the index record
// survives: object routes must report a distinguishable 404.
fs::remove_dir_all(temp.path().join("runs/run_0001")).expect("remove run dir");
let err = test_route_request(&db, None, "/api/v1/runs/run_0001/objects")
.expect_err("run data gone");
assert_eq!(err.status, 404);
assert!(
err.message.contains("expired"),
"unexpected message: {}",
err.message
);
let err = test_route_request(&db, None, "/api/v1/runs/run_0001/issues")
.expect_err("run data gone");
assert_eq!(err.status, 404);
// Stats served from the index keep working.
assert!(
test_route_request(&db, None, "/api/v1/runs/run_0001/stats/overview").is_ok()
);
}
#[test]
fn decode_path_segments_percent_decodes_ids() {
let segments = decode_path_segments("/api/v1/runs/run_0001/objects/abc%2Fdef");
assert_eq!(segments, vec!["runs", "run_0001", "objects", "abc/def"]);
assert!(decode_path_segments("/api/v1").is_empty());
assert!(decode_path_segments("/api/v1/").is_empty());
}
#[test]
fn sanitize_filename_token_strips_crlf_and_quotes() {
assert_eq!(sanitize_filename_token("run_0001"), "run_0001");
assert_eq!(sanitize_filename_token("a\r\nb\"c d/e"), "abcde");
}
#[test]
fn raw_route_downloads_object_bytes_from_repo_bytes_db() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -2794,6 +2970,31 @@ mod tests {
assert_eq!(job.status, "complete");
assert_eq!(job.object_count, 1);
assert!(job.bytes_written > 0);
let download = test_route_raw_request(
&db,
None,
&format!("/api/v1/runs/latest/exports/{job_id}/download"),
)
.expect("download route")
.expect("download response");
let head_end = download
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("header separator");
let head = String::from_utf8_lossy(&download[..head_end]);
assert!(head.starts_with("HTTP/1.1 200 OK"), "head: {head}");
assert!(
head.contains("Content-Type: application/x-tar\r\n"),
"head: {head}"
);
let expected_disposition = format!(
"Content-Disposition: attachment; filename=\"rpki-export-run_0001-{job_id}.tar\""
);
assert!(
head.contains(&expected_disposition),
"head: {head}"
);
}
#[test]

View File

@@ -78,6 +78,7 @@ pub struct ExternalRepoBytesDb {
path: PathBuf,
db: Arc<DB>,
read_only: bool,
secondary: bool,
}
impl ExternalRawStoreDb {
@@ -89,6 +90,7 @@ impl ExternalRawStoreDb {
let mut opts = Options::default();
opts.create_if_missing(true);
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
opts.set_max_open_files(512);
let db = DB::open(&opts, &path).map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(Self {
path,
@@ -186,11 +188,13 @@ impl ExternalRepoBytesDb {
let mut opts = Options::default();
opts.create_if_missing(true);
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
opts.set_max_open_files(512);
let db = DB::open(&opts, &path).map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(Self {
path,
db: Arc::new(db),
read_only: false,
secondary: false,
})
}
@@ -198,15 +202,60 @@ impl ExternalRepoBytesDb {
let path = path.into();
let mut opts = Options::default();
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
opts.set_max_open_files(512);
let db = DB::open_for_read_only(&opts, &path, false)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(Self {
path,
db: Arc::new(db),
read_only: true,
secondary: false,
})
}
/// Open the repo-bytes DB as a RocksDB secondary instance.
///
/// Unlike `open_read_only` (a frozen point-in-time view), a secondary
/// instance can follow the live primary via `try_catch_up_with_primary`,
/// which is required when the soak keeps writing new object bytes while
/// the query service is running.
pub fn open_as_secondary(
path: impl Into<PathBuf>,
secondary_path: impl Into<PathBuf>,
) -> StorageResult<Self> {
let path = path.into();
let secondary_path = secondary_path.into();
if let Some(parent) = secondary_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| StorageError::RocksDb(e.to_string()))?;
}
let mut opts = Options::default();
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
opts.set_max_open_files(512);
let db = DB::open_as_secondary(&opts, &path, &secondary_path)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(Self {
path,
db: Arc::new(db),
read_only: true,
secondary: true,
})
}
/// Pull the secondary view up to the primary's current state. No-op for
/// non-secondary handles.
pub fn try_catch_up_with_primary(&self) -> StorageResult<()> {
if self.secondary {
self.db
.try_catch_up_with_primary()
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
}
Ok(())
}
pub(crate) fn is_secondary(&self) -> bool {
self.secondary
}
pub fn put_blob_bytes_batch(&self, blobs: &[(String, Vec<u8>)]) -> StorageResult<()> {
if blobs.is_empty() {
return Ok(());
@@ -740,4 +789,48 @@ mod tests {
);
assert!(repo_bytes.get_blob_bytes("not-a-valid-hash").is_err());
}
#[test]
fn external_repo_bytes_db_secondary_catches_up_with_live_primary() {
let td = tempfile::tempdir().expect("tempdir");
let primary_path = td.path().join("repo-bytes.db");
let secondary_path = td.path().join("repo-bytes.secondary");
let primary = ExternalRepoBytesDb::open(&primary_path).expect("open primary");
let bytes_a = b"repo-bytes-a".to_vec();
let hash_a = sha256_hex(&bytes_a);
primary
.put_blob_bytes_batch(&[(hash_a.clone(), bytes_a.clone())])
.expect("put a");
let secondary =
ExternalRepoBytesDb::open_as_secondary(&primary_path, &secondary_path)
.expect("open secondary");
assert!(secondary.is_secondary());
// A secondary open does not necessarily see pre-existing data until it
// catches up with the primary's manifest.
secondary
.try_catch_up_with_primary()
.expect("initial catch up");
assert_eq!(
secondary.get_blob_bytes(&hash_a).expect("get a via secondary"),
Some(bytes_a)
);
// Bytes written by the primary *after* the secondary open become
// visible after another catch-up (this is the live-soak scenario).
let bytes_b = b"repo-bytes-b".to_vec();
let hash_b = sha256_hex(&bytes_b);
primary
.put_blob_bytes_batch(&[(hash_b.clone(), bytes_b.clone())])
.expect("put b after secondary open");
secondary
.try_catch_up_with_primary()
.expect("second catch up");
assert_eq!(
secondary.get_blob_bytes(&hash_b).expect("get b via secondary"),
Some(bytes_b)
);
}
}

View File

@@ -406,6 +406,7 @@ mod tests {
sha256: manifest_hash,
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
}],
summary: VcirSummary {
local_vrp_count: 0,

View File

@@ -729,6 +729,7 @@ mod tests {
sha256: hash,
object_type: Some("mft".to_string()),
validation_status: crate::storage::VcirArtifactValidationStatus::Accepted,
reject_reason: None,
}],
summary: crate::storage::VcirSummary {
local_vrp_count: 0,

View File

@@ -267,6 +267,7 @@ mod tests {
sha256: hash,
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
}],
summary: VcirSummary {
local_vrp_count: 0,

View File

@@ -645,6 +645,7 @@ mod tests {
sha256: "ff".repeat(32),
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
}],
summary: VcirSummary {
local_vrp_count: 0,

View File

@@ -140,6 +140,26 @@ impl CirInputAccumulator {
rejects.entry(rsync_uri.to_string()).or_insert(reason);
}
/// Merge another accumulator into this one. Object digests conflict-check
/// exactly like repeated `insert_object` calls; rejects keep the first
/// reason seen, matching sequential submission order when `other` holds
/// later publication points than `self`.
pub fn merge(&mut self, other: CirInputAccumulator) -> Result<(), CirExportError> {
for (rsync_uri, digest) in other.fresh_objects {
self.insert_object_digest(CirInputSection::Fresh, &rsync_uri, digest)?;
}
for (rsync_uri, digest) in other.cached_objects {
self.insert_object_digest(CirInputSection::Cached, &rsync_uri, digest)?;
}
for (rsync_uri, reason) in other.fresh_rejects {
self.insert_reject(CirInputSection::Fresh, &rsync_uri, reason);
}
for (rsync_uri, reason) in other.cached_rejects {
self.insert_reject(CirInputSection::Cached, &rsync_uri, reason);
}
Ok(())
}
pub fn finalize(self) -> CirInputSnapshot {
CirInputSnapshot {
fresh_validated_objects: finalize_objects(self.fresh_objects),
@@ -280,4 +300,95 @@ mod tests {
assert_eq!(merged.cached_validated_objects.len(), 1);
assert_eq!(merged.fresh_rejected_objects.len(), 1);
}
#[test]
fn accumulator_merge_matches_sequential_submission() {
let mut sequential = CirInputAccumulator::default();
sequential
.submit_audit_entries(
CirInputSection::Fresh,
&[
entry("rsync://example.net/a.roa", 0x11, AuditObjectResult::Ok),
entry("rsync://example.net/b.roa", 0x22, AuditObjectResult::Error),
],
)
.unwrap();
sequential
.submit_audit_entries(
CirInputSection::Cached,
&[entry(
"rsync://example.net/c.roa",
0x33,
AuditObjectResult::Ok,
)],
)
.unwrap();
let mut first = CirInputAccumulator::default();
first
.submit_audit_entries(
CirInputSection::Fresh,
&[entry("rsync://example.net/a.roa", 0x11, AuditObjectResult::Ok)],
)
.unwrap();
let mut second = CirInputAccumulator::default();
second
.submit_audit_entries(
CirInputSection::Fresh,
&[entry(
"rsync://example.net/b.roa",
0x22,
AuditObjectResult::Error,
)],
)
.unwrap();
second
.submit_audit_entries(
CirInputSection::Cached,
&[entry(
"rsync://example.net/c.roa",
0x33,
AuditObjectResult::Ok,
)],
)
.unwrap();
first.merge(second).unwrap();
assert_eq!(sequential.finalize(), first.finalize());
}
#[test]
fn accumulator_merge_detects_conflicting_hashes_across_shards() {
let mut first = CirInputAccumulator::default();
first
.insert_object(CirInputSection::Fresh, "rsync://example.net/a.roa", &"11".repeat(32))
.unwrap();
let mut second = CirInputAccumulator::default();
second
.insert_object(CirInputSection::Fresh, "rsync://example.net/a.roa", &"22".repeat(32))
.unwrap();
let err = first.merge(second).unwrap_err();
assert!(matches!(err, CirExportError::ConflictingObjectHash { .. }));
}
#[test]
fn accumulator_merge_keeps_first_reject_reason() {
let mut first = CirInputAccumulator::default();
first.insert_reject(
CirInputSection::Fresh,
"rsync://example.net/a.roa",
Some("first".to_string()),
);
let mut second = CirInputAccumulator::default();
second.insert_reject(
CirInputSection::Fresh,
"rsync://example.net/a.roa",
Some("second".to_string()),
);
first.merge(second).unwrap();
let snapshot = first.finalize();
assert_eq!(
snapshot.fresh_rejected_objects[0].reason,
Some("first".to_string())
);
}
}

View File

@@ -26,6 +26,7 @@ use crate::parallel::config::{ParallelPhase1Config, ParallelPhase2Config};
use crate::parallel::types::TalInputSpec;
use crate::policy::{Policy, ResourceValidationMode, StrictPolicy};
use crate::storage::{RocksStore, VcirStorageSummary};
use crate::ta_constraints::TaConstraintsByTal;
use crate::validation::run_tree_from_tal::{
RunTreeFromTalAuditOutput, run_tree_from_multiple_tals_parallel_phase2_audit,
run_tree_from_multiple_tals_parallel_phase2_audit_with_timing,
@@ -55,6 +56,7 @@ struct RunStageTiming {
enable_child_certificate_validation_cache: bool,
publication_point_cache_observe_only: bool,
enable_publication_point_validation_cache: bool,
crypto_signature_cache_observe: Option<crate::crypto_sig_cache::CryptoSigCacheSummary>,
enable_transport_request_prefetch: bool,
report_build_ms: u64,
report_write_ms: Option<u64>,
@@ -130,6 +132,7 @@ pub struct CliArgs {
pub parallel_phase1_config: ParallelPhase1Config,
pub parallel_phase2_config: ParallelPhase2Config,
pub tal_inputs: Vec<TalInputSpec>,
pub ta_constraints: TaConstraintsByTal,
pub db_path: PathBuf,
pub raw_store_db: Option<PathBuf>,
@@ -145,6 +148,8 @@ pub struct CliArgs {
pub enable_child_certificate_validation_cache: bool,
pub publication_point_cache_observe_only: bool,
pub enable_publication_point_validation_cache: bool,
pub crypto_signature_cache_observe_only: bool,
pub enable_crypto_signature_cache: bool,
pub enable_transport_request_prefetch: bool,
pub ccr_out_path: Option<PathBuf>,
pub vrps_csv_out_path: Option<PathBuf>,
@@ -207,6 +212,8 @@ Options:
--raw-store-db <path> External raw-by-hash store DB path (optional)
--repo-bytes-db <path> External repo object bytes DB path (optional)
--policy <path> Policy TOML path (optional)
--ta-constraints <tal-id>=<path>
Apply local EE-resource constraints to one TAL (repeatable); adjacent <tal>.constraints files are auto-discovered
--strict [policies] Enable strict policies (default all; comma list: name,cms-der,signed-attrs; none disables)
--resource-validation-mode <validation-update-03|rfc6487>
Resource certificate validation mode (default: validation-update-03)
@@ -222,6 +229,12 @@ Options:
Evaluate publication-point cache eligibility without changing results
--enable-publication-point-validation-cache
Experimental: reuse complete publication-point validation projections
--crypto-signature-cache-observe-only
Measure crypto signature cache key hit rates and verify durations
without changing validation behavior (default: off)
--enable-crypto-signature-cache
Experimental: skip cryptographic signature verification on cache
hits (positive conclusions only; default: off)
--enable-transport-request-prefetch
Experimental: prefetch previous run transport repo requests before tree traversal
--ccr-out <path> Write CCR DER ContentInfo to this path (optional)
@@ -267,6 +280,16 @@ Options:
Legacy Phase 2 scheduler finalize time budget; dedicated finalize worker ignores it (default: 100)
--parallel-phase2-finalize-queue-capacity <n>
Phase 2 dedicated finalize worker queue capacity (default: 32768)
--control-plane-stage-workers <n>
Experimental: Phase 2 ready publication point stage worker count;
0 disables the stage pool and keeps inline staging (default: 0)
--dead-repo-blacklist <path>
Enable the dead-repo transport blacklist persisted at this JSON
path (default: disabled). Blacklisted rrdp repos skip straight to
rsync; dual-blacklisted repos terminate instantly.
--dead-repo-blacklist-fail-threshold <n>
Consecutive runs with transport-class fetch failure before a
(repo, transport) entry is blacklisted (default: 3)
--rsync-local-dir <path> Use LocalDirRsyncFetcher rooted at this directory (offline tests)
--disable-rrdp Disable RRDP and synchronize only via rsync
@@ -299,8 +322,11 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
let mut tal_urls: Vec<String> = Vec::new();
let mut tal_paths: Vec<PathBuf> = Vec::new();
let mut ta_paths: Vec<PathBuf> = Vec::new();
let mut ta_constraint_specs: Vec<String> = Vec::new();
let mut parallel_phase1_cfg = ParallelPhase1Config::default();
let mut parallel_phase2_cfg = ParallelPhase2Config::default();
let mut dead_repo_blacklist_path: Option<PathBuf> = None;
let mut dead_repo_blacklist_fail_threshold: Option<u32> = None;
let mut db_path: Option<PathBuf> = None;
let mut raw_store_db: Option<PathBuf> = None;
@@ -315,6 +341,8 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
let mut enable_roa_validation_cache: bool = false;
let mut enable_child_certificate_validation_cache: bool = false;
let mut publication_point_cache_observe_only: bool = false;
let mut crypto_signature_cache_observe_only: bool = false;
let mut enable_crypto_signature_cache: bool = false;
let mut enable_publication_point_validation_cache: bool = false;
let mut enable_transport_request_prefetch: bool = false;
let mut ccr_out_path: Option<PathBuf> = None;
@@ -404,6 +432,13 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
let v = argv.get(i).ok_or("--ta-path requires a value")?;
ta_paths.push(PathBuf::from(v));
}
"--ta-constraints" => {
i += 1;
let v = argv
.get(i)
.ok_or("--ta-constraints requires <tal-id>=<path>")?;
ta_constraint_specs.push(v.clone());
}
"--parallel-max-repo-sync-workers-global" => {
i += 1;
let v = argv
@@ -508,6 +543,32 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
format!("invalid --parallel-phase2-finalize-queue-capacity: {v}")
})?;
}
"--control-plane-stage-workers" => {
i += 1;
let v = argv
.get(i)
.ok_or("--control-plane-stage-workers requires a value")?;
parallel_phase2_cfg.stage_workers = v
.parse::<usize>()
.map_err(|_| format!("invalid --control-plane-stage-workers: {v}"))?;
}
"--dead-repo-blacklist" => {
i += 1;
let v = argv
.get(i)
.ok_or("--dead-repo-blacklist requires a value")?;
dead_repo_blacklist_path = Some(PathBuf::from(v));
}
"--dead-repo-blacklist-fail-threshold" => {
i += 1;
let v = argv
.get(i)
.ok_or("--dead-repo-blacklist-fail-threshold requires a value")?;
dead_repo_blacklist_fail_threshold = Some(
v.parse::<u32>()
.map_err(|_| format!("invalid --dead-repo-blacklist-fail-threshold: {v}"))?,
);
}
"--db" => {
i += 1;
let v = argv.get(i).ok_or("--db requires a value")?;
@@ -579,6 +640,12 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
"--enable-publication-point-validation-cache" => {
enable_publication_point_validation_cache = true;
}
"--crypto-signature-cache-observe-only" => {
crypto_signature_cache_observe_only = true;
}
"--enable-crypto-signature-cache" => {
enable_crypto_signature_cache = true;
}
"--enable-transport-request-prefetch" => {
enable_transport_request_prefetch = true;
}
@@ -808,6 +875,8 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|| enable_child_certificate_validation_cache
|| publication_point_cache_observe_only
|| enable_publication_point_validation_cache
|| crypto_signature_cache_observe_only
|| enable_crypto_signature_cache
|| enable_transport_request_prefetch
|| rsync_local_dir.is_some()
|| rsync_command.is_some()
@@ -932,6 +1001,12 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
));
}
}
if verification_only && !ta_constraint_specs.is_empty() {
return Err(format!(
"--ta-constraints is not supported with --verification-only\\n\\n{}",
usage()
));
}
let tal_url = tal_urls.first().cloned();
let tal_path = tal_paths.first().cloned();
let ta_path = ta_paths.first().cloned();
@@ -1090,6 +1165,26 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
tal_inputs.extend(tal_paths.iter().cloned().map(TalInputSpec::from_file_path));
}
}
let ta_constraints = TaConstraintsByTal::load_for_tals(&tal_inputs, &ta_constraint_specs)?;
if dead_repo_blacklist_fail_threshold.is_some() && dead_repo_blacklist_path.is_none() {
return Err(
"--dead-repo-blacklist-fail-threshold requires --dead-repo-blacklist".to_string()
);
}
if let Some(path) = dead_repo_blacklist_path {
let fail_threshold = dead_repo_blacklist_fail_threshold.unwrap_or(3);
if fail_threshold == 0 {
return Err("--dead-repo-blacklist-fail-threshold must be >= 1".to_string());
}
parallel_phase1_cfg.dead_repo_blacklist =
Some(crate::parallel::dead_repo_blacklist::DeadRepoBlacklistConfig {
path,
fail_threshold,
capacity:
crate::parallel::dead_repo_blacklist::DEAD_REPO_BLACKLIST_DEFAULT_CAPACITY,
});
}
Ok(CliArgs {
verification_only,
@@ -1107,6 +1202,7 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
parallel_phase1_config: parallel_phase1_cfg,
parallel_phase2_config: parallel_phase2_cfg,
tal_inputs,
ta_constraints,
db_path,
raw_store_db,
repo_bytes_db,
@@ -1121,6 +1217,8 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
enable_child_certificate_validation_cache,
publication_point_cache_observe_only,
enable_publication_point_validation_cache,
crypto_signature_cache_observe_only,
enable_crypto_signature_cache,
enable_transport_request_prefetch,
ccr_out_path,
vrps_csv_out_path,
@@ -2012,7 +2110,11 @@ where
H: crate::sync::rrdp::Fetcher + Clone + 'static,
R: crate::fetch::rsync::RsyncFetcher + Clone + 'static,
{
if args.verification_only || args.tal_inputs.len() > 1 {
// The multi-TAL entry point preserves the TAL id supplied by the CLI.
// A single-file TAL may otherwise derive its id from the embedded TA URI,
// which is intentionally different from the local filename used for
// adjacent <tal>.constraints discovery.
if args.verification_only || args.tal_inputs.len() > 1 || !policy.ta_constraints.is_empty() {
return if let Some(t) = timing {
run_tree_from_multiple_tals_parallel_phase2_audit_with_timing(
store,
@@ -2152,6 +2254,36 @@ where
}
}
fn policy_for_verification_contract(
contract: &crate::verification_only::ValidationContract,
) -> crate::policy::Policy {
// Preserve every contracted policy field, including sync_preference. The
// verification fetchers themselves are network-disabled; changing this
// field would change the VCIR reuse-identity fingerprint and falsely make
// a contract-selected fallback ineligible.
let mut policy = contract.policy.clone();
policy.ca_failed_fetch_policy = if contract.fallback_replay_selections.is_empty() {
crate::policy::CaFailedFetchPolicy::StopAllOutput
} else {
crate::policy::CaFailedFetchPolicy::ReuseCurrentInstanceVcir
};
policy.verification_forced_vcir_reuse_manifest_uris = contract
.fallback_replay_selections
.iter()
.map(|selection| selection.manifest_rsync_uri.clone())
.collect();
policy
}
fn transport_prefetch_for_verification_contract(
contract: &crate::verification_only::ValidationContract,
) -> bool {
// This only restores the source run's transport scheduling and request
// deduplication topology. It does not enable validation-result reuse or
// permit network access.
contract.cache.transport_prefetch
}
pub fn run(argv: &[String]) -> Result<(), String> {
let mut args = parse_args(argv)?;
let mut prepared_verification = if args.verification_only {
@@ -2176,7 +2308,11 @@ pub fn run(argv: &[String]) -> Result<(), String> {
args.enable_child_certificate_validation_cache = false;
args.publication_point_cache_observe_only = false;
args.enable_publication_point_validation_cache = false;
args.enable_transport_request_prefetch = false;
args.crypto_signature_cache_observe_only = false;
args.enable_crypto_signature_cache = false;
args.enable_transport_request_prefetch =
transport_prefetch_for_verification_contract(&prepared.contract);
args.rsync_scope_policy = prepared.contract.rsync_scope_policy;
args.ccr_out_path = Some(prepared.artifacts.result_ccr.clone());
args.vrps_csv_out_path = Some(prepared.artifacts.vrps_csv.clone());
args.vaps_csv_out_path = Some(prepared.artifacts.vaps_csv.clone());
@@ -2196,7 +2332,7 @@ pub fn run(argv: &[String]) -> Result<(), String> {
};
let mut policy = if let Some(prepared) = prepared_verification.as_ref() {
prepared.contract.policy.clone()
policy_for_verification_contract(&prepared.contract)
} else {
read_policy(args.policy_path.as_deref())?
};
@@ -2210,9 +2346,10 @@ pub fn run(argv: &[String]) -> Result<(), String> {
if args.disable_rrdp {
policy.sync_preference = crate::policy::SyncPreference::RsyncOnly;
}
} else {
policy.sync_preference = crate::policy::SyncPreference::RsyncOnly;
policy.ca_failed_fetch_policy = crate::policy::CaFailedFetchPolicy::StopAllOutput;
policy.ta_constraints = args.ta_constraints.clone();
for warning in policy.ta_constraints.configuration_warnings() {
eprintln!("warning: {warning}");
}
}
let validation_time = args
.validation_time
@@ -2220,21 +2357,6 @@ pub fn run(argv: &[String]) -> Result<(), String> {
let validation_time =
time::OffsetDateTime::from_unix_timestamp(validation_time.unix_timestamp())
.map_err(|error| format!("normalize validation time failed: {error}"))?;
if let Some(path) = args.validation_contract_out_path.as_deref() {
let contract = crate::verification_only::ValidationContract::for_current_binary(
validation_time,
policy.clone(),
args.max_ca_depth,
args.max_instances,
crate::verification_only::ValidationCacheContract {
publication_point: args.enable_publication_point_validation_cache,
roa: args.enable_roa_validation_cache,
child_certificate: args.enable_child_certificate_validation_cache,
transport_prefetch: args.enable_transport_request_prefetch,
},
)?;
crate::verification_only::write_validation_contract(path, &contract)?;
}
let http_root_certificates_pem = args
.http_root_cert_paths
.iter()
@@ -2351,11 +2473,24 @@ pub fn run(argv: &[String]) -> Result<(), String> {
store.as_ref(),
);
let validation_started = std::time::Instant::now();
let crypto_sig_cache =
if args.crypto_signature_cache_observe_only || args.enable_crypto_signature_cache {
let cache_file = crate::crypto_sig_cache::default_cache_file_path(&args.db_path);
let cache = Arc::new(crate::crypto_sig_cache::CryptoSigCache::load_or_rebuild(
cache_file,
args.enable_crypto_signature_cache,
));
crate::crypto_sig_cache::install_global(Arc::clone(&cache));
Some(cache)
} else {
None
};
let collect_current_repo_objects = false;
let out = if args.verification_only {
let http = crate::verification_only::NetworkDisabledHttpFetcher;
let rsync = crate::fetch::current_repository::CurrentRepositoryViewRsyncFetcher::new(
Arc::clone(&store),
args.rsync_scope_policy,
);
run_online_validation_with_fetchers(
Arc::clone(&store),
@@ -2616,48 +2751,129 @@ pub fn run(argv: &[String]) -> Result<(), String> {
ReportJsonFormat::Pretty
};
let ccr_produced_at = time::OffsetDateTime::now_utc();
let (report_result, ccr_result) = if args.skip_report_build {
(
Ok(ReportTaskOutput::skipped()),
run_ccr_task(
store.as_ref(),
&shared,
args.ccr_out_path.as_deref(),
ccr_produced_at,
),
let compare_view_trust_anchor = args
.compare_view_trust_anchor
.as_deref()
.unwrap_or("unknown");
let cir_tal_uris = if args.cir_enabled {
Some(effective_cir_tal_uris_for_discoveries(
&args,
&shared,
resolve_cir_export_tal_uris(&args)?,
)?)
} else {
None
};
let cir_out_path = if args.cir_enabled {
Some(
args.cir_out_path
.as_deref()
.expect("validated by parse_args for cir"),
)
} else {
None
};
// Take the CIR input snapshot before the output stage so the CIR export can
// run inside the same scoped-thread group as report/ccr/compare_view; no
// other output task reads `shared.cir_input`.
let cir_input_owned = args
.cir_enabled
.then(|| std::mem::take(&mut shared.cir_input));
let (report_result, ccr_result, compare_view_result, cir_result) =
std::thread::scope(|scope| {
let report_handle = scope.spawn(|| {
run_report_task(
&policy,
validation_time,
&shared,
args.report_json_path.as_deref(),
report_json_format,
)
});
// Reborrow `shared` as a plain reference so the scoped output tasks
// (incl. the `move` CIR task) capture the reference instead of
// moving fields out of the owned value.
let shared = &shared;
let report_handle = if args.skip_report_build {
None
} else {
Some(scope.spawn(|| {
run_report_task(
&policy,
validation_time,
shared,
args.report_json_path.as_deref(),
report_json_format,
)
}))
};
let ccr_handle = scope.spawn(|| {
run_ccr_task(
store.as_ref(),
&shared,
shared,
args.ccr_out_path.as_deref(),
ccr_produced_at,
)
});
let report_result = report_handle
.join()
.map_err(|_| "report task panicked".to_string())
.and_then(|result| result);
let compare_view_handle = scope.spawn(|| {
run_compare_view_task(
shared,
args.vrps_csv_out_path.as_deref(),
args.vaps_csv_out_path.as_deref(),
compare_view_trust_anchor,
)
});
let cir_handle = match (cir_tal_uris.as_ref(), cir_out_path, cir_input_owned) {
(Some(cir_tal_uris), Some(cir_out_path), Some(cir_input)) => {
Some(scope.spawn(move || {
if cir_tal_uris.len() != shared.discoveries.len() {
return Err(format!(
"CIR export TAL URI count ({}) does not match discovery count ({})",
cir_tal_uris.len(),
shared.discoveries.len()
));
}
let tal_bindings = shared
.discoveries
.iter()
.zip(cir_tal_uris.iter())
.map(|(discovery, tal_uri)| CirTrustAnchorBinding {
trust_anchor: &discovery.trust_anchor,
tal_uri: tal_uri.as_str(),
})
.collect::<Vec<_>>();
export_cir_from_input_snapshot_multi(
&tal_bindings,
validation_time,
cir_input,
cir_out_path,
)
.map_err(|e| e.to_string())
}))
}
_ => None,
};
let report_result = match report_handle {
Some(handle) => handle
.join()
.map_err(|_| "report task panicked".to_string())
.and_then(|result| result),
None => Ok(ReportTaskOutput::skipped()),
};
let ccr_result = ccr_handle
.join()
.map_err(|_| "ccr task panicked".to_string())
.and_then(|result| result);
(report_result, ccr_result)
})
};
let compare_view_result = compare_view_handle
.join()
.map_err(|_| "compare view task panicked".to_string())
.and_then(|result| result);
let cir_result = cir_handle.map(|handle| {
handle
.join()
.map_err(|_| "cir task panicked".to_string())
.and_then(|result| result)
});
(report_result, ccr_result, compare_view_result, cir_result)
});
let report_output = report_result?;
let ccr_output = ccr_result?;
let compare_view_output = compare_view_result?;
let cir_summary = match cir_result {
Some(result) => Some(result?),
None => None,
};
record_memory_checkpoint(
&mut memory_checkpoints,
"after_report_and_ccr",
@@ -2678,16 +2894,6 @@ pub fn run(argv: &[String]) -> Result<(), String> {
let ccr_build_ms = ccr_output.ccr_build_ms;
let ccr_build_breakdown = ccr_output.ccr_build_breakdown;
let ccr_write_ms = ccr_output.ccr_write_ms;
let compare_view_trust_anchor = args
.compare_view_trust_anchor
.as_deref()
.unwrap_or("unknown");
let compare_view_output = run_compare_view_task(
&shared,
args.vrps_csv_out_path.as_deref(),
args.vaps_csv_out_path.as_deref(),
compare_view_trust_anchor,
)?;
let compare_view_build_ms = compare_view_output.build_ms;
let compare_view_write_ms = compare_view_output.write_ms;
record_memory_checkpoint(
@@ -2700,45 +2906,13 @@ pub fn run(argv: &[String]) -> Result<(), String> {
let mut cir_build_cir_ms = None;
let mut cir_write_cir_ms = None;
let mut cir_total_ms = None;
if args.cir_enabled {
let cir_tal_uris = effective_cir_tal_uris_for_discoveries(
&args,
&shared,
resolve_cir_export_tal_uris(&args)?,
)?;
if cir_tal_uris.len() != shared.discoveries.len() {
return Err(format!(
"CIR export TAL URI count ({}) does not match discovery count ({})",
cir_tal_uris.len(),
shared.discoveries.len()
));
}
let cir_out_path = args
.cir_out_path
.as_deref()
.expect("validated by parse_args for cir");
let tal_bindings = shared
.discoveries
.iter()
.zip(cir_tal_uris.iter())
.map(|(discovery, tal_uri)| CirTrustAnchorBinding {
trust_anchor: &discovery.trust_anchor,
tal_uri: tal_uri.as_str(),
})
.collect::<Vec<_>>();
let summary = export_cir_from_input_snapshot_multi(
&tal_bindings,
validation_time,
std::mem::take(&mut shared.cir_input),
cir_out_path,
)
.map_err(|e| e.to_string())?;
if let Some(summary) = cir_summary {
cir_build_cir_ms = Some(summary.timing.build_cir_ms);
cir_write_cir_ms = Some(summary.timing.write_cir_ms);
cir_total_ms = Some(summary.timing.total_ms);
eprintln!(
"wrote CIR: {} (objects={}, trust_anchors={}, build_cir_ms={}, write_cir_ms={}, total_ms={})",
cir_out_path.display(),
cir_out_path.expect("cir path present when cir enabled").display(),
summary.object_count,
summary.trust_anchor_count,
summary.timing.build_cir_ms,
@@ -2778,6 +2952,37 @@ pub fn run(argv: &[String]) -> Result<(), String> {
None
};
let publication_point_cache_index_load = store.publication_point_cache_mmap_index_load_stats();
let crypto_signature_cache_observe = crypto_sig_cache.as_ref().map(|cache| {
if let Err(e) = cache.persist() {
crate::progress_log::emit(
"crypto_signature_cache_persist",
serde_json::json!({
"state": "failed",
"error": e,
}),
);
}
crate::crypto_sig_cache::clear_global();
cache.summary()
});
if let (Some((_, t)), Some(summary)) =
(timing.as_ref(), crypto_signature_cache_observe.as_ref())
{
let (mut calls, mut would_hit, mut new_keys, mut executed, mut skipped) =
(0u64, 0u64, 0u64, 0u64, 0u64);
for stats in summary.per_point.values() {
calls += stats.calls;
would_hit += stats.would_hit;
new_keys += stats.new_keys;
executed += stats.verify_executed;
skipped += stats.verify_skipped;
}
t.record_count("crypto_signature_cache_observe_calls", calls);
t.record_count("crypto_signature_cache_observe_would_hit", would_hit);
t.record_count("crypto_signature_cache_observe_new_keys", new_keys);
t.record_count("crypto_signature_cache_verify_executed", executed);
t.record_count("crypto_signature_cache_verify_skipped", skipped);
}
let timing_report_snapshot = timing
.as_ref()
.map(|(_, handle)| handle.report_snapshot(50));
@@ -2787,6 +2992,7 @@ pub fn run(argv: &[String]) -> Result<(), String> {
enable_child_certificate_validation_cache: args.enable_child_certificate_validation_cache,
publication_point_cache_observe_only: args.publication_point_cache_observe_only,
enable_publication_point_validation_cache: args.enable_publication_point_validation_cache,
crypto_signature_cache_observe,
enable_transport_request_prefetch: args.enable_transport_request_prefetch,
report_build_ms,
report_write_ms,
@@ -2851,6 +3057,40 @@ pub fn run(argv: &[String]) -> Result<(), String> {
.or(args.vrps_csv_out_path.as_deref());
write_stage_timing(stage_timing_anchor_path, &stage_timing)?;
// A normal run's contract is intentionally finalized only after its
// validation outputs have all been produced successfully. This prevents a
// failed/incomplete run from leaving a contract that appears eligible for
// verification-only, and captures the fallback set actually exercised by
// the completed traversal rather than a prediction made before it starts.
if !args.verification_only {
if let Some(path) = args.validation_contract_out_path.as_deref() {
let mut contract = crate::verification_only::ValidationContract::for_current_binary(
validation_time,
policy.clone(),
args.max_ca_depth,
args.max_instances,
crate::verification_only::ValidationCacheContract {
publication_point: args.enable_publication_point_validation_cache,
roa: args.enable_roa_validation_cache,
child_certificate: args.enable_child_certificate_validation_cache,
transport_prefetch: args.enable_transport_request_prefetch,
crypto_signature: args.enable_crypto_signature_cache,
},
args.rsync_scope_policy,
)?;
if !policy.ta_constraints.is_empty() {
contract.ta_constraints_fingerprint =
Some(policy.ta_constraints.fingerprint_sha256_hex());
}
contract.fallback_replay_selections =
crate::verification_only::collect_fallback_replay_selections(
store.as_ref(),
shared.publication_points.as_ref(),
)?;
crate::verification_only::write_validation_contract(path, &contract)?;
}
}
if let Some((out_dir, t)) = timing.as_ref() {
t.record_count("vrps", shared.vrps.len() as u64);
t.record_count("aspas", shared.aspas.len() as u64);
@@ -2913,7 +3153,26 @@ pub fn run(argv: &[String]) -> Result<(), String> {
Some(&prepared.artifacts.compare_dir.join("ccr-state-digest.md")),
&comparison,
)?;
let scratch_retained = !comparison.state_digest_match || args.keep_verification_scratch;
let actual_fallback_replay_selections =
crate::verification_only::collect_fallback_replay_selections(
store.as_ref(),
shared.publication_points.as_ref(),
)?;
let fallback_replay_selection_comparison =
crate::verification_only::compare_fallback_replay_selections(
&prepared.contract.fallback_replay_selections,
&actual_fallback_replay_selections,
);
crate::verification_only::write_fallback_replay_selection_comparison(
&prepared
.artifacts
.compare_dir
.join("fallback-replay-selection.json"),
&fallback_replay_selection_comparison,
)?;
let verification_match =
comparison.state_digest_match && fallback_replay_selection_comparison.matches;
let scratch_retained = !verification_match || args.keep_verification_scratch;
let source_run_id = prepared
.artifacts
.source_run_dir
@@ -2925,7 +3184,7 @@ pub fn run(argv: &[String]) -> Result<(), String> {
&prepared.artifacts.verification_meta,
&crate::verification_only::VerificationRunMeta {
schema_version: 1,
status: if comparison.state_digest_match {
status: if verification_match {
"success".to_string()
} else {
"mismatch".to_string()
@@ -2936,14 +3195,27 @@ pub fn run(argv: &[String]) -> Result<(), String> {
repository_objects_checked: prepared.blob_summary.current_objects,
repository_bytes_checked: prepared.blob_summary.bytes_verified,
state_digest_match: comparison.state_digest_match,
fallback_replay_selection_match: fallback_replay_selection_comparison.matches,
fallback_replay_materialization: prepared.fallback_replay_materialization.clone(),
scratch_retained,
},
)?;
if !comparison.state_digest_match {
return Err(format!(
"verification-only CCR state digest mismatch: {}",
comparison.mismatched_states.join(",")
));
if !verification_match {
let mut mismatch_parts = Vec::new();
if !comparison.state_digest_match {
mismatch_parts.push(format!(
"CCR state digest mismatch: {}",
comparison.mismatched_states.join(",")
));
}
if !fallback_replay_selection_comparison.matches {
mismatch_parts.push(format!(
"fallback replay selection mismatch: missing={}, unexpected={}",
fallback_replay_selection_comparison.missing.len(),
fallback_replay_selection_comparison.unexpected.len()
));
}
return Err(format!("verification-only {}", mismatch_parts.join("; ")));
}
if !args.keep_verification_scratch {
drop(shared);

View File

@@ -23,6 +23,7 @@ fn parse_help_returns_usage() {
assert!(err.contains("--memory-trim-after-validation"), "{err}");
assert!(err.contains("--enable-roa-validation-cache"), "{err}");
assert!(err.contains("--resource-validation-mode"), "{err}");
assert!(err.contains("--ta-constraints"), "{err}");
assert!(err.contains("--max-ca-depth"), "{err}");
assert!(err.contains("default: 32"), "{err}");
assert!(
@@ -37,6 +38,26 @@ fn parse_help_returns_usage() {
assert!(!err.contains("--parallel-phase2 "), "{err}");
}
#[test]
fn parse_accepts_explicit_ta_constraints_for_known_tal() {
let dir = tempfile::tempdir().expect("tmpdir");
let constraints_path = dir.path().join("example.constraints");
std::fs::write(&constraints_path, "allow 192.0.2.0/24\n").expect("write constraints");
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-path".to_string(),
"example.tal".to_string(),
"--ta-path".to_string(),
"example.cer".to_string(),
"--ta-constraints".to_string(),
format!("example={}", constraints_path.display()),
];
let args = parse_args(&argv).expect("parse args");
assert!(!args.ta_constraints.is_empty());
}
#[test]
fn parse_rejects_unknown_argument() {
let argv = vec![
@@ -108,6 +129,74 @@ fn parse_accepts_normal_validation_contract_output() {
);
}
#[test]
fn verification_policy_preserves_contract_identity_and_selects_only_bound_fallbacks() {
let manifest_rsync_uri = "rsync://example.test/repo/current.mft".to_string();
let contract = crate::verification_only::ValidationContract {
schema_version: crate::verification_only::VALIDATION_CONTRACT_SCHEMA_VERSION,
validation_time: "2026-07-16T00:00:00Z".to_string(),
binary_sha256: "11".repeat(32),
policy: Policy {
sync_preference: crate::policy::SyncPreference::RrdpThenRsync,
..Policy::default()
},
ta_constraints_fingerprint: None,
max_ca_depth: 32,
max_instances: None,
cache: crate::verification_only::ValidationCacheContract {
publication_point: true,
roa: false,
child_certificate: false,
transport_prefetch: false,
crypto_signature: false,
},
rsync_scope_policy: crate::fetch::rsync_system::RsyncScopePolicy::ModuleRoot,
fallback_replay_selections: vec![crate::verification_only::FallbackReplaySelection {
manifest_rsync_uri: manifest_rsync_uri.clone(),
vcir_sha256: "22".repeat(32),
reuse_identity_sha256: "33".repeat(32),
}],
};
let policy = policy_for_verification_contract(&contract);
assert_eq!(
policy.sync_preference,
crate::policy::SyncPreference::RrdpThenRsync,
"sync preference is part of the stored VCIR reuse-identity fingerprint"
);
assert_eq!(
policy.ca_failed_fetch_policy,
crate::policy::CaFailedFetchPolicy::ReuseCurrentInstanceVcir
);
assert!(
policy
.verification_forced_vcir_reuse_manifest_uris
.contains(&manifest_rsync_uri)
);
assert!(
!serde_json::to_string(&policy)
.expect("serialize policy")
.contains("verification_forced_vcir_reuse_manifest_uris"),
"the runtime-only override must not perturb the contracted policy fingerprint"
);
assert!(
!transport_prefetch_for_verification_contract(&contract),
"verification-only must preserve a disabled source transport prefetch setting"
);
let mut contract_with_prefetch = contract;
contract_with_prefetch.cache.transport_prefetch = true;
assert_eq!(
contract_with_prefetch.rsync_scope_policy,
crate::fetch::rsync_system::RsyncScopePolicy::ModuleRoot,
"transport scope is part of the source contract"
);
assert!(
transport_prefetch_for_verification_contract(&contract_with_prefetch),
"verification-only must preserve a source transport prefetch setting"
);
}
#[test]
fn parse_rejects_both_tal_url_and_tal_path() {
let argv = vec![
@@ -289,6 +378,56 @@ fn parse_accepts_publication_point_cache_flags() {
assert!(args.enable_publication_point_validation_cache);
}
#[test]
fn parse_disables_crypto_signature_cache_observe_only_by_default() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-path".to_string(),
"x.tal".to_string(),
"--ta-path".to_string(),
"x.cer".to_string(),
];
let args = parse_args(&argv).expect("parse args");
assert!(!args.crypto_signature_cache_observe_only);
assert!(!args.enable_crypto_signature_cache);
}
#[test]
fn parse_accepts_crypto_signature_cache_observe_only() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-path".to_string(),
"x.tal".to_string(),
"--ta-path".to_string(),
"x.cer".to_string(),
"--crypto-signature-cache-observe-only".to_string(),
];
let args = parse_args(&argv).expect("parse args");
assert!(args.crypto_signature_cache_observe_only);
assert!(!args.enable_crypto_signature_cache);
}
#[test]
fn parse_accepts_enable_crypto_signature_cache() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-path".to_string(),
"x.tal".to_string(),
"--ta-path".to_string(),
"x.cer".to_string(),
"--enable-crypto-signature-cache".to_string(),
];
let args = parse_args(&argv).expect("parse args");
assert!(args.enable_crypto_signature_cache);
assert!(!args.crypto_signature_cache_observe_only);
}
#[test]
fn parse_accepts_enable_transport_request_prefetch() {
let argv = vec![
@@ -1807,6 +1946,7 @@ fn run_report_task_and_stage_timing_work() {
enable_child_certificate_validation_cache: false,
publication_point_cache_observe_only: false,
enable_publication_point_validation_cache: false,
crypto_signature_cache_observe: None,
enable_transport_request_prefetch: false,
report_build_ms: report_output.report_build_ms,
report_write_ms: report_output.report_write_ms,
@@ -1913,6 +2053,7 @@ fn stage_timing_serializes_memory_telemetry() {
enable_child_certificate_validation_cache: true,
publication_point_cache_observe_only: false,
enable_publication_point_validation_cache: false,
crypto_signature_cache_observe: None,
enable_transport_request_prefetch: true,
report_build_ms: 2,
report_write_ms: None,

876
src/crypto_sig_cache.rs Normal file
View File

@@ -0,0 +1,876 @@
//! Crypto signature verification cache (feature #126).
//!
//! Caches the context-free cryptographic fact "this signature over these exact bytes verifies
//! under this public key" so that later runs can skip the RSA verification entirely. Only
//! positive conclusions are stored (no negative caching). The cache never replaces any other
//! validation semantics: revocation (CRL), time-window, and policy checks all run unchanged on
//! the hit path — the hook wraps exactly the crypto verify call and nothing else.
//!
//! Modes (CLI):
//!
//! - `--crypto-signature-cache-observe-only` (M1): compute keys, maintain the persistent set,
//! record hit/miss/timing statistics, always run the real verification.
//! - `--enable-crypto-signature-cache` (M2): on a cache hit, skip the real verification and
//! return success; on a miss, run the real verification and store the positive conclusion.
//! Statistics are recorded the same way; `verify_skipped`/`verify_executed` report what
//! actually happened. Both flags may be combined (reuse + statistics).
//!
//! The five choke points (see the development plan for the full caller inventory):
//!
//! - `CmsSignedObject`: `data_model::signed_object` CMS verify (ROA / ASPA / manifest on the
//! main validation path; GBR objects are not signature-verified by the tree runner).
//! - `EeCertFast`: `validation::cert_path::verify_ee_cert_signature_fast` (ring, TBS + signature
//! bytes from the signed-object embedded EE certificate; ROA / ASPA / manifest EE paths).
//! - `EeCertX509`: `validation::cert_path::verify_ee_cert_signature` (x509-parser; router
//! certificates and the `validate_ee_cert_path` family).
//! - `ChildCaCert`: `validation::ca_path::verify_child_signature` (x509-parser; subordinate CA
//! certificates).
//! - `Crl`: `data_model::crl::verify_signature_with_issuer_spki` (x509-parser; issuer CRLs).
//!
//! Trust-anchor self-signature verification is deliberately excluded (a handful of calls per
//! run).
//!
//! Cache key (M1 decision: verify-input bytes, not full object bytes — the key material is in
//! hand inside each choke point, while the repo-bytes content hash is only available at the
//! upper processing layer):
//!
//! ```text
//! key = SHA-256( "rpki-crypto-sig-cache-key-v1" | point-tag | "sha256WithRSAEncryption"
//! | SHA-256(verify input bytes) | SHA-256(signature bytes)
//! | SHA-256(verifier SPKI DER) )
//! ```
//!
//! The signature bytes are part of the key so that a different signature over the same input
//! under the same key can never inherit a positive verification fact.
//!
//! Storage:
//!
//! - Single small file next to the work DB (`<work-db-name>.crypto-sig-cache`), never inside
//! the work DB, not touched by periodic snapshot reset (#091/#092): the reset paths only
//! remove/replace the work DB directory and the PP/child-cert cache artifacts, not siblings
//! of their own choosing.
//! - Binary format: 8-byte magic `RPKICSC2` | u64 record-count | records of
//! key[32] | verified_at_unix_secs(i64) | crypto_impl_version(u32) | reserved(u32).
//! A missing, corrupt, or magic-mismatched file (including the M1 `RPKICSC1` seen-set
//! format) is silently rebuilt empty. Writes are atomic (write-then-rename).
//! - The cache is persisted once at the end of the validation stage (same point as the M1
//! seen-set). If a run fails mid-validation, entries added during that run are lost;
//! previously persisted entries are unaffected.
//! - Size cap: approximate上限 `capacity` entries (default 2,000,000; measured M1 APNIC
//! ~148k keys/run, all5 estimated ~750k). When an insert would exceed the cap, the whole
//! cache is cleared and rebuilt from scratch (full-rebuild eviction — the simplest policy;
//! concurrent inserts may briefly overshoot the cap by a few entries). Each eviction is
//! counted in `evictions_total`.
//! - Concurrency: entries live in 64 shards keyed by the first key byte, so parallel
//! validation workers rarely contend; statistics use relaxed atomics.
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use serde::Serialize;
use sha2::Digest;
const CACHE_FILE_MAGIC: &[u8; 8] = b"RPKICSC2";
const KEY_DOMAIN: &[u8] = b"rpki-crypto-sig-cache-key-v1";
const ALG_ID: &[u8] = b"sha256WithRSAEncryption";
const SHARD_COUNT: usize = 64;
const RECORD_BYTES: usize = 32 + 8 + 4 + 4;
/// The schema version reported in summaries. The on-disk magic already carries the format
/// version; this constant is the semantic version of the key/value definitions.
pub const CACHE_SCHEMA_VERSION: u32 = 2;
/// Default maximum number of cached positive conclusions (see module docs).
pub const DEFAULT_CAPACITY: usize = 2_000_000;
/// Crypto implementation identifier stored in every value. Bump when the verification
/// backend changes so old entries can be invalidated wholesale.
/// `1` = ring 0.17 `RSA_PKCS1_2048_8192_SHA256` (direct ring calls and x509-parser's
/// `verify` feature, which is backed by ring in this crate).
pub const CRYPTO_IMPL_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CryptoSigVerifyPoint {
CmsSignedObject,
EeCertFast,
EeCertX509,
ChildCaCert,
Crl,
}
impl CryptoSigVerifyPoint {
pub const ALL: [Self; 5] = [
Self::CmsSignedObject,
Self::EeCertFast,
Self::EeCertX509,
Self::ChildCaCert,
Self::Crl,
];
fn tag(self) -> u8 {
match self {
Self::CmsSignedObject => 1,
Self::EeCertFast => 2,
Self::EeCertX509 => 3,
Self::ChildCaCert => 4,
Self::Crl => 5,
}
}
pub fn name(self) -> &'static str {
match self {
Self::CmsSignedObject => "cms_signed_object",
Self::EeCertFast => "ee_cert_fast",
Self::EeCertX509 => "ee_cert_x509",
Self::ChildCaCert => "child_ca_cert",
Self::Crl => "crl",
}
}
fn from_index(index: usize) -> Self {
Self::ALL[index]
}
}
/// Compute the cache key for one verification fact.
///
/// `input_bytes` is the exact byte string fed to the verifier (CMS signedAttrs DER for
/// signature, TBSCertificate DER, TBSCertList DER), `signature_bytes` the signature value,
/// and `verifier_spki_der` the DER of the verifying SubjectPublicKeyInfo.
pub fn compute_cache_key(
point: CryptoSigVerifyPoint,
input_bytes: &[u8],
signature_bytes: &[u8],
verifier_spki_der: &[u8],
) -> [u8; 32] {
let mut h = sha2::Sha256::new();
h.update(KEY_DOMAIN);
h.update([point.tag()]);
h.update(ALG_ID);
for part in [input_bytes, signature_bytes, verifier_spki_der] {
let part_hash = sha2::Sha256::digest(part);
h.update(part_hash);
}
h.finalize().into()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CacheValue {
pub verified_at_unix_secs: i64,
pub crypto_impl_version: u32,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct CryptoSigVerifyPointStats {
pub calls: u64,
/// Key already present in the cache (positive conclusion available).
pub would_hit: u64,
/// Key not present; after a successful verification it is inserted.
pub new_keys: u64,
/// Real cryptographic verification executed (always equals `calls` in observe-only
/// mode; equals `new_keys` plus hit-but-observe calls otherwise).
pub verify_executed: u64,
/// Real verification skipped because of a cache hit (only > 0 when reuse is enabled).
pub verify_skipped: u64,
pub verify_nanos_on_would_hit: u64,
pub verify_nanos_on_new_key: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct CryptoSigCacheSummary {
pub schema_version: u32,
pub cache_file: String,
pub reuse_enabled: bool,
pub capacity: u64,
pub entries_loaded: u64,
pub entries_total: u64,
pub evictions_total: u64,
pub per_point: BTreeMap<String, CryptoSigVerifyPointStats>,
}
#[derive(Default)]
struct PointCounters {
calls: AtomicU64,
would_hit: AtomicU64,
new_keys: AtomicU64,
verify_executed: AtomicU64,
verify_skipped: AtomicU64,
verify_nanos_on_would_hit: AtomicU64,
verify_nanos_on_new_key: AtomicU64,
}
fn shard_index(key: &[u8; 32]) -> usize {
(key[0] as usize) % SHARD_COUNT
}
/// The persistent positive-conclusion signature cache plus per-run statistics.
///
/// See the module docs for storage format, eviction, and failure semantics.
pub struct CryptoSigCache {
cache_file: PathBuf,
reuse_enabled: bool,
capacity: usize,
entries_loaded: u64,
shards: Vec<Mutex<HashMap<[u8; 32], CacheValue>>>,
len: AtomicUsize,
evictions: AtomicU64,
eviction_lock: Mutex<()>,
points: [PointCounters; 5],
}
impl CryptoSigCache {
/// Load the cache from `cache_file`; start empty when the file is missing, corrupt, or
/// has an unknown schema header. `reuse_enabled` selects the M2 hit-skips-verify
/// behavior; when false the cache is observe-only (M1 semantics).
pub fn load_or_rebuild(cache_file: PathBuf, reuse_enabled: bool) -> Self {
Self::load_or_rebuild_with_capacity(cache_file, reuse_enabled, DEFAULT_CAPACITY)
}
pub fn load_or_rebuild_with_capacity(
cache_file: PathBuf,
reuse_enabled: bool,
capacity: usize,
) -> Self {
let entries = load_cache_file(&cache_file).unwrap_or_default();
let mut shards = Vec::with_capacity(SHARD_COUNT);
for _ in 0..SHARD_COUNT {
shards.push(Mutex::new(HashMap::new()));
}
let mut len = 0usize;
for (key, value) in entries {
// Respect the cap even when loading a foreign/oversized file: keep the first
// `capacity` records in file order.
if len >= capacity {
break;
}
shards[shard_index(&key)]
.lock()
.expect("crypto sig shard lock")
.insert(key, value);
len += 1;
}
Self {
cache_file,
reuse_enabled,
capacity,
entries_loaded: len as u64,
shards,
len: AtomicUsize::new(len),
evictions: AtomicU64::new(0),
eviction_lock: Mutex::new(()),
points: Default::default(),
}
}
pub fn reuse_enabled(&self) -> bool {
self.reuse_enabled
}
fn lookup(&self, key: &[u8; 32]) -> bool {
self.shards[shard_index(key)]
.lock()
.expect("crypto sig shard lock")
.contains_key(key)
}
#[cfg(test)]
pub fn contains(&self, key: &[u8; 32]) -> bool {
self.lookup(key)
}
/// Insert a positive conclusion (used by the verify path and by tests).
pub fn insert_positive(&self, key: [u8; 32]) {
let value = CacheValue {
verified_at_unix_secs: time::OffsetDateTime::now_utc().unix_timestamp(),
crypto_impl_version: CRYPTO_IMPL_VERSION,
};
if self.len.load(Ordering::Relaxed) >= self.capacity {
self.evict_all();
}
let mut shard = self.shards[shard_index(&key)]
.lock()
.expect("crypto sig shard lock");
if shard.insert(key, value).is_none() {
self.len.fetch_add(1, Ordering::Relaxed);
}
}
/// Full-rebuild eviction: clear every shard. See the module docs for the semantics.
fn evict_all(&self) {
let _guard = self.eviction_lock.lock().expect("crypto sig eviction lock");
if self.len.load(Ordering::Relaxed) < self.capacity {
// Another thread already evicted.
return;
}
for shard in &self.shards {
shard.lock().expect("crypto sig shard lock").clear();
}
self.len.store(0, Ordering::Relaxed);
self.evictions.fetch_add(1, Ordering::Relaxed);
}
/// Cache-aware verification: observe-only or reuse depending on `reuse_enabled`.
/// The `verify` closure must perform exactly the real cryptographic verification.
pub fn verify<E>(
&self,
point: CryptoSigVerifyPoint,
input_bytes: &[u8],
signature_bytes: &[u8],
verifier_spki_der: &[u8],
verify: impl FnOnce() -> Result<(), E>,
) -> Result<(), E> {
let key = compute_cache_key(point, input_bytes, signature_bytes, verifier_spki_der);
let hit = self.lookup(&key);
let counters = &self.points[(point.tag() - 1) as usize];
counters.calls.fetch_add(1, Ordering::Relaxed);
if hit {
counters.would_hit.fetch_add(1, Ordering::Relaxed);
if self.reuse_enabled {
counters.verify_skipped.fetch_add(1, Ordering::Relaxed);
return Ok(());
}
} else {
counters.new_keys.fetch_add(1, Ordering::Relaxed);
}
counters.verify_executed.fetch_add(1, Ordering::Relaxed);
let started = std::time::Instant::now();
let result = verify();
let nanos = started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64;
if hit {
counters
.verify_nanos_on_would_hit
.fetch_add(nanos, Ordering::Relaxed);
} else {
counters
.verify_nanos_on_new_key
.fetch_add(nanos, Ordering::Relaxed);
if result.is_ok() {
self.insert_positive(key);
}
}
result
}
/// Persist the cache atomically (write-then-rename).
pub fn persist(&self) -> Result<(), String> {
persist_cache_file(&self.cache_file, &self.shards)
}
pub fn summary(&self) -> CryptoSigCacheSummary {
let mut per_point = BTreeMap::new();
for (index, counters) in self.points.iter().enumerate() {
let stats = CryptoSigVerifyPointStats {
calls: counters.calls.load(Ordering::Relaxed),
would_hit: counters.would_hit.load(Ordering::Relaxed),
new_keys: counters.new_keys.load(Ordering::Relaxed),
verify_executed: counters.verify_executed.load(Ordering::Relaxed),
verify_skipped: counters.verify_skipped.load(Ordering::Relaxed),
verify_nanos_on_would_hit: counters
.verify_nanos_on_would_hit
.load(Ordering::Relaxed),
verify_nanos_on_new_key: counters
.verify_nanos_on_new_key
.load(Ordering::Relaxed),
};
if stats.calls > 0 {
per_point.insert(
CryptoSigVerifyPoint::from_index(index).name().to_string(),
stats,
);
}
}
CryptoSigCacheSummary {
schema_version: CACHE_SCHEMA_VERSION,
cache_file: self.cache_file.to_string_lossy().into_owned(),
reuse_enabled: self.reuse_enabled,
capacity: self.capacity as u64,
entries_loaded: self.entries_loaded,
entries_total: self.len.load(Ordering::Relaxed) as u64,
evictions_total: self.evictions.load(Ordering::Relaxed),
per_point,
}
}
}
/// Default cache file location: a sibling of the work DB directory, mirroring
/// `default_pp_cache_index_dir` (`<work-db-name>.crypto-sig-cache`).
pub fn default_cache_file_path(db_path: &Path) -> PathBuf {
let file_name = db_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("work-db");
db_path.with_file_name(format!("{file_name}.crypto-sig-cache"))
}
fn load_cache_file(path: &Path) -> Option<HashMap<[u8; 32], CacheValue>> {
let bytes = std::fs::read(path).ok()?;
let header_len = CACHE_FILE_MAGIC.len() + 8;
if bytes.len() < header_len || &bytes[..CACHE_FILE_MAGIC.len()] != CACHE_FILE_MAGIC {
return None;
}
let mut count_bytes = [0u8; 8];
count_bytes.copy_from_slice(&bytes[CACHE_FILE_MAGIC.len()..header_len]);
let count = u64::from_le_bytes(count_bytes) as usize;
let body = &bytes[header_len..];
if body.len() != count.saturating_mul(RECORD_BYTES) {
return None;
}
let mut entries = HashMap::with_capacity(count);
for chunk in body.chunks_exact(RECORD_BYTES) {
let mut key = [0u8; 32];
key.copy_from_slice(&chunk[..32]);
let mut secs_bytes = [0u8; 8];
secs_bytes.copy_from_slice(&chunk[32..40]);
let mut version_bytes = [0u8; 4];
version_bytes.copy_from_slice(&chunk[40..44]);
entries.insert(
key,
CacheValue {
verified_at_unix_secs: i64::from_le_bytes(secs_bytes),
crypto_impl_version: u32::from_le_bytes(version_bytes),
},
);
}
Some(entries)
}
fn persist_cache_file(
path: &Path,
shards: &[Mutex<HashMap<[u8; 32], CacheValue>>],
) -> Result<(), String> {
let mut bytes = Vec::new();
bytes.extend_from_slice(CACHE_FILE_MAGIC);
// Placeholder for the record count; patched after collecting.
bytes.extend_from_slice(&[0u8; 8]);
let mut count = 0u64;
for shard in shards {
let map = shard.lock().expect("crypto sig shard lock");
for (key, value) in map.iter() {
bytes.extend_from_slice(key);
bytes.extend_from_slice(&value.verified_at_unix_secs.to_le_bytes());
bytes.extend_from_slice(&value.crypto_impl_version.to_le_bytes());
bytes.extend_from_slice(&[0u8; 4]);
count += 1;
}
}
bytes[CACHE_FILE_MAGIC.len()..CACHE_FILE_MAGIC.len() + 8]
.copy_from_slice(&count.to_le_bytes());
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create cache-file dir failed: {}: {e}", parent.display()))?;
}
let tmp_path = path.with_extension("tmp");
std::fs::write(&tmp_path, &bytes)
.map_err(|e| format!("write cache file failed: {}: {e}", tmp_path.display()))?;
std::fs::rename(&tmp_path, path).map_err(|e| {
format!(
"rename cache file failed: {} -> {}: {e}",
tmp_path.display(),
path.display()
)
})?;
Ok(())
}
static GLOBAL_CACHE: RwLock<Option<Arc<CryptoSigCache>>> = RwLock::new(None);
/// Install the process-global cache (called once from the CLI run path when
/// `--crypto-signature-cache-observe-only` and/or `--enable-crypto-signature-cache` is set).
pub fn install_global(cache: Arc<CryptoSigCache>) {
*GLOBAL_CACHE.write().expect("crypto sig cache lock") = Some(cache);
}
/// Remove the process-global cache. Used by tests and by the CLI after the run finished.
pub fn clear_global() {
*GLOBAL_CACHE.write().expect("crypto sig cache lock") = None;
}
pub fn global_cache() -> Option<Arc<CryptoSigCache>> {
GLOBAL_CACHE
.read()
.expect("crypto sig cache lock")
.clone()
}
/// Choke-point hook: cache-aware wrapper around a real signature verification.
///
/// With no cache installed this is a single read-lock check plus the verification itself;
/// verification semantics never change either way (a hit only skips the crypto primitive,
/// never the surrounding revocation/time/policy checks).
pub fn verify_with_cache<E>(
point: CryptoSigVerifyPoint,
input_bytes: &[u8],
signature_bytes: &[u8],
verifier_spki_der: &[u8],
verify: impl FnOnce() -> Result<(), E>,
) -> Result<(), E> {
let Some(cache) = global_cache() else {
return verify();
};
cache.verify(
point,
input_bytes,
signature_bytes,
verifier_spki_der,
verify,
)
}
#[cfg(test)]
pub(crate) static GLOBAL_TEST_LOCK: Mutex<()> = Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
fn temp_cache(reuse_enabled: bool) -> (tempfile::TempDir, CryptoSigCache) {
let dir = tempfile::tempdir().expect("tempdir");
let cache = CryptoSigCache::load_or_rebuild(
dir.path().join("work-db.crypto-sig-cache"),
reuse_enabled,
);
(dir, cache)
}
#[test]
fn cache_key_is_deterministic_and_domain_separated() {
let a = compute_cache_key(
CryptoSigVerifyPoint::CmsSignedObject,
b"input",
b"sig",
b"spki",
);
let b = compute_cache_key(
CryptoSigVerifyPoint::CmsSignedObject,
b"input",
b"sig",
b"spki",
);
assert_eq!(a, b);
// Different verify point.
assert_ne!(
a,
compute_cache_key(CryptoSigVerifyPoint::Crl, b"input", b"sig", b"spki")
);
// Different input bytes.
assert_ne!(
a,
compute_cache_key(
CryptoSigVerifyPoint::CmsSignedObject,
b"input2",
b"sig",
b"spki"
)
);
// Different signature bytes over the same input must not share a key.
assert_ne!(
a,
compute_cache_key(
CryptoSigVerifyPoint::CmsSignedObject,
b"input",
b"sig2",
b"spki"
)
);
// Different verifier key.
assert_ne!(
a,
compute_cache_key(
CryptoSigVerifyPoint::CmsSignedObject,
b"input",
b"sig",
b"spki2"
)
);
}
#[test]
fn miss_verifies_and_writes_then_hit_skips_verify() {
let (_dir, cache) = temp_cache(true);
let mut executed = 0u64;
// First call: miss -> real verification runs and passes -> entry written.
cache
.verify(
CryptoSigVerifyPoint::EeCertFast,
b"tbs",
b"sig",
b"spki",
|| -> Result<(), String> {
executed += 1;
Ok(())
},
)
.expect("verify ok");
assert_eq!(executed, 1);
// Second call: hit -> verification skipped entirely.
cache
.verify(
CryptoSigVerifyPoint::EeCertFast,
b"tbs",
b"sig",
b"spki",
|| -> Result<(), String> {
executed += 1;
Ok(())
},
)
.expect("verify ok");
assert_eq!(executed, 1, "cache hit must skip the real verification");
let summary = cache.summary();
let stats = summary.per_point.get("ee_cert_fast").expect("stats present");
assert_eq!(stats.calls, 2);
assert_eq!(stats.new_keys, 1);
assert_eq!(stats.would_hit, 1);
assert_eq!(stats.verify_executed, 1);
assert_eq!(stats.verify_skipped, 1);
assert!(summary.reuse_enabled);
}
#[test]
fn preset_positive_key_skips_verify_that_would_fail() {
let (_dir, cache) = temp_cache(true);
// Tampered object bytes whose signature can never verify; pre-seed the positive
// conclusion directly (as if a previous run had verified the untampered object).
let key = compute_cache_key(
CryptoSigVerifyPoint::CmsSignedObject,
b"tampered-input",
b"tampered-sig",
b"spki",
);
cache.insert_positive(key);
let mut executed = 0u64;
let result = cache.verify(
CryptoSigVerifyPoint::CmsSignedObject,
b"tampered-input",
b"tampered-sig",
b"spki",
|| -> Result<(), String> {
executed += 1;
Err("signature does not verify".to_string())
},
);
assert_eq!(result, Ok(()), "cache hit must return Ok without verifying");
assert_eq!(executed, 0, "real verification must not run on a hit");
}
#[test]
fn failed_verify_is_not_cached_and_reruns_next_time() {
let (_dir, cache) = temp_cache(true);
let mut executed = 0u64;
for _ in 0..2 {
let result = cache.verify(
CryptoSigVerifyPoint::Crl,
b"tbs",
b"sig",
b"spki",
|| -> Result<(), String> {
executed += 1;
Err("bad signature".to_string())
},
);
assert!(result.is_err());
}
assert_eq!(executed, 2, "no negative caching: every call re-verifies");
let key = compute_cache_key(CryptoSigVerifyPoint::Crl, b"tbs", b"sig", b"spki");
assert!(!cache.contains(&key));
let summary = cache.summary();
let stats = summary.per_point.get("crl").expect("stats present");
assert_eq!(stats.new_keys, 2);
assert_eq!(stats.verify_executed, 2);
assert_eq!(stats.verify_skipped, 0);
}
#[test]
fn observe_only_mode_counts_would_hit_but_still_verifies() {
let (_dir, cache) = temp_cache(false);
let key = compute_cache_key(CryptoSigVerifyPoint::ChildCaCert, b"tbs", b"sig", b"spki");
cache.insert_positive(key);
let mut executed = 0u64;
cache
.verify(
CryptoSigVerifyPoint::ChildCaCert,
b"tbs",
b"sig",
b"spki",
|| -> Result<(), String> {
executed += 1;
Ok(())
},
)
.expect("verify ok");
assert_eq!(executed, 1, "observe-only mode must still run the verification");
let summary = cache.summary();
assert!(!summary.reuse_enabled);
let stats = summary
.per_point
.get("child_ca_cert")
.expect("stats present");
assert_eq!(stats.would_hit, 1);
assert_eq!(stats.verify_executed, 1);
assert_eq!(stats.verify_skipped, 0);
}
#[test]
fn cache_file_roundtrips_entries_across_reload() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("work-db.crypto-sig-cache");
let cache = CryptoSigCache::load_or_rebuild(path.clone(), true);
let key = compute_cache_key(CryptoSigVerifyPoint::EeCertFast, b"tbs", b"sig", b"spki");
cache.insert_positive(key);
cache.persist().expect("persist");
let reloaded = CryptoSigCache::load_or_rebuild(path, true);
assert!(reloaded.contains(&key));
let summary = reloaded.summary();
assert_eq!(summary.entries_loaded, 1);
assert_eq!(summary.entries_total, 1);
assert_eq!(summary.schema_version, CACHE_SCHEMA_VERSION);
// Reloaded entry drives a skip.
let mut executed = 0u64;
reloaded
.verify(
CryptoSigVerifyPoint::EeCertFast,
b"tbs",
b"sig",
b"spki",
|| -> Result<(), String> {
executed += 1;
Ok(())
},
)
.expect("verify ok");
assert_eq!(executed, 0);
}
#[test]
fn cache_file_rebuilds_on_missing_corrupt_or_mismatched_schema() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("work-db.crypto-sig-cache");
// Missing file.
let cache = CryptoSigCache::load_or_rebuild(path.clone(), true);
assert_eq!(cache.summary().entries_loaded, 0);
// Garbage content.
std::fs::write(&path, b"not a valid cache file").expect("write garbage");
let cache = CryptoSigCache::load_or_rebuild(path.clone(), true);
assert_eq!(cache.summary().entries_loaded, 0);
// M1 seen-set format (RPKICSC1) must not load as a v2 cache: cold start.
let mut bytes = Vec::new();
bytes.extend_from_slice(b"RPKICSC1");
bytes.extend_from_slice(&1u64.to_le_bytes());
bytes.extend_from_slice(&[7u8; 32]);
std::fs::write(&path, &bytes).expect("write v1 file");
let cache = CryptoSigCache::load_or_rebuild(path.clone(), true);
assert_eq!(cache.summary().entries_loaded, 0);
// Valid v2 header but truncated body.
let mut bytes = Vec::new();
bytes.extend_from_slice(CACHE_FILE_MAGIC);
bytes.extend_from_slice(&2u64.to_le_bytes());
bytes.extend_from_slice(&[9u8; RECORD_BYTES]);
std::fs::write(&path, &bytes).expect("write truncated");
let cache = CryptoSigCache::load_or_rebuild(path, true);
assert_eq!(cache.summary().entries_loaded, 0);
}
#[test]
fn size_cap_triggers_full_rebuild_eviction() {
let dir = tempfile::tempdir().expect("tempdir");
let cache = CryptoSigCache::load_or_rebuild_with_capacity(
dir.path().join("work-db.crypto-sig-cache"),
true,
2,
);
let k1 = compute_cache_key(CryptoSigVerifyPoint::Crl, b"a", b"s", b"k");
let k2 = compute_cache_key(CryptoSigVerifyPoint::Crl, b"b", b"s", b"k");
let k3 = compute_cache_key(CryptoSigVerifyPoint::Crl, b"c", b"s", b"k");
cache.insert_positive(k1);
cache.insert_positive(k2);
assert!(cache.contains(&k1) && cache.contains(&k2));
// Third insert exceeds the cap -> full rebuild, then insert.
cache.insert_positive(k3);
assert!(!cache.contains(&k1));
assert!(!cache.contains(&k2));
assert!(cache.contains(&k3));
let summary = cache.summary();
assert_eq!(summary.entries_total, 1);
assert_eq!(summary.evictions_total, 1);
}
#[test]
fn default_cache_file_path_mirrors_pp_cache_index_placement() {
let db = Path::new("/state/db/work-db");
assert_eq!(
default_cache_file_path(db),
PathBuf::from("/state/db/work-db.crypto-sig-cache")
);
}
#[test]
fn verify_with_cache_without_global_cache_runs_verify_and_collects_nothing() {
let _guard = GLOBAL_TEST_LOCK.lock().expect("test lock");
clear_global();
assert!(global_cache().is_none());
let mut executed = 0u64;
let result: Result<u64, String> = (|| {
verify_with_cache(
CryptoSigVerifyPoint::CmsSignedObject,
b"input",
b"sig",
b"spki",
|| -> Result<(), String> {
executed += 1;
Ok(())
},
)?;
Ok(42)
})();
assert_eq!(result, Ok(42));
assert_eq!(executed, 1);
assert!(global_cache().is_none());
}
#[test]
fn verify_with_cache_preserves_result_through_global_install() {
let _guard = GLOBAL_TEST_LOCK.lock().expect("test lock");
clear_global();
let dir = tempfile::tempdir().expect("tempdir");
let cache = Arc::new(CryptoSigCache::load_or_rebuild(
dir.path().join("work-db.crypto-sig-cache"),
true,
));
install_global(Arc::clone(&cache));
let ok: Result<(), String> = verify_with_cache(
CryptoSigVerifyPoint::ChildCaCert,
b"tbs",
b"sig",
b"spki",
|| Ok(()),
);
assert_eq!(ok, Ok(()));
let err: Result<(), String> = verify_with_cache(
CryptoSigVerifyPoint::ChildCaCert,
b"tbs2",
b"sig",
b"spki",
|| Err("bad signature".to_string()),
);
assert_eq!(err, Err("bad signature".to_string()));
let summary = cache.summary();
let stats = summary
.per_point
.get("child_ca_cert")
.expect("stats present");
assert_eq!(stats.calls, 2);
assert_eq!(stats.new_keys, 2);
clear_global();
assert!(global_cache().is_none());
}
}

View File

@@ -1,5 +1,5 @@
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, RwLock};
use crate::storage::{RepositoryViewEntry, RepositoryViewState};
@@ -25,7 +25,12 @@ pub struct CurrentRepoIndex {
by_uri: HashMap<String, CurrentRepoEntry>,
}
pub type CurrentRepoIndexHandle = Arc<Mutex<CurrentRepoIndex>>;
/// Shared handle to the run-wide current repository index. Readers (phase 2
/// publication point staging, cache lookups, output snapshots) take
/// `.read()` and no longer exclude each other; writers (repo sync transports
/// applying repository view entries, run-state reset) take `.write()` and
/// stay exclusive.
pub type CurrentRepoIndexHandle = Arc<RwLock<CurrentRepoIndex>>;
impl CurrentRepoIndex {
pub fn new() -> Self {
@@ -33,7 +38,7 @@ impl CurrentRepoIndex {
}
pub fn shared() -> CurrentRepoIndexHandle {
Arc::new(Mutex::new(Self::new()))
Arc::new(RwLock::new(Self::new()))
}
pub fn get_by_uri(&self, rsync_uri: &str) -> Option<&CurrentRepoEntry> {
@@ -205,7 +210,7 @@ mod tests {
#[test]
fn current_repo_index_snapshot_objects_and_counts_are_sorted() {
let handle = CurrentRepoIndex::shared();
let mut index = handle.lock().expect("lock index");
let mut index = handle.write().expect("write-lock index");
index
.apply_repository_view_entries(&[
present(

View File

@@ -285,8 +285,16 @@ impl RpkixCrl {
if !rem.is_empty() {
return Err(CrlVerifyError::CrlTrailingBytes(rem.len()));
}
crl.verify_signature(issuer_spki)
.map_err(|e| CrlVerifyError::InvalidSignature(e.to_string()))
crate::crypto_sig_cache::verify_with_cache(
crate::crypto_sig_cache::CryptoSigVerifyPoint::Crl,
crl.tbs_cert_list.as_ref(),
crl.signature_value.data.as_ref(),
issuer_spki.raw,
|| {
crl.verify_signature(issuer_spki)
.map_err(|e| CrlVerifyError::InvalidSignature(e.to_string()))
},
)
}
/// Verify the cryptographic signature on this CRL using a DER-encoded SubjectPublicKeyInfo.

View File

@@ -330,6 +330,16 @@ impl RpkiSignedObject {
parse_signed_object_content_info(der, der, CmsParseMode::DerStrict)
}
/// Return the strict-DER CMS parse error for an object that was otherwise
/// accepted through the normal BER-compatible CMS parser.
///
/// Callers must only surface this as a compatibility warning after normal
/// decoding and validation have succeeded; a strict parse failure alone
/// does not prove that an arbitrary byte string is an RPKI signed object.
pub fn strict_cms_der_error(der: &[u8]) -> Option<SignedObjectParseError> {
Self::parse_der_strict_cms(der).err()
}
/// Decode a DER-encoded RPKI Signed Object (CMS ContentInfo wrapping SignedData) and enforce
/// the profile constraints from RFC 6488 §2-§3 and RFC 9589 §4.
pub fn decode_der(der: &[u8]) -> Result<Self, SignedObjectDecodeError> {
@@ -358,7 +368,19 @@ impl RpkiSignedObject {
/// Verify the CMS signature using the embedded EE certificate public key.
pub fn verify_signature(&self) -> Result<(), SignedObjectVerifyError> {
let ee = &self.signed_data.certificates[0];
self.verify_signature_with_rsa_components(&ee.rsa_public_modulus, &ee.rsa_public_exponent)
let signer = &self.signed_data.signer_infos[0];
crate::crypto_sig_cache::verify_with_cache(
crate::crypto_sig_cache::CryptoSigVerifyPoint::CmsSignedObject,
&signer.signed_attrs_der_for_signature,
&signer.signature,
&ee.spki_der,
|| {
self.verify_signature_with_rsa_components(
&ee.rsa_public_modulus,
&ee.rsa_public_exponent,
)
},
)
}
/// Verify the CMS signature using a DER-encoded SubjectPublicKeyInfo.
@@ -394,7 +416,14 @@ impl RpkiSignedObject {
_ => return Err(SignedObjectVerifyError::UnsupportedEePublicKeyAlgorithm),
};
self.verify_signature_with_rsa_components(n.as_slice(), e.as_slice())
let signer = &self.signed_data.signer_infos[0];
crate::crypto_sig_cache::verify_with_cache(
crate::crypto_sig_cache::CryptoSigVerifyPoint::CmsSignedObject,
&signer.signed_attrs_der_for_signature,
&signer.signature,
ee_spki.raw,
|| self.verify_signature_with_rsa_components(n.as_slice(), e.as_slice()),
)
}
fn verify_signature_with_rsa_components(
@@ -1522,6 +1551,10 @@ mod tests {
let err = RpkiSignedObject::parse_der_strict_cms(&mutated)
.expect_err("DER strict rejects constructed OCTET STRING");
assert!(err.to_string().contains("DER"), "{err}");
let compatibility_error = RpkiSignedObject::strict_cms_der_error(&mutated)
.expect("BER-compatible fixture must report strict-DER incompatibility");
assert_eq!(compatibility_error.to_string(), err.to_string());
assert!(RpkiSignedObject::strict_cms_der_error(&der).is_none());
}
fn replace_first_subslice(input: &[u8], from: &[u8], to: &[u8]) -> Option<Vec<u8>> {

View File

@@ -1,24 +1,33 @@
use std::sync::Arc;
use crate::fetch::rsync::{
RsyncFetchError, RsyncFetchResult, RsyncFetcher, normalize_rsync_base_uri,
use crate::fetch::rsync::{RsyncFetchError, RsyncFetchResult, RsyncFetcher};
use crate::fetch::rsync_system::{
RsyncScopePolicy, scoped_rsync_failure_dedup_key, scoped_rsync_fetch_uri,
};
use crate::storage::{RepositoryViewState, RocksStore};
#[derive(Clone)]
pub struct CurrentRepositoryViewRsyncFetcher {
store: Arc<RocksStore>,
scope_policy: RsyncScopePolicy,
}
impl CurrentRepositoryViewRsyncFetcher {
pub fn new(store: Arc<RocksStore>) -> Self {
Self { store }
pub fn new(store: Arc<RocksStore>, scope_policy: RsyncScopePolicy) -> Self {
Self {
store,
scope_policy,
}
}
}
impl RsyncFetcher for CurrentRepositoryViewRsyncFetcher {
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
let base = normalize_rsync_base_uri(rsync_base_uri);
// The source run's live fetcher may have fetched a module root rather
// than the publication-point URI that triggered it. Read that same
// frozen-view prefix here so transport prefetch request identities and
// their materialized object sets stay equivalent.
let base = scoped_rsync_fetch_uri(self.scope_policy, rsync_base_uri);
let entries = self
.store
.list_repository_view_entries_with_prefix(&base)
@@ -75,7 +84,11 @@ impl RsyncFetcher for CurrentRepositoryViewRsyncFetcher {
}
fn dedup_key(&self, rsync_base_uri: &str) -> String {
normalize_rsync_base_uri(rsync_base_uri)
scoped_rsync_fetch_uri(self.scope_policy, rsync_base_uri)
}
fn failure_dedup_key(&self, rsync_base_uri: &str) -> Option<String> {
scoped_rsync_failure_dedup_key(self.scope_policy, rsync_base_uri)
}
}
@@ -107,7 +120,7 @@ mod tests {
})
.expect("put view");
}
let fetcher = CurrentRepositoryViewRsyncFetcher::new(store);
let fetcher = CurrentRepositoryViewRsyncFetcher::new(store, RsyncScopePolicy::default());
let objects = fetcher
.fetch_objects("rsync://example.test/repo/")
.expect("fetch objects");
@@ -168,11 +181,54 @@ mod tests {
current_hash: Some(missing_hash),
})
.expect("put missing view");
let fetcher = CurrentRepositoryViewRsyncFetcher::new(store);
let fetcher = CurrentRepositoryViewRsyncFetcher::new(store, RsyncScopePolicy::default());
let error = fetcher
.fetch_objects("rsync://example.test/repo/")
.unwrap_err()
.to_string();
assert!(error.contains("blob bytes missing"));
}
#[test]
fn current_repository_fetcher_replays_module_scope_and_failure_dedup() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Arc::new(RocksStore::open(dir.path()).expect("open store"));
for (uri, bytes) in [
("rsync://example.test/repo/ca/a.mft", b"a".as_slice()),
("rsync://example.test/repo/other/b.roa", b"b".as_slice()),
] {
let hash = hex::encode(crate::cir::sha256(bytes));
store
.put_blob_bytes_batch(&[(hash.clone(), bytes.to_vec())])
.expect("put blob");
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: uri.to_string(),
repository_source: Some("fixture".to_string()),
object_type: Some("fixture".to_string()),
state: RepositoryViewState::Present,
current_hash: Some(hash),
})
.expect("put view");
}
let module_fetcher = CurrentRepositoryViewRsyncFetcher::new(
Arc::clone(&store),
RsyncScopePolicy::ModuleRoot,
);
let objects = module_fetcher
.fetch_objects("rsync://example.test/repo/ca/")
.expect("fetch module scope");
assert_eq!(objects.len(), 2);
assert_eq!(
module_fetcher.dedup_key("rsync://example.test/repo/ca/"),
"rsync://example.test/repo/"
);
let host_fetcher = CurrentRepositoryViewRsyncFetcher::new(store, RsyncScopePolicy::Host);
assert_eq!(
host_fetcher.failure_dedup_key("rsync://example.test/repo/ca/"),
Some("rsync://example.test/".to_string())
);
}
}

View File

@@ -3,9 +3,12 @@ use std::io::Write;
use std::time::Duration;
use reqwest::blocking::Client;
use reqwest::header::HeaderMap;
use reqwest::header::{HeaderMap, LOCATION};
use url::Url;
use crate::sync::rrdp::Fetcher;
use crate::sync::rrdp::{Fetcher, RrdpError, RrdpFetchError, RrdpOrigin, RrdpResourceKind};
const MAX_RRDP_REDIRECTS: usize = 10;
thread_local! {
static HTTP_TIMEOUT_OVERRIDE: RefCell<Option<Duration>> = const { RefCell::new(None) };
@@ -20,6 +23,28 @@ pub fn with_scoped_http_timeout_override<R>(timeout: Duration, f: impl FnOnce()
})
}
/// Default User-Agent sent with every outgoing HTTP request (RRDP
/// notification/snapshot/delta, TAL/TA downloads, daemon probes).
pub const DEFAULT_HTTP_USER_AGENT: &str = "panda-rpki/0.2";
/// Environment variable that overrides the HTTP User-Agent. Unset, empty, or
/// values containing characters that are illegal in a header value fall back
/// to [`DEFAULT_HTTP_USER_AGENT`].
pub const HTTP_USER_AGENT_ENV: &str = "RPKI_HTTP_USER_AGENT";
fn resolve_http_user_agent(env_value: Option<String>) -> String {
let Some(value) = env_value else {
return DEFAULT_HTTP_USER_AGENT.to_string();
};
let trimmed = value.trim();
let valid = !trimmed.is_empty() && trimmed.bytes().all(|b| (0x20..=0x7e).contains(&b));
if valid {
trimmed.to_string()
} else {
DEFAULT_HTTP_USER_AGENT.to_string()
}
}
#[derive(Clone, Debug)]
pub struct HttpFetcherConfig {
/// Connection-establishment timeout for HTTP requests.
@@ -39,7 +64,7 @@ impl Default for HttpFetcherConfig {
connect_timeout: Duration::from_secs(15),
timeout: Duration::from_secs(30),
large_body_timeout: Duration::from_secs(180),
user_agent: "rpki-dev/0.1 (stage2)".to_string(),
user_agent: resolve_http_user_agent(std::env::var(HTTP_USER_AGENT_ENV).ok()),
extra_root_certificates_pem: Vec::new(),
}
}
@@ -55,6 +80,9 @@ pub struct BlockingHttpFetcher {
short_client: Client,
large_body_client: Client,
retry_short_client: Client,
rrdp_short_client: Client,
rrdp_large_body_client: Client,
rrdp_retry_short_client: Client,
short_timeout: Duration,
large_body_timeout: Duration,
}
@@ -84,7 +112,31 @@ impl BlockingHttpFetcher {
&config,
Duration::from_secs(1),
Duration::from_secs(1),
"rpki-dev/0.1 (stage2)".to_string(),
config.user_agent.clone(),
)?
.build()
.map_err(|e| e.to_string())?;
let rrdp_short_client = Self::rrdp_client_builder(
&config,
connect_timeout,
config.timeout,
config.user_agent.clone(),
)?
.build()
.map_err(|e| e.to_string())?;
let rrdp_large_body_client = Self::rrdp_client_builder(
&config,
connect_timeout,
large_body_timeout,
config.user_agent.clone(),
)?
.build()
.map_err(|e| e.to_string())?;
let rrdp_retry_short_client = Self::rrdp_client_builder(
&config,
Duration::from_secs(1),
Duration::from_secs(1),
config.user_agent.clone(),
)?
.build()
.map_err(|e| e.to_string())?;
@@ -92,6 +144,9 @@ impl BlockingHttpFetcher {
short_client,
large_body_client,
retry_short_client,
rrdp_short_client,
rrdp_large_body_client,
rrdp_retry_short_client,
short_timeout,
large_body_timeout,
})
@@ -115,6 +170,16 @@ impl BlockingHttpFetcher {
Ok(builder)
}
fn rrdp_client_builder(
config: &HttpFetcherConfig,
connect_timeout: Duration,
timeout: Duration,
user_agent: String,
) -> Result<reqwest::blocking::ClientBuilder, String> {
Ok(Self::client_builder(config, connect_timeout, timeout, user_agent)?
.redirect(reqwest::redirect::Policy::none()))
}
pub fn fetch_bytes(&self, uri: &str) -> Result<Vec<u8>, String> {
let started = std::time::Instant::now();
let (client, timeout_profile, timeout_value) = self.client_for_uri(uri);
@@ -238,6 +303,141 @@ impl BlockingHttpFetcher {
(&self.short_client, "short", self.short_timeout)
}
}
fn rrdp_client_for_uri(&self, uri: &str) -> (&Client, &'static str, Duration) {
if let Some(timeout) = HTTP_TIMEOUT_OVERRIDE.with(|cell| *cell.borrow()) {
return (&self.rrdp_retry_short_client, "retry_short", timeout);
}
if uses_large_body_timeout(uri) {
(
&self.rrdp_large_body_client,
"large_body",
self.large_body_timeout,
)
} else {
(&self.rrdp_short_client, "short", self.short_timeout)
}
}
fn rrdp_response(
&self,
resource: RrdpResourceKind,
uri: &str,
notification_origin: &RrdpOrigin,
) -> Result<(reqwest::blocking::Response, &'static str, Duration), RrdpFetchError> {
let mut current = Url::parse(uri).map_err(|e| RrdpError::InvalidRrdpUri {
resource,
detail: e.to_string(),
})?;
let current_origin = RrdpOrigin::from_url(&current, resource)?;
if &current_origin != notification_origin {
return Err(RrdpError::CrossOriginReference {
resource,
notification_origin: notification_origin.to_string(),
resource_origin: current_origin.to_string(),
}
.into());
}
for redirects in 0..=MAX_RRDP_REDIRECTS {
let (client, profile, timeout) = self.rrdp_client_for_uri(current.as_str());
let response = client
.get(current.clone())
.send()
.map_err(|e| RrdpFetchError::Fetch(format!("http request failed: {e:?}")))?;
if !response.status().is_redirection() {
return Ok((response, profile, timeout));
}
if redirects == MAX_RRDP_REDIRECTS {
return Err(RrdpError::RedirectLimitExceeded {
resource,
max: MAX_RRDP_REDIRECTS,
}
.into());
}
let location = response
.headers()
.get(LOCATION)
.ok_or(RrdpError::RedirectLocationMissing { resource })?
.to_str()
.map_err(|_| RrdpError::InvalidRedirectLocation {
resource,
location: "<non-UTF-8 header>".to_string(),
})?;
let next = current
.join(location)
.map_err(|_| RrdpError::InvalidRedirectLocation {
resource,
location: location.to_string(),
})?;
let next_origin = RrdpOrigin::from_url(&next, resource)?;
crate::progress_log::emit(
"rrdp_redirect_checked",
serde_json::json!({
"resource": resource.to_string(),
"from": current.as_str(),
"to": next.as_str(),
"notification_origin": notification_origin.to_string(),
"same_origin": &next_origin == notification_origin,
"rfc": "RFC 9674 §3.2",
}),
);
if &next_origin != notification_origin {
crate::progress_log::emit(
"rrdp_cross_origin_rejected",
serde_json::json!({
"resource": resource.to_string(),
"notification_origin": notification_origin.to_string(),
"resource_origin": next_origin.to_string(),
"reason": "redirect",
"rfc": "RFC 9674 §3.2",
}),
);
return Err(RrdpError::CrossOriginRedirect {
resource,
notification_origin: notification_origin.to_string(),
redirect_origin: next_origin.to_string(),
}
.into());
}
current = next;
}
unreachable!("redirect loop either returns or reaches its limit")
}
fn rrdp_fetch_bytes(
&self,
resource: RrdpResourceKind,
uri: &str,
notification_origin: &RrdpOrigin,
) -> Result<Vec<u8>, RrdpFetchError> {
let (response, _profile, _timeout) = self.rrdp_response(resource, uri, notification_origin)?;
let status = response.status();
if !status.is_success() {
return Err(RrdpFetchError::Fetch(format!("http status {status}")));
}
response
.bytes()
.map(|b| b.to_vec())
.map_err(|e| RrdpFetchError::Fetch(format!("http read body failed: {e}")))
}
fn rrdp_fetch_to_writer(
&self,
resource: RrdpResourceKind,
uri: &str,
notification_origin: &RrdpOrigin,
out: &mut dyn Write,
) -> Result<u64, RrdpFetchError> {
let (mut response, _profile, _timeout) = self.rrdp_response(resource, uri, notification_origin)?;
let status = response.status();
if !status.is_success() {
return Err(RrdpFetchError::Fetch(format!("http status {status}")));
}
response
.copy_to(out)
.map_err(|e| RrdpFetchError::Fetch(format!("http stream body failed: {e}")))
}
}
impl Fetcher for BlockingHttpFetcher {
@@ -354,6 +554,25 @@ impl Fetcher for BlockingHttpFetcher {
}
}
}
fn fetch_rrdp(
&self,
resource: RrdpResourceKind,
uri: &str,
notification_origin: &RrdpOrigin,
) -> Result<Vec<u8>, RrdpFetchError> {
self.rrdp_fetch_bytes(resource, uri, notification_origin)
}
fn fetch_rrdp_to_writer(
&self,
resource: RrdpResourceKind,
uri: &str,
notification_origin: &RrdpOrigin,
out: &mut dyn Write,
) -> Result<u64, RrdpFetchError> {
self.rrdp_fetch_to_writer(resource, uri, notification_origin, out)
}
}
fn header_value(headers: &HeaderMap, name: &str) -> String {
@@ -376,6 +595,8 @@ mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
use std::time::Duration as StdDuration;
@@ -532,4 +753,179 @@ mod tests {
assert_eq!(profile_large, "large_body");
assert_eq!(timeout_large, Duration::from_secs(9));
}
#[test]
fn resolve_http_user_agent_falls_back_to_default() {
assert_eq!(resolve_http_user_agent(None), DEFAULT_HTTP_USER_AGENT);
assert_eq!(
resolve_http_user_agent(Some(String::new())),
DEFAULT_HTTP_USER_AGENT
);
assert_eq!(
resolve_http_user_agent(Some(" ".to_string())),
DEFAULT_HTTP_USER_AGENT
);
// Control characters are illegal in header values; fall back instead
// of letting reqwest fail to build the client.
assert_eq!(
resolve_http_user_agent(Some("bad\u{1}ua".to_string())),
DEFAULT_HTTP_USER_AGENT
);
assert_eq!(
resolve_http_user_agent(Some("non-ascii-ua-中".to_string())),
DEFAULT_HTTP_USER_AGENT
);
}
#[test]
fn resolve_http_user_agent_accepts_custom_value() {
assert_eq!(
resolve_http_user_agent(Some("panda-rpki/9.9 (test)".to_string())),
"panda-rpki/9.9 (test)"
);
// Surrounding whitespace is trimmed.
assert_eq!(
resolve_http_user_agent(Some(" custom/1.0 ".to_string())),
"custom/1.0"
);
}
fn spawn_user_agent_capture_server() -> (String, std::sync::mpsc::Receiver<String>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
let addr = listener.local_addr().expect("addr");
let (tx, rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut buf = [0u8; 4096];
let read = stream.read(&mut buf).expect("read request");
let request = String::from_utf8_lossy(&buf[..read]).to_string();
let body = b"ok";
let hdr = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(hdr.as_bytes()).expect("write hdr");
stream.write_all(body).expect("write body");
let _ = tx.send(request);
});
(format!("http://{}/", addr), rx)
}
#[test]
fn fetch_bytes_sends_configured_user_agent_header() {
let (url, rx) = spawn_user_agent_capture_server();
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(2),
user_agent: "panda-rpki/0.2".to_string(),
..HttpFetcherConfig::default()
})
.expect("http");
http.fetch_bytes(&url).expect("fetch");
let request = rx.recv().expect("request captured");
assert!(
request
.to_ascii_lowercase()
.contains("user-agent: panda-rpki/0.2"),
"{request}"
);
}
#[test]
fn fetch_bytes_sends_custom_user_agent_header() {
let (url, rx) = spawn_user_agent_capture_server();
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(2),
user_agent: "custom-marker/7.7".to_string(),
..HttpFetcherConfig::default()
})
.expect("http");
http.fetch_bytes(&url).expect("fetch");
let request = rx.recv().expect("request captured");
assert!(
request
.to_ascii_lowercase()
.contains("user-agent: custom-marker/7.7"),
"{request}"
);
}
#[test]
fn rrdp_same_origin_redirect_is_followed_manually() {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
let addr = listener.local_addr().expect("addr");
thread::spawn(move || {
for response in [
b"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".as_slice(),
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".as_slice(),
] {
let (mut stream, _) = listener.accept().expect("accept");
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
stream.write_all(response).expect("response");
}
});
let uri = format!("http://{addr}/start");
let origin = RrdpOrigin::parse(&uri, RrdpResourceKind::Notification).expect("origin");
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(2),
..HttpFetcherConfig::default()
})
.expect("http");
let body = http
.fetch_rrdp(RrdpResourceKind::Notification, &uri, &origin)
.expect("same-origin redirect accepted");
assert_eq!(body, b"ok");
}
#[test]
fn rrdp_cross_origin_redirect_is_rejected_before_target_request() {
let target = TcpListener::bind(("127.0.0.1", 0)).expect("bind target");
target.set_nonblocking(true).expect("nonblocking target");
let target_addr = target.local_addr().expect("target addr");
let target_hits = Arc::new(AtomicUsize::new(0));
let target_hits_worker = Arc::clone(&target_hits);
let target_thread = thread::spawn(move || {
let deadline = std::time::Instant::now() + StdDuration::from_millis(400);
while std::time::Instant::now() < deadline {
match target.accept() {
Ok((mut stream, _)) => {
target_hits_worker.fetch_add(1, Ordering::SeqCst);
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(StdDuration::from_millis(5));
}
Err(e) => panic!("target accept: {e}"),
}
}
});
let source = TcpListener::bind(("127.0.0.1", 0)).expect("bind source");
let source_addr = source.local_addr().expect("source addr");
thread::spawn(move || {
let (mut stream, _) = source.accept().expect("accept source");
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let response = format!(
"HTTP/1.1 302 Found\r\nLocation: http://{target_addr}/foreign\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
stream.write_all(response.as_bytes()).expect("redirect");
});
let uri = format!("http://{source_addr}/start");
let origin = RrdpOrigin::parse(&uri, RrdpResourceKind::Notification).expect("origin");
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(2),
..HttpFetcherConfig::default()
})
.expect("http");
let err = http
.fetch_rrdp(RrdpResourceKind::Notification, &uri, &origin)
.unwrap_err();
assert!(matches!(
err,
RrdpFetchError::Rrdp(RrdpError::CrossOriginRedirect { .. })
));
target_thread.join().expect("target thread");
assert_eq!(target_hits.load(Ordering::SeqCst), 0);
}
}

View File

@@ -13,7 +13,8 @@ use crate::fetch::rsync::{
RsyncFetchError, RsyncFetchResult, RsyncFetcher, normalize_rsync_base_uri,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RsyncScopePolicy {
Host,
PublicationPoint,
@@ -297,13 +298,7 @@ impl SystemRsyncFetcher {
}
fn scope_fetch_uri(&self, rsync_base_uri: &str) -> String {
match self.config.scope_policy {
RsyncScopePolicy::Host | RsyncScopePolicy::PublicationPoint => {
normalize_rsync_base_uri(rsync_base_uri)
}
RsyncScopePolicy::ModuleRoot => rsync_module_root_uri(rsync_base_uri)
.unwrap_or_else(|| normalize_rsync_base_uri(rsync_base_uri)),
}
scoped_rsync_fetch_uri(self.config.scope_policy, rsync_base_uri)
}
}
@@ -382,10 +377,35 @@ impl RsyncFetcher for SystemRsyncFetcher {
}
fn failure_dedup_key(&self, rsync_base_uri: &str) -> Option<String> {
match self.config.scope_policy {
RsyncScopePolicy::Host => rsync_host_scope_uri(rsync_base_uri),
RsyncScopePolicy::PublicationPoint | RsyncScopePolicy::ModuleRoot => None,
scoped_rsync_failure_dedup_key(self.config.scope_policy, rsync_base_uri)
}
}
/// Return the exact successful-fetch scope used by the live rsync fetcher.
///
/// Verification-only reuses this function while reading the frozen repository
/// view, so its request keys and object projection scope remain equivalent to
/// the source run without granting it network access.
pub fn scoped_rsync_fetch_uri(scope_policy: RsyncScopePolicy, rsync_base_uri: &str) -> String {
match scope_policy {
RsyncScopePolicy::Host | RsyncScopePolicy::PublicationPoint => {
normalize_rsync_base_uri(rsync_base_uri)
}
RsyncScopePolicy::ModuleRoot => rsync_module_root_uri(rsync_base_uri)
.unwrap_or_else(|| normalize_rsync_base_uri(rsync_base_uri)),
}
}
/// Return the live fetcher's failure-deduplication key for the configured
/// scope. This is part of the transport request identity, even though host
/// scope deliberately does not widen the successful fetch URI.
pub fn scoped_rsync_failure_dedup_key(
scope_policy: RsyncScopePolicy,
rsync_base_uri: &str,
) -> Option<String> {
match scope_policy {
RsyncScopePolicy::Host => rsync_host_scope_uri(rsync_base_uri),
RsyncScopePolicy::PublicationPoint | RsyncScopePolicy::ModuleRoot => None,
}
}

View File

@@ -1,5 +1,6 @@
pub mod ccr;
pub mod cir;
pub mod crypto_sig_cache;
pub mod data_model;
#[cfg(feature = "full")]
@@ -39,6 +40,8 @@ pub mod storage;
#[cfg(feature = "full")]
pub mod sync;
#[cfg(feature = "full")]
pub mod ta_constraints;
#[cfg(feature = "full")]
pub mod tools;
#[cfg(feature = "full")]
pub mod validation;

View File

@@ -1,8 +1,13 @@
use crate::parallel::dead_repo_blacklist::DeadRepoBlacklistConfig;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParallelPhase1Config {
pub max_repo_sync_workers_global: usize,
pub max_inflight_snapshot_bytes_global: usize,
pub max_pending_repo_results: usize,
/// Dead-repo transport blacklist (#141). `None` disables the feature
/// entirely (default, behavior unchanged).
pub dead_repo_blacklist: Option<DeadRepoBlacklistConfig>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -15,6 +20,9 @@ pub struct ParallelPhase2Config {
pub publication_point_finalize_batch_size: usize,
pub publication_point_finalize_wall_time_budget_ms: u64,
pub publication_point_finalize_queue_capacity: usize,
/// Experimental: workers of the ready publication point stage pool. `0`
/// disables the pool and keeps the inline compute+apply staging path.
pub stage_workers: usize,
}
impl Default for ParallelPhase2Config {
@@ -28,6 +36,7 @@ impl Default for ParallelPhase2Config {
publication_point_finalize_batch_size: 256,
publication_point_finalize_wall_time_budget_ms: 100,
publication_point_finalize_queue_capacity: 32768,
stage_workers: 0,
}
}
}
@@ -38,6 +47,7 @@ impl Default for ParallelPhase1Config {
max_repo_sync_workers_global: 4,
max_inflight_snapshot_bytes_global: 512 * 1024 * 1024,
max_pending_repo_results: 1024,
dead_repo_blacklist: None,
}
}
}

View File

@@ -0,0 +1,376 @@
//! Persistent dead-repo transport blacklist (#141).
//!
//! Tracks per-(repo, transport) consecutive transport-layer fetch failures
//! across runs. Once an entry reaches the configured failure threshold it is
//! admitted to the blacklist; the live transport scheduler then skips the
//! dead transport (rrdp-blacklisted -> straight to rsync, dual-blacklisted ->
//! immediate terminal failure). Entries are removed either by a successful
//! fetch (self-heal) or by the daemon-side health probe.
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::parallel::types::{RepoTransportErrorClass, RepoTransportMode};
pub const DEAD_REPO_BLACKLIST_SCHEMA_VERSION: u32 = 1;
pub const DEAD_REPO_BLACKLIST_DEFAULT_CAPACITY: usize = 256;
/// Probe backoff is capped at interval * 2^PROBE_BACKOFF_CAP_SHIFT.
pub const PROBE_BACKOFF_CAP_SHIFT: u32 = 4;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeadRepoBlacklistConfig {
pub path: PathBuf,
pub fail_threshold: u32,
pub capacity: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeadRepoBlacklistEntry {
pub transport: RepoTransportMode,
pub uri: String,
/// Consecutive runs with transport-class fetch failure. Reset on success.
pub consecutive_failures: u32,
/// True once `consecutive_failures` reached the admission threshold.
#[serde(default)]
pub blacklisted: bool,
#[serde(default)]
pub first_failure_at_unix: u64,
#[serde(default)]
pub last_failure_at_unix: u64,
/// When the entry was admitted to the blacklist.
#[serde(default)]
pub added_at_unix: u64,
#[serde(default)]
pub last_probe_at_unix: Option<u64>,
#[serde(default)]
pub probe_failures: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeadRepoFailureOutcome {
/// Failure counted, entry not (or not yet) blacklisted.
Counted { consecutive_failures: u32 },
/// Entry just reached the threshold and was admitted.
Admitted,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeadRepoSuccessOutcome {
/// A counting (not yet admitted) entry was reset.
CounterReset,
/// A blacklisted entry was removed (repo revived).
BlacklistRemoved,
}
#[derive(Serialize, Deserialize)]
struct DeadRepoBlacklistFile {
schema_version: u32,
updated_at_unix: u64,
#[serde(default)]
entries: Vec<DeadRepoBlacklistEntry>,
}
/// In-memory blacklist state for one writer at a time (child run, or daemon
/// supervisor between runs). Lookups go through a HashMap keyed by
/// (transport, uri).
#[derive(Clone, Debug, Default)]
pub struct DeadRepoBlacklist {
entries: HashMap<(RepoTransportMode, String), DeadRepoBlacklistEntry>,
}
impl DeadRepoBlacklist {
pub fn new() -> Self {
Self::default()
}
/// Load from disk. A missing file or an unreadable/incompatible file
/// degrades to an empty blacklist (logged by the caller via `warning`).
pub fn load(path: &Path) -> (Self, Option<String>) {
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == io::ErrorKind::NotFound => return (Self::new(), None),
Err(err) => {
return (
Self::new(),
Some(format!(
"dead repo blacklist {} unreadable ({err}); starting empty",
path.display()
)),
);
}
};
match serde_json::from_slice::<DeadRepoBlacklistFile>(&bytes) {
Ok(file) if file.schema_version == DEAD_REPO_BLACKLIST_SCHEMA_VERSION => {
let entries = file
.entries
.into_iter()
.map(|entry| ((entry.transport, entry.uri.clone()), entry))
.collect();
(Self { entries }, None)
}
Ok(file) => (
Self::new(),
Some(format!(
"dead repo blacklist {} schema_version {} != {}; starting empty",
path.display(),
file.schema_version,
DEAD_REPO_BLACKLIST_SCHEMA_VERSION
)),
),
Err(err) => (
Self::new(),
Some(format!(
"dead repo blacklist {} unparsable ({err}); starting empty",
path.display()
)),
),
}
}
/// Persist atomically (tmp file + rename).
pub fn store_atomic(&self, path: &Path, now_unix: u64) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut entries: Vec<&DeadRepoBlacklistEntry> = self.entries.values().collect();
entries.sort_by(|a, b| a.uri.cmp(&b.uri).then(a.transport.cmp(&b.transport)));
let file = DeadRepoBlacklistFile {
schema_version: DEAD_REPO_BLACKLIST_SCHEMA_VERSION,
updated_at_unix: now_unix,
entries: entries.into_iter().cloned().collect(),
};
let bytes = serde_json::to_vec_pretty(&file)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
let mut tmp_name = path.file_name().map(|name| name.to_os_string()).unwrap_or_default();
tmp_name.push(".tmp");
let tmp_path = path.with_file_name(tmp_name);
fs::write(&tmp_path, &bytes)?;
// Best-effort durability before the rename.
if let Ok(file) = fs::File::open(&tmp_path) {
let _ = file.sync_all();
}
fs::rename(&tmp_path, path)?;
Ok(())
}
pub fn is_blacklisted(&self, transport: RepoTransportMode, uri: &str) -> bool {
self.entries
.get(&(transport, uri.to_string()))
.map(|entry| entry.blacklisted)
.unwrap_or(false)
}
/// Record a transport-class fetch failure. Returns whether the failure
/// newly admitted the entry to the blacklist.
pub fn record_transport_failure(
&mut self,
transport: RepoTransportMode,
uri: &str,
now_unix: u64,
fail_threshold: u32,
capacity: usize,
) -> DeadRepoFailureOutcome {
let key = (transport, uri.to_string());
if !self.entries.contains_key(&key) {
self.evict_if_full(capacity);
}
let threshold = fail_threshold.max(1);
let entry = self.entries.entry(key).or_insert_with(|| DeadRepoBlacklistEntry {
transport,
uri: uri.to_string(),
consecutive_failures: 0,
blacklisted: false,
first_failure_at_unix: now_unix,
last_failure_at_unix: now_unix,
added_at_unix: 0,
last_probe_at_unix: None,
probe_failures: 0,
});
entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
entry.last_failure_at_unix = now_unix;
if entry.first_failure_at_unix == 0 {
entry.first_failure_at_unix = now_unix;
}
if !entry.blacklisted && entry.consecutive_failures >= threshold {
entry.blacklisted = true;
entry.added_at_unix = now_unix;
// A fresh admission restarts probe bookkeeping.
entry.last_probe_at_unix = None;
entry.probe_failures = 0;
return DeadRepoFailureOutcome::Admitted;
}
DeadRepoFailureOutcome::Counted {
consecutive_failures: entry.consecutive_failures,
}
}
/// Record a successful fetch: reset a counting entry, or remove a
/// blacklisted entry entirely (self-heal).
pub fn record_transport_success(
&mut self,
transport: RepoTransportMode,
uri: &str,
) -> Option<DeadRepoSuccessOutcome> {
match self.entries.remove(&(transport, uri.to_string())) {
Some(entry) if entry.blacklisted => Some(DeadRepoSuccessOutcome::BlacklistRemoved),
Some(_) => Some(DeadRepoSuccessOutcome::CounterReset),
None => None,
}
}
/// Blacklisted entries whose next probe is due at `now_unix`.
pub fn probe_due_entries(
&self,
now_unix: u64,
probe_interval_secs: u64,
) -> Vec<DeadRepoBlacklistEntry> {
let mut due: Vec<DeadRepoBlacklistEntry> = self
.entries
.values()
.filter(|entry| entry.blacklisted)
.filter(|entry| {
let interval = probe_backoff_secs(probe_interval_secs, entry.probe_failures);
match entry.last_probe_at_unix {
None => true,
Some(last) => now_unix.saturating_sub(last) >= interval,
}
})
.cloned()
.collect();
due.sort_by(|a, b| a.uri.cmp(&b.uri));
due
}
/// Probe succeeded: remove the entry. Returns true if it was present.
pub fn record_probe_success(&mut self, transport: RepoTransportMode, uri: &str) -> bool {
self.entries.remove(&(transport, uri.to_string())).is_some()
}
pub fn record_probe_failure(&mut self, transport: RepoTransportMode, uri: &str, now_unix: u64) {
if let Some(entry) = self.entries.get_mut(&(transport, uri.to_string())) {
entry.last_probe_at_unix = Some(now_unix);
entry.probe_failures = entry.probe_failures.saturating_add(1);
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn blacklisted_len(&self) -> usize {
self.entries.values().filter(|entry| entry.blacklisted).count()
}
/// Deterministic snapshot of all entries (for status/observability).
pub fn entries_sorted(&self) -> Vec<DeadRepoBlacklistEntry> {
let mut entries: Vec<DeadRepoBlacklistEntry> = self.entries.values().cloned().collect();
entries.sort_by(|a, b| a.uri.cmp(&b.uri).then(a.transport.cmp(&b.transport)));
entries
}
fn evict_if_full(&mut self, capacity: usize) {
let capacity = capacity.max(1);
if self.entries.len() < capacity {
return;
}
// Prefer evicting the stalest counting entry; fall back to the oldest
// blacklisted entry.
let victim = self
.entries
.values()
.filter(|entry| !entry.blacklisted)
.min_by_key(|entry| entry.last_failure_at_unix)
.map(|entry| (entry.transport, entry.uri.clone()))
.or_else(|| {
self.entries
.values()
.min_by_key(|entry| entry.added_at_unix)
.map(|entry| (entry.transport, entry.uri.clone()))
});
if let Some(key) = victim {
self.entries.remove(&key);
}
}
}
pub fn probe_backoff_secs(base_interval_secs: u64, probe_failures: u32) -> u64 {
// First retry after a probe failure still uses the base interval;
// exponential backoff kicks in from the second consecutive failure.
let shift = probe_failures.saturating_sub(1).min(PROBE_BACKOFF_CAP_SHIFT);
base_interval_secs.saturating_mul(1u64 << shift)
}
/// Classify an HTTP fetch error string (formats from
/// `fetch/http.rs::BlockingHttpFetcher::fetch_bytes`).
pub fn classify_http_fetch_error(detail: &str) -> RepoTransportErrorClass {
if detail.starts_with("http status") {
// The peer answered: transport is fine, the failure is protocol-level.
RepoTransportErrorClass::Protocol
} else if detail.starts_with("http request failed") || detail.starts_with("http read body failed") {
RepoTransportErrorClass::TransportFetch
} else {
RepoTransportErrorClass::Unknown
}
}
/// Classify an rsync fetch error string using the same host-level substring
/// set as the run-local rsync failure scope short-circuit.
pub fn classify_rsync_fetch_error(detail: &str) -> RepoTransportErrorClass {
if crate::parallel::repo_scheduler::is_host_level_rsync_failure(detail)
// Stall failures are transport death too: the fail-fast mechanism
// gives up when wall-clock windows pass with no (additional) progress
// (#141 M9). Content-level errors fail immediately and never carry
// this marker, so they still do not count.
|| detail.contains("rsync fail-fast gave up")
{
RepoTransportErrorClass::TransportFetch
} else {
// Content/protocol-level rsync failures (digest mismatch, vanished
// files, partial transfers) never count toward the blacklist.
RepoTransportErrorClass::Unknown
}
}
/// Map a structured RRDP sync error to a transport error class.
pub fn classify_rrdp_sync_error(err: &crate::sync::rrdp::RrdpSyncError) -> RepoTransportErrorClass {
match err {
crate::sync::rrdp::RrdpSyncError::Fetch(detail) => classify_http_fetch_error(detail),
crate::sync::rrdp::RrdpSyncError::Rrdp(_) => RepoTransportErrorClass::Protocol,
crate::sync::rrdp::RrdpSyncError::Storage(_) => RepoTransportErrorClass::Storage,
}
}
/// Map a structured rsync-side repo sync error to a transport error class.
pub fn classify_rsync_repo_sync_error(err: &crate::sync::repo::RepoSyncError) -> RepoTransportErrorClass {
match err {
crate::sync::repo::RepoSyncError::Rsync(crate::fetch::rsync::RsyncFetchError::Fetch(detail)) => {
classify_rsync_fetch_error(detail)
}
crate::sync::repo::RepoSyncError::Storage(_) => RepoTransportErrorClass::Storage,
_ => RepoTransportErrorClass::Protocol,
}
}
impl RepoTransportMode {
pub fn as_str(self) -> &'static str {
match self {
RepoTransportMode::Rrdp => "rrdp",
RepoTransportMode::Rsync => "rsync",
}
}
}
// Keep the public surface free of internal key types; the (transport, uri)
// tuple key is an implementation detail of the lookup map.
#[cfg(test)]
#[path = "dead_repo_blacklist_tests.rs"]
mod tests;

View File

@@ -0,0 +1,360 @@
use super::*;
use crate::parallel::types::RepoTransportMode;
const RRDP_URI: &str = "https://dead.example.com/notification.xml";
const RSYNC_URI: &str = "rsync://dead.example.com/repo/";
fn now() -> u64 {
1_700_000_000
}
#[test]
fn admission_requires_threshold_consecutive_failures() {
let mut bl = DeadRepoBlacklist::new();
assert!(!bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI));
let out1 = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now(), 3, 256);
assert_eq!(
out1,
DeadRepoFailureOutcome::Counted {
consecutive_failures: 1
}
);
assert!(!bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI));
let out2 = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 1, 3, 256);
assert_eq!(
out2,
DeadRepoFailureOutcome::Counted {
consecutive_failures: 2
}
);
assert!(!bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI));
let out3 = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 2, 3, 256);
assert_eq!(out3, DeadRepoFailureOutcome::Admitted);
assert!(bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI));
// Further failures keep counting but do not re-admit.
let out4 = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 3, 3, 256);
assert!(matches!(
out4,
DeadRepoFailureOutcome::Counted {
consecutive_failures: 4
}
));
}
#[test]
fn threshold_of_one_admits_immediately() {
let mut bl = DeadRepoBlacklist::new();
let out = bl.record_transport_failure(RepoTransportMode::Rsync, RSYNC_URI, now(), 1, 256);
assert_eq!(out, DeadRepoFailureOutcome::Admitted);
}
#[test]
fn success_resets_counting_entry() {
let mut bl = DeadRepoBlacklist::new();
bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now(), 3, 256);
assert_eq!(bl.len(), 1);
let out = bl.record_transport_success(RepoTransportMode::Rrdp, RRDP_URI);
assert_eq!(out, Some(DeadRepoSuccessOutcome::CounterReset));
assert!(bl.is_empty());
// Next failure starts from zero again.
let out = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now(), 3, 256);
assert_eq!(
out,
DeadRepoFailureOutcome::Counted {
consecutive_failures: 1
}
);
}
#[test]
fn success_on_blacklisted_entry_self_heals() {
let mut bl = DeadRepoBlacklist::new();
for i in 0..3 {
bl.record_transport_failure(RepoTransportMode::Rsync, RSYNC_URI, now() + i, 3, 256);
}
assert!(bl.is_blacklisted(RepoTransportMode::Rsync, RSYNC_URI));
let out = bl.record_transport_success(RepoTransportMode::Rsync, RSYNC_URI);
assert_eq!(out, Some(DeadRepoSuccessOutcome::BlacklistRemoved));
assert!(!bl.is_blacklisted(RepoTransportMode::Rsync, RSYNC_URI));
assert!(bl.is_empty());
}
#[test]
fn success_on_unknown_entry_is_none() {
let mut bl = DeadRepoBlacklist::new();
assert_eq!(
bl.record_transport_success(RepoTransportMode::Rrdp, RRDP_URI),
None
);
}
#[test]
fn entries_are_per_transport() {
let mut bl = DeadRepoBlacklist::new();
for i in 0..3 {
bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + i, 3, 256);
}
assert!(bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI));
assert!(!bl.is_blacklisted(RepoTransportMode::Rsync, RRDP_URI));
}
#[test]
fn capacity_evicts_stale_counting_entries_first() {
let mut bl = DeadRepoBlacklist::new();
// One blacklisted entry (old) and two counting entries.
for i in 0..3 {
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://a.example/n.xml", 100 + i, 1, 3);
}
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://b.example/n.xml", 200, 3, 3);
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://c.example/n.xml", 300, 3, 3);
assert_eq!(bl.len(), 3);
assert_eq!(bl.blacklisted_len(), 1);
// Inserting a fourth entry evicts the stalest counting entry (b).
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://d.example/n.xml", 400, 3, 3);
assert_eq!(bl.len(), 3);
assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://a.example/n.xml"));
assert!(!bl.entries_sorted().iter().any(|e| e.uri == "https://b.example/n.xml"));
assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://c.example/n.xml"));
assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://d.example/n.xml"));
}
#[test]
fn capacity_evicts_oldest_blacklisted_when_no_counting_entries() {
let mut bl = DeadRepoBlacklist::new();
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://a.example/n.xml", 100, 1, 2);
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://b.example/n.xml", 200, 1, 2);
assert_eq!(bl.blacklisted_len(), 2);
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://c.example/n.xml", 300, 1, 2);
assert_eq!(bl.len(), 2);
assert!(!bl.entries_sorted().iter().any(|e| e.uri == "https://a.example/n.xml"));
assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://b.example/n.xml"));
assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://c.example/n.xml"));
}
#[test]
fn store_load_roundtrip() {
let dir = std::env::temp_dir().join(format!(
"dead_repo_blacklist_test_roundtrip_{}_{}",
std::process::id(),
now()
));
let path = dir.join("state").join("dead-repo-blacklist.json");
let mut bl = DeadRepoBlacklist::new();
for i in 0..3 {
bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + i, 3, 256);
}
bl.record_transport_failure(RepoTransportMode::Rsync, RSYNC_URI, now(), 3, 256);
bl.store_atomic(&path, now() + 10).unwrap();
let (loaded, warning) = DeadRepoBlacklist::load(&path);
assert!(warning.is_none());
assert!(loaded.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI));
assert!(!loaded.is_blacklisted(RepoTransportMode::Rsync, RSYNC_URI));
assert_eq!(loaded.len(), 2);
let entry = loaded
.entries_sorted()
.into_iter()
.find(|e| e.uri == RRDP_URI)
.unwrap();
assert_eq!(entry.consecutive_failures, 3);
assert!(entry.blacklisted);
assert_eq!(entry.first_failure_at_unix, now());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_missing_file_is_empty_without_warning() {
let path = std::env::temp_dir().join(format!(
"dead_repo_blacklist_test_missing_{}_{}.json",
std::process::id(),
now()
));
let (bl, warning) = DeadRepoBlacklist::load(&path);
assert!(bl.is_empty());
assert!(warning.is_none());
}
#[test]
fn load_corrupted_file_degrades_to_empty_with_warning() {
let path = std::env::temp_dir().join(format!(
"dead_repo_blacklist_test_corrupt_{}_{}.json",
std::process::id(),
now()
));
std::fs::write(&path, b"{not json").unwrap();
let (bl, warning) = DeadRepoBlacklist::load(&path);
assert!(bl.is_empty());
assert!(warning.is_some());
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_wrong_schema_version_degrades_to_empty() {
let path = std::env::temp_dir().join(format!(
"dead_repo_blacklist_test_schema_{}_{}.json",
std::process::id(),
now()
));
std::fs::write(
&path,
r#"{"schema_version": 99, "updated_at_unix": 0, "entries": []}"#,
)
.unwrap();
let (bl, warning) = DeadRepoBlacklist::load(&path);
assert!(bl.is_empty());
assert!(warning.unwrap().contains("schema_version"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn probe_due_uses_exponential_backoff() {
let mut bl = DeadRepoBlacklist::new();
for i in 0..3 {
bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + i, 3, 256);
}
// Never probed: due immediately.
let due = bl.probe_due_entries(now() + 100, 600);
assert_eq!(due.len(), 1);
// Probe failure: not due until base interval has passed.
bl.record_probe_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 100);
assert!(bl.probe_due_entries(now() + 100 + 599, 600).is_empty());
assert_eq!(bl.probe_due_entries(now() + 100 + 600, 600).len(), 1);
// Second probe failure: backoff doubles to 1200s.
bl.record_probe_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 700);
assert!(bl.probe_due_entries(now() + 700 + 1199, 600).is_empty());
assert_eq!(bl.probe_due_entries(now() + 700 + 1200, 600).len(), 1);
}
#[test]
fn probe_success_removes_entry() {
let mut bl = DeadRepoBlacklist::new();
for i in 0..3 {
bl.record_transport_failure(RepoTransportMode::Rsync, RSYNC_URI, now() + i, 3, 256);
}
assert!(bl.record_probe_success(RepoTransportMode::Rsync, RSYNC_URI));
assert!(bl.is_empty());
assert!(!bl.record_probe_success(RepoTransportMode::Rsync, RSYNC_URI));
}
#[test]
fn counting_entries_are_not_probed() {
let mut bl = DeadRepoBlacklist::new();
bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now(), 3, 256);
assert!(bl.probe_due_entries(now() + 10_000, 600).is_empty());
}
#[test]
fn probe_backoff_caps_at_max_shift() {
assert_eq!(probe_backoff_secs(600, 0), 600);
assert_eq!(probe_backoff_secs(600, 1), 600);
assert_eq!(probe_backoff_secs(600, 2), 1200);
assert_eq!(probe_backoff_secs(600, 5), 9600);
assert_eq!(probe_backoff_secs(600, 100), 9600);
}
#[test]
fn http_fetch_error_classification() {
use crate::parallel::types::RepoTransportErrorClass;
assert_eq!(
classify_http_fetch_error("http request failed: reqwest::Error { kind: Connect, .. }"),
RepoTransportErrorClass::TransportFetch
);
assert_eq!(
classify_http_fetch_error("http read body failed: connection closed; status=200"),
RepoTransportErrorClass::TransportFetch
);
assert_eq!(
classify_http_fetch_error("http status 404 Not Found; content_type=; .."),
RepoTransportErrorClass::Protocol
);
assert_eq!(
classify_http_fetch_error("something unexpected"),
RepoTransportErrorClass::Unknown
);
}
#[test]
fn rsync_fetch_error_classification() {
use crate::parallel::types::RepoTransportErrorClass;
assert_eq!(
classify_rsync_fetch_error("rsync error: timeout waiting for daemon connection"),
RepoTransportErrorClass::TransportFetch
);
assert_eq!(
classify_rsync_fetch_error("rsync: failed to connect to host: Connection refused"),
RepoTransportErrorClass::TransportFetch
);
assert_eq!(
classify_rsync_fetch_error("temporary failure in name resolution"),
RepoTransportErrorClass::TransportFetch
);
// Content-level failures must not count.
assert_eq!(
classify_rsync_fetch_error("rsync file digest mismatch after download"),
RepoTransportErrorClass::Unknown
);
// Stall failures (#141 M9): fail-fast give-up means the host delivered no
// bytes inside the wall-clock window — transport death, must count.
assert_eq!(
classify_rsync_fetch_error(
"rsync fail-fast gave up after 1 attempts with no progress: rsync wall-clock window expired"
),
RepoTransportErrorClass::TransportFetch
);
assert_eq!(
classify_rsync_fetch_error(
"rsync fail-fast gave up after 3 attempts with no additional progress: rsync wall-clock window expired"
),
RepoTransportErrorClass::TransportFetch
);
// Content-level give-up text without the fail-fast marker stays unknown.
assert_eq!(
classify_rsync_fetch_error("rsync error: some files vanished before transfer"),
RepoTransportErrorClass::Unknown
);
}
#[test]
fn rrdp_sync_error_classification() {
use crate::parallel::types::RepoTransportErrorClass;
use crate::sync::rrdp::RrdpSyncError;
assert_eq!(
classify_rrdp_sync_error(&RrdpSyncError::Fetch(
"http request failed: connect timeout".to_string()
)),
RepoTransportErrorClass::TransportFetch
);
assert_eq!(
classify_rrdp_sync_error(&RrdpSyncError::Fetch("http status 500".to_string())),
RepoTransportErrorClass::Protocol
);
assert_eq!(
classify_rrdp_sync_error(&RrdpSyncError::Storage("db full".to_string())),
RepoTransportErrorClass::Storage
);
}
#[test]
fn readmission_after_probe_removal_starts_fresh() {
let mut bl = DeadRepoBlacklist::new();
for i in 0..3 {
bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + i, 3, 256);
}
assert!(bl.record_probe_success(RepoTransportMode::Rrdp, RRDP_URI));
// No half-open: needs the full threshold again.
let out = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 10, 3, 256);
assert_eq!(
out,
DeadRepoFailureOutcome::Counted {
consecutive_failures: 1
}
);
}

View File

@@ -1,4 +1,5 @@
pub mod config;
pub mod dead_repo_blacklist;
pub mod object_worker;
pub mod phase2_scheduler;
pub mod repo_runtime;

View File

@@ -1,9 +1,13 @@
use std::marker::PhantomData;
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError};
use std::thread::{self, JoinHandle};
use std::thread::{self, JoinHandle, Scope};
use std::time::Duration;
pub trait ObjectTaskExecutor<T, R>: Send + Sync + 'static {
/// Executor shared by every worker of a pool. The `'static` bound required to
/// move the executor into detached threads is expressed on the pool types
/// instead of this trait so scoped pools can borrow their environment.
pub trait ObjectTaskExecutor<T, R>: Send + Sync {
fn execute(&self, worker_index: usize, task: T) -> R;
}
@@ -22,7 +26,7 @@ pub struct ObjectWorkerPool<T, R, E>
where
T: Send + 'static,
R: Send + 'static,
E: ObjectTaskExecutor<T, R>,
E: ObjectTaskExecutor<T, R> + 'static,
{
task_txs: Vec<SyncSender<ObjectWorkerMessage<T>>>,
result_rx: Receiver<R>,
@@ -35,7 +39,7 @@ impl<T, R, E> ObjectWorkerPool<T, R, E>
where
T: Send + 'static,
R: Send + 'static,
E: ObjectTaskExecutor<T, R>,
E: ObjectTaskExecutor<T, R> + 'static,
{
pub fn new(worker_count: usize, queue_capacity: usize, executor: E) -> Result<Self, String> {
if worker_count == 0 {
@@ -140,7 +144,7 @@ impl<T, R, E> Drop for ObjectWorkerPool<T, R, E>
where
T: Send + 'static,
R: Send + 'static,
E: ObjectTaskExecutor<T, R>,
E: ObjectTaskExecutor<T, R> + 'static,
{
fn drop(&mut self) {
let _ = self.shutdown_inner();
@@ -153,8 +157,8 @@ fn object_worker_loop<T, R, E>(
result_tx: mpsc::Sender<R>,
executor: Arc<E>,
) where
T: Send + 'static,
R: Send + 'static,
T: Send,
R: Send,
E: ObjectTaskExecutor<T, R>,
{
loop {
@@ -170,6 +174,120 @@ fn object_worker_loop<T, R, E>(
}
}
/// Scoped variant of `ObjectWorkerPool`: workers are spawned on a
/// `std::thread::Scope`, so tasks, results and the executor may borrow their
/// environment (`'env`) instead of being `'static`. The pool never sends
/// `Shutdown`; workers exit when every task sender is dropped, which happens
/// when the pool itself is dropped ahead of the scope join.
pub struct ScopedObjectWorkerPool<'scope, 'env, T, R, E>
where
T: Send + 'env,
R: Send + 'env,
E: ObjectTaskExecutor<T, R> + 'env,
{
task_txs: Vec<SyncSender<ObjectWorkerMessage<T>>>,
result_rx: Receiver<R>,
next_worker_idx: usize,
_executor: Arc<E>,
// Join handles are intentionally not stored: dropping a `ScopedJoinHandle`
// detaches the worker and the enclosing scope joins it on exit, after the
// dropped task senders have made every worker return.
_marker: PhantomData<(&'scope (), &'env ())>,
}
impl<'scope, 'env, T, R, E> ScopedObjectWorkerPool<'scope, 'env, T, R, E>
where
T: Send + 'env,
R: Send + 'env,
E: ObjectTaskExecutor<T, R> + 'env,
{
pub fn new(
scope: &'scope Scope<'scope, 'env>,
worker_count: usize,
queue_capacity: usize,
executor: E,
) -> Result<Self, String> {
if worker_count == 0 {
return Err("ScopedObjectWorkerPool requires at least one worker".to_string());
}
if queue_capacity == 0 {
return Err("ScopedObjectWorkerPool requires queue_capacity > 0".to_string());
}
let executor = Arc::new(executor);
let (result_tx, result_rx) = mpsc::channel::<R>();
let mut task_txs = Vec::with_capacity(worker_count);
for worker_index in 0..worker_count {
let (task_tx, task_rx) = mpsc::sync_channel::<ObjectWorkerMessage<T>>(queue_capacity);
let result_tx = result_tx.clone();
let executor = Arc::clone(&executor);
thread::Builder::new()
.name(format!("object-validation-worker-{worker_index}"))
.spawn_scoped(scope, move || {
object_worker_loop(worker_index, task_rx, result_tx, executor)
})
.map_err(|e| format!("spawn scoped object worker failed: {e}"))?;
task_txs.push(task_tx);
}
Ok(Self {
task_txs,
result_rx,
next_worker_idx: 0,
_executor: executor,
_marker: PhantomData,
})
}
pub fn worker_count(&self) -> usize {
self.task_txs.len()
}
pub fn try_submit_round_robin(&mut self, task: T) -> Result<usize, ObjectWorkerSubmitError<T>> {
let worker_index = self.next_worker_idx % self.task_txs.len();
match self.task_txs[worker_index].try_send(ObjectWorkerMessage::Task(task)) {
Ok(()) => {
self.next_worker_idx = (worker_index + 1) % self.task_txs.len();
Ok(worker_index)
}
Err(TrySendError::Full(ObjectWorkerMessage::Task(task))) => {
Err(ObjectWorkerSubmitError::QueueFull { worker_index, task })
}
Err(TrySendError::Disconnected(ObjectWorkerMessage::Task(task))) => {
Err(ObjectWorkerSubmitError::Disconnected { worker_index, task })
}
Err(TrySendError::Full(ObjectWorkerMessage::Shutdown))
| Err(TrySendError::Disconnected(ObjectWorkerMessage::Shutdown)) => {
unreachable!("shutdown is never submitted via try_submit_round_robin")
}
}
}
pub fn recv_result_timeout(&self, timeout: Duration) -> Result<Option<R>, String> {
match self.result_rx.recv_timeout(timeout) {
Ok(result) => Ok(Some(result)),
Err(RecvTimeoutError::Timeout) => Ok(None),
Err(RecvTimeoutError::Disconnected) => {
Err("scoped object worker result channel disconnected".to_string())
}
}
}
}
impl<'scope, 'env, T, R, E> Drop for ScopedObjectWorkerPool<'scope, 'env, T, R, E>
where
T: Send + 'env,
R: Send + 'env,
E: ObjectTaskExecutor<T, R> + 'env,
{
fn drop(&mut self) {
// Close every worker input queue so blocked `recv` calls return and
// the scoped workers exit before the enclosing scope joins them.
self.task_txs.clear();
}
}
#[cfg(test)]
mod tests {
use super::{ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError};
@@ -284,4 +402,84 @@ mod tests {
Some(2)
);
}
struct BorrowingEchoExecutor<'a> {
base: &'a u32,
}
impl<'a> ObjectTaskExecutor<u32, u32> for BorrowingEchoExecutor<'a> {
fn execute(&self, _worker_index: usize, task: u32) -> u32 {
task + *self.base
}
}
#[test]
fn scoped_object_worker_pool_borrows_environment_and_processes_tasks() {
let base = 100u32;
std::thread::scope(|scope| {
let mut pool = super::ScopedObjectWorkerPool::new(
scope,
2,
2,
BorrowingEchoExecutor { base: &base },
)
.expect("scoped pool");
assert_eq!(pool.worker_count(), 2);
pool.try_submit_round_robin(1).expect("submit 1");
pool.try_submit_round_robin(2).expect("submit 2");
let mut results = Vec::new();
for _ in 0..2 {
results.push(
pool.recv_result_timeout(Duration::from_secs(1))
.expect("result channel")
.expect("result"),
);
}
results.sort();
assert_eq!(results, vec![101, 102]);
// Dropping the pool inside the scope closes the task queues; the
// workers exit on their own and the scope join below must not hang.
drop(pool);
});
}
#[test]
fn scoped_object_worker_pool_reports_full_queue() {
std::thread::scope(|scope| {
let barrier = Arc::new(Barrier::new(2));
let started = Arc::new(AtomicBool::new(false));
let mut pool = super::ScopedObjectWorkerPool::new(
scope,
1,
1,
BlockingExecutor {
barrier: Arc::clone(&barrier),
started: Arc::clone(&started),
},
)
.expect("scoped pool");
pool.try_submit_round_robin(1).expect("first task");
let deadline = std::time::Instant::now() + Duration::from_secs(1);
while !started.load(Ordering::SeqCst) {
assert!(
std::time::Instant::now() < deadline,
"scoped worker did not start first task"
);
std::thread::sleep(Duration::from_millis(1));
}
pool.try_submit_round_robin(2).expect("queued task");
match pool.try_submit_round_robin(3) {
Err(ObjectWorkerSubmitError::QueueFull { worker_index, task }) => {
assert_eq!(worker_index, 0);
assert_eq!(task, 3);
}
other => panic!("expected queue full, got {other:?}"),
}
// Dropping the pool closes the task queues; releasing the barrier
// afterwards lets the blocked worker finish task 1, observe the
// closed result channel, and exit before the scope join.
drop(pool);
barrier.wait();
});
}
}

View File

@@ -103,6 +103,17 @@ pub trait RepoSyncRuntime: Send + Sync {
) -> Result<TransportPrefetchDispatchStats, String>;
fn transport_prefetch_snapshot(&self) -> TransportPrefetchSnapshot;
/// Dead-repo blacklist working copy (#141) for run-end persistence.
/// `None` when the feature is disabled.
fn dead_repo_blacklist_state(
&self,
) -> Option<(
crate::parallel::dead_repo_blacklist::DeadRepoBlacklistConfig,
crate::parallel::dead_repo_blacklist::DeadRepoBlacklist,
)> {
None
}
}
pub struct Phase1RepoSyncRuntime<E: RepoTransportExecutor> {
@@ -634,6 +645,18 @@ impl<E: RepoTransportExecutor> RepoSyncRuntime for Phase1RepoSyncRuntime<E> {
})
.unwrap_or_else(|| TransportPrefetchSnapshot::new(self.sync_preference, Vec::new()))
}
fn dead_repo_blacklist_state(
&self,
) -> Option<(
crate::parallel::dead_repo_blacklist::DeadRepoBlacklistConfig,
crate::parallel::dead_repo_blacklist::DeadRepoBlacklist,
)> {
let coordinator = self.coordinator.lock().expect("coordinator lock poisoned");
let config = coordinator.config.dead_repo_blacklist.clone()?;
let blacklist = coordinator.dead_repo_blacklist().cloned()?;
Some((config, blacklist))
}
}
fn outcome_from_transport_result(
@@ -666,7 +689,9 @@ fn outcome_from_transport_result(
}
}
(
RepoTransportResultKind::Failed { detail, warnings },
RepoTransportResultKind::Failed {
detail, warnings, ..
},
RepoRuntimeState::FailedTerminal,
) => RepoSyncRuntimeOutcome {
repo_sync_ok: false,
@@ -798,6 +823,7 @@ mod tests {
result: RepoTransportResultKind::Failed {
detail: "rrdp failed".to_string(),
warnings: vec![Warning::new("rrdp failed")],
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
},
}
}
@@ -842,6 +868,7 @@ mod tests {
result: RepoTransportResultKind::Failed {
detail: "rrdp failed".to_string(),
warnings: vec![Warning::new("rrdp failed")],
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
},
}
}
@@ -858,6 +885,7 @@ mod tests {
result: RepoTransportResultKind::Failed {
detail: "rsync failed".to_string(),
warnings: vec![Warning::new("rsync failed")],
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
},
}
}

View File

@@ -1,11 +1,14 @@
use std::collections::{HashMap, HashSet};
use crate::parallel::dead_repo_blacklist::DeadRepoBlacklist;
use crate::parallel::types::{
InFlightRepoEntry, RepoDedupKey, RepoIdentity, RepoKey, RepoRequester, RepoRuntimeState,
RepoSyncResultEnvelope, RepoSyncResultKind, RepoSyncResultRef, RepoSyncTask, RepoTaskState,
RepoTransportMode, RepoTransportResultEnvelope, RepoTransportResultKind, RepoTransportTask,
RepoTransportErrorClass, RepoTransportMode, RepoTransportResultEnvelope,
RepoTransportResultKind, RepoTransportTask,
};
use crate::policy::SyncPreference;
use crate::report::Warning;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RepoRequestAction {
@@ -74,6 +77,9 @@ pub struct TransportStateTables {
rsync_failure_probe_inflight: HashMap<String, String>,
rsync_failure_scope_reachable: HashSet<String>,
runtime_records: HashMap<RepoIdentity, RepoRuntimeRecord>,
/// Frozen run-start snapshot of the dead-repo blacklist (#141). Entries
/// admitted during this run only take effect from the next run.
dead_repo_blacklist: Option<DeadRepoBlacklist>,
}
impl TransportStateTables {
@@ -81,6 +87,44 @@ impl TransportStateTables {
Self::default()
}
pub fn set_dead_repo_blacklist(&mut self, blacklist: DeadRepoBlacklist) {
self.dead_repo_blacklist = Some(blacklist);
}
fn dead_repo_terminal_envelope(
identity: &RepoIdentity,
requesters: &[RepoRequester],
rsync_scope_uri: &str,
rsync_failure_scope_uri: Option<String>,
) -> RepoTransportResultEnvelope {
let first_requester = requesters
.first()
.expect("blacklist terminal record must keep at least one requester");
RepoTransportResultEnvelope {
dedup_key: RepoDedupKey::RsyncScope {
rsync_scope_uri: rsync_scope_uri.to_string(),
},
rsync_failure_scope_uri,
repo_identity: identity.clone(),
mode: RepoTransportMode::Rsync,
tal_id: first_requester.tal_id.clone(),
rir_id: first_requester.rir_id.clone(),
timing_ms: 0,
result: RepoTransportResultKind::Failed {
detail: format!(
"dead repo blacklist: rsync transport persistently unreachable: {}",
identity.rsync_base_uri
),
warnings: vec![Warning::new(format!(
"dead_repo_blacklist_skip_all: repository {} skipped after repeated transport failures",
identity.rsync_base_uri
))
.with_context(identity.rsync_base_uri.clone())],
error_class: RepoTransportErrorClass::Unknown,
},
}
}
pub fn runtime_record(&self, identity: &RepoIdentity) -> Option<&RepoRuntimeRecord> {
self.runtime_records.get(identity)
}
@@ -205,6 +249,82 @@ impl TransportStateTables {
};
}
// Dead-repo blacklist fast paths (#141), consulted only for fresh
// identities; existing records already encode their terminal state.
if let Some(blacklist) = self.dead_repo_blacklist.as_ref() {
let rrdp_wanted = sync_preference == SyncPreference::RrdpThenRsync
&& identity.notification_uri.is_some();
let rrdp_dead = rrdp_wanted
&& identity
.notification_uri
.as_deref()
.map(|uri| blacklist.is_blacklisted(RepoTransportMode::Rrdp, uri))
.unwrap_or(false);
let rsync_dead = blacklist.is_blacklisted(
RepoTransportMode::Rsync,
identity.rsync_base_uri.as_str(),
);
if rsync_dead && (!rrdp_wanted || rrdp_dead) {
crate::progress_log::emit(
"dead_repo_blacklist_skip_all",
serde_json::json!({
"repo_key_notification_uri": identity.notification_uri,
"repo_key_rsync_base_uri": identity.rsync_base_uri,
}),
);
let envelope = Self::dead_repo_terminal_envelope(
&identity,
std::slice::from_ref(&requester),
&rsync_scope_uri,
rsync_failure_scope_uri.clone(),
);
self.runtime_records.insert(
identity.clone(),
RepoRuntimeRecord {
identity,
state: RepoRuntimeState::FailedTerminal,
rrdp_notification_key: None,
rsync_scope_key: rsync_scope_uri,
rsync_failure_scope_key: rsync_failure_scope_uri,
requesters: vec![requester],
validation_time,
priority,
last_success: None,
terminal_failure: Some(envelope.clone()),
},
);
return TransportRequestAction::ReusedTerminalFailure(envelope);
}
if rrdp_dead {
crate::progress_log::emit(
"dead_repo_blacklist_skip_rrdp",
serde_json::json!({
"repo_key_notification_uri": identity.notification_uri,
"repo_key_rsync_base_uri": identity.rsync_base_uri,
}),
);
let mut action = self.register_rsync_request(
identity,
requester,
validation_time,
priority,
rsync_scope_uri,
rsync_failure_scope_uri,
);
// A blacklisted rrdp transport is a known-persistent failure,
// so the rsync fallback must keep the short retry profile
// (#141): the prefetch layer records no real rrdp failure in
// skipped runs, which would otherwise silently drop the task
// back to the default 15s window from the second skipped run
// on. The coordinator wrapper only ever sets the flag to true,
// so setting it here survives registration.
if let TransportRequestAction::Enqueue(task) = &mut action {
task.retry_short_timeout = true;
}
return action;
}
}
if sync_preference == SyncPreference::RrdpThenRsync {
if let Some(notification_uri) = identity.notification_uri.clone() {
if let Some(entry) = self.rrdp_inflight.get_mut(&notification_uri) {
@@ -512,6 +632,7 @@ impl TransportStateTables {
rsync_failure_by_scope: &HashMap<String, RepoTransportResultEnvelope>,
rsync_failure_probe_inflight: &mut HashMap<String, String>,
rsync_failure_scope_reachable: &HashSet<String>,
dead_repo_blacklist: Option<&DeadRepoBlacklist>,
follow_up_tasks: &mut Vec<RepoTransportTask>,
) {
let rsync_scope_uri = record.rsync_scope_key.clone();
@@ -548,6 +669,34 @@ impl TransportStateTables {
}
}
// Dead-repo blacklist (#141): the rsync transport of this repo is
// persistently unreachable; terminate instantly instead of enqueueing
// another doomed fetch (rrdp was already attempted or skipped).
if let Some(blacklist) = dead_repo_blacklist {
if blacklist.is_blacklisted(
RepoTransportMode::Rsync,
record.identity.rsync_base_uri.as_str(),
) {
crate::progress_log::emit(
"dead_repo_blacklist_skip_all",
serde_json::json!({
"repo_key_notification_uri": record.identity.notification_uri,
"repo_key_rsync_base_uri": record.identity.rsync_base_uri,
"after_rrdp_failure": true,
}),
);
let envelope = Self::dead_repo_terminal_envelope(
&record.identity,
&record.requesters,
&rsync_scope_uri,
record.rsync_failure_scope_key.clone(),
);
record.state = RepoRuntimeState::FailedTerminal;
record.terminal_failure = Some(envelope);
return;
}
}
let first_requester = record
.requesters
.first()
@@ -666,6 +815,7 @@ impl TransportStateTables {
&self.rsync_failure_by_scope,
&mut self.rsync_failure_probe_inflight,
&self.rsync_failure_scope_reachable,
self.dead_repo_blacklist.as_ref(),
&mut follow_up_tasks,
);
}
@@ -718,6 +868,7 @@ impl TransportStateTables {
&self.rsync_failure_by_scope,
&mut self.rsync_failure_probe_inflight,
&self.rsync_failure_scope_reachable,
self.dead_repo_blacklist.as_ref(),
&mut follow_up_tasks,
);
}
@@ -783,6 +934,7 @@ impl TransportStateTables {
&self.rsync_failure_by_scope,
&mut self.rsync_failure_probe_inflight,
&self.rsync_failure_scope_reachable,
self.dead_repo_blacklist.as_ref(),
&mut follow_up_tasks,
);
}
@@ -814,7 +966,7 @@ fn reusable_rsync_failure_scope(result: &RepoTransportResultEnvelope) -> Option<
}
}
fn is_host_level_rsync_failure(detail: &str) -> bool {
pub(crate) fn is_host_level_rsync_failure(detail: &str) -> bool {
let lower = detail.to_ascii_lowercase();
lower.contains("timeout waiting for daemon connection")
|| lower.contains("failed to connect")
@@ -1462,6 +1614,7 @@ mod transport_tests {
result: RepoTransportResultKind::Failed {
detail: "rrdp timeout".to_string(),
warnings: Vec::new(),
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
},
},
time::OffsetDateTime::UNIX_EPOCH,
@@ -1567,6 +1720,7 @@ mod transport_tests {
result: RepoTransportResultKind::Failed {
detail: "rrdp timeout".to_string(),
warnings: Vec::new(),
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
},
},
time::OffsetDateTime::UNIX_EPOCH,
@@ -1597,6 +1751,7 @@ mod transport_tests {
result: RepoTransportResultKind::Failed {
detail: "rsync error: timeout waiting for daemon connection".to_string(),
warnings: Vec::new(),
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
},
},
time::OffsetDateTime::UNIX_EPOCH,
@@ -1659,6 +1814,7 @@ mod transport_tests {
result: RepoTransportResultKind::Failed {
detail: "rsync timeout".to_string(),
warnings: Vec::new(),
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
},
},
time::OffsetDateTime::UNIX_EPOCH,
@@ -1920,6 +2076,7 @@ mod transport_tests {
result: RepoTransportResultKind::Failed {
detail: "rsync file digest mismatch after download".to_string(),
warnings: Vec::new(),
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
},
},
time::OffsetDateTime::UNIX_EPOCH,
@@ -1972,6 +2129,7 @@ mod transport_tests {
result: RepoTransportResultKind::Failed {
detail: "rsync error: failed to connect to daemon".to_string(),
warnings: Vec::new(),
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
},
},
time::OffsetDateTime::UNIX_EPOCH,
@@ -2031,6 +2189,7 @@ mod transport_tests {
result: RepoTransportResultKind::Failed {
detail: "temporary failure in name resolution".to_string(),
warnings: Vec::new(),
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
},
},
time::OffsetDateTime::UNIX_EPOCH,

View File

@@ -115,19 +115,24 @@ impl<H: Fetcher + 'static> RepoTransportExecutor for LiveRrdpTransportExecutor<H
warnings: Vec::new(),
},
},
Err(err) => RepoTransportResultEnvelope {
dedup_key: task.dedup_key,
rsync_failure_scope_uri: task.rsync_failure_scope_uri,
repo_identity: task.repo_identity,
mode: RepoTransportMode::Rrdp,
tal_id: task.tal_id,
rir_id: task.rir_id,
timing_ms: started.elapsed().as_millis() as u64,
result: RepoTransportResultKind::Failed {
detail: err.to_string(),
warnings: Vec::new(),
},
},
Err(err) => {
let error_class =
crate::parallel::dead_repo_blacklist::classify_rrdp_sync_error(&err);
RepoTransportResultEnvelope {
dedup_key: task.dedup_key,
rsync_failure_scope_uri: task.rsync_failure_scope_uri,
repo_identity: task.repo_identity,
mode: RepoTransportMode::Rrdp,
tal_id: task.tal_id,
rir_id: task.rir_id,
timing_ms: started.elapsed().as_millis() as u64,
result: RepoTransportResultKind::Failed {
detail: err.to_string(),
warnings: Vec::new(),
error_class,
},
}
}
}
}
}
@@ -206,19 +211,24 @@ impl<R: RsyncFetcher + 'static> RepoTransportExecutor for LiveRsyncTransportExec
warnings: Vec::new(),
},
},
Err(err) => RepoTransportResultEnvelope {
dedup_key: task.dedup_key,
rsync_failure_scope_uri: task.rsync_failure_scope_uri,
repo_identity: task.repo_identity,
mode: RepoTransportMode::Rsync,
tal_id: task.tal_id,
rir_id: task.rir_id,
timing_ms: started.elapsed().as_millis() as u64,
result: RepoTransportResultKind::Failed {
detail: err.to_string(),
warnings: Vec::new(),
},
},
Err(err) => {
let error_class =
crate::parallel::dead_repo_blacklist::classify_rsync_repo_sync_error(&err);
RepoTransportResultEnvelope {
dedup_key: task.dedup_key,
rsync_failure_scope_uri: task.rsync_failure_scope_uri,
repo_identity: task.repo_identity,
mode: RepoTransportMode::Rsync,
tal_id: task.tal_id,
rir_id: task.rir_id,
timing_ms: started.elapsed().as_millis() as u64,
result: RepoTransportResultKind::Failed {
detail: err.to_string(),
warnings: Vec::new(),
error_class,
},
}
}
}
}
}
@@ -1160,7 +1170,7 @@ mod tests {
matches!(result.result, RepoTransportResultKind::Success { .. }),
"{result:?}"
);
let index = current_repo_index.lock().expect("index lock");
let index = current_repo_index.read().expect("index read lock");
assert!(
index
.get_by_uri("rsync://example.test/repo/a.roa")
@@ -1217,7 +1227,7 @@ mod tests {
result.result,
RepoTransportResultKind::Success { .. }
));
let index = current_repo_index.lock().expect("index lock");
let index = current_repo_index.read().expect("index read lock");
assert!(
index
.get_by_uri("rsync://example.test/repo/a.roa")

View File

@@ -2,6 +2,9 @@ use std::collections::VecDeque;
use crate::current_repo_index::{CurrentRepoIndex, CurrentRepoIndexHandle};
use crate::parallel::config::ParallelPhase1Config;
use crate::parallel::dead_repo_blacklist::{
DeadRepoBlacklist, DeadRepoFailureOutcome, DeadRepoSuccessOutcome,
};
use crate::parallel::repo_scheduler::{
InFlightRepoTable, RepoCompletion, RepoRequestAction, TransportCompletion,
TransportRequestAction, TransportStateTables,
@@ -9,7 +12,8 @@ use crate::parallel::repo_scheduler::{
use crate::parallel::stats::ParallelRunStats;
use crate::parallel::types::{
RepoDedupKey, RepoIdentity, RepoKey, RepoRequester, RepoSyncResultEnvelope, RepoSyncTask,
RepoTransportResultEnvelope, RepoTransportTask, TalInputSpec,
RepoTransportErrorClass, RepoTransportMode, RepoTransportResultEnvelope,
RepoTransportResultKind, RepoTransportTask, TalInputSpec,
};
use crate::policy::SyncPreference;
@@ -22,19 +26,42 @@ pub struct GlobalRunCoordinator {
pub pending_repo_tasks: VecDeque<RepoSyncTask>,
pub pending_transport_tasks: VecDeque<RepoTransportTask>,
pub stats: ParallelRunStats,
/// Mutable working copy of the dead-repo blacklist (#141): failure/success
/// counters update during the run and persist at run end. Skip decisions
/// inside `transport_tables` use the frozen run-start snapshot.
pub dead_repo_blacklist: Option<DeadRepoBlacklist>,
}
impl GlobalRunCoordinator {
pub fn new(config: ParallelPhase1Config, tal_inputs: Vec<TalInputSpec>) -> Self {
let dead_repo_blacklist = config
.dead_repo_blacklist
.as_ref()
.map(|blacklist_config| {
let (blacklist, warning) = DeadRepoBlacklist::load(&blacklist_config.path);
if let Some(warning) = warning {
crate::progress_log::emit(
"dead_repo_blacklist_load_warning",
serde_json::json!({ "warning": warning }),
);
}
blacklist
});
let mut transport_tables = TransportStateTables::new();
if let Some(blacklist) = dead_repo_blacklist.as_ref() {
// Frozen run-start snapshot for skip decisions.
transport_tables.set_dead_repo_blacklist(blacklist.clone());
}
Self {
config,
tal_inputs,
current_repo_index: CurrentRepoIndex::shared(),
in_flight_repos: InFlightRepoTable::new(),
transport_tables: TransportStateTables::new(),
transport_tables,
pending_repo_tasks: VecDeque::new(),
pending_transport_tasks: VecDeque::new(),
stats: ParallelRunStats::default(),
dead_repo_blacklist,
}
}
@@ -193,9 +220,92 @@ impl GlobalRunCoordinator {
{
self.stats.repo_tasks_failed += 1;
}
self.update_dead_repo_blacklist(&result, finished_at);
Ok(completion)
}
/// Update dead-repo blacklist counters (#141) from a real worker result.
/// Runs on the single pump thread; synthesized terminal envelopes never
/// pass through here, so no double counting is possible.
fn update_dead_repo_blacklist(
&mut self,
result: &RepoTransportResultEnvelope,
finished_at: time::OffsetDateTime,
) {
let Some(blacklist_config) = self.config.dead_repo_blacklist.clone() else {
return;
};
let Some(blacklist) = self.dead_repo_blacklist.as_mut() else {
return;
};
let now_unix = finished_at.unix_timestamp().max(0) as u64;
let key: Option<(RepoTransportMode, String)> = match (&result.dedup_key, &result.result) {
(RepoDedupKey::RrdpNotify { notification_uri }, RepoTransportResultKind::Success { .. }) => {
Some((RepoTransportMode::Rrdp, notification_uri.clone()))
}
(RepoDedupKey::RsyncScope { .. }, RepoTransportResultKind::Success { .. }) => {
Some((RepoTransportMode::Rsync, result.repo_identity.rsync_base_uri.clone()))
}
(
RepoDedupKey::RrdpNotify { notification_uri },
RepoTransportResultKind::Failed {
error_class: RepoTransportErrorClass::TransportFetch,
..
},
) => Some((RepoTransportMode::Rrdp, notification_uri.clone())),
(
RepoDedupKey::RsyncScope { .. },
RepoTransportResultKind::Failed {
error_class: RepoTransportErrorClass::TransportFetch,
..
},
) => Some((RepoTransportMode::Rsync, result.repo_identity.rsync_base_uri.clone())),
_ => None,
};
let Some((transport, uri)) = key else {
return;
};
match &result.result {
RepoTransportResultKind::Success { .. } => {
if let Some(outcome) = blacklist.record_transport_success(transport, &uri) {
crate::progress_log::emit(
"dead_repo_blacklist_remove",
serde_json::json!({
"transport": transport.as_str(),
"uri": uri,
"reason": match outcome {
DeadRepoSuccessOutcome::CounterReset => "counter_reset",
DeadRepoSuccessOutcome::BlacklistRemoved => "fetch_success",
},
}),
);
}
}
RepoTransportResultKind::Failed { .. } => {
if let DeadRepoFailureOutcome::Admitted = blacklist.record_transport_failure(
transport,
&uri,
now_unix,
blacklist_config.fail_threshold,
blacklist_config.capacity,
) {
crate::progress_log::emit(
"dead_repo_blacklist_admit",
serde_json::json!({
"transport": transport.as_str(),
"uri": uri,
"fail_threshold": blacklist_config.fail_threshold,
}),
);
}
}
}
}
pub fn dead_repo_blacklist(&self) -> Option<&DeadRepoBlacklist> {
self.dead_repo_blacklist.as_ref()
}
pub fn runtime_record(
&self,
identity: &RepoIdentity,
@@ -225,7 +335,7 @@ impl GlobalRunCoordinator {
self.pending_repo_tasks.clear();
self.pending_transport_tasks.clear();
self.stats = ParallelRunStats::default();
if let Ok(mut index) = self.current_repo_index.lock() {
if let Ok(mut index) = self.current_repo_index.write() {
index.clear();
}
}
@@ -322,7 +432,7 @@ mod tests {
assert_eq!(coordinator.pending_transport_tasks.len(), 1);
{
let mut index = coordinator.current_repo_index.lock().expect("index lock");
let mut index = coordinator.current_repo_index.write().expect("index write lock");
index
.apply_repository_view_entries(&[RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
@@ -343,8 +453,8 @@ mod tests {
assert_eq!(
coordinator
.current_repo_index
.lock()
.expect("index lock")
.read()
.expect("index read lock")
.active_uri_count(),
0
);
@@ -465,4 +575,288 @@ mod tests {
assert_eq!(coordinator.stats.repo_tasks_total, 1);
assert_eq!(coordinator.stats.repo_tasks_reused, 1);
}
// ---- Dead-repo blacklist (#141) integration tests ----
use crate::parallel::dead_repo_blacklist::{DeadRepoBlacklist, DeadRepoBlacklistConfig};
use crate::parallel::types::{
RepoDedupKey, RepoTransportErrorClass, RepoTransportMode, RepoTransportResultEnvelope,
RepoTransportResultKind,
};
const DEAD_NOTIFY: &str = "https://dead.example/notification.xml";
const DEAD_BASE: &str = "rsync://dead.example/repo/";
fn blacklist_config(fail_threshold: u32) -> DeadRepoBlacklistConfig {
DeadRepoBlacklistConfig {
path: std::env::temp_dir().join(format!(
"dead_repo_blacklist_coordinator_test_{}.json",
std::process::id()
)),
fail_threshold,
capacity: 256,
}
}
fn coordinator_with_blacklist(
fail_threshold: u32,
seed: DeadRepoBlacklist,
) -> GlobalRunCoordinator {
let mut config = ParallelPhase1Config::default();
config.dead_repo_blacklist = Some(blacklist_config(fail_threshold));
let mut coordinator = GlobalRunCoordinator::new(
config,
vec![TalInputSpec::from_url("https://example.test/arin.tal")],
);
coordinator.dead_repo_blacklist = Some(seed.clone());
coordinator.transport_tables.set_dead_repo_blacklist(seed);
coordinator
}
fn dead_identity() -> RepoIdentity {
RepoIdentity::new(Some(DEAD_NOTIFY.to_string()), DEAD_BASE)
}
fn dead_requester() -> RepoRequester {
requester("arin", "arin", "rsync://dead.example/repo/root.mft")
}
fn transport_envelope(
mode: RepoTransportMode,
error_class: RepoTransportErrorClass,
) -> RepoTransportResultEnvelope {
let (dedup_key, detail) = match mode {
RepoTransportMode::Rrdp => (
RepoDedupKey::RrdpNotify {
notification_uri: DEAD_NOTIFY.to_string(),
},
"http request failed: connect timeout".to_string(),
),
RepoTransportMode::Rsync => (
RepoDedupKey::RsyncScope {
rsync_scope_uri: DEAD_BASE.to_string(),
},
"rsync error: timeout waiting for daemon connection".to_string(),
),
};
RepoTransportResultEnvelope {
dedup_key,
rsync_failure_scope_uri: None,
repo_identity: dead_identity(),
mode,
tal_id: "arin".to_string(),
rir_id: "arin".to_string(),
timing_ms: 1,
result: RepoTransportResultKind::Failed {
detail,
warnings: Vec::new(),
error_class,
},
}
}
fn register_dead(
coordinator: &mut GlobalRunCoordinator,
) -> crate::parallel::repo_scheduler::TransportRequestAction {
coordinator.register_transport_request(
dead_identity(),
dead_requester(),
time::OffsetDateTime::UNIX_EPOCH,
0,
DEAD_BASE.to_string(),
None,
SyncPreference::RrdpThenRsync,
false,
)
}
#[test]
fn blacklist_admission_after_threshold_transport_failures() {
let mut coordinator = coordinator_with_blacklist(2, DeadRepoBlacklist::new());
for _ in 0..2 {
let action = register_dead(&mut coordinator);
assert!(matches!(
action,
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(_)
));
coordinator
.complete_transport_result(
transport_envelope(
RepoTransportMode::Rrdp,
RepoTransportErrorClass::TransportFetch,
),
time::OffsetDateTime::UNIX_EPOCH,
)
.expect("complete");
// Simulate the next run: run-local scheduler state resets, the
// persistent blacklist working copy carries over.
coordinator.reset_run_state();
}
let blacklist = coordinator.dead_repo_blacklist().expect("blacklist");
assert!(blacklist.is_blacklisted(RepoTransportMode::Rrdp, DEAD_NOTIFY));
// Admission mid-run does not affect this run's frozen snapshot: the
// scheduler still enqueues rrdp for a fresh identity registration.
coordinator.reset_run_state();
let action = register_dead(&mut coordinator);
assert!(matches!(
action,
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(_)
));
}
#[test]
fn blacklist_ignores_protocol_and_unknown_failures() {
let mut coordinator = coordinator_with_blacklist(1, DeadRepoBlacklist::new());
for error_class in [
RepoTransportErrorClass::Protocol,
RepoTransportErrorClass::Unknown,
RepoTransportErrorClass::Storage,
] {
let action = register_dead(&mut coordinator);
assert!(matches!(
action,
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(_)
));
coordinator
.complete_transport_result(
transport_envelope(RepoTransportMode::Rrdp, error_class),
time::OffsetDateTime::UNIX_EPOCH,
)
.expect("complete");
coordinator.reset_run_state();
}
assert!(
coordinator
.dead_repo_blacklist()
.expect("blacklist")
.is_empty()
);
}
#[test]
fn blacklist_skip_rrdp_routes_straight_to_rsync() {
let mut seed = DeadRepoBlacklist::new();
seed.record_transport_failure(RepoTransportMode::Rrdp, DEAD_NOTIFY, 100, 1, 256);
let mut coordinator = coordinator_with_blacklist(3, seed);
let action = register_dead(&mut coordinator);
let task = match action {
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(task) => task,
other => panic!("expected rsync enqueue, got {other:?}"),
};
assert_eq!(task.mode, RepoTransportMode::Rsync);
// register_dead passes retry_short_timeout=false (mirroring a prefetch
// snapshot that recorded no real rrdp failure for skipped runs); the
// skip path must still keep the short retry profile (#141).
assert!(task.retry_short_timeout);
}
#[test]
fn blacklist_dual_dead_terminates_immediately() {
let mut seed = DeadRepoBlacklist::new();
seed.record_transport_failure(RepoTransportMode::Rrdp, DEAD_NOTIFY, 100, 1, 256);
seed.record_transport_failure(RepoTransportMode::Rsync, DEAD_BASE, 100, 1, 256);
let mut coordinator = coordinator_with_blacklist(3, seed);
let action = register_dead(&mut coordinator);
let envelope = match action {
crate::parallel::repo_scheduler::TransportRequestAction::ReusedTerminalFailure(
envelope,
) => envelope,
other => panic!("expected terminal failure, got {other:?}"),
};
match &envelope.result {
RepoTransportResultKind::Failed {
detail, warnings, ..
} => {
assert!(detail.contains("dead repo blacklist"));
assert!(warnings
.iter()
.any(|warning| warning.message.contains("dead_repo_blacklist_skip_all")));
}
other => panic!("expected failed result, got {other:?}"),
}
let record = coordinator
.runtime_record(&dead_identity())
.expect("record");
assert_eq!(
record.state,
crate::parallel::types::RepoRuntimeState::FailedTerminal
);
assert_eq!(coordinator.stats.repo_tasks_total, 0);
assert_eq!(coordinator.stats.repo_tasks_reused, 1);
}
#[test]
fn blacklist_rsync_dead_terminates_after_rrdp_failure() {
// Only rsync blacklisted: rrdp is attempted; when it fails, the rsync
// fallback is short-circuited into an instant terminal failure.
let mut seed = DeadRepoBlacklist::new();
seed.record_transport_failure(RepoTransportMode::Rsync, DEAD_BASE, 100, 1, 256);
let mut coordinator = coordinator_with_blacklist(3, seed);
let action = register_dead(&mut coordinator);
assert!(matches!(
action,
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(_)
));
let completion = coordinator
.complete_transport_result(
transport_envelope(RepoTransportMode::Rrdp, RepoTransportErrorClass::TransportFetch),
time::OffsetDateTime::UNIX_EPOCH,
)
.expect("complete");
assert!(completion.follow_up_tasks.is_empty());
let record = coordinator
.runtime_record(&dead_identity())
.expect("record");
assert_eq!(
record.state,
crate::parallel::types::RepoRuntimeState::FailedTerminal
);
let terminal = record.terminal_failure.as_ref().expect("terminal failure");
match &terminal.result {
RepoTransportResultKind::Failed { detail, .. } => {
assert!(detail.contains("dead repo blacklist"));
}
other => panic!("expected failed result, got {other:?}"),
}
}
#[test]
fn blacklist_success_resets_failure_counter() {
let mut coordinator = coordinator_with_blacklist(3, DeadRepoBlacklist::new());
// One transport failure starts counting.
let action = register_dead(&mut coordinator);
assert!(matches!(
action,
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(_)
));
coordinator
.complete_transport_result(
transport_envelope(RepoTransportMode::Rrdp, RepoTransportErrorClass::TransportFetch),
time::OffsetDateTime::UNIX_EPOCH,
)
.expect("complete");
assert_eq!(coordinator.dead_repo_blacklist().expect("blacklist").len(), 1);
// A later success clears the entry.
coordinator.reset_run_state();
let action = register_dead(&mut coordinator);
assert!(matches!(
action,
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(_)
));
let mut success = transport_envelope(RepoTransportMode::Rrdp, RepoTransportErrorClass::Unknown);
success.result = RepoTransportResultKind::Success {
source: "rrdp".to_string(),
warnings: Vec::new(),
};
coordinator
.complete_transport_result(success, time::OffsetDateTime::UNIX_EPOCH)
.expect("complete");
assert!(
coordinator
.dead_repo_blacklist()
.expect("blacklist")
.is_empty()
);
}
}

View File

@@ -484,6 +484,7 @@ mod tests {
result: crate::parallel::types::RepoTransportResultKind::Failed {
detail: "timeout".to_string(),
warnings: Vec::new(),
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
},
});
recorder.record_result(&crate::parallel::types::RepoTransportResultEnvelope {
@@ -499,6 +500,7 @@ mod tests {
result: crate::parallel::types::RepoTransportResultKind::Failed {
detail: "timeout".to_string(),
warnings: Vec::new(),
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
},
});

View File

@@ -1,5 +1,7 @@
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::policy::SyncPreference;
use crate::report::Warning;
@@ -96,7 +98,8 @@ pub enum RepoDedupKey {
RsyncScope { rsync_scope_uri: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RepoTransportMode {
Rrdp,
Rsync,
@@ -116,6 +119,26 @@ pub struct RepoTransportTask {
pub requesters: Vec<RepoRequester>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RepoTransportErrorClass {
/// Transport-layer fetch failure (connect timeout/refused/TLS/DNS/broken
/// connection). Only this class counts toward the dead-repo blacklist.
TransportFetch,
/// Protocol-level failure (HTTP status, XML/parse, content mismatch).
Protocol,
/// Local storage failure.
Storage,
/// Unclassified (synthesized or legacy results); never counted.
Unknown,
}
impl Default for RepoTransportErrorClass {
fn default() -> Self {
RepoTransportErrorClass::Unknown
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RepoTransportResultKind {
Success {
@@ -125,6 +148,7 @@ pub enum RepoTransportResultKind {
Failed {
detail: String,
warnings: Vec<Warning>,
error_class: RepoTransportErrorClass,
},
}
@@ -479,6 +503,7 @@ mod tests {
result: RepoTransportResultKind::Failed {
detail: "timeout".to_string(),
warnings: vec![Warning::new("timeout")],
error_class: super::RepoTransportErrorClass::Unknown,
},
};
assert!(matches!(ok.result, RepoTransportResultKind::Success { .. }));

View File

@@ -1,4 +1,7 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use crate::ta_constraints::TaConstraintsByTal;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -127,6 +130,15 @@ pub struct Policy {
pub signed_object_failure_policy: SignedObjectFailurePolicy,
pub resource_validation_mode: ResourceValidationMode,
pub strict: StrictPolicy,
/// Ephemeral verification-only override. This is deliberately excluded
/// from policy files, contracts, and policy fingerprints: it only selects
/// the already contract-bound VCIR fallback branch while replaying a run.
#[serde(skip)]
pub verification_forced_vcir_reuse_manifest_uris: BTreeSet<String>,
/// Locally configured, per-TAL EE certificate resource constraints. They
/// are intentionally CLI/runtime-only rather than policy-file input.
#[serde(skip)]
pub ta_constraints: TaConstraintsByTal,
}
impl Default for Policy {
@@ -137,6 +149,8 @@ impl Default for Policy {
signed_object_failure_policy: SignedObjectFailurePolicy::default(),
resource_validation_mode: ResourceValidationMode::default(),
strict: StrictPolicy::default(),
verification_forced_vcir_reuse_manifest_uris: BTreeSet::new(),
ta_constraints: TaConstraintsByTal::default(),
}
}
}

View File

@@ -176,9 +176,14 @@ pub fn list_report_objects_filtered(
start_pp_index,
start_object_index,
);
ObjectScanSeed { state: &mut state }
.deserialize(&mut deserializer)
.map_err(QueryDbError::from)?;
let scan = ObjectScanSeed { state: &mut state }.deserialize(&mut deserializer);
match scan {
Ok(()) => {}
// A full page plus its next cursor is all the caller needs; the
// visitor aborts the remaining scan with a sentinel error.
Err(err) if is_object_scan_complete(&err) => {}
Err(err) => return Err(QueryDbError::from(err)),
}
Ok(QueryPage {
data: state.data,
next_cursor: state.next_cursor,
@@ -215,6 +220,16 @@ pub fn object_cursor(pp_index: u64, object_index: u64) -> String {
format!("r1:{pp_index}:{object_index}")
}
/// Sentinel message used to abort the streaming object scan early once a list
/// page (plus its next cursor) has been collected. The deserializer is left
/// mid-document and dropped, which is fine because the caller only needs the
/// state accumulated so far.
const OBJECT_SCAN_COMPLETE_MSG: &str = "rpki report object scan complete: page collected";
fn is_object_scan_complete(err: &serde_json::Error) -> bool {
err.is_data() && err.to_string().starts_with(OBJECT_SCAN_COMPLETE_MSG)
}
fn parse_object_cursor(cursor: Option<&str>) -> QueryDbResult<(u64, u64)> {
let Some(cursor) = cursor else {
return Ok((0, 0));
@@ -598,15 +613,15 @@ impl<'de> Deserialize<'de> for ObjectStatsSeq {
stats.count += 1;
if object.result == "error" {
stats.rejected += 1;
if let Some(detail) = object.detail.filter(|value| !value.is_empty()) {
bump_limited_count(&mut stats.reason_counts, &detail, 512);
}
}
*stats
.object_type_counts
.entry(object.object_type)
.or_default() += 1;
*stats.result_counts.entry(object.result).or_default() += 1;
if let Some(detail) = object.detail.filter(|value| !value.is_empty()) {
bump_limited_count(&mut stats.reason_counts, &detail, 512);
}
}
Ok(ObjectStatsSeq(stats))
}
@@ -732,6 +747,15 @@ impl<'a> ObjectScanState<'a> {
scanned_objects: 0,
}
}
/// True once a list page is full and its next cursor is fixed; everything
/// after this point cannot alter the page (cursor semantics: the cursor
/// already points at the first item of the next page).
fn list_page_complete(&self) -> bool {
matches!(self.mode, ObjectScanMode::List)
&& self.data.len() >= self.limit
&& self.next_cursor.is_some()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -807,7 +831,13 @@ impl<'de> Visitor<'de> for ObjectScanPpsVisitor<'_, '_> {
A: SeqAccess<'de>,
{
let mut pp_index = 0u64;
while let Some(raw_pp) = seq.next_element::<Box<RawValue>>()? {
loop {
if self.state.list_page_complete() {
return Err(de::Error::custom(OBJECT_SCAN_COMPLETE_MSG));
}
let Some(raw_pp) = seq.next_element::<Box<RawValue>>()? else {
break;
};
process_raw_publication_point(self.state, pp_index, raw_pp.get())
.map_err(de::Error::custom)?;
pp_index += 1;
@@ -822,6 +852,20 @@ fn process_raw_publication_point(
raw_pp: &str,
) -> QueryDbResult<()> {
state.scanned_publication_points += 1;
if matches!(state.mode, ObjectScanMode::List) && pp_index < state.start_pp_index {
// Every object in this PP sits before the cursor, so none of them can
// be part of the page: skip materializing the subtree entirely.
return Ok(());
}
if state.scope != ObjectScope::All {
// Scope is decidable from PP-level fields alone; probe them before
// materializing the (potentially huge) objects subtree.
let probe: PublicationPointScopeProbe = serde_json::from_str(raw_pp)?;
let ctx = PpObjectContext::from_pp(state.run_id, &probe.into_summary());
if !state.scope.matches(&ctx) {
return Ok(());
}
}
let pp: PublicationPointForObjects = serde_json::from_str(raw_pp)?;
let ctx = PpObjectContext::from_pp(state.run_id, &pp.summary);
if !state.scope.matches(&ctx) {
@@ -909,6 +953,30 @@ impl<'de> Visitor<'de> for PublicationPointForObjectsVisitor {
}
}
/// Lightweight probe of the PP-level fields needed to decide `ObjectScope`
/// before the (potentially huge) objects subtree is materialized.
#[derive(Clone, Debug, Default, Deserialize)]
struct PublicationPointScopeProbe {
node_id: Option<u64>,
rsync_base_uri: Option<String>,
manifest_rsync_uri: Option<String>,
publication_point_rsync_uri: Option<String>,
rrdp_notification_uri: Option<String>,
}
impl PublicationPointScopeProbe {
fn into_summary(self) -> PublicationPointSummary {
PublicationPointSummary {
node_id: self.node_id,
rsync_base_uri: self.rsync_base_uri,
manifest_rsync_uri: self.manifest_rsync_uri,
publication_point_rsync_uri: self.publication_point_rsync_uri,
rrdp_notification_uri: self.rrdp_notification_uri,
..PublicationPointSummary::default()
}
}
}
#[derive(Clone, Debug, Default)]
struct ReportObject {
uri: String,
@@ -1307,7 +1375,7 @@ mod tests {
"repo_terminal_state":"fresh",
"warnings":[],
"objects":[
{"rsync_uri":"rsync://repo.example/rpki/m.mft","sha256_hex":"11","kind":"manifest","result":"ok"},
{"rsync_uri":"rsync://repo.example/rpki/m.mft","sha256_hex":"11","kind":"manifest","result":"ok","detail":"restored child CA instance from publication-point cache"},
{"rsync_uri":"rsync://repo.example/rpki/a.roa","sha256_hex":"22","kind":"roa","result":"error","detail":"bad roa"}
]
}],
@@ -1333,6 +1401,13 @@ mod tests {
assert_eq!(summary.aspas_count, 1);
assert_eq!(summary.stats.object_type_counts["roa"], 1);
assert_eq!(summary.stats.reason_counts["bad roa"], 1);
// #133: informational detail on non-error objects must not be counted
// as reject reasons.
assert_eq!(summary.stats.reason_counts.len(), 1);
assert!(!summary
.stats
.reason_counts
.contains_key("restored child CA instance from publication-point cache"));
let page =
list_report_objects(&report_path, "run_0001", ObjectScope::All, 1, None).expect("page");
@@ -1360,4 +1435,195 @@ mod tests {
assert_eq!(found.object.result, "error");
assert_eq!(found.resolution.mode, "report_scan");
}
/// Three PPs with two objects each; used to anchor paging and scope
/// behavior of the early-stop object scan.
fn write_multi_pp_report(report_path: &std::path::Path) {
fs::write(
report_path,
r#"{
"meta":{"validation_time_rfc3339_utc":"2026-06-16T00:00:00Z"},
"tree":{"warnings":[]},
"publication_points":[
{
"node_id":1,
"rsync_base_uri":"rsync://repo-a.example/rpki/",
"manifest_rsync_uri":"rsync://repo-a.example/rpki/m.mft",
"publication_point_rsync_uri":"rsync://repo-a.example/rpki/",
"rrdp_notification_uri":"https://repo-a.example/rrdp/notification.xml",
"objects":[
{"rsync_uri":"rsync://repo-a.example/rpki/m.mft","sha256_hex":"a1","kind":"manifest","result":"ok"},
{"rsync_uri":"rsync://repo-a.example/rpki/a.roa","sha256_hex":"a2","kind":"roa","result":"ok"}
]
},
{
"node_id":2,
"rsync_base_uri":"rsync://repo-b.example/rpki/",
"manifest_rsync_uri":"rsync://repo-b.example/rpki/m.mft",
"publication_point_rsync_uri":"rsync://repo-b.example/rpki/",
"objects":[
{"rsync_uri":"rsync://repo-b.example/rpki/m.mft","sha256_hex":"b1","kind":"manifest","result":"ok"},
{"rsync_uri":"rsync://repo-b.example/rpki/b.roa","sha256_hex":"b2","kind":"roa","result":"error","detail":"bad roa"}
]
},
{
"node_id":3,
"rsync_base_uri":"rsync://repo-c.example/rpki/",
"manifest_rsync_uri":"rsync://repo-c.example/rpki/m.mft",
"publication_point_rsync_uri":"rsync://repo-c.example/rpki/",
"rrdp_notification_uri":"https://repo-c.example/rrdp/notification.xml",
"objects":[
{"rsync_uri":"rsync://repo-c.example/rpki/m.mft","sha256_hex":"c1","kind":"manifest","result":"ok"},
{"rsync_uri":"rsync://repo-c.example/rpki/c.roa","sha256_hex":"c2","kind":"roa","result":"ok"}
]
}
]
}"#,
)
.expect("report");
}
#[test]
fn object_listing_pages_across_publication_points_with_stable_cursors() {
let temp = tempfile::tempdir().expect("tempdir");
let report_path = temp.path().join("report.json");
write_multi_pp_report(&report_path);
// Page 1: fills inside PP 0; the cursor must resume at PP 0 object 2.
let page1 = list_report_objects(&report_path, "run_0001", ObjectScope::All, 2, None)
.expect("page1");
assert_eq!(page1.data.len(), 2);
assert_eq!(page1.data[0].uri, "rsync://repo-a.example/rpki/m.mft");
assert_eq!(page1.data[1].uri, "rsync://repo-a.example/rpki/a.roa");
assert_eq!(page1.next_cursor.as_deref(), Some("r1:0:2"));
// Page 2: PP 0 is exhausted; the two PP 1 objects fill the page.
let page2 = list_report_objects(
&report_path,
"run_0001",
ObjectScope::All,
2,
page1.next_cursor.as_deref(),
)
.expect("page2");
assert_eq!(page2.data.len(), 2);
assert_eq!(page2.data[0].uri, "rsync://repo-b.example/rpki/m.mft");
assert_eq!(page2.data[1].uri, "rsync://repo-b.example/rpki/b.roa");
assert_eq!(page2.next_cursor.as_deref(), Some("r1:1:2"));
// Page 3: last PP, page fills at the final object.
let page3 = list_report_objects(
&report_path,
"run_0001",
ObjectScope::All,
2,
page2.next_cursor.as_deref(),
)
.expect("page3");
assert_eq!(page3.data.len(), 2);
assert_eq!(page3.next_cursor.as_deref(), Some("r1:2:2"));
// Page 4: nothing left.
let page4 = list_report_objects(
&report_path,
"run_0001",
ObjectScope::All,
2,
page3.next_cursor.as_deref(),
)
.expect("page4");
assert!(page4.data.is_empty());
assert_eq!(page4.next_cursor, None);
// A page larger than the remaining tail must not produce a cursor.
let tail = list_report_objects(&report_path, "run_0001", ObjectScope::All, 10, None)
.expect("tail");
assert_eq!(tail.data.len(), 6);
assert_eq!(tail.next_cursor, None);
}
#[test]
fn object_listing_scope_skips_non_matching_publication_points() {
let temp = tempfile::tempdir().expect("tempdir");
let report_path = temp.path().join("report.json");
write_multi_pp_report(&report_path);
// PP scope: only PP 1 (node_id 2) objects, cursor resumes within PP 1.
let page1 = list_report_objects(
&report_path,
"run_0001",
ObjectScope::PublicationPoint("node_2".to_string()),
1,
None,
)
.expect("page1");
assert_eq!(page1.data.len(), 1);
assert_eq!(page1.data[0].uri, "rsync://repo-b.example/rpki/m.mft");
assert_eq!(page1.next_cursor.as_deref(), Some("r1:1:1"));
let page2 = list_report_objects(
&report_path,
"run_0001",
ObjectScope::PublicationPoint("node_2".to_string()),
10,
page1.next_cursor.as_deref(),
)
.expect("page2");
assert_eq!(page2.data.len(), 1);
assert_eq!(page2.data[0].uri, "rsync://repo-b.example/rpki/b.roa");
assert_eq!(page2.next_cursor, None);
// Repo scope derived from the PP without rrdp uri (repo-b).
let probe_pp = list_report_objects(
&report_path,
"run_0001",
ObjectScope::PublicationPoint("node_2".to_string()),
10,
None,
)
.expect("probe");
let repo_id = probe_pp.data[0].repo_id.clone();
let repo_page = list_report_objects(
&report_path,
"run_0001",
ObjectScope::Repo(repo_id),
10,
None,
)
.expect("repo page");
assert_eq!(repo_page.data.len(), 2);
assert!(
repo_page
.data
.iter()
.all(|object| object.uri.contains("repo-b.example"))
);
// Unknown scope id: empty page, no cursor, no error.
let empty = list_report_objects(
&report_path,
"run_0001",
ObjectScope::PublicationPoint("node_999".to_string()),
10,
None,
)
.expect("empty");
assert!(empty.data.is_empty());
assert_eq!(empty.next_cursor, None);
// Filtered listing keeps working across skipped PPs.
let mut filter = ObjectFilter::default();
filter.query = Some("c.roa".to_string());
let filtered = list_report_objects_filtered(
&report_path,
"run_0001",
ObjectScope::All,
&filter,
10,
None,
)
.expect("filtered");
assert_eq!(filtered.data.len(), 1);
assert_eq!(filtered.data[0].uri, "rsync://repo-c.example/rpki/c.roa");
assert_eq!(filtered.next_cursor, None);
}
}

View File

@@ -60,6 +60,8 @@ pub enum QueryDbError {
MissingColumnFamily(&'static str),
#[error("invalid run artifact: {0}")]
InvalidArtifact(String),
#[error("report data for run {0} is no longer available (retention cleanup)")]
RunDataGone(String),
#[error("CIR decode error: {0}")]
CirDecode(String),
}
@@ -160,6 +162,24 @@ pub struct RepositoryRecord {
pub terminal_states: BTreeMap<String, u64>,
}
/// Sort order for `QueryDb::list_repos_sorted`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RepoSortOrder {
ObjectsDesc,
ObjectsAsc,
}
/// Parse a `repos:<offset>` cursor used by sorted repository listings.
fn parse_repos_offset_cursor(cursor: Option<&str>) -> QueryDbResult<usize> {
match cursor {
None => Ok(0),
Some(raw) => raw
.strip_prefix("repos:")
.and_then(|value| value.parse::<usize>().ok())
.ok_or_else(|| QueryDbError::InvalidArtifact(format!("invalid repos cursor: {raw}"))),
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublicationPointRecord {
@@ -289,6 +309,7 @@ impl QueryDb {
opts.create_if_missing(true);
opts.create_missing_column_families(true);
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
opts.set_max_open_files(512);
let descriptors = QUERY_DB_COLUMN_FAMILIES
.iter()
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
@@ -310,6 +331,7 @@ impl QueryDb {
opts.create_if_missing(false);
opts.create_missing_column_families(false);
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
opts.set_max_open_files(512);
let descriptors = QUERY_DB_COLUMN_FAMILIES
.iter()
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
@@ -351,8 +373,10 @@ impl QueryDb {
pub fn resolve_run_id(&self, run_id: &str) -> QueryDbResult<Option<String>> {
if run_id == "latest" || run_id == "latest_run" {
self.latest_ready_run()
} else {
} else if self.get_run(run_id)?.is_some() {
Ok(Some(run_id.to_string()))
} else {
Ok(None)
}
}
@@ -361,7 +385,38 @@ impl QueryDb {
limit: usize,
cursor: Option<&str>,
) -> QueryDbResult<QueryPage<RunRecord>> {
self.list_json_by_prefix(CF_RUNS, "run/", limit, cursor)
// Runs are listed newest first: zero-padded run ids sort by age, so a
// reverse scan over the `run/` prefix yields the latest run on page 1.
// Cursor semantics mirror `list_json_by_prefix`: the cursor is the
// first key of the next page (inclusive).
let limit = limit.clamp(1, 1000);
let cf = self.cf(CF_RUNS)?;
let prefix = "run/";
let start = match cursor {
Some(cursor) => cursor.as_bytes().to_vec(),
None => prefix_range_end(prefix.as_bytes())
.unwrap_or_else(|| prefix.as_bytes().to_vec()),
};
let mut data = Vec::new();
let mut next_cursor = None;
let mode = IteratorMode::From(&start, rocksdb::Direction::Reverse);
for item in self.db.iterator_cf(cf, mode) {
let (key, value) = item?;
let key_str = String::from_utf8_lossy(&key);
if !key_str.starts_with(prefix) {
break;
}
if data.len() >= limit {
next_cursor = Some(key_str.to_string());
break;
}
data.push(serde_json::from_slice(&value)?);
}
Ok(QueryPage {
data,
next_cursor,
limit,
})
}
pub fn list_repos(
@@ -373,6 +428,58 @@ impl QueryDb {
self.list_json_by_prefix(CF_REPOS, &format!("repo/{run_id}/"), limit, cursor)
}
/// List repositories sorted by object count. The sort happens at query
/// time over the run's repo set (hundreds of records at most), so no
/// extra index is needed. Pagination uses a `repos:<offset>` cursor,
/// independent from the index-order key cursor used by `list_repos`.
pub fn list_repos_sorted(
&self,
run_id: &str,
limit: usize,
cursor: Option<&str>,
order: RepoSortOrder,
) -> QueryDbResult<QueryPage<RepositoryRecord>> {
let cf = self.cf(CF_REPOS)?;
let prefix = format!("repo/{run_id}/");
let mut records = Vec::new();
for item in self.db.iterator_cf(
cf,
IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward),
) {
let (key, value) = item?;
if !key.starts_with(prefix.as_bytes()) {
break;
}
records.push(serde_json::from_slice::<RepositoryRecord>(&value)?);
}
records.sort_by(|left, right| {
let by_objects = match order {
RepoSortOrder::ObjectsDesc => right.objects.cmp(&left.objects),
RepoSortOrder::ObjectsAsc => left.objects.cmp(&right.objects),
};
// Stable, deterministic order for equal object counts.
by_objects.then_with(|| left.repo_id.cmp(&right.repo_id))
});
let offset = parse_repos_offset_cursor(cursor)?;
let bounded_limit = limit.clamp(1, 1000);
let total = records.len();
let data = records
.into_iter()
.skip(offset)
.take(bounded_limit)
.collect::<Vec<_>>();
let next_cursor = if offset + bounded_limit < total {
Some(format!("repos:{}", offset + bounded_limit))
} else {
None
};
Ok(QueryPage {
data,
next_cursor,
limit: bounded_limit,
})
}
pub fn lookup_vrps(
&self,
run_id: &str,
@@ -691,9 +798,17 @@ impl QueryDb {
}
fn report_path_for_run(&self, run_id: &str) -> QueryDbResult<Option<PathBuf>> {
Ok(self
.get_run(run_id)?
.map(|run| Path::new(&run.run_dir).join("report.json")))
let Some(run) = self.get_run(run_id)? else {
return Ok(None);
};
let path = Path::new(&run.run_dir).join("report.json");
if !path.exists() {
// The run is still indexed but its report.json was removed by
// retention cleanup; callers must be able to tell this apart
// from a genuine io failure (mapped to 404 upstream).
return Err(QueryDbError::RunDataGone(run_id.to_string()));
}
Ok(Some(path))
}
pub fn get_object_projection(
@@ -1094,6 +1209,7 @@ impl QueryDb {
fn cf_options() -> Options {
let mut opts = Options::default();
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
opts.set_max_open_files(512);
opts
}
@@ -1951,15 +2067,17 @@ mod tests {
db.resolve_run_id("run_0001").unwrap().as_deref(),
Some("run_0001")
);
assert_eq!(db.resolve_run_id("run_9999").unwrap(), None);
let first_page = db.list_runs(1, None).expect("runs");
assert_eq!(first_page.data.len(), 1);
assert_eq!(first_page.data[0].run_id, "run_0001");
assert_eq!(first_page.data[0].run_id, "run_0002");
let second_page = db
.list_runs(1, first_page.next_cursor.as_deref())
.expect("second runs");
assert_eq!(second_page.data.len(), 1);
assert_eq!(second_page.data[0].run_id, "run_0002");
assert_eq!(second_page.data[0].run_id, "run_0001");
assert!(second_page.next_cursor.is_none());
let repos = db.list_repos("run_0002", 10, None).expect("repos");
assert_eq!(repos.data.len(), 1);
@@ -2105,6 +2223,43 @@ mod tests {
);
}
#[test]
fn missing_report_file_is_reported_as_run_data_gone() {
let temp = tempfile::tempdir().expect("tempdir");
let run1 = temp.path().join("runs/run_0001");
fs::create_dir_all(&run1).expect("run1");
write_sample_run(&run1, "run_0001", 1);
let query_db_path = temp.path().join("query-db");
index_artifacts(&ArtifactIndexerConfig {
query_db_path: query_db_path.clone(),
run_root: Some(temp.path().to_path_buf()),
run_dir: None,
repo_bytes_db_path: None,
projection_entry_limit: 50,
min_run_seq: None,
retain_indexed_runs: None,
})
.expect("index");
let db = QueryDb::open(&query_db_path).expect("open query db");
assert_eq!(db.list_objects("run_0001", 10, None).unwrap().data.len(), 2);
// Simulate retention cleanup removing the run directory while the
// index record survives: object listing/lookup must fail with a
// distinguishable error instead of a bare io error.
fs::remove_dir_all(&run1).expect("remove run dir");
let err = db.list_objects("run_0001", 10, None).unwrap_err();
assert!(
matches!(err, QueryDbError::RunDataGone(ref run_id) if run_id == "run_0001"),
"unexpected error: {err}"
);
let err = db.get_object_by_sha256("run_0001", "22").unwrap_err();
assert!(
matches!(err, QueryDbError::RunDataGone(_)),
"unexpected error: {err}"
);
}
#[test]
fn repeated_indexing_does_not_move_latest_backwards() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -2201,6 +2356,163 @@ mod tests {
.expect("report");
}
#[test]
fn query_db_list_repos_sorted_orders_by_object_count() {
let temp = tempfile::tempdir().expect("tempdir");
let run1 = temp.path().join("runs/run_0001");
fs::create_dir_all(&run1).expect("run1");
// Index order (a, b, c) deliberately differs from object-count order.
write_sample_run_with_repos(
&run1,
"run_0001",
1,
&[
("repo-a.example", 2),
("repo-b.example", 5),
("repo-c.example", 1),
],
);
let query_db_path = temp.path().join("query-db");
index_artifacts(&ArtifactIndexerConfig {
query_db_path: query_db_path.clone(),
run_root: Some(temp.path().to_path_buf()),
run_dir: None,
repo_bytes_db_path: None,
projection_entry_limit: 50,
min_run_seq: None,
retain_indexed_runs: None,
})
.expect("index");
let db = QueryDb::open(&query_db_path).expect("open query db");
let hosts = |page: QueryPage<RepositoryRecord>| {
page.data
.iter()
.map(|repo| (repo.host.clone(), repo.objects))
.collect::<Vec<_>>()
};
let desc = db
.list_repos_sorted("run_0001", 10, None, RepoSortOrder::ObjectsDesc)
.expect("desc");
assert_eq!(
hosts(desc),
vec![
("repo-b.example".to_string(), 5),
("repo-a.example".to_string(), 2),
("repo-c.example".to_string(), 1),
]
);
let asc = db
.list_repos_sorted("run_0001", 10, None, RepoSortOrder::ObjectsAsc)
.expect("asc");
assert_eq!(
hosts(asc),
vec![
("repo-c.example".to_string(), 1),
("repo-a.example".to_string(), 2),
("repo-b.example".to_string(), 5),
]
);
// Offset cursor pagination over the sorted listing.
let first = db
.list_repos_sorted("run_0001", 2, None, RepoSortOrder::ObjectsDesc)
.expect("first page");
assert_eq!(first.data.len(), 2);
assert_eq!(first.data[0].host, "repo-b.example");
assert_eq!(first.next_cursor.as_deref(), Some("repos:2"));
let second = db
.list_repos_sorted(
"run_0001",
2,
first.next_cursor.as_deref(),
RepoSortOrder::ObjectsDesc,
)
.expect("second page");
assert_eq!(second.data.len(), 1);
assert_eq!(second.data[0].host, "repo-c.example");
assert!(second.next_cursor.is_none());
// A key-style cursor from the index-order listing is rejected.
assert!(db
.list_repos_sorted("run_0001", 2, Some("repo/run_0001/x"), RepoSortOrder::ObjectsDesc)
.is_err());
}
/// Sample run with one publication point per repo host, each holding
/// `object_count` ok objects, so repo records get distinct object counts.
fn write_sample_run_with_repos(
run_dir: &Path,
run_id: &str,
run_seq: u64,
repo_specs: &[(&str, usize)],
) {
let publication_points = repo_specs
.iter()
.enumerate()
.map(|(index, (host, object_count))| {
let objects = (0..*object_count)
.map(|i| {
json!({
"rsync_uri": format!("rsync://{host}/rpki/obj{i}.roa"),
"sha256_hex": format!("{index:02x}{i:02x}"),
"kind": "roa",
"result": "ok"
})
})
.collect::<Vec<_>>();
json!({
"node_id": 10 + index,
"rsync_base_uri": format!("rsync://{host}/rpki/"),
"manifest_rsync_uri": format!("rsync://{host}/rpki/m.mft"),
"publication_point_rsync_uri": format!("rsync://{host}/rpki/"),
"rrdp_notification_uri": format!("https://{host}/rrdp/notification.xml"),
"source": "rrdp",
"repo_sync_source": "rrdp",
"repo_sync_phase": "rrdp_snapshot",
"repo_sync_duration_ms": 100,
"repo_terminal_state": "fresh",
"warnings": [],
"objects": objects,
})
})
.collect::<Vec<_>>();
let report = json!({
"format_version": 2,
"meta": {"validation_time_rfc3339_utc": "2026-06-15T00:00:00Z"},
"tree": {"warnings": []},
"publication_points": publication_points,
"vrps": [],
"aspas": [],
"downloads": [],
"download_stats": {},
"repo_sync_stats": {}
});
fs::write(
run_dir.join("report.json"),
serde_json::to_vec(&report).unwrap(),
)
.expect("report");
let summary = json!({
"status": "success",
"runId": run_id,
"runSeq": run_seq,
"startedAtRfc3339Utc": "2026-06-15T00:00:00Z",
"finishedAtRfc3339Utc": "2026-06-15T00:01:00Z",
"wallMs": 60000,
"reportCounts": {"vrps": 0, "aspas": 0, "publicationPoints": repo_specs.len(), "warnings": 0}
});
fs::write(
run_dir.join("run-summary.json"),
serde_json::to_vec(&summary).unwrap(),
)
.expect("summary");
fs::write(run_dir.join("stage-timing.json"), b"{}").expect("stage");
}
fn write_sample_run(run_dir: &Path, run_id: &str, run_seq: u64) {
let report = json!({
"format_version": 2,

View File

@@ -1,9 +1,31 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RfcRef(pub &'static str);
/// Stable categories for warnings written to audit reports and exported metrics.
///
/// New warning sites should use a specific category when one is available. The
/// default preserves the behaviour and schema of existing callers while making
/// their metric label explicit.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum WarningCategory {
#[default]
Unclassified,
BerCompatibleCmsEncoding,
}
impl WarningCategory {
pub const fn as_str(self) -> &'static str {
match self {
Self::Unclassified => "unclassified",
Self::BerCompatibleCmsEncoding => "ber_compatible_cms_encoding",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Warning {
pub message: String,
pub category: WarningCategory,
pub rfc_refs: Vec<RfcRef>,
pub context: Option<String>,
}
@@ -12,11 +34,17 @@ impl Warning {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
category: WarningCategory::Unclassified,
rfc_refs: Vec::new(),
context: None,
}
}
pub fn with_category(mut self, category: WarningCategory) -> Self {
self.category = category;
self
}
pub fn with_rfc_refs(mut self, refs: &[RfcRef]) -> Self {
self.rfc_refs.extend_from_slice(refs);
self

View File

@@ -613,6 +613,12 @@ pub struct VcirFailedFetchReuseIdentity {
pub effective_until: PackTime,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct VcirReuseRecordsCleared {
pub vcir_records: u64,
pub failed_fetch_identity_records: u64,
}
impl VcirFailedFetchReuseIdentity {
pub fn validate_internal(&self) -> StorageResult<()> {
let effective_not_before = parse_time(
@@ -1660,6 +1666,9 @@ pub struct PublicationPointCacheObject {
pub object_type: Option<String>,
#[serde(rename = "s")]
pub validation_status: VcirArtifactValidationStatus,
/// See `VcirRelatedArtifact::reject_reason`.
#[serde(rename = "e", default, skip_serializing_if = "Option::is_none")]
pub reject_reason: Option<String>,
}
impl PublicationPointCacheObject {
@@ -1671,6 +1680,7 @@ impl PublicationPointCacheObject {
sha256: artifact.sha256.clone(),
object_type: artifact.object_type.clone(),
validation_status: artifact.validation_status,
reject_reason: artifact.reject_reason.clone(),
}
}
@@ -1682,6 +1692,7 @@ impl PublicationPointCacheObject {
sha256: self.sha256.clone(),
object_type: self.object_type.clone(),
validation_status: self.validation_status,
reject_reason: self.reject_reason.clone(),
}
}
@@ -2157,6 +2168,11 @@ pub struct VcirRelatedArtifact {
pub object_type: Option<String>,
#[serde(rename = "s")]
pub validation_status: VcirArtifactValidationStatus,
/// Reject reason captured at fresh validation time for `Rejected` artifacts.
/// `None` for accepted/warning artifacts and for cache entries written
/// before this field existed.
#[serde(rename = "e", default, skip_serializing_if = "Option::is_none")]
pub reject_reason: Option<String>,
}
impl VcirRelatedArtifact {
@@ -4523,6 +4539,36 @@ impl RocksStore {
Ok(out)
}
/// Remove every reusable VCIR and its failed-fetch identity while retaining
/// manifest replay metadata. The replay metadata is a fresh-validation
/// guard, not a reusable validation result; removing it changes the normal
/// traversal semantics for manifests that are not selected for replay.
///
/// This is intentionally narrower than [`Self::delete_vcir`]. It is used
/// by verification-only after cloning a work-db so that it can import an
/// explicit, contract-bound allowlist of fallback projections.
pub fn clear_vcir_reuse_records(&self) -> StorageResult<VcirReuseRecordsCleared> {
let vcir_cf = self.cf(CF_VCIR)?;
let identity_cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?;
let mut batch = WriteBatch::default();
let mut summary = VcirReuseRecordsCleared::default();
for entry in self.db.iterator_cf(vcir_cf, IteratorMode::Start) {
let (key, _value) = entry.map_err(|error| StorageError::RocksDb(error.to_string()))?;
batch.delete_cf(vcir_cf, key);
summary.vcir_records += 1;
}
for entry in self.db.iterator_cf(identity_cf, IteratorMode::Start) {
let (key, _value) = entry.map_err(|error| StorageError::RocksDb(error.to_string()))?;
batch.delete_cf(identity_cf, key);
summary.failed_fetch_identity_records += 1;
}
if summary.vcir_records > 0 || summary.failed_fetch_identity_records > 0 {
self.write_batch(batch)?;
}
Ok(summary)
}
pub fn summarize_vcir_storage(&self) -> StorageResult<VcirStorageSummary> {
let cf = self.cf(CF_VCIR)?;
let mode = IteratorMode::Start;

View File

@@ -257,6 +257,7 @@ fn sample_vcir(manifest_rsync_uri: &str) -> ValidatedCaInstanceResult {
sha256: sha256_hex(b"manifest-object"),
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
},
VcirRelatedArtifact {
artifact_role: VcirArtifactRole::CurrentCrl,
@@ -265,6 +266,7 @@ fn sample_vcir(manifest_rsync_uri: &str) -> ValidatedCaInstanceResult {
sha256: sha256_hex(b"current-crl"),
object_type: Some("crl".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
},
],
summary: VcirSummary {
@@ -1308,6 +1310,111 @@ fn vcir_roundtrip_and_validation_failures_are_reported() {
);
}
#[test]
fn clear_vcir_reuse_records_preserves_manifest_replay_meta() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let vcir = sample_vcir("rsync://example.test/repo/current.mft");
store.put_vcir(&vcir).expect("put vcir");
let replay_meta = store
.get_manifest_replay_meta(&vcir.manifest_rsync_uri)
.expect("get replay meta")
.expect("replay meta exists");
let cleared = store
.clear_vcir_reuse_records()
.expect("clear reusable VCIR records");
assert_eq!(cleared.vcir_records, 1);
assert_eq!(cleared.failed_fetch_identity_records, 0);
assert!(store
.get_vcir(&vcir.manifest_rsync_uri)
.expect("get cleared vcir")
.is_none());
assert_eq!(
store
.get_manifest_replay_meta(&vcir.manifest_rsync_uri)
.expect("get preserved replay meta"),
Some(replay_meta)
);
}
#[test]
fn verification_only_materializes_only_contract_selected_fallback_vcirs() {
fn reuse_identity() -> VcirFailedFetchReuseIdentity {
VcirFailedFetchReuseIdentity {
current_ca_sha256: [0x11; 32],
ta_context_digest: [0x22; 32],
ca_validation_context_digest: [0x33; 32],
policy_fingerprint: [0x44; 32],
effective_not_before: pack_time(0),
effective_until: pack_time(12),
}
}
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let selected = sample_vcir("rsync://example.test/repo/selected.mft");
let unselected = sample_vcir("rsync://example.test/repo/unselected.mft");
store
.put_vcir_with_failed_fetch_reuse_identity(&selected, &reuse_identity())
.expect("put selected fallback vcir");
store
.put_vcir_with_failed_fetch_reuse_identity(&unselected, &reuse_identity())
.expect("put unselected fallback vcir");
let selection = crate::verification_only::fallback_replay_selection_for_manifest(
&store,
&selected.manifest_rsync_uri,
)
.expect("select fallback vcir");
let normal_audits = vec![crate::audit::PublicationPointAudit {
manifest_rsync_uri: selected.manifest_rsync_uri.clone(),
source: "vcir_current_instance".to_string(),
..crate::audit::PublicationPointAudit::default()
}];
assert_eq!(
crate::verification_only::collect_fallback_replay_selections(&store, &normal_audits)
.expect("collect normal fallback selection"),
vec![selection.clone()]
);
let mut tampered = selection.clone();
tampered.vcir_sha256 = "00".repeat(32);
assert!(crate::verification_only::materialize_fallback_replay_selections(
&store,
&[tampered]
)
.expect_err("tampered contract hash must fail before cleanup")
.contains("hash mismatch"));
assert_eq!(store.list_vcirs().expect("VCIRs remain after hash rejection").len(), 2);
let summary = crate::verification_only::materialize_fallback_replay_selections(
&store,
&[selection.clone()],
)
.expect("materialize contract fallback selection");
assert_eq!(summary.contract_selection_count, 1);
assert_eq!(summary.source_vcir_records_cleared, 2);
assert_eq!(summary.source_reuse_identity_records_cleared, 2);
assert_eq!(summary.imported_selection_count, 1);
assert_eq!(summary.scratch_vcir_count, 1);
assert_eq!(
crate::verification_only::fallback_replay_selection_for_manifest(
&store,
&selected.manifest_rsync_uri,
)
.expect("selected fallback remains"),
selection
);
assert!(store
.get_vcir(&unselected.manifest_rsync_uri)
.expect("get unselected fallback")
.is_none());
assert!(store
.get_manifest_replay_meta(&unselected.manifest_rsync_uri)
.expect("get unselected replay meta")
.is_some());
}
#[test]
fn transport_prefetch_snapshot_roundtrips() {
use crate::parallel::transport_prefetch::{
@@ -2394,3 +2501,35 @@ fn repository_blob_verification_detects_missing_external_blob() {
.expect_err("missing blob must fail");
assert!(error.to_string().contains("missing.roa"), "{error}");
}
#[test]
fn vcir_related_artifact_reject_reason_serde_backward_compatible() {
// Legacy cache JSON written before the reject_reason ("e") field existed.
let legacy = r#"{"r":"manifest","k":"mft","u":"rsync://example.test/a.mft","h":"0000000000000000000000000000000000000000000000000000000000000000","t":"mft","s":"accepted"}"#;
let artifact: VcirRelatedArtifact = serde_json::from_str(legacy).expect("legacy artifact");
assert_eq!(artifact.reject_reason, None);
// None reason is skipped on serialization, keeping cache entries compact.
let json_none = serde_json::to_string(&artifact).expect("serialize none");
assert!(!json_none.contains("\"e\""));
// A recorded reason round-trips through the "e" field.
let mut with_reason = artifact.clone();
with_reason.validation_status = VcirArtifactValidationStatus::Rejected;
with_reason.reject_reason = Some("bad object".to_string());
let json = serde_json::to_string(&with_reason).expect("serialize");
assert!(json.contains("\"e\":\"bad object\""));
let decoded: VcirRelatedArtifact = serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded.reject_reason.as_deref(), Some("bad object"));
// PublicationPointCacheObject conversion carries the reason both ways.
let cache_object = PublicationPointCacheObject::from_related_artifact(&with_reason);
assert_eq!(cache_object.reject_reason.as_deref(), Some("bad object"));
let back = cache_object.to_related_artifact();
assert_eq!(back.reject_reason.as_deref(), Some("bad object"));
// Legacy PublicationPointCacheObject JSON without "e" still deserializes.
let legacy_object: PublicationPointCacheObject =
serde_json::from_str(legacy).expect("legacy cache object");
assert_eq!(legacy_object.reject_reason, None);
}

View File

@@ -9,7 +9,10 @@ use crate::replay::delta_archive::{ReplayDeltaArchiveIndex, ReplayDeltaRrdpKind}
use crate::report::{RfcRef, Warning};
use crate::storage::RocksStore;
use crate::sync::rrdp::sync_from_notification_with_timing_and_download_log;
use crate::sync::rrdp::{Fetcher as HttpFetcher, RrdpSyncError, load_rrdp_local_state};
use crate::sync::rrdp::{
Fetcher as HttpFetcher, RrdpFetchError, RrdpOrigin, RrdpResourceKind, RrdpSyncError,
load_rrdp_local_state,
};
use crate::sync::store_projection::{
build_repository_view_present_entry, build_repository_view_withdrawn_entry,
prepare_repo_bytes_batch_owned,
@@ -127,11 +130,15 @@ pub fn sync_publication_point(
"rrdp_error": err.to_string(),
}),
);
let warnings = vec![
Warning::new(format!("RRDP failed; falling back to rsync: {err}"))
.with_rfc_refs(&[RfcRef("RFC 8182 §3.4.5")])
.with_context(notification_uri),
];
let mut rfc_refs = vec![RfcRef("RFC 8182 §3.4.5")];
if matches!(&err, RrdpSyncError::Rrdp(e) if e.is_cross_origin_violation()) {
rfc_refs.push(RfcRef("RFC 9674 §3.2"));
}
let warnings = vec![Warning::new(format!(
"RRDP failed; falling back to rsync: {err}"
))
.with_rfc_refs(&rfc_refs)
.with_context(notification_uri)];
let written = rsync_sync_into_current_store(
store,
rsync_base_uri,
@@ -419,6 +426,7 @@ fn try_rrdp_sync(
timing: Option<&TimingHandle>,
download_log: Option<&DownloadLogHandle>,
) -> Result<usize, RrdpSyncError> {
let notification_origin = RrdpOrigin::parse(notification_uri, RrdpResourceKind::Notification)?;
let notification_xml = {
let _step = timing
.as_ref()
@@ -428,7 +436,11 @@ fn try_rrdp_sync(
.map(|t| t.span_phase("rrdp_fetch_notification_total"));
let mut dl_span = download_log
.map(|dl| dl.span_download(AuditDownloadKind::RrdpNotification, notification_uri));
match http_fetcher.fetch(notification_uri) {
match http_fetcher.fetch_rrdp(
RrdpResourceKind::Notification,
notification_uri,
&notification_origin,
) {
Ok(v) => {
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_notification_fetch_ok_total", 1);
@@ -439,7 +451,7 @@ fn try_rrdp_sync(
}
v
}
Err(e) => {
Err(RrdpFetchError::Fetch(e)) => {
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_notification_fetch_fail_total", 1);
}
@@ -448,6 +460,18 @@ fn try_rrdp_sync(
}
return Err(RrdpSyncError::Fetch(e));
}
Err(RrdpFetchError::Rrdp(e)) => {
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_notification_fetch_fail_total", 1);
if e.is_cross_origin_violation() {
t.record_count("rrdp_rejected_cross_origin_total", 1);
}
}
if let Some(s) = dl_span.as_mut() {
s.set_err(e.to_string());
}
return Err(e.into());
}
}
};
if let Some(t) = timing.as_ref() {
@@ -672,7 +696,7 @@ fn rsync_sync_into_current_store(
.map_err(|e| RepoSyncError::Storage(e.to_string()))?;
if let Some(index) = current_repo_index {
index
.lock()
.write()
.map_err(|_| RepoSyncError::Storage("current repo index lock poisoned".to_string()))?
.apply_repository_view_entries(&repository_view_entries)
.map_err(RepoSyncError::Storage)?;

View File

@@ -824,6 +824,77 @@ fn rrdp_protocol_error_does_not_retry_and_falls_back_to_rsync() {
);
}
#[test]
fn rrdp_cross_origin_reference_falls_back_to_rsync_without_fetching_foreign_uri() {
let temp = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(&temp.path().join("db")).expect("open rocksdb");
let notification_uri = "https://origin.example/notification.xml";
let foreign_snapshot_uri = "https://foreign.example/snapshot.xml";
let notification = notification_xml(
"9df4b597-af9e-4dca-bdda-719cce2c4e28",
1,
foreign_snapshot_uri,
&"00".repeat(32),
);
struct RecordingHttp {
notification: Vec<u8>,
notification_calls: AtomicUsize,
foreign_calls: AtomicUsize,
}
impl HttpFetcher for RecordingHttp {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
if uri == "https://origin.example/notification.xml" {
self.notification_calls.fetch_add(1, Ordering::SeqCst);
return Ok(self.notification.clone());
}
if uri == "https://foreign.example/snapshot.xml" {
self.foreign_calls.fetch_add(1, Ordering::SeqCst);
}
Err(format!("unexpected HTTP fetch: {uri}"))
}
}
struct OneObjectRsync;
impl RsyncFetcher for OneObjectRsync {
fn fetch_objects(
&self,
_rsync_base_uri: &str,
) -> Result<Vec<(String, Vec<u8>)>, RsyncFetchError> {
Ok(vec![("rsync://origin.example/repo/a.mft".to_string(), b"mft".to_vec())])
}
}
let http = RecordingHttp {
notification,
notification_calls: AtomicUsize::new(0),
foreign_calls: AtomicUsize::new(0),
};
let policy = Policy {
sync_preference: SyncPreference::RrdpThenRsync,
..Policy::default()
};
let out = sync_publication_point(
&store,
&policy,
Some(notification_uri),
"rsync://origin.example/repo/",
&http,
&OneObjectRsync,
None,
None,
)
.expect("rsync fallback succeeds");
assert_eq!(out.phase, RepoSyncPhase::RrdpFailedRsyncOk);
assert_eq!(http.notification_calls.load(Ordering::SeqCst), 1);
assert_eq!(http.foreign_calls.load(Ordering::SeqCst), 0);
assert!(out.warnings[0].message.contains("crosses notification origin"));
assert!(out.warnings[0]
.rfc_refs
.iter()
.any(|reference| reference.0 == "RFC 9674 §3.2"));
assert_current_object(&store, "rsync://origin.example/repo/a.mft", b"mft");
}
#[test]
fn rrdp_delta_fetches_are_logged_even_if_snapshot_fallback_is_used() {
let temp = tempfile::tempdir().expect("tempdir");

View File

@@ -17,12 +17,34 @@ use crate::sync::store_projection::{
use base64::Engine;
use serde::{Deserialize, Serialize};
use sha2::Digest;
use std::fmt;
use std::io::Write;
use url::Url;
use uuid::Uuid;
const RRDP_XMLNS: &str = "http://www.ripe.net/rpki/rrdp";
const RRDP_SNAPSHOT_APPLY_BATCH_SIZE: usize = 1024;
/// The RRDP object currently being fetched. Keeping this in protocol errors
/// makes an origin rejection actionable without treating it as a transient
/// transport failure.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RrdpResourceKind {
Notification,
Snapshot,
Delta,
}
impl fmt::Display for RrdpResourceKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Notification => "notification",
Self::Snapshot => "snapshot",
Self::Delta => "delta",
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum RrdpError {
#[error("RRDP XML must be US-ASCII encoded (RFC 8182 §3.5.1.3, §3.5.2.3), got non-ASCII bytes")]
@@ -177,6 +199,102 @@ pub enum RrdpError {
#[error("delta file contains unexpected element <{0}> (RFC 8182 §3.5.3.3)")]
DeltaUnexpectedElement(String),
#[error("invalid RRDP {resource} URI: {detail} (RFC 9674 §3.2)")]
InvalidRrdpUri {
resource: RrdpResourceKind,
detail: String,
},
#[error(
"RRDP {resource} URI crosses notification origin: notification={notification_origin}, resource={resource_origin} (RFC 9674 §3.2)"
)]
CrossOriginReference {
resource: RrdpResourceKind,
notification_origin: String,
resource_origin: String,
},
#[error(
"RRDP {resource} redirect crosses notification origin: notification={notification_origin}, redirect={redirect_origin} (RFC 9674 §3.2)"
)]
CrossOriginRedirect {
resource: RrdpResourceKind,
notification_origin: String,
redirect_origin: String,
},
#[error("RRDP {resource} redirect has no Location header (RFC 9674 §3.2)")]
RedirectLocationMissing { resource: RrdpResourceKind },
#[error("RRDP {resource} redirect Location is invalid: {location} (RFC 9674 §3.2)")]
InvalidRedirectLocation {
resource: RrdpResourceKind,
location: String,
},
#[error("RRDP {resource} redirect limit exceeded ({max}) (RFC 9674 §3.2)")]
RedirectLimitExceeded { resource: RrdpResourceKind, max: usize },
}
impl RrdpError {
pub fn is_cross_origin_violation(&self) -> bool {
matches!(self, Self::CrossOriginReference { .. } | Self::CrossOriginRedirect { .. })
}
}
/// RFC 9674 §3.2 compares scheme, host name, and port. `Url` normalizes an
/// omitted well-known port, so `https://example/` and `https://example:443/`
/// intentionally have the same effective origin.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RrdpOrigin {
scheme: String,
host: String,
port: u16,
}
impl fmt::Display for RrdpOrigin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}://{}:{}", self.scheme, self.host, self.port)
}
}
impl RrdpOrigin {
pub fn parse(uri: &str, resource: RrdpResourceKind) -> Result<Self, RrdpError> {
let url = Url::parse(uri).map_err(|e| RrdpError::InvalidRrdpUri {
resource,
detail: e.to_string(),
})?;
Self::from_url(&url, resource)
}
pub fn from_url(url: &Url, resource: RrdpResourceKind) -> Result<Self, RrdpError> {
if !matches!(url.scheme(), "http" | "https") {
return Err(RrdpError::InvalidRrdpUri {
resource,
detail: format!("unsupported scheme {}", url.scheme()),
});
}
if !url.username().is_empty() || url.password().is_some() {
return Err(RrdpError::InvalidRrdpUri {
resource,
detail: "userinfo is not permitted".to_string(),
});
}
let host = url.host_str().ok_or_else(|| RrdpError::InvalidRrdpUri {
resource,
detail: "host is missing".to_string(),
})?;
let port = url.port_or_known_default().ok_or_else(|| RrdpError::InvalidRrdpUri {
resource,
detail: "port is missing or unknown".to_string(),
})?;
Ok(Self {
scheme: url.scheme().to_ascii_lowercase(),
host: host.to_ascii_lowercase(),
port,
})
}
}
#[derive(Debug, thiserror::Error)]
@@ -191,6 +309,15 @@ pub enum RrdpSyncError {
Storage(String),
}
#[derive(Debug, thiserror::Error)]
pub enum RrdpFetchError {
#[error("{0}")]
Rrdp(#[from] RrdpError),
#[error("{0}")]
Fetch(String),
}
pub type RrdpSyncResult<T> = Result<T, RrdpSyncError>;
pub trait Fetcher: Send + Sync {
@@ -202,6 +329,28 @@ pub trait Fetcher: Send + Sync {
.map_err(|e| format!("write sink failed: {e}"))?;
Ok(bytes.len() as u64)
}
/// RRDP-only fetch path. Implementations that do not perform HTTP may use
/// the compatibility default; HTTP implementations must enforce redirect
/// origin checks before issuing the next request.
fn fetch_rrdp(
&self,
_resource: RrdpResourceKind,
uri: &str,
_notification_origin: &RrdpOrigin,
) -> Result<Vec<u8>, RrdpFetchError> {
self.fetch(uri).map_err(RrdpFetchError::Fetch)
}
fn fetch_rrdp_to_writer(
&self,
_resource: RrdpResourceKind,
uri: &str,
_notification_origin: &RrdpOrigin,
out: &mut dyn Write,
) -> Result<u64, RrdpFetchError> {
self.fetch_to_writer(uri, out).map_err(RrdpFetchError::Fetch)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -408,6 +557,51 @@ pub fn parse_notification_snapshot(xml: &[u8]) -> Result<NotificationSnapshot, R
})
}
/// Validate every URI embedded in a notification before reading mutable local
/// RRDP state or fetching a snapshot/delta. RFC 9674 §3.2 requires the
/// notification, snapshot, and all delta URIs to share one origin.
pub fn validate_notification_references(
notification_uri: &str,
notification: &Notification,
) -> Result<RrdpOrigin, RrdpError> {
let notification_origin = RrdpOrigin::parse(notification_uri, RrdpResourceKind::Notification)?;
validate_reference_origin(
&notification_origin,
&notification.snapshot_uri,
RrdpResourceKind::Snapshot,
)?;
for delta in &notification.deltas {
validate_reference_origin(&notification_origin, &delta.uri, RrdpResourceKind::Delta)?;
}
Ok(notification_origin)
}
fn validate_reference_origin(
notification_origin: &RrdpOrigin,
uri: &str,
resource: RrdpResourceKind,
) -> Result<(), RrdpError> {
let resource_origin = RrdpOrigin::parse(uri, resource)?;
if &resource_origin != notification_origin {
crate::progress_log::emit(
"rrdp_cross_origin_rejected",
serde_json::json!({
"resource": resource.to_string(),
"notification_origin": notification_origin.to_string(),
"resource_origin": resource_origin.to_string(),
"reason": "direct_reference",
"rfc": "RFC 9674 §3.2",
}),
);
return Err(RrdpError::CrossOriginReference {
resource,
notification_origin: notification_origin.to_string(),
resource_origin: resource_origin.to_string(),
});
}
Ok(())
}
pub fn parse_delta_file(xml: &[u8]) -> Result<DeltaFile, RrdpError> {
let doc = parse_rrdp_xml(xml)?;
let root = doc.root_element();
@@ -548,7 +742,16 @@ fn sync_from_notification_snapshot_inner(
let _parse_total = timing
.as_ref()
.map(|t| t.span_phase("rrdp_parse_notification_total"));
let notif = parse_notification_snapshot(notification_xml)?;
let notif = parse_notification(notification_xml)?;
let notification_origin = match validate_notification_references(notification_uri, &notif) {
Ok(origin) => origin,
Err(e) => {
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_rejected_cross_origin_total", e.is_cross_origin_violation() as u64);
}
return Err(e.into());
}
};
drop(_parse_step);
drop(_parse_total);
@@ -564,6 +767,7 @@ fn sync_from_notification_snapshot_inner(
fetcher,
&notif.snapshot_uri,
&notif.snapshot_hash_sha256,
&notification_origin,
) {
Ok(v) => {
if let Some(t) = timing.as_ref() {
@@ -710,6 +914,15 @@ fn sync_from_notification_inner(
.as_ref()
.map(|t| t.span_phase("rrdp_parse_notification_total"));
let notif = parse_notification(notification_xml)?;
let notification_origin = match validate_notification_references(notification_uri, &notif) {
Ok(origin) => origin,
Err(e) => {
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_rejected_cross_origin_total", e.is_cross_origin_violation() as u64);
}
return Err(e.into());
}
};
drop(_parse_step);
drop(_parse_total);
if let Some(t) = timing.as_ref() {
@@ -800,7 +1013,11 @@ fn sync_from_notification_inner(
let mut dl_span = download_log
.map(|dl| dl.span_download(AuditDownloadKind::RrdpDelta, &dref.uri));
match fetcher.fetch(&dref.uri) {
match fetcher.fetch_rrdp(
RrdpResourceKind::Delta,
&dref.uri,
&notification_origin,
) {
Ok(bytes) => {
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_delta_fetch_ok_total", 1);
@@ -812,7 +1029,7 @@ fn sync_from_notification_inner(
}
fetched.push((serial, dref.hash_sha256, bytes))
}
Err(e) => {
Err(RrdpFetchError::Fetch(e)) => {
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_delta_fetch_fail_total", 1);
}
@@ -822,6 +1039,7 @@ fn sync_from_notification_inner(
fetch_ok = false;
break;
}
Err(RrdpFetchError::Rrdp(e)) => return Err(e.into()),
}
}
drop(_fetch_d_step);
@@ -920,6 +1138,7 @@ fn sync_from_notification_inner(
fetcher,
&notif.snapshot_uri,
&notif.snapshot_hash_sha256,
&notification_origin,
) {
Ok(v) => {
if let Some(t) = timing.as_ref() {
@@ -1029,7 +1248,7 @@ fn hydrate_current_repo_index_from_rrdp_members(
}
index
.lock()
.write()
.map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
.apply_repository_view_entries(&entries)
.map_err(RrdpSyncError::Storage)?;
@@ -1184,7 +1403,7 @@ fn apply_delta(
&current_hash,
);
index
.lock()
.write()
.map_err(|_| {
RrdpSyncError::Storage("current repo index lock poisoned".to_string())
})?
@@ -1240,7 +1459,7 @@ fn apply_delta(
Some(previous_hash.clone()),
);
index
.lock()
.write()
.map_err(|_| {
RrdpSyncError::Storage(
"current repo index lock poisoned".to_string(),

View File

@@ -15,8 +15,8 @@ use crate::sync::store_projection::{
};
use super::{
Fetcher, RRDP_SNAPSHOT_APPLY_BATCH_SIZE, RRDP_XMLNS, RrdpError, RrdpSyncError, parse_u64_str,
strip_all_ascii_whitespace,
Fetcher, RRDP_SNAPSHOT_APPLY_BATCH_SIZE, RRDP_XMLNS, RrdpError, RrdpFetchError,
RrdpOrigin, RrdpResourceKind, RrdpSyncError, parse_u64_str, strip_all_ascii_whitespace,
};
#[cfg(test)]
@@ -306,7 +306,7 @@ pub(super) fn apply_snapshot_from_bufread<R: BufRead>(
.map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
if let Some(index) = current_repo_index {
index
.lock()
.write()
.map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
.apply_repository_view_entries(&repository_view_entries)
.map_err(RrdpSyncError::Storage)?;
@@ -365,7 +365,7 @@ fn flush_snapshot_publish_batch(
.map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
if let Some(index) = current_repo_index {
index
.lock()
.write()
.map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
.apply_repository_view_entries(&repository_view_entries)
.map_err(RrdpSyncError::Storage)?;
@@ -422,14 +422,23 @@ pub(super) fn fetch_snapshot_into_tempfile(
fetcher: &dyn Fetcher,
snapshot_uri: &str,
expected_hash_sha256: &[u8; 32],
notification_origin: &RrdpOrigin,
) -> Result<(tempfile::NamedTempFile, u64), RrdpSyncError> {
let mut tmp = tempfile::NamedTempFile::new()
.map_err(|e| RrdpSyncError::Fetch(format!("tempfile create failed: {e}")))?;
let mut spool = SnapshotSpoolWriter::new(tmp.as_file_mut());
let bytes_written = match fetcher.fetch_to_writer(snapshot_uri, &mut spool) {
let bytes_written = match fetcher.fetch_rrdp_to_writer(
RrdpResourceKind::Snapshot,
snapshot_uri,
notification_origin,
&mut spool,
) {
Ok(bytes) => bytes,
Err(e) if e.contains(SNAPSHOT_NON_ASCII_ERROR) => return Err(RrdpError::NotAscii.into()),
Err(e) => return Err(RrdpSyncError::Fetch(e)),
Err(RrdpFetchError::Fetch(e)) if e.contains(SNAPSHOT_NON_ASCII_ERROR) => {
return Err(RrdpError::NotAscii.into());
}
Err(RrdpFetchError::Fetch(e)) => return Err(RrdpSyncError::Fetch(e)),
Err(RrdpFetchError::Rrdp(e)) => return Err(e.into()),
};
let computed = spool.finalize_hash();
if computed.as_slice() != expected_hash_sha256.as_slice() {

View File

@@ -10,6 +10,14 @@ struct MapFetcher {
map: HashMap<String, Vec<u8>>,
}
struct PanicFetcher;
impl Fetcher for PanicFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
panic!("cross-origin RRDP reference must be rejected before fetch: {uri}")
}
}
impl Fetcher for MapFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
self.map
@@ -108,6 +116,74 @@ fn notification_xml_with_deltas(
out.into_bytes()
}
#[test]
fn rrdp_origin_normalizes_default_ports_but_not_non_default_ports() {
let default_port = RrdpOrigin::parse("https://origin.example/notification.xml", RrdpResourceKind::Notification)
.expect("default origin");
let explicit_default = RrdpOrigin::parse("https://origin.example:443/snapshot.xml", RrdpResourceKind::Snapshot)
.expect("explicit default origin");
let different_port = RrdpOrigin::parse("https://origin.example:8443/snapshot.xml", RrdpResourceKind::Snapshot)
.expect("different port origin");
assert_eq!(default_port, explicit_default);
assert_ne!(default_port, different_port);
}
#[test]
fn cross_origin_snapshot_is_rejected_before_fetch_or_state_read() {
let temp = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(temp.path()).expect("open rocksdb");
let notification_uri = "https://origin.example/notification.xml";
let session = "550e8400-e29b-41d4-a716-446655440000";
let notification = notification_xml(
session,
1,
"https://foreign.example/snapshot.xml",
&"00".repeat(32),
);
let err = sync_from_notification_snapshot(
&store,
notification_uri,
&notification,
&PanicFetcher,
)
.unwrap_err();
assert!(matches!(
err,
RrdpSyncError::Rrdp(RrdpError::CrossOriginReference {
resource: RrdpResourceKind::Snapshot,
..
})
));
assert_eq!(load_rrdp_local_state(&store, notification_uri).expect("state"), None);
}
#[test]
fn cross_origin_delta_is_rejected_before_fetch_or_state_read() {
let temp = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(temp.path()).expect("open rocksdb");
let notification_uri = "https://origin.example/notification.xml";
let session = "550e8400-e29b-41d4-a716-446655440000";
let notification = notification_xml_with_deltas(
session,
1,
"https://origin.example/snapshot.xml",
&"00".repeat(32),
&[("delta", 1, "https://foreign.example/delta.xml", &"00".repeat(32))],
);
let err = sync_from_notification(&store, notification_uri, &notification, &PanicFetcher)
.unwrap_err();
assert!(matches!(
err,
RrdpSyncError::Rrdp(RrdpError::CrossOriginReference {
resource: RrdpResourceKind::Delta,
..
})
));
assert_eq!(load_rrdp_local_state(&store, notification_uri).expect("state"), None);
}
fn snapshot_xml(session_id: &str, serial: u64, published: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = format!(
r#"<snapshot xmlns="{RRDP_XMLNS}" version="1" session_id="{session_id}" serial="{serial}">"#
@@ -129,9 +205,11 @@ fn fetch_snapshot_into_tempfile_streams_and_validates_hash() {
let fetcher = WriterOnlyFetcher {
map: HashMap::from([(snapshot_uri.to_string(), snapshot.clone())]),
};
let origin = RrdpOrigin::parse("https://example.test/notification.xml", RrdpResourceKind::Notification)
.expect("origin");
let (mut file, bytes_written) =
fetch_snapshot_into_tempfile(&fetcher, snapshot_uri, &expected_hash)
fetch_snapshot_into_tempfile(&fetcher, snapshot_uri, &expected_hash, &origin)
.expect("fetch snapshot into tempfile");
assert_eq!(bytes_written, snapshot.len() as u64);
let mut got = Vec::new();
@@ -142,7 +220,7 @@ fn fetch_snapshot_into_tempfile_streams_and_validates_hash() {
let mut wrong_hash = expected_hash;
wrong_hash[0] ^= 0xff;
let err = fetch_snapshot_into_tempfile(&fetcher, snapshot_uri, &wrong_hash).unwrap_err();
let err = fetch_snapshot_into_tempfile(&fetcher, snapshot_uri, &wrong_hash, &origin).unwrap_err();
assert!(matches!(
err,
RrdpSyncError::Rrdp(RrdpError::SnapshotHashMismatch)
@@ -155,6 +233,8 @@ fn fetch_snapshot_into_tempfile_maps_stream_non_ascii_error() {
&NonAsciiWriterFetcher,
"https://example.test/snapshot.xml",
&[0u8; 32],
&RrdpOrigin::parse("https://example.test/notification.xml", RrdpResourceKind::Notification)
.expect("origin"),
)
.unwrap_err();
assert!(matches!(err, RrdpSyncError::Rrdp(RrdpError::NotAscii)));
@@ -1025,7 +1105,7 @@ fn sync_from_notification_same_serial_hydrates_current_repo_index() {
.expect("same serial no-op");
assert_eq!(applied, 0);
let index = index.lock().expect("lock index");
let index = index.read().expect("read-lock index");
assert_eq!(index.active_uri_count(), 2);
assert!(index.get_by_uri(uri_a).is_some());
assert!(index.get_by_uri(uri_b).is_some());
@@ -1089,7 +1169,7 @@ fn sync_from_notification_delta_hydrates_unchanged_current_repo_entries() {
.expect("delta sync");
assert_eq!(applied, 1);
let index = index.lock().expect("lock index");
let index = index.read().expect("read-lock index");
assert_eq!(index.active_uri_count(), 3);
assert!(
index.get_by_uri(uri_a).is_some(),

1139
src/ta_constraints.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -424,6 +424,7 @@ struct LatestRunMetrics {
vaps: u64,
publication_points: u64,
warnings: u64,
warning_categories: BTreeMap<String, u64>,
tree_instances_processed: Option<u64>,
tree_instances_failed: Option<u64>,
stage_seconds: BTreeMap<String, f64>,
@@ -433,6 +434,8 @@ struct LatestRunMetrics {
download_bytes: Option<u64>,
artifact_sizes: BTreeMap<String, u64>,
state_path_sizes: BTreeMap<String, PathSize>,
dead_repo_blacklist_entries: Option<u64>,
dead_repo_blacklist_blacklisted: Option<u64>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -1377,6 +1380,10 @@ fn build_latest_metrics(record: &RunRecord, snapshot: &mut MetricsSnapshot) {
latest.download_bytes = json_u64(summary, &["stageTiming", "download_bytes_total"]);
latest.artifact_sizes = extract_artifact_sizes(summary.get("artifacts"));
latest.state_path_sizes = extract_path_sizes(summary.get("pathStats"));
latest.dead_repo_blacklist_entries =
json_u64(summary, &["deadRepoBlacklist", "entries"]);
latest.dead_repo_blacklist_blacklisted =
json_u64(summary, &["deadRepoBlacklist", "blacklisted"]);
}
parse_report(&record.path.join("report.json"), snapshot, &mut latest);
@@ -1471,13 +1478,9 @@ fn parse_report(path: &Path, snapshot: &mut MetricsSnapshot, latest: &mut Latest
.map(|a| a.len() as u64)
.unwrap_or(0);
}
latest.warnings = latest.warnings.max(
report
.pointer("/tree/warnings")
.and_then(|v| v.as_array())
.map(|a| a.len() as u64)
.unwrap_or(0),
);
let (reported_warning_count, warning_categories) = collect_warning_categories(&report);
latest.warnings = latest.warnings.max(reported_warning_count);
latest.warning_categories = warning_categories;
if let Some(processed) = json_u64(&report, &["tree", "instances_processed"]) {
latest.tree_instances_processed = Some(processed);
}
@@ -1498,6 +1501,47 @@ fn parse_report(path: &Path, snapshot: &mut MetricsSnapshot, latest: &mut Latest
}
}
fn collect_warning_categories(report: &Value) -> (u64, BTreeMap<String, u64>) {
let mut total = 0u64;
let mut categories = BTreeMap::new();
total += count_warning_categories(
report.pointer("/tree/warnings").and_then(Value::as_array),
&mut categories,
);
if let Some(publication_points) = report.get("publication_points").and_then(Value::as_array) {
for publication_point in publication_points {
total += count_warning_categories(
publication_point.get("warnings").and_then(Value::as_array),
&mut categories,
);
}
}
// Export the baseline and BER-compatibility series even when their count is
// zero, so dashboards can distinguish a clean run from a missing metric.
for category in ["unclassified", "ber_compatible_cms_encoding"] {
categories.entry(category.to_string()).or_insert(0);
}
(total, categories)
}
fn count_warning_categories(
warnings: Option<&Vec<Value>>,
categories: &mut BTreeMap<String, u64>,
) -> u64 {
let Some(warnings) = warnings else {
return 0;
};
for warning in warnings {
let category = warning
.get("category")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("unclassified");
*categories.entry(category.to_string()).or_default() += 1;
}
warnings.len() as u64
}
fn extract_publication_point_metrics(
pps: &[Value],
downloads: Option<&Value>,
@@ -2403,6 +2447,17 @@ fn render_latest_metrics(writer: &mut PromWriter<'_>, instance: &str, latest: &L
&[label("instance", instance)],
latest.warnings as f64,
);
for (category, count) in &latest.warning_categories {
writer.gauge(
"ours_rp_warnings_by_category",
"Latest run warning count by category",
&[
label("instance", instance),
label("warning_category", category),
],
*count as f64,
);
}
if let Some(value) = latest.tree_instances_processed {
writer.gauge(
"ours_rp_tree_instances",
@@ -2419,6 +2474,22 @@ fn render_latest_metrics(writer: &mut PromWriter<'_>, instance: &str, latest: &L
value as f64,
);
}
if let Some(value) = latest.dead_repo_blacklist_entries {
writer.gauge(
"ours_rp_dead_repo_blacklist_entries",
"Dead-repo transport blacklist entries (latest run)",
&[label("instance", instance), label("state", "total")],
value as f64,
);
}
if let Some(value) = latest.dead_repo_blacklist_blacklisted {
writer.gauge(
"ours_rp_dead_repo_blacklist_entries",
"Dead-repo transport blacklist entries (latest run)",
&[label("instance", instance), label("state", "blacklisted")],
value as f64,
);
}
for (stage, value) in &latest.stage_seconds {
writer.gauge(
"ours_rp_run_stage_duration_seconds",
@@ -3342,6 +3413,48 @@ mod tests {
assert_eq!(args.out_metrics.as_deref(), Some(Path::new("metrics.prom")));
}
#[test]
fn warning_categories_cover_tree_and_publication_point_warnings() {
let report = json!({
"tree": {
"warnings": [
{"category": "ber_compatible_cms_encoding"},
{"category": "ber_compatible_cms_encoding"}
]
},
"publication_points": [
{"warnings": [{"category": "repository_sync"}]},
{"warnings": [{"message": "legacy warning without category"}]}
]
});
let (total, categories) = collect_warning_categories(&report);
assert_eq!(total, 4);
assert_eq!(categories["ber_compatible_cms_encoding"], 2);
assert_eq!(categories["repository_sync"], 1);
assert_eq!(categories["unclassified"], 1);
}
#[test]
fn renders_warning_category_metrics() {
let mut warning_categories = BTreeMap::new();
warning_categories.insert("ber_compatible_cms_encoding".to_string(), 2);
let snapshot = MetricsSnapshot {
instance: "test".to_string(),
latest_run: Some(LatestRunMetrics {
warnings: 2,
warning_categories,
..LatestRunMetrics::default()
}),
..MetricsSnapshot::default()
};
let metrics = render_metrics(&snapshot);
assert!(metrics.contains(
r#"ours_rp_warnings_by_category{instance="test",warning_category="ber_compatible_cms_encoding"} 2"#
));
}
#[test]
fn scan_fixture_exports_repo_pp_cir_and_ccr_metrics() {
let td = TempDir::new().expect("tempdir");
@@ -3893,4 +4006,37 @@ mod tests {
});
encode_content_info(&ci).expect("encode ccr")
}
#[test]
fn dead_repo_blacklist_gauges_render_from_latest_summary() {
let td = TempDir::new().expect("tempdir");
write_success_run(td.path(), "run_0001", 1);
// Rewrite the summary with the blacklist section present.
let run = td.path().join("runs").join("run_0001");
fs::write(
run.join("run-summary.json"),
r#"{"runSeq":1,"runId":"run_0001","startedAtRfc3339Utc":"2026-07-21T00:00:00Z","finishedAtRfc3339Utc":"2026-07-21T00:00:10Z","wallMs":10000,"status":"success","exitCode":0,"deadRepoBlacklist":{"path":"/state/dead-repo-blacklist.json","entries":5,"blacklisted":3}}"#,
)
.expect("summary");
let snapshot = scan_run_root(td.path(), "test").expect("scan");
let latest = snapshot.latest_run.as_ref().expect("latest run");
assert_eq!(latest.dead_repo_blacklist_entries, Some(5));
assert_eq!(latest.dead_repo_blacklist_blacklisted, Some(3));
let metrics = render_metrics(&snapshot);
assert!(metrics.contains(
r#"ours_rp_dead_repo_blacklist_entries{instance="test",state="total"} 5"#
));
assert!(metrics.contains(
r#"ours_rp_dead_repo_blacklist_entries{instance="test",state="blacklisted"} 3"#
));
}
#[test]
fn dead_repo_blacklist_gauges_absent_without_section() {
let td = TempDir::new().expect("tempdir");
write_success_run(td.path(), "run_0001", 1);
let snapshot = scan_run_root(td.path(), "test").expect("scan");
let metrics = render_metrics(&snapshot);
assert!(!metrics.contains("ours_rp_dead_repo_blacklist_entries"));
}
}

View File

@@ -6,6 +6,9 @@ use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use crate::parallel::dead_repo_blacklist::DeadRepoBlacklist;
use crate::parallel::types::RepoTransportMode;
#[derive(Clone, Debug, PartialEq, Eq)]
struct Args {
state_root: PathBuf,
@@ -21,6 +24,12 @@ struct Args {
db_stats_bin: Option<PathBuf>,
db_stats_exact_every: Option<u64>,
time_bin: Option<PathBuf>,
/// Dead-repo blacklist (#141): enables daemon-side health checks and
/// auto-injects the child `--dead-repo-blacklist` flag when absent.
dead_repo_blacklist: Option<PathBuf>,
dead_repo_blacklist_fail_threshold: Option<u32>,
dead_repo_health_check_interval_secs: u64,
dead_repo_probe_rsync_bin: PathBuf,
child_args: Vec<String>,
}
@@ -52,6 +61,23 @@ struct DaemonStatus {
current_run_seq: Option<u64>,
current_run_id: Option<String>,
last_run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
dead_repo_blacklist: Option<DeadRepoBlacklistStatus>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct DeadRepoBlacklistStatus {
path: String,
entries: usize,
blacklisted: usize,
last_health_check_at_rfc3339_utc: Option<String>,
}
/// Mutable daemon-side health check bookkeeping (#141).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct DeadRepoHealthRuntime {
last_health_check_at: Option<time::OffsetDateTime>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
@@ -145,6 +171,16 @@ struct RunSummary {
db_stats: Vec<DbStatsSummary>,
retention_deleted_runs: Vec<String>,
artifacts: Vec<ArtifactInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
dead_repo_blacklist: Option<DeadRepoBlacklistRunSummary>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct DeadRepoBlacklistRunSummary {
path: String,
entries: usize,
blacklisted: usize,
}
fn usage() -> String {
@@ -170,6 +206,17 @@ Options:
Run db_stats --exact every n runs (default: disabled)
--time-bin <path> GNU time binary for child process metrics (default: /usr/bin/time when present)
--no-time-wrapper Disable GNU time wrapper
--dead-repo-blacklist <path>
Enable the dead-repo transport blacklist (#141): daemon probes
blacklisted entries between runs and removes revived repos;
auto-injects the child flag when not already present
--dead-repo-blacklist-fail-threshold <n>
Admission threshold forwarded to the child when its args do not
already set it (default: child default 3)
--dead-repo-health-check-interval-secs <n>
Minimum seconds between health check sweeps (default: 600)
--dead-repo-probe-rsync-bin <path>
rsync binary used for health probes (default: rsync)
--help Show this help
Child argument placeholders:
@@ -211,12 +258,16 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
let mut db_stats_exact_every = None;
let mut time_bin = default_time_bin();
let mut no_time_wrapper = false;
let mut dead_repo_blacklist: Option<PathBuf> = None;
let mut dead_repo_blacklist_fail_threshold: Option<u32> = None;
let mut dead_repo_health_check_interval_secs = 600u64;
let mut dead_repo_probe_rsync_bin = PathBuf::from("rsync");
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--" => {
let child_args = argv[i + 1..].to_vec();
let mut child_args = argv[i + 1..].to_vec();
let state_root =
state_root.ok_or_else(|| format!("--state-root is required\n\n{}", usage()))?;
let work_db = work_db.unwrap_or_else(|| state_root.join("state").join("work-db"));
@@ -225,6 +276,23 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
if no_time_wrapper {
time_bin = None;
}
// Dead-repo blacklist (#141): forward enablement to the child
// unless its args already carry the flag explicitly.
if let Some(path) = dead_repo_blacklist.as_ref() {
if !child_args.iter().any(|arg| arg == "--dead-repo-blacklist") {
child_args.push("--dead-repo-blacklist".to_string());
child_args.push(path_string(path));
}
if let Some(threshold) = dead_repo_blacklist_fail_threshold {
if !child_args
.iter()
.any(|arg| arg == "--dead-repo-blacklist-fail-threshold")
{
child_args.push("--dead-repo-blacklist-fail-threshold".to_string());
child_args.push(threshold.to_string());
}
}
}
let args = Args {
state_root,
rpki_bin: rpki_bin
@@ -240,6 +308,10 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
db_stats_bin,
db_stats_exact_every,
time_bin,
dead_repo_blacklist,
dead_repo_blacklist_fail_threshold,
dead_repo_health_check_interval_secs,
dead_repo_probe_rsync_bin,
child_args,
};
return validate_args(args);
@@ -315,6 +387,33 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
"--no-time-wrapper" => {
no_time_wrapper = true;
}
"--dead-repo-blacklist" => {
i += 1;
dead_repo_blacklist = Some(PathBuf::from(value_at(argv, i, "--dead-repo-blacklist")?));
}
"--dead-repo-blacklist-fail-threshold" => {
i += 1;
let parsed = parse_u64(
value_at(argv, i, "--dead-repo-blacklist-fail-threshold")?,
"--dead-repo-blacklist-fail-threshold",
)?;
if parsed == 0 || parsed > u32::MAX as u64 {
return Err("--dead-repo-blacklist-fail-threshold must be in 1..=u32::MAX".to_string());
}
dead_repo_blacklist_fail_threshold = Some(parsed as u32);
}
"--dead-repo-health-check-interval-secs" => {
i += 1;
dead_repo_health_check_interval_secs = parse_u64(
value_at(argv, i, "--dead-repo-health-check-interval-secs")?,
"--dead-repo-health-check-interval-secs",
)?;
}
"--dead-repo-probe-rsync-bin" => {
i += 1;
dead_repo_probe_rsync_bin =
PathBuf::from(value_at(argv, i, "--dead-repo-probe-rsync-bin")?);
}
other => return Err(format!("unknown argument: {other}\n\n{}", usage())),
}
i += 1;
@@ -330,6 +429,11 @@ fn validate_args(args: Args) -> Result<Args, String> {
usage()
));
}
if args.dead_repo_blacklist_fail_threshold.is_some() && args.dead_repo_blacklist.is_none() {
return Err(
"--dead-repo-blacklist-fail-threshold requires --dead-repo-blacklist".to_string(),
);
}
Ok(args)
}
@@ -387,10 +491,19 @@ fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> Result<(), String>
fs::create_dir_all(parent)
.map_err(|e| format!("create parent dir failed: {}: {e}", parent.display()))?;
}
let file =
File::create(path).map_err(|e| format!("create json failed: {}: {e}", path.display()))?;
serde_json::to_writer_pretty(file, value)
.map_err(|e| format!("write json failed: {}: {e}", path.display()))
let bytes = serde_json::to_vec_pretty(value)
.map_err(|e| format!("serialize json failed: {}: {e}", path.display()))?;
// Atomic write (tmp + rename) so concurrent readers never see a torn file.
let mut tmp_name = path
.file_name()
.map(|name| name.to_os_string())
.unwrap_or_default();
tmp_name.push(".tmp");
let tmp_path = path.with_file_name(tmp_name);
fs::write(&tmp_path, &bytes)
.map_err(|e| format!("write json tmp failed: {}: {e}", tmp_path.display()))?;
fs::rename(&tmp_path, path)
.map_err(|e| format!("rename json tmp failed: {} -> {}: {e}", tmp_path.display(), path.display()))
}
fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
@@ -409,12 +522,30 @@ fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String>
.map_err(|e| format!("flush jsonl failed: {}: {e}", path.display()))
}
fn dead_repo_blacklist_status(
args: &Args,
health: &DeadRepoHealthRuntime,
) -> Option<DeadRepoBlacklistStatus> {
let path = args.dead_repo_blacklist.as_ref()?;
let (blacklist, _) = DeadRepoBlacklist::load(path);
let last_health_check_at_rfc3339_utc = health
.last_health_check_at
.and_then(|t| format_rfc3339(t).ok());
Some(DeadRepoBlacklistStatus {
path: path_string(path),
entries: blacklist.len(),
blacklisted: blacklist.blacklisted_len(),
last_health_check_at_rfc3339_utc,
})
}
fn write_status(
args: &Args,
state: DaemonState,
runs_completed: u64,
current: Option<&RunContext>,
last_run_id: Option<String>,
health: &DeadRepoHealthRuntime,
) -> Result<(), String> {
let updated_at_rfc3339_utc = format_rfc3339(utc_now())?;
let status = DaemonStatus {
@@ -425,10 +556,131 @@ fn write_status(
current_run_seq: current.map(|ctx| ctx.seq),
current_run_id: current.map(|ctx| ctx.run_id.clone()),
last_run_id,
dead_repo_blacklist: dead_repo_blacklist_status(args, health),
};
write_json_pretty(&status_path(args), &status)
}
/// Short-timeout HTTP GET of the RRDP notification file. Any HTTP status
/// (even 4xx) proves the transport is alive; only transport errors keep the
/// entry blacklisted.
fn probe_rrdp_transport(uri: &str) -> bool {
let config = crate::fetch::http::HttpFetcherConfig {
connect_timeout: Duration::from_secs(3),
timeout: Duration::from_secs(6),
large_body_timeout: Duration::from_secs(6),
..Default::default()
};
let fetcher = match crate::fetch::http::BlockingHttpFetcher::new(config) {
Ok(fetcher) => fetcher,
Err(_) => return false,
};
match fetcher.fetch_bytes(uri) {
Ok(_) => true,
Err(err) => err.starts_with("http status"),
}
}
/// rsync list-only probe: connecting to the daemon and listing the module
/// root is enough to prove the transport is alive.
fn probe_rsync_transport(rsync_bin: &Path, base_uri: &str) -> bool {
Command::new(rsync_bin)
.arg("--contimeout=5")
.arg("--timeout=8")
.arg(base_uri)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
/// Probe due blacklist entries between runs (#141). Runs in the supervisor
/// loop while no child is active, preserving the single-writer assumption.
fn maybe_run_dead_repo_health_check(args: &Args, health: &mut DeadRepoHealthRuntime) {
let Some(path) = args.dead_repo_blacklist.clone() else {
return;
};
let now = utc_now();
if let Some(last) = health.last_health_check_at {
let elapsed = (now - last).whole_seconds();
if elapsed >= 0 && (elapsed as u64) < args.dead_repo_health_check_interval_secs {
return;
}
}
let (mut blacklist, warning) = DeadRepoBlacklist::load(&path);
if let Some(warning) = warning {
eprintln!("[dead-repo-health] {warning}");
}
let now_unix = now.unix_timestamp().max(0) as u64;
let due = blacklist.probe_due_entries(now_unix, args.dead_repo_health_check_interval_secs);
if due.is_empty() {
health.last_health_check_at = Some(now);
return;
}
// Probe in parallel: in the docker soak the daemon runs once per run
// (--max-runs 1), so a serial sweep of ~12 dead entries would add up to a
// minute of latency to every run.
let rsync_bin = args.dead_repo_probe_rsync_bin.clone();
let probe_results: Vec<(RepoTransportMode, String, bool)> = due
.iter()
.map(|entry| (entry.transport, entry.uri.clone()))
.collect::<Vec<_>>()
.into_iter()
.map(|(transport, uri)| {
let rsync_bin = rsync_bin.clone();
std::thread::spawn(move || {
let alive = match transport {
RepoTransportMode::Rrdp => probe_rrdp_transport(&uri),
RepoTransportMode::Rsync => probe_rsync_transport(&rsync_bin, &uri),
};
(transport, uri, alive)
})
})
.filter_map(|handle| handle.join().ok())
.collect();
let mut removed = 0usize;
let mut still_dead = 0usize;
for (transport, uri, alive) in &probe_results {
if *alive {
blacklist.record_probe_success(*transport, uri);
removed += 1;
eprintln!("[dead-repo-health] removed {} {} (probe ok)", transport.as_str(), uri);
crate::progress_log::emit(
"dead_repo_blacklist_remove",
serde_json::json!({
"transport": transport.as_str(),
"uri": uri,
"reason": "health_probe_ok",
}),
);
} else {
blacklist.record_probe_failure(*transport, uri, now_unix);
still_dead += 1;
eprintln!(
"[dead-repo-health] still dead {} {} (probe failed)",
transport.as_str(),
uri
);
}
}
if let Err(err) = blacklist.store_atomic(&path, now_unix) {
eprintln!(
"[dead-repo-health] persist blacklist failed: {}: {err}",
path.display()
);
}
crate::progress_log::emit(
"dead_repo_health_check",
serde_json::json!({
"probed": probe_results.len(),
"removed": removed,
"still_dead": still_dead,
}),
);
health.last_health_check_at = Some(now);
}
fn render_child_args(args: &[String], daemon_args: &Args, ctx: &RunContext) -> Vec<String> {
args.iter()
.map(|arg| {
@@ -634,6 +886,7 @@ fn run_child_once(args: &Args, ctx: &RunContext) -> Result<RunSummary, String> {
db_stats: Vec::new(),
retention_deleted_runs: Vec::new(),
artifacts,
dead_repo_blacklist: None,
};
Ok(summary)
}
@@ -1031,6 +1284,14 @@ fn collect_post_run_metrics(args: &Args, ctx: &RunContext, summary: &mut RunSumm
summary.path_stats = collect_state_path_stats(args, ctx);
summary.db_stats = collect_db_stats(args, ctx);
summary.artifacts = collect_artifacts(&ctx.run_dir).unwrap_or_default();
if let Some(path) = args.dead_repo_blacklist.as_ref() {
let (blacklist, _) = DeadRepoBlacklist::load(path);
summary.dead_repo_blacklist = Some(DeadRepoBlacklistRunSummary {
path: path_string(path),
entries: blacklist.len(),
blacklisted: blacklist.blacklisted_len(),
});
}
}
fn run_daemon(args: &Args) -> Result<(), String> {
@@ -1050,13 +1311,17 @@ fn run_daemon(args: &Args) -> Result<(), String> {
let mut runs_completed = 0u64;
let mut next_seq = 1u64;
let mut last_run_id = None;
let mut health = DeadRepoHealthRuntime::default();
write_status(
args,
DaemonState::Starting,
runs_completed,
None,
last_run_id.clone(),
&health,
)?;
// Probe once at startup so revived repos are removed before the first run.
maybe_run_dead_repo_health_check(args, &mut health);
loop {
if args.max_runs.is_some_and(|max| runs_completed >= max) {
@@ -1069,6 +1334,7 @@ fn run_daemon(args: &Args) -> Result<(), String> {
runs_completed,
None,
last_run_id.clone(),
&health,
)?;
let ctx = make_run_context(args, next_seq, utc_now());
write_status(
@@ -1077,6 +1343,7 @@ fn run_daemon(args: &Args) -> Result<(), String> {
runs_completed,
Some(&ctx),
last_run_id.clone(),
&health,
)?;
let mut summary = run_child_once(args, &ctx)?;
write_status(
@@ -1085,6 +1352,7 @@ fn run_daemon(args: &Args) -> Result<(), String> {
runs_completed,
Some(&ctx),
last_run_id.clone(),
&health,
)?;
collect_post_run_metrics(args, &ctx, &mut summary);
let removed = apply_retention(&args.state_root.join("runs"), args.retain_runs)?;
@@ -1099,19 +1367,29 @@ fn run_daemon(args: &Args) -> Result<(), String> {
if args.max_runs.is_some_and(|max| runs_completed >= max) {
break;
}
// Health probes run only while no child is active (single-writer).
maybe_run_dead_repo_health_check(args, &mut health);
write_status(
args,
DaemonState::Sleeping,
runs_completed,
None,
last_run_id.clone(),
&health,
)?;
if args.interval_secs > 0 {
std::thread::sleep(Duration::from_secs(args.interval_secs));
}
}
write_status(args, DaemonState::Exited, runs_completed, None, last_run_id)
write_status(
args,
DaemonState::Exited,
runs_completed,
None,
last_run_id,
&health,
)
}
pub fn main_entry() -> i32 {
@@ -1154,6 +1432,10 @@ mod tests {
db_stats_bin: None,
db_stats_exact_every: None,
time_bin: None,
dead_repo_blacklist: None,
dead_repo_blacklist_fail_threshold: None,
dead_repo_health_check_interval_secs: 600,
dead_repo_probe_rsync_bin: PathBuf::from("rsync"),
child_args: vec!["--version".to_string()],
}
}
@@ -1330,6 +1612,10 @@ mod tests {
db_stats_bin: None,
db_stats_exact_every: None,
time_bin: None,
dead_repo_blacklist: None,
dead_repo_blacklist_fail_threshold: None,
dead_repo_health_check_interval_secs: 600,
dead_repo_probe_rsync_bin: PathBuf::from("rsync"),
child_args: vec![
"{state_root}/state/work-db".to_string(),
"{run_out}/result.ccr".to_string(),
@@ -1652,6 +1938,10 @@ mod tests {
db_stats_bin: None,
db_stats_exact_every: None,
time_bin: None,
dead_repo_blacklist: None,
dead_repo_blacklist_fail_threshold: None,
dead_repo_health_check_interval_secs: 600,
dead_repo_probe_rsync_bin: PathBuf::from("rsync"),
child_args: vec![
"-c".to_string(),
"echo stdout-{run_seq}; echo stderr-{run_seq} >&2; echo marker > {run_out}/marker.txt".to_string(),
@@ -1743,6 +2033,10 @@ mod tests {
db_stats_bin: Some(db_stats_bin),
db_stats_exact_every: Some(1),
time_bin: None,
dead_repo_blacklist: None,
dead_repo_blacklist_fail_threshold: None,
dead_repo_health_check_interval_secs: 600,
dead_repo_probe_rsync_bin: PathBuf::from("rsync"),
child_args: vec![
"-c".to_string(),
"mkdir -p {state_root}/state/work-db {state_root}/state/repo-bytes.db; \
@@ -1781,4 +2075,130 @@ mod tests {
.any(|item| item["label"] == "work_db" && item["exists"] == true)
);
}
#[test]
fn dead_repo_blacklist_flag_is_injected_into_child_args() {
let argv = vec![
"rpki_daemon".to_string(),
"--state-root".to_string(),
"/tmp/daemon".to_string(),
"--rpki-bin".to_string(),
"/bin/echo".to_string(),
"--dead-repo-blacklist".to_string(),
"/tmp/daemon/state/dead-repo-blacklist.json".to_string(),
"--dead-repo-blacklist-fail-threshold".to_string(),
"2".to_string(),
"--".to_string(),
"--db".to_string(),
"{state_root}/state/work-db".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(
args.child_args,
vec![
"--db".to_string(),
"{state_root}/state/work-db".to_string(),
"--dead-repo-blacklist".to_string(),
"/tmp/daemon/state/dead-repo-blacklist.json".to_string(),
"--dead-repo-blacklist-fail-threshold".to_string(),
"2".to_string(),
]
);
}
#[test]
fn dead_repo_blacklist_injection_respects_existing_child_flags() {
let argv = vec![
"rpki_daemon".to_string(),
"--state-root".to_string(),
"/tmp/daemon".to_string(),
"--rpki-bin".to_string(),
"/bin/echo".to_string(),
"--dead-repo-blacklist".to_string(),
"/tmp/daemon/state/dead-repo-blacklist.json".to_string(),
"--".to_string(),
"--dead-repo-blacklist".to_string(),
"/custom/path.json".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(
args.child_args,
vec![
"--dead-repo-blacklist".to_string(),
"/custom/path.json".to_string(),
]
);
}
#[test]
fn dead_repo_threshold_without_blacklist_is_rejected() {
let argv = vec![
"rpki_daemon".to_string(),
"--state-root".to_string(),
"/tmp/daemon".to_string(),
"--rpki-bin".to_string(),
"/bin/echo".to_string(),
"--dead-repo-blacklist-fail-threshold".to_string(),
"2".to_string(),
"--".to_string(),
"--version".to_string(),
];
let err = parse_args(&argv).expect_err("parse should fail");
assert!(err.contains("requires --dead-repo-blacklist"), "{err}");
}
#[test]
fn health_check_keeps_dead_entry_and_removes_revived_entry() {
use std::io::{Read as _, Write as _};
use std::net::TcpListener;
// Revived RRDP host: one-shot HTTP server answering 200.
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("local addr").port();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let body = b"<notification/>";
let _ = stream.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.as_bytes(),
);
let _ = stream.write_all(body);
}
});
let td = tempfile::tempdir().expect("tempdir");
let blacklist_path = td.path().join("dead-repo-blacklist.json");
let revived_uri = format!("http://127.0.0.1:{port}/notification.xml");
let dead_uri = "http://127.0.0.1:1/notification.xml".to_string();
let mut blacklist = DeadRepoBlacklist::new();
for uri in [&revived_uri, &dead_uri] {
blacklist.record_transport_failure(RepoTransportMode::Rrdp, uri, 100, 1, 256);
}
blacklist
.store_atomic(&blacklist_path, 100)
.expect("store blacklist");
let mut args = test_args(td.path().join("daemon"));
args.dead_repo_blacklist = Some(blacklist_path.clone());
args.dead_repo_health_check_interval_secs = 0;
let mut health = DeadRepoHealthRuntime::default();
maybe_run_dead_repo_health_check(&args, &mut health);
let (after, warning) = DeadRepoBlacklist::load(&blacklist_path);
assert!(warning.is_none());
assert!(!after.is_blacklisted(RepoTransportMode::Rrdp, &revived_uri));
assert!(after.is_blacklisted(RepoTransportMode::Rrdp, &dead_uri));
assert!(health.last_health_check_at.is_some());
// Status JSON exposes the blacklist section.
let status = dead_repo_blacklist_status(&args, &health).expect("status");
assert_eq!(status.entries, 1);
assert_eq!(status.blacklisted, 1);
assert!(status.last_health_check_at_rfc3339_utc.is_some());
}
}

View File

@@ -485,9 +485,17 @@ fn verify_child_signature(
child: &X509Certificate<'_>,
issuer_spki: &SubjectPublicKeyInfo<'_>,
) -> Result<(), CaPathError> {
child
.verify_signature(Some(issuer_spki))
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))
crate::crypto_sig_cache::verify_with_cache(
crate::crypto_sig_cache::CryptoSigVerifyPoint::ChildCaCert,
child.tbs_certificate.as_ref(),
child.signature_value.data.as_ref(),
issuer_spki.raw,
|| {
child
.verify_signature(Some(issuer_spki))
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))
},
)
}
fn validate_child_aki_matches_issuer_ski(
@@ -2118,6 +2126,53 @@ mod tests {
);
}
#[test]
fn signature_cache_stores_real_verify_success_and_skips_on_hit() {
let _guard = crate::crypto_sig_cache::GLOBAL_TEST_LOCK.lock().expect("test lock");
crate::crypto_sig_cache::clear_global();
let td = tempfile::tempdir().expect("tempdir");
let (issuer, child, other) = gen_issuer_and_child_der(td.path());
let issuer_cert = parse_x509_cert(&issuer).expect("x509 parse issuer");
let child_cert = parse_x509_cert(&child).expect("x509 parse child");
let other_cert = parse_x509_cert(&other).expect("x509 parse other");
let cache_dir = tempfile::tempdir().expect("tempdir");
let cache = std::sync::Arc::new(crate::crypto_sig_cache::CryptoSigCache::load_or_rebuild(
cache_dir.path().join("work-db.crypto-sig-cache"),
true,
));
crate::crypto_sig_cache::install_global(std::sync::Arc::clone(&cache));
// Miss -> real verification runs and passes -> positive conclusion written.
verify_child_signature(&child_cert, &issuer_cert.tbs_certificate.subject_pki)
.expect("signature ok");
// Hit -> skipped (a second execution would also pass, so assert via stats).
verify_child_signature(&child_cert, &issuer_cert.tbs_certificate.subject_pki)
.expect("signature ok");
// Wrong issuer: real verification fails, nothing is written (no negative caching),
// so a retry executes the real verification again instead of inheriting anything.
for _ in 0..2 {
let err = verify_child_signature(&child_cert, &other_cert.tbs_certificate.subject_pki)
.unwrap_err();
assert!(
matches!(err, CaPathError::ChildSignatureInvalid(_)),
"{err}"
);
}
let summary = cache.summary();
let stats = summary
.per_point
.get("child_ca_cert")
.expect("child_ca_cert stats");
assert_eq!(stats.calls, 4);
assert_eq!(stats.would_hit, 1);
assert_eq!(stats.new_keys, 3);
assert_eq!(stats.verify_executed, 3);
assert_eq!(stats.verify_skipped, 1);
crate::crypto_sig_cache::clear_global();
}
#[test]
fn issuer_effective_resources_index_and_indexed_resolvers_cover_success_and_failure_paths() {
use crate::data_model::rc::{AsIdOrRange, IpPrefix};

View File

@@ -385,8 +385,16 @@ fn verify_ee_cert_signature(
ee: &X509Certificate<'_>,
issuer_spki: &SubjectPublicKeyInfo<'_>,
) -> Result<(), CertPathError> {
ee.verify_signature(Some(issuer_spki))
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
crate::crypto_sig_cache::verify_with_cache(
crate::crypto_sig_cache::CryptoSigVerifyPoint::EeCertX509,
ee.tbs_certificate.as_ref(),
ee.signature_value.data.as_ref(),
issuer_spki.raw,
|| {
ee.verify_signature(Some(issuer_spki))
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
},
)
}
fn verify_ee_cert_signature_fast(
@@ -394,12 +402,20 @@ fn verify_ee_cert_signature_fast(
ee_signature_bytes: &[u8],
issuer_spki: &SubjectPublicKeyInfo<'_>,
) -> Result<(), CertPathError> {
let key = signature::UnparsedPublicKey::new(
&signature::RSA_PKCS1_2048_8192_SHA256,
&issuer_spki.subject_public_key.data,
);
key.verify(ee_tbs_der, ee_signature_bytes)
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
crate::crypto_sig_cache::verify_with_cache(
crate::crypto_sig_cache::CryptoSigVerifyPoint::EeCertFast,
ee_tbs_der,
ee_signature_bytes,
issuer_spki.raw,
|| {
let key = signature::UnparsedPublicKey::new(
&signature::RSA_PKCS1_2048_8192_SHA256,
&issuer_spki.subject_public_key.data,
);
key.verify(ee_tbs_der, ee_signature_bytes)
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
},
)
}
fn validate_ee_aki_matches_issuer_ski(
@@ -720,4 +736,145 @@ mod tests {
validate_ee_crldp_contains_issuer_crl_uri(&ee, "rsync://example.test/issuer.crl")
.expect("crldp ok");
}
#[test]
fn signature_cache_hit_does_not_skip_crl_revocation_check() {
use crate::crypto_sig_cache::{
CryptoSigCache, CryptoSigVerifyPoint, compute_cache_key,
};
use crate::data_model::common::{Asn1TimeEncoding, Asn1TimeUtc};
use crate::data_model::crl::CrlExtensions;
let _guard = crate::crypto_sig_cache::GLOBAL_TEST_LOCK.lock().expect("test lock");
crate::crypto_sig_cache::clear_global();
let now = time::OffsetDateTime::now_utc();
let mut issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
issuer.tbs.validity_not_before = now - time::Duration::days(1);
issuer.tbs.validity_not_after = now + time::Duration::days(1);
let mut ee_rc = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
ee_rc.tbs.validity_not_before = now - time::Duration::days(1);
ee_rc.tbs.validity_not_after = now + time::Duration::days(1);
// Tampered "object": these bytes could never pass the real RSA verification, so an
// Ok outcome below proves the crypto step was skipped by the cache hit.
let ee = ResourceEeCertificate {
raw_der: Vec::new(),
subject_key_identifier: vec![2],
spki_der: Vec::new(),
rsa_public_modulus: Vec::new(),
rsa_public_exponent: Vec::new(),
tbs_certificate_der: b"tampered-tbs-bytes".to_vec(),
signature_bytes: b"tampered-signature-bytes".to_vec(),
key_usage_summary: EeKeyUsageSummary::DigitalSignatureOnly,
sia_signed_object_uris: Vec::new(),
resource_cert: ee_rc,
};
// Any real RSA SPKI works as the verifier identity (the TA fixture is a valid RSA
// certificate); the SPKI DER only feeds the cache key here.
let ta_der = std::fs::read(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/ta/apnic-ta.cer"
))
.expect("read ta fixture");
let (_, ta_x509) = X509Certificate::from_der(&ta_der).expect("parse ta fixture");
let issuer_spki = &ta_x509.tbs_certificate.subject_pki;
let crl = RpkixCrl {
raw_der: Vec::new(),
version: 2,
issuer_dn: "CN=issuer".to_string(),
signature_algorithm_oid: "1.2.840.113549.1.1.11".to_string(),
this_update: Asn1TimeUtc {
utc: now - time::Duration::hours(1),
encoding: Asn1TimeEncoding::UtcTime,
},
next_update: Asn1TimeUtc {
utc: now + time::Duration::hours(1),
encoding: Asn1TimeEncoding::UtcTime,
},
revoked_certs: Vec::new(),
extensions: CrlExtensions {
authority_key_identifier: vec![1],
crl_number: BigUnsigned { bytes_be: vec![1] },
},
};
let dir = tempfile::tempdir().expect("tempdir");
let cache = std::sync::Arc::new(CryptoSigCache::load_or_rebuild(
dir.path().join("work-db.crypto-sig-cache"),
true,
));
cache.insert_positive(compute_cache_key(
CryptoSigVerifyPoint::EeCertFast,
b"tampered-tbs-bytes",
b"tampered-signature-bytes",
issuer_spki.raw,
));
crate::crypto_sig_cache::install_global(std::sync::Arc::clone(&cache));
// EE serial is 1 (dummy_cert); revoke it. The revocation check runs before the
// signature step in this code path, so the result must be EeRevoked — never Ok —
// even though a positive signature conclusion is cached.
let revoked_serials: HashSet<Vec<u8>> = HashSet::from([vec![1u8]]);
let err = validate_signed_object_ee_cert_path_fast(
&ee,
&issuer,
issuer_spki,
&crl,
&revoked_serials,
Some("rsync://example.test/issuer.cer"),
Some("rsync://example.test/issuer.crl"),
now,
)
.unwrap_err();
assert!(
matches!(err, CertPathError::EeRevoked),
"cached signature conclusion must not bypass revocation: {err}"
);
// Not revoked: the cached positive conclusion lets validation pass even though the
// tampered bytes could never verify — proving the crypto step was skipped.
let not_revoked: HashSet<Vec<u8>> = HashSet::new();
validate_signed_object_ee_cert_path_fast(
&ee,
&issuer,
issuer_spki,
&crl,
&not_revoked,
Some("rsync://example.test/issuer.cer"),
Some("rsync://example.test/issuer.crl"),
now,
)
.expect("cache hit skips the impossible verification");
let summary = cache.summary();
let stats = summary
.per_point
.get("ee_cert_fast")
.expect("ee_cert_fast stats");
assert_eq!(stats.verify_skipped, 1);
assert_eq!(
stats.verify_executed, 0,
"real verification must never run on cache hits"
);
crate::crypto_sig_cache::clear_global();
}
}

View File

@@ -135,6 +135,11 @@ impl PublicationPointData for PublicationPointSnapshot {
#[derive(Debug, thiserror::Error)]
pub enum ManifestFreshError {
#[error(
"verification-only fallback replay selected current-instance VCIR: {manifest_rsync_uri}"
)]
VerificationOnlyForcedCurrentInstanceVcir { manifest_rsync_uri: String },
#[error("repo sync failed: {detail} (RFC 8182 §3.4.5; RFC 9286 §6.6)")]
RepoSyncFailed { detail: String },
@@ -222,7 +227,8 @@ impl ManifestFreshError {
pub(crate) fn should_warn_when_current_instance_reused(&self) -> bool {
!matches!(
self,
ManifestFreshError::RepoSyncFailed { .. }
ManifestFreshError::VerificationOnlyForcedCurrentInstanceVcir { .. }
| ManifestFreshError::RepoSyncFailed { .. }
| ManifestFreshError::MissingManifest { .. }
| ManifestFreshError::MissingFile { .. }
)
@@ -633,7 +639,7 @@ pub(crate) fn try_build_fresh_publication_point_with_timing(
> {
let mut timing = FreshPublicationPointTimingBreakdown::default();
let current_index_lock_started = std::time::Instant::now();
let current_index_guard = current_repo_index.and_then(|handle| handle.lock().ok());
let current_index_guard = current_repo_index.and_then(|handle| handle.read().ok());
timing.current_index_lock_ms = current_index_lock_started.elapsed().as_millis() as u64;
if !rsync_uri_is_under_publication_point(manifest_rsync_uri, publication_point_rsync_uri) {
@@ -1199,6 +1205,7 @@ mod tests {
sha256: manifest_sha256.to_string(),
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
},
VcirRelatedArtifact {
artifact_role: VcirArtifactRole::SignedObject,
@@ -1207,6 +1214,7 @@ mod tests {
sha256: locked_object_sha256.to_string(),
object_type: Some("roa".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
},
],
summary: VcirSummary {
@@ -1437,8 +1445,8 @@ mod tests {
}
current_index
.lock()
.expect("index lock")
.write()
.expect("index write lock")
.apply_repository_view_entries(&entries)
.expect("apply current index");

View File

@@ -8,13 +8,13 @@ use crate::data_model::rc::{
ResourceCertificate,
};
use crate::data_model::roa::{IpPrefix, RoaAfi, RoaDecodeError, RoaObject, RoaValidateError};
use crate::data_model::signed_object::SignedObjectVerifyError;
use crate::data_model::signed_object::{RpkiSignedObject, SignedObjectVerifyError};
use crate::parallel::config::ParallelPhase2Config;
use crate::parallel::object_worker::{
ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError,
};
use crate::policy::{Policy, ResourceValidationMode, SignedObjectFailurePolicy};
use crate::report::{RfcRef, Warning};
use crate::report::{RfcRef, Warning, WarningCategory};
use crate::storage::{
PackFile, PackTime, RoaCacheObjectMeta, RoaCacheProjection, VcirLocalOutput,
VcirLocalOutputPayload, VcirOutputType, VcirSourceObjectType,
@@ -34,6 +34,22 @@ const RFC_CRLDP: &[RfcRef] = &[RfcRef("RFC 6487 §4.8.6")];
const RFC_CRLDP_AND_LOCKED_PACK: &[RfcRef] =
&[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §4.2.1")];
fn ber_compatible_cms_warning(der: &[u8], rsync_uri: &str, object_kind: &str) -> Option<Warning> {
let strict_error = RpkiSignedObject::strict_cms_der_error(der)?;
Some(
Warning::new(format!(
"accepted BER-compatible CMS encoding for {object_kind}: {rsync_uri}: {strict_error}"
))
.with_category(WarningCategory::BerCompatibleCmsEncoding)
.with_rfc_refs(&[RfcRef("X.690 §10"), RfcRef("RFC 6488 §2")])
.with_context(rsync_uri),
)
}
fn ber_compatible_cms_warning_for_file(file: &PackFile, object_kind: &str) -> Option<Warning> {
ber_compatible_cms_warning(file.bytes().ok()?, &file.rsync_uri, object_kind)
}
fn sha256_hex_to_32(hex_value: &str) -> [u8; 32] {
let bytes = hex::decode(hex_value).expect("internal sha256 hex should decode");
let mut out = [0u8; 32];
@@ -945,6 +961,38 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
timing: Option<&TimingHandle>,
collect_vcir_local_outputs: bool,
roa_cache: RoaValidationCacheInput<'_>,
) -> ObjectsOutput {
process_publication_point_for_issuer_with_cache_options_and_ta_constraints(
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
timing,
collect_vcir_local_outputs,
roa_cache,
None,
)
}
/// Serial signed-object processing with an optional, locally configured
/// constraint set for the TA that owns this publication-point tree.
pub fn process_publication_point_for_issuer_with_cache_options_and_ta_constraints<
P: PublicationPointData,
>(
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>,
collect_vcir_local_outputs: bool,
roa_cache: RoaValidationCacheInput<'_>,
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
) -> ObjectsOutput {
let manifest_rsync_uri = publication_point.manifest_rsync_uri();
let manifest_bytes = publication_point.manifest_bytes();
@@ -1015,6 +1063,12 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
}
};
if let Some(warning) =
ber_compatible_cms_warning(manifest_bytes, manifest_rsync_uri, "manifest")
{
warnings.push(warning);
}
// Decode issuer CA once; if it fails we cannot validate ROA/ASPA EE certificates.
let issuer_ca = match decode_resource_certificate_with_policy(issuer_ca_der, policy) {
Ok(v) => v,
@@ -1249,6 +1303,7 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
policy.strict.cms_der,
policy.strict.name,
policy.resource_validation_mode,
ta_constraints,
)
}
blocked @ (RoaCacheLookupResult::HashBlocked
@@ -1275,6 +1330,7 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
policy.strict.cms_der,
policy.strict.name,
policy.resource_validation_mode,
ta_constraints,
)
}
}
@@ -1298,6 +1354,7 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
policy.strict.cms_der,
policy.strict.name,
policy.resource_validation_mode,
ta_constraints,
)
};
match result.outcome {
@@ -1317,6 +1374,9 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
result: AuditObjectResult::Ok,
detail: None,
});
if let Some(warning) = ber_compatible_cms_warning_for_file(file, "ROA") {
warnings.push(warning);
}
}
Err(e) => match policy.signed_object_failure_policy {
SignedObjectFailurePolicy::DropObject => {
@@ -1412,6 +1472,7 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
policy.strict.cms_der,
policy.strict.name,
policy.resource_validation_mode,
ta_constraints,
) {
Ok((att, local_output)) => {
stats.aspa_ok += 1;
@@ -1426,6 +1487,9 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
result: AuditObjectResult::Ok,
detail: None,
});
if let Some(warning) = ber_compatible_cms_warning_for_file(file, "ASPA") {
warnings.push(warning);
}
}
Err(e) => match policy.signed_object_failure_policy {
SignedObjectFailurePolicy::DropObject => {
@@ -1762,6 +1826,10 @@ pub(crate) struct RoaTaskShared {
issuer_effective_ip: Option<Arc<crate::data_model::rc::IpResourceSet>>,
issuer_effective_as: Option<Arc<crate::data_model::rc::AsResourceSet>>,
resource_validation_mode: ResourceValidationMode,
/// Immutable constraints snapshot selected by the owning TAL. Every
/// ROA task for a publication point shares this Arc, so workers do not
/// need a mutable/global policy lookup or a copy of the interval rules.
ta_constraints: Option<Arc<crate::ta_constraints::TaConstraints>>,
}
#[derive(Clone)]
@@ -1883,6 +1951,7 @@ fn validate_owned_roa_task(worker_index: usize, task: OwnedRoaTask) -> RoaTaskRe
task.strict_cms_der,
task.strict_name,
task.resource_validation_mode,
shared.ta_constraints.as_deref(),
)
.map(|(vrps, local_outputs, cache_object_meta)| RoaTaskOk {
vrps,
@@ -1981,6 +2050,40 @@ pub(crate) fn prepare_publication_point_for_parallel_roa_with_cache<P: Publicati
validation_time: time::OffsetDateTime,
collect_vcir_local_outputs: bool,
roa_cache: RoaValidationCacheInput<'_>,
) -> ParallelObjectsPrepare {
prepare_publication_point_for_parallel_roa_with_cache_and_ta_constraints(
publication_point_id,
publication_point,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
collect_vcir_local_outputs,
roa_cache,
None,
)
}
/// Prepare a publication point for parallel ROA validation with the
/// immutable constraints snapshot belonging to its TAL. The snapshot is
/// moved into the stage-owned shared payload and therefore remains available
/// to detached ROA workers after the scoped phase-2 stage worker returns.
pub(crate) fn prepare_publication_point_for_parallel_roa_with_cache_and_ta_constraints<
P: PublicationPointData,
>(
publication_point_id: u64,
publication_point: &P,
policy: &Policy,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
validation_time: time::OffsetDateTime,
collect_vcir_local_outputs: bool,
roa_cache: RoaValidationCacheInput<'_>,
ta_constraints: Option<Arc<crate::ta_constraints::TaConstraints>>,
) -> ParallelObjectsPrepare {
let manifest_rsync_uri = publication_point.manifest_rsync_uri();
let manifest_bytes = publication_point.manifest_bytes();
@@ -2050,6 +2153,12 @@ pub(crate) fn prepare_publication_point_for_parallel_roa_with_cache<P: Publicati
}
};
if let Some(warning) =
ber_compatible_cms_warning(manifest_bytes, manifest_rsync_uri, "manifest")
{
warnings.push(warning);
}
let issuer_ca = match decode_resource_certificate_with_policy(issuer_ca_der, policy) {
Ok(v) => v,
Err(e) => {
@@ -2289,6 +2398,7 @@ pub(crate) fn prepare_publication_point_for_parallel_roa_with_cache<P: Publicati
issuer_effective_ip: issuer_effective_ip.cloned().map(Arc::new),
issuer_effective_as: issuer_effective_as.cloned().map(Arc::new),
resource_validation_mode: policy.resource_validation_mode,
ta_constraints,
}),
validation_time,
collect_vcir_local_outputs,
@@ -2367,6 +2477,9 @@ pub(crate) fn reduce_parallel_roa_stage(
result: AuditObjectResult::Ok,
detail: None,
});
if let Some(warning) = ber_compatible_cms_warning_for_file(file, "ROA") {
warnings.push(warning);
}
}
Err(e) => {
audit.push(ObjectAuditEntry {
@@ -2404,6 +2517,7 @@ pub(crate) fn reduce_parallel_roa_stage(
strict_cms_der,
strict_name,
shared.resource_validation_mode,
shared.ta_constraints.as_deref(),
) {
Ok((att, local_output)) => {
stats.aspa_ok += 1;
@@ -2418,6 +2532,9 @@ pub(crate) fn reduce_parallel_roa_stage(
result: AuditObjectResult::Ok,
detail: None,
});
if let Some(warning) = ber_compatible_cms_warning_for_file(file, "ASPA") {
warnings.push(warning);
}
}
Err(e) => {
audit.push(ObjectAuditEntry {
@@ -2620,6 +2737,9 @@ pub(crate) enum ObjectValidateError {
"EE certificate resources are not a subset of issuer effective resources (RFC 6487 §7.2; RFC 3779)"
)]
EeResourcesNotSubset,
#[error("EE certificate violates locally configured TA constraints: {0}")]
TaConstraints(#[from] crate::ta_constraints::TaConstraintsViolation),
}
pub(crate) fn validate_roa_task_serial(
@@ -2639,6 +2759,7 @@ pub(crate) fn validate_roa_task_serial(
strict_cms_der: bool,
strict_name: bool,
resource_validation_mode: ResourceValidationMode,
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
) -> RoaTaskResult {
let outcome = process_roa_with_issuer(
task.file,
@@ -2657,6 +2778,7 @@ pub(crate) fn validate_roa_task_serial(
strict_cms_der,
strict_name,
resource_validation_mode,
ta_constraints,
)
.map(|(vrps, local_outputs, cache_object_meta)| RoaTaskOk {
vrps,
@@ -2692,6 +2814,7 @@ fn process_roa_with_issuer(
strict_cms_der: bool,
strict_name: bool,
resource_validation_mode: ResourceValidationMode,
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
) -> Result<(Vec<Vrp>, Vec<VcirLocalOutput>, Option<RoaCacheObjectMeta>), ObjectValidateError> {
let _decode = timing
.as_ref()
@@ -2752,6 +2875,10 @@ fn process_roa_with_issuer(
)?;
drop(_subset);
if let Some(ta_constraints) = ta_constraints {
ta_constraints.validate_ee_certificate(&ee.resource_cert)?;
}
let vrps = roa_to_vrps_with_vrs(&roa, ee_vrs.ip.as_ref())?;
let cache_object_meta = RoaCacheObjectMeta {
source_object_uri: file.rsync_uri.clone(),
@@ -2821,6 +2948,7 @@ fn process_roa_with_issuer_parallel_cached(
strict_cms_der: bool,
strict_name: bool,
resource_validation_mode: ResourceValidationMode,
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
) -> Result<(Vec<Vrp>, Vec<VcirLocalOutput>, Option<RoaCacheObjectMeta>), ObjectValidateError> {
let _decode = timing
.as_ref()
@@ -2887,6 +3015,10 @@ fn process_roa_with_issuer_parallel_cached(
)?;
drop(_subset);
if let Some(ta_constraints) = ta_constraints {
ta_constraints.validate_ee_certificate(&ee.resource_cert)?;
}
let vrps = roa_to_vrps_with_vrs(&roa, ee_vrs.ip.as_ref())?;
let cache_object_meta = RoaCacheObjectMeta {
source_object_uri: file.rsync_uri.clone(),
@@ -2956,6 +3088,7 @@ fn process_aspa_with_issuer(
strict_cms_der: bool,
strict_name: bool,
resource_validation_mode: ResourceValidationMode,
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
) -> Result<(AspaAttestation, Option<VcirLocalOutput>), ObjectValidateError> {
let _decode = timing
.as_ref()
@@ -3016,6 +3149,10 @@ fn process_aspa_with_issuer(
)?;
drop(_subset);
if let Some(ta_constraints) = ta_constraints {
ta_constraints.validate_ee_certificate(&ee.resource_cert)?;
}
validate_aspa_customer_in_vrs(&aspa, ee_vrs.asn.as_ref())?;
let attestation = AspaAttestation {
@@ -4023,6 +4160,7 @@ mod tests {
sha256: sha256_hex(issuer_der),
object_type: Some("cer".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
},
VcirRelatedArtifact {
artifact_role: VcirArtifactRole::CurrentCrl,
@@ -4031,6 +4169,7 @@ mod tests {
sha256: sha256_hex_from_32(&crl_hash),
object_type: Some("crl".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
},
],
summary: VcirSummary {
@@ -4543,6 +4682,7 @@ mod tests {
sha256: "00".repeat(32),
object_type: Some("cer".to_string()),
validation_status: VcirArtifactValidationStatus::Rejected,
reject_reason: None,
},
);
vcir.local_outputs.push(VcirLocalOutput {
@@ -5462,6 +5602,13 @@ mod tests {
#[test]
fn parallel_stage_roa_tasks_share_stage_owned_payloads() {
let ta_constraints = Arc::new(
crate::ta_constraints::TaConstraints::from_file(
&std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/ta_constraints/afrinic-current-ipv4-deny.constraints"),
)
.expect("load constraints fixture"),
);
let stage = ParallelObjectsStage {
publication_point_id: 7,
shared: Arc::new(RoaTaskShared {
@@ -5492,6 +5639,7 @@ mod tests {
issuer_effective_ip: None,
issuer_effective_as: None,
resource_validation_mode: ResourceValidationMode::default(),
ta_constraints: Some(Arc::clone(&ta_constraints)),
}),
validation_time: OffsetDateTime::now_utc(),
collect_vcir_local_outputs: false,
@@ -5511,6 +5659,14 @@ mod tests {
let tasks = stage.build_roa_tasks();
assert_eq!(tasks.len(), 2);
assert!(Arc::ptr_eq(&tasks[0].shared, &tasks[1].shared));
assert!(Arc::ptr_eq(
tasks[0]
.shared
.ta_constraints
.as_ref()
.expect("task constraints snapshot"),
&ta_constraints
));
}
#[test]

View File

@@ -119,7 +119,7 @@ fn snapshot_current_repo_objects(
return Vec::new();
}
current_repo_index
.and_then(|handle| handle.lock().ok().map(|idx| idx.snapshot_objects()))
.and_then(|handle| handle.read().ok().map(|idx| idx.snapshot_objects()))
.unwrap_or_default()
}
@@ -328,20 +328,73 @@ fn persist_transport_request_prefetch_snapshot(
}
let snapshot = runtime.transport_prefetch_snapshot();
let recorded = snapshot.requests.len() as u64;
let persist_started = std::time::Instant::now();
store
.put_transport_prefetch_snapshot(&snapshot)
.map_err(|err| TreeRunError::Runner(err.to_string()))?;
let persist_ms = persist_started.elapsed().as_millis() as u64;
record_transport_prefetch_count(timing, "transport_prefetch_recorded_requests", recorded);
crate::progress_log::emit(
"phase1_repo_prefetch_persisted",
serde_json::json!({
"recorded_requests": recorded,
"schema_version": snapshot.schema_version,
"persist_ms": persist_ms,
}),
);
Ok(())
}
/// Persist the dead-repo blacklist working copy at run end (#141). Failures
/// degrade to a progress event; a broken blacklist file must never fail a run.
fn persist_dead_repo_blacklist(
runtime: &Arc<dyn RepoSyncRuntime>,
timing: Option<&TimingHandle>,
) -> Result<(), RunTreeFromTalError> {
let Some((blacklist_config, blacklist)) = runtime.dead_repo_blacklist_state() else {
return Ok(());
};
let persist_started = std::time::Instant::now();
let now_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
match blacklist.store_atomic(&blacklist_config.path, now_unix) {
Ok(()) => {
let persist_ms = persist_started.elapsed().as_millis() as u64;
record_transport_prefetch_count(
timing,
"dead_repo_blacklist_entries",
blacklist.len() as u64,
);
record_transport_prefetch_count(
timing,
"dead_repo_blacklist_blacklisted",
blacklist.blacklisted_len() as u64,
);
crate::progress_log::emit(
"dead_repo_blacklist_persisted",
serde_json::json!({
"path": blacklist_config.path.display().to_string(),
"entries": blacklist.len(),
"blacklisted": blacklist.blacklisted_len(),
"persist_ms": persist_ms,
}),
);
}
Err(err) => {
crate::progress_log::emit(
"dead_repo_blacklist_persist_error",
serde_json::json!({
"path": blacklist_config.path.display().to_string(),
"error": err.to_string(),
}),
);
}
}
Ok(())
}
fn root_discovery_from_tal_input(
tal_input: &TalInputSpec,
http_fetcher: &dyn Fetcher,
@@ -810,6 +863,7 @@ where
run_tree_serial_audit(root, &runner, config)?
};
persist_transport_request_prefetch_snapshot(store.as_ref(), &runtime, config, timing.as_ref())?;
persist_dead_repo_blacklist(&runtime, timing.as_ref())?;
let downloads = download_log.snapshot_events();
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
Ok(RunTreeFromTalAuditOutput {
@@ -847,6 +901,10 @@ where
H: Fetcher + Clone + 'static,
R: crate::fetch::rsync::RsyncFetcher + Clone + 'static,
{
// Constraints are an immutable per-run policy snapshot. The phase-2
// ready-stage binds the snapshot to each CA's TAL and moves an Arc into
// ROA/ASPA worker state, so constrained multi-TAL runs retain the same
// parallel scheduler as unconstrained runs.
let phase2_enabled = phase2_config.is_some();
if tal_inputs.is_empty() {
return Err(RunTreeFromTalError::Replay(
@@ -933,6 +991,7 @@ where
run_tree_serial_audit_multi_root(root_handles, &runner, config)?
};
persist_transport_request_prefetch_snapshot(store.as_ref(), &runtime, config, timing.as_ref())?;
persist_dead_repo_blacklist(&runtime, timing.as_ref())?;
let downloads = download_log.snapshot_events();
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
Ok(RunTreeFromTalAuditOutput {
@@ -2392,8 +2451,8 @@ mod multi_tal_tests {
fn snapshot_current_repo_objects_is_on_demand() {
let handle = CurrentRepoIndex::shared();
handle
.lock()
.expect("lock index")
.write()
.expect("write-lock index")
.apply_repository_view_entries(&[RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
current_hash: Some("11".repeat(32)),

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,7 @@ use crate::validation::objects::{
AspaAttestation, ParallelRoaWorkerPool, RoaValidationCacheInput, RoaValidationCacheView,
RouterKeyPayload, Vrp, process_publication_point_for_issuer_parallel_roa_with_cache_options,
process_publication_point_for_issuer_parallel_roa_with_pool_cache_options,
process_publication_point_for_issuer_with_cache_options_and_ta_constraints,
};
use crate::validation::publication_point::PublicationPointSnapshot;
use crate::validation::tree::{
@@ -511,7 +512,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
fn current_hash_for_uri(&self, uri: &str) -> Option<[u8; 32]> {
if let Some(index) = self.current_repo_index.as_ref() {
if let Ok(index) = index.lock() {
if let Ok(index) = index.read() {
if let Some(entry) = index.get_by_uri(uri) {
return Some(entry.current_hash);
}
@@ -760,7 +761,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
fn current_manifest_hash_hex_for_audit(&self, ca: &CaInstanceHandle) -> Option<String> {
if let Some(index_handle) = self.current_repo_index.as_ref()
&& let Ok(index) = index_handle.lock()
&& let Ok(index) = index_handle.read()
&& let Some(entry) = index.get_by_uri(&ca.manifest_rsync_uri)
{
return Some(entry.current_hash_hex.clone());
@@ -807,6 +808,21 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
repo_sync_ok: bool,
repo_sync_err: Option<&str>,
) -> Result<FreshPublicationPointStage, FreshPublicationPointStageError> {
if self
.policy
.verification_forced_vcir_reuse_manifest_uris
.contains(&ca.manifest_rsync_uri)
{
if let Some(timing) = self.timing.as_ref() {
timing.record_count("verification_forced_vcir_reuse_publication_points", 1);
}
return Err(FreshPublicationPointStageError {
error: ManifestFreshError::VerificationOnlyForcedCurrentInstanceVcir {
manifest_rsync_uri: ca.manifest_rsync_uri.clone(),
},
snapshot_prepare_ms: 0,
});
}
let snapshot_prepare_started = std::time::Instant::now();
let issuer_ca_der = ca_certificate_der_for_validation(ca, self.store, self.timing.as_ref())
.map_err(|detail| FreshPublicationPointStageError {
@@ -1536,12 +1552,30 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> {
RoaValidationCacheInput::disabled()
};
let objects_processing_started = std::time::Instant::now();
let ta_constraints = self.policy.ta_constraints.for_tal(&ca.tal_id);
let mut objects = {
let _objects_total = self
.timing
.as_ref()
.map(|t| t.span_phase("objects_processing_total"));
if let Some(phase2_pool) = self.parallel_roa_worker_pool.as_ref() {
if let Some(ta_constraints) = ta_constraints {
// This method is the serial/fallback publication-point path. The
// phase-2 scheduler uses the stage-specific parallel prepare path,
// which carries the same immutable per-TAL snapshot into workers.
process_publication_point_for_issuer_with_cache_options_and_ta_constraints(
&fresh_point,
self.policy,
issuer_ca_der.as_ref(),
ca.ca_certificate_rsync_uri.as_deref(),
ca.effective_ip_resources.as_ref(),
ca.effective_as_resources.as_ref(),
self.validation_time,
self.timing.as_ref(),
false,
roa_cache,
Some(ta_constraints),
)
} else if let Some(phase2_pool) = self.parallel_roa_worker_pool.as_ref() {
process_publication_point_for_issuer_parallel_roa_with_pool_cache_options(
&fresh_point,
self.policy,
@@ -1956,13 +1990,13 @@ fn sha256_digest_32(bytes: impl AsRef<[u8]>) -> [u8; 32] {
out
}
fn hash_serialized_parts(parts: &[(&str, Vec<u8>)]) -> [u8; 32] {
fn hash_serialized_parts(parts: &[(&str, &[u8])]) -> [u8; 32] {
let mut hasher = sha2::Sha256::new();
for (label, value) in parts {
hasher.update((label.len() as u64).to_be_bytes());
hasher.update(label.as_bytes());
hasher.update((value.len() as u64).to_be_bytes());
hasher.update(value);
hasher.update(*value);
}
let digest = hasher.finalize();
let mut out = [0u8; 32];
@@ -1976,41 +2010,34 @@ fn cbor_or_debug_bytes<T: serde::Serialize + std::fmt::Debug>(value: &T) -> Vec<
fn ta_context_digest_for_ca(ca: &CaInstanceHandle) -> [u8; 32] {
hash_serialized_parts(&[
("version", b"publication-point-cache-ta-v1".to_vec()),
("tal_id", ca.tal_id.as_bytes().to_vec()),
("version", b"publication-point-cache-ta-v1"),
("tal_id", ca.tal_id.as_bytes()),
])
}
pub(crate) fn ca_validation_context_digest_for_ca(ca: &CaInstanceHandle) -> [u8; 32] {
let parent_manifest = ca
.parent_manifest_rsync_uri
.as_deref()
.unwrap_or("")
.as_bytes();
let effective_ip = cbor_or_debug_bytes(&ca.effective_ip_resources);
let effective_as = cbor_or_debug_bytes(&ca.effective_as_resources);
hash_serialized_parts(&[
(
"version",
b"publication-point-cache-parent-context-v1".to_vec(),
),
("tal_id", ca.tal_id.as_bytes().to_vec()),
(
"parent_manifest",
ca.parent_manifest_rsync_uri
.as_deref()
.unwrap_or("")
.as_bytes()
.to_vec(),
),
(
"effective_ip",
cbor_or_debug_bytes(&ca.effective_ip_resources),
),
(
"effective_as",
cbor_or_debug_bytes(&ca.effective_as_resources),
),
("version", b"publication-point-cache-parent-context-v1"),
("tal_id", ca.tal_id.as_bytes()),
("parent_manifest", parent_manifest),
("effective_ip", effective_ip.as_slice()),
("effective_as", effective_as.as_slice()),
])
}
pub(crate) fn publication_point_cache_policy_fingerprint(policy: &Policy) -> [u8; 32] {
let policy_bytes = cbor_or_debug_bytes(policy);
hash_serialized_parts(&[
("version", b"publication-point-cache-policy-v1".to_vec()),
("policy", cbor_or_debug_bytes(policy)),
("version", b"publication-point-cache-policy-v3"),
("policy", policy_bytes.as_slice()),
("ta_constraints", policy.ta_constraints.fingerprint_bytes()),
])
}
@@ -2069,16 +2096,13 @@ fn child_certificate_cache_key_sha256_hex(
policy_fingerprint: &[u8; 32],
) -> String {
let digest = hash_serialized_parts(&[
("version", b"child-certificate-cache-key-v1".to_vec()),
("child_cert_uri", child_cert_uri.as_bytes().to_vec()),
("child_cert_sha256", child_cert_sha256.to_vec()),
("issuer_ca_sha256", issuer_ca_sha256.to_vec()),
("version", b"child-certificate-cache-key-v1"),
("child_cert_uri", child_cert_uri.as_bytes()),
("child_cert_sha256", child_cert_sha256),
("issuer_ca_sha256", issuer_ca_sha256),
// Keep the persisted cache-key label stable; this change only renames Rust identifiers.
(
"parent_context_digest",
ca_validation_context_digest.to_vec(),
),
("policy_fingerprint", policy_fingerprint.to_vec()),
("parent_context_digest", ca_validation_context_digest),
("policy_fingerprint", policy_fingerprint),
]);
sha256_hex_from_32(&digest)
}
@@ -2971,6 +2995,24 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
match router_result {
Ok(router) => {
if let Some(ta_constraints) = policy.ta_constraints.for_tal(&issuer.tal_id)
{
if let Err(error) =
ta_constraints.validate_ee_certificate(&router.resource_cert)
{
router_error = router_error.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::RouterCertificate,
result: AuditObjectResult::Error,
detail: Some(format!(
"router certificate violates TA constraints: {error}"
)),
});
continue;
}
}
let router_asns = match router_asns_for_resource_mode(
&router.asns,
issuer.effective_as_resources.as_ref(),
@@ -3840,6 +3882,28 @@ fn audit_result_from_vcir_status(status: VcirArtifactValidationStatus) -> AuditO
}
}
/// Fallback detail for cached artifacts rejected by an earlier run whose cache
/// entry predates reject-reason recording (field added 2026-07-27).
const CACHED_REJECT_REASON_NOT_RECORDED: &str =
"rejected in an earlier validation run (reject reason not recorded in cache)";
/// Detail for an audit entry rebuilt from a cached artifact: the real reject
/// reason when the cache recorded one, an explicit fallback for legacy cache
/// entries, and `None` for non-rejected artifacts.
fn audit_detail_from_vcir_status(
status: VcirArtifactValidationStatus,
reject_reason: Option<&str>,
) -> Option<String> {
match status {
VcirArtifactValidationStatus::Rejected => Some(
reject_reason
.map(str::to_string)
.unwrap_or_else(|| CACHED_REJECT_REASON_NOT_RECORDED.to_string()),
),
VcirArtifactValidationStatus::Accepted | VcirArtifactValidationStatus::WarningOnly => None,
}
}
fn build_publication_point_audit_from_snapshot(
ca: &CaInstanceHandle,
source: PublicationPointSource,
@@ -4059,7 +4123,10 @@ fn build_publication_point_audit_from_vcir(
sha256_hex: artifact.sha256.clone(),
kind: kind_from_vcir_artifact_kind(artifact.artifact_kind),
result: audit_result_from_vcir_status(artifact.validation_status),
detail: None,
detail: audit_detail_from_vcir_status(
artifact.validation_status,
artifact.reject_reason.as_deref(),
),
},
);
}
@@ -4170,7 +4237,10 @@ fn build_publication_point_audit_from_publication_point_cache_projection(
sha256_hex: artifact.sha256.clone(),
kind: kind_from_vcir_artifact_kind(artifact.artifact_kind),
result: audit_result_from_vcir_status(artifact.validation_status),
detail: None,
detail: audit_detail_from_vcir_status(
artifact.validation_status,
artifact.reject_reason.as_deref(),
),
},
);
}
@@ -6003,9 +6073,9 @@ fn build_vcir_related_artifacts(
objects: &crate::validation::objects::ObjectsOutput,
child_audits: &[ObjectAuditEntry],
) -> Vec<VcirRelatedArtifact> {
let mut audit_by_uri: HashMap<&str, AuditObjectResult> = HashMap::new();
let mut audit_by_uri: HashMap<&str, &ObjectAuditEntry> = HashMap::new();
for entry in child_audits.iter().chain(objects.audit.iter()) {
audit_by_uri.insert(entry.rsync_uri.as_str(), entry.result.clone());
audit_by_uri.insert(entry.rsync_uri.as_str(), entry);
}
let mut artifacts = Vec::with_capacity(pack.files.len() + 2);
@@ -6016,6 +6086,7 @@ fn build_vcir_related_artifacts(
sha256: sha256_hex(&pack.manifest_bytes),
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
});
artifacts.push(VcirRelatedArtifact {
artifact_role: if ca.parent_manifest_rsync_uri.is_none() {
@@ -6035,13 +6106,20 @@ fn build_vcir_related_artifacts(
}),
object_type: Some("cer".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
});
for file in &pack.files {
let result = audit_by_uri
.get(file.rsync_uri.as_str())
.cloned()
let audit_entry = audit_by_uri.get(file.rsync_uri.as_str()).copied();
let result = audit_entry
.map(|entry| entry.result.clone())
.unwrap_or(AuditObjectResult::Ok);
let validation_status = audit_result_to_vcir_status(&result);
let reject_reason = if validation_status == VcirArtifactValidationStatus::Rejected {
audit_entry.and_then(|entry| entry.detail.clone())
} else {
None
};
let (artifact_role, artifact_kind) = artifact_role_and_kind(file, current_crl_rsync_uri);
artifacts.push(VcirRelatedArtifact {
artifact_role,
@@ -6049,7 +6127,8 @@ fn build_vcir_related_artifacts(
uri: Some(file.rsync_uri.clone()),
sha256: sha256_hex_from_32(&file.sha256),
object_type: object_type_from_uri(file.rsync_uri.as_str()),
validation_status: audit_result_to_vcir_status(&result),
validation_status,
reject_reason,
});
}

View File

@@ -708,6 +708,7 @@ fn sample_vcir_for_projection(
sha256: manifest_hash,
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
},
VcirRelatedArtifact {
artifact_role: VcirArtifactRole::CurrentCrl,
@@ -716,6 +717,7 @@ fn sample_vcir_for_projection(
sha256: current_crl_hash,
object_type: Some("crl".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
},
VcirRelatedArtifact {
artifact_role: VcirArtifactRole::ChildCaCert,
@@ -724,6 +726,7 @@ fn sample_vcir_for_projection(
sha256: child_cert_hash.to_string(),
object_type: Some("cer".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
},
VcirRelatedArtifact {
artifact_role: VcirArtifactRole::SignedObject,
@@ -732,6 +735,7 @@ fn sample_vcir_for_projection(
sha256: roa_hash,
object_type: Some("roa".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
},
VcirRelatedArtifact {
artifact_role: VcirArtifactRole::SignedObject,
@@ -740,6 +744,7 @@ fn sample_vcir_for_projection(
sha256: aspa_hash,
object_type: Some("aspa".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
},
],
summary: VcirSummary {
@@ -1455,10 +1460,12 @@ fn build_vcir_related_artifacts_classifies_snapshot_files_and_audit_statuses() {
&& artifact.artifact_role == VcirArtifactRole::ChildCaCert));
assert!(artifacts.iter().any(|artifact| artifact.uri.as_deref()
== Some("rsync://example.test/repo/issuer/a.roa")
&& artifact.validation_status == VcirArtifactValidationStatus::Rejected));
&& artifact.validation_status == VcirArtifactValidationStatus::Rejected
&& artifact.reject_reason.as_deref() == Some("bad roa")));
assert!(artifacts.iter().any(|artifact| artifact.uri.as_deref()
== Some("rsync://example.test/repo/issuer/a.asa")
&& artifact.validation_status == VcirArtifactValidationStatus::WarningOnly));
&& artifact.validation_status == VcirArtifactValidationStatus::WarningOnly
&& artifact.reject_reason.is_none()));
assert!(artifacts.iter().any(|artifact| artifact.uri.as_deref()
== Some("rsync://example.test/repo/issuer/a.gbr")
&& artifact.artifact_kind == VcirArtifactKind::Gbr));
@@ -3419,6 +3426,45 @@ fn runner_when_repo_sync_fails_uses_current_instance_vcir_and_keeps_children_emp
.any(|w| w.message.contains("repo sync failed")),
"expected warning about repo sync failure"
);
// Verification-only replays have the frozen repository bytes available,
// so a fresh read would succeed and never naturally enter the fallback
// branch. A contract-selected URI must therefore select the same VCIR
// projection without attempting fresh manifest/object validation.
let mut forced_policy = policy.clone();
forced_policy
.verification_forced_vcir_reuse_manifest_uris
.insert(manifest_rsync_uri.clone());
let forced_runner = Rpkiv1PublicationPointRunner {
store: &store,
policy: &forced_policy,
http_fetcher: &NeverHttpFetcher,
rsync_fetcher: &LocalDirRsyncFetcher::new(&fixture_dir),
validation_time,
timing: None,
download_log: None,
replay_archive_index: None,
replay_delta_index: None,
rrdp_dedup: false,
rrdp_repo_cache: Mutex::new(HashMap::new()),
rsync_dedup: false,
rsync_repo_cache: Mutex::new(HashMap::new()),
current_repo_index: None,
repo_sync_runtime: None,
parallel_phase2_config: None,
parallel_roa_worker_pool: None,
ccr_accumulator: None,
persist_vcir: true,
enable_roa_validation_cache: false,
enable_child_certificate_validation_cache: false,
publication_point_cache_observe_only: false,
enable_publication_point_validation_cache: false,
};
let forced = forced_runner
.run_publication_point(&handle)
.expect("contract-selected fallback should reuse current-instance VCIR");
assert_eq!(forced.source, PublicationPointSource::VcirCurrentInstance);
assert!(forced.discovered_children.is_empty());
}
#[test]
@@ -4510,6 +4556,7 @@ fn build_objects_output_from_vcir_tracks_expired_and_invalid_cached_outputs() {
.to_string(),
),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
});
}
@@ -4732,6 +4779,173 @@ fn build_publication_point_audit_from_vcir_uses_vcir_metadata_and_overlays_child
}));
}
#[test]
fn build_publication_point_audit_from_vcir_restores_reject_reason_with_legacy_fallback() {
let now = time::OffsetDateTime::now_utc();
let child_cert_hash = sha256_hex(b"child-cert");
let mut vcir = sample_vcir_for_projection(now, &child_cert_hash);
vcir.related_artifacts.push(VcirRelatedArtifact {
artifact_role: VcirArtifactRole::SignedObject,
artifact_kind: VcirArtifactKind::Roa,
uri: Some("rsync://example.test/repo/issuer/rejected-with-reason.roa".to_string()),
sha256: sha256_hex(b"rejected-with-reason"),
object_type: Some("roa".to_string()),
validation_status: VcirArtifactValidationStatus::Rejected,
reject_reason: Some("EE certificate path validation failed: test".to_string()),
});
vcir.related_artifacts.push(VcirRelatedArtifact {
artifact_role: VcirArtifactRole::SignedObject,
artifact_kind: VcirArtifactKind::Roa,
uri: Some("rsync://example.test/repo/issuer/rejected-legacy.roa".to_string()),
sha256: sha256_hex(b"rejected-legacy"),
object_type: Some("roa".to_string()),
validation_status: VcirArtifactValidationStatus::Rejected,
reject_reason: None,
});
let ca = CaInstanceHandle {
depth: 0,
tal_id: "test-tal".to_string(),
parent_manifest_rsync_uri: None,
ca_certificate: CaCertificateRef::inline_der(Vec::new()),
ca_certificate_rsync_uri: None,
effective_ip_resources: None,
effective_as_resources: None,
rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(),
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(),
rrdp_notification_uri: None,
};
let objects = crate::validation::objects::ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: crate::validation::objects::ObjectsStats::default(),
audit: Vec::new(),
roa_cache_stats: crate::validation::objects::RoaValidationCacheStats::default(),
roa_cache_object_meta: Vec::new(),
};
let audit = build_publication_point_audit_from_vcir(
&ca,
PublicationPointSource::VcirCurrentInstance,
None,
None,
None,
None,
Some(&vcir),
None,
&[],
&objects,
&[],
&[],
);
assert!(audit.objects.iter().any(|entry| {
entry.rsync_uri == "rsync://example.test/repo/issuer/rejected-with-reason.roa"
&& matches!(entry.result, AuditObjectResult::Error)
&& entry.detail.as_deref() == Some("EE certificate path validation failed: test")
}));
assert!(audit.objects.iter().any(|entry| {
entry.rsync_uri == "rsync://example.test/repo/issuer/rejected-legacy.roa"
&& matches!(entry.result, AuditObjectResult::Error)
&& entry.detail.as_deref() == Some(CACHED_REJECT_REASON_NOT_RECORDED)
}));
assert!(
audit.objects.iter().all(|entry| {
!matches!(entry.result, AuditObjectResult::Ok) || entry.detail.is_none()
})
);
}
#[test]
fn build_publication_point_audit_from_pp_cache_projection_restores_reject_reason() {
let now = time::OffsetDateTime::now_utc();
let child_cert_hash = sha256_hex(b"child-cert");
let mut vcir = sample_vcir_for_projection(now, &child_cert_hash);
vcir.related_artifacts.push(VcirRelatedArtifact {
artifact_role: VcirArtifactRole::SignedObject,
artifact_kind: VcirArtifactKind::Roa,
uri: Some("rsync://example.test/repo/issuer/rejected-with-reason.roa".to_string()),
sha256: sha256_hex(b"rejected-with-reason"),
object_type: Some("roa".to_string()),
validation_status: VcirArtifactValidationStatus::Rejected,
reject_reason: Some("EE certificate path validation failed: test".to_string()),
});
vcir.related_artifacts.push(VcirRelatedArtifact {
artifact_role: VcirArtifactRole::SignedObject,
artifact_kind: VcirArtifactKind::Roa,
uri: Some("rsync://example.test/repo/issuer/rejected-legacy.roa".to_string()),
sha256: sha256_hex(b"rejected-legacy"),
object_type: Some("roa".to_string()),
validation_status: VcirArtifactValidationStatus::Rejected,
reject_reason: None,
});
let projection = PublicationPointCacheProjection::from_vcir_with_context(
&vcir,
"rsync://example.test/repo/issuer/".to_string(),
None,
[0x11; 32],
[0x22; 32],
[0x33; 32],
[0x44; 32],
[0x55; 32],
)
.expect("build publication point projection");
let ca = CaInstanceHandle {
depth: 0,
tal_id: "test-tal".to_string(),
parent_manifest_rsync_uri: None,
ca_certificate: CaCertificateRef::inline_der(Vec::new()),
ca_certificate_rsync_uri: None,
effective_ip_resources: None,
effective_as_resources: None,
rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(),
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(),
rrdp_notification_uri: None,
};
let objects = crate::validation::objects::ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
router_keys: Vec::new(),
local_outputs_cache: Vec::new(),
warnings: Vec::new(),
stats: crate::validation::objects::ObjectsStats::default(),
audit: Vec::new(),
roa_cache_stats: crate::validation::objects::RoaValidationCacheStats::default(),
roa_cache_object_meta: Vec::new(),
};
let audit = build_publication_point_audit_from_publication_point_cache_projection(
&ca,
PublicationPointSource::PublicationPointCache,
None,
None,
None,
None,
&projection,
now,
&[],
&objects,
&[],
);
assert!(audit.objects.iter().any(|entry| {
entry.rsync_uri == "rsync://example.test/repo/issuer/rejected-with-reason.roa"
&& matches!(entry.result, AuditObjectResult::Error)
&& entry.detail.as_deref() == Some("EE certificate path validation failed: test")
}));
assert!(audit.objects.iter().any(|entry| {
entry.rsync_uri == "rsync://example.test/repo/issuer/rejected-legacy.roa"
&& matches!(entry.result, AuditObjectResult::Error)
&& entry.detail.as_deref() == Some(CACHED_REJECT_REASON_NOT_RECORDED)
}));
}
#[test]
fn build_publication_point_audit_from_vcir_failed_no_cache_keeps_current_reject_only() {
let now = time::OffsetDateTime::now_utc();
@@ -4948,6 +5162,7 @@ fn reconstruct_snapshot_from_vcir_reports_missing_manifest_and_related_raw_bytes
sha256: sha256_hex(b"dup-roa-1"),
object_type: Some("roa".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
});
vcir.related_artifacts.push(VcirRelatedArtifact {
artifact_role: VcirArtifactRole::SignedObject,
@@ -4956,6 +5171,7 @@ fn reconstruct_snapshot_from_vcir_reports_missing_manifest_and_related_raw_bytes
sha256: sha256_hex(b"dup-roa-2"),
object_type: Some("roa".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
});
vcir.related_artifacts.push(VcirRelatedArtifact {
artifact_role: VcirArtifactRole::IssuerCert,
@@ -4964,6 +5180,7 @@ fn reconstruct_snapshot_from_vcir_reports_missing_manifest_and_related_raw_bytes
sha256: sha256_hex(b"issuer-cert"),
object_type: Some("cer".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
});
let ca = CaInstanceHandle {

View File

@@ -8,8 +8,12 @@ use sha2::{Digest, Sha256};
use time::format_description::well_known::Rfc3339;
use crate::parallel::types::TalInputSpec;
use crate::policy::Policy;
use crate::storage::{RepositoryBlobVerificationSummary, RocksStore};
use crate::policy::{CaFailedFetchPolicy, Policy};
use crate::fetch::rsync_system::RsyncScopePolicy;
use crate::storage::{
RepositoryBlobVerificationSummary, RocksStore, ValidatedCaInstanceResult,
VcirFailedFetchReuseIdentity, VcirReuseRecordsCleared,
};
pub const VALIDATION_CONTRACT_SCHEMA_VERSION: u32 = 1;
pub const CURRENT_STATE_BINDING_SCHEMA_VERSION: u32 = 1;
@@ -21,11 +25,44 @@ pub struct ValidationCacheContract {
pub roa: bool,
pub child_certificate: bool,
pub transport_prefetch: bool,
#[serde(default)]
pub crypto_signature: bool,
}
/// The normal-run fallback inputs which verification-only is permitted to
/// replay. The payload remains in the source work-db; the contract binds the
/// selected VCIR and its failed-fetch identity by canonical CBOR hashes.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FallbackReplaySelection {
pub manifest_rsync_uri: String,
pub vcir_sha256: String,
pub reuse_identity_sha256: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FallbackReplayMaterialization {
pub contract_selection_count: u64,
pub source_vcir_records_cleared: u64,
pub source_reuse_identity_records_cleared: u64,
pub imported_selection_count: u64,
pub scratch_vcir_count: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FallbackReplaySelectionComparison {
pub matches: bool,
pub expected: Vec<FallbackReplaySelection>,
pub actual: Vec<FallbackReplaySelection>,
pub missing: Vec<FallbackReplaySelection>,
pub unexpected: Vec<FallbackReplaySelection>,
}
impl ValidationCacheContract {
pub fn any_validation_cache_enabled(&self) -> bool {
self.publication_point || self.roa || self.child_certificate
self.publication_point || self.roa || self.child_certificate || self.crypto_signature
}
}
@@ -36,9 +73,21 @@ pub struct ValidationContract {
pub validation_time: String,
pub binary_sha256: String,
pub policy: Policy,
/// Constraints are currently normal-run-only. Persist their semantic
/// fingerprint so verification-only can fail closed rather than replay a
/// constrained run with an empty runtime-only policy map.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ta_constraints_fingerprint: Option<String>,
pub max_ca_depth: usize,
pub max_instances: Option<usize>,
pub cache: ValidationCacheContract,
/// Source transport scope. With `transportPrefetch=true`, verification-only
/// replays this scope over the frozen repository view rather than deriving
/// a new publication-point scope.
#[serde(default)]
pub rsync_scope_policy: RsyncScopePolicy,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fallback_replay_selections: Vec<FallbackReplaySelection>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -89,15 +138,19 @@ impl ValidationContract {
max_ca_depth: usize,
max_instances: Option<usize>,
cache: ValidationCacheContract,
rsync_scope_policy: RsyncScopePolicy,
) -> Result<Self, String> {
Ok(Self {
schema_version: VALIDATION_CONTRACT_SCHEMA_VERSION,
validation_time: format_validation_time(validation_time)?,
binary_sha256: current_binary_sha256()?,
policy,
ta_constraints_fingerprint: None,
max_ca_depth,
max_instances,
cache,
rsync_scope_policy,
fallback_replay_selections: Vec::new(),
})
}
@@ -116,9 +169,46 @@ impl ValidationContract {
}
self.validation_time()?;
validate_sha256_hex("validation contract binarySha256", &self.binary_sha256)?;
if let Some(fingerprint) = self.ta_constraints_fingerprint.as_deref() {
validate_sha256_hex("validation contract taConstraintsFingerprint", fingerprint)?;
}
if self.max_ca_depth == 0 {
return Err("validation contract maxCaDepth must be greater than zero".to_string());
}
if !self.fallback_replay_selections.is_empty()
&& self.policy.ca_failed_fetch_policy != CaFailedFetchPolicy::ReuseCurrentInstanceVcir
{
return Err(
"validation contract fallbackReplaySelections require caFailedFetchPolicy=reuse_current_instance_vcir"
.to_string(),
);
}
let mut previous: Option<&str> = None;
for selection in &self.fallback_replay_selections {
if !selection.manifest_rsync_uri.starts_with("rsync://") {
return Err(format!(
"validation contract fallbackReplaySelections manifestRsyncUri must be an rsync URI: {}",
selection.manifest_rsync_uri
));
}
validate_sha256_hex(
"validation contract fallbackReplaySelections vcirSha256",
&selection.vcir_sha256,
)?;
validate_sha256_hex(
"validation contract fallbackReplaySelections reuseIdentitySha256",
&selection.reuse_identity_sha256,
)?;
if let Some(previous) = previous {
if previous >= selection.manifest_rsync_uri.as_str() {
return Err(
"validation contract fallbackReplaySelections must be sorted and unique by manifestRsyncUri"
.to_string(),
);
}
}
previous = Some(&selection.manifest_rsync_uri);
}
Ok(())
}
@@ -195,6 +285,7 @@ pub struct PreparedVerification {
pub cir: crate::cir::CanonicalInputRepresentation,
pub contract: ValidationContract,
pub blob_summary: RepositoryBlobVerificationSummary,
pub fallback_replay_materialization: FallbackReplayMaterialization,
pub store: Option<Arc<RocksStore>>,
}
@@ -232,6 +323,8 @@ pub struct VerificationRunMeta {
pub repository_objects_checked: u64,
pub repository_bytes_checked: u64,
pub state_digest_match: bool,
pub fallback_replay_selection_match: bool,
pub fallback_replay_materialization: FallbackReplayMaterialization,
pub scratch_retained: bool,
}
@@ -303,6 +396,174 @@ pub fn cir_tal_uris(cir: &crate::cir::CanonicalInputRepresentation) -> Vec<Strin
.collect()
}
/// Derive the exact fallback set used by a completed tree traversal and bind
/// every selected payload to its canonical storage representation.
pub fn collect_fallback_replay_selections(
store: &RocksStore,
publication_points: &[crate::audit::PublicationPointAudit],
) -> Result<Vec<FallbackReplaySelection>, String> {
let mut selections = Vec::new();
for publication_point in publication_points {
if publication_point.source != "vcir_current_instance" {
continue;
}
selections.push(fallback_replay_selection_for_manifest(
store,
&publication_point.manifest_rsync_uri,
)?);
}
selections.sort();
for pair in selections.windows(2) {
if pair[0].manifest_rsync_uri == pair[1].manifest_rsync_uri {
return Err(format!(
"multiple vcir_current_instance publication points use the same manifest URI: {}",
pair[0].manifest_rsync_uri
));
}
}
Ok(selections)
}
pub(crate) fn fallback_replay_selection_for_manifest(
store: &RocksStore,
manifest_rsync_uri: &str,
) -> Result<FallbackReplaySelection, String> {
let vcir = store
.get_vcir(manifest_rsync_uri)
.map_err(|error| format!("load fallback VCIR failed for {manifest_rsync_uri}: {error}"))?
.ok_or_else(|| format!("fallback VCIR is missing for {manifest_rsync_uri}"))?;
let reuse_identity = store
.get_vcir_failed_fetch_reuse_identity(manifest_rsync_uri)
.map_err(|error| {
format!("load fallback VCIR reuse identity failed for {manifest_rsync_uri}: {error}")
})?
.ok_or_else(|| format!("fallback VCIR reuse identity is missing for {manifest_rsync_uri}"))?;
fallback_replay_selection_from_values(&vcir, &reuse_identity)
}
fn fallback_replay_selection_from_values(
vcir: &ValidatedCaInstanceResult,
reuse_identity: &VcirFailedFetchReuseIdentity,
) -> Result<FallbackReplaySelection, String> {
Ok(FallbackReplaySelection {
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
vcir_sha256: canonical_cbor_sha256("fallback VCIR", vcir)?,
reuse_identity_sha256: canonical_cbor_sha256("fallback VCIR reuse identity", reuse_identity)?,
})
}
fn canonical_cbor_sha256<T: Serialize>(label: &str, value: &T) -> Result<String, String> {
let bytes = serde_cbor::to_vec(value)
.map_err(|error| format!("serialize {label} for SHA-256 failed: {error}"))?;
Ok(hex::encode(Sha256::digest(bytes)))
}
pub(crate) fn materialize_fallback_replay_selections(
store: &RocksStore,
selections: &[FallbackReplaySelection],
) -> Result<FallbackReplayMaterialization, String> {
let mut extracted = Vec::with_capacity(selections.len());
for selection in selections {
let actual = fallback_replay_selection_for_manifest(store, &selection.manifest_rsync_uri)?;
if actual != *selection {
return Err(format!(
"fallback replay selection hash mismatch for {}: contract vcir={}, work-db vcir={}, contract identity={}, work-db identity={}",
selection.manifest_rsync_uri,
selection.vcir_sha256,
actual.vcir_sha256,
selection.reuse_identity_sha256,
actual.reuse_identity_sha256,
));
}
let vcir = store
.get_vcir(&selection.manifest_rsync_uri)
.map_err(|error| format!("reload selected fallback VCIR failed: {error}"))?
.expect("fallback selection was just verified as present");
let reuse_identity = store
.get_vcir_failed_fetch_reuse_identity(&selection.manifest_rsync_uri)
.map_err(|error| format!("reload selected fallback reuse identity failed: {error}"))?
.expect("fallback selection was just verified as present");
extracted.push((vcir, reuse_identity));
}
let VcirReuseRecordsCleared {
vcir_records,
failed_fetch_identity_records,
} = store
.clear_vcir_reuse_records()
.map_err(|error| format!("clear scratch VCIR fallback records failed: {error}"))?;
for (vcir, reuse_identity) in &extracted {
store
.put_vcir_with_failed_fetch_reuse_identity(vcir, reuse_identity)
.map_err(|error| format!("import selected fallback VCIR failed: {error}"))?;
}
let actual = store
.list_vcirs()
.map_err(|error| format!("list scratch fallback VCIR records failed: {error}"))?
.iter()
.map(|vcir| {
let reuse_identity = store
.get_vcir_failed_fetch_reuse_identity(&vcir.manifest_rsync_uri)
.map_err(|error| format!("read materialized fallback reuse identity failed: {error}"))?
.ok_or_else(|| {
format!(
"materialized fallback reuse identity is missing for {}",
vcir.manifest_rsync_uri
)
})?;
fallback_replay_selection_from_values(vcir, &reuse_identity)
})
.collect::<Result<Vec<_>, String>>()?;
if actual != selections {
return Err(format!(
"scratch fallback replay allowlist differs from validation contract: expected={}, actual={}",
selections.len(),
actual.len()
));
}
Ok(FallbackReplayMaterialization {
contract_selection_count: selections.len() as u64,
source_vcir_records_cleared: vcir_records,
source_reuse_identity_records_cleared: failed_fetch_identity_records,
imported_selection_count: extracted.len() as u64,
scratch_vcir_count: actual.len() as u64,
})
}
pub fn compare_fallback_replay_selections(
expected: &[FallbackReplaySelection],
actual: &[FallbackReplaySelection],
) -> FallbackReplaySelectionComparison {
let missing: Vec<FallbackReplaySelection> = expected
.iter()
.filter(|selection| !actual.contains(selection))
.cloned()
.collect();
let unexpected: Vec<FallbackReplaySelection> = actual
.iter()
.filter(|selection| !expected.contains(selection))
.cloned()
.collect();
FallbackReplaySelectionComparison {
matches: missing.is_empty() && unexpected.is_empty(),
expected: expected.to_vec(),
actual: actual.to_vec(),
missing,
unexpected,
}
}
pub fn write_fallback_replay_selection_comparison(
path: &Path,
comparison: &FallbackReplaySelectionComparison,
) -> Result<(), String> {
let bytes = serde_json::to_vec_pretty(comparison)
.map_err(|error| format!("encode fallback replay selection comparison failed: {error}"))?;
atomic_write(path, &bytes)
}
pub fn prepare_verification(
source_run_dir: &Path,
source_state_root: &Path,
@@ -348,6 +609,12 @@ pub fn prepare_verification(
let cir = read_cir(&artifacts.source_cir)?;
let contract = read_validation_contract(&artifacts.source_contract)?;
if contract.ta_constraints_fingerprint.is_some() {
return Err(
"verification-only is not supported for a run with TA constraints; rerun normal validation with the same constraints"
.to_string(),
);
}
if cir.validation_time.to_offset(time::UtcOffset::UTC) != contract.validation_time()? {
return Err(format!(
"source CIR validation time differs from validation contract: cir={}, contract={}",
@@ -358,7 +625,7 @@ pub fn prepare_verification(
contract.require_current_binary()?;
if !contract.cache.any_validation_cache_enabled() {
return Err(
"source run did not enable PP, ROA, or child-certificate validation cache".to_string(),
"source run did not enable PP, ROA, child-certificate, or crypto signature validation cache".to_string(),
);
}
if !artifacts.source_work_db.is_dir() {
@@ -389,6 +656,10 @@ pub fn prepare_verification(
)
.map_err(|error| format!("open frozen verification store failed: {error}"))?,
);
let fallback_replay_materialization = materialize_fallback_replay_selections(
store.as_ref(),
&contract.fallback_replay_selections,
)?;
let blob_summary = store
.verify_current_repository_blobs(1024)
.map_err(|error| format!("verify frozen repository blobs failed: {error}"))?;
@@ -399,6 +670,7 @@ pub fn prepare_verification(
cir,
contract,
blob_summary,
fallback_replay_materialization,
store: Some(store),
})
}
@@ -638,6 +910,7 @@ mod tests {
validation_time: "2026-07-16T00:00:00Z".to_string(),
binary_sha256: "ab".repeat(32),
policy: Policy::default(),
ta_constraints_fingerprint: None,
max_ca_depth: 32,
max_instances: None,
cache: ValidationCacheContract {
@@ -645,7 +918,10 @@ mod tests {
roa: true,
child_certificate: true,
transport_prefetch: false,
crypto_signature: false,
},
rsync_scope_policy: RsyncScopePolicy::default(),
fallback_replay_selections: Vec::new(),
}
}
@@ -751,7 +1027,9 @@ mod tests {
roa: false,
child_certificate: false,
transport_prefetch: true,
crypto_signature: false,
},
RsyncScopePolicy::default(),
)
.expect("contract");
write_validation_contract(&source_run.join("validation-contract.json"), &contract)
@@ -795,6 +1073,60 @@ mod tests {
assert!(contract.validate().is_err());
}
#[test]
fn fallback_replay_contract_requires_reuse_policy_and_sorted_unique_selections() {
let selection = FallbackReplaySelection {
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
vcir_sha256: "11".repeat(32),
reuse_identity_sha256: "22".repeat(32),
};
let mut contract = sample_contract();
contract.policy.ca_failed_fetch_policy = CaFailedFetchPolicy::StopAllOutput;
contract.fallback_replay_selections = vec![selection.clone()];
assert!(contract
.validate()
.unwrap_err()
.contains("caFailedFetchPolicy"));
contract.policy.ca_failed_fetch_policy = CaFailedFetchPolicy::ReuseCurrentInstanceVcir;
contract.fallback_replay_selections = vec![
FallbackReplaySelection {
manifest_rsync_uri: "rsync://example.test/repo/z.mft".to_string(),
..selection.clone()
},
FallbackReplaySelection {
manifest_rsync_uri: "rsync://example.test/repo/a.mft".to_string(),
..selection.clone()
},
];
assert!(contract.validate().unwrap_err().contains("sorted and unique"));
contract.fallback_replay_selections.sort();
contract.validate().expect("sorted selection contract");
}
#[test]
fn fallback_replay_selection_comparison_reports_exact_differences() {
let expected = vec![FallbackReplaySelection {
manifest_rsync_uri: "rsync://example.test/repo/a.mft".to_string(),
vcir_sha256: "11".repeat(32),
reuse_identity_sha256: "22".repeat(32),
}];
let mut actual = expected.clone();
actual[0].vcir_sha256 = "33".repeat(32);
let comparison = compare_fallback_replay_selections(&expected, &actual);
assert!(!comparison.matches);
assert_eq!(comparison.missing, expected);
assert_eq!(comparison.unexpected, actual);
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("fallback-replay-selection.json");
write_fallback_replay_selection_comparison(&path, &comparison)
.expect("write fallback selection comparison");
assert!(fs::read_to_string(path)
.expect("read fallback selection comparison")
.contains("reuseIdentitySha256"));
}
#[test]
fn cache_contract_distinguishes_prefetch_from_validation_caches() {
let cache = ValidationCacheContract {
@@ -802,10 +1134,23 @@ mod tests {
roa: false,
child_certificate: false,
transport_prefetch: true,
crypto_signature: false,
};
assert!(!cache.any_validation_cache_enabled());
}
#[test]
fn validation_contract_defaults_missing_rsync_scope_to_module_root() {
let mut value = serde_json::to_value(sample_contract()).expect("serialize contract");
value
.as_object_mut()
.expect("contract object")
.remove("rsyncScopePolicy");
let decoded: ValidationContract =
serde_json::from_value(value).expect("decode older contract");
assert_eq!(decoded.rsync_scope_policy, RsyncScopePolicy::ModuleRoot);
}
#[test]
fn validation_contract_and_binding_validation_cover_rejection_paths() {
let time = time::OffsetDateTime::parse(
@@ -819,6 +1164,7 @@ mod tests {
32,
Some(10),
sample_contract().cache,
RsyncScopePolicy::default(),
)
.expect("current contract");
assert_eq!(contract.validation_time().expect("contract time"), time);
@@ -914,6 +1260,8 @@ mod tests {
repository_objects_checked: 3,
repository_bytes_checked: 4,
state_digest_match: true,
fallback_replay_selection_match: true,
fallback_replay_materialization: FallbackReplayMaterialization::default(),
scratch_retained: false,
};
let meta_path = temp.path().join("meta/result.json");
@@ -1003,6 +1351,16 @@ mod tests {
assert_eq!(prepared.blob_summary.current_objects, 0);
assert_eq!(prepared.blob_summary.bytes_verified, 0);
assert_eq!(prepared.contract.validation_time, "2026-07-16T00:00:00Z");
assert_eq!(
prepared
.fallback_replay_materialization
.contract_selection_count,
0
);
assert_eq!(
prepared.fallback_replay_materialization.scratch_vcir_count,
0
);
assert_eq!(cir_tal_uris(&prepared.cir).len(), 1);
assert!(prepared.artifacts.scratch_work_db.is_dir());
assert!(prepared.artifacts.result_contract.is_file());

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,394 @@
# Feature #151 remote all5 performance fixture.
#
# Source: official rpki-client 9.8 afrinic.constraints
# ($OpenBSD: afrinic.constraints,v 1.4 2026/03/11 21:46:36 job Exp $).
# Every IPv4 allow entry from that file (including recovered and transfer
# ranges) is converted to deny. IPv6 and ASN entries are intentionally not
# included because this fixture measures the full AFRINIC IPv4 policy.
allow 0.0.0.0/0
allow ::/0
allow 0 - 4294967295
deny 41.0.0.0/8
deny 102.0.0.0/8
deny 105.0.0.0/8
deny 154.0.0.0/16
deny 154.16.0.0/16
deny 154.65.0.0 - 154.255.255.255
deny 196.0.0.0 - 196.1.0.255
deny 196.1.4.0/24
deny 196.1.7.0 - 196.1.63.255
deny 196.1.71.0/24
deny 196.1.74.0 - 196.1.103.255
deny 196.1.115.0 - 196.1.133.255
deny 196.1.137.0/24
deny 196.1.143.0 - 196.1.159.255
deny 196.1.176.0 - 196.1.255.255
deny 196.2.2.0/23
deny 196.2.8.0 - 196.2.255.255
deny 196.3.14.0/23
deny 196.3.57.0 - 196.3.64.255
deny 196.3.90.0/24
deny 196.3.92.0 - 196.3.94.255
deny 196.3.96.0/21
deny 196.3.105.0/24
deny 196.3.107.0 - 196.3.131.255
deny 196.3.148.0/22
deny 196.3.154.0 - 196.3.183.255
deny 196.3.224.0 - 196.4.45.255
deny 196.4.71.0 - 196.11.171.255
deny 196.11.174.0 - 196.11.239.255
deny 196.11.248.0/21
deny 196.12.10.0 - 196.12.31.255
deny 196.12.128.0/19
deny 196.12.192.0 - 196.15.15.255
deny 196.15.64.0 - 196.26.255.255
deny 196.27.64.0 - 196.28.47.255
deny 196.28.64.0 - 196.29.63.255
deny 196.29.96.0 - 196.31.255.255
deny 196.32.8.0 - 196.32.31.255
deny 196.32.96.0/19
deny 196.32.160.0 - 196.39.255.255
deny 196.40.96.0 - 196.41.255.255
deny 196.42.64.0 - 196.216.0.255
deny 196.216.2.0 - 197.255.255.255
deny 45.96.0.0 - 45.111.255.255
deny 45.192.0.0 - 45.222.255.255
deny 45.240.0.0 - 45.247.255.255
deny 66.251.128.0 - 66.251.191.255
deny 139.26.0.0 - 139.26.255.255
deny 146.196.128.0 - 146.196.255.255
deny 160.19.36.0 - 160.19.39.255
deny 160.19.60.0 - 160.19.63.255
deny 160.19.96.0 - 160.19.103.255
deny 160.19.112.0 - 160.19.143.255
deny 160.19.152.0 - 160.19.155.255
deny 160.19.188.0 - 160.19.191.255
deny 160.19.192.0 - 160.19.199.255
deny 160.19.232.0 - 160.19.239.255
deny 160.20.24.0 - 160.20.31.255
deny 160.20.112.0 - 160.20.115.255
deny 160.20.213.0 - 160.20.213.255
deny 160.20.217.0 - 160.20.217.255
deny 160.20.221.0 - 160.20.221.255
deny 160.20.226.0 - 160.20.227.255
deny 160.20.252.0 - 160.20.255.255
deny 160.238.11.0 - 160.238.11.255
deny 160.238.48.0 - 160.238.49.255
deny 160.238.50.0 - 160.238.50.255
deny 160.238.57.0 - 160.238.57.255
deny 160.238.101.0 - 160.238.101.255
deny 161.123.0.0 - 161.123.255.255
deny 164.160.0.0 - 164.160.255.255
deny 192.12.110.0 - 192.12.111.255
deny 192.12.116.0 - 192.12.117.255
deny 192.47.36.0 - 192.47.36.255
deny 192.51.240.0 - 192.51.240.255
deny 192.70.200.0 - 192.70.201.255
deny 192.75.236.0 - 192.75.236.255
deny 192.83.208.0 - 192.83.215.255
deny 192.91.200.0 - 192.91.200.255
deny 192.142.0.0 - 192.143.255.255
deny 192.145.128.0 - 192.145.191.255
deny 192.145.230.0 - 192.145.230.255
deny 204.8.204.0 - 204.8.207.255
deny 208.85.156.0 - 208.85.159.255
deny 83.143.24.0 - 83.143.31.255
deny 84.205.96.0 - 84.205.127.255
deny 131.176.0.0 - 131.176.255.255
deny 163.121.0.0 - 163.121.255.255
deny 165.231.0.0 - 165.231.255.255
deny 192.52.232.0 - 192.52.232.255
deny 193.17.215.0 - 193.17.215.255
deny 193.19.232.0 - 193.19.235.255
deny 193.41.146.0 - 193.41.147.255
deny 193.108.23.0 - 193.108.23.255
deny 193.108.28.0 - 193.108.28.255
deny 193.109.66.0 - 193.109.67.255
deny 193.110.104.0 - 193.110.105.255
deny 193.194.128.0 - 193.194.128.255
deny 193.227.128.0 - 193.227.128.255
deny 194.9.64.0 - 194.9.65.255
deny 194.9.82.0 - 194.9.83.255
deny 195.24.80.0 - 195.24.87.255
deny 195.39.218.0 - 195.39.219.255
deny 195.234.120.0 - 195.234.123.255
deny 195.234.168.0 - 195.234.168.255
deny 195.234.185.0 - 195.234.185.255
deny 195.234.252.0 - 195.234.255.255
deny 193.188.7.0/24
deny 193.189.0.0/18
deny 193.189.128.0/24
deny 193.194.160.0/19
deny 193.221.218.0/24
deny 64.57.112.0 - 64.57.127.255
deny 66.8.0.0 - 66.8.127.255
deny 66.18.64.0 - 66.18.95.255
deny 69.63.64.0 - 69.63.79.255
deny 69.67.32.0 - 69.67.47.255
deny 137.158.0.0 - 137.158.255.255
deny 137.214.0.0 - 137.214.255.255
deny 137.215.0.0 - 137.215.255.255
deny 139.53.0.0 - 139.53.255.255
deny 143.128.0.0 - 143.128.255.255
deny 143.160.0.0 - 143.160.255.255
deny 146.64.0.0 - 146.64.255.255
deny 146.141.0.0 - 146.141.255.255
deny 146.182.0.0 - 146.182.255.255
deny 146.230.0.0 - 146.230.255.255
deny 146.231.0.0 - 146.231.255.255
deny 146.232.0.0 - 146.232.255.255
deny 147.110.0.0 - 147.110.255.255
deny 152.106.0.0 - 152.106.255.255
deny 152.107.0.0 - 152.107.255.255
deny 152.108.0.0 - 152.108.255.255
deny 152.109.0.0 - 152.109.255.255
deny 152.110.0.0 - 152.110.255.255
deny 152.111.0.0 - 152.111.255.255
deny 152.112.0.0 - 152.112.255.255
deny 155.159.0.0 - 155.159.255.255
deny 155.232.0.0 - 155.232.255.255
deny 155.233.0.0 - 155.233.255.255
deny 155.234.0.0 - 155.234.255.255
deny 155.235.0.0 - 155.235.255.255
deny 155.236.0.0 - 155.236.255.255
deny 155.237.0.0 - 155.237.255.255
deny 155.238.0.0 - 155.238.255.255
deny 155.239.0.0 - 155.239.255.255
deny 155.240.0.0 - 155.240.255.255
deny 156.8.0.0 - 156.8.255.255
deny 160.115.0.0 - 160.115.255.255
deny 160.116.0.0 - 160.116.255.255
deny 160.117.0.0 - 160.117.255.255
deny 160.118.0.0 - 160.118.255.255
deny 160.119.0.0 - 160.119.255.255
deny 160.120.0.0 - 160.120.255.255
deny 160.121.0.0 - 160.121.255.255
deny 160.122.0.0 - 160.122.255.255
deny 160.123.0.0 - 160.123.255.255
deny 160.124.0.0 - 160.124.255.255
deny 163.195.0.0 - 163.195.255.255
deny 163.196.0.0 - 163.196.255.255
deny 163.197.0.0 - 163.197.255.255
deny 163.198.0.0 - 163.198.255.255
deny 163.199.0.0 - 163.199.255.255
deny 163.200.0.0 - 163.200.255.255
deny 163.201.0.0 - 163.201.255.255
deny 163.202.0.0 - 163.202.255.255
deny 163.203.0.0 - 163.203.255.255
deny 164.88.0.0 - 164.88.255.255
deny 164.146.0.0 - 164.151.255.255
deny 164.155.0.0 - 164.155.255.255
deny 165.3.0.0 - 165.5.255.255
deny 165.8.0.0 - 165.11.255.255
deny 165.25.0.0 - 165.25.255.255
deny 165.143.0.0 - 165.149.255.255
deny 165.165.0.0 - 165.165.255.255
deny 165.180.0.0 - 165.180.255.255
deny 165.233.0.0 - 165.233.255.255
deny 166.85.0.0 - 166.85.255.255
deny 168.76.0.0 - 168.76.255.255
deny 168.80.0.0 - 168.81.255.255
deny 168.89.0.0 - 168.89.255.255
deny 168.128.0.0 - 168.128.255.255
deny 168.142.0.0 - 168.142.255.255
deny 168.155.0.0 - 168.155.255.255
deny 168.164.0.0 - 168.164.255.255
deny 168.167.0.0 - 168.167.255.255
deny 168.172.0.0 - 168.172.255.255
deny 168.206.0.0 - 168.206.255.255
deny 168.209.0.0 - 168.210.255.255
deny 169.129.0.0 - 169.129.255.255
deny 169.202.0.0 - 169.202.255.255
deny 192.33.10.0 - 192.33.10.255
deny 192.42.99.0 - 192.42.99.255
deny 192.48.253.0 - 192.48.253.255
deny 192.68.138.0 - 192.68.138.255
deny 192.70.237.0 - 192.70.237.255
deny 192.82.142.0 - 192.82.142.255
deny 192.84.244.0 - 192.84.244.255
deny 192.94.61.0 - 192.94.61.255
deny 192.94.210.0 - 192.94.210.255
deny 192.94.240.0 - 192.94.240.255
deny 192.94.241.0 - 192.94.241.255
deny 192.94.246.0 - 192.94.246.255
deny 192.96.0.0 - 192.96.255.255
deny 192.100.1.0 - 192.100.1.255
deny 192.101.142.0 - 192.101.142.255
deny 192.102.9.0 - 192.102.9.255
deny 192.133.250.0 - 192.133.250.255
deny 192.136.55.0 - 192.136.55.255
deny 192.136.56.0 - 192.136.56.255
deny 192.136.57.0 - 192.136.57.255
deny 192.157.190.0 - 192.157.190.255
deny 192.188.164.0 - 192.188.167.255
deny 192.189.75.0 - 192.189.75.255
deny 192.189.139.0 - 192.189.140.255
deny 192.231.237.0 - 192.231.237.255
deny 192.231.254.0 - 192.231.254.255
deny 192.245.148.0 - 192.245.148.255
deny 192.251.202.0 - 192.251.202.255
deny 198.54.0.0 - 198.54.255.255
deny 200.16.8.0 - 200.16.15.255
deny 204.12.128.0 - 204.12.143.255
deny 204.87.179.0 - 204.87.179.255
deny 204.152.14.0 - 204.152.15.255
deny 204.235.32.0 - 204.235.43.255
deny 205.159.79.0 - 205.159.79.255
deny 206.223.136.0 - 206.223.136.255
deny 209.203.0.0 - 209.203.63.255
deny 209.212.96.0 - 209.212.127.255
deny 216.236.176.0 - 216.236.191.255
deny 202.123.0.0/19
deny 62.8.64.0/19
deny 62.12.96.0/19
deny 62.24.96.0/19
deny 62.61.192.0/18
deny 62.68.32.0/19
deny 62.68.224.0/19
deny 62.114.0.0/16
deny 62.117.32.0/19
deny 62.135.0.0/17
deny 62.139.0.0/16
deny 62.140.64.0/18
deny 62.173.32.0/19
deny 62.193.64.0/18
deny 62.193.160.0/19
deny 62.240.32.0/19
deny 62.240.96.0/19
deny 62.241.128.0/19
deny 62.251.128.0/17
deny 77.220.0.0/19
deny 80.67.128.0/20
deny 80.72.96.0/20
deny 80.75.160.0/19
deny 80.87.64.0/19
deny 80.88.0.0/20
deny 80.95.0.0/20
deny 80.240.192.0/20
deny 80.246.0.0/20
deny 80.248.0.0/20
deny 80.248.64.0/20
deny 80.249.64.0/20
deny 80.250.32.0/20
deny 81.4.0.0/18
deny 81.10.0.0/17
deny 81.21.96.0/20
deny 81.22.64.0/19
deny 81.26.64.0/20
deny 81.29.96.0/20
deny 81.91.224.0/20
deny 81.192.0.0/16
deny 82.101.128.0/18
deny 82.128.0.0/17
deny 82.129.128.0/17
deny 82.151.64.0/19
deny 82.201.128.0/17
deny 84.36.0.0/16
deny 84.233.0.0/17
deny 87.255.96.0/19
deny 193.95.0.0/17
deny 193.108.214.0/24
deny 193.108.252.0/22
deny 193.189.64.0 - 193.189.65.255
deny 193.194.1.0 - 193.194.5.255
deny 193.194.32.0 - 193.194.95.255
deny 193.227.0.0/18
deny 194.6.224.0/24
deny 194.79.96.0/19
deny 194.204.192.0/18
deny 195.24.192.0/19
deny 195.43.0.0/19
deny 195.166.224.0/19
deny 195.202.64.0/19
deny 195.246.32.0/19
deny 212.0.128.0/19
deny 212.12.224.0/19
deny 212.22.160.0/19
deny 212.49.64.0/19
deny 212.52.128.0/19
deny 212.60.64.0/19
deny 212.85.192.0/19
deny 212.88.96.0/19
deny 212.96.0.0/19
deny 212.100.64.0/19
deny 212.103.160.0/19
deny 212.122.224.0/19
deny 212.217.0.0/17
deny 213.55.64.0/18
deny 213.131.64.0/19
deny 213.136.96.0/19
deny 213.147.64.0/19
deny 213.150.96.0/19
deny 213.150.160.0 - 213.150.223.255
deny 213.152.64.0/19
deny 213.154.32.0 - 213.154.95.255
deny 213.158.160.0/19
deny 213.172.128.0/19
deny 213.179.160.0/19
deny 213.181.224.0/19
deny 213.193.32.0/19
deny 213.212.192.0/18
deny 213.247.0.0/19
deny 213.255.128.0/19
deny 217.14.80.0/20
deny 217.20.224.0/20
deny 217.21.112.0/20
deny 217.29.128.0/20
deny 217.29.208.0/20
deny 217.52.0.0/14
deny 217.64.96.0/20
deny 217.77.64.0/20
deny 217.78.64.0/20
deny 217.117.0.0/20
deny 217.139.0.0/16
deny 217.170.144.0/20
deny 217.199.144.0/20
deny 129.0.0.0/16
deny 129.18.0.0/16
deny 129.45.0.0/16
deny 129.56.0.0/16
deny 129.122.0.0/16
deny 129.140.0.0/16
deny 129.205.0.0/16
deny 129.232.0.0/16
deny 137.63.0.0 - 137.64.255.255
deny 137.115.0.0/16
deny 137.171.0.0/16
deny 137.196.0.0/16
deny 137.255.0.0/16
deny 155.0.0.0/16
deny 155.11.0.0 - 155.12.255.255
deny 155.89.0.0/16
deny 155.93.0.0/16
deny 155.196.0.0/16
deny 155.251.0.0/16
deny 155.255.0.0 - 156.0.255.255
deny 156.38.0.0/16
deny 156.155.0.0 - 156.255.255.255
deny 160.0.0.0/16
deny 160.77.0.0/16
deny 160.89.0.0 - 160.90.255.255
deny 160.105.0.0/16
deny 160.113.0.0/16
deny 160.152.0.0/16
deny 160.154.0.0 - 160.179.255.255
deny 160.181.0.0 - 160.184.255.255
deny 160.224.0.0 - 160.226.255.255
deny 160.242.0.0/16
deny 160.255.0.0/16
deny 165.0.0.0/16
deny 165.16.0.0/16
deny 165.49.0.0 - 165.63.255.255
deny 165.73.0.0/16
deny 165.90.0.0/16
deny 165.169.0.0/16
deny 165.210.0.0/15
deny 165.255.0.0/16
deny 168.211.0.0 - 168.211.255.255
deny 168.253.0.0/16
deny 169.0.0.0/15
deny 169.159.0.0/16
deny 169.239.0.0/16
deny 169.255.0.0/16
deny 192.109.242.0/24

View File

@@ -0,0 +1,2 @@
# local baseline-v1: the single valid ROA EE certificate has this IPv4 block
allow 203.0.113.0/24

View File

@@ -0,0 +1,3 @@
# local baseline-v1 negative control: deny wins over a covering allow rule
allow 0.0.0.0/0
deny 203.0.113.0/24

View File

@@ -0,0 +1,12 @@
# Remote RIPE TA soak policy for feature #151.
#
# Start permissive for every INR kind, then carve out several /8 blocks
# allocated to AFRINIC. These denies should not match normal RIPE NCC
# EE resources, while exercising deny-overrides-allow in a live run.
allow 0.0.0.0/0
allow ::/0
allow 0 - 4294967295
deny 41.0.0.0/8
deny 102.0.0.0/8
deny 105.0.0.0/8

View File

@@ -29,7 +29,7 @@ fn live_http_fetcher() -> BlockingHttpFetcher {
BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(timeout_secs),
large_body_timeout: Duration::from_secs(timeout_secs),
user_agent: "rpki-dev/0.1 (stage2 live rrdp delta test)".to_string(),
user_agent: "panda-rpki/0.2 (live rrdp delta test)".to_string(),
..HttpFetcherConfig::default()
})
.expect("http fetcher")

View File

@@ -244,6 +244,7 @@ fn verify_against_vcir_store_matches_manifest_hashes() {
sha256: manifest_hash.clone(),
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
}],
summary: VcirSummary {
local_vrp_count: 0,

View File

@@ -109,6 +109,7 @@ fn store_validated_manifest_baseline(
sha256: manifest_sha256,
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
}],
summary: VcirSummary {
local_vrp_count: 0,

View File

@@ -112,6 +112,7 @@ fn store_validated_manifest_baseline(
sha256: manifest_sha256,
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
}],
summary: VcirSummary {
local_vrp_count: 0,

View File

@@ -51,11 +51,22 @@ async function parseErrorBody(res: Response): Promise<string> {
return `HTTP ${res.status} ${res.statusText}`.trim();
}
/** Default timeout for every API request — hung streams must not spin forever. */
const REQUEST_TIMEOUT_MS = 30_000;
/** Combine the caller's abort signal (if any) with the default timeout. */
function requestSignal(signal?: AbortSignal | null): AbortSignal {
const signals = [signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)].filter(
(s): s is AbortSignal => s != null,
);
return AbortSignal.any(signals);
}
/** Fetch JSON from the API, throwing ApiError on non-2xx. */
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(path, init);
res = await fetch(path, { ...init, signal: requestSignal(init?.signal) });
} catch (err) {
throw new ApiError(0, err instanceof Error ? err.message : "network error");
}
@@ -69,7 +80,7 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
export async function apiFetchBlob(path: string): Promise<Blob> {
let res: Response;
try {
res = await fetch(path);
res = await fetch(path, { signal: requestSignal() });
} catch (err) {
throw new ApiError(0, err instanceof Error ? err.message : "network error");
}

View File

@@ -244,7 +244,8 @@ export const manifestFileEntrySchema = z
export const revokedCertEntrySchema = z
.object({
serialNumberHex: z.string().nullish(),
serialNumber: z.string().nullish(),
// Backend serializes CRL serials as a JSON number when they fit, else a string.
serialNumber: z.union([z.string(), z.number()]).nullish(),
revocationDate: z.string().nullish(),
})
.passthrough();

View File

@@ -76,7 +76,8 @@ async function getList<S extends z.ZodTypeAny>(
/* ------------------------------------------------------------------ */
export function getServiceInfo(): Promise<ServiceInfo> {
return getData(`${API}`, serviceInfoSchema);
// Trailing slash: the axum router serves the info document at `/api/v1/`.
return getData(`${API}/`, serviceInfoSchema);
}
export function getHealth(): Promise<HealthStatus> {
@@ -116,9 +117,15 @@ export function getRunSummary(runId: string): Promise<RunSummary> {
/* Repositories */
/* ------------------------------------------------------------------ */
export type RepoListParams = PageParams & {
/** Server-side sort; currently only "objects" is supported. */
sort?: "objects";
order?: "asc" | "desc";
};
export function listRepos(
runId: string,
params: PageParams = {},
params: RepoListParams = {},
): Promise<ListResult<RepositoryRecord>> {
return getList(`${runBase(runId)}/repos`, repositoryRecordSchema, params);
}
@@ -417,6 +424,10 @@ export function getStatsObjectTypes(runId: string): Promise<Record<string, numbe
return getData(`${runBase(runId)}/stats/object-types`, statsMapSchema);
}
export function getStatsOverview(runId: string): Promise<Record<string, number>> {
return getData(`${runBase(runId)}/stats/overview`, statsMapSchema);
}
export function getStatsValidation(runId: string): Promise<Record<string, number>> {
return getData(`${runBase(runId)}/stats/validation`, statsMapSchema);
}

View File

@@ -86,6 +86,19 @@ export function DataTable<T>({
key={rowKey(row)}
className={onRowClick ? "clickable" : undefined}
onClick={onRowClick ? () => onRowClick(row) : undefined}
// Clickable rows behave like links: focusable and Enter/Space-activated.
tabIndex={onRowClick ? 0 : undefined}
role={onRowClick ? "link" : undefined}
onKeyDown={
onRowClick
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onRowClick(row);
}
}
: undefined
}
>
{columns.map((col) => (
<td

View File

@@ -14,16 +14,48 @@ import { StatusPill } from "./StatusPill";
export interface ObjectFilterValues {
type: string;
result: string;
reason: string;
rejectedOnly: boolean;
q: string;
}
export const EMPTY_OBJECT_FILTERS: ObjectFilterValues = {
type: "",
result: "",
reason: "",
rejectedOnly: false,
q: "",
};
/** Read object filters from the page URL (standalone pages sync them there). */
export function objectFiltersFromParams(params: URLSearchParams): ObjectFilterValues {
return {
type: params.get("type") ?? "",
result: params.get("result") ?? "",
reason: params.get("reason") ?? "",
rejectedOnly: params.get("rejected") === "true",
q: params.get("q") ?? "",
};
}
/** Write object filters into a URLSearchParams copy, preserving other params. */
export function objectFiltersToParams(
next: ObjectFilterValues,
base: URLSearchParams,
): URLSearchParams {
const params = new URLSearchParams(base);
const write = (key: string, value: string) => {
if (value) params.set(key, value);
else params.delete(key);
};
write("type", next.type);
write("result", next.result);
write("reason", next.reason);
write("q", next.q);
if (next.rejectedOnly) params.set("rejected", "true");
else params.delete("rejected");
return params;
}
/** Report-stream object type vocabulary (backend also accepts mft/cer/asa aliases). */
const TYPE_OPTIONS = ["roa", "manifest", "crl", "certificate", "aspa", "gbr"];
const RESULT_OPTIONS = ["ok", "error", "skipped"];
@@ -55,7 +87,16 @@ export function ObjectsTable({
useEffect(() => {
setQDraft(filters.q);
}, [filters.q]);
const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.rejectedOnly, filters.q]);
// Auto-apply the URI draft after a short typing pause (Enter/blur still work).
useEffect(() => {
const trimmed = qDraft.trim();
if (trimmed === filters.q) return;
const timer = setTimeout(() => onFiltersChange({ ...filters, q: trimmed }), 400);
return () => clearTimeout(timer);
}, [qDraft, filters, onFiltersChange]);
const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.reason, filters.rejectedOnly, filters.q]);
const pager = useCursorPager(filterKey);
const query = useQuery({
@@ -67,6 +108,7 @@ export function ObjectsTable({
type: filters.type || undefined,
result: filters.result || undefined,
rejected: filters.rejectedOnly || undefined,
reason: filters.reason || undefined,
q: filters.q || undefined,
}),
placeholderData: (prev) => prev,
@@ -101,7 +143,9 @@ export function ObjectsTable({
{
key: "result",
header: "Result",
render: (obj) => <StatusPill status={obj.rejected ? "rejected" : obj.result} />,
// Raw `result` value — the Result filter options must match what the
// column displays (rejection is visible via the reason column/filter).
render: (obj) => <StatusPill status={obj.result} />,
},
...(showReason
? [
@@ -162,6 +206,17 @@ export function ObjectsTable({
))}
</select>
</div>
<div className="filter-field">
<label htmlFor={`${queryKeyScope}-reason`}>Reason contains</label>
<input
id={`${queryKeyScope}-reason`}
type="search"
className="mono"
value={filters.reason}
onChange={(e) => onFiltersChange({ ...filters, reason: e.target.value })}
placeholder="substring…"
/>
</div>
<div className="filter-field">
<label htmlFor={`${queryKeyScope}-q`}>URI contains</label>
<input
@@ -174,7 +229,7 @@ export function ObjectsTable({
if (e.key === "Enter") submitQ();
}}
onBlur={submitQ}
placeholder="substring…"
placeholder="substring… (applies as you type)"
/>
</div>
<label className="filter-check">
@@ -186,7 +241,7 @@ export function ObjectsTable({
Rejected only
</label>
<span className="text-faint text-small" style={{ marginLeft: "auto" }}>
server-side filters
server-side filters · URI search applies as you type
</span>
</div>

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { listManifestFiles, listRevokedCertificates } from "../api/service";
import type { ProjectionRecord } from "../api/schemas";
import { CopyableValue } from "./CopyableValue";
@@ -19,6 +19,147 @@ function MetaRow({ label, value, mono = false }: { label: string; value: ReactNo
);
}
/** Format an IP address from a projection byte array (v4 dotted / v6 compressed hextets). */
function formatIpAddr(addr: unknown, afi: unknown): string | null {
if (!Array.isArray(addr)) return null;
const bytes = addr.filter((b): b is number => typeof b === "number");
if (bytes.length === 0) return null;
if (asString(afi) === "Ipv6" || bytes.length > 4) {
const groups: number[] = [];
for (let i = 0; i + 1 < bytes.length; i += 2) {
groups.push((bytes[i] << 8) | bytes[i + 1]);
}
// RFC 5952-style compression of the longest run of zero groups.
let bestStart = -1;
let bestLen = 0;
for (let i = 0; i < groups.length; ) {
if (groups[i] !== 0) {
i += 1;
continue;
}
let j = i;
while (j < groups.length && groups[j] === 0) j += 1;
if (j - i > bestLen) {
bestStart = i;
bestLen = j - i;
}
i = j;
}
const hex = groups.map((g) => g.toString(16));
if (bestLen >= 2) {
const head = hex.slice(0, bestStart).join(":");
const tail = hex.slice(bestStart + bestLen).join(":");
return `${head}::${tail}`;
}
return hex.join(":");
}
return bytes.join(".");
}
/**
* Flatten projection ipResources to display strings.
* Real shape (serde of the rpki types):
* { families: [ { afi, choice: { AddressesOrRanges: [ {Prefix:{addr,afi,prefix_len}} | {Range:{min,max}} ] } } ] }
*/
function formatIpResources(resources: unknown): string[] {
const out: string[] = [];
for (const famRaw of asArray(asRecord(resources)?.families)) {
const fam = asRecord(famRaw);
const choice = asRecord(fam?.choice);
if (!choice) continue;
if ("Inherit" in choice) {
out.push("inherit");
continue;
}
for (const entryRaw of asArray(choice.AddressesOrRanges)) {
const entry = asRecord(entryRaw);
const prefix = asRecord(entry?.Prefix);
if (prefix) {
const base = formatIpAddr(prefix.addr, prefix.afi ?? fam?.afi);
out.push(base ? `${base}/${asNumber(prefix.prefix_len) ?? "?"}` : JSON.stringify(entryRaw));
continue;
}
const range = asRecord(entry?.Range);
if (range) {
const min = formatIpAddr(asRecord(range.min)?.addr ?? range.min, fam?.afi);
const max = formatIpAddr(asRecord(range.max)?.addr ?? range.max, fam?.afi);
out.push(min && max ? `${min} - ${max}` : JSON.stringify(entryRaw));
continue;
}
out.push(JSON.stringify(entryRaw));
}
}
return out;
}
/**
* Flatten projection asResources to display strings.
* Real shape: { asnum: { AsIdsOrRanges: [ {Id: n} | {Range: {min, max}} ] } } or { "inherit"/"Inherit": … }.
*/
function formatAsResources(resources: unknown): string[] {
const rec = asRecord(resources);
if (!rec) return [];
if ("inherit" in rec || "Inherit" in rec) return ["inherit"];
const out: string[] = [];
for (const entryRaw of asArray(asRecord(rec.asnum)?.AsIdsOrRanges)) {
const entry = asRecord(entryRaw);
const id = asNumber(entry?.Id);
if (id != null) {
out.push(`AS${id}`);
continue;
}
const range = asRecord(entry?.Range);
if (range) {
out.push(`AS${asNumber(range.min) ?? "?"}-AS${asNumber(range.max) ?? "?"}`);
continue;
}
out.push(JSON.stringify(entryRaw));
}
return out;
}
/** Render a list of URIs (one per line) or null when empty. */
function uriLines(uris: string[]): ReactNode {
if (!uris.length) return null;
return (
<span>
{uris.map((u) => (
<span key={u} className="mono" style={{ display: "block", wordBreak: "break-all" }}>
{u}
</span>
))}
</span>
);
}
/**
* Chip list that collapses to `collapsedCount` entries with a clickable
* "+N more" toggle; expanding shows every item (plus a "Show less" toggle).
*/
export function ExpandableChipList({ items, collapsedCount = 12 }: { items: string[]; collapsedCount?: number }) {
const [expanded, setExpanded] = useState(false);
if (!items.length) return <>—</>;
const collapsible = items.length > collapsedCount;
const visible = expanded || !collapsible ? items : items.slice(0, collapsedCount);
return (
<span className="chip-list">
{visible.map((item, i) => (
<span className="chip" key={i}>{item}</span>
))}
{collapsible ? (
<button
type="button"
className="chip chip-more"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
>
{expanded ? "Show less" : `+${items.length - collapsedCount} more`}
</button>
) : null}
</span>
);
}
function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
const query = useQuery({
@@ -69,12 +210,16 @@ function RevokedCerts({ runId, objectInstanceId }: { runId: string; objectInstan
placeholderData: (prev) => prev,
});
const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | null; revocationDate?: string | null }>[] = [
const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | number | null; revocationDate?: string | null }>[] = [
{
key: "serial",
header: "Serial number",
render: (row) => (
<CopyableValue value={row.serialNumberHex ?? row.serialNumber} max={44} label="serial number" />
<CopyableValue
value={row.serialNumberHex ?? (row.serialNumber != null ? String(row.serialNumber) : null)}
max={44}
label="serial number"
/>
),
},
{ key: "date", header: "Revocation date", render: (row) => formatUtc(row.revocationDate) },
@@ -175,12 +320,43 @@ function AspaView({ projection }: { projection: Record<string, unknown> }) {
);
}
function CertificateView({ projection }: { projection: Record<string, unknown> }) {
const cert = asRecord(projection.certificate) ?? asRecord(projection.cer) ?? projection;
export function CertificateView({ projection }: { projection: Record<string, unknown> }) {
// Real API shape: the certificate payload lives in `resourceCertificate`
// with extensions nested under `extensions`; keep the older
// certificate/cer + flat-field variants working as fallbacks.
const cert =
asRecord(projection.certificate) ??
asRecord(projection.cer) ??
asRecord(projection.resourceCertificate) ??
projection;
const validity = asRecord(cert.validity);
const resources = asRecord(cert.resources);
const ipBlocks = asArray(resources?.ipResources ?? resources?.ipv4 ?? resources?.addresses);
const asBlocks = asArray(resources?.asResources ?? resources?.asns);
const ext = asRecord(cert.extensions);
const legacyResources = asRecord(cert.resources);
const siaDescriptions = asArray(asRecord(ext?.subjectInfoAccess)?.accessDescriptions);
const siaUris = siaDescriptions
.map((d) => asString(asRecord(d)?.accessLocation))
.filter((v): v is string => Boolean(v));
const aiaUris = asArray(ext?.caIssuersUris)
.map((u) => asString(u))
.filter((v): v is string => Boolean(v));
const crlDpUris = asArray(ext?.crlDistributionPointsUris)
.map((u) => asString(u))
.filter((v): v is string => Boolean(v));
const extIp = formatIpResources(ext?.ipResources);
const ipBlocks = extIp.length
? extIp
: asArray(legacyResources?.ipResources ?? legacyResources?.ipv4 ?? legacyResources?.addresses).map((b) =>
typeof b === "string" ? b : JSON.stringify(b),
);
const extAs = formatAsResources(ext?.asResources);
const asBlocks = extAs.length
? extAs
: asArray(legacyResources?.asResources ?? legacyResources?.asns).map((b) =>
typeof b === "string" || typeof b === "number" ? String(b) : JSON.stringify(b),
);
return (
<dl className="meta-grid">
<MetaRow label="Serial number" value={asString(cert.serialNumberHex ?? cert.serialNumber)} mono />
@@ -188,43 +364,42 @@ function CertificateView({ projection }: { projection: Record<string, unknown> }
<MetaRow label="Subject" value={asString(cert.subject)} mono />
<MetaRow
label="Validity"
value={`${asString(validity?.notBefore) ?? "—"} → ${asString(validity?.notAfter) ?? "—"}`}
value={`${formatUtc(asString(validity?.notBefore)) ?? "—"} → ${formatUtc(asString(validity?.notAfter)) ?? "—"}`}
mono
/>
<MetaRow label="SKI" value={<CopyableValue value={asString(cert.ski)} max={56} label="SKI" />} />
<MetaRow label="AKI" value={<CopyableValue value={asString(cert.aki)} max={56} label="AKI" />} />
<MetaRow label="SIA" value={<CopyableValue value={asString(cert.sia)} max={72} label="SIA" />} />
<MetaRow label="AIA" value={<CopyableValue value={asString(cert.aia)} max={72} label="AIA" />} />
<MetaRow label="CRL DP" value={<CopyableValue value={asString(cert.crlDistributionPoint ?? cert.crldp)} max={72} label="CRL distribution point" />} />
<MetaRow
label="IP resources"
label="SKI"
value={<CopyableValue value={asString(cert.ski) ?? asString(ext?.subjectKeyIdentifier)} max={56} label="SKI" />}
/>
<MetaRow
label="AKI"
value={<CopyableValue value={asString(cert.aki) ?? asString(ext?.authorityKeyIdentifier)} max={56} label="AKI" />}
/>
<MetaRow
label="SIA"
value={asString(cert.sia) ? <CopyableValue value={asString(cert.sia)} max={72} label="SIA" /> : uriLines(siaUris)}
/>
<MetaRow
label="AIA"
value={asString(cert.aia) ? <CopyableValue value={asString(cert.aia)} max={72} label="AIA" /> : uriLines(aiaUris)}
/>
<MetaRow
label="CRL DP"
value={
ipBlocks.length ? (
<span className="chip-list">
{ipBlocks.slice(0, 12).map((b, i) => (
<span className="chip" key={i}>{typeof b === "string" ? b : JSON.stringify(b)}</span>
))}
{ipBlocks.length > 12 ? <span className="chip">+{ipBlocks.length - 12} more</span> : null}
</span>
asString(cert.crlDistributionPoint ?? cert.crldp) ? (
<CopyableValue value={asString(cert.crlDistributionPoint ?? cert.crldp)} max={72} label="CRL distribution point" />
) : (
"—"
uriLines(crlDpUris)
)
}
/>
<MetaRow
label="IP resources"
value={<ExpandableChipList items={ipBlocks} />}
/>
<MetaRow
label="AS resources"
value={
asBlocks.length ? (
<span className="chip-list">
{asBlocks.slice(0, 12).map((b, i) => (
<span className="chip" key={i}>{typeof b === "string" || typeof b === "number" ? String(b) : JSON.stringify(b)}</span>
))}
{asBlocks.length > 12 ? <span className="chip">+{asBlocks.length - 12} more</span> : null}
</span>
) : (
"—"
)
}
value={<ExpandableChipList items={asBlocks} />}
/>
<MetaRow label="Signature algorithm" value={asString(cert.signatureAlgorithm)} mono />
</dl>
@@ -257,12 +432,16 @@ export function ProjectionView({
const projection = record.projection;
const type = (record.objectType ?? "").toLowerCase();
// The query service nests the typed payload one level down:
// projection = { input, object: { roa | manifest | crl | certificate | aspa, … }, schemaVersion, tool }
// Accept a flat payload too, so older fixtures / shape variants keep working.
const typed = asRecord(projection.object) ?? projection;
if (type === "roa") return <RoaView projection={projection} />;
if (type === "asa" || type === "aspa") return <AspaView projection={projection} />;
if (type === "roa") return <RoaView projection={typed} />;
if (type === "asa" || type === "aspa") return <AspaView projection={typed} />;
if (type === "mft") {
const manifest = asRecord(projection.manifest);
const manifest = asRecord(typed.manifest);
return (
<>
<dl className="meta-grid" style={{ marginBottom: 16 }}>
@@ -278,15 +457,37 @@ export function ProjectionView({
}
if (type === "crl") {
const crl = asRecord(projection.crl);
// Real API shape: CRL fields sit directly on the typed payload
// ({ issuer, thisUpdate, nextUpdate, extensions: { crlNumberHex, authorityKeyIdentifier }, … }),
// there is no `crl` wrapper; keep `typed.crl` as a legacy fallback.
const crl = asRecord(typed.crl) ?? typed;
const crlExt = asRecord(crl.extensions);
return (
<>
<dl className="meta-grid" style={{ marginBottom: 16 }}>
<MetaRow label="Issuer" value={asString(crl?.issuer)} mono />
<MetaRow label="thisUpdate" value={formatUtc(asString(crl?.thisUpdate))} />
<MetaRow label="nextUpdate" value={formatUtc(asString(crl?.nextUpdate))} />
<MetaRow label="CRL number" value={asString(crl?.crlNumberHex ?? crl?.crlNumber)} mono />
<MetaRow label="AKI" value={<CopyableValue value={asString(crl?.aki)} max={56} label="AKI" />} />
<MetaRow label="Issuer" value={asString(crl.issuer)} mono />
<MetaRow label="thisUpdate" value={formatUtc(asString(crl.thisUpdate))} />
<MetaRow label="nextUpdate" value={formatUtc(asString(crl.nextUpdate))} />
<MetaRow
label="CRL number"
value={
asString(crl.crlNumberHex ?? crlExt?.crlNumberHex) ??
(asNumber(crl.crlNumber ?? crlExt?.crlNumber) != null
? String(asNumber(crl.crlNumber ?? crlExt?.crlNumber))
: null)
}
mono
/>
<MetaRow
label="AKI"
value={
<CopyableValue
value={asString(crl.aki) ?? asString(crlExt?.authorityKeyIdentifier)}
max={56}
label="AKI"
/>
}
/>
</dl>
<RevokedCerts runId={runId} objectInstanceId={objectInstanceId} />
</>
@@ -294,12 +495,12 @@ export function ProjectionView({
}
if (type === "cer" || type === "ee" || type === "router_cert") {
return <CertificateView projection={projection} />;
return <CertificateView projection={typed} />;
}
return (
<pre className="json-block" data-testid="projection-json">
{JSON.stringify(projection, null, 2)}
{JSON.stringify(typed, null, 2)}
</pre>
);
}

View File

@@ -59,6 +59,9 @@ function RunSelector() {
queryKey: ["runs", "selector"],
queryFn: () => listRuns({ limit: 25 }),
staleTime: 60_000,
// Soak runs keep producing new runs; poll so the dropdown picks them up
// without a manual page refresh.
refetchInterval: 30_000,
});
const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth });
const latestId = healthQuery.data?.latestReadyRun ?? runsQuery.data?.items[0]?.runId;
@@ -129,6 +132,9 @@ export function Shell({ children }: { children: ReactNode }) {
return (
<div className={`shell${collapsed ? " collapsed" : ""}`}>
<a href="#main-content" className="skip-link">
Skip to content
</a>
<div className="shell-brand">
<span className="brand-badge" aria-hidden="true">
<ShieldCheck size={15} />
@@ -170,7 +176,9 @@ export function Shell({ children }: { children: ReactNode }) {
<div className="nav-footer">ours RP query layer</div>
</nav>
<main className="shell-main">{children}</main>
<main className="shell-main" id="main-content" tabIndex={-1}>
{children}
</main>
</div>
);
}

View File

@@ -49,7 +49,9 @@ export function Tabs({
role="tab"
id={`tab-${tab.id}`}
aria-selected={selected}
aria-controls={`tabpanel-${tab.id}`}
// Only the active tab's panel is mounted — pointing aria-controls
// at an unmounted panel would leave a dangling reference.
aria-controls={selected ? `tabpanel-${tab.id}` : undefined}
tabIndex={selected ? 0 : -1}
onClick={() => onChange(tab.id)}
>

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