atlassian_cli_auth/
lib.rs1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::fs::{self, OpenOptions};
4use std::path::PathBuf;
5use tracing::warn;
6
7#[cfg(unix)]
8use std::os::unix::fs::OpenOptionsExt;
9
10pub mod encryption;
11pub mod secret;
12
13pub const BITBUCKET_API_URL: &str = "https://api.bitbucket.org";
15
16pub fn token_key(profile: &str) -> String {
18 profile.to_string()
19}
20
21pub fn bitbucket_token_key(profile: &str) -> String {
23 format!("{}_bitbucket", profile)
24}
25
26fn credentials_path() -> Option<PathBuf> {
27 dirs::home_dir().map(|h| h.join(".atlassian-cli").join("credentials"))
28}
29
30pub fn set_secret(account: &str, secret: &str) -> Result<()> {
32 let path = credentials_path().context("Cannot determine home directory")?;
33 if let Some(parent) = path.parent() {
34 fs::create_dir_all(parent)?;
35 }
36
37 let mut creds: HashMap<String, String> = if path.exists() {
38 let content = fs::read_to_string(&path)?;
39 serde_json::from_str(&content).unwrap_or_else(|e| {
40 warn!("Failed to parse credentials file: {}", e);
41 HashMap::new()
42 })
43 } else {
44 HashMap::new()
45 };
46
47 creds.insert(account.to_string(), secret.to_string());
48
49 #[cfg(unix)]
50 {
51 use std::io::Write;
52 let mut file = OpenOptions::new()
53 .write(true)
54 .create(true)
55 .truncate(true)
56 .mode(0o600)
57 .open(&path)?;
58 let json = serde_json::to_string_pretty(&creds)?;
59 file.write_all(json.as_bytes())?;
60 }
61
62 #[cfg(not(unix))]
63 {
64 let file = OpenOptions::new()
65 .write(true)
66 .create(true)
67 .truncate(true)
68 .open(&path)?;
69 serde_json::to_writer_pretty(file, &creds)?;
70 }
71
72 Ok(())
73}
74
75pub fn get_secret(account: &str) -> Result<Option<String>> {
77 let path = credentials_path().context("Cannot determine home directory")?;
78 if !path.exists() {
79 return Ok(None);
80 }
81 let content = fs::read_to_string(&path)?;
82 let creds: HashMap<String, String> = serde_json::from_str(&content)?;
83 Ok(creds.get(account).cloned())
84}
85
86pub fn delete_secret(account: &str) -> Result<()> {
88 let path = credentials_path().context("Cannot determine home directory")?;
89 if !path.exists() {
90 return Ok(());
91 }
92 let content = fs::read_to_string(&path)?;
93 let mut creds: HashMap<String, String> = serde_json::from_str(&content).unwrap_or_else(|e| {
94 warn!("Failed to parse credentials file: {}", e);
95 HashMap::new()
96 });
97 creds.remove(account);
98
99 #[cfg(unix)]
100 {
101 use std::io::Write;
102 let mut file = OpenOptions::new()
103 .write(true)
104 .create(true)
105 .truncate(true)
106 .mode(0o600)
107 .open(&path)?;
108 let json = serde_json::to_string_pretty(&creds)?;
109 file.write_all(json.as_bytes())?;
110 }
111
112 #[cfg(not(unix))]
113 {
114 let file = OpenOptions::new()
115 .write(true)
116 .create(true)
117 .truncate(true)
118 .open(&path)?;
119 serde_json::to_writer_pretty(file, &creds)?;
120 }
121
122 Ok(())
123}
124
125fn encrypted_credentials_path() -> Option<PathBuf> {
127 dirs::home_dir().map(|h| h.join(".atlassian-cli").join("credentials.enc"))
128}
129
130fn load_encrypted_credentials() -> Result<encryption::EncryptedCredentials> {
132 let path = encrypted_credentials_path().context("Cannot determine home directory")?;
133
134 if !path.exists() {
135 return Ok(encryption::EncryptedCredentials::default());
136 }
137
138 let content = fs::read_to_string(&path)?;
139 let creds: encryption::EncryptedCredentials =
140 serde_json::from_str(&content).context("Failed to parse encrypted credentials file")?;
141
142 Ok(creds)
143}
144
145fn save_encrypted_credentials(creds: &encryption::EncryptedCredentials) -> Result<()> {
147 let path = encrypted_credentials_path().context("Cannot determine home directory")?;
148
149 if let Some(parent) = path.parent() {
150 fs::create_dir_all(parent)?;
151 }
152
153 let json = serde_json::to_string_pretty(creds)?;
154
155 #[cfg(unix)]
156 {
157 use std::io::Write;
158 let mut file = OpenOptions::new()
159 .write(true)
160 .create(true)
161 .truncate(true)
162 .mode(0o600)
163 .open(&path)?;
164 file.write_all(json.as_bytes())?;
165 }
166
167 #[cfg(not(unix))]
168 {
169 let file = OpenOptions::new()
170 .write(true)
171 .create(true)
172 .truncate(true)
173 .open(&path)?;
174 std::io::Write::write_all(&mut std::io::BufWriter::new(file), json.as_bytes())?;
175 }
176
177 Ok(())
178}
179
180pub fn set_secret_encrypted(account: &str, secret: &str) -> Result<()> {
182 let key = encryption::derive_key()?;
183 let (nonce, ciphertext) = encryption::encrypt(secret, &key)?;
184
185 let mut creds = load_encrypted_credentials()?;
186 creds.credentials.insert(
187 account.to_string(),
188 encryption::EncryptedToken { nonce, ciphertext },
189 );
190
191 save_encrypted_credentials(&creds)?;
192 Ok(())
193}
194
195pub fn get_secret_encrypted(account: &str) -> Result<Option<String>> {
197 let creds = load_encrypted_credentials()?;
198
199 let encrypted_token = match creds.credentials.get(account) {
200 Some(token) => token,
201 None => return Ok(None),
202 };
203
204 let key = encryption::derive_key()?;
205 let plaintext = encryption::decrypt(&encrypted_token.ciphertext, &encrypted_token.nonce, &key)?;
206
207 Ok(Some(plaintext))
208}
209
210pub fn delete_secret_encrypted(account: &str) -> Result<()> {
212 let mut creds = load_encrypted_credentials()?;
213 creds.credentials.remove(account);
214 save_encrypted_credentials(&creds)?;
215 Ok(())
216}
217
218pub fn migrate_plaintext_to_encrypted() -> Result<usize> {
221 let plaintext_path = credentials_path().context("Cannot determine home directory")?;
222
223 if !plaintext_path.exists() {
225 return Ok(0);
226 }
227
228 let content = fs::read_to_string(&plaintext_path)?;
230 let plaintext_creds: HashMap<String, String> =
231 serde_json::from_str(&content).context("Failed to parse plaintext credentials file")?;
232
233 if plaintext_creds.is_empty() {
234 fs::remove_file(&plaintext_path)?;
236 return Ok(0);
237 }
238
239 let count = plaintext_creds.len();
241 for (account, token) in plaintext_creds {
242 set_secret_encrypted(&account, &token)?;
243 }
244
245 secure_delete_file(&plaintext_path)?;
247
248 Ok(count)
249}
250
251fn secure_delete_file(path: &std::path::Path) -> Result<()> {
253 let metadata = fs::metadata(path)?;
255 let file_size = metadata.len() as usize;
256
257 let zeros = vec![0u8; file_size];
259 fs::write(path, zeros)?;
260
261 fs::remove_file(path)?;
263
264 Ok(())
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn test_token_key() {
273 assert_eq!(token_key("work"), "work");
274 assert_eq!(token_key("my-profile"), "my-profile");
275 }
276
277 #[test]
278 fn test_bitbucket_token_key() {
279 assert_eq!(bitbucket_token_key("work"), "work_bitbucket");
280 assert_eq!(bitbucket_token_key("my-profile"), "my-profile_bitbucket");
281 }
282
283 #[test]
284 fn test_bitbucket_api_url() {
285 assert_eq!(BITBUCKET_API_URL, "https://api.bitbucket.org");
286 }
287
288 #[test]
289 fn test_encrypted_storage_roundtrip() {
290 let account = "test_account_roundtrip";
291 let secret = "test_secret_value_12345";
292
293 set_secret_encrypted(account, secret).expect("Failed to set encrypted secret");
295
296 let retrieved = get_secret_encrypted(account)
298 .expect("Failed to get encrypted secret")
299 .expect("Secret should exist");
300
301 assert_eq!(retrieved, secret, "Retrieved secret should match original");
302
303 delete_secret_encrypted(account).expect("Failed to delete encrypted secret");
305
306 let after_delete = get_secret_encrypted(account).expect("Failed to check after delete");
308 assert!(after_delete.is_none(), "Secret should be deleted");
309 }
310
311 #[test]
312 fn test_encrypted_storage_nonexistent() {
313 let result = get_secret_encrypted("nonexistent_account_xyz")
314 .expect("Should succeed even if not found");
315
316 assert!(result.is_none(), "Non-existent account should return None");
317 }
318
319 #[test]
320 fn test_migration_no_plaintext_file() {
321 let result = migrate_plaintext_to_encrypted();
325
326 assert!(
327 result.is_ok(),
328 "Migration should succeed even when file doesn't exist or has existing creds"
329 );
330 }
331}