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    if let Some(path) = explicit {
111        return AuditStoreChoice::Explicit(path);
112    }
113    if per_user_exists {
114        return AuditStoreChoice::PerUser(per_user);
115    }
116    if system_exists {
117        return AuditStoreChoice::System(PathBuf::from(PRODUCTION_DATABASE_PATH));
118    }
119    // Nothing exists anywhere: keep the per-user path so the diagnostic names
120    // the location a user-mode daemon would have created.
121    AuditStoreChoice::PerUser(per_user)
122}
123
124/// Whether an audit-store path should count as present for selection.
125///
126/// [`Path::exists`] is `metadata().is_ok()`, so a root-owned `0700` directory
127/// returns `false` for a non-root operator (`EACCES`) exactly as a missing path
128/// does (`ENOENT`). Treating permission denied as present keeps the `System`
129/// branch and the sudo hint, instead of sending the operator to an empty
130/// per-user path.
131pub fn path_is_present(path: &Path) -> bool {
132    path_is_present_result(std::fs::metadata(path).map(|_| ()))
133}
134
135/// Classify a filesystem probe result for [`path_is_present`].
136///
137/// Split out so the `PermissionDenied` -> present rule is unit-tested without
138/// needing a second Unix user.
139pub(crate) fn path_is_present_result(result: Result<(), std::io::Error>) -> bool {
140    match result {
141        Ok(()) => true,
142        Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => true,
143        Err(_) => false,
144    }
145}
146
147/// Resolve the audit store for this process against the real environment and
148/// filesystem.
149pub fn resolve_audit_store() -> AuditStoreChoice {
150    let explicit = std::env::var_os("SYSKNIFE_DATABASE_PATH").map(PathBuf::from);
151    let per_user = default_database_path();
152    let system = PathBuf::from(PRODUCTION_DATABASE_PATH);
153    let per_user_exists = path_is_present(&per_user);
154    let system_exists = path_is_present(&system);
155    choose_audit_store(explicit, per_user, per_user_exists, system_exists)
156}
157
158/// Read the current process's real UID from `/proc/self/status`.
159///
160/// Avoids a libc dep for one syscall. The return value is only used to
161/// disambiguate the per-UID socket name in the last-resort branch of
162/// [`default_listen_uri`]; on read failure we use `0`, which still produces a
163/// valid path (just one shared by any caller in the same fallback case).
164fn current_uid() -> u32 {
165    std::fs::read_to_string("/proc/self/status")
166        .ok()
167        .and_then(|s| {
168            s.lines()
169                .find(|l| l.starts_with("Uid:"))
170                .and_then(|l| l.split_whitespace().nth(1))
171                .and_then(|v| v.parse::<u32>().ok())
172        })
173        .unwrap_or(0)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::{default_database_path, default_listen_uri, PRODUCTION_DATABASE_PATH};
179    use std::sync::Mutex;
180
181    static ENV_LOCK: Mutex<()> = Mutex::new(());
182
183    #[test]
184    fn production_database_path_is_absolute() {
185        assert!(PRODUCTION_DATABASE_PATH.starts_with("/var/lib/"));
186    }
187
188    #[test]
189    fn database_env_var_wins() {
190        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
191        unsafe {
192            std::env::set_var("SYSKNIFE_DATABASE_PATH", "/explicit/path/db.sqlite");
193        }
194        let p = default_database_path();
195        unsafe {
196            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
197        }
198        assert_eq!(p.to_str(), Some("/explicit/path/db.sqlite"));
199    }
200
201    #[test]
202    fn database_xdg_state_used_when_set() {
203        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
204        unsafe {
205            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
206            std::env::set_var("XDG_STATE_HOME", "/xdg/state");
207        }
208        let p = default_database_path();
209        unsafe {
210            std::env::remove_var("XDG_STATE_HOME");
211        }
212        assert_eq!(p.to_str(), Some("/xdg/state/sysknife/daemon.sqlite"));
213    }
214
215    #[test]
216    fn database_falls_back_to_home_local_state() {
217        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
218        unsafe {
219            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
220            std::env::remove_var("XDG_STATE_HOME");
221            std::env::set_var("HOME", "/home/contributor");
222        }
223        let p = default_database_path();
224        unsafe {
225            std::env::remove_var("HOME");
226        }
227        assert_eq!(
228            p.to_str(),
229            Some("/home/contributor/.local/state/sysknife/daemon.sqlite")
230        );
231    }
232
233    #[test]
234    fn database_last_resort_is_production_path() {
235        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
236        // Snapshot HOME so we can restore it (other tests in this binary need it).
237        let saved_home = std::env::var("HOME").ok();
238        unsafe {
239            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
240            std::env::remove_var("XDG_STATE_HOME");
241            std::env::remove_var("HOME");
242        }
243        let p = default_database_path();
244        unsafe {
245            if let Some(h) = saved_home {
246                std::env::set_var("HOME", h);
247            }
248        }
249        assert_eq!(p.to_str(), Some(PRODUCTION_DATABASE_PATH));
250    }
251
252    #[test]
253    fn listen_env_var_wins() {
254        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
255        unsafe {
256            std::env::set_var("SYSKNIFE_LISTEN_URI", "unix:///explicit.sock");
257        }
258        let u = default_listen_uri();
259        unsafe {
260            std::env::remove_var("SYSKNIFE_LISTEN_URI");
261        }
262        assert_eq!(u, "unix:///explicit.sock");
263    }
264
265    #[test]
266    fn listen_uses_xdg_runtime_dir_when_set() {
267        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
268        unsafe {
269            std::env::remove_var("SYSKNIFE_LISTEN_URI");
270            std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1000");
271        }
272        let u = default_listen_uri();
273        unsafe {
274            std::env::remove_var("XDG_RUNTIME_DIR");
275        }
276        assert_eq!(u, "unix:///run/user/1000/sysknife/daemon.sock");
277    }
278
279    #[test]
280    fn listen_last_resort_is_per_uid_tmp() {
281        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
282        unsafe {
283            std::env::remove_var("SYSKNIFE_LISTEN_URI");
284            std::env::remove_var("XDG_RUNTIME_DIR");
285        }
286        let u = default_listen_uri();
287        assert!(u.starts_with("unix:///tmp/sysknife-"));
288        assert!(u.ends_with(".sock"));
289    }
290
291    // -----------------------------------------------------------------------
292    // Audit store selection
293    // -----------------------------------------------------------------------
294
295    use std::path::{Path, PathBuf};
296
297    #[test]
298    fn an_explicit_database_path_is_never_second_guessed() {
299        let choice = super::choose_audit_store(
300            Some(PathBuf::from("/srv/audit.sqlite")),
301            PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite"),
302            true,
303            true,
304        );
305        assert_eq!(choice.path(), Path::new("/srv/audit.sqlite"));
306        assert_eq!(
307            choice.note(),
308            None,
309            "an explicit choice needs no explanation"
310        );
311    }
312
313    #[test]
314    fn a_present_per_user_store_wins_over_the_system_one() {
315        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
316        let choice = super::choose_audit_store(None, per_user.clone(), true, true);
317        assert_eq!(choice.path(), per_user.as_path());
318    }
319
320    /// The system-install case: the daemon writes /var/lib, the operator's CLI
321    /// has no per-user store, and verification must not report an empty chain.
322    #[test]
323    fn the_system_store_is_used_when_no_per_user_store_exists() {
324        let choice = super::choose_audit_store(
325            None,
326            PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite"),
327            false,
328            true,
329        );
330        assert_eq!(choice.path(), Path::new(super::PRODUCTION_DATABASE_PATH));
331        let note = choice
332            .note()
333            .expect("reading another store must be announced");
334        assert!(
335            note.contains(super::PRODUCTION_DATABASE_PATH),
336            "note names the store: {note}"
337        );
338    }
339
340    #[test]
341    fn with_no_store_anywhere_the_per_user_path_is_reported() {
342        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
343        let choice = super::choose_audit_store(None, per_user.clone(), false, false);
344        assert_eq!(choice.path(), per_user.as_path());
345        assert_eq!(choice.note(), None);
346    }
347
348    #[test]
349    fn permission_denied_counts_as_a_present_store() {
350        assert!(
351            super::path_is_present_result(Ok(())),
352            "a successful stat is present"
353        );
354        assert!(
355            super::path_is_present_result(Err(std::io::Error::from(
356                std::io::ErrorKind::PermissionDenied
357            ))),
358            "EACCES must not be treated as missing"
359        );
360        assert!(
361            !super::path_is_present_result(Err(std::io::Error::from(std::io::ErrorKind::NotFound))),
362            "ENOENT remains missing"
363        );
364    }
365
366    /// A 0700 system store that the operator cannot stat must still select the
367    /// system path so `audit verify` prints the sudo / `--pubkey` hint instead
368    /// of sending them to an empty per-user store.
369    #[test]
370    fn unreadable_system_store_wins_over_a_missing_per_user_store() {
371        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
372        let system_exists = super::path_is_present_result(Err(std::io::Error::from(
373            std::io::ErrorKind::PermissionDenied,
374        )));
375        let choice = super::choose_audit_store(None, per_user, false, system_exists);
376        assert_eq!(
377            choice.path(),
378            Path::new(super::PRODUCTION_DATABASE_PATH),
379            "EACCES on the system store must not fall through to PerUser"
380        );
381        assert!(
382            choice.note().is_some(),
383            "System choice must still announce itself"
384        );
385    }
386
387    /// Filesystem form of the EACCES probe: a database behind a mode-000
388    /// directory is unreadable even to its owner, and must count as present.
389    #[cfg(unix)]
390    #[test]
391    fn path_behind_mode_000_directory_counts_as_present() {
392        use std::os::unix::fs::PermissionsExt;
393
394        let root = tempfile::tempdir().expect("tempdir");
395        let locked = root.path().join("locked");
396        std::fs::create_dir(&locked).expect("mkdir");
397        let db = locked.join("daemon.sqlite");
398        std::fs::write(&db, b"x").expect("write db");
399        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
400            .expect("chmod 000");
401
402        let present = super::path_is_present(&db);
403        let exists_under_eacces = db.exists();
404
405        // Restore perms so TempDir cleanup can unlink the tree.
406        let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700));
407        assert!(
408            present,
409            "metadata EACCES on a 0700/000 store must count as present, not missing"
410        );
411        assert!(
412            !exists_under_eacces,
413            "precondition: Path::exists is false under EACCES (the bug we are fixing)"
414        );
415    }
416
417    /// Drift guard: the constant the CLI falls back to must be the path the
418    /// packaged unit actually gives the daemon. If someone moves the store in
419    /// packaging, this fails instead of `audit verify` silently reading nothing.
420    #[test]
421    fn the_system_store_constant_matches_the_packaged_unit() {
422        let unit = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
423            .join("../../packaging/sysknife-daemon.service");
424        let contents = std::fs::read_to_string(&unit)
425            .unwrap_or_else(|e| panic!("read {}: {e}", unit.display()));
426        let expected = format!("SYSKNIFE_DATABASE_PATH={}", super::PRODUCTION_DATABASE_PATH);
427        assert!(
428            contents.contains(&expected),
429            "packaged unit must set {expected}; got:\n{contents}"
430        );
431    }
432}