From 28aa7ab7fce2d7084999cdc6b70b02976ea298a3 Mon Sep 17 00:00:00 2001 From: isabel Date: Wed, 27 May 2026 08:24:36 +0100 Subject: [PATCH] feat!(tranquil-config): error on unknown keys an attempt to make it so that fixes like fc6063dba898707d309248aa41711b94e0838094 never have to occur again --- crates/tranquil-config/src/lib.rs | 120 ++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs index b74560a..6b21d5b 100644 --- a/crates/tranquil-config/src/lib.rs +++ b/crates/tranquil-config/src/lib.rs @@ -104,6 +104,7 @@ pub fn load(config_path: Option<&PathBuf>) -> Result bool { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct SmarthostConfig { /// SMTP relay host. When set, mail is delivered through this host /// instead of resolving recipient MX records directly. @@ -1060,6 +1071,7 @@ pub struct SmarthostConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct DirectMxConfig { /// Per-command SMTP timeout in seconds. #[config(env = "MAIL_COMMAND_TIMEOUT_SECS", default = 30)] @@ -1084,6 +1096,7 @@ pub struct DirectMxConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct DkimConfig { /// DKIM selector. When unset, outgoing mail is not signed. #[config(env = "MAIL_DKIM_SELECTOR")] @@ -1100,6 +1113,7 @@ pub struct DkimConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct DiscordConfig { /// Discord bot token. When unset, Discord integration is disabled. #[config(env = "DISCORD_BOT_TOKEN")] @@ -1107,6 +1121,7 @@ pub struct DiscordConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct TelegramConfig { /// Telegram bot token. When unset, Telegram integration is disabled. #[config(env = "TELEGRAM_BOT_TOKEN")] @@ -1118,6 +1133,7 @@ pub struct TelegramConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct NotificationConfig { /// Polling interval in milliseconds for the comms queue. #[config(env = "NOTIFICATION_POLL_INTERVAL_MS", default = 1000)] @@ -1140,6 +1156,7 @@ pub trait SsoProviderIssuerConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct SsoConfig { #[config(nested)] pub github: SsoGitHubConfig, @@ -1161,6 +1178,7 @@ pub struct SsoConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct SsoGitHubConfig { #[config(env = "SSO_GITHUB_ENABLED", default = false)] pub enabled: bool, @@ -1194,6 +1212,7 @@ impl SsoProviderConfig for SsoGitHubConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct SsoDiscordConfig { #[config(env = "SSO_DISCORD_ENABLED", default = false)] pub enabled: bool, @@ -1227,6 +1246,7 @@ impl SsoProviderConfig for SsoDiscordConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct SsoGoogleConfig { #[config(env = "SSO_GOOGLE_ENABLED", default = false)] pub enabled: bool, @@ -1260,6 +1280,7 @@ impl SsoProviderConfig for SsoGoogleConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct SsoGitLabConfig { #[config(env = "SSO_GITLAB_ENABLED", default = false)] pub enabled: bool, @@ -1302,6 +1323,7 @@ impl SsoProviderIssuerConfig for SsoGitLabConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct SsoOidcConfig { #[config(env = "SSO_OIDC_ENABLED", default = false)] pub enabled: bool, @@ -1344,6 +1366,7 @@ impl SsoProviderIssuerConfig for SsoOidcConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct SsoAppleConfig { #[config(env = "SSO_APPLE_ENABLED", default = false)] pub enabled: bool, @@ -1362,6 +1385,7 @@ pub struct SsoAppleConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct ModerationConfig { /// External report-handling service URL. #[config(env = "REPORT_SERVICE_URL")] @@ -1373,6 +1397,7 @@ pub struct ModerationConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct ImportConfig { /// Whether the PDS accepts repo imports. #[config(env = "ACCEPTING_REPO_IMPORTS", default = true)] @@ -1404,6 +1429,7 @@ fn split_comma_list(value: &str) -> Result, std::convert::Infallible } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct RippleCacheConfig { /// Address to bind the Ripple gossip protocol listener. #[config(env = "RIPPLE_BIND", default = "0.0.0.0:0")] @@ -1427,6 +1453,7 @@ pub struct RippleCacheConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct ScheduledConfig { /// Interval in seconds between scheduled delete checks. #[config(env = "SCHEDULED_DELETE_CHECK_INTERVAL_SECS", default = 3600)] @@ -1474,6 +1501,7 @@ pub struct ScheduledConfig { } #[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] pub struct TranquilStoreConfig { /// Directory for tranquil-store data: the metastore, eventlog, and blockstore. #[config( @@ -1548,6 +1576,98 @@ mod tests { .for_each(|(k, v)| unsafe { std::env::set_var(k, v) }); } + #[test] + fn load_rejects_unknown_top_level_key() { + let dir = std::env::temp_dir().join(format!( + "tranquil-config-unknown-toplevel-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("mkdir tempdir"); + let path = dir.join("config.toml"); + std::fs::write( + &path, + r#" +[server] +hostname = "test.local" + +[totally_made_up] +foo = "bar" +"#, + ) + .expect("write tempfile"); + + let result = TranquilConfig::builder().file(&path).load(); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_dir(&dir); + + let err = format!("{:#}", result.expect_err("load must reject unknown key")); + assert!( + err.contains("totally_made_up"), + "expected totally_made_up in error, got {err:?}" + ); + } + + #[test] + fn load_rejects_unknown_nested_key() { + let dir = std::env::temp_dir().join(format!( + "tranquil-config-unknown-nested-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("mkdir tempdir"); + let path = dir.join("config.toml"); + std::fs::write( + &path, + r#" +[server] +hostname = "test.local" +not_a_real_field = "oops" +"#, + ) + .expect("write tempfile"); + + let result = TranquilConfig::builder().file(&path).load(); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_dir(&dir); + + let err = format!("{:#}", result.expect_err("load must reject unknown key")); + assert!( + err.contains("not_a_real_field"), + "expected not_a_real_field in error, got {err:?}" + ); + } + + #[test] + fn load_accepts_known_keys() { + let dir = std::env::temp_dir().join(format!( + "tranquil-config-known-keys-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("mkdir tempdir"); + let path = dir.join("config.toml"); + std::fs::write( + &path, + r#" +[server] +hostname = "test.local" +port = 3000 + +[database] +url = "postgres://localhost/test" + +[email.smarthost] +host = "smtp.example" +port = 587 +"#, + ) + .expect("write tempfile"); + + let result = TranquilConfig::builder().file(&path).load(); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_dir(&dir); + + result.expect("known keys must load successfully"); + } + #[test] fn serial_validate_rejects_legacy_sendmail_path() { seed_required_env();