safesweep 0.6.7

Clean caches and temp files that are provably safe to delete
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
//! End-to-end tests: they run the real `safesweep` binary against a fake home directory.
//!
//! Each test builds a fixture ([`Fx`]) with its own `HOME` and `TMPDIR` in temporary directories,
//! so nothing here can touch real files. Two limits shape the fixtures:
//!
//! - The binary reads the real clock, and a file's inode change time cannot be back-dated, so a
//!   freshly created file always looks recent. Tests that need an "old" candidate use the
//!   zero-day `pycache` rule, or check that the age rule *refuses* recent files. The age logic
//!   itself is tested in the unit tests, where the clock can be moved.
//! - A few tests need unprivileged mount namespaces (`unshare -rm`). On CI they must run, so being
//!   unable to use namespaces there fails the test instead of skipping it (see
//!   [`namespace_policy`]).

use assert_cmd::Command;
use filetime::{FileTime, set_file_mtime};
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use tempfile::TempDir;

/// Sets the modification time of everything under `path` to `days` days ago.
///
/// Only the modification time can be changed; the inode change time stays at "now", which is
/// how `tar -p` or `rsync -a` leave files. The guard treats such a tree as new.
fn age(path: &Path, days: u32) {
    let when = FileTime::from_system_time(SystemTime::now() - Duration::from_secs(86400) * days);
    for e in walkdir::WalkDir::new(path).contents_first(true) {
        set_file_mtime(e.unwrap().path(), when).unwrap();
    }
}

/// Creates a 4 KiB file at `path` (and its parent directories) and returns the path.
fn put(path: PathBuf) -> PathBuf {
    fs::create_dir_all(path.parent().unwrap()).unwrap();
    fs::write(&path, vec![1u8; 4096]).unwrap();
    path
}

/// A fake environment: a home directory and a temp directory with a few known candidates.
///
/// - `~/.cache/old`: modification time backdated, but changed just now, so still "recent";
/// - `~/.cache/fresh`: new;
/// - `proj/__pycache__`: deletable through the zero-day `pycache` rule;
/// - `busy/__pycache__`: a file inside is held open by the test, so it is in use;
/// - `$TMPDIR/stale`: backdated like `old`.
struct Fx {
    /// The fake `HOME`.
    home: TempDir,
    /// The fake `TMPDIR`.
    tmp: TempDir,
    /// Keeps a file inside `busy/__pycache__` open for the life of the fixture.
    _held: File,
}

impl Fx {
    /// Builds the fixture described on [`Fx`].
    fn new() -> Fx {
        let (home, tmp) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
        let h = home.path();
        // The binary runs on the real clock and ctime cannot be backdated, so every
        // age-gated candidate is recent; deletions go through the 0-day pycache rule.
        put(h.join(".cache/old/f"));
        put(h.join(".cache/fresh/f"));
        age(&h.join(".cache/old"), 10); // backdated mtime, like tar/cp -p
        put(h.join("proj/__pycache__/a.pyc"));
        let held = put(h.join("busy/__pycache__/b.pyc"));
        put(tmp.path().join("stale/f"));
        age(&tmp.path().join("stale"), 10);
        let _held = File::open(held).unwrap();
        Fx { home, tmp, _held }
    }

    /// `relative path -> status` from `scan --json` with extra args.
    fn statuses(&self, args: &[&str]) -> BTreeMap<String, String> {
        let out = self
            .cmd()
            .args(args)
            .args(["scan", "--json"])
            .assert()
            .success()
            .get_output()
            .stdout
            .clone();
        let rows: Vec<serde_json::Value> = serde_json::from_slice(&out).unwrap();
        rows.iter()
            .map(|r| {
                let path = self.rel(r["path"].as_str().unwrap());
                (path, r["status"].as_str().unwrap().to_string())
            })
            .collect()
    }

    /// A `safesweep` command whose `HOME` and `TMPDIR` point at this fixture.
    fn cmd(&self) -> Command {
        let mut c = Command::cargo_bin("safesweep").unwrap();
        c.env("HOME", self.home.path())
            .env("TMPDIR", self.tmp.path());
        c
    }

    /// Path relative to the fixture home or tmp, for readable assertions.
    fn rel(&self, p: &str) -> String {
        let p = Path::new(p);
        let base = if p.starts_with(self.home.path()) {
            self.home.path()
        } else {
            self.tmp.path()
        };
        p.strip_prefix(base).unwrap().to_string_lossy().into_owned()
    }
}

/// Runs `cmd`, asserts that it exits with `code`, and returns what it wrote to stderr.
fn fails(cmd: &mut Command, code: i32) -> String {
    let out = cmd.assert().code(code).get_output().stderr.clone();
    String::from_utf8_lossy(&out).into_owned()
}

/// Default `scan --json`: recent entries are skipped with the age reason, and the opt-in `pycache` rule is absent.
#[test]
fn scan_json_statuses() {
    let fx = Fx::new();
    let recent = "skipped: modified within 7 days".to_string();
    let want = BTreeMap::from([
        (".cache/old".into(), recent.clone()), // backdated mtime, fresh ctime
        (".cache/fresh".into(), recent.clone()),
        ("stale".into(), recent),
    ]);
    assert_eq!(fx.statuses(&[]), want);
}

/// `--category pycache` finds the `__pycache__` directories: the free one is `ok`, the one a process holds open is skipped as in use.
#[test]
fn pycache_is_opt_in() {
    let fx = Fx::new();
    let want = BTreeMap::from([
        ("proj/__pycache__".into(), "ok".into()),
        (
            "busy/__pycache__".into(),
            "skipped: in use by a process".into(),
        ),
    ]);
    assert_eq!(fx.statuses(&["--category", "pycache"]), want);
}

/// A relative `TMPDIR`, `/`, or one containing `HOME` is refused with exit code 2, because the tmp rule would sweep real data.
#[test]
fn rejects_unsafe_tmpdir() {
    let fx = Fx::new();
    for bad in [Path::new("."), fx.home.path(), Path::new("/")] {
        let out = fails(fx.cmd().env("TMPDIR", bad), 2);
        assert!(out.contains("error: TMPDIR must be"), "TMPDIR={bad:?}");
    }
}

/// What to do when a test needs `unshare -rm` (unprivileged user and mount namespaces):
/// `Ok(true)` run it, `Ok(false)` skip it, `Err(message)` fail the test.
///
/// A skip is silent in test output, and these tests guard the most important safety checks
/// (files used inside containers, mount points). So on CI, where a skip would hide that a
/// regression test never ran, being unable to use namespaces is a failure, not a skip.
fn namespace_policy(available: bool, on_ci: bool) -> Result<bool, String> {
    match (available, on_ci) {
        (true, _) => Ok(true),
        (false, false) => Ok(false),
        (false, true) => Err(
            "`unshare -rm` is unavailable on CI, so the mount-namespace safety \
                              tests cannot run; enable unprivileged user namespaces on the runner"
                .into(),
        ),
    }
}

/// Whether this test can run `unshare -rm`; panics on CI if it cannot. See [`namespace_policy`].
fn namespaces_usable() -> bool {
    let works = std::process::Command::new("unshare")
        .args(["-rm", "true"])
        .status()
        .is_ok_and(|s| s.success());
    match namespace_policy(works, std::env::var_os("CI").is_some()) {
        Ok(run) => {
            if !run {
                eprintln!("skipping: unshare -rm unavailable");
            }
            run
        }
        Err(msg) => panic!("{msg}"),
    }
}

/// Without namespace support a test is skipped on a developer machine but fails on CI.
#[test]
fn namespace_policy_skips_locally_and_fails_on_ci() {
    assert_eq!(namespace_policy(true, false), Ok(true));
    assert_eq!(namespace_policy(true, true), Ok(true));
    assert_eq!(namespace_policy(false, false), Ok(false));
    let err = namespace_policy(false, true).unwrap_err();
    assert!(err.contains("unshare") && err.contains("CI"), "{err}");
}

/// Runs the binary inside a user+mount namespace with `setup` mounts applied first.
/// Returns None if this system does not allow unprivileged namespaces.
fn scan_in_namespace(fx: &Fx, setup: &str) -> Option<BTreeMap<String, String>> {
    if !namespaces_usable() {
        return None;
    }
    let bin = assert_cmd::cargo::cargo_bin("safesweep");
    let script = format!("{setup} && exec \"$0\" --category pycache scan --json");
    let out = std::process::Command::new("unshare")
        .args(["-rm", "sh", "-c", &script])
        .arg(bin)
        .env("HOME", fx.home.path())
        .env("TMPDIR", fx.tmp.path())
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    let rows: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout).unwrap();
    Some(
        rows.iter()
            .map(|r| {
                (
                    fx.rel(r["path"].as_str().unwrap()),
                    r["status"].as_str().unwrap().to_string(),
                )
            })
            .collect(),
    )
}

/// A candidate that is itself a mount point is skipped, not deleted (its contents belong to another filesystem).
#[test]
fn skips_candidate_that_is_a_mount_point() {
    let fx = Fx::new();
    let dir = fx.home.path().join("proj/__pycache__");
    let setup = format!("mount -t tmpfs none '{}'", dir.display());
    if let Some(got) = scan_in_namespace(&fx, &setup) {
        assert_eq!(got["proj/__pycache__"], "skipped: contains a mount point");
    }
}

/// A candidate with a bind mount inside is skipped, and the data behind the mount survives (device numbers alone cannot see this).
#[test]
fn skips_candidate_containing_bind_mount() {
    let fx = Fx::new();
    let precious = put(fx.home.path().join("precious/data"));
    let target = fx.home.path().join("proj/__pycache__/b");
    fs::create_dir(&target).unwrap();
    let setup = format!(
        "mount --bind '{}' '{}'",
        precious.parent().unwrap().display(),
        target.display()
    );
    if let Some(got) = scan_in_namespace(&fx, &setup) {
        assert_eq!(got["proj/__pycache__"], "skipped: contains a mount point");
    }
    assert!(precious.exists());
}

/// `clean` is a dry run and deletes nothing; `--apply` removes only what the rules allow, and the manifest lists exactly the removed paths.
#[test]
fn clean_dry_run_then_apply() {
    let fx = Fx::new();
    let (h, t) = (fx.home.path(), fx.tmp.path());
    let dry = fx
        .cmd()
        .arg("clean")
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    assert!(String::from_utf8_lossy(&dry).contains("would free"));
    assert!(h.join(".cache/old").exists() && t.join("stale").exists());

    // default rules: everything is recent (fresh ctime), and pycache is opt-in
    fx.cmd().args(["clean", "--apply"]).assert().success();
    assert!(h.join(".cache/old").exists() && t.join("stale").exists());
    assert!(h.join("proj/__pycache__").exists());

    fx.cmd()
        .args(["--category", "pycache", "clean", "--apply"])
        .assert()
        .success();
    assert!(!h.join("proj/__pycache__").exists());
    assert!(h.join("busy/__pycache__").exists());

    let log: String = fs::read_dir(h.join(".local/state/safesweep"))
        .unwrap()
        .map(|e| fs::read_to_string(e.unwrap().path()).unwrap())
        .collect();
    let removed: Vec<String> = log
        .lines()
        .map(|l| fx.rel(l.split('\t').nth(1).unwrap()))
        .collect();
    assert_eq!(removed, ["proj/__pycache__"]);
}

/// An unknown `--category` is a usage error (exit 2) that lists the valid names.
#[test]
fn unknown_category_exits_2() {
    let fx = Fx::new();
    let out = fails(fx.cmd().args(["--category", "nope"]), 2);
    assert!(out.contains("unknown category 'nope'"));
}

/// Without `HOME` the program refuses to run (exit 2) instead of treating the current directory as home.
#[test]
fn missing_home_exits_2() {
    let fx = Fx::new();
    let out = fails(fx.cmd().env_remove("HOME"), 2);
    assert!(out.contains("error: HOME is not set"));
}

/// `why` prints the busy percentage and the per-program table.
#[test]
fn why_reports_cpu() {
    let fx = Fx::new();
    let out = fx
        .cmd()
        .args(["why", "--interval-ms", "200"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let out = String::from_utf8_lossy(&out);
    assert!(out.starts_with("CPU busy: "), "{out}");
    assert!(out.contains("PROGRAM"), "{out}");
}

/// The opt-in `artifacts` rule lists only the idle project, and the guard still skips it because the tree changed recently.
#[test]
fn artifacts_lists_idle_projects_but_guard_still_applies() {
    let fx = Fx::new();
    let h = fx.home.path();
    let old = FileTime::from_system_time(SystemTime::now() - Duration::from_secs(86400 * 60));
    for (name, git_age) in [("idle", Some(old)), ("active", None)] {
        put(h.join(format!("code/{name}/node_modules/x/f")));
        put(h.join(format!("code/{name}/node_modules/.package-lock.json")));
        put(h.join(format!("code/{name}/package.json")));
        fs::create_dir_all(h.join(format!("code/{name}/.git/logs"))).unwrap();
        for f in ["logs/HEAD", "index"] {
            let p = h.join(format!("code/{name}/.git/{f}"));
            fs::write(&p, "x").unwrap();
            if let Some(t) = git_age {
                set_file_mtime(&p, t).unwrap();
            }
        }
    }
    // opt-in: absent from the default scan
    assert!(!fx.statuses(&[]).keys().any(|k| k.contains("node_modules")));
    // listed for the idle project only, and still skipped: its tree changed just now
    let want = BTreeMap::from([(
        "code/idle/node_modules".into(),
        "skipped: modified within 30 days".into(),
    )]);
    assert_eq!(fx.statuses(&["--category", "artifacts"]), want);
}

/// `why --interval-ms 0` is a usage error: a zero-length sample would print made-up numbers.
#[test]
fn why_rejects_tiny_interval() {
    let fx = Fx::new();
    let out = fails(fx.cmd().args(["why", "--interval-ms", "0"]), 2);
    assert!(out.contains("--interval-ms must be at least 100"));
}

/// `why` does not panic when its output pipe is closed early (`safesweep why | head`).
#[test]
fn why_survives_closed_pipe() {
    let fx = Fx::new();
    let bin = assert_cmd::cargo::cargo_bin("safesweep");
    let mut child = std::process::Command::new(bin)
        .args(["why", "--interval-ms", "200"])
        .env("HOME", fx.home.path())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .unwrap();
    drop(child.stdout.take()); // like `| head -0`: the reader is gone before output arrives
    let out = child.wait_with_output().unwrap();
    assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
}

/// `tui` refuses to start without a terminal (exit 2) instead of writing control codes to a pipe.
#[test]
fn tui_needs_a_terminal() {
    let fx = Fx::new();
    let out = fails(fx.cmd().arg("tui"), 2);
    assert!(out.contains("error: tui needs an interactive terminal"));
}

/// A process in another mount namespace sees the candidate under a different path; it is still detected as in use, by inode.
#[test]
fn in_use_seen_through_other_mount_namespace() {
    // podman / unshare -m: the process sees the candidate under another path (/mnt here),
    // so path text in /proc never matches; only the inode does.
    let fx = Fx::new();
    let live = fx.home.path().join("proj/__pycache__");
    let mnt = fx.home.path().join("mnt");
    fs::create_dir(&mnt).unwrap();
    if !namespaces_usable() {
        return;
    }
    let mut child = std::process::Command::new("unshare")
        .args([
            "-rm",
            "sh",
            "-c",
            r#"mount --bind "$0" "$1" && cd "$1" && echo ready && exec sleep 30"#,
        ])
        .arg(&live)
        .arg(&mnt)
        .stdout(std::process::Stdio::piped())
        .spawn()
        .unwrap();
    let mut line = String::new();
    std::io::BufRead::read_line(
        &mut std::io::BufReader::new(child.stdout.take().unwrap()),
        &mut line,
    )
    .unwrap();
    assert_eq!(line.trim(), "ready");
    let got = fx.statuses(&["--category", "pycache"]);
    child.kill().unwrap();
    child.wait().unwrap();
    assert_eq!(got["proj/__pycache__"], "skipped: in use by a process");
}

/// A relative `HOME` is a usage error (exit 2).
#[test]
fn relative_home_exits_2() {
    let fx = Fx::new();
    let out = fails(fx.cmd().env("HOME", "rel"), 2);
    assert!(out.contains("HOME must be an absolute path"));
}

/// `--category` with `why` is an error, because `why` does not use the rule table.
#[test]
fn category_with_why_is_an_error() {
    let fx = Fx::new();
    let out = fails(fx.cmd().args(["--category", "pip", "why"]), 2);
    assert!(out.contains("--category does not apply to why"));
}

/// `completions` prints a script for bash, zsh and fish, and rejects an unknown shell (exit 2).
#[test]
fn completions_for_each_shell() {
    let fx = Fx::new();
    for (shell, marker) in [
        ("bash", "complete -F"),
        ("zsh", "#compdef safesweep"),
        ("fish", "complete -c safesweep"),
    ] {
        let out = fx
            .cmd()
            .args(["completions", shell])
            .assert()
            .success()
            .get_output()
            .stdout
            .clone();
        let out = String::from_utf8_lossy(&out);
        assert!(out.contains(marker), "{shell}: {out}");
        assert!(
            out.contains("artifacts") || out.contains("clean"),
            "{shell} lists subcommands"
        );
    }
    fx.cmd()
        .args(["completions", "nosuchshell"])
        .assert()
        .code(2);
}

/// `man` prints a roff man page that mentions the command and its subcommands.
#[test]
fn man_page_is_roff() {
    let fx = Fx::new();
    let out = fx
        .cmd()
        .arg("man")
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let out = String::from_utf8_lossy(&out);
    assert!(
        out.starts_with(".ie") || out.starts_with(".TH"),
        "{}",
        &out[..out.len().min(80)]
    );
    assert!(
        out.contains("safesweep") && out.contains("clean"),
        "mentions the command and subcommands"
    );
}

/// `--category` with `completions` or `man` is an error, like with `why`.
#[test]
fn category_with_completions_and_man_is_an_error() {
    let fx = Fx::new();
    for args in [
        ["--category", "pip", "completions", "bash"].as_slice(),
        ["--category", "pip", "man"].as_slice(),
    ] {
        let out = fails(fx.cmd().args(args), 2);
        assert!(out.contains("--category does not apply to"), "{args:?}");
    }
}