Skip to main content

memstead_cli/auth/
credentials.rs

1//! Persistent credential store for the `memstead` CLI.
2//!
3//! Layout: `<config_dir>/memstead/credentials` — TOML, keyed on registry
4//! host so the same CLI can talk to staging + production without
5//! juggling files. Hostnames are lowercased so `Memstead.io` and
6//! `memstead.io` resolve to the same entry.
7//!
8//! Example on disk:
9//! ```toml
10//! [registries."memstead.io"]
11//! token = "gho_..."
12//! user_login = "you"
13//! scopes = ["read:user"]
14//! obtained_at = "2026-04-16T08:12:34Z"
15//! ```
16//!
17//! File mode is locked to `0600` on Unix. Windows inherits default
18//! user ACL — tightening that is out of scope for Session E.
19
20use std::collections::BTreeMap;
21use std::path::PathBuf;
22
23use anyhow::{Context, Result};
24use serde::{Deserialize, Serialize};
25use time::OffsetDateTime;
26use time::format_description::well_known::Rfc3339;
27
28/// One credentials block.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Entry {
31    pub token: String,
32    pub user_login: String,
33    #[serde(default)]
34    pub scopes: Vec<String>,
35    /// RFC 3339 UTC timestamp the token was obtained.
36    pub obtained_at: String,
37}
38
39impl Entry {
40    pub fn new(token: String, user_login: String, scopes: Vec<String>) -> Self {
41        let obtained_at = OffsetDateTime::now_utc()
42            .format(&Rfc3339)
43            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
44        Self {
45            token,
46            user_login,
47            scopes,
48            obtained_at,
49        }
50    }
51}
52
53#[derive(Debug, Default, Serialize, Deserialize)]
54struct Store {
55    #[serde(default)]
56    registries: BTreeMap<String, Entry>,
57}
58
59/// Absolute path to the credentials file. Caller is free to dereference
60/// this even when the file itself is missing; the read functions
61/// handle ENOENT.
62pub fn credentials_path() -> Result<PathBuf> {
63    let base = dirs::config_dir()
64        .context("no config directory resolvable on this platform (set $XDG_CONFIG_HOME)")?;
65    Ok(base.join("memstead").join("credentials"))
66}
67
68/// Test hook: point the credentials file at a caller-specified path.
69/// Honoured via the `MEMSTEAD_CREDENTIALS_FILE` env var — chosen over a
70/// separate function parameter so every subcommand that calls into
71/// auth picks it up uniformly.
72fn resolved_path() -> Result<PathBuf> {
73    if let Ok(override_path) = std::env::var("MEMSTEAD_CREDENTIALS_FILE")
74        && !override_path.is_empty()
75    {
76        return Ok(PathBuf::from(override_path));
77    }
78    credentials_path()
79}
80
81fn load_store() -> Result<Store> {
82    let path = resolved_path()?;
83    match std::fs::read_to_string(&path) {
84        Ok(s) => toml::from_str(&s)
85            .with_context(|| format!("parsing credentials file at {}", path.display())),
86        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Store::default()),
87        Err(e) => Err(e).with_context(|| format!("reading credentials file at {}", path.display())),
88    }
89}
90
91fn save_store(store: &Store) -> Result<()> {
92    let path = resolved_path()?;
93    if let Some(parent) = path.parent() {
94        std::fs::create_dir_all(parent)
95            .with_context(|| format!("creating credentials dir at {}", parent.display()))?;
96    }
97    let body = toml::to_string(store).context("serializing credentials TOML")?;
98    std::fs::write(&path, body)
99        .with_context(|| format!("writing credentials file at {}", path.display()))?;
100    tighten_permissions(&path)?;
101    Ok(())
102}
103
104#[cfg(unix)]
105fn tighten_permissions(path: &std::path::Path) -> Result<()> {
106    use std::os::unix::fs::PermissionsExt;
107    let perms = std::fs::Permissions::from_mode(0o600);
108    std::fs::set_permissions(path, perms)
109        .with_context(|| format!("setting mode 0600 on {}", path.display()))?;
110    Ok(())
111}
112
113#[cfg(not(unix))]
114fn tighten_permissions(_: &std::path::Path) -> Result<()> {
115    Ok(())
116}
117
118/// Retrieve the credentials entry for a registry host, or `None` if
119/// nothing is stored. Missing file is treated as "no credentials".
120pub fn load_for(host: &str) -> Result<Option<Entry>> {
121    let store = load_store()?;
122    Ok(store.registries.get(&host.to_ascii_lowercase()).cloned())
123}
124
125/// Persist a credentials entry for a registry host. Overwrites any
126/// existing entry for that host.
127pub fn save_for(host: &str, entry: Entry) -> Result<()> {
128    let mut store = load_store()?;
129    store.registries.insert(host.to_ascii_lowercase(), entry);
130    save_store(&store)
131}
132
133/// Remove a credentials entry. Returns true if something was removed.
134/// Non-existent host is a silent no-op (returns false).
135pub fn remove_for(host: &str) -> Result<bool> {
136    let mut store = load_store()?;
137    let removed = store
138        .registries
139        .remove(&host.to_ascii_lowercase())
140        .is_some();
141    if removed {
142        save_store(&store)?;
143    }
144    Ok(removed)
145}