cellos-ctl 0.5.2

cellctl — kubectl-style CLI for CellOS execution cells and formations. Thin HTTP client over cellos-server with apply/get/describe/logs/events/webui.
Documentation
//! cellctl configuration — persisted at `~/.cellctl/config` (TOML).
//!
//! The on-disk shape is intentionally tiny. cellctl never caches CellOS state
//! locally; the only legitimate client-local state is the server URL, the
//! optional API token, and (eventually, per Session 16) the NATS stream cursor.
//! Anything else would violate the "events are the source of truth" principle.

use std::fs;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::exit::{CtlError, CtlResult};

const DEFAULT_SERVER: &str = "http://127.0.0.1:8080";

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
    /// Base URL of cellos-server (e.g. `https://cellos.example.com`).
    pub server_url: Option<String>,
    /// Bearer token presented in `Authorization: Bearer <token>` on every request.
    pub api_token: Option<String>,
}

impl Config {
    /// Effective server URL: explicit config > $CELLCTL_SERVER > default loopback.
    pub fn effective_server(&self) -> String {
        self.server_url
            .clone()
            .or_else(|| std::env::var("CELLCTL_SERVER").ok())
            .unwrap_or_else(|| DEFAULT_SERVER.to_string())
    }

    /// Effective token: explicit config > $CELLCTL_TOKEN > none.
    pub fn effective_token(&self) -> Option<String> {
        self.api_token
            .clone()
            .or_else(|| std::env::var("CELLCTL_TOKEN").ok())
    }
}

/// Resolve the config file path, honoring `$CELLCTL_CONFIG` and `$XDG_CONFIG_HOME`.
pub fn config_path() -> CtlResult<PathBuf> {
    if let Ok(explicit) = std::env::var("CELLCTL_CONFIG") {
        return Ok(PathBuf::from(explicit));
    }
    let home =
        home::home_dir().ok_or_else(|| CtlError::usage("cannot determine home directory"))?;
    Ok(home.join(".cellctl").join("config"))
}

pub fn load() -> CtlResult<Config> {
    let path = config_path()?;
    if !path.exists() {
        return Ok(Config::default());
    }
    let raw = fs::read_to_string(&path)
        .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
    let cfg: Config = toml::from_str(&raw)
        .map_err(|e| CtlError::usage(format!("parse {}: {e}", path.display())))?;
    Ok(cfg)
}

pub fn save(cfg: &Config) -> CtlResult<()> {
    let path = config_path()?;
    if let Some(parent) = path.parent() {
        ensure_dir(parent)?;
    }
    let raw = toml::to_string_pretty(cfg)
        .map_err(|e| CtlError::usage(format!("serialize config: {e}")))?;
    write_owner_only(&path, raw.as_bytes())
        .map_err(|e| CtlError::usage(format!("write {}: {e}", path.display())))?;
    Ok(())
}

/// HIGH-3 (red-team pass B): the cellctl config file holds an API bearer
/// token in plain text. `fs::write` honours the process umask, which on
/// most distros is `0o022`, producing mode `0o644` — world-readable. We
/// open the file explicitly with `mode(0o600)` on Unix and then fall back
/// to `set_permissions` for the pre-existing-file edge case where
/// `OpenOptions::mode` only applies on create.
///
/// On Windows we let the default ACLs apply — the file lands under the
/// user profile directory, which by default inherits restrictive ACLs.
fn write_owner_only(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
    use std::io::Write as _;

    #[cfg(unix)]
    {
        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
        let mut f = fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .mode(0o600)
            .open(path)?;
        f.write_all(bytes)?;
        f.sync_all()?;
        // Belt-and-suspenders: if the file already existed with looser perms,
        // `mode(0o600)` doesn't tighten — fix that with an explicit chmod.
        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
        Ok(())
    }

    #[cfg(not(unix))]
    {
        let mut f = fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .open(path)?;
        f.write_all(bytes)?;
        f.sync_all()?;
        Ok(())
    }
}

fn ensure_dir(p: &Path) -> CtlResult<()> {
    fs::create_dir_all(p).map_err(|e| CtlError::usage(format!("mkdir {}: {e}", p.display())))
}