1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::io::Write as _;
4use std::path::PathBuf;
5use std::process::Stdio;
6use std::rc::Rc;
7use std::sync::mpsc;
8use std::time::{Duration, Instant};
9
10use crate::orchestration::RunExecutionRecord;
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 reset_process_state() {
45 VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
46 VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
47}
48
49pub fn execution_root_path() -> PathBuf {
50 current_execution_context()
51 .and_then(|context| context.cwd.map(PathBuf::from))
52 .or_else(|| std::env::current_dir().ok())
53 .unwrap_or_else(|| PathBuf::from("."))
54}
55
56pub fn source_root_path() -> PathBuf {
57 VM_SOURCE_DIR
58 .with(|sd| sd.borrow().clone())
59 .or_else(|| {
60 current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
61 })
62 .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
63 .or_else(|| std::env::current_dir().ok())
64 .unwrap_or_else(|| PathBuf::from("."))
65}
66
67pub fn asset_root_path() -> PathBuf {
68 source_root_path()
69}
70
71fn env_override(name: &str) -> Option<String> {
72 (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
73 .then(|| "1".to_string())
74}
75
76pub(crate) fn read_env_value(name: &str) -> Option<String> {
77 env_override(name)
78 .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
79 .or_else(|| std::env::var(name).ok())
80}
81
82pub fn runtime_root_base() -> PathBuf {
83 find_project_root(&execution_root_path())
84 .or_else(|| find_project_root(&source_root_path()))
85 .unwrap_or_else(source_root_path)
86}
87
88fn lexically_collapse(path: &std::path::Path) -> Option<PathBuf> {
93 use std::path::Component;
94 let mut out: Vec<Component> = Vec::new();
95 for component in path.components() {
96 match component {
97 Component::CurDir => {}
98 Component::ParentDir => {
99 let popped = out.pop();
100 if !matches!(popped, Some(Component::Normal(_))) {
101 return None;
102 }
103 }
104 other => out.push(other),
105 }
106 }
107 Some(out.iter().collect())
108}
109
110pub fn resolve_source_relative_path(path: &str) -> PathBuf {
111 let candidate = PathBuf::from(path);
112 if candidate.is_absolute() {
113 return candidate;
114 }
115 let root = execution_root_path();
116 let joined = root.join(&candidate);
117 if path_escapes_project_root(&joined) {
124 return root.join("__harn_rejected_parent_dir_traversal__");
125 }
126 joined
127}
128
129pub fn resolve_source_asset_path(path: &str) -> PathBuf {
130 let candidate = PathBuf::from(path);
131 if candidate.is_absolute() {
132 return candidate;
133 }
134 let root = asset_root_path();
135 let joined = root.join(&candidate);
136 if path_escapes_project_root(&joined) {
137 return root.join("__harn_rejected_parent_dir_traversal__");
138 }
139 joined
140}
141
142fn path_escapes_project_root(joined: &std::path::Path) -> bool {
156 lexically_collapse(joined).is_none()
157}
158
159pub(crate) fn register_process_builtins(vm: &mut Vm) {
160 vm.register_builtin("env", |args, _out| {
161 let name = args.first().map(|a| a.display()).unwrap_or_default();
162 if let Some(value) = read_env_value(&name) {
163 return Ok(VmValue::String(Rc::from(value)));
164 }
165 Ok(VmValue::Nil)
166 });
167
168 vm.register_builtin("env_or", |args, _out| {
169 let name = args.first().map(|a| a.display()).unwrap_or_default();
170 let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
171 if let Some(value) = read_env_value(&name) {
172 return Ok(VmValue::String(Rc::from(value)));
173 }
174 Ok(default)
175 });
176
177 vm.register_builtin("exit", |args, _out| {
181 let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
182 std::process::exit(code as i32);
183 });
184
185 vm.register_builtin("exec", |args, _out| {
186 if args.is_empty() {
187 return Err(VmError::Thrown(VmValue::String(Rc::from(
188 "exec: command is required",
189 ))));
190 }
191 let cmd = args[0].display();
192 let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
193 let output = exec_command(None, &cmd, &cmd_args)?;
194 Ok(vm_output_to_value(output))
195 });
196
197 vm.register_builtin("shell", |args, _out| {
198 let cmd = args.first().map(|a| a.display()).unwrap_or_default();
199 if cmd.is_empty() {
200 return Err(VmError::Thrown(VmValue::String(Rc::from(
201 "shell: command string is required",
202 ))));
203 }
204 let invocation = crate::shells::default_shell_invocation(&cmd)
205 .map_err(|error| VmError::Runtime(format!("shell: {error}")))?;
206 let output = exec_shell_args(None, &invocation.program, &invocation.args)?;
207 Ok(vm_output_to_value(output))
208 });
209
210 vm.register_builtin("exec_at", |args, _out| {
211 if args.len() < 2 {
212 return Err(VmError::Thrown(VmValue::String(Rc::from(
213 "exec_at: directory and command are required",
214 ))));
215 }
216 let dir = args[0].display();
217 let cmd = args[1].display();
218 let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
219 let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)?;
220 Ok(vm_output_to_value(output))
221 });
222
223 vm.register_builtin("shell_at", |args, _out| {
224 if args.len() < 2 {
225 return Err(VmError::Thrown(VmValue::String(Rc::from(
226 "shell_at: directory and command string are required",
227 ))));
228 }
229 let dir = args[0].display();
230 let cmd = args[1].display();
231 if cmd.is_empty() {
232 return Err(VmError::Thrown(VmValue::String(Rc::from(
233 "shell_at: command string is required",
234 ))));
235 }
236 let invocation = crate::shells::default_shell_invocation(&cmd)
237 .map_err(|error| VmError::Runtime(format!("shell_at: {error}")))?;
238 let output = exec_shell_args(Some(dir.as_str()), &invocation.program, &invocation.args)?;
239 Ok(vm_output_to_value(output))
240 });
241
242 vm.register_builtin("username", |_args, _out| {
245 let user = std::env::var("USER")
246 .or_else(|_| std::env::var("USERNAME"))
247 .unwrap_or_default();
248 Ok(VmValue::String(Rc::from(user)))
249 });
250
251 vm.register_builtin("hostname", |_args, _out| {
252 let name = std::env::var("HOSTNAME")
253 .or_else(|_| std::env::var("COMPUTERNAME"))
254 .or_else(|_| {
255 std::process::Command::new("hostname")
256 .output()
257 .ok()
258 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
259 .ok_or(std::env::VarError::NotPresent)
260 })
261 .unwrap_or_default();
262 Ok(VmValue::String(Rc::from(name)))
263 });
264
265 vm.register_builtin("platform", |_args, _out| {
266 let os = if cfg!(target_os = "macos") {
267 "darwin"
268 } else if cfg!(target_os = "linux") {
269 "linux"
270 } else if cfg!(target_os = "windows") {
271 "windows"
272 } else {
273 std::env::consts::OS
274 };
275 Ok(VmValue::String(Rc::from(os)))
276 });
277
278 vm.register_builtin("arch", |_args, _out| {
279 Ok(VmValue::String(Rc::from(std::env::consts::ARCH)))
280 });
281
282 vm.register_builtin("home_dir", |_args, _out| {
283 let home = std::env::var("HOME")
284 .or_else(|_| std::env::var("USERPROFILE"))
285 .unwrap_or_default();
286 Ok(VmValue::String(Rc::from(home)))
287 });
288
289 vm.register_builtin("pid", |_args, _out| {
290 Ok(VmValue::Int(std::process::id() as i64))
291 });
292
293 vm.register_builtin("date_iso", |_args, _out| {
294 let now = crate::clock_mock::leak_audit::wall_now("stdlib/date_iso");
301 let dt: chrono::DateTime<chrono::Utc> = now.into();
302 Ok(VmValue::String(Rc::from(
303 dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
304 )))
305 });
306
307 vm.register_builtin("cwd", |_args, _out| {
308 let dir = current_execution_context()
309 .and_then(|context| context.cwd)
310 .or_else(|| {
311 std::env::current_dir()
312 .ok()
313 .map(|p| p.to_string_lossy().into_owned())
314 })
315 .unwrap_or_default();
316 Ok(VmValue::String(Rc::from(dir)))
317 });
318
319 vm.register_builtin("execution_root", |_args, _out| {
320 Ok(VmValue::String(Rc::from(
321 execution_root_path().to_string_lossy().into_owned(),
322 )))
323 });
324
325 vm.register_builtin("asset_root", |_args, _out| {
326 Ok(VmValue::String(Rc::from(
327 asset_root_path().to_string_lossy().into_owned(),
328 )))
329 });
330
331 vm.register_builtin("runtime_paths", |_args, _out| {
332 let runtime_base = runtime_root_base();
333 let mut paths = BTreeMap::new();
334 paths.insert(
335 "execution_root".to_string(),
336 VmValue::String(Rc::from(
337 execution_root_path().to_string_lossy().into_owned(),
338 )),
339 );
340 paths.insert(
341 "asset_root".to_string(),
342 VmValue::String(Rc::from(asset_root_path().to_string_lossy().into_owned())),
343 );
344 paths.insert(
345 "state_root".to_string(),
346 VmValue::String(Rc::from(
347 crate::runtime_paths::state_root(&runtime_base)
348 .to_string_lossy()
349 .into_owned(),
350 )),
351 );
352 paths.insert(
353 "run_root".to_string(),
354 VmValue::String(Rc::from(
355 crate::runtime_paths::run_root(&runtime_base)
356 .to_string_lossy()
357 .into_owned(),
358 )),
359 );
360 paths.insert(
361 "worktree_root".to_string(),
362 VmValue::String(Rc::from(
363 crate::runtime_paths::worktree_root(&runtime_base)
364 .to_string_lossy()
365 .into_owned(),
366 )),
367 );
368 Ok(VmValue::Dict(Rc::new(paths)))
369 });
370
371 vm.register_builtin("spawn_captured", |args, _out| spawn_captured_value(args));
372
373 vm.register_builtin("term_width", |_args, _out| {
383 Ok(VmValue::Int(crate::term::width() as i64))
384 });
385 vm.register_builtin("term_height", |_args, _out| {
386 Ok(VmValue::Int(crate::term::height() as i64))
387 });
388}
389
390pub(crate) fn spawn_captured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
395 let opts = match args.first() {
396 Some(VmValue::Dict(opts)) => opts.clone(),
397 _ => {
398 return Err(VmError::Runtime(
399 "spawn_captured: options dict is required".to_string(),
400 ));
401 }
402 };
403 let cmd = match opts.get("cmd").map(|v| v.display()).unwrap_or_default() {
404 s if s.is_empty() => {
405 return Err(VmError::Runtime(
406 "spawn_captured: opts.cmd is required".to_string(),
407 ));
408 }
409 s => s,
410 };
411 let cmd_args: Vec<String> = match opts.get("args") {
412 Some(VmValue::List(items)) => items.iter().map(|v| v.display()).collect(),
413 None | Some(VmValue::Nil) => Vec::new(),
414 Some(other) => {
415 return Err(VmError::Runtime(format!(
416 "spawn_captured: opts.args must be a list of strings, got {}",
417 other.type_name()
418 )));
419 }
420 };
421 let cwd = opts
422 .get("cwd")
423 .map(|v| v.display())
424 .filter(|s| !s.is_empty());
425 let env_overrides: Vec<(String, String)> = match opts.get("env") {
426 Some(VmValue::Dict(env)) => env.iter().map(|(k, v)| (k.clone(), v.display())).collect(),
427 None | Some(VmValue::Nil) => Vec::new(),
428 Some(other) => {
429 return Err(VmError::Runtime(format!(
430 "spawn_captured: opts.env must be a dict, got {}",
431 other.type_name()
432 )));
433 }
434 };
435 let stdin_bytes: Option<Vec<u8>> = match opts.get("stdin") {
436 Some(VmValue::Bytes(bytes)) => Some(bytes.as_slice().to_vec()),
437 Some(VmValue::String(s)) => Some(s.as_bytes().to_vec()),
438 None | Some(VmValue::Nil) => None,
439 Some(other) => {
440 return Err(VmError::Runtime(format!(
441 "spawn_captured: opts.stdin must be string or bytes, got {}",
442 other.type_name()
443 )));
444 }
445 };
446 let timeout = opts
447 .get("timeout_ms")
448 .and_then(|v| v.as_int())
449 .filter(|n| *n > 0)
450 .map(|n| Duration::from_millis(n as u64));
451
452 let mut command = std::process::Command::new(&cmd);
453 command.args(&cmd_args);
454 if let Some(cwd) = cwd.as_ref() {
455 command.current_dir(cwd);
456 }
457 for (key, value) in &env_overrides {
458 command.env(key, value);
459 }
460 command.stdout(Stdio::piped()).stderr(Stdio::piped());
461 if stdin_bytes.is_some() {
462 command.stdin(Stdio::piped());
463 } else {
464 command.stdin(Stdio::null());
465 }
466
467 let started = Instant::now();
468 let mut child = command.spawn().map_err(|error| {
469 VmError::Thrown(VmValue::String(Rc::from(format!(
470 "spawn_captured: failed to spawn '{cmd}': {error}"
471 ))))
472 })?;
473
474 if let (Some(payload), Some(mut stdin)) = (stdin_bytes, child.stdin.take()) {
475 let _ = stdin.write_all(&payload);
477 }
478
479 let (output, timed_out) = match timeout {
480 None => match child.wait_with_output() {
481 Ok(output) => (output, false),
482 Err(error) => {
483 return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
484 "spawn_captured: wait failed: {error}"
485 )))));
486 }
487 },
488 Some(limit) => {
489 let deadline = started + limit;
490 let mut timed_out = false;
491 loop {
492 match child.try_wait() {
493 Ok(Some(_)) => break,
494 Ok(None) => {
495 if Instant::now() >= deadline {
496 let _ = child.kill();
497 let _ = child.wait();
498 timed_out = true;
499 break;
500 }
501 std::thread::sleep(Duration::from_millis(10));
502 }
503 Err(error) => {
504 return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
505 "spawn_captured: poll failed: {error}"
506 )))));
507 }
508 }
509 }
510 if timed_out {
511 let stdout_handle = child.stdout.take();
512 let stderr_handle = child.stderr.take();
513 let (tx_out, rx_out) = mpsc::channel::<Vec<u8>>();
514 let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();
515 if let Some(mut s) = stdout_handle {
516 std::thread::spawn(move || {
517 use std::io::Read as _;
518 let mut buf = Vec::new();
519 let _ = s.read_to_end(&mut buf);
520 let _ = tx_out.send(buf);
521 });
522 }
523 if let Some(mut s) = stderr_handle {
524 std::thread::spawn(move || {
525 use std::io::Read as _;
526 let mut buf = Vec::new();
527 let _ = s.read_to_end(&mut buf);
528 let _ = tx_err.send(buf);
529 });
530 }
531 let stdout = rx_out
532 .recv_timeout(Duration::from_millis(100))
533 .unwrap_or_default();
534 let stderr = rx_err
535 .recv_timeout(Duration::from_millis(100))
536 .unwrap_or_default();
537 (
538 std::process::Output {
539 status: std::process::ExitStatus::default(),
540 stdout,
541 stderr,
542 },
543 true,
544 )
545 } else {
546 match child.wait_with_output() {
547 Ok(output) => (output, false),
548 Err(error) => {
549 return Err(VmError::Thrown(VmValue::String(Rc::from(format!(
550 "spawn_captured: wait failed: {error}"
551 )))));
552 }
553 }
554 }
555 }
556 };
557
558 let duration_ms = started.elapsed().as_millis() as i64;
559 let exit_code = if timed_out {
560 -1
561 } else {
562 output.status.code().unwrap_or(-1) as i64
563 };
564 let success = if timed_out {
565 false
566 } else {
567 output.status.success()
568 };
569 let mut result = BTreeMap::new();
570 result.insert("exit_code".to_string(), VmValue::Int(exit_code));
571 result.insert(
572 "stdout".to_string(),
573 VmValue::String(Rc::from(String::from_utf8_lossy(&output.stdout).as_ref())),
574 );
575 result.insert(
576 "stderr".to_string(),
577 VmValue::String(Rc::from(String::from_utf8_lossy(&output.stderr).as_ref())),
578 );
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(Rc::new(result)))
583}
584
585pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
587 let mut dir = base.to_path_buf();
588 loop {
589 if dir.join("harn.toml").exists() {
590 return Some(dir);
591 }
592 if !dir.pop() {
593 return None;
594 }
595 }
596}
597
598pub(crate) fn register_path_builtins(vm: &mut Vm) {
600 vm.register_builtin("source_dir", |_args, _out| {
601 let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
602 match dir {
603 Some(d) => Ok(VmValue::String(Rc::from(d.to_string_lossy().into_owned()))),
604 None => {
605 let cwd = std::env::current_dir()
606 .map(|p| p.to_string_lossy().into_owned())
607 .unwrap_or_default();
608 Ok(VmValue::String(Rc::from(cwd)))
609 }
610 }
611 });
612
613 vm.register_builtin("project_root", |_args, _out| {
614 let base = current_execution_context()
615 .and_then(|context| context.cwd.map(PathBuf::from))
616 .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
617 .or_else(|| std::env::current_dir().ok())
618 .unwrap_or_else(|| PathBuf::from("."));
619 match find_project_root(&base) {
620 Some(root) => Ok(VmValue::String(Rc::from(
621 root.to_string_lossy().into_owned(),
622 ))),
623 None => Ok(VmValue::Nil),
624 }
625 });
626}
627
628fn vm_output_to_value(output: std::process::Output) -> VmValue {
629 let mut result = BTreeMap::new();
630 result.insert(
631 "stdout".to_string(),
632 VmValue::String(Rc::from(String::from_utf8_lossy(&output.stdout).as_ref())),
633 );
634 result.insert(
635 "stderr".to_string(),
636 VmValue::String(Rc::from(String::from_utf8_lossy(&output.stderr).as_ref())),
637 );
638 result.insert(
639 "status".to_string(),
640 VmValue::Int(output.status.code().unwrap_or(-1) as i64),
641 );
642 result.insert(
643 "success".to_string(),
644 VmValue::Bool(output.status.success()),
645 );
646 VmValue::Dict(Rc::new(result))
647}
648
649fn exec_command(
650 dir: Option<&str>,
651 cmd: &str,
652 args: &[String],
653) -> Result<std::process::Output, VmError> {
654 let config = process_command_config(dir)?;
655 crate::stdlib::sandbox::command_output(cmd, args, &config)
656 .map_err(|error| prefix_process_error(error, "exec"))
657}
658
659#[cfg(test)]
660fn exec_shell(
661 dir: Option<&str>,
662 shell: &str,
663 flag: &str,
664 script: &str,
665) -> Result<std::process::Output, VmError> {
666 let args = vec![flag.to_string(), script.to_string()];
667 exec_shell_args(dir, shell, &args)
668}
669
670fn exec_shell_args(
671 dir: Option<&str>,
672 shell: &str,
673 args: &[String],
674) -> Result<std::process::Output, VmError> {
675 let config = process_command_config(dir)?;
676 crate::stdlib::sandbox::command_output(shell, args, &config)
677 .map_err(|error| prefix_process_error(error, "shell"))
678}
679
680fn process_command_config(
681 dir: Option<&str>,
682) -> Result<crate::stdlib::sandbox::ProcessCommandConfig, VmError> {
683 let mut config = crate::stdlib::sandbox::ProcessCommandConfig {
684 stdin_null: true,
685 ..Default::default()
686 };
687 if let Some(dir) = dir {
688 let resolved = resolve_command_dir(dir);
689 crate::stdlib::sandbox::enforce_process_cwd(&resolved)?;
690 config.cwd = Some(resolved);
691 } else if let Some(context) = current_execution_context() {
692 if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
693 crate::stdlib::sandbox::enforce_process_cwd(std::path::Path::new(&cwd))?;
694 config.cwd = Some(std::path::PathBuf::from(cwd));
695 }
696 if !context.env.is_empty() {
697 config.env.extend(context.env);
698 }
699 }
700 if let Some(value) = env_override(HARN_REPLAY_ENV) {
701 config.env.push((HARN_REPLAY_ENV.to_string(), value));
702 }
703 Ok(config)
704}
705
706fn prefix_process_error(error: VmError, prefix: &str) -> VmError {
707 match error {
708 VmError::Thrown(VmValue::String(message)) => VmError::Thrown(VmValue::String(Rc::from(
709 format!("{prefix} failed: {message}"),
710 ))),
711 other => other,
712 }
713}
714
715fn resolve_command_dir(dir: &str) -> PathBuf {
716 let candidate = PathBuf::from(dir);
717 if candidate.is_absolute() {
718 return candidate;
719 }
720 if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
721 return PathBuf::from(cwd).join(candidate);
722 }
723 if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
724 return source_dir.join(candidate);
725 }
726 candidate
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn lexically_collapse_resolves_sibling_walk() {
735 let path = PathBuf::from("/tmp/project/tests/../fixtures/x.json");
736 let collapsed = lexically_collapse(&path).expect("sibling walk");
737 assert_eq!(collapsed, PathBuf::from("/tmp/project/fixtures/x.json"));
738 }
739
740 #[test]
741 fn lexically_collapse_blocks_escape_past_root() {
742 let path = PathBuf::from("/app/../../etc/passwd");
745 assert!(lexically_collapse(&path).is_none());
746 }
747
748 #[test]
749 fn lexically_collapse_strips_curdir() {
750 let path = PathBuf::from("/app/./logs/today.txt");
751 let collapsed = lexically_collapse(&path).expect("curdir is benign");
752 assert_eq!(collapsed, PathBuf::from("/app/logs/today.txt"));
753 }
754
755 #[test]
756 fn resolve_source_relative_path_blocks_obvious_escape() {
757 let dir =
758 std::env::temp_dir().join(format!("harn-process-escape-{}", uuid::Uuid::now_v7()));
759 std::fs::create_dir_all(&dir).unwrap();
760 set_thread_source_dir(&dir);
761 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
762 cwd: Some(dir.to_string_lossy().into_owned()),
763 source_dir: Some(dir.to_string_lossy().into_owned()),
764 env: BTreeMap::new(),
765 adapter: None,
766 repo_path: None,
767 worktree_path: None,
768 branch: None,
769 base_ref: None,
770 cleanup: None,
771 }));
772 let resolved = resolve_source_relative_path("../../../../../../../../etc/passwd");
776 assert!(
777 resolved
778 .to_string_lossy()
779 .contains("__harn_rejected_parent_dir_traversal__"),
780 "expected rejection sentinel, got {resolved:?}"
781 );
782 reset_process_state();
783 let _ = std::fs::remove_dir_all(&dir);
784 }
785
786 #[test]
787 fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
788 let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
789 std::fs::create_dir_all(&dir).unwrap();
790 let current_dir = std::env::current_dir().unwrap();
791 set_thread_source_dir(&dir);
792 let resolved = resolve_source_relative_path("templates/prompt.txt");
793 assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
794 reset_process_state();
795 let _ = std::fs::remove_dir_all(&dir);
796 }
797
798 #[test]
799 fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
800 let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
801 let source_dir =
802 std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
803 std::fs::create_dir_all(&cwd).unwrap();
804 std::fs::create_dir_all(&source_dir).unwrap();
805 set_thread_source_dir(&source_dir);
806 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
807 cwd: Some(cwd.to_string_lossy().into_owned()),
808 source_dir: Some(source_dir.to_string_lossy().into_owned()),
809 env: BTreeMap::new(),
810 adapter: None,
811 repo_path: None,
812 worktree_path: None,
813 branch: None,
814 base_ref: None,
815 cleanup: None,
816 }));
817 let resolved = resolve_source_relative_path("templates/prompt.txt");
818 assert_eq!(resolved, cwd.join("templates/prompt.txt"));
819 reset_process_state();
820 let _ = std::fs::remove_dir_all(&cwd);
821 let _ = std::fs::remove_dir_all(&source_dir);
822 }
823
824 #[test]
825 fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
826 let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
827 let source_dir =
828 std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
829 std::fs::create_dir_all(&cwd).unwrap();
830 std::fs::create_dir_all(&source_dir).unwrap();
831 set_thread_source_dir(&source_dir);
832 set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
833 cwd: Some(cwd.to_string_lossy().into_owned()),
834 source_dir: Some(source_dir.to_string_lossy().into_owned()),
835 env: BTreeMap::new(),
836 adapter: None,
837 repo_path: None,
838 worktree_path: None,
839 branch: None,
840 base_ref: None,
841 cleanup: None,
842 }));
843 let resolved = resolve_source_asset_path("templates/prompt.txt");
844 assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
845 reset_process_state();
846 let _ = std::fs::remove_dir_all(&cwd);
847 let _ = std::fs::remove_dir_all(&source_dir);
848 }
849
850 #[test]
851 fn set_thread_source_dir_absolutizes_relative_paths() {
852 reset_process_state();
853 let current_dir = std::env::current_dir().unwrap();
854 set_thread_source_dir(std::path::Path::new("scripts"));
855 assert_eq!(source_root_path(), current_dir.join("scripts"));
856 reset_process_state();
857 }
858
859 #[test]
860 fn exec_context_sets_default_cwd_and_env() {
861 let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
862 std::fs::create_dir_all(&dir).unwrap();
863 std::fs::write(dir.join("marker.txt"), "ok").unwrap();
864 set_thread_execution_context(Some(RunExecutionRecord {
865 cwd: Some(dir.to_string_lossy().into_owned()),
866 env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
867 ..Default::default()
868 }));
869 let output = exec_shell(
870 None,
871 "sh",
872 "-c",
873 "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
874 )
875 .unwrap();
876 assert!(output.status.success());
877 assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
878 reset_process_state();
879 let _ = std::fs::remove_dir_all(&dir);
880 }
881
882 #[test]
883 fn exec_at_resolves_relative_to_execution_cwd() {
884 let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
885 std::fs::create_dir_all(dir.join("nested")).unwrap();
886 std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
887 set_thread_execution_context(Some(RunExecutionRecord {
888 cwd: Some(dir.to_string_lossy().into_owned()),
889 ..Default::default()
890 }));
891 let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
892 assert!(output.status.success());
893 reset_process_state();
894 let _ = std::fs::remove_dir_all(&dir);
895 }
896
897 #[test]
898 fn runtime_paths_uses_configurable_state_roots() {
899 let base =
900 std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
901 std::fs::create_dir_all(&base).unwrap();
902 std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
903 std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
904 std::env::set_var(
905 crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
906 ".custom-worktrees",
907 );
908 set_thread_execution_context(Some(RunExecutionRecord {
909 cwd: Some(base.to_string_lossy().into_owned()),
910 ..Default::default()
911 }));
912
913 let mut vm = crate::vm::Vm::new();
914 register_process_builtins(&mut vm);
915 let mut out = String::new();
916 let builtin = vm
917 .builtins
918 .get("runtime_paths")
919 .expect("runtime_paths builtin");
920 let paths = match builtin(&[], &mut out).unwrap() {
921 VmValue::Dict(map) => map,
922 other => panic!("expected dict, got {other:?}"),
923 };
924 assert_eq!(
925 paths.get("state_root").unwrap().display(),
926 base.join(".custom-harn").display().to_string()
927 );
928 assert_eq!(
929 paths.get("run_root").unwrap().display(),
930 base.join(".custom-runs").display().to_string()
931 );
932 assert_eq!(
933 paths.get("worktree_root").unwrap().display(),
934 base.join(".custom-worktrees").display().to_string()
935 );
936
937 reset_process_state();
938 std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV);
939 std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV);
940 std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV);
941 let _ = std::fs::remove_dir_all(&base);
942 }
943}