fix(protocol): reject duplicate command/args keys in wait-for block

A second `command` node silently overwrote the first via a scalar
Option<PathBuf>, and a second `args` node silently overwrote the first
via a plain Vec, so duplicate keys last-won instead of tripping the
exactly-one-condition rule. Track args as Option<Vec<String>> and
error immediately on a repeated command or args key.
This commit is contained in:
2026-08-07 15:41:48 +02:00
parent 7631eefc67
commit 4c34e336a2
+62 -5
View File
@@ -267,7 +267,7 @@ fn parse_wait_for(doc: &KdlDocument, path: &Path) -> Result<Option<WaitForConfig
let mut conditions: Vec<WaitCondition> = Vec::new(); let mut conditions: Vec<WaitCondition> = Vec::new();
let mut command: Option<PathBuf> = None; let mut command: Option<PathBuf> = None;
let mut args: Vec<String> = Vec::new(); let mut args: Option<Vec<String>> = None;
let mut timeout = default_wait_timeout(); let mut timeout = default_wait_timeout();
let mut interval = default_wait_interval(); let mut interval = default_wait_interval();
@@ -284,14 +284,24 @@ fn parse_wait_for(doc: &KdlDocument, path: &Path) -> Result<Option<WaitForConfig
conditions.push(WaitCondition::Tcp(addr)); conditions.push(WaitCondition::Tcp(addr));
} }
"command" => { "command" => {
if command.is_some() {
return Err(invalid("duplicate key `command`".into()));
}
command = Some(PathBuf::from(string_arg(child, "wait-for", path)?)); command = Some(PathBuf::from(string_arg(child, "wait-for", path)?));
} }
"args" => { "args" => {
args = child if args.is_some() {
return Err(invalid("duplicate key `args`".into()));
}
args = Some(
child
.entries() .entries()
.iter() .iter()
.filter_map(|e| e.value().as_string().map(str::to_string)) .filter_map(|e| e.value().as_string().map(str::to_string))
.collect(); .collect(),
);
} }
"timeout" => { "timeout" => {
timeout = parse_duration_arg(child, "wait-for", path)?; timeout = parse_duration_arg(child, "wait-for", path)?;
@@ -306,8 +316,11 @@ fn parse_wait_for(doc: &KdlDocument, path: &Path) -> Result<Option<WaitForConfig
} }
if let Some(command) = command { if let Some(command) = command {
conditions.push(WaitCondition::Command { command, args }); conditions.push(WaitCondition::Command {
} else if !args.is_empty() { command,
args: args.unwrap_or_default(),
});
} else if args.is_some() {
return Err(invalid("`args` requires `command`".into())); return Err(invalid("`args` requires `command`".into()));
} }
@@ -670,4 +683,48 @@ stop {
} }
)); ));
} }
#[test]
fn duplicate_command_key_fails() {
let text =
"command \"/bin/x\"\nport 1\nwait-for { command \"/bin/a\"\ncommand \"/bin/b\" }";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue {
field: "wait-for",
..
}
));
}
#[test]
fn duplicate_args_key_fails() {
let text =
"command \"/bin/x\"\nport 1\nwait-for { command \"docker\"\nargs \"a\"\nargs \"b\" }";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue {
field: "wait-for",
..
}
));
}
#[test]
fn expand_tilde_alone_is_left_unchanged() {
let home = PathBuf::from("/home/someone");
assert_eq!(expand_tilde("~", Some(&home)), PathBuf::from("~"));
}
#[test]
fn expand_tilde_of_empty_string_is_left_unchanged() {
let home = PathBuf::from("/home/someone");
assert_eq!(expand_tilde("", Some(&home)), PathBuf::from(""));
}
} }