1use std::io::Write as _;
17use std::process::{Command, Stdio};
18use std::time::{Duration, Instant};
19
20const COMPILE_TIMEOUT: Duration = Duration::from_secs(60);
23
24const WASM_EXEC_TIMEOUT_MS: u64 = 15_000;
27
28use serde::{Deserialize, Serialize};
29
30use crate::capability::{Budget, CapabilityMemoryRecord, CapabilityRequest, ToolMetrics};
31use crate::code_gen::GeneratedTool;
32use crate::input_validation;
33use crate::policy::{self, PolicyState};
34use crate::tool_memory::CapabilityMemory;
35
36const WASM_MAIN: &str = r#"
44
45fn main() {
46 use std::io::Read;
47 let mut input = String::new();
48 if std::io::stdin().read_to_string(&mut input).is_err() {
49 std::process::exit(2);
50 }
51 match run(&input) {
52 Ok(output) => print!("{}", output),
53 Err(e) => {
54 eprintln!("run error: {}", e);
55 std::process::exit(1);
56 }
57 }
58}
59"#;
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
67pub enum CompileOutcome {
68 Success {
69 wasm_bytes: Vec<u8>,
70 compilation_ms: u64,
71 },
72 TargetNotInstalled { attempted_target: String },
74 CompilationFailed { stderr: String, compilation_ms: u64 },
76 CompilerNotFound { error: String },
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub enum ExecuteOutcome {
83 Success {
84 stdout: String,
85 execution_ms: u64,
86 },
87 RuntimeNotFound,
89 ExecutionFailed {
91 stderr: String,
92 exit_code: i32,
93 execution_ms: u64,
94 },
95 RuntimeError {
97 error: String,
98 },
99}
100
101#[derive(Debug, Serialize, Deserialize, Clone)]
106pub struct WasmMetrics {
107 pub compilation_ms: u64,
108 pub execution_ms: u64,
109 pub wasm_binary_bytes: usize,
110 pub input_bytes: usize,
111 pub output_bytes: usize,
112}
113
114#[derive(Debug, Serialize, Deserialize, Clone)]
115pub struct WasmExecutionReport {
116 pub accepted: bool,
117 pub rejection_reasons: Vec<String>,
118 pub compiled: bool,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub compilation_error: Option<String>,
122 pub executed: bool,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub execution_error: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub output: Option<String>,
129 pub destroyed: bool,
131 pub metrics: WasmMetrics,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub memory_update: Option<CapabilityMemoryRecord>,
134}
135
136pub trait WasmCompiler: Send + Sync {
141 fn compile(&self, source: &str) -> CompileOutcome;
142}
143
144pub trait WasmExecutor: Send + Sync {
145 fn execute(&self, wasm_bytes: &[u8], input: &str) -> ExecuteOutcome;
146}
147
148pub struct RustcCompiler;
153
154impl WasmCompiler for RustcCompiler {
155 fn compile(&self, source: &str) -> CompileOutcome {
156 let target = match detect_wasm_target() {
158 Some(t) => t,
159 None => {
160 return CompileOutcome::TargetNotInstalled {
161 attempted_target: "wasm32-wasip1 / wasm32-wasi".into(),
162 }
163 }
164 };
165
166 let tmp_dir = std::env::temp_dir().join(format!("sbh-wasm-{}", monotonic_id()));
168 if std::fs::create_dir_all(&tmp_dir).is_err() {
169 return CompileOutcome::CompilationFailed {
170 stderr: "failed to create temp directory".into(),
171 compilation_ms: 0,
172 };
173 }
174
175 let src_path = tmp_dir.join("tool.rs");
176 let wasm_path = tmp_dir.join("tool.wasm");
177 let full_source = format!("{}\n{}", source, WASM_MAIN);
178
179 if std::fs::write(&src_path, &full_source).is_err() {
180 let _ = std::fs::remove_dir_all(&tmp_dir);
181 return CompileOutcome::CompilationFailed {
182 stderr: "failed to write source file".into(),
183 compilation_ms: 0,
184 };
185 }
186
187 let start = Instant::now();
188
189 let child = Command::new("rustc")
191 .args(["--target", target, "--edition", "2021", "-o"])
192 .arg(&wasm_path)
193 .arg(&src_path)
194 .stdout(Stdio::piped())
195 .stderr(Stdio::piped())
196 .spawn();
197
198 let child = match child {
199 Err(e) => {
200 let _ = std::fs::remove_file(&src_path);
201 let _ = std::fs::remove_dir_all(&tmp_dir);
202 return CompileOutcome::CompilerNotFound {
203 error: format!("could not spawn rustc: {e}"),
204 };
205 }
206 Ok(c) => c,
207 };
208
209 let pid = child.id();
211 let (tx, rx) = std::sync::mpsc::channel::<std::io::Result<std::process::Output>>();
212 std::thread::spawn(move || { let _ = tx.send(child.wait_with_output()); });
213
214 let result = match rx.recv_timeout(COMPILE_TIMEOUT) {
215 Ok(r) => r,
216 Err(_) => {
217 let _ = Command::new("kill").args(["-9", &pid.to_string()]).status();
220 let _ = std::fs::remove_file(&src_path);
221 let _ = std::fs::remove_dir_all(&tmp_dir);
222 return CompileOutcome::CompilationFailed {
223 stderr: format!("rustc timed out after {}s", COMPILE_TIMEOUT.as_secs()),
224 compilation_ms: COMPILE_TIMEOUT.as_millis() as u64,
225 };
226 }
227 };
228
229 let compilation_ms = start.elapsed().as_millis() as u64;
230
231 let _ = std::fs::remove_file(&src_path);
233
234 match result {
235 Err(e) => {
236 let _ = std::fs::remove_dir_all(&tmp_dir);
237 CompileOutcome::CompilerNotFound {
238 error: format!("rustc wait error: {e}"),
239 }
240 }
241 Ok(out) if !out.status.success() => {
242 let _ = std::fs::remove_dir_all(&tmp_dir);
243 CompileOutcome::CompilationFailed {
244 stderr: String::from_utf8_lossy(&out.stderr)
245 .chars()
246 .take(2048)
247 .collect(),
248 compilation_ms,
249 }
250 }
251 Ok(_) => {
252 let wasm_bytes = std::fs::read(&wasm_path).unwrap_or_default();
253 let _ = std::fs::remove_dir_all(&tmp_dir);
255 CompileOutcome::Success {
256 wasm_bytes,
257 compilation_ms,
258 }
259 }
260 }
261 }
262}
263
264fn detect_wasm_target() -> Option<&'static str> {
267 let out = Command::new("rustup")
268 .args(["target", "list", "--installed"])
269 .output()
270 .ok()?;
271 let targets = String::from_utf8_lossy(&out.stdout);
272 if targets.contains("wasm32-wasip1") {
273 Some("wasm32-wasip1")
274 } else if targets.contains("wasm32-wasi") {
275 Some("wasm32-wasi")
276 } else {
277 None
278 }
279}
280
281pub struct WasmtimeCli;
286
287impl WasmExecutor for WasmtimeCli {
288 fn execute(&self, wasm_bytes: &[u8], input: &str) -> ExecuteOutcome {
289 let tmp_wasm = std::env::temp_dir().join(format!("sbh-run-{}.wasm", monotonic_id()));
291 if std::fs::write(&tmp_wasm, wasm_bytes).is_err() {
292 return ExecuteOutcome::RuntimeError {
293 error: "failed to write wasm to temp file".into(),
294 };
295 }
296
297 let start = Instant::now();
298
299 let spawn_result = Command::new("wasmtime")
304 .arg(format!("-W timeout={WASM_EXEC_TIMEOUT_MS}ms"))
305 .arg("--")
306 .arg(&tmp_wasm)
307 .stdin(Stdio::piped())
308 .stdout(Stdio::piped())
309 .stderr(Stdio::piped())
310 .spawn();
311
312 let output_result = match spawn_result {
313 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
314 let _ = std::fs::remove_file(&tmp_wasm);
315 return ExecuteOutcome::RuntimeNotFound;
316 }
317 Err(e) => {
318 let _ = std::fs::remove_file(&tmp_wasm);
319 return ExecuteOutcome::RuntimeError {
320 error: format!("spawn error: {e}"),
321 };
322 }
323 Ok(mut child) => {
324 if let Some(mut stdin) = child.stdin.take() {
325 let _ = stdin.write_all(input.as_bytes());
326 }
327 child.wait_with_output()
328 }
329 };
330
331 let _ = std::fs::remove_file(&tmp_wasm);
333
334 match output_result {
335 Err(e) => ExecuteOutcome::RuntimeError {
336 error: format!("wait error: {e}"),
337 },
338 Ok(out) => {
339 let execution_ms = start.elapsed().as_millis() as u64;
340 let stdout = String::from_utf8_lossy(&out.stdout)
341 .chars()
342 .take(65_536)
343 .collect();
344 let exit_code = out.status.code().unwrap_or(-1);
345
346 if out.status.success() {
347 ExecuteOutcome::Success {
348 stdout,
349 execution_ms,
350 }
351 } else {
352 let stderr = String::from_utf8_lossy(&out.stderr)
353 .chars()
354 .take(1024)
355 .collect();
356 ExecuteOutcome::ExecutionFailed {
357 stderr,
358 exit_code,
359 execution_ms,
360 }
361 }
362 }
363 }
364 }
365}
366
367pub struct WasmForge {
372 budget: Budget,
373 state: PolicyState,
374 pub memory: CapabilityMemory,
375 compiler: Box<dyn WasmCompiler>,
376 executor: Box<dyn WasmExecutor>,
377 session_log: Vec<WasmExecutionReport>,
378}
379
380impl WasmForge {
381 pub fn new() -> Self {
382 Self::with_deps(Box::new(RustcCompiler), Box::new(WasmtimeCli))
383 }
384
385 pub fn with_deps(compiler: Box<dyn WasmCompiler>, executor: Box<dyn WasmExecutor>) -> Self {
386 Self {
387 budget: Budget {
389 max_tools_per_session: 2,
390 max_total_runtime_ms: 120_000, require_approval_after_failures: 1,
392 },
393 state: PolicyState::default(),
394 memory: CapabilityMemory::new(),
395 compiler,
396 executor,
397 session_log: vec![],
398 }
399 }
400
401 pub fn audit(&self) -> &[WasmExecutionReport] {
402 &self.session_log
403 }
404
405 pub fn handle(
409 &mut self,
410 req: &CapabilityRequest,
411 tool: &GeneratedTool,
412 input: &str,
413 ) -> WasmExecutionReport {
414 let report = self.handle_inner(req, tool, input);
415 self.session_log.push(report.clone());
416 report
417 }
418
419 fn handle_inner(
420 &mut self,
421 req: &CapabilityRequest,
422 tool: &GeneratedTool,
423 input: &str,
424 ) -> WasmExecutionReport {
425 if let Err(e) = input_validation::validate_forge_input(input) {
427 return rejected(vec![format!("input validation: {e}")]);
428 }
429 if let Err(e) = input_validation::validate_capability_fields(req) {
430 return rejected(vec![format!("capability field validation: {e}")]);
431 }
432
433 if let Some(reason) = self.state.budget_exceeded(&self.budget) {
435 return rejected(vec![reason]);
436 }
437
438 let violations = policy::check_request(req);
440 if !violations.is_empty() {
441 return rejected(violations.into_iter().map(|v| v.detail).collect());
442 }
443
444 if !tool.static_analysis.passed {
446 let reasons: Vec<String> = tool
447 .static_analysis
448 .violations
449 .iter()
450 .map(|v| format!("static_analysis: {} at line {}", v.kind, v.line))
451 .collect();
452 return rejected(reasons);
453 }
454 if !tool.tests_included {
455 return rejected(vec![
456 "generated tool does not include the required minimum of 2 #[test] functions"
457 .into(),
458 ]);
459 }
460
461 let compile_outcome = self.compiler.compile(&tool.source);
463 let (wasm_bytes, compilation_ms) = match compile_outcome {
464 CompileOutcome::Success {
465 wasm_bytes,
466 compilation_ms,
467 } => (wasm_bytes, compilation_ms),
468 CompileOutcome::TargetNotInstalled { attempted_target } => {
469 let metrics = zero_metrics(input);
470 self.state.record_run(&ToolMetrics {
471 success: false,
472 input_bytes: input.len(),
473 ..Default::default()
474 });
475 return WasmExecutionReport {
476 accepted: true,
477 rejection_reasons: vec![],
478 compiled: false,
479 compilation_error: Some(format!(
480 "WASM target not installed: {attempted_target} — \
481 run: rustup target add wasm32-wasip1"
482 )),
483 executed: false,
484 execution_error: None,
485 output: None,
486 destroyed: true,
487 metrics,
488 memory_update: None,
489 };
490 }
491 CompileOutcome::CompilationFailed {
492 stderr,
493 compilation_ms,
494 } => {
495 let metrics = WasmMetrics {
496 compilation_ms,
497 ..zero_metrics(input)
498 };
499 self.state.record_run(&ToolMetrics {
500 success: false,
501 input_bytes: input.len(),
502 runtime_ms: compilation_ms,
503 ..Default::default()
504 });
505 return WasmExecutionReport {
506 accepted: true,
507 rejection_reasons: vec![],
508 compiled: false,
509 compilation_error: Some(stderr),
510 executed: false,
511 execution_error: None,
512 output: None,
513 destroyed: true,
514 metrics,
515 memory_update: None,
516 };
517 }
518 CompileOutcome::CompilerNotFound { error } => {
519 let metrics = zero_metrics(input);
520 self.state.record_run(&ToolMetrics {
521 success: false,
522 input_bytes: input.len(),
523 ..Default::default()
524 });
525 return WasmExecutionReport {
526 accepted: true,
527 rejection_reasons: vec![],
528 compiled: false,
529 compilation_error: Some(error),
530 executed: false,
531 execution_error: None,
532 output: None,
533 destroyed: true,
534 metrics,
535 memory_update: None,
536 };
537 }
538 };
539
540 let wasm_binary_bytes = wasm_bytes.len();
542
543 let execute_outcome = self.executor.execute(&wasm_bytes, input);
545 drop(wasm_bytes);
547
548 let (executed, stdout, execution_ms, execution_error) = match execute_outcome {
549 ExecuteOutcome::Success {
550 stdout,
551 execution_ms,
552 } => (true, Some(stdout), execution_ms, None),
553 ExecuteOutcome::RuntimeNotFound => (
554 false,
555 None,
556 0,
557 Some("wasmtime not found on PATH — install from https://wasmtime.dev".into()),
558 ),
559 ExecuteOutcome::ExecutionFailed {
560 stderr,
561 execution_ms,
562 ..
563 } => (false, None, execution_ms, Some(stderr)),
564 ExecuteOutcome::RuntimeError { error } => (false, None, 0, Some(error)),
565 };
566
567 let success = executed && execution_error.is_none();
568 let tool_metrics = ToolMetrics {
569 runtime_ms: compilation_ms + execution_ms,
570 input_bytes: input.len(),
571 output_bytes: stdout.as_deref().map(|s| s.len()).unwrap_or(0),
572 success,
573 };
574 self.state.record_run(&tool_metrics);
575
576 let metrics = WasmMetrics {
577 compilation_ms,
578 execution_ms,
579 wasm_binary_bytes,
580 input_bytes: input.len(),
581 output_bytes: stdout.as_deref().map(|s| s.len()).unwrap_or(0),
582 };
583
584 let memory_update = if success {
586 let signature = CapabilityMemory::derive_signature(req);
587 let record = CapabilityMemoryRecord {
588 problem_signature: signature,
589 solution_pattern: format!("wasm:{}", req.capability),
590 input_shape: shape_token(&req.input_contract),
591 output_shape: shape_token(&req.output_contract),
592 constraints: req.constraints.clone(),
593 };
594 self.memory.upsert(record.clone(), &tool_metrics);
595 Some(record)
596 } else {
597 None
598 };
599
600 WasmExecutionReport {
601 accepted: true,
602 rejection_reasons: vec![],
603 compiled: true,
604 compilation_error: None,
605 executed,
606 execution_error,
607 output: stdout,
608 destroyed: true,
609 metrics,
610 memory_update,
611 }
612 }
613
614 pub fn tools_invoked(&self) -> usize {
615 self.state.tools_invoked
616 }
617}
618
619impl Default for WasmForge {
620 fn default() -> Self {
621 Self::new()
622 }
623}
624
625fn rejected(reasons: Vec<String>) -> WasmExecutionReport {
630 WasmExecutionReport {
631 accepted: false,
632 rejection_reasons: reasons,
633 compiled: false,
634 compilation_error: None,
635 executed: false,
636 execution_error: None,
637 output: None,
638 destroyed: false,
639 metrics: WasmMetrics {
640 compilation_ms: 0,
641 execution_ms: 0,
642 wasm_binary_bytes: 0,
643 input_bytes: 0,
644 output_bytes: 0,
645 },
646 memory_update: None,
647 }
648}
649
650fn zero_metrics(input: &str) -> WasmMetrics {
651 WasmMetrics {
652 compilation_ms: 0,
653 execution_ms: 0,
654 wasm_binary_bytes: 0,
655 input_bytes: input.len(),
656 output_bytes: 0,
657 }
658}
659
660fn shape_token(contract: &str) -> String {
661 contract
662 .split_whitespace()
663 .take(3)
664 .map(|w| {
665 w.to_lowercase()
666 .trim_matches(|c: char| !c.is_alphanumeric())
667 .to_string()
668 })
669 .filter(|s| !s.is_empty())
670 .collect::<Vec<_>>()
671 .join("_")
672}
673
674fn monotonic_id() -> String {
675 let pid = std::process::id();
676 std::time::SystemTime::now()
677 .duration_since(std::time::UNIX_EPOCH)
678 .map(|d| format!("{}-{}{}", pid, d.as_secs(), d.subsec_nanos()))
679 .unwrap_or_else(|_| format!("{}-0", pid))
680}
681
682#[cfg(test)]
687mod tests {
688 use super::*;
689 use crate::capability::CapabilityConstraints;
690 use crate::static_analysis;
691
692 struct MockCompiler(CompileOutcome);
695
696 impl WasmCompiler for MockCompiler {
697 fn compile(&self, _source: &str) -> CompileOutcome {
698 self.0.clone()
699 }
700 }
701
702 struct MockExecutor(ExecuteOutcome);
705
706 impl WasmExecutor for MockExecutor {
707 fn execute(&self, _bytes: &[u8], _input: &str) -> ExecuteOutcome {
708 self.0.clone()
709 }
710 }
711
712 fn clean_req(cap: &str) -> CapabilityRequest {
715 CapabilityRequest {
716 kind: "capability_request".into(),
717 capability: cap.into(),
718 input_contract: "utf8 text".into(),
719 output_contract: "json object".into(),
720 constraints: CapabilityConstraints::default(),
721 reason: "text reasoning insufficient".into(),
722 }
723 }
724
725 fn verified_tool() -> GeneratedTool {
726 let source = r#"pub fn run(input: &str) -> Result<String, String> {
727 let c = input.split_whitespace().count();
728 Ok(format!("{\"count\":{}}", c))
729}
730#[test] fn t1() { assert!(run("a b").is_ok()); }
731#[test] fn t2() { assert!(run("").is_ok()); }"#;
732 GeneratedTool {
733 source: source.into(),
734 function_name: "run".into(),
735 tests_included: true,
736 test_count: 2,
737 static_analysis: static_analysis::check(source),
738 }
739 }
740
741 fn unverified_tool_unsafe() -> GeneratedTool {
742 let source = r#"pub fn run(input: &str) -> Result<String, String> {
743 unsafe { }
744 Ok("ok".into())
745}
746#[test] fn t1() {}
747#[test] fn t2() {}"#;
748 GeneratedTool {
749 source: source.into(),
750 function_name: "run".into(),
751 tests_included: true,
752 test_count: 2,
753 static_analysis: static_analysis::check(source),
754 }
755 }
756
757 fn unverified_tool_no_tests() -> GeneratedTool {
758 let source = "pub fn run(input: &str) -> Result<String, String> { Ok(\"ok\".into()) }";
759 GeneratedTool {
760 source: source.into(),
761 function_name: "run".into(),
762 tests_included: false,
763 test_count: 0,
764 static_analysis: static_analysis::check(source),
765 }
766 }
767
768 fn forge_with(compile: CompileOutcome, exec: ExecuteOutcome) -> WasmForge {
769 WasmForge::with_deps(
770 Box::new(MockCompiler(compile)),
771 Box::new(MockExecutor(exec)),
772 )
773 }
774
775 const MOCK_WASM: &[u8] = b"\x00asm\x01\x00\x00\x00"; #[test]
780 fn successful_compile_and_execute() {
781 let mut forge = forge_with(
782 CompileOutcome::Success {
783 wasm_bytes: MOCK_WASM.to_vec(),
784 compilation_ms: 800,
785 },
786 ExecuteOutcome::Success {
787 stdout: r#"{"count":2}"#.into(),
788 execution_ms: 12,
789 },
790 );
791 let report = forge.handle(&clean_req("word_count"), &verified_tool(), "hello world");
792 assert!(report.accepted);
793 assert!(report.compiled);
794 assert!(report.executed);
795 assert!(report.destroyed, "binary must be destroyed after execution");
796 assert_eq!(report.output.as_deref(), Some(r#"{"count":2}"#));
797 assert!(report.memory_update.is_some());
798 }
799
800 #[test]
803 fn rejects_tool_that_failed_static_analysis() {
804 let mut forge = forge_with(
805 CompileOutcome::Success {
806 wasm_bytes: MOCK_WASM.to_vec(),
807 compilation_ms: 0,
808 },
809 ExecuteOutcome::Success {
810 stdout: "ok".into(),
811 execution_ms: 0,
812 },
813 );
814 let report = forge.handle(&clean_req("x"), &unverified_tool_unsafe(), "input");
815 assert!(!report.accepted);
816 assert!(report
817 .rejection_reasons
818 .iter()
819 .any(|r| r.contains("static_analysis")));
820 }
821
822 #[test]
823 fn rejects_tool_without_tests() {
824 let mut forge = forge_with(
825 CompileOutcome::Success {
826 wasm_bytes: MOCK_WASM.to_vec(),
827 compilation_ms: 0,
828 },
829 ExecuteOutcome::Success {
830 stdout: "ok".into(),
831 execution_ms: 0,
832 },
833 );
834 let report = forge.handle(&clean_req("x"), &unverified_tool_no_tests(), "input");
835 assert!(!report.accepted);
836 assert!(report.rejection_reasons[0].contains("#[test]"));
837 }
838
839 #[test]
840 fn rejects_policy_violation() {
841 let mut req = clean_req("fetch_url");
842 req.constraints.no_network = false;
843 let mut forge = forge_with(
844 CompileOutcome::Success {
845 wasm_bytes: MOCK_WASM.to_vec(),
846 compilation_ms: 0,
847 },
848 ExecuteOutcome::RuntimeNotFound,
849 );
850 let report = forge.handle(&req, &verified_tool(), "input");
851 assert!(!report.accepted);
852 assert!(report
853 .rejection_reasons
854 .iter()
855 .any(|r| r.contains("no_network")));
856 }
857
858 #[test]
859 fn rejects_oversized_input() {
860 let mut forge = forge_with(
861 CompileOutcome::Success {
862 wasm_bytes: MOCK_WASM.to_vec(),
863 compilation_ms: 0,
864 },
865 ExecuteOutcome::Success {
866 stdout: "ok".into(),
867 execution_ms: 0,
868 },
869 );
870 let big = "x".repeat(crate::input_validation::MAX_FORGE_INPUT_BYTES + 1);
871 let report = forge.handle(&clean_req("x"), &verified_tool(), &big);
872 assert!(!report.accepted);
873 assert!(report.rejection_reasons[0].contains("input validation"));
874 }
875
876 #[test]
877 fn rejects_when_budget_exhausted() {
878 let mut forge = WasmForge {
879 budget: Budget {
880 max_tools_per_session: 1,
881 ..Budget::default()
882 },
883 state: PolicyState::default(),
884 memory: CapabilityMemory::new(),
885 compiler: Box::new(MockCompiler(CompileOutcome::Success {
886 wasm_bytes: MOCK_WASM.to_vec(),
887 compilation_ms: 0,
888 })),
889 executor: Box::new(MockExecutor(ExecuteOutcome::Success {
890 stdout: "ok".into(),
891 execution_ms: 0,
892 })),
893 session_log: vec![],
894 };
895 forge.handle(&clean_req("x"), &verified_tool(), "a");
896 let report = forge.handle(&clean_req("x"), &verified_tool(), "b");
897 assert!(!report.accepted);
898 assert!(report.rejection_reasons[0].contains("session tool limit"));
899 }
900
901 #[test]
904 fn compilation_failure_reports_stderr() {
905 let mut forge = forge_with(
906 CompileOutcome::CompilationFailed {
907 stderr: "error[E0001]: syntax error".into(),
908 compilation_ms: 300,
909 },
910 ExecuteOutcome::RuntimeNotFound,
911 );
912 let report = forge.handle(&clean_req("x"), &verified_tool(), "input");
913 assert!(report.accepted);
914 assert!(!report.compiled);
915 assert!(!report.executed);
916 assert!(report.destroyed, "nothing to destroy but flag must be set");
917 assert!(report
918 .compilation_error
919 .as_deref()
920 .unwrap_or("")
921 .contains("syntax error"));
922 }
923
924 #[test]
925 fn target_not_installed_returns_clear_message() {
926 let mut forge = forge_with(
927 CompileOutcome::TargetNotInstalled {
928 attempted_target: "wasm32-wasip1".into(),
929 },
930 ExecuteOutcome::RuntimeNotFound,
931 );
932 let report = forge.handle(&clean_req("x"), &verified_tool(), "input");
933 assert!(report.accepted);
934 assert!(!report.compiled);
935 let err = report.compilation_error.unwrap_or_default();
936 assert!(err.contains("wasm32-wasip1") || err.contains("target not installed"));
937 }
938
939 #[test]
942 fn runtime_not_found_does_not_panic() {
943 let mut forge = forge_with(
944 CompileOutcome::Success {
945 wasm_bytes: MOCK_WASM.to_vec(),
946 compilation_ms: 500,
947 },
948 ExecuteOutcome::RuntimeNotFound,
949 );
950 let report = forge.handle(&clean_req("x"), &verified_tool(), "input");
951 assert!(report.accepted);
952 assert!(report.compiled);
953 assert!(!report.executed);
954 assert!(report
955 .execution_error
956 .as_deref()
957 .unwrap_or("")
958 .contains("wasmtime"));
959 }
960
961 #[test]
962 fn execution_failure_reports_stderr() {
963 let mut forge = forge_with(
964 CompileOutcome::Success {
965 wasm_bytes: MOCK_WASM.to_vec(),
966 compilation_ms: 500,
967 },
968 ExecuteOutcome::ExecutionFailed {
969 stderr: "runtime trap".into(),
970 exit_code: 1,
971 execution_ms: 5,
972 },
973 );
974 let report = forge.handle(&clean_req("x"), &verified_tool(), "input");
975 assert!(report.compiled);
976 assert!(!report.executed);
977 assert!(report
978 .execution_error
979 .as_deref()
980 .unwrap_or("")
981 .contains("runtime trap"));
982 }
983
984 #[test]
987 fn memory_not_updated_when_execution_fails() {
988 let mut forge = forge_with(
989 CompileOutcome::Success {
990 wasm_bytes: MOCK_WASM.to_vec(),
991 compilation_ms: 0,
992 },
993 ExecuteOutcome::RuntimeNotFound,
994 );
995 forge.handle(&clean_req("x"), &verified_tool(), "input");
996 assert_eq!(forge.memory.len(), 0);
997 }
998
999 #[test]
1000 fn session_log_records_all_calls() {
1001 let mut forge = forge_with(
1002 CompileOutcome::Success {
1003 wasm_bytes: MOCK_WASM.to_vec(),
1004 compilation_ms: 0,
1005 },
1006 ExecuteOutcome::Success {
1007 stdout: "ok".into(),
1008 execution_ms: 0,
1009 },
1010 );
1011 forge.handle(&clean_req("x"), &verified_tool(), "a");
1012 let mut req2 = clean_req("y");
1013 req2.constraints.no_network = false;
1014 forge.handle(&req2, &verified_tool(), "b");
1015 assert_eq!(forge.audit().len(), 2);
1016 }
1017
1018 #[test]
1019 fn wasm_wrapper_contains_stdin_read_and_run_call() {
1020 assert!(WASM_MAIN.contains("read_to_string"));
1021 assert!(WASM_MAIN.contains("run(&input)"));
1022 assert!(WASM_MAIN.contains("process::exit"));
1023 }
1024
1025 #[test]
1026 fn metrics_record_binary_bytes() {
1027 let wasm = vec![0u8; 42_000];
1028 let mut forge = forge_with(
1029 CompileOutcome::Success {
1030 wasm_bytes: wasm,
1031 compilation_ms: 900,
1032 },
1033 ExecuteOutcome::Success {
1034 stdout: "result".into(),
1035 execution_ms: 15,
1036 },
1037 );
1038 let report = forge.handle(&clean_req("x"), &verified_tool(), "input");
1039 assert_eq!(report.metrics.wasm_binary_bytes, 42_000);
1040 assert_eq!(report.metrics.compilation_ms, 900);
1041 assert_eq!(report.metrics.execution_ms, 15);
1042 }
1043}