初始化 Panda RPKI v0.1.0 开源候选版本
This commit is contained in:
commit
277cbca878
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
32
.github/workflows/ci.yml
vendored
Normal 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
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/target/
|
||||||
|
/out/
|
||||||
|
/state/
|
||||||
|
/input/
|
||||||
|
/output/
|
||||||
|
/data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal 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
14
CONTRIBUTING.md
Normal 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
2133
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
Cargo.toml
Normal file
34
Cargo.toml
Normal 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
29
LICENSE
Normal 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
68
README.md
Normal 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
239
THIRD_PARTY_NOTICES.md
Normal 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
22
docker/Dockerfile
Normal 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
62
docker/compose.all5.yaml
Normal 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
|
||||||
40
docker/compose.daemon.yaml
Normal file
40
docker/compose.daemon.yaml
Normal 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
28
docker/compose.yaml
Normal 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
|
||||||
139
docs/command-line-reference.md
Normal file
139
docs/command-line-reference.md
Normal 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.
|
||||||
21
docs/conformance-matrix.md
Normal file
21
docs/conformance-matrix.md
Normal 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.4–3.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` §§2–4 | `src/ccr/` | encode/decode tests and integration artifacts |
|
||||||
|
| TA Constraints rule normalization and EE resource checks | `draft-ietf-sidrops-constraining-rpki-trust-anchors-01` §§3–4 | `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
33
docs/development.md
Normal 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
84
docs/docker.md
Normal 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
66
docs/getting-started.md
Normal 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
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
148
docs/trust-anchors.md
Normal 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
101
docs/usage.md
Normal 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
245
src/ccr/accumulator.rs
Normal 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
266
src/ccr/build.rs
Normal 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
235
src/ccr/compare_view.rs
Normal 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
541
src/ccr/decode.rs
Normal 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
303
src/ccr/encode.rs
Normal 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
25
src/ccr/export.rs
Normal 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
9
src/ccr/hash.rs
Normal 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
|
||||||
|
}
|
||||||
425
src/ccr/manifest_location.rs
Normal file
425
src/ccr/manifest_location.rs
Normal 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
34
src/ccr/mod.rs
Normal 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
398
src/ccr/model.rs
Normal 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
69
src/ccr/projection.rs
Normal 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
359
src/ccr/verify.rs
Normal 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
226
src/cli/mod.rs
Normal 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
276
src/cli/validate.rs
Normal 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
78
src/daemon/metrics.rs
Normal 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
374
src/daemon/mod.rs
Normal 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
104
src/daemon/process.rs
Normal 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
46
src/daemon/storage.rs
Normal 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
113
src/daemon/tests.rs
Normal 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
23
src/lib.rs
Normal 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
335
src/logging.rs
Normal 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
72
src/logging/progress.rs
Normal 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
16
src/main.rs
Normal 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
412
src/model/aspa.rs
Normal 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
289
src/model/common.rs
Normal 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
537
src/model/crl.rs
Normal 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
976
src/model/manifest.rs
Normal 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
13
src/model/mod.rs
Normal 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
78
src/model/oid.rs
Normal 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
663
src/model/projection.rs
Normal 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
30
src/model/rc.rs
Normal 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");
|
||||||
248
src/model/rc/certificate_validation.rs
Normal file
248
src/model/rc/certificate_validation.rs
Normal 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(())
|
||||||
|
}
|
||||||
346
src/model/rc/parsed_validation.rs
Normal file
346
src/model/rc/parsed_validation.rs
Normal 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
584
src/model/rc/parsing.rs
Normal 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)
|
||||||
|
}
|
||||||
102
src/model/rc/strict_name_tests.rs
Normal file
102
src/model/rc/strict_name_tests.rs
Normal 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
564
src/model/rc/types.rs
Normal 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
631
src/model/roa.rs
Normal 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
365
src/model/router_cert.rs
Normal 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(())
|
||||||
|
}
|
||||||
27
src/model/signed_object.rs
Normal file
27
src/model/signed_object.rs
Normal 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;
|
||||||
172
src/model/signed_object/cms_reader.rs
Normal file
172
src/model/signed_object/cms_reader.rs
Normal 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)
|
||||||
|
}
|
||||||
542
src/model/signed_object/parsed_profile.rs
Normal file
542
src/model/signed_object/parsed_profile.rs
Normal 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,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
344
src/model/signed_object/signed_attrs.rs
Normal file
344
src/model/signed_object/signed_attrs.rs
Normal 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..]
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/model/signed_object/signed_object_impl.rs
Normal file
118
src/model/signed_object/signed_object_impl.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/model/signed_object/tests.rs
Normal file
117
src/model/signed_object/tests.rs
Normal 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);
|
||||||
|
}
|
||||||
300
src/model/signed_object/types_errors.rs
Normal file
300
src/model/signed_object/types_errors.rs
Normal 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
312
src/model/ta.rs
Normal 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
183
src/model/tal.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/output/analysis/mod.rs
Normal file
1
src/output/analysis/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod timing;
|
||||||
353
src/output/analysis/timing.rs
Normal file
353
src/output/analysis/timing.rs
Normal 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
336
src/output/audit.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/output/audit_downloads.rs
Normal file
170
src/output/audit_downloads.rs
Normal 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
350
src/output/memory.rs
Normal 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, ¤t_path, ¤t);
|
||||||
|
}
|
||||||
|
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, ¤t_path, ¤t);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
6
src/output/mod.rs
Normal 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
57
src/output/report.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
835
src/repository/blob_store.rs
Normal file
835
src/repository/blob_store.rs
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
289
src/repository/current_repo_index.rs
Normal file
289
src/repository/current_repo_index.rs
Normal 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(¤t_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
234
src/repository/fetch/current_repository.rs
Normal file
234
src/repository/fetch/current_repository.rs
Normal 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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
922
src/repository/fetch/http.rs
Normal file
922
src/repository/fetch/http.rs
Normal 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(¤t, resource)?;
|
||||||
|
if ¤t_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/repository/fetch/mod.rs
Normal file
4
src/repository/fetch/mod.rs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
pub mod current_repository;
|
||||||
|
pub mod http;
|
||||||
|
pub mod rsync;
|
||||||
|
pub mod rsync_system;
|
||||||
214
src/repository/fetch/rsync.rs
Normal file
214
src/repository/fetch/rsync.rs
Normal 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/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
927
src/repository/fetch/rsync_system.rs
Normal file
927
src/repository/fetch/rsync_system.rs
Normal 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
6
src/repository/mod.rs
Normal 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
43
src/repository/storage.rs
Normal 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;
|
||||||
43
src/repository/storage/config.rs
Normal file
43
src/repository/storage/config.rs
Normal 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()
|
||||||
|
}
|
||||||
130
src/repository/storage/keys.rs
Normal file
130
src/repository/storage/keys.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
191
src/repository/storage/memory.rs
Normal file
191
src/repository/storage/memory.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
153
src/repository/storage/models_core.rs
Normal file
153
src/repository/storage/models_core.rs
Normal 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;
|
||||||
117
src/repository/storage/models_summary.rs
Normal file
117
src/repository/storage/models_summary.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/repository/storage/pack.rs
Normal file
200
src/repository/storage/pack.rs
Normal 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
|
||||||
|
}
|
||||||
158
src/repository/storage/store_lifecycle.rs
Normal file
158
src/repository/storage/store_lifecycle.rs
Normal 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
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
33
src/repository/storage/store_manifest.rs
Normal file
33
src/repository/storage/store_manifest.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
363
src/repository/storage/store_repository.rs
Normal file
363
src/repository/storage/store_repository.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
108
src/repository/storage/store_transport_rrdp.rs
Normal file
108
src/repository/storage/store_transport_rrdp.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/repository/storage/tests.rs
Normal file
52
src/repository/storage/tests.rs
Normal 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");
|
||||||
234
src/repository/storage/tests_parts/object_loading.rs
Normal file
234
src/repository/storage/tests_parts/object_loading.rs
Normal 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}");
|
||||||
|
}
|
||||||
391
src/repository/storage/tests_parts/repository.rs
Normal file
391
src/repository/storage/tests_parts/repository.rs
Normal 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());
|
||||||
|
}
|
||||||
262
src/repository/storage/tests_parts/rrdp.rs
Normal file
262
src/repository/storage/tests_parts/rrdp.rs
Normal 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);
|
||||||
|
}
|
||||||
32
src/repository/storage/verification.rs
Normal file
32
src/repository/storage/verification.rs
Normal 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
Loading…
x
Reference in New Issue
Block a user