fix(vm): a single quoted payload argument runs through the image's shell

'ecrou run alpine -- "apk add x && y"' reached the guest as one argv
element and execvp'd a program named after the whole line. One
argument containing whitespace is a quoted shell line: hand it to the
image's shell as 'sh -c'. Argv vectors stay literal, including ones
that begin with sh -c.
This commit is contained in:
Valentin Haudiquet committed 2026-10-28 00:43:56 +01:00
1 parent 2e91db9acc
commit a27879dbe7
3 files changed
+81 -7

No files matched your search

+8 -3
View File
@@ -144,7 +144,11 @@ hostname and workdir ride `krun_set_exec`'s config natively (the init
blob applies both); the script's own hostname and cd lines stay as
fallbacks. There is no exec (the init must outlive the payload to
report it) and no share-less special case: the same contract covers a
bare shell run. Ending the machine is not the script's job either:
bare shell run. The payload itself is an argv vector taken
literally; the one rewrite is a single argument containing whitespace,
which is a quoted shell line ("apk add x && y") and runs through the
image's shell as `sh -c`. Ending the machine is not the script's job
either:
when the script exits, the init blob reports the code and the VMM
powers the machine off (the libkrunfw kernel has no sysrq).
@@ -223,12 +227,13 @@ pub struct RunOptions {
pub workdir: String, // guest cwd; empty = /
pub env: Vec<(String, String)>, // composed envp; host env never inherited
pub hostname: String, // empty = generated ecrou-vm-XXXXXX
pub quiet: bool,
pub log_level: LogLevel, // Quiet | Normal | Verbose
}
pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32>
```
stdio is inherited; the return value is the guest exit code. A run
Guest output reaches real stdout through the streamed console; the
return value is the guest exit code. A run
without working KVM is `Error::KvmUnavailable` with the remediation
text. Everything else is `Error::Other` over the anyhow chain, ecr's
shape. The v1 API has no captured-output variant and no long-lived
+3 -1
View File
@@ -29,7 +29,9 @@ pub enum Command {
pub struct RunArgs {
/// Distribution name or OCI image reference
pub image: String,
/// Command to run in the machine; the image's default shell when omitted
/// Command to run in the machine; the image's default shell when
/// omitted. An argument vector after `--` is taken literally; a
/// single quoted line runs through the image's shell.
#[arg(last = true)]
pub command: Vec<String>,
/// Directory to share into the machine as HOST:GUEST[:ro] (repeatable)
+70 -3
View File
@@ -362,10 +362,19 @@ fn collect_shares(
/// The guest payload: the caller's argv, or the rootfs's default shell.
fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec<String> {
if !options.command.is_empty() {
return options.command.clone();
let shell = utils::detect_shell(rootfs_dir).to_string();
match options.command.as_slice() {
// No command: the image's shell.
[] => vec![shell],
// One quoted shell line: `ecrou run alpine -- "apk add x && y"`.
// The whole line would otherwise be execvp'd as one program
// name; the consumer clearly asked for a shell line.
[line] if line.contains(char::is_whitespace) => {
vec![shell, "-c".to_string(), line.clone()]
}
// An argv vector, taken literally.
cmd => cmd.to_vec(),
}
vec![utils::detect_shell(rootfs_dir).to_string()]
}
/// The guest environment, composed from scratch: a usable base, the
@@ -447,6 +456,64 @@ fn parse_memory_mib(s: &str) -> anyhow::Result<u32> {
mod tests {
use super::*;
/// A scratch with just a shell, for payload_command tests.
fn shell_rootfs() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("bin")).unwrap();
std::fs::write(dir.path().join("bin/sh"), "").unwrap();
dir
}
#[test]
fn payload_defaults_to_the_image_shell() {
let rootfs = shell_rootfs();
let options = RunOptions::default();
assert_eq!(
payload_command(&options, rootfs.path()),
vec!["/bin/sh".to_string()]
);
}
#[test]
fn payload_argv_is_taken_literally() {
let rootfs = shell_rootfs();
let options = RunOptions {
command: vec!["uname".to_string(), "-a".to_string()],
..RunOptions::default()
};
assert_eq!(
payload_command(&options, rootfs.path()),
vec!["uname".to_string(), "-a".to_string()]
);
// Even when it looks like a shell invocation: the consumer
// asked for argv.
let options = RunOptions {
command: vec!["sh".to_string(), "-c".to_string(), "exit 7".to_string()],
..RunOptions::default()
};
assert_eq!(
payload_command(&options, rootfs.path()),
vec!["sh".to_string(), "-c".to_string(), "exit 7".to_string()]
);
}
#[test]
fn payload_single_quoted_line_runs_through_the_shell() {
let rootfs = shell_rootfs();
let options = RunOptions {
command: vec!["apk add fastfetch && fastfetch".to_string()],
..RunOptions::default()
};
assert_eq!(
payload_command(&options, rootfs.path()),
vec![
"/bin/sh".to_string(),
"-c".to_string(),
"apk add fastfetch && fastfetch".to_string()
]
);
}
#[test]
fn memory_parses_to_whole_mib() {
assert_eq!(parse_memory_mib("1G").unwrap(), 1024);