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
9pub use distro::{detect, detect_distro, parse_os_release, DistroFamily, DistroId};
10
11/// Production socket URI written by the systemd unit (`sysknife-daemon.service`).
12///
13/// This is **not** the dev/test fallback — see [`default_listen_uri`].
14pub const PRODUCTION_LISTEN_URI: &str = "unix:///run/sysknife/daemon.sock";
15
16/// Production SQLite path written by the systemd unit (`sysknife-daemon.service`).
17///
18/// This is **not** the dev/test fallback — see [`default_database_path`].
19pub const PRODUCTION_DATABASE_PATH: &str = "/var/lib/sysknife/daemon.sqlite";
20
21/// Resolve the daemon listen URI for the current process.
22///
23/// Order of precedence:
24/// 1. `$SYSKNIFE_LISTEN_URI` (set by systemd unit and `sysknife-setup`)
25/// 2. `$XDG_RUNTIME_DIR/sysknife/daemon.sock` (per-user, follows freedesktop.org spec)
26/// 3. `/tmp/sysknife-$UID.sock` as the absolute last resort
27///
28/// Production deployments set the env var; dev/test invocations get a private
29/// per-user socket without root or `/var/lib` access.
30pub fn default_listen_uri() -> String {
31    if let Ok(uri) = std::env::var("SYSKNIFE_LISTEN_URI") {
32        return uri;
33    }
34    if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
35        let p = PathBuf::from(runtime).join("sysknife/daemon.sock");
36        return format!("unix://{}", p.display());
37    }
38    let uid = current_uid();
39    format!("unix:///tmp/sysknife-{uid}.sock")
40}
41
42/// Resolve the daemon SQLite database path for the current process.
43///
44/// Order of precedence:
45/// 1. `$SYSKNIFE_DATABASE_PATH` (set by systemd unit and `sysknife-setup`)
46/// 2. `$XDG_STATE_HOME/sysknife/daemon.sqlite` (per-user, persistent)
47/// 3. `$HOME/.local/state/sysknife/daemon.sqlite` (XDG fallback)
48/// 4. [`PRODUCTION_DATABASE_PATH`] if `HOME` is unset (production case where
49///    systemd sets the env var anyway, so this branch is rarely hit)
50pub fn default_database_path() -> PathBuf {
51    if let Ok(path) = std::env::var("SYSKNIFE_DATABASE_PATH") {
52        return PathBuf::from(path);
53    }
54    if let Ok(state) = std::env::var("XDG_STATE_HOME") {
55        return PathBuf::from(state).join("sysknife/daemon.sqlite");
56    }
57    if let Ok(home) = std::env::var("HOME") {
58        return PathBuf::from(home).join(".local/state/sysknife/daemon.sqlite");
59    }
60    PathBuf::from(PRODUCTION_DATABASE_PATH)
61}
62
63// ---------------------------------------------------------------------------
64// Audit store selection
65// ---------------------------------------------------------------------------
66
67/// Which audit store a CLI-side reader (`sysknife audit verify`, MCP doctor)
68/// should open.
69///
70/// The daemon and the CLI resolve storage differently by design: the packaged
71/// unit sets `SYSKNIFE_DATABASE_PATH` in the *daemon's* environment only, so a
72/// CLI process started by an operator sees none of it and falls through to the
73/// per-user XDG path. On a system install that meant `audit verify` read an
74/// empty per-user store and reported the chain as missing while the real,
75/// populated chain sat in [`PRODUCTION_DATABASE_PATH`].
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum AuditStoreChoice {
78    /// `$SYSKNIFE_DATABASE_PATH` was set explicitly; honour it verbatim, even
79    /// if the file is absent. An operator naming a path is never overridden.
80    Explicit(PathBuf),
81    /// The per-user store: the CLI default, and correct for a user-mode daemon.
82    PerUser(PathBuf),
83    /// The system daemon's store, chosen because no per-user store exists.
84    System(PathBuf),
85}
86
87impl AuditStoreChoice {
88    /// The database path to open.
89    pub fn path(&self) -> &Path {
90        match self {
91            Self::Explicit(p) | Self::PerUser(p) | Self::System(p) => p,
92        }
93    }
94
95    /// One line explaining a non-obvious choice, or `None` when the default
96    /// needs no explanation. Reading a store the operator did not name should
97    /// never be silent.
98    pub fn note(&self) -> Option<String> {
99        match self {
100            Self::System(path) => Some(format!(
101                "no per-user audit store found; reading the system daemon's store at {}",
102                path.display()
103            )),
104            Self::Explicit(_) | Self::PerUser(_) => None,
105        }
106    }
107}
108
109/// Pure selection rule behind [`resolve_audit_store`], split out so the
110/// precedence is testable without touching the filesystem or the environment.
111pub fn choose_audit_store(
112    explicit: Option<PathBuf>,
113    per_user: PathBuf,
114    per_user_exists: bool,
115    system_exists: bool,
116) -> AuditStoreChoice {
117    if let Some(path) = explicit {
118        return AuditStoreChoice::Explicit(path);
119    }
120    if per_user_exists {
121        return AuditStoreChoice::PerUser(per_user);
122    }
123    if system_exists {
124        return AuditStoreChoice::System(PathBuf::from(PRODUCTION_DATABASE_PATH));
125    }
126    // Nothing exists anywhere: keep the per-user path so the diagnostic names
127    // the location a user-mode daemon would have created.
128    AuditStoreChoice::PerUser(per_user)
129}
130
131/// Resolve the audit store for this process against the real environment and
132/// filesystem.
133pub fn resolve_audit_store() -> AuditStoreChoice {
134    let explicit = std::env::var_os("SYSKNIFE_DATABASE_PATH").map(PathBuf::from);
135    let per_user = default_database_path();
136    let system = PathBuf::from(PRODUCTION_DATABASE_PATH);
137    let per_user_exists = per_user.exists();
138    let system_exists = system.exists();
139    choose_audit_store(explicit, per_user, per_user_exists, system_exists)
140}
141
142/// Read the current process's real UID from `/proc/self/status`.
143///
144/// Avoids a libc dep for one syscall. The return value is only used to
145/// disambiguate the per-UID socket name in the last-resort branch of
146/// [`default_listen_uri`]; on read failure we use `0`, which still produces a
147/// valid path (just one shared by any caller in the same fallback case).
148fn current_uid() -> u32 {
149    std::fs::read_to_string("/proc/self/status")
150        .ok()
151        .and_then(|s| {
152            s.lines()
153                .find(|l| l.starts_with("Uid:"))
154                .and_then(|l| l.split_whitespace().nth(1))
155                .and_then(|v| v.parse::<u32>().ok())
156        })
157        .unwrap_or(0)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::{
163        default_database_path, default_listen_uri, PRODUCTION_DATABASE_PATH, PRODUCTION_LISTEN_URI,
164    };
165    use std::sync::Mutex;
166
167    static ENV_LOCK: Mutex<()> = Mutex::new(());
168
169    #[test]
170    fn production_constants_are_absolute() {
171        assert!(PRODUCTION_LISTEN_URI.starts_with("unix:///run/"));
172        assert!(PRODUCTION_DATABASE_PATH.starts_with("/var/lib/"));
173    }
174
175    #[test]
176    fn database_env_var_wins() {
177        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
178        unsafe {
179            std::env::set_var("SYSKNIFE_DATABASE_PATH", "/explicit/path/db.sqlite");
180        }
181        let p = default_database_path();
182        unsafe {
183            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
184        }
185        assert_eq!(p.to_str(), Some("/explicit/path/db.sqlite"));
186    }
187
188    #[test]
189    fn database_xdg_state_used_when_set() {
190        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
191        unsafe {
192            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
193            std::env::set_var("XDG_STATE_HOME", "/xdg/state");
194        }
195        let p = default_database_path();
196        unsafe {
197            std::env::remove_var("XDG_STATE_HOME");
198        }
199        assert_eq!(p.to_str(), Some("/xdg/state/sysknife/daemon.sqlite"));
200    }
201
202    #[test]
203    fn database_falls_back_to_home_local_state() {
204        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
205        unsafe {
206            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
207            std::env::remove_var("XDG_STATE_HOME");
208            std::env::set_var("HOME", "/home/contributor");
209        }
210        let p = default_database_path();
211        unsafe {
212            std::env::remove_var("HOME");
213        }
214        assert_eq!(
215            p.to_str(),
216            Some("/home/contributor/.local/state/sysknife/daemon.sqlite")
217        );
218    }
219
220    #[test]
221    fn database_last_resort_is_production_path() {
222        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
223        // Snapshot HOME so we can restore it (other tests in this binary need it).
224        let saved_home = std::env::var("HOME").ok();
225        unsafe {
226            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
227            std::env::remove_var("XDG_STATE_HOME");
228            std::env::remove_var("HOME");
229        }
230        let p = default_database_path();
231        unsafe {
232            if let Some(h) = saved_home {
233                std::env::set_var("HOME", h);
234            }
235        }
236        assert_eq!(p.to_str(), Some(PRODUCTION_DATABASE_PATH));
237    }
238
239    #[test]
240    fn listen_env_var_wins() {
241        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
242        unsafe {
243            std::env::set_var("SYSKNIFE_LISTEN_URI", "unix:///explicit.sock");
244        }
245        let u = default_listen_uri();
246        unsafe {
247            std::env::remove_var("SYSKNIFE_LISTEN_URI");
248        }
249        assert_eq!(u, "unix:///explicit.sock");
250    }
251
252    #[test]
253    fn listen_uses_xdg_runtime_dir_when_set() {
254        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
255        unsafe {
256            std::env::remove_var("SYSKNIFE_LISTEN_URI");
257            std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1000");
258        }
259        let u = default_listen_uri();
260        unsafe {
261            std::env::remove_var("XDG_RUNTIME_DIR");
262        }
263        assert_eq!(u, "unix:///run/user/1000/sysknife/daemon.sock");
264    }
265
266    #[test]
267    fn listen_last_resort_is_per_uid_tmp() {
268        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
269        unsafe {
270            std::env::remove_var("SYSKNIFE_LISTEN_URI");
271            std::env::remove_var("XDG_RUNTIME_DIR");
272        }
273        let u = default_listen_uri();
274        assert!(u.starts_with("unix:///tmp/sysknife-"));
275        assert!(u.ends_with(".sock"));
276    }
277
278    // -----------------------------------------------------------------------
279    // Audit store selection
280    // -----------------------------------------------------------------------
281
282    use std::path::{Path, PathBuf};
283
284    #[test]
285    fn an_explicit_database_path_is_never_second_guessed() {
286        let choice = super::choose_audit_store(
287            Some(PathBuf::from("/srv/audit.sqlite")),
288            PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite"),
289            true,
290            true,
291        );
292        assert_eq!(choice.path(), Path::new("/srv/audit.sqlite"));
293        assert_eq!(
294            choice.note(),
295            None,
296            "an explicit choice needs no explanation"
297        );
298    }
299
300    #[test]
301    fn a_present_per_user_store_wins_over_the_system_one() {
302        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
303        let choice = super::choose_audit_store(None, per_user.clone(), true, true);
304        assert_eq!(choice.path(), per_user.as_path());
305    }
306
307    /// The system-install case: the daemon writes /var/lib, the operator's CLI
308    /// has no per-user store, and verification must not report an empty chain.
309    #[test]
310    fn the_system_store_is_used_when_no_per_user_store_exists() {
311        let choice = super::choose_audit_store(
312            None,
313            PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite"),
314            false,
315            true,
316        );
317        assert_eq!(choice.path(), Path::new(super::PRODUCTION_DATABASE_PATH));
318        let note = choice
319            .note()
320            .expect("reading another store must be announced");
321        assert!(
322            note.contains(super::PRODUCTION_DATABASE_PATH),
323            "note names the store: {note}"
324        );
325    }
326
327    #[test]
328    fn with_no_store_anywhere_the_per_user_path_is_reported() {
329        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
330        let choice = super::choose_audit_store(None, per_user.clone(), false, false);
331        assert_eq!(choice.path(), per_user.as_path());
332        assert_eq!(choice.note(), None);
333    }
334
335    /// Drift guard: the constant the CLI falls back to must be the path the
336    /// packaged unit actually gives the daemon. If someone moves the store in
337    /// packaging, this fails instead of `audit verify` silently reading nothing.
338    #[test]
339    fn the_system_store_constant_matches_the_packaged_unit() {
340        let unit = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
341            .join("../../packaging/sysknife-daemon.service");
342        let contents = std::fs::read_to_string(&unit)
343            .unwrap_or_else(|e| panic!("read {}: {e}", unit.display()));
344        let expected = format!("SYSKNIFE_DATABASE_PATH={}", super::PRODUCTION_DATABASE_PATH);
345        assert!(
346            contents.contains(&expected),
347            "packaged unit must set {expected}; got:\n{contents}"
348        );
349    }
350}