Skip to main content

aion_server/
filesystem.rs

1//! Descriptor-relative, no-follow filesystem operations for sensitive server roots.
2//!
3//! Every path component is opened from a held directory capability. Symlinks and
4//! Windows reparse points are refused at component boundaries, and final files are
5//! opened with no-follow semantics. A concurrent local actor may replace a name
6//! after validation, but the operation remains relative to an already-open parent
7//! descriptor: it can redirect a name within that held directory, never expand the
8//! operation's authority beyond the configured root.
9//!
10//! These roots are Aion's own, so Aion provisions them: a missing root is
11//! created owner-only in a single `mkdir`, and an existing root the server's
12//! user owns is tightened to owner-only rather than refused. The operator is
13//! never handed a `chmod` command for a directory the server could fix itself.
14//! What Aion cannot make safe — a foreign owner, a symlinked component, a
15//! filesystem without Unix modes, or an unsafe ANCESTOR (see `ancestors`) —
16//! still refuses, naming the path, the problem, and the remedy.
17
18use 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
41/// Owner-only mode for every directory Aion owns. This is a security constant,
42/// not a tunable: these roots hold workflow payloads, signal arguments, and
43/// authored source, so group and world get nothing. It is never read from
44/// configuration and there is no override.
45pub(crate) const PRIVATE_DIR_MODE: u32 = 0o700;
46
47/// Owner-only mode for every file Aion writes under those roots. Same reasoning,
48/// same absence of an override.
49pub(crate) const PRIVATE_FILE_MODE: u32 = 0o600;
50
51/// Every `open(2)` this module issues through a held capability — file opens,
52/// directory opens, and the component walk that acquires a root — counted for
53/// the whole process.
54///
55/// This is the instrument behind the boot-cost pin: a boot over a store of a
56/// million object files must not open a million files. Wall time cannot say
57/// that (a fast disk hides a walk; a busy box exaggerates one); a count can.
58/// The counter is process-wide, so a test that asserts on it resets first
59/// and runs alone in its process (nextest) or tolerates only the opens it
60/// causes itself. `entries()` (a directory stream) is not counted: it is not
61/// an open of a named path, and the walk it belongs to is counted by the
62/// opens it issues.
63static CAPABILITY_OPENS: AtomicU64 = AtomicU64::new(0);
64
65pub(crate) fn note_open() {
66    CAPABILITY_OPENS.fetch_add(1, Ordering::Relaxed);
67}
68
69/// How many opens this module has issued through held capabilities since
70/// process start or the last [`reset_capability_opens`].
71pub fn capability_opens() -> u64 {
72    CAPABILITY_OPENS.load(Ordering::Relaxed)
73}
74
75/// Reset [`capability_opens`] to zero, for a test that brackets one boot.
76pub fn reset_capability_opens() {
77    CAPABILITY_OPENS.store(0, Ordering::Relaxed);
78}
79
80/// A held directory descriptor confining all subsequent operations beneath it.
81pub(crate) struct ConfinedDir {
82    dir: Dir,
83}
84
85impl ConfinedDir {
86    /// Open an existing real directory without following any path component,
87    /// bringing it to owner-only mode if it is ours and too permissive.
88    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    /// Open or create a real directory, creating every missing component
98    /// privately and bringing an existing root to owner-only mode.
99    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    /// Refuse at acquisition a root the server cannot actually read.
109    ///
110    /// Openability is not readability: on Linux the held handle is an
111    /// `O_PATH` descriptor and opens even a directory whose mode denies every
112    /// read, so without this probe an unreadable root is "successfully"
113    /// confined and the `EACCES` surfaces later, at whichever operation first
114    /// reads through the handle — or never, when a request happens not to
115    /// need the directory's contents (aion#62). macOS refuses the same
116    /// directory at `open(2)`. Probing the entry stream here makes an
117    /// unusable root fail on every platform at the same place, with its
118    /// truthful errno.
119    fn probe_readable(&self) -> io::Result<()> {
120        self.dir.entries().map(drop)
121    }
122
123    /// Read a UTF-8 file without following any component or final symlink.
124    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    /// Read a file without following any component or final symlink.
132    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    /// Create a new private file, refusing an existing file or link.
140    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    /// Atomically replace a private file using an unpredictable `create_new`
155    /// temporary in the same held parent directory.
156    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    /// Remove a file relative to this capability, without traversing parents.
189    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    /// Recursively list `.awl` files while refusing directory links.
195    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    /// Eagerly create a descendant directory through this capability.
202    pub(crate) fn create_dir_all(&self, relative: &Path) -> io::Result<()> {
203        drop(self.open_dir(relative, true)?);
204        Ok(())
205    }
206
207    /// Return the narrowest path bridge the platform offers from this held
208    /// descriptor to a backend that accepts only `PathBuf`.
209    ///
210    /// Linux/Android can traverse descendants through `/proc/self/fd`, so the
211    /// returned path remains descriptor-authoritative. macOS and other Unix
212    /// targets expose a directory descriptor in `/dev/fd` but do not permit
213    /// descendant traversal through that name; there we resolve the descriptor's
214    /// current real path immediately before backend startup. Normal backend reads
215    /// and commits remain ambient pathname operations after startup on those
216    /// platforms, so callers must reject renameable ancestor chains until
217    /// Haematite exposes descriptor-relative I/O.
218    #[cfg(unix)]
219    pub(crate) fn backend_path(&self) -> io::Result<PathBuf> {
220        #[cfg(any(target_os = "linux", target_os = "android"))]
221        {
222            // The descriptor bridge only exists where procfs is mounted and
223            // readable — containers with a hidden or absent /proc would
224            // otherwise hand the backend a path that resolves to nothing.
225            // This probe is the arm's real fallibility (aion#59): without it
226            // the compiled Linux body was infallible inside the `io::Result`
227            // the macOS arm genuinely needs, and the workspace was
228            // clippy-red on Linux (`unnecessary_wraps`).
229            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    /// Bring an immediate child directory to owner-only mode, the way the
245    /// root itself is brought at open: one `fstat` on the held child, one
246    /// repair if it is ours and too loose, and a refusal naming the
247    /// directory, its mode, and its owner when it is not ours to change.
248    ///
249    /// This is what a boot does to each shard directory — sixty-five
250    /// `fstat`s for a sixty-four-shard store — in place of the walk that used
251    /// to open every object file beneath them. Files are private from their
252    /// own creation (haematite 0.12.1 names 0600/0700 at every create site),
253    /// so a boot has nothing to repair below the directories it checks here.
254    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    /// The held directory, for a walk that needs it (`store_harden`).
261    pub(crate) fn dir(&self) -> &Dir {
262        &self.dir
263    }
264
265    /// Bring the held root to owner-only mode, tightening it ourselves when it
266    /// is ours to tighten.
267    ///
268    /// The operator must never be told to go and hand-run `chmod 700` on a
269    /// directory Aion created, or would have created, before Aion will start.
270    /// That refusal was a real and repeated defect: a `~/.aion` left at 0755 by
271    /// a permissive umask stopped a stock server, and the only cure the message
272    /// offered was a shell command the server could have run itself. Loosening
273    /// the requirement would have been the wrong fix — the directory holds
274    /// workflow payloads — so the server now performs the repair instead.
275    ///
276    /// Every decision is taken against the ALREADY-OPEN descriptor: `fstat` on
277    /// the held fd, and the repair addressed through the held capability
278    /// (descriptor-relative `.`), never a second resolution of the ambient
279    /// `path`. That closes the check-then-act window — there is no interval in
280    /// which a concurrent rename could point the inspection at one inode and
281    /// the repair at another, because both resolve from the same held
282    /// descriptor. `path` is carried purely to make the log and error text
283    /// nameable.
284    ///
285    /// The repair is cap-std's `set_permissions`, not a raw `fchmod` on the
286    /// handle: on Linux the held directory handle is an `O_PATH` descriptor,
287    /// and `fchmod(2)` refuses those with `EBADF` — which left every
288    /// tightenable 0755 root refused on Linux while macOS repaired it
289    /// (aion#62). cap-std performs the descriptor-relative mode change
290    /// portably on both.
291    ///
292    /// Refusal is reserved for what Aion genuinely cannot repair: a directory
293    /// owned by another principal (not ours to change, and a mode change would
294    /// fail anyway), and a filesystem that will not carry Unix modes.
295    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(&current, name, create)?;
322        }
323        Ok(current)
324    }
325}
326
327/// The one mode check every directory Aion holds goes through: the root at
328/// open, each shard directory at boot. See [`ConfinedDir::ensure_private_mode`]
329/// for the reasoning; `path` names the directory in the log and the refusal.
330fn 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        // Re-stat the same descriptor rather than assume the write took.
374        // Filesystems exist that accept a mode change and discard it
375        // (network and FAT-family mounts among them); without this the
376        // server would
377        // log that it had made the root private while the payloads stayed
378        // world-readable.
379        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(&current, &name, create) {
415            Ok(child) => current = child,
416            Err(error) if index == 0 => {
417                // macOS exposes root-owned compatibility aliases such as
418                // `/var -> /private/var`. Following only a filesystem-root
419                // entry preserves those platform paths; every user-controlled
420                // component below it remains descriptor-relative and no-follow.
421                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/// Verify that an existing sensitive root is a real directory, on the targets
549/// where Aion cannot express or install an owner-only ACL.
550///
551/// Unix does not use this: there, [`ConfinedDir::open_or_create`] both creates
552/// the root privately and repairs an existing loose one, so there is nothing
553/// left for a separate pathname inspection to do. Non-Unix targets have no mode
554/// Aion can set, so the configuration boundary additionally refuses default
555/// roots, requires the operator to pre-provision and name the directory, and
556/// warns that ACL privacy is not verified.
557#[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    /// A root that does not exist yet is created owner-only, in one step. The
628    /// umask probe above proves the mode comes from the `mkdir` itself rather
629    /// than a follow-up `chmod`, so there is no window in which the directory
630    /// exists while still group-readable.
631    #[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    /// The defect this whole surface exists to close: a `~/.aion` left at 0755
653    /// by a conventional umask used to refuse startup and hand the operator a
654    /// `chmod` command. It is our directory and our user owns it, so we fix it.
655    #[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    /// World-writable is the same defect one notch worse, and gets the same
680    /// answer. The leaf being world-writable is repairable; only an unsafe
681    /// ANCESTOR is not (pinned in `ancestors::tests`).
682    #[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    /// An already-private root is left exactly as found, and says nothing.
699    #[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    /// A permissive root owned by someone else is refused, not repaired: it is
719    /// not ours to change, and the mode change would fail regardless. The refusal has
720    /// to carry the path, the mode, both uids, and what the operator can do.
721    ///
722    /// `/usr` is a stable stand-in for "root-owned and group/world readable" on
723    /// every Unix. The euid guard is not decoration: running as root, the
724    /// ownership branch would not fire and the test would try to tighten a
725    /// system directory. Gated at runtime rather than with `#[ignore]` so the
726    /// skip is visible in the log.
727    #[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    /// A symlinked root refuses rather than resolving. Aion's privacy claim is
773    /// about an inode it opened by no-follow walk; honouring a link would let
774    /// whoever can write the link's parent redirect the entire root, and
775    /// tightening the link's target would be modifying a directory we were
776    /// never pointed at directly.
777    #[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    /// A root whose name is taken by a file is refused, not silently replaced.
804    #[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}