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#[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 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 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 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 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 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 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 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 assert_eq!(
265 shown,
266 r#"Credentials { email: "dev@example.com", token: "<redacted>" }"#
267 );
268 assert!(!shown.contains("leaky"), "token leaked: {shown}");
269 }
270}