use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
const SERVICE: &str = "semora";
#[derive(Debug, Default, Serialize)]
struct CredFile {
default_server: Option<String>,
servers: BTreeMap<String, ServerCred>,
}
impl<'de> Deserialize<'de> for CredFile {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Raw {
default_server: Option<String>,
#[serde(default)]
servers: BTreeMap<String, ServerCredRepr>,
}
let raw = Raw::deserialize(deserializer)?;
let mut f = CredFile {
default_server: raw.default_server,
servers: BTreeMap::new(),
};
for (server, cred) in raw.servers {
if let Ok(cred) = ServerCred::try_from(cred) {
f.servers.insert(server, cred);
}
}
f.reconcile_default();
Ok(f)
}
}
impl CredFile {
fn insert(&mut self, server: &str, cred: ServerCred) {
self.servers.insert(server.to_string(), cred);
self.default_server = Some(server.to_string());
}
fn remove(&mut self, server: &str) -> Option<ServerCred> {
let removed = self.servers.remove(server);
self.reconcile_default();
removed
}
fn reconcile_default(&mut self) {
if self
.default_server
.as_ref()
.is_none_or(|d| !self.servers.contains_key(d))
{
self.default_server = self.servers.keys().next().cloned();
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "ServerCredRepr", into = "ServerCredRepr")]
enum ServerCred {
File {
key: String,
key_name: Option<String>,
},
Keyring {
key_name: Option<String>,
},
}
impl ServerCred {
fn key_name(&self) -> Option<&str> {
match self {
ServerCred::File { key_name, .. } | ServerCred::Keyring { key_name } => {
key_name.as_deref()
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ServerCredRepr {
#[serde(default, skip_serializing_if = "Option::is_none")]
api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
key_name: Option<String>,
#[serde(default)]
in_keyring: bool,
}
impl TryFrom<ServerCredRepr> for ServerCred {
type Error = &'static str;
fn try_from(repr: ServerCredRepr) -> Result<Self, Self::Error> {
if repr.in_keyring {
Ok(ServerCred::Keyring {
key_name: repr.key_name,
})
} else if let Some(key) = repr.api_key {
Ok(ServerCred::File {
key,
key_name: repr.key_name,
})
} else {
Err("a file credential has no key")
}
}
}
impl From<ServerCred> for ServerCredRepr {
fn from(cred: ServerCred) -> Self {
match cred {
ServerCred::File { key, key_name } => ServerCredRepr {
api_key: Some(key),
key_name,
in_keyring: false,
},
ServerCred::Keyring { key_name } => ServerCredRepr {
api_key: None,
key_name,
in_keyring: true,
},
}
}
}
#[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();
match f.servers.get(server)? {
ServerCred::File { key, .. } => Some(key.clone()),
ServerCred::Keyring { .. } => keyring_get(server),
}
}
pub fn key_name(&self, server: &str) -> Option<String> {
self.read()
.servers
.get(server)
.and_then(|c| c.key_name().map(str::to_string))
}
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 {
keyring_delete(server);
}
let key_name = Some(key_name.to_string());
let cred = if in_keyring {
ServerCred::Keyring { key_name }
} else {
ServerCred::File {
key: key.to_string(),
key_name,
}
};
f.insert(server, cred);
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 matches!(f.remove(server), Some(ServerCred::Keyring { .. })) {
keyring_delete(server);
}
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);
}
#[test]
fn the_old_file_shape_still_loads() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("credentials.toml"),
r#"default_server = "https://a.example"
[servers."https://a.example"]
api_key = "sm_x"
key_name = "cli@host"
in_keyring = false
"#,
)
.unwrap();
let s = Store::new(dir.path(), false);
assert_eq!(s.get("https://a.example").as_deref(), Some("sm_x"));
assert_eq!(s.key_name("https://a.example").as_deref(), Some("cli@host"));
assert_eq!(s.default_server().as_deref(), Some("https://a.example"));
s.set("https://b.example", "sm_y", "cli@host").unwrap();
let text = std::fs::read_to_string(s.path()).unwrap();
assert!(text.contains("api_key = \"sm_y\""), "{text}");
assert!(text.contains("in_keyring = false"), "{text}");
}
#[test]
fn an_old_bad_entry_is_dropped_or_repaired() {
let text = r#"default_server = "https://missing.example"
[servers."https://no-key.example"]
key_name = "n"
in_keyring = false
[servers."https://stale.example"]
api_key = "sm_stale"
key_name = "n"
in_keyring = true
"#;
let f: CredFile = toml::from_str(text).unwrap();
assert!(
!f.servers.contains_key("https://no-key.example"),
"an entry with no key is not a stored login"
);
assert_eq!(
f.servers.get("https://stale.example"),
Some(&ServerCred::Keyring {
key_name: Some("n".into()),
}),
"a keyring entry ignores a stale plaintext copy"
);
assert_eq!(
f.default_server.as_deref(),
Some("https://stale.example"),
"the default moves to a server that exists"
);
let text = toml::to_string(&f).unwrap();
assert!(!text.contains("api_key"), "the stale copy is gone: {text}");
}
#[test]
fn default_server_follows_the_servers() {
let dir = tempfile::tempdir().unwrap();
let s = Store::new(dir.path(), false);
s.set("https://a.example", "sm_a", "a").unwrap();
s.set("https://b.example", "sm_b", "b").unwrap();
assert_eq!(s.default_server().as_deref(), Some("https://b.example"));
s.remove("https://b.example").unwrap();
assert_eq!(s.default_server().as_deref(), Some("https://a.example"));
s.remove("https://a.example").unwrap();
assert_eq!(s.default_server(), None);
}
}