1use std::collections::HashMap;
28use std::ffi::OsString;
29use std::path::{Path, PathBuf};
30use std::process::Stdio;
31use std::sync::Arc;
32use std::time::Duration;
33
34use adk_sandbox::{
35 ExecRequest, ExecResult, Language, ProcessBackend, SandboxBackend, SandboxError,
36};
37use tracing::{debug, info, instrument};
38
39use crate::diagnostics::{RustDiagnostic, parse_diagnostics};
40use crate::error::CodeError;
41use crate::harness::{HARNESS_TEMPLATE, extract_structured_output, validate_rust_source};
42use crate::host_process;
43
44#[derive(Debug, Clone)]
55pub struct RustExecutorConfig {
56 pub rustc_path: String,
58 pub serde_json_path: Option<PathBuf>,
61 pub rustc_flags: Vec<String>,
63}
64
65impl Default for RustExecutorConfig {
66 fn default() -> Self {
67 Self { rustc_path: "rustc".to_string(), serde_json_path: None, rustc_flags: vec![] }
68 }
69}
70
71struct RustcOutput {
73 success: bool,
74 stderr: String,
75}
76
77#[derive(Debug, Clone)]
82pub struct CodeResult {
83 pub exec_result: ExecResult,
85 pub diagnostics: Vec<RustDiagnostic>,
87 pub output: Option<serde_json::Value>,
89 pub display_stdout: String,
91}
92
93pub struct RustExecutor {
123 backend: Arc<dyn SandboxBackend>,
124 config: RustExecutorConfig,
125}
126
127impl RustExecutor {
128 pub fn new(backend: Arc<dyn SandboxBackend>, config: RustExecutorConfig) -> Self {
130 Self { backend, config }
131 }
132
133 #[instrument(skip_all, fields(backend = %self.backend.name()))]
145 pub async fn execute(
146 &self,
147 code: &str,
148 input: Option<&serde_json::Value>,
149 timeout: Duration,
150 ) -> Result<CodeResult, CodeError> {
151 validate_rust_source(code).map_err(|e| CodeError::InvalidCode(e.to_string()))?;
153
154 let tmp_dir = tempfile::tempdir()
156 .map_err(|e| CodeError::InvalidCode(format!("failed to create temp directory: {e}")))?;
157
158 let source_path = tmp_dir.path().join("main.rs");
159 let binary_path = tmp_dir.path().join(format!("main{}", std::env::consts::EXE_SUFFIX));
163
164 let harnessed_source = HARNESS_TEMPLATE.replace("{user_code}", code);
166 tokio::fs::write(&source_path, &harnessed_source)
167 .await
168 .map_err(|e| CodeError::InvalidCode(format!("failed to write source file: {e}")))?;
169
170 debug!(source_path = %source_path.display(), "wrote harnessed source");
171
172 let serde_json_path = self.find_serde_json_dep().await?;
174
175 let diagnostics = self.check(&source_path, &serde_json_path, timeout).await?;
177
178 self.build(&source_path, &binary_path, &serde_json_path, timeout).await?;
180
181 info!("compilation succeeded, delegating execution to sandbox backend");
182
183 let stdin = input.map(|v| serde_json::to_string(v).unwrap_or_default());
185 let exec_request = ExecRequest {
186 language: Language::Command,
187 code: binary_path.to_string_lossy().to_string(),
188 stdin,
189 timeout,
190 memory_limit_mb: None,
191 env: HashMap::new(),
192 };
193
194 let exec_result = self.backend.execute(exec_request).await?;
195
196 let (output, display_stdout) = extract_structured_output(&exec_result.stdout);
198
199 debug!(
200 exit_code = exec_result.exit_code,
201 duration_ms = exec_result.duration.as_millis() as u64,
202 has_output = output.is_some(),
203 "execution completed"
204 );
205
206 Ok(CodeResult { exec_result, diagnostics, output, display_stdout })
207 }
208
209 async fn check(
214 &self,
215 source_path: &Path,
216 serde_json_path: &Option<PathBuf>,
217 timeout: Duration,
218 ) -> Result<Vec<RustDiagnostic>, CodeError> {
219 let check_dir = tempfile::tempdir().map_err(|e| {
221 CodeError::InvalidCode(format!("failed to create check temp directory: {e}"))
222 })?;
223 let metadata_out = check_dir.path().join("check_output");
224
225 let mut args: Vec<OsString> = vec![
226 source_path.into(),
227 "--edition".into(),
228 "2021".into(),
229 "--error-format=json".into(),
230 "--color".into(),
231 "never".into(),
232 "--emit=metadata".into(),
233 "-o".into(),
234 metadata_out.into(),
235 ];
236 args.extend(self.dependency_args(serde_json_path));
237
238 let RustcOutput { stderr, .. } = self.run_rustc(args, timeout, "check").await?;
239 let diagnostics = parse_diagnostics(&stderr);
240
241 let has_errors = diagnostics.iter().any(|d| d.level == "error");
242 if has_errors {
243 debug!(
244 error_count = diagnostics.iter().filter(|d| d.level == "error").count(),
245 "check step found errors"
246 );
247 return Err(CodeError::CompileError { diagnostics, stderr });
248 }
249
250 let warning_count = diagnostics.iter().filter(|d| d.level == "warning").count();
251 if warning_count > 0 {
252 debug!(warning_count, "check step found warnings");
253 }
254
255 Ok(diagnostics)
256 }
257
258 async fn build(
260 &self,
261 source_path: &Path,
262 binary_path: &Path,
263 serde_json_path: &Option<PathBuf>,
264 timeout: Duration,
265 ) -> Result<(), CodeError> {
266 let mut args: Vec<OsString> = vec![
267 source_path.into(),
268 "-o".into(),
269 binary_path.into(),
270 "--edition".into(),
271 "2021".into(),
272 ];
273 args.extend(self.dependency_args(serde_json_path));
274
275 let output = self.run_rustc(args, timeout, "build").await?;
276 if !output.success {
277 let diagnostics = parse_diagnostics(&output.stderr);
278 return Err(CodeError::CompileError { diagnostics, stderr: output.stderr });
279 }
280
281 Ok(())
282 }
283
284 fn dependency_args(&self, serde_json_path: &Option<PathBuf>) -> Vec<OsString> {
287 let mut args = Vec::new();
288 if let Some(dep_path) = serde_json_path {
289 args.push("--extern".into());
290 args.push(format!("serde_json={}", dep_path.display()).into());
291
292 if let Some(parent) = dep_path.parent() {
293 args.push("-L".into());
294 args.push(format!("dependency={}", parent.display()).into());
295 }
296 }
297 args.extend(self.config.rustc_flags.iter().map(OsString::from));
298 args
299 }
300
301 async fn run_rustc(
309 &self,
310 args: Vec<OsString>,
311 timeout: Duration,
312 step: &str,
313 ) -> Result<RustcOutput, CodeError> {
314 let capabilities = self.backend.capabilities();
315 let compile_in_backend = cfg!(unix)
317 && capabilities.enforced_limits.filesystem_read_isolation
318 && capabilities.supported_languages.contains(&Language::Command);
319
320 if compile_in_backend {
321 let code = std::iter::once(OsString::from(&self.config.rustc_path))
322 .chain(args)
323 .map(|arg| format!("'{}'", arg.to_string_lossy().replace('\'', r"'\''")))
324 .collect::<Vec<_>>()
325 .join(" ");
326 let env = ProcessBackend::toolchain_env()
327 .into_iter()
328 .filter_map(|(key, value)| value.into_string().ok().map(|value| (key, value)))
329 .collect();
330 debug!(step, "running rustc inside the sandbox backend");
331
332 let request = ExecRequest {
333 language: Language::Command,
334 code,
335 stdin: None,
336 timeout,
337 memory_limit_mb: None,
338 env,
339 };
340 return match self.backend.execute(request).await {
341 Ok(result) => {
342 Ok(RustcOutput { success: result.exit_code == 0, stderr: result.stderr })
343 }
344 Err(SandboxError::Timeout { .. }) => {
345 Err(CodeError::InvalidCode(format!("{step} step timed out")))
346 }
347 Err(error) => Err(error.into()),
348 };
349 }
350
351 let mut cmd = host_process::rustc_command(&self.config.rustc_path);
352 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
353
354 match tokio::time::timeout(timeout, cmd.output()).await {
355 Ok(Ok(output)) => Ok(RustcOutput {
356 success: output.status.success(),
357 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
358 }),
359 Ok(Err(e)) => {
360 Err(CodeError::InvalidCode(format!("failed to invoke rustc for {step}: {e}")))
361 }
362 Err(_) => Err(CodeError::InvalidCode(format!("{step} step timed out"))),
364 }
365 }
366
367 async fn find_serde_json_dep(&self) -> Result<Option<PathBuf>, CodeError> {
372 let mut searched = Vec::new();
373
374 if let Some(ref path) = self.config.serde_json_path {
376 if path.exists() {
377 debug!(path = %path.display(), "using configured serde_json path");
378 return Ok(Some(path.clone()));
379 }
380 searched.push(format!("config: {}", path.display()));
381 }
382
383 let output = tokio::process::Command::new("cargo")
385 .args(["metadata", "--format-version=1", "--no-deps"])
386 .stdout(std::process::Stdio::piped())
387 .stderr(std::process::Stdio::null())
388 .output()
389 .await;
390
391 if let Ok(output) = output
392 && output.status.success()
393 && let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&output.stdout)
394 && let Some(target_dir) = metadata["target_directory"].as_str()
395 {
396 let deps_dir = PathBuf::from(target_dir).join("debug").join("deps");
397 searched.push(format!("cargo metadata: {}", deps_dir.display()));
398 if let Some(rlib) = find_rlib_in_dir(&deps_dir, "serde_json").await {
399 debug!(path = %rlib.display(), "found serde_json via cargo metadata");
400 return Ok(Some(rlib));
401 }
402 } else {
403 searched.push("cargo metadata: command failed".to_string());
404 }
405
406 Err(CodeError::DependencyNotFound { name: "serde_json".to_string(), searched })
408 }
409}
410
411async fn find_rlib_in_dir(dir: &Path, crate_name: &str) -> Option<PathBuf> {
413 let prefix = format!("lib{crate_name}-");
414 let mut entries = match tokio::fs::read_dir(dir).await {
415 Ok(entries) => entries,
416 Err(_) => return None,
417 };
418
419 while let Ok(Some(entry)) = entries.next_entry().await {
420 let name = entry.file_name();
421 let name_str = name.to_string_lossy();
422 if name_str.starts_with(&prefix) && name_str.ends_with(".rlib") {
423 return Some(entry.path());
424 }
425 }
426 None
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use adk_sandbox::{BackendCapabilities, EnforcedLimits, SandboxError};
433 use async_trait::async_trait;
434 use std::sync::Mutex;
435
436 struct MockBackend {
438 captured: Mutex<Vec<ExecRequest>>,
440 response: Mutex<Option<Result<ExecResult, SandboxError>>>,
442 }
443
444 impl MockBackend {
445 fn new(response: Result<ExecResult, SandboxError>) -> Self {
446 Self { captured: Mutex::new(Vec::new()), response: Mutex::new(Some(response)) }
447 }
448
449 fn success(stdout: &str) -> Self {
450 Self::new(Ok(ExecResult {
451 stdout: stdout.to_string(),
452 stderr: String::new(),
453 exit_code: 0,
454 duration: Duration::from_millis(10),
455 }))
456 }
457 }
458
459 #[async_trait]
460 impl SandboxBackend for MockBackend {
461 fn name(&self) -> &str {
462 "mock"
463 }
464
465 fn capabilities(&self) -> BackendCapabilities {
466 BackendCapabilities {
467 supported_languages: vec![Language::Command],
468 isolation_class: "mock".to_string(),
469 enforced_limits: EnforcedLimits {
470 timeout: true,
471 memory: false,
472 network_isolation: false,
473 filesystem_write_isolation: false,
474 filesystem_read_isolation: false,
475 environment_isolation: false,
476 },
477 }
478 }
479
480 async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
481 self.captured.lock().unwrap().push(request);
482 self.response
483 .lock()
484 .unwrap()
485 .take()
486 .unwrap_or(Err(SandboxError::ExecutionFailed("no canned response".to_string())))
487 }
488 }
489
490 struct ReadConfiningBackend {
492 captured: Mutex<Vec<ExecRequest>>,
493 }
494
495 #[async_trait]
496 impl SandboxBackend for ReadConfiningBackend {
497 fn name(&self) -> &str {
498 "read-confining"
499 }
500
501 fn capabilities(&self) -> BackendCapabilities {
502 BackendCapabilities {
503 supported_languages: vec![Language::Command],
504 isolation_class: "mock".to_string(),
505 enforced_limits: EnforcedLimits {
506 timeout: true,
507 memory: false,
508 network_isolation: true,
509 filesystem_write_isolation: true,
510 filesystem_read_isolation: true,
511 environment_isolation: true,
512 },
513 }
514 }
515
516 async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
517 self.captured.lock().unwrap().push(request);
518 Ok(ExecResult {
519 stdout: r#"{"ok":true}"#.to_string(),
520 stderr: String::new(),
521 exit_code: 0,
522 duration: Duration::from_millis(1),
523 })
524 }
525 }
526
527 #[cfg(unix)]
530 #[tokio::test]
531 async fn compilation_runs_inside_a_read_confining_backend() {
532 let tmp_dir = tempfile::tempdir().unwrap();
533 let fake_rlib = tmp_dir.path().join("libserde_json-abc123.rlib");
534 tokio::fs::write(&fake_rlib, b"fake rlib").await.unwrap();
535
536 let backend = Arc::new(ReadConfiningBackend { captured: Mutex::new(Vec::new()) });
537 let config = RustExecutorConfig {
538 rustc_path: "/nonexistent/rustc-must-not-run-on-host".to_string(),
539 serde_json_path: Some(fake_rlib),
540 ..Default::default()
541 };
542 let executor = RustExecutor::new(backend.clone(), config);
543
544 let code = "fn run(input: serde_json::Value) -> serde_json::Value { input }";
545 let result = executor.execute(code, None, Duration::from_secs(5)).await.unwrap();
546 assert_eq!(result.output, Some(serde_json::json!({ "ok": true })));
547
548 let captured = backend.captured.lock().unwrap();
549 assert_eq!(captured.len(), 3, "check, build, and execute all go through the backend");
550
551 let toolchain: Vec<String> =
552 ProcessBackend::toolchain_env().into_iter().map(|(key, _)| key).collect();
553 for (request, step_flag) in captured[..2].iter().zip(["'--error-format=json'", "'-o'"]) {
554 assert_eq!(request.language, Language::Command);
555 assert!(
556 request.code.starts_with("'/nonexistent/rustc-must-not-run-on-host' "),
557 "{}",
558 request.code
559 );
560 assert!(request.code.contains(step_flag), "{}", request.code);
561 assert!(
562 request.env.keys().all(|key| toolchain.contains(key)),
563 "compile environment must be the toolchain allowlist: {:?}",
564 request.env.keys()
565 );
566 }
567 assert!(captured[2].env.is_empty());
568 }
569
570 #[test]
571 fn default_config() {
572 let config = RustExecutorConfig::default();
573 assert_eq!(config.rustc_path, "rustc");
574 assert!(config.serde_json_path.is_none());
575 assert!(config.rustc_flags.is_empty());
576 }
577
578 #[tokio::test]
579 async fn check_valid_code_produces_no_errors() {
580 let backend = Arc::new(MockBackend::success(r#"{"result":42}"#));
581 let executor = RustExecutor::new(backend, RustExecutorConfig::default());
582
583 let tmp_dir = tempfile::tempdir().unwrap();
584 let source_path = tmp_dir.path().join("valid.rs");
585 let code = r#"
586fn run(input: serde_json::Value) -> serde_json::Value {
587 input
588}
589"#;
590 let harnessed = HARNESS_TEMPLATE.replace("{user_code}", code);
591 tokio::fs::write(&source_path, &harnessed).await.unwrap();
592
593 let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
596
597 match result {
600 Ok(diagnostics) => {
601 assert!(
603 !diagnostics.iter().any(|d| d.level == "error"),
604 "expected no error diagnostics for valid code"
605 );
606 }
607 Err(CodeError::CompileError { diagnostics, .. }) => {
608 assert!(
610 diagnostics.iter().any(|d| d.level == "error"),
611 "expected error diagnostics when serde_json is missing"
612 );
613 }
614 Err(other) => panic!("unexpected error: {other}"),
615 }
616 }
617
618 #[tokio::test]
619 async fn check_invalid_code_returns_compile_error() {
620 let backend = Arc::new(MockBackend::success(""));
621 let executor = RustExecutor::new(backend, RustExecutorConfig::default());
622
623 let tmp_dir = tempfile::tempdir().unwrap();
624 let source_path = tmp_dir.path().join("invalid.rs");
625 let code = "fn broken( { }";
627 tokio::fs::write(&source_path, code).await.unwrap();
628
629 let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
630
631 match result {
632 Err(CodeError::CompileError { diagnostics, stderr }) => {
633 assert!(
634 diagnostics.iter().any(|d| d.level == "error"),
635 "expected at least one error diagnostic"
636 );
637 assert!(!stderr.is_empty(), "expected non-empty stderr");
638 }
639 other => panic!("expected CompileError, got: {other:?}"),
640 }
641 }
642
643 #[tokio::test]
644 async fn check_warnings_are_returned_without_halting() {
645 let backend = Arc::new(MockBackend::success(""));
646 let executor = RustExecutor::new(backend, RustExecutorConfig::default());
647
648 let tmp_dir = tempfile::tempdir().unwrap();
649 let source_path = tmp_dir.path().join("warnings.rs");
650 let code = "fn main() { let x = 42; }";
652 tokio::fs::write(&source_path, code).await.unwrap();
653
654 let result = executor.check(&source_path, &None, Duration::from_secs(30)).await;
655
656 match result {
657 Ok(diagnostics) => {
658 assert!(
660 diagnostics.iter().any(|d| d.level == "warning"),
661 "expected at least one warning diagnostic, got: {diagnostics:?}"
662 );
663 }
664 Err(CodeError::CompileError { diagnostics, .. }) => {
665 panic!("unexpected compile errors: {diagnostics:?}");
667 }
668 Err(other) => panic!("unexpected error: {other}"),
669 }
670 }
671
672 #[tokio::test]
673 async fn serde_json_discovery_config_path_exists() {
674 let tmp_dir = tempfile::tempdir().unwrap();
675 let fake_rlib = tmp_dir.path().join("libserde_json-abc123.rlib");
676 tokio::fs::write(&fake_rlib, b"fake rlib").await.unwrap();
677
678 let config =
679 RustExecutorConfig { serde_json_path: Some(fake_rlib.clone()), ..Default::default() };
680 let backend = Arc::new(MockBackend::success(""));
681 let executor = RustExecutor::new(backend, config);
682
683 let result = executor.find_serde_json_dep().await.unwrap();
684 assert_eq!(result, Some(fake_rlib));
685 }
686
687 #[tokio::test]
688 async fn serde_json_discovery_config_path_missing() {
689 let config = RustExecutorConfig {
690 serde_json_path: Some(PathBuf::from("/nonexistent/libserde_json.rlib")),
691 ..Default::default()
692 };
693 let backend = Arc::new(MockBackend::success(""));
694 let executor = RustExecutor::new(backend, config);
695
696 let result = executor.find_serde_json_dep().await;
699 match result {
700 Err(CodeError::DependencyNotFound { name, searched }) => {
701 assert_eq!(name, "serde_json");
702 assert!(
703 searched.iter().any(|s| s.contains("/nonexistent/")),
704 "expected searched to include the config path, got: {searched:?}"
705 );
706 }
707 Ok(Some(_)) => {}
709 other => panic!("unexpected result: {other:?}"),
710 }
711 }
712
713 #[test]
714 fn code_error_from_sandbox_error() {
715 let sandbox_err = SandboxError::Timeout { timeout: Duration::from_secs(5) };
716 let code_err: CodeError = sandbox_err.into();
717 assert!(matches!(code_err, CodeError::Sandbox(_)));
718 assert!(code_err.to_string().contains("sandbox error"));
719 }
720
721 #[test]
722 fn validate_rejects_fn_main() {
723 let code = "fn main() { }";
724 let result = validate_rust_source(code);
725 assert!(result.is_err());
726 }
727
728 #[test]
729 fn validate_accepts_valid_run() {
730 let code = r#"
731fn run(input: serde_json::Value) -> serde_json::Value {
732 input
733}
734"#;
735 assert!(validate_rust_source(code).is_ok());
736 }
737}