securegit 0.8.5

Zero-trust git replacement with 12 built-in security scanners, LLM redteam bridge, universal undo, durable backups, and a 50-tool MCP server
Documentation
use crate::auth::secure_string::SecureString;
use tracing::debug;

/// Token sources checked in priority order.
const TOKEN_ENV_VARS: &[(&str, &str)] = &[
    ("SECUREGIT_TOKEN", "SecureGit"),
    ("GITHUB_TOKEN", "GitHub"),
    ("GH_TOKEN", "GitHub CLI"),
    ("GITLAB_TOKEN", "GitLab"),
    ("GITLAB_CI_JOB_TOKEN", "GitLab CI"),
    ("BITBUCKET_TOKEN", "Bitbucket"),
];

/// Discover authentication tokens from environment variables.
pub fn discover_token() -> Option<(SecureString, &'static str)> {
    for (var, source) in TOKEN_ENV_VARS {
        if let Ok(val) = std::env::var(var) {
            if !val.is_empty() {
                debug!("Found token from {} ({})", source, var);
                return Some((SecureString::from_string(val), source));
            }
        }
    }
    None
}

/// Get a token for a specific provider.
/// Checks: env vars first, then stored credentials (from `securegit auth login`).
pub fn token_for_host(host: &str) -> Option<SecureString> {
    // 1. Try environment variables
    let env_token = match host {
        h if h.contains("github") => std::env::var("GITHUB_TOKEN")
            .or_else(|_| std::env::var("GH_TOKEN"))
            .or_else(|_| std::env::var("SECUREGIT_TOKEN"))
            .ok()
            .filter(|s| !s.is_empty())
            .map(SecureString::from_string),
        h if h.contains("gitlab") => std::env::var("GITLAB_TOKEN")
            .or_else(|_| std::env::var("GITLAB_CI_JOB_TOKEN"))
            .or_else(|_| std::env::var("SECUREGIT_TOKEN"))
            .ok()
            .filter(|s| !s.is_empty())
            .map(SecureString::from_string),
        h if h.contains("bitbucket") => std::env::var("BITBUCKET_TOKEN")
            .or_else(|_| std::env::var("SECUREGIT_TOKEN"))
            .ok()
            .filter(|s| !s.is_empty())
            .map(SecureString::from_string),
        _ => std::env::var("SECUREGIT_TOKEN")
            .ok()
            .filter(|s| !s.is_empty())
            .map(SecureString::from_string),
    };

    if env_token.is_some() {
        return env_token;
    }

    // 2. Try stored credentials (from `securegit auth login`)
    crate::auth::store::get_token(host)
}

/// Get a token for a named server config.
/// Priority: env var → stored credential → host-based fallback.
pub fn token_for_server(
    server: &crate::platform::server_registry::ServerConfig,
) -> Option<SecureString> {
    // 1. SECUREGIT_SERVER_<UPPER_NAME>_TOKEN env var
    let env_key = format!(
        "SECUREGIT_SERVER_{}_TOKEN",
        server.name.to_uppercase().replace('-', "_")
    );
    if let Ok(val) = std::env::var(&env_key) {
        if !val.is_empty() {
            debug!(
                "Found token for server '{}' from env var {}",
                server.name, env_key
            );
            return Some(SecureString::from_string(val));
        }
    }

    // 2. Stored credential keyed "server:<name>"
    let store_key = format!("server:{}", server.name);
    if let Some(token) = crate::auth::store::get_token(&store_key) {
        debug!("Found stored token for server '{}'", server.name);
        return Some(token);
    }

    // 3. Fall back to host-based resolution
    if let Ok(url) = url::Url::parse(&server.api_url) {
        if let Some(host) = url.host_str() {
            debug!(
                "Falling back to host-based token resolution for server '{}' (host: {})",
                server.name, host
            );
            return token_for_host(host);
        }
    }

    None
}

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

    /// Helper: clear all token-related env vars so tests start from a known state.
    fn clear_token_env_vars() {
        for var in &[
            "SECUREGIT_TOKEN",
            "GITHUB_TOKEN",
            "GH_TOKEN",
            "GITLAB_TOKEN",
            "GITLAB_CI_JOB_TOKEN",
            "BITBUCKET_TOKEN",
        ] {
            std::env::remove_var(var);
        }
    }

    #[test]
    #[serial]
    fn test_resolve_token_github_env() {
        clear_token_env_vars();
        std::env::set_var("GITHUB_TOKEN", "ghp_test_github_token");

        let result = discover_token();
        assert!(result.is_some(), "Should discover GITHUB_TOKEN");
        let (token, source) = result.unwrap();
        assert_eq!(token.as_str(), "ghp_test_github_token");
        assert_eq!(source, "GitHub");

        clear_token_env_vars();
    }

    #[test]
    #[serial]
    fn test_resolve_token_gitlab_env() {
        clear_token_env_vars();
        std::env::set_var("GITLAB_TOKEN", "glpat-test_gitlab_token");

        let result = discover_token();
        assert!(result.is_some(), "Should discover GITLAB_TOKEN");
        let (token, source) = result.unwrap();
        assert_eq!(token.as_str(), "glpat-test_gitlab_token");
        assert_eq!(source, "GitLab");

        clear_token_env_vars();
    }

    #[test]
    #[serial]
    fn test_resolve_token_missing() {
        clear_token_env_vars();

        let result = discover_token();
        assert!(
            result.is_none(),
            "Should return None when no env vars are set"
        );
    }

    #[test]
    #[serial]
    fn test_resolve_token_empty_value_skipped() {
        clear_token_env_vars();
        std::env::set_var("GITHUB_TOKEN", "");

        let result = discover_token();
        assert!(result.is_none(), "Empty env var should be skipped");

        clear_token_env_vars();
    }

    #[test]
    #[serial]
    fn test_discover_token_priority_order() {
        clear_token_env_vars();
        // SECUREGIT_TOKEN has highest priority in TOKEN_ENV_VARS
        std::env::set_var("SECUREGIT_TOKEN", "securegit-first");
        std::env::set_var("GITHUB_TOKEN", "github-second");

        let result = discover_token();
        assert!(result.is_some());
        let (token, source) = result.unwrap();
        assert_eq!(token.as_str(), "securegit-first");
        assert_eq!(source, "SecureGit");

        clear_token_env_vars();
    }

    #[test]
    #[serial]
    fn test_token_for_host_github_env() {
        clear_token_env_vars();
        std::env::set_var("GITHUB_TOKEN", "ghp_for_github_host");

        let result = token_for_host("github.com");
        assert!(result.is_some());
        assert_eq!(result.unwrap().as_str(), "ghp_for_github_host");

        clear_token_env_vars();
    }

    #[test]
    #[serial]
    fn test_token_for_host_gitlab_env() {
        clear_token_env_vars();
        std::env::set_var("GITLAB_TOKEN", "glpat-for_gitlab_host");

        let result = token_for_host("gitlab.com");
        assert!(result.is_some());
        assert_eq!(result.unwrap().as_str(), "glpat-for_gitlab_host");

        clear_token_env_vars();
    }

    #[test]
    #[serial]
    fn test_token_for_host_bitbucket_env() {
        clear_token_env_vars();
        std::env::set_var("BITBUCKET_TOKEN", "bb_for_bitbucket_host");

        let result = token_for_host("bitbucket.org");
        assert!(result.is_some());
        assert_eq!(result.unwrap().as_str(), "bb_for_bitbucket_host");

        clear_token_env_vars();
    }

    #[test]
    #[serial]
    fn test_token_for_host_securegit_fallback() {
        clear_token_env_vars();
        std::env::set_var("SECUREGIT_TOKEN", "securegit-universal");

        // For an unknown host, SECUREGIT_TOKEN should be the fallback
        let result = token_for_host("custom-git.example.com");
        assert!(result.is_some());
        assert_eq!(result.unwrap().as_str(), "securegit-universal");

        clear_token_env_vars();
    }

    #[test]
    #[serial]
    fn test_token_for_host_gh_token_fallback() {
        clear_token_env_vars();
        // GH_TOKEN is the second env var checked for github hosts
        std::env::set_var("GH_TOKEN", "gh_cli_token");

        let result = token_for_host("github.com");
        assert!(result.is_some());
        assert_eq!(result.unwrap().as_str(), "gh_cli_token");

        clear_token_env_vars();
    }

    #[test]
    #[serial]
    fn test_token_for_host_missing_returns_none() {
        clear_token_env_vars();

        // With no env vars and no stored credentials, should return None
        // (store::get_token may return something if the user has stored creds,
        // but in a clean test environment it typically won't)
        let result = token_for_host("nonexistent-host.example.com");
        // We can only assert about the env path; stored creds depend on user's machine
        // So just verify the function doesn't panic
        let _ = result;
    }

    // ── token_for_server tests ──────────────────────────────────────

    fn clear_server_env_vars() {
        clear_token_env_vars();
        // Also clear any server-specific env vars we might set
        for var in &[
            "SECUREGIT_SERVER_MY_GITLAB_TOKEN",
            "SECUREGIT_SERVER_TEST_SERVER_TOKEN",
        ] {
            std::env::remove_var(var);
        }
    }

    fn make_server_config(
        name: &str,
        api_url: &str,
    ) -> crate::platform::server_registry::ServerConfig {
        crate::platform::server_registry::ServerConfig {
            name: name.to_string(),
            platform: crate::platform::server_registry::ServerPlatform::GitLab,
            api_url: api_url.to_string(),
            web_url: None,
            push_enabled: false,
        }
    }

    #[test]
    #[serial]
    fn test_token_for_server_env_var_priority() {
        clear_server_env_vars();
        std::env::set_var("SECUREGIT_SERVER_MY_GITLAB_TOKEN", "env-server-token");

        let server = make_server_config("my-gitlab", "https://gitlab.example.com/api/v4");
        let result = token_for_server(&server);
        assert!(result.is_some());
        assert_eq!(result.unwrap().as_str(), "env-server-token");

        clear_server_env_vars();
    }

    #[test]
    #[serial]
    fn test_token_for_server_host_fallback() {
        clear_server_env_vars();
        // Set a gitlab env var which token_for_host("gitlab.example.com") should find
        // since the host contains "gitlab"
        std::env::set_var("GITLAB_TOKEN", "gitlab-host-fallback");

        let server = make_server_config("test-server", "https://gitlab.example.com/api/v4");
        let result = token_for_server(&server);
        assert!(result.is_some());
        assert_eq!(result.unwrap().as_str(), "gitlab-host-fallback");

        clear_server_env_vars();
    }

    #[test]
    #[serial]
    fn test_token_for_server_env_beats_host_fallback() {
        clear_server_env_vars();
        std::env::set_var("SECUREGIT_SERVER_TEST_SERVER_TOKEN", "specific-env");
        std::env::set_var("GITLAB_TOKEN", "generic-gitlab");

        let server = make_server_config("test-server", "https://gitlab.example.com/api/v4");
        let result = token_for_server(&server);
        assert!(result.is_some());
        assert_eq!(result.unwrap().as_str(), "specific-env");

        clear_server_env_vars();
    }
}