cleanlib-cli 0.1.1

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! 3-tier bearer resolver (cycle-7 Cli6).
//!
//! Per `feedback_never_inline_secrets`: never log/print/embed the bearer.
//! All public surfaces return the tier source as a typed enum, NEVER the
//! bearer value alongside the source — callers receive `(source, secret)`
//! and must redact via the `Display` impl on `BearerSource`.

use std::process::Command;

use anyhow::{Context, Result};

/// Keyring service name — sister of the dispatch §2.5 fixed `service` slug.
/// Per `feedback_never_inline_secrets`: this is a public identifier, not
/// the secret; safe to embed.
pub const KEYRING_SERVICE: &str = "cleanstart.com/cleanlib-enrich";
/// Keyring user/account slot. Single-user CLI today; we may add multi-tenant
/// slots in v0.1.x if customer feedback surfaces multi-bearer use.
pub const KEYRING_USER: &str = "default";

/// Which tier produced the bearer (diagnostic surface — safe to log).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BearerSource {
    /// `CLEANLIB_ENRICH_BEARER` or `CLEANLIBRARY_API_KEY` env var.
    EnvVar,
    /// `keyring-rs` entry `cleanstart.com/cleanlib-enrich`.
    Keyring,
    /// `gcloud auth print-access-token` (best-effort, may use impersonation).
    GcloudImpersonation,
}

impl std::fmt::Display for BearerSource {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = match self {
            BearerSource::EnvVar => "env",
            BearerSource::Keyring => "keyring",
            BearerSource::GcloudImpersonation => "gcloud-impersonation",
        };
        f.write_str(s)
    }
}

/// Resolve a bearer per the 3-tier lookup. Returns `(source, bearer)`.
///
/// IMPORTANT: the caller MUST NOT log the second tuple element. Per
/// `feedback_never_inline_secrets` only the `BearerSource` is safe to surface.
pub fn resolve_bearer() -> Result<(BearerSource, String)> {
    // 1. Env var — Cli6 canonical (CLEANLIB_ENRICH_BEARER) + legacy
    //    cleanlib-client (CLEANLIBRARY_API_KEY) for back-compat.
    if let Ok(b) = std::env::var("CLEANLIB_ENRICH_BEARER") {
        if !b.is_empty() {
            return Ok((BearerSource::EnvVar, b));
        }
    }
    if let Ok(b) = std::env::var("CLEANLIBRARY_API_KEY") {
        if !b.is_empty() {
            return Ok((BearerSource::EnvVar, b));
        }
    }

    // 2. Keyring (cross-platform Keychain / Secret Service / Credential Mgr)
    if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER) {
        if let Ok(b) = entry.get_password() {
            if !b.is_empty() {
                return Ok((BearerSource::Keyring, b));
            }
        }
    }

    // 3. gcloud impersonation — env-var docs that we shell out for an
    //    OAuth access token (CleanStart-internal customer fallback only;
    //    `~/.config/gcloud` must be initialised with ADC + the impersonation
    //    grant). We never invoke `gcloud secrets versions access` from the
    //    end-user CLI — that would be a SM fallback path which dispatch §2.5
    //    explicitly forbids.
    if let Ok(b) = try_gcloud_access_token() {
        if !b.is_empty() {
            return Ok((BearerSource::GcloudImpersonation, b));
        }
    }

    anyhow::bail!(
        "CLIENT_BEARER_MISSING — set CLEANLIB_ENRICH_BEARER, run `cleanlib login`, \
         or configure `gcloud auth application-default login` with impersonation \
         (see docs/cli/auth.md)"
    )
}

/// Best-effort `gcloud auth print-access-token` invocation. Returns the
/// printed token (trimmed) or an error if gcloud is absent / unauthenticated.
fn try_gcloud_access_token() -> Result<String> {
    let output = Command::new("gcloud")
        .args(["auth", "print-access-token"])
        .output()
        .context("gcloud CLI not on PATH")?;
    if !output.status.success() {
        anyhow::bail!("gcloud auth print-access-token failed (rc={})", output.status);
    }
    let token = String::from_utf8(output.stdout)
        .context("gcloud token output is not UTF-8")?
        .trim()
        .to_string();
    Ok(token)
}

/// Store a bearer in keyring under the canonical service slot.
/// Sister of `cleanlib login`; explicit-call so the caller can validate the
/// bearer (e.g., probe `/api/v1/version`) before persisting.
pub fn store_in_keyring(bearer: &str) -> Result<()> {
    let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
        .context("failed to open keyring entry")?;
    entry.set_password(bearer).context("failed to write keyring entry")?;
    Ok(())
}

/// Remove the bearer from keyring; sister of `cleanlib logout`.
pub fn delete_from_keyring() -> Result<()> {
    let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
        .context("failed to open keyring entry")?;
    // Tolerate the not-found case — logout is idempotent.
    match entry.delete_credential() {
        Ok(()) => Ok(()),
        Err(keyring::Error::NoEntry) => Ok(()),
        Err(e) => Err(anyhow::anyhow!(e).context("failed to delete keyring entry")),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn bearer_source_display_does_not_leak_secret() {
        let s = format!("{}", BearerSource::Keyring);
        assert_eq!(s, "keyring");
        // No secret material is ever embedded in the Display impl, by
        // construction.  We assert that the source-name does not collide
        // with anything that looks like a bearer (length, charset).
        assert!(s.len() < 32);
    }

    #[test]
    fn env_var_tier_wins_when_set() {
        // Use a scoped env var so we don't leak across tests.
        // SAFETY: tests are intentionally single-threaded for env mutation.
        // Use the legacy name (CLEANLIBRARY_API_KEY) so we don't disturb
        // CLEANLIB_ENRICH_BEARER which may be set in dev shells.
        let key = "CLEANLIBRARY_API_KEY";
        let prior = std::env::var(key).ok();
        std::env::set_var(key, "test-bearer-do-not-leak");
        let (source, bearer) = resolve_bearer().expect("env tier must resolve");
        assert_eq!(source, BearerSource::EnvVar);
        assert_eq!(bearer, "test-bearer-do-not-leak");
        // Restore prior state.
        match prior {
            Some(v) => std::env::set_var(key, v),
            None => std::env::remove_var(key),
        }
    }

    #[test]
    fn keyring_service_slug_is_stable() {
        assert_eq!(KEYRING_SERVICE, "cleanstart.com/cleanlib-enrich");
    }
}