use std::path::{Path, PathBuf};
use crate::core::error::PathJailError;
pub fn expand_user_path(raw: &str) -> PathBuf {
let mut s = raw.to_string();
if (s == "~" || s.starts_with("~/"))
&& let Some(home) = dirs::home_dir()
{
s = format!("{}{}", home.to_string_lossy(), &s[1..]);
}
while let Some(start) = s.find('$') {
let rest = &s[start + 1..];
let (name, token_len) = if let Some(stripped) = rest.strip_prefix('{') {
match stripped.find('}') {
Some(end) => (stripped[..end].to_string(), end + 3),
None => break,
}
} else {
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
(rest[..end].to_string(), end + 1)
};
if name.is_empty() {
break;
}
if let Ok(val) = std::env::var(&name) {
s.replace_range(start..start + token_len, &val);
} else {
tracing::warn!(
"allow_paths/extra_roots entry '{raw}' references unset variable ${name} — entry will never match"
);
break;
}
}
PathBuf::from(s)
}
pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
let mut out = Vec::new();
let cfg = crate::core::config::Config::load();
if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
out.push(canonicalize_secure(&data_dir));
}
if let Some(home) = dirs::home_dir() {
let ide_dirs_allowed = cfg.allow_ide_config_dirs.unwrap_or(false)
|| std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
out.extend(home_allow_dirs(&home, ide_dirs_allowed));
}
for p in &cfg.allow_paths {
out.push(canonicalize_secure(&expand_user_path(p)));
}
for p in &cfg.extra_roots {
out.push(canonicalize_secure(&expand_user_path(p)));
}
let v = std::env::var("LCTX_ALLOW_PATH")
.or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
.unwrap_or_default();
if !v.trim().is_empty() {
for p in std::env::split_paths(&v) {
out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
}
}
let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
if !extra.trim().is_empty() {
for p in std::env::split_paths(&extra) {
out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
}
}
out.extend(canonicalized_roots(
&cfg.read_only_roots,
"LEAN_CTX_READ_ONLY_ROOTS",
));
out
}
fn canonicalized_roots(config_entries: &[String], env_var: &str) -> Vec<PathBuf> {
let mut out = Vec::new();
for p in config_entries {
out.push(canonicalize_secure(&expand_user_path(p)));
}
let v = std::env::var(env_var).unwrap_or_default();
if !v.trim().is_empty() {
for p in std::env::split_paths(&v) {
out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
}
}
out
}
pub fn read_only_roots_from_env_and_config() -> Vec<PathBuf> {
let cfg = crate::core::config::Config::load();
canonicalized_roots(&cfg.read_only_roots, "LEAN_CTX_READ_ONLY_ROOTS")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct JailRelaxation {
pub source: &'static str,
pub detail: &'static str,
}
fn env_is_set(var: &str) -> bool {
std::env::var(var).is_ok_and(|v| !v.trim().is_empty())
}
#[must_use]
pub fn active_relaxations() -> Vec<JailRelaxation> {
let mut out = Vec::new();
if cfg!(feature = "no-jail") {
out.push(JailRelaxation {
source: "no-jail (build feature)",
detail: "path jail compiled out — every tool path is allowed",
});
}
if crate::core::config::Config::load().path_jail == Some(false) {
out.push(JailRelaxation {
source: "path_jail = false (config.toml)",
detail: "path jail disabled — every tool path is allowed",
});
}
if env_is_set("LEAN_CTX_ALLOW_PATH") || env_is_set("LCTX_ALLOW_PATH") {
out.push(JailRelaxation {
source: "LEAN_CTX_ALLOW_PATH",
detail: "widens the read/write allow-list beyond the project root",
});
}
if env_is_set("LEAN_CTX_EXTRA_ROOTS") {
out.push(JailRelaxation {
source: "LEAN_CTX_EXTRA_ROOTS",
detail: "adds extra accessible roots beyond the project root",
});
}
let ide_env = std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
if ide_env
|| crate::core::config::Config::load()
.allow_ide_config_dirs
.unwrap_or(false)
{
out.push(JailRelaxation {
source: if ide_env {
"LEAN_CTX_ALLOW_IDE_DIRS=1"
} else {
"allow_ide_config_dirs = true (config.toml)"
},
detail: "exposes ~/.cursor, ~/.claude, … (other agents' sessions/credentials) to tools",
});
}
out
}
pub fn warn_if_relaxed() {
for relaxation in active_relaxations() {
tracing::warn!(
"[SECURITY] path jail relaxed via {}: {} — intended for trusted local use only",
relaxation.source,
relaxation.detail
);
}
}
pub fn is_read_only_path(candidate: &Path) -> bool {
let roots = read_only_roots_from_env_and_config();
if roots.is_empty() {
return false;
}
let base = match canonicalize_existing_ancestor(candidate) {
Some((base, remainder)) => {
let mut p = base;
for part in remainder.iter().rev() {
p.push(part);
}
p
}
None => canonicalize_or_self(candidate),
};
roots.iter().any(|r| is_under_prefix(&base, r))
}
pub fn enforce_writable(candidate: &Path) -> Result<(), String> {
if is_read_only_path(candidate) {
return Err(format!(
"path is inside a read-only root — writes are denied (read_only_roots): {}",
candidate.display()
));
}
Ok(())
}
fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
let mut out = Vec::new();
if ide_dirs_allowed {
let targets = crate::core::editor_registry::build_targets(home);
collect_ide_allow_dirs(home, &targets, &mut out);
}
out
}
fn collect_ide_allow_dirs(
home: &Path,
targets: &[crate::core::editor_registry::EditorTarget],
out: &mut Vec<PathBuf>,
) {
let mut seen: std::collections::HashSet<PathBuf> = out.iter().cloned().collect();
for target in targets {
let candidates = [
target.config_path.parent().map(Path::to_path_buf),
Some(target.detect_path.clone()),
];
for cand in candidates.into_iter().flatten() {
if cand.as_path() == home || !cand.starts_with(home) || !cand.is_dir() {
continue;
}
let resolved = canonicalize_secure(&cand);
if seen.insert(resolved.clone()) {
out.push(resolved);
}
}
}
}
fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
path.starts_with(prefix)
}
pub fn canonicalize_or_self(path: &Path) -> PathBuf {
super::pathutil::safe_canonicalize_bounded(path, 2000)
}
fn canonicalize_secure(path: &Path) -> PathBuf {
super::pathutil::canonicalize_secure_bounded(path, 2000)
}
fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
let mut cur = path.to_path_buf();
let mut remainder: Vec<std::ffi::OsString> = Vec::new();
loop {
if cur.exists() {
return Some((canonicalize_secure(&cur), remainder));
}
let name = cur.file_name()?.to_os_string();
remainder.push(name);
if !cur.pop() {
return None;
}
}
}
pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, PathJailError> {
jail_path_with_roots(candidate, jail_root, &[])
}
pub fn jail_path_with_roots(
candidate: &Path,
jail_root: &Path,
extra_roots: &[String],
) -> Result<PathBuf, PathJailError> {
if candidate.to_string_lossy().as_bytes().contains(&0) {
return Err(PathJailError::NullByte);
}
#[cfg(feature = "no-jail")]
{
let _ = (jail_root, extra_roots);
return Ok(canonicalize_or_self(candidate));
}
#[allow(unreachable_code)]
{
let cfg = crate::core::config::Config::load();
if cfg.path_jail == Some(false) {
return Ok(canonicalize_or_self(candidate));
}
let root = canonicalize_secure(jail_root);
let resolved: PathBuf;
let candidate: &Path = if candidate.is_absolute() {
candidate
} else {
resolved = root.join(candidate);
resolved.as_path()
};
let mut allow = allow_paths_from_env_and_config();
allow.extend(
extra_roots
.iter()
.filter(|r| !r.is_empty())
.map(|r| canonicalize_secure(Path::new(r))),
);
let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
PathJailError::NoExistingAncestor {
path: candidate.to_path_buf(),
}
})?;
let allowed =
is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
#[cfg(windows)]
let allowed = allowed || is_under_prefix_windows(&base, &root);
if !allowed {
let mut hint = if crate::core::protocol::meta_visible() {
let dir = candidate.parent().unwrap_or(candidate).display();
format!(
". Hint: set LEAN_CTX_READ_ONLY_ROOTS={dir} for read-only access, \
or LEAN_CTX_ALLOW_PATH={dir} for read-write access, \
or add entries to read_only_roots/allow_paths in ~/.config/lean-ctx/config.toml"
)
} else {
String::new()
};
if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
hint.push_str(". ");
hint.push_str(¬ice);
}
if let Some(missing) = crate::core::config::Config::missing_config_path() {
hint.push_str(&format!(
". ⚠ lean-ctx reads no config file at {} (running on defaults) — an \
allow_paths edit in a config.toml elsewhere is not read; \
`lean-ctx doctor` shows the path in effect",
missing.display()
));
}
return Err(PathJailError::EscapesRoot {
path: candidate.to_path_buf(),
root,
hint,
});
}
#[cfg(windows)]
reject_symlink_on_windows(candidate)?;
let mut out = base;
for part in remainder.iter().rev() {
out.push(part);
}
if out.exists() {
let final_canon = canonicalize_secure(&out);
let final_ok = is_under_prefix(&final_canon, &root)
|| allow.iter().any(|p| is_under_prefix(&final_canon, p));
#[cfg(windows)]
let final_ok = final_ok || is_under_prefix_windows(&final_canon, &root);
if !final_ok {
return Err(PathJailError::PostCanonicalizeEscape {
path: candidate.to_path_buf(),
resolved: final_canon,
});
}
}
Ok(out)
}
}
#[cfg(windows)]
fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
let path_str = normalize_windows_path(&path.to_string_lossy());
let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
path_str.starts_with(&prefix_str)
}
#[cfg(windows)]
fn normalize_windows_path(s: &str) -> String {
let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
stripped.to_lowercase().replace('/', "\\")
}
#[cfg(windows)]
fn reject_symlink_on_windows(path: &Path) -> Result<(), PathJailError> {
if let Ok(meta) = std::fs::symlink_metadata(path) {
if super::pathutil::is_symlink_or_reparse(&meta) {
return Err(PathJailError::Symlink {
path: path.to_path_buf(),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(feature = "no-jail"))]
#[test]
fn rejects_path_outside_root() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(root.join("a.txt"), "ok").unwrap();
std::fs::write(other.join("b.txt"), "no").unwrap();
let ok = jail_path(&root.join("a.txt"), &root);
assert!(ok.is_ok());
let bad = jail_path(&other.join("b.txt"), &root);
assert!(bad.is_err());
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn read_only_roots_deny_writes_but_allow_reads() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let refrepo = tmp.path().join("refrepo");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(refrepo.join("sub")).unwrap();
std::fs::write(refrepo.join("lib.rs"), "pub fn x() {}\n").unwrap();
let ro_canon = canonicalize_secure(&refrepo);
crate::test_env::set_var(
"LEAN_CTX_READ_ONLY_ROOTS",
ro_canon.to_string_lossy().as_ref(),
);
let existing = refrepo.join("lib.rs");
let new_file = refrepo.join("sub").join("new.rs");
let proj_file = project.join("main.rs");
let read_existing = jail_path(&existing, &project);
let deny_existing = enforce_writable(&existing);
let deny_new = enforce_writable(&new_file);
let allow_project = enforce_writable(&proj_file);
let ro_existing = is_read_only_path(&existing);
let ro_project = is_read_only_path(&proj_file);
crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
assert!(
deny_existing.is_err(),
"write to an existing file in a read-only root must be denied"
);
assert!(
deny_new.is_err(),
"creating a new file in a read-only root must be denied"
);
assert!(
allow_project.is_ok(),
"writes into the project root must stay allowed: {allow_project:?}"
);
assert!(
read_existing.is_ok(),
"reads inside a read-only root must resolve (read allow-list): {read_existing:?}"
);
assert!(ro_existing, "the file is inside the read-only root");
assert!(!ro_project, "the project file is not read-only");
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn honors_path_jail_false_after_mtime_preserving_edit() {
let _iso = crate::core::data_dir::isolated_data_dir();
let cfg_path = crate::core::config::Config::path().unwrap();
if let Some(parent) = cfg_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let secret = outside.join("secret.txt");
std::fs::write(&secret, "x").unwrap();
std::fs::write(&cfg_path, "# jail on\n").unwrap();
let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
assert_eq!(crate::core::config::Config::load().path_jail, None);
std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
assert!(
jail_path(&secret, &root).is_ok(),
"path_jail=false must take effect without a fresh process (#406)"
);
}
#[test]
fn allows_nonexistent_child_under_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.txt"), "ok").unwrap();
let p = root.join("new").join("file.txt");
let ok = jail_path(&p, &root).unwrap();
assert!(ok.to_string_lossy().contains("file.txt"));
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn relative_candidate_resolves_against_root_not_cwd() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
let jailed = jail_path(Path::new("sub/file.rs"), &root)
.expect("relative candidate should resolve under the jail root");
assert!(jailed.ends_with("sub/file.rs"));
assert!(
is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
"resolved path must live under the jail root: {jailed:?}"
);
}
#[test]
fn ide_allow_dirs_are_registry_derived_and_skip_home() {
use crate::core::editor_registry::{ConfigType, EditorTarget};
let home = tempfile::tempdir().unwrap();
let h = home.path();
std::fs::create_dir_all(h.join("Library/Application Support/Code/User")).unwrap();
std::fs::create_dir_all(h.join(".cursor")).unwrap();
let targets = vec![
EditorTarget {
name: "VS Code",
agent_key: "vscode".into(),
config_path: h.join("Library/Application Support/Code/User/mcp.json"),
detect_path: h.join("Library/Application Support/Code"),
config_type: ConfigType::VsCodeMcp,
},
EditorTarget {
name: "Cursor",
agent_key: "cursor".into(),
config_path: h.join(".cursor/mcp.json"),
detect_path: h.join(".cursor"),
config_type: ConfigType::McpJson,
},
EditorTarget {
name: "Claude Code",
agent_key: "claude".into(),
config_path: h.join(".claude.json"),
detect_path: h.join(".no-such-dir"),
config_type: ConfigType::McpJson,
},
];
let mut out = Vec::new();
collect_ide_allow_dirs(h, &targets, &mut out);
assert!(
out.iter().any(|p| p.ends_with("Code/User")),
"non-dotfile VS Code dir must be covered: {out:?}"
);
assert!(out.iter().any(|p| p.ends_with(".cursor")), "{out:?}");
let home_canon = canonicalize_secure(h);
assert!(
!out.contains(&home_canon),
"must never widen the jail to $HOME: {out:?}"
);
}
#[test]
fn ide_config_dirs_are_excluded_by_default() {
let home = tempfile::tempdir().unwrap();
for d in [".lean-ctx", ".cursor", ".codex"] {
std::fs::create_dir_all(home.path().join(d)).unwrap();
}
let denied = home_allow_dirs(home.path(), false);
assert!(
denied.is_empty(),
"foreign editor dirs must stay jailed by default: {denied:?}"
);
let allowed = home_allow_dirs(home.path(), true);
assert!(
allowed.iter().any(|p| p.ends_with(".cursor")),
"opt-in must expose editor dirs: {allowed:?}"
);
assert!(
!allowed.iter().any(|p| p.ends_with(".lean-ctx")),
"lean-ctx's own dir is covered by the data_dir root, not home_allow_dirs: {allowed:?}"
);
}
#[test]
fn canonicalize_or_self_strips_verbatim() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("project");
std::fs::create_dir_all(&dir).unwrap();
let result = canonicalize_or_self(&dir);
let s = result.to_string_lossy();
assert!(
!s.starts_with(r"\\?\"),
"canonicalize_or_self should strip verbatim prefix, got: {s}"
);
}
#[test]
fn jail_path_accepts_same_dir_different_format() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("file.rs"), "ok").unwrap();
let result = jail_path(&root.join("file.rs"), &root);
assert!(result.is_ok(), "same dir should be accepted: {result:?}");
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn error_message_contains_escape_info() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("b.txt"), "no").unwrap();
let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
assert!(
err.to_string().contains("path escapes project root"),
"error should mention escape: {err}"
);
}
#[test]
fn expand_user_path_expands_tilde_and_vars() {
let home = dirs::home_dir().expect("home dir");
let home_s = home.to_string_lossy().to_string();
assert_eq!(expand_user_path("~"), home);
assert_eq!(expand_user_path("~/code"), home.join("code"));
assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
crate::test_env::set_var("LEAN_CTX_TEST_SUB", "sub");
assert_eq!(
expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
PathBuf::from(format!("{home_s}/sub/x"))
);
crate::test_env::remove_var("LEAN_CTX_TEST_SUB");
assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
}
#[test]
fn expand_user_path_leaves_unset_vars_verbatim() {
crate::test_env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
}
#[cfg(unix)]
#[test]
fn allow_path_root_slash_permits_everything() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("b.txt"), "allowed").unwrap();
crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/");
let result = jail_path(&other.join("b.txt"), &root);
crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
}
#[test]
fn active_relaxations_detects_allow_path_env() {
let _iso = crate::core::data_dir::isolated_data_dir();
crate::test_env::remove_var("LEAN_CTX_EXTRA_ROOTS");
crate::test_env::remove_var("LEAN_CTX_ALLOW_IDE_DIRS");
crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/tmp");
let relaxed = active_relaxations();
crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
assert!(
relaxed.iter().any(|r| r.source == "LEAN_CTX_ALLOW_PATH"),
"LEAN_CTX_ALLOW_PATH must be reported as a jail relaxation: {relaxed:?}"
);
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn active_relaxations_empty_when_jail_intact() {
let _iso = crate::core::data_dir::isolated_data_dir();
for var in [
"LEAN_CTX_ALLOW_PATH",
"LCTX_ALLOW_PATH",
"LEAN_CTX_EXTRA_ROOTS",
"LEAN_CTX_ALLOW_IDE_DIRS",
] {
crate::test_env::remove_var(var);
}
assert!(
active_relaxations().is_empty(),
"an intact jail (clean config, no relaxation env) must report no relaxations: {:?}",
active_relaxations()
);
}
#[test]
fn allow_path_env_permits_outside_root() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("b.txt"), "allowed").unwrap();
let canon = canonicalize_or_self(&other);
crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
let result = jail_path(&other.join("b.txt"), &root);
crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
assert!(
result.is_ok(),
"LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
);
}
#[cfg(all(unix, not(feature = "no-jail")))]
#[test]
fn rejects_symlink_escape_on_unix() {
use std::os::unix::fs::symlink;
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("secret.txt"), "no").unwrap();
let link = root.join("link.txt");
symlink(other.join("secret.txt"), &link).unwrap();
let bad = jail_path(&link, &root);
assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
}
#[test]
fn rejects_null_byte_in_path() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
std::fs::create_dir_all(&root).unwrap();
let bad_path = PathBuf::from("file\0.txt");
let result = jail_path(&bad_path, &root);
assert!(result.is_err(), "null byte in path must be rejected");
assert!(
result.unwrap_err().to_string().contains("null byte"),
"error must mention null byte"
);
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn extra_roots_permit_paths_outside_jail() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let worktree = tmp.path().join("worktree");
let elsewhere = tmp.path().join("elsewhere");
for d in [&root, &worktree, &elsewhere] {
std::fs::create_dir_all(d).unwrap();
}
let in_worktree = worktree.join("a.txt");
std::fs::write(&in_worktree, "x").unwrap();
let outside = elsewhere.join("b.txt");
std::fs::write(&outside, "y").unwrap();
assert!(jail_path(&in_worktree, &root).is_err());
assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
let extra = vec![worktree.to_string_lossy().to_string()];
assert!(
jail_path_with_roots(&in_worktree, &root, &extra).is_ok(),
"path under a session extra_root must resolve (#403)"
);
assert!(
jail_path_with_roots(&outside, &root, &extra).is_err(),
"paths outside ALL roots must still be rejected"
);
assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
}
}