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, tmp_path) = match opts.open(&tmp_path) {
135            Ok(f) => (f, tmp_path),
136            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
137                // Stale `.tmp` (prior crash, or a co-process). Unlink
138                // the predictable path and retry exactly once — never
139                // loop, so an adversary racing us cannot cause an
140                // indefinite spin.
141                //
142                // **Randomised retry suffix**: if a co-process races us
143                // between unlink and reopen of the predictable
144                // `<path>.tmp`, `opts.open(&tmp_path)` could succeed
145                // against a tmp file owned by the racing process — and
146                // the subsequent rename would install *their* bytes at
147                // `credentials.json`. Using a `<path>.tmp.<pid>.<nanos>`
148                // suffix the racer cannot predict closes that window
149                // without weakening the single-retry discipline.
150                std::fs::remove_file(&tmp_path).map_err(|source| CredentialsError::Io {
151                    source,
152                    path: Some(tmp_path.clone()),
153                })?;
154                let retry_path = tmp_path_for_retry(path);
155                let f = opts
156                    .open(&retry_path)
157                    .map_err(|source| CredentialsError::Io {
158                        source,
159                        path: Some(retry_path.clone()),
160                    })?;
161                (f, retry_path)
162            }
163            Err(source) => {
164                return Err(CredentialsError::Io {
165                    source,
166                    path: Some(tmp_path),
167                });
168            }
169        };
170        file.write_all(&body)
171            .map_err(|source| CredentialsError::Io {
172                source,
173                path: Some(tmp_path.clone()),
174            })?;
175        file.sync_all().map_err(|source| CredentialsError::Io {
176            source,
177            path: Some(tmp_path.clone()),
178        })?;
179        drop(file);
180
181        std::fs::rename(&tmp_path, path).map_err(|source| {
182            // On rename failure clean up the tmp so we don't leak a
183            // stale tmp file. Ignore cleanup errors — surfacing the
184            // original failure is more useful.
185            let _ = std::fs::remove_file(&tmp_path);
186            CredentialsError::Io {
187                source,
188                path: Some(path.to_path_buf()),
189            }
190        })?;
191        Ok(())
192    }
193
194    /// Convenience: read from the default location.
195    pub fn read_default() -> Result<Self, CredentialsError> {
196        let path = Self::default_path()?;
197        Self::read_from(&path)
198    }
199
200    /// Look up the token for a given registry URL. Trailing slashes
201    /// are normalised so callers do not have to.
202    #[must_use]
203    pub fn get(&self, registry_url: &str) -> Option<&Entry> {
204        let normalised = normalise(registry_url);
205        self.registries.get(&normalised)
206    }
207
208    /// Add or replace an entry. Returns the previous value.
209    pub fn set(&mut self, registry_url: &str, entry: Entry) -> Option<Entry> {
210        self.registries.insert(normalise(registry_url), entry)
211    }
212
213    /// Remove an entry. Returns the previous value.
214    pub fn remove(&mut self, registry_url: &str) -> Option<Entry> {
215        self.registries.remove(&normalise(registry_url))
216    }
217}
218
219fn normalise(url: &str) -> String {
220    url.trim_end_matches('/').to_lowercase()
221}
222
223/// Compute the temp path used by [`Credentials::write_to`]. Splitting
224/// this out lets us unit-test the rename target shape without going
225/// through the filesystem.
226fn tmp_path_for(path: &Path) -> PathBuf {
227    let mut s = path.as_os_str().to_owned();
228    s.push(".tmp");
229    PathBuf::from(s)
230}
231
232/// Randomised tmp path used by the single-retry path inside
233/// [`Credentials::write_to`].
234///
235/// The first attempt uses the predictable `<path>.tmp`. If that one
236/// races a co-process holding the same path, we unlink and retry
237/// against a `<path>.tmp.<pid>.<nanos>` shape — both halves are
238/// process-local so a racing adversary cannot predict the next tmp
239/// path and pre-create a file we'd then write our token into. Single
240/// retry remains; only the tmp path shape changes.
241fn tmp_path_for_retry(path: &Path) -> PathBuf {
242    let nanos = std::time::SystemTime::now()
243        .duration_since(std::time::UNIX_EPOCH)
244        .map_or(0, |d| d.as_nanos());
245    let mut s = path.as_os_str().to_owned();
246    s.push(format!(".tmp.{}.{nanos}", std::process::id()));
247    PathBuf::from(s)
248}
249
250/// Render the optional `path` annotation in `CredentialsError`'s
251/// `Display`. Mirrors the redaction logic in `errors::fmt_path`: a CI
252/// log embedding the host-absolute credentials path leaks the runner
253/// workspace (and on self-hosted runners the operator's username), so
254/// we render the path relative to cwd when possible, otherwise the
255/// basename. The full absolute path remains available programmatically
256/// via the variant's `path` field.
257fn fmt_path(p: Option<&PathBuf>) -> String {
258    p.map_or_else(String::new, |path| format!(" at {}", redact(path)))
259}
260
261fn redact(path: &std::path::Path) -> String {
262    if let Ok(cwd) = std::env::current_dir() {
263        if let Ok(rel) = path.strip_prefix(&cwd) {
264            return rel.to_string_lossy().replace('\\', "/");
265        }
266    }
267    path.file_name().map_or_else(
268        || path.display().to_string(),
269        |n| n.to_string_lossy().into_owned(),
270    )
271}