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).
110 lines
3.6 KiB
Rust
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);
|
|
}
|
|
}
|