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}