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/// Resolve the audit store for this process against the real environment and
125/// filesystem.
126pub fn resolve_audit_store() -> AuditStoreChoice {
127    let explicit = std::env::var_os("SYSKNIFE_DATABASE_PATH").map(PathBuf::from);
128    let per_user = default_database_path();
129    let system = PathBuf::from(PRODUCTION_DATABASE_PATH);
130    let per_user_exists = per_user.exists();
131    let system_exists = system.exists();
132    choose_audit_store(explicit, per_user, per_user_exists, system_exists)
133}
134
135/// Read the current process's real UID from `/proc/self/status`.
136///
137/// Avoids a libc dep for one syscall. The return value is only used to
138/// disambiguate the per-UID socket name in the last-resort branch of
139/// [`default_listen_uri`]; on read failure we use `0`, which still produces a
140/// valid path (just one shared by any caller in the same fallback case).
141fn current_uid() -> u32 {
142    std::fs::read_to_string("/proc/self/status")
143        .ok()
144        .and_then(|s| {
145            s.lines()
146                .find(|l| l.starts_with("Uid:"))
147                .and_then(|l| l.split_whitespace().nth(1))
148                .and_then(|v| v.parse::<u32>().ok())
149        })
150        .unwrap_or(0)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::{default_database_path, default_listen_uri, PRODUCTION_DATABASE_PATH};
156    use std::sync::Mutex;
157
158    static ENV_LOCK: Mutex<()> = Mutex::new(());
159
160    #[test]
161    fn production_database_path_is_absolute() {
162        assert!(PRODUCTION_DATABASE_PATH.starts_with("/var/lib/"));
163    }
164
165    #[test]
166    fn database_env_var_wins() {
167        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
168        unsafe {
169            std::env::set_var("SYSKNIFE_DATABASE_PATH", "/explicit/path/db.sqlite");
170        }
171        let p = default_database_path();
172        unsafe {
173            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
174        }
175        assert_eq!(p.to_str(), Some("/explicit/path/db.sqlite"));
176    }
177
178    #[test]
179    fn database_xdg_state_used_when_set() {
180        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
181        unsafe {
182            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
183            std::env::set_var("XDG_STATE_HOME", "/xdg/state");
184        }
185        let p = default_database_path();
186        unsafe {
187            std::env::remove_var("XDG_STATE_HOME");
188        }
189        assert_eq!(p.to_str(), Some("/xdg/state/sysknife/daemon.sqlite"));
190    }
191
192    #[test]
193    fn database_falls_back_to_home_local_state() {
194        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
195        unsafe {
196            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
197            std::env::remove_var("XDG_STATE_HOME");
198            std::env::set_var("HOME", "/home/contributor");
199        }
200        let p = default_database_path();
201        unsafe {
202            std::env::remove_var("HOME");
203        }
204        assert_eq!(
205            p.to_str(),
206            Some("/home/contributor/.local/state/sysknife/daemon.sqlite")
207        );
208    }
209
210    #[test]
211    fn database_last_resort_is_production_path() {
212        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
213        // Snapshot HOME so we can restore it (other tests in this binary need it).
214        let saved_home = std::env::var("HOME").ok();
215        unsafe {
216            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
217            std::env::remove_var("XDG_STATE_HOME");
218            std::env::remove_var("HOME");
219        }
220        let p = default_database_path();
221        unsafe {
222            if let Some(h) = saved_home {
223                std::env::set_var("HOME", h);
224            }
225        }
226        assert_eq!(p.to_str(), Some(PRODUCTION_DATABASE_PATH));
227    }
228
229    #[test]
230    fn listen_env_var_wins() {
231        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
232        unsafe {
233            std::env::set_var("SYSKNIFE_LISTEN_URI", "unix:///explicit.sock");
234        }
235        let u = default_listen_uri();
236        unsafe {
237            std::env::remove_var("SYSKNIFE_LISTEN_URI");
238        }
239        assert_eq!(u, "unix:///explicit.sock");
240    }
241
242    #[test]
243    fn listen_uses_xdg_runtime_dir_when_set() {
244        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
245        unsafe {
246            std::env::remove_var("SYSKNIFE_LISTEN_URI");
247            std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1000");
248        }
249        let u = default_listen_uri();
250        unsafe {
251            std::env::remove_var("XDG_RUNTIME_DIR");
252        }
253        assert_eq!(u, "unix:///run/user/1000/sysknife/daemon.sock");
254    }
255
256    #[test]
257    fn listen_last_resort_is_per_uid_tmp() {
258        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
259        unsafe {
260            std::env::remove_var("SYSKNIFE_LISTEN_URI");
261            std::env::remove_var("XDG_RUNTIME_DIR");
262        }
263        let u = default_listen_uri();
264        assert!(u.starts_with("unix:///tmp/sysknife-"));
265        assert!(u.ends_with(".sock"));
266    }
267
268    // -----------------------------------------------------------------------
269    // Audit store selection
270    // -----------------------------------------------------------------------
271
272    use std::path::{Path, PathBuf};
273
274    #[test]
275    fn an_explicit_database_path_is_never_second_guessed() {
276        let choice = super::choose_audit_store(
277            Some(PathBuf::from("/srv/audit.sqlite")),
278            PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite"),
279            true,
280            true,
281        );
282        assert_eq!(choice.path(), Path::new("/srv/audit.sqlite"));
283        assert_eq!(
284            choice.note(),
285            None,
286            "an explicit choice needs no explanation"
287        );
288    }
289
290    #[test]
291    fn a_present_per_user_store_wins_over_the_system_one() {
292        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
293        let choice = super::choose_audit_store(None, per_user.clone(), true, true);
294        assert_eq!(choice.path(), per_user.as_path());
295    }
296
297    /// The system-install case: the daemon writes /var/lib, the operator's CLI
298    /// has no per-user store, and verification must not report an empty chain.
299    #[test]
300    fn the_system_store_is_used_when_no_per_user_store_exists() {
301        let choice = super::choose_audit_store(
302            None,
303            PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite"),
304            false,
305            true,
306        );
307        assert_eq!(choice.path(), Path::new(super::PRODUCTION_DATABASE_PATH));
308        let note = choice
309            .note()
310            .expect("reading another store must be announced");
311        assert!(
312            note.contains(super::PRODUCTION_DATABASE_PATH),
313            "note names the store: {note}"
314        );
315    }
316
317    #[test]
318    fn with_no_store_anywhere_the_per_user_path_is_reported() {
319        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
320        let choice = super::choose_audit_store(None, per_user.clone(), false, false);
321        assert_eq!(choice.path(), per_user.as_path());
322        assert_eq!(choice.note(), None);
323    }
324
325    /// Drift guard: the constant the CLI falls back to must be the path the
326    /// packaged unit actually gives the daemon. If someone moves the store in
327    /// packaging, this fails instead of `audit verify` silently reading nothing.
328    #[test]
329    fn the_system_store_constant_matches_the_packaged_unit() {
330        let unit = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
331            .join("../../packaging/sysknife-daemon.service");
332        let contents = std::fs::read_to_string(&unit)
333            .unwrap_or_else(|e| panic!("read {}: {e}", unit.display()));
334        let expected = format!("SYSKNIFE_DATABASE_PATH={}", super::PRODUCTION_DATABASE_PATH);
335        assert!(
336            contents.contains(&expected),
337            "packaged unit must set {expected}; got:\n{contents}"
338        );
339    }
340}