Skip to content
Merged
Show file tree
Hide file tree
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
is skipped with nothing to say rather than asserting a version it never read.

### Breaking
- **`sdk run` passes its argv through literally; the container shell no longer

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Published HIL doc teaches the pattern this breaks. docs/src/docs-guides/hardware-in-the-loop.md:140-146 (peridio/docs) instructs avocado sdk run cd /opt/_avocado \&\& mkdir -p ./qemux86-64/extensions/my-app/usr \&\& echo "hello from host" \> ./qemux86-64/extensions/my-app/usr/hello.txt, followed by an "Escaping SDK run commands" callout stating that && and > are escaped precisely so they "reach the container's shell unchanged." That is exactly the contract this PR retires, and the vendor's own guide isn't in the audit list this entry asks users to check.

Replayed the documented argv against both code paths:

  • Before: cd <dir> && mkdir -p ./ext/usr && echo hello from host > ./ext/usr/hello.txt -> exit 0, file created.
  • After: each argv element quoted separately -> bash: line 1: cd: too many arguments, exit 2, nothing created.

The echo/redirect half in isolation is worse: it exits 0, prints hello from host > ./out.txt to stdout, and writes nothing - so someone following this guide sees a success and a missing file. Worth a companion docs fix landing with this change, not after.

re-splits or expands it.** Anything passing a `$VAR`, a glob, or a `&&` chain
as a single argument and counting on the container shell to interpret it now
gets that string through as one command word: `avocado sdk run -- 'ls /foo &&
ls /bar'` no longer runs two commands, and `avocado sdk run -- echo '$HOME'`
prints a literal `$HOME`. Ask for a shell explicitly instead — `avocado sdk
run -- bash -lc 'ls /foo && ls /bar'` — which is the form the flags already
implied and which only works correctly after this change.

This one breaks silently at runtime: there is no error and no warning, so a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"no error, no warning" doesn't hold for && chains - and the entry's own first example two sentences up falsifies it.

$ bash -c "'ls /foo && ls /bar'"
bash: line 1: ls /foo && ls /bar: No such file or directory

That's the exact avocado sdk run -- 'ls /foo && ls /bar' example from line 58 above, reproduced as what join_argv actually sends the container shell (a single element, shell_escaped into one quoted word since it's already one argv element). It exits 127 with a clear error, not silently. The cd recipe in the first comment on this PR exits 2, also loudly.

Only the expansion cases (echo '$HOME') are genuinely silent - the &&/> chain cases fail loudly. Recommend splitting the claim: expansion breaks silently, but a &&/|/>/; chain passed as one argument now hard-fails at runtime, which is actually the easier case to catch in CI, not a risk to bury under "no error, no warning."

Separately, the audit list ($VAR, a glob, a && chain) omits the assignment-prefix form, which bash only honors on a fully unquoted word: avocado sdk run -- CC=clang make now breaks silently (the one real case) while matching none of the three things this paragraph tells the reader to grep for.

pinned-version bump in CI will not fail the build, it will just run a
different command. Audit any job that shells through `sdk run` before
upgrading.

**What it fixes.** The arguments after `--` were spliced into the container's
script with a bare `join(" ")`, so the shell inside re-parsed them. `avocado
sdk run -- bash -lc 'U=/opt/x; ls $U'` arrived as `bash -lc U=/opt/x; ls $U`,
which the shell read as two commands and whose `$U` the *outer* shell expanded
to nothing — printing plausible output for a different directory rather than
failing. Each element is quoted now, so argv is argv, matching `docker run`
and `kubectl exec`.
- **`is_version_compatible` returns `Option<bool>` instead of `bool`.** Only
affects consumers of the `avocado_cli` lib target. `None` means the remote
version could not be read at all, which the old `bool` could not express -
Expand Down
45 changes: 41 additions & 4 deletions src/commands/sdk/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,21 @@ use crate::utils::{
config::{ComposedConfig, Config},
container::{RunConfig, SdkContainer},
output::{print_info, print_success, OutputLevel},
shell::shell_escape,
target::validate_and_log_target,
};

/// Join a user-supplied argv into one shell-safe command string.
///
/// The result is spliced into a shell script inside the container, so each
/// element must be quoted or the container shell re-splits it.
fn join_argv(argv: &[String]) -> String {
argv.iter()
.map(|a| shell_escape(a))
.collect::<Vec<_>>()
.join(" ")
}

/// Implementation of the 'sdk run' command.
pub struct SdkRunCommand {
/// Path to configuration file
Expand Down Expand Up @@ -261,7 +273,7 @@ impl SdkRunCommand {
// Require either a command or --interactive flag
if !self.interactive && self.command.is_none() {
return Err(anyhow::anyhow!(
"You must either provide a --command (-c) or use --interactive (-i)."
"You must either provide a command to run or use --interactive (-i)."
));
}

Expand Down Expand Up @@ -310,9 +322,16 @@ impl SdkRunCommand {
println!("Container name: {name}");
}

// Build the command to execute
// Build the command to execute.
//
// The argv vector is spliced into a shell script inside the container, so
// each element has to be quoted or the container shell re-splits it. A bare
// join(" ") turns `-- bash -lc 'X=1; echo $X'` into
// `bash -lc X=1; echo $X`, which the shell reads as two commands and
// expands `$X` in the *outer* shell (to nothing). That silently produces
// wrong output instead of an error, which is worse than failing.
let command = if let Some(ref cmd) = self.command {
let user_command = cmd.join(" ");
let user_command = join_argv(cmd);
if self.env {
format!(". avocado-env && {user_command}")
} else {
Expand Down Expand Up @@ -442,6 +461,24 @@ mod tests {
assert_eq!(cmd.command, Some(vec!["ls".to_string(), "-la".to_string()]));
}

#[test]
fn test_join_argv_preserves_argv_boundaries() {
// A bare join(" ") would emit `bash -lc X=1; echo $X`, which the
// container shell splits into two commands and expands `$X` itself.
let argv = vec![
"bash".to_string(),
"-lc".to_string(),
"X=1; echo $X".to_string(),
];
assert_eq!(join_argv(&argv), "'bash' '-lc' 'X=1; echo $X'");
}

#[test]
fn test_join_argv_escapes_embedded_single_quotes() {
let argv = vec!["echo".to_string(), "it's".to_string()];
assert_eq!(join_argv(&argv), "'echo' 'it'\\''s'");
}

#[tokio::test]
async fn test_invalid_arguments() {
let cmd = SdkRunCommand::new(
Expand Down Expand Up @@ -493,7 +530,7 @@ mod tests {
assert!(result
.unwrap_err()
.to_string()
.contains("You must either provide a --command"));
.contains("You must either provide a command to run"));
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub mod runs_on;
pub mod runtime;
pub mod runtime_extension;
pub mod scheduler;
pub mod shell;
pub mod signing_keys;
#[cfg(unix)]
pub mod signing_service;
Expand Down
34 changes: 1 addition & 33 deletions src/utils/runs_on.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use crate::utils::remote::{
get_local_ip_for_remote, version_check_notice, RemoteHost, RemoteVolumeManager, SshClient,
SshControlMaster, VersionNotice,
};
use crate::utils::shell::shell_escape;

#[cfg(unix)]
use crate::utils::remote::SshTunnel;
Expand Down Expand Up @@ -691,36 +692,3 @@ impl RunsOnContext {
Ok(())
}
}

/// Shell escape a string for safe use in a shell command
fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_shell_escape_simple() {
assert_eq!(shell_escape("hello"), "'hello'");
}

#[test]
fn test_shell_escape_with_spaces() {
assert_eq!(shell_escape("hello world"), "'hello world'");
}

#[test]
fn test_shell_escape_with_quotes() {
assert_eq!(shell_escape("it's"), "'it'\\''s'");
}

#[test]
fn test_shell_escape_complex() {
assert_eq!(
shell_escape("echo 'hello' && rm -rf /"),
"'echo '\\''hello'\\'' && rm -rf /'"
);
}
}
38 changes: 38 additions & 0 deletions src/utils/shell.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! Shell quoting helpers.
//!
//! Several commands splice values into a shell script that runs inside the SDK
//! container. Anything user-supplied has to be quoted on the way in or the
//! container shell re-splits and expands it.

/// Shell escape a string for safe use in a shell command
pub(crate) fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_shell_escape_simple() {
assert_eq!(shell_escape("hello"), "'hello'");
}

#[test]
fn test_shell_escape_with_spaces() {
assert_eq!(shell_escape("hello world"), "'hello world'");
}

#[test]
fn test_shell_escape_with_quotes() {
assert_eq!(shell_escape("it's"), "'it'\\''s'");
}

#[test]
fn test_shell_escape_complex() {
assert_eq!(
shell_escape("echo 'hello' && rm -rf /"),
"'echo '\\''hello'\\'' && rm -rf /'"
);
}
}
Loading