use anyhow::{Context, Result};
use std::collections::HashMap;
use std::fs::{self, OpenOptions};
use std::path::PathBuf;
use tracing::warn;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
pub mod encryption;
pub mod secret;
pub const BITBUCKET_API_URL: &str = "https://api.bitbucket.org";
pub fn token_key(profile: &str) -> String {
profile.to_string()
}
pub fn bitbucket_token_key(profile: &str) -> String {
format!("{}_bitbucket", profile)
}
fn credentials_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".atlassian-cli").join("credentials"))
}
pub fn set_secret(account: &str, secret: &str) -> Result<()> {
let path = credentials_path().context("Cannot determine home directory")?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut creds: HashMap<String, String> = if path.exists() {
let content = fs::read_to_string(&path)?;
serde_json::from_str(&content).unwrap_or_else(|e| {
warn!("Failed to parse credentials file: {}", e);
HashMap::new()
})
} else {
HashMap::new()
};
creds.insert(account.to_string(), secret.to_string());
#[cfg(unix)]
{
use std::io::Write;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
let json = serde_json::to_string_pretty(&creds)?;
file.write_all(json.as_bytes())?;
}
#[cfg(not(unix))]
{
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
serde_json::to_writer_pretty(file, &creds)?;
}
Ok(())
}
pub fn get_secret(account: &str) -> Result<Option<String>> {
let path = credentials_path().context("Cannot determine home directory")?;
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path)?;
let creds: HashMap<String, String> = serde_json::from_str(&content)?;
Ok(creds.get(account).cloned())
}
pub fn delete_secret(account: &str) -> Result<()> {
let path = credentials_path().context("Cannot determine home directory")?;
if !path.exists() {
return Ok(());
}
let content = fs::read_to_string(&path)?;
let mut creds: HashMap<String, String> = serde_json::from_str(&content).unwrap_or_else(|e| {
warn!("Failed to parse credentials file: {}", e);
HashMap::new()
});
creds.remove(account);
#[cfg(unix)]
{
use std::io::Write;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
let json = serde_json::to_string_pretty(&creds)?;
file.write_all(json.as_bytes())?;
}
#[cfg(not(unix))]
{
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
serde_json::to_writer_pretty(file, &creds)?;
}
Ok(())
}
fn encrypted_credentials_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".atlassian-cli").join("credentials.enc"))
}
fn load_encrypted_credentials() -> Result<encryption::EncryptedCredentials> {
let path = encrypted_credentials_path().context("Cannot determine home directory")?;
if !path.exists() {
return Ok(encryption::EncryptedCredentials::default());
}
let content = fs::read_to_string(&path)?;
let creds: encryption::EncryptedCredentials =
serde_json::from_str(&content).context("Failed to parse encrypted credentials file")?;
Ok(creds)
}
fn save_encrypted_credentials(creds: &encryption::EncryptedCredentials) -> Result<()> {
let path = encrypted_credentials_path().context("Cannot determine home directory")?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(creds)?;
#[cfg(unix)]
{
use std::io::Write;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
file.write_all(json.as_bytes())?;
}
#[cfg(not(unix))]
{
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
std::io::Write::write_all(&mut std::io::BufWriter::new(file), json.as_bytes())?;
}
Ok(())
}
pub fn set_secret_encrypted(account: &str, secret: &str) -> Result<()> {
let key = encryption::derive_key()?;
let (nonce, ciphertext) = encryption::encrypt(secret, &key)?;
let mut creds = load_encrypted_credentials()?;
creds.credentials.insert(
account.to_string(),
encryption::EncryptedToken { nonce, ciphertext },
);
save_encrypted_credentials(&creds)?;
Ok(())
}
pub fn get_secret_encrypted(account: &str) -> Result<Option<String>> {
let creds = load_encrypted_credentials()?;
let encrypted_token = match creds.credentials.get(account) {
Some(token) => token,
None => return Ok(None),
};
let key = encryption::derive_key()?;
let plaintext = encryption::decrypt(&encrypted_token.ciphertext, &encrypted_token.nonce, &key)?;
Ok(Some(plaintext))
}
pub fn delete_secret_encrypted(account: &str) -> Result<()> {
let mut creds = load_encrypted_credentials()?;
creds.credentials.remove(account);
save_encrypted_credentials(&creds)?;
Ok(())
}
pub fn migrate_plaintext_to_encrypted() -> Result<usize> {
let plaintext_path = credentials_path().context("Cannot determine home directory")?;
if !plaintext_path.exists() {
return Ok(0);
}
let content = fs::read_to_string(&plaintext_path)?;
let plaintext_creds: HashMap<String, String> =
serde_json::from_str(&content).context("Failed to parse plaintext credentials file")?;
if plaintext_creds.is_empty() {
fs::remove_file(&plaintext_path)?;
return Ok(0);
}
let count = plaintext_creds.len();
for (account, token) in plaintext_creds {
set_secret_encrypted(&account, &token)?;
}
secure_delete_file(&plaintext_path)?;
Ok(count)
}
fn secure_delete_file(path: &std::path::Path) -> Result<()> {
let metadata = fs::metadata(path)?;
let file_size = metadata.len() as usize;
let zeros = vec![0u8; file_size];
fs::write(path, zeros)?;
fs::remove_file(path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_token_key() {
assert_eq!(token_key("work"), "work");
assert_eq!(token_key("my-profile"), "my-profile");
}
#[test]
fn test_bitbucket_token_key() {
assert_eq!(bitbucket_token_key("work"), "work_bitbucket");
assert_eq!(bitbucket_token_key("my-profile"), "my-profile_bitbucket");
}
#[test]
fn test_bitbucket_api_url() {
assert_eq!(BITBUCKET_API_URL, "https://api.bitbucket.org");
}
#[test]
fn test_encrypted_storage_roundtrip() {
let account = "test_account_roundtrip";
let secret = "test_secret_value_12345";
set_secret_encrypted(account, secret).expect("Failed to set encrypted secret");
let retrieved = get_secret_encrypted(account)
.expect("Failed to get encrypted secret")
.expect("Secret should exist");
assert_eq!(retrieved, secret, "Retrieved secret should match original");
delete_secret_encrypted(account).expect("Failed to delete encrypted secret");
let after_delete = get_secret_encrypted(account).expect("Failed to check after delete");
assert!(after_delete.is_none(), "Secret should be deleted");
}
#[test]
fn test_encrypted_storage_nonexistent() {
let result = get_secret_encrypted("nonexistent_account_xyz")
.expect("Should succeed even if not found");
assert!(result.is_none(), "Non-existent account should return None");
}
#[test]
fn test_migration_no_plaintext_file() {
let result = migrate_plaintext_to_encrypted();
assert!(
result.is_ok(),
"Migration should succeed even when file doesn't exist or has existing creds"
);
}
}