Skip to main content

strop_containers/
exec.rs

1//! The interactive exec channel (DC1b): `docker exec -i` for programs
2//! that speak a protocol over stdio — language servers, Git. Owned like
3//! every strop process: piped stdio, argv arrays (never a shell), and
4//! the local client's lifetime bounds the in-container program (stdin
5//! EOF on drop; the daemon ends the session).
6//!
7//! There is deliberately no pre-probe for "does this binary exist":
8//! on a distroless container no shell or `which` exists to answer, and
9//! the spawn itself classifies a missing executable truthfully from the
10//! engine's own error.
11
12use std::path::Path;
13use std::process::{Command, Stdio};
14use std::time::Duration;
15
16use crate::engine::{capture, Captured, EngineRef};
17use crate::ContainerError;
18use strop_core::worker::CancelToken;
19use strop_workspace::ContainerId;
20
21/// A `docker exec -i` command channel for one in-container program:
22/// piped stdin/stdout/stderr, working directory inside the container.
23/// The caller owns process supervision (group, kill-on-drop) as with
24/// every strop process launch. The id is the canonical inspect id —
25/// incarnation pinning is the read path's concern; a spawned session's
26/// liveness is scoped to itself.
27pub fn exec_command(id: &ContainerId, program: &str, args: &[String], cwd: &Path) -> Command {
28    let mut command = Command::new("docker");
29    command
30        .arg("exec")
31        .arg("-i")
32        .arg("--workdir")
33        .arg(cwd)
34        .arg(id.as_str())
35        .arg(program)
36        .args(args)
37        .stdin(Stdio::piped())
38        .stdout(Stdio::piped())
39        .stderr(Stdio::piped());
40    command
41}
42
43/// Wall-clock budget for one bounded in-container command run.
44const EXEC_DEADLINE: Duration = Duration::from_secs(30);
45
46/// One bounded command run inside the container (0037 DC1b): Git and
47/// friends. argv-only, supervised, deadline-bounded pipes. The engine
48/// was probed by the caller; the container id is canonical.
49pub fn exec_capture(
50    engine: &EngineRef,
51    id: &ContainerId,
52    program: &str,
53    args: &[String],
54    cwd: &Path,
55    stdout_limit: u64,
56    token: &CancelToken,
57) -> Result<Captured, ContainerError> {
58    let _ = engine;
59    let mut argv: Vec<&str> = vec!["exec", "--workdir"];
60    let Some(cwd_text) = cwd.to_str() else {
61        return Err(ContainerError::CapabilityRefused {
62            what: "exec: the working directory is not UTF-8".into(),
63        });
64    };
65    argv.push(cwd_text);
66    argv.push(id.as_str());
67    argv.push(program);
68    argv.extend(args.iter().map(String::as_str));
69    capture(&argv, stdout_limit, EXEC_DEADLINE, token)
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    use std::path::PathBuf;
77
78    fn reference() -> ContainerId {
79        ContainerId::canonical("a".repeat(64)).unwrap()
80    }
81
82    #[test]
83    fn exec_command_is_argv_only_with_piped_stdio() {
84        let command = exec_command(
85            &reference(),
86            "pyright-langserver",
87            &["--stdio".into()],
88            &PathBuf::from("/src"),
89        );
90        let text = format!("{command:?}");
91        assert!(text.contains("exec"), "{text}");
92        assert!(text.contains("--workdir"), "{text}");
93        assert!(text.contains("/src"), "{text}");
94        assert!(text.contains(&"a".repeat(64)), "{text}");
95        assert!(text.contains("pyright-langserver"), "{text}");
96        assert!(!text.contains("sh -c"), "no shell, ever: {text}");
97    }
98}