use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::desk::DeskKey;
#[cfg(any(target_os = "macos", windows))]
const SERVICE: &str = "snyvi";
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Kept {
Keychain,
File,
}
#[derive(Clone, Debug)]
pub struct Secrets {
file: PathBuf,
#[cfg_attr(not(any(target_os = "macos", windows)), allow(dead_code))]
keychain: bool,
}
#[cfg(not(any(target_os = "macos", windows)))]
struct NoKeychain;
#[cfg(not(any(target_os = "macos", windows)))]
impl NoKeychain {
fn set_password(&self, _value: &str) -> std::result::Result<(), ()> {
Err(())
}
fn get_password(&self) -> std::result::Result<String, ()> {
Err(())
}
fn delete_credential(&self) -> std::result::Result<(), ()> {
Err(())
}
}
impl Secrets {
pub fn new(file: PathBuf) -> Self {
Self {
file,
keychain: true,
}
}
#[cfg(test)]
pub fn file_only(file: PathBuf) -> Self {
Self {
file,
keychain: false,
}
}
fn account(desk: i64, name: &str) -> String {
format!("{desk}/{name}")
}
#[cfg(any(target_os = "macos", windows))]
fn entry(&self, account: &str) -> Option<keyring::Entry> {
if !self.keychain {
return None;
}
keyring::Entry::new(SERVICE, account).ok()
}
#[cfg(not(any(target_os = "macos", windows)))]
fn entry(&self, _account: &str) -> Option<NoKeychain> {
None
}
pub fn keep(&self, desk: i64, name: &str, value: &str) -> Result<Kept> {
let account = Self::account(desk, name);
if let Some(e) = self.entry(&account) {
if e.set_password(value).is_ok() {
self.edit(|m| {
m.remove(&account);
})?;
return Ok(Kept::Keychain);
}
}
self.edit(|m| {
m.insert(account, value.to_string());
})?;
Ok(Kept::File)
}
pub fn value(&self, desk: i64, name: &str) -> Option<String> {
let account = Self::account(desk, name);
if let Some(e) = self.entry(&account) {
if let Ok(v) = e.get_password() {
return Some(v);
}
}
self.read().remove(&account)
}
pub fn forget(&self, desk: i64, name: &str) {
let account = Self::account(desk, name);
if let Some(e) = self.entry(&account) {
let _ = e.delete_credential();
}
let _ = self.edit(|m| {
m.remove(&account);
});
}
pub fn values(&self, keys: &[DeskKey]) -> Vec<(String, String)> {
keys.iter()
.filter_map(|k| self.value(k.desk_id, &k.name).map(|v| (k.name.clone(), v)))
.collect()
}
fn read(&self) -> BTreeMap<String, String> {
std::fs::read(&self.file)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn edit(&self, f: impl FnOnce(&mut BTreeMap<String, String>)) -> Result<()> {
let mut m = self.read();
let was = m.clone();
f(&mut m);
if m == was {
return Ok(());
}
if m.is_empty() {
let _ = std::fs::remove_file(&self.file);
return Ok(());
}
write_private(&self.file, &serde_json::to_vec_pretty(&m)?)
}
}
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
let mut o = std::fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
let mut f = o
.open(&tmp)
.with_context(|| format!("writing {}", tmp.display()))?;
f.write_all(bytes)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn valid_name(name: &str) -> std::result::Result<(), &'static str> {
if name.is_empty() || name.len() > 64 {
return Err("a name is 1 to 64 characters");
}
let shape = name.bytes().next().is_some_and(|b| b.is_ascii_uppercase())
&& name
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_');
if !shape {
return Err(
"a name is capitals, digits and underscores, starting with a capital, like OPENROUTER_API_KEY",
);
}
if name.starts_with("SNYVI_") {
return Err("SNYVI_ names are snyvi's own");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn key(desk: i64, name: &str) -> DeskKey {
DeskKey {
desk_id: desk,
name: name.into(),
provider: String::new(),
created_at: 0,
used_at: 0,
}
}
#[test]
fn values_go_to_a_file_only_the_owner_reads_when_no_keychain_answers() {
let dir = crate::store::tempdir::Dir::new("snyvi-keys");
let file = dir.path.join("keys.json");
let s = Secrets::file_only(file.clone());
assert_eq!(s.value(1, "GH_TOKEN"), None);
assert_eq!(s.keep(1, "GH_TOKEN", "ghp_one").unwrap(), Kept::File);
assert_eq!(s.keep(0, "GH_TOKEN", "ghp_all").unwrap(), Kept::File);
assert_eq!(s.value(1, "GH_TOKEN").as_deref(), Some("ghp_one"));
assert_eq!(s.value(2, "GH_TOKEN"), None);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&file).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "only its owner reads it");
}
assert_eq!(
s.values(&[key(1, "GH_TOKEN"), key(0, "OPENROUTER_API_KEY")]),
vec![("GH_TOKEN".to_string(), "ghp_one".to_string())]
);
s.forget(1, "GH_TOKEN");
assert_eq!(s.value(1, "GH_TOKEN"), None);
assert_eq!(s.value(0, "GH_TOKEN").as_deref(), Some("ghp_all"));
s.forget(0, "GH_TOKEN");
assert!(!file.exists());
s.forget(0, "GH_TOKEN");
}
#[test]
fn a_name_is_an_environment_variable_and_not_one_of_snyvis() {
assert_eq!(valid_name("OPENROUTER_API_KEY"), Ok(()));
assert_eq!(valid_name("GH_TOKEN"), Ok(()));
assert_eq!(valid_name("AWS_ACCESS_KEY_ID"), Ok(()));
assert!(valid_name("").is_err());
assert!(valid_name("gh_token").is_err());
assert!(valid_name("1KEY").is_err());
assert!(valid_name("GH-TOKEN").is_err());
assert!(valid_name("GH TOKEN").is_err());
assert!(valid_name("SNYVI_SESSION").is_err());
assert!(valid_name(&"A".repeat(65)).is_err());
}
}