adk_code/rust_sandbox.rs
1//! Rust sandbox executor — the flagship Rust-authored code execution backend.
2//!
3//! [`RustSandboxExecutor`] compiles and executes authored Rust in an isolated
4//! environment using a host-local process approach (phase 1). It wraps user code
5//! in a harness that injects JSON input via stdin and captures JSON output from
6//! stdout.
7//!
8//! # Phase 1 Rust Source Model
9//!
10//! Phase 1 supports **self-contained Rust snippets** compiled into a controlled
11//! harness. This is intentionally bounded — the executor does not support
12//! arbitrary Cargo workspaces, multi-file projects, or external dependency
13//! resolution.
14//!
15//! ## The `run()` Contract
16//!
17//! User code must provide exactly one entry point:
18//!
19//! ```rust,ignore
20//! fn run(input: serde_json::Value) -> serde_json::Value
21//! ```
22//!
23//! The harness wraps this function with a generated `fn main()` that:
24//!
25//! 1. Reads JSON input from stdin
26//! 2. Calls the user's `run()` function
27//! 3. Writes JSON output to stdout as the last line
28//!
29//! ## Available Imports and Dependencies
30//!
31//! The harness automatically provides:
32//!
33//! | Dependency | Version | Notes |
34//! |----------------|---------|-------|
35//! | `serde_json` | workspace-pinned | Re-exported as `serde_json::Value`, `serde_json::json!`, etc. |
36//!
37//! The harness injects `use serde_json::Value;` at the top. User code can
38//! reference any public item from `serde_json` (e.g., `serde_json::json!`,
39//! `serde_json::Map`, `serde_json::Number`).
40//!
41//! No other external crates are available. The Rust standard library is fully
42//! available (`std::collections`, `std::fmt`, etc.).
43//!
44//! ## What Is NOT Supported (Phase 1)
45//!
46//! - **`fn main()`**: The harness provides `main()`. User code that defines its
47//! own `fn main()` will be rejected with an [`ExecutionError::InvalidRequest`].
48//! - **`Cargo.toml`**: There is no Cargo project. Compilation uses `rustc` directly.
49//! - **External crates**: Only `serde_json` is linked. `use some_other_crate::*`
50//! will produce a compile error.
51//! - **Multi-file projects**: The source model is a single code string. No `mod`
52//! declarations referencing external files.
53//! - **Procedural macros or build scripts**: Not available.
54//! - **`#![...]` crate-level attributes**: Not supported in the harness body.
55//!
56//! ## Phase 1 Isolation Model
57//!
58//! Phase 1 uses host-local process execution via `rustc`. The backend is honest
59//! about its capabilities:
60//!
61//! - **Timeout enforcement**: Yes (via `tokio::time::timeout`)
62//! - **Output truncation**: Yes (configurable limits)
63//! - **Network restriction**: No (host-local cannot enforce this)
64//! - **Filesystem restriction**: No (host-local cannot enforce this)
65//! - **Environment restriction**: No (host-local cannot enforce this)
66//!
67//! ## Example
68//!
69//! ```rust,no_run
70//! # async fn example() -> Result<(), adk_code::ExecutionError> {
71//! use adk_code::{
72//! CodeExecutor, ExecutionLanguage, ExecutionPayload, ExecutionRequest,
73//! ExecutionStatus, SandboxPolicy, RustSandboxExecutor,
74//! };
75//!
76//! let executor = RustSandboxExecutor::default();
77//! let request = ExecutionRequest {
78//! language: ExecutionLanguage::Rust,
79//! payload: ExecutionPayload::Source {
80//! code: r#"
81//! fn run(input: serde_json::Value) -> serde_json::Value {
82//! let v = input["value"].as_i64().unwrap_or(0);
83//! serde_json::json!({ "doubled": v * 2 })
84//! }
85//! "#.to_string(),
86//! },
87//! argv: vec![],
88//! stdin: None,
89//! input: Some(serde_json::json!({ "value": 21 })),
90//! sandbox: SandboxPolicy::default(),
91//! identity: None,
92//! };
93//!
94//! let result = executor.execute(request).await?;
95//! assert_eq!(result.status, ExecutionStatus::Success);
96//! assert_eq!(result.output, Some(serde_json::json!({ "doubled": 42 })));
97//! # Ok(())
98//! # }
99//! ```
100
101use std::path::PathBuf;
102use std::time::Instant;
103
104use async_trait::async_trait;
105use tokio::io::AsyncWriteExt;
106use tracing::{debug, info, instrument, warn};
107
108use crate::harness::{
109 HARNESS_TEMPLATE, extract_structured_output, truncate_output, validate_rust_source,
110};
111use crate::{
112 BackendCapabilities, CodeExecutor, ExecutionError, ExecutionIsolation, ExecutionLanguage,
113 ExecutionPayload, ExecutionRequest, ExecutionResult, ExecutionStatus, validate_request,
114};
115
116/// Configuration for the Rust sandbox executor.
117///
118/// # Example
119///
120/// ```rust
121/// use adk_code::RustSandboxConfig;
122///
123/// let config = RustSandboxConfig::default();
124/// assert_eq!(config.rustc_path, "rustc");
125/// ```
126#[derive(Debug, Clone)]
127pub struct RustSandboxConfig {
128 /// Path to the `rustc` compiler binary.
129 pub rustc_path: String,
130 /// Extra flags passed to `rustc` during compilation.
131 pub rustc_flags: Vec<String>,
132 /// Path to the `serde_json` rlib or directory containing it.
133 /// If `None`, the executor will attempt to locate it automatically.
134 pub serde_json_path: Option<PathBuf>,
135}
136
137impl Default for RustSandboxConfig {
138 fn default() -> Self {
139 Self { rustc_path: "rustc".to_string(), rustc_flags: vec![], serde_json_path: None }
140 }
141}
142
143/// The flagship Rust-authored code execution backend.
144///
145/// Compiles and executes authored Rust using a host-local process approach.
146/// Phase 1 is honest about isolation: it can enforce timeouts and output
147/// truncation, but cannot enforce network or filesystem restrictions.
148///
149/// # Backend Capability Reporting
150///
151/// `RustSandboxExecutor` reports its capabilities truthfully through
152/// [`BackendCapabilities`]. The phase 1 implementation uses host-local
153/// process execution (`rustc` + spawned binary), so:
154///
155/// | Capability | Enforced | Reason |
156/// |---|---|---|
157/// | Isolation class | `HostLocal` | Runs as a local process, not in a container |
158/// | Network policy | No | Host-local processes inherit host network access |
159/// | Filesystem policy | No | Host-local processes inherit host filesystem access |
160/// | Environment policy | No | Host-local processes inherit host environment |
161/// | Timeout | Yes | Enforced via `tokio::time::timeout` |
162/// | Structured output | Yes | Harness extracts JSON from last stdout line |
163/// | Process execution | No | User code cannot spawn child processes through the harness |
164/// | Persistent workspace | No | Each execution uses a fresh temp directory |
165/// | Interactive sessions | No | Single-shot execution only |
166///
167/// Callers should use [`validate_policy`] to check whether a requested
168/// [`SandboxPolicy`] is compatible with these capabilities before execution.
169/// If a policy requests a control the backend cannot enforce (e.g., disabled
170/// network), validation fails with [`ExecutionError::UnsupportedPolicy`].
171///
172/// # Example
173///
174/// ```rust
175/// use adk_code::{CodeExecutor, RustSandboxExecutor, ExecutionIsolation};
176///
177/// let executor = RustSandboxExecutor::default();
178/// assert_eq!(executor.name(), "rust-sandbox");
179/// assert_eq!(executor.capabilities().isolation, ExecutionIsolation::HostLocal);
180/// assert!(executor.capabilities().enforce_timeout);
181/// assert!(!executor.capabilities().enforce_network_policy);
182/// ```
183#[derive(Debug, Clone)]
184pub struct RustSandboxExecutor {
185 config: RustSandboxConfig,
186}
187
188impl RustSandboxExecutor {
189 /// Create a new executor with the given configuration.
190 pub fn new(config: RustSandboxConfig) -> Self {
191 Self { config }
192 }
193}
194
195impl Default for RustSandboxExecutor {
196 fn default() -> Self {
197 Self::new(RustSandboxConfig::default())
198 }
199}
200
201#[async_trait]
202impl CodeExecutor for RustSandboxExecutor {
203 fn name(&self) -> &str {
204 "rust-sandbox"
205 }
206
207 fn capabilities(&self) -> BackendCapabilities {
208 BackendCapabilities {
209 isolation: ExecutionIsolation::HostLocal,
210 enforce_network_policy: false,
211 enforce_filesystem_policy: false,
212 enforce_environment_policy: false,
213 enforce_timeout: true,
214 supports_structured_output: true,
215 supports_process_execution: false,
216 supports_persistent_workspace: false,
217 supports_interactive_sessions: false,
218 }
219 }
220
221 fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
222 matches!(lang, ExecutionLanguage::Rust)
223 }
224
225 #[instrument(skip_all, fields(backend = "rust-sandbox", language = "Rust"))]
226 async fn execute(&self, request: ExecutionRequest) -> Result<ExecutionResult, ExecutionError> {
227 // Validate the request against our capabilities.
228 validate_request(&self.capabilities(), &[ExecutionLanguage::Rust], &request)?;
229
230 let code = match &request.payload {
231 ExecutionPayload::Source { code } => code.clone(),
232 ExecutionPayload::GuestModule { .. } => {
233 return Err(ExecutionError::InvalidRequest(
234 "RustSandboxExecutor only accepts Source payloads".to_string(),
235 ));
236 }
237 };
238
239 // Validate that the source fits the phase 1 bounded model.
240 validate_rust_source(&code)?;
241
242 let start = Instant::now();
243
244 // Create a temp directory for compilation artifacts.
245 let tmp_dir = tempfile::tempdir().map_err(|e| {
246 ExecutionError::ExecutionFailed(format!("failed to create temp directory: {e}"))
247 })?;
248
249 let source_path = tmp_dir.path().join("main.rs");
250 let binary_path = tmp_dir.path().join("main");
251
252 // Write the harnessed source file.
253 let harnessed_source = HARNESS_TEMPLATE.replace("{user_code}", &code);
254 tokio::fs::write(&source_path, &harnessed_source).await.map_err(|e| {
255 ExecutionError::ExecutionFailed(format!("failed to write source file: {e}"))
256 })?;
257
258 debug!(source_path = %source_path.display(), "wrote harnessed source");
259
260 // ── Compilation ────────────────────────────────────────────────
261 let compile_result = self.compile(&source_path, &binary_path, &request).await?;
262 if let Some(result) = compile_result {
263 // Compilation failed — return the compile failure result.
264 return Ok(result);
265 }
266
267 info!("compilation succeeded, executing binary");
268
269 // ── Execution ──────────────────────────────────────────────────
270 let result = self.run_binary(&binary_path, &request, start).await;
271
272 // Clean up temp dir (best-effort, tempfile handles this on drop too).
273 drop(tmp_dir);
274
275 result
276 }
277}
278
279impl RustSandboxExecutor {
280 /// Compile the source file. Returns `Ok(Some(result))` if compilation failed
281 /// (with a `CompileFailed` result), `Ok(None)` if compilation succeeded,
282 /// or `Err` for infrastructure failures.
283 async fn compile(
284 &self,
285 source_path: &std::path::Path,
286 binary_path: &std::path::Path,
287 request: &ExecutionRequest,
288 ) -> Result<Option<ExecutionResult>, ExecutionError> {
289 let serde_json_dep = self.find_serde_json_dep().await?;
290
291 let mut cmd = tokio::process::Command::new(&self.config.rustc_path);
292 cmd.arg(source_path).arg("-o").arg(binary_path).arg("--edition").arg("2021");
293
294 // Link against serde_json.
295 if let Some(dep_path) = &serde_json_dep {
296 cmd.arg("--extern").arg(format!("serde_json={}", dep_path.display()));
297
298 // Add the parent directory to the library search path so transitive
299 // deps (serde, itoa, ryu, memchr, etc.) can be found.
300 if let Some(parent) = dep_path.parent() {
301 cmd.arg("-L").arg(format!("dependency={}", parent.display()));
302 }
303 }
304
305 // Add any extra flags from config.
306 for flag in &self.config.rustc_flags {
307 cmd.arg(flag);
308 }
309
310 // Capture stdout and stderr.
311 cmd.stdout(std::process::Stdio::piped());
312 cmd.stderr(std::process::Stdio::piped());
313
314 let compile_timeout = request.sandbox.timeout;
315 let compile_output = match tokio::time::timeout(compile_timeout, cmd.output()).await {
316 Ok(Ok(output)) => output,
317 Ok(Err(e)) => {
318 return Err(ExecutionError::CompileFailed(format!("failed to invoke rustc: {e}")));
319 }
320 Err(_) => {
321 return Ok(Some(ExecutionResult {
322 status: ExecutionStatus::Timeout,
323 stdout: String::new(),
324 stderr: "compilation timed out".to_string(),
325 output: None,
326 exit_code: None,
327 stdout_truncated: false,
328 stderr_truncated: false,
329 duration_ms: compile_timeout.as_millis() as u64,
330 metadata: None,
331 }));
332 }
333 };
334
335 if !compile_output.status.success() {
336 let stderr = String::from_utf8_lossy(&compile_output.stderr).to_string();
337 let (stderr, stderr_truncated) =
338 truncate_output(stderr, request.sandbox.max_stderr_bytes);
339
340 debug!(exit_code = compile_output.status.code(), "compilation failed");
341
342 return Ok(Some(ExecutionResult {
343 status: ExecutionStatus::CompileFailed,
344 stdout: String::new(),
345 stderr,
346 output: None,
347 exit_code: compile_output.status.code(),
348 stdout_truncated: false,
349 stderr_truncated,
350 duration_ms: 0, // Will be set by caller if needed.
351 metadata: None,
352 }));
353 }
354
355 Ok(None)
356 }
357
358 /// Run the compiled binary with timeout enforcement and output capture.
359 async fn run_binary(
360 &self,
361 binary_path: &std::path::Path,
362 request: &ExecutionRequest,
363 start: Instant,
364 ) -> Result<ExecutionResult, ExecutionError> {
365 let mut cmd = tokio::process::Command::new(binary_path);
366
367 // Pass argv to the binary.
368 for arg in &request.argv {
369 cmd.arg(arg);
370 }
371
372 cmd.stdin(std::process::Stdio::piped());
373 cmd.stdout(std::process::Stdio::piped());
374 cmd.stderr(std::process::Stdio::piped());
375 // Kill the child when the handle is dropped (important for timeout).
376 cmd.kill_on_drop(true);
377
378 let mut child = cmd
379 .spawn()
380 .map_err(|e| ExecutionError::ExecutionFailed(format!("failed to spawn binary: {e}")))?;
381
382 // Write structured input as JSON to stdin, then close it.
383 if let Some(ref input) = request.input {
384 if let Some(mut stdin) = child.stdin.take() {
385 let json_bytes = serde_json::to_vec(input).unwrap_or_default();
386 let _ = stdin.write_all(&json_bytes).await;
387 drop(stdin);
388 }
389 } else if let Some(ref raw_stdin) = request.stdin {
390 if let Some(mut stdin) = child.stdin.take() {
391 let _ = stdin.write_all(raw_stdin).await;
392 drop(stdin);
393 }
394 } else {
395 // Close stdin immediately so the child doesn't block reading.
396 drop(child.stdin.take());
397 }
398
399 // Wait with timeout. `wait_with_output` consumes `child`, so on
400 // timeout we rely on `kill_on_drop` to clean up the process.
401 let output =
402 match tokio::time::timeout(request.sandbox.timeout, child.wait_with_output()).await {
403 Ok(Ok(output)) => output,
404 Ok(Err(e)) => {
405 return Err(ExecutionError::ExecutionFailed(format!(
406 "failed to wait for binary: {e}"
407 )));
408 }
409 Err(_) => {
410 // Timeout — `kill_on_drop` will clean up the child process.
411 warn!("execution timed out");
412 let duration_ms = start.elapsed().as_millis() as u64;
413 return Ok(ExecutionResult {
414 status: ExecutionStatus::Timeout,
415 stdout: String::new(),
416 stderr: String::new(),
417 output: None,
418 exit_code: None,
419 stdout_truncated: false,
420 stderr_truncated: false,
421 duration_ms,
422 metadata: None,
423 });
424 }
425 };
426
427 let duration_ms = start.elapsed().as_millis() as u64;
428
429 let raw_stdout = String::from_utf8_lossy(&output.stdout).to_string();
430 let raw_stderr = String::from_utf8_lossy(&output.stderr).to_string();
431
432 let (stdout, stdout_truncated) =
433 truncate_output(raw_stdout, request.sandbox.max_stdout_bytes);
434 let (stderr, stderr_truncated) =
435 truncate_output(raw_stderr, request.sandbox.max_stderr_bytes);
436
437 // Try to parse the last line of stdout as structured JSON output.
438 // The harness prints the JSON output as the last line.
439 let (structured_output, display_stdout) = extract_structured_output(&stdout);
440
441 let status = if output.status.success() {
442 ExecutionStatus::Success
443 } else {
444 ExecutionStatus::Failed
445 };
446
447 debug!(
448 exit_code = output.status.code(),
449 duration_ms,
450 has_structured_output = structured_output.is_some(),
451 "execution completed"
452 );
453
454 Ok(ExecutionResult {
455 status,
456 stdout: display_stdout,
457 stderr,
458 output: structured_output,
459 exit_code: output.status.code(),
460 stdout_truncated,
461 stderr_truncated,
462 duration_ms,
463 metadata: None,
464 })
465 }
466
467 /// Locate the `serde_json` rlib for linking.
468 ///
469 /// If `serde_json_path` is configured, use that. Otherwise, try to find it
470 /// by querying cargo for the serde_json package location.
471 async fn find_serde_json_dep(&self) -> Result<Option<PathBuf>, ExecutionError> {
472 if let Some(ref path) = self.config.serde_json_path {
473 if path.exists() {
474 return Ok(Some(path.clone()));
475 }
476 return Err(ExecutionError::ExecutionFailed(format!(
477 "configured serde_json path does not exist: {}",
478 path.display()
479 )));
480 }
481
482 // Try to find serde_json rlib in the cargo target directory.
483 // We look for it in the workspace's target/debug/deps directory.
484 let output = tokio::process::Command::new("cargo")
485 .args(["metadata", "--format-version=1", "--no-deps"])
486 .stdout(std::process::Stdio::piped())
487 .stderr(std::process::Stdio::null())
488 .output()
489 .await;
490
491 if let Ok(output) = output {
492 if output.status.success() {
493 if let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&output.stdout) {
494 if let Some(target_dir) = metadata["target_directory"].as_str() {
495 let deps_dir = PathBuf::from(target_dir).join("debug").join("deps");
496 if let Some(rlib) = find_rlib_in_dir(&deps_dir, "serde_json").await {
497 return Ok(Some(rlib));
498 }
499 }
500 }
501 }
502 }
503
504 // Fallback: return None and let rustc try to find it on its own.
505 // This will likely fail, but the compile error will be descriptive.
506 Ok(None)
507 }
508}
509
510/// Find an rlib file matching the given crate name in a directory.
511async fn find_rlib_in_dir(dir: &std::path::Path, crate_name: &str) -> Option<PathBuf> {
512 let prefix = format!("lib{crate_name}-");
513 let mut entries = match tokio::fs::read_dir(dir).await {
514 Ok(entries) => entries,
515 Err(_) => return None,
516 };
517
518 while let Ok(Some(entry)) = entries.next_entry().await {
519 let name = entry.file_name();
520 let name_str = name.to_string_lossy();
521 if name_str.starts_with(&prefix) && name_str.ends_with(".rlib") {
522 return Some(entry.path());
523 }
524 }
525 None
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[test]
533 fn capabilities_are_honest() {
534 let executor = RustSandboxExecutor::default();
535 let caps = executor.capabilities();
536 assert_eq!(caps.isolation, ExecutionIsolation::HostLocal);
537 assert!(caps.enforce_timeout);
538 assert!(caps.supports_structured_output);
539 assert!(!caps.enforce_network_policy);
540 assert!(!caps.enforce_filesystem_policy);
541 assert!(!caps.enforce_environment_policy);
542 }
543
544 #[test]
545 fn supports_only_rust() {
546 let executor = RustSandboxExecutor::default();
547 assert!(executor.supports_language(&ExecutionLanguage::Rust));
548 assert!(!executor.supports_language(&ExecutionLanguage::JavaScript));
549 assert!(!executor.supports_language(&ExecutionLanguage::Python));
550 assert!(!executor.supports_language(&ExecutionLanguage::Wasm));
551 assert!(!executor.supports_language(&ExecutionLanguage::Command));
552 }
553
554 #[test]
555 fn default_config() {
556 let config = RustSandboxConfig::default();
557 assert_eq!(config.rustc_path, "rustc");
558 assert!(config.rustc_flags.is_empty());
559 assert!(config.serde_json_path.is_none());
560 }
561}