tests/backend_quoting.rs
//! Stinky #10: every backend hands the process EXACTLY the words and env values the
//! spec declared — no shell injection, no word splitting, no lost empty argument.
//!
//! The shell-script backends (OpenRC, SysV, runit, s6) are checked by running the
//! rendered text through a real `/bin/sh` with the supervisor verbs stubbed out, so the
//! proof is the shell's own parse, not a string compare. The non-shell formats (dinit,
//! quadlet, Windows) are decoded by their documented tokenizer rules.
#![cfg(unix)]
use skidbladnir::service::{Backend, RenderedService, ServiceSpec, renderer_for};
use std::path::Path;
use std::process::Command;
const DESC: &str = "it's $(touch pwned) `touch pwned` \"q\" 50%";
const EXEC: &str = "/opt/my app/bin/svc";
const ARGS: &[&str] = &[
"a b",
"$(touch pwned)",
"it's",
"",
"x\"y\\z",
"*",
"~",
"#c",
";&|",
"$HOME",
"50%",
];
const WD: &str = "/srv/my dir/$HOME";
const TOKEN: &str = "v'1\"$(touch pwned)`touch pwned`\\ ;&|<>*?~#%";
const MULTI: &str = "line1\nline2";
fn spec(multi: bool) -> ServiceSpec {
let mut s = ServiceSpec::new("svc", DESC, EXEC)
.working_dir(WD)
.image("example/img:1")
.env("TOKEN", TOKEN)
.secret_env("SECRET", TOKEN);
for a in ARGS {
s = s.arg(*a);
}
if multi {
s = s.env("MULTI", MULTI);
}
s
}
fn words() -> Vec<String> {
std::iter::once(EXEC)
.chain(ARGS.iter().copied())
.map(String::from)
.collect()
}
fn render(b: Backend, multi: bool) -> anyhow::Result<RenderedService> {
renderer_for(b).expect("implemented backend").render(&spec(multi))
}
fn file<'a>(r: &'a RenderedService, suffix: &str) -> &'a str {
&r.files
.iter()
.find(|f| f.path.ends_with(suffix))
.unwrap_or_else(|| panic!("no rendered file ending in {suffix}"))
.contents
}
/// Run `script` under `/bin/sh` in a scratch dir and return the `\x1e`-separated
/// fields it printed. Fails if any injected `touch pwned` ran.
fn sh(script: &str) -> Vec<String> {
let dir = tempfile::tempdir().unwrap();
let out = Command::new("/bin/sh")
.arg("-c")
.arg(script)
.current_dir(dir.path())
.env("HOME", "/home/nobody")
.output()
.unwrap();
assert!(
!Path::new(&dir.path().join("pwned")).exists(),
"a rendered value was EXECUTED by the shell"
);
assert!(
out.status.success(),
"sh failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8(out.stdout).unwrap();
let mut f: Vec<String> = text.split('\x1e').map(String::from).collect();
assert_eq!(f.pop().as_deref(), Some(""), "output ends with a separator");
f
}
const PRINT: &str = "printf '%s\\036'";
/// Replace the final `exec …` line of a run script with a printf of its words.
fn print_exec_line(run: &str) -> String {
run.lines()
.map(|l| match l.strip_prefix("exec ") {
Some(rest) if l != "exec 2>&1" => format!("{PRINT} {rest}"),
_ => l.to_string(),
})
.collect::<Vec<_>>()
.join("\n")
}
fn env_from_shell(conf: &str) -> Vec<String> {
sh(&format!(
"{conf}\n{PRINT} \"$TOKEN\" \"$SECRET\" \"$MULTI\"\n"
))
}
#[test]
fn openrc_script_and_conf_survive_the_shell_byte_exact() {
let r = render(Backend::OpenRc, true).unwrap();
let init = file(&r, "/etc/init.d/svc");
// openrc-run sources the script, then `eval`s command/command_args/directory.
let got = sh(&format!(
"{init}\n{PRINT} \"$description\"\neval \"set -- $command $command_args\"\n{PRINT} \"$@\"\neval \"set -- $directory\"\n{PRINT} \"$@\"\n"
));
let mut want = vec![DESC.to_string()];
want.extend(words());
want.push(WD.to_string());
assert_eq!(got, want);
assert_eq!(
env_from_shell(file(&r, "/etc/conf.d/svc")),
[TOKEN, TOKEN, MULTI]
);
}
#[test]
fn sysv_script_and_default_survive_the_shell_byte_exact() {
let r = render(Backend::SysV, true).unwrap();
let init = file(&r, "/etc/init.d/svc");
let got = sh(&format!(
"start_stop_daemon_stub() {{ {PRINT} \"$@\"; }}\nset -- start\n{}\n",
init.replace("start-stop-daemon", "start_stop_daemon_stub")
));
let at = |flag: &str| got[got.iter().position(|w| w == flag).unwrap() + 1].clone();
assert_eq!(at("--exec"), EXEC);
assert_eq!(at("--chdir"), WD);
let dashdash = got.iter().position(|w| w == "--").unwrap();
assert_eq!(got[dashdash + 1..], ARGS.iter().map(|a| a.to_string()).collect::<Vec<_>>()[..]);
assert_eq!(
env_from_shell(file(&r, "/etc/default/svc")),
[TOKEN, TOKEN, MULTI]
);
}
#[test]
fn runit_run_and_conf_survive_the_shell_byte_exact() {
let r = render(Backend::Runit, true).unwrap();
let run = file(&r, "/svc/run");
let got = sh(&format!(
"cd() {{ {PRINT} cd \"$@\"; }}\nsv() {{ :; }}\n{}\n",
print_exec_line(run).replace("[ -r ./conf ] && . ./conf", ":")
));
assert_eq!(got[..2], ["cd".to_string(), WD.to_string()]);
assert!(got.ends_with(&words()), "{got:?}");
assert_eq!(env_from_shell(file(&r, "/svc/conf")), [TOKEN, TOKEN, MULTI]);
}
#[test]
fn s6_run_and_envdir_carry_every_value_byte_exact() {
let r = render(Backend::S6Rc, true).unwrap();
let got = sh(&print_exec_line(file(&r, "/svc/run")));
let cd = got.iter().position(|w| w == "cd").unwrap();
assert_eq!(got[cd + 1], WD);
assert!(got.ends_with(&words()), "{got:?}");
// s6-envdir: first line, trailing blanks chomped, NUL → newline.
let decode = |c: &str| {
c.split('\n')
.next()
.unwrap()
.trim_end_matches([' ', '\t'])
.replace('\0', "\n")
};
assert_eq!(decode(file(&r, "/env/TOKEN")), TOKEN);
assert_eq!(decode(file(&r, "/env/MULTI")), MULTI);
}
/// dinit-service(5): whitespace splits tokens, `"` groups, `\` escapes the next
/// character (also inside quotes), and `$$` is a literal `$` after tokenizing.
fn dinit_tokens(v: &str) -> Vec<String> {
let mut out = Vec::new();
let (mut cur, mut quoted, mut any) = (String::new(), false, false);
let mut it = v.chars();
while let Some(c) = it.next() {
match c {
'\\' => {
cur.push(it.next().unwrap());
any = true;
}
'"' => {
quoted = !quoted;
any = true;
}
c if c.is_whitespace() && !quoted => {
if any {
out.push(std::mem::take(&mut cur));
any = false;
}
}
c => {
cur.push(c);
any = true;
}
}
}
if any {
out.push(cur);
}
assert!(
out.iter().all(|t| !t.replace("$$", "").contains('$')),
"an unescaped `$` would be substituted by dinit: {out:?}"
);
out.into_iter().map(|t| t.replace("$$", "$")).collect()
}
#[test]
fn dinit_command_and_working_dir_tokenize_back_exactly() {
let r = render(Backend::Dinit, false).unwrap();
let desc = file(&r, "/dinit.d/svc");
let value = |key: &str| {
desc.lines()
.find_map(|l| l.strip_prefix(&format!("{key} = ")))
.unwrap()
.to_string()
};
assert_eq!(dinit_tokens(&value("command")), words());
assert_eq!(dinit_tokens(&value("working-dir")), [WD]);
let env = file(&r, "svc.env");
assert!(env.lines().any(|l| l == format!("TOKEN={TOKEN}")));
}
#[test]
fn literal_env_files_refuse_a_value_they_cannot_carry() {
for b in [Backend::Dinit, Backend::Oci] {
let err = render(b, true).expect_err("a newline in a literal NAME=VALUE file must be refused");
assert!(format!("{err:#}").contains("MULTI"), "{b:?}: {err:#}");
}
}
/// Split a systemd/quadlet value into raw words (`"` groups, `\` escapes inside
/// quotes), then decode each with the crate's own inverse.
fn systemd_words(v: &str) -> Vec<String> {
let mut raw = Vec::new();
let (mut cur, mut quoted) = (String::new(), false);
let mut it = v.chars();
while let Some(c) = it.next() {
match c {
'\\' if quoted => {
cur.push(c);
cur.push(it.next().unwrap());
}
'"' => {
quoted = !quoted;
cur.push(c);
}
' ' if !quoted => raw.push(std::mem::take(&mut cur)),
c => cur.push(c),
}
}
raw.push(cur);
raw.iter()
.map(|w| skidbladnir::systemd::systemd_unword(w))
.collect()
}
#[test]
fn quadlet_exec_environment_and_description_decode_exactly() {
let r = render(Backend::Quadlet, true).unwrap();
let unit = file(&r, "svc.container");
let line = |key: &str| -> Vec<&str> {
unit.lines()
.filter_map(|l| l.strip_prefix(&format!("{key}=")))
.collect()
};
assert_eq!(systemd_words(line("Exec")[0]), words());
// `K=<word>`: the key is bare, the value one systemd word.
let env: Vec<(String, String)> = line("Environment")
.iter()
.map(|l| {
let (k, v) = l.split_once('=').unwrap();
let v = systemd_words(v);
assert_eq!(v.len(), 1, "{l}");
(k.to_string(), v[0].clone())
})
.collect();
assert!(env.contains(&("TOKEN".into(), TOKEN.into())), "{env:?}");
assert!(env.contains(&("MULTI".into(), MULTI.into())), "{env:?}");
assert_eq!(line("Description"), [DESC.replace('%', "%%").as_str()]);
}
/// `CommandLineToArgvW`: whitespace splits outside quotes; `2n` backslashes + `"`
/// → `n` backslashes and a quote toggle; `2n+1` → `n` and a literal `"`.
fn windows_argv(cmd: &str) -> Vec<String> {
let (mut out, mut cur, mut quoted, mut any) = (Vec::new(), String::new(), false, false);
let cs: Vec<char> = cmd.chars().collect();
let mut i = 0;
while i < cs.len() {
let c = cs[i];
if c == '\\' {
let mut n = 0;
while i < cs.len() && cs[i] == '\\' {
n += 1;
i += 1;
}
if i < cs.len() && cs[i] == '"' {
cur.extend(std::iter::repeat_n('\\', n / 2));
if n % 2 == 1 {
cur.push('"');
i += 1;
}
} else {
cur.extend(std::iter::repeat_n('\\', n));
}
any = true;
continue;
}
match c {
'"' => {
quoted = !quoted;
any = true;
}
' ' | '\t' if !quoted => {
if any {
out.push(std::mem::take(&mut cur));
any = false;
}
}
c => {
cur.push(c);
any = true;
}
}
i += 1;
}
if any {
out.push(cur);
}
out
}
#[test]
fn windows_bin_path_splits_back_into_the_exact_argv() {
// Windows refuses secrets outright (no private env channel), so no secret here.
let mut spec = ServiceSpec::new("svc", DESC, EXEC);
for a in ARGS {
spec = spec.arg(*a);
}
let r = renderer_for(Backend::WindowsService)
.unwrap()
.render(&spec)
.unwrap();
let bin = r
.enable
.iter()
.position(|w| w == "binPath=")
.map(|i| r.enable[i + 1].clone())
.unwrap();
assert_eq!(windows_argv(&bin), words());
}