1use std::collections::BTreeMap;
8use std::fmt;
9use std::fs::{self, File, OpenOptions};
10use std::io::{self, Write};
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14#[cfg(unix)]
15use std::os::unix::fs::OpenOptionsExt;
16
17use chrono::{DateTime, Utc};
18use fs2::FileExt;
19use serde::{Deserialize, Serialize};
20use tempfile::Builder;
21use thiserror::Error;
22
23pub const CREDENTIAL_STORE_VERSION: u32 = 1;
24pub const DEFAULT_CREDENTIALS_FILE: &str = "credentials.json";
25pub const CREDENTIALS_DIR_MODE: u32 = 0o700;
26pub const CREDENTIALS_FILE_MODE: u32 = 0o600;
27
28#[derive(Debug, Error)]
29pub enum CredentialError {
30 #[error("credential I/O error at {path}: {source}")]
31 Io {
32 path: PathBuf,
33 #[source]
34 source: io::Error,
35 },
36 #[error("credential document is invalid: {0}")]
37 Invalid(String),
38 #[error("credential document is unsafe at {path}: {reason}")]
39 Unsafe { path: PathBuf, reason: &'static str },
40 #[error("credential document could not be decoded")]
41 Decode,
42 #[error("credential lock could not be acquired at {path}: {source}")]
43 Lock {
44 path: PathBuf,
45 #[source]
46 source: io::Error,
47 },
48}
49
50pub type Result<T> = std::result::Result<T, CredentialError>;
51
52#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct NousCredential {
55 pub client_id: String,
56 pub access_token: String,
57 pub refresh_token: String,
58 pub expires_at: DateTime<Utc>,
59}
60
61impl fmt::Debug for NousCredential {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 f.debug_struct("NousCredential")
64 .field("client_id", &self.client_id)
65 .field("access_token", &"<redacted>")
66 .field("refresh_token", &"<redacted>")
67 .field("expires_at", &self.expires_at)
68 .finish()
69 }
70}
71
72impl NousCredential {
73 pub fn validate(&self) -> Result<()> {
74 if self.client_id.trim().is_empty() {
75 return Err(CredentialError::Invalid("client_id is empty".into()));
76 }
77 if self.access_token.trim().is_empty() {
78 return Err(CredentialError::Invalid("access token is empty".into()));
79 }
80 if self.refresh_token.trim().is_empty() {
81 return Err(CredentialError::Invalid("refresh token is empty".into()));
82 }
83 if self.expires_at.timestamp() < 0 {
84 return Err(CredentialError::Invalid("expiration is invalid".into()));
85 }
86 Ok(())
87 }
88}
89
90#[derive(Clone, PartialEq, Serialize, Deserialize)]
93pub struct CredentialDocument {
94 pub version: u32,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub nous: Option<NousCredential>,
97 #[serde(flatten)]
98 other: BTreeMap<String, serde_json::Value>,
99}
100
101impl fmt::Debug for CredentialDocument {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.debug_struct("CredentialDocument")
104 .field("version", &self.version)
105 .field("nous", &self.nous.as_ref().map(|_| "<present>"))
106 .field("other_keys", &self.other.keys().collect::<Vec<_>>())
107 .finish()
108 }
109}
110
111impl CredentialDocument {
112 pub fn new(nous: Option<NousCredential>) -> Self {
113 Self {
114 version: CREDENTIAL_STORE_VERSION,
115 nous,
116 other: BTreeMap::new(),
117 }
118 }
119
120 pub fn validate(&self) -> Result<()> {
121 if self.version != CREDENTIAL_STORE_VERSION {
122 return Err(CredentialError::Invalid(format!(
123 "unsupported credential document version {}",
124 self.version
125 )));
126 }
127 if let Some(nous) = &self.nous {
128 nous.validate()?;
129 }
130 Ok(())
131 }
132
133 #[cfg(test)]
135 fn insert_other(&mut self, key: impl Into<String>, value: serde_json::Value) {
136 self.other.insert(key.into(), value);
137 }
138}
139
140pub trait OwnerIdProvider: Send + Sync {
143 fn current_uid(&self) -> io::Result<u32>;
144}
145
146#[derive(Debug, Default)]
147pub struct ProcessOwner;
148
149impl OwnerIdProvider for ProcessOwner {
150 fn current_uid(&self) -> io::Result<u32> {
151 #[cfg(unix)]
152 {
153 Ok(rustix::process::geteuid().as_raw())
156 }
157 #[cfg(not(unix))]
158 {
159 Ok(0)
160 }
161 }
162}
163
164pub struct CredentialStore {
166 path: PathBuf,
167 owner: Arc<dyn OwnerIdProvider>,
168}
169
170impl fmt::Debug for CredentialStore {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 f.debug_struct("CredentialStore")
173 .field("path", &self.path)
174 .finish()
175 }
176}
177
178impl Clone for CredentialStore {
179 fn clone(&self) -> Self {
180 Self {
181 path: self.path.clone(),
182 owner: Arc::clone(&self.owner),
183 }
184 }
185}
186
187impl CredentialStore {
188 pub fn at(path: impl Into<PathBuf>) -> Self {
189 Self {
190 path: path.into(),
191 owner: Arc::new(ProcessOwner),
192 }
193 }
194
195 pub fn with_owner_provider(path: impl Into<PathBuf>, owner: Arc<dyn OwnerIdProvider>) -> Self {
196 Self {
197 path: path.into(),
198 owner,
199 }
200 }
201
202 pub fn path(&self) -> &Path {
203 &self.path
204 }
205
206 pub fn lock_path(&self) -> PathBuf {
207 let name = self
208 .path
209 .file_name()
210 .and_then(|name| name.to_str())
211 .unwrap_or(DEFAULT_CREDENTIALS_FILE);
212 self.path.with_file_name(format!("{name}.lock"))
213 }
214
215 pub fn acquire_lock(&self) -> Result<CredentialLock> {
216 ensure_private_parent(self.parent(), &self.owner)?;
217 let path = self.lock_path();
218 reject_symlink(&path)?;
219 let file = open_private_lock_file(&path).map_err(|source| io_at(&path, source))?;
220 validate_file_metadata(&path, &file, &self.owner)?;
221 file.lock_exclusive()
222 .map_err(|source| CredentialError::Lock {
223 path: path.clone(),
224 source,
225 })?;
226 Ok(CredentialLock { file, path })
227 }
228
229 pub fn read(&self) -> Result<Option<CredentialDocument>> {
230 let _lock = self.acquire_lock()?;
231 self.read_unlocked()
232 }
233
234 pub fn write(&self, document: &CredentialDocument) -> Result<()> {
235 document.validate()?;
236 let _lock = self.acquire_lock()?;
237 self.write_unlocked(document)
238 }
239
240 pub fn write_locked(&self, lock: &CredentialLock, document: &CredentialDocument) -> Result<()> {
244 if lock.path != self.lock_path() {
245 return Err(CredentialError::Lock {
246 path: self.lock_path(),
247 source: io::Error::new(
248 io::ErrorKind::InvalidInput,
249 "lock belongs to another store",
250 ),
251 });
252 }
253 document.validate()?;
254 self.write_unlocked(document)
255 }
256
257 pub fn logout(&self) -> Result<()> {
258 let _lock = self.acquire_lock()?;
259 let Some(mut document) = self.read_unlocked()? else {
260 return Ok(());
261 };
262 if document.nous.is_none() {
263 return Ok(());
264 }
265 document.nous = None;
266 if document.other.is_empty() {
267 reject_symlink(&self.path)?;
268 fs::remove_file(&self.path).map_err(|source| io_at(&self.path, source))?;
269 sync_parent(self.parent())?;
270 } else {
271 self.write_unlocked(&document)?;
272 }
273 Ok(())
274 }
275
276 pub fn read_unlocked(&self) -> Result<Option<CredentialDocument>> {
277 reject_symlink(&self.path)?;
278 let metadata = match fs::symlink_metadata(&self.path) {
279 Ok(metadata) => metadata,
280 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
281 Err(source) => return Err(io_at(&self.path, source)),
282 };
283 if !metadata.is_file() {
284 return Err(CredentialError::Unsafe {
285 path: self.path.clone(),
286 reason: "credential path is not a regular file",
287 });
288 }
289 validate_metadata(&self.path, &metadata, &self.owner)?;
290 let bytes = fs::read(&self.path).map_err(|source| io_at(&self.path, source))?;
291 let document: CredentialDocument =
292 serde_json::from_slice(&bytes).map_err(|_| CredentialError::Decode)?;
293 document.validate()?;
294 Ok(Some(document))
295 }
296
297 fn write_unlocked(&self, document: &CredentialDocument) -> Result<()> {
298 ensure_private_parent(self.parent(), &self.owner)?;
299 reject_symlink(&self.path)?;
300 if let Ok(metadata) = fs::symlink_metadata(&self.path) {
301 validate_metadata(&self.path, &metadata, &self.owner)?;
302 }
303
304 let mut temporary = Builder::new()
305 .prefix(".credentials.json.")
306 .tempfile_in(self.parent())
307 .map_err(|source| io_at(self.parent(), source))?;
308 set_private_permissions(temporary.as_file(), CREDENTIALS_FILE_MODE)
309 .map_err(|source| io_at(temporary.path(), source))?;
310 let bytes = serde_json::to_vec_pretty(document).map_err(|_| {
311 CredentialError::Invalid("credential document could not be encoded".into())
312 })?;
313 temporary
314 .as_file_mut()
315 .write_all(&bytes)
316 .map_err(|source| io_at(temporary.path(), source))?;
317 temporary
318 .as_file()
319 .sync_all()
320 .map_err(|source| io_at(temporary.path(), source))?;
321
322 reject_symlink(&self.path)?;
325 temporary
326 .persist(&self.path)
327 .map_err(|error| io_at(&self.path, error.error))?;
328 sync_parent(self.parent())?;
329
330 let metadata =
331 fs::symlink_metadata(&self.path).map_err(|source| io_at(&self.path, source))?;
332 validate_metadata(&self.path, &metadata, &self.owner)
333 }
334
335 fn parent(&self) -> &Path {
336 self.path.parent().unwrap_or_else(|| Path::new("."))
337 }
338}
339
340pub struct CredentialLock {
343 file: File,
344 path: PathBuf,
345}
346
347impl fmt::Debug for CredentialLock {
348 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349 f.debug_struct("CredentialLock")
350 .field("path", &self.path)
351 .finish()
352 }
353}
354
355impl Drop for CredentialLock {
356 fn drop(&mut self) {
357 let _ = FileExt::unlock(&self.file);
358 }
359}
360
361impl Default for CredentialStore {
362 fn default() -> Self {
363 Self::at(default_credentials_path())
364 }
365}
366
367pub fn default_credentials_path() -> PathBuf {
368 directories::ProjectDirs::from("", "", "ai-usagebar")
369 .map(|project| project.config_dir().join(DEFAULT_CREDENTIALS_FILE))
370 .unwrap_or_else(|| PathBuf::from(".config/ai-usagebar/credentials.json"))
371}
372
373fn ensure_private_parent(path: &Path, owner: &Arc<dyn OwnerIdProvider>) -> Result<()> {
374 let created = !path.exists();
375 if created {
376 fs::create_dir_all(path).map_err(|source| io_at(path, source))?;
377 }
378 let metadata = fs::symlink_metadata(path).map_err(|source| io_at(path, source))?;
379 if !metadata.is_dir() {
380 return Err(CredentialError::Unsafe {
381 path: path.to_path_buf(),
382 reason: "credential parent is not a directory",
383 });
384 }
385 validate_owner(path, &metadata, owner)?;
386 #[cfg(unix)]
387 {
388 let mode = file_mode(&metadata);
389 if created {
390 set_private_permissions_path(path, CREDENTIALS_DIR_MODE)
391 .map_err(|source| io_at(path, source))?;
392 let after = fs::symlink_metadata(path).map_err(|source| io_at(path, source))?;
393 if file_mode(&after) != CREDENTIALS_DIR_MODE {
394 return Err(CredentialError::Unsafe {
395 path: path.to_path_buf(),
396 reason: "new credential parent must be mode 0700",
397 });
398 }
399 } else if mode & 0o022 != 0 {
400 return Err(CredentialError::Unsafe {
404 path: path.to_path_buf(),
405 reason: "credential parent must not be group- or world-writable",
406 });
407 }
408 }
409 Ok(())
410}
411
412fn open_private_lock_file(path: &Path) -> io::Result<File> {
413 let mut options = OpenOptions::new();
414 options.create(true).read(true).write(true);
415 #[cfg(unix)]
416 options.mode(CREDENTIALS_FILE_MODE);
417 options.open(path)
418}
419
420fn validate_metadata(
421 path: &Path,
422 metadata: &fs::Metadata,
423 owner: &Arc<dyn OwnerIdProvider>,
424) -> Result<()> {
425 if metadata.file_type().is_symlink() {
426 return Err(CredentialError::Unsafe {
427 path: path.to_path_buf(),
428 reason: "credential path is a symlink",
429 });
430 }
431 if !metadata.is_file() {
432 return Err(CredentialError::Unsafe {
433 path: path.to_path_buf(),
434 reason: "credential path is not a regular file",
435 });
436 }
437 validate_owner(path, metadata, owner)?;
438 #[cfg(unix)]
439 {
440 if file_mode(metadata) != CREDENTIALS_FILE_MODE {
441 return Err(CredentialError::Unsafe {
442 path: path.to_path_buf(),
443 reason: "credential file must be mode 0600",
444 });
445 }
446 }
447 Ok(())
448}
449
450fn validate_file_metadata(
451 path: &Path,
452 file: &File,
453 owner: &Arc<dyn OwnerIdProvider>,
454) -> Result<()> {
455 let metadata = file.metadata().map_err(|source| io_at(path, source))?;
456 validate_metadata(path, &metadata, owner)
457}
458
459fn validate_owner(
460 path: &Path,
461 metadata: &fs::Metadata,
462 owner: &Arc<dyn OwnerIdProvider>,
463) -> Result<()> {
464 #[cfg(unix)]
465 {
466 use std::os::unix::fs::MetadataExt;
467 let uid = owner.current_uid().map_err(|source| io_at(path, source))?;
468 if metadata.uid() != uid {
469 return Err(CredentialError::Unsafe {
470 path: path.to_path_buf(),
471 reason: "credential path is not owned by the current user",
472 });
473 }
474 }
475 #[cfg(not(unix))]
476 let _ = (path, metadata, owner);
477 Ok(())
478}
479
480fn reject_symlink(path: &Path) -> Result<()> {
481 match fs::symlink_metadata(path) {
482 Ok(metadata) => {
483 if metadata.file_type().is_symlink() {
484 return Err(CredentialError::Unsafe {
485 path: path.to_path_buf(),
486 reason: "credential path is a symlink",
487 });
488 }
489 Ok(())
490 }
491 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
492 Err(source) => Err(io_at(path, source)),
493 }
494}
495
496fn file_mode(metadata: &fs::Metadata) -> u32 {
497 #[cfg(unix)]
498 {
499 use std::os::unix::fs::MetadataExt;
500 metadata.mode() & 0o777
501 }
502 #[cfg(not(unix))]
503 {
504 let _ = metadata;
505 CREDENTIALS_FILE_MODE
506 }
507}
508
509fn set_private_permissions(file: &File, mode: u32) -> io::Result<()> {
510 #[cfg(unix)]
511 {
512 use std::os::unix::fs::PermissionsExt;
513 file.set_permissions(fs::Permissions::from_mode(mode))
514 }
515 #[cfg(not(unix))]
516 {
517 let _ = (file, mode);
518 Ok(())
519 }
520}
521
522fn set_private_permissions_path(path: &Path, mode: u32) -> io::Result<()> {
523 #[cfg(unix)]
524 {
525 use std::os::unix::fs::PermissionsExt;
526 fs::set_permissions(path, fs::Permissions::from_mode(mode))
527 }
528 #[cfg(not(unix))]
529 {
530 let _ = (path, mode);
531 Ok(())
532 }
533}
534
535fn sync_parent(parent: &Path) -> Result<()> {
536 #[cfg(unix)]
537 {
538 File::open(parent)
539 .and_then(|directory| directory.sync_all())
540 .map_err(|source| io_at(parent, source))
541 }
542 #[cfg(not(unix))]
543 {
544 let _ = parent;
545 Ok(())
546 }
547}
548
549fn io_at(path: impl Into<PathBuf>, source: io::Error) -> CredentialError {
550 CredentialError::Io {
551 path: path.into(),
552 source,
553 }
554}
555
556#[cfg(all(test, unix))]
557mod tests {
558 use std::fs;
559 use std::os::unix::fs::PermissionsExt;
560 use std::sync::mpsc;
561 use std::thread;
562 use std::time::Duration;
563
564 use chrono::{TimeZone, Utc};
565 use tempfile::TempDir;
566
567 use super::*;
568
569 fn credential() -> NousCredential {
570 NousCredential {
571 client_id: "hermes-cli".into(),
572 access_token: "test-access-token".into(),
573 refresh_token: "test-refresh-token".into(),
574 expires_at: Utc.with_ymd_and_hms(2026, 8, 16, 12, 0, 0).unwrap(),
575 }
576 }
577
578 fn document() -> CredentialDocument {
579 CredentialDocument::new(Some(credential()))
580 }
581
582 fn private_path(root: &TempDir) -> std::path::PathBuf {
583 let parent = root.path().join("config");
584 fs::create_dir(&parent).unwrap();
585 fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).unwrap();
586 parent.join("credentials.json")
587 }
588
589 #[test]
590 fn write_creates_private_directory_and_file() {
591 let root = TempDir::new().unwrap();
592 let path = root.path().join("config").join("credentials.json");
593 let store = CredentialStore::at(&path);
594
595 store.write(&document()).unwrap();
596
597 assert_eq!(
598 fs::metadata(path.parent().unwrap())
599 .unwrap()
600 .permissions()
601 .mode()
602 & 0o777,
603 0o700
604 );
605 assert_eq!(
606 fs::symlink_metadata(&path).unwrap().permissions().mode() & 0o777,
607 0o600
608 );
609 assert!(store.read().unwrap().unwrap().nous.is_some());
610 }
611
612 #[test]
613 fn atomic_replacement_leaves_complete_private_json_without_temp_files() {
614 let root = TempDir::new().unwrap();
615 let path = private_path(&root);
616 let store = CredentialStore::at(&path);
617 store.write(&document()).unwrap();
618
619 let mut replacement = document();
620 replacement.nous.as_mut().unwrap().access_token = "test-new-access".into();
621 store.write(&replacement).unwrap();
622
623 let bytes = fs::read(&path).unwrap();
624 let text = String::from_utf8(bytes).unwrap();
625 assert!(text.contains("test-new-access"));
626 assert_eq!(
627 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
628 0o600
629 );
630 assert!(!fs::read_dir(path.parent().unwrap()).unwrap().any(|entry| {
631 entry
632 .unwrap()
633 .file_name()
634 .to_string_lossy()
635 .starts_with(".credentials.json.")
636 }));
637 }
638
639 #[test]
640 fn unsafe_existing_file_is_rejected_without_chmodifying_it() {
641 let root = TempDir::new().unwrap();
642 let path = private_path(&root);
643 fs::write(&path, b"{}").unwrap();
644 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
645 let store = CredentialStore::at(&path);
646
647 assert!(store.read().is_err());
648 assert_eq!(
649 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
650 0o644
651 );
652 }
653
654 #[test]
655 fn symlink_credential_path_is_rejected() {
656 let root = TempDir::new().unwrap();
657 let target = root.path().join("target.json");
658 let path = private_path(&root);
659 fs::write(&target, b"{}").unwrap();
660 std::os::unix::fs::symlink(&target, &path).unwrap();
661
662 assert!(CredentialStore::at(&path).read().is_err());
663 assert!(CredentialStore::at(&path).write(&document()).is_err());
664 }
665
666 #[test]
667 fn malformed_or_wrong_version_documents_fail_closed_and_logout_preserves_other_fields() {
668 let root = TempDir::new().unwrap();
669 let path = private_path(&root);
670 let store = CredentialStore::at(&path);
671 fs::write(&path, br#"{"version":2,"other":{"keep":true}}"#).unwrap();
672 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
673 assert!(store.read().is_err());
674 assert!(store.logout().is_err());
675
676 let mut doc = document();
677 doc.insert_other("future", serde_json::json!({"keep": true}));
678 store.write(&doc).unwrap();
679 store.logout().unwrap();
680 let json: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
681 assert!(json.get("nous").is_none());
682 assert_eq!(json["future"]["keep"], true);
683 }
684
685 #[test]
686 fn lock_blocks_a_second_holder_until_the_first_is_released() {
687 let root = TempDir::new().unwrap();
688 let store = CredentialStore::at(private_path(&root));
689 let first = store.acquire_lock().unwrap();
690 let (started_tx, started_rx) = mpsc::channel();
691 let (finished_tx, finished_rx) = mpsc::channel();
692 let path = store.path().to_path_buf();
693 let handle = thread::spawn(move || {
694 let other = CredentialStore::at(path);
695 started_tx.send(()).unwrap();
696 let _second = other.acquire_lock().unwrap();
697 finished_tx.send(()).unwrap();
698 });
699 started_rx.recv_timeout(Duration::from_secs(1)).unwrap();
700 assert!(
701 finished_rx
702 .recv_timeout(Duration::from_millis(100))
703 .is_err()
704 );
705 drop(first);
706 finished_rx.recv_timeout(Duration::from_secs(1)).unwrap();
707 handle.join().unwrap();
708 }
709
710 #[test]
711 fn existing_owner_directory_with_standard_config_mode_is_accepted() {
712 let root = TempDir::new().unwrap();
713 let parent = root.path().join("config");
714 fs::create_dir(&parent).unwrap();
715 fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap();
716 let store = CredentialStore::at(parent.join("credentials.json"));
717
718 store.write(&document()).unwrap();
719 assert_eq!(
720 fs::metadata(&parent).unwrap().permissions().mode() & 0o777,
721 0o755
722 );
723 assert!(store.read().unwrap().unwrap().nous.is_some());
724 }
725
726 #[test]
727 fn group_or_world_writable_parent_is_rejected_without_chmodifying_it() {
728 let root = TempDir::new().unwrap();
729 let parent = root.path().join("config");
730 fs::create_dir(&parent).unwrap();
731 fs::set_permissions(&parent, fs::Permissions::from_mode(0o770)).unwrap();
732 let store = CredentialStore::at(parent.join("credentials.json"));
733
734 assert!(store.write(&document()).is_err());
735 assert_eq!(
736 fs::metadata(&parent).unwrap().permissions().mode() & 0o777,
737 0o770
738 );
739 }
740
741 #[test]
742 fn process_owner_matches_files_created_by_the_effective_user() {
743 use std::os::unix::fs::MetadataExt;
744
745 let file = tempfile::tempfile().unwrap();
746 assert_eq!(
747 ProcessOwner.current_uid().unwrap(),
748 file.metadata().unwrap().uid()
749 );
750 }
751
752 #[test]
753 fn secret_bearing_debug_output_contains_no_tokens() {
754 let output = format!("{:?}", credential());
755 assert!(!output.contains("test-access-token"));
756 assert!(!output.contains("test-refresh-token"));
757 }
758}
759
760#[cfg(all(test, not(unix)))]
761mod non_unix_tests {
762 use chrono::{TimeZone, Utc};
763 use tempfile::TempDir;
764
765 use super::*;
766
767 fn document(access_token: &str) -> CredentialDocument {
768 CredentialDocument::new(Some(NousCredential {
769 client_id: "hermes-cli".into(),
770 access_token: access_token.into(),
771 refresh_token: "test-refresh-token".into(),
772 expires_at: Utc.with_ymd_and_hms(2026, 8, 16, 12, 0, 0).unwrap(),
773 }))
774 }
775
776 #[test]
777 fn credential_store_writes_replaces_reads_and_logs_out() {
778 let root = TempDir::new().unwrap();
779 let store = CredentialStore::at(root.path().join("config").join("credentials.json"));
780
781 store.write(&document("first-access-token")).unwrap();
782 store.write(&document("second-access-token")).unwrap();
783 let stored = store.read().unwrap().unwrap().nous.unwrap();
784 assert_eq!(stored.access_token, "second-access-token");
785
786 store.logout().unwrap();
787 assert!(store.read().unwrap().is_none());
788 }
789}