dracon-system 0.112.45

Disk, process, storage, and service diagnostics for local development machines
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
//! Safety checks — protect system paths from accidental deletion.

use anyhow::Result;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};

/// User-protected entries already reported as unresolvable, so the warning is
/// emitted once per entry rather than once per candidate.
///
/// `check_safe_to_delete_guard` runs per cleanup candidate, so a config typo
/// would otherwise reprint the same line on every candidate of every 30-second
/// pass — a warning nobody reads because it is the only thing in the log.
/// Deduped, it is one line naming the entry that is not protecting anything.
static WARNED_UNRESOLVABLE_PROTECTED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();

/// Record that `entry` is unresolvable, returning true the first time it is
/// seen and false afterwards. Split out from the `eprintln` so the
/// once-per-entry behaviour is testable rather than trapped inside a stream
/// write.
pub(crate) fn note_unresolvable_protected(entry: &str) -> bool {
    let seen = WARNED_UNRESOLVABLE_PROTECTED.get_or_init(|| Mutex::new(HashSet::new()));
    seen.lock()
        .map(|mut s| s.insert(entry.to_string()))
        .unwrap_or(true)
}

/// System directories always protected from deletion.
pub(crate) const SYSTEM_PROTECTED: &[&str] = &[
    "/", "/home", "/etc", "/usr", "/var", "/boot", "/nix", "/run", "/sys", "/dev", "/proc",
];

/// Verifies that `path` is safe to delete (not a protected system path).
///
/// **Security note:** This function canonicalizes the path and returns it for
/// the caller to delete separately. There is a TOCTOU window between
/// canonicalization and deletion where a symlink could be planted.
/// This is mitigated by the systemd service hardening:
/// `NoNewPrivileges=true`, `ProtectSystem=strict`, `ProtectHome=read-only`.
///
/// Kept as the STRICT classifier on purpose. The guard's own delete paths use
/// `check_safe_to_delete_guard`, which permits a `$HOME` descendant (the guard
/// legitimately deletes `~/Dev/*/target`, `~/.cache/*`); this one refuses it.
/// The 2026-10-01 audit found `link apply --force-replace` calling THIS
/// variant, which made the flag dead for every link under `$HOME` — having two
/// near-identical safety functions is exactly how that happened, so the
/// difference is pinned by tests rather than left to memory. Dead-code allowed
/// outside test builds, where only those tests use it.
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn check_safe_to_delete(path: &Path, user_protected: &[String]) -> Result<PathBuf> {
    let canon = match path.canonicalize() {
        Ok(p) => p,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Ok(path.to_path_buf());
        }
        Err(e) => anyhow::bail!(
            "cannot canonicalize {}: {} — refusing to delete",
            path.display(),
            e
        ),
    };

    // Reject symlinks to mitigate TOCTOU
    if let Ok(meta) = std::fs::symlink_metadata(path) {
        if meta.file_type().is_symlink() {
            anyhow::bail!(
                "refusing to delete symlink {} — use target directly",
                path.display()
            );
        }
    }

    let canon_str = canon.display().to_string();

    for prot in SYSTEM_PROTECTED {
        if is_protected_ancestor(&canon_str, prot) {
            anyhow::bail!(
                "refusing to delete protected path {} (under system root {})",
                canon.display(),
                prot
            );
        }
    }

    for user_prot in user_protected {
        let prot_canon = match Path::new(user_prot).canonicalize() {
            Ok(p) => p.display().to_string(),
            // FIXED 2026-10-02 (audit HIGH): this arm was a bare `continue` with
            // no diagnostic — the same fail-open-silently bug as in
            // `check_safe_to_delete_guard`, found here while proving the fix.
            // An entry that cannot resolve protects nothing, and the operator
            // has no way to learn that. Warn once per entry, then skip: still
            // fail OPEN, because refusing every candidate over one typo would
            // fill the disk, which is worse than the typo.
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                if note_unresolvable_protected(user_prot) {
                    eprintln!(
                        "⚠️ protected_paths entry does not resolve, so it is protecting NOTHING: {}",
                        user_prot
                    );
                }
                continue;
            }
            Err(e) => anyhow::bail!(
                "cannot canonicalize user-protected path {}: {} — refusing",
                user_prot,
                e
            ),
        };
        if is_protected_ancestor(&canon_str, &prot_canon) {
            anyhow::bail!(
                "refusing to delete protected path {} (under user-protected path {})",
                canon.display(),
                user_prot
            );
        }
    }

    Ok(canon)
}

/// Temporary roots accepted by the age-based `clean_tmp` cleanup.
///
/// This is deliberately an explicit allowlist rather than a generic
/// "anything below the current user's home" rule. Paths below `/tmp` are
/// allowed, but the configured root must resolve there and may not itself be
/// a symlink.
pub(crate) const SAFE_TMP_ROOTS: &[&str] = &["/tmp"];

/// Validate a configured `clean_tmp` search root and return its canonical path.
///
/// Missing, non-directory, symlink, and non-temporary roots are rejected so a
/// bad configuration cannot turn the top-level age scan into home-directory
/// cleanup. Callers should use the returned canonical path for both scanning
/// and containment checks on deletion candidates.
pub(crate) fn check_safe_tmp_root(path: &Path) -> Result<PathBuf> {
    let metadata = std::fs::symlink_metadata(path).map_err(|e| {
        anyhow::anyhow!(
            "cannot inspect configured tmp root {}: {} — refusing to scan",
            path.display(),
            e
        )
    })?;
    if metadata.file_type().is_symlink() {
        anyhow::bail!(
            "refusing configured tmp root {} because it is a symlink",
            path.display()
        );
    }
    if !metadata.is_dir() {
        anyhow::bail!(
            "refusing configured tmp root {} because it is not a directory",
            path.display()
        );
    }

    let canon = path.canonicalize().map_err(|e| {
        anyhow::anyhow!(
            "cannot canonicalize configured tmp root {}: {} — refusing to scan",
            path.display(),
            e
        )
    })?;
    if !SAFE_TMP_ROOTS
        .iter()
        .any(|root| canon.starts_with(Path::new(root)))
    {
        anyhow::bail!(
            "refusing configured tmp root {}: it must be /tmp (or a descendant)",
            canon.display()
        );
    }

    Ok(canon)
}

/// Guard-specific safety check — skips descendant checks for SYSTEM_PROTECTED
/// because the guard only deletes known artifact/cache directories (~/Dev/*/target,
/// ~/.cache/*, ~/.local/share/Trash/*) which are legitimately under /home.
/// Still rejects exact system roots, user-protected paths, symlinks, and
/// canonicalization failures.
pub(crate) fn check_safe_to_delete_guard(
    path: &Path,
    user_protected: &[String],
) -> Result<PathBuf> {
    let canon = match path.canonicalize() {
        Ok(p) => p,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Ok(path.to_path_buf());
        }
        Err(e) => anyhow::bail!(
            "cannot canonicalize {}: {} — refusing to delete",
            path.display(),
            e
        ),
    };

    // Reject symlinks to mitigate TOCTOU
    if let Ok(meta) = std::fs::symlink_metadata(path) {
        if meta.file_type().is_symlink() {
            anyhow::bail!(
                "refusing to delete symlink {} — use target directly",
                path.display()
            );
        }
    }

    let canon_str = canon.display().to_string();

    for prot in SYSTEM_PROTECTED {
        if canon_str == *prot {
            anyhow::bail!(
                "refusing guard cleanup of protected system path {}",
                canon.display()
            );
        }
    }

    // Only check user-protected paths, not SYSTEM_PROTECTED descendants
    for user_prot in user_protected {
        let prot_canon = match Path::new(user_prot).canonicalize() {
            Ok(p) => p.display().to_string(),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                // FIXED 2026-10-02 (audit HIGH): this used to `continue` with
                // no diagnostic at all. That is what made the `~` form of
                // `protected_paths` fail invisibly for four days: the config
                // parsed, the guard listed the protected tree as a reclaim
                // candidate, and the only symptom was a tree the operator
                // believed was protected not being protected.
                //
                // Warn, then skip — deliberately NOT a hard refusal. A single
                // typo must not make every cleanup candidate refuse, because
                // that converts a typo into a disk that fills and never
                // reclaims, which is worse than the typo. The point is to make
                // the failure visible so it gets fixed.
                let first = note_unresolvable_protected(user_prot);
                if first {
                    eprintln!(
                        "⚠️ protected_paths entry does not resolve, so it is protecting NOTHING: {}",
                        user_prot
                    );
                }
                continue;
            }
            Err(e) => anyhow::bail!(
                "cannot canonicalize user-protected path {}: {} — refusing",
                user_prot,
                e
            ),
        };
        if is_protected_ancestor(&canon_str, &prot_canon) {
            anyhow::bail!(
                "refusing to delete protected path {} (under user-protected path {})",
                canon.display(),
                user_prot
            );
        }
    }

    Ok(canon)
}

/// Validate a tmp cleanup candidate against the already validated tmp root in
/// addition to the guard's normal protected-path and symlink checks. The
/// containment check prevents a root-directory redirection from making a
/// candidate outside the approved temporary namespace deletable.
pub(crate) fn check_safe_to_delete_tmp_entry(
    path: &Path,
    tmp_root: &Path,
    user_protected: &[String],
) -> Result<PathBuf> {
    let canon = check_safe_to_delete_guard(path, user_protected)?;
    if !canon.starts_with(tmp_root) {
        anyhow::bail!(
            "refusing to delete tmp entry {}: resolved path is outside validated tmp root {}",
            canon.display(),
            tmp_root.display()
        );
    }
    Ok(canon)
}

/// Check if `path` is equal to or a descendant of `protected`.
/// Both must be canonicalized absolute paths.
pub(crate) fn is_protected_ancestor(path: &str, protected: &str) -> bool {
    if path == protected {
        return true;
    }
    // Root '/' is special: every path is a descendant, so only match exact.
    if protected == "/" {
        return path == "/";
    }
    // Ensure protected ends with '/' so '/home' doesn't match '/homefoo'
    let prefix = if protected.ends_with('/') {
        protected.to_string()
    } else {
        format!("{}/", protected)
    };
    path.starts_with(&prefix)
}

// ---------------------------------------------------------------------------
// Storage roots vs the shipped unit's hardening (2026-10-01)
// ---------------------------------------------------------------------------

/// The guard unit this binary ships with, compiled in.
///
/// `include_str!` rather than reading the deployed copy: this check must
/// describe the unit THIS release would install, and stay testable on a host
/// where nothing is deployed. `scripts/check-unit-deployment.sh` is what keeps
/// the deployed copy identical to it.
pub(crate) const SHIPPED_GUARD_UNIT: &str = include_str!("../dracon-system-guard.service");

/// The paths `ReadWritePaths=` in `unit` grants write access to, with `%h`
/// expanded and the `-` ("ignore if missing") prefix stripped.
///
/// A leading `-` does NOT narrow what is granted — it only stops systemd from
/// refusing to START when the path is absent. On a host where it exists, the
/// path is still made writable, so it must count as covered here. Multiple
/// `ReadWritePaths=` lines accumulate, exactly as systemd accumulates them.
pub(crate) fn unit_readwrite_paths(unit: &str, home: &Path) -> Vec<PathBuf> {
    let mut granted = Vec::new();
    for line in unit.lines() {
        let line = line.trim();
        // Comments and non-directives are skipped; a directive is `Key=value`.
        let Some(value) = line.strip_prefix("ReadWritePaths=") else {
            continue;
        };
        for token in value.split_whitespace() {
            let token = token.strip_prefix('-').unwrap_or(token);
            granted.push(expand_unit_path(token, home));
        }
    }
    granted
}

/// Expand one `ReadWritePaths=` token against `home`. Absolute paths pass
/// through; `%h` is the unit specifier for the user's home.
fn expand_unit_path(token: &str, home: &Path) -> PathBuf {
    if let Some(rest) = token.strip_prefix("%h") {
        return home.join(rest.trim_start_matches('/'));
    }
    PathBuf::from(token)
}

/// Whether the unit's `ReadWritePaths=` makes `path` writable.
///
/// `ProtectSystem=strict` remounts the whole hierarchy read-only inside the
/// service's namespace, and `ProtectHome=read-only` does the same for `$HOME`.
/// Only these paths are exempted, so a root that is not under one of them fails
/// every write with EROFS — which is exactly how quarantine silently stopped
/// working on 2026-10-01.
pub(crate) fn unit_grants_write(unit: &str, home: &Path, path: &Path) -> bool {
    // FIXED 2026-10-03 (audit R4-SYS-14): compare canonical paths so
    // a symlinked storage root matches the grant on its target — the
    // checker (R3-L23) already matches canonical, and the split warned
    // here while passing there.
    let canon_path = canonical_or_literal(path);
    unit_readwrite_paths(unit, home)
        .iter()
        .any(|granted| canon_path.starts_with(canonical_or_literal(granted)))
}

/// Canonicalize for comparison, with literal fallback (mirrors the
/// checker's `readlink -f ... || literal`, R3-L23). Missing paths
/// (granted `-` entries for not-yet-created dirs, unconfigured
/// roots) keep comparing literally. Relative paths are NEVER
/// resolved: canonicalize would anchor them to THIS process's CWD,
/// not the service's WorkingDirectory, so resolving them would flip
/// verdicts depending on where the CLI was invoked.
fn canonical_or_literal(path: &Path) -> PathBuf {
    if !path.is_absolute() {
        return path.to_path_buf();
    }
    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}

/// The storage roots this policy writes to that the shipped unit would leave
/// read-only, as `(policy key, resolved path)` pairs.
///
/// Only roots that are actually configured are reported: an unset
/// `relocate_cold_root` means relocation is off, and naming a path nothing will
/// ever write to would be noise.
pub(crate) fn uncovered_storage_roots(
    unit: &str,
    guard: &crate::GuardPolicy,
) -> Vec<(&'static str, PathBuf)> {
    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
    let cold_root = guard.relocate_cold_root.trim();
    let mut roots: Vec<(&'static str, PathBuf)> = vec![
        ("quarantine_dir", crate::quarantine_root(guard)),
        ("relocate_cold_root", crate::expand_tilde(cold_root)),
    ];
    // FIXED 2026-10-03 (audit R4-SYS-06): the guard also WRITES the
    // event log, the (truncated) log dirs, and the sync freeze marker.
    // A repointed one outside ReadWritePaths failed EROFS on every
    // pass with no startup warning — the silent-reclaim-death class
    // this check exists to catch. Each arm mirrors the write site's
    // own resolution so the check tests the path actually written.
    if let Some(log_file) = crate::resolve_guard_log_path(&guard.guard_log_file) {
        roots.push(("guard_log_file", log_file));
    }
    if let Some(configured) = crate::effective_log_dirs(&guard.log_dirs) {
        for entry in configured.split(',') {
            let entry = entry.trim();
            if !entry.is_empty() {
                roots.push(("log_dirs", crate::expand_tilde(entry)));
            }
        }
    }
    let marker = guard.sync_freeze_marker.trim();
    if marker.is_empty() {
        // Blank normalizes to the default (policy.rs), exactly as
        // quarantine_root does for its blank — check the default.
        roots.push((
            "sync_freeze_marker",
            PathBuf::from(crate::default_sync_freeze_marker()),
        ));
    } else {
        // FIXED 2026-10-09 (audit F130): this arm used to mirror the
        // write site's (missing) expansion verbatim — "no tilde
        // expansion there, so none here — a `~` value would be written
        // literally". That made the unit-hardening check certify the
        // SAME wrong path the daemon would have written. Both sides now
        // resolve through `resolve_policy_path`, so the check tests the
        // path actually written.
        roots.push(("sync_freeze_marker", crate::resolve_policy_path(marker)));
    }
    roots
        .into_iter()
        .filter(|(_, path)| !path.as_os_str().is_empty() && path != &PathBuf::from("."))
        .filter(|(_, path)| !unit_grants_write(unit, &home, path))
        .collect()
}

/// Say ONCE, on stderr, which configured storage roots the shipped unit would
/// leave read-only, naming the exact entry to add.
///
/// Printed to stderr so `guard once --json` stays machine-parseable. Emitted
/// once per process from the command entry point, NOT per pass: the old
/// behaviour was one "failed to quarantine … Read-only file system" line per
/// candidate per pass, which buried the single actionable fact under thousands
/// of identical errors.
pub(crate) fn warn_uncovered_storage_roots(guard: &crate::GuardPolicy) {
    for (key, path) in uncovered_storage_roots(SHIPPED_GUARD_UNIT, guard) {
        eprintln!(
            "⚠️ {key} = {} is not in the guard unit's ReadWritePaths — writes there \
             fail EROFS under ProtectSystem=strict. Add: -{} to ReadWritePaths= in \
             dracon-system-guard.service, then reinstall the unit.",
            path.display(),
            path.display()
        );
    }
}