Skip to main content

start_command/
docker_command_handoff.rs

1//! Copy-free command handoff for newly launched detached Docker containers.
2use crate::docker_cleanup::docker_command;
3use crate::execution_control::{CommandRunOutput, CommandRunner};
4use crate::isolation::isolation_log::shell_quote;
5use std::fs;
6
7pub fn command_handoff_path(session_name: &str) -> String {
8    let safe: String = session_name
9        .chars()
10        .map(|c| {
11            if c.is_ascii_alphanumeric() || "_.-".contains(c) {
12                c
13            } else {
14                '_'
15            }
16        })
17        .collect();
18    format!("/.start-command-resume-{}", safe)
19}
20
21pub fn build_command_handoff_args(main_args: &[String], marker_path: &str) -> Vec<String> {
22    let mut args = vec![
23        "sh".into(),
24        "-c".into(),
25        // `$p.env` refreshes the START_* variables a `docker start` cannot
26        // change (issue #230) before either command runs.
27        "p=$1; shift; if [ -f \"$p.env\" ]; then . \"$p.env\"; fi; if [ -f \"$p\" ]; then exec sh \"$p\"; fi; exec \"$@\"".into(),
28        "start-command-handoff".into(),
29        marker_path.into(),
30    ];
31    args.extend_from_slice(main_args);
32    args
33}
34
35pub fn build_command_handoff_script(command: &str, shell: &str, keep_alive: bool) -> String {
36    use crate::isolation::isolation_shell::{
37        docker_shell_args, is_interactive_shell_command, is_shell_invocation_with_args,
38    };
39    if !keep_alive
40        && (is_interactive_shell_command(command) || is_shell_invocation_with_args(command))
41    {
42        return format!(
43            "exec {}\n",
44            docker_shell_args(command, "sh", false)
45                .iter()
46                .map(|arg| shell_quote(arg))
47                .collect::<Vec<_>>()
48                .join(" ")
49        );
50    }
51    if shell == "auto" || shell.is_empty() {
52        let effective = if keep_alive {
53            format!("{}; exec \"$__START_COMMAND_HANDOFF_SHELL\"", command)
54        } else {
55            command.into()
56        };
57        return format!("__START_COMMAND_HANDOFF_SHELL=$(command -v bash || command -v zsh || command -v sh); export __START_COMMAND_HANDOFF_SHELL\ncase \"$__START_COMMAND_HANDOFF_SHELL\" in */bash|bash|*/zsh|zsh) exec \"$__START_COMMAND_HANDOFF_SHELL\" -i -c {};; *) exec \"$__START_COMMAND_HANDOFF_SHELL\" -c {};; esac\n", shell_quote(&effective), shell_quote(&effective));
58    }
59    let interactive = if matches!(shell.rsplit('/').next(), Some("bash" | "zsh")) {
60        " -i"
61    } else {
62        ""
63    };
64    let effective = if keep_alive {
65        format!("{}; exec {}", command, shell_quote(shell))
66    } else {
67        command.into()
68    };
69    format!(
70        "exec {}{} -c {}\n",
71        shell_quote(shell),
72        interactive,
73        shell_quote(&effective)
74    )
75}
76
77fn copy_into_container<R: CommandRunner + ?Sized>(
78    name: &str,
79    target: &str,
80    content: &str,
81    runner: &R,
82) -> Result<CommandRunOutput, String> {
83    let dir = std::env::temp_dir().join(format!("start-handoff-{}", uuid::Uuid::new_v4()));
84    fs::create_dir(&dir).map_err(|e| e.to_string())?;
85    let file = dir.join("command");
86    let result = (|| {
87        fs::write(&file, content).map_err(|e| e.to_string())?;
88        #[cfg(unix)]
89        {
90            use std::os::unix::fs::PermissionsExt;
91            fs::set_permissions(&file, fs::Permissions::from_mode(0o644))
92                .map_err(|e| e.to_string())?;
93        }
94        Ok(runner.run(
95            &docker_command().to_string_lossy(),
96            &[
97                "cp".into(),
98                file.to_string_lossy().into(),
99                format!("{}:{}", name, target),
100            ],
101        ))
102    })();
103    let _ = fs::remove_dir_all(&dir);
104    result
105}
106
107pub fn write_command_handoff<R: CommandRunner + ?Sized>(
108    name: &str,
109    command: &str,
110    shell: &str,
111    keep_alive: bool,
112    runner: &R,
113) -> Result<CommandRunOutput, String> {
114    copy_into_container(
115        name,
116        &command_handoff_path(name),
117        &build_command_handoff_script(command, shell, keep_alive),
118        runner,
119    )
120}
121
122/// Copy the START_* variables of the next attempt into a stopped container
123/// that was created with the handoff selector (issue #230).
124pub fn write_execution_env_handoff<R: CommandRunner + ?Sized>(
125    name: &str,
126    env: &crate::execution_env::ExecutionEnv,
127    runner: &R,
128) -> Result<CommandRunOutput, String> {
129    let statement = crate::execution_env::build_execution_env_export(env);
130    let content = if statement.is_empty() {
131        statement
132    } else {
133        format!("{}\n", statement)
134    };
135    copy_into_container(
136        name,
137        &format!("{}.env", command_handoff_path(name)),
138        &content,
139        runner,
140    )
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use std::process::Command;
147
148    #[test]
149    fn selector_preserves_argv_and_replaces_only_the_command() {
150        let dir = tempfile::tempdir().unwrap();
151        let marker = dir.path().join("command");
152        let args = build_command_handoff_args(
153            &["sh".into(), "-c".into(), "printf original".into()],
154            "command",
155        );
156        let run = || {
157            Command::new(&args[0])
158                .args(&args[1..])
159                .current_dir(dir.path())
160                .output()
161                .unwrap()
162                .stdout
163        };
164        assert_eq!(run(), b"original");
165        fs::write(&marker, "exec sh -c 'printf replacement'\n").unwrap();
166        assert_eq!(run(), b"replacement");
167        assert_eq!(run(), b"replacement");
168    }
169
170    #[test]
171    fn handoff_preserves_direct_shell_argv_and_compound_status() {
172        for (command, expected) in [
173            (
174                "sh -c 'printf \"%s:%s\" \"$0\" \"$1\"' tag 'two words'",
175                "tag:two words",
176            ),
177            ("sh -c 'exit 7'; printf 'after%s' \"$?\"", "after7"),
178        ] {
179            let result = Command::new("sh")
180                .args(["-c", &build_command_handoff_script(command, "sh", false)])
181                .output()
182                .unwrap();
183            assert!(
184                result.status.success(),
185                "{}",
186                String::from_utf8_lossy(&result.stderr)
187            );
188            assert_eq!(String::from_utf8_lossy(&result.stdout), expected);
189        }
190    }
191
192    #[test]
193    fn auto_handoff_chooses_an_installed_shell_inside_the_container() {
194        let script = build_command_handoff_script("printf auto", "auto", false);
195        assert!(script.contains("command -v bash || command -v zsh || command -v sh"));
196        let result = Command::new("sh").args(["-c", &script]).output().unwrap();
197        assert!(
198            result.status.success(),
199            "{}",
200            String::from_utf8_lossy(&result.stderr)
201        );
202        assert_eq!(result.stdout, b"auto");
203    }
204}