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