use std::collections::HashMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use adk_sandbox::{
ExecRequest, ExecResult, Language, ProcessBackend, SandboxBackend, SandboxError,
};
use tracing::{debug, info, instrument};
use crate::diagnostics::{RustDiagnostic, parse_diagnostics};
use crate::error::CodeError;
use crate::harness::{HARNESS_TEMPLATE, extract_structured_output, validate_rust_source};
use crate::host_process;
#[derive(Debug, Clone)]
pub struct RustExecutorConfig {
pub rustc_path: String,
pub serde_json_path: Option<PathBuf>,
pub rustc_flags: Vec<String>,
}
impl Default for RustExecutorConfig {
fn default() -> Self {
Self { rustc_path: "rustc".to_string(), serde_json_path: None, rustc_flags: vec![] }
}
}
struct RustcOutput {
success: bool,
stderr: String,
}
#[derive(Debug, Clone)]
pub struct CodeResult {
pub exec_result: ExecResult,
pub diagnostics: Vec<RustDiagnostic>,
pub output: Option<serde_json::Value>,
pub display_stdout: String,
}
pub struct RustExecutor {
backend: Arc<dyn SandboxBackend>,
config: RustExecutorConfig,
}
impl RustExecutor {
pub fn new(backend: Arc<dyn SandboxBackend>, config: RustExecutorConfig) -> Self {
Self { backend, config }
}
#[instrument(skip_all, fields(backend = %self.backend.name()))]
pub async fn execute(
&self,
code: &str,
input: Option<&serde_json::Value>,
timeout: Duration,
) -> Result<CodeResult, CodeError> {
validate_rust_source(code).map_err(|e| CodeError::InvalidCode(e.to_string()))?;
let tmp_dir = tempfile::tempdir()
.map_err(|e| CodeError::InvalidCode(format!("failed to create temp directory: {e}")))?;
let source_path = tmp_dir.path().join("main.rs");
let binary_path = tmp_dir.path().join(format!("main{}", std::env::consts::EXE_SUFFIX));
let harnessed_source = HARNESS_TEMPLATE.replace("{user_code}", code);
tokio::fs::write(&source_path, &harnessed_source)
.await
.map_err(|e| CodeError::InvalidCode(format!("failed to write source file: {e}")))?;
debug!(source_path = %source_path.display(), "wrote harnessed source");
let serde_json_path = self.find_serde_json_dep().await?;
let diagnostics = self.check(&source_path, &serde_json_path, timeout).await?;
self.build(&source_path, &binary_path, &serde_json_path, timeout).await?;
info!("compilation succeeded, delegating execution to sandbox backend");
let stdin = input.map(|v| serde_json::to_string(v).unwrap_or_default());
let exec_request = ExecRequest {
language: Language::Command,
code: binary_path.to_string_lossy().to_string(),
stdin,
timeout,
memory_limit_mb: None,
env: HashMap::new(),
};
let exec_result = self.backend.execute(exec_request).await?;
let (output, display_stdout) = extract_structured_output(&exec_result.stdout);
debug!(
exit_code = exec_result.exit_code,
duration_ms = exec_result.duration.as_millis() as u64,
has_output = output.is_some(),
"execution completed"
);
Ok(CodeResult { exec_result, diagnostics, output, display_stdout })
}
async fn check(
&self,
source_path: &Path,
serde_json_path: &Option<PathBuf>,
timeout: Duration,
) -> Result<Vec<RustDiagnostic>, CodeError> {
let check_dir = tempfile::tempdir().map_err(|e| {
CodeError::InvalidCode(format!("failed to create check temp directory: {e}"))
})?;
let metadata_out = check_dir.path().join("check_output");
let mut args: Vec<OsString> = vec![
source_path.into(),
"--edition".into(),
"2021".into(),
"--error-format=json".into(),
"--color".into(),
"never".into(),
"--emit=metadata".into(),
"-o".into(),
metadata_out.into(),
];
args.extend(self.dependency_args(serde_json_path));
let RustcOutput { stderr, .. } = self.run_rustc(args, timeout, "check").await?;
let diagnostics = parse_diagnostics(&stderr);
let has_errors = diagnostics.iter().any(|d| d.level == "error");
if has_errors {
debug!(
error_count = diagnostics.iter().filter(|d| d.level == "error").count(),
"check step found errors"
);
return Err(CodeError::CompileError { diagnostics, stderr });
}
let warning_count = diagnostics.iter().filter(|d| d.level == "warning").count();
if warning_count > 0 {
debug!(warning_count, "check step found warnings");
}
Ok(diagnostics)
}
async fn build(
&self,
source_path: &Path,
binary_path: &Path,
serde_json_path: &Option<PathBuf>,
timeout: Duration,
) -> Result<(), CodeError> {
let mut args: Vec<OsString> = vec![
source_path.into(),
"-o".into(),
binary_path.into(),
"--edition".into(),
"2021".into(),
];
args.extend(self.dependency_args(serde_json_path));
let output = self.run_rustc(args, timeout, "build").await?;
if !output.success {
let diagnostics = parse_diagnostics(&output.stderr);
return Err(CodeError::CompileError { diagnostics, stderr: output.stderr });
}
Ok(())
}
fn dependency_args(&self, serde_json_path: &Option<PathBuf>) -> Vec<OsString> {
let mut args = Vec::new();
if let Some(dep_path) = serde_json_path {
args.push("--extern".into());
args.push(format!("serde_json={}", dep_path.display()).into());
if let Some(parent) = dep_path.parent() {
args.push("-L".into());
args.push(format!("dependency={}", parent.display()).into());
}
}
args.extend(self.config.rustc_flags.iter().map(OsString::from));
args
}
async fn run_rustc(
&self,
args: Vec<OsString>,
timeout: Duration,
step: &str,
) -> Result<RustcOutput, CodeError> {
let capabilities = self.backend.capabilities();
let compile_in_backend = cfg!(unix)
&& capabilities.enforced_limits.filesystem_read_isolation
&& capabilities.supported_languages.contains(&Language::Command);
if compile_in_backend {
let code = std::iter::once(OsString::from(&self.config.rustc_path))
.chain(args)
.map(|arg| format!("'{}'", arg.to_string_lossy().replace('\'', r"'\''")))
.collect::<Vec<_>>()
.join(" ");
let env = ProcessBackend::toolchain_env()
.into_iter()
.filter_map(|(key, value)| value.into_string().ok().map(|value| (key, value)))
.collect();
debug!(step, "running rustc inside the sandbox backend");
let request = ExecRequest {
language: Language::Command,
code,
stdin: None,
timeout,
memory_limit_mb: None,
env,
};
return match self.backend.execute(request).await {
Ok(result) => {
Ok(RustcOutput { success: result.exit_code == 0, stderr: result.stderr })
}
Err(SandboxError::Timeout { .. }) => {
Err(CodeError::InvalidCode(format!("{step} step timed out")))
}
Err(error) => Err(error.into()),
};
}
let mut cmd = host_process::rustc_command(&self.config.rustc_path);
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
match tokio::time::timeout(timeout, cmd.output()).await {
Ok(Ok(output)) => Ok(RustcOutput {
success: output.status.success(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}),
Ok(Err(e)) => {
Err(CodeError::InvalidCode(format!("failed to invoke rustc for {step}: {e}")))
}
Err(_) => Err(CodeError::InvalidCode(format!("{step} step timed out"))),
}
}
async fn find_serde_json_dep(&self) -> Result<Option<PathBuf>, CodeError> {
let mut searched = Vec::new();
if let Some(ref path) = self.config.serde_json_path {
if path.exists() {
debug!(path = %path.display(), "using configured serde_json path");
return Ok(Some(path.clone()));
}
searched.push(format!("config: {}", path.display()));
}
let output = tokio::process::Command::new("cargo")
.args(["metadata", "--format-version=1", "--no-deps"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await;
if let Ok(output) = output
&& output.status.success()
&& let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&output.stdout)
&& let Some(target_dir) = metadata["target_directory"].as_str()
{
let deps_dir = PathBuf::from(target_dir).join("debug").join("deps");
searched.push(format!("cargo metadata: {}", deps_dir.display()));
if let Some(rlib) = find_rlib_in_dir(&deps_dir, "serde_json").await {
debug!(path = %rlib.display(), "found serde_json via cargo metadata");
return Ok(Some(rlib));
}
} else {
searched.push("cargo metadata: command failed".to_string());
}
Err(CodeError::DependencyNotFound { name: "serde_json".to_string(), searched })
}
}
async fn find_rlib_in_dir(dir: &Path, crate_name: &str) -> Option<PathBuf> {
let prefix = format!("lib{crate_name}-");
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(_) => return None,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(&prefix) && name_str.ends_with(".rlib") {
return Some(entry.path());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use adk_sandbox::{BackendCapabilities, EnforcedLimits, SandboxError};
use async_trait::async_trait;
use std::sync::Mutex;
struct MockBackend {
captured: Mutex<Vec<ExecRequest>>,
response: Mutex<Option<Result<ExecResult, SandboxError>>>,
}
impl MockBackend {
fn new(response: Result<ExecResult, SandboxError>) -> Self {
Self { captured: Mutex::new(Vec::new()), response: Mutex::new(Some(response)) }
}
fn success(stdout: &str) -> Self {
Self::new(Ok(ExecResult {
stdout: stdout.to_string(),
stderr: String::new(),
exit_code: 0,
duration: Duration::from_millis(10),
}))
}
}
#[async_trait]
impl SandboxBackend for MockBackend {
fn name(&self) -> &str {
"mock"
}
fn capabilities(&self) -> BackendCapabilities {
BackendCapabilities {
supported_languages: vec![Language::Command],
isolation_class: "mock".to_string(),
enforced_limits: EnforcedLimits {
timeout: true,
memory: false,
network_isolation: false,
filesystem_write_isolation: false,
filesystem_read_isolation: false,
environment_isolation: false,
},
}
}
async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
self.captured.lock().unwrap().push(request);
self.response
.lock()
.unwrap()
.take()
.unwrap_or(Err(SandboxError::ExecutionFailed("no canned response".to_string())))
}
}
struct ReadConfiningBackend {
captured: Mutex<Vec<ExecRequest>>,
}
#[async_trait]
impl SandboxBackend for ReadConfiningBackend {
fn name(&self) -> &str {
"read-confining"
}
fn capabilities(&self) -> BackendCapabilities {
BackendCapabilities {
supported_languages: vec![Language::Command],
isolation_class: "mock".to_string(),
enforced_limits: EnforcedLimits {
timeout: true,
memory: false,
network_isolation: true,
filesystem_write_isolation: true,
filesystem_read_isolation: true,
environment_isolation: true,
},
}
}
async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
self.captured.lock().unwrap().push(request);
Ok(ExecResult {
stdout: r#"{"ok":true}"#.to_string(),
stderr: String::new(),
exit_code: 0,
duration: Duration::from_millis(1),
})
}
}
#[cfg(unix)]
#[tokio::test]
async fn compilation_runs_inside_a_read_confining_backend() {
let tmp_dir = tempfile::tempdir().unwrap();
let fake_rlib = tmp_dir.path().join("libserde_json-abc123.rlib");
tokio::fs::write(&fake_rlib, b"fake rlib").await.unwrap();
let backend = Arc::new(ReadConfiningBackend { captured: Mutex::new(Vec::new()) });
let config = RustExecutorConfig {
rustc_path: "/nonexistent/rustc-must-not-run-on-host".to_string(),
serde_json_path: Some(fake_rlib),
..Default::default()
};
let executor = RustExecutor::new(backend.clone(), config);
let code = "fn run(input: serde_json::Value) -> serde_json::Value { input }";
let result = executor.execute(code, None, Duration::from_secs(5)).await.unwrap();
assert_eq!(result.output, Some(serde_json::json!({ "ok": true })));
let captured = backend.captured.lock().unwrap();
assert_eq!(captured.len(), 3, "check, build, and execute all go through the backend");
let toolchain: Vec<String> =
ProcessBackend::toolchain_env().into_iter().map(|(key, _)| key).collect();
for (request, step_flag) in captured[..2].iter().zip(["'--error-format=json'", "'-o'"]) {
assert_eq!(request.language, Language::Command);
assert!(
request.code.starts_with("'/nonexistent/rustc-must-not-run-on-host' "),
"{}",
request.code
);
assert!(request.code.contains(step_flag), "{}", request.code);
assert!(
request.env.keys().all(|key| toolchain.contains(key)),
"compile environment must be the toolchain allowlist: {:?}",
request.env.keys()
);
}
assert!(captured[2].env.is_empty());
}
#[test]
fn default_config() {
let config = RustExecutorConfig::default();
assert_eq!(config.rustc_path, "rustc");
assert!(config.serde_json_path.is_none());
assert!(config.rustc_flags.is_empty());
}
#[tokio::test]
async fn check_valid_code_produces_no_errors() {
let backend = Arc::new(MockBackend::success(r#"{"result":42}"#));
let executor = RustExecutor::new(backend, RustExecutorConfig::default());
let tmp_dir = tempfile::tempdir().unwrap();
let source_path = tmp_dir.path().join("valid.rs");
let code = r#"
fn run(input: serde_json::Value) -> serde_json::Value {
input
}
"#;
let harnessed = HARNESS_TEMPLATE.replace("{user_code}", code);
tokio::fs::write(&source_path, &harnessed).await.unwrap();
let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
match result {
Ok(diagnostics) => {
assert!(
!diagnostics.iter().any(|d| d.level == "error"),
"expected no error diagnostics for valid code"
);
}
Err(CodeError::CompileError { diagnostics, .. }) => {
assert!(
diagnostics.iter().any(|d| d.level == "error"),
"expected error diagnostics when serde_json is missing"
);
}
Err(other) => panic!("unexpected error: {other}"),
}
}
#[tokio::test]
async fn check_invalid_code_returns_compile_error() {
let backend = Arc::new(MockBackend::success(""));
let executor = RustExecutor::new(backend, RustExecutorConfig::default());
let tmp_dir = tempfile::tempdir().unwrap();
let source_path = tmp_dir.path().join("invalid.rs");
let code = "fn broken( { }";
tokio::fs::write(&source_path, code).await.unwrap();
let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
match result {
Err(CodeError::CompileError { diagnostics, stderr }) => {
assert!(
diagnostics.iter().any(|d| d.level == "error"),
"expected at least one error diagnostic"
);
assert!(!stderr.is_empty(), "expected non-empty stderr");
}
other => panic!("expected CompileError, got: {other:?}"),
}
}
#[tokio::test]
async fn check_warnings_are_returned_without_halting() {
let backend = Arc::new(MockBackend::success(""));
let executor = RustExecutor::new(backend, RustExecutorConfig::default());
let tmp_dir = tempfile::tempdir().unwrap();
let source_path = tmp_dir.path().join("warnings.rs");
let code = "fn main() { let x = 42; }";
tokio::fs::write(&source_path, code).await.unwrap();
let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
match result {
Ok(diagnostics) => {
assert!(
diagnostics.iter().any(|d| d.level == "warning"),
"expected at least one warning diagnostic, got: {diagnostics:?}"
);
}
Err(CodeError::CompileError { diagnostics, .. }) => {
panic!("unexpected compile errors: {diagnostics:?}");
}
Err(other) => panic!("unexpected error: {other}"),
}
}
#[tokio::test]
async fn serde_json_discovery_config_path_exists() {
let tmp_dir = tempfile::tempdir().unwrap();
let fake_rlib = tmp_dir.path().join("libserde_json-abc123.rlib");
tokio::fs::write(&fake_rlib, b"fake rlib").await.unwrap();
let config =
RustExecutorConfig { serde_json_path: Some(fake_rlib.clone()), ..Default::default() };
let backend = Arc::new(MockBackend::success(""));
let executor = RustExecutor::new(backend, config);
let result = executor.find_serde_json_dep().await.unwrap();
assert_eq!(result, Some(fake_rlib));
}
#[tokio::test]
async fn serde_json_discovery_config_path_missing() {
let config = RustExecutorConfig {
serde_json_path: Some(PathBuf::from("/nonexistent/libserde_json.rlib")),
..Default::default()
};
let backend = Arc::new(MockBackend::success(""));
let executor = RustExecutor::new(backend, config);
let result = executor.find_serde_json_dep().await;
match result {
Err(CodeError::DependencyNotFound { name, searched }) => {
assert_eq!(name, "serde_json");
assert!(
searched.iter().any(|s| s.contains("/nonexistent/")),
"expected searched to include the config path, got: {searched:?}"
);
}
Ok(Some(_)) => {}
other => panic!("unexpected result: {other:?}"),
}
}
#[test]
fn code_error_from_sandbox_error() {
let sandbox_err = SandboxError::Timeout { timeout: Duration::from_secs(5) };
let code_err: CodeError = sandbox_err.into();
assert!(matches!(code_err, CodeError::Sandbox(_)));
assert!(code_err.to_string().contains("sandbox error"));
}
#[test]
fn validate_rejects_fn_main() {
let code = "fn main() { }";
let result = validate_rust_source(code);
assert!(result.is_err());
}
#[test]
fn validate_accepts_valid_run() {
let code = r#"
fn run(input: serde_json::Value) -> serde_json::Value {
input
}
"#;
assert!(validate_rust_source(code).is_ok());
}
}