Skip to main content

atlassian_cli_auth/
lib.rs

1use 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
13/// Bitbucket API base URL.
14pub const BITBUCKET_API_URL: &str = "https://api.bitbucket.org";
15
16/// Helper to construct a key for profile secrets.
17pub fn token_key(profile: &str) -> String {
18    profile.to_string()
19}
20
21/// Helper to construct a key for Bitbucket profile secrets.
22pub 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
30/// Store a secret in the credentials file with 600 permissions.
31pub 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
75/// Get a secret from the credentials file.
76pub 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
86/// Delete a secret from the credentials file.
87pub 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
125/// Path to encrypted credentials file
126fn encrypted_credentials_path() -> Option<PathBuf> {
127    dirs::home_dir().map(|h| h.join(".atlassian-cli").join("credentials.enc"))
128}
129
130/// Load encrypted credentials from disk
131fn 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
145/// Save encrypted credentials to disk with 600 permissions
146fn 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
180/// Store an encrypted secret
181pub 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
195/// Get an encrypted secret
196pub 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
210/// Delete an encrypted secret
211pub 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
218/// Migrate plaintext credentials to encrypted storage
219/// Returns the number of credentials migrated
220pub fn migrate_plaintext_to_encrypted() -> Result<usize> {
221    let plaintext_path = credentials_path().context("Cannot determine home directory")?;
222
223    // If plaintext file doesn't exist, nothing to migrate
224    if !plaintext_path.exists() {
225        return Ok(0);
226    }
227
228    // Read plaintext credentials
229    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        // Empty file, just delete it
235        fs::remove_file(&plaintext_path)?;
236        return Ok(0);
237    }
238
239    // Re-save using encrypted storage
240    let count = plaintext_creds.len();
241    for (account, token) in plaintext_creds {
242        set_secret_encrypted(&account, &token)?;
243    }
244
245    // Securely delete old file (overwrite then delete)
246    secure_delete_file(&plaintext_path)?;
247
248    Ok(count)
249}
250
251/// Securely delete a file by overwriting with zeros before removal
252fn secure_delete_file(path: &std::path::Path) -> Result<()> {
253    // Get file size
254    let metadata = fs::metadata(path)?;
255    let file_size = metadata.len() as usize;
256
257    // Overwrite with zeros
258    let zeros = vec![0u8; file_size];
259    fs::write(path, zeros)?;
260
261    // Now delete
262    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        // Store encrypted
294        set_secret_encrypted(account, secret).expect("Failed to set encrypted secret");
295
296        // Retrieve encrypted
297        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        // Clean up
304        delete_secret_encrypted(account).expect("Failed to delete encrypted secret");
305
306        // Verify deletion
307        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        // Test migration when no plaintext file exists
322        // Note: This test might find existing credentials on a real system,
323        // so we just verify it succeeds (doesn't panic)
324        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}