tsafe-cli 1.0.22

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! Configuration for the 1Password Connect Server HTTP client.
//!
//! Reads `OP_CONNECT_URL` and `OP_CONNECT_TOKEN` from the process environment.
//! When both are present and the URL passes the HTTPS posture check, the Connect
//! path is activated; otherwise `from_env` returns `Err` and the caller falls
//! back to the `op` CLI subprocess.

use super::error::OpConnectError;

/// Runtime config for the 1Password Connect Server client.
///
/// Constructed via `from_env` — there is no CLI flag for the token because
/// passing credentials on the command line exposes them in the process table.
#[derive(Debug, Clone)]
pub struct OpConnectConfig {
    /// Base URL of the Connect Server, e.g. `https://connect.example.com`.
    /// Plain HTTP is accepted only for `localhost` / `127.0.0.1` (local dev).
    pub connect_url: String,

    /// Bearer token issued by the Connect Server.
    pub token: String,
}

impl OpConnectConfig {
    /// Attempt to load Connect config from environment variables.
    ///
    /// Returns `Ok` only when:
    /// - Both `OP_CONNECT_URL` and `OP_CONNECT_TOKEN` are set and non-empty.
    /// - `OP_CONNECT_URL` uses `https://` OR targets localhost.
    ///
    /// When this returns `Err`, the caller should fall back to the `op` CLI path.
    pub fn from_env() -> Result<Self, OpConnectError> {
        let url = std::env::var("OP_CONNECT_URL")
            .map_err(|_| OpConnectError::Config("OP_CONNECT_URL is not set".into()))?;
        if url.is_empty() {
            return Err(OpConnectError::Config(
                "OP_CONNECT_URL is set but empty".into(),
            ));
        }

        let token = std::env::var("OP_CONNECT_TOKEN")
            .map_err(|_| OpConnectError::Config("OP_CONNECT_TOKEN is not set".into()))?;
        if token.is_empty() {
            return Err(OpConnectError::Config(
                "OP_CONNECT_TOKEN is set but empty".into(),
            ));
        }

        // Mirror the HTTPS posture from cmd_vault_pull.rs / tsafe-azure.
        // Plain HTTP is only allowed for local dev endpoints.
        let url = url.trim_end_matches('/').to_string();
        if !url.starts_with("https://")
            && !url.starts_with("http://127.0.0.1")
            && !url.starts_with("http://localhost")
        {
            return Err(OpConnectError::Config(format!(
                "OP_CONNECT_URL must use https:// for remote servers \
                 (plain HTTP is only allowed for localhost); got: {url}"
            )));
        }

        Ok(Self {
            connect_url: url,
            token,
        })
    }
}

// ── tests ──────────────────────────────────────────────────────────────────────

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

    #[test]
    fn connect_config_from_env_requires_both_vars() {
        // Only URL set — must fail.
        temp_env::with_vars(
            [
                ("OP_CONNECT_URL", Some("https://connect.example.com")),
                ("OP_CONNECT_TOKEN", None::<&str>),
            ],
            || {
                let result = OpConnectConfig::from_env();
                assert!(
                    matches!(result, Err(OpConnectError::Config(_))),
                    "expected Config error when token is absent, got: {result:?}"
                );
            },
        );

        // Only token set — must fail.
        temp_env::with_vars(
            [
                ("OP_CONNECT_URL", None::<&str>),
                ("OP_CONNECT_TOKEN", Some("tok")),
            ],
            || {
                let result = OpConnectConfig::from_env();
                assert!(
                    matches!(result, Err(OpConnectError::Config(_))),
                    "expected Config error when URL is absent, got: {result:?}"
                );
            },
        );
    }

    #[test]
    fn connect_config_rejects_http_non_localhost() {
        temp_env::with_vars(
            [
                ("OP_CONNECT_URL", Some("http://remote.example.com")),
                ("OP_CONNECT_TOKEN", Some("tok")),
            ],
            || {
                let result = OpConnectConfig::from_env();
                assert!(
                    matches!(result, Err(OpConnectError::Config(_))),
                    "expected Config error for non-localhost plain HTTP, got: {result:?}"
                );
            },
        );
    }

    #[test]
    fn connect_config_allows_http_localhost() {
        temp_env::with_vars(
            [
                ("OP_CONNECT_URL", Some("http://localhost:8080")),
                ("OP_CONNECT_TOKEN", Some("tok")),
            ],
            || {
                let result = OpConnectConfig::from_env();
                assert!(
                    result.is_ok(),
                    "expected Ok for localhost plain HTTP, got: {result:?}"
                );
                let cfg = result.unwrap();
                assert_eq!(cfg.connect_url, "http://localhost:8080");
                assert_eq!(cfg.token, "tok");
            },
        );
    }

    #[test]
    fn connect_config_allows_http_127_0_0_1() {
        temp_env::with_vars(
            [
                ("OP_CONNECT_URL", Some("http://127.0.0.1:8080")),
                ("OP_CONNECT_TOKEN", Some("tok")),
            ],
            || {
                let result = OpConnectConfig::from_env();
                assert!(
                    result.is_ok(),
                    "expected Ok for 127.0.0.1 plain HTTP, got: {result:?}"
                );
            },
        );
    }

    #[test]
    fn connect_config_allows_https_remote() {
        temp_env::with_vars(
            [
                ("OP_CONNECT_URL", Some("https://connect.example.com")),
                ("OP_CONNECT_TOKEN", Some("my-token")),
            ],
            || {
                let result = OpConnectConfig::from_env();
                assert!(
                    result.is_ok(),
                    "expected Ok for HTTPS remote, got: {result:?}"
                );
                let cfg = result.unwrap();
                // Trailing slash is stripped.
                assert_eq!(cfg.connect_url, "https://connect.example.com");
            },
        );
    }

    #[test]
    fn connect_config_strips_trailing_slash_from_url() {
        temp_env::with_vars(
            [
                ("OP_CONNECT_URL", Some("https://connect.example.com/")),
                ("OP_CONNECT_TOKEN", Some("tok")),
            ],
            || {
                let cfg = OpConnectConfig::from_env().unwrap();
                assert_eq!(cfg.connect_url, "https://connect.example.com");
            },
        );
    }
}