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::{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/// Write a file only its owner can read.
17///
18/// Via a temporary file in the same directory, then a rename. `OpenOptions::mode`
19/// applies only when a file is created, so writing in place would leave an
20/// existing 0644 credentials file world-readable. The rename also means a reader
21/// never sees a half-written file, which truncate-then-write allows.
22fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
23    use std::io::Write;
24
25    let dir = path.parent().unwrap_or_else(|| Path::new("."));
26    let tmp = dir.join(format!(
27        ".{}.tmp{}",
28        path.file_name().and_then(|n| n.to_str()).unwrap_or("file"),
29        std::process::id()
30    ));
31
32    {
33        // `create_new`, not `create`: the temp name is derived from the pid, so
34        // it is guessable, and opening an existing path would follow a symlink
35        // planted there and write the token wherever it points. Failing is the
36        // right answer - a leftover means a crash, and the retry below clears it
37        // only after confirming it is a plain file we own.
38        let mut options = OpenOptions::new();
39        options.write(true).create_new(true);
40        #[cfg(unix)]
41        options.mode(0o600);
42
43        let mut file = match options.open(&tmp) {
44            Ok(file) => file,
45            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
46                let stale = fs::symlink_metadata(&tmp)
47                    .map(|m| m.is_file())
48                    .unwrap_or(false);
49                if !stale {
50                    return Err(anyhow::anyhow!(
51                        "Refusing to write {}: it exists and is not a regular file",
52                        tmp.display()
53                    ));
54                }
55                fs::remove_file(&tmp)
56                    .with_context(|| format!("Unable to clear stale {}", tmp.display()))?;
57                options
58                    .open(&tmp)
59                    .with_context(|| format!("Unable to write {}", tmp.display()))?
60            }
61            Err(e) => {
62                return Err(anyhow::Error::new(e))
63                    .with_context(|| format!("Unable to write {}", tmp.display()))
64            }
65        };
66        file.write_all(bytes)
67            .with_context(|| format!("Unable to write {}", tmp.display()))?;
68        file.sync_all().ok();
69    }
70
71    fs::rename(&tmp, path).map_err(|e| {
72        let _ = fs::remove_file(&tmp);
73        anyhow::anyhow!("Unable to write {}: {}", path.display(), e)
74    })?;
75
76    Ok(())
77}
78
79/// Create a directory only its owner can enter.
80fn create_private_dir(dir: &Path) -> Result<()> {
81    // Only a directory we create is ours to set a mode on; see the matching
82    // helper in atlassian-cli-config for why tightening an existing one is wrong.
83    if dir.is_dir() {
84        return Ok(());
85    }
86
87    fs::create_dir_all(dir)
88        .with_context(|| format!("Unable to create directory {}", dir.display()))?;
89
90    #[cfg(unix)]
91    {
92        use std::os::unix::fs::PermissionsExt;
93        fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
94            .with_context(|| format!("Unable to restrict permissions on {}", dir.display()))?;
95    }
96
97    Ok(())
98}
99
100/// Helper to construct a key for profile secrets.
101pub fn token_key(profile: &str) -> String {
102    profile.to_string()
103}
104
105/// Helper to construct a key for Bitbucket profile secrets.
106pub fn bitbucket_token_key(profile: &str) -> String {
107    format!("{}_bitbucket", profile)
108}
109
110/// Where the CLI keeps its credentials.
111///
112/// The directory is supplied by the caller rather than derived here. That is
113/// what lets `$ATLASSIAN_CLI_CONFIG_DIR` move it, and it is what lets the tests
114/// run against a temporary directory instead of the developer's real one.
115#[derive(Debug, Clone)]
116pub struct CredentialStore {
117    dir: PathBuf,
118}
119
120impl CredentialStore {
121    pub fn new(dir: impl Into<PathBuf>) -> Self {
122        Self { dir: dir.into() }
123    }
124
125    pub fn dir(&self) -> &Path {
126        &self.dir
127    }
128
129    /// The pre-encryption credentials file. Still read so an old install keeps
130    /// working until `migrate_plaintext_to_encrypted` runs.
131    pub fn credentials_path(&self) -> PathBuf {
132        self.dir.join("credentials")
133    }
134
135    pub fn encrypted_path(&self) -> PathBuf {
136        self.dir.join("credentials.enc")
137    }
138
139    fn read_plaintext(&self) -> Result<HashMap<String, String>> {
140        let path = self.credentials_path();
141        if !path.exists() {
142            return Ok(HashMap::new());
143        }
144        let content = fs::read_to_string(&path)
145            .with_context(|| format!("Unable to read {}", path.display()))?;
146        Ok(serde_json::from_str(&content).unwrap_or_else(|e| {
147            warn!("Failed to parse credentials file: {}", e);
148            HashMap::new()
149        }))
150    }
151
152    fn write_plaintext(&self, creds: &HashMap<String, String>) -> Result<()> {
153        create_private_dir(&self.dir)?;
154        let json = serde_json::to_string_pretty(creds)?;
155        write_private(&self.credentials_path(), json.as_bytes())
156    }
157
158    /// Store a secret in the plaintext credentials file.
159    pub fn set(&self, account: &str, secret: &str) -> Result<()> {
160        let mut creds = self.read_plaintext()?;
161        creds.insert(account.to_string(), secret.to_string());
162        self.write_plaintext(&creds)
163    }
164
165    /// Read a secret from the plaintext credentials file.
166    pub fn get(&self, account: &str) -> Result<Option<String>> {
167        let path = self.credentials_path();
168        if !path.exists() {
169            return Ok(None);
170        }
171        let content = fs::read_to_string(&path)
172            .with_context(|| format!("Unable to read {}", path.display()))?;
173        let creds: HashMap<String, String> = serde_json::from_str(&content)?;
174        Ok(creds.get(account).cloned())
175    }
176
177    /// Remove a secret from the plaintext credentials file.
178    pub fn delete(&self, account: &str) -> Result<()> {
179        if !self.credentials_path().exists() {
180            return Ok(());
181        }
182        let mut creds = self.read_plaintext()?;
183        creds.remove(account);
184        self.write_plaintext(&creds)
185    }
186
187    fn load_encrypted(&self) -> Result<encryption::EncryptedCredentials> {
188        let path = self.encrypted_path();
189        if !path.exists() {
190            return Ok(encryption::EncryptedCredentials::default());
191        }
192        let content = fs::read_to_string(&path)
193            .with_context(|| format!("Unable to read {}", path.display()))?;
194        serde_json::from_str(&content).context("Failed to parse encrypted credentials file")
195    }
196
197    fn save_encrypted(&self, creds: &encryption::EncryptedCredentials) -> Result<()> {
198        create_private_dir(&self.dir)?;
199        let json = serde_json::to_string_pretty(creds)?;
200        write_private(&self.encrypted_path(), json.as_bytes())
201    }
202
203    /// Store an encrypted secret.
204    pub fn set_encrypted(&self, account: &str, secret: &str) -> Result<()> {
205        let key = encryption::derive_key()?;
206        let (nonce, ciphertext) = encryption::encrypt(secret, &key)?;
207
208        let mut creds = self.load_encrypted()?;
209        creds.credentials.insert(
210            account.to_string(),
211            encryption::EncryptedToken { nonce, ciphertext },
212        );
213
214        self.save_encrypted(&creds)
215    }
216
217    /// Read an encrypted secret.
218    pub fn get_encrypted(&self, account: &str) -> Result<Option<String>> {
219        let creds = self.load_encrypted()?;
220
221        let encrypted_token = match creds.credentials.get(account) {
222            Some(token) => token,
223            None => return Ok(None),
224        };
225
226        let key = encryption::derive_key()?;
227        let plaintext =
228            encryption::decrypt(&encrypted_token.ciphertext, &encrypted_token.nonce, &key)?;
229
230        Ok(Some(plaintext))
231    }
232
233    /// Remove an encrypted secret.
234    pub fn delete_encrypted(&self, account: &str) -> Result<()> {
235        let mut creds = self.load_encrypted()?;
236        creds.credentials.remove(account);
237        self.save_encrypted(&creds)
238    }
239
240    /// Re-store any plaintext credentials as encrypted ones, then securely
241    /// delete the plaintext file. Returns how many were migrated.
242    pub fn migrate_plaintext_to_encrypted(&self) -> Result<usize> {
243        let plaintext_path = self.credentials_path();
244        if !plaintext_path.exists() {
245            return Ok(0);
246        }
247
248        let content = fs::read_to_string(&plaintext_path)
249            .with_context(|| format!("Unable to read {}", plaintext_path.display()))?;
250        let plaintext_creds: HashMap<String, String> =
251            serde_json::from_str(&content).context("Failed to parse plaintext credentials file")?;
252
253        if plaintext_creds.is_empty() {
254            fs::remove_file(&plaintext_path)?;
255            return Ok(0);
256        }
257
258        let count = plaintext_creds.len();
259        for (account, token) in plaintext_creds {
260            self.set_encrypted(&account, &token)?;
261        }
262
263        secure_delete_file(&plaintext_path)?;
264
265        Ok(count)
266    }
267}
268
269/// Securely delete a file by overwriting with zeros before removal
270fn secure_delete_file(path: &std::path::Path) -> Result<()> {
271    // Get file size
272    let metadata = fs::metadata(path)?;
273    let file_size = metadata.len() as usize;
274
275    // Overwrite with zeros
276    let zeros = vec![0u8; file_size];
277    fs::write(path, zeros)?;
278
279    // Now delete
280    fs::remove_file(path)?;
281
282    Ok(())
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use tempfile::TempDir;
289
290    #[test]
291    fn test_token_key() {
292        assert_eq!(token_key("work"), "work");
293        assert_eq!(token_key("my-profile"), "my-profile");
294    }
295
296    #[test]
297    fn test_bitbucket_token_key() {
298        assert_eq!(bitbucket_token_key("work"), "work_bitbucket");
299        assert_eq!(bitbucket_token_key("my-profile"), "my-profile_bitbucket");
300    }
301
302    #[test]
303    fn test_bitbucket_api_url() {
304        assert_eq!(BITBUCKET_API_URL, "https://api.bitbucket.org");
305    }
306
307    /// A store rooted in a temporary directory.
308    ///
309    /// These tests used to run against the real `~/.atlassian-cli`:
310    /// `test_encrypted_storage_roundtrip` read-modify-wrote a developer's actual
311    /// `credentials.enc`, and the migration test called a function that
312    /// securely deletes a real plaintext `credentials` file. That is the reason
313    /// `CredentialStore` takes its directory rather than deriving one.
314    fn store() -> (TempDir, CredentialStore) {
315        let dir = TempDir::new().expect("failed to create a temp dir");
316        let store = CredentialStore::new(dir.path());
317        (dir, store)
318    }
319
320    #[test]
321    fn test_encrypted_storage_roundtrip() {
322        let (_dir, store) = store();
323        let account = "test_account_roundtrip";
324        let secret = "test_secret_value_12345";
325
326        store
327            .set_encrypted(account, secret)
328            .expect("Failed to set encrypted secret");
329
330        let retrieved = store
331            .get_encrypted(account)
332            .expect("Failed to get encrypted secret")
333            .expect("Secret should exist");
334        assert_eq!(retrieved, secret, "Retrieved secret should match original");
335
336        store
337            .delete_encrypted(account)
338            .expect("Failed to delete encrypted secret");
339
340        let after_delete = store
341            .get_encrypted(account)
342            .expect("Failed to check after delete");
343        assert!(after_delete.is_none(), "Secret should be deleted");
344    }
345
346    #[test]
347    fn test_encrypted_storage_nonexistent() {
348        let (_dir, store) = store();
349        let result = store
350            .get_encrypted("nonexistent_account_xyz")
351            .expect("Should succeed even if not found");
352
353        assert!(result.is_none(), "Non-existent account should return None");
354    }
355
356    /// Nothing to migrate is not an error. Previously this ran against the real
357    /// home directory, so on a machine with a plaintext credentials file it
358    /// would delete it.
359    #[test]
360    fn test_migration_no_plaintext_file() {
361        let (_dir, store) = store();
362        assert_eq!(
363            store
364                .migrate_plaintext_to_encrypted()
365                .expect("Migration should succeed when there is no file"),
366            0
367        );
368    }
369
370    #[test]
371    fn test_migration_moves_plaintext_into_encrypted_storage() {
372        let (_dir, store) = store();
373        let mut plaintext = HashMap::new();
374        plaintext.insert("work".to_string(), "token-a".to_string());
375        plaintext.insert("personal".to_string(), "token-b".to_string());
376        store
377            .write_plaintext(&plaintext)
378            .expect("failed to seed plaintext credentials");
379
380        let count = store
381            .migrate_plaintext_to_encrypted()
382            .expect("migration should succeed");
383
384        assert_eq!(count, 2);
385        assert!(
386            !store.credentials_path().exists(),
387            "the plaintext file should be gone once migrated"
388        );
389        assert_eq!(
390            store.get_encrypted("work").unwrap().as_deref(),
391            Some("token-a")
392        );
393        assert_eq!(
394            store.get_encrypted("personal").unwrap().as_deref(),
395            Some("token-b")
396        );
397    }
398
399    #[test]
400    fn test_plaintext_roundtrip_and_delete() {
401        let (_dir, store) = store();
402        assert!(store.get("absent").unwrap().is_none());
403
404        store.set("work", "token").unwrap();
405        assert_eq!(store.get("work").unwrap().as_deref(), Some("token"));
406
407        store.delete("work").unwrap();
408        assert!(store.get("work").unwrap().is_none());
409    }
410
411    /// The store creates its own directory rather than requiring the caller to.
412    #[test]
413    fn test_writing_creates_a_private_directory() {
414        let parent = TempDir::new().unwrap();
415        let dir = parent.path().join("nested").join("config");
416        let store = CredentialStore::new(&dir);
417
418        store.set_encrypted("work", "token").unwrap();
419
420        assert!(dir.exists());
421        #[cfg(unix)]
422        {
423            use std::os::unix::fs::PermissionsExt;
424            let mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
425            assert_eq!(mode, 0o700, "config directory should be owner-only");
426        }
427    }
428
429    #[cfg(unix)]
430    #[test]
431    fn test_credentials_are_owner_only_even_when_overwriting() {
432        use std::os::unix::fs::PermissionsExt;
433
434        let (_dir, store) = store();
435        store.set_encrypted("work", "token").unwrap();
436
437        // Loosen it the way an older version of this code could have left it,
438        // then write again. OpenOptions::mode only applies at creation, so an
439        // in-place write would leave this world-readable.
440        fs::set_permissions(store.encrypted_path(), fs::Permissions::from_mode(0o644)).unwrap();
441        store.set_encrypted("work", "token2").unwrap();
442
443        let mode = fs::metadata(store.encrypted_path())
444            .unwrap()
445            .permissions()
446            .mode()
447            & 0o777;
448        assert_eq!(mode, 0o600, "credentials must not stay world-readable");
449        assert_eq!(
450            store.get_encrypted("work").unwrap().as_deref(),
451            Some("token2")
452        );
453    }
454}