1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::BTreeMap;
4use std::io::Write as _;
5use std::path::PathBuf;
6use std::process::Stdio;
7use std::time::{Duration, Instant};
8
9use crate::orchestration::RunExecutionRecord;
10use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
11use crate::value::{VmError, VmValue};
12use crate::vm::Vm;
13
14const HARN_REPLAY_ENV: &str = "HARN_REPLAY";
15
16thread_local! {
17 pub(crate) static VM_SOURCE_DIR: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
18 static VM_EXECUTION_CONTEXT: RefCell<Option<RunExecutionRecord>> = const { RefCell::new(None) };
19}
20
21pub(crate) fn set_thread_source_dir(dir: &std::path::Path) {
23 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = Some(normalize_context_path(dir)));
24}
25
26pub(crate) fn normalize_context_path(path: &std::path::Path) -> PathBuf {
27 if path.is_absolute() {
28 return path.to_path_buf();
29 }
30 std::env::current_dir()
31 .map(|cwd| cwd.join(path))
32 .unwrap_or_else(|_| path.to_path_buf())
33}
34
35pub fn set_thread_execution_context(context: Option<RunExecutionRecord>) {
36 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = context);
37}
38
39pub(crate) fn current_execution_context() -> Option<RunExecutionRecord> {
40 VM_EXECUTION_CONTEXT.with(|current| current.borrow().clone())
41}
42
43pub(crate) fn swap_thread_execution_context(
51 next: Option<RunExecutionRecord>,
52) -> Option<RunExecutionRecord> {
53 VM_EXECUTION_CONTEXT.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
54}
55
56pub(crate) fn swap_source_dir(next: Option<PathBuf>) -> Option<PathBuf> {
60 VM_SOURCE_DIR.with(|current| std::mem::replace(&mut *current.borrow_mut(), next))
61}
62
63pub(crate) fn reset_process_state() {
65 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
66 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
67}
68
69pub fn execution_root_path() -> PathBuf {
70 current_execution_context()
71 .and_then(|context| context.cwd.map(PathBuf::from))
72 .or_else(|| std::env::current_dir().ok())
73 .unwrap_or_else(|| PathBuf::from("."))
74}
75
76pub fn source_root_path() -> PathBuf {
77 VM_SOURCE_DIR
78 .with(|sd| sd.borrow().clone())
79 .or_else(|| {
80 current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
81 })
82 .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
83 .or_else(|| std::env::current_dir().ok())
84 .unwrap_or_else(|| PathBuf::from("."))
85}
86
87pub fn asset_root_path() -> PathBuf {
88 source_root_path()
89}
90
91fn env_override(name: &str) -> Option<String> {
92 (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
93 .then(|| "1".to_string())
94}
95
96pub(crate) fn read_env_value(name: &str) -> Option<String> {
97 env_override(name)
98 .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
99 .or_else(|| std::env::var(name).ok())
100}
101
102pub fn runtime_root_base() -> PathBuf {
103 find_project_root(&execution_root_path())
104 .or_else(|| find_project_root(&source_root_path()))
105 .unwrap_or_else(source_root_path)
106}
107
108fn lexically_collapse(path: &std::path::Path) -> Option<PathBuf> {
113 use std::path::Component;
114 let mut out: Vec<Component> = Vec::new();
115 for component in path.components() {
116 match component {
117 Component::CurDir => {}
118 Component::ParentDir => {
119 let popped = out.pop();
120 if !matches!(popped, Some(Component::Normal(_))) {
121 return None;
122 }
123 }
124 other => out.push(other),
125 }
126 }
127 Some(out.iter().collect())
128}
129
130pub fn resolve_source_relative_path(path: &str) -> PathBuf {
131 let candidate = PathBuf::from(path);
132 if candidate.is_absolute() {
133 return candidate;
134 }
135 let root = execution_root_path();
136 let joined = root.join(&candidate);
137 if path_escapes_project_root(&joined) {
144 return root.join("__harn_rejected_parent_dir_traversal__");
145 }
146 joined
147}
148
149pub fn resolve_source_asset_path(path: &str) -> PathBuf {
150 let candidate = PathBuf::from(path);
151 if candidate.is_absolute() {
152 return candidate;
153 }
154 let root = asset_root_path();
155 let joined = root.join(&candidate);
156 if path_escapes_project_root(&joined) {
157 return root.join("__harn_rejected_parent_dir_traversal__");
158 }
159 joined
160}
161
162fn path_escapes_project_root(joined: &std::path::Path) -> bool {
176 lexically_collapse(joined).is_none()
177}
178
179pub(crate) fn register_process_builtins(vm: &mut Vm) {
180 for def in PROCESS_BUILTINS {
181 vm.register_builtin_def(def);
182 }
183}
184
185#[harn_builtin(sig = "env(name: string) -> string?", category = "process")]
186fn env_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
187 let name = args.first().map(|a| a.display()).unwrap_or_default();
188 if let Some(value) = read_env_value(&name) {
189 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
190 }
191 Ok(VmValue::Nil)
192}
193
194#[harn_builtin(
195 sig = "env_or(name: string, default: any) -> any",
196 category = "process"
197)]
198fn env_or_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
199 let name = args.first().map(|a| a.display()).unwrap_or_default();
200 let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
201 if let Some(value) = read_env_value(&name) {
202 return Ok(VmValue::String(arcstr::ArcStr::from(value)));
203 }
204 Ok(default)
205}
206
207#[harn_builtin(sig = "exit(code?: int) -> never", category = "process")]
208fn exit_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
209 let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
210 std::process::exit(code as i32);
211}
212
213#[harn_builtin(sig = "exec(...command: string) -> dict", category = "process")]
214fn exec_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
215 if args.is_empty() {
216 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
217 "exec: command is required",
218 ))));
219 }
220 let cmd = args[0].display();
221 let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
222 let output = exec_command(None, &cmd, &cmd_args)?;
223 Ok(vm_output_to_value(output))
224}
225
226#[harn_builtin(sig = "shell(command: string) -> dict", category = "process")]
227fn shell_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
228 let cmd = args.first().map(|a| a.display()).unwrap_or_default();
229 if cmd.is_empty() {
230 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
231 "shell: command string is required",
232 ))));
233 }
234 let invocation = crate::shells::default_shell_invocation(&cmd)
235 .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
236 let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
237 Ok(vm_output_to_value(output))
238}
239
240#[harn_builtin(
241 sig = "exec_at(dir: string, ...command: string) -> dict",
242 category = "process"
243)]
244fn exec_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
245 if args.len() < 2 {
246 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
247 "exec_at: directory and command are required",
248 ))));
249 }
250 let dir = args[0].display();
251 let cmd = args[1].display();
252 let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
253 let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
254 Ok(vm_output_to_value(output))
255}
256
257#[harn_builtin(
258 sig = "shell_at(dir: string, command: string) -> dict",
259 category = "process"
260)]
261fn shell_at_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
262 if args.len() < 2 {
263 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
264 "shell_at: directory and command string are required",
265 ))));
266 }
267 let dir = args[0].display();
268 let cmd = args[1].display();
269 if cmd.is_empty() {
270 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
271 "shell_at: command string is required",
272 ))));
273 }
274 let invocation = crate::shells::default_shell_invocation(&cmd)
275 .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
276 let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
277 Ok(vm_output_to_value(output))
278}
279
280#[harn_builtin(sig = "username(...args: any) -> string", category = "process")]
281fn username_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
282 let user = std::env::var("USER")
283 .or_else(|_| std::env::var("USERNAME"))
284 .unwrap_or_default();
285 Ok(VmValue::String(arcstr::ArcStr::from(user)))
286}
287
288#[harn_builtin(sig = "hostname() -> string", category = "process")]
289fn hostname_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
290 let name = std::env::var("HOSTNAME")
291 .or_else(|_| std::env::var("COMPUTERNAME"))
292 .or_else(|_| {
293 std::process::Command::new("hostname")
294 .output()
295 .ok()
296 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
297 .ok_or(std::env::VarError::NotPresent)
298 })
299 .unwrap_or_default();
300 Ok(VmValue::String(arcstr::ArcStr::from(name)))
301}
302
303#[harn_builtin(sig = "platform(...args: any) -> string", category = "process")]
304fn platform_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
305 let os = if cfg!(target_os = "macos") {
306 "darwin"
307 } else if cfg!(target_os = "linux") {
308 "linux"
309 } else if cfg!(target_os = "windows") {
310 "windows"
311 } else {
312 std::env::consts::OS
313 };
314 Ok(VmValue::String(arcstr::ArcStr::from(os)))
315}
316
317#[harn_builtin(sig = "arch() -> string", category = "process")]
318fn arch_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
319 Ok(VmValue::String(arcstr::ArcStr::from(
320 std::env::consts::ARCH,
321 )))
322}
323
324#[harn_builtin(sig = "home_dir() -> string", category = "process")]
325fn home_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
326 let home = crate::user_dirs::home_dir()
327 .map(|home| home.to_string_lossy().into_owned())
328 .unwrap_or_default();
329 Ok(VmValue::String(arcstr::ArcStr::from(home)))
330}
331
332#[harn_builtin(sig = "pid(...args: any) -> int", category = "process")]
333fn pid_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
334 Ok(VmValue::Int(std::process::id() as i64))
335}
336
337#[harn_builtin(sig = "date_iso() -> string", category = "process")]
338fn date_iso_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
339 let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
346 let dt: chrono::DateTime<chrono::Utc> = now.into();
347 Ok(VmValue::String(arcstr::ArcStr::from(
348 dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
349 )))
350}
351
352#[harn_builtin(sig = "cwd() -> string", category = "process")]
353fn cwd_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
354 let dir = current_execution_context()
355 .and_then(|context| context.cwd)
356 .or_else(|| {
357 std::env::current_dir()
358 .ok()
359 .map(|p| p.to_string_lossy().into_owned())
360 })
361 .unwrap_or_default();
362 Ok(VmValue::String(arcstr::ArcStr::from(dir)))
363}
364
365#[harn_builtin(sig = "execution_root() -> string", category = "process")]
366fn execution_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
367 Ok(VmValue::String(arcstr::ArcStr::from(
368 execution_root_path().to_string_lossy().into_owned(),
369 )))
370}
371
372#[harn_builtin(sig = "asset_root() -> string", category = "process")]
373fn asset_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
374 Ok(VmValue::String(arcstr::ArcStr::from(
375 asset_root_path().to_string_lossy().into_owned(),
376 )))
377}
378
379#[harn_builtin(sig = "runtime_paths() -> dict", category = "process")]
380fn runtime_paths_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
381 let runtime_base = runtime_root_base();
382 let mut paths = BTreeMap::new();
383 paths.put_str("execution_root", execution_root_path().to_string_lossy());
384 paths.put_str("asset_root", asset_root_path().to_string_lossy());
385 paths.put_str(
386 "state_root",
387 crate::runtime_paths::state_root(&runtime_base).to_string_lossy(),
388 );
389 paths.put_str(
390 "run_root",
391 crate::runtime_paths::run_root(&runtime_base).to_string_lossy(),
392 );
393 paths.put_str(
394 "worktree_root",
395 crate::runtime_paths::worktree_root(&runtime_base).to_string_lossy(),
396 );
397 Ok(VmValue::dict(paths))
398}
399
400#[harn_builtin(sig = "spawn_captured(opts: dict) -> dict", category = "process")]
401fn spawn_captured_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
402 spawn_captured_value(args)
403}
404
405#[harn_builtin(sig = "term_width() -> int", category = "process")]
415fn term_width_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
416 Ok(VmValue::Int(crate::term::width() as i64))
417}
418
419#[harn_builtin(sig = "term_height() -> int", category = "process")]
420fn term_height_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
421 Ok(VmValue::Int(crate::term::height() as i64))
422}
423
424const PROCESS_BUILTINS: &[&VmBuiltinDef] = &[
425 &ENV_IMPL_DEF,
426 &ENV_OR_IMPL_DEF,
427 &EXIT_IMPL_DEF,
428 &EXEC_IMPL_DEF,
429 &EXEC_OPTS_IMPL_DEF,
430 &SHELL_IMPL_DEF,
431 &EXEC_AT_IMPL_DEF,
432 &EXEC_AT_OPTS_IMPL_DEF,
433 &SHELL_AT_IMPL_DEF,
434 &USERNAME_IMPL_DEF,
435 &HOSTNAME_IMPL_DEF,
436 &PLATFORM_IMPL_DEF,
437 &ARCH_IMPL_DEF,
438 &HOME_DIR_IMPL_DEF,
439 &PID_IMPL_DEF,
440 &DATE_ISO_IMPL_DEF,
441 &CWD_IMPL_DEF,
442 &EXECUTION_ROOT_IMPL_DEF,
443 &ASSET_ROOT_IMPL_DEF,
444 &RUNTIME_PATHS_IMPL_DEF,
445 &SPAWN_CAPTURED_IMPL_DEF,
446 &TERM_WIDTH_IMPL_DEF,
447 &TERM_HEIGHT_IMPL_DEF,
448];
449
450pub(crate) fn spawn_captured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
455 let opts = match args.first() {
456 Some(VmValue::Dict(opts)) => opts.clone(),
457 _ => {
458 return Err(VmError::Runtime(
459 "spawn_captured: options dict is required".to_string(),
460 ));
461 }
462 };
463 let cmd = match opts.get("cmd").map(|v| v.display()).unwrap_or_default() {
464 s if s.is_empty() => {
465 return Err(VmError::Runtime(
466 "spawn_captured: opts.cmd is required".to_string(),
467 ));
468 }
469 s => s,
470 };
471 let cmd_args: Vec<String> = match opts.get("args") {
472 Some(VmValue::List(items)) => items.iter().map(|v| v.display()).collect(),
473 None | Some(VmValue::Nil) => Vec::new(),
474 Some(other) => {
475 return Err(VmError::Runtime(format!(
476 "spawn_captured: opts.args must be a list of strings, got {}",
477 other.type_name()
478 )));
479 }
480 };
481 let cwd = opts
482 .get("cwd")
483 .map(|v| v.display())
484 .filter(|s| !s.is_empty());
485 let env_overrides: Vec<(String, String)> = match opts.get("env") {
486 Some(VmValue::Dict(env)) => env
487 .iter()
488 .map(|(k, v)| (k.to_string(), v.display()))
489 .collect(),
490 None | Some(VmValue::Nil) => Vec::new(),
491 Some(other) => {
492 return Err(VmError::Runtime(format!(
493 "spawn_captured: opts.env must be a dict, got {}",
494 other.type_name()
495 )));
496 }
497 };
498 let stdin_bytes: Option<Vec<u8>> = match opts.get("stdin") {
499 Some(VmValue::Bytes(bytes)) => Some(bytes.as_slice().to_vec()),
500 Some(VmValue::String(s)) => Some(s.as_bytes().to_vec()),
501 None | Some(VmValue::Nil) => None,
502 Some(other) => {
503 return Err(VmError::Runtime(format!(
504 "spawn_captured: opts.stdin must be string or bytes, got {}",
505 other.type_name()
506 )));
507 }
508 };
509 let timeout = opts
510 .get("timeout_ms")
511 .and_then(|v| v.as_int())
512 .filter(|n| *n > 0)
513 .map(|n| Duration::from_millis(n as u64));
514
515 let spawn = CapturedSpawn {
516 label: "spawn_captured",
517 cmd: &cmd,
518 args: &cmd_args,
519 cwd: cwd.as_deref(),
520 env: &env_overrides,
521 env_clear: false,
524 stdin: stdin_bytes,
525 timeout,
526 };
527 let CapturedRun {
528 output,
529 timed_out,
530 duration_ms,
531 } = run_captured_spawn(spawn)?;
532
533 let exit_code = if timed_out {
534 -1
535 } else {
536 output.status.code().unwrap_or(-1) as i64
537 };
538 let success = if timed_out {
539 false
540 } else {
541 output.status.success()
542 };
543 let mut result = BTreeMap::new();
544 result.insert("exit_code".to_string(), VmValue::Int(exit_code));
545 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
546 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
547 result.insert("duration_ms".to_string(), VmValue::Int(duration_ms));
548 result.insert("success".to_string(), VmValue::Bool(success));
549 result.insert("timed_out".to_string(), VmValue::Bool(timed_out));
550 Ok(VmValue::dict(result))
551}
552
553struct CapturedSpawn<'a> {
558 label: &'static str,
559 cmd: &'a str,
560 args: &'a [String],
561 cwd: Option<&'a str>,
562 env: &'a [(String, String)],
563 env_clear: bool,
564 stdin: Option<Vec<u8>>,
565 timeout: Option<Duration>,
566}
567
568struct CapturedRun {
570 output: std::process::Output,
571 timed_out: bool,
572 duration_ms: i64,
573}
574
575fn run_captured_spawn(spec: CapturedSpawn<'_>) -> Result<CapturedRun, VmError> {
586 let label = spec.label;
587 let mut command = std::process::Command::new(spec.cmd);
588 command.args(spec.args);
589 if let Some(cwd) = spec.cwd {
590 command.current_dir(cwd);
591 }
592 if spec.env_clear {
593 command.env_clear();
594 }
595 for (key, value) in spec.env {
596 command.env(key, value);
597 }
598 command.stdout(Stdio::piped()).stderr(Stdio::piped());
599 if spec.stdin.is_some() {
600 command.stdin(Stdio::piped());
601 } else {
602 command.stdin(Stdio::null());
603 }
604 crate::op_interrupt::configure_kill_group(&mut command);
605
606 let started = Instant::now();
607 let cmd = spec.cmd;
608 let mut child = command.spawn().map_err(|error| {
609 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
610 "{label}: failed to spawn '{cmd}': {error}"
611 ))))
612 })?;
613
614 if let (Some(payload), Some(mut stdin)) = (spec.stdin, child.stdin.take()) {
615 let _ = stdin.write_all(&payload);
617 }
618
619 let rx_out = child
623 .stdout
624 .take()
625 .map(crate::op_interrupt::spawn_pipe_drain);
626 let rx_err = child
627 .stderr
628 .take()
629 .map(crate::op_interrupt::spawn_pipe_drain);
630
631 let child_pid = child.id();
632 let wait_end = crate::op_interrupt::wait_child_interruptible(&mut child, spec.timeout)
633 .map_err(|error| {
634 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
635 "{label}: wait failed: {error}"
636 ))))
637 })?;
638 let (status, timed_out, killed) = match wait_end {
639 crate::op_interrupt::ChildWait::Exited(status) => (status, false, false),
640 crate::op_interrupt::ChildWait::TimedOut => {
641 (std::process::ExitStatus::default(), true, true)
642 }
643 crate::op_interrupt::ChildWait::Interrupted(status) => {
647 (status.unwrap_or_default(), false, true)
648 }
649 };
650
651 let stdout = rx_out
652 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
653 .unwrap_or_default();
654 let stderr = rx_err
655 .map(|rx| crate::op_interrupt::drain_captured_pipe(&rx, killed, child_pid))
656 .unwrap_or_default();
657
658 Ok(CapturedRun {
659 output: std::process::Output {
660 status,
661 stdout,
662 stderr,
663 },
664 timed_out,
665 duration_ms: started.elapsed().as_millis() as i64,
666 })
667}
668
669#[derive(Default)]
672struct ExecOptions {
673 env: Vec<(String, String)>,
674 env_clear: bool,
675 cwd: Option<String>,
676 timeout: Option<Duration>,
677}
678
679fn exec_options(label: &str, options: Option<&VmValue>) -> Result<ExecOptions, VmError> {
686 let opts = match options {
687 None | Some(VmValue::Nil) => return Ok(ExecOptions::default()),
688 Some(VmValue::Dict(opts)) => opts.clone(),
689 Some(other) => {
690 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
691 format!("{label}: options must be a dict, got {}", other.type_name()),
692 ))));
693 }
694 };
695 let env: Vec<(String, String)> = match opts.get("env") {
696 Some(VmValue::Dict(env)) => env
697 .iter()
698 .map(|(k, v)| (k.to_string(), v.display()))
699 .collect(),
700 None | Some(VmValue::Nil) => Vec::new(),
701 Some(other) => {
702 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
703 format!(
704 "{label}: options.env must be a dict, got {}",
705 other.type_name()
706 ),
707 ))));
708 }
709 };
710 let env_clear = match opts.get("env_mode").map(|v| v.display()).as_deref() {
711 None | Some("merge") => false,
712 Some("replace") => true,
713 Some(other) => {
714 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
715 format!(
716 "{label}: options.env_mode must be \"merge\" or \"replace\", got {other:?}"
717 ),
718 ))));
719 }
720 };
721 let cwd = opts
722 .get("cwd")
723 .map(|v| v.display())
724 .filter(|s| !s.is_empty());
725 let timeout = opts
728 .get("timeout")
729 .or_else(|| opts.get("timeout_ms"))
730 .and_then(|v| v.as_int())
731 .filter(|n| *n > 0)
732 .map(|n| Duration::from_millis(n as u64));
733 Ok(ExecOptions {
734 env,
735 env_clear,
736 cwd,
737 timeout,
738 })
739}
740
741fn captured_run_to_value(run: &CapturedRun) -> VmValue {
745 let status = if run.timed_out {
746 -1
747 } else {
748 run.output.status.code().unwrap_or(-1) as i64
749 };
750 let success = !run.timed_out && run.output.status.success();
751 let mut result = BTreeMap::new();
752 result.put_str(
753 "stdout",
754 String::from_utf8_lossy(&run.output.stdout).as_ref(),
755 );
756 result.put_str(
757 "stderr",
758 String::from_utf8_lossy(&run.output.stderr).as_ref(),
759 );
760 result.insert("status".to_string(), VmValue::Int(status));
761 result.insert("success".to_string(), VmValue::Bool(success));
762 result.insert("timed_out".to_string(), VmValue::Bool(run.timed_out));
763 result.insert("duration_ms".to_string(), VmValue::Int(run.duration_ms));
764 VmValue::dict(result)
765}
766
767#[harn_builtin(
768 sig = "exec_opts(command: list, options: dict?) -> dict",
769 category = "process"
770)]
771fn exec_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
772 let command = exec_opts_command("exec_opts", args.first())?;
773 let opts = exec_options("exec_opts", args.get(1))?;
774 let run = run_captured_spawn(CapturedSpawn {
775 label: "exec_opts",
776 cmd: &command[0],
777 args: &command[1..],
778 cwd: opts.cwd.as_deref(),
779 env: &opts.env,
780 env_clear: opts.env_clear,
781 stdin: None,
782 timeout: opts.timeout,
783 })?;
784 Ok(captured_run_to_value(&run))
785}
786
787#[harn_builtin(
788 sig = "exec_at_opts(dir: string, command: list, options: dict?) -> dict",
789 category = "process"
790)]
791fn exec_at_opts_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
792 let dir = match args.first() {
793 Some(value) if !value.display().is_empty() => value.display(),
794 _ => {
795 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
796 "exec_at_opts: directory is required",
797 ))));
798 }
799 };
800 let command = exec_opts_command("exec_at_opts", args.get(1))?;
801 let opts = exec_options("exec_at_opts", args.get(2))?;
802 let resolved_cwd = opts.cwd.unwrap_or(dir);
805 let run = run_captured_spawn(CapturedSpawn {
806 label: "exec_at_opts",
807 cmd: &command[0],
808 args: &command[1..],
809 cwd: Some(resolved_cwd.as_str()),
810 env: &opts.env,
811 env_clear: opts.env_clear,
812 stdin: None,
813 timeout: opts.timeout,
814 })?;
815 Ok(captured_run_to_value(&run))
816}
817
818fn exec_opts_command(label: &str, value: Option<&VmValue>) -> Result<Vec<String>, VmError> {
821 let items = match value {
822 Some(VmValue::List(items)) => items,
823 _ => {
824 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
825 format!("{label}: command must be a non-empty list of strings"),
826 ))));
827 }
828 };
829 let command: Vec<String> = items.iter().map(|v| v.display()).collect();
830 if command.is_empty() || command[0].is_empty() {
831 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
832 format!("{label}: command must be a non-empty list of strings"),
833 ))));
834 }
835 Ok(command)
836}
837
838pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
840 let mut dir = base.to_path_buf();
841 loop {
842 if dir.join("harn.toml").exists() {
843 return Some(dir);
844 }
845 if !dir.pop() {
846 return None;
847 }
848 }
849}
850
851pub(crate) fn register_path_builtins(vm: &mut Vm) {
853 for def in PATH_BUILTINS {
854 vm.register_builtin_def(def);
855 }
856}
857
858#[harn_builtin(sig = "source_dir(...args: any) -> string", category = "process")]
859fn source_dir_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
860 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
861 match dir {
862 Some(d) => Ok(VmValue::String(arcstr::ArcStr::from(
863 d.to_string_lossy().into_owned(),
864 ))),
865 None => {
866 let cwd = std::env::current_dir()
867 .map(|p| p.to_string_lossy().into_owned())
868 .unwrap_or_default();
869 Ok(VmValue::String(arcstr::ArcStr::from(cwd)))
870 }
871 }
872}
873
874#[harn_builtin(sig = "project_root() -> string?", category = "process")]
875fn project_root_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
876 let base = current_execution_context()
877 .and_then(|context| context.cwd.map(PathBuf::from))
878 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
879 .or_else(|| std::env::current_dir().ok())
880 .unwrap_or_else(|| PathBuf::from("."));
881 match find_project_root(&base) {
882 Some(root) => Ok(VmValue::String(arcstr::ArcStr::from(
883 root.to_string_lossy().into_owned(),
884 ))),
885 None => Ok(VmValue::Nil),
886 }
887}
888
889const PATH_BUILTINS: &[&VmBuiltinDef] = &[&SOURCE_DIR_IMPL_DEF, &PROJECT_ROOT_IMPL_DEF];
890
891fn vm_output_to_value(output: std::process::Output) -> VmValue {
892 let mut result = BTreeMap::new();
893 result.put_str("stdout", String::from_utf8_lossy(&output.stdout).as_ref());
894 result.put_str("stderr", String::from_utf8_lossy(&output.stderr).as_ref());
895 result.insert(
896 "status".to_string(),
897 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
898 );
899 result.insert(
900 "success".to_string(),
901 VmValue::Bool(output.status.success()),
902 );
903 VmValue::dict(result)
904}
905
906fn exec_command(
907 dir: Option<&str>,
908 cmd: &str,
909 args: &[String],
910) -> Result<std::process::Output, VmError> {
911 let config = process_command_config(dir)?;
912 crate::stdlib::sandbox::command_output(cmd, args, &config)
913 .map_err(|error| prefix_process_error(error, "exec"))
914}
915
916#[cfg(test)]
917fn exec_shell(
918 dir: Option<&str>,
919 shell: &str,
920 flag: &str,
921 script: &str,
922) -> Result<std::process::Output, VmError> {
923 let args = vec![flag.to_string(), script.to_string()];
924 exec_shell_args(dir, shell, &args)
925}
926
927fn exec_shell_args(
928 dir: Option<&str>,
929 shell: &str,
930 args: &[String],
931) -> Result<std::process::Output, VmError> {
932 let config = process_command_config(dir)?;
933 crate::stdlib::sandbox::command_output(shell, args, &config)
934 .map_err(|error| prefix_process_error(error, "shell"))
935}
936
937fn process_command_config(
938 dir: Option<&str>,
939) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
940 let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
941 stdin_null: true,
942 ..Default::default()
943 };
944 if let Some(dir) = dir {
945 let resolved = resolve_command_dir(dir);
946 crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
947 config.cwd = Some(resolved);
948 } else if let Some(context) = current_execution_context() {
949 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
950 crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
951 config.cwd = Some(std::path::PathBuf::from(cwd));
952 }
953 if !context.env.is_empty() {
954 config.env.extend(context.env);
955 }
956 }
957 if let Some(value) = env_override(HARN_REPLAY_ENV) {
958 config.env.push((HARN_REPLAY_ENV.to_string(), value));
959 }
960 Ok(config)
961}
962
963fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
964 match error {
965 VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(
966 arcstr::ArcStr::from(format!("{prefix} failed: {message}")),
967 )),
968 other => other,
969 }
970}
971
972fn resolve_command_dir(dir: &str) -> PathBuf {
973 let candidate = PathBuf::from(dir);
974 if candidate.is_absolute() {
975 return candidate;
976 }
977 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
978 return PathBuf::from(cwd).join(candidate);
979 }
980 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
981 return source_dir.join(candidate);
982 }
983 candidate
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989
990 struct RuntimePathsEnvGuard {
991 state: Option<String>,
992 run: Option<String>,
993 worktree: Option<String>,
994 }
995
996 impl RuntimePathsEnvGuard {
997 fn capture() -> Self {
998 Self {
999 state: std::env::var(crate::runtime_paths::HARN_STATE_DIR_ENV).ok(),
1000 run: std::env::var(crate::runtime_paths::HARN_RUN_DIR_ENV).ok(),
1001 worktree: std::env::var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV).ok(),
1002 }
1003 }
1004 }
1005
1006 impl Drop for RuntimePathsEnvGuard {
1007 fn drop(&mut self) {
1008 match self.state.as_deref() {
1009 Some(value) => std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, value),
1010 None => std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV),
1011 }
1012 match self.run.as_deref() {
1013 Some(value) => std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, value),
1014 None => std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV),
1015 }
1016 match self.worktree.as_deref() {
1017 Some(value) => {
1018 std::env::set_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV, value);
1019 }
1020 None => std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV),
1021 }
1022 }
1023 }
1024
1025 #[test]
1026 fn lexically_collapse_resolves_sibling_walk() {
1027 let path = PathBuf::from("/tmp/project/tests/../fixtures/x.json");
1028 let collapsed = lexically_collapse(&path).expect("sibling walk");
1029 assert_eq!(collapsed, PathBuf::from("/tmp/project/fixtures/x.json"));
1030 }
1031
1032 #[test]
1033 fn lexically_collapse_blocks_escape_past_root() {
1034 let path = PathBuf::from("/app/../../etc/passwd");
1037 assert!(lexically_collapse(&path).is_none());
1038 }
1039
1040 #[test]
1041 fn lexically_collapse_strips_curdir() {
1042 let path = PathBuf::from("/app/./logs/today.txt");
1043 let collapsed = lexically_collapse(&path).expect("curdir is benign");
1044 assert_eq!(collapsed, PathBuf::from("/app/logs/today.txt"));
1045 }
1046
1047 #[test]
1048 fn resolve_source_relative_path_blocks_obvious_escape() {
1049 let dir =
1050 std::env::temp_dir().join(format!("harn-process-escape-{}", uuid::Uuid::now_v7()));
1051 std::fs::create_dir_all(&dir).unwrap();
1052 set_thread_source_dir(&dir);
1053 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
1054 cwd: Some(dir.to_string_lossy().into_owned()),
1055 source_dir: Some(dir.to_string_lossy().into_owned()),
1056 env: BTreeMap::new(),
1057 adapter: None,
1058 repo_path: None,
1059 worktree_path: None,
1060 branch: None,
1061 base_ref: None,
1062 cleanup: None,
1063 }));
1064 let resolved = resolve_source_relative_path("../../../../../../../../etc/passwd");
1068 assert!(
1069 resolved
1070 .to_string_lossy()
1071 .contains("__harn_rejected_parent_dir_traversal__"),
1072 "expected rejection sentinel, got {resolved:?}"
1073 );
1074 reset_process_state();
1075 let _ = std::fs::remove_dir_all(&dir);
1076 }
1077
1078 #[test]
1079 fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
1080 let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
1081 std::fs::create_dir_all(&dir).unwrap();
1082 let current_dir = std::env::current_dir().unwrap();
1083 set_thread_source_dir(&dir);
1084 let resolved = resolve_source_relative_path("templates/prompt.txt");
1085 assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
1086 reset_process_state();
1087 let _ = std::fs::remove_dir_all(&dir);
1088 }
1089
1090 #[test]
1091 fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
1092 let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
1093 let source_dir =
1094 std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
1095 std::fs::create_dir_all(&cwd).unwrap();
1096 std::fs::create_dir_all(&source_dir).unwrap();
1097 set_thread_source_dir(&source_dir);
1098 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
1099 cwd: Some(cwd.to_string_lossy().into_owned()),
1100 source_dir: Some(source_dir.to_string_lossy().into_owned()),
1101 env: BTreeMap::new(),
1102 adapter: None,
1103 repo_path: None,
1104 worktree_path: None,
1105 branch: None,
1106 base_ref: None,
1107 cleanup: None,
1108 }));
1109 let resolved = resolve_source_relative_path("templates/prompt.txt");
1110 assert_eq!(resolved, cwd.join("templates/prompt.txt"));
1111 reset_process_state();
1112 let _ = std::fs::remove_dir_all(&cwd);
1113 let _ = std::fs::remove_dir_all(&source_dir);
1114 }
1115
1116 #[test]
1117 fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
1118 let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
1119 let source_dir =
1120 std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
1121 std::fs::create_dir_all(&cwd).unwrap();
1122 std::fs::create_dir_all(&source_dir).unwrap();
1123 set_thread_source_dir(&source_dir);
1124 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
1125 cwd: Some(cwd.to_string_lossy().into_owned()),
1126 source_dir: Some(source_dir.to_string_lossy().into_owned()),
1127 env: BTreeMap::new(),
1128 adapter: None,
1129 repo_path: None,
1130 worktree_path: None,
1131 branch: None,
1132 base_ref: None,
1133 cleanup: None,
1134 }));
1135 let resolved = resolve_source_asset_path("templates/prompt.txt");
1136 assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
1137 reset_process_state();
1138 let _ = std::fs::remove_dir_all(&cwd);
1139 let _ = std::fs::remove_dir_all(&source_dir);
1140 }
1141
1142 #[test]
1143 fn set_thread_source_dir_absolutizes_relative_paths() {
1144 reset_process_state();
1145 let current_dir = std::env::current_dir().unwrap();
1146 set_thread_source_dir(std::path::Path::new("scripts"));
1147 assert_eq!(source_root_path(), current_dir.join("scripts"));
1148 reset_process_state();
1149 }
1150
1151 #[test]
1152 fn exec_context_sets_default_cwd_and_env() {
1153 let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
1154 std::fs::create_dir_all(&dir).unwrap();
1155 std::fs::write(dir.join("marker.txt"), "ok").unwrap();
1156 set_thread_execution_context(Some(RunExecutionRecord {
1157 cwd: Some(dir.to_string_lossy().into_owned()),
1158 env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
1159 ..Default::default()
1160 }));
1161 let output = exec_shell(
1162 None,
1163 "sh",
1164 "-c",
1165 "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
1166 )
1167 .unwrap();
1168 assert!(output.status.success());
1169 assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
1170 reset_process_state();
1171 let _ = std::fs::remove_dir_all(&dir);
1172 }
1173
1174 #[test]
1175 fn exec_at_resolves_relative_to_execution_cwd() {
1176 let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
1177 std::fs::create_dir_all(dir.join("nested")).unwrap();
1178 std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
1179 set_thread_execution_context(Some(RunExecutionRecord {
1180 cwd: Some(dir.to_string_lossy().into_owned()),
1181 ..Default::default()
1182 }));
1183 let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
1184 assert!(output.status.success());
1185 reset_process_state();
1186 let _ = std::fs::remove_dir_all(&dir);
1187 }
1188
1189 #[test]
1190 fn runtime_paths_uses_configurable_state_roots() {
1191 let _runtime_paths_env_lock = crate::runtime_paths::test_env_lock()
1192 .lock()
1193 .unwrap_or_else(|poisoned| poisoned.into_inner());
1194 let _env_guard = RuntimePathsEnvGuard::capture();
1195 let base =
1196 std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
1197 std::fs::create_dir_all(&base).unwrap();
1198 std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
1199 std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
1200 std::env::set_var(
1201 crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
1202 ".custom-worktrees",
1203 );
1204 set_thread_execution_context(Some(RunExecutionRecord {
1205 cwd: Some(base.to_string_lossy().into_owned()),
1206 ..Default::default()
1207 }));
1208
1209 let mut vm = crate::vm::Vm::new();
1210 register_process_builtins(&mut vm);
1211 let mut out = String::new();
1212 let builtin = vm
1213 .builtins
1214 .get("runtime_paths")
1215 .expect("runtime_paths builtin");
1216 let paths = match builtin(&[], &mut out).unwrap() {
1217 VmValue::Dict(map) => map,
1218 other => panic!("expected dict, got {other:?}"),
1219 };
1220 assert_eq!(
1221 paths.get("state_root").unwrap().display(),
1222 base.join(".custom-harn").display().to_string()
1223 );
1224 assert_eq!(
1225 paths.get("run_root").unwrap().display(),
1226 base.join(".custom-runs").display().to_string()
1227 );
1228 assert_eq!(
1229 paths.get("worktree_root").unwrap().display(),
1230 base.join(".custom-worktrees").display().to_string()
1231 );
1232
1233 reset_process_state();
1234 let _ = std::fs::remove_dir_all(&base);
1235 }
1236
1237 #[cfg(unix)]
1238 fn exec_opts_list(items: &[&str]) -> VmValue {
1239 VmValue::List(std::sync::Arc::new(
1240 items
1241 .iter()
1242 .map(|s| VmValue::String(arcstr::ArcStr::from(*s)))
1243 .collect(),
1244 ))
1245 }
1246
1247 #[cfg(unix)]
1248 fn exec_opts_dict(pairs: &[(&str, VmValue)]) -> VmValue {
1249 VmValue::dict(
1250 pairs
1251 .iter()
1252 .map(|(k, v)| (crate::value::intern_key(k), v.clone()))
1253 .collect::<crate::value::DictMap>(),
1254 )
1255 }
1256
1257 #[cfg(unix)]
1258 #[test]
1259 fn exec_opts_merges_env_with_parent_by_default() {
1260 std::env::set_var("HARN_EXEC_OPTS_PARENT", "from-parent");
1261 let env = exec_opts_dict(&[("CHILD", VmValue::String(arcstr::ArcStr::from("from-child")))]);
1262 let args = vec![
1263 exec_opts_list(&[
1264 "/bin/sh",
1265 "-c",
1266 "printf '%s|%s' \"$HARN_EXEC_OPTS_PARENT\" \"$CHILD\"",
1267 ]),
1268 exec_opts_dict(&[("env", env)]),
1269 ];
1270 let mut out = String::new();
1271 let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1272 let dict = result.as_dict().expect("dict");
1273 assert_eq!(
1274 dict.get("stdout").unwrap().display(),
1275 "from-parent|from-child"
1276 );
1277 assert!(matches!(dict.get("success"), Some(VmValue::Bool(true))));
1278 std::env::remove_var("HARN_EXEC_OPTS_PARENT");
1279 }
1280
1281 #[cfg(unix)]
1282 #[test]
1283 fn exec_opts_replace_env_clears_parent() {
1284 std::env::set_var("HARN_EXEC_OPTS_PARENT2", "from-parent");
1285 let env = exec_opts_dict(&[("CHILD", VmValue::String(arcstr::ArcStr::from("from-child")))]);
1286 let args = vec![
1287 exec_opts_list(&[
1288 "/bin/sh",
1289 "-c",
1290 "printf '%s|%s' \"$HARN_EXEC_OPTS_PARENT2\" \"$CHILD\"",
1291 ]),
1292 exec_opts_dict(&[
1293 ("env", env),
1294 ("env_mode", VmValue::String(arcstr::ArcStr::from("replace"))),
1295 ]),
1296 ];
1297 let mut out = String::new();
1298 let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1299 let dict = result.as_dict().expect("dict");
1300 assert_eq!(dict.get("stdout").unwrap().display(), "|from-child");
1301 std::env::remove_var("HARN_EXEC_OPTS_PARENT2");
1302 }
1303
1304 #[cfg(unix)]
1305 #[test]
1306 fn exec_at_opts_honors_directory() {
1307 let dir = std::env::temp_dir().join(format!("harn-exec-opts-cwd-{}", uuid::Uuid::now_v7()));
1308 std::fs::create_dir_all(&dir).unwrap();
1309 let args = vec![
1310 VmValue::String(arcstr::ArcStr::from(dir.to_string_lossy().into_owned())),
1311 exec_opts_list(&["/bin/sh", "-c", "pwd -P"]),
1312 ];
1313 let mut out = String::new();
1314 let result = exec_at_opts_impl(&args, &mut out).expect("exec_at_opts result");
1315 let dict = result.as_dict().expect("dict");
1316 let want = std::fs::canonicalize(&dir).unwrap();
1318 let got = dict.get("stdout").unwrap().display();
1319 assert_eq!(got.trim(), want.to_string_lossy());
1320 let _ = std::fs::remove_dir_all(&dir);
1321 }
1322
1323 #[cfg(unix)]
1324 #[test]
1325 fn exec_opts_enforces_timeout() {
1326 let args = vec![
1327 exec_opts_list(&["/bin/sh", "-c", "sleep 5"]),
1328 exec_opts_dict(&[("timeout", VmValue::Int(50))]),
1329 ];
1330 let mut out = String::new();
1331 let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1332 let dict = result.as_dict().expect("dict");
1333 assert!(
1334 matches!(dict.get("timed_out"), Some(VmValue::Bool(true))),
1335 "command exceeding timeout must report timed_out"
1336 );
1337 assert!(matches!(dict.get("success"), Some(VmValue::Bool(false))));
1338 }
1339
1340 #[cfg(unix)]
1341 #[test]
1342 fn exec_opts_rejects_empty_command() {
1343 let args = vec![exec_opts_list(&[])];
1344 let mut out = String::new();
1345 assert!(exec_opts_impl(&args, &mut out).is_err());
1346 let bad = vec![VmValue::String(arcstr::ArcStr::from("not-a-list"))];
1347 assert!(exec_opts_impl(&bad, &mut out).is_err());
1348 }
1349
1350 #[cfg(unix)]
1351 #[test]
1352 fn exec_opts_interrupt_kills_child_process_group() {
1353 use std::sync::atomic::AtomicBool;
1354 use std::sync::Arc;
1355
1356 let cancel = Arc::new(AtomicBool::new(false));
1360 let _guard = crate::op_interrupt::install(Some(Arc::clone(&cancel)), None);
1361 let flipper = {
1362 let cancel = Arc::clone(&cancel);
1363 std::thread::spawn(move || {
1364 std::thread::sleep(Duration::from_millis(200));
1365 cancel.store(true, std::sync::atomic::Ordering::SeqCst);
1366 })
1367 };
1368
1369 let started = Instant::now();
1370 let args = vec![exec_opts_list(&[
1371 "/bin/sh",
1372 "-c",
1373 "echo started; sleep 30 & wait",
1375 ])];
1376 let mut out = String::new();
1377 let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
1378 flipper.join().unwrap();
1379
1380 assert!(
1381 started.elapsed() < Duration::from_secs(10),
1382 "interrupt must preempt the 30s child, took {:?}",
1383 started.elapsed()
1384 );
1385 let dict = result.as_dict().expect("dict");
1386 assert!(matches!(dict.get("success"), Some(VmValue::Bool(false))));
1387 assert!(matches!(dict.get("timed_out"), Some(VmValue::Bool(false))));
1388 }
1389}