Skip to main content

sysknife_core/
lib.rs

1//! Shared defaults, low-level constants, and configuration loading for the SysKnife workspace.
2
3use std::path::{Path, PathBuf};
4
5pub mod action_family;
6pub mod config;
7pub mod distro;
8
9/// Production SQLite path written by the systemd unit (`sysknife-daemon.service`).
10///
11/// This is **not** the dev/test fallback — see [`default_database_path`].
12pub const PRODUCTION_DATABASE_PATH: &str = "/var/lib/sysknife/daemon.sqlite";
13
14/// Resolve the daemon listen URI for the current process.
15///
16/// Order of precedence:
17/// 1. `$SYSKNIFE_LISTEN_URI` (set by systemd unit and `sysknife-setup`)
18/// 2. `$XDG_RUNTIME_DIR/sysknife/daemon.sock` (per-user, follows freedesktop.org spec)
19/// 3. `/tmp/sysknife-$UID.sock` as the absolute last resort
20///
21/// Production deployments set the env var; dev/test invocations get a private
22/// per-user socket without root or `/var/lib` access.
23pub fn default_listen_uri() -> String {
24    if let Ok(uri) = std::env::var("SYSKNIFE_LISTEN_URI") {
25        return uri;
26    }
27    if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
28        let p = PathBuf::from(runtime).join("sysknife/daemon.sock");
29        return format!("unix://{}", p.display());
30    }
31    let uid = current_uid();
32    format!("unix:///tmp/sysknife-{uid}.sock")
33}
34
35/// Resolve the daemon SQLite database path for the current process.
36///
37/// Order of precedence:
38/// 1. `$SYSKNIFE_DATABASE_PATH` (set by systemd unit and `sysknife-setup`)
39/// 2. `$XDG_STATE_HOME/sysknife/daemon.sqlite` (per-user, persistent)
40/// 3. `$HOME/.local/state/sysknife/daemon.sqlite` (XDG fallback)
41/// 4. [`PRODUCTION_DATABASE_PATH`] if `HOME` is unset (production case where
42///    systemd sets the env var anyway, so this branch is rarely hit)
43pub fn default_database_path() -> PathBuf {
44    if let Ok(path) = std::env::var("SYSKNIFE_DATABASE_PATH") {
45        return PathBuf::from(path);
46    }
47    if let Ok(state) = std::env::var("XDG_STATE_HOME") {
48        return PathBuf::from(state).join("sysknife/daemon.sqlite");
49    }
50    if let Ok(home) = std::env::var("HOME") {
51        return PathBuf::from(home).join(".local/state/sysknife/daemon.sqlite");
52    }
53    PathBuf::from(PRODUCTION_DATABASE_PATH)
54}
55
56// ---------------------------------------------------------------------------
57// Audit store selection
58// ---------------------------------------------------------------------------
59
60/// Which audit store a CLI-side reader (`sysknife audit verify`, MCP doctor)
61/// should open.
62///
63/// The daemon and the CLI resolve storage differently by design: the packaged
64/// unit sets `SYSKNIFE_DATABASE_PATH` in the *daemon's* environment only, so a
65/// CLI process started by an operator sees none of it and falls through to the
66/// per-user XDG path. On a system install that meant `audit verify` read an
67/// empty per-user store and reported the chain as missing while the real,
68/// populated chain sat in [`PRODUCTION_DATABASE_PATH`].
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum AuditStoreChoice {
71    /// `$SYSKNIFE_DATABASE_PATH` was set explicitly; honour it verbatim, even
72    /// if the file is absent. An operator naming a path is never overridden.
73    Explicit(PathBuf),
74    /// The per-user store: the CLI default, and correct for a user-mode daemon.
75    PerUser(PathBuf),
76    /// The system daemon's store, chosen because no per-user store exists.
77    System(PathBuf),
78}
79
80impl AuditStoreChoice {
81    /// The database path to open.
82    pub fn path(&self) -> &Path {
83        match self {
84            Self::Explicit(p) | Self::PerUser(p) | Self::System(p) => p,
85        }
86    }
87
88    /// One line explaining a non-obvious choice, or `None` when the default
89    /// needs no explanation. Reading a store the operator did not name should
90    /// never be silent.
91    pub fn note(&self) -> Option<String> {
92        match self {
93            Self::System(path) => Some(format!(
94                "no per-user audit store found; reading the system daemon's store at {}",
95                path.display()
96            )),
97            Self::Explicit(_) | Self::PerUser(_) => None,
98        }
99    }
100}
101
102/// Pure selection rule behind [`resolve_audit_store`], split out so the
103/// precedence is testable without touching the filesystem or the environment.
104pub fn choose_audit_store(
105    explicit: Option<PathBuf>,
106    per_user: PathBuf,
107    per_user_exists: bool,
108    system_exists: bool,
109) -> AuditStoreChoice {
110    choose_audit_store_with_paths(
111        explicit,
112        per_user,
113        per_user_exists,
114        PathBuf::from(PRODUCTION_DATABASE_PATH),
115        system_exists,
116    )
117}
118
119/// Apply the audit-store precedence when the system store path is supplied by
120/// the caller. The production resolver uses [`PRODUCTION_DATABASE_PATH`]; the
121/// explicit path parameter also lets tests model that fallback without writing
122/// under `/var/lib`.
123pub fn choose_audit_store_with_paths(
124    explicit: Option<PathBuf>,
125    per_user: PathBuf,
126    per_user_exists: bool,
127    system_path: PathBuf,
128    system_exists: bool,
129) -> AuditStoreChoice {
130    if let Some(path) = explicit {
131        return AuditStoreChoice::Explicit(path);
132    }
133    if per_user_exists {
134        return AuditStoreChoice::PerUser(per_user);
135    }
136    if system_exists {
137        return AuditStoreChoice::System(system_path);
138    }
139    // Nothing exists anywhere: keep the per-user path so the diagnostic names
140    // the location a user-mode daemon would have created.
141    AuditStoreChoice::PerUser(per_user)
142}
143
144/// Whether an audit-store path should count as present for selection.
145///
146/// [`Path::exists`] is `metadata().is_ok()`, so a root-owned `0700` directory
147/// returns `false` for a non-root operator (`EACCES`) exactly as a missing path
148/// does (`ENOENT`). Treating permission denied as present keeps the `System`
149/// branch and the sudo hint, instead of sending the operator to an empty
150/// per-user path.
151pub fn path_is_present(path: &Path) -> bool {
152    path_is_present_result(std::fs::metadata(path).map(|_| ()))
153}
154
155/// Classify a filesystem probe result for [`path_is_present`].
156///
157/// Split out so the `PermissionDenied` -> present rule is unit-tested without
158/// needing a second Unix user.
159pub(crate) fn path_is_present_result(result: Result<(), std::io::Error>) -> bool {
160    match result {
161        Ok(()) => true,
162        Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => true,
163        Err(_) => false,
164    }
165}
166
167/// Resolve the audit store for this process against the real environment and
168/// filesystem.
169pub fn resolve_audit_store() -> AuditStoreChoice {
170    let explicit = std::env::var_os("SYSKNIFE_DATABASE_PATH").map(PathBuf::from);
171    let per_user = default_database_path();
172    let system = PathBuf::from(PRODUCTION_DATABASE_PATH);
173    resolve_audit_store_from(explicit, per_user, system)
174}
175
176/// Resolve the audit store with an explicit system path. The production entry
177/// point supplies [`PRODUCTION_DATABASE_PATH`]; tests can supply a temporary
178/// file to exercise the packaged-install fallback without writing under
179/// `/var/lib`.
180pub fn resolve_audit_store_from(
181    explicit: Option<PathBuf>,
182    per_user: PathBuf,
183    system: PathBuf,
184) -> AuditStoreChoice {
185    let per_user_exists = path_is_present(&per_user);
186    let system_exists = path_is_present(&system);
187    choose_audit_store_with_paths(explicit, per_user, per_user_exists, system, system_exists)
188}
189
190/// Read the current process's real UID from `/proc/self/status`.
191///
192/// Avoids a libc dep for one syscall. The return value is only used to
193/// disambiguate the per-UID socket name in the last-resort branch of
194/// [`default_listen_uri`]; on read failure we use `0`, which still produces a
195/// valid path (just one shared by any caller in the same fallback case).
196fn current_uid() -> u32 {
197    std::fs::read_to_string("/proc/self/status")
198        .ok()
199        .and_then(|s| {
200            s.lines()
201                .find(|l| l.starts_with("Uid:"))
202                .and_then(|l| l.split_whitespace().nth(1))
203                .and_then(|v| v.parse::<u32>().ok())
204        })
205        .unwrap_or(0)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::{default_database_path, default_listen_uri, PRODUCTION_DATABASE_PATH};
211    use std::sync::Mutex;
212
213    static ENV_LOCK: Mutex<()> = Mutex::new(());
214
215    #[test]
216    fn production_database_path_is_absolute() {
217        assert!(PRODUCTION_DATABASE_PATH.starts_with("/var/lib/"));
218    }
219
220    #[test]
221    fn database_env_var_wins() {
222        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
223        unsafe {
224            std::env::set_var("SYSKNIFE_DATABASE_PATH", "/explicit/path/db.sqlite");
225        }
226        let p = default_database_path();
227        unsafe {
228            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
229        }
230        assert_eq!(p.to_str(), Some("/explicit/path/db.sqlite"));
231    }
232
233    #[test]
234    fn database_xdg_state_used_when_set() {
235        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
236        unsafe {
237            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
238            std::env::set_var("XDG_STATE_HOME", "/xdg/state");
239        }
240        let p = default_database_path();
241        unsafe {
242            std::env::remove_var("XDG_STATE_HOME");
243        }
244        assert_eq!(p.to_str(), Some("/xdg/state/sysknife/daemon.sqlite"));
245    }
246
247    #[test]
248    fn database_falls_back_to_home_local_state() {
249        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
250        unsafe {
251            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
252            std::env::remove_var("XDG_STATE_HOME");
253            std::env::set_var("HOME", "/home/contributor");
254        }
255        let p = default_database_path();
256        unsafe {
257            std::env::remove_var("HOME");
258        }
259        assert_eq!(
260            p.to_str(),
261            Some("/home/contributor/.local/state/sysknife/daemon.sqlite")
262        );
263    }
264
265    #[test]
266    fn database_last_resort_is_production_path() {
267        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
268        // Snapshot HOME so we can restore it (other tests in this binary need it).
269        let saved_home = std::env::var("HOME").ok();
270        unsafe {
271            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
272            std::env::remove_var("XDG_STATE_HOME");
273            std::env::remove_var("HOME");
274        }
275        let p = default_database_path();
276        unsafe {
277            if let Some(h) = saved_home {
278                std::env::set_var("HOME", h);
279            }
280        }
281        assert_eq!(p.to_str(), Some(PRODUCTION_DATABASE_PATH));
282    }
283
284    #[test]
285    fn listen_env_var_wins() {
286        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
287        unsafe {
288            std::env::set_var("SYSKNIFE_LISTEN_URI", "unix:///explicit.sock");
289        }
290        let u = default_listen_uri();
291        unsafe {
292            std::env::remove_var("SYSKNIFE_LISTEN_URI");
293        }
294        assert_eq!(u, "unix:///explicit.sock");
295    }
296
297    #[test]
298    fn listen_uses_xdg_runtime_dir_when_set() {
299        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
300        unsafe {
301            std::env::remove_var("SYSKNIFE_LISTEN_URI");
302            std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1000");
303        }
304        let u = default_listen_uri();
305        unsafe {
306            std::env::remove_var("XDG_RUNTIME_DIR");
307        }
308        assert_eq!(u, "unix:///run/user/1000/sysknife/daemon.sock");
309    }
310
311    #[test]
312    fn listen_last_resort_is_per_uid_tmp() {
313        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
314        unsafe {
315            std::env::remove_var("SYSKNIFE_LISTEN_URI");
316            std::env::remove_var("XDG_RUNTIME_DIR");
317        }
318        let u = default_listen_uri();
319        assert!(u.starts_with("unix:///tmp/sysknife-"));
320        assert!(u.ends_with(".sock"));
321    }
322
323    // -----------------------------------------------------------------------
324    // Audit store selection
325    // -----------------------------------------------------------------------
326
327    use std::path::{Path, PathBuf};
328
329    #[test]
330    fn an_explicit_database_path_is_never_second_guessed() {
331        let choice = super::choose_audit_store(
332            Some(PathBuf::from("/srv/audit.sqlite")),
333            PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite"),
334            true,
335            true,
336        );
337        assert_eq!(choice.path(), Path::new("/srv/audit.sqlite"));
338        assert_eq!(
339            choice.note(),
340            None,
341            "an explicit choice needs no explanation"
342        );
343    }
344
345    #[test]
346    fn a_present_per_user_store_wins_over_the_system_one() {
347        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
348        let choice = super::choose_audit_store(None, per_user.clone(), true, true);
349        assert_eq!(choice.path(), per_user.as_path());
350    }
351
352    /// The system-install case: the daemon writes /var/lib, the operator's CLI
353    /// has no per-user store, and verification must not report an empty chain.
354    #[test]
355    fn the_system_store_is_used_when_no_per_user_store_exists() {
356        let choice = super::choose_audit_store(
357            None,
358            PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite"),
359            false,
360            true,
361        );
362        assert_eq!(choice.path(), Path::new(super::PRODUCTION_DATABASE_PATH));
363        let note = choice
364            .note()
365            .expect("reading another store must be announced");
366        assert!(
367            note.contains(super::PRODUCTION_DATABASE_PATH),
368            "note names the store: {note}"
369        );
370    }
371
372    #[test]
373    fn a_supplied_system_store_path_is_used_when_the_per_user_store_is_absent() {
374        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
375        let system = PathBuf::from("/tmp/system/daemon.sqlite");
376        let choice =
377            super::choose_audit_store_with_paths(None, per_user, false, system.clone(), true);
378        assert_eq!(choice, super::AuditStoreChoice::System(system));
379    }
380
381    #[test]
382    fn with_no_store_anywhere_the_per_user_path_is_reported() {
383        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
384        let choice = super::choose_audit_store(None, per_user.clone(), false, false);
385        assert_eq!(choice.path(), per_user.as_path());
386        assert_eq!(choice.note(), None);
387    }
388
389    #[test]
390    fn permission_denied_counts_as_a_present_store() {
391        assert!(
392            super::path_is_present_result(Ok(())),
393            "a successful stat is present"
394        );
395        assert!(
396            super::path_is_present_result(Err(std::io::Error::from(
397                std::io::ErrorKind::PermissionDenied
398            ))),
399            "EACCES must not be treated as missing"
400        );
401        assert!(
402            !super::path_is_present_result(Err(std::io::Error::from(std::io::ErrorKind::NotFound))),
403            "ENOENT remains missing"
404        );
405    }
406
407    /// A 0700 system store that the operator cannot stat must still select the
408    /// system path so `audit verify` prints the sudo / `--pubkey` hint instead
409    /// of sending them to an empty per-user store.
410    #[test]
411    fn unreadable_system_store_wins_over_a_missing_per_user_store() {
412        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
413        let system_exists = super::path_is_present_result(Err(std::io::Error::from(
414            std::io::ErrorKind::PermissionDenied,
415        )));
416        let choice = super::choose_audit_store(None, per_user, false, system_exists);
417        assert_eq!(
418            choice.path(),
419            Path::new(super::PRODUCTION_DATABASE_PATH),
420            "EACCES on the system store must not fall through to PerUser"
421        );
422        assert!(
423            choice.note().is_some(),
424            "System choice must still announce itself"
425        );
426    }
427
428    /// Filesystem form of the EACCES probe: a database behind a mode-000
429    /// directory is unreadable even to its owner, and must count as present.
430    #[cfg(unix)]
431    #[test]
432    fn path_behind_mode_000_directory_counts_as_present() {
433        use std::os::unix::fs::PermissionsExt;
434
435        let root = tempfile::tempdir().expect("tempdir");
436        let locked = root.path().join("locked");
437        std::fs::create_dir(&locked).expect("mkdir");
438        let db = locked.join("daemon.sqlite");
439        std::fs::write(&db, b"x").expect("write db");
440        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
441            .expect("chmod 000");
442
443        let present = super::path_is_present(&db);
444        let exists_under_eacces = db.exists();
445
446        // Restore perms so TempDir cleanup can unlink the tree.
447        let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700));
448        assert!(
449            present,
450            "metadata EACCES on a 0700/000 store must count as present, not missing"
451        );
452        assert!(
453            !exists_under_eacces,
454            "precondition: Path::exists is false under EACCES (the bug we are fixing)"
455        );
456    }
457
458    /// Drift guard: the constant the CLI falls back to must be the path the
459    /// packaged unit actually gives the daemon. If someone moves the store in
460    /// packaging, this fails instead of `audit verify` silently reading nothing.
461    #[test]
462    fn the_system_store_constant_matches_the_packaged_unit() {
463        let unit = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
464            .join("../../packaging/sysknife-daemon.service");
465        let contents = std::fs::read_to_string(&unit)
466            .unwrap_or_else(|e| panic!("read {}: {e}", unit.display()));
467        let expected = format!("SYSKNIFE_DATABASE_PATH={}", super::PRODUCTION_DATABASE_PATH);
468        assert!(
469            contents.contains(&expected),
470            "packaged unit must set {expected}; got:\n{contents}"
471        );
472    }
473}