1use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12use time::OffsetDateTime;
13
14pub const SCHEMA_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct AuthFile {
21 pub schema_version: u32,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub admin: Option<AdminSection>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub read_uplift: Option<ReadUpliftSection>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct AdminSection {
34 pub user_id: String,
36 pub token: String,
38 #[serde(with = "time::serde::rfc3339")]
40 pub expires_at: OffsetDateTime,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct ReadUpliftSection {
46 pub github_login: String,
48 pub token: String,
50 #[serde(with = "time::serde::rfc3339")]
52 pub expires_at: OffsetDateTime,
53}
54
55impl AuthFile {
56 pub fn read_optional(path: &Path) -> Result<Option<Self>, AuthFileError> {
70 match std::fs::metadata(path) {
71 Ok(md) => {
72 check_permissions(path, &md)?;
73 let body = std::fs::read_to_string(path).map_err(|e| AuthFileError::Io {
74 path: path.to_path_buf(),
75 message: e.to_string(),
76 })?;
77 let file: Self = toml::from_str(&body).map_err(|e| AuthFileError::Parse {
78 path: path.to_path_buf(),
79 message: e.to_string(),
80 })?;
81 if file.schema_version != SCHEMA_VERSION {
82 return Err(AuthFileError::SchemaVersionMismatch {
83 path: path.to_path_buf(),
84 found: file.schema_version,
85 expected: SCHEMA_VERSION,
86 });
87 }
88 Ok(Some(file))
89 }
90 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
91 Err(e) => Err(AuthFileError::Io {
92 path: path.to_path_buf(),
93 message: e.to_string(),
94 }),
95 }
96 }
97
98 #[must_use]
100 pub fn active_admin_token(&self, now: OffsetDateTime) -> Option<&str> {
101 self.admin
102 .as_ref()
103 .filter(|a| now < a.expires_at)
104 .map(|a| a.token.as_str())
105 }
106
107 #[must_use]
109 pub fn active_read_uplift_token(&self, now: OffsetDateTime) -> Option<&str> {
110 self.read_uplift
111 .as_ref()
112 .filter(|r| now < r.expires_at)
113 .map(|r| r.token.as_str())
114 }
115
116 pub fn to_toml(&self) -> Result<String, AuthFileError> {
125 toml::to_string(self).map_err(|e| AuthFileError::Serialize(e.to_string()))
126 }
127
128 pub fn write(&self, path: &Path) -> Result<(), AuthFileError> {
144 if let Ok(md) = std::fs::metadata(path) {
148 check_permissions(path, &md)?;
149 }
150 let body = self.to_toml()?;
151 atomic_write(path, &body)?;
152 Ok(())
153 }
154
155 pub fn write_admin_token(
164 path: &Path,
165 user_id: impl Into<String>,
166 token: impl Into<String>,
167 expires_at: OffsetDateTime,
168 ) -> Result<(), AuthFileError> {
169 let mut file = Self::read_optional(path)?.unwrap_or_else(Self::empty);
170 file.admin = Some(AdminSection {
171 user_id: user_id.into(),
172 token: token.into(),
173 expires_at,
174 });
175 file.write(path)
176 }
177
178 pub fn write_read_uplift_token(
185 path: &Path,
186 github_login: impl Into<String>,
187 token: impl Into<String>,
188 expires_at: OffsetDateTime,
189 ) -> Result<(), AuthFileError> {
190 let mut file = Self::read_optional(path)?.unwrap_or_else(Self::empty);
191 file.read_uplift = Some(ReadUpliftSection {
192 github_login: github_login.into(),
193 token: token.into(),
194 expires_at,
195 });
196 file.write(path)
197 }
198
199 #[must_use]
201 pub const fn empty() -> Self {
202 Self {
203 schema_version: SCHEMA_VERSION,
204 admin: None,
205 read_uplift: None,
206 }
207 }
208}
209
210fn atomic_write(path: &Path, body: &str) -> Result<(), AuthFileError> {
214 use std::io::Write as _;
215 if let Some(parent) = path.parent() {
216 if !parent.as_os_str().is_empty() {
217 std::fs::create_dir_all(parent).map_err(|e| AuthFileError::Io {
218 path: parent.to_path_buf(),
219 message: e.to_string(),
220 })?;
221 }
222 }
223 let tmp = path.with_extension("toml.tmp");
224 let file_handle = open_restricted(&tmp)?;
225 {
226 let mut bw = std::io::BufWriter::new(file_handle);
227 bw.write_all(body.as_bytes())
228 .map_err(|e| AuthFileError::Io {
229 path: tmp.clone(),
230 message: e.to_string(),
231 })?;
232 bw.flush().map_err(|e| AuthFileError::Io {
233 path: tmp.clone(),
234 message: e.to_string(),
235 })?;
236 }
237 std::fs::rename(&tmp, path).map_err(|e| AuthFileError::Io {
238 path: path.to_path_buf(),
239 message: format!("rename {} -> {}: {e}", tmp.display(), path.display()),
240 })?;
241 Ok(())
242}
243
244#[cfg(unix)]
245fn open_restricted(path: &Path) -> Result<std::fs::File, AuthFileError> {
246 use std::os::unix::fs::OpenOptionsExt as _;
247 std::fs::OpenOptions::new()
248 .create(true)
249 .truncate(true)
250 .write(true)
251 .mode(0o600)
252 .open(path)
253 .map_err(|e| AuthFileError::Io {
254 path: path.to_path_buf(),
255 message: e.to_string(),
256 })
257}
258
259#[cfg(not(unix))]
260fn open_restricted(path: &Path) -> Result<std::fs::File, AuthFileError> {
261 std::fs::OpenOptions::new()
263 .create(true)
264 .truncate(true)
265 .write(true)
266 .open(path)
267 .map_err(|e| AuthFileError::Io {
268 path: path.to_path_buf(),
269 message: e.to_string(),
270 })
271}
272
273#[derive(Debug, Error)]
275pub enum AuthFileError {
276 #[error("failed to read auth file `{}`: {message}", path.display())]
278 Io {
279 path: PathBuf,
281 message: String,
283 },
284 #[error("failed to parse auth file `{}`: {message}", path.display())]
286 Parse {
287 path: PathBuf,
289 message: String,
291 },
292 #[error(
294 "auth file `{}` has schema_version={found}; expected {expected}. Re-run `mnm login` to refresh.",
295 .path.display()
296 )]
297 SchemaVersionMismatch {
298 path: PathBuf,
300 found: u32,
302 expected: u32,
304 },
305 #[error(
310 "auth file `{}` has insecure permissions ({mode:#o}); expected 0o600. Run `chmod 600 \"{}\"` and retry.",
311 path.display(), path.display()
312 )]
313 InsecurePermissions {
314 path: PathBuf,
316 mode: u32,
318 },
319 #[error("failed to serialize auth file: {0}")]
321 Serialize(String),
322}
323
324#[cfg(unix)]
325fn check_permissions(path: &Path, md: &std::fs::Metadata) -> Result<(), AuthFileError> {
326 use std::os::unix::fs::PermissionsExt as _;
327 let mode = md.permissions().mode() & 0o777;
328 if mode & 0o077 != 0 {
330 return Err(AuthFileError::InsecurePermissions { path: path.to_path_buf(), mode });
331 }
332 Ok(())
333}
334
335#[cfg(not(unix))]
336fn check_permissions(_path: &Path, _md: &std::fs::Metadata) -> Result<(), AuthFileError> {
337 Ok(())
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 fn write_tempfile(body: &str) -> tempfile::NamedTempFile {
346 use std::io::Write as _;
347 let mut f = tempfile::Builder::new()
348 .suffix(".toml")
349 .tempfile()
350 .expect("create tempfile");
351 f.write_all(body.as_bytes()).expect("write tempfile");
352 f
353 }
354
355 #[test]
356 fn absent_file_returns_none() {
357 let path = std::path::PathBuf::from("/definitely/does/not/exist/auth.toml");
358 let r = AuthFile::read_optional(&path).expect("ok");
359 assert!(r.is_none());
360 }
361
362 #[test]
363 fn empty_sections_load_ok() {
364 let body = "schema_version = 1\n";
365 let f = write_tempfile(body);
366 let r = AuthFile::read_optional(f.path()).unwrap().unwrap();
367 assert_eq!(r.schema_version, 1);
368 assert!(r.admin.is_none());
369 assert!(r.read_uplift.is_none());
370 }
371
372 #[test]
373 fn schema_mismatch_fails() {
374 let body = "schema_version = 2\n";
375 let f = write_tempfile(body);
376 let err = AuthFile::read_optional(f.path()).unwrap_err();
377 assert!(matches!(
378 err,
379 AuthFileError::SchemaVersionMismatch { found: 2, expected: 1, .. },
380 ));
381 }
382
383 #[test]
384 fn admin_active_window_respected() {
385 let body = r#"
386schema_version = 1
387
388[admin]
389user_id = "aaron"
390token = "jwt-abc"
391expires_at = "2026-05-13T15:30:00Z"
392"#;
393 let f = write_tempfile(body);
394 let r = AuthFile::read_optional(f.path()).unwrap().unwrap();
395 let now_before = OffsetDateTime::parse(
396 "2026-05-13T15:29:00Z",
397 &time::format_description::well_known::Rfc3339,
398 )
399 .unwrap();
400 let now_after = OffsetDateTime::parse(
401 "2026-05-13T15:31:00Z",
402 &time::format_description::well_known::Rfc3339,
403 )
404 .unwrap();
405 assert_eq!(r.active_admin_token(now_before), Some("jwt-abc"));
406 assert_eq!(r.active_admin_token(now_after), None);
407 }
408
409 #[test]
410 fn malformed_toml_returns_parse() {
411 let f = write_tempfile("definitely := not = toml\n");
412 let err = AuthFile::read_optional(f.path()).unwrap_err();
413 assert!(matches!(err, AuthFileError::Parse { .. }));
414 }
415
416 #[cfg(unix)]
417 #[test]
418 fn rejects_group_readable_file() {
419 use std::os::unix::fs::PermissionsExt as _;
420 let f = write_tempfile("schema_version = 1\n");
421 std::fs::set_permissions(f.path(), std::fs::Permissions::from_mode(0o640))
424 .expect("chmod tempfile");
425 let err = AuthFile::read_optional(f.path()).unwrap_err();
426 assert!(
427 matches!(err, AuthFileError::InsecurePermissions { mode: 0o640, .. }),
428 "expected InsecurePermissions, got {err:?}"
429 );
430 }
431
432 fn rfc(s: &str) -> OffsetDateTime {
433 OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).unwrap()
434 }
435
436 #[test]
437 fn writes_then_reads_admin_section() {
438 let dir = tempfile::tempdir().unwrap();
439 let path = dir.path().join("auth.toml");
440 let exp = rfc("2026-05-14T01:30:00Z");
441 AuthFile::write_admin_token(&path, "aaron", "jwt-xyz", exp).expect("write");
442
443 let loaded = AuthFile::read_optional(&path).unwrap().unwrap();
444 let admin = loaded.admin.unwrap();
445 assert_eq!(admin.user_id, "aaron");
446 assert_eq!(admin.token, "jwt-xyz");
447 assert_eq!(admin.expires_at, exp);
448 assert!(loaded.read_uplift.is_none(), "writer must not invent unrelated sections");
449 }
450
451 #[test]
452 fn writes_then_reads_read_uplift_section() {
453 let dir = tempfile::tempdir().unwrap();
454 let path = dir.path().join("auth.toml");
455 let exp = rfc("2026-06-14T00:00:00Z");
456 AuthFile::write_read_uplift_token(&path, "aaronbassett", "ru_abc", exp).expect("write");
457
458 let loaded = AuthFile::read_optional(&path).unwrap().unwrap();
459 let r = loaded.read_uplift.unwrap();
460 assert_eq!(r.github_login, "aaronbassett");
461 assert_eq!(r.token, "ru_abc");
462 assert_eq!(r.expires_at, exp);
463 }
464
465 #[test]
466 fn writing_admin_does_not_clobber_read_uplift() {
467 let dir = tempfile::tempdir().unwrap();
468 let path = dir.path().join("auth.toml");
469 let ru_exp = rfc("2026-06-14T00:00:00Z");
470 AuthFile::write_read_uplift_token(&path, "aaronbassett", "ru_abc", ru_exp).unwrap();
471 let admin_exp = rfc("2026-05-14T01:30:00Z");
472 AuthFile::write_admin_token(&path, "aaron", "jwt-xyz", admin_exp).unwrap();
473
474 let loaded = AuthFile::read_optional(&path).unwrap().unwrap();
475 assert_eq!(loaded.read_uplift.unwrap().token, "ru_abc");
476 assert_eq!(loaded.admin.unwrap().token, "jwt-xyz");
477 }
478
479 #[cfg(unix)]
480 #[test]
481 fn writer_creates_file_with_0600() {
482 use std::os::unix::fs::PermissionsExt as _;
483 let dir = tempfile::tempdir().unwrap();
484 let path = dir.path().join("auth.toml");
485 AuthFile::write_admin_token(&path, "aaron", "jwt", rfc("2026-05-14T01:30:00Z")).unwrap();
486 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
487 assert_eq!(mode, 0o600, "writer must create the file with 0o600");
488 }
489
490 #[cfg(unix)]
491 #[test]
492 fn writer_refuses_insecure_existing_file() {
493 use std::os::unix::fs::PermissionsExt as _;
494 let dir = tempfile::tempdir().unwrap();
495 let path = dir.path().join("auth.toml");
496 std::fs::write(&path, "schema_version = 1\n").unwrap();
499 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
500 let err = AuthFile::write_admin_token(&path, "aaron", "jwt", rfc("2026-05-14T01:30:00Z"))
501 .unwrap_err();
502 assert!(
503 matches!(err, AuthFileError::InsecurePermissions { mode: 0o644, .. }),
504 "expected InsecurePermissions, got {err:?}"
505 );
506 }
507}