use crate::config::Config;
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(target_os = "linux")]
pub(crate) mod bwrap;
#[cfg(target_os = "linux")]
mod landlock;
#[cfg(target_os = "macos")]
mod seatbelt;
#[cfg(target_os = "linux")]
mod seccomp;
pub(crate) mod rlimits;
#[cfg(test)]
pub(crate) mod test_support;
#[cfg(target_os = "linux")]
pub use bwrap::SandboxGuard;
#[cfg(target_os = "macos")]
pub use seatbelt::SandboxGuard;
pub(crate) const LOCKDOWN_PATH: &str =
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
pub(crate) const TERM_ENV_VARS: &[&str] =
&["TERM", "COLORTERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION"];
pub(crate) const JAIL_PS1: &str = "(jail) \\w \\$ ";
const DOTDIR_DENY: &[&str] = &[
".gnupg",
".aws",
".ssh",
".mozilla",
".basilisk-dev",
".sparrow",
];
fn is_dotdir_rw(name: &str) -> bool {
let normalized = name.strip_prefix('.').unwrap_or(name);
DOTDIR_RW
.iter()
.any(|&d| d.strip_prefix('.').unwrap_or(d) == normalized)
}
#[allow(dead_code)] pub fn is_dotdir_denied(name: &str, extra: &[String], exempt: &[&str]) -> bool {
let normalized = name.strip_prefix('.').unwrap_or(name);
if exempt
.iter()
.any(|&e| e.strip_prefix('.').unwrap_or(e) == normalized)
{
return false;
}
if DOTDIR_DENY
.iter()
.any(|&d| d.strip_prefix('.').unwrap_or(d) == normalized)
{
return true;
}
for e in extra {
let e_normalized = e.strip_prefix('.').unwrap_or(e);
if e_normalized == normalized {
if is_dotdir_rw(normalized) {
crate::output::warn(&format!(
"Cannot hide {e}: it is required for sandboxed tool operation"
));
return false;
}
return true;
}
}
false
}
#[allow(dead_code)] pub fn denied_dotdirs<'a>(
extra: &'a [String],
exempt: &'a [&'a str],
) -> impl Iterator<Item = String> + 'a {
DOTDIR_DENY
.iter()
.map(|s| s.strip_prefix('.').unwrap_or(s).to_string())
.chain(
extra
.iter()
.map(|s| s.strip_prefix('.').unwrap_or(s).to_string()),
)
.filter(move |name| {
!exempt
.iter()
.any(|&e| e.strip_prefix('.').unwrap_or(e) == name)
})
}
const DOTDIR_RW: &[&str] = &[
".gemini",
".claude",
".crush",
".codex",
".aider",
".soulforge",
".grok",
".agents",
".omp",
".pi",
".config",
".cargo",
".cache",
".docker",
".bundle",
".gem",
".rustup",
".npm",
".bun",
".deno",
".yarn",
".pnpm",
".m2",
".gradle",
".dotnet",
".nuget",
".pub-cache",
".mix",
".hex",
];
#[derive(Debug, Clone)]
pub struct LaunchCommand {
pub program: String,
pub args: Vec<String>,
}
const BROWSER_COMMANDS: &[&str] = &[
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"brave",
"brave-browser",
"firefox",
"librewolf",
];
pub(crate) fn is_browser_command_name(name: &str) -> bool {
BROWSER_COMMANDS.contains(&name)
}
fn browser_basename(program: &str) -> Option<&str> {
let name = Path::new(program).file_name()?.to_str()?;
if is_browser_command_name(name) {
Some(name)
} else {
None
}
}
pub(crate) fn browser_state_dir(config: &Config) -> Option<PathBuf> {
let profile = config.browser_profile()?;
let browser = browser_basename(config.command.first()?)?;
match profile {
crate::config::BrowserProfile::Hard => None,
crate::config::BrowserProfile::Soft => Some(
home_dir()
.join(".local/share/ai-jail/browsers")
.join(browser),
),
}
}
pub fn dotdir_exemptions(config: &Config) -> Vec<&'static str> {
let mut exempt = Vec::new();
if config.ssh_enabled() {
exempt.push(".ssh");
}
exempt
}
fn home_dir() -> PathBuf {
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
}
fn xdg_config_home() -> PathBuf {
match std::env::var("XDG_CONFIG_HOME") {
Ok(v) if !v.is_empty() => PathBuf::from(v),
_ => home_dir().join(".config"),
}
}
fn path_exists(p: &Path) -> bool {
p.exists() || p.symlink_metadata().is_ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GitWorktreePaths {
pub git_dir: PathBuf,
pub common_dir: PathBuf,
}
impl GitWorktreePaths {
pub(crate) fn unique_paths(&self) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = Vec::new();
for path in [self.git_dir.clone(), self.common_dir.clone()] {
if !paths
.iter()
.any(|existing| paths_equivalent(existing, &path))
{
paths.push(path);
}
}
paths
}
}
pub(crate) fn discover_git_worktree_paths(
config: &Config,
project_dir: &Path,
verbose: bool,
) -> Option<GitWorktreePaths> {
if !config.worktree_enabled() {
if verbose {
crate::output::verbose("Git worktree: disabled");
}
return None;
}
match validate_linked_git_worktree(project_dir) {
Ok(Some(paths)) => {
if verbose {
crate::output::verbose(&format!(
"Git worktree: exposing {}",
paths
.unique_paths()
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
Some(paths)
}
Ok(None) => {
if verbose {
crate::output::verbose(
"Git worktree: not a linked worktree root",
);
}
None
}
Err(reason) => {
if verbose {
crate::output::verbose(&format!(
"Git worktree: skipped ({reason})"
));
}
None
}
}
}
fn validate_linked_git_worktree(
project_dir: &Path,
) -> Result<Option<GitWorktreePaths>, String> {
let project_git = project_dir.join(".git");
if project_git.is_dir() {
return Ok(None);
}
if !project_git.is_file() {
return Ok(None);
}
let git_dir = parse_gitfile_target(&project_git)?;
if !git_dir.is_dir() {
return Err(format!(
"gitdir target {} is not a directory",
git_dir.display()
));
}
let reverse_gitdir = read_resolved_path_file(&git_dir.join("gitdir"))?;
if !paths_equivalent(&reverse_gitdir, &project_git) {
return Err(format!(
"{} does not point back to {}",
git_dir.join("gitdir").display(),
project_git.display()
));
}
let common_dir = read_resolved_path_file(&git_dir.join("commondir"))?;
if !common_dir.is_dir() {
return Err(format!(
"commondir target {} is not a directory",
common_dir.display()
));
}
Ok(Some(GitWorktreePaths {
git_dir,
common_dir,
}))
}
fn parse_gitfile_target(gitfile: &Path) -> Result<PathBuf, String> {
let contents = std::fs::read_to_string(gitfile)
.map_err(|e| format!("cannot read {}: {e}", gitfile.display()))?;
let line = contents.trim();
let raw = line
.strip_prefix("gitdir:")
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
format!("{} is not a valid gitfile", gitfile.display())
})?;
Ok(resolve_path_from_file(gitfile, Path::new(raw)))
}
fn read_resolved_path_file(path: &Path) -> Result<PathBuf, String> {
let contents = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
let raw = contents.trim();
if raw.is_empty() {
return Err(format!("{} is empty", path.display()));
}
Ok(resolve_path_from_file(path, Path::new(raw)))
}
fn resolve_path_from_file(file: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
file.parent().unwrap_or_else(|| Path::new(".")).join(path)
}
}
fn paths_equivalent(left: &Path, right: &Path) -> bool {
match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
(Ok(a), Ok(b)) => a == b,
_ => left == right,
}
}
pub(crate) fn quote_shell_arg(arg: &str) -> String {
if arg.is_empty()
|| arg.contains(|c: char| {
c.is_whitespace() || "'\"\\$`(){}[]|&;<>*!?".contains(c)
})
{
return format!("'{}'", arg.replace('\'', "'\\''"));
}
arg.to_string()
}
fn mise_bin() -> Option<PathBuf> {
std::env::var("PATH").ok().and_then(|paths| {
paths.split(':').find_map(|dir| {
let p = PathBuf::from(dir).join("mise");
if p.is_file() { Some(p) } else { None }
})
})
}
fn default_launch_command(config: &Config) -> LaunchCommand {
if config.command.is_empty() {
return LaunchCommand {
program: "bash".into(),
args: vec![],
};
}
let mut iter = config.command.iter();
let program = iter.next().cloned().unwrap_or_else(|| "bash".to_string());
let args = iter.cloned().collect::<Vec<_>>();
LaunchCommand { program, args }
}
fn mise_wrapper_command(
mise_path: &Path,
user_cmd: LaunchCommand,
) -> LaunchCommand {
let script = "MISE=\"$1\"; shift; \"$MISE\" trust -q && eval \"$($MISE activate bash)\" && eval \"$($MISE env)\" && exec \"$@\"";
let mut args = vec![
"-lc".into(),
script.into(),
"ai-jail-mise".into(),
mise_path.display().to_string(),
user_cmd.program,
];
args.extend(user_cmd.args);
LaunchCommand {
program: "bash".into(),
args,
}
}
fn browser_profile_launch_command(
config: &Config,
mut user_cmd: LaunchCommand,
) -> LaunchCommand {
let Some(profile) = config.browser_profile() else {
return user_cmd;
};
let Some(browser) = browser_basename(&user_cmd.program) else {
return user_cmd;
};
match browser {
"firefox" | "librewolf" => {
let profile_dir = match profile {
crate::config::BrowserProfile::Hard => {
format!("/tmp/ai-jail-browser-{browser}")
}
crate::config::BrowserProfile::Soft => {
browser_state_dir(config)
.unwrap_or_else(|| {
home_dir()
.join(".local/share/ai-jail/browsers")
.join(browser)
})
.display()
.to_string()
}
};
user_cmd.args.extend([
"--no-remote".into(),
"--profile".into(),
profile_dir,
]);
}
_ => {
let data_dir = match profile {
crate::config::BrowserProfile::Hard => {
format!("/tmp/ai-jail-browser-{browser}/data")
}
crate::config::BrowserProfile::Soft => {
browser_state_dir(config)
.unwrap_or_else(|| {
home_dir()
.join(".local/share/ai-jail/browsers")
.join(browser)
})
.join("data")
.display()
.to_string()
}
};
let cache_dir = match profile {
crate::config::BrowserProfile::Hard => {
format!("/tmp/ai-jail-browser-{browser}/cache")
}
crate::config::BrowserProfile::Soft => {
browser_state_dir(config)
.unwrap_or_else(|| {
home_dir()
.join(".local/share/ai-jail/browsers")
.join(browser)
})
.join("cache")
.display()
.to_string()
}
};
user_cmd.args.extend([
"--no-sandbox".into(),
"--test-type".into(),
"--disable-crash-reporter".into(),
"--disable-breakpad".into(),
"--no-first-run".into(),
"--no-default-browser-check".into(),
"--disable-background-networking".into(),
"--disable-sync".into(),
"--password-store=basic".into(),
format!("--user-data-dir={data_dir}"),
format!("--disk-cache-dir={cache_dir}"),
]);
if !config.gpu_enabled() {
user_cmd.args.extend([
"--disable-gpu".into(),
"--disable-gpu-compositing".into(),
"--disable-accelerated-video-decode".into(),
"--disable-accelerated-video-encode".into(),
]);
}
}
}
user_cmd
}
pub fn build_launch_command(config: &Config) -> LaunchCommand {
let user_cmd =
browser_profile_launch_command(config, default_launch_command(config));
if config.lockdown_enabled() || !config.mise_enabled() {
return user_cmd;
}
if let Some(mise) = mise_bin() {
return mise_wrapper_command(&mise, user_cmd);
}
user_cmd
}
pub fn apply_landlock(
config: &Config,
project_dir: &Path,
verbose: bool,
) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
landlock::apply(config, project_dir, verbose)
}
#[cfg(not(target_os = "linux"))]
{
let _ = (config, project_dir, verbose);
Ok(())
}
}
pub fn apply_seccomp(config: &Config, verbose: bool) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
seccomp::apply(config, verbose)
}
#[cfg(not(target_os = "linux"))]
{
let _ = (config, verbose);
Ok(())
}
}
pub fn check() -> Result<(), String> {
#[cfg(target_os = "linux")]
{
bwrap::check()
}
#[cfg(target_os = "macos")]
{
seatbelt::check()
}
}
pub fn prepare() -> Result<SandboxGuard, String> {
#[cfg(target_os = "linux")]
{
bwrap::prepare()
}
#[cfg(target_os = "macos")]
{
Ok(seatbelt::SandboxGuard)
}
}
pub fn platform_notes(config: &Config) {
if config.lockdown_enabled() {
crate::output::info(
"Lockdown mode enabled: read-only project, no host write mounts, no mise.",
);
}
#[cfg(target_os = "macos")]
{
seatbelt::platform_notes(config);
}
#[cfg(not(target_os = "macos"))]
{
let _ = config;
}
}
pub fn build(
guard: &SandboxGuard,
config: &Config,
project_dir: &Path,
verbose: bool,
) -> Result<Command, String> {
#[cfg(target_os = "linux")]
{
bwrap::build(guard, config, project_dir, verbose)
}
#[cfg(target_os = "macos")]
{
let _ = guard;
Ok(seatbelt::build(config, project_dir, verbose))
}
}
pub fn dry_run(
guard: &SandboxGuard,
config: &Config,
project_dir: &Path,
verbose: bool,
) -> Result<String, String> {
#[cfg(target_os = "linux")]
{
bwrap::dry_run(guard, config, project_dir, verbose)
}
#[cfg(target_os = "macos")]
{
let _ = guard;
Ok(seatbelt::dry_run(config, project_dir, verbose))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
use super::test_support::linked_worktree_fixture;
use crate::test_utils::{ENV_LOCK, EnvVarGuard};
fn temp_test_dir(prefix: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir()
.join(format!("ai-jail-{prefix}-{}-{nonce}", std::process::id()))
}
#[test]
fn default_launch_is_bash() {
let cfg = Config::default();
let cmd = default_launch_command(&cfg);
assert_eq!(cmd.program, "bash");
assert!(cmd.args.is_empty());
}
#[test]
fn default_launch_uses_first_token_as_program() {
let cfg = Config {
command: vec!["claude".into(), "--model".into(), "opus".into()],
..Config::default()
};
let cmd = default_launch_command(&cfg);
assert_eq!(cmd.program, "claude");
assert_eq!(cmd.args, vec!["--model", "opus"]);
}
#[test]
fn build_launch_respects_no_mise() {
let cfg = Config {
command: vec!["claude".into()],
no_mise: Some(true),
..Config::default()
};
let cmd = build_launch_command(&cfg);
assert_eq!(cmd.program, "claude");
assert!(cmd.args.is_empty());
}
#[test]
fn build_launch_disables_mise_in_lockdown() {
let cfg = Config {
command: vec!["claude".into()],
lockdown: Some(true),
..Config::default()
};
let cmd = build_launch_command(&cfg);
assert_eq!(cmd.program, "claude");
assert!(cmd.args.is_empty());
}
#[test]
fn browser_hard_profile_adds_chromium_ephemeral_args() {
let cfg = Config {
command: vec!["chromium".into()],
browser_profile: Some("hard".into()),
no_mise: Some(true),
no_gpu: Some(true),
..Config::default()
};
let cmd = build_launch_command(&cfg);
assert_eq!(cmd.program, "chromium");
assert!(cmd.args.contains(&"--no-sandbox".into()));
assert!(cmd.args.contains(&"--test-type".into()));
assert!(cmd.args.contains(&"--disable-breakpad".into()));
assert!(cmd.args.contains(&"--disable-gpu".into()));
assert!(cmd.args.contains(&"--no-first-run".into()));
assert!(cmd.args.contains(&"--disable-sync".into()));
assert!(cmd.args.contains(&"--password-store=basic".into()));
assert!(
cmd.args.iter().any(|arg| arg
== "--user-data-dir=/tmp/ai-jail-browser-chromium/data")
);
assert!(
cmd.args.iter().any(|arg| arg
== "--disk-cache-dir=/tmp/ai-jail-browser-chromium/cache")
);
}
#[test]
fn browser_soft_profile_uses_ai_jail_state_dir() {
let cfg = Config {
command: vec!["chromium".into()],
browser_profile: Some("soft".into()),
no_mise: Some(true),
..Config::default()
};
let cmd = build_launch_command(&cfg);
let state = browser_state_dir(&cfg).unwrap();
assert!(state.ends_with(".local/share/ai-jail/browsers/chromium"));
assert!(cmd.args.iter().any(|arg| {
arg == &format!("--user-data-dir={}", state.join("data").display())
}));
assert!(cmd.args.iter().any(|arg| {
arg == &format!(
"--disk-cache-dir={}",
state.join("cache").display()
)
}));
}
#[test]
fn browser_chromium_profile_respects_explicit_gpu() {
let cfg = Config {
command: vec!["chromium".into()],
browser_profile: Some("hard".into()),
no_mise: Some(true),
no_gpu: Some(false),
..Config::default()
};
let cmd = build_launch_command(&cfg);
assert!(!cmd.args.contains(&"--disable-gpu".into()));
assert!(!cmd.args.contains(&"--disable-gpu-compositing".into()));
}
#[test]
fn browser_firefox_profile_adds_isolated_profile_args() {
let cfg = Config {
command: vec!["firefox".into()],
browser_profile: Some("hard".into()),
no_mise: Some(true),
..Config::default()
};
let cmd = build_launch_command(&cfg);
assert_eq!(cmd.program, "firefox");
assert!(cmd.args.contains(&"--no-remote".into()));
assert!(cmd.args.contains(&"--profile".into()));
assert!(cmd.args.contains(&"/tmp/ai-jail-browser-firefox".into()));
}
#[test]
fn regression_user_args_are_not_shell_interpreted() {
let cfg = Config {
command: vec!["echo".into(), "$(id)".into(), ";rm".into()],
no_mise: Some(true),
..Config::default()
};
let cmd = build_launch_command(&cfg);
assert_eq!(cmd.program, "echo");
assert_eq!(cmd.args, vec!["$(id)", ";rm"]);
}
#[test]
fn regression_mise_wrapper_forwards_user_argv_verbatim() {
let user_cmd = LaunchCommand {
program: "echo".into(),
args: vec!["$(id)".into(), "a b".into()],
};
let wrapped =
mise_wrapper_command(Path::new("/usr/bin/mise"), user_cmd);
assert_eq!(wrapped.program, "bash");
assert!(
wrapped.args.iter().any(|a| a.contains("exec \"$@\"")),
"mise wrapper should forward command argv via exec \"$@\""
);
assert_eq!(wrapped.args.last(), Some(&"a b".to_string()));
}
#[test]
fn deny_list_contains_sensitive_dirs() {
for name in &[
".gnupg",
".aws",
".ssh",
".mozilla",
".basilisk-dev",
".sparrow",
] {
assert!(
DOTDIR_DENY.contains(name),
"{name} should be in deny list"
);
}
}
#[test]
fn rw_list_contains_ai_tool_dirs() {
for name in &[
".gemini",
".claude",
".crush",
".codex",
".aider",
".soulforge",
".grok",
".agents",
".omp",
".pi",
] {
assert!(DOTDIR_RW.contains(name), "{name} should be in rw list");
}
}
#[test]
fn rw_list_contains_tool_dirs() {
for name in &[".config", ".cargo", ".cache", ".docker"] {
assert!(DOTDIR_RW.contains(name), "{name} should be in rw list");
}
}
#[test]
fn deny_and_rw_lists_do_not_overlap() {
for name in DOTDIR_DENY {
assert!(
!DOTDIR_RW.contains(name),
"{name} is in both deny and rw lists"
);
}
}
#[test]
fn is_dotdir_denied_builtin() {
assert!(is_dotdir_denied(".gnupg", &[], &[]));
assert!(is_dotdir_denied("gnupg", &[], &[])); assert!(is_dotdir_denied(".aws", &[], &[]));
assert!(is_dotdir_denied(".ssh", &[], &[]));
assert!(is_dotdir_denied(".mozilla", &[], &[]));
assert!(is_dotdir_denied(".basilisk-dev", &[], &[]));
assert!(is_dotdir_denied(".sparrow", &[], &[]));
}
#[test]
fn is_dotdir_denied_extra() {
let extra = vec![".my_secrets".into(), ".proton".into()];
assert!(is_dotdir_denied(".my_secrets", &extra, &[]));
assert!(is_dotdir_denied("my_secrets", &extra, &[])); assert!(is_dotdir_denied(".proton", &extra, &[]));
assert!(is_dotdir_denied("proton", &extra, &[]));
}
#[test]
fn is_dotdir_denied_not_in_list() {
assert!(!is_dotdir_denied(".cargo", &[], &[]));
assert!(!is_dotdir_denied(".config", &[], &[]));
assert!(!is_dotdir_denied(".my_custom", &[], &[]));
}
#[test]
fn is_dotdir_denied_combined() {
let extra = vec![".my_secrets".into()];
assert!(is_dotdir_denied(".aws", &extra, &[]));
assert!(is_dotdir_denied(".my_secrets", &extra, &[]));
assert!(!is_dotdir_denied(".cargo", &extra, &[]));
}
#[test]
fn ssh_exempt_removes_from_deny() {
assert!(is_dotdir_denied(".ssh", &[], &[]));
assert!(!is_dotdir_denied(".ssh", &[], &[".ssh"]));
assert!(is_dotdir_denied(".gnupg", &[], &[".ssh"]));
}
#[test]
fn cannot_deny_rw_required_dirs() {
let required = [
".cargo", ".cache", ".config", ".claude", ".gemini", ".omp", ".pi",
];
for name in required {
let extra = vec![name.to_string()];
assert!(
!is_dotdir_denied(name, &extra, &[]),
"{name} should not be deniable - it's RW-required"
);
}
}
#[test]
fn is_dotdir_rw_check() {
assert!(is_dotdir_rw(".cargo"));
assert!(is_dotdir_rw("cargo"));
assert!(is_dotdir_rw(".config"));
assert!(is_dotdir_rw(".cache"));
assert!(is_dotdir_rw(".omp"));
assert!(is_dotdir_rw("omp"));
assert!(is_dotdir_rw(".pi"));
assert!(is_dotdir_rw("pi"));
assert!(!is_dotdir_rw(".aws"));
assert!(!is_dotdir_rw(".my_secrets"));
}
#[test]
fn denied_dotdirs_iter() {
let extra: Vec<String> = vec![".my_secrets".into(), ".proton".into()];
let denied: Vec<String> = denied_dotdirs(&extra, &[]).collect();
assert!(denied.contains(&"gnupg".to_string()));
assert!(denied.contains(&"aws".to_string()));
assert!(denied.contains(&"my_secrets".to_string()));
assert!(denied.contains(&"proton".to_string()));
}
#[test]
fn validate_linked_git_worktree_skips_normal_repo_root() {
let root = temp_test_dir("normal-repo");
let project_dir = root.join("project");
std::fs::create_dir_all(project_dir.join(".git")).unwrap();
assert!(
validate_linked_git_worktree(&project_dir)
.unwrap()
.is_none()
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn validate_linked_git_worktree_discovers_valid_layout() {
let fixture = linked_worktree_fixture("worktree");
let paths = validate_linked_git_worktree(&fixture.project_dir)
.unwrap()
.unwrap();
assert!(paths_equivalent(&paths.git_dir, &fixture.git_dir));
assert!(paths_equivalent(&paths.common_dir, &fixture.common_dir));
assert_eq!(paths.unique_paths().len(), 2);
}
#[test]
fn validate_linked_git_worktree_rejects_malformed_gitfile() {
let root = temp_test_dir("bad-gitfile");
let project_dir = root.join("project");
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(project_dir.join(".git"), "definitely not a gitfile\n")
.unwrap();
let err = validate_linked_git_worktree(&project_dir).unwrap_err();
assert!(err.contains("valid gitfile"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn validate_linked_git_worktree_rejects_mismatched_reverse_link() {
let fixture = linked_worktree_fixture("worktree");
std::fs::write(
fixture.git_dir.join("gitdir"),
"../../../../other/.git\n",
)
.unwrap();
let err =
validate_linked_git_worktree(&fixture.project_dir).unwrap_err();
assert!(err.contains("does not point back"));
}
#[test]
fn discover_git_worktree_paths_respects_disabled_config() {
let fixture = linked_worktree_fixture("worktree");
let config = Config {
no_worktree: Some(true),
..Config::default()
};
assert!(
discover_git_worktree_paths(&config, &fixture.project_dir, false)
.is_none()
);
}
#[test]
fn xdg_config_home_falls_back_to_home_dot_config() {
let _lock = ENV_LOCK.lock().unwrap();
let _home = EnvVarGuard::set("HOME", "/home/test-user");
let _xdg = EnvVarGuard::remove("XDG_CONFIG_HOME");
assert_eq!(xdg_config_home(), PathBuf::from("/home/test-user/.config"));
}
#[test]
fn xdg_config_home_falls_back_when_env_is_empty() {
let _lock = ENV_LOCK.lock().unwrap();
let _home = EnvVarGuard::set("HOME", "/home/test-user");
let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", "");
assert_eq!(xdg_config_home(), PathBuf::from("/home/test-user/.config"));
}
#[test]
fn xdg_config_home_honors_env_var() {
let _lock = ENV_LOCK.lock().unwrap();
let _home = EnvVarGuard::set("HOME", "/home/test-user");
let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", "/opt/custom-config");
assert_eq!(xdg_config_home(), PathBuf::from("/opt/custom-config"));
}
}