1use crate::error::Result;
15
16#[derive(Clone)]
18pub struct BeforeStart(pub std::sync::Arc<BeforeStartFn>);
19
20pub 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
29const SHELLS: &[&str] = &["sh", "bash", "dash", "ash", "zsh", "ksh", "mksh"];
31
32pub 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
62pub 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
77fn 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
92fn 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
117fn shell_eval(script: &str) -> String {
120 let mut esc = String::with_capacity(script.len() + 16);
121 for c in script.chars() {
122 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 load("services:\n web: {image: dev-base, command: [app, \"a\\nb\"]}\n").unwrap();
215 }
216
217 #[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}