Skip to main content

pacsea/logic/preflight/
guardrails.rs

1//! Pre-transaction guardrails: pacman db-lock detection, disk-space checks, and
2//! sync-database freshness checks with actionable guidance.
3//!
4//! These checks run before install/remove/update transactions (CLI and TUI) and are
5//! read-only: they never mutate the system and are therefore safe in dry-run mode.
6
7use std::path::{Path, PathBuf};
8use std::time::SystemTime;
9
10/// The kind of transaction a guardrail check runs for.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum GuardrailOperation {
13    /// Packages will be installed (downloads into the pacman cache).
14    Install,
15    /// Packages will be removed (no downloads).
16    Remove,
17    /// Full system update (downloads into the pacman cache).
18    Update,
19}
20
21/// A single guardrail finding carrying the data needed to render an actionable message.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum GuardrailIssue {
24    /// The pacman database lock file exists.
25    DbLocked {
26        /// Path of the lock file (usually `/var/lib/pacman/db.lck`).
27        lock_path: PathBuf,
28        /// Whether a package manager process (pacman/paru/yay) appears to be running.
29        pacman_running: bool,
30    },
31    /// Free space on the filesystem holding `path` is below the configured minimum.
32    LowDiskSpace {
33        /// Directory whose filesystem was checked (usually the pacman package cache).
34        path: PathBuf,
35        /// Free space in MiB.
36        available_mib: u64,
37        /// Configured minimum in MiB.
38        min_free_mib: u64,
39    },
40    /// The pacman sync databases have not been refreshed for `age_days` days.
41    SyncDbStale {
42        /// Age of the newest sync database in whole days.
43        age_days: u64,
44    },
45}
46
47/// Filesystem locations and thresholds consulted by the guardrail checks.
48///
49/// Details: Injectable so tests can point the checks at temporary directories.
50pub struct GuardrailContext {
51    /// Path of the pacman database lock file.
52    pub db_lock_path: PathBuf,
53    /// Pacman package cache directory (download target for installs/updates).
54    pub pacman_cache_dir: PathBuf,
55    /// Directory holding the pacman sync databases (`*.db`).
56    pub sync_db_dir: PathBuf,
57    /// Root of the `/proc` filesystem used to detect running package managers.
58    pub proc_dir: PathBuf,
59    /// Minimum free space (MiB) below which a warning is raised.
60    pub min_free_mib: u64,
61    /// Age in days after which the sync databases are considered stale.
62    pub stale_sync_days: u64,
63}
64
65impl Default for GuardrailContext {
66    /// What: Build the standard Arch Linux locations with default thresholds.
67    ///
68    /// Inputs:
69    /// - None.
70    ///
71    /// Output:
72    /// - Context pointing at `/var/lib/pacman`, `/var/cache/pacman/pkg`, and `/proc`,
73    ///   with a 1 GiB free-space minimum and a 14-day staleness window.
74    ///
75    /// Details:
76    /// - Paths intentionally match pacman defaults; systems with a relocated `DBPath`
77    ///   can construct the context manually.
78    fn default() -> Self {
79        Self {
80            db_lock_path: PathBuf::from("/var/lib/pacman/db.lck"),
81            pacman_cache_dir: PathBuf::from("/var/cache/pacman/pkg"),
82            sync_db_dir: PathBuf::from("/var/lib/pacman/sync"),
83            proc_dir: PathBuf::from("/proc"),
84            min_free_mib: 1024,
85            stale_sync_days: 14,
86        }
87    }
88}
89
90/// What: Determine whether a package manager process appears to be running.
91///
92/// Inputs:
93/// - `proc_dir`: Root of the `/proc` filesystem (injectable for tests).
94///
95/// Output:
96/// - `true` when a process named pacman/paru/yay (or pacman-key) is found.
97///
98/// Details:
99/// - Scans numeric `/proc/<pid>/comm` entries; unreadable entries are skipped.
100/// - Returns `false` on platforms or systems without a proc filesystem.
101fn package_manager_running(proc_dir: &Path) -> bool {
102    let Ok(entries) = std::fs::read_dir(proc_dir) else {
103        return false;
104    };
105    for entry in entries.flatten() {
106        let name = entry.file_name();
107        let Some(name) = name.to_str() else { continue };
108        if !name.bytes().all(|b| b.is_ascii_digit()) {
109            continue;
110        }
111        let Ok(comm) = std::fs::read_to_string(entry.path().join("comm")) else {
112            continue;
113        };
114        let comm = comm.trim();
115        if matches!(comm, "pacman" | "paru" | "yay" | "pacman-key") {
116            return true;
117        }
118    }
119    false
120}
121
122/// What: Check whether the pacman database is locked.
123///
124/// Inputs:
125/// - `ctx`: Guardrail context providing the lock path and proc dir.
126///
127/// Output:
128/// - `Some(GuardrailIssue::DbLocked)` when the lock file exists; `None` otherwise.
129///
130/// Details:
131/// - Also reports whether a package manager process is running so callers can
132///   distinguish "wait for it to finish" from "remove the stale lock".
133#[must_use]
134pub fn check_db_lock(ctx: &GuardrailContext) -> Option<GuardrailIssue> {
135    if !ctx.db_lock_path.exists() {
136        return None;
137    }
138    Some(GuardrailIssue::DbLocked {
139        lock_path: ctx.db_lock_path.clone(),
140        pacman_running: package_manager_running(&ctx.proc_dir),
141    })
142}
143
144/// What: Report the free space in MiB on the filesystem containing `path`.
145///
146/// Inputs:
147/// - `path`: Directory to inspect (must exist).
148///
149/// Output:
150/// - `Some(free MiB)` on success; `None` when the query fails or is unsupported.
151///
152/// Details:
153/// - Uses `statvfs` (unprivileged block counts) on Unix; no-op elsewhere.
154#[cfg(unix)]
155fn available_mib(path: &Path) -> Option<u64> {
156    let stat = nix::sys::statvfs::statvfs(path).ok()?;
157    // fsblkcnt_t/c_ulong are u32 on some 32-bit targets, so the conversion is not
158    // always redundant even though it is on 64-bit Linux.
159    #[allow(clippy::useless_conversion)]
160    let bytes = u64::from(stat.blocks_available()) * u64::from(stat.fragment_size());
161    Some(bytes / (1024 * 1024))
162}
163
164/// What: Report the free space in MiB on the filesystem containing `path`.
165///
166/// Inputs:
167/// - `path`: Directory to inspect (unused).
168///
169/// Output:
170/// - Always `None`; disk-space checks are unsupported on this platform.
171///
172/// Details:
173/// - Non-Unix fallback keeping callers platform-agnostic.
174#[cfg(not(unix))]
175fn available_mib(_path: &Path) -> Option<u64> {
176    None
177}
178
179/// What: Check whether the pacman cache filesystem has enough free space.
180///
181/// Inputs:
182/// - `ctx`: Guardrail context providing cache dir and minimum threshold.
183/// - `op`: Transaction kind; removals skip the check (they free space).
184///
185/// Output:
186/// - `Some(GuardrailIssue::LowDiskSpace)` when free space is below the minimum.
187///
188/// Details:
189/// - Falls back to the filesystem root when the cache directory is missing.
190#[must_use]
191pub fn check_disk_space(ctx: &GuardrailContext, op: GuardrailOperation) -> Option<GuardrailIssue> {
192    if op == GuardrailOperation::Remove {
193        return None;
194    }
195    let path: &Path = if ctx.pacman_cache_dir.exists() {
196        &ctx.pacman_cache_dir
197    } else {
198        Path::new("/")
199    };
200    let available = available_mib(path)?;
201    if available >= ctx.min_free_mib {
202        return None;
203    }
204    Some(GuardrailIssue::LowDiskSpace {
205        path: path.to_path_buf(),
206        available_mib: available,
207        min_free_mib: ctx.min_free_mib,
208    })
209}
210
211/// What: Check whether the pacman sync databases are stale.
212///
213/// Inputs:
214/// - `ctx`: Guardrail context providing the sync directory and staleness window.
215/// - `now`: Reference time (injectable for tests).
216///
217/// Output:
218/// - `Some(GuardrailIssue::SyncDbStale)` when the newest `*.db` is older than the window.
219///
220/// Details:
221/// - Stale databases correlate with mirror/database drift ("partial upgrade" errors,
222///   404s during download); the guidance is to refresh before mutating.
223/// - Returns `None` when the directory or usable mtimes are unavailable.
224#[must_use]
225pub fn check_sync_db_freshness(ctx: &GuardrailContext, now: SystemTime) -> Option<GuardrailIssue> {
226    let entries = std::fs::read_dir(&ctx.sync_db_dir).ok()?;
227    let newest = entries
228        .flatten()
229        .filter(|e| {
230            e.path()
231                .extension()
232                .is_some_and(|ext| ext.eq_ignore_ascii_case("db"))
233        })
234        .filter_map(|e| e.metadata().ok()?.modified().ok())
235        .max()?;
236    let age = now.duration_since(newest).ok()?;
237    let age_days = age.as_secs() / 86_400;
238    if age_days < ctx.stale_sync_days {
239        return None;
240    }
241    Some(GuardrailIssue::SyncDbStale { age_days })
242}
243
244/// What: Run all guardrail checks for a transaction.
245///
246/// Inputs:
247/// - `ctx`: Guardrail context (paths and thresholds).
248/// - `op`: Transaction kind about to run.
249///
250/// Output:
251/// - All findings, most severe first (db lock, disk space, staleness).
252///
253/// Details:
254/// - Read-only; safe to call in dry-run mode and from both CLI and TUI paths.
255#[must_use]
256pub fn run_guardrails(ctx: &GuardrailContext, op: GuardrailOperation) -> Vec<GuardrailIssue> {
257    let mut issues = Vec::new();
258    if let Some(issue) = check_db_lock(ctx) {
259        issues.push(issue);
260    }
261    if let Some(issue) = check_disk_space(ctx, op) {
262        issues.push(issue);
263    }
264    if op != GuardrailOperation::Remove
265        && let Some(issue) = check_sync_db_freshness(ctx, SystemTime::now())
266    {
267        issues.push(issue);
268    }
269    issues
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use std::time::{Duration, UNIX_EPOCH};
276
277    /// What: Build a unique temp directory for guardrail tests.
278    ///
279    /// Inputs:
280    /// - `label`: Short label distinguishing the calling test.
281    ///
282    /// Output:
283    /// - Created directory path under the system temp dir.
284    ///
285    /// Details:
286    /// - Combines process id and a nanosecond timestamp for uniqueness.
287    fn temp_dir(label: &str) -> PathBuf {
288        let dir = std::env::temp_dir().join(format!(
289            "pacsea_guardrails_{label}_{}_{}",
290            std::process::id(),
291            SystemTime::now()
292                .duration_since(UNIX_EPOCH)
293                .expect("System time is before UNIX epoch")
294                .as_nanos()
295        ));
296        std::fs::create_dir_all(&dir).expect("create temp dir");
297        dir
298    }
299
300    /// What: Build a context rooted in a temp directory with no lock and fresh dbs.
301    ///
302    /// Inputs:
303    /// - `root`: Temp directory to root all paths under.
304    ///
305    /// Output:
306    /// - Context whose paths point below `root`.
307    ///
308    /// Details:
309    /// - `proc_dir` points at an empty directory so no package manager is detected.
310    fn context_under(root: &Path) -> GuardrailContext {
311        let proc_dir = root.join("proc");
312        let sync_dir = root.join("sync");
313        let cache_dir = root.join("cache");
314        std::fs::create_dir_all(&proc_dir).expect("create proc dir");
315        std::fs::create_dir_all(&sync_dir).expect("create sync dir");
316        std::fs::create_dir_all(&cache_dir).expect("create cache dir");
317        GuardrailContext {
318            db_lock_path: root.join("db.lck"),
319            pacman_cache_dir: cache_dir,
320            sync_db_dir: sync_dir,
321            proc_dir,
322            min_free_mib: 0,
323            stale_sync_days: 14,
324        }
325    }
326
327    #[test]
328    /// What: Verify the db-lock check reports the lock file and stale-vs-running state.
329    ///
330    /// Inputs:
331    /// - Context without a lock, then with a lock file and a fake running pacman.
332    ///
333    /// Output:
334    /// - `None` without the lock; `DbLocked` with correct `pacman_running` afterwards.
335    ///
336    /// Details:
337    /// - Fakes `/proc/<pid>/comm` entries to drive the process detection.
338    fn db_lock_check_detects_lock_and_running_process() {
339        let root = temp_dir("dblock");
340        let ctx = context_under(&root);
341
342        assert!(check_db_lock(&ctx).is_none());
343
344        std::fs::write(&ctx.db_lock_path, "").expect("create lock");
345        match check_db_lock(&ctx) {
346            Some(GuardrailIssue::DbLocked { pacman_running, .. }) => {
347                assert!(!pacman_running, "no fake process created yet");
348            }
349            other => panic!("expected DbLocked, got {other:?}"),
350        }
351
352        let fake_pid = ctx.proc_dir.join("4242");
353        std::fs::create_dir_all(&fake_pid).expect("create fake pid dir");
354        std::fs::write(fake_pid.join("comm"), "pacman\n").expect("write comm");
355        match check_db_lock(&ctx) {
356            Some(GuardrailIssue::DbLocked { pacman_running, .. }) => {
357                assert!(pacman_running, "fake pacman process should be detected");
358            }
359            other => panic!("expected DbLocked, got {other:?}"),
360        }
361
362        let _ = std::fs::remove_dir_all(&root);
363    }
364
365    #[test]
366    /// What: Verify disk-space checks respect the operation kind and threshold.
367    ///
368    /// Inputs:
369    /// - Context with a huge threshold (always low) and a zero threshold (never low).
370    ///
371    /// Output:
372    /// - Install reports low space with a huge threshold; Remove never reports;
373    ///   zero threshold reports nothing.
374    ///
375    /// Details:
376    /// - Uses the real filesystem via statvfs on the temp directory.
377    fn disk_space_check_respects_threshold_and_operation() {
378        let root = temp_dir("disk");
379        let mut ctx = context_under(&root);
380
381        ctx.min_free_mib = u64::MAX;
382        assert!(matches!(
383            check_disk_space(&ctx, GuardrailOperation::Install),
384            Some(GuardrailIssue::LowDiskSpace { .. })
385        ));
386        assert!(check_disk_space(&ctx, GuardrailOperation::Remove).is_none());
387
388        ctx.min_free_mib = 0;
389        assert!(check_disk_space(&ctx, GuardrailOperation::Update).is_none());
390
391        let _ = std::fs::remove_dir_all(&root);
392    }
393
394    #[test]
395    /// What: Verify sync-db staleness uses the newest `*.db` mtime against the window.
396    ///
397    /// Inputs:
398    /// - A fresh `core.db`, compared against "now" and a time 20 days ahead.
399    ///
400    /// Output:
401    /// - Fresh comparison yields `None`; the 20-day-later comparison yields `SyncDbStale`.
402    ///
403    /// Details:
404    /// - Advances the reference time instead of back-dating the file to stay portable.
405    fn sync_db_freshness_reports_stale_databases() {
406        let root = temp_dir("sync");
407        let ctx = context_under(&root);
408
409        // No databases at all: cannot judge, no finding.
410        assert!(check_sync_db_freshness(&ctx, SystemTime::now()).is_none());
411
412        std::fs::write(ctx.sync_db_dir.join("core.db"), "x").expect("write db");
413        assert!(check_sync_db_freshness(&ctx, SystemTime::now()).is_none());
414
415        let later = SystemTime::now() + Duration::from_hours(20 * 24);
416        match check_sync_db_freshness(&ctx, later) {
417            Some(GuardrailIssue::SyncDbStale { age_days }) => {
418                assert!(
419                    age_days >= 19,
420                    "age should be about 20 days, got {age_days}"
421                );
422            }
423            other => panic!("expected SyncDbStale, got {other:?}"),
424        }
425
426        let _ = std::fs::remove_dir_all(&root);
427    }
428
429    #[test]
430    /// What: Verify `run_guardrails` aggregates findings in severity order.
431    ///
432    /// Inputs:
433    /// - Context with a lock file present and an impossible free-space threshold.
434    ///
435    /// Output:
436    /// - Db lock first, then low disk space.
437    ///
438    /// Details:
439    /// - Sync staleness is absent because the databases are fresh.
440    fn run_guardrails_aggregates_findings() {
441        let root = temp_dir("aggregate");
442        let mut ctx = context_under(&root);
443        std::fs::write(&ctx.db_lock_path, "").expect("create lock");
444        std::fs::write(ctx.sync_db_dir.join("core.db"), "x").expect("write db");
445        ctx.min_free_mib = u64::MAX;
446
447        let issues = run_guardrails(&ctx, GuardrailOperation::Install);
448        assert_eq!(issues.len(), 2);
449        assert!(matches!(issues[0], GuardrailIssue::DbLocked { .. }));
450        assert!(matches!(issues[1], GuardrailIssue::LowDiskSpace { .. }));
451
452        let _ = std::fs::remove_dir_all(&root);
453    }
454}