From bcfea792e87a27516ad72384b6ae00b0f888d7de Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 17 Sep 2026 04:08:17 +0800 Subject: [PATCH] fix(config): tolerate unused startup settings Ignore unused YAML fields across startup configuration and report their paths without values. Report missing required fields while retaining existing type and value validation. Validation: configuration and bootstrap tests, strict Clippy, and architecture checks passed. Co-authored-by: Codex --- backend/Cargo.lock | 11 ++ backend/apps/gateway/src/bootstrap.rs | 7 +- backend/apps/gateway/tests/bootstrap.rs | 117 +++++++++++++++--- backend/crates/gateway-admin/src/lib.rs | 2 - backend/crates/gateway-api/src/lib.rs | 1 - backend/crates/gateway-host/Cargo.toml | 1 + backend/crates/gateway-host/src/config.rs | 40 +++++- .../gateway-host/src/system_update/mod.rs | 2 +- backend/crates/gateway-host/tests/config.rs | 7 +- backend/crates/gateway-store/src/config.rs | 4 +- backend/crates/gateway-store/tests/config.rs | 7 +- backend/crates/providers/openai/src/config.rs | 6 - backend/crates/providers/xai/src/config.rs | 2 - backend/crates/providers/xai/tests/config.rs | 2 +- 14 files changed, 161 insertions(+), 48 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 3d7f5ab86..f92a5215e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1541,6 +1541,7 @@ dependencies = [ "scraper", "semver", "serde", + "serde_ignored", "serde_json", "sha2 0.11.0", "tar", @@ -3346,6 +3347,16 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.151" diff --git a/backend/apps/gateway/src/bootstrap.rs b/backend/apps/gateway/src/bootstrap.rs index 8acf94525..fc7590e09 100644 --- a/backend/apps/gateway/src/bootstrap.rs +++ b/backend/apps/gateway/src/bootstrap.rs @@ -7,8 +7,8 @@ use serde::Deserialize; const CONFIG_SCHEMA_VERSION: u32 = 1; /// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。 +/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。 #[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] pub struct GatewayConfig { schema_version: u32, host: HostConfig, @@ -19,11 +19,11 @@ pub struct GatewayConfig { api: gateway_api::ApiConfig, openai: provider_openai::OpenAiConfig, xai: provider_xai::XaiConfig, - #[serde(default, rename = "services")] - _compose_services: Option, } impl LoadableConfig for GatewayConfig { + const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"]; + fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> { if self.schema_version != CONFIG_SCHEMA_VERSION { return Err(ConfigError::InvalidField("schema_version")); @@ -65,7 +65,6 @@ pub async fn run() -> Result<(), BootstrapError> { api, openai, xai, - _compose_services: _, } = config; let host = gateway_host::initialize(host).await?; diff --git a/backend/apps/gateway/tests/bootstrap.rs b/backend/apps/gateway/tests/bootstrap.rs index 94632aaae..895830729 100644 --- a/backend/apps/gateway/tests/bootstrap.rs +++ b/backend/apps/gateway/tests/bootstrap.rs @@ -272,16 +272,107 @@ fn bootstrap_config_debug_should_redact_all_passwords() { } #[test] -fn config_loader_should_reject_unknown_fields() { - assert_rejected(format!( - "{}\nunknown_terminal_field: true\n", - valid_config() - )); +fn config_loader_should_ignore_unknown_fields_in_configuration_sections() { + let mut document = valid_config_document(); + document["host"]["system_update"] = serde_json::json!({}); + document["store"]["pool"] = serde_json::json!({}); + for path in [ + "", + "/host", + "/host/listen", + "/host/logging", + "/host/logging/file", + "/host/system_update", + "/store", + "/store/database", + "/store/redis", + "/store/pool", + "/admin", + "/client", + "/api", + "/openai", + "/openai/wire_profile", + "/xai", + "/xai/wire_profile", + ] { + let mut extended = document.clone(); + extended.pointer_mut(path).expect("configuration section")["unused_setting"] = + serde_json::json!({"nested": [true, null, "ignored"]}); + parse_config(&extended.to_string()) + .unwrap_or_else(|error| panic!("unknown field in {path}: {error}")); + } } #[test] -fn config_loader_should_reject_removed_tls_section() { - assert_rejected(valid_config().replace("openai:\n", "openai:\n tls: {}\n")); +fn config_loader_should_ignore_removed_tls_and_fingerprint_sections() { + let mut document = valid_config_document(); + document["openai"]["tls"] = serde_json::json!({}); + document["openai"]["fingerprint"] = serde_json::json!({"browser": "removed"}); + parse_config(&document.to_string()).expect("removed provider settings are ignored"); +} + +#[test] +fn config_loader_should_reject_invalid_known_fields_alongside_unknown_fields() { + for port in [serde_json::json!(0), serde_json::json!("not-a-port")] { + let mut document = valid_config_document(); + document["host"]["listen"]["unused_setting"] = serde_json::json!(true); + document["host"]["listen"]["port"] = port; + assert_rejected(document.to_string()); + } +} + +#[test] +fn config_loader_should_report_startup_configuration_diagnostics() { + const CHILD_ENV: &str = "CPR_TEST_CONFIG_DIAGNOSTICS_CHILD"; + const UNUSED_SECRET: &str = "unused-value-must-not-appear-in-diagnostics"; + if std::env::var_os(CHILD_ENV).is_some() { + gateway_host::load_config::().expect("startup configuration"); + return; + } + for case in ["normal", "unused", "missing"] { + let mut document = valid_config_document(); + if case == "unused" { + document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET); + } else if case == "missing" { + document["host"]["listen"] + .as_object_mut() + .unwrap() + .remove("port"); + } + let directory = tempfile::tempdir().unwrap(); + fs::create_dir(directory.path().join("deploy")).unwrap(); + fs::write( + directory.path().join("deploy/config.yaml"), + document.to_string(), + ) + .unwrap(); + let output = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "bootstrap::config_loader_should_report_startup_configuration_diagnostics", + "--nocapture", + ]) + .env(CHILD_ENV, "1") + .current_dir(directory.path()) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.success(), + case != "missing", + "{case}: {stderr}" + ); + match case { + "unused" => assert!( + stderr.contains("openai.wire_profile.location") && stderr.contains("已忽略"), + "{stderr}" + ), + "missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"), + _ => assert!(!stderr.contains("警告"), "{stderr}"), + } + assert!(!stderr.contains(UNUSED_SECRET), "{stderr}"); + assert!(!stderr.contains("services"), "{stderr}"); + } } #[test] @@ -353,14 +444,6 @@ fn config_loader_should_reject_missing_client_session_ttl() { assert_rejected(config.to_string()); } -#[test] -fn config_loader_should_reject_removed_fingerprint_section() { - assert_rejected(valid_config().replace( - "openai:\n", - "openai:\n fingerprint:\n browser: removed\n", - )); -} - #[test] fn config_loader_should_reject_invalid_codex_cli_version() { assert_rejected(valid_config().replace("codex_version: '0.153.4'", "codex_version: 'latest'")); @@ -386,7 +469,7 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() { } #[test] -fn config_loader_should_reject_removed_request_location_setting() { +fn config_loader_should_ignore_removed_request_location_setting() { let original = valid_config(); parse_config(&original).expect("location belongs to runtime settings"); for value in [ @@ -400,7 +483,7 @@ fn config_loader_should_reject_removed_request_location_setting() { 1, ); assert_ne!(legacy, original); - assert_rejected(legacy); + parse_config(&legacy).expect("removed request location does not prevent startup"); } } diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index 30a18f77d..6167ca7eb 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -97,7 +97,6 @@ impl fmt::Debug for InitialAdminPassword { /// 管理控制面的启动配置。 #[derive(Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct AdminConfig { pub session_ttl_minutes: u64, pub default_username: String, @@ -106,7 +105,6 @@ pub struct AdminConfig { /// Client 登录域的通用启动配置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct ClientConfig { pub session_ttl_minutes: u64, } diff --git a/backend/crates/gateway-api/src/lib.rs b/backend/crates/gateway-api/src/lib.rs index 19e30810f..dc9d9c66d 100644 --- a/backend/crates/gateway-api/src/lib.rs +++ b/backend/crates/gateway-api/src/lib.rs @@ -37,7 +37,6 @@ mod session_cookie; /// API-owned HTTP 与静态资源配置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct ApiConfig { pub asset_directory: PathBuf, pub cors_allowed_origins: Vec, diff --git a/backend/crates/gateway-host/Cargo.toml b/backend/crates/gateway-host/Cargo.toml index 15a21f413..0fcccf3f4 100644 --- a/backend/crates/gateway-host/Cargo.toml +++ b/backend/crates/gateway-host/Cargo.toml @@ -21,6 +21,7 @@ reqwest = { version = "=0.12.28", default-features = false, features = ["json", scraper = "0.27.0" semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } +serde_ignored = "0.1.14" serde_json = "1.0.150" sha2 = "0.11.0" tar = "0.4.44" diff --git a/backend/crates/gateway-host/src/config.rs b/backend/crates/gateway-host/src/config.rs index 0fcdf091b..0e6be4a0b 100644 --- a/backend/crates/gateway-host/src/config.rs +++ b/backend/crates/gateway-host/src/config.rs @@ -19,6 +19,9 @@ const SERVER_PORT_ENV: &str = "CPR_SERVER_PORT"; /// Host 只负责找到和解析文件;每个包的字段解释与相对路径解析 /// 由顶层配置委托给对应的包完成。 pub trait LoadableConfig: DeserializeOwned { + /// 同一配置文件中由其他工具消费的顶层配置段。 + const EXTERNAL_SECTIONS: &'static [&'static str] = &[]; + fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), ConfigError>; } @@ -27,18 +30,44 @@ pub fn load_config() -> Result { let current = env::current_dir().map_err(|_| ConfigError::CurrentDirectory)?; let path = discover_config_path(¤t)?; let source_dir = path.parent().ok_or(ConfigError::InvalidConfigPath)?; - let mut value: T = config::Config::builder() + let document = config::Config::builder() .add_source(config::File::from(path.as_path()).required(true)) .build() - .and_then(config::Config::try_deserialize) .map_err(|_| ConfigError::InvalidDocument { path: path.clone() })?; + let mut unused = std::collections::BTreeSet::new(); + let mut value: T = serde_ignored::deserialize(document, |field| { + unused.insert(field.to_string()); + }) + .map_err(|error| match missing_config_field(&error) { + Some(field) => ConfigError::MissingField { + path: path.clone(), + field, + }, + None => ConfigError::InvalidDocument { path: path.clone() }, + })?; + // 此时日志尚未初始化;只报告字段路径,不回显可能包含凭据的配置值。 + for field in unused { + if !T::EXTERNAL_SECTIONS.contains(&field.as_str()) { + eprintln!("[警告] 配置字段 {field:?} 未使用,已忽略"); + } + } value.resolve_and_validate(source_dir)?; Ok(value) } +fn missing_config_field(error: &config::ConfigError) -> Option { + match error { + config::ConfigError::NotFound(field) => Some(field.clone()), + config::ConfigError::At { error, key, .. } => missing_config_field(error).map(|field| { + key.as_ref() + .map_or_else(|| field.clone(), |key| format!("{key}.{field}")) + }), + _ => None, + } +} + /// Host 唯一拥有的进程配置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct HostConfig { pub listen: ListenConfig, pub runtime_data_dir: PathBuf, @@ -113,7 +142,6 @@ impl HostConfig { /// HTTP 监听地址。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct ListenConfig { pub host: String, pub port: u16, @@ -121,7 +149,6 @@ pub struct ListenConfig { /// Host 结构化日志配置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct LoggingConfig { pub level: String, pub stdout: bool, @@ -178,7 +205,6 @@ impl LoggingConfig { /// Host 文件日志配置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct FileLoggingConfig { pub enabled: bool, pub directory: PathBuf, @@ -198,6 +224,8 @@ pub enum ConfigError { InvalidConfigPath, #[error("configuration document is invalid: {path}")] InvalidDocument { path: PathBuf }, + #[error("配置文件 {path} 缺少必填字段 {field:?}")] + MissingField { path: PathBuf, field: String }, #[error("configuration field is invalid: {0}")] InvalidField(&'static str), #[error("environment variable is invalid: {0}")] diff --git a/backend/crates/gateway-host/src/system_update/mod.rs b/backend/crates/gateway-host/src/system_update/mod.rs index b12b381fb..66a3bbbe7 100644 --- a/backend/crates/gateway-host/src/system_update/mod.rs +++ b/backend/crates/gateway-host/src/system_update/mod.rs @@ -50,7 +50,7 @@ type OperationError = SystemOperationError; /// 系统更新与重启配置;所有字段只由 Host 解释。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(default, deny_unknown_fields)] +#[serde(default)] pub struct SystemUpdateConfig { pub version: String, pub git_sha: String, diff --git a/backend/crates/gateway-host/tests/config.rs b/backend/crates/gateway-host/tests/config.rs index 54a3cf3da..79428ce18 100644 --- a/backend/crates/gateway-host/tests/config.rs +++ b/backend/crates/gateway-host/tests/config.rs @@ -160,7 +160,7 @@ fn logging_rejects_zero_retention_windows() { } #[test] -fn logging_defaults_to_seven_days_and_one_day_for_payloads_and_rejects_count_retention() { +fn logging_defaults_to_time_retention_and_ignores_removed_count_retention() { let mut value = serde_json::json!({ "level": "info", "stdout": true, "file": { "enabled": true, "directory": "logs", "max_file_size_mb": 20 } @@ -170,7 +170,10 @@ fn logging_defaults_to_seven_days_and_one_day_for_payloads_and_rejects_count_ret assert_eq!(config.file.retention_days, 7); assert!(!config.oauth_recovery); value["file"]["max_files"] = serde_json::json!(20); - assert!(serde_json::from_value::(value).is_err()); + assert_eq!( + serde_json::from_value::(value).unwrap(), + config + ); } #[test] diff --git a/backend/crates/gateway-store/src/config.rs b/backend/crates/gateway-store/src/config.rs index ed524512d..f44205d38 100644 --- a/backend/crates/gateway-store/src/config.rs +++ b/backend/crates/gateway-store/src/config.rs @@ -19,7 +19,6 @@ pub(crate) const POSTGRES_HEALTH_RETRY_DELAY: Duration = Duration::from_millis(5 /// Store 自己拥有并校验的启动配置。 #[derive(Clone, Deserialize)] -#[serde(deny_unknown_fields)] pub struct StoreConfig { pub(crate) database: StoreConnectionConfig, pub(crate) redis: StoreConnectionConfig, @@ -31,7 +30,7 @@ pub struct StoreConfig { /// PostgreSQL 连接池预算;acquire 超时决定池耗尽时快速失败而非排队积压。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(deny_unknown_fields, default)] +#[serde(default)] pub struct StorePoolConfig { pub max_connections: u32, pub acquire_timeout_seconds: u64, @@ -149,7 +148,6 @@ impl fmt::Debug for StoreConfig { } #[derive(Clone, Deserialize)] -#[serde(deny_unknown_fields)] pub(crate) struct StoreConnectionConfig { pub(crate) url: String, pub(crate) password: String, diff --git a/backend/crates/gateway-store/tests/config.rs b/backend/crates/gateway-store/tests/config.rs index 1be179c0e..dc699a47a 100644 --- a/backend/crates/gateway-store/tests/config.rs +++ b/backend/crates/gateway-store/tests/config.rs @@ -18,7 +18,7 @@ fn store_config_should_derive_backup_staging_from_runtime_data_dir() { } #[test] -fn store_pool_config_keeps_the_public_surface_to_connection_pool_limits() { +fn store_pool_config_ignores_unknown_options_and_keeps_connection_pool_limits() { let pool: StorePoolConfig = serde_json::from_value(serde_json::json!({})).expect("default pool configuration"); @@ -34,11 +34,12 @@ fn store_pool_config_keeps_the_public_surface_to_connection_pool_limits() { .observability_max_connections(), 40 ); - assert!( + assert_eq!( serde_json::from_value::(serde_json::json!({ "statement_timeout_seconds": 60, })) - .is_err() + .unwrap(), + pool, ); } diff --git a/backend/crates/providers/openai/src/config.rs b/backend/crates/providers/openai/src/config.rs index 9fef1120a..564c7f970 100644 --- a/backend/crates/providers/openai/src/config.rs +++ b/backend/crates/providers/openai/src/config.rs @@ -29,7 +29,6 @@ const fn default_stream_max_retries() -> u64 { /// OpenAI Provider 唯一启动配置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct OpenAiConfig { #[serde(default)] pub api: CodexApiConfig, @@ -129,7 +128,6 @@ impl Default for OpenAiConfig { /// Codex 上游 API 的 Provider-owned 地址配置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct CodexApiConfig { pub base_url: String, } @@ -153,7 +151,6 @@ impl CodexApiConfig { /// Codex Responses WebSocket pool 的 Provider-owned 启动设置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct CodexWebSocketPoolSettings { pub enabled: bool, pub max_age_ms: u64, @@ -194,7 +191,6 @@ impl CodexWebSocketPoolSettings { /// OpenAI Provider 的额度刷新策略。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct CodexQuotaSettings { /// 保留模型目录的刷新周期;额度独立每 30 秒检查周期复核和 reset 到期条件。 pub refresh_interval_minutes: u64, @@ -227,7 +223,6 @@ impl CodexQuotaSettings { /// OpenAI OAuth 的 Provider-owned 运行开关和端点。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct CodexAuthSettings { pub refresh_enabled: bool, pub oauth_client_id: String, @@ -260,7 +255,6 @@ impl CodexAuthSettings { /// 经审计固定的 Codex Desktop 上游请求画像。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct CodexWireProfileConfig { pub originator: String, /// 官方 Desktop ZIP 内嵌 Core 的启动基线;运行时按完整制品元组更新。 diff --git a/backend/crates/providers/xai/src/config.rs b/backend/crates/providers/xai/src/config.rs index 21adb7a80..e54c8c036 100644 --- a/backend/crates/providers/xai/src/config.rs +++ b/backend/crates/providers/xai/src/config.rs @@ -9,7 +9,6 @@ use crate::{GrokOAuthConfig, XaiWireProfileState}; /// xAI Provider 唯一启动配置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct XaiConfig { pub wire_profile: XaiWireProfileConfig, } @@ -33,7 +32,6 @@ impl XaiConfig { /// 经参考实现核验的 Grok CLI 请求画像。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct XaiWireProfileConfig { pub client_identifier: String, pub client_version: String, diff --git a/backend/crates/providers/xai/tests/config.rs b/backend/crates/providers/xai/tests/config.rs index cfbda8d95..a86f169e2 100644 --- a/backend/crates/providers/xai/tests/config.rs +++ b/backend/crates/providers/xai/tests/config.rs @@ -18,5 +18,5 @@ fn xai_config_requires_and_freezes_the_explicit_wire_profile() { assert!(serde_json::from_str::( r#"{"wire_profile":{"client_identifier":"grok-shell","client_version":"0.2.106","client_mode":"headless","target_os":"linux","target_arch":"x86_64","verified_at":"2026-07-21T00:00:00+08:00","unknown":true}}"#, ) - .is_err()); + .is_ok()); }