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