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}