From db60fb9ddbc224e9fbd738ef3356d09a9a87929f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Wed, 1 Apr 2026 23:51:47 +0200 Subject: [PATCH] Reject unknown config keys --- src/config.rs | 53 ++++++++++++++++++++++++++++++++++++++++---- src/errors.rs | 2 ++ tests/integration.rs | 18 +++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index eb292a5..3d4c1c7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -145,6 +145,9 @@ pub struct FileConfig { pub options: Options, #[serde(default)] pub profile: HashMap, + // Collects unrecognized keys; deny_unknown_fields is incompatible with flatten. + #[serde(flatten)] + _unknown: HashMap, } impl FileConfig { @@ -153,12 +156,26 @@ impl FileConfig { path: path.to_path_buf(), source: e, })?; - toml::from_str(&contents).map_err(|e| SandboxError::ConfigParse { - path: path.to_path_buf(), - source: e, + Self::parse(&contents).map_err(|e| match e { + SandboxError::ConfigParse { source, .. } => SandboxError::ConfigParse { + path: path.to_path_buf(), + source, + }, + other => other, }) } + fn parse(contents: &str) -> Result { + let config: Self = toml::from_str(contents).map_err(|e| SandboxError::ConfigParse { + path: PathBuf::new(), + source: e, + })?; + if let Some(key) = config._unknown.keys().next() { + return Err(SandboxError::UnknownConfigKey(key.clone())); + } + Ok(config) + } + fn resolve_profile(&self, name: Option<&str>) -> Result { match name { Some(n) => self @@ -172,7 +189,7 @@ impl FileConfig { } #[derive(Deserialize, Default, Clone)] -#[serde(rename_all = "kebab-case")] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Options { pub blacklist: Option, pub whitelist: Option, @@ -396,6 +413,7 @@ mod tests { ..Options::default() }, )]), + ..FileConfig::default() }; let args = Args { profile: Some("relaxed".into()), @@ -461,6 +479,7 @@ mod tests { ..Options::default() }, )]), + ..FileConfig::default() }; let args = Args { profile: Some("extra".into()), @@ -603,6 +622,7 @@ mod tests { ..Options::default() }, )]), + ..FileConfig::default() }; let args = Args { profile: Some("extra".into()), @@ -613,6 +633,31 @@ mod tests { assert_eq!(config.mask.len(), 3); } + #[test] + fn unknown_option_rejected() { + let toml = r#" + hardened = true + bogus = "nope" + "#; + assert!(matches!( + FileConfig::parse(toml), + Err(SandboxError::UnknownConfigKey(_)) + )); + } + + #[test] + fn unknown_profile_option_rejected() { + let toml = r#" + [profile.test] + hardened = true + frobnicate = 42 + "#; + assert!(matches!( + FileConfig::parse(toml), + Err(SandboxError::ConfigParse { .. }) + )); + } + fn assert_paths(actual: &[PathBuf], expected: &[&str]) { let expected: Vec = expected.iter().map(PathBuf::from).collect(); assert_eq!(actual, &expected); diff --git a/src/errors.rs b/src/errors.rs index 02f41dd..2709e2a 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -22,6 +22,7 @@ pub enum SandboxError { }, ProfileNotFound(String), ConflictingMode, + UnknownConfigKey(String), ConfigPathNotAbsolute(PathBuf), } @@ -60,6 +61,7 @@ impl std::fmt::Display for SandboxError { f, "config section sets both blacklist and whitelist to true" ), + Self::UnknownConfigKey(key) => write!(f, "unknown config key: {key}"), Self::ConfigPathNotAbsolute(p) => { write!(f, "config path is not absolute: {}", p.display()) } diff --git a/tests/integration.rs b/tests/integration.rs index 1290bf1..36b1d62 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -578,6 +578,24 @@ fn config_invalid_toml_errors() { ); } +#[test] +fn config_unknown_key_errors() { + let dir = TempDir::new().unwrap(); + let cfg = write_config(&dir, "hardened = true\nbogus = \"nope\"\n"); + + let output = sandbox(&["--config", &cfg]) + .args(["--", "true"]) + .output() + .expect("failed to execute"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("unknown config key"), + "expected unknown key error, got: {stderr}" + ); +} + #[test] fn mask_hides_directory() { let dir = TempDir::new().unwrap();