tsafe-bitwarden 0.1.0

Bitwarden cloud-pull integration for tsafe — secret import via the bw CLI.
Documentation
//! Bitwarden pull configuration.

/// Bitwarden pull configuration.
///
/// Auth uses the `client_credentials` grant against the Bitwarden identity
/// endpoint.  The machine token does **not** carry the master password, so
/// cipher values returned by the REST sync endpoint are E2E-encrypted and
/// cannot be decrypted in-process.  This crate uses the `bw` CLI subprocess
/// to unlock and list items, which handles local decryption after `bw unlock`.
///
/// # Environment variables
///
/// | Variable                  | Description                                     |
/// |---------------------------|-------------------------------------------------|
/// | `TSAFE_BW_CLIENT_ID`      | Bitwarden API client ID (org or personal)       |
/// | `TSAFE_BW_CLIENT_SECRET`  | Bitwarden API client secret                     |
/// | `TSAFE_BW_PASSWORD`       | Master password for `bw unlock`                 |
#[derive(Debug, Clone)]
pub struct BitwConfig {
    /// Bitwarden API base URL (default: `https://api.bitwarden.com`).
    pub api_url: String,
    /// Bitwarden identity base URL (default: `https://identity.bitwarden.com`).
    pub identity_url: String,
    /// OAuth2 client ID.  Maps to `TSAFE_BW_CLIENT_ID` when not set in the manifest.
    pub client_id: String,
    /// OAuth2 client secret.  Maps to `TSAFE_BW_CLIENT_SECRET` when not set in the manifest.
    pub client_secret: String,
}

impl BitwConfig {
    /// Construct from explicit values.
    pub fn new(
        api_url: impl Into<String>,
        identity_url: impl Into<String>,
        client_id: impl Into<String>,
        client_secret: impl Into<String>,
    ) -> Self {
        Self {
            api_url: api_url.into(),
            identity_url: identity_url.into(),
            client_id: client_id.into(),
            client_secret: client_secret.into(),
        }
    }

    /// Construct from environment variables (`TSAFE_BW_CLIENT_ID` / `TSAFE_BW_CLIENT_SECRET`).
    ///
    /// Returns `None` if either variable is unset or empty.
    pub fn from_env() -> Option<Self> {
        let client_id = std::env::var("TSAFE_BW_CLIENT_ID")
            .ok()
            .filter(|s| !s.is_empty())?;
        let client_secret = std::env::var("TSAFE_BW_CLIENT_SECRET")
            .ok()
            .filter(|s| !s.is_empty())?;
        Some(Self {
            api_url: std::env::var("TSAFE_BW_API_URL")
                .unwrap_or_else(|_| "https://api.bitwarden.com".into()),
            identity_url: std::env::var("TSAFE_BW_IDENTITY_URL")
                .unwrap_or_else(|_| "https://identity.bitwarden.com".into()),
            client_id,
            client_secret,
        })
    }

    /// Default cloud API URL.
    pub fn default_api_url() -> &'static str {
        "https://api.bitwarden.com"
    }

    /// Default cloud identity URL.
    pub fn default_identity_url() -> &'static str {
        "https://identity.bitwarden.com"
    }
}

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

    #[test]
    fn new_stores_fields() {
        let cfg = BitwConfig::new(
            "https://api.example.com",
            "https://identity.example.com",
            "org.abc123",
            "secret-xyz",
        );
        assert_eq!(cfg.api_url, "https://api.example.com");
        assert_eq!(cfg.identity_url, "https://identity.example.com");
        assert_eq!(cfg.client_id, "org.abc123");
        assert_eq!(cfg.client_secret, "secret-xyz");
    }

    #[test]
    fn from_env_returns_none_when_vars_absent() {
        temp_env::with_vars(
            [
                ("TSAFE_BW_CLIENT_ID", None::<&str>),
                ("TSAFE_BW_CLIENT_SECRET", None::<&str>),
            ],
            || {
                assert!(BitwConfig::from_env().is_none());
            },
        );
    }

    #[test]
    fn from_env_uses_client_id_and_secret() {
        temp_env::with_vars(
            [
                ("TSAFE_BW_CLIENT_ID", Some("org.testid")),
                ("TSAFE_BW_CLIENT_SECRET", Some("testsecret")),
                ("TSAFE_BW_API_URL", None::<&str>),
                ("TSAFE_BW_IDENTITY_URL", None::<&str>),
            ],
            || {
                let cfg = BitwConfig::from_env().expect("should build from env");
                assert_eq!(cfg.client_id, "org.testid");
                assert_eq!(cfg.client_secret, "testsecret");
                assert_eq!(cfg.api_url, "https://api.bitwarden.com");
                assert_eq!(cfg.identity_url, "https://identity.bitwarden.com");
            },
        );
    }

    #[test]
    fn from_env_uses_custom_urls_when_set() {
        temp_env::with_vars(
            [
                ("TSAFE_BW_CLIENT_ID", Some("myid")),
                ("TSAFE_BW_CLIENT_SECRET", Some("mysecret")),
                ("TSAFE_BW_API_URL", Some("https://vaultwarden.internal/api")),
                (
                    "TSAFE_BW_IDENTITY_URL",
                    Some("https://vaultwarden.internal/identity"),
                ),
            ],
            || {
                let cfg = BitwConfig::from_env().expect("should build from env");
                assert_eq!(cfg.api_url, "https://vaultwarden.internal/api");
                assert_eq!(cfg.identity_url, "https://vaultwarden.internal/identity");
            },
        );
    }
}