1use std::fs::File;
9use std::io;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum PrivatePathDurabilityFailurePoint {
16 ParentDirectorySync,
19}
20
21#[derive(Clone, Debug, Default)]
27pub struct PrivatePathDurabilityFailureInjector {
28 failures: Arc<Mutex<Vec<PrivatePathDurabilityFailurePoint>>>,
29}
30
31impl PrivatePathDurabilityFailureInjector {
32 pub fn fail_next(&self, point: PrivatePathDurabilityFailurePoint) {
34 self.failures
35 .lock()
36 .expect("private-path failure injector lock poisoned")
37 .push(point);
38 }
39
40 fn check(&self, point: PrivatePathDurabilityFailurePoint) -> io::Result<()> {
41 let mut failures = self
42 .failures
43 .lock()
44 .map_err(|_| io::Error::other("private-path failure injector lock poisoned"))?;
45 if failures.first() == Some(&point) {
46 failures.remove(0);
47 return Err(io::Error::other(
48 "injected private-path parent directory sync failure",
49 ));
50 }
51 Ok(())
52 }
53}
54
55#[derive(Clone, Debug, Default, PartialEq, Eq)]
57pub struct PrivateTreePolicy {
58 selected: Vec<PathBuf>,
59}
60
61impl PrivateTreePolicy {
62 pub fn root_only() -> Self {
64 Self::default()
65 }
66
67 pub fn selected<I, P>(paths: I) -> Self
70 where
71 I: IntoIterator<Item = P>,
72 P: AsRef<Path>,
73 {
74 Self {
75 selected: paths
76 .into_iter()
77 .map(|path| path.as_ref().to_path_buf())
78 .collect(),
79 }
80 }
81}
82
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
85pub struct PrivateTreeReport {
86 pub directories_hardened: usize,
87 pub files_hardened: usize,
88}
89
90#[derive(Debug)]
92pub struct PrivateTree {
93 root: PathBuf,
94 root_descriptor: File,
95 operation_lock: std::sync::Mutex<()>,
96}
97
98impl PrivateTree {
99 pub fn open(root: &Path) -> io::Result<Self> {
101 #[cfg(unix)]
102 let root_descriptor = {
103 let descriptor = unix_walk_directory(root, false, true)?;
104 unix_validate_private_dir_exact(&descriptor)?;
105 unix_revalidate_directory_path(root, &descriptor)?;
106 descriptor
107 };
108 #[cfg(target_os = "windows")]
109 let root_descriptor = {
110 let descriptor = windows_open_directory_for_hardening(root)?;
111 windows_validate_directory(&descriptor)?;
112 windows_validate_hardenable_file_owner(&descriptor)?;
113 windows_harden_file_acl(&descriptor)?;
114 windows_revalidate_directory_path(root, &descriptor)?;
115 descriptor
116 };
117 #[cfg(not(any(unix, target_os = "windows")))]
118 let root_descriptor = File::open(root)?;
119
120 let tree = Self {
121 root: root.to_path_buf(),
122 root_descriptor,
123 operation_lock: std::sync::Mutex::new(()),
124 };
125 tree.revalidate_root()?;
126 Ok(tree)
127 }
128
129 pub fn harden_selected(&self, policy: &PrivateTreePolicy) -> io::Result<PrivateTreeReport> {
133 let _operation = self
134 .operation_lock
135 .lock()
136 .map_err(|_| io::Error::other("private-tree operation lock is poisoned"))?;
137 let selected = normalized_private_tree_selection(policy)?;
138 self.revalidate_root()?;
139 #[cfg(unix)]
140 let report = unix_harden_private_tree(self, &selected)?;
141 #[cfg(target_os = "windows")]
142 let report = windows_harden_private_tree(self, &selected)?;
143 #[cfg(not(any(unix, target_os = "windows")))]
144 let report = generic_harden_private_tree(self, &selected)?;
145 self.revalidate_root()?;
146 Ok(report)
147 }
148
149 pub fn revalidate_root(&self) -> io::Result<()> {
151 #[cfg(unix)]
152 {
153 unix_validate_private_dir_exact(&self.root_descriptor)?;
154 unix_revalidate_directory_path(&self.root, &self.root_descriptor)
155 }
156 #[cfg(target_os = "windows")]
157 {
158 windows_validate_directory(&self.root_descriptor)?;
159 windows_validate_file_owner(&self.root_descriptor)?;
160 windows_revalidate_directory_path(&self.root, &self.root_descriptor)
161 }
162 #[cfg(not(any(unix, target_os = "windows")))]
163 {
164 if self.root_descriptor.metadata()?.is_dir() {
165 Ok(())
166 } else {
167 Err(permission_denied("private tree root is not a directory"))
168 }
169 }
170 }
171}
172
173pub fn harden_private_tree(
175 root: &Path,
176 policy: &PrivateTreePolicy,
177) -> io::Result<PrivateTreeReport> {
178 PrivateTree::open(root)?.harden_selected(policy)
179}
180
181fn normalized_private_tree_selection(policy: &PrivateTreePolicy) -> io::Result<Vec<PathBuf>> {
182 let mut selected = policy.selected.clone();
183 for path in &selected {
184 if path.as_os_str().is_empty() || path.is_absolute() {
185 return Err(io::Error::new(
186 io::ErrorKind::InvalidInput,
187 "private-tree selections must be non-empty relative paths",
188 ));
189 }
190 for component in path.components() {
191 if !matches!(component, std::path::Component::Normal(_)) {
192 return Err(io::Error::new(
193 io::ErrorKind::InvalidInput,
194 "private-tree selections must contain only normal components",
195 ));
196 }
197 }
198 }
199 selected.sort();
200 selected.dedup();
201 Ok(selected)
202}
203
204#[derive(Clone, Copy, PartialEq, Eq)]
205enum PrivateOpenMode {
206 Read,
207 Append,
208 Truncate,
209 CreateNew,
210}
211
212pub fn harden_owner_only(path: &Path) {
215 #[cfg(target_os = "windows")]
216 if let Err(error) = harden_windows_acl(path) {
217 tracing::warn!(?error, ?path, "owner-only ACL hardening failed");
218 }
219 #[cfg(not(target_os = "windows"))]
220 let _ = path;
221}
222
223pub fn harden_owner_only_fallible(path: &Path) -> io::Result<()> {
225 #[cfg(unix)]
226 {
227 let metadata = std::fs::symlink_metadata(path)?;
228 if metadata.file_type().is_symlink() {
229 return Err(permission_denied("owner-private path cannot be a symlink"));
230 }
231 if metadata.is_dir() {
232 return ensure_private_dir(path);
233 }
234 let _file = open_private_read(path)?;
235 Ok(())
236 }
237 #[cfg(target_os = "windows")]
238 {
239 let metadata = std::fs::symlink_metadata(path)?;
240 reject_windows_reparse_metadata(&metadata)?;
241 if metadata.is_dir() {
242 return windows_ensure_private_dir(path);
243 }
244 let _file = windows_open_private(path, PrivateOpenMode::Read, None)?;
245 Ok(())
246 }
247 #[cfg(not(any(unix, target_os = "windows")))]
248 {
249 let _ = path;
250 Ok(())
251 }
252}
253
254pub fn ensure_private_dir(path: &Path) -> io::Result<()> {
258 ensure_private_dir_inner(path, None)
259}
260
261pub fn ensure_private_dir_with_failure_injector(
263 path: &Path,
264 failures: &PrivatePathDurabilityFailureInjector,
265) -> io::Result<()> {
266 ensure_private_dir_inner(path, Some(failures))
267}
268
269fn ensure_private_dir_inner(
270 path: &Path,
271 failures: Option<&PrivatePathDurabilityFailureInjector>,
272) -> io::Result<()> {
273 #[cfg(unix)]
274 {
275 let _ = unix_walk_directory_with_failure_injector(path, true, true, failures)?;
276 Ok(())
277 }
278 #[cfg(target_os = "windows")]
279 {
280 windows_ensure_private_dir_with_failure_injector(path, failures)
281 }
282 #[cfg(not(any(unix, target_os = "windows")))]
283 {
284 std::fs::create_dir_all(path)
285 }
286}
287
288pub fn create_private_file(path: &Path) -> io::Result<File> {
290 open_private(path, PrivateOpenMode::CreateNew, None)
291}
292
293pub fn create_private_file_with_failure_injector(
295 path: &Path,
296 failures: &PrivatePathDurabilityFailureInjector,
297) -> io::Result<File> {
298 open_private(path, PrivateOpenMode::CreateNew, Some(failures))
299}
300
301pub fn open_private_read(path: &Path) -> io::Result<File> {
305 open_private(path, PrivateOpenMode::Read, None)
306}
307
308pub fn open_private_append(path: &Path) -> io::Result<File> {
310 open_private(path, PrivateOpenMode::Append, None)
311}
312
313pub fn open_private_append_with_failure_injector(
315 path: &Path,
316 failures: &PrivatePathDurabilityFailureInjector,
317) -> io::Result<File> {
318 open_private(path, PrivateOpenMode::Append, Some(failures))
319}
320
321pub fn open_private_truncate(path: &Path) -> io::Result<File> {
324 open_private(path, PrivateOpenMode::Truncate, None)
325}
326
327pub fn revalidate_private_file(file: &File) -> io::Result<()> {
331 #[cfg(unix)]
332 {
333 unix_revalidate_private_file(file)
334 }
335 #[cfg(target_os = "windows")]
336 {
337 windows_validate_file(file)
338 }
339 #[cfg(not(any(unix, target_os = "windows")))]
340 {
341 if file.metadata()?.is_file() {
342 Ok(())
343 } else {
344 Err(permission_denied("private file is not a regular file"))
345 }
346 }
347}
348
349pub fn revalidate_private_path(path: &Path, file: &File) -> io::Result<()> {
352 revalidate_private_file(file)?;
353 #[cfg(unix)]
354 {
355 use std::ffi::CString;
356 use std::os::fd::AsRawFd;
357 use std::os::unix::ffi::OsStrExt;
358
359 let name = path.file_name().ok_or_else(|| {
360 io::Error::new(
361 io::ErrorKind::InvalidInput,
362 "private file path has no file name",
363 )
364 })?;
365 let name = CString::new(name.as_bytes())
366 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "file name contains NUL"))?;
367 let parent = unix_walk_directory(normalized_parent(path), false, false)?;
368 unix_validate_path_matches_file(parent.as_raw_fd(), &name, file)
369 }
370 #[cfg(target_os = "windows")]
371 {
372 windows_revalidate_private_path(path, file)
373 }
374 #[cfg(not(any(unix, target_os = "windows")))]
375 {
376 let _ = path;
377 Err(io::Error::new(
378 io::ErrorKind::Unsupported,
379 "path identity revalidation is unsupported on this platform",
380 ))
381 }
382}
383
384pub fn atomic_replace_private_file(temp: &Path, destination: &Path) -> io::Result<()> {
387 if normalized_parent(temp) != normalized_parent(destination) {
388 return Err(io::Error::new(
389 io::ErrorKind::InvalidInput,
390 "private atomic replacement requires one lexical parent directory",
391 ));
392 }
393 #[cfg(unix)]
394 {
395 unix_atomic_replace_private_file(temp, destination)
396 }
397 #[cfg(target_os = "windows")]
398 {
399 windows_atomic_replace_private_file(temp, destination)
400 }
401 #[cfg(not(any(unix, target_os = "windows")))]
402 {
403 std::fs::rename(temp, destination)?;
404 let file = open_private_read(destination)?;
405 revalidate_private_file(&file)
406 }
407}
408
409fn open_private(
410 path: &Path,
411 mode: PrivateOpenMode,
412 failures: Option<&PrivatePathDurabilityFailureInjector>,
413) -> io::Result<File> {
414 #[cfg(unix)]
415 {
416 unix_open_private(path, mode, failures)
417 }
418 #[cfg(target_os = "windows")]
419 {
420 windows_open_private(path, mode, failures)
421 }
422 #[cfg(not(any(unix, target_os = "windows")))]
423 {
424 let _ = failures;
425 generic_open_private(path, mode)
426 }
427}
428
429fn normalized_parent(path: &Path) -> &Path {
430 path.parent()
431 .filter(|parent| !parent.as_os_str().is_empty())
432 .unwrap_or_else(|| Path::new("."))
433}
434
435fn permission_denied(message: &'static str) -> io::Error {
436 io::Error::new(io::ErrorKind::PermissionDenied, message)
437}
438
439#[cfg(unix)]
440fn unix_walk_directory(path: &Path, create: bool, harden_final: bool) -> io::Result<File> {
441 unix_walk_directory_with_failure_injector(path, create, harden_final, None)
442}
443
444#[cfg(unix)]
445fn unix_walk_directory_with_failure_injector(
446 path: &Path,
447 create: bool,
448 harden_final: bool,
449 failures: Option<&PrivatePathDurabilityFailureInjector>,
450) -> io::Result<File> {
451 unix_walk_directory_with_hook(path, create, harden_final, failures, |_| Ok(()))
452}
453
454#[cfg(unix)]
455fn unix_walk_directory_with_hook<F>(
456 path: &Path,
457 create: bool,
458 harden_final: bool,
459 failures: Option<&PrivatePathDurabilityFailureInjector>,
460 mut after_child_barrier: F,
461) -> io::Result<File>
462where
463 F: FnMut(&std::ffi::OsStr) -> io::Result<()>,
464{
465 use std::ffi::{CString, OsStr};
466 use std::os::fd::{AsRawFd, FromRawFd};
467 use std::os::unix::ffi::OsStrExt;
468
469 let descriptor_path = unix_descriptor_path(path);
470 let mut components = Vec::new();
471 let mut absolute = false;
472 for component in descriptor_path.components() {
473 match component {
474 std::path::Component::RootDir => absolute = true,
475 std::path::Component::CurDir => {}
476 std::path::Component::Normal(value) => components.push(value.to_os_string()),
477 std::path::Component::ParentDir => {
478 return Err(io::Error::new(
479 io::ErrorKind::InvalidInput,
480 "owner-private paths cannot contain parent traversal",
481 ));
482 }
483 std::path::Component::Prefix(_) => {
484 return Err(io::Error::new(
485 io::ErrorKind::InvalidInput,
486 "unsupported Unix path prefix",
487 ));
488 }
489 }
490 }
491
492 let base = CString::new(if absolute { "/" } else { "." }).expect("static path");
493 let raw = unsafe {
495 libc::open(
496 base.as_ptr(),
497 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
498 )
499 };
500 if raw < 0 {
501 return Err(io::Error::last_os_error());
502 }
503 let directory = unsafe { File::from_raw_fd(raw) };
505 let mut hierarchy = vec![directory];
510
511 for (index, component) in components.iter().enumerate() {
512 let name = CString::new(OsStr::new(component).as_bytes()).map_err(|_| {
513 io::Error::new(io::ErrorKind::InvalidInput, "path component contains NUL")
514 })?;
515 let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW;
516 let parent = hierarchy
518 .last()
519 .expect("private directory hierarchy always retains its base");
520 let mut child_raw = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) };
521 let mut created = false;
522 if child_raw < 0 && io::Error::last_os_error().kind() == io::ErrorKind::NotFound && create {
523 let mkdir_result = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o700) };
525 if mkdir_result < 0 {
526 let error = io::Error::last_os_error();
527 if error.kind() != io::ErrorKind::AlreadyExists {
528 return Err(error);
529 }
530 } else {
531 created = true;
532 }
533 child_raw = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) };
535 }
536 if child_raw < 0 {
537 return Err(io::Error::last_os_error());
538 }
539 let child = unsafe { File::from_raw_fd(child_raw) };
541 let is_final = index + 1 == components.len();
542 unix_validate_private_dir(&child, created || (is_final && harden_final))?;
543 let already_private = unix_private_dir_is_exact(&child)?;
544 if created {
545 if let Some(failures) = failures {
546 failures.check(PrivatePathDurabilityFailurePoint::ParentDirectorySync)?;
547 }
548 }
549 if created || (create && already_private) {
553 parent.sync_all()?;
554 unix_validate_path_matches_directory(parent.as_raw_fd(), &name, &child)?;
555 }
556 hierarchy.push(child);
557 after_child_barrier(component.as_os_str())?;
558 unix_revalidate_retained_directory_chain(&hierarchy, &components[..=index])?;
559 }
560
561 if components.is_empty() && harden_final {
562 return Err(io::Error::new(
563 io::ErrorKind::InvalidInput,
564 "refusing to harden a filesystem root or current directory",
565 ));
566 }
567 unix_revalidate_retained_directory_chain(&hierarchy, &components)?;
572 hierarchy
573 .pop()
574 .ok_or_else(|| io::Error::other("private directory hierarchy is empty"))
575}
576
577#[cfg(unix)]
578fn unix_revalidate_retained_directory_chain(
579 hierarchy: &[File],
580 components: &[std::ffi::OsString],
581) -> io::Result<()> {
582 use std::ffi::{CString, OsStr};
583 use std::os::fd::AsRawFd;
584 use std::os::unix::ffi::OsStrExt;
585
586 if hierarchy.len() != components.len() + 1 {
587 return Err(io::Error::other(
588 "private directory hierarchy does not match its component chain",
589 ));
590 }
591 for (index, component) in components.iter().enumerate() {
592 let name = CString::new(OsStr::new(component).as_bytes()).map_err(|_| {
593 io::Error::new(io::ErrorKind::InvalidInput, "path component contains NUL")
594 })?;
595 unix_validate_path_matches_directory(
596 hierarchy[index].as_raw_fd(),
597 &name,
598 &hierarchy[index + 1],
599 )?;
600 }
601 Ok(())
602}
603
604#[cfg(unix)]
605fn unix_private_dir_is_exact(directory: &File) -> io::Result<bool> {
606 use std::os::unix::fs::{MetadataExt, PermissionsExt};
607
608 let metadata = directory.metadata()?;
609 Ok(metadata.is_dir()
610 && metadata.uid() == unsafe { libc::geteuid() }
611 && metadata.permissions().mode() & 0o777 == 0o700)
612}
613
614#[cfg(unix)]
615fn unix_descriptor_path(path: &Path) -> std::path::PathBuf {
616 #[cfg(target_os = "macos")]
617 {
618 for (alias, real) in [
622 (Path::new("/var"), Path::new("/private/var")),
623 (Path::new("/tmp"), Path::new("/private/tmp")),
624 (Path::new("/etc"), Path::new("/private/etc")),
625 ] {
626 if let Ok(suffix) = path.strip_prefix(alias) {
627 return real.join(suffix);
628 }
629 }
630 }
631 path.to_path_buf()
632}
633
634#[cfg(unix)]
635fn unix_validate_private_dir(directory: &File, harden: bool) -> io::Result<()> {
636 use std::os::unix::fs::{MetadataExt, PermissionsExt};
637
638 let before = directory.metadata()?;
639 if !before.is_dir() {
640 return Err(permission_denied(
641 "private path component is not a directory",
642 ));
643 }
644 if harden {
645 if before.uid() != unsafe { libc::geteuid() } {
646 return Err(permission_denied(
647 "private directory is not owned by the effective user",
648 ));
649 }
650 directory.set_permissions(std::fs::Permissions::from_mode(0o700))?;
651 let after = directory.metadata()?;
652 if after.dev() != before.dev()
653 || after.ino() != before.ino()
654 || after.uid() != before.uid()
655 || !after.is_dir()
656 || after.permissions().mode() & 0o777 != 0o700
657 {
658 return Err(permission_denied(
659 "private directory changed while it was hardened",
660 ));
661 }
662 }
663 Ok(())
664}
665
666#[cfg(unix)]
667fn unix_validate_private_dir_exact(directory: &File) -> io::Result<()> {
668 use std::os::unix::fs::{MetadataExt, PermissionsExt};
669
670 let metadata = directory.metadata()?;
671 if !metadata.is_dir()
672 || metadata.uid() != unsafe { libc::geteuid() }
673 || metadata.permissions().mode() & 0o777 != 0o700
674 {
675 return Err(permission_denied(
676 "private directory must be current-user owned with mode 0700",
677 ));
678 }
679 Ok(())
680}
681
682#[cfg(unix)]
683fn unix_revalidate_directory_path(path: &Path, directory: &File) -> io::Result<()> {
684 use std::ffi::CString;
685 use std::os::fd::AsRawFd;
686 use std::os::unix::ffi::OsStrExt;
687
688 unix_validate_private_dir_exact(directory)?;
689 let name = path.file_name().ok_or_else(|| {
690 io::Error::new(
691 io::ErrorKind::InvalidInput,
692 "private directory path has no file name",
693 )
694 })?;
695 let name = CString::new(name.as_bytes())
696 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "directory name contains NUL"))?;
697 let parent = unix_walk_directory(normalized_parent(path), false, false)?;
698 unix_validate_path_matches_directory(parent.as_raw_fd(), &name, directory)
699}
700
701#[cfg(unix)]
702fn unix_validate_path_matches_directory(
703 parent_fd: std::os::fd::RawFd,
704 name: &std::ffi::CStr,
705 directory: &File,
706) -> io::Result<()> {
707 use std::mem::MaybeUninit;
708 use std::os::unix::fs::MetadataExt;
709
710 let mut stat = MaybeUninit::<libc::stat>::uninit();
711 let result = unsafe {
713 libc::fstatat(
714 parent_fd,
715 name.as_ptr(),
716 stat.as_mut_ptr(),
717 libc::AT_SYMLINK_NOFOLLOW,
718 )
719 };
720 if result < 0 {
721 return Err(io::Error::last_os_error());
722 }
723 let stat = unsafe { stat.assume_init() };
725 let metadata = directory.metadata()?;
726 if i128::from(stat.st_dev) != i128::from(metadata.dev())
727 || stat.st_ino != metadata.ino()
728 || (u64::from(stat.st_mode) & libc::S_IFMT as u64) != libc::S_IFDIR as u64
729 {
730 return Err(permission_denied(
731 "private directory path changed during descriptor validation",
732 ));
733 }
734 Ok(())
735}
736
737#[cfg(unix)]
738fn unix_harden_private_tree(
739 tree: &PrivateTree,
740 selected: &[PathBuf],
741) -> io::Result<PrivateTreeReport> {
742 use std::collections::BTreeSet;
743
744 let mut report = PrivateTreeReport::default();
745 if selected.is_empty() {
746 unix_validate_private_dir(&tree.root_descriptor, true)?;
747 unix_validate_private_dir_exact(&tree.root_descriptor)?;
748 report.directories_hardened = 1;
749 return Ok(report);
750 }
751
752 let mut visited = BTreeSet::new();
753 visited.insert(unix_file_identity(&tree.root_descriptor)?);
754 for relative in selected {
755 let components = relative
756 .components()
757 .map(|component| component.as_os_str().to_os_string())
758 .collect::<Vec<_>>();
759 unix_harden_selected_path(
760 &tree.root_descriptor,
761 &components,
762 &mut visited,
763 &mut report,
764 )?;
765 }
766 Ok(report)
767}
768
769#[cfg(unix)]
770fn unix_harden_selected_path(
771 parent: &File,
772 components: &[std::ffi::OsString],
773 visited: &mut std::collections::BTreeSet<(u64, u64)>,
774 report: &mut PrivateTreeReport,
775) -> io::Result<()> {
776 use std::ffi::{CString, OsStr};
777 use std::os::fd::AsRawFd;
778 use std::os::unix::ffi::OsStrExt;
779
780 let (component, remaining) = components.split_first().ok_or_else(|| {
781 io::Error::new(
782 io::ErrorKind::InvalidInput,
783 "private-tree selection has no components",
784 )
785 })?;
786 let name = CString::new(OsStr::new(component).as_bytes())
787 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "tree component contains NUL"))?;
788
789 if !remaining.is_empty() {
790 let directory = unix_open_directory_at(parent, &name)?;
791 let identity = unix_file_identity(&directory)?;
792 if visited.insert(identity) {
793 report.directories_hardened += 1;
794 }
795 unix_harden_selected_path(&directory, remaining, visited, report)?;
796 unix_validate_path_matches_directory(parent.as_raw_fd(), &name, &directory)?;
797 return Ok(());
798 }
799
800 match unix_entry_kind(parent.as_raw_fd(), &name)? {
801 UnixEntryKind::Directory => {
802 let directory = unix_open_directory_at(parent, &name)?;
803 let identity = unix_file_identity(&directory)?;
804 if visited.insert(identity) {
805 report.directories_hardened += 1;
806 unix_harden_directory_contents(&directory, visited, report)?;
807 }
808 unix_validate_path_matches_directory(parent.as_raw_fd(), &name, &directory)
809 }
810 UnixEntryKind::RegularFile => {
811 let file = unix_open_file_at(parent, &name, PrivateOpenMode::Read)?;
812 unix_validate_path_matches_file(parent.as_raw_fd(), &name, &file)?;
813 if visited.insert(unix_file_identity(&file)?) {
814 report.files_hardened += 1;
815 }
816 unix_validate_path_matches_file(parent.as_raw_fd(), &name, &file)
817 }
818 UnixEntryKind::Rejected => Err(permission_denied(
819 "private tree contains a symlink or special file",
820 )),
821 }
822}
823
824#[cfg(unix)]
825fn unix_harden_directory_contents(
826 directory: &File,
827 visited: &mut std::collections::BTreeSet<(u64, u64)>,
828 report: &mut PrivateTreeReport,
829) -> io::Result<()> {
830 use std::ffi::{CStr, OsStr};
831 use std::os::fd::AsRawFd;
832 use std::os::unix::ffi::{OsStrExt, OsStringExt};
833
834 let dot = c".";
837 let enumeration_fd = unsafe {
838 libc::openat(
839 directory.as_raw_fd(),
840 dot.as_ptr(),
841 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
842 )
843 };
844 if enumeration_fd < 0 {
845 return Err(io::Error::last_os_error());
846 }
847 let stream = unsafe { libc::fdopendir(enumeration_fd) };
848 if stream.is_null() {
849 let error = io::Error::last_os_error();
850 unsafe {
851 libc::close(enumeration_fd);
852 }
853 return Err(error);
854 }
855
856 let mut names = Vec::new();
857 let enumeration_result = loop {
858 unix_set_errno(0);
859 let entry = unsafe { libc::readdir(stream) };
860 if entry.is_null() {
861 let errno = unix_errno();
862 break if errno == 0 {
863 Ok(())
864 } else {
865 Err(io::Error::from_raw_os_error(errno))
866 };
867 }
868 let bytes = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
869 if bytes != b"." && bytes != b".." {
870 names.push(std::ffi::OsString::from_vec(bytes.to_vec()));
871 }
872 };
873 let close_result = unsafe { libc::closedir(stream) };
874 enumeration_result?;
875 if close_result < 0 {
876 return Err(io::Error::last_os_error());
877 }
878 names.sort();
879
880 for child in names {
881 let name = std::ffi::CString::new(OsStr::new(&child).as_bytes())
882 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "tree entry contains NUL"))?;
883 match unix_entry_kind(directory.as_raw_fd(), &name)? {
884 UnixEntryKind::Directory => {
885 let child_directory = unix_open_directory_at(directory, &name)?;
886 let identity = unix_file_identity(&child_directory)?;
887 if visited.insert(identity) {
888 report.directories_hardened += 1;
889 unix_harden_directory_contents(&child_directory, visited, report)?;
890 }
891 unix_validate_path_matches_directory(
892 directory.as_raw_fd(),
893 &name,
894 &child_directory,
895 )?;
896 }
897 UnixEntryKind::RegularFile => {
898 let file = unix_open_file_at(directory, &name, PrivateOpenMode::Read)?;
899 unix_validate_path_matches_file(directory.as_raw_fd(), &name, &file)?;
900 if visited.insert(unix_file_identity(&file)?) {
901 report.files_hardened += 1;
902 }
903 unix_validate_path_matches_file(directory.as_raw_fd(), &name, &file)?;
904 }
905 UnixEntryKind::Rejected => {
906 return Err(permission_denied(
907 "private tree contains a symlink or special file",
908 ));
909 }
910 }
911 }
912 Ok(())
913}
914
915#[cfg(unix)]
916fn unix_open_directory_at(parent: &File, name: &std::ffi::CStr) -> io::Result<File> {
917 use std::os::fd::{AsRawFd, FromRawFd};
918
919 let raw = unsafe {
920 libc::openat(
921 parent.as_raw_fd(),
922 name.as_ptr(),
923 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
924 )
925 };
926 if raw < 0 {
927 return Err(io::Error::last_os_error());
928 }
929 let directory = unsafe { File::from_raw_fd(raw) };
930 unix_validate_private_dir(&directory, true)?;
931 unix_validate_private_dir_exact(&directory)?;
932 unix_validate_path_matches_directory(parent.as_raw_fd(), name, &directory)?;
933 Ok(directory)
934}
935
936#[cfg(unix)]
937#[derive(Clone, Copy, Debug, PartialEq, Eq)]
938enum UnixEntryKind {
939 Directory,
940 RegularFile,
941 Rejected,
942}
943
944#[cfg(unix)]
945fn unix_entry_kind(
946 parent_fd: std::os::fd::RawFd,
947 name: &std::ffi::CStr,
948) -> io::Result<UnixEntryKind> {
949 use std::mem::MaybeUninit;
950
951 let mut stat = MaybeUninit::<libc::stat>::uninit();
952 let result = unsafe {
953 libc::fstatat(
954 parent_fd,
955 name.as_ptr(),
956 stat.as_mut_ptr(),
957 libc::AT_SYMLINK_NOFOLLOW,
958 )
959 };
960 if result < 0 {
961 return Err(io::Error::last_os_error());
962 }
963 let mode = u64::from(unsafe { stat.assume_init() }.st_mode) & libc::S_IFMT as u64;
964 Ok(if mode == libc::S_IFDIR as u64 {
965 UnixEntryKind::Directory
966 } else if mode == libc::S_IFREG as u64 {
967 UnixEntryKind::RegularFile
968 } else {
969 UnixEntryKind::Rejected
970 })
971}
972
973#[cfg(all(unix, target_os = "linux"))]
974fn unix_errno() -> i32 {
975 unsafe { *libc::__errno_location() }
976}
977
978#[cfg(all(unix, target_os = "linux"))]
979fn unix_set_errno(value: i32) {
980 unsafe {
981 *libc::__errno_location() = value;
982 }
983}
984
985#[cfg(target_os = "android")]
986fn unix_errno() -> i32 {
987 unsafe { *libc::__errno() }
988}
989
990#[cfg(target_os = "android")]
991fn unix_set_errno(value: i32) {
992 unsafe {
993 *libc::__errno() = value;
994 }
995}
996
997#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
998fn unix_errno() -> i32 {
999 unsafe { *libc::__error() }
1000}
1001
1002#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
1003fn unix_set_errno(value: i32) {
1004 unsafe {
1005 *libc::__error() = value;
1006 }
1007}
1008
1009#[cfg(unix)]
1010fn unix_open_private(
1011 path: &Path,
1012 mode: PrivateOpenMode,
1013 failures: Option<&PrivatePathDurabilityFailureInjector>,
1014) -> io::Result<File> {
1015 use std::ffi::CString;
1016 use std::os::fd::AsRawFd;
1017 use std::os::unix::ffi::OsStrExt;
1018
1019 let name = path.file_name().ok_or_else(|| {
1020 io::Error::new(
1021 io::ErrorKind::InvalidInput,
1022 "private file path has no file name",
1023 )
1024 })?;
1025 let name = CString::new(name.as_bytes())
1026 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "file name contains NUL"))?;
1027 let parent_path = normalized_parent(path);
1028 let harden_parent = parent_path != Path::new(".");
1029 let create_parent = matches!(mode, PrivateOpenMode::Append | PrivateOpenMode::CreateNew);
1030 let parent = unix_walk_directory_with_failure_injector(
1031 parent_path,
1032 create_parent,
1033 harden_parent,
1034 failures,
1035 )?;
1036 if harden_parent {
1037 unix_revalidate_directory_path(parent_path, &parent)?;
1038 }
1039 let (file, created) = unix_open_file_at_with_created(&parent, &name, mode)?;
1040 unix_validate_path_matches_file(parent.as_raw_fd(), &name, &file)?;
1041 if harden_parent {
1042 unix_revalidate_directory_path(parent_path, &parent)?;
1043 }
1044 if mode == PrivateOpenMode::Truncate {
1045 file.set_len(0)?;
1046 unix_revalidate_private_file(&file)?;
1047 unix_validate_path_matches_file(parent.as_raw_fd(), &name, &file)?;
1048 if harden_parent {
1049 unix_revalidate_directory_path(parent_path, &parent)?;
1050 }
1051 }
1052 if matches!(mode, PrivateOpenMode::Append | PrivateOpenMode::CreateNew) {
1053 if created {
1054 if let Some(failures) = failures {
1055 failures.check(PrivatePathDurabilityFailurePoint::ParentDirectorySync)?;
1056 }
1057 }
1058 parent.sync_all()?;
1062 unix_validate_path_matches_file(parent.as_raw_fd(), &name, &file)?;
1063 if harden_parent {
1064 unix_revalidate_directory_path(parent_path, &parent)?;
1065 }
1066 }
1067 Ok(file)
1068}
1069
1070#[cfg(unix)]
1071fn unix_open_file_at(
1072 parent: &File,
1073 name: &std::ffi::CStr,
1074 mode: PrivateOpenMode,
1075) -> io::Result<File> {
1076 unix_open_file_at_with_created(parent, name, mode).map(|(file, _created)| file)
1077}
1078
1079#[cfg(unix)]
1080fn unix_open_file_at_with_created(
1081 parent: &File,
1082 name: &std::ffi::CStr,
1083 mode: PrivateOpenMode,
1084) -> io::Result<(File, bool)> {
1085 use std::os::fd::{AsRawFd, FromRawFd};
1086
1087 let base_flags = libc::O_CLOEXEC | libc::O_NOFOLLOW;
1088 let (mut flags, mut created) = match mode {
1089 PrivateOpenMode::Read => (base_flags | libc::O_RDONLY, false),
1090 PrivateOpenMode::Append => (base_flags | libc::O_RDWR | libc::O_APPEND, false),
1091 PrivateOpenMode::Truncate => (base_flags | libc::O_RDWR, false),
1092 PrivateOpenMode::CreateNew => (
1093 base_flags | libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL,
1094 true,
1095 ),
1096 };
1097 let mut raw = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags, 0o600) };
1100 if raw < 0
1101 && mode == PrivateOpenMode::Append
1102 && io::Error::last_os_error().kind() == io::ErrorKind::NotFound
1103 {
1104 flags |= libc::O_CREAT | libc::O_EXCL;
1105 raw = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags, 0o600) };
1106 if raw < 0 && io::Error::last_os_error().kind() == io::ErrorKind::AlreadyExists {
1107 flags &= !(libc::O_CREAT | libc::O_EXCL);
1108 raw = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags, 0o600) };
1109 } else if raw >= 0 {
1110 created = true;
1111 }
1112 }
1113 if raw < 0 {
1114 return Err(io::Error::last_os_error());
1115 }
1116 let file = unsafe { File::from_raw_fd(raw) };
1118 unix_revalidate_private_file(&file)?;
1119 Ok((file, created))
1120}
1121
1122#[cfg(unix)]
1123fn unix_revalidate_private_file(file: &File) -> io::Result<()> {
1124 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1125
1126 let before = file.metadata()?;
1127 let euid = unsafe { libc::geteuid() };
1128 if !before.is_file() || before.uid() != euid || before.nlink() != 1 {
1129 return Err(permission_denied(
1130 "private file must be a current-user, single-link regular file",
1131 ));
1132 }
1133 file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
1134 let after = file.metadata()?;
1135 if !after.is_file()
1136 || after.uid() != euid
1137 || after.nlink() != 1
1138 || after.dev() != before.dev()
1139 || after.ino() != before.ino()
1140 || after.permissions().mode() & 0o777 != 0o600
1141 {
1142 return Err(permission_denied(
1143 "private file changed while it was hardened",
1144 ));
1145 }
1146 Ok(())
1147}
1148
1149#[cfg(unix)]
1150fn unix_validate_path_matches_file(
1151 parent_fd: std::os::fd::RawFd,
1152 name: &std::ffi::CStr,
1153 file: &File,
1154) -> io::Result<()> {
1155 use std::mem::MaybeUninit;
1156 use std::os::unix::fs::MetadataExt;
1157
1158 let mut stat = MaybeUninit::<libc::stat>::uninit();
1159 let result = unsafe {
1161 libc::fstatat(
1162 parent_fd,
1163 name.as_ptr(),
1164 stat.as_mut_ptr(),
1165 libc::AT_SYMLINK_NOFOLLOW,
1166 )
1167 };
1168 if result < 0 {
1169 return Err(io::Error::last_os_error());
1170 }
1171 let stat = unsafe { stat.assume_init() };
1173 let metadata = file.metadata()?;
1174 if i128::from(stat.st_dev) != i128::from(metadata.dev())
1175 || stat.st_ino != metadata.ino()
1176 || (u64::from(stat.st_mode) & libc::S_IFMT as u64) != libc::S_IFREG as u64
1177 {
1178 return Err(permission_denied(
1179 "private file path changed during descriptor validation",
1180 ));
1181 }
1182 Ok(())
1183}
1184
1185#[cfg(unix)]
1186fn unix_atomic_replace_private_file(temp: &Path, destination: &Path) -> io::Result<()> {
1187 unix_atomic_replace_private_file_with_hook(temp, destination, || Ok(()))
1188}
1189
1190#[cfg(unix)]
1191fn unix_atomic_replace_private_file_with_hook<F>(
1192 temp: &Path,
1193 destination: &Path,
1194 before_rename: F,
1195) -> io::Result<()>
1196where
1197 F: FnOnce() -> io::Result<()>,
1198{
1199 use std::ffi::CString;
1200 use std::os::fd::AsRawFd;
1201 use std::os::unix::ffi::OsStrExt;
1202
1203 let parent_path = normalized_parent(destination);
1204 let parent = unix_walk_directory(parent_path, false, parent_path != Path::new("."))?;
1205 let revalidate_parent = parent_path != Path::new(".");
1206 if revalidate_parent {
1207 unix_revalidate_directory_path(parent_path, &parent)?;
1208 }
1209 let temp_name = CString::new(
1210 temp.file_name()
1211 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "temp has no name"))?
1212 .as_bytes(),
1213 )
1214 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "temp name contains NUL"))?;
1215 let destination_name = CString::new(
1216 destination
1217 .file_name()
1218 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "destination has no name"))?
1219 .as_bytes(),
1220 )
1221 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "destination name contains NUL"))?;
1222
1223 let temp_file = unix_open_file_at(&parent, &temp_name, PrivateOpenMode::Read)?;
1224 unix_validate_path_matches_file(parent.as_raw_fd(), &temp_name, &temp_file)?;
1225 let temp_identity = unix_file_identity(&temp_file)?;
1226 temp_file.sync_all()?;
1227 before_rename()?;
1228 unix_revalidate_private_file(&temp_file)?;
1229 unix_validate_path_matches_file(parent.as_raw_fd(), &temp_name, &temp_file)?;
1230 if revalidate_parent {
1231 unix_revalidate_directory_path(parent_path, &parent)?;
1232 }
1233
1234 let result = unsafe {
1237 libc::renameat(
1238 parent.as_raw_fd(),
1239 temp_name.as_ptr(),
1240 parent.as_raw_fd(),
1241 destination_name.as_ptr(),
1242 )
1243 };
1244 if result < 0 {
1245 return Err(io::Error::last_os_error());
1246 }
1247 let published = unix_open_file_at(&parent, &destination_name, PrivateOpenMode::Read)?;
1248 unix_validate_path_matches_file(parent.as_raw_fd(), &destination_name, &published)?;
1249 if unix_file_identity(&published)? != temp_identity {
1250 return Err(permission_denied(
1251 "published private file does not match the validated temp descriptor",
1252 ));
1253 }
1254 unix_revalidate_private_file(&temp_file)?;
1255 parent.sync_all()?;
1256 unix_revalidate_private_file(&published)?;
1257 unix_validate_path_matches_file(parent.as_raw_fd(), &destination_name, &published)?;
1258 if revalidate_parent {
1259 unix_revalidate_directory_path(parent_path, &parent)?;
1260 }
1261 Ok(())
1262}
1263
1264#[cfg(unix)]
1265fn unix_file_identity(file: &File) -> io::Result<(u64, u64)> {
1266 use std::os::unix::fs::MetadataExt;
1267
1268 let metadata = file.metadata()?;
1269 Ok((metadata.dev(), metadata.ino()))
1270}
1271
1272#[cfg(target_os = "windows")]
1273const FILE_ATTRIBUTE_REPARSE_POINT_VALUE: u32 = 0x0000_0400;
1274
1275#[cfg(target_os = "windows")]
1276struct WindowsPrivateSecurityDescriptor {
1277 descriptor: windows::Win32::Security::PSECURITY_DESCRIPTOR,
1278}
1279
1280#[cfg(target_os = "windows")]
1281impl WindowsPrivateSecurityDescriptor {
1282 fn new() -> io::Result<Self> {
1283 use std::os::windows::ffi::OsStrExt;
1284 use windows::core::PCWSTR;
1285 use windows::Win32::Security::Authorization::{
1286 ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
1287 };
1288 use windows::Win32::Security::PSECURITY_DESCRIPTOR;
1289
1290 let owner = current_process_default_owner_sid_string()?;
1291 let user = current_process_sid_string()?;
1292 let sddl = format!("O:{owner}D:P(A;;FA;;;{user})");
1298 let mut wide = std::ffi::OsStr::new(&sddl)
1299 .encode_wide()
1300 .collect::<Vec<_>>();
1301 wide.push(0);
1302 let mut descriptor = PSECURITY_DESCRIPTOR::default();
1303 unsafe {
1304 ConvertStringSecurityDescriptorToSecurityDescriptorW(
1305 PCWSTR(wide.as_ptr()),
1306 SDDL_REVISION_1,
1307 &mut descriptor,
1308 None,
1309 )
1310 }
1311 .map_err(windows_io_error)?;
1312 if descriptor.0.is_null() {
1313 return Err(io::Error::other(
1314 "Windows returned a null private security descriptor",
1315 ));
1316 }
1317 Ok(Self { descriptor })
1318 }
1319
1320 fn security_attributes(&self) -> windows::Win32::Security::SECURITY_ATTRIBUTES {
1321 use windows::Win32::Foundation::BOOL;
1322 use windows::Win32::Security::SECURITY_ATTRIBUTES;
1323
1324 SECURITY_ATTRIBUTES {
1325 nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
1326 lpSecurityDescriptor: self.descriptor.0,
1327 bInheritHandle: BOOL(0),
1328 }
1329 }
1330
1331 fn dacl(&self) -> io::Result<*const windows::Win32::Security::ACL> {
1332 use windows::Win32::Foundation::BOOL;
1333 use windows::Win32::Security::{GetSecurityDescriptorDacl, ACL};
1334
1335 let mut present = BOOL(0);
1336 let mut defaulted = BOOL(0);
1337 let mut dacl = std::ptr::null_mut::<ACL>();
1338 unsafe {
1339 GetSecurityDescriptorDacl(self.descriptor, &mut present, &mut dacl, &mut defaulted)
1340 }
1341 .map_err(windows_io_error)?;
1342 if !present.as_bool() || dacl.is_null() {
1343 return Err(io::Error::other(
1344 "private Windows security descriptor has no DACL",
1345 ));
1346 }
1347 Ok(dacl)
1348 }
1349}
1350
1351#[cfg(target_os = "windows")]
1352impl Drop for WindowsPrivateSecurityDescriptor {
1353 fn drop(&mut self) {
1354 use windows::Win32::Foundation::{LocalFree, HLOCAL};
1355
1356 unsafe {
1357 let _ = LocalFree(HLOCAL(self.descriptor.0));
1358 }
1359 }
1360}
1361
1362#[cfg(target_os = "windows")]
1363fn windows_io_error(error: windows::core::Error) -> io::Error {
1364 use windows::Win32::Foundation::WIN32_ERROR;
1365
1366 WIN32_ERROR::from_error(&error)
1367 .map(|code| io::Error::from_raw_os_error(code.0 as i32))
1368 .unwrap_or_else(|| io::Error::other(error.to_string()))
1369}
1370
1371#[cfg(target_os = "windows")]
1372fn windows_error_stage<T>(stage: &'static str, result: io::Result<T>) -> io::Result<T> {
1373 result.map_err(|error| io::Error::new(error.kind(), format!("{stage}: {error}")))
1374}
1375
1376#[cfg(target_os = "windows")]
1377fn windows_path_wide(path: &Path) -> io::Result<Vec<u16>> {
1378 use std::os::windows::ffi::OsStrExt;
1379
1380 let mut wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
1381 if wide.contains(&0) {
1382 return Err(io::Error::new(
1383 io::ErrorKind::InvalidInput,
1384 "Windows private path contains NUL",
1385 ));
1386 }
1387 wide.push(0);
1388 Ok(wide)
1389}
1390
1391#[cfg(target_os = "windows")]
1392fn windows_harden_file_acl(file: &File) -> io::Result<()> {
1393 use std::os::windows::io::AsRawHandle;
1394 use windows::Win32::Foundation::{CloseHandle, ERROR_SUCCESS, HANDLE, PSID};
1395 use windows::Win32::Security::Authorization::{SetSecurityInfo, SE_FILE_OBJECT};
1396 use windows::Win32::Security::{
1397 GetTokenInformation, TokenOwner, DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION,
1398 PROTECTED_DACL_SECURITY_INFORMATION, TOKEN_OWNER, TOKEN_QUERY,
1399 };
1400 use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
1401
1402 windows_validate_hardenable_file_owner(file)?;
1403 let descriptor = WindowsPrivateSecurityDescriptor::new()?;
1404
1405 let windows_error = |error: windows::core::Error| io::Error::other(error.to_string());
1410 let mut token = HANDLE::default();
1411 unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }
1412 .map_err(windows_error)?;
1413 let result = (|| {
1414 let mut needed = 0u32;
1415 let _ = unsafe { GetTokenInformation(token, TokenOwner, None, 0, &mut needed) };
1416 if needed == 0 {
1417 return Err(io::Error::last_os_error());
1418 }
1419 let words = (needed as usize).div_ceil(std::mem::size_of::<usize>());
1420 let mut buffer = vec![0usize; words];
1421 unsafe {
1422 GetTokenInformation(
1423 token,
1424 TokenOwner,
1425 Some(buffer.as_mut_ptr().cast()),
1426 needed,
1427 &mut needed,
1428 )
1429 }
1430 .map_err(windows_error)?;
1431 let owner = unsafe { &*buffer.as_ptr().cast::<TOKEN_OWNER>() };
1432 let status = unsafe {
1433 SetSecurityInfo(
1434 HANDLE(file.as_raw_handle() as isize),
1435 SE_FILE_OBJECT,
1436 DACL_SECURITY_INFORMATION
1437 | OWNER_SECURITY_INFORMATION
1438 | PROTECTED_DACL_SECURITY_INFORMATION,
1439 owner.Owner,
1440 PSID::default(),
1441 Some(descriptor.dacl()?),
1442 None,
1443 )
1444 };
1445 if status == ERROR_SUCCESS {
1446 Ok(())
1447 } else {
1448 Err(io::Error::from_raw_os_error(status.0 as i32))
1449 }
1450 })();
1451 let _ = unsafe { CloseHandle(token) };
1452 result?;
1453 windows_validate_file_owner(file)?;
1454 windows_validate_exact_private_acl(file)
1455}
1456
1457#[cfg(target_os = "windows")]
1458fn windows_validate_exact_private_acl(file: &File) -> io::Result<()> {
1459 use std::os::windows::io::AsRawHandle;
1460 use windows::Win32::Foundation::{LocalFree, HANDLE, HLOCAL, PSID};
1461 use windows::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
1462 use windows::Win32::Security::{
1463 GetAce, GetSecurityDescriptorControl, ACCESS_ALLOWED_ACE, ACL, DACL_SECURITY_INFORMATION,
1464 PSECURITY_DESCRIPTOR, SE_DACL_PROTECTED,
1465 };
1466 use windows::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
1467 const ACCESS_ALLOWED_ACE_TYPE_VALUE: u8 = 0;
1468
1469 let mut dacl = std::ptr::null_mut::<ACL>();
1470 let mut descriptor = PSECURITY_DESCRIPTOR::default();
1471 let status = unsafe {
1472 GetSecurityInfo(
1473 HANDLE(file.as_raw_handle() as isize),
1474 SE_FILE_OBJECT,
1475 DACL_SECURITY_INFORMATION,
1476 None,
1477 None,
1478 Some(&mut dacl),
1479 None,
1480 Some(&mut descriptor),
1481 )
1482 };
1483 if !status.is_ok() {
1484 return Err(io::Error::from_raw_os_error(status.0 as i32));
1485 }
1486 let result = (|| {
1487 if dacl.is_null() {
1488 return Err(permission_denied("private Windows ACL is absent"));
1489 }
1490 let mut control = 0u16;
1491 let mut revision = 0u32;
1492 unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) }
1493 .map_err(windows_io_error)?;
1494 if control & SE_DACL_PROTECTED.0 == 0 || unsafe { (*dacl).AceCount } != 1 {
1495 return Err(permission_denied(
1496 "private Windows ACL is not one protected owner ACE",
1497 ));
1498 }
1499 let mut raw_ace = std::ptr::null_mut();
1500 unsafe { GetAce(dacl, 0, &mut raw_ace) }.map_err(windows_io_error)?;
1501 let ace = unsafe { &*raw_ace.cast::<ACCESS_ALLOWED_ACE>() };
1502 if ace.Header.AceType != ACCESS_ALLOWED_ACE_TYPE_VALUE || ace.Mask != FILE_ALL_ACCESS.0 {
1503 return Err(permission_denied(
1504 "private Windows ACL does not grant exact owner full control",
1505 ));
1506 }
1507 let sid = PSID(std::ptr::addr_of!(ace.SidStart).cast_mut().cast());
1508 if windows_sid_string(sid)? != current_process_sid_string()? {
1509 return Err(permission_denied(
1510 "private Windows ACL is granted to another identity",
1511 ));
1512 }
1513 Ok(())
1514 })();
1515 unsafe {
1516 let _ = LocalFree(HLOCAL(descriptor.0));
1517 }
1518 result
1519}
1520
1521#[cfg(target_os = "windows")]
1522fn windows_create_private_directory(path: &Path) -> io::Result<()> {
1523 use windows::core::PCWSTR;
1524 use windows::Win32::Storage::FileSystem::CreateDirectoryW;
1525
1526 let descriptor = WindowsPrivateSecurityDescriptor::new()?;
1527 let attributes = descriptor.security_attributes();
1528 let wide = windows_path_wide(path)?;
1529 unsafe { CreateDirectoryW(PCWSTR(wide.as_ptr()), Some(&attributes)) }.map_err(windows_io_error)
1530}
1531
1532#[cfg(target_os = "windows")]
1533fn reject_windows_reparse_metadata(metadata: &std::fs::Metadata) -> io::Result<()> {
1534 use std::os::windows::fs::MetadataExt;
1535
1536 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT_VALUE != 0 {
1537 Err(permission_denied(
1538 "owner-private path cannot be a reparse point",
1539 ))
1540 } else {
1541 Ok(())
1542 }
1543}
1544
1545#[cfg(target_os = "windows")]
1546fn windows_ensure_private_dir(path: &Path) -> io::Result<()> {
1547 windows_ensure_private_dir_with_failure_injector(path, None)
1548}
1549
1550#[cfg(target_os = "windows")]
1551fn windows_ensure_private_dir_with_failure_injector(
1552 path: &Path,
1553 failures: Option<&PrivatePathDurabilityFailureInjector>,
1554) -> io::Result<()> {
1555 windows_ensure_private_dir_with_hook(path, failures, |_| Ok(()))
1556}
1557
1558#[cfg(target_os = "windows")]
1559fn windows_ensure_private_dir_with_hook<F>(
1560 path: &Path,
1561 failures: Option<&PrivatePathDurabilityFailureInjector>,
1562 mut after_validated_child: F,
1563) -> io::Result<()>
1564where
1565 F: FnMut(&Path) -> io::Result<()>,
1566{
1567 let mut current = std::path::PathBuf::new();
1568 let mut saw_directory = false;
1569 let mut hierarchy: Vec<(PathBuf, File)> = Vec::new();
1570 for component in path.components() {
1571 current.push(component.as_os_str());
1572 if !matches!(component, std::path::Component::Normal(_)) {
1573 if matches!(component, std::path::Component::ParentDir) {
1574 return Err(io::Error::new(
1575 io::ErrorKind::InvalidInput,
1576 "owner-private paths cannot contain parent traversal",
1577 ));
1578 }
1579 continue;
1580 }
1581 saw_directory = true;
1582 match std::fs::symlink_metadata(¤t) {
1583 Ok(metadata) => {
1584 reject_windows_reparse_metadata(&metadata)?;
1585 if !metadata.is_dir() {
1586 return Err(permission_denied(
1587 "private path component is not a directory",
1588 ));
1589 }
1590 let directory = if current == path {
1591 windows_open_directory_for_hardening(¤t)?
1592 } else {
1593 windows_open_directory(¤t)?
1594 };
1595 windows_validate_directory(&directory)?;
1596 if current == path {
1597 windows_validate_hardenable_file_owner(&directory)?;
1598 windows_harden_file_acl(&directory)?;
1599 windows_revalidate_directory_path(¤t, &directory)?;
1600 }
1601 if windows_validate_file_owner(&directory).is_ok() {
1609 let parent_path = normalized_parent(¤t);
1610 let parent_probe = windows_open_directory(parent_path)?;
1611 match windows_validate_file_owner(&parent_probe) {
1612 Ok(()) => {
1613 let parent = windows_open_directory_for_durability(parent_path)?;
1614 windows_flush_directory_metadata(&parent)?;
1615 windows_revalidate_directory_path(parent_path, &parent)?;
1616 windows_revalidate_directory_path(¤t, &directory)?;
1617 }
1618 Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {}
1619 Err(error) => return Err(error),
1620 }
1621 }
1622 after_validated_child(¤t)?;
1623 let retained_access = if current == path {
1624 windows::Win32::Storage::FileSystem::FILE_GENERIC_READ.0
1625 | windows::Win32::Storage::FileSystem::WRITE_DAC.0
1626 | windows::Win32::Storage::FileSystem::WRITE_OWNER.0
1627 } else {
1628 windows::Win32::Storage::FileSystem::FILE_GENERIC_READ.0
1629 };
1630 let retained = windows_open_directory_with_access_and_share(
1631 ¤t,
1632 retained_access,
1633 windows::Win32::Storage::FileSystem::FILE_SHARE_READ
1634 | windows::Win32::Storage::FileSystem::FILE_SHARE_WRITE,
1635 )?;
1636 windows_validate_directory(&retained)?;
1637 if windows_file_identity(&directory)? != windows_file_identity(&retained)? {
1638 return Err(permission_denied(
1639 "validated private directory changed before retention",
1640 ));
1641 }
1642 if current == path {
1643 windows_harden_file_acl(&retained)?;
1644 windows_revalidate_directory_path(¤t, &retained)?;
1645 } else {
1646 windows_revalidate_directory_path_identity(¤t, &retained)?;
1647 }
1648 hierarchy.push((current.clone(), retained));
1649 }
1650 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1651 let parent_path = normalized_parent(¤t);
1652 let parent = windows_open_directory_for_durability(parent_path)?;
1653 windows_validate_directory(&parent)?;
1654 if let Some((retained_path, retained)) = hierarchy.last() {
1655 if retained_path == parent_path
1656 && windows_file_identity(retained)? != windows_file_identity(&parent)?
1657 {
1658 return Err(permission_denied(
1659 "private directory parent changed before creation",
1660 ));
1661 }
1662 }
1663 if let Err(create_error) = windows_create_private_directory(¤t) {
1664 if create_error.kind() != io::ErrorKind::AlreadyExists {
1665 return Err(create_error);
1666 }
1667 }
1668 let directory = windows_open_directory_for_hardening(¤t)?;
1669 windows_validate_directory(&directory)?;
1670 windows_validate_hardenable_file_owner(&directory)?;
1671 windows_harden_file_acl(&directory)?;
1672 windows_revalidate_directory_path(¤t, &directory)?;
1673 after_validated_child(¤t)?;
1674 let retained = windows_open_directory_with_access_and_share(
1675 ¤t,
1676 windows::Win32::Storage::FileSystem::FILE_GENERIC_READ.0
1677 | windows::Win32::Storage::FileSystem::WRITE_DAC.0
1678 | windows::Win32::Storage::FileSystem::WRITE_OWNER.0,
1679 windows::Win32::Storage::FileSystem::FILE_SHARE_READ
1680 | windows::Win32::Storage::FileSystem::FILE_SHARE_WRITE,
1681 )?;
1682 windows_validate_directory(&retained)?;
1683 if windows_file_identity(&directory)? != windows_file_identity(&retained)? {
1684 return Err(permission_denied(
1685 "created private directory changed before retention",
1686 ));
1687 }
1688 windows_harden_file_acl(&retained)?;
1689 windows_revalidate_directory_path(¤t, &retained)?;
1690 if let Some(failures) = failures {
1691 failures.check(PrivatePathDurabilityFailurePoint::ParentDirectorySync)?;
1692 }
1693 windows_flush_directory_metadata(&parent)?;
1694 windows_revalidate_directory_path(parent_path, &parent)?;
1695 windows_revalidate_directory_path(¤t, &retained)?;
1696 windows_validate_exact_private_acl(&retained)?;
1697 hierarchy.push((current.clone(), retained));
1698 }
1699 Err(error) => return Err(error),
1700 }
1701 }
1702 if !saw_directory {
1703 return Err(io::Error::new(
1704 io::ErrorKind::InvalidInput,
1705 "refusing to harden a filesystem root or current directory",
1706 ));
1707 }
1708 Ok(())
1709}
1710
1711#[cfg(target_os = "windows")]
1718fn windows_flush_directory_metadata(directory: &File) -> io::Result<()> {
1719 use std::os::windows::io::AsRawHandle;
1720 use windows::Win32::Foundation::{ERROR_ACCESS_DENIED, HANDLE, WIN32_ERROR};
1721 use windows::Win32::Storage::FileSystem::FlushFileBuffers;
1722
1723 match unsafe { FlushFileBuffers(HANDLE(directory.as_raw_handle() as isize)) } {
1724 Ok(()) => Ok(()),
1725 Err(error) if WIN32_ERROR::from_error(&error) == Some(ERROR_ACCESS_DENIED) => Ok(()),
1726 Err(error) => Err(windows_io_error(error)),
1727 }
1728}
1729
1730#[cfg(target_os = "windows")]
1731fn windows_validate_existing_directory_chain(path: &Path) -> io::Result<()> {
1732 let mut current = PathBuf::new();
1733 for component in path.components() {
1734 current.push(component.as_os_str());
1735 if !matches!(component, std::path::Component::Normal(_)) {
1736 if matches!(component, std::path::Component::ParentDir) {
1737 return Err(io::Error::new(
1738 io::ErrorKind::InvalidInput,
1739 "owner-private paths cannot contain parent traversal",
1740 ));
1741 }
1742 continue;
1743 }
1744 let metadata = std::fs::symlink_metadata(¤t)?;
1745 reject_windows_reparse_metadata(&metadata)?;
1746 if !metadata.is_dir() {
1747 return Err(permission_denied(
1748 "private path component is not a directory",
1749 ));
1750 }
1751 let directory = windows_open_directory(¤t)?;
1752 windows_validate_directory(&directory)?;
1753 }
1754 Ok(())
1755}
1756
1757#[cfg(target_os = "windows")]
1758fn windows_open_directory(path: &Path) -> io::Result<File> {
1759 use windows::Win32::Storage::FileSystem::FILE_GENERIC_READ;
1760
1761 windows_open_directory_with_access(path, FILE_GENERIC_READ.0)
1762}
1763
1764#[cfg(target_os = "windows")]
1765fn windows_open_directory_for_hardening(path: &Path) -> io::Result<File> {
1766 use windows::Win32::Storage::FileSystem::{FILE_GENERIC_READ, WRITE_DAC, WRITE_OWNER};
1767
1768 windows_open_directory_with_access(path, FILE_GENERIC_READ.0 | WRITE_DAC.0 | WRITE_OWNER.0)
1769}
1770
1771#[cfg(target_os = "windows")]
1772fn windows_open_directory_for_durability(path: &Path) -> io::Result<File> {
1773 use windows::Win32::Storage::FileSystem::{
1774 FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_READ, FILE_SHARE_WRITE, WRITE_DAC,
1775 };
1776
1777 windows_open_directory_with_access_and_share(
1778 path,
1779 FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0 | WRITE_DAC.0,
1780 FILE_SHARE_READ | FILE_SHARE_WRITE,
1781 )
1782}
1783
1784#[cfg(target_os = "windows")]
1785fn windows_open_directory_with_access(path: &Path, desired_access: u32) -> io::Result<File> {
1786 use windows::Win32::Storage::FileSystem::{
1787 FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
1788 };
1789
1790 windows_open_directory_with_access_and_share(
1791 path,
1792 desired_access,
1793 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
1794 )
1795}
1796
1797#[cfg(target_os = "windows")]
1798fn windows_open_directory_with_access_and_share(
1799 path: &Path,
1800 desired_access: u32,
1801 share: windows::Win32::Storage::FileSystem::FILE_SHARE_MODE,
1802) -> io::Result<File> {
1803 use std::os::windows::io::FromRawHandle;
1804 use windows::core::PCWSTR;
1805 use windows::Win32::Foundation::HANDLE;
1806 use windows::Win32::Storage::FileSystem::{
1807 CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, OPEN_EXISTING,
1808 };
1809
1810 let wide = windows_path_wide(path)?;
1811 let handle = unsafe {
1812 CreateFileW(
1813 PCWSTR(wide.as_ptr()),
1814 desired_access,
1815 share,
1816 None,
1817 OPEN_EXISTING,
1818 FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
1819 HANDLE::default(),
1820 )
1821 }
1822 .map_err(windows_io_error)?;
1823 Ok(unsafe { File::from_raw_handle(handle.0 as *mut std::ffi::c_void) })
1825}
1826
1827#[cfg(target_os = "windows")]
1828fn windows_open_existing_file_for_hardening(path: &Path) -> io::Result<File> {
1829 use windows::Win32::Storage::FileSystem::{FILE_GENERIC_READ, WRITE_DAC, WRITE_OWNER};
1830
1831 windows_open_existing_file_with_access(path, FILE_GENERIC_READ.0 | WRITE_DAC.0 | WRITE_OWNER.0)
1832}
1833
1834#[cfg(target_os = "windows")]
1835fn windows_open_existing_file_with_access(path: &Path, desired_access: u32) -> io::Result<File> {
1836 use std::os::windows::io::FromRawHandle;
1837 use windows::core::PCWSTR;
1838 use windows::Win32::Foundation::HANDLE;
1839 use windows::Win32::Storage::FileSystem::{
1840 CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE,
1841 FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
1842 };
1843
1844 let wide = windows_path_wide(path)?;
1845 let handle = unsafe {
1846 CreateFileW(
1847 PCWSTR(wide.as_ptr()),
1848 desired_access,
1849 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
1850 None,
1851 OPEN_EXISTING,
1852 FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
1853 HANDLE::default(),
1854 )
1855 }
1856 .map_err(windows_io_error)?;
1857 Ok(unsafe { File::from_raw_handle(handle.0 as *mut std::ffi::c_void) })
1859}
1860
1861#[cfg(target_os = "windows")]
1862fn windows_validate_directory(directory: &File) -> io::Result<()> {
1863 let metadata = directory.metadata()?;
1864 reject_windows_reparse_metadata(&metadata)?;
1865 if metadata.is_dir() {
1866 Ok(())
1867 } else {
1868 Err(permission_denied("private path is not a directory"))
1869 }
1870}
1871
1872#[cfg(target_os = "windows")]
1873fn windows_validate_file_owner(file: &File) -> io::Result<()> {
1874 let owner = windows_file_owner_sid_string(file)?;
1875 let process_owner = current_process_default_owner_sid_string()?;
1876 if owner == process_owner {
1877 Ok(())
1878 } else {
1879 Err(io::Error::new(
1880 io::ErrorKind::PermissionDenied,
1881 format!("private path owner {owner} does not match token owner {process_owner}"),
1882 ))
1883 }
1884}
1885
1886#[cfg(target_os = "windows")]
1887fn windows_validate_hardenable_file_owner(file: &File) -> io::Result<()> {
1888 let owner = windows_file_owner_sid_string(file)?;
1894 let process_user = current_process_sid_string()?;
1895 let default_owner = current_process_default_owner_sid_string()?;
1896 if owner == process_user || owner == default_owner {
1897 Ok(())
1898 } else {
1899 Err(io::Error::new(
1900 io::ErrorKind::PermissionDenied,
1901 format!(
1902 "private path owner {owner} is neither process user {process_user} nor token default owner {default_owner}"
1903 ),
1904 ))
1905 }
1906}
1907
1908#[cfg(target_os = "windows")]
1909fn windows_file_owner_sid_string(file: &File) -> io::Result<String> {
1910 use std::os::windows::io::AsRawHandle;
1911 use windows::Win32::Foundation::{LocalFree, HANDLE, HLOCAL, PSID};
1912 use windows::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
1913 use windows::Win32::Security::{OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR};
1914
1915 let mut owner = PSID::default();
1916 let mut descriptor = PSECURITY_DESCRIPTOR::default();
1917 let status = unsafe {
1918 GetSecurityInfo(
1919 HANDLE(file.as_raw_handle() as isize),
1920 SE_FILE_OBJECT,
1921 OWNER_SECURITY_INFORMATION,
1922 Some(&mut owner),
1923 None,
1924 None,
1925 None,
1926 Some(&mut descriptor),
1927 )
1928 };
1929 if status.is_err() {
1930 return Err(io::Error::other(status.to_hresult().message()));
1931 }
1932 let result = windows_sid_string(owner);
1933 unsafe {
1934 let _ = LocalFree(HLOCAL(descriptor.0));
1935 }
1936 result
1937}
1938
1939#[cfg(target_os = "windows")]
1940fn windows_sid_string(sid: windows::Win32::Foundation::PSID) -> io::Result<String> {
1941 use windows::core::PWSTR;
1942 use windows::Win32::Foundation::{LocalFree, HLOCAL};
1943 use windows::Win32::Security::Authorization::ConvertSidToStringSidW;
1944
1945 let mut sid_text = PWSTR::null();
1946 unsafe { ConvertSidToStringSidW(sid, &mut sid_text) }
1947 .map_err(|error| io::Error::other(error.to_string()))?;
1948 let result = unsafe { sid_text.to_string() }
1949 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error));
1950 unsafe {
1951 let _ = LocalFree(HLOCAL(sid_text.0.cast()));
1952 }
1953 result
1954}
1955
1956#[cfg(target_os = "windows")]
1957fn windows_revalidate_directory_path(path: &Path, directory: &File) -> io::Result<()> {
1958 windows_validate_directory(directory)?;
1959 windows_validate_file_owner(directory)?;
1960 let by_path = windows_open_directory(path)?;
1961 windows_validate_directory(&by_path)?;
1962 windows_validate_file_owner(&by_path)?;
1963 if windows_file_identity(directory)? != windows_file_identity(&by_path)? {
1964 return Err(permission_denied(
1965 "private directory path changed during ACL hardening",
1966 ));
1967 }
1968 Ok(())
1969}
1970
1971#[cfg(target_os = "windows")]
1972fn windows_revalidate_directory_path_identity(path: &Path, directory: &File) -> io::Result<()> {
1973 windows_validate_directory(directory)?;
1974 let by_path = windows_open_directory(path)?;
1975 windows_validate_directory(&by_path)?;
1976 if windows_file_identity(directory)? != windows_file_identity(&by_path)? {
1977 return Err(permission_denied(
1978 "directory path changed before its retained handle was validated",
1979 ));
1980 }
1981 Ok(())
1982}
1983
1984#[cfg(target_os = "windows")]
1985fn windows_open_private(
1986 path: &Path,
1987 mode: PrivateOpenMode,
1988 failures: Option<&PrivatePathDurabilityFailureInjector>,
1989) -> io::Result<File> {
1990 use std::os::windows::io::FromRawHandle;
1991 use windows::core::PCWSTR;
1992 use windows::Win32::Foundation::HANDLE;
1993 use windows::Win32::Storage::FileSystem::{
1994 CreateFileW, CREATE_NEW, FILE_APPEND_DATA, FILE_ATTRIBUTE_NORMAL,
1995 FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_DELETE,
1996 FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, WRITE_DAC, WRITE_OWNER,
1997 };
1998
1999 let parent = normalized_parent(path);
2000 if matches!(mode, PrivateOpenMode::Append | PrivateOpenMode::CreateNew) {
2001 if parent != Path::new(".") {
2002 windows_ensure_private_dir_with_failure_injector(parent, failures)?;
2003 }
2004 } else if parent != Path::new(".") {
2005 windows_validate_existing_directory_chain(parent)?;
2006 }
2007 let durability_parent = if matches!(mode, PrivateOpenMode::Append | PrivateOpenMode::CreateNew)
2008 {
2009 let descriptor = windows_open_directory_for_durability(parent)?;
2010 windows_validate_directory(&descriptor)?;
2011 Some(descriptor)
2012 } else {
2013 None
2014 };
2015 let (desired_access, disposition) = match mode {
2016 PrivateOpenMode::Read => (
2017 FILE_GENERIC_READ.0 | WRITE_DAC.0 | WRITE_OWNER.0,
2018 OPEN_EXISTING,
2019 ),
2020 PrivateOpenMode::Append => (
2021 FILE_GENERIC_READ.0 | FILE_APPEND_DATA.0 | WRITE_DAC.0 | WRITE_OWNER.0,
2022 OPEN_EXISTING,
2023 ),
2024 PrivateOpenMode::Truncate => (
2025 FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0 | WRITE_DAC.0 | WRITE_OWNER.0,
2026 OPEN_EXISTING,
2027 ),
2028 PrivateOpenMode::CreateNew => (
2029 FILE_GENERIC_WRITE.0 | WRITE_DAC.0 | WRITE_OWNER.0,
2030 CREATE_NEW,
2031 ),
2032 };
2033 let descriptor = WindowsPrivateSecurityDescriptor::new()?;
2034 let attributes = descriptor.security_attributes();
2035 let wide = windows_path_wide(path)?;
2036 let open = |disposition| {
2037 unsafe {
2038 CreateFileW(
2039 PCWSTR(wide.as_ptr()),
2040 desired_access,
2041 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
2042 Some(&attributes),
2043 disposition,
2044 FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
2045 HANDLE::default(),
2046 )
2047 }
2048 .map_err(windows_io_error)
2049 };
2050 let mut created = mode == PrivateOpenMode::CreateNew;
2051 let handle = match open(disposition) {
2052 Ok(handle) => handle,
2053 Err(error)
2054 if mode == PrivateOpenMode::Append && error.kind() == io::ErrorKind::NotFound =>
2055 {
2056 match open(CREATE_NEW) {
2057 Ok(handle) => {
2058 created = true;
2059 handle
2060 }
2061 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => open(OPEN_EXISTING)?,
2062 Err(error) => return Err(error),
2063 }
2064 }
2065 Err(error) => return Err(error),
2066 };
2067 let file = unsafe { File::from_raw_handle(handle.0 as *mut std::ffi::c_void) };
2069 windows_validate_file(&file)?;
2070 windows_validate_hardenable_file_owner(&file)?;
2071 windows_harden_file_acl(&file)?;
2072 windows_revalidate_private_path(path, &file)?;
2073 if mode == PrivateOpenMode::Truncate {
2074 if parent != Path::new(".") {
2075 windows_validate_existing_directory_chain(parent)?;
2076 }
2077 file.set_len(0)?;
2078 windows_validate_file(&file)?;
2079 windows_validate_file_owner(&file)?;
2080 windows_revalidate_private_path(path, &file)?;
2081 }
2082 if let Some(parent_descriptor) = durability_parent.as_ref() {
2083 if created {
2084 if let Some(failures) = failures {
2085 failures.check(PrivatePathDurabilityFailurePoint::ParentDirectorySync)?;
2086 }
2087 }
2088 windows_flush_directory_metadata(parent_descriptor)?;
2089 windows_revalidate_directory_path(parent, parent_descriptor)?;
2090 windows_revalidate_private_path(path, &file)?;
2091 }
2092 Ok(file)
2093}
2094
2095#[cfg(target_os = "windows")]
2096fn windows_validate_file(file: &File) -> io::Result<()> {
2097 let metadata = file.metadata()?;
2098 reject_windows_reparse_metadata(&metadata)?;
2099 if !metadata.is_file() {
2100 return Err(permission_denied("private file is not a regular file"));
2101 }
2102 if windows_file_link_count(file)? != 1 {
2103 return Err(permission_denied(
2104 "private file must have exactly one hard link",
2105 ));
2106 }
2107 Ok(())
2108}
2109
2110#[cfg(target_os = "windows")]
2111fn windows_revalidate_private_path(path: &Path, file: &File) -> io::Result<()> {
2112 use std::os::windows::fs::OpenOptionsExt;
2113
2114 let by_path = std::fs::OpenOptions::new()
2115 .read(true)
2116 .custom_flags(0x0020_0000) .open(path)?;
2118 windows_validate_file(&by_path)?;
2119 windows_validate_file_owner(&by_path)?;
2120 if windows_file_identity(file)? != windows_file_identity(&by_path)? {
2121 return Err(permission_denied(
2122 "private file path no longer names the validated descriptor",
2123 ));
2124 }
2125 Ok(())
2126}
2127
2128#[cfg(target_os = "windows")]
2129fn windows_harden_private_tree(
2130 tree: &PrivateTree,
2131 selected: &[PathBuf],
2132) -> io::Result<PrivateTreeReport> {
2133 use std::collections::BTreeSet;
2134
2135 let mut report = PrivateTreeReport::default();
2136 if selected.is_empty() {
2137 windows_harden_file_acl(&tree.root_descriptor)?;
2138 tree.revalidate_root()?;
2139 report.directories_hardened = 1;
2140 return Ok(report);
2141 }
2142
2143 let mut visited = BTreeSet::new();
2144 visited.insert(windows_file_identity(&tree.root_descriptor)?);
2145 for relative in selected {
2146 tree.revalidate_root()?;
2147 windows_harden_selected_path(tree, relative, &mut visited, &mut report)?;
2148 tree.revalidate_root()?;
2149 }
2150 Ok(report)
2151}
2152
2153#[cfg(target_os = "windows")]
2154fn windows_harden_selected_path(
2155 tree: &PrivateTree,
2156 relative: &Path,
2157 visited: &mut std::collections::BTreeSet<(u32, u64)>,
2158 report: &mut PrivateTreeReport,
2159) -> io::Result<()> {
2160 let components = relative
2161 .components()
2162 .map(|component| component.as_os_str().to_os_string())
2163 .collect::<Vec<_>>();
2164 windows_harden_selected_components(
2165 tree,
2166 &tree.root_descriptor,
2167 &tree.root,
2168 &components,
2169 visited,
2170 report,
2171 )
2172}
2173
2174#[cfg(target_os = "windows")]
2175fn windows_harden_selected_components(
2176 tree: &PrivateTree,
2177 parent: &File,
2178 parent_path: &Path,
2179 components: &[std::ffi::OsString],
2180 visited: &mut std::collections::BTreeSet<(u32, u64)>,
2181 report: &mut PrivateTreeReport,
2182) -> io::Result<()> {
2183 let (component, remaining) = components.split_first().ok_or_else(|| {
2184 io::Error::new(
2185 io::ErrorKind::InvalidInput,
2186 "private-tree selection has no components",
2187 )
2188 })?;
2189 let path = parent_path.join(component);
2190 let entry = windows_open_entry_at_for_hardening(parent, component)?;
2191 if remaining.is_empty() {
2192 return windows_harden_tree_entry(tree, parent, component, &path, entry, visited, report);
2193 }
2194 windows_validate_directory(&entry)?;
2195 windows_validate_hardenable_file_owner(&entry)?;
2196 windows_harden_file_acl(&entry)?;
2197 windows_revalidate_directory_path(&path, &entry)?;
2198 windows_revalidate_entry_at(parent, component, &entry)?;
2199 if visited.insert(windows_file_identity(&entry)?) {
2200 report.directories_hardened += 1;
2201 }
2202 windows_harden_selected_components(tree, &entry, &path, remaining, visited, report)?;
2203 windows_revalidate_entry_at(parent, component, &entry)
2204}
2205
2206#[cfg(target_os = "windows")]
2207fn windows_harden_tree_entry(
2208 tree: &PrivateTree,
2209 parent: &File,
2210 name: &std::ffi::OsStr,
2211 path: &Path,
2212 entry: File,
2213 visited: &mut std::collections::BTreeSet<(u32, u64)>,
2214 report: &mut PrivateTreeReport,
2215) -> io::Result<()> {
2216 let metadata = entry.metadata()?;
2217 reject_windows_reparse_metadata(&metadata)?;
2218 windows_validate_hardenable_file_owner(&entry)?;
2219 if metadata.is_dir() {
2220 windows_validate_directory(&entry)?;
2221 windows_harden_file_acl(&entry)?;
2222 windows_revalidate_directory_path(path, &entry)?;
2223 windows_revalidate_entry_at(parent, name, &entry)?;
2224 if visited.insert(windows_file_identity(&entry)?) {
2225 report.directories_hardened += 1;
2226 for child in windows_directory_names(&entry)? {
2227 tree.revalidate_root()?;
2228 let child_path = path.join(&child);
2229 let child_entry = windows_open_entry_at_for_hardening(&entry, &child)?;
2230 windows_harden_tree_entry(
2231 tree,
2232 &entry,
2233 &child,
2234 &child_path,
2235 child_entry,
2236 visited,
2237 report,
2238 )?;
2239 }
2240 }
2241 windows_revalidate_entry_at(parent, name, &entry)?;
2242 } else if metadata.is_file() {
2243 windows_validate_file(&entry)?;
2244 windows_harden_file_acl(&entry)?;
2245 windows_revalidate_private_path(path, &entry)?;
2246 windows_revalidate_entry_at(parent, name, &entry)?;
2247 if visited.insert(windows_file_identity(&entry)?) {
2248 report.files_hardened += 1;
2249 }
2250 windows_revalidate_entry_at(parent, name, &entry)?;
2251 } else {
2252 return Err(permission_denied(
2253 "private tree contains a reparse point or special file",
2254 ));
2255 }
2256 Ok(())
2257}
2258
2259#[cfg(target_os = "windows")]
2260fn windows_revalidate_entry_at(
2261 parent: &File,
2262 name: &std::ffi::OsStr,
2263 entry: &File,
2264) -> io::Result<()> {
2265 let by_name = windows_open_entry_at(parent, name)?;
2266 let metadata = by_name.metadata()?;
2267 reject_windows_reparse_metadata(&metadata)?;
2268 windows_validate_file_owner(&by_name)?;
2269 if metadata.is_file() {
2270 windows_validate_file(&by_name)?;
2271 } else if metadata.is_dir() {
2272 windows_validate_directory(&by_name)?;
2273 } else {
2274 return Err(permission_denied(
2275 "private tree contains a reparse point or special file",
2276 ));
2277 }
2278 if windows_file_identity(entry)? != windows_file_identity(&by_name)? {
2279 return Err(permission_denied(
2280 "private tree entry changed during descriptor validation",
2281 ));
2282 }
2283 Ok(())
2284}
2285
2286#[cfg(target_os = "windows")]
2287fn windows_open_entry_at(parent: &File, name: &std::ffi::OsStr) -> io::Result<File> {
2288 use windows::Win32::Storage::FileSystem::FILE_GENERIC_READ;
2289
2290 windows_open_entry_at_with_access(parent, name, FILE_GENERIC_READ)
2291}
2292
2293#[cfg(target_os = "windows")]
2294fn windows_open_entry_at_for_hardening(parent: &File, name: &std::ffi::OsStr) -> io::Result<File> {
2295 use windows::Win32::Storage::FileSystem::{FILE_GENERIC_READ, WRITE_DAC, WRITE_OWNER};
2296
2297 windows_open_entry_at_with_access(parent, name, FILE_GENERIC_READ | WRITE_DAC | WRITE_OWNER)
2298}
2299
2300#[cfg(target_os = "windows")]
2301fn windows_open_entry_at_with_access(
2302 parent: &File,
2303 name: &std::ffi::OsStr,
2304 desired_access: windows::Win32::Storage::FileSystem::FILE_ACCESS_RIGHTS,
2305) -> io::Result<File> {
2306 use windows::Win32::Storage::FileSystem::{
2307 FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
2308 };
2309
2310 windows_open_entry_at_with_access_and_share(
2311 parent,
2312 name,
2313 desired_access,
2314 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
2315 )
2316}
2317
2318#[cfg(target_os = "windows")]
2319fn windows_open_entry_at_with_access_and_share(
2320 parent: &File,
2321 name: &std::ffi::OsStr,
2322 desired_access: windows::Win32::Storage::FileSystem::FILE_ACCESS_RIGHTS,
2323 share: windows::Win32::Storage::FileSystem::FILE_SHARE_MODE,
2324) -> io::Result<File> {
2325 use std::os::windows::ffi::OsStrExt;
2326 use std::os::windows::io::{AsRawHandle, FromRawHandle};
2327 use windows::core::PWSTR;
2328 use windows::Wdk::Foundation::OBJECT_ATTRIBUTES;
2329 use windows::Wdk::Storage::FileSystem::{
2330 NtCreateFile, FILE_OPEN, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT,
2331 };
2332 use windows::Win32::Foundation::{RtlNtStatusToDosError, HANDLE, UNICODE_STRING};
2333 use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL;
2334 use windows::Win32::System::IO::IO_STATUS_BLOCK;
2335
2336 let mut wide = name.encode_wide().collect::<Vec<_>>();
2337 let byte_length = wide
2338 .len()
2339 .checked_mul(std::mem::size_of::<u16>())
2340 .and_then(|length| u16::try_from(length).ok())
2341 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "tree name is too long"))?;
2342 let unicode = UNICODE_STRING {
2343 Length: byte_length,
2344 MaximumLength: byte_length,
2345 Buffer: PWSTR(wide.as_mut_ptr()),
2346 };
2347 let attributes = OBJECT_ATTRIBUTES {
2348 Length: std::mem::size_of::<OBJECT_ATTRIBUTES>() as u32,
2349 RootDirectory: HANDLE(parent.as_raw_handle() as isize),
2350 ObjectName: &unicode,
2351 Attributes: 0x40, SecurityDescriptor: std::ptr::null(),
2353 SecurityQualityOfService: std::ptr::null(),
2354 };
2355 let mut handle = HANDLE::default();
2356 let mut status_block = IO_STATUS_BLOCK::default();
2357 let status = unsafe {
2358 NtCreateFile(
2359 &mut handle,
2360 desired_access,
2361 &attributes,
2362 &mut status_block,
2363 None,
2364 FILE_ATTRIBUTE_NORMAL,
2365 share,
2366 FILE_OPEN,
2367 FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT,
2368 None,
2369 0,
2370 )
2371 };
2372 if status.is_err() {
2373 let win32_error = unsafe { RtlNtStatusToDosError(status) };
2374 return Err(io::Error::from_raw_os_error(win32_error as i32));
2375 }
2376 Ok(unsafe { File::from_raw_handle(handle.0 as *mut std::ffi::c_void) })
2378}
2379
2380#[cfg(target_os = "windows")]
2381fn windows_directory_names(directory: &File) -> io::Result<Vec<std::ffi::OsString>> {
2382 use std::os::windows::ffi::OsStringExt;
2383 use std::os::windows::io::AsRawHandle;
2384 use windows::Win32::Foundation::{ERROR_NO_MORE_FILES, HANDLE, WIN32_ERROR};
2385 use windows::Win32::Storage::FileSystem::{
2386 FileIdBothDirectoryInfo, FileIdBothDirectoryRestartInfo, GetFileInformationByHandleEx,
2387 FILE_ID_BOTH_DIR_INFO,
2388 };
2389
2390 let scan = directory.try_clone()?;
2391 windows_validate_directory(&scan)?;
2392 let mut buffer = vec![0u64; 8192];
2393 let mut restart = true;
2394 let mut names = Vec::new();
2395 loop {
2396 let class = if restart {
2397 FileIdBothDirectoryRestartInfo
2398 } else {
2399 FileIdBothDirectoryInfo
2400 };
2401 restart = false;
2402 let result = unsafe {
2403 GetFileInformationByHandleEx(
2404 HANDLE(scan.as_raw_handle() as isize),
2405 class,
2406 buffer.as_mut_ptr().cast(),
2407 (buffer.len() * std::mem::size_of::<u64>()) as u32,
2408 )
2409 };
2410 if let Err(error) = result {
2411 if WIN32_ERROR::from_error(&error) == Some(ERROR_NO_MORE_FILES) {
2412 break;
2413 }
2414 return Err(io::Error::other(error.to_string()));
2415 }
2416
2417 let buffer_bytes = buffer.len() * std::mem::size_of::<u64>();
2418 let mut offset = 0usize;
2419 loop {
2420 if offset + std::mem::size_of::<FILE_ID_BOTH_DIR_INFO>() > buffer_bytes {
2421 return Err(io::Error::new(
2422 io::ErrorKind::InvalidData,
2423 "directory enumeration returned a truncated entry",
2424 ));
2425 }
2426 let entry = unsafe {
2427 &*buffer
2428 .as_ptr()
2429 .cast::<u8>()
2430 .add(offset)
2431 .cast::<FILE_ID_BOTH_DIR_INFO>()
2432 };
2433 let name_units = usize::try_from(entry.FileNameLength / 2).map_err(|_| {
2434 io::Error::new(io::ErrorKind::InvalidData, "directory name is too long")
2435 })?;
2436 let name_offset = offset + std::mem::offset_of!(FILE_ID_BOTH_DIR_INFO, FileName);
2437 let name_bytes = name_units.checked_mul(2).ok_or_else(|| {
2438 io::Error::new(io::ErrorKind::InvalidData, "directory name is too long")
2439 })?;
2440 if name_offset + name_bytes > buffer_bytes {
2441 return Err(io::Error::new(
2442 io::ErrorKind::InvalidData,
2443 "directory enumeration returned a truncated name",
2444 ));
2445 }
2446 let name_slice = unsafe {
2447 std::slice::from_raw_parts(
2448 buffer.as_ptr().cast::<u8>().add(name_offset).cast::<u16>(),
2449 name_units,
2450 )
2451 };
2452 if name_slice != [b'.' as u16] && name_slice != [b'.' as u16, b'.' as u16] {
2453 names.push(std::ffi::OsString::from_wide(name_slice));
2454 }
2455 if entry.NextEntryOffset == 0 {
2456 break;
2457 }
2458 let next = usize::try_from(entry.NextEntryOffset).map_err(|_| {
2459 io::Error::new(io::ErrorKind::InvalidData, "invalid directory entry offset")
2460 })?;
2461 if next == 0 || offset + next >= buffer_bytes {
2462 return Err(io::Error::new(
2463 io::ErrorKind::InvalidData,
2464 "invalid directory entry offset",
2465 ));
2466 }
2467 offset += next;
2468 }
2469 }
2470 names.sort();
2471 Ok(names)
2472}
2473
2474#[cfg(target_os = "windows")]
2475fn windows_file_identity(file: &File) -> io::Result<(u32, u64)> {
2476 let information = windows_file_information(file)?;
2477 Ok((
2478 information.dwVolumeSerialNumber,
2479 (u64::from(information.nFileIndexHigh) << 32) | u64::from(information.nFileIndexLow),
2480 ))
2481}
2482
2483#[cfg(target_os = "windows")]
2484fn windows_file_link_count(file: &File) -> io::Result<u32> {
2485 Ok(windows_file_information(file)?.nNumberOfLinks)
2486}
2487
2488#[cfg(target_os = "windows")]
2489fn windows_file_information(
2490 file: &File,
2491) -> io::Result<windows::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION> {
2492 use std::os::windows::io::AsRawHandle;
2493 use windows::Win32::Foundation::HANDLE;
2494 use windows::Win32::Storage::FileSystem::{
2495 GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
2496 };
2497
2498 let mut information = BY_HANDLE_FILE_INFORMATION::default();
2499 unsafe { GetFileInformationByHandle(HANDLE(file.as_raw_handle() as isize), &mut information) }
2502 .map_err(|error| io::Error::other(error.to_string()))?;
2503 Ok(information)
2504}
2505
2506#[cfg(target_os = "windows")]
2507fn windows_atomic_replace_private_file(temp: &Path, destination: &Path) -> io::Result<()> {
2508 windows_atomic_replace_private_file_with_hook(temp, destination, || Ok(()))
2509}
2510
2511#[cfg(target_os = "windows")]
2512fn windows_atomic_replace_private_file_with_hook<F>(
2513 temp: &Path,
2514 destination: &Path,
2515 before_rename: F,
2516) -> io::Result<()>
2517where
2518 F: FnOnce() -> io::Result<()>,
2519{
2520 use std::os::windows::ffi::OsStrExt;
2521 use std::os::windows::io::AsRawHandle;
2522 use windows::Win32::Foundation::HANDLE;
2523 use windows::Win32::Storage::FileSystem::{
2524 FileRenameInfoEx, SetFileInformationByHandle, DELETE, FILE_GENERIC_READ,
2525 FILE_GENERIC_WRITE, FILE_RENAME_INFO, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
2526 WRITE_DAC, WRITE_OWNER,
2527 };
2528
2529 const FILE_RENAME_FLAG_REPLACE_IF_EXISTS: u32 = 0x1;
2530 const FILE_RENAME_FLAG_POSIX_SEMANTICS: u32 = 0x2;
2531
2532 let parent_path = normalized_parent(destination);
2533 windows_validate_existing_directory_chain(parent_path)?;
2534 let parent = windows_error_stage(
2535 "open retained replacement parent",
2536 windows_open_directory_with_access_and_share(
2537 parent_path,
2538 FILE_GENERIC_READ.0 | WRITE_DAC.0 | WRITE_OWNER.0,
2539 FILE_SHARE_READ | FILE_SHARE_WRITE,
2540 ),
2541 )?;
2542 windows_error_stage(
2543 "validate retained replacement parent",
2544 windows_validate_directory(&parent),
2545 )?;
2546 windows_error_stage(
2547 "validate retained replacement parent owner",
2548 windows_validate_hardenable_file_owner(&parent),
2549 )?;
2550 windows_error_stage(
2551 "revalidate retained replacement parent before hardening",
2552 windows_revalidate_directory_path(parent_path, &parent),
2553 )?;
2554 windows_error_stage(
2555 "harden retained replacement parent",
2556 windows_harden_file_acl(&parent),
2557 )?;
2558 windows_error_stage(
2559 "revalidate retained replacement parent after hardening",
2560 windows_revalidate_directory_path(parent_path, &parent),
2561 )?;
2562
2563 let temp_name = temp.file_name().ok_or_else(|| {
2564 io::Error::new(io::ErrorKind::InvalidInput, "private temp has no file name")
2565 })?;
2566 let destination_name = destination.file_name().ok_or_else(|| {
2567 io::Error::new(
2568 io::ErrorKind::InvalidInput,
2569 "private destination has no file name",
2570 )
2571 })?;
2572 let temp_file = windows_error_stage(
2573 "open retained replacement source",
2574 windows_open_entry_at_with_access_and_share(
2575 &parent,
2576 temp_name,
2577 FILE_GENERIC_READ | FILE_GENERIC_WRITE | WRITE_DAC | WRITE_OWNER | DELETE,
2582 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
2583 ),
2584 )?;
2585 windows_error_stage(
2586 "validate retained replacement source",
2587 windows_validate_file(&temp_file),
2588 )?;
2589 windows_error_stage(
2590 "validate retained replacement source owner",
2591 windows_validate_hardenable_file_owner(&temp_file),
2592 )?;
2593 windows_error_stage(
2594 "revalidate retained replacement source before hardening",
2595 windows_revalidate_entry_at(&parent, temp_name, &temp_file),
2596 )?;
2597 let temp_identity = windows_error_stage(
2598 "read retained replacement source identity",
2599 windows_file_identity(&temp_file),
2600 )?;
2601 windows_error_stage(
2602 "harden retained replacement source",
2603 windows_harden_file_acl(&temp_file),
2604 )?;
2605 windows_error_stage(
2606 "revalidate retained replacement source after hardening",
2607 windows_revalidate_entry_at(&parent, temp_name, &temp_file),
2608 )?;
2609 windows_error_stage("sync retained replacement source", temp_file.sync_all())?;
2610
2611 let destination_identity = match windows_open_entry_at(&parent, destination_name) {
2612 Ok(file) => {
2613 windows_validate_file(&file)?;
2614 windows_validate_file_owner(&file)?;
2615 Some(windows_file_identity(&file)?)
2616 }
2617 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
2618 Err(error) => return Err(error),
2619 };
2620
2621 windows_error_stage("run replacement race hook", before_rename())?;
2622 windows_error_stage(
2623 "revalidate retained replacement parent before publication",
2624 windows_revalidate_directory_path(parent_path, &parent),
2625 )?;
2626 windows_error_stage(
2627 "revalidate retained replacement source before publication",
2628 windows_revalidate_entry_at(&parent, temp_name, &temp_file),
2629 )?;
2630 let current_destination_identity = match windows_open_entry_at(&parent, destination_name) {
2631 Ok(file) => {
2632 windows_validate_file(&file)?;
2633 windows_validate_file_owner(&file)?;
2634 Some(windows_file_identity(&file)?)
2635 }
2636 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
2637 Err(error) => return Err(error),
2638 };
2639 if current_destination_identity != destination_identity {
2640 return Err(permission_denied(
2641 "private destination changed before handle-relative replacement",
2642 ));
2643 }
2644
2645 let destination_absolute = std::path::absolute(destination)?;
2646 let destination_wide = destination_absolute
2647 .as_os_str()
2648 .encode_wide()
2649 .collect::<Vec<_>>();
2650 if destination_wide.is_empty() || destination_wide.contains(&0) {
2651 return Err(io::Error::new(
2652 io::ErrorKind::InvalidInput,
2653 "private destination name is empty or contains NUL",
2654 ));
2655 }
2656 let name_bytes = destination_wide
2657 .len()
2658 .checked_mul(std::mem::size_of::<u16>())
2659 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "file name is too long"))?;
2660 let buffer_bytes = std::mem::offset_of!(FILE_RENAME_INFO, FileName)
2663 .checked_add(name_bytes)
2664 .and_then(|size| size.checked_add(std::mem::size_of::<u16>()))
2665 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "file name is too long"))?;
2666 let mut storage = vec![0u64; buffer_bytes.div_ceil(std::mem::size_of::<u64>())];
2667 let rename = storage.as_mut_ptr().cast::<FILE_RENAME_INFO>();
2668 let rename_result = unsafe {
2669 (*rename).Anonymous.Flags =
2670 FILE_RENAME_FLAG_REPLACE_IF_EXISTS | FILE_RENAME_FLAG_POSIX_SEMANTICS;
2671 (*rename).RootDirectory = HANDLE::default();
2675 (*rename).FileNameLength = u32::try_from(name_bytes)
2676 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "file name is too long"))?;
2677 std::ptr::copy_nonoverlapping(
2678 destination_wide.as_ptr(),
2679 std::ptr::addr_of_mut!((*rename).FileName).cast::<u16>(),
2680 destination_wide.len(),
2681 );
2682 SetFileInformationByHandle(
2683 HANDLE(temp_file.as_raw_handle() as isize),
2684 FileRenameInfoEx,
2685 rename.cast(),
2686 u32::try_from(buffer_bytes).map_err(|_| {
2687 io::Error::new(io::ErrorKind::InvalidInput, "rename buffer is too large")
2688 })?,
2689 )
2690 };
2691 windows_error_stage(
2692 "publish retained replacement source",
2693 rename_result.map_err(windows_io_error),
2694 )?;
2695
2696 let published = windows_error_stage(
2697 "open published replacement",
2698 windows_open_entry_at_for_hardening(&parent, destination_name),
2699 )?;
2700 windows_validate_file(&published)?;
2701 windows_validate_file_owner(&published)?;
2702 if windows_file_identity(&published)? != temp_identity {
2703 return Err(permission_denied(
2704 "published private file does not match the validated temp descriptor",
2705 ));
2706 }
2707 windows_harden_file_acl(&published)?;
2708 windows_revalidate_entry_at(&parent, destination_name, &published)?;
2709 windows_revalidate_directory_path(parent_path, &parent)
2710}
2711
2712#[cfg(not(any(unix, target_os = "windows")))]
2713fn generic_open_private(path: &Path, mode: PrivateOpenMode) -> io::Result<File> {
2714 if mode != PrivateOpenMode::Read {
2715 if let Some(parent) = path.parent().filter(|path| !path.as_os_str().is_empty()) {
2716 std::fs::create_dir_all(parent)?;
2717 }
2718 }
2719 let mut options = std::fs::OpenOptions::new();
2720 match mode {
2721 PrivateOpenMode::Read => {
2722 options.read(true);
2723 }
2724 PrivateOpenMode::Append => {
2725 options.read(true).append(true).create(true);
2726 }
2727 PrivateOpenMode::Truncate => {
2728 options.read(true).write(true);
2729 }
2730 PrivateOpenMode::CreateNew => {
2731 options.write(true).create_new(true);
2732 }
2733 }
2734 let file = options.open(path)?;
2735 revalidate_private_file(&file)?;
2736 if mode == PrivateOpenMode::Truncate {
2737 file.set_len(0)?;
2738 revalidate_private_file(&file)?;
2739 }
2740 Ok(file)
2741}
2742
2743#[cfg(not(any(unix, target_os = "windows")))]
2744fn generic_harden_private_tree(
2745 _tree: &PrivateTree,
2746 _selected: &[PathBuf],
2747) -> io::Result<PrivateTreeReport> {
2748 Err(io::Error::new(
2749 io::ErrorKind::Unsupported,
2750 "descriptor-root private-tree hardening is unsupported on this platform",
2751 ))
2752}
2753
2754#[cfg(target_os = "windows")]
2755fn harden_windows_acl(path: &Path) -> io::Result<()> {
2756 let metadata = std::fs::symlink_metadata(path)?;
2757 reject_windows_reparse_metadata(&metadata)?;
2758 let file = if metadata.is_dir() {
2759 windows_open_directory_for_hardening(path)?
2760 } else if metadata.is_file() {
2761 windows_open_existing_file_for_hardening(path)?
2762 } else {
2763 return Err(permission_denied(
2764 "owner-private path is not a regular file or directory",
2765 ));
2766 };
2767 windows_validate_hardenable_file_owner(&file)?;
2768 windows_harden_file_acl(&file)?;
2769 if metadata.is_dir() {
2770 windows_revalidate_directory_path(path, &file)
2771 } else {
2772 windows_validate_file(&file)?;
2773 windows_revalidate_private_path(path, &file)
2774 }
2775}
2776
2777#[cfg(target_os = "windows")]
2778fn current_process_default_owner_sid_string() -> std::io::Result<String> {
2779 use windows::Win32::Foundation::{CloseHandle, HANDLE};
2780 use windows::Win32::Security::{GetTokenInformation, TokenOwner, TOKEN_OWNER, TOKEN_QUERY};
2781 use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
2782
2783 let windows_error = |error: windows::core::Error| std::io::Error::other(error.to_string());
2784 let mut token = HANDLE::default();
2785 unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }
2786 .map_err(windows_error)?;
2787
2788 let result = (|| {
2789 let mut needed = 0u32;
2790 let _ = unsafe { GetTokenInformation(token, TokenOwner, None, 0, &mut needed) };
2791 if needed == 0 {
2792 return Err(std::io::Error::last_os_error());
2793 }
2794 let words = (needed as usize).div_ceil(std::mem::size_of::<usize>());
2795 let mut buffer = vec![0usize; words];
2796 unsafe {
2797 GetTokenInformation(
2798 token,
2799 TokenOwner,
2800 Some(buffer.as_mut_ptr().cast()),
2801 needed,
2802 &mut needed,
2803 )
2804 }
2805 .map_err(windows_error)?;
2806 let owner = unsafe { &*buffer.as_ptr().cast::<TOKEN_OWNER>() };
2807 windows_sid_string(owner.Owner)
2808 })();
2809
2810 let _ = unsafe { CloseHandle(token) };
2811 result
2812}
2813
2814#[cfg(target_os = "windows")]
2815fn current_process_sid_string() -> std::io::Result<String> {
2816 use windows::core::PWSTR;
2817 use windows::Win32::Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL};
2818 use windows::Win32::Security::Authorization::ConvertSidToStringSidW;
2819 use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER};
2820 use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
2821
2822 let windows_error = |error: windows::core::Error| std::io::Error::other(error.to_string());
2823 let mut token = HANDLE::default();
2824 unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }
2825 .map_err(windows_error)?;
2826
2827 let result = (|| {
2828 let mut needed = 0u32;
2829 let _ = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut needed) };
2830 if needed == 0 {
2831 return Err(std::io::Error::last_os_error());
2832 }
2833 let words = (needed as usize).div_ceil(std::mem::size_of::<usize>());
2834 let mut buffer = vec![0usize; words];
2835 unsafe {
2836 GetTokenInformation(
2837 token,
2838 TokenUser,
2839 Some(buffer.as_mut_ptr().cast()),
2840 needed,
2841 &mut needed,
2842 )
2843 }
2844 .map_err(windows_error)?;
2845 let user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
2846 let mut sid_text = PWSTR::null();
2847 unsafe { ConvertSidToStringSidW(user.User.Sid, &mut sid_text) }.map_err(windows_error)?;
2848 let sid = unsafe { sid_text.to_string() }
2849 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error));
2850 unsafe {
2851 let _ = LocalFree(HLOCAL(sid_text.0.cast()));
2852 }
2853 sid
2854 })();
2855
2856 let _ = unsafe { CloseHandle(token) };
2857 result
2858}
2859
2860#[cfg(test)]
2861mod tests {
2862 use super::*;
2863
2864 #[test]
2865 fn windows_private_paths_use_native_handle_bound_security_apis() {
2866 let source = include_str!("secure_path.rs");
2867 assert!(source.contains(&["Create", "DirectoryW"].concat()));
2868 assert!(source.contains(&["Create", "FileW"].concat()));
2869 assert!(source.contains(&["Set", "SecurityInfo"].concat()));
2870 assert!(source.contains(&["SetFileInformation", "ByHandle"].concat()));
2871 assert!(source.contains(&["Flush", "FileBuffers"].concat()));
2872 assert!(source.contains("FILE_GENERIC_WRITE.0 | WRITE_DAC.0"));
2873 assert!(!source.contains(&["Command::new(\"", "icacls", "\")"].concat()));
2874 assert!(!source.contains(&["Move", "FileExW("].concat()));
2875 }
2876
2877 #[test]
2878 fn harden_is_a_noop_on_a_missing_path_and_never_panics() {
2879 harden_owner_only(Path::new("this/path/does/not/exist/xyz"));
2881 }
2882
2883 #[cfg(target_os = "windows")]
2884 fn assert_exact_protected_owner_acl(file: &File) {
2885 windows_validate_file_owner(file).unwrap();
2886 use std::os::windows::io::AsRawHandle;
2887 use windows::Win32::Foundation::{LocalFree, HANDLE, HLOCAL, PSID};
2888 use windows::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
2889 use windows::Win32::Security::{
2890 GetAce, GetSecurityDescriptorControl, ACCESS_ALLOWED_ACE, ACL,
2891 DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SE_DACL_PROTECTED,
2892 };
2893
2894 let mut dacl = std::ptr::null_mut::<ACL>();
2895 let mut descriptor = PSECURITY_DESCRIPTOR::default();
2896 let status = unsafe {
2897 GetSecurityInfo(
2898 HANDLE(file.as_raw_handle() as isize),
2899 SE_FILE_OBJECT,
2900 DACL_SECURITY_INFORMATION,
2901 None,
2902 None,
2903 Some(&mut dacl),
2904 None,
2905 Some(&mut descriptor),
2906 )
2907 };
2908 assert!(status.is_ok());
2909 assert!(!dacl.is_null());
2910
2911 let mut control = 0u16;
2912 let mut revision = 0u32;
2913 unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) }.unwrap();
2914 assert_ne!(control & SE_DACL_PROTECTED.0, 0);
2915 assert_eq!(unsafe { (*dacl).AceCount }, 1);
2916
2917 let mut raw_ace = std::ptr::null_mut();
2918 unsafe { GetAce(dacl, 0, &mut raw_ace) }.unwrap();
2919 let ace = unsafe { &*raw_ace.cast::<ACCESS_ALLOWED_ACE>() };
2920 assert_eq!(ace.Header.AceType, 0, "ACE must be access-allowed");
2921 let sid = PSID(std::ptr::addr_of!(ace.SidStart).cast_mut().cast());
2922 let actual_sid = windows_sid_string(sid).unwrap();
2923 assert_eq!(actual_sid, current_process_sid_string().unwrap());
2924
2925 unsafe {
2926 let _ = LocalFree(HLOCAL(descriptor.0));
2927 }
2928 }
2929
2930 #[cfg(target_os = "windows")]
2931 #[test]
2932 fn created_files_and_directories_have_exact_protected_owner_acl() {
2933 let sandbox = tempfile::tempdir().unwrap();
2934 let directory = sandbox.path().join("private");
2935 ensure_private_dir(&directory).unwrap();
2936 let directory_handle = windows_open_directory(&directory).unwrap();
2937 assert_exact_protected_owner_acl(&directory_handle);
2938
2939 let path = directory.join("record.jsonl");
2940 let file = create_private_file(&path).unwrap();
2941 assert_exact_protected_owner_acl(&file);
2942 }
2943
2944 #[cfg(target_os = "windows")]
2945 #[test]
2946 fn windows_first_use_flush_failure_is_not_acknowledged_and_retry_flushes() {
2947 let sandbox = tempfile::tempdir().unwrap();
2948 let directory = sandbox.path().join("private").join("nested");
2949 let failures = PrivatePathDurabilityFailureInjector::default();
2950 failures.fail_next(PrivatePathDurabilityFailurePoint::ParentDirectorySync);
2951
2952 assert!(ensure_private_dir_with_failure_injector(&directory, &failures).is_err());
2953 ensure_private_dir_with_failure_injector(&directory, &failures).unwrap();
2954 ensure_private_dir_with_failure_injector(&directory, &failures).unwrap();
2958
2959 let path = directory.join("receipt.json");
2960 failures.fail_next(PrivatePathDurabilityFailurePoint::ParentDirectorySync);
2961 assert!(open_private_append_with_failure_injector(&path, &failures).is_err());
2962 let file = open_private_append_with_failure_injector(&path, &failures).unwrap();
2963 assert_exact_protected_owner_acl(&file);
2964 }
2965
2966 #[cfg(target_os = "windows")]
2967 #[test]
2968 fn windows_validated_directory_handoff_swap_fails_closed_and_retries() {
2969 let sandbox = tempfile::tempdir().unwrap();
2970 let directory = sandbox.path().join("private");
2971 ensure_private_dir(&directory).unwrap();
2972 let moved = sandbox.path().join("moved-private");
2973 let child = directory.join("child");
2974 let mut swapped = false;
2975
2976 let error = windows_ensure_private_dir_with_hook(&child, None, |validated| {
2977 if !swapped && validated == directory {
2978 std::fs::rename(&directory, &moved)?;
2979 ensure_private_dir(&directory)?;
2980 swapped = true;
2981 }
2982 Ok(())
2983 })
2984 .unwrap_err();
2985
2986 assert!(swapped, "the exact intermediate directory was substituted");
2987 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
2988 assert!(!child.exists());
2989 std::fs::remove_dir(&directory).unwrap();
2990 std::fs::rename(&moved, &directory).unwrap();
2991 ensure_private_dir(&child).unwrap();
2992 let retained = windows_open_directory(&directory).unwrap();
2993 windows_validate_exact_private_acl(&retained).unwrap();
2994 }
2995
2996 #[cfg(target_os = "windows")]
2997 #[test]
2998 fn windows_replace_rejects_temp_name_substitution_before_publication() {
2999 use std::io::Write;
3000
3001 let root = tempfile::tempdir().unwrap();
3002 let destination = root.path().join("record.jsonl");
3003 let mut destination_file = create_private_file(&destination).unwrap();
3004 destination_file.write_all(b"old").unwrap();
3005 drop(destination_file);
3006 let temp = root.path().join("validated.tmp");
3007 let moved = root.path().join("moved.tmp");
3008 let mut file = create_private_file(&temp).unwrap();
3009 file.write_all(b"validated").unwrap();
3010 drop(file);
3011
3012 let error = windows_atomic_replace_private_file_with_hook(&temp, &destination, || {
3013 std::fs::rename(&temp, &moved)?;
3014 std::fs::write(&temp, b"substitute")?;
3015 Ok(())
3016 })
3017 .unwrap_err();
3018
3019 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
3020 assert!(
3021 error
3022 .to_string()
3023 .contains("revalidate retained replacement source before publication"),
3024 "substitution must be rejected before publication: {error}"
3025 );
3026 }
3027
3028 #[cfg(target_os = "windows")]
3029 #[test]
3030 fn handle_relative_replace_blocks_parent_root_swap() {
3031 use std::io::Write;
3032
3033 let sandbox = tempfile::tempdir().unwrap();
3034 let root = sandbox.path().join("private-root");
3035 ensure_private_dir(&root).unwrap();
3036 let destination = root.join("record.jsonl");
3037 std::fs::write(&destination, b"old").unwrap();
3038 let temp = root.join("validated.tmp");
3039 let mut file = create_private_file(&temp).unwrap();
3040 file.write_all(b"validated").unwrap();
3041 drop(file);
3042 let moved_root = sandbox.path().join("moved-root");
3043
3044 windows_atomic_replace_private_file_with_hook(&temp, &destination, || {
3045 assert!(
3046 std::fs::rename(&root, &moved_root).is_err(),
3047 "retained no-delete-share parent handle must block root replacement"
3048 );
3049 Ok(())
3050 })
3051 .unwrap();
3052
3053 assert_eq!(std::fs::read(&destination).unwrap(), b"validated");
3054 assert!(!moved_root.exists());
3055 }
3056
3057 #[cfg(unix)]
3058 fn mode(path: &Path) -> u32 {
3059 use std::os::unix::fs::PermissionsExt;
3060 std::fs::symlink_metadata(path)
3061 .unwrap()
3062 .permissions()
3063 .mode()
3064 & 0o777
3065 }
3066
3067 #[cfg(unix)]
3068 #[test]
3069 fn private_creation_uses_owner_only_modes_from_first_open() {
3070 let root = tempfile::tempdir().unwrap();
3071 let private_dir = root.path().join("nested").join("private");
3072 ensure_private_dir(&private_dir).unwrap();
3073 assert_eq!(mode(&root.path().join("nested")), 0o700);
3074 assert_eq!(mode(&private_dir), 0o700);
3075
3076 let path = private_dir.join("record.jsonl");
3077 let file = create_private_file(&path).unwrap();
3078 assert_eq!(mode(&path), 0o600);
3079 revalidate_private_file(&file).unwrap();
3080 }
3081
3082 #[cfg(unix)]
3083 #[test]
3084 fn nested_private_creation_propagates_parent_sync_failure_and_retries() {
3085 let sandbox = tempfile::tempdir().unwrap();
3086 let nested = sandbox.path().join("private").join("nested");
3087 let failures = PrivatePathDurabilityFailureInjector::default();
3088 failures.fail_next(PrivatePathDurabilityFailurePoint::ParentDirectorySync);
3089
3090 let error = ensure_private_dir_with_failure_injector(&nested, &failures).unwrap_err();
3091 assert_eq!(error.kind(), io::ErrorKind::Other);
3092 assert!(
3093 !nested.exists(),
3094 "failed first component must not acknowledge the tree"
3095 );
3096
3097 ensure_private_dir_with_failure_injector(&nested, &failures).unwrap();
3098 assert_eq!(mode(&sandbox.path().join("private")), 0o700);
3099 assert_eq!(mode(&nested), 0o700);
3100 }
3101
3102 #[cfg(unix)]
3103 #[test]
3104 fn retained_intermediate_rename_is_rejected_before_the_next_child_create() {
3105 let sandbox = tempfile::tempdir().unwrap();
3106 let private = sandbox.path().join("private");
3107 ensure_private_dir(&private).unwrap();
3108 let intermediate = private.join("retained-intermediate");
3109 let moved = private.join("moved-intermediate");
3110 let target = intermediate.join("child");
3111 let mut renamed = false;
3112
3113 let error =
3114 unix_walk_directory_with_hook(&target, true, true, None, |validated_component| {
3115 if !renamed && validated_component == std::ffi::OsStr::new("retained-intermediate")
3116 {
3117 std::fs::rename(&intermediate, &moved)?;
3118 renamed = true;
3119 }
3120 Ok(())
3121 })
3122 .unwrap_err();
3123
3124 assert!(
3125 renamed,
3126 "the retained intermediate was renamed by the race hook"
3127 );
3128 assert!(matches!(
3129 error.kind(),
3130 io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied
3131 ));
3132 assert!(
3133 !target.exists() && !moved.join("child").exists(),
3134 "the next child must not be created under the moved retained directory"
3135 );
3136
3137 std::fs::rename(&moved, &intermediate).unwrap();
3138 ensure_private_dir(&target).unwrap();
3139 assert!(
3140 target.is_dir(),
3141 "retry on the restored exact chain succeeds"
3142 );
3143 }
3144
3145 #[cfg(unix)]
3146 #[test]
3147 fn new_private_file_propagates_parent_sync_failure_and_append_retry_is_durable() {
3148 use std::io::Write;
3149
3150 let sandbox = tempfile::tempdir().unwrap();
3151 let directory = sandbox.path().join("private");
3152 ensure_private_dir(&directory).unwrap();
3153 let path = directory.join("journal.jsonl");
3154 let failures = PrivatePathDurabilityFailureInjector::default();
3155 failures.fail_next(PrivatePathDurabilityFailurePoint::ParentDirectorySync);
3156
3157 let error = open_private_append_with_failure_injector(&path, &failures).unwrap_err();
3158 assert_eq!(error.kind(), io::ErrorKind::Other);
3159 assert!(
3160 path.exists(),
3161 "the unacknowledged entry remains private for safe retry"
3162 );
3163 assert_eq!(mode(&path), 0o600);
3164
3165 let mut file = open_private_append_with_failure_injector(&path, &failures).unwrap();
3166 file.write_all(b"one\n").unwrap();
3167 file.sync_all().unwrap();
3168 assert_eq!(std::fs::read(&path).unwrap(), b"one\n");
3169 }
3170
3171 #[cfg(unix)]
3172 #[test]
3173 fn opening_owned_permissive_file_hardens_it() {
3174 use std::os::unix::fs::PermissionsExt;
3175
3176 let root = tempfile::tempdir().unwrap();
3177 let path = root.path().join("record.jsonl");
3178 std::fs::write(&path, b"old\n").unwrap();
3179 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3180
3181 let _file = open_private_append(&path).unwrap();
3182 assert_eq!(mode(&path), 0o600);
3183 }
3184
3185 #[cfg(unix)]
3186 #[test]
3187 fn private_read_hardens_without_changing_content() {
3188 use std::io::Read;
3189 use std::os::unix::fs::PermissionsExt;
3190
3191 let root = tempfile::tempdir().unwrap();
3192 let path = root.path().join("record.jsonl");
3193 std::fs::write(&path, b"historical").unwrap();
3194 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3195
3196 let mut file = open_private_read(&path).unwrap();
3197 let mut content = String::new();
3198 file.read_to_string(&mut content).unwrap();
3199 assert_eq!(content, "historical");
3200 assert_eq!(mode(&path), 0o600);
3201 }
3202
3203 #[cfg(unix)]
3204 #[test]
3205 fn atomic_replace_publishes_the_validated_private_inode() {
3206 use std::io::Write;
3207
3208 let root = tempfile::tempdir().unwrap();
3209 let destination = root.path().join("record.jsonl");
3210 std::fs::write(&destination, b"old").unwrap();
3211 let temp = root.path().join(".record.random.tmp");
3212 let mut file = create_private_file(&temp).unwrap();
3213 file.write_all(b"new").unwrap();
3214 file.sync_all().unwrap();
3215 drop(file);
3216
3217 atomic_replace_private_file(&temp, &destination).unwrap();
3218 assert_eq!(std::fs::read(&destination).unwrap(), b"new");
3219 assert_eq!(mode(&destination), 0o600);
3220 assert!(!temp.exists());
3221 }
3222
3223 #[cfg(unix)]
3224 #[test]
3225 fn atomic_replace_rejects_temp_path_substitution() {
3226 use std::io::Write;
3227
3228 let root = tempfile::tempdir().unwrap();
3229 let destination = root.path().join("record.jsonl");
3230 std::fs::write(&destination, b"old").unwrap();
3231 let temp = root.path().join(".record.random.tmp");
3232 let moved = root.path().join("validated.tmp");
3233 let mut file = create_private_file(&temp).unwrap();
3234 file.write_all(b"validated").unwrap();
3235 drop(file);
3236
3237 let result = unix_atomic_replace_private_file_with_hook(&temp, &destination, || {
3238 std::fs::rename(&temp, &moved)?;
3239 let mut substitute = create_private_file(&temp)?;
3240 substitute.write_all(b"substitute")?;
3241 Ok(())
3242 });
3243
3244 assert!(result.is_err());
3245 assert_eq!(std::fs::read(&destination).unwrap(), b"old");
3246 assert_eq!(std::fs::read(&moved).unwrap(), b"validated");
3247 }
3248
3249 #[cfg(unix)]
3250 #[test]
3251 fn path_revalidation_rejects_rename_and_substitution() {
3252 let root = tempfile::tempdir().unwrap();
3253 let path = root.path().join("record.jsonl");
3254 let file = open_private_append(&path).unwrap();
3255 revalidate_private_path(&path, &file).unwrap();
3256
3257 let moved = root.path().join("moved.jsonl");
3258 std::fs::rename(&path, &moved).unwrap();
3259 let _substitute = create_private_file(&path).unwrap();
3260 assert!(revalidate_private_path(&path, &file).is_err());
3261 }
3262
3263 #[cfg(unix)]
3264 #[test]
3265 fn private_tree_rejects_root_rename_and_replacement() {
3266 let sandbox = tempfile::tempdir().unwrap();
3267 let root = sandbox.path().join("car");
3268 std::fs::create_dir(&root).unwrap();
3269 let tree = PrivateTree::open(&root).unwrap();
3270
3271 let moved = sandbox.path().join("moved-car");
3272 std::fs::rename(&root, &moved).unwrap();
3273 std::fs::create_dir(&root).unwrap();
3274
3275 assert!(tree.revalidate_root().is_err());
3276 assert!(tree
3277 .harden_selected(&PrivateTreePolicy::root_only())
3278 .is_err());
3279 }
3280
3281 #[cfg(unix)]
3282 #[test]
3283 fn private_tree_retry_is_idempotent_and_does_not_touch_unselected_siblings() {
3284 use std::os::unix::fs::{symlink, PermissionsExt};
3285
3286 let sandbox = tempfile::tempdir().unwrap();
3287 let root = sandbox.path().join("car");
3288 std::fs::create_dir(&root).unwrap();
3289 let good = root.join("a-good.json");
3290 let blocked = root.join("b-blocked.json");
3291 let unrelated = root.join("unrelated.json");
3292 std::fs::write(&good, b"good").unwrap();
3293 symlink(&good, &blocked).unwrap();
3294 std::fs::write(&unrelated, b"unrelated").unwrap();
3295 for path in [&good, &unrelated] {
3296 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).unwrap();
3297 }
3298
3299 let tree = PrivateTree::open(&root).unwrap();
3300 let policy = PrivateTreePolicy::selected(["a-good.json", "b-blocked.json"]);
3301 assert!(tree.harden_selected(&policy).is_err());
3302 assert_eq!(mode(&good), 0o600);
3303 assert_eq!(mode(&unrelated), 0o644);
3304
3305 std::fs::remove_file(&blocked).unwrap();
3306 std::fs::write(&blocked, b"repaired").unwrap();
3307 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o644)).unwrap();
3308 let report = tree.harden_selected(&policy).unwrap();
3309 assert_eq!(report.files_hardened, 2);
3310 assert_eq!(mode(&good), 0o600);
3311 assert_eq!(mode(&blocked), 0o600);
3312 assert_eq!(mode(&unrelated), 0o644);
3313
3314 let retry = tree.harden_selected(&policy).unwrap();
3315 assert_eq!(retry, report);
3316 }
3317
3318 #[cfg(unix)]
3319 #[test]
3320 fn private_tree_recurses_selected_directories_and_rejects_hardlinks() {
3321 use std::os::unix::fs::PermissionsExt;
3322
3323 let sandbox = tempfile::tempdir().unwrap();
3324 let root = sandbox.path().join("car");
3325 let selected = root.join("runs");
3326 std::fs::create_dir_all(&selected).unwrap();
3327 let victim = selected.join("victim.json");
3328 let alias = selected.join("alias.json");
3329 std::fs::write(&victim, b"run").unwrap();
3330 std::fs::hard_link(&victim, &alias).unwrap();
3331 std::fs::set_permissions(&selected, std::fs::Permissions::from_mode(0o755)).unwrap();
3332
3333 let tree = PrivateTree::open(&root).unwrap();
3334 let policy = PrivateTreePolicy::selected(["runs"]);
3335 assert!(tree.harden_selected(&policy).is_err());
3336 assert_eq!(mode(&selected), 0o700);
3337 assert_eq!(std::fs::read(&victim).unwrap(), b"run");
3338 }
3339
3340 #[cfg(unix)]
3341 #[test]
3342 fn private_tree_recursive_retry_has_stable_receipts_and_exact_modes() {
3343 use std::os::unix::fs::PermissionsExt;
3344
3345 let sandbox = tempfile::tempdir().unwrap();
3346 let root = sandbox.path().join("car");
3347 let nested = root.join("runs").join("today");
3348 std::fs::create_dir_all(&nested).unwrap();
3349 let run = nested.join("run.json");
3350 std::fs::write(&run, b"run").unwrap();
3351 std::fs::set_permissions(root.join("runs"), std::fs::Permissions::from_mode(0o755))
3352 .unwrap();
3353 std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o755)).unwrap();
3354 std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o644)).unwrap();
3355
3356 let tree = PrivateTree::open(&root).unwrap();
3357 let policy = PrivateTreePolicy::selected(["runs"]);
3358 let first = tree.harden_selected(&policy).unwrap();
3359 let retry = tree.harden_selected(&policy).unwrap();
3360
3361 assert_eq!(first, retry);
3362 assert_eq!(first.directories_hardened, 2);
3363 assert_eq!(first.files_hardened, 1);
3364 assert_eq!(mode(&root), 0o700);
3365 assert_eq!(mode(&root.join("runs")), 0o700);
3366 assert_eq!(mode(&nested), 0o700);
3367 assert_eq!(mode(&run), 0o600);
3368 }
3369
3370 #[cfg(unix)]
3371 #[test]
3372 fn private_tree_rejects_parent_traversal_without_touching_external_file() {
3373 use std::os::unix::fs::PermissionsExt;
3374
3375 let sandbox = tempfile::tempdir().unwrap();
3376 let root = sandbox.path().join("car");
3377 std::fs::create_dir(&root).unwrap();
3378 let external = sandbox.path().join("external.json");
3379 std::fs::write(&external, b"external").unwrap();
3380 std::fs::set_permissions(&external, std::fs::Permissions::from_mode(0o644)).unwrap();
3381
3382 let tree = PrivateTree::open(&root).unwrap();
3383 let policy = PrivateTreePolicy::selected(["../external.json"]);
3384 assert!(tree.harden_selected(&policy).is_err());
3385 assert_eq!(mode(&external), 0o644);
3386 assert_eq!(std::fs::read(&external).unwrap(), b"external");
3387 }
3388
3389 #[cfg(unix)]
3390 #[test]
3391 fn overlapping_exact_selection_still_requires_the_nested_marker() {
3392 let sandbox = tempfile::tempdir().unwrap();
3393 let root = sandbox.path().join("car");
3394 let runs = root.join("runs");
3395 std::fs::create_dir_all(&runs).unwrap();
3396
3397 let tree = PrivateTree::open(&root).unwrap();
3398 let policy = PrivateTreePolicy::selected(["runs", "runs/.nobackup"]);
3399 assert_eq!(
3400 tree.harden_selected(&policy).unwrap_err().kind(),
3401 io::ErrorKind::NotFound
3402 );
3403
3404 std::fs::write(runs.join(".nobackup"), b"").unwrap();
3405 let report = tree.harden_selected(&policy).unwrap();
3406 assert_eq!(report.directories_hardened, 1);
3407 assert_eq!(report.files_hardened, 1);
3408 }
3409
3410 #[cfg(unix)]
3411 #[test]
3412 fn truncate_requires_an_existing_validated_file() {
3413 let root = tempfile::tempdir().unwrap();
3414 let existing = root.path().join("existing.json");
3415 std::fs::write(&existing, b"content").unwrap();
3416
3417 let file = open_private_truncate(&existing).unwrap();
3418 assert_eq!(file.metadata().unwrap().len(), 0);
3419 assert_eq!(mode(&existing), 0o600);
3420
3421 let missing = root.path().join("missing.json");
3422 assert_eq!(
3423 open_private_truncate(&missing).unwrap_err().kind(),
3424 io::ErrorKind::NotFound
3425 );
3426 assert!(!missing.exists());
3427 }
3428
3429 #[cfg(unix)]
3430 #[test]
3431 fn truncate_rejects_a_hardlink_without_touching_victim_bytes() {
3432 let root = tempfile::tempdir().unwrap();
3433 let victim = root.path().join("victim.json");
3434 let alias = root.path().join("alias.json");
3435 std::fs::write(&victim, b"must-survive").unwrap();
3436 std::fs::hard_link(&victim, &alias).unwrap();
3437
3438 assert!(open_private_truncate(&alias).is_err());
3439 assert_eq!(std::fs::read(&victim).unwrap(), b"must-survive");
3440 }
3441
3442 #[cfg(unix)]
3443 #[test]
3444 fn symlink_and_hardlink_files_are_rejected() {
3445 use std::os::unix::fs::symlink;
3446
3447 let root = tempfile::tempdir().unwrap();
3448 let target = root.path().join("target");
3449 std::fs::write(&target, b"target").unwrap();
3450
3451 let link = root.path().join("link");
3452 symlink(&target, &link).unwrap();
3453 assert!(open_private_append(&link).is_err());
3454
3455 let hardlink = root.path().join("hardlink");
3456 std::fs::hard_link(&target, &hardlink).unwrap();
3457 assert!(open_private_append(&hardlink).is_err());
3458 }
3459
3460 #[cfg(unix)]
3461 #[test]
3462 fn symlink_directory_component_is_rejected() {
3463 use std::os::unix::fs::symlink;
3464
3465 let root = tempfile::tempdir().unwrap();
3466 let real = root.path().join("real");
3467 std::fs::create_dir(&real).unwrap();
3468 let link = root.path().join("link");
3469 symlink(&real, &link).unwrap();
3470
3471 assert!(ensure_private_dir(&link.join("child")).is_err());
3472 assert!(create_private_file(&link.join("record")).is_err());
3473 }
3474
3475 #[test]
3476 fn create_private_file_never_reuses_an_existing_name() {
3477 let root = tempfile::tempdir().unwrap();
3478 let path = root.path().join("record");
3479 let _file = create_private_file(&path).unwrap();
3480 assert_eq!(
3481 create_private_file(&path).unwrap_err().kind(),
3482 std::io::ErrorKind::AlreadyExists
3483 );
3484 }
3485}