初始化 Panda RPKI v0.1.0 开源候选版本
Some checks failed
ci / rust (push) Has been cancelled
ci / docker (push) Has been cancelled

This commit is contained in:
Panda RPKI OSS Local 2026-09-09 18:01:15 +08:00
commit 277cbca878
203 changed files with 92495 additions and 0 deletions

13
.dockerignore Normal file
View File

@ -0,0 +1,13 @@
.git
.github
target
out
state
tests
docs
!docs/third-party-licenses.txt
docker
input
output
data
*.log

32
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: ci
on:
push:
pull_request:
permissions:
contents: read
jobs:
rust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@6d653ac52d5f748757700b3b0ec16abf11083d92
with:
toolchain: 1.92.0
components: rustfmt, clippy
- run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential clang libclang-dev pkg-config python3-cryptography openssl
- run: cargo fmt --all --check
- run: cargo check --locked --no-default-features
- run: cargo test --locked
- run: cargo clippy --locked --all-targets -- -D warnings
- run: cargo build --locked --release
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: sudo apt-get update && sudo apt-get install -y --no-install-recommends python3-cryptography openssl
- run: docker build -f docker/Dockerfile -t panda-rpki:ci .
- run: docker run --rm panda-rpki:ci --help
- run: bash tests/synthetic_docker_e2e.sh

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
/target/
/out/
/state/
/input/
/output/
/data/
__pycache__/
*.pyc
*.log
.DS_Store

13
CHANGELOG.md Normal file
View File

@ -0,0 +1,13 @@
# Changelog
## 0.1.0 — Unreleased
- RPKI validation with multiple TAL/TA inputs and resource constraints.
- RRDP snapshot/delta synchronization and same-origin enforcement.
- Bounded transport and object worker pools.
- CSV, CCR, JSON summaries and level-controlled text/JSON logs.
- Foreground daemon with intervals, timeouts, restart and retention.
- Docker and Compose deployment, including all-five-anchor configuration.
The CLI accepts HTTPS RRDP. Operators supply TALs and matching certificates.
No RTR server is included. See the standards matrix and CLI reference for scope.

14
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,14 @@
# Contributing
Describe reproducible problems and proposed changes in an issue or pull request.
Include the version, command, expected and actual results. Remove credentials
and sensitive paths from shared logs.
Run [development checks](docs/development.md), keep changes focused, use
`cargo fmt`, and add regression tests for changed behavior. Explain relevant
RFC/WG requirements and update documentation when interfaces change.
Documentation and code comments use English. Do not commit generated repository
objects, keys, state, logs or build outputs. Keep third-party attribution intact.
Submit only code you are entitled to contribute under the [project license](LICENSE)
and identify externally sourced code and its license in the pull request.

2133
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

34
Cargo.toml Normal file
View File

@ -0,0 +1,34 @@
[package]
name = "panda-rpki"
version = "0.1.0"
edition = "2024"
rust-version = "1.92"
description = "A small, strict RFC RPKI validation core"
license = "BSD-3-Clause"
publish = false
[dependencies]
libc = "0.2"
asn1-rs = "0.7.1"
der-parser = { version = "10.0.0", features = ["serialize"] }
hex = "0.4.3"
base64 = "0.22.1"
sha2 = "0.10.8"
thiserror = "2.0.18"
time = "0.3.45"
ring = "0.17.14"
x509-parser = { version = "0.18.0", features = ["verify"] }
url = "2.5.8"
serde = { version = "1.0.218", features = ["derive"] }
serde_json = { version = "1.0.140", features = ["raw_value"] }
toml = "0.8.20"
rocksdb = { version = "0.22.0", default-features = false, features = ["lz4"] }
serde_cbor = "0.11.2"
roxmltree = "0.20.0"
quick-xml = "0.37.2"
uuid = { version = "1.7.0", features = ["v4"] }
reqwest = { version = "0.12.12", default-features = false, features = ["blocking", "rustls-tls", "gzip", "brotli", "deflate"] }
tempfile = "3.16.0"
[lints.rust]
unsafe_code = "warn"

29
LICENSE Normal file
View File

@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2026, Panda RPKI contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

68
README.md Normal file
View File

@ -0,0 +1,68 @@
# Panda RPKI
Panda RPKI is an RPKI relying party written in Rust. It synchronizes repositories
over RRDP, validates signed objects, and exports routing data as CSV and Canonical
Cache Representation (CCR). Run a single cycle or use the daemon for continuous operation.
## Quick start
Requirements: Linux, Docker, and a TAL with its matching DER trust-anchor
certificate. Follow [input preparation](docs/getting-started.md#prepare-trust-anchors)
to obtain these from your chosen RIR. From the project root, after placing them
in `input/anchor.tal` and `input/anchor.cer`:
```bash
docker build -f docker/Dockerfile -t panda-rpki:v0.1.0 .
mkdir -p state output
docker run --rm --read-only --tmpfs /tmp \
--user "$(id -u):$(id -g)" \
-v "$PWD/input:/input:ro" \
-v "$PWD/state:/state" \
-v "$PWD/output:/output" \
panda-rpki:v0.1.0 validate \
--tal /input/anchor.tal --ta /input/anchor.cer \
--rrdp-state-dir /state --out /output \
--ccr-out /output/result.ccr
```
Inspect `output/summary.json` and `output/vrps.csv`. Logs go to stderr.
Reuse the state directory to allow delta updates on subsequent runs.
For continuous operation see [Usage](docs/usage.md#continuous-operation).
## Features
- Multiple TAL/TA pairs and optional per-anchor resource constraints.
- RRDP snapshot, delta, replace/withdraw and protocol fallback.
- HTTPS same-origin checks for RRDP references and redirects.
- Independent bounded repository and object worker pools.
- CA, CRL, manifest, ROA, ASPA and BGPsec router-certificate processing.
- CSV, CCR, JSON summaries and configurable text or JSON logs.
- Daemon intervals, persistent state, run retention, timeouts and graceful stop.
Version 0.1.0 accepts HTTPS RRDP through its CLI. Operators supply TALs and
matching TA certificates. No RTR server is included. See the
[standards matrix](docs/conformance-matrix.md) for profiles and limitations.
## Documentation
- [Getting started](docs/getting-started.md): dependencies, inputs and first run.
- [Five-RIR trust anchors](docs/trust-anchors.md): official TAL/TA downloads and checks.
- [Usage](docs/usage.md): multiple anchors, delta, daemon and troubleshooting.
- [Command-line reference](docs/command-line-reference.md): options and defaults.
- [Docker and Compose](docs/docker.md): single-anchor, all-five and daemon deployments.
- [Development and testing](docs/development.md).
- [Contributing](CONTRIBUTING.md) and [changelog](CHANGELOG.md).
## Build from source
Install Rust 1.92 or newer and the [native dependencies](docs/getting-started.md#build-from-source):
```bash
cargo build --locked --release
target/release/panda-rpki --help
```
## License
Panda RPKI uses the [BSD-3-Clause license](LICENSE).
See [third-party notices](THIRD_PARTY_NOTICES.md) for dependency attribution.

239
THIRD_PARTY_NOTICES.md Normal file
View File

@ -0,0 +1,239 @@
# Third-party notices
Panda RPKI is distributed under BSD-3-Clause; dependencies retain their own
licenses. The table below records the locked Cargo dependency graph, including
build-time and platform-specific packages that may not be linked on Linux.
Upstream license expressions are reproduced as declared by each package.
The accompanying [license texts](docs/third-party-licenses.txt) retain notices
from package archives, including bundled native code. These files must accompany
binary distributions. Container OS package notices remain under
`/usr/share/doc/` and `/usr/share/common-licenses/` in the Debian image.
Python cryptography and OpenSSL are external test tools, not bundled source.
Consult their installed license notices when distributing a test environment.
No production trust-anchor or repository data is included in this source tree.
| Package | Locked version | Declared license |
| --- | --- | --- |
| adler2 | 2.0.1 | 0BSD OR MIT OR Apache-2.0 |
| aho-corasick | 1.1.5 | Unlicense OR MIT |
| alloc-no-stdlib | 2.0.4 | BSD-3-Clause |
| alloc-stdlib | 0.2.4 | BSD-3-Clause |
| asn1-rs | 0.7.2 | MIT OR Apache-2.0 |
| asn1-rs-derive | 0.6.0 | MIT OR Apache-2.0 |
| asn1-rs-impl | 0.2.0 | MIT/Apache-2.0 |
| async-compression | 0.4.44 | MIT OR Apache-2.0 |
| atomic-waker | 1.1.2 | Apache-2.0 OR MIT |
| autocfg | 1.5.1 | Apache-2.0 OR MIT |
| base64 | 0.22.1 | MIT OR Apache-2.0 |
| bindgen | 0.69.5 | BSD-3-Clause |
| bitflags | 2.13.1 | MIT OR Apache-2.0 |
| block-buffer | 0.10.4 | MIT OR Apache-2.0 |
| brotli | 8.0.4 | BSD-3-Clause AND MIT |
| brotli-decompressor | 5.0.3 | BSD-3-Clause/MIT |
| bumpalo | 3.20.3 | MIT OR Apache-2.0 |
| bytes | 1.12.1 | MIT |
| bzip2-sys | 0.1.13+1.0.8 | MIT/Apache-2.0 |
| cc | 1.4.4 | MIT OR Apache-2.0 |
| cexpr | 0.6.0 | Apache-2.0/MIT |
| cfg-if | 1.0.4 | MIT OR Apache-2.0 |
| cfg_aliases | 0.2.2 | MIT |
| chacha20 | 0.10.2 | MIT OR Apache-2.0 |
| clang-sys | 1.9.1 | Apache-2.0 |
| compression-codecs | 0.4.39 | MIT OR Apache-2.0 |
| compression-core | 0.4.33 | MIT OR Apache-2.0 |
| cookie-factory | 0.3.3 | MIT |
| cpufeatures | 0.2.17 | MIT OR Apache-2.0 |
| cpufeatures | 0.3.1 | MIT OR Apache-2.0 |
| crc32fast | 1.5.1 | MIT OR Apache-2.0 |
| crypto-common | 0.1.7 | MIT OR Apache-2.0 |
| data-encoding | 2.11.1 | MIT |
| der-parser | 10.0.0 | MIT OR Apache-2.0 |
| deranged | 0.5.8 | MIT OR Apache-2.0 |
| digest | 0.10.7 | MIT OR Apache-2.0 |
| displaydoc | 0.2.7 | MIT OR Apache-2.0 |
| either | 1.18.0 | MIT OR Apache-2.0 |
| equivalent | 1.0.2 | Apache-2.0 OR MIT |
| errno | 0.3.14 | MIT OR Apache-2.0 |
| fastrand | 2.5.0 | Apache-2.0 OR MIT |
| find-msvc-tools | 0.1.11 | MIT OR Apache-2.0 |
| flate2 | 1.1.10 | MIT OR Apache-2.0 |
| form_urlencoded | 1.2.2 | MIT OR Apache-2.0 |
| futures | 0.3.34 | MIT OR Apache-2.0 |
| futures-channel | 0.3.34 | MIT OR Apache-2.0 |
| futures-core | 0.3.34 | MIT OR Apache-2.0 |
| futures-executor | 0.3.34 | MIT OR Apache-2.0 |
| futures-io | 0.3.34 | MIT OR Apache-2.0 |
| futures-macro | 0.3.34 | MIT OR Apache-2.0 |
| futures-sink | 0.3.34 | MIT OR Apache-2.0 |
| futures-task | 0.3.34 | MIT OR Apache-2.0 |
| futures-util | 0.3.34 | MIT OR Apache-2.0 |
| generic-array | 0.14.7 | MIT |
| getrandom | 0.2.17 | MIT OR Apache-2.0 |
| getrandom | 0.4.3 | MIT OR Apache-2.0 |
| glob | 0.3.4 | MIT OR Apache-2.0 |
| half | 1.8.3 | MIT OR Apache-2.0 |
| hashbrown | 0.17.1 | MIT OR Apache-2.0 |
| hex | 0.4.3 | MIT OR Apache-2.0 |
| http | 1.5.0 | MIT OR Apache-2.0 |
| http-body | 1.1.0 | MIT |
| http-body-util | 0.1.5 | MIT |
| httparse | 1.10.1 | MIT OR Apache-2.0 |
| hyper | 1.11.1 | MIT |
| hyper-rustls | 0.27.9 | Apache-2.0 OR ISC OR MIT |
| hyper-util | 0.1.20 | MIT |
| icu_collections | 2.3.0 | Unicode-3.0 |
| icu_locale_core | 2.3.0 | Unicode-3.0 |
| icu_normalizer | 2.3.0 | Unicode-3.0 |
| icu_normalizer_data | 2.3.0 | Unicode-3.0 |
| icu_properties | 2.3.0 | Unicode-3.0 |
| icu_properties_data | 2.3.0 | Unicode-3.0 |
| icu_provider | 2.3.1 | Unicode-3.0 |
| idna | 1.1.0 | MIT OR Apache-2.0 |
| idna_adapter | 1.2.2 | Apache-2.0 OR MIT |
| indexmap | 2.14.2 | Apache-2.0 OR MIT |
| ipnet | 2.12.1 | MIT OR Apache-2.0 |
| itertools | 0.12.1 | MIT OR Apache-2.0 |
| itoa | 1.0.18 | MIT OR Apache-2.0 |
| jobserver | 0.1.35 | MIT OR Apache-2.0 |
| js-sys | 0.3.104 | MIT OR Apache-2.0 |
| lazy_static | 1.5.0 | MIT OR Apache-2.0 |
| lazycell | 1.3.0 | MIT/Apache-2.0 |
| libc | 0.2.189 | MIT OR Apache-2.0 |
| libloading | 0.8.9 | ISC |
| librocksdb-sys | 0.16.0+8.10.0 | MIT/Apache-2.0/BSD-3-Clause |
| libz-sys | 1.1.29 | MIT OR Apache-2.0 |
| linux-raw-sys | 0.12.1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT |
| litemap | 0.8.3 | Unicode-3.0 |
| log | 0.4.34 | MIT OR Apache-2.0 |
| lru-slab | 0.1.2 | MIT OR Apache-2.0 OR Zlib |
| lz4-sys | 1.11.1+lz4-1.10.0 | MIT |
| memchr | 2.8.3 | Unlicense OR MIT |
| minimal-lexical | 0.2.1 | MIT/Apache-2.0 |
| miniz_oxide | 0.9.1 | MIT OR Zlib OR Apache-2.0 |
| mio | 1.2.3 | MIT |
| nom | 7.1.3 | MIT |
| num-bigint | 0.4.8 | MIT OR Apache-2.0 |
| num-conv | 0.2.2 | MIT OR Apache-2.0 |
| num-integer | 0.1.47 | MIT OR Apache-2.0 |
| num-traits | 0.2.19 | MIT OR Apache-2.0 |
| oid-registry | 0.8.1 | MIT OR Apache-2.0 |
| once_cell | 1.21.4 | MIT OR Apache-2.0 |
| percent-encoding | 2.3.2 | MIT OR Apache-2.0 |
| pin-project-lite | 0.2.17 | Apache-2.0 OR MIT |
| pkg-config | 0.3.34 | MIT OR Apache-2.0 |
| potential_utf | 0.1.6 | Unicode-3.0 |
| powerfmt | 0.2.0 | MIT OR Apache-2.0 |
| proc-macro2 | 1.0.107 | MIT OR Apache-2.0 |
| quick-xml | 0.37.5 | MIT |
| quinn | 0.11.11 | MIT OR Apache-2.0 |
| quinn-proto | 0.11.17 | MIT OR Apache-2.0 |
| quinn-udp | 0.5.15 | MIT OR Apache-2.0 |
| quote | 1.0.47 | MIT OR Apache-2.0 |
| r-efi | 6.0.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later |
| rand | 0.10.2 | MIT OR Apache-2.0 |
| rand_core | 0.10.1 | MIT OR Apache-2.0 |
| rand_pcg | 0.10.2 | MIT OR Apache-2.0 |
| regex | 1.13.1 | MIT OR Apache-2.0 |
| regex-automata | 0.4.18 | MIT OR Apache-2.0 |
| regex-syntax | 0.8.11 | MIT OR Apache-2.0 |
| reqwest | 0.12.28 | MIT OR Apache-2.0 |
| ring | 0.17.14 | Apache-2.0 AND ISC |
| rocksdb | 0.22.0 | Apache-2.0 |
| roxmltree | 0.20.0 | MIT OR Apache-2.0 |
| rustc-hash | 1.1.0 | Apache-2.0/MIT |
| rustc-hash | 2.1.3 | Apache-2.0 OR MIT |
| rusticata-macros | 4.1.0 | MIT/Apache-2.0 |
| rustix | 1.1.4 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT |
| rustls | 0.23.43 | Apache-2.0 OR ISC OR MIT |
| rustls-pki-types | 1.15.1 | MIT OR Apache-2.0 |
| rustls-webpki | 0.103.15 | ISC |
| rustversion | 1.0.23 | MIT OR Apache-2.0 |
| ryu | 1.0.23 | Apache-2.0 OR BSL-1.0 |
| serde | 1.0.229 | MIT OR Apache-2.0 |
| serde_cbor | 0.11.2 | MIT/Apache-2.0 |
| serde_core | 1.0.229 | MIT OR Apache-2.0 |
| serde_derive | 1.0.229 | MIT OR Apache-2.0 |
| serde_json | 1.0.151 | MIT OR Apache-2.0 |
| serde_spanned | 0.6.9 | MIT OR Apache-2.0 |
| serde_urlencoded | 0.7.1 | MIT/Apache-2.0 |
| sha2 | 0.10.9 | MIT OR Apache-2.0 |
| shlex | 1.3.0 | MIT OR Apache-2.0 |
| shlex | 2.0.1 | MIT OR Apache-2.0 |
| simd-adler32 | 0.3.10 | MIT |
| slab | 0.4.12 | MIT |
| smallvec | 1.16.0 | MIT OR Apache-2.0 |
| socket2 | 0.6.5 | MIT OR Apache-2.0 |
| stable_deref_trait | 1.2.1 | MIT OR Apache-2.0 |
| subtle | 2.6.1 | BSD-3-Clause |
| syn | 2.0.119 | MIT OR Apache-2.0 |
| syn | 3.0.4 | MIT OR Apache-2.0 |
| sync_wrapper | 1.0.2 | Apache-2.0 |
| synstructure | 0.13.2 | MIT |
| tempfile | 3.27.0 | MIT OR Apache-2.0 |
| thiserror | 2.0.20 | MIT OR Apache-2.0 |
| thiserror-impl | 2.0.20 | MIT OR Apache-2.0 |
| time | 0.3.55 | MIT OR Apache-2.0 |
| time-core | 0.1.9 | MIT OR Apache-2.0 |
| time-macros | 0.2.32 | MIT OR Apache-2.0 |
| tinystr | 0.8.4 | Unicode-3.0 |
| tinyvec | 1.13.2 | Zlib OR Apache-2.0 OR MIT |
| tinyvec_macros | 0.1.1 | MIT OR Apache-2.0 OR Zlib |
| tokio | 1.53.1 | MIT |
| tokio-rustls | 0.26.4 | MIT OR Apache-2.0 |
| tokio-util | 0.7.19 | MIT |
| toml | 0.8.23 | MIT OR Apache-2.0 |
| toml_datetime | 0.6.11 | MIT OR Apache-2.0 |
| toml_edit | 0.22.27 | MIT OR Apache-2.0 |
| toml_write | 0.1.2 | MIT OR Apache-2.0 |
| tower | 0.5.3 | MIT |
| tower-http | 0.6.11 | MIT |
| tower-layer | 0.3.3 | MIT |
| tower-service | 0.3.3 | MIT |
| tracing | 0.1.44 | MIT |
| tracing-core | 0.1.36 | MIT |
| try-lock | 0.2.5 | MIT |
| typenum | 1.20.1 | MIT OR Apache-2.0 |
| unicode-ident | 1.0.24 | (MIT OR Apache-2.0) AND Unicode-3.0 |
| untrusted | 0.9.0 | ISC |
| url | 2.5.8 | MIT OR Apache-2.0 |
| utf8_iter | 1.0.4 | Apache-2.0 OR MIT |
| uuid | 1.26.0 | Apache-2.0 OR MIT |
| vcpkg | 0.2.15 | MIT/Apache-2.0 |
| version_check | 0.9.5 | MIT/Apache-2.0 |
| want | 0.3.1 | MIT |
| wasi | 0.11.1+wasi-snapshot-preview1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT |
| wasm-bindgen | 0.2.127 | MIT OR Apache-2.0 |
| wasm-bindgen-futures | 0.4.77 | MIT OR Apache-2.0 |
| wasm-bindgen-macro | 0.2.127 | MIT OR Apache-2.0 |
| wasm-bindgen-macro-support | 0.2.127 | MIT OR Apache-2.0 |
| wasm-bindgen-shared | 0.2.127 | MIT OR Apache-2.0 |
| web-sys | 0.3.104 | MIT OR Apache-2.0 |
| web-time | 1.1.0 | MIT OR Apache-2.0 |
| webpki-roots | 1.0.9 | CDLA-Permissive-2.0 |
| windows-link | 0.2.1 | MIT OR Apache-2.0 |
| windows-sys | 0.52.0 | MIT OR Apache-2.0 |
| windows-sys | 0.61.2 | MIT OR Apache-2.0 |
| windows-targets | 0.52.6 | MIT OR Apache-2.0 |
| windows_aarch64_gnullvm | 0.52.6 | MIT OR Apache-2.0 |
| windows_aarch64_msvc | 0.52.6 | MIT OR Apache-2.0 |
| windows_i686_gnu | 0.52.6 | MIT OR Apache-2.0 |
| windows_i686_gnullvm | 0.52.6 | MIT OR Apache-2.0 |
| windows_i686_msvc | 0.52.6 | MIT OR Apache-2.0 |
| windows_x86_64_gnu | 0.52.6 | MIT OR Apache-2.0 |
| windows_x86_64_gnullvm | 0.52.6 | MIT OR Apache-2.0 |
| windows_x86_64_msvc | 0.52.6 | MIT OR Apache-2.0 |
| winnow | 0.7.15 | MIT |
| writeable | 0.6.4 | Unicode-3.0 |
| x509-parser | 0.18.1 | MIT OR Apache-2.0 |
| yoke | 0.8.3 | Unicode-3.0 |
| yoke-derive | 0.8.2 | Unicode-3.0 |
| zerofrom | 0.1.8 | Unicode-3.0 |
| zerofrom-derive | 0.1.7 | Unicode-3.0 |
| zeroize | 1.9.0 | Apache-2.0 OR MIT |
| zerotrie | 0.2.5 | Unicode-3.0 |
| zerovec | 0.11.8 | Unicode-3.0 |
| zerovec-derive | 0.11.6 | Unicode-3.0 |
| zlib-rs | 0.6.7 | Zlib |
| zmij | 1.0.23 | MIT |

22
docker/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM rust:1.92-bookworm AS build
WORKDIR /src
RUN apt-get update \
&& apt-get install -y --no-install-recommends clang libclang-dev \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && printf 'fn main() {}\n' > src/main.rs && cargo build --locked --release
COPY src ./src
RUN touch src/main.rs src/lib.rs && cargo build --locked --release
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates rsync time \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --home-dir /var/lib/panda-rpki panda
COPY --from=build /src/target/release/panda-rpki /usr/local/bin/panda-rpki
COPY LICENSE /usr/share/licenses/panda-rpki/LICENSE
COPY THIRD_PARTY_NOTICES.md /usr/share/licenses/panda-rpki/THIRD_PARTY_NOTICES.md
COPY docs/third-party-licenses.txt /usr/share/licenses/panda-rpki/docs/third-party-licenses.txt
USER panda
ENTRYPOINT ["/usr/local/bin/panda-rpki"]
CMD ["--help"]

62
docker/compose.all5.yaml Normal file
View File

@ -0,0 +1,62 @@
services:
validator:
build:
context: ..
dockerfile: docker/Dockerfile
image: panda-rpki:v0.1.0
user: "${PUID:-1000}:${PGID:-1000}"
read_only: true
tmpfs:
- /tmp
volumes:
- ${INPUT_DIR:?set INPUT_DIR to the TAL/TA directory}:/input:ro
- ${OUTPUT_DIR:?set OUTPUT_DIR to a writable output directory}:/output
- ${STATE_DIR:?set STATE_DIR to a writable state directory}:/state
command:
- validate
- --tal
- /input/${AFRINIC_TAL_FILE:?set AFRINIC_TAL_FILE}
- --ta
- /input/${AFRINIC_TA_FILE:?set AFRINIC_TA_FILE}
- --tal-id
- afrinic
- --tal
- /input/${APNIC_TAL_FILE:?set APNIC_TAL_FILE}
- --ta
- /input/${APNIC_TA_FILE:?set APNIC_TA_FILE}
- --tal-id
- apnic
- --tal
- /input/${ARIN_TAL_FILE:?set ARIN_TAL_FILE}
- --ta
- /input/${ARIN_TA_FILE:?set ARIN_TA_FILE}
- --tal-id
- arin
- --tal
- /input/${LACNIC_TAL_FILE:?set LACNIC_TAL_FILE}
- --ta
- /input/${LACNIC_TA_FILE:?set LACNIC_TA_FILE}
- --tal-id
- lacnic
- --tal
- /input/${RIPE_TAL_FILE:?set RIPE_TAL_FILE}
- --ta
- /input/${RIPE_TA_FILE:?set RIPE_TA_FILE}
- --tal-id
- ripe
- --parallel-phase2-object-workers
- "${PANDA_RPKI_WORKERS:-8}"
- --parallel-max-repo-sync-workers-global
- "${PANDA_RPKI_REPO_WORKERS:-8}"
- --parallel-phase2-worker-queue-capacity
- "${PANDA_RPKI_WORKER_QUEUE_CAPACITY:-256}"
- --rrdp-state-dir
- /state
- --rrdp-sync-mode
- "${PANDA_RPKI_RRDP_SYNC_MODE:-auto}"
- --http-timeout-secs
- "${PANDA_RPKI_HTTP_TIMEOUT_SECS:-600}"
- --ccr-out
- /output/all5.ccr
- --out
- /output

View File

@ -0,0 +1,40 @@
services:
validator:
build:
context: ..
dockerfile: docker/Dockerfile
image: panda-rpki:v0.1.0
user: "${PUID:-1000}:${PGID:-1000}"
read_only: true
init: true
restart: unless-stopped
stop_grace_period: 40s
tmpfs:
- /tmp
volumes:
- ${INPUT_DIR:?set INPUT_DIR to the TAL/TA directory}:/input:ro
- ${DAEMON_DIR:?set DAEMON_DIR to a writable dedicated directory}:/data
environment:
PANDA_RPKI_LOG_LEVEL: "${PANDA_RPKI_LOG_LEVEL:-info}"
PANDA_RPKI_LOG_FORMAT: "${PANDA_RPKI_LOG_FORMAT:-json}"
command:
- daemon
- --state-root
- /data
- --interval-secs
- "${PANDA_RPKI_INTERVAL_SECS:-60}"
- --retain-runs
- "${PANDA_RPKI_RETAIN_RUNS:-10}"
- --run-timeout-secs
- "${PANDA_RPKI_RUN_TIMEOUT_SECS:-0}"
- --shutdown-grace-secs
- "30"
- --
- --tal
- /input/${TAL_FILE:?set TAL_FILE}
- --ta
- /input/${TA_FILE:?set TA_FILE}
- --parallel-max-repo-sync-workers-global
- "${PANDA_RPKI_REPO_WORKERS:-8}"
- --parallel-phase2-object-workers
- "${PANDA_RPKI_WORKERS:-8}"

28
docker/compose.yaml Normal file
View File

@ -0,0 +1,28 @@
services:
validator:
build:
context: ..
dockerfile: docker/Dockerfile
image: panda-rpki:v0.1.0
user: "${PUID:-1000}:${PGID:-1000}"
read_only: true
tmpfs:
- /tmp
volumes:
- ${INPUT_DIR:?set INPUT_DIR to the TAL/TA directory}:/input:ro
- ${OUTPUT_DIR:?set OUTPUT_DIR to a writable output directory}:/output
- ${STATE_DIR:?set STATE_DIR to a writable state directory}:/state
command:
- validate
- --tal
- /input/${TAL_FILE:?set TAL_FILE}
- --ta
- /input/${TA_FILE:?set TA_FILE}
- --rrdp-state-dir
- /state
- --rrdp-sync-mode
- "${PANDA_RPKI_RRDP_SYNC_MODE:-auto}"
- --ccr-out
- /output/result.ccr
- --out
- /output

View File

@ -0,0 +1,139 @@
# Command-line reference
This reference covers v0.1.0. `panda-rpki --help` displays version and usage;
there is no separate `--version` option.
## Syntax
```text
panda-rpki validate --tal FILE --ta FILE --out DIRECTORY [OPTIONS]
panda-rpki daemon --state-root DIRECTORY [DAEMON OPTIONS] -- [VALIDATION OPTIONS]
```
Use `--name value`, not `--name=value`. Only help has a short alias (`-h`).
Values are case-sensitive; options cannot repeat unless listed as repeatable.
Paths are relative to the process working directory. No configuration-file
option is provided. No arguments or a help request exits successfully.
## Inputs and outputs
| Option | Default / requirement | Repeatable | Meaning |
| --- | --- | --- | --- |
| `--tal <file>` | Required | Yes | Local TAL file. |
| `--ta <file>` | Required | Yes | Matching DER trust-anchor certificate. |
| `--tal-id <id>` | TAL filename stem | Yes | Anchor identifier; if supplied, provide one for every TAL. |
| `--ta-constraints <tal-id>=<file>` | Adjacent `.constraints` if present | Yes | Override constraints for a known TAL ID; each ID may occur once. |
| `--out <directory>` | Required | No | Output directory, created if absent. Existing output files may be overwritten. |
| `--ccr-out <file>` | Disabled | No | CCR DER path, relative to the working directory, not `--out`. |
TAL and TA lists are paired by occurrence order and must have equal lengths.
Use distinct TAL IDs. For `input/anchor.tal`, automatic discovery checks
`input/anchor.constraints`. If absent with no explicit path, no additional
constraints are loaded for that anchor.
## State and network
| Option | Default | Meaning |
| --- | --- | --- |
| `--rrdp-state-dir <directory>` | `<out>/.state` | Dedicated persistent root; database is stored in `repository-db/`. |
| `--rrdp-sync-mode <auto\|snapshot\|delta>` | `auto` | Starting state requirements described below. |
| `--http-timeout-secs <n>` | `300` | HTTP timeout seconds; nonnegative with runtime minimum 1. Connection, retry and large-object transport policies also apply. Not a whole-run deadline. |
| `--http-root-cert <pem>` | None | Repeatable extra HTTPS roots; system roots and hostname verification remain enabled. |
| `--max-ca-depth <n>` | `64` | Nonnegative CA traversal depth limit. |
| Mode | Starting state | Behavior |
| --- | --- | --- |
| `auto` | Empty or populated database | Select snapshot, delta or noop per repository. |
| `snapshot` | Missing or empty database | Start fresh; reject a populated database. |
| `delta` | Existing populated database | Resume state, allowing protocol snapshot fallback and new-repository snapshots. |
Unrecognized state layouts and unsupported database schemas are rejected.
Use a new dedicated directory for an incompatible format. State includes
repository objects, RRDP sessions and manifest anti-rollback metadata. Do not
share a writable database between processes. Changing `--out` changes default
state location unless `--rrdp-state-dir` is explicit.
RRDP references and redirects enforce HTTPS and same-origin rules. Extra
trust roots do not relax origin checks. No separate rsync timeout is exposed.
## Workers and queues
| Option | Default | Meaning |
| --- | ---: | --- |
| `--parallel-max-repo-sync-workers-global <n>` | 8 | Global repository transport workers. |
| `--parallel-phase2-object-workers <n>` | 8 | Object workers, independent of transport. |
| `--parallel-phase2-worker-queue-capacity <n>` | 256 | Object-worker queue capacity. |
| `--parallel-repo-worker-queue-capacity <n>` | 256 | Pending repository results; effective limit is `max(n, 1024)`. |
All values must be positive. Budgets are shared across TALs. Higher concurrency
can increase memory consumption and does not guarantee proportional speedup.
## Logging
| Option | Default | Values |
| --- | --- | --- |
| `--log-level <level>` | `info` | `off`, `error`, `warn`, `info`, `debug`, `trace` |
| `--log-format <format>` | `text` | `text`, `json` |
Precedence: explicit option, then `PANDA_RPKI_LOG_LEVEL` or
`PANDA_RPKI_LOG_FORMAT`, then default. Each level includes more severe messages.
`off` suppresses logs but does not change exit codes. JSON logs contain
`timestamp`, `level`, `event`, and `fields`, one object per line. Logs use stderr;
help uses stdout; validation results are written to files.
Other `PANDA_RPKI_*` variables in [Compose](docker.md) are template substitutions,
not configuration variables read directly by the binary.
## Daemon
Daemon runs in the foreground, launching this executable's `validate` command
in a separate process per cycle. Pass validator options after `--`, without
the word `validate`. The controller owns `--out`, `--ccr-out`,
`--rrdp-state-dir` and `--rrdp-sync-mode`; do not pass them after `--`.
| Option | Default | Meaning |
| --- | --- | --- |
| `--state-root <directory>` | Required | Dedicated root for state, lifecycle metadata and run outputs. |
| `--interval-secs <n>` | 60 | Wait after run recording and retention; zero starts the next run immediately. |
| `--max-runs <n>` | Unlimited | Positive attempt count for this invocation, including failed attempts. |
| `--retain-runs <n>` | 10 | Positive number of completed run directories retained, including recorded failures. Older completed directories are deleted. |
| `--run-timeout-secs <n>` | 0 | Whole-run deadline; zero disables. Sends SIGTERM to the child group, then SIGKILL after two seconds if needed. |
| `--shutdown-grace-secs <n>` | 30 | On SIGINT/SIGTERM, stop starting runs and wait at most n seconds before terminating the active process group. |
Options cannot repeat. Seconds are nonnegative. Empty state starts with
snapshot, successful continuation requests delta, and recovery after a failed
run with existing state requests auto. Actual operations can include noop and
snapshot fallback. Normal child failures/timeouts are recorded and retried;
corrupt lifecycle metadata or inability to record state stops the controller.
Restart continues numbering and reuses state. Retention leaves incomplete,
unrelated and symlink run directories untouched. It does not trim history JSONL.
Do not remove a live lock file. After SIGKILL, ensure the old validator child
has stopped before restarting. Stop the entire container for Docker deployments.
## Outputs and exit status
| File | Content |
| --- | --- |
| `summary.json` | TAL, worker, validation and actual RRDP operation counts. |
| `vrps.csv` | ROA payloads: `asn,prefix,max_length`. |
| `vaps.csv` | ASPA payloads. |
| `stage-timing.json`, `analysis/timing.json` | Timing and analysis counters. |
| `.vrps-source.csv` | Intermediate CSV used to produce `vrps.csv`. |
| Requested CCR path | Optional CCR DER; automatic per run in daemon mode. |
Daemon adds `daemon.lock`, `daemon-status.json`, `lifecycle.json`,
`run-summary.jsonl`, `state/repository-db/`, and `runs/run_000001/` etc.
Each run includes validator outputs, `run-meta.json`, `run-summary.json`,
`stdout.log`, `stderr.log` and optional `process-time.txt`. `/usr/bin/time`
provides user/system CPU and peak RSS when installed; Docker includes it.
Controller states: `starting`, `running`, `sleeping`, `exited`, `failed`.
Help and successful validation return 0; argument and propagated runtime
errors return 2. Completion can include rejected objects or recoverable
repository failures: inspect warnings and outputs too. Failed runs can leave
partial output; use a separate output directory per cycle.
A bounded daemon returns 2 if any attempt failed, otherwise 0. Stopping during
an interval exits normally. A forcibly terminated current run is recorded as
failed and causes exit 2.

View File

@ -0,0 +1,21 @@
# v0.1.0 conformance matrix
| Behaviour | Standard reference | Implementation | Evidence |
| --- | --- | --- | --- |
| TAL syntax and TA public-key binding | RFC 8630 §2 | `src/validation/from_tal.rs`, `src/model/ta.rs` | parser and synthetic integration tests |
| TA and issued CA profile/signature/resource checks | RFC 6487 §4 | `src/validation/`, `src/model/` | profile unit tests and synthetic integration tests |
| Manifest, manifest file hashes, CRL freshness, and ROA checks | RFC 6486, RFC 6487, RFC 6482 | `src/validation/`, `src/model/` | RRDP unit tests and self-contained `tests/synthetic_docker_e2e.sh` |
| RRDP notification, snapshot, delta/replace/withdraw and fallback | RFC 8182 §§3.43.5 | `src/repository/sync/rrdp.rs`, `src/repository/sync/repo.rs` | snapshot/delta state-restart unit tests and synthetic Docker tests |
| RRDP direct-reference and redirect origin checks | RFC 9674 §3.2 | `src/repository/fetch/http.rs`, `src/repository/sync/rrdp.rs` | cross-origin unit tests |
| Repeated TAL/TA pairing and stable aggregate output | RFC 8630 §2; RFC 6813 data model | `src/cli/mod.rs`, `src/cli/validate.rs`, `src/runtime/` | CLI parser checks; locked multi-TAL E2E |
| Bounded repository and object workers (default 8 each, shared across TALs) | operational support for the standard validation pipeline | `src/scheduler/`, `src/runtime/` | worker-pool unit tests |
| Resumable RRDP protocol state | RFC 8182 §3.4 | `src/repository/storage/`, `src/repository/sync/rrdp.rs` | atomic state index and restart delta test |
| CCR manifest, ROA, ASPA, trust-anchor and router-key states | `draft-ietf-sidrops-rpki-ccr-11` §§24 | `src/ccr/` | encode/decode tests and integration artifacts |
| TA Constraints rule normalization and EE resource checks | `draft-ietf-sidrops-constraining-rpki-trust-anchors-01` §§34 | `src/ta_constraints.rs`, `src/validation/` | parser/normalization tests; constrained validation E2E |
| VRP CSV output | RFC 6810 data semantics | `src/cli/validate.rs`, `src/runtime/` | self-contained Docker E2E CSV/summary assertions |
The worker pool and interval index are implementation mechanisms, not new wire
protocols; their tests must also prove deterministic output and unchanged
validation decisions. Only the table entries above are in v0.1.0 scope.
Adding a new source file or protocol feature requires a new row with a document
revision, section, test, and reviewer decision.

33
docs/development.md Normal file
View File

@ -0,0 +1,33 @@
# Development and testing
Use Rust 1.92 or newer on Linux and the [native dependencies](getting-started.md).
Tests additionally need Python 3, Python `cryptography`, and OpenSSL's CLI.
```bash
cargo fmt --all --check
cargo check --locked --no-default-features
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
cargo build --locked --release
bash tests/synthetic_docker_e2e.sh
```
Synthetic tests generate fresh keys, TALs, certificates and an HTTPS RRDP
repository. They cover snapshot, actual delta, persistence, daemon restart and
outputs without public RIR requests. Docker tests require Linux host networking.
Generated keys and outputs are temporary and unsuitable as production inputs.
## Code organization
- `src/cli/` parses arguments; `src/runtime/` coordinates validation.
- `src/repository/` handles transport, RRDP and persistence.
- `src/scheduler/` coordinates bounded workers.
- `src/model/` and `src/validation/` parse and validate RPKI objects.
- `src/ccr/` exports canonical state; `src/ta_constraints/` evaluates constraints.
- `src/daemon/` schedules runs; logging/output modules provide diagnostics.
- `tests/` contains integration tests and generated fixtures.
- `docker/` contains container and Compose deployment files.
Internal Rust modules are not a stable library API. The supported interface is
the CLI and documented output files. Add behavior tests and update documentation
when changing these interfaces.

84
docs/docker.md Normal file
View File

@ -0,0 +1,84 @@
# Docker and Compose
Run commands from the project root. A Linux Docker engine is required for the
documented deployment and integration tests.
```bash
docker build -f docker/Dockerfile -t panda-rpki:v0.1.0 .
```
The image defaults to an unprivileged user and includes CA certificates,
`rsync` and `/usr/bin/time`. Compose uses a read-only root filesystem and a
temporary `/tmp`. Host input mounts are read-only; state and outputs are writable.
## Single anchor
```bash
mkdir -p state output
export INPUT_DIR="$PWD/input" STATE_DIR="$PWD/state" OUTPUT_DIR="$PWD/output"
export TAL_FILE=anchor.tal TA_FILE=anchor.cer
export PUID="$(id -u)" PGID="$(id -g)"
docker compose -f docker/compose.yaml run --build --rm validator
```
Supply absolute host paths as above. Relative Compose paths are resolved from
the Compose file's directory, which is `docker/`. Build context is explicitly
the project root. `PANDA_RPKI_RRDP_SYNC_MODE` defaults to `auto`.
## All five regional anchors
Follow [Prepare all five RIR trust anchors](trust-anchors.md) to download and
check each TAL and matching certificate under `input/`, then run:
```bash
mkdir -p state output
export INPUT_DIR="$PWD/input" STATE_DIR="$PWD/state" OUTPUT_DIR="$PWD/output"
export PUID="$(id -u)" PGID="$(id -g)"
export AFRINIC_TAL_FILE=afrinic.tal AFRINIC_TA_FILE=afrinic.cer
export APNIC_TAL_FILE=apnic.tal APNIC_TA_FILE=apnic.cer
export ARIN_TAL_FILE=arin.tal ARIN_TA_FILE=arin.cer
export LACNIC_TAL_FILE=lacnic.tal LACNIC_TA_FILE=lacnic.cer
export RIPE_TAL_FILE=ripe.tal RIPE_TA_FILE=ripe.cer
docker compose -f docker/compose.all5.yaml run --build --rm validator
```
| Template variable | Default | Applies to |
| --- | --- | --- |
| `PANDA_RPKI_WORKERS` | 8 | all5 and daemon object workers |
| `PANDA_RPKI_REPO_WORKERS` | 8 | all5 and daemon transport workers |
| `PANDA_RPKI_WORKER_QUEUE_CAPACITY` | 256 | all5 object queue |
| `PANDA_RPKI_HTTP_TIMEOUT_SECS` | 600 | all5 HTTP timeout (native CLI default is 300) |
| `PANDA_RPKI_RRDP_SYNC_MODE` | auto | single-anchor and all5 |
The all5 CCR is `OUTPUT_DIR/all5.ccr`; the single-anchor CCR is
`OUTPUT_DIR/result.ccr`. Reuse `STATE_DIR` and select new `OUTPUT_DIR` values
to retain multiple cycles. These templates take no bundled RIR input files.
## Daemon service
```bash
mkdir -p data/daemon
export INPUT_DIR="$PWD/input" DAEMON_DIR="$PWD/data/daemon"
export TAL_FILE=anchor.tal TA_FILE=anchor.cer
export PUID="$(id -u)" PGID="$(id -g)"
export PANDA_RPKI_INTERVAL_SECS=600 PANDA_RPKI_RETAIN_RUNS=10
docker compose -f docker/compose.daemon.yaml up -d --build
docker compose -f docker/compose.daemon.yaml logs -f validator
# Stop gracefully; persistent host data remains available.
docker compose -f docker/compose.daemon.yaml stop
```
Defaults: interval 60 seconds, retain 10, `PANDA_RPKI_RUN_TIMEOUT_SECS=0`
(disabled), log level `info`, log format `json`. The daemon template passes
`PANDA_RPKI_LOG_LEVEL` and `PANDA_RPKI_LOG_FORMAT` into the container.
It restarts unless stopped and allows 40 seconds for shutdown, exceeding the
controller's default 30-second grace plus termination wait.
For multiple anchors, append TAL/TA pairs after `--` in the daemon service's
command. For other validator options, edit that same list. A finite run can
be invoked with `docker compose run --rm validator daemon ...` using the
[daemon CLI](command-line-reference.md#daemon); use `--max-runs` before `--`.
Compose variables are expanded into arguments; only the two documented log
environment variables are read directly by the binary. For single-anchor/all5
logs, pass them with `docker compose run -e PANDA_RPKI_LOG_LEVEL=debug ...`.

66
docs/getting-started.md Normal file
View File

@ -0,0 +1,66 @@
# Getting started
Panda RPKI v0.1.0 is a Linux command-line application. You can use Docker without
installing Rust, or build a native binary. It requires outbound HTTPS to RPKI
repositories, a writable state directory, and enough disk space for repository
objects and outputs. Resource use depends on the selected trust anchors.
## Prepare trust anchors
A trust anchor defines what you trust. Obtain its TAL from the responsible RIR
and obtain the current DER certificate from a certificate URI in that TAL.
Panda RPKI checks that the certificate matches the TAL public key. It takes
both files as explicit inputs; it does not refresh the supplied TA file for you.
For example, the [RIPE NCC trust-anchor page](https://www.ripe.net/manage-ips-and-asns/resource-management/rpki/ripe-ncc-rpki-trust-anchor-structure/)
publishes the RIPE NCC TAL. From the project root:
```bash
mkdir -p input
curl --fail --location --proto '=https' --proto-redir '=https' \
--connect-timeout 15 --max-time 120 \
https://tal.rpki.ripe.net/ripe-ncc.tal -o input/anchor.tal
curl --fail --location --proto '=https' --proto-redir '=https' \
--connect-timeout 15 --max-time 120 \
https://rpki.ripe.net/ta/ripe-ncc-ta.cer -o input/anchor.cer
```
Check the TAL's current certificate URI before downloading. The
[RIR trust-anchor directory](https://www.ripe.net/manage-ips-and-asns/resource-management/rpki/rir-trust-anchor-statistics/)
links the other regional anchors. Use their official TALs and corresponding
certificate locations when preparing a multiple-anchor deployment. Store inputs
outside version control, keep TA certificates current, and retain local policy
files alongside the appropriate TALs. For all five RIRs, use the complete
[five-RIR input guide](trust-anchors.md), including official sources, download
commands, input checks, and the filenames used by Compose.
## Docker first run
Follow the [README Quick start](../README.md#quick-start). It creates
`output/summary.json`, `output/vrps.csv` and `output/result.ccr`. State is retained
under `state/repository-db/`. View warnings in the terminal and inspect the
summary to distinguish rejected objects from successful repository updates.
## Build from source
Install Rust 1.92 or newer. On Debian/Ubuntu, native build dependencies include:
```bash
sudo apt-get update
sudo apt-get install --no-install-recommends build-essential clang libclang-dev \
pkg-config ca-certificates rsync time
cargo build --locked --release
target/release/panda-rpki --help
```
Run with the same inputs:
```bash
target/release/panda-rpki validate \
--tal input/anchor.tal --ta input/anchor.cer \
--rrdp-state-dir state --out output/native \
--ccr-out output/native/result.ccr
```
For tests, install `python3-cryptography` and `openssl`. See
[Development](development.md) and [Usage](usage.md) for next steps.

42111
docs/third-party-licenses.txt Normal file

File diff suppressed because it is too large Load Diff

148
docs/trust-anchors.md Normal file
View File

@ -0,0 +1,148 @@
# Prepare all five RIR trust anchors
Panda RPKI requires a TAL and a matching DER-encoded TA certificate for each
anchor. The TAL supplies the trusted public key; the certificate supplies the
current trust-anchor certificate. A certificate alone is not a substitute for
an independently obtained TAL. No RIR inputs are bundled with this project.
## Official sources
Use the five ordinary production anchors below. Separate AS0 and test anchors
are not part of this all-five example. Review the applicable RIR terms before
using their services, including the [ARIN TAL page](https://www.arin.net/resources/manage/rpki/tal/)
and its linked [Relying Party Agreement](https://www.arin.net/resources/manage/rpki/rpa.pdf).
The project's BSD license does not replace those terms.
| RIR | Official TAL download | TA certificate location | Local files |
| --- | --- | --- | --- |
| AFRINIC | [afrinic.tal](https://rpki.afrinic.net/tal/afrinic.tal) | `https://rpki.afrinic.net/repository/AfriNIC.cer` | `afrinic.tal`, `afrinic.cer` |
| APNIC | [apnic.tal](https://tal.apnic.net/apnic.tal) | `rsync://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer` | `apnic.tal`, `apnic.cer` |
| ARIN | [arin.tal](https://www.arin.net/resources/manage/rpki/arin.tal) | `https://rrdp.arin.net/arin-rpki-ta.cer` | `arin.tal`, `arin.cer` |
| LACNIC | [lacnic.tal (official file endpoint)](https://www.lacnic.net/innovaportal/file/4983/1/lacnic.tal) | `https://rrdp.lacnic.net/ta/rta-lacnic-rpki.cer` | `lacnic.tal`, `lacnic.cer` |
| RIPE NCC | [ripe-ncc.tal](https://tal.rpki.ripe.net/ripe-ncc.tal) | `https://rpki.ripe.net/ta/ripe-ncc-ta.cer` | `ripe.tal`, `ripe.cer` |
Further official references: [APNIC TAL archive](https://www.apnic.net/community/security/resource-certification/tal-archive/),
[LACNIC trust anchors](https://www.lacnic.net/4984/2/lacnic/rpki-rpki-trust-anchor),
and [RIPE NCC trust-anchor structure](https://www.ripe.net/manage-ips-and-asns/resource-management/rpki/ripe-ncc-rpki-trust-anchor-structure/).
Locations can change: inspect the downloaded TAL before using a certificate URL
from this table. Do not rewrite TAL public keys or invent certificate URLs.
## Download the TAL files
Requirements: Bash, curl, OpenSSL, and rsync. On Debian/Ubuntu:
```bash
sudo apt-get install --no-install-recommends ca-certificates curl openssl rsync
```
Run from the project root, in a Bash shell. Use a fresh input directory when
updating an existing deployment; the commands below overwrite matching filenames.
```bash
set -euo pipefail
mkdir -p input
fetch_https() {
curl --fail --show-error --location \
--proto '=https' --proto-redir '=https' \
--connect-timeout 15 --max-time 120 --retry 2 --retry-max-time 300 \
"$1" --output "$2"
}
fetch_https https://rpki.afrinic.net/tal/afrinic.tal input/afrinic.tal
fetch_https https://tal.apnic.net/apnic.tal input/apnic.tal
fetch_https https://www.arin.net/resources/manage/rpki/arin.tal input/arin.tal
fetch_https https://www.lacnic.net/innovaportal/file/4983/1/lacnic.tal input/lacnic.tal
fetch_https https://tal.rpki.ripe.net/ripe-ncc.tal input/ripe.tal
```
An HTTP 200 response does not prove that a file is a TAL. During a documentation
check on 2026-09-09, LACNIC's short download URL
`https://www.lacnic.net/rpki/lacnic.tal` returned an HTML website instead;
the official file endpoint used above returned a TAL. If either download
stops working, open the official LACNIC trust-anchor page above and
save its **ordinary production TAL** text as `input/lacnic.tal`: URI lines,
one blank line, then the complete Base64 public key. Do not copy the separate
AS0 TAL, page markup, or a certificate-derived key. If the official TAL text is
unavailable, stop and obtain it from LACNIC; do not substitute an arbitrary mirror.
Check all five TALs before downloading certificates:
```bash
for rir in afrinic apnic arin lacnic ripe; do
tal="input/$rir.tal"
if grep -Eiq '<!doctype|<html' "$tal"; then
echo "HTML received instead of a TAL: $tal" >&2
exit 1
fi
grep -E '^(https|rsync)://' "$tal"
awk '
{ sub(/\r$/, "") }
/^#/ { next }
/^(https|rsync):\/\// { uri=1; next }
/^[[:space:]]*$/ { if (uri) key=1; next }
key { printf "%s", $0 }
' "$tal" | tr -d '[:space:]' | openssl base64 -d -A \
| openssl pkey -pubin -inform DER -noout
done
```
## Download the TA certificates
After checking that the URI lines match the table, run in the same Bash shell
(which defines `fetch_https`):
```bash
fetch_https https://rpki.afrinic.net/repository/AfriNIC.cer input/afrinic.cer
timeout 120 rsync --timeout=60 --contimeout=15 \
rsync://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer input/apnic.cer
fetch_https https://rrdp.arin.net/arin-rpki-ta.cer input/arin.cer
fetch_https https://rrdp.lacnic.net/ta/rta-lacnic-rpki.cer input/lacnic.cer
fetch_https https://rpki.ripe.net/ta/ripe-ncc-ta.cer input/ripe.cer
```
APNIC's TAL currently lists only rsync, which requires outbound TCP port 873
for this bootstrap step. This does not change Panda RPKI's HTTPS RRDP sync
mode. APNIC also serves the certificate at the following HTTPS endpoint,
which can be used when port 873 is blocked; this is an alternative download
endpoint, not an HTTPS URI present in the current APNIC TAL:
```bash
fetch_https https://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer input/apnic.cer
```
Always verify the downloaded certificate against the independently obtained
TAL, including when using this alternative endpoint.
## Check the pairs and run
The following checks DER parsing, expiration, and equality of the certificate's
SubjectPublicKeyInfo with the TAL key. They are input sanity checks, not a full
RPKI profile or chain validation; Panda RPKI performs its validation during a run.
```bash
for rir in afrinic apnic arin lacnic ripe; do
openssl x509 -inform DER -in "input/$rir.cer" -noout -subject -dates
openssl x509 -inform DER -in "input/$rir.cer" -noout -checkend 0
tal_key=$(awk '
{ sub(/\r$/, "") }
/^#/ { next }
/^(https|rsync):\/\// { uri=1; next }
/^[[:space:]]*$/ { if (uri) key=1; next }
key { printf "%s", $0 }
' "input/$rir.tal" | tr -d '[:space:]')
cert_key=$(openssl x509 -inform DER -in "input/$rir.cer" -pubkey -noout \
| openssl pkey -pubin -outform DER | openssl base64 -A)
test -n "$tal_key" && test "$tal_key" = "$cert_key"
echo "$rir: TAL/TA public keys match"
done
```
You now have ten files in `input/`, named exactly as expected by the
[all-five Compose example](docker.md#all-five-regional-anchors). For the
single-anchor README example only, copy a chosen pair to `input/anchor.tal`
and `input/anchor.cer`, or change the CLI arguments to use its RIR filenames.
Keep inputs out of version control. Monitor certificate validity and RIR
announcements; Panda RPKI does not refresh supplied TA files automatically.
Stage and check updated pairs before replacing active inputs between runs.
Never resolve a key mismatch by replacing the TAL key with the downloaded
certificate's key: retrieve the current TAL from the RIR and investigate first.

101
docs/usage.md Normal file
View File

@ -0,0 +1,101 @@
# Usage
Examples assume `panda-rpki` is on PATH; for a local build use
`target/release/panda-rpki`. Prepare TAL/TA files as described in
[Getting started](getting-started.md).
## Snapshot and delta
Use a fresh state directory for an explicit snapshot:
```bash
panda-rpki validate --tal input/anchor.tal --ta input/anchor.cer \
--rrdp-state-dir state/anchor --rrdp-sync-mode snapshot \
--out output/snapshot --ccr-out output/snapshot/result.ccr
panda-rpki validate --tal input/anchor.tal --ta input/anchor.cer \
--rrdp-state-dir state/anchor --rrdp-sync-mode delta \
--out output/delta-01 --ccr-out output/delta-01/result.ccr
```
Subsequent runs reuse state and can use `auto`. Delta mode can include a
protocol-required snapshot or noop. Check actual operation counters in the
summary. Do not discard persistent state between scheduled runs.
## Multiple anchors and constraints
```bash
panda-rpki validate \
--tal input/arin.tal --ta input/arin.cer --tal-id arin \
--tal input/ripe.tal --ta input/ripe.cer --tal-id ripe \
--parallel-max-repo-sync-workers-global 8 \
--parallel-phase2-object-workers 8 \
--rrdp-state-dir state/multi --out output/multi \
--ccr-out output/multi/result.ccr
```
Add further pairs in the same way. Optional resource constraints are read from
the TAL-adjacent `.constraints` file, or specified with
`--ta-constraints arin=input/arin.constraints`. Rules follow the WG profile
listed in the [standards matrix](conformance-matrix.md).
A constraint file contains `allow`/`deny` followed by an IPv4/IPv6 prefix,
address range, ASN or ASN range. Blank lines and `#` comments are allowed.
For a synthetic test anchor, an example is:
```text
# Example resources only: do not apply this policy to a production RIR.
allow 192.0.2.0/24
deny 192.0.2.128/25
allow 2001:db8::/32
allow 64496 - 64511
```
Deny overlap rejects an EE resource; otherwise the resource must be fully
covered by allow rules of its resource family. Unlisted resources are not
implicitly allowed. An empty constraint file therefore differs from having
no constraint file. Overlapping rules are normalized and may produce warnings.
## Continuous operation
```bash
panda-rpki daemon --state-root data/daemon \
--interval-secs 600 --retain-runs 10 -- \
--tal input/anchor.tal --ta input/anchor.cer \
--log-level info --log-format json
```
The daemon runs in the foreground. It starts with snapshot for empty state and
requests delta after successful runs. The interval starts after each cycle
finishes. Add `--max-runs 3` before `--` for a finite run. SIGTERM requests a
graceful stop. Restart with the same root to continue numbering and state.
Each cycle writes to `data/daemon/runs/run_000001/`, then `run_000002/`, etc.
Retention deletes the oldest completed run directories beyond the configured
limit, including their artifacts. Copy anything you need to retain elsewhere.
The append-only summary history is not trimmed by retention.
## Outputs and diagnostics
`vrps.csv` contains `asn,prefix,max_length`. CCR contains canonical state;
duplicate routing payloads can collapse into a single CCR entry. Count raw
rows and normalized unique payloads separately when comparing outputs.
ASPA canonicalization groups providers by customer ASN.
Use `--log-level debug` for diagnosis, or `trace` for detailed object/worker
events. Logs are written to stderr and can be redirected independently from
outputs. In daemon mode, inspect per-run `stderr.log` and the controller's
`daemon-status.json` and `run-summary.jsonl`.
| Symptom | Action |
| --- | --- |
| TAL/TA key mismatch or expired TA | Refresh the certificate from the TAL's official URI and check the selected pair. |
| State-layout/schema error | Preserve the directory and select a new dedicated root for a compatible run. |
| Snapshot refuses existing state | Use `auto`/`delta`, or choose a fresh directory for a new snapshot. |
| Delta refuses empty state | Establish state with `auto` or `snapshot`. |
| Permission denied in Docker | Pre-create writable host directories and use your UID/GID mapping. |
| RRDP origin rejection | Inspect the publisher's notification/references; extra TLS roots do not bypass origin policy. |
| Daemon root locked | Stop the other instance normally; do not delete its lock file. |
| Unexpected counts or partial output | Inspect warnings and per-run status; use new output paths to avoid stale files. |
See the [CLI reference](command-line-reference.md) for precise defaults,
timeouts, exit codes and recovery behavior.

245
src/ccr/accumulator.rs Normal file
View File

@ -0,0 +1,245 @@
use std::collections::BTreeMap;
use crate::ccr::build::{
build_aspa_payload_state, build_roa_payload_state, build_router_key_state_from_runtime,
build_trust_anchor_state,
};
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,
};
use crate::model::common::BigUnsigned;
use crate::model::ta::TrustAnchor;
use crate::repository::storage::CcrManifestProjection;
use crate::validation::objects::{AspaAttestation, RouterKeyPayload, Vrp};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CcrManifestContribution {
pub manifest_rsync_uri: String,
pub hash: Vec<u8>,
pub size: u64,
pub aki: Vec<u8>,
pub manifest_number_be: Vec<u8>,
pub this_update: time::OffsetDateTime,
pub locations_der: Vec<Vec<u8>>,
pub subordinate_skis: Vec<Vec<u8>>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CcrAccumulatorMemoryStats {
pub trust_anchor_count: u64,
pub manifest_count: u64,
pub estimated_heap_bytes: u64,
pub string_bytes: u64,
pub string_capacity_bytes: u64,
pub vec_payload_bytes: u64,
pub vec_capacity_bytes: u64,
pub locations_der_count: u64,
pub subordinate_ski_count: u64,
pub btree_key_capacity_bytes: u64,
pub btree_entry_shallow_bytes: u64,
}
impl CcrManifestContribution {
fn from_projection(projection: &CcrManifestProjection) -> Result<Self, String> {
let this_update = projection
.manifest_this_update
.parse()
.map_err(|e| format!("parse projection manifest_this_update failed: {e}"))?;
Ok(Self {
manifest_rsync_uri: projection.manifest_rsync_uri.clone(),
hash: projection.manifest_sha256.clone(),
size: projection.manifest_size,
aki: projection.manifest_ee_aki.clone(),
manifest_number_be: projection.manifest_number_be.clone(),
this_update,
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(),
})
}
fn to_manifest_instance(&self) -> ManifestInstance {
ManifestInstance {
hash: self.hash.clone(),
size: self.size,
aki: self.aki.clone(),
manifest_number: BigUnsigned {
bytes_be: self.manifest_number_be.clone(),
},
this_update: self.this_update,
locations: self.locations_der.clone(),
subordinates: self.subordinate_skis.clone(),
}
}
fn add_memory_stats(&self, stats: &mut CcrAccumulatorMemoryStats) {
stats.string_bytes += self.manifest_rsync_uri.len() as u64;
stats.string_capacity_bytes += self.manifest_rsync_uri.capacity() as u64;
stats.estimated_heap_bytes += self.manifest_rsync_uri.capacity() as u64;
add_vec_stats(&self.hash, stats);
add_vec_stats(&self.aki, stats);
add_vec_stats(&self.manifest_number_be, stats);
add_vec_of_vec_stats(&self.locations_der, stats);
add_vec_of_vec_stats(&self.subordinate_skis, stats);
stats.locations_der_count += self.locations_der.len() as u64;
stats.subordinate_ski_count += self.subordinate_skis.len() as u64;
}
}
fn add_vec_stats(value: &Vec<u8>, stats: &mut CcrAccumulatorMemoryStats) {
stats.vec_payload_bytes += value.len() as u64;
stats.vec_capacity_bytes += value.capacity() as u64;
stats.estimated_heap_bytes += value.capacity() as u64;
}
fn add_vec_of_vec_stats(values: &Vec<Vec<u8>>, stats: &mut CcrAccumulatorMemoryStats) {
let outer_capacity = values.capacity() * std::mem::size_of::<Vec<u8>>();
stats.vec_payload_bytes += (values.len() * std::mem::size_of::<Vec<u8>>()) as u64;
stats.vec_capacity_bytes += outer_capacity as u64;
stats.estimated_heap_bytes += outer_capacity as u64;
for value in values {
add_vec_stats(value, stats);
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CcrAccumulator {
trust_anchors: Vec<TrustAnchor>,
manifests_by_hash: BTreeMap<Vec<u8>, CcrManifestContribution>,
most_recent_update: time::OffsetDateTime,
}
impl CcrAccumulator {
pub fn new(trust_anchors: Vec<TrustAnchor>) -> Self {
Self {
trust_anchors,
manifests_by_hash: BTreeMap::new(),
most_recent_update: time::OffsetDateTime::UNIX_EPOCH,
}
}
pub fn append_manifest_projection(
&mut self,
projection: &CcrManifestProjection,
) -> Result<(), String> {
let contribution = CcrManifestContribution::from_projection(projection)?;
match self.manifests_by_hash.get(contribution.hash.as_slice()) {
Some(existing) if existing != &contribution => {
return Err(format!(
"duplicate manifest hash with conflicting content for URI: {}",
contribution.manifest_rsync_uri
));
}
Some(_) => {}
None => {
self.manifests_by_hash
.insert(contribution.hash.clone(), contribution.clone());
}
}
if contribution.this_update > self.most_recent_update {
self.most_recent_update = contribution.this_update;
}
Ok(())
}
pub fn finish(
&self,
produced_at: time::OffsetDateTime,
vrps: &[Vrp],
aspas: &[AspaAttestation],
router_keys: &[RouterKeyPayload],
) -> Result<RpkiCanonicalCacheRepresentation, String> {
let manifest_instances = self
.manifests_by_hash
.values()
.map(CcrManifestContribution::to_manifest_instance)
.collect::<Vec<_>>();
let manifest_payload_der = encode_manifest_state_payload_der(&manifest_instances)
.map_err(|e| format!("manifest state encoding failed: {e}"))?;
let manifest_state = ManifestState {
mis: manifest_instances,
most_recent_update: self.most_recent_update,
hash: compute_state_hash(&manifest_payload_der),
};
let vrp_state = build_roa_payload_state(vrps).map_err(|e| e.to_string())?;
let aspa_state = build_aspa_payload_state(aspas).map_err(|e| e.to_string())?;
let ta_state = build_trust_anchor_state(&self.trust_anchors).map_err(|e| e.to_string())?;
let router_key_state =
build_router_key_state_from_runtime(router_keys).map_err(|e| e.to_string())?;
Ok(RpkiCanonicalCacheRepresentation {
version: 0,
hash_alg: CcrDigestAlgorithm::Sha256,
produced_at,
mfts: Some(manifest_state),
vrps: Some(vrp_state),
vaps: Some(aspa_state),
tas: Some(ta_state),
rks: Some(router_key_state),
})
}
pub fn manifest_count(&self) -> usize {
self.manifests_by_hash.len()
}
pub fn memory_stats(&self) -> CcrAccumulatorMemoryStats {
let mut stats = CcrAccumulatorMemoryStats {
trust_anchor_count: self.trust_anchors.len() as u64,
manifest_count: self.manifests_by_hash.len() as u64,
..CcrAccumulatorMemoryStats::default()
};
stats.estimated_heap_bytes +=
(self.trust_anchors.capacity() * std::mem::size_of::<TrustAnchor>()) as u64;
for trust_anchor in &self.trust_anchors {
add_vec_stats(&trust_anchor.tal.raw, &mut stats);
add_vec_of_string_stats(&trust_anchor.tal.comments, &mut stats);
stats.vec_payload_bytes +=
(trust_anchor.tal.ta_uris.len() * std::mem::size_of::<url::Url>()) as u64;
stats.vec_capacity_bytes +=
(trust_anchor.tal.ta_uris.capacity() * std::mem::size_of::<url::Url>()) as u64;
stats.estimated_heap_bytes +=
(trust_anchor.tal.ta_uris.capacity() * std::mem::size_of::<url::Url>()) as u64;
for uri in &trust_anchor.tal.ta_uris {
stats.string_bytes += uri.as_str().len() as u64;
stats.string_capacity_bytes += uri.as_str().len() as u64;
stats.estimated_heap_bytes += uri.as_str().len() as u64;
}
add_vec_stats(&trust_anchor.tal.subject_public_key_info_der, &mut stats);
add_vec_stats(&trust_anchor.ta_certificate.raw_der, &mut stats);
if let Some(uri) = &trust_anchor.resolved_ta_uri {
stats.string_bytes += uri.as_str().len() as u64;
stats.string_capacity_bytes += uri.as_str().len() as u64;
stats.estimated_heap_bytes += uri.as_str().len() as u64;
}
}
stats.btree_entry_shallow_bytes = (self.manifests_by_hash.len()
* (std::mem::size_of::<Vec<u8>>() + std::mem::size_of::<CcrManifestContribution>()))
as u64;
stats.estimated_heap_bytes += stats.btree_entry_shallow_bytes;
for (key, contribution) in &self.manifests_by_hash {
stats.btree_key_capacity_bytes += key.capacity() as u64;
stats.estimated_heap_bytes += key.capacity() as u64;
contribution.add_memory_stats(&mut stats);
}
stats
}
}
fn add_vec_of_string_stats(values: &Vec<String>, stats: &mut CcrAccumulatorMemoryStats) {
let outer_capacity = values.capacity() * std::mem::size_of::<String>();
stats.vec_payload_bytes += (values.len() * std::mem::size_of::<String>()) as u64;
stats.vec_capacity_bytes += outer_capacity as u64;
stats.estimated_heap_bytes += outer_capacity as u64;
for value in values {
stats.string_bytes += value.len() as u64;
stats.string_capacity_bytes += value.capacity() as u64;
stats.estimated_heap_bytes += value.capacity() as u64;
}
}

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

@ -0,0 +1,266 @@
use std::collections::{BTreeMap, BTreeSet};
use crate::ccr::encode::{
encode_aspa_payload_state_payload_der, encode_roa_payload_state_payload_der,
encode_router_key_state_payload_der, encode_trust_anchor_state_payload_der,
};
use crate::ccr::hash::compute_state_hash;
use crate::ccr::model::{
AspaPayloadSet, AspaPayloadState, RoaPayloadSet, RoaPayloadState, RouterKey, RouterKeySet,
RouterKeyState, TrustAnchorState,
};
use crate::model::roa::RoaAfi;
use crate::model::router_cert::BgpsecRouterCertificate;
use crate::model::ta::TrustAnchor;
use crate::validation::objects::{AspaAttestation, RouterKeyPayload, Vrp};
#[derive(Debug, thiserror::Error)]
pub enum CcrBuildError {
#[error("trust anchor set must not be empty")]
EmptyTrustAnchors,
#[error("trust anchor certificate missing SubjectKeyIdentifier")]
MissingTrustAnchorSki,
#[error("ROA payload state encoding failed: {0}")]
RoaEncode(String),
#[error("ASPA payload state encoding failed: {0}")]
AspaEncode(String),
#[error("TrustAnchor state encoding failed: {0}")]
TrustAnchorEncode(String),
#[error("router key state encoding failed: {0}")]
RouterKeyEncode(String),
}
pub fn build_roa_payload_state(vrps: &[Vrp]) -> Result<RoaPayloadState, CcrBuildError> {
let mut grouped: BTreeMap<u32, BTreeSet<RoaPayloadKey>> = BTreeMap::new();
for vrp in vrps {
grouped
.entry(vrp.asn)
.or_default()
.insert(RoaPayloadKey::from_vrp(vrp));
}
let rps = grouped
.into_iter()
.map(|(asn, entries)| {
let mut families: BTreeMap<u16, Vec<RoaPayloadKey>> = BTreeMap::new();
for entry in entries {
families.entry(entry.afi).or_default().push(entry);
}
RoaPayloadSet {
as_id: asn,
ip_addr_blocks: families
.into_iter()
.map(|(afi, entries)| encode_roa_ip_address_family(afi, &entries))
.collect(),
}
})
.collect::<Vec<_>>();
let payload_der = encode_roa_payload_state_payload_der(&rps)
.map_err(|e| CcrBuildError::RoaEncode(e.to_string()))?;
Ok(RoaPayloadState {
rps,
hash: compute_state_hash(&payload_der),
})
}
pub fn build_aspa_payload_state(
attestations: &[AspaAttestation],
) -> Result<AspaPayloadState, CcrBuildError> {
let mut grouped: BTreeMap<u32, BTreeSet<u32>> = BTreeMap::new();
for attestation in attestations {
grouped
.entry(attestation.customer_as_id)
.or_default()
.extend(attestation.provider_as_ids.iter().copied());
}
let aps = grouped
.into_iter()
.map(|(customer_as_id, providers)| AspaPayloadSet {
customer_as_id,
providers: providers.into_iter().collect(),
})
.collect::<Vec<_>>();
let payload_der = encode_aspa_payload_state_payload_der(&aps)
.map_err(|e| CcrBuildError::AspaEncode(e.to_string()))?;
Ok(AspaPayloadState {
aps,
hash: compute_state_hash(&payload_der),
})
}
pub fn build_trust_anchor_state(
trust_anchors: &[TrustAnchor],
) -> Result<TrustAnchorState, CcrBuildError> {
if trust_anchors.is_empty() {
return Err(CcrBuildError::EmptyTrustAnchors);
}
let mut skis = BTreeSet::new();
for ta in trust_anchors {
let ski = ta
.ta_certificate
.rc_ca
.tbs
.extensions
.subject_key_identifier
.clone()
.ok_or(CcrBuildError::MissingTrustAnchorSki)?;
skis.insert(ski);
}
let skis = skis.into_iter().collect::<Vec<_>>();
let payload_der = encode_trust_anchor_state_payload_der(&skis)
.map_err(|e| CcrBuildError::TrustAnchorEncode(e.to_string()))?;
Ok(TrustAnchorState {
skis,
hash: compute_state_hash(&payload_der),
})
}
pub fn build_router_key_state(
router_certs: &[BgpsecRouterCertificate],
) -> Result<RouterKeyState, CcrBuildError> {
let mut grouped: BTreeMap<u32, BTreeSet<RouterKey>> = BTreeMap::new();
for cert in router_certs {
let key = RouterKey {
ski: cert.subject_key_identifier.clone(),
spki_der: cert.spki_der.clone(),
};
for asn in &cert.asns {
grouped.entry(*asn).or_default().insert(key.clone());
}
}
build_router_key_sets(grouped)
}
pub fn build_router_key_state_from_runtime(
router_keys: &[RouterKeyPayload],
) -> Result<RouterKeyState, CcrBuildError> {
let mut grouped: BTreeMap<u32, BTreeSet<RouterKey>> = BTreeMap::new();
for key in router_keys {
grouped.entry(key.as_id).or_default().insert(RouterKey {
ski: key.ski.clone(),
spki_der: key.spki_der.clone(),
});
}
build_router_key_sets(grouped)
}
fn build_router_key_sets(
grouped: BTreeMap<u32, BTreeSet<RouterKey>>,
) -> Result<RouterKeyState, CcrBuildError> {
let rksets = grouped
.into_iter()
.map(|(as_id, router_keys)| RouterKeySet {
as_id,
router_keys: router_keys.into_iter().collect(),
})
.collect::<Vec<_>>();
let payload_der = encode_router_key_state_payload_der(&rksets)
.map_err(|e| CcrBuildError::RouterKeyEncode(e.to_string()))?;
Ok(RouterKeyState {
rksets,
hash: compute_state_hash(&payload_der),
})
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct RoaPayloadKey {
afi: u16,
addr: Vec<u8>,
prefix_len: u8,
max_length: u8,
}
impl RoaPayloadKey {
fn from_vrp(vrp: &Vrp) -> Self {
Self {
afi: match vrp.prefix.afi {
RoaAfi::Ipv4 => 1,
RoaAfi::Ipv6 => 2,
},
addr: vrp.prefix.addr.to_vec(),
prefix_len: vrp.prefix.prefix_len as u8,
max_length: vrp.max_length as u8,
}
}
}
fn encode_roa_ip_address_family(afi: u16, entries: &[RoaPayloadKey]) -> Vec<u8> {
encode_sequence(&[
encode_octet_string(&afi.to_be_bytes()),
encode_sequence(
&entries
.iter()
.map(encode_roa_ip_address)
.collect::<Vec<_>>(),
),
])
}
fn encode_roa_ip_address(entry: &RoaPayloadKey) -> Vec<u8> {
let (unused, content) = encode_prefix_bit_string(&entry.addr, entry.prefix_len);
let mut fields = vec![encode_bit_string(unused, &content)];
if entry.max_length != entry.prefix_len {
fields.push(encode_integer_u8(entry.max_length));
}
encode_sequence(&fields)
}
fn encode_prefix_bit_string(addr: &[u8], prefix_len: u8) -> (u8, Vec<u8>) {
if prefix_len == 0 {
return (0, Vec::new());
}
let octets = (prefix_len as usize).div_ceil(8);
let mut content = addr[..octets].to_vec();
let rem = prefix_len % 8;
let unused = if rem == 0 { 0 } else { 8 - rem };
if unused > 0 {
*content.last_mut().expect("prefix has an octet") &= 0xff << unused;
}
(unused, content)
}
fn encode_integer_u8(value: u8) -> Vec<u8> {
encode_integer_bytes(vec![value])
}
fn encode_integer_bytes(mut bytes: Vec<u8>) -> Vec<u8> {
if bytes.is_empty() {
bytes.push(0);
}
if bytes[0] & 0x80 != 0 {
bytes.insert(0, 0);
}
encode_tlv(0x02, bytes)
}
fn encode_bit_string(unused: u8, content: &[u8]) -> Vec<u8> {
let mut value = Vec::with_capacity(content.len() + 1);
value.push(unused);
value.extend_from_slice(content);
encode_tlv(0x03, value)
}
fn encode_octet_string(bytes: &[u8]) -> Vec<u8> {
encode_tlv(0x04, bytes.to_vec())
}
fn encode_sequence(elements: &[Vec<u8>]) -> Vec<u8> {
encode_tlv(
0x30,
elements
.iter()
.flat_map(|element| element.iter().copied())
.collect(),
)
}
fn encode_tlv(tag: u8, value: Vec<u8>) -> Vec<u8> {
let mut out = vec![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);
}

235
src/ccr/compare_view.rs Normal file
View File

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

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

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

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

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

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

@ -0,0 +1,25 @@
use crate::ccr::encode::{CcrEncodeError, encode_content_info};
use crate::ccr::model::{CcrContentInfo, RpkiCanonicalCacheRepresentation};
use std::path::Path;
#[derive(Debug, thiserror::Error)]
pub enum CcrExportError {
#[error("encode CCR failed: {0}")]
Encode(#[from] CcrEncodeError),
#[error("write CCR file failed: {0}: {1}")]
Write(String, String),
}
pub fn write_ccr_file(
path: &Path,
ccr: &RpkiCanonicalCacheRepresentation,
) -> Result<(), CcrExportError> {
let der = encode_content_info(&CcrContentInfo::new(ccr.clone()))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
CcrExportError::Write(path.display().to_string(), error.to_string())
})?;
}
std::fs::write(path, der)
.map_err(|error| CcrExportError::Write(path.display().to_string(), error.to_string()))
}

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

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

View File

@ -0,0 +1,425 @@
use crate::model::common::DerReader;
use crate::model::oid::OID_AD_SIGNED_OBJECT;
use crate::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(),
"CCR manifest 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::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(std::slice::from_ref(&oid)))
.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());
}
}

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

@ -0,0 +1,34 @@
pub mod accumulator;
pub mod build;
pub mod compare_view;
pub mod decode;
pub mod encode;
pub mod export;
pub mod hash;
pub(crate) mod manifest_location;
pub mod model;
pub mod verify;
pub use accumulator::{CcrAccumulator, CcrManifestContribution};
pub use build::{
CcrBuildError, build_aspa_payload_state, build_roa_payload_state,
build_router_key_state_from_runtime, build_trust_anchor_state,
};
pub use compare_view::{
VapCompareRow, VrpCompareRow, build_vap_compare_rows, build_vrp_compare_rows,
canonical_vrp_prefix, decode_ccr_compare_views, write_vap_csv, write_vrp_csv,
};
pub use decode::{CcrDecodeError, decode_content_info};
pub use encode::{CcrEncodeError, encode_content_info};
pub use export::{CcrExportError, write_ccr_file};
pub use hash::{compute_state_hash, verify_state_hash};
pub use model::{
AspaPayloadSet, AspaPayloadState, CcrContentInfo, CcrDigestAlgorithm, ManifestInstance,
ManifestState, RoaPayloadSet, RoaPayloadState, RouterKey, RouterKeySet, RouterKeyState,
RpkiCanonicalCacheRepresentation, TrustAnchorState,
};
pub use verify::{
CcrVerifyError, CcrVerifySummary, extract_vrp_rows, verify_against_report_json_path,
verify_content_info, verify_content_info_bytes,
};
pub mod projection;

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

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

69
src/ccr/projection.rs Normal file
View File

@ -0,0 +1,69 @@
//! Manifest contribution to CCR, independent of validation-result persistence.
use crate::repository::storage::PackTime;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CcrManifestProjection {
pub manifest_rsync_uri: String,
pub manifest_sha256: Vec<u8>,
pub manifest_size: u64,
pub manifest_ee_aki: Vec<u8>,
pub manifest_number_be: Vec<u8>,
pub manifest_this_update: PackTime,
pub manifest_sia_locations_der: Vec<Vec<u8>>,
pub subordinate_skis: Vec<Vec<u8>>,
}
use crate::ccr::manifest_location::select_manifest_signed_object_location;
use crate::model::manifest::ManifestObject;
use crate::model::rc::SubjectInfoAccess;
use crate::validation::publication_point::PublicationPointSnapshot;
use crate::validation::tree::CaInstanceHandle;
use sha2::Digest;
pub(crate) fn from_snapshot(
ca: &CaInstanceHandle,
pack: &PublicationPointSnapshot,
mut subordinate_skis: Vec<Vec<u8>>,
) -> Result<CcrManifestProjection, String> {
let manifest = ManifestObject::decode_der(&pack.manifest_bytes)
.map_err(|e| format!("decode manifest for CCR projection failed: {e}"))?;
let ee = &manifest.signed_object.signed_data.certificates[0].resource_cert;
let manifest_ee_aki = ee
.tbs
.extensions
.authority_key_identifier
.clone()
.ok_or_else(|| "manifest EE certificate missing AuthorityKeyIdentifier".to_string())?;
let manifest_sia_locations_der = match ee
.tbs
.extensions
.subject_info_access
.as_ref()
.ok_or_else(|| "manifest EE certificate missing Subject Information Access".to_string())?
{
SubjectInfoAccess::Ee(ee_sia) => vec![select_manifest_signed_object_location(
&ca.manifest_rsync_uri,
&ee_sia.access_descriptions,
)?],
SubjectInfoAccess::Ca(_) => {
return Err(
"manifest EE certificate Subject Information Access has CA variant".to_string(),
);
}
};
subordinate_skis.sort();
subordinate_skis.dedup();
Ok(CcrManifestProjection {
manifest_rsync_uri: ca.manifest_rsync_uri.clone(),
manifest_sha256: sha2::Sha256::digest(&pack.manifest_bytes).to_vec(),
manifest_size: pack.manifest_bytes.len() as u64,
manifest_ee_aki,
manifest_number_be: pack.manifest_number_be.clone(),
manifest_this_update: pack.this_update.clone(),
manifest_sia_locations_der,
subordinate_skis,
})
}

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

@ -0,0 +1,359 @@
#![allow(clippy::type_complexity)]
use crate::ccr::decode::{CcrDecodeError, decode_content_info};
use crate::ccr::encode::{
encode_aspa_payload_state_payload_der, encode_manifest_state_payload_der,
encode_roa_payload_state_payload_der, encode_router_key_state_payload_der,
encode_trust_anchor_state_payload_der,
};
use crate::ccr::hash::verify_state_hash;
use crate::ccr::model::{CcrContentInfo, RouterKeyState, TrustAnchorState};
use serde::Serialize;
use std::collections::BTreeSet;
use std::path::Path;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct CcrVerifySummary {
pub content_type_oid: String,
pub version: u32,
pub produced_at_rfc3339_utc: String,
pub state_hashes_ok: bool,
pub manifest_instances: usize,
pub roa_payload_sets: usize,
pub roa_vrp_count: usize,
pub aspa_payload_sets: usize,
pub trust_anchor_ski_count: usize,
pub router_key_sets: usize,
pub router_key_count: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum CcrVerifyError {
#[error("CCR decode failed: {0}")]
Decode(#[from] CcrDecodeError),
#[error("ManifestState hash mismatch")]
ManifestHashMismatch,
#[error("ROAPayloadState hash mismatch")]
RoaHashMismatch,
#[error("ASPAPayloadState hash mismatch")]
AspaHashMismatch,
#[error("TrustAnchorState hash mismatch")]
TrustAnchorHashMismatch,
#[error("RouterKeyState hash mismatch")]
RouterKeyHashMismatch,
#[error("read report json failed: {0}: {1}")]
ReportRead(String, String),
#[error("parse report json failed: {0}")]
ReportParse(String),
#[error("VRP set mismatch: only_in_ccr={only_in_ccr} only_in_report={only_in_report}")]
ReportVrpMismatch {
only_in_ccr: usize,
only_in_report: usize,
},
#[error("ASPA set mismatch: only_in_ccr={only_in_ccr} only_in_report={only_in_report}")]
ReportAspaMismatch {
only_in_ccr: usize,
only_in_report: usize,
},
}
pub fn verify_content_info_bytes(der: &[u8]) -> Result<CcrVerifySummary, CcrVerifyError> {
let content_info = decode_content_info(der)?;
verify_content_info(&content_info)
}
pub fn verify_content_info(
content_info: &CcrContentInfo,
) -> Result<CcrVerifySummary, CcrVerifyError> {
content_info.validate().map_err(CcrDecodeError::Validate)?;
let state_hashes_ok = true;
let mut manifest_instances = 0usize;
let mut roa_payload_sets = 0usize;
let mut roa_vrp_count = 0usize;
let mut aspa_payload_sets = 0usize;
let mut trust_anchor_ski_count = 0usize;
let mut router_key_sets = 0usize;
let mut router_key_count = 0usize;
if let Some(mfts) = &content_info.content.mfts {
let payload_der = encode_manifest_state_payload_der(&mfts.mis)
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?;
if !verify_state_hash(&mfts.hash, &payload_der) {
return Err(CcrVerifyError::ManifestHashMismatch);
}
manifest_instances = mfts.mis.len();
}
if let Some(vrps) = &content_info.content.vrps {
let payload_der = encode_roa_payload_state_payload_der(&vrps.rps)
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?;
if !verify_state_hash(&vrps.hash, &payload_der) {
return Err(CcrVerifyError::RoaHashMismatch);
}
roa_payload_sets = vrps.rps.len();
roa_vrp_count = vrps
.rps
.iter()
.map(|set| count_roa_block_entries(&set.ip_addr_blocks))
.sum();
}
if let Some(vaps) = &content_info.content.vaps {
let payload_der = encode_aspa_payload_state_payload_der(&vaps.aps)
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?;
if !verify_state_hash(&vaps.hash, &payload_der) {
return Err(CcrVerifyError::AspaHashMismatch);
}
aspa_payload_sets = vaps.aps.len();
}
if let Some(tas) = &content_info.content.tas {
verify_trust_anchor_state_hash(tas)?;
trust_anchor_ski_count = tas.skis.len();
}
if let Some(rks) = &content_info.content.rks {
verify_router_key_state_hash(rks)?;
router_key_sets = rks.rksets.len();
router_key_count = rks.rksets.iter().map(|set| set.router_keys.len()).sum();
}
let produced_at_rfc3339_utc = content_info
.content
.produced_at
.to_offset(time::UtcOffset::UTC)
.format(&time::format_description::well_known::Rfc3339)
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?;
Ok(CcrVerifySummary {
content_type_oid: content_info.content_type_oid.clone(),
version: content_info.content.version,
produced_at_rfc3339_utc,
state_hashes_ok,
manifest_instances,
roa_payload_sets,
roa_vrp_count,
aspa_payload_sets,
trust_anchor_ski_count,
router_key_sets,
router_key_count,
})
}
pub fn verify_against_report_json_path(
content_info: &CcrContentInfo,
report_json_path: &Path,
) -> Result<(), CcrVerifyError> {
let bytes = std::fs::read(report_json_path).map_err(|e| {
CcrVerifyError::ReportRead(report_json_path.display().to_string(), e.to_string())
})?;
let json: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| CcrVerifyError::ReportParse(e.to_string()))?;
let report_vrps = report_vrp_keys(&json)?;
let ccr_vrps = extract_vrp_rows(content_info)?;
let only_in_ccr = ccr_vrps.difference(&report_vrps).count();
let only_in_report = report_vrps.difference(&ccr_vrps).count();
if only_in_ccr != 0 || only_in_report != 0 {
return Err(CcrVerifyError::ReportVrpMismatch {
only_in_ccr,
only_in_report,
});
}
let report_aspas = report_aspa_keys(&json)?;
let ccr_aspas = ccr_aspa_keys(content_info)?;
let only_in_ccr = ccr_aspas.difference(&report_aspas).count();
let only_in_report = report_aspas.difference(&ccr_aspas).count();
if only_in_ccr != 0 || only_in_report != 0 {
return Err(CcrVerifyError::ReportAspaMismatch {
only_in_ccr,
only_in_report,
});
}
Ok(())
}
fn verify_trust_anchor_state_hash(state: &TrustAnchorState) -> Result<(), CcrVerifyError> {
let payload_der = encode_trust_anchor_state_payload_der(&state.skis)
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?;
if !verify_state_hash(&state.hash, &payload_der) {
return Err(CcrVerifyError::TrustAnchorHashMismatch);
}
Ok(())
}
fn verify_router_key_state_hash(state: &RouterKeyState) -> Result<(), CcrVerifyError> {
let payload_der = encode_router_key_state_payload_der(&state.rksets)
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Validate(e.to_string())))?;
if !verify_state_hash(&state.hash, &payload_der) {
return Err(CcrVerifyError::RouterKeyHashMismatch);
}
Ok(())
}
fn report_vrp_keys(
json: &serde_json::Value,
) -> Result<BTreeSet<(u32, String, u16)>, CcrVerifyError> {
let mut out = BTreeSet::new();
let Some(items) = json.get("vrps").and_then(|v| v.as_array()) else {
return Ok(out);
};
for item in items {
let asn = item
.get("asn")
.and_then(|v| v.as_u64())
.ok_or_else(|| CcrVerifyError::ReportParse("vrps[].asn missing".into()))?
as u32;
let prefix = item
.get("prefix")
.and_then(|v| v.as_str())
.ok_or_else(|| CcrVerifyError::ReportParse("vrps[].prefix missing".into()))?
.to_string();
let max_length = item
.get("max_length")
.and_then(|v| v.as_u64())
.ok_or_else(|| CcrVerifyError::ReportParse("vrps[].max_length missing".into()))?
as u16;
out.insert((asn, prefix, max_length));
}
Ok(out)
}
fn report_aspa_keys(json: &serde_json::Value) -> Result<BTreeSet<(u32, Vec<u32>)>, CcrVerifyError> {
let mut out = BTreeSet::new();
let Some(items) = json.get("aspas").and_then(|v| v.as_array()) else {
return Ok(out);
};
for item in items {
let customer = item
.get("customer_as_id")
.and_then(|v| v.as_u64())
.ok_or_else(|| CcrVerifyError::ReportParse("aspas[].customer_as_id missing".into()))?
as u32;
let mut providers = item
.get("provider_as_ids")
.and_then(|v| v.as_array())
.ok_or_else(|| CcrVerifyError::ReportParse("aspas[].provider_as_ids missing".into()))?
.iter()
.map(|v| {
v.as_u64()
.ok_or_else(|| CcrVerifyError::ReportParse("provider_as_ids[] invalid".into()))
.map(|v| v as u32)
})
.collect::<Result<Vec<_>, _>>()?;
providers.sort_unstable();
providers.dedup();
out.insert((customer, providers));
}
Ok(out)
}
pub fn extract_vrp_rows(
content_info: &CcrContentInfo,
) -> Result<BTreeSet<(u32, String, u16)>, CcrVerifyError> {
let mut out = BTreeSet::new();
let Some(vrps) = &content_info.content.vrps else {
return Ok(out);
};
for set in &vrps.rps {
for block in &set.ip_addr_blocks {
let (afi, entries) = decode_roa_family_block(block)?;
for (prefix_len, addr_bytes, max_len) in entries {
let prefix = format_prefix(afi, &addr_bytes, prefix_len)?;
out.insert((set.as_id, prefix, max_len.unwrap_or(prefix_len as u16)));
}
}
}
Ok(out)
}
fn ccr_aspa_keys(
content_info: &CcrContentInfo,
) -> Result<BTreeSet<(u32, Vec<u32>)>, CcrVerifyError> {
let mut out = BTreeSet::new();
let Some(vaps) = &content_info.content.vaps else {
return Ok(out);
};
for set in &vaps.aps {
out.insert((set.customer_as_id, set.providers.clone()));
}
Ok(out)
}
fn decode_roa_family_block(
block: &[u8],
) -> Result<(u16, Vec<(u8, Vec<u8>, Option<u16>)>), CcrVerifyError> {
let mut top = crate::model::common::DerReader::new(block);
let mut seq = top
.take_sequence()
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?;
if !top.is_empty() {
return Err(CcrVerifyError::Decode(CcrDecodeError::Parse(
"trailing bytes after ROAIPAddressFamily".into(),
)));
}
let afi_bytes = seq
.take_octet_string()
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?;
let afi = u16::from_be_bytes([afi_bytes[0], afi_bytes[1]]);
let mut addrs = seq
.take_sequence()
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?;
let mut entries = Vec::new();
while !addrs.is_empty() {
let mut addr_seq = addrs
.take_sequence()
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?;
let (unused_bits, content) = addr_seq
.take_bit_string()
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?;
let prefix_len = (content.len() * 8) as u8 - unused_bits;
let max_len = if addr_seq.is_empty() {
None
} else {
Some(
addr_seq
.take_uint_u64()
.map_err(|e| CcrVerifyError::Decode(CcrDecodeError::Parse(e)))?
as u16,
)
};
entries.push((prefix_len, content.to_vec(), max_len));
}
Ok((afi, entries))
}
fn format_prefix(afi: u16, addr_bytes: &[u8], prefix_len: u8) -> Result<String, CcrVerifyError> {
match afi {
1 => {
let mut full = [0u8; 4];
full[..addr_bytes.len()].copy_from_slice(addr_bytes);
Ok(format!("{}/{prefix_len}", std::net::Ipv4Addr::from(full)))
}
2 => {
let mut full = [0u8; 16];
full[..addr_bytes.len()].copy_from_slice(addr_bytes);
Ok(format!("{}/{prefix_len}", std::net::Ipv6Addr::from(full)))
}
other => Err(CcrVerifyError::Decode(CcrDecodeError::Parse(format!(
"unsupported AFI {other}"
)))),
}
}
fn count_roa_block_entries(blocks: &[Vec<u8>]) -> usize {
blocks
.iter()
.map(|block| {
decode_roa_family_block(block)
.map(|(_, entries)| entries.len())
.unwrap_or(0)
})
.sum()
}

226
src/cli/mod.rs Normal file
View File

@ -0,0 +1,226 @@
//! Narrow command-line surface for the v0.1.0 RFC core.
mod validate;
/// Reject unrelated state layouts before selecting a synchronization mode.
pub(crate) fn check_state_root(root: &std::path::Path) -> Result<(), String> {
if !root.exists() {
return Ok(());
}
for entry in std::fs::read_dir(root).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if entry.file_name() != "repository-db"
|| !entry.file_type().map_err(|e| e.to_string())?.is_dir()
{
return Err(format!(
"unrecognized state layout at {}; use a fresh state directory",
root.display()
));
}
}
Ok(())
}
pub(crate) use validate::{VALIDATE_OPTIONS, VALIDATE_REPEATABLE};
/// Bounded default long enough for large public RIR snapshots.
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 300;
const DEFAULT_WORKER_COUNT: usize = 8;
const DEFAULT_REPO_SYNC_WORKER_COUNT: usize = 8;
const DEFAULT_WORKER_QUEUE_CAPACITY: usize = 256;
pub fn usage() -> String {
let validation = format!(
"panda-rpki {}\n\nUSAGE:\n panda-rpki validate --tal <file> --ta <file> [--tal <file> --ta <file> ...] --out <dir> [--rrdp-state-dir <dir>] [--rrdp-sync-mode auto|snapshot|delta] [--ccr-out <file>] [--parallel-phase2-object-workers <n>] [--parallel-max-repo-sync-workers-global <n>] [--parallel-phase2-worker-queue-capacity <n>] [--parallel-repo-worker-queue-capacity <n>] [--max-ca-depth <n>] [--http-timeout-secs <n>] [--http-root-cert <pem>]... [--log-level off|error|warn|info|debug|trace] [--log-format text|json]\n\nTAL/TA pairs are positional and may be repeated. RRDP state is an explicit protocol-resume directory, not a validation cache. The public v0.1.0 fast path accepts HTTPS RRDP notification/snapshot/delta URLs only.",
crate::VERSION
);
format!("{validation}\n\n{}", crate::daemon::usage())
}
pub fn run<I, S>(args: I) -> Result<(), String>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args: Vec<String> = args.into_iter().map(Into::into).collect();
if args.is_empty() || matches!(args.first().map(String::as_str), Some("--help" | "-h")) {
return Err(usage());
}
match args[0].as_str() {
"validate" => validate::run(&args[1..]),
"daemon" => crate::daemon::run(&args[1..]),
_ => Err(format!("unknown command: {}", args[0])),
}
}
#[derive(Debug)]
pub(crate) struct Options {
values: std::collections::BTreeMap<String, Vec<String>>,
}
impl Options {
pub(crate) fn parse(
args: &[String],
allowed: &[&str],
repeatable: &[&str],
) -> Result<Self, String> {
let mut values = std::collections::BTreeMap::<String, Vec<String>>::new();
let mut index = 0;
while index < args.len() {
let flag = &args[index];
if matches!(flag.as_str(), "--help" | "-h") {
return Err(usage());
}
let name = flag
.strip_prefix("--")
.ok_or_else(|| format!("expected an option, got {flag}"))?;
if !allowed.contains(&name) {
return Err(format!("unsupported option: {flag}"));
}
index += 1;
let value = args
.get(index)
.ok_or_else(|| format!("{flag} requires a value"))?;
if value.starts_with("--") {
return Err(format!("{flag} requires a value"));
}
let entry = values.entry(name.to_string()).or_default();
if !repeatable.contains(&name) && !entry.is_empty() {
return Err(format!("option repeated: {flag}"));
}
entry.push(value.clone());
index += 1;
}
Ok(Self { values })
}
pub(crate) fn required(&self, name: &str) -> Result<&str, String> {
self.values
.get(name)
.and_then(|values| values.first())
.map(String::as_str)
.ok_or_else(|| format!("--{name} is required"))
}
pub(crate) fn values(&self, name: &str) -> &[String] {
self.values.get(name).map(Vec::as_slice).unwrap_or(&[])
}
pub(crate) fn parse_or<T>(&self, name: &str, default: T) -> Result<T, String>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
self.values
.get(name)
.and_then(|values| values.first())
.map(|value| {
value
.parse()
.map_err(|error| format!("invalid --{name}: {error}"))
})
.unwrap_or(Ok(default))
}
pub(crate) fn optional_parse<T>(&self, name: &str) -> Result<Option<T>, String>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
self.values
.get(name)
.and_then(|values| values.first())
.map(|value| {
value
.parse()
.map(Some)
.map_err(|error| format!("invalid --{name}: {error}"))
})
.unwrap_or(Ok(None))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_layout_rejects_unrecognized_entries_without_modifying_them() {
let root = tempfile::tempdir().unwrap();
check_state_root(root.path()).unwrap();
std::fs::create_dir(root.path().join("repository-db")).unwrap();
check_state_root(root.path()).unwrap();
let unknown = root.path().join("previous-state");
std::fs::create_dir(&unknown).unwrap();
std::fs::write(unknown.join("checkpoint"), b"retain").unwrap();
assert!(
check_state_root(root.path())
.unwrap_err()
.contains("unrecognized state layout")
);
assert_eq!(
std::fs::read(unknown.join("checkpoint")).unwrap(),
b"retain"
);
}
#[test]
fn parser_rejects_private_or_unknown_options() {
let error = Options::parse(&["--extra".to_string(), "1".to_string()], &["out"], &[])
.expect_err("unknown option");
assert!(error.contains("unsupported option"));
}
#[test]
fn parser_preserves_repeatable_tal_pairs() {
let options = Options::parse(
&[
"--tal".to_string(),
"one.tal".to_string(),
"--ta".to_string(),
"one.cer".to_string(),
"--tal".to_string(),
"two.tal".to_string(),
"--ta".to_string(),
"two.cer".to_string(),
],
&["tal", "ta"],
&["tal", "ta"],
)
.expect("repeatable pairs");
assert_eq!(options.values("tal"), ["one.tal", "two.tal"]);
assert_eq!(options.values("ta"), ["one.cer", "two.cer"]);
}
#[test]
fn parser_rejects_non_repeatable_worker_option() {
let error = Options::parse(
&[
"--workers".to_string(),
"2".to_string(),
"--workers".to_string(),
"3".to_string(),
],
&["workers"],
&[],
)
.expect_err("worker option should not repeat");
assert!(error.contains("option repeated"));
}
#[test]
fn parser_preserves_repeatable_http_root_certificates() {
let options = Options::parse(
&[
"--http-root-cert".to_string(),
"one.pem".to_string(),
"--http-root-cert".to_string(),
"two.pem".to_string(),
],
&["http-root-cert"],
&["http-root-cert"],
)
.expect("repeatable HTTP roots");
assert_eq!(options.values("http-root-cert"), ["one.pem", "two.pem"]);
}
}

276
src/cli/validate.rs Normal file
View File

@ -0,0 +1,276 @@
//! Parse validation options into typed runtime configuration.
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SyncMode {
Auto,
Snapshot,
Delta,
}
pub(crate) const VALIDATE_OPTIONS: &[&str] = &[
"tal",
"log-level",
"log-format",
"ta",
"tal-id",
"ta-constraints",
"out",
"ccr-out",
"rrdp-state-dir",
"rrdp-sync-mode",
"max-ca-depth",
"http-timeout-secs",
"http-root-cert",
"parallel-max-repo-sync-workers-global",
"parallel-phase2-object-workers",
"parallel-phase2-worker-queue-capacity",
"parallel-repo-worker-queue-capacity",
];
pub(crate) const VALIDATE_REPEATABLE: &[&str] =
&["tal", "ta", "tal-id", "ta-constraints", "http-root-cert"];
pub(super) fn run(args: &[String]) -> Result<(), String> {
let options = super::Options::parse(args, VALIDATE_OPTIONS, VALIDATE_REPEATABLE)?;
let level = resolve_setting(
options.values("log-level"),
std::env::var("PANDA_RPKI_LOG_LEVEL").ok(),
"info",
);
let format = resolve_setting(
options.values("log-format"),
std::env::var("PANDA_RPKI_LOG_FORMAT").ok(),
"text",
);
crate::logging::configure(&level, &format)?;
let tal_paths = options.values("tal");
let ta_paths = options.values("ta");
if tal_paths.is_empty() || ta_paths.is_empty() || tal_paths.len() != ta_paths.len() {
return Err("--tal and --ta must be repeated as equal-length positional pairs".to_string());
}
let requested_ids = options.values("tal-id");
if !requested_ids.is_empty() && requested_ids.len() != tal_paths.len() {
return Err("--tal-id must be supplied once for each --tal when used".to_string());
}
let max_ca_depth = options.parse_or("max-ca-depth", 64_usize)?;
let timeout = options.parse_or("http-timeout-secs", super::DEFAULT_HTTP_TIMEOUT_SECS)?;
let workers = options
.optional_parse("parallel-phase2-object-workers")?
.unwrap_or(super::DEFAULT_WORKER_COUNT);
let repo_workers = options
.optional_parse("parallel-max-repo-sync-workers-global")?
.unwrap_or(super::DEFAULT_REPO_SYNC_WORKER_COUNT);
let phase2_queue = options.parse_or(
"parallel-phase2-worker-queue-capacity",
super::DEFAULT_WORKER_QUEUE_CAPACITY,
)?;
let repo_queue = options.parse_or(
"parallel-repo-worker-queue-capacity",
super::DEFAULT_WORKER_QUEUE_CAPACITY,
)?;
if workers == 0 || repo_workers == 0 || phase2_queue == 0 || repo_queue == 0 {
return Err("worker counts and queue capacities must be greater than zero".to_string());
}
let out = PathBuf::from(options.required("out")?);
fs::create_dir_all(&out)
.map_err(|error| format!("create output directory {}: {error}", out.display()))?;
let state_root = options
.values("rrdp-state-dir")
.first()
.map(PathBuf::from)
.unwrap_or_else(|| out.join(".state"));
fs::create_dir_all(&state_root)
.map_err(|error| format!("create state directory {}: {error}", state_root.display()))?;
let db_path = state_root.join("repository-db");
super::check_state_root(&state_root)?;
let sync_mode = parse_sync_mode(options.values("rrdp-sync-mode"))?;
if sync_mode == SyncMode::Snapshot && directory_has_entries(&db_path)? {
return Err(format!(
"snapshot mode requires a fresh fast-path state directory; existing database: {}",
db_path.display()
));
}
if sync_mode == SyncMode::Delta && !directory_has_entries(&db_path)? {
return Err(format!(
"delta mode requires an existing fast-path state database: {}",
db_path.display()
));
}
use crate::runtime::RunConfig;
use crate::scheduler::config::{ParallelPhase1Config, ParallelPhase2Config};
use crate::scheduler::types::TalInputSpec;
let mut tal_inputs: Vec<_> = tal_paths
.iter()
.zip(ta_paths)
.map(|(tal, ta)| TalInputSpec::from_file_path_with_ta(tal, ta))
.collect();
for (input, id) in tal_inputs.iter_mut().zip(requested_ids) {
input.tal_id = id.clone();
}
let tal_ids: Vec<String> = tal_inputs
.iter()
.map(|input| input.tal_id.clone())
.collect();
let ta_constraints = crate::ta_constraints::TaConstraintsByTal::load_for_tals(
&tal_inputs,
options.values("ta-constraints"),
)?;
let config = RunConfig {
db_path,
tal_paths: tal_paths.iter().map(PathBuf::from).collect(),
ta_paths: ta_paths.iter().map(PathBuf::from).collect(),
tal_path: tal_paths.first().map(PathBuf::from),
ta_path: ta_paths.first().map(PathBuf::from),
tal_inputs,
ta_constraints,
parallel_phase1_config: ParallelPhase1Config {
max_repo_sync_workers_global: repo_workers,
max_pending_repo_results: repo_queue.max(1024),
..Default::default()
},
parallel_phase2_config: ParallelPhase2Config {
object_workers: workers,
worker_queue_capacity: phase2_queue,
..Default::default()
},
max_ca_depth,
http_timeout_secs: timeout,
http_root_cert_paths: options
.values("http-root-cert")
.iter()
.map(PathBuf::from)
.collect(),
rsync_timeout_secs: 30,
summary_out_path: Some(out.join("summary.json")),
vrps_csv_out_path: Some(out.join(".vrps-source.csv")),
vaps_csv_out_path: Some(out.join("vaps.csv")),
analysis_out_path: Some(out.join("analysis")),
analyze: true,
ccr_out_path: options.values("ccr-out").first().map(PathBuf::from),
..Default::default()
};
let started = std::time::Instant::now();
crate::logging::emit(crate::logging::Level::Info, "validation_started", || {
serde_json::json!({
"tal_count": tal_paths.len(), "tal_ids": tal_ids, "phase2_workers": workers, "repo_workers": repo_workers,
"sync_mode": format!("{sync_mode:?}").to_lowercase(),
})
});
crate::runtime::run_config(config).inspect_err(|_| {
crate::logging::emit(crate::logging::Level::Error, "validation_failed", || {
serde_json::json!({
"elapsed_ms": started.elapsed().as_millis(),
})
});
})?;
let fast_vrps = out.join(".vrps-source.csv");
convert_vrp_csv(&fast_vrps, &out.join("vrps.csv"))?;
crate::logging::emit(crate::logging::Level::Info, "validation_completed", || {
serde_json::json!({
"elapsed_ms": started.elapsed().as_millis(), "phase2_workers": workers, "repo_workers": repo_workers,
})
});
Ok(())
}
fn parse_sync_mode(values: &[String]) -> Result<SyncMode, String> {
match values.first().map(String::as_str).unwrap_or("auto") {
"auto" => Ok(SyncMode::Auto),
"snapshot" => Ok(SyncMode::Snapshot),
"delta" => Ok(SyncMode::Delta),
other => Err(format!(
"--rrdp-sync-mode must be auto, snapshot, or delta; got {other}"
)),
}
}
fn resolve_setting(cli_values: &[String], env_value: Option<String>, default: &str) -> String {
cli_values
.first()
.cloned()
.or(env_value)
.unwrap_or_else(|| default.to_string())
}
fn directory_has_entries(path: &Path) -> Result<bool, String> {
if !path.exists() {
return Ok(false);
}
let mut entries = fs::read_dir(path)
.map_err(|error| format!("read state database {}: {error}", path.display()))?;
Ok(entries
.next()
.transpose()
.map_err(|error| format!("read state database entry {}: {error}", path.display()))?
.is_some())
}
fn convert_vrp_csv(input: &Path, output: &Path) -> Result<(), String> {
let input_file = File::open(input)
.map_err(|error| format!("open source VRP CSV {}: {error}", input.display()))?;
let output_file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(output)
.map_err(|error| format!("create Panda RPKI VRP CSV {}: {error}", output.display()))?;
let mut reader = BufReader::new(input_file);
let mut writer = BufWriter::new(output_file);
writer
.write_all(b"asn,prefix,max_length\n")
.map_err(|error| format!("write Panda RPKI VRP CSV header: {error}"))?;
let mut line = String::new();
let mut first = true;
loop {
line.clear();
if reader
.read_line(&mut line)
.map_err(|error| format!("read source VRP CSV: {error}"))?
== 0
{
break;
}
if first {
first = false;
continue;
}
let mut fields = line.trim_end_matches(['\r', '\n']).splitn(4, ',');
let Some(asn) = fields.next() else { continue };
let Some(prefix) = fields.next() else {
continue;
};
let Some(max_length) = fields.next() else {
continue;
};
writeln!(writer, "{asn},{prefix},{max_length}")
.map_err(|error| format!("write Panda RPKI VRP CSV: {error}"))?;
}
writer
.flush()
.map_err(|error| format!("flush Panda RPKI VRP CSV: {error}"))
}
#[cfg(test)]
mod tests {
use super::resolve_setting;
#[test]
fn logging_setting_precedence_is_cli_then_env_then_default() {
assert_eq!(
resolve_setting(&["debug".to_string()], Some("error".to_string()), "info"),
"debug"
);
assert_eq!(
resolve_setting(&[], Some("json".to_string()), "text"),
"json"
);
assert_eq!(resolve_setting(&[], None, "info"), "info");
}
}

78
src/daemon/metrics.rs Normal file
View File

@ -0,0 +1,78 @@
//! Process CPU time and peak memory metrics.
use serde::Serialize;
use std::fs;
use std::path::Path;
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ProcessMetrics {
time_wrapper_used: bool,
time_output_path: Option<String>,
user_seconds: Option<f64>,
system_seconds: Option<f64>,
cpu_percent: Option<f64>,
elapsed_raw: Option<String>,
max_rss_kb: Option<u64>,
exit_status_from_time: Option<i32>,
parse_error: Option<String>,
}
pub(super) fn collect_process_metrics(
time_wrapper_used: bool,
time_output_path: &Path,
) -> ProcessMetrics {
if !time_wrapper_used {
return ProcessMetrics {
time_wrapper_used,
time_output_path: None,
user_seconds: None,
system_seconds: None,
cpu_percent: None,
elapsed_raw: None,
max_rss_kb: None,
exit_status_from_time: None,
parse_error: None,
};
}
let mut metrics = ProcessMetrics {
time_wrapper_used,
time_output_path: Some(time_output_path.to_string_lossy().into_owned()),
user_seconds: None,
system_seconds: None,
cpu_percent: None,
elapsed_raw: None,
max_rss_kb: None,
exit_status_from_time: None,
parse_error: None,
};
let text = match fs::read_to_string(time_output_path) {
Ok(text) => text,
Err(err) => {
metrics.parse_error = Some(format!(
"read process time output failed: {}: {err}",
time_output_path.display()
));
return metrics;
}
};
for line in text.lines() {
let line = line.trim();
if let Some(value) = line.strip_prefix("User time (seconds):") {
metrics.user_seconds = value.trim().parse::<f64>().ok();
} else if let Some(value) = line.strip_prefix("System time (seconds):") {
metrics.system_seconds = value.trim().parse::<f64>().ok();
} else if let Some(value) = line.strip_prefix("Percent of CPU this job got:") {
metrics.cpu_percent = value.trim().trim_end_matches('%').parse::<f64>().ok();
} else if let Some(value) =
line.strip_prefix("Elapsed (wall clock) time (h:mm:ss or m:ss):")
{
metrics.elapsed_raw = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("Maximum resident set size (kbytes):") {
metrics.max_rss_kb = value.trim().parse::<u64>().ok();
} else if let Some(value) = line.strip_prefix("Exit status:") {
metrics.exit_status_from_time = value.trim().parse::<i32>().ok();
}
}
metrics
}

374
src/daemon/mod.rs Normal file
View File

@ -0,0 +1,374 @@
//! Serial scheduling with an independent validator process for each run.
//! Reuses its atomic status/JSONL writers, serial loop and retention model.
//! The adapter runs the public validate command with durable protocol state.
mod metrics;
mod process;
mod storage;
use crate::cli::{Options, VALIDATE_OPTIONS, VALIDATE_REPEATABLE};
use crate::logging::{Level, emit};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Instant;
use storage::{append_json_line, write_json_pretty};
const OWNED: &[&str] = &["out", "ccr-out", "rrdp-state-dir", "rrdp-sync-mode"];
const CONTROLLER: &[&str] = &[
"state-root",
"interval-secs",
"max-runs",
"retain-runs",
"run-timeout-secs",
"shutdown-grace-secs",
];
pub(crate) fn usage() -> &'static str {
"DAEMON:\n panda-rpki daemon --state-root <dir> [--interval-secs <n>] [--max-runs <n>] [--retain-runs <n>] [--run-timeout-secs <n>] [--shutdown-grace-secs <n>] -- --tal <file> --ta <file> [validation options]\n\nDefaults: interval=60s after each run, unlimited runs, retain=10, timeout=0 (disabled), shutdown grace=30s. Reuses protocol state across runs/restarts; automatically exports CCR and CSV per run. Output/state/sync-mode options are controller-owned."
}
struct Config {
root: PathBuf,
interval: u64,
max_runs: Option<u64>,
retain: usize,
timeout: u64,
grace: u64,
child_args: Vec<String>,
log_level: String,
log_format: String,
}
fn parse(args: &[String]) -> Result<Config, String> {
let split = args
.iter()
.position(|arg| arg == "--")
.ok_or("daemon requires -- before validation options")?;
let options = Options::parse(&args[..split], CONTROLLER, &[])?;
let child_args = args[split + 1..].to_vec();
let child = Options::parse(&child_args, VALIDATE_OPTIONS, VALIDATE_REPEATABLE)?;
for name in OWNED {
if !child.values(name).is_empty() {
return Err(format!("daemon manages --{name}; use --state-root"));
}
}
let count = child.values("tal").len();
if count == 0 || child.values("ta").len() != count {
return Err("--tal and --ta must be repeated as equal-length positional pairs".into());
}
if !child.values("tal-id").is_empty() && child.values("tal-id").len() != count {
return Err("--tal-id must be supplied once for each --tal when used".into());
}
for name in [
"parallel-phase2-object-workers",
"parallel-max-repo-sync-workers-global",
"parallel-phase2-worker-queue-capacity",
"parallel-repo-worker-queue-capacity",
] {
if child.parse_or(name, 1usize)? == 0 {
return Err(format!("--{name} must be greater than zero"));
}
}
child.parse_or("max-ca-depth", 64usize)?;
child.parse_or("http-timeout-secs", 300u64)?;
for (name, variable, default) in [
("log-level", "PANDA_RPKI_LOG_LEVEL", "info"),
("log-format", "PANDA_RPKI_LOG_FORMAT", "text"),
] {
let value = child
.values(name)
.first()
.cloned()
.or_else(|| std::env::var(variable).ok())
.unwrap_or(default.into());
if name == "log-level" {
crate::logging::parse_level(&value)?;
} else if !["text", "json"].contains(&value.as_str()) {
return Err(format!("invalid log format: {value}"));
}
}
let level = child
.values("log-level")
.first()
.cloned()
.or_else(|| std::env::var("PANDA_RPKI_LOG_LEVEL").ok())
.unwrap_or("info".into());
let format = child
.values("log-format")
.first()
.cloned()
.or_else(|| std::env::var("PANDA_RPKI_LOG_FORMAT").ok())
.unwrap_or("text".into());
let config = Config {
root: PathBuf::from(options.required("state-root")?),
interval: options.parse_or("interval-secs", 60u64)?,
max_runs: options.optional_parse("max-runs")?,
retain: options.parse_or("retain-runs", 10usize)?,
timeout: options.parse_or("run-timeout-secs", 0u64)?,
grace: options.parse_or("shutdown-grace-secs", 30u64)?,
child_args,
log_level: level,
log_format: format,
};
if config.root.as_os_str().is_empty() || config.max_runs == Some(0) || config.retain == 0 {
return Err("state-root must be non-empty; max-runs and retain-runs must be > 0".into());
}
Ok(config)
}
#[derive(Default, Serialize, Deserialize)]
struct Lifecycle {
last_seq: u64,
last_success: bool,
}
fn now() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.expect("UTC timestamp formatting")
}
fn regular_directory(path: &Path) -> Result<(), String> {
match fs::symlink_metadata(path) {
Ok(meta) if !meta.file_type().is_dir() => {
Err(format!("not a real directory: {}", path.display()))
}
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir_all(path).map_err(|e| e.to_string())
}
Err(e) => Err(e.to_string()),
}
}
fn read_json(path: &Path) -> Result<Value, String> {
let bytes = fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
serde_json::from_slice(&bytes).map_err(|e| format!("decode {}: {e}", path.display()))
}
fn run_sequence(name: &str) -> Option<u64> {
let suffix = name.strip_prefix("run_")?;
if suffix.len() < 6 || !suffix.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
suffix.parse().ok()
}
fn sync_mode(state: &Path, last_success: bool) -> Result<&'static str, String> {
crate::cli::check_state_root(state)?;
let db = state.join("repository-db");
if !db.exists()
|| fs::read_dir(&db)
.map_err(|e| e.to_string())?
.next()
.transpose()
.map_err(|e| e.to_string())?
.is_none()
{
Ok("snapshot")
} else if last_success {
Ok("delta")
} else {
Ok("auto")
}
}
// Only completed run directories owned by this controller
// are eligible. Unrelated paths, symlinks and incomplete evidence stay untouched.
fn apply_retention(runs: &Path, retain: usize) -> Result<Vec<String>, String> {
let mut dirs = Vec::new();
for entry in fs::read_dir(runs).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy().into_owned();
if entry.file_type().map_err(|e| e.to_string())?.is_dir()
&& let Some(seq) = run_sequence(&name)
&& let Ok(summary) = read_json(&entry.path().join("run-summary.json"))
&& summary["controller"] == "panda-rpki-daemon-v1"
&& summary["run_seq"] == seq
&& ["success", "failed"]
.iter()
.any(|s| summary["status"] == *s)
{
dirs.push((seq, entry.path(), name));
}
}
dirs.sort_by_key(|x| x.0);
let remove_count = dirs.len().saturating_sub(retain);
let mut removed = Vec::new();
for (_, path, name) in dirs.into_iter().take(remove_count) {
fs::remove_dir_all(&path).map_err(|e| format!("remove old run {}: {e}", path.display()))?;
removed.push(name);
}
Ok(removed)
}
fn status(root: &Path, state: &str, seq: u64, completed: u64) -> Result<(), String> {
write_json_pretty(
&root.join("daemon-status.json"),
&json!({
"controller": "panda-rpki-daemon-v1", "state": state,
"pid": std::process::id(), "run_seq": seq, "runs_completed": completed, "updated_at": now(),
}),
)
}
pub(crate) fn run(args: &[String]) -> Result<(), String> {
if args.iter().any(|a| a == "--help" || a == "-h") {
return Err(crate::cli::usage());
}
let mut config = parse(args)?;
crate::logging::configure(&config.log_level, &config.log_format)?;
regular_directory(&config.root)?;
config.root = fs::canonicalize(&config.root).map_err(|e| e.to_string())?;
let lock = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(config.root.join("daemon.lock"))
.map_err(|e| e.to_string())?;
lock.try_lock()
.map_err(|e| format!("daemon state-root is locked: {e}"))?;
let _signals = process::Signals::install()?;
let result = controller(&config);
if result.is_err() {
let path = config.root.join("daemon-status.json");
if let Ok(mut last) = read_json(&path) {
last["state"] = json!("failed");
last["updated_at"] = json!(now());
let _ = write_json_pretty(&path, &last);
}
}
result
}
fn controller(config: &Config) -> Result<(), String> {
let root = &config.root;
let runs = root.join("runs");
let state = root.join("state");
regular_directory(&runs)?;
regular_directory(&state)?;
let lifecycle_path = root.join("lifecycle.json");
let mut lifecycle: Lifecycle = if lifecycle_path.exists() {
serde_json::from_value(read_json(&lifecycle_path)?).map_err(|e| e.to_string())?
} else {
Lifecycle::default()
};
for entry in fs::read_dir(&runs).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if let Some(seq) = run_sequence(&entry.file_name().to_string_lossy()) {
if seq > lifecycle.last_seq {
lifecycle.last_success = false;
}
lifecycle.last_seq = lifecycle.last_seq.max(seq);
}
}
let executable = std::env::current_exe().map_err(|e| e.to_string())?;
let mut completed = 0u64;
let mut failures = 0u64;
status(root, "starting", lifecycle.last_seq, completed)?;
while !process::stopping() && config.max_runs.is_none_or(|n| completed < n) {
let mode = sync_mode(&state, lifecycle.last_success)?;
lifecycle.last_seq = lifecycle
.last_seq
.checked_add(1)
.ok_or("run sequence overflow")?;
lifecycle.last_success = false;
write_json_pretty(&lifecycle_path, &lifecycle)?;
let seq = lifecycle.last_seq;
let out = runs.join(format!("run_{seq:06}"));
fs::create_dir(&out).map_err(|e| format!("create run: {e}"))?;
let started_at = now();
let start = Instant::now();
let mut meta = json!({"controller": "panda-rpki-daemon-v1", "run_seq": seq,
"status": "running", "sync_mode": mode, "started_at": started_at});
write_json_pretty(&out.join("run-meta.json"), &meta)?;
status(root, "running", seq, completed)?;
emit(
Level::Info,
"daemon_run_started",
|| json!({"run_seq": seq, "sync_mode": mode}),
);
// Child-per-run execution and stdout/stderr redirection; paths
// are supplied as arguments, never rendered into shell command text.
let time_path = out.join("process-time.txt");
let with_time = Path::new("/usr/bin/time").is_file();
let mut command = if with_time {
let mut command = Command::new("/usr/bin/time");
command
.args(["-v", "-o"])
.arg(&time_path)
.arg("--")
.arg(&executable);
command
} else {
Command::new(&executable)
};
command.env("LC_ALL", "C");
command
.arg("validate")
.args(&config.child_args)
.arg("--out")
.arg(&out)
.arg("--ccr-out")
.arg(out.join("result.ccr"))
.arg("--rrdp-state-dir")
.arg(&state)
.arg("--rrdp-sync-mode")
.arg(mode)
.stdin(Stdio::null())
.stdout(File::create(out.join("stdout.log")).map_err(|e| e.to_string())?)
.stderr(File::create(out.join("stderr.log")).map_err(|e| e.to_string())?);
let result = process::execute(&mut command, config.timeout, config.grace);
let (exit_code, failure) = match result {
Ok((exit, reason)) => (
exit.code(),
reason
.map(str::to_string)
.or_else(|| (!exit.success()).then(|| format!("validator exited: {exit}"))),
),
Err(error) => (None, Some(error)),
};
let summary = read_json(&out.join("summary.json"));
let failure = failure.or_else(|| summary.as_ref().err().cloned());
let success = failure.is_none();
meta["status"] = json!(if success { "success" } else { "failed" });
meta["finished_at"] = json!(now());
meta["wall_ms"] = json!(start.elapsed().as_millis() as u64);
meta["exit_code"] = json!(exit_code);
meta["error"] = json!(failure);
meta["summary"] = summary.unwrap_or(Value::Null);
meta["process_metrics"] =
serde_json::to_value(metrics::collect_process_metrics(with_time, &time_path))
.map_err(|e| e.to_string())?;
write_json_pretty(&out.join("run-meta.json"), &meta)?;
write_json_pretty(&out.join("run-summary.json"), &meta)?;
append_json_line(&root.join("run-summary.jsonl"), &meta)?;
lifecycle.last_success = success;
write_json_pretty(&lifecycle_path, &lifecycle)?;
completed += 1;
failures += u64::from(!success);
let removed = apply_retention(&runs, config.retain)?;
emit(
if success { Level::Info } else { Level::Warn },
"daemon_run_completed",
|| json!({"run_seq": seq, "success": success, "wall_ms": meta["wall_ms"], "removed_runs": removed}),
);
if process::stopping() || config.max_runs.is_some_and(|n| completed >= n) {
break;
}
status(root, "sleeping", seq, completed)?;
process::sleep(config.interval);
}
status(root, "exited", lifecycle.last_seq, completed)?;
if failures > 0 {
Err(format!("daemon completed with {failures} failed run(s)"))
} else {
Ok(())
}
}
#[cfg(test)]
mod tests;

104
src/daemon/process.rs Normal file
View File

@ -0,0 +1,104 @@
//! Unix process groups, timeouts and signal handling.
use std::process::{Child, Command, ExitStatus};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
static STOP: AtomicBool = AtomicBool::new(false);
extern "C" fn stop(_: libc::c_int) {
STOP.store(true, Ordering::Relaxed);
}
pub(super) fn stopping() -> bool {
STOP.load(Ordering::Relaxed)
}
pub(super) struct Signals(Vec<(libc::c_int, libc::sighandler_t)>);
impl Signals {
#[allow(unsafe_code)]
pub(super) fn install() -> Result<Self, String> {
STOP.store(false, Ordering::Relaxed);
let mut guard = Self(Vec::new());
for signal in [libc::SIGINT, libc::SIGTERM] {
// SAFETY: handler only writes a lock-free atomic; ABI matches signal(2).
let previous = unsafe { libc::signal(signal, stop as libc::sighandler_t) };
if previous == libc::SIG_ERR {
return Err(std::io::Error::last_os_error().to_string());
}
guard.0.push((signal, previous));
}
Ok(guard)
}
}
impl Drop for Signals {
#[allow(unsafe_code)]
fn drop(&mut self) {
for (signal, handler) in &self.0 {
// SAFETY: restore the exact handler returned by signal(2).
unsafe { libc::signal(*signal, *handler) };
}
}
}
struct ChildGuard(Child);
impl ChildGuard {
#[allow(unsafe_code)]
fn signal_group(&self, signal: libc::c_int) {
// SAFETY: child is launched in its own group, with pgid equal to child PID.
unsafe { libc::kill(-(self.0.id() as libc::pid_t), signal) };
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
// Reap every child and terminate residual descendants on all error paths.
self.signal_group(libc::SIGKILL);
let _ = self.0.wait();
}
}
pub(super) fn execute(
command: &mut Command,
timeout: u64,
grace: u64,
) -> Result<(ExitStatus, Option<&'static str>), String> {
use std::os::unix::process::CommandExt;
command.process_group(0);
let mut child = ChildGuard(
command
.spawn()
.map_err(|e| format!("spawn validator: {e}"))?,
);
let start = Instant::now();
let mut stop_at = None;
let mut terminated_at = None;
let mut reason = None;
loop {
if let Some(status) = child
.0
.try_wait()
.map_err(|e| format!("wait validator: {e}"))?
{
return Ok((status, reason));
}
if stopping() {
stop_at.get_or_insert_with(Instant::now);
}
let timed_out = timeout > 0 && start.elapsed().as_secs() >= timeout;
let grace_expired = stop_at.is_some_and(|at: Instant| at.elapsed().as_secs() >= grace);
if terminated_at.is_none() && (timed_out || grace_expired) {
reason = Some(if timed_out { "timeout" } else { "interrupted" });
child.signal_group(libc::SIGTERM);
terminated_at = Some(Instant::now());
}
if terminated_at.is_some_and(|at| at.elapsed() >= Duration::from_secs(2)) {
child.signal_group(libc::SIGKILL);
}
std::thread::sleep(Duration::from_millis(50));
}
}
pub(super) fn sleep(seconds: u64) {
let start = Instant::now();
while !stopping() && start.elapsed().as_secs() < seconds {
std::thread::sleep(Duration::from_millis(100));
}
}

46
src/daemon/storage.rs Normal file
View File

@ -0,0 +1,46 @@
//! Atomic JSON and append-only history writers.
use serde::Serialize;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::Path;
pub(super) fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create parent dir failed: {}: {e}", parent.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()
)
})
}
pub(super) fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("create parent dir failed: {}: {e}", parent.display()))?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| format!("open jsonl failed: {}: {e}", path.display()))?;
serde_json::to_writer(&mut file, value)
.map_err(|e| format!("write jsonl failed: {}: {e}", path.display()))?;
file.write_all(b"\n")
.map_err(|e| format!("flush jsonl failed: {}: {e}", path.display()))
}

113
src/daemon/tests.rs Normal file
View File

@ -0,0 +1,113 @@
use super::*;
fn args(extra: &[&str]) -> Vec<String> {
[
vec!["--state-root", "data"],
extra.to_vec(),
vec!["--", "--tal", "a.tal", "--ta", "a.cer"],
]
.concat()
.into_iter()
.map(str::to_owned)
.collect()
}
#[test]
fn defaults_and_invalid_controller_options() {
let config = parse(&args(&[])).unwrap();
assert_eq!(
(
config.interval,
config.max_runs,
config.retain,
config.timeout,
config.grace
),
(60, None, 10, 0, 30)
);
for bad in [
vec!["--max-runs", "0"],
vec!["--retain-runs", "0"],
vec!["--interval-secs", "-1"],
vec!["--run-timeout-secs", "bad"],
vec!["--interval-secs", "2", "--interval-secs", "3"],
] {
assert!(parse(&args(&bad)).is_err());
}
let config = parse(&args(&["--interval-secs", "0", "--max-runs", "3"])).unwrap();
assert_eq!((config.interval, config.max_runs), (0, Some(3)));
}
#[test]
fn rejects_child_overrides_and_unknown_flags() {
for flag in [
"--out",
"--rrdp-state-dir",
"--rrdp-sync-mode",
"--ccr-out",
"--unknown",
] {
let mut argv = args(&[]);
argv.extend([flag.into(), "some-value".into()]);
assert!(parse(&argv).is_err(), "{flag}");
}
let mut argv = args(&[]);
argv.extend(["--parallel-phase2-object-workers".into(), "0".into()]);
assert!(parse(&argv).is_err());
}
#[test]
fn protocol_state_selects_snapshot_delta_and_recovery() {
let root = tempfile::tempdir().unwrap();
assert_eq!(sync_mode(root.path(), false).unwrap(), "snapshot");
let db = root.path().join("repository-db");
fs::create_dir(&db).unwrap();
assert_eq!(sync_mode(root.path(), true).unwrap(), "snapshot");
fs::write(db.join("CURRENT"), b"x").unwrap();
assert_eq!(sync_mode(root.path(), true).unwrap(), "delta");
assert_eq!(sync_mode(root.path(), false).unwrap(), "auto");
}
#[test]
fn retention_preserves_unrelated_incomplete_and_symlinked_runs() {
let root = tempfile::tempdir().unwrap();
for seq in 1..=3 {
let path = root.path().join(format!("run_{seq:06}"));
fs::create_dir(&path).unwrap();
write_json_pretty(
&path.join("run-summary.json"),
&json!({
"controller": "panda-rpki-daemon-v1", "run_seq": seq, "status": "success"
}),
)
.unwrap();
}
fs::create_dir(root.path().join("unrelated")).unwrap();
fs::create_dir(root.path().join("run_000004")).unwrap();
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), root.path().join("run_000005")).unwrap();
assert_eq!(
apply_retention(root.path(), 1).unwrap(),
["run_000001", "run_000002"]
);
for name in ["run_000003", "run_000004", "run_000005", "unrelated"] {
assert!(root.path().join(name).exists());
}
assert!(regular_directory(&root.path().join("run_000005")).is_err());
}
#[test]
fn atomic_json_writers_and_state_lock() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("state.json");
write_json_pretty(&path, &json!({"seq": 1})).unwrap();
write_json_pretty(&path, &json!({"seq": 2})).unwrap();
assert_eq!(read_json(&path).unwrap()["seq"], 2);
let lines = root.path().join("summary.jsonl");
append_json_line(&lines, &json!({"seq": 1})).unwrap();
append_json_line(&lines, &json!({"seq": 2})).unwrap();
assert_eq!(fs::read_to_string(lines).unwrap().lines().count(), 2);
let file = File::open(&path).unwrap();
file.try_lock().unwrap();
assert!(File::open(path).unwrap().try_lock().is_err());
}

23
src/lib.rs Normal file
View File

@ -0,0 +1,23 @@
//! RFC-scoped RPKI validation core.
//!
//! The supported integration surface is the validation CLI. Implementation
//! modules are grouped by repository, scheduling, validation, and output.
#![warn(unsafe_code)]
pub mod ccr;
pub mod cli;
mod daemon;
pub mod logging;
pub mod model;
pub mod output;
pub mod repository;
pub mod runtime;
pub mod scheduler;
pub mod ta_constraints;
pub mod validation;
#[cfg(test)]
pub(crate) mod test_support;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

335
src/logging.rs Normal file
View File

@ -0,0 +1,335 @@
//! Process-wide diagnostic output. Validation artifacts never use this sink.
use std::io::Write;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU8, Ordering};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum Level {
Error = 1,
Warn = 2,
Info = 3,
Debug = 4,
Trace = 5,
}
static FILTER: AtomicU8 = AtomicU8::new(Level::Info as u8);
static JSON: AtomicU8 = AtomicU8::new(0);
static OUTPUT_LOCK: Mutex<()> = Mutex::new(());
pub fn parse_level(value: &str) -> Result<u8, String> {
match value {
"off" => Ok(0),
"error" => Ok(1),
"warn" => Ok(2),
"info" => Ok(3),
"debug" => Ok(4),
"trace" => Ok(5),
_ => Err(format!(
"invalid log level: {value}; expected off|error|warn|info|debug|trace"
)),
}
}
pub fn configure(level: &str, format: &str) -> Result<(), String> {
let filter = parse_level(level)?;
let json = match format {
"text" => 0,
"json" => 1,
_ => return Err(format!("invalid log format: {format}; expected text|json")),
};
FILTER.store(filter, Ordering::Relaxed);
JSON.store(json, Ordering::Relaxed);
Ok(())
}
pub fn enabled(level: Level) -> bool {
level as u8 <= FILTER.load(Ordering::Relaxed)
}
fn redact_text(text: &str) -> String {
let mut output = String::with_capacity(text.len());
let lower = text.to_ascii_lowercase();
let mut cursor = 0;
while cursor < text.len() {
let Some(relative_start) = ["https://", "http://", "rsync://"]
.iter()
.filter_map(|scheme| lower[cursor..].find(scheme).map(|start| (start, *scheme)))
.min_by_key(|(start, _)| *start)
.map(|(start, _)| start)
else {
output.push_str(&text[cursor..]);
break;
};
let start = cursor + relative_start;
output.push_str(&text[cursor..start]);
let token_end = text[start..]
.find(char::is_whitespace)
.map(|offset| start + offset)
.unwrap_or(text.len());
let raw = &text[start..token_end];
let core = raw.trim_end_matches(|ch: char| ",.;:!?)]}'\"".contains(ch));
let suffix = &raw[core.len()..];
let redacted = url::Url::parse(core).map(|mut uri| {
let _ = uri.set_username("");
let _ = uri.set_password(None);
uri.set_query(None);
uri.set_fragment(None);
uri.to_string()
});
output.push_str(&redacted.unwrap_or_else(|_| "[redacted-uri]".to_string()));
output.push_str(suffix);
cursor = token_end;
}
// Free-form error strings can contain credentials outside a URL. Prefer
// dropping that text to guessing how much of an opaque credential to keep.
let lower = output.to_ascii_lowercase();
for name in [
"password",
"token",
"authorization",
"cookie",
"secret",
"api_key",
] {
for (start, _) in lower.match_indices(name) {
let suffix = lower[start + name.len()..].trim_start_matches([' ', '\"', '\'']);
if suffix.starts_with('=') || suffix.starts_with(':') {
return "[redacted-sensitive-text]".to_string();
}
}
}
output
}
fn redact(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(text) => *text = redact_text(text),
serde_json::Value::Array(items) => items.iter_mut().for_each(redact),
serde_json::Value::Object(fields) => {
for (key, value) in fields {
if ["password", "token", "authorization", "cookie", "secret"]
.iter()
.any(|word| key.to_ascii_lowercase().contains(word))
{
*value = serde_json::Value::String("[redacted]".into());
} else {
redact(value);
}
}
}
_ => {}
}
}
/// Payload creation is lazy, so disabled diagnostics do not allocate JSON.
pub fn emit(level: Level, event: &str, payload: impl FnOnce() -> serde_json::Value) {
let _ = emit_to(
FILTER.load(Ordering::Relaxed),
JSON.load(Ordering::Relaxed) != 0,
level,
event,
payload,
|line| {
let _guard = OUTPUT_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
std::io::stderr().lock().write_all(line)
},
);
}
fn emit_to(
filter: u8,
json: bool,
level: Level,
event: &str,
payload: impl FnOnce() -> serde_json::Value,
sink: impl FnOnce(&[u8]) -> std::io::Result<()>,
) -> std::io::Result<()> {
if level as u8 > filter {
return Ok(());
}
let timestamp = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default();
let mut fields = payload();
redact(&mut fields);
let record = serde_json::json!({
"timestamp": timestamp,
"level": format!("{level:?}").to_lowercase(),
"event": event,
"fields": fields,
});
let line = if json {
format!("{record}\n")
} else {
format!(
"{} {} {} {}\n",
timestamp,
record["level"].as_str().unwrap_or(""),
event,
record["fields"]
)
};
sink(line.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mixed_case_urls_and_opaque_credentials_are_redacted() {
assert_eq!(
redact_text("HTTPS://alice:pw@example.test/a?token=x#f"),
"https://example.test/a"
);
assert_eq!(redact_text("password=opaque"), "[redacted-sensitive-text]");
assert_eq!(
redact_text("Authorization: Bearer opaque"),
"[redacted-sensitive-text]"
);
}
#[test]
fn every_threshold_filters_before_payload_and_sink() {
for filter in 0..=5 {
for level in [
Level::Error,
Level::Warn,
Level::Info,
Level::Debug,
Level::Trace,
] {
let called = std::cell::Cell::new(false);
let written = std::cell::Cell::new(false);
emit_to(
filter,
true,
level,
"test",
|| {
called.set(true);
serde_json::json!({})
},
|_| {
written.set(true);
Ok(())
},
)
.unwrap();
assert_eq!(called.get(), level as u8 <= filter);
assert_eq!(written.get(), called.get());
}
}
}
#[test]
fn sink_failure_is_reported_without_panic_and_text_is_one_line() {
let result = emit_to(
5,
false,
Level::Error,
"failure",
|| serde_json::json!({"message":"one\ntwo"}),
|line| {
assert_eq!(line.iter().filter(|&&c| c == b'\n').count(), 1);
Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe))
},
);
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::BrokenPipe);
}
#[test]
fn concurrent_json_records_are_complete_and_parseable() {
let buffer = Mutex::new(Vec::new());
std::thread::scope(|scope| {
for worker in 0..8 {
let buffer = &buffer;
scope.spawn(move || {
for seq in 0..100 {
emit_to(
5,
true,
Level::Info,
"parallel",
|| serde_json::json!({"worker":worker,"seq":seq}),
|line| buffer.lock().unwrap().write_all(line),
)
.unwrap();
}
});
}
});
let data = String::from_utf8(buffer.into_inner().unwrap()).unwrap();
let records: Vec<serde_json::Value> = data
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(records.len(), 800);
let ids: std::collections::BTreeSet<_> = records
.iter()
.map(|r| {
(
r["fields"]["worker"].as_u64().unwrap(),
r["fields"]["seq"].as_u64().unwrap(),
)
})
.collect();
assert_eq!(ids.len(), 800);
}
#[test]
fn levels_have_threshold_order_and_reject_mistakes() {
for (index, name) in ["off", "error", "warn", "info", "debug", "trace"]
.iter()
.enumerate()
{
assert_eq!(parse_level(name).unwrap(), index as u8);
}
assert!(parse_level("verbose").is_err());
}
#[test]
fn diagnostics_redact_uri_credentials_and_nested_secrets() {
let mut value = serde_json::json!({"error": "fetch https://alice:secret@example.test/x?token=private#fragment failed", "nested": [{"authorization": "secret"}]});
redact(&mut value);
assert_eq!(value["error"], "fetch https://example.test/x failed");
assert_eq!(value["nested"][0]["authorization"], "[redacted]");
}
#[test]
fn diagnostics_redact_punctuated_and_multiple_urls() {
let mut value = serde_json::json!({
"message": "(https://alice:secret@example.test/a?token=x#f), rsync://bob:pw@example.test/m/;"
});
redact(&mut value);
assert_eq!(
value["message"],
"(https://example.test/a), rsync://example.test/m/;"
);
}
#[test]
fn invalid_configuration_does_not_change_filter() {
let before = FILTER.load(Ordering::Relaxed);
assert!(configure("trace", "xml").is_err());
assert_eq!(FILTER.load(Ordering::Relaxed), before);
}
}
pub(crate) mod progress;
macro_rules! info {
($($args:tt)*) => {
crate::logging::emit(crate::logging::Level::Info, "runtime", || serde_json::json!({"message": format!($($args)*)}))
};
}
pub(crate) use info;
macro_rules! warning {
($($args:tt)*) => {
crate::logging::emit(crate::logging::Level::Warn, "runtime_warning", || serde_json::json!({"message": format!($($args)*)}))
};
}
pub(crate) use warning;

72
src/logging/progress.rs Normal file
View File

@ -0,0 +1,72 @@
// Event sites construct detailed payloads lazily.
macro_rules! emit {
($kind:expr, $payload:expr $(,)?) => {
crate::logging::emit(crate::logging::progress::event_level($kind), $kind, || {
$payload
})
};
}
pub(crate) use emit;
pub(crate) fn event_level(kind: &str) -> crate::logging::Level {
if matches!(kind, "http_fetch_failed" | "rrdp_sync_failed") {
crate::logging::Level::Warn
} else if kind == "validation_failed" || kind.ends_with("_failed") || kind.starts_with("error_")
{
crate::logging::Level::Error
} else if kind.ends_with("_warning")
|| kind.ends_with("_warnings")
|| kind.starts_with("warning_")
{
crate::logging::Level::Warn
} else if kind.starts_with("publication_point")
|| kind.starts_with("phase2_")
|| kind.starts_with("object_")
{
crate::logging::Level::Trace
} else if kind.starts_with("validation_")
|| kind.starts_with("run_")
|| kind.starts_with("output_")
|| kind.starts_with("ccr_")
|| kind.ends_with("_summary")
{
crate::logging::Level::Info
} else {
crate::logging::Level::Debug
}
}
pub fn slow_threshold_secs() -> f64 {
30.0
}
pub fn stage_fresh_slow_threshold_ms() -> u64 {
1_000
}
pub fn pp_control_slow_threshold_ms() -> u64 {
100
}
pub fn control_loop_slow_threshold_ms() -> u64 {
1_000
}
#[cfg(test)]
mod tests {
use super::event_level;
use crate::logging::Level;
#[test]
fn event_levels_keep_default_output_quiet() {
assert_eq!(event_level("validation_started"), Level::Info);
assert_eq!(event_level("repository_sync_selected"), Level::Debug);
assert_eq!(event_level("publication_point_object"), Level::Trace);
assert_eq!(event_level("validation_warning"), Level::Warn);
assert_eq!(event_level("validation_warnings"), Level::Warn);
assert_eq!(event_level("validation_failed"), Level::Error);
assert_eq!(event_level("object_failed"), Level::Error);
assert_eq!(event_level("http_fetch_failed"), Level::Warn);
assert_eq!(event_level("rrdp_sync_failed"), Level::Warn);
}
}

16
src/main.rs Normal file
View File

@ -0,0 +1,16 @@
fn main() {
match panda_rpki::cli::run(std::env::args().skip(1)) {
Ok(()) => {}
Err(error) if error == panda_rpki::cli::usage() => {
println!("{error}");
}
Err(error) => {
panda_rpki::logging::emit(
panda_rpki::logging::Level::Error,
"command_failed",
|| serde_json::json!({"error": error}),
);
std::process::exit(2);
}
}
}

412
src/model/aspa.rs Normal file
View File

@ -0,0 +1,412 @@
use crate::model::common::{DerReader, der_take_tlv};
use crate::model::oid::OID_CT_ASPA;
use crate::model::rc::ResourceCertificate;
use crate::model::signed_object::{
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectDecodeError, SignedObjectParseError,
SignedObjectValidateError,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaObject {
pub signed_object: RpkiSignedObject,
pub econtent_type: String,
pub aspa: AspaEContent,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaObjectParsed {
pub signed_object: RpkiSignedObjectParsed,
pub econtent_type: String,
pub aspa: Option<AspaEContentParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaEContent {
pub version: u32,
pub customer_as_id: u32,
pub provider_as_ids: Vec<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaEContentParsed {
der: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum AspaParseError {
#[error("signed object parse error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectParseError),
#[error("ASPA parse error: {0} (draft-ietf-sidrops-aspa-profile-21 §3; DER)")]
Parse(String),
#[error("ASPA trailing bytes: {0} bytes (draft-ietf-sidrops-aspa-profile-21 §3; DER)")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum AspaProfileError {
#[error("signed object profile error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectValidateError),
#[error(
"ASPA eContentType must be {OID_CT_ASPA}, got {0} (draft-ietf-sidrops-aspa-profile-21 §2)"
)]
InvalidEContentType(String),
#[error("ASPA profile decode error: {0} (draft-ietf-sidrops-aspa-profile-21 §3; DER)")]
ProfileDecode(String),
#[error(
"ASProviderAttestation must be a SEQUENCE of 3 elements (draft-ietf-sidrops-aspa-profile-21 §3)"
)]
InvalidAttestationSequence,
#[error(
"ASPA version must be 1 and MUST be explicitly encoded (draft-ietf-sidrops-aspa-profile-21 §3.1)"
)]
VersionMustBeExplicitOne,
#[error(
"ASPA customerASID out of range (0..=4294967295), got {0} (draft-ietf-sidrops-aspa-profile-21 §3.2)"
)]
CustomerAsIdOutOfRange(u64),
#[error(
"ASPA providers must contain at least one ASID (draft-ietf-sidrops-aspa-profile-21 §3.3)"
)]
EmptyProviders,
#[error(
"ASPA provider ASID out of range (0..=4294967295), got {0} (draft-ietf-sidrops-aspa-profile-21 §3.3)"
)]
ProviderAsIdOutOfRange(u64),
#[error(
"ASPA providers must be in strictly increasing order (draft-ietf-sidrops-aspa-profile-21 §3.3)"
)]
ProvidersNotStrictlyIncreasing,
#[error(
"ASPA providers contains the customerASID ({0}) which is not allowed (draft-ietf-sidrops-aspa-profile-21 §3.3)"
)]
ProvidersContainCustomer(u32),
}
impl From<SignedObjectDecodeError> for AspaProfileError {
fn from(value: SignedObjectDecodeError) -> Self {
match value {
SignedObjectDecodeError::Parse(e) => AspaProfileError::ProfileDecode(e.to_string()),
SignedObjectDecodeError::Validate(e) => AspaProfileError::SignedObject(e),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum AspaDecodeError {
#[error("{0}")]
Parse(#[from] AspaParseError),
#[error("{0}")]
Validate(#[from] AspaProfileError),
}
#[derive(Debug, thiserror::Error)]
pub enum AspaValidateError {
#[error(
"ASPA EE certificate must contain AS resources extension (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2)"
)]
EeAsResourcesMissing,
#[error(
"ASPA EE certificate AS resources must not use inherit (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2.3.3)"
)]
EeAsResourcesInherit,
#[error(
"ASPA EE certificate AS resources must not include ranges (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2.3.6-§3.2.3.7)"
)]
EeAsResourcesRangePresent,
#[error(
"ASPA EE certificate AS resources must not include RDI (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2.3.5; RFC 6487 §4.8.11)"
)]
EeAsResourcesRdiPresent,
#[error(
"ASPA EE certificate AS resources must contain exactly one ASID (id element) (draft-ietf-sidrops-aspa-profile-21 §4)"
)]
EeAsResourcesNotSingleId,
#[error(
"ASPA customerASID ({customer_as_id}) does not match EE AS resources ({ee_as_id}) (draft-ietf-sidrops-aspa-profile-21 §4)"
)]
CustomerAsIdMismatch { customer_as_id: u32, ee_as_id: u32 },
#[error(
"ASPA EE certificate must not contain IP resources extension (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §2.2)"
)]
EeIpResourcesPresent,
}
impl AspaObject {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<AspaObjectParsed, AspaParseError> {
let signed_object = RpkiSignedObject::parse_der(der)?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
let aspa = signed_object
.signed_data
.encap_content_info
.econtent
.as_deref()
.map(AspaEContent::parse_der)
.transpose()?;
Ok(AspaObjectParsed {
signed_object,
econtent_type,
aspa,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `AspaObject` is already profile-validated when constructed via `decode_der()` /
/// `AspaObjectParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), AspaProfileError> {
Ok(())
}
pub fn decode_der(der: &[u8]) -> Result<Self, AspaDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
pub fn decode_der_with_strict_options(
der: &[u8],
strict_cms_der: bool,
strict_name: bool,
) -> Result<Self, AspaDecodeError> {
let signed_object =
RpkiSignedObject::decode_der_with_strict_options(der, strict_cms_der, strict_name)
.map_err(AspaProfileError::from)?;
Self::from_signed_object(signed_object)
}
pub fn from_signed_object(signed_object: RpkiSignedObject) -> Result<Self, AspaDecodeError> {
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_ASPA {
return Err(AspaProfileError::InvalidEContentType(econtent_type).into());
}
let aspa =
AspaEContent::decode_der(&signed_object.signed_data.encap_content_info.econtent)?;
Ok(Self {
aspa,
signed_object,
econtent_type: OID_CT_ASPA.to_string(),
})
}
/// Validate this ASPA's embedded EE certificate resources.
pub fn validate_embedded_ee_cert(&self) -> Result<(), AspaValidateError> {
let ee = &self.signed_object.signed_data.certificates[0].resource_cert;
self.aspa.validate_against_ee_cert(ee)
}
}
impl AspaEContent {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<AspaEContentParsed, AspaParseError> {
let (_tag, _value, rem) = der_take_tlv(der).map_err(AspaParseError::Parse)?;
if !rem.is_empty() {
return Err(AspaParseError::TrailingBytes(rem.len()));
}
Ok(AspaEContentParsed { der: der.to_vec() })
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `AspaEContent` is already profile-validated when constructed via `decode_der()` /
/// `AspaEContentParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), AspaProfileError> {
Ok(())
}
/// Decode the DER-encoded ASProviderAttestation defined in
/// draft-ietf-sidrops-aspa-profile-21 §3 (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, AspaDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
/// Validate ASPA payload against the embedded EE resource certificate.
///
/// This implements the EE/payload semantic checks described in
/// `draft-ietf-sidrops-aspa-profile-21` §4 (as summarized in `rpki/specs/08_aspa.md`).
pub fn validate_against_ee_cert(
&self,
ee: &ResourceCertificate,
) -> Result<(), AspaValidateError> {
if ee.tbs.extensions.ip_resources.is_some() {
return Err(AspaValidateError::EeIpResourcesPresent);
}
let asn = ee
.tbs
.extensions
.as_resources
.as_ref()
.ok_or(AspaValidateError::EeAsResourcesMissing)?;
if asn.rdi.is_some() {
return Err(AspaValidateError::EeAsResourcesRdiPresent);
}
if asn.is_asnum_inherit() {
return Err(AspaValidateError::EeAsResourcesInherit);
}
if asn.has_any_range() {
return Err(AspaValidateError::EeAsResourcesRangePresent);
}
let ee_as_id = asn
.asnum_single_id()
.ok_or(AspaValidateError::EeAsResourcesNotSingleId)?;
if ee_as_id != self.customer_as_id {
return Err(AspaValidateError::CustomerAsIdMismatch {
customer_as_id: self.customer_as_id,
ee_as_id,
});
}
Ok(())
}
}
impl AspaObjectParsed {
pub fn validate_profile(self) -> Result<AspaObject, AspaProfileError> {
let signed_object = self.signed_object.validate_profile()?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_ASPA {
return Err(AspaProfileError::InvalidEContentType(econtent_type));
}
let aspa = self
.aspa
.ok_or_else(|| AspaProfileError::ProfileDecode("ASPA.eContent missing".into()))?
.validate_profile()?;
Ok(AspaObject {
signed_object,
econtent_type: OID_CT_ASPA.to_string(),
aspa,
})
}
}
impl AspaEContentParsed {
pub fn validate_profile(self) -> Result<AspaEContent, AspaProfileError> {
fn count_elements(mut r: DerReader<'_>) -> Result<usize, String> {
let mut n = 0usize;
while !r.is_empty() {
r.skip_any()?;
n += 1;
}
Ok(n)
}
let mut r = DerReader::new(&self.der);
let mut seq = r.take_sequence().map_err(AspaProfileError::ProfileDecode)?;
if !r.is_empty() {
return Err(AspaProfileError::ProfileDecode(
"trailing bytes after ASProviderAttestation".into(),
));
}
let elem_count =
count_elements(seq).map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if elem_count != 3 {
return Err(AspaProfileError::InvalidAttestationSequence);
}
// version [0] EXPLICIT INTEGER MUST be present and MUST be 1.
if seq
.peek_tag()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?
!= 0xA0
{
return Err(AspaProfileError::VersionMustBeExplicitOne);
}
let (inner_tag, inner_val) = seq
.take_explicit(0xA0)
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if inner_tag != 0x02 {
return Err(AspaProfileError::VersionMustBeExplicitOne);
}
let v = crate::model::common::der_uint_from_bytes(inner_val)
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if v != 1 {
return Err(AspaProfileError::VersionMustBeExplicitOne);
}
let customer_u64 = seq
.take_uint_u64()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if customer_u64 > u32::MAX as u64 {
return Err(AspaProfileError::CustomerAsIdOutOfRange(customer_u64));
}
let customer_as_id = customer_u64 as u32;
let providers_seq = seq
.take_sequence()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if !seq.is_empty() {
return Err(AspaProfileError::InvalidAttestationSequence);
}
let providers = parse_providers_cursor(providers_seq, customer_as_id)?;
Ok(AspaEContent {
version: 1,
customer_as_id,
provider_as_ids: providers,
})
}
}
fn parse_providers_cursor(
mut seq: DerReader<'_>,
customer_as_id: u32,
) -> Result<Vec<u32>, AspaProfileError> {
if seq.is_empty() {
return Err(AspaProfileError::EmptyProviders);
}
let mut out: Vec<u32> = Vec::new();
let mut prev: Option<u32> = None;
while !seq.is_empty() {
let v = seq
.take_uint_u64()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if v > u32::MAX as u64 {
return Err(AspaProfileError::ProviderAsIdOutOfRange(v));
}
let asn = v as u32;
if asn == customer_as_id {
return Err(AspaProfileError::ProvidersContainCustomer(customer_as_id));
}
if let Some(p) = prev
&& asn <= p
{
return Err(AspaProfileError::ProvidersNotStrictlyIncreasing);
}
prev = Some(asn);
out.push(asn);
}
Ok(out)
}

289
src/model/common.rs Normal file
View File

@ -0,0 +1,289 @@
use x509_parser::asn1_rs::Tag;
use x509_parser::prelude::FromDer;
use x509_parser::x509::AlgorithmIdentifier;
pub type UtcTime = time::OffsetDateTime;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Asn1TimeEncoding {
UtcTime,
GeneralizedTime,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Asn1TimeUtc {
pub utc: UtcTime,
pub encoding: Asn1TimeEncoding,
}
impl Asn1TimeUtc {
/// Validate Time encoding rules (RFC 5280): years 1950-2049 use UTCTime,
/// other years use GeneralizedTime.
pub fn validate_encoding_rfc5280(
&self,
field: &'static str,
) -> Result<(), InvalidTimeEncodingError> {
let year = self.utc.year();
let expected = if year <= 2049 {
Asn1TimeEncoding::UtcTime
} else {
Asn1TimeEncoding::GeneralizedTime
};
if self.encoding != expected {
return Err(InvalidTimeEncodingError {
field,
year,
encoding: self.encoding,
});
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BigUnsigned {
/// Minimal big-endian bytes. For zero, this is `[0]`.
pub bytes_be: Vec<u8>,
}
impl BigUnsigned {
pub fn from_biguint(n: &der_parser::num_bigint::BigUint) -> Self {
let mut bytes = n.to_bytes_be();
if bytes.is_empty() {
bytes.push(0);
}
Self { bytes_be: bytes }
}
pub fn to_hex_upper(&self) -> String {
hex::encode_upper(&self.bytes_be)
}
pub fn to_u64(&self) -> Option<u64> {
if self.bytes_be.len() > 8 {
return None;
}
let mut value: u64 = 0;
for &b in &self.bytes_be {
value = (value << 8) | (b as u64);
}
Some(value)
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error(
"{field} time encoding invalid for year {year}: got {encoding:?} (RFC 5280 §4.1.2.5; RFC 5280 §5.1.2.4-§5.1.2.6)"
)]
pub struct InvalidTimeEncodingError {
pub field: &'static str,
pub year: i32,
pub encoding: Asn1TimeEncoding,
}
pub fn asn1_time_to_model(t: x509_parser::time::ASN1Time) -> Asn1TimeUtc {
let encoding = if t.is_utctime() {
Asn1TimeEncoding::UtcTime
} else {
Asn1TimeEncoding::GeneralizedTime
};
Asn1TimeUtc {
utc: t.to_datetime(),
encoding,
}
}
pub fn algorithm_params_absent_or_null(sig: &AlgorithmIdentifier<'_>) -> bool {
match sig.parameters.as_ref() {
None => true,
Some(p) if p.tag() == Tag::Null => true,
Some(_p) => false,
}
}
/// Take a single DER TLV (Tag-Length-Value) from the start of `input`.
///
/// This helper supports:
/// - short- and long-form lengths (up to 8 length bytes)
/// - only low-tag-number form tags (no high-tag-number form)
/// - definite length only (DER forbids indefinite length)
///
/// Returns: `(tag_byte, value_bytes, remaining_bytes)`.
pub(crate) fn der_take_tlv(input: &[u8]) -> Result<(u8, &[u8], &[u8]), String> {
if input.len() < 2 {
return Err("truncated DER (need tag+len)".into());
}
let tag = input[0];
if (tag & 0x1F) == 0x1F {
return Err("high-tag-number form not supported".into());
}
let len0 = input[1];
if len0 == 0x80 {
return Err("indefinite length not allowed in DER".into());
}
let (len, hdr_len) = if len0 & 0x80 == 0 {
(len0 as usize, 2usize)
} else {
let n = (len0 & 0x7F) as usize;
if n == 0 || n > 8 {
return Err("invalid DER length".into());
}
if input.len() < 2 + n {
return Err("truncated DER (length bytes)".into());
}
let mut l: usize = 0;
for &b in &input[2..2 + n] {
l = (l << 8) | (b as usize);
}
(l, 2 + n)
};
if input.len() < hdr_len + len {
return Err("truncated DER (value bytes)".into());
}
let value = &input[hdr_len..hdr_len + len];
let rem = &input[hdr_len + len..];
Ok((tag, value, rem))
}
/// Minimal streaming DER reader built on `der_take_tlv`.
///
/// This is intentionally small and only supports the subset of DER needed by
/// RPKI object eContent decoders (ROA/ASPA), to avoid constructing a generic AST
/// (which is expensive on large objects such as ROAs with thousands of prefixes).
#[derive(Clone, Copy)]
pub(crate) struct DerReader<'a> {
buf: &'a [u8],
}
impl<'a> DerReader<'a> {
pub(crate) fn new(buf: &'a [u8]) -> Self {
Self { buf }
}
pub(crate) fn is_empty(&self) -> bool {
self.buf.is_empty()
}
pub(crate) fn peek_tag(&self) -> Result<u8, String> {
self.buf
.first()
.copied()
.ok_or_else(|| "truncated DER".into())
}
pub(crate) fn take_any(&mut self) -> Result<(u8, &'a [u8]), String> {
let (tag, value, rem) = der_take_tlv(self.buf)?;
self.buf = rem;
Ok((tag, value))
}
pub(crate) fn take_any_full(&mut self) -> Result<(u8, &'a [u8], &'a [u8]), String> {
let (tag, value, rem) = der_take_tlv(self.buf)?;
let consumed = self.buf.len() - rem.len();
let full = &self.buf[..consumed];
self.buf = rem;
Ok((tag, full, value))
}
pub(crate) fn skip_any(&mut self) -> Result<(), String> {
let _ = self.take_any()?;
Ok(())
}
pub(crate) fn take_tag(&mut self, expected_tag: u8) -> Result<&'a [u8], String> {
let (tag, value) = self.take_any()?;
if tag != expected_tag {
return Err(format!(
"unexpected tag: got 0x{tag:02X}, expected 0x{expected_tag:02X}"
));
}
Ok(value)
}
pub(crate) fn take_sequence(&mut self) -> Result<DerReader<'a>, String> {
let value = self.take_tag(0x30)?;
Ok(DerReader::new(value))
}
pub(crate) fn take_octet_string(&mut self) -> Result<&'a [u8], String> {
self.take_tag(0x04)
}
pub(crate) fn take_bit_string(&mut self) -> Result<(u8, &'a [u8]), String> {
let v = self.take_tag(0x03)?;
if v.is_empty() {
return Err("BIT STRING content is empty".into());
}
Ok((v[0], &v[1..]))
}
pub(crate) fn take_uint_u64(&mut self) -> Result<u64, String> {
let v = self.take_tag(0x02)?;
der_uint_from_bytes(v)
}
pub(crate) fn take_explicit(
&mut self,
expected_outer_tag: u8,
) -> Result<(u8, &'a [u8]), String> {
let inner_der = self.take_tag(expected_outer_tag)?;
let (tag, value, rem) = der_take_tlv(inner_der)?;
if !rem.is_empty() {
return Err("trailing bytes inside EXPLICIT value".into());
}
Ok((tag, value))
}
}
pub(crate) fn der_uint_from_bytes(bytes: &[u8]) -> Result<u64, String> {
if bytes.is_empty() {
return Err("INTEGER has empty content".into());
}
// Disallow negative values.
if (bytes[0] & 0x80) != 0 {
return Err("INTEGER is negative".into());
}
// DER requires minimal encoding for INTEGER.
if bytes.len() > 1 && bytes[0] == 0x00 && (bytes[1] & 0x80) == 0 {
return Err("INTEGER not minimally encoded".into());
}
if bytes.len() > 8 {
return Err("INTEGER does not fit u64".into());
}
let mut v: u64 = 0;
for &b in bytes {
v = (v << 8) | (b as u64);
}
Ok(v)
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct X509NameDer(pub Vec<u8>);
impl X509NameDer {
pub fn as_raw(&self) -> &[u8] {
&self.0
}
}
impl std::fmt::Display for X509NameDer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Ok((rem, name)) = x509_parser::x509::X509Name::from_der(&self.0) else {
return write!(f, "<invalid X.509 Name DER>");
};
if !rem.is_empty() {
return write!(f, "<invalid X.509 Name DER (trailing bytes)>");
}
write!(f, "{name}")
}
}
/// Filename extensions registered in IANA "RPKI Repository Name Schemes".
///
/// Source: <https://www.iana.org/assignments/rpki/rpki.xhtml>
/// Snapshot date: 2026-01-28.
///
/// Notes:
/// - Includes entries marked TEMPORARY/DEPRECATED by IANA (e.g., `asa`, `gbr`).
pub const IANA_RPKI_REPOSITORY_FILENAME_EXTENSIONS: &[&str] =
&["asa", "cer", "crl", "gbr", "mft", "roa", "sig", "tak"];

537
src/model/crl.rs Normal file
View File

@ -0,0 +1,537 @@
pub use crate::model::common::{Asn1TimeEncoding, Asn1TimeUtc, BigUnsigned};
use crate::model::oid::{
OID_AUTHORITY_KEY_IDENTIFIER, OID_AUTHORITY_KEY_IDENTIFIER_RAW, OID_CRL_NUMBER,
OID_CRL_NUMBER_RAW, OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW,
OID_SUBJECT_KEY_IDENTIFIER_RAW,
};
use x509_parser::certificate::X509Certificate;
use x509_parser::extensions::{ParsedExtension, X509Extension};
use x509_parser::prelude::{FromDer, X509Version};
use x509_parser::revocation_list::CertificateRevocationList;
use x509_parser::x509::{AlgorithmIdentifier, SubjectPublicKeyInfo};
use x509_parser::{asn1_rs::Class as Asn1Class, asn1_rs::Tag as Asn1Tag};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RevokedCert {
pub serial_number: BigUnsigned,
pub revocation_date: Asn1TimeUtc,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CrlExtensions {
pub authority_key_identifier: Vec<u8>,
pub crl_number: BigUnsigned,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RpkixCrl {
pub raw_der: Vec<u8>,
pub version: u32,
pub issuer_dn: String,
pub signature_algorithm_oid: String,
pub this_update: Asn1TimeUtc,
pub next_update: Asn1TimeUtc,
pub revoked_certs: Vec<RevokedCert>,
pub extensions: CrlExtensions,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RpkixCrlParsed {
pub raw_der: Vec<u8>,
pub version: Option<X509Version>,
pub issuer_dn: String,
pub signature_algorithm: AlgorithmIdentifierValue,
pub tbs_signature_algorithm: AlgorithmIdentifierValue,
pub this_update: Asn1TimeUtc,
pub next_update: Option<Asn1TimeUtc>,
pub revoked_certs: Vec<RevokedCertParsed>,
pub extensions: Vec<CrlExtensionParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RevokedCertParsed {
pub serial_number: BigUnsigned,
pub revocation_date: Asn1TimeUtc,
pub has_extensions: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CrlExtensionParsed {
AuthorityKeyIdentifier {
key_identifier: Option<Vec<u8>>,
has_other_fields: bool,
critical: bool,
},
CrlNumber {
number: der_parser::num_bigint::BigUint,
critical: bool,
},
Other {
oid: String,
critical: bool,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlgorithmIdentifierValue {
pub oid: String,
pub parameters: Option<AlgorithmParametersValue>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlgorithmParametersValue {
pub class: Asn1Class,
pub tag: Asn1Tag,
pub data: Vec<u8>,
}
impl AlgorithmIdentifierValue {
pub fn params_absent_or_null(&self) -> bool {
match &self.parameters {
None => true,
Some(p) if p.class == Asn1Class::Universal && p.tag == Asn1Tag::Null => true,
Some(_p) => false,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum CrlParseError {
#[error("X.509 CRL parse error: {0} (RFC 5280 §5.1; RFC 6487 §5)")]
Parse(String),
#[error("trailing bytes after CRL DER: {0} bytes (DER; RFC 5280 §5.1)")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum CrlProfileError {
#[error("CRL version must be v2, got {0:?} (RFC 5280 §5.1; RFC 6487 §5)")]
InvalidVersion(Option<u32>),
#[error("CRL signatureAlgorithm must match TBSCertList.signature (RFC 5280 §5.1)")]
SignatureAlgorithmMismatch,
#[error(
"CRL signatureAlgorithm must be sha256WithRSAEncryption ({OID_SHA256_WITH_RSA_ENCRYPTION}), got {0} (RFC 6487 §5; RFC 7935 §2)"
)]
InvalidSignatureAlgorithm(String),
#[error(
"CRL signature algorithm parameters must be absent or NULL (RFC 5280 §4.1.1.2; RFC 7935 §2)"
)]
InvalidSignatureAlgorithmParameters,
#[error("CRL extensions must be exactly two (AKI + CRLNumber), got {0} (RFC 9829 §3.1)")]
InvalidExtensionsCount(usize),
#[error("unsupported CRL extension OID {0} (RFC 9829 §3.1)")]
UnsupportedExtension(String),
#[error("duplicate CRL extension OID {0} (RFC 5280 §4.2; RFC 9829 §3.1)")]
DuplicateExtension(String),
#[error("AuthorityKeyIdentifier CRL extension missing (RFC 9829 §3.1; RFC 5280 §5.2.1)")]
AkiMissing,
#[error("AuthorityKeyIdentifier must contain keyIdentifier (RFC 5280 §5.2.1; RFC 9829 §3.1)")]
AkiMissingKeyIdentifier,
#[error(
"AuthorityKeyIdentifier must not contain authorityCertIssuer or authorityCertSerialNumber (RFC 5280 §5.2.1; RFC 9829 §3.1)"
)]
AkiHasOtherFields,
#[error("CRLNumber CRL extension missing (RFC 9829 §3.1; RFC 5280 §5.2.3)")]
CrlNumberMissing,
#[error("CRLNumber must be non-critical (RFC 9829 §3.1; RFC 5280 §5.2.3)")]
CrlNumberCritical,
#[error("CRLNumber out of range (must fit in 0..2^159-1) (RFC 9829 §3.1)")]
CrlNumberOutOfRange,
#[error("CRL entry extensions must not be present (RFC 6487 §5; RFC 5280 §5.1)")]
EntryExtensionsNotAllowed,
#[error("CRL nextUpdate must be present (RFC 5280 §5.1.2.5; RFC 6487 §5)")]
NextUpdateMissing,
#[error(
"{field} time encoding invalid for year {year}: got {encoding:?} (RFC 5280 §5.1.2.4-§5.1.2.6)"
)]
InvalidTimeEncoding {
field: &'static str,
year: i32,
encoding: Asn1TimeEncoding,
},
}
#[derive(Debug, thiserror::Error)]
pub enum CrlDecodeError {
#[error("{0}")]
Parse(#[from] CrlParseError),
#[error("{0}")]
Validate(#[from] CrlProfileError),
}
impl RpkixCrl {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<RpkixCrlParsed, CrlParseError> {
let (rem, crl) = CertificateRevocationList::from_der(der)
.map_err(|e| CrlParseError::Parse(e.to_string()))?;
if !rem.is_empty() {
return Err(CrlParseError::TrailingBytes(rem.len()));
}
let revoked_certs = crl
.iter_revoked_certificates()
.map(|rc| RevokedCertParsed {
serial_number: BigUnsigned::from_biguint(rc.serial()),
revocation_date: crate::model::common::asn1_time_to_model(rc.revocation_date),
has_extensions: !rc.extensions().is_empty(),
})
.collect::<Vec<_>>();
let this_update = crate::model::common::asn1_time_to_model(crl.last_update());
let next_update = crl
.next_update()
.map(crate::model::common::asn1_time_to_model);
let extensions = parse_extensions_parse(crl.extensions()).map_err(CrlParseError::Parse)?;
Ok(RpkixCrlParsed {
raw_der: der.to_vec(),
version: crl.version(),
issuer_dn: crl.issuer().to_string(),
signature_algorithm: algorithm_identifier_value(&crl.signature_algorithm),
tbs_signature_algorithm: algorithm_identifier_value(&crl.tbs_cert_list.signature),
this_update,
next_update,
revoked_certs,
extensions,
})
}
/// Decode a DER-encoded X.509 v2 CRL and enforce the RPKI profile constraints from
/// `specs/prepare/data_models/04_crl.md` (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1).
pub fn decode_der(der: &[u8]) -> Result<Self, CrlDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `RpkixCrl` is already profile-validated when constructed via `decode_der()` /
/// `RpkixCrlParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), CrlProfileError> {
Ok(())
}
/// Verify the cryptographic signature on this CRL using the issuer certificate.
///
/// Signature verification needs the issuer public key (RFC 5280 §6.3.3 (f)-(g)).
/// In RPKI practice, this public key is obtained from the CRL issuer CA certificate
/// (and that certificate must already be validated up to the same trust anchor).
///
/// This helper also performs common binding checks:
/// - CRL `issuer_dn` must equal issuer certificate `subject`
/// - if issuer KeyUsage is present, require `cRLSign`
/// - if issuer SKI is present, require it matches CRL AKI.keyIdentifier
pub fn verify_signature_with_issuer_certificate_der(
&self,
issuer_cert_der: &[u8],
) -> Result<(), CrlVerifyError> {
let (rem, issuer_cert) = X509Certificate::from_der(issuer_cert_der)
.map_err(|e| CrlVerifyError::IssuerCertificateParse(e.to_string()))?;
if !rem.is_empty() {
return Err(CrlVerifyError::IssuerCertificateTrailingBytes(rem.len()));
}
let subject_dn = issuer_cert.subject().to_string();
if subject_dn != self.issuer_dn {
return Err(CrlVerifyError::IssuerSubjectMismatch {
crl_issuer_dn: self.issuer_dn.clone(),
issuer_subject_dn: subject_dn,
});
}
if let Some(ku) = issuer_cert
.key_usage()
.map_err(|e| CrlVerifyError::IssuerCertificateParse(e.to_string()))?
&& !ku.value.crl_sign()
{
return Err(CrlVerifyError::IssuerKeyUsageMissingCrlSign);
}
if let Some(issuer_ski) = get_subject_key_identifier(&issuer_cert)
&& issuer_ski != self.extensions.authority_key_identifier
{
return Err(CrlVerifyError::AkiSkiMismatch);
}
self.verify_signature_with_issuer_spki(issuer_cert.public_key())
}
/// Verify the cryptographic signature on this CRL using the issuer SubjectPublicKeyInfo.
pub fn verify_signature_with_issuer_spki(
&self,
issuer_spki: &SubjectPublicKeyInfo<'_>,
) -> Result<(), CrlVerifyError> {
let (rem, crl) = CertificateRevocationList::from_der(&self.raw_der)
.map_err(|e| CrlVerifyError::CrlParse(e.to_string()))?;
if !rem.is_empty() {
return Err(CrlVerifyError::CrlTrailingBytes(rem.len()));
}
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.
pub fn verify_signature_with_issuer_spki_der(
&self,
issuer_spki_der: &[u8],
) -> Result<(), CrlVerifyError> {
let (rem, spki) = SubjectPublicKeyInfo::from_der(issuer_spki_der)
.map_err(|e| CrlVerifyError::IssuerSpkiParse(e.to_string()))?;
if !rem.is_empty() {
return Err(CrlVerifyError::IssuerSpkiTrailingBytes(rem.len()));
}
self.verify_signature_with_issuer_spki(&spki)
}
}
impl RpkixCrlParsed {
/// Profile validate step of scheme A (`parse → validate → verify`).
pub fn validate_profile(self) -> Result<RpkixCrl, CrlProfileError> {
let version = match self.version {
Some(X509Version::V2) => 2,
Some(v) => return Err(CrlProfileError::InvalidVersion(Some(v.0))),
None => return Err(CrlProfileError::InvalidVersion(None)),
};
// signatureAlgorithm must match tbsCertList.signature
if self.signature_algorithm != self.tbs_signature_algorithm {
return Err(CrlProfileError::SignatureAlgorithmMismatch);
}
let sig_oid = self.signature_algorithm.oid.clone();
if sig_oid != OID_SHA256_WITH_RSA_ENCRYPTION {
return Err(CrlProfileError::InvalidSignatureAlgorithm(sig_oid));
}
if !self.signature_algorithm.params_absent_or_null() {
return Err(CrlProfileError::InvalidSignatureAlgorithmParameters);
}
let extensions = validate_extensions_profile(&self.extensions)?;
let mut revoked_out = Vec::with_capacity(self.revoked_certs.len());
for rc in self.revoked_certs {
if rc.has_extensions {
return Err(CrlProfileError::EntryExtensionsNotAllowed);
}
validate_time_encoding_rfc5280("revocationDate", &rc.revocation_date)?;
revoked_out.push(RevokedCert {
serial_number: rc.serial_number,
revocation_date: rc.revocation_date,
});
}
validate_time_encoding_rfc5280("thisUpdate", &self.this_update)?;
let next_update = self.next_update.ok_or(CrlProfileError::NextUpdateMissing)?;
validate_time_encoding_rfc5280("nextUpdate", &next_update)?;
Ok(RpkixCrl {
raw_der: self.raw_der,
version,
issuer_dn: self.issuer_dn,
signature_algorithm_oid: OID_SHA256_WITH_RSA_ENCRYPTION.to_string(),
this_update: self.this_update,
next_update,
revoked_certs: revoked_out,
extensions,
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum CrlVerifyError {
#[error("issuer certificate parse error: {0} (RFC 5280 §4.1; RFC 6487 §4)")]
IssuerCertificateParse(String),
#[error("trailing bytes after issuer certificate DER: {0} bytes (DER; RFC 5280 §4.1)")]
IssuerCertificateTrailingBytes(usize),
#[error("issuer SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7)")]
IssuerSpkiParse(String),
#[error(
"trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 5280 §4.1.2.7)"
)]
IssuerSpkiTrailingBytes(usize),
#[error("CRL parse error: {0} (RFC 5280 §5.1; RFC 6487 §5)")]
CrlParse(String),
#[error("trailing bytes after CRL DER: {0} bytes (DER; RFC 5280 §5.1)")]
CrlTrailingBytes(usize),
#[error(
"CRL issuer DN does not match issuer certificate subject (RFC 5280 §5.1; RFC 5280 §6.3.3(b))"
)]
IssuerSubjectMismatch {
crl_issuer_dn: String,
issuer_subject_dn: String,
},
#[error(
"issuer certificate keyUsage present but missing cRLSign (RFC 5280 §4.2.1.3; RFC 5280 §6.3.3(f))"
)]
IssuerKeyUsageMissingCrlSign,
#[error(
"CRL AKI.keyIdentifier does not match issuer certificate SKI (RFC 5280 §4.2.1.1; RFC 5280 §4.2.1.2; RFC 5280 §6.3.3(c)/(f))"
)]
AkiSkiMismatch,
#[error("CRL signature verification failed: {0} (RFC 5280 §6.3.3(g); RFC 7935 §2)")]
InvalidSignature(String),
}
fn validate_time_encoding_rfc5280(
field: &'static str,
t: &Asn1TimeUtc,
) -> Result<(), CrlProfileError> {
let year = t.utc.year();
let expected = if year <= 2049 {
Asn1TimeEncoding::UtcTime
} else {
Asn1TimeEncoding::GeneralizedTime
};
if t.encoding != expected {
return Err(CrlProfileError::InvalidTimeEncoding {
field,
year,
encoding: t.encoding,
});
}
Ok(())
}
fn algorithm_identifier_value(ai: &AlgorithmIdentifier<'_>) -> AlgorithmIdentifierValue {
let parameters = ai.parameters.as_ref().map(|p| AlgorithmParametersValue {
class: p.class(),
tag: p.tag(),
data: p.as_bytes().to_vec(),
});
// NOTE(perf): Avoid `to_id_string()` allocations for the signature algorithms we expect
// in RPKI CRLs. Fall back to `to_id_string()` for unexpected algorithms (mostly error paths).
let oid = if ai.algorithm.as_bytes() == OID_SHA256_WITH_RSA_ENCRYPTION_RAW {
OID_SHA256_WITH_RSA_ENCRYPTION.to_string()
} else {
ai.algorithm.to_id_string()
};
AlgorithmIdentifierValue { oid, parameters }
}
fn parse_extensions_parse(exts: &[X509Extension<'_>]) -> Result<Vec<CrlExtensionParsed>, String> {
let mut out = Vec::with_capacity(exts.len());
for ext in exts {
let oid = ext.oid.as_bytes();
if oid == OID_AUTHORITY_KEY_IDENTIFIER_RAW {
let ParsedExtension::AuthorityKeyIdentifier(aki) = ext.parsed_extension() else {
return Err("AKI extension parse failed".to_string());
};
out.push(CrlExtensionParsed::AuthorityKeyIdentifier {
key_identifier: aki.key_identifier.as_ref().map(|k| k.0.to_vec()),
has_other_fields: aki.authority_cert_issuer.is_some()
|| aki.authority_cert_serial.is_some(),
critical: ext.critical,
});
} else if oid == OID_CRL_NUMBER_RAW {
match ext.parsed_extension() {
ParsedExtension::CRLNumber(n) => out.push(CrlExtensionParsed::CrlNumber {
number: n.clone(),
critical: ext.critical,
}),
_ => return Err("CRLNumber extension parse failed".to_string()),
}
} else {
out.push(CrlExtensionParsed::Other {
oid: ext.oid.to_id_string(),
critical: ext.critical,
})
}
}
Ok(out)
}
fn validate_extensions_profile(
exts: &[CrlExtensionParsed],
) -> Result<CrlExtensions, CrlProfileError> {
if exts.len() != 2 {
return Err(CrlProfileError::InvalidExtensionsCount(exts.len()));
}
let mut seen: Vec<String> = Vec::new();
let mut authority_key_identifier: Option<Vec<u8>> = None;
let mut crl_number: Option<BigUnsigned> = None;
for ext in exts {
match ext {
CrlExtensionParsed::AuthorityKeyIdentifier {
key_identifier,
has_other_fields,
critical: _,
} => {
let oid = OID_AUTHORITY_KEY_IDENTIFIER.to_string();
if seen.iter().any(|s| s == &oid) {
return Err(CrlProfileError::DuplicateExtension(oid));
}
seen.push(oid.clone());
if *has_other_fields {
return Err(CrlProfileError::AkiHasOtherFields);
}
let ki = key_identifier
.as_ref()
.ok_or(CrlProfileError::AkiMissingKeyIdentifier)?;
authority_key_identifier = Some(ki.clone());
}
CrlExtensionParsed::CrlNumber { number, critical } => {
let oid = OID_CRL_NUMBER.to_string();
if seen.iter().any(|s| s == &oid) {
return Err(CrlProfileError::DuplicateExtension(oid));
}
seen.push(oid.clone());
if *critical {
return Err(CrlProfileError::CrlNumberCritical);
}
if number.bits() > 159 {
return Err(CrlProfileError::CrlNumberOutOfRange);
}
crl_number = Some(BigUnsigned::from_biguint(number));
}
CrlExtensionParsed::Other { oid, .. } => {
return Err(CrlProfileError::UnsupportedExtension(oid.clone()));
}
}
}
Ok(CrlExtensions {
authority_key_identifier: authority_key_identifier.ok_or(CrlProfileError::AkiMissing)?,
crl_number: crl_number.ok_or(CrlProfileError::CrlNumberMissing)?,
})
}
fn get_subject_key_identifier(cert: &X509Certificate<'_>) -> Option<Vec<u8>> {
cert.extensions()
.iter()
.find(|ext| ext.oid.as_bytes() == OID_SUBJECT_KEY_IDENTIFIER_RAW)
.and_then(|ext| match ext.parsed_extension() {
ParsedExtension::SubjectKeyIdentifier(ki) => Some(ki.0.to_vec()),
_ => None,
})
}

976
src/model/manifest.rs Normal file
View File

@ -0,0 +1,976 @@
use crate::model::common::der_take_tlv;
use crate::model::common::{BigUnsigned, UtcTime};
use crate::model::oid::{OID_CT_RPKI_MANIFEST, OID_SHA256};
use crate::model::rc::ResourceCertificate;
use crate::model::signed_object::{
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectDecodeError, SignedObjectParseError,
SignedObjectValidateError,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestObject {
pub signed_object: RpkiSignedObject,
pub econtent_type: String,
pub manifest: ManifestEContent,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestObjectParsed {
pub signed_object: RpkiSignedObjectParsed,
pub econtent_type: String,
pub manifest: Option<ManifestEContentParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestEContent {
pub version: u32,
pub manifest_number: BigUnsigned,
pub this_update: UtcTime,
pub next_update: UtcTime,
pub file_hash_alg: String,
/// DER-encoded content bytes of `Manifest.fileList` (SEQUENCE OF FileAndHash).
pub file_list_der: Vec<u8>,
/// Count of FileAndHash entries in `fileList`.
pub file_count: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestEContentParsed {
der: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileAndHash {
pub file_name: String,
pub hash_bytes: [u8; 32],
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestParseError {
#[error("signed object parse error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectParseError),
#[error("DER parse error: {0} (RFC 9286 §4.2; DER)")]
Parse(String),
#[error("trailing bytes after DER object: {0} bytes (RFC 9286 §4.2; DER)")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestProfileError {
#[error("signed object profile error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectValidateError),
#[error(
"eContentType must be id-ct-rpkiManifest ({OID_CT_RPKI_MANIFEST}), got {0} (RFC 9286 §4.1; RFC 9286 §4.4(1))"
)]
InvalidEContentType(String),
#[error("manifest profile decode error: {0} (RFC 9286 §4.2; DER)")]
ProfileDecode(String),
#[error("Manifest must be a SEQUENCE of 5 or 6 elements, got {0} (RFC 9286 §4.2)")]
InvalidManifestSequenceLen(usize),
#[error("Manifest.version must be 0, got {0} (RFC 9286 §4.2.1)")]
InvalidManifestVersion(u64),
#[error(
"Manifest.manifestNumber must be non-negative INTEGER (RFC 9286 §4.2; RFC 9286 §4.2.1)"
)]
InvalidManifestNumber,
#[error("Manifest.manifestNumber longer than 20 octets (RFC 9286 §4.2.1)")]
ManifestNumberTooLong,
#[error("Manifest.thisUpdate must be GeneralizedTime (RFC 9286 §4.2)")]
InvalidThisUpdate,
#[error("Manifest.nextUpdate must be GeneralizedTime (RFC 9286 §4.2)")]
InvalidNextUpdate,
#[error("Manifest.nextUpdate must be later than thisUpdate (RFC 9286 §4.2.1)")]
NextUpdateNotLater,
#[error(
"Manifest.fileHashAlg must be id-sha256 ({OID_SHA256}), got {0} (RFC 9286 §4.2.1; RFC 7935 §2)"
)]
InvalidFileHashAlg(String),
#[error("Manifest.fileList must be a SEQUENCE (RFC 9286 §4.2)")]
InvalidFileList,
#[error("FileAndHash must be SEQUENCE of 2 (RFC 9286 §4.2)")]
InvalidFileAndHash,
#[error("fileList file name invalid: {0} (RFC 9286 §4.2.2)")]
InvalidFileName(String),
#[error("fileList hash must be BIT STRING (RFC 9286 §4.2)")]
InvalidHashType,
#[error(
"fileList hash BIT STRING must be octet-aligned (unused bits=0) (RFC 9286 §4.2.1; DER BIT STRING)"
)]
HashNotOctetAligned,
#[error(
"fileList hash length invalid for sha256: got {0} bytes (RFC 9286 §4.2.1; RFC 7935 §2)"
)]
InvalidHashLength(usize),
}
impl From<SignedObjectDecodeError> for ManifestProfileError {
fn from(value: SignedObjectDecodeError) -> Self {
match value {
SignedObjectDecodeError::Parse(e) => ManifestProfileError::ProfileDecode(e.to_string()),
SignedObjectDecodeError::Validate(e) => ManifestProfileError::SignedObject(e),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestDecodeError {
#[error("{0}")]
Parse(#[from] ManifestParseError),
#[error("{0}")]
Validate(#[from] ManifestProfileError),
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestValidateError {
#[error(
"Manifest EE certificate MUST include at least one RFC 3779 resource extension (IP or AS) (RFC 6487 §4.8.10-§4.8.11; RFC 3779; RFC 9286 §5.1)"
)]
EeResourcesMissing,
#[error(
"Manifest EE certificate IP resources MUST use inherit only (RFC 9286 §5.1; RFC 3779 §2.2.3.5)"
)]
EeIpResourcesNotInherit,
#[error(
"Manifest EE certificate AS resources MUST use inherit only (RFC 9286 §5.1; RFC 3779 §3.2.3.3)"
)]
EeAsResourcesNotInherit,
#[error(
"Manifest EE certificate AS resources rdi MUST be absent (RFC 6487 §4.8.11; RFC 3779 §3.2.3.5)"
)]
EeAsResourcesRdiPresent,
}
impl ManifestObject {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<ManifestObjectParsed, ManifestParseError> {
let signed_object = RpkiSignedObject::parse_der(der)?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
let manifest = signed_object
.signed_data
.encap_content_info
.econtent
.as_deref()
.map(ManifestEContent::parse_der)
.transpose()?;
Ok(ManifestObjectParsed {
signed_object,
econtent_type,
manifest,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `ManifestObject` is already profile-validated when constructed via `decode_der()` /
/// `ManifestObjectParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), ManifestProfileError> {
Ok(())
}
pub fn decode_der(der: &[u8]) -> Result<Self, ManifestDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
pub fn decode_der_with_strict_options(
der: &[u8],
strict_cms_der: bool,
strict_name: bool,
) -> Result<Self, ManifestDecodeError> {
let signed_object =
RpkiSignedObject::decode_der_with_strict_options(der, strict_cms_der, strict_name)
.map_err(ManifestProfileError::from)?;
Self::from_signed_object(signed_object)
}
pub fn from_signed_object(
signed_object: RpkiSignedObject,
) -> Result<Self, ManifestDecodeError> {
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_RPKI_MANIFEST {
return Err(ManifestProfileError::InvalidEContentType(econtent_type).into());
}
let manifest =
ManifestEContent::decode_der(&signed_object.signed_data.encap_content_info.econtent)?;
Ok(Self {
signed_object,
econtent_type: OID_CT_RPKI_MANIFEST.to_string(),
manifest,
})
}
/// Validate the embedded EE certificate resources against RFC 9286 §5.1.
///
/// This does **not** perform certificate path validation. It assumes `ee` is a parsed and
/// profile-validated RPKI EE resource certificate.
pub fn validate_against_ee_cert(
&self,
ee: &ResourceCertificate,
) -> Result<(), ManifestValidateError> {
let ip = ee.tbs.extensions.ip_resources.as_ref();
let asn = ee.tbs.extensions.as_resources.as_ref();
if ip.is_none() && asn.is_none() {
return Err(ManifestValidateError::EeResourcesMissing);
}
if let Some(ip) = ip
&& !ip.is_all_inherit()
{
return Err(ManifestValidateError::EeIpResourcesNotInherit);
}
if let Some(asn) = asn {
if asn.rdi.is_some() {
return Err(ManifestValidateError::EeAsResourcesRdiPresent);
}
if !asn.is_asnum_inherit() {
return Err(ManifestValidateError::EeAsResourcesNotInherit);
}
}
Ok(())
}
/// Validate this manifest's embedded EE certificate resources.
pub fn validate_embedded_ee_cert(&self) -> Result<(), ManifestValidateError> {
let ee = &self.signed_object.signed_data.certificates[0].resource_cert;
self.validate_against_ee_cert(ee)
}
}
impl ManifestEContent {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<ManifestEContentParsed, ManifestParseError> {
let (_tag, _value, rem) = der_take_tlv(der).map_err(ManifestParseError::Parse)?;
if !rem.is_empty() {
return Err(ManifestParseError::TrailingBytes(rem.len()));
}
Ok(ManifestEContentParsed { der: der.to_vec() })
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `ManifestEContent` is already profile-validated when constructed via `decode_der()` /
/// `ManifestEContentParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), ManifestProfileError> {
Ok(())
}
/// Decode the DER-encoded Manifest eContent defined in RFC 9286 §4.2 (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, ManifestDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
/// Parse and return the manifest fileList.
///
/// Note: `ManifestEContent` is profile-validated when produced via `decode_der()`, so this
/// should only fail due to internal inconsistencies (or if constructed manually).
pub fn parse_files(&self) -> Result<Vec<FileAndHash>, ManifestProfileError> {
parse_file_list_sha256_fast(&self.file_list_der)
}
pub fn file_count(&self) -> usize {
self.file_count
}
}
impl ManifestObjectParsed {
pub fn validate_profile(self) -> Result<ManifestObject, ManifestProfileError> {
let signed_object = self.signed_object.validate_profile()?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_RPKI_MANIFEST {
return Err(ManifestProfileError::InvalidEContentType(econtent_type));
}
let manifest = self
.manifest
.ok_or_else(|| ManifestProfileError::ProfileDecode("Manifest.eContent missing".into()))?
.validate_profile()?;
Ok(ManifestObject {
signed_object,
econtent_type: OID_CT_RPKI_MANIFEST.to_string(),
manifest,
})
}
}
impl ManifestEContentParsed {
pub fn validate_profile(self) -> Result<ManifestEContent, ManifestProfileError> {
decode_manifest_econtent_fast(&self.der)
}
}
fn validate_file_name_bytes(bytes: &[u8]) -> Result<(), ManifestProfileError> {
// RFC 9286 §4.2.2:
// 1+ chars from a-zA-Z0-9-_ , then '.', then 3-letter extension.
if bytes.len() < 5 {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
};
// "followed by a single . (DOT), followed by a three letter extension"
// -> the dot must be exactly 4 bytes from the end.
let dot_pos = bytes.len() - 4;
if bytes[dot_pos] != b'.' {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
}
#[inline(always)]
fn valid_base_char(b: u8) -> bool {
// RFC 9286 allowed set: a-zA-Z0-9-_
b.is_ascii_digit()
|| b.is_ascii_lowercase()
|| b.is_ascii_uppercase()
|| b == b'-'
|| b == b'_'
}
for &b in &bytes[..dot_pos] {
if (b & 0x80) != 0 || !valid_base_char(b) {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
}
}
let e0 = bytes[dot_pos + 1];
let e1 = bytes[dot_pos + 2];
let e2 = bytes[dot_pos + 3];
if (e0 & 0x80) != 0 || (e1 & 0x80) != 0 || (e2 & 0x80) != 0 {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
}
#[inline(always)]
fn lower_if_alpha(b: u8) -> Option<u8> {
match b {
b'a'..=b'z' => Some(b),
b'A'..=b'Z' => Some(b + 32),
_ => None,
}
}
let Some(l0) = lower_if_alpha(e0) else {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
};
let Some(l1) = lower_if_alpha(e1) else {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
};
let Some(l2) = lower_if_alpha(e2) else {
return Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
));
};
match [l0, l1, l2] {
// Full IANA list (see `common.rs`).
[b'a', b's', b'a']
| [b'c', b'e', b'r']
| [b'c', b'r', b'l']
| [b'g', b'b', b'r']
| [b'm', b'f', b't']
| [b'r', b'o', b'a']
| [b's', b'i', b'g']
| [b't', b'a', b'k'] => Ok(()),
_ => Err(ManifestProfileError::InvalidFileName(
String::from_utf8_lossy(bytes).into_owned(),
)),
}
}
fn decode_manifest_econtent_fast(der: &[u8]) -> Result<ManifestEContent, ManifestProfileError> {
let (tag, mut seq_content, rem) = der_take_tlv(der)
.map_err(|e| ManifestProfileError::ProfileDecode(format!("DER decode error: {e}")))?;
if !rem.is_empty() {
return Err(ManifestProfileError::ProfileDecode(format!(
"trailing bytes after DER object: {} bytes",
rem.len()
)));
}
if tag != 0x30 {
return Err(ManifestProfileError::ProfileDecode(
"Manifest eContent must be SEQUENCE".into(),
));
}
let seq_len = der_count_elements(seq_content).map_err(ManifestProfileError::ProfileDecode)?;
if seq_len != 5 && seq_len != 6 {
return Err(ManifestProfileError::InvalidManifestSequenceLen(seq_len));
}
let mut version: u32 = 0;
if seq_len == 6 {
let Some(&first_tag) = seq_content.first() else {
return Err(ManifestProfileError::InvalidManifestSequenceLen(0));
};
if first_tag != 0xA0 {
return Err(ManifestProfileError::ProfileDecode(
"Manifest.version must be [0] EXPLICIT INTEGER".into(),
));
}
let (_cs_tag, cs_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.version decode error: {e}"))
})?;
seq_content = after;
let (inner_tag, inner_value, inner_rem) = der_take_tlv(cs_value).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.version inner decode error: {e}"))
})?;
if !inner_rem.is_empty() {
return Err(ManifestProfileError::ProfileDecode(
"trailing bytes inside Manifest.version".into(),
));
}
if inner_tag != 0x02 {
return Err(ManifestProfileError::ProfileDecode(
"Manifest.version must be [0] EXPLICIT INTEGER".into(),
));
}
let v = der_integer_to_u64(inner_value).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.version decode error: {e}"))
})?;
if v != 0 {
return Err(ManifestProfileError::InvalidManifestVersion(v));
}
version = 0;
}
let (mn_tag, mn_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.manifestNumber decode error: {e}"))
})?;
seq_content = after;
if mn_tag != 0x02 {
return Err(ManifestProfileError::InvalidManifestNumber);
}
let manifest_number = der_integer_to_bigunsigned(mn_value)?;
let (tu_tag, tu_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.thisUpdate decode error: {e}"))
})?;
seq_content = after;
if tu_tag != 0x18 {
return Err(ManifestProfileError::InvalidThisUpdate);
}
let this_update =
parse_generalized_time_bytes(tu_value).map_err(ManifestProfileError::ProfileDecode)?;
let (nu_tag, nu_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.nextUpdate decode error: {e}"))
})?;
seq_content = after;
if nu_tag != 0x18 {
return Err(ManifestProfileError::InvalidNextUpdate);
}
let next_update =
parse_generalized_time_bytes(nu_value).map_err(ManifestProfileError::ProfileDecode)?;
if next_update <= this_update {
return Err(ManifestProfileError::NextUpdateNotLater);
}
let (oid_tag, oid_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.fileHashAlg decode error: {e}"))
})?;
seq_content = after;
if oid_tag != 0x06 {
return Err(ManifestProfileError::ProfileDecode(
"Manifest.fileHashAlg must be OBJECT IDENTIFIER".into(),
));
}
if !oid_content_is_sha256(oid_value) {
return Err(ManifestProfileError::InvalidFileHashAlg(
oid_content_to_string(oid_value),
));
}
let (fl_tag, fl_value, after) = der_take_tlv(seq_content).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("Manifest.fileList decode error: {e}"))
})?;
seq_content = after;
if fl_tag != 0x30 {
return Err(ManifestProfileError::InvalidFileList);
}
let file_count = validate_file_list_sha256_fast(fl_value)?;
let file_list_der = fl_value.to_vec();
if !seq_content.is_empty() {
return Err(ManifestProfileError::InvalidManifestSequenceLen(seq_len));
}
Ok(ManifestEContent {
version,
manifest_number,
this_update,
next_update,
file_hash_alg: OID_SHA256.to_string(),
file_list_der,
file_count,
})
}
fn validate_file_list_sha256_fast(content: &[u8]) -> Result<usize, ManifestProfileError> {
let mut cur = content;
let mut count: usize = 0;
while !cur.is_empty() {
let (tag, value, rem) = der_take_tlv(cur).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("fileList entry decode error: {e}"))
})?;
cur = rem;
if tag != 0x30 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
let mut entry = value;
let (fn_tag, fn_value, entry_rem) = der_take_tlv(entry).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("fileList fileName decode error: {e}"))
})?;
entry = entry_rem;
if fn_tag != 0x16 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
if entry.is_empty() {
return Err(ManifestProfileError::InvalidFileAndHash);
}
validate_file_name_bytes(fn_value)?;
let (hash_tag, hash_value, entry_rem) = der_take_tlv(entry).map_err(|_e| {
// Missing second element should map to "SEQUENCE of 2" shape error.
ManifestProfileError::InvalidFileAndHash
})?;
entry = entry_rem;
if !entry.is_empty() {
return Err(ManifestProfileError::InvalidFileAndHash);
}
if hash_tag != 0x03 {
return Err(ManifestProfileError::InvalidHashType);
}
if hash_value.is_empty() {
return Err(ManifestProfileError::InvalidHashLength(0));
}
let unused_bits = hash_value[0];
if unused_bits != 0 {
return Err(ManifestProfileError::HashNotOctetAligned);
}
let bits = &hash_value[1..];
if bits.len() != 32 {
return Err(ManifestProfileError::InvalidHashLength(bits.len()));
}
count += 1;
}
Ok(count)
}
fn parse_file_list_sha256_fast(content: &[u8]) -> Result<Vec<FileAndHash>, ManifestProfileError> {
// Heuristic initial capacity (avoid a full pre-scan, which is expensive for xlarge manifests).
// Each FileAndHash entry is typically tens of bytes; 80 is a conservative average.
let est = (content.len() / 80).clamp(16, 4096);
let mut cur = content;
let mut out: Vec<FileAndHash> = Vec::with_capacity(est);
while !cur.is_empty() {
let (tag, value, rem) = der_take_tlv(cur).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("fileList entry decode error: {e}"))
})?;
cur = rem;
if tag != 0x30 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
let mut entry = value;
let (fn_tag, fn_value, entry_rem) = der_take_tlv(entry).map_err(|e| {
ManifestProfileError::ProfileDecode(format!("fileList fileName decode error: {e}"))
})?;
entry = entry_rem;
if fn_tag != 0x16 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
if entry.is_empty() {
return Err(ManifestProfileError::InvalidFileAndHash);
}
let file_name = validate_and_copy_file_name(fn_value)?;
let (hash_tag, hash_value, entry_rem) = der_take_tlv(entry).map_err(|_e| {
// Missing second element should map to "SEQUENCE of 2" shape error.
ManifestProfileError::InvalidFileAndHash
})?;
entry = entry_rem;
if !entry.is_empty() {
return Err(ManifestProfileError::InvalidFileAndHash);
}
if hash_tag != 0x03 {
return Err(ManifestProfileError::InvalidHashType);
}
if hash_value.is_empty() {
return Err(ManifestProfileError::InvalidHashLength(0));
}
let unused_bits = hash_value[0];
if unused_bits != 0 {
return Err(ManifestProfileError::HashNotOctetAligned);
}
let bits = &hash_value[1..];
if bits.len() != 32 {
return Err(ManifestProfileError::InvalidHashLength(bits.len()));
}
let mut hash_bytes = [0u8; 32];
hash_bytes.copy_from_slice(bits);
out.push(FileAndHash {
file_name,
hash_bytes,
});
}
Ok(out)
}
fn validate_and_copy_file_name(bytes: &[u8]) -> Result<String, ManifestProfileError> {
validate_file_name_bytes(bytes)?;
String::from_utf8(bytes.to_vec()).map_err(|_| {
ManifestProfileError::InvalidFileName(String::from_utf8_lossy(bytes).into_owned())
})
}
fn der_count_elements(mut input: &[u8]) -> Result<usize, String> {
let mut count: usize = 0;
while !input.is_empty() {
let (_tag, _value, rem) = der_take_tlv(input)?;
input = rem;
count += 1;
}
Ok(count)
}
fn der_integer_to_u64(bytes: &[u8]) -> Result<u64, String> {
if bytes.is_empty() {
return Err("INTEGER empty".into());
}
// Reject negative (two's complement).
if bytes[0] & 0x80 != 0 {
return Err("INTEGER is negative".into());
}
if bytes.len() > 8 {
return Err("INTEGER too large".into());
}
let mut v: u64 = 0;
for &b in bytes {
v = (v << 8) | (b as u64);
}
Ok(v)
}
fn der_integer_to_bigunsigned(bytes: &[u8]) -> Result<BigUnsigned, ManifestProfileError> {
if bytes.is_empty() {
return Err(ManifestProfileError::InvalidManifestNumber);
}
// Two's complement: for non-negative values, a leading 0x00 may be present.
if bytes[0] & 0x80 != 0 {
return Err(ManifestProfileError::InvalidManifestNumber);
}
let mut start = 0usize;
while start + 1 < bytes.len() && bytes[start] == 0 {
start += 1;
}
let mut minimal = bytes[start..].to_vec();
if minimal.is_empty() {
minimal.push(0);
}
if minimal.len() > 20 {
return Err(ManifestProfileError::ManifestNumberTooLong);
}
Ok(BigUnsigned { bytes_be: minimal })
}
fn parse_generalized_time_bytes(bytes: &[u8]) -> Result<UtcTime, String> {
// Accept "YYYYMMDDHHMMSSZ" and also allow optional fractional seconds (".fff...Z").
if !bytes.is_ascii() {
return Err("GeneralizedTime not ASCII".into());
}
let s = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
if !s.ends_with('Z') {
return Err("GeneralizedTime must end with 'Z'".into());
}
let core = &s[..s.len() - 1];
let (main, frac) = core
.split_once('.')
.map_or((core, None), |(a, b)| (a, Some(b)));
if main.len() != 14 || !main.bytes().all(|b| b.is_ascii_digit()) {
return Err("GeneralizedTime must be YYYYMMDDHHMMSS[.fff]Z".into());
}
let year: i32 = main[0..4].parse().map_err(|_| "bad year")?;
let month: u8 = main[4..6].parse().map_err(|_| "bad month")?;
let day: u8 = main[6..8].parse().map_err(|_| "bad day")?;
let hour: u8 = main[8..10].parse().map_err(|_| "bad hour")?;
let minute: u8 = main[10..12].parse().map_err(|_| "bad minute")?;
let second: u8 = main[12..14].parse().map_err(|_| "bad second")?;
let nanosecond: u32 = if let Some(frac) = frac {
if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
return Err("bad fractional seconds".into());
}
let mut ns: u32 = 0;
let mut scale: u32 = 1_000_000_000;
for (i, ch) in frac.bytes().enumerate() {
if i >= 9 {
break;
}
scale /= 10;
ns += ((ch - b'0') as u32) * scale;
}
ns
} else {
0
};
let date = time::Date::from_calendar_date(
year,
time::Month::try_from(month).map_err(|_| "bad month")?,
day,
)
.map_err(|e| e.to_string())?;
let t =
time::Time::from_hms_nano(hour, minute, second, nanosecond).map_err(|e| e.to_string())?;
Ok(date.with_time(t).assume_utc())
}
fn oid_content_is_sha256(bytes: &[u8]) -> bool {
// 2.16.840.1.101.3.4.2.1
let mut arcs = oid_content_iter(bytes);
const EXPECTED: &[u64] = &[2, 16, 840, 1, 101, 3, 4, 2, 1];
for &e in EXPECTED {
match arcs.next() {
Some(v) if v == e => {}
_ => return false,
}
}
arcs.next().is_none()
}
fn oid_content_to_string(bytes: &[u8]) -> String {
let arcs: Vec<u64> = oid_content_iter(bytes).collect();
if arcs.is_empty() {
return "<invalid oid>".to_string();
}
let mut s = String::new();
for (i, a) in arcs.iter().enumerate() {
if i > 0 {
s.push('.');
}
s.push_str(&a.to_string());
}
s
}
fn oid_content_iter(bytes: &[u8]) -> impl Iterator<Item = u64> + '_ {
struct It<'a> {
bytes: &'a [u8],
pos: usize,
first_done: bool,
first_a0: u64,
first_a1: u64,
emit_first_idx: u8,
}
impl<'a> Iterator for It<'a> {
type Item = u64;
fn next(&mut self) -> Option<u64> {
if !self.first_done {
if self.bytes.is_empty() {
self.first_done = true;
return None;
}
let first = self.bytes[0] as u64;
self.first_a0 = first / 40;
self.first_a1 = first % 40;
self.pos = 1;
self.first_done = true;
self.emit_first_idx = 0;
}
if self.emit_first_idx == 0 {
self.emit_first_idx = 1;
return Some(self.first_a0);
}
if self.emit_first_idx == 1 {
self.emit_first_idx = 2;
return Some(self.first_a1);
}
if self.pos >= self.bytes.len() {
return None;
}
let mut v: u64 = 0;
while self.pos < self.bytes.len() {
let b = self.bytes[self.pos];
self.pos += 1;
v = (v << 7) | ((b & 0x7F) as u64);
if b & 0x80 == 0 {
return Some(v);
}
}
None
}
}
It {
bytes,
pos: 0,
first_done: false,
first_a0: 0,
first_a1: 0,
emit_first_idx: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tlv(tag: u8, value: &[u8]) -> Vec<u8> {
assert!(value.len() < 128);
let mut out = Vec::with_capacity(2 + value.len());
out.push(tag);
out.push(value.len() as u8);
out.extend_from_slice(value);
out
}
fn tlv_long_len(tag: u8, len_bytes: &[u8], value: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(2 + len_bytes.len() + value.len());
out.push(tag);
out.push(0x80 | (len_bytes.len() as u8));
out.extend_from_slice(len_bytes);
out.extend_from_slice(value);
out
}
#[test]
fn der_take_tlv_supports_short_and_long_form_lengths_and_errors() {
let v = b"abc";
let der = tlv(0x04, v);
let (tag, val, rem) = der_take_tlv(&der).expect("short len");
assert_eq!(tag, 0x04);
assert_eq!(val, v);
assert!(rem.is_empty());
// Long-form length with 1 length byte (130).
let v = vec![b'x'; 130];
let der = tlv_long_len(0x04, &[0x82], &v);
let (tag, val, rem) = der_take_tlv(&der).expect("long len 1");
assert_eq!(tag, 0x04);
assert_eq!(val.len(), 130);
assert!(rem.is_empty());
// Long-form length with 2 length bytes (256).
let v = vec![b'y'; 256];
let der = tlv_long_len(0x04, &[0x01, 0x00], &v);
let (tag, val, rem) = der_take_tlv(&der).expect("long len 2");
assert_eq!(tag, 0x04);
assert_eq!(val.len(), 256);
assert!(rem.is_empty());
assert!(der_take_tlv(&[]).is_err());
assert!(der_take_tlv(&[0x04]).is_err());
// High-tag-number form not supported.
assert!(der_take_tlv(&[0x1F, 0x01, 0x00]).is_err());
// Indefinite length is not allowed in DER.
assert!(der_take_tlv(&[0x04, 0x80]).is_err());
// Invalid long-form length encoding.
assert!(der_take_tlv(&[0x04, 0x81]).is_err());
assert!(der_take_tlv(&[0x04, 0x89]).is_err());
}
#[test]
fn parse_generalized_time_bytes_accepts_fraction_and_rejects_invalid() {
let t = parse_generalized_time_bytes(b"20260101000000Z").expect("basic time");
assert_eq!(t.year(), 2026);
let t = parse_generalized_time_bytes(b"20260101000000.1Z").expect("fractional");
assert_eq!(t.nanosecond(), 100_000_000);
assert!(parse_generalized_time_bytes(b"20260101000000").is_err());
assert!(parse_generalized_time_bytes(b"20260101000000+00").is_err());
assert!(parse_generalized_time_bytes(b"2026010100000Z").is_err());
assert!(parse_generalized_time_bytes(b"20261301000000Z").is_err());
assert!(parse_generalized_time_bytes(b"20260132000000Z").is_err());
assert!(parse_generalized_time_bytes(&[0xFF]).is_err());
}
#[test]
fn oid_helpers_accept_sha256_and_format_invalid() {
// 2.16.840.1.101.3.4.2.1
let sha256_oid_content = [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01];
assert!(oid_content_is_sha256(&sha256_oid_content));
assert!(!oid_content_is_sha256(&[0x55, 0x04, 0x03])); // 2.5.4.3
assert_eq!(oid_content_to_string(&[]), "<invalid oid>".to_string());
}
#[test]
fn validate_file_list_sha256_fast_counts_and_rejects_bad_hash() {
fn file_and_hash(file: &str, digest: u8) -> Vec<u8> {
let mut hash = vec![0u8; 33];
hash[0] = 0; // unused bits
for b in &mut hash[1..] {
*b = digest;
}
let ia5 = tlv(0x16, file.as_bytes());
let bit = tlv(0x03, &hash);
let mut entry = Vec::new();
entry.extend_from_slice(&ia5);
entry.extend_from_slice(&bit);
tlv(0x30, &entry)
}
let mut list = Vec::new();
list.extend_from_slice(&file_and_hash("A.cer", 0xAA));
list.extend_from_slice(&file_and_hash("B.roa", 0xBB));
assert_eq!(validate_file_list_sha256_fast(&list).expect("count"), 2);
// Wrong hash length.
let mut bad = Vec::new();
let ia5 = tlv(0x16, b"A.cer");
let bit = tlv(0x03, &[0u8; 2]); // too short
let mut entry = Vec::new();
entry.extend_from_slice(&ia5);
entry.extend_from_slice(&bit);
bad.extend_from_slice(&tlv(0x30, &entry));
assert!(matches!(
validate_file_list_sha256_fast(&bad),
Err(ManifestProfileError::InvalidHashLength(_))
));
}
}

13
src/model/mod.rs Normal file
View File

@ -0,0 +1,13 @@
pub mod aspa;
pub mod common;
pub mod crl;
pub mod manifest;
pub mod oid;
pub mod rc;
pub mod roa;
pub mod signed_object;
pub mod ta;
pub mod tal;
pub mod projection;
pub mod router_cert;

78
src/model/oid.rs Normal file
View File

@ -0,0 +1,78 @@
pub const OID_SHA256: &str = "2.16.840.1.101.3.4.2.1";
pub const OID_SHA256_RAW: &[u8] = &asn1_rs::oid!(raw 2.16.840.1.101.3.4.2.1);
pub const OID_SIGNED_DATA: &str = "1.2.840.113549.1.7.2";
pub const OID_SIGNED_DATA_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.7.2);
pub const OID_CMS_ATTR_CONTENT_TYPE: &str = "1.2.840.113549.1.9.3";
pub const OID_CMS_ATTR_CONTENT_TYPE_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.3);
pub const OID_CMS_ATTR_MESSAGE_DIGEST: &str = "1.2.840.113549.1.9.4";
pub const OID_CMS_ATTR_MESSAGE_DIGEST_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.4);
pub const OID_CMS_ATTR_SIGNING_TIME: &str = "1.2.840.113549.1.9.5";
pub const OID_CMS_ATTR_SIGNING_TIME_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.5);
pub const OID_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.1";
pub const OID_RSA_ENCRYPTION_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.1.1);
pub const OID_SHA256_WITH_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.11";
pub const OID_SHA256_WITH_RSA_ENCRYPTION_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.1.11);
// X.509 extensions (RFC 5280 / RFC 6487)
pub const OID_BASIC_CONSTRAINTS: &str = "2.5.29.19";
pub const OID_BASIC_CONSTRAINTS_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.19);
pub const OID_KEY_USAGE: &str = "2.5.29.15";
pub const OID_KEY_USAGE_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.15);
pub const OID_EXTENDED_KEY_USAGE: &str = "2.5.29.37";
pub const OID_EXTENDED_KEY_USAGE_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.37);
pub const OID_CRL_DISTRIBUTION_POINTS: &str = "2.5.29.31";
pub const OID_CRL_DISTRIBUTION_POINTS_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.31);
pub const OID_AUTHORITY_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.1";
pub const OID_AUTHORITY_INFO_ACCESS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.1);
pub const OID_CERTIFICATE_POLICIES: &str = "2.5.29.32";
pub const OID_CERTIFICATE_POLICIES_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.32);
pub const OID_QT_CPS: &str = "1.3.6.1.5.5.7.2.1";
pub const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35";
pub const OID_AUTHORITY_KEY_IDENTIFIER_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.35);
pub const OID_CRL_NUMBER: &str = "2.5.29.20";
pub const OID_CRL_NUMBER_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.20);
pub const OID_SUBJECT_KEY_IDENTIFIER: &str = "2.5.29.14";
pub const OID_SUBJECT_KEY_IDENTIFIER_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.14);
pub const OID_CT_RPKI_MANIFEST: &str = "1.2.840.113549.1.9.16.1.26";
pub const OID_CT_RPKI_MANIFEST_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.16.1.26);
pub const OID_CT_ROUTE_ORIGIN_AUTHZ: &str = "1.2.840.113549.1.9.16.1.24";
pub const OID_CT_ROUTE_ORIGIN_AUTHZ_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.16.1.24);
pub const OID_CT_ASPA: &str = "1.2.840.113549.1.9.16.1.49";
pub const OID_CT_ASPA_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.16.1.49);
// X.509 extensions / access methods (RFC 5280 / RFC 6487)
pub const OID_SUBJECT_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.11";
pub const OID_SUBJECT_INFO_ACCESS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.11);
pub const OID_AD_SIGNED_OBJECT: &str = "1.3.6.1.5.5.7.48.11";
pub const OID_AD_SIGNED_OBJECT_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.11);
pub const OID_AD_CA_ISSUERS: &str = "1.3.6.1.5.5.7.48.2";
pub const OID_AD_CA_ISSUERS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.2);
pub const OID_AD_CA_REPOSITORY: &str = "1.3.6.1.5.5.7.48.5";
pub const OID_AD_CA_REPOSITORY_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.5);
pub const OID_AD_RPKI_MANIFEST: &str = "1.3.6.1.5.5.7.48.10";
pub const OID_AD_RPKI_MANIFEST_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.10);
pub const OID_AD_RPKI_NOTIFY: &str = "1.3.6.1.5.5.7.48.13";
pub const OID_AD_RPKI_NOTIFY_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.48.13);
// RFC 3779 resource extensions (RFC 6487 profile)
pub const OID_IP_ADDR_BLOCKS: &str = "1.3.6.1.5.5.7.1.7";
pub const OID_IP_ADDR_BLOCKS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.7);
pub const OID_AUTONOMOUS_SYS_IDS: &str = "1.3.6.1.5.5.7.1.8";
pub const OID_AUTONOMOUS_SYS_IDS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.8);
// RPKI CP (RFC 6484 / RFC 6487)
pub const OID_CP_IPADDR_ASNUMBER: &str = "1.3.6.1.5.5.7.14.2";
pub const OID_CP_IPADDR_ASNUMBER_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.14.2);
pub const OID_CT_RPKI_CCR: &str = "1.2.840.113549.1.9.16.1.54";
pub const OID_CT_RPKI_CCR_RAW: &[u8] = &asn1_rs::oid!(raw 1.2.840.113549.1.9.16.1.54);
pub const OID_KP_BGPSEC_ROUTER: &str = "1.3.6.1.5.5.7.3.30";
pub const OID_EC_PUBLIC_KEY: &str = "1.2.840.10045.2.1";
pub const OID_SECP256R1: &str = "1.2.840.10045.3.1.7";

663
src/model/projection.rs Normal file
View File

@ -0,0 +1,663 @@
use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use crate::model::aspa::AspaObject;
use crate::model::crl::RpkixCrl;
use crate::model::manifest::ManifestObject;
use crate::model::rc::{AccessDescription, RcExtensions, ResourceCertificate, SubjectInfoAccess};
use crate::model::roa::{IpPrefix as RoaIpPrefix, RoaAfi, RoaObject};
use crate::model::signed_object::{
ResourceEeCertificate, RpkiSignedObject, SignedAttrsProfiled, SignerInfoProfiled,
};
use crate::model::ta::TaCertificate;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ObjectType {
Auto,
Cer,
Mft,
Crl,
Roa,
Aspa,
}
impl ObjectType {
pub fn parse(value: &str) -> Result<Self, String> {
match value.to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"cer" | ".cer" | "cert" | "certificate" => Ok(Self::Cer),
"mft" | ".mft" | "manifest" => Ok(Self::Mft),
"crl" | ".crl" => Ok(Self::Crl),
"roa" | ".roa" => Ok(Self::Roa),
"asa" | ".asa" | "aspa" => Ok(Self::Aspa),
_ => Err(format!("unsupported object type: {value}")),
}
}
pub fn label(self) -> &'static str {
object_type_label(self)
}
}
pub fn resolve_object_type(object_type: ObjectType, path: &Path) -> Result<ObjectType, String> {
if object_type != ObjectType::Auto {
return Ok(object_type);
}
match path
.extension()
.and_then(|v| v.to_str())
.map(|v| v.to_ascii_lowercase())
.as_deref()
{
Some("cer") => Ok(ObjectType::Cer),
Some("mft") => Ok(ObjectType::Mft),
Some("crl") => Ok(ObjectType::Crl),
Some("roa") => Ok(ObjectType::Roa),
Some("asa") | Some("aspa") => Ok(ObjectType::Aspa),
_ => Err(format!(
"cannot infer object type from path: {}",
path.display()
)),
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectProjectionRecord {
pub schema_version: u32,
pub sha256: String,
pub object_type: String,
pub parse_status: String,
pub error_summary: Option<String>,
pub projection: Value,
}
pub fn build_object_projection(
object_type: ObjectType,
input_path: &Path,
bytes: &[u8],
entry_limit: usize,
) -> ObjectProjectionRecord {
let resolved = match resolve_object_type(object_type, input_path) {
Ok(value) => value,
Err(err) => {
return ObjectProjectionRecord {
schema_version: 1,
sha256: sha256_hex(bytes),
object_type: "unknown".to_string(),
parse_status: "error".to_string(),
error_summary: Some(err),
projection: json!({"decode": {"profileValid": false}}),
};
}
};
let projection = parse_object_json(resolved, input_path, bytes, entry_limit);
let parse_status = if projection
.get("object")
.and_then(|v| v.get("decode"))
.and_then(|v| v.get("profileValid"))
.and_then(Value::as_bool)
.unwrap_or(false)
{
"ok"
} else {
"error"
};
let error_summary = projection
.get("object")
.and_then(|v| v.get("decode"))
.and_then(|v| v.get("error"))
.and_then(Value::as_str)
.map(str::to_string);
ObjectProjectionRecord {
schema_version: 1,
sha256: sha256_hex(bytes),
object_type: resolved.label().to_string(),
parse_status: parse_status.to_string(),
error_summary,
projection,
}
}
pub fn parse_object_json(
object_type: ObjectType,
input_path: &Path,
bytes: &[u8],
entry_limit: usize,
) -> Value {
let object = match object_type {
ObjectType::Auto => unreachable!("auto must be resolved"),
ObjectType::Cer => parse_cer_json(bytes),
ObjectType::Mft => parse_mft_json(bytes, entry_limit),
ObjectType::Crl => parse_crl_json(bytes, entry_limit),
ObjectType::Roa => parse_roa_json(bytes, entry_limit),
ObjectType::Aspa => parse_aspa_json(bytes, entry_limit),
};
json!({
"tool": "rpki_object_parse",
"schemaVersion": 1,
"input": {
"path": input_path.display().to_string(),
"type": object_type_label(object_type),
"bytes": bytes_summary(bytes),
},
"object": object,
})
}
pub fn parse_cer_json(bytes: &[u8]) -> Value {
match ResourceCertificate::decode_der(bytes) {
Ok(cert) => {
let ta_profile = match TaCertificate::decode_der(bytes) {
Ok(ta) => json!({
"valid": true,
"selfSignature": result_json(ta.verify_self_signature().map_err(|e| e.to_string())),
}),
Err(err) => json!({
"valid": false,
"error": err.to_string(),
}),
};
json!({
"type": "cer",
"decode": {"profileValid": true},
"resourceCertificate": resource_certificate_json(&cert),
"trustAnchorProfile": ta_profile,
})
}
Err(err) => json!({
"type": "cer",
"decode": {"profileValid": false, "error": err.to_string()},
}),
}
}
pub fn parse_mft_json(bytes: &[u8], entry_limit: usize) -> Value {
match ManifestObject::decode_der(bytes) {
Ok(mft) => {
let files = mft.manifest.parse_files();
let (file_sample, file_list_error) = match files {
Ok(entries) => (
json!({
"count": entries.len(),
"truncated": entries.len() > entry_limit,
"entries": entries.iter().take(entry_limit).map(|item| {
json!({"fileName": item.file_name, "hashHex": hex::encode(item.hash_bytes)})
}).collect::<Vec<_>>(),
}),
Value::Null,
),
Err(err) => (Value::Null, json!(err.to_string())),
};
json!({
"type": "mft",
"decode": {"profileValid": true},
"eContentType": mft.econtent_type,
"signedObject": signed_object_json(&mft.signed_object),
"manifest": {
"version": mft.manifest.version,
"manifestNumberHex": mft.manifest.manifest_number.to_hex_upper(),
"thisUpdate": format_time(mft.manifest.this_update),
"nextUpdate": format_time(mft.manifest.next_update),
"fileHashAlg": mft.manifest.file_hash_alg,
"fileCount": mft.manifest.file_count(),
"fileList": file_sample,
"fileListError": file_list_error,
},
"embeddedEeProfile": result_json(mft.validate_embedded_ee_cert().map_err(|e| e.to_string())),
"cmsSignature": result_json(mft.signed_object.verify_signature().map_err(|e| e.to_string())),
})
}
Err(err) => json!({
"type": "mft",
"decode": {"profileValid": false, "error": err.to_string()},
}),
}
}
pub fn parse_crl_json(bytes: &[u8], entry_limit: usize) -> Value {
match RpkixCrl::decode_der(bytes) {
Ok(crl) => json!({
"type": "crl",
"decode": {"profileValid": true},
"rawDer": bytes_summary(&crl.raw_der),
"version": crl.version,
"issuer": crl.issuer_dn,
"signatureAlgorithm": crl.signature_algorithm_oid,
"thisUpdate": format_time(crl.this_update.utc),
"nextUpdate": format_time(crl.next_update.utc),
"extensions": {
"authorityKeyIdentifier": hex::encode(&crl.extensions.authority_key_identifier),
"crlNumberHex": crl.extensions.crl_number.to_hex_upper(),
"crlNumber": crl.extensions.crl_number.to_u64(),
},
"revokedCertificates": {
"count": crl.revoked_certs.len(),
"truncated": crl.revoked_certs.len() > entry_limit,
"entries": crl.revoked_certs.iter().take(entry_limit).map(|item| {
json!({
"serialNumberHex": item.serial_number.to_hex_upper(),
"serialNumber": item.serial_number.to_u64(),
"revocationDate": format_time(item.revocation_date.utc),
})
}).collect::<Vec<_>>(),
},
}),
Err(err) => json!({
"type": "crl",
"decode": {"profileValid": false, "error": err.to_string()},
}),
}
}
pub fn manifest_file_entries_page(
bytes: &[u8],
offset: usize,
limit: usize,
) -> Result<(usize, Vec<Value>), String> {
let mft = ManifestObject::decode_der(bytes).map_err(|err| err.to_string())?;
let entries = mft.manifest.parse_files().map_err(|err| err.to_string())?;
let total = entries.len();
let end = (offset + limit).min(total);
let page = entries[offset.min(total)..end]
.iter()
.map(|item| json!({"fileName": item.file_name, "hashHex": hex::encode(item.hash_bytes)}))
.collect::<Vec<_>>();
Ok((total, page))
}
pub fn crl_revoked_entries_page(
bytes: &[u8],
offset: usize,
limit: usize,
) -> Result<(usize, Vec<Value>), String> {
let crl = RpkixCrl::decode_der(bytes).map_err(|err| err.to_string())?;
let total = crl.revoked_certs.len();
let end = (offset + limit).min(total);
let page = crl.revoked_certs[offset.min(total)..end]
.iter()
.map(|item| {
json!({
"serialNumberHex": item.serial_number.to_hex_upper(),
"serialNumber": item.serial_number.to_u64(),
"revocationDate": format_time(item.revocation_date.utc),
})
})
.collect::<Vec<_>>();
Ok((total, page))
}
pub fn parse_roa_json(bytes: &[u8], entry_limit: usize) -> Value {
match RoaObject::decode_der(bytes) {
Ok(roa) => json!({
"type": "roa",
"decode": {"profileValid": true},
"eContentType": roa.econtent_type,
"signedObject": signed_object_json(&roa.signed_object),
"roa": {
"version": roa.roa.version,
"asId": roa.roa.as_id,
"ipAddressFamilies": roa.roa.ip_addr_blocks.iter().map(|family| {
json!({
"afi": format!("{:?}", family.afi),
"addressCount": family.addresses.len(),
"truncated": family.addresses.len() > entry_limit,
"addresses": family.addresses.iter().take(entry_limit).map(|entry| {
json!({
"prefix": roa_prefix_string(&entry.prefix),
"maxLength": entry.max_length,
})
}).collect::<Vec<_>>(),
})
}).collect::<Vec<_>>(),
},
"embeddedEeProfile": result_json(roa.validate_embedded_ee_cert().map_err(|e| e.to_string())),
"cmsSignature": result_json(roa.signed_object.verify_signature().map_err(|e| e.to_string())),
}),
Err(err) => json!({
"type": "roa",
"decode": {"profileValid": false, "error": err.to_string()},
}),
}
}
pub fn parse_aspa_json(bytes: &[u8], entry_limit: usize) -> Value {
match AspaObject::decode_der(bytes) {
Ok(aspa) => json!({
"type": "aspa",
"decode": {"profileValid": true},
"eContentType": aspa.econtent_type,
"signedObject": signed_object_json(&aspa.signed_object),
"aspa": {
"version": aspa.aspa.version,
"customerAsId": aspa.aspa.customer_as_id,
"providerCount": aspa.aspa.provider_as_ids.len(),
"providersTruncated": aspa.aspa.provider_as_ids.len() > entry_limit,
"providerAsIds": aspa.aspa.provider_as_ids.iter().take(entry_limit).copied().collect::<Vec<_>>(),
},
"embeddedEeProfile": result_json(aspa.validate_embedded_ee_cert().map_err(|e| e.to_string())),
"cmsSignature": result_json(aspa.signed_object.verify_signature().map_err(|e| e.to_string())),
}),
Err(err) => json!({
"type": "aspa",
"decode": {"profileValid": false, "error": err.to_string()},
}),
}
}
fn resource_certificate_json(cert: &ResourceCertificate) -> Value {
let tbs = &cert.tbs;
json!({
"rawDer": bytes_summary(&cert.raw_der),
"kind": format!("{:?}", cert.kind),
"version": tbs.version,
"serialNumberHex": hex::encode(tbs.serial_number.to_bytes_be()),
"signatureAlgorithm": tbs.signature_algorithm,
"issuer": tbs.issuer_name.to_string(),
"subject": tbs.subject_name.to_string(),
"validity": {
"notBefore": format_time(tbs.validity_not_before),
"notAfter": format_time(tbs.validity_not_after),
},
"subjectPublicKeyInfo": bytes_summary(&tbs.subject_public_key_info),
"extensions": rc_extensions_json(&tbs.extensions),
})
}
fn rc_extensions_json(ext: &RcExtensions) -> Value {
json!({
"basicConstraintsCa": ext.basic_constraints_ca,
"subjectKeyIdentifier": ext.subject_key_identifier.as_ref().map(hex::encode),
"authorityKeyIdentifier": ext.authority_key_identifier.as_ref().map(hex::encode),
"crlDistributionPointsUris": ext.crl_distribution_points_uris,
"caIssuersUris": ext.ca_issuers_uris,
"subjectInfoAccess": subject_info_access_json(ext.subject_info_access.as_ref()),
"certificatePoliciesOid": ext.certificate_policies_oid,
"ipResources": serde_json::to_value(&ext.ip_resources).unwrap_or(Value::Null),
"asResources": serde_json::to_value(&ext.as_resources).unwrap_or(Value::Null),
})
}
fn subject_info_access_json(value: Option<&SubjectInfoAccess>) -> Value {
match value {
None => Value::Null,
Some(SubjectInfoAccess::Ca(ca)) => json!({
"kind": "ca",
"accessDescriptions": ca.access_descriptions.iter().map(access_description_json).collect::<Vec<_>>(),
}),
Some(SubjectInfoAccess::Ee(ee)) => json!({
"kind": "ee",
"signedObjectUris": ee.signed_object_uris,
"accessDescriptions": ee.access_descriptions.iter().map(access_description_json).collect::<Vec<_>>(),
}),
}
}
fn access_description_json(value: &AccessDescription) -> Value {
json!({
"accessMethodOid": value.access_method_oid,
"accessLocation": value.access_location,
})
}
fn signed_object_json(signed_object: &RpkiSignedObject) -> Value {
let signed_data = &signed_object.signed_data;
json!({
"rawDer": bytes_summary(&signed_object.raw_der),
"contentInfoContentType": signed_object.content_info_content_type,
"signedData": {
"version": signed_data.version,
"digestAlgorithms": signed_data.digest_algorithms,
"encapContentInfo": {
"eContentType": signed_data.encap_content_info.econtent_type,
"eContent": bytes_summary(&signed_data.encap_content_info.econtent),
},
"certificates": signed_data.certificates.iter().map(ee_certificate_json).collect::<Vec<_>>(),
"crlsPresent": signed_data.crls_present,
"signerInfos": signed_data.signer_infos.iter().map(signer_info_json).collect::<Vec<_>>(),
},
})
}
fn ee_certificate_json(cert: &ResourceEeCertificate) -> Value {
json!({
"rawDer": bytes_summary(&cert.raw_der),
"subjectKeyIdentifier": hex::encode(&cert.subject_key_identifier),
"spkiDer": bytes_summary(&cert.spki_der),
"rsaPublicKey": {
"modulus": bytes_summary(&cert.rsa_public_modulus),
"exponent": bytes_summary(&cert.rsa_public_exponent),
},
"tbsCertificate": bytes_summary(&cert.tbs_certificate_der),
"certificateSignature": bytes_summary(&cert.signature_bytes),
"keyUsageSummary": format!("{:?}", cert.key_usage_summary),
"siaSignedObjectUris": cert.sia_signed_object_uris,
"resourceCertificate": resource_certificate_json(&cert.resource_cert),
})
}
fn signer_info_json(info: &SignerInfoProfiled) -> Value {
json!({
"version": info.version,
"sidSki": hex::encode(&info.sid_ski),
"digestAlgorithm": info.digest_algorithm,
"signatureAlgorithm": info.signature_algorithm,
"signedAttrs": signed_attrs_json(&info.signed_attrs),
"unsignedAttrsPresent": info.unsigned_attrs_present,
"signature": bytes_summary(&info.signature),
"signedAttrsDerForSignature": bytes_summary(&info.signed_attrs_der_for_signature),
})
}
fn signed_attrs_json(attrs: &SignedAttrsProfiled) -> Value {
json!({
"contentType": attrs.content_type,
"messageDigest": hex::encode(&attrs.message_digest),
"signingTime": {
"utc": format_time(attrs.signing_time.utc),
"encoding": format!("{:?}", attrs.signing_time.encoding),
},
"otherAttrsPresent": attrs.other_attrs_present,
})
}
fn result_json(result: Result<(), String>) -> Value {
match result {
Ok(()) => json!({"valid": true}),
Err(err) => json!({"valid": false, "error": err}),
}
}
fn object_type_label(object_type: ObjectType) -> &'static str {
match object_type {
ObjectType::Auto => "auto",
ObjectType::Cer => "cer",
ObjectType::Mft => "mft",
ObjectType::Crl => "crl",
ObjectType::Roa => "roa",
ObjectType::Aspa => "aspa",
}
}
fn bytes_summary(bytes: &[u8]) -> Value {
let head_len = bytes.len().min(16);
let tail_len = bytes.len().min(16);
json!({
"len": bytes.len(),
"sha256": sha256_hex(bytes),
"headHex": hex::encode(&bytes[..head_len]),
"tailHex": hex::encode(&bytes[bytes.len().saturating_sub(tail_len)..]),
})
}
fn sha256_hex(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
fn format_time(value: time::OffsetDateTime) -> String {
value
.to_offset(time::UtcOffset::UTC)
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| value.unix_timestamp().to_string())
}
fn roa_prefix_string(prefix: &RoaIpPrefix) -> String {
let bytes = prefix.addr_bytes();
match prefix.afi {
RoaAfi::Ipv4 => {
let octets = [bytes[0], bytes[1], bytes[2], bytes[3]];
format!("{}/{}", Ipv4Addr::from(octets), prefix.prefix_len)
}
RoaAfi::Ipv6 => {
let mut octets = [0u8; 16];
octets.copy_from_slice(bytes);
format!("{}/{}", Ipv6Addr::from(octets), prefix.prefix_len)
}
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
#[test]
fn object_type_parser_and_resolver_cover_aliases() {
assert_eq!(ObjectType::parse("auto").unwrap(), ObjectType::Auto);
assert_eq!(ObjectType::parse(".cer").unwrap(), ObjectType::Cer);
assert_eq!(ObjectType::parse("certificate").unwrap(), ObjectType::Cer);
assert_eq!(ObjectType::parse("manifest").unwrap(), ObjectType::Mft);
assert_eq!(ObjectType::parse(".crl").unwrap(), ObjectType::Crl);
assert_eq!(ObjectType::parse("roa").unwrap(), ObjectType::Roa);
assert_eq!(ObjectType::parse("aspa").unwrap(), ObjectType::Aspa);
assert_eq!(ObjectType::parse(".asa").unwrap(), ObjectType::Aspa);
assert!(ObjectType::parse("unknown").is_err());
assert_eq!(ObjectType::Aspa.label(), "aspa");
assert_eq!(
resolve_object_type(ObjectType::Auto, Path::new("repo/a.cer")).unwrap(),
ObjectType::Cer
);
assert_eq!(
resolve_object_type(ObjectType::Auto, Path::new("repo/a.mft")).unwrap(),
ObjectType::Mft
);
assert_eq!(
resolve_object_type(ObjectType::Auto, Path::new("repo/a.crl")).unwrap(),
ObjectType::Crl
);
assert_eq!(
resolve_object_type(ObjectType::Auto, Path::new("repo/a.roa")).unwrap(),
ObjectType::Roa
);
assert_eq!(
resolve_object_type(ObjectType::Auto, Path::new("repo/a.asa")).unwrap(),
ObjectType::Aspa
);
assert_eq!(
resolve_object_type(ObjectType::Roa, Path::new("repo/a.bin")).unwrap(),
ObjectType::Roa
);
assert!(resolve_object_type(ObjectType::Auto, Path::new("repo/a.bin")).is_err());
}
#[test]
fn invalid_der_returns_error_projection_for_all_object_types() {
let bytes = b"not der";
for object_type in [
ObjectType::Cer,
ObjectType::Mft,
ObjectType::Crl,
ObjectType::Roa,
ObjectType::Aspa,
] {
let value = parse_object_json(object_type, Path::new("bad.der"), bytes, 1);
assert_eq!(
value["object"]["decode"]["profileValid"].as_bool(),
Some(false)
);
assert!(value["object"]["decode"]["error"].as_str().is_some());
}
let record = build_object_projection(ObjectType::Auto, Path::new("bad.bin"), bytes, 1);
assert_eq!(record.object_type, "unknown");
assert_eq!(record.parse_status, "error");
assert!(record.error_summary.is_some());
}
#[test]
fn parses_synthetic_objects_into_human_readable_projection() {
let fixture = crate::test_support::synthetic_repository();
let repository = fixture.case_repository("baseline-v1");
let cases = [
(
ObjectType::Cer,
fixture.trust_anchor(),
"cer",
"resourceCertificate",
),
(
ObjectType::Mft,
repository.join("child/child.mft"),
"mft",
"manifest",
),
(
ObjectType::Crl,
repository.join("child/child.crl"),
"crl",
"revokedCertificates",
),
(
ObjectType::Roa,
repository.join("child/valid.roa"),
"roa",
"roa",
),
(
ObjectType::Aspa,
repository.join("child/valid.asa"),
"aspa",
"aspa",
),
];
for (object_type, path, expected_type, expected_section) in cases {
let bytes = std::fs::read(&path).expect("synthetic object");
let record = build_object_projection(object_type, &path, &bytes, 1);
assert_eq!(record.object_type, expected_type);
assert_eq!(record.parse_status, "ok");
assert_eq!(
record.projection["object"]["decode"]["profileValid"].as_bool(),
Some(true)
);
assert!(record.projection["object"][expected_section].is_object());
}
}
#[test]
fn large_projection_lists_are_paged_from_raw_bytes() {
let repository = crate::test_support::synthetic_repository().case_repository("baseline-v1");
let mft_bytes = std::fs::read(repository.join("child/child.mft")).expect("synthetic mft");
let (total, page) = manifest_file_entries_page(&mft_bytes, 1, 3).expect("mft page");
assert!(total >= 3);
assert_eq!(page.len(), (total - 1).min(3));
assert!(page[0]["fileName"].as_str().is_some());
let (_, empty_page) =
manifest_file_entries_page(&mft_bytes, total + 10, 3).expect("empty page");
assert!(empty_page.is_empty());
let crl_bytes = std::fs::read(repository.join("child/child.crl")).expect("synthetic crl");
let (total, page) = crl_revoked_entries_page(&crl_bytes, 0, 5).expect("crl page");
assert!(page.len() <= total);
let (_, empty_page) =
crl_revoked_entries_page(&crl_bytes, total + 10, 5).expect("empty crl page");
assert!(empty_page.is_empty());
}
}

30
src/model/rc.rs Normal file
View File

@ -0,0 +1,30 @@
use der_parser::ber::{BerObjectContent, Class};
use der_parser::der::{DerObject, Tag, parse_der};
use der_parser::num_bigint::BigUint;
use x509_parser::asn1_rs::{Class as Asn1Class, Tag as Asn1Tag};
use x509_parser::extensions::ParsedExtension;
use x509_parser::prelude::{FromDer, X509Certificate, X509Extension, X509Version};
use serde::{Deserialize, Serialize};
use crate::model::common::{
Asn1TimeUtc, DerReader, InvalidTimeEncodingError, UtcTime, X509NameDer, asn1_time_to_model,
};
use crate::model::oid::{
OID_AD_CA_ISSUERS_RAW, OID_AD_CA_REPOSITORY, OID_AD_CA_REPOSITORY_RAW, OID_AD_RPKI_MANIFEST,
OID_AD_RPKI_MANIFEST_RAW, OID_AD_RPKI_NOTIFY, OID_AD_RPKI_NOTIFY_RAW, OID_AD_SIGNED_OBJECT,
OID_AD_SIGNED_OBJECT_RAW, OID_AUTHORITY_INFO_ACCESS, OID_AUTHORITY_INFO_ACCESS_RAW,
OID_AUTHORITY_KEY_IDENTIFIER, OID_AUTHORITY_KEY_IDENTIFIER_RAW, OID_AUTONOMOUS_SYS_IDS,
OID_AUTONOMOUS_SYS_IDS_RAW, OID_BASIC_CONSTRAINTS, OID_BASIC_CONSTRAINTS_RAW,
OID_CERTIFICATE_POLICIES, OID_CERTIFICATE_POLICIES_RAW, OID_CP_IPADDR_ASNUMBER,
OID_CP_IPADDR_ASNUMBER_RAW, OID_CRL_DISTRIBUTION_POINTS, OID_CRL_DISTRIBUTION_POINTS_RAW,
OID_EXTENDED_KEY_USAGE, OID_IP_ADDR_BLOCKS, OID_IP_ADDR_BLOCKS_RAW, OID_KEY_USAGE, OID_QT_CPS,
OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW, OID_SUBJECT_INFO_ACCESS,
OID_SUBJECT_INFO_ACCESS_RAW, OID_SUBJECT_KEY_IDENTIFIER, OID_SUBJECT_KEY_IDENTIFIER_RAW,
};
include!("rc/types.rs");
include!("rc/certificate_validation.rs");
include!("rc/parsed_validation.rs");
include!("rc/parsing.rs");
include!("rc/strict_name_tests.rs");

View File

@ -0,0 +1,248 @@
// Resource certificate profile validation and strict-name checks.
impl ResourceCertificate {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(
der: &[u8],
) -> Result<ResourceCertificateParsed, ResourceCertificateParseError> {
let (rem, cert) = X509Certificate::from_der(der)
.map_err(|e| ResourceCertificateParseError::Parse(e.to_string()))?;
if !rem.is_empty() {
return Err(ResourceCertificateParseError::TrailingBytes(rem.len()));
}
let validity_not_before = asn1_time_to_model(cert.validity().not_before);
let validity_not_after = asn1_time_to_model(cert.validity().not_after);
let subject_public_key_info = cert.tbs_certificate.subject_pki.raw.to_vec();
let signature_algorithm = algorithm_identifier_value(&cert.signature_algorithm);
let tbs_signature_algorithm = algorithm_identifier_value(&cert.tbs_certificate.signature);
let extensions = parse_extensions_parse(cert.extensions())?;
Ok(ResourceCertificateParsed {
raw_der: der.to_vec(),
version: cert.version(),
serial_number: cert.tbs_certificate.serial.clone(),
signature_algorithm,
tbs_signature_algorithm,
issuer_name: X509NameDer(cert.issuer().as_raw().to_vec()),
subject_name: X509NameDer(cert.subject().as_raw().to_vec()),
validity_not_before,
validity_not_after,
subject_public_key_info,
extensions,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `ResourceCertificate` is already profile-validated when constructed via `decode_der()` /
/// `ResourceCertificateParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), ResourceCertificateProfileError> {
Ok(())
}
pub fn validate_rfc6487_profile(
&self,
role: ResourceCertificateRole,
) -> Result<(), ResourceCertificateProfileError> {
let role_name = match role {
ResourceCertificateRole::TrustAnchor => "trust anchor CA",
ResourceCertificateRole::Ca => "CA",
ResourceCertificateRole::SignedObjectEe => "signed-object EE",
ResourceCertificateRole::RouterEe => "router EE",
};
let ca_role = matches!(
role,
ResourceCertificateRole::TrustAnchor | ResourceCertificateRole::Ca
);
if ca_role {
let constraints = self
.tbs
.extensions
.basic_constraints
.as_ref()
.ok_or(ResourceCertificateProfileError::BasicConstraintsMissing)?;
if !constraints.critical {
return Err(ResourceCertificateProfileError::BasicConstraintsCriticality);
}
if !constraints.ca {
return Err(ResourceCertificateProfileError::BasicConstraintsCaFalse);
}
if constraints.path_len_constraint.is_some() {
return Err(ResourceCertificateProfileError::BasicConstraintsPathLenPresent);
}
} else if self.tbs.extensions.basic_constraints.is_some() {
return Err(ResourceCertificateProfileError::BasicConstraintsEeMustOmit);
}
for oid in &self.tbs.extensions.extension_oids {
if !is_permitted_extension(oid, role) {
return Err(ResourceCertificateProfileError::DisallowedExtension {
role: role_name,
oid: oid.clone(),
});
}
}
let policies = self
.tbs
.extensions
.certificate_policies
.as_ref()
.ok_or(ResourceCertificateProfileError::CertificatePoliciesMissing)?;
if policies.policy_oid != OID_CP_IPADDR_ASNUMBER {
return Err(ResourceCertificateProfileError::InvalidCertificatePolicy(
policies.policy_oid.clone(),
));
}
if policies.qualifier_oids.len() > 1 {
return Err(ResourceCertificateProfileError::CertificatePoliciesTooManyQualifiers);
}
if let Some(qualifier_oid) = policies.qualifier_oids.first()
&& qualifier_oid != OID_QT_CPS {
return Err(
ResourceCertificateProfileError::CertificatePoliciesInvalidQualifier(
qualifier_oid.clone(),
),
);
}
if self
.tbs
.extensions
.as_resources
.as_ref()
.is_some_and(|resources| resources.rdi.is_some())
{
return Err(ResourceCertificateProfileError::AsResourcesRdiPresent);
}
Ok(())
}
pub fn validate_strict_name_profile(&self) -> Result<(), ResourceCertificateProfileError> {
validate_strict_rpki_name(&self.tbs.issuer_name, "issuer")?;
validate_strict_rpki_name(&self.tbs.subject_name, "subject")?;
Ok(())
}
/// Decode a resource certificate (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, ResourceCertificateDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
pub fn decode_der_with_strict_name(der: &[u8]) -> Result<Self, ResourceCertificateDecodeError> {
let cert = Self::decode_der(der)?;
cert.validate_strict_name_profile()?;
Ok(cert)
}
/// Backwards-compatible helper (historical name).
pub fn from_der(der: &[u8]) -> Result<Self, ResourceCertificateError> {
Self::decode_der(der)
}
}
fn is_permitted_extension(oid: &str, role: ResourceCertificateRole) -> bool {
matches!(
oid,
OID_BASIC_CONSTRAINTS
| OID_KEY_USAGE
| OID_SUBJECT_KEY_IDENTIFIER
| OID_AUTHORITY_KEY_IDENTIFIER
| OID_CRL_DISTRIBUTION_POINTS
| OID_AUTHORITY_INFO_ACCESS
| OID_SUBJECT_INFO_ACCESS
| OID_CERTIFICATE_POLICIES
| OID_IP_ADDR_BLOCKS
| OID_AUTONOMOUS_SYS_IDS
) || (role == ResourceCertificateRole::RouterEe && oid == OID_EXTENDED_KEY_USAGE)
}
fn validate_strict_rpki_name(
name: &X509NameDer,
role: &'static str,
) -> Result<(), ResourceCertificateProfileError> {
let mut name_seq = DerReader::new(name.as_raw())
.take_sequence()
.map_err(|e| ResourceCertificateProfileError::StrictName { role, detail: e })?;
let mut common_name_count = 0usize;
let mut serial_number_count = 0usize;
while !name_seq.is_empty() {
let set_bytes = name_seq
.take_tag(0x31)
.map_err(|e| ResourceCertificateProfileError::StrictName { role, detail: e })?;
let mut rdn_set = DerReader::new(set_bytes);
if rdn_set.is_empty() {
return Err(ResourceCertificateProfileError::StrictName {
role,
detail: "RelativeDistinguishedName SET is empty".to_string(),
});
}
while !rdn_set.is_empty() {
let mut attr = rdn_set
.take_sequence()
.map_err(|e| ResourceCertificateProfileError::StrictName { role, detail: e })?;
let oid = attr
.take_tag(0x06)
.map_err(|e| ResourceCertificateProfileError::StrictName { role, detail: e })?;
let (value_tag, _value) = attr
.take_any()
.map_err(|e| ResourceCertificateProfileError::StrictName { role, detail: e })?;
if !attr.is_empty() {
return Err(ResourceCertificateProfileError::StrictName {
role,
detail: "AttributeTypeAndValue must be SEQUENCE of 2".to_string(),
});
}
match *oid {
// 2.5.4.3 commonName
[0x55, 0x04, 0x03] => {
common_name_count += 1;
if value_tag != 0x13 {
return Err(ResourceCertificateProfileError::StrictName {
role,
detail: format!(
"commonName must be PrintableString, got tag 0x{value_tag:02X}"
),
});
}
}
// 2.5.4.5 serialNumber
[0x55, 0x04, 0x05] => {
serial_number_count += 1;
if value_tag != 0x13 {
return Err(ResourceCertificateProfileError::StrictName {
role,
detail: format!(
"serialNumber must be PrintableString, got tag 0x{value_tag:02X}"
),
});
}
}
_ => {}
}
}
}
if common_name_count != 1 {
return Err(ResourceCertificateProfileError::StrictName {
role,
detail: format!("commonName must appear exactly once, got {common_name_count}"),
});
}
if serial_number_count > 1 {
return Err(ResourceCertificateProfileError::StrictName {
role,
detail: format!("serialNumber must appear at most once, got {serial_number_count}"),
});
}
Ok(())
}

View File

@ -0,0 +1,346 @@
// Parsed certificate and extension profile validation.
impl ResourceCertificateParsed {
pub fn validate_profile(self) -> Result<ResourceCertificate, ResourceCertificateProfileError> {
let version = match self.version {
X509Version::V3 => 2u32,
_ => return Err(ResourceCertificateProfileError::InvalidVersion),
};
self.validity_not_before
.validate_encoding_rfc5280("notBefore")?;
self.validity_not_after
.validate_encoding_rfc5280("notAfter")?;
if self.signature_algorithm != self.tbs_signature_algorithm {
return Err(ResourceCertificateProfileError::SignatureAlgorithmMismatch);
}
if self.signature_algorithm.oid != OID_SHA256_WITH_RSA_ENCRYPTION {
return Err(ResourceCertificateProfileError::UnsupportedSignatureAlgorithm);
}
if !self.signature_algorithm.params_absent_or_null() {
return Err(ResourceCertificateProfileError::InvalidSignatureAlgorithmParameters);
}
let is_self_signed = self.issuer_name == self.subject_name;
let extensions = self.extensions.validate_profile(is_self_signed)?;
let kind = if extensions.basic_constraints_ca {
ResourceCertKind::Ca
} else {
ResourceCertKind::Ee
};
Ok(ResourceCertificate {
raw_der: self.raw_der,
tbs: RpkixTbsCertificate {
version,
serial_number: self.serial_number,
signature_algorithm: self.signature_algorithm.oid,
issuer_name: self.issuer_name,
subject_name: self.subject_name,
validity_not_before: self.validity_not_before.utc,
validity_not_after: self.validity_not_after.utc,
subject_public_key_info: self.subject_public_key_info,
extensions,
},
kind,
})
}
}
impl RcExtensionsParsed {
pub fn validate_profile(
self,
is_self_signed: bool,
) -> Result<RcExtensions, ResourceCertificateProfileError> {
// NOTE(perf): `self` is consumed. Prefer moving decoded fields out rather than cloning,
// especially for large resource sets and URI lists.
let RcExtensionsParsed {
basic_constraints,
subject_key_identifier,
authority_key_identifier,
crl_distribution_points,
authority_info_access,
subject_info_access,
certificate_policies,
extension_oids,
ip_resources,
as_resources,
} = self;
if basic_constraints.len() > 1 {
return Err(ResourceCertificateProfileError::DuplicateExtension(
"basicConstraints",
));
}
let basic_constraints = basic_constraints.into_iter().next();
let basic_constraints_ca = basic_constraints.as_ref().is_some_and(|bc| bc.ca);
let subject_key_identifier = match subject_key_identifier.len() {
0 => None,
1 => {
let (ski, critical) = subject_key_identifier.into_iter().next().expect("len==1");
if critical {
return Err(ResourceCertificateProfileError::SkiCriticality);
}
Some(ski)
}
_ => {
return Err(ResourceCertificateProfileError::DuplicateExtension(
"subjectKeyIdentifier",
));
}
};
let authority_key_identifier = match authority_key_identifier.len() {
0 => {
if is_self_signed {
None
} else {
return Err(ResourceCertificateProfileError::AkiMissing);
}
}
1 => {
let (aki, critical) = authority_key_identifier.into_iter().next().expect("len==1");
if critical {
return Err(ResourceCertificateProfileError::AkiCriticality);
}
if aki.has_authority_cert_issuer {
return Err(ResourceCertificateProfileError::AkiAuthorityCertIssuerPresent);
}
if aki.has_authority_cert_serial {
return Err(ResourceCertificateProfileError::AkiAuthorityCertSerialPresent);
}
let keyid = aki.key_identifier;
if is_self_signed {
if let (Some(keyid), Some(ski)) =
(keyid.as_ref(), subject_key_identifier.as_ref())
&& keyid != ski {
return Err(ResourceCertificateProfileError::AkiSelfSignedNotEqualSki);
}
} else if keyid.is_none() {
return Err(ResourceCertificateProfileError::AkiMissing);
}
keyid
}
_ => {
return Err(ResourceCertificateProfileError::DuplicateExtension(
"authorityKeyIdentifier",
));
}
};
let crl_distribution_points_uris = match crl_distribution_points.len() {
0 => {
if is_self_signed {
None
} else {
return Err(ResourceCertificateProfileError::CrlDistributionPointsMissing);
}
}
1 => {
let (crldp, critical) = crl_distribution_points.into_iter().next().expect("len==1");
if critical {
return Err(ResourceCertificateProfileError::CrlDistributionPointsCriticality);
}
if is_self_signed {
return Err(
ResourceCertificateProfileError::CrlDistributionPointsSelfSignedMustOmit,
);
}
if crldp.distribution_points.len() != 1 {
return Err(ResourceCertificateProfileError::CrlDistributionPointsNotSingle);
}
let dp = crldp
.distribution_points
.into_iter()
.next()
.expect("len==1");
if dp.reasons_present {
return Err(ResourceCertificateProfileError::CrlDistributionPointsHasReasons);
}
if dp.crl_issuer_present {
return Err(ResourceCertificateProfileError::CrlDistributionPointsHasCrlIssuer);
}
if !dp.distribution_point_present {
return Err(
ResourceCertificateProfileError::CrlDistributionPointsNoDistributionPoint,
);
}
if dp.name_relative_to_crl_issuer_present || !dp.full_name_present {
return Err(ResourceCertificateProfileError::CrlDistributionPointsInvalidName);
}
if dp.full_name_not_uri {
return Err(
ResourceCertificateProfileError::CrlDistributionPointsFullNameNotUri,
);
}
if !dp.full_name_uris.iter().any(|u| u.starts_with("rsync://")) {
return Err(ResourceCertificateProfileError::CrlDistributionPointsNoRsync);
}
Some(dp.full_name_uris)
}
_ => {
return Err(ResourceCertificateProfileError::DuplicateExtension(
"cRLDistributionPoints",
));
}
};
let ca_issuers_uris = match authority_info_access.len() {
0 => {
if is_self_signed {
None
} else {
return Err(ResourceCertificateProfileError::AuthorityInfoAccessMissing);
}
}
1 => {
let (aia, critical) = authority_info_access.into_iter().next().expect("len==1");
if critical {
return Err(ResourceCertificateProfileError::AuthorityInfoAccessCriticality);
}
if is_self_signed {
return Err(
ResourceCertificateProfileError::AuthorityInfoAccessSelfSignedMustOmit,
);
}
if aia.ca_issuers_access_location_not_uri {
return Err(
ResourceCertificateProfileError::AuthorityInfoAccessCaIssuersNotUri,
);
}
if aia.ca_issuers_uris.is_empty() {
return Err(
ResourceCertificateProfileError::AuthorityInfoAccessMissingCaIssuers,
);
}
if !aia
.ca_issuers_uris
.iter()
.any(|u| u.starts_with("rsync://"))
{
return Err(ResourceCertificateProfileError::AuthorityInfoAccessNoRsync);
}
Some(aia.ca_issuers_uris)
}
_ => {
return Err(ResourceCertificateProfileError::DuplicateExtension(
"authorityInfoAccess",
));
}
};
let subject_info_access = match subject_info_access.len() {
0 => None,
1 => {
let (sia, critical) = subject_info_access.into_iter().next().expect("len==1");
if critical {
return Err(ResourceCertificateProfileError::SiaCriticality);
}
if sia.signed_object_access_location_not_uri {
return Err(ResourceCertificateProfileError::SignedObjectSiaNotUri);
}
if !sia.signed_object_uris.is_empty()
&& !sia
.signed_object_uris
.iter()
.any(|u| u.starts_with("rsync://"))
{
return Err(ResourceCertificateProfileError::SignedObjectSiaNoRsync);
}
if sia.signed_object_uris.is_empty() {
Some(SubjectInfoAccess::Ca(SubjectInfoAccessCa {
access_descriptions: sia.access_descriptions,
}))
} else {
Some(SubjectInfoAccess::Ee(SubjectInfoAccessEe {
signed_object_uris: sia.signed_object_uris,
access_descriptions: sia.access_descriptions,
}))
}
}
_ => {
return Err(ResourceCertificateProfileError::DuplicateExtension(
"subjectInfoAccess",
));
}
};
let certificate_policies = match certificate_policies.len() {
0 => None,
1 => {
let (policies, critical) = certificate_policies.into_iter().next().expect("len==1");
if !critical {
return Err(ResourceCertificateProfileError::CertificatePoliciesCriticality);
}
if policies.len() != 1 {
return Err(ResourceCertificateProfileError::InvalidCertificatePolicy(
"expected exactly one policy".into(),
));
}
let policy = policies.into_iter().next().expect("len==1");
if policy.policy_oid != OID_CP_IPADDR_ASNUMBER {
return Err(ResourceCertificateProfileError::InvalidCertificatePolicy(
policy.policy_oid,
));
}
Some(policy)
}
_ => {
return Err(ResourceCertificateProfileError::DuplicateExtension(
"certificatePolicies",
));
}
};
let ip_resources = match ip_resources.len() {
0 => None,
1 => {
let (ip, critical) = ip_resources.into_iter().next().expect("len==1");
if !critical {
return Err(ResourceCertificateProfileError::IpResourcesCriticality);
}
Some(ip)
}
_ => {
return Err(ResourceCertificateProfileError::DuplicateExtension(
"ipAddrBlocks",
));
}
};
let as_resources = match as_resources.len() {
0 => None,
1 => {
let (asn, critical) = as_resources.into_iter().next().expect("len==1");
if !critical {
return Err(ResourceCertificateProfileError::AsResourcesCriticality);
}
Some(asn)
}
_ => {
return Err(ResourceCertificateProfileError::DuplicateExtension(
"autonomousSysIds",
));
}
};
Ok(RcExtensions {
basic_constraints_ca,
basic_constraints,
subject_key_identifier,
authority_key_identifier,
crl_distribution_points_uris,
ca_issuers_uris,
subject_info_access,
certificate_policies_oid: certificate_policies
.as_ref()
.map(|_| OID_CP_IPADDR_ASNUMBER.to_string()),
certificate_policies,
extension_oids,
ip_resources,
as_resources,
})
}
}

584
src/model/rc/parsing.rs Normal file
View File

@ -0,0 +1,584 @@
// DER parsing helpers for certificate extensions and resources.
fn algorithm_identifier_value(
ai: &x509_parser::x509::AlgorithmIdentifier<'_>,
) -> AlgorithmIdentifierValue {
let parameters = ai.parameters.as_ref().map(|p| AlgorithmParametersValue {
class: p.class(),
tag: p.tag(),
data: p.as_bytes().to_vec(),
});
// NOTE(perf): Avoid `to_id_string()` allocations for the algorithms we expect
// in RPKI resource certificates. Fall back to `to_id_string()` for unexpected
// algorithms (mostly error paths).
let oid = if ai.algorithm.as_bytes() == OID_SHA256_WITH_RSA_ENCRYPTION_RAW {
OID_SHA256_WITH_RSA_ENCRYPTION.to_string()
} else {
ai.algorithm.to_id_string()
};
AlgorithmIdentifierValue { oid, parameters }
}
fn parse_extensions_parse(
exts: &[X509Extension<'_>],
) -> Result<RcExtensionsParsed, ResourceCertificateParseError> {
let mut basic_constraints: Vec<BasicConstraintsProfile> = Vec::new();
let mut ski: Vec<(Vec<u8>, bool)> = Vec::new();
let mut aki: Vec<(AuthorityKeyIdentifierParsed, bool)> = Vec::new();
let mut crldp: Vec<(CrlDistributionPointsParsed, bool)> = Vec::new();
let mut aia: Vec<(AuthorityInfoAccessParsed, bool)> = Vec::new();
let mut sia: Vec<(SubjectInfoAccessParsed, bool)> = Vec::new();
let mut cert_policies: Vec<(Vec<CertificatePoliciesProfile>, bool)> = Vec::new();
let mut extension_oids: Vec<String> = Vec::with_capacity(exts.len());
let mut ip_resources: Vec<(IpResourceSet, bool)> = Vec::new();
let mut as_resources: Vec<(AsResourceSet, bool)> = Vec::new();
for ext in exts {
let oid = ext.oid.as_bytes();
extension_oids.push(ext.oid.to_id_string());
if oid == OID_BASIC_CONSTRAINTS_RAW {
let ParsedExtension::BasicConstraints(bc) = ext.parsed_extension() else {
return Err(ResourceCertificateParseError::Parse(
"basicConstraints parse failed".into(),
));
};
basic_constraints.push(BasicConstraintsProfile {
ca: bc.ca,
critical: ext.critical,
path_len_constraint: bc.path_len_constraint,
});
} else if oid == OID_SUBJECT_KEY_IDENTIFIER_RAW {
let ParsedExtension::SubjectKeyIdentifier(s) = ext.parsed_extension() else {
return Err(ResourceCertificateParseError::Parse(
"subjectKeyIdentifier parse failed".into(),
));
};
ski.push((s.0.to_vec(), ext.critical));
} else if oid == OID_AUTHORITY_KEY_IDENTIFIER_RAW {
let ParsedExtension::AuthorityKeyIdentifier(a) = ext.parsed_extension() else {
return Err(ResourceCertificateParseError::Parse(
"authorityKeyIdentifier parse failed".into(),
));
};
aki.push((
AuthorityKeyIdentifierParsed {
key_identifier: a.key_identifier.as_ref().map(|k| k.0.to_vec()),
has_authority_cert_issuer: a.authority_cert_issuer.is_some(),
has_authority_cert_serial: a.authority_cert_serial.is_some(),
},
ext.critical,
));
} else if oid == OID_CRL_DISTRIBUTION_POINTS_RAW {
let ParsedExtension::CRLDistributionPoints(p) = ext.parsed_extension() else {
return Err(ResourceCertificateParseError::Parse(
"cRLDistributionPoints parse failed".into(),
));
};
crldp.push((parse_crldp_parse(p)?, ext.critical));
} else if oid == OID_AUTHORITY_INFO_ACCESS_RAW {
let ParsedExtension::AuthorityInfoAccess(p) = ext.parsed_extension() else {
return Err(ResourceCertificateParseError::Parse(
"authorityInfoAccess parse failed".into(),
));
};
aia.push((parse_aia_parse(p.accessdescs.as_slice())?, ext.critical));
} else if oid == OID_SUBJECT_INFO_ACCESS_RAW {
let ParsedExtension::SubjectInfoAccess(s) = ext.parsed_extension() else {
return Err(ResourceCertificateParseError::Parse(
"subjectInfoAccess parse failed".into(),
));
};
sia.push((parse_sia_parse(s.accessdescs.as_slice())?, ext.critical));
} else if oid == OID_CERTIFICATE_POLICIES_RAW {
let ParsedExtension::CertificatePolicies(cp) = ext.parsed_extension() else {
return Err(ResourceCertificateParseError::Parse(
"certificatePolicies parse failed".into(),
));
};
let mut policies: Vec<CertificatePoliciesProfile> = Vec::with_capacity(cp.len());
for p in cp.iter() {
let b = p.policy_id.as_bytes();
let policy_oid = if b == OID_CP_IPADDR_ASNUMBER_RAW {
OID_CP_IPADDR_ASNUMBER.to_string()
} else {
p.policy_id.to_id_string()
};
let qualifier_oids = p
.policy_qualifiers
.as_ref()
.map(|qualifiers| {
qualifiers
.iter()
.map(|qualifier| qualifier.policy_qualifier_id.to_id_string())
.collect()
})
.unwrap_or_default();
policies.push(CertificatePoliciesProfile {
policy_oid,
qualifier_oids,
});
}
cert_policies.push((policies, ext.critical));
} else if oid == OID_IP_ADDR_BLOCKS_RAW {
let parsed = IpResourceSet::decode_extn_value(ext.value)
.map_err(|_e| ResourceCertificateParseError::InvalidIpResourcesEncoding)?;
ip_resources.push((parsed, ext.critical));
} else if oid == OID_AUTONOMOUS_SYS_IDS_RAW {
let parsed = AsResourceSet::decode_extn_value(ext.value)
.map_err(|_e| ResourceCertificateParseError::InvalidAsResourcesEncoding)?;
as_resources.push((parsed, ext.critical));
}
}
Ok(RcExtensionsParsed {
basic_constraints,
subject_key_identifier: ski,
authority_key_identifier: aki,
crl_distribution_points: crldp,
authority_info_access: aia,
subject_info_access: sia,
certificate_policies: cert_policies,
extension_oids,
ip_resources,
as_resources,
})
}
fn parse_aia_parse(
access: &[x509_parser::extensions::AccessDescription<'_>],
) -> Result<AuthorityInfoAccessParsed, ResourceCertificateParseError> {
let mut ca_issuers_uris: Vec<String> = Vec::new();
let mut ca_issuers_access_location_not_uri = false;
for ad in access {
if ad.access_method.as_bytes() != OID_AD_CA_ISSUERS_RAW {
continue;
}
let uri = match &ad.access_location {
x509_parser::extensions::GeneralName::URI(u) => u,
_ => {
ca_issuers_access_location_not_uri = true;
continue;
}
};
ca_issuers_uris.push(uri.to_string());
}
Ok(AuthorityInfoAccessParsed {
ca_issuers_uris,
ca_issuers_access_location_not_uri,
})
}
fn parse_crldp_parse(
crldp: &x509_parser::extensions::CRLDistributionPoints<'_>,
) -> Result<CrlDistributionPointsParsed, ResourceCertificateParseError> {
let mut out: Vec<CrlDistributionPointParsed> = Vec::new();
for p in crldp.iter() {
let mut full_name_uris: Vec<String> = Vec::new();
let mut full_name_not_uri = false;
let mut full_name_present = false;
let mut name_relative_to_crl_issuer_present = false;
let mut distribution_point_present = false;
if let Some(dp) = &p.distribution_point {
distribution_point_present = true;
match dp {
x509_parser::extensions::DistributionPointName::FullName(names) => {
full_name_present = true;
for n in names {
match n {
x509_parser::extensions::GeneralName::URI(u) => {
full_name_uris.push(u.to_string());
}
_ => {
full_name_not_uri = true;
}
}
}
}
x509_parser::extensions::DistributionPointName::NameRelativeToCRLIssuer(_) => {
name_relative_to_crl_issuer_present = true;
}
}
}
out.push(CrlDistributionPointParsed {
distribution_point_present,
reasons_present: p.reasons.is_some(),
crl_issuer_present: p.crl_issuer.is_some(),
name_relative_to_crl_issuer_present,
full_name_uris,
full_name_not_uri,
full_name_present,
});
}
Ok(CrlDistributionPointsParsed {
distribution_points: out,
})
}
fn parse_sia_parse(
access: &[x509_parser::extensions::AccessDescription<'_>],
) -> Result<SubjectInfoAccessParsed, ResourceCertificateParseError> {
let mut all = Vec::with_capacity(access.len());
let mut signed_object_uris: Vec<String> = Vec::new();
let mut signed_object_access_location_not_uri = false;
for ad in access {
let access_method_oid = if ad.access_method.as_bytes() == OID_AD_CA_REPOSITORY_RAW {
OID_AD_CA_REPOSITORY.to_string()
} else if ad.access_method.as_bytes() == OID_AD_RPKI_MANIFEST_RAW {
OID_AD_RPKI_MANIFEST.to_string()
} else if ad.access_method.as_bytes() == OID_AD_RPKI_NOTIFY_RAW {
OID_AD_RPKI_NOTIFY.to_string()
} else if ad.access_method.as_bytes() == OID_AD_SIGNED_OBJECT_RAW {
OID_AD_SIGNED_OBJECT.to_string()
} else {
ad.access_method.to_id_string()
};
let is_signed_object = access_method_oid == OID_AD_SIGNED_OBJECT;
let uri = match &ad.access_location {
x509_parser::extensions::GeneralName::URI(u) => u,
_ => {
if is_signed_object {
signed_object_access_location_not_uri = true;
}
continue;
}
};
if is_signed_object {
signed_object_uris.push(uri.to_string());
}
all.push(AccessDescription {
access_method_oid,
access_location: uri.to_string(),
});
}
Ok(SubjectInfoAccessParsed {
access_descriptions: all,
signed_object_uris,
signed_object_access_location_not_uri,
})
}
fn parse_ip_addr_blocks(ext_value: &[u8]) -> Result<IpResourceSet, ()> {
let (rem, obj) = parse_der(ext_value).map_err(|_| ())?;
if !rem.is_empty() {
return Err(());
}
let seq = obj.as_sequence().map_err(|_| ())?;
let mut families = Vec::with_capacity(seq.len());
for fam in seq {
let fam_seq = fam.as_sequence().map_err(|_| ())?;
if fam_seq.len() != 2 {
return Err(());
}
let af_bytes = fam_seq[0].as_slice().map_err(|_| ())?;
if af_bytes.len() != 2 {
return Err(());
}
let afi = match af_bytes {
[0x00, 0x01] => Afi::Ipv4,
[0x00, 0x02] => Afi::Ipv6,
_ => return Err(()),
};
let choice = match &fam_seq[1].content {
BerObjectContent::Null => IpAddressChoice::Inherit,
BerObjectContent::Sequence(_) => {
let items_seq = fam_seq[1].as_sequence().map_err(|_| ())?;
let mut items = Vec::with_capacity(items_seq.len());
for item in items_seq {
items.push(parse_ip_address_or_range(afi, item)?);
}
IpAddressChoice::AddressesOrRanges(items)
}
_ => return Err(()),
};
families.push(IpAddressFamily { afi, choice });
}
Ok(IpResourceSet { families })
}
fn parse_ip_address_or_range(afi: Afi, obj: &DerObject<'_>) -> Result<IpAddressOrRange, ()> {
match &obj.content {
BerObjectContent::BitString(_, _) => {
Ok(IpAddressOrRange::Prefix(parse_ip_prefix(afi, obj)?))
}
BerObjectContent::Sequence(_) => {
let seq = obj.as_sequence().map_err(|_| ())?;
if seq.len() != 2 {
return Err(());
}
let min = parse_ip_address_bound(afi, &seq[0], false)?;
let max = parse_ip_address_bound(afi, &seq[1], true)?;
Ok(IpAddressOrRange::Range(IpAddressRange { min, max }))
}
_ => Err(()),
}
}
fn parse_ip_prefix(afi: Afi, obj: &DerObject<'_>) -> Result<IpPrefix, ()> {
let (unused_bits, bytes) = match &obj.content {
BerObjectContent::BitString(unused, bso) => (*unused, bso.data.to_vec()),
_ => return Err(()),
};
if unused_bits > 7 {
return Err(());
}
if !bytes.is_empty() && unused_bits != 0 {
let mask = (1u8 << unused_bits) - 1;
if (bytes[bytes.len() - 1] & mask) != 0 {
return Err(());
}
} else if bytes.is_empty() && unused_bits != 0 {
return Err(());
}
let prefix_len = (bytes.len() * 8)
.checked_sub(unused_bits as usize)
.ok_or(())? as u16;
if prefix_len > afi.ub() {
return Err(());
}
let addr = canonicalize_prefix_addr(afi, prefix_len, &bytes);
Ok(IpPrefix {
afi,
prefix_len,
addr,
})
}
/// Parse an RFC 3779 `IPAddress` BIT STRING into an address-like byte array.
///
/// When used as an `IPAddressRange` endpoint, RFC 3779 allows endpoints to be encoded with
/// fewer than `ub` bits. In that case, the missing bits are interpreted as 0s for the lower
/// bound and 1s for the upper bound. This is essential to correctly interpret ranges that
/// are expressed on non-octet boundaries.
fn parse_ip_address_bound(
afi: Afi,
obj: &DerObject<'_>,
fill_remaining_ones: bool,
) -> Result<Vec<u8>, ()> {
let (unused_bits, bytes) = match &obj.content {
BerObjectContent::BitString(unused, bso) => (*unused, bso.data.to_vec()),
_ => return Err(()),
};
if unused_bits > 7 {
return Err(());
}
if !bytes.is_empty() && unused_bits != 0 {
let mask = (1u8 << unused_bits) - 1;
if (bytes[bytes.len() - 1] & mask) != 0 {
return Err(());
}
} else if bytes.is_empty() && unused_bits != 0 {
return Err(());
}
let bit_len: u16 = (bytes.len() * 8)
.checked_sub(unused_bits as usize)
.ok_or(())?
.try_into()
.map_err(|_| ())?;
if bit_len > afi.ub() {
return Err(());
}
let mut out = vec![0u8; afi.octets_len()];
let copy_len = bytes.len().min(out.len());
out[..copy_len].copy_from_slice(&bytes[..copy_len]);
if fill_remaining_ones {
if bit_len == 0 {
out.fill(0xFF);
return Ok(out);
}
let last_bit = (bit_len - 1) as usize;
let last_byte = last_bit / 8;
let rem = (bit_len % 8) as u8;
if rem != 0 && last_byte < out.len() {
// Set the (8-rem) trailing bits in the last byte to 1.
let mask: u8 = (1u8 << (8 - rem)) - 1;
out[last_byte] |= mask;
}
for b in out.iter_mut().skip(last_byte + 1) {
*b = 0xFF;
}
}
Ok(out)
}
fn parse_as_identifiers(ext_value: &[u8]) -> Result<AsResourceSet, ()> {
let (rem, obj) = parse_der(ext_value).map_err(|_| ())?;
if !rem.is_empty() {
return Err(());
}
let seq = obj.as_sequence().map_err(|_| ())?;
let mut asnum: Option<AsIdentifierChoice> = None;
let mut rdi: Option<AsIdentifierChoice> = None;
for item in seq {
if item.class() != Class::ContextSpecific {
return Err(());
}
match item.tag() {
Tag(0) => {
if asnum.is_some() {
return Err(());
}
let inner = parse_explicit_inner(item)?;
asnum = Some(parse_as_identifier_choice(&inner)?);
}
Tag(1) => {
if rdi.is_some() {
return Err(());
}
let inner = parse_explicit_inner(item)?;
rdi = Some(parse_as_identifier_choice(&inner)?);
}
_ => return Err(()),
}
}
Ok(AsResourceSet { asnum, rdi })
}
fn parse_explicit_inner<'a>(obj: &'a DerObject<'a>) -> Result<DerObject<'a>, ()> {
let inner_der = obj.as_slice().map_err(|_| ())?;
let (rem, inner) = parse_der(inner_der).map_err(|_| ())?;
if !rem.is_empty() {
return Err(());
}
Ok(inner)
}
fn parse_as_identifier_choice(obj: &DerObject<'_>) -> Result<AsIdentifierChoice, ()> {
match &obj.content {
BerObjectContent::Null => Ok(AsIdentifierChoice::Inherit),
BerObjectContent::Sequence(_) => {
let seq = obj.as_sequence().map_err(|_| ())?;
let mut items = Vec::with_capacity(seq.len());
for item in seq {
items.push(parse_as_id_or_range(item)?);
}
Ok(AsIdentifierChoice::AsIdsOrRanges(items))
}
_ => Err(()),
}
}
fn parse_as_id_or_range(obj: &DerObject<'_>) -> Result<AsIdOrRange, ()> {
match &obj.content {
BerObjectContent::Integer(_) => {
let v = obj.as_u64().map_err(|_| ())?;
if v > u32::MAX as u64 {
return Err(());
}
Ok(AsIdOrRange::Id(v as u32))
}
BerObjectContent::Sequence(_) => {
let seq = obj.as_sequence().map_err(|_| ())?;
if seq.len() != 2 {
return Err(());
}
let min = seq[0].as_u64().map_err(|_| ())?;
let max = seq[1].as_u64().map_err(|_| ())?;
if min > u32::MAX as u64 || max > u32::MAX as u64 || min > max {
return Err(());
}
Ok(AsIdOrRange::Range {
min: min as u32,
max: max as u32,
})
}
_ => Err(()),
}
}
fn canonicalize_prefix_addr(afi: Afi, prefix_len: u16, bytes: &[u8]) -> Vec<u8> {
let full_len = afi.octets_len();
let mut addr = vec![0u8; full_len];
let copy_len = bytes.len().min(full_len);
addr[..copy_len].copy_from_slice(&bytes[..copy_len]);
if prefix_len == 0 {
return addr;
}
let last_prefix_bit = (prefix_len - 1) as usize;
let last_prefix_byte = last_prefix_bit / 8;
let rem = (prefix_len % 8) as u8;
if rem != 0 && last_prefix_byte < addr.len() {
let mask: u8 = 0xFF << (8 - rem);
addr[last_prefix_byte] &= mask;
}
addr
}
fn prefix_covers(resource: &IpPrefix, subject: &IpPrefix) -> bool {
if resource.afi != subject.afi {
return false;
}
if resource.prefix_len > subject.prefix_len {
return false;
}
let n = resource.prefix_len as usize;
let whole = n / 8;
let rem = (n % 8) as u8;
if resource.addr.len() != subject.addr.len() {
return false;
}
if resource.addr[..whole] != subject.addr[..whole] {
return false;
}
if rem == 0 {
return true;
}
let mask = 0xFFu8 << (8 - rem);
(resource.addr[whole] & mask) == (subject.addr[whole] & mask)
}
fn prefix_range(afi: Afi, p: &IpPrefix) -> (u128, u128) {
let mut base_bytes = [0u8; 16];
match afi {
Afi::Ipv4 => {
base_bytes[12..].copy_from_slice(&p.addr[..4]);
}
Afi::Ipv6 => {
base_bytes.copy_from_slice(&p.addr[..16]);
}
}
let base = u128::from_be_bytes(base_bytes);
let host_bits = (afi.ub() - p.prefix_len) as u32;
if host_bits == 0 {
return (base, base);
}
let mask = (1u128 << host_bits) - 1;
(base, base | mask)
}
fn range_covers_prefix(afi: Afi, r: &IpAddressRange, p: &IpPrefix) -> bool {
let (p_min, p_max) = prefix_range(afi, p);
let r_min = bytes_to_u128(afi, &r.min);
let r_max = bytes_to_u128(afi, &r.max);
r_min <= p_min && p_max <= r_max
}
fn bytes_to_u128(afi: Afi, bytes: &[u8]) -> u128 {
let mut out = [0u8; 16];
match afi {
Afi::Ipv4 => {
let copy_len = bytes.len().min(4);
out[12..12 + copy_len].copy_from_slice(&bytes[..copy_len]);
}
Afi::Ipv6 => {
let copy_len = bytes.len().min(16);
out[..copy_len].copy_from_slice(&bytes[..copy_len]);
}
}
u128::from_be_bytes(out)
}

View File

@ -0,0 +1,102 @@
// Strict RPKI name profile tests.
#[cfg(test)]
mod strict_name_tests {
use super::*;
fn name_with_attrs(attrs: &[(&[u8], u8, &[u8])]) -> X509NameDer {
let mut rdns = Vec::new();
for (oid, tag, value) in attrs {
let mut attr = Vec::new();
attr.extend(der_tlv(0x06, oid));
attr.extend(der_tlv(*tag, value));
let attr = der_tlv(0x30, &attr);
let rdn = der_tlv(0x31, &attr);
rdns.extend(rdn);
}
X509NameDer(der_tlv(0x30, &rdns))
}
fn der_tlv(tag: u8, value: &[u8]) -> Vec<u8> {
let mut out = vec![tag];
encode_len(value.len(), &mut out);
out.extend_from_slice(value);
out
}
fn encode_len(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(bytes);
}
#[test]
fn strict_name_accepts_printable_common_name_and_serial_number() {
let name = name_with_attrs(&[
(&[0x55, 0x04, 0x03], 0x13, b"CN1"),
(&[0x55, 0x04, 0x05], 0x13, b"SN1"),
]);
validate_strict_rpki_name(&name, "subject").expect("strict name");
}
#[test]
fn strict_name_rejects_utf8_common_name() {
let name = name_with_attrs(&[(&[0x55, 0x04, 0x03], 0x0C, b"CN1")]);
let err = validate_strict_rpki_name(&name, "subject").expect_err("strict name fails");
assert!(err.to_string().contains("PrintableString"), "{err}");
}
#[test]
fn strict_name_rejects_duplicate_common_name() {
let name = name_with_attrs(&[
(&[0x55, 0x04, 0x03], 0x13, b"CN1"),
(&[0x55, 0x04, 0x03], 0x13, b"CN2"),
]);
let err = validate_strict_rpki_name(&name, "subject").expect_err("strict name fails");
assert!(err.to_string().contains("exactly once"), "{err}");
}
#[test]
fn profile_rejects_rfc8360_v2_policy_oid() {
let extensions = RcExtensionsParsed {
basic_constraints: vec![BasicConstraintsProfile {
ca: true,
critical: true,
path_len_constraint: None,
}],
subject_key_identifier: Vec::new(),
authority_key_identifier: Vec::new(),
crl_distribution_points: Vec::new(),
authority_info_access: Vec::new(),
subject_info_access: Vec::new(),
certificate_policies: vec![(
vec![CertificatePoliciesProfile {
policy_oid: "1.3.6.1.5.5.7.14.3".to_string(),
qualifier_oids: Vec::new(),
}],
true,
)],
extension_oids: Vec::new(),
ip_resources: Vec::new(),
as_resources: Vec::new(),
};
let err = extensions
.validate_profile(true)
.expect_err("v2 policy OID must remain invalid");
assert!(
matches!(&err, ResourceCertificateProfileError::InvalidCertificatePolicy(oid) if oid == "1.3.6.1.5.5.7.14.3"),
"{err}"
);
}
}

564
src/model/rc/types.rs Normal file
View File

@ -0,0 +1,564 @@
// Resource certificate and resource-set model types.
/// Resource Certificate kind (semantic classification).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResourceCertKind {
Ca,
Ee,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResourceCertificateRole {
TrustAnchor,
Ca,
SignedObjectEe,
RouterEe,
}
/// A parsed RPKI Resource Certificate (RFC 6487) data model.
///
/// This module intentionally focuses on the semantics needed by Signed Object validation and
/// object-specific EE certificate checks (MFT/ROA/ASPA), as described in
/// `rpki/specs/03_resource_certificate_rc.md`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResourceCertificate {
pub raw_der: Vec<u8>,
pub tbs: RpkixTbsCertificate,
pub kind: ResourceCertKind,
}
pub type ResourceCaCertificate = ResourceCertificate;
pub type ResourceEeCertificate = ResourceCertificate;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RpkixTbsCertificate {
pub version: u32,
pub serial_number: BigUint,
pub signature_algorithm: String,
pub issuer_name: X509NameDer,
pub subject_name: X509NameDer,
pub validity_not_before: UtcTime,
pub validity_not_after: UtcTime,
/// DER encoding of SubjectPublicKeyInfo.
pub subject_public_key_info: Vec<u8>,
pub extensions: RcExtensions,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RcExtensions {
pub basic_constraints_ca: bool,
pub basic_constraints: Option<BasicConstraintsProfile>,
pub subject_key_identifier: Option<Vec<u8>>,
/// Authority Key Identifier (AKI) keyIdentifier value.
pub authority_key_identifier: Option<Vec<u8>>,
/// CRL Distribution Points URIs (fullName).
pub crl_distribution_points_uris: Option<Vec<String>>,
/// Authority Information Access (AIA) caIssuers URIs.
pub ca_issuers_uris: Option<Vec<String>>,
pub subject_info_access: Option<SubjectInfoAccess>,
pub certificate_policies_oid: Option<String>,
pub certificate_policies: Option<CertificatePoliciesProfile>,
pub extension_oids: Vec<String>,
pub ip_resources: Option<IpResourceSet>,
pub as_resources: Option<AsResourceSet>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BasicConstraintsProfile {
pub ca: bool,
pub critical: bool,
pub path_len_constraint: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CertificatePoliciesProfile {
pub policy_oid: String,
pub qualifier_oids: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResourceCertificateParsed {
pub raw_der: Vec<u8>,
pub version: X509Version,
pub serial_number: BigUint,
pub signature_algorithm: AlgorithmIdentifierValue,
pub tbs_signature_algorithm: AlgorithmIdentifierValue,
pub issuer_name: X509NameDer,
pub subject_name: X509NameDer,
pub validity_not_before: Asn1TimeUtc,
pub validity_not_after: Asn1TimeUtc,
/// DER encoding of SubjectPublicKeyInfo.
pub subject_public_key_info: Vec<u8>,
pub extensions: RcExtensionsParsed,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlgorithmIdentifierValue {
pub oid: String,
pub parameters: Option<AlgorithmParametersValue>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlgorithmParametersValue {
pub class: Asn1Class,
pub tag: Asn1Tag,
pub data: Vec<u8>,
}
impl AlgorithmIdentifierValue {
pub fn params_absent_or_null(&self) -> bool {
match &self.parameters {
None => true,
Some(p) if p.class == Asn1Class::Universal && p.tag == Asn1Tag::Null => true,
Some(_p) => false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RcExtensionsParsed {
pub basic_constraints: Vec<BasicConstraintsProfile>,
pub subject_key_identifier: Vec<(Vec<u8>, bool)>,
pub authority_key_identifier: Vec<(AuthorityKeyIdentifierParsed, bool)>,
pub crl_distribution_points: Vec<(CrlDistributionPointsParsed, bool)>,
pub authority_info_access: Vec<(AuthorityInfoAccessParsed, bool)>,
pub subject_info_access: Vec<(SubjectInfoAccessParsed, bool)>,
pub certificate_policies: Vec<(Vec<CertificatePoliciesProfile>, bool)>,
pub extension_oids: Vec<String>,
pub ip_resources: Vec<(IpResourceSet, bool)>,
pub as_resources: Vec<(AsResourceSet, bool)>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthorityKeyIdentifierParsed {
pub key_identifier: Option<Vec<u8>>,
pub has_authority_cert_issuer: bool,
pub has_authority_cert_serial: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthorityInfoAccessParsed {
pub ca_issuers_uris: Vec<String>,
pub ca_issuers_access_location_not_uri: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CrlDistributionPointsParsed {
pub distribution_points: Vec<CrlDistributionPointParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CrlDistributionPointParsed {
pub distribution_point_present: bool,
pub reasons_present: bool,
pub crl_issuer_present: bool,
pub name_relative_to_crl_issuer_present: bool,
pub full_name_uris: Vec<String>,
pub full_name_not_uri: bool,
pub full_name_present: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubjectInfoAccessParsed {
pub access_descriptions: Vec<AccessDescription>,
pub signed_object_uris: Vec<String>,
pub signed_object_access_location_not_uri: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SubjectInfoAccess {
Ca(SubjectInfoAccessCa),
Ee(SubjectInfoAccessEe),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubjectInfoAccessCa {
pub access_descriptions: Vec<AccessDescription>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubjectInfoAccessEe {
pub signed_object_uris: Vec<String>,
/// The full list of access descriptions as carried in the SIA extension.
pub access_descriptions: Vec<AccessDescription>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AccessDescription {
pub access_method_oid: String,
pub access_location: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Afi {
Ipv4,
Ipv6,
}
impl Afi {
pub fn ub(self) -> u16 {
match self {
Afi::Ipv4 => 32,
Afi::Ipv6 => 128,
}
}
pub fn octets_len(self) -> usize {
match self {
Afi::Ipv4 => 4,
Afi::Ipv6 => 16,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct IpResourceSet {
pub families: Vec<IpAddressFamily>,
}
impl IpResourceSet {
/// Decode the DER bytes carried inside the X.509 `extnValue` OCTET STRING for
/// `id-pe-ipAddrBlocks` (RFC 3779 / RFC 6487).
pub fn decode_extn_value(extn_value: &[u8]) -> Result<Self, IpResourceSetDecodeError> {
parse_ip_addr_blocks(extn_value).map_err(|_| IpResourceSetDecodeError::InvalidEncoding)
}
pub fn is_all_inherit(&self) -> bool {
self.families
.iter()
.all(|f| matches!(f.choice, IpAddressChoice::Inherit))
}
pub fn has_any_inherit(&self) -> bool {
self.families
.iter()
.any(|f| matches!(f.choice, IpAddressChoice::Inherit))
}
pub fn contains_prefix(&self, prefix: &IpPrefix) -> bool {
self.families.iter().any(|fam| fam.contains_prefix(prefix))
}
}
#[derive(Debug, thiserror::Error)]
pub enum IpResourceSetDecodeError {
#[error("invalid ipAddrBlocks encoding (RFC 3779 §2.2.3; RFC 6487 §4.8.10)")]
InvalidEncoding,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct IpAddressFamily {
pub afi: Afi,
pub choice: IpAddressChoice,
}
impl IpAddressFamily {
pub fn contains_prefix(&self, prefix: &IpPrefix) -> bool {
if self.afi != prefix.afi {
return false;
}
match &self.choice {
IpAddressChoice::Inherit => true,
IpAddressChoice::AddressesOrRanges(items) => items.iter().any(|item| match item {
IpAddressOrRange::Prefix(p) => prefix_covers(p, prefix),
IpAddressOrRange::Range(r) => range_covers_prefix(self.afi, r, prefix),
}),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum IpAddressChoice {
Inherit,
AddressesOrRanges(Vec<IpAddressOrRange>),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum IpAddressOrRange {
Prefix(IpPrefix),
Range(IpAddressRange),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct IpAddressRange {
pub min: Vec<u8>,
pub max: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct IpPrefix {
pub afi: Afi,
pub prefix_len: u16,
pub addr: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AsResourceSet {
pub asnum: Option<AsIdentifierChoice>,
pub rdi: Option<AsIdentifierChoice>,
}
impl AsResourceSet {
/// Decode the DER bytes carried inside the X.509 `extnValue` OCTET STRING for
/// `id-pe-autonomousSysIds` (RFC 3779 / RFC 6487).
pub fn decode_extn_value(extn_value: &[u8]) -> Result<Self, AsResourceSetDecodeError> {
parse_as_identifiers(extn_value).map_err(|_| AsResourceSetDecodeError::InvalidEncoding)
}
pub fn is_asnum_inherit(&self) -> bool {
matches!(self.asnum, Some(AsIdentifierChoice::Inherit))
}
pub fn has_any_range(&self) -> bool {
self.asnum.as_ref().map(|c| c.has_range()).unwrap_or(false)
|| self.rdi.as_ref().map(|c| c.has_range()).unwrap_or(false)
}
pub fn asnum_single_id(&self) -> Option<u32> {
match self.asnum.as_ref()? {
AsIdentifierChoice::Inherit => None,
AsIdentifierChoice::AsIdsOrRanges(items) => {
if items.len() != 1 {
return None;
}
match &items[0] {
AsIdOrRange::Id(v) => Some(*v),
AsIdOrRange::Range { .. } => None,
}
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum AsResourceSetDecodeError {
#[error("invalid autonomousSysIds encoding (RFC 3779 §3.2.3; RFC 6487 §4.8.11)")]
InvalidEncoding,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AsIdentifierChoice {
Inherit,
AsIdsOrRanges(Vec<AsIdOrRange>),
}
impl AsIdentifierChoice {
pub fn has_range(&self) -> bool {
match self {
AsIdentifierChoice::Inherit => false,
AsIdentifierChoice::AsIdsOrRanges(items) => {
items.iter().any(|i| matches!(i, AsIdOrRange::Range { .. }))
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AsIdOrRange {
Id(u32),
Range { min: u32, max: u32 },
}
#[derive(Debug, thiserror::Error)]
pub enum ResourceCertificateParseError {
#[error("X.509 parse error: {0} (RFC 5280 §4.1; RFC 6487 §4)")]
Parse(String),
#[error("trailing bytes after certificate DER: {0} bytes (DER; RFC 5280 §4.1)")]
TrailingBytes(usize),
#[error("invalid RFC 3779 IP resources extension encoding (RFC 6487 §4.8.10; RFC 3779 §2.2)")]
InvalidIpResourcesEncoding,
#[error("invalid RFC 3779 AS resources extension encoding (RFC 6487 §4.8.11; RFC 3779 §3.2)")]
InvalidAsResourcesEncoding,
}
#[derive(Debug, thiserror::Error)]
pub enum ResourceCertificateProfileError {
#[error("{0}")]
InvalidTimeEncoding(#[from] InvalidTimeEncodingError),
#[error("certificate version must be v3 (RFC 5280 §4.1; RFC 6487 §4)")]
InvalidVersion,
#[error("signatureAlgorithm does not match tbsCertificate.signature (RFC 5280 §4.1)")]
SignatureAlgorithmMismatch,
#[error(
"unsupported signature algorithm (expected sha256WithRSAEncryption {OID_SHA256_WITH_RSA_ENCRYPTION}) (RFC 7935 §2; RFC 6487 §4)"
)]
UnsupportedSignatureAlgorithm,
#[error("invalid signature algorithm parameters (RFC 5280 §4.1.1.2)")]
InvalidSignatureAlgorithmParameters,
#[error(
"{role} Name strict validation failed: {detail} (RFC 6487 §4.4; RFC 5280 §4.1.2.4/§4.1.2.6)"
)]
StrictName { role: &'static str, detail: String },
#[error("duplicate extension: {0} (RFC 5280 §4.2; RFC 6487 §4.8)")]
DuplicateExtension(&'static str),
#[error("SubjectKeyIdentifier criticality must be non-critical (RFC 6487 §4.8.2)")]
SkiCriticality,
#[error("SubjectInfoAccess criticality must be non-critical (RFC 6487 §4.8.8)")]
SiaCriticality,
#[error("certificatePolicies criticality must be critical (RFC 6487 §4.8.9)")]
CertificatePoliciesCriticality,
#[error("certificatePolicies must be present (RFC 6487 §4.8.9)")]
CertificatePoliciesMissing,
#[error(
"certificatePolicies must contain RPKI policy OID {OID_CP_IPADDR_ASNUMBER}, got {0} (RFC 6487 §4.8.9)"
)]
InvalidCertificatePolicy(String),
#[error("certificatePolicies may contain at most one CPS qualifier (RFC 6487 §4.8.9)")]
CertificatePoliciesTooManyQualifiers,
#[error(
"certificatePolicies qualifier must be id-qt-cps ({OID_QT_CPS}), got {0} (RFC 6487 §4.8.9)"
)]
CertificatePoliciesInvalidQualifier(String),
#[error("basicConstraints must be present in CA certificates (RFC 6487 §4.8.1)")]
BasicConstraintsMissing,
#[error("basicConstraints criticality must be critical in CA certificates (RFC 6487 §4.8.1)")]
BasicConstraintsCriticality,
#[error("basicConstraints cA must be TRUE in CA certificates (RFC 6487 §4.8.1)")]
BasicConstraintsCaFalse,
#[error(
"basicConstraints pathLenConstraint must be absent in CA certificates (RFC 6487 §4.8.1)"
)]
BasicConstraintsPathLenPresent,
#[error("basicConstraints must be absent in EE certificates (RFC 6487 §4.8.1)")]
BasicConstraintsEeMustOmit,
#[error("extension {oid} is not permitted for {role} resource certificates (RFC 6487 §4.8)")]
DisallowedExtension { role: &'static str, oid: String },
#[error("autonomousSysIds RDI field must be absent (RFC 6487 §4.8.11; RFC 3779 §3.2.3)")]
AsResourcesRdiPresent,
#[error(
"SIA id-ad-signedObject accessLocation must be URI (RFC 6487 §4.8.8.2; RFC 5280 §4.2.2.2)"
)]
SignedObjectSiaNotUri,
#[error("SIA id-ad-signedObject must include at least one rsync:// URI (RFC 6487 §4.8.8.2)")]
SignedObjectSiaNoRsync,
#[error("ipAddrBlocks criticality must be critical when present (RFC 6487 §4.8.10)")]
IpResourcesCriticality,
#[error("autonomousSysIds criticality must be critical when present (RFC 6487 §4.8.11)")]
AsResourcesCriticality,
#[error(
"authorityKeyIdentifier must be present in non-self-signed certificates (RFC 6487 §4.8.3; RFC 5280 §4.2.1.1)"
)]
AkiMissing,
#[error(
"authorityKeyIdentifier criticality must be non-critical (RFC 6487 §4.8.3; RFC 5280 §4.2.1.1)"
)]
AkiCriticality,
#[error(
"authorityKeyIdentifier authorityCertIssuer MUST NOT be present (RFC 6487 §4.8.3; RFC 5280 §4.2.1.1)"
)]
AkiAuthorityCertIssuerPresent,
#[error(
"authorityKeyIdentifier authorityCertSerialNumber MUST NOT be present (RFC 6487 §4.8.3; RFC 5280 §4.2.1.1)"
)]
AkiAuthorityCertSerialPresent,
#[error(
"self-signed certificate authorityKeyIdentifier must equal subjectKeyIdentifier when present (RFC 6487 §4.8.3)"
)]
AkiSelfSignedNotEqualSki,
#[error(
"CRLDistributionPoints must be present in non-self-signed certificates (RFC 6487 §4.8.6; RFC 5280 §4.2.1.13)"
)]
CrlDistributionPointsMissing,
#[error(
"CRLDistributionPoints criticality must be non-critical (RFC 6487 §4.8.6; RFC 5280 §4.2.1.13)"
)]
CrlDistributionPointsCriticality,
#[error("CRLDistributionPoints MUST be omitted in self-signed certificates (RFC 6487 §4.8.6)")]
CrlDistributionPointsSelfSignedMustOmit,
#[error("CRLDistributionPoints must contain exactly one DistributionPoint (RFC 6487 §4.8.6)")]
CrlDistributionPointsNotSingle,
#[error("CRLDistributionPoints distributionPoint field MUST be present (RFC 6487 §4.8.6)")]
CrlDistributionPointsNoDistributionPoint,
#[error("CRLDistributionPoints reasons field MUST be omitted (RFC 6487 §4.8.6)")]
CrlDistributionPointsHasReasons,
#[error("CRLDistributionPoints cRLIssuer field MUST be omitted (RFC 6487 §4.8.6)")]
CrlDistributionPointsHasCrlIssuer,
#[error(
"CRLDistributionPoints distributionPoint MUST contain fullName and MUST NOT contain nameRelativeToCRLIssuer (RFC 6487 §4.8.6)"
)]
CrlDistributionPointsInvalidName,
#[error(
"CRLDistributionPoints fullName must contain only URI GeneralNames (RFC 6487 §4.8.6; RFC 5280 §4.2.1.6)"
)]
CrlDistributionPointsFullNameNotUri,
#[error("CRLDistributionPoints must include at least one rsync:// URI (RFC 6487 §4.8.6)")]
CrlDistributionPointsNoRsync,
#[error(
"authorityInfoAccess must be present in non-self-signed certificates (RFC 6487 §4.8.7; RFC 5280 §4.2.2.1)"
)]
AuthorityInfoAccessMissing,
#[error(
"authorityInfoAccess criticality must be non-critical (RFC 6487 §4.8.7; RFC 5280 §4.2.2.1)"
)]
AuthorityInfoAccessCriticality,
#[error("authorityInfoAccess MUST be omitted in self-signed certificates (RFC 6487 §4.8.7)")]
AuthorityInfoAccessSelfSignedMustOmit,
#[error(
"authorityInfoAccess id-ad-caIssuers accessLocation must be URI (RFC 6487 §4.8.7; RFC 5280 §4.2.2.1)"
)]
AuthorityInfoAccessCaIssuersNotUri,
#[error("authorityInfoAccess must include at least one id-ad-caIssuers URI (RFC 6487 §4.8.7)")]
AuthorityInfoAccessMissingCaIssuers,
#[error("authorityInfoAccess must include at least one rsync:// URI (RFC 6487 §4.8.7)")]
AuthorityInfoAccessNoRsync,
}
#[derive(Debug, thiserror::Error)]
pub enum ResourceCertificateDecodeError {
#[error("{0}")]
Parse(#[from] ResourceCertificateParseError),
#[error("{0}")]
Validate(#[from] ResourceCertificateProfileError),
}
pub type ResourceCertificateError = ResourceCertificateDecodeError;

631
src/model/roa.rs Normal file
View File

@ -0,0 +1,631 @@
use crate::model::common::{DerReader, der_take_tlv};
use crate::model::oid::OID_CT_ROUTE_ORIGIN_AUTHZ;
use crate::model::rc::{Afi as RcAfi, IpPrefix as RcIpPrefix, ResourceCertificate};
use crate::model::signed_object::{
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectDecodeError, SignedObjectParseError,
SignedObjectValidateError,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaObject {
pub signed_object: RpkiSignedObject,
pub econtent_type: String,
pub roa: RoaEContent,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaObjectParsed {
pub signed_object: RpkiSignedObjectParsed,
pub econtent_type: String,
pub roa: Option<RoaEContentParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaEContent {
pub version: u32,
pub as_id: u32,
pub ip_addr_blocks: Vec<RoaIpAddressFamily>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaEContentParsed {
der: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum RoaParseError {
#[error("signed object parse error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectParseError),
#[error("ROA parse error: {0} (RFC 9582 §4; DER)")]
Parse(String),
#[error("ROA trailing bytes: {0} bytes (RFC 9582 §4; DER)")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum RoaProfileError {
#[error("signed object profile error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectValidateError),
#[error("ROA eContentType must be {OID_CT_ROUTE_ORIGIN_AUTHZ}, got {0} (RFC 9582 §3)")]
InvalidEContentType(String),
#[error("ROA profile decode error: {0} (RFC 9582 §4; DER)")]
ProfileDecode(String),
#[error("RouteOriginAttestation must be a SEQUENCE of 2 or 3 elements, got {0} (RFC 9582 §4)")]
InvalidAttestationSequenceLen(usize),
#[error("ROA version must be 0, got {0} (RFC 9582 §4.1)")]
InvalidVersion(u64),
#[error("ROA asID out of range (0..=4294967295), got {0} (RFC 9582 §4.2)")]
AsIdOutOfRange(u64),
#[error("ROA ipAddrBlocks must have length 1..2, got {0} (RFC 9582 §4; RFC 9582 §4.3.1)")]
InvalidIpAddrBlocksLen(usize),
#[error("ROAIPAddressFamily must be a SEQUENCE of 2 elements (RFC 9582 §4.3.1)")]
InvalidIpAddressFamily,
#[error("ROA addressFamily must be an OCTET STRING of 2 bytes (RFC 9582 §4.3.1)")]
InvalidAddressFamily,
#[error("ROA addressFamily AFI not supported: {0:02X?} (RFC 9582 §4.3.1)")]
UnsupportedAfi(Vec<u8>),
#[error("ROA contains duplicate AFI {0:?} (RFC 9582 §4.3.1)")]
DuplicateAfi(RoaAfi),
#[error("ROAAddresses must have at least one entry (RFC 9582 §4.3.2)")]
EmptyAddressList,
#[error("ROAIPAddress must be a SEQUENCE of 1..2 elements (RFC 9582 §4.3.2)")]
InvalidRoaIpAddress,
#[error("ROAIPAddress.address must be a BIT STRING (RFC 9582 §4.3.2.1; RFC 3779 §2.2.3.8)")]
InvalidPrefixBitString,
#[error(
"ROAIPAddress.address has invalid unused bits encoding (RFC 9582 §4.3.2.1; RFC 3779 §2.2.3.8)"
)]
InvalidPrefixUnusedBits,
#[error(
"ROAIPAddress.address prefix length {prefix_len} out of range for {afi:?} (RFC 9582 §4.3.2.1)"
)]
PrefixLenOutOfRange { afi: RoaAfi, prefix_len: u16 },
#[error(
"ROAIPAddress.maxLength out of range for {afi:?}: prefix_len={prefix_len}, max_len={max_len} (RFC 9582 §4.3.2.2)"
)]
InvalidMaxLength {
afi: RoaAfi,
prefix_len: u16,
max_len: u16,
},
}
impl From<SignedObjectDecodeError> for RoaProfileError {
fn from(value: SignedObjectDecodeError) -> Self {
match value {
SignedObjectDecodeError::Parse(e) => RoaProfileError::ProfileDecode(e.to_string()),
SignedObjectDecodeError::Validate(e) => RoaProfileError::SignedObject(e),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum RoaDecodeError {
#[error("{0}")]
Parse(#[from] RoaParseError),
#[error("{0}")]
Validate(#[from] RoaProfileError),
}
#[derive(Debug, thiserror::Error)]
pub enum RoaValidateError {
#[error("ROA EE certificate must not contain AS resources extension (RFC 9582 §5)")]
EeAsResourcesPresent,
#[error("ROA EE certificate must contain IP resources extension (RFC 9582 §5)")]
EeIpResourcesMissing,
#[error("ROA EE certificate IP resources must not use inherit (RFC 9582 §5)")]
EeIpResourcesInherit,
#[error(
"ROA prefix not covered by EE certificate IP resources: {afi:?} {addr:?}/{prefix_len} (RFC 9582 §5; RFC 3779 §2.3)"
)]
PrefixNotInEeResources {
afi: RoaAfi,
addr: Vec<u8>,
prefix_len: u16,
},
}
impl RoaObject {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<RoaObjectParsed, RoaParseError> {
let signed_object = RpkiSignedObject::parse_der(der)?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
let roa = signed_object
.signed_data
.encap_content_info
.econtent
.as_deref()
.map(RoaEContent::parse_der)
.transpose()?;
Ok(RoaObjectParsed {
signed_object,
econtent_type,
roa,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `RoaObject` is already profile-validated when constructed via `decode_der()` /
/// `RoaObjectParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), RoaProfileError> {
Ok(())
}
pub fn decode_der(der: &[u8]) -> Result<Self, RoaDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
pub fn decode_der_with_strict_options(
der: &[u8],
strict_cms_der: bool,
strict_name: bool,
) -> Result<Self, RoaDecodeError> {
let signed_object =
RpkiSignedObject::decode_der_with_strict_options(der, strict_cms_der, strict_name)
.map_err(RoaProfileError::from)?;
Self::from_signed_object(signed_object)
}
pub fn from_signed_object(signed_object: RpkiSignedObject) -> Result<Self, RoaDecodeError> {
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_ROUTE_ORIGIN_AUTHZ {
return Err(RoaProfileError::InvalidEContentType(econtent_type).into());
}
let roa = RoaEContent::decode_der(&signed_object.signed_data.encap_content_info.econtent)?;
Ok(Self {
roa,
signed_object,
econtent_type: OID_CT_ROUTE_ORIGIN_AUTHZ.to_string(),
})
}
/// Validate this ROA's embedded EE certificate resources.
pub fn validate_embedded_ee_cert(&self) -> Result<(), RoaValidateError> {
let ee = &self.signed_object.signed_data.certificates[0].resource_cert;
self.roa.validate_against_ee_cert(ee)
}
}
#[derive(
Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub enum RoaAfi {
Ipv4,
Ipv6,
}
impl RoaAfi {
fn ub(self) -> u16 {
match self {
RoaAfi::Ipv4 => 32,
RoaAfi::Ipv6 => 128,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaIpAddressFamily {
pub afi: RoaAfi,
pub addresses: Vec<RoaIpAddress>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct RoaIpAddress {
pub prefix: IpPrefix,
pub max_length: Option<u16>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct IpPrefix {
pub afi: RoaAfi,
/// Prefix length in bits.
pub prefix_len: u16,
/// Network order address bytes (always 16 bytes), with host bits cleared.
///
/// For IPv4 prefixes, only the first 4 bytes are used and the remaining 12 bytes are zero.
pub addr: [u8; 16],
}
impl IpPrefix {
pub fn addr_bytes(&self) -> &[u8] {
match self.afi {
RoaAfi::Ipv4 => &self.addr[..4],
RoaAfi::Ipv6 => &self.addr[..16],
}
}
}
impl RoaEContent {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<RoaEContentParsed, RoaParseError> {
let (_tag, _value, rem) = der_take_tlv(der).map_err(RoaParseError::Parse)?;
if !rem.is_empty() {
return Err(RoaParseError::TrailingBytes(rem.len()));
}
Ok(RoaEContentParsed { der: der.to_vec() })
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `RoaEContent` is already profile-validated when constructed via `decode_der()` /
/// `RoaEContentParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), RoaProfileError> {
Ok(())
}
/// Decode the DER-encoded RouteOriginAttestation defined in RFC 9582 §4 (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, RoaDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
pub fn canonicalize(&mut self) {
self.ip_addr_blocks.sort_by_key(|f| f.afi);
for fam in &mut self.ip_addr_blocks {
fam.addresses.sort();
fam.addresses.dedup();
}
}
/// Validate ROA payload against the embedded EE resource certificate (RFC 9582 §5).
///
/// This performs the EE/payload semantic checks that do not require certificate path
/// validation.
pub fn validate_against_ee_cert(
&self,
ee: &ResourceCertificate,
) -> Result<(), RoaValidateError> {
if ee.tbs.extensions.as_resources.is_some() {
return Err(RoaValidateError::EeAsResourcesPresent);
}
let ip = ee
.tbs
.extensions
.ip_resources
.as_ref()
.ok_or(RoaValidateError::EeIpResourcesMissing)?;
if ip.has_any_inherit() {
return Err(RoaValidateError::EeIpResourcesInherit);
}
for fam in &self.ip_addr_blocks {
for entry in &fam.addresses {
let rc_prefix = roa_prefix_to_rc(&entry.prefix);
if !ip.contains_prefix(&rc_prefix) {
return Err(RoaValidateError::PrefixNotInEeResources {
afi: entry.prefix.afi,
addr: entry.prefix.addr_bytes().to_vec(),
prefix_len: entry.prefix.prefix_len,
});
}
}
}
Ok(())
}
}
impl RoaObjectParsed {
pub fn validate_profile(self) -> Result<RoaObject, RoaProfileError> {
let signed_object = self.signed_object.validate_profile()?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_ROUTE_ORIGIN_AUTHZ {
return Err(RoaProfileError::InvalidEContentType(econtent_type));
}
let roa = self
.roa
.ok_or_else(|| RoaProfileError::ProfileDecode("ROA.eContent missing".into()))?
.validate_profile()?;
Ok(RoaObject {
signed_object,
econtent_type: OID_CT_ROUTE_ORIGIN_AUTHZ.to_string(),
roa,
})
}
}
impl RoaEContentParsed {
pub fn validate_profile(self) -> Result<RoaEContent, RoaProfileError> {
fn count_elements(mut r: DerReader<'_>) -> Result<usize, String> {
let mut n = 0usize;
while !r.is_empty() {
r.skip_any()?;
n += 1;
}
Ok(n)
}
let mut r = DerReader::new(&self.der);
let mut seq = r.take_sequence().map_err(RoaProfileError::ProfileDecode)?;
if !r.is_empty() {
return Err(RoaProfileError::ProfileDecode(
"trailing bytes after RouteOriginAttestation".into(),
));
}
let elem_count =
count_elements(seq).map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if elem_count != 2 && elem_count != 3 {
return Err(RoaProfileError::InvalidAttestationSequenceLen(elem_count));
}
let mut version: u32 = 0;
if elem_count == 3 {
if seq
.peek_tag()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?
!= 0xA0
{
return Err(RoaProfileError::ProfileDecode(
"RouteOriginAttestation.version must be [0] EXPLICIT INTEGER".into(),
));
}
let (inner_tag, inner_val) = seq
.take_explicit(0xA0)
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if inner_tag != 0x02 {
return Err(RoaProfileError::ProfileDecode(
"RouteOriginAttestation.version must be [0] EXPLICIT INTEGER".into(),
));
}
let v = crate::model::common::der_uint_from_bytes(inner_val)
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if v != 0 {
return Err(RoaProfileError::InvalidVersion(v));
}
version = 0;
}
let as_id_u64 = seq
.take_uint_u64()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if as_id_u64 > u32::MAX as u64 {
return Err(RoaProfileError::AsIdOutOfRange(as_id_u64));
}
let as_id = as_id_u64 as u32;
let ip_addr_blocks = parse_ip_addr_blocks_cursor(
seq.take_sequence()
.map_err(|e| RoaProfileError::ProfileDecode(format!("ipAddrBlocks: {e}")))?,
)?;
if !seq.is_empty() {
// Extra elements beyond the expected 2..3.
let extra =
count_elements(seq).map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
return Err(RoaProfileError::InvalidAttestationSequenceLen(
elem_count + extra,
));
}
let mut out = RoaEContent {
version,
as_id,
ip_addr_blocks,
};
out.canonicalize();
Ok(out)
}
}
fn roa_prefix_to_rc(p: &IpPrefix) -> RcIpPrefix {
let afi = match p.afi {
RoaAfi::Ipv4 => RcAfi::Ipv4,
RoaAfi::Ipv6 => RcAfi::Ipv6,
};
RcIpPrefix {
afi,
prefix_len: p.prefix_len,
addr: p.addr_bytes().to_vec(),
}
}
fn parse_ip_addr_blocks_cursor(
mut seq: DerReader<'_>,
) -> Result<Vec<RoaIpAddressFamily>, RoaProfileError> {
fn count_elements(mut r: DerReader<'_>) -> Result<usize, String> {
let mut n = 0usize;
while !r.is_empty() {
r.skip_any()?;
n += 1;
}
Ok(n)
}
let fam_count =
count_elements(seq).map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if fam_count == 0 || fam_count > 2 {
return Err(RoaProfileError::InvalidIpAddrBlocksLen(fam_count));
}
let mut out: Vec<RoaIpAddressFamily> = Vec::with_capacity(fam_count);
while !seq.is_empty() {
let family = parse_ip_address_family_cursor(
seq.take_sequence()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?,
)?;
if out.iter().any(|f| f.afi == family.afi) {
return Err(RoaProfileError::DuplicateAfi(family.afi));
}
out.push(family);
}
Ok(out)
}
fn parse_ip_address_family_cursor(
mut fam: DerReader<'_>,
) -> Result<RoaIpAddressFamily, RoaProfileError> {
let afi = {
let bytes = fam
.take_octet_string()
.map_err(|_e| RoaProfileError::InvalidAddressFamily)?;
if bytes.len() != 2 {
return Err(RoaProfileError::InvalidAddressFamily);
}
match bytes {
[0x00, 0x01] => RoaAfi::Ipv4,
[0x00, 0x02] => RoaAfi::Ipv6,
_ => return Err(RoaProfileError::UnsupportedAfi(bytes.to_vec())),
}
};
let mut addrs = fam
.take_sequence()
.map_err(|_e| RoaProfileError::InvalidIpAddressFamily)?;
if !fam.is_empty() {
return Err(RoaProfileError::InvalidIpAddressFamily);
}
if addrs.is_empty() {
return Err(RoaProfileError::EmptyAddressList);
}
let mut addresses: Vec<RoaIpAddress> = Vec::new();
while !addrs.is_empty() {
let entry = addrs
.take_sequence()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
addresses.push(parse_roa_ip_address_cursor(afi, entry)?);
}
Ok(RoaIpAddressFamily { afi, addresses })
}
fn parse_roa_ip_address_cursor(
afi: RoaAfi,
mut seq: DerReader<'_>,
) -> Result<RoaIpAddress, RoaProfileError> {
if seq.is_empty() {
return Err(RoaProfileError::InvalidRoaIpAddress);
}
let (unused_bits, bytes) = seq
.take_bit_string()
.map_err(|_e| RoaProfileError::InvalidPrefixBitString)?;
let prefix = parse_prefix_bits_bytes(afi, unused_bits, bytes)?;
let max_length = if !seq.is_empty() {
let v = seq
.take_uint_u64()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
let max_len: u16 = v
.try_into()
.map_err(|_e| RoaProfileError::InvalidMaxLength {
afi,
prefix_len: prefix.prefix_len,
max_len: u16::MAX,
})?;
Some(max_len)
} else {
None
};
if !seq.is_empty() {
return Err(RoaProfileError::InvalidRoaIpAddress);
}
if let Some(max_len) = max_length {
let ub = afi.ub();
if max_len > ub || max_len < prefix.prefix_len {
return Err(RoaProfileError::InvalidMaxLength {
afi,
prefix_len: prefix.prefix_len,
max_len,
});
}
}
Ok(RoaIpAddress { prefix, max_length })
}
fn parse_prefix_bits_bytes(
afi: RoaAfi,
unused_bits: u8,
bytes: &[u8],
) -> Result<IpPrefix, RoaProfileError> {
if unused_bits > 7 {
return Err(RoaProfileError::InvalidPrefixUnusedBits);
}
if bytes.is_empty() {
if unused_bits != 0 {
return Err(RoaProfileError::InvalidPrefixUnusedBits);
}
} else if unused_bits != 0 {
let mask = (1u8 << unused_bits) - 1;
if (bytes[bytes.len() - 1] & mask) != 0 {
return Err(RoaProfileError::InvalidPrefixUnusedBits);
}
}
let prefix_len = (bytes.len() * 8)
.checked_sub(unused_bits as usize)
.ok_or(RoaProfileError::InvalidPrefixUnusedBits)? as u16;
if prefix_len > afi.ub() {
return Err(RoaProfileError::PrefixLenOutOfRange { afi, prefix_len });
}
let addr = canonicalize_prefix_addr(afi, prefix_len, bytes);
Ok(IpPrefix {
afi,
prefix_len,
addr,
})
}
fn canonicalize_prefix_addr(afi: RoaAfi, prefix_len: u16, bytes: &[u8]) -> [u8; 16] {
let full_len = match afi {
RoaAfi::Ipv4 => 4,
RoaAfi::Ipv6 => 16,
};
let mut addr = [0u8; 16];
let copy_len = bytes.len().min(full_len);
addr[..copy_len].copy_from_slice(&bytes[..copy_len]);
if prefix_len == 0 {
return addr;
}
let last_prefix_bit = (prefix_len - 1) as usize;
let last_prefix_byte = last_prefix_bit / 8;
let rem = (prefix_len % 8) as u8;
if rem != 0 {
let mask: u8 = 0xFF << (8 - rem);
if last_prefix_byte < full_len {
addr[last_prefix_byte] &= mask;
}
}
addr
}

365
src/model/router_cert.rs Normal file
View File

@ -0,0 +1,365 @@
#![allow(clippy::too_many_arguments)]
use crate::model::oid::{
OID_EC_PUBLIC_KEY, OID_EXTENDED_KEY_USAGE_RAW, OID_KP_BGPSEC_ROUTER, OID_SECP256R1,
};
use crate::model::rc::{
AsIdOrRange, AsIdentifierChoice, ResourceCertKind, ResourceCertificate,
ResourceCertificateParseError, ResourceCertificateParsed, ResourceCertificateProfileError,
ResourceCertificateRole,
};
use crate::validation::cert_path::{CertPathError, validate_ee_cert_path_with_predecoded_ee};
use x509_parser::extensions::ParsedExtension;
use x509_parser::prelude::{FromDer, X509Certificate};
use x509_parser::public_key::PublicKey;
use x509_parser::x509::SubjectPublicKeyInfo;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BgpsecRouterCertificateParsed {
pub rc_parsed: ResourceCertificateParsed,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BgpsecRouterCertificate {
pub raw_der: Vec<u8>,
pub resource_cert: ResourceCertificate,
pub subject_key_identifier: Vec<u8>,
pub spki_der: Vec<u8>,
pub asns: Vec<u32>,
}
#[derive(Debug, thiserror::Error)]
pub enum BgpsecRouterCertificateParseError {
#[error("resource certificate parse error: {0} (RFC 5280 §4.1; RFC 6487 §4; RFC 8209 §3.1)")]
ResourceCertificate(#[from] ResourceCertificateParseError),
#[error("X.509 parse error: {0} (RFC 5280 §4.1; RFC 8209 §3.1)")]
X509(String),
#[error("trailing bytes after router certificate DER: {0} bytes (DER; RFC 5280 §4.1)")]
TrailingBytes(usize),
#[error("router SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7; RFC 8208 §3.1)")]
SpkiParse(String),
#[error("trailing bytes after router SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 8208 §3.1)")]
SpkiTrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum BgpsecRouterCertificateProfileError {
#[error("resource certificate profile error: {0} (RFC 6487 §4; RFC 8209 §3.1)")]
ResourceCertificate(#[from] ResourceCertificateProfileError),
#[error("BGPsec router certificate must be an EE certificate (RFC 8209 §3.1)")]
NotEe,
#[error(
"BGPsec router certificate must contain SubjectKeyIdentifier (RFC 6487 §4.8.2; RFC 8209 §3.3)"
)]
MissingSki,
#[error(
"BGPsec router certificate must include ExtendedKeyUsage (RFC 8209 §3.1.3.2; RFC 8209 §3.3)"
)]
MissingExtendedKeyUsage,
#[error(
"BGPsec router certificate ExtendedKeyUsage must be non-critical (RFC 6487 §4.8.4; RFC 8209 §3.1.3.2)"
)]
ExtendedKeyUsageCriticality,
#[error(
"BGPsec router certificate ExtendedKeyUsage must contain id-kp-bgpsec-router ({OID_KP_BGPSEC_ROUTER}) (RFC 8209 §3.1.3.2; RFC 8209 §3.3)"
)]
MissingBgpsecRouterEku,
#[error(
"BGPsec router certificate MUST NOT include Subject Information Access (RFC 8209 §3.1.3.3; RFC 8209 §3.3)"
)]
SubjectInfoAccessPresent,
#[error(
"BGPsec router certificate MUST NOT include IP resources extension (RFC 8209 §3.1.3.4; RFC 8209 §3.3)"
)]
IpResourcesPresent,
#[error(
"BGPsec router certificate MUST include AS resources extension (RFC 8209 §3.1.3.5; RFC 8209 §3.3)"
)]
AsResourcesMissing,
#[error(
"BGPsec router certificate AS resources MUST include one or more ASNs (RFC 8209 §3.1.3.5)"
)]
AsResourcesAsnumMissing,
#[error("BGPsec router certificate AS resources MUST NOT use inherit (RFC 8209 §3.1.3.5)")]
AsResourcesInherit,
#[error(
"BGPsec router certificate AS resources MUST contain explicit ASNs, not ranges (RFC 8209 §3.1.3.5)"
)]
AsResourcesRangeNotAllowed,
#[error(
"BGPsec router certificate subjectPublicKeyInfo.algorithm must be id-ecPublicKey ({OID_EC_PUBLIC_KEY}) (RFC 8208 §3.1)"
)]
SpkiAlgorithmNotEcPublicKey,
#[error(
"BGPsec router certificate subjectPublicKeyInfo.parameters must be secp256r1 ({OID_SECP256R1}) (RFC 8208 §3.1)"
)]
SpkiWrongCurve,
#[error(
"BGPsec router certificate subjectPublicKeyInfo.parameters missing or invalid (RFC 8208 §3.1)"
)]
SpkiParametersMissingOrInvalid,
#[error(
"BGPsec router certificate subjectPublicKey MUST be uncompressed P-256 ECPoint (RFC 8208 §3.1)"
)]
SpkiEcPointNotUncompressedP256,
}
#[derive(Debug, thiserror::Error)]
pub enum BgpsecRouterCertificateDecodeError {
#[error("{0}")]
Parse(#[from] BgpsecRouterCertificateParseError),
#[error("{0}")]
Validate(#[from] BgpsecRouterCertificateProfileError),
}
#[derive(Debug, thiserror::Error)]
pub enum BgpsecRouterCertificatePathError {
#[error("{0}")]
Decode(#[from] BgpsecRouterCertificateDecodeError),
#[error("{0}")]
CertPath(#[from] CertPathError),
}
impl BgpsecRouterCertificate {
pub fn parse_der(
der: &[u8],
) -> Result<BgpsecRouterCertificateParsed, BgpsecRouterCertificateParseError> {
let (rem, cert) = X509Certificate::from_der(der)
.map_err(|e| BgpsecRouterCertificateParseError::X509(e.to_string()))?;
if !rem.is_empty() {
return Err(BgpsecRouterCertificateParseError::TrailingBytes(rem.len()));
}
let (spki_rem, _spki) =
SubjectPublicKeyInfo::from_der(cert.tbs_certificate.subject_pki.raw)
.map_err(|e| BgpsecRouterCertificateParseError::SpkiParse(e.to_string()))?;
if !spki_rem.is_empty() {
return Err(BgpsecRouterCertificateParseError::SpkiTrailingBytes(
spki_rem.len(),
));
}
let rc_parsed = ResourceCertificate::parse_der(der)?;
Ok(BgpsecRouterCertificateParsed { rc_parsed })
}
pub fn validate_profile(&self) -> Result<(), BgpsecRouterCertificateProfileError> {
Ok(())
}
pub fn decode_der(der: &[u8]) -> Result<Self, BgpsecRouterCertificateDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
pub fn from_der(der: &[u8]) -> Result<Self, BgpsecRouterCertificateDecodeError> {
Self::decode_der(der)
}
pub fn validate_path_with_prevalidated_issuer(
der: &[u8],
issuer_ca: &ResourceCertificate,
issuer_spki: &SubjectPublicKeyInfo<'_>,
issuer_crl: &crate::model::crl::RpkixCrl,
issuer_crl_revoked_serials: &std::collections::HashSet<Vec<u8>>,
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<Self, BgpsecRouterCertificatePathError> {
let cert = Self::decode_der(der)?;
validate_ee_cert_path_with_predecoded_ee(
&cert.resource_cert,
der,
issuer_ca,
issuer_spki,
issuer_crl,
issuer_crl_revoked_serials,
issuer_ca_rsync_uri,
issuer_crl_rsync_uri,
validation_time,
)?;
Ok(cert)
}
}
impl BgpsecRouterCertificateParsed {
pub fn validate_profile(
self,
) -> Result<BgpsecRouterCertificate, BgpsecRouterCertificateProfileError> {
let rc = self.rc_parsed.validate_profile()?;
rc.validate_rfc6487_profile(ResourceCertificateRole::RouterEe)?;
if rc.kind != ResourceCertKind::Ee {
return Err(BgpsecRouterCertificateProfileError::NotEe);
}
let ski = rc
.tbs
.extensions
.subject_key_identifier
.clone()
.ok_or(BgpsecRouterCertificateProfileError::MissingSki)?;
if rc.tbs.extensions.subject_info_access.is_some() {
return Err(BgpsecRouterCertificateProfileError::SubjectInfoAccessPresent);
}
if rc.tbs.extensions.ip_resources.is_some() {
return Err(BgpsecRouterCertificateProfileError::IpResourcesPresent);
}
let as_resources = rc
.tbs
.extensions
.as_resources
.as_ref()
.ok_or(BgpsecRouterCertificateProfileError::AsResourcesMissing)?;
let asns = extract_router_asns(as_resources)?;
let (rem, cert) = X509Certificate::from_der(&rc.raw_der).map_err(|e| {
BgpsecRouterCertificateProfileError::ResourceCertificate(
ResourceCertificateProfileError::InvalidCertificatePolicy(e.to_string()),
)
})?;
if !rem.is_empty() {
return Err(BgpsecRouterCertificateProfileError::ResourceCertificate(
ResourceCertificateProfileError::InvalidCertificatePolicy(format!(
"trailing bytes after router certificate DER: {}",
rem.len()
)),
));
}
validate_router_eku(&cert)?;
validate_router_spki(&rc.tbs.subject_public_key_info)?;
Ok(BgpsecRouterCertificate {
raw_der: rc.raw_der.clone(),
resource_cert: rc.clone(),
subject_key_identifier: ski,
spki_der: rc.tbs.subject_public_key_info.clone(),
asns,
})
}
}
fn extract_router_asns(
as_resources: &crate::model::rc::AsResourceSet,
) -> Result<Vec<u32>, BgpsecRouterCertificateProfileError> {
let asnum = as_resources
.asnum
.as_ref()
.ok_or(BgpsecRouterCertificateProfileError::AsResourcesAsnumMissing)?;
if matches!(asnum, AsIdentifierChoice::Inherit)
|| matches!(as_resources.rdi.as_ref(), Some(AsIdentifierChoice::Inherit))
{
return Err(BgpsecRouterCertificateProfileError::AsResourcesInherit);
}
let AsIdentifierChoice::AsIdsOrRanges(items) = asnum else {
return Err(BgpsecRouterCertificateProfileError::AsResourcesInherit);
};
if items.is_empty() {
return Err(BgpsecRouterCertificateProfileError::AsResourcesAsnumMissing);
}
let mut asns = Vec::with_capacity(items.len());
for item in items {
match item {
AsIdOrRange::Id(v) => asns.push(*v),
AsIdOrRange::Range { .. } => {
return Err(BgpsecRouterCertificateProfileError::AsResourcesRangeNotAllowed);
}
}
}
asns.sort_unstable();
asns.dedup();
Ok(asns)
}
fn validate_router_eku(
cert: &X509Certificate<'_>,
) -> Result<(), BgpsecRouterCertificateProfileError> {
let mut matches = cert
.tbs_certificate
.extensions()
.iter()
.filter(|ext| ext.oid.as_bytes() == OID_EXTENDED_KEY_USAGE_RAW);
let Some(ext) = matches.next() else {
return Err(BgpsecRouterCertificateProfileError::MissingExtendedKeyUsage);
};
if matches.next().is_some() {
return Err(BgpsecRouterCertificateProfileError::MissingExtendedKeyUsage);
}
if ext.critical {
return Err(BgpsecRouterCertificateProfileError::ExtendedKeyUsageCriticality);
}
let ParsedExtension::ExtendedKeyUsage(eku) = ext.parsed_extension() else {
return Err(BgpsecRouterCertificateProfileError::MissingExtendedKeyUsage);
};
let found = eku
.other
.iter()
.any(|oid| oid.to_id_string() == OID_KP_BGPSEC_ROUTER);
if !found {
return Err(BgpsecRouterCertificateProfileError::MissingBgpsecRouterEku);
}
Ok(())
}
fn validate_router_spki(spki_der: &[u8]) -> Result<(), BgpsecRouterCertificateProfileError> {
let (rem, spki) = SubjectPublicKeyInfo::from_der(spki_der)
.map_err(|_| BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid)?;
if !rem.is_empty() {
return Err(BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid);
}
if spki.algorithm.algorithm.to_id_string() != OID_EC_PUBLIC_KEY {
return Err(BgpsecRouterCertificateProfileError::SpkiAlgorithmNotEcPublicKey);
}
let Some(params) = spki.algorithm.parameters.as_ref() else {
return Err(BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid);
};
if params.header.tag().0 != 0x06 {
return Err(BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid);
}
let mut der = Vec::with_capacity(params.data.len() + 2);
der.push(0x06);
if params.data.len() >= 0x80 {
return Err(BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid);
}
der.push(params.data.len() as u8);
der.extend_from_slice(params.data);
let (prem, oid) = der_parser::der::parse_der_oid(&der)
.map_err(|_| BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid)?;
if !prem.is_empty() {
return Err(BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid);
}
let curve = oid
.as_oid_val()
.map_err(|_| BgpsecRouterCertificateProfileError::SpkiParametersMissingOrInvalid)?
.to_string();
if curve != OID_SECP256R1 {
return Err(BgpsecRouterCertificateProfileError::SpkiWrongCurve);
}
let parsed = spki
.parsed()
.map_err(|_| BgpsecRouterCertificateProfileError::SpkiEcPointNotUncompressedP256)?;
let PublicKey::EC(ec) = parsed else {
return Err(BgpsecRouterCertificateProfileError::SpkiAlgorithmNotEcPublicKey);
};
if ec.data().len() != 65 || ec.data().first() != Some(&0x04) {
return Err(BgpsecRouterCertificateProfileError::SpkiEcPointNotUncompressedP256);
}
Ok(())
}

View File

@ -0,0 +1,27 @@
use crate::model::common::{Asn1TimeEncoding, Asn1TimeUtc, DerReader, der_uint_from_bytes};
use crate::model::oid::{
OID_AD_SIGNED_OBJECT, OID_CMS_ATTR_CONTENT_TYPE, OID_CMS_ATTR_CONTENT_TYPE_RAW,
OID_CMS_ATTR_MESSAGE_DIGEST, OID_CMS_ATTR_MESSAGE_DIGEST_RAW, OID_CMS_ATTR_SIGNING_TIME,
OID_CMS_ATTR_SIGNING_TIME_RAW, OID_CT_ASPA, OID_CT_ASPA_RAW, OID_CT_ROUTE_ORIGIN_AUTHZ,
OID_CT_ROUTE_ORIGIN_AUTHZ_RAW, OID_CT_RPKI_MANIFEST, OID_CT_RPKI_MANIFEST_RAW,
OID_KEY_USAGE_RAW, OID_RSA_ENCRYPTION, OID_RSA_ENCRYPTION_RAW, OID_SHA256, OID_SHA256_RAW,
OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW, OID_SIGNED_DATA,
OID_SIGNED_DATA_RAW, OID_SUBJECT_INFO_ACCESS,
};
use crate::model::rc::{ResourceCertificate, ResourceCertificateRole, SubjectInfoAccess};
use asn1_rs::{Any, Class, FromBer, FromDer as Asn1FromDer, Header, Tag};
use ring::digest;
use x509_parser::extensions::ParsedExtension;
use x509_parser::prelude::X509Certificate;
use x509_parser::public_key::PublicKey;
use x509_parser::x509::SubjectPublicKeyInfo;
include!("signed_object/types_errors.rs");
include!("signed_object/signed_object_impl.rs");
include!("signed_object/cms_reader.rs");
include!("signed_object/parsed_profile.rs");
include!("signed_object/signed_attrs.rs");
#[cfg(test)]
#[path = "signed_object/tests.rs"]
mod tests;

View File

@ -0,0 +1,172 @@
// CMS reader and BER/DER content parsing primitives.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CmsParseMode {
BerCompatible,
DerStrict,
}
struct CmsReader<'a> {
buf: &'a [u8],
mode: CmsParseMode,
}
impl<'a> CmsReader<'a> {
fn new(buf: &'a [u8], mode: CmsParseMode) -> Self {
Self { buf, mode }
}
fn is_empty(&self) -> bool {
self.buf.is_empty()
}
fn remaining_len(&self) -> usize {
self.buf.len()
}
fn peek_tag(&self) -> Result<u8, String> {
let (_rem, any) = parse_any(self.buf, self.mode)?;
header_to_single_byte_tag(&any.header)
}
fn take_any(&mut self) -> Result<(u8, &'a [u8]), String> {
let (rem, any) = parse_any(self.buf, self.mode)?;
let tag = header_to_single_byte_tag(&any.header)?;
self.buf = rem;
Ok((tag, any.data))
}
fn take_any_full(&mut self) -> Result<(u8, &'a [u8], &'a [u8]), String> {
let (rem, any) = parse_any(self.buf, self.mode)?;
let consumed = self.buf.len() - rem.len();
let full = &self.buf[..consumed];
let tag = header_to_single_byte_tag(&any.header)?;
self.buf = rem;
Ok((tag, full, any.data))
}
fn skip_any(&mut self) -> Result<(), String> {
let _ = self.take_any()?;
Ok(())
}
fn take_tag(&mut self, expected_tag: u8) -> Result<&'a [u8], String> {
let (tag, value) = self.take_any()?;
if tag != expected_tag {
return Err(format!(
"unexpected tag: got 0x{tag:02X}, expected 0x{expected_tag:02X}"
));
}
Ok(value)
}
fn take_sequence(&mut self) -> Result<CmsReader<'a>, String> {
let value = self.take_tag(0x30)?;
Ok(CmsReader::new(value, self.mode))
}
fn take_octet_string(&mut self) -> Result<Vec<u8>, String> {
let (rem, any) = parse_any(self.buf, self.mode)?;
let tag = header_to_single_byte_tag(&any.header)?;
if self.mode == CmsParseMode::DerStrict && tag != 0x04 {
return Err(format!(
"unexpected tag in DER strict mode: got 0x{tag:02X}, expected 0x04"
));
}
if tag != 0x04 && tag != 0x24 {
return Err(format!("unexpected tag: got 0x{tag:02X}, expected 0x04"));
}
let octets = flatten_octet_string(any, self.mode)?;
self.buf = rem;
Ok(octets)
}
fn take_uint_u64(&mut self) -> Result<u64, String> {
let value = self.take_tag(0x02)?;
der_uint_from_bytes(value)
}
fn take_explicit_der(&mut self, expected_outer_tag: u8) -> Result<&'a [u8], String> {
let inner_der = self.take_tag(expected_outer_tag)?;
let (_tag, _value, rem) = cms_take_tlv(inner_der, self.mode)?;
if !rem.is_empty() {
return Err("trailing bytes inside EXPLICIT value".into());
}
Ok(inner_der)
}
}
fn parse_signed_object_content_info(
raw_der: &[u8],
parse_der: &[u8],
mode: CmsParseMode,
) -> Result<RpkiSignedObjectParsed, SignedObjectParseError> {
let mut r = CmsReader::new(parse_der, mode);
let mut content_info_seq = r
.take_sequence()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if !r.is_empty() {
return Err(SignedObjectParseError::TrailingBytes(r.remaining_len()));
}
let content_type = take_oid_string(&mut content_info_seq)?;
let signed_data = parse_signed_data_from_contentinfo_cursor(&mut content_info_seq)?;
if !content_info_seq.is_empty() {
return Err(SignedObjectParseError::Parse(
"ContentInfo must be a SEQUENCE of 2 elements".into(),
));
}
Ok(RpkiSignedObjectParsed {
raw_der: raw_der.to_vec(),
content_info_content_type: content_type,
signed_data,
})
}
fn parse_any<'a>(input: &'a [u8], mode: CmsParseMode) -> Result<(&'a [u8], Any<'a>), String> {
match mode {
CmsParseMode::BerCompatible => {
Any::from_ber(input).map_err(|e| format!("BER parse error: {e}"))
}
CmsParseMode::DerStrict => {
Any::from_der(input).map_err(|e| format!("DER parse error: {e}"))
}
}
}
fn header_to_single_byte_tag(header: &Header<'_>) -> Result<u8, String> {
let tag_no = header.tag().0;
if tag_no > 30 {
return Err(format!("high-tag-number form not supported: {tag_no}"));
}
Ok(((header.class() as u8) << 6)
| if header.constructed() { 0x20 } else { 0x00 }
| tag_no as u8)
}
fn cms_take_tlv(input: &[u8], mode: CmsParseMode) -> Result<(u8, &[u8], &[u8]), String> {
let (rem, any) = parse_any(input, mode)?;
let tag = header_to_single_byte_tag(&any.header)?;
Ok((tag, any.data, rem))
}
fn flatten_octet_string(any: Any<'_>, mode: CmsParseMode) -> Result<Vec<u8>, String> {
if any.class() != Class::Universal || any.tag() != Tag::OctetString {
return Err("expected OCTET STRING".into());
}
if !any.header.constructed() {
return Ok(any.data.to_vec());
}
if mode == CmsParseMode::DerStrict {
return Err("constructed OCTET STRING is not allowed in DER strict mode".into());
}
let mut out = Vec::new();
let mut input = any.data;
while !input.is_empty() {
let (rem, child) = Any::from_ber(input).map_err(|e| format!("BER parse error: {e}"))?;
out.extend(flatten_octet_string(child, mode)?);
input = rem;
}
Ok(out)
}

View File

@ -0,0 +1,542 @@
// SignedData profile validation and EE certificate checks.
impl RpkiSignedObjectParsed {
pub fn validate_profile(self) -> Result<RpkiSignedObject, SignedObjectValidateError> {
self.validate_profile_with_strict_name(false)
}
pub fn validate_profile_with_strict_name(
self,
strict_name: bool,
) -> Result<RpkiSignedObject, SignedObjectValidateError> {
if self.content_info_content_type != OID_SIGNED_DATA {
return Err(SignedObjectValidateError::InvalidContentInfoContentType(
self.content_info_content_type,
));
}
let signed_data = validate_signed_data_profile(self.signed_data, strict_name)?;
Ok(RpkiSignedObject {
raw_der: self.raw_der,
content_info_content_type: OID_SIGNED_DATA.to_string(),
signed_data,
})
}
}
fn parse_signed_data_from_contentinfo_cursor(
seq: &mut CmsReader<'_>,
) -> Result<SignedDataParsed, SignedObjectParseError> {
let inner_der = seq.take_explicit_der(0xA0).map_err(|_e| {
SignedObjectParseError::Parse("ContentInfo.content must be [0] EXPLICIT".into())
})?;
let mut r = CmsReader::new(inner_der, seq.mode);
let signed_data_seq = r
.take_sequence()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if !r.is_empty() {
return Err(SignedObjectParseError::Parse(
"trailing bytes inside ContentInfo.content".into(),
));
}
parse_signed_data_cursor(signed_data_seq)
}
fn parse_signed_data_cursor(
mut seq: CmsReader<'_>,
) -> Result<SignedDataParsed, SignedObjectParseError> {
let version = seq
.take_uint_u64()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let digest_set_bytes = seq
.take_tag(0x31)
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let mut digest_set = CmsReader::new(digest_set_bytes, seq.mode);
let mut digest_algorithms: Vec<AlgorithmIdentifierParsed> = Vec::new();
while !digest_set.is_empty() {
let alg = digest_set
.take_sequence()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let (oid, params_ok) = parse_algorithm_identifier_cursor(alg)?;
digest_algorithms.push(AlgorithmIdentifierParsed { oid, params_ok });
}
let encap_content_info = parse_encapsulated_content_info_cursor(
seq.take_sequence()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?,
)?;
let mut certificates: Option<Vec<Vec<u8>>> = None;
let mut crls_present = false;
let mut signer_infos: Option<Vec<SignerInfoParsed>> = None;
while !seq.is_empty() {
let tag = seq
.peek_tag()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
match tag {
0xA0 => {
if certificates.is_some() {
return Err(SignedObjectParseError::Parse(
"SignedData.certificates appears more than once".into(),
));
}
let content = seq
.take_tag(0xA0)
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
certificates = Some(split_der_objects(content, seq.mode)?);
}
0xA1 => {
crls_present = true;
seq.skip_any()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
}
0x31 => {
if signer_infos.is_some() {
return Err(SignedObjectParseError::Parse(
"SignedData.signerInfos appears more than once".into(),
));
}
let set_bytes = seq
.take_tag(0x31)
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
signer_infos = Some(parse_signer_infos_set_cursor(set_bytes, seq.mode)?);
}
_ => {
return Err(SignedObjectParseError::Parse(
"unexpected field in SignedData".into(),
));
}
}
}
let signer_infos = signer_infos
.ok_or_else(|| SignedObjectParseError::Parse("SignedData.signerInfos missing".into()))?;
Ok(SignedDataParsed {
version,
digest_algorithms,
encap_content_info,
certificates,
crls_present,
signer_infos,
})
}
fn parse_encapsulated_content_info_cursor(
mut seq: CmsReader<'_>,
) -> Result<EncapsulatedContentInfoParsed, SignedObjectParseError> {
if seq.is_empty() {
return Err(SignedObjectParseError::Parse(
"EncapsulatedContentInfo must be SEQUENCE of 1..2".into(),
));
}
let econtent_type = take_oid_string(&mut seq)?;
let econtent = if seq.is_empty() {
None
} else {
let inner_der = seq.take_explicit_der(0xA0).map_err(|_e| {
SignedObjectParseError::Parse(
"EncapsulatedContentInfo.eContent must be [0] EXPLICIT".into(),
)
})?;
let mut inner = CmsReader::new(inner_der, seq.mode);
let octets = inner
.take_octet_string()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if !inner.is_empty() {
return Err(SignedObjectParseError::Parse(
"trailing bytes inside EncapsulatedContentInfo.eContent".into(),
));
}
Some(octets)
};
if !seq.is_empty() {
return Err(SignedObjectParseError::Parse(
"EncapsulatedContentInfo must be SEQUENCE of 1..2".into(),
));
}
Ok(EncapsulatedContentInfoParsed {
econtent_type,
econtent,
})
}
fn split_der_objects(
mut input: &[u8],
mode: CmsParseMode,
) -> Result<Vec<Vec<u8>>, SignedObjectParseError> {
let mut out: Vec<Vec<u8>> = Vec::new();
while !input.is_empty() {
let (_tag, _value, rem) =
cms_take_tlv(input, mode).map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let consumed = input.len() - rem.len();
out.push(input[..consumed].to_vec());
input = rem;
}
Ok(out)
}
fn parse_signer_infos_set_cursor(
set_bytes: &[u8],
mode: CmsParseMode,
) -> Result<Vec<SignerInfoParsed>, SignedObjectParseError> {
let mut set = CmsReader::new(set_bytes, mode);
let mut out: Vec<SignerInfoParsed> = Vec::new();
while !set.is_empty() {
let si = set
.take_sequence()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
out.push(parse_signer_info_cursor(si)?);
}
Ok(out)
}
fn validate_ee_certificate(
der: &[u8],
strict_name: bool,
) -> Result<ResourceEeCertificate, SignedObjectValidateError> {
let (rem, cert) = X509Certificate::from_der(der)
.map_err(|e| SignedObjectValidateError::EeCertificateParse(e.to_string()))?;
if !rem.is_empty() {
return Err(SignedObjectValidateError::EeCertificateParse(format!(
"trailing bytes after EE certificate DER: {}",
rem.len()
)));
}
let rc = match ResourceCertificate::from_der(der) {
Ok(v) => v,
Err(e) => {
return match e {
crate::model::rc::ResourceCertificateDecodeError::Validate(
crate::model::rc::ResourceCertificateProfileError::SignedObjectSiaNotUri,
) => Err(SignedObjectValidateError::EeCertificateSignedObjectSiaNotUri),
crate::model::rc::ResourceCertificateDecodeError::Validate(
crate::model::rc::ResourceCertificateProfileError::SignedObjectSiaNoRsync,
) => Err(SignedObjectValidateError::EeCertificateSignedObjectSiaNoRsync),
_ => Err(SignedObjectValidateError::EeCertificateParse(e.to_string())),
};
}
};
rc.validate_rfc6487_profile(ResourceCertificateRole::SignedObjectEe)
.map_err(|e| SignedObjectValidateError::EeCertificateParse(e.to_string()))?;
if strict_name {
rc.validate_strict_name_profile()
.map_err(|e| SignedObjectValidateError::EeCertificateParse(e.to_string()))?;
}
let ski = rc
.tbs
.extensions
.subject_key_identifier
.clone()
.ok_or(SignedObjectValidateError::EeCertificateMissingSki)?;
let spki_der = rc.tbs.subject_public_key_info.clone();
let (rem, spki) = SubjectPublicKeyInfo::from_der(&spki_der)
.map_err(|e| SignedObjectValidateError::EeCertificateParse(e.to_string()))?;
if !rem.is_empty() {
return Err(SignedObjectValidateError::EeCertificateParse(
"trailing bytes after EE SubjectPublicKeyInfo DER".to_string(),
));
}
let parsed_pk = spki.parsed().map_err(|_e| {
SignedObjectValidateError::EeCertificateParse(
"unsupported EE public key algorithm".to_string(),
)
})?;
let (rsa_public_modulus, rsa_public_exponent) = match parsed_pk {
PublicKey::RSA(rsa) => {
let modulus = strip_leading_zeros(rsa.modulus).to_vec();
let exponent = strip_leading_zeros(rsa.exponent).to_vec();
let _ = rsa.try_exponent().map_err(|_e| {
SignedObjectValidateError::EeCertificateParse("invalid EE RSA exponent".to_string())
})?;
(modulus, exponent)
}
_ => {
return Err(SignedObjectValidateError::EeCertificateParse(
"unsupported EE public key algorithm".to_string(),
));
}
};
let sia = rc
.tbs
.extensions
.subject_info_access
.as_ref()
.ok_or(SignedObjectValidateError::EeCertificateMissingSia)?;
let signed_object_uris: Vec<String> = match sia {
SubjectInfoAccess::Ee(ee) => ee.signed_object_uris.clone(),
SubjectInfoAccess::Ca(_ca) => Vec::new(),
};
if signed_object_uris.is_empty() {
return Err(SignedObjectValidateError::EeCertificateMissingSignedObjectSia);
}
if !signed_object_uris.iter().any(|u| u.starts_with("rsync://")) {
return Err(SignedObjectValidateError::EeCertificateSignedObjectSiaNoRsync);
}
Ok(ResourceEeCertificate {
raw_der: der.to_vec(),
subject_key_identifier: ski,
spki_der,
rsa_public_modulus,
rsa_public_exponent,
tbs_certificate_der: cert.tbs_certificate.as_ref().to_vec(),
signature_bytes: cert.signature_value.data.to_vec(),
key_usage_summary: summarize_ee_key_usage(&cert),
sia_signed_object_uris: signed_object_uris,
resource_cert: rc,
})
}
fn summarize_ee_key_usage(cert: &X509Certificate<'_>) -> EeKeyUsageSummary {
for ext in cert.extensions() {
if ext.oid.as_bytes() == OID_KEY_USAGE_RAW {
match ext.parsed_extension() {
ParsedExtension::KeyUsage(ku) => {
if !ext.critical {
return EeKeyUsageSummary::NotCritical;
}
let ok = ku.digital_signature()
&& !ku.key_cert_sign()
&& !ku.crl_sign()
&& !ku.non_repudiation()
&& !ku.key_encipherment()
&& !ku.data_encipherment()
&& !ku.key_agreement()
&& !ku.encipher_only()
&& !ku.decipher_only();
return if ok {
EeKeyUsageSummary::DigitalSignatureOnly
} else {
EeKeyUsageSummary::InvalidBits
};
}
other => {
return EeKeyUsageSummary::ParseError(format!(
"unexpected parsed keyUsage extension: {other:?}"
));
}
}
}
}
EeKeyUsageSummary::Missing
}
fn parse_signer_info_cursor(
mut seq: CmsReader<'_>,
) -> Result<SignerInfoParsed, SignedObjectParseError> {
let version = seq
.take_uint_u64()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let (sid_tag, sid_bytes) = seq
.take_any()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let sid = if (sid_tag & 0xC0) == 0x80 && (sid_tag & 0x1F) == 0 {
SignerIdentifierParsed::SubjectKeyIdentifier(sid_bytes.to_vec())
} else {
SignerIdentifierParsed::Other
};
let digest_alg_seq = seq
.take_sequence()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let (digest_oid, digest_params_ok) = parse_algorithm_identifier_cursor(digest_alg_seq)?;
let digest_algorithm = AlgorithmIdentifierParsed {
oid: digest_oid,
params_ok: digest_params_ok,
};
let mut signed_attrs_content: Option<Vec<u8>> = None;
let mut signed_attrs_der_for_signature: Option<Vec<u8>> = None;
if seq
.peek_tag()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?
== 0xA0
{
let (tag, full_tlv, value) = seq
.take_any_full()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if tag != 0xA0 {
return Err(SignedObjectParseError::Parse(
"SignerInfo.signedAttrs must be [0] IMPLICIT".into(),
));
}
signed_attrs_content = Some(value.to_vec());
signed_attrs_der_for_signature = Some(make_signed_attrs_der_for_signature(full_tlv)?);
}
let sig_alg_seq = seq
.take_sequence()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let (signature_oid, signature_params_ok) = parse_algorithm_identifier_cursor(sig_alg_seq)?;
let signature_algorithm = AlgorithmIdentifierParsed {
oid: signature_oid,
params_ok: signature_params_ok,
};
let signature = seq
.take_octet_string()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?
.to_vec();
let unsigned_attrs_present = !seq.is_empty();
Ok(SignerInfoParsed {
version,
sid,
digest_algorithm,
signature_algorithm,
signed_attrs_content,
signed_attrs_der_for_signature,
unsigned_attrs_present,
signature,
})
}
fn validate_signed_data_profile(
signed_data: SignedDataParsed,
strict_name: bool,
) -> Result<SignedDataProfiled, SignedObjectValidateError> {
if signed_data.version != 3 {
return Err(SignedObjectValidateError::InvalidSignedDataVersion(
signed_data.version,
));
}
if signed_data.digest_algorithms.len() != 1 {
return Err(SignedObjectValidateError::InvalidDigestAlgorithmsCount(
signed_data.digest_algorithms.len(),
));
}
let digest_alg = &signed_data.digest_algorithms[0];
if digest_alg.oid != OID_SHA256 {
return Err(SignedObjectValidateError::InvalidDigestAlgorithm(
digest_alg.oid.clone(),
));
}
if signed_data.crls_present {
return Err(SignedObjectValidateError::CrlsPresent);
}
let econtent = signed_data
.encap_content_info
.econtent
.clone()
.ok_or(SignedObjectValidateError::EContentMissing)?;
if econtent.is_empty() {
return Err(SignedObjectValidateError::EContentMissing);
}
let encap_content_info = EncapsulatedContentInfo {
econtent_type: signed_data.encap_content_info.econtent_type.clone(),
econtent: econtent.clone(),
};
let certs = signed_data
.certificates
.as_ref()
.ok_or(SignedObjectValidateError::CertificatesMissing)?;
if certs.len() != 1 {
return Err(SignedObjectValidateError::InvalidCertificatesCount(
certs.len(),
));
}
let ee = validate_ee_certificate(&certs[0], strict_name)?;
if signed_data.signer_infos.len() != 1 {
return Err(SignedObjectValidateError::InvalidSignerInfosCount(
signed_data.signer_infos.len(),
));
}
let signer = &signed_data.signer_infos[0];
if signer.version != 3 {
return Err(SignedObjectValidateError::InvalidSignerInfoVersion(
signer.version,
));
}
let sid_ski = match &signer.sid {
SignerIdentifierParsed::SubjectKeyIdentifier(ski) => ski.clone(),
SignerIdentifierParsed::Other => {
return Err(SignedObjectValidateError::InvalidSignerIdentifier);
}
};
if signer.digest_algorithm.oid != OID_SHA256 {
return Err(SignedObjectValidateError::InvalidSignerInfoDigestAlgorithm(
signer.digest_algorithm.oid.clone(),
));
}
let signed_attrs_content = signer
.signed_attrs_content
.as_deref()
.ok_or(SignedObjectValidateError::SignedAttrsMissing)?;
let signed_attrs_der_for_signature = signer
.signed_attrs_der_for_signature
.clone()
.ok_or(SignedObjectValidateError::SignedAttrsMissing)?;
let signed_attrs = parse_signed_attrs_implicit(signed_attrs_content)?;
if signer.unsigned_attrs_present {
return Err(SignedObjectValidateError::UnsignedAttrsPresent);
}
if !signer.signature_algorithm.params_ok {
return Err(SignedObjectValidateError::InvalidSignatureAlgorithmParameters);
}
let signature_algorithm = signer.signature_algorithm.oid.clone();
if signature_algorithm != OID_RSA_ENCRYPTION
&& signature_algorithm != OID_SHA256_WITH_RSA_ENCRYPTION
{
return Err(SignedObjectValidateError::InvalidSignatureAlgorithm(
signature_algorithm,
));
}
if sid_ski != ee.subject_key_identifier {
return Err(SignedObjectValidateError::SidSkiMismatch);
}
if signed_attrs.content_type != encap_content_info.econtent_type {
return Err(SignedObjectValidateError::ContentTypeAttrMismatch {
econtent_type: encap_content_info.econtent_type.clone(),
attr_content_type: signed_attrs.content_type.clone(),
});
}
let computed = digest::digest(&digest::SHA256, &encap_content_info.econtent);
if computed.as_ref() != signed_attrs.message_digest.as_slice() {
return Err(SignedObjectValidateError::MessageDigestMismatch);
}
Ok(SignedDataProfiled {
version: 3,
digest_algorithms: vec![OID_SHA256.to_string()],
encap_content_info,
certificates: vec![ee.clone()],
crls_present: false,
signer_infos: vec![SignerInfoProfiled {
version: 3,
sid_ski,
digest_algorithm: OID_SHA256.to_string(),
signature_algorithm: signer.signature_algorithm.oid.clone(),
signed_attrs,
unsigned_attrs_present: false,
signature: signer.signature.clone(),
signed_attrs_der_for_signature,
}],
})
}

View File

@ -0,0 +1,344 @@
// Signed attributes, signing-time, and algorithm parsing helpers.
fn parse_signed_attrs_implicit(
input: &[u8],
) -> Result<SignedAttrsProfiled, SignedObjectValidateError> {
let mut content_type: Option<String> = None;
let mut message_digest: Option<Vec<u8>> = None;
let mut signing_time: Option<Asn1TimeUtc> = None;
fn count_elements(mut r: DerReader<'_>) -> Result<usize, String> {
let mut n = 0usize;
while !r.is_empty() {
r.skip_any()?;
n += 1;
}
Ok(n)
}
let mut remaining = DerReader::new(input);
while !remaining.is_empty() {
let mut attr = remaining
.take_sequence()
.map_err(|e| SignedObjectValidateError::SignedAttrsParse(e.to_string()))?;
let oid_bytes = attr
.take_tag(0x06)
.map_err(|e| SignedObjectValidateError::SignedAttrsParse(e.to_string()))?;
let oid = oid_value_bytes_to_string(oid_bytes);
let values_bytes = attr
.take_tag(0x31)
.map_err(|e| SignedObjectValidateError::SignedAttrsParse(e.to_string()))?;
if !attr.is_empty() {
return Err(SignedObjectValidateError::SignedAttrsParse(
"Attribute must be SEQUENCE of 2".into(),
));
}
let mut values = DerReader::new(values_bytes);
let count = if values.is_empty() {
0
} else {
values
.skip_any()
.map_err(|e| SignedObjectValidateError::SignedAttrsParse(e.to_string()))?;
if values.is_empty() {
1
} else {
1 + count_elements(values)
.map_err(|e| SignedObjectValidateError::SignedAttrsParse(e.to_string()))?
}
};
if count != 1 {
return Err(
SignedObjectValidateError::InvalidSignedAttributeValuesCount { oid, count },
);
}
// Re-parse the sole value.
let mut values = DerReader::new(values_bytes);
let (val_tag, val_bytes) = values
.take_any()
.map_err(|e| SignedObjectValidateError::SignedAttrsParse(e.to_string()))?;
match oid.as_str() {
OID_CMS_ATTR_CONTENT_TYPE => {
if content_type.is_some() {
return Err(SignedObjectValidateError::DuplicateSignedAttribute(oid));
}
if val_tag != 0x06 {
return Err(SignedObjectValidateError::SignedAttrsParse(
"content-type attr value must be OBJECT IDENTIFIER".into(),
));
}
content_type = Some(oid_value_bytes_to_string(val_bytes));
}
OID_CMS_ATTR_MESSAGE_DIGEST => {
if message_digest.is_some() {
return Err(SignedObjectValidateError::DuplicateSignedAttribute(oid));
}
if val_tag != 0x04 {
return Err(SignedObjectValidateError::SignedAttrsParse(
"message-digest attr value must be OCTET STRING".into(),
));
}
message_digest = Some(val_bytes.to_vec());
}
OID_CMS_ATTR_SIGNING_TIME => {
if signing_time.is_some() {
return Err(SignedObjectValidateError::DuplicateSignedAttribute(oid));
}
signing_time = Some(parse_signing_time_value_tlv(val_tag, val_bytes)?);
}
_ => {
return Err(SignedObjectValidateError::UnsupportedSignedAttribute(oid));
}
}
}
Ok(SignedAttrsProfiled {
content_type: content_type
.ok_or(SignedObjectValidateError::SignedAttrsContentTypeMissing)?,
message_digest: message_digest
.ok_or(SignedObjectValidateError::SignedAttrsMessageDigestMissing)?,
signing_time: signing_time
.ok_or(SignedObjectValidateError::SignedAttrsSigningTimeMissing)?,
other_attrs_present: false,
})
}
fn parse_signing_time_value_tlv(
tag: u8,
value: &[u8],
) -> Result<Asn1TimeUtc, SignedObjectValidateError> {
match tag {
0x17 => Ok(Asn1TimeUtc {
utc: parse_utctime(value)?,
encoding: Asn1TimeEncoding::UtcTime,
}),
0x18 => Ok(Asn1TimeUtc {
utc: parse_generalized_time(value)?,
encoding: Asn1TimeEncoding::GeneralizedTime,
}),
_ => Err(SignedObjectValidateError::InvalidSigningTimeValue),
}
}
fn make_signed_attrs_der_for_signature(full_tlv: &[u8]) -> Result<Vec<u8>, SignedObjectParseError> {
// We need the DER encoding of SignedAttributes (SET OF Attribute) as signature input.
// The SignedAttributes field in SignerInfo is `[0] IMPLICIT`, so the on-wire bytes start with
// a context-specific constructed tag (0xA0 for tag 0). For signature verification, this tag
// is replaced with the universal SET tag (0x31), leaving length+content unchanged.
//
let mut cs_der = full_tlv.to_vec();
if cs_der.is_empty() {
return Err(SignedObjectParseError::Parse(
"signedAttrs encoding is empty".into(),
));
}
// The first byte should be the context-specific tag (0xA0) for [0] constructed.
// Replace it with universal SET (0x31) for signature input.
cs_der[0] = 0x31;
Ok(cs_der)
}
fn take_oid_string(seq: &mut CmsReader<'_>) -> Result<String, SignedObjectParseError> {
let oid = seq
.take_tag(0x06)
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
Ok(oid_value_bytes_to_string(oid))
}
fn oid_value_bytes_to_string(oid_value: &[u8]) -> String {
if oid_value == OID_SHA256_RAW {
return OID_SHA256.to_string();
}
if oid_value == OID_SIGNED_DATA_RAW {
return OID_SIGNED_DATA.to_string();
}
if oid_value == OID_CMS_ATTR_CONTENT_TYPE_RAW {
return OID_CMS_ATTR_CONTENT_TYPE.to_string();
}
if oid_value == OID_CMS_ATTR_MESSAGE_DIGEST_RAW {
return OID_CMS_ATTR_MESSAGE_DIGEST.to_string();
}
if oid_value == OID_CMS_ATTR_SIGNING_TIME_RAW {
return OID_CMS_ATTR_SIGNING_TIME.to_string();
}
if oid_value == OID_RSA_ENCRYPTION_RAW {
return OID_RSA_ENCRYPTION.to_string();
}
if oid_value == OID_SHA256_WITH_RSA_ENCRYPTION_RAW {
return OID_SHA256_WITH_RSA_ENCRYPTION.to_string();
}
if oid_value == OID_CT_RPKI_MANIFEST_RAW {
return OID_CT_RPKI_MANIFEST.to_string();
}
if oid_value == OID_CT_ROUTE_ORIGIN_AUTHZ_RAW {
return OID_CT_ROUTE_ORIGIN_AUTHZ.to_string();
}
if oid_value == OID_CT_ASPA_RAW {
return OID_CT_ASPA.to_string();
}
decode_oid_to_dotted_string(oid_value)
}
fn decode_oid_to_dotted_string(value: &[u8]) -> String {
if value.is_empty() {
return "<empty-oid>".into();
}
let first = value[0];
let a = (first / 40) as u32;
let b = (first % 40) as u32;
let mut out = String::new();
out.push_str(&a.to_string());
out.push('.');
out.push_str(&b.to_string());
let mut idx = 1usize;
while idx < value.len() {
let mut v: u32 = 0;
loop {
if idx >= value.len() {
out.push_str(".<truncated>");
return out;
}
let byte = value[idx];
idx += 1;
v = (v << 7) | (byte as u32 & 0x7F);
if (byte & 0x80) == 0 {
break;
}
}
out.push('.');
out.push_str(&v.to_string());
}
out
}
fn parse_algorithm_identifier_cursor(
mut seq: CmsReader<'_>,
) -> Result<(String, bool), SignedObjectParseError> {
if seq.is_empty() {
return Err(SignedObjectParseError::Parse(
"AlgorithmIdentifier must be SEQUENCE of 1..2".into(),
));
}
let oid = take_oid_string(&mut seq)?;
let params_ok = if seq.is_empty() {
true
} else {
let (tag, value) = seq
.take_any()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
tag == 0x05 && value.is_empty()
};
if !seq.is_empty() {
return Err(SignedObjectParseError::Parse(
"AlgorithmIdentifier must be SEQUENCE of 1..2".into(),
));
}
Ok((oid, params_ok))
}
fn parse_utctime(value: &[u8]) -> Result<time::OffsetDateTime, SignedObjectValidateError> {
let s = std::str::from_utf8(value)
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
if !s.ends_with('Z') {
return Err(SignedObjectValidateError::InvalidSigningTimeValue);
}
let digits = &s[..s.len() - 1];
if digits.len() != 10 && digits.len() != 12 {
return Err(SignedObjectValidateError::InvalidSigningTimeValue);
}
if !digits.as_bytes().iter().all(|b| b.is_ascii_digit()) {
return Err(SignedObjectValidateError::InvalidSigningTimeValue);
}
let yy: i32 = digits[0..2]
.parse()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let year = if yy <= 49 { 2000 + yy } else { 1900 + yy };
let mon: u8 = digits[2..4]
.parse()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let day: u8 = digits[4..6]
.parse()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let hour: u8 = digits[6..8]
.parse()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let min: u8 = digits[8..10]
.parse()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let sec: u8 = if digits.len() == 12 {
digits[10..12]
.parse()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?
} else {
0
};
let month = time::Month::try_from(mon)
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let date = time::Date::from_calendar_date(year, month, day)
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let time = time::Time::from_hms(hour, min, sec)
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
Ok(time::OffsetDateTime::new_utc(date, time))
}
fn parse_generalized_time(value: &[u8]) -> Result<time::OffsetDateTime, SignedObjectValidateError> {
let s = std::str::from_utf8(value)
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
if !s.ends_with('Z') {
return Err(SignedObjectValidateError::InvalidSigningTimeValue);
}
let digits = &s[..s.len() - 1];
if digits.len() != 12 && digits.len() != 14 {
return Err(SignedObjectValidateError::InvalidSigningTimeValue);
}
if !digits.as_bytes().iter().all(|b| b.is_ascii_digit()) {
return Err(SignedObjectValidateError::InvalidSigningTimeValue);
}
let year: i32 = digits[0..4]
.parse()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let mon: u8 = digits[4..6]
.parse()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let day: u8 = digits[6..8]
.parse()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let hour: u8 = digits[8..10]
.parse()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let min: u8 = digits[10..12]
.parse()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let sec: u8 = if digits.len() == 14 {
digits[12..14]
.parse()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?
} else {
0
};
let month = time::Month::try_from(mon)
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let date = time::Date::from_calendar_date(year, month, day)
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
let time = time::Time::from_hms(hour, min, sec)
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?;
Ok(time::OffsetDateTime::new_utc(date, time))
}
fn strip_leading_zeros(bytes: &[u8]) -> &[u8] {
let mut idx = 0;
while idx < bytes.len() && bytes[idx] == 0 {
idx += 1;
}
if idx == bytes.len() {
&bytes[bytes.len() - 1..]
} else {
&bytes[idx..]
}
}

View File

@ -0,0 +1,118 @@
// Signed-object decoding and RSA signature verification API.
impl RpkiSignedObject {
/// Parse a DER-encoded RPKI Signed Object (CMS ContentInfo wrapping SignedData).
///
/// This performs encoding/structure parsing only. Profile constraints are enforced by
/// `RpkiSignedObjectParsed::validate_profile`.
pub fn parse_der(der: &[u8]) -> Result<RpkiSignedObjectParsed, SignedObjectParseError> {
parse_signed_object_content_info(der, der, CmsParseMode::BerCompatible)
}
pub fn parse_der_strict_cms(
der: &[u8],
) -> Result<RpkiSignedObjectParsed, SignedObjectParseError> {
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> {
let parsed = Self::parse_der(der)?;
Ok(parsed.validate_profile()?)
}
pub fn decode_der_with_strict_options(
der: &[u8],
strict_cms_der: bool,
strict_name: bool,
) -> Result<Self, SignedObjectDecodeError> {
let parsed = if strict_cms_der {
Self::parse_der_strict_cms(der)?
} else {
Self::parse_der(der)?
};
Ok(parsed.validate_profile_with_strict_name(strict_name)?)
}
/// Scheme-A naming for signature verification.
pub fn verify(&self) -> Result<(), SignedObjectVerifyError> {
self.verify_signature()
}
/// 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,
)
}
/// Verify the CMS signature using a DER-encoded SubjectPublicKeyInfo.
pub fn verify_signature_with_ee_spki_der(
&self,
ee_spki_der: &[u8],
) -> Result<(), SignedObjectVerifyError> {
let (rem, spki) = SubjectPublicKeyInfo::from_der(ee_spki_der)
.map_err(|e| SignedObjectVerifyError::EeSpkiParse(e.to_string()))?;
if !rem.is_empty() {
return Err(SignedObjectVerifyError::EeSpkiTrailingBytes(rem.len()));
}
self.verify_signature_with_ee_spki(&spki)
}
/// Verify the CMS signature using a parsed SubjectPublicKeyInfo.
pub fn verify_signature_with_ee_spki(
&self,
ee_spki: &SubjectPublicKeyInfo<'_>,
) -> Result<(), SignedObjectVerifyError> {
let pk = ee_spki
.parsed()
.map_err(|_e| SignedObjectVerifyError::UnsupportedEePublicKeyAlgorithm)?;
let (n, e) = match pk {
PublicKey::RSA(rsa) => {
let n = strip_leading_zeros(rsa.modulus).to_vec();
let e = strip_leading_zeros(rsa.exponent).to_vec();
let _exp = rsa
.try_exponent()
.map_err(|_e| SignedObjectVerifyError::InvalidEeRsaExponent)?;
(n, e)
}
_ => return Err(SignedObjectVerifyError::UnsupportedEePublicKeyAlgorithm),
};
self.verify_signature_with_rsa_components(n.as_slice(), e.as_slice())
}
fn verify_signature_with_rsa_components(
&self,
modulus: &[u8],
exponent: &[u8],
) -> Result<(), SignedObjectVerifyError> {
let signer = &self.signed_data.signer_infos[0];
let msg = &signer.signed_attrs_der_for_signature;
let pk = ring::signature::RsaPublicKeyComponents {
n: modulus,
e: exponent,
};
pk.verify(
&ring::signature::RSA_PKCS1_2048_8192_SHA256,
msg,
&signer.signature,
)
.map_err(|_e| SignedObjectVerifyError::InvalidSignature)
}
}

View File

@ -0,0 +1,117 @@
// Signed-object CMS compatibility and profile tests.
use super::*;
#[test]
fn strict_cms_der_rejects_constructed_octet_string_fixture() {
let der = std::fs::read(
crate::test_support::synthetic_repository()
.case_repository("baseline-v1")
.join("child/child.mft"),
)
.expect("read synthetic manifest");
let parsed = RpkiSignedObject::parse_der(&der).expect("parse fixture");
let econtent = parsed
.signed_data
.encap_content_info
.econtent
.clone()
.expect("fixture eContent");
assert_eq!(
parsed.signed_data.encap_content_info.econtent.as_deref(),
Some(econtent.as_slice())
);
let primitive_octets = der_tlv(0x04, &econtent);
let constructed_octets = same_size_constructed_octet_string(&primitive_octets, &econtent);
let mutated = replace_first_subslice(&der, &primitive_octets, &constructed_octets)
.expect("replace eContent OCTET STRING");
let compatible = RpkiSignedObject::parse_der(&mutated).expect("BER-compatible parse");
assert!(
econtent.starts_with(
compatible
.signed_data
.encap_content_info
.econtent
.as_deref()
.expect("compatible eContent")
)
);
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>> {
let pos = input
.windows(from.len())
.position(|candidate| candidate == from)?;
let mut out = Vec::with_capacity(input.len() - from.len() + to.len());
out.extend_from_slice(&input[..pos]);
out.extend_from_slice(to);
out.extend_from_slice(&input[pos + from.len()..]);
Some(out)
}
fn same_size_constructed_octet_string(primitive: &[u8], content: &[u8]) -> Vec<u8> {
assert_eq!(primitive[0], 0x04);
let header_len = tlv_header_len(primitive);
let outer_value_len = primitive.len() - header_len;
let child_len = (0..=outer_value_len)
.rev()
.find(|candidate| 1 + len_len(*candidate) + *candidate == outer_value_len)
.expect("find child length");
let mut out = primitive[..header_len].to_vec();
out[0] = 0x24;
out.extend(der_tlv(0x04, &content[..child_len]));
assert_eq!(out.len(), primitive.len());
out
}
fn tlv_header_len(tlv: &[u8]) -> usize {
if tlv[1] & 0x80 == 0 {
2
} else {
2 + (tlv[1] & 0x7F) as usize
}
}
fn len_len(len: usize) -> usize {
if len < 0x80 {
return 1;
}
let mut value = len;
let mut n = 0usize;
while value > 0 {
n += 1;
value >>= 8;
}
1 + n
}
fn der_tlv(tag: u8, value: &[u8]) -> Vec<u8> {
let mut out = vec![tag];
encode_len(value.len(), &mut out);
out.extend_from_slice(value);
out
}
fn encode_len(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(bytes);
}

View File

@ -0,0 +1,300 @@
// CMS signed-object model types and parse/validation errors.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EeKeyUsageSummary {
DigitalSignatureOnly,
Missing,
NotCritical,
InvalidBits,
ParseError(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResourceEeCertificate {
pub raw_der: Vec<u8>,
pub subject_key_identifier: Vec<u8>,
pub spki_der: Vec<u8>,
pub rsa_public_modulus: Vec<u8>,
pub rsa_public_exponent: Vec<u8>,
pub tbs_certificate_der: Vec<u8>,
pub signature_bytes: Vec<u8>,
pub key_usage_summary: EeKeyUsageSummary,
pub sia_signed_object_uris: Vec<String>,
pub resource_cert: ResourceCertificate,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RpkiSignedObject {
pub raw_der: Vec<u8>,
pub content_info_content_type: String,
pub signed_data: SignedDataProfiled,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignedDataProfiled {
pub version: u32,
pub digest_algorithms: Vec<String>,
pub encap_content_info: EncapsulatedContentInfo,
pub certificates: Vec<ResourceEeCertificate>,
pub crls_present: bool,
pub signer_infos: Vec<SignerInfoProfiled>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EncapsulatedContentInfo {
pub econtent_type: String,
pub econtent: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignerInfoProfiled {
pub version: u32,
pub sid_ski: Vec<u8>,
pub digest_algorithm: String,
pub signature_algorithm: String,
pub signed_attrs: SignedAttrsProfiled,
pub unsigned_attrs_present: bool,
pub signature: Vec<u8>,
pub signed_attrs_der_for_signature: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignedAttrsProfiled {
pub content_type: String,
pub message_digest: Vec<u8>,
pub signing_time: Asn1TimeUtc,
pub other_attrs_present: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RpkiSignedObjectParsed {
pub raw_der: Vec<u8>,
pub content_info_content_type: String,
pub signed_data: SignedDataParsed,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignedDataParsed {
pub version: u64,
pub digest_algorithms: Vec<AlgorithmIdentifierParsed>,
pub encap_content_info: EncapsulatedContentInfoParsed,
pub certificates: Option<Vec<Vec<u8>>>,
pub crls_present: bool,
pub signer_infos: Vec<SignerInfoParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlgorithmIdentifierParsed {
pub oid: String,
pub params_ok: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EncapsulatedContentInfoParsed {
pub econtent_type: String,
pub econtent: Option<Vec<u8>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignerInfoParsed {
pub version: u64,
pub sid: SignerIdentifierParsed,
pub digest_algorithm: AlgorithmIdentifierParsed,
pub signature_algorithm: AlgorithmIdentifierParsed,
pub signed_attrs_content: Option<Vec<u8>>,
pub signed_attrs_der_for_signature: Option<Vec<u8>>,
pub unsigned_attrs_present: bool,
pub signature: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SignerIdentifierParsed {
SubjectKeyIdentifier(Vec<u8>),
Other,
}
#[derive(Debug, thiserror::Error)]
pub enum SignedObjectParseError {
#[error("DER parse error: {0} (RFC 6488 §2; RFC 6488 §3(1l); RFC 5652 §3/§5)")]
Parse(String),
#[error("trailing bytes after DER object: {0} bytes (DER; RFC 6488 §3(1l))")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum SignedObjectValidateError {
#[error(
"ContentInfo.contentType must be SignedData ({OID_SIGNED_DATA}), got {0} (RFC 6488 §3(1a); RFC 5652 §3)"
)]
InvalidContentInfoContentType(String),
#[error(
"SignedData.version must be 3, got {0} (RFC 6488 §2.1.1; RFC 6488 §3(1b); RFC 5652 §5.1)"
)]
InvalidSignedDataVersion(u64),
#[error(
"SignedData.digestAlgorithms must contain exactly one AlgorithmIdentifier, got {0} (RFC 6488 §2.1.2; RFC 6488 §3(1b); RFC 5652 §5.1)"
)]
InvalidDigestAlgorithmsCount(usize),
#[error(
"digest algorithm must be id-sha256 ({OID_SHA256}), got {0} (RFC 6488 §2.1.2; RFC 6488 §3(1b); RFC 7935 §2)"
)]
InvalidDigestAlgorithm(String),
#[error("SignedData.certificates MUST be present (RFC 6488 §3(1c); RFC 5652 §5.1)")]
CertificatesMissing,
#[error(
"SignedData.certificates must contain exactly one EE certificate, got {0} (RFC 6488 §3(1c))"
)]
InvalidCertificatesCount(usize),
#[error("SignedData.crls MUST be omitted (RFC 6488 §3(1d))")]
CrlsPresent,
#[error(
"SignedData.signerInfos must contain exactly one SignerInfo, got {0} (RFC 6488 §2.1; RFC 6488 §3(1e); RFC 5652 §5.1)"
)]
InvalidSignerInfosCount(usize),
#[error("SignerInfo.version must be 3, got {0} (RFC 6488 §3(1e); RFC 5652 §5.3)")]
InvalidSignerInfoVersion(u64),
#[error("SignerInfo.sid must be subjectKeyIdentifier [0] (RFC 6488 §3(1c); RFC 5652 §5.3)")]
InvalidSignerIdentifier,
#[error(
"SignerInfo.digestAlgorithm must be id-sha256 ({OID_SHA256}), got {0} (RFC 6488 §3(1j); RFC 7935 §2)"
)]
InvalidSignerInfoDigestAlgorithm(String),
#[error("SignerInfo.signedAttrs MUST be present (RFC 9589 §4; RFC 6488 §3(1f))")]
SignedAttrsMissing,
#[error("SignerInfo.unsignedAttrs MUST be omitted (RFC 6488 §3(1i))")]
UnsignedAttrsPresent,
#[error(
"SignerInfo.signatureAlgorithm must be rsaEncryption ({OID_RSA_ENCRYPTION}) or \
sha256WithRSAEncryption ({OID_SHA256_WITH_RSA_ENCRYPTION}), got {0} (RFC 6488 §3(1k); RFC 7935 §2)"
)]
InvalidSignatureAlgorithm(String),
#[error(
"SignerInfo.signatureAlgorithm parameters must be absent or NULL (RFC 5280 §4.1.1.2; RFC 7935 §2)"
)]
InvalidSignatureAlgorithmParameters,
#[error("signedAttrs contains unsupported attribute OID {0} (RFC 9589 §4; RFC 6488 §2.1.6.4)")]
UnsupportedSignedAttribute(String),
#[error("signedAttrs contains duplicate attribute OID {0} (RFC 6488 §2.1.6.4; RFC 9589 §4)")]
DuplicateSignedAttribute(String),
#[error("signedAttrs parse error: {0} (RFC 5652 §5.3; RFC 6488 §3(1f); RFC 9589 §4)")]
SignedAttrsParse(String),
#[error(
"signedAttrs attribute {oid} attrValues must contain exactly one value, got {count} (RFC 6488 §2.1.6.4; RFC 5652 §5.3)"
)]
InvalidSignedAttributeValuesCount { oid: String, count: usize },
#[error(
"signedAttrs missing content-type attribute (RFC 9589 §4; RFC 5652 §11.1; RFC 6488 §2.1.6.4)"
)]
SignedAttrsContentTypeMissing,
#[error(
"signedAttrs missing message-digest attribute (RFC 9589 §4; RFC 5652 §11.2; RFC 6488 §2.1.6.4)"
)]
SignedAttrsMessageDigestMissing,
#[error(
"signedAttrs missing signing-time attribute (RFC 9589 §4; RFC 5652 §11.3; RFC 6488 §2.1.6.4)"
)]
SignedAttrsSigningTimeMissing,
#[error(
"signedAttrs.content-type attrValues must equal eContentType ({econtent_type}), got {attr_content_type} (RFC 6488 §3(1h); RFC 9589 §4)"
)]
ContentTypeAttrMismatch {
econtent_type: String,
attr_content_type: String,
},
#[error("EncapsulatedContentInfo.eContent MUST be present (RFC 6488 §2.1.3; RFC 5652 §5.2)")]
EContentMissing,
#[error(
"signedAttrs.message-digest does not match SHA-256(eContent) (RFC 6488 §3(1f); RFC 5652 §11.2)"
)]
MessageDigestMismatch,
#[error("EE certificate parse error: {0} (RFC 6488 §3(1c); RFC 6487 §4)")]
EeCertificateParse(String),
#[error(
"EE certificate missing SubjectKeyIdentifier extension (RFC 6488 §3(1c); RFC 6487 §4.8.2)"
)]
EeCertificateMissingSki,
#[error(
"EE certificate missing SubjectInfoAccess extension ({OID_SUBJECT_INFO_ACCESS}) (RFC 6487 §4.8.8.2)"
)]
EeCertificateMissingSia,
#[error(
"EE certificate SIA missing id-ad-signedObject access method ({OID_AD_SIGNED_OBJECT}) (RFC 6487 §4.8.8.2)"
)]
EeCertificateMissingSignedObjectSia,
#[error(
"EE certificate SIA id-ad-signedObject accessLocation must be a URI (RFC 6487 §4.8.8.2; RFC 5280 §4.2.2.2)"
)]
EeCertificateSignedObjectSiaNotUri,
#[error(
"EE certificate SIA id-ad-signedObject must include at least one rsync:// URI (RFC 6487 §4.8.8.2)"
)]
EeCertificateSignedObjectSiaNoRsync,
#[error(
"SignerInfo.sid SKI does not match EE certificate SKI (RFC 6488 §3(1c); RFC 5652 §5.3)"
)]
SidSkiMismatch,
#[error(
"invalid signing-time attribute value (expected UTCTime or GeneralizedTime) (RFC 5652 §11.3; RFC 9589 §4)"
)]
InvalidSigningTimeValue,
}
#[derive(Debug, thiserror::Error)]
pub enum SignedObjectDecodeError {
#[error("SignedObject parse error: {0}")]
Parse(#[from] SignedObjectParseError),
#[error("SignedObject validate error: {0}")]
Validate(#[from] SignedObjectValidateError),
}
#[derive(Debug, thiserror::Error)]
pub enum SignedObjectVerifyError {
#[error("EE SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7)")]
EeSpkiParse(String),
#[error("trailing bytes after EE SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 5280 §4.1.2.7)")]
EeSpkiTrailingBytes(usize),
#[error("unsupported EE public key algorithm (only RSA is supported) (RFC 7935 §2)")]
UnsupportedEePublicKeyAlgorithm,
#[error("EE RSA public exponent invalid (RFC 8017 §A.1.1; RFC 7935 §2)")]
InvalidEeRsaExponent,
#[error("signature verification failed (RFC 6488 §3(2)-(3); RFC 5652 §5.3; RFC 7935 §2)")]
InvalidSignature,
}

312
src/model/ta.rs Normal file
View File

@ -0,0 +1,312 @@
use url::Url;
use x509_parser::prelude::{FromDer, X509Certificate};
use crate::model::oid::OID_CP_IPADDR_ASNUMBER;
use crate::model::rc::{
AsIdentifierChoice, IpAddressChoice, ResourceCertKind, ResourceCertificate,
ResourceCertificateParseError, ResourceCertificateParsed, ResourceCertificateProfileError,
ResourceCertificateRole,
};
use crate::model::tal::Tal;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaCertificate {
pub raw_der: Vec<u8>,
pub rc_ca: ResourceCertificate,
}
#[derive(Debug, thiserror::Error)]
pub enum TaCertificateParseError {
#[error("TA certificate parse error: {0} (RFC 5280 §4.1; RFC 6487 §4; RFC 8630 §2.3)")]
ResourceCertificate(#[from] ResourceCertificateParseError),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaCertificateParsed {
pub rc_parsed: ResourceCertificateParsed,
}
#[derive(Debug, thiserror::Error)]
pub enum TaCertificateProfileError {
#[error("resource certificate profile error: {0} (RFC 5280 §4; RFC 6487 §4)")]
ResourceCertificate(#[from] ResourceCertificateProfileError),
#[error("TA certificate must be a CA certificate (RFC 8630 §2.3; RFC 6487 §4.8.1)")]
NotCa,
#[error(
"TA certificate must be self-signed (issuer DN must equal subject DN) (RFC 8630 §2.3; RFC 5280 §4.1.2.4)"
)]
NotSelfSignedIssuerSubject,
#[error(
"TA certificate must contain certificatePolicies ipAddr-asNumber ({OID_CP_IPADDR_ASNUMBER}) (RFC 6487 §4.8.9; RFC 8630 §2.3)"
)]
MissingOrInvalidCertificatePolicies,
#[error("TA certificate must contain SubjectKeyIdentifier (RFC 6487 §4.8.2; RFC 8630 §2.3)")]
MissingSubjectKeyIdentifier,
#[error(
"TA certificate must contain at least one RFC 3779 resource extension (IP or AS) (RFC 6487 §4.8.10-§4.8.11; RFC 8630 §2.3)"
)]
ResourcesMissing,
#[error("TA certificate resources must be non-empty (RFC 8630 §2.3)")]
ResourcesEmpty,
#[error(
"TA certificate MUST NOT use inherit in IP resources (RFC 8630 §2.3; RFC 3779 §2.2.3.5)"
)]
IpResourcesInherit,
#[error(
"TA certificate MUST NOT use inherit in AS resources (RFC 8630 §2.3; RFC 3779 §3.2.3.3)"
)]
AsResourcesInherit,
}
#[derive(Debug, thiserror::Error)]
pub enum TaCertificateDecodeError {
#[error("{0}")]
Parse(#[from] TaCertificateParseError),
#[error("{0}")]
Validate(#[from] TaCertificateProfileError),
}
/// Backwards-compatible name: TA certificate errors from parse+validate.
pub type TaCertificateError = TaCertificateDecodeError;
#[derive(Debug, thiserror::Error)]
pub enum TaCertificateVerifyError {
#[error("TA certificate parse error: {0} (RFC 5280 §4.1; RFC 8630 §2.3)")]
Parse(String),
#[error("trailing bytes after TA certificate DER: {0} bytes (DER; RFC 5280 §4.1)")]
TrailingBytes(usize),
#[error(
"TA certificate self-signature verification failed: {0} (RFC 8630 §2.3; RFC 5280 §6.1)"
)]
InvalidSelfSignature(String),
}
impl TaCertificate {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<TaCertificateParsed, TaCertificateParseError> {
Ok(TaCertificateParsed {
rc_parsed: ResourceCertificate::parse_der(der)?,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `TaCertificate` is already profile-validated when constructed via `decode_der()` /
/// `TaCertificateParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), TaCertificateProfileError> {
Ok(())
}
/// Decode a TA certificate (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, TaCertificateDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
pub fn decode_der_with_strict_name(der: &[u8]) -> Result<Self, TaCertificateDecodeError> {
let ta = Self::decode_der(der)?;
ta.rc_ca
.validate_strict_name_profile()
.map_err(TaCertificateProfileError::from)?;
Ok(ta)
}
/// Backwards-compatible helper (historical name).
pub fn from_der(der: &[u8]) -> Result<Self, TaCertificateError> {
Self::decode_der(der)
}
pub fn spki_der(&self) -> &[u8] {
&self.rc_ca.tbs.subject_public_key_info
}
/// Verify step of scheme A (`parse → validate → verify`).
pub fn verify_self_signature(&self) -> Result<(), TaCertificateVerifyError> {
let (rem, cert) = X509Certificate::from_der(&self.raw_der)
.map_err(|e| TaCertificateVerifyError::Parse(e.to_string()))?;
if !rem.is_empty() {
return Err(TaCertificateVerifyError::TrailingBytes(rem.len()));
}
cert.verify_signature(None)
.map_err(|e| TaCertificateVerifyError::InvalidSelfSignature(e.to_string()))?;
Ok(())
}
/// Validate TA-specific semantic constraints on a parsed Resource Certificate.
///
/// Note: this does not verify the X.509 signature; it is intended for higher-level logic and
/// for unit tests that exercise individual constraint branches.
pub fn validate_rc_constraints(
rc_ca: &ResourceCertificate,
) -> Result<(), TaCertificateProfileError> {
if rc_ca.kind != ResourceCertKind::Ca {
return Err(TaCertificateProfileError::NotCa);
}
if rc_ca.tbs.extensions.certificate_policies_oid.as_deref() != Some(OID_CP_IPADDR_ASNUMBER)
{
return Err(TaCertificateProfileError::MissingOrInvalidCertificatePolicies);
}
if rc_ca.tbs.extensions.subject_key_identifier.is_none() {
return Err(TaCertificateProfileError::MissingSubjectKeyIdentifier);
}
let ip = rc_ca.tbs.extensions.ip_resources.as_ref();
let asn = rc_ca.tbs.extensions.as_resources.as_ref();
if ip.is_none() && asn.is_none() {
return Err(TaCertificateProfileError::ResourcesMissing);
}
let mut has_any_resource = false;
if let Some(ip) = ip {
if ip.has_any_inherit() {
return Err(TaCertificateProfileError::IpResourcesInherit);
}
for fam in &ip.families {
match &fam.choice {
IpAddressChoice::Inherit => {
return Err(TaCertificateProfileError::IpResourcesInherit);
}
IpAddressChoice::AddressesOrRanges(items) => {
if !items.is_empty() {
has_any_resource = true;
}
}
}
}
}
if let Some(asn) = asn {
if matches!(asn.asnum, Some(AsIdentifierChoice::Inherit))
|| matches!(asn.rdi, Some(AsIdentifierChoice::Inherit))
{
return Err(TaCertificateProfileError::AsResourcesInherit);
}
if let Some(AsIdentifierChoice::AsIdsOrRanges(items)) = asn.asnum.as_ref()
&& !items.is_empty()
{
has_any_resource = true;
}
if let Some(AsIdentifierChoice::AsIdsOrRanges(items)) = asn.rdi.as_ref()
&& !items.is_empty()
{
has_any_resource = true;
}
}
if !has_any_resource {
return Err(TaCertificateProfileError::ResourcesEmpty);
}
Ok(())
}
}
impl TaCertificateParsed {
pub fn validate_profile(self) -> Result<TaCertificate, TaCertificateProfileError> {
let rc_ca = self.rc_parsed.validate_profile()?;
if rc_ca.kind != ResourceCertKind::Ca {
return Err(TaCertificateProfileError::NotCa);
}
rc_ca.validate_rfc6487_profile(ResourceCertificateRole::TrustAnchor)?;
if rc_ca.tbs.issuer_name != rc_ca.tbs.subject_name {
return Err(TaCertificateProfileError::NotSelfSignedIssuerSubject);
}
TaCertificate::validate_rc_constraints(&rc_ca)?;
Ok(TaCertificate {
raw_der: rc_ca.raw_der.clone(),
rc_ca,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TrustAnchor {
pub tal: Tal,
pub ta_certificate: TaCertificate,
pub resolved_ta_uri: Option<Url>,
}
#[derive(Debug, thiserror::Error)]
pub enum TrustAnchorError {
#[error("TA certificate error: {0} (RFC 8630 §2.3)")]
TaCertificate(#[from] TaCertificateDecodeError),
#[error("TA certificate self-signature error: {0} (RFC 8630 §2.3)")]
TaSelfSignature(#[from] TaCertificateVerifyError),
#[error("{0}")]
Bind(#[from] TrustAnchorBindError),
}
#[derive(Debug, thiserror::Error)]
pub enum TrustAnchorBindError {
#[error("resolved TA URI not listed in TAL: {0} (RFC 8630 §2.2-§2.3)")]
ResolvedUriNotInTal(String),
#[error(
"TAL SPKI does not match TA certificate SubjectPublicKeyInfo (RFC 8630 §2.3; RFC 5280 §4.1.2.7)"
)]
TalSpkiMismatch,
}
impl TrustAnchor {
/// Bind a TAL and a downloaded TA certificate.
///
/// This does not download anything; it only validates the binding rules from RFC 8630 §2.3.
pub fn bind_der(
tal: Tal,
ta_der: &[u8],
resolved_uri: Option<&Url>,
) -> Result<Self, TrustAnchorError> {
let ta_certificate = TaCertificate::decode_der(ta_der)?;
ta_certificate.verify_self_signature()?;
Ok(Self::bind(tal, ta_certificate, resolved_uri)?)
}
pub fn bind_der_with_strict_name(
tal: Tal,
ta_der: &[u8],
resolved_uri: Option<&Url>,
) -> Result<Self, TrustAnchorError> {
let ta_certificate = TaCertificate::decode_der_with_strict_name(ta_der)?;
ta_certificate.verify_self_signature()?;
Ok(Self::bind(tal, ta_certificate, resolved_uri)?)
}
pub fn bind(
tal: Tal,
ta_certificate: TaCertificate,
resolved_uri: Option<&Url>,
) -> Result<Self, TrustAnchorBindError> {
if let Some(u) = resolved_uri
&& !tal.ta_uris.iter().any(|x| x == u)
{
return Err(TrustAnchorBindError::ResolvedUriNotInTal(u.to_string()));
}
if tal.subject_public_key_info_der != ta_certificate.spki_der() {
return Err(TrustAnchorBindError::TalSpkiMismatch);
}
Ok(Self {
tal,
ta_certificate,
resolved_ta_uri: resolved_uri.cloned(),
})
}
}

183
src/model/tal.rs Normal file
View File

@ -0,0 +1,183 @@
use base64::Engine;
use url::Url;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TalParsed {
pub raw: Vec<u8>,
/// Lines split by '\n' and normalized by stripping a trailing '\r' per line.
pub lines: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Tal {
pub raw: Vec<u8>,
pub comments: Vec<String>,
pub ta_uris: Vec<Url>,
pub subject_public_key_info_der: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum TalParseError {
#[error("TAL must be valid UTF-8 (RFC 8630 §2.2)")]
InvalidUtf8,
}
#[derive(Debug, thiserror::Error)]
pub enum TalProfileError {
#[error("TAL comments must appear only at the beginning (RFC 8630 §2.2)")]
CommentAfterHeader,
#[error("TAL must contain at least one TA URI line (RFC 8630 §2.2)")]
MissingTaUris,
#[error(
"TAL must contain an empty line separator between URI list and SPKI base64 (RFC 8630 §2.2)"
)]
MissingSeparatorEmptyLine,
#[error("TAL TA URI invalid: {0} (RFC 8630 §2.2)")]
InvalidUri(String),
#[error("TAL TA URI scheme must be rsync or https, got {0} (RFC 8630 §2.2)")]
UnsupportedUriScheme(String),
#[error(
"TAL TA URI must reference a single object (must not end with '/'): {0} (RFC 8630 §2.3)"
)]
UriIsDirectory(String),
#[error(
"TAL must contain base64-encoded SubjectPublicKeyInfo after the separator (RFC 8630 §2.2)"
)]
MissingSpki,
#[error("TAL SPKI base64 decode failed (RFC 8630 §2.2)")]
SpkiBase64Decode,
#[error("TAL SPKI DER is empty (RFC 8630 §2.2)")]
SpkiDerEmpty,
}
#[derive(Debug, thiserror::Error)]
pub enum TalDecodeError {
#[error("{0}")]
Parse(#[from] TalParseError),
#[error("{0}")]
Validate(#[from] TalProfileError),
}
impl Tal {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_bytes(input: &[u8]) -> Result<TalParsed, TalParseError> {
let raw = input.to_vec();
let text = std::str::from_utf8(input).map_err(|_| TalParseError::InvalidUtf8)?;
let lines: Vec<String> = text
.split('\n')
.map(|l| l.strip_suffix('\r').unwrap_or(l).to_string())
.collect();
Ok(TalParsed { raw, lines })
}
/// Validate step of scheme A (`parse → validate → verify`).
///
/// `Tal` is already profile-validated when constructed via `decode_bytes()` /
/// `TalParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), TalProfileError> {
Ok(())
}
pub fn decode_bytes(input: &[u8]) -> Result<Self, TalDecodeError> {
Ok(Self::parse_bytes(input)?.validate_profile()?)
}
}
impl TalParsed {
pub fn validate_profile(self) -> Result<Tal, TalProfileError> {
let mut idx = 0usize;
// 1) Leading comments.
let mut comments: Vec<String> = Vec::new();
while idx < self.lines.len() && self.lines[idx].starts_with('#') {
comments.push(self.lines[idx][1..].to_string());
idx += 1;
}
// 2) URI list (one or more non-empty lines).
let mut ta_uris: Vec<Url> = Vec::new();
while idx < self.lines.len() {
let line = self.lines[idx].trim();
if line.is_empty() {
break;
}
if line.starts_with('#') {
return Err(TalProfileError::CommentAfterHeader);
}
let url = match Url::parse(line) {
Ok(u) => u,
Err(_) => {
if !ta_uris.is_empty() {
return Err(TalProfileError::MissingSeparatorEmptyLine);
}
return Err(TalProfileError::InvalidUri(line.to_string()));
}
};
match url.scheme() {
"rsync" | "https" => {}
s => return Err(TalProfileError::UnsupportedUriScheme(s.to_string())),
}
if url.path().ends_with('/') {
return Err(TalProfileError::UriIsDirectory(line.to_string()));
}
if url
.path_segments()
.and_then(|mut s| s.next_back())
.unwrap_or("")
.is_empty()
{
return Err(TalProfileError::UriIsDirectory(line.to_string()));
}
ta_uris.push(url);
idx += 1;
}
if ta_uris.is_empty() {
return Err(TalProfileError::MissingTaUris);
}
// 3) Empty line separator (must exist).
if idx >= self.lines.len() || !self.lines[idx].trim().is_empty() {
return Err(TalProfileError::MissingSeparatorEmptyLine);
}
idx += 1;
// 4) Base64(SPKI DER) remainder; allow line wrapping.
let mut b64 = String::new();
while idx < self.lines.len() {
let line = self.lines[idx].trim();
if !line.is_empty() {
b64.push_str(line);
}
idx += 1;
}
if b64.is_empty() {
return Err(TalProfileError::MissingSpki);
}
let spki_der = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|_| TalProfileError::SpkiBase64Decode)?;
if spki_der.is_empty() {
return Err(TalProfileError::SpkiDerEmpty);
}
Ok(Tal {
raw: self.raw,
comments,
ta_uris,
subject_public_key_info_der: spki_der,
})
}
}

View File

@ -0,0 +1 @@
pub mod timing;

View File

@ -0,0 +1,353 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct TimingHandle {
inner: Arc<Mutex<TimingCollector>>,
}
impl TimingHandle {
pub fn new(meta: TimingMeta) -> Self {
Self {
inner: Arc::new(Mutex::new(TimingCollector::new(meta))),
}
}
pub fn span_phase(&self, phase: &'static str) -> TimingSpanGuard<'_> {
TimingSpanGuard {
handle: self.clone(),
kind: TimingSpanKind::Phase(phase),
start: Instant::now(),
}
}
pub fn span_rrdp_repo<'a>(&self, repo_uri: &'a str) -> TimingSpanGuard<'a> {
TimingSpanGuard {
handle: self.clone(),
kind: TimingSpanKind::RrdpRepo(repo_uri),
start: Instant::now(),
}
}
pub fn span_rrdp_repo_step<'a>(
&self,
repo_uri: &'a str,
step: &'static str,
) -> TimingSpanGuard<'a> {
TimingSpanGuard {
handle: self.clone(),
kind: TimingSpanKind::RrdpRepoStep { repo_uri, step },
start: Instant::now(),
}
}
pub fn span_publication_point<'a>(&self, manifest_rsync_uri: &'a str) -> TimingSpanGuard<'a> {
TimingSpanGuard {
handle: self.clone(),
kind: TimingSpanKind::PublicationPoint(manifest_rsync_uri),
start: Instant::now(),
}
}
pub fn set_meta(&self, update: TimingMetaUpdate<'_>) {
let mut g = self.inner.lock().expect("timing lock");
if let Some(v) = update.tal_url {
g.meta.tal_url = Some(v.to_string());
}
if let Some(v) = update.db_path {
g.meta.db_path = Some(v.to_string());
}
}
pub fn record_count(&self, key: &'static str, inc: u64) {
let mut g = self.inner.lock().expect("timing lock");
g.counts
.entry(key)
.and_modify(|v| *v = v.saturating_add(inc))
.or_insert(inc);
}
pub fn counts_snapshot(&self) -> HashMap<String, u64> {
let g = self.inner.lock().expect("timing lock");
g.counts
.iter()
.map(|(key, value)| ((*key).to_string(), *value))
.collect()
}
pub fn report_snapshot(&self, top_n: usize) -> TimingReportV1 {
let g = self.inner.lock().expect("timing lock");
g.to_report(top_n)
}
/// Record a phase duration directly in nanoseconds.
///
/// This is useful when aggregating sub-phase timings locally (to reduce lock contention)
/// and then emitting a single record per publication point.
pub fn record_phase_nanos(&self, phase: &'static str, nanos: u64) {
let mut g = self.inner.lock().expect("timing lock");
g.phases.record(phase, nanos);
}
pub fn record_publication_point_nanos(&self, manifest_rsync_uri: &str, nanos: u64) {
let mut g = self.inner.lock().expect("timing lock");
g.publication_points.record(manifest_rsync_uri, nanos);
}
pub fn record_publication_point_step_nanos(
&self,
manifest_rsync_uri: &str,
step: &'static str,
nanos: u64,
) {
let mut g = self.inner.lock().expect("timing lock");
g.publication_point_steps
.record(&format!("{manifest_rsync_uri}::{step}"), nanos);
}
pub fn write_json(&self, path: &Path, top_n: usize) -> Result<(), String> {
let report = {
let g = self.inner.lock().expect("timing lock");
g.to_report(top_n)
};
let f = std::fs::File::create(path)
.map_err(|e| format!("create timing json failed: {}: {e}", path.display()))?;
serde_json::to_writer_pretty(f, &report)
.map_err(|e| format!("write timing json failed: {e}"))?;
Ok(())
}
fn record_duration(&self, kind: TimingSpanKind<'_>, duration: Duration) {
let nanos_u64 = duration.as_nanos().min(u128::from(u64::MAX)) as u64;
let mut g = self.inner.lock().expect("timing lock");
match kind {
TimingSpanKind::Phase(name) => g.phases.record(name, nanos_u64),
TimingSpanKind::RrdpRepo(uri) => g.rrdp_repos.record(uri, nanos_u64),
TimingSpanKind::RrdpRepoStep { repo_uri, step } => g
.rrdp_repo_steps
.record(&format!("{repo_uri}::{step}"), nanos_u64),
TimingSpanKind::PublicationPoint(uri) => g.publication_points.record(uri, nanos_u64),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimingMeta {
pub recorded_at_utc_rfc3339: String,
pub validation_time_utc_rfc3339: String,
pub tal_url: Option<String>,
pub db_path: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct TimingMetaUpdate<'a> {
pub tal_url: Option<&'a str>,
pub db_path: Option<&'a str>,
}
pub struct TimingSpanGuard<'a> {
handle: TimingHandle,
kind: TimingSpanKind<'a>,
start: Instant,
}
impl Drop for TimingSpanGuard<'_> {
fn drop(&mut self) {
self.handle
.record_duration(self.kind.clone(), self.start.elapsed());
}
}
#[derive(Clone, Debug)]
enum TimingSpanKind<'a> {
Phase(&'static str),
RrdpRepo(&'a str),
RrdpRepoStep {
repo_uri: &'a str,
step: &'static str,
},
PublicationPoint(&'a str),
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DurationStats {
pub count: u64,
pub total_nanos: u64,
}
impl DurationStats {
fn record(&mut self, nanos: u64) {
self.count = self.count.saturating_add(1);
self.total_nanos = self.total_nanos.saturating_add(nanos);
}
}
#[derive(Clone, Debug, Default)]
struct DurationStatsMap {
map: HashMap<String, DurationStats>,
}
impl DurationStatsMap {
fn record(&mut self, key: &str, nanos: u64) {
self.map.entry(key.to_string()).or_default().record(nanos);
}
fn top(&self, n: usize) -> Vec<TopDurationEntry> {
let mut v = self
.map
.iter()
.map(|(k, s)| TopDurationEntry {
key: k.clone(),
count: s.count,
total_nanos: s.total_nanos,
})
.collect::<Vec<_>>();
v.sort_by(|a, b| b.total_nanos.cmp(&a.total_nanos));
v.truncate(n);
v
}
}
struct TimingCollector {
meta: TimingMeta,
counts: HashMap<&'static str, u64>,
phases: DurationStatsMap,
rrdp_repos: DurationStatsMap,
rrdp_repo_steps: DurationStatsMap,
publication_points: DurationStatsMap,
publication_point_steps: DurationStatsMap,
}
impl TimingCollector {
fn new(meta: TimingMeta) -> Self {
Self {
meta,
counts: HashMap::new(),
phases: DurationStatsMap::default(),
rrdp_repos: DurationStatsMap::default(),
rrdp_repo_steps: DurationStatsMap::default(),
publication_points: DurationStatsMap::default(),
publication_point_steps: DurationStatsMap::default(),
}
}
fn to_report(&self, top_n: usize) -> TimingReportV1 {
TimingReportV1 {
format_version: 1,
meta: self.meta.clone(),
counts: self
.counts
.iter()
.map(|(k, v)| ((*k).to_string(), *v))
.collect(),
phases: self
.phases
.map
.iter()
.map(|(k, s)| (k.clone(), s.clone()))
.collect(),
top_rrdp_repos: self.rrdp_repos.top(top_n),
top_rrdp_repo_steps: self.rrdp_repo_steps.top(top_n),
top_publication_points: self.publication_points.top(top_n),
top_publication_point_steps: self.publication_point_steps.top(top_n),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TimingReportV1 {
pub format_version: u64,
pub meta: TimingMeta,
pub counts: HashMap<String, u64>,
pub phases: HashMap<String, DurationStats>,
pub top_rrdp_repos: Vec<TopDurationEntry>,
pub top_rrdp_repo_steps: Vec<TopDurationEntry>,
pub top_publication_points: Vec<TopDurationEntry>,
pub top_publication_point_steps: Vec<TopDurationEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TopDurationEntry {
pub key: String,
pub count: u64,
pub total_nanos: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timing_handle_writes_json_with_phases_and_tops() {
let meta = TimingMeta {
recorded_at_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
validation_time_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
tal_url: Some("https://example.test/x.tal".to_string()),
db_path: Some("db".to_string()),
};
let h = TimingHandle::new(meta);
{
let _p = h.span_phase("tal_bootstrap");
}
{
let _r = h.span_rrdp_repo("https://rrdp.example.test/notification.xml");
}
{
let _s = h.span_rrdp_repo_step(
"https://rrdp.example.test/notification.xml",
"fetch_notification",
);
}
{
let _pp = h.span_publication_point("rsync://example.test/repo/manifest.mft");
}
h.record_count("vrps", 42);
h.record_publication_point_nanos("rsync://example.test/repo/manifest.mft", 1_000_000);
h.record_publication_point_step_nanos(
"rsync://example.test/repo/manifest.mft",
"fresh_snapshot_prepare",
1_000_000,
);
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("timing.json");
h.write_json(&path, 10).expect("write_json");
let rep: TimingReportV1 =
serde_json::from_slice(&std::fs::read(&path).expect("read timing.json"))
.expect("parse timing.json");
assert_eq!(rep.format_version, 1);
assert!(rep.phases.contains_key("tal_bootstrap"));
assert_eq!(rep.counts.get("vrps").copied(), Some(42));
assert!(
rep.top_rrdp_repos
.iter()
.any(|e| e.key.contains("rrdp.example.test")),
"expected repo in top list"
);
assert!(
rep.top_rrdp_repo_steps
.iter()
.any(|e| e.key.contains("fetch_notification")),
"expected repo step in top list"
);
assert!(
rep.top_publication_points
.iter()
.any(|e| e.key.contains("manifest.mft")),
"expected PP in top list"
);
assert!(
rep.top_publication_point_steps
.iter()
.any(|e| e.key.contains("fresh_snapshot_prepare")),
"expected PP step in top list"
);
}
}

336
src/output/audit.rs Normal file
View File

@ -0,0 +1,336 @@
use serde::Serialize;
use sha2::Digest;
use crate::validation::policy::Policy;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditObjectKind {
Manifest,
Crl,
Certificate,
RouterCertificate,
Roa,
Aspa,
Other,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditObjectResult {
Ok,
Skipped,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ObjectAuditEntry {
pub rsync_uri: String,
pub sha256_hex: String,
pub kind: AuditObjectKind,
pub result: AuditObjectResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditWarning {
pub message: String,
pub category: String,
pub rfc_refs: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QueryAuditManifest {
pub schema_version: u32,
pub status: String,
pub events_path: String,
pub events_count: u64,
pub events_sha256: String,
pub writer_version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ValidationEventCounts {
#[serde(skip_serializing_if = "Option::is_none")]
pub objects: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub warnings: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vrps: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aspas: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ValidationEvent {
pub schema_version: u32,
pub seq: u64,
pub event_type: String,
pub validation_time: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pp_node_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pp_manifest_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pp_rsync_base_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_sync_phase: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_terminal_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_type: Option<AuditObjectKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<AuditObjectResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub counts: Option<ValidationEventCounts>,
}
impl From<&crate::output::report::Warning> for AuditWarning {
fn from(w: &crate::output::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(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct PublicationPointAudit {
/// Monotonic node ID assigned by the traversal engine.
///
/// Present when running via the Stage2 tree engine; may be absent in ad-hoc runs.
#[serde(skip_serializing_if = "Option::is_none")]
pub node_id: Option<u64>,
/// Parent node ID in the traversal tree.
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_node_id: Option<u64>,
/// Provenance metadata for non-root nodes (how this CA instance was discovered).
#[serde(skip_serializing_if = "Option::is_none")]
pub discovered_from: Option<DiscoveredFrom>,
pub rsync_base_uri: String,
pub manifest_rsync_uri: String,
pub publication_point_rsync_uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rrdp_notification_uri: Option<String>,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_sync_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_sync_phase: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_sync_duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_sync_error: Option<String>,
pub repo_terminal_state: String,
pub this_update_rfc3339_utc: String,
pub next_update_rfc3339_utc: String,
pub verified_at_rfc3339_utc: String,
pub warnings: Vec<AuditWarning>,
pub objects: Vec<ObjectAuditEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct DiscoveredFrom {
pub parent_manifest_rsync_uri: String,
pub child_ca_certificate_rsync_uri: String,
pub child_ca_certificate_sha256_hex: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TreeSummary {
pub instances_processed: usize,
pub instances_failed: usize,
pub warnings: Vec<AuditWarning>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditRunMeta {
pub validation_time_rfc3339_utc: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditDownloadKind {
RrdpNotification,
RrdpSnapshot,
RrdpDelta,
Rsync,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AuditDownloadObjectsStat {
pub objects_count: u64,
pub objects_bytes_total: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditDownloadEvent {
pub kind: AuditDownloadKind,
pub uri: String,
pub started_at_rfc3339_utc: String,
pub finished_at_rfc3339_utc: String,
pub duration_ms: u64,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub objects: Option<AuditDownloadObjectsStat>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AuditDownloadKindStats {
pub ok_total: u64,
pub fail_total: u64,
pub duration_ms_total: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes_total: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub objects_count_total: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub objects_bytes_total: Option<u64>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AuditDownloadStats {
pub events_total: u64,
/// Statistics keyed by serialized `AuditDownloadKind` string (e.g. "rrdp_snapshot").
pub by_kind: std::collections::BTreeMap<String, AuditDownloadKindStats>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn publication_point_audit_serializes_object_audit_only() {
let audit = PublicationPointAudit {
objects: vec![ObjectAuditEntry {
rsync_uri: "rsync://example.test/repo/fresh.roa".to_string(),
sha256_hex: "11".repeat(32),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Ok,
detail: None,
}],
..PublicationPointAudit::default()
};
let value = serde_json::to_value(&audit).expect("serialize audit");
assert!(value.get("objects").is_some());
}
#[test]
fn warning_audit_keeps_the_warning_category() {
let warning = crate::output::report::Warning::new("BER-compatible CMS accepted")
.with_category(crate::output::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)]
pub struct AuditRepoSyncStateStat {
pub count: u64,
pub duration_ms_total: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AuditRepoSyncStats {
pub publication_points_total: u64,
pub by_phase: std::collections::BTreeMap<String, AuditRepoSyncStateStat>,
pub by_terminal_state: std::collections::BTreeMap<String, AuditRepoSyncStateStat>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditReportV1 {
pub format_version: u32,
pub meta: AuditRunMeta,
pub policy: Policy,
pub tree: TreeSummary,
pub publication_points: Vec<PublicationPointAudit>,
pub vrps: Vec<VrpOutput>,
pub aspas: Vec<AspaOutput>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditReportV2 {
pub format_version: u32,
pub meta: AuditRunMeta,
pub policy: Policy,
pub tree: TreeSummary,
pub publication_points: Vec<PublicationPointAudit>,
pub vrps: Vec<VrpOutput>,
pub aspas: Vec<AspaOutput>,
pub downloads: Vec<AuditDownloadEvent>,
pub download_stats: AuditDownloadStats,
pub repo_sync_stats: AuditRepoSyncStats,
#[serde(rename = "queryAudit", skip_serializing_if = "Option::is_none")]
pub query_audit: Option<QueryAuditManifest>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct VrpOutput {
pub asn: u32,
pub prefix: String,
pub max_length: u16,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AspaOutput {
pub customer_as_id: u32,
pub provider_as_ids: Vec<u32>,
}
pub fn sha256_hex_from_32(bytes: &[u8; 32]) -> String {
hex::encode(bytes)
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest = sha2::Sha256::digest(bytes);
hex::encode(digest)
}
pub fn format_roa_ip_prefix(p: &crate::model::roa::IpPrefix) -> String {
let addr = p.addr_bytes();
match p.afi {
crate::model::roa::RoaAfi::Ipv4 => {
format!(
"{}.{}.{}.{}/{}",
addr[0], addr[1], addr[2], addr[3], p.prefix_len
)
}
crate::model::roa::RoaAfi::Ipv6 => {
let mut parts = Vec::with_capacity(8);
for i in 0..8 {
let hi = addr[i * 2] as u16;
let lo = addr[i * 2 + 1] as u16;
parts.push(format!("{:x}", (hi << 8) | lo));
}
format!("{}/{}", parts.join(":"), p.prefix_len)
}
}
}

View File

@ -0,0 +1,170 @@
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::output::audit::{
AuditDownloadEvent, AuditDownloadKind, AuditDownloadKindStats, AuditDownloadObjectsStat,
AuditDownloadStats,
};
#[derive(Clone, Debug, Default)]
pub struct DownloadLogHandle {
inner: Arc<Mutex<Vec<AuditDownloadEvent>>>,
}
impl DownloadLogHandle {
pub fn new() -> Self {
Self::default()
}
pub fn record_event(&self, event: AuditDownloadEvent) {
self.inner.lock().expect("download log lock").push(event);
}
pub fn snapshot_events(&self) -> Vec<AuditDownloadEvent> {
self.inner.lock().expect("download log lock").clone()
}
pub fn stats_from_events(events: &[AuditDownloadEvent]) -> AuditDownloadStats {
let mut out = AuditDownloadStats {
events_total: events.len() as u64,
by_kind: BTreeMap::new(),
};
for e in events {
let kind_key = match e.kind {
AuditDownloadKind::RrdpNotification => "rrdp_notification",
AuditDownloadKind::RrdpSnapshot => "rrdp_snapshot",
AuditDownloadKind::RrdpDelta => "rrdp_delta",
AuditDownloadKind::Rsync => "rsync",
}
.to_string();
let st = out
.by_kind
.entry(kind_key)
.or_insert_with(|| AuditDownloadKindStats {
ok_total: 0,
fail_total: 0,
duration_ms_total: 0,
bytes_total: None,
objects_count_total: None,
objects_bytes_total: None,
});
if e.success {
st.ok_total = st.ok_total.saturating_add(1);
} else {
st.fail_total = st.fail_total.saturating_add(1);
}
st.duration_ms_total = st.duration_ms_total.saturating_add(e.duration_ms);
if let Some(b) = e.bytes {
st.bytes_total = Some(st.bytes_total.unwrap_or(0).saturating_add(b));
}
if let Some(objects) = &e.objects {
st.objects_count_total = Some(
st.objects_count_total
.unwrap_or(0)
.saturating_add(objects.objects_count),
);
st.objects_bytes_total = Some(
st.objects_bytes_total
.unwrap_or(0)
.saturating_add(objects.objects_bytes_total),
);
}
}
out
}
pub fn stats(&self) -> AuditDownloadStats {
let events = self.snapshot_events();
Self::stats_from_events(&events)
}
pub fn span_download<'a>(
&'a self,
kind: AuditDownloadKind,
uri: &'a str,
) -> DownloadSpanGuard<'a> {
DownloadSpanGuard {
handle: self,
kind,
uri,
start_instant: Instant::now(),
started_at: time::OffsetDateTime::now_utc(),
bytes: None,
objects: None,
error: None,
success: None,
}
}
}
pub struct DownloadSpanGuard<'a> {
handle: &'a DownloadLogHandle,
kind: AuditDownloadKind,
uri: &'a str,
start_instant: Instant,
started_at: time::OffsetDateTime,
bytes: Option<u64>,
objects: Option<AuditDownloadObjectsStat>,
error: Option<String>,
success: Option<bool>,
}
impl DownloadSpanGuard<'_> {
pub fn set_bytes(&mut self, bytes: u64) {
self.bytes = Some(bytes);
}
pub fn set_objects(&mut self, objects_count: u64, objects_bytes_total: u64) {
self.objects = Some(AuditDownloadObjectsStat {
objects_count,
objects_bytes_total,
});
}
pub fn set_ok(&mut self) {
self.success = Some(true);
}
pub fn set_err(&mut self, msg: impl Into<String>) {
self.success = Some(false);
self.error = Some(msg.into());
}
}
impl Drop for DownloadSpanGuard<'_> {
fn drop(&mut self) {
use time::format_description::well_known::Rfc3339;
let finished_at = time::OffsetDateTime::now_utc();
let dur = self.start_instant.elapsed();
let duration_ms = duration_to_ms(dur);
let started_at_rfc3339_utc = self
.started_at
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.unwrap_or_else(|_| "<format-error>".to_string());
let finished_at_rfc3339_utc = finished_at
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.unwrap_or_else(|_| "<format-error>".to_string());
let success = self.success.unwrap_or(false);
let event = AuditDownloadEvent {
kind: self.kind.clone(),
uri: self.uri.to_string(),
started_at_rfc3339_utc,
finished_at_rfc3339_utc,
duration_ms,
success,
error: if success { None } else { self.error.clone() },
bytes: self.bytes,
objects: self.objects.clone(),
};
self.handle.record_event(event);
}
}
fn duration_to_ms(d: Duration) -> u64 {
let ms = d.as_millis();
ms.min(u128::from(u64::MAX)) as u64
}

350
src/output/memory.rs Normal file
View File

@ -0,0 +1,350 @@
use serde::Serialize;
use crate::repository::storage::RocksDbMemorySnapshot;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct MallocTrimProbe {
pub supported: bool,
pub return_value: Option<i32>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ProcessMemorySnapshot {
pub label: String,
pub vm_rss_kb: Option<u64>,
pub vm_size_kb: Option<u64>,
pub vm_data_kb: Option<u64>,
pub vm_swap_kb: Option<u64>,
pub rss_anon_kb: Option<u64>,
pub rss_file_kb: Option<u64>,
pub rss_shmem_kb: Option<u64>,
pub threads: Option<u64>,
pub fd_count: Option<u64>,
pub smaps_rollup: Option<SmapsRollupSnapshot>,
pub smaps_mapping_summary: Option<SmapsMappingSummary>,
pub errors: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct SmapsRollupSnapshot {
pub rss_kb: Option<u64>,
pub pss_kb: Option<u64>,
pub shared_clean_kb: Option<u64>,
pub shared_dirty_kb: Option<u64>,
pub private_clean_kb: Option<u64>,
pub private_dirty_kb: Option<u64>,
pub anonymous_kb: Option<u64>,
pub swap_kb: Option<u64>,
pub swap_pss_kb: Option<u64>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct SmapsMappingSummary {
pub heap: SmapsMappingCategory,
pub anonymous_mmap: SmapsMappingCategory,
pub file_backed: SmapsMappingCategory,
pub stack: SmapsMappingCategory,
pub special: SmapsMappingCategory,
pub total: SmapsMappingCategory,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct SmapsMappingCategory {
pub mappings: u64,
pub size_kb: u64,
pub rss_kb: u64,
pub pss_kb: u64,
pub private_clean_kb: u64,
pub private_dirty_kb: u64,
pub anonymous_kb: u64,
pub largest_mapping_rss_kb: u64,
pub large_mapping_count_64m: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct MemoryTelemetryCheckpoint {
pub label: String,
pub elapsed_ms: u64,
pub process: ProcessMemorySnapshot,
pub rocksdb: RocksDbMemorySnapshot,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct MemoryTelemetrySummary {
pub checkpoints: Vec<MemoryTelemetryCheckpoint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_graph: Option<ObjectGraphMemorySummary>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub malloc_trim_probes: Vec<MallocTrimProbe>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct ObjectGraphMemorySummary {
pub captured_at_label: String,
pub total_estimated_bytes: u64,
pub sections: Vec<ObjectGraphMemorySection>,
pub notes: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct ObjectGraphMemorySection {
pub name: String,
pub item_count: u64,
pub shallow_bytes: u64,
pub heap_bytes: u64,
pub estimated_bytes: u64,
pub string_count: u64,
pub string_bytes: u64,
pub string_capacity_bytes: u64,
pub vec_count: u64,
pub vec_heap_bytes: u64,
pub vec_capacity_bytes: u64,
pub details: Vec<ObjectGraphMemoryMetric>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ObjectGraphMemoryMetric {
pub name: String,
pub value: u64,
}
pub fn process_memory_snapshot(label: impl Into<String>) -> ProcessMemorySnapshot {
let label = label.into();
let mut snapshot = ProcessMemorySnapshot {
label,
vm_rss_kb: None,
vm_size_kb: None,
vm_data_kb: None,
vm_swap_kb: None,
rss_anon_kb: None,
rss_file_kb: None,
rss_shmem_kb: None,
threads: None,
fd_count: current_fd_count(),
smaps_rollup: None,
smaps_mapping_summary: None,
errors: Vec::new(),
};
match std::fs::read_to_string("/proc/self/status") {
Ok(status) => parse_status(&status, &mut snapshot),
Err(err) => snapshot
.errors
.push(format!("read /proc/self/status failed: {err}")),
}
match std::fs::read_to_string("/proc/self/smaps_rollup") {
Ok(smaps) => snapshot.smaps_rollup = Some(parse_smaps_rollup(&smaps)),
Err(err) => snapshot
.errors
.push(format!("read /proc/self/smaps_rollup failed: {err}")),
}
match std::fs::read_to_string("/proc/self/smaps") {
Ok(smaps) => snapshot.smaps_mapping_summary = Some(parse_smaps_mapping_summary(&smaps)),
Err(err) => snapshot
.errors
.push(format!("read /proc/self/smaps failed: {err}")),
}
snapshot
}
#[allow(unsafe_code)]
pub fn malloc_trim_probe() -> MallocTrimProbe {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
{
MallocTrimProbe {
supported: true,
return_value: Some(unsafe { malloc_trim(0) }),
}
}
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
{
MallocTrimProbe {
supported: false,
return_value: None,
}
}
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
#[allow(unsafe_code)]
unsafe extern "C" {
fn malloc_trim(pad: usize) -> i32;
}
fn current_fd_count() -> Option<u64> {
std::fs::read_dir("/proc/self/fd")
.ok()
.map(|entries| entries.filter_map(Result::ok).count() as u64)
}
fn parse_status(status: &str, snapshot: &mut ProcessMemorySnapshot) {
for line in status.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let parsed = parse_kb_or_plain_u64(value);
match key {
"VmRSS" => snapshot.vm_rss_kb = parsed,
"VmSize" => snapshot.vm_size_kb = parsed,
"VmData" => snapshot.vm_data_kb = parsed,
"VmSwap" => snapshot.vm_swap_kb = parsed,
"RssAnon" => snapshot.rss_anon_kb = parsed,
"RssFile" => snapshot.rss_file_kb = parsed,
"RssShmem" => snapshot.rss_shmem_kb = parsed,
"Threads" => snapshot.threads = parsed,
_ => {}
}
}
}
fn parse_smaps_rollup(smaps: &str) -> SmapsRollupSnapshot {
let mut snapshot = SmapsRollupSnapshot::default();
for line in smaps.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let parsed = parse_kb_or_plain_u64(value);
match key {
"Rss" => snapshot.rss_kb = parsed,
"Pss" => snapshot.pss_kb = parsed,
"Shared_Clean" => snapshot.shared_clean_kb = parsed,
"Shared_Dirty" => snapshot.shared_dirty_kb = parsed,
"Private_Clean" => snapshot.private_clean_kb = parsed,
"Private_Dirty" => snapshot.private_dirty_kb = parsed,
"Anonymous" => snapshot.anonymous_kb = parsed,
"Swap" => snapshot.swap_kb = parsed,
"SwapPss" => snapshot.swap_pss_kb = parsed,
_ => {}
}
}
snapshot
}
fn parse_smaps_mapping_summary(smaps: &str) -> SmapsMappingSummary {
let mut summary = SmapsMappingSummary::default();
let mut current_path = String::new();
let mut current = SmapsMappingCategory::default();
let mut have_mapping = false;
for line in smaps.lines() {
if is_smaps_mapping_header(line) {
if have_mapping {
add_mapping(&mut summary, &current_path, &current);
}
current_path = smaps_header_path(line);
current = SmapsMappingCategory {
mappings: 1,
..SmapsMappingCategory::default()
};
have_mapping = true;
continue;
}
if !have_mapping {
continue;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
let parsed = parse_kb_or_plain_u64(value).unwrap_or(0);
match key {
"Size" => current.size_kb = parsed,
"Rss" => current.rss_kb = parsed,
"Pss" => current.pss_kb = parsed,
"Private_Clean" => current.private_clean_kb = parsed,
"Private_Dirty" => current.private_dirty_kb = parsed,
"Anonymous" => current.anonymous_kb = parsed,
_ => {}
}
}
if have_mapping {
add_mapping(&mut summary, &current_path, &current);
}
summary
}
fn is_smaps_mapping_header(line: &str) -> bool {
let mut parts = line.split_whitespace();
let Some(range) = parts.next() else {
return false;
};
let Some(perms) = parts.next() else {
return false;
};
let Some((start, end)) = range.split_once('-') else {
return false;
};
!start.is_empty()
&& !end.is_empty()
&& start.as_bytes().iter().all(u8::is_ascii_hexdigit)
&& end.as_bytes().iter().all(u8::is_ascii_hexdigit)
&& perms.len() == 4
&& perms
.as_bytes()
.iter()
.all(|b| matches!(b, b'r' | b'w' | b'x' | b's' | b'p' | b'-'))
}
fn smaps_header_path(line: &str) -> String {
line.split_whitespace()
.skip(5)
.collect::<Vec<_>>()
.join(" ")
}
fn add_mapping(summary: &mut SmapsMappingSummary, path: &str, mapping: &SmapsMappingCategory) {
add_category(&mut summary.total, mapping);
match mapping_category(path) {
MappingCategory::Heap => add_category(&mut summary.heap, mapping),
MappingCategory::AnonymousMmap => add_category(&mut summary.anonymous_mmap, mapping),
MappingCategory::FileBacked => add_category(&mut summary.file_backed, mapping),
MappingCategory::Stack => add_category(&mut summary.stack, mapping),
MappingCategory::Special => add_category(&mut summary.special, mapping),
}
}
fn add_category(target: &mut SmapsMappingCategory, source: &SmapsMappingCategory) {
target.mappings += source.mappings;
target.size_kb += source.size_kb;
target.rss_kb += source.rss_kb;
target.pss_kb += source.pss_kb;
target.private_clean_kb += source.private_clean_kb;
target.private_dirty_kb += source.private_dirty_kb;
target.anonymous_kb += source.anonymous_kb;
target.largest_mapping_rss_kb = target.largest_mapping_rss_kb.max(source.rss_kb);
if source.rss_kb >= 64 * 1024 {
target.large_mapping_count_64m += source.mappings;
}
}
enum MappingCategory {
Heap,
AnonymousMmap,
FileBacked,
Stack,
Special,
}
fn mapping_category(path: &str) -> MappingCategory {
if path == "[heap]" {
MappingCategory::Heap
} else if path.starts_with("[stack") {
MappingCategory::Stack
} else if path.is_empty() {
MappingCategory::AnonymousMmap
} else if path.starts_with('/') {
MappingCategory::FileBacked
} else {
MappingCategory::Special
}
}
fn parse_kb_or_plain_u64(value: &str) -> Option<u64> {
value.split_whitespace().next()?.parse::<u64>().ok()
}

6
src/output/mod.rs Normal file
View File

@ -0,0 +1,6 @@
//! Validation artifacts and bounded runtime measurements.
pub mod analysis;
pub mod audit;
pub mod audit_downloads;
pub mod memory;
pub mod report;

57
src/output/report.rs Normal file
View File

@ -0,0 +1,57 @@
#[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>,
}
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
}
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
}

View File

@ -0,0 +1,835 @@
use std::path::PathBuf;
use std::sync::Arc;
use rocksdb::{DB, Options, WriteBatch};
use crate::repository::storage::{
RawByHashEntry, RocksDbMemoryDbSnapshot, RocksStore, StorageError, StorageResult,
memory_db_snapshot_for_column_families,
};
const RAW_BY_HASH_KEY_PREFIX: &str = "rawbyhash:";
const RAW_BLOB_KEY_PREFIX: &str = "rawblob:";
const REPO_BYTES_KEY_PREFIX: &str = "sha256:";
fn raw_by_hash_key(sha256_hex: &str) -> String {
format!("{RAW_BY_HASH_KEY_PREFIX}{sha256_hex}")
}
fn raw_blob_key(sha256_hex: &str) -> String {
format!("{RAW_BLOB_KEY_PREFIX}{sha256_hex}")
}
fn repo_bytes_key(sha256_hex: &str) -> String {
format!("{REPO_BYTES_KEY_PREFIX}{sha256_hex}")
}
fn validate_blob_sha256_hex(sha256_hex: &str) -> StorageResult<()> {
if sha256_hex.len() != 64 || !sha256_hex.as_bytes().iter().all(u8::is_ascii_hexdigit) {
return Err(StorageError::InvalidData {
entity: "raw_blob",
detail: format!("invalid sha256 hex: {sha256_hex}"),
});
}
Ok(())
}
fn validate_blob_bytes(bytes: &[u8]) -> StorageResult<()> {
if bytes.is_empty() {
return Err(StorageError::InvalidData {
entity: "raw_blob",
detail: "bytes must not be empty".to_string(),
});
}
Ok(())
}
pub trait RawObjectStore {
fn get_raw_entry(&self, sha256_hex: &str) -> StorageResult<Option<RawByHashEntry>>;
fn get_raw_entries_batch(
&self,
sha256_hexes: &[String],
) -> StorageResult<Vec<Option<RawByHashEntry>>>;
fn get_blob_bytes(&self, sha256_hex: &str) -> StorageResult<Option<Vec<u8>>> {
self.get_raw_entry(sha256_hex)
.map(|entry| entry.map(|entry| entry.bytes))
}
fn get_blob_bytes_batch(&self, sha256_hexes: &[String]) -> StorageResult<Vec<Option<Vec<u8>>>> {
self.get_raw_entries_batch(sha256_hexes).map(|entries| {
entries
.into_iter()
.map(|entry| entry.map(|entry| entry.bytes))
.collect()
})
}
}
#[derive(Clone, Debug)]
pub struct ExternalRawStoreDb {
path: PathBuf,
db: Arc<DB>,
}
#[derive(Clone, Debug)]
pub struct ExternalRepoBytesDb {
path: PathBuf,
db: Arc<DB>,
read_only: bool,
secondary: bool,
}
impl ExternalRawStoreDb {
pub fn open(path: impl Into<PathBuf>) -> StorageResult<Self> {
let path = path.into();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| StorageError::RocksDb(e.to_string()))?;
}
let mut opts = Options::default();
opts.create_if_missing(true);
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
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),
})
}
pub fn put_raw_entry(&self, entry: &RawByHashEntry) -> StorageResult<()> {
entry.validate_internal()?;
let key = raw_by_hash_key(&entry.sha256_hex);
let blob_key = raw_blob_key(&entry.sha256_hex);
let value = serde_cbor::to_vec(entry).map_err(|e| StorageError::Codec {
entity: "raw_by_hash",
detail: e.to_string(),
})?;
let blob_value = entry.bytes.clone();
self.db
.write({
let mut batch = WriteBatch::default();
batch.put(key.as_bytes(), value);
batch.put(blob_key.as_bytes(), blob_value);
batch
})
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn put_raw_entries_batch(&self, entries: &[RawByHashEntry]) -> StorageResult<()> {
if entries.is_empty() {
return Ok(());
}
let mut batch = WriteBatch::default();
for entry in entries {
entry.validate_internal()?;
let key = raw_by_hash_key(&entry.sha256_hex);
let blob_key = raw_blob_key(&entry.sha256_hex);
let value = serde_cbor::to_vec(entry).map_err(|e| StorageError::Codec {
entity: "raw_by_hash",
detail: e.to_string(),
})?;
batch.put(key.as_bytes(), value);
batch.put(blob_key.as_bytes(), entry.bytes.as_slice());
}
self.db
.write(batch)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn put_blob_bytes_batch(&self, blobs: &[(String, Vec<u8>)]) -> StorageResult<()> {
if blobs.is_empty() {
return Ok(());
}
let mut batch = WriteBatch::default();
for (sha256_hex, bytes) in blobs {
validate_blob_sha256_hex(sha256_hex)?;
validate_blob_bytes(bytes)?;
let blob_key = raw_blob_key(sha256_hex);
batch.put(blob_key.as_bytes(), bytes.as_slice());
}
self.db
.write(batch)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn delete_raw_entry(&self, sha256_hex: &str) -> StorageResult<()> {
let key = raw_by_hash_key(sha256_hex);
let blob_key = raw_blob_key(sha256_hex);
self.db
.write({
let mut batch = WriteBatch::default();
batch.delete(key.as_bytes());
batch.delete(blob_key.as_bytes());
batch
})
.map_err(|e| StorageError::RocksDb(e.to_string()))
}
pub fn path(&self) -> &PathBuf {
&self.path
}
pub(crate) fn memory_snapshot(&self, label: impl Into<String>) -> RocksDbMemoryDbSnapshot {
memory_db_snapshot_for_column_families(label, self.db.as_ref(), None)
}
}
impl ExternalRepoBytesDb {
pub fn open(path: impl Into<PathBuf>) -> StorageResult<Self> {
let path = path.into();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| StorageError::RocksDb(e.to_string()))?;
}
let mut opts = Options::default();
opts.create_if_missing(true);
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
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,
})
}
pub fn open_read_only(path: impl Into<PathBuf>) -> StorageResult<Self> {
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 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)?;
validate_blob_bytes(bytes)?;
let key = repo_bytes_key(sha256_hex);
batch.put(key.as_bytes(), bytes.as_slice());
}
self.db
.write(batch)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
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);
self.db
.get(key.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))
}
pub fn get_blob_bytes_batch(
&self,
sha256_hexes: &[String],
) -> StorageResult<Vec<Option<Vec<u8>>>> {
if sha256_hexes.is_empty() {
return Ok(Vec::new());
}
let keys: Vec<String> = sha256_hexes
.iter()
.map(|hash| {
validate_blob_sha256_hex(hash)?;
Ok::<String, StorageError>(repo_bytes_key(hash))
})
.collect::<Result<_, _>>()?;
self.db
.multi_get(keys.iter().map(|key| key.as_bytes()))
.into_iter()
.map(|res| res.map_err(|e| StorageError::RocksDb(e.to_string())))
.collect()
}
pub fn path(&self) -> &PathBuf {
&self.path
}
pub(crate) fn memory_snapshot(&self, label: impl Into<String>) -> RocksDbMemoryDbSnapshot {
memory_db_snapshot_for_column_families(label, self.db.as_ref(), None)
}
}
impl RawObjectStore for RocksStore {
fn get_raw_entry(&self, sha256_hex: &str) -> StorageResult<Option<RawByHashEntry>> {
self.get_raw_by_hash_entry(sha256_hex)
}
fn get_raw_entries_batch(
&self,
sha256_hexes: &[String],
) -> StorageResult<Vec<Option<RawByHashEntry>>> {
self.get_raw_by_hash_entries_batch(sha256_hexes)
}
fn get_blob_bytes(&self, sha256_hex: &str) -> StorageResult<Option<Vec<u8>>> {
RocksStore::get_blob_bytes(self, sha256_hex)
}
fn get_blob_bytes_batch(&self, sha256_hexes: &[String]) -> StorageResult<Vec<Option<Vec<u8>>>> {
RocksStore::get_blob_bytes_batch(self, sha256_hexes)
}
}
impl RawObjectStore for ExternalRawStoreDb {
fn get_raw_entry(&self, sha256_hex: &str) -> StorageResult<Option<RawByHashEntry>> {
let key = raw_by_hash_key(sha256_hex);
let Some(bytes) = self
.db
.get(key.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?
else {
return Ok(None);
};
let entry =
serde_cbor::from_slice::<RawByHashEntry>(&bytes).map_err(|e| StorageError::Codec {
entity: "raw_by_hash",
detail: e.to_string(),
})?;
entry.validate_internal()?;
Ok(Some(entry))
}
fn get_raw_entries_batch(
&self,
sha256_hexes: &[String],
) -> StorageResult<Vec<Option<RawByHashEntry>>> {
if sha256_hexes.is_empty() {
return Ok(Vec::new());
}
let keys: Vec<String> = sha256_hexes
.iter()
.map(|hash| raw_by_hash_key(hash))
.collect();
self.db
.multi_get(keys.iter().map(|key| key.as_bytes()))
.into_iter()
.map(|res| {
let maybe = res.map_err(|e| StorageError::RocksDb(e.to_string()))?;
match maybe {
Some(bytes) => {
let entry =
serde_cbor::from_slice::<RawByHashEntry>(&bytes).map_err(|e| {
StorageError::Codec {
entity: "raw_by_hash",
detail: e.to_string(),
}
})?;
entry.validate_internal()?;
Ok(Some(entry))
}
None => Ok(None),
}
})
.collect()
}
fn get_blob_bytes(&self, sha256_hex: &str) -> StorageResult<Option<Vec<u8>>> {
let key = raw_blob_key(sha256_hex);
self.db
.get(key.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))
}
fn get_blob_bytes_batch(&self, sha256_hexes: &[String]) -> StorageResult<Vec<Option<Vec<u8>>>> {
if sha256_hexes.is_empty() {
return Ok(Vec::new());
}
let keys: Vec<String> = sha256_hexes.iter().map(|hash| raw_blob_key(hash)).collect();
self.db
.multi_get(keys.iter().map(|key| key.as_bytes()))
.into_iter()
.map(|res| res.map_err(|e| StorageError::RocksDb(e.to_string())))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::{ExternalRawStoreDb, ExternalRepoBytesDb, RawObjectStore};
use crate::repository::storage::{RawByHashEntry, RocksStore, StorageError, StorageResult};
use std::collections::HashMap;
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(bytes))
}
#[derive(Default)]
struct MockRawStore {
entries: HashMap<String, RawByHashEntry>,
}
impl RawObjectStore for MockRawStore {
fn get_raw_entry(&self, sha256_hex: &str) -> StorageResult<Option<RawByHashEntry>> {
Ok(self.entries.get(sha256_hex).cloned())
}
fn get_raw_entries_batch(
&self,
sha256_hexes: &[String],
) -> StorageResult<Vec<Option<RawByHashEntry>>> {
Ok(sha256_hexes
.iter()
.map(|hash| self.entries.get(hash).cloned())
.collect())
}
}
#[test]
fn rocks_store_raw_object_store_reads_single_and_batch_entries() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let a = b"object-a".to_vec();
let b = b"object-b".to_vec();
let a_hash = sha256_hex(&a);
let b_hash = sha256_hex(&b);
store
.put_raw_by_hash_entry(&RawByHashEntry::from_bytes(a_hash.clone(), a.clone()))
.expect("put a");
store
.put_raw_by_hash_entry(&RawByHashEntry::from_bytes(b_hash.clone(), b.clone()))
.expect("put b");
let single = store
.get_raw_entry(&a_hash)
.expect("get single")
.expect("present");
assert_eq!(single.bytes, a);
let batch = store
.get_raw_entries_batch(&[a_hash.clone(), "00".repeat(32), b_hash.clone()])
.expect("get batch");
assert_eq!(batch.len(), 3);
assert_eq!(
batch[0].as_ref().map(|entry| entry.bytes.as_slice()),
Some(a.as_slice())
);
assert!(batch[1].is_none());
assert_eq!(
batch[2].as_ref().map(|entry| entry.bytes.as_slice()),
Some(b.as_slice())
);
}
#[test]
fn external_raw_store_db_roundtrips_entries() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store =
ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
let mut entry = RawByHashEntry::from_bytes(sha256_hex(b"blob"), b"blob".to_vec());
entry
.origin_uris
.push("rsync://example.test/repo/a.cer".to_string());
entry.object_type = Some("cer".to_string());
raw_store.put_raw_entry(&entry).expect("put raw entry");
let got = raw_store
.get_raw_entry(&entry.sha256_hex)
.expect("read raw entry")
.expect("entry exists");
assert_eq!(got, entry);
}
#[test]
fn external_raw_store_db_batch_writes_and_reads() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store =
ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
let a = RawByHashEntry::from_bytes(sha256_hex(b"a"), b"a".to_vec());
let b = RawByHashEntry::from_bytes(sha256_hex(b"b"), b"b".to_vec());
raw_store
.put_raw_entries_batch(&[a.clone(), b.clone()])
.expect("batch put");
let batch = raw_store
.get_raw_entries_batch(&[a.sha256_hex.clone(), b.sha256_hex.clone()])
.expect("batch get");
assert_eq!(batch.len(), 2);
assert_eq!(batch[0], Some(a));
assert_eq!(batch[1], Some(b));
}
#[test]
fn raw_object_store_default_blob_helpers_return_bytes_only() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store = ExternalRawStoreDb::open(td.path().join("nested/raw-store.db"))
.expect("open raw store");
let mut entry = RawByHashEntry::from_bytes(sha256_hex(b"blob"), b"blob".to_vec());
entry
.origin_uris
.push("rsync://example.test/repo/blob.roa".to_string());
raw_store.put_raw_entry(&entry).expect("put raw entry");
let single = raw_store
.get_blob_bytes(&entry.sha256_hex)
.expect("get blob bytes")
.expect("entry exists");
assert_eq!(single, b"blob".to_vec());
let batch = raw_store
.get_blob_bytes_batch(&[entry.sha256_hex.clone(), "00".repeat(32)])
.expect("get blob bytes batch");
assert_eq!(batch, vec![Some(b"blob".to_vec()), None]);
}
#[test]
fn raw_object_store_default_blob_helpers_work_for_custom_store() {
let mut store = MockRawStore::default();
let a = RawByHashEntry::from_bytes(sha256_hex(b"a"), b"a".to_vec());
let b = RawByHashEntry::from_bytes(sha256_hex(b"b"), b"b".to_vec());
store.entries.insert(a.sha256_hex.clone(), a.clone());
store.entries.insert(b.sha256_hex.clone(), b.clone());
let single = store
.get_blob_bytes(&a.sha256_hex)
.expect("single blob bytes")
.expect("present");
assert_eq!(single, b"a".to_vec());
let batch = store
.get_blob_bytes_batch(&[a.sha256_hex.clone(), "00".repeat(32), b.sha256_hex.clone()])
.expect("batch blob bytes");
assert_eq!(batch, vec![Some(b"a".to_vec()), None, Some(b"b".to_vec())]);
}
#[test]
fn rocks_store_blob_helpers_use_external_raw_store_fast_path() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open_with_external_raw_store(
&td.path().join("db"),
&td.path().join("raw-store.db"),
)
.expect("open store with external raw store");
let entry = RawByHashEntry::from_bytes(sha256_hex(b"blob-fast"), b"blob-fast".to_vec());
store.put_raw_by_hash_entry(&entry).expect("put");
let single = store
.get_blob_bytes(&entry.sha256_hex)
.expect("single blob bytes")
.expect("present");
assert_eq!(single, b"blob-fast".to_vec());
let batch = store
.get_blob_bytes_batch(&[entry.sha256_hex.clone(), "00".repeat(32)])
.expect("batch blob bytes");
assert_eq!(batch, vec![Some(b"blob-fast".to_vec()), None]);
}
#[test]
fn external_raw_store_db_delete_removes_entry() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store =
ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
let entry = RawByHashEntry::from_bytes(sha256_hex(b"gone"), b"gone".to_vec());
raw_store.put_raw_entry(&entry).expect("put");
assert!(
raw_store
.get_raw_entry(&entry.sha256_hex)
.unwrap()
.is_some()
);
raw_store
.delete_raw_entry(&entry.sha256_hex)
.expect("delete entry");
assert!(
raw_store
.get_raw_entry(&entry.sha256_hex)
.unwrap()
.is_none()
);
}
#[test]
fn put_blob_bytes_batch_round_trips_without_raw_entry() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store =
ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
let a = (sha256_hex(b"blob-a"), b"blob-a".to_vec());
let b = (sha256_hex(b"blob-b"), b"blob-b".to_vec());
raw_store
.put_blob_bytes_batch(&[a.clone(), b.clone()])
.expect("put blobs");
assert_eq!(
raw_store.get_blob_bytes(&a.0).expect("get blob a"),
Some(a.1.clone())
);
assert_eq!(
raw_store.get_blob_bytes(&b.0).expect("get blob b"),
Some(b.1.clone())
);
assert!(raw_store.get_raw_entry(&a.0).expect("get raw a").is_none());
assert!(raw_store.get_raw_entry(&b.0).expect("get raw b").is_none());
}
#[test]
fn put_blob_bytes_batch_rejects_invalid_inputs() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store =
ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
let err = raw_store
.put_blob_bytes_batch(&[("zz".repeat(32), b"blob".to_vec())])
.expect_err("invalid hash should fail");
assert!(matches!(err, StorageError::InvalidData { .. }));
let err = raw_store
.put_blob_bytes_batch(&[(sha256_hex(b"blob"), Vec::new())])
.expect_err("empty bytes should fail");
assert!(matches!(err, StorageError::InvalidData { .. }));
}
#[test]
fn external_raw_store_db_rejects_invalid_entry_on_put() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store =
ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
let bad = RawByHashEntry {
sha256_hex: "11".repeat(32),
bytes: b"blob".to_vec(),
origin_uris: Vec::new(),
object_type: None,
encoding: None,
};
let err = raw_store
.put_raw_entry(&bad)
.expect_err("invalid hash should fail");
assert!(matches!(err, StorageError::InvalidData { .. }));
}
#[test]
fn external_raw_store_db_reports_codec_error_for_corrupt_value() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store =
ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
raw_store
.db
.put(b"rawbyhash:deadbeef", b"not-cbor")
.expect("inject corrupt bytes");
let err = raw_store
.get_raw_entry("deadbeef")
.expect_err("corrupt value should fail");
assert!(matches!(
err,
StorageError::Codec {
entity: "raw_by_hash",
..
}
));
}
#[test]
fn external_raw_store_db_batch_returns_empty_for_empty_request() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store =
ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store");
let entries = raw_store
.get_raw_entries_batch(&[])
.expect("empty batch succeeds");
assert!(entries.is_empty());
raw_store
.put_raw_entries_batch(&[])
.expect("empty put succeeds");
}
#[test]
fn external_repo_bytes_db_roundtrips_blob_bytes_without_raw_entry() {
let td = tempfile::tempdir().expect("tempdir");
let repo_bytes =
ExternalRepoBytesDb::open(td.path().join("repo-bytes.db")).expect("open repo bytes");
let bytes = b"repo-bytes-object".to_vec();
let hash = sha256_hex(&bytes);
repo_bytes
.put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
.expect("put repo bytes");
assert_eq!(
repo_bytes.get_blob_bytes(&hash).expect("get repo bytes"),
Some(bytes.clone())
);
assert_eq!(
repo_bytes
.get_blob_bytes_batch(&[hash, "00".repeat(32)])
.expect("get repo bytes batch"),
vec![Some(bytes), None]
);
}
#[test]
fn external_repo_bytes_db_rejects_invalid_inputs() {
let td = tempfile::tempdir().expect("tempdir");
let repo_bytes =
ExternalRepoBytesDb::open(td.path().join("repo-bytes.db")).expect("open repo bytes");
assert!(
repo_bytes
.put_blob_bytes_batch(&[("not-a-valid-hash".to_string(), b"blob".to_vec())])
.is_err()
);
assert!(
repo_bytes
.put_blob_bytes_batch(&[(sha256_hex(b"blob"), Vec::new())])
.is_err()
);
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.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

@ -0,0 +1,289 @@
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use crate::repository::storage::{RepositoryViewEntry, RepositoryViewState};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CurrentRepoEntry {
pub current_hash: [u8; 32],
pub current_hash_hex: String,
pub repository_source: String,
pub object_type: Option<String>,
pub state: RepositoryViewState,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct CurrentRepoObject {
pub rsync_uri: String,
pub current_hash_hex: String,
pub repository_source: String,
pub object_type: Option<String>,
}
#[derive(Default, Debug)]
pub struct CurrentRepoIndex {
by_uri: HashMap<String, CurrentRepoEntry>,
}
/// 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 {
Self::default()
}
pub fn shared() -> CurrentRepoIndexHandle {
Arc::new(RwLock::new(Self::new()))
}
pub fn get_by_uri(&self, rsync_uri: &str) -> Option<&CurrentRepoEntry> {
self.by_uri.get(rsync_uri)
}
pub fn list_scope_uris(&self, repository_source: &str) -> Vec<String> {
let mut out = self
.by_uri
.iter()
.filter(|&(_rsync_uri, entry)| entry.repository_source == repository_source)
.map(|(rsync_uri, _entry)| rsync_uri.clone())
.collect::<Vec<_>>();
out.sort();
out
}
pub fn active_uri_count(&self) -> usize {
self.by_uri.len()
}
pub fn scope_count(&self) -> usize {
self.by_uri
.values()
.map(|entry| entry.repository_source.as_str())
.collect::<HashSet<_>>()
.len()
}
pub fn snapshot_objects(&self) -> Vec<CurrentRepoObject> {
let mut out = self
.by_uri
.iter()
.map(|(rsync_uri, entry)| CurrentRepoObject {
rsync_uri: rsync_uri.clone(),
current_hash_hex: entry.current_hash_hex.clone(),
repository_source: entry.repository_source.clone(),
object_type: entry.object_type.clone(),
})
.collect::<Vec<_>>();
out.sort();
out
}
pub fn clear(&mut self) {
self.by_uri.clear();
}
pub fn apply_repository_view_entries(
&mut self,
entries: &[RepositoryViewEntry],
) -> Result<(), String> {
for entry in entries {
self.apply_repository_view_entry(entry)?;
}
Ok(())
}
fn apply_repository_view_entry(&mut self, entry: &RepositoryViewEntry) -> Result<(), String> {
entry.validate_internal().map_err(|e| e.to_string())?;
match entry.state {
RepositoryViewState::Present | RepositoryViewState::Replaced => {
let repository_source = entry.repository_source.clone().ok_or_else(|| {
format!(
"repository_view entry missing repository_source for current object {}",
entry.rsync_uri
)
})?;
let current_hash_hex = entry.current_hash.clone().ok_or_else(|| {
format!(
"repository_view entry missing current_hash for current object {}",
entry.rsync_uri
)
})?;
let current_hash = decode_sha256_hex_32(&current_hash_hex)?;
self.by_uri.insert(
entry.rsync_uri.clone(),
CurrentRepoEntry {
current_hash,
current_hash_hex: current_hash_hex.to_ascii_lowercase(),
repository_source,
object_type: entry.object_type.clone(),
state: entry.state,
},
);
}
RepositoryViewState::Withdrawn => {
self.by_uri.remove(&entry.rsync_uri);
}
}
Ok(())
}
}
fn decode_sha256_hex_32(value: &str) -> Result<[u8; 32], String> {
if value.len() != 64 || !value.as_bytes().iter().all(u8::is_ascii_hexdigit) {
return Err(format!("invalid sha256 hex: {value}"));
}
let mut out = [0u8; 32];
hex::decode_to_slice(value, &mut out).map_err(|e| format!("hex decode failed: {e}"))?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::CurrentRepoIndex;
use crate::repository::storage::{RepositoryViewEntry, RepositoryViewState};
fn present(source: &str, uri: &str, hash: &str) -> RepositoryViewEntry {
RepositoryViewEntry {
rsync_uri: uri.to_string(),
current_hash: Some(hash.to_string()),
repository_source: Some(source.to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
}
}
#[test]
fn current_repo_index_tracks_present_and_withdrawn_entries() {
let mut index = CurrentRepoIndex::new();
let uri = "rsync://example.test/repo/a.roa";
let source = "rsync://example.test/repo/";
let hash = &"11".repeat(32);
index
.apply_repository_view_entries(&[present(source, uri, hash)])
.expect("apply present");
let got = index.get_by_uri(uri).expect("current entry");
assert_eq!(got.current_hash_hex, hash.to_string());
assert_eq!(index.list_scope_uris(source), vec![uri.to_string()]);
index
.apply_repository_view_entries(&[RepositoryViewEntry {
rsync_uri: uri.to_string(),
current_hash: Some(hash.to_string()),
repository_source: Some(source.to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Withdrawn,
}])
.expect("apply withdrawn");
assert!(index.get_by_uri(uri).is_none());
assert!(index.list_scope_uris(source).is_empty());
}
#[test]
fn current_repo_index_moves_uri_between_scopes() {
let mut index = CurrentRepoIndex::new();
let uri = "rsync://example.test/repo/a.roa";
let old_scope = "rsync://example.test/repo/";
let new_scope = "https://rrdp.example.test/notification.xml";
index
.apply_repository_view_entries(&[present(old_scope, uri, &"22".repeat(32))])
.expect("apply old scope");
index
.apply_repository_view_entries(&[present(new_scope, uri, &"33".repeat(32))])
.expect("apply new scope");
assert!(index.list_scope_uris(old_scope).is_empty());
assert_eq!(index.list_scope_uris(new_scope), vec![uri.to_string()]);
assert_eq!(
index.get_by_uri(uri).expect("entry").current_hash_hex,
"33".repeat(32)
);
}
#[test]
fn current_repo_index_snapshot_objects_and_counts_are_sorted() {
let handle = CurrentRepoIndex::shared();
let mut index = handle.write().expect("write-lock index");
index
.apply_repository_view_entries(&[
present(
"rsync://example.test/repo-b/",
"rsync://example.test/repo-b/b.roa",
&"22".repeat(32),
),
present(
"rsync://example.test/repo-a/",
"rsync://example.test/repo-a/a.roa",
&"11".repeat(32),
),
])
.expect("apply present entries");
assert_eq!(index.active_uri_count(), 2);
assert_eq!(index.scope_count(), 2);
let snapshot = index.snapshot_objects();
assert_eq!(snapshot.len(), 2);
assert_eq!(snapshot[0].rsync_uri, "rsync://example.test/repo-a/a.roa");
assert_eq!(snapshot[1].rsync_uri, "rsync://example.test/repo-b/b.roa");
}
#[test]
fn current_repo_index_reports_missing_fields_and_invalid_hash() {
let mut index = CurrentRepoIndex::new();
let err = index
.apply_repository_view_entries(&[RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
current_hash: Some("11".repeat(32)),
repository_source: None,
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
}])
.expect_err("missing source should fail");
assert!(err.contains("missing repository_source"), "{err}");
let err = index
.apply_repository_view_entries(&[RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
current_hash: Some("not-a-valid-sha256".to_string()),
repository_source: Some("rsync://example.test/repo/".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
}])
.expect_err("invalid hash should fail");
assert!(err.contains("invalid"), "{err}");
index
.apply_repository_view_entries(&[RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/b.roa".to_string(),
current_hash: Some("22".repeat(32)),
repository_source: Some("rsync://example.test/repo/".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
}])
.expect("valid entry");
let got = index.get_by_uri("rsync://example.test/repo/b.roa").unwrap();
assert_eq!(got.current_hash_hex, "22".repeat(32));
}
#[test]
fn current_repo_index_withdraw_unknown_uri_is_noop() {
let mut index = CurrentRepoIndex::new();
index
.apply_repository_view_entries(&[RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/missing.roa".to_string(),
current_hash: None,
repository_source: Some("rsync://example.test/repo/".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Withdrawn,
}])
.expect("withdraw unknown should not fail");
assert_eq!(index.active_uri_count(), 0);
assert_eq!(index.scope_count(), 0);
}
}

View File

@ -0,0 +1,234 @@
use std::sync::Arc;
use crate::repository::fetch::rsync::{RsyncFetchError, RsyncFetchResult, RsyncFetcher};
use crate::repository::fetch::rsync_system::{
RsyncScopePolicy, scoped_rsync_failure_dedup_key, scoped_rsync_fetch_uri,
};
use crate::repository::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 the materialized object set stays
// equivalent to the publication-point URI that triggered the fetch.
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::repository::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 = crate::repository::sync::store_projection::compute_sha256_hex(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_uses_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 = crate::repository::sync::store_projection::compute_sha256_hex(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

@ -0,0 +1,922 @@
use std::cell::RefCell;
use std::io::Write;
use std::time::Duration;
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, LOCATION};
use url::Url;
use crate::repository::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) };
}
pub fn with_scoped_http_timeout_override<R>(timeout: Duration, f: impl FnOnce() -> R) -> R {
HTTP_TIMEOUT_OVERRIDE.with(|cell| {
let previous = cell.replace(Some(timeout));
let result = f();
let _ = cell.replace(previous);
result
})
}
/// Default User-Agent sent with every outgoing HTTP request (RRDP
/// notification/snapshot/delta and TAL/TA downloads).
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.
pub connect_timeout: Duration,
/// Short timeout used for connection establishment and small metadata objects.
pub timeout: Duration,
/// Larger timeout used for RRDP snapshot / delta bodies.
pub large_body_timeout: Duration,
pub user_agent: String,
/// Extra PEM trust anchors for HTTPS transport tests or controlled endpoints.
pub extra_root_certificates_pem: Vec<Vec<u8>>,
}
impl Default for HttpFetcherConfig {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(15),
timeout: Duration::from_secs(30),
large_body_timeout: Duration::from_secs(180),
user_agent: resolve_http_user_agent(std::env::var(HTTP_USER_AGENT_ENV).ok()),
extra_root_certificates_pem: Vec::new(),
}
}
}
/// Minimal blocking HTTP(S) fetcher for validation runs.
///
/// This is used for:
/// - downloading TAL / TA certificates (RFC 8630 §2)
/// - fetching RRDP notification/snapshot files (RFC 8182 §3.4)
#[derive(Clone, Debug)]
pub struct BlockingHttpFetcher {
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,
}
impl BlockingHttpFetcher {
pub fn new(config: HttpFetcherConfig) -> Result<Self, String> {
let short_timeout = config.timeout;
let large_body_timeout = std::cmp::max(config.large_body_timeout, config.timeout);
let connect_timeout = std::cmp::min(config.connect_timeout, config.timeout);
let short_client = Self::client_builder(
&config,
connect_timeout,
config.timeout,
config.user_agent.clone(),
)?
.build()
.map_err(|e| e.to_string())?;
let large_body_client = Self::client_builder(
&config,
connect_timeout,
large_body_timeout,
config.user_agent.clone(),
)?
.build()
.map_err(|e| e.to_string())?;
let retry_short_client = Self::client_builder(
&config,
Duration::from_secs(1),
Duration::from_secs(1),
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())?;
Ok(Self {
short_client,
large_body_client,
retry_short_client,
rrdp_short_client,
rrdp_large_body_client,
rrdp_retry_short_client,
short_timeout,
large_body_timeout,
})
}
fn client_builder(
config: &HttpFetcherConfig,
connect_timeout: Duration,
timeout: Duration,
user_agent: String,
) -> Result<reqwest::blocking::ClientBuilder, String> {
let mut builder = Client::builder()
.connect_timeout(connect_timeout)
.timeout(timeout)
.user_agent(user_agent);
for (idx, pem) in config.extra_root_certificates_pem.iter().enumerate() {
let certificate = reqwest::Certificate::from_pem(pem)
.map_err(|e| format!("parse HTTP root certificate #{idx} failed: {e}"))?;
builder = builder.add_root_certificate(certificate);
}
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);
let resp = client.get(uri).send().map_err(|e| {
let msg = format!("http request failed: {e:?}");
crate::logging::progress::emit!(
"http_fetch_failed",
serde_json::json!({
"uri": uri,
"stage": "request",
"timeout_profile": timeout_profile,
"request_timeout_ms": timeout_value.as_millis() as u64,
"duration_ms": started.elapsed().as_millis() as u64,
"error": msg,
}),
);
msg
})?;
let status = resp.status();
let headers = resp.headers().clone();
if !status.is_success() {
let msg = format!(
"http status {status}; content_type={}; content_encoding={}; content_length={}; transfer_encoding={}",
header_value(&headers, "content-type"),
header_value(&headers, "content-encoding"),
header_value(&headers, "content-length"),
header_value(&headers, "transfer-encoding"),
);
crate::logging::progress::emit!(
"http_fetch_failed",
serde_json::json!({
"uri": uri,
"stage": "status",
"timeout_profile": timeout_profile,
"request_timeout_ms": timeout_value.as_millis() as u64,
"duration_ms": started.elapsed().as_millis() as u64,
"status": status.as_u16(),
"content_type": header_value_opt(&headers, "content-type"),
"content_encoding": header_value_opt(&headers, "content-encoding"),
"content_length": header_value_opt(&headers, "content-length"),
"transfer_encoding": header_value_opt(&headers, "transfer-encoding"),
"error": msg,
}),
);
return Err(msg);
}
match resp.bytes() {
Ok(bytes) => {
let duration_ms = started.elapsed().as_millis() as u64;
if (duration_ms as f64) / 1000.0 >= crate::logging::progress::slow_threshold_secs()
{
crate::logging::progress::emit!(
"http_fetch_slow",
serde_json::json!({
"uri": uri,
"status": status.as_u16(),
"timeout_profile": timeout_profile,
"request_timeout_ms": timeout_value.as_millis() as u64,
"duration_ms": duration_ms,
"bytes": bytes.len(),
"content_type": header_value_opt(&headers, "content-type"),
"content_encoding": header_value_opt(&headers, "content-encoding"),
"content_length": header_value_opt(&headers, "content-length"),
"transfer_encoding": header_value_opt(&headers, "transfer-encoding"),
}),
);
}
Ok(bytes.to_vec())
}
Err(e) => {
let msg = format!(
"http read body failed: {e}; status={}; content_type={}; content_encoding={}; content_length={}; transfer_encoding={}",
status,
header_value(&headers, "content-type"),
header_value(&headers, "content-encoding"),
header_value(&headers, "content-length"),
header_value(&headers, "transfer-encoding"),
);
crate::logging::progress::emit!(
"http_fetch_failed",
serde_json::json!({
"uri": uri,
"stage": "read_body",
"timeout_profile": timeout_profile,
"request_timeout_ms": timeout_value.as_millis() as u64,
"duration_ms": started.elapsed().as_millis() as u64,
"status": status.as_u16(),
"content_type": header_value_opt(&headers, "content-type"),
"content_encoding": header_value_opt(&headers, "content-encoding"),
"content_length": header_value_opt(&headers, "content-length"),
"transfer_encoding": header_value_opt(&headers, "transfer-encoding"),
"error": msg,
}),
);
Err(msg)
}
}
}
fn client_for_uri(&self, uri: &str) -> (&Client, &'static str, Duration) {
if let Some(timeout) = HTTP_TIMEOUT_OVERRIDE.with(|cell| *cell.borrow()) {
return (&self.retry_short_client, "retry_short", timeout);
}
if uses_large_body_timeout(uri) {
(
&self.large_body_client,
"large_body",
self.large_body_timeout,
)
} else {
(&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::logging::progress::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::logging::progress::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 {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
self.fetch_bytes(uri)
}
fn fetch_to_writer(&self, uri: &str, out: &mut dyn Write) -> Result<u64, String> {
let started = std::time::Instant::now();
let (client, timeout_profile, timeout_value) = self.client_for_uri(uri);
let resp = client.get(uri).send().map_err(|e| {
let msg = format!("http request failed: {e:?}");
crate::logging::progress::emit!(
"http_fetch_failed",
serde_json::json!({
"uri": uri,
"stage": "request",
"timeout_profile": timeout_profile,
"request_timeout_ms": timeout_value.as_millis() as u64,
"duration_ms": started.elapsed().as_millis() as u64,
"error": msg,
}),
);
msg
})?;
let status = resp.status();
let headers = resp.headers().clone();
if !status.is_success() {
let msg = format!(
"http status {status}; content_type={}; content_encoding={}; content_length={}; transfer_encoding={}",
header_value(&headers, "content-type"),
header_value(&headers, "content-encoding"),
header_value(&headers, "content-length"),
header_value(&headers, "transfer-encoding"),
);
crate::logging::progress::emit!(
"http_fetch_failed",
serde_json::json!({
"uri": uri,
"stage": "status",
"timeout_profile": timeout_profile,
"request_timeout_ms": timeout_value.as_millis() as u64,
"duration_ms": started.elapsed().as_millis() as u64,
"status": status.as_u16(),
"content_type": header_value_opt(&headers, "content-type"),
"content_encoding": header_value_opt(&headers, "content-encoding"),
"content_length": header_value_opt(&headers, "content-length"),
"transfer_encoding": header_value_opt(&headers, "transfer-encoding"),
"error": msg,
}),
);
return Err(msg);
}
let mut resp = resp;
match resp.copy_to(out) {
Ok(bytes) => {
let duration_ms = started.elapsed().as_millis() as u64;
if (duration_ms as f64) / 1000.0 >= crate::logging::progress::slow_threshold_secs()
{
crate::logging::progress::emit!(
"http_fetch_slow",
serde_json::json!({
"uri": uri,
"status": status.as_u16(),
"timeout_profile": timeout_profile,
"request_timeout_ms": timeout_value.as_millis() as u64,
"duration_ms": duration_ms,
"bytes": bytes,
"content_type": header_value_opt(&headers, "content-type"),
"content_encoding": header_value_opt(&headers, "content-encoding"),
"content_length": header_value_opt(&headers, "content-length"),
"transfer_encoding": header_value_opt(&headers, "transfer-encoding"),
}),
);
}
Ok(bytes)
}
Err(e) => {
let msg = format!(
"http stream body failed: {e}; status={}; content_type={}; content_encoding={}; content_length={}; transfer_encoding={}",
status,
header_value(&headers, "content-type"),
header_value(&headers, "content-encoding"),
header_value(&headers, "content-length"),
header_value(&headers, "transfer-encoding"),
);
crate::logging::progress::emit!(
"http_fetch_failed",
serde_json::json!({
"uri": uri,
"stage": "stream_body",
"timeout_profile": timeout_profile,
"request_timeout_ms": timeout_value.as_millis() as u64,
"duration_ms": started.elapsed().as_millis() as u64,
"status": status.as_u16(),
"content_type": header_value_opt(&headers, "content-type"),
"content_encoding": header_value_opt(&headers, "content-encoding"),
"content_length": header_value_opt(&headers, "content-length"),
"transfer_encoding": header_value_opt(&headers, "transfer-encoding"),
"error": msg,
}),
);
Err(msg)
}
}
}
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 {
header_value_opt(headers, name).unwrap_or_else(|| "<none>".to_string())
}
fn header_value_opt(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|v| v.to_string())
}
fn uses_large_body_timeout(uri: &str) -> bool {
uri.starts_with("https://") && uri.ends_with(".xml") && !uri.ends_with("notification.xml")
}
#[cfg(test)]
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;
fn spawn_one_shot_http_server(status_line: &'static str, body: &'static [u8]) -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
let addr = listener.local_addr().expect("addr");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let hdr = format!(
"{status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(hdr.as_bytes()).expect("write hdr");
stream.write_all(body).expect("write body");
});
format!("http://{}/", addr)
}
#[test]
fn fetch_bytes_returns_body_on_success() {
let url = spawn_one_shot_http_server("HTTP/1.1 200 OK", b"hello");
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(2),
..HttpFetcherConfig::default()
})
.expect("http");
let got = http.fetch_bytes(&url).expect("fetch");
assert_eq!(got, b"hello");
}
#[test]
fn fetch_bytes_rejects_non_success_status() {
let url = spawn_one_shot_http_server("HTTP/1.1 404 Not Found", b"");
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(2),
..HttpFetcherConfig::default()
})
.expect("http");
let err = http.fetch_bytes(&url).unwrap_err();
assert!(err.contains("http status"), "{err}");
}
#[test]
fn fetch_bytes_times_out_on_idle_body_read() {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
let addr = listener.local_addr().expect("addr");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nh")
.expect("write partial body");
std::thread::sleep(StdDuration::from_secs(2));
let _ = stream.write_all(b"ello");
});
let url = format!("http://{}/", addr);
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(1),
..HttpFetcherConfig::default()
})
.expect("http");
let err = http.fetch_bytes(&url).unwrap_err();
assert!(err.contains("http read body failed"), "{err}");
}
#[test]
fn fetch_to_writer_streams_body_on_success() {
let url = spawn_one_shot_http_server("HTTP/1.1 200 OK", b"writer-body");
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(2),
..HttpFetcherConfig::default()
})
.expect("http");
let mut out = Vec::new();
let bytes = http.fetch_to_writer(&url, &mut out).expect("stream");
assert_eq!(bytes, 11);
assert_eq!(out, b"writer-body");
}
#[test]
fn fetch_to_writer_rejects_non_success_status() {
let url = spawn_one_shot_http_server("HTTP/1.1 500 Internal Server Error", b"boom");
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(2),
..HttpFetcherConfig::default()
})
.expect("http");
let mut out = Vec::new();
let err = http.fetch_to_writer(&url, &mut out).unwrap_err();
assert!(err.contains("http status"), "{err}");
assert!(out.is_empty());
}
#[test]
fn fetch_to_writer_times_out_on_idle_stream_read() {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
let addr = listener.local_addr().expect("addr");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nh")
.expect("write partial body");
std::thread::sleep(StdDuration::from_secs(2));
let _ = stream.write_all(b"ello");
});
let url = format!("http://{}/", addr);
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(1),
..HttpFetcherConfig::default()
})
.expect("http");
let mut out = Vec::new();
let err = http.fetch_to_writer(&url, &mut out).unwrap_err();
assert!(err.contains("http stream body failed"), "{err}");
}
#[test]
fn uses_large_body_timeout_selects_rrdp_snapshot_and_delta_not_notification() {
assert!(!uses_large_body_timeout(
"https://rrdp.example.test/notification.xml"
));
assert!(uses_large_body_timeout(
"https://rrdp.example.test/session/123/snapshot.xml"
));
assert!(uses_large_body_timeout(
"https://rrdp.example.test/session/123/delta-42.xml"
));
assert!(!uses_large_body_timeout(
"https://tal.example.test/example.tal"
));
}
#[test]
fn client_for_uri_selects_expected_timeout_profile() {
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(3),
large_body_timeout: Duration::from_secs(9),
..HttpFetcherConfig::default()
})
.expect("http");
let (_, profile_short, timeout_short) =
http.client_for_uri("https://example.test/root.tal");
assert_eq!(profile_short, "short");
assert_eq!(timeout_short, Duration::from_secs(3));
let (_, profile_large, timeout_large) =
http.client_for_uri("https://rrdp.example.test/session/1/snapshot.xml");
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

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

View File

@ -0,0 +1,214 @@
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum RsyncFetchError {
#[error("rsync fetch error: {0}")]
Fetch(String),
}
pub type RsyncFetchResult<T> = Result<T, RsyncFetchError>;
pub fn normalize_rsync_base_uri(s: &str) -> String {
if s.ends_with('/') {
s.to_string()
} else {
format!("{s}/")
}
}
/// Fetch repository objects from a publication point.
///
/// v1: this is intentionally abstract so unit tests can use a mock, and later we can
/// back it by calling the system `rsync` binary (RFC 6481 §5; RFC 8182 §3.4.5).
pub trait RsyncFetcher: Send + Sync {
/// Return a list of objects as `(rsync_uri, bytes)` pairs.
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>>;
/// Fetch one object by exact rsync URI.
///
/// The default implementation fetches the parent directory and filters the exact
/// object. Live fetchers should override this to avoid widening one-object TAL
/// bootstrap fetches into whole publication point or module synchronizations.
fn fetch_object(&self, rsync_uri: &str) -> RsyncFetchResult<Vec<u8>> {
let base = parent_rsync_uri(rsync_uri).map_err(RsyncFetchError::Fetch)?;
self.fetch_objects(&base)?
.into_iter()
.find(|(uri, _)| uri == rsync_uri)
.map(|(_, bytes)| bytes)
.ok_or_else(|| RsyncFetchError::Fetch(format!("rsync object not found: {rsync_uri}")))
}
/// Stream fetched objects to a visitor without requiring callers to materialize the
/// full result vector in memory.
fn visit_objects(
&self,
rsync_base_uri: &str,
visitor: &mut dyn FnMut(String, Vec<u8>) -> Result<(), String>,
) -> RsyncFetchResult<(usize, u64)> {
let objects = self.fetch_objects(rsync_base_uri)?;
let mut count = 0usize;
let mut bytes_total = 0u64;
for (uri, bytes) in objects {
bytes_total += bytes.len() as u64;
count += 1;
visitor(uri, bytes).map_err(RsyncFetchError::Fetch)?;
}
Ok((count, bytes_total))
}
/// Return the deduplication key used by orchestration layers.
///
/// By default this is the normalized publication point base URI. Fetchers that
/// intentionally widen their fetch scope (for example to a full rsync module)
/// should override this so callers can safely deduplicate at the same scope.
fn dedup_key(&self, rsync_base_uri: &str) -> String {
normalize_rsync_base_uri(rsync_base_uri)
}
/// Return an optional failure-only deduplication key.
///
/// This key is only used to short-circuit repeated failed rsync fallbacks. It must
/// not be used to reuse successful fetch results unless it is identical to
/// `dedup_key`, because a successful fetch for one publication point does not imply
/// that another publication point under the same host has been fetched.
fn failure_dedup_key(&self, _rsync_base_uri: &str) -> Option<String> {
None
}
}
fn parent_rsync_uri(rsync_uri: &str) -> Result<String, String> {
let parsed = url::Url::parse(rsync_uri).map_err(|e| e.to_string())?;
if parsed.scheme() != "rsync" {
return Err(format!("not an rsync URI: {rsync_uri}"));
}
let host = parsed
.host_str()
.ok_or_else(|| format!("missing host in rsync URI: {rsync_uri}"))?;
let segments = parsed
.path_segments()
.ok_or_else(|| format!("missing path in rsync URI: {rsync_uri}"))?
.collect::<Vec<_>>();
if segments.is_empty() || segments.last().copied().unwrap_or_default().is_empty() {
return Err(format!(
"rsync URI must reference a file object: {rsync_uri}"
));
}
let parent_segments = &segments[..segments.len() - 1];
// Preserve an explicitly supplied port. Dropping it changes the URI
// identity and prevents the default one-object implementation from
// matching objects returned by a fetcher (notably for test/private
// publication points that specify :873 explicitly).
let authority = match parsed.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
let mut parent = format!("rsync://{authority}/");
if !parent_segments.is_empty() {
parent.push_str(&parent_segments.join("/"));
parent.push('/');
}
Ok(parent)
}
/// A simple "rsync" implementation backed by a local directory.
///
/// This is primarily meant for offline tests and fixtures. The key generation mimics rsync URIs:
/// `rsync_base_uri` + relative path (with `/` separators).
#[derive(Clone, Debug)]
pub struct LocalDirRsyncFetcher {
pub root_dir: PathBuf,
}
impl LocalDirRsyncFetcher {
pub fn new(root_dir: impl Into<PathBuf>) -> Self {
Self {
root_dir: root_dir.into(),
}
}
}
impl RsyncFetcher for LocalDirRsyncFetcher {
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
let base = normalize_rsync_base_uri(rsync_base_uri);
let mut out = Vec::new();
walk_dir_collect(&self.root_dir, &self.root_dir, &base, &mut out)
.map_err(RsyncFetchError::Fetch)?;
Ok(out)
}
}
fn walk_dir_collect(
root: &Path,
current: &Path,
rsync_base_uri: &str,
out: &mut Vec<(String, Vec<u8>)>,
) -> Result<(), String> {
let rd = std::fs::read_dir(current).map_err(|e| e.to_string())?;
for entry in rd {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let meta = entry.metadata().map_err(|e| e.to_string())?;
if meta.is_dir() {
walk_dir_collect(root, &path, rsync_base_uri, out)?;
continue;
}
if !meta.is_file() {
continue;
}
let rel = path
.strip_prefix(root)
.map_err(|e| e.to_string())?
.to_string_lossy()
.replace('\\', "/");
let uri = format!("{rsync_base_uri}{rel}");
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
out.push((uri, bytes));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_dir_rsync_fetcher_collects_files_and_normalizes_base_uri() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join("nested")).expect("mkdir");
std::fs::write(tmp.path().join("a.mft"), b"a").expect("write");
std::fs::write(tmp.path().join("nested").join("b.roa"), b"b").expect("write");
let f = LocalDirRsyncFetcher::new(tmp.path());
let mut objects = f
.fetch_objects("rsync://example.net/repo")
.expect("fetch_objects");
objects.sort_by(|(a, _), (b, _)| a.cmp(b));
assert_eq!(objects.len(), 2);
assert_eq!(objects[0].0, "rsync://example.net/repo/a.mft");
assert_eq!(objects[0].1, b"a");
assert_eq!(objects[1].0, "rsync://example.net/repo/nested/b.roa");
assert_eq!(objects[1].1, b"b");
}
#[test]
fn local_dir_rsync_fetcher_reports_read_dir_errors() {
let tmp = tempfile::tempdir().expect("tempdir");
let missing = tmp.path().join("missing");
let f = LocalDirRsyncFetcher::new(missing);
let err = f.fetch_objects("rsync://example.net/repo").unwrap_err();
match err {
RsyncFetchError::Fetch(msg) => assert!(!msg.is_empty()),
}
}
#[test]
fn default_dedup_key_is_normalized_base_uri() {
let tmp = tempfile::tempdir().expect("tempdir");
let fetcher = LocalDirRsyncFetcher::new(tmp.path());
assert_eq!(
fetcher.dedup_key("rsync://example.net/repo"),
"rsync://example.net/repo/"
);
}
}

View File

@ -0,0 +1,927 @@
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::Stdio;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use sha2::Digest;
use uuid::Uuid;
use crate::repository::fetch::rsync::{
RsyncFetchError, RsyncFetchResult, RsyncFetcher, normalize_rsync_base_uri,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum RsyncScopePolicy {
Host,
PublicationPoint,
#[default]
ModuleRoot,
}
impl RsyncScopePolicy {
pub fn parse_cli_value(value: &str) -> Result<Self, String> {
match value {
"host" => Ok(Self::Host),
"publication-point" => Ok(Self::PublicationPoint),
"module-root" => Ok(Self::ModuleRoot),
_ => Err(format!(
"invalid --rsync-scope: {value}; expected host, publication-point, or module-root"
)),
}
}
}
#[derive(Clone, Debug)]
pub struct SystemRsyncConfig {
pub rsync_bin: PathBuf,
pub connect_timeout: Duration,
pub timeout: Duration,
pub extra_args: Vec<String>,
/// Optional root directory for persistent rsync mirrors.
///
/// When set, callers may choose to sync into stable subdirectories under this
/// root (instead of a temporary directory) to benefit from rsync's incremental
/// behavior across runs.
///
/// Note: actual mirror behavior is implemented separately from config wiring.
pub mirror_root: Option<PathBuf>,
pub scope_policy: RsyncScopePolicy,
}
impl Default for SystemRsyncConfig {
fn default() -> Self {
Self {
rsync_bin: PathBuf::from("rsync"),
connect_timeout: Duration::from_secs(15),
timeout: Duration::from_secs(30),
extra_args: Vec::new(),
mirror_root: None,
scope_policy: RsyncScopePolicy::default(),
}
}
}
/// A `RsyncFetcher` implementation backed by the system `rsync` binary.
///
/// This is intended for live synchronization runs. For unit tests and local fixtures,
/// prefer `LocalDirRsyncFetcher`.
#[derive(Clone, Debug)]
pub struct SystemRsyncFetcher {
config: SystemRsyncConfig,
}
thread_local! {
static RSYNC_TIMEOUT_OVERRIDE: RefCell<Option<Duration>> = const { RefCell::new(None) };
static RSYNC_FAIL_FAST_PROFILE: RefCell<Option<RsyncFailFastProfile>> = const { RefCell::new(None) };
}
pub fn with_scoped_rsync_timeout_override<R>(timeout: Duration, f: impl FnOnce() -> R) -> R {
RSYNC_TIMEOUT_OVERRIDE.with(|cell| {
let previous = cell.replace(Some(timeout));
let result = f();
let _ = cell.replace(previous);
result
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RsyncFailFastProfile {
pub initial_wall_clock_timeout: Duration,
pub max_wall_clock_timeout: Duration,
pub max_attempts: usize,
}
pub fn with_scoped_rsync_fail_fast_profile<R>(
profile: RsyncFailFastProfile,
f: impl FnOnce() -> R,
) -> R {
RSYNC_FAIL_FAST_PROFILE.with(|cell| {
let previous = cell.replace(Some(profile));
let result = f();
let _ = cell.replace(previous);
result
})
}
impl SystemRsyncFetcher {
pub fn new(config: SystemRsyncConfig) -> Self {
Self { config }
}
fn mirror_dst_dir(&self, normalized_rsync_base_uri: &str) -> Result<Option<PathBuf>, String> {
let Some(root) = self.config.mirror_root.as_ref() else {
return Ok(None);
};
std::fs::create_dir_all(root)
.map_err(|e| format!("create rsync mirror root failed: {}: {e}", root.display()))?;
let hash = hex::encode(sha2::Sha256::digest(normalized_rsync_base_uri.as_bytes()));
let dir = root.join(hash);
std::fs::create_dir_all(&dir).map_err(|e| {
format!(
"create rsync mirror directory failed: {}: {e}",
dir.display()
)
})?;
Ok(Some(dir))
}
fn run_rsync(&self, src: &str, dst: &Path) -> Result<(), String> {
let fail_fast = RSYNC_FAIL_FAST_PROFILE.with(|cell| *cell.borrow());
if let Some(profile) = fail_fast {
return self.run_rsync_fail_fast(src, dst, profile);
}
self.run_rsync_once(src, dst, None, false)
}
fn run_rsync_once(
&self,
src: &str,
dst: &Path,
wall_clock_timeout: Option<Duration>,
keep_partial: bool,
) -> Result<(), String> {
// `--timeout` is I/O timeout in seconds (applies to network reads/writes).
let timeout =
RSYNC_TIMEOUT_OVERRIDE.with(|cell| cell.borrow().unwrap_or(self.config.timeout));
let connect_timeout_secs = self.config.connect_timeout.as_secs().max(1).to_string();
let timeout_secs = timeout.as_secs().max(1).to_string();
let is_remote_rsync = src.starts_with("rsync://");
let mut cmd = Command::new(&self.config.rsync_bin);
cmd.arg("-rt")
.arg("--delete")
.arg("--timeout")
.arg(timeout_secs)
.args(&self.config.extra_args);
if is_remote_rsync {
cmd.arg("--contimeout").arg(connect_timeout_secs);
}
if keep_partial {
cmd.arg("--partial");
}
cmd.arg(src)
.arg(dst)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| format!("rsync spawn failed: {e}"))?;
if let Some(limit) = wall_clock_timeout {
let started = Instant::now();
loop {
match child
.try_wait()
.map_err(|e| format!("rsync wait failed: {e}"))?
{
Some(_status) => {
let out = child
.wait_with_output()
.map_err(|e| format!("rsync wait_with_output failed: {e}"))?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
return Err(format!(
"rsync failed: status={} stdout={} stderr={}",
out.status,
stdout.trim(),
stderr.trim()
));
}
None => {
if started.elapsed() >= limit {
let _ = child.kill();
let out = child
.wait_with_output()
.map_err(|e| format!("rsync wait_with_output failed: {e}"))?;
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
return Err(format!(
"rsync wall-clock timeout after {}s: stdout={} stderr={}",
limit.as_secs(),
stdout.trim(),
stderr.trim()
));
}
thread::sleep(Duration::from_millis(100));
}
}
}
}
let out = child
.wait_with_output()
.map_err(|e| format!("rsync wait_with_output failed: {e}"))?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
Err(format!(
"rsync failed: status={} stdout={} stderr={}",
out.status,
stdout.trim(),
stderr.trim()
))
}
fn run_rsync_fail_fast(
&self,
src: &str,
dst: &Path,
profile: RsyncFailFastProfile,
) -> Result<(), String> {
let mut attempt = 0usize;
let mut timeout = profile.initial_wall_clock_timeout;
let mut previous_progress = (0usize, 0u64);
let mut zero_progress_attempts = 0usize;
let max_timeout = std::cmp::max(
profile.max_wall_clock_timeout,
profile.initial_wall_clock_timeout,
);
loop {
attempt += 1;
match self.run_rsync_once(src, dst, Some(timeout), true) {
Ok(()) => return Ok(()),
Err(err) => {
if is_hard_fail_rsync_error(&err) {
return Err(format!(
"rsync fail-fast hard-fail on attempt {}: {}",
attempt, err
));
}
if !err.contains("wall-clock timeout") {
return Err(err);
}
let progress = dir_progress(dst)
.map_err(|e| format!("rsync fail-fast progress stat failed: {e}"))?;
if progress == (0, 0) {
zero_progress_attempts += 1;
if zero_progress_attempts >= 2 || attempt >= profile.max_attempts {
return Err(format!(
"rsync fail-fast gave up after {} attempts with no progress: {}",
attempt, err
));
}
} else if progress == previous_progress {
return Err(format!(
"rsync fail-fast gave up after {} attempts with no additional progress: {}",
attempt, err
));
} else {
previous_progress = progress;
}
if attempt >= profile.max_attempts {
return Err(format!(
"rsync fail-fast exhausted {} attempts: {}",
profile.max_attempts, err
));
}
timeout = std::cmp::min(timeout.saturating_mul(2), max_timeout);
}
}
}
}
fn scope_fetch_uri(&self, rsync_base_uri: &str) -> String {
scoped_rsync_fetch_uri(self.config.scope_policy, rsync_base_uri)
}
}
impl RsyncFetcher for SystemRsyncFetcher {
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
let mut out = Vec::new();
self.visit_objects(rsync_base_uri, &mut |uri, bytes| {
out.push((uri, bytes));
Ok(())
})?;
Ok(out)
}
fn fetch_object(&self, rsync_uri: &str) -> RsyncFetchResult<Vec<u8>> {
let parsed =
url::Url::parse(rsync_uri).map_err(|e| RsyncFetchError::Fetch(e.to_string()))?;
if parsed.scheme() != "rsync" {
return Err(RsyncFetchError::Fetch(format!(
"not an rsync URI: {rsync_uri}"
)));
}
let file_name = parsed
.path_segments()
.and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
.ok_or_else(|| {
RsyncFetchError::Fetch(format!(
"rsync URI must reference a file object: {rsync_uri}"
))
})?;
let tmp = TempDir::new().map_err(RsyncFetchError::Fetch)?;
self.run_rsync(rsync_uri, tmp.path())
.map_err(RsyncFetchError::Fetch)?;
let object_path = tmp.path().join(file_name);
std::fs::read(&object_path).map_err(|e| {
RsyncFetchError::Fetch(format!(
"read fetched rsync object failed: {}: {e}",
object_path.display()
))
})
}
fn visit_objects(
&self,
rsync_base_uri: &str,
visitor: &mut dyn FnMut(String, Vec<u8>) -> Result<(), String>,
) -> RsyncFetchResult<(usize, u64)> {
let base = self.scope_fetch_uri(rsync_base_uri);
let mut count = 0usize;
let mut bytes_total = 0u64;
let mut wrapped = |uri: String, bytes: Vec<u8>| -> Result<(), String> {
bytes_total += bytes.len() as u64;
count += 1;
visitor(uri, bytes)
};
if let Some(dst) = self
.mirror_dst_dir(&base)
.map_err(|e| RsyncFetchError::Fetch(e.to_string()))?
{
self.run_rsync(&base, &dst)
.map_err(RsyncFetchError::Fetch)?;
walk_dir_visit(&dst, &dst, &base, &mut wrapped).map_err(RsyncFetchError::Fetch)?;
return Ok((count, bytes_total));
}
let tmp = TempDir::new().map_err(|e| RsyncFetchError::Fetch(e.to_string()))?;
self.run_rsync(&base, tmp.path())
.map_err(RsyncFetchError::Fetch)?;
walk_dir_visit(tmp.path(), tmp.path(), &base, &mut wrapped)
.map_err(RsyncFetchError::Fetch)?;
Ok((count, bytes_total))
}
fn dedup_key(&self, rsync_base_uri: &str) -> String {
self.scope_fetch_uri(rsync_base_uri)
}
fn failure_dedup_key(&self, rsync_base_uri: &str) -> Option<String> {
scoped_rsync_failure_dedup_key(self.config.scope_policy, rsync_base_uri)
}
}
/// Return the exact successful-fetch scope used by the live rsync fetcher.
///
/// The request scope is shared by live and materialized repository paths so their
/// request keys and object projection scope remain equivalent.
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,
}
}
fn rsync_host_scope_uri(rsync_base_uri: &str) -> Option<String> {
let parsed = url::Url::parse(rsync_base_uri).ok()?;
if parsed.scheme() != "rsync" {
return None;
}
Some(format!("rsync://{}/", parsed.host_str()?))
}
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new() -> Result<Self, String> {
let mut p = std::env::temp_dir();
p.push(format!("rpki-system-rsync-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).map_err(|e| e.to_string())?;
Ok(Self { path: p })
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn rsync_module_root_uri(s: &str) -> Option<String> {
let normalized = normalize_rsync_base_uri(s);
let rest = normalized.strip_prefix("rsync://")?;
let (authority, path) = rest.split_once('/')?;
let mut segments: Vec<&str> = path
.split('/')
.filter(|segment| !segment.is_empty())
.collect();
if segments.is_empty() {
return None;
}
let module = segments.remove(0);
Some(format!("rsync://{authority}/{module}/"))
}
fn dir_progress(root: &Path) -> Result<(usize, u64), String> {
if !root.exists() {
return Ok((0, 0));
}
let mut files = 0usize;
let mut bytes = 0u64;
let mut stack = vec![root.to_path_buf()];
while let Some(path) = stack.pop() {
let rd = std::fs::read_dir(&path).map_err(|e| e.to_string())?;
for entry in rd {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let meta = entry.metadata().map_err(|e| e.to_string())?;
if meta.is_dir() {
stack.push(path);
} else if meta.is_file() {
files += 1;
bytes += meta.len();
}
}
}
Ok((files, bytes))
}
fn is_hard_fail_rsync_error(msg: &str) -> bool {
let lower = msg.to_ascii_lowercase();
lower.contains("no route to host")
|| lower.contains("network is unreachable")
|| lower.contains("connection refused")
|| lower.contains("name or service not known")
}
fn walk_dir_visit(
root: &Path,
current: &Path,
rsync_base_uri: &str,
visitor: &mut dyn FnMut(String, Vec<u8>) -> Result<(), String>,
) -> Result<(), String> {
let rd = std::fs::read_dir(current).map_err(|e| e.to_string())?;
for entry in rd {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let meta = entry.metadata().map_err(|e| e.to_string())?;
if meta.is_dir() {
walk_dir_visit(root, &path, rsync_base_uri, visitor)?;
continue;
}
if !meta.is_file() {
continue;
}
let rel = path
.strip_prefix(root)
.map_err(|e| e.to_string())?
.to_string_lossy()
.replace('\\', "/");
let uri = format!("{rsync_base_uri}{rel}");
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
visitor(uri, bytes)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_rsync_base_uri_appends_slash_when_missing() {
assert_eq!(
normalize_rsync_base_uri("rsync://example.net/repo"),
"rsync://example.net/repo/".to_string()
);
assert_eq!(
normalize_rsync_base_uri("rsync://example.net/repo/"),
"rsync://example.net/repo/".to_string()
);
}
#[test]
fn walk_dir_collect_collects_files_and_normalizes_backslashes_in_uri() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
std::fs::create_dir_all(root.join("sub")).expect("mkdir");
std::fs::write(root.join("sub").join("a.cer"), b"x").expect("write");
std::fs::write(root.join("b\\c.mft"), b"y").expect("write backslash file");
let mut out: Vec<(String, Vec<u8>)> = Vec::new();
walk_dir_visit(
root,
root,
"rsync://example.net/repo/",
&mut |uri, bytes| {
out.push((uri, bytes));
Ok(())
},
)
.expect("walk");
out.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(out.len(), 2);
assert_eq!(out[0].0, "rsync://example.net/repo/b/c.mft");
assert_eq!(out[0].1, b"y");
assert_eq!(out[1].0, "rsync://example.net/repo/sub/a.cer");
assert_eq!(out[1].1, b"x");
}
#[test]
fn rsync_module_root_uri_returns_host_and_module_only() {
assert_eq!(
rsync_module_root_uri("rsync://example.net/repo/ta/ca/publication-point/"),
Some("rsync://example.net/repo/".to_string())
);
assert_eq!(
rsync_module_root_uri("rsync://example.net/repo/ta/"),
Some("rsync://example.net/repo/".to_string())
);
assert_eq!(
rsync_module_root_uri("rsync://example.net/repo/"),
Some("rsync://example.net/repo/".to_string())
);
assert_eq!(rsync_module_root_uri("https://example.net/repo"), None);
}
#[test]
fn rsync_host_scope_uri_returns_host_only() {
assert_eq!(
rsync_host_scope_uri("rsync://example.net/repo/ta/ca/publication-point/"),
Some("rsync://example.net/".to_string())
);
assert_eq!(rsync_host_scope_uri("https://example.net/repo"), None);
}
#[test]
fn system_rsync_dedup_key_uses_module_root_by_default() {
let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig::default());
assert_eq!(
fetcher.dedup_key("rsync://example.net/repo/ta/ca/publication-point/"),
"rsync://example.net/repo/"
);
assert_eq!(
fetcher.failure_dedup_key("rsync://example.net/repo/ta/ca/publication-point/"),
None
);
}
#[test]
fn system_rsync_host_scope_does_not_widen_success_fetch_scope() {
let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig {
scope_policy: RsyncScopePolicy::Host,
..SystemRsyncConfig::default()
});
assert_eq!(
fetcher.dedup_key("rsync://example.net/repo/ta/ca/publication-point/"),
"rsync://example.net/repo/ta/ca/publication-point/"
);
assert_eq!(
fetcher.scope_fetch_uri("rsync://example.net/repo/ta/ca/publication-point/"),
"rsync://example.net/repo/ta/ca/publication-point/"
);
assert_eq!(
fetcher.failure_dedup_key("rsync://example.net/repo/ta/ca/publication-point/"),
Some("rsync://example.net/".to_string())
);
}
#[test]
fn system_rsync_dedup_key_uses_publication_point_scope_when_configured() {
let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig {
scope_policy: RsyncScopePolicy::PublicationPoint,
..SystemRsyncConfig::default()
});
assert_eq!(
fetcher.dedup_key("rsync://example.net/repo/ta/ca/publication-point/"),
"rsync://example.net/repo/ta/ca/publication-point/"
);
}
#[test]
fn system_rsync_dedup_key_uses_module_root_when_configured() {
let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig {
scope_policy: RsyncScopePolicy::ModuleRoot,
..SystemRsyncConfig::default()
});
assert_eq!(
fetcher.dedup_key("rsync://example.net/repo/ta/ca/publication-point/"),
"rsync://example.net/repo/"
);
}
#[test]
fn system_rsync_fetcher_reports_spawn_and_exit_errors() {
let dst = tempfile::tempdir().expect("tempdir");
// 1) Spawn error.
let f = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: PathBuf::from("/this/does/not/exist/rsync"),
connect_timeout: Duration::from_secs(1),
timeout: Duration::from_secs(1),
extra_args: Vec::new(),
mirror_root: None,
scope_policy: RsyncScopePolicy::default(),
});
let e = f
.run_rsync("rsync://example.net/repo/", dst.path())
.expect_err("spawn must fail");
assert!(e.contains("rsync spawn failed:"), "{e}");
// 2) Non-zero exit status.
let f = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: PathBuf::from("false"),
connect_timeout: Duration::from_secs(1),
timeout: Duration::from_secs(1),
extra_args: Vec::new(),
mirror_root: None,
scope_policy: RsyncScopePolicy::default(),
});
let e = f
.run_rsync("rsync://example.net/repo/", dst.path())
.expect_err("false must fail");
assert!(e.contains("rsync failed:"), "{e}");
assert!(e.contains("status="), "{e}");
}
#[test]
fn mirror_dst_dir_reports_root_creation_error() {
let temp = tempfile::tempdir().expect("tempdir");
let root_file = temp.path().join("mirror-root-file");
std::fs::write(&root_file, b"not a directory").expect("write root file");
let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: PathBuf::from("rsync"),
connect_timeout: Duration::from_secs(1),
timeout: Duration::from_secs(1),
extra_args: Vec::new(),
mirror_root: Some(root_file.clone()),
scope_policy: RsyncScopePolicy::default(),
});
let err = fetcher
.mirror_dst_dir("rsync://example.net/repo/")
.expect_err("file mirror root must fail");
assert!(err.contains("create rsync mirror root failed"), "{err}");
assert!(err.contains(&root_file.display().to_string()), "{err}");
}
#[cfg(unix)]
#[test]
fn mirror_dst_dir_reports_directory_creation_error_inside_root() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join("mirror");
std::fs::create_dir_all(&root).expect("mkdir root");
let mut perms = std::fs::metadata(&root).expect("metadata").permissions();
perms.set_mode(0o555);
std::fs::set_permissions(&root, perms).expect("chmod root readonly");
let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: PathBuf::from("rsync"),
connect_timeout: Duration::from_secs(1),
timeout: Duration::from_secs(1),
extra_args: Vec::new(),
mirror_root: Some(root.clone()),
scope_policy: RsyncScopePolicy::default(),
});
let err = fetcher
.mirror_dst_dir("rsync://example.net/repo/")
.expect_err("readonly mirror root must fail");
assert!(
err.contains("create rsync mirror directory failed"),
"{err}"
);
let mut perms = std::fs::metadata(&root).expect("metadata").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&root, perms).expect("restore perms");
}
#[cfg(unix)]
#[test]
fn walk_dir_collect_ignores_non_file_entries() {
use std::os::unix::net::UnixListener;
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
std::fs::write(root.join("a.cer"), b"x").expect("write file");
let socket_path = root.join("skip.sock");
let _listener = UnixListener::bind(&socket_path).expect("bind socket");
let mut out: Vec<(String, Vec<u8>)> = Vec::new();
walk_dir_visit(
root,
root,
"rsync://example.net/repo/",
&mut |uri, bytes| {
out.push((uri, bytes));
Ok(())
},
)
.expect("walk");
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, "rsync://example.net/repo/a.cer");
}
#[cfg(unix)]
#[test]
fn rsync_fail_fast_retries_when_progress_is_made() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("tempdir");
let script = temp.path().join("fake-rsync.sh");
let state = temp.path().join("state.txt");
std::fs::write(
&script,
format!(
"#!/usr/bin/env bash\nset -euo pipefail\nSTATE=\"{}\"\nDST=\"${{@: -1}}\"\nCOUNT=0\nif [[ -f \"$STATE\" ]]; then COUNT=$(cat \"$STATE\"); fi\nCOUNT=$((COUNT+1))\necho \"$COUNT\" > \"$STATE\"\nmkdir -p \"$DST\"\nif [[ \"$COUNT\" -eq 1 ]]; then\n echo first > \"$DST/part1\"\n sleep 2\nelse\n echo second > \"$DST/part2\"\nfi\n",
state.display()
),
)
.expect("write script");
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
let dst = temp.path().join("dst");
let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: script,
connect_timeout: Duration::from_secs(15),
timeout: Duration::from_secs(60),
extra_args: Vec::new(),
mirror_root: None,
scope_policy: RsyncScopePolicy::default(),
});
fetcher
.run_rsync_fail_fast(
"rsync://example.net/repo/",
&dst,
RsyncFailFastProfile {
initial_wall_clock_timeout: Duration::from_secs(1),
max_wall_clock_timeout: Duration::from_secs(4),
max_attempts: 3,
},
)
.expect("eventual success");
assert!(dst.join("part1").exists());
assert!(dst.join("part2").exists());
}
#[cfg(unix)]
#[test]
fn rsync_fail_fast_gives_up_after_two_zero_progress_timeouts() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("tempdir");
let script = temp.path().join("fake-rsync.sh");
std::fs::write(&script, "#!/usr/bin/env bash\nset -euo pipefail\nsleep 5\n")
.expect("write script");
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
let dst = temp.path().join("dst");
let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: script,
connect_timeout: Duration::from_secs(15),
timeout: Duration::from_secs(60),
extra_args: Vec::new(),
mirror_root: None,
scope_policy: RsyncScopePolicy::default(),
});
let err = fetcher
.run_rsync_fail_fast(
"rsync://example.net/repo/",
&dst,
RsyncFailFastProfile {
initial_wall_clock_timeout: Duration::from_secs(1),
max_wall_clock_timeout: Duration::from_secs(2),
max_attempts: 4,
},
)
.expect_err("must fail");
assert!(err.contains("no progress"), "{err}");
}
#[cfg(unix)]
#[test]
fn rsync_fail_fast_hard_fail_stops_after_first_attempt() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("tempdir");
let script = temp.path().join("fake-rsync.sh");
let state = temp.path().join("state.txt");
std::fs::write(
&script,
format!(
"#!/usr/bin/env bash\nset -euo pipefail\nSTATE=\"{}\"\nCOUNT=0\nif [[ -f \"$STATE\" ]]; then COUNT=$(cat \"$STATE\"); fi\nCOUNT=$((COUNT+1))\necho \"$COUNT\" > \"$STATE\"\necho 'rsync: [Receiver] failed to connect to host (1.2.3.4): Connection refused (111)' >&2\nexit 10\n",
state.display()
),
)
.expect("write script");
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
let dst = temp.path().join("dst");
let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: script,
connect_timeout: Duration::from_secs(15),
timeout: Duration::from_secs(60),
extra_args: Vec::new(),
mirror_root: None,
scope_policy: RsyncScopePolicy::default(),
});
let err = fetcher
.run_rsync_fail_fast(
"rsync://example.net/repo/",
&dst,
RsyncFailFastProfile {
initial_wall_clock_timeout: Duration::from_secs(10),
max_wall_clock_timeout: Duration::from_secs(80),
max_attempts: 4,
},
)
.expect_err("must hard fail");
assert!(err.contains("hard-fail"), "{err}");
let count = std::fs::read_to_string(&state).unwrap();
assert_eq!(count.trim(), "1");
}
#[cfg(unix)]
#[test]
fn run_rsync_once_passes_contimeout_and_timeout_args() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("tempdir");
let script = temp.path().join("capture-rsync.sh");
let args_file = temp.path().join("args.txt");
std::fs::write(
&script,
format!(
"#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' \"$@\" > \"{}\"\nDST=\"${{@: -1}}\"\nmkdir -p \"$DST\"\n",
args_file.display()
),
)
.expect("write script");
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
let dst = temp.path().join("dst");
let fetcher = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: script,
connect_timeout: Duration::from_secs(15),
timeout: Duration::from_secs(30),
extra_args: Vec::new(),
mirror_root: None,
scope_policy: RsyncScopePolicy::default(),
});
fetcher
.run_rsync_once("rsync://example.net/repo/", &dst, None, false)
.expect("rsync");
let args = std::fs::read_to_string(&args_file).expect("read args");
assert!(args.contains("--contimeout\n15\n"), "{args}");
assert!(args.contains("--timeout\n30\n"), "{args}");
}
}

6
src/repository/mod.rs Normal file
View File

@ -0,0 +1,6 @@
//! Repository transport, protocol state, and object storage.
pub mod blob_store;
pub mod current_repo_index;
pub mod fetch;
pub mod storage;
pub mod sync;

43
src/repository/storage.rs Normal file
View File

@ -0,0 +1,43 @@
mod config;
mod keys;
mod memory;
mod pack;
use std::collections::HashSet;
use std::path::Path;
use rocksdb::checkpoint::Checkpoint;
use rocksdb::{ColumnFamily, DB, Direction, IteratorMode, Options, WriteBatch};
use serde::{Deserialize, Serialize};
use crate::repository::blob_store::{ExternalRawStoreDb, ExternalRepoBytesDb, RawObjectStore};
use config::*;
pub use config::{
ALL_COLUMN_FAMILY_NAMES, CF_MANIFEST_ANTI_ROLLBACK, CF_RAW_BY_HASH, CF_REPOSITORY_VIEW,
CF_RRDP_SOURCE, CF_RRDP_SOURCE_MEMBER, CF_RRDP_URI_OWNER, column_family_descriptors,
};
use keys::*;
pub(crate) use memory::memory_db_snapshot_for_column_families;
pub use memory::{
RocksDbColumnFamilyMemoryProperties, RocksDbMemoryDbSnapshot, RocksDbMemoryProperties,
RocksDbMemorySnapshot, RocksDbMemoryTotals,
};
use pack::compute_sha256_32;
pub use pack::{PackBytes, PackFile, PackTime};
pub struct RocksStore {
db: DB,
external_raw_store: Option<ExternalRawStoreDb>,
external_repo_bytes: Option<ExternalRepoBytesDb>,
}
include!("storage/models_core.rs");
include!("storage/models_summary.rs");
include!("storage/store_lifecycle.rs");
include!("storage/store_repository.rs");
include!("storage/store_manifest.rs");
include!("storage/store_transport_rrdp.rs");
include!("storage/verification.rs");
#[cfg(test)]
#[path = "storage/tests.rs"]
mod tests;

View File

@ -0,0 +1,43 @@
use rocksdb::{ColumnFamilyDescriptor, DBCompressionType, Options};
pub const CF_REPOSITORY_VIEW: &str = "repository_view";
pub const CF_RAW_BY_HASH: &str = "raw_by_hash";
pub const CF_RAW_BLOB: &str = "raw_blob";
/// Persistent RFC 9286 manifest freshness metadata. This is protocol state,
/// not a validation-result cache.
pub const CF_MANIFEST_ANTI_ROLLBACK: &str = "manifest_anti_rollback";
pub const CF_RRDP_SOURCE: &str = "rrdp_source";
pub const CF_RRDP_SOURCE_MEMBER: &str = "rrdp_source_member";
pub const CF_RRDP_URI_OWNER: &str = "rrdp_uri_owner";
pub const ALL_COLUMN_FAMILY_NAMES: &[&str] = &[
CF_REPOSITORY_VIEW,
CF_RAW_BY_HASH,
CF_RAW_BLOB,
CF_MANIFEST_ANTI_ROLLBACK,
CF_RRDP_SOURCE,
CF_RRDP_SOURCE_MEMBER,
CF_RRDP_URI_OWNER,
];
pub(super) const REPOSITORY_VIEW_KEY_PREFIX: &str = "repo_view:";
pub(super) const RAW_BY_HASH_KEY_PREFIX: &str = "rawbyhash:";
pub(super) const RAW_BLOB_KEY_PREFIX: &str = "rawblob:";
pub(super) const MANIFEST_ANTI_ROLLBACK_KEY_PREFIX: &str = "manifest_anti_rollback:";
pub(super) const RRDP_SOURCE_KEY_PREFIX: &str = "rrdp_source:";
pub(super) const RRDP_SOURCE_MEMBER_KEY_PREFIX: &str = "rrdp_source_member:";
pub(super) const RRDP_URI_OWNER_KEY_PREFIX: &str = "rrdp_uri_owner:";
pub(super) fn configure_work_db_options(opts: &mut Options) {
opts.set_compression_type(DBCompressionType::Lz4);
}
pub fn column_family_descriptors() -> Vec<ColumnFamilyDescriptor> {
ALL_COLUMN_FAMILY_NAMES
.iter()
.map(|name| {
let mut opts = Options::default();
configure_work_db_options(&mut opts);
ColumnFamilyDescriptor::new(*name, opts)
})
.collect()
}

View File

@ -0,0 +1,130 @@
use serde::{Serialize, de::DeserializeOwned};
use super::config::*;
use super::pack::PackTime;
use super::{StorageError, StorageResult};
pub(super) fn repository_view_key(rsync_uri: &str) -> String {
format!("{REPOSITORY_VIEW_KEY_PREFIX}{rsync_uri}")
}
pub(super) fn repository_view_prefix(rsync_uri_prefix: &str) -> String {
format!("{REPOSITORY_VIEW_KEY_PREFIX}{rsync_uri_prefix}")
}
pub(super) fn raw_by_hash_key(sha256_hex: &str) -> String {
format!("{RAW_BY_HASH_KEY_PREFIX}{sha256_hex}")
}
pub(super) fn raw_blob_key(sha256_hex: &str) -> String {
format!("{RAW_BLOB_KEY_PREFIX}{sha256_hex}")
}
pub(super) fn manifest_anti_rollback_key(manifest_rsync_uri: &str) -> String {
format!("{MANIFEST_ANTI_ROLLBACK_KEY_PREFIX}{manifest_rsync_uri}")
}
pub(super) fn rrdp_source_key(notify_uri: &str) -> String {
format!("{RRDP_SOURCE_KEY_PREFIX}{notify_uri}")
}
pub(super) fn rrdp_source_member_key(notify_uri: &str, rsync_uri: &str) -> String {
format!("{RRDP_SOURCE_MEMBER_KEY_PREFIX}{notify_uri}:{rsync_uri}")
}
pub(super) fn rrdp_source_member_prefix(notify_uri: &str) -> String {
format!("{RRDP_SOURCE_MEMBER_KEY_PREFIX}{notify_uri}:")
}
pub(super) fn rrdp_uri_owner_key(rsync_uri: &str) -> String {
format!("{RRDP_URI_OWNER_KEY_PREFIX}{rsync_uri}")
}
pub(super) fn encode_cbor<T: Serialize>(value: &T, entity: &'static str) -> StorageResult<Vec<u8>> {
serde_cbor::to_vec(value).map_err(|e| StorageError::Codec {
entity,
detail: e.to_string(),
})
}
pub(super) fn decode_cbor<T: DeserializeOwned>(
bytes: &[u8],
entity: &'static str,
) -> StorageResult<T> {
serde_cbor::from_slice(bytes).map_err(|e| StorageError::Codec {
entity,
detail: e.to_string(),
})
}
pub(super) fn validate_non_empty(field: &'static str, value: &str) -> StorageResult<()> {
if value.is_empty() {
return Err(StorageError::InvalidData {
entity: field,
detail: "must not be empty".to_string(),
});
}
Ok(())
}
pub(super) fn validate_sha256_hex(field: &'static str, value: &str) -> StorageResult<()> {
if value.len() != 64 || !value.as_bytes().iter().all(u8::is_ascii_hexdigit) {
return Err(StorageError::InvalidData {
entity: field,
detail: "must be a 64-character lowercase or uppercase SHA-256 hex string".to_string(),
});
}
Ok(())
}
pub(super) fn decode_sha256_hex_32(field: &'static str, value: &str) -> StorageResult<[u8; 32]> {
validate_sha256_hex(field, value)?;
let mut out = [0u8; 32];
hex::decode_to_slice(value, &mut out).map_err(|e| StorageError::InvalidData {
entity: field,
detail: format!("hex decode failed: {e}"),
})?;
Ok(out)
}
pub(super) fn validate_manifest_number_be(field: &'static str, value: &[u8]) -> StorageResult<()> {
if value.is_empty() {
return Err(StorageError::InvalidData {
entity: field,
detail: "must not be empty".to_string(),
});
}
if value.len() > 20 {
return Err(StorageError::InvalidData {
entity: field,
detail: "must be at most 20 octets".to_string(),
});
}
if value.len() > 1 && value[0] == 0 {
return Err(StorageError::InvalidData {
entity: field,
detail: "must be minimal big-endian without leading zeros".to_string(),
});
}
Ok(())
}
pub(super) fn validate_sha256_digest_bytes(field: &'static str, value: &[u8]) -> StorageResult<()> {
if value.len() != 32 {
return Err(StorageError::InvalidData {
entity: field,
detail: format!("must be 32 bytes, got {}", value.len()),
});
}
Ok(())
}
pub(super) fn parse_time(
field: &'static str,
value: &PackTime,
) -> StorageResult<time::OffsetDateTime> {
value.parse().map_err(|detail| StorageError::InvalidData {
entity: field,
detail,
})
}

View File

@ -0,0 +1,191 @@
//! RocksDB and process-memory observation.
//!
//! This module owns the serialization-friendly memory snapshot model and the
//! mapping from RocksDB property names to that model. Storage mutation stays
//! in the parent module.
use rocksdb::DB;
use serde::Serialize;
const ROCKSDB_MEMORY_PROPERTY_NAMES: &[(&str, &str)] = &[
("cur_size_all_mem_tables", "rocksdb.cur-size-all-mem-tables"),
("size_all_mem_tables", "rocksdb.size-all-mem-tables"),
(
"estimate_table_readers_mem",
"rocksdb.estimate-table-readers-mem",
),
("block_cache_capacity", "rocksdb.block-cache-capacity"),
("block_cache_usage", "rocksdb.block-cache-usage"),
(
"block_cache_pinned_usage",
"rocksdb.block-cache-pinned-usage",
),
("num_snapshots", "rocksdb.num-snapshots"),
("background_errors", "rocksdb.background-errors"),
];
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct RocksDbMemoryProperties {
pub cur_size_all_mem_tables: Option<u64>,
pub size_all_mem_tables: Option<u64>,
pub estimate_table_readers_mem: Option<u64>,
pub block_cache_capacity: Option<u64>,
pub block_cache_usage: Option<u64>,
pub block_cache_pinned_usage: Option<u64>,
pub num_snapshots: Option<u64>,
pub background_errors: Option<u64>,
pub errors: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct RocksDbColumnFamilyMemoryProperties {
pub name: String,
pub properties: RocksDbMemoryProperties,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct RocksDbMemoryDbSnapshot {
pub label: String,
pub properties: RocksDbMemoryProperties,
pub column_families: Vec<RocksDbColumnFamilyMemoryProperties>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct RocksDbMemoryTotals {
pub cur_size_all_mem_tables: u64,
pub size_all_mem_tables: u64,
pub estimate_table_readers_mem: u64,
pub block_cache_capacity: u64,
pub block_cache_usage: u64,
pub block_cache_pinned_usage: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct RocksDbMemorySnapshot {
pub databases: Vec<RocksDbMemoryDbSnapshot>,
pub totals: RocksDbMemoryTotals,
}
impl RocksDbMemoryTotals {
pub(super) fn add_properties(&mut self, properties: &RocksDbMemoryProperties) {
self.cur_size_all_mem_tables += properties.cur_size_all_mem_tables.unwrap_or(0);
self.size_all_mem_tables += properties.size_all_mem_tables.unwrap_or(0);
self.estimate_table_readers_mem += properties.estimate_table_readers_mem.unwrap_or(0);
self.block_cache_capacity += properties.block_cache_capacity.unwrap_or(0);
self.block_cache_usage += properties.block_cache_usage.unwrap_or(0);
self.block_cache_pinned_usage += properties.block_cache_pinned_usage.unwrap_or(0);
}
}
fn set_memory_property(properties: &mut RocksDbMemoryProperties, name: &str, value: u64) {
match name {
"cur_size_all_mem_tables" => properties.cur_size_all_mem_tables = Some(value),
"size_all_mem_tables" => properties.size_all_mem_tables = Some(value),
"estimate_table_readers_mem" => properties.estimate_table_readers_mem = Some(value),
"block_cache_capacity" => properties.block_cache_capacity = Some(value),
"block_cache_usage" => properties.block_cache_usage = Some(value),
"block_cache_pinned_usage" => properties.block_cache_pinned_usage = Some(value),
"num_snapshots" => properties.num_snapshots = Some(value),
"background_errors" => properties.background_errors = Some(value),
_ => {}
}
}
fn parse_rocksdb_property_int(raw: Option<String>) -> Option<u64> {
raw.and_then(|value| value.trim().parse::<u64>().ok())
}
fn memory_properties_for_db(db: &DB) -> RocksDbMemoryProperties {
let mut properties = RocksDbMemoryProperties::default();
for (field_name, property_name) in ROCKSDB_MEMORY_PROPERTY_NAMES {
match db.property_value(*property_name) {
Ok(value) => {
if let Some(parsed) = parse_rocksdb_property_int(value) {
set_memory_property(&mut properties, field_name, parsed);
}
}
Err(err) => properties.errors.push(format!("{property_name}: {}", err)),
}
}
properties
}
fn memory_properties_for_cf(db: &DB, cf_name: &'static str) -> RocksDbColumnFamilyMemoryProperties {
let mut properties = RocksDbMemoryProperties::default();
let Some(cf) = db.cf_handle(cf_name) else {
properties
.errors
.push(format!("missing column family: {cf_name}"));
return RocksDbColumnFamilyMemoryProperties {
name: cf_name.to_string(),
properties,
};
};
for (field_name, property_name) in ROCKSDB_MEMORY_PROPERTY_NAMES {
match db.property_value_cf(cf, *property_name) {
Ok(value) => {
if let Some(parsed) = parse_rocksdb_property_int(value) {
set_memory_property(&mut properties, field_name, parsed);
}
}
Err(err) => properties.errors.push(format!("{property_name}: {}", err)),
}
}
RocksDbColumnFamilyMemoryProperties {
name: cf_name.to_string(),
properties,
}
}
pub(crate) fn memory_db_snapshot_for_column_families(
label: impl Into<String>,
db: &DB,
column_families: Option<&[&'static str]>,
) -> RocksDbMemoryDbSnapshot {
RocksDbMemoryDbSnapshot {
label: label.into(),
properties: memory_properties_for_db(db),
column_families: column_families
.map(|names| {
names
.iter()
.map(|name| memory_properties_for_cf(db, name))
.collect()
})
.unwrap_or_default(),
}
}
#[cfg(test)]
mod tests {
use super::{
RocksDbMemoryProperties, RocksDbMemoryTotals, parse_rocksdb_property_int,
set_memory_property,
};
#[test]
fn parses_rocksdb_integer_properties_without_panicking() {
assert_eq!(
parse_rocksdb_property_int(Some(" 42 ".to_string())),
Some(42)
);
assert_eq!(
parse_rocksdb_property_int(Some("not-a-number".to_string())),
None
);
assert_eq!(parse_rocksdb_property_int(None), None);
}
#[test]
fn aggregates_only_known_memory_properties() {
let mut properties = RocksDbMemoryProperties::default();
set_memory_property(&mut properties, "block_cache_usage", 11);
set_memory_property(&mut properties, "unknown", 99);
let mut totals = RocksDbMemoryTotals::default();
totals.add_properties(&properties);
assert_eq!(totals.block_cache_usage, 11);
assert_eq!(totals.block_cache_capacity, 0);
}
}

View File

@ -0,0 +1,153 @@
// Repository view, raw-object records, and protocol rollback metadata.
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("rocksdb error: {0}")]
RocksDb(String),
#[error("missing column family: {0}")]
MissingColumnFamily(&'static str),
#[error("cbor codec error for {entity}: {detail}")]
Codec { entity: &'static str, detail: String },
#[error("invalid {entity}: {detail}")]
InvalidData { entity: &'static str, detail: String },
}
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, PartialEq, Eq, Serialize, Deserialize)]
pub enum RrdpDeltaOp {
Upsert { rsync_uri: String, bytes: Vec<u8> },
Delete { rsync_uri: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RepositoryViewState {
Present,
Withdrawn,
Replaced,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryViewEntry {
pub rsync_uri: String,
pub current_hash: Option<String>,
pub repository_source: Option<String>,
pub object_type: Option<String>,
pub state: RepositoryViewState,
}
impl RepositoryViewEntry {
pub fn validate_internal(&self) -> StorageResult<()> {
validate_non_empty("repository_view.rsync_uri", &self.rsync_uri)?;
if let Some(source) = &self.repository_source {
validate_non_empty("repository_view.repository_source", source)?;
}
match self.state {
RepositoryViewState::Present | RepositoryViewState::Replaced => {
let hash = self.current_hash.as_deref().ok_or(StorageError::InvalidData {
entity: "repository_view",
detail: "current_hash is required when state is present or replaced".to_string(),
})?;
validate_sha256_hex("repository_view.current_hash", hash)?;
}
RepositoryViewState::Withdrawn => {
if let Some(hash) = &self.current_hash {
validate_sha256_hex("repository_view.current_hash", hash)?;
}
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RawByHashEntry {
pub sha256_hex: String,
pub bytes: Vec<u8>,
pub origin_uris: Vec<String>,
pub object_type: Option<String>,
pub encoding: Option<String>,
}
impl RawByHashEntry {
pub fn from_bytes(sha256_hex: impl Into<String>, bytes: Vec<u8>) -> Self {
Self { sha256_hex: sha256_hex.into(), bytes, origin_uris: Vec::new(), object_type: None, encoding: None }
}
pub fn validate_internal(&self) -> StorageResult<()> {
validate_sha256_hex("raw_by_hash.sha256_hex", &self.sha256_hex)?;
if self.bytes.is_empty() {
return Err(StorageError::InvalidData { entity: "raw_by_hash", detail: "bytes must not be empty".to_string() });
}
let computed = hex::encode(compute_sha256_32(&self.bytes));
if computed != self.sha256_hex.to_ascii_lowercase() {
return Err(StorageError::InvalidData { entity: "raw_by_hash", detail: "sha256_hex does not match bytes".to_string() });
}
let mut seen = HashSet::with_capacity(self.origin_uris.len());
for uri in &self.origin_uris {
validate_non_empty("raw_by_hash.origin_uris[]", uri)?;
if !seen.insert(uri.as_str()) {
return Err(StorageError::InvalidData { entity: "raw_by_hash", detail: format!("duplicate origin URI: {uri}") });
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CurrentObjectWithHash {
pub current_hash_hex: String,
pub current_hash: [u8; 32],
pub bytes: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidatedManifestMeta {
pub validated_manifest_number: Vec<u8>,
pub validated_manifest_this_update: PackTime,
pub validated_manifest_next_update: PackTime,
}
impl ValidatedManifestMeta {
pub fn validate_internal(&self) -> StorageResult<()> {
validate_manifest_number_be("validated_manifest_meta.validated_manifest_number", &self.validated_manifest_number)?;
let this_update = parse_time("validated_manifest_meta.validated_manifest_this_update", &self.validated_manifest_this_update)?;
let next_update = parse_time("validated_manifest_meta.validated_manifest_next_update", &self.validated_manifest_next_update)?;
if next_update < this_update {
return Err(StorageError::InvalidData { entity: "validated_manifest_meta", detail: "validated_manifest_next_update must be >= validated_manifest_this_update".to_string() });
}
Ok(())
}
}
/// Persistent manifest freshness metadata used by RFC 9286 anti-rollback
/// checks. It stores no validated object output.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestAntiRollbackMeta {
pub manifest_rsync_uri: String,
pub manifest_number_be: Vec<u8>,
pub manifest_this_update: PackTime,
pub manifest_sha256: Vec<u8>,
pub updated_at_validation_time: PackTime,
}
impl ManifestAntiRollbackMeta {
pub fn validate_internal(&self) -> StorageResult<()> {
validate_non_empty("manifest_anti_rollback.manifest_rsync_uri", &self.manifest_rsync_uri)?;
validate_manifest_number_be("manifest_anti_rollback.manifest_number_be", &self.manifest_number_be)?;
parse_time("manifest_anti_rollback.manifest_this_update", &self.manifest_this_update)?;
validate_sha256_digest_bytes("manifest_anti_rollback.manifest_sha256", &self.manifest_sha256)?;
parse_time("manifest_anti_rollback.updated_at_validation_time", &self.updated_at_validation_time)?;
Ok(())
}
}
pub use crate::ccr::projection::CcrManifestProjection;

View File

@ -0,0 +1,117 @@
// RRDP protocol state records.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RrdpSourceSyncState {
Empty,
SnapshotOnly,
DeltaReady,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RrdpSourceRecord {
pub notify_uri: String,
pub last_session_id: Option<String>,
pub last_serial: Option<u64>,
pub first_seen_at: PackTime,
pub last_seen_at: PackTime,
pub last_sync_at: Option<PackTime>,
pub sync_state: RrdpSourceSyncState,
pub last_snapshot_uri: Option<String>,
pub last_snapshot_hash: Option<String>,
pub last_error: Option<String>,
}
impl RrdpSourceRecord {
pub fn validate_internal(&self) -> StorageResult<()> {
validate_non_empty("rrdp_source.notify_uri", &self.notify_uri)?;
if let Some(session_id) = &self.last_session_id {
validate_non_empty("rrdp_source.last_session_id", session_id)?;
}
parse_time("rrdp_source.first_seen_at", &self.first_seen_at)?;
parse_time("rrdp_source.last_seen_at", &self.last_seen_at)?;
if let Some(last_sync_at) = &self.last_sync_at {
parse_time("rrdp_source.last_sync_at", last_sync_at)?;
}
if let Some(last_snapshot_uri) = &self.last_snapshot_uri {
validate_non_empty("rrdp_source.last_snapshot_uri", last_snapshot_uri)?;
}
if let Some(last_snapshot_hash) = &self.last_snapshot_hash {
validate_sha256_hex("rrdp_source.last_snapshot_hash", last_snapshot_hash)?;
}
if let Some(last_error) = &self.last_error {
validate_non_empty("rrdp_source.last_error", last_error)?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RrdpSourceMemberRecord {
pub notify_uri: String,
pub rsync_uri: String,
pub current_hash: Option<String>,
pub object_type: Option<String>,
pub present: bool,
pub last_confirmed_session_id: String,
pub last_confirmed_serial: u64,
pub last_changed_at: PackTime,
}
impl RrdpSourceMemberRecord {
pub fn validate_internal(&self) -> StorageResult<()> {
validate_non_empty("rrdp_source_member.notify_uri", &self.notify_uri)?;
validate_non_empty("rrdp_source_member.rsync_uri", &self.rsync_uri)?;
validate_non_empty(
"rrdp_source_member.last_confirmed_session_id",
&self.last_confirmed_session_id,
)?;
if self.present {
let hash = self.current_hash.as_deref().ok_or(StorageError::InvalidData {
entity: "rrdp_source_member",
detail: "current_hash is required when present=true".to_string(),
})?;
validate_sha256_hex("rrdp_source_member.current_hash", hash)?;
} else if let Some(hash) = &self.current_hash {
validate_sha256_hex("rrdp_source_member.current_hash", hash)?;
}
parse_time("rrdp_source_member.last_changed_at", &self.last_changed_at)?;
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RrdpUriOwnerState {
Active,
Conflict,
Withdrawn,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RrdpUriOwnerRecord {
pub rsync_uri: String,
pub notify_uri: String,
pub current_hash: Option<String>,
pub last_confirmed_session_id: String,
pub last_confirmed_serial: u64,
pub last_changed_at: PackTime,
pub owner_state: RrdpUriOwnerState,
}
impl RrdpUriOwnerRecord {
pub fn validate_internal(&self) -> StorageResult<()> {
validate_non_empty("rrdp_uri_owner.rsync_uri", &self.rsync_uri)?;
validate_non_empty("rrdp_uri_owner.notify_uri", &self.notify_uri)?;
validate_non_empty(
"rrdp_uri_owner.last_confirmed_session_id",
&self.last_confirmed_session_id,
)?;
if let Some(hash) = &self.current_hash {
validate_sha256_hex("rrdp_uri_owner.current_hash", hash)?;
}
parse_time("rrdp_uri_owner.last_changed_at", &self.last_changed_at)?;
Ok(())
}
}

View File

@ -0,0 +1,200 @@
use serde::{Deserialize, Serialize};
use sha2::Digest;
use crate::repository::blob_store::{ExternalRawStoreDb, ExternalRepoBytesDb, RawObjectStore};
#[derive(Clone, Debug)]
pub enum PackBytes {
Eager(std::sync::Arc<[u8]>),
LazyExternal {
sha256_hex: String,
store: std::sync::Arc<ExternalRawStoreDb>,
cache: std::sync::Arc<std::sync::OnceLock<std::sync::Arc<[u8]>>>,
},
LazyRepoBytes {
sha256_hex: String,
store: std::sync::Arc<ExternalRepoBytesDb>,
cache: std::sync::Arc<std::sync::OnceLock<std::sync::Arc<[u8]>>>,
},
}
impl PackBytes {
pub fn eager(bytes: Vec<u8>) -> Self {
Self::Eager(std::sync::Arc::from(bytes))
}
pub fn lazy_external(sha256_hex: String, store: std::sync::Arc<ExternalRawStoreDb>) -> Self {
Self::LazyExternal {
sha256_hex,
store,
cache: std::sync::Arc::new(std::sync::OnceLock::new()),
}
}
pub fn lazy_repo_bytes(sha256_hex: String, store: std::sync::Arc<ExternalRepoBytesDb>) -> Self {
Self::LazyRepoBytes {
sha256_hex,
store,
cache: std::sync::Arc::new(std::sync::OnceLock::new()),
}
}
pub fn as_slice(&self) -> Result<&[u8], String> {
match self {
Self::Eager(bytes) => Ok(bytes.as_ref()),
Self::LazyExternal {
sha256_hex,
store,
cache,
} => {
if cache.get().is_none() {
let bytes = store
.get_blob_bytes(sha256_hex)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("missing raw blob for sha256={sha256_hex}"))?;
let _ = cache.set(std::sync::Arc::from(bytes));
}
let bytes = cache
.get()
.ok_or_else(|| format!("missing raw blob cache for sha256={sha256_hex}"))?;
Ok(bytes.as_ref())
}
Self::LazyRepoBytes {
sha256_hex,
store,
cache,
} => {
if cache.get().is_none() {
let bytes = store
.get_blob_bytes(sha256_hex)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("missing repo bytes for sha256={sha256_hex}"))?;
let _ = cache.set(std::sync::Arc::from(bytes));
}
let bytes = cache
.get()
.ok_or_else(|| format!("missing repo bytes cache for sha256={sha256_hex}"))?;
Ok(bytes.as_ref())
}
}
}
pub fn to_vec(&self) -> Result<Vec<u8>, String> {
Ok(self.as_slice()?.to_vec())
}
}
impl PartialEq for PackBytes {
fn eq(&self, other: &Self) -> bool {
match (self.as_slice(), other.as_slice()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
}
impl Eq for PackBytes {}
#[derive(Clone, Debug)]
pub struct PackFile {
pub rsync_uri: String,
pub bytes: PackBytes,
pub sha256: [u8; 32],
}
impl PackFile {
pub fn new(rsync_uri: impl Into<String>, bytes: PackBytes, sha256: [u8; 32]) -> Self {
Self {
rsync_uri: rsync_uri.into(),
bytes,
sha256,
}
}
pub fn from_bytes_with_sha256(
rsync_uri: impl Into<String>,
bytes: Vec<u8>,
sha256: [u8; 32],
) -> Self {
Self::new(rsync_uri, PackBytes::eager(bytes), sha256)
}
pub fn from_lazy_external_raw_store(
rsync_uri: impl Into<String>,
sha256_hex: String,
sha256: [u8; 32],
store: std::sync::Arc<ExternalRawStoreDb>,
) -> Self {
Self::new(
rsync_uri,
PackBytes::lazy_external(sha256_hex, store),
sha256,
)
}
pub fn from_lazy_repo_bytes(
rsync_uri: impl Into<String>,
sha256_hex: String,
sha256: [u8; 32],
store: std::sync::Arc<ExternalRepoBytesDb>,
) -> Self {
Self::new(
rsync_uri,
PackBytes::lazy_repo_bytes(sha256_hex, store),
sha256,
)
}
pub fn from_bytes_compute_sha256(rsync_uri: impl Into<String>, bytes: Vec<u8>) -> Self {
let sha256 = compute_sha256_32(&bytes);
Self::new(rsync_uri, PackBytes::eager(bytes), sha256)
}
pub fn bytes(&self) -> Result<&[u8], String> {
self.bytes.as_slice()
}
pub fn bytes_cloned(&self) -> Result<Vec<u8>, String> {
self.bytes.to_vec()
}
pub fn compute_sha256(&self) -> Result<[u8; 32], String> {
Ok(compute_sha256_32(self.bytes()?))
}
}
impl PartialEq for PackFile {
fn eq(&self, other: &Self) -> bool {
self.rsync_uri == other.rsync_uri
&& self.sha256 == other.sha256
&& self.bytes == other.bytes
}
}
impl Eq for PackFile {}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackTime {
pub rfc3339_utc: String,
}
impl PackTime {
pub fn from_utc_offset_datetime(t: time::OffsetDateTime) -> Self {
use time::format_description::well_known::Rfc3339;
let utc = t.to_offset(time::UtcOffset::UTC);
let s = utc.format(&Rfc3339).expect("format RFC 3339 UTC time");
Self { rfc3339_utc: s }
}
pub fn parse(&self) -> Result<time::OffsetDateTime, String> {
use time::format_description::well_known::Rfc3339;
time::OffsetDateTime::parse(&self.rfc3339_utc, &Rfc3339).map_err(|e| e.to_string())
}
}
pub(super) fn compute_sha256_32(bytes: &[u8]) -> [u8; 32] {
let digest = sha2::Sha256::digest(bytes);
let mut out = [0u8; 32];
out.copy_from_slice(&digest);
out
}

View File

@ -0,0 +1,158 @@
// RocksDB lifecycle and external repository stores.
impl RocksStore {
pub fn create_read_only_checkpoint(source: &Path, destination: &Path) -> StorageResult<()> {
reject_unsupported_column_families(source)?;
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();
configure_work_db_options(&mut options);
let descriptors = column_family_descriptors();
let db = DB::open_cf_descriptors_read_only(
&options,
source,
descriptors,
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> {
reject_unsupported_column_families(path)?;
let mut base_opts = Options::default();
base_opts.create_if_missing(true);
base_opts.create_missing_column_families(true);
configure_work_db_options(&mut base_opts);
let descriptors = column_family_descriptors();
let db = DB::open_cf_descriptors(
&base_opts,
path,
descriptors,
)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(Self {
db,
external_raw_store: None,
external_repo_bytes: None,
})
}
pub fn open_with_external_raw_store(path: &Path, raw_store_path: &Path) -> StorageResult<Self> {
Self::open_with_external_stores(path, Some(raw_store_path), None)
}
pub fn open_with_external_repo_bytes(
path: &Path,
repo_bytes_path: &Path,
) -> StorageResult<Self> {
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>,
repo_bytes_path: Option<&Path>,
) -> StorageResult<Self> {
let mut store = Self::open(path)?;
if let Some(raw_store_path) = raw_store_path {
store.external_raw_store = Some(ExternalRawStoreDb::open(raw_store_path)?);
}
if let Some(repo_bytes_path) = repo_bytes_path {
store.external_repo_bytes = Some(ExternalRepoBytesDb::open(repo_bytes_path)?);
}
Ok(store)
}
pub(crate) fn external_raw_store_ref(&self) -> Option<&ExternalRawStoreDb> {
self.external_raw_store.as_ref()
}
pub(crate) fn external_repo_bytes_ref(&self) -> Option<&ExternalRepoBytesDb> {
self.external_repo_bytes.as_ref()
}
pub fn memory_snapshot(&self) -> RocksDbMemorySnapshot {
let mut databases = Vec::new();
databases.push(memory_db_snapshot_for_column_families(
"work-db",
&self.db,
Some(ALL_COLUMN_FAMILY_NAMES),
));
if let Some(raw_store) = self.external_raw_store.as_ref() {
databases.push(raw_store.memory_snapshot("raw-store.db"));
}
if let Some(repo_bytes) = self.external_repo_bytes.as_ref() {
databases.push(repo_bytes.memory_snapshot("repo-bytes.db"));
}
let mut totals = RocksDbMemoryTotals::default();
for db in &databases {
totals.add_properties(&db.properties);
}
RocksDbMemorySnapshot { databases, totals }
}
fn cf(&self, name: &'static str) -> StorageResult<&ColumnFamily> {
self.db
.cf_handle(name)
.ok_or(StorageError::MissingColumnFamily(name))
}
}
fn reject_unsupported_column_families(path: &Path) -> StorageResult<()> {
// An empty directory is a valid first-run state root. A RocksDB directory
// has CURRENT; inspect its column families before opening so an older
// private schema cannot be silently ignored or partially loaded.
if !path.join("CURRENT").exists() {
return Ok(());
}
let options = Options::default();
let existing = DB::list_cf(&options, path)
.map_err(|error| StorageError::RocksDb(format!("inspect state schema: {error}")))?;
let supported: std::collections::HashSet<&str> = std::iter::once("default")
.chain(ALL_COLUMN_FAMILY_NAMES.iter().copied())
.collect();
let unsupported: Vec<_> = existing
.iter()
.filter(|name| !supported.contains(name.as_str()))
.cloned()
.collect();
if unsupported.is_empty() {
return Ok(());
}
Err(StorageError::InvalidData {
entity: "state_schema",
detail: format!(
"unsupported column families {:?}; create a new state directory instead of reusing this database",
unsupported
),
})
}

View File

@ -0,0 +1,33 @@
// Manifest rollback metadata is protocol state retained independently from
// repository objects and validation outputs.
impl RocksStore {
pub fn get_manifest_anti_rollback_meta(
&self,
manifest_rsync_uri: &str,
) -> StorageResult<Option<ManifestAntiRollbackMeta>> {
let cf = self.cf(CF_MANIFEST_ANTI_ROLLBACK)?;
let key = manifest_anti_rollback_key(manifest_rsync_uri);
let Some(bytes) = self
.db
.get_cf(cf, key.as_bytes())
.map_err(|error| StorageError::RocksDb(error.to_string()))?
else {
return Ok(None);
};
let metadata = decode_cbor::<ManifestAntiRollbackMeta>(&bytes, "manifest_anti_rollback")?;
metadata.validate_internal()?;
Ok(Some(metadata))
}
pub fn put_manifest_anti_rollback_meta(&self, metadata: &ManifestAntiRollbackMeta) -> StorageResult<()> {
metadata.validate_internal()?;
let cf = self.cf(CF_MANIFEST_ANTI_ROLLBACK)?;
let key = manifest_anti_rollback_key(&metadata.manifest_rsync_uri);
let value = encode_cbor(metadata, "manifest_anti_rollback")?;
self.db
.put_cf(cf, key.as_bytes(), value)
.map_err(|error| StorageError::RocksDb(error.to_string()))?;
Ok(())
}
}

View File

@ -0,0 +1,363 @@
// Repository-view and raw-object/blob storage operations.
impl RocksStore {
pub fn put_repository_view_entry(&self, entry: &RepositoryViewEntry) -> StorageResult<()> {
entry.validate_internal()?;
let cf = self.cf(CF_REPOSITORY_VIEW)?;
let key = repository_view_key(&entry.rsync_uri);
let value = encode_cbor(entry, "repository_view")?;
self.db
.put_cf(cf, key.as_bytes(), value)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn get_repository_view_entry(
&self,
rsync_uri: &str,
) -> StorageResult<Option<RepositoryViewEntry>> {
let cf = self.cf(CF_REPOSITORY_VIEW)?;
let key = repository_view_key(rsync_uri);
let Some(bytes) = self
.db
.get_cf(cf, key.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?
else {
return Ok(None);
};
let entry = decode_cbor::<RepositoryViewEntry>(&bytes, "repository_view")?;
entry.validate_internal()?;
Ok(Some(entry))
}
pub fn delete_repository_view_entry(&self, rsync_uri: &str) -> StorageResult<()> {
let cf = self.cf(CF_REPOSITORY_VIEW)?;
let key = repository_view_key(rsync_uri);
self.db
.delete_cf(cf, key.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn put_projection_batch(
&self,
repository_view_entries: &[RepositoryViewEntry],
member_records: &[RrdpSourceMemberRecord],
owner_records: &[RrdpUriOwnerRecord],
) -> StorageResult<()> {
if repository_view_entries.is_empty()
&& member_records.is_empty()
&& owner_records.is_empty()
{
return Ok(());
}
let repo_cf = self.cf(CF_REPOSITORY_VIEW)?;
let member_cf = self.cf(CF_RRDP_SOURCE_MEMBER)?;
let owner_cf = self.cf(CF_RRDP_URI_OWNER)?;
let mut batch = WriteBatch::default();
for entry in repository_view_entries {
entry.validate_internal()?;
let key = repository_view_key(&entry.rsync_uri);
let value = encode_cbor(entry, "repository_view")?;
batch.put_cf(repo_cf, key.as_bytes(), value);
}
for record in member_records {
record.validate_internal()?;
let key = rrdp_source_member_key(&record.notify_uri, &record.rsync_uri);
let value = encode_cbor(record, "rrdp_source_member")?;
batch.put_cf(member_cf, key.as_bytes(), value);
}
for record in owner_records {
record.validate_internal()?;
let key = rrdp_uri_owner_key(&record.rsync_uri);
let value = encode_cbor(record, "rrdp_uri_owner")?;
batch.put_cf(owner_cf, key.as_bytes(), value);
}
self.write_batch(batch)
}
pub fn list_repository_view_entries_with_prefix(
&self,
rsync_uri_prefix: &str,
) -> StorageResult<Vec<RepositoryViewEntry>> {
let cf = self.cf(CF_REPOSITORY_VIEW)?;
let prefix = repository_view_prefix(rsync_uri_prefix);
let mode = IteratorMode::From(prefix.as_bytes(), Direction::Forward);
self.db
.iterator_cf(cf, mode)
.take_while(|res| match res {
Ok((key, _)) => key.starts_with(prefix.as_bytes()),
Err(_) => false,
})
.map(|res| {
let (_key, value) = res.map_err(|e| StorageError::RocksDb(e.to_string()))?;
let entry = decode_cbor::<RepositoryViewEntry>(&value, "repository_view")?;
entry.validate_internal()?;
Ok(entry)
})
.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() {
return raw_store.put_raw_entry(entry);
}
let cf = self.cf(CF_RAW_BY_HASH)?;
let key = raw_by_hash_key(&entry.sha256_hex);
let value = encode_cbor(entry, "raw_by_hash")?;
self.db
.put_cf(cf, key.as_bytes(), value)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn put_raw_by_hash_entries_batch(&self, entries: &[RawByHashEntry]) -> StorageResult<()> {
if entries.is_empty() {
return Ok(());
}
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.put_raw_entries_batch(entries);
}
let cf = self.cf(CF_RAW_BY_HASH)?;
let mut batch = WriteBatch::default();
for entry in entries {
entry.validate_internal()?;
let key = raw_by_hash_key(&entry.sha256_hex);
let value = encode_cbor(entry, "raw_by_hash")?;
batch.put_cf(cf, key.as_bytes(), value);
}
self.write_batch(batch)
}
pub fn put_raw_by_hash_entries_batch_unchecked(
&self,
entries: &[RawByHashEntry],
) -> StorageResult<()> {
if entries.is_empty() {
return Ok(());
}
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.put_raw_entries_batch(entries);
}
let cf = self.cf(CF_RAW_BY_HASH)?;
let mut batch = WriteBatch::default();
for entry in entries {
let key = raw_by_hash_key(&entry.sha256_hex);
let value = encode_cbor(entry, "raw_by_hash")?;
batch.put_cf(cf, key.as_bytes(), value);
}
self.write_batch(batch)
}
pub fn put_blob_bytes_batch(&self, blobs: &[(String, Vec<u8>)]) -> StorageResult<()> {
if blobs.is_empty() {
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() {
return raw_store.put_blob_bytes_batch(blobs);
}
let cf = self.cf(CF_RAW_BLOB)?;
let mut batch = WriteBatch::default();
for (sha256_hex, bytes) in blobs {
validate_sha256_hex("raw_blob.sha256_hex", sha256_hex)?;
if bytes.is_empty() {
return Err(StorageError::InvalidData {
entity: "raw_blob",
detail: "bytes must not be empty".to_string(),
});
}
let key = raw_blob_key(sha256_hex);
batch.put_cf(cf, key.as_bytes(), bytes.as_slice());
}
self.write_batch(batch)
}
pub fn delete_raw_by_hash_entry(&self, sha256_hex: &str) -> StorageResult<()> {
validate_sha256_hex("raw_by_hash.sha256_hex", sha256_hex)?;
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.delete_raw_entry(sha256_hex);
}
let cf = self.cf(CF_RAW_BY_HASH)?;
let key = raw_by_hash_key(sha256_hex);
self.db
.delete_cf(cf, key.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn get_raw_by_hash_entry(&self, sha256_hex: &str) -> StorageResult<Option<RawByHashEntry>> {
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.get_raw_entry(sha256_hex);
}
let cf = self.cf(CF_RAW_BY_HASH)?;
let key = raw_by_hash_key(sha256_hex);
let Some(bytes) = self
.db
.get_cf(cf, key.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?
else {
return Ok(None);
};
let entry = decode_cbor::<RawByHashEntry>(&bytes, "raw_by_hash")?;
entry.validate_internal()?;
Ok(Some(entry))
}
pub fn get_raw_by_hash_entries_batch(
&self,
sha256_hexes: &[String],
) -> StorageResult<Vec<Option<RawByHashEntry>>> {
if sha256_hexes.is_empty() {
return Ok(Vec::new());
}
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.get_raw_entries_batch(sha256_hexes);
}
let cf = self.cf(CF_RAW_BY_HASH)?;
let keys: Vec<String> = sha256_hexes
.iter()
.map(|hash| raw_by_hash_key(hash))
.collect();
self.db
.multi_get_cf(keys.iter().map(|key| (cf, key.as_bytes())))
.into_iter()
.map(|res| {
let maybe = res.map_err(|e| StorageError::RocksDb(e.to_string()))?;
match maybe {
Some(bytes) => {
let entry = decode_cbor::<RawByHashEntry>(&bytes, "raw_by_hash")?;
entry.validate_internal()?;
Ok(Some(entry))
}
None => Ok(None),
}
})
.collect()
}
pub fn get_blob_bytes(&self, sha256_hex: &str) -> StorageResult<Option<Vec<u8>>> {
if let Some(repo_bytes) = self.external_repo_bytes.as_ref() {
return repo_bytes.get_blob_bytes(sha256_hex);
}
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.get_blob_bytes(sha256_hex);
}
validate_sha256_hex("raw_blob.sha256_hex", sha256_hex)?;
let cf = self.cf(CF_RAW_BLOB)?;
let key = raw_blob_key(sha256_hex);
if let Some(bytes) = self
.db
.get_cf(cf, key.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?
{
return Ok(Some(bytes));
}
self.get_raw_by_hash_entry(sha256_hex)
.map(|entry| entry.map(|entry| entry.bytes))
}
pub fn get_blob_bytes_batch(
&self,
sha256_hexes: &[String],
) -> StorageResult<Vec<Option<Vec<u8>>>> {
if sha256_hexes.is_empty() {
return Ok(Vec::new());
}
if let Some(repo_bytes) = self.external_repo_bytes.as_ref() {
return repo_bytes.get_blob_bytes_batch(sha256_hexes);
}
if let Some(raw_store) = self.external_raw_store.as_ref() {
return raw_store.get_blob_bytes_batch(sha256_hexes);
}
let cf = self.cf(CF_RAW_BLOB)?;
let keys: Vec<String> = sha256_hexes
.iter()
.map(|hash| {
validate_sha256_hex("raw_blob.sha256_hex", hash)?;
Ok::<String, StorageError>(raw_blob_key(hash))
})
.collect::<Result<_, _>>()?;
let blob_results: Vec<Option<Vec<u8>>> = self
.db
.multi_get_cf(keys.iter().map(|key| (cf, key.as_bytes())))
.into_iter()
.map(|res| res.map_err(|e| StorageError::RocksDb(e.to_string())))
.collect::<Result<_, _>>()?;
let mut out = Vec::with_capacity(sha256_hexes.len());
for (sha256_hex, maybe_blob) in sha256_hexes.iter().zip(blob_results.into_iter()) {
if maybe_blob.is_some() {
out.push(maybe_blob);
} else {
out.push(
self.get_raw_by_hash_entry(sha256_hex)?
.map(|entry| entry.bytes),
);
}
}
Ok(out)
}
}

View File

@ -0,0 +1,108 @@
// RRDP source/session state and repository-view reads.
impl RocksStore {
pub fn put_rrdp_source_record(&self, record: &RrdpSourceRecord) -> StorageResult<()> {
record.validate_internal()?;
let cf = self.cf(CF_RRDP_SOURCE)?;
let key = rrdp_source_key(&record.notify_uri);
let value = encode_cbor(record, "rrdp_source")?;
self.db.put_cf(cf, key.as_bytes(), value).map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn get_rrdp_source_record(&self, notify_uri: &str) -> StorageResult<Option<RrdpSourceRecord>> {
let cf = self.cf(CF_RRDP_SOURCE)?;
let key = rrdp_source_key(notify_uri);
let Some(bytes) = self.db.get_cf(cf, key.as_bytes()).map_err(|e| StorageError::RocksDb(e.to_string()))? else { return Ok(None) };
let record = decode_cbor::<RrdpSourceRecord>(&bytes, "rrdp_source")?;
record.validate_internal()?;
Ok(Some(record))
}
pub fn put_rrdp_source_member_record(&self, record: &RrdpSourceMemberRecord) -> StorageResult<()> {
record.validate_internal()?;
let cf = self.cf(CF_RRDP_SOURCE_MEMBER)?;
let key = rrdp_source_member_key(&record.notify_uri, &record.rsync_uri);
let value = encode_cbor(record, "rrdp_source_member")?;
self.db.put_cf(cf, key.as_bytes(), value).map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn get_rrdp_source_member_record(&self, notify_uri: &str, rsync_uri: &str) -> StorageResult<Option<RrdpSourceMemberRecord>> {
let cf = self.cf(CF_RRDP_SOURCE_MEMBER)?;
let key = rrdp_source_member_key(notify_uri, rsync_uri);
let Some(bytes) = self.db.get_cf(cf, key.as_bytes()).map_err(|e| StorageError::RocksDb(e.to_string()))? else { return Ok(None) };
let record = decode_cbor::<RrdpSourceMemberRecord>(&bytes, "rrdp_source_member")?;
record.validate_internal()?;
Ok(Some(record))
}
pub fn list_rrdp_source_member_records(&self, notify_uri: &str) -> StorageResult<Vec<RrdpSourceMemberRecord>> {
let cf = self.cf(CF_RRDP_SOURCE_MEMBER)?;
let prefix = rrdp_source_member_prefix(notify_uri);
let mode = IteratorMode::From(prefix.as_bytes(), Direction::Forward);
self.db.iterator_cf(cf, mode).take_while(|res| match res { Ok((key, _)) => key.starts_with(prefix.as_bytes()), Err(_) => false }).map(|res| {
let (_key, value) = res.map_err(|e| StorageError::RocksDb(e.to_string()))?;
let record = decode_cbor::<RrdpSourceMemberRecord>(&value, "rrdp_source_member")?;
record.validate_internal()?;
Ok(record)
}).collect()
}
pub fn list_current_rrdp_source_members(&self, notify_uri: &str) -> StorageResult<Vec<RrdpSourceMemberRecord>> {
let mut records = self.list_rrdp_source_member_records(notify_uri)?;
records.retain(|record| record.present);
records.sort_by(|a, b| a.rsync_uri.cmp(&b.rsync_uri));
Ok(records)
}
pub fn is_current_rrdp_source_member(&self, notify_uri: &str, rsync_uri: &str) -> StorageResult<bool> {
Ok(matches!(self.get_rrdp_source_member_record(notify_uri, rsync_uri)?, Some(record) if record.present))
}
pub fn load_current_object_bytes_by_uri(&self, rsync_uri: &str) -> StorageResult<Option<Vec<u8>>> {
Ok(self.load_current_object_with_hash_by_uri(rsync_uri)?.map(|object| object.bytes))
}
pub fn load_current_object_with_hash_by_uri(&self, rsync_uri: &str) -> StorageResult<Option<CurrentObjectWithHash>> {
let Some(view) = self.get_repository_view_entry(rsync_uri)? else { return Ok(None) };
match view.state {
RepositoryViewState::Withdrawn => Ok(None),
RepositoryViewState::Present | RepositoryViewState::Replaced => {
let hash = view.current_hash.as_deref().ok_or(StorageError::InvalidData { entity: "repository_view", detail: format!("current_hash missing for current object URI: {rsync_uri}") })?;
let bytes = self.get_blob_bytes(hash)?.ok_or(StorageError::InvalidData { entity: "repository_view", detail: format!("blob bytes missing for current object URI: {rsync_uri} (hash={hash})") })?;
Ok(Some(CurrentObjectWithHash { current_hash_hex: hash.to_ascii_lowercase(), current_hash: decode_sha256_hex_32("repository_view.current_hash", hash)?, bytes }))
}
}
}
pub fn put_rrdp_uri_owner_record(&self, record: &RrdpUriOwnerRecord) -> StorageResult<()> {
record.validate_internal()?;
let cf = self.cf(CF_RRDP_URI_OWNER)?;
let key = rrdp_uri_owner_key(&record.rsync_uri);
let value = encode_cbor(record, "rrdp_uri_owner")?;
self.db.put_cf(cf, key.as_bytes(), value).map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn get_rrdp_uri_owner_record(&self, rsync_uri: &str) -> StorageResult<Option<RrdpUriOwnerRecord>> {
let cf = self.cf(CF_RRDP_URI_OWNER)?;
let key = rrdp_uri_owner_key(rsync_uri);
let Some(bytes) = self.db.get_cf(cf, key.as_bytes()).map_err(|e| StorageError::RocksDb(e.to_string()))? else { return Ok(None) };
let record = decode_cbor::<RrdpUriOwnerRecord>(&bytes, "rrdp_uri_owner")?;
record.validate_internal()?;
Ok(Some(record))
}
pub fn delete_rrdp_uri_owner_record(&self, rsync_uri: &str) -> StorageResult<()> {
let cf = self.cf(CF_RRDP_URI_OWNER)?;
let key = rrdp_uri_owner_key(rsync_uri);
self.db.delete_cf(cf, key.as_bytes()).map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn write_batch(&self, batch: WriteBatch) -> StorageResult<()> {
self.db.write(batch).map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
}

View File

@ -0,0 +1,52 @@
// Storage tests are grouped by the public storage surface they protect.
use super::*;
use rocksdb::{DB, Options};
use sha2::Digest;
fn sha256_hex(bytes: &[u8]) -> String {
hex::encode(sha2::Sha256::digest(bytes))
}
fn pack_time(seconds: i64) -> PackTime {
PackTime::from_utc_offset_datetime(
time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(seconds),
)
}
fn sample_repository_view_entry(uri: &str, bytes: &[u8]) -> RepositoryViewEntry {
RepositoryViewEntry {
rsync_uri: uri.to_string(),
current_hash: Some(sha256_hex(bytes)),
repository_source: Some("fixture".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
}
}
fn sample_raw_by_hash_entry(bytes: Vec<u8>) -> RawByHashEntry {
RawByHashEntry::from_bytes(sha256_hex(&bytes), bytes)
}
#[test]
fn opening_an_unsupported_state_schema_fails_without_rewriting() {
let td = tempfile::tempdir().expect("tempdir");
let mut options = Options::default();
options.create_if_missing(true);
options.create_missing_column_families(true);
let legacy = DB::open_cf(&options, td.path(), ["legacy_private_state"])
.expect("create legacy state database");
drop(legacy);
match RocksStore::open(td.path()) {
Err(StorageError::InvalidData {
entity: "state_schema",
detail,
}) => assert!(detail.contains("legacy_private_state")),
Err(error) => panic!("unexpected schema error: {error}"),
Ok(_) => panic!("legacy schema must be rejected"),
}
}
include!("tests_parts/repository.rs");
include!("tests_parts/rrdp.rs");
include!("tests_parts/object_loading.rs");

View File

@ -0,0 +1,234 @@
// Storage test group: object loading.
#[test]
fn load_current_object_with_hash_by_uri_uses_internal_blob_cf_without_raw_entry() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let rsync_uri = "rsync://example.test/repo/blob-only.roa";
let bytes = b"blob-only-current-object".to_vec();
let hash = sha256_hex(&bytes);
store
.put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
.expect("put blob bytes");
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: rsync_uri.to_string(),
current_hash: Some(hash.clone()),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
})
.expect("put view");
let got = store
.load_current_object_with_hash_by_uri(rsync_uri)
.expect("load current object")
.expect("current object exists");
assert_eq!(got.current_hash_hex, hash);
assert_eq!(got.current_hash, compute_sha256_32(&bytes));
assert_eq!(got.bytes, bytes);
assert!(
store
.get_raw_by_hash_entry(&got.current_hash_hex)
.expect("get raw entry")
.is_none()
);
}
#[test]
fn load_current_object_bytes_by_uri_uses_internal_blob_cf_without_raw_entry() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let rsync_uri = "rsync://example.test/repo/blob-only-bytes.roa";
let bytes = b"blob-only-current-object-bytes".to_vec();
let hash = sha256_hex(&bytes);
store
.put_blob_bytes_batch(&[(hash, bytes.clone())])
.expect("put blob bytes");
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: rsync_uri.to_string(),
current_hash: Some(sha256_hex(&bytes)),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
})
.expect("put view");
assert_eq!(
store
.load_current_object_bytes_by_uri(rsync_uri)
.expect("load current object bytes"),
Some(bytes)
);
}
#[test]
fn pack_file_can_lazy_load_bytes_from_external_raw_store() {
let td = tempfile::tempdir().expect("tempdir");
let raw_store = std::sync::Arc::new(
ExternalRawStoreDb::open(td.path().join("raw-store.db")).expect("open raw store"),
);
let bytes = b"lazy-pack-file".to_vec();
let sha256_hex = sha256_hex(&bytes);
raw_store
.put_raw_entry(&RawByHashEntry::from_bytes(
sha256_hex.clone(),
bytes.clone(),
))
.expect("put raw entry");
let file = PackFile::from_lazy_external_raw_store(
"rsync://example.test/repo/a.roa",
sha256_hex,
compute_sha256_32(&bytes),
raw_store,
);
assert_eq!(file.bytes().expect("lazy bytes"), bytes.as_slice());
assert_eq!(file.bytes_cloned().expect("cloned bytes"), bytes);
}
#[test]
fn pack_file_can_lazy_load_bytes_from_external_repo_bytes_store() {
let td = tempfile::tempdir().expect("tempdir");
let repo_bytes_store = std::sync::Arc::new(
ExternalRepoBytesDb::open(td.path().join("repo-bytes.db")).expect("open repo bytes"),
);
let bytes = b"repo-object-pack-file".to_vec();
let sha256_hex = sha256_hex(&bytes);
repo_bytes_store
.put_blob_bytes_batch(&[(sha256_hex.clone(), bytes.clone())])
.expect("put repo bytes");
let file = PackFile::from_lazy_repo_bytes(
"rsync://example.test/repo/a.roa",
sha256_hex,
compute_sha256_32(&bytes),
repo_bytes_store,
);
assert_eq!(file.bytes().expect("lazy repo bytes"), bytes.as_slice());
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}");
}

View File

@ -0,0 +1,391 @@
// Storage test group: repository.
fn sample_rrdp_source_member_record(
notify_uri: &str,
rsync_uri: &str,
serial: u64,
) -> RrdpSourceMemberRecord {
RrdpSourceMemberRecord {
notify_uri: notify_uri.to_string(),
rsync_uri: rsync_uri.to_string(),
current_hash: Some(sha256_hex(rsync_uri.as_bytes())),
object_type: Some("cer".to_string()),
present: true,
last_confirmed_session_id: "session-1".to_string(),
last_confirmed_serial: serial,
last_changed_at: pack_time(serial as i64),
}
}
#[test]
fn repository_view_and_raw_by_hash_roundtrip() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let entry1 = sample_repository_view_entry("rsync://example.test/repo/a.cer", b"object-a");
let entry2 = sample_repository_view_entry("rsync://example.test/repo/sub/b.roa", b"object-b");
store
.put_repository_view_entry(&entry1)
.expect("put repository view entry1");
store
.put_repository_view_entry(&entry2)
.expect("put repository view entry2");
let got1 = store
.get_repository_view_entry(&entry1.rsync_uri)
.expect("get repository view entry1")
.expect("entry1 exists");
assert_eq!(got1, entry1);
let got_prefix = store
.list_repository_view_entries_with_prefix("rsync://example.test/repo/sub/")
.expect("list repository view prefix");
assert_eq!(got_prefix, vec![entry2.clone()]);
store
.delete_repository_view_entry(&entry1.rsync_uri)
.expect("delete repository view entry1");
assert!(
store
.get_repository_view_entry(&entry1.rsync_uri)
.expect("get deleted repository view entry1")
.is_none()
);
let raw = sample_raw_by_hash_entry(b"raw-der-object".to_vec());
store
.put_raw_by_hash_entry(&raw)
.expect("put raw_by_hash entry");
let got_raw = store
.get_raw_by_hash_entry(&raw.sha256_hex)
.expect("get raw_by_hash entry")
.expect("raw entry exists");
assert_eq!(got_raw, raw);
}
#[test]
fn raw_by_hash_routes_to_external_raw_store_when_configured() {
let td = tempfile::tempdir().expect("tempdir");
let main_db = td.path().join("main-db");
let raw_db = td.path().join("raw-store.db");
let raw = sample_raw_by_hash_entry(b"external-raw".to_vec());
{
let store =
RocksStore::open_with_external_raw_store(&main_db, &raw_db).expect("open store");
store.put_raw_by_hash_entry(&raw).expect("put external raw");
let got = store
.get_raw_by_hash_entry(&raw.sha256_hex)
.expect("get external raw")
.expect("raw exists");
assert_eq!(got, raw);
}
let main_store = RocksStore::open(&main_db).expect("open main only");
assert!(
main_store
.get_raw_by_hash_entry(&raw.sha256_hex)
.expect("read main store")
.is_none(),
"main db should not contain raw entry when external raw store is configured"
);
}
#[test]
fn put_blob_bytes_batch_uses_internal_blob_cf_without_raw_entry() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let bytes = b"internal-blob-only".to_vec();
let hash = sha256_hex(&bytes);
store
.put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
.expect("put blob bytes");
assert_eq!(
store.get_blob_bytes(&hash).expect("get blob bytes"),
Some(bytes.clone())
);
assert!(
store
.get_raw_by_hash_entry(&hash)
.expect("get raw entry")
.is_none()
);
}
#[test]
fn put_blob_bytes_batch_routes_to_external_raw_store_without_raw_entry() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open_with_external_raw_store(
&td.path().join("main-db"),
&td.path().join("raw-store.db"),
)
.expect("open store");
let bytes = b"external-blob-only".to_vec();
let hash = sha256_hex(&bytes);
store
.put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
.expect("put external blob bytes");
assert_eq!(store.get_blob_bytes(&hash).unwrap(), Some(bytes));
assert!(store.get_raw_by_hash_entry(&hash).unwrap().is_none());
}
#[test]
fn repo_bytes_db_is_physically_separate_from_external_raw_store() {
let td = tempfile::tempdir().expect("tempdir");
let main_db = td.path().join("main-db");
let raw_db = td.path().join("raw-store.db");
let repo_bytes_db = td.path().join("repo-bytes.db");
let store =
RocksStore::open_with_external_stores(&main_db, Some(&raw_db), Some(&repo_bytes_db))
.expect("open store");
let repo_bytes = b"repo-object".to_vec();
let repo_hash = sha256_hex(&repo_bytes);
let raw = sample_raw_by_hash_entry(b"raw-evidence".to_vec());
store
.put_blob_bytes_batch(&[(repo_hash.clone(), repo_bytes.clone())])
.expect("put repo bytes");
store.put_raw_by_hash_entry(&raw).expect("put raw evidence");
assert_eq!(store.get_blob_bytes(&repo_hash).unwrap(), Some(repo_bytes));
assert_eq!(
store.get_raw_by_hash_entry(&raw.sha256_hex).unwrap(),
Some(raw.clone())
);
drop(store);
let raw_only = RocksStore::open_with_external_raw_store(&td.path().join("raw-reader"), &raw_db)
.expect("open raw only");
assert!(
raw_only.get_blob_bytes(&repo_hash).unwrap().is_none(),
"repo object bytes must not be written into raw-store.db"
);
let repo_only =
RocksStore::open_with_external_repo_bytes(&td.path().join("repo-reader"), &repo_bytes_db)
.expect("open repo bytes only");
assert_eq!(
repo_only.get_blob_bytes(&repo_hash).unwrap(),
Some(b"repo-object".to_vec())
);
assert!(
repo_only.get_blob_bytes(&raw.sha256_hex).unwrap().is_none(),
"raw evidence bytes must not be written into repo-bytes.db"
);
}
#[test]
fn memory_snapshot_includes_work_db_and_external_stores() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open_with_external_stores(
&td.path().join("main-db"),
Some(&td.path().join("raw-store.db")),
Some(&td.path().join("repo-bytes.db")),
)
.expect("open store");
let snapshot = store.memory_snapshot();
let labels: Vec<&str> = snapshot
.databases
.iter()
.map(|db| db.label.as_str())
.collect();
assert_eq!(labels, vec!["work-db", "raw-store.db", "repo-bytes.db"]);
assert!(
snapshot.databases[0]
.column_families
.iter()
.any(|cf| cf.name == CF_REPOSITORY_VIEW)
);
serde_json::to_value(&snapshot).expect("serialize memory snapshot");
}
#[test]
fn put_blob_bytes_batch_accepts_empty_batch_with_external_raw_store() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open_with_external_raw_store(
&td.path().join("main-db"),
&td.path().join("raw-store.db"),
)
.expect("open store");
store
.put_blob_bytes_batch(&[])
.expect("empty external blob batch should be a no-op");
}
#[test]
fn get_blob_bytes_internal_falls_back_to_raw_entry_when_blob_missing() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let raw = sample_raw_by_hash_entry(b"raw-fallback".to_vec());
store.put_raw_by_hash_entry(&raw).expect("put raw entry");
assert_eq!(
store
.get_blob_bytes(&raw.sha256_hex)
.expect("get blob bytes via raw fallback"),
Some(raw.bytes.clone())
);
}
#[test]
fn get_blob_bytes_batch_internal_prefers_blob_cf_and_falls_back_to_raw_entry() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let blob_bytes = b"blob-cf-object".to_vec();
let blob_hash = sha256_hex(&blob_bytes);
store
.put_blob_bytes_batch(&[(blob_hash.clone(), blob_bytes.clone())])
.expect("put blob bytes");
let raw = sample_raw_by_hash_entry(b"raw-fallback-batch".to_vec());
store.put_raw_by_hash_entry(&raw).expect("put raw fallback");
let batch = store
.get_blob_bytes_batch(&[blob_hash.clone(), raw.sha256_hex.clone(), "00".repeat(32)])
.expect("get blob bytes batch");
assert_eq!(batch, vec![Some(blob_bytes), Some(raw.bytes.clone()), None]);
}
#[test]
fn get_blob_bytes_batch_routes_to_external_raw_store_without_raw_entry() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open_with_external_raw_store(
&td.path().join("main-db"),
&td.path().join("raw-store.db"),
)
.expect("open store");
let bytes = b"external-batch-blob".to_vec();
let hash = sha256_hex(&bytes);
store
.put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
.expect("put external blob bytes");
assert_eq!(
store
.get_blob_bytes_batch(&[hash, "00".repeat(32)])
.expect("get external blob batch"),
vec![Some(bytes), None]
);
}
#[test]
fn get_blob_bytes_rejects_invalid_hash_for_internal_store() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let err = store
.get_blob_bytes("not-a-valid-hash")
.expect_err("invalid hash must fail");
assert!(matches!(err, StorageError::InvalidData { .. }));
}
#[test]
fn get_blob_bytes_batch_rejects_invalid_hash_for_internal_store() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let err = store
.get_blob_bytes_batch(&["not-a-valid-hash".to_string()])
.expect_err("invalid hash must fail");
assert!(matches!(err, StorageError::InvalidData { .. }));
}
#[test]
fn get_blob_bytes_batch_returns_empty_for_empty_request_internal() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
assert!(
store
.get_blob_bytes_batch(&[])
.expect("empty blob batch request")
.is_empty()
);
}
#[test]
fn put_blob_bytes_batch_accepts_empty_batch() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
store
.put_blob_bytes_batch(&[])
.expect("empty blob batch should be a no-op");
}
#[test]
fn put_blob_bytes_batch_rejects_empty_bytes() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let err = store
.put_blob_bytes_batch(&[(sha256_hex(b"valid"), Vec::new())])
.expect_err("empty bytes must fail");
assert!(matches!(err, StorageError::InvalidData { .. }));
}
#[test]
fn delete_raw_by_hash_entry_internal_preserves_blob_bytes() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let bytes = b"blob-persists-after-raw-delete".to_vec();
let hash = sha256_hex(&bytes);
let raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone());
store
.put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
.expect("put blob bytes");
store.put_raw_by_hash_entry(&raw).expect("put raw entry");
store
.delete_raw_by_hash_entry(&hash)
.expect("delete raw entry only");
assert!(store.get_raw_by_hash_entry(&hash).unwrap().is_none());
assert_eq!(store.get_blob_bytes(&hash).unwrap(), Some(bytes));
}
#[test]
fn delete_raw_by_hash_entry_rejects_invalid_hash() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let err = store
.delete_raw_by_hash_entry("not-a-valid-hash")
.expect_err("invalid hash must fail");
assert!(matches!(err, StorageError::InvalidData { .. }));
}
#[test]
fn delete_raw_by_hash_entry_routes_to_external_raw_store() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open_with_external_raw_store(
&td.path().join("main-db"),
&td.path().join("raw-store.db"),
)
.expect("open store");
let raw = sample_raw_by_hash_entry(b"external-delete".to_vec());
store.put_raw_by_hash_entry(&raw).expect("put raw entry");
store
.delete_raw_by_hash_entry(&raw.sha256_hex)
.expect("delete external raw entry");
assert!(
store
.get_raw_by_hash_entry(&raw.sha256_hex)
.unwrap()
.is_none()
);
assert!(store.get_blob_bytes(&raw.sha256_hex).unwrap().is_none());
}

View File

@ -0,0 +1,262 @@
// Storage test group: rrdp.
#[test]
fn projection_batch_roundtrip_writes_repository_view_member_and_owner_records() {
let dir = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(dir.path()).expect("open store");
let view = RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
current_hash: Some(hex::encode([1u8; 32])),
repository_source: Some("https://example.test/notify.xml".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
};
let member = RrdpSourceMemberRecord {
notify_uri: "https://example.test/notify.xml".to_string(),
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
current_hash: Some(hex::encode([1u8; 32])),
object_type: Some("roa".to_string()),
present: true,
last_confirmed_session_id: "session-1".to_string(),
last_confirmed_serial: 7,
last_changed_at: pack_time(1),
};
let owner = RrdpUriOwnerRecord {
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
notify_uri: "https://example.test/notify.xml".to_string(),
current_hash: Some(hex::encode([1u8; 32])),
last_confirmed_session_id: "session-1".to_string(),
last_confirmed_serial: 7,
last_changed_at: pack_time(1),
owner_state: RrdpUriOwnerState::Active,
};
store
.put_projection_batch(
std::slice::from_ref(&view),
std::slice::from_ref(&member),
std::slice::from_ref(&owner),
)
.expect("write projection batch");
assert_eq!(
store
.get_repository_view_entry(&view.rsync_uri)
.expect("get view")
.expect("present view"),
view
);
assert_eq!(
store
.get_rrdp_source_member_record(&member.notify_uri, &member.rsync_uri)
.expect("get member")
.expect("present member"),
member
);
assert_eq!(
store
.get_rrdp_uri_owner_record(&owner.rsync_uri)
.expect("get owner")
.expect("present owner"),
owner
);
}
#[test]
fn current_rrdp_source_member_helpers_filter_present_records() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let notify_uri = "https://rrdp.example.test/notification.xml";
let mut present_a =
sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/a.cer", 1);
let mut withdrawn_b =
sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/b.roa", 2);
withdrawn_b.present = false;
let present_c =
sample_rrdp_source_member_record(notify_uri, "rsync://example.test/repo/c.crl", 3);
let other_source = sample_rrdp_source_member_record(
"https://other.example.test/notification.xml",
"rsync://other.example.test/repo/x.cer",
4,
);
present_a.last_confirmed_serial = 10;
store
.put_rrdp_source_member_record(&present_a)
.expect("put present a");
store
.put_rrdp_source_member_record(&withdrawn_b)
.expect("put withdrawn b");
store
.put_rrdp_source_member_record(&present_c)
.expect("put present c");
store
.put_rrdp_source_member_record(&other_source)
.expect("put other source");
let members = store
.list_current_rrdp_source_members(notify_uri)
.expect("list current members");
assert_eq!(
members
.iter()
.map(|record| record.rsync_uri.as_str())
.collect::<Vec<_>>(),
vec![
"rsync://example.test/repo/a.cer",
"rsync://example.test/repo/c.crl",
]
);
assert!(
store
.is_current_rrdp_source_member(notify_uri, &present_a.rsync_uri)
.expect("current a")
);
assert!(
!store
.is_current_rrdp_source_member(notify_uri, &withdrawn_b.rsync_uri)
.expect("withdrawn b")
);
assert!(
!store
.is_current_rrdp_source_member(notify_uri, &other_source.rsync_uri)
.expect("other source")
);
}
#[test]
fn load_current_object_bytes_by_uri_uses_repository_view_and_raw_by_hash() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let present_bytes = b"present-object".to_vec();
let present_hash = sha256_hex(&present_bytes);
let mut present_raw = RawByHashEntry::from_bytes(present_hash.clone(), present_bytes.clone());
present_raw
.origin_uris
.push("rsync://example.test/repo/present.roa".to_string());
present_raw.object_type = Some("roa".to_string());
store
.put_raw_by_hash_entry(&present_raw)
.expect("put present raw");
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/present.roa".to_string(),
current_hash: Some(present_hash),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
})
.expect("put present view");
let replaced_bytes = b"replaced-object".to_vec();
let replaced_hash = sha256_hex(&replaced_bytes);
let mut replaced_raw =
RawByHashEntry::from_bytes(replaced_hash.clone(), replaced_bytes.clone());
replaced_raw
.origin_uris
.push("rsync://example.test/repo/replaced.cer".to_string());
replaced_raw.object_type = Some("cer".to_string());
store
.put_raw_by_hash_entry(&replaced_raw)
.expect("put replaced raw");
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/replaced.cer".to_string(),
current_hash: Some(replaced_hash),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("cer".to_string()),
state: RepositoryViewState::Replaced,
})
.expect("put replaced view");
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: "rsync://example.test/repo/withdrawn.crl".to_string(),
current_hash: Some(sha256_hex(b"withdrawn")),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("crl".to_string()),
state: RepositoryViewState::Withdrawn,
})
.expect("put withdrawn view");
assert_eq!(
store
.load_current_object_bytes_by_uri("rsync://example.test/repo/present.roa")
.expect("load present"),
Some(present_bytes)
);
assert_eq!(
store
.load_current_object_bytes_by_uri("rsync://example.test/repo/replaced.cer")
.expect("load replaced"),
Some(replaced_bytes)
);
assert_eq!(
store
.load_current_object_bytes_by_uri("rsync://example.test/repo/withdrawn.crl")
.expect("load withdrawn"),
None
);
assert_eq!(
store
.load_current_object_bytes_by_uri("rsync://example.test/repo/missing.roa")
.expect("load missing"),
None
);
}
#[test]
fn load_current_object_bytes_by_uri_errors_when_raw_by_hash_is_missing() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let rsync_uri = "rsync://example.test/repo/missing.cer";
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: rsync_uri.to_string(),
current_hash: Some(hex::encode([0x11; 32])),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("cer".to_string()),
state: RepositoryViewState::Present,
})
.expect("put view");
let err = store
.load_current_object_bytes_by_uri(rsync_uri)
.expect_err("missing raw_by_hash should error");
assert!(matches!(err, StorageError::InvalidData { .. }));
}
#[test]
fn load_current_object_with_hash_by_uri_returns_hash_and_bytes() {
let td = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(td.path()).expect("open rocksdb");
let rsync_uri = "rsync://example.test/repo/present.roa";
let bytes = b"present-object".to_vec();
let hash = sha256_hex(&bytes);
let mut raw = RawByHashEntry::from_bytes(hash.clone(), bytes.clone());
raw.origin_uris.push(rsync_uri.to_string());
raw.object_type = Some("roa".to_string());
store.put_raw_by_hash_entry(&raw).expect("put raw");
store
.put_repository_view_entry(&RepositoryViewEntry {
rsync_uri: rsync_uri.to_string(),
current_hash: Some(hash.clone()),
repository_source: Some("https://rrdp.example.test/notification.xml".to_string()),
object_type: Some("roa".to_string()),
state: RepositoryViewState::Present,
})
.expect("put view");
let got = store
.load_current_object_with_hash_by_uri(rsync_uri)
.expect("load current object")
.expect("current object exists");
assert_eq!(got.current_hash_hex, hash);
assert_eq!(got.current_hash, compute_sha256_32(&bytes));
assert_eq!(got.bytes, bytes);
}

View File

@ -0,0 +1,32 @@
// Repository blob integrity verification helper.
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(())
}

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