Skip to main content

icebox/core/
sandbox.rs

1use crate::core::module::ModuleResult;
2use bollard::container::LogOutput;
3use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
4use bollard::models::ContainerCreateBody;
5use bollard::query_parameters::{
6    CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
7};
8use bollard::Docker;
9use futures_util::StreamExt;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SandboxEngineType {
13    Docker,
14}
15
16#[derive(Debug)]
17pub enum Sandbox {
18    Docker(DockerSandbox),
19}
20
21#[derive(Debug, thiserror::Error)]
22pub enum SandboxError {
23    #[error("docker: {0}")]
24    Docker(#[from] bollard::errors::Error),
25    #[error(
26        "sandbox worker binary not found. Run: cargo xtask build-sandbox-worker (requires Docker)"
27    )]
28    Unavailable,
29    #[error("worker error: {0}")]
30    Worker(String),
31    #[error("engine {0} is not supported on this operating system")]
32    UnsupportedOS(&'static str),
33}
34
35impl Sandbox {
36    pub async fn freeze(
37        engine: SandboxEngineType,
38        target: &str,
39        image: &str,
40    ) -> Result<Self, SandboxError> {
41        match engine {
42            SandboxEngineType::Docker => {
43                Ok(Sandbox::Docker(DockerSandbox::freeze(target, image).await?))
44            }
45        }
46    }
47
48    pub fn container_id(&self) -> &str {
49        match self {
50            Sandbox::Docker(d) => d.container_id(),
51        }
52    }
53
54    pub fn target(&self) -> &str {
55        match self {
56            Sandbox::Docker(d) => d.target(),
57        }
58    }
59
60    pub async fn ip_address(&self) -> Result<String, SandboxError> {
61        match self {
62            Sandbox::Docker(d) => d.ip_address().await,
63        }
64    }
65
66    pub async fn capture_logs(&self) -> Vec<String> {
67        match self {
68            Sandbox::Docker(d) => d.capture_logs().await,
69        }
70    }
71
72    pub async fn melt(self) -> Result<(), SandboxError> {
73        match self {
74            Sandbox::Docker(d) => d.melt().await,
75        }
76    }
77
78    pub async fn exec_module(
79        &self,
80        name: &str,
81        target: &str,
82        options: &serde_json::Value,
83    ) -> Result<ModuleResult, SandboxError> {
84        match self {
85            Sandbox::Docker(d) => d.exec_module(name, target, options).await,
86        }
87    }
88}
89
90#[derive(Debug)]
91pub struct DockerSandbox {
92    docker: Docker,
93    container_id: String,
94    target: String,
95}
96
97async fn ensure_image(docker: &Docker, image: &str) -> Result<(), SandboxError> {
98    if docker.inspect_image(image).await.is_ok() {
99        return Ok(());
100    }
101    if image == "icebox-sandbox:latest" {
102        let worker = std::env::current_exe()
103            .ok()
104            .and_then(|p| p.parent().map(|d| d.join("icebox-worker")))
105            .filter(|p| p.exists())
106            .or_else(|| {
107                let p = std::path::Path::new("dist/icebox-worker");
108                p.exists().then(|| p.to_path_buf())
109            });
110        let Some(worker) = worker else {
111            return Err(SandboxError::Unavailable);
112        };
113        let dir = std::env::temp_dir().join("icebox-sandbox-build");
114        let _ = std::fs::create_dir_all(&dir);
115        let _ = std::fs::write(
116            dir.join("icebox-worker"),
117            std::fs::read(&worker).map_err(|e| SandboxError::Worker(e.to_string()))?,
118        );
119        let _ = std::fs::write(
120            dir.join("Dockerfile"),
121            "FROM alpine:3.20\nCOPY icebox-worker /usr/local/bin/icebox-worker\nRUN chmod +x /usr/local/bin/icebox-worker\n",
122        );
123        let ok = std::process::Command::new("docker")
124            .args(["build", "-t", "icebox-sandbox:latest", "."])
125            .current_dir(&dir)
126            .status()
127            .map(|s| s.success())
128            .unwrap_or(false);
129        if ok && docker.inspect_image(image).await.is_ok() {
130            return Ok(());
131        }
132        return Err(SandboxError::Unavailable);
133    }
134    let mut stream = docker.create_image(
135        Some(bollard::query_parameters::CreateImageOptions {
136            from_image: Some(image.to_string()),
137            ..Default::default()
138        }),
139        None,
140        None,
141    );
142    while stream.next().await.is_some() {}
143    Ok(())
144}
145
146impl DockerSandbox {
147    pub fn is_available() -> bool {
148        Docker::connect_with_local_defaults()
149            .and_then(|d| {
150                std::thread::spawn(move || {
151                    let rt = tokio::runtime::Runtime::new().map_err(|_| {
152                        bollard::errors::Error::DockerResponseServerError {
153                            status_code: 500,
154                            message: "runtime creation failed".into(),
155                        }
156                    })?;
157                    rt.block_on(d.ping())
158                })
159                .join()
160                .map_err(|_| {
161                    bollard::errors::Error::DockerResponseServerError {
162                        status_code: 500,
163                        message: "join failed".into(),
164                    }
165                })?
166            })
167            .is_ok()
168    }
169
170    pub async fn freeze(target: &str, image: &str) -> Result<Self, SandboxError> {
171        let docker =
172            Docker::connect_with_local_defaults().map_err(|_| SandboxError::Unavailable)?;
173        docker.ping().await.map_err(|_| SandboxError::Unavailable)?;
174
175        ensure_image(&docker, image).await?;
176
177        let name = format!("icebox-sandbox-{}", std::process::id());
178
179        let cmd = Some(vec!["sleep".to_string(), "3600".to_string()]);
180
181        let config = ContainerCreateBody {
182            image: Some(image.to_string()),
183            cmd,
184            hostname: Some("icebox-sandbox".to_string()),
185            ..Default::default()
186        };
187
188        let resp = docker
189            .create_container(
190                Some(CreateContainerOptions {
191                    name: Some(name.clone()),
192                    ..Default::default()
193                }),
194                config,
195            )
196            .await?;
197
198        docker
199            .start_container(&resp.id, None::<StartContainerOptions>)
200            .await?;
201
202        Ok(DockerSandbox {
203            docker,
204            container_id: resp.id,
205            target: target.to_string(),
206        })
207    }
208
209    pub fn container_id(&self) -> &str {
210        &self.container_id
211    }
212
213    pub fn target(&self) -> &str {
214        &self.target
215    }
216
217    pub async fn ip_address(&self) -> Result<String, SandboxError> {
218        let inspect = self
219            .docker
220            .inspect_container(&self.container_id, None)
221            .await?;
222        if let Some(ns) = inspect.network_settings {
223            if let Some(networks) = ns.networks {
224                if let Some(bridge) = networks.get("bridge") {
225                    if let Some(ip) = bridge.ip_address.as_deref() {
226                        if !ip.is_empty() {
227                            return Ok(ip.to_string());
228                        }
229                    }
230                }
231            }
232        }
233        Err(SandboxError::Unavailable)
234    }
235
236    pub async fn capture_logs(&self) -> Vec<String> {
237        let opts = LogsOptions {
238            stdout: true,
239            stderr: true,
240            ..Default::default()
241        };
242        let mut stream = self.docker.logs(&self.container_id, Some(opts));
243        let mut lines = Vec::new();
244        while let Some(Ok(chunk)) = stream.next().await {
245            let text = match chunk {
246                LogOutput::StdOut { message } => String::from_utf8_lossy(&message).to_string(),
247                LogOutput::StdErr { message } => String::from_utf8_lossy(&message).to_string(),
248                _ => continue,
249            };
250            for line in text.lines() {
251                if !line.is_empty() {
252                    lines.push(format!("[SANDBOX-DOCKER] {line}"));
253                }
254            }
255        }
256        lines
257    }
258
259    pub async fn melt(self) -> Result<(), SandboxError> {
260        self.docker
261            .remove_container(
262                &self.container_id,
263                Some(RemoveContainerOptions {
264                    force: true,
265                    ..Default::default()
266                }),
267            )
268            .await?;
269        Ok(())
270    }
271
272    pub async fn exec_module(
273        &self,
274        name: &str,
275        target: &str,
276        options: &serde_json::Value,
277    ) -> Result<ModuleResult, SandboxError> {
278        let opts = serde_json::to_string(options).unwrap_or_else(|_| "{}".into());
279        let cmd = vec![
280            "icebox-worker".to_string(),
281            "worker".to_string(),
282            "--module".to_string(),
283            name.to_string(),
284            "--target".to_string(),
285            target.to_string(),
286            "--options".to_string(),
287            opts,
288        ];
289        let create = self
290            .docker
291            .create_exec(
292                &self.container_id,
293                CreateExecOptions {
294                    cmd: Some(cmd),
295                    attach_stdout: Some(true),
296                    attach_stderr: Some(true),
297                    ..Default::default()
298                },
299            )
300            .await?;
301        let id = create.id;
302        let mut stdout: Vec<u8> = Vec::new();
303        let stream = self
304            .docker
305            .start_exec(&id, None::<StartExecOptions>)
306            .await?;
307        if let StartExecResults::Attached { mut output, .. } = stream {
308            while let Some(chunk) = output.next().await {
309                match chunk {
310                    Ok(LogOutput::StdOut { message }) => stdout.extend_from_slice(&message),
311                    Ok(LogOutput::StdErr { message }) => {
312                        tracing::info!("[SANDBOX-WORKER] {}", String::from_utf8_lossy(&message));
313                    }
314                    _ => {}
315                }
316            }
317        }
318        let inspect = self.docker.inspect_exec(&id).await?;
319        if inspect.exit_code.unwrap_or(1) != 0 {
320            return Err(SandboxError::Worker(format!(
321                "worker exited with code {}",
322                inspect.exit_code.unwrap_or(-1)
323            )));
324        }
325        serde_json::from_slice::<ModuleResult>(&stdout)
326            .map_err(|e| SandboxError::Worker(format!("failed to parse worker output: {e}")))
327    }
328}