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 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 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 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 when the script exits, the init blob reports the code and the VMM
powers the machine off (the libkrunfw kernel has no sysrq). powers the machine off (the libkrunfw kernel has no sysrq).
@@ -223,12 +227,13 @@ pub struct RunOptions {
pub workdir: String, // guest cwd; empty = / pub workdir: String, // guest cwd; empty = /
pub env: Vec<(String, String)>, // composed envp; host env never inherited pub env: Vec<(String, String)>, // composed envp; host env never inherited
pub hostname: String, // empty = generated ecrou-vm-XXXXXX 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> 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 without working KVM is `Error::KvmUnavailable` with the remediation
text. Everything else is `Error::Other` over the anyhow chain, ecr's 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 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 { pub struct RunArgs {
/// Distribution name or OCI image reference /// Distribution name or OCI image reference
pub image: String, 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)] #[arg(last = true)]
pub command: Vec<String>, pub command: Vec<String>,
/// Directory to share into the machine as HOST:GUEST[:ro] (repeatable) /// 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. /// The guest payload: the caller's argv, or the rootfs's default shell.
fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec<String> { fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec<String> {
if !options.command.is_empty() { let shell = utils::detect_shell(rootfs_dir).to_string();
return options.command.clone(); 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 /// 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 { mod tests {
use super::*; 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] #[test]
fn memory_parses_to_whole_mib() { fn memory_parses_to_whole_mib() {
assert_eq!(parse_memory_mib("1G").unwrap(), 1024); assert_eq!(parse_memory_mib("1G").unwrap(), 1024);