use super::*;
use tempfile::TempDir;
fn quarantine_files(cfg: &Path) -> Vec<String> {
let mut found: Vec<String> = std::fs::read_dir(cfg)
.expect("config dir must be readable")
.flatten()
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
.filter(|n| n.starts_with(".claude.json.corrupt"))
.collect();
found.sort();
found
}
fn write_config_with_projects(cfg: &Path, entries: serde_json::Value) {
let config = serde_json::json!({ "projects": entries });
std::fs::write(
cfg.join(".claude.json"),
serde_json::to_string_pretty(&config).unwrap(),
)
.unwrap();
}
fn read_projects(cfg: &Path) -> serde_json::Map<String, serde_json::Value> {
let text = std::fs::read_to_string(cfg.join(".claude.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
val["projects"].as_object().cloned().unwrap_or_default()
}
fn seeder_only_entry() -> serde_json::Value {
serde_json::json!({
"hasTrustDialogAccepted": true,
"hasCompletedProjectOnboarding": true,
"projectOnboardingSeenCount": 1,
"enabledMcpjsonServers": ["trusty-mpm"],
})
}
#[test]
fn test_preseed_managed_trust_marks_directory() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("projects").join("my-repo").join("repo");
std::fs::create_dir_all(&workspace).unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let text = std::fs::read_to_string(cfg.join(".claude.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let key = workspace.to_string_lossy().to_string();
let proj = val["projects"][&key].as_object().unwrap();
assert_eq!(
proj.get("hasTrustDialogAccepted"),
Some(&serde_json::Value::Bool(true)),
"hasTrustDialogAccepted must be true"
);
assert_eq!(
proj.get("hasCompletedProjectOnboarding"),
Some(&serde_json::Value::Bool(true)),
"hasCompletedProjectOnboarding must be true"
);
assert!(
proj.get("projectOnboardingSeenCount")
.and_then(|v| v.as_i64())
.is_some_and(|n| n >= 1),
"projectOnboardingSeenCount must be >= 1"
);
}
#[test]
fn test_preseed_managed_trust_is_idempotent() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("my-repo");
std::fs::create_dir_all(&workspace).unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let after_first = std::fs::read_to_string(cfg.join(".claude.json")).unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let after_second = std::fs::read_to_string(cfg.join(".claude.json")).unwrap();
assert_eq!(
after_first, after_second,
"preseed_managed_trust must be idempotent: two calls must produce identical output"
);
}
#[test]
fn test_preseed_managed_trust_preserves_other_keys() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("repo");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::write(
cfg.join(".claude.json"),
r#"{"someOAuthToken":"keep-me","otherField":99}"#,
)
.unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let text = std::fs::read_to_string(cfg.join(".claude.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
val.get("someOAuthToken").and_then(|v| v.as_str()),
Some("keep-me"),
"someOAuthToken must be preserved after trust seed"
);
assert_eq!(
val.get("otherField").and_then(|v| v.as_i64()),
Some(99),
"otherField must be preserved after trust seed"
);
}
#[serial_test::serial]
#[test]
fn test_preseed_managed_trust_no_home_write() {
struct HomeGuard(Option<String>);
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.0 {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
let root = TempDir::new().unwrap();
let cfg = root.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = root.path().join("repo");
std::fs::create_dir_all(&workspace).unwrap();
let fake_home = TempDir::new().unwrap();
let _home_guard = {
let prev = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", fake_home.path()) };
HomeGuard(prev)
};
preseed_managed_trust(&cfg, &workspace).unwrap();
assert!(
cfg.join(".claude.json").exists(),
"preseed_managed_trust must write .claude.json inside claude_config_dir"
);
assert!(
!root.path().join(".claude.json").exists(),
"preseed_managed_trust must NOT write .claude.json outside claude_config_dir \
(isolation invariant)"
);
assert!(
!root.path().join(".claude").exists(),
"preseed_managed_trust must NOT create .claude/ outside claude_config_dir \
(isolation invariant)"
);
assert!(
!fake_home.path().join(".claude.json").exists(),
"preseed_managed_trust must NOT write .claude.json to $HOME \
(isolation invariant)"
);
assert!(
!fake_home.path().join(".claude").exists(),
"preseed_managed_trust must NOT create $HOME/.claude/ \
(isolation invariant)"
);
}
#[test]
fn test_preseed_managed_trust_quarantines_malformed_json() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("repo");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::write(cfg.join(".claude.json"), b"{ this is not valid json !!!").unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let quarantined = quarantine_files(&cfg);
assert_eq!(
quarantined.len(),
1,
"exactly one timestamped quarantine file must exist after malformed-JSON \
quarantine, found: {quarantined:?}"
);
let text = std::fs::read_to_string(cfg.join(".claude.json"))
.expect(".claude.json must exist after quarantine + fresh seed");
let val: serde_json::Value =
serde_json::from_str(&text).expect(".claude.json must be valid JSON after fresh seed");
let key = workspace.to_string_lossy().to_string();
let proj = val["projects"][&key]
.as_object()
.expect("projects.<workspace> must be an object");
assert_eq!(
proj.get("hasTrustDialogAccepted"),
Some(&serde_json::Value::Bool(true)),
"hasTrustDialogAccepted must be true after quarantine + fresh seed"
);
assert_eq!(
proj.get("hasCompletedProjectOnboarding"),
Some(&serde_json::Value::Bool(true)),
"hasCompletedProjectOnboarding must be true after quarantine + fresh seed"
);
}
#[test]
fn prune_drops_entry_when_directory_definitively_absent() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("live-repo");
std::fs::create_dir_all(&workspace).unwrap();
let vanished = tmp.path().join("vanished-fixture-dir");
assert!(!vanished.exists(), "precondition: the path must not exist");
write_config_with_projects(
&cfg,
serde_json::json!({
vanished.to_string_lossy(): seeder_only_entry(),
workspace.to_string_lossy(): seeder_only_entry(),
}),
);
preseed_managed_trust(&cfg, &workspace).unwrap();
let projects = read_projects(&cfg);
assert!(
!projects.contains_key(vanished.to_string_lossy().as_ref()),
"a pure-seeder entry for a definitively-absent directory must be pruned: {:?}",
projects.keys().collect::<Vec<_>>()
);
assert!(
projects.contains_key(workspace.to_string_lossy().as_ref()),
"the live workspace entry must survive the prune"
);
}
#[test]
fn prune_keeps_entry_when_path_error_is_ambiguous() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("live-repo");
std::fs::create_dir_all(&workspace).unwrap();
let blocker = tmp.path().join("not-a-directory");
std::fs::write(&blocker, b"regular file").unwrap();
let unreachable = blocker.join("project");
let err =
std::fs::symlink_metadata(&unreachable).expect_err("stat through a regular file must fail");
assert_ne!(
err.kind(),
std::io::ErrorKind::NotFound,
"precondition: this path must fail AMBIGUOUSLY, not with NotFound — \
otherwise this test is not exercising the over-deletion guard (got {err:?})"
);
write_config_with_projects(
&cfg,
serde_json::json!({
unreachable.to_string_lossy(): seeder_only_entry(),
workspace.to_string_lossy(): seeder_only_entry(),
}),
);
preseed_managed_trust(&cfg, &workspace).unwrap();
let projects = read_projects(&cfg);
assert!(
projects.contains_key(unreachable.to_string_lossy().as_ref()),
"an entry whose path is unreachable for an AMBIGUOUS reason must be KEPT — \
only a definitive NotFound may prune: {:?}",
projects.keys().collect::<Vec<_>>()
);
}
#[test]
fn prune_keeps_entry_with_runtime_fields() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("live-repo");
std::fs::create_dir_all(&workspace).unwrap();
let gone = tmp.path().join("moved-away-project");
assert!(!gone.exists(), "precondition: the path must not exist");
write_config_with_projects(
&cfg,
serde_json::json!({
gone.to_string_lossy(): {
"hasTrustDialogAccepted": true,
"hasCompletedProjectOnboarding": true,
"projectOnboardingSeenCount": 3,
"enabledMcpjsonServers": ["trusty-mpm"],
"lastSessionId": "abc-123",
"lastCost": 4.2,
"mcpServers": { "custom": { "command": "x" } },
},
workspace.to_string_lossy(): seeder_only_entry(),
}),
);
preseed_managed_trust(&cfg, &workspace).unwrap();
let projects = read_projects(&cfg);
let kept = projects
.get(gone.to_string_lossy().as_ref())
.expect("an entry with Claude Code runtime fields must be KEPT even when its path is gone");
assert_eq!(
kept.get("lastSessionId").and_then(|v| v.as_str()),
Some("abc-123"),
"the preserved entry must keep its runtime state intact, not just its key"
);
assert!(
kept.get("mcpServers").is_some(),
"mcpServers must survive the prune"
);
}
#[test]
fn two_quarantine_events_produce_two_distinct_files() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let workspace = tmp.path().join("repo");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::write(cfg.join(".claude.json"), b"{ first corruption !!!").unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
assert_eq!(
quarantine_files(&cfg).len(),
1,
"first quarantine must produce exactly one file"
);
std::fs::write(cfg.join(".claude.json"), b"{ second corruption ???").unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let files = quarantine_files(&cfg);
assert_eq!(
files.len(),
2,
"two quarantine events must leave TWO distinct records, not overwrite one: {files:?}"
);
let bodies: Vec<String> = files
.iter()
.map(|f| std::fs::read_to_string(cfg.join(f)).unwrap())
.collect();
assert!(
bodies.iter().any(|b| b.contains("first corruption")),
"the FIRST failure's bytes must survive the second quarantine: {bodies:?}"
);
assert!(
bodies.iter().any(|b| b.contains("second corruption")),
"the second failure's bytes must be preserved too: {bodies:?}"
);
}
#[test]
fn test_preseed_managed_trust_writes_no_mcp_approval() {
let tmp = tempfile::tempdir().unwrap();
let cfg = tmp.path().join("cfg");
let workspace = tmp.path().join("ws");
std::fs::create_dir_all(&workspace).unwrap();
preseed_managed_trust(&cfg, &workspace).unwrap();
let projects = read_projects(&cfg);
let entry = &projects[&workspace.to_string_lossy().to_string()];
assert_eq!(entry["hasTrustDialogAccepted"], serde_json::json!(true));
assert!(
entry.get("enabledMcpjsonServers").is_none(),
"no MCP name may be pre-approved: {entry}"
);
}
#[test]
fn test_preseed_managed_trust_strips_a_stale_mcp_approval() {
let tmp = tempfile::tempdir().unwrap();
let cfg = tmp.path().join("cfg");
let workspace = tmp.path().join("ws");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::create_dir_all(&cfg).unwrap();
write_config_with_projects(
&cfg,
serde_json::json!({
workspace.to_string_lossy().to_string(): {
"hasTrustDialogAccepted": true,
"hasCompletedProjectOnboarding": true,
"projectOnboardingSeenCount": 4,
"enabledMcpjsonServers": ["trusty-mpm", "evil-server"],
"lastSessionId": "abc-123"
}
}),
);
preseed_managed_trust(&cfg, &workspace).unwrap();
let projects = read_projects(&cfg);
let entry = &projects[&workspace.to_string_lossy().to_string()];
assert!(
entry.get("enabledMcpjsonServers").is_none(),
"a stale approval must be stripped, not merely left unwritten: {entry}"
);
assert_eq!(entry["lastSessionId"], serde_json::json!("abc-123"));
assert_eq!(entry["projectOnboardingSeenCount"], serde_json::json!(4));
}
const CONCURRENT_WRITERS: usize = 8;
const CONCURRENT_ROUNDS: usize = 40;
#[test]
fn concurrent_managed_seeds_preserve_every_workspace_entry() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
let workspaces: Vec<std::path::PathBuf> = (0..CONCURRENT_WRITERS)
.flat_map(|writer| {
let cfg = cfg.clone();
(0..CONCURRENT_ROUNDS).map(move |round| cfg.join(format!("ws-w{writer}-s{round}")))
})
.collect();
for ws in &workspaces {
std::fs::create_dir_all(ws).expect("workspace dir");
}
std::thread::scope(|scope| {
for writer in 0..CONCURRENT_WRITERS {
let (cfg, workspaces) = (&cfg, &workspaces);
scope.spawn(move || {
for round in 0..CONCURRENT_ROUNDS {
let ws = &workspaces[writer * CONCURRENT_ROUNDS + round];
preseed_managed_trust(cfg, ws).expect("a managed trust seed must not fail");
}
});
}
});
let projects = read_projects(&cfg);
let lost: Vec<String> = workspaces
.iter()
.map(|ws| ws.to_string_lossy().to_string())
.filter(|key| !projects.contains_key(key))
.collect();
assert!(
lost.is_empty(),
"concurrent managed trust seeds lost {} of {} workspace entries — a \
read-modify-write cycle stored a snapshot taken before a sibling's store \
(issue #4076). Lost: {lost:?}",
lost.len(),
workspaces.len()
);
for ws in &workspaces {
let entry = &projects[&ws.to_string_lossy().to_string()];
assert_eq!(
entry["hasTrustDialogAccepted"],
serde_json::json!(true),
"{} lost its trust acceptance",
ws.display()
);
assert_eq!(
entry["hasCompletedProjectOnboarding"],
serde_json::json!(true),
"{} lost its onboarding acceptance",
ws.display()
);
}
}