Skip to main content

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/// ```
206#[derive(Debug, Deserialize)]
207pub struct SyslogForwardSection {
208    /// Receiver address (`host:port`). Mandatory.
209    pub host: String,
210    /// Syslog facility number (default 1 = user-level messages).
211    #[serde(default = "default_syslog_facility")]
212    pub facility: u8,
213}
214
215fn default_syslog_facility() -> u8 {
216    1
217}
218
219/// `[policy]` section. Currently holds per-action risk-level overrides.
220///
221/// See [`PolicySection::risk_overrides`] for semantics. Absent → no overrides
222/// (the daemon uses compile-time defaults from `sysknife-daemon::policy`).
223#[derive(Debug, Default, Deserialize)]
224pub struct PolicySection {
225    /// Per-action risk-level overrides. Map from action name → risk level
226    /// (`"Low"` | `"Medium"` | `"High"`). The daemon validates this map at
227    /// startup and rejects unknown action names or attempted downgrades.
228    ///
229    /// Overrides may only **raise** the minimum role required for an action
230    /// — never lower it. The compile-time default is a floor.
231    ///
232    /// Example:
233    ///
234    /// ```toml
235    /// [policy.risk_overrides]
236    /// InstallFlatpak = "High"   # require Admin in this org (default: Medium/Dev)
237    /// ```
238    pub risk_overrides: Option<HashMap<String, String>>,
239}
240
241/// `[llm]` section.
242#[derive(Debug, Deserialize)]
243pub struct LlmSection {
244    /// LLM provider. One of: `"anthropic"`, `"ollama"`, `"openai"`,
245    /// `"gemini"`, `"groq"`, `"deepseek"`, `"mistral"`, `"xai"`.
246    /// Maps to `SYSKNIFE_LLM_PROVIDER`.
247    pub provider: Option<String>,
248    /// Model identifier. Maps to `SYSKNIFE_LLM_MODEL`.
249    pub model: Option<String>,
250    /// Ollama base URL. Maps to `SYSKNIFE_OLLAMA_URL`.
251    pub ollama_url: Option<String>,
252    /// Anthropic base URL. Maps to `SYSKNIFE_ANTHROPIC_URL`.
253    pub anthropic_url: Option<String>,
254    /// Planning loop turn limit. Maps to `SYSKNIFE_BRAIN_MAX_TURNS`.
255    pub max_turns: Option<u32>,
256    /// Override Ollama thinking-mode auto-detection. `None` means
257    /// `sysknife-brain` decides based on the model name. Maps to
258    /// `SYSKNIFE_OLLAMA_THINK` (`"true"` or `"false"`).
259    pub ollama_think: Option<bool>,
260}
261
262// ---------------------------------------------------------------------------
263// Loading
264// ---------------------------------------------------------------------------
265
266impl LacsConfig {
267    /// Returns the path to the config file (`~/.config/sysknife/config.toml`).
268    pub fn config_path() -> PathBuf {
269        config_path()
270    }
271
272    /// Load `~/.config/sysknife/config.toml`.
273    ///
274    /// Returns `LacsConfig::default()` (all `None`) if the file is absent —
275    /// this is the normal, unconfigured happy path and is unchanged.
276    ///
277    /// If the file IS present but cannot be parsed as TOML, or exists but
278    /// cannot be read (e.g. permission denied), this is treated as a fatal
279    /// misconfiguration rather than silently falling back to defaults: a clear
280    /// message is printed to stderr and the process exits with status 1.
281    /// Silently defaulting in that case would drop `[storage]` / `[policy]`
282    /// overrides (backend selection, risk-level overrides) without any
283    /// indication to the operator — a silent security downgrade. See
284    /// `try_load` for the non-exiting core used by tests.
285    pub fn load() -> Self {
286        let path = config_path();
287        Self::try_load(&path).unwrap_or_else(|reason| {
288            eprintln!("[sysknife] FATAL: {reason}");
289            std::process::exit(1);
290        })
291    }
292
293    /// Core of [`Self::load`], factored out so the parse-error / read-error
294    /// paths can be exercised in tests without triggering `process::exit`.
295    ///
296    /// Returns `Ok(Self::default())` when `path` does not exist. Returns
297    /// `Err` for any other failure (present-but-unparseable, or a read
298    /// error) — see [`Self::load`] for why this must never silently
299    /// default instead.
300    fn try_load(path: &std::path::Path) -> Result<Self, String> {
301        match std::fs::read_to_string(path) {
302            Ok(content) => toml::from_str(&content).map_err(|e| {
303                format!(
304                    "config file {} is present but could not be parsed as TOML: {e}; \
305                     refusing to silently fall back to defaults",
306                    path.display()
307                )
308            }),
309            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
310            Err(e) => Err(format!(
311                "config file {} exists but could not be read: {e}; refusing to \
312                 silently fall back to defaults",
313                path.display()
314            )),
315        }
316    }
317
318    /// Set environment variables from the config file for any key that is NOT
319    /// already present in the process environment.
320    ///
321    /// # Safety note
322    ///
323    /// This must be called during single-threaded startup, before the async
324    /// runtime or any thread pool is initialised. Modifying env vars while
325    /// other threads are reading them is undefined behaviour. Both `main.rs`
326    /// (daemon) and the Tauri `setup` hook (shell) satisfy this contract.
327    pub fn apply_defaults_to_env(&self) {
328        if let Some(daemon) = &self.daemon {
329            if let Some(socket) = &daemon.socket {
330                // Accept a raw path like `/run/sysknife/daemon.sock` and convert
331                // to the URI format the daemon expects.
332                let uri = if socket.starts_with("unix://") {
333                    socket.clone()
334                } else {
335                    format!("unix://{socket}")
336                };
337                set_if_absent("SYSKNIFE_LISTEN_URI", &uri);
338            }
339            if let Some(db) = &daemon.database {
340                set_if_absent("SYSKNIFE_DATABASE_PATH", db);
341            }
342        }
343
344        if let Some(llm) = &self.llm {
345            if let Some(provider) = &llm.provider {
346                set_if_absent("SYSKNIFE_LLM_PROVIDER", provider);
347            }
348            if let Some(model) = &llm.model {
349                set_if_absent("SYSKNIFE_LLM_MODEL", model);
350            }
351            if let Some(url) = &llm.ollama_url {
352                set_if_absent("SYSKNIFE_OLLAMA_URL", url);
353            }
354            if let Some(url) = &llm.anthropic_url {
355                set_if_absent("SYSKNIFE_ANTHROPIC_URL", url);
356            }
357            if let Some(turns) = llm.max_turns {
358                set_if_absent("SYSKNIFE_BRAIN_MAX_TURNS", &turns.to_string());
359            }
360            if let Some(think) = llm.ollama_think {
361                // `sysknife-brain::planner::resolve_ollama_think` parses
362                // case-insensitive "true"/"false"; emit the canonical
363                // form for clarity in ps/systemctl output.
364                set_if_absent(
365                    "SYSKNIFE_OLLAMA_THINK",
366                    if think { "true" } else { "false" },
367                );
368            }
369        }
370    }
371}
372
373// ---------------------------------------------------------------------------
374// Helpers
375// ---------------------------------------------------------------------------
376
377/// Returns `~/.config/sysknife`, respecting `XDG_CONFIG_HOME` if set.
378///
379/// `XDG_CONFIG_HOME` is accepted only if it is an absolute path with no `..`
380/// components, to prevent path-traversal attacks where an attacker sets
381/// `XDG_CONFIG_HOME=/etc` to redirect config and prefs writes to system
382/// directories. Invalid values are ignored and the default `~/.config` is used.
383///
384/// **Panics** when both `XDG_CONFIG_HOME` (or its validated form) and `HOME`
385/// are unset.  The previous behaviour silently fell back to `./.config`
386/// (relative to the daemon's CWD), which routinely surprised operators —
387/// the daemon would write its prefs file under whatever directory the
388/// systemd unit happened to be working in, often `/tmp` on a fresh boot.
389/// Failing loudly at the first call site forces the deployment manifest
390/// (or shell environment) to make HOME explicit.  Production
391/// daemon/shell paths already do — every systemd unit ships with
392/// `Environment=HOME=…` — so this panic is the right place to surface
393/// the misconfiguration.
394fn config_dir() -> PathBuf {
395    // Validate XDG_CONFIG_HOME: must be absolute and contain no `..` components.
396    let xdg_valid = std::env::var("XDG_CONFIG_HOME").ok().and_then(|v| {
397        let p = PathBuf::from(&v);
398        if p.is_absolute() && !p.components().any(|c| c == std::path::Component::ParentDir) {
399            Some(p)
400        } else {
401            eprintln!(
402                "[sysknife] warning: XDG_CONFIG_HOME={v:?} is not a safe absolute path; \
403                 ignoring and using default ~/.config"
404            );
405            None
406        }
407    });
408
409    let base = xdg_valid.unwrap_or_else(|| match std::env::var("HOME") {
410        Ok(home) => PathBuf::from(home).join(".config"),
411        Err(_) => panic!(
412            "[sysknife] FATAL: HOME and XDG_CONFIG_HOME are both unset — refusing \
413             to fall back to ./.config (relative paths produce ./.config/sysknife/ \
414             under whatever CWD the daemon was launched in, typically a tempdir or \
415             worse).  Set `HOME` (or `XDG_CONFIG_HOME` to an absolute path) in the \
416             systemd unit / shell session and try again."
417        ),
418    });
419    base.join("sysknife")
420}
421
422/// Returns the path to `~/.config/sysknife/config.toml`, respecting
423/// `XDG_CONFIG_HOME` if set.
424pub fn config_path() -> PathBuf {
425    config_dir().join("config.toml")
426}
427
428/// Returns the path to `~/.config/sysknife/prefs.md`, respecting
429/// `XDG_CONFIG_HOME` if set. Same directory as `config.toml`.
430pub fn prefs_path() -> PathBuf {
431    config_dir().join("prefs.md")
432}
433
434/// Set `key` to `value` only if `key` is absent from the process environment.
435fn set_if_absent(key: &str, value: &str) {
436    if std::env::var_os(key).is_none() {
437        // SAFETY: single-threaded startup — no other threads are reading env
438        // vars yet. See `apply_defaults_to_env` safety note.
439        #[allow(unused_unsafe)]
440        unsafe {
441            std::env::set_var(key, value);
442        }
443    }
444}
445
446// ---------------------------------------------------------------------------
447// Tests
448// ---------------------------------------------------------------------------
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn storage_section_parses_sqlite_default() {
456        let s = StorageSection {
457            backend: "sqlite".to_string(),
458            url: None,
459            pool: None,
460        };
461        assert_eq!(s.parsed().unwrap(), StorageBackend::Sqlite);
462    }
463
464    #[test]
465    fn storage_section_parses_sqlite_case_insensitive() {
466        let s = StorageSection {
467            backend: "Sqlite".to_string(),
468            url: None,
469            pool: None,
470        };
471        assert_eq!(s.parsed().unwrap(), StorageBackend::Sqlite);
472    }
473
474    #[test]
475    fn storage_section_parses_postgres_with_url() {
476        let s = StorageSection {
477            backend: "postgres".to_string(),
478            url: Some("postgres://user@host/db".to_string()),
479            pool: None,
480        };
481        match s.parsed().unwrap() {
482            StorageBackend::Postgres { url, pool } => {
483                assert_eq!(url, "postgres://user@host/db");
484                assert_eq!(pool, StoragePoolSection::default());
485            }
486            other => panic!("expected Postgres, got {other:?}"),
487        }
488    }
489
490    #[test]
491    fn storage_section_rejects_postgres_without_url() {
492        let s = StorageSection {
493            backend: "postgres".to_string(),
494            url: None,
495            pool: None,
496        };
497        let err = s.parsed().unwrap_err();
498        assert!(err.contains("requires `url"), "got: {err}");
499    }
500
501    #[test]
502    fn storage_section_rejects_unknown_backend() {
503        let s = StorageSection {
504            backend: "sqlit".to_string(), // typo
505            url: None,
506            pool: None,
507        };
508        let err = s.parsed().unwrap_err();
509        assert!(err.contains("unknown backend"), "got: {err}");
510    }
511
512    #[test]
513    fn load_returns_default_when_file_absent() {
514        // XDG_CONFIG_HOME pointing to a temp dir with no sysknife/config.toml
515        let dir = tempfile::tempdir().unwrap();
516        // Temporarily override XDG_CONFIG_HOME in this process.
517        // Tests that mutate env vars must not run in parallel — use a mutex.
518        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
519        unsafe {
520            std::env::set_var("XDG_CONFIG_HOME", dir.path());
521        }
522        let cfg = LacsConfig::load();
523        unsafe {
524            std::env::remove_var("XDG_CONFIG_HOME");
525        }
526        assert!(cfg.daemon.is_none());
527        assert!(cfg.llm.is_none());
528    }
529
530    #[test]
531    fn try_load_returns_default_when_file_absent() {
532        // No env-var mutation needed: try_load takes the path directly.
533        let dir = tempfile::tempdir().unwrap();
534        let path = dir.path().join("does-not-exist").join("config.toml");
535        let cfg = LacsConfig::try_load(&path).expect("absent file must be Ok(default)");
536        assert!(cfg.daemon.is_none());
537        assert!(cfg.llm.is_none());
538        assert!(cfg.storage.is_none());
539        assert!(cfg.policy.is_none());
540    }
541
542    #[test]
543    fn try_load_fails_loud_on_present_but_unparseable_file() {
544        // A present-but-broken config file must error, not silently fall
545        // back to defaults (which would drop [storage]/[policy] overrides
546        // without any indication to the operator).
547        let dir = tempfile::tempdir().unwrap();
548        let path = dir.path().join("config.toml");
549        std::fs::write(&path, "this is [ not valid toml at all").unwrap();
550
551        let result = LacsConfig::try_load(&path);
552        assert!(
553            result.is_err(),
554            "a present-but-unparseable config file must error, not silently default"
555        );
556        let err = result.unwrap_err();
557        assert!(
558            err.contains("could not be parsed"),
559            "error should explain the parse failure; got: {err}"
560        );
561    }
562
563    #[test]
564    fn load_parses_full_config() {
565        let dir = tempfile::tempdir().unwrap();
566        let sysknife_dir = dir.path().join("sysknife");
567        std::fs::create_dir_all(&sysknife_dir).unwrap();
568        std::fs::write(
569            sysknife_dir.join("config.toml"),
570            r#"
571[daemon]
572socket   = "/run/sysknife/daemon.sock"
573database = "/var/lib/sysknife/daemon.sqlite"
574
575[llm]
576provider     = "ollama"
577model        = "llama3.2"
578ollama_url   = "http://localhost:11434"
579max_turns    = 7
580"#,
581        )
582        .unwrap();
583
584        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
585        unsafe {
586            std::env::set_var("XDG_CONFIG_HOME", dir.path());
587        }
588        let cfg = LacsConfig::load();
589        unsafe {
590            std::env::remove_var("XDG_CONFIG_HOME");
591        }
592
593        let daemon = cfg.daemon.expect("daemon section missing");
594        assert_eq!(daemon.socket.as_deref(), Some("/run/sysknife/daemon.sock"));
595        assert_eq!(
596            daemon.database.as_deref(),
597            Some("/var/lib/sysknife/daemon.sqlite")
598        );
599
600        let llm = cfg.llm.expect("llm section missing");
601        assert_eq!(llm.provider.as_deref(), Some("ollama"));
602        assert_eq!(llm.model.as_deref(), Some("llama3.2"));
603        assert_eq!(llm.max_turns, Some(7));
604    }
605
606    #[test]
607    fn apply_defaults_does_not_override_existing_env() {
608        let dir = tempfile::tempdir().unwrap();
609        let sysknife_dir = dir.path().join("sysknife");
610        std::fs::create_dir_all(&sysknife_dir).unwrap();
611        std::fs::write(
612            sysknife_dir.join("config.toml"),
613            r#"
614[llm]
615provider = "anthropic"
616"#,
617        )
618        .unwrap();
619
620        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
621        unsafe {
622            std::env::set_var("XDG_CONFIG_HOME", dir.path());
623            // Pre-set the env var — config file must NOT override it.
624            std::env::set_var("SYSKNIFE_LLM_PROVIDER", "ollama");
625        }
626        let cfg = LacsConfig::load();
627        cfg.apply_defaults_to_env();
628        let provider = std::env::var("SYSKNIFE_LLM_PROVIDER").unwrap();
629        unsafe {
630            std::env::remove_var("XDG_CONFIG_HOME");
631            std::env::remove_var("SYSKNIFE_LLM_PROVIDER");
632        }
633        assert_eq!(provider, "ollama", "env var must win over config file");
634    }
635
636    #[test]
637    fn socket_path_gets_unix_prefix() {
638        let dir = tempfile::tempdir().unwrap();
639        let sysknife_dir = dir.path().join("sysknife");
640        std::fs::create_dir_all(&sysknife_dir).unwrap();
641        std::fs::write(
642            sysknife_dir.join("config.toml"),
643            r#"
644[daemon]
645socket = "/tmp/test.sock"
646"#,
647        )
648        .unwrap();
649
650        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
651        unsafe {
652            std::env::set_var("XDG_CONFIG_HOME", dir.path());
653            std::env::remove_var("SYSKNIFE_LISTEN_URI");
654        }
655        let cfg = LacsConfig::load();
656        cfg.apply_defaults_to_env();
657        let uri = std::env::var("SYSKNIFE_LISTEN_URI").unwrap();
658        unsafe {
659            std::env::remove_var("XDG_CONFIG_HOME");
660            std::env::remove_var("SYSKNIFE_LISTEN_URI");
661        }
662        assert_eq!(uri, "unix:///tmp/test.sock");
663    }
664
665    #[test]
666    fn ollama_think_false_emits_env_var() {
667        let dir = tempfile::tempdir().unwrap();
668        let sysknife_dir = dir.path().join("sysknife");
669        std::fs::create_dir_all(&sysknife_dir).unwrap();
670        std::fs::write(
671            sysknife_dir.join("config.toml"),
672            r#"
673[llm]
674ollama_think = false
675"#,
676        )
677        .unwrap();
678
679        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
680        unsafe {
681            std::env::set_var("XDG_CONFIG_HOME", dir.path());
682            std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
683        }
684        let cfg = LacsConfig::load();
685        cfg.apply_defaults_to_env();
686        let think = std::env::var("SYSKNIFE_OLLAMA_THINK").unwrap();
687        unsafe {
688            std::env::remove_var("XDG_CONFIG_HOME");
689            std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
690        }
691        assert_eq!(think, "false");
692    }
693
694    #[test]
695    fn ollama_think_true_emits_env_var() {
696        let dir = tempfile::tempdir().unwrap();
697        let sysknife_dir = dir.path().join("sysknife");
698        std::fs::create_dir_all(&sysknife_dir).unwrap();
699        std::fs::write(
700            sysknife_dir.join("config.toml"),
701            r#"
702[llm]
703ollama_think = true
704"#,
705        )
706        .unwrap();
707
708        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
709        unsafe {
710            std::env::set_var("XDG_CONFIG_HOME", dir.path());
711            std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
712        }
713        let cfg = LacsConfig::load();
714        cfg.apply_defaults_to_env();
715        let think = std::env::var("SYSKNIFE_OLLAMA_THINK").unwrap();
716        unsafe {
717            std::env::remove_var("XDG_CONFIG_HOME");
718            std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
719        }
720        assert_eq!(think, "true");
721    }
722
723    #[test]
724    fn ollama_think_absent_does_not_set_env_var() {
725        let dir = tempfile::tempdir().unwrap();
726        let sysknife_dir = dir.path().join("sysknife");
727        std::fs::create_dir_all(&sysknife_dir).unwrap();
728        std::fs::write(
729            sysknife_dir.join("config.toml"),
730            r#"
731[llm]
732model = "qwen3:8b"
733"#,
734        )
735        .unwrap();
736
737        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
738        unsafe {
739            std::env::set_var("XDG_CONFIG_HOME", dir.path());
740            std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
741        }
742        let cfg = LacsConfig::load();
743        cfg.apply_defaults_to_env();
744        let think_set = std::env::var_os("SYSKNIFE_OLLAMA_THINK").is_some();
745        unsafe {
746            std::env::remove_var("XDG_CONFIG_HOME");
747        }
748        assert!(!think_set, "absent ollama_think must not set the env var");
749    }
750
751    #[test]
752    fn prefs_path_lives_alongside_config() {
753        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
754        let prefs = prefs_path();
755        let config = config_path();
756        assert_eq!(prefs.parent(), config.parent());
757        assert_eq!(prefs.file_name().unwrap(), "prefs.md");
758    }
759
760    /// XDG_CONFIG_HOME validation must reject obvious traversal attempts
761    /// (relative paths, `..` components).  The remaining attack surface is a
762    /// **symlink-based** redirect — an attacker who cannot write to
763    /// XDG_CONFIG_HOME but CAN create a symlink at the resolved path.  We
764    /// don't dereference symlinks today: this regression test pins that
765    /// behaviour so a future change either documents the trade-off or
766    /// upgrades to canonicalising the path.
767    #[test]
768    #[cfg(unix)]
769    fn xdg_config_home_does_not_follow_symlinks() {
770        use std::os::unix::fs::symlink;
771        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
772
773        let dir = tempfile::tempdir().unwrap();
774        let real = dir.path().join("real");
775        let link = dir.path().join("link");
776        std::fs::create_dir_all(&real).unwrap();
777        symlink(&real, &link).unwrap();
778
779        let prev = std::env::var("XDG_CONFIG_HOME").ok();
780        unsafe {
781            std::env::set_var("XDG_CONFIG_HOME", &link);
782        }
783
784        let resolved = config_path();
785
786        // Restore env before asserting so a panic does not leak state.
787        match prev {
788            Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) },
789            None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
790        }
791
792        // Today: symlinks ARE accepted (resolver only rejects relative + `..`),
793        // and the returned path keeps the symlink form. If a future change
794        // adds canonicalize(), this assert will need updating to point at
795        // `real` and the symlink-redirect threat will be neutralised.
796        assert!(
797            resolved.starts_with(&link),
798            "current resolver keeps the symlink path verbatim — got {resolved:?}"
799        );
800    }
801
802    /// MD-12 — `config_dir` must panic loudly when both `HOME` and
803    /// `XDG_CONFIG_HOME` are unset.  The previous behaviour silently fell
804    /// back to `./.config`, which writes prefs under whatever CWD the
805    /// daemon happened to be launched in (often a tempdir) — a class of
806    /// data-loss footgun the operator never sees until rotation moves
807    /// CWD.  Fail loud at startup instead.
808    #[test]
809    fn config_dir_panics_when_home_and_xdg_config_home_are_both_unset() {
810        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
811        let prev_home = std::env::var("HOME").ok();
812        let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
813        unsafe {
814            std::env::remove_var("HOME");
815            std::env::remove_var("XDG_CONFIG_HOME");
816        }
817
818        let result = std::panic::catch_unwind(config_dir);
819
820        // Restore env before any assertion so a failed test cannot leak
821        // state into sibling tests in the same process.
822        unsafe {
823            match prev_home {
824                Some(v) => std::env::set_var("HOME", v),
825                None => std::env::remove_var("HOME"),
826            }
827            match prev_xdg {
828                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
829                None => std::env::remove_var("XDG_CONFIG_HOME"),
830            }
831        }
832
833        let panic = result
834            .expect_err("config_dir() must panic when both HOME and XDG_CONFIG_HOME are unset");
835        let msg: String = panic
836            .downcast::<String>()
837            .map(|b| *b)
838            .or_else(|p| p.downcast::<&'static str>().map(|b| (*b).to_string()))
839            .unwrap_or_else(|_| "<non-string panic>".to_string());
840        assert!(
841            msg.contains("HOME") && msg.contains("XDG_CONFIG_HOME"),
842            "panic message must mention both env vars; got: {msg}"
843        );
844    }
845
846    use std::sync::Mutex;
847    static ENV_LOCK: Mutex<()> = Mutex::new(());
848}