#![deny(missing_docs)]
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const DEFAULT_SERVICE: &str = "deepseek";
pub const SECRET_BACKEND_ENV: &str = "DEEPSEEK_SECRET_BACKEND";
#[derive(Debug, Error)]
pub enum SecretsError {
#[error("keyring backend error: {0}")]
Keyring(String),
#[error("file-backed secret store I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("file-backed secret store JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
InsecurePermissions {
path: PathBuf,
mode: u32,
},
}
pub trait KeyringStore: Send + Sync {
fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
fn delete(&self, key: &str) -> Result<(), SecretsError>;
fn backend_name(&self) -> &'static str;
}
#[derive(Debug, Clone)]
pub struct DefaultKeyringStore {
service: String,
}
impl Default for DefaultKeyringStore {
fn default() -> Self {
Self::new(DEFAULT_SERVICE)
}
}
impl DefaultKeyringStore {
#[must_use]
pub fn new(service: impl Into<String>) -> Self {
Self {
service: service.into(),
}
}
pub fn probe(&self) -> Result<(), SecretsError> {
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
let entry = keyring::Entry::new(&self.service, "__probe__")
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
let _ = entry;
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
match entry.get_password() {
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
Err(keyring::Error::PlatformFailure(err)) => {
Err(SecretsError::Keyring(format!("platform failure: {err}")))
}
Err(keyring::Error::NoStorageAccess(err)) => {
Err(SecretsError::Keyring(format!("no storage access: {err}")))
}
Err(other) => Err(SecretsError::Keyring(other.to_string())),
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = &self.service;
Err(SecretsError::Keyring(unsupported_keyring_message()))
}
}
}
impl KeyringStore for DefaultKeyringStore {
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
let entry = keyring::Entry::new(&self.service, key)
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
match entry.get_password() {
Ok(value) => Ok(Some(value)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(SecretsError::Keyring(err.to_string())),
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = key;
Err(SecretsError::Keyring(unsupported_keyring_message()))
}
}
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
let entry = keyring::Entry::new(&self.service, key)
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
entry
.set_password(value)
.map_err(|err| SecretsError::Keyring(err.to_string()))
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = (key, value);
Err(SecretsError::Keyring(unsupported_keyring_message()))
}
}
fn delete(&self, key: &str) -> Result<(), SecretsError> {
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
let entry = keyring::Entry::new(&self.service, key)
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(SecretsError::Keyring(err.to_string())),
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = key;
Err(SecretsError::Keyring(unsupported_keyring_message()))
}
}
fn backend_name(&self) -> &'static str {
"system keyring"
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
fn unsupported_keyring_message() -> String {
"system keyring backend is unsupported on this platform".to_string()
}
#[derive(Debug, Default)]
pub struct InMemoryKeyringStore {
entries: Mutex<HashMap<String, String>>,
}
impl InMemoryKeyringStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl KeyringStore for InMemoryKeyringStore {
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
let guard = self.entries.lock().map_err(|e| {
SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
})?;
Ok(guard.get(key).cloned())
}
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
let mut guard = self.entries.lock().map_err(|e| {
SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
})?;
guard.insert(key.to_string(), value.to_string());
Ok(())
}
fn delete(&self, key: &str) -> Result<(), SecretsError> {
let mut guard = self.entries.lock().map_err(|e| {
SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}"))
})?;
guard.remove(key);
Ok(())
}
fn backend_name(&self) -> &'static str {
"in-memory (test)"
}
}
#[derive(Debug, Clone)]
pub struct FileKeyringStore {
path: PathBuf,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct FileSecretsBlob {
#[serde(default)]
entries: HashMap<String, String>,
}
impl FileKeyringStore {
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn default_path() -> Result<PathBuf, SecretsError> {
let home = dirs::home_dir().ok_or_else(|| {
SecretsError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not resolve home directory for FileKeyringStore",
))
})?;
Ok(home.join(".deepseek").join("secrets").join("secrets.json"))
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
if !self.path.exists() {
return Ok(FileSecretsBlob::default());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = fs::metadata(&self.path)?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(SecretsError::InsecurePermissions {
path: self.path.clone(),
mode,
});
}
}
let raw = fs::read_to_string(&self.path)?;
if raw.trim().is_empty() {
return Ok(FileSecretsBlob::default());
}
let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
Ok(blob)
}
fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(parent)?.permissions();
perms.set_mode(0o700);
let _ = fs::set_permissions(parent, perms);
}
}
let body = serde_json::to_string_pretty(blob)?;
fs::write(&self.path, body)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&self.path) {
let mut perms = meta.permissions();
perms.set_mode(0o600);
let _ = fs::set_permissions(&self.path, perms);
}
}
Ok(())
}
}
impl KeyringStore for FileKeyringStore {
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
let blob = self.load_unlocked()?;
Ok(blob.entries.get(key).cloned())
}
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
let mut blob = self.load_unlocked()?;
blob.entries.insert(key.to_string(), value.to_string());
self.store_unlocked(&blob)
}
fn delete(&self, key: &str) -> Result<(), SecretsError> {
let mut blob = self.load_unlocked()?;
blob.entries.remove(key);
self.store_unlocked(&blob)
}
fn backend_name(&self) -> &'static str {
"file-based (~/.deepseek/secrets/)"
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SecretBackendSelection {
File,
System,
Unknown,
}
fn secret_backend_selection(value: Option<&str>) -> SecretBackendSelection {
match value.map(str::trim).filter(|value| !value.is_empty()) {
None => SecretBackendSelection::File,
Some(value) => match value.to_ascii_lowercase().as_str() {
"file" | "local" | "json" => SecretBackendSelection::File,
"system" | "keyring" | "os" | "os-keyring" => SecretBackendSelection::System,
_ => SecretBackendSelection::Unknown,
},
}
}
#[derive(Clone)]
pub struct Secrets {
pub store: Arc<dyn KeyringStore>,
service: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretSource {
Keyring,
Env,
}
impl std::fmt::Debug for Secrets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Secrets")
.field("backend", &self.store.backend_name())
.field("service", &self.service)
.finish()
}
}
impl Secrets {
#[must_use]
pub fn new(store: Arc<dyn KeyringStore>) -> Self {
Self {
store,
service: DEFAULT_SERVICE.to_string(),
}
}
pub fn auto_detect() -> Self {
match secret_backend_selection(std::env::var(SECRET_BACKEND_ENV).ok().as_deref()) {
SecretBackendSelection::File => Self::file_backed_default(),
SecretBackendSelection::Unknown => {
tracing::warn!(
"{SECRET_BACKEND_ENV} has an unsupported value; using file-backed secret store"
);
Self::file_backed_default()
}
SecretBackendSelection::System => {
let default_store = DefaultKeyringStore::default();
match default_store.probe() {
Ok(()) => Self::new(Arc::new(default_store)),
Err(err) => {
tracing::warn!(
"OS keyring unavailable ({err}); falling back to file-backed secret store"
);
Self::file_backed_default()
}
}
}
}
}
fn file_backed_default() -> Self {
let path = FileKeyringStore::default_path()
.unwrap_or_else(|_| PathBuf::from(".deepseek-secrets.json"));
Self::new(Arc::new(FileKeyringStore::new(path)))
}
#[must_use]
pub fn file_backed() -> Self {
Self::file_backed_default()
}
#[must_use]
pub fn system_keyring() -> Self {
let default_store = DefaultKeyringStore::default();
match default_store.probe() {
Ok(()) => Self::new(Arc::new(default_store)),
Err(err) => {
tracing::warn!(
"OS keyring unavailable ({err}); falling back to file-backed secret store"
);
Self::file_backed_default()
}
}
}
#[must_use]
pub fn backend_name(&self) -> &'static str {
self.store.backend_name()
}
#[must_use]
pub fn resolve(&self, name: &str) -> Option<String> {
self.resolve_with_source(name).map(|(value, _)| value)
}
#[must_use]
pub fn resolve_with_source(&self, name: &str) -> Option<(String, SecretSource)> {
if let Ok(Some(v)) = self.store.get(name)
&& !v.trim().is_empty()
{
return Some((v, SecretSource::Keyring));
}
env_for(name).map(|value| (value, SecretSource::Env))
}
pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
self.store.set(name, value)
}
pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
self.store.delete(name)
}
pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
self.store.get(name)
}
}
#[must_use]
pub fn env_for(name: &str) -> Option<String> {
let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
"deepseek" => &["DEEPSEEK_API_KEY"],
"openrouter" => &["OPENROUTER_API_KEY"],
"novita" => &["NOVITA_API_KEY"],
"nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
&["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
}
"fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
"sglang" | "sg-lang" => &["SGLANG_API_KEY"],
"vllm" | "v-llm" => &["VLLM_API_KEY"],
"ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
"openai" => &["OPENAI_API_KEY"],
"atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
"wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
| "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[
"WANJIE_ARK_API_KEY",
"WANJIE_API_KEY",
"WANJIE_MAAS_API_KEY",
],
_ => return None,
};
for var in candidates {
if let Ok(value) = std::env::var(var)
&& !value.trim().is_empty()
{
return Some(value);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
fn clear_known_envs() {
for var in [
"DEEPSEEK_API_KEY",
"OPENROUTER_API_KEY",
"NOVITA_API_KEY",
"NVIDIA_API_KEY",
"NVIDIA_NIM_API_KEY",
"FIREWORKS_API_KEY",
"SGLANG_API_KEY",
"VLLM_API_KEY",
"OLLAMA_API_KEY",
"OPENAI_API_KEY",
"ATLASCLOUD_API_KEY",
"WANJIE_ARK_API_KEY",
"WANJIE_API_KEY",
"WANJIE_MAAS_API_KEY",
SECRET_BACKEND_ENV,
] {
unsafe { std::env::remove_var(var) };
}
}
#[test]
fn backend_selection_defaults_to_file() {
assert_eq!(secret_backend_selection(None), SecretBackendSelection::File);
assert_eq!(
secret_backend_selection(Some("")),
SecretBackendSelection::File
);
assert_eq!(
secret_backend_selection(Some(" file ")),
SecretBackendSelection::File
);
}
#[test]
fn backend_selection_accepts_explicit_system_keyring() {
assert_eq!(
secret_backend_selection(Some("system")),
SecretBackendSelection::System
);
assert_eq!(
secret_backend_selection(Some("keyring")),
SecretBackendSelection::System
);
assert_eq!(
secret_backend_selection(Some("os-keyring")),
SecretBackendSelection::System
);
}
#[test]
fn auto_detect_is_file_backed_by_default() {
let _lock = env_lock();
clear_known_envs();
let secrets = Secrets::auto_detect();
assert_eq!(secrets.backend_name(), "file-based (~/.deepseek/secrets/)");
}
#[test]
fn auto_detect_honors_explicit_file_backend() {
let _lock = env_lock();
clear_known_envs();
unsafe { std::env::set_var(SECRET_BACKEND_ENV, "local") };
let secrets = Secrets::auto_detect();
assert_eq!(secrets.backend_name(), "file-based (~/.deepseek/secrets/)");
unsafe { std::env::remove_var(SECRET_BACKEND_ENV) };
}
#[test]
fn in_memory_store_round_trips() {
let store = InMemoryKeyringStore::new();
assert_eq!(store.get("deepseek").unwrap(), None);
store.set("deepseek", "sk-test").unwrap();
assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
store.set("deepseek", "sk-replaced").unwrap();
assert_eq!(
store.get("deepseek").unwrap(),
Some("sk-replaced".to_string())
);
store.delete("deepseek").unwrap();
assert_eq!(store.get("deepseek").unwrap(), None);
store.delete("missing").unwrap();
}
#[test]
fn resolve_prefers_keyring_over_env() {
let _lock = env_lock();
clear_known_envs();
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
let store = Arc::new(InMemoryKeyringStore::new());
store.set("deepseek", "ring-key").unwrap();
let secrets = Secrets::new(store);
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
assert_eq!(
secrets.resolve_with_source("deepseek"),
Some(("ring-key".to_string(), SecretSource::Keyring))
);
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
}
#[test]
fn resolve_falls_back_to_env_when_keyring_empty() {
let _lock = env_lock();
clear_known_envs();
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
assert_eq!(
secrets.resolve_with_source("deepseek"),
Some(("env-fallback".to_string(), SecretSource::Env))
);
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
}
#[test]
fn resolve_returns_none_when_both_layers_empty() {
let _lock = env_lock();
clear_known_envs();
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
assert_eq!(secrets.resolve("deepseek"), None);
}
#[test]
fn resolve_treats_blank_keyring_value_as_unset() {
let _lock = env_lock();
clear_known_envs();
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
let store = Arc::new(InMemoryKeyringStore::new());
store.set("deepseek", " ").unwrap();
let secrets = Secrets::new(store);
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
}
#[test]
fn nvidia_env_aliases_resolve() {
let _lock = env_lock();
clear_known_envs();
unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
}
#[test]
fn atlascloud_env_aliases_resolve() {
let _guard = env_lock();
clear_known_envs();
unsafe { std::env::set_var("ATLASCLOUD_API_KEY", "atlas-key") };
assert_eq!(env_for("atlascloud").as_deref(), Some("atlas-key"));
assert_eq!(env_for("atlas").as_deref(), Some("atlas-key"));
assert_eq!(env_for("atlas-cloud").as_deref(), Some("atlas-key"));
clear_known_envs();
}
#[test]
fn wanjie_ark_env_aliases_resolve() {
let _guard = env_lock();
clear_known_envs();
unsafe { std::env::set_var("WANJIE_API_KEY", "wanjie-key") };
assert_eq!(env_for("wanjie-ark").as_deref(), Some("wanjie-key"));
assert_eq!(env_for("ark_wanjie").as_deref(), Some("wanjie-key"));
assert_eq!(env_for("wanjie-maas").as_deref(), Some("wanjie-key"));
clear_known_envs();
}
#[test]
fn fireworks_env_aliases_resolve() {
let _lock = env_lock();
clear_known_envs();
unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
}
#[test]
fn sglang_env_aliases_resolve() {
let _lock = env_lock();
clear_known_envs();
unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
unsafe { std::env::remove_var("SGLANG_API_KEY") };
}
#[test]
fn vllm_env_aliases_resolve() {
let _lock = env_lock();
clear_known_envs();
unsafe { std::env::set_var("VLLM_API_KEY", "vllm-key") };
assert_eq!(env_for("vllm").as_deref(), Some("vllm-key"));
assert_eq!(env_for("v-llm").as_deref(), Some("vllm-key"));
unsafe { std::env::remove_var("VLLM_API_KEY") };
}
#[test]
fn ollama_env_aliases_resolve() {
let _lock = env_lock();
clear_known_envs();
unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-key") };
assert_eq!(env_for("ollama").as_deref(), Some("ollama-key"));
assert_eq!(env_for("ollama-local").as_deref(), Some("ollama-key"));
unsafe { std::env::remove_var("OLLAMA_API_KEY") };
}
#[cfg(unix)]
#[test]
fn file_store_round_trips_with_secure_perms() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested").join("secrets.json");
let store = FileKeyringStore::new(path.clone());
assert_eq!(store.get("deepseek").unwrap(), None);
store.set("deepseek", "sk-disk").unwrap();
assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
store.set("openrouter", "or-disk").unwrap();
assert_eq!(
store.get("openrouter").unwrap(),
Some("or-disk".to_string())
);
assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
store.delete("deepseek").unwrap();
assert_eq!(store.get("deepseek").unwrap(), None);
}
#[cfg(unix)]
#[test]
fn file_store_rejects_world_readable_file() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secrets.json");
fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o644);
fs::set_permissions(&path, perms).unwrap();
let store = FileKeyringStore::new(path);
let err = store.get("deepseek").unwrap_err();
assert!(
matches!(err, SecretsError::InsecurePermissions { .. }),
"unexpected error: {err}"
);
}
#[cfg(unix)]
#[test]
fn file_store_set_does_not_clobber_secrets_when_perms_are_bad() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secrets.json");
let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
fs::write(&path, original).unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o644);
fs::set_permissions(&path, perms).unwrap();
let store = FileKeyringStore::new(path.clone());
let err = store.set("openrouter", "or-new").unwrap_err();
assert!(
matches!(err, SecretsError::InsecurePermissions { .. }),
"set must surface the read error rather than overwriting; got: {err}"
);
let on_disk = fs::read_to_string(&path).unwrap();
assert_eq!(
on_disk, original,
"set must not modify the file when load_unlocked errored"
);
}
#[cfg(unix)]
#[test]
fn file_store_delete_does_not_clobber_secrets_when_perms_are_bad() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secrets.json");
let original = "{\"entries\":{\"deepseek\":\"sk-keep\",\"nvidia\":\"nv-keep\"}}";
fs::write(&path, original).unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o644);
fs::set_permissions(&path, perms).unwrap();
let store = FileKeyringStore::new(path.clone());
let err = store.delete("nvidia").unwrap_err();
assert!(
matches!(err, SecretsError::InsecurePermissions { .. }),
"delete must surface the read error rather than wiping the file; got: {err}"
);
let on_disk = fs::read_to_string(&path).unwrap();
assert_eq!(on_disk, original);
}
#[test]
fn file_store_set_does_not_clobber_secrets_when_json_is_corrupt() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secrets.json");
fs::write(&path, "{ this is not valid json").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o600);
fs::set_permissions(&path, perms).unwrap();
}
let store = FileKeyringStore::new(path.clone());
let err = store.set("deepseek", "sk-new").unwrap_err();
assert!(
matches!(err, SecretsError::Json(_)),
"set must surface the parse error rather than wiping the file; got: {err}"
);
let on_disk = fs::read_to_string(&path).unwrap();
assert_eq!(on_disk, "{ this is not valid json");
}
#[test]
fn file_store_set_still_creates_file_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested").join("secrets.json");
let store = FileKeyringStore::new(path.clone());
store.set("deepseek", "sk-fresh").unwrap();
assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string()));
}
#[test]
fn file_store_default_path_uses_home() {
let path = FileKeyringStore::default_path().unwrap();
assert!(
path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
"unexpected default path: {}",
path.display()
);
}
}