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