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