use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
const SERVICE: &str = "semora";
#[derive(Debug, Default, Serialize, Deserialize)]
struct CredFile {
default_server: Option<String>,
#[serde(default)]
servers: BTreeMap<String, ServerCred>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct ServerCred {
api_key: Option<String>,
key_name: Option<String>,
#[serde(default)]
in_keyring: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stored {
Keyring,
File,
}
pub struct Store {
dir: PathBuf,
use_keyring: bool,
}
impl Store {
pub fn new(dir: &Path, use_keyring: bool) -> Store {
Store {
dir: dir.to_path_buf(),
use_keyring: use_keyring && keyring_supported(),
}
}
pub fn path(&self) -> PathBuf {
self.dir.join("credentials.toml")
}
fn read(&self) -> CredFile {
std::fs::read_to_string(self.path())
.ok()
.and_then(|t| toml::from_str(&t).ok())
.unwrap_or_default()
}
fn write(&self, f: &CredFile) -> anyhow::Result<()> {
std::fs::create_dir_all(&self.dir)
.with_context(|| format!("creating {}", self.dir.display()))?;
let text = toml::to_string_pretty(f)?;
write_private(&self.path(), text.as_bytes())
}
pub fn default_server(&self) -> Option<String> {
self.read().default_server
}
pub fn get(&self, server: &str) -> Option<String> {
let f = self.read();
let cred = f.servers.get(server)?;
if cred.in_keyring {
keyring_get(server)
} else {
cred.api_key.clone()
}
}
pub fn key_name(&self, server: &str) -> Option<String> {
self.read()
.servers
.get(server)
.and_then(|c| c.key_name.clone())
}
pub fn set(&self, server: &str, key: &str, key_name: &str) -> anyhow::Result<Stored> {
let mut f = self.read();
let in_keyring = self.use_keyring && keyring_set(server, key).is_ok();
if !in_keyring && f.servers.get(server).is_some_and(|old| old.in_keyring) {
keyring_delete(server);
}
f.servers.insert(
server.to_string(),
ServerCred {
api_key: (!in_keyring).then(|| key.to_string()),
key_name: Some(key_name.to_string()),
in_keyring,
},
);
f.default_server = Some(server.to_string());
self.write(&f)?;
Ok(if in_keyring {
Stored::Keyring
} else {
Stored::File
})
}
pub fn remove(&self, server: &str) -> anyhow::Result<()> {
let mut f = self.read();
if let Some(c) = f.servers.remove(server)
&& c.in_keyring
{
keyring_delete(server);
}
if f.default_server.as_deref() == Some(server) {
f.default_server = f.servers.keys().next().cloned();
}
self.write(&f)
}
}
fn write_private(path: &Path, data: &[u8]) -> anyhow::Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
f.write_all(data)?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, data).with_context(|| format!("writing {}", path.display()))
}
}
#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
fn keyring_supported() -> bool {
true
}
#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
fn keyring_supported() -> bool {
false
}
#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
fn keyring_get(server: &str) -> Option<String> {
keyring::Entry::new(SERVICE, server)
.ok()?
.get_password()
.ok()
}
#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
fn keyring_set(server: &str, key: &str) -> anyhow::Result<()> {
keyring::Entry::new(SERVICE, server)?.set_password(key)?;
Ok(())
}
#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
fn keyring_delete(server: &str) {
if let Ok(e) = keyring::Entry::new(SERVICE, server) {
let _ = e.delete_credential();
}
}
#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
fn keyring_get(_server: &str) -> Option<String> {
let _ = SERVICE;
None
}
#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
fn keyring_set(_server: &str, _key: &str) -> anyhow::Result<()> {
anyhow::bail!("no keyring on this platform")
}
#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
fn keyring_delete(_server: &str) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_store_round_trip() {
let dir = tempfile::tempdir().unwrap();
let s = Store::new(dir.path(), false);
assert_eq!(s.get("https://a.example"), None);
assert_eq!(
s.set("https://a.example", "sm_x", "cli@host").unwrap(),
Stored::File
);
assert_eq!(s.get("https://a.example").as_deref(), Some("sm_x"));
assert_eq!(s.default_server().as_deref(), Some("https://a.example"));
assert_eq!(s.key_name("https://a.example").as_deref(), Some("cli@host"));
s.remove("https://a.example").unwrap();
assert_eq!(s.get("https://a.example"), None);
assert_eq!(s.default_server(), None);
}
#[cfg(unix)]
#[test]
fn credentials_file_is_private() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let s = Store::new(dir.path(), false);
s.set("https://a.example", "sm_x", "n").unwrap();
let mode = std::fs::metadata(s.path()).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
}