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
13pub const BITBUCKET_API_URL: &str = "https://api.bitbucket.org";
15
16fn 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 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
79fn create_private_dir(dir: &Path) -> Result<()> {
81 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
100pub fn token_key(profile: &str) -> String {
102 profile.to_string()
103}
104
105pub fn bitbucket_token_key(profile: &str) -> String {
107 format!("{}_bitbucket", profile)
108}
109
110#[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 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 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 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 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 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 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 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 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
269fn secure_delete_file(path: &std::path::Path) -> Result<()> {
271 let metadata = fs::metadata(path)?;
273 let file_size = metadata.len() as usize;
274
275 let zeros = vec![0u8; file_size];
277 fs::write(path, zeros)?;
278
279 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 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 #[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 #[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 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}