diff --git a/litebox/src/fd/mod.rs b/litebox/src/fd/mod.rs index f3e3ebf98..6f988ce76 100644 --- a/litebox/src/fd/mod.rs +++ b/litebox/src/fd/mod.rs @@ -525,6 +525,26 @@ pub struct EntryHandle EntryHandle { + /// Get the entry behind this handle. + /// + /// Note: this grabs a lock, thus the result should not be held for too long, to prevent + /// deadlocks. Prefer using [`Self::with_entry`] when possible, to make life easier. + pub fn get_entry( + &self, + ) -> impl core::ops::Deref + use<'_, Platform, Subsystem> { + crate::sync::RwLockReadGuard::map(self.0.read(), |e| e.as_subsystem::()) + } + + /// Get the entry behind this handle mutably. + /// + /// Note: this grabs a lock, thus the result should not be held for too long, to prevent + /// deadlocks. Prefer using [`Self::with_entry_mut`] when possible, to make life easier. + pub fn get_entry_mut( + &self, + ) -> impl core::ops::DerefMut + use<'_, Platform, Subsystem> { + crate::sync::RwLockWriteGuard::map(self.0.write(), |e| e.as_subsystem_mut::()) + } + pub fn with_entry(&self, f: impl FnOnce(&Subsystem::Entry) -> R) -> R { f(self.0.read().as_subsystem::()) } diff --git a/litebox/src/fs/backend.rs b/litebox/src/fs/backend.rs new file mode 100644 index 000000000..7cc698500 --- /dev/null +++ b/litebox/src/fs/backend.rs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! [`Backend`] for filesystems supported by [`super::resolver`] + +use alloc::vec::Vec; + +use super::errors::{ + ChmodError, ChownError, FileStatusError, MkdirError, OpenError, ReadDirError, ReadError, + RmdirError, TruncateError, UnlinkError, WalkError, WriteError, +}; +use super::{DirEntry, FileStatus, Mode, OFlags, UserInfo}; + +/// How a backend file handle participates in seek. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum SeekBehavior { + /// Seek should fail with `SeekError::NonSeekable`. + NonSeekable, + /// Seek should always succeed and report offset zero. + ZeroPosition, + /// Seek should be resolved against normal resolver-owned file position state. + PositionBased, +} + +/// A private module (private to the filesystem subsystem), to help support writing sealed traits. +/// This module should _itself_ not be made public. +pub(super) mod private { + /// A trait to help seal the main `Backend` trait. + /// + /// This trait is explicitly public, but unnameable, thereby preventing code outside this crate + /// from implementing this trait. + /// + /// XXX(jayb): We may (in the future) de-restrict backends to allow other crates to also + /// introduce backends, but while we migrate the file-system subsystem over from the old + /// approach to the new one, we will not allow other crates to introduce backends. + pub trait Sealed {} +} + +/// A backend that can be used to support a (full or subset of) a LiteBox filesystem. +pub trait Backend: private::Sealed + Send + Sync + 'static { + /// Supporting walk through the backend + type WalkingDirHandle<'a>: 'a; + + /// An owned handle to an open file + type FileHandle: Clone + Send + Sync + 'static; + + /// An owned handle to an open directory + type DirHandle: Clone + Send + Sync + 'static; + + /// Obtain access to the root directory of the backend. + fn root(&self) -> Self::WalkingDirHandle<'_>; + + /// Walk one or more `components` starting from the `from` handle. + /// + /// `components` must be non-empty. Backends may panic if called with an empty slice. + /// + /// This function explicitly does not walk into files. If the next component exists but is not a + /// directory, the backend should stop at its parent and return + /// `WalkStopReason::StoppedAtNonDirectory`. + fn walk_directories<'a>( + &'a self, + from: Self::WalkingDirHandle<'a>, + components: &[&str], + ) -> Result>, WalkError>; + + /// Take an owned handle to a `dir` found via a walk. + fn owned_dir_at(&self, dir: Self::WalkingDirHandle<'_>) -> Self::DirHandle; + + /// Obtain a walking handle to an existing owned dir. + /// + /// This operation always succeeds and returns a `Some` _unless_ on a networked backend where + /// owned handles can go stale. + /// + /// XXX(jayb): We will likely migrate away from `Option` here when we do a bit of an overhaul of + /// the `errors` module in order to more consistently support stale errors everywhere. + fn walking_dir_at<'a>(&'a self, dir: &Self::DirHandle) -> Option>; + + /// Open an (existing) file at `dir`. + /// + /// To create a file, you need [`Self::create_file_at`]. + fn open_file_at( + &self, + dir: Self::WalkingDirHandle<'_>, + name: &str, + flags: OFlags, + ) -> Result, OpenError>; + + /// Read directory entries at `dir`. + fn list_dir_at(&self, handle: Self::DirHandle) -> Result, ReadDirError>; + + /// Read at `offset` into `buf`, returning the number of bytes read. + /// + /// Backends do not have an internal notion of offsets; instead the resolver maintains offsets + /// as needed. For files with non-position-based [`SeekBehavior`], such as `stdin`, the resolver + /// passes zero and the backend should ignore the offset. + fn read(&self, h: &Self::FileHandle, buf: &mut [u8], offset: usize) + -> Result; + + /// Optional performance hook: get static backing data for a file, if available and supported. + /// + /// This method returns the (entire) underlying static byte slice if the file's contents are + /// backed by borrowed static data. + /// + /// Returns `None` if indicating no static backing data is available/supported. + #[expect(unused_variables, reason = "default body, non-underscored param names")] + fn get_static_backing_data(&self, h: &Self::FileHandle) -> Option<&'static [u8]> { + None + } + + /// Write `buf` into the file, based on `offset`, returning the number of bytes written. + /// + /// See [`Self::read`] on internal offset storage for backends. + // XXX(jayb): I need to think more about how we set up some sort of "intend to write" flag that + // we can use to obtain the ability to support writes to an `O_APPEND` file, but without making + // it ugly on the interface side here. It would be very ugly for us to pass in extra flags, or + // indeed even need to maintain/handle seeking on every backend; mostly we need some sort of + // nicer locking discipline, but I don't want to block the MVP for this just yet. + fn write(&self, h: &Self::FileHandle, buf: &[u8], offset: usize) -> Result; + + /// Truncate the file to the specified length. + /// + /// If shorter than existing size, extra data is lost. If longer than existing size, resize by + /// adding `\0`s. + fn truncate(&self, h: &Self::FileHandle, length: usize) -> Result<(), TruncateError>; + + /// Describe seek behavior for an open file handle. + fn seek_behavior(&self, h: &Self::FileHandle) -> SeekBehavior; + + /// Status of an open file handle. + fn file_status(&self, h: &Self::FileHandle) -> Result; + + /// Status of an open directory handle. + fn dir_status(&self, h: &Self::DirHandle) -> Result; + + /// Create a new file at `parent` with the given `name` and `mode`. + fn create_file_at( + &self, + dir: Self::DirHandle, + name: &str, + mode: Mode, + ) -> Result; + + /// Create a new directory at `parent` with the given `name` and `mode`. + fn mkdir_at( + &self, + dir: Self::DirHandle, + name: &str, + mode: Mode, + ) -> Result; + + /// Remove the file `name` at `parent`. + fn unlink_at(&self, dir: Self::DirHandle, name: &str) -> Result<(), UnlinkError>; + + /// Remove the directory `name` at `parent`. + // XXX(jayb): I don't like that unlink and rmdir exist separately, we should probably merge them. + fn rmdir_at(&self, dir: Self::DirHandle, name: &str) -> Result<(), RmdirError>; + + /// Update the permissions for the file/dir `name` at `parent`. + fn chmod_at(&self, dir: Self::DirHandle, name: &str, mode: Mode) -> Result<(), ChmodError>; + + /// Update the owner/group for the file/dir `name` at `parent`. + fn chown_at( + &self, + dir: Self::DirHandle, + name: &str, + user: Option, + group: Option, + ) -> Result<(), ChownError>; +} + +/// A successful walk of directories through the backend +pub struct WalkOutcome { + /// A component per walked element. + /// + /// This vector can be empty when the first input component is a non-directory element. + /// + /// Components are in natural order (i.e., the last element is the last component visited thus + /// far). + pub(super) components: Vec, + /// The last handle of the walk thus far. + /// + pub(super) last: Walking, + /// Why this walk stopped at `last`. + pub(super) stop_reason: WalkStopReason, +} + +/// Why a backend directory walk stopped. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[must_use] +pub(super) enum WalkStopReason { + /// All requested components were walked, and `last` is the requested directory. + CompleteDirectory, + /// The next requested component exists but is not a directory; `last` is its parent directory. + StoppedAtNonDirectory, + /// The backend stopped early; the resolver should continue walking from `last`. + #[expect(dead_code, reason = "no backend currently returns partial walks")] + Continue, +} + +/// A backend item plus permission metadata for resolver-side checks. +pub struct Permissioned { + pub(super) item: H, + pub(super) permissions: PermissionCheck, +} + +/// Whether a resolved component should be permission-checked by the resolver. +#[derive(Clone, Debug)] +#[must_use] +pub(super) enum PermissionCheck { + /// The backend is self-enforcing permissions for this item. + ByBackend, + /// The resolver should check this permission metadata. + #[expect( + dead_code, + reason = "only backend-self-enforced devices exist during migration" + )] + ByResolver(PermissionInfo), +} + +/// Per-component status returned by a backend walk +#[derive(Clone, Debug)] +#[must_use] +pub(super) struct WalkedComponent { + /// How permissions for this component should be checked. + pub(super) permissions: PermissionCheck, +} + +/// Permission information for a particular component of the walk. +#[derive(Clone, Debug)] +pub(super) struct PermissionInfo { + pub(super) mode: Mode, + pub(super) owner: UserInfo, +} diff --git a/litebox/src/fs/devices.rs b/litebox/src/fs/devices.rs index 90e715230..a54edc960 100644 --- a/litebox/src/fs/devices.rs +++ b/litebox/src/fs/devices.rs @@ -1,24 +1,29 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -//! Device provider for LiteBox including: -//! 1. Standard input/output devices. -//! 2. /dev/null device. +//! Unix-y devices [`super::backend::Backend`]. +//! +//! Provides `/dev/{stdin,stdout,null,urandom,...}`. + +// XXX(jayb): soon this will switch to just being {stdin,stdout,...}, so that it is _mounted_ at +// `/dev/` rather than associated at `/`, but that will be later. use alloc::string::String; +use alloc::vec::Vec; + +use crate::LiteBox; +use crate::sync::RawSyncPrimitivesProvider; -use crate::{ - LiteBox, - fs::{ - FileStatus, FileType, Mode, NodeInfo, OFlags, SeekWhence, UserInfo, - errors::{ - ChmodError, ChownError, CloseError, FileStatusError, MkdirError, OpenError, PathError, - ReadDirError, ReadError, RmdirError, SeekError, TruncateError, UnlinkError, WriteError, - }, - }, - path::Arg, - platform::{StdioOutStream, StdioReadError, StdioWriteError}, +use super::backend::{ + Backend, PermissionCheck, Permissioned, SeekBehavior, WalkOutcome, WalkStopReason, + WalkedComponent, }; +use super::errors::{ + ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, + ReadError, RmdirError, TruncateError, UnlinkError, WalkError, WriteError, +}; +use super::inode_allocator::InodeAllocator; +use super::{DirEntry, FileStatus, FileType, Mode, NodeInfo, OFlags, UserInfo}; /// Block size for stdio devices const STDIO_BLOCK_SIZE: usize = 1024; @@ -34,6 +39,7 @@ const URANDOM_BLOCK_SIZE: usize = 0x1000; /// name=/dev/stdout dev=64 ino=9 rdev=34822 /// name=/dev/stderr dev=64 ino=9 rdev=34822 /// ``` +// XXX(jayb): Should we be pulling the device names and such from the inode allocator? const STDIO_NODE_INFO: NodeInfo = NodeInfo { dev: 64, ino: 9, @@ -53,7 +59,8 @@ const URANDOM_NODE_INFO: NodeInfo = NodeInfo { // major=1, minor=9 rdev: core::num::NonZeroUsize::new(0x109), }; -#[derive(Debug, Clone, Copy)] + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Device { Stdin, Stdout, @@ -62,60 +69,21 @@ enum Device { URandom, } -/// A backing implementation for [`FileSystem`](super::FileSystem). -/// -/// This provider provides only `/dev/stdin`, `/dev/stdout`, and `/dev/stderr`. -pub struct FileSystem< - Platform: crate::sync::RawSyncPrimitivesProvider + crate::platform::StdioProvider + 'static, -> { - litebox: LiteBox, - // cwd invariant: always ends with a `/` - current_working_dir: String, -} - -impl - FileSystem -{ - /// Construct a new `FileSystem` instance - /// - /// This function is expected to only be invoked once per platform, as an initialiation step, - /// and the created `FileSystem` handle is expected to be shared across all usage over the - /// system. - #[must_use] - pub fn new(litebox: &LiteBox) -> Self { - Self { - litebox: litebox.clone(), - current_working_dir: "/".into(), - } - } -} - -impl - super::private::Sealed for FileSystem -{ -} +impl Device { + const ALL: &'static [(&'static str, Device)] = &[ + ("stdin", Device::Stdin), + ("stdout", Device::Stdout), + ("stderr", Device::Stderr), + ("null", Device::Null), + ("urandom", Device::URandom), + ]; -impl - FileSystem -{ - // Gives the absolute path for `path`, resolving any `.` or `..`s, and making sure to account - // for any relative paths from current working directory. - // - // Note: does NOT account for symlinks. - fn absolute_path(&self, path: impl Arg) -> Result { - assert!(self.current_working_dir.ends_with('/')); - let path = path.as_rust_str()?; - if path.starts_with('/') { - // Absolute path - Ok(path.normalized()?) - } else { - // Relative path - Ok((self.current_working_dir.clone() + path.as_rust_str()?).normalized()?) - } + fn from_name(name: &str) -> Option { + Self::ALL.iter().find(|(n, _)| *n == name).map(|(_, d)| *d) } - fn device_file_status(device: Device) -> FileStatus { - match device { + fn file_status(self) -> FileStatus { + match self { Device::Stdin | Device::Stdout | Device::Stderr => FileStatus { file_type: FileType::CharacterDevice, mode: Mode::RUSR | Mode::WUSR | Mode::WGRP, @@ -144,135 +112,226 @@ impl +where + Platform: RawSyncPrimitivesProvider + crate::platform::StdioProvider - + crate::platform::CrngProvider, -> super::FileSystem for FileSystem + + crate::platform::CrngProvider + + 'static, { - fn open( - &self, - path: impl Arg, - flags: OFlags, - mode: Mode, - ) -> Result, OpenError> { - let open_directory = flags.contains(OFlags::DIRECTORY); - let flags = flags - OFlags::DIRECTORY; - let nonblocking = flags.contains(OFlags::NONBLOCK); - let flags = flags - OFlags::NONBLOCK; - // ignore NOCTTY, NOFOLLOW, and APPEND - let flags = flags - OFlags::NOCTTY - OFlags::NOFOLLOW - OFlags::APPEND; - let truncate = flags.contains(OFlags::TRUNC); - let flags = flags - OFlags::TRUNC; - let path = self.absolute_path(path)?; - let device = match path.as_str() { - "/dev/stdin" => { - if flags == OFlags::RDONLY && mode.is_empty() { - Device::Stdin - } else { - unimplemented!() + litebox: LiteBox, + /// Stable inode info for `/dev`. + dev_dir_inode: NodeInfo, + _alloc: InodeAllocator, +} + +impl Devices +where + Platform: RawSyncPrimitivesProvider + + crate::platform::StdioProvider + + crate::platform::CrngProvider + + 'static, +{ + /// Construct a new `Devices` backend. + #[must_use] + pub(crate) fn new(litebox: &LiteBox, allocator: InodeAllocator) -> Self { + let dev_dir_inode = allocator.next(); + Self { + litebox: litebox.clone(), + dev_dir_inode, + _alloc: allocator, + } + } + + /// Migration helper. This function will disappear soon. + #[must_use] + pub fn migration_helper_standalone_new(litebox: &LiteBox) -> Self { + Self::new(litebox, InodeAllocator::standalone()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Location { + Root, + Dev, +} + +/// Owned file handle; identifies which device backs this fd. +#[derive(Debug, Clone, Copy)] +pub struct DeviceFileHandle { + device: Device, +} + +/// Directory handle +// For devices, since no borrows are needed, we reuse this struct for both the walking handles as +// well as the dir handles. +#[derive(Debug, Clone, Copy)] +pub struct DeviceDirHandle { + location: Location, +} + +impl super::backend::private::Sealed for Devices where + Platform: RawSyncPrimitivesProvider + + crate::platform::StdioProvider + + crate::platform::CrngProvider + + 'static +{ +} + +impl Backend for Devices +where + Platform: RawSyncPrimitivesProvider + + crate::platform::StdioProvider + + crate::platform::CrngProvider + + 'static, +{ + type WalkingDirHandle<'a> + = DeviceDirHandle + where + Self: 'a; + type FileHandle = DeviceFileHandle; + type DirHandle = DeviceDirHandle; + + fn root(&self) -> Self::WalkingDirHandle<'_> { + DeviceDirHandle { + location: Location::Root, + } + } + + fn walk_directories<'a>( + &'a self, + from: Self::WalkingDirHandle<'a>, + components: &[&str], + ) -> Result>, WalkError> { + // This backend only exposes one directory below root. Device files are + // final path targets, so directory walking must stop before them. + let mut location = from.location; + let mut walked_components: Vec = Vec::with_capacity(components.len()); + for &c in components { + match (location, c) { + (Location::Root, "dev") => { + walked_components.push(WalkedComponent { + permissions: PermissionCheck::ByBackend, + }); + location = Location::Dev; } - } - "/dev/stdout" => { - if flags == OFlags::WRONLY && mode.is_empty() { - Device::Stdout - } else { - unimplemented!() + (Location::Dev, name) if Device::from_name(name).is_some() => { + return Ok(WalkOutcome { + components: walked_components, + last: DeviceDirHandle { location }, + stop_reason: WalkStopReason::StoppedAtNonDirectory, + }); } - } - "/dev/stderr" => { - if flags == OFlags::WRONLY && mode.is_empty() { - Device::Stderr - } else { - unimplemented!() + _ => { + return Err(WalkError::PathError(PathError::NoSuchFileOrDirectory)); } } - "/dev/null" => Device::Null, - "/dev/urandom" => Device::URandom, - _ => return Err(OpenError::PathError(PathError::NoSuchFileOrDirectory)), - }; - if open_directory { + } + Ok(WalkOutcome { + components: walked_components, + last: DeviceDirHandle { location }, + stop_reason: WalkStopReason::CompleteDirectory, + }) + } + + fn owned_dir_at(&self, dir: Self::WalkingDirHandle<'_>) -> Self::DirHandle { + dir + } + + fn walking_dir_at<'a>(&'a self, dir: &Self::DirHandle) -> Option> { + Some(*dir) + } + + fn open_file_at( + &self, + dir: Self::WalkingDirHandle<'_>, + name: &str, + flags: OFlags, + ) -> Result, OpenError> { + if dir.location != Location::Dev { + return Err(OpenError::PathError(PathError::NoSuchFileOrDirectory)); + } + let device = Device::from_name(name) + .ok_or(OpenError::PathError(PathError::NoSuchFileOrDirectory))?; + + if flags.contains(OFlags::DIRECTORY) { return Err(OpenError::PathError(PathError::ComponentNotADirectory)); } - if nonblocking + if flags.contains(OFlags::NONBLOCK) && matches!( device, - Device::Stdin | Device::Stderr | Device::Stdout | Device::URandom + Device::Stdin | Device::Stdout | Device::Stderr | Device::URandom ) { - unimplemented!("Non-blocking I/O is not supported for {:?}", device); + unimplemented!("Non-blocking I/O is not yet supported for {:?}", device); } - let fd = self.litebox.descriptor_table_mut().insert(device); - if truncate { + + if flags.contains(OFlags::TRUNC) { // Note: matching Linux behavior, this does not actually perform any truncation, and // instead, it is silently ignored if you attempt to truncate upon opening stdio. - assert!(matches!( - self.truncate(&fd, 0, true), + debug_assert!(matches!( + self.truncate(&DeviceFileHandle { device }, 0), Err(TruncateError::IsTerminalDevice) )); } - Ok(fd) + + Ok(Permissioned { + item: DeviceFileHandle { device }, + permissions: PermissionCheck::ByBackend, + }) } - fn close(&self, fd: &FileFd) -> Result<(), CloseError> { - self.litebox.descriptor_table_mut().remove(fd); - Ok(()) + fn list_dir_at(&self, handle: Self::DirHandle) -> Result, ReadDirError> { + match handle.location { + Location::Root => Ok(alloc::vec![DirEntry { + name: String::from("dev"), + file_type: FileType::Directory, + ino_info: Some(self.dev_dir_inode.clone()), + }]), + Location::Dev => Ok(Device::ALL + .iter() + .map(|(n, d)| DirEntry { + name: String::from(*n), + file_type: FileType::CharacterDevice, + ino_info: Some(d.file_status().node_info), + }) + .collect()), + } } fn read( &self, - fd: &FileFd, + h: &Self::FileHandle, buf: &mut [u8], - offset: Option, + _offset: usize, ) -> Result { - match &self - .litebox - .descriptor_table() - .get_entry(fd) - .ok_or(ReadError::ClosedFd)? - .entry - { - Device::Stdin => {} - Device::Stdout | Device::Stderr => { - return Err(ReadError::NotForReading); - } + match h.device { + Device::Stdin => self + .litebox + .x + .platform + .read_from_stdin(buf) + .map_err(|e| match e { + crate::platform::StdioReadError::Closed => ReadError::Io, + }), + Device::Stdout | Device::Stderr => Err(ReadError::NotForReading), Device::Null => { // /dev/null read returns EOF - return Ok(0); + Ok(0) } Device::URandom => { self.litebox.x.platform.fill_bytes_crng(buf); - return Ok(buf.len()); + Ok(buf.len()) } } - if offset.is_some() { - unimplemented!() - } - self.litebox - .x - .platform - .read_from_stdin(buf) - .map_err(|e| match e { - StdioReadError::Closed => unimplemented!(), - }) } - fn write( - &self, - fd: &FileFd, - buf: &[u8], - offset: Option, - ) -> Result { - let stream = match &self - .litebox - .descriptor_table() - .get_entry(fd) - .ok_or(WriteError::ClosedFd)? - .entry - { + fn write(&self, h: &Self::FileHandle, buf: &[u8], _offset: usize) -> Result { + let stream = match h.device { Device::Stdin => return Err(WriteError::NotForWriting), - Device::Stdout => StdioOutStream::Stdout, - Device::Stderr => StdioOutStream::Stderr, + Device::Stdout => crate::platform::StdioOutStream::Stdout, + Device::Stderr => crate::platform::StdioOutStream::Stderr, Device::Null | Device::URandom => { // /dev/null discards data: report as if written fully // @@ -285,112 +344,92 @@ impl< return Ok(buf.len()); } }; - if offset.is_some() { - unimplemented!() - } self.litebox .x .platform .write_to(stream, buf) .map_err(|e| match e { - StdioWriteError::Closed => unimplemented!(), + crate::platform::StdioWriteError::Closed => WriteError::Io, }) } - fn seek( - &self, - fd: &FileFd, - _offset: isize, - _whence: SeekWhence, - ) -> Result { - match &self - .litebox - .descriptor_table() - .get_entry(fd) - .ok_or(SeekError::ClosedFd)? - .entry - { - Device::Stdin | Device::Stdout | Device::Stderr => Err(SeekError::NonSeekable), - Device::Null | Device::URandom => { - // Linux allows lseek on /dev/null and returns position 0 (or sets to length 0). - Ok(0) - } - } - } - - fn truncate( - &self, - _fd: &FileFd, - _length: usize, - _reset_offset: bool, - ) -> Result<(), TruncateError> { + fn truncate(&self, _h: &Self::FileHandle, _len: usize) -> Result<(), TruncateError> { Err(TruncateError::IsTerminalDevice) } - #[expect(unused_variables, reason = "unimplemented")] - fn chmod(&self, path: impl Arg, mode: Mode) -> Result<(), ChmodError> { - unimplemented!() + fn seek_behavior(&self, h: &Self::FileHandle) -> SeekBehavior { + match h.device { + Device::Stdin | Device::Stdout | Device::Stderr => SeekBehavior::NonSeekable, + Device::Null | Device::URandom => SeekBehavior::ZeroPosition, + } } - #[expect(unused_variables, reason = "unimplemented")] - fn chown( - &self, - path: impl Arg, - user: Option, - group: Option, - ) -> Result<(), ChownError> { - unimplemented!() + fn file_status(&self, h: &Self::FileHandle) -> Result { + Ok(h.device.file_status()) } - #[expect(unused_variables, reason = "unimplemented")] - fn unlink(&self, path: impl Arg) -> Result<(), UnlinkError> { - unimplemented!() + fn dir_status(&self, h: &Self::DirHandle) -> Result { + Ok(match h.location { + Location::Root => FileStatus { + file_type: FileType::Directory, + mode: Mode::RWXU | Mode::RGRP | Mode::XGRP | Mode::ROTH | Mode::XOTH, + size: super::DEFAULT_DIRECTORY_SIZE, + owner: UserInfo::ROOT, + node_info: NodeInfo { + dev: self.dev_dir_inode.dev, + ino: 0, + rdev: None, + }, + blksize: super::DEFAULT_DIRECTORY_SIZE, + }, + Location::Dev => FileStatus { + file_type: FileType::Directory, + mode: Mode::RWXU | Mode::RGRP | Mode::XGRP | Mode::ROTH | Mode::XOTH, + size: super::DEFAULT_DIRECTORY_SIZE, + owner: UserInfo::ROOT, + node_info: self.dev_dir_inode.clone(), + blksize: super::DEFAULT_DIRECTORY_SIZE, + }, + }) } - #[expect(unused_variables, reason = "unimplemented")] - fn mkdir(&self, path: impl Arg, mode: Mode) -> Result<(), MkdirError> { - unimplemented!() + fn create_file_at( + &self, + _dir: Self::DirHandle, + _name: &str, + _mode: Mode, + ) -> Result { + Err(OpenError::ReadOnlyFileSystem) } - #[expect(unused_variables, reason = "unimplemented")] - fn rmdir(&self, path: impl Arg) -> Result<(), RmdirError> { - unimplemented!() + fn mkdir_at( + &self, + _dir: Self::DirHandle, + _name: &str, + _mode: Mode, + ) -> Result { + Err(MkdirError::ReadOnlyFileSystem) } - fn read_dir( - &self, - _fd: &FileFd, - ) -> Result, ReadDirError> { - Err(ReadDirError::NotADirectory) + fn unlink_at(&self, _dir: Self::DirHandle, _name: &str) -> Result<(), UnlinkError> { + Err(UnlinkError::ReadOnlyFileSystem) } - fn file_status(&self, path: impl Arg) -> Result { - let path = self.absolute_path(path)?; - let device = match path.as_str() { - "/dev/stdin" => Device::Stdin, - "/dev/stdout" => Device::Stdout, - "/dev/stderr" => Device::Stderr, - "/dev/null" => Device::Null, - "/dev/urandom" => Device::URandom, - _ => return Err(FileStatusError::PathError(PathError::NoSuchFileOrDirectory)), - }; - Ok(Self::device_file_status(device)) + fn rmdir_at(&self, _dir: Self::DirHandle, _name: &str) -> Result<(), RmdirError> { + Err(RmdirError::ReadOnlyFileSystem) } - fn fd_file_status(&self, fd: &FileFd) -> Result { - let device = self - .litebox - .descriptor_table() - .get_entry(fd) - .ok_or(FileStatusError::ClosedFd)? - .entry; - Ok(Self::device_file_status(device)) + fn chmod_at(&self, _dir: Self::DirHandle, _name: &str, _mode: Mode) -> Result<(), ChmodError> { + Err(ChmodError::ReadOnlyFileSystem) } -} -crate::fd::enable_fds_for_subsystem! { - @ Platform: { crate::sync::RawSyncPrimitivesProvider + crate::platform::StdioProvider }; - FileSystem; - Device; - -> FileFd; + fn chown_at( + &self, + _dir: Self::DirHandle, + _name: &str, + _user: Option, + _group: Option, + ) -> Result<(), ChownError> { + Err(ChownError::ReadOnlyFileSystem) + } } diff --git a/litebox/src/fs/errors.rs b/litebox/src/fs/errors.rs index 3e5846596..e74b331c3 100644 --- a/litebox/src/fs/errors.rs +++ b/litebox/src/fs/errors.rs @@ -11,6 +11,9 @@ use super::FileSystem; use thiserror::Error; +// XXX(jayb): We probably need to introduce a notion of `Stale` to many/most of these errors, in +// order to more correctly support network-attached file systems. + /// Possible errors from [`FileSystem::open`] #[non_exhaustive] #[derive(Error, Debug)] @@ -207,6 +210,16 @@ pub enum FileStatusError { PathError(#[from] PathError), } +/// Possible errors from a backend walk +#[non_exhaustive] +#[derive(Error, Debug)] +pub enum WalkError { + #[error("I/O error")] + Io, + #[error(transparent)] + PathError(#[from] PathError), +} + /// Possible errors in any file-system function due to path errors. #[derive(Error, Debug)] pub enum PathError { diff --git a/litebox/src/fs/inode_allocator.rs b/litebox/src/fs/inode_allocator.rs new file mode 100644 index 000000000..f58ab49de --- /dev/null +++ b/litebox/src/fs/inode_allocator.rs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use core::sync::atomic::{AtomicU64, Ordering}; + +use super::NodeInfo; + +/// Allocator for `(device_id, inode)` pairs scoped to one backend instance. +#[derive(Debug)] +pub struct InodeAllocator { + device_id: u64, + counter: AtomicU64, +} + +impl InodeAllocator { + /// Construct an allocator for a specific `device_id`. The composer hands + /// out unique `device_id`s per mounted backend. + #[must_use] + pub fn for_device(device_id: u64) -> Self { + Self { + device_id, + counter: AtomicU64::new(0), + } + } + + /// Standalone allocator using the back-compat sentinel `device_id`. + /// + /// This should (eventually) disappear once we have better device ID allocation setup. + #[must_use] + pub fn standalone() -> Self { + // `b"Stnd".hex()` + const STANDALONE_DEVICE_ID: u64 = 0x53746e64; + Self::for_device(STANDALONE_DEVICE_ID) + } + + /// Allocate a fresh `NodeInfo` for a new entry on this backend. + #[must_use] + pub fn next(&self) -> NodeInfo { + let ino = self.counter.fetch_add(1, Ordering::Relaxed); + NodeInfo { + dev: self.device_id.try_into().unwrap(), + ino: ino.try_into().unwrap(), + rdev: None, + } + } +} diff --git a/litebox/src/fs/mod.rs b/litebox/src/fs/mod.rs index 6083a4086..400173883 100644 --- a/litebox/src/fs/mod.rs +++ b/litebox/src/fs/mod.rs @@ -12,11 +12,14 @@ use bitflags::bitflags; use core::ffi::c_uint; use core::num::NonZeroUsize; +pub mod backend; pub mod devices; pub mod errors; pub mod in_mem; +pub(crate) mod inode_allocator; pub mod layered; pub mod nine_p; +pub mod resolver; pub mod tar_ro; #[cfg(test)] diff --git a/litebox/src/fs/resolver.rs b/litebox/src/fs/resolver.rs new file mode 100644 index 000000000..1541a2914 --- /dev/null +++ b/litebox/src/fs/resolver.rs @@ -0,0 +1,768 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! The path-management/permissions/... layer, that sits above [`super::backend`]. + +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; + +use crate::fs::UserInfo; +use crate::path::Arg; +use crate::{LiteBox, fd::TypedFd, sync}; + +use super::errors::{ + ChmodError, ChownError, CloseError, FileStatusError, MkdirError, OpenError, PathError, + ReadDirError, ReadError, RmdirError, SeekError, TruncateError, UnlinkError, WalkError, + WriteError, +}; +use super::{ + FileType, Mode, OFlags, + backend::{PermissionCheck, PermissionInfo, SeekBehavior, WalkOutcome, WalkStopReason}, +}; + +/// The north-facing filesystem entry point, generic over a [`Backend`](super::backend::Backend). +/// +/// The resolver _itself_ maintains no state; all state is maintained either by the backend or the +/// [`Context`]. The user may choose to store the [`Context`] as they wish. +// NOTE(jayb): the `Context` separation is in preparation for multi-process support; specifically, +// each guest process would have their own `Context` but would share the resolver. Currently, since +// we are using the `FileSystem` trait for migration, the interfaces do not show the full actual +// separated context support (yet!). Nonetheless, future changes will separate this out. +pub struct Resolver< + Platform: sync::RawSyncPrimitivesProvider, + Backend: super::backend::Backend + 'static, +> { + litebox: LiteBox, + backend: Backend, +} + +impl + Resolver +{ + /// Construct a new resolver over a `backend`. + #[must_use] + pub fn new(litebox: &LiteBox, backend: Backend) -> Self { + Self { + litebox: litebox.clone(), + backend, + } + } +} + +/// Per-call resolution context. The user may hold and mutate this as they wish. +#[derive(Clone, Debug)] +pub struct Context { + /// Current working directory. + /// + /// An empty list is equivalent to `/`. Guaranteed to never have `.` or `..`. + cwd: Vec, + /// Effective user for permission checks. + user_info: UserInfo, +} + +impl Context { + /// A new default context, anchored at `/` for a non-root user. + pub fn new() -> Context { + Self { + cwd: vec![], + user_info: UserInfo { + user: 1000, + group: 1000, + }, + } + } + + /// Resolve `path` against the current context. + // XXX(jayb): if/when we support chroot, we might need to tweak this to not allow "escaping" + // outside the chrooted part. + // XXX(jayb): since we are migrating all resolution into the resolver, we probably don't need + // `Arg` anymore, so could get rid of it in the future. + fn resolve(&self, path: impl Arg) -> Result { + let mut components = if path.as_rust_str()?.starts_with('/') { + vec![] + } else { + self.cwd.clone() + }; + for component in path.components()? { + match component { + "" | "." => {} + ".." => { + let _ = components.pop(); + } + _ => { + components.push(component.into()); + } + } + } + Ok(ResolvedPath { components }) + } + + fn can_execute(&self, permissions: &PermissionInfo) -> bool { + if self.user_info.user == permissions.owner.user { + permissions.mode.contains(Mode::XUSR) + } else if self.user_info.group == permissions.owner.group { + permissions.mode.contains(Mode::XGRP) + } else { + permissions.mode.contains(Mode::XOTH) + } + } + + fn can_read(&self, permissions: &PermissionInfo) -> bool { + if self.user_info.user == permissions.owner.user { + permissions.mode.contains(Mode::RUSR) + } else if self.user_info.group == permissions.owner.group { + permissions.mode.contains(Mode::RGRP) + } else { + permissions.mode.contains(Mode::ROTH) + } + } + + fn can_write(&self, permissions: &PermissionInfo) -> bool { + if self.user_info.user == permissions.owner.user { + permissions.mode.contains(Mode::WUSR) + } else if self.user_info.group == permissions.owner.group { + permissions.mode.contains(Mode::WGRP) + } else { + permissions.mode.contains(Mode::WOTH) + } + } +} + +impl Default for Context { + fn default() -> Self { + Self::new() + } +} + +/// Absolute normalized path, must only be created from [`Context::resolve`]. +struct ResolvedPath { + components: Vec, +} + +impl ResolvedPath { + fn parent_and_name(&self) -> Option<(Vec<&str>, &str)> { + let (name, parent) = self.components.split_last()?; + Some((parent.iter().map(String::as_str).collect(), name.as_str())) + } +} + +impl + super::private::Sealed for Resolver +{ +} + +impl + Resolver +{ + fn parent_dir_and_name<'a>( + &'a self, + context: &Context, + path: &'a ResolvedPath, + ) -> Result, &'a str)>, WalkError> { + // Return the walking handle rather than an owned directory handle so backends can keep any + // locks acquired during path resolution held across the final operation. This lets e.g. + // "walk parent + mutate child" stay atomic. + let Some((parent_components, name)) = path.parent_and_name() else { + return Ok(None); + }; + let parent = self.walk_to_directory( + context, + self.backend.root(), + &parent_components, + #[cfg(debug_assertions)] + &parent_components, + )?; + Ok(Some((parent, name))) + } + + fn walk_to_directory<'a>( + &'a self, + context: &Context, + from: Backend::WalkingDirHandle<'a>, + components: &[&str], + #[cfg(debug_assertions)] absolute_components: &[&str], + ) -> Result, WalkError> { + if components.is_empty() { + // TODO(jayb): Decide whether empty walks from a non-root handle need permission checks. + return Ok(from); + } + + let outcome = self.backend.walk_directories(from, components)?; + Self::check_walk_permissions( + context, + #[cfg(debug_assertions)] + absolute_components, + &outcome, + )?; + + match outcome.stop_reason { + WalkStopReason::CompleteDirectory => { + assert_eq!(outcome.components.len(), components.len()); + Ok(outcome.last) + } + WalkStopReason::StoppedAtNonDirectory => { + Err(WalkError::PathError(PathError::ComponentNotADirectory)) + } + WalkStopReason::Continue => { + // TODO(jayb): Continue walking from `outcome.last` once partial backend walks are + // supported by the resolver. + unimplemented!("partial backend walks are not supported yet") + } + } + } + + fn walk_path<'a>( + &'a self, + context: &Context, + from: Backend::WalkingDirHandle<'a>, + components: &[&str], + #[cfg(debug_assertions)] absolute_components: &[&str], + ) -> Result<(WalkOutcome>, usize), WalkError> { + assert!(!components.is_empty()); + let outcome = self.backend.walk_directories(from, components)?; + Self::check_walk_permissions( + context, + #[cfg(debug_assertions)] + absolute_components, + &outcome, + )?; + + let walked = outcome.components.len(); + match outcome.stop_reason { + WalkStopReason::CompleteDirectory => { + assert_eq!(walked, components.len()); + Ok((outcome, walked)) + } + WalkStopReason::StoppedAtNonDirectory if walked + 1 == components.len() => { + Ok((outcome, walked)) + } + WalkStopReason::StoppedAtNonDirectory => { + Err(WalkError::PathError(PathError::ComponentNotADirectory)) + } + WalkStopReason::Continue => { + // TODO(jayb): Continue walking from `outcome.last` once partial backend walks are + // supported by the resolver. + unimplemented!("partial backend walks are not supported yet") + } + } + } + + fn check_walk_permissions( + context: &Context, + #[cfg(debug_assertions)] absolute_components: &[&str], + outcome: &WalkOutcome>, + ) -> Result<(), PathError> { + for (idx, walked) in outcome.components.iter().enumerate() { + match &walked.permissions { + PermissionCheck::ByBackend => {} + PermissionCheck::ByResolver(permissions) => { + if !context.can_execute(permissions) { + return Err(PathError::NoSearchPerms { + #[cfg(debug_assertions)] + dir: { + let mut path = String::new(); + for component in &absolute_components[..=idx] { + path.push('/'); + path.push_str(component); + } + path + }, + #[cfg(debug_assertions)] + perms: permissions.mode, + }); + } + } + } + } + Ok(()) + } +} + +/// This exists purely as a migration feature, until we have completely separated contexts. See +/// comment on `Resolver`. +fn default_context_pre_context_management_changes() -> Context { + Context::new() +} + +impl + super::FileSystem for Resolver +{ + fn open(&self, path: impl Arg, flags: OFlags, mode: Mode) -> Result, OpenError> { + const CURRENTLY_SUPPORTED_OFLAGS: OFlags = OFlags::CREAT + .union(OFlags::RDONLY) + .union(OFlags::WRONLY) + .union(OFlags::RDWR) + .union(OFlags::TRUNC) + .union(OFlags::NOCTTY) + .union(OFlags::EXCL) + .union(OFlags::DIRECTORY) + .union(OFlags::NONBLOCK) + .union(OFlags::LARGEFILE) + .union(OFlags::NOFOLLOW) + .union(OFlags::APPEND) + .union(OFlags::PATH); + + if flags.intersects(CURRENTLY_SUPPORTED_OFLAGS.complement()) { + unimplemented!("{flags:?}") + } + let path_only = flags.contains(OFlags::PATH); + + let context = default_context_pre_context_management_changes(); + let path = context.resolve(path)?; + let access_mode = flags & (OFlags::WRONLY | OFlags::RDWR); + let read_allowed = access_mode == OFlags::RDONLY || access_mode == OFlags::RDWR; + let write_allowed = access_mode == OFlags::WRONLY || access_mode == OFlags::RDWR; + let append_mode = flags.contains(OFlags::APPEND); + let insert = |handle, seek_behavior| { + self.litebox.descriptor_table_mut().insert(ResolverEntry { + handle, + read_allowed, + write_allowed, + position: 0, + append_mode, + path_only, + seek_behavior, + }) + }; + + if path.components.is_empty() { + if flags.contains(OFlags::CREAT) && flags.contains(OFlags::EXCL) { + return Err(OpenError::AlreadyExists); + } + return Ok(insert( + OwnedHandle::Dir(self.backend.owned_dir_at(self.backend.root())), + SeekBehavior::NonSeekable, + )); + } + + let components: Vec<_> = path.components.iter().map(String::as_str).collect(); + let walk = self.walk_path( + &context, + self.backend.root(), + &components, + #[cfg(debug_assertions)] + &components, + ); + match walk { + Ok((outcome, _)) if outcome.stop_reason == WalkStopReason::CompleteDirectory => { + if flags.contains(OFlags::CREAT) && flags.contains(OFlags::EXCL) { + return Err(OpenError::AlreadyExists); + } + Ok(insert( + OwnedHandle::Dir(self.backend.owned_dir_at(outcome.last)), + SeekBehavior::NonSeekable, + )) + } + Ok((outcome, walked)) + if outcome.stop_reason == WalkStopReason::StoppedAtNonDirectory => + { + let name = components[walked]; + // TODO(jayb): Reject O_CREAT | O_EXCL before invoking the backend, so open-time + // side effects like truncation cannot happen before AlreadyExists is returned. + let file = self.backend.open_file_at(outcome.last, name, flags)?; + if flags.contains(OFlags::CREAT) && flags.contains(OFlags::EXCL) { + return Err(OpenError::AlreadyExists); + } + if !path_only + && let PermissionCheck::ByResolver(permissions) = &file.permissions + && ((read_allowed && !context.can_read(permissions)) + || (write_allowed && !context.can_write(permissions))) + { + return Err(OpenError::AccessNotAllowed); + } + let seek_behavior = self.backend.seek_behavior(&file.item); + Ok(insert(OwnedHandle::File(file.item), seek_behavior)) + } + Ok(_) => { + // `walk_path` validates stop reasons before returning. + unreachable!() + } + Err(WalkError::PathError(PathError::NoSuchFileOrDirectory)) + if flags.contains(OFlags::CREAT) => + { + let Some((parent_components, name)) = path.parent_and_name() else { + unreachable!("root path was handled above") + }; + let parent = self + .walk_to_directory( + &context, + self.backend.root(), + &parent_components, + #[cfg(debug_assertions)] + &parent_components, + ) + .map_err(|error| match error { + WalkError::Io => OpenError::Io, + WalkError::PathError(error) => error.into(), + })?; + let parent = self.backend.owned_dir_at(parent); + let file = self.backend.create_file_at(parent, name, mode)?; + let seek_behavior = self.backend.seek_behavior(&file); + Ok(insert(OwnedHandle::File(file), seek_behavior)) + } + Err(error) => match error { + WalkError::Io => Err(OpenError::Io), + WalkError::PathError(error) => Err(error.into()), + }, + } + } + + fn close(&self, fd: &TypedFd) -> Result<(), CloseError> { + self.litebox.descriptor_table_mut().remove(fd); + Ok(()) + } + + fn read( + &self, + fd: &TypedFd, + buf: &mut [u8], + offset: Option, + ) -> Result { + let entry = self + .litebox + .descriptor_table() + .entry_handle(fd) + .ok_or(ReadError::ClosedFd)?; + let mut entry = entry.get_entry_mut(); + // XXX(jayb): This over-holds the descriptor-entry lock across backend I/O. We need a + // smaller per-open-file-description primitive for position/append serialization, so the + // descriptor entry can be unlocked before potentially blocking backend calls. + let file = match &entry.entry.handle { + OwnedHandle::File(file) => file, + OwnedHandle::Dir(_) => return Err(ReadError::NotAFile), + }; + let seek_behavior = entry.entry.seek_behavior; + if !entry.entry.read_allowed { + return Err(ReadError::NotForReading); + } + if entry.entry.path_only { + // TODO(jayb): Add an error variant for operations not permitted on O_PATH fds. + unimplemented!("read from O_PATH fd") + } + + let read_offset = match seek_behavior { + SeekBehavior::NonSeekable | SeekBehavior::ZeroPosition => 0, + SeekBehavior::PositionBased => offset.unwrap_or(entry.entry.position), + }; + let read = self.backend.read(file, buf, read_offset)?; + if matches!(seek_behavior, SeekBehavior::PositionBased) && offset.is_none() { + entry.entry.position = read_offset.checked_add(read).unwrap(); + } + Ok(read) + } + + fn write( + &self, + fd: &TypedFd, + buf: &[u8], + offset: Option, + ) -> Result { + let entry = self + .litebox + .descriptor_table() + .entry_handle(fd) + .ok_or(WriteError::ClosedFd)?; + let mut entry = entry.get_entry_mut(); + // XXX(jayb): This over-holds the descriptor-entry lock across backend I/O. We need a + // smaller per-open-file-description primitive for position/append serialization, so the + // descriptor entry can be unlocked before potentially blocking backend calls. + let file = match &entry.entry.handle { + OwnedHandle::File(file) => file, + OwnedHandle::Dir(_) => return Err(WriteError::NotAFile), + }; + let seek_behavior = entry.entry.seek_behavior; + if !entry.entry.write_allowed { + return Err(WriteError::NotForWriting); + } + if entry.entry.path_only { + // TODO(jayb): Add an error variant for operations not permitted on O_PATH fds. + unimplemented!("write to O_PATH fd") + } + + let write_offset = match seek_behavior { + SeekBehavior::NonSeekable | SeekBehavior::ZeroPosition => 0, + SeekBehavior::PositionBased if entry.entry.append_mode && offset.is_none() => { + self.backend + .file_status(file) + .map_err(|_| WriteError::Io)? + .size + } + SeekBehavior::PositionBased => offset.unwrap_or(entry.entry.position), + }; + let written = self.backend.write(file, buf, write_offset)?; + if matches!(seek_behavior, SeekBehavior::PositionBased) && offset.is_none() { + entry.entry.position = write_offset.checked_add(written).unwrap(); + } + Ok(written) + } + + fn seek( + &self, + fd: &TypedFd, + offset: isize, + whence: super::SeekWhence, + ) -> Result { + let entry = self + .litebox + .descriptor_table() + .entry_handle(fd) + .ok_or(SeekError::ClosedFd)?; + let mut entry = entry.get_entry_mut(); + let file = match &entry.entry.handle { + OwnedHandle::File(file) => file, + OwnedHandle::Dir(_) => return Err(SeekError::NotAFile), + }; + if entry.entry.path_only { + // TODO(jayb): Add an error variant for operations not permitted on O_PATH fds. + unimplemented!("seek on O_PATH fd") + } + + match entry.entry.seek_behavior { + SeekBehavior::NonSeekable => Err(SeekError::NonSeekable), + SeekBehavior::ZeroPosition => Ok(0), + SeekBehavior::PositionBased => { + let file_len = self + .backend + .file_status(file) + .map_err(|_| SeekError::Io)? + .size; + let base = match whence { + super::SeekWhence::RelativeToBeginning => 0, + super::SeekWhence::RelativeToCurrentOffset => entry.entry.position, + super::SeekWhence::RelativeToEnd => file_len, + }; + let new_position = base + .checked_add_signed(offset) + .ok_or(SeekError::InvalidOffset)?; + // TODO(jayb): Linux allows regular files to seek past EOF, while some backends or + // file types may not. Model that distinction instead of using one resolver rule. + if new_position > file_len { + return Err(SeekError::InvalidOffset); + } + entry.entry.position = new_position; + Ok(new_position) + } + } + } + + fn truncate( + &self, + fd: &TypedFd, + length: usize, + reset_offset: bool, + ) -> Result<(), TruncateError> { + let entry = self + .litebox + .descriptor_table() + .entry_handle(fd) + .ok_or(TruncateError::ClosedFd)?; + let mut entry = entry.get_entry_mut(); + let file = match &entry.entry.handle { + OwnedHandle::File(file) => file, + OwnedHandle::Dir(_) => return Err(TruncateError::IsDirectory), + }; + if !entry.entry.write_allowed { + return Err(TruncateError::NotForWriting); + } + if entry.entry.path_only { + // TODO(jayb): Add an error variant for operations not permitted on O_PATH fds. + unimplemented!("truncate O_PATH fd") + } + + self.backend.truncate(file, length)?; + if reset_offset { + entry.entry.position = 0; + } + Ok(()) + } + + fn chmod(&self, path: impl Arg, mode: Mode) -> Result<(), ChmodError> { + let context = default_context_pre_context_management_changes(); + let path = context.resolve(path)?; + let Some((parent, name)) = + self.parent_dir_and_name(&context, &path) + .map_err(|error| match error { + WalkError::Io => ChmodError::Io, + WalkError::PathError(error) => error.into(), + })? + else { + // TODO(jayb): Add backend support for mutating the root directory itself. + unimplemented!("chmod root directory") + }; + self.backend + .chmod_at(self.backend.owned_dir_at(parent), name, mode) + } + + fn chown( + &self, + path: impl Arg, + user: Option, + group: Option, + ) -> Result<(), ChownError> { + let context = default_context_pre_context_management_changes(); + let path = context.resolve(path)?; + let Some((parent, name)) = + self.parent_dir_and_name(&context, &path) + .map_err(|error| match error { + WalkError::Io => ChownError::Io, + WalkError::PathError(error) => error.into(), + })? + else { + // TODO(jayb): Add backend support for mutating the root directory itself. + unimplemented!("chown root directory") + }; + self.backend + .chown_at(self.backend.owned_dir_at(parent), name, user, group) + } + + fn unlink(&self, path: impl Arg) -> Result<(), UnlinkError> { + let context = default_context_pre_context_management_changes(); + let path = context.resolve(path)?; + let Some((parent, name)) = + self.parent_dir_and_name(&context, &path) + .map_err(|error| match error { + WalkError::Io => UnlinkError::Io, + WalkError::PathError(error) => error.into(), + })? + else { + return Err(UnlinkError::IsADirectory); + }; + self.backend + .unlink_at(self.backend.owned_dir_at(parent), name) + } + + fn mkdir(&self, path: impl Arg, mode: Mode) -> Result<(), MkdirError> { + let context = default_context_pre_context_management_changes(); + let path = context.resolve(path)?; + let Some((parent, name)) = + self.parent_dir_and_name(&context, &path) + .map_err(|error| match error { + WalkError::Io => MkdirError::Io, + WalkError::PathError(error) => error.into(), + })? + else { + return Err(MkdirError::AlreadyExists); + }; + self.backend + .mkdir_at(self.backend.owned_dir_at(parent), name, mode) + .map(|_| ()) + } + + fn rmdir(&self, path: impl Arg) -> Result<(), RmdirError> { + let context = default_context_pre_context_management_changes(); + let path = context.resolve(path)?; + let Some((parent, name)) = + self.parent_dir_and_name(&context, &path) + .map_err(|error| match error { + WalkError::Io => RmdirError::Io, + WalkError::PathError(error) => error.into(), + })? + else { + return Err(RmdirError::Busy); + }; + self.backend + .rmdir_at(self.backend.owned_dir_at(parent), name) + } + + fn read_dir(&self, fd: &TypedFd) -> Result, ReadDirError> { + let entry = self + .litebox + .descriptor_table() + .entry_handle(fd) + .ok_or(ReadDirError::ClosedFd)?; + let entry = entry.get_entry(); + if entry.entry.path_only { + // TODO(jayb): Add an error variant for operations not permitted on O_PATH fds. + unimplemented!("read_dir on O_PATH fd") + } + let dir = match &entry.entry.handle { + OwnedHandle::File(_) => return Err(ReadDirError::NotADirectory), + OwnedHandle::Dir(dir) => dir, + }; + + let mut entries = Vec::new(); + // TODO(jayb): Fill in inode info for synthesized dot entries. + entries.push(super::DirEntry { + name: String::from("."), + file_type: FileType::Directory, + ino_info: None, + }); + entries.push(super::DirEntry { + name: String::from(".."), + file_type: FileType::Directory, + ino_info: None, + }); + entries.extend(self.backend.list_dir_at(dir.clone())?); + Ok(entries) + } + + fn file_status(&self, path: impl Arg) -> Result { + // TODO(jayb): Improve this. Opening just to stat forces the resolver to choose open flags, + // but stat itself should be access-neutral. + let fd = self + .open(path, OFlags::RDONLY, Mode::empty()) + .map_err(|error| match error { + OpenError::PathError(error) => error.into(), + OpenError::Io + | OpenError::AccessNotAllowed + | OpenError::NoWritePerms + | OpenError::ReadOnlyFileSystem + | OpenError::AlreadyExists + | OpenError::TruncateError(_) => FileStatusError::Io, + })?; + let status = self.fd_file_status(&fd); + self.close(&fd).unwrap(); + status + } + + fn fd_file_status(&self, fd: &TypedFd) -> Result { + let entry = self + .litebox + .descriptor_table() + .entry_handle(fd) + .ok_or(FileStatusError::ClosedFd)?; + let entry = entry.get_entry(); + match &entry.entry.handle { + OwnedHandle::File(file) => self.backend.file_status(file), + OwnedHandle::Dir(dir) => self.backend.dir_status(dir), + } + } + + fn get_static_backing_data(&self, fd: &TypedFd) -> Option<&'static [u8]> { + let entry = self.litebox.descriptor_table().entry_handle(fd)?; + let entry = entry.get_entry(); + match &entry.entry.handle { + OwnedHandle::File(file) => self.backend.get_static_backing_data(file), + OwnedHandle::Dir(_) => None, + } + } +} + +/// A file or a directory handle +enum OwnedHandle { + File(Backend::FileHandle), + Dir(Backend::DirHandle), +} + +#[expect( + clippy::struct_excessive_bools, + reason = "resolver fd entries carry independent descriptor flags" +)] +struct ResolverEntry { + handle: OwnedHandle, + read_allowed: bool, + write_allowed: bool, + position: usize, + append_mode: bool, + path_only: bool, + seek_behavior: SeekBehavior, +} + +crate::fd::enable_fds_for_subsystem! { + @ Platform: { sync::RawSyncPrimitivesProvider }, Backend: { super::backend::Backend + 'static }; + Resolver; + @ Backend: { super::backend::Backend + 'static }; + ResolverEntry; + -> ResolverFd; +} diff --git a/litebox/src/fs/tests.rs b/litebox/src/fs/tests.rs index a59767873..8173e6f68 100644 --- a/litebox/src/fs/tests.rs +++ b/litebox/src/fs/tests.rs @@ -1938,6 +1938,8 @@ mod layered { mod stdio { use crate::LiteBox; + use crate::fs::devices::Devices; + use crate::fs::resolver::Resolver; use crate::fs::{FileSystem as _, Mode, OFlags}; use crate::platform::mock::MockPlatform; use alloc::vec; @@ -1947,7 +1949,7 @@ mod stdio { fn stdio_open_read_write() { let platform = MockPlatform::new(); let litebox = LiteBox::new(platform); - let fs = crate::fs::devices::FileSystem::new(&litebox); + let fs = Resolver::new(&litebox, Devices::migration_helper_standalone_new(&litebox)); // Test opening and writing to /dev/stdout let fd_stdout = fs @@ -1992,7 +1994,7 @@ mod stdio { #[test] fn non_dev_path_fails() { let litebox = LiteBox::new(MockPlatform::new()); - let fs = crate::fs::devices::FileSystem::new(&litebox); + let fs = Resolver::new(&litebox, Devices::migration_helper_standalone_new(&litebox)); // Attempt to open a non-/dev/* path let result = fs.open("foo", OFlags::RDONLY, Mode::empty()); @@ -2008,6 +2010,7 @@ mod stdio { mod layered_stdio { use crate::LiteBox; use crate::fs::layered::LayeringSemantics; + use crate::fs::resolver::Resolver; use crate::fs::{FileSystem as _, Mode, OFlags}; use crate::fs::{devices, in_mem, layered}; use crate::platform::mock::MockPlatform; @@ -2021,7 +2024,10 @@ mod layered_stdio { let layered_fs = layered::FileSystem::new( &litebox, in_mem::FileSystem::new(&litebox), - devices::FileSystem::new(&litebox), + Resolver::new( + &litebox, + devices::Devices::migration_helper_standalone_new(&litebox), + ), LayeringSemantics::LowerLayerWritableFiles, ); @@ -2085,7 +2091,10 @@ mod layered_stdio { let fs = layered::FileSystem::new( &litebox, in_mem, - devices::FileSystem::new(&litebox), + Resolver::new( + &litebox, + devices::Devices::migration_helper_standalone_new(&litebox), + ), LayeringSemantics::LowerLayerWritableFiles, ); diff --git a/litebox_runner_snp/src/main.rs b/litebox_runner_snp/src/main.rs index fdde2a705..b63ce508f 100644 --- a/litebox_runner_snp/src/main.rs +++ b/litebox_runner_snp/src/main.rs @@ -43,7 +43,7 @@ type DefaultFS = litebox::fs::layered::FileSystem< litebox::fs::in_mem::FileSystem, litebox::fs::layered::FileSystem< Platform, - litebox::fs::devices::FileSystem, + litebox::fs::resolver::Resolver>, litebox::fs::nine_p::FileSystem, >, >; @@ -238,7 +238,10 @@ pub extern "C" fn sandbox_process_init( globals::SM_TERM_GENERAL, ); }; - let dev_stdio = litebox::fs::devices::FileSystem::new(litebox); + let dev_stdio = litebox::fs::resolver::Resolver::new( + litebox, + litebox::fs::devices::Devices::migration_helper_standalone_new(litebox), + ); let default_fs = litebox::fs::layered::FileSystem::new( litebox, in_mem_fs, diff --git a/litebox_shim_linux/src/lib.rs b/litebox_shim_linux/src/lib.rs index 09835c541..244619b17 100644 --- a/litebox_shim_linux/src/lib.rs +++ b/litebox_shim_linux/src/lib.rs @@ -57,7 +57,7 @@ pub(crate) type LinuxFS = litebox::fs::layered::FileSystem< litebox::fs::in_mem::FileSystem, litebox::fs::layered::FileSystem< Platform, - litebox::fs::devices::FileSystem, + litebox::fs::resolver::Resolver>, litebox::fs::tar_ro::FileSystem, >, >; @@ -334,7 +334,10 @@ fn default_fs( in_mem_fs: litebox::fs::in_mem::FileSystem, tar_ro_fs: litebox::fs::tar_ro::FileSystem, ) -> LinuxFS { - let dev_stdio = litebox::fs::devices::FileSystem::new(litebox); + let dev_stdio = litebox::fs::resolver::Resolver::new( + litebox, + litebox::fs::devices::Devices::migration_helper_standalone_new(litebox), + ); litebox::fs::layered::FileSystem::new( litebox, in_mem_fs, diff --git a/litebox_shim_linux/src/syscalls/tests.rs b/litebox_shim_linux/src/syscalls/tests.rs index 81f4e07ed..e47144f7a 100644 --- a/litebox_shim_linux/src/syscalls/tests.rs +++ b/litebox_shim_linux/src/syscalls/tests.rs @@ -241,7 +241,15 @@ fn test_getdent64() { entry_names.sort(); assert_eq!( entry_names, - alloc::vec![".", "..", "bar", "foo", "test_file1.txt", "test_file2.txt"] + alloc::vec![ + ".", + "..", + "bar", + "dev", + "foo", + "test_file1.txt", + "test_file2.txt" + ] ); // Verify that our test files have the correct type (regular file) @@ -395,7 +403,15 @@ fn test_getdent64() { all_entries.sort(); assert_eq!( all_entries, - alloc::vec![".", "..", "bar", "foo", "test_file1.txt", "test_file2.txt"] + alloc::vec![ + ".", + "..", + "bar", + "dev", + "foo", + "test_file1.txt", + "test_file2.txt" + ] ); }