Skip to main content

ai_usagebar/nous/
credentials.rs

1//! Secure, versioned Nous credential storage.
2//!
3//! The store is independent from every other provider's credentials.  It uses
4//! a sibling lock and same-directory atomic replacement so a refresh either
5//! leaves the old complete document or exposes the new complete document.
6
7use 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/// A secret-bearing Nous credential.  Do not derive `Debug` for this type.
53#[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/// Versioned top-level credential document.  Unknown top-level values are
91/// preserved so logout does not erase future unrelated credential entries.
92#[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    /// Test seam for proving that writes preserve unrelated future entries.
134    #[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
140/// Injectable current-owner lookup.  The trait keeps the UID source separate
141/// from store logic and lets tests/coordinators provide a deterministic UID.
142pub 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            // Unlike `/proc/self`, this is available on every supported Unix,
154            // including macOS, without introducing an unsafe FFI call here.
155            Ok(rustix::process::geteuid().as_raw())
156        }
157        #[cfg(not(unix))]
158        {
159            Ok(0)
160        }
161    }
162}
163
164/// A file-backed credential store with an injectable path and owner policy.
165pub 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    /// Replace a document while the caller holds this store's exclusive lock.
241    /// This is the seam used by the async refresh flow; it never performs an
242    /// exchange after releasing the lock.
243    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        // Recheck immediately before replacing the destination.  The caller
323        // holds the sibling lock; a symlink is never intentionally replaced.
324        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
340/// An exclusive sibling lock.  Dropping it releases the lock before any
341/// caller can observe a subsequent refresh/write operation.
342pub 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            // `~/.config/ai-usagebar` predates this credential store and is
401            // normally 0755. A current-user-owned, non-writable parent is safe
402            // with the credential and lock files themselves fixed at 0600.
403            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}