Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 135 additions & 31 deletions crates/dev_container/src/devcontainer_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,10 +334,11 @@ impl LifecycleScript {
.collect(),
}
}
fn shell_command(script: &str) -> Vec<String> {
vec!["/bin/sh".to_string(), "-c".to_string(), script.to_string()]
}
fn from_str(args: &str) -> Self {
let script: Vec<String> = args.split(" ").map(|a| a.to_string()).collect();

Self::from_args(script)
Self::from_args(Self::shell_command(args))
}
fn from_args(args: Vec<String>) -> Self {
Self::from_map(HashMap::from([("default".to_string(), args)]))
Expand Down Expand Up @@ -435,7 +436,7 @@ impl<'de> Deserialize<'de> for LifecycleScript {
let value: Value = map.next_value()?;
let script_args = match value {
Value::String(s) => {
s.split(" ").map(|s| s.to_string()).collect::<Vec<String>>()
LifecycleScript::shell_command(&s)
}
Value::Array(arr) => {
let strings: Vec<String> = arr
Expand Down Expand Up @@ -777,7 +778,7 @@ mod test {
"userEnvProbe": "loginShell",
"features": {
"ghcr.io/devcontainers/features/aws-cli:1": {},
"ghcr.io/devcontainers/features/anaconda:1": {}
"ghcr.io/devcontainers/features/anaconda:1": {}
},
"overrideFeatureInstallOrder": [
"ghcr.io/devcontainers/features/anaconda:1",
Expand Down Expand Up @@ -876,33 +877,42 @@ mod test {
"echo".to_string(),
"initialize_command".to_string()
])),
// String-form lifecycle commands run in /bin/sh -c per the dev container spec.
on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
update_content_command: Some(LifecycleScript::from_map(HashMap::from([
(
"first".to_string(),
vec!["echo".to_string(), "update_content_command".to_string()]
vec![
"/bin/sh".to_string(),
"-c".to_string(),
"echo update_content_command".to_string()
]
),
(
"second".to_string(),
vec!["echo".to_string(), "update_content_command".to_string()]
)
]))),
post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
post_start_command: Some(LifecycleScript::from_args(vec![
post_create_command: Some(LifecycleScript::from_args(vec![
"echo".to_string(),
"post_start_command".to_string()
"post_create_command".to_string()
])),
post_start_command: Some(LifecycleScript::from_str("echo post_start_command")),
post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
(
"something".to_string(),
vec!["echo".to_string(), "post_attach_command".to_string()]
vec![
"/bin/sh".to_string(),
"-c".to_string(),
"echo post_attach_command".to_string()
]
),
(
"something1".to_string(),
vec![
"echo".to_string(),
"something".to_string(),
"else".to_string()
"/bin/sh".to_string(),
"-c".to_string(),
"echo something else".to_string()
]
)
]))),
Expand Down Expand Up @@ -1021,7 +1031,7 @@ mod test {
"features": {
"ghcr.io/devcontainers/features/aws-cli:1": {},
"ghcr.io/devcontainers/features/anaconda:1": {}
},
},
"overrideFeatureInstallOrder": [
"ghcr.io/devcontainers/features/anaconda:1",
"ghcr.io/devcontainers/features/aws-cli:1"
Expand Down Expand Up @@ -1095,33 +1105,42 @@ mod test {
"echo".to_string(),
"initialize_command".to_string()
])),
// String-form lifecycle commands run in /bin/sh -c per the dev container spec.
on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
update_content_command: Some(LifecycleScript::from_map(HashMap::from([
(
"first".to_string(),
vec!["echo".to_string(), "update_content_command".to_string()]
vec![
"/bin/sh".to_string(),
"-c".to_string(),
"echo update_content_command".to_string()
]
),
(
"second".to_string(),
vec!["echo".to_string(), "update_content_command".to_string()]
)
]))),
post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
post_start_command: Some(LifecycleScript::from_args(vec![
post_create_command: Some(LifecycleScript::from_args(vec![
"echo".to_string(),
"post_start_command".to_string()
"post_create_command".to_string()
])),
post_start_command: Some(LifecycleScript::from_str("echo post_start_command")),
post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
(
"something".to_string(),
vec!["echo".to_string(), "post_attach_command".to_string()]
vec![
"/bin/sh".to_string(),
"-c".to_string(),
"echo post_attach_command".to_string()
]
),
(
"something1".to_string(),
vec![
"echo".to_string(),
"something".to_string(),
"else".to_string()
"/bin/sh".to_string(),
"-c".to_string(),
"echo something else".to_string()
]
)
]))),
Expand Down Expand Up @@ -1222,7 +1241,7 @@ mod test {
"features": {
"ghcr.io/devcontainers/features/aws-cli:1": {},
"ghcr.io/devcontainers/features/anaconda:1": {}
},
},
"overrideFeatureInstallOrder": [
"ghcr.io/devcontainers/features/anaconda:1",
"ghcr.io/devcontainers/features/aws-cli:1"
Expand Down Expand Up @@ -1323,33 +1342,42 @@ mod test {
"echo".to_string(),
"initialize_command".to_string()
])),
// String-form lifecycle commands run in /bin/sh -c per the dev container spec.
on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
update_content_command: Some(LifecycleScript::from_map(HashMap::from([
(
"first".to_string(),
vec!["echo".to_string(), "update_content_command".to_string()]
vec![
"/bin/sh".to_string(),
"-c".to_string(),
"echo update_content_command".to_string()
]
),
(
"second".to_string(),
vec!["echo".to_string(), "update_content_command".to_string()]
)
]))),
post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
post_start_command: Some(LifecycleScript::from_args(vec![
post_create_command: Some(LifecycleScript::from_args(vec![
"echo".to_string(),
"post_start_command".to_string()
"post_create_command".to_string()
])),
post_start_command: Some(LifecycleScript::from_str("echo post_start_command")),
post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
(
"something".to_string(),
vec!["echo".to_string(), "post_attach_command".to_string()]
vec![
"/bin/sh".to_string(),
"-c".to_string(),
"echo post_attach_command".to_string()
]
),
(
"something1".to_string(),
vec![
"echo".to_string(),
"something".to_string(),
"else".to_string()
"/bin/sh".to_string(),
"-c".to_string(),
"echo something else".to_string()
]
)
]))),
Expand Down Expand Up @@ -1682,4 +1710,80 @@ mod test {

assert!(devcontainer.validate_devcontainer_contents().is_ok());
}

#[test]
fn string_lifecycle_command_runs_in_a_shell() {
// Per the dev container spec, string-form lifecycle commands MUST run in /bin/sh -c.
// This regression test guards against #60228, where `from_str("echo $HOME")` was
// naively split on spaces, exec-ing `["echo", "$HOME"]` directly without a shell,
// so `$HOME` would not be expanded (the spec mandates shell expansion).
let json = r#"
{
"image": "nginx",
"postCreateCommand": "echo $HOME"
}
"#;

let result = deserialize_devcontainer_json(json);
assert!(result.is_ok(), "Deserialization should succeed: {:?}", result.err());

let dc = result.unwrap();
if let Some(script) = dc.post_create_command {
let cmds = script.script_commands();
let cmd = cmds.get("default").expect("String command should have a 'default' key");
let args: Vec<std::ffi::OsString> = cmd.get_args().map(|a| a.to_os_string()).collect();
assert_eq!(
cmd.get_program(),
std::ffi::OsStr::new("/bin/sh"),
"String-form lifecycle command program must be /bin/sh"
);
assert_eq!(
args,
vec![
std::ffi::OsString::from("-c"),
std::ffi::OsString::from("echo $HOME"),
],
"String-form lifecycle command args must be -c <script> so the shell expands $HOME"
);
} else {
panic!("Expected post_create_command to be Some");
}
}

#[test]
fn array_lifecycle_command_runs_directly() {
// Per the dev container spec, array-form lifecycle commands MUST exec directly
// without a shell wrapper.
let json = r#"
{
"image": "nginx",
"postCreateCommand": ["/usr/bin/env", "echo", "hello"]
}
"#;

let result = deserialize_devcontainer_json(json);
assert!(result.is_ok(), "Deserialization should succeed: {:?}", result.err());

let dc = result.unwrap();
if let Some(script) = dc.post_create_command {
let cmds = script.script_commands();
let cmd = cmds.get("default").expect("Array command should have a 'default' key");
let args: Vec<std::ffi::OsString> = cmd.get_args().map(|a| a.to_os_string()).collect();
assert_eq!(
cmd.get_program(),
std::ffi::OsStr::new("/usr/bin/env"),
"Array-form lifecycle command must exec the first element directly"
);
assert_eq!(
args,
vec![
std::ffi::OsString::from("echo"),
std::ffi::OsString::from("hello"),
],
"Array-form lifecycle command must pass remaining elements as args without a shell wrapper"
);
} else {
panic!("Expected post_create_command to be Some");
}
}
}