1use std::{collections::BTreeMap, fs, path::PathBuf};
6
7use anyhow::{Context, Result};
8use objects::fs_atomic::write_file_atomic_secret;
9use serde::{Deserialize, Serialize};
10
11static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
12
13pub fn lock_test_env() -> std::sync::MutexGuard<'static, ()> {
14 TEST_ENV_LOCK
15 .lock()
16 .unwrap_or_else(|poisoned| poisoned.into_inner())
17}
18
19const ROTATION_WINDOW_SECS: u64 = 7 * 24 * 3600; #[derive(Debug, Serialize, Deserialize, Default)]
26pub struct CredentialStore {
27 #[serde(default)]
28 pub defaults: CredentialDefaults,
29 #[serde(default)]
30 pub servers: BTreeMap<String, ServerCredential>,
31}
32
33#[derive(Debug, Serialize, Deserialize, Default)]
35pub struct CredentialDefaults {
36 pub server: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ServerCredential {
42 pub token: String,
43 pub subject: String,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub device_id: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub credential_id: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub private_key_pem: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub expires_at: Option<String>,
52}
53
54pub fn credentials_path() -> PathBuf {
56 dirs_or_home().join("credentials.toml")
57}
58
59pub fn load_credentials() -> Result<CredentialStore> {
62 let path = credentials_path();
63 match fs::read_to_string(&path) {
64 Ok(contents) => {
65 toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))
66 }
67 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(CredentialStore::default()),
68 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
69 }
70}
71
72pub fn save_credentials(store: &CredentialStore) -> Result<()> {
74 let path = credentials_path();
75 if let Some(parent) = path.parent() {
76 fs::create_dir_all(parent)
77 .with_context(|| format!("creating directory {}", parent.display()))?;
78 }
79 let contents = toml::to_string_pretty(store).context("serializing credentials")?;
80 write_file_atomic_secret(&path, contents.as_bytes())
81 .with_context(|| format!("writing {}", path.display()))?;
82
83 Ok(())
84}
85
86pub fn get_server_credential(server: &str) -> Result<Option<ServerCredential>> {
88 let store = load_credentials()?;
89 Ok(store.servers.get(server).cloned())
90}
91
92pub fn store_server_credential(server: &str, cred: ServerCredential) -> Result<()> {
95 let mut store = load_credentials()?;
96 store.servers.insert(server.to_string(), cred);
97 if store.defaults.server.is_none() {
98 store.defaults.server = Some(server.to_string());
99 }
100 save_credentials(&store)
101}
102
103pub fn resolve_credential_for_server(server_key: &str) -> Result<Option<ServerCredential>> {
109 let store = load_credentials()?;
110
111 if let Some(cred) = store.servers.get(server_key) {
113 return Ok(Some(cred.clone()));
114 }
115
116 for prefix in &["http://", "https://", "heddle://"] {
118 let prefixed = format!("{prefix}{server_key}");
119 if let Some(cred) = store.servers.get(&prefixed) {
120 return Ok(Some(cred.clone()));
121 }
122 }
123
124 let stripped = server_key
126 .strip_prefix("http://")
127 .or_else(|| server_key.strip_prefix("https://"))
128 .or_else(|| server_key.strip_prefix("heddle://"));
129 if let Some(bare) = stripped
130 && let Some(cred) = store.servers.get(bare)
131 {
132 return Ok(Some(cred.clone()));
133 }
134
135 Ok(None)
136}
137
138pub fn remove_server_credential(server: &str) -> Result<()> {
140 let mut store = load_credentials()?;
141 store.servers.remove(server);
142 if store.defaults.server.as_deref() == Some(server) {
143 store.defaults.server = None;
144 }
145 save_credentials(&store)
146}
147
148pub fn default_server() -> Result<Option<String>> {
150 let store = load_credentials()?;
151 Ok(store.defaults.server)
152}
153
154pub fn token_needs_rotation(cred: &ServerCredential) -> bool {
164 let Some(expires_str) = cred.expires_at.as_deref() else {
165 return false;
169 };
170 let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_str) else {
171 return false;
172 };
173 let now = chrono::Utc::now().timestamp();
174 let exp = expires_at.timestamp();
175 exp.saturating_sub(now) <= ROTATION_WINDOW_SECS as i64
176}
177
178fn dirs_or_home() -> PathBuf {
180 std::env::var("HOME")
181 .map(PathBuf::from)
182 .unwrap_or_else(|_| PathBuf::from("."))
183 .join(".heddle")
184}
185
186#[cfg(test)]
187mod tests {
188 use std::{
189 fs,
190 panic::{AssertUnwindSafe, catch_unwind},
191 path::PathBuf,
192 sync::atomic::{AtomicU64, Ordering},
193 time::{SystemTime, UNIX_EPOCH},
194 };
195
196 use super::*;
197
198 static TEST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
199
200 fn unique_temp_dir(prefix: &str) -> PathBuf {
201 let unique = SystemTime::now()
202 .duration_since(UNIX_EPOCH)
203 .expect("system time before unix epoch")
204 .as_nanos();
205 let counter = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
206 std::env::temp_dir().join(format!(
207 "{prefix}-{unique}-{}-{counter}",
208 std::process::id()
209 ))
210 }
211
212 fn with_home_dir<T>(home: PathBuf, f: impl FnOnce() -> T) -> T {
213 let _guard = lock_test_env();
214 let original_home = std::env::var_os("HOME");
215 unsafe {
216 std::env::set_var("HOME", &home);
217 }
218 let result = catch_unwind(AssertUnwindSafe(f));
219 match original_home {
220 Some(value) => unsafe {
221 std::env::set_var("HOME", value);
222 },
223 None => unsafe {
224 std::env::remove_var("HOME");
225 },
226 }
227 match result {
228 Ok(value) => value,
229 Err(payload) => std::panic::resume_unwind(payload),
230 }
231 }
232
233 #[test]
234 fn save_credentials_round_trips_through_atomic_write() {
235 let home = unique_temp_dir("heddle-credentials-test");
236 fs::create_dir_all(&home).expect("create temp home");
237
238 with_home_dir(home.clone(), || {
239 let mut store = CredentialStore::default();
240 store.servers.insert(
241 "heddle.example:8421".to_string(),
242 ServerCredential {
243 token: "token-123".to_string(),
244 subject: "dev".to_string(),
245 device_id: Some("device-1".to_string()),
246 credential_id: Some("cred-1".to_string()),
247 private_key_pem: Some("pem".to_string()),
248 expires_at: Some("2026-01-01T00:00:00Z".to_string()),
249 },
250 );
251 save_credentials(&store).expect("save credentials");
252
253 let path = credentials_path();
254 assert!(path.exists(), "expected credentials file to exist");
255
256 let loaded = load_credentials().expect("load credentials");
257 let cred = loaded
258 .servers
259 .get("heddle.example:8421")
260 .expect("stored credential");
261 assert_eq!(cred.subject, "dev");
262 assert_eq!(cred.token, "token-123");
263 });
264
265 let _ = fs::remove_dir_all(home);
266 }
267
268 #[cfg(unix)]
269 #[test]
270 fn save_credentials_writes_credential_file_0600() {
271 use std::os::unix::fs::PermissionsExt;
272
273 let home = unique_temp_dir("heddle-credentials-mode-test");
274 fs::create_dir_all(&home).expect("create temp home");
275
276 with_home_dir(home.clone(), || {
277 let mut store = CredentialStore::default();
278 store.servers.insert(
279 "heddle.example:8421".to_string(),
280 ServerCredential {
281 token: "token-123".to_string(),
282 subject: "dev".to_string(),
283 device_id: None,
284 credential_id: None,
285 private_key_pem: Some("pem".to_string()),
286 expires_at: None,
287 },
288 );
289 save_credentials(&store).expect("save credentials");
290
291 let mode = fs::metadata(credentials_path())
292 .expect("credentials metadata")
293 .permissions()
294 .mode()
295 & 0o777;
296 assert_eq!(mode, 0o600);
297 });
298
299 let _ = fs::remove_dir_all(home);
300 }
301
302 #[cfg(unix)]
303 #[test]
304 fn save_credentials_permission_failure_returns_error() {
305 use std::os::unix::fs::PermissionsExt;
306
307 let home = unique_temp_dir("heddle-credentials-permission-test");
308 let heddle_dir = home.join(".heddle");
309 fs::create_dir_all(&heddle_dir).expect("create credentials dir");
310 fs::set_permissions(&heddle_dir, fs::Permissions::from_mode(0o500))
311 .expect("make credentials dir unwritable");
312
313 with_home_dir(home.clone(), || {
314 let mut store = CredentialStore::default();
315 store.servers.insert(
316 "heddle.example:8421".to_string(),
317 ServerCredential {
318 token: "token-123".to_string(),
319 subject: "dev".to_string(),
320 device_id: None,
321 credential_id: None,
322 private_key_pem: Some("pem".to_string()),
323 expires_at: None,
324 },
325 );
326
327 let err = save_credentials(&store).expect_err("permission failure must propagate");
328 assert!(
329 err.to_string().contains("writing") || err.to_string().contains("Permission"),
330 "unexpected error: {err:?}"
331 );
332 assert!(
333 !credentials_path().exists(),
334 "failed write must not publish credentials"
335 );
336 });
337
338 fs::set_permissions(&heddle_dir, fs::Permissions::from_mode(0o700))
339 .expect("restore credentials dir");
340 let _ = fs::remove_dir_all(home);
341 }
342
343 #[test]
344 fn resolve_credential_for_server_accepts_scheme_prefixed_keys() {
345 let home = unique_temp_dir("heddle-credentials-test");
346 fs::create_dir_all(&home).expect("create temp home");
347
348 with_home_dir(home.clone(), || {
349 let mut store = CredentialStore::default();
350 store.servers.insert(
351 "http://heddle.example:8421".to_string(),
352 ServerCredential {
353 token: "token-abc".to_string(),
354 subject: "dev".to_string(),
355 device_id: None,
356 credential_id: None,
357 private_key_pem: None,
358 expires_at: None,
359 },
360 );
361 save_credentials(&store).expect("save credentials");
362
363 let resolved = resolve_credential_for_server("heddle.example:8421")
364 .expect("resolve credential")
365 .expect("scheme-prefixed credential");
366 assert_eq!(resolved.token, "token-abc");
367 assert_eq!(resolved.subject, "dev");
368 });
369
370 let _ = fs::remove_dir_all(home);
371 }
372}