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

@@ -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(