32 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
f86fbd4939 20260721 metrics 服务新增 CCR rpki-client 格式检查指标与 Grafana 面板(#125) 2026-07-21 18:07:05 +08:00
4074323302 20260714 补充 sync worker 消融实验远端运行脚本(#115 遗留) 2026-07-21 16:10:09 +08:00
b00a5f920c 20260721 rpctl ours RP 显式设置全局 repo sync workers 为 8 2026-07-21 15:55:15 +08:00
654b6d1c56 20260713_2 落地升级环境变量优先级契约(#114 主工作树收口) 2026-07-21 15:55:15 +08:00
daf0764cd0 20260720 规范化 CCR Manifest location 导出 2026-07-20 16:24:47 +08:00
06f4b086b4 20260718 Explorer 接入 VRP 检索查询 2026-07-20 10:05:00 +08:00
6d9eedd680 20260718 新增查询服务 VRP 检索接口 2026-07-20 10:04:43 +08:00
e80fba44c3 20260718 修复 Explorer 接口列表键值冲突 2026-07-18 10:46:25 +08:00
c48dc04909 20260718 格式化查询服务代码 2026-07-18 10:40:51 +08:00
a5902c0a5e 20260718 重构 RPKI Explorer 查询界面 2026-07-18 10:38:38 +08:00
a77982deaa 20260717 rpctl接入rpki-prover实验控制 2026-07-17 18:10:06 +08:00
4865d3698d 20260716 澄清CA验证上下文摘要命名 2026-07-16 22:54:59 +08:00
59623b449b 20260716 增加verification-only缓存正确性复核 2026-07-16 19:08:16 +08:00
7aac4855bd 20260715 add configurable max CA depth 2026-07-16 09:29:57 +08:00
198 changed files with 31060 additions and 5999 deletions

View File

@@ -1,6 +1,7 @@
target/
.git/
.gitignore
docker/
perf.*
**/* copy.excalidraw
ui/rpki-explorer/node_modules/

1
VERSION Normal file
View File

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

View File

@@ -2,6 +2,8 @@
# `build_docker_installer_package.sh` rewrites package-specific placeholders
# when producing amd64/arm64 release packages.
# 中文说明见 docs/README.zh-CN.md。English guide: docs/README.en.md
# Every assignment is a required non-empty configuration key with a package
# default. During upgrade: .env.example < reused old .env < command environment.
# Package metadata and architecture guardrails.
PACKAGE_ARCH=__PACKAGE_ARCH__
@@ -37,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
@@ -86,6 +110,12 @@ RPKI_PROGRESS_CONTROL_LOOP_SLOW_MS=2000
METRICS_INSTANCE=__PACKAGE_ARCH__-installer
METRICS_PORT=9556
METRICS_POLL_SECS=10
# CCR rpki-client format check: the artifact-metrics container runs
# `rpki-client -f result.ccr` once for every newly completed run and exports
# ours_rp_ccr_format_check_* metrics. The binary is packaged inside the
# metrics image; adjust only when deliberately testing another binary.
CCR_CHECK_BIN=/opt/ours-rp/bin/rpki-client
CCR_CHECK_TIMEOUT_SECS=120
# Optional external RTR report directory produced by a separately deployed RTR service.
# Default points to an installer-managed empty fallback directory. To enable real RTR
# metrics, set this to the host-side report directory, for example:

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
@@ -39,6 +41,10 @@ services:
- ${METRICS_POLL_SECS:-10}
- --instance
- ${METRICS_INSTANCE:-package-installer}
- --ccr-check-bin
- ${CCR_CHECK_BIN:-/opt/ours-rp/bin/rpki-client}
- --ccr-check-timeout-secs
- ${CCR_CHECK_TIMEOUT_SECS:-120}
ports:
- "${METRICS_PORT:-9556}:9556"
volumes:

View File

@@ -850,6 +850,343 @@
],
"title": "Fresh PP / Object Counts by Run",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"decimals": 0,
"unit": "none",
"mappings": [
{
"type": "value",
"options": {
"1": {
"text": "PASS",
"color": "green"
},
"0": {
"text": "FAIL",
"color": "red"
}
}
}
]
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 56,
"w": 6,
"h": 4
},
"id": 19,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "max(ours_rp_ccr_format_check_last_success and on (exported_instance) topk(1, ours_rp_run_sequence))",
"legendFormat": "CCR check",
"refId": "A"
}
],
"title": "Latest CCR Format Check",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"decimals": 0,
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 6,
"y": 56,
"w": 6,
"h": 4
},
"id": 20,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "max(ours_rp_ccr_format_check_last_run_sequence and on (exported_instance) topk(1, ours_rp_run_sequence))",
"legendFormat": "run seq",
"refId": "A"
}
],
"title": "CCR Checked Run Sequence",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"decimals": 1,
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 56,
"w": 6,
"h": 4
},
"id": 21,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "max(ours_rp_ccr_format_check_last_duration_seconds and on (exported_instance) topk(1, ours_rp_run_sequence))",
"legendFormat": "duration",
"refId": "A"
}
],
"title": "CCR Check Duration",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"decimals": 0,
"unit": "none",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"gridPos": {
"x": 18,
"y": 56,
"w": 6,
"h": 4
},
"id": 22,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "max(ours_rp_ccr_format_check_consecutive_failures and on (exported_instance) topk(1, ours_rp_run_sequence))",
"legendFormat": "failures",
"refId": "A"
}
],
"title": "CCR Check Consecutive Failures",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none",
"decimals": 0,
"min": 0
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 60,
"w": 24,
"h": 8
},
"id": 23,
"options": {
"legend": {
"calcs": [
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "sum by (result) (ours_rp_ccr_format_check_total)",
"legendFormat": "{{result}} checks",
"refId": "A"
},
{
"expr": "max(ours_rp_ccr_format_check_last_success and on (exported_instance) topk(1, ours_rp_run_sequence))",
"legendFormat": "latest check ok",
"refId": "B"
}
],
"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",
@@ -870,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.
@@ -80,7 +86,7 @@ Supported values:
- `rfc6487`: switch back to the original RFC 6487 resource containment behavior.
The installer / soak runner passes `--resource-validation-mode` to every `rpki` child start.
When an upgraded deployment reuses an older `.env` that does not contain this key, the installer appends the new default `validation-update-03` automatically.
When an upgraded deployment reuses an older `.env` that does not contain this key, the new package `.env.example` default `validation-update-03` remains in the final `.env`; an empty final value fails before Docker operations.
## Architecture Guardrails
@@ -125,6 +131,12 @@ If a separately deployed RTR service on the same host continuously writes `rtr-s
The command accepts only an absolute path on the same host, validates the candidate Compose mount, and atomically updates `.env`. It recreates only `artifact-metrics`, Prometheus, and Grafana; it does not restart or recreate `ours-rp-soak`. The `artifact-metrics` container mounts the directory read-only and reads it through `RPKI_METRICS_RTR_REPORT_DIR`, producing `ours_rp_rtr_*` metrics. By default, `RTR_REPORT_DIR` points at an empty fallback directory under the installer data root, so the metrics container also starts cleanly without an RTR service.
## CCR artifact format check metrics
The metrics image ships the official `rpki-client 9.8`. For every newly completed run, `artifact-metrics` runs `rpki-client -f result.ccr` once and exports `ours_rp_ccr_format_check_*` metrics (cumulative pass/ccr_parse_error/tool_error counters, latest check result, check duration, consecutive failures). The Grafana `Ours RP Soak Overview` dashboard shows them in a dedicated panel row. On service start only the latest existing run is checked; older history is not backfilled.
Related environment variables (normally no need to change): `CCR_CHECK_BIN` (defaults to the in-image `/opt/ours-rp/bin/rpki-client`) and `CCR_CHECK_TIMEOUT_SECS` (default 120 seconds).
## Data Directory
Default host directory:

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。
@@ -80,7 +85,7 @@ RESOURCE_VALIDATION_MODE=validation-update-03
- `rfc6487`:切换为 RFC 6487 原始资源包含判定语义。
installer / soak runner 每次启动 `rpki` 子进程时都会显式传入 `--resource-validation-mode`。
如果升级时复用旧 `.env` 且缺少该变量,installer 会自动补入默认值 `validation-update-03`,不会破坏旧 `.env` 复用流程。
如果升级时复用旧 `.env` 且缺少该变量,新包 `.env.example` 中的默认值 `validation-update-03` 会保留在最终 `.env` 中;最终值为空时升级会在 Docker 操作前失败。
## 架构检查
@@ -125,6 +130,12 @@ GRAFANA_ADMIN_PASSWORD=admin
该命令只接受本机绝对路径,会先校验候选 Compose 挂载,再原子更新 `.env`。随后只重建 `artifact-metrics`、Prometheus 和 Grafana,不重启或重建 `ours-rp-soak`。`artifact-metrics` 以只读方式挂载该目录,并通过 `RPKI_METRICS_RTR_REPORT_DIR` 读取 report,输出 `ours_rp_rtr_*` 指标。默认 `RTR_REPORT_DIR` 指向安装包数据目录下的空 fallback 目录,因此未接入 RTR 服务时 metrics 容器也能稳定启动。
## CCR 产物格式检查监控
metrics 镜像内置官方 `rpki-client 9.8`。`artifact-metrics` 会对每个新完成 run 的 `result.ccr` 执行一次 `rpki-client -f` 格式检查,并输出 `ours_rp_ccr_format_check_*` 指标(累计 pass/ccr_parse_error/tool_error、最近检查结果、检查耗时、连续失败数)。Grafana `Ours RP Soak Overview` 底部有对应面板。服务启动时只补查最新的一个历史 run,不回扫全部历史。
相关环境变量(通常无需修改):`CCR_CHECK_BIN`(默认为镜像内 `/opt/ours-rp/bin/rpki-client`)、`CCR_CHECK_TIMEOUT_SECS`(默认 120 秒)。
## 数据目录
默认宿主机目录:

View File

@@ -89,9 +89,26 @@ Extract the new package into a new directory and explicitly reuse the existing `
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
```
If the new package directory already has a `.env`, the upgrade script keeps it.
Configuration is merged in this order, with a key on the right overriding the same key on the left:
If an older `.env` does not contain `METRICS_IMAGE` or `METRICS_PLATFORM`, the install/upgrade flow backfills package-matched defaults automatically.
```text
new .env.example < old .env < environment variables on this upgrade command
```
For example, override the next-run interval and Grafana password for this upgrade:
```bash
INTERVAL_SECS=300 GRAFANA_ADMIN_PASSWORD='new-password' \
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
```
Before any image is loaded or container is started, upgrade verifies that every key declared by the new template is present and non-empty in the final `.env`. An explicit empty assignment fails, for example `INTERVAL_SECS= ./scripts/upgrade.sh ...`. Removed keys from an old `.env` are not copied, and the script reports key names only, never password-like values.
The installer reads `.env` as Compose-compatible dotenv assignments. Do not put shell command substitutions or environment references such as `$HOME` in the file; use concrete values or command-level overrides instead.
If the new package directory already has a `.env`, the upgrade script keeps it. In that case `--reuse-env-from` is not imported again, but environment variables from the current command still override the existing `.env`.
When an older `.env` lacks a key introduced by the new package, the new `.env.example` default remains in the final `.env`. Upgrade fails before Docker operations if any final declared value is empty.
Upgrade does not delete:

View File

@@ -89,9 +89,26 @@ curl -s "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" | grep '^ours_rp_rtr_'
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
```
如果新目录已经存在 `.env`,升级脚本会保留它,不覆盖。
配置按以下优先级合并,右侧同名键覆盖左侧:
如果旧 `.env` 缺失 `METRICS_IMAGE` 或 `METRICS_PLATFORM`,升级/安装链路会按当前包架构自动补齐默认值。
```text
新包 .env.example < 旧 .env < 本次 upgrade 命令的环境变量
```
例如,临时调整下轮触发间隔和 Grafana 密码:
```bash
INTERVAL_SECS=300 GRAFANA_ADMIN_PASSWORD='new-password' \
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
```
升级会在加载镜像或启动容器前检查:新模板声明的所有变量在最终 `.env` 中都必须存在且非空。显式传入空值会失败,例如 `INTERVAL_SECS= ./scripts/upgrade.sh ...`。旧 `.env` 中已不在新模板内的键不会复制,脚本只输出相关键名而不会输出密码等值。
installer 按与 Compose 一致的 dotenv 赋值格式读取 `.env`。不要在文件中使用 Shell 命令替换或 `$HOME` 一类环境变量引用;请使用确定的值,或在执行 upgrade 时通过命令环境变量覆盖。
如果新目录已经存在 `.env`,升级脚本会保留它,不覆盖;此时 `--reuse-env-from` 不会再次导入旧配置,但本次命令的环境变量仍会覆盖已存在的 `.env`。
旧 `.env` 缺少新版本新增的键时,新包 `.env.example` 的默认值会保留在最终 `.env` 中;任一最终值为空则升级会在 Docker 操作前失败。
升级不会删除以下数据:

View File

@@ -6,6 +6,11 @@ ENV_FILE="${ENV_FILE:-$INSTALLER_ROOT/.env}"
ENV_EXAMPLE="$INSTALLER_ROOT/.env.example"
COMPOSE_FILE="$INSTALLER_ROOT/compose/docker-compose.yml"
MANIFEST_FILE="${MANIFEST_FILE:-$INSTALLER_ROOT/PACKAGE-MANIFEST.env}"
declare -a ENV_TEMPLATE_KEYS=()
declare -A ENV_TEMPLATE_KEY_SET=()
declare -A INVOCATION_ENV_OVERRIDES=()
declare -A REUSED_ENV_KEYS=()
declare -A ENV_PARSED_VALUES=()
log() {
printf '[ours-rp-installer] %s\n' "$*"
@@ -48,20 +53,246 @@ env_file_has_key() {
[[ -f "$env_path" ]] && grep -Eq "^${key}=" "$env_path"
}
persist_env_default() {
load_env_template_keys() {
local env_path="$1"
local line
local key
[[ -f "$env_path" ]] || die "missing environment template: $env_path"
ENV_TEMPLATE_KEYS=()
ENV_TEMPLATE_KEY_SET=()
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)= ]] || die "invalid environment assignment in $env_path: $line"
key="${BASH_REMATCH[1]}"
[[ -z "${ENV_TEMPLATE_KEY_SET[$key]+x}" ]] || die "duplicate environment key in $env_path: $key"
ENV_TEMPLATE_KEYS+=("$key")
ENV_TEMPLATE_KEY_SET["$key"]=1
done < "$env_path"
(( ${#ENV_TEMPLATE_KEYS[@]} > 0 )) || die "no environment assignments found in $env_path"
}
template_has_env_key() {
local key="$1"
[[ -n "${ENV_TEMPLATE_KEY_SET[$key]+x}" ]]
}
env_file_assignment_keys() {
local env_path="$1"
local line
[[ -f "$env_path" ]] || die "missing environment file: $env_path"
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)= ]] || continue
printf '%s\n' "${BASH_REMATCH[1]}"
done < "$env_path"
}
dotenv_decode_value() {
local raw_value="$1"
local quote
local body
local decoded=""
local character
local next_character
local index=0
[[ -n "$raw_value" ]] || {
printf '\n'
return 0
}
quote="${raw_value:0:1}"
case "$quote" in
"'")
[[ "${raw_value: -1}" == "'" && ${#raw_value} -ge 2 ]] || return 1
body="${raw_value:1:${#raw_value}-2}"
while (( index < ${#body} )); do
character="${body:index:1}"
if [[ "$character" == "\\" && $((index + 1)) -lt ${#body} ]]; then
next_character="${body:index + 1:1}"
case "$next_character" in
"'")
decoded+="$next_character"
((index += 2))
continue
;;
esac
fi
decoded+="$character"
((index += 1))
done
;;
'"')
[[ "${raw_value: -1}" == '"' && ${#raw_value} -ge 2 ]] || return 1
body="${raw_value:1:${#raw_value}-2}"
while (( index < ${#body} )); do
character="${body:index:1}"
if [[ "$character" == '$' && $((index + 1)) -lt ${#body} && "${body:index + 1:1}" == '$' ]]; then
decoded+='$'
((index += 2))
continue
fi
if [[ "$character" == "\\" && $((index + 1)) -lt ${#body} ]]; then
next_character="${body:index + 1:1}"
case "$next_character" in
'"'|\\|'$'|'`')
decoded+="$next_character"
((index += 2))
continue
;;
n)
decoded+=$'\n'
((index += 2))
continue
;;
r)
decoded+=$'\r'
((index += 2))
continue
;;
t)
decoded+=$'\t'
((index += 2))
continue
;;
esac
fi
decoded+="$character"
((index += 1))
done
;;
*)
decoded="$raw_value"
;;
esac
printf '%s' "$decoded"
}
parse_env_file() {
local env_path="$1"
local line
local parsed_key
local raw_value
local parsed_value
[[ -f "$env_path" ]] || die "missing environment file: $env_path"
ENV_PARSED_VALUES=()
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || return 2
parsed_key="${BASH_REMATCH[1]}"
raw_value="${BASH_REMATCH[2]}"
parsed_value="$(dotenv_decode_value "$raw_value")" || return 2
ENV_PARSED_VALUES["$parsed_key"]="$parsed_value"
done < "$env_path"
}
env_file_value() {
local env_path="$1"
local key="$2"
parse_env_file "$env_path"
[[ -n "${ENV_PARSED_VALUES[$key]+x}" ]] || return 3
printf '%s' "${ENV_PARSED_VALUES[$key]}"
}
env_file_set_value() {
local env_path="$1"
local key="$2"
local value="$3"
local tmp_env
if env_file_has_key "$env_path" "$key"; then
local encoded_value
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "environment value for $key contains an unsupported newline"
encoded_value="$(env_file_encode_value "$value")"
if ! env_file_has_key "$env_path" "$key"; then
printf '%s=%s\n' "$key" "$encoded_value" >> "$env_path"
return 0
fi
tmp_env="$(mktemp)"
cat "$env_path" > "$tmp_env"
printf '%s=%s\n' "$key" "$value" >> "$tmp_env"
ENV_FILE_REPLACEMENT="$key=$encoded_value" awk -v key="$key" '
BEGIN { done=0 }
$0 ~ "^" key "=" { print ENVIRON["ENV_FILE_REPLACEMENT"]; done=1; next }
{ print }
END { if (!done) print ENVIRON["ENV_FILE_REPLACEMENT"] }
' "$env_path" > "$tmp_env"
mv "$tmp_env" "$env_path"
}
env_file_encode_value() {
local value="$1"
local escaped="$value"
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "environment value contains an unsupported newline"
escaped="${escaped//\\/\\\\}"
escaped="${escaped//\"/\\\"}"
escaped="${escaped//\$/\$\$}"
printf '"%s"' "$escaped"
}
load_env_file() {
local env_path="$1"
local key
parse_env_file "$env_path" || die "unable to parse environment file: $env_path"
for key in "${!ENV_PARSED_VALUES[@]}"; do
printf -v "$key" '%s' "${ENV_PARSED_VALUES[$key]}"
export "$key"
done
}
validate_env_file_contract() {
local env_path="$1"
local invalid_keys=""
local key
[[ -f "$env_path" ]] || die "missing environment file: $env_path"
(( ${#ENV_TEMPLATE_KEYS[@]} > 0 )) || die "environment template keys were not loaded"
if ! parse_env_file "$env_path"; then
die "unable to parse environment file: $env_path"
fi
for key in "${ENV_TEMPLATE_KEYS[@]}"; do
if [[ -z "${ENV_PARSED_VALUES[$key]+x}" || -z "${ENV_PARSED_VALUES[$key]}" ]]; then
invalid_keys+="$key"$'\n'
fi
done
[[ -z "$invalid_keys" ]] || die "environment contract requires non-empty values in $env_path: ${invalid_keys//$'\n'/,}"
}
capture_invocation_env_overrides() {
local key
(( ${#ENV_TEMPLATE_KEYS[@]} > 0 )) || die "environment template keys were not loaded"
INVOCATION_ENV_OVERRIDES=()
for key in "${ENV_TEMPLATE_KEYS[@]}"; do
if [[ -v "$key" ]]; then
INVOCATION_ENV_OVERRIDES["$key"]="${!key}"
fi
done
}
log_env_key_group() {
local group="$1"
shift
local rendered="none"
if (( $# > 0 )); then
local IFS=,
rendered="$*"
fi
log "$group=$rendered"
}
env_flag_enabled() {
case "${1:-0}" in
1|true|TRUE|yes|YES|on|ON)
@@ -156,15 +387,15 @@ assert_arch_compatibility() {
load_env() {
load_manifest
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE"
load_env_template_keys "$ENV_EXAMPLE"
validate_env_file_contract "$ENV_EXAMPLE"
if [[ ! -f "$ENV_FILE" ]]; then
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE"
cp "$ENV_EXAMPLE" "$ENV_FILE"
log "created .env from .env.example"
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
validate_env_file_contract "$ENV_FILE"
load_env_file "$ENV_FILE"
if [[ -n "${MANIFEST_PACKAGE_ARCH_RAW:-}" ]]; then
manifest_package_arch="$(normalize_arch "$MANIFEST_PACKAGE_ARCH_RAW")" || die "unsupported manifest package arch: $MANIFEST_PACKAGE_ARCH_RAW"
@@ -190,27 +421,6 @@ load_env() {
PACKAGE_PLATFORM="${PACKAGE_PLATFORM:-$(platform_for_arch "$PACKAGE_ARCH")}"
fi
HOST_DATA_DIR="${HOST_DATA_DIR:-/var/lib/ours-rp-${PACKAGE_ARCH}-installer}"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-ours-rp-${PACKAGE_ARCH}-installer}"
RPKI_IMAGE="${RPKI_IMAGE:-ours-rp-runtime-${PACKAGE_ARCH}:${source_commit_short:-unknown}}"
RPKI_PLATFORM="${RPKI_PLATFORM:-$PACKAGE_PLATFORM}"
METRICS_IMAGE="${METRICS_IMAGE:-ours-rp-metrics-${PACKAGE_ARCH}:${source_commit_short:-unknown}}"
METRICS_PLATFORM="${METRICS_PLATFORM:-$PACKAGE_PLATFORM}"
RTR_REPORT_DIR="${RTR_REPORT_DIR:-$HOST_DATA_DIR/empty-rtr-report}"
RTR_REPORT_CONTAINER_DIR="${RTR_REPORT_CONTAINER_DIR:-/var/lib/ours-rp/rtr-report}"
MONITOR_PLATFORM="${MONITOR_PLATFORM:-$PACKAGE_PLATFORM}"
PROMETHEUS_IMAGE="${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}"
GRAFANA_IMAGE="${GRAFANA_IMAGE:-grafana/grafana:11.3.1}"
METRICS_INSTANCE="${METRICS_INSTANCE:-${PACKAGE_ARCH}-installer}"
ALLOW_CROSS_ARCH="${ALLOW_CROSS_ARCH:-0}"
RESOURCE_VALIDATION_MODE="${RESOURCE_VALIDATION_MODE:-validation-update-03}"
FIRST_RUN_WAIT_TIMEOUT_SECS="${FIRST_RUN_WAIT_TIMEOUT_SECS:-7200}"
persist_env_default "$ENV_FILE" "METRICS_IMAGE" "$METRICS_IMAGE"
persist_env_default "$ENV_FILE" "METRICS_PLATFORM" "$METRICS_PLATFORM"
persist_env_default "$ENV_FILE" "RTR_REPORT_DIR" "$RTR_REPORT_DIR"
persist_env_default "$ENV_FILE" "RTR_REPORT_CONTAINER_DIR" "$RTR_REPORT_CONTAINER_DIR"
persist_env_default "$ENV_FILE" "RESOURCE_VALIDATION_MODE" "$RESOURCE_VALIDATION_MODE"
}
compose_cmd() {
@@ -546,6 +756,18 @@ verify_metrics_image() {
log "verifying metrics image $METRICS_IMAGE on $METRICS_PLATFORM"
verify_image_platform "$METRICS_IMAGE" "$METRICS_PLATFORM" "metrics"
verify_image_usage_output "$METRICS_IMAGE" "$METRICS_PLATFORM" "metrics" /opt/ours-rp/bin/rpki_artifact_metrics --help
verify_metrics_image_rpki_client
}
verify_metrics_image_rpki_client() {
local output
local status
set +e
output="$(docker run --rm --platform "$METRICS_PLATFORM" "$METRICS_IMAGE" /opt/ours-rp/bin/rpki-client -V 2>&1)"
status=$?
set -e
[[ "$status" == "0" ]] || die "metrics image rpki-client -V failed: image=$METRICS_IMAGE exit=$status output=$output"
grep -q "rpki-client" <<<"$output" || die "metrics image rpki-client -V unexpected output: $output"
}
verify_image_platform() {

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

@@ -13,6 +13,8 @@ Usage: ./scripts/upgrade.sh [--reuse-env-from /path/to/.env] [--keep-reused-imag
By default, --reuse-env-from creates the new .env from this package's
.env.example first, then overlays existing user settings from the old .env.
For declared configuration keys, explicit invocation variables have highest
priority, for example: INTERVAL_SECS=300 ./scripts/upgrade.sh ...
Image tags are intentionally kept from the new package so the upgraded service
actually runs the new packaged runtime and monitor images. Use
--keep-reused-image only when you intentionally want to keep previous image tags.
@@ -39,56 +41,80 @@ while [[ $# -gt 0 ]]; do
esac
done
env_get_key() {
local env_path="$1"
local key="$2"
awk -F= -v key="$key" '$1 == key {sub(/^[^=]*=/, ""); print; exit}' "$env_path"
}
env_set_key() {
local env_path="$1"
local key="$2"
local value="$3"
local tmp_env
if grep -q "^${key}=" "$env_path"; then
tmp_env="$(mktemp)"
awk -v key="$key" -v value="$value" '
BEGIN { done=0 }
$0 ~ "^" key "=" { print key "=" value; done=1; next }
{ print }
END { if (!done) print key "=" value }
' "$env_path" > "$tmp_env"
mv "$tmp_env" "$env_path"
else
printf '%s=%s\n' "$key" "$value" >> "$env_path"
fi
}
overlay_reused_env() {
local source_env="$1"
local target_env="$2"
local key
local value
while IFS='=' read -r key _; do
[[ -n "$key" ]] || continue
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
local -a reused_keys=()
local -a ignored_removed_keys=()
local -a preserved_package_keys=()
REUSED_ENV_KEYS=()
parse_env_file "$source_env" || die "unable to parse reused environment: $source_env"
while IFS= read -r key; do
if ! template_has_env_key "$key"; then
ignored_removed_keys+=("$key")
continue
fi
case "$key" in
PACKAGE_ARCH|PACKAGE_PLATFORM|RPKI_PLATFORM|METRICS_PLATFORM|MONITOR_PLATFORM)
preserved_package_keys+=("$key")
continue
;;
RPKI_IMAGE|METRICS_IMAGE|PROMETHEUS_IMAGE|GRAFANA_IMAGE)
[[ "$UPDATE_PACKAGE_IMAGE" == "0" ]] || continue
if [[ "$UPDATE_PACKAGE_IMAGE" != "0" ]]; then
preserved_package_keys+=("$key")
continue
fi
;;
esac
value="$(env_get_key "$source_env" "$key")"
env_set_key "$target_env" "$key" "$value"
done < <(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$source_env" || true)
[[ -n "${ENV_PARSED_VALUES[$key]+x}" ]] || die "unable to read $key from reused environment: $source_env"
value="${ENV_PARSED_VALUES[$key]}"
env_file_set_value "$target_env" "$key" "$value"
REUSED_ENV_KEYS["$key"]=1
reused_keys+=("$key")
done < <(env_file_assignment_keys "$source_env")
log_env_key_group "reused_keys" "${reused_keys[@]}"
log_env_key_group "ignored_removed_old_keys" "${ignored_removed_keys[@]}"
log_env_key_group "preserved_package_keys" "${preserved_package_keys[@]}"
}
apply_invocation_env_overrides() {
local target_env="$1"
local key
local -a overridden_keys=()
for key in "${ENV_TEMPLATE_KEYS[@]}"; do
[[ -n "${INVOCATION_ENV_OVERRIDES[$key]+x}" ]] || continue
env_file_set_value "$target_env" "$key" "${INVOCATION_ENV_OVERRIDES[$key]}"
overridden_keys+=("$key")
done
log_env_key_group "command_override_keys" "${overridden_keys[@]}"
}
log_template_only_keys() {
local key
local -a template_only_keys=()
for key in "${ENV_TEMPLATE_KEYS[@]}"; do
[[ -n "${REUSED_ENV_KEYS[$key]+x}" ]] && continue
[[ -n "${INVOCATION_ENV_OVERRIDES[$key]+x}" ]] && continue
template_only_keys+=("$key")
done
log_env_key_group "template_only_keys" "${template_only_keys[@]}"
}
load_env_template_keys "$ENV_EXAMPLE"
validate_env_file_contract "$ENV_EXAMPLE"
capture_invocation_env_overrides
REUSED_ENV_KEYS=()
if [[ -n "$REUSE_ENV_FROM" ]]; then
[[ -f "$REUSE_ENV_FROM" ]] || die "missing reuse env file: $REUSE_ENV_FROM"
if [[ ! -f "$ENV_FILE" ]]; then
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE"
cp "$ENV_EXAMPLE" "$ENV_FILE"
overlay_reused_env "$REUSE_ENV_FROM" "$ENV_FILE"
log "created new package env from .env.example and overlaid user settings from $REUSE_ENV_FROM"
@@ -97,6 +123,13 @@ if [[ -n "$REUSE_ENV_FROM" ]]; then
fi
fi
if [[ ! -f "$ENV_FILE" ]]; then
cp "$ENV_EXAMPLE" "$ENV_FILE"
log "created new package env from .env.example"
fi
apply_invocation_env_overrides "$ENV_FILE"
log_template_only_keys
validate_env_file_contract "$ENV_FILE"
load_env
assert_arch_compatibility
create_data_dirs

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALLER_SOURCE="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ours-rp-installer-env-contract.XXXXXX")"
cleanup() {
rm -rf "$TEST_ROOT"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
assert_equals() {
local expected="$1"
local actual="$2"
local message="$3"
[[ "$actual" == "$expected" ]] || fail "$message (expected=$expected actual=$actual)"
}
assert_not_contains() {
local needle="$1"
local path="$2"
! grep -Fq "$needle" "$path" || fail "unexpected value in $path: $needle"
}
expect_exit() {
local expected_status="$1"
local output_path="$2"
shift 2
local actual_status
set +e
"$@" >"$output_path" 2>&1
actual_status=$?
set -e
[[ "$actual_status" == "$expected_status" ]] || {
cat "$output_path" >&2 || true
fail "unexpected exit status (expected=$expected_status actual=$actual_status)"
}
}
host_arch() {
case "$(uname -m)" in
x86_64) printf 'amd64\n' ;;
aarch64) printf 'arm64\n' ;;
*) fail "unsupported test host architecture: $(uname -m)" ;;
esac
}
env_value() {
local env_path="$1"
local key="$2"
(
# shellcheck disable=SC1090
source "$INSTALLER_SOURCE/scripts/common.sh"
env_file_value "$env_path" "$key"
)
}
create_fixture() {
local name="$1"
local fixture="$TEST_ROOT/$name"
local arch
arch="$(host_arch)"
mkdir -p "$fixture"
cp -a "$INSTALLER_SOURCE"/. "$fixture"/
rm -rf "$fixture/tests"
mkdir -p "$fixture/images" "$fixture/fake-bin"
sed -i \
-e "s|__PACKAGE_ARCH__|$arch|g" \
-e "s|__PACKAGE_PLATFORM__|linux/$arch|g" \
-e "s|__RUNTIME_IMAGE__|ours-rp-runtime-$arch:new|g" \
-e "s|__METRICS_IMAGE__|ours-rp-metrics-$arch:new|g" \
-e "s|__HOST_DATA_DIR__|$fixture/data|g" \
"$fixture/.env.example"
cat > "$fixture/PACKAGE-MANIFEST.env" <<EOF
PACKAGE_ARCH=$arch
PACKAGE_PLATFORM=linux/$arch
EOF
cat > "$fixture/fake-bin/docker" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$FAKE_DOCKER_LOG"
if [[ "${1:-}" == "compose" && "${2:-}" == "version" ]]; then
exit 0
fi
exit 99
EOF
chmod +x "$fixture/fake-bin/docker"
for command in jq rsync curl gzip tar; do
cat > "$fixture/fake-bin/$command" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$fixture/fake-bin/$command"
done
printf '%s\n' "$fixture"
}
run_upgrade() {
local fixture="$1"
local old_env="$2"
shift 2
(
cd "$fixture"
export PATH="$fixture/fake-bin:$PATH"
export FAKE_DOCKER_LOG="$fixture/fake-docker.log"
"$@" ./scripts/upgrade.sh --reuse-env-from "$old_env"
)
}
run_upgrade_without_reuse() {
local fixture="$1"
(
cd "$fixture"
export PATH="$fixture/fake-bin:$PATH"
export FAKE_DOCKER_LOG="$fixture/fake-docker.log"
./scripts/upgrade.sh
)
}
new_fixture_with_old_env() {
local name="$1"
local fixture
fixture="$(create_fixture "$name")"
cat > "$fixture/old.env" <<'EOF'
INTERVAL_SECS=120
RPKI_IMAGE=ours-rp-runtime-old:old
REMOVED_LEGACY_KEY=legacy-value
EOF
printf '%s\n' "$fixture"
}
fixture="$(create_fixture template-defaults)"
expect_exit 99 "$fixture/template.log" run_upgrade_without_reuse "$fixture"
assert_equals "600" "$(env_value "$fixture/.env" INTERVAL_SECS)" "template default must be retained without reuse"
fixture="$(new_fixture_with_old_env default-old)"
expect_exit 99 "$fixture/default.log" run_upgrade "$fixture" "$fixture/old.env"
assert_equals "120" "$(env_value "$fixture/.env" INTERVAL_SECS)" "old value must override template default"
assert_equals "ours-rp-runtime-$(host_arch):new" "$(env_value "$fixture/.env" RPKI_IMAGE)" "standard upgrade must retain package image"
assert_equals "10" "$(env_value "$fixture/.env" METRICS_POLL_SECS)" "template default must fill a key absent from old environment"
! grep -q '^REMOVED_LEGACY_KEY=' "$fixture/.env" || fail "removed old key was copied into target environment"
grep -q 'reused_keys=INTERVAL_SECS' "$fixture/default.log" || fail "reused key summary missing"
grep -q 'ignored_removed_old_keys=REMOVED_LEGACY_KEY' "$fixture/default.log" || fail "removed key summary missing"
grep -q 'template_only_keys=' "$fixture/default.log" || fail "template key summary missing"
fixture="$(new_fixture_with_old_env invocation-overrides)"
expect_exit 99 "$fixture/override.log" run_upgrade "$fixture" "$fixture/old.env" env INTERVAL_SECS=300 GRAFANA_ADMIN_PASSWORD='secret with space'
assert_equals "300" "$(env_value "$fixture/.env" INTERVAL_SECS)" "invocation value must override old value"
assert_equals "secret with space" "$(env_value "$fixture/.env" GRAFANA_ADMIN_PASSWORD)" "invocation value must preserve shell-sensitive content"
grep -q 'command_override_keys=INTERVAL_SECS,GRAFANA_ADMIN_PASSWORD' "$fixture/override.log" || fail "override key summary missing"
assert_not_contains 'secret with space' "$fixture/override.log"
fixture="$(new_fixture_with_old_env invocation-special-characters)"
special_password="O'Reilly"
special_password+=' $literal `tick` slash\'
expect_exit 99 "$fixture/special.log" run_upgrade "$fixture" "$fixture/old.env" env GRAFANA_ADMIN_PASSWORD="$special_password"
assert_equals "$special_password" "$(env_value "$fixture/.env" GRAFANA_ADMIN_PASSWORD)" "invocation value with dotenv-sensitive characters must round-trip"
assert_not_contains "$special_password" "$fixture/special.log"
fixture="$(create_fixture existing-target-env)"
cp "$fixture/.env.example" "$fixture/.env"
sed -i 's/^INTERVAL_SECS=.*/INTERVAL_SECS=180/' "$fixture/.env"
cat > "$fixture/old.env" <<'EOF'
INTERVAL_SECS=120
EOF
expect_exit 99 "$fixture/existing.log" run_upgrade "$fixture" "$fixture/old.env" env INTERVAL_SECS=300
assert_equals "300" "$(env_value "$fixture/.env" INTERVAL_SECS)" "invocation value must override an existing target environment"
fixture="$(new_fixture_with_old_env explicit-empty)"
expect_exit 1 "$fixture/empty.log" run_upgrade "$fixture" "$fixture/old.env" env INTERVAL_SECS=
[[ ! -s "$fixture/fake-docker.log" ]] || fail "explicit empty value reached Docker"
grep -q 'INTERVAL_SECS' "$fixture/empty.log" || fail "empty-value error did not identify key"
fixture="$(create_fixture empty-old-value)"
cat > "$fixture/old.env" <<'EOF'
INTERVAL_SECS=
EOF
expect_exit 1 "$fixture/old-empty.log" run_upgrade "$fixture" "$fixture/old.env"
[[ ! -s "$fixture/fake-docker.log" ]] || fail "empty reused value reached Docker"
grep -q 'INTERVAL_SECS' "$fixture/old-empty.log" || fail "empty reused value error did not identify key"
fixture="$(create_fixture empty-template)"
sed -i 's/^INTERVAL_SECS=.*/INTERVAL_SECS=/' "$fixture/.env.example"
cat > "$fixture/old.env" <<'EOF'
INTERVAL_SECS=120
EOF
expect_exit 1 "$fixture/template-empty.log" run_upgrade "$fixture" "$fixture/old.env"
[[ ! -s "$fixture/fake-docker.log" ]] || fail "empty template default reached Docker"
grep -q 'INTERVAL_SECS' "$fixture/template-empty.log" || fail "empty template error did not identify key"
fixture="$(new_fixture_with_old_env package-guard)"
arch="$(host_arch)"
other_arch="amd64"
[[ "$arch" == "amd64" ]] && other_arch="arm64"
expect_exit 1 "$fixture/arch.log" run_upgrade "$fixture" "$fixture/old.env" env PACKAGE_ARCH="$other_arch"
[[ ! -s "$fixture/fake-docker.log" ]] || fail "package architecture mismatch reached Docker"
grep -q 'mismatches manifest PACKAGE_ARCH' "$fixture/arch.log" || fail "package architecture guard did not reject override"
printf 'PASS: Docker installer upgrade environment contract\n'

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

@@ -85,27 +85,66 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
mkdir -p /build-out/bin; \
cp "target/$target_triple/release/rpki_artifact_metrics" /build-out/bin/
FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS rpki-client-builder
ARG RPKI_CLIENT_VERSION=9.8
ARG RPKI_CLIENT_SHA256=42920aac5afd0996173fb9f7848691a2c49dd234e4f10478808c1aa475620861
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
libexpat1-dev \
libssl-dev \
libtls-dev \
pkg-config \
rsync \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --no-create-home --shell /usr/sbin/nologin _rpki-client
RUN set -eux; \
curl -fsSL "https://ftp.openbsd.org/pub/OpenBSD/rpki-client/rpki-client-${RPKI_CLIENT_VERSION}.tar.gz" -o /tmp/rpki-client.tar.gz; \
echo "${RPKI_CLIENT_SHA256} /tmp/rpki-client.tar.gz" | sha256sum -c -; \
tar -xzf /tmp/rpki-client.tar.gz -C /tmp; \
cd "/tmp/rpki-client-${RPKI_CLIENT_VERSION}"; \
./configure --prefix=/opt/rpki-client; \
make -j"$(nproc)"; \
make install; \
/opt/rpki-client/sbin/rpki-client -V
FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
libexpat1 \
libssl3 \
libtls26 \
tzdata \
&& rm -rf /var/lib/apt/lists/*
zlib1g \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --no-create-home --shell /usr/sbin/nologin _rpki-client \
&& mkdir -p /opt/rpki-client/var/cache/rpki-client
WORKDIR /opt/ours-rp
COPY --from=builder /build-out/bin/ /opt/ours-rp/bin/
COPY --from=rpki-client-builder /opt/rpki-client/ /opt/rpki-client/
COPY --from=rpki-client-builder /opt/rpki-client/sbin/rpki-client /opt/ours-rp/bin/rpki-client
ARG SOURCE_COMMIT=unknown
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}" \
org.opencontainers.image.source-dirty="${SOURCE_DIRTY}"
org.opencontainers.image.source-dirty="${SOURCE_DIRTY}" \
org.opencontainers.image.rpki-client.version="${RPKI_CLIENT_VERSION}"
RUN chmod +x /opt/ours-rp/bin/* \
&& mkdir -p /var/lib/ours-rp/state /var/lib/ours-rp/runs /var/lib/ours-rp/logs

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

@@ -0,0 +1,667 @@
#!/usr/bin/env python3
"""Run ours RP sync-worker-count ablation on a remote host.
The experiment intentionally mirrors the Feature #100 ours-pp-object-cache
profile and changes only --parallel-max-repo-sync-workers-global.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import html
import json
import os
import re
import shlex
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
PRODUCT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_REMOTE = "root@47.77.204.233"
DEFAULT_WORKERS = "1,2,4,8"
DEFAULT_INTERVAL_SECS = 0
DEFAULT_RUNS_PER_WORKER = 5
RIRS = ["afrinic", "apnic", "arin", "lacnic", "ripe"]
TAL_URLS = {
"afrinic": "https://rpki.afrinic.net/tal/afrinic.tal",
"apnic": "https://tal.apnic.net/apnic.tal",
"arin": "https://www.arin.net/resources/manage/rpki/arin.tal",
"lacnic": "https://www.lacnic.net/innovaportal/file/4983/1/lacnic.tal",
"ripe": "https://tal.rpki.ripe.net/ripe-ncc.tal",
}
REMOTE_RUNNER = r"""#!/usr/bin/env bash
set -euo pipefail
mode="$1"
state_dir="$2"
run_dir="$3"
shift 3
mkdir -p "$run_dir"
if [[ "$mode" == "snapshot" ]]; then
rm -rf "$state_dir"
fi
mkdir -p "$state_dir" "$(dirname "$run_dir")"
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$run_dir/start-time.txt"
printf '%q ' "$@" > "$run_dir/command.txt"
printf '\n' >> "$run_dir/command.txt"
set +e
/usr/bin/time -v -o "$run_dir/process-time.txt" "$@" > "$run_dir/stdout.log" 2> "$run_dir/stderr.log"
status=$?
set -e
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$run_dir/end-time.txt"
printf '%s\n' "$status" > "$run_dir/exit-code.txt"
exit "$status"
"""
REMOTE_METRICS = r"""import csv
import json
import os
import re
import subprocess
import sys
from pathlib import Path
run_dir = Path(os.environ["RUN_DIR"])
state_dir = Path(os.environ["STATE_DIR"])
workers = int(os.environ["WORKERS"])
mode = os.environ["MODE"]
run_index = int(os.environ["RUN_INDEX"])
scheduled_epoch = float(os.environ["SCHEDULED_EPOCH"])
started_epoch = float(os.environ["STARTED_EPOCH"])
def read_text(path):
try:
return Path(path).read_text(errors="replace")
except FileNotFoundError:
return ""
def read_json(path):
try:
with Path(path).open() as fh:
return json.load(fh)
except FileNotFoundError:
return None
except json.JSONDecodeError as exc:
return {"_json_error": str(exc)}
def parse_time(path):
text = read_text(path)
out = {}
match = re.search(r"Elapsed \(wall clock\) time.*: (?:(\d+):)?(\d+):(\d+(?:\.\d+)?)", text)
if match:
hours = int(match.group(1) or 0)
minutes = int(match.group(2))
seconds = float(match.group(3))
out["wall_ms_timev"] = int(round(((hours * 60 + minutes) * 60 + seconds) * 1000))
for name, key in [
("Maximum resident set size \\(kbytes\\)", "max_rss_kb"),
("Percent of CPU this job got", "cpu_percent_text"),
("Major \\(requiring I/O\\) page faults", "major_faults"),
("Minor \\(reclaiming a frame\\) page faults", "minor_faults"),
]:
found = re.search(name + r":\s+(.+)", text)
if found:
value = found.group(1).strip()
if key.endswith("_faults") or key == "max_rss_kb":
try:
out[key] = int(value)
except ValueError:
out[key] = value
else:
out[key] = value
return out
def count_csv(path):
rows = 0
unique = set()
try:
with Path(path).open(newline="") as fh:
reader = csv.reader(fh)
header = next(reader, None)
for row in reader:
if not row:
continue
rows += 1
unique.add(tuple(row))
except FileNotFoundError:
return {"rows": None, "unique_rows": None}
return {"rows": rows, "unique_rows": len(unique)}
def dir_bytes(path):
path = Path(path)
if not path.exists():
return 0
total = 0
for item in path.rglob("*"):
try:
if item.is_file():
total += item.stat().st_size
except OSError:
pass
return total
def file_bytes(path):
try:
return Path(path).stat().st_size
except FileNotFoundError:
return None
stage = read_json(run_dir / "stage-timing.json") or {}
process_time = parse_time(run_dir / "process-time.txt")
exit_text = read_text(run_dir / "exit-code.txt").strip()
start_text = read_text(run_dir / "start-time.txt").strip()
end_text = read_text(run_dir / "end-time.txt").strip()
df = subprocess.run(["df", "-P", "/"], text=True, capture_output=True)
df_line = df.stdout.strip().splitlines()[-1].split() if df.returncode == 0 and len(df.stdout.strip().splitlines()) >= 2 else []
df_root = {}
if len(df_line) >= 5:
df_root = {
"filesystem": df_line[0],
"blocks_1k": int(df_line[1]),
"used_1k": int(df_line[2]),
"available_1k": int(df_line[3]),
"use_percent": int(df_line[4].rstrip("%")),
}
metrics = {
"workers": workers,
"mode": mode,
"run_index": run_index,
"scheduled_epoch": scheduled_epoch,
"started_epoch": started_epoch,
"schedule_lag_ms": int(round((started_epoch - scheduled_epoch) * 1000)),
"start_time_utc": start_text,
"end_time_utc": end_text,
"exit_code": int(exit_text) if exit_text.isdigit() else None,
"run_dir": str(run_dir),
"state_dir": str(state_dir),
"stage": stage,
"process_time": process_time,
"vrps": count_csv(run_dir / "vrps.csv"),
"vaps": count_csv(run_dir / "vaps.csv"),
"artifact_bytes": {
"report_json": file_bytes(run_dir / "report.json"),
"result_ccr": file_bytes(run_dir / "result.ccr"),
"input_cir": file_bytes(run_dir / "input.cir"),
"vrps_csv": file_bytes(run_dir / "vrps.csv"),
"vaps_csv": file_bytes(run_dir / "vaps.csv"),
},
"state_bytes": dir_bytes(state_dir),
"run_bytes": dir_bytes(run_dir),
"df_root": df_root,
}
print(json.dumps(metrics, ensure_ascii=False, sort_keys=True))
"""
def utc_stamp() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def run_cmd(cmd: list[str], *, cwd: Path | None = None, check: bool = True, input_text: str | None = None) -> subprocess.CompletedProcess[str]:
result = subprocess.run(cmd, cwd=cwd, input=input_text, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if check and result.returncode != 0:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
raise SystemExit(result.returncode)
return result
def ssh(remote: str, command: str, *, check: bool = True, input_text: str | None = None) -> subprocess.CompletedProcess[str]:
return run_cmd(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", remote, command], check=check, input_text=input_text)
def scp_to(remote: str, local: Path, remote_path: str) -> None:
ssh(remote, "mkdir -p " + shlex.quote(str(Path(remote_path).parent)))
run_cmd(["scp", "-q", str(local), f"{remote}:{remote_path}"])
def parse_workers(value: str) -> list[int]:
workers = []
for part in value.split(","):
part = part.strip()
if not part:
continue
worker = int(part)
if worker <= 0:
raise argparse.ArgumentTypeError("worker count must be positive")
workers.append(worker)
if len(set(workers)) != len(workers):
raise argparse.ArgumentTypeError("worker counts must be unique")
return workers
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run ours RP sync worker ablation.")
parser.add_argument("--remote", default=DEFAULT_REMOTE)
parser.add_argument("--workers", type=parse_workers, default=parse_workers(DEFAULT_WORKERS))
parser.add_argument("--interval-secs", type=int, default=DEFAULT_INTERVAL_SECS)
parser.add_argument("--runs-per-worker", type=int, default=DEFAULT_RUNS_PER_WORKER)
parser.add_argument("--remote-root")
parser.add_argument("--out-root", type=Path, default=Path("specs/develop/20260714/feature115_sync_worker_ablation_runs"))
parser.add_argument("--binary", type=Path)
parser.add_argument("--skip-build", action="store_true")
parser.add_argument("--dry-run", action="store_true")
return parser.parse_args()
def ensure_binary(args: argparse.Namespace) -> Path:
if args.binary:
binary = args.binary.resolve()
if not binary.exists():
raise SystemExit(f"binary does not exist: {binary}")
return binary
binary = PRODUCT_ROOT / "target" / "release" / "rpki"
if not args.skip_build:
print("building release rpki binary", flush=True)
run_cmd(["cargo", "build", "--release", "--bin", "rpki"], cwd=PRODUCT_ROOT)
if not binary.exists():
raise SystemExit(f"release binary not found: {binary}")
return binary
def fixed_args(remote_bin: str, state_dir: str, run_dir: str, workers: int) -> list[str]:
args = [
remote_bin,
"--db", f"{state_dir}/work-db",
"--repo-bytes-db", f"{state_dir}/repo-bytes.db",
"--rsync-mirror-root", f"{state_dir}/rsync-mirror",
"--rsync-scope", "module-root",
"--report-json", f"{run_dir}/report.json",
"--report-json-compact",
"--ccr-out", f"{run_dir}/result.ccr",
"--cir-enable",
"--cir-out", f"{run_dir}/input.cir",
"--vrps-csv-out", f"{run_dir}/vrps.csv",
"--vaps-csv-out", f"{run_dir}/vaps.csv",
"--compare-view-trust-anchor", "all5",
"--parallel-phase2-ready-batch-size", "256",
"--parallel-phase2-ready-batch-wall-time-budget-ms", "100",
"--parallel-phase2-result-drain-batch-size", "2048",
"--parallel-phase2-finalize-batch-size", "256",
"--parallel-phase2-finalize-batch-wall-time-budget-ms", "100",
"--parallel-max-repo-sync-workers-global", str(workers),
"--enable-publication-point-validation-cache",
"--enable-roa-validation-cache",
"--enable-child-certificate-validation-cache",
]
for rir in RIRS:
url = TAL_URLS[rir]
args.extend(["--tal-url", url, "--cir-tal-uri", url])
return args
def plan_runs(workers: list[int], runs_per_worker: int, interval_secs: int, remote_root: str, remote_bin: str) -> list[dict[str, Any]]:
if runs_per_worker < 2:
raise SystemExit("--runs-per-worker must be at least 2")
planned = []
ordinal = 0
for worker in workers:
case_root = f"{remote_root}/cases/workers-{worker:02d}"
state_dir = f"{case_root}/state"
for run_index in range(1, runs_per_worker + 1):
mode = "snapshot" if run_index == 1 else "delta"
run_dir = f"{case_root}/runs/run_{run_index:04d}_{mode}"
planned.append({
"ordinal": ordinal,
"workers": worker,
"mode": mode,
"run_index": run_index,
"state_dir": state_dir,
"run_dir": run_dir,
"interval_secs": interval_secs,
"command": fixed_args(remote_bin, state_dir, run_dir, worker),
})
ordinal += 1
return planned
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True) + "\n")
def write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
def prepare_remote(remote: str, remote_root: str, binary: Path) -> str:
remote_bin = f"{remote_root}/bin/rpki"
preflight = f"""
set -euo pipefail
mkdir -p {shlex.quote(remote_root)}/bin {shlex.quote(remote_root)}/env
systemctl disable --now rpki-client.timer >/dev/null 2>&1 || true
systemctl stop rpki-client.service >/dev/null 2>&1 || true
pkill -x rpki-client >/dev/null 2>&1 || true
pkill -x routinator >/dev/null 2>&1 || true
pkill -x fort >/dev/null 2>&1 || true
pkill -x rpki >/dev/null 2>&1 || true
{{
date -u +"%Y-%m-%dT%H:%M:%SZ"
hostname
uname -a
nproc
df -h /
free -h || true
ps -eo pid,ppid,comm,%cpu,%mem,args --sort=-%cpu | head -30
systemctl is-active rpki-client.timer 2>/dev/null || true
}} > {shlex.quote(remote_root)}/env/preflight.txt
"""
ssh(remote, "bash -s", input_text=preflight)
scp_to(remote, binary, remote_bin)
ssh(remote, "chmod +x " + shlex.quote(remote_bin))
return remote_bin
def run_remote_plan(remote: str, plan: dict[str, Any], scheduled_epoch: float) -> dict[str, Any]:
now = time.time()
if now < scheduled_epoch:
wait_secs = int(round(scheduled_epoch - now))
print(f"waiting {wait_secs}s before workers={plan['workers']} {plan['mode']}", flush=True)
time.sleep(scheduled_epoch - now)
started_epoch = time.time()
command = shlex.join(["bash", "-s", "--", plan["mode"], plan["state_dir"], plan["run_dir"], *plan["command"]])
print(f"start workers={plan['workers']} {plan['mode']} run={plan['run_index']} at {datetime.now(timezone.utc).isoformat()}", flush=True)
result = ssh(remote, command, input_text=REMOTE_RUNNER, check=False)
if result.returncode != 0:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
metrics_script = "\n".join([
f"export RUN_DIR={shlex.quote(plan['run_dir'])}",
f"export STATE_DIR={shlex.quote(plan['state_dir'])}",
f"export WORKERS={plan['workers']}",
f"export MODE={shlex.quote(plan['mode'])}",
f"export RUN_INDEX={plan['run_index']}",
f"export SCHEDULED_EPOCH={scheduled_epoch}",
f"export STARTED_EPOCH={started_epoch}",
"python3 - <<'PY'",
REMOTE_METRICS,
"PY",
])
metrics_result = ssh(remote, "bash -s", input_text=metrics_script, check=True)
metrics = json.loads(metrics_result.stdout)
metrics["ssh_return_code"] = result.returncode
metrics["command_digest"] = hashlib.sha256("\0".join(plan["command"]).encode()).hexdigest()
ssh(
remote,
"cat > " + shlex.quote(plan["run_dir"] + "/metrics.json"),
input_text=json.dumps(metrics, ensure_ascii=False, sort_keys=True) + "\n",
)
if result.returncode != 0:
raise RuntimeError(f"remote run failed: workers={plan['workers']} mode={plan['mode']} rc={result.returncode}")
validate_metrics(metrics)
print(f"done workers={plan['workers']} {plan['mode']} wall={metrics.get('wall_ms')}ms vrps={metrics['vrps'].get('rows')} vaps={metrics['vaps'].get('rows')}", flush=True)
return metrics
def validate_metrics(metrics: dict[str, Any]) -> None:
stage = metrics.get("stage") or {}
expected = {
"enable_transport_request_prefetch": False,
"enable_publication_point_validation_cache": True,
"enable_roa_validation_cache": True,
"enable_child_certificate_validation_cache": True,
}
for key, value in expected.items():
if stage.get(key) is not value:
raise RuntimeError(f"unexpected {key}: {stage.get(key)!r}, expected {value!r}")
if metrics["vrps"].get("rows") in (None, 0):
raise RuntimeError("VRP CSV is missing or empty")
if metrics["vaps"].get("rows") is None:
raise RuntimeError("VAP CSV is missing")
df_root = metrics.get("df_root") or {}
if df_root.get("use_percent", 0) >= 90:
raise RuntimeError(f"remote disk is above threshold: {df_root}")
stage_total = stage.get("total_ms")
timev_total = metrics.get("process_time", {}).get("wall_ms_timev")
metrics["wall_ms"] = stage_total if isinstance(stage_total, int) else timev_total
def append_metric(out_root: Path, metrics: dict[str, Any]) -> None:
path = out_root / "per_run_metrics.jsonl"
with path.open("a") as fh:
fh.write(json.dumps(metrics, ensure_ascii=False, sort_keys=True) + "\n")
def load_metrics(out_root: Path) -> list[dict[str, Any]]:
path = out_root / "per_run_metrics.jsonl"
if not path.exists():
return []
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
def metric_row(metric: dict[str, Any]) -> dict[str, Any]:
stage = metric.get("stage") or {}
roa_cache = stage.get("roa_validation_cache") or {}
return {
"workers": metric["workers"],
"mode": metric["mode"],
"run_index": metric["run_index"],
"start_time_utc": metric.get("start_time_utc"),
"wall_ms": metric.get("wall_ms"),
"timev_wall_ms": metric.get("process_time", {}).get("wall_ms_timev"),
"max_rss_kb": metric.get("process_time", {}).get("max_rss_kb"),
"cpu_percent": metric.get("process_time", {}).get("cpu_percent_text"),
"validation_ms": stage.get("validation_ms"),
"repo_sync_ms_total": stage.get("repo_sync_ms_total"),
"rrdp_download_ms_total": stage.get("rrdp_download_ms_total"),
"rsync_download_ms_total": stage.get("rsync_download_ms_total"),
"download_bytes_total": stage.get("download_bytes_total"),
"publication_points": stage.get("publication_points"),
"pp_cache_hit_ratio": stage.get("publication_point_cache_hit_ratio"),
"roa_hit_roas": roa_cache.get("hit_roas"),
"roa_miss_roas": roa_cache.get("miss_roas"),
"vrps_rows": metric.get("vrps", {}).get("rows"),
"vrps_unique_rows": metric.get("vrps", {}).get("unique_rows"),
"vaps_rows": metric.get("vaps", {}).get("rows"),
"vaps_unique_rows": metric.get("vaps", {}).get("unique_rows"),
"state_bytes": metric.get("state_bytes"),
"run_bytes": metric.get("run_bytes"),
"remote_run_dir": metric.get("run_dir"),
}
def write_csv_report(out_root: Path, metrics: list[dict[str, Any]]) -> None:
rows = [metric_row(item) for item in metrics]
if not rows:
return
with (out_root / "per_run_metrics.csv").open("w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
def fmt_ms(value: Any) -> str:
if value is None:
return "-"
try:
return f"{float(value) / 1000:.2f}s"
except (TypeError, ValueError):
return str(value)
def fmt_bytes(value: Any) -> str:
if value is None:
return "-"
try:
value = float(value)
except (TypeError, ValueError):
return str(value)
for unit in ["B", "KiB", "MiB", "GiB"]:
if value < 1024 or unit == "GiB":
return f"{value:.1f} {unit}"
value /= 1024
return str(value)
def write_markdown_report(out_root: Path, metrics: list[dict[str, Any]], provenance: dict[str, Any]) -> None:
rows = [metric_row(item) for item in metrics]
lines = [
"# Feature #115 Sync Worker Ablation Summary",
"",
"## Experiment",
"",
f"- Remote: {provenance['remote']}",
f"- Remote root: {provenance['remote_root']}",
f"- Local commit: {provenance['git_commit']}",
f"- Worker counts: {', '.join(str(w) for w in provenance['workers'])}",
f"- Runs per worker: {provenance['runs_per_worker']}",
f"- Interval seconds: {provenance['interval_secs']}",
"- Fixed profile: all5 live TAL URL, module-root rsync scope, PP cache enabled, ROA cache enabled, child-certificate cache enabled, transport prefetch disabled.",
"",
"## Per Run",
"",
"| workers | mode | wall | rss | validation | repo sync | RRDP | rsync | download | PP | VRPs | VAPs |",
"|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
]
for row in rows:
lines.append(
f"| {row['workers']} | {row['mode']} | {fmt_ms(row['wall_ms'])} | "
f"{fmt_bytes((row['max_rss_kb'] or 0) * 1024 if row['max_rss_kb'] else None)} | "
f"{fmt_ms(row['validation_ms'])} | {fmt_ms(row['repo_sync_ms_total'])} | "
f"{fmt_ms(row['rrdp_download_ms_total'])} | {fmt_ms(row['rsync_download_ms_total'])} | "
f"{fmt_bytes(row['download_bytes_total'])} | {row['publication_points']} | "
f"{row['vrps_rows']} | {row['vaps_rows']} |"
)
lines.extend(["", "## Artifacts", "", f"- Per-run metrics JSONL: {out_root / 'per_run_metrics.jsonl'}", f"- Per-run metrics CSV: {out_root / 'per_run_metrics.csv'}"])
write_text(out_root / "summary.md", "\n".join(lines) + "\n")
def write_html_report(out_root: Path, metrics: list[dict[str, Any]], provenance: dict[str, Any]) -> None:
rows = [metric_row(item) for item in metrics]
if not rows:
return
max_wall = max(float(row["wall_ms"] or 0) for row in rows) or 1
bars = []
for row in rows:
width = max(1, int((float(row["wall_ms"] or 0) / max_wall) * 100))
bars.append(
f"<tr><td>{row['workers']}</td><td>{html.escape(str(row['mode']))}</td>"
f"<td>{fmt_ms(row['wall_ms'])}</td><td><div class='bar'><span style='width:{width}%'></span></div></td>"
f"<td>{fmt_bytes((row['max_rss_kb'] or 0) * 1024 if row['max_rss_kb'] else None)}</td>"
f"<td>{fmt_ms(row['validation_ms'])}</td><td>{fmt_ms(row['repo_sync_ms_total'])}</td>"
f"<td>{row['publication_points']}</td><td>{row['vrps_rows']}</td><td>{row['vaps_rows']}</td></tr>"
)
html_doc = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Feature #115 Sync Worker Ablation</title>
<style>
body {{ font-family: system-ui, -apple-system, Segoe UI, sans-serif; margin: 32px; color: #1f2933; }}
h1, h2 {{ margin-bottom: 0.35rem; }}
.meta {{ display: grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 12px; margin: 20px 0; }}
.card {{ border: 1px solid #d8dee9; border-radius: 8px; padding: 14px; background: #f8fafc; }}
.card b {{ display: block; font-size: 12px; color: #52606d; text-transform: uppercase; letter-spacing: .04em; }}
.card span {{ font-size: 18px; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 12px; font-size: 13px; }}
th, td {{ border: 1px solid #d8dee9; padding: 8px; text-align: left; vertical-align: middle; }}
th {{ background: #eef2f7; }}
.bar {{ width: 180px; height: 12px; background: #edf2f7; border-radius: 999px; overflow: hidden; }}
.bar span {{ display: block; height: 100%; background: #2563eb; }}
code {{ background: #edf2f7; padding: 2px 5px; border-radius: 4px; }}
</style>
</head>
<body>
<h1>Feature #115 Sync Worker Ablation</h1>
<p>This report compares only the global repo-sync worker count while holding the Feature #100 ours-cache profile fixed.</p>
<section class="meta">
<div class="card"><b>Remote</b><span>{html.escape(provenance['remote'])}</span></div>
<div class="card"><b>Remote Root</b><span>{html.escape(provenance['remote_root'])}</span></div>
<div class="card"><b>Workers</b><span>{html.escape(', '.join(str(w) for w in provenance['workers']))}</span></div>
<div class="card"><b>Runs</b><span>{provenance['runs_per_worker']} per worker, {provenance['interval_secs']}s interval</span></div>
</section>
<h2>Per Run Metrics</h2>
<table>
<thead><tr><th>Workers</th><th>Mode</th><th>Wall</th><th>Wall Bar</th><th>Max RSS</th><th>Validation</th><th>Repo Sync</th><th>PP</th><th>VRPs</th><th>VAPs</th></tr></thead>
<tbody>
{''.join(bars)}
</tbody>
</table>
<h2>Configuration</h2>
<p>all5 live TAL URL, module-root rsync scope, PP cache enabled, ROA cache enabled, child-certificate cache enabled, transport prefetch disabled.</p>
<p>Local commit: <code>{html.escape(provenance['git_commit'])}</code>. Full JSONL and CSV evidence are stored beside this report.</p>
</body>
</html>
"""
write_text(out_root / "summary.html", html_doc)
def write_reports(out_root: Path, provenance: dict[str, Any]) -> None:
metrics = load_metrics(out_root)
write_csv_report(out_root, metrics)
write_markdown_report(out_root, metrics, provenance)
write_html_report(out_root, metrics, provenance)
def git_metadata() -> dict[str, str]:
commit = run_cmd(["git", "rev-parse", "--short", "HEAD"], cwd=PRODUCT_ROOT).stdout.strip()
status = run_cmd(["git", "status", "--short"], cwd=PRODUCT_ROOT).stdout
return {"git_commit": commit, "git_status_short": status}
def main() -> int:
args = parse_args()
stamp = utc_stamp()
out_root = (args.out_root / stamp).resolve()
remote_root = args.remote_root or f"/root/rpki_feature115_sync_worker_ablation_{stamp}"
remote_bin = f"{remote_root}/bin/rpki"
plans = plan_runs(args.workers, args.runs_per_worker, args.interval_secs, remote_root, remote_bin)
provenance = {
"created_at_utc": stamp,
"remote": args.remote,
"remote_root": remote_root,
"workers": args.workers,
"runs_per_worker": args.runs_per_worker,
"interval_secs": args.interval_secs,
"fixed_profile": "feature100 ours-pp-object-cache",
**git_metadata(),
}
write_json(out_root / "provenance.json", provenance)
write_text(out_root / "remote_root.txt", remote_root + "\n")
write_json(out_root / "execution_plan.json", plans)
if args.dry_run:
print(f"dry-run plan written to {out_root}")
return 0
binary = ensure_binary(args)
remote_bin = prepare_remote(args.remote, remote_root, binary)
plans = plan_runs(args.workers, args.runs_per_worker, args.interval_secs, remote_root, remote_bin)
write_json(out_root / "execution_plan.json", plans)
start_epoch = time.time()
for plan in plans:
write_json(
out_root / "run_configs" / f"workers-{plan['workers']:02d}-run-{plan['run_index']:04d}-{plan['mode']}.json",
plan,
)
scheduled_epoch = start_epoch + plan["ordinal"] * args.interval_secs
metrics = run_remote_plan(args.remote, plan, scheduled_epoch)
append_metric(out_root, metrics)
write_reports(out_root, provenance)
print(f"reports written under {out_root}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
check_rpki_client_ccr.sh \
--rpki-client <path> \
--ccr-root <directory-or-ccr-file> \
--out <result.jsonl> \
[--limit <count>]
Runs the supplied rpki-client importer against CCR files. A non-zero result
means that rpki-client reported a CCR-file parsing error or could not start.
Per-file command output is retained next to the JSONL result file.
EOF
}
RPKI_CLIENT=""
CCR_ROOT=""
OUT=""
LIMIT=0
while [[ $# -gt 0 ]]; do
case "$1" in
--rpki-client) RPKI_CLIENT=${2:?missing value for --rpki-client}; shift 2 ;;
--ccr-root) CCR_ROOT=${2:?missing value for --ccr-root}; shift 2 ;;
--out) OUT=${2:?missing value for --out}; shift 2 ;;
--limit) LIMIT=${2:?missing value for --limit}; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
[[ -n "$RPKI_CLIENT" && -n "$CCR_ROOT" && -n "$OUT" ]] || {
echo "error: --rpki-client, --ccr-root, and --out are required" >&2
usage >&2
exit 2
}
[[ -x "$RPKI_CLIENT" ]] || { echo "error: rpki-client is not executable: $RPKI_CLIENT" >&2; exit 2; }
[[ "$LIMIT" =~ ^[0-9]+$ ]] || { echo "error: --limit must be a non-negative integer" >&2; exit 2; }
mkdir -p "$(dirname "$OUT")"
LOG_DIR="${OUT%.jsonl}.logs"
mkdir -p "$LOG_DIR"
: > "$OUT"
declare -a CCR_FILES=()
if [[ -f "$CCR_ROOT" ]]; then
CCR_FILES=("$(readlink -f "$CCR_ROOT")")
elif [[ -d "$CCR_ROOT" ]]; then
while IFS= read -r -d '' file; do
CCR_FILES+=("$file")
done < <(find "$CCR_ROOT" -type f -name '*.ccr' -print0 | sort -z)
else
echo "error: CCR root does not exist: $CCR_ROOT" >&2
exit 2
fi
if (( LIMIT > 0 && ${#CCR_FILES[@]} > LIMIT )); then
CCR_FILES=("${CCR_FILES[@]:0:LIMIT}")
fi
if (( ${#CCR_FILES[@]} == 0 )); then
echo "error: no .ccr files found under: $CCR_ROOT" >&2
exit 2
fi
passed=0
failed=0
for ccr in "${CCR_FILES[@]}"; do
digest=$(sha256sum "$ccr" | awk '{print $1}')
log="$LOG_DIR/${digest}.log"
set +e
"$RPKI_CLIENT" -f "$ccr" >"$log" 2>&1
exit_code=$?
set -e
classification=pass
if (( exit_code != 0 )); then
classification=tool_error
elif grep -Fq "rpki-client: ${ccr}:" "$log"; then
classification=ccr_parse_error
fi
if [[ "$classification" == pass ]]; then
((passed += 1))
else
((failed += 1))
fi
python3 - "$OUT" "$ccr" "$digest" "$exit_code" "$classification" "$log" <<'PY'
import json
import sys
out, ccr, sha256, exit_code, classification, log = sys.argv[1:]
with open(out, "a", encoding="utf-8") as stream:
stream.write(json.dumps({
"ccr": ccr,
"sha256": sha256,
"rpkiClientExitCode": int(exit_code),
"classification": classification,
"log": log,
}, ensure_ascii=False, sort_keys=True) + "\n")
PY
done
echo "checked=${#CCR_FILES[@]} passed=${passed} failed=${failed} jsonl=${OUT} logs=${LOG_DIR}"
(( failed == 0 ))

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

@@ -1,18 +1,26 @@
# rpctl
`rpctl` 是四款 RP 本地控制 CLI,用于在实验服务器本地独立启动、停止和观察单个 RP/profile 的 all5 实验。一个实验可包含一次 snapshot 和多次 delta 子 run。
`rpctl` 是五款 RP 本地控制 CLI,用于在实验服务器本地独立启动、停止和观察单个 RP/profile 的 all5 实验。一个实验可包含一次 snapshot 和多次复用 state 的子 run。
## 部署
```bash
mkdir -p /root/rpki_4rp_ctl
rsync -a rpki_2/rpki/scripts/rpctl/ root@47.77.237.41:/root/rpki_4rp_ctl/
rsync -a specs/develop/20260707_3/feature100_four_rp_interval_archive/runtime_bundle/ root@47.77.237.41:/root/rpki_4rp_ctl_runtime_bundle/
ssh root@47.77.237.41 'cd /root/rpki_4rp_ctl && ./rpctl init --bundle /root/rpki_4rp_ctl_runtime_bundle'
mkdir -p /data/rpki_4rp_ctl
rsync -a rpki_2/rpki/scripts/rpctl/ root@47.251.127.231:/data/rpki_4rp_ctl/
rsync -a artifacts/rpki-prover/rpki-prover-6857d4bf-linux-amd64.docker.tar.gz root@47.251.127.231:/data/
ssh root@47.251.127.231 'cd /data/rpki_4rp_ctl && ./rpctl init \
--bundle /data/rpki_4rp_ctl_runtime_bundle \
--rpki-prover-image /data/rpki-prover-6857d4bf-linux-amd64.docker.tar.gz'
```
`--bundle` 目录需要包含 #100 runtime bundle 的 `bin/`、`lib/`、`fixtures/`、`scripts/` 和 `versions.json`。不要使用早期 precheck 目录作为标准 bundle,因为它可能缺少 FORT 运行所需动态库。
`--rpki-prover-image` 是可选项;不配置时原有四款 RP 仍可运行,但启动 rpki-prover 会明确报错。配置后 `init` 会复制并加载离线镜像,校验宿主机和镜像均为 `amd64`,确认 tag 为 `rpki-prover:6857d4bf`、版本为 `rpki-prover-0.10.1`,并将 image ID 与 archive SHA-256 写入 `config.json`。运行时不会在线拉取镜像。
## ours RP 默认同步并发
`ours` 和 `ours-no-cache` 两个 profile 都会显式传入 `--parallel-max-repo-sync-workers-global 8`,即默认使用 8 个全局仓库同步 worker。此设置只作用于 ours RP;Routinator、rpki-client 与 FORT 的运行参数保持不变。`show` 与 `view --command` 会在参数摘要中显示 `sync-workers=8`。
## 常用命令
```bash
@@ -20,6 +28,8 @@ ssh root@47.77.237.41 'cd /root/rpki_4rp_ctl && ./rpctl init --bundle /root/rpki
./rpctl status
./rpctl start --rp ours-pp-object-cache --interval 10m --runs 6 --rirs all5
./rpctl start --rp routinator --interval 0 --runs 1 --rirs apnic
./rpctl start --rp rpki-prover --interval 0 --runs 2 --rirs all5
./rpctl start --rp rpki-prover-full --interval 0 --runs 1 --rirs all5
./rpctl continue --exp-id <successful-exp-id> --runs 3 --interval 10m
./rpctl tail
./rpctl show
@@ -40,6 +50,10 @@ ssh root@47.77.237.41 'cd /root/rpki_4rp_ctl && ./rpctl init --bundle /root/rpki
| `routinator` | `routinator-latest-release` |
| `rpki-client` | `rpki-client-latest-release` |
| `fort` | `fort-latest-release` |
| `rpki-prover`, `rpki-prover-incremental` | `rpki-prover-incremental` |
| `rpki-prover-full`, `rpki-prover-no-incremental` | `rpki-prover-full` |
`rpki-prover-incremental` 使用上游默认增量算法;`rpki-prover-full` 显式传入 `--no-incremental-validation`。两者的 state 位于各自实验/profile 目录,互不复用。首轮显示为 `snapshot`;后续和 `continue` 追加轮显示为 `warm`,表示保留了上轮 state,实际 RRDP snapshot/delta 决策仍由 rpki-prover 自身完成。
## 数据目录
@@ -53,7 +67,9 @@ ssh root@47.77.237.41 'cd /root/rpki_4rp_ctl && ./rpctl init --bundle /root/rpki
runs/<profile>/<exp-id>/profiles/<profile>/runs/run_0001/
```
每个子 run 目录包含 `stdout.log`、`stderr.log`、`process-time.txt`、`exit-code.txt`、`run-meta.json` 以及各 RP 产物。
每个子 run 目录包含 `stdout.log`、`stderr.log`、`exit-code.txt`、`run-meta.json` 以及各 RP 产物。传统四 RP 使用 `process-time.txt`;rpki-prover 额外保存 `container-inspect.json`、`rss-samples.tsv` 和 `vrps.csv`。
rpki-prover 的 `run-meta.json` 和 `view` 同时记录 VRP 原始行数与按 `(ASN, prefix, maxLength)` 去重数。其当前 CLI 不输出 VAP,所以统一显示 `n/a`,不是 `0`。max RSS 是容器全部相关 PID 的 RSS 峰值汇总,不是短暂 Docker client 进程的 RSS。
## 历史查看与删除

View File

@@ -4,8 +4,10 @@ from __future__ import annotations
import argparse
import csv
import gzip
import hashlib
import json
import os
import platform
import re
import shutil
import signal
@@ -37,6 +39,8 @@ PROFILES = {
"routinator-latest-release": "routinator",
"rpki-client-latest-release": "rpki-client",
"fort-latest-release": "fort",
"rpki-prover-incremental": "rpki-prover",
"rpki-prover-full": "rpki-prover",
}
ALIASES = {
@@ -52,8 +56,15 @@ ALIASES = {
"rpki-client-latest-release": "rpki-client-latest-release",
"fort": "fort-latest-release",
"fort-latest-release": "fort-latest-release",
"rpki-prover": "rpki-prover-incremental",
"rpki-prover-incremental": "rpki-prover-incremental",
"rpki-prover-full": "rpki-prover-full",
"rpki-prover-no-incremental": "rpki-prover-full",
}
RPKI_PROVER_IMAGE_TAG = "rpki-prover:6857d4bf"
RPKI_PROVER_VERSION = "rpki-prover-0.10.1"
DEFAULT_CONFIG = {
"root": "/root/rpki_4rp_ctl",
"bundle": "",
@@ -207,6 +218,14 @@ def command_string(argv: list[str]) -> str:
return " ".join(shlex.quote(x) for x in argv)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def check_bundle(root: Path) -> None:
required = ["bin/rpki", "bin/routinator", "bin/rpki-client", "bin/fort", "fixtures/tal", "scripts/normalize_and_collect.py", "scripts/write_group_report.py", "versions.json"]
missing = [item for item in required if not (root / item).exists()]
@@ -232,6 +251,102 @@ def install_from_bundle(bundle: Path) -> None:
item.chmod(item.stat().st_mode | 0o111)
def validate_rpki_prover_host() -> None:
arch = platform.machine().lower()
if arch not in {"x86_64", "amd64"}:
raise SystemExit(f"rpki-prover image requires amd64 host, found: {arch}")
if shutil.which("docker") is None:
raise SystemExit("docker is required for rpki-prover profiles")
result = subprocess.run(
["docker", "info"],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if result.returncode != 0:
raise SystemExit(f"docker is not available: {result.stderr.strip()}")
def inspect_rpki_prover_image() -> dict[str, str]:
inspect = subprocess.run(
["docker", "image", "inspect", RPKI_PROVER_IMAGE_TAG],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if inspect.returncode != 0:
raise SystemExit(f"loaded image is missing tag {RPKI_PROVER_IMAGE_TAG}: {inspect.stderr.strip()}")
data = json.loads(inspect.stdout)[0]
architecture = str(data.get("Architecture") or "")
if architecture != "amd64":
raise SystemExit(f"rpki-prover image architecture mismatch: expected amd64, found {architecture or '<unknown>'}")
version = subprocess.run(
["docker", "run", "--rm", "--entrypoint", "/opt/app/rpki-prover", RPKI_PROVER_IMAGE_TAG, "--version"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
version_text = version.stdout.strip()
if version.returncode != 0 or version_text != RPKI_PROVER_VERSION:
raise SystemExit(
f"rpki-prover version check failed: expected {RPKI_PROVER_VERSION}, "
f"exit={version.returncode} output={version_text or version.stderr.strip()}"
)
return {
"tag": RPKI_PROVER_IMAGE_TAG,
"imageId": str(data.get("Id") or ""),
"architecture": architecture,
"version": version_text,
}
def install_rpki_prover_image(archive: Path) -> dict[str, str]:
if not archive.is_file():
raise SystemExit(f"rpki-prover image archive does not exist: {archive}")
validate_rpki_prover_host()
image_dir = ctl_root() / "images"
image_dir.mkdir(parents=True, exist_ok=True)
destination = image_dir / archive.name
if archive != destination:
shutil.copy2(archive, destination)
load = subprocess.run(
["docker", "load", "--input", str(destination)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if load.returncode != 0:
raise SystemExit(f"failed to load rpki-prover image archive: {load.stderr.strip()}")
metadata = inspect_rpki_prover_image()
metadata.update({
"archive": str(destination),
"archiveSha256": sha256_file(destination),
"loadedAtUtc": utc_now(),
})
return metadata
def require_rpki_prover_image() -> dict[str, Any]:
metadata = load_config().get("rpkiProverImage")
if not isinstance(metadata, dict) or metadata.get("tag") != RPKI_PROVER_IMAGE_TAG:
raise SystemExit(
"rpki-prover image is not initialized; run ./rpctl init --bundle <bundle> "
"--rpki-prover-image <docker.tar.gz>"
)
validate_rpki_prover_host()
inspected = inspect_rpki_prover_image()
if metadata.get("imageId") and metadata.get("imageId") != inspected.get("imageId"):
raise SystemExit(
f"rpki-prover image id changed: configured={metadata.get('imageId')} "
f"loaded={inspected.get('imageId')}"
)
return metadata
def cmd_init(args: argparse.Namespace) -> None:
ensure_dirs()
bundle = Path(args.bundle).resolve()
@@ -239,9 +354,16 @@ def cmd_init(args: argparse.Namespace) -> None:
cfg = load_config()
cfg["bundle"] = str(bundle)
cfg["initializedAtUtc"] = utc_now()
if args.rpki_prover_image:
cfg["rpkiProverImage"] = install_rpki_prover_image(Path(args.rpki_prover_image).resolve())
save_config(cfg)
print(f"initialized root={ctl_root()}")
print(f"bundle={bundle}")
if cfg.get("rpkiProverImage"):
image = cfg["rpkiProverImage"]
print(f"rpki_prover_image={image.get('tag')}")
print(f"rpki_prover_image_id={image.get('imageId')}")
print(f"rpki_prover_archive_sha256={image.get('archiveSha256')}")
def timed_run(run_dir: Path, argv: list[str], env: dict[str, str] | None = None) -> int:
@@ -293,6 +415,7 @@ def build_ours_args(profile: str, run_root: Path, run_dir: Path, state: Path, ri
"--vrps-csv-out", str(run_dir / "vrps.csv"),
"--vaps-csv-out", str(run_dir / "vaps.csv"),
"--compare-view-trust-anchor", "all5" if len(rirs) > 1 else rirs[0],
"--parallel-max-repo-sync-workers-global", "8",
"--parallel-phase2-ready-batch-size", "256",
"--parallel-phase2-ready-batch-wall-time-budget-ms", "100",
"--parallel-phase2-result-drain-batch-size", "2048",
@@ -396,6 +519,240 @@ def build_fort_args(run_dir: Path, state: Path, rirs: list[str]) -> tuple[list[s
return argv, env
def prepare_rpki_prover_state(state: Path, rirs: list[str]) -> None:
tal_dir = state / "tals"
tal_dir.mkdir(parents=True, exist_ok=True)
for old_tal in tal_dir.glob("*.tal"):
old_tal.unlink()
for rir in rirs:
shutil.copy2(ctl_root() / "fixtures" / "tal" / RIR_TAL[rir], tal_dir / RIR_TAL[rir])
def rpki_prover_arguments(profile: str) -> list[str]:
argv = [
"--once",
"--vrp-output", "/outputs/vrps.csv",
"--no-rir-tals",
"--rpki-root-directory", "/rpki-data",
"--cpu-count", "4",
"--fetcher-count", "8",
"--log-level", "info",
]
if profile == "rpki-prover-full":
argv.append("--no-incremental-validation")
return argv
def build_rpki_prover_command(profile: str, run_dir: Path, state: Path, container_name: str) -> list[str]:
return [
"docker", "run", "--name", container_name,
"-v", f"{state.resolve()}:/rpki-data",
"-v", f"{run_dir.resolve()}:/outputs",
RPKI_PROVER_IMAGE_TAG,
*rpki_prover_arguments(profile),
]
def container_rss_kb(container_name: str) -> tuple[int, int]:
top = subprocess.run(
["docker", "top", container_name, "-eo", "pid"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
check=False,
)
total = 0
maximum = 0
for line in top.stdout.splitlines():
pid = line.strip()
if not pid.isdigit():
continue
try:
status = Path(f"/proc/{pid}/status").read_text(encoding="utf-8", errors="replace")
except OSError:
continue
match = re.search(r"^VmRSS:\s+(\d+)\s+kB$", status, re.MULTILINE)
if not match:
continue
rss = int(match.group(1))
total += rss
maximum = max(maximum, rss)
return total, maximum
def timed_rpki_prover_run(
profile: str,
run_dir: Path,
state: Path,
container_name: str,
) -> tuple[int, int, int, int, dict[str, Any]]:
run_dir.mkdir(parents=True, exist_ok=True)
start_utc = utc_now()
start_ns = time.monotonic_ns()
(run_dir / "start-utc.txt").write_text(start_utc + "\n", encoding="utf-8")
create_argv = [
"docker", "create", "--name", container_name,
"-v", f"{state.resolve()}:/rpki-data",
"-v", f"{run_dir.resolve()}:/outputs",
RPKI_PROVER_IMAGE_TAG,
*rpki_prover_arguments(profile),
]
create = subprocess.run(create_argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
if create.returncode != 0:
(run_dir / "stderr.log").write_text(create.stderr, encoding="utf-8")
(run_dir / "stdout.log").write_text(create.stdout, encoding="utf-8")
(run_dir / "end-utc.txt").write_text(utc_now() + "\n", encoding="utf-8")
(run_dir / "exit-code.txt").write_text(str(create.returncode) + "\n", encoding="utf-8")
return create.returncode, 0, 0, (time.monotonic_ns() - start_ns) // 1_000_000, {}
previous_sigterm = signal.getsignal(signal.SIGTERM)
def terminate_container(signum: int, _frame: Any) -> None:
subprocess.run(
["docker", "rm", "--force", container_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
raise SystemExit(128 + signum)
signal.signal(signal.SIGTERM, terminate_container)
max_total_rss_kb = 0
max_process_rss_kb = 0
inspect_data: dict[str, Any] = {}
samples_path = run_dir / "rss-samples.tsv"
with (run_dir / "stdout.log").open("wb") as stdout, (run_dir / "stderr.log").open("wb") as stderr, samples_path.open("w", encoding="utf-8") as samples:
samples.write("epoch_ms\ttotal_rss_kb\tmax_process_rss_kb\n")
start = subprocess.Popen(["docker", "start", "--attach", container_name], stdout=stdout, stderr=stderr)
while start.poll() is None:
total_rss_kb, process_rss_kb = container_rss_kb(container_name)
max_total_rss_kb = max(max_total_rss_kb, total_rss_kb)
max_process_rss_kb = max(max_process_rss_kb, process_rss_kb)
samples.write(f"{int(time.time() * 1000)}\t{total_rss_kb}\t{process_rss_kb}\n")
samples.flush()
time.sleep(0.25)
start.wait()
try:
inspect = subprocess.run(
["docker", "inspect", container_name],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if inspect.returncode == 0:
parsed = json.loads(inspect.stdout)
if parsed:
inspect_data = parsed[0]
write_json(run_dir / "container-inspect.json", inspect_data)
state_data = inspect_data.get("State", {}) if inspect_data else {}
exit_code = int(state_data.get("ExitCode", start.returncode if start.returncode is not None else 1))
finally:
subprocess.run(["docker", "rm", "--force", container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
signal.signal(signal.SIGTERM, previous_sigterm)
wall_ms = (time.monotonic_ns() - start_ns) // 1_000_000
(run_dir / "end-utc.txt").write_text(utc_now() + "\n", encoding="utf-8")
(run_dir / "exit-code.txt").write_text(str(exit_code) + "\n", encoding="utf-8")
return exit_code, max_total_rss_kb, max_process_rss_kb, wall_ms, inspect_data
def collect_rpki_prover_vrps(path: Path) -> tuple[int, set[str]]:
unique: set[str] = set()
raw = 0
if not path.is_file():
return raw, unique
with path.open("r", encoding="utf-8", errors="replace", newline="") as handle:
for row in csv.reader(handle):
if not row or row[0].strip().lower() == "asn":
continue
if len(row) < 3:
continue
raw += 1
unique.add(",".join(value.strip() for value in row[:3]))
return raw, unique
def write_rpki_prover_run_metadata(
run_root: Path,
profile: str,
run_seq: int,
mode: str,
wall_ms: int,
max_rss_kb: int,
max_process_rss_kb: int,
exit_code: int,
) -> dict[str, Any]:
run_dir = run_root / "profiles" / profile / "runs" / f"run_{run_seq:04d}"
raw_vrps, unique_vrps = collect_rpki_prover_vrps(run_dir / "vrps.csv")
with gzip.open(run_dir / "normalized-vrps.txt.gz", "wt", encoding="utf-8") as handle:
for row in sorted(unique_vrps):
handle.write(row + "\n")
image = load_config().get("rpkiProverImage", {})
meta = {
"profile": profile,
"rp": "rpki-prover",
"runSeq": run_seq,
"syncMode": mode,
"startUtc": read_text_strip(run_dir / "start-utc.txt"),
"endUtc": read_text_strip(run_dir / "end-utc.txt"),
"exitCode": exit_code,
"timing": {
"wallMs": wall_ms,
"maxRssKb": max_rss_kb,
"maxProcessRssKb": max_process_rss_kb,
},
"counts": {
"vrps": raw_vrps,
"vrpsRaw": raw_vrps,
"vrpsUnique": len(unique_vrps),
"vaps": "n/a",
},
"vapsAvailable": False,
"validationAlgorithm": "full-every-iteration" if profile == "rpki-prover-full" else "incremental",
"image": image,
"remoteRunDir": str(run_dir),
}
write_json(run_dir / "run-meta.json", meta)
return meta
def write_rpki_prover_group_report(run_root: Path, profile: str) -> None:
rows: list[dict[str, Any]] = []
run_dirs = sorted((run_root / "profiles" / profile / "runs").glob("run_*"))
for run_dir in run_dirs:
meta = read_json(run_dir / "run-meta.json", {})
if not meta:
continue
rows.append({
"profile": profile,
"rp": "rpki-prover",
"run_seq": meta.get("runSeq", ""),
"sync_mode": meta.get("syncMode", ""),
"start_utc": meta.get("startUtc", ""),
"end_utc": meta.get("endUtc", ""),
"exit_code": meta.get("exitCode", ""),
"wall_ms": meta.get("timing", {}).get("wallMs", ""),
"max_rss_kb": meta.get("timing", {}).get("maxRssKb", ""),
"max_process_rss_kb": meta.get("timing", {}).get("maxProcessRssKb", ""),
"vrps": meta.get("counts", {}).get("vrpsRaw", ""),
"vrps_unique": meta.get("counts", {}).get("vrpsUnique", ""),
"vaps": "n/a",
"vaps_available": False,
"validation_algorithm": meta.get("validationAlgorithm", ""),
"remote_run_dir": str(run_dir),
})
reports = run_root / "reports"
reports.mkdir(parents=True, exist_ok=True)
if rows:
with (reports / "per_run_metrics.csv").open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
write_json(reports / "per_run_metrics.json", {"generatedAtUtc": utc_now(), "rows": rows})
def normalize_one(run_root: Path, profile: str, run_seq: int) -> None:
script = ctl_root() / "scripts" / "normalize_and_collect.py"
if not script.exists():
@@ -460,6 +817,18 @@ def parse_entry_rirs(entry: dict[str, Any]) -> list[str]:
return parse_rirs(str(value))
def write_experiment_versions(run_root: Path) -> None:
versions = read_json(ctl_root() / "versions.json", {})
image = load_config().get("rpkiProverImage")
if image:
versions["rpki-prover"] = image
profiles = versions.setdefault("profiles", [])
for profile in ["rpki-prover-incremental", "rpki-prover-full"]:
if profile not in profiles:
profiles.append(profile)
write_json(run_root / "versions.json", versions)
def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs: list[str], append: bool = False) -> int:
ensure_dirs()
rp = PROFILES[profile]
@@ -467,7 +836,7 @@ def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs:
profile_root = run_root / "profiles" / profile
(run_root / "logs").mkdir(parents=True, exist_ok=True)
(run_root / "reports").mkdir(parents=True, exist_ok=True)
(run_root / "versions.json").write_text((ctl_root() / "versions.json").read_text(encoding="utf-8"), encoding="utf-8")
write_experiment_versions(run_root)
now = utc_now()
if append:
run_info = normalize_experiment_metadata(read_json(run_root / "run-info.json", {}))
@@ -504,6 +873,9 @@ def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs:
"experimentRoot": str(run_root),
"stateRoot": str(profile_root / "state"),
}
if rp == "rpki-prover":
run_info["validationAlgorithm"] = cache_profile_name(profile)
run_info["rpkiProverImage"] = load_config().get("rpkiProverImage", {})
write_json(run_root / "run-info.json", run_info)
status = {
"state": "running",
@@ -535,7 +907,7 @@ def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs:
time.sleep(sleep_seconds)
last_start = time.time()
snapshot = run_seq == 1
mode = "snapshot" if snapshot else "delta"
mode = "snapshot" if snapshot else ("warm" if rp == "rpki-prover" else "delta")
run_dir = profile_root / "runs" / f"run_{run_seq:04d}"
if run_dir.exists() and any(run_dir.iterdir()):
raise RuntimeError(f"refusing to overwrite existing run directory: {run_dir}")
@@ -550,13 +922,34 @@ def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs:
argv, env = build_rpki_client_args(run_dir, state, rirs)
elif rp == "fort":
argv, env = build_fort_args(run_dir, state, rirs)
elif rp == "rpki-prover":
prepare_rpki_prover_state(state, rirs)
container_suffix = hashlib.sha1(f"{run_root}:{run_seq}".encode("utf-8")).hexdigest()[:12]
container_name = f"rpctl-prover-{container_suffix}"
argv = build_rpki_prover_command(profile, run_dir, state, container_name)
else:
raise RuntimeError(rp)
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "command.txt").write_text(command_string(argv) + "\n", encoding="utf-8")
code = timed_run(run_dir, argv, env=env)
normalize_one(run_root, profile, run_seq)
meta = parse_latest_meta(run_root, profile, run_seq)
if rp == "rpki-prover":
code, max_rss_kb, max_process_rss_kb, wall_ms, _ = timed_rpki_prover_run(
profile, run_dir, state, container_name
)
meta = write_rpki_prover_run_metadata(
run_root,
profile,
run_seq,
mode,
wall_ms,
max_rss_kb,
max_process_rss_kb,
code,
)
write_rpki_prover_group_report(run_root, profile)
else:
code = timed_run(run_dir, argv, env=env)
normalize_one(run_root, profile, run_seq)
meta = parse_latest_meta(run_root, profile, run_seq)
update_current(
state="running",
lastRunSeq=run_seq,
@@ -571,7 +964,10 @@ def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs:
if code != 0:
exit_code = code
break
write_group_report(run_root, interval_secs, completed_seq)
if rp == "rpki-prover":
write_rpki_prover_group_report(run_root, profile)
else:
write_group_report(run_root, interval_secs, completed_seq)
final_state = "success" if exit_code == 0 else "failed"
update_current(state=final_state, finishedAtUtc=utc_now(), exitCode=exit_code)
run_info = normalize_experiment_metadata(read_json(run_root / "run-info.json", {}))
@@ -645,6 +1041,10 @@ def cmd_paths(args: argparse.Namespace) -> None:
print(f"runs={runs_dir()}")
print(f"logs={logs_dir()}")
print(f"bundle={cfg.get('bundle','')}")
image = cfg.get("rpkiProverImage", {})
print(f"rpki_prover_image={image.get('tag', '')}")
print(f"rpki_prover_image_id={image.get('imageId', '')}")
print(f"rpki_prover_image_archive={image.get('archive', '')}")
cur = current_live_status()
if cur.get("experimentRoot"):
print(f"current_experiment_root={cur['experimentRoot']}")
@@ -668,6 +1068,8 @@ def cmd_start(args: argparse.Namespace) -> None:
raise SystemExit("--runs must be >= 1")
if not (ctl_root() / "versions.json").exists():
raise SystemExit("rpctl is not initialized; run ./rpctl init --bundle <bundle-root> first")
if PROFILES[profile] == "rpki-prover":
require_rpki_prover_image()
exp_id = args.exp_id or f"{utc_id()}_{profile}_{args.runs}runs_{interval_secs}s"
runner_args = [
sys.executable,
@@ -717,6 +1119,8 @@ def cmd_continue(args: argparse.Namespace) -> None:
if entry["state"] != "success":
raise SystemExit(f"can only continue a successful experiment: exp_id={args.exp_id} state={entry['state']}")
profile = entry["profile"]
if PROFILES[profile] == "rpki-prover":
require_rpki_prover_image()
rirs = parse_entry_rirs(entry)
interval_secs = parse_interval(args.interval)
runner_args = [
@@ -867,6 +1271,13 @@ def parse_command_summary(command: str) -> str:
keys.append(f"--rsync-scope={tokens[idx + 1]}")
elif token.startswith("--rsync-scope="):
keys.append(token)
elif token == "--parallel-max-repo-sync-workers-global" and idx + 1 < len(tokens):
keys.append(f"sync-workers={tokens[idx + 1]}")
elif token.startswith("--parallel-max-repo-sync-workers-global="):
keys.append(f"sync-workers={token.rsplit('=', 1)[1]}")
if RPKI_PROVER_IMAGE_TAG in token_set:
algorithm = "full-every-iteration" if "--no-incremental-validation" in token_set else "incremental"
keys.append(f"algorithm={algorithm}")
return " ".join(keys)
@@ -945,6 +1356,10 @@ def cache_profile_name(profile: str) -> str:
return "pp+roa+crt"
if profile == "ours-no-cache":
return "off"
if profile == "rpki-prover-incremental":
return "incremental"
if profile == "rpki-prover-full":
return "full-every-iteration"
return "n/a"
@@ -971,6 +1386,8 @@ def profile_label(profile: str) -> str:
"routinator-latest-release": "routinator",
"rpki-client-latest-release": "rpki-client",
"fort-latest-release": "fort",
"rpki-prover-incremental": "prover-inc",
"rpki-prover-full": "prover-full",
}
return labels.get(profile, profile)
@@ -1043,6 +1460,9 @@ def infer_output_counts(profile: str, run_dir: Path) -> tuple[str, str]:
return vrps, vaps
if rp == "fort":
return count_csv_data_rows(run_dir / "vrps.csv"), "0" if (run_dir / "vrps.csv").exists() else ""
if rp == "rpki-prover":
raw, _ = collect_rpki_prover_vrps(run_dir / "vrps.csv")
return str(raw) if (run_dir / "vrps.csv").exists() else "", "n/a" if (run_dir / "vrps.csv").exists() else ""
return "", ""
@@ -1355,6 +1775,7 @@ def fallback_run_details(entry: dict[str, Any]) -> list[dict[str, Any]]:
"wall_ms": timing.get("wallMs", ""),
"max_rss_kb": timing.get("maxRssKb", ""),
"vrps": first_non_empty(meta.get("counts", {}).get("vrps") if meta else "", inferred_vrps),
"vrps_unique": first_non_empty(meta.get("counts", {}).get("vrpsUnique") if meta else ""),
"vaps": first_non_empty(meta.get("counts", {}).get("vaps") if meta else "", inferred_vaps),
"remote_run_dir": str(run_dir),
})
@@ -1391,7 +1812,7 @@ def cmd_view(args: argparse.Namespace) -> None:
print("command=" + entry["command"])
if rows:
print("\nper-run:")
headers = ["run", "mode", "exit", "start", "end", "wall_ms", "rss_kb", "vrps", "vaps", "path"]
headers = ["run", "mode", "exit", "start", "end", "wall_ms", "rss_kb", "vrps", "vrps_unique", "vaps", "path"]
table_rows = []
for row in rows:
table_rows.append([
@@ -1403,6 +1824,7 @@ def cmd_view(args: argparse.Namespace) -> None:
first_non_empty(row.get("wall_ms"), row.get("wallMs")),
first_non_empty(row.get("max_rss_kb"), row.get("maxRssKb")),
first_non_empty(row.get("vrps"), row.get("VRPs")),
first_non_empty(row.get("vrps_unique"), row.get("vrpsUnique")),
first_non_empty(row.get("vaps"), row.get("VAPs")),
first_non_empty(row.get("remote_run_dir"), row.get("remoteRunDir")),
])
@@ -1412,7 +1834,7 @@ def cmd_view(args: argparse.Namespace) -> None:
def build_parser() -> argparse.ArgumentParser:
formatter = argparse.RawDescriptionHelpFormatter
parser = argparse.ArgumentParser(
description="Control one local four-RP performance run on this server.",
description="Control one local five-RP performance run on this server.",
formatter_class=formatter,
epilog="""
RP/profile naming:
@@ -1435,6 +1857,14 @@ RP/profile naming:
fort
Alias of fort-latest-release. Uses archived FORT validator release binary.
rpki-prover / rpki-prover-incremental
Uses the archived rpki-prover Docker image with its default incremental
validation algorithm and persistent state.
rpki-prover-full / rpki-prover-no-incremental
Uses the same image with --no-incremental-validation. Its state is kept
separate from the incremental profile.
Cache difference for ours RP:
--rp ours adds:
--enable-publication-point-validation-cache
@@ -1443,11 +1873,17 @@ Cache difference for ours RP:
--rp ours-no-cache does not add those flags.
Ours RP sync parallelism:
Both ours RP profiles explicitly use 8 global repository sync workers.
This setting does not change Routinator, rpki-client, or FORT parameters.
Typical usage:
./rpctl init --bundle /root/rpki_4rp_ctl_runtime_bundle
./rpctl init --bundle /data/rpki_4rp_ctl_runtime_bundle \\
--rpki-prover-image /data/rpki-prover-6857d4bf-linux-amd64.docker.tar.gz
./rpctl list
./rpctl start --rp ours --interval 10m --runs 6 --rirs all5
./rpctl start --rp ours-no-cache --interval 10m --runs 6 --rirs all5
./rpctl start --rp rpki-prover --interval 0 --runs 2 --rirs all5
./rpctl status
./rpctl show
./rpctl show --failed
@@ -1458,7 +1894,8 @@ Typical usage:
Data model:
- Experiment data stays under ./runs/<profile>/<exp-id>/.
- Each experiment starts with a snapshot; later child runs are deltas and reuse the profile state.
- Each experiment starts with a snapshot; later child runs reuse the profile state.
- rpki-prover labels reused-state runs as warm because its native storage decides snapshot/delta behavior.
- By default only one RP/profile can run at a time.
""",
)
@@ -1481,14 +1918,24 @@ The bundle must contain:
scripts/write_group_report.py
versions.json
Optional rpki-prover image archive:
Pass --rpki-prover-image to copy and load the saved linux/amd64 Docker image.
Init verifies image architecture, tag and rpki-prover version without pulling online.
Recommended source on 47.77.237.41:
/root/rpki_4rp_ctl_runtime_bundle
Example:
./rpctl init --bundle /root/rpki_4rp_ctl_runtime_bundle
./rpctl init --bundle /data/rpki_4rp_ctl_runtime_bundle \\
--rpki-prover-image /data/rpki-prover-6857d4bf-linux-amd64.docker.tar.gz
""",
)
p.add_argument("--bundle", required=True, help="Path to the archived #100 runtime bundle root.")
p.add_argument(
"--rpki-prover-image",
default="",
help="Optional saved rpki-prover linux/amd64 Docker image archive. Existing four-RP profiles do not require it.",
)
p.set_defaults(func=cmd_init)
p = sub.add_parser(
@@ -1502,6 +1949,10 @@ Use this command when unsure which value to pass to --rp.
Important ours RP profiles:
ours-pp-object-cache cache-enabled profile
ours-no-cache no-cache baseline profile
rpki-prover profiles:
rpki-prover-incremental default incremental validation
rpki-prover-full --no-incremental-validation baseline
""",
)
p.set_defaults(func=cmd_list)
@@ -1525,11 +1976,17 @@ RP/profile examples:
./rpctl start --rp routinator --interval 15m --runs 2 --rirs apnic
./rpctl start --rp rpki-client --interval 0 --runs 1 --rirs apnic,arin
./rpctl start --rp fort --interval 0 --runs 1 --rirs all5
./rpctl start --rp rpki-prover --interval 0 --runs 2 --rirs all5
./rpctl start --rp rpki-prover-full --interval 0 --runs 1 --rirs all5
Cache mapping:
--rp ours enables PP cache + ROA cache + child certificate cache.
--rp ours-no-cache disables those cache flags.
Ours RP sync workers:
Both ours profiles pass --parallel-max-repo-sync-workers-global 8.
This does not alter the parameters of Routinator, rpki-client, or FORT.
Safety:
rpctl refuses to start a second RP while another rpctl runner is alive.
""",
@@ -1537,7 +1994,7 @@ Safety:
p.add_argument(
"--rp",
required=True,
help="RP/profile or alias. Common values: ours, ours-no-cache, routinator, rpki-client, fort.",
help="RP/profile or alias. Common values: ours, ours-no-cache, routinator, rpki-client, fort, rpki-prover, rpki-prover-full.",
)
p.add_argument(
"--interval",
@@ -1578,6 +2035,7 @@ Run semantics:
Examples:
./rpctl continue --exp-id 20260709T081122Z_ours-pp-object-cache_2runs_600s --runs 3 --interval 10m
./rpctl continue --rp routinator --exp-id m18_237_routinator_all5_snapshot_delta_20260710T002726Z --runs 1 --interval 0
./rpctl continue --rp rpki-prover --exp-id prover_all5 --runs 1 --interval 0
""",
)
p.add_argument("--exp-id", required=True, help="Existing successful experiment id to append to.")
@@ -1692,7 +2150,7 @@ Examples:
./rpctl show --json
""",
)
p.add_argument("--rp", default="", help="Optional RP/profile filter, e.g. ours, ours-no-cache, routinator, rpki-client, fort.")
p.add_argument("--rp", default="", help="Optional RP/profile filter, e.g. ours, ours-no-cache, routinator, rpki-client, fort, rpki-prover.")
p.add_argument("--failed", action="store_true", help="Only show failed, partial, incomplete or exited experiments.")
p.add_argument("--limit", type=int, default=50, help="Maximum rows to show. 0 means no limit. Default: 50.")
p.add_argument("--paths", action="store_true", help="Also show full experiment root paths. Hidden by default to keep the table readable.")

113
scripts/rpctl/test_rpctl.py Normal file
View File

@@ -0,0 +1,113 @@
from __future__ import annotations
import importlib.machinery
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
def load_rpctl():
path = Path(__file__).with_name("rpctl")
loader = importlib.machinery.SourceFileLoader("rpctl_module", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
class RpkiProverProfileTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.rpctl = load_rpctl()
def test_profile_aliases_and_algorithms(self) -> None:
self.assertEqual(self.rpctl.normalize_profile("rpki-prover"), "rpki-prover-incremental")
self.assertEqual(self.rpctl.normalize_profile("rpki-prover-no-incremental"), "rpki-prover-full")
self.assertNotIn("--no-incremental-validation", self.rpctl.rpki_prover_arguments("rpki-prover-incremental"))
self.assertIn("--no-incremental-validation", self.rpctl.rpki_prover_arguments("rpki-prover-full"))
incremental = self.rpctl.parse_command_summary(
f"docker run {self.rpctl.RPKI_PROVER_IMAGE_TAG} --once"
)
full = self.rpctl.parse_command_summary(
f"docker run {self.rpctl.RPKI_PROVER_IMAGE_TAG} --once --no-incremental-validation"
)
self.assertEqual(incremental, "algorithm=incremental")
self.assertEqual(full, "algorithm=full-every-iteration")
def test_vrp_count_preserves_raw_and_unique(self) -> None:
with tempfile.TemporaryDirectory() as temp:
csv_path = Path(temp) / "vrps.csv"
csv_path.write_text(
"ASN,IP Prefix,Max Length,Trust Anchor\n"
"AS64496,192.0.2.0/24,24,TA-A\n"
"AS64496,192.0.2.0/24,24,TA-B\n"
"AS64497,2001:db8::/32,48,TA-A\n",
encoding="utf-8",
)
raw, unique = self.rpctl.collect_rpki_prover_vrps(csv_path)
self.assertEqual(raw, 3)
self.assertEqual(len(unique), 2)
def test_run_metadata_marks_vaps_not_applicable(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
old_ctl_root = self.rpctl.ctl_root
self.rpctl.ctl_root = lambda: root
try:
self.rpctl.save_config({"rpkiProverImage": {"tag": self.rpctl.RPKI_PROVER_IMAGE_TAG}})
run_root = root / "runs" / "rpki-prover-incremental" / "test"
run_dir = run_root / "profiles" / "rpki-prover-incremental" / "runs" / "run_0001"
run_dir.mkdir(parents=True)
(run_dir / "start-utc.txt").write_text("2026-07-17T00:00:00Z\n", encoding="utf-8")
(run_dir / "end-utc.txt").write_text("2026-07-17T00:01:00Z\n", encoding="utf-8")
(run_dir / "vrps.csv").write_text(
"ASN,IP Prefix,Max Length,Trust Anchor\nAS64496,192.0.2.0/24,24,TA-A\n",
encoding="utf-8",
)
meta = self.rpctl.write_rpki_prover_run_metadata(
run_root, "rpki-prover-incremental", 1, "snapshot", 60_000, 1234, 1200, 0
)
self.assertEqual(meta["counts"]["vrpsRaw"], 1)
self.assertEqual(meta["counts"]["vrpsUnique"], 1)
self.assertEqual(meta["counts"]["vaps"], "n/a")
self.assertFalse(meta["vapsAvailable"])
self.rpctl.write_rpki_prover_group_report(run_root, "rpki-prover-incremental")
report = (run_root / "reports" / "per_run_metrics.csv").read_text(encoding="utf-8")
self.assertIn("vrps_unique", report)
self.assertIn("n/a", report)
finally:
self.rpctl.ctl_root = old_ctl_root
def test_missing_image_configuration_fails_before_docker(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
old_ctl_root = self.rpctl.ctl_root
self.rpctl.ctl_root = lambda: root
try:
self.rpctl.save_config({})
with self.assertRaisesRegex(SystemExit, "not initialized"):
self.rpctl.require_rpki_prover_image()
finally:
self.rpctl.ctl_root = old_ctl_root
def test_versions_include_prover_profiles(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
old_ctl_root = self.rpctl.ctl_root
self.rpctl.ctl_root = lambda: root
try:
(root / "versions.json").write_text(json.dumps({"profiles": ["fort-latest-release"]}), encoding="utf-8")
self.rpctl.save_config({"rpkiProverImage": {"tag": self.rpctl.RPKI_PROVER_IMAGE_TAG}})
run_root = root / "experiment"
self.rpctl.write_experiment_versions(run_root)
versions = json.loads((run_root / "versions.json").read_text(encoding="utf-8"))
self.assertIn("rpki-prover-incremental", versions["profiles"])
self.assertIn("rpki-prover-full", versions["profiles"])
finally:
self.rpctl.ctl_root = old_ctl_root
if __name__ == "__main__":
unittest.main()

View File

@@ -14,7 +14,7 @@ Usage:
scripts/soak/build_portable_soak_package.sh [--out-dir <path>] [--profile <profile>]
Requires release binaries to already exist. Build them first, for example:
cargo build --release --bin rpki --bin rpki_daemon --bin db_stats --bin rpki_artifact_metrics --bin rpki_query_service --bin rpki_query_indexer
cargo build --release --bin rpki --bin rpki_daemon --bin db_stats --bin rpki_artifact_metrics --bin rpki_query_service --bin rpki_query_indexer --bin verification_ccr_compare
USAGE
}
@@ -53,7 +53,7 @@ else
TARGET_BIN_DIR="$REPO_ROOT/target/$PROFILE"
fi
REQUIRED_BINS=(rpki rpki_daemon db_stats rpki_artifact_metrics rpki_query_service rpki_query_indexer)
REQUIRED_BINS=(rpki rpki_daemon db_stats rpki_artifact_metrics rpki_query_service rpki_query_indexer verification_ccr_compare)
OPTIONAL_BINS=(
ccr_dump
ccr_state_compare
@@ -82,16 +82,21 @@ STAGE_DIR="$BUILD_ROOT/$PACKAGE_DIR_NAME"
ARCHIVE_PATH="$OUT_DIR/$PACKAGE_NAME.tar.gz"
rm -rf "$STAGE_DIR" "$ARCHIVE_PATH"
mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/fixtures" "$STAGE_DIR/scripts" "$STAGE_DIR/scripts/soak" \
mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/fixtures" "$STAGE_DIR/scripts" "$STAGE_DIR/scripts/soak" "$STAGE_DIR/scripts/verification" \
"$STAGE_DIR/runs" "$STAGE_DIR/state" "$STAGE_DIR/logs" "$STAGE_DIR/tmp"
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"
install -m 0755 "$SCRIPT_DIR/publish_remote231_full.sh" "$STAGE_DIR/scripts/soak/publish_remote231_full.sh"
install -m 0755 "$REPO_ROOT/scripts/verification/run_verification_only.sh" "$STAGE_DIR/scripts/verification/run_verification_only.sh"
install -m 0644 "$REPO_ROOT/scripts/verification/README.md" "$STAGE_DIR/scripts/verification/README.md"
install -m 0644 "$SCRIPT_DIR/portable-soak.env.example" "$STAGE_DIR/.env"
install -m 0644 "$SCRIPT_DIR/portable-soak.env.example" "$STAGE_DIR/portable-soak.env.example"
@@ -116,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}"
@@ -48,6 +52,7 @@ TMP_DIR="${TMP_DIR:-$RUN_ROOT/tmp}"
RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-$STATE_ROOT/rsync-mirror}"
INVALID_ROOT="$STATE_ROOT/invalid"
RESET_STAGING_ROOT="$STATE_ROOT/reset-staging"
STATE_EXECUTION_LOCK_PATH="$STATE_ROOT/locks/state-execution.lock"
LIVE_TA_REFRESH_DIR="${LIVE_TA_REFRESH_DIR:-$META_DIR/live-ta-refresh}"
LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS="${LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS:-15}"
LIVE_TA_REFRESH_MAX_TIME_SECS="${LIVE_TA_REFRESH_MAX_TIME_SECS:-120}"
@@ -118,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
}
@@ -155,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" ;;
@@ -170,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
}
@@ -181,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
}
@@ -192,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
}
@@ -1057,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
@@ -1080,6 +1131,7 @@ build_child_args() {
CHILD_ARGS+=(
--report-json "{run_out}/report.json"
--validation-contract-out "{run_out}/validation-contract.json"
)
if is_true "$OUTPUT_COMPACT_REPORT"; then
CHILD_ARGS+=(--report-json-compact)
@@ -1109,6 +1161,59 @@ build_child_args() {
fi
}
write_current_state_binding() {
local run_dir="$1"
local run_id="$2"
local output_path="$META_DIR/current-state-binding.json"
python3 - "$run_dir" "$run_id" "$output_path" <<'PY'
import hashlib
import json
import os
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
run_dir = Path(sys.argv[1])
run_id = sys.argv[2]
output_path = Path(sys.argv[3])
summary = json.loads((run_dir / "run-summary.json").read_text(encoding="utf-8"))
if summary.get("status") != "success":
raise SystemExit(f"cannot bind unsuccessful source run: {run_id}")
def digest(name):
path = run_dir / name
if not path.is_file():
raise SystemExit(f"current state binding input is missing: {path}")
hasher = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
hasher.update(chunk)
return hasher.hexdigest()
binding = {
"schemaVersion": 1,
"currentRunId": run_id,
"sourceCirSha256": digest("input.cir"),
"sourceCcrSha256": digest("result.ccr"),
"validationContractSha256": digest("validation-contract.json"),
"updatedAtRfc3339Utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
output_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=output_path.name + ".", dir=output_path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(binding, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_name, output_path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
PY
}
copy_inner_run_outputs() {
local daemon_state_root="$1"
local run_dir="$2"
@@ -1276,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 \
@@ -1304,6 +1420,12 @@ run_one_round() {
final_status="failed"
fi
fi
if [[ "$final_status" == "success" ]]; then
if ! write_current_state_binding "$run_dir" "$run_id"; then
warn "failed to bind successful run to current state: $run_id"
final_status="failed"
fi
fi
write_run_meta "$run_dir/run-meta.json" "$final_status" "$run_index" "$run_id" "$sync_mode" \
"$snapshot_reason" "$previous_run_id" "$previous_success_value" "$started_at" "$completed_at" \
"$INVALID_DB_PATH" "$INVALID_STATE_PATH" "$INVALID_TMP_PATH" "$daemon_exit_code" "$PACKAGE_ROOT" "$ENV_FILE" \
@@ -1327,6 +1449,7 @@ main() {
require_command python3
require_command date
require_command find
require_command flock
if [[ "$TAL_INPUT_MODE" == "file-live-ta" ]]; then
require_command curl
validate_positive_int "LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS" "$LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS"
@@ -1344,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"
@@ -1360,7 +1484,7 @@ main() {
fi
done
mkdir -p "$RUNS_ROOT" "$LOG_ROOT" "$DB_DIR" "$META_DIR" "$TMP_DIR" "$INVALID_ROOT" "$RESET_STAGING_ROOT" "$LIVE_TA_REFRESH_DIR"
mkdir -p "$RUNS_ROOT" "$LOG_ROOT" "$DB_DIR" "$META_DIR" "$TMP_DIR" "$INVALID_ROOT" "$RESET_STAGING_ROOT" "$LIVE_TA_REFRESH_DIR" "$(dirname "$STATE_EXECUTION_LOCK_PATH")"
if is_true "$ALLOW_RSYNC_MIRROR_REUSE"; then
mkdir -p "$RSYNC_MIRROR_ROOT"
fi
@@ -1386,6 +1510,9 @@ main() {
local any_failed=0
while (( run_forever == 1 || next_index <= stop_index )); do
local state_lock_fd
exec {state_lock_fd}>"$STATE_EXECUTION_LOCK_PATH"
flock "$state_lock_fd"
INVALID_DB_PATH=""
INVALID_STATE_PATH=""
INVALID_TMP_PATH=""
@@ -1472,6 +1599,8 @@ main() {
echo "completed run $(printf 'run_%04d' "$next_index") status=failed" >&2
any_failed=1
fi
flock -u "$state_lock_fd"
exec {state_lock_fd}>&-
if (( STOP_AFTER_SECS > 0 )); then
elapsed_secs=$(( $(date +%s) - started_epoch ))
if (( elapsed_secs >= STOP_AFTER_SECS )); then

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

@@ -0,0 +1,50 @@
# verification-only 使用说明
`verification-only` 用于复核启用验证缓存的最新成功 normal run。它冻结当前仓库视图,关闭 PP、ROA、child certificate cache、prefetch、VCIR 持久化和网络访问,在源 CIR 的同一验证时间重新执行完整验证,并比较 CCR 的 MFT、VRP、VAP、TA、router key 五类 state digest。
## 前提
- 源 run 必须是当前 state DB 对应的最新成功 normal run。
- 源 run 必须包含 `input.cir`、`result.ccr`、`run-summary.json` 和 `validation-contract.json`。
- state root 必须包含 `db/work-db`、`db/repo-bytes.db` 和 `meta/current-state-binding.json`。
- 源 normal run 必须启用至少一种验证缓存;仅启用 transport prefetch 不算验证缓存。
- verification-only 与 normal soak 共用 `state/locks/state-execution.lock`,不能绕过 wrapper 并发执行。
## 执行
在 portable package 根目录执行:
```bash
./scripts/verification/run_verification_only.sh \
--source-run-dir /data/ours-rp/runs/run_0123 \
--source-state-root /data/ours-rp/state \
--out-dir /data/ours-rp/verification-runs/run_0123
```
验证时间自动从源 `input.cir` 读取,不接受命令行覆盖。成功后 scratch work-db 默认删除;排障时可加 `--keep-scratch`。
## 输出
- `input.cir`、`result.ccr`、`report.json`
- `vrps.csv`、`vaps.csv`
- `stage-timing.json`、`validation-events.jsonl`
- `validation-contract.json`、`verification-meta.json`
- `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` 不参与比较。
## 隔离保证
- 不分配 normal run id,不更新 lifecycle、periodic snapshot 计数和 retention。
- 源 work-db 只用于创建 checkpoint,后续写入只发生在独立 scratch work-db。
- 源 repo-bytes.db 以只读模式打开。
- 输出目录必须不存在,防止覆盖旧验证证据。
## 常见失败
- `current state binding`:所选 run 已不是当前 state 对应的最新成功 run,需选择 binding 指向的 run。
- `differs from current state binding`:源 CIR、CCR 或 contract 已变化,不能继续复核。
- `blob is missing`:repository view 引用的原始 bytes 不完整,验证不会启动。
- `did not enable a validation cache`:源 run 不是缓存正确性对比的有效基准。
- `CCR state digest mismatch`:查看 `compare/` 下 JSON/Markdown,按不匹配状态集合继续排查。

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

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
run_verification_only.sh \
--source-run-dir <run_dir> \
--source-state-root <state_dir> \
--out-dir <verification_dir> \
[--rpki-bin <path>] [--keep-scratch]
validation time is always read from the source CIR. The wrapper serializes access
with normal soak runs and never writes into the normal run sequence.
USAGE
}
SOURCE_RUN_DIR=""
SOURCE_STATE_ROOT=""
OUT_DIR=""
RPKI_BIN=""
KEEP_SCRATCH=0
while [[ $# -gt 0 ]]; do
case "$1" in
--source-run-dir) SOURCE_RUN_DIR="${2:?missing value}"; shift 2 ;;
--source-state-root) SOURCE_STATE_ROOT="${2:?missing value}"; shift 2 ;;
--out-dir) OUT_DIR="${2:?missing value}"; shift 2 ;;
--rpki-bin) RPKI_BIN="${2:?missing value}"; shift 2 ;;
--keep-scratch) KEEP_SCRATCH=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
[[ -n "$SOURCE_RUN_DIR" ]] || { echo "--source-run-dir is required" >&2; exit 2; }
[[ -n "$SOURCE_STATE_ROOT" ]] || { echo "--source-state-root is required" >&2; exit 2; }
[[ -n "$OUT_DIR" ]] || { echo "--out-dir is required" >&2; exit 2; }
if [[ -z "$RPKI_BIN" ]]; then
RPKI_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/target/release/rpki"
fi
[[ -x "$RPKI_BIN" ]] || { echo "rpki binary is not executable: $RPKI_BIN" >&2; exit 2; }
command -v flock >/dev/null || { echo "flock is required" >&2; exit 2; }
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
now_ms() {
python3 -c 'import time; print(time.time_ns() // 1_000_000)'
}
mkdir -p "$SOURCE_STATE_ROOT/locks" "$(dirname "$OUT_DIR")"
[[ ! -e "$OUT_DIR" ]] || { echo "verification output already exists: $OUT_DIR" >&2; exit 2; }
LOCK_PATH="$SOURCE_STATE_ROOT/locks/state-execution.lock"
STDOUT_TMP="${OUT_DIR}.stdout.tmp"
STDERR_TMP="${OUT_DIR}.stderr.tmp"
PROCESS_TIME_TMP="${OUT_DIR}.process-time.tmp"
WAIT_STARTED_MS="$(now_ms)"
exec {LOCK_FD}>"$LOCK_PATH"
flock "$LOCK_FD"
LOCK_ACQUIRED_MS="$(now_ms)"
ARGS=(
--verification-only
--verification-source-run "$SOURCE_RUN_DIR"
--verification-source-state "$SOURCE_STATE_ROOT"
--verification-out "$OUT_DIR"
)
if [[ "$KEEP_SCRATCH" -eq 1 ]]; then
ARGS+=(--keep-verification-scratch)
fi
set +e
if [[ -x /usr/bin/time ]]; then
RPKI_STATE_EXECUTION_LOCK_HELD=1 /usr/bin/time -v -o "$PROCESS_TIME_TMP" -- \
"$RPKI_BIN" "${ARGS[@]}" >"$STDOUT_TMP" 2>"$STDERR_TMP"
else
RPKI_STATE_EXECUTION_LOCK_HELD=1 "$RPKI_BIN" "${ARGS[@]}" >"$STDOUT_TMP" 2>"$STDERR_TMP"
fi
EXIT_CODE=$?
set -e
LOCK_RELEASED_MS="$(now_ms)"
flock -u "$LOCK_FD"
exec {LOCK_FD}>&-
mkdir -p "$OUT_DIR"
mv "$STDOUT_TMP" "$OUT_DIR/stdout.log"
mv "$STDERR_TMP" "$OUT_DIR/stderr.log"
if [[ -f "$PROCESS_TIME_TMP" ]]; then
mv "$PROCESS_TIME_TMP" "$OUT_DIR/process-time.txt"
fi
python3 - "$OUT_DIR/wrapper-timing.json" "$WAIT_STARTED_MS" "$LOCK_ACQUIRED_MS" "$LOCK_RELEASED_MS" "$EXIT_CODE" <<'PY'
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
wait_started_ms, lock_acquired_ms, lock_released_ms, exit_code = map(int, sys.argv[2:])
path.write_text(json.dumps({
"lockWaitMs": lock_acquired_ms - wait_started_ms,
"lockHeldMs": lock_released_ms - lock_acquired_ms,
"exitCode": exit_code,
}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
PY
exit "$EXIT_CODE"

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

@@ -7,9 +7,11 @@ use std::process::Command;
use std::sync::{Arc, Mutex};
use rpki::blob_store::ExternalRepoBytesDb;
use rpki::query::report_stream::{ObjectFilter, ObjectScope};
use rpki::query::vrp::VrpLookup;
use rpki::query_db::{
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, QueryDb, QueryDbError,
ValidationExplainRecord,
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb,
QueryDbError, RepoSortOrder, ValidationExplainRecord,
};
use serde::Serialize;
use serde_json::{Value, json};
@@ -148,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() {
@@ -161,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,
@@ -227,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,
@@ -248,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!(
@@ -258,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}"),
}
@@ -521,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()),
}
}
}
@@ -534,16 +566,27 @@ 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}}));
}
match segments.as_slice() {
["healthz"] => {
let latest_ready_run = db.latest_ready_run()?;
data_response(
&json!({
"status": "ok",
"service": "rpki_query_service",
"version": 1,
"latestReadyRun": latest_ready_run,
}),
None,
)
}
["runs"] => page_response(db.list_runs(limit(&query), cursor(&query))?, None),
["latest_run"] => {
let run_id = db
@@ -585,13 +628,30 @@ fn route_request(
Some(run_id),
)
}
["runs", raw_run_id, "repos"] => {
["runs", raw_run_id, "vrps", "lookup"] => {
let run_id = resolve_run(db, raw_run_id)?;
let lookup = vrp_lookup_from_query(&query)?;
let asn = query
.get("asn")
.map(|value| {
value.parse::<u32>().map_err(|_| {
ApiError::new(400, format!("invalid ASN query parameter: {value}"))
})
})
.transpose()?;
page_response(
db.list_repos(&run_id, limit(&query), cursor(&query))?,
db.lookup_vrps(&run_id, &lookup, asn, limit(&query), cursor(&query))?,
Some(run_id),
)
}
["runs", raw_run_id, "repos"] => {
let run_id = resolve_run(db, raw_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)?;
let repo = db
@@ -630,8 +690,15 @@ fn route_request(
}
["runs", raw_run_id, "repos", repo_id, "objects"] => {
let run_id = resolve_run(db, raw_run_id)?;
let filter = object_filter_from_query(&query)?;
page_response(
db.list_objects_for_repo(&run_id, repo_id, limit(&query), cursor(&query))?,
db.list_objects_filtered(
&run_id,
ObjectScope::Repo(repo_id.to_string()),
&filter,
limit(&query),
cursor(&query),
)?,
Some(run_id),
)
}
@@ -669,18 +736,63 @@ fn route_request(
}
["runs", raw_run_id, "publication-points", pp_id, "objects"] => {
let run_id = resolve_run(db, raw_run_id)?;
let filter = object_filter_from_query(&query)?;
page_response(
db.list_objects_for_pp(&run_id, pp_id, limit(&query), cursor(&query))?,
db.list_objects_filtered(
&run_id,
ObjectScope::PublicationPoint(pp_id.to_string()),
&filter,
limit(&query),
cursor(&query),
)?,
Some(run_id),
)
}
["runs", raw_run_id, "objects"] => {
let run_id = resolve_run(db, raw_run_id)?;
let filter = object_filter_from_query(&query)?;
page_response(
db.list_objects(&run_id, limit(&query), cursor(&query))?,
db.list_objects_filtered(
&run_id,
ObjectScope::All,
&filter,
limit(&query),
cursor(&query),
)?,
Some(run_id),
)
}
["runs", raw_run_id, "issues"] => {
let run_id = resolve_run(db, raw_run_id)?;
let mut filter = object_filter_from_query(&query)?;
filter.issues_only = true;
filter.repo_id = query.get("repoId").cloned();
filter.pp_id = query.get("ppId").cloned();
page_response(
db.list_objects_filtered(
&run_id,
ObjectScope::All,
&filter,
limit(&query),
cursor(&query),
)?,
Some(run_id),
)
}
["runs", raw_run_id, "search"] => {
let run_id = resolve_run(db, raw_run_id)?;
let q = query
.get("q")
.map(String::as_str)
.unwrap_or("")
.trim()
.to_string();
if q.is_empty() {
return Err(ApiError::new(400, "q query parameter is required"));
}
let limit = search_limit(&query);
data_response(&search_run(db, &run_id, &q, limit)?, Some(run_id))
}
["runs", raw_run_id, "objects", "by-uri"] => {
let run_id = resolve_run(db, raw_run_id)?;
let uri = query
@@ -792,6 +904,18 @@ fn route_request(
offset_cursor(&query),
)
}
["runs", raw_run_id, "exports"] => {
let run_id = resolve_run(db, raw_run_id)?;
let limit = exports_limit(&query);
page_response(
rpki::query_db::QueryPage {
data: list_export_jobs(export_jobs, &run_id, limit)?,
next_cursor: None,
limit,
},
Some(run_id),
)
}
["runs", raw_run_id, "exports", job_id] => {
let run_id = resolve_run(db, raw_run_id)?;
let job = get_export_job(db, export_jobs, &run_id, job_id)?
@@ -813,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)?;
@@ -889,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"] => {
@@ -1046,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(
@@ -1111,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 {
@@ -1150,6 +1286,24 @@ fn get_export_job(
Ok(db.get_export_job(run_id, job_id)?)
}
fn list_export_jobs(
export_jobs: &ExportJobStore,
run_id: &str,
limit: usize,
) -> Result<Vec<ExportJobRecord>, ApiError> {
let jobs = export_jobs
.lock()
.map_err(|_| ApiError::new(500, "export job store lock poisoned"))?;
let mut out = jobs
.values()
.filter(|job| job.run_id == run_id)
.cloned()
.collect::<Vec<_>>();
out.sort_by(|left, right| right.created_at.cmp(&left.created_at));
out.truncate(limit);
Ok(out)
}
fn run_export_job(
db: &QueryDb,
repo_bytes: &ExternalRepoBytesDb,
@@ -1575,6 +1729,103 @@ fn projection_for_explain(
Ok(Some(projection))
}
fn search_run(db: &QueryDb, run_id: &str, q: &str, limit: usize) -> Result<Value, ApiError> {
let objects = search_run_objects(db, run_id, q, limit)?;
let lower = q.to_lowercase();
let repos = db.search_repos(run_id, &lower, limit)?;
let publication_points = db.search_publication_points(run_id, &lower, limit)?;
Ok(json!({
"objects": objects,
"repos": repos,
"publicationPoints": publication_points,
}))
}
fn search_run_objects(
db: &QueryDb,
run_id: &str,
q: &str,
limit: usize,
) -> Result<Vec<ObjectInstanceRecord>, ApiError> {
if q.starts_with("rsync://") || q.starts_with("http://") || q.starts_with("https://") {
let Some(index) = db.get_object_by_uri(run_id, q)? else {
return Ok(Vec::new());
};
let object = match db.get_object_by_instance_id(run_id, &index.object_instance_id)? {
Some(full) => full,
None => object_instance_from_uri_index(&index),
};
return Ok(vec![object]);
}
let lower = q.to_lowercase();
if is_sha256_hex(q) {
if let Some(projection) = db.get_object_projection(&lower)? {
if let Some(full) = db.get_cached_object_by_sha256(run_id, &projection.sha256)? {
return Ok(vec![full]);
}
return Ok(vec![object_instance_from_projection(run_id, &projection)]);
}
if let Some(object) = db.get_object_by_sha256(run_id, &lower)? {
return Ok(vec![object]);
}
return Ok(Vec::new());
}
let is_hex = lower.len() >= 8 && lower.bytes().all(|byte| byte.is_ascii_hexdigit());
if is_hex {
let mut objects = Vec::new();
for projection in db.search_object_projections_by_hash_prefix(&lower, limit)? {
let object = match db.get_cached_object_by_sha256(run_id, &projection.sha256)? {
Some(full) => full,
None => object_instance_from_projection(run_id, &projection),
};
objects.push(object);
}
return Ok(objects);
}
Ok(Vec::new())
}
fn object_instance_from_uri_index(index: &ObjectUriIndexRecord) -> ObjectInstanceRecord {
ObjectInstanceRecord {
schema_version: 1,
run_id: index.run_id.clone(),
object_instance_id: index.object_instance_id.clone(),
uri: index.uri.clone(),
uri_hash: String::new(),
sha256: index.sha256.clone(),
object_type: String::new(),
result: String::new(),
detail_summary: None,
repo_id: index.repo_id.clone(),
pp_id: index.pp_id.clone(),
source_section: "uri_index".to_string(),
rejected: false,
reject_reason: None,
}
}
fn object_instance_from_projection(
run_id: &str,
projection: &rpki::object_projection::ObjectProjectionRecord,
) -> ObjectInstanceRecord {
ObjectInstanceRecord {
schema_version: 1,
run_id: run_id.to_string(),
object_instance_id: String::new(),
uri: String::new(),
uri_hash: String::new(),
sha256: projection.sha256.clone(),
object_type: projection.object_type.clone(),
result: projection.parse_status.clone(),
detail_summary: projection.error_summary.clone(),
repo_id: String::new(),
pp_id: String::new(),
source_section: "hash_index".to_string(),
rejected: false,
reject_reason: None,
}
}
fn global_projection_for_hash(
db: &QueryDb,
repo_bytes: Option<&ExternalRepoBytesDb>,
@@ -1870,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")
@@ -1878,6 +2141,105 @@ fn limit(query: &BTreeMap<String, String>) -> usize {
.clamp(1, 1000)
}
fn search_limit(query: &BTreeMap<String, String>) -> usize {
query
.get("limit")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(10)
.clamp(1, 50)
}
fn exports_limit(query: &BTreeMap<String, String>) -> usize {
query
.get("limit")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(50)
.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") {
if value.is_empty() {
return Err(ApiError::new(400, "type filter must not be empty"));
}
// Accept projection-vocabulary aliases so both `mft` and `manifest`
// (etc.) match the URI-derived report stream types.
let lower = value.to_ascii_lowercase();
let normalized = match lower.as_str() {
"mft" => "manifest",
"cer" => "certificate",
"asa" => "aspa",
_ => lower.as_str(),
};
filter.object_type = Some(normalized.to_string());
}
if let Some(value) = query.get("result") {
if !matches!(
value.to_ascii_lowercase().as_str(),
"ok" | "error" | "skipped"
) {
return Err(ApiError::new(
400,
format!("invalid result filter: {value}"),
));
}
filter.result = Some(value.clone());
}
if let Some(value) = query.get("rejected") {
filter.rejected = Some(match value.as_str() {
"true" => true,
"false" => false,
_ => {
return Err(ApiError::new(
400,
format!("invalid rejected filter: {value}"),
));
}
});
}
if let Some(value) = query.get("reason") {
filter.reason = Some(value.to_lowercase());
}
if let Some(value) = query.get("q") {
filter.query = Some(value.to_lowercase());
}
Ok(filter)
}
fn vrp_lookup_from_query(query: &BTreeMap<String, String>) -> Result<VrpLookup, ApiError> {
let ip = query.get("ip");
let prefix = query.get("prefix");
match (ip, prefix) {
(Some(_), Some(_)) => Err(ApiError::new(
400,
"ip and prefix query parameters are mutually exclusive",
)),
(None, None) => Err(ApiError::new(
400,
"one of ip or prefix query parameters is required",
)),
(Some(value), None) => {
VrpLookup::parse_ip(value).map_err(|error| ApiError::new(400, error))
}
(None, Some(value)) => {
VrpLookup::parse_prefix(value).map_err(|error| ApiError::new(400, error))
}
}
}
fn cursor(query: &BTreeMap<String, String>) -> Option<&str> {
query.get("cursor").map(String::as_str)
}
@@ -1933,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();
@@ -1947,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())
}
@@ -2223,6 +2607,28 @@ mod tests {
.as_u64(),
Some(1)
);
let vrp_ip = test_route_request(
&db,
Some(&repo_bytes),
"/api/v1/runs/latest/vrps/lookup?ip=2001:da8::1",
)
.expect("VRP IP lookup");
assert_eq!(vrp_ip["data"][0]["asn"].as_u64(), Some(4538));
assert_eq!(vrp_ip["meta"]["runId"].as_str(), Some("run_0001"));
let vrp_prefix = test_route_request(
&db,
Some(&repo_bytes),
"/api/v1/runs/latest/vrps/lookup?prefix=2001%3Ada8%3A%3A%2F32&asn=4538",
)
.expect("VRP prefix lookup");
assert_eq!(vrp_prefix["data"].as_array().unwrap().len(), 1);
let invalid = test_route_request(
&db,
Some(&repo_bytes),
"/api/v1/runs/latest/vrps/lookup?ip=192.0.2.1&prefix=192.0.2.0%2F24",
)
.expect_err("conflicting VRP lookup parameters");
assert_eq!(invalid.status, 400);
for target in [
"/api/v1/runs/latest/repos",
@@ -2289,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");
@@ -2476,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

@@ -0,0 +1,171 @@
use std::path::PathBuf;
fn usage() -> &'static str {
"Usage: verification_ccr_compare --source-ccr <path> --verification-ccr <path> --out-json <path> [--out-md <path>]"
}
#[derive(Debug, PartialEq, Eq)]
struct Args {
source_ccr: PathBuf,
verification_ccr: PathBuf,
out_json: PathBuf,
out_md: Option<PathBuf>,
}
fn parse_args(argv: &[String]) -> Result<Args, String> {
let mut source_ccr = None;
let mut verification_ccr = None;
let mut out_json = None;
let mut out_md = None;
let mut index = 1usize;
while index < argv.len() {
let target = match argv[index].as_str() {
"--source-ccr" => &mut source_ccr,
"--verification-ccr" => &mut verification_ccr,
"--out-json" => &mut out_json,
"--out-md" => &mut out_md,
"-h" | "--help" => return Err(usage().to_string()),
other => return Err(format!("unknown argument: {other}\n{}", usage())),
};
index += 1;
*target =
Some(PathBuf::from(argv.get(index).ok_or_else(|| {
format!("{} requires a value", argv[index - 1])
})?));
index += 1;
}
let source_ccr = source_ccr.ok_or_else(|| format!("--source-ccr is required\n{}", usage()))?;
let verification_ccr =
verification_ccr.ok_or_else(|| format!("--verification-ccr is required\n{}", usage()))?;
let out_json = out_json.ok_or_else(|| format!("--out-json is required\n{}", usage()))?;
Ok(Args {
source_ccr,
verification_ccr,
out_json,
out_md,
})
}
fn run(args: &Args) -> Result<(), String> {
let comparison =
rpki::verification_only::compare_ccr_files(&args.source_ccr, &args.verification_ccr)?;
rpki::verification_only::write_ccr_comparison(
&args.out_json,
args.out_md.as_deref(),
&comparison,
)?;
if !comparison.state_digest_match {
return Err(format!(
"CCR state digest mismatch: {}",
comparison.mismatched_states.join(",")
));
}
println!("CCR state digests match: {}", args.out_json.display());
Ok(())
}
fn main() -> Result<(), String> {
let argv = std::env::args().collect::<Vec<_>>();
run(&parse_args(&argv)?)
}
#[cfg(test)]
mod tests {
use super::*;
use rpki::ccr::{
CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation,
build_roa_payload_state, encode_content_info,
};
use rpki::data_model::roa::{IpPrefix, RoaAfi};
use rpki::validation::objects::Vrp;
use std::fs;
fn argv(values: &[&str]) -> Vec<String> {
std::iter::once("verification_ccr_compare")
.chain(values.iter().copied())
.map(str::to_string)
.collect()
}
fn sample_ccr(asn: u32) -> Vec<u8> {
let vrps = build_roa_payload_state(&[Vrp {
asn,
prefix: IpPrefix {
afi: RoaAfi::Ipv4,
prefix_len: 24,
addr: [192, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
max_length: 24,
}])
.expect("build VRP state");
encode_content_info(&CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: time::OffsetDateTime::UNIX_EPOCH,
mfts: None,
vrps: Some(vrps),
vaps: None,
tas: None,
rks: None,
}))
.expect("encode CCR")
}
#[test]
fn parse_args_accepts_complete_input_and_rejects_bad_forms() {
let parsed = parse_args(&argv(&[
"--source-ccr",
"source.ccr",
"--verification-ccr",
"verification.ccr",
"--out-json",
"result.json",
"--out-md",
"result.md",
]))
.expect("parse");
assert_eq!(parsed.source_ccr, PathBuf::from("source.ccr"));
assert_eq!(parsed.out_md, Some(PathBuf::from("result.md")));
assert!(parse_args(&argv(&[])).unwrap_err().contains("source-ccr"));
assert!(
parse_args(&argv(&["--source-ccr"]))
.unwrap_err()
.contains("requires a value")
);
assert!(
parse_args(&argv(&["--unknown"]))
.unwrap_err()
.contains("unknown argument")
);
assert!(parse_args(&argv(&["--help"])).is_err());
}
#[test]
fn run_writes_matching_outputs_and_rejects_mismatch() {
let temp = tempfile::tempdir().expect("tempdir");
let source = temp.path().join("source.ccr");
let verification = temp.path().join("verification.ccr");
let out_json = temp.path().join("out/result.json");
let out_md = temp.path().join("out/result.md");
fs::write(&source, sample_ccr(64496)).expect("source");
fs::write(&verification, sample_ccr(64496)).expect("verification");
let args = Args {
source_ccr: source.clone(),
verification_ccr: verification.clone(),
out_json: out_json.clone(),
out_md: Some(out_md.clone()),
};
run(&args).expect("matching run");
assert!(out_json.is_file());
assert!(out_md.is_file());
fs::write(&verification, sample_ccr(64497)).expect("mismatch");
let error = run(&args).unwrap_err();
assert!(error.contains("vrps"));
assert!(
fs::read_to_string(out_json)
.expect("comparison JSON")
.contains("mismatchedStates")
);
}
}

View File

@@ -77,6 +77,8 @@ pub struct ExternalRawStoreDb {
pub struct ExternalRepoBytesDb {
path: PathBuf,
db: Arc<DB>,
read_only: bool,
secondary: bool,
}
impl ExternalRawStoreDb {
@@ -88,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,
@@ -185,10 +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,
})
}
@@ -196,18 +202,70 @@ 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(());
}
if self.read_only {
return Err(StorageError::RocksDb(format!(
"repo-bytes DB is read-only: {}",
self.path.display()
)));
}
let mut batch = WriteBatch::default();
for (sha256_hex, bytes) in blobs {
validate_blob_sha256_hex(sha256_hex)?;
@@ -221,6 +279,42 @@ impl ExternalRepoBytesDb {
Ok(())
}
pub(crate) fn is_read_only(&self) -> bool {
self.read_only
}
pub(crate) fn require_existing_blob_bytes_batch(
&self,
blobs: &[(String, Vec<u8>)],
) -> StorageResult<()> {
if blobs.is_empty() {
return Ok(());
}
let hashes = blobs
.iter()
.map(|(sha256_hex, _)| sha256_hex.clone())
.collect::<Vec<_>>();
let existing = self.get_blob_bytes_batch(&hashes)?;
for ((sha256_hex, expected_bytes), actual_bytes) in blobs.iter().zip(existing) {
match actual_bytes {
Some(actual_bytes) if actual_bytes == *expected_bytes => {}
Some(_) => {
return Err(StorageError::InvalidData {
entity: "read_only_repo_bytes",
detail: format!("existing bytes differ for SHA-256 {sha256_hex}"),
});
}
None => {
return Err(StorageError::InvalidData {
entity: "read_only_repo_bytes",
detail: format!("blob is missing for SHA-256 {sha256_hex}"),
});
}
}
}
Ok(())
}
pub fn get_blob_bytes(&self, sha256_hex: &str) -> StorageResult<Option<Vec<u8>>> {
validate_blob_sha256_hex(sha256_hex)?;
let key = repo_bytes_key(sha256_hex);
@@ -695,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

@@ -6,6 +6,7 @@ use crate::ccr::build::{
};
use crate::ccr::encode::encode_manifest_state_payload_der;
use crate::ccr::hash::compute_state_hash;
use crate::ccr::manifest_location::select_manifest_signed_object_location_from_der;
use crate::ccr::model::{
CcrDigestAlgorithm, ManifestInstance, ManifestState, RpkiCanonicalCacheRepresentation,
};
@@ -54,7 +55,10 @@ impl CcrManifestContribution {
aki: projection.manifest_ee_aki.clone(),
manifest_number_be: projection.manifest_number_be.clone(),
this_update,
locations_der: projection.manifest_sia_locations_der.clone(),
locations_der: vec![select_manifest_signed_object_location_from_der(
&projection.manifest_rsync_uri,
&projection.manifest_sia_locations_der,
)?],
subordinate_skis: projection.subordinate_skis.clone(),
})
}
@@ -246,7 +250,8 @@ mod tests {
use crate::ccr::export::build_ccr_from_run;
use crate::ccr::verify::verify_content_info;
use crate::data_model::manifest::ManifestObject;
use crate::data_model::rc::SubjectInfoAccess;
use crate::data_model::oid::{OID_AD_RPKI_NOTIFY, OID_AD_SIGNED_OBJECT};
use crate::data_model::rc::{AccessDescription, SubjectInfoAccess};
use crate::data_model::roa::{IpPrefix, RoaAfi};
use crate::data_model::ta::TrustAnchor;
use crate::data_model::tal::Tal;
@@ -283,16 +288,35 @@ mod tests {
)
.expect("read manifest");
let manifest = ManifestObject::decode_der(&manifest_der).expect("decode manifest");
let manifest_uri = match manifest.signed_object.signed_data.certificates[0]
.resource_cert
.tbs
.extensions
.subject_info_access
.as_ref()
.expect("manifest sia")
{
SubjectInfoAccess::Ee(ee_sia) => ee_sia
.access_descriptions
.iter()
.find(|ad| {
ad.access_method_oid == OID_AD_SIGNED_OBJECT
&& ad.access_location.starts_with("rsync://")
})
.expect("manifest rsync signedObject")
.access_location
.clone(),
SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"),
};
let manifest_hash = hex::encode(sha2::Sha256::digest(&manifest_der));
let mut raw = RawByHashEntry::from_bytes(manifest_hash.clone(), manifest_der.clone());
raw.origin_uris
.push("rsync://example.test/repo/current.mft".to_string());
raw.origin_uris.push(manifest_uri.clone());
raw.object_type = Some("mft".to_string());
raw.encoding = Some("der".to_string());
store.put_raw_by_hash_entry(&raw).expect("put raw");
let projection = VcirCcrManifestProjection {
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
manifest_rsync_uri: manifest_uri.clone(),
manifest_sha256: sha2::Sha256::digest(&manifest_der).to_vec(),
manifest_size: manifest_der.len() as u64,
manifest_ee_aki: manifest.signed_object.signed_data.certificates[0]
@@ -312,23 +336,20 @@ mod tests {
.as_ref()
.expect("manifest sia")
{
SubjectInfoAccess::Ee(ee_sia) => ee_sia
.access_descriptions
.iter()
.map(|ad| {
let oid = encode_oid_for_test(&ad.access_method_oid)
.expect("encode access method oid");
let uri = encode_tlv_for_test(0x86, ad.access_location.as_bytes().to_vec());
encode_sequence_for_test(&[oid, uri])
})
.collect(),
SubjectInfoAccess::Ee(ee_sia) => vec![
crate::ccr::manifest_location::select_manifest_signed_object_location(
&manifest_uri,
&ee_sia.access_descriptions,
)
.expect("select manifest signedObject"),
],
SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"),
},
subordinate_skis: vec![vec![0x33; 20]],
};
let vcir = ValidatedCaInstanceResult {
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
manifest_rsync_uri: manifest_uri.clone(),
parent_manifest_rsync_uri: None,
tal_id: "apnic".to_string(),
ca_subject_name: "CN=test".to_string(),
@@ -337,8 +358,8 @@ mod tests {
last_successful_validation_time: PackTime::from_utc_offset_datetime(
manifest.manifest.this_update,
),
current_manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
current_crl_rsync_uri: "rsync://example.test/repo/current.crl".to_string(),
current_manifest_rsync_uri: manifest_uri.clone(),
current_crl_rsync_uri: format!("{manifest_uri}.crl"),
validated_manifest_meta: ValidatedManifestMeta {
validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(),
validated_manifest_this_update: PackTime::from_utc_offset_datetime(
@@ -381,10 +402,11 @@ mod tests {
related_artifacts: vec![VcirRelatedArtifact {
artifact_role: VcirArtifactRole::Manifest,
artifact_kind: VcirArtifactKind::Mft,
uri: Some("rsync://example.test/repo/current.mft".to_string()),
uri: Some(manifest_uri.clone()),
sha256: manifest_hash,
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
}],
summary: VcirSummary {
local_vrp_count: 0,
@@ -429,55 +451,6 @@ mod tests {
(vcir, vrps, aspas, router_keys)
}
fn encode_oid_for_test(oid: &str) -> Result<Vec<u8>, String> {
let arcs = oid
.split('.')
.map(|part| part.parse::<u64>().map_err(|_| format!("bad oid: {oid}")))
.collect::<Result<Vec<_>, _>>()?;
if arcs.len() < 2 {
return Err(format!("bad oid: {oid}"));
}
let mut body = Vec::new();
body.push((arcs[0] * 40 + arcs[1]) as u8);
for arc in &arcs[2..] {
encode_base128_for_test(*arc, &mut body);
}
Ok(encode_tlv_for_test(0x06, body))
}
fn encode_base128_for_test(mut value: u64, out: &mut Vec<u8>) {
let mut tmp = vec![(value & 0x7F) as u8];
value >>= 7;
while value > 0 {
tmp.push(((value & 0x7F) as u8) | 0x80);
value >>= 7;
}
tmp.reverse();
out.extend_from_slice(&tmp);
}
fn encode_sequence_for_test(elements: &[Vec<u8>]) -> Vec<u8> {
let total_len: usize = elements.iter().map(Vec::len).sum();
let mut buf = Vec::with_capacity(total_len);
for element in elements {
buf.extend_from_slice(element);
}
encode_tlv_for_test(0x30, buf)
}
fn encode_tlv_for_test(tag: u8, value: Vec<u8>) -> Vec<u8> {
let mut out = Vec::with_capacity(1 + 9 + value.len());
out.push(tag);
if value.len() < 0x80 {
out.push(value.len() as u8);
} else {
out.push(0x81);
out.push(value.len() as u8);
}
out.extend_from_slice(&value);
out
}
#[test]
fn accumulator_finish_matches_builder_on_fresh_vcir_inputs() {
let td = tempfile::tempdir().expect("tempdir");
@@ -512,4 +485,32 @@ mod tests {
let ci = crate::ccr::model::CcrContentInfo::new(accumulated_ccr);
verify_content_info(&ci).expect("verify accumulated ccr");
}
#[test]
fn accumulator_sanitizes_historical_projection_with_rpki_notify() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let (mut vcir, _, _, _) = sample_vcir_and_manifest(&store);
let notify_der =
crate::ccr::manifest_location::encode_access_description_der(&AccessDescription {
access_method_oid: OID_AD_RPKI_NOTIFY.to_string(),
access_location: "https://rrdp.example.test/notification.xml".to_string(),
})
.expect("encode rpkiNotify");
let expected_location = vcir.ccr_manifest_projection.manifest_sia_locations_der[0].clone();
vcir.ccr_manifest_projection
.manifest_sia_locations_der
.push(notify_der);
let mut accumulator = CcrAccumulator::new(Vec::new());
accumulator
.append_manifest_projection(&vcir.ccr_manifest_projection)
.expect("sanitize historical projection");
let contribution = accumulator
.manifests_by_hash
.values()
.next()
.expect("manifest contribution");
assert_eq!(contribution.locations_der, vec![expected_location]);
}
}

View File

@@ -9,12 +9,13 @@ use crate::ccr::encode::{
encode_trust_anchor_state_payload_der,
};
use crate::ccr::hash::compute_state_hash;
use crate::ccr::manifest_location::select_manifest_signed_object_location;
use crate::ccr::model::{
AspaPayloadSet, AspaPayloadState, ManifestInstance, ManifestState, RoaPayloadSet,
RoaPayloadState, RouterKey, RouterKeySet, RouterKeyState, TrustAnchorState,
};
use crate::data_model::manifest::ManifestObject;
use crate::data_model::rc::{AccessDescription, SubjectInfoAccess};
use crate::data_model::rc::SubjectInfoAccess;
use crate::data_model::roa::RoaAfi;
use crate::data_model::router_cert::BgpsecRouterCertificate;
use crate::data_model::ta::TrustAnchor;
@@ -68,12 +69,17 @@ pub enum CcrBuildError {
#[error("manifest EE certificate SIA is not the EE form: {0}")]
ManifestEeSiaWrongVariant(String),
#[error(
"manifest EE certificate SIA location selection failed for {manifest_rsync_uri}: {detail}"
)]
ManifestSiaLocationSelection {
manifest_rsync_uri: String,
detail: String,
},
#[error("manifest child SKI is not valid lowercase hex or not 20 bytes: {0}")]
InvalidChildSki(String),
#[error("manifest AccessDescription contains unsupported accessMethod OID: {0}")]
UnsupportedAccessMethodOid(String),
#[error("manifest state encoding failed: {0}")]
ManifestEncode(String),
@@ -262,11 +268,16 @@ pub fn build_manifest_state_from_vcirs_with_breakdown(
CcrBuildError::ManifestEeMissingSia(vcir.current_manifest_rsync_uri.clone())
})?;
let locations = match sia {
SubjectInfoAccess::Ee(ee_sia) => ee_sia
.access_descriptions
.iter()
.map(encode_access_description_der)
.collect::<Result<Vec<_>, _>>()?,
SubjectInfoAccess::Ee(ee_sia) => vec![
select_manifest_signed_object_location(
&vcir.current_manifest_rsync_uri,
&ee_sia.access_descriptions,
)
.map_err(|detail| CcrBuildError::ManifestSiaLocationSelection {
manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(),
detail,
})?,
],
SubjectInfoAccess::Ca(_) => {
return Err(CcrBuildError::ManifestEeSiaWrongVariant(
vcir.current_manifest_rsync_uri.clone(),
@@ -353,45 +364,6 @@ fn collect_subordinate_ski_bytes(
Ok(skis.into_iter().collect())
}
fn encode_access_description_der(ad: &AccessDescription) -> Result<Vec<u8>, CcrBuildError> {
let oid = encode_oid_from_string(&ad.access_method_oid)?;
let uri = encode_tlv(0x86, ad.access_location.as_bytes().to_vec());
Ok(encode_sequence(&[oid, uri]))
}
fn encode_oid_from_string(oid: &str) -> Result<Vec<u8>, CcrBuildError> {
let arcs = oid
.split('.')
.map(|part| {
part.parse::<u64>()
.map_err(|_| CcrBuildError::UnsupportedAccessMethodOid(oid.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
if arcs.len() < 2 {
return Err(CcrBuildError::UnsupportedAccessMethodOid(oid.to_string()));
}
if arcs[0] > 2 || (arcs[0] < 2 && arcs[1] >= 40) {
return Err(CcrBuildError::UnsupportedAccessMethodOid(oid.to_string()));
}
let mut body = Vec::new();
body.push((arcs[0] * 40 + arcs[1]) as u8);
for arc in &arcs[2..] {
encode_base128(*arc, &mut body);
}
Ok(encode_tlv(0x06, body))
}
fn encode_base128(mut value: u64, out: &mut Vec<u8>) {
let mut tmp = vec![(value & 0x7F) as u8];
value >>= 7;
while value > 0 {
tmp.push(((value & 0x7F) as u8) | 0x80);
value >>= 7;
}
tmp.reverse();
out.extend_from_slice(&tmp);
}
pub fn build_router_key_state(
router_certs: &[BgpsecRouterCertificate],
) -> Result<RouterKeyState, CcrBuildError> {
@@ -650,17 +622,33 @@ mod tests {
}
}
fn sample_manifest_vcir(
manifest_uri: &str,
manifest_der: &[u8],
child_ski_hex: &str,
) -> ValidatedCaInstanceResult {
fn sample_manifest_vcir(manifest_der: &[u8], child_ski_hex: &str) -> ValidatedCaInstanceResult {
let manifest = ManifestObject::decode_der(manifest_der).expect("decode manifest fixture");
let manifest_uri = match manifest.signed_object.signed_data.certificates[0]
.resource_cert
.tbs
.extensions
.subject_info_access
.as_ref()
.expect("manifest sia")
{
SubjectInfoAccess::Ee(ee_sia) => ee_sia
.access_descriptions
.iter()
.find(|ad| {
ad.access_method_oid == crate::data_model::oid::OID_AD_SIGNED_OBJECT
&& ad.access_location.starts_with("rsync://")
})
.expect("manifest rsync signedObject")
.access_location
.clone(),
SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"),
};
let hash = hex::encode(sha2::Sha256::digest(manifest_der));
let now = manifest.manifest.this_update;
let next = manifest.manifest.next_update;
let projection = crate::storage::VcirCcrManifestProjection {
manifest_rsync_uri: manifest_uri.to_string(),
manifest_rsync_uri: manifest_uri.clone(),
manifest_sha256: sha2::Sha256::digest(manifest_der).to_vec(),
manifest_size: manifest_der.len() as u64,
manifest_ee_aki: manifest.signed_object.signed_data.certificates[0]
@@ -680,18 +668,19 @@ mod tests {
.as_ref()
.expect("manifest sia")
{
SubjectInfoAccess::Ee(ee_sia) => ee_sia
.access_descriptions
.iter()
.map(encode_access_description_der)
.collect::<Result<Vec<_>, _>>()
.expect("encode access descriptions"),
SubjectInfoAccess::Ee(ee_sia) => vec![
select_manifest_signed_object_location(
&manifest_uri,
&ee_sia.access_descriptions,
)
.expect("select manifest signedObject"),
],
SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"),
},
subordinate_skis: vec![hex::decode(child_ski_hex).expect("decode child ski")],
};
crate::storage::ValidatedCaInstanceResult {
manifest_rsync_uri: manifest_uri.to_string(),
manifest_rsync_uri: manifest_uri.clone(),
parent_manifest_rsync_uri: None,
tal_id: "test-tal".to_string(),
ca_subject_name: "CN=test".to_string(),
@@ -700,7 +689,7 @@ mod tests {
last_successful_validation_time: crate::storage::PackTime::from_utc_offset_datetime(
now,
),
current_manifest_rsync_uri: manifest_uri.to_string(),
current_manifest_rsync_uri: manifest_uri.clone(),
current_crl_rsync_uri: format!("{manifest_uri}.crl"),
validated_manifest_meta: crate::storage::ValidatedManifestMeta {
validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(),
@@ -736,10 +725,11 @@ mod tests {
related_artifacts: vec![crate::storage::VcirRelatedArtifact {
artifact_role: crate::storage::VcirArtifactRole::Manifest,
artifact_kind: crate::storage::VcirArtifactKind::Mft,
uri: Some(manifest_uri.to_string()),
uri: Some(manifest_uri.clone()),
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,
@@ -766,10 +756,8 @@ mod tests {
"tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft",
))
.expect("read manifest b");
let vcir_a =
sample_manifest_vcir("rsync://example.test/a.mft", &manifest_a, &"33".repeat(20));
let vcir_b =
sample_manifest_vcir("rsync://example.test/b.mft", &manifest_b, &"44".repeat(20));
let vcir_a = sample_manifest_vcir(&manifest_a, &"33".repeat(20));
let vcir_b = sample_manifest_vcir(&manifest_b, &"44".repeat(20));
let store_dir = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
for (vcir, bytes) in [(&vcir_a, &manifest_a), (&vcir_b, &manifest_b)] {

View File

@@ -471,3 +471,71 @@ fn decode_big_unsigned(bytes: &[u8]) -> Result<BigUnsigned, CcrDecodeError> {
};
Ok(BigUnsigned { bytes_be })
}
#[cfg(all(test, feature = "full"))]
mod tests {
use super::*;
use crate::ccr::encode::{encode_content_info, encode_manifest_state_payload_der};
use crate::ccr::hash::compute_state_hash;
use crate::ccr::manifest_location::encode_access_description_der;
use crate::ccr::model::{
CcrContentInfo, CcrDigestAlgorithm, ManifestState, RpkiCanonicalCacheRepresentation,
};
use crate::data_model::oid::{OID_AD_RPKI_NOTIFY, OID_AD_SIGNED_OBJECT};
use crate::data_model::rc::AccessDescription;
#[test]
fn generic_decoder_preserves_multiple_manifest_locations() {
let signed_object = encode_access_description_der(&AccessDescription {
access_method_oid: OID_AD_SIGNED_OBJECT.to_string(),
access_location: "rsync://example.test/repository/current.mft".to_string(),
})
.expect("encode signedObject");
let rpki_notify = encode_access_description_der(&AccessDescription {
access_method_oid: OID_AD_RPKI_NOTIFY.to_string(),
access_location: "https://rrdp.example.test/notification.xml".to_string(),
})
.expect("encode rpkiNotify");
let manifest = ManifestInstance {
hash: vec![0x11; 32],
size: 1024,
aki: vec![0x22; 20],
manifest_number: BigUnsigned { bytes_be: vec![1] },
this_update: time::OffsetDateTime::parse(
"2026-07-20T00:00:00Z",
&time::format_description::well_known::Rfc3339,
)
.expect("parse time"),
locations: vec![signed_object, rpki_notify],
subordinates: Vec::new(),
};
let manifest_payload = encode_manifest_state_payload_der(&[manifest.clone()])
.expect("encode manifest payload");
let content = CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at: time::OffsetDateTime::parse(
"2026-07-20T00:00:00Z",
&time::format_description::well_known::Rfc3339,
)
.expect("parse time"),
mfts: Some(ManifestState {
mis: vec![manifest],
most_recent_update: time::OffsetDateTime::parse(
"2026-07-20T00:00:00Z",
&time::format_description::well_known::Rfc3339,
)
.expect("parse time"),
hash: compute_state_hash(&manifest_payload),
}),
vrps: None,
vaps: None,
tas: None,
rks: None,
});
let encoded = encode_content_info(&content).expect("encode CCR");
let decoded = decode_content_info(&encoded).expect("decode CCR");
assert_eq!(decoded.content.mfts.unwrap().mis[0].locations.len(), 2);
}
}

View File

@@ -124,6 +124,8 @@ mod tests {
use super::*;
use crate::ccr::decode::decode_content_info;
use crate::data_model::manifest::ManifestObject;
use crate::data_model::oid::OID_AD_SIGNED_OBJECT;
use crate::data_model::rc::SubjectInfoAccess;
use crate::data_model::roa::{IpPrefix, RoaAfi};
use crate::data_model::ta::TrustAnchor;
use crate::data_model::tal::Tal;
@@ -149,15 +151,34 @@ mod tests {
let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let manifest_der = std::fs::read(base.join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft")).expect("read manifest");
let manifest = ManifestObject::decode_der(&manifest_der).expect("decode manifest");
let manifest_uri = match manifest.signed_object.signed_data.certificates[0]
.resource_cert
.tbs
.extensions
.subject_info_access
.as_ref()
.expect("manifest sia")
{
SubjectInfoAccess::Ee(ee_sia) => ee_sia
.access_descriptions
.iter()
.find(|ad| {
ad.access_method_oid == OID_AD_SIGNED_OBJECT
&& ad.access_location.starts_with("rsync://")
})
.expect("manifest rsync signedObject")
.access_location
.clone(),
SubjectInfoAccess::Ca(_) => panic!("manifest EE SIA should not be CA variant"),
};
let hash = hex::encode(sha2::Sha256::digest(&manifest_der));
let mut raw = RawByHashEntry::from_bytes(hash.clone(), manifest_der.clone());
raw.origin_uris
.push("rsync://example.test/repo/current.mft".to_string());
raw.origin_uris.push(manifest_uri.clone());
raw.object_type = Some("mft".to_string());
raw.encoding = Some("der".to_string());
store.put_raw_by_hash_entry(&raw).expect("put raw");
let projection = VcirCcrManifestProjection {
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
manifest_rsync_uri: manifest_uri.clone(),
manifest_sha256: sha2::Sha256::digest(&manifest_der).to_vec(),
manifest_size: manifest_der.len() as u64,
manifest_ee_aki: manifest.signed_object.signed_data.certificates[0]
@@ -169,14 +190,27 @@ mod tests {
.expect("manifest aki"),
manifest_number_be: manifest.manifest.manifest_number.bytes_be.clone(),
manifest_this_update: PackTime::from_utc_offset_datetime(manifest.manifest.this_update),
manifest_sia_locations_der: vec![vec![
0x30, 0x11, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x05, 0x86, 0x05,
b'r', b's', b'y', b'n', b'c',
]],
manifest_sia_locations_der: match manifest.signed_object.signed_data.certificates[0]
.resource_cert
.tbs
.extensions
.subject_info_access
.as_ref()
.expect("manifest sia")
{
SubjectInfoAccess::Ee(ee_sia) => vec![
crate::ccr::manifest_location::select_manifest_signed_object_location(
&manifest_uri,
&ee_sia.access_descriptions,
)
.expect("select manifest signedObject"),
],
SubjectInfoAccess::Ca(_) => panic!("manifest EE SIA should not be CA variant"),
},
subordinate_skis: vec![vec![0x33; 20]],
};
ValidatedCaInstanceResult {
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
manifest_rsync_uri: manifest_uri.clone(),
parent_manifest_rsync_uri: None,
tal_id: "apnic".to_string(),
ca_subject_name: "CN=test".to_string(),
@@ -185,7 +219,7 @@ mod tests {
last_successful_validation_time: PackTime::from_utc_offset_datetime(
manifest.manifest.this_update,
),
current_manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
current_manifest_rsync_uri: manifest_uri.clone(),
current_crl_rsync_uri: "rsync://example.test/repo/current.crl".to_string(),
validated_manifest_meta: ValidatedManifestMeta {
validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(),
@@ -229,10 +263,11 @@ mod tests {
related_artifacts: vec![VcirRelatedArtifact {
artifact_role: VcirArtifactRole::Manifest,
artifact_kind: VcirArtifactKind::Mft,
uri: Some("rsync://example.test/repo/current.mft".to_string()),
uri: Some(manifest_uri),
sha256: hash,
object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted,
reject_reason: None,
}],
summary: VcirSummary {
local_vrp_count: 0,

View File

@@ -0,0 +1,424 @@
use crate::data_model::common::DerReader;
use crate::data_model::oid::OID_AD_SIGNED_OBJECT;
use crate::data_model::rc::AccessDescription;
pub(crate) fn select_manifest_signed_object_location(
manifest_rsync_uri: &str,
access_descriptions: &[AccessDescription],
) -> Result<Vec<u8>, String> {
let matching = access_descriptions
.iter()
.filter(|access_description| {
access_description.access_method_oid == OID_AD_SIGNED_OBJECT
&& access_description.access_location == manifest_rsync_uri
})
.collect::<Vec<_>>();
let access_description = expect_single_matching_location(
manifest_rsync_uri,
matching.len(),
"parsed Manifest EE SIA",
)?;
encode_access_description_der(matching[access_description])
}
pub(crate) fn select_manifest_signed_object_location_from_der(
manifest_rsync_uri: &str,
locations_der: &[Vec<u8>],
) -> Result<Vec<u8>, String> {
let matching = locations_der
.iter()
.filter_map(
|location_der| match decode_access_description_der(location_der) {
Ok(access_description)
if access_description.access_method_oid == OID_AD_SIGNED_OBJECT
&& access_description.access_location == manifest_rsync_uri =>
{
Some(Ok(location_der.clone()))
}
Ok(_) => None,
Err(detail) => Some(Err(detail)),
},
)
.collect::<Result<Vec<_>, _>>()?;
let index = expect_single_matching_location(
manifest_rsync_uri,
matching.len(),
"persisted VCIR CCR projection",
)?;
Ok(matching[index].clone())
}
pub(crate) fn encode_access_description_der(
access_description: &AccessDescription,
) -> Result<Vec<u8>, String> {
let oid = encode_oid_der(&access_description.access_method_oid)?;
let uri = encode_tlv(0x86, access_description.access_location.as_bytes().to_vec());
Ok(encode_sequence(&[oid, uri]))
}
fn expect_single_matching_location(
manifest_rsync_uri: &str,
matching_count: usize,
source: &str,
) -> Result<usize, String> {
if matching_count == 1 {
return Ok(0);
}
Err(format!(
"{source} contains {matching_count} id-ad-signedObject locations matching manifest URI {manifest_rsync_uri}; expected exactly one"
))
}
fn decode_access_description_der(der: &[u8]) -> Result<AccessDescription, String> {
let mut top = DerReader::new(der);
let mut sequence = top.take_sequence()?;
if !top.is_empty() {
return Err("trailing bytes after AccessDescription".to_string());
}
let access_method_oid = decode_oid_der(sequence.take_tag(0x06)?)?;
let access_location = std::str::from_utf8(sequence.take_tag(0x86)?)
.map_err(|error| format!("AccessDescription URI is not UTF-8: {error}"))?
.to_string();
if !sequence.is_empty() {
return Err("trailing fields in AccessDescription".to_string());
}
Ok(AccessDescription {
access_method_oid,
access_location,
})
}
fn decode_oid_der(value: &[u8]) -> Result<String, String> {
let mut offset = 0usize;
let first = decode_base128(value, &mut offset)?;
let (first_arc, second_arc) = match first {
0..=39 => (0, first),
40..=79 => (1, first - 40),
value => (2, value - 80),
};
let mut arcs = vec![first_arc, second_arc];
while offset < value.len() {
arcs.push(decode_base128(value, &mut offset)?);
}
Ok(arcs
.into_iter()
.map(|arc| arc.to_string())
.collect::<Vec<_>>()
.join("."))
}
fn decode_base128(value: &[u8], offset: &mut usize) -> Result<u64, String> {
let first = *value
.get(*offset)
.ok_or_else(|| "truncated OBJECT IDENTIFIER".to_string())?;
if first == 0x80 {
return Err("non-minimal OBJECT IDENTIFIER base-128 encoding".to_string());
}
let mut out = 0u64;
loop {
let byte = *value
.get(*offset)
.ok_or_else(|| "truncated OBJECT IDENTIFIER".to_string())?;
*offset += 1;
out = out
.checked_shl(7)
.ok_or_else(|| "OBJECT IDENTIFIER arc overflows u64".to_string())?
.checked_add((byte & 0x7f) as u64)
.ok_or_else(|| "OBJECT IDENTIFIER arc overflows u64".to_string())?;
if byte & 0x80 == 0 {
return Ok(out);
}
}
}
fn encode_oid_der(oid: &str) -> Result<Vec<u8>, String> {
let arcs = oid
.split('.')
.map(|part| {
part.parse::<u64>()
.map_err(|_| format!("unsupported accessMethod OID: {oid}"))
})
.collect::<Result<Vec<_>, _>>()?;
if arcs.len() < 2 || arcs[0] > 2 || (arcs[0] < 2 && arcs[1] >= 40) {
return Err(format!("unsupported accessMethod OID: {oid}"));
}
let mut body = Vec::new();
encode_base128(
arcs[0]
.checked_mul(40)
.and_then(|value| value.checked_add(arcs[1]))
.ok_or_else(|| format!("unsupported accessMethod OID: {oid}"))?,
&mut body,
);
for arc in &arcs[2..] {
encode_base128(*arc, &mut body);
}
Ok(encode_tlv(0x06, body))
}
fn encode_base128(mut value: u64, out: &mut Vec<u8>) {
let mut encoded = vec![(value & 0x7f) as u8];
value >>= 7;
while value > 0 {
encoded.push(((value & 0x7f) as u8) | 0x80);
value >>= 7;
}
encoded.reverse();
out.extend_from_slice(&encoded);
}
fn encode_sequence(elements: &[Vec<u8>]) -> Vec<u8> {
let total_len = elements.iter().map(Vec::len).sum();
let mut value = Vec::with_capacity(total_len);
for element in elements {
value.extend_from_slice(element);
}
encode_tlv(0x30, value)
}
fn encode_tlv(tag: u8, value: Vec<u8>) -> Vec<u8> {
let mut out = Vec::with_capacity(1 + 9 + value.len());
out.push(tag);
encode_length(value.len(), &mut out);
out.extend_from_slice(&value);
out
}
fn encode_length(len: usize, out: &mut Vec<u8>) {
if len < 0x80 {
out.push(len as u8);
return;
}
let mut bytes = Vec::new();
let mut value = len;
while value > 0 {
bytes.push((value & 0xff) as u8);
value >>= 8;
}
bytes.reverse();
out.push(0x80 | bytes.len() as u8);
out.extend_from_slice(&bytes);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_model::oid::OID_AD_RPKI_NOTIFY;
const MANIFEST_URI: &str = "rsync://example.test/repo/manifest.mft";
fn access_description(access_method_oid: &str, access_location: &str) -> AccessDescription {
AccessDescription {
access_method_oid: access_method_oid.to_string(),
access_location: access_location.to_string(),
}
}
#[test]
fn selects_matching_signed_object_and_excludes_rpki_notify() {
let signed_object = access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI);
let selected = select_manifest_signed_object_location(
MANIFEST_URI,
&[
signed_object.clone(),
access_description(
OID_AD_RPKI_NOTIFY,
"https://rrdp.example.test/notification.xml",
),
],
)
.expect("select signed object");
assert_eq!(
selected,
encode_access_description_der(&signed_object).unwrap()
);
}
#[test]
fn selects_only_the_signed_object_matching_manifest_uri() {
let expected = access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI);
let selected = select_manifest_signed_object_location(
MANIFEST_URI,
&[
access_description(
OID_AD_SIGNED_OBJECT,
"https://backup.example.test/manifest.mft",
),
expected.clone(),
],
)
.expect("select matching signed object");
assert_eq!(selected, encode_access_description_der(&expected).unwrap());
}
#[test]
fn rejects_missing_or_duplicate_matching_signed_object() {
let missing = select_manifest_signed_object_location(
MANIFEST_URI,
&[access_description(
OID_AD_RPKI_NOTIFY,
"https://rrdp.example.test/notification.xml",
)],
)
.expect_err("missing signed object must fail");
assert!(missing.contains("contains 0"), "{missing}");
let duplicate = select_manifest_signed_object_location(
MANIFEST_URI,
&[
access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI),
access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI),
],
)
.expect_err("duplicate signed object must fail");
assert!(duplicate.contains("contains 2"), "{duplicate}");
}
#[test]
fn selects_matching_signed_object_from_historical_projection() {
let signed_object = access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI);
let signed_object_der = encode_access_description_der(&signed_object).unwrap();
let notify_der = encode_access_description_der(&access_description(
OID_AD_RPKI_NOTIFY,
"https://rrdp.example.test/notification.xml",
))
.unwrap();
let selected = select_manifest_signed_object_location_from_der(
MANIFEST_URI,
&[notify_der, signed_object_der.clone()],
)
.expect("select historical signed object");
assert_eq!(selected, signed_object_der);
}
#[test]
fn rejects_malformed_historical_projection() {
let error = select_manifest_signed_object_location_from_der(
MANIFEST_URI,
&[vec![0x30, 0x01, 0x06]],
)
.expect_err("malformed historical projection must fail");
assert!(error.contains("truncated DER"), "{error}");
}
#[test]
fn access_description_der_codec_covers_long_and_invalid_forms() {
let long = access_description(
"2.999.200.1",
&format!("rsync://example.test/repo/{}", "x".repeat(160)),
);
let encoded = encode_access_description_der(&long).expect("encode long access description");
assert_eq!(
decode_access_description_der(&encoded).expect("decode long access description"),
long
);
let mut trailing = encoded.clone();
trailing.push(0);
assert!(
decode_access_description_der(&trailing)
.expect_err("trailing bytes must fail")
.contains("trailing bytes")
);
let oid = encode_oid_der(OID_AD_SIGNED_OBJECT).expect("encode signedObject OID");
let uri = encode_tlv(0x86, MANIFEST_URI.as_bytes().to_vec());
assert!(
decode_access_description_der(&encode_sequence(&[
oid.clone(),
uri.clone(),
encode_tlv(0x05, Vec::new()),
]))
.expect_err("trailing field must fail")
.contains("trailing fields")
);
assert!(
decode_access_description_der(&encode_sequence(&[oid, encode_tlv(0x86, vec![0xff]),]))
.expect_err("non-UTF8 URI must fail")
.contains("not UTF-8")
);
assert!(
decode_access_description_der(&encode_sequence(&[
encode_tlv(0x06, vec![0x80, 0x00]),
uri.clone(),
]))
.expect_err("non-minimal OID must fail")
.contains("non-minimal")
);
assert!(
decode_access_description_der(&encode_sequence(&[encode_tlv(0x06, vec![0x81]), uri,]))
.expect_err("truncated OID must fail")
.contains("truncated OBJECT IDENTIFIER")
);
assert!(
encode_access_description_der(&access_description("3.1", MANIFEST_URI))
.expect_err("invalid OID must fail")
.contains("unsupported accessMethod OID")
);
}
#[test]
fn access_description_der_codec_reports_malformed_selector_inputs() {
let malformed_sequence = decode_access_description_der(&[0x31, 0x00])
.expect_err("non-sequence AccessDescription must fail");
assert!(!malformed_sequence.is_empty());
let missing_method = decode_access_description_der(&encode_sequence(&[encode_tlv(
0x86,
MANIFEST_URI.as_bytes().to_vec(),
)]))
.expect_err("missing accessMethod must fail");
assert!(!missing_method.is_empty());
let oid = encode_oid_der(OID_AD_SIGNED_OBJECT).expect("encode signedObject OID");
let missing_location = decode_access_description_der(&encode_sequence(&[oid.clone()]))
.expect_err("missing accessLocation must fail");
assert!(!missing_location.is_empty());
let wrong_location_tag = decode_access_description_der(&encode_sequence(&[
oid.clone(),
encode_tlv(0x04, MANIFEST_URI.as_bytes().to_vec()),
]))
.expect_err("wrong accessLocation tag must fail");
assert!(!wrong_location_tag.is_empty());
let empty_oid = decode_access_description_der(&encode_sequence(&[
encode_tlv(0x06, Vec::new()),
encode_tlv(0x86, MANIFEST_URI.as_bytes().to_vec()),
]))
.expect_err("empty OID must fail");
assert!(!empty_oid.is_empty());
let first_arc_zero = decode_access_description_der(&encode_sequence(&[
encode_tlv(0x06, vec![0x01, 0x02]),
encode_tlv(0x86, MANIFEST_URI.as_bytes().to_vec()),
]))
.expect("decode first OID arc zero");
assert_eq!(first_arc_zero.access_method_oid, "0.1.2");
assert_eq!(first_arc_zero.access_location, MANIFEST_URI);
let non_numeric =
encode_access_description_der(&access_description("no.such.oid", MANIFEST_URI))
.expect_err("non-numeric OID must fail");
assert!(!non_numeric.is_empty());
let too_short = encode_access_description_der(&access_description("1", MANIFEST_URI))
.expect_err("OID without second arc must fail");
assert!(!too_short.is_empty());
let invalid_second_arc =
encode_access_description_der(&access_description("1.40", MANIFEST_URI))
.expect_err("invalid second OID arc must fail");
assert!(!invalid_second_arc.is_empty());
let overflowing_first_subidentifier = encode_access_description_der(&access_description(
"2.18446744073709551615",
MANIFEST_URI,
))
.expect_err("overflowing first OID subidentifier must fail");
assert!(!overflowing_first_subidentifier.is_empty());
}
}

View File

@@ -10,6 +10,8 @@ pub mod encode;
#[cfg(feature = "full")]
pub mod export;
pub mod hash;
#[cfg(feature = "full")]
pub(crate) mod manifest_location;
pub mod model;
pub mod state_digest;
#[cfg(feature = "full")]

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())
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
use super::*;
use crate::data_model::oid::OID_AD_SIGNED_OBJECT;
use crate::data_model::rc::AccessDescription;
use crate::memory_telemetry::{
MemoryTelemetryCheckpoint, MemoryTelemetrySummary, ProcessMemorySnapshot,
};
@@ -21,6 +23,9 @@ 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!(
err.contains("--publication-point-cache-observe-only"),
"{err}"
@@ -33,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![
@@ -47,6 +72,131 @@ fn parse_rejects_unknown_argument() {
assert!(err.contains("unknown argument"), "{err}");
}
#[test]
fn parse_accepts_verification_only_without_manual_validation_time() {
let argv = vec![
"rpki".to_string(),
"--verification-only".to_string(),
"--verification-source-run".to_string(),
"/run/run_0002".to_string(),
"--verification-source-state".to_string(),
"/run/state".to_string(),
"--verification-out".to_string(),
"/verification/check_0002".to_string(),
];
let args = parse_args(&argv).expect("parse verification-only args");
assert!(args.verification_only);
assert!(args.validation_time.is_none());
assert_eq!(
args.db_path,
PathBuf::from("/verification/check_0002/scratch/work-db")
);
}
#[test]
fn parse_rejects_manual_validation_time_in_verification_only() {
let argv = vec![
"rpki".to_string(),
"--verification-only".to_string(),
"--verification-source-run".to_string(),
"/run/run_0002".to_string(),
"--verification-source-state".to_string(),
"/run/state".to_string(),
"--verification-out".to_string(),
"/verification/check_0002".to_string(),
"--validation-time".to_string(),
"2026-07-16T00:00:00Z".to_string(),
];
let error = parse_args(&argv).expect_err("manual time must be rejected");
assert!(error.contains("validation time"), "{error}");
}
#[test]
fn parse_accepts_normal_validation_contract_output() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/root.tal".to_string(),
"--validation-contract-out".to_string(),
"out/validation-contract.json".to_string(),
];
let args = parse_args(&argv).expect("parse normal contract output");
assert_eq!(
args.validation_contract_out_path,
Some(PathBuf::from("out/validation-contract.json"))
);
}
#[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![
@@ -80,6 +230,51 @@ fn parse_rejects_invalid_max_depth() {
assert!(err.contains("invalid --max-depth"), "{err}");
}
#[test]
fn parse_rejects_invalid_max_ca_depth() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--max-ca-depth".to_string(),
"nope".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("invalid --max-ca-depth"), "{err}");
}
#[test]
fn parse_rejects_both_ca_depth_options() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--max-ca-depth".to_string(),
"32".to_string(),
"--max-depth".to_string(),
"12".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("cannot be combined"), "{err}");
}
#[test]
fn parse_defaults_max_ca_depth_to_32() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(args.max_ca_depth, 32);
}
#[test]
fn parse_accepts_ccr_out_path() {
let argv = vec![
@@ -183,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![
@@ -1103,7 +1348,7 @@ fn parse_accepts_offline_mode_requires_ta() {
assert_eq!(args.ta_paths, vec![PathBuf::from("ta.cer")]);
assert_eq!(args.tal_path.as_deref(), Some(Path::new("a.tal")));
assert_eq!(args.ta_path.as_deref(), Some(Path::new("ta.cer")));
assert_eq!(args.max_depth, Some(0));
assert_eq!(args.max_ca_depth, 0);
}
#[test]
@@ -1618,8 +1863,9 @@ fn sample_cli_ccr_accumulator() -> CcrAccumulator {
)
.expect("discover root");
let mut accumulator = CcrAccumulator::new(vec![discovery.trust_anchor.clone()]);
let manifest_uri = "rsync://example.test/repo/current.mft".to_string();
let projection = crate::storage::VcirCcrManifestProjection {
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
manifest_rsync_uri: manifest_uri.clone(),
manifest_sha256: vec![0x44; 32],
manifest_size: 2048,
manifest_ee_aki: vec![0x55; 20],
@@ -1627,10 +1873,13 @@ fn sample_cli_ccr_accumulator() -> CcrAccumulator {
manifest_this_update: crate::storage::PackTime::from_utc_offset_datetime(
time::OffsetDateTime::now_utc(),
),
manifest_sia_locations_der: vec![vec![
0x30, 0x11, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x05, 0x86, 0x05,
b'r', b's', b'y', b'n', b'c',
]],
manifest_sia_locations_der: vec![
crate::ccr::manifest_location::encode_access_description_der(&AccessDescription {
access_method_oid: OID_AD_SIGNED_OBJECT.to_string(),
access_location: manifest_uri,
})
.expect("encode signedObject"),
],
subordinate_skis: vec![vec![0x33; 20]],
};
accumulator
@@ -1697,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,
@@ -1803,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

@@ -0,0 +1,234 @@
use std::sync::Arc;
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>, 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>)>> {
// 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)
.map_err(|error| {
RsyncFetchError::Fetch(format!(
"list frozen repository view failed for {base}: {error}"
))
})?;
let mut objects = Vec::with_capacity(entries.len());
for entry in entries {
if !matches!(
entry.state,
RepositoryViewState::Present | RepositoryViewState::Replaced
) {
continue;
}
let bytes = self
.store
.load_current_object_bytes_by_uri(&entry.rsync_uri)
.map_err(|error| {
RsyncFetchError::Fetch(format!(
"load frozen repository object failed for {}: {error}",
entry.rsync_uri
))
})?
.ok_or_else(|| {
RsyncFetchError::Fetch(format!(
"frozen repository object missing for {}",
entry.rsync_uri
))
})?;
objects.push((entry.rsync_uri, bytes));
}
objects.sort_by(|left, right| left.0.cmp(&right.0));
if objects.is_empty() {
return Err(RsyncFetchError::Fetch(format!(
"frozen repository view contains no current objects under {base}"
)));
}
Ok(objects)
}
fn fetch_object(&self, rsync_uri: &str) -> RsyncFetchResult<Vec<u8>> {
self.store
.load_current_object_bytes_by_uri(rsync_uri)
.map_err(|error| {
RsyncFetchError::Fetch(format!(
"load frozen repository object failed for {rsync_uri}: {error}"
))
})?
.ok_or_else(|| {
RsyncFetchError::Fetch(format!("frozen repository object not found: {rsync_uri}"))
})
}
fn dedup_key(&self, rsync_base_uri: &str) -> String {
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::{RepositoryViewEntry, RocksStore};
#[test]
fn current_repository_fetcher_returns_sorted_present_objects() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Arc::new(RocksStore::open(dir.path()).expect("open store"));
let entries = [
("rsync://example.test/repo/b.roa", b"b".as_slice()),
("rsync://example.test/repo/a.mft", b"a".as_slice()),
];
for (uri, bytes) in entries {
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("roa".to_string()),
state: RepositoryViewState::Present,
current_hash: Some(hash),
})
.expect("put view");
}
let fetcher = CurrentRepositoryViewRsyncFetcher::new(store, RsyncScopePolicy::default());
let objects = fetcher
.fetch_objects("rsync://example.test/repo/")
.expect("fetch objects");
assert_eq!(
objects
.iter()
.map(|(uri, _)| uri.as_str())
.collect::<Vec<_>>(),
vec![
"rsync://example.test/repo/a.mft",
"rsync://example.test/repo/b.roa"
]
);
assert_eq!(
fetcher
.fetch_object("rsync://example.test/repo/a.mft")
.expect("fetch one"),
b"a"
);
assert!(
fetcher
.fetch_object("rsync://example.test/repo/missing.roa")
.is_err()
);
assert_eq!(
fetcher.dedup_key("rsync://example.test/repo"),
"rsync://example.test/repo/"
);
assert!(
fetcher
.fetch_objects("rsync://empty.example/repo/")
.unwrap_err()
.to_string()
.contains("no current objects")
);
}
#[test]
fn current_repository_fetcher_ignores_withdrawn_and_reports_missing_blob() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Arc::new(RocksStore::open(dir.path()).expect("open store"));
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/withdrawn.roa".to_string(),
repository_source: Some("fixture".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Withdrawn,
current_hash: None,
})
.expect("put withdrawn view");
let missing_hash = "ab".repeat(32);
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/missing.roa".to_string(),
repository_source: Some("fixture".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
current_hash: Some(missing_hash),
})
.expect("put missing view");
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

@@ -1,3 +1,4 @@
pub mod current_repository;
pub mod http;
pub mod rsync;
pub mod rsync_system;

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,10 @@ 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;
#[cfg(feature = "full")]
pub mod verification_only;

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

@@ -1,3 +1,4 @@
pub mod artifact_manifest;
pub mod object_resolver;
pub mod report_stream;
pub mod vrp;

View File

@@ -9,6 +9,7 @@ use serde_json::value::RawValue;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use crate::query::vrp::VrpRecord;
use crate::query_db::{
ObjectInstanceRecord, PublicationPointRecord, QUERY_DB_SCHEMA_VERSION, QueryDbError,
QueryDbResult, QueryPage, RepositoryRecord, StatsRecord,
@@ -23,6 +24,7 @@ pub struct ReportSummary {
pub query_audit: Option<Value>,
pub download_stats: Option<Value>,
pub vrps_count: u64,
pub vrps: Vec<VrpRecord>,
pub aspas_count: u64,
pub warnings_count: u64,
pub objects_count: u64,
@@ -50,6 +52,68 @@ pub enum ObjectLookup {
Sha256(String),
}
/// Optional predicates applied while streaming report objects. `reason` and
/// `query` must be pre-lowercased by the caller; they are matched as
/// case-insensitive substrings. The other fields are exact matches.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ObjectFilter {
pub object_type: Option<String>,
pub result: Option<String>,
pub rejected: Option<bool>,
pub reason: Option<String>,
pub query: Option<String>,
pub repo_id: Option<String>,
pub pp_id: Option<String>,
pub issues_only: bool,
}
impl ObjectFilter {
pub fn matches(&self, object: &ObjectInstanceRecord) -> bool {
if self.issues_only && !(object.rejected || object.result == "error") {
return false;
}
if let Some(rejected) = self.rejected
&& object.rejected != rejected
{
return false;
}
if let Some(result) = self.result.as_deref()
&& !object.result.eq_ignore_ascii_case(result)
{
return false;
}
if let Some(object_type) = self.object_type.as_deref()
&& !object.object_type.eq_ignore_ascii_case(object_type)
{
return false;
}
if let Some(repo_id) = self.repo_id.as_deref()
&& object.repo_id != repo_id
{
return false;
}
if let Some(pp_id) = self.pp_id.as_deref()
&& object.pp_id != pp_id
{
return false;
}
if let Some(reason) = self.reason.as_deref() {
let Some(reject_reason) = object.reject_reason.as_deref() else {
return false;
};
if !reject_reason.to_lowercase().contains(reason) {
return false;
}
}
if let Some(query) = self.query.as_deref()
&& !object.uri.to_lowercase().contains(query)
{
return false;
}
true
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectResolutionMeta {
@@ -81,16 +145,45 @@ pub fn list_report_objects(
scope: ObjectScope,
limit: usize,
cursor: Option<&str>,
) -> QueryDbResult<QueryPage<ObjectInstanceRecord>> {
list_report_objects_filtered(
report_path,
run_id,
scope,
&ObjectFilter::default(),
limit,
cursor,
)
}
pub fn list_report_objects_filtered(
report_path: &Path,
run_id: &str,
scope: ObjectScope,
filter: &ObjectFilter,
limit: usize,
cursor: Option<&str>,
) -> QueryDbResult<QueryPage<ObjectInstanceRecord>> {
let (start_pp_index, start_object_index) = parse_object_cursor(cursor)?;
let file = File::open(report_path)?;
let reader = BufReader::new(file);
let mut deserializer = serde_json::Deserializer::from_reader(reader);
let mut state =
ObjectScanState::new_list(run_id, scope, limit, start_pp_index, start_object_index);
ObjectScanSeed { state: &mut state }
.deserialize(&mut deserializer)
.map_err(QueryDbError::from)?;
let mut state = ObjectScanState::new_list(
run_id,
scope,
filter.clone(),
limit,
start_pp_index,
start_object_index,
);
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,
@@ -127,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));
@@ -201,7 +304,13 @@ impl<'de> Visitor<'de> for ReportSummaryVisitor<'_> {
out.repos = pps.repos;
out.stats = pps.stats;
}
"vrps" => out.vrps_count = map.next_value::<CountSeq>()?.0,
"vrps" => {
let vrps = map.next_value_seed(VrpSequenceSeed {
run_id: self.run_id,
})?;
out.vrps_count = vrps.count;
out.vrps = vrps.records;
}
"aspas" => out.aspas_count = map.next_value::<CountSeq>()?.0,
"download_stats" => out.download_stats = Some(map.next_value()?),
"queryAudit" => out.query_audit = Some(map.next_value()?),
@@ -239,6 +348,62 @@ struct PublicationPointsSummary {
objects_count: u64,
}
#[derive(Deserialize)]
struct RawVrpRecord {
asn: u32,
prefix: String,
max_length: u8,
}
struct VrpSequenceSeed<'a> {
run_id: &'a str,
}
struct VrpSequence {
count: u64,
records: Vec<VrpRecord>,
}
impl<'de> DeserializeSeed<'de> for VrpSequenceSeed<'_> {
type Value = VrpSequence;
fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_seq(VrpSequenceVisitor {
run_id: self.run_id,
})
}
}
struct VrpSequenceVisitor<'a> {
run_id: &'a str,
}
impl<'de> Visitor<'de> for VrpSequenceVisitor<'_> {
type Value = VrpSequence;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("VRP array")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut count = 0u64;
let mut records = Vec::new();
while let Some(raw) = seq.next_element::<RawVrpRecord>()? {
let record = VrpRecord::from_report(self.run_id, raw.asn, &raw.prefix, raw.max_length)
.map_err(de::Error::custom)?;
count += 1;
records.push(record);
}
Ok(VrpSequence { count, records })
}
}
struct PublicationPointsSummaryVisitor<'a> {
run_id: &'a str,
}
@@ -448,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))
}
@@ -530,6 +695,7 @@ struct ObjectScanState<'a> {
run_id: &'a str,
scope: ObjectScope,
mode: ObjectScanMode,
filter: ObjectFilter,
limit: usize,
start_pp_index: u64,
start_object_index: u64,
@@ -544,6 +710,7 @@ impl<'a> ObjectScanState<'a> {
fn new_list(
run_id: &'a str,
scope: ObjectScope,
filter: ObjectFilter,
limit: usize,
start_pp_index: u64,
start_object_index: u64,
@@ -552,6 +719,7 @@ impl<'a> ObjectScanState<'a> {
run_id,
scope,
mode: ObjectScanMode::List,
filter,
limit,
start_pp_index,
start_object_index,
@@ -568,6 +736,7 @@ impl<'a> ObjectScanState<'a> {
run_id,
scope,
mode: ObjectScanMode::Lookup(lookup),
filter: ObjectFilter::default(),
limit: 1,
start_pp_index: 0,
start_object_index: 0,
@@ -578,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)]
@@ -653,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;
@@ -668,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) {
@@ -682,7 +880,7 @@ fn process_raw_publication_point(
let after_cursor = pp_index > state.start_pp_index
|| (pp_index == state.start_pp_index
&& object_index >= state.start_object_index);
if after_cursor && state.data.len() < state.limit {
if after_cursor && state.data.len() < state.limit && state.filter.matches(&record) {
state.data.push(record);
if state.data.len() == state.limit {
state.next_cursor = Some(object_cursor(pp_index, object_index + 1));
@@ -755,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,
@@ -1153,11 +1375,14 @@ 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"}
]
}],
"vrps":[{},{}],
"vrps":[
{"asn":64496,"prefix":"192.0.2.0/24","max_length":24},
{"asn":64497,"prefix":"2001:db8::/32","max_length":48}
],
"aspas":[{}],
"download_stats":{"eventsTotal":0},
"queryAudit":{"status":"complete","eventsPath":"validation-events.jsonl"}
@@ -1176,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");
@@ -1203,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);
}
}

275
src/query/vrp.rs Normal file
View File

@@ -0,0 +1,275 @@
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VrpRecord {
pub run_id: String,
pub asn: u32,
pub prefix: String,
pub max_length: u8,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IpPrefix {
addr: IpAddr,
prefix_length: u8,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VrpLookup {
Ip(IpAddr),
Prefix(IpPrefix),
}
impl VrpRecord {
pub fn from_report(
run_id: impl Into<String>,
asn: u32,
prefix: &str,
max_length: u8,
) -> Result<Self, String> {
let network = IpPrefix::parse(prefix)?;
if max_length < network.prefix_length {
return Err(format!(
"VRP max_length {max_length} is shorter than prefix length {}",
network.prefix_length
));
}
if max_length > network.max_prefix_length() {
return Err(format!(
"VRP max_length {max_length} exceeds address family limit {}",
network.max_prefix_length()
));
}
Ok(Self {
run_id: run_id.into(),
asn,
prefix: network.to_cidr(),
max_length,
})
}
pub fn prefix_data(&self) -> Result<IpPrefix, String> {
IpPrefix::parse(&self.prefix)
}
pub fn index_key(&self) -> Result<String, String> {
let prefix = self.prefix_data()?;
Ok(format!(
"vrp/{}/{}/{:03}/{}/{:010}/{:03}",
self.run_id,
prefix.family(),
prefix.prefix_length,
prefix.network_hex(),
self.asn,
self.max_length
))
}
}
impl IpPrefix {
pub fn parse(value: &str) -> Result<Self, String> {
let (addr, raw_length) = value
.rsplit_once('/')
.ok_or_else(|| format!("invalid CIDR prefix: {value}"))?;
let ip = addr
.parse::<IpAddr>()
.map_err(|_| format!("invalid IP address in prefix: {value}"))?;
let prefix_length = raw_length
.parse::<u8>()
.map_err(|_| format!("invalid prefix length in: {value}"))?;
Self::new(ip, prefix_length)
}
pub fn new(addr: IpAddr, prefix_length: u8) -> Result<Self, String> {
if prefix_length > max_prefix_length(addr) {
return Err(format!(
"prefix length {prefix_length} exceeds address family limit {}",
max_prefix_length(addr)
));
}
Ok(Self {
addr: network_address(addr, prefix_length),
prefix_length,
})
}
pub fn prefix_length(&self) -> u8 {
self.prefix_length
}
pub fn max_prefix_length(&self) -> u8 {
max_prefix_length(self.addr)
}
pub fn family(&self) -> &'static str {
match self.addr {
IpAddr::V4(_) => "v4",
IpAddr::V6(_) => "v6",
}
}
pub fn network_hex(&self) -> String {
match self.addr {
IpAddr::V4(addr) => hex::encode(addr.octets()),
IpAddr::V6(addr) => hex::encode(addr.octets()),
}
}
pub fn to_cidr(&self) -> String {
format!("{}/{}", self.addr, self.prefix_length)
}
pub fn contains_ip(&self, ip: IpAddr) -> bool {
if std::mem::discriminant(&self.addr) != std::mem::discriminant(&ip) {
return false;
}
network_address(ip, self.prefix_length) == self.addr
}
pub fn contains_prefix(&self, other: &Self) -> bool {
self.family() == other.family()
&& self.prefix_length <= other.prefix_length
&& self.contains_ip(other.addr)
}
pub fn ancestor_prefixes(&self) -> Vec<Self> {
(0..=self.prefix_length)
.filter_map(|length| Self::new(self.addr, length).ok())
.collect()
}
}
impl VrpLookup {
pub fn parse_ip(value: &str) -> Result<Self, String> {
value
.parse::<IpAddr>()
.map(VrpLookup::Ip)
.map_err(|_| format!("invalid IP address: {value}"))
}
pub fn parse_prefix(value: &str) -> Result<Self, String> {
IpPrefix::parse(value).map(VrpLookup::Prefix)
}
pub fn candidate_prefixes(&self) -> Vec<IpPrefix> {
match self {
VrpLookup::Ip(ip) => (0..=max_prefix_length(*ip))
.filter_map(|length| IpPrefix::new(*ip, length).ok())
.collect(),
VrpLookup::Prefix(prefix) => prefix.ancestor_prefixes(),
}
}
pub fn matches(&self, record: &VrpRecord) -> Result<bool, String> {
let record_prefix = record.prefix_data()?;
Ok(match self {
VrpLookup::Ip(ip) => record_prefix.contains_ip(*ip),
VrpLookup::Prefix(prefix) => {
record_prefix.contains_prefix(prefix) && record.max_length >= prefix.prefix_length
}
})
}
}
pub fn vrp_key_prefix(run_id: &str, prefix: &IpPrefix) -> String {
format!(
"vrp/{}/{}/{:03}/{}/",
run_id,
prefix.family(),
prefix.prefix_length,
prefix.network_hex()
)
}
pub fn parse_cursor(cursor: Option<&str>) -> Result<usize, String> {
let Some(cursor) = cursor else {
return Ok(0);
};
let offset = cursor
.strip_prefix("vrp:")
.ok_or_else(|| format!("invalid VRP cursor: {cursor}"))?
.parse::<usize>()
.map_err(|_| format!("invalid VRP cursor: {cursor}"))?;
Ok(offset)
}
pub fn next_cursor(offset: usize, limit: usize, total: usize) -> Option<String> {
let next = offset.saturating_add(limit);
(next < total).then(|| format!("vrp:{next}"))
}
fn max_prefix_length(addr: IpAddr) -> u8 {
match addr {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
}
}
fn network_address(addr: IpAddr, prefix_length: u8) -> IpAddr {
match addr {
IpAddr::V4(addr) => {
let value = u32::from(addr);
let mask = if prefix_length == 0 {
0
} else {
u32::MAX << (32 - prefix_length)
};
IpAddr::V4(Ipv4Addr::from(value & mask))
}
IpAddr::V6(addr) => {
let mut octets = addr.octets();
let full_bytes = usize::from(prefix_length / 8);
let remaining_bits = prefix_length % 8;
if full_bytes < octets.len() {
if remaining_bits > 0 {
octets[full_bytes] &= 0xff << (8 - remaining_bits);
}
let first_zero = full_bytes + usize::from(remaining_bits > 0);
for byte in &mut octets[first_zero..] {
*byte = 0;
}
}
IpAddr::V6(Ipv6Addr::from(octets))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_ipv4_and_ipv6_networks() {
assert_eq!(
IpPrefix::parse("192.0.2.129/24").unwrap().to_cidr(),
"192.0.2.0/24"
);
assert_eq!(
IpPrefix::parse("2001:db8:1::1234/48").unwrap().to_cidr(),
"2001:db8:1::/48"
);
}
#[test]
fn prefix_lookup_uses_covering_and_max_length_semantics() {
let lookup = VrpLookup::parse_prefix("192.0.2.0/24").unwrap();
let covering = VrpRecord::from_report("run_1", 64496, "192.0.0.0/16", 24).unwrap();
let too_short = VrpRecord::from_report("run_1", 64497, "192.0.0.0/16", 23).unwrap();
let intersecting = VrpRecord::from_report("run_1", 64498, "192.0.2.128/25", 25).unwrap();
assert!(lookup.matches(&covering).unwrap());
assert!(!lookup.matches(&too_short).unwrap());
assert!(!lookup.matches(&intersecting).unwrap());
}
#[test]
fn cursor_is_scoped_and_bounded() {
assert_eq!(parse_cursor(None).unwrap(), 0);
assert_eq!(parse_cursor(Some("vrp:12")).unwrap(), 12);
assert!(parse_cursor(Some("12")).is_err());
assert_eq!(next_cursor(0, 10, 12).as_deref(), Some("vrp:10"));
assert_eq!(next_cursor(10, 10, 12), None);
}
}

View File

@@ -9,10 +9,11 @@ use sha2::{Digest, Sha256};
use crate::query::artifact_manifest::build_artifact_manifest;
use crate::query::report_stream::{self, ObjectLookup, ObjectScope, ReportSummary};
use crate::query::vrp::{self, VrpLookup, VrpRecord};
use crate::blob_store::ExternalRepoBytesDb;
pub const QUERY_DB_SCHEMA_VERSION: u32 = 1;
pub const QUERY_DB_SCHEMA_VERSION: u32 = 2;
pub const CF_META: &str = "meta";
pub const CF_RUNS: &str = "runs";
@@ -26,6 +27,7 @@ pub const CF_VALIDATION_EXPLAIN_CACHE: &str = "validation_explain_cache";
pub const CF_EXPORT_JOBS: &str = "export_jobs";
pub const CF_STATS: &str = "stats";
pub const CF_REASON_INDEX: &str = "reason_index";
pub const CF_VRPS: &str = "vrps";
pub const QUERY_DB_COLUMN_FAMILIES: &[&str] = &[
CF_META,
@@ -40,6 +42,7 @@ pub const QUERY_DB_COLUMN_FAMILIES: &[&str] = &[
CF_EXPORT_JOBS,
CF_STATS,
CF_REASON_INDEX,
CF_VRPS,
];
const KEY_SCHEMA_VERSION: &[u8] = b"schema_version";
@@ -57,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),
}
@@ -92,6 +97,7 @@ pub struct QueryIndexSummary {
pub object_instances_indexed: u64,
pub object_projections_indexed: u64,
pub stats_indexed: u64,
pub vrps_indexed: u64,
pub latest_ready_run: Option<String>,
pub errors: Vec<String>,
}
@@ -156,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 {
@@ -285,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()))
@@ -306,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()))
@@ -347,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)
}
}
@@ -357,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(
@@ -369,6 +428,104 @@ 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,
lookup: &VrpLookup,
asn: Option<u32>,
limit: usize,
cursor: Option<&str>,
) -> QueryDbResult<QueryPage<VrpRecord>> {
let cf = self.cf(CF_VRPS)?;
let mut records = BTreeMap::<String, VrpRecord>::new();
for candidate in lookup.candidate_prefixes() {
let key_prefix = vrp::vrp_key_prefix(run_id, &candidate);
let mode = IteratorMode::From(key_prefix.as_bytes(), rocksdb::Direction::Forward);
for item in self.db.iterator_cf(cf, mode) {
let (key, value) = item?;
if !key.starts_with(key_prefix.as_bytes()) {
break;
}
let key_string = String::from_utf8_lossy(&key).to_string();
let record: VrpRecord = serde_json::from_slice(&value)?;
if asn.is_some_and(|expected| expected != record.asn)
|| !lookup
.matches(&record)
.map_err(QueryDbError::InvalidArtifact)?
{
continue;
}
records.insert(key_string, record);
}
}
let offset = vrp::parse_cursor(cursor).map_err(QueryDbError::InvalidArtifact)?;
let bounded_limit = limit.clamp(1, 1000);
let total = records.len();
let data = records
.into_values()
.skip(offset)
.take(bounded_limit)
.collect::<Vec<_>>();
Ok(QueryPage {
data,
next_cursor: vrp::next_cursor(offset, bounded_limit, total),
limit: bounded_limit,
})
}
pub fn get_repo(&self, run_id: &str, repo_id: &str) -> QueryDbResult<Option<RepositoryRecord>> {
self.get_json_cf(CF_REPOS, repo_key(run_id, repo_id).as_bytes())
}
@@ -473,6 +630,31 @@ impl QueryDb {
)
}
pub fn list_objects_filtered(
&self,
run_id: &str,
scope: ObjectScope,
filter: &report_stream::ObjectFilter,
limit: usize,
cursor: Option<&str>,
) -> QueryDbResult<QueryPage<ObjectInstanceRecord>> {
let Some(report_path) = self.report_path_for_run(run_id)? else {
return Ok(QueryPage {
data: Vec::new(),
next_cursor: None,
limit: limit.clamp(1, 1000),
});
};
report_stream::list_report_objects_filtered(
&report_path,
run_id,
scope,
filter,
limit,
cursor,
)
}
pub fn get_object_by_instance_id(
&self,
run_id: &str,
@@ -616,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(
@@ -628,6 +818,118 @@ impl QueryDb {
self.get_json_cf(CF_OBJECTS_BY_HASH, object_hash_key(sha256).as_bytes())
}
pub fn search_object_projections_by_hash_prefix(
&self,
sha256_prefix: &str,
limit: usize,
) -> QueryDbResult<Vec<crate::object_projection::ObjectProjectionRecord>> {
let cf = self.cf(CF_OBJECTS_BY_HASH)?;
let prefix = format!("objhash/{sha256_prefix}");
let mut out = Vec::new();
let mode = IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward);
for item in self.db.iterator_cf(cf, mode) {
let (key, value) = item?;
if !String::from_utf8_lossy(&key).starts_with(&prefix) {
break;
}
if out.len() >= limit {
break;
}
out.push(serde_json::from_slice(&value)?);
}
Ok(out)
}
pub fn get_cached_object_by_sha256(
&self,
run_id: &str,
sha256: &str,
) -> QueryDbResult<Option<ObjectInstanceRecord>> {
let mut cursor = None;
loop {
let page: QueryPage<ObjectInstanceRecord> = self.list_json_by_prefix(
CF_OBJECT_INSTANCES,
&format!("objinst/{run_id}/"),
1000,
cursor.as_deref(),
)?;
if let Some(found) = page
.data
.into_iter()
.find(|item| item.sha256.eq_ignore_ascii_case(sha256))
{
return Ok(Some(found));
}
let Some(next_cursor) = page.next_cursor else {
return Ok(None);
};
cursor = Some(next_cursor);
}
}
pub fn search_repos(
&self,
run_id: &str,
query_lower: &str,
limit: usize,
) -> QueryDbResult<Vec<RepositoryRecord>> {
let cf = self.cf(CF_REPOS)?;
let prefix = format!("repo/{run_id}/");
let mut out = Vec::new();
let mode = IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward);
for item in self.db.iterator_cf(cf, mode) {
let (key, value) = item?;
if !String::from_utf8_lossy(&key).starts_with(&prefix) {
break;
}
if out.len() >= limit {
break;
}
let repo: RepositoryRecord = serde_json::from_slice(&value)?;
if repo.host.to_lowercase().contains(query_lower)
|| repo.uri.to_lowercase().contains(query_lower)
{
out.push(repo);
}
}
Ok(out)
}
pub fn search_publication_points(
&self,
run_id: &str,
query_lower: &str,
limit: usize,
) -> QueryDbResult<Vec<PublicationPointRecord>> {
let cf = self.cf(CF_PUBLICATION_POINTS)?;
let prefix = format!("pp/{run_id}/");
let mut out = Vec::new();
let mode = IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward);
for item in self.db.iterator_cf(cf, mode) {
let (key, value) = item?;
if !String::from_utf8_lossy(&key).starts_with(&prefix) {
break;
}
if out.len() >= limit {
break;
}
let pp: PublicationPointRecord = serde_json::from_slice(&value)?;
let uri_matches = [
pp.manifest_rsync_uri.as_deref(),
pp.publication_point_rsync_uri.as_deref(),
pp.rrdp_notification_uri.as_deref(),
pp.rsync_base_uri.as_deref(),
]
.into_iter()
.flatten()
.any(|uri| uri.to_lowercase().contains(query_lower));
if uri_matches {
out.push(pp);
}
}
Ok(out)
}
pub fn get_stat(
&self,
run_id: &str,
@@ -762,12 +1064,20 @@ impl QueryDb {
(CF_EXPORT_JOBS, format!("export/{}/", run.run_id)),
(CF_STATS, format!("stats/{}/", run.run_id)),
(CF_REASON_INDEX, format!("reason/{}/", run.run_id)),
(CF_VRPS, format!("vrp/{}/", run.run_id)),
] {
self.delete_prefix_range(&mut batch, cf_name, &prefix)?;
}
self.write_batch(batch)
}
fn clear_vrp_index(&self, run_id: &str) -> QueryDbResult<()> {
let mut batch = WriteBatch::default();
let prefix = format!("vrp/{run_id}/");
self.delete_prefix_range(&mut batch, CF_VRPS, &prefix)?;
self.write_batch(batch)
}
fn cf(&self, name: &'static str) -> QueryDbResult<&rocksdb::ColumnFamily> {
self.db
.cf_handle(name)
@@ -899,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
}
@@ -943,6 +1254,7 @@ pub fn index_artifacts_with_open_db(
summary.object_instances_indexed += run_summary.object_instances_indexed;
summary.object_projections_indexed += run_summary.object_projections_indexed;
summary.stats_indexed += run_summary.stats_indexed;
summary.vrps_indexed += run_summary.vrps_indexed;
summary.latest_ready_run = run_summary.latest_ready_run;
}
Err(err) => summary.errors.push(format!("{}: {err}", run_dir.display())),
@@ -1015,6 +1327,7 @@ struct SingleRunIndexSummary {
object_instances_indexed: u64,
object_projections_indexed: u64,
stats_indexed: u64,
vrps_indexed: u64,
latest_ready_run: Option<String>,
}
@@ -1056,6 +1369,7 @@ fn index_run_dir(
run_record.index_status = "building".to_string();
run_record.index_error = None;
db.put_json_cf(CF_RUNS, run_key(&run_id).as_bytes(), &run_record)?;
db.clear_vrp_index(&run_id)?;
let indexed =
match write_summary_index_records(db, &run_id, &report_summary, &artifact_manifest) {
@@ -1099,6 +1413,7 @@ fn index_run_dir(
object_instances_indexed: indexed.object_instances_indexed,
object_projections_indexed: indexed.object_projections_indexed,
stats_indexed: indexed.stats_indexed,
vrps_indexed: indexed.vrps_indexed,
latest_ready_run: if should_update_latest {
Some(run_id)
} else {
@@ -1138,6 +1453,7 @@ struct IndexWriteSummary {
object_instances_indexed: u64,
object_projections_indexed: u64,
stats_indexed: u64,
vrps_indexed: u64,
}
fn write_summary_index_records(
@@ -1150,6 +1466,7 @@ fn write_summary_index_records(
let mut batch = WriteBatch::default();
let mut pending = 0usize;
let mut summary = IndexWriteSummary::default();
let mut seen_vrp_keys = BTreeSet::new();
macro_rules! flush_if_needed {
() => {
@@ -1187,6 +1504,18 @@ fn write_summary_index_records(
flush_if_needed!();
}
for vrp_record in &report_summary.vrps {
let key = vrp_record
.index_key()
.map_err(QueryDbError::InvalidArtifact)?;
if seen_vrp_keys.insert(key.clone()) {
put_json_batch(&mut batch, db, CF_VRPS, key.as_bytes(), vrp_record)?;
pending += 1;
summary.vrps_indexed += 1;
flush_if_needed!();
}
}
let artifacts_value = serde_json::to_value(artifact_manifest)?;
let stats = report_stream::stats_records_from_summary(run_id, report_summary, artifacts_value);
for record in &stats {
@@ -1447,6 +1776,56 @@ mod tests {
assert_eq!(db.count_cf(CF_OBJECT_INSTANCES).unwrap(), 0);
}
#[test]
fn vrp_index_supports_ip_prefix_asn_and_pagination_queries() {
let temp = tempfile::tempdir().expect("tempdir");
let run_dir = temp.path().join("runs/run_0001");
fs::create_dir_all(&run_dir).expect("run dir");
write_sample_run(&run_dir, "run_0001", 1);
let mut report = read_json_file(&run_dir.join("report.json")).expect("read report");
let duplicate = report["vrps"][0].clone();
report["vrps"].as_array_mut().unwrap().push(duplicate);
fs::write(
run_dir.join("report.json"),
serde_json::to_vec(&report).unwrap(),
)
.expect("write duplicate VRP report");
let query_db_path = temp.path().join("query-db");
let summary = 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");
assert_eq!(summary.vrps_indexed, 1);
let db = QueryDb::open(&query_db_path).expect("query db");
assert_eq!(db.count_cf(CF_VRPS).unwrap(), 1);
let ip = VrpLookup::parse_ip("192.0.2.1").expect("ip lookup");
let page = db
.lookup_vrps("run_0001", &ip, None, 1, None)
.expect("ip query");
assert_eq!(page.data.len(), 1);
assert_eq!(page.data[0].asn, 64496);
assert_eq!(page.data[0].prefix, "192.0.2.0/24");
assert!(page.next_cursor.is_none());
let prefix = VrpLookup::parse_prefix("192.0.2.0/24").expect("prefix lookup");
let filtered = db
.lookup_vrps("run_0001", &prefix, Some(64497), 100, None)
.expect("asn query");
assert!(filtered.data.is_empty());
let latest = db
.lookup_vrps("run_0001", &prefix, Some(64496), 100, None)
.expect("filtered prefix query");
assert_eq!(latest.data.len(), 1);
}
#[test]
fn failed_run_does_not_replace_previous_latest() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -1688,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);
@@ -1842,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");
@@ -1938,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

@@ -8,6 +8,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use base64::Engine;
use rocksdb::checkpoint::Checkpoint;
use rocksdb::{ColumnFamily, DB, Direction, IteratorMode, Options, WriteBatch};
use serde::{Deserialize, Serialize};
@@ -53,6 +54,13 @@ pub enum StorageError {
pub type StorageResult<T> = Result<T, StorageError>;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct RepositoryBlobVerificationSummary {
pub current_objects: u64,
pub bytes_verified: u64,
pub batches: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct ChildCertificateCacheMmapLookup {
pub projections: Vec<Option<ChildCertificateCacheProjection>>,
@@ -595,7 +603,7 @@ pub struct VcirFailedFetchReuseIdentity {
pub ta_context_digest: [u8; 32],
#[serde(rename = "p")]
#[serde(with = "serde_bytes_32")]
pub parent_context_digest: [u8; 32],
pub ca_validation_context_digest: [u8; 32],
#[serde(rename = "f")]
#[serde(with = "serde_bytes_32")]
pub policy_fingerprint: [u8; 32],
@@ -605,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(
@@ -1117,7 +1131,7 @@ pub struct RoaCacheObjectMeta {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaCacheProjectionContext {
pub parent_context_digest: [u8; 32],
pub ca_validation_context_digest: [u8; 32],
pub policy_fingerprint: [u8; 32],
pub object_meta: Vec<RoaCacheObjectMeta>,
}
@@ -1258,7 +1272,7 @@ pub struct RoaCacheProjection {
pub issuer_ca_sha256_hex: Option<String>,
#[serde(rename = "p", default, skip_serializing_if = "Option::is_none")]
#[serde(with = "serde_optional_bytes_32")]
pub parent_context_digest: Option<[u8; 32]>,
pub ca_validation_context_digest: Option<[u8; 32]>,
#[serde(rename = "f", default, skip_serializing_if = "Option::is_none")]
#[serde(with = "serde_optional_bytes_32")]
pub policy_fingerprint: Option<[u8; 32]>,
@@ -1400,7 +1414,7 @@ pub struct ChildCertificateCacheProjection {
pub issuer_crl_sha256_hex: String,
#[serde(rename = "pc")]
#[serde(with = "serde_bytes_32")]
pub parent_context_digest: [u8; 32],
pub ca_validation_context_digest: [u8; 32],
#[serde(rename = "pf")]
#[serde(with = "serde_bytes_32")]
pub validation_policy_fingerprint: [u8; 32],
@@ -1652,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 {
@@ -1663,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(),
}
}
@@ -1674,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(),
}
}
@@ -1707,7 +1726,7 @@ pub struct PublicationPointCacheProjection {
pub ta_context_digest: [u8; 32],
#[serde(rename = "pc")]
#[serde(with = "serde_bytes_32")]
pub parent_context_digest: [u8; 32],
pub ca_validation_context_digest: [u8; 32],
#[serde(rename = "pf")]
#[serde(with = "serde_bytes_32")]
pub validation_policy_fingerprint: [u8; 32],
@@ -1743,7 +1762,7 @@ impl PublicationPointCacheProjection {
ca_cert_sha256: [u8; 32],
manifest_sha256: [u8; 32],
ta_context_digest: [u8; 32],
parent_context_digest: [u8; 32],
ca_validation_context_digest: [u8; 32],
validation_policy_fingerprint: [u8; 32],
) -> StorageResult<Self> {
let projection = Self {
@@ -1756,7 +1775,7 @@ impl PublicationPointCacheProjection {
manifest_sha256,
tal_id: vcir.tal_id.clone(),
ta_context_digest,
parent_context_digest,
ca_validation_context_digest,
validation_policy_fingerprint,
instance_effective_not_before: vcir.last_successful_validation_time.clone(),
instance_effective_until: vcir.instance_gate.instance_effective_until.clone(),
@@ -1811,7 +1830,7 @@ impl PublicationPointCacheProjection {
tal_id: self.tal_id.clone(),
ca_subject_name: String::from("publication-point-cache"),
ca_ski: hex::encode(self.ca_cert_sha256),
issuer_ski: hex::encode(self.parent_context_digest),
issuer_ski: hex::encode(self.ca_validation_context_digest),
last_successful_validation_time: validation_time.clone(),
current_manifest_rsync_uri: self.manifest_rsync_uri.clone(),
current_crl_rsync_uri: self
@@ -2042,7 +2061,7 @@ impl RoaCacheProjection {
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
instance_effective_until: vcir.instance_gate.instance_effective_until.clone(),
issuer_ca_sha256_hex,
parent_context_digest: Some(context.parent_context_digest),
ca_validation_context_digest: Some(context.ca_validation_context_digest),
policy_fingerprint: Some(context.policy_fingerprint),
crl_sha256_by_uri,
entries,
@@ -2063,10 +2082,10 @@ impl RoaCacheProjection {
if let Some(hash) = &self.issuer_ca_sha256_hex {
validate_sha256_hex("roa_cache_projection.issuer_ca_sha256_hex", hash)?;
}
if self.parent_context_digest.is_some() != self.policy_fingerprint.is_some() {
if self.ca_validation_context_digest.is_some() != self.policy_fingerprint.is_some() {
return Err(StorageError::InvalidData {
entity: "roa_cache_projection.context",
detail: "parent_context_digest and policy_fingerprint must be both present or both absent"
detail: "ca_validation_context_digest and policy_fingerprint must be both present or both absent"
.to_string(),
});
}
@@ -2149,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 {
@@ -2893,6 +2917,38 @@ fn write_publication_point_cache_projection_to_batch(
}
impl RocksStore {
pub fn create_read_only_checkpoint(source: &Path, destination: &Path) -> StorageResult<()> {
if destination.exists() {
return Err(StorageError::InvalidData {
entity: "work_db_checkpoint",
detail: format!(
"checkpoint destination already exists: {}",
destination.display()
),
});
}
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| StorageError::RocksDb(error.to_string()))?;
}
let mut options = Options::default();
let blob_mode = work_db_blob_mode_from_env();
let memory_profile = work_db_memory_profile_from_env();
configure_work_db_options(&mut options, blob_mode, memory_profile);
let db = DB::open_cf_descriptors_read_only(
&options,
source,
column_family_descriptors_for_blob_mode(blob_mode),
false,
)
.map_err(|error| StorageError::RocksDb(error.to_string()))?;
let checkpoint =
Checkpoint::new(&db).map_err(|error| StorageError::RocksDb(error.to_string()))?;
checkpoint
.create_checkpoint(destination)
.map_err(|error| StorageError::RocksDb(error.to_string()))
}
pub fn open(path: &Path) -> StorageResult<Self> {
let mut base_opts = Options::default();
base_opts.create_if_missing(true);
@@ -2931,6 +2987,15 @@ impl RocksStore {
Self::open_with_external_stores(path, None, Some(repo_bytes_path))
}
pub fn open_with_external_repo_bytes_read_only(
path: &Path,
repo_bytes_path: &Path,
) -> StorageResult<Self> {
let mut store = Self::open(path)?;
store.external_repo_bytes = Some(ExternalRepoBytesDb::open_read_only(repo_bytes_path)?);
Ok(store)
}
pub fn open_with_external_stores(
path: &Path,
raw_store_path: Option<&Path>,
@@ -3147,6 +3212,53 @@ impl RocksStore {
.collect()
}
pub fn verify_current_repository_blobs(
&self,
batch_size: usize,
) -> StorageResult<RepositoryBlobVerificationSummary> {
if batch_size == 0 {
return Err(StorageError::InvalidData {
entity: "repository_blob_verification",
detail: "batch_size must be greater than zero".to_string(),
});
}
let cf = self.cf(CF_REPOSITORY_VIEW)?;
let prefix = repository_view_prefix("");
let mode = IteratorMode::From(prefix.as_bytes(), Direction::Forward);
let mut summary = RepositoryBlobVerificationSummary::default();
let mut batch = Vec::with_capacity(batch_size);
for item in self.db.iterator_cf(cf, mode) {
let (key, value) = item.map_err(|error| StorageError::RocksDb(error.to_string()))?;
if !key.starts_with(prefix.as_bytes()) {
break;
}
let entry = decode_cbor::<RepositoryViewEntry>(&value, "repository_view")?;
entry.validate_internal()?;
if !matches!(
entry.state,
RepositoryViewState::Present | RepositoryViewState::Replaced
) {
continue;
}
let hash = entry
.current_hash
.clone()
.ok_or(StorageError::InvalidData {
entity: "repository_blob_verification",
detail: format!("current hash missing for {}", entry.rsync_uri),
})?;
batch.push((entry.rsync_uri, hash));
if batch.len() >= batch_size {
verify_repository_blob_batch(self, &batch, &mut summary)?;
batch.clear();
}
}
if !batch.is_empty() {
verify_repository_blob_batch(self, &batch, &mut summary)?;
}
Ok(summary)
}
pub fn put_raw_by_hash_entry(&self, entry: &RawByHashEntry) -> StorageResult<()> {
entry.validate_internal()?;
if let Some(raw_store) = self.external_raw_store.as_ref() {
@@ -3206,6 +3318,9 @@ impl RocksStore {
return Ok(());
}
if let Some(repo_bytes) = self.external_repo_bytes.as_ref() {
if repo_bytes.is_read_only() {
return repo_bytes.require_existing_blob_bytes_batch(blobs);
}
return repo_bytes.put_blob_bytes_batch(blobs);
}
if let Some(raw_store) = self.external_raw_store.as_ref() {
@@ -4424,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;
@@ -4682,6 +4827,37 @@ impl RocksStore {
}
}
fn verify_repository_blob_batch(
store: &RocksStore,
batch: &[(String, String)],
summary: &mut RepositoryBlobVerificationSummary,
) -> StorageResult<()> {
let hashes = batch
.iter()
.map(|(_, hash)| hash.clone())
.collect::<Vec<_>>();
let blobs = store.get_blob_bytes_batch(&hashes)?;
for ((uri, expected_hash), blob) in batch.iter().zip(blobs.into_iter()) {
let bytes = blob.as_ref().ok_or(StorageError::InvalidData {
entity: "repository_blob_verification",
detail: format!("blob missing for URI {uri} (hash={expected_hash})"),
})?;
let actual_hash = hex::encode(compute_sha256_32(bytes));
if !actual_hash.eq_ignore_ascii_case(expected_hash) {
return Err(StorageError::InvalidData {
entity: "repository_blob_verification",
detail: format!(
"blob hash mismatch for URI {uri}: expected={expected_hash}, actual={actual_hash}"
),
});
}
summary.current_objects += 1;
summary.bytes_verified += bytes.len() as u64;
}
summary.batches += 1;
Ok(())
}
#[cfg(test)]
#[path = "storage/tests.rs"]
mod tests;

View File

@@ -29,7 +29,7 @@ fn sample_child_certificate_cache_projection(
issuer_ca_sha256_hex: sha256_hex(b"issuer-ca"),
issuer_crl_uri: "rsync://example.test/repo/issuer.crl".to_string(),
issuer_crl_sha256_hex: sha256_hex(b"issuer-crl"),
parent_context_digest: sha256_32(b"parent-context"),
ca_validation_context_digest: sha256_32(b"parent-context"),
validation_policy_fingerprint: sha256_32(b"policy"),
effective_not_before: pack_time(0),
effective_until: pack_time(24),
@@ -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 {
@@ -294,7 +296,7 @@ fn roa_cache_projection_context(vcir: &ValidatedCaInstanceResult) -> RoaCachePro
})
.expect("sample VCIR has a ROA output");
RoaCacheProjectionContext {
parent_context_digest: [0x31; 32],
ca_validation_context_digest: [0x31; 32],
policy_fingerprint: [0x32; 32],
object_meta: vec![RoaCacheObjectMeta {
source_object_uri: roa_output.source_object_uri.clone(),
@@ -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::{
@@ -2276,3 +2383,153 @@ fn pack_file_can_lazy_load_bytes_from_external_repo_bytes_store() {
assert_eq!(file.bytes_cloned().expect("cloned repo bytes"), bytes);
assert_eq!(file.compute_sha256().expect("compute sha256"), file.sha256);
}
#[test]
fn read_only_checkpoint_isolated_from_source_work_db() {
let td = tempfile::tempdir().expect("tempdir");
let source_path = td.path().join("source-work-db");
let checkpoint_path = td.path().join("checkpoint-work-db");
let uri = "rsync://example.test/repo/a.roa";
let bytes = b"checkpoint-object";
let hash = sha256_hex(bytes);
{
let source = RocksStore::open(&source_path).expect("open source");
source
.put_blob_bytes_batch(&[(hash.clone(), bytes.to_vec())])
.expect("put blob");
source
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: uri.to_string(),
current_hash: Some(hash),
repository_source: Some("fixture".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
})
.expect("put view");
}
RocksStore::create_read_only_checkpoint(&source_path, &checkpoint_path)
.expect("create checkpoint");
let checkpoint = RocksStore::open(&checkpoint_path).expect("open checkpoint");
checkpoint
.delete_repository_view_entry(uri)
.expect("delete checkpoint entry");
drop(checkpoint);
let source = RocksStore::open(&source_path).expect("reopen source");
assert!(
source
.get_repository_view_entry(uri)
.expect("get source entry")
.is_some()
);
}
#[test]
fn read_only_external_repo_bytes_rejects_writes() {
let td = tempfile::tempdir().expect("tempdir");
let path = td.path().join("repo-bytes.db");
let hash = sha256_hex(b"repo-object");
{
let writable = ExternalRepoBytesDb::open(&path).expect("open writable repo bytes");
writable
.put_blob_bytes_batch(&[(hash.clone(), b"repo-object".to_vec())])
.expect("seed repo bytes");
}
let read_only = ExternalRepoBytesDb::open_read_only(&path).expect("open read-only repo bytes");
assert_eq!(
read_only
.get_blob_bytes(&hash)
.expect("read repo bytes")
.expect("blob"),
b"repo-object"
);
assert!(
read_only
.put_blob_bytes_batch(&[(hash, b"repo-object".to_vec())])
.is_err()
);
}
#[test]
fn read_only_store_accepts_only_idempotent_existing_blob_apply() {
let td = tempfile::tempdir().expect("tempdir");
let work_db_path = td.path().join("work-db");
let repo_bytes_path = td.path().join("repo-bytes.db");
let bytes = b"repo-object".to_vec();
let hash = sha256_hex(&bytes);
{
let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("open repo bytes");
repo_bytes
.put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
.expect("seed repo bytes");
}
let store =
RocksStore::open_with_external_repo_bytes_read_only(&work_db_path, &repo_bytes_path)
.expect("open read-only store");
store
.put_blob_bytes_batch(&[(hash.clone(), bytes)])
.expect("idempotent apply");
assert!(
store
.put_blob_bytes_batch(&[(hash, b"different".to_vec())])
.is_err()
);
}
#[test]
fn repository_blob_verification_detects_missing_external_blob() {
let td = tempfile::tempdir().expect("tempdir");
let repo_bytes_path = td.path().join("repo-bytes.db");
ExternalRepoBytesDb::open(&repo_bytes_path).expect("create repo bytes");
let store = RocksStore::open_with_external_repo_bytes_read_only(
&td.path().join("work-db"),
&repo_bytes_path,
)
.expect("open work db");
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/missing.roa".to_string(),
current_hash: Some(sha256_hex(b"missing")),
repository_source: Some("fixture".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
})
.expect("put view");
let error = store
.verify_current_repository_blobs(16)
.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

@@ -3,6 +3,7 @@ use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -26,10 +27,13 @@ struct Args {
once: bool,
out_metrics: Option<PathBuf>,
out_status: Option<PathBuf>,
ccr_check_bin: Option<PathBuf>,
ccr_check_timeout_secs: u64,
ccr_check_max_per_scan: u64,
}
fn usage() -> &'static str {
"Usage: rpki_artifact_metrics --run-root <path> [--listen <addr:port>] [--poll-secs <n>] [--instance <name>] [--once] [--out-metrics <path>] [--out-status <path>]"
"Usage: rpki_artifact_metrics --run-root <path> [--listen <addr:port>] [--poll-secs <n>] [--instance <name>] [--once] [--out-metrics <path>] [--out-status <path>] [--ccr-check-bin <path>] [--ccr-check-timeout-secs <n>] [--ccr-check-max-per-scan <n>]"
}
pub fn main_entry() -> Result<(), String> {
@@ -38,8 +42,16 @@ pub fn main_entry() -> Result<(), String> {
fn real_main() -> Result<(), String> {
let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
let ccr_check_config = CcrCheckConfig::from_args(&args);
let rtr_report_dir = configured_rtr_report_dir();
if args.once {
let snapshot = scan_run_root(&args.run_root, &args.instance)?;
let snapshot = scan_run_root_incremental_with_rtr_dir(
&args.run_root,
&args.instance,
None,
rtr_report_dir.as_deref(),
&ccr_check_config,
)?;
let metrics = render_metrics(&snapshot);
let status = render_status_json(&snapshot)?;
if let Some(path) = args.out_metrics.as_ref() {
@@ -53,7 +65,13 @@ fn real_main() -> Result<(), String> {
return Ok(());
}
let shared = Arc::new(RwLock::new(scan_run_root(&args.run_root, &args.instance)?));
let shared = Arc::new(RwLock::new(scan_run_root_incremental_with_rtr_dir(
&args.run_root,
&args.instance,
None,
rtr_report_dir.as_deref(),
&ccr_check_config,
)?));
let scanner = Arc::clone(&shared);
let run_root = args.run_root.clone();
let instance = args.instance.clone();
@@ -62,20 +80,25 @@ fn real_main() -> Result<(), String> {
loop {
thread::sleep(Duration::from_secs(poll_secs));
let previous_snapshot = scanner.read().expect("metrics lock poisoned").clone();
let next =
match scan_run_root_incremental(&run_root, &instance, Some(&previous_snapshot)) {
Ok(snapshot) => snapshot,
Err(err) => {
let mut previous = scanner.write().expect("metrics lock poisoned");
previous
.service
.parse_errors
.push(format!("scan failed: {err}"));
previous.service.last_reload_success = false;
previous.service.last_scan_timestamp_seconds = unix_now_seconds();
continue;
}
};
let next = match scan_run_root_incremental_with_rtr_dir(
&run_root,
&instance,
Some(&previous_snapshot),
rtr_report_dir.as_deref(),
&ccr_check_config,
) {
Ok(snapshot) => snapshot,
Err(err) => {
let mut previous = scanner.write().expect("metrics lock poisoned");
previous
.service
.parse_errors
.push(format!("scan failed: {err}"));
previous.service.last_reload_success = false;
previous.service.last_scan_timestamp_seconds = unix_now_seconds();
continue;
}
};
*scanner.write().expect("metrics lock poisoned") = next;
}
});
@@ -91,6 +114,9 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
let mut once = false;
let mut out_metrics = None;
let mut out_status = None;
let mut ccr_check_bin = None;
let mut ccr_check_timeout_secs = 120u64;
let mut ccr_check_max_per_scan = 2u64;
let mut index = 1usize;
while index < argv.len() {
match argv[index].as_str() {
@@ -122,6 +148,24 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
index += 1;
out_status = Some(PathBuf::from(value_at(argv, index, "--out-status")?));
}
"--ccr-check-bin" => {
index += 1;
ccr_check_bin = Some(PathBuf::from(value_at(argv, index, "--ccr-check-bin")?));
}
"--ccr-check-timeout-secs" => {
index += 1;
let value = value_at(argv, index, "--ccr-check-timeout-secs")?;
ccr_check_timeout_secs = value
.parse::<u64>()
.map_err(|_| format!("invalid --ccr-check-timeout-secs: {value}"))?;
}
"--ccr-check-max-per-scan" => {
index += 1;
let value = value_at(argv, index, "--ccr-check-max-per-scan")?;
ccr_check_max_per_scan = value
.parse::<u64>()
.map_err(|_| format!("invalid --ccr-check-max-per-scan: {value}"))?;
}
"-h" | "--help" => return Err(usage().to_string()),
other => return Err(format!("unknown argument: {other}\n{}", usage())),
}
@@ -135,6 +179,9 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
once,
out_metrics,
out_status,
ccr_check_bin,
ccr_check_timeout_secs,
ccr_check_max_per_scan,
})
}
@@ -161,9 +208,67 @@ struct MetricsSnapshot {
top_pp_by_sync_duration: Vec<TopPublicationPoint>,
cir: Option<CirMetrics>,
ccr: Option<CcrMetrics>,
ccr_check: CcrCheckMetrics,
rtr: RtrMetrics,
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct CcrCheckMetrics {
enabled: bool,
up: bool,
bin: Option<String>,
pass_total: u64,
ccr_parse_error_total: u64,
tool_error_total: u64,
consecutive_failures: u64,
last: Option<CcrCheckLastResult>,
checked_run_dirs: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CcrCheckLastResult {
run_dir: String,
run_seq: Option<u64>,
outcome: String,
duration_seconds: f64,
checked_at_seconds: f64,
detail: Option<String>,
}
#[derive(Clone, Debug)]
struct CcrCheckConfig {
bin: Option<PathBuf>,
timeout_secs: u64,
max_per_scan: u64,
}
impl CcrCheckConfig {
fn disabled() -> Self {
Self {
bin: None,
timeout_secs: 120,
max_per_scan: 2,
}
}
fn from_args(args: &Args) -> Self {
let bin = args.ccr_check_bin.clone().or_else(|| {
std::env::var("RPKI_METRICS_CCR_CHECK_BIN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
});
Self {
bin,
timeout_secs: args.ccr_check_timeout_secs,
max_per_scan: args.ccr_check_max_per_scan,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct ServiceMetrics {
@@ -319,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>,
@@ -328,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)]
@@ -493,21 +601,13 @@ struct RunRecord {
}
fn scan_run_root(input_root: &Path, instance: &str) -> Result<MetricsSnapshot, String> {
let rtr_report_dir = configured_rtr_report_dir();
scan_run_root_incremental_with_rtr_dir(input_root, instance, None, rtr_report_dir.as_deref())
}
fn scan_run_root_incremental(
input_root: &Path,
instance: &str,
previous: Option<&MetricsSnapshot>,
) -> Result<MetricsSnapshot, String> {
let rtr_report_dir = configured_rtr_report_dir();
scan_run_root_incremental_with_rtr_dir(
input_root,
instance,
previous,
None,
rtr_report_dir.as_deref(),
&CcrCheckConfig::disabled(),
)
}
@@ -516,6 +616,7 @@ fn scan_run_root_incremental_with_rtr_dir(
instance: &str,
previous: Option<&MetricsSnapshot>,
rtr_report_dir: Option<&Path>,
ccr_check_config: &CcrCheckConfig,
) -> Result<MetricsSnapshot, String> {
let started = Instant::now();
let runs_root = resolve_runs_root(input_root);
@@ -570,6 +671,7 @@ fn scan_run_root_incremental_with_rtr_dir(
}
snapshot.rtr = scan_rtr_reports(rtr_report_dir, previous.map(|snapshot| &snapshot.rtr));
run_pending_ccr_checks(&records, previous, ccr_check_config, &mut snapshot);
snapshot.service.last_scan_timestamp_seconds = unix_now_seconds();
snapshot.service.last_scan_duration_seconds = started.elapsed().as_secs_f64();
snapshot.service.last_reload_success =
@@ -975,6 +1077,218 @@ fn consecutive_failures(records: &[RunRecord]) -> u64 {
count
}
const MAX_CHECKED_RUN_DIRS: usize = 512;
fn run_pending_ccr_checks(
records: &[RunRecord],
previous: Option<&MetricsSnapshot>,
config: &CcrCheckConfig,
snapshot: &mut MetricsSnapshot,
) {
let mut check = previous
.map(|prev| prev.ccr_check.clone())
.unwrap_or_default();
check.enabled = config.bin.is_some();
check.bin = config.bin.as_ref().map(|bin| bin.display().to_string());
check.up = false;
let Some(bin) = config.bin.as_ref() else {
snapshot.ccr_check = check;
return;
};
if !bin.is_file() {
snapshot
.service
.parse_errors
.push(format!("ccr check bin is not a file: {}", bin.display()));
snapshot.ccr_check = check;
return;
}
check.up = true;
let mut pending: Vec<&RunRecord> = records
.iter()
.filter(|record| record.status == "success")
.filter(|record| record.path.join("result.ccr").is_file())
.filter(|record| {
let dir = record.path.display().to_string();
!check.checked_run_dirs.iter().any(|seen| seen == &dir)
})
.collect();
if previous.is_none() {
// On service start only the latest existing run is checked; older
// history is marked as seen without executing checks.
let latest_path = pending.last().map(|record| record.path.clone());
for record in &pending {
if Some(&record.path) != latest_path.as_ref() {
check
.checked_run_dirs
.push(record.path.display().to_string());
}
}
pending.retain(|record| Some(&record.path) == latest_path.as_ref());
}
for record in pending
.into_iter()
.take(config.max_per_scan.max(1) as usize)
{
let ccr_path = record.path.join("result.ccr");
let outcome = execute_ccr_check(bin, &ccr_path, Duration::from_secs(config.timeout_secs));
let run_seq = record
.summary
.as_ref()
.and_then(|v| json_u64(v, &["runSeq"]))
.or_else(|| {
record
.meta
.as_ref()
.and_then(|v| json_u64(v, &["run_index"]))
});
let passed = outcome.outcome == "pass";
match outcome.outcome.as_str() {
"pass" => check.pass_total += 1,
"ccr_parse_error" => check.ccr_parse_error_total += 1,
_ => check.tool_error_total += 1,
}
check.consecutive_failures = if passed {
0
} else {
check.consecutive_failures + 1
};
check.last = Some(CcrCheckLastResult {
run_dir: record.path.display().to_string(),
run_seq,
outcome: outcome.outcome,
duration_seconds: outcome.duration_seconds,
checked_at_seconds: unix_now_seconds(),
detail: outcome.detail,
});
check
.checked_run_dirs
.push(record.path.display().to_string());
}
if check.checked_run_dirs.len() > MAX_CHECKED_RUN_DIRS {
let drain = check.checked_run_dirs.len() - MAX_CHECKED_RUN_DIRS;
check.checked_run_dirs.drain(..drain);
}
snapshot.ccr_check = check;
}
struct CcrCheckOutcome {
outcome: String,
duration_seconds: f64,
detail: Option<String>,
}
fn execute_ccr_check(bin: &Path, ccr_path: &Path, timeout: Duration) -> CcrCheckOutcome {
let started = Instant::now();
let log_path = std::env::temp_dir().join(format!(
"rpki-artifact-metrics-ccr-check-{}-{}.log",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let (outcome, detail) = execute_ccr_check_logged(bin, ccr_path, timeout, &log_path);
let _ = fs::remove_file(&log_path);
CcrCheckOutcome {
outcome,
duration_seconds: started.elapsed().as_secs_f64(),
detail,
}
}
fn execute_ccr_check_logged(
bin: &Path,
ccr_path: &Path,
timeout: Duration,
log_path: &Path,
) -> (String, Option<String>) {
let log_file = match fs::File::create(log_path) {
Ok(file) => file,
Err(err) => {
return (
"tool_error".to_string(),
Some(format!("create ccr check log failed: {err}")),
);
}
};
let log_err = match log_file.try_clone() {
Ok(file) => file,
Err(err) => {
return (
"tool_error".to_string(),
Some(format!("clone ccr check log handle failed: {err}")),
);
}
};
let mut child = match Command::new(bin)
.arg("-f")
.arg(ccr_path)
.stdin(Stdio::null())
.stdout(Stdio::from(log_file))
.stderr(Stdio::from(log_err))
.spawn()
{
Ok(child) => child,
Err(err) => {
return (
"tool_error".to_string(),
Some(format!("spawn ccr check failed: {err}")),
);
}
};
let started = Instant::now();
let exit_code = loop {
match child.try_wait() {
Ok(Some(status)) => break status.code(),
Ok(None) => {
if started.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return (
"tool_error".to_string(),
Some(format!("ccr check timed out after {}s", timeout.as_secs())),
);
}
thread::sleep(Duration::from_millis(100));
}
Err(err) => {
return (
"tool_error".to_string(),
Some(format!("wait ccr check failed: {err}")),
);
}
}
};
let output = fs::read_to_string(log_path).unwrap_or_default();
classify_ccr_check_output(exit_code, ccr_path, &output)
}
fn classify_ccr_check_output(
exit_code: Option<i32>,
ccr_path: &Path,
output: &str,
) -> (String, Option<String>) {
if exit_code != Some(0) {
return (
"tool_error".to_string(),
Some(format!("rpki-client exited with code {exit_code:?}")),
);
}
// rpki-client exits 0 even when the CCR file itself fails to parse, so
// the file-level error line is the authoritative failure signal. This
// mirrors scripts/ccr/check_rpki_client_ccr.sh.
let marker = format!("rpki-client: {}:", ccr_path.display());
if let Some(line) = output.lines().find(|line| line.contains(&marker)) {
return ("ccr_parse_error".to_string(), Some(line.trim().to_string()));
}
("pass".to_string(), None)
}
fn read_json_optional(path: &Path, errors: &mut Vec<String>) -> Option<Value> {
if !path.exists() {
return None;
@@ -1066,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);
@@ -1160,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);
}
@@ -1187,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>,
@@ -1634,10 +1989,60 @@ fn render_metrics(snapshot: &MetricsSnapshot) -> String {
if let Some(ccr) = snapshot.ccr.as_ref() {
render_ccr_metrics(&mut writer, instance, ccr);
}
render_ccr_check_metrics(&mut writer, instance, &snapshot.ccr_check);
render_rtr_metrics(&mut writer, instance, snapshot);
out
}
fn render_ccr_check_metrics(writer: &mut PromWriter<'_>, instance: &str, check: &CcrCheckMetrics) {
writer.gauge(
"ours_rp_ccr_format_check_up",
"Whether the CCR rpki-client format checker binary is configured and present",
&[label("instance", instance)],
bool_value(check.up),
);
for (result, value) in [
("pass", check.pass_total),
("ccr_parse_error", check.ccr_parse_error_total),
("tool_error", check.tool_error_total),
] {
writer.counter(
"ours_rp_ccr_format_check_total",
"CCR rpki-client format check results by classification",
&[label("instance", instance), label("result", result)],
value as f64,
);
}
writer.gauge(
"ours_rp_ccr_format_check_consecutive_failures",
"Consecutive failed CCR rpki-client format checks",
&[label("instance", instance)],
check.consecutive_failures as f64,
);
if let Some(last) = check.last.as_ref() {
writer.gauge(
"ours_rp_ccr_format_check_last_success",
"Whether the latest CCR rpki-client format check passed",
&[label("instance", instance)],
bool_value(last.outcome == "pass"),
);
if let Some(run_seq) = last.run_seq {
writer.gauge(
"ours_rp_ccr_format_check_last_run_sequence",
"Run sequence of the latest checked CCR artifact",
&[label("instance", instance)],
run_seq as f64,
);
}
writer.gauge(
"ours_rp_ccr_format_check_last_duration_seconds",
"Duration of the latest CCR rpki-client format check",
&[label("instance", instance)],
last.duration_seconds,
);
}
}
fn render_rtr_metrics(writer: &mut PromWriter<'_>, instance: &str, snapshot: &MetricsSnapshot) {
let rtr = &snapshot.rtr;
let scan_now = snapshot.service.last_scan_timestamp_seconds;
@@ -2042,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",
@@ -2058,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",
@@ -2525,6 +2957,7 @@ fn render_status_json(snapshot: &MetricsSnapshot) -> Result<String, String> {
"latestRun": snapshot.latest_run,
"cir": snapshot.cir,
"ccr": snapshot.ccr,
"ccrCheck": snapshot.ccr_check,
"rtr": snapshot.rtr,
"topRepositoriesBySyncDuration": snapshot.top_repos_by_sync_duration,
"topPublicationPointsByObjectCount": snapshot.top_pp_by_object_count,
@@ -2980,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");
@@ -3162,8 +3637,14 @@ mod tests {
)
.expect("clients");
let snapshot = scan_run_root_incremental_with_rtr_dir(td.path(), "test", None, Some(&rtr))
.expect("scan");
let snapshot = scan_run_root_incremental_with_rtr_dir(
td.path(),
"test",
None,
Some(&rtr),
&CcrCheckConfig::disabled(),
)
.expect("scan");
assert!(snapshot.rtr.enabled);
assert_eq!(snapshot.rtr.files["source"].seen, 1);
assert_eq!(
@@ -3233,8 +3714,14 @@ mod tests {
)
.expect("clients");
let snapshot = scan_run_root_incremental_with_rtr_dir(td.path(), "test", None, Some(&rtr))
.expect("scan");
let snapshot = scan_run_root_incremental_with_rtr_dir(
td.path(),
"test",
None,
Some(&rtr),
&CcrCheckConfig::disabled(),
)
.expect("scan");
assert!(snapshot.rtr.enabled);
assert_eq!(snapshot.rtr.files["source"].seen, 1);
assert!(snapshot.rtr.source.is_none());
@@ -3247,6 +3734,223 @@ mod tests {
assert!(metrics.contains(r#"ours_rp_rtr_metrics_enabled{instance="test"} 1"#));
}
#[test]
fn parse_args_accepts_ccr_check_options() {
let args = parse_args(&[
"rpki_artifact_metrics".to_string(),
"--run-root".to_string(),
"root".to_string(),
"--ccr-check-bin".to_string(),
"/opt/ours-rp/bin/rpki-client".to_string(),
"--ccr-check-timeout-secs".to_string(),
"60".to_string(),
"--ccr-check-max-per-scan".to_string(),
"3".to_string(),
])
.expect("parse");
assert_eq!(
args.ccr_check_bin.as_deref(),
Some(Path::new("/opt/ours-rp/bin/rpki-client"))
);
assert_eq!(args.ccr_check_timeout_secs, 60);
assert_eq!(args.ccr_check_max_per_scan, 3);
}
#[test]
fn ccr_check_disabled_without_bin() {
let td = TempDir::new().expect("tempdir");
write_success_run(td.path(), "run_0001", 1);
let snapshot = scan_run_root(td.path(), "test").expect("scan");
assert!(!snapshot.ccr_check.enabled);
assert!(!snapshot.ccr_check.up);
assert!(snapshot.ccr_check.last.is_none());
let metrics = render_metrics(&snapshot);
assert!(metrics.contains(r#"ours_rp_ccr_format_check_up{instance="test"} 0"#));
assert!(!metrics.contains("ours_rp_ccr_format_check_last_success"));
}
#[test]
fn ccr_check_pass_records_metrics() {
let td = TempDir::new().expect("tempdir");
write_success_run(td.path(), "run_0001", 1);
let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nexit 0\n");
let snapshot = scan_with_ccr_check(td.path(), None, &bin, 30);
assert!(snapshot.ccr_check.up);
assert_eq!(snapshot.ccr_check.pass_total, 1);
assert_eq!(snapshot.ccr_check.consecutive_failures, 0);
let last = snapshot.ccr_check.last.as_ref().expect("last result");
assert_eq!(last.outcome, "pass");
assert_eq!(last.run_seq, Some(1));
let metrics = render_metrics(&snapshot);
assert!(
metrics.contains(r#"ours_rp_ccr_format_check_total{instance="test",result="pass"} 1"#)
);
assert!(metrics.contains(r#"ours_rp_ccr_format_check_last_success{instance="test"} 1"#));
assert!(
metrics.contains(r#"ours_rp_ccr_format_check_last_run_sequence{instance="test"} 1"#)
);
let status = render_status_json(&snapshot).expect("status");
assert!(status.contains(r#""ccrCheck""#));
assert!(status.contains(r#""passTotal": 1"#));
}
#[test]
fn ccr_check_parse_error_detected_from_output() {
let td = TempDir::new().expect("tempdir");
write_success_run(td.path(), "run_0001", 1);
let bin = write_fake_rpki_client(
td.path(),
"#!/bin/sh\necho \"rpki-client: $2: unexpected number of locations\" >&2\nexit 0\n",
);
let snapshot = scan_with_ccr_check(td.path(), None, &bin, 30);
assert_eq!(snapshot.ccr_check.ccr_parse_error_total, 1);
assert_eq!(snapshot.ccr_check.consecutive_failures, 1);
let last = snapshot.ccr_check.last.as_ref().expect("last result");
assert_eq!(last.outcome, "ccr_parse_error");
assert!(
last.detail
.as_deref()
.unwrap_or_default()
.contains("unexpected number of locations")
);
let metrics = render_metrics(&snapshot);
assert!(metrics.contains(
r#"ours_rp_ccr_format_check_total{instance="test",result="ccr_parse_error"} 1"#
));
assert!(metrics.contains(r#"ours_rp_ccr_format_check_last_success{instance="test"} 0"#));
}
#[test]
fn ccr_check_nonzero_exit_is_tool_error() {
let td = TempDir::new().expect("tempdir");
write_success_run(td.path(), "run_0001", 1);
let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nexit 3\n");
let snapshot = scan_with_ccr_check(td.path(), None, &bin, 30);
assert_eq!(snapshot.ccr_check.tool_error_total, 1);
assert_eq!(
snapshot.ccr_check.last.as_ref().expect("last").outcome,
"tool_error"
);
}
#[test]
fn ccr_check_timeout_is_tool_error() {
let td = TempDir::new().expect("tempdir");
write_success_run(td.path(), "run_0001", 1);
let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nsleep 30\n");
let snapshot = scan_with_ccr_check(td.path(), None, &bin, 1);
let last = snapshot.ccr_check.last.as_ref().expect("last result");
assert_eq!(last.outcome, "tool_error");
assert!(
last.detail
.as_deref()
.unwrap_or_default()
.contains("timed out")
);
}
#[test]
fn ccr_check_startup_only_checks_latest_and_new_runs_once() {
let td = TempDir::new().expect("tempdir");
write_success_run(td.path(), "run_0001", 1);
write_success_run(td.path(), "run_0002", 2);
let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nexit 0\n");
// First scan at service start: only the latest existing run is
// checked, older history is marked as seen without executing checks.
let first = scan_with_ccr_check(td.path(), None, &bin, 30);
assert_eq!(first.ccr_check.pass_total, 1);
assert!(
first
.ccr_check
.last
.as_ref()
.expect("last")
.run_dir
.contains("run_0002")
);
// Second scan: pre-existing history must not be backfilled.
let second = scan_with_ccr_check(td.path(), Some(&first), &bin, 30);
assert_eq!(second.ccr_check.pass_total, 1);
// A newly completed run is checked exactly once.
write_success_run(td.path(), "run_0003", 3);
let third = scan_with_ccr_check(td.path(), Some(&second), &bin, 30);
assert_eq!(third.ccr_check.pass_total, 2);
assert_eq!(
third.ccr_check.last.as_ref().expect("last").run_seq,
Some(3)
);
let fourth = scan_with_ccr_check(td.path(), Some(&third), &bin, 30);
assert_eq!(fourth.ccr_check.pass_total, 2);
}
#[test]
fn ccr_check_missing_bin_marks_not_up() {
let td = TempDir::new().expect("tempdir");
write_success_run(td.path(), "run_0001", 1);
let missing = td.path().join("no-such-rpki-client");
let snapshot = scan_with_ccr_check(td.path(), None, &missing, 30);
assert!(snapshot.ccr_check.enabled);
assert!(!snapshot.ccr_check.up);
assert!(snapshot.ccr_check.last.is_none());
assert!(
snapshot
.service
.parse_errors
.iter()
.any(|error| error.contains("ccr check bin is not a file"))
);
let metrics = render_metrics(&snapshot);
assert!(metrics.contains(r#"ours_rp_ccr_format_check_up{instance="test"} 0"#));
}
fn write_success_run(root: &Path, name: &str, seq: u64) {
let run = root.join("runs").join(name);
fs::create_dir_all(&run).expect("create run");
fs::write(
run.join("run-meta.json"),
format!(
r#"{{"status":"success","run_index":{seq},"run_id":"{name}","sync_mode":"snapshot","snapshot_reason":"first_run","started_at_rfc3339_utc":"2026-07-21T00:00:00Z","completed_at_rfc3339_utc":"2026-07-21T00:00:10Z"}}"#
),
)
.expect("meta");
fs::write(
run.join("run-summary.json"),
format!(
r#"{{"runSeq":{seq},"runId":"{name}","startedAtRfc3339Utc":"2026-07-21T00:00:00Z","finishedAtRfc3339Utc":"2026-07-21T00:00:10Z","wallMs":10000,"status":"success","exitCode":0}}"#
),
)
.expect("summary");
fs::write(run.join("result.ccr"), sample_ccr()).expect("ccr");
}
fn write_fake_rpki_client(root: &Path, body: &str) -> PathBuf {
let path = root.join("fake-rpki-client.sh");
fs::write(&path, body).expect("write fake rpki-client");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
}
path
}
fn scan_with_ccr_check(
root: &Path,
previous: Option<&MetricsSnapshot>,
bin: &Path,
timeout_secs: u64,
) -> MetricsSnapshot {
let config = CcrCheckConfig {
bin: Some(bin.to_path_buf()),
timeout_secs,
max_per_scan: 2,
};
scan_run_root_incremental_with_rtr_dir(root, "test", previous, None, &config).expect("scan")
}
fn sample_cir() -> Vec<u8> {
let rejected = vec![CirRejectedObject {
object_uri: "rsync://repo.example/a/bad.roa".to_string(),
@@ -3302,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];
@@ -180,7 +196,7 @@ pub struct RoaValidationCacheStats {
pub struct RoaValidationCacheInput<'a> {
enabled: bool,
view: Option<&'a RoaValidationCacheView>,
parent_context_digest: Option<[u8; 32]>,
ca_validation_context_digest: Option<[u8; 32]>,
policy_fingerprint: Option<[u8; 32]>,
}
@@ -189,7 +205,7 @@ impl<'a> RoaValidationCacheInput<'a> {
Self {
enabled: false,
view: None,
parent_context_digest: None,
ca_validation_context_digest: None,
policy_fingerprint: None,
}
}
@@ -198,20 +214,20 @@ impl<'a> RoaValidationCacheInput<'a> {
Self {
enabled: true,
view,
parent_context_digest: None,
ca_validation_context_digest: None,
policy_fingerprint: None,
}
}
pub fn enabled_with_context(
view: Option<&'a RoaValidationCacheView>,
parent_context_digest: [u8; 32],
ca_validation_context_digest: [u8; 32],
policy_fingerprint: [u8; 32],
) -> Self {
Self {
enabled: true,
view,
parent_context_digest: Some(parent_context_digest),
ca_validation_context_digest: Some(ca_validation_context_digest),
policy_fingerprint: Some(policy_fingerprint),
}
}
@@ -495,7 +511,7 @@ fn evaluate_roa_cache_crl_gate(
pub struct RoaValidationCacheView {
entries_by_uri: HashMap<String, CachedRoaValidationResult>,
issuer_ca_sha256_hex: Option<String>,
parent_context_digest: Option<[u8; 32]>,
ca_validation_context_digest: Option<[u8; 32]>,
policy_fingerprint: Option<[u8; 32]>,
crl_sha256_by_uri: HashMap<String, String>,
blocked: bool,
@@ -519,7 +535,7 @@ impl RoaValidationCacheView {
let mut entries_by_uri: HashMap<String, CachedRoaValidationResult> =
HashMap::with_capacity(projection.entries.len());
let issuer_ca_sha256_hex = projection.issuer_ca_sha256_hex.clone();
let parent_context_digest = projection.parent_context_digest;
let ca_validation_context_digest = projection.ca_validation_context_digest;
let policy_fingerprint = projection.policy_fingerprint;
let crl_sha256_by_uri = projection
.crl_sha256_by_uri
@@ -536,7 +552,7 @@ impl RoaValidationCacheView {
return Self {
entries_by_uri,
issuer_ca_sha256_hex,
parent_context_digest,
ca_validation_context_digest,
policy_fingerprint,
crl_sha256_by_uri,
blocked,
@@ -580,7 +596,7 @@ impl RoaValidationCacheView {
Self {
entries_by_uri,
issuer_ca_sha256_hex,
parent_context_digest,
ca_validation_context_digest,
policy_fingerprint,
crl_sha256_by_uri,
blocked,
@@ -590,7 +606,7 @@ impl RoaValidationCacheView {
fn matches_current_context(
&self,
issuer_ca_der: &[u8],
parent_context_digest: Option<[u8; 32]>,
ca_validation_context_digest: Option<[u8; 32]>,
policy_fingerprint: Option<[u8; 32]>,
) -> bool {
if self.blocked {
@@ -604,8 +620,8 @@ impl RoaValidationCacheView {
return false;
}
if let Some(expected_parent_context) = self.parent_context_digest {
if Some(expected_parent_context) != parent_context_digest {
if let Some(expected_ca_validation_context) = self.ca_validation_context_digest {
if Some(expected_ca_validation_context) != ca_validation_context_digest {
return false;
}
} else {
@@ -797,7 +813,7 @@ fn active_roa_cache_view<'a>(
let gate_started = Instant::now();
if view.matches_current_context(
issuer_ca_der,
roa_cache.parent_context_digest,
roa_cache.ca_validation_context_digest,
roa_cache.policy_fingerprint,
) {
stats.context_gate_nanos = stats
@@ -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 {
@@ -3914,7 +4051,7 @@ mod tests {
OffsetDateTime::parse(value, &Rfc3339).expect("parse fixed test time")
}
const TEST_PARENT_CONTEXT: [u8; 32] = [0x70; 32];
const TEST_CA_VALIDATION_CONTEXT: [u8; 32] = [0x70; 32];
const TEST_POLICY_FINGERPRINT: [u8; 32] = [0x71; 32];
const TEST_CRL_URI: &str = "rsync://example.test/repo/current.crl";
const TEST_ROA_URI: &str = "rsync://example.test/repo/a.roa";
@@ -3933,7 +4070,7 @@ mod tests {
RoaCacheProjection::from_vcir_with_context(
vcir,
Some(&RoaCacheProjectionContext {
parent_context_digest: TEST_PARENT_CONTEXT,
ca_validation_context_digest: TEST_CA_VALIDATION_CONTEXT,
policy_fingerprint: TEST_POLICY_FINGERPRINT,
object_meta: vec![RoaCacheObjectMeta {
source_object_uri: TEST_ROA_URI.to_string(),
@@ -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 {
@@ -4071,7 +4210,7 @@ mod tests {
assert!(view.matches_current_context(
issuer_der,
Some(TEST_PARENT_CONTEXT),
Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT)
));
let hit = view.lookup(&file, &mut crl_cache, issuer_der, validation_time);
@@ -4202,7 +4341,10 @@ mod tests {
fixed_time("2026-06-08T00:00:00Z"),
);
let projection = sample_roa_cache_projection(&vcir, roa_hash);
assert_eq!(projection.parent_context_digest, Some(TEST_PARENT_CONTEXT));
assert_eq!(
projection.ca_validation_context_digest,
Some(TEST_CA_VALIDATION_CONTEXT)
);
assert_eq!(projection.policy_fingerprint, Some(TEST_POLICY_FINGERPRINT));
assert_eq!(
projection.entries[0].outputs_effective_until_unix,
@@ -4245,7 +4387,7 @@ mod tests {
let issuer_changed = RoaValidationCacheView::from_projection(&projection, validation_time);
assert!(!issuer_changed.matches_current_context(
issuer_der,
Some(TEST_PARENT_CONTEXT),
Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT)
));
@@ -4258,7 +4400,7 @@ mod tests {
));
assert!(!parent_changed.matches_current_context(
issuer_der,
Some(TEST_PARENT_CONTEXT),
Some(TEST_CA_VALIDATION_CONTEXT),
Some([0x98; 32])
));
@@ -4283,7 +4425,7 @@ mod tests {
RoaValidationCacheView::from_projection(&expired_projection, validation_time);
assert!(!expired_view.matches_current_context(
issuer_der,
Some(TEST_PARENT_CONTEXT),
Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT)
));
let mut crl_cache = sample_crl_cache(b"current-crl".to_vec());
@@ -4540,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 {
@@ -4563,7 +4706,7 @@ mod tests {
assert!(view.matches_current_context(
issuer_der,
Some(TEST_PARENT_CONTEXT),
Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT)
));
assert!(matches!(
@@ -4595,43 +4738,43 @@ mod tests {
let mut view = RoaValidationCacheView {
entries_by_uri: HashMap::new(),
issuer_ca_sha256_hex: None,
parent_context_digest: Some(TEST_PARENT_CONTEXT),
ca_validation_context_digest: Some(TEST_CA_VALIDATION_CONTEXT),
policy_fingerprint: Some(TEST_POLICY_FINGERPRINT),
crl_sha256_by_uri: HashMap::new(),
blocked: false,
};
assert!(!view.matches_current_context(
b"issuer-ca",
Some(TEST_PARENT_CONTEXT),
Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT)
));
view.issuer_ca_sha256_hex = Some("00".repeat(32));
assert!(!view.matches_current_context(
b"issuer-ca",
Some(TEST_PARENT_CONTEXT),
Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT)
));
view.issuer_ca_sha256_hex = Some(sha256_hex(b"issuer-ca"));
assert!(view.matches_current_context(
b"issuer-ca",
Some(TEST_PARENT_CONTEXT),
Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT)
));
assert!(!view.matches_current_context(b"issuer-ca", None, Some(TEST_POLICY_FINGERPRINT)));
view.parent_context_digest = None;
view.ca_validation_context_digest = None;
assert!(!view.matches_current_context(
b"issuer-ca",
Some(TEST_PARENT_CONTEXT),
Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT)
));
view.parent_context_digest = Some(TEST_PARENT_CONTEXT);
view.ca_validation_context_digest = Some(TEST_CA_VALIDATION_CONTEXT);
view.policy_fingerprint = None;
assert!(!view.matches_current_context(
b"issuer-ca",
Some(TEST_PARENT_CONTEXT),
Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT)
));
}
@@ -4661,7 +4804,7 @@ mod tests {
let mut view = RoaValidationCacheView {
entries_by_uri: HashMap::new(),
issuer_ca_sha256_hex: Some(sha256_hex(issuer_der)),
parent_context_digest: Some(TEST_PARENT_CONTEXT),
ca_validation_context_digest: Some(TEST_CA_VALIDATION_CONTEXT),
policy_fingerprint: Some(TEST_POLICY_FINGERPRINT),
crl_sha256_by_uri: HashMap::from([(TEST_CRL_URI.to_string(), crl_hash_hex.clone())]),
blocked: false,
@@ -4849,7 +4992,7 @@ mod tests {
false,
RoaValidationCacheInput::enabled_with_context(
Some(&view),
TEST_PARENT_CONTEXT,
TEST_CA_VALIDATION_CONTEXT,
TEST_POLICY_FINGERPRINT,
),
) {
@@ -5459,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 {
@@ -5489,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,
@@ -5508,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)),

View File

@@ -10,6 +10,8 @@ use crate::validation::publication_point::PublicationPointSnapshot;
use std::borrow::Cow;
use std::sync::{Arc, OnceLock};
pub const DEFAULT_MAX_CA_DEPTH: usize = 32;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRunConfig {
/// Max CA instance depth to process (0 = root only).
@@ -37,7 +39,7 @@ pub struct TreeRunConfig {
impl Default for TreeRunConfig {
fn default() -> Self {
Self {
max_depth: None,
max_depth: Some(DEFAULT_MAX_CA_DEPTH),
max_instances: None,
compact_audit: false,
persist_vcir: true,
@@ -51,6 +53,15 @@ impl Default for TreeRunConfig {
}
}
pub(crate) fn ca_depth_is_allowed(config: &TreeRunConfig, depth: usize) -> bool {
config.max_depth.is_none_or(|max_depth| depth <= max_depth)
}
pub(crate) fn next_allowed_ca_depth(config: &TreeRunConfig, parent_depth: usize) -> Option<usize> {
let child_depth = parent_depth.checked_add(1)?;
ca_depth_is_allowed(config, child_depth).then_some(child_depth)
}
#[derive(Clone, Debug)]
pub enum CaCertificateRef {
InlineDer(Vec<u8>),
@@ -347,10 +358,8 @@ pub fn run_tree_serial_audit_multi_root(
}
}
if let Some(max_depth) = config.max_depth {
if ca.depth > max_depth {
continue;
}
if !ca_depth_is_allowed(config, ca.depth) {
continue;
}
let res = match runner.run_publication_point(ca) {
@@ -393,6 +402,9 @@ pub fn run_tree_serial_audit_multi_root(
}
publication_points.push(audit);
let Some(child_depth) = next_allowed_ca_depth(config, ca.depth) else {
continue;
};
let mut children = res.discovered_children;
children.sort_by(|a, b| {
a.handle
@@ -413,7 +425,7 @@ pub fn run_tree_serial_audit_multi_root(
for child in children {
queue.push_back(QueuedCaInstance {
id: next_id,
handle: child.handle.with_depth(ca.depth + 1),
handle: child.handle.with_depth(child_depth),
parent_id: Some(node.id),
discovered_from: Some(child.discovered_from),
});

File diff suppressed because it is too large Load Diff

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