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(
49 default,
50 alias = "private_key",
51 skip_serializing_if = "Option::is_none"
52 )]
53 pub private_key_pem: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub expires_at: Option<String>,
56}
57
58pub fn credentials_path() -> PathBuf {
63 repo::identity::heddle_home_dir().join("credentials.toml")
64}
65
66pub fn load_credentials() -> Result<CredentialStore> {
69 let path = credentials_path();
70 match fs::read_to_string(&path) {
71 Ok(contents) => {
72 toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))
73 }
74 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(CredentialStore::default()),
75 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
76 }
77}
78
79pub fn save_credentials(store: &CredentialStore) -> Result<()> {
81 let path = credentials_path();
82 if let Some(parent) = path.parent() {
83 objects::fs_atomic::create_private_dir_all(parent)
84 .with_context(|| format!("creating directory {}", parent.display()))?;
85 }
86 let contents = toml::to_string_pretty(store).context("serializing credentials")?;
87 write_file_atomic_secret(&path, contents.as_bytes())
88 .with_context(|| format!("writing {}", path.display()))?;
89
90 Ok(())
91}
92
93pub fn get_server_credential(server: &str) -> Result<Option<ServerCredential>> {
95 let store = load_credentials()?;
96 Ok(store.servers.get(server).cloned())
97}
98
99pub fn store_server_credential(server: &str, cred: ServerCredential) -> Result<()> {
102 let mut store = load_credentials()?;
103 store.servers.insert(server.to_string(), cred);
104 if store.defaults.server.is_none() {
105 store.defaults.server = Some(server.to_string());
106 }
107 save_credentials(&store)
108}
109
110pub fn resolve_credential_for_server(server_key: &str) -> Result<Option<ServerCredential>> {
116 let store = load_credentials()?;
117
118 if let Some(cred) = store.servers.get(server_key) {
120 return Ok(Some(cred.clone()));
121 }
122
123 for prefix in &["http://", "https://", "heddle://"] {
125 let prefixed = format!("{prefix}{server_key}");
126 if let Some(cred) = store.servers.get(&prefixed) {
127 return Ok(Some(cred.clone()));
128 }
129 }
130
131 let stripped = server_key
133 .strip_prefix("http://")
134 .or_else(|| server_key.strip_prefix("https://"))
135 .or_else(|| server_key.strip_prefix("heddle://"));
136 if let Some(bare) = stripped
137 && let Some(cred) = store.servers.get(bare)
138 {
139 return Ok(Some(cred.clone()));
140 }
141
142 Ok(None)
143}
144
145pub fn remove_server_credential(server: &str) -> Result<()> {
147 let mut store = load_credentials()?;
148 store.servers.remove(server);
149 if store.defaults.server.as_deref() == Some(server) {
150 store.defaults.server = None;
151 }
152 save_credentials(&store)
153}
154
155pub fn default_server() -> Result<Option<String>> {
157 let store = load_credentials()?;
158 Ok(store.defaults.server)
159}
160
161pub fn token_needs_rotation(cred: &ServerCredential) -> bool {
171 let Some(expires_str) = cred.expires_at.as_deref() else {
172 return false;
176 };
177 let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_str) else {
178 return false;
179 };
180 let now = chrono::Utc::now().timestamp();
181 let exp = expires_at.timestamp();
182 exp.saturating_sub(now) <= ROTATION_WINDOW_SECS as i64
183}
184
185#[cfg(test)]
186mod tests {
187 use std::{
188 fs,
189 panic::{AssertUnwindSafe, catch_unwind},
190 path::PathBuf,
191 sync::atomic::{AtomicU64, Ordering},
192 time::{SystemTime, UNIX_EPOCH},
193 };
194
195 use super::*;
196
197 static TEST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
198
199 fn unique_temp_dir(prefix: &str) -> PathBuf {
200 let unique = SystemTime::now()
201 .duration_since(UNIX_EPOCH)
202 .expect("system time before unix epoch")
203 .as_nanos();
204 let counter = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
205 std::env::temp_dir().join(format!(
206 "{prefix}-{unique}-{}-{counter}",
207 std::process::id()
208 ))
209 }
210
211 fn with_home_dir<T>(home: PathBuf, f: impl FnOnce() -> T) -> T {
212 let _guard = lock_test_env();
213 let original_home = std::env::var_os("HOME");
214 let original_heddle_home = std::env::var_os("HEDDLE_HOME");
215 unsafe {
216 std::env::set_var("HOME", &home);
217 std::env::remove_var("HEDDLE_HOME");
219 }
220 let result = catch_unwind(AssertUnwindSafe(f));
221 match original_home {
222 Some(value) => unsafe {
223 std::env::set_var("HOME", value);
224 },
225 None => unsafe {
226 std::env::remove_var("HOME");
227 },
228 }
229 match original_heddle_home {
230 Some(value) => unsafe {
231 std::env::set_var("HEDDLE_HOME", value);
232 },
233 None => unsafe {
234 std::env::remove_var("HEDDLE_HOME");
235 },
236 }
237 match result {
238 Ok(value) => value,
239 Err(payload) => std::panic::resume_unwind(payload),
240 }
241 }
242
243 #[test]
244 fn save_credentials_round_trips_through_atomic_write() {
245 let home = unique_temp_dir("heddle-credentials-test");
246 fs::create_dir_all(&home).expect("create temp home");
247
248 with_home_dir(home.clone(), || {
249 let mut store = CredentialStore::default();
250 store.servers.insert(
251 "heddle.example:8421".to_string(),
252 ServerCredential {
253 token: "token-123".to_string(),
254 subject: "dev".to_string(),
255 device_id: Some("device-1".to_string()),
256 credential_id: Some("cred-1".to_string()),
257 private_key_pem: Some("pem".to_string()),
258 expires_at: Some("2026-01-01T00:00:00Z".to_string()),
259 },
260 );
261 save_credentials(&store).expect("save credentials");
262
263 let path = credentials_path();
264 assert!(path.exists(), "expected credentials file to exist");
265
266 let loaded = load_credentials().expect("load credentials");
267 let cred = loaded
268 .servers
269 .get("heddle.example:8421")
270 .expect("stored credential");
271 assert_eq!(cred.subject, "dev");
272 assert_eq!(cred.token, "token-123");
273 });
274
275 let _ = fs::remove_dir_all(home);
276 }
277
278 #[test]
279 fn legacy_private_key_loads_and_saves_as_private_key_pem() {
280 let legacy = r#"
281[servers."heddle.example:8421"]
282token = "token-123"
283subject = "dev"
284private_key = "legacy-pem"
285"#;
286
287 let store: CredentialStore = toml::from_str(legacy).expect("load legacy credential");
288 let credential = store
289 .servers
290 .get("heddle.example:8421")
291 .expect("legacy credential");
292 assert_eq!(credential.private_key_pem.as_deref(), Some("legacy-pem"));
293
294 let canonical = toml::to_string_pretty(&store).expect("serialize canonical credential");
295 assert!(canonical.contains("private_key_pem = \"legacy-pem\""));
296 assert!(!canonical.contains("\nprivate_key ="));
297 }
298
299 #[cfg(unix)]
300 #[test]
301 fn save_credentials_writes_credential_file_0600() {
302 use std::os::unix::fs::PermissionsExt;
303
304 let home = unique_temp_dir("heddle-credentials-mode-test");
305 fs::create_dir_all(&home).expect("create temp home");
306
307 with_home_dir(home.clone(), || {
308 let mut store = CredentialStore::default();
309 store.servers.insert(
310 "heddle.example:8421".to_string(),
311 ServerCredential {
312 token: "token-123".to_string(),
313 subject: "dev".to_string(),
314 device_id: None,
315 credential_id: None,
316 private_key_pem: Some("pem".to_string()),
317 expires_at: None,
318 },
319 );
320 save_credentials(&store).expect("save credentials");
321
322 let mode = fs::metadata(credentials_path())
323 .expect("credentials metadata")
324 .permissions()
325 .mode()
326 & 0o777;
327 assert_eq!(mode, 0o600);
328 });
329
330 let _ = fs::remove_dir_all(home);
331 }
332
333 #[cfg(unix)]
334 #[test]
335 fn save_credentials_permission_failure_returns_error() {
336 use std::os::unix::fs::PermissionsExt;
337
338 let home = unique_temp_dir("heddle-credentials-permission-test");
339 let heddle_dir = home.join(".heddle");
340 fs::create_dir_all(&heddle_dir).expect("create credentials dir");
341 fs::set_permissions(&heddle_dir, fs::Permissions::from_mode(0o500))
342 .expect("make credentials dir unwritable");
343
344 with_home_dir(home.clone(), || {
345 let mut store = CredentialStore::default();
346 store.servers.insert(
347 "heddle.example:8421".to_string(),
348 ServerCredential {
349 token: "token-123".to_string(),
350 subject: "dev".to_string(),
351 device_id: None,
352 credential_id: None,
353 private_key_pem: Some("pem".to_string()),
354 expires_at: None,
355 },
356 );
357
358 let err = save_credentials(&store).expect_err("permission failure must propagate");
359 assert!(
360 err.to_string().contains("writing") || err.to_string().contains("Permission"),
361 "unexpected error: {err:?}"
362 );
363 assert!(
364 !credentials_path().exists(),
365 "failed write must not publish credentials"
366 );
367 });
368
369 fs::set_permissions(&heddle_dir, fs::Permissions::from_mode(0o700))
370 .expect("restore credentials dir");
371 let _ = fs::remove_dir_all(home);
372 }
373
374 #[test]
375 fn resolve_credential_for_server_accepts_scheme_prefixed_keys() {
376 let home = unique_temp_dir("heddle-credentials-test");
377 fs::create_dir_all(&home).expect("create temp home");
378
379 with_home_dir(home.clone(), || {
380 let mut store = CredentialStore::default();
381 store.servers.insert(
382 "http://heddle.example:8421".to_string(),
383 ServerCredential {
384 token: "token-abc".to_string(),
385 subject: "dev".to_string(),
386 device_id: None,
387 credential_id: None,
388 private_key_pem: None,
389 expires_at: None,
390 },
391 );
392 save_credentials(&store).expect("save credentials");
393
394 let resolved = resolve_credential_for_server("heddle.example:8421")
395 .expect("resolve credential")
396 .expect("scheme-prefixed credential");
397 assert_eq!(resolved.token, "token-abc");
398 assert_eq!(resolved.subject, "dev");
399 });
400
401 let _ = fs::remove_dir_all(home);
402 }
403
404 #[test]
405 fn credentials_path_honors_heddle_home() {
406 let home = unique_temp_dir("heddle-credentials-heddle-home");
407 fs::create_dir_all(&home).expect("create temp home");
408 let heddle_home = home.join("custom-heddle");
409
410 let _guard = lock_test_env();
411 let original_home = std::env::var_os("HOME");
412 let original_heddle_home = std::env::var_os("HEDDLE_HOME");
413 unsafe {
414 std::env::set_var("HOME", &home);
415 std::env::set_var("HEDDLE_HOME", &heddle_home);
416 }
417 let path = credentials_path();
418 match original_home {
419 Some(value) => unsafe {
420 std::env::set_var("HOME", value);
421 },
422 None => unsafe {
423 std::env::remove_var("HOME");
424 },
425 }
426 match original_heddle_home {
427 Some(value) => unsafe {
428 std::env::set_var("HEDDLE_HOME", value);
429 },
430 None => unsafe {
431 std::env::remove_var("HEDDLE_HOME");
432 },
433 }
434
435 assert_eq!(path, heddle_home.join("credentials.toml"));
436 let _ = fs::remove_dir_all(home);
437 }
438}