Skip to main content

isb_core/plan/
oci.rs

1//! What an OCI instance needs beyond config: its command line, and work
2//! done on it between creation and first start.
3//!
4//! The command line: incus writes `oci.entrypoint` into the line-based LXC
5//! config and splits it on whitespace with quotes grouping. There is no
6//! escape character, so a line break cannot be carried at all, and an
7//! argument may hold only one kind of quote. The common case that needs
8//! more, `sh -c SCRIPT`, is rewritten so the
9//! shell decodes the script itself: `eval "$(printf %b "...")"`, with every
10//! line break, quote, `$`, backtick and backslash written as a `printf %b`
11//! escape. `$0` and the positional arguments are the shell's, as with `-c`.
12//! Anything else that cannot be carried is refused.
13
14use crate::error::Result;
15
16/// See `Desired::before_start`: called with the instance's name.
17#[derive(Clone)]
18pub struct BeforeStart(pub std::sync::Arc<BeforeStartFn>);
19
20/// The work: given a client on the instance's project, and its name.
21pub type BeforeStartFn = dyn Fn(&crate::client::Client, &str) -> Result<()> + Send + Sync;
22
23impl std::fmt::Debug for BeforeStart {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        f.write_str("BeforeStart")
26    }
27}
28
29/// Shells whose `-c` script may be rewritten (by basename).
30const SHELLS: &[&str] = &["sh", "bash", "dash", "ash", "zsh", "ksh", "mksh"];
31
32/// Quote argv for `oci.entrypoint`. An argument that fits on the line is
33/// written as before, so the result for one is byte-identical.
34pub fn oci_command_line(argv: &[String]) -> std::result::Result<String, String> {
35    let script = shell_script_index(argv);
36    argv.iter()
37        .enumerate()
38        .map(|(i, a)| {
39            if fits(a) {
40                return Ok(quote(a));
41            }
42            if Some(i) == script {
43                return Ok(quote(&shell_eval(a)));
44            }
45            if a.contains(['\n', '\r']) {
46                Err(format!(
47                    "argument {a:?} has a line break, which an OCI command line cannot carry. \
48                     Only the script of `sh -c SCRIPT` (or bash, ash, dash, zsh, ksh) may span \
49                     lines; put the text in a file or pass it through such a shell"
50                ))
51            } else {
52                Err(format!(
53                    "argument {a:?} has both ' and \" in it, which an OCI command line cannot \
54                     carry; use `sh -c SCRIPT` (isb rewrites a shell script) or a file"
55                ))
56            }
57        })
58        .collect::<std::result::Result<Vec<_>, _>>()
59        .map(|v| v.join(" "))
60}
61
62/// Refuse, when a file is loaded, an OCI command line that cannot be written.
63pub fn check_oci_command(
64    service: &str,
65    spec: &crate::spec::SandboxSpec,
66) -> std::result::Result<(), String> {
67    if !super::ImageSource::parse(&spec.image).is_ok_and(|i| i.is_oci()) {
68        return Ok(());
69    }
70    let mut line = spec.entrypoint.clone().unwrap_or_default();
71    line.extend(spec.command.clone().unwrap_or_default());
72    oci_command_line(&line)
73        .map(|_| ())
74        .map_err(|e| format!("service {service:?}: {e}"))
75}
76
77/// Whether `oci.entrypoint` can hold `a` as it is.
78fn fits(a: &str) -> bool {
79    !a.contains(['\n', '\r']) && !(a.contains('"') && a.contains('\''))
80}
81
82fn quote(a: &str) -> String {
83    if !a.is_empty() && !a.contains(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
84        a.to_string()
85    } else if !a.contains('"') {
86        format!("\"{a}\"")
87    } else {
88        format!("'{a}'")
89    }
90}
91
92/// The index of the script in `<shell> [OPTIONS] -c SCRIPT ...`, if argv has
93/// that shape (`-ec`, `-lc` and `-o OPT` included).
94fn shell_script_index(argv: &[String]) -> Option<usize> {
95    let sh = argv.iter().position(|a| {
96        let base = a.rsplit('/').next().unwrap_or(a);
97        SHELLS.contains(&base)
98    })?;
99    let mut i = sh + 1;
100    while let Some(a) = argv.get(i) {
101        let flags = a.strip_prefix('-').or_else(|| a.strip_prefix('+'))?;
102        if flags.is_empty() || flags.starts_with('-') {
103            return None;
104        }
105        if flags == "o" || flags == "O" {
106            i += 2;
107            continue;
108        }
109        if a.starts_with('-') && flags.contains('c') {
110            return (i + 1 < argv.len()).then_some(i + 1);
111        }
112        i += 1;
113    }
114    None
115}
116
117/// `script` as a one-line `-c` argument that only holds `"`: the shell's own
118/// `printf %b` turns the escapes back into the script, which `eval` runs.
119fn shell_eval(script: &str) -> String {
120    let mut esc = String::with_capacity(script.len() + 16);
121    for c in script.chars() {
122        // Inside "...", `\\` is one backslash, so `\\n` reaches printf as `\n`.
123        match c {
124            '\n' => esc.push_str("\\\\n"),
125            '\r' => esc.push_str("\\\\r"),
126            '"' => esc.push_str("\\\\0042"),
127            '$' => esc.push_str("\\\\0044"),
128            '\'' => esc.push_str("\\\\0047"),
129            '\\' => esc.push_str("\\\\0134"),
130            '`' => esc.push_str("\\\\0140"),
131            c => esc.push(c),
132        }
133    }
134    format!("eval \"$(printf %b \"{esc}\")\"")
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn argv(a: &[&str]) -> Vec<String> {
142        a.iter().map(|s| s.to_string()).collect()
143    }
144
145    #[test]
146    fn single_line_arguments_are_written_as_before() {
147        let l = oci_command_line(&argv(&["sh", "-c", "echo $HOME; exec app", "", "a'b"])).unwrap();
148        assert_eq!(l, r#"sh -c "echo $HOME; exec app" "" "a'b""#);
149        let l = oci_command_line(&argv(&["app", "--x=\"y z\""])).unwrap();
150        assert_eq!(l, r#"app '--x="y z"'"#);
151    }
152
153    #[test]
154    fn a_multi_line_shell_script_becomes_one_line() {
155        let script = "set -e\necho \"$1\" 'x' `id` \\\nexec app";
156        let l = oci_command_line(&argv(&["/bin/sh", "-ec", script, "name", "arg"])).unwrap();
157        assert!(!l.contains('\n'), "{l}");
158        assert_eq!(
159            l,
160            "/bin/sh -ec 'eval \"$(printf %b \"set -e\\\\necho \\\\0042\\\\00441\\\\0042 \
161             \\\\0047x\\\\0047 \\\\0140id\\\\0140 \\\\0134\\\\nexec app\")\"' name arg"
162        );
163    }
164
165    #[test]
166    fn shell_options_before_c_are_skipped() {
167        let a = argv(&["tini", "--", "bash", "-o", "pipefail", "-lc", "a\nb"]);
168        assert_eq!(shell_script_index(&a), Some(6));
169        assert_eq!(
170            shell_script_index(&argv(&["bash", "--login", "-c", "x"])),
171            None
172        );
173        assert_eq!(shell_script_index(&argv(&["sh", "-c"])), None);
174        assert_eq!(
175            shell_script_index(&argv(&["sh", "script.sh", "-c", "x"])),
176            None
177        );
178        assert_eq!(
179            shell_script_index(&argv(&["busybox", "sh", "-c", "x"])),
180            Some(3)
181        );
182    }
183
184    #[test]
185    fn a_line_break_elsewhere_is_refused() {
186        let e = oci_command_line(&argv(&["app", "--config", "a\nb"])).unwrap_err();
187        assert!(e.contains("line break"), "{e}");
188        let e = oci_command_line(&argv(&["sh", "-c", "echo", "a\nb"])).unwrap_err();
189        assert!(e.contains("line break"), "{e}");
190        let e = oci_command_line(&argv(&["python", "-c", "a\nb"])).unwrap_err();
191        assert!(e.contains("line break"), "{e}");
192    }
193
194    #[test]
195    fn both_quotes_in_a_shell_script_are_carried() {
196        let l = oci_command_line(&argv(&["sh", "-c", r#"echo "it's""#])).unwrap();
197        assert!(l.starts_with("sh -c 'eval "), "{l}");
198        let e = oci_command_line(&argv(&["app", r#""it's""#])).unwrap_err();
199        assert!(e.contains("both ' and \""), "{e}");
200    }
201
202    #[test]
203    fn loading_a_file_refuses_what_cannot_be_written() {
204        let load = |doc: &str| {
205            let docs = [(std::path::PathBuf::from("f.yaml"), doc.to_string())];
206            crate::compose::load_docs(&docs, std::path::Path::new("/tmp/p"), None, &|_| None)
207        };
208        let e = load("services:\n  web: {image: docker:busybox, command: [app, \"a\\nb\"]}\n")
209            .unwrap_err()
210            .to_string();
211        assert!(e.contains("\"web\"") && e.contains("line break"), "{e}");
212        load("services:\n  web: {image: docker:busybox, command: [sh, -c, \"a\\nb\"]}\n").unwrap();
213        // A system image's command is a unit's ExecStart, not this line.
214        load("services:\n  web: {image: dev-base, command: [app, \"a\\nb\"]}\n").unwrap();
215    }
216
217    /// The rewritten script means the same to real shells (when present).
218    #[test]
219    fn shells_decode_the_rewritten_script() {
220        let script = "set -e\nprintf '%s|' \"$0\" \"$@\" 'a\"b' \"c'd\" `echo e` \\\n  f\ncat <<EOF\nx $1 \\$y\nEOF";
221        for sh in ["sh", "dash", "bash", "busybox"] {
222            let run = |s: &str| {
223                let mut cmd = std::process::Command::new(sh);
224                if sh == "busybox" {
225                    cmd.arg("sh");
226                }
227                cmd.args(["-c", s, "zero", "one", "t w o"]).output()
228            };
229            let (Ok(out), Ok(direct)) = (run(&shell_eval(script)), run(script)) else {
230                continue;
231            };
232            assert!(
233                out.status.success(),
234                "{sh}: {}",
235                String::from_utf8_lossy(&out.stderr)
236            );
237            assert_eq!(
238                String::from_utf8_lossy(&out.stdout),
239                "zero|one|t w o|a\"b|c'd|e|f|x one $y\n",
240                "{sh}"
241            );
242            assert_eq!(out.stdout, direct.stdout, "{sh}");
243        }
244    }
245}