Skip to main content

construct/runtime/
docker.rs

1use super::traits::RuntimeAdapter;
2use crate::config::DockerRuntimeConfig;
3use anyhow::{Context, Result};
4use std::path::{Path, PathBuf};
5
6/// Docker runtime with lightweight container isolation.
7#[derive(Debug, Clone)]
8pub struct DockerRuntime {
9    config: DockerRuntimeConfig,
10}
11
12impl DockerRuntime {
13    pub fn new(config: DockerRuntimeConfig) -> Self {
14        Self { config }
15    }
16
17    fn workspace_mount_path(&self, workspace_dir: &Path) -> Result<PathBuf> {
18        let resolved = workspace_dir
19            .canonicalize()
20            .unwrap_or_else(|_| workspace_dir.to_path_buf());
21
22        if !resolved.is_absolute() {
23            anyhow::bail!(
24                "Docker runtime requires an absolute workspace path, got: {}",
25                resolved.display()
26            );
27        }
28
29        if resolved == Path::new("/") {
30            anyhow::bail!("Refusing to mount filesystem root (/) into docker runtime");
31        }
32
33        if self.config.allowed_workspace_roots.is_empty() {
34            return Ok(resolved);
35        }
36
37        let allowed = self.config.allowed_workspace_roots.iter().any(|root| {
38            let root_path = Path::new(root)
39                .canonicalize()
40                .unwrap_or_else(|_| PathBuf::from(root));
41            resolved.starts_with(root_path)
42        });
43
44        if !allowed {
45            anyhow::bail!(
46                "Workspace path {} is not in runtime.docker.allowed_workspace_roots",
47                resolved.display()
48            );
49        }
50
51        Ok(resolved)
52    }
53}
54
55impl RuntimeAdapter for DockerRuntime {
56    fn name(&self) -> &str {
57        "docker"
58    }
59
60    fn has_shell_access(&self) -> bool {
61        true
62    }
63
64    fn has_filesystem_access(&self) -> bool {
65        self.config.mount_workspace
66    }
67
68    fn storage_path(&self) -> PathBuf {
69        if self.config.mount_workspace {
70            PathBuf::from("/workspace/.construct")
71        } else {
72            PathBuf::from("/tmp/.construct")
73        }
74    }
75
76    fn supports_long_running(&self) -> bool {
77        false
78    }
79
80    fn memory_budget(&self) -> u64 {
81        self.config
82            .memory_limit_mb
83            .map_or(0, |mb| mb.saturating_mul(1024 * 1024))
84    }
85
86    fn build_shell_command(
87        &self,
88        command: &str,
89        workspace_dir: &Path,
90    ) -> anyhow::Result<tokio::process::Command> {
91        let mut process = tokio::process::Command::new("docker");
92        process
93            .arg("run")
94            .arg("--rm")
95            .arg("--init")
96            .arg("--interactive");
97
98        let network = self.config.network.trim();
99        if !network.is_empty() {
100            process.arg("--network").arg(network);
101        }
102
103        if let Some(memory_limit_mb) = self.config.memory_limit_mb.filter(|mb| *mb > 0) {
104            process.arg("--memory").arg(format!("{memory_limit_mb}m"));
105        }
106
107        if let Some(cpu_limit) = self.config.cpu_limit.filter(|cpus| *cpus > 0.0) {
108            process.arg("--cpus").arg(cpu_limit.to_string());
109        }
110
111        if self.config.read_only_rootfs {
112            process.arg("--read-only");
113        }
114
115        if self.config.mount_workspace {
116            let host_workspace = self.workspace_mount_path(workspace_dir).with_context(|| {
117                format!(
118                    "Failed to validate workspace mount path {}",
119                    workspace_dir.display()
120                )
121            })?;
122
123            process
124                .arg("--volume")
125                .arg(format!("{}:/workspace:rw", host_workspace.display()))
126                .arg("--workdir")
127                .arg("/workspace");
128        }
129
130        process
131            .arg(self.config.image.trim())
132            .arg("sh")
133            .arg("-c")
134            .arg(command);
135
136        Ok(process)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn docker_runtime_name() {
146        let runtime = DockerRuntime::new(DockerRuntimeConfig::default());
147        assert_eq!(runtime.name(), "docker");
148    }
149
150    #[test]
151    fn docker_runtime_memory_budget() {
152        let mut cfg = DockerRuntimeConfig::default();
153        cfg.memory_limit_mb = Some(256);
154        let runtime = DockerRuntime::new(cfg);
155        assert_eq!(runtime.memory_budget(), 256 * 1024 * 1024);
156    }
157
158    #[test]
159    fn docker_build_shell_command_includes_runtime_flags() {
160        let cfg = DockerRuntimeConfig {
161            image: "alpine:3.20".into(),
162            network: "none".into(),
163            memory_limit_mb: Some(128),
164            cpu_limit: Some(1.5),
165            read_only_rootfs: true,
166            mount_workspace: true,
167            allowed_workspace_roots: Vec::new(),
168        };
169        let runtime = DockerRuntime::new(cfg);
170
171        let workspace = std::env::temp_dir();
172        let command = runtime
173            .build_shell_command("echo hello", &workspace)
174            .unwrap();
175        let debug = format!("{command:?}");
176
177        assert!(debug.contains("docker"));
178        assert!(debug.contains("--memory"));
179        assert!(debug.contains("128m"));
180        assert!(debug.contains("--cpus"));
181        assert!(debug.contains("1.5"));
182        assert!(debug.contains("--workdir"));
183        assert!(debug.contains("echo hello"));
184    }
185
186    #[test]
187    fn docker_workspace_allowlist_blocks_outside_paths() {
188        let cfg = DockerRuntimeConfig {
189            allowed_workspace_roots: vec!["/tmp/allowed".into()],
190            ..DockerRuntimeConfig::default()
191        };
192        let runtime = DockerRuntime::new(cfg);
193
194        let outside = PathBuf::from("/tmp/blocked_workspace");
195        let result = runtime.build_shell_command("echo test", &outside);
196
197        assert!(result.is_err());
198    }
199
200    // ── §3.3 / §3.4 Docker mount & network isolation tests ──
201
202    #[test]
203    fn docker_build_shell_command_includes_network_flag() {
204        let cfg = DockerRuntimeConfig {
205            network: "none".into(),
206            ..DockerRuntimeConfig::default()
207        };
208        let runtime = DockerRuntime::new(cfg);
209        let workspace = std::env::temp_dir();
210        let cmd = runtime
211            .build_shell_command("echo hello", &workspace)
212            .unwrap();
213        let debug = format!("{cmd:?}");
214        assert!(
215            debug.contains("--network") && debug.contains("none"),
216            "must include --network none for isolation"
217        );
218    }
219
220    #[test]
221    fn docker_build_shell_command_includes_read_only_flag() {
222        let cfg = DockerRuntimeConfig {
223            read_only_rootfs: true,
224            ..DockerRuntimeConfig::default()
225        };
226        let runtime = DockerRuntime::new(cfg);
227        let workspace = std::env::temp_dir();
228        let cmd = runtime
229            .build_shell_command("echo hello", &workspace)
230            .unwrap();
231        let debug = format!("{cmd:?}");
232        assert!(
233            debug.contains("--read-only"),
234            "must include --read-only flag when read_only_rootfs is set"
235        );
236    }
237
238    #[cfg(unix)]
239    #[test]
240    fn docker_refuses_root_mount() {
241        let cfg = DockerRuntimeConfig {
242            mount_workspace: true,
243            ..DockerRuntimeConfig::default()
244        };
245        let runtime = DockerRuntime::new(cfg);
246        let result = runtime.build_shell_command("echo test", Path::new("/"));
247        assert!(
248            result.is_err(),
249            "mounting filesystem root (/) must be refused"
250        );
251        let error_chain = format!("{:#}", result.unwrap_err());
252        assert!(
253            error_chain.contains("root"),
254            "expected root-mount error chain, got: {error_chain}"
255        );
256    }
257
258    #[test]
259    fn docker_no_memory_flag_when_not_configured() {
260        let cfg = DockerRuntimeConfig {
261            memory_limit_mb: None,
262            ..DockerRuntimeConfig::default()
263        };
264        let runtime = DockerRuntime::new(cfg);
265        let workspace = std::env::temp_dir();
266        let cmd = runtime
267            .build_shell_command("echo hello", &workspace)
268            .unwrap();
269        let debug = format!("{cmd:?}");
270        assert!(
271            !debug.contains("--memory"),
272            "should not include --memory when not configured"
273        );
274    }
275}