use std::path::{Path, PathBuf};
const BANNED_ENV_WRITES: &[&str] = &["HOME", "CLAUDE_CONFIG_DIR"];
const ENV_MUTATION_BUDGET: &[(&str, usize, usize)] = &[
("tm/gh_identity.rs", 6, 0),
("tm/commands/daemon_run.rs", 2, 0),
("tm/commands/managed_root.rs", 24, 4),
("tm/tests_behavior_a_tests.rs", 4, 4),
("tm/tests_behavior_b_tests.rs", 24, 18),
("tm/tests_behavior_c_tests.rs", 9, 9),
("tm/commands/managed_workspace_tests.rs", 3, 3),
("tm/commands/guided_inplace/tests.rs", 6, 4),
("tm/commands/session_picker_tests.rs", 6, 0),
];
const SELF_BASENAME: &str = "env_isolation_tests.rs";
fn bin_target_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("bin")
.join("tm")
}
fn bin_target_sources() -> Vec<PathBuf> {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let entries = std::fs::read_dir(dir)
.unwrap_or_else(|e| panic!("read {}: {e}", dir.display()))
.filter_map(Result::ok);
for entry in entries {
let path = entry.path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|e| e == "rs")
&& path.file_name().is_some_and(|n| n != SELF_BASENAME)
{
out.push(path);
}
}
}
let mut out = Vec::new();
walk(&bin_target_root(), &mut out);
assert!(
out.len() > 20,
"the bin-target scan found only {} files — the walk is broken, and a \
broken walk would report a clean target regardless of its contents",
out.len()
);
out.sort();
out
}
fn strip_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
loop {
let Some(at) = rest.find(['/', '\n']) else {
out.push_str(rest);
return out;
};
let after = &rest[at..];
if let Some(body) = after.strip_prefix("/*") {
out.push_str(&rest[..at]);
let body_end = body.find("*/").map(|e| at + 2 + e + 2);
let body = match body_end {
Some(end) => &rest[at..end],
None => rest,
};
for _ in body.matches('\n') {
out.push('\n');
}
match body_end {
Some(end) => rest = &rest[end..],
None => return out,
}
} else if after.starts_with("//") {
out.push_str(&rest[..at]);
match after.find('\n') {
Some(nl) => rest = &rest[at + nl..],
None => return out,
}
} else {
let step = at + rest[at..].chars().next().map_or(1, char::len_utf8);
out.push_str(&rest[..step]);
rest = &rest[step..];
}
}
}
fn literal_key(code: &str, at: usize) -> Option<String> {
let open = code[at..].find('(')?;
let args = code[at + open + 1..].trim_start();
let body = args.strip_prefix('"')?;
let end = body.find('"')?;
let key = &body[..end];
if key.contains('\\') {
return None;
}
Some(key.to_string())
}
fn mutation_sites(code: &str) -> Vec<(usize, bool)> {
const CALLS: &[(&str, bool)] = &[
("set_var", true),
("remove_var", true),
("set_current_dir", false),
("set_env", true),
("remove_env", true),
];
let mut sites = Vec::new();
for (call, names_a_var) in CALLS {
let mut from = 0;
while let Some(rel) = code[from..].find(call) {
let at = from + rel;
if code[at + call.len()..].trim_start().starts_with('(') {
sites.push((at, *names_a_var));
}
from = at + call.len();
}
}
sites
}
#[test]
fn bin_target_writes_no_home_env() {
let mut findings: Vec<String> = Vec::new();
for path in bin_target_sources() {
let code = strip_comments(&std::fs::read_to_string(&path).expect("read source"));
for (at, names_a_var) in mutation_sites(&code) {
if !names_a_var {
continue;
}
let Some(key) = literal_key(&code, at) else {
continue;
};
if BANNED_ENV_WRITES.contains(&key.as_str()) {
findings.push(format!("{}: writes ${key}", path.display()));
}
}
}
assert!(
findings.is_empty(),
"process-global `$HOME`/`$CLAUDE_CONFIG_DIR` writes are banned in the `tm` bin \
target (#5544) — `cargo test` runs this target's tests as threads in ONE process, so \
the write is visible to every parallel sibling and `#[serial]` does not exclude them. \
Inject the path instead: `BannerEnv` (formatters/banner/source.rs), `trust_cmd_in` \
(commands/project.rs), and `FrameworkPaths::under` are the established seams.\n {}",
findings.join("\n ")
);
}
#[test]
fn bin_target_env_mutation_count_does_not_grow() {
let mut over: Vec<String> = Vec::new();
let mut stale: Vec<String> = Vec::new();
for path in bin_target_sources() {
let code = strip_comments(&std::fs::read_to_string(&path).expect("read source"));
let sites = mutation_sites(&code);
let total = sites.len();
let indirect = sites
.iter()
.filter(|(at, names_a_var)| *names_a_var && literal_key(&code, *at).is_none())
.count();
let display = path.display().to_string();
let (budget, indirect_budget) = ENV_MUTATION_BUDGET
.iter()
.find(|(suffix, _, _)| display.ends_with(suffix))
.map(|(_, t, i)| (*t, *i))
.unwrap_or((0, 0));
if total > budget {
over.push(format!("{display}: {total} sites, budget {budget}"));
} else if total < budget {
stale.push(format!("{display}: {total} sites, budget {budget}"));
}
if indirect > indirect_budget {
over.push(format!(
"{display}: {indirect} mutations with a NON-LITERAL key, budget \
{indirect_budget} — a source scan cannot tell what these write, so rule 1 \
cannot clear them"
));
} else if indirect < indirect_budget {
stale.push(format!(
"{display}: {indirect} non-literal-key mutations, budget {indirect_budget}"
));
}
}
assert!(
over.is_empty(),
"new process-global env mutation in the `tm` bin target (#5544). Prefer an injected \
path or value — see `BannerEnv` and `PathEnv` for the pattern. If a process write is \
genuinely required, raise the file's numbers in `ENV_MUTATION_BUDGET` in this file and \
say why in the PR.\n {}",
over.join("\n ")
);
assert!(
stale.is_empty(),
"`ENV_MUTATION_BUDGET` is stale — these files now mutate less than their recorded \
budget. Lower the numbers so the ratchet keeps its grip.\n {}",
stale.join("\n ")
);
}
#[test]
fn the_guard_detects_a_home_write_and_ignores_prose() {
let sample = concat!(
"fn a() { unsafe { std::env::set_var(\"HOME\", p) } }\n",
"fn b() { unsafe { std::env::set_var(\n \"HOME\",\n p,\n) } }\n",
"fn c() { unsafe { std::env::remove_var(\"CLAUDE_CONFIG_DIR\") } }\n",
"// fn d() { std::env::set_var(\"HOME\", p) }\n",
"fn e() { unsafe { std::env::set_var(\"NO_COLOR\", \"1\") } }\n",
);
let code = strip_comments(sample);
let banned: Vec<usize> = mutation_sites(&code)
.into_iter()
.filter(|(at, names_a_var)| {
*names_a_var
&& literal_key(&code, *at).is_some_and(|k| BANNED_ENV_WRITES.contains(&k.as_str()))
})
.map(|(at, _)| at)
.collect();
assert_eq!(
banned.len(),
3,
"the guard must catch the inline, multi-line, and remove_var shapes — and only those. \
Stripped source:\n{code}"
);
assert_eq!(
mutation_sites(&code).len(),
4,
"the commented-out write must not be counted, the NO_COLOR one must be"
);
}
#[test]
fn the_guard_flags_an_indirect_env_key() {
let sample = concat!(
"fn s(k: &str, v: &str) { unsafe { std::env::set_var(k, v) } }\n",
"fn t() { unsafe { std::env::set_var(\"NO_COLOR\", \"1\") } }\n",
"fn u() { unsafe { std::env::set_var(SOME_CONST, \"1\") } }\n",
);
let code = strip_comments(sample);
let indirect: Vec<usize> = mutation_sites(&code)
.into_iter()
.filter(|(at, names_a_var)| *names_a_var && literal_key(&code, *at).is_none())
.map(|(at, _)| at)
.collect();
assert_eq!(
indirect.len(),
2,
"a parameter key and a const key are both unverifiable; a string literal is not"
);
}
#[test]
fn the_guard_does_not_confuse_xdg_config_home_for_home() {
let sample = concat!(
"fn a() { set_env(\"XDG_CONFIG_HOME\", v) }\n",
"fn b() { set_env(\"HOME\", v) }\n",
"fn c() { remove_env(\"TRUSTY_MPM_ROOT\") }\n",
);
let code = strip_comments(sample);
let keys: Vec<String> = mutation_sites(&code)
.into_iter()
.filter_map(|(at, _)| literal_key(&code, at))
.collect();
assert_eq!(keys, ["XDG_CONFIG_HOME", "HOME", "TRUSTY_MPM_ROOT"]);
let banned: Vec<&String> = keys
.iter()
.filter(|k| BANNED_ENV_WRITES.contains(&k.as_str()))
.collect();
assert_eq!(
banned,
[&"HOME".to_string()],
"only the exact name is a violation — a variable that merely CONTAINS it is not"
);
}
#[test]
fn the_guard_survives_non_ascii_source() {
let sample = concat!(
"// ── prose with box-drawing, em-dashes — and 🤖 ──\n",
"const BANNER: &str = \"█▀█ trusty — ✅\";\n",
"fn f() { unsafe { std::env::set_var(\"NO_COLOR\", \"→\") } }\n",
);
let code = strip_comments(sample);
assert!(
code.contains("█▀█ trusty — ✅"),
"non-comment text must survive byte-identically, got:\n{code}"
);
assert!(!code.contains("🤖"), "the comment must still be stripped");
for (at, _) in mutation_sites(&code) {
let _ = literal_key(&code, at);
}
for path in bin_target_sources() {
let code = strip_comments(&std::fs::read_to_string(&path).expect("read source"));
for (at, _) in mutation_sites(&code) {
let _ = literal_key(&code, at);
}
}
}