openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Credential storage for OpenLatch API keys.
//!
//! Provides a [`CredentialStore`] trait with three implementations:
//! - [`KeyringCredentialStore`] — OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
//! - [`FileCredentialStore`] — AES-256-GCM encrypted file fallback for headless environments
//! - [`InMemoryCredentialStore`] — In-memory store for testing
//!
//! The fallback chain (per D-06): OS keychain -> OPENLATCH_TOKEN env var -> encrypted file.

pub mod binding_secrets;
pub mod file;
pub mod keyring;
pub mod memory;

use secrecy::SecretString;

use crate::error::OlError;

/// Credential storage abstraction (per D-05).
///
/// All implementations must be Send + Sync for use in async contexts.
pub trait CredentialStore: Send + Sync {
    /// Store an API key. Overwrites any existing key.
    fn store(&self, key: SecretString) -> Result<(), OlError>;

    /// Retrieve the stored API key.
    fn retrieve(&self) -> Result<SecretString, OlError>;

    /// Delete the stored API key. No-op if no key exists.
    fn delete(&self) -> Result<(), OlError>;
}

// Re-export error constants from crate::error for use within this leaf module.
pub(crate) use crate::error::{
    OL_4200_TOKEN_EXPIRED, OL_4201_KEYRING_UNAVAILABLE, OL_4204_TOKEN_FILE_UNREADABLE,
};

/// Standard "what to do when the keychain is unavailable" suggestion. Used by
/// both the keyring backend (when the platform refuses access) and the file
/// fallback (when the encrypted-file path also blows up).
pub fn keychain_suggestion() -> String {
    "If running on headless Linux, install gnome-keyring-daemon, kwallet, or \
     `keepassxc`; or set OPENLATCH_PROVIDER_SKIP_KEYRING=1 to use the encrypted \
     file fallback at ~/.openlatch/provider/auth.toml."
        .to_string()
}

/// Retrieve a credential using the fallback chain (per D-06):
/// 1. Try the primary store (OS keychain)
/// 2. Check OPENLATCH_TOKEN env var
/// 3. Try the fallback store (encrypted file)
///
/// Returns the first successful result, or an error if all fail.
pub fn retrieve_credential(
    primary: &dyn CredentialStore,
    fallback: &dyn CredentialStore,
) -> Result<SecretString, OlError> {
    // Step 1: primary store (keyring)
    if let Ok(key) = primary.retrieve() {
        return Ok(key);
    }

    // Step 2: env var override (per CRED-02)
    if let Ok(val) = std::env::var("OPENLATCH_TOKEN") {
        if !val.is_empty() {
            return Ok(SecretString::from(val));
        }
    }

    // Step 3: fallback store (encrypted file)
    if let Ok(key) = fallback.retrieve() {
        return Ok(key);
    }

    Err(OlError::new(
        OL_4200_TOKEN_EXPIRED,
        "No API key found in keychain, OPENLATCH_TOKEN env var, or encrypted file",
    )
    .with_suggestion("Run 'openlatch-provider login' to authenticate, or set OPENLATCH_TOKEN."))
}

pub use self::file::FileCredentialStore;
pub use self::keyring::KeyringCredentialStore;
pub use self::memory::InMemoryCredentialStore;

/// `CredentialStore` that composes the full fallback chain (keyring -> env -> file)
/// behind a single `retrieve()` call, so the daemon can hand one store to the
/// cloud worker without caring which source holds the key.
///
/// `store()` and `delete()` target the primary (keyring) store only — mutations
/// are the `auth` command's job, not the daemon's.
pub struct FallbackCredentialStore {
    primary: Box<dyn CredentialStore>,
    fallback: Box<dyn CredentialStore>,
}

impl FallbackCredentialStore {
    pub fn new(primary: Box<dyn CredentialStore>, fallback: Box<dyn CredentialStore>) -> Self {
        Self { primary, fallback }
    }
}

impl CredentialStore for FallbackCredentialStore {
    fn store(&self, key: SecretString) -> Result<(), OlError> {
        self.primary.store(key)
    }

    fn retrieve(&self) -> Result<SecretString, OlError> {
        retrieve_credential(self.primary.as_ref(), self.fallback.as_ref())
    }

    fn delete(&self) -> Result<(), OlError> {
        self.primary.delete()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::auth::memory::InMemoryCredentialStore;
    use secrecy::ExposeSecret;

    #[test]
    fn test_retrieve_credential_uses_primary_first() {
        let primary = InMemoryCredentialStore::new();
        primary
            .store(SecretString::from("primary-key".to_string()))
            .unwrap();
        let fallback = InMemoryCredentialStore::new();
        fallback
            .store(SecretString::from("fallback-key".to_string()))
            .unwrap();

        let result = retrieve_credential(&primary, &fallback).unwrap();
        assert_eq!(result.expose_secret(), "primary-key");
    }

    #[test]
    #[ignore = "reads OPENLATCH_TOKEN env var; not safe in dev environments where it's set"]
    fn test_retrieve_credential_falls_through_to_fallback_when_primary_empty() {
        let primary = InMemoryCredentialStore::new();
        let fallback = InMemoryCredentialStore::new();
        fallback
            .store(SecretString::from("fallback-key".to_string()))
            .unwrap();

        let result = retrieve_credential(&primary, &fallback).unwrap();
        assert_eq!(result.expose_secret(), "fallback-key");
    }

    #[test]
    #[ignore = "reads OPENLATCH_TOKEN env var; not safe in dev environments where it's set"]
    fn test_retrieve_credential_returns_err_when_all_empty() {
        let primary = InMemoryCredentialStore::new();
        let fallback = InMemoryCredentialStore::new();

        let result = retrieve_credential(&primary, &fallback);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert_eq!(err.code, OL_4200_TOKEN_EXPIRED);
    }

    #[test]
    fn test_secret_string_debug_does_not_leak_value() {
        let secret = SecretString::from("my-api-key".to_string());
        let debug_output = format!("{:?}", secret);
        assert!(
            !debug_output.contains("my-api-key"),
            "SecretString Debug output must not contain the actual secret: {debug_output}"
        );
    }

    #[test]
    #[ignore = "reads OPENLATCH_TOKEN env var; not safe in dev environments where it's set"]
    fn test_retrieve_credential_env_var_override() {
        // NOTE: This test mutates process-wide env vars and is not safe to run
        // in parallel with other tests that read OPENLATCH_TOKEN.
        // We test env var behavior by calling retrieve_credential directly with
        // a pre-populated env var, using a unique var name for isolation.
        //
        // Actual env var integration is tested via E2E smoke tests in the CI pipeline.
        // Here we verify the fallback chain logic only.
        //
        // The env var step is exercised implicitly by test_retrieve_credential_falls_through
        // (when env var is absent, it falls through to the fallback store).
        // The positive env var case would require unsafe env mutation or a test-only seam.
        //
        // This is a no-op placeholder to document the decision.
        let primary = InMemoryCredentialStore::new();
        let fallback = InMemoryCredentialStore::new();
        // When no env var is set, all three sources are empty → OL-1600
        let result = retrieve_credential(&primary, &fallback);
        assert!(result.is_err());
        assert_eq!(result.unwrap_err().code, OL_4200_TOKEN_EXPIRED);
    }
}