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        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    /// Wait for another refresh without parking Tokio's reactor. The file
228    /// stays owned by this future, so cancelling the wait closes it without
229    /// leaving a blocking worker behind. The acquired guard still spans the
230    /// complete read, token exchange, and persisted rotation.
231    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    /// Replace a document while the caller holds this store's exclusive lock.
267    /// This is the seam used by the async refresh flow; it never performs an
268    /// exchange after releasing the lock.
269    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        // Recheck immediately before replacing the destination.  The caller
349        // holds the sibling lock; a symlink is never intentionally replaced.
350        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
366/// An exclusive sibling lock.  Dropping it releases the lock before any
367/// caller can observe a subsequent refresh/write operation.
368pub 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            // `~/.config/ai-usagebar` predates this credential store and is
427            // normally 0755. A current-user-owned, non-writable parent is safe
428            // with the credential and lock files themselves fixed at 0600.
429            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/// `unix`-only: a permission bit has no meaning elsewhere, and every caller is
523/// already inside a `#[cfg(unix)]` block, so a non-unix arm here would be a
524/// branch nothing can reach.
525#[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/// `unix`-only for the same reason as [`file_mode`]. Its sibling
545/// [`set_private_permissions`] is *not* gated: that one is called
546/// unconditionally, so its non-unix no-op arm is live.
547#[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}