Skip to main content

cookie_agent_models/secure_store/
mod.rs

1//! Reusable storage primitives with private-at-creation state.
2
3#[cfg(windows)]
4mod windows;
5
6#[cfg(windows)]
7pub use windows::{
8    create_private_dir_all as create_windows_private_dir_all,
9    create_private_file as create_windows_private_file,
10    create_private_lock_file as create_windows_private_lock_file,
11    open_private_lock_file as open_windows_private_lock_file,
12    repair_private_file_handle_acl as repair_windows_private_file_handle_acl,
13    replace_path as replace_windows_path,
14    verify_private_creation as verify_windows_private_creation,
15    verify_private_file_handle as verify_windows_private_file_handle,
16};
17
18use std::{
19    fs, io,
20    path::{Path, PathBuf},
21    time::{Duration, Instant},
22};
23
24#[cfg(unix)]
25use std::path::Component;
26
27use cookie_agent_protocol::paths;
28use thiserror::Error;
29#[cfg(unix)]
30use uuid::Uuid;
31
32/// A held storage directory; missing components are created privately.
33#[derive(Debug)]
34pub struct SecureDirectory {
35    #[cfg(unix)]
36    pub(crate) directory: fs::File,
37    path: PathBuf,
38}
39
40impl SecureDirectory {
41    /// Opens `~/.cookie-agent/<relative>`, creating missing components privately.
42    pub fn user_data(relative: impl AsRef<Path>) -> Result<Self, SecureStoreError> {
43        let root = paths::user_data_root().map_err(|_| SecureStoreError::HomeUnavailable)?;
44        #[cfg(unix)]
45        {
46            let home = root.parent().ok_or(SecureStoreError::UnsafePath)?;
47            let root_name = root.file_name().ok_or(SecureStoreError::UnsafePath)?;
48            let home_directory = open_absolute_directory(home)?;
49            let relative = Path::new(root_name).join(relative);
50            let mut directory = Self::open_private_in(&home_directory, &relative)?;
51            directory.path = home.join(relative);
52            Ok(directory)
53        }
54        #[cfg(windows)]
55        {
56            let home = root.parent().ok_or(SecureStoreError::UnsafePath)?;
57            let root_name = root.file_name().ok_or(SecureStoreError::UnsafePath)?;
58            windows::open_private(home, &Path::new(root_name).join(relative))
59        }
60    }
61
62    /// Opens a path below an existing anchor, creating missing components privately.
63    pub fn open_in(
64        anchor: impl AsRef<Path>,
65        relative: impl AsRef<Path>,
66    ) -> Result<Self, SecureStoreError> {
67        #[cfg(unix)]
68        {
69            let anchor_path = anchor.as_ref().to_path_buf();
70            let anchor = open_absolute_directory(&anchor_path)?;
71            let mut directory = Self::open_private_in(&anchor, relative.as_ref())?;
72            directory.path = anchor_path.join(relative.as_ref());
73            Ok(directory)
74        }
75        #[cfg(windows)]
76        {
77            windows::open_private(anchor.as_ref(), relative.as_ref())
78        }
79    }
80
81    /// Opens a directory or creates it privately when missing.
82    pub fn open(path: impl AsRef<Path>) -> Result<Self, SecureStoreError> {
83        let path = path.as_ref();
84        #[cfg(unix)]
85        {
86            let parent = path.parent().ok_or(SecureStoreError::UnsafePath)?;
87            let name = path.file_name().ok_or(SecureStoreError::UnsafePath)?;
88            Self::open_in(parent, Path::new(name))
89        }
90        #[cfg(windows)]
91        {
92            windows::open_absolute_private(path)
93        }
94    }
95
96    /// Returns the diagnostic path. Security decisions never use this path.
97    #[must_use]
98    pub fn path(&self) -> &Path {
99        &self.path
100    }
101
102    /// Reads an optional storage file from its held descriptor with a hard cap.
103    pub fn read(&self, name: &str, limit: u64) -> Result<Option<Vec<u8>>, SecureStoreError> {
104        validate_name(name)?;
105        #[cfg(unix)]
106        {
107            read_file(&self.directory, name, limit)
108        }
109        #[cfg(windows)]
110        {
111            windows::validate_leaf_name(name)?;
112            windows::read_file(self, name, limit)
113        }
114    }
115
116    /// Durably replaces one private file using an exclusive sibling temporary.
117    ///
118    /// This is the lock-free half of [`SecureDirectoryLock::atomic_replace`]: it
119    /// grants the same exclusive-create temporary plus atomic rename semantics
120    /// without acquiring or holding any cross-process lock.
121    pub(crate) fn atomic_replace(&self, name: &str, bytes: &[u8]) -> Result<(), SecureStoreError> {
122        validate_name(name)?;
123        #[cfg(unix)]
124        {
125            use std::io::Write as _;
126
127            let temporary = format!(".{name}.tmp-{}", Uuid::now_v7());
128            let mut file = create_file(&self.directory, &temporary)?;
129            let result = (|| {
130                file.write_all(bytes).map_err(SecureStoreError::Io)?;
131                file.sync_all().map_err(SecureStoreError::Io)?;
132                rustix::fs::renameat(&self.directory, temporary.as_str(), &self.directory, name)
133                    .map_err(io_error)?;
134                rustix::fs::fsync(&self.directory).map_err(io_error)?;
135                Ok(())
136            })();
137            if result.is_err() {
138                let _ = rustix::fs::unlinkat(
139                    &self.directory,
140                    temporary.as_str(),
141                    rustix::fs::AtFlags::empty(),
142                );
143            }
144            result
145        }
146        #[cfg(windows)]
147        {
148            windows::validate_leaf_name(name)?;
149            windows::atomic_replace(self, name, bytes)
150        }
151    }
152
153    /// Acquires a cross-process exclusive lock file with the default budget.
154    ///
155    /// Bounded alias for [`Self::lock_within`]; the unbounded form is not
156    /// reachable.
157    pub fn lock(&self, name: &str) -> Result<SecureDirectoryLock<'_>, SecureStoreError> {
158        self.lock_within(name, DEFAULT_LOCK_BUDGET)
159    }
160
161    /// Acquires a cross-process exclusive lock file within `budget`.
162    ///
163    /// Nonblocking attempts retry on an exponential, jittered backoff until
164    /// `budget` elapses, then fail with [`SecureStoreError::LockContention`].
165    pub fn lock_within(
166        &self,
167        name: &str,
168        budget: Duration,
169    ) -> Result<SecureDirectoryLock<'_>, SecureStoreError> {
170        validate_name(name)?;
171        #[cfg(unix)]
172        {
173            let lock = open_or_create_file(&self.directory, name)?;
174            let started = Instant::now();
175            if !lock_within_file(&lock, budget).map_err(SecureStoreError::Io)? {
176                return Err(lock_contention(self.path.join(name), started.elapsed()));
177            }
178            rustix::fs::fsync(&self.directory).map_err(io_error)?;
179            Ok(SecureDirectoryLock {
180                directory: self,
181                lock_name: name.to_owned(),
182                _lock: lock,
183            })
184        }
185        #[cfg(windows)]
186        {
187            windows::validate_leaf_name(name)?;
188            windows::lock_within(self, name, budget)
189        }
190    }
191
192    /// Attempts a single nonblocking exclusive acquisition.
193    ///
194    /// `None` means another process or descriptor currently holds the lock.
195    pub fn try_lock(
196        &self,
197        name: &str,
198    ) -> Result<Option<SecureDirectoryLock<'_>>, SecureStoreError> {
199        validate_name(name)?;
200        #[cfg(unix)]
201        {
202            let lock = open_or_create_file(&self.directory, name)?;
203            if !try_lock_once(&lock).map_err(SecureStoreError::Io)? {
204                return Ok(None);
205            }
206            rustix::fs::fsync(&self.directory).map_err(io_error)?;
207            Ok(Some(SecureDirectoryLock {
208                directory: self,
209                lock_name: name.to_owned(),
210                _lock: lock,
211            }))
212        }
213        #[cfg(windows)]
214        {
215            windows::validate_leaf_name(name)?;
216            windows::try_lock(self, name)
217        }
218    }
219
220    #[cfg(unix)]
221    fn open_private_in(parent: &fs::File, relative: &Path) -> Result<Self, SecureStoreError> {
222        let components = private_components(relative)?;
223        let mut current = parent.try_clone().map_err(SecureStoreError::Io)?;
224        for component in &components {
225            current = open_or_create_directory(&current, Path::new(component))?;
226        }
227        Ok(Self {
228            directory: current,
229            path: relative.to_path_buf(),
230        })
231    }
232}
233
234/// A held lock authorizing lock/reread/replace transactions in one directory.
235#[derive(Debug)]
236pub struct SecureDirectoryLock<'a> {
237    directory: &'a SecureDirectory,
238    lock_name: String,
239    _lock: fs::File,
240}
241
242impl SecureDirectoryLock<'_> {
243    /// Reads bounded transaction-journal bytes stored in the held lock file.
244    pub fn read_journal(&self, limit: u64) -> Result<Vec<u8>, SecureStoreError> {
245        #[cfg(unix)]
246        {
247            use std::io::{Read as _, Seek as _, SeekFrom};
248
249            let metadata = self._lock.metadata().map_err(SecureStoreError::Io)?;
250            if metadata.len() > limit {
251                return Err(SecureStoreError::TooLarge);
252            }
253            let mut file = self._lock.try_clone().map_err(SecureStoreError::Io)?;
254            file.seek(SeekFrom::Start(0))
255                .map_err(SecureStoreError::Io)?;
256            let mut bytes = Vec::with_capacity(
257                usize::try_from(metadata.len()).map_err(|_| SecureStoreError::TooLarge)?,
258            );
259            file.take(limit.saturating_add(1))
260                .read_to_end(&mut bytes)
261                .map_err(SecureStoreError::Io)?;
262            if bytes.len() as u64 > limit {
263                return Err(SecureStoreError::TooLarge);
264            }
265            Ok(bytes)
266        }
267        #[cfg(windows)]
268        {
269            windows::read_journal(self, limit)
270        }
271    }
272
273    /// Durably appends one bounded record to the held lock-file journal.
274    pub fn append_journal(&self, bytes: &[u8], limit: u64) -> Result<(), SecureStoreError> {
275        #[cfg(unix)]
276        {
277            use std::io::{Seek as _, SeekFrom, Write as _};
278
279            let current = self._lock.metadata().map_err(SecureStoreError::Io)?.len();
280            if current.saturating_add(bytes.len() as u64) > limit {
281                return Err(SecureStoreError::TooLarge);
282            }
283            let mut file = self._lock.try_clone().map_err(SecureStoreError::Io)?;
284            file.seek(SeekFrom::End(0)).map_err(SecureStoreError::Io)?;
285            file.write_all(bytes).map_err(SecureStoreError::Io)?;
286            file.sync_all().map_err(SecureStoreError::Io)?;
287            Ok(())
288        }
289        #[cfg(windows)]
290        {
291            windows::append_journal(self, bytes, limit)
292        }
293    }
294
295    /// Durably clears the held lock-file journal without replacing the lock inode.
296    pub fn clear_journal(&self) -> Result<(), SecureStoreError> {
297        #[cfg(unix)]
298        {
299            self._lock.set_len(0).map_err(SecureStoreError::Io)?;
300            self._lock.sync_all().map_err(SecureStoreError::Io)?;
301            Ok(())
302        }
303        #[cfg(windows)]
304        {
305            windows::clear_journal(self)
306        }
307    }
308
309    /// Rereads an optional file while the cross-process lock is held.
310    pub fn read(&self, name: &str, limit: u64) -> Result<Option<Vec<u8>>, SecureStoreError> {
311        let result = self.directory.read(name, limit)?;
312        Ok(result)
313    }
314
315    /// Durably replaces one private file using an exclusive sibling temporary.
316    pub fn atomic_replace(&self, name: &str, bytes: &[u8]) -> Result<(), SecureStoreError> {
317        validate_name(name)?;
318        if name == self.lock_name {
319            return Err(SecureStoreError::UnsafePath);
320        }
321        self.directory.atomic_replace(name, bytes)
322    }
323
324    /// Removes an optional private file and fsyncs the directory.
325    pub fn remove(&self, name: &str) -> Result<(), SecureStoreError> {
326        validate_name(name)?;
327        if name == self.lock_name {
328            return Err(SecureStoreError::UnsafePath);
329        }
330        #[cfg(unix)]
331        {
332            if open_existing_file(&self.directory.directory, name, rustix::fs::OFlags::RDONLY)?
333                .is_some()
334            {
335                rustix::fs::unlinkat(
336                    &self.directory.directory,
337                    name,
338                    rustix::fs::AtFlags::empty(),
339                )
340                .map_err(io_error)?;
341                rustix::fs::fsync(&self.directory.directory).map_err(io_error)?;
342            }
343            Ok(())
344        }
345        #[cfg(windows)]
346        {
347            windows::validate_leaf_name(name)?;
348            windows::remove(self, name)
349        }
350    }
351}
352
353#[cfg(windows)]
354impl Drop for SecureDirectoryLock<'_> {
355    fn drop(&mut self) {
356        let _ = windows::unlock(&self._lock);
357    }
358}
359
360/// Fail-closed storage errors with no file contents or secret values.
361#[derive(Debug, Error)]
362pub enum SecureStoreError {
363    #[error("could not determine the home directory")]
364    HomeUnavailable,
365    #[error("invalid secure storage path")]
366    UnsafePath,
367    #[error("secure storage object exceeds its byte limit")]
368    TooLarge,
369    #[error(
370        "timed out after {waited_ms} ms waiting for the cross-process lock at `{}`",
371        path.display()
372    )]
373    LockContention { path: PathBuf, waited_ms: u64 },
374    #[error("secure storage I/O failed")]
375    Io(#[source] io::Error),
376}
377
378/// Default budget for bounded cross-process store locks.
379pub const DEFAULT_LOCK_BUDGET: Duration = Duration::from_secs(5);
380
381/// Attempts one nonblocking exclusive lock on an already-open file.
382///
383/// `true` means the lock was acquired; `false` means it is held elsewhere.
384#[cfg(unix)]
385pub fn try_lock_once(file: &fs::File) -> io::Result<bool> {
386    match rustix::fs::flock(file, rustix::fs::FlockOperation::NonBlockingLockExclusive) {
387        Ok(()) => Ok(true),
388        Err(error)
389            if error == rustix::io::Errno::WOULDBLOCK || error == rustix::io::Errno::AGAIN =>
390        {
391            Ok(false)
392        }
393        Err(error) => Err(error.into()),
394    }
395}
396
397#[cfg(windows)]
398pub fn try_lock_once(file: &fs::File) -> io::Result<bool> {
399    windows::try_lock_once(file)
400}
401
402/// Releases an exclusive lock held on `file`.
403pub fn unlock(file: &fs::File) -> io::Result<()> {
404    #[cfg(unix)]
405    {
406        rustix::fs::flock(file, rustix::fs::FlockOperation::Unlock).map_err(Into::into)
407    }
408    #[cfg(windows)]
409    {
410        windows::unlock(file)
411    }
412}
413
414/// Bounded retry of [`try_lock_once`]; `true` when acquired within `budget`.
415///
416/// Backoff is exponential with jitter, starting at ~2 ms and capped at ~50 ms.
417pub fn lock_within_file(file: &fs::File, budget: Duration) -> io::Result<bool> {
418    let deadline = Instant::now() + budget;
419    let mut attempt = 0_u32;
420    loop {
421        if try_lock_once(file)? {
422            return Ok(true);
423        }
424        let now = Instant::now();
425        if now >= deadline {
426            return Ok(false);
427        }
428        let delay = contended_backoff(attempt).min(deadline - now);
429        std::thread::sleep(delay);
430        attempt = attempt.saturating_add(1);
431    }
432}
433
434fn contended_backoff(attempt: u32) -> Duration {
435    let base_ms = 2_u64.saturating_mul(1_u64 << attempt.min(5)).min(40);
436    // No RNG dependency: derive a small jitter from the wall clock.
437    let jitter_ms = std::time::SystemTime::now()
438        .duration_since(std::time::UNIX_EPOCH)
439        .map_or(0, |elapsed| u64::from(elapsed.subsec_nanos()) % 11);
440    Duration::from_millis((base_ms + jitter_ms).min(50))
441}
442
443fn lock_contention(path: PathBuf, waited: Duration) -> SecureStoreError {
444    SecureStoreError::LockContention {
445        path,
446        waited_ms: u64::try_from(waited.as_millis()).unwrap_or(u64::MAX),
447    }
448}
449
450#[cfg(unix)]
451fn open_absolute_directory(path: &Path) -> Result<fs::File, SecureStoreError> {
452    if !path.is_absolute() {
453        return Err(SecureStoreError::UnsafePath);
454    }
455    let flags = directory_flags();
456    let mut current = fs::File::from(
457        rustix::fs::open("/", flags, rustix::fs::Mode::empty()).map_err(path_error)?,
458    );
459    for component in path.components() {
460        match component {
461            Component::RootDir => {}
462            Component::Normal(name) => {
463                current = fs::File::from(
464                    rustix::fs::openat(&current, name, flags, rustix::fs::Mode::empty())
465                        .map_err(path_error)?,
466                );
467            }
468            Component::CurDir | Component::ParentDir | Component::Prefix(_) => {
469                return Err(SecureStoreError::UnsafePath);
470            }
471        }
472    }
473    Ok(current)
474}
475
476#[cfg(unix)]
477fn private_components(path: &Path) -> Result<Vec<std::ffi::OsString>, SecureStoreError> {
478    let mut components = Vec::new();
479    for component in path.components() {
480        match component {
481            Component::Normal(name) => components.push(name.to_owned()),
482            Component::CurDir => {}
483            Component::RootDir | Component::ParentDir | Component::Prefix(_) => {
484                return Err(SecureStoreError::UnsafePath);
485            }
486        }
487    }
488    if components.is_empty() {
489        return Err(SecureStoreError::UnsafePath);
490    }
491    Ok(components)
492}
493
494#[cfg(unix)]
495fn open_or_create_directory(
496    parent: &fs::File,
497    relative: &Path,
498) -> Result<fs::File, SecureStoreError> {
499    let mut current = parent.try_clone().map_err(SecureStoreError::Io)?;
500    for component in private_components(relative)? {
501        let created = match rustix::fs::mkdirat(&current, &component, rustix::fs::Mode::RWXU) {
502            Ok(()) => true,
503            Err(error) if error == rustix::io::Errno::EXIST => false,
504            Err(error) => return Err(io_error(error)),
505        };
506        let next = fs::File::from(
507            rustix::fs::openat(
508                &current,
509                &component,
510                directory_flags(),
511                rustix::fs::Mode::empty(),
512            )
513            .map_err(path_error)?,
514        );
515        if created {
516            rustix::fs::fchmod(&next, rustix::fs::Mode::RWXU).map_err(io_error)?;
517        }
518        current = next;
519    }
520    Ok(current)
521}
522
523#[cfg(unix)]
524fn directory_flags() -> rustix::fs::OFlags {
525    rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC
526}
527
528#[cfg(unix)]
529fn read_file(
530    directory: &fs::File,
531    name: &str,
532    limit: u64,
533) -> Result<Option<Vec<u8>>, SecureStoreError> {
534    use std::io::Read as _;
535
536    let Some(file) = open_existing_file(directory, name, rustix::fs::OFlags::RDONLY)? else {
537        return Ok(None);
538    };
539    let metadata = file.metadata().map_err(SecureStoreError::Io)?;
540    if metadata.len() > limit {
541        return Err(SecureStoreError::TooLarge);
542    }
543    let capacity = usize::try_from(metadata.len()).map_err(|_| SecureStoreError::TooLarge)?;
544    let mut bytes = Vec::with_capacity(capacity);
545    file.take(limit.saturating_add(1))
546        .read_to_end(&mut bytes)
547        .map_err(SecureStoreError::Io)?;
548    if bytes.len() as u64 > limit {
549        return Err(SecureStoreError::TooLarge);
550    }
551    Ok(Some(bytes))
552}
553
554#[cfg(unix)]
555fn open_or_create_file(directory: &fs::File, name: &str) -> Result<fs::File, SecureStoreError> {
556    if let Some(file) = open_existing_file(directory, name, rustix::fs::OFlags::RDWR)? {
557        return Ok(file);
558    }
559    match create_file(directory, name) {
560        Ok(file) => Ok(file),
561        Err(SecureStoreError::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {
562            open_existing_file(directory, name, rustix::fs::OFlags::RDWR)?
563                .ok_or(SecureStoreError::UnsafePath)
564        }
565        Err(error) => Err(error),
566    }
567}
568
569#[cfg(unix)]
570fn open_existing_file(
571    directory: &fs::File,
572    name: &str,
573    access: rustix::fs::OFlags,
574) -> Result<Option<fs::File>, SecureStoreError> {
575    let flags = access | rustix::fs::OFlags::NONBLOCK | rustix::fs::OFlags::CLOEXEC;
576    match rustix::fs::openat(directory, name, flags, rustix::fs::Mode::empty()) {
577        Ok(fd) => Ok(Some(fs::File::from(fd))),
578        Err(error) if error == rustix::io::Errno::NOENT => Ok(None),
579        Err(error) => Err(path_error(error)),
580    }
581}
582
583#[cfg(unix)]
584fn create_file(directory: &fs::File, name: &str) -> Result<fs::File, SecureStoreError> {
585    let flags = rustix::fs::OFlags::RDWR
586        | rustix::fs::OFlags::CREATE
587        | rustix::fs::OFlags::EXCL
588        | rustix::fs::OFlags::NOFOLLOW
589        | rustix::fs::OFlags::NONBLOCK
590        | rustix::fs::OFlags::CLOEXEC;
591    let mode = rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR;
592    let file =
593        fs::File::from(rustix::fs::openat(directory, name, flags, mode).map_err(path_error)?);
594    rustix::fs::fchmod(&file, mode).map_err(io_error)?;
595    Ok(file)
596}
597
598fn validate_name(name: &str) -> Result<(), SecureStoreError> {
599    if name.is_empty()
600        || name == "."
601        || name == ".."
602        || name.as_bytes().contains(&b'/')
603        || name.as_bytes().contains(&0)
604    {
605        Err(SecureStoreError::UnsafePath)
606    } else {
607        Ok(())
608    }
609}
610
611#[cfg(unix)]
612fn path_error(error: rustix::io::Errno) -> SecureStoreError {
613    SecureStoreError::Io(error.into())
614}
615
616#[cfg(unix)]
617fn io_error(error: rustix::io::Errno) -> SecureStoreError {
618    SecureStoreError::Io(error.into())
619}