use crate::auth::secure_string::SecureString;
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::debug;
pub fn store_token(host: &str, token: &SecureString) -> Result<()> {
let path = credentials_path()?;
let mut creds = load_credentials(&path);
creds.insert(host.to_string(), token.as_str().to_string());
save_credentials(&path, &creds)?;
debug!("Stored token for {} in {}", host, path.display());
Ok(())
}
pub fn get_token(host: &str) -> Option<SecureString> {
let path = credentials_path().ok()?;
let creds = load_credentials(&path);
creds
.get(host)
.filter(|s| !s.is_empty())
.map(|s| SecureString::from_string(s.clone()))
}
pub fn delete_token(host: &str) -> Result<()> {
let path = credentials_path()?;
let mut creds = load_credentials(&path);
creds.remove(host);
save_credentials(&path, &creds)?;
debug!("Deleted token for {}", host);
Ok(())
}
pub fn delete_all_tokens() -> Result<()> {
let path = credentials_path()?;
save_credentials(&path, &HashMap::new())?;
Ok(())
}
pub fn list_stored_hosts() -> Vec<String> {
let path = match credentials_path() {
Ok(p) => p,
Err(_) => return vec![],
};
let creds = load_credentials(&path);
creds
.keys()
.filter(|k| !creds[*k].is_empty())
.cloned()
.collect()
}
fn credentials_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not determine home directory")?;
let dir = home.join(".config/securegit");
std::fs::create_dir_all(&dir).context("Could not create config directory")?;
Ok(dir.join("credentials.json"))
}
fn derive_key() -> [u8; 32] {
let mut hasher = Sha256::new();
if let Ok(hostname) = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.or_else(|_| std::fs::read_to_string("/etc/hostname").map(|s| s.trim().to_string()))
{
hasher.update(hostname.as_bytes());
}
if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
hasher.update(user.as_bytes());
}
if let Ok(path) = credentials_path() {
hasher.update(path.to_string_lossy().as_bytes());
}
hasher.update(b"securegit-credential-store-v1");
hasher.finalize().into()
}
fn encrypt(plaintext: &[u8]) -> String {
let key = derive_key();
let mut ciphertext = Vec::with_capacity(plaintext.len());
for (i, byte) in plaintext.iter().enumerate() {
let block_index = i / 32;
let byte_index = i % 32;
if byte_index == 0 && block_index > 0 {
let mut hasher = Sha256::new();
hasher.update(key);
hasher.update((block_index as u64).to_le_bytes());
let extended: [u8; 32] = hasher.finalize().into();
ciphertext.push(byte ^ extended[byte_index]);
} else {
ciphertext.push(byte ^ key[byte_index]);
}
}
hex::encode(ciphertext)
}
fn decrypt(hex_ciphertext: &str) -> Result<Vec<u8>> {
let ciphertext =
hex::decode(hex_ciphertext).context("Failed to decode credential ciphertext")?;
let key = derive_key();
let mut plaintext = Vec::with_capacity(ciphertext.len());
for (i, byte) in ciphertext.iter().enumerate() {
let block_index = i / 32;
let byte_index = i % 32;
if byte_index == 0 && block_index > 0 {
let mut hasher = Sha256::new();
hasher.update(key);
hasher.update((block_index as u64).to_le_bytes());
let extended: [u8; 32] = hasher.finalize().into();
plaintext.push(byte ^ extended[byte_index]);
} else {
plaintext.push(byte ^ key[byte_index]);
}
}
Ok(plaintext)
}
fn load_credentials(path: &Path) -> HashMap<String, String> {
if !path.exists() {
return HashMap::new();
}
match std::fs::read_to_string(path) {
Ok(content) => {
if let Ok(decrypted) = decrypt(content.trim()) {
if let Ok(json_str) = std::str::from_utf8(&decrypted) {
if let Ok(creds) = serde_json::from_str(json_str) {
return creds;
}
}
}
serde_json::from_str(&content).unwrap_or_default()
}
Err(_) => HashMap::new(),
}
}
fn save_credentials(path: &Path, creds: &HashMap<String, String>) -> Result<()> {
let json = serde_json::to_string(creds)?;
let encrypted = encrypt(json.as_bytes());
std::fs::write(path, &encrypted).context("Failed to write credentials file")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encrypt_decrypt_roundtrip() {
let plaintext = b"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
let ciphertext = encrypt(plaintext);
let decrypted = decrypt(&ciphertext).expect("decrypt should succeed");
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encrypt_produces_hex() {
let ciphertext = encrypt(b"test-token-value");
assert!(
ciphertext.chars().all(|c| c.is_ascii_hexdigit()),
"Ciphertext should be valid hex, got: {}",
ciphertext
);
assert_eq!(ciphertext.len(), "test-token-value".len() * 2);
}
#[test]
fn test_decrypt_invalid_hex_fails() {
let result = decrypt("not-valid-hex!!!");
assert!(result.is_err(), "Decrypting garbage hex should fail");
}
#[test]
fn test_decrypt_odd_length_hex_fails() {
let result = decrypt("abc");
assert!(result.is_err(), "Decrypting odd-length hex should fail");
}
#[test]
fn test_encrypt_different_inputs_different_outputs() {
let ct1 = encrypt(b"token-alpha");
let ct2 = encrypt(b"token-bravo");
assert_ne!(
ct1, ct2,
"Different plaintexts must produce different ciphertexts"
);
}
#[test]
fn test_encrypt_empty_data() {
let ciphertext = encrypt(b"");
assert_eq!(
ciphertext, "",
"Encrypting empty data should produce empty hex"
);
let decrypted = decrypt(&ciphertext).expect("decrypt empty should succeed");
assert!(decrypted.is_empty());
}
#[test]
fn test_encrypt_large_data() {
let large = vec![0x42u8; 1_048_576 + 100];
let ciphertext = encrypt(&large);
let decrypted = decrypt(&ciphertext).expect("decrypt large data should succeed");
assert_eq!(decrypted, large);
}
#[test]
fn test_encrypt_deterministic() {
let ct1 = encrypt(b"deterministic-test");
let ct2 = encrypt(b"deterministic-test");
assert_eq!(ct1, ct2, "Encryption should be deterministic with same key");
}
#[test]
fn test_store_and_retrieve_token() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
let cred_path = tmpdir.path().join("credentials.json");
let mut creds = HashMap::new();
creds.insert("github.com".to_string(), "ghp_test123".to_string());
save_credentials(&cred_path, &creds).expect("save should succeed");
assert!(cred_path.exists());
let loaded = load_credentials(&cred_path);
assert_eq!(
loaded.get("github.com").map(|s| s.as_str()),
Some("ghp_test123")
);
}
#[test]
fn test_store_multiple_hosts() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
let cred_path = tmpdir.path().join("credentials.json");
let mut creds = HashMap::new();
creds.insert("github.com".to_string(), "ghp_abc".to_string());
creds.insert("gitlab.com".to_string(), "glpat-xyz".to_string());
creds.insert("bitbucket.org".to_string(), "bb_token".to_string());
save_credentials(&cred_path, &creds).expect("save should succeed");
let loaded = load_credentials(&cred_path);
assert_eq!(loaded.len(), 3);
assert_eq!(
loaded.get("github.com").map(|s| s.as_str()),
Some("ghp_abc")
);
assert_eq!(
loaded.get("gitlab.com").map(|s| s.as_str()),
Some("glpat-xyz")
);
assert_eq!(
loaded.get("bitbucket.org").map(|s| s.as_str()),
Some("bb_token")
);
}
#[test]
fn test_delete_token_from_store() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
let cred_path = tmpdir.path().join("credentials.json");
let mut creds = HashMap::new();
creds.insert("github.com".to_string(), "ghp_abc".to_string());
creds.insert("gitlab.com".to_string(), "glpat-xyz".to_string());
save_credentials(&cred_path, &creds).expect("save should succeed");
let mut loaded = load_credentials(&cred_path);
loaded.remove("github.com");
save_credentials(&cred_path, &loaded).expect("save after delete");
let reloaded = load_credentials(&cred_path);
assert!(reloaded.get("github.com").is_none());
assert_eq!(
reloaded.get("gitlab.com").map(|s| s.as_str()),
Some("glpat-xyz")
);
}
#[test]
fn test_list_hosts() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
let cred_path = tmpdir.path().join("credentials.json");
let mut creds = HashMap::new();
creds.insert("github.com".to_string(), "token1".to_string());
creds.insert("gitlab.com".to_string(), "token2".to_string());
creds.insert("empty.com".to_string(), "".to_string()); save_credentials(&cred_path, &creds).expect("save should succeed");
let loaded = load_credentials(&cred_path);
let hosts: Vec<String> = loaded
.keys()
.filter(|k| !loaded[*k].is_empty())
.cloned()
.collect();
assert!(hosts.contains(&"github.com".to_string()));
assert!(hosts.contains(&"gitlab.com".to_string()));
assert!(!hosts.contains(&"empty.com".to_string()));
}
#[test]
fn test_load_nonexistent_file() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
let cred_path = tmpdir.path().join("does-not-exist.json");
let loaded = load_credentials(&cred_path);
assert!(loaded.is_empty());
}
#[test]
fn test_encrypted_file_not_plaintext() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
let cred_path = tmpdir.path().join("credentials.json");
let mut creds = HashMap::new();
creds.insert("github.com".to_string(), "ghp_secret_value".to_string());
save_credentials(&cred_path, &creds).expect("save should succeed");
let raw = std::fs::read_to_string(&cred_path).expect("read file");
assert!(
!raw.contains("ghp_secret_value"),
"Credentials file must not contain plaintext tokens"
);
assert!(
!raw.contains("github.com"),
"Credentials file must not contain plaintext host names"
);
}
}