use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use super::{KeyStore, KeyStoreError};
const CREDENTIALS_DIR: &str = ".trusty-tools";
const CREDENTIALS_FILE: &str = "credentials.toml";
const LOCK_WAIT: Duration = Duration::from_secs(10);
const LOCK_POLL: Duration = Duration::from_millis(10);
#[derive(Default, Serialize, Deserialize)]
struct CredentialsFile {
#[serde(default)]
keys: BTreeMap<String, String>,
}
#[derive(Debug)]
pub struct FileKeyStore {
path: PathBuf,
lock: Mutex<()>,
lock_wait: Duration,
}
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(()),
lock_wait: LOCK_WAIT,
}
}
fn lock_file(&self) -> Result<std::fs::File, KeyStoreError> {
let lock_path = self.path.with_extension("toml.lock");
let io_err = |source: std::io::Error| KeyStoreError::Io {
path: lock_path.clone(),
source,
};
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| KeyStoreError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
let mut options = std::fs::OpenOptions::new();
options.read(true).write(true).create(true).truncate(false);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let file = options.open(&lock_path).map_err(io_err)?;
let deadline = Instant::now() + self.lock_wait;
loop {
match file.try_lock() {
Ok(()) => return Ok(file),
Err(std::fs::TryLockError::WouldBlock) if Instant::now() < deadline => {
std::thread::sleep(LOCK_POLL);
}
Err(std::fs::TryLockError::WouldBlock) => {
return Err(io_err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("credential store lock held for over {:?}", self.lock_wait),
)));
}
Err(std::fs::TryLockError::Error(e)) => return Err(io_err(e)),
}
}
}
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> {
self.try_get(provider).ok().flatten()
}
fn try_get(&self, provider: &str) -> Result<Option<String>, KeyStoreError> {
let _guard = self.lock.lock().unwrap_or_else(|p| p.into_inner());
Ok(self.read()?.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 _file_lock = self.lock_file()?;
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 _file_lock = self.lock_file()?;
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}");
}
fn store_path(base: &Path) -> PathBuf {
base.join(".trusty-tools").join("credentials.toml")
}
#[test]
fn a_directory_at_the_store_path_is_an_error_not_absent() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(store_path(tmp.path())).unwrap();
let store = FileKeyStore::at(tmp.path());
let got = store.try_get("fireworks");
assert!(
matches!(got, Err(KeyStoreError::Io { .. })),
"expected an Io error, got {got:?}"
);
}
#[cfg(unix)]
#[test]
fn an_unreadable_store_file_is_an_error_not_absent() {
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 = store_path(tmp.path());
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&path).is_ok() {
return; }
let got = store.try_get("fireworks");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(
matches!(got, Err(KeyStoreError::Io { .. })),
"expected an Io error, got {got:?}"
);
}
#[test]
fn try_get_separates_a_hit_from_a_miss() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FileKeyStore::at(tmp.path());
assert_eq!(store.try_get("fireworks").unwrap(), None);
store.set("fireworks", "fw-secret").unwrap();
assert_eq!(
store.try_get("fireworks").unwrap(),
Some("fw-secret".to_string())
);
assert_eq!(store.try_get("openai").unwrap(), None);
}
#[test]
fn concurrent_writers_with_separate_handles_lose_no_key() {
use std::sync::{Arc, Barrier};
const ROUNDS: usize = 50;
const KEYS: usize = 5;
let mut failed_rounds = Vec::new();
for round in 0..ROUNDS {
let tmp = tempfile::TempDir::new().unwrap();
let barrier = Arc::new(Barrier::new(2));
let writers: Vec<_> = ["a", "b"]
.into_iter()
.map(|who| {
let base = tmp.path().to_path_buf();
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
let store = FileKeyStore::at(&base);
barrier.wait();
(0..KEYS)
.filter_map(|i| store.set(&format!("{who}{i}"), "v").err())
.map(|e| e.to_string())
.collect::<Vec<_>>()
})
})
.collect();
let errors: Vec<String> = writers
.into_iter()
.flat_map(|w| w.join().unwrap())
.collect();
let present = FileKeyStore::at(tmp.path()).list().len();
if present != 2 * KEYS || !errors.is_empty() {
failed_rounds.push(format!("round {round}: {present} keys, errors {errors:?}"));
}
}
assert!(
failed_rounds.is_empty(),
"{} of {ROUNDS} rounds lost a key or failed a set: {failed_rounds:?}",
failed_rounds.len()
);
}
#[test]
fn a_held_lock_times_out_and_a_released_one_does_not() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FileKeyStore {
lock_wait: Duration::from_millis(100),
..FileKeyStore::at(tmp.path())
};
store.set("fireworks", "fw-secret").unwrap();
let holder =
std::fs::File::open(store_path(tmp.path()).with_extension("toml.lock")).unwrap();
holder.lock().unwrap();
let started = Instant::now();
let err = store.set("openai", "sk-abc").unwrap_err();
assert!(
matches!(&err, KeyStoreError::Io { source, .. } if source.kind() == std::io::ErrorKind::TimedOut),
"expected a TimedOut Io error, got {err:?}"
);
assert!(
started.elapsed() < Duration::from_secs(5),
"wait was not bounded"
);
drop(holder);
store.set("openai", "sk-abc").unwrap();
assert_eq!(store.list().len(), 2);
}
#[cfg(unix)]
#[test]
fn set_keeps_the_store_and_its_lock_file_at_0600() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let path = store_path(tmp.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "[keys]\nfireworks = \"fw-secret\"\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
let store = FileKeyStore::at(tmp.path());
store.set("openai", "sk-abc").unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600, "store mode after set");
assert_eq!(store.get("fireworks"), Some("fw-secret".to_string()));
let lock = path.with_extension("toml.lock");
assert!(lock.is_file(), "expected a lock file at {}", lock.display());
assert_eq!(mode(&lock), 0o600, "lock file mode");
}
}