semora-cli 0.4.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! Stored logins: the OS keyring (Windows, macOS) or `credentials.toml` (mode 600).

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 {
    /// Present when the key lives in this file rather than the keyring.
    api_key: Option<String>,
    key_name: Option<String>,
    #[serde(default)]
    in_keyring: bool,
}

/// Where a stored key ended up.
#[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
    }

    /// The stored key for `server` (an origin like `https://semora.example.com`).
    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();
        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);
    }
}