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 /// Falls back to defaults on parse error (with a warning). I/O errors
276 /// other than `NotFound` (e.g. permission denied) are also warned so the
277 /// user knows their config file exists but could not be read.
278 pub fn load() -> Self {
279 let path = config_path();
280 match std::fs::read_to_string(&path) {
281 Ok(content) => toml::from_str(&content).unwrap_or_else(|e| {
282 eprintln!(
283 "[sysknife] warning: could not parse {}: {e}; using defaults",
284 path.display()
285 );
286 Self::default()
287 }),
288 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::default(),
289 Err(e) => {
290 eprintln!(
291 "[sysknife] warning: could not read {}: {e}; using defaults",
292 path.display()
293 );
294 Self::default()
295 }
296 }
297 }
298
299 /// Set environment variables from the config file for any key that is NOT
300 /// already present in the process environment.
301 ///
302 /// # Safety note
303 ///
304 /// This must be called during single-threaded startup, before the async
305 /// runtime or any thread pool is initialised. Modifying env vars while
306 /// other threads are reading them is undefined behaviour. Both `main.rs`
307 /// (daemon) and the Tauri `setup` hook (shell) satisfy this contract.
308 pub fn apply_defaults_to_env(&self) {
309 if let Some(daemon) = &self.daemon {
310 if let Some(socket) = &daemon.socket {
311 // Accept a raw path like `/run/sysknife/daemon.sock` and convert
312 // to the URI format the daemon expects.
313 let uri = if socket.starts_with("unix://") {
314 socket.clone()
315 } else {
316 format!("unix://{socket}")
317 };
318 set_if_absent("SYSKNIFE_LISTEN_URI", &uri);
319 }
320 if let Some(db) = &daemon.database {
321 set_if_absent("SYSKNIFE_DATABASE_PATH", db);
322 }
323 }
324
325 if let Some(llm) = &self.llm {
326 if let Some(provider) = &llm.provider {
327 set_if_absent("SYSKNIFE_LLM_PROVIDER", provider);
328 }
329 if let Some(model) = &llm.model {
330 set_if_absent("SYSKNIFE_LLM_MODEL", model);
331 }
332 if let Some(url) = &llm.ollama_url {
333 set_if_absent("SYSKNIFE_OLLAMA_URL", url);
334 }
335 if let Some(url) = &llm.anthropic_url {
336 set_if_absent("SYSKNIFE_ANTHROPIC_URL", url);
337 }
338 if let Some(turns) = llm.max_turns {
339 set_if_absent("SYSKNIFE_BRAIN_MAX_TURNS", &turns.to_string());
340 }
341 if let Some(think) = llm.ollama_think {
342 // `sysknife-brain::planner::resolve_ollama_think` parses
343 // case-insensitive "true"/"false"; emit the canonical
344 // form for clarity in ps/systemctl output.
345 set_if_absent(
346 "SYSKNIFE_OLLAMA_THINK",
347 if think { "true" } else { "false" },
348 );
349 }
350 }
351 }
352}
353
354// ---------------------------------------------------------------------------
355// Helpers
356// ---------------------------------------------------------------------------
357
358/// Returns `~/.config/sysknife`, respecting `XDG_CONFIG_HOME` if set.
359///
360/// `XDG_CONFIG_HOME` is accepted only if it is an absolute path with no `..`
361/// components, to prevent path-traversal attacks where an attacker sets
362/// `XDG_CONFIG_HOME=/etc` to redirect config and prefs writes to system
363/// directories. Invalid values are ignored and the default `~/.config` is used.
364///
365/// **Panics** when both `XDG_CONFIG_HOME` (or its validated form) and `HOME`
366/// are unset. The previous behaviour silently fell back to `./.config`
367/// (relative to the daemon's CWD), which routinely surprised operators —
368/// the daemon would write its prefs file under whatever directory the
369/// systemd unit happened to be working in, often `/tmp` on a fresh boot.
370/// Failing loudly at the first call site forces the deployment manifest
371/// (or shell environment) to make HOME explicit. Production
372/// daemon/shell paths already do — every systemd unit ships with
373/// `Environment=HOME=…` — so this panic is the right place to surface
374/// the misconfiguration.
375fn config_dir() -> PathBuf {
376 // Validate XDG_CONFIG_HOME: must be absolute and contain no `..` components.
377 let xdg_valid = std::env::var("XDG_CONFIG_HOME").ok().and_then(|v| {
378 let p = PathBuf::from(&v);
379 if p.is_absolute() && !p.components().any(|c| c == std::path::Component::ParentDir) {
380 Some(p)
381 } else {
382 eprintln!(
383 "[sysknife] warning: XDG_CONFIG_HOME={v:?} is not a safe absolute path; \
384 ignoring and using default ~/.config"
385 );
386 None
387 }
388 });
389
390 let base = xdg_valid.unwrap_or_else(|| match std::env::var("HOME") {
391 Ok(home) => PathBuf::from(home).join(".config"),
392 Err(_) => panic!(
393 "[sysknife] FATAL: HOME and XDG_CONFIG_HOME are both unset — refusing \
394 to fall back to ./.config (relative paths produce ./.config/sysknife/ \
395 under whatever CWD the daemon was launched in, typically a tempdir or \
396 worse). Set `HOME` (or `XDG_CONFIG_HOME` to an absolute path) in the \
397 systemd unit / shell session and try again."
398 ),
399 });
400 base.join("sysknife")
401}
402
403/// Returns the path to `~/.config/sysknife/config.toml`, respecting
404/// `XDG_CONFIG_HOME` if set.
405pub fn config_path() -> PathBuf {
406 config_dir().join("config.toml")
407}
408
409/// Returns the path to `~/.config/sysknife/prefs.md`, respecting
410/// `XDG_CONFIG_HOME` if set. Same directory as `config.toml`.
411pub fn prefs_path() -> PathBuf {
412 config_dir().join("prefs.md")
413}
414
415/// Set `key` to `value` only if `key` is absent from the process environment.
416fn set_if_absent(key: &str, value: &str) {
417 if std::env::var_os(key).is_none() {
418 // SAFETY: single-threaded startup — no other threads are reading env
419 // vars yet. See `apply_defaults_to_env` safety note.
420 #[allow(unused_unsafe)]
421 unsafe {
422 std::env::set_var(key, value);
423 }
424 }
425}
426
427// ---------------------------------------------------------------------------
428// Tests
429// ---------------------------------------------------------------------------
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn storage_section_parses_sqlite_default() {
437 let s = StorageSection {
438 backend: "sqlite".to_string(),
439 url: None,
440 pool: None,
441 };
442 assert_eq!(s.parsed().unwrap(), StorageBackend::Sqlite);
443 }
444
445 #[test]
446 fn storage_section_parses_sqlite_case_insensitive() {
447 let s = StorageSection {
448 backend: "Sqlite".to_string(),
449 url: None,
450 pool: None,
451 };
452 assert_eq!(s.parsed().unwrap(), StorageBackend::Sqlite);
453 }
454
455 #[test]
456 fn storage_section_parses_postgres_with_url() {
457 let s = StorageSection {
458 backend: "postgres".to_string(),
459 url: Some("postgres://user@host/db".to_string()),
460 pool: None,
461 };
462 match s.parsed().unwrap() {
463 StorageBackend::Postgres { url, pool } => {
464 assert_eq!(url, "postgres://user@host/db");
465 assert_eq!(pool, StoragePoolSection::default());
466 }
467 other => panic!("expected Postgres, got {other:?}"),
468 }
469 }
470
471 #[test]
472 fn storage_section_rejects_postgres_without_url() {
473 let s = StorageSection {
474 backend: "postgres".to_string(),
475 url: None,
476 pool: None,
477 };
478 let err = s.parsed().unwrap_err();
479 assert!(err.contains("requires `url"), "got: {err}");
480 }
481
482 #[test]
483 fn storage_section_rejects_unknown_backend() {
484 let s = StorageSection {
485 backend: "sqlit".to_string(), // typo
486 url: None,
487 pool: None,
488 };
489 let err = s.parsed().unwrap_err();
490 assert!(err.contains("unknown backend"), "got: {err}");
491 }
492
493 #[test]
494 fn load_returns_default_when_file_absent() {
495 // XDG_CONFIG_HOME pointing to a temp dir with no sysknife/config.toml
496 let dir = tempfile::tempdir().unwrap();
497 // Temporarily override XDG_CONFIG_HOME in this process.
498 // Tests that mutate env vars must not run in parallel — use a mutex.
499 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
500 unsafe {
501 std::env::set_var("XDG_CONFIG_HOME", dir.path());
502 }
503 let cfg = LacsConfig::load();
504 unsafe {
505 std::env::remove_var("XDG_CONFIG_HOME");
506 }
507 assert!(cfg.daemon.is_none());
508 assert!(cfg.llm.is_none());
509 }
510
511 #[test]
512 fn load_parses_full_config() {
513 let dir = tempfile::tempdir().unwrap();
514 let sysknife_dir = dir.path().join("sysknife");
515 std::fs::create_dir_all(&sysknife_dir).unwrap();
516 std::fs::write(
517 sysknife_dir.join("config.toml"),
518 r#"
519[daemon]
520socket = "/run/sysknife/daemon.sock"
521database = "/var/lib/sysknife/daemon.sqlite"
522
523[llm]
524provider = "ollama"
525model = "llama3.2"
526ollama_url = "http://localhost:11434"
527max_turns = 7
528"#,
529 )
530 .unwrap();
531
532 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
533 unsafe {
534 std::env::set_var("XDG_CONFIG_HOME", dir.path());
535 }
536 let cfg = LacsConfig::load();
537 unsafe {
538 std::env::remove_var("XDG_CONFIG_HOME");
539 }
540
541 let daemon = cfg.daemon.expect("daemon section missing");
542 assert_eq!(daemon.socket.as_deref(), Some("/run/sysknife/daemon.sock"));
543 assert_eq!(
544 daemon.database.as_deref(),
545 Some("/var/lib/sysknife/daemon.sqlite")
546 );
547
548 let llm = cfg.llm.expect("llm section missing");
549 assert_eq!(llm.provider.as_deref(), Some("ollama"));
550 assert_eq!(llm.model.as_deref(), Some("llama3.2"));
551 assert_eq!(llm.max_turns, Some(7));
552 }
553
554 #[test]
555 fn apply_defaults_does_not_override_existing_env() {
556 let dir = tempfile::tempdir().unwrap();
557 let sysknife_dir = dir.path().join("sysknife");
558 std::fs::create_dir_all(&sysknife_dir).unwrap();
559 std::fs::write(
560 sysknife_dir.join("config.toml"),
561 r#"
562[llm]
563provider = "anthropic"
564"#,
565 )
566 .unwrap();
567
568 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
569 unsafe {
570 std::env::set_var("XDG_CONFIG_HOME", dir.path());
571 // Pre-set the env var — config file must NOT override it.
572 std::env::set_var("SYSKNIFE_LLM_PROVIDER", "ollama");
573 }
574 let cfg = LacsConfig::load();
575 cfg.apply_defaults_to_env();
576 let provider = std::env::var("SYSKNIFE_LLM_PROVIDER").unwrap();
577 unsafe {
578 std::env::remove_var("XDG_CONFIG_HOME");
579 std::env::remove_var("SYSKNIFE_LLM_PROVIDER");
580 }
581 assert_eq!(provider, "ollama", "env var must win over config file");
582 }
583
584 #[test]
585 fn socket_path_gets_unix_prefix() {
586 let dir = tempfile::tempdir().unwrap();
587 let sysknife_dir = dir.path().join("sysknife");
588 std::fs::create_dir_all(&sysknife_dir).unwrap();
589 std::fs::write(
590 sysknife_dir.join("config.toml"),
591 r#"
592[daemon]
593socket = "/tmp/test.sock"
594"#,
595 )
596 .unwrap();
597
598 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
599 unsafe {
600 std::env::set_var("XDG_CONFIG_HOME", dir.path());
601 std::env::remove_var("SYSKNIFE_LISTEN_URI");
602 }
603 let cfg = LacsConfig::load();
604 cfg.apply_defaults_to_env();
605 let uri = std::env::var("SYSKNIFE_LISTEN_URI").unwrap();
606 unsafe {
607 std::env::remove_var("XDG_CONFIG_HOME");
608 std::env::remove_var("SYSKNIFE_LISTEN_URI");
609 }
610 assert_eq!(uri, "unix:///tmp/test.sock");
611 }
612
613 #[test]
614 fn ollama_think_false_emits_env_var() {
615 let dir = tempfile::tempdir().unwrap();
616 let sysknife_dir = dir.path().join("sysknife");
617 std::fs::create_dir_all(&sysknife_dir).unwrap();
618 std::fs::write(
619 sysknife_dir.join("config.toml"),
620 r#"
621[llm]
622ollama_think = false
623"#,
624 )
625 .unwrap();
626
627 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
628 unsafe {
629 std::env::set_var("XDG_CONFIG_HOME", dir.path());
630 std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
631 }
632 let cfg = LacsConfig::load();
633 cfg.apply_defaults_to_env();
634 let think = std::env::var("SYSKNIFE_OLLAMA_THINK").unwrap();
635 unsafe {
636 std::env::remove_var("XDG_CONFIG_HOME");
637 std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
638 }
639 assert_eq!(think, "false");
640 }
641
642 #[test]
643 fn ollama_think_true_emits_env_var() {
644 let dir = tempfile::tempdir().unwrap();
645 let sysknife_dir = dir.path().join("sysknife");
646 std::fs::create_dir_all(&sysknife_dir).unwrap();
647 std::fs::write(
648 sysknife_dir.join("config.toml"),
649 r#"
650[llm]
651ollama_think = true
652"#,
653 )
654 .unwrap();
655
656 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
657 unsafe {
658 std::env::set_var("XDG_CONFIG_HOME", dir.path());
659 std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
660 }
661 let cfg = LacsConfig::load();
662 cfg.apply_defaults_to_env();
663 let think = std::env::var("SYSKNIFE_OLLAMA_THINK").unwrap();
664 unsafe {
665 std::env::remove_var("XDG_CONFIG_HOME");
666 std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
667 }
668 assert_eq!(think, "true");
669 }
670
671 #[test]
672 fn ollama_think_absent_does_not_set_env_var() {
673 let dir = tempfile::tempdir().unwrap();
674 let sysknife_dir = dir.path().join("sysknife");
675 std::fs::create_dir_all(&sysknife_dir).unwrap();
676 std::fs::write(
677 sysknife_dir.join("config.toml"),
678 r#"
679[llm]
680model = "qwen3:8b"
681"#,
682 )
683 .unwrap();
684
685 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
686 unsafe {
687 std::env::set_var("XDG_CONFIG_HOME", dir.path());
688 std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
689 }
690 let cfg = LacsConfig::load();
691 cfg.apply_defaults_to_env();
692 let think_set = std::env::var_os("SYSKNIFE_OLLAMA_THINK").is_some();
693 unsafe {
694 std::env::remove_var("XDG_CONFIG_HOME");
695 }
696 assert!(!think_set, "absent ollama_think must not set the env var");
697 }
698
699 #[test]
700 fn prefs_path_lives_alongside_config() {
701 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
702 let prefs = prefs_path();
703 let config = config_path();
704 assert_eq!(prefs.parent(), config.parent());
705 assert_eq!(prefs.file_name().unwrap(), "prefs.md");
706 }
707
708 /// XDG_CONFIG_HOME validation must reject obvious traversal attempts
709 /// (relative paths, `..` components). The remaining attack surface is a
710 /// **symlink-based** redirect — an attacker who cannot write to
711 /// XDG_CONFIG_HOME but CAN create a symlink at the resolved path. We
712 /// don't dereference symlinks today: this regression test pins that
713 /// behaviour so a future change either documents the trade-off or
714 /// upgrades to canonicalising the path.
715 #[test]
716 #[cfg(unix)]
717 fn xdg_config_home_does_not_follow_symlinks() {
718 use std::os::unix::fs::symlink;
719 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
720
721 let dir = tempfile::tempdir().unwrap();
722 let real = dir.path().join("real");
723 let link = dir.path().join("link");
724 std::fs::create_dir_all(&real).unwrap();
725 symlink(&real, &link).unwrap();
726
727 let prev = std::env::var("XDG_CONFIG_HOME").ok();
728 unsafe {
729 std::env::set_var("XDG_CONFIG_HOME", &link);
730 }
731
732 let resolved = config_path();
733
734 // Restore env before asserting so a panic does not leak state.
735 match prev {
736 Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) },
737 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
738 }
739
740 // Today: symlinks ARE accepted (resolver only rejects relative + `..`),
741 // and the returned path keeps the symlink form. If a future change
742 // adds canonicalize(), this assert will need updating to point at
743 // `real` and the symlink-redirect threat will be neutralised.
744 assert!(
745 resolved.starts_with(&link),
746 "current resolver keeps the symlink path verbatim — got {resolved:?}"
747 );
748 }
749
750 /// MD-12 — `config_dir` must panic loudly when both `HOME` and
751 /// `XDG_CONFIG_HOME` are unset. The previous behaviour silently fell
752 /// back to `./.config`, which writes prefs under whatever CWD the
753 /// daemon happened to be launched in (often a tempdir) — a class of
754 /// data-loss footgun the operator never sees until rotation moves
755 /// CWD. Fail loud at startup instead.
756 #[test]
757 fn config_dir_panics_when_home_and_xdg_config_home_are_both_unset() {
758 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
759 let prev_home = std::env::var("HOME").ok();
760 let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
761 unsafe {
762 std::env::remove_var("HOME");
763 std::env::remove_var("XDG_CONFIG_HOME");
764 }
765
766 let result = std::panic::catch_unwind(config_dir);
767
768 // Restore env before any assertion so a failed test cannot leak
769 // state into sibling tests in the same process.
770 unsafe {
771 match prev_home {
772 Some(v) => std::env::set_var("HOME", v),
773 None => std::env::remove_var("HOME"),
774 }
775 match prev_xdg {
776 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
777 None => std::env::remove_var("XDG_CONFIG_HOME"),
778 }
779 }
780
781 let panic = result
782 .expect_err("config_dir() must panic when both HOME and XDG_CONFIG_HOME are unset");
783 let msg: String = panic
784 .downcast::<String>()
785 .map(|b| *b)
786 .or_else(|p| p.downcast::<&'static str>().map(|b| (*b).to_string()))
787 .unwrap_or_else(|_| "<non-string panic>".to_string());
788 assert!(
789 msg.contains("HOME") && msg.contains("XDG_CONFIG_HOME"),
790 "panic message must mention both env vars; got: {msg}"
791 );
792 }
793
794 use std::sync::Mutex;
795 static ENV_LOCK: Mutex<()> = Mutex::new(());
796}