Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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());
}