20260922 对齐 OSS 验证配置并补充 public TOML E2E
This commit is contained in:
@@ -89,6 +89,14 @@ path types without network access, directory creation, state writes or locks.
|
|||||||
`--print-config` emits `configVersion`, `mode`, `effective`, `sources` and
|
`--print-config` emits `configVersion`, `mode`, `effective`, `sources` and
|
||||||
`normalizations` as JSON; defaults are filled and paths are absolute.
|
`normalizations` as JSON; defaults are filled and paths are absolute.
|
||||||
|
|
||||||
|
Strict validation, resource validation mode, validation time, RRDP/rsync
|
||||||
|
preference and all rsync tuning are TOML settings, not business CLI options.
|
||||||
|
Use `[strict]` plus the top-level `resource-validation-mode`,
|
||||||
|
`validation-time`, `sync-preference`, `rsync-timeout-secs`, `rsync-command`,
|
||||||
|
`rsync-mirror-root` and `rsync-scope` keys. `--print-config` exposes their
|
||||||
|
effective values and sources; an omitted validation time is generated for the
|
||||||
|
inspection and recaptured when an actual run starts.
|
||||||
|
|
||||||
Validation writes `summary.json`, `vrps.csv`, `vaps.csv`, timing files and an
|
Validation writes `summary.json`, `vrps.csv`, `vaps.csv`, timing files and an
|
||||||
optional CCR. The final `result-complete.json` is written only after all required
|
optional CCR. The final `result-complete.json` is written only after all required
|
||||||
artifacts are ready. `resultStatus` is `success`, `partial` or `failed`:
|
artifacts are ready. `resultStatus` is `success`, `partial` or `failed`:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Complete TOML configuration reference (schema v1)
|
# Complete TOML configuration reference (schema v1)
|
||||||
|
|
||||||
This reference documents all 26 top-level settings and four anchor fields. The product provides validation and a daemon
|
This reference documents all top-level settings and four anchor fields. The product provides validation and a daemon
|
||||||
that schedules validation cycles. SLURM, RTR, ROV, HTTP/API services, offline
|
that schedules validation cycles. SLURM, RTR, ROV, HTTP/API services, offline
|
||||||
validation and additional payload JSON export are outside this configuration.
|
validation and additional payload JSON export are outside this configuration.
|
||||||
|
|
||||||
@@ -66,11 +66,43 @@ those choices do not change the built-in defaults.
|
|||||||
| `log-progress-interval-secs` | Nonnegative integer, seconds | 10 | Periodic progress interval. Zero disables periodic progress, not other stage messages. Levels off/error/warn do not start a progress thread. |
|
| `log-progress-interval-secs` | Nonnegative integer, seconds | 10 | Periodic progress interval. Zero disables periodic progress, not other stage messages. Levels off/error/warn do not start a progress thread. |
|
||||||
| `require-complete` | Boolean | false | A partial run exits 0 when false, 3 when true. Does not change validation rules, turn partial into success, or permit partial publication. |
|
| `require-complete` | Boolean | false | A partial run exits 0 when false, 3 when true. Does not change validation rules, turn partial into success, or permit partial publication. |
|
||||||
| `run-timeout-secs` | Nonnegative integer, seconds | 0 | Whole-run budget; zero disables it. Validate includes runtime TA acquisition and output preparation. Daemon measures from child startup and enforces the budget in the controller. Check/print do not use this budget. |
|
| `run-timeout-secs` | Nonnegative integer, seconds | 0 | Whole-run budget; zero disables it. Validate includes runtime TA acquisition and output preparation. Daemon measures from child startup and enforces the budget in the controller. Check/print do not use this budget. |
|
||||||
|
| `resource-validation-mode` | String | `validation-update-03` | Resource validation profile. Allowed values are `validation-update-03` and `rfc6487`; the selected profile is passed to object and certificate validation. |
|
||||||
|
| `validation-time` | RFC3339 string | Omitted: current UTC at run start | Logical validation clock for certificate, CRL and object validity. The value is normalized to UTC whole seconds. Network timeouts, TA `fetchedAt` and run timing remain wall-clock time. |
|
||||||
|
| `sync-preference` | String | `rrdp-then-rsync` | Repository transport preference. `rrdp-then-rsync` uses RRDP with rsync fallback; `rsync-only` disables RRDP. This is independent from `rrdp-sync-mode`, which controls state/snapshot/delta handling. |
|
||||||
|
| `rsync-timeout-secs` | Positive integer, seconds | 30 | Rsync I/O timeout. Zero is rejected; it is not an unlimited value. |
|
||||||
|
| `rsync-command` | Absolute executable path | Omitted: `rsync` from `PATH` | Replacement rsync executable. The path must name an existing executable file. Shell strings, wrappers with inline arguments and argument arrays are not accepted. |
|
||||||
|
| `rsync-mirror-root` | Directory path | Omitted | Persistent root for rsync mirrors. Relative paths are resolved against the configuration file; the root and per-module directories are created at runtime as needed. |
|
||||||
|
| `rsync-scope` | String | `module-root` | Rsync request scope. Allowed values are `host`, `publication-point` and `module-root`. |
|
||||||
|
| `strict` | TOML table | All three fields false | Strict validation switches; see section 2.1. |
|
||||||
|
|
||||||
Integers are also limited by TOML and the target platform's integer types.
|
Integers are also limited by TOML and the target platform's integer types.
|
||||||
Larger worker counts do not guarantee higher throughput; consider CPU, memory
|
Larger worker counts do not guarantee higher throughput; consider CPU, memory
|
||||||
and repository-service load.
|
and repository-service load.
|
||||||
|
|
||||||
|
### 2.1 Strict validation table
|
||||||
|
|
||||||
|
The optional `[strict]` table exposes the strict capabilities already used by
|
||||||
|
the private RP. Omit the table, or set all fields to `false`, for compatibility
|
||||||
|
mode. Set all fields to `true` for the complete strict profile; combinations are
|
||||||
|
allowed.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[strict]
|
||||||
|
name = true
|
||||||
|
cms-der = true
|
||||||
|
signed-attrs = false
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` enables strict RPKI resource-certificate name checks, including TA
|
||||||
|
binding checks. `cms-der` rejects CMS encodings that are BER-compatible but not
|
||||||
|
strict DER; when it is false, the existing compatibility path may accept the
|
||||||
|
object and emit a warning. `signed-attrs` is retained for policy compatibility,
|
||||||
|
but it does not invent a second behavior branch: the required, duplicate and
|
||||||
|
unknown signed-attribute profile checks remain fixed as in the private RP.
|
||||||
|
|
||||||
|
`strict` is a table, not a list, and it has no separate `enabled` field. Unknown
|
||||||
|
fields inside the table are rejected.
|
||||||
|
|
||||||
## 3. Validate-only settings
|
## 3. Validate-only settings
|
||||||
|
|
||||||
These keys are rejected in daemon configurations, even when set to their usual
|
These keys are rejected in daemon configurations, even when set to their usual
|
||||||
@@ -113,13 +145,14 @@ otherwise 3 if strict completion is enabled and any cycle was partial; otherwise
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `id` | String | TAL filename stem | Identifier in results, logs and constraints. An explicitly empty string also falls back to the filename stem. Resolved IDs must be nonempty and unique; explicit names are preferred. |
|
| `id` | String | TAL filename stem | Identifier in results, logs and constraints. An explicitly empty string also falls back to the filename stem. Resolved IDs must be nonempty and unique; explicit names are preferred. |
|
||||||
| `tal` | File path | Required | TAL obtained from an independently trusted source; must exist and parse during preflight. Contains certificate URIs and the trusted public key. |
|
| `tal` | File path | Required | TAL obtained from an independently trusted source; must exist and parse during preflight. Contains certificate URIs and the trusted public key. |
|
||||||
| `ta` | File path | Omitted: automatic mode | If present, selects a fixed DER TA certificate. Preflight checks binding, certificate profile/signature and validity. Network downloads do not silently replace it. |
|
| `ta` | File path | Omitted: automatic mode | If present, selects a fixed DER TA certificate. Preflight checks binding, certificate profile/signature and validity at `validation-time` (or the current UTC run clock). Network downloads do not silently replace it. |
|
||||||
| `constraints` | File path | Discover same-stem `.constraints` | For ripe.tal, try ripe.constraints. Absence of that file means no additional constraints. When ta-constraints-enabled is true, an explicit path must exist and parse. Omitting this setting does not disable discovery; an empty string is not a disable switch. Set top-level `ta-constraints-enabled = false` to disable all constraints. |
|
| `constraints` | File path | Discover same-stem `.constraints` | For ripe.tal, try ripe.constraints. Absence of that file means no additional constraints. When ta-constraints-enabled is true, an explicit path must exist and parse. Omitting this setting does not disable discovery; an empty string is not a disable switch. Set top-level `ta-constraints-enabled = false` to disable all constraints. |
|
||||||
|
|
||||||
Automatic refresh updates the **TA certificate, never the TAL file**. Each
|
Automatic refresh updates the **TA certificate, never the TAL file**. Each
|
||||||
validate run or daemon cycle tries the TAL's HTTPS/rsync URIs in order. There is
|
validate run or daemon cycle tries the TAL's HTTPS/rsync URIs in order. There is
|
||||||
no separate background refresh timer, and no invented HTTPS alternative for an
|
no separate background refresh timer, and no invented HTTPS alternative for an
|
||||||
rsync-only TAL. Candidates must pass certificate and TAL-binding validation.
|
rsync-only TAL. Candidates must pass certificate and TAL-binding validation at
|
||||||
|
the configured logical validation time.
|
||||||
|
|
||||||
The cache is `<rrdp-state-dir>.ta-cache`, keyed by the TAL public-key SHA-256.
|
The cache is `<rrdp-state-dir>.ta-cache`, keyed by the TAL public-key SHA-256.
|
||||||
Download failure permits fallback only under allow-valid-cache with a usable,
|
Download failure permits fallback only under allow-valid-cache with a usable,
|
||||||
@@ -257,12 +290,43 @@ Replace existing top-level settings; do not add duplicate keys. Prepare the
|
|||||||
custom TLS PEM file first, or keep [] when no extra roots are needed. This does
|
custom TLS PEM file first, or keep [] when no extra roots are needed. This does
|
||||||
not change the TAL public key, relax TA validation or disable system TLS checks.
|
not change the TAL public key, relax TA validation or disable system TLS checks.
|
||||||
|
|
||||||
|
### 8.4 Validation policy and rsync transport: top-level fragment
|
||||||
|
|
||||||
|
```toml
|
||||||
|
resource-validation-mode = "validation-update-03" # or "rfc6487"
|
||||||
|
validation-time = "2026-09-22T00:00:00Z" # omit for the UTC clock at run start
|
||||||
|
sync-preference = "rrdp-then-rsync" # or "rsync-only"
|
||||||
|
rsync-timeout-secs = 30
|
||||||
|
rsync-command = "/usr/bin/rsync" # omit to use rsync from PATH
|
||||||
|
rsync-mirror-root = "state/rsync-mirror" # optional persistent mirror root
|
||||||
|
rsync-scope = "module-root" # host, publication-point, or module-root
|
||||||
|
|
||||||
|
[strict]
|
||||||
|
name = false
|
||||||
|
cms-der = false
|
||||||
|
signed-attrs = false
|
||||||
|
```
|
||||||
|
|
||||||
|
`validation-time` is the logical time used for certificate, CRL and resource
|
||||||
|
validity throughout one run. If omitted, the run captures one UTC value at
|
||||||
|
startup; `--print-config` shows an inspection-time effective value, while the
|
||||||
|
actual run captures its own value. `fetchedAt`, network timeout measurement and
|
||||||
|
run duration remain wall-clock metadata.
|
||||||
|
|
||||||
|
`rsync-command` is passed directly to the process launcher. It must be an
|
||||||
|
absolute executable path, with no shell syntax or inline arguments. The mirror
|
||||||
|
root and scope affect repository rsync synchronization; they do not turn
|
||||||
|
`rrdp-sync-mode = "delta"` into rsync-only. Use `sync-preference = "rsync-only"`
|
||||||
|
for that transport choice.
|
||||||
|
|
||||||
## 9. Preflight and troubleshooting
|
## 9. Preflight and troubleshooting
|
||||||
|
|
||||||
Check first, print effective configuration, then run normally. Check does not
|
Check first, print effective configuration, then run normally. Check does not
|
||||||
use the network, take runtime locks or create run directories. It reads TOML,
|
use the network, take runtime locks or create run directories. It reads TOML,
|
||||||
TALs, fixed TAs, constraints and extra HTTP roots. Print performs the same checks
|
TALs, fixed TAs, constraints and extra HTTP roots, and validates rsync command
|
||||||
and emits effective settings, sources and normalizations.
|
paths without invoking them. Print performs the same checks and emits effective
|
||||||
|
settings, sources and normalizations. An omitted `validation-time` is shown as
|
||||||
|
a generated default and is recaptured when the actual validation run starts.
|
||||||
|
|
||||||
For example, setting http-timeout-secs to 0 and the repository queue to 256
|
For example, setting http-timeout-secs to 0 and the repository queue to 256
|
||||||
produces this excerpt, not the full print output:
|
produces this excerpt, not the full print output:
|
||||||
@@ -287,6 +351,9 @@ produces this excerpt, not the full print output:
|
|||||||
| Unknown anchor field log-format | Move it before the first [[anchors]] table. |
|
| Unknown anchor field log-format | Move it before the first [[anchors]] table. |
|
||||||
| Out is not allowed in daemon | Remove validate-only keys; the controller owns output. |
|
| Out is not allowed in daemon | Remove validate-only keys; the controller owns output. |
|
||||||
| Zero workers/queues/max-runs/retention | Use positive values; omit max-runs for unlimited cycles. |
|
| Zero workers/queues/max-runs/retention | Use positive values; omit max-runs for unlimited cycles. |
|
||||||
|
| Invalid validation-time | Use an RFC3339 timestamp such as `2026-09-22T00:00:00Z`; offsets are accepted and normalized to UTC. |
|
||||||
|
| Invalid rsync command | Use an existing absolute executable path only; omit the field to use `rsync` from `PATH`. |
|
||||||
|
| RRDP still used | `rrdp-sync-mode = "delta"` is not a transport preference; set `sync-preference = "rsync-only"`. |
|
||||||
| File not found | Resolve paths against the config directory; check container mounts. |
|
| File not found | Resolve paths against the config directory; check container mounts. |
|
||||||
| No automatic TA cache | First use needs a successful download; fixed cer files do not seed the cache. |
|
| No automatic TA cache | First use needs a successful download; fixed cer files do not seed the cache. |
|
||||||
| Preflight passes, runtime fails | Check does not test network reachability, runtime locks or actual writes. |
|
| Preflight passes, runtime fails | Check does not test network reachability, runtime locks or actual writes. |
|
||||||
|
|||||||
@@ -13,12 +13,25 @@ use std::{
|
|||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
use time::OffsetDateTime;
|
||||||
use x509_parser::prelude::{FromDer, X509Certificate};
|
use x509_parser::prelude::{FromDer, X509Certificate};
|
||||||
|
|
||||||
pub(crate) fn validate_certificate(tal: &Tal, bytes: &[u8]) -> Result<Value, String> {
|
pub(crate) fn validate_certificate(
|
||||||
|
tal: &Tal,
|
||||||
|
bytes: &[u8],
|
||||||
|
strict_name: bool,
|
||||||
|
validation_time: OffsetDateTime,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
if strict_name {
|
||||||
|
TrustAnchor::bind_der_with_strict_name(tal.clone(), bytes, None)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
TrustAnchor::bind_der(tal.clone(), bytes, None).map_err(|e| e.to_string())?;
|
TrustAnchor::bind_der(tal.clone(), bytes, None).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
let (_, cert) = X509Certificate::from_der(bytes).map_err(|e| e.to_string())?;
|
let (_, cert) = X509Certificate::from_der(bytes).map_err(|e| e.to_string())?;
|
||||||
if !cert.validity().is_valid() {
|
let not_before = cert.validity().not_before.to_datetime();
|
||||||
|
let not_after = cert.validity().not_after.to_datetime();
|
||||||
|
if validation_time < not_before || validation_time > not_after {
|
||||||
return Err("TA certificate is expired or not yet valid".into());
|
return Err("TA certificate is expired or not yet valid".into());
|
||||||
}
|
}
|
||||||
Ok(
|
Ok(
|
||||||
@@ -31,8 +44,9 @@ pub(crate) fn cache_root(state: &Path) -> PathBuf {
|
|||||||
PathBuf::from(name)
|
PathBuf::from(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn resolve(
|
pub(crate) fn resolve_at(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
|
validation_time: OffsetDateTime,
|
||||||
) -> Result<(Vec<crate::scheduler::types::TalInputSpec>, Vec<Value>), String> {
|
) -> Result<(Vec<crate::scheduler::types::TalInputSpec>, Vec<Value>), String> {
|
||||||
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
|
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
|
||||||
timeout: Duration::from_secs(config.http_timeout_secs),
|
timeout: Duration::from_secs(config.http_timeout_secs),
|
||||||
@@ -44,18 +58,41 @@ pub(crate) fn resolve(
|
|||||||
.map_err(|e| e.to_string())?,
|
.map_err(|e| e.to_string())?,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})?;
|
})?;
|
||||||
let rsync = SystemRsyncFetcher::new(SystemRsyncConfig::default());
|
let rsync = SystemRsyncFetcher::new(SystemRsyncConfig {
|
||||||
resolve_with(config, |uri| {
|
rsync_bin: config
|
||||||
match url::Url::parse(uri).map_err(|e| e.to_string())?.scheme() {
|
.rsync_command
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("rsync")),
|
||||||
|
timeout: Duration::from_secs(config.rsync_timeout_secs.max(1)),
|
||||||
|
mirror_root: config.rsync_mirror_root.clone(),
|
||||||
|
scope_policy: config.rsync_scope,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
resolve_with_at(
|
||||||
|
config,
|
||||||
|
validation_time,
|
||||||
|
config.strict.name,
|
||||||
|
|uri| match url::Url::parse(uri).map_err(|e| e.to_string())?.scheme() {
|
||||||
"https" => http.fetch_bytes(uri),
|
"https" => http.fetch_bytes(uri),
|
||||||
"rsync" => rsync.fetch_object(uri).map_err(|e| e.to_string()),
|
"rsync" => rsync.fetch_object(uri).map_err(|e| e.to_string()),
|
||||||
_ => Err("unsupported TA URI scheme".into()),
|
_ => Err("unsupported TA URI scheme".into()),
|
||||||
}
|
},
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) fn resolve_with(
|
pub(crate) fn resolve_with(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
|
fetch: impl FnMut(&str) -> Result<Vec<u8>, String>,
|
||||||
|
) -> Result<(Vec<crate::scheduler::types::TalInputSpec>, Vec<Value>), String> {
|
||||||
|
let validation_time = config.effective_validation_time()?;
|
||||||
|
resolve_with_at(config, validation_time, config.strict.name, fetch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_with_at(
|
||||||
|
config: &Config,
|
||||||
|
validation_time: OffsetDateTime,
|
||||||
|
strict_name: bool,
|
||||||
mut fetch: impl FnMut(&str) -> Result<Vec<u8>, String>,
|
mut fetch: impl FnMut(&str) -> Result<Vec<u8>, String>,
|
||||||
) -> Result<(Vec<crate::scheduler::types::TalInputSpec>, Vec<Value>), String> {
|
) -> Result<(Vec<crate::scheduler::types::TalInputSpec>, Vec<Value>), String> {
|
||||||
let root = cache_root(config.rrdp_state_dir.as_ref().ok_or("state path missing")?);
|
let root = cache_root(config.rrdp_state_dir.as_ref().ok_or("state path missing")?);
|
||||||
@@ -67,7 +104,12 @@ pub(crate) fn resolve_with(
|
|||||||
let tal = Tal::decode_bytes(&fs::read(&anchor.tal).map_err(|e| e.to_string())?)
|
let tal = Tal::decode_bytes(&fs::read(&anchor.tal).map_err(|e| e.to_string())?)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if let Some(path) = &anchor.ta {
|
if let Some(path) = &anchor.ta {
|
||||||
let info = validate_certificate(&tal, &fs::read(path).map_err(|e| e.to_string())?)?;
|
let info = validate_certificate(
|
||||||
|
&tal,
|
||||||
|
&fs::read(path).map_err(|e| e.to_string())?,
|
||||||
|
strict_name,
|
||||||
|
validation_time,
|
||||||
|
)?;
|
||||||
record
|
record
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -82,7 +124,8 @@ pub(crate) fn resolve_with(
|
|||||||
let mut error_code = "download_failed";
|
let mut error_code = "download_failed";
|
||||||
for uri in &tal.ta_uris {
|
for uri in &tal.ta_uris {
|
||||||
match fetch(uri.as_str()) {
|
match fetch(uri.as_str()) {
|
||||||
Ok(bytes) => match validate_certificate(&tal, &bytes) {
|
Ok(bytes) => {
|
||||||
|
match validate_certificate(&tal, &bytes, strict_name, validation_time) {
|
||||||
Ok(info) => {
|
Ok(info) => {
|
||||||
record
|
record
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
@@ -102,7 +145,8 @@ pub(crate) fn resolve_with(
|
|||||||
last_error = e;
|
last_error = e;
|
||||||
error_code = "certificate_invalid";
|
error_code = "certificate_invalid";
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
last_error = e;
|
last_error = e;
|
||||||
}
|
}
|
||||||
@@ -134,7 +178,8 @@ pub(crate) fn resolve_with(
|
|||||||
return Err("cached TA metadata has no DER value".into());
|
return Err("cached TA metadata has no DER value".into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let info = validate_certificate(&tal, &bytes).inspect_err(|_| {
|
let info = validate_certificate(&tal, &bytes, strict_name, validation_time)
|
||||||
|
.inspect_err(|_| {
|
||||||
record["errorCode"] = json!("cache_unusable");
|
record["errorCode"] = json!("cache_unusable");
|
||||||
})?;
|
})?;
|
||||||
record
|
record
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
//! One explicit TOML file is the sole source of runtime settings.
|
//! One explicit TOML file is the sole source of runtime settings.
|
||||||
|
use crate::repository::fetch::rsync_system::RsyncScopePolicy;
|
||||||
|
use crate::validation::policy::{ResourceValidationMode, StrictPolicy, SyncPreference};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
use time::{OffsetDateTime, UtcOffset};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
|
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
|
||||||
@@ -34,6 +38,15 @@ pub struct Config {
|
|||||||
pub log_progress_interval_secs: u64,
|
pub log_progress_interval_secs: u64,
|
||||||
pub require_complete: bool,
|
pub require_complete: bool,
|
||||||
pub run_timeout_secs: u64,
|
pub run_timeout_secs: u64,
|
||||||
|
#[serde(with = "strict_config_serde")]
|
||||||
|
pub strict: StrictPolicy,
|
||||||
|
pub resource_validation_mode: ResourceValidationMode,
|
||||||
|
pub validation_time: Option<String>,
|
||||||
|
pub sync_preference: String,
|
||||||
|
pub rsync_timeout_secs: u64,
|
||||||
|
pub rsync_command: Option<PathBuf>,
|
||||||
|
pub rsync_mirror_root: Option<PathBuf>,
|
||||||
|
pub rsync_scope: RsyncScopePolicy,
|
||||||
pub out: Option<PathBuf>,
|
pub out: Option<PathBuf>,
|
||||||
pub rrdp_state_dir: Option<PathBuf>,
|
pub rrdp_state_dir: Option<PathBuf>,
|
||||||
pub rrdp_sync_mode: String,
|
pub rrdp_sync_mode: String,
|
||||||
@@ -45,6 +58,56 @@ pub struct Config {
|
|||||||
pub retain_runs: usize,
|
pub retain_runs: usize,
|
||||||
pub shutdown_grace_secs: u64,
|
pub shutdown_grace_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
struct StrictConfigFields {
|
||||||
|
name: bool,
|
||||||
|
#[serde(rename = "cms-der", alias = "cms_der")]
|
||||||
|
cms_der: bool,
|
||||||
|
#[serde(rename = "signed-attrs", alias = "signed_attrs")]
|
||||||
|
signed_attrs: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StrictConfigFields {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: false,
|
||||||
|
cms_der: false,
|
||||||
|
signed_attrs: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod strict_config_serde {
|
||||||
|
use super::{StrictConfigFields, StrictPolicy};
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
|
pub(super) fn serialize<S>(value: &StrictPolicy, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
StrictConfigFields {
|
||||||
|
name: value.name,
|
||||||
|
cms_der: value.cms_der,
|
||||||
|
signed_attrs: value.signed_attrs,
|
||||||
|
}
|
||||||
|
.serialize(serializer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<StrictPolicy, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let fields = StrictConfigFields::deserialize(deserializer)?;
|
||||||
|
Ok(StrictPolicy {
|
||||||
|
name: fields.name,
|
||||||
|
cms_der: fields.cms_der,
|
||||||
|
signed_attrs: fields.signed_attrs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -65,6 +128,14 @@ impl Default for Config {
|
|||||||
log_progress_interval_secs: 10,
|
log_progress_interval_secs: 10,
|
||||||
require_complete: false,
|
require_complete: false,
|
||||||
run_timeout_secs: 0,
|
run_timeout_secs: 0,
|
||||||
|
strict: StrictPolicy::default(),
|
||||||
|
resource_validation_mode: ResourceValidationMode::default(),
|
||||||
|
validation_time: None,
|
||||||
|
sync_preference: "rrdp-then-rsync".into(),
|
||||||
|
rsync_timeout_secs: 30,
|
||||||
|
rsync_command: None,
|
||||||
|
rsync_mirror_root: None,
|
||||||
|
rsync_scope: RsyncScopePolicy::default(),
|
||||||
out: None,
|
out: None,
|
||||||
rrdp_state_dir: None,
|
rrdp_state_dir: None,
|
||||||
rrdp_sync_mode: "auto".into(),
|
rrdp_sync_mode: "auto".into(),
|
||||||
@@ -113,6 +184,76 @@ pub(crate) fn absolute(base: &Path, path: &Path) -> Result<PathBuf, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
fn parse_validation_time(raw: &str) -> Result<OffsetDateTime, String> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err("validation-time must not be empty".into());
|
||||||
|
}
|
||||||
|
let parsed = OffsetDateTime::parse(raw, &Rfc3339)
|
||||||
|
.map_err(|e| format!("invalid validation-time (RFC3339 required): {e}"))?;
|
||||||
|
OffsetDateTime::from_unix_timestamp(parsed.unix_timestamp())
|
||||||
|
.map(|value| value.to_offset(UtcOffset::UTC))
|
||||||
|
.map_err(|e| format!("invalid validation-time: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_validation_time(value: OffsetDateTime) -> Result<String, String> {
|
||||||
|
value
|
||||||
|
.to_offset(UtcOffset::UTC)
|
||||||
|
.format(&Rfc3339)
|
||||||
|
.map_err(|e| format!("format validation-time failed: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_validation_time(value: OffsetDateTime) -> Result<OffsetDateTime, String> {
|
||||||
|
OffsetDateTime::from_unix_timestamp(value.unix_timestamp())
|
||||||
|
.map(|value| value.to_offset(UtcOffset::UTC))
|
||||||
|
.map_err(|e| format!("normalize validation-time failed: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn effective_validation_time(&self) -> Result<OffsetDateTime, String> {
|
||||||
|
let value = self
|
||||||
|
.validation_time
|
||||||
|
.as_deref()
|
||||||
|
.map(Self::parse_validation_time)
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_else(OffsetDateTime::now_utc);
|
||||||
|
Self::normalize_validation_time(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sync_preference(&self) -> Result<SyncPreference, String> {
|
||||||
|
match self.sync_preference.trim() {
|
||||||
|
"rrdp-then-rsync" => Ok(SyncPreference::RrdpThenRsync),
|
||||||
|
"rsync-only" => Ok(SyncPreference::RsyncOnly),
|
||||||
|
value => Err(format!(
|
||||||
|
"invalid sync-preference: {value}; expected rrdp-then-rsync or rsync-only"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_rsync_command(&self) -> Result<(), String> {
|
||||||
|
let Some(path) = &self.rsync_command else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err("rsync-command must be an absolute executable path".into());
|
||||||
|
}
|
||||||
|
let metadata = std::fs::metadata(path)
|
||||||
|
.map_err(|e| format!("rsync-command is not usable: {}: {e}", path.display()))?;
|
||||||
|
if !metadata.is_file() {
|
||||||
|
return Err(format!("rsync-command is not a file: {}", path.display()));
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
if metadata.permissions().mode() & 0o111 == 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"rsync-command is not executable: {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn load(path: &Path, mode: &str) -> Result<(Self, Value), String> {
|
pub(crate) fn load(path: &Path, mode: &str) -> Result<(Self, Value), String> {
|
||||||
let path = absolute(&std::env::current_dir().map_err(|e| e.to_string())?, path)?;
|
let path = absolute(&std::env::current_dir().map_err(|e| e.to_string())?, path)?;
|
||||||
let raw = std::fs::read_to_string(&path)
|
let raw = std::fs::read_to_string(&path)
|
||||||
@@ -159,6 +300,7 @@ impl Config {
|
|||||||
&mut config.ccr_out,
|
&mut config.ccr_out,
|
||||||
&mut config.publish_dir,
|
&mut config.publish_dir,
|
||||||
&mut config.state_root,
|
&mut config.state_root,
|
||||||
|
&mut config.rsync_mirror_root,
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
@@ -202,6 +344,14 @@ impl Config {
|
|||||||
}
|
}
|
||||||
config.validate_inputs()?;
|
config.validate_inputs()?;
|
||||||
let mut normalizations = Vec::new();
|
let mut normalizations = Vec::new();
|
||||||
|
if let Some(raw) = config.validation_time.as_deref() {
|
||||||
|
let normalized = Self::format_validation_time(Self::parse_validation_time(raw)?)?;
|
||||||
|
if raw != normalized {
|
||||||
|
normalizations
|
||||||
|
.push(json!({"field":"validation-time","input":raw,"effective":normalized}));
|
||||||
|
}
|
||||||
|
config.validation_time = Some(normalized);
|
||||||
|
}
|
||||||
if config.http_timeout_secs < 1 {
|
if config.http_timeout_secs < 1 {
|
||||||
normalizations.push(json!({"field":"http-timeout-secs","input":0,"effective":1}));
|
normalizations.push(json!({"field":"http-timeout-secs","input":0,"effective":1}));
|
||||||
config.http_timeout_secs = 1;
|
config.http_timeout_secs = 1;
|
||||||
@@ -211,6 +361,11 @@ impl Config {
|
|||||||
config.parallel_repo_worker_queue_capacity = 1024;
|
config.parallel_repo_worker_queue_capacity = 1024;
|
||||||
}
|
}
|
||||||
let mut effective = serde_json::to_value(&config).map_err(|e| e.to_string())?;
|
let mut effective = serde_json::to_value(&config).map_err(|e| e.to_string())?;
|
||||||
|
if config.validation_time.is_none() {
|
||||||
|
effective["validation-time"] = json!(Self::format_validation_time(
|
||||||
|
config.effective_validation_time()?
|
||||||
|
)?);
|
||||||
|
}
|
||||||
for key in forbidden {
|
for key in forbidden {
|
||||||
effective.as_object_mut().unwrap().remove(*key);
|
effective.as_object_mut().unwrap().remove(*key);
|
||||||
}
|
}
|
||||||
@@ -240,6 +395,12 @@ impl Config {
|
|||||||
if !["auto", "snapshot", "delta"].contains(&self.rrdp_sync_mode.as_str()) {
|
if !["auto", "snapshot", "delta"].contains(&self.rrdp_sync_mode.as_str()) {
|
||||||
return Err("invalid rrdp-sync-mode".into());
|
return Err("invalid rrdp-sync-mode".into());
|
||||||
}
|
}
|
||||||
|
self.sync_preference()?;
|
||||||
|
let validation_time = self.effective_validation_time()?;
|
||||||
|
if self.rsync_timeout_secs == 0 {
|
||||||
|
return Err("rsync-timeout-secs must be positive".into());
|
||||||
|
}
|
||||||
|
self.validate_rsync_command()?;
|
||||||
crate::logging::parse_level(&self.log_level)?;
|
crate::logging::parse_level(&self.log_level)?;
|
||||||
if !["text", "json"].contains(&self.log_format.as_str()) {
|
if !["text", "json"].contains(&self.log_format.as_str()) {
|
||||||
return Err("invalid log-format".into());
|
return Err("invalid log-format".into());
|
||||||
@@ -270,6 +431,8 @@ impl Config {
|
|||||||
super::anchors::validate_certificate(
|
super::anchors::validate_certificate(
|
||||||
&tal,
|
&tal,
|
||||||
&std::fs::read(ta).map_err(|e| format!("read TA: {e}"))?,
|
&std::fs::read(ta).map_err(|e| format!("read TA: {e}"))?,
|
||||||
|
self.strict.name,
|
||||||
|
validation_time,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +453,7 @@ impl Config {
|
|||||||
&self.state_root,
|
&self.state_root,
|
||||||
&self.rrdp_state_dir,
|
&self.rrdp_state_dir,
|
||||||
&self.publish_dir,
|
&self.publish_dir,
|
||||||
|
&self.rsync_mirror_root,
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
@@ -415,6 +579,116 @@ fn collect_sources(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_time_is_parsed_as_utc_whole_seconds() {
|
||||||
|
let parsed = Config::parse_validation_time("2026-09-22T08:00:00.750+08:00").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
Config::format_validation_time(parsed).unwrap(),
|
||||||
|
"2026-09-22T00:00:00Z"
|
||||||
|
);
|
||||||
|
assert_eq!(parsed.nanosecond(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_table_rejects_unknown_fields() {
|
||||||
|
let error = toml::from_str::<Config>(
|
||||||
|
r#"
|
||||||
|
[strict]
|
||||||
|
enabled = true
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect_err("unknown strict field must be rejected");
|
||||||
|
assert!(error.to_string().contains("unknown field"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_alignment_fields_load_and_print_effective_values() {
|
||||||
|
let fixture = crate::test_support::synthetic_repository();
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let config_path = directory.path().join("validate.toml");
|
||||||
|
let executable = std::env::current_exe().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
&config_path,
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
config-version = 1
|
||||||
|
mode = "validate"
|
||||||
|
out = "out"
|
||||||
|
resource-validation-mode = "rfc6487"
|
||||||
|
validation-time = "2026-09-22T08:00:00+08:00"
|
||||||
|
sync-preference = "rsync-only"
|
||||||
|
rsync-timeout-secs = 47
|
||||||
|
rsync-command = {:?}
|
||||||
|
rsync-mirror-root = "mirror"
|
||||||
|
rsync-scope = "publication-point"
|
||||||
|
|
||||||
|
[strict]
|
||||||
|
name = true
|
||||||
|
cms-der = true
|
||||||
|
signed-attrs = false
|
||||||
|
|
||||||
|
[[anchors]]
|
||||||
|
id = "custom"
|
||||||
|
tal = {:?}
|
||||||
|
"#,
|
||||||
|
executable.to_string_lossy(),
|
||||||
|
fixture.tal().to_string_lossy(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (config, printed) = Config::load(&config_path, "validate").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
config.strict,
|
||||||
|
StrictPolicy {
|
||||||
|
name: true,
|
||||||
|
cms_der: true,
|
||||||
|
signed_attrs: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.resource_validation_mode,
|
||||||
|
ResourceValidationMode::Rfc6487
|
||||||
|
);
|
||||||
|
assert_eq!(config.sync_preference().unwrap(), SyncPreference::RsyncOnly);
|
||||||
|
assert_eq!(config.rsync_timeout_secs, 47);
|
||||||
|
assert_eq!(config.rsync_scope, RsyncScopePolicy::PublicationPoint);
|
||||||
|
assert_eq!(
|
||||||
|
config.effective_validation_time().unwrap().unix_timestamp(),
|
||||||
|
Config::parse_validation_time("2026-09-22T00:00:00Z")
|
||||||
|
.unwrap()
|
||||||
|
.unix_timestamp()
|
||||||
|
);
|
||||||
|
assert_eq!(printed["effective"]["resource-validation-mode"], "rfc6487");
|
||||||
|
assert_eq!(printed["effective"]["sync-preference"], "rsync-only");
|
||||||
|
assert_eq!(printed["effective"]["rsync-timeout-secs"], 47);
|
||||||
|
assert_eq!(
|
||||||
|
printed["effective"]["validation-time"],
|
||||||
|
"2026-09-22T00:00:00Z"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
printed["sources"]["strict.cms-der"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("file:"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
printed["sources"]["validation-time"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("file:"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
printed["normalizations"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|item| item["field"] == "validation-time")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn constraints_selection_disable_override_and_reload() {
|
fn constraints_selection_disable_override_and_reload() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Execute one validation attempt, then commit complete successful outputs.
|
//! Execute one validation attempt, then commit complete successful outputs.
|
||||||
use super::{config::Config, publication};
|
use super::{config::Config, publication};
|
||||||
|
use crate::validation::policy::SyncPreference;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::{BufRead, BufReader, BufWriter, Write};
|
use std::io::{BufRead, BufReader, BufWriter, Write};
|
||||||
@@ -75,12 +76,14 @@ fn execute(config: &Config, run_id: &str) -> Result<i32, String> {
|
|||||||
}
|
}
|
||||||
let observer = crate::logging::observer::Observer::start(config.log_progress_interval_secs)?;
|
let observer = crate::logging::observer::Observer::start(config.log_progress_interval_secs)?;
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
let validation_time = config.effective_validation_time()?;
|
||||||
|
let sync_preference = config.sync_preference()?;
|
||||||
crate::logging::emit(
|
crate::logging::emit(
|
||||||
crate::logging::Level::Info,
|
crate::logging::Level::Info,
|
||||||
"validation_started",
|
"validation_started",
|
||||||
|| json!({"tal_count":config.anchors.len(),"sync_mode":config.rrdp_sync_mode}),
|
|| json!({"tal_count":config.anchors.len(),"sync_mode":config.rrdp_sync_mode}),
|
||||||
);
|
);
|
||||||
let (inputs, anchors) = super::anchors::resolve(config)?;
|
let (inputs, anchors) = super::anchors::resolve_at(config, validation_time)?;
|
||||||
let failed = config.anchors.len() - inputs.len();
|
let failed = config.anchors.len() - inputs.len();
|
||||||
if inputs.is_empty() {
|
if inputs.is_empty() {
|
||||||
publication::write_json(
|
publication::write_json(
|
||||||
@@ -122,7 +125,14 @@ fn execute(config: &Config, run_id: &str) -> Result<i32, String> {
|
|||||||
max_ca_depth: config.max_ca_depth,
|
max_ca_depth: config.max_ca_depth,
|
||||||
http_timeout_secs: config.http_timeout_secs,
|
http_timeout_secs: config.http_timeout_secs,
|
||||||
http_root_cert_paths: config.http_root_cert.clone(),
|
http_root_cert_paths: config.http_root_cert.clone(),
|
||||||
rsync_timeout_secs: 30,
|
strict_policy: Some(config.strict),
|
||||||
|
resource_validation_mode: Some(config.resource_validation_mode),
|
||||||
|
disable_rrdp: sync_preference == SyncPreference::RsyncOnly,
|
||||||
|
rsync_command: config.rsync_command.clone(),
|
||||||
|
rsync_timeout_secs: config.rsync_timeout_secs,
|
||||||
|
rsync_mirror_root: config.rsync_mirror_root.clone(),
|
||||||
|
rsync_scope_policy: config.rsync_scope,
|
||||||
|
validation_time: Some(validation_time),
|
||||||
summary_out_path: Some(root.join("summary.json")),
|
summary_out_path: Some(root.join("summary.json")),
|
||||||
vrps_csv_out_path: Some(root.join(".vrps-source.csv")),
|
vrps_csv_out_path: Some(root.join(".vrps-source.csv")),
|
||||||
vaps_csv_out_path: Some(root.join("vaps.csv")),
|
vaps_csv_out_path: Some(root.join("vaps.csv")),
|
||||||
|
|||||||
@@ -42,10 +42,12 @@ impl ResourceValidationMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct StrictPolicy {
|
pub struct StrictPolicy {
|
||||||
pub name: bool,
|
pub name: bool,
|
||||||
|
#[serde(alias = "cms-der")]
|
||||||
pub cms_der: bool,
|
pub cms_der: bool,
|
||||||
|
#[serde(alias = "signed-attrs")]
|
||||||
pub signed_attrs: bool,
|
pub signed_attrs: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +182,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn policy_toml_accepts_public_kebab_case_strict_keys() {
|
||||||
|
let policy = Policy::from_toml_str(
|
||||||
|
r#"
|
||||||
|
[strict]
|
||||||
|
name = true
|
||||||
|
cms-der = true
|
||||||
|
signed-attrs = false
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("parse public strict keys");
|
||||||
|
assert_eq!(
|
||||||
|
policy.strict,
|
||||||
|
StrictPolicy {
|
||||||
|
name: true,
|
||||||
|
cms_der: true,
|
||||||
|
signed_attrs: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn policy_toml_accepts_resource_validation_mode() {
|
fn policy_toml_accepts_resource_validation_mode() {
|
||||||
let policy = Policy::from_toml_str(
|
let policy = Policy::from_toml_str(
|
||||||
|
|||||||
12
tests/config_e2e.rs
Normal file
12
tests/config_e2e.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#[test]
|
||||||
|
fn public_toml_validation_settings_are_end_to_end() {
|
||||||
|
let status = std::process::Command::new("python3")
|
||||||
|
.arg("tests/support/test_public_config.py")
|
||||||
|
.arg(env!("CARGO_BIN_EXE_panda-rpki"))
|
||||||
|
.status()
|
||||||
|
.expect("start public TOML configuration integration driver");
|
||||||
|
assert!(
|
||||||
|
status.success(),
|
||||||
|
"public TOML configuration E2E failed: {status}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ from cryptography.hazmat.primitives import hashes, serialization
|
|||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
|
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
|
||||||
from cryptography.x509 import AccessDescription, AuthorityInformationAccess, DistributionPoint, UniformResourceIdentifier
|
from cryptography.x509 import AccessDescription, AuthorityInformationAccess, DistributionPoint, UniformResourceIdentifier
|
||||||
|
from cryptography.x509.name import _ASN1Type
|
||||||
from cryptography.x509.oid import AuthorityInformationAccessOID, NameOID, ObjectIdentifier
|
from cryptography.x509.oid import AuthorityInformationAccessOID, NameOID, ObjectIdentifier
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +56,14 @@ SECOND_PREFIX = ipaddress.ip_network("203.0.113.128/25")
|
|||||||
OUTSIDE_PREFIX = ipaddress.ip_network("203.0.114.0/24")
|
OUTSIDE_PREFIX = ipaddress.ip_network("203.0.114.0/24")
|
||||||
TEST_ASN = 64496
|
TEST_ASN = 64496
|
||||||
SESSION_ID = "11111111-2222-4333-8444-555555555555"
|
SESSION_ID = "11111111-2222-4333-8444-555555555555"
|
||||||
|
STRICT_NAMES = False
|
||||||
|
|
||||||
|
|
||||||
|
def common_name(value: str) -> x509.NameAttribute:
|
||||||
|
"""Build the fixture CN with the DER string type selected by the test."""
|
||||||
|
if STRICT_NAMES:
|
||||||
|
return x509.NameAttribute(NameOID.COMMON_NAME, value, _type=_ASN1Type.PrintableString)
|
||||||
|
return x509.NameAttribute(NameOID.COMMON_NAME, value)
|
||||||
|
|
||||||
|
|
||||||
def tlv(tag: int, value: bytes) -> bytes:
|
def tlv(tag: int, value: bytes) -> bytes:
|
||||||
@@ -317,7 +326,7 @@ def make_roa(
|
|||||||
) -> None:
|
) -> None:
|
||||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
cert = sign_cert(
|
cert = sign_cert(
|
||||||
x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"Local Test EE {name}")]),
|
x509.Name([common_name(f"Local Test EE {name}")]),
|
||||||
child_name,
|
child_name,
|
||||||
key.public_key(),
|
key.public_key(),
|
||||||
child_key,
|
child_key,
|
||||||
@@ -338,7 +347,7 @@ def make_roa(
|
|||||||
def make_aspa(name: str, child_name, child_key, common: Path, work: Path) -> None:
|
def make_aspa(name: str, child_name, child_key, common: Path, work: Path) -> None:
|
||||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
cert = sign_cert(
|
cert = sign_cert(
|
||||||
x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"Local Test EE {name}")]),
|
x509.Name([common_name(f"Local Test EE {name}")]),
|
||||||
child_name,
|
child_name,
|
||||||
key.public_key(),
|
key.public_key(),
|
||||||
child_key,
|
child_key,
|
||||||
@@ -359,7 +368,7 @@ def make_aspa(name: str, child_name, child_key, common: Path, work: Path) -> Non
|
|||||||
def make_manifest(name: str, number: int, files: list[tuple[str, bytes]], child_name, child_key, common: Path, work: Path) -> bytes:
|
def make_manifest(name: str, number: int, files: list[tuple[str, bytes]], child_name, child_key, common: Path, work: Path) -> bytes:
|
||||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
cert = sign_cert(
|
cert = sign_cert(
|
||||||
x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"Local Test Manifest EE {name}")]),
|
x509.Name([common_name(f"Local Test Manifest EE {name}")]),
|
||||||
child_name,
|
child_name,
|
||||||
key.public_key(),
|
key.public_key(),
|
||||||
child_key,
|
child_key,
|
||||||
@@ -538,7 +547,7 @@ def make_https_certificates(output: Path) -> None:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
global RRDP_HOST, FOREIGN_RRDP_HOST, RRDP_PORT, RSYNC_HOST, RSYNC_PORT
|
global RRDP_HOST, FOREIGN_RRDP_HOST, RRDP_PORT, RSYNC_HOST, RSYNC_PORT
|
||||||
global TAL_URI, TAL_HTTPS_URI, RRDP_URI
|
global TAL_URI, TAL_HTTPS_URI, RRDP_URI, STRICT_NAMES
|
||||||
global ROOT_REPO_URI, ROOT_MFT_URI, CHILD_REPO_URI, CHILD_MFT_URI
|
global ROOT_REPO_URI, ROOT_MFT_URI, CHILD_REPO_URI, CHILD_MFT_URI
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--output", type=Path, required=True)
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
@@ -571,12 +580,18 @@ def main() -> None:
|
|||||||
default=RSYNC_PORT,
|
default=RSYNC_PORT,
|
||||||
help="port embedded in rsync URIs (default: 873)",
|
help="port embedded in rsync URIs (default: 873)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--strict-names",
|
||||||
|
action="store_true",
|
||||||
|
help="encode RPKI commonName values as PrintableString for strict-name tests",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
RRDP_HOST = args.rrdp_host
|
RRDP_HOST = args.rrdp_host
|
||||||
FOREIGN_RRDP_HOST = args.foreign_rrdp_host
|
FOREIGN_RRDP_HOST = args.foreign_rrdp_host
|
||||||
RRDP_PORT = args.rrdp_port
|
RRDP_PORT = args.rrdp_port
|
||||||
RSYNC_HOST = args.rsync_host
|
RSYNC_HOST = args.rsync_host
|
||||||
RSYNC_PORT = args.rsync_port
|
RSYNC_PORT = args.rsync_port
|
||||||
|
STRICT_NAMES = args.strict_names
|
||||||
TAL_URI = f"rsync://{RSYNC_HOST}:{RSYNC_PORT}/custom/ta.cer"
|
TAL_URI = f"rsync://{RSYNC_HOST}:{RSYNC_PORT}/custom/ta.cer"
|
||||||
TAL_HTTPS_URI = f"https://{RRDP_HOST}:{RRDP_PORT}/ta/custom-ta.cer"
|
TAL_HTTPS_URI = f"https://{RRDP_HOST}:{RRDP_PORT}/ta/custom-ta.cer"
|
||||||
RRDP_URI = f"https://{RRDP_HOST}:{RRDP_PORT}/rrdp/notification.xml"
|
RRDP_URI = f"https://{RRDP_HOST}:{RRDP_PORT}/rrdp/notification.xml"
|
||||||
@@ -592,8 +607,8 @@ def main() -> None:
|
|||||||
|
|
||||||
root_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
root_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
child_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
child_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
root_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Local Test RPKI Root")])
|
root_name = x509.Name([common_name("Local Test RPKI Root")])
|
||||||
child_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Local Test RPKI Child")])
|
child_name = x509.Name([common_name("Local Test RPKI Child")])
|
||||||
root_cert = sign_cert(
|
root_cert = sign_cert(
|
||||||
root_name, root_name, root_key.public_key(), root_key, 0x14700001, True, root_key,
|
root_name, root_name, root_key.public_key(), root_key, 0x14700001, True, root_key,
|
||||||
ip_ext=ip_resources([CHILD_PREFIX]),
|
ip_ext=ip_resources([CHILD_PREFIX]),
|
||||||
@@ -606,7 +621,7 @@ def main() -> None:
|
|||||||
ca_repository=CHILD_REPO_URI, manifest_uri=CHILD_MFT_URI, notify_uri=RRDP_URI,
|
ca_repository=CHILD_REPO_URI, manifest_uri=CHILD_MFT_URI, notify_uri=RRDP_URI,
|
||||||
crl_uri=f"{ROOT_REPO_URI}root.crl", ca_issuer_uri=f"{ROOT_REPO_URI}child.cer",
|
crl_uri=f"{ROOT_REPO_URI}root.crl", ca_issuer_uri=f"{ROOT_REPO_URI}child.cer",
|
||||||
)
|
)
|
||||||
bad_child_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Local Test RPKI Overclaiming Child")])
|
bad_child_name = x509.Name([common_name("Local Test RPKI Overclaiming Child")])
|
||||||
bad_child_repo_uri = f"rsync://{RSYNC_HOST}:{RSYNC_PORT}/custom/bad-child/"
|
bad_child_repo_uri = f"rsync://{RSYNC_HOST}:{RSYNC_PORT}/custom/bad-child/"
|
||||||
bad_child_cert = sign_cert(
|
bad_child_cert = sign_cert(
|
||||||
bad_child_name, root_name, child_key.public_key(), root_key, 0x14700003, True, child_key, root_key,
|
bad_child_name, root_name, child_key.public_key(), root_key, 0x14700003, True, child_key, root_key,
|
||||||
@@ -706,6 +721,7 @@ def main() -> None:
|
|||||||
"fixture_schema_version=1\nfixture_kind=stack-local-test-rpki-repo\n"
|
"fixture_schema_version=1\nfixture_kind=stack-local-test-rpki-repo\n"
|
||||||
f"tal_uri={TAL_URI}\ntal_https_uri={TAL_HTTPS_URI}\nrrdp_uri={RRDP_URI}\nrsync_module=custom\n"
|
f"tal_uri={TAL_URI}\ntal_https_uri={TAL_HTTPS_URI}\nrrdp_uri={RRDP_URI}\nrsync_module=custom\n"
|
||||||
f"rrdp_host={RRDP_HOST}\nforeign_rrdp_host={FOREIGN_RRDP_HOST}\nrrdp_port={RRDP_PORT}\nrsync_host={RSYNC_HOST}\nrsync_port={RSYNC_PORT}\n"
|
f"rrdp_host={RRDP_HOST}\nforeign_rrdp_host={FOREIGN_RRDP_HOST}\nrrdp_port={RRDP_PORT}\nrsync_host={RSYNC_HOST}\nrsync_port={RSYNC_PORT}\n"
|
||||||
|
f"strict_names={str(STRICT_NAMES).lower()}\n"
|
||||||
"cases=baseline-v1,sync-hash-mismatch,rrdp-cross-origin-snapshot,baseline-v2,validation-expired,validation-max-length,validation-max-length-invalid,"
|
"cases=baseline-v1,sync-hash-mismatch,rrdp-cross-origin-snapshot,baseline-v2,validation-expired,validation-max-length,validation-max-length-invalid,"
|
||||||
"validation-roa-prefix-outside,validation-out-of-resource,validation-ca-over-resource,validation-nonstandard\n",
|
"validation-roa-prefix-outside,validation-out-of-resource,validation-ca-over-resource,validation-nonstandard\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
|
|||||||
276
tests/support/test_public_config.py
Normal file
276
tests/support/test_public_config.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"""End-to-end coverage for the #170 public TOML validation settings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def free_port() -> int:
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return int(sock.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
def toml_string(path: Path) -> str:
|
||||||
|
return json.dumps(str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def run_checked(args: list[str], *, timeout: int = 120) -> subprocess.CompletedProcess[str]:
|
||||||
|
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(
|
||||||
|
f"command failed ({result.returncode}): {' '.join(args)}\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_server(server: subprocess.Popen[str]) -> None:
|
||||||
|
assert server.stdout is not None
|
||||||
|
line = server.stdout.readline().strip()
|
||||||
|
if line != "ready":
|
||||||
|
raise AssertionError(f"RRDP fixture server did not become ready: {line!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_process(process: subprocess.Popen[str] | None) -> None:
|
||||||
|
if process is None:
|
||||||
|
return
|
||||||
|
if process.poll() is None:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def write_config(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
fixture: Path,
|
||||||
|
output: Path,
|
||||||
|
state: Path,
|
||||||
|
ccr: Path,
|
||||||
|
wrapper: Path,
|
||||||
|
mirror: Path,
|
||||||
|
sync_preference: str,
|
||||||
|
rrdp_mode: str,
|
||||||
|
validation_time: str,
|
||||||
|
resource_mode: str,
|
||||||
|
scope: str,
|
||||||
|
) -> None:
|
||||||
|
path.write_text(
|
||||||
|
f'''config-version = 1
|
||||||
|
mode = "validate"
|
||||||
|
out = {toml_string(output)}
|
||||||
|
rrdp-state-dir = {toml_string(state)}
|
||||||
|
rrdp-sync-mode = "{rrdp_mode}"
|
||||||
|
ccr-out = {toml_string(ccr)}
|
||||||
|
http-timeout-secs = 2
|
||||||
|
http-root-cert = [{toml_string(fixture / "certs/rrdp-ca.pem")}]
|
||||||
|
require-complete = true
|
||||||
|
parallel-max-repo-sync-workers-global = 2
|
||||||
|
parallel-phase2-object-workers = 2
|
||||||
|
log-level = "info"
|
||||||
|
log-format = "json"
|
||||||
|
resource-validation-mode = "{resource_mode}"
|
||||||
|
validation-time = "{validation_time}"
|
||||||
|
sync-preference = "{sync_preference}"
|
||||||
|
rsync-timeout-secs = 47
|
||||||
|
rsync-command = {toml_string(wrapper)}
|
||||||
|
rsync-mirror-root = {toml_string(mirror)}
|
||||||
|
rsync-scope = "{scope}"
|
||||||
|
|
||||||
|
[strict]
|
||||||
|
name = true
|
||||||
|
cms-der = true
|
||||||
|
signed-attrs = true
|
||||||
|
|
||||||
|
[[anchors]]
|
||||||
|
id = "custom"
|
||||||
|
tal = {toml_string(fixture / "tal/custom.tal")}
|
||||||
|
ta = {toml_string(fixture / "ta/custom-ta.cer")}
|
||||||
|
''',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_rsync_wrapper(path: Path, fixture: Path, log: Path) -> None:
|
||||||
|
repository = fixture / "cases" / "baseline-v1" / "repository"
|
||||||
|
path.write_text(
|
||||||
|
f'''#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
repository = Path({str(repository)!r})
|
||||||
|
log = Path({str(log)!r})
|
||||||
|
args = sys.argv[1:]
|
||||||
|
with log.open("a", encoding="utf-8") as stream:
|
||||||
|
stream.write(json.dumps(args) + "\\n")
|
||||||
|
|
||||||
|
if len(args) < 2:
|
||||||
|
raise SystemExit("rsync wrapper expected source and destination")
|
||||||
|
source_uri, destination_text = args[-2:]
|
||||||
|
destination = Path(destination_text)
|
||||||
|
parts = [part for part in urlparse(source_uri).path.split("/") if part]
|
||||||
|
if not parts or parts[0] != "custom":
|
||||||
|
raise SystemExit(f"unexpected fixture rsync source: {{source_uri}}")
|
||||||
|
relative = Path(*parts[1:]) if len(parts) > 1 else Path()
|
||||||
|
source = repository / relative
|
||||||
|
if not source.exists():
|
||||||
|
raise SystemExit(f"fixture rsync source does not exist: {{source}}")
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
if source.is_file():
|
||||||
|
shutil.copy2(source, destination / source.name)
|
||||||
|
else:
|
||||||
|
for item in source.iterdir():
|
||||||
|
target = destination / item.name
|
||||||
|
if item.is_dir():
|
||||||
|
shutil.copytree(item, target, dirs_exist_ok=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(item, target)
|
||||||
|
''',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
path.chmod(0o755)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path) -> dict:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def assert_successful_output(output: Path, expected_validation_time: str) -> None:
|
||||||
|
summary = read_json(output / "summary.json")
|
||||||
|
assert summary["resultStatus"] == "success", summary
|
||||||
|
assert summary["vrps"] == 1, summary
|
||||||
|
assert (output / "result-complete.json").is_file()
|
||||||
|
assert len((output / "vrps.csv").read_text(encoding="utf-8").splitlines()) == 2
|
||||||
|
|
||||||
|
timing = read_json(output / "analysis" / "timing.json")
|
||||||
|
assert timing["meta"]["validation_time_utc_rfc3339"] == expected_validation_time, timing
|
||||||
|
|
||||||
|
|
||||||
|
def run(binary: str) -> None:
|
||||||
|
support = Path(__file__).resolve().parent
|
||||||
|
with tempfile.TemporaryDirectory(prefix="panda-rpki-public-config-") as tmp:
|
||||||
|
base = Path(tmp)
|
||||||
|
fixture = base / "fixture"
|
||||||
|
port = free_port()
|
||||||
|
run_checked(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(support / "generate_repository.py"),
|
||||||
|
"--rrdp-host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--rrdp-port",
|
||||||
|
str(port),
|
||||||
|
"--rsync-host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--strict-names",
|
||||||
|
"--output",
|
||||||
|
str(fixture),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
active = fixture / "active"
|
||||||
|
active.symlink_to("cases/baseline-v1/http")
|
||||||
|
|
||||||
|
wrapper_log = base / "rsync-wrapper.log"
|
||||||
|
wrapper = base / "rsync-wrapper.py"
|
||||||
|
make_rsync_wrapper(wrapper, fixture, wrapper_log)
|
||||||
|
|
||||||
|
server = subprocess.Popen(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(support / "serve_repository.py"),
|
||||||
|
str(active),
|
||||||
|
str(port),
|
||||||
|
str(fixture / "certs/rrdp-server.pem"),
|
||||||
|
str(fixture / "certs/rrdp-server.key"),
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
wait_for_server(server)
|
||||||
|
fixed_input = "2026-06-01T08:00:00.250+08:00"
|
||||||
|
fixed_effective = "2026-06-01T00:00:00Z"
|
||||||
|
rrdp_output = base / "rrdp-output"
|
||||||
|
rrdp_state = base / "rrdp-state"
|
||||||
|
rrdp_config = base / "rrdp.toml"
|
||||||
|
write_config(
|
||||||
|
rrdp_config,
|
||||||
|
fixture=fixture,
|
||||||
|
output=rrdp_output,
|
||||||
|
state=rrdp_state,
|
||||||
|
ccr=rrdp_output / "result.ccr",
|
||||||
|
wrapper=wrapper,
|
||||||
|
mirror=base / "rrdp-mirror",
|
||||||
|
sync_preference="rrdp-then-rsync",
|
||||||
|
rrdp_mode="snapshot",
|
||||||
|
validation_time=fixed_input,
|
||||||
|
resource_mode="validation-update-03",
|
||||||
|
scope="publication-point",
|
||||||
|
)
|
||||||
|
run_checked([binary, "validate", "--config", str(rrdp_config)])
|
||||||
|
assert_successful_output(rrdp_output, fixed_effective)
|
||||||
|
assert not wrapper_log.exists() or not wrapper_log.read_text(encoding="utf-8").strip(), (
|
||||||
|
"RRDP success path unexpectedly invoked the rsync command"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
stop_process(server)
|
||||||
|
|
||||||
|
rsync_output = base / "rsync-output"
|
||||||
|
rsync_state = base / "rsync-state"
|
||||||
|
rsync_mirror = base / "rsync-mirror"
|
||||||
|
rsync_config = base / "rsync.toml"
|
||||||
|
write_config(
|
||||||
|
rsync_config,
|
||||||
|
fixture=fixture,
|
||||||
|
output=rsync_output,
|
||||||
|
state=rsync_state,
|
||||||
|
ccr=rsync_output / "result.ccr",
|
||||||
|
wrapper=wrapper,
|
||||||
|
mirror=rsync_mirror,
|
||||||
|
sync_preference="rsync-only",
|
||||||
|
rrdp_mode="auto",
|
||||||
|
validation_time="2026-06-01T00:00:00Z",
|
||||||
|
resource_mode="rfc6487",
|
||||||
|
scope="publication-point",
|
||||||
|
)
|
||||||
|
# The RRDP server is stopped. A successful run therefore proves that
|
||||||
|
# sync-preference came from public TOML and reached the scheduler.
|
||||||
|
run_checked([binary, "validate", "--config", str(rsync_config)])
|
||||||
|
assert_successful_output(rsync_output, "2026-06-01T00:00:00Z")
|
||||||
|
|
||||||
|
calls = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in wrapper_log.read_text(encoding="utf-8").splitlines()
|
||||||
|
if line.strip()
|
||||||
|
]
|
||||||
|
assert calls, "rsync-only validation did not invoke configured rsync command"
|
||||||
|
assert all("--timeout" in call and call[call.index("--timeout") + 1] == "47" for call in calls), calls
|
||||||
|
sources = [call[-2] for call in calls]
|
||||||
|
assert any("/custom/root/" in source for source in sources), sources
|
||||||
|
assert any("/custom/child/" in source for source in sources), sources
|
||||||
|
assert all("/custom/" in source and source.rstrip("/") != "rsync://127.0.0.1:873/custom" for source in sources), sources
|
||||||
|
mirror_files = [path for path in rsync_mirror.rglob("*") if path.is_file()]
|
||||||
|
assert mirror_files, f"configured rsync mirror remained empty: {rsync_mirror}"
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Public TOML E2E passed: strict/resource/time + RRDP path + "
|
||||||
|
"rsync-only command/timeout/mirror/scope"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run(str(Path(sys.argv[1]).resolve()))
|
||||||
Reference in New Issue
Block a user