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    /// Gated like the `tests` module below, which is `unix`-only because every
135    /// assertion in it turns on file modes.
136    #[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
142/// Injectable current-owner lookup.  The trait keeps the UID source separate
143/// from store logic and lets tests/coordinators provide a deterministic UID.
144pub 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            // Unlike `/proc/self`, this is available on every supported Unix,
156            // including macOS, without introducing an unsafe FFI call here.
157            Ok(rustix::process::geteuid().as_raw())
158        }
159        #[cfg(not(unix))]
160        {
161            Ok(0)
162        }
163    }
164}
165
166/// A file-backed credential store with an injectable path and owner policy.
167pub 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    /// Replace a document while the caller holds this store's exclusive lock.
243    /// This is the seam used by the async refresh flow; it never performs an
244    /// exchange after releasing the lock.
245    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        // Recheck immediately before replacing the destination.  The caller
325        // holds the sibling lock; a symlink is never intentionally replaced.
326        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
342/// An exclusive sibling lock.  Dropping it releases the lock before any
343/// caller can observe a subsequent refresh/write operation.
344pub 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            // `~/.config/ai-usagebar` predates this credential store and is
403            // normally 0755. A current-user-owned, non-writable parent is safe
404            // with the credential and lock files themselves fixed at 0600.
405            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/// `unix`-only: a permission bit has no meaning elsewhere, and every caller is
499/// already inside a `#[cfg(unix)]` block, so a non-unix arm here would be a
500/// branch nothing can reach.
501#[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/// `unix`-only for the same reason as [`file_mode`]. Its sibling
521/// [`set_private_permissions`] is *not* gated: that one is called
522/// unconditionally, so its non-unix no-op arm is live.
523#[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}