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}