Skip to main content

openlatch_client/core/config/
mod.rs

1/// Configuration loading for OpenLatch daemon.
2///
3/// Implements the full precedence chain per CONF-03:
4/// CLI flags > env vars (`OPENLATCH_*`) > `~/.openlatch/config.toml` > defaults
5///
6/// The default config file uses a commented-template style (D-10) with only the
7/// pinned port written as an active value (D-11).
8use std::path::{Path, PathBuf};
9
10use serde::Deserialize;
11
12use crate::error::{OlError, ERR_INVALID_CONFIG, ERR_PORT_IN_USE};
13
14// ---------------------------------------------------------------------------
15// Update check configuration
16// ---------------------------------------------------------------------------
17
18/// Configuration for the version update check performed at daemon startup.
19///
20/// Override via env var: `OPENLATCH_UPDATE_CHECK=false` disables the check.
21#[derive(Debug, Clone)]
22pub struct UpdateConfig {
23    /// Whether to check for a newer version on daemon start. Default: true.
24    pub check: bool,
25}
26
27impl Default for UpdateConfig {
28    fn default() -> Self {
29        Self { check: true }
30    }
31}
32
33// ---------------------------------------------------------------------------
34// Platform-aware home directory
35// ---------------------------------------------------------------------------
36
37/// Returns the platform-appropriate openlatch data directory.
38///
39/// - Unix/macOS: `~/.openlatch`
40/// - Windows: `%APPDATA%\openlatch` (falls back to `~\openlatch` if %APPDATA% unavailable)
41pub fn openlatch_dir() -> PathBuf {
42    #[cfg(windows)]
43    {
44        dirs::data_dir()
45            .unwrap_or_else(|| dirs::home_dir().expect("home directory must exist"))
46            .join("openlatch")
47    }
48    #[cfg(not(windows))]
49    {
50        dirs::home_dir()
51            .expect("home directory must exist")
52            .join(".openlatch")
53    }
54}
55
56// ---------------------------------------------------------------------------
57// Config struct (public, runtime-resolved)
58// ---------------------------------------------------------------------------
59
60/// Resolved runtime configuration for the OpenLatch daemon.
61///
62/// This struct is constructed by [`Config::load`] from the full precedence chain:
63/// CLI flags > env vars > config.toml > defaults.
64#[derive(Debug, Clone)]
65pub struct Config {
66    /// TCP port for the local daemon (default: 7443).
67    pub port: u16,
68    /// Directory for audit and daemon logs.
69    pub log_dir: PathBuf,
70    /// Tracing log level (default: "info").
71    pub log_level: String,
72    /// Audit log retention in days (default: 30).
73    pub retention_days: u32,
74    /// Additional regex patterns for secret masking (additive to built-ins, per D-03).
75    pub extra_patterns: Vec<String>,
76    /// When true, daemon runs in foreground without forking.
77    pub foreground: bool,
78    /// Update check configuration (UPDT-01 through UPDT-04).
79    pub update: UpdateConfig,
80}
81
82impl Config {
83    /// Return sensible defaults for all config fields.
84    pub fn defaults() -> Self {
85        Self {
86            port: 7443,
87            log_dir: openlatch_dir().join("logs"),
88            log_level: "info".into(),
89            retention_days: 30,
90            extra_patterns: vec![],
91            foreground: false,
92            update: UpdateConfig::default(),
93        }
94    }
95
96    /// Load configuration from the full precedence chain.
97    ///
98    /// Precedence order (highest to lowest):
99    /// 1. CLI flags (`cli_*` parameters — `Some(value)` overrides)
100    /// 2. Environment variables (`OPENLATCH_PORT`, `OPENLATCH_HOST`, `OPENLATCH_LOG_DIR`,
101    ///    `OPENLATCH_LOG`, `OPENLATCH_RETENTION_DAYS`)
102    /// 3. `~/.openlatch/config.toml` (parsed, partial overrides)
103    /// 4. Compile-time defaults
104    ///
105    /// # Errors
106    ///
107    /// Returns [`OlError`] if the config file exists but cannot be parsed as valid TOML.
108    pub fn load(
109        cli_port: Option<u16>,
110        cli_log_level: Option<String>,
111        cli_foreground: bool,
112    ) -> Result<Self, OlError> {
113        let mut cfg = Self::defaults();
114
115        // Layer 3: config.toml
116        let config_path = openlatch_dir().join("config.toml");
117        if config_path.exists() {
118            let raw = std::fs::read_to_string(&config_path).map_err(|e| {
119                OlError::new(ERR_INVALID_CONFIG, format!("Cannot read config file: {e}"))
120                    .with_suggestion("Check that the file is readable and not corrupted.")
121            })?;
122            let toml_cfg: TomlConfig = toml::from_str(&raw).map_err(|e| {
123                OlError::new(
124                    ERR_INVALID_CONFIG,
125                    format!("Invalid TOML in config file: {e}"),
126                )
127                .with_suggestion("Check your config.toml for syntax errors.")
128                .with_docs("https://docs.openlatch.ai/configuration")
129            })?;
130            if let Some(daemon) = toml_cfg.daemon {
131                if let Some(port) = daemon.port {
132                    cfg.port = port;
133                }
134            }
135            if let Some(logging) = toml_cfg.logging {
136                if let Some(level) = logging.level {
137                    cfg.log_level = level;
138                }
139                if let Some(dir) = logging.dir {
140                    cfg.log_dir = PathBuf::from(dir);
141                }
142                if let Some(days) = logging.retention_days {
143                    cfg.retention_days = days;
144                }
145            }
146            if let Some(privacy) = toml_cfg.privacy {
147                if let Some(patterns) = privacy.extra_patterns {
148                    cfg.extra_patterns = patterns;
149                }
150            }
151            if let Some(update) = toml_cfg.update {
152                if let Some(check) = update.check {
153                    cfg.update.check = check;
154                }
155            }
156        }
157
158        // Layer 2: env vars
159        if let Ok(val) = std::env::var("OPENLATCH_PORT") {
160            cfg.port = val.parse::<u16>().map_err(|_| {
161                OlError::new(
162                    ERR_INVALID_CONFIG,
163                    format!("OPENLATCH_PORT is not a valid port number: '{val}'"),
164                )
165                .with_suggestion("Set OPENLATCH_PORT to an integer between 1024 and 65535.")
166            })?;
167        }
168        if let Ok(val) = std::env::var("OPENLATCH_LOG_DIR") {
169            cfg.log_dir = PathBuf::from(val);
170        }
171        if let Ok(val) = std::env::var("OPENLATCH_LOG") {
172            cfg.log_level = val;
173        }
174        if let Ok(val) = std::env::var("OPENLATCH_RETENTION_DAYS") {
175            cfg.retention_days = val.parse::<u32>().map_err(|_| {
176                OlError::new(
177                    ERR_INVALID_CONFIG,
178                    format!("OPENLATCH_RETENTION_DAYS is not a valid integer: '{val}'"),
179                )
180                .with_suggestion("Set OPENLATCH_RETENTION_DAYS to a positive integer.")
181            })?;
182        }
183        // UPDT-04: env var override for update check
184        if let Ok(val) = std::env::var("OPENLATCH_UPDATE_CHECK") {
185            if val == "false" || val == "0" {
186                cfg.update.check = false;
187            }
188        }
189
190        // Layer 1: CLI flags (highest priority)
191        if let Some(port) = cli_port {
192            cfg.port = port;
193        }
194        if let Some(level) = cli_log_level {
195            cfg.log_level = level;
196        }
197        if cli_foreground {
198            cfg.foreground = true;
199        }
200
201        Ok(cfg)
202    }
203}
204
205// ---------------------------------------------------------------------------
206// TOML intermediate structs (all fields optional — partial overrides)
207// ---------------------------------------------------------------------------
208
209#[derive(Debug, Deserialize)]
210struct TomlConfig {
211    #[serde(default)]
212    daemon: Option<DaemonToml>,
213    #[serde(default)]
214    logging: Option<LoggingToml>,
215    #[serde(default)]
216    privacy: Option<PrivacyToml>,
217    #[serde(default)]
218    update: Option<UpdateToml>,
219}
220
221#[derive(Debug, Deserialize)]
222struct DaemonToml {
223    port: Option<u16>,
224}
225
226#[derive(Debug, Deserialize)]
227struct LoggingToml {
228    level: Option<String>,
229    dir: Option<String>,
230    retention_days: Option<u32>,
231}
232
233#[derive(Debug, Deserialize)]
234struct PrivacyToml {
235    extra_patterns: Option<Vec<String>>,
236}
237
238#[derive(Debug, Deserialize)]
239struct UpdateToml {
240    check: Option<bool>,
241}
242
243// ---------------------------------------------------------------------------
244// Default config template (D-10 / D-11)
245// ---------------------------------------------------------------------------
246
247/// Generate the default config.toml content.
248///
249/// Per D-10: commented template style — all sections present, every field commented.
250/// Per D-11: only the pinned `port` value is written as an active (uncommented) line.
251pub fn generate_default_config_toml(port: u16) -> String {
252    format!(
253        r#"# OpenLatch Configuration
254# Uncomment and modify values to customize behavior.
255
256[daemon]
257port = {port}
258# SECURITY: bind address is always 127.0.0.1 — not configurable
259
260[logging]
261# level = "info"
262# dir = "~/.openlatch/logs"
263# retention_days = 30
264
265[privacy]
266# Extra regex patterns for secret masking (additive to built-ins).
267# Each entry is a regex string applied to JSON string values.
268# extra_patterns = ["CUSTOM_SECRET_[A-Z0-9]{{32}}"]
269
270# [update]
271# check = true  # Set to false to disable update checks on daemon start
272"#
273    )
274}
275
276/// Ensure the openlatch config directory and config.toml exist.
277///
278/// Creates `~/.openlatch/` if missing, writes `config.toml` with the default
279/// template if missing, then returns the path to `config.toml`.
280///
281/// # Errors
282///
283/// Returns [`OlError`] if the directory or file cannot be created.
284pub fn ensure_config(port: u16) -> Result<PathBuf, OlError> {
285    let dir = openlatch_dir();
286    std::fs::create_dir_all(&dir).map_err(|e| {
287        OlError::new(
288            ERR_INVALID_CONFIG,
289            format!("Cannot create config directory '{}': {e}", dir.display()),
290        )
291        .with_suggestion("Check that you have write permission to your home directory.")
292    })?;
293
294    let config_path = dir.join("config.toml");
295    if !config_path.exists() {
296        let content = generate_default_config_toml(port);
297        std::fs::write(&config_path, content).map_err(|e| {
298            OlError::new(
299                ERR_INVALID_CONFIG,
300                format!("Cannot write config file '{}': {e}", config_path.display()),
301            )
302            .with_suggestion("Check that you have write permission to ~/.openlatch/.")
303        })?;
304    }
305
306    Ok(config_path)
307}
308
309// ---------------------------------------------------------------------------
310// Token generation and management (SEC-01)
311// ---------------------------------------------------------------------------
312
313/// Generate a cryptographically random 64-character hex token.
314///
315/// Uses two UUIDv4 values (each 16 bytes of OS CSPRNG entropy via `getrandom`)
316/// concatenated to produce 32 bytes = 64 hex characters. No additional
317/// dependencies are needed since `uuid` with the `v4` feature already pulls
318/// in `getrandom` which maps to the OS CSPRNG on all platforms.
319///
320/// # Security
321///
322/// The resulting string is suitable as a bearer token for daemon authentication.
323/// The `uuid` crate uses `getrandom` internally, which calls `BCryptGenRandom`
324/// on Windows and `getrandom(2)` / `/dev/urandom` on Unix.
325pub fn generate_token() -> String {
326    let a = uuid::Uuid::new_v4();
327    let b = uuid::Uuid::new_v4();
328    format!("{}{}", a.simple(), b.simple())
329}
330
331/// Ensure a daemon bearer token exists at `{dir}/daemon.token`.
332///
333/// If the token file already exists, reads and returns the existing token.
334/// If it does not exist, generates a new token, writes it to the file with
335/// restricted permissions (mode 0600 on Unix), and returns the new token.
336///
337/// # Errors
338///
339/// Returns [`OlError`] if the token file cannot be read or written.
340///
341/// # Security (SEC-01)
342///
343/// The token file is set to mode 0600 on Unix (user read/write only).
344/// On Windows, the file is written to the user's AppData directory which is
345/// already restricted to the current user by default ACLs.
346pub fn ensure_token(dir: &Path) -> Result<String, OlError> {
347    let token_path = dir.join("daemon.token");
348
349    if token_path.exists() {
350        // Read existing token
351        let token = std::fs::read_to_string(&token_path).map_err(|e| {
352            OlError::new(
353                crate::error::ERR_INVALID_CONFIG,
354                format!("Cannot read token file '{}': {e}", token_path.display()),
355            )
356            .with_suggestion("Check that the file exists and is readable.")
357        })?;
358        return Ok(token.trim().to_string());
359    }
360
361    // Generate and write new token — ensure parent directory exists first
362    let token = generate_token();
363    if let Some(parent) = token_path.parent() {
364        std::fs::create_dir_all(parent).map_err(|e| {
365            OlError::new(
366                crate::error::ERR_INVALID_CONFIG,
367                format!("Cannot create directory '{}': {e}", parent.display()),
368            )
369            .with_suggestion("Check that you have write permission to the parent directory.")
370        })?;
371    }
372    std::fs::write(&token_path, &token).map_err(|e| {
373        OlError::new(
374            crate::error::ERR_INVALID_CONFIG,
375            format!("Cannot write token file '{}': {e}", token_path.display()),
376        )
377        .with_suggestion("Check that you have write permission to the openlatch directory.")
378    })?;
379
380    // SECURITY: Restrict token file to owner only (mode 0600) on Unix.
381    // Windows does not need this because AppData is already restricted by ACLs.
382    #[cfg(unix)]
383    {
384        use std::os::unix::fs::PermissionsExt;
385        let perms = std::fs::Permissions::from_mode(0o600);
386        std::fs::set_permissions(&token_path, perms).map_err(|e| {
387            OlError::new(
388                crate::error::ERR_INVALID_CONFIG,
389                format!("Cannot set permissions on token file: {e}"),
390            )
391            .with_suggestion("Check that you have permission to modify file attributes.")
392        })?;
393    }
394
395    Ok(token)
396}
397
398// ---------------------------------------------------------------------------
399// Port probing and port file (PRD: probe 7443-7543 on first init)
400// ---------------------------------------------------------------------------
401
402/// Default port range start for probing.
403pub const PORT_RANGE_START: u16 = 7443;
404/// Default port range end for probing (inclusive).
405pub const PORT_RANGE_END: u16 = 7543;
406
407/// Probe ports in the given range, returning the first available port.
408///
409/// Uses a sync `std::net::TcpListener` bind-and-drop to test availability.
410/// Safe to call before any async runtime is started.
411///
412/// # Errors
413///
414/// Returns `OL-1500` if no port in the range is free.
415pub fn probe_free_port(start: u16, end: u16) -> Result<u16, OlError> {
416    for port in start..=end {
417        if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
418            return Ok(port);
419        }
420    }
421    Err(OlError::new(
422        ERR_PORT_IN_USE,
423        format!("No free port found in range {start}-{end}"),
424    )
425    .with_suggestion(format!(
426        "Free a port in the {start}-{end} range, or set OPENLATCH_PORT to a specific port."
427    ))
428    .with_docs("https://docs.openlatch.ai/errors/OL-1500"))
429}
430
431/// Write the daemon's port number to `~/.openlatch/daemon.port`.
432///
433/// Plain text file containing just the port number. Readable by the hook binary
434/// without TOML parsing.
435pub fn write_port_file(port: u16) -> Result<(), OlError> {
436    let path = openlatch_dir().join("daemon.port");
437    std::fs::write(&path, port.to_string()).map_err(|e| {
438        OlError::new(
439            ERR_INVALID_CONFIG,
440            format!("Cannot write port file '{}': {e}", path.display()),
441        )
442    })?;
443    Ok(())
444}
445
446/// Read the daemon's port from `~/.openlatch/daemon.port`.
447///
448/// Returns `None` if the file doesn't exist or can't be parsed.
449pub fn read_port_file() -> Option<u16> {
450    let path = openlatch_dir().join("daemon.port");
451    std::fs::read_to_string(path)
452        .ok()?
453        .trim()
454        .parse::<u16>()
455        .ok()
456}
457
458// ---------------------------------------------------------------------------
459// Tests
460// ---------------------------------------------------------------------------
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use tempfile::TempDir;
466
467    #[test]
468    fn test_config_defaults_values() {
469        // Test 1: Config::defaults() returns expected values
470        let cfg = Config::defaults();
471        assert_eq!(cfg.port, 7443, "Default port must be 7443");
472        assert_eq!(cfg.log_level, "info", "Default log level must be info");
473        assert_eq!(cfg.retention_days, 30, "Default retention must be 30 days");
474    }
475
476    #[test]
477    fn test_config_loads_from_toml_file() {
478        // Test 2: Config loads from a TOML file in a temp directory
479        let tmp = TempDir::new().unwrap();
480        let config_path = tmp.path().join("config.toml");
481        std::fs::write(
482            &config_path,
483            r#"
484[daemon]
485port = 8080
486"#,
487        )
488        .unwrap();
489
490        // Write a minimal config to the openlatch dir location by overriding
491        // via env var (since Config::load reads from openlatch_dir())
492        // We'll test the TOML parsing logic directly via env override
493        // and by placing the file at the expected path.
494
495        // Directly test the TOML parse path:
496        let raw = std::fs::read_to_string(&config_path).unwrap();
497        let toml_cfg: TomlConfig = toml::from_str(&raw).unwrap();
498        let daemon = toml_cfg.daemon.unwrap();
499        assert_eq!(daemon.port, Some(8080));
500    }
501
502    #[test]
503    fn test_config_cli_port_overrides_default() {
504        // Test 3: CLI port flag overrides default (avoids thread-unsafe env::set_var)
505        let cfg = Config::load(Some(9000), None, false)
506            .expect("Config::load should succeed with valid CLI port");
507        assert_eq!(cfg.port, 9000, "CLI port should override default");
508    }
509
510    #[test]
511    fn test_generate_token_produces_64_char_hex() {
512        // Test 4: generate_token() produces a 32-byte hex string (64 chars)
513        let token = generate_token();
514        assert_eq!(
515            token.len(),
516            64,
517            "Token must be 64 characters (32 bytes hex-encoded), got: {token}"
518        );
519        assert!(
520            token.chars().all(|c| c.is_ascii_hexdigit()),
521            "Token must be hex-encoded, got: {token}"
522        );
523    }
524
525    #[test]
526    fn test_ensure_token_creates_and_returns_token() {
527        // Test 5: ensure_token() creates token file and returns token; second call reads existing
528        let tmp = TempDir::new().unwrap();
529
530        // First call: creates the file
531        let token1 = ensure_token(tmp.path()).expect("First ensure_token call should succeed");
532        assert_eq!(token1.len(), 64, "Generated token must be 64 chars");
533        assert!(
534            tmp.path().join("daemon.token").exists(),
535            "Token file must be created"
536        );
537
538        // Second call: reads the existing file
539        let token2 = ensure_token(tmp.path()).expect("Second ensure_token call should succeed");
540        assert_eq!(token1, token2, "Second call must return the same token");
541    }
542
543    #[cfg(unix)]
544    #[test]
545    fn test_ensure_token_file_has_mode_0600() {
546        // Test 6: On Unix, token file has mode 0o600
547        use std::os::unix::fs::PermissionsExt;
548
549        let tmp = TempDir::new().unwrap();
550        ensure_token(tmp.path()).expect("ensure_token should succeed");
551
552        let token_path = tmp.path().join("daemon.token");
553        let metadata = std::fs::metadata(&token_path).unwrap();
554        let mode = metadata.permissions().mode() & 0o777;
555        assert_eq!(mode, 0o600, "Token file must have mode 0600, got: {mode:o}");
556    }
557
558    #[test]
559    fn test_generate_default_config_toml_format() {
560        // Test 7: generate_default_config_toml() contains "# [daemon]" (commented sections)
561        // and "port = 7443" as the only uncommented value
562        let content = generate_default_config_toml(7443);
563
564        assert!(
565            content.contains("port = 7443"),
566            "Must contain active port line: {content}"
567        );
568        // The [daemon] section header is present (active)
569        assert!(
570            content.contains("[daemon]"),
571            "Must contain [daemon] section: {content}"
572        );
573        // All other fields are commented
574        assert!(
575            content.contains("# level ="),
576            "level must be commented out: {content}"
577        );
578        assert!(
579            content.contains("# retention_days ="),
580            "retention_days must be commented: {content}"
581        );
582    }
583
584    #[test]
585    fn test_config_extra_patterns_defaults_empty() {
586        // Test 8: Config::extra_patterns field exists and defaults to empty vec
587        let cfg = Config::defaults();
588        assert!(
589            cfg.extra_patterns.is_empty(),
590            "Default extra_patterns must be empty"
591        );
592    }
593
594    #[test]
595    fn test_probe_free_port_finds_available_port() {
596        // Probe a range — at least one port should be free on any test machine
597        let port = probe_free_port(PORT_RANGE_START, PORT_RANGE_END)
598            .expect("should find at least one free port");
599        assert!((PORT_RANGE_START..=PORT_RANGE_END).contains(&port));
600    }
601
602    #[test]
603    fn test_probe_free_port_skips_occupied_port() {
604        // Bind a port, then probe a range starting at that port — should skip it
605        let listener =
606            std::net::TcpListener::bind(("127.0.0.1", 0)).expect("should bind to random port");
607        let occupied = listener.local_addr().unwrap().port();
608
609        // Probe a 1-port range with the occupied port — must fail
610        let result = probe_free_port(occupied, occupied);
611        assert!(
612            result.is_err(),
613            "must fail when only port in range is occupied"
614        );
615
616        // Probe a 2-port range — should find the next one
617        if occupied < 65535 {
618            let result = probe_free_port(occupied, occupied + 1);
619            assert!(result.is_ok(), "should find next port after occupied one");
620            assert_eq!(result.unwrap(), occupied + 1);
621        }
622    }
623
624    #[test]
625    fn test_write_and_read_port_file_round_trip() {
626        let tmp = TempDir::new().unwrap();
627        let port_path = tmp.path().join("daemon.port");
628
629        // Write port file to temp location (test the format, not the path logic)
630        std::fs::write(&port_path, "8080").unwrap();
631        let content = std::fs::read_to_string(&port_path).unwrap();
632        assert_eq!(content.trim().parse::<u16>().unwrap(), 8080);
633    }
634}