1#[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#[derive(Debug)]
34pub struct SecureDirectory {
35 #[cfg(unix)]
36 pub(crate) directory: fs::File,
37 path: PathBuf,
38}
39
40impl SecureDirectory {
41 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 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 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 #[must_use]
98 pub fn path(&self) -> &Path {
99 &self.path
100 }
101
102 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 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 pub fn lock(&self, name: &str) -> Result<SecureDirectoryLock<'_>, SecureStoreError> {
158 self.lock_within(name, DEFAULT_LOCK_BUDGET)
159 }
160
161 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 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(¤t, Path::new(component))?;
226 }
227 Ok(Self {
228 directory: current,
229 path: relative.to_path_buf(),
230 })
231 }
232}
233
234#[derive(Debug)]
236pub struct SecureDirectoryLock<'a> {
237 directory: &'a SecureDirectory,
238 lock_name: String,
239 _lock: fs::File,
240}
241
242impl SecureDirectoryLock<'_> {
243 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 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 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 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 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 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#[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
378pub const DEFAULT_LOCK_BUDGET: Duration = Duration::from_secs(5);
380
381#[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
402pub 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
414pub 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 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(¤t, 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(¤t, &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 ¤t,
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}