Files
pkh/src/context/shell.rs
T
vhaudiquet f508f20846 context: quote program, args, cwd and env in ssh, schroot and unshare drivers
All three non-local drivers assembled remote/chroot command strings by
raw concatenation: ssh pushed args verbatim (TODO: escape), schroot
interpolated env values raw so DEB_BUILD_OPTIONS='parallel=4 nocheck'
made sh treat 'nocheck' as the command, and unshare wrapped args in
unescaped double quotes letting quotes break out and $/backticks
expand. Add a shared POSIX shell_quote helper and use it for every
component interpolated into a shell string, including ssh copy_path
(which used Rust's {:?}, not shell quoting) and schroot write_file
(which also switches echo -ne to printf %s so backslash sequences in
content are no longer interpreted).
2026-09-16 00:36:02 +02:00

110 lines
3.6 KiB
Rust

//! POSIX-shell quoting for command strings assembled by the remote/chroot
//! execution contexts.
//!
//! Unlike [`super::local`] — which spawns programs directly through
//! `std::process::Command`, with no shell in between — the SSH, schroot and
//! unshare drivers ultimately hand a *string* to a shell (`ssh
//! channel.exec`, `sh -c`, `bash -c`). Every program name, argument,
//! path or environment value interpolated into such a string must be
//! quoted, or shell metacharacters (`;`, `|`, `&`, quotes, `$`, backticks,
//! globs, whitespace, ...) are reinterpreted by the shell: at best the
//! command breaks, at worst it executes injected input.
/// Quote `s` for safe interpolation into a POSIX shell command line.
///
/// The result is `s` wrapped in single quotes, with every embedded single
/// quote replaced by the standard `'\''` sequence (close the quoting, an
/// escaped literal quote, reopen). Whatever the input contains — spaces,
/// newlines, `"`, `'`, `$`, backticks, globs, `;` — the shell parses the
/// result back into exactly `s` as a single word. The empty string becomes
/// `''` (one empty argument, not zero arguments).
///
/// Use this for *every* value interpolated into a shell command string:
/// programs, arguments, `cd` targets, `env` assignments (both key and
/// value) and paths. It is safe (though redundant) to quote values that are
/// known to need no quoting.
pub(crate) fn shell_quote(s: &str) -> String {
let mut quoted = String::with_capacity(s.len() + 2);
quoted.push('\'');
for c in s.chars() {
if c == '\'' {
quoted.push_str("'\\''");
} else {
quoted.push(c);
}
}
quoted.push('\'');
quoted
}
#[cfg(test)]
mod tests {
use super::shell_quote;
#[test]
fn plain_word() {
assert_eq!(shell_quote("plain"), "'plain'");
}
#[test]
fn spaces_stay_one_word() {
assert_eq!(shell_quote("parallel=4 nocheck"), "'parallel=4 nocheck'");
assert_eq!(
shell_quote(" leading and trailing "),
"' leading and trailing '"
);
}
#[test]
fn embedded_single_quotes() {
assert_eq!(shell_quote("it's"), "'it'\\''s'");
assert_eq!(shell_quote("''"), r"''\'''\'''");
}
#[test]
fn double_quotes_and_metacharacters() {
assert_eq!(
shell_quote("say \"hi\" $HOME `id` ; | & * ?"),
"'say \"hi\" $HOME `id` ; | & * ?'"
);
}
#[test]
fn dollar_and_backtick_do_not_expand() {
assert_eq!(shell_quote("$HOME"), "'$HOME'");
assert_eq!(shell_quote("$(rm -rf /)"), "'$(rm -rf /)'");
assert_eq!(shell_quote("`touch /tmp/pwned`"), "'`touch /tmp/pwned`'");
}
#[test]
fn empty_string() {
assert_eq!(shell_quote(""), "''");
}
#[test]
fn unicode_preserved() {
assert_eq!(shell_quote("héllo→wörld ✓"), "'héllo→wörld ✓'");
}
#[test]
fn newlines_preserved() {
assert_eq!(shell_quote("a\nb"), "'a\nb'");
}
/// The definitive check: a real shell must parse the quoted string back
/// into the original value as a single argument, without expanding or
/// executing anything inside it.
#[cfg(unix)]
#[test]
fn round_trips_through_sh() {
let tricky = "a'b\"c $HOME `echo pwned` ; | & \n x*y";
let output = std::process::Command::new("sh")
.arg("-c")
.arg(format!("printf '%s' {}", shell_quote(tricky)))
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout), tricky);
}
}