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"));
}
}