sysknife_core/config.rs
1//! `~/.config/sysknife/config.toml` — optional user configuration file.
2//!
3//! # Resolution order (highest priority wins)
4//!
5//! 1. Environment variables (always win — set by the caller or systemd unit)
6//! 2. Values in `~/.config/sysknife/config.toml`
7//! 3. Built-in defaults (defined in `sysknife-brain` and `sysknife-core`)
8//!
9//! # Usage
10//!
11//! Call [`LacsConfig::load`] once at startup, then call
12//! [`LacsConfig::apply_defaults_to_env`] to populate env vars for any
13//! key that the config file sets but that is *not* already present in the
14//! environment. After that, the rest of the codebase continues reading env
15//! vars as before — no callers need to change.
16//!
17//! ```no_run
18//! use sysknife_core::config::LacsConfig;
19//!
20//! LacsConfig::load().apply_defaults_to_env();
21//! ```
22//!
23//! # Example `config.toml`
24//!
25//! ```toml
26//! [daemon]
27//! socket = "/run/sysknife/daemon.sock" # written as a raw path, not a URI
28//! database = "/var/lib/sysknife/daemon.sqlite"
29//!
30//! [llm]
31//! provider = "ollama" # "ollama" | "anthropic" | "openai" | "gemini" | ...
32//! model = "qwen3:8b" # default — see sysknife-brain DEFAULT_OLLAMA_MODEL
33//! ollama_url = "http://localhost:11434"
34//! max_turns = 10
35//! # Optional: override the auto-detected thinking mode for Ollama.
36//! # Default: auto-detect from the model name (qwen3 / qwq / deepseek-r → true).
37//! # Set to `false` on CPU-only hosts running thinking models — thinking
38//! # traces exceed Ollama's internal request timeout on 4 vCPUs.
39//! # ollama_think = false
40//! ```
41
42use std::collections::HashMap;
43use std::path::PathBuf;
44
45use serde::Deserialize;
46
47// ---------------------------------------------------------------------------
48// Types
49// ---------------------------------------------------------------------------
50
51/// Top-level structure of `~/.config/sysknife/config.toml`.
52///
53/// All fields are optional — absent sections use built-in defaults.
54#[derive(Debug, Default, Deserialize)]
55pub struct LacsConfig {
56 pub daemon: Option<DaemonSection>,
57 pub llm: Option<LlmSection>,
58 pub policy: Option<PolicySection>,
59 pub audit: Option<AuditSection>,
60 pub storage: Option<StorageSection>,
61}
62
63/// `[daemon]` section.
64#[derive(Debug, Deserialize)]
65pub struct DaemonSection {
66 /// Unix socket path (raw path, not a URI). Maps to `SYSKNIFE_LISTEN_URI`.
67 /// The loader prepends `unix://` when setting the env var.
68 pub socket: Option<String>,
69 /// SQLite database path. Maps to `SYSKNIFE_DATABASE_PATH`.
70 pub database: Option<String>,
71}
72
73/// `[storage]` section. Selects the audit-log backend.
74///
75/// Default (absent or `backend = "sqlite"`) uses the local rusqlite-backed
76/// store at `[daemon].database`. **Production deployments should set
77/// `backend = "postgres"`** and supply a connection URL — the local SQLite
78/// path is recommended only for testing and sandboxing because the audit
79/// log dies with the host (no off-box durability, nothing to forward to
80/// a SOC).
81///
82/// Example (Postgres, AWS RDS):
83///
84/// ```toml
85/// [storage]
86/// backend = "postgres"
87/// url = "postgres://sysknife:${PG_PASSWORD}@db.example.com:5432/audit?sslmode=verify-full"
88///
89/// [storage.pool]
90/// max_connections = 8
91/// acquire_timeout_secs = 10
92/// statement_cache_capacity = 100 # set to 0 for transaction-mode poolers
93/// ```
94///
95/// See `docs/storage-cloud.md` for cloud-provider URL reference.
96#[derive(Debug, Default, Deserialize)]
97pub struct StorageSection {
98 /// `"sqlite"` (default) or `"postgres"`.
99 #[serde(default = "default_storage_backend")]
100 pub backend: String,
101 /// Postgres connection URL (`postgres://...`). Required when
102 /// `backend = "postgres"`. Ignored otherwise.
103 pub url: Option<String>,
104 /// Optional pool tuning. Defaults are sane for typical SysKnife load.
105 pub pool: Option<StoragePoolSection>,
106}
107
108fn default_storage_backend() -> String {
109 "sqlite".to_string()
110}
111
112/// Type-state projection of [`StorageSection`] with cross-field invariants
113/// enforced.
114///
115/// `StorageSection` is deserialized from `config.toml` with relaxed shape so
116/// existing user configs continue to load — `backend: String`, `url:
117/// Option<String>`. That means a misconfigured `backend = "postgres"` with
118/// no `url` only fails at daemon startup with a string-mismatch error, and
119/// callers downstream of the parse have to keep re-checking the
120/// invariant.
121///
122/// `StorageBackend` is the parsed form: variants carry exactly the fields
123/// they require, so it is impossible to construct a `Postgres` variant
124/// without a URL or to ask for a pool configuration in a `Sqlite` variant.
125/// Use [`StorageSection::parsed`] at the boundary; downstream code
126/// matches on the enum.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum StorageBackend {
129 Sqlite,
130 Postgres {
131 url: String,
132 pool: StoragePoolSection,
133 },
134}
135
136impl StorageSection {
137 /// Project the relaxed deserialised form into the type-state-checked
138 /// [`StorageBackend`] enum. Returns a human-readable error when the
139 /// `(backend, url, pool)` tuple is inconsistent (unknown backend,
140 /// `postgres` without `url`, etc.).
141 pub fn parsed(&self) -> Result<StorageBackend, String> {
142 match self.backend.to_ascii_lowercase().as_str() {
143 "sqlite" => Ok(StorageBackend::Sqlite),
144 "postgres" => {
145 let url = self
146 .url
147 .as_ref()
148 .ok_or_else(|| {
149 "[storage] backend = \"postgres\" requires `url = \"postgres://...\"`"
150 .to_string()
151 })?
152 .clone();
153 let pool = self.pool.clone().unwrap_or_default();
154 Ok(StorageBackend::Postgres { url, pool })
155 }
156 other => Err(format!(
157 "[storage] unknown backend {other:?}; expected \"sqlite\" or \"postgres\""
158 )),
159 }
160 }
161}
162
163/// `[storage.pool]` section.
164#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
165pub struct StoragePoolSection {
166 /// Max connections in the pool. Default 8.
167 pub max_connections: Option<u32>,
168 /// `acquire_timeout` for pool checkout (seconds). Default 10.
169 /// Raise above 10 if you observe Neon cold-start failures.
170 pub acquire_timeout_secs: Option<u64>,
171 /// Per-connection prepared-statement cache size. Default 100.
172 /// **Set to 0** for transaction-mode PgBouncer (including the Supabase
173 /// pooler), which rejects sqlx's named prepared statements.
174 pub statement_cache_capacity: Option<usize>,
175}
176
177/// `[audit]` section.
178///
179/// Absent → no SIEM forwarding (the daemon's local hash-chained log is the
180/// only audit sink). When present, [`AuditSection::forward`] enables one or
181/// more external receivers.
182#[derive(Debug, Default, Deserialize)]
183pub struct AuditSection {
184 pub forward: Option<AuditForwardSection>,
185}
186
187/// `[audit.forward]` section. Each subsection enables one external sink.
188///
189/// All sinks are best-effort: forwarding failures never block the daemon's
190/// audit-log INSERT. Phase 1 ships RFC 5424 syslog over UDP; CEF and
191/// NDJSON-over-TCP arrive in follow-up PRs.
192#[derive(Debug, Default, Deserialize)]
193pub struct AuditForwardSection {
194 /// `[audit.forward.syslog]` — RFC 5424 over UDP.
195 pub syslog: Option<SyslogForwardSection>,
196}
197
198/// `[audit.forward.syslog]` section.
199///
200/// Example:
201/// ```toml
202/// [audit.forward.syslog]
203/// host = "siem.internal:514"
204/// facility = 1 # 1 = user-level (default)
205/// enterprise_number = 99999 # your own IANA PEN; defaults to the doc/test PEN
206/// ```
207#[derive(Debug, Deserialize)]
208pub struct SyslogForwardSection {
209 /// Receiver address (`host:port`). Mandatory.
210 pub host: String,
211 /// Syslog facility number (default 1 = user-level messages).
212 #[serde(default = "default_syslog_facility")]
213 pub facility: u8,
214 /// IANA Private Enterprise Number used to build the syslog SD-ID
215 /// (`sysknife@<enterprise_number>`, see RFC 5424 §7.2.2).
216 ///
217 /// Defaults to [`DOCUMENTATION_PEN`], RFC 5612's reserved
218 /// documentation/test PEN — see the warning comment on
219 /// `format_rfc5424` in `sysknife-daemon::audit_forward`. Operators
220 /// forwarding into a regulated SIEM should set this to their own
221 /// assigned PEN; see [`SyslogForwardSection::validate`].
222 #[serde(default = "default_enterprise_number")]
223 pub enterprise_number: u32,
224}
225
226fn default_syslog_facility() -> u8 {
227 1
228}
229
230/// RFC 5612's reserved documentation/test Private Enterprise Number. Used
231/// as [`SyslogForwardSection::enterprise_number`]'s default so the syslog
232/// forwarder keeps working out of the box while still carrying the
233/// "not a real PEN" signal until an operator configures one.
234pub const DOCUMENTATION_PEN: u32 = 32473;
235
236fn default_enterprise_number() -> u32 {
237 DOCUMENTATION_PEN
238}
239
240impl SyslogForwardSection {
241 /// Reject an `enterprise_number` of `0`, which is not a valid IANA PEN
242 /// (the registry's `enterprise-numbers` file starts numbering at 1).
243 /// Called at config-parse time, alongside any `facility` validation.
244 ///
245 /// Every other `u32` value is accepted: IANA PENs are allocated
246 /// sequentially with no reserved upper gap, so any nonzero `u32` is a
247 /// syntactically valid PEN even if it has not actually been assigned to
248 /// this operator.
249 pub fn validate(&self) -> Result<(), String> {
250 if self.enterprise_number == 0 {
251 return Err(
252 "[audit.forward.syslog] enterprise_number must be a nonzero IANA \
253 Private Enterprise Number"
254 .to_string(),
255 );
256 }
257 Ok(())
258 }
259}
260
261/// `[policy]` section. Currently holds per-action risk-level overrides.
262///
263/// See [`PolicySection::risk_overrides`] for semantics. Absent → no overrides
264/// (the daemon uses compile-time defaults from `sysknife-daemon::policy`).
265#[derive(Debug, Default, Deserialize)]
266pub struct PolicySection {
267 /// Per-action risk-level overrides. Map from action name → risk level
268 /// (`"Low"` | `"Medium"` | `"High"`). The daemon validates this map at
269 /// startup and rejects unknown action names or attempted downgrades.
270 ///
271 /// Overrides may only **raise** the minimum role required for an action
272 /// — never lower it. The compile-time default is a floor.
273 ///
274 /// Example:
275 ///
276 /// ```toml
277 /// [policy.risk_overrides]
278 /// InstallFlatpak = "High" # require Admin in this org (default: Medium/Dev)
279 /// ```
280 pub risk_overrides: Option<HashMap<String, String>>,
281}
282
283/// `[llm]` section.
284#[derive(Debug, Deserialize)]
285pub struct LlmSection {
286 /// LLM provider. One of: `"anthropic"`, `"ollama"`, `"openai"`,
287 /// `"gemini"`, `"groq"`, `"deepseek"`, `"mistral"`, `"xai"`.
288 /// Maps to `SYSKNIFE_LLM_PROVIDER`.
289 pub provider: Option<String>,
290 /// Model identifier. Maps to `SYSKNIFE_LLM_MODEL`.
291 pub model: Option<String>,
292 /// Ollama base URL. Maps to `SYSKNIFE_OLLAMA_URL`.
293 pub ollama_url: Option<String>,
294 /// Anthropic base URL. Maps to `SYSKNIFE_ANTHROPIC_URL`.
295 pub anthropic_url: Option<String>,
296 /// Planning loop turn limit. Maps to `SYSKNIFE_BRAIN_MAX_TURNS`.
297 pub max_turns: Option<u32>,
298 /// Override Ollama thinking-mode auto-detection. `None` means
299 /// `sysknife-brain` decides based on the model name. Maps to
300 /// `SYSKNIFE_OLLAMA_THINK` (`"true"` or `"false"`).
301 pub ollama_think: Option<bool>,
302}
303
304// ---------------------------------------------------------------------------
305// Loading
306// ---------------------------------------------------------------------------
307
308impl LacsConfig {
309 /// Returns the path to the config file (`~/.config/sysknife/config.toml`).
310 pub fn config_path() -> PathBuf {
311 config_path()
312 }
313
314 /// Load `~/.config/sysknife/config.toml`.
315 ///
316 /// Returns `LacsConfig::default()` (all `None`) if the file is absent —
317 /// this is the normal, unconfigured happy path and is unchanged.
318 ///
319 /// If the file IS present but cannot be parsed as TOML, or exists but
320 /// cannot be read (e.g. permission denied), this is treated as a fatal
321 /// misconfiguration rather than silently falling back to defaults: a clear
322 /// message is printed to stderr and the process exits with status 1.
323 /// Silently defaulting in that case would drop `[storage]` / `[policy]`
324 /// overrides (backend selection, risk-level overrides) without any
325 /// indication to the operator — a silent security downgrade. See
326 /// `try_load` for the non-exiting core used by tests.
327 pub fn load() -> Self {
328 let path = config_path();
329 Self::try_load(&path).unwrap_or_else(|reason| {
330 eprintln!("[sysknife] FATAL: {reason}");
331 std::process::exit(1);
332 })
333 }
334
335 /// Core of [`Self::load`], factored out so the parse-error / read-error
336 /// paths can be exercised in tests without triggering `process::exit`.
337 ///
338 /// Returns `Ok(Self::default())` when `path` does not exist. Returns
339 /// `Err` for any other failure (present-but-unparseable, or a read
340 /// error) — see [`Self::load`] for why this must never silently
341 /// default instead.
342 fn try_load(path: &std::path::Path) -> Result<Self, String> {
343 match std::fs::read_to_string(path) {
344 Ok(content) => toml::from_str(&content).map_err(|e| {
345 format!(
346 "config file {} is present but could not be parsed as TOML: {e}; \
347 refusing to silently fall back to defaults",
348 path.display()
349 )
350 }),
351 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
352 Err(e) => Err(format!(
353 "config file {} exists but could not be read: {e}; refusing to \
354 silently fall back to defaults",
355 path.display()
356 )),
357 }
358 }
359
360 /// Set environment variables from the config file for any key that is NOT
361 /// already present in the process environment.
362 ///
363 /// # Safety note
364 ///
365 /// This must be called during single-threaded startup, before the async
366 /// runtime or any thread pool is initialised. Modifying env vars while
367 /// other threads are reading them is undefined behaviour. Both `main.rs`
368 /// (daemon) and the Tauri `setup` hook (shell) satisfy this contract.
369 pub fn apply_defaults_to_env(&self) {
370 if let Some(daemon) = &self.daemon {
371 if let Some(socket) = &daemon.socket {
372 // Accept a raw path like `/run/sysknife/daemon.sock` and convert
373 // to the URI format the daemon expects.
374 let uri = if socket.starts_with("unix://") {
375 socket.clone()
376 } else {
377 format!("unix://{socket}")
378 };
379 set_if_absent("SYSKNIFE_LISTEN_URI", &uri);
380 }
381 if let Some(db) = &daemon.database {
382 set_if_absent("SYSKNIFE_DATABASE_PATH", db);
383 }
384 }
385
386 if let Some(llm) = &self.llm {
387 if let Some(provider) = &llm.provider {
388 set_if_absent("SYSKNIFE_LLM_PROVIDER", provider);
389 }
390 if let Some(model) = &llm.model {
391 set_if_absent("SYSKNIFE_LLM_MODEL", model);
392 }
393 if let Some(url) = &llm.ollama_url {
394 set_if_absent("SYSKNIFE_OLLAMA_URL", url);
395 }
396 if let Some(url) = &llm.anthropic_url {
397 set_if_absent("SYSKNIFE_ANTHROPIC_URL", url);
398 }
399 if let Some(turns) = llm.max_turns {
400 set_if_absent("SYSKNIFE_BRAIN_MAX_TURNS", &turns.to_string());
401 }
402 if let Some(think) = llm.ollama_think {
403 // `sysknife-brain::planner::resolve_ollama_think` parses
404 // case-insensitive "true"/"false"; emit the canonical
405 // form for clarity in ps/systemctl output.
406 set_if_absent(
407 "SYSKNIFE_OLLAMA_THINK",
408 if think { "true" } else { "false" },
409 );
410 }
411 }
412 }
413}
414
415// ---------------------------------------------------------------------------
416// Helpers
417// ---------------------------------------------------------------------------
418
419/// Returns `~/.config/sysknife`, respecting `XDG_CONFIG_HOME` if set.
420///
421/// `XDG_CONFIG_HOME` is accepted only if it is an absolute path with no `..`
422/// components, to prevent path-traversal attacks where an attacker sets
423/// `XDG_CONFIG_HOME=/etc` to redirect config and prefs writes to system
424/// directories. Invalid values are ignored and the default `~/.config` is used.
425///
426/// **Panics** when both `XDG_CONFIG_HOME` (or its validated form) and `HOME`
427/// are unset. The previous behaviour silently fell back to `./.config`
428/// (relative to the daemon's CWD), which routinely surprised operators —
429/// the daemon would write its prefs file under whatever directory the
430/// systemd unit happened to be working in, often `/tmp` on a fresh boot.
431/// Failing loudly at the first call site forces the deployment manifest
432/// (or shell environment) to make HOME explicit. Production
433/// daemon/shell paths already do — every systemd unit ships with
434/// `Environment=HOME=…` — so this panic is the right place to surface
435/// the misconfiguration.
436fn config_dir() -> PathBuf {
437 // Validate XDG_CONFIG_HOME: must be absolute and contain no `..` components.
438 let xdg_valid = std::env::var("XDG_CONFIG_HOME").ok().and_then(|v| {
439 let p = PathBuf::from(&v);
440 if p.is_absolute() && !p.components().any(|c| c == std::path::Component::ParentDir) {
441 Some(p)
442 } else {
443 eprintln!(
444 "[sysknife] warning: XDG_CONFIG_HOME={v:?} is not a safe absolute path; \
445 ignoring and using default ~/.config"
446 );
447 None
448 }
449 });
450
451 let base = xdg_valid.unwrap_or_else(|| match std::env::var("HOME") {
452 Ok(home) => PathBuf::from(home).join(".config"),
453 Err(_) => panic!(
454 "[sysknife] FATAL: HOME and XDG_CONFIG_HOME are both unset — refusing \
455 to fall back to ./.config (relative paths produce ./.config/sysknife/ \
456 under whatever CWD the daemon was launched in, typically a tempdir or \
457 worse). Set `HOME` (or `XDG_CONFIG_HOME` to an absolute path) in the \
458 systemd unit / shell session and try again."
459 ),
460 });
461 base.join("sysknife")
462}
463
464/// Returns the path to `~/.config/sysknife/config.toml`, respecting
465/// `XDG_CONFIG_HOME` if set.
466pub fn config_path() -> PathBuf {
467 config_dir().join("config.toml")
468}
469
470/// Returns the path to `~/.config/sysknife/prefs.md`, respecting
471/// `XDG_CONFIG_HOME` if set. Same directory as `config.toml`.
472pub fn prefs_path() -> PathBuf {
473 config_dir().join("prefs.md")
474}
475
476/// Set `key` to `value` only if `key` is absent from the process environment.
477fn set_if_absent(key: &str, value: &str) {
478 if std::env::var_os(key).is_none() {
479 // SAFETY: single-threaded startup — no other threads are reading env
480 // vars yet. See `apply_defaults_to_env` safety note.
481 unsafe {
482 std::env::set_var(key, value);
483 }
484 }
485}
486
487// ---------------------------------------------------------------------------
488// Tests
489// ---------------------------------------------------------------------------
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[test]
496 fn storage_section_parses_sqlite_default() {
497 let s = StorageSection {
498 backend: "sqlite".to_string(),
499 url: None,
500 pool: None,
501 };
502 assert_eq!(s.parsed().unwrap(), StorageBackend::Sqlite);
503 }
504
505 #[test]
506 fn storage_section_parses_sqlite_case_insensitive() {
507 let s = StorageSection {
508 backend: "Sqlite".to_string(),
509 url: None,
510 pool: None,
511 };
512 assert_eq!(s.parsed().unwrap(), StorageBackend::Sqlite);
513 }
514
515 #[test]
516 fn storage_section_parses_postgres_with_url() {
517 let s = StorageSection {
518 backend: "postgres".to_string(),
519 url: Some("postgres://user@host/db".to_string()),
520 pool: None,
521 };
522 match s.parsed().unwrap() {
523 StorageBackend::Postgres { url, pool } => {
524 assert_eq!(url, "postgres://user@host/db");
525 assert_eq!(pool, StoragePoolSection::default());
526 }
527 other => panic!("expected Postgres, got {other:?}"),
528 }
529 }
530
531 #[test]
532 fn storage_section_rejects_postgres_without_url() {
533 let s = StorageSection {
534 backend: "postgres".to_string(),
535 url: None,
536 pool: None,
537 };
538 let err = s.parsed().unwrap_err();
539 assert!(err.contains("requires `url"), "got: {err}");
540 }
541
542 #[test]
543 fn storage_section_rejects_unknown_backend() {
544 let s = StorageSection {
545 backend: "sqlit".to_string(), // typo
546 url: None,
547 pool: None,
548 };
549 let err = s.parsed().unwrap_err();
550 assert!(err.contains("unknown backend"), "got: {err}");
551 }
552
553 #[test]
554 fn load_returns_default_when_file_absent() {
555 // XDG_CONFIG_HOME pointing to a temp dir with no sysknife/config.toml
556 let dir = tempfile::tempdir().unwrap();
557 // Temporarily override XDG_CONFIG_HOME in this process.
558 // Tests that mutate env vars must not run in parallel — use a mutex.
559 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
560 unsafe {
561 std::env::set_var("XDG_CONFIG_HOME", dir.path());
562 }
563 let cfg = LacsConfig::load();
564 unsafe {
565 std::env::remove_var("XDG_CONFIG_HOME");
566 }
567 assert!(cfg.daemon.is_none());
568 assert!(cfg.llm.is_none());
569 }
570
571 #[test]
572 fn try_load_returns_default_when_file_absent() {
573 // No env-var mutation needed: try_load takes the path directly.
574 let dir = tempfile::tempdir().unwrap();
575 let path = dir.path().join("does-not-exist").join("config.toml");
576 let cfg = LacsConfig::try_load(&path).expect("absent file must be Ok(default)");
577 assert!(cfg.daemon.is_none());
578 assert!(cfg.llm.is_none());
579 assert!(cfg.storage.is_none());
580 assert!(cfg.policy.is_none());
581 }
582
583 #[test]
584 fn try_load_fails_loud_on_present_but_unparseable_file() {
585 // A present-but-broken config file must error, not silently fall
586 // back to defaults (which would drop [storage]/[policy] overrides
587 // without any indication to the operator).
588 let dir = tempfile::tempdir().unwrap();
589 let path = dir.path().join("config.toml");
590 std::fs::write(&path, "this is [ not valid toml at all").unwrap();
591
592 let result = LacsConfig::try_load(&path);
593 assert!(
594 result.is_err(),
595 "a present-but-unparseable config file must error, not silently default"
596 );
597 let err = result.unwrap_err();
598 assert!(
599 err.contains("could not be parsed"),
600 "error should explain the parse failure; got: {err}"
601 );
602 }
603
604 #[test]
605 fn load_parses_full_config() {
606 let dir = tempfile::tempdir().unwrap();
607 let sysknife_dir = dir.path().join("sysknife");
608 std::fs::create_dir_all(&sysknife_dir).unwrap();
609 std::fs::write(
610 sysknife_dir.join("config.toml"),
611 r#"
612[daemon]
613socket = "/run/sysknife/daemon.sock"
614database = "/var/lib/sysknife/daemon.sqlite"
615
616[llm]
617provider = "ollama"
618model = "llama3.2"
619ollama_url = "http://localhost:11434"
620max_turns = 7
621"#,
622 )
623 .unwrap();
624
625 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
626 unsafe {
627 std::env::set_var("XDG_CONFIG_HOME", dir.path());
628 }
629 let cfg = LacsConfig::load();
630 unsafe {
631 std::env::remove_var("XDG_CONFIG_HOME");
632 }
633
634 let daemon = cfg.daemon.expect("daemon section missing");
635 assert_eq!(daemon.socket.as_deref(), Some("/run/sysknife/daemon.sock"));
636 assert_eq!(
637 daemon.database.as_deref(),
638 Some("/var/lib/sysknife/daemon.sqlite")
639 );
640
641 let llm = cfg.llm.expect("llm section missing");
642 assert_eq!(llm.provider.as_deref(), Some("ollama"));
643 assert_eq!(llm.model.as_deref(), Some("llama3.2"));
644 assert_eq!(llm.max_turns, Some(7));
645 }
646
647 #[test]
648 fn apply_defaults_does_not_override_existing_env() {
649 let dir = tempfile::tempdir().unwrap();
650 let sysknife_dir = dir.path().join("sysknife");
651 std::fs::create_dir_all(&sysknife_dir).unwrap();
652 std::fs::write(
653 sysknife_dir.join("config.toml"),
654 r#"
655[llm]
656provider = "anthropic"
657"#,
658 )
659 .unwrap();
660
661 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
662 unsafe {
663 std::env::set_var("XDG_CONFIG_HOME", dir.path());
664 // Pre-set the env var — config file must NOT override it.
665 std::env::set_var("SYSKNIFE_LLM_PROVIDER", "ollama");
666 }
667 let cfg = LacsConfig::load();
668 cfg.apply_defaults_to_env();
669 let provider = std::env::var("SYSKNIFE_LLM_PROVIDER").unwrap();
670 unsafe {
671 std::env::remove_var("XDG_CONFIG_HOME");
672 std::env::remove_var("SYSKNIFE_LLM_PROVIDER");
673 }
674 assert_eq!(provider, "ollama", "env var must win over config file");
675 }
676
677 #[test]
678 fn socket_path_gets_unix_prefix() {
679 let dir = tempfile::tempdir().unwrap();
680 let sysknife_dir = dir.path().join("sysknife");
681 std::fs::create_dir_all(&sysknife_dir).unwrap();
682 std::fs::write(
683 sysknife_dir.join("config.toml"),
684 r#"
685[daemon]
686socket = "/tmp/test.sock"
687"#,
688 )
689 .unwrap();
690
691 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
692 unsafe {
693 std::env::set_var("XDG_CONFIG_HOME", dir.path());
694 std::env::remove_var("SYSKNIFE_LISTEN_URI");
695 }
696 let cfg = LacsConfig::load();
697 cfg.apply_defaults_to_env();
698 let uri = std::env::var("SYSKNIFE_LISTEN_URI").unwrap();
699 unsafe {
700 std::env::remove_var("XDG_CONFIG_HOME");
701 std::env::remove_var("SYSKNIFE_LISTEN_URI");
702 }
703 assert_eq!(uri, "unix:///tmp/test.sock");
704 }
705
706 #[test]
707 fn ollama_think_false_emits_env_var() {
708 let dir = tempfile::tempdir().unwrap();
709 let sysknife_dir = dir.path().join("sysknife");
710 std::fs::create_dir_all(&sysknife_dir).unwrap();
711 std::fs::write(
712 sysknife_dir.join("config.toml"),
713 r#"
714[llm]
715ollama_think = false
716"#,
717 )
718 .unwrap();
719
720 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
721 unsafe {
722 std::env::set_var("XDG_CONFIG_HOME", dir.path());
723 std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
724 }
725 let cfg = LacsConfig::load();
726 cfg.apply_defaults_to_env();
727 let think = std::env::var("SYSKNIFE_OLLAMA_THINK").unwrap();
728 unsafe {
729 std::env::remove_var("XDG_CONFIG_HOME");
730 std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
731 }
732 assert_eq!(think, "false");
733 }
734
735 #[test]
736 fn ollama_think_true_emits_env_var() {
737 let dir = tempfile::tempdir().unwrap();
738 let sysknife_dir = dir.path().join("sysknife");
739 std::fs::create_dir_all(&sysknife_dir).unwrap();
740 std::fs::write(
741 sysknife_dir.join("config.toml"),
742 r#"
743[llm]
744ollama_think = true
745"#,
746 )
747 .unwrap();
748
749 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
750 unsafe {
751 std::env::set_var("XDG_CONFIG_HOME", dir.path());
752 std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
753 }
754 let cfg = LacsConfig::load();
755 cfg.apply_defaults_to_env();
756 let think = std::env::var("SYSKNIFE_OLLAMA_THINK").unwrap();
757 unsafe {
758 std::env::remove_var("XDG_CONFIG_HOME");
759 std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
760 }
761 assert_eq!(think, "true");
762 }
763
764 #[test]
765 fn ollama_think_absent_does_not_set_env_var() {
766 let dir = tempfile::tempdir().unwrap();
767 let sysknife_dir = dir.path().join("sysknife");
768 std::fs::create_dir_all(&sysknife_dir).unwrap();
769 std::fs::write(
770 sysknife_dir.join("config.toml"),
771 r#"
772[llm]
773model = "qwen3:8b"
774"#,
775 )
776 .unwrap();
777
778 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
779 unsafe {
780 std::env::set_var("XDG_CONFIG_HOME", dir.path());
781 std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
782 }
783 let cfg = LacsConfig::load();
784 cfg.apply_defaults_to_env();
785 let think_set = std::env::var_os("SYSKNIFE_OLLAMA_THINK").is_some();
786 unsafe {
787 std::env::remove_var("XDG_CONFIG_HOME");
788 }
789 assert!(!think_set, "absent ollama_think must not set the env var");
790 }
791
792 #[test]
793 fn prefs_path_lives_alongside_config() {
794 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
795 let prefs = prefs_path();
796 let config = config_path();
797 assert_eq!(prefs.parent(), config.parent());
798 assert_eq!(prefs.file_name().unwrap(), "prefs.md");
799 }
800
801 /// XDG_CONFIG_HOME validation must reject obvious traversal attempts
802 /// (relative paths, `..` components). The remaining attack surface is a
803 /// **symlink-based** redirect — an attacker who cannot write to
804 /// XDG_CONFIG_HOME but CAN create a symlink at the resolved path. We
805 /// don't dereference symlinks today: this regression test pins that
806 /// behaviour so a future change either documents the trade-off or
807 /// upgrades to canonicalising the path.
808 #[test]
809 #[cfg(unix)]
810 fn xdg_config_home_does_not_follow_symlinks() {
811 use std::os::unix::fs::symlink;
812 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
813
814 let dir = tempfile::tempdir().unwrap();
815 let real = dir.path().join("real");
816 let link = dir.path().join("link");
817 std::fs::create_dir_all(&real).unwrap();
818 symlink(&real, &link).unwrap();
819
820 let prev = std::env::var("XDG_CONFIG_HOME").ok();
821 unsafe {
822 std::env::set_var("XDG_CONFIG_HOME", &link);
823 }
824
825 let resolved = config_path();
826
827 // Restore env before asserting so a panic does not leak state.
828 match prev {
829 Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) },
830 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
831 }
832
833 // Today: symlinks ARE accepted (resolver only rejects relative + `..`),
834 // and the returned path keeps the symlink form. If a future change
835 // adds canonicalize(), this assert will need updating to point at
836 // `real` and the symlink-redirect threat will be neutralised.
837 assert!(
838 resolved.starts_with(&link),
839 "current resolver keeps the symlink path verbatim — got {resolved:?}"
840 );
841 }
842
843 /// MD-12 — `config_dir` must panic loudly when both `HOME` and
844 /// `XDG_CONFIG_HOME` are unset. The previous behaviour silently fell
845 /// back to `./.config`, which writes prefs under whatever CWD the
846 /// daemon happened to be launched in (often a tempdir) — a class of
847 /// data-loss footgun the operator never sees until rotation moves
848 /// CWD. Fail loud at startup instead.
849 #[test]
850 fn config_dir_panics_when_home_and_xdg_config_home_are_both_unset() {
851 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
852 let prev_home = std::env::var("HOME").ok();
853 let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
854 unsafe {
855 std::env::remove_var("HOME");
856 std::env::remove_var("XDG_CONFIG_HOME");
857 }
858
859 let result = std::panic::catch_unwind(config_dir);
860
861 // Restore env before any assertion so a failed test cannot leak
862 // state into sibling tests in the same process.
863 unsafe {
864 match prev_home {
865 Some(v) => std::env::set_var("HOME", v),
866 None => std::env::remove_var("HOME"),
867 }
868 match prev_xdg {
869 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
870 None => std::env::remove_var("XDG_CONFIG_HOME"),
871 }
872 }
873
874 let panic = result
875 .expect_err("config_dir() must panic when both HOME and XDG_CONFIG_HOME are unset");
876 let msg: String = panic
877 .downcast::<String>()
878 .map(|b| *b)
879 .or_else(|p| p.downcast::<&'static str>().map(|b| (*b).to_string()))
880 .unwrap_or_else(|_| "<non-string panic>".to_string());
881 assert!(
882 msg.contains("HOME") && msg.contains("XDG_CONFIG_HOME"),
883 "panic message must mention both env vars; got: {msg}"
884 );
885 }
886
887 #[test]
888 fn syslog_forward_section_default_enterprise_number_is_documentation_pen() {
889 let s = SyslogForwardSection {
890 host: "siem.internal:514".to_string(),
891 facility: default_syslog_facility(),
892 enterprise_number: default_enterprise_number(),
893 };
894 assert_eq!(s.enterprise_number, DOCUMENTATION_PEN);
895 assert!(s.validate().is_ok());
896 }
897
898 #[test]
899 fn syslog_forward_section_accepts_custom_enterprise_number() {
900 let s = SyslogForwardSection {
901 host: "siem.internal:514".to_string(),
902 facility: default_syslog_facility(),
903 enterprise_number: 99999,
904 };
905 assert!(s.validate().is_ok());
906 }
907
908 #[test]
909 fn syslog_forward_section_rejects_zero_enterprise_number() {
910 let s = SyslogForwardSection {
911 host: "siem.internal:514".to_string(),
912 facility: default_syslog_facility(),
913 enterprise_number: 0,
914 };
915 let err = s.validate().unwrap_err();
916 assert!(err.contains("enterprise_number"), "got: {err}");
917 }
918
919 use std::sync::Mutex;
920 static ENV_LOCK: Mutex<()> = Mutex::new(());
921}