Skip to main content

cellos_ctl/
config.rs

1//! cellctl configuration — persisted at `~/.cellctl/config` (TOML).
2//!
3//! The on-disk shape is intentionally tiny. cellctl never caches CellOS state
4//! locally; the only legitimate client-local state is the server URL, the
5//! optional API token, and (eventually, per Session 16) the NATS stream cursor.
6//! Anything else would violate the "events are the source of truth" principle.
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::exit::{CtlError, CtlResult};
14
15const DEFAULT_SERVER: &str = "http://127.0.0.1:8080";
16
17#[derive(Debug, Clone, Serialize, Deserialize, Default)]
18pub struct Config {
19    /// Base URL of cellos-server (e.g. `https://cellos.example.com`).
20    pub server_url: Option<String>,
21    /// Bearer token presented in `Authorization: Bearer <token>` on every request.
22    pub api_token: Option<String>,
23}
24
25impl Config {
26    /// Effective server URL: explicit config > $CELLCTL_SERVER > default loopback.
27    pub fn effective_server(&self) -> String {
28        self.server_url
29            .clone()
30            .or_else(|| std::env::var("CELLCTL_SERVER").ok())
31            .unwrap_or_else(|| DEFAULT_SERVER.to_string())
32    }
33
34    /// Effective token: explicit config > $CELLCTL_TOKEN > none.
35    pub fn effective_token(&self) -> Option<String> {
36        self.api_token
37            .clone()
38            .or_else(|| std::env::var("CELLCTL_TOKEN").ok())
39    }
40}
41
42/// Resolve the config file path, honoring `$CELLCTL_CONFIG` and `$XDG_CONFIG_HOME`.
43pub fn config_path() -> CtlResult<PathBuf> {
44    if let Ok(explicit) = std::env::var("CELLCTL_CONFIG") {
45        return Ok(PathBuf::from(explicit));
46    }
47    let home =
48        home::home_dir().ok_or_else(|| CtlError::usage("cannot determine home directory"))?;
49    Ok(home.join(".cellctl").join("config"))
50}
51
52pub fn load() -> CtlResult<Config> {
53    let path = config_path()?;
54    if !path.exists() {
55        return Ok(Config::default());
56    }
57    let raw = fs::read_to_string(&path)
58        .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
59    let cfg: Config = toml::from_str(&raw)
60        .map_err(|e| CtlError::usage(format!("parse {}: {e}", path.display())))?;
61    Ok(cfg)
62}
63
64pub fn save(cfg: &Config) -> CtlResult<()> {
65    let path = config_path()?;
66    if let Some(parent) = path.parent() {
67        ensure_dir(parent)?;
68    }
69    let raw = toml::to_string_pretty(cfg)
70        .map_err(|e| CtlError::usage(format!("serialize config: {e}")))?;
71    write_owner_only(&path, raw.as_bytes())
72        .map_err(|e| CtlError::usage(format!("write {}: {e}", path.display())))?;
73    Ok(())
74}
75
76/// HIGH-3 (red-team pass B): the cellctl config file holds an API bearer
77/// token in plain text. `fs::write` honours the process umask, which on
78/// most distros is `0o022`, producing mode `0o644` — world-readable. We
79/// open the file explicitly with `mode(0o600)` on Unix and then fall back
80/// to `set_permissions` for the pre-existing-file edge case where
81/// `OpenOptions::mode` only applies on create.
82///
83/// On Windows we let the default ACLs apply — the file lands under the
84/// user profile directory, which by default inherits restrictive ACLs.
85fn write_owner_only(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
86    use std::io::Write as _;
87
88    #[cfg(unix)]
89    {
90        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
91        let mut f = fs::OpenOptions::new()
92            .write(true)
93            .create(true)
94            .truncate(true)
95            .mode(0o600)
96            .open(path)?;
97        f.write_all(bytes)?;
98        f.sync_all()?;
99        // Belt-and-suspenders: if the file already existed with looser perms,
100        // `mode(0o600)` doesn't tighten — fix that with an explicit chmod.
101        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
102        Ok(())
103    }
104
105    #[cfg(not(unix))]
106    {
107        let mut f = fs::OpenOptions::new()
108            .write(true)
109            .create(true)
110            .truncate(true)
111            .open(path)?;
112        f.write_all(bytes)?;
113        f.sync_all()?;
114        Ok(())
115    }
116}
117
118fn ensure_dir(p: &Path) -> CtlResult<()> {
119    fs::create_dir_all(p).map_err(|e| CtlError::usage(format!("mkdir {}: {e}", p.display())))
120}