use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use super::{KeyStore, KeyStoreError};
const CREDENTIALS_DIR: &str = ".trusty-tools";
const CREDENTIALS_FILE: &str = "credentials.toml";
#[derive(Default, Serialize, Deserialize)]
struct CredentialsFile {
#[serde(default)]
keys: BTreeMap<String, String>,
}
#[derive(Debug)]
pub struct FileKeyStore {
path: PathBuf,
lock: Mutex<()>,
}
impl FileKeyStore {
pub fn new() -> Result<Self, KeyStoreError> {
let home = dirs::home_dir().ok_or(KeyStoreError::HomeUnavailable)?;
Ok(Self::at(&home))
}
pub fn at(base: &Path) -> Self {
let path = base.join(CREDENTIALS_DIR).join(CREDENTIALS_FILE);
Self {
path,
lock: Mutex::new(()),
}
}
fn read(&self) -> Result<CredentialsFile, KeyStoreError> {
match std::fs::read_to_string(&self.path) {
Ok(raw) => toml::from_str(&raw).map_err(|e| KeyStoreError::Toml {
path: self.path.clone(),
message: sanitize_toml_error(&e),
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(CredentialsFile::default()),
Err(e) => Err(KeyStoreError::Io {
path: self.path.clone(),
source: e,
}),
}
}
fn write(&self, data: &CredentialsFile) -> Result<(), KeyStoreError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| KeyStoreError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
let toml_str = toml::to_string_pretty(data).map_err(|e| KeyStoreError::Toml {
path: self.path.clone(),
message: e.to_string(),
})?;
let tmp = self.path.with_extension("toml.tmp");
write_owner_only(&tmp, &toml_str)?;
std::fs::rename(&tmp, &self.path).map_err(|e| KeyStoreError::Io {
path: self.path.clone(),
source: e,
})?;
set_permissions_0600(&self.path)?;
Ok(())
}
}
fn sanitize_toml_error(e: &toml::de::Error) -> String {
match e.span() {
Some(span) => format!(
"{} (at byte offset {}..{})",
e.message(),
span.start,
span.end
),
None => e.message().to_string(),
}
}
#[cfg(unix)]
fn write_owner_only(path: &Path, content: &str) -> Result<(), KeyStoreError> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let io_err = |e: std::io::Error| KeyStoreError::Io {
path: path.to_path_buf(),
source: e,
};
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(io_err)?;
std::fs::set_permissions(path, {
use std::os::unix::fs::PermissionsExt;
std::fs::Permissions::from_mode(0o600)
})
.map_err(io_err)?;
file.write_all(content.as_bytes()).map_err(io_err)
}
#[cfg(not(unix))]
fn write_owner_only(path: &Path, content: &str) -> Result<(), KeyStoreError> {
std::fs::write(path, content).map_err(|e| KeyStoreError::Io {
path: path.to_path_buf(),
source: e,
})
}
#[cfg(unix)]
fn set_permissions_0600(path: &Path) -> Result<(), KeyStoreError> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
KeyStoreError::Io {
path: path.to_path_buf(),
source: e,
}
})
}
#[cfg(not(unix))]
fn set_permissions_0600(_path: &Path) -> Result<(), KeyStoreError> {
Ok(())
}
impl KeyStore for FileKeyStore {
fn get(&self, provider: &str) -> Option<String> {
let _guard = self.lock.lock().unwrap_or_else(|p| p.into_inner());
self.read().ok()?.keys.get(provider).cloned()
}
fn set(&self, provider: &str, value: &str) -> Result<(), KeyStoreError> {
let _guard = self.lock.lock().unwrap_or_else(|p| p.into_inner());
let mut data = self.read()?;
data.keys.insert(provider.to_string(), value.to_string());
self.write(&data)
}
fn unset(&self, provider: &str) -> Result<(), KeyStoreError> {
let _guard = self.lock.lock().unwrap_or_else(|p| p.into_inner());
let mut data = self.read()?;
data.keys.remove(provider);
self.write(&data)
}
fn list(&self) -> Vec<String> {
let _guard = self.lock.lock().unwrap_or_else(|p| p.into_inner());
self.read()
.map(|d| d.keys.keys().cloned().collect())
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_then_get_round_trips() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FileKeyStore::at(tmp.path());
assert_eq!(store.get("fireworks"), None);
store.set("fireworks", "fw-secret").unwrap();
assert_eq!(store.get("fireworks"), Some("fw-secret".to_string()));
}
#[test]
fn unset_removes_and_is_idempotent() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FileKeyStore::at(tmp.path());
store.set("openai", "sk-abc").unwrap();
store.unset("openai").unwrap();
assert_eq!(store.get("openai"), None);
store.unset("openai").unwrap();
}
#[test]
fn list_returns_all_provider_names() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FileKeyStore::at(tmp.path());
store.set("anthropic", "sk-ant-secret").unwrap();
store.set("openrouter", "or-secret").unwrap();
let mut names = store.list();
names.sort();
assert_eq!(
names,
vec!["anthropic".to_string(), "openrouter".to_string()]
);
}
#[cfg(unix)]
#[test]
fn file_is_created_with_0600_perms() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let store = FileKeyStore::at(tmp.path());
store.set("fireworks", "fw-secret").unwrap();
let path = tmp.path().join(".trusty-tools").join("credentials.toml");
assert!(path.is_file());
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
}
#[cfg(unix)]
#[test]
fn perms_are_reasserted_on_every_write() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let store = FileKeyStore::at(tmp.path());
store.set("fireworks", "fw-secret").unwrap();
let path = tmp.path().join(".trusty-tools").join("credentials.toml");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
store.set("openai", "sk-abc").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected re-asserted 0600, got {mode:o}");
}
#[test]
fn absent_file_reads_as_empty() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FileKeyStore::at(tmp.path());
assert_eq!(store.list(), Vec::<String>::new());
assert_eq!(store.get("anything"), None);
}
#[test]
fn debug_output_never_contains_values() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FileKeyStore::at(tmp.path());
store.set("fireworks", "fw-super-secret-value").unwrap();
let dbg = format!("{store:?}");
assert!(
!dbg.contains("fw-super-secret-value"),
"Debug output leaked a credential value: {dbg}"
);
}
#[test]
fn toml_error_never_contains_file_content() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FileKeyStore::at(tmp.path());
let dir = tmp.path().join(".trusty-tools");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("credentials.toml"),
"[keys]\nfireworks = \"fw-truncated-secret",
)
.unwrap();
assert_eq!(store.get("fireworks"), None);
let err = store.set("openai", "sk-new").unwrap_err();
let msg = err.to_string();
assert!(
!msg.contains("fw-truncated-secret"),
"TOML error leaked file content: {msg}"
);
assert!(matches!(err, KeyStoreError::Toml { .. }), "got {msg}");
}
#[cfg(unix)]
#[test]
fn tmp_write_path_is_0600_from_birth() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("fresh.toml");
write_owner_only(&target, "[keys]\n").unwrap();
let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600 at creation, got {mode:o}");
}
#[cfg(unix)]
#[test]
fn preexisting_loose_tmp_file_is_tightened() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("stale.toml");
std::fs::write(&target, "old").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
write_owner_only(&target, "[keys]\n").unwrap();
let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected tightened 0600, got {mode:o}");
}
}