logicaffeine-cli 0.9.16

CLI tool for logicaffeine (largo)
Documentation
//! Phase 39: Credential Management
//!
//! Stores and retrieves API tokens for the package registry.
//!
//! Credentials are stored in a TOML file at `~/.config/logos/credentials.toml`
//! with restrictive permissions (0600 on Unix) to protect sensitive tokens.
//!
//! # Token Resolution Order
//!
//! When retrieving a token via [`get_token`], the following order is used:
//! 1. `LOGOS_TOKEN` environment variable (highest priority)
//! 2. Credentials file entry for the registry URL
//!
//! # Security
//!
//! - Tokens are stored in plaintext (like cargo, npm, etc.)
//! - File permissions are set to owner-only on Unix systems
//! - The `LOGOS_CREDENTIALS_PATH` env var can override the default location

use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

/// Persistent storage for registry authentication tokens.
///
/// Tokens are stored per-registry URL, allowing authentication with
/// multiple registries simultaneously.
///
/// # File Format
///
/// ```toml
/// [registries]
/// "https://registry.logicaffeine.com" = "tok_xxxxx"
/// ```
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Credentials {
    /// Map of registry URL to authentication token.
    #[serde(default)]
    pub registries: HashMap<String, String>,
}

impl Credentials {
    /// Load credentials from the default location
    pub fn load() -> Result<Self, CredentialsError> {
        let path = credentials_path().ok_or(CredentialsError::NoConfigDir)?;

        if !path.exists() {
            return Ok(Self::default());
        }

        let content = fs::read_to_string(&path)
            .map_err(|e| CredentialsError::Io(e.to_string()))?;

        toml::from_str(&content)
            .map_err(|e| CredentialsError::Parse(e.to_string()))
    }

    /// Save credentials to the default location
    pub fn save(&self) -> Result<(), CredentialsError> {
        let path = credentials_path().ok_or(CredentialsError::NoConfigDir)?;

        // Create parent directory if needed
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .map_err(|e| CredentialsError::Io(e.to_string()))?;
        }

        let content = toml::to_string_pretty(self)
            .map_err(|e| CredentialsError::Serialize(e.to_string()))?;

        fs::write(&path, content)
            .map_err(|e| CredentialsError::Io(e.to_string()))?;

        // Set restrictive permissions on Unix
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let perms = std::fs::Permissions::from_mode(0o600);
            fs::set_permissions(&path, perms)
                .map_err(|e| CredentialsError::Io(e.to_string()))?;
        }

        Ok(())
    }

    /// Get token for a registry
    pub fn get_token(&self, registry_url: &str) -> Option<&str> {
        self.registries.get(registry_url).map(|s| s.as_str())
    }

    /// Set token for a registry
    pub fn set_token(&mut self, registry_url: &str, token: &str) {
        self.registries.insert(registry_url.to_string(), token.to_string());
    }

    /// Remove token for a registry
    pub fn remove_token(&mut self, registry_url: &str) {
        self.registries.remove(registry_url);
    }
}

/// Get the token for a registry, checking env var first then credentials file
pub fn get_token(registry_url: &str) -> Option<String> {
    // Check LOGOS_TOKEN env var first
    if let Ok(token) = std::env::var("LOGOS_TOKEN") {
        if !token.is_empty() {
            return Some(token);
        }
    }

    // Fall back to credentials file
    Credentials::load()
        .ok()
        .and_then(|c| c.get_token(registry_url).map(String::from))
}

/// Get the path to the credentials file
pub fn credentials_path() -> Option<PathBuf> {
    // Check LOGOS_CREDENTIALS_PATH env var first
    if let Ok(path) = std::env::var("LOGOS_CREDENTIALS_PATH") {
        return Some(PathBuf::from(path));
    }

    // Use standard config directory
    dirs::config_dir().map(|p| p.join("logos").join("credentials.toml"))
}

/// Errors that can occur when loading or saving credentials.
#[derive(Debug)]
pub enum CredentialsError {
    /// Could not determine the config directory (e.g., `$HOME` not set).
    NoConfigDir,
    /// File system operation failed.
    Io(String),
    /// Failed to parse the credentials TOML file.
    Parse(String),
    /// Failed to serialize credentials to TOML.
    Serialize(String),
}

impl std::fmt::Display for CredentialsError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NoConfigDir => write!(f, "Could not determine config directory"),
            Self::Io(e) => write!(f, "I/O error: {}", e),
            Self::Parse(e) => write!(f, "Failed to parse credentials: {}", e),
            Self::Serialize(e) => write!(f, "Failed to serialize credentials: {}", e),
        }
    }
}

impl std::error::Error for CredentialsError {}