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