use super::*;
struct RuntimePathsEnvGuard {
state: Option<String>,
run: Option<String>,
worktree: Option<String>,
}
impl RuntimePathsEnvGuard {
fn capture() -> Self {
Self {
state: std::env::var(crate::runtime_paths::HARN_STATE_DIR_ENV).ok(),
run: std::env::var(crate::runtime_paths::HARN_RUN_DIR_ENV).ok(),
worktree: std::env::var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV).ok(),
}
}
}
impl Drop for RuntimePathsEnvGuard {
fn drop(&mut self) {
match self.state.as_deref() {
Some(value) => std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, value),
None => std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV),
}
match self.run.as_deref() {
Some(value) => std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, value),
None => std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV),
}
match self.worktree.as_deref() {
Some(value) => {
std::env::set_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV, value);
}
None => std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV),
}
}
}
#[test]
fn lexically_collapse_resolves_sibling_walk() {
let path = PathBuf::from("/tmp/project/tests/../fixtures/x.json");
let collapsed = lexically_collapse(&path).expect("sibling walk");
assert_eq!(collapsed, PathBuf::from("/tmp/project/fixtures/x.json"));
}
#[test]
fn lexically_collapse_blocks_escape_past_root() {
let path = PathBuf::from("/app/../../etc/passwd");
assert!(lexically_collapse(&path).is_none());
}
#[test]
fn lexically_collapse_strips_curdir() {
let path = PathBuf::from("/app/./logs/today.txt");
let collapsed = lexically_collapse(&path).expect("curdir is benign");
assert_eq!(collapsed, PathBuf::from("/app/logs/today.txt"));
}
#[test]
fn resolve_source_relative_path_blocks_obvious_escape() {
let dir = std::env::temp_dir().join(format!("harn-process-escape-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
set_thread_source_dir(&dir);
set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
cwd: Some(dir.to_string_lossy().into_owned()),
project_root: None,
source_dir: Some(dir.to_string_lossy().into_owned()),
env: BTreeMap::new(),
adapter: None,
repo_path: None,
worktree_path: None,
branch: None,
base_ref: None,
cleanup: None,
grants: Vec::new(),
}));
let resolved = resolve_source_relative_path("../../../../../../../../etc/passwd");
assert!(
resolved
.to_string_lossy()
.contains("__harn_rejected_parent_dir_traversal__"),
"expected rejection sentinel, got {resolved:?}"
);
reset_process_state();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let current_dir = std::env::current_dir().unwrap();
set_thread_source_dir(&dir);
let resolved = resolve_source_relative_path("templates/prompt.txt");
assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
reset_process_state();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
let source_dir =
std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&cwd).unwrap();
std::fs::create_dir_all(&source_dir).unwrap();
set_thread_source_dir(&source_dir);
set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
cwd: Some(cwd.to_string_lossy().into_owned()),
project_root: None,
source_dir: Some(source_dir.to_string_lossy().into_owned()),
env: BTreeMap::new(),
adapter: None,
repo_path: None,
worktree_path: None,
branch: None,
base_ref: None,
cleanup: None,
grants: Vec::new(),
}));
let resolved = resolve_source_relative_path("templates/prompt.txt");
assert_eq!(resolved, cwd.join("templates/prompt.txt"));
reset_process_state();
let _ = std::fs::remove_dir_all(&cwd);
let _ = std::fs::remove_dir_all(&source_dir);
}
#[test]
fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
let source_dir =
std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&cwd).unwrap();
std::fs::create_dir_all(&source_dir).unwrap();
set_thread_source_dir(&source_dir);
set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
cwd: Some(cwd.to_string_lossy().into_owned()),
project_root: None,
source_dir: Some(source_dir.to_string_lossy().into_owned()),
env: BTreeMap::new(),
adapter: None,
repo_path: None,
worktree_path: None,
branch: None,
base_ref: None,
cleanup: None,
grants: Vec::new(),
}));
let resolved = resolve_source_asset_path("templates/prompt.txt");
assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
reset_process_state();
let _ = std::fs::remove_dir_all(&cwd);
let _ = std::fs::remove_dir_all(&source_dir);
}
#[test]
fn set_thread_source_dir_absolutizes_relative_paths() {
reset_process_state();
let current_dir = std::env::current_dir().unwrap();
set_thread_source_dir(std::path::Path::new("scripts"));
assert_eq!(source_root_path(), current_dir.join("scripts"));
reset_process_state();
}
#[test]
fn project_root_builtin_prefers_explicit_execution_project_root() {
let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
let project_root =
std::env::temp_dir().join(format!("harn-process-root-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&cwd).unwrap();
std::fs::create_dir_all(&project_root).unwrap();
set_thread_execution_context(Some(RunExecutionRecord {
cwd: Some(cwd.to_string_lossy().into_owned()),
project_root: Some(project_root.to_string_lossy().into_owned()),
..Default::default()
}));
let mut out = String::new();
let value = project_root_impl(&[], &mut out).unwrap();
assert_eq!(value.display(), project_root.display().to_string());
reset_process_state();
let _ = std::fs::remove_dir_all(&cwd);
let _ = std::fs::remove_dir_all(&project_root);
}
#[test]
fn exec_context_sets_default_cwd_and_env() {
let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("marker.txt"), "ok").unwrap();
set_thread_execution_context(Some(RunExecutionRecord {
cwd: Some(dir.to_string_lossy().into_owned()),
env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
..Default::default()
}));
let output = exec_shell(
None,
"sh",
"-c",
"printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
)
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
reset_process_state();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exec_at_resolves_relative_to_execution_cwd() {
let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(dir.join("nested")).unwrap();
std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
set_thread_execution_context(Some(RunExecutionRecord {
cwd: Some(dir.to_string_lossy().into_owned()),
..Default::default()
}));
let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
assert!(output.status.success());
reset_process_state();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn runtime_paths_uses_configurable_state_roots() {
let _runtime_paths_env_lock = crate::runtime_paths::test_env_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let _env_guard = RuntimePathsEnvGuard::capture();
let base = std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&base).unwrap();
std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
std::env::set_var(
crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
".custom-worktrees",
);
set_thread_execution_context(Some(RunExecutionRecord {
cwd: Some(base.to_string_lossy().into_owned()),
..Default::default()
}));
let mut vm = crate::vm::Vm::new();
register_process_builtins(&mut vm);
let mut out = String::new();
let builtin = vm
.builtins
.get("runtime_paths")
.expect("runtime_paths builtin");
let paths = match builtin(&[], &mut out).unwrap() {
VmValue::Dict(map) => map,
other => panic!("expected dict, got {other:?}"),
};
assert_eq!(
paths.get("state_root").unwrap().display(),
base.join(".custom-harn").display().to_string()
);
assert_eq!(
paths.get("run_root").unwrap().display(),
base.join(".custom-runs").display().to_string()
);
assert_eq!(
paths.get("worktree_root").unwrap().display(),
base.join(".custom-worktrees").display().to_string()
);
reset_process_state();
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(unix)]
fn exec_opts_list(items: &[&str]) -> VmValue {
VmValue::List(std::sync::Arc::new(
items
.iter()
.map(|s| VmValue::String(arcstr::ArcStr::from(*s)))
.collect(),
))
}
#[cfg(unix)]
fn exec_opts_dict(pairs: &[(&str, VmValue)]) -> VmValue {
VmValue::dict(
pairs
.iter()
.map(|(k, v)| (crate::value::intern_key(k), v.clone()))
.collect::<crate::value::DictMap>(),
)
}
#[cfg(unix)]
#[test]
fn exec_opts_merges_env_with_parent_by_default() {
std::env::set_var("HARN_EXEC_OPTS_PARENT", "from-parent");
let env = exec_opts_dict(&[("CHILD", VmValue::String(arcstr::ArcStr::from("from-child")))]);
let args = vec![
exec_opts_list(&[
"/bin/sh",
"-c",
"printf '%s|%s' \"$HARN_EXEC_OPTS_PARENT\" \"$CHILD\"",
]),
exec_opts_dict(&[("env", env)]),
];
let mut out = String::new();
let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
let dict = result.as_dict().expect("dict");
assert_eq!(
dict.get("stdout").unwrap().display(),
"from-parent|from-child"
);
assert!(matches!(dict.get("success"), Some(VmValue::Bool(true))));
std::env::remove_var("HARN_EXEC_OPTS_PARENT");
}
#[cfg(unix)]
#[test]
fn exec_opts_replace_env_clears_parent() {
std::env::set_var("HARN_EXEC_OPTS_PARENT2", "from-parent");
let env = exec_opts_dict(&[("CHILD", VmValue::String(arcstr::ArcStr::from("from-child")))]);
let args = vec![
exec_opts_list(&[
"/bin/sh",
"-c",
"printf '%s|%s' \"$HARN_EXEC_OPTS_PARENT2\" \"$CHILD\"",
]),
exec_opts_dict(&[
("env", env),
("env_mode", VmValue::String(arcstr::ArcStr::from("replace"))),
]),
];
let mut out = String::new();
let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
let dict = result.as_dict().expect("dict");
assert_eq!(dict.get("stdout").unwrap().display(), "|from-child");
std::env::remove_var("HARN_EXEC_OPTS_PARENT2");
}
#[cfg(unix)]
#[test]
fn exec_at_opts_honors_directory() {
let dir = std::env::temp_dir().join(format!("harn-exec-opts-cwd-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let args = vec![
VmValue::String(arcstr::ArcStr::from(dir.to_string_lossy().into_owned())),
exec_opts_list(&["/bin/sh", "-c", "pwd -P"]),
];
let mut out = String::new();
let result = exec_at_opts_impl(&args, &mut out).expect("exec_at_opts result");
let dict = result.as_dict().expect("dict");
let want = std::fs::canonicalize(&dir).unwrap();
let got = dict.get("stdout").unwrap().display();
assert_eq!(got.trim(), want.to_string_lossy());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn exec_opts_enforces_timeout() {
let args = vec![
exec_opts_list(&["/bin/sh", "-c", "sleep 5"]),
exec_opts_dict(&[("timeout", VmValue::Int(50))]),
];
let mut out = String::new();
let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
let dict = result.as_dict().expect("dict");
assert!(
matches!(dict.get("timed_out"), Some(VmValue::Bool(true))),
"command exceeding timeout must report timed_out"
);
assert!(matches!(dict.get("success"), Some(VmValue::Bool(false))));
}
#[cfg(unix)]
#[test]
fn exec_opts_rejects_empty_command() {
let args = vec![exec_opts_list(&[])];
let mut out = String::new();
assert!(exec_opts_impl(&args, &mut out).is_err());
let bad = vec![VmValue::String(arcstr::ArcStr::from("not-a-list"))];
assert!(exec_opts_impl(&bad, &mut out).is_err());
}
#[cfg(unix)]
#[test]
fn exec_opts_interrupt_kills_child_process_group() {
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
let cancel = Arc::new(AtomicBool::new(false));
let _guard = crate::op_interrupt::install(Some(Arc::clone(&cancel)), None);
let flipper = {
let cancel = Arc::clone(&cancel);
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
})
};
let started = Instant::now();
let args = vec![exec_opts_list(&[
"/bin/sh",
"-c",
"echo started; sleep 30 & wait",
])];
let mut out = String::new();
let result = exec_opts_impl(&args, &mut out).expect("exec_opts result");
flipper.join().unwrap();
assert!(
started.elapsed() < Duration::from_secs(10),
"interrupt must preempt the 30s child, took {:?}",
started.elapsed()
);
let dict = result.as_dict().expect("dict");
assert!(matches!(dict.get("success"), Some(VmValue::Bool(false))));
assert!(matches!(dict.get("timed_out"), Some(VmValue::Bool(false))));
}