sysknife-core 0.13.1

Configuration and domain primitives for SysKnife
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
//! Shared defaults, low-level constants, and configuration loading for the SysKnife workspace.

use std::path::{Path, PathBuf};

pub mod action_family;
pub mod config;
pub mod distro;

/// Production SQLite path written by the systemd unit (`sysknife-daemon.service`).
///
/// This is **not** the dev/test fallback — see [`default_database_path`].
pub const PRODUCTION_DATABASE_PATH: &str = "/var/lib/sysknife/daemon.sqlite";

/// Resolve the daemon listen URI for the current process.
///
/// Order of precedence:
/// 1. `$SYSKNIFE_LISTEN_URI` (set by systemd unit and `sysknife-setup`)
/// 2. `$XDG_RUNTIME_DIR/sysknife/daemon.sock` (per-user, follows freedesktop.org spec)
/// 3. `/tmp/sysknife-$UID.sock` as the absolute last resort
///
/// Production deployments set the env var; dev/test invocations get a private
/// per-user socket without root or `/var/lib` access.
pub fn default_listen_uri() -> String {
    if let Ok(uri) = std::env::var("SYSKNIFE_LISTEN_URI") {
        return uri;
    }
    if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
        let p = PathBuf::from(runtime).join("sysknife/daemon.sock");
        return format!("unix://{}", p.display());
    }
    let uid = current_uid();
    format!("unix:///tmp/sysknife-{uid}.sock")
}

/// Resolve the daemon SQLite database path for the current process.
///
/// Order of precedence:
/// 1. `$SYSKNIFE_DATABASE_PATH` (set by systemd unit and `sysknife-setup`)
/// 2. `$XDG_STATE_HOME/sysknife/daemon.sqlite` (per-user, persistent)
/// 3. `$HOME/.local/state/sysknife/daemon.sqlite` (XDG fallback)
/// 4. [`PRODUCTION_DATABASE_PATH`] if `HOME` is unset (production case where
///    systemd sets the env var anyway, so this branch is rarely hit)
pub fn default_database_path() -> PathBuf {
    if let Ok(path) = std::env::var("SYSKNIFE_DATABASE_PATH") {
        return PathBuf::from(path);
    }
    if let Ok(state) = std::env::var("XDG_STATE_HOME") {
        return PathBuf::from(state).join("sysknife/daemon.sqlite");
    }
    if let Ok(home) = std::env::var("HOME") {
        return PathBuf::from(home).join(".local/state/sysknife/daemon.sqlite");
    }
    PathBuf::from(PRODUCTION_DATABASE_PATH)
}

// ---------------------------------------------------------------------------
// Audit store selection
// ---------------------------------------------------------------------------

/// Which audit store a CLI-side reader (`sysknife audit verify`, MCP doctor)
/// should open.
///
/// The daemon and the CLI resolve storage differently by design: the packaged
/// unit sets `SYSKNIFE_DATABASE_PATH` in the *daemon's* environment only, so a
/// CLI process started by an operator sees none of it and falls through to the
/// per-user XDG path. On a system install that meant `audit verify` read an
/// empty per-user store and reported the chain as missing while the real,
/// populated chain sat in [`PRODUCTION_DATABASE_PATH`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuditStoreChoice {
    /// `$SYSKNIFE_DATABASE_PATH` was set explicitly; honour it verbatim, even
    /// if the file is absent. An operator naming a path is never overridden.
    Explicit(PathBuf),
    /// The per-user store: the CLI default, and correct for a user-mode daemon.
    PerUser(PathBuf),
    /// The system daemon's store, chosen because no per-user store exists.
    System(PathBuf),
}

impl AuditStoreChoice {
    /// The database path to open.
    pub fn path(&self) -> &Path {
        match self {
            Self::Explicit(p) | Self::PerUser(p) | Self::System(p) => p,
        }
    }

    /// One line explaining a non-obvious choice, or `None` when the default
    /// needs no explanation. Reading a store the operator did not name should
    /// never be silent.
    pub fn note(&self) -> Option<String> {
        match self {
            Self::System(path) => Some(format!(
                "no per-user audit store found; reading the system daemon's store at {}",
                path.display()
            )),
            Self::Explicit(_) | Self::PerUser(_) => None,
        }
    }
}

/// Pure selection rule behind [`resolve_audit_store`], split out so the
/// precedence is testable without touching the filesystem or the environment.
pub fn choose_audit_store(
    explicit: Option<PathBuf>,
    per_user: PathBuf,
    per_user_exists: bool,
    system_exists: bool,
) -> AuditStoreChoice {
    if let Some(path) = explicit {
        return AuditStoreChoice::Explicit(path);
    }
    if per_user_exists {
        return AuditStoreChoice::PerUser(per_user);
    }
    if system_exists {
        return AuditStoreChoice::System(PathBuf::from(PRODUCTION_DATABASE_PATH));
    }
    // Nothing exists anywhere: keep the per-user path so the diagnostic names
    // the location a user-mode daemon would have created.
    AuditStoreChoice::PerUser(per_user)
}

/// Whether an audit-store path should count as present for selection.
///
/// [`Path::exists`] is `metadata().is_ok()`, so a root-owned `0700` directory
/// returns `false` for a non-root operator (`EACCES`) exactly as a missing path
/// does (`ENOENT`). Treating permission denied as present keeps the `System`
/// branch and the sudo hint, instead of sending the operator to an empty
/// per-user path.
pub fn path_is_present(path: &Path) -> bool {
    path_is_present_result(std::fs::metadata(path).map(|_| ()))
}

/// Classify a filesystem probe result for [`path_is_present`].
///
/// Split out so the `PermissionDenied` -> present rule is unit-tested without
/// needing a second Unix user.
pub(crate) fn path_is_present_result(result: Result<(), std::io::Error>) -> bool {
    match result {
        Ok(()) => true,
        Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => true,
        Err(_) => false,
    }
}

/// Resolve the audit store for this process against the real environment and
/// filesystem.
pub fn resolve_audit_store() -> AuditStoreChoice {
    let explicit = std::env::var_os("SYSKNIFE_DATABASE_PATH").map(PathBuf::from);
    let per_user = default_database_path();
    let system = PathBuf::from(PRODUCTION_DATABASE_PATH);
    let per_user_exists = path_is_present(&per_user);
    let system_exists = path_is_present(&system);
    choose_audit_store(explicit, per_user, per_user_exists, system_exists)
}

/// Read the current process's real UID from `/proc/self/status`.
///
/// Avoids a libc dep for one syscall. The return value is only used to
/// disambiguate the per-UID socket name in the last-resort branch of
/// [`default_listen_uri`]; on read failure we use `0`, which still produces a
/// valid path (just one shared by any caller in the same fallback case).
fn current_uid() -> u32 {
    std::fs::read_to_string("/proc/self/status")
        .ok()
        .and_then(|s| {
            s.lines()
                .find(|l| l.starts_with("Uid:"))
                .and_then(|l| l.split_whitespace().nth(1))
                .and_then(|v| v.parse::<u32>().ok())
        })
        .unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::{default_database_path, default_listen_uri, PRODUCTION_DATABASE_PATH};
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn production_database_path_is_absolute() {
        assert!(PRODUCTION_DATABASE_PATH.starts_with("/var/lib/"));
    }

    #[test]
    fn database_env_var_wins() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        unsafe {
            std::env::set_var("SYSKNIFE_DATABASE_PATH", "/explicit/path/db.sqlite");
        }
        let p = default_database_path();
        unsafe {
            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
        }
        assert_eq!(p.to_str(), Some("/explicit/path/db.sqlite"));
    }

    #[test]
    fn database_xdg_state_used_when_set() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        unsafe {
            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
            std::env::set_var("XDG_STATE_HOME", "/xdg/state");
        }
        let p = default_database_path();
        unsafe {
            std::env::remove_var("XDG_STATE_HOME");
        }
        assert_eq!(p.to_str(), Some("/xdg/state/sysknife/daemon.sqlite"));
    }

    #[test]
    fn database_falls_back_to_home_local_state() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        unsafe {
            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
            std::env::remove_var("XDG_STATE_HOME");
            std::env::set_var("HOME", "/home/contributor");
        }
        let p = default_database_path();
        unsafe {
            std::env::remove_var("HOME");
        }
        assert_eq!(
            p.to_str(),
            Some("/home/contributor/.local/state/sysknife/daemon.sqlite")
        );
    }

    #[test]
    fn database_last_resort_is_production_path() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        // Snapshot HOME so we can restore it (other tests in this binary need it).
        let saved_home = std::env::var("HOME").ok();
        unsafe {
            std::env::remove_var("SYSKNIFE_DATABASE_PATH");
            std::env::remove_var("XDG_STATE_HOME");
            std::env::remove_var("HOME");
        }
        let p = default_database_path();
        unsafe {
            if let Some(h) = saved_home {
                std::env::set_var("HOME", h);
            }
        }
        assert_eq!(p.to_str(), Some(PRODUCTION_DATABASE_PATH));
    }

    #[test]
    fn listen_env_var_wins() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        unsafe {
            std::env::set_var("SYSKNIFE_LISTEN_URI", "unix:///explicit.sock");
        }
        let u = default_listen_uri();
        unsafe {
            std::env::remove_var("SYSKNIFE_LISTEN_URI");
        }
        assert_eq!(u, "unix:///explicit.sock");
    }

    #[test]
    fn listen_uses_xdg_runtime_dir_when_set() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        unsafe {
            std::env::remove_var("SYSKNIFE_LISTEN_URI");
            std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1000");
        }
        let u = default_listen_uri();
        unsafe {
            std::env::remove_var("XDG_RUNTIME_DIR");
        }
        assert_eq!(u, "unix:///run/user/1000/sysknife/daemon.sock");
    }

    #[test]
    fn listen_last_resort_is_per_uid_tmp() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        unsafe {
            std::env::remove_var("SYSKNIFE_LISTEN_URI");
            std::env::remove_var("XDG_RUNTIME_DIR");
        }
        let u = default_listen_uri();
        assert!(u.starts_with("unix:///tmp/sysknife-"));
        assert!(u.ends_with(".sock"));
    }

    // -----------------------------------------------------------------------
    // Audit store selection
    // -----------------------------------------------------------------------

    use std::path::{Path, PathBuf};

    #[test]
    fn an_explicit_database_path_is_never_second_guessed() {
        let choice = super::choose_audit_store(
            Some(PathBuf::from("/srv/audit.sqlite")),
            PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite"),
            true,
            true,
        );
        assert_eq!(choice.path(), Path::new("/srv/audit.sqlite"));
        assert_eq!(
            choice.note(),
            None,
            "an explicit choice needs no explanation"
        );
    }

    #[test]
    fn a_present_per_user_store_wins_over_the_system_one() {
        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
        let choice = super::choose_audit_store(None, per_user.clone(), true, true);
        assert_eq!(choice.path(), per_user.as_path());
    }

    /// The system-install case: the daemon writes /var/lib, the operator's CLI
    /// has no per-user store, and verification must not report an empty chain.
    #[test]
    fn the_system_store_is_used_when_no_per_user_store_exists() {
        let choice = super::choose_audit_store(
            None,
            PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite"),
            false,
            true,
        );
        assert_eq!(choice.path(), Path::new(super::PRODUCTION_DATABASE_PATH));
        let note = choice
            .note()
            .expect("reading another store must be announced");
        assert!(
            note.contains(super::PRODUCTION_DATABASE_PATH),
            "note names the store: {note}"
        );
    }

    #[test]
    fn with_no_store_anywhere_the_per_user_path_is_reported() {
        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
        let choice = super::choose_audit_store(None, per_user.clone(), false, false);
        assert_eq!(choice.path(), per_user.as_path());
        assert_eq!(choice.note(), None);
    }

    #[test]
    fn permission_denied_counts_as_a_present_store() {
        assert!(
            super::path_is_present_result(Ok(())),
            "a successful stat is present"
        );
        assert!(
            super::path_is_present_result(Err(std::io::Error::from(
                std::io::ErrorKind::PermissionDenied
            ))),
            "EACCES must not be treated as missing"
        );
        assert!(
            !super::path_is_present_result(Err(std::io::Error::from(std::io::ErrorKind::NotFound))),
            "ENOENT remains missing"
        );
    }

    /// A 0700 system store that the operator cannot stat must still select the
    /// system path so `audit verify` prints the sudo / `--pubkey` hint instead
    /// of sending them to an empty per-user store.
    #[test]
    fn unreadable_system_store_wins_over_a_missing_per_user_store() {
        let per_user = PathBuf::from("/home/u/.local/state/sysknife/daemon.sqlite");
        let system_exists = super::path_is_present_result(Err(std::io::Error::from(
            std::io::ErrorKind::PermissionDenied,
        )));
        let choice = super::choose_audit_store(None, per_user, false, system_exists);
        assert_eq!(
            choice.path(),
            Path::new(super::PRODUCTION_DATABASE_PATH),
            "EACCES on the system store must not fall through to PerUser"
        );
        assert!(
            choice.note().is_some(),
            "System choice must still announce itself"
        );
    }

    /// Filesystem form of the EACCES probe: a database behind a mode-000
    /// directory is unreadable even to its owner, and must count as present.
    #[cfg(unix)]
    #[test]
    fn path_behind_mode_000_directory_counts_as_present() {
        use std::os::unix::fs::PermissionsExt;

        let root = tempfile::tempdir().expect("tempdir");
        let locked = root.path().join("locked");
        std::fs::create_dir(&locked).expect("mkdir");
        let db = locked.join("daemon.sqlite");
        std::fs::write(&db, b"x").expect("write db");
        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
            .expect("chmod 000");

        let present = super::path_is_present(&db);
        let exists_under_eacces = db.exists();

        // Restore perms so TempDir cleanup can unlink the tree.
        let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700));
        assert!(
            present,
            "metadata EACCES on a 0700/000 store must count as present, not missing"
        );
        assert!(
            !exists_under_eacces,
            "precondition: Path::exists is false under EACCES (the bug we are fixing)"
        );
    }

    /// Drift guard: the constant the CLI falls back to must be the path the
    /// packaged unit actually gives the daemon. If someone moves the store in
    /// packaging, this fails instead of `audit verify` silently reading nothing.
    #[test]
    fn the_system_store_constant_matches_the_packaged_unit() {
        let unit = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../../packaging/sysknife-daemon.service");
        let contents = std::fs::read_to_string(&unit)
            .unwrap_or_else(|e| panic!("read {}: {e}", unit.display()));
        let expected = format!("SYSKNIFE_DATABASE_PATH={}", super::PRODUCTION_DATABASE_PATH);
        assert!(
            contents.contains(&expected),
            "packaged unit must set {expected}; got:\n{contents}"
        );
    }
}