1use std::ffi::{OsStr, OsString};
19use std::io::{self, Read, Write};
20use std::path::{Component, Path, PathBuf};
21use std::sync::atomic::{AtomicU64, Ordering};
22
23#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
24mod ancestors;
25#[cfg(target_os = "macos")]
26mod darwin_acl;
27
28#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
29pub(crate) use ancestors::{DataRootAncestorError, validate_ambient_backend_ancestors};
30
31use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt};
32use cap_std::ambient_authority;
33use cap_std::fs::{Dir, DirBuilder, OpenOptions};
34#[cfg(unix)]
35use cap_std::fs::{DirBuilderExt, MetadataExt as _, OpenOptionsExt, PermissionsExt};
36#[cfg(all(unix, not(target_os = "macos")))]
37use std::os::fd::AsRawFd as _;
38#[cfg(target_os = "macos")]
39use std::os::unix::ffi::OsStringExt as _;
40
41pub(crate) const PRIVATE_DIR_MODE: u32 = 0o700;
46
47pub(crate) const PRIVATE_FILE_MODE: u32 = 0o600;
50
51static CAPABILITY_OPENS: AtomicU64 = AtomicU64::new(0);
64
65pub(crate) fn note_open() {
66 CAPABILITY_OPENS.fetch_add(1, Ordering::Relaxed);
67}
68
69pub fn capability_opens() -> u64 {
72 CAPABILITY_OPENS.load(Ordering::Relaxed)
73}
74
75pub fn reset_capability_opens() {
77 CAPABILITY_OPENS.store(0, Ordering::Relaxed);
78}
79
80pub(crate) struct ConfinedDir {
82 dir: Dir,
83}
84
85impl ConfinedDir {
86 pub(crate) fn open(path: &Path) -> io::Result<Self> {
89 let root = Self {
90 dir: open_absolute(path, false)?,
91 };
92 root.ensure_private_mode(path)?;
93 root.probe_readable()?;
94 Ok(root)
95 }
96
97 pub(crate) fn open_or_create(path: &Path) -> io::Result<Self> {
100 let root = Self {
101 dir: open_absolute(path, true)?,
102 };
103 root.ensure_private_mode(path)?;
104 root.probe_readable()?;
105 Ok(root)
106 }
107
108 fn probe_readable(&self) -> io::Result<()> {
120 self.dir.entries().map(drop)
121 }
122
123 pub(crate) fn read_to_string(&self, relative: &Path) -> io::Result<String> {
125 let mut file = self.open_file(relative, false)?;
126 let mut value = String::new();
127 file.read_to_string(&mut value)?;
128 Ok(value)
129 }
130
131 pub(crate) fn read(&self, relative: &Path) -> io::Result<Vec<u8>> {
133 let mut file = self.open_file(relative, false)?;
134 let mut value = Vec::new();
135 file.read_to_end(&mut value)?;
136 Ok(value)
137 }
138
139 pub(crate) fn create_new(&self, relative: &Path, bytes: &[u8]) -> io::Result<()> {
141 let (parent, name) = self.open_parent(relative, true)?;
142 let mut options = private_file_options();
143 options.write(true).create_new(true);
144 note_open();
145 let mut file = parent.open_with(name, &options)?;
146 if let Err(error) = write_and_sync(&mut file, bytes) {
147 drop(file);
148 let _ = parent.remove_file(name);
149 return Err(error);
150 }
151 Ok(())
152 }
153
154 pub(crate) fn atomic_write(&self, relative: &Path, bytes: &[u8]) -> io::Result<()> {
157 let (parent, name) = self.open_parent(relative, true)?;
158 match parent.symlink_metadata(name) {
159 Ok(metadata) if metadata.file_type().is_symlink() => {
160 return Err(io::Error::new(
161 io::ErrorKind::InvalidInput,
162 "refusing to replace a symbolic link",
163 ));
164 }
165 Ok(_) => {}
166 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
167 Err(error) => return Err(error),
168 }
169
170 let temp_name = OsString::from(format!(".aion-{}.tmp", uuid::Uuid::new_v4()));
171 let mut options = private_file_options();
172 options.write(true).create_new(true);
173 note_open();
174 let mut temp = parent.open_with(&temp_name, &options)?;
175 if let Err(error) = write_and_sync(&mut temp, bytes) {
176 drop(temp);
177 let _ = parent.remove_file(&temp_name);
178 return Err(error);
179 }
180 drop(temp);
181 if let Err(error) = parent.rename(&temp_name, &parent, name) {
182 let _ = parent.remove_file(&temp_name);
183 return Err(error);
184 }
185 Ok(())
186 }
187
188 pub(crate) fn remove_file(&self, relative: &Path) -> io::Result<()> {
190 let (parent, name) = self.open_parent(relative, false)?;
191 parent.remove_file(name)
192 }
193
194 pub(crate) fn list_awl(&self) -> io::Result<Vec<PathBuf>> {
196 let mut paths = Vec::new();
197 visit_awl(&self.dir, Path::new(""), &mut paths)?;
198 Ok(paths)
199 }
200
201 pub(crate) fn create_dir_all(&self, relative: &Path) -> io::Result<()> {
203 drop(self.open_dir(relative, true)?);
204 Ok(())
205 }
206
207 #[cfg(unix)]
219 pub(crate) fn backend_path(&self) -> io::Result<PathBuf> {
220 #[cfg(any(target_os = "linux", target_os = "android"))]
221 {
222 let bridge = Path::new("/proc/self/fd").join(self.dir.as_raw_fd().to_string());
230 std::fs::symlink_metadata(&bridge)?;
231 Ok(bridge)
232 }
233 #[cfg(target_os = "macos")]
234 {
235 let path = rustix::fs::getpath(&self.dir)?;
236 Ok(PathBuf::from(OsString::from_vec(path.into_bytes())))
237 }
238 #[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos")))]
239 {
240 std::fs::canonicalize(Path::new("/dev/fd").join(self.dir.as_raw_fd().to_string()))
241 }
242 }
243
244 pub(crate) fn ensure_child_dir_private(&self, relative: &Path) -> io::Result<()> {
255 validate_relative(relative)?;
256 let child = self.open_dir(relative, false)?;
257 ensure_dir_private(&child, relative)
258 }
259
260 pub(crate) fn dir(&self) -> &Dir {
262 &self.dir
263 }
264
265 fn ensure_private_mode(&self, path: &Path) -> io::Result<()> {
296 ensure_dir_private(&self.dir, path)
297 }
298
299 fn open_file(&self, relative: &Path, create_parents: bool) -> io::Result<cap_std::fs::File> {
300 let (parent, name) = self.open_parent(relative, create_parents)?;
301 let mut options = OpenOptions::new();
302 options.read(true).follow(FollowSymlinks::No);
303 note_open();
304 parent.open_with(name, &options)
305 }
306
307 fn open_parent<'a>(&self, relative: &'a Path, create: bool) -> io::Result<(Dir, &'a OsStr)> {
308 validate_relative(relative)?;
309 let name = relative.file_name().ok_or_else(invalid_relative)?;
310 let parent = relative.parent().unwrap_or_else(|| Path::new(""));
311 self.open_dir(parent, create).map(|dir| (dir, name))
312 }
313
314 fn open_dir(&self, relative: &Path, create: bool) -> io::Result<Dir> {
315 validate_relative_or_empty(relative)?;
316 let mut current = self.dir.try_clone()?;
317 for component in relative.components() {
318 let Component::Normal(name) = component else {
319 return Err(invalid_relative());
320 };
321 current = open_child_dir(¤t, name, create)?;
322 }
323 Ok(current)
324 }
325}
326
327fn ensure_dir_private(dir: &Dir, path: &Path) -> io::Result<()> {
331 #[cfg(unix)]
332 {
333 let metadata = dir.dir_metadata()?;
334 let mode = metadata.permissions().mode() & 0o777;
335 let grants_group_or_world = mode & 0o077 != 0;
336 if !grants_group_or_world {
337 return Ok(());
338 }
339
340 let owner = metadata.uid();
341 let effective = rustix::process::geteuid().as_raw();
342 if owner != effective {
343 return Err(io::Error::new(
344 io::ErrorKind::PermissionDenied,
345 format!(
346 "sensitive root `{}` has mode {mode:04o}, which grants group or world \
347 access, and is owned by uid {owner} rather than the uid {effective} this \
348 server runs as. Aion will not change another principal's directory. \
349 Either run the server as uid {owner}, or point this root at a directory \
350 owned by uid {effective}.",
351 path.display()
352 ),
353 ));
354 }
355
356 dir.set_permissions(
357 Path::new("."),
358 cap_std::fs::Permissions::from_mode(PRIVATE_DIR_MODE),
359 )
360 .map_err(|error| {
361 io::Error::new(
362 io::ErrorKind::PermissionDenied,
363 format!(
364 "sensitive root `{}` has mode {mode:04o}, which grants group or world \
365 access, and Aion could not tighten it to 0700: {error}. Move this \
366 root onto a filesystem that carries Unix permissions, or pre-create \
367 it with mode 0700.",
368 path.display()
369 ),
370 )
371 })?;
372
373 let applied = dir.dir_metadata()?.permissions().mode() & 0o777;
380 if applied & 0o077 != 0 {
381 return Err(io::Error::new(
382 io::ErrorKind::PermissionDenied,
383 format!(
384 "sensitive root `{}` still reports mode {applied:04o} after Aion set it \
385 to 0700, so this filesystem does not honour Unix permissions and Aion \
386 cannot keep workflow state private here. Move this root onto a \
387 filesystem that does.",
388 path.display()
389 ),
390 ));
391 }
392
393 let previous_mode = format!("{mode:04o}");
394 let applied_mode = format!("{PRIVATE_DIR_MODE:04o}");
395 tracing::info!(
396 sensitive_root = %path.display(),
397 %previous_mode,
398 %applied_mode,
399 "tightened a sensitive root to owner-only: it granted group or world access and \
400 the server's own user owns it"
401 );
402 }
403 #[cfg(not(unix))]
404 let _ = path;
405 Ok(())
406}
407
408fn open_absolute(path: &Path, create: bool) -> io::Result<Dir> {
409 let absolute = std::path::absolute(path)?;
410 let (anchor, names) = split_absolute(&absolute)?;
411 note_open();
412 let mut current = Dir::open_ambient_dir(&anchor, ambient_authority())?;
413 for (index, name) in names.into_iter().enumerate() {
414 match open_child_dir(¤t, &name, create) {
415 Ok(child) => current = child,
416 Err(error) if index == 0 => {
417 let alias = anchor.join(&name);
422 let metadata = std::fs::symlink_metadata(&alias)?;
423 if !metadata.file_type().is_symlink() {
424 return Err(component_error(&name, &error));
425 }
426 let canonical = std::fs::canonicalize(alias)?;
427 note_open();
428 current = Dir::open_ambient_dir(canonical, ambient_authority())?;
429 }
430 Err(error) => return Err(component_error(&name, &error)),
431 }
432 }
433 Ok(current)
434}
435
436fn split_absolute(path: &Path) -> io::Result<(PathBuf, Vec<OsString>)> {
437 let mut anchor = PathBuf::new();
438 let mut names = Vec::new();
439 for component in path.components() {
440 match component {
441 Component::Prefix(_) | Component::RootDir => anchor.push(component.as_os_str()),
442 Component::CurDir => {}
443 Component::ParentDir => {
444 if names.pop().is_none() {
445 return Err(invalid_relative());
446 }
447 }
448 Component::Normal(name) => names.push(name.to_owned()),
449 }
450 }
451 if anchor.as_os_str().is_empty() {
452 return Err(invalid_relative());
453 }
454 Ok((anchor, names))
455}
456
457fn component_error(name: &OsStr, error: &io::Error) -> io::Error {
458 io::Error::new(
459 error.kind(),
460 format!(
461 "failed to open real directory component `{}`: {error}",
462 name.to_string_lossy()
463 ),
464 )
465}
466
467fn open_child_dir(parent: &Dir, name: &OsStr, create: bool) -> io::Result<Dir> {
468 note_open();
469 match parent.open_dir_nofollow(name) {
470 Ok(dir) => Ok(dir),
471 Err(error) if create && error.kind() == io::ErrorKind::NotFound => {
472 let mut builder = DirBuilder::new();
473 #[cfg(unix)]
474 builder.mode(PRIVATE_DIR_MODE);
475 match parent.create_dir_with(name, &builder) {
476 Ok(()) => {}
477 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
478 Err(error) => return Err(error),
479 }
480 note_open();
481 parent.open_dir_nofollow(name)
482 }
483 Err(error) => Err(error),
484 }
485}
486
487fn private_file_options() -> OpenOptions {
488 let mut options = OpenOptions::new();
489 options.follow(FollowSymlinks::No);
490 #[cfg(unix)]
491 options.mode(PRIVATE_FILE_MODE);
492 options
493}
494
495fn write_and_sync(file: &mut cap_std::fs::File, bytes: &[u8]) -> io::Result<()> {
496 file.write_all(bytes)?;
497 file.sync_all()
498}
499
500fn visit_awl(dir: &Dir, relative: &Path, paths: &mut Vec<PathBuf>) -> io::Result<()> {
501 for entry in dir.entries()? {
502 let entry = entry?;
503 let name = entry.file_name();
504 let file_type = entry.file_type()?;
505 if file_type.is_symlink() {
506 continue;
507 }
508 let child_relative = relative.join(&name);
509 if file_type.is_dir() {
510 let child = dir.open_dir_nofollow(&name)?;
511 visit_awl(&child, &child_relative, paths)?;
512 } else if file_type.is_file() && child_relative.extension() == Some(OsStr::new("awl")) {
513 paths.push(child_relative);
514 }
515 }
516 Ok(())
517}
518
519fn validate_relative(path: &Path) -> io::Result<()> {
520 if path.as_os_str().is_empty() {
521 return Err(invalid_relative());
522 }
523 validate_relative_or_empty(path)
524}
525
526fn validate_relative_or_empty(path: &Path) -> io::Result<()> {
527 if path
528 .components()
529 .any(|component| !matches!(component, Component::Normal(_)))
530 {
531 return Err(invalid_relative());
532 }
533 Ok(())
534}
535
536fn invalid_relative() -> io::Error {
537 io::Error::new(
538 io::ErrorKind::InvalidInput,
539 "path must be relative and contain only normal components",
540 )
541}
542
543#[cfg(all(test, target_os = "macos"))]
544pub(crate) fn darwin_user_uuid_for_test(uid: u32) -> io::Result<uuid::Uuid> {
545 darwin_acl::user_uuid_for_test(uid)
546}
547
548#[cfg(not(unix))]
558pub(crate) fn validate_real_directory_root(path: &Path, label: &str) -> io::Result<()> {
559 let metadata = match std::fs::symlink_metadata(path) {
560 Ok(metadata) => metadata,
561 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
562 Err(error) => return Err(error),
563 };
564 if metadata.file_type().is_symlink() || !metadata.is_dir() {
565 return Err(io::Error::new(
566 io::ErrorKind::InvalidInput,
567 format!("{label} `{}` is not a real directory", path.display()),
568 ));
569 }
570 Ok(())
571}
572
573#[cfg(all(test, unix))]
574mod tests {
575 use std::os::unix::fs::PermissionsExt as _;
576
577 use super::*;
578
579 #[test]
580 fn nested_sensitive_roots_and_files_ignore_a_permissive_umask()
581 -> Result<(), Box<dyn std::error::Error>> {
582 const PROBE: &str = "AION_PRIVATE_MODE_UMASK_PROBE";
583 if let Some(path) = std::env::var_os(PROBE) {
584 return assert_private_creation(Path::new(&path));
585 }
586
587 let sandbox = crate::test_support::private_tempdir()?;
588 let executable = std::env::current_exe()?;
589 let status = std::process::Command::new("sh")
590 .arg("-c")
591 .arg(
592 "umask 000; exec \"$1\" --exact \
593 filesystem::tests::nested_sensitive_roots_and_files_ignore_a_permissive_umask \
594 --nocapture",
595 )
596 .arg("aion-private-mode-probe")
597 .arg(executable)
598 .env(PROBE, sandbox.path())
599 .status()?;
600 assert!(status.success(), "private-mode umask probe failed");
601 Ok(())
602 }
603
604 fn assert_private_creation(sandbox: &Path) -> Result<(), Box<dyn std::error::Error>> {
605 let home = sandbox.join("aion-home");
606 let authoring = home.join("authoring");
607 let root = ConfinedDir::open_or_create(&authoring)?;
608 root.create_new(Path::new("private.txt"), b"secret")?;
609 assert_eq!(
610 std::fs::metadata(&home)?.permissions().mode() & 0o777,
611 0o700
612 );
613 assert_eq!(
614 std::fs::metadata(&authoring)?.permissions().mode() & 0o777,
615 0o700
616 );
617 assert_eq!(
618 std::fs::metadata(authoring.join("private.txt"))?
619 .permissions()
620 .mode()
621 & 0o777,
622 0o600
623 );
624 Ok(())
625 }
626
627 #[test]
632 fn a_missing_root_is_created_owner_only() -> Result<(), Box<dyn std::error::Error>> {
633 let sandbox = crate::test_support::private_tempdir()?;
634 let root = sandbox.path().join("nested").join("aion-home");
635
636 let (captured, opened) = crate::test_support::CapturedLogs::capture(|| {
637 ConfinedDir::open_or_create(&root).map(drop)
638 });
639 opened?;
640
641 assert_eq!(
642 std::fs::metadata(&root)?.permissions().mode() & 0o777,
643 0o700
644 );
645 assert!(
646 !captured.text()?.contains("tightened a sensitive root"),
647 "a freshly created root must not need tightening"
648 );
649 Ok(())
650 }
651
652 #[test]
656 fn a_permissive_root_we_own_is_tightened_and_logged() -> Result<(), Box<dyn std::error::Error>>
657 {
658 let sandbox = crate::test_support::private_tempdir()?;
659 let root = sandbox.path().join("aion-home");
660 std::fs::create_dir(&root)?;
661 std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755))?;
662
663 let (captured, opened) =
664 crate::test_support::CapturedLogs::capture(|| ConfinedDir::open(&root).map(drop));
665 opened?;
666
667 assert_eq!(
668 std::fs::metadata(&root)?.permissions().mode() & 0o777,
669 0o700
670 );
671 let logs = captured.text()?;
672 assert!(logs.contains("tightened a sensitive root to owner-only"));
673 assert!(logs.contains(&root.display().to_string()));
674 assert!(logs.contains("0755"), "the previous mode was not logged");
675 assert!(logs.contains("0700"), "the applied mode was not logged");
676 Ok(())
677 }
678
679 #[test]
683 fn a_world_writable_root_we_own_is_tightened() -> Result<(), Box<dyn std::error::Error>> {
684 let sandbox = crate::test_support::private_tempdir()?;
685 let root = sandbox.path().join("aion-data");
686 std::fs::create_dir(&root)?;
687 std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777))?;
688
689 ConfinedDir::open_or_create(&root)?;
690
691 assert_eq!(
692 std::fs::metadata(&root)?.permissions().mode() & 0o777,
693 0o700
694 );
695 Ok(())
696 }
697
698 #[test]
700 fn an_already_private_root_is_untouched_and_silent() -> Result<(), Box<dyn std::error::Error>> {
701 let sandbox = crate::test_support::private_tempdir()?;
702 let root = sandbox.path().join("aion-home");
703 std::fs::create_dir(&root)?;
704 std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))?;
705
706 let (captured, opened) =
707 crate::test_support::CapturedLogs::capture(|| ConfinedDir::open(&root).map(drop));
708 opened?;
709
710 assert_eq!(
711 std::fs::metadata(&root)?.permissions().mode() & 0o777,
712 0o700
713 );
714 assert!(captured.text()?.is_empty());
715 Ok(())
716 }
717
718 #[test]
728 fn a_permissive_root_owned_by_another_user_refuses_with_remediation()
729 -> Result<(), Box<dyn std::error::Error>> {
730 let effective = rustix::process::geteuid().as_raw();
731 if effective == 0 {
732 tracing::info!(
733 "skipping the foreign-owner refusal pin: running as root, which owns every \
734 candidate directory"
735 );
736 return Ok(());
737 }
738 let foreign = Path::new("/usr");
739 let metadata = std::fs::symlink_metadata(foreign)?;
740 let owner = std::os::unix::fs::MetadataExt::uid(&metadata);
741 let mode = metadata.permissions().mode() & 0o777;
742 let grants_group_or_world = mode & 0o077 != 0;
743 if owner == effective || !grants_group_or_world {
744 tracing::info!(
745 path = %foreign.display(),
746 "skipping the foreign-owner refusal pin: this system's /usr is not a \
747 foreign-owned, group/world-readable directory"
748 );
749 return Ok(());
750 }
751
752 let error = ConfinedDir::open(foreign)
753 .err()
754 .ok_or("a permissive foreign-owned root was accepted")?;
755 let message = error.to_string();
756 assert!(message.contains("/usr"), "the path was not named");
757 assert!(
758 message.contains(&format!("mode {mode:04o}")),
759 "the offending mode was not named"
760 );
761 assert!(message.contains(&format!("uid {owner}")));
762 assert!(message.contains(&format!("uid {effective}")));
763 assert!(message.contains("run the server as"), "no remediation");
764 assert_eq!(
765 std::fs::symlink_metadata(foreign)?.permissions().mode() & 0o777,
766 mode,
767 "a foreign-owned directory must never be modified"
768 );
769 Ok(())
770 }
771
772 #[test]
778 fn a_symlinked_root_refuses_and_leaves_its_target_alone()
779 -> Result<(), Box<dyn std::error::Error>> {
780 let sandbox = crate::test_support::private_tempdir()?;
781 let target = sandbox.path().join("elsewhere");
782 let link = sandbox.path().join("aion-home");
783 std::fs::create_dir(&target)?;
784 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))?;
785 std::os::unix::fs::symlink(&target, &link)?;
786
787 let error = ConfinedDir::open(&link)
788 .err()
789 .ok_or("a symlinked root was accepted")?;
790 assert!(
791 error.to_string().contains("aion-home"),
792 "the refusal did not name the offending component"
793 );
794 assert!(ConfinedDir::open_or_create(&link).is_err());
795 assert_eq!(
796 std::fs::metadata(&target)?.permissions().mode() & 0o777,
797 0o755,
798 "a symlink target must never be tightened"
799 );
800 Ok(())
801 }
802
803 #[test]
805 fn a_root_occupied_by_a_file_refuses() -> Result<(), Box<dyn std::error::Error>> {
806 let sandbox = crate::test_support::private_tempdir()?;
807 let occupied = sandbox.path().join("aion-home");
808 std::fs::write(&occupied, b"not a directory")?;
809
810 let error = ConfinedDir::open_or_create(&occupied)
811 .err()
812 .ok_or("a file standing in for a root was accepted")?;
813 assert!(error.to_string().contains("aion-home"));
814 Ok(())
815 }
816}