Skip to main content

pakx_core/
credentials.rs

1//! Credential store for `pakx login` / `pakx publish` / `pakx whoami`.
2//!
3//! Storage: `~/.pakx/credentials.json` (per-user, lazily created). One
4//! struct per known registry — a single user can be logged in to
5//! multiple pakx-registry deployments at once, keyed by base URL.
6//!
7//! File permissions: on unix the file is created with mode `0600` at
8//! the `open` call (not as a post-write chmod) — the previous
9//! `std::fs::write` then `set_permissions` flow briefly exposed the
10//! token at the default umask (typically `0o644`), readable by any
11//! other local user on a multi-user box.
12//!
13//! Atomicity: the body is written to `credentials.json.tmp` and
14//! renamed into place so a crash mid-write does not leave a
15//! half-written file. On Windows we still rely on the user-profile
16//! ACL — pakx does not mutate ACLs to keep the implementation
17//! portable.
18
19use std::collections::BTreeMap;
20use std::fs::OpenOptions;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26
27pub const DEFAULT_REGISTRY_URL: &str = "https://registry.pakx.dev";
28pub const CREDENTIALS_FILENAME: &str = "credentials.json";
29
30#[derive(Debug, Error)]
31pub enum CredentialsError {
32    #[error("could not resolve home directory")]
33    NoHomeDir,
34    #[error("credentials io error{path}: {source}", path = fmt_path(.path.as_ref()))]
35    Io {
36        #[source]
37        source: std::io::Error,
38        path: Option<PathBuf>,
39    },
40    #[error("credentials file malformed{path}: {source}", path = fmt_path(.path.as_ref()))]
41    Parse {
42        #[source]
43        source: serde_json::Error,
44        path: Option<PathBuf>,
45    },
46}
47
48/// `deny_unknown_fields`: a typo in `credentials.json` surfaces.
49///
50/// Without it, a future-version field we don't model yet would be
51/// silently dropped on round-trip — and losing the `token` field is
52/// catastrophic, so we want strict parsing here.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct Entry {
56    pub token: String,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub login: Option<String>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub created_at: Option<String>,
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct Credentials {
65    /// Map of `<registry_base_url>` → entry.
66    #[serde(default)]
67    pub registries: BTreeMap<String, Entry>,
68}
69
70impl Credentials {
71    /// Default path: `~/.pakx/credentials.json`.
72    pub fn default_path() -> Result<PathBuf, CredentialsError> {
73        let home = dirs::home_dir().ok_or(CredentialsError::NoHomeDir)?;
74        Ok(home.join(".pakx").join(CREDENTIALS_FILENAME))
75    }
76
77    /// Read from disk. Returns an empty store if the file is absent.
78    pub fn read_from(path: &Path) -> Result<Self, CredentialsError> {
79        match std::fs::read(path) {
80            Ok(bytes) => serde_json::from_slice(&bytes).map_err(|source| CredentialsError::Parse {
81                source,
82                path: Some(path.to_path_buf()),
83            }),
84            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
85            Err(source) => Err(CredentialsError::Io {
86                source,
87                path: Some(path.to_path_buf()),
88            }),
89        }
90    }
91
92    /// Write to disk. Creates the parent directory. On unix, the file
93    /// is created with mode `0600` directly via `OpenOptions::mode` —
94    /// not via a post-write `chmod` — so the token is never on disk at
95    /// the default umask.
96    ///
97    /// The write is atomic: the body lands in `<path>.tmp` first, then
98    /// `rename` swaps it into place. A crash mid-write leaves either
99    /// the old file untouched or the new file complete; never a
100    /// half-written `credentials.json`.
101    pub fn write_to(&self, path: &Path) -> Result<(), CredentialsError> {
102        if let Some(parent) = path.parent() {
103            std::fs::create_dir_all(parent).map_err(|source| CredentialsError::Io {
104                source,
105                path: Some(parent.to_path_buf()),
106            })?;
107        }
108        let body = serde_json::to_vec_pretty(self).expect("BTreeMap<String, Entry> serializes");
109
110        let tmp_path = tmp_path_for(path);
111
112        let mut opts = OpenOptions::new();
113        // `create_new(true)` instead of `create(true).truncate(true)`:
114        // `OpenOptions::mode(0o600)` is **ignored on existing files**, so
115        // a stale `<path>.tmp` from a prior crash — or one pre-planted by
116        // a co-process — would keep its prior permission bits (often
117        // `0o644` at the default umask) and the subsequent `rename` would
118        // install `credentials.json` at the wrong mode, defeating the
119        // security guarantee in exactly the crash-recovery scenario the
120        // atomic-write was meant to handle. `create_new` errors out on
121        // pre-existing `.tmp`; we unlink + retry once on `AlreadyExists`
122        // so an honest stale `.tmp` does not wedge the user.
123        opts.write(true).create_new(true);
124        #[cfg(unix)]
125        {
126            use std::os::unix::fs::OpenOptionsExt;
127            // 0600 = owner read/write, no group, no other. Setting the
128            // mode at `open` time is the atomicity guarantee — a
129            // subsequent `set_permissions` would leave a window where
130            // the file existed at the default umask.
131            opts.mode(0o600);
132        }
133
134        let mut file = match opts.open(&tmp_path) {
135            Ok(f) => f,
136            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
137                // Stale `.tmp` (prior crash, or a co-process). Unlink and
138                // retry exactly once — never loop, so an adversary
139                // racing us cannot cause an indefinite spin.
140                std::fs::remove_file(&tmp_path).map_err(|source| CredentialsError::Io {
141                    source,
142                    path: Some(tmp_path.clone()),
143                })?;
144                opts.open(&tmp_path)
145                    .map_err(|source| CredentialsError::Io {
146                        source,
147                        path: Some(tmp_path.clone()),
148                    })?
149            }
150            Err(source) => {
151                return Err(CredentialsError::Io {
152                    source,
153                    path: Some(tmp_path),
154                });
155            }
156        };
157        file.write_all(&body)
158            .map_err(|source| CredentialsError::Io {
159                source,
160                path: Some(tmp_path.clone()),
161            })?;
162        file.sync_all().map_err(|source| CredentialsError::Io {
163            source,
164            path: Some(tmp_path.clone()),
165        })?;
166        drop(file);
167
168        std::fs::rename(&tmp_path, path).map_err(|source| {
169            // On rename failure clean up the tmp so we don't leak a
170            // stale tmp file. Ignore cleanup errors — surfacing the
171            // original failure is more useful.
172            let _ = std::fs::remove_file(&tmp_path);
173            CredentialsError::Io {
174                source,
175                path: Some(path.to_path_buf()),
176            }
177        })?;
178        Ok(())
179    }
180
181    /// Convenience: read from the default location.
182    pub fn read_default() -> Result<Self, CredentialsError> {
183        let path = Self::default_path()?;
184        Self::read_from(&path)
185    }
186
187    /// Look up the token for a given registry URL. Trailing slashes
188    /// are normalised so callers do not have to.
189    #[must_use]
190    pub fn get(&self, registry_url: &str) -> Option<&Entry> {
191        let normalised = normalise(registry_url);
192        self.registries.get(&normalised)
193    }
194
195    /// Add or replace an entry. Returns the previous value.
196    pub fn set(&mut self, registry_url: &str, entry: Entry) -> Option<Entry> {
197        self.registries.insert(normalise(registry_url), entry)
198    }
199
200    /// Remove an entry. Returns the previous value.
201    pub fn remove(&mut self, registry_url: &str) -> Option<Entry> {
202        self.registries.remove(&normalise(registry_url))
203    }
204}
205
206fn normalise(url: &str) -> String {
207    url.trim_end_matches('/').to_lowercase()
208}
209
210/// Compute the temp path used by [`Credentials::write_to`]. Splitting
211/// this out lets us unit-test the rename target shape without going
212/// through the filesystem.
213fn tmp_path_for(path: &Path) -> PathBuf {
214    let mut s = path.as_os_str().to_owned();
215    s.push(".tmp");
216    PathBuf::from(s)
217}
218
219/// Render the optional `path` annotation in `CredentialsError`'s
220/// `Display`. Mirrors the redaction logic in `errors::fmt_path`: a CI
221/// log embedding the host-absolute credentials path leaks the runner
222/// workspace (and on self-hosted runners the operator's username), so
223/// we render the path relative to cwd when possible, otherwise the
224/// basename. The full absolute path remains available programmatically
225/// via the variant's `path` field.
226fn fmt_path(p: Option<&PathBuf>) -> String {
227    p.map_or_else(String::new, |path| format!(" at {}", redact(path)))
228}
229
230fn redact(path: &std::path::Path) -> String {
231    if let Ok(cwd) = std::env::current_dir() {
232        if let Ok(rel) = path.strip_prefix(&cwd) {
233            return rel.to_string_lossy().replace('\\', "/");
234        }
235    }
236    path.file_name().map_or_else(
237        || path.display().to_string(),
238        |n| n.to_string_lossy().into_owned(),
239    )
240}