20260922 对齐 OSS 验证配置并补充 public TOML E2E
Some checks failed
ci / rust (push) Has been cancelled
ci / docker (push) Has been cancelled
ci / audit (push) Has been cancelled

This commit is contained in:
2026-09-22 22:27:10 +08:00
parent 09b4c40b9f
commit af693f5e6b
9 changed files with 779 additions and 48 deletions

View File

@@ -89,6 +89,14 @@ path types without network access, directory creation, state writes or locks.
`--print-config` emits `configVersion`, `mode`, `effective`, `sources` and
`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
optional CCR. The final `result-complete.json` is written only after all required
artifacts are ready. `resultStatus` is `success`, `partial` or `failed`:

View File

@@ -1,6 +1,6 @@
# 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
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. |
| `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. |
| `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.
Larger worker counts do not guarantee higher throughput; consider CPU, memory
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
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. |
| `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. |
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
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.
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
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
Check first, print effective configuration, then run normally. Check does not
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
and emits effective settings, sources and normalizations.
TALs, fixed TAs, constraints and extra HTTP roots, and validates rsync command
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
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. |
| 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. |
| 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. |
| 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. |

View File

@@ -13,12 +13,25 @@ use std::{
path::{Path, PathBuf},
time::Duration,
};
use time::OffsetDateTime;
use x509_parser::prelude::{FromDer, X509Certificate};
pub(crate) fn validate_certificate(tal: &Tal, bytes: &[u8]) -> Result<Value, String> {
TrustAnchor::bind_der(tal.clone(), bytes, None).map_err(|e| e.to_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())?;
}
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());
}
Ok(
@@ -31,8 +44,9 @@ pub(crate) fn cache_root(state: &Path) -> PathBuf {
PathBuf::from(name)
}
pub(crate) fn resolve(
pub(crate) fn resolve_at(
config: &Config,
validation_time: OffsetDateTime,
) -> Result<(Vec<crate::scheduler::types::TalInputSpec>, Vec<Value>), String> {
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(config.http_timeout_secs),
@@ -44,18 +58,41 @@ pub(crate) fn resolve(
.map_err(|e| e.to_string())?,
..Default::default()
})?;
let rsync = SystemRsyncFetcher::new(SystemRsyncConfig::default());
resolve_with(config, |uri| {
match url::Url::parse(uri).map_err(|e| e.to_string())?.scheme() {
let rsync = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: config
.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),
"rsync" => rsync.fetch_object(uri).map_err(|e| e.to_string()),
_ => Err("unsupported TA URI scheme".into()),
}
})
},
)
}
#[cfg(test)]
pub(crate) fn resolve_with(
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>,
) -> Result<(Vec<crate::scheduler::types::TalInputSpec>, Vec<Value>), String> {
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())?)
.map_err(|e| e.to_string())?;
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
.as_object_mut()
.unwrap()
@@ -82,27 +124,29 @@ pub(crate) fn resolve_with(
let mut error_code = "download_failed";
for uri in &tal.ta_uris {
match fetch(uri.as_str()) {
Ok(bytes) => match validate_certificate(&tal, &bytes) {
Ok(info) => {
record
.as_object_mut()
.unwrap()
.extend(info.as_object().unwrap().clone());
record["source"] = json!("download");
record["fetchedAt"] = json!(super::publication::now());
// Certificate and metadata are one atomic cache record; the .cer is a runtime projection.
super::publication::write_json(
&path,
&json!({"der":hex::encode(&bytes),"record":record}),
)?;
super::publication::write_atomic(&der_path, &bytes)?;
return Ok(der_path);
Ok(bytes) => {
match validate_certificate(&tal, &bytes, strict_name, validation_time) {
Ok(info) => {
record
.as_object_mut()
.unwrap()
.extend(info.as_object().unwrap().clone());
record["source"] = json!("download");
record["fetchedAt"] = json!(super::publication::now());
// Certificate and metadata are one atomic cache record; the .cer is a runtime projection.
super::publication::write_json(
&path,
&json!({"der":hex::encode(&bytes),"record":record}),
)?;
super::publication::write_atomic(&der_path, &bytes)?;
return Ok(der_path);
}
Err(e) => {
last_error = e;
error_code = "certificate_invalid";
}
}
Err(e) => {
last_error = e;
error_code = "certificate_invalid";
}
},
}
Err(e) => {
last_error = e;
}
@@ -134,9 +178,10 @@ pub(crate) fn resolve_with(
return Err("cached TA metadata has no DER value".into());
}
};
let info = validate_certificate(&tal, &bytes).inspect_err(|_| {
record["errorCode"] = json!("cache_unusable");
})?;
let info = validate_certificate(&tal, &bytes, strict_name, validation_time)
.inspect_err(|_| {
record["errorCode"] = json!("cache_unusable");
})?;
record
.as_object_mut()
.unwrap()

View File

@@ -1,8 +1,12 @@
//! 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_json::{Value, json};
use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use time::format_description::well_known::Rfc3339;
use time::{OffsetDateTime, UtcOffset};
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
@@ -34,6 +38,15 @@ pub struct Config {
pub log_progress_interval_secs: u64,
pub require_complete: bool,
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 rrdp_state_dir: Option<PathBuf>,
pub rrdp_sync_mode: String,
@@ -45,6 +58,56 @@ pub struct Config {
pub retain_runs: usize,
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 {
fn default() -> Self {
Self {
@@ -65,6 +128,14 @@ impl Default for Config {
log_progress_interval_secs: 10,
require_complete: false,
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,
rrdp_state_dir: None,
rrdp_sync_mode: "auto".into(),
@@ -113,6 +184,76 @@ pub(crate) fn absolute(base: &Path, path: &Path) -> Result<PathBuf, String> {
}
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> {
let path = absolute(&std::env::current_dir().map_err(|e| e.to_string())?, path)?;
let raw = std::fs::read_to_string(&path)
@@ -159,6 +300,7 @@ impl Config {
&mut config.ccr_out,
&mut config.publish_dir,
&mut config.state_root,
&mut config.rsync_mirror_root,
]
.into_iter()
.flatten()
@@ -202,6 +344,14 @@ impl Config {
}
config.validate_inputs()?;
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 {
normalizations.push(json!({"field":"http-timeout-secs","input":0,"effective":1}));
config.http_timeout_secs = 1;
@@ -211,6 +361,11 @@ impl Config {
config.parallel_repo_worker_queue_capacity = 1024;
}
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 {
effective.as_object_mut().unwrap().remove(*key);
}
@@ -240,6 +395,12 @@ impl Config {
if !["auto", "snapshot", "delta"].contains(&self.rrdp_sync_mode.as_str()) {
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)?;
if !["text", "json"].contains(&self.log_format.as_str()) {
return Err("invalid log-format".into());
@@ -270,6 +431,8 @@ impl Config {
super::anchors::validate_certificate(
&tal,
&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.rrdp_state_dir,
&self.publish_dir,
&self.rsync_mirror_root,
]
.into_iter()
.flatten()
@@ -415,6 +579,116 @@ fn collect_sources(
mod tests {
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]
fn constraints_selection_disable_override_and_reload() {
let dir = tempfile::tempdir().unwrap();

View File

@@ -1,5 +1,6 @@
//! Execute one validation attempt, then commit complete successful outputs.
use super::{config::Config, publication};
use crate::validation::policy::SyncPreference;
use serde_json::{Value, json};
use std::fs::{self, File, OpenOptions};
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 start = std::time::Instant::now();
let validation_time = config.effective_validation_time()?;
let sync_preference = config.sync_preference()?;
crate::logging::emit(
crate::logging::Level::Info,
"validation_started",
|| 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();
if inputs.is_empty() {
publication::write_json(
@@ -122,7 +125,14 @@ fn execute(config: &Config, run_id: &str) -> Result<i32, String> {
max_ca_depth: config.max_ca_depth,
http_timeout_secs: config.http_timeout_secs,
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")),
vrps_csv_out_path: Some(root.join(".vrps-source.csv")),
vaps_csv_out_path: Some(root.join("vaps.csv")),

View File

@@ -42,10 +42,12 @@ impl ResourceValidationMode {
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
#[serde(default, deny_unknown_fields)]
pub struct StrictPolicy {
pub name: bool,
#[serde(alias = "cms-der")]
pub cms_der: bool,
#[serde(alias = "signed-attrs")]
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]
fn policy_toml_accepts_resource_validation_mode() {
let policy = Policy::from_toml_str(

12
tests/config_e2e.rs Normal file
View 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}"
);
}

View File

@@ -20,6 +20,7 @@ from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
from cryptography.x509 import AccessDescription, AuthorityInformationAccess, DistributionPoint, UniformResourceIdentifier
from cryptography.x509.name import _ASN1Type
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")
TEST_ASN = 64496
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:
@@ -317,7 +326,7 @@ def make_roa(
) -> None:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
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,
key.public_key(),
child_key,
@@ -338,7 +347,7 @@ def make_roa(
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)
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,
key.public_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:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
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,
key.public_key(),
child_key,
@@ -538,7 +547,7 @@ def make_https_certificates(output: Path) -> None:
def main() -> None:
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
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, required=True)
@@ -571,12 +580,18 @@ def main() -> None:
default=RSYNC_PORT,
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()
RRDP_HOST = args.rrdp_host
FOREIGN_RRDP_HOST = args.foreign_rrdp_host
RRDP_PORT = args.rrdp_port
RSYNC_HOST = args.rsync_host
RSYNC_PORT = args.rsync_port
STRICT_NAMES = args.strict_names
TAL_URI = f"rsync://{RSYNC_HOST}:{RSYNC_PORT}/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"
@@ -592,8 +607,8 @@ def main() -> None:
root_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")])
child_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Local Test RPKI Child")])
root_name = x509.Name([common_name("Local Test RPKI Root")])
child_name = x509.Name([common_name("Local Test RPKI Child")])
root_cert = sign_cert(
root_name, root_name, root_key.public_key(), root_key, 0x14700001, True, root_key,
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,
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_cert = sign_cert(
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"
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"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,"
"validation-roa-prefix-outside,validation-out-of-resource,validation-ca-over-resource,validation-nonstandard\n",
encoding="utf-8",

View 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()))