use super::paths::{follows_link, mutable_entry, resolve}; use super::*; #[cfg(unix)] use std::os::fd::AsFd; #[cfg(target_os = "linux")] use std::os::fd::AsRawFd; #[cfg(unix)] fn times(secs: i64, nanos: u32) -> io::Result { if !(-8_640_000_000_000..=8_640_000_000_000).contains(&secs) || (secs == 8_640_000_000_000 && nanos > 0) || nanos >= 1_000_000_000 { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Invalid timestamp", )); } Ok(rustix::fs::Timespec { tv_sec: secs, tv_nsec: nanos as _, }) } fn unchanged(directory: &Dir, name: &std::ffi::OsStr) -> io::Result<()> { // Following ops resolve the referent before checking authority, so the // leaf must still be that referent at use time; anything else raced. if directory.symlink_metadata(name)?.is_symlink() { return Err(io::Error::new( io::ErrorKind::NotFound, "Symbolic link changed or has a missing target", )); } Ok(()) } fn link_changed(error: io::Error) -> io::Error { // A loop in the resolved path names no file; report it like a raced link. #[cfg(unix)] if error.raw_os_error() == Some(libc::ELOOP) { return io::Error::new( io::ErrorKind::NotFound, "Symbolic link changed or has a missing target", ); } error } #[cfg(target_os = "linux")] fn fchmodat2_nr() -> Option { // fchmodat2 is number 452 on the shared-table architectures and x86; // x32 offsets it, and alpha/mips number it differently or not at all. #[cfg(all(target_arch = "x86_64", target_pointer_width = "32"))] { Some(libc::SYS_fchmodat2) } #[cfg(any( all(target_arch = "x86_64", target_pointer_width = "64"), target_arch = "x86", target_arch = "aarch64", target_arch = "arm", target_arch = "riscv64", target_arch = "riscv32", target_arch = "loongarch64", target_arch = "m68k", target_arch = "csky", target_arch = "powerpc", target_arch = "powerpc64", target_arch = "s390x", target_arch = "sparc", target_arch = "sparc64" ))] { Some(452) } #[cfg(not(any( target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64", target_arch = "arm", target_arch = "riscv64", target_arch = "riscv32", target_arch = "loongarch64", target_arch = "m68k", target_arch = "csky", target_arch = "powerpc", target_arch = "powerpc64", target_arch = "s390x", target_arch = "sparc", target_arch = "sparc64" )))] { None } } #[cfg(target_os = "linux")] fn chmod_nofollow( directory: std::os::fd::BorrowedFd<'_>, name: &std::ffi::OsStr, mode: u32, ) -> io::Result<()> { use std::os::unix::ffi::OsStrExt; let name = std::ffi::CString::new(name.as_bytes()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Invalid file name"))?; // SAFETY: fchmodat2 takes a directory fd, a NUL-terminated leaf name, // a mode, and flags; all are valid here. let number = match fchmodat2_nr() { // Architectures numbering fchmodat2 differently take the open path. Some(number) => number, None => return Err(io::Error::from_raw_os_error(libc::ENOSYS)), }; let result = unsafe { libc::syscall( number, directory.as_raw_fd(), name.as_ptr(), mode, libc::AT_SYMLINK_NOFOLLOW, ) }; if result == 0 { Ok(()) } else { Err(io::Error::last_os_error()) } } #[cfg(unix)] fn owner( uid: Option, gid: Option, ) -> io::Result<(Option, Option)> { // -1 (u32::MAX) means "no change" to the kernel; it must stay explicit null. if uid.is_some_and(|id| id == u32::MAX) || gid.is_some_and(|id| id == u32::MAX) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Invalid user or group id", )); } if uid.is_none() && gid.is_none() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Expected a user or group id", )); } Ok(( uid.map(rustix::fs::Uid::from_raw), gid.map(rustix::fs::Gid::from_raw), )) } pub(super) fn ops(files: Arc) -> Vec { let ops = files.clone(); let chmod = files.clone(); let chown = files.clone(); let lchown = files.clone(); let utime = files.clone(); let lutime = files.clone(); let link = files.clone(); let truncate = files.clone(); let fchmod = files.clone(); let fchown = files.clone(); let futime = files.clone(); let flock = files.clone(); let funlock = files.clone(); vec![ ops.io_ops( "op_file_chmod", "op_file_chmod_sync", move |(path, mode): (String, u32)| { let caps = chmod.caps.clone(); let pending = chmod.pool.run(move || { if mode > 0o7777 { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Invalid file mode", )); } let target = resolve(&caps, &path, false, true).map_err(link_changed)?; let (directory, name) = mutable_entry(&target)?; authorize(&caps, &target, &path, true)?; unchanged(&directory, &name)?; #[cfg(target_os = "linux")] { // fchmodat2 with NOFOLLOW chmods the resolved leaf itself: a // link swapped in after the pre-check fails closed instead // of retargeting, and with no open, unreadable files, // locked directories, and device nodes need no access. match chmod_nofollow(directory.as_fd(), &name, mode) { Ok(()) => Ok(true), Err(error) if error.raw_os_error() == Some(libc::EOPNOTSUPP) => { // A link swapped in after the pre-check fails here // instead of retargeting. Err(io::Error::new( io::ErrorKind::NotFound, "Symbolic link changed or has a missing target", )) } Err(error) if matches!( error.raw_os_error(), Some(libc::ENOSYS) | Some(libc::EPERM) ) => { // Kernels before 6.6 lack fchmodat2, and seccomp // filters may block it; open without following, // preferring read over write. let mut read = OpenOptions::new(); read.read(true).follow(FollowSymlinks::No).nonblock(true); let mut write = OpenOptions::new(); write.write(true).follow(FollowSymlinks::No).nonblock(true); let opened = directory .open_with(&name, &read) .or_else(|_| directory.open_with(&name, &write)) .map_err(link_changed)?; let metadata = opened.metadata()?; if !metadata.is_file() && !metadata.is_dir() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Cannot change mode of non-regular file", )); } rustix::fs::fchmod( opened.as_fd(), rustix::fs::Mode::from_raw_mode(mode), )?; Ok(true) } Err(error) => Err(error), } } #[cfg(all(unix, not(target_os = "linux")))] { rustix::fs::chmodat( directory.as_fd(), Path::new(&name), rustix::fs::Mode::from_raw_mode(mode), rustix::fs::AtFlags::SYMLINK_NOFOLLOW, )?; Ok(true) } #[cfg(not(unix))] { let _ = (&directory, &name); Err(io::Error::new( io::ErrorKind::Unsupported, "not yet implemented: file modes on this platform", )) } }); async move { Ok::<_, Infallible>(receive(pending).await) } }, ), ops.io_ops( "op_file_chown", "op_file_chown_sync", move |(path, uid, gid): (String, Option, Option)| { let caps = chown.caps.clone(); let pending = chown.pool.run(move || { #[cfg(unix)] let owned = owner(uid, gid)?; let target = resolve(&caps, &path, false, true)?; let (directory, name) = mutable_entry(&target)?; authorize(&caps, &target, &path, true)?; unchanged(&directory, &name)?; #[cfg(unix)] { let (uid, gid) = owned; rustix::fs::chownat( directory.as_fd(), Path::new(&name), uid, gid, rustix::fs::AtFlags::SYMLINK_NOFOLLOW, )?; Ok(true) } #[cfg(not(unix))] { let _ = (&directory, &name, uid, gid); Err(io::Error::new( io::ErrorKind::Unsupported, "not yet implemented: file ownership on this platform", )) } }); async move { Ok::<_, Infallible>(receive(pending).await) } }, ), ops.io_ops( "op_file_lchown", "op_file_lchown_sync", move |(path, uid, gid): (String, Option, Option)| { let caps = lchown.caps.clone(); let pending = lchown.pool.run(move || { #[cfg(unix)] let owned = owner(uid, gid)?; // A trailing slash names the referent, like path_stat. let target = resolve(&caps, &path, !follows_link(&path), true)?; let (directory, name) = mutable_entry(&target)?; authorize(&caps, &target, &path, true)?; #[cfg(unix)] { let (uid, gid) = owned; rustix::fs::chownat( directory.as_fd(), Path::new(&name), uid, gid, rustix::fs::AtFlags::SYMLINK_NOFOLLOW, )?; Ok(true) } #[cfg(not(unix))] { let _ = (&directory, &name, uid, gid); Err(io::Error::new( io::ErrorKind::Unsupported, "not yet implemented: file ownership on this platform", )) } }); async move { Ok::<_, Infallible>(receive(pending).await) } }, ), ops.io_ops( "op_file_utime", "op_file_utime_sync", move |(path, asec, anano, msec, mnano): (String, i64, u32, i64, u32)| { let caps = utime.caps.clone(); let pending = utime.pool.run(move || { #[cfg(unix)] let times = rustix::fs::Timestamps { last_access: times(asec, anano)?, last_modification: times(msec, mnano)?, }; let target = resolve(&caps, &path, false, true)?; let (directory, name) = mutable_entry(&target)?; authorize(&caps, &target, &path, true)?; unchanged(&directory, &name)?; #[cfg(unix)] { rustix::fs::utimensat( directory.as_fd(), Path::new(&name), ×, rustix::fs::AtFlags::SYMLINK_NOFOLLOW, )?; Ok(true) } #[cfg(not(unix))] { let _ = (&directory, &name, asec, anano, msec, mnano); Err(io::Error::new( io::ErrorKind::Unsupported, "not yet implemented: file times on this platform", )) } }); async move { Ok::<_, Infallible>(receive(pending).await) } }, ), ops.io_ops( "op_file_lutime", "op_file_lutime_sync", move |(path, asec, anano, msec, mnano): (String, i64, u32, i64, u32)| { let caps = lutime.caps.clone(); let pending = lutime.pool.run(move || { #[cfg(unix)] let times = rustix::fs::Timestamps { last_access: times(asec, anano)?, last_modification: times(msec, mnano)?, }; // A trailing slash names the referent, like path_stat. let target = resolve(&caps, &path, !follows_link(&path), true)?; let (directory, name) = mutable_entry(&target)?; authorize(&caps, &target, &path, true)?; #[cfg(unix)] { rustix::fs::utimensat( directory.as_fd(), Path::new(&name), ×, rustix::fs::AtFlags::SYMLINK_NOFOLLOW, )?; Ok(true) } #[cfg(not(unix))] { let _ = (&directory, &name, asec, anano, msec, mnano); Err(io::Error::new( io::ErrorKind::Unsupported, "not yet implemented: file times on this platform", )) } }); async move { Ok::<_, Infallible>(receive(pending).await) } }, ), ops.io_ops( "op_file_link", "op_file_link_sync", move |(source, destination): (String, String)| { let caps = link.caps.clone(); let pending = link.pool.run(move || { // Link authorization must not race another managed link mutation. let _guard = super::links::MUTATIONS .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let from = resolve(&caps, &source, true, false)?; let to = resolve(&caps, &destination, true, true)?; let (from_dir, from_name) = parent(from.as_path())?; let (to_dir, to_name) = mutable_entry(&to)?; authorize(&caps, &from, &source, false)?; authorize(&caps, &from, &source, true)?; authorize(&caps, &to, &destination, true)?; // linkat would hard-link the link itself past its target checks. if from_dir.symlink_metadata(&from_name)?.is_symlink() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Source is a symbolic link", )); } from_dir.hard_link(from_name, &to_dir, to_name)?; Ok(true) }); async move { Ok::<_, Infallible>(receive(pending).await) } }, ), ops.io_ops( "op_file_path_truncate", "op_file_path_truncate_sync", move |(path, length): (String, u64)| { let caps = truncate.caps.clone(); let pending = truncate.pool.run(move || { if length > SAFE_INTEGER { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Unsafe truncate length", )); } let target = resolve(&caps, &path, false, true).map_err(link_changed)?; let (directory, name) = mutable_entry(&target)?; authorize(&caps, &target, &path, true)?; unchanged(&directory, &name)?; let mut flags = OpenOptions::new(); flags.write(true).follow(FollowSymlinks::No).nonblock(true); let opened = directory.open_with(name, &flags)?; if !opened.metadata()?.is_file() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Cannot truncate non-regular file", )); } opened.set_len(length)?; Ok(true) }); async move { Ok::<_, Infallible>(receive(pending).await) } }, ), ops.io_ops( "op_file_fchmod", "op_file_fchmod_sync", move |(id, mode): (u64, u32)| { let pending = fchmod.submit(id, move |opened| { if mode > 0o7777 { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Invalid file mode", )); } opened.check(true)?; #[cfg(unix)] { rustix::fs::fchmod( opened.file.as_fd(), rustix::fs::Mode::from_raw_mode(mode), )?; Ok(true) } #[cfg(not(unix))] { Err(io::Error::new( io::ErrorKind::Unsupported, "not yet implemented: file modes on this platform", )) } }); async move { Ok::<_, Infallible>(receive(pending).await) } }, ), ops.io_ops( "op_file_fchown", "op_file_fchown_sync", move |(id, uid, gid): (u64, Option, Option)| { let pending = fchown.submit(id, move |opened| { #[cfg(unix)] let owned = owner(uid, gid)?; opened.check(true)?; #[cfg(unix)] { let (uid, gid) = owned; rustix::fs::fchown(opened.file.as_fd(), uid, gid)?; Ok(true) } #[cfg(not(unix))] { let _ = (uid, gid); Err(io::Error::new( io::ErrorKind::Unsupported, "not yet implemented: file ownership on this platform", )) } }); async move { Ok::<_, Infallible>(receive(pending).await) } }, ), ops.io_ops( "op_file_futime", "op_file_futime_sync", move |(id, asec, anano, msec, mnano): (u64, i64, u32, i64, u32)| { let pending = futime.submit(id, move |opened| { #[cfg(unix)] let times = rustix::fs::Timestamps { last_access: times(asec, anano)?, last_modification: times(msec, mnano)?, }; opened.check(true)?; #[cfg(unix)] { rustix::fs::futimens(opened.file.as_fd(), ×)?; Ok(true) } #[cfg(not(unix))] { let _ = (asec, anano, msec, mnano); Err(io::Error::new( io::ErrorKind::Unsupported, "not yet implemented: file times on this platform", )) } }); async move { Ok::<_, Infallible>(receive(pending).await) } }, ), ops.io_ops( "op_file_flock", "op_file_flock_sync", move |(id, exclusive): (u64, bool)| { let state = flock.clone(); async move { #[cfg(unix)] { // Only nonblocking attempts run on the handle queue, so a // waiting lock holds neither a pool thread nor the queue. let mut wait = std::time::Duration::from_millis(5); loop { let pending = state.submit(id, move |opened| { if exclusive { opened.check(true)?; } else { opened.check(false)?; } rustix::fs::flock( opened.file.as_fd(), if exclusive { rustix::fs::FlockOperation::NonBlockingLockExclusive } else { rustix::fs::FlockOperation::NonBlockingLockShared }, )?; Ok(true) }); match receive(pending).await { (value @ Some(_), None) => { // A closed handle releases its locks; never // report success past close. let live = state.submit(id, |_| Ok::<_, io::Error>(true)); match receive(live).await { (Some(_), None) => { return Ok::<_, Infallible>((value, None)); } (_, error) => return Ok((None, error)), } } (None, Some(("Busy", _))) => { let delay = { let _enter = state.pool.runtime.enter(); tokio::time::sleep(wait) }; delay.await; wait = (wait * 2).min(std::time::Duration::from_millis(100)); } done => return Ok(done), } } } #[cfg(not(unix))] { let pending = state.submit(id, move |opened| { if exclusive { opened.check(true)?; } else { opened.check(false)?; } let _ = exclusive; Err::(io::Error::new( io::ErrorKind::Unsupported, "not yet implemented: file locking on this platform", )) }); Ok(receive(pending).await) } } }, ), ops.io_ops("op_file_funlock", "op_file_funlock_sync", move |id: u64| { // Releasing a lock grants nothing, so any live handle may unlock. let pending = funlock.submit(id, |opened| { #[cfg(unix)] { rustix::fs::flock(opened.file.as_fd(), rustix::fs::FlockOperation::Unlock)?; Ok(true) } #[cfg(not(unix))] { let _ = opened; Err(io::Error::new( io::ErrorKind::Unsupported, "not yet implemented: file locking on this platform", )) } }); async move { Ok::<_, Infallible>(receive(pending).await) } }), ] .into_iter() .flatten() .collect() } #[cfg(all(test, unix))] mod tests { use super::*; #[test] fn leaf_check_rejects_links_swapped_in_after_resolve() { let root = tempfile::tempdir().unwrap(); std::fs::write(root.path().join("leaf"), b"data").unwrap(); let caps = Capabilities::new(avril_caps::Initial::new(false), root.path().to_owned()); let target = resolve(&caps, "leaf", false, true).unwrap(); let (directory, name) = parent(target.as_path()).unwrap(); unchanged(&directory, &name).unwrap(); std::fs::remove_file(root.path().join("leaf")).unwrap(); std::os::unix::fs::symlink("elsewhere", root.path().join("leaf")).unwrap(); let error = unchanged(&directory, &name).unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::NotFound); assert_eq!( error.to_string(), "Symbolic link changed or has a missing target" ); } }