use super::*;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
static FIXTURE_EXEC_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(unix)]
fn fixture_exec_guard() -> std::sync::MutexGuard<'static, ()> {
FIXTURE_EXEC_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[test]
fn defaults_are_expected() {
assert_eq!(default_min_size_mb(), 512);
assert_eq!(default_kinds(), "rust-build,node-deps,build-output,cache");
}
#[test]
fn nix_delete_generations_arg_means_keep_last_n() {
assert_eq!(nix_delete_generations_arg(5), Some("+5".to_string()));
assert_eq!(nix_delete_generations_arg(1), Some("+1".to_string()));
assert_eq!(nix_delete_generations_arg(0), None);
}
#[cfg(unix)]
#[tokio::test]
async fn nix_cleanup_apply_preserves_configured_profile_generations() {
let _fixture_exec = fixture_exec_guard();
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
let root = std::env::temp_dir().join(format!(
"dracon_system_nix_cleanup_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&root).expect("create temp dir");
let log = root.join("calls.log");
let quoted_log = shell_quote(log.to_str().expect("log path"));
let nix_env = root.join("nix-env");
let nix_gc = root.join("nix-collect-garbage");
write_test_script(
&nix_env,
&format!("echo \"nix-env:$*\" >> {quoted_log}\nexit 0"),
);
write_test_script(
&nix_gc,
&format!(
r#"echo "nix-gc:$*" >> {quoted_log}
if [ "$1" = "-d" ]; then
echo 'would delete retained profile generations' >&2
exit 99
fi
exit 0"#
),
);
let result = clean_nix_garbage_with_bins(5, true, &nix_env, &nix_gc)
.await
.expect("Nix cleanup should succeed without destructive garbage mode");
assert_eq!(result, (0, Vec::new()));
let calls = fs::read_to_string(&log).expect("read command log");
let lines: Vec<&str> = calls.lines().collect();
assert_eq!(
lines.len(),
3,
"two profile prunes and one store GC expected"
);
assert_eq!(lines[0], "nix-env:--delete-generations +5");
assert_eq!(
lines[1],
"nix-env:--delete-generations +5 -p /nix/var/nix/profiles/default"
);
assert_eq!(lines[2], "nix-gc:");
assert!(
!lines[2].contains("-d"),
"apply must not use nix-collect-garbage -d, which discards retained generations"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn guard_clean_bare_command_selects_all_targets() {
let cli = Cli::try_parse_from(["dracon-system", "guard", "clean"])
.expect("bare guard clean should parse");
let Commands::Guard {
cmd:
GuardCommands::Clean {
all,
rust,
trash,
nix,
caches,
node_modules,
docker,
..
},
} = cli.cmd
else {
panic!("expected guard clean command");
};
assert!(
!all,
"the bare command should not need an explicit --all flag"
);
let targets = resolve_clean_targets(
all,
&CleanTargets {
rust,
trash,
nix,
caches,
node_modules,
docker,
},
);
assert!(
targets.rust
&& targets.trash
&& targets.nix
&& targets.caches
&& targets.node_modules
&& targets.docker,
"bare guard clean must resolve every cleanup target"
);
}
#[test]
fn guard_clean_target_flags_remain_selective_and_all_overrides_them() {
let rust_only = CleanTargets {
rust: true,
..CleanTargets::default()
};
let resolved = resolve_clean_targets(false, &rust_only);
assert!(resolved.rust);
assert!(!resolved.trash);
let resolved_all = resolve_clean_targets(true, &rust_only);
assert!(resolved_all.rust && resolved_all.trash && resolved_all.docker);
}
#[cfg(unix)]
#[tokio::test]
async fn renice_process_with_bin_reports_success_and_failure() {
let _fixture_exec = fixture_exec_guard();
let tmp = std::env::temp_dir().join(format!(
"dracon_system_renice_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&tmp).expect("temp dir");
let success = tmp.join("renice-success");
write_test_script(&success, "echo 'ok' >&2\nexit 0");
let failure = tmp.join("renice-failure");
write_test_script(&failure, "echo 'permission denied' >&2\nexit 1");
renice_process_with_bin(&success, 123, 5)
.await
.expect("success renice");
let err = renice_process_with_bin(&failure, 123, 5)
.await
.expect_err("failure renice");
assert!(err.to_string().contains("permission denied"));
let _ = fs::remove_dir_all(&tmp);
}
#[cfg(unix)]
fn write_process_fixture(
root: &std::path::Path,
pid: i32,
comm: &str,
starttime: u64,
oom_score_adj: Option<i32>,
cgroup: Option<&str>,
) {
fs::create_dir_all(root.join("self")).expect("create proc availability fixture");
let dir = root.join(pid.to_string());
fs::create_dir_all(&dir).expect("create process fixture");
fs::write(dir.join("comm"), format!("{comm}\n")).expect("write comm fixture");
let mut stat_fields = vec!["S".to_string()];
stat_fields.extend(std::iter::repeat_n("0".to_string(), 18));
stat_fields.push(starttime.to_string());
fs::write(
dir.join("stat"),
format!("{pid} ({comm}) {}\n", stat_fields.join(" ")),
)
.expect("write stat fixture");
if let Some(adj) = oom_score_adj {
fs::write(dir.join("oom_score_adj"), format!("{adj}\n")).expect("write oom fixture");
}
if let Some(cgroup) = cgroup {
fs::write(dir.join("cgroup"), cgroup).expect("write cgroup fixture");
}
}
const FIXTURE_SETTLE_ARG: &str = "--dracon-fixture-settle";
#[cfg(unix)]
fn settle_fixture(path: &std::path::Path) {
const ETXTBSY: i32 = 26;
const ATTEMPTS: usize = 40;
for attempt in 0..ATTEMPTS {
match std::process::Command::new(path)
.arg(FIXTURE_SETTLE_ARG)
.output()
{
Ok(_) => return,
Err(e) if e.raw_os_error() == Some(ETXTBSY) => {
std::thread::sleep(std::time::Duration::from_millis(1 + (attempt as u64) / 8));
}
Err(e) => panic!("fixture {} is not executable: {e}", path.display()),
}
}
panic!(
"fixture {} stayed exec-busy (ETXTBSY) across {ATTEMPTS} settle attempts",
path.display()
);
}
#[cfg(unix)]
fn write_executable_script(path: &std::path::Path, body: &str) {
fs::write(
path,
format!("#!/bin/sh\ncase \"$1\" in\n{FIXTURE_SETTLE_ARG}) exit 0;;\nesac\n{body}\n"),
)
.expect("write test script");
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("chmod test script");
}
#[cfg(unix)]
fn write_test_script(path: &std::path::Path, body: &str) {
write_executable_script(path, body);
settle_fixture(path);
}
#[cfg(unix)]
#[test]
fn settled_fixture_survives_parallel_reexec() {
const THREADS: usize = 8;
const REEXECS: usize = 100;
const ETXTBSY: i32 = 26;
let etxtbsy = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let workers: Vec<_> = (0..THREADS)
.map(|thread| {
let etxtbsy = std::sync::Arc::clone(&etxtbsy);
std::thread::spawn(move || {
let dir = unique_test_home(&format!("settled_{thread}"));
fs::create_dir_all(&dir).expect("create fixture dir");
let script = dir.join("fixture");
{
let _fixture_exec = fixture_exec_guard();
write_test_script(&script, "exit 0");
}
for _ in 0..REEXECS {
if let Err(e) = std::process::Command::new(&script).output() {
if e.raw_os_error() == Some(ETXTBSY) {
etxtbsy.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
} else {
panic!("settled fixture exec failed unexpectedly: {e}");
}
}
}
let _ = fs::remove_dir_all(&dir);
})
})
.collect();
for worker in workers {
worker.join().expect("worker thread must not panic");
}
assert_eq!(
etxtbsy.load(std::sync::atomic::Ordering::SeqCst),
0,
"a settled fixture must never come back exec-busy ({} execs across {} threads)",
THREADS * REEXECS,
THREADS
);
}
#[cfg(unix)]
#[test]
fn settle_fixture_probes_without_side_effects_and_fails_loudly() {
let dir = unique_test_home("settle_contract");
fs::create_dir_all(&dir).expect("create fixture dir");
let log = dir.join("calls.log");
let escaped = log.to_string_lossy().replace('\'', "'\\''");
let script = dir.join("recording");
write_test_script(&script, &format!("printf '%s\\n' \"$*\" >> '{escaped}'"));
assert!(
!log.exists(),
"settling must not run the fixture body: the probe created the log"
);
std::process::Command::new(&script)
.args(["-n", "5", "-p", "123"])
.output()
.expect("a settled fixture must exec");
assert_eq!(
fs::read_to_string(&log).expect("log written by the real exec"),
"-n 5 -p 123\n",
"the real invocation must record exactly one line, probe or no probe"
);
let broken = dir.join("not-executable");
fs::write(&broken, "#!/bin/sh\nexit 0\n").expect("write broken fixture");
fs::set_permissions(&broken, fs::Permissions::from_mode(0o644)).expect("clear the execute bit");
let outcome = std::panic::catch_unwind(|| settle_fixture(&broken));
let payload = outcome.expect_err("settling a non-executable fixture must panic, not swallow");
let message = payload
.downcast_ref::<String>()
.cloned()
.unwrap_or_else(|| "<non-string panic payload>".to_string());
assert!(
message.contains("is not executable") && message.contains("not-executable"),
"the panic must name the fixture and the reason, got: {message}"
);
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn fixture_writing_tests_hold_the_fixture_exec_guard() {
const SOURCE: &str = include_str!("tests.rs");
const HELPERS: [&str; 2] = ["write_test_script", "write_executable_script"];
let mut bodies: Vec<(String, String)> = Vec::new();
for line in SOURCE.lines() {
if line.starts_with("fn ") || line.starts_with("async fn ") {
let name = line
.split_once('(')
.map(|(head, _)| head)
.unwrap_or(line)
.trim_start_matches("async fn ")
.trim_start_matches("fn ")
.trim()
.to_string();
bodies.push((name, String::new()));
continue;
}
if let Some((_, body)) = bodies.last_mut() {
body.push_str(line);
body.push('\n');
}
}
let mut guarded = 0usize;
let mut offenders: Vec<&str> = Vec::new();
for (name, body) in &bodies {
if HELPERS.contains(&name.as_str()) {
continue;
}
let writes_fixture = ["write_test_script(", "write_executable_script("]
.iter()
.any(|call| body.contains(call));
if !writes_fixture {
continue;
}
guarded += 1;
if !body.contains("fixture_exec_guard()") {
offenders.push(name.as_str());
}
}
assert!(
guarded >= 10,
"the source scan should find every fixture-writing test; found {guarded}, \
which means the top-level body parser drifted from this module's layout"
);
assert!(
offenders.is_empty(),
"these tests write an executable fixture and then exec it but never hold \
fixture_exec_guard() for the create->exec window (see FIXTURE_EXEC_LOCK): \
{offenders:?}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn restore_runtime_adjustments_restores_renice_and_oom() {
let _fixture_exec = fixture_exec_guard();
let tmp = std::env::temp_dir().join(format!(
"dracon_system_restore_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&tmp).expect("create temp dir");
let renice = tmp.join("renice");
let systemctl = tmp.join("systemctl");
write_test_script(&renice, "exit 0");
write_test_script(&systemctl, "exit 0");
write_process_fixture(&tmp, 101, "zsh", 77, Some(250), None);
let identity = ProcessIdentity {
comm: "zsh".to_string(),
starttime: 77,
};
assert_eq!(
process_identity_status(&tmp, 101, &identity),
ProcessIdentityStatus::Match
);
assert_eq!(
process_identity_status(
&tmp,
101,
&ProcessIdentity {
comm: "zsh".to_string(),
starttime: 78,
},
),
ProcessIdentityStatus::Mismatch
);
assert_eq!(
process_identity_status(&tmp, 102, &identity),
ProcessIdentityStatus::Gone
);
let missing_proc_root = tmp.join("missing-proc-root");
assert_eq!(
process_identity_status(&missing_proc_root, 101, &identity),
ProcessIdentityStatus::Unavailable,
"a missing proc root must not be treated as a gone PID"
);
let mut unavailable_state = GuardRuntimeState::default();
unavailable_state.reniced_pids.insert(
101,
LegacyReniceState {
original_nice: 0,
applied_nice: 5,
identity: identity.clone(),
},
);
unavailable_state.capped_pids.insert(
101,
(
"dracon-cap.service".to_string(),
"user.slice".to_string(),
identity.clone(),
),
);
assert!(
!restore_runtime_adjustments_with(
&mut unavailable_state,
&renice,
&systemctl,
&missing_proc_root,
)
.await,
"missing proc root must retain every indeterminate adjustment"
);
assert!(unavailable_state.reniced_pids.contains_key(&101));
assert!(unavailable_state.capped_pids.contains_key(&101));
let failing_renice = tmp.join("renice-failure");
write_test_script(&failing_renice, "exit 1");
write_process_fixture(&tmp, 103, "worker", 79, None, None);
write_process_fixture(&tmp, 104, "worker", 80, None, None);
fs::create_dir(tmp.join("104/oom_score_adj")).expect("create failing oom fixture");
let mut failure_state = GuardRuntimeState::default();
failure_state.reniced_pids.insert(
103,
LegacyReniceState {
original_nice: 0,
applied_nice: 5,
identity: ProcessIdentity {
comm: "worker".to_string(),
starttime: 79,
},
},
);
failure_state.oom_biased_pids.insert(
104,
(
-100,
ProcessIdentity {
comm: "worker".to_string(),
starttime: 80,
},
),
);
assert!(
!restore_runtime_adjustments_with(&mut failure_state, &failing_renice, &systemctl, &tmp)
.await,
"failed release operations must prevent runtime reset"
);
assert!(failure_state.reniced_pids.contains_key(&103));
assert!(failure_state.oom_biased_pids.contains_key(&104));
write_process_fixture(&tmp, 105, "very-long-process", 81, None, None);
let long_comm_identity = ProcessIdentity {
comm: "very-long".to_string(),
starttime: 81,
};
assert_eq!(
process_identity_status(&tmp, 105, &long_comm_identity),
ProcessIdentityStatus::Match
);
write_process_fixture(&tmp, 106, "memory-worker", 82, None, None);
let renice_log = tmp.join("renice.log");
let recording_renice = tmp.join("renice-recording");
let escaped_renice_log = renice_log.to_string_lossy().replace('\'', "'\\''");
write_test_script(
&recording_renice,
&format!("printf '%s\\n' \"$*\" >> '{escaped_renice_log}'\nexit 0"),
);
let mut state = GuardRuntimeState::default();
state.reniced_pids.insert(
101,
LegacyReniceState {
original_nice: 0,
applied_nice: 5,
identity: identity.clone(),
},
);
state.reniced_pids.insert(
105,
LegacyReniceState {
original_nice: 0,
applied_nice: 5,
identity: long_comm_identity,
},
);
state.oom_biased_pids.insert(101, (-100, identity));
state.memory_reniced_pids.insert(
106,
MemoryReniceState {
original_nice: 10,
applied_nice: 5,
identity: ProcessIdentity {
comm: "memory-worker".to_string(),
starttime: 82,
},
},
);
assert!(
restore_runtime_adjustments_with(&mut state, &recording_renice, &systemctl, &tmp).await,
"all successful restorations should permit runtime reset"
);
assert!(state.reniced_pids.is_empty());
assert!(state.memory_reniced_pids.is_empty());
assert!(state.oom_biased_pids.is_empty());
assert_eq!(
fs::read_to_string(tmp.join("101/oom_score_adj")).unwrap(),
"-100\n"
);
let renice_calls = fs::read_to_string(&renice_log).unwrap();
assert!(
renice_calls.lines().any(|call| call == "-n 10 -p 106"),
"memory release must restore the captured original nice value"
);
let _ = fs::remove_dir_all(&tmp);
}
#[cfg(unix)]
#[tokio::test]
async fn restore_runtime_adjustments_composes_overlapping_nice_limiters() {
let _fixture_exec = fixture_exec_guard();
let tmp = std::env::temp_dir().join(format!(
"dracon_system_overlap_restore_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&tmp).expect("create temp dir");
let renice_log = tmp.join("renice.log");
let renice = tmp.join("renice");
let systemctl = tmp.join("systemctl");
let escaped_log = renice_log.to_string_lossy().replace('\'', "'\\''");
write_test_script(
&renice,
&format!("printf '%s\\n' \"$*\" >> '{escaped_log}'\nexit 0"),
);
write_test_script(&systemctl, "exit 0");
write_process_fixture(&tmp, 107, "overlap-worker", 83, None, None);
let identity = ProcessIdentity {
comm: "overlap-worker".to_string(),
starttime: 83,
};
let mut state = GuardRuntimeState::default();
state.reniced_pids.insert(
107,
LegacyReniceState {
original_nice: 3,
applied_nice: 8,
identity: identity.clone(),
},
);
state.memory_reniced_pids.insert(
107,
MemoryReniceState {
original_nice: 3,
applied_nice: 12,
identity,
},
);
assert!(
restore_runtime_adjustments_with(&mut state, &renice, &systemctl, &tmp).await,
"overlapping nice limiters should restore successfully"
);
assert!(state.reniced_pids.is_empty());
assert!(state.memory_reniced_pids.is_empty());
assert_eq!(
fs::read_to_string(&renice_log)
.expect("read renice log")
.lines()
.collect::<Vec<_>>(),
vec!["-n 3 -p 107"],
"one compositional restore must target the pre-limiter nice value"
);
let _ = fs::remove_dir_all(&tmp);
}
#[cfg(unix)]
#[tokio::test]
async fn restore_runtime_adjustments_retains_overlapping_nice_limiters_on_failure() {
let _fixture_exec = fixture_exec_guard();
let tmp = std::env::temp_dir().join(format!(
"dracon_system_overlap_failure_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&tmp).expect("create temp dir");
let renice = tmp.join("renice-failure");
let systemctl = tmp.join("systemctl");
write_test_script(&renice, "exit 1");
write_test_script(&systemctl, "exit 0");
write_process_fixture(&tmp, 108, "overlap-worker", 84, None, None);
let identity = ProcessIdentity {
comm: "overlap-worker".to_string(),
starttime: 84,
};
let mut state = GuardRuntimeState::default();
state.reniced_pids.insert(
108,
LegacyReniceState {
original_nice: 4,
applied_nice: 9,
identity: identity.clone(),
},
);
state.memory_reniced_pids.insert(
108,
MemoryReniceState {
original_nice: 4,
applied_nice: 13,
identity,
},
);
assert!(
!restore_runtime_adjustments_with(&mut state, &renice, &systemctl, &tmp).await,
"a failed compositional restore must remain retryable"
);
assert!(state.reniced_pids.contains_key(&108));
assert!(state.memory_reniced_pids.contains_key(&108));
let _ = fs::remove_dir_all(&tmp);
}
#[cfg(unix)]
#[tokio::test]
async fn restore_runtime_adjustments_preserves_current_pid_incarnation() {
let _fixture_exec = fixture_exec_guard();
let tmp = std::env::temp_dir().join(format!(
"dracon_system_pid_reuse_restore_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&tmp).expect("create temp dir");
let renice_log = tmp.join("renice.log");
let renice = tmp.join("renice");
let systemctl = tmp.join("systemctl");
let escaped_log = renice_log.to_string_lossy().replace('\'', "'\\''");
write_test_script(
&renice,
&format!("printf '%s\\n' \"$*\" >> '{escaped_log}'\nexit 0"),
);
write_test_script(&systemctl, "exit 0");
write_process_fixture(&tmp, 109, "reused-worker", 86, None, None);
let mut state = GuardRuntimeState::default();
state.reniced_pids.insert(
109,
LegacyReniceState {
original_nice: 3,
applied_nice: 8,
identity: ProcessIdentity {
comm: "reused-worker".to_string(),
starttime: 85,
},
},
);
state.memory_reniced_pids.insert(
109,
MemoryReniceState {
original_nice: 7,
applied_nice: 12,
identity: ProcessIdentity {
comm: "reused-worker".to_string(),
starttime: 86,
},
},
);
assert!(
restore_runtime_adjustments_with(&mut state, &renice, &systemctl, &tmp).await,
"stale PID state must not prevent current-incarnation restoration"
);
assert!(state.reniced_pids.is_empty());
assert!(state.memory_reniced_pids.is_empty());
assert_eq!(
fs::read_to_string(&renice_log)
.expect("read renice log")
.lines()
.collect::<Vec<_>>(),
vec!["-n 7 -p 109"],
"the replacement process must restore its own captured nice value"
);
let _ = fs::remove_dir_all(&tmp);
}
#[cfg(unix)]
#[test]
fn stale_nice_state_does_not_override_current_pid_incarnation() {
let mut state = GuardRuntimeState::default();
state.reniced_pids.insert(
110,
LegacyReniceState {
original_nice: 2,
applied_nice: 8,
identity: ProcessIdentity {
comm: "worker".to_string(),
starttime: 90,
},
},
);
state.memory_reniced_pids.insert(
110,
MemoryReniceState {
original_nice: 6,
applied_nice: 12,
identity: ProcessIdentity {
comm: "worker".to_string(),
starttime: 91,
},
},
);
let current_identity = ProcessIdentity {
comm: "worker".to_string(),
starttime: 91,
};
drop_stale_nice_adjustments(&mut state, 110, ¤t_identity);
assert!(!state.reniced_pids.contains_key(&110));
assert_eq!(
state
.memory_reniced_pids
.get(&110)
.map(|entry| entry.original_nice),
Some(6)
);
}
#[cfg(unix)]
#[test]
fn sweep_stranded_oom_descendants_restores_only_post_bias_children() {
let tmp = std::env::temp_dir().join(format!(
"dracon_system_oom_descendants_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&tmp).expect("create temp dir");
write_process_fixture(&tmp, 101, "oom-parent", 77, Some(250), None);
write_process_fixture(&tmp, 102, "existing-child", 78, Some(250), None);
write_process_fixture(&tmp, 103, "forked-child", 79, Some(250), None);
write_process_fixture(&tmp, 104, "unrelated", 80, Some(250), None);
write_process_fixture(&tmp, 105, "failed-child", 81, None, None);
fs::create_dir(tmp.join("105/oom_score_adj")).expect("create failing child oom fixture");
let sample = |pid: i32, ppid: i32, starttime: u64, command: &str| ProcSample {
pid,
ppid,
cpu_percent: 0.0,
rss_mb: 1,
nice: 0,
command: command.to_string(),
args: String::new(),
starttime,
};
let samples = vec![
sample(101, 1, 77, "oom-parent"),
sample(102, 101, 78, "existing-child"),
sample(103, 101, 79, "forked-child"),
sample(104, 1, 80, "unrelated"),
sample(105, 101, 81, "failed-child"),
];
let identity = ProcessIdentity {
comm: "oom-parent".to_string(),
starttime: 77,
};
let mut state = GuardRuntimeState::default();
state.oom_biased_pids.insert(101, (-100, identity));
state
.oom_known_descendants
.insert(101, std::collections::HashSet::from([(102, 78)]));
let actions = sweep_stranded_oom_descendants(
&tmp,
&samples,
&mut state,
&std::collections::HashSet::new(),
);
assert_eq!(
actions.restored,
vec!["oom-restore-descendant forked-child=-100"]
);
assert_eq!(actions.deferred, 1);
assert!(state.oom_pending_descendants.contains_key(&(105, 81)));
remove_oom_bias(&mut state, 101);
assert!(
state.oom_biased_pids.contains_key(&101),
"root bias must remain tracked while child restoration is pending"
);
assert_eq!(
fs::read_to_string(tmp.join("102/oom_score_adj")).unwrap(),
"250\n"
);
assert_eq!(
fs::read_to_string(tmp.join("103/oom_score_adj")).unwrap(),
"-100\n"
);
assert_eq!(
fs::read_to_string(tmp.join("104/oom_score_adj")).unwrap(),
"250\n"
);
assert!(state
.oom_known_descendants
.get(&101)
.unwrap()
.contains(&(103, 79)));
fs::remove_dir(tmp.join("105/oom_score_adj")).expect("remove failing child fixture");
fs::write(tmp.join("105/oom_score_adj"), "250\n").expect("restore child fixture");
let retry = sweep_stranded_oom_descendants(
&tmp,
&samples,
&mut state,
&std::collections::HashSet::new(),
);
assert_eq!(
retry.restored,
vec!["oom-restore-descendant failed-child=-100"]
);
assert_eq!(retry.deferred, 0);
assert!(!state.oom_pending_descendants.contains_key(&(105, 81)));
remove_oom_bias(&mut state, 101);
assert!(!state.oom_biased_pids.contains_key(&101));
let _ = fs::remove_dir_all(&tmp);
}
#[cfg(unix)]
#[test]
fn sweep_stranded_oom_descendants_uses_nearest_biased_ancestor() {
let tmp = std::env::temp_dir().join(format!(
"dracon_system_nested_oom_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&tmp).expect("create temp dir");
write_process_fixture(&tmp, 201, "outer", 91, Some(250), None);
write_process_fixture(&tmp, 202, "inner", 92, Some(250), None);
write_process_fixture(&tmp, 203, "nested-child", 93, Some(250), None);
let sample = |pid: i32, ppid: i32, starttime: u64, command: &str| ProcSample {
pid,
ppid,
cpu_percent: 0.0,
rss_mb: 1,
nice: 0,
command: command.to_string(),
args: String::new(),
starttime,
};
let samples = vec![
sample(201, 1, 91, "outer"),
sample(202, 201, 92, "inner"),
sample(203, 202, 93, "nested-child"),
];
let mut state = GuardRuntimeState::default();
state.oom_biased_pids.insert(
201,
(
-100,
ProcessIdentity {
comm: "outer".to_string(),
starttime: 91,
},
),
);
state.oom_biased_pids.insert(
202,
(
25,
ProcessIdentity {
comm: "inner".to_string(),
starttime: 92,
},
),
);
state
.oom_known_descendants
.insert(201, std::collections::HashSet::from([(202, 92)]));
state
.oom_known_descendants
.insert(202, std::collections::HashSet::new());
let result = sweep_stranded_oom_descendants(
&tmp,
&samples,
&mut state,
&std::collections::HashSet::new(),
);
assert_eq!(
result.restored,
vec!["oom-restore-descendant nested-child=25"]
);
assert_eq!(result.deferred, 0);
assert_eq!(
fs::read_to_string(tmp.join("203/oom_score_adj")).unwrap(),
"25\n"
);
let _ = fs::remove_dir_all(&tmp);
}
#[cfg(unix)]
#[tokio::test]
async fn restore_runtime_adjustments_retains_failed_cpu_cap() {
let _fixture_exec = fixture_exec_guard();
let tmp = std::env::temp_dir().join(format!(
"dracon_system_cap_restore_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&tmp).expect("create temp dir");
fs::create_dir_all(tmp.join("self")).expect("create proc availability marker");
let renice = tmp.join("renice");
let systemctl = tmp.join("systemctl");
write_test_script(&renice, "exit 0");
write_test_script(&systemctl, "exit 1");
let mut state = GuardRuntimeState::default();
state.capped_pids.insert(
999_999,
(
"dracon-cap.service".to_string(),
"user.slice".to_string(),
ProcessIdentity {
comm: "gone".to_string(),
starttime: 1,
},
),
);
assert!(
!restore_runtime_adjustments_with(&mut state, &renice, &systemctl, &tmp).await,
"failed systemd cleanup must prevent runtime reset"
);
assert!(state.capped_pids.contains_key(&999_999));
write_test_script(&systemctl, "exit 0");
assert!(restore_runtime_adjustments_with(&mut state, &renice, &systemctl, &tmp).await);
assert!(state.capped_pids.is_empty());
write_process_fixture(&tmp, 1000, "worker", 88, None, None);
fs::create_dir(tmp.join("1000/cgroup")).expect("create unreadable cgroup fixture");
state.capped_pids.insert(
1000,
(
"dracon-cap.service".to_string(),
"user.slice".to_string(),
ProcessIdentity {
comm: "worker".to_string(),
starttime: 88,
},
),
);
assert!(
!restore_runtime_adjustments_with(&mut state, &renice, &systemctl, &tmp).await,
"cgroup read failure must prevent runtime reset"
);
assert!(state.capped_pids.contains_key(&1000));
let systemctl_probe = tmp.join("systemctl-probe");
let probe_log = tmp.join("systemctl-probe.log");
let escaped_probe_log = probe_log.to_string_lossy().replace('\'', "'\\''");
write_test_script(
&systemctl_probe,
&format!("printf 'called\\n' >> '{escaped_probe_log}'\nexit 0"),
);
write_process_fixture(&tmp, 1001, "worker", 89, None, None);
state.capped_pids.insert(
1001,
(
"dracon-cap.service".to_string(),
"user.slice".to_string(),
ProcessIdentity {
comm: "worker".to_string(),
starttime: 89,
},
),
);
assert!(
!restore_runtime_adjustments_with(&mut state, &renice, &systemctl_probe, &tmp).await,
"a missing live-PID cgroup file must prevent runtime reset"
);
assert!(state.capped_pids.contains_key(&1001));
write_process_fixture(&tmp, 1002, "worker", 90, None, Some("malformed\n"));
state.capped_pids.insert(
1002,
(
"dracon-cap.service".to_string(),
"user.slice".to_string(),
ProcessIdentity {
comm: "worker".to_string(),
starttime: 90,
},
),
);
assert!(
!restore_runtime_adjustments_with(&mut state, &renice, &systemctl_probe, &tmp).await,
"malformed cgroup data must prevent runtime reset"
);
assert!(state.capped_pids.contains_key(&1002));
assert!(
!probe_log.exists(),
"indeterminate cgroup state must not stop the transient service"
);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn human_bytes_formats_units() {
assert_eq!(human_bytes(1), "1.0 B");
assert_eq!(human_bytes(1024), "1.0 KiB");
assert_eq!(human_bytes(1024 * 1024), "1.0 MiB");
}
#[test]
fn parse_kinds_trims_and_dedupes() {
let kinds = parse_kinds(" rust-build, node-deps ,rust-build,,cache ");
assert_eq!(kinds.len(), 3);
assert!(kinds.contains("rust-build"));
assert!(kinds.contains("node-deps"));
assert!(kinds.contains("cache"));
}
#[test]
fn expand_tilde_resolves_to_home_dir() {
let home = dirs::home_dir().expect("home dir should exist");
assert_eq!(expand_tilde("~"), home);
assert_eq!(expand_tilde("~/foo/bar"), home.join("foo/bar"));
assert_eq!(expand_tilde("/x/y"), PathBuf::from("/x/y"));
}
#[test]
fn expand_tilde_with_home_unset_falls_back_to_dot() {
assert_eq!(
expand_tilde("/absolute/path"),
PathBuf::from("/absolute/path")
);
assert_eq!(
expand_tilde("relative/path"),
PathBuf::from("relative/path")
);
}
#[test]
fn guard_log_path_expands_the_example_configuration() {
let policy: SystemPolicy = toml::from_str(include_str!("../dracon-system.example.toml"))
.expect("shipped example policy should parse");
assert_eq!(
policy.guard.guard_log_file,
"~/.local/state/dracon/dracon-system-guard.log"
);
let home = dirs::home_dir().expect("home directory");
let resolved = resolve_guard_log_path(&policy.guard.guard_log_file)
.expect("example guard log path should be enabled");
assert_eq!(
resolved,
home.join(".local/state/dracon/dracon-system-guard.log")
);
assert!(!resolved.to_string_lossy().contains('~'));
assert!(resolve_guard_log_path(" ").is_none());
assert_eq!(
resolve_guard_log_path("logs/guard.log"),
Some(PathBuf::from("logs/guard.log"))
);
}
#[test]
fn guard_log_example_path_writes_and_startup_rotates_in_isolated_home() {
let policy: SystemPolicy = toml::from_str(include_str!("../dracon-system.example.toml"))
.expect("shipped example policy should parse");
assert_eq!(
policy.guard.guard_log_file,
"~/.local/state/dracon/dracon-system-guard.log"
);
let home = std::env::temp_dir().join(format!(
"dracon_system_guard_log_home_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after Unix epoch")
.as_nanos()
));
let mut guard = policy.guard;
guard.guard_log_max_mb = 1;
let path = resolve_guard_log_path_with_home(&guard.guard_log_file, Some(&home))
.expect("example guard log path should be enabled");
assert_eq!(
path,
home.join(".local/state/dracon/dracon-system-guard.log")
);
fs::create_dir_all(path.parent().expect("guard log parent")).expect("create guard log parent");
fs::write(&path, vec![b'x'; 1024 * 1024 + 1]).expect("seed oversized guard log");
rotate_guard_log_for_policy_with_home(&guard, true, Some(&home));
assert!(
!path.exists(),
"startup rotation must use the expanded advertised log path"
);
log_guard_event_with_home(&guard, "f66-test", "expanded path", Some(&home));
let contents = fs::read_to_string(&path).expect("expanded guard log should be written");
let record: serde_json::Value =
serde_json::from_str(contents.trim()).expect("guard log should contain JSONL");
assert_eq!(record["event"], "f66-test");
assert_eq!(record["details"], "expanded path");
assert!(
fs::metadata(&path).expect("guard log metadata").len() < 1024 * 1024,
"guard log should remain below the configured limit after rotation"
);
fs::remove_dir_all(&home).expect("remove isolated guard log home");
}
#[test]
fn build_link_report_counts_states() {
let policy = SystemPolicy {
storage: StoragePolicy::default(),
links: LinkPolicy {
entries: vec![LinkEntry {
link: "/tmp/does-not-exist-link".into(),
target: "/tmp/does-not-exist-target".into(),
}],
},
guard: GuardPolicy::default(),
};
let report = build_link_report(&policy);
assert_eq!(report.total, 1);
assert_eq!(report.healthy, 0);
assert_eq!(report.drifted, 1);
assert_eq!(report.missing_target, 1);
}
#[cfg(unix)]
#[test]
fn evaluate_link_handles_missing_and_sync_cases() {
let base = std::env::temp_dir().join(format!(
"dracon_system_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&base).expect("base dir");
let target = base.join("target.txt");
fs::write(&target, "x").expect("target");
let missing_link = LinkEntry {
link: base.join("missing-link").display().to_string(),
target: target.display().to_string(),
};
let s1 = evaluate_link(&missing_link);
assert_eq!(s1.issue, "link_missing");
let normal_file_link = base.join("normal-file");
fs::write(&normal_file_link, "x").expect("file");
let not_symlink = LinkEntry {
link: normal_file_link.display().to_string(),
target: target.display().to_string(),
};
let s2 = evaluate_link(¬_symlink);
assert_eq!(s2.issue, "path_not_symlink");
let good_link = base.join("good-link");
symlink(&target, &good_link).expect("symlink");
let synced = LinkEntry {
link: good_link.display().to_string(),
target: target.display().to_string(),
};
let s3 = evaluate_link(&synced);
assert_eq!(s3.issue, "ok");
assert!(s3.in_sync);
let wrong_target = base.join("other.txt");
fs::write(&wrong_target, "y").expect("other");
let mismatch_link = base.join("mismatch-link");
symlink(&wrong_target, &mismatch_link).expect("symlink mismatch");
let mismatch = LinkEntry {
link: mismatch_link.display().to_string(),
target: target.display().to_string(),
};
let s4 = evaluate_link(&mismatch);
assert_eq!(s4.issue, "link_target_mismatch");
let _ = fs::remove_dir_all(&base);
}
#[test]
fn parse_and_format_repeated_scenarios() {
for i in 0..220usize {
let csv = if i % 2 == 0 {
"rust-build,node-deps,cache"
} else {
" rust-build , build-output , cache , rust-build "
};
let kinds = parse_kinds(csv);
assert!(kinds.contains("rust-build"));
assert!(kinds.contains("cache"));
let bytes = (i as u64 + 1) * 2048;
let out = human_bytes(bytes);
assert!(!out.is_empty());
assert!(out.contains(' '));
}
}
#[test]
fn parse_df_use_percent_works() {
let sample =
"Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/root 100 91 9 91% /\n";
assert_eq!(parse_df_use_percent(sample), Some(91));
}
#[test]
fn parse_ps_output_works() {
let sample = "123 1 250.5 4194304 5 git\n456 2 12.0 2048 0 zsh\n";
let rows = parse_ps_output(sample);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].pid, 123);
assert_eq!(rows[0].ppid, 1);
assert_eq!(rows[0].command, "git");
assert_eq!(rows[0].rss_mb, 4096);
assert_eq!(rows[0].nice, 5);
assert_eq!(rows[0].args, "");
}
#[test]
fn is_protected_ancestor_exact_match() {
assert!(is_protected_ancestor("/home", "/home"));
assert!(is_protected_ancestor("/etc", "/etc"));
assert!(is_protected_ancestor("/", "/"));
}
#[test]
fn is_protected_ancestor_descendant_match() {
assert!(is_protected_ancestor("/home/dracon", "/home"));
assert!(is_protected_ancestor("/home/dracon/Dev", "/home"));
assert!(is_protected_ancestor("/etc/nginx/nginx.conf", "/etc"));
}
#[test]
fn is_protected_ancestor_rejects_partial_prefix() {
assert!(!is_protected_ancestor("/homefoo", "/home"));
assert!(!is_protected_ancestor("/homefoo/bar", "/home"));
assert!(!is_protected_ancestor("/etcnginx", "/etc"));
}
#[test]
fn is_protected_ancestor_root_matches_exact_only() {
assert!(is_protected_ancestor("/", "/"));
assert!(!is_protected_ancestor("/anything", "/")); assert!(!is_protected_ancestor("/home", "/"));
}
#[test]
fn check_path_str_blocks_descendants() {
assert!(!check_path_str("/home/dracon", &[]));
assert!(!check_path_str("/home/dracon/Dev", &[]));
assert!(!check_path_str("/etc/nginx", &[]));
assert!(check_path_str("/safe/path", &[]));
assert!(check_path_str("/homefoo", &[])); }
#[test]
fn parse_ps_output_extracts_all_fields() {
let sample = "9999 1 75.0 8192000 0 git-fetch origin main\n";
let rows = parse_ps_output(sample);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].pid, 9999);
assert_eq!(rows[0].ppid, 1);
assert_eq!(rows[0].cpu_percent, 75.0);
assert_eq!(rows[0].rss_mb, 8192000 / 1024);
assert_eq!(rows[0].command, "git-fetch");
assert_eq!(rows[0].args, "origin main");
}
#[test]
fn process_cmdline_is_sanitized_for_logs() {
let raw = b"python3\0worker.py\0line\nwith\tcontrol\0";
assert_eq!(
sanitize_process_cmdline(raw, false),
"python3 worker.py line\\nwith\\tcontrol"
);
}
#[test]
fn process_cmdline_is_bounded() {
let raw = vec![b'x'; MAX_PROCESS_CMDLINE_BYTES];
let output = sanitize_process_cmdline(&raw, true);
assert!(output.ends_with(" [truncated]"));
assert!(output.len() <= MAX_PROCESS_CMDLINE_BYTES + " [truncated]".len());
}
#[test]
fn disk_state_transitions_at_thresholds() {
let guard = GuardPolicy {
disk_warn_percent: 70,
disk_action_percent: 85,
disk_critical_percent: 95,
..GuardPolicy::default()
};
assert_eq!(disk_state(50, &guard), "ok");
assert_eq!(disk_state(70, &guard), "warn");
assert_eq!(disk_state(84, &guard), "warn");
assert_eq!(disk_state(85, &guard), "action");
assert_eq!(disk_state(94, &guard), "action");
assert_eq!(disk_state(95, &guard), "critical");
assert_eq!(disk_state(100, &guard), "critical");
}
#[test]
fn should_notify_respects_cooldown() {
let mut state = GuardRuntimeState::default();
let key = "test-key";
assert!(
should_notify(&mut state, key, 60),
"first notify should succeed"
);
assert!(
!should_notify(&mut state, key, 60),
"immediate second notify should be blocked"
);
assert!(
should_notify(&mut state, "other-key", 60),
"different key should succeed"
);
}
#[test]
fn predict_fill_time_requires_minimum_samples() {
let history: Vec<(Instant, u8)> = vec![(Instant::now(), 50), (Instant::now(), 51)];
assert!(
predict_fill_time(&history).is_none(),
"needs at least 3 samples"
);
}
#[test]
fn predict_fill_time_returns_none_for_stable_disk() {
let base = Instant::now();
let history: Vec<(Instant, u8)> = vec![
(base, 50),
(base + Duration::from_secs(10), 50),
(base + Duration::from_secs(20), 50),
];
assert!(
predict_fill_time(&history).is_none(),
"stable disk should not predict fill"
);
}
#[test]
fn predict_fill_time_estimates_for_filling_disk() {
let base = Instant::now();
let history: Vec<(Instant, u8)> = vec![
(base, 50),
(base + Duration::from_secs(3600), 60),
(base + Duration::from_secs(7200), 70),
];
let hours = predict_fill_time(&history);
assert!(hours.is_some(), "should predict fill time for rising disk");
let h = hours.unwrap();
assert!(h > 0.0, "predicted hours should be positive");
assert!(
h < 100.0,
"predicted hours should be reasonable for 10%/hr rate"
);
}
#[tokio::test]
async fn docker_prune_returns_zero_on_dry_run() {
let result = docker_prune(false, true, true).await;
assert!(result.is_ok(), "dry-run docker_prune should not error");
assert_eq!(result.unwrap(), 0, "dry-run should reclaim 0 bytes");
}
#[tokio::test]
async fn empty_trash_credential_guard_blocks_dry_run_estimate() {
let home = std::env::temp_dir().join(format!(
"dracon_system_trash_guard_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
let trash_files = home.join(".local/share/Trash/files");
fs::create_dir_all(&trash_files).expect("create trash fixture");
let credential_fixture = trash_files.join("CREDENTIALS.md");
fs::write(&credential_fixture, b"fixture contents").expect("write credential fixture");
let (reclaimed, cleaned) = empty_trash_at(&home, false, &[], true, 0)
.await
.expect("dry-run trash scan");
assert_eq!(
reclaimed, 0,
"blocked dry-run must not report reclaimable bytes"
);
assert!(
cleaned.is_empty(),
"blocked dry-run must report no cleanup action"
);
assert!(
credential_fixture.exists(),
"dry-run must not delete the credential-like fixture"
);
let _ = fs::remove_dir_all(&home);
}
fn write_file_with_mtime(path: &std::path::Path, contents: &[u8], age_secs: u64) {
fs::write(path, contents).expect("write fixture");
let f = fs::OpenOptions::new()
.append(true)
.open(path)
.expect("open fixture for mtime set");
let mtime = SystemTime::now() - Duration::from_secs(age_secs);
f.set_modified(mtime).expect("set_modified");
}
fn unique_test_home(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"dracon_system_{}_{}_{}",
tag,
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
))
}
#[tokio::test]
async fn empty_trash_min_age_keeps_recent_entries() {
let home = unique_test_home("trash_age");
let trash_files = home.join(".local/share/Trash/files");
let trash_info = home.join(".local/share/Trash/info");
fs::create_dir_all(&trash_files).expect("create trash fixture");
fs::create_dir_all(&trash_info).expect("create info fixture");
let old = trash_files.join("old-stuff.txt");
let recent = trash_files.join("recent-stuff.txt");
write_file_with_mtime(&old, b"old", 30 * 86_400);
write_file_with_mtime(&recent, b"new", 3_600);
fs::write(trash_info.join("old-stuff.txt.trashinfo"), b"[Trash Info]").expect("info old");
fs::write(
trash_info.join("recent-stuff.txt.trashinfo"),
b"[Trash Info]",
)
.expect("info recent");
let (reclaimed, cleaned) = empty_trash_at(&home, true, &[], false, 7)
.await
.expect("aged purge");
assert!(reclaimed > 0, "aged purge should reclaim the old entry");
assert_eq!(cleaned.len(), 1, "exactly one cleanup line expected");
assert!(!old.exists(), "old entry must be removed");
assert!(recent.exists(), "recent entry must be kept");
assert!(
!trash_info.join("old-stuff.txt.trashinfo").exists(),
"paired .trashinfo for the old entry must be removed"
);
assert!(
trash_info.join("recent-stuff.txt.trashinfo").exists(),
"paired .trashinfo for the kept entry must survive"
);
let _ = fs::remove_dir_all(&home);
}
#[tokio::test]
async fn empty_trash_zero_age_empties_everything() {
let home = unique_test_home("trash_zero");
let trash_files = home.join(".local/share/Trash/files");
fs::create_dir_all(&trash_files).expect("create trash fixture");
write_file_with_mtime(&trash_files.join("a.txt"), b"a", 0);
write_file_with_mtime(&trash_files.join("b.txt"), b"b", 0);
let (_reclaimed, _cleaned) = empty_trash_at(&home, true, &[], false, 0)
.await
.expect("whole-empty purge");
assert!(!trash_files.join("a.txt").exists());
assert!(!trash_files.join("b.txt").exists());
let _ = fs::remove_dir_all(&home);
}
fn set_mtime_old(path: &std::path::Path, age_secs: u64) {
let f = fs::File::open(path).expect("open fixture for mtime set");
let mtime = SystemTime::now() - Duration::from_secs(age_secs);
f.set_modified(mtime).expect("set_modified");
}
#[tokio::test]
async fn empty_trash_credential_guard_skips_flagged_purges_rest() {
let home = unique_test_home("trash_skip");
let trash_files = home.join(".local/share/Trash/files");
let trash_info = home.join(".local/share/Trash/info");
fs::create_dir_all(&trash_files).expect("create trash fixture");
fs::create_dir_all(&trash_info).expect("create info fixture");
let flagged = trash_files.join("target");
fs::create_dir_all(flagged.join("debug/.fingerprint/x")).expect("flagged nest");
write_file_with_mtime(
&flagged.join("debug/.fingerprint/x/example-mint_dev_token"),
b"t",
30 * 86_400,
);
set_mtime_old(&flagged, 30 * 86_400);
let benign = trash_files.join("old-notes.txt");
write_file_with_mtime(&benign, b"notes", 30 * 86_400);
fs::write(trash_info.join("target.trashinfo"), b"[Trash Info]").expect("info flagged");
fs::write(trash_info.join("old-notes.txt.trashinfo"), b"[Trash Info]").expect("info benign");
let (reclaimed, _cleaned) = empty_trash_at(&home, true, &[], true, 7)
.await
.expect("purge with flagged entry");
assert!(
reclaimed > 0,
"benign entry must be purged despite the flag"
);
assert!(!benign.exists(), "benign old entry must be removed");
assert!(flagged.exists(), "flagged entry must be kept");
assert!(
trash_info.join("target.trashinfo").exists(),
"kept entry's .trashinfo must survive"
);
assert!(
!trash_info.join("old-notes.txt.trashinfo").exists(),
"purged entry's .trashinfo must be removed"
);
let _ = fs::remove_dir_all(&home);
}
#[tokio::test]
async fn empty_trash_zero_age_with_flagged_keeps_flagged() {
let home = unique_test_home("trash_zero_skip");
let trash_files = home.join(".local/share/Trash/files");
let trash_info = home.join(".local/share/Trash/info");
fs::create_dir_all(&trash_files).expect("create trash fixture");
fs::create_dir_all(&trash_info).expect("create info fixture");
write_file_with_mtime(&trash_files.join("id_ed25519.key"), b"k", 0);
write_file_with_mtime(&trash_files.join("a.txt"), b"a", 0);
fs::write(trash_info.join("id_ed25519.key.trashinfo"), b"[Trash Info]").expect("info flagged");
fs::write(trash_info.join("a.txt.trashinfo"), b"[Trash Info]").expect("info benign");
let (reclaimed, _cleaned) = empty_trash_at(&home, true, &[], true, 0)
.await
.expect("zero-age purge with flagged entry");
assert!(reclaimed > 0, "benign entry must be purged");
assert!(!trash_files.join("a.txt").exists());
assert!(
trash_files.join("id_ed25519.key").exists(),
"flagged entry must be kept"
);
assert!(
trash_info.join("id_ed25519.key.trashinfo").exists(),
"kept entry's .trashinfo must survive the zero-age path"
);
let _ = fs::remove_dir_all(&home);
}
#[tokio::test]
async fn empty_trash_credential_guard_keeps_depth_capped_subtree() {
let home = unique_test_home("trash_capped");
let trash_files = home.join(".local/share/Trash/files");
let trash_info = home.join(".local/share/Trash/info");
fs::create_dir_all(&trash_files).expect("create trash fixture");
fs::create_dir_all(&trash_info).expect("create info fixture");
let mut deep = trash_files.join("deep");
for d in ["d1", "d2", "d3", "d4", "d5", "d6", "d7"] {
deep = deep.join(d);
}
fs::create_dir_all(&deep).expect("deep nest");
write_file_with_mtime(&deep.join("notes.txt"), b"benign", 0);
write_file_with_mtime(&trash_files.join("a.txt"), b"a", 0);
fs::write(trash_info.join("deep.trashinfo"), b"[Trash Info]").expect("info deep");
fs::write(trash_info.join("a.txt.trashinfo"), b"[Trash Info]").expect("info benign");
let (reclaimed, _cleaned) = empty_trash_at(&home, true, &[], true, 0)
.await
.expect("zero-age purge with capped subtree");
assert!(reclaimed > 0, "benign entry must be purged");
assert!(
!trash_files.join("a.txt").exists(),
"benign entry must be removed"
);
assert!(
trash_files.join("deep").exists(),
"depth-capped entry must be kept (children unobservable)"
);
let _ = fs::remove_dir_all(&home);
}
#[cfg(unix)]
#[tokio::test]
async fn empty_trash_credential_guard_keeps_unreadable_subtree() {
use std::os::unix::fs::PermissionsExt;
let home = unique_test_home("trash_unreadable");
let trash_files = home.join(".local/share/Trash/files");
let trash_info = home.join(".local/share/Trash/info");
fs::create_dir_all(&trash_files).expect("create trash fixture");
fs::create_dir_all(&trash_info).expect("create info fixture");
let locked = trash_files.join("top").join("locked");
fs::create_dir_all(&locked).expect("locked nest");
fs::write(locked.join("inner.txt"), b"x").expect("inner file");
write_file_with_mtime(&trash_files.join("a.txt"), b"a", 0);
fs::write(trash_info.join("top.trashinfo"), b"[Trash Info]").expect("info top");
fs::write(trash_info.join("a.txt.trashinfo"), b"[Trash Info]").expect("info benign");
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("lock dir");
let (reclaimed, _cleaned) = empty_trash_at(&home, true, &[], true, 0)
.await
.expect("zero-age purge with unreadable subtree");
fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).expect("unlock dir");
assert!(reclaimed > 0, "benign entry must be purged");
assert!(
!trash_files.join("a.txt").exists(),
"benign entry must be removed"
);
assert!(
trash_files.join("top").exists(),
"entry with an unreadable subtree must be kept"
);
let _ = fs::remove_dir_all(&home);
}
#[cfg(unix)]
#[tokio::test]
async fn nix_cleanup_gen_prune_failure_still_runs_gc() {
let _fixture_exec = fixture_exec_guard();
let root = unique_test_home("nix_warn");
fs::create_dir_all(&root).expect("create temp dir");
let nix_env = root.join("nix-env");
let nix_gc = root.join("nix-collect-garbage");
write_test_script(&nix_env, "echo 'cannot lock profile' >&2\nexit 1");
write_test_script(&nix_gc, "echo 'deleting /nix/store/abc'\nexit 0");
let (_bytes, cleaned) = clean_nix_garbage_with_bins(5, true, &nix_env, &nix_gc)
.await
.expect("GC success must win over prune failure");
assert_eq!(cleaned.len(), 1, "GC results must survive prune failure");
let _ = fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[tokio::test]
async fn critical_tier_bypass_cleans_fresh_target() {
let root = unique_test_home("critical_rust");
let proj = root.join("proj");
let target = proj.join("target");
fs::create_dir_all(target.join("debug")).expect("target fixture");
fs::write(proj.join("Cargo.toml"), b"[package]\nname = \"x\"\n").expect("manifest");
fs::write(target.join("debug/blob"), vec![0u8; 1024]).expect("blob");
set_mtime_old(&target, 3_600);
let policy = GuardPolicy {
rust_search_roots: root.to_str().expect("utf8 root").to_string(),
cleanup_min_size_mb: 0,
rust_target_action_min_age_days: 7,
..Default::default()
};
let mut gated_state = GuardRuntimeState::default();
let gated = auto_cleanup_rust_targets(&policy, &mut gated_state, false, false)
.await
.expect("gated scan");
assert_eq!(
gated.cleaned_count, 0,
"age gate must protect the fresh target on the normal path"
);
let mut crit_state = GuardRuntimeState::default();
let crit = auto_cleanup_rust_targets(&policy, &mut crit_state, false, true)
.await
.expect("bypass scan");
assert_eq!(
crit.cleaned_count, 1,
"critical bypass must candidate the fresh target"
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn clean_tmp_paths_respects_age_dry_run_and_open_fds() {
let root = unique_test_home("tmp_clean");
fs::create_dir_all(&root).expect("create tmp root");
let old_file = root.join("stale-pi-bash.log");
let new_file = root.join("active-session.log");
write_file_with_mtime(&old_file, b"old log data", 2 * 86_400);
write_file_with_mtime(&new_file, b"live", 60);
let held = root.join("held-open.bin");
write_file_with_mtime(&held, b"held", 2 * 86_400);
let handle = fs::File::open(&held).expect("open held file");
let roots = vec![root.display().to_string()];
let (dry_bytes, dry_lines) = clean_tmp_paths(false, &roots, 24, &[])
.await
.expect("dry run");
assert!(dry_bytes > 0, "dry run should report reclaimable bytes");
assert!(old_file.exists(), "dry run must not delete");
assert!(
!dry_lines.iter().any(|l| l.contains("active-session")),
"young entries are not candidates"
);
assert!(
!dry_lines.iter().any(|l| l.contains("held-open")),
"open-held entries are skipped even when old"
);
let (bytes, lines) = clean_tmp_paths(true, &roots, 24, &[]).await.expect("apply");
assert_eq!(
bytes, dry_bytes,
"apply should reclaim exactly what dry-run measured"
);
assert!(!old_file.exists(), "aged unheld entry must be removed");
assert!(new_file.exists(), "young entry must be kept");
assert!(
held.exists(),
"entry held open by a live process must be kept"
);
assert_eq!(lines.len(), 1);
drop(handle);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn clean_tmp_keeps_tree_with_fresh_nested_content() {
use std::time::Duration;
let root = unique_test_home("tmp_nested");
fs::create_dir_all(&root).expect("create tmp root");
let stale_dir = root.join("old-run");
let nested = stale_dir.join("logs");
fs::create_dir_all(&nested).expect("create nested dir");
write_file_with_mtime(&nested.join("fresh.log"), b"just written", 60);
let old = SystemTime::now() - Duration::from_secs(2 * 86_400);
for p in [&stale_dir, &nested] {
fs::OpenOptions::new()
.read(true)
.open(p)
.expect("open dir for mtime set")
.set_modified(old)
.expect("backdate dir");
}
let dead_dir = root.join("dead-run");
fs::create_dir_all(&dead_dir).expect("create dead dir");
write_file_with_mtime(&dead_dir.join("old.log"), b"stale", 2 * 86_400);
fs::OpenOptions::new()
.read(true)
.open(&dead_dir)
.expect("open dir for mtime set")
.set_modified(old)
.expect("backdate dir");
let roots = vec![root.display().to_string()];
let (bytes, lines) = clean_tmp_paths(true, &roots, 24, &[]).await.expect("apply");
assert!(bytes > 0, "the dead tree must be reclaimed");
assert!(!dead_dir.exists(), "fully stale tree must be removed");
assert!(
stale_dir.join("logs").join("fresh.log").exists(),
"tree with fresh nested content must be kept: {lines:?}"
);
assert!(
!lines.iter().any(|l| l.contains("old-run")),
"kept tree must not be reported cleaned: {lines:?}"
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn clean_tmp_paths_rejects_home_search_root_before_apply() {
let home = dirs::home_dir().expect("home directory");
let sentinel = home.join(format!(
".dracon_system_f65_home_sentinel_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
write_file_with_mtime(&sentinel, b"must survive", 2 * 86_400);
let result = clean_tmp_paths(true, &["~".to_string()], 24, &[]).await;
let sentinel_survived = sentinel.exists();
let _ = fs::remove_file(&sentinel);
let error = result.expect_err("home must not be accepted as a tmp root");
assert!(
error.to_string().contains("invalid tmp_search_paths entry"),
"invalid root should be reported, got: {error:#}"
);
assert!(
sentinel_survived,
"an apply config targeting ~ must not delete home data"
);
}
#[cfg(unix)]
#[tokio::test]
async fn clean_tmp_paths_keeps_old_process_cwd_directory() {
let root = unique_test_home("tmp_cwd");
let proc_root = unique_test_home("proc_cwd");
let cwd_dir = root.join("stale-working-directory");
let unheld_file = root.join("stale-unheld-file");
fs::create_dir_all(&cwd_dir).expect("create cwd fixture");
write_file_with_mtime(&unheld_file, b"unheld", 2 * 86_400);
let cwd_handle = File::open(&cwd_dir).expect("open cwd fixture for mtime");
cwd_handle
.set_modified(SystemTime::now() - Duration::from_secs(2 * 86_400))
.expect("set cwd fixture mtime");
drop(cwd_handle);
let process_dir = proc_root.join("4242");
fs::create_dir_all(process_dir.join("fd")).expect("create proc fixture");
symlink(&cwd_dir, process_dir.join("cwd")).expect("create cwd proc link");
let roots = vec![root.display().to_string()];
let unheld_bytes = unheld_file.metadata().expect("unheld metadata").len();
let open_paths = collect_open_paths_under_from(&proc_root, std::slice::from_ref(&root))
.await
.expect("readable proc fixture must scan");
assert!(
open_paths.contains(&cwd_dir),
"process cwd must be included in open-path protection"
);
let (reclaimed, cleaned) = clean_tmp_paths_with_proc(true, &roots, 24, &[], &proc_root)
.await
.expect("tmp cleanup");
assert_eq!(reclaimed, unheld_bytes);
assert_eq!(cleaned.len(), 1, "only the unheld old entry is removed");
assert!(cwd_dir.exists(), "an old process cwd must remain protected");
assert!(
!unheld_file.exists(),
"an old unheld entry remains removable"
);
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&proc_root);
}
#[cfg(unix)]
#[tokio::test]
async fn clean_tmp_paths_refuses_blind_cleanup_when_proc_unreadable() {
let root = unique_test_home("tmp_blind");
let stale = root.join("stale-file");
fs::create_dir_all(&root).expect("create fixture");
write_file_with_mtime(&stale, b"stale", 2 * 86_400);
let roots = vec![root.display().to_string()];
let missing_proc = root.join("no-such-proc");
for apply in [false, true] {
let err = clean_tmp_paths_with_proc(apply, &roots, 24, &[], &missing_proc)
.await
.expect_err("unreadable proc must fail the pass");
assert!(
err.to_string().contains("refusing tmp cleanup blind"),
"failure must name the blind-cleanup refusal, got: {err:#}"
);
assert!(
stale.exists(),
"nothing may be deleted blind (apply={apply})"
);
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn guard_policy_defaults_cover_tmp_and_trash_age_fields() {
let guard = GuardPolicy::default();
assert!(guard.clean_tmp, "clean_tmp should default on");
assert_eq!(guard.tmp_search_paths, "/tmp");
assert_eq!(guard.tmp_min_age_hours, 24);
assert_eq!(guard.trash_min_age_days, 7);
}
#[test]
fn guard_startup_config_errors_use_non_restarting_status() {
let wrapped = anyhow::Error::new(PolicyLoadError(anyhow::anyhow!("invalid policy")));
assert_eq!(exit_status_for_error(&wrapped), CONFIG_ERROR_EXIT_STATUS);
assert_eq!(
exit_status_for_error(&anyhow::anyhow!("runtime failure")),
1
);
}
#[cfg(unix)]
#[test]
fn non_unicode_policy_override_is_authoritative() {
use std::os::unix::ffi::OsStringExt;
let custom = std::ffi::OsString::from_vec(b"/tmp/policy-\xff.toml".to_vec());
let resolved = resolve_system_policy_path_with(Some(custom.clone()), PathBuf::from("/nowhere"));
assert_eq!(resolved.unwrap(), Some(PathBuf::from(custom)));
}
#[test]
fn shipped_guard_service_exposes_host_tmp_for_clean_tmp() {
let service = include_str!("../dracon-system-guard.service");
assert!(
service
.lines()
.any(|line| line.trim() == "PrivateTmp=false"),
"the shipped guard must share the host temporary namespace"
);
assert!(
!service.lines().any(|line| line.trim() == "PrivateTmp=true"),
"the shipped guard must not use a private temporary namespace"
);
assert!(
service
.lines()
.any(|line| line.trim() == "WorkingDirectory=%h"),
"relative policy paths need a stable user-service working directory"
);
let writable_paths = service
.lines()
.find_map(|line| line.strip_prefix("ReadWritePaths="))
.expect("the guard service must declare writable paths");
assert!(
writable_paths.split_whitespace().any(|path| path == "/tmp"),
"host /tmp must be writable despite ProtectSystem=strict"
);
let guard = GuardPolicy::default();
assert!(
guard.clean_tmp,
"the production default must enable clean_tmp"
);
assert_eq!(
guard.tmp_search_paths, "/tmp",
"the policy and service must target the same host namespace"
);
}
#[test]
fn shipped_guard_service_restart_policy_handles_disabled_and_bad_config() {
let service = include_str!("../dracon-system-guard.service");
assert!(
service
.lines()
.any(|line| line.trim() == "Restart=on-failure"),
"clean enabled=false exits must not restart the guard"
);
assert!(
!service.lines().any(|line| line.trim() == "Restart=always"),
"the guard must not restart after a clean policy-disabled exit"
);
assert!(
service
.lines()
.any(|line| line.trim() == "RestartPreventExitStatus=2 78"),
"usage errors and EX_CONFIG policy errors must remain stopped"
);
}
#[tokio::test]
async fn guard_report_completes_for_ok_disk() {
let fixture = unique_test_home("guard_report");
fs::create_dir(&fixture).expect("create report fixture");
let mut state = GuardRuntimeState::default();
let guard = GuardPolicy {
disk_early_warn_percent: 101,
disk_warn_percent: 101,
disk_action_percent: 101,
disk_critical_percent: 101,
proactive_cleanup_percent: 101,
disk_mount_path: fixture.display().to_string(),
sync_freeze_marker: fixture.join("freeze").display().to_string(),
guard_log_file: fixture.join("guard.log").display().to_string(),
freeze_sync_at_action: false,
track_trends: false,
notify: false,
auto_cleanup_apply: false,
auto_cleanup_rust: false,
docker_prune: false,
clean_package_caches: false,
clean_trash: false,
clean_nix_garbage: false,
clean_node_modules: false,
clean_tmp: false,
monitor_inodes: false,
monitor_zombies: false,
monitor_memory: false,
monitor_logs: false,
auto_renice: false,
auto_renice_on_memory: false,
bias_oom_on_pressure: false,
..GuardPolicy::default()
};
let report = tokio::time::timeout(Duration::from_secs(10), run_guard_once(&guard, &mut state))
.await
.expect("report assembly must be bounded")
.expect("report succeeds");
assert_eq!(report.disk_state, "ok");
assert!(!report.sync_frozen);
assert!(!fixture.join("freeze").exists());
fs::remove_dir_all(fixture).expect("remove report fixture");
}
#[test]
fn guard_never_removes_or_overwrites_operator_freeze_marker() {
let marker = std::env::temp_dir().join(format!(
"dracon-system-freeze-operator-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
let operator_contents = "dracon-sync pause\n";
fs::write(&marker, operator_contents).expect("write operator marker");
let guard = GuardPolicy {
freeze_sync_at_action: false,
sync_freeze_marker: marker.display().to_string(),
unfreeze_below_percent: 70,
..GuardPolicy::default()
};
let mut sync_frozen = true;
manage_sync_freeze(&guard, 68, "ok", &mut sync_frozen);
assert!(marker.exists(), "guard-disabled policy must not remove it");
assert_eq!(
fs::read_to_string(&marker).expect("read marker"),
operator_contents
);
assert!(sync_frozen);
let enabled_guard = GuardPolicy {
freeze_sync_at_action: true,
..guard
};
manage_sync_freeze(&enabled_guard, 95, "action", &mut sync_frozen);
assert!(marker.exists(), "guard must not replace an operator marker");
assert_eq!(
fs::read_to_string(&marker).expect("read marker"),
operator_contents
);
let _ = fs::remove_file(marker);
}
#[test]
fn guard_removes_only_its_own_freeze_marker() {
let marker = std::env::temp_dir().join(format!(
"dracon-system-freeze-owned-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
let guard = GuardPolicy {
freeze_sync_at_action: true,
sync_freeze_marker: marker.display().to_string(),
unfreeze_below_percent: 70,
..GuardPolicy::default()
};
let mut sync_frozen = false;
manage_sync_freeze(&guard, 95, "action", &mut sync_frozen);
assert!(sync_frozen);
assert!(marker.exists());
assert!(fs::read_to_string(&marker)
.expect("read guard marker")
.starts_with(GUARD_FREEZE_MARKER_PREFIX));
manage_sync_freeze(&guard, 68, "ok", &mut sync_frozen);
assert!(!marker.exists(), "guard-owned marker should be released");
assert!(!sync_frozen);
}
#[test]
fn test_graduated_nice_value_cpu_tiers() {
assert_eq!(graduated_nice_value(100.0, 0, 5, 0), 5);
assert_eq!(graduated_nice_value(180.0, 0, 5, 0), 5);
assert_eq!(graduated_nice_value(250.0, 0, 5, 0), 5);
assert_eq!(graduated_nice_value(300.0, 0, 5, 0), 10);
assert_eq!(graduated_nice_value(450.0, 0, 5, 0), 10);
assert_eq!(graduated_nice_value(500.0, 0, 5, 0), 15);
assert_eq!(graduated_nice_value(900.0, 0, 5, 0), 15);
}
#[test]
fn test_graduated_nice_value_memory_tiers() {
assert_eq!(graduated_nice_value(0.0, 2000, 5, 0), 5);
assert_eq!(graduated_nice_value(0.0, 4096, 5, 0), 5);
assert_eq!(graduated_nice_value(0.0, 5000, 5, 0), 5);
assert_eq!(graduated_nice_value(0.0, 8192, 5, 0), 10);
assert_eq!(graduated_nice_value(0.0, 16000, 5, 0), 10);
}
#[test]
fn test_graduated_nice_value_cpu_plus_memory() {
assert_eq!(graduated_nice_value(300.0, 8192, 5, 0), 10);
assert_eq!(graduated_nice_value(500.0, 4096, 5, 0), 15);
assert_eq!(graduated_nice_value(180.0, 8192, 5, 0), 10);
}
#[test]
fn test_graduated_nice_value_clamped() {
assert_eq!(graduated_nice_value(0.0, 0, 5, 0), 5);
assert_eq!(graduated_nice_value(0.0, 0, 0, 0), 0);
}
#[test]
fn test_graduated_nice_value_negative_base_clamped() {
assert_eq!(graduated_nice_value(0.0, 0, -5, 0), 0);
}
#[test]
fn test_graduated_nice_value_high_base_clamped() {
assert_eq!(graduated_nice_value(0.0, 0, 20, 0), 19);
}
#[test]
fn test_graduated_nice_value_memory_boundary() {
assert_eq!(graduated_nice_value(0.0, 4095, 0, 0), 0);
assert_eq!(graduated_nice_value(0.0, 4096, 0, 0), 5);
assert_eq!(graduated_nice_value(0.0, 8191, 0, 0), 5);
assert_eq!(graduated_nice_value(0.0, 8192, 0, 0), 10);
}
fn guard_test_tmp(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"dracon_test_{}_{}_{}",
name,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn safe_tmp_root_policy_allows_tmp_descendants_and_rejects_home() {
let tmp = guard_test_tmp("safe_tmp_root");
std::fs::create_dir_all(&tmp).expect("tmp root");
let resolved = check_safe_tmp_root(&tmp).expect("tmp descendant should be allowed");
assert!(resolved.starts_with("/tmp"));
let home = dirs::home_dir().expect("home directory");
let error = check_safe_tmp_root(&home).expect_err("home must not be a tmp root");
assert!(error.to_string().contains("must be /tmp (or a descendant)"));
let _ = std::fs::remove_dir_all(&tmp);
}
fn set_mtime_hours_ago(path: &std::path::Path, hours: u64) {
let f = std::fs::OpenOptions::new()
.read(true)
.open(path)
.unwrap_or_else(|e| panic!("open {} to set mtime: {e}", path.display()));
f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(hours * 3600))
.unwrap_or_else(|e| panic!("set mtime on {}: {e}", path.display()));
}
#[tokio::test]
async fn tree_has_fresh_content_fails_closed_on_walk_errors() {
use std::os::unix::fs::PermissionsExt;
let now = std::time::SystemTime::now();
let cutoff = now - std::time::Duration::from_secs(60);
let stale = guard_test_tmp("fresh_closed_stale");
std::fs::create_dir_all(&stale).unwrap();
let old_file = stale.join("old");
std::fs::write(&old_file, b"old").unwrap();
set_mtime_hours_ago(&old_file, 1);
set_mtime_hours_ago(&stale, 1);
assert!(
!tree_has_fresh_content(&stale, cutoff).await,
"all-old tree must read stale"
);
let fresh = guard_test_tmp("fresh_closed_fresh");
std::fs::create_dir_all(&fresh).unwrap();
std::fs::write(fresh.join("new"), b"new").unwrap();
assert!(
tree_has_fresh_content(&fresh, cutoff).await,
"tree with a fresh file must read fresh"
);
let locked = guard_test_tmp("fresh_closed_locked");
let sub = locked.join("sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("x"), b"x").unwrap();
set_mtime_hours_ago(&sub.join("x"), 1);
set_mtime_hours_ago(&sub, 1);
set_mtime_hours_ago(&locked, 1);
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o000)).unwrap();
let verdict = tree_has_fresh_content(&locked, cutoff).await;
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(verdict, "unreadable subtree must fail closed (fresh/keep)");
let _ = std::fs::remove_dir_all(&stale);
let _ = std::fs::remove_dir_all(&fresh);
let _ = std::fs::remove_dir_all(&locked);
}
#[test]
fn tmp_entry_must_remain_under_validated_root() {
let root = guard_test_tmp("tmp_root_containment");
let outside = guard_test_tmp("tmp_outside_containment");
std::fs::create_dir_all(&root).expect("tmp root");
std::fs::create_dir_all(&outside).expect("outside root");
let validated = check_safe_tmp_root(&root).expect("tmp root should be valid");
let result = check_safe_to_delete_tmp_entry(&outside, &validated, &[]);
assert!(
result.is_err(),
"a candidate outside the validated tmp root must be rejected"
);
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
}
#[test]
fn guard_safe_delete_allows_paths_under_system_protected() {
let tmp = guard_test_tmp("guard_safe_1");
let target = tmp.join("target");
std::fs::create_dir_all(&target).unwrap();
let result = check_safe_to_delete_guard(&target, &[]);
assert!(
result.is_ok(),
"guard safe delete should allow paths under /home (system-protected skipped)"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn guard_safe_delete_blocks_user_protected() {
let tmp = guard_test_tmp("guard_safe_2");
let target = tmp.join("target");
std::fs::create_dir_all(&target).unwrap();
let user_protected = vec![tmp.display().to_string()];
let result = check_safe_to_delete_guard(&target, &user_protected);
assert!(
result.is_err(),
"guard safe delete should block user-protected paths"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn guard_safe_delete_rejects_symlink() {
let tmp = guard_test_tmp("guard_safe_3");
let real = tmp.join("real_target");
std::fs::create_dir_all(&real).unwrap();
let link = tmp.join("link_target");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&real, &link).unwrap();
let result = check_safe_to_delete_guard(&link, &[]);
assert!(result.is_err(), "guard safe delete should reject symlinks");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn guard_safe_delete_rejects_exact_system_roots() {
for prot in SYSTEM_PROTECTED {
if !Path::new(prot).exists() {
continue;
}
let result = check_safe_to_delete_guard(Path::new(prot), &[]);
assert!(
result.is_err(),
"guard safe delete should reject exact protected system root {prot}"
);
}
}
#[test]
fn check_safe_to_delete_rejects_log_symlink_before_truncate() {
let tmp = guard_test_tmp("log_symlink");
std::fs::create_dir_all(&tmp).unwrap();
let real = tmp.join("real.log");
let link = tmp.join("link.log");
std::fs::write(&real, "line1\nline2\nline3\n").unwrap();
std::os::unix::fs::symlink(&real, &link).unwrap();
let result = check_safe_to_delete(&link, &[]);
assert!(
result.is_err(),
"symlink log should be rejected before truncate"
);
assert_eq!(
std::fs::read_to_string(&real).unwrap(),
"line1\nline2\nline3\n"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn proactive_cleanup_defaults() {
assert_eq!(default_proactive_cleanup_percent(), 80);
assert_eq!(default_auto_cleanup_interval_secs(), 1800);
assert_eq!(default_rust_target_max_age_days(), 14);
assert_eq!(default_rust_target_action_min_age_days(), 7);
assert_eq!(default_proactive_cleanup_interval_cycles(), 120);
}
#[test]
fn action_cleanup_age_gate_lingering_definition() {
const DAY: u64 = 86400;
assert!(!rust_target_old_enough_for_action_cleanup(3600, 7));
assert!(!rust_target_old_enough_for_action_cleanup(DAY, 7));
assert!(rust_target_old_enough_for_action_cleanup(7 * DAY, 7));
assert!(rust_target_old_enough_for_action_cleanup(30 * DAY, 7));
}
#[test]
fn action_cleanup_age_gate_zero_disables() {
assert!(rust_target_old_enough_for_action_cleanup(0, 0));
let policy = GuardPolicy {
rust_target_action_min_age_days: 0,
..Default::default()
};
let mut policy = policy;
normalize_guard_policy(&mut policy);
assert_eq!(policy.rust_target_action_min_age_days, 0);
}
#[test]
fn normalize_proactive_cleanup_percent_bounded_by_action() {
let policy = GuardPolicy {
disk_action_percent: 85,
proactive_cleanup_percent: 90,
..Default::default()
};
let mut policy = policy;
normalize_guard_policy(&mut policy);
assert!(
policy.proactive_cleanup_percent < policy.disk_action_percent,
"proactive_cleanup_percent must be below disk_action_percent"
);
}
#[test]
fn normalize_rust_target_max_age_days_min_1() {
let policy = GuardPolicy {
rust_target_max_age_days: 0,
..Default::default()
};
let mut policy = policy;
normalize_guard_policy(&mut policy);
assert!(policy.rust_target_max_age_days >= 1);
}
#[test]
fn normalize_proactive_interval_min_1() {
let policy = GuardPolicy {
proactive_cleanup_interval_cycles: 0,
..Default::default()
};
let mut policy = policy;
normalize_guard_policy(&mut policy);
assert!(policy.proactive_cleanup_interval_cycles >= 1);
}
#[test]
fn normalize_auto_cleanup_interval_min_60() {
let policy = GuardPolicy {
auto_cleanup_interval_secs: 0,
..Default::default()
};
let mut policy = policy;
normalize_guard_policy(&mut policy);
assert!(policy.auto_cleanup_interval_secs >= 60);
}
#[test]
fn auto_cleanup_cadence_is_stateful() {
let now = Instant::now();
let mut state = GuardRuntimeState::default();
assert!(auto_cleanup_due_at(&state, 1800, now));
state.last_auto_cleanup = Some(now - Duration::from_secs(1799));
assert!(!auto_cleanup_due_at(&state, 1800, now));
state.last_auto_cleanup = Some(now - Duration::from_secs(1800));
assert!(auto_cleanup_due_at(&state, 1800, now));
}
#[test]
fn guard_runtime_state_default_cycle_zero() {
let state = GuardRuntimeState::default();
assert_eq!(state.guard_cycle, 0);
assert!(state.last_proactive_cleanup.is_none());
assert!(state.last_auto_cleanup.is_none());
}
#[test]
fn target_dir_info_has_mtime() {
let info = TargetDirInfo {
path: PathBuf::from("/tmp/test/target"),
bytes: 1024,
mtime_secs_ago: 86400 * 15,
};
assert_eq!(info.mtime_secs_ago, 86400 * 15);
assert_eq!(info.bytes, 1024);
}
#[test]
fn truncate_log_preserves_open_writer_inode() {
let td =
std::env::temp_dir().join(format!("dracon-system-truncate-log-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&td);
std::fs::create_dir_all(&td).expect("temp dir");
let path = td.join("guard.log");
std::fs::write(&path, "header\nbody-one\nbody-two\n").expect("log");
let mut writer = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.expect("open writer");
let reclaimed = truncate_log_file(&path, 12, 1).expect("truncate");
assert!(reclaimed > 0);
use std::io::Write;
writer.write_all(b"tail\n").expect("append after truncate");
let content = std::fs::read_to_string(&path).expect("read log");
assert!(content.starts_with("header\n"));
assert!(
content.contains("tail\n"),
"writer output must remain visible"
);
assert!(!content.contains("body-two"));
let _ = std::fs::remove_dir_all(&td);
}
#[test]
fn truncate_log_preserves_non_utf8_lines_verbatim() {
let td = std::env::temp_dir().join(format!(
"dracon-system-truncate-utf8-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&td);
std::fs::create_dir_all(&td).expect("temp dir");
let path = td.join("app.log");
let mut raw = b"header\nok-line\n".to_vec();
raw.extend_from_slice(b"bad-\xff\xfe-bytes\n");
raw.extend_from_slice(b"after-bad-line\n");
raw.extend_from_slice(&[b'x'; 200]);
std::fs::write(&path, &raw).expect("log");
let reclaimed = truncate_log_file(&path, 64, 1).expect("truncate");
assert!(reclaimed > 0);
let out = std::fs::read(&path).expect("read log");
assert!(out.starts_with(b"header\n"), "header must survive: {out:?}");
assert!(
out.windows(8).any(|w| w == b"bad-\xff\xfe-b"),
"non-UTF8 line must be preserved verbatim: {out:?}"
);
assert!(
out.windows(14).any(|w| w == b"after-bad-line"),
"lines AFTER the bad line must survive (old code stopped): {out:?}"
);
assert!(
out.len() as u64 <= 64,
"budget must hold: {} bytes",
out.len()
);
let _ = std::fs::remove_dir_all(&td);
}
#[test]
fn truncate_log_handles_missing_trailing_newline_and_blank_lines() {
let td = std::env::temp_dir().join(format!(
"dracon-system-truncate-edge-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&td);
std::fs::create_dir_all(&td).expect("temp dir");
let path = td.join("app.log");
std::fs::write(&path, b"h1\n\nbody-no-newline").expect("log");
let reclaimed = truncate_log_file(&path, 10, 1).expect("truncate");
assert!(reclaimed > 0);
let out = std::fs::read(&path).expect("read log");
assert_eq!(
out, b"h1\n\n",
"header + blank line, terminator artifact dropped"
);
let _ = std::fs::remove_dir_all(&td);
}
#[tokio::test]
async fn clean_old_node_modules_counts_nested_tree_once() {
let td = std::env::temp_dir().join(format!(
"dracon-system-node-modules-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
let outer = td.join("project/node_modules");
let inner = outer.join("nested/node_modules");
std::fs::create_dir_all(&inner).expect("create nested node_modules");
std::fs::write(outer.join("outer.bin"), vec![b'o'; 17]).expect("write outer file");
std::fs::write(inner.join("inner.bin"), vec![b'i'; 29]).expect("write inner file");
let expected = get_dir_size(&outer).await.expect("measure outer tree");
let (reclaimed, cleaned) = clean_old_node_modules(
std::slice::from_ref(&td),
0,
false,
&[],
false,
std::path::Path::new(""),
)
.await
.expect("dry-run node_modules cleanup");
assert_eq!(
cleaned.len(),
1,
"nested node_modules must not be listed twice"
);
assert_eq!(reclaimed, expected, "outer tree must be counted once");
let _ = std::fs::remove_dir_all(&td);
}
#[test]
fn rust_build_process_detection_covers_long_lived_tooling() {
assert!(is_rust_build_process("cargo"));
assert!(is_rust_build_process("cargo-build"));
assert!(is_rust_build_process("rustc"));
assert!(is_rust_build_process("rustc-1.94.1"));
assert!(is_rust_build_process("clippy-driver"));
assert!(is_rust_build_process("rust-analyzer"));
assert!(is_rust_build_process("cargo-watch"));
assert!(!is_rust_build_process(""));
assert!(!is_rust_build_process("firefox"));
assert!(!is_rust_build_process("dracon-sync"));
assert!(!is_rust_build_process("node"));
}
#[test]
fn package_cache_process_detection_covers_direct_and_wrapped_tools() {
let proc_root = unique_test_home("package_proc");
fs::create_dir_all(proc_root.join("self")).expect("create proc fixture");
for (pid, cmdline) in [
(101, b"/usr/bin/python3\0-m\0pip\0install\0".as_slice()),
(
102,
b"/usr/bin/node\0/usr/lib/npm/npm-cli.js\0install\0".as_slice(),
),
(103, b"/bin/sh\0-c\0go\0build\0./...\0".as_slice()),
] {
let process_dir = proc_root.join(pid.to_string());
fs::create_dir_all(&process_dir).expect("create process fixture");
fs::write(process_dir.join("cmdline"), cmdline).expect("write cmdline fixture");
}
let active = detect_active_package_manager_operations_from(
"100 cargo\n101 python3\n102 node\n103 sh\n104 firefox\n",
&proc_root,
)
.expect("process metadata fixture should be readable");
assert_eq!(active.len(), 4);
assert!(active.contains(&PackageCacheKind::Cargo));
assert!(active.contains(&PackageCacheKind::Npm));
assert!(active.contains(&PackageCacheKind::Pip));
assert!(active.contains(&PackageCacheKind::Go));
let _ = fs::remove_dir_all(proc_root);
}
#[cfg(unix)]
#[test]
fn package_cache_process_detection_fails_closed_on_unreadable_cmdline() {
let proc_root = unique_test_home("package_proc_unreadable");
let process_dir = proc_root.join("201");
fs::create_dir_all(proc_root.join("self")).expect("create proc fixture");
fs::create_dir_all(&process_dir).expect("create process fixture");
let cmdline = process_dir.join("cmdline");
fs::write(
&cmdline,
b"/usr/bin/node\0/usr/lib/npm/npm-cli.js\0install\0",
)
.expect("write cmdline fixture");
fs::set_permissions(&cmdline, fs::Permissions::from_mode(0o000))
.expect("make cmdline unreadable");
let result = detect_active_package_manager_operations_from("201 python3\n", &proc_root);
assert!(
result.is_err(),
"an unreadable wrapper command line must abort cache protection"
);
let _ = fs::remove_dir_all(proc_root);
}
#[test]
fn package_cache_process_detection_fails_closed_on_missing_cmdline() {
let proc_root = unique_test_home("package_proc_missing_cmdline");
fs::create_dir_all(proc_root.join("self")).expect("create proc fixture");
fs::create_dir_all(proc_root.join("202")).expect("create process fixture");
let result = detect_active_package_manager_operations_from("202 python3\n", &proc_root);
assert!(
result.is_err(),
"an existing process without cmdline metadata must abort cache protection"
);
let _ = fs::remove_dir_all(proc_root);
}
#[test]
fn package_cache_process_detection_rejects_malformed_ps_output() {
let proc_root = unique_test_home("package_proc_malformed");
fs::create_dir_all(proc_root.join("self")).expect("create proc fixture");
let result = detect_active_package_manager_operations_from("not-a-process\n", &proc_root);
assert!(
result.is_err(),
"partial process metadata must abort cache protection"
);
let _ = fs::remove_dir_all(proc_root);
}
#[cfg(unix)]
#[tokio::test]
async fn package_cache_process_listing_failure_is_an_error() {
let _fixture_exec = fixture_exec_guard();
let proc_root = unique_test_home("package_proc_failed_ps");
let bin_root = unique_test_home("package_ps_failed");
fs::create_dir_all(proc_root.join("self")).expect("create proc fixture");
fs::create_dir_all(&bin_root).expect("create bin fixture");
let ps = bin_root.join("ps");
write_test_script(&ps, "exit 7");
let result = detect_active_package_manager_operations_with(&ps, &proc_root).await;
assert!(result.is_err(), "a failed ps command must fail closed");
let _ = fs::remove_dir_all(proc_root);
let _ = fs::remove_dir_all(bin_root);
}
#[tokio::test]
async fn package_cache_apply_refuses_all_cache_deletion() {
let home = unique_test_home("package_cache_protection");
let targets = [
(PackageCacheKind::Cargo, ".cargo/registry/cache"),
(PackageCacheKind::Npm, ".npm"),
(PackageCacheKind::Pip, ".cache/pip"),
(PackageCacheKind::Go, ".cache/go-build"),
];
let mut active = HashSet::new();
for (kind, relative) in targets {
let cache = home.join(relative);
fs::create_dir_all(&cache).expect("create cache fixture");
fs::write(cache.join("in-use.bin"), b"must survive").expect("write cache fixture");
active.insert(kind);
}
let (reclaimed, cleaned) =
clean_package_caches_at(&home, true, true, true, true, true, &active)
.await
.expect("protected cache cleanup");
assert_eq!(
reclaimed, 0,
"apply cache deletion is disabled, so caches must not be counted as reclaimed"
);
assert!(
cleaned.is_empty(),
"apply cache deletion is disabled, so caches must not be reported cleaned"
);
for (_, relative) in targets {
assert!(
home.join(relative).exists(),
"package cache {relative} must survive apply"
);
}
let _ = fs::remove_dir_all(home);
}
#[tokio::test]
async fn package_cache_apply_refuses_uncoordinated_start_after_snapshot() {
let home = unique_test_home("package_cache_adversarial_start");
let proc_root = unique_test_home("package_proc_adversarial_start");
fs::create_dir_all(proc_root.join("self")).expect("create proc fixture");
let initial_process = proc_root.join("400");
fs::create_dir_all(&initial_process).expect("create initial process fixture");
fs::write(initial_process.join("cmdline"), b"/usr/bin/sleep\0").expect("write initial cmdline");
let initial = detect_active_package_manager_operations_from("400 sleep\n", &proc_root)
.expect("initial process snapshot");
assert!(initial.is_empty());
let new_process = proc_root.join("401");
fs::create_dir_all(&new_process).expect("create adversarial process fixture");
fs::write(
new_process.join("cmdline"),
b"/usr/bin/node\0/usr/lib/npm/npm-cli.js\0install\0",
)
.expect("write adversarial cmdline");
let npm_cache = home.join(".npm");
fs::create_dir_all(&npm_cache).expect("create npm cache fixture");
fs::write(npm_cache.join("in-use.bin"), b"must survive").expect("write cache fixture");
let result = clean_package_caches_at(&home, false, true, false, false, true, &initial)
.await
.expect("uncoordinated apply must be a safe no-op");
assert_eq!(result, (0, Vec::new()));
assert!(
npm_cache.exists(),
"an operation starting after detection must not race deletion"
);
let _ = fs::remove_dir_all(home);
let _ = fs::remove_dir_all(proc_root);
}
#[tokio::test]
async fn active_package_operations_skip_matching_cache_estimates_in_dry_run() {
let home = unique_test_home("package_cache_dry_run");
let cargo_cache = home.join(".cargo/registry/cache");
let npm_cache = home.join(".npm");
fs::create_dir_all(&cargo_cache).expect("create cargo cache fixture");
fs::create_dir_all(&npm_cache).expect("create npm cache fixture");
fs::write(cargo_cache.join("cargo.bin"), b"cargo").expect("write cargo fixture");
fs::write(npm_cache.join("npm.bin"), b"npm").expect("write npm fixture");
let active = HashSet::from([PackageCacheKind::Npm]);
let result = clean_package_caches_at(&home, true, true, false, false, false, &active)
.await
.expect("dry-run cache estimate");
assert_eq!(result.0, 5);
assert!(result.1.iter().any(|entry| entry.contains("cargo")));
assert!(!result.1.iter().any(|entry| entry.contains("npm")));
assert!(cargo_cache.exists() && npm_cache.exists());
let _ = fs::remove_dir_all(home);
}
#[test]
fn storage_cleanup_apply_accepts_home_artifact_dirs_and_refuses_system_roots() {
let home = dirs::home_dir().expect("home dir available");
let fixture = home.join(format!(
".dracon_storage_apply_test_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&fixture).unwrap();
let validated = validate_storage_cleanup_path(&fixture, &[]);
assert!(
validated.is_ok(),
"apply must accept artifact dirs under /home: {:?}",
validated.err()
);
let _ = std::fs::remove_dir_all(&fixture);
for prot in SYSTEM_PROTECTED {
if !Path::new(prot).exists() {
continue;
}
assert!(
validate_storage_cleanup_path(Path::new(prot), &[]).is_err(),
"apply must refuse exact system root {prot}"
);
}
if Path::new("/home").exists() {
assert!(check_safe_to_delete(Path::new("/home"), &[]).is_err());
}
}
#[test]
fn storage_cache_hotspots_share_package_apply_safety_gate() {
assert!(storage_hotspot_apply_is_blocked("cache", true));
assert!(!storage_hotspot_apply_is_blocked("cache", false));
assert!(!storage_hotspot_apply_is_blocked("node-deps", true));
}
#[test]
fn filter_selectable_cleanup_kinds_drops_git_db_and_keeps_artifact_kinds() {
let requested: HashSet<String> = ["git-db", "rust-build", "node-deps"]
.iter()
.map(|s| s.to_string())
.collect();
let (kept, excluded) = filter_selectable_cleanup_kinds(requested);
assert!(kept.contains("rust-build") && kept.contains("node-deps"));
assert!(
!kept.contains("git-db"),
"git-db must never survive filtering"
);
assert_eq!(excluded, vec!["git-db".to_string()]);
let (kept, excluded) =
filter_selectable_cleanup_kinds(["cache".to_string()].into_iter().collect());
assert!(excluded.is_empty() && kept == ["cache".to_string()].into_iter().collect());
let (kept, excluded) = filter_selectable_cleanup_kinds(HashSet::new());
assert!(kept.is_empty() && excluded.is_empty());
for kind in NON_CLEANUP_KINDS {
let (kept, excluded) =
filter_selectable_cleanup_kinds([(kind.to_string())].into_iter().collect());
assert!(!kept.contains(*kind));
assert_eq!(excluded, vec![kind.to_string()]);
}
}
#[test]
fn storage_cleanup_apply_refuses_git_database_dirs() {
let home = dirs::home_dir().expect("home dir available");
let fixture = home.join(format!(
".dracon_storage_gitdb_test_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let git_db = fixture.join("project").join(".git");
std::fs::create_dir_all(&git_db).unwrap();
let validated = validate_storage_cleanup_path(&git_db, &[]);
let _ = std::fs::remove_dir_all(&fixture);
let err = validated.expect_err("apply must refuse a .git directory");
let msg = format!("{err:#}");
assert!(
msg.contains("git database"),
"refusal must name the reason, got: {msg}"
);
}
#[test]
fn storage_cleanup_apply_refuses_git_database_subpaths() {
let err = validate_storage_cleanup_path(Path::new("/home/user/Dev/project/.git/objects"), &[])
.expect_err("apply must refuse paths under a .git directory");
assert!(format!("{err:#}").contains("git database"));
}
#[test]
fn normalize_early_warn_clamped_to_warn() {
let policy = GuardPolicy {
disk_early_warn_percent: 95,
disk_warn_percent: 75,
..Default::default()
};
let mut policy = policy;
normalize_guard_policy(&mut policy);
assert!(
policy.disk_early_warn_percent <= policy.disk_warn_percent,
"early warn must not exceed warn, got early={} warn={}",
policy.disk_early_warn_percent,
policy.disk_warn_percent
);
}
#[test]
fn sync_frozen_resyncs_when_the_marker_disappears_externally() {
let fixture = std::env::temp_dir().join(format!(
"dracon-system-freeze-resync-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&fixture).expect("create fixture");
let marker = fixture.join("freeze");
let guard = GuardPolicy {
freeze_sync_at_action: true,
sync_freeze_marker: marker.display().to_string(),
unfreeze_below_percent: 70,
..GuardPolicy::default()
};
let mut sync_frozen = false;
manage_sync_freeze(&guard, 95, "action", &mut sync_frozen);
assert!(sync_frozen, "guard must claim a freeze it just performed");
assert!(marker.exists(), "guard must write its own marker");
manage_sync_freeze(&guard, 68, "ok", &mut sync_frozen);
assert!(!sync_frozen);
assert!(!marker.exists(), "guard must remove its own marker");
manage_sync_freeze(&guard, 95, "action", &mut sync_frozen);
assert!(sync_frozen);
fs::remove_file(&marker).expect("simulate an external clear");
manage_sync_freeze(&guard, 68, "ok", &mut sync_frozen);
assert!(
!sync_frozen,
"an externally cleared marker must resync the in-memory flag"
);
fs::remove_dir_all(fixture).expect("remove fixture");
}
#[test]
fn cleanup_stale_cooldowns_survives_an_extreme_cooldown() {
let mut state = GuardRuntimeState::default();
state.notify_cooldowns.insert(
"pid-1".to_string(),
Instant::now() - Duration::from_secs(60),
);
cleanup_stale_cooldowns(&mut state, u64::MAX);
cleanup_stale_cooldowns(&mut state, u64::MAX / 2);
state.notify_cooldowns.insert(
"pid-2".to_string(),
Instant::now() - Duration::from_secs(600),
);
cleanup_stale_cooldowns(&mut state, 5);
assert!(
!state.notify_cooldowns.contains_key("pid-2"),
"a 600s-old entry must be pruned at a 5s cooldown"
);
}
#[test]
fn guard_runtime_state_default_no_expiry_yet() {
let state = GuardRuntimeState::default();
assert!(state.last_quarantine_expire.is_none());
}
#[test]
fn quarantine_expire_interval_zero_is_never_clamped() {
let mut policy = GuardPolicy {
quarantine_expire_interval_secs: 0,
..Default::default()
};
normalize_guard_policy(&mut policy);
assert_eq!(policy.quarantine_expire_interval_secs, 0);
}
#[test]
fn quarantine_expire_interval_defaults_to_a_day() {
let policy = GuardPolicy::default();
assert_eq!(policy.quarantine_expire_interval_secs, 86_400);
}
#[test]
fn maybe_expire_quarantine_respects_both_opt_outs() {
let root = std::env::temp_dir().join(format!(
"dracon-expire-gate-{}-{}",
std::process::id(),
"gate"
));
let _ = std::fs::remove_dir_all(&root);
let src = root.join("work").join("proj");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("a.txt"), b"hello").unwrap();
let qdir = root.join("q");
let manifest = quarantine_move(&src, &qdir, &[]).unwrap();
let aged = QuarantineManifest {
name: manifest.name.clone(),
origin: manifest.origin.clone(),
moved_at_unix: now_unix().saturating_sub(400 * 86_400),
bytes: manifest.bytes,
files: manifest.files,
};
std::fs::write(
qdir.join(&manifest.name).join(".quarantine.json"),
serde_json::to_string_pretty(&aged).unwrap(),
)
.unwrap();
let entry_dir = qdir.join(&manifest.name);
let armed = GuardPolicy {
quarantine_dir: qdir.display().to_string(),
clean_quarantine_first: true,
quarantine_ttl_days: 30,
quarantine_expire_interval_secs: 0,
..Default::default()
};
let mut state = GuardRuntimeState::default();
maybe_expire_quarantine(&armed, &mut state, Instant::now());
assert!(
entry_dir.exists(),
"interval 0 must disable the unattended expiry entirely"
);
let unarmed = GuardPolicy {
quarantine_expire_interval_secs: 86_400,
clean_quarantine_first: false,
..armed.clone()
};
let mut state2 = GuardRuntimeState::default();
maybe_expire_quarantine(&unarmed, &mut state2, Instant::now());
assert!(
entry_dir.exists(),
"entries from a manual `quarantine move` must not become an unattended delete"
);
let active = GuardPolicy {
quarantine_expire_interval_secs: 86_400,
auto_cleanup_apply: true,
..armed.clone()
};
let mut state3 = GuardRuntimeState::default();
maybe_expire_quarantine(&active, &mut state3, Instant::now());
assert!(!entry_dir.exists(), "a past-TTL entry must be expired");
assert!(state3.last_quarantine_expire.is_some());
let src2 = root.join("work2").join("proj");
std::fs::create_dir_all(&src2).unwrap();
std::fs::write(src2.join("b.txt"), b"world!").unwrap();
let m2 = quarantine_move(&src2, &qdir, &[]).unwrap();
let aged2 = QuarantineManifest {
name: m2.name.clone(),
origin: m2.origin.clone(),
moved_at_unix: now_unix().saturating_sub(400 * 86_400),
bytes: m2.bytes,
files: m2.files,
};
std::fs::write(
qdir.join(&m2.name).join(".quarantine.json"),
serde_json::to_string_pretty(&aged2).unwrap(),
)
.unwrap();
maybe_expire_quarantine(&active, &mut state3, Instant::now());
assert!(
qdir.join(&m2.name).exists(),
"the cooldown must stop a per-pass rescan"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn maybe_expire_quarantine_dry_run_deletes_nothing() {
let root = std::env::temp_dir().join(format!(
"dracon-expire-dryrun-{}-{}",
std::process::id(),
"dryrun"
));
let _ = std::fs::remove_dir_all(&root);
let src = root.join("work").join("proj");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("a.txt"), b"hello").unwrap();
let qdir = root.join("q");
let manifest = quarantine_move(&src, &qdir, &[]).unwrap();
let aged = QuarantineManifest {
name: manifest.name.clone(),
origin: manifest.origin.clone(),
moved_at_unix: now_unix().saturating_sub(400 * 86_400),
bytes: manifest.bytes,
files: manifest.files,
};
std::fs::write(
qdir.join(&manifest.name).join(".quarantine.json"),
serde_json::to_string_pretty(&aged).unwrap(),
)
.unwrap();
let entry_dir = qdir.join(&manifest.name);
let dry = GuardPolicy {
quarantine_dir: qdir.display().to_string(),
clean_quarantine_first: true,
quarantine_ttl_days: 30,
quarantine_expire_interval_secs: 86_400,
auto_cleanup_apply: false,
..Default::default()
};
let mut state = GuardRuntimeState::default();
maybe_expire_quarantine(&dry, &mut state, Instant::now());
assert!(
entry_dir.exists(),
"dry-run expiry must not delete a past-TTL entry"
);
assert!(state.last_quarantine_expire.is_some());
let _ = std::fs::remove_dir_all(&root);
}
const ABSURD_INTERVALS: &[u64] = &[
0,
1,
60,
86_400,
u32::MAX as u64,
u64::MAX / 2,
u64::MAX - 1,
u64::MAX,
];
fn absurd_interval_fixture(tag: &str) -> (PathBuf, PathBuf, String) {
let root = std::env::temp_dir().join(format!(
"dracon-expire-absurd-{}-{}",
std::process::id(),
tag
));
let _ = std::fs::remove_dir_all(&root);
let qdir = root.join("q");
let src = root.join("work").join("proj");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("a.txt"), b"hello").unwrap();
let manifest = quarantine_move(&src, &qdir, &[]).unwrap();
let aged = QuarantineManifest {
name: manifest.name.clone(),
origin: manifest.origin.clone(),
moved_at_unix: now_unix().saturating_sub(400 * 86_400),
bytes: manifest.bytes,
files: manifest.files,
};
std::fs::write(
qdir.join(&manifest.name).join(".quarantine.json"),
serde_json::to_string_pretty(&aged).unwrap(),
)
.unwrap();
(root, qdir, manifest.name)
}
fn expiry_policy(qdir: &Path, interval: u64) -> GuardPolicy {
GuardPolicy {
quarantine_dir: qdir.display().to_string(),
clean_quarantine_first: true,
quarantine_ttl_days: 30,
quarantine_expire_interval_secs: interval,
auto_cleanup_apply: true,
..Default::default()
}
}
#[test]
fn absurd_expiry_intervals_are_not_due_once_the_cooldown_exists() {
for &interval in ABSURD_INTERVALS {
let (root, qdir, name) = absurd_interval_fixture("not-due");
let mut state = GuardRuntimeState {
last_quarantine_expire: Some(Instant::now() - Duration::from_secs(2)),
..Default::default()
};
maybe_expire_quarantine(&expiry_policy(&qdir, interval), &mut state, Instant::now());
if interval != 0 {
assert!(
qdir.join(&name).exists(),
"a non-zero interval must not be due immediately (interval={interval})"
);
}
let _ = std::fs::remove_dir_all(&root);
}
}
#[test]
fn absurd_expiry_intervals_complete_normally_on_the_first_pass() {
let (root, qdir, name) = absurd_interval_fixture("first-pass");
for &interval in ABSURD_INTERVALS {
let mut state = GuardRuntimeState::default();
maybe_expire_quarantine(&expiry_policy(&qdir, interval), &mut state, Instant::now());
if interval == 0 {
assert!(
state.last_quarantine_expire.is_none(),
"interval 0 must leave the runtime state untouched"
);
} else {
assert!(
state.last_quarantine_expire.is_some(),
"the cooldown must be stamped (interval={interval})"
);
}
}
assert!(
!qdir.join(&name).exists(),
"the first pass must still expire a past-TTL entry"
);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn dry_run_preview_omits_user_protected_targets() {
let root = std::env::temp_dir().join(format!(
"dracon-preview-protected-{}-{}",
std::process::id(),
"t"
));
let _ = std::fs::remove_dir_all(&root);
let protected_proj = root.join("protected-proj");
let free_proj = root.join("free-proj");
for proj in [&protected_proj, &free_proj] {
std::fs::create_dir_all(proj).unwrap();
std::fs::write(
proj.join("Cargo.toml"),
b"[package]\nname = \"probe\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
)
.unwrap();
let target = proj.join("target");
std::fs::create_dir_all(target.join("debug")).unwrap();
std::fs::write(
target.join("debug").join("blob.bin"),
vec![7u8; 4 * 1024 * 1024],
)
.unwrap();
let aged = std::process::Command::new("touch")
.arg("-d")
.arg("20 days ago")
.arg(&target)
.output()
.expect("touch must be available to age the fixture");
assert!(
aged.status.success(),
"touch failed: {}",
String::from_utf8_lossy(&aged.stderr)
);
}
let guard = GuardPolicy {
rust_search_roots: root.display().to_string(),
cleanup_min_size_mb: 1,
protected_paths: vec![protected_proj.display().to_string()],
quarantine_dir: root.join("q").display().to_string(),
clean_quarantine_first: true,
..Default::default()
};
let mut runtime = GuardRuntimeState::default();
let result = auto_cleanup_rust_targets(&guard, &mut runtime, false, true)
.await
.expect("dry-run scan must succeed");
let cleaned: Vec<&String> = result.cleaned_paths.iter().collect();
assert!(
!cleaned.iter().any(|p| p.contains("protected-proj")),
"the protected tree must NOT appear as reclaimable, got: {:?}",
result.cleaned_paths
);
assert!(
cleaned.iter().any(|p| p.contains("free-proj")),
"an unprotected sibling must still be offered, got: {:?}",
result.cleaned_paths
);
assert!(
result
.protected_paths
.iter()
.any(|p| p.contains("protected-proj")),
"the protected tree must be reported as protected, got: {:?}",
result.protected_paths
);
assert!(
result
.protected_paths
.iter()
.any(|p| p.contains("refusing to delete protected path")),
"the protected verdict must carry its reason, got: {:?}",
result.protected_paths
);
assert!(protected_proj.join("target").exists());
assert!(free_proj.join("target").exists());
assert!(
free_proj
.join("target")
.join("debug")
.join("blob.bin")
.exists(),
"the offered candidate must still be intact after a dry run"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn one_line_collapses_multiline_messages() {
assert_eq!(
one_line("refusing to delete protected path\n under user-protected path X"),
"refusing to delete protected path under user-protected path X"
);
assert_eq!(one_line("already one line"), "already one line");
assert_eq!(one_line(""), "");
}
#[tokio::test]
async fn storage_cleanup_activity_refuses_recent_artifacts() {
let project = tempfile::tempdir().unwrap();
let target = project.path().join("target");
fs::create_dir(&target).unwrap();
let error = validate_storage_cleanup_activity("rust-build", &target)
.await
.unwrap_err();
assert!(error.to_string().contains("modified <60s"), "{error}");
assert!(target.exists());
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn storage_cleanup_activity_refuses_live_rust_and_node_processes() {
if let Ok(comm) = std::env::var("DRACON_TEST_BUILD_COMM") {
fs::write("/proc/self/comm", comm).unwrap();
fs::write(".build-fixture-ready", b"ready").unwrap();
loop {
std::thread::sleep(Duration::from_secs(300));
}
}
struct ChildGuard(std::process::Child);
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
for (comm, kind, artifact) in [
("rustc", "rust-build", "target"),
("cargo", "build-output", "build"),
("node", "node-deps", "node_modules"),
] {
let project = tempfile::tempdir().unwrap();
let path = project.path().join(artifact);
fs::create_dir(&path).unwrap();
fs::write(path.join("keep"), b"synthetic artifact").unwrap();
let old = SystemTime::now() - Duration::from_secs(120);
File::open(&path)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(old))
.unwrap();
let mut child = ChildGuard(
std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"tests::storage_cleanup_activity_refuses_live_rust_and_node_processes",
])
.env("DRACON_TEST_BUILD_COMM", comm)
.current_dir(project.path())
.stdout(std::process::Stdio::null())
.spawn()
.unwrap(),
);
let ready = project.path().join(".build-fixture-ready");
let deadline = Instant::now() + Duration::from_secs(5);
while !ready.exists() && Instant::now() < deadline {
assert!(
child.0.try_wait().unwrap().is_none(),
"fixture exited before ready"
);
std::thread::sleep(Duration::from_millis(10));
}
assert!(ready.exists(), "fixture did not become ready");
let error = validate_storage_cleanup_activity(kind, &path)
.await
.unwrap_err();
assert!(error.to_string().contains("active"), "{kind}: {error}");
assert!(child.0.try_wait().unwrap().is_none());
assert_eq!(fs::read(path.join("keep")).unwrap(), b"synthetic artifact");
}
}
#[cfg(unix)]
#[tokio::test]
async fn rust_build_listing_failure_or_malformed_output_is_an_error() {
let _fixture_exec = fixture_exec_guard();
let bin = tempfile::tempdir().unwrap();
for body in ["exit 7", "printf 'broken\\n'", "exit 0"] {
let ps = bin.path().join("ps");
write_test_script(&ps, body);
assert!(detect_active_rust_builds_with(&ps).await.is_err());
}
}