Skip to main content

bb_cli/
credentials.rs

1use crate::error::{BbError, Result};
2use crate::secret::{ExposeSecret, SecretString};
3use std::path::PathBuf;
4
5const KEYRING_SERVICE: &str = "bb-cli";
6const KEYRING_USER: &str = "bitbucket-api-token";
7
8/// Email plus API token. `Debug` deliberately omits the token.
9#[derive(Clone)]
10pub struct Credentials {
11    pub email: String,
12    pub token: SecretString,
13}
14
15impl std::fmt::Debug for Credentials {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        f.debug_struct("Credentials")
18            .field("email", &self.email)
19            .field("token", &"<redacted>")
20            .finish()
21    }
22}
23
24impl Credentials {
25    pub fn basic_header(&self) -> SecretString {
26        let raw = format!("{}:{}", self.email, self.token.expose_secret());
27        SecretString::from(format!("Basic {}", base64_encode(raw.as_bytes())))
28    }
29
30    /// Safe-to-print form of the token.
31    pub fn redacted_token(&self) -> String {
32        crate::secret::redact(self.token.expose_secret())
33    }
34}
35
36fn base64_encode(input: &[u8]) -> String {
37    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
38    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
39    for chunk in input.chunks(3) {
40        let b0 = chunk[0] as u32;
41        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
42        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
43        let n = (b0 << 16) | (b1 << 8) | b2;
44        out.push(TABLE[(n >> 18) as usize & 63] as char);
45        out.push(TABLE[(n >> 12) as usize & 63] as char);
46        out.push(if chunk.len() > 1 {
47            TABLE[(n >> 6) as usize & 63] as char
48        } else {
49            '='
50        });
51        out.push(if chunk.len() > 2 {
52            TABLE[n as usize & 63] as char
53        } else {
54            '='
55        });
56    }
57    out
58}
59
60pub fn keyring_entry() -> Option<keyring::Entry> {
61    keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).ok()
62}
63
64fn keyring_email_entry() -> Option<keyring::Entry> {
65    keyring::Entry::new(KEYRING_SERVICE, "bitbucket-email").ok()
66}
67
68pub fn load() -> Result<Credentials> {
69    let env_email = std::env::var("BB_EMAIL")
70        .ok()
71        .filter(|v| !v.trim().is_empty());
72    let env_token = std::env::var("BB_TOKEN")
73        .ok()
74        .filter(|v| !v.trim().is_empty());
75    if let (Some(email), Some(token)) = (env_email, env_token) {
76        return Ok(Credentials {
77            email,
78            token: SecretString::from(token),
79        });
80    }
81
82    if std::env::var("BB_KEYRING_DISABLE").is_ok() {
83        return Err(BbError::Auth);
84    }
85
86    let token = keyring_entry().and_then(|e| e.get_password().ok());
87    let email = keyring_email_entry().and_then(|e| e.get_password().ok());
88    match (email, token) {
89        (Some(email), Some(token)) => Ok(Credentials {
90            email,
91            token: SecretString::from(token),
92        }),
93        _ => Err(BbError::Auth),
94    }
95}
96
97pub fn store(email: &str, token: &SecretString) -> Result<()> {
98    if std::env::var("BB_KEYRING_DISABLE").is_ok() {
99        return Ok(());
100    }
101
102    let token_entry =
103        keyring_entry().ok_or_else(|| BbError::Config("cannot open os keyring".into()))?;
104    let email_entry =
105        keyring_email_entry().ok_or_else(|| BbError::Config("cannot open os keyring".into()))?;
106    token_entry
107        .set_password(token.expose_secret())
108        .map_err(|e| BbError::Config(format!("cannot write token to keyring: {e}")))?;
109    email_entry
110        .set_password(email)
111        .map_err(|e| BbError::Config(format!("cannot write email to keyring: {e}")))?;
112    Ok(())
113}
114
115pub fn delete() -> Result<()> {
116    if std::env::var("BB_KEYRING_DISABLE").is_ok() {
117        return Ok(());
118    }
119
120    for entry in [keyring_entry(), keyring_email_entry()]
121        .into_iter()
122        .flatten()
123    {
124        // A missing entry is not an error for `logout`.
125        let _ = entry.delete_credential();
126    }
127    Ok(())
128}
129
130pub fn legacy_config_path() -> PathBuf {
131    let home = std::env::var("HOME").unwrap_or_default();
132    PathBuf::from(home).join(".bitbucket-rest-cli-config.json")
133}
134
135#[cfg(test)]
136#[allow(clippy::unwrap_used)]
137mod tests {
138    use super::*;
139    use secrecy::SecretString;
140    use serial_test::serial;
141
142    #[test]
143    fn basic_header_encodes_email_and_token() {
144        let creds = Credentials {
145            email: "dev@example.com".into(),
146            token: SecretString::from("s3cr3t"),
147        };
148        // base64("dev@example.com:s3cr3t")
149        assert_eq!(
150            secrecy::ExposeSecret::expose_secret(&creds.basic_header()),
151            "Basic ZGV2QGV4YW1wbGUuY29tOnMzY3IzdA=="
152        );
153    }
154
155    #[test]
156    #[serial]
157    fn env_vars_take_precedence_over_keyring() {
158        std::env::set_var("BB_EMAIL", "env@example.com");
159        std::env::set_var("BB_TOKEN", "envtoken");
160        let creds = load().unwrap();
161        assert_eq!(creds.email, "env@example.com");
162        std::env::remove_var("BB_EMAIL");
163        std::env::remove_var("BB_TOKEN");
164    }
165
166    #[test]
167    #[serial]
168    fn missing_credentials_yield_auth_error() {
169        std::env::remove_var("BB_EMAIL");
170        std::env::remove_var("BB_TOKEN");
171        // `BB_KEYRING_DISABLE` short-circuits the keyring lookup, so this asserts
172        // unconditionally instead of depending on whether the machine running the
173        // test happens to have a stored entry.
174        std::env::set_var("BB_KEYRING_DISABLE", "1");
175        let result = load();
176        std::env::remove_var("BB_KEYRING_DISABLE");
177        assert!(matches!(result, Err(BbError::Auth)), "expected Auth error");
178    }
179
180    /// A credential builder that panics the instant anything tries to construct an
181    /// `Entry` through it. Stands in for "the real OS keyring" for this test: keyring's
182    /// mock store gives each `Entry::new` call independent, unshared storage (see
183    /// `CredentialPersistence::EntryOnly` in `keyring::mock`), so it cannot prove
184    /// `delete()`'s *internal* entries were never touched — this builder can, because
185    /// it fires on construction itself, before any get/set/delete call.
186    struct PanicOnConstruction;
187
188    impl keyring::credential::CredentialBuilderApi for PanicOnConstruction {
189        fn build(
190            &self,
191            _target: Option<&str>,
192            _service: &str,
193            _user: &str,
194        ) -> keyring::Result<Box<keyring::credential::Credential>> {
195            panic!(
196                "delete() constructed a keyring Entry despite BB_KEYRING_DISABLE being set; \
197                 it must return before touching the credential store at all"
198            );
199        }
200
201        fn as_any(&self) -> &dyn std::any::Any {
202            self
203        }
204    }
205
206    #[test]
207    #[serial]
208    fn delete_with_keyring_disabled_never_touches_the_credential_store() {
209        // **IMPORTANT: Global State Mutation (process-wide, no cleanup)**
210        // This test installs a panicking credential builder as the process-global default
211        // via `keyring::set_default_credential_builder()`. The keyring crate does not
212        // provide a public API to retrieve or restore the previous builder, so this
213        // mutation persists for the entire remainder of the test binary.
214        //
215        // Any future test that exercises the real keyring path (i.e., one that calls
216        // `keyring_entry()` or `keyring_email_entry()` when BB_KEYRING_DISABLE is unset)
217        // will panic if it runs after this test. To avoid this:
218        //
219        // 1. Ensure any test needing real keyring access runs BEFORE this test, OR
220        // 2. Ensure such tests account for the panicking builder being installed, OR
221        // 3. Run this test last (e.g., via a separate test suite or final phase).
222        //
223        // The test validates that `delete()` respects BB_KEYRING_DISABLE by confirming
224        // the builder never gets instantiated (it would panic if it did). This is the only
225        // reliable way to prove `delete()` returns early and never touches the keyring.
226        keyring::set_default_credential_builder(Box::new(PanicOnConstruction));
227
228        std::env::set_var("BB_KEYRING_DISABLE", "1");
229        let result = delete();
230        std::env::remove_var("BB_KEYRING_DISABLE");
231
232        assert!(result.is_ok(), "delete() should still report success");
233    }
234
235    #[test]
236    #[serial]
237    fn store_with_keyring_disabled_never_touches_the_credential_store() {
238        // Mirrors `delete_with_keyring_disabled_never_touches_the_credential_store` above:
239        // installs the same panicking builder (idempotent if already installed by that
240        // test) and proves `store()` returns before constructing any keyring `Entry`.
241        keyring::set_default_credential_builder(Box::new(PanicOnConstruction));
242
243        std::env::set_var("BB_KEYRING_DISABLE", "1");
244        let result = store(
245            "dev@example.com",
246            &SecretString::from("s3cr3t-should-never-reach-the-keyring"),
247        );
248        std::env::remove_var("BB_KEYRING_DISABLE");
249
250        assert!(result.is_ok(), "store() should still report success");
251    }
252
253    #[test]
254    fn debug_impl_renders_exactly_the_redacted_shape() {
255        let creds = Credentials {
256            email: "dev@example.com".into(),
257            token: SecretString::from("ATATT_leaky_value"),
258        };
259        let shown = format!("{creds:?}");
260
261        // Pinned exactly: a `#[derive(Debug)]` would render the SecretString's own
262        // Debug (`SecretBox<..>`) instead of this, so this test fails if the
263        // hand-written impl is removed.
264        assert_eq!(
265            shown,
266            r#"Credentials { email: "dev@example.com", token: "<redacted>" }"#
267        );
268        assert!(!shown.contains("leaky"), "token leaked: {shown}");
269    }
270}