Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

holger-cli/src/cred.rs

//! **The person's own credential, as the console holds it** — a bearer: the box's static
//! bearer, or the person's passkey session token (copied from the console, or minted by
//! `holger-server passkey`). The console keeps it in the tab's session storage; the CLI
//! keeps it in ONE file only its owner can read (mode 0600, in a 0700 directory), one
//! line per server, and refuses to read that file when anybody else could.
//!
//! Where it is looked for, first hit wins: `--token-file FILE`, then `$HOLGER_TOKEN`,
//! then the line stored for this `--at` by `holger-cli login`. It is never taken from the
//! command line itself (which every `ps` shows), and never printed.

use std::path::{Path, PathBuf};

use crate::error::CliError;

/// The environment variable a credential may come in.
pub const TOKEN_ENV: &str = "HOLGER_TOKEN";
/// Where the store lives, when set (else `$XDG_CONFIG_HOME/holger-cli`, else `~/.config/holger-cli`).
pub const HOME_ENV: &str = "HOLGER_CLI_HOME";
/// The store's file name.
pub const FILE: &str = "credentials";

/// The store's directory under `env` (a lookup, so tests never read the real environment).
pub fn store_dir(env: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
    if let Some(d) = env(HOME_ENV).filter(|d| !d.is_empty()) {
        return Some(PathBuf::from(d));
    }
    if let Some(x) = env("XDG_CONFIG_HOME").filter(|d| !d.is_empty()) {
        return Some(PathBuf::from(x).join("holger-cli"));
    }
    env("HOME")
        .filter(|d| !d.is_empty())
        .map(|h| PathBuf::from(h).join(".config").join("holger-cli"))
}

/// Pure: the store's key for a server: the `--at` as given, without a trailing slash.
pub fn key(at: &str) -> String {
    at.trim().trim_end_matches('/').to_owned()
}

/// Pure: the stored lines, `(server, credential)`. A line without a tab is skipped.
pub fn parse(text: &str) -> Vec<(String, String)> {
    text.lines()
        .filter_map(|l| l.split_once('\t'))
        .map(|(k, v)| (k.trim().to_owned(), v.trim().to_owned()))
        .filter(|(k, v)| !k.is_empty() && !v.is_empty())
        .collect()
}

/// Pure: the store with `at`'s line set to `token` (or removed when `None`), in order.
pub fn render(mut rows: Vec<(String, String)>, at: &str, token: Option<&str>) -> String {
    let k = key(at);
    rows.retain(|(r, _)| *r != k);
    if let Some(t) = token {
        rows.push((k, t.trim().to_owned()));
    }
    rows.iter().map(|(k, v)| format!("{k}\t{v}\n")).collect()
}

/// A credential's text from a file the person named.
pub fn read_token_file(path: &Path) -> Result<String, CliError> {
    let path = crate::error::existing(path)?;
    let text = std::fs::read_to_string(&path)
        .map_err(|e| CliError::Local(format!("reading {}: {e}", path.display())))?;
    let t = text.trim().to_owned();
    if t.is_empty() {
        return Err(CliError::Local(format!(
            "{} holds no credential",
            path.display()
        )));
    }
    Ok(t)
}

/// The stored credential for `at`, if any. Refused when the file could be read by anybody
/// but its owner: a credential somebody else could have read is not this person's any more.
pub fn stored(dir: &Path, at: &str) -> Result<Option<String>, CliError> {
    let file = dir.join(FILE);
    let Ok(meta) = std::fs::metadata(&file) else {
        return Ok(None);
    };
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        let mode = meta.permissions().mode() & 0o777;
        if mode & 0o077 != 0 {
            return Err(CliError::Local(format!(
                "{} is readable by others (mode {mode:o}); it holds credentials, so it must be 0600: chmod 600 it, or log in again",
                file.display()
            )));
        }
    }
    #[cfg(not(unix))]
    let _ = meta;
    let text = std::fs::read_to_string(&file)
        .map_err(|e| CliError::Local(format!("reading {}: {e}", file.display())))?;
    let k = key(at);
    Ok(parse(&text)
        .into_iter()
        .find(|(r, _)| *r == k)
        .map(|(_, v)| v))
}

/// Store (or with `None` forget) the credential for `at`: written to a fresh 0600 file in a
/// 0700 directory, then renamed over the old one, so no reader ever sees half a file.
pub fn store(dir: &Path, at: &str, token: Option<&str>) -> Result<PathBuf, CliError> {
    let local =
        |what: &str, e: std::io::Error| CliError::Local(format!("{what} {}: {e}", dir.display()));
    std::fs::create_dir_all(dir).map_err(|e| local("creating", e))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
            .map_err(|e| local("restricting", e))?;
    }
    let file = dir.join(FILE);
    let rows = match std::fs::read_to_string(&file) {
        Ok(t) => parse(&t),
        Err(_) => Vec::new(),
    };
    let body = render(rows, at, token);
    let tmp = dir.join(format!("{FILE}.tmp"));
    let _ = std::fs::remove_file(&tmp);
    {
        use std::io::Write as _;
        let mut o = std::fs::OpenOptions::new();
        o.write(true).create_new(true);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt as _;
            o.mode(0o600);
        }
        let mut f = o.open(&tmp).map_err(|e| local("writing in", e))?;
        f.write_all(body.as_bytes())
            .and_then(|()| f.sync_all())
            .map_err(|e| local("writing in", e))?;
    }
    std::fs::rename(&tmp, &file).map_err(|e| local("writing in", e))?;
    Ok(file)
}

/// **The credential a command acts with**: `--token-file`, `$HOLGER_TOKEN`, the stored one
/// for `at` — or none (an anonymous call; the server decides whether that may read).
pub fn resolve(
    token_file: Option<&Path>,
    env: impl Fn(&str) -> Option<String>,
    at: &str,
) -> Result<Option<String>, CliError> {
    if let Some(f) = token_file {
        return read_token_file(f).map(Some);
    }
    if let Some(t) = env(TOKEN_ENV)
        .map(|t| t.trim().to_owned())
        .filter(|t| !t.is_empty())
    {
        return Ok(Some(t));
    }
    match store_dir(&env) {
        Some(dir) => stored(&dir, at),
        None => Ok(None),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
        let v: Vec<(String, String)> = pairs
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect();
        move |k: &str| v.iter().find(|(n, _)| n == k).map(|(_, x)| x.clone())
    }

    #[test]
    fn the_store_is_found_where_the_person_keeps_their_config() {
        assert_eq!(
            store_dir(env_of(&[("HOLGER_CLI_HOME", "/h"), ("HOME", "/home/a")])),
            Some(PathBuf::from("/h"))
        );
        assert_eq!(
            store_dir(env_of(&[("XDG_CONFIG_HOME", "/x"), ("HOME", "/home/a")])),
            Some(PathBuf::from("/x/holger-cli"))
        );
        assert_eq!(
            store_dir(env_of(&[("HOME", "/home/a")])),
            Some(PathBuf::from("/home/a/.config/holger-cli"))
        );
        assert_eq!(store_dir(env_of(&[])), None);
    }

    #[test]
    fn one_line_per_server_set_replaced_and_forgotten() {
        let rows = parse("http://a:1\tA\nhttp://b:2\tB\nbroken line\n");
        assert_eq!(rows.len(), 2);
        let again = render(rows.clone(), "http://a:1/", Some("A2"));
        assert_eq!(again, "http://b:2\tB\nhttp://a:1\tA2\n");
        assert_eq!(render(rows, "http://b:2", None), "http://a:1\tA\n");
    }

    #[cfg(unix)]
    #[test]
    fn stored_credentials_are_the_owners_alone_and_a_readable_file_is_refused() {
        use std::os::unix::fs::PermissionsExt as _;
        let tmp = tempfile::tempdir().unwrap();
        let dir = tmp.path().join("holger-cli");
        let file = store(&dir, "http://h:50051", Some("s3cret")).unwrap();
        assert_eq!(
            std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
            0o600,
            "the file is the owner's only"
        );
        assert_eq!(
            std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777,
            0o700
        );
        assert_eq!(
            stored(&dir, "http://h:50051").unwrap().as_deref(),
            Some("s3cret")
        );
        assert_eq!(stored(&dir, "http://other:1").unwrap(), None);
        store(&dir, "http://other:1", Some("o")).unwrap();
        assert_eq!(
            stored(&dir, "http://h:50051").unwrap().as_deref(),
            Some("s3cret"),
            "a second server keeps the first"
        );
        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
        let e = stored(&dir, "http://h:50051").unwrap_err();
        assert!(e.to_string().contains("must be 0600"), "{e}");
        assert!(
            !e.to_string().contains("s3cret"),
            "a refusal never shows the credential"
        );
        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
        store(&dir, "http://h:50051", None).unwrap();
        assert_eq!(
            stored(&dir, "http://h:50051").unwrap(),
            None,
            "logout forgets it"
        );
    }

    #[test]
    fn a_token_file_beats_the_environment_which_beats_the_store() {
        let tmp = tempfile::tempdir().unwrap();
        let dir = tmp.path().to_path_buf();
        store(&dir, "http://h:1", Some("from-store")).unwrap();
        let tf = tmp.path().join("t");
        std::fs::write(&tf, "from-file\n").unwrap();
        let home = dir.display().to_string();
        assert_eq!(
            resolve(
                Some(&tf),
                env_of(&[("HOLGER_TOKEN", "from-env"), ("HOLGER_CLI_HOME", &home)]),
                "http://h:1"
            )
            .unwrap()
            .as_deref(),
            Some("from-file")
        );
        assert_eq!(
            resolve(
                None,
                env_of(&[("HOLGER_TOKEN", "from-env"), ("HOLGER_CLI_HOME", &home)]),
                "http://h:1"
            )
            .unwrap()
            .as_deref(),
            Some("from-env")
        );
        assert_eq!(
            resolve(None, env_of(&[("HOLGER_CLI_HOME", &home)]), "http://h:1")
                .unwrap()
                .as_deref(),
            Some("from-store")
        );
        assert_eq!(
            resolve(
                None,
                env_of(&[("HOLGER_CLI_HOME", &home)]),
                "http://elsewhere:1"
            )
            .unwrap(),
            None,
            "anonymous"
        );
        let missing = resolve(Some(Path::new("/no/such/token")), env_of(&[]), "x").unwrap_err();
        assert_eq!(missing.code().as_deref(), Some("holger-cli-2"));
    }
}