diff --git a/docs/command-line-reference.md b/docs/command-line-reference.md index 5db2937..55276c4 100644 --- a/docs/command-line-reference.md +++ b/docs/command-line-reference.md @@ -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`: diff --git a/docs/configuration.md b/docs/configuration.md index 476978b..d44b64b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 `.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. | diff --git a/src/cli/anchors.rs b/src/cli/anchors.rs index 3b7eb2d..a37fd16 100644 --- a/src/cli/anchors.rs +++ b/src/cli/anchors.rs @@ -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 { - 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 { + 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, Vec), 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, String>, +) -> Result<(Vec, Vec), 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, String>, ) -> Result<(Vec, Vec), 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() diff --git a/src/cli/config.rs b/src/cli/config.rs index 95fb9c2..7964bf1 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -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, + pub sync_preference: String, + pub rsync_timeout_secs: u64, + pub rsync_command: Option, + pub rsync_mirror_root: Option, + pub rsync_scope: RsyncScopePolicy, pub out: Option, pub rrdp_state_dir: Option, 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(value: &StrictPolicy, serializer: S) -> Result + 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 + 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 { } impl Config { + fn parse_validation_time(raw: &str) -> Result { + 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 { + value + .to_offset(UtcOffset::UTC) + .format(&Rfc3339) + .map_err(|e| format!("format validation-time failed: {e}")) + } + + fn normalize_validation_time(value: OffsetDateTime) -> Result { + 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 { + 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 { + 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::( + 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(); diff --git a/src/cli/validate.rs b/src/cli/validate.rs index dd5a8a2..907ae0c 100644 --- a/src/cli/validate.rs +++ b/src/cli/validate.rs @@ -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 { } 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 { 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")), diff --git a/src/validation/policy.rs b/src/validation/policy.rs index 5dba5d8..0b95aab 100644 --- a/src/validation/policy.rs +++ b/src/validation/policy.rs @@ -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( diff --git a/tests/config_e2e.rs b/tests/config_e2e.rs new file mode 100644 index 0000000..bdaec7e --- /dev/null +++ b/tests/config_e2e.rs @@ -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}" + ); +} diff --git a/tests/support/generate_repository.py b/tests/support/generate_repository.py index 3d2849f..e2d4d0b 100644 --- a/tests/support/generate_repository.py +++ b/tests/support/generate_repository.py @@ -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", diff --git a/tests/support/test_public_config.py b/tests/support/test_public_config.py new file mode 100644 index 0000000..62257fb --- /dev/null +++ b/tests/support/test_public_config.py @@ -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()))