From 1dccaae83f5155faae6bef336347f6bfb8ee25e6 Mon Sep 17 00:00:00 2001 From: Weiteng Chen Date: Sun, 1 Nov 2026 01:01:16 -0700 Subject: [PATCH 01/12] Add bounded host-backed storage for broker files Prepare lazy shared-file storage and Linux/Windows host providers without exposing a file-mapping protocol endpoint or changing guest mmap behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- litebox/src/fs/errors.rs | 2 + litebox/src/fs/file.rs | 2 + litebox_broker_core/src/fs/backend.rs | 22 +- litebox_broker_core/src/fs/composer.rs | 8 + litebox_broker_core/src/fs/errors.rs | 19 + litebox_broker_core/src/fs/in_mem.rs | 185 ++++++-- litebox_broker_core/src/fs/mod.rs | 1 + litebox_broker_core/src/fs/overlay.rs | 13 + litebox_broker_core/src/fs/resolver.rs | 30 +- litebox_broker_core/src/fs/service.rs | 1 + litebox_broker_core/src/fs/shared_memory.rs | 284 +++++++++++ litebox_broker_core/src/fs/tests.rs | 229 +++++++++ litebox_broker_userland/src/fs/linux.rs | 4 +- litebox_broker_userland/src/fs/mod.rs | 4 +- litebox_broker_userland/src/fs/windows.rs | 4 +- litebox_broker_userland/src/lib.rs | 1 + litebox_broker_userland/src/main.rs | 16 + litebox_broker_userland/src/shared_objects.rs | 439 ++++++++++++++++++ litebox_common_linux/src/errno/mod.rs | 2 + 19 files changed, 1225 insertions(+), 41 deletions(-) create mode 100644 litebox_broker_core/src/fs/shared_memory.rs create mode 100644 litebox_broker_userland/src/shared_objects.rs diff --git a/litebox/src/fs/errors.rs b/litebox/src/fs/errors.rs index 8c34184d1..450381216 100644 --- a/litebox/src/fs/errors.rs +++ b/litebox/src/fs/errors.rs @@ -109,6 +109,8 @@ pub enum TruncateError { NotOpenForWriting, #[error("file descriptor points to a terminal device")] IsTerminalDevice, + #[error("the file cannot shrink while it is mapped")] + Busy, #[error("I/O error")] Io, } diff --git a/litebox/src/fs/file.rs b/litebox/src/fs/file.rs index 3ad405dd9..a51ad8074 100644 --- a/litebox/src/fs/file.rs +++ b/litebox/src/fs/file.rs @@ -750,6 +750,7 @@ impl From for OpenError { FileError::IsDirectory => Self::TruncateError(TruncateError::IsDirectory), FileError::NotForWriting => Self::TruncateError(TruncateError::NotForWriting), FileError::IsTerminalDevice => Self::TruncateError(TruncateError::IsTerminalDevice), + FileError::Busy => Self::TruncateError(TruncateError::Busy), FileError::NoSuchDevice => Self::NoSuchDevice, FileError::NoSpace => Self::NoSpace, _ => Self::Io, @@ -797,6 +798,7 @@ impl From for TruncateError { FileError::AccessNotAllowed | FileError::NotForWriting => Self::NotForWriting, FileError::PathOnly => Self::NotOpenForWriting, FileError::IsTerminalDevice => Self::IsTerminalDevice, + FileError::Busy => Self::Busy, _ => Self::Io, } } diff --git a/litebox_broker_core/src/fs/backend.rs b/litebox_broker_core/src/fs/backend.rs index 57bbc0100..e9319bcb5 100644 --- a/litebox_broker_core/src/fs/backend.rs +++ b/litebox_broker_core/src/fs/backend.rs @@ -13,9 +13,9 @@ use core::marker::PhantomData; use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; use super::errors::{ - ChmodError, ChownError, FileStatusError, MkdirError, OpenError, ReadDirError, ReadError, - RenameError, ResolutionError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, - WriteError, + ChmodError, ChownError, FileStatusError, MapSharedError, MkdirError, OpenError, ReadDirError, + ReadError, RenameError, ResolutionError, RmdirError, SymlinkError, TruncateError, UnlinkError, + WalkError, WriteError, }; use super::{DirEntry, FileStatus, FileType, Mode, OFlags, UserInfo}; use crate::readiness::ReadinessSource; @@ -145,6 +145,22 @@ pub trait Backend: Send + Sync + Any { None } + /// Returns the host shared object that holds the file's contents, moving them there first if + /// necessary, for a deployment to export its storage without copying it. + /// + /// After this succeeds, the backend must keep the file's contents in that object, so that + /// every mapping and every read, write, and truncation see the same bytes, and must return + /// the same object for every later call on the same file. + /// + /// The default implementation returns [`MapSharedError::Unsupported`]. + #[expect(unused_variables, reason = "default body, non-underscored param names")] + fn map_shared( + &self, + h: &FileHandle, + ) -> Result, MapSharedError> { + Err(MapSharedError::Unsupported) + } + /// Write `buf` into the file, based on `offset` (see [`crate::transfer`]), returning the number /// of bytes written from its start. /// diff --git a/litebox_broker_core/src/fs/composer.rs b/litebox_broker_core/src/fs/composer.rs index fbb5b8c23..8fee02c8e 100644 --- a/litebox_broker_core/src/fs/composer.rs +++ b/litebox_broker_core/src/fs/composer.rs @@ -735,6 +735,14 @@ impl Backend for Composer { .get_static_backing_data(&h.handle) } + fn map_shared( + &self, + h: &FileHandle, + ) -> Result, super::errors::MapSharedError> { + let h = h.get_typed::(); + self.mounts[h.mount_index].backend.map_shared(&h.handle) + } + fn write( &self, h: &FileHandle, diff --git a/litebox_broker_core/src/fs/errors.rs b/litebox_broker_core/src/fs/errors.rs index a55ce896d..199d536cc 100644 --- a/litebox_broker_core/src/fs/errors.rs +++ b/litebox_broker_core/src/fs/errors.rs @@ -114,6 +114,25 @@ pub enum TruncateError { NotOpenForWriting, #[error("file descriptor points to a terminal device")] IsTerminalDevice, + #[error("the file cannot shrink while it is mapped")] + Busy, + #[error("I/O error")] + Io, +} + +/// Possible errors from [`super::resolver::Resolver::map_shared`] +#[derive(Error, Debug)] +pub enum MapSharedError { + #[error("file descriptor points to a directory")] + IsDirectory, + #[error("file is not opened for reading")] + NotForReading, + #[error("file descriptor is not open for reading or writing")] + PathOnly, + #[error("the file system cannot share this file's pages")] + Unsupported, + #[error("out of host shared memory")] + NoMemory, #[error("I/O error")] Io, } diff --git a/litebox_broker_core/src/fs/in_mem.rs b/litebox_broker_core/src/fs/in_mem.rs index b1b889284..fc52a5ec3 100644 --- a/litebox_broker_core/src/fs/in_mem.rs +++ b/litebox_broker_core/src/fs/in_mem.rs @@ -8,18 +8,21 @@ use alloc::sync::Arc; use alloc::vec::Vec; use hashbrown::HashMap; -use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; +use litebox_platform::buffer::{Fault, ReadBuffer, WriteBuffer}; use litebox_platform::sync; use super::backend::{ PermissionCheck, PermissionInfo, Resolution, ResolvedDir, ResolvedFile, ResolvedTarget, }; use super::errors::{ - ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, - ReadError, RenameError, ResolutionError, RmdirError, SymlinkError, TruncateError, UnlinkError, - WalkError, WriteError, + ChmodError, ChownError, FileStatusError, MapSharedError, MkdirError, OpenError, PathError, + ReadDirError, ReadError, RenameError, ResolutionError, RmdirError, SymlinkError, TruncateError, + UnlinkError, WalkError, WriteError, }; use super::inode_allocator::InodeAllocator; +use super::shared_memory::{ + SharedFile, SharedFileLimit, SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, +}; use super::{DirEntry, FileStatus, FileType, Mode, NodeInfo, UserInfo}; /// A [`super::backend::Backend`] that stores all files in memory. @@ -36,6 +39,9 @@ pub struct InMem { // XXX(jayb): serializes directory moves so ancestry stays stable while rename chooses // ancestor-first locks; should be cleaned up as part of broader atomicity fixups. rename_lock: sync::Mutex, + /// Creates the host shared memory that a file's contents move to when a runner maps it, if + /// this deployment supports shared file mappings, and bounds how many files may hold it. + shared_memory: Option<(Arc, Arc)>, } impl InMem { @@ -60,9 +66,25 @@ impl InMem { root, inode_allocator, rename_lock: sync::Mutex::new(()), + shared_memory: None, } } + /// Lets runners map this backend's regular files `MAP_SHARED`, keeping a mapped file's + /// contents in host shared memory from `provider`. + /// + /// A file keeps that memory for as long as it exists, and at most as many files as `limit` + /// allows may hold some at once; mapping another fails as out of memory. + #[must_use] + pub fn with_shared_memory( + mut self, + provider: Arc, + limit: Arc, + ) -> Self { + self.shared_memory = Some((provider, limit)); + self + } + /// Construct an `InMem` backend pre-populated with `entries`. /// /// Entries are inserted in order, bypassing all permission checks, which is what lets a caller @@ -154,7 +176,7 @@ impl InMem { mode, userinfo: owner, }; - existing.data = data; + existing.contents = Contents::Inline(data); } ( Some(Node::File(existing)), @@ -174,7 +196,7 @@ impl InMem { mode, userinfo: owner, }; - existing.data = target.into_bytes().into(); + existing.contents = Contents::Inline(target.into_bytes().into()); } (Some(_), _) => panic!("{path:?} already exists with a different type"), (None, InitialNode::Directory { mode, owner }) => { @@ -196,7 +218,7 @@ impl InMem { userinfo: owner, }, file_type: FileType::RegularFile, - data, + contents: Contents::Inline(data), node_info: self.inode_allocator.next(), })); dir.children.insert(name.into(), Node::File(child)); @@ -215,7 +237,7 @@ impl InMem { userinfo: owner, }, file_type: FileType::SymbolicLink, - data: target.into_bytes().into(), + contents: Contents::Inline(target.into_bytes().into()), node_info: self.inode_allocator.next(), })); dir.children.insert(name.into(), Node::File(child)); @@ -263,10 +285,10 @@ impl InMem { "must only be used on regular files during initialization" ); assert!( - file.data.is_empty(), + matches!(&file.contents, Contents::Inline(contents) if contents.is_empty()), "must only be used on empty files during initialization" ); - file.data = data; + file.contents = Contents::Inline(data); } } @@ -412,7 +434,7 @@ impl super::backend::Backend for InMe if file.file_type != FileType::SymbolicLink { return Err(PathError::InvalidPathname.into()); } - Ok(core::str::from_utf8(&file.data) + Ok(core::str::from_utf8(file.inline_bytes()) .expect("symlink targets are initialized from strings") .into()) } @@ -492,12 +514,20 @@ impl super::backend::Backend for InMe offset: usize, ) -> Result { let file = h.get_typed::().file.read(); - let start = offset.min(file.data.len()); - let end = offset.checked_add(buf.len()).unwrap().min(file.data.len()); + let data = match &file.contents { + Contents::Inline(data) => data, + Contents::Shared { memory, .. } => { + return memory.read_at(offset, buf).map_err(|error| match error { + SharedFileMemoryError::Fault => Fault.into(), + _ => ReadError::Io, + }); + } + }; + let start = offset.min(data.len()); + let end = offset.checked_add(buf.len()).unwrap().min(data.len()); debug_assert!(start <= end); - let len = end - start; - buf.copy_from_slice_at(0, &file.data[start..end])?; - Ok(len) + buf.copy_from_slice_at(0, &data[start..end])?; + Ok(end - start) } fn write( @@ -507,9 +537,31 @@ impl super::backend::Backend for InMe offset: usize, ) -> Result { let mut file = h.get_typed::().file.write(); - let data = file.data.to_mut(); - let previous_len = data.len(); let end = offset.checked_add(buf.len()).unwrap(); + let data = match &mut file.contents { + Contents::Inline(data) => data, + Contents::Shared { memory, .. } => { + let previous_len = memory.size().map_err(|_| WriteError::Io)?; + if end > previous_len { + // Pads with 0s if the offset is past the end of the file. + memory.set_len(end).map_err(|_| WriteError::Io)?; + } + return match memory.write_at(offset, buf) { + Ok(()) => Ok(buf.len()), + Err(error) => { + if end > previous_len { + let _ = memory.set_len(previous_len); + } + Err(match error { + SharedFileMemoryError::Fault => Fault.into(), + _ => WriteError::Io, + }) + } + }; + } + }; + let data = data.to_mut(); + let previous_len = data.len(); if end > previous_len { // Pads with 0s if the offset is past the end of the file. data.resize(end, 0); @@ -523,13 +575,25 @@ impl super::backend::Backend for InMe fn truncate(&self, h: &super::backend::FileHandle, length: usize) -> Result<(), TruncateError> { let mut file = h.get_typed::().file.write(); - match length.cmp(&file.data.len()) { - core::cmp::Ordering::Less => match &mut file.data { + let data = match &mut file.contents { + Contents::Inline(data) => data, + Contents::Shared { memory, .. } => { + // Shrinking discards the pages past the end, so runners fault on them, as Linux + // raises `SIGBUS` past the end of a mapped file. Hosts that cannot shrink mapped + // memory refuse instead. + return memory.set_len(length).map_err(|error| match error { + SharedFileMemoryError::Busy => TruncateError::Busy, + _ => TruncateError::Io, + }); + } + }; + match length.cmp(&data.len()) { + core::cmp::Ordering::Less => match data { alloc::borrow::Cow::Borrowed(d) => *d = &d[..length], alloc::borrow::Cow::Owned(d) => d.truncate(length), }, core::cmp::Ordering::Equal => (), - core::cmp::Ordering::Greater => file.data.to_mut().resize(length, 0), + core::cmp::Ordering::Greater => data.to_mut().resize(length, 0), } Ok(()) } @@ -545,7 +609,8 @@ impl super::backend::Backend for InMe Ok(FileStatus { file_type: file.file_type, mode: file.perms.mode, - size: u64::try_from(file.data.len()).map_err(|_| FileStatusError::Io)?, + size: u64::try_from(file.len().map_err(|_| FileStatusError::Io)?) + .map_err(|_| FileStatusError::Io)?, owner: file.perms.userinfo, node_info: file.node_info, blksize: BLOCK_SIZE, @@ -595,7 +660,7 @@ impl super::backend::Backend for InMe userinfo: metadata.owner, }, file_type: FileType::RegularFile, - data: Vec::new().into(), + contents: Contents::Inline(Vec::new().into()), node_info: self.inode_allocator.next(), })); let old = parent @@ -625,7 +690,7 @@ impl super::backend::Backend for InMe userinfo: metadata.owner, }, file_type: FileType::SymbolicLink, - data: target.as_bytes().to_vec().into(), + contents: Contents::Inline(target.as_bytes().to_vec().into()), node_info: self.inode_allocator.next(), })); parent.children.insert(name.into(), Node::File(file)); @@ -849,10 +914,39 @@ impl super::backend::Backend for InMe if file.file_type == FileType::SymbolicLink { return None; } - match file.data { - alloc::borrow::Cow::Borrowed(slice) => Some(slice), - alloc::borrow::Cow::Owned(_) => None, + match file.contents { + Contents::Inline(alloc::borrow::Cow::Borrowed(slice)) => Some(slice), + Contents::Inline(alloc::borrow::Cow::Owned(_)) | Contents::Shared { .. } => None, + } + } + + fn map_shared( + &self, + h: &super::backend::FileHandle, + ) -> Result, MapSharedError> { + let mut file = h.get_typed::().file.write(); + let data = match &file.contents { + Contents::Inline(data) => data, + Contents::Shared { file, .. } => return Ok(Arc::clone(file)), + }; + let (provider, limit) = self + .shared_memory + .as_ref() + .ok_or(MapSharedError::Unsupported)?; + if file.file_type != FileType::RegularFile { + return Err(MapSharedError::Unsupported); } + let slot = limit.reserve().ok_or(MapSharedError::NoMemory)?; + let memory = provider.create(data).map_err(|error| match error { + SharedFileMemoryError::NoMemory => MapSharedError::NoMemory, + _ => MapSharedError::Io, + })?; + let shared = Arc::new(SharedFile::new(memory.clone(), slot)); + file.contents = Contents::Shared { + memory, + file: Arc::clone(&shared), + }; + Ok(shared) } } @@ -933,10 +1027,43 @@ struct FileData { perms: Permissions, file_type: FileType, /// The contents of a regular file, or the target of a symlink. - data: alloc::borrow::Cow<'static, [u8]>, + contents: Contents, node_info: NodeInfo, } +/// What a regular file or a symbolic link holds. +enum Contents { + /// Bytes in ordinary memory: a regular file's contents, or a symbolic link's target. + Inline(alloc::borrow::Cow<'static, [u8]>), + /// A regular file's contents in host shared memory, once a runner has mapped the file: + /// `memory`, which the file system reads and writes, shared with runners as `file`. + Shared { + memory: Arc, + file: Arc, + }, +} + +impl FileData { + fn len(&self) -> Result { + match &self.contents { + Contents::Inline(data) => Ok(data.len()), + Contents::Shared { memory, .. } => memory.size(), + } + } + + /// Returns the bytes of a file that runners never mapped, such as a symbolic link. + /// + /// # Panics + /// + /// Panics if the file's contents are in host shared memory. + fn inline_bytes(&self) -> &[u8] { + match &self.contents { + Contents::Inline(data) => data, + Contents::Shared { .. } => panic!("only regular files move into shared memory"), + } + } +} + #[derive(Clone, Debug)] struct Permissions { mode: Mode, @@ -1025,7 +1152,7 @@ mod tests { Err(OpenError::PathError(PathError::NoSuchFileOrDirectory)) )); } - assert_eq!(backing_file.read().data.as_ref(), b"preserve"); + assert_eq!(backing_file.read().inline_bytes(), b"preserve"); let fd = fs .open(OWNER, "/link", OFlags::RDONLY, Mode::empty()) .unwrap(); diff --git a/litebox_broker_core/src/fs/mod.rs b/litebox_broker_core/src/fs/mod.rs index 7321182a8..06d89aca9 100644 --- a/litebox_broker_core/src/fs/mod.rs +++ b/litebox_broker_core/src/fs/mod.rs @@ -23,6 +23,7 @@ pub mod nine_p; pub mod overlay; pub mod resolver; mod service; +pub mod shared_memory; pub mod tar_ro; #[cfg(test)] mod tests; diff --git a/litebox_broker_core/src/fs/overlay.rs b/litebox_broker_core/src/fs/overlay.rs index 20ff550f5..c3365d857 100644 --- a/litebox_broker_core/src/fs/overlay.rs +++ b/litebox_broker_core/src/fs/overlay.rs @@ -1085,6 +1085,19 @@ impl Backend for Overlay { }) } + fn map_shared( + &self, + h: &FileHandle, + ) -> Result, super::errors::MapSharedError> { + // A file opened for writing was copied up already. A file still in a lower layer can + // only be mapped readable, so the lower layer's object serves it, if it has one. + // Copying it up later leaves existing mappings on the lower file, as an + // overlayfs mapping of a lower file does. + self.with_file(h.get_typed::(), |_, backend, handle| { + backend.map_shared(handle) + }) + } + fn write( &self, h: &FileHandle, diff --git a/litebox_broker_core/src/fs/resolver.rs b/litebox_broker_core/src/fs/resolver.rs index a94cf25a0..adee7b5a1 100644 --- a/litebox_broker_core/src/fs/resolver.rs +++ b/litebox_broker_core/src/fs/resolver.rs @@ -13,9 +13,9 @@ use core::marker::PhantomData; use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; use super::errors::{ - ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, - ReadError, RenameError, ResolutionError, RmdirError, SeekError, SymlinkError, TruncateError, - UnlinkError, WalkError, WriteError, + ChmodError, ChownError, FileStatusError, MapSharedError, MkdirError, OpenError, PathError, + ReadDirError, ReadError, RenameError, ResolutionError, RmdirError, SeekError, SymlinkError, + TruncateError, UnlinkError, WalkError, WriteError, }; use super::{ FileType, Mode, OFlags, @@ -1241,6 +1241,30 @@ impl Resolver None, } } + + /// Returns the host shared object holding an open file's contents, for a `MAP_SHARED` + /// mapping, and whether it may be mapped writable. + /// + /// The file must be open for reading, and the object may be mapped writable exactly when the + /// file is open for writing too, as Linux allows of a shared mapping that `mprotect` may + /// later make writable. + pub fn map_shared( + &self, + entry: &ResolverEntry, + ) -> Result<(Arc, bool), MapSharedError> { + let file = match &entry.handle { + Handle::File(file) => file, + Handle::Dir(_) => return Err(MapSharedError::IsDirectory), + }; + if entry.path_only { + return Err(MapSharedError::PathOnly); + } + if !entry.read_allowed { + return Err(MapSharedError::NotForReading); + } + let contents = self.backend.map_shared(file)?; + Ok((contents, entry.write_allowed)) + } } #[expect( diff --git a/litebox_broker_core/src/fs/service.rs b/litebox_broker_core/src/fs/service.rs index fff9c3296..cbf98ed4b 100644 --- a/litebox_broker_core/src/fs/service.rs +++ b/litebox_broker_core/src/fs/service.rs @@ -1033,6 +1033,7 @@ fn file_truncate_error(error: TruncateError) -> FileError { TruncateError::NotForWriting => FileError::NotForWriting, TruncateError::NotOpenForWriting => FileError::PathOnly, TruncateError::IsTerminalDevice => FileError::IsTerminalDevice, + TruncateError::Busy => FileError::Busy, TruncateError::ClosedFd | TruncateError::Io => FileError::Io, } } diff --git a/litebox_broker_core/src/fs/shared_memory.rs b/litebox_broker_core/src/fs/shared_memory.rs new file mode 100644 index 000000000..c05e066ba --- /dev/null +++ b/litebox_broker_core/src/fs/shared_memory.rs @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Bounded host storage for file contents. +//! +//! A backend moves a file's contents into [`SharedFileMemory`] the first time +//! [`Backend::map_shared`](super::backend::Backend::map_shared) is called. Subsequent reads, +//! writes, truncations, and requests for the object use the same storage. A deployment creates +//! that storage through [`SharedFileMemoryProvider`], for example in a memfd or a temporary +//! file. The broker core accesses it through buffers and never maps it itself. +//! +//! [`SharedFileLimit`] bounds how many files hold host resources at once. The file and any +//! retained [`SharedFile`] keep their slot until the last reference is dropped. + +use alloc::sync::Arc; +use core::marker::PhantomData; +use core::sync::atomic::{AtomicUsize, Ordering}; + +use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; +use thiserror::Error; + +/// Error accessing, creating, or exporting a host object holding a file's contents. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum SharedFileMemoryError { + /// The host could not provide enough memory. + #[error("out of host shared memory")] + NoMemory, + /// The host failed to access the memory. + #[error("host shared memory I/O failed")] + Io, + /// The host cannot shrink the memory while it is mapped. + #[error("host shared memory is mapped")] + Busy, + /// Copying to or from the caller's buffer faulted. + #[error("buffer access faulted")] + Fault, + /// The host cannot provide a handle with the requested rights. + #[error("host cannot provide a handle to the object")] + Unsupported, +} + +impl From for SharedFileMemoryError { + fn from(_: litebox_platform::buffer::Fault) -> Self { + Self::Fault + } +} + +/// A host handle to a [`SharedFileObject`], such as a descriptor on Linux or a `HANDLE` on +/// Windows, which the object lends for as long as it is borrowed. +#[derive(Clone, Copy, Debug)] +pub struct HostHandle<'object> { + raw: u64, + _object: PhantomData<&'object ()>, +} + +impl HostHandle<'_> { + /// Lends the host handle `raw`. + /// + /// # Safety + /// + /// `raw` must name a host handle that stays open for as long as the returned value lives. + pub unsafe fn borrow_raw(raw: u64) -> Self { + Self { + raw, + _object: PhantomData, + } + } + + /// Returns the raw host handle, which stays open for as long as `self` lives. + pub fn raw(self) -> u64 { + self.raw + } +} + +/// A host object whose pages are one file's contents. +/// +/// Its size is the file's size. +pub trait SharedFileObject: Send + Sync { + /// Returns a host handle for exporting the object, writable only if `writable` is set. + /// + /// The handle's own rights enforce `writable` where the host checks them when mapping, as + /// for a descriptor; otherwise the deployment must derive an object with those rights, + /// such as a section. + /// + /// Fails with [`SharedFileMemoryError::Unsupported`] if the host cannot provide such a + /// handle. + fn host_handle(&self, writable: bool) -> Result, SharedFileMemoryError>; +} + +/// Host memory that holds one file's contents and serves as the backend's storage. +pub trait SharedFileMemory: SharedFileObject { + /// Copies bytes from `offset` into `buffer`, returning how many were copied, which is fewer + /// than `buffer.len()` only at the end of the memory. + fn read_at( + &self, + offset: usize, + buffer: &mut dyn ReadBuffer, + ) -> Result; + + /// Copies all of `data` to `offset`. The memory must already be large enough. + fn write_at(&self, offset: usize, data: &dyn WriteBuffer) -> Result<(), SharedFileMemoryError>; + + /// Returns the size in bytes, including size changes made through an exported handle. + fn size(&self) -> Result; + + /// Changes the size to `length` bytes, zero-filling any added bytes. + fn set_len(&self, length: usize) -> Result<(), SharedFileMemoryError>; +} + +/// Creates [`SharedFileMemory`] for a deployment. +pub trait SharedFileMemoryProvider: Send + Sync { + /// Creates memory of `contents.len()` bytes that initially holds `contents`. + fn create(&self, contents: &[u8]) -> Result, SharedFileMemoryError>; +} + +/// The default for how many files may keep their contents in host shared memory at once. +pub const DEFAULT_MAX_SHARED_FILES: usize = 256; + +/// Bounds how many files keep their contents in host shared memory at once. +/// +/// Backends that share one limit share its bound, so a deployment makes it broker-wide by +/// passing every backend the same limit. +#[derive(Debug)] +pub struct SharedFileLimit { + live: AtomicUsize, + max: usize, +} + +impl SharedFileLimit { + /// Allows up to `max` files at once. + pub const fn new(max: usize) -> Self { + Self { + live: AtomicUsize::new(0), + max, + } + } + + /// Reserves room for one more file, or returns `None` if `max` files already hold some. + pub fn reserve(self: &Arc) -> Option { + self.live + .try_update(Ordering::Relaxed, Ordering::Relaxed, |live| { + live.checked_add(1).filter(|next| *next <= self.max) + }) + .ok()?; + Some(SharedFileSlot(Arc::clone(self))) + } + + /// Returns how many files hold host shared memory now. + pub fn live(&self) -> usize { + self.live.load(Ordering::Relaxed) + } +} + +/// Room for one file in a [`SharedFileLimit`], released when dropped. +#[derive(Debug)] +pub struct SharedFileSlot(Arc); + +impl Drop for SharedFileSlot { + fn drop(&mut self) { + self.0.live.fetch_sub(1, Ordering::Relaxed); + } +} + +/// A file's host shared object, retained by its backend and callers that request it. +pub struct SharedFile { + object: Arc, + _slot: SharedFileSlot, +} + +impl SharedFile { + /// Shares a file through `object`, counted against a limit by `slot`. + pub fn new(object: Arc, slot: SharedFileSlot) -> Self { + Self { + object, + _slot: slot, + } + } + + /// Returns the host object holding the file's contents. + pub fn object(&self) -> &Arc { + &self.object + } +} + +#[cfg(test)] +pub(crate) mod test_memory { + //! A [`SharedFileMemory`] in an ordinary vector, for tests. + + use super::{ + HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, + SharedFileObject, + }; + use alloc::sync::Arc; + use alloc::vec::Vec; + use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; + use spin::mutex::SpinMutex as Mutex; + + struct VecMemory { + data: Mutex>, + refuse_shrink: bool, + } + + impl SharedFileObject for VecMemory { + fn host_handle(&self, _writable: bool) -> Result, SharedFileMemoryError> { + Err(SharedFileMemoryError::Unsupported) + } + } + + impl SharedFileMemory for VecMemory { + fn read_at( + &self, + offset: usize, + buffer: &mut dyn ReadBuffer, + ) -> Result { + let data = self.data.lock(); + let start = offset.min(data.len()); + let end = offset.saturating_add(buffer.len()).min(data.len()); + buffer.copy_from_slice_at(0, &data[start..end])?; + Ok(end - start) + } + + fn write_at( + &self, + offset: usize, + data: &dyn WriteBuffer, + ) -> Result<(), SharedFileMemoryError> { + let mut memory = self.data.lock(); + let end = offset + data.len(); + if end > memory.len() { + return Err(SharedFileMemoryError::Io); + } + data.copy_to_slice_at(0, &mut memory[offset..end])?; + Ok(()) + } + + fn size(&self) -> Result { + Ok(self.data.lock().len()) + } + + fn set_len(&self, length: usize) -> Result<(), SharedFileMemoryError> { + let mut data = self.data.lock(); + if self.refuse_shrink && length < data.len() { + return Err(SharedFileMemoryError::Busy); + } + data.resize(length, 0); + Ok(()) + } + } + + /// Creates vector memory, and remembers it for tests to access as a runner would. + pub(crate) struct VecMemoryProvider { + refuse_shrink: bool, + created: Mutex>>, + } + + impl VecMemoryProvider { + /// Creates memory that refuses to shrink if `refuse_shrink` is set. + pub(crate) fn new(refuse_shrink: bool) -> Self { + Self { + refuse_shrink, + created: Mutex::new(Vec::new()), + } + } + + /// Returns the memory created last. + pub(crate) fn last(&self) -> Arc { + Arc::clone(self.created.lock().last().expect("no memory was created")) + } + } + + impl SharedFileMemoryProvider for VecMemoryProvider { + fn create( + &self, + contents: &[u8], + ) -> Result, SharedFileMemoryError> { + let memory: Arc = Arc::new(VecMemory { + data: Mutex::new(contents.to_vec()), + refuse_shrink: self.refuse_shrink, + }); + self.created.lock().push(Arc::clone(&memory)); + Ok(memory) + } + } +} diff --git a/litebox_broker_core/src/fs/tests.rs b/litebox_broker_core/src/fs/tests.rs index 4508edee0..ce0dbeebd 100644 --- a/litebox_broker_core/src/fs/tests.rs +++ b/litebox_broker_core/src/fs/tests.rs @@ -2584,3 +2584,232 @@ mod composed { ); } } + +mod shared_mapping { + use super::{InMem, InodeAllocator, Mode, OFlags, ROOT, Resolver, TestPlatform, USER}; + use crate::fs::errors::{MapSharedError, OpenError, TruncateError}; + use crate::fs::in_mem::InitialNode; + use crate::fs::shared_memory::test_memory::VecMemoryProvider; + use crate::fs::shared_memory::{DEFAULT_MAX_SHARED_FILES, SharedFileLimit, SharedFileMemory}; + use alloc::borrow::Cow; + use alloc::sync::Arc; + use alloc::vec; + use alloc::vec::Vec; + + fn shared_in_mem_with_limit( + refuse_shrink: bool, + limit: &Arc, + ) -> (InMem, Arc) { + let provider = Arc::new(VecMemoryProvider::new(refuse_shrink)); + let fs = InMem::::new(InodeAllocator::standalone()) + .with_shared_memory(provider.clone(), Arc::clone(limit)); + (fs, provider) + } + + fn shared_in_mem(refuse_shrink: bool) -> (InMem, Arc) { + shared_in_mem_with_limit( + refuse_shrink, + &Arc::new(SharedFileLimit::new(DEFAULT_MAX_SHARED_FILES)), + ) + } + + fn contents(memory: &dyn SharedFileMemory) -> Vec { + let mut bytes = vec![0; memory.size().unwrap()]; + let read = memory.read_at(0, &mut bytes.as_mut_slice()).unwrap(); + assert_eq!(read, bytes.len()); + bytes + } + + #[test] + fn mapped_file_contents_stay_coherent_with_file_io() { + let (fs, provider) = shared_in_mem(false); + let fs = Resolver::::new(fs); + let mut fd = fs + .open(ROOT, "/file", OFlags::CREAT | OFlags::RDWR, Mode::RWXU) + .unwrap(); + fs.write(&mut fd, b"hello", None).unwrap(); + + let (mapping, writable) = fs.map_shared(&fd).unwrap(); + assert!(writable); + let memory = provider.last(); + assert_eq!(contents(&*memory), b"hello"); + + fs.write(&mut fd, b"!!", Some(6)).unwrap(); + assert_eq!(contents(&*memory), b"hello\0!!"); + assert_eq!(fs.handle_status(&fd).unwrap().size, 8); + + memory.write_at(0, &b"J".as_slice()).unwrap(); + let mut read = vec![0; 8]; + assert_eq!(fs.read(&mut fd, &mut read, Some(0)).unwrap(), 8); + assert_eq!(&read, b"Jello\0!!"); + + memory.set_len(10).unwrap(); + assert_eq!(fs.handle_status(&fd).unwrap().size, 10); + fs.truncate(&mut fd, 2, false).unwrap(); + assert_eq!(contents(&*memory), b"Je"); + assert_eq!(fs.handle_status(&fd).unwrap().size, 2); + + let fd = fs + .open(ROOT, "/file", OFlags::RDONLY, Mode::empty()) + .unwrap(); + let (again, writable) = fs.map_shared(&fd).unwrap(); + assert!(!writable); + assert!(Arc::ptr_eq(&again, &mapping)); + } + + #[test] + fn shrinking_mapped_memory_that_refuses_reports_busy() { + let (fs, provider) = shared_in_mem(true); + let fs = Resolver::::new(fs); + let mut fd = fs + .open(ROOT, "/file", OFlags::CREAT | OFlags::RDWR, Mode::RWXU) + .unwrap(); + fs.write(&mut fd, b"hello", None).unwrap(); + fs.map_shared(&fd).unwrap(); + + assert!(matches!( + fs.truncate(&mut fd, 1, false), + Err(TruncateError::Busy) + )); + assert!(matches!( + fs.open(ROOT, "/file", OFlags::RDWR | OFlags::TRUNC, Mode::empty()), + Err(OpenError::TruncateError(TruncateError::Busy)) + )); + assert_eq!(contents(&*provider.last()), b"hello"); + fs.truncate(&mut fd, 8, false).unwrap(); + assert_eq!(fs.handle_status(&fd).unwrap().size, 8); + } + + #[test] + fn mapping_requires_reading_and_support() { + let fs = Resolver::::new(shared_in_mem(false).0); + drop( + fs.open(ROOT, "/file", OFlags::CREAT | OFlags::WRONLY, Mode::RWXU) + .unwrap(), + ); + let write_only = fs + .open(ROOT, "/file", OFlags::WRONLY, Mode::empty()) + .unwrap(); + assert!(matches!( + fs.map_shared(&write_only), + Err(MapSharedError::NotForReading) + )); + let dir = fs.open(ROOT, "/", OFlags::RDONLY, Mode::empty()).unwrap(); + assert!(matches!( + fs.map_shared(&dir), + Err(MapSharedError::IsDirectory) + )); + let path_only = fs.open(ROOT, "/file", OFlags::PATH, Mode::empty()).unwrap(); + assert!(matches!( + fs.map_shared(&path_only), + Err(MapSharedError::PathOnly) + )); + + let fs = super::in_mem_fs(); + let fd = fs + .open(ROOT, "/file", OFlags::CREAT | OFlags::RDWR, Mode::RWXU) + .unwrap(); + assert!(matches!( + fs.map_shared(&fd), + Err(MapSharedError::Unsupported) + )); + } + + #[test] + fn files_in_shared_memory_are_limited_until_they_go_away() { + let limit = Arc::new(SharedFileLimit::new(2)); + let fs = Resolver::::new(shared_in_mem_with_limit(false, &limit).0); + let open = |path| { + fs.open(ROOT, path, OFlags::CREAT | OFlags::RDWR, Mode::RWXU) + .unwrap() + }; + let (first, second, third) = (open("/first"), open("/second"), open("/third")); + let mapping = fs.map_shared(&first).unwrap().0; + fs.map_shared(&second).unwrap(); + assert_eq!(limit.live(), 2); + assert!(matches!( + fs.map_shared(&third), + Err(MapSharedError::NoMemory) + )); + fs.map_shared(&first).unwrap(); + assert_eq!(limit.live(), 2); + + fs.unlink(ROOT, "/first").unwrap(); + assert!(matches!( + fs.map_shared(&third), + Err(MapSharedError::NoMemory) + )); + drop(first); + assert_eq!(limit.live(), 2); + drop(mapping); + assert_eq!(limit.live(), 1); + fs.map_shared(&third).unwrap(); + assert_eq!(limit.live(), 2); + } + + #[test] + fn failed_storage_creation_releases_its_slot_and_preserves_contents() { + use crate::fs::shared_memory::{SharedFileMemoryError, SharedFileMemoryProvider}; + + struct FailingProvider; + + impl SharedFileMemoryProvider for FailingProvider { + fn create( + &self, + _contents: &[u8], + ) -> Result, SharedFileMemoryError> { + Err(SharedFileMemoryError::NoMemory) + } + } + + let limit = Arc::new(SharedFileLimit::new(1)); + let backend = InMem::::new(InodeAllocator::standalone()) + .with_shared_memory(Arc::new(FailingProvider), Arc::clone(&limit)); + let fs = Resolver::::new(backend); + let mut fd = fs + .open(ROOT, "/file", OFlags::CREAT | OFlags::RDWR, Mode::RWXU) + .unwrap(); + fs.write(&mut fd, b"hello", None).unwrap(); + for _ in 0..2 { + assert!(matches!(fs.map_shared(&fd), Err(MapSharedError::NoMemory))); + assert_eq!(limit.live(), 0); + } + let mut read = vec![0; 5]; + assert_eq!(fs.read(&mut fd, &mut read, Some(0)).unwrap(), 5); + assert_eq!(read, b"hello"); + } + + #[test] + fn overlay_shares_only_upper_files() { + let provider = Arc::new(VecMemoryProvider::new(false)); + let upper = InMem::::new_initialized([( + "/", + InitialNode::Directory { + mode: Mode::RWXU | Mode::RWXG | Mode::RWXO, + owner: ROOT, + }, + )]) + .with_shared_memory( + provider.clone(), + Arc::new(SharedFileLimit::new(DEFAULT_MAX_SHARED_FILES)), + ); + let fs = super::overlay_fs(upper, Cow::Borrowed(super::TEST_TAR_FILE)); + let lower = fs + .open(ROOT, "/foo", OFlags::RDONLY, Mode::empty()) + .unwrap(); + assert!(matches!( + fs.map_shared(&lower), + Err(MapSharedError::Unsupported) + )); + let upper = fs.open(USER, "/foo", OFlags::RDWR, Mode::empty()).unwrap(); + fs.map_shared(&upper).unwrap(); + let bytes = contents(&*provider.last()); + assert_eq!(bytes.len(), 8); + let mut read = vec![0; 8]; + let mut lower = fs + .open(ROOT, "/foo", OFlags::RDONLY, Mode::empty()) + .unwrap(); + assert_eq!(fs.read(&mut lower, &mut read, Some(0)).unwrap(), 8); + assert_eq!(read, bytes); + } +} diff --git a/litebox_broker_userland/src/fs/linux.rs b/litebox_broker_userland/src/fs/linux.rs index 2a6b18cb7..64e28e495 100644 --- a/litebox_broker_userland/src/fs/linux.rs +++ b/litebox_broker_userland/src/fs/linux.rs @@ -21,7 +21,7 @@ use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; /// /// Returns `None` without reading unless `buf` is one contiguous part of raw /// bytes. -pub(super) fn read_at(file: &File, buf: &mut dyn ReadBuffer, offset: u64) -> Option> { +pub(crate) fn read_at(file: &File, buf: &mut dyn ReadBuffer, offset: u64) -> Option> { let mut result = None; let length = buf.len(); buf.read_direct_at(0, length, &mut |parts| { @@ -40,7 +40,7 @@ pub(super) fn read_at(file: &File, buf: &mut dyn ReadBuffer, offset: u64) -> Opt /// /// Returns `None` without writing unless `buf` is one contiguous part of raw /// bytes. -pub(super) fn write_at(file: &File, buf: &dyn WriteBuffer, offset: u64) -> Option> { +pub(crate) fn write_at(file: &File, buf: &dyn WriteBuffer, offset: u64) -> Option> { let mut result = None; buf.write_direct_at(0, buf.len(), &mut |parts| { let [part] = parts else { return 0 }; diff --git a/litebox_broker_userland/src/fs/mod.rs b/litebox_broker_userland/src/fs/mod.rs index ceecb8ee6..dae141a19 100644 --- a/litebox_broker_userland/src/fs/mod.rs +++ b/litebox_broker_userland/src/fs/mod.rs @@ -34,9 +34,9 @@ mod io_errors; // and a buffer that is one contiguous part of raw bytes, such as a run of a runner's shared memory. // Other buffers, and all buffers on other hosts, are staged through a bounce buffer. #[cfg(target_os = "linux")] -mod linux; +pub(crate) mod linux; #[cfg(all(windows, target_arch = "x86_64"))] -mod windows; +pub(crate) mod windows; #[cfg(unix)] use io_errors::chown_error; diff --git a/litebox_broker_userland/src/fs/windows.rs b/litebox_broker_userland/src/fs/windows.rs index 572f995c1..c15896772 100644 --- a/litebox_broker_userland/src/fs/windows.rs +++ b/litebox_broker_userland/src/fs/windows.rs @@ -25,7 +25,7 @@ use windows_sys::core::BOOL; /// /// Returns `None` without reading unless `buf` is one contiguous part of raw /// bytes. -pub(super) fn read_at(file: &File, buf: &mut dyn ReadBuffer, offset: u64) -> Option> { +pub(crate) fn read_at(file: &File, buf: &mut dyn ReadBuffer, offset: u64) -> Option> { let mut result = None; let length = buf.len(); buf.read_direct_at(0, length, &mut |parts| { @@ -59,7 +59,7 @@ pub(super) fn read_at(file: &File, buf: &mut dyn ReadBuffer, offset: u64) -> Opt /// /// Returns `None` without writing unless `buf` is one contiguous part of raw /// bytes. -pub(super) fn write_at(file: &File, buf: &dyn WriteBuffer, offset: u64) -> Option> { +pub(crate) fn write_at(file: &File, buf: &dyn WriteBuffer, offset: u64) -> Option> { let mut result = None; buf.write_direct_at(0, buf.len(), &mut |parts| { let [part] = parts else { return 0 }; diff --git a/litebox_broker_userland/src/lib.rs b/litebox_broker_userland/src/lib.rs index ffecb577c..2f6971ef3 100644 --- a/litebox_broker_userland/src/lib.rs +++ b/litebox_broker_userland/src/lib.rs @@ -29,6 +29,7 @@ pub mod readiness; #[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] pub mod runner; pub mod runtime; +pub mod shared_objects; #[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] pub mod socket; diff --git a/litebox_broker_userland/src/main.rs b/litebox_broker_userland/src/main.rs index 3b2ac51c6..c435ea7e1 100644 --- a/litebox_broker_userland/src/main.rs +++ b/litebox_broker_userland/src/main.rs @@ -317,6 +317,22 @@ where None => Cow::Borrowed(EMPTY_TAR_FILE), }; let in_mem = InMem::::new_initialized(entries); + #[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] + let shared_file_limit = Arc::new( + litebox_broker_core::fs::shared_memory::SharedFileLimit::new( + litebox_broker_core::fs::shared_memory::DEFAULT_MAX_SHARED_FILES, + ), + ); + #[cfg(target_os = "linux")] + let in_mem = in_mem.with_shared_memory( + Arc::new(litebox_broker_userland::shared_objects::linux::MemfdFileMemoryProvider), + shared_file_limit, + ); + #[cfg(all(windows, target_arch = "x86_64"))] + let in_mem = in_mem.with_shared_memory( + Arc::new(litebox_broker_userland::shared_objects::windows::TempFileMemoryProvider), + shared_file_limit, + ); let mut builder = Composer::builder() .mount_nestable("/", |allocators| { Overlay::::new( diff --git a/litebox_broker_userland/src/shared_objects.rs b/litebox_broker_userland/src/shared_objects.rs new file mode 100644 index 000000000..454842cb5 --- /dev/null +++ b/litebox_broker_userland/src/shared_objects.rs @@ -0,0 +1,439 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Host-file-backed storage for the broker's in-memory files. +//! +//! Linux uses memfds and Windows uses temporary files. Both providers access contents through +//! positional I/O, including staged copies for buffers that cannot expose their bytes directly. +//! Requesting an object and delivering it to a runner are separate from creating its storage. + +/// A file's contents in a host file, accessed with positional I/O. +#[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] +mod host_file { + use std::fs::File; + use std::io::{Error as IoError, ErrorKind}; + + use litebox_broker_core::fs::shared_memory::{ + HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileObject, + }; + use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; + + const STAGING_SIZE: usize = 4096; + + pub(super) struct HostFileMemory { + file: File, + #[cfg(target_os = "linux")] + read_only: std::sync::OnceLock, + } + + impl HostFileMemory { + fn new(file: File) -> Self { + Self { + file, + #[cfg(target_os = "linux")] + read_only: std::sync::OnceLock::new(), + } + } + + fn read_once(&self, buffer: &mut [u8], offset: u64) -> std::io::Result { + loop { + #[cfg(target_os = "linux")] + let read = std::os::unix::fs::FileExt::read_at(&self.file, buffer, offset); + #[cfg(windows)] + let read = std::os::windows::fs::FileExt::seek_read(&self.file, buffer, offset); + match read { + Err(error) if error.kind() == ErrorKind::Interrupted => {} + #[cfg(windows)] + Err(error) + if error.raw_os_error() + == Some( + windows_sys::Win32::Foundation::ERROR_HANDLE_EOF.cast_signed(), + ) => + { + return Ok(0); + } + read => return read, + } + } + } + + fn write_all(&self, mut data: &[u8], mut offset: u64) -> std::io::Result<()> { + while !data.is_empty() { + #[cfg(target_os = "linux")] + let written = std::os::unix::fs::FileExt::write_at(&self.file, data, offset); + #[cfg(windows)] + let written = std::os::windows::fs::FileExt::seek_write(&self.file, data, offset); + match written { + Ok(0) => return Err(ErrorKind::WriteZero.into()), + Ok(count) => { + data = &data[count..]; + offset += count as u64; + } + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } + Ok(()) + } + } + + pub(super) fn memory_error(error: &IoError) -> SharedFileMemoryError { + let code = error.raw_os_error(); + #[cfg(target_os = "linux")] + if code == Some(libc::ENOSPC) || code == Some(libc::ENOMEM) { + return SharedFileMemoryError::NoMemory; + } + #[cfg(windows)] + { + use windows_sys::Win32::Foundation::{ + ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, ERROR_USER_MAPPED_FILE, + }; + let code = code.and_then(|code| u32::try_from(code).ok()); + if code == Some(ERROR_USER_MAPPED_FILE) { + return SharedFileMemoryError::Busy; + } + if code == Some(ERROR_DISK_FULL) || code == Some(ERROR_HANDLE_DISK_FULL) { + return SharedFileMemoryError::NoMemory; + } + } + SharedFileMemoryError::Io + } + + fn position(offset: usize) -> Result { + u64::try_from(offset).map_err(|_| SharedFileMemoryError::Io) + } + + impl SharedFileMemory for HostFileMemory { + fn read_at( + &self, + offset: usize, + buffer: &mut dyn ReadBuffer, + ) -> Result { + let position = position(offset)?; + #[cfg(target_os = "linux")] + let direct = crate::fs::linux::read_at(&self.file, buffer, position); + #[cfg(windows)] + let direct = crate::fs::windows::read_at(&self.file, buffer, position); + if let Some(read) = direct { + return read.map_err(|error| memory_error(&error)); + } + let mut staging = [0; STAGING_SIZE]; + let mut read = 0; + while read < buffer.len() { + let chunk = &mut staging[..STAGING_SIZE.min(buffer.len() - read)]; + let count = self + .read_once(chunk, position + read as u64) + .map_err(|error| memory_error(&error))?; + if count == 0 { + break; + } + buffer.copy_from_slice_at(read, &chunk[..count])?; + read += count; + } + Ok(read) + } + + fn write_at( + &self, + offset: usize, + data: &dyn WriteBuffer, + ) -> Result<(), SharedFileMemoryError> { + let position = position(offset)?; + #[cfg(target_os = "linux")] + let direct = crate::fs::linux::write_at(&self.file, data, position); + #[cfg(windows)] + let direct = crate::fs::windows::write_at(&self.file, data, position); + // A short direct write continues through the stack buffer. + let mut written = match direct { + Some(written) => written.map_err(|error| memory_error(&error))?, + None => 0, + }; + let mut staging = [0; STAGING_SIZE]; + while written < data.len() { + let chunk = &mut staging[..STAGING_SIZE.min(data.len() - written)]; + data.copy_to_slice_at(written, chunk)?; + self.write_all(chunk, position + written as u64) + .map_err(|error| memory_error(&error))?; + written += chunk.len(); + } + Ok(()) + } + + fn size(&self) -> Result { + let length = self + .file + .metadata() + .map_err(|error| memory_error(&error))? + .len(); + usize::try_from(length).map_err(|_| SharedFileMemoryError::Io) + } + + fn set_len(&self, length: usize) -> Result<(), SharedFileMemoryError> { + self.file + .set_len(position(length)?) + .map_err(|error| memory_error(&error)) + } + } + + impl SharedFileObject for HostFileMemory { + #[cfg(target_os = "linux")] + fn host_handle(&self, writable: bool) -> Result, SharedFileMemoryError> { + use std::os::fd::AsRawFd as _; + + let file = if writable { + &self.file + } else if let Some(read_only) = self.read_only.get() { + read_only + } else { + // Duplication preserves the access mode, so reopen for readers. + let file = File::open(format!("/proc/self/fd/{}", self.file.as_raw_fd())) + .map_err(|_| SharedFileMemoryError::Unsupported)?; + self.read_only.get_or_init(|| file) + }; + let raw = u64::try_from(file.as_raw_fd()).map_err(|_| SharedFileMemoryError::Io)?; + // SAFETY: The descriptor belongs to `self`, which keeps it open while borrowed. + Ok(unsafe { HostHandle::borrow_raw(raw) }) + } + + #[cfg(windows)] + fn host_handle(&self, _writable: bool) -> Result, SharedFileMemoryError> { + use std::os::windows::io::AsRawHandle as _; + + let raw = self.file.as_raw_handle() as usize as u64; + // SAFETY: The handle belongs to `self`, which keeps it open while borrowed. + Ok(unsafe { HostHandle::borrow_raw(raw) }) + } + } + + pub(super) fn create( + file: File, + contents: &[u8], + ) -> Result, SharedFileMemoryError> { + let memory = HostFileMemory::new(file); + memory.set_len(contents.len())?; + memory.write_at(0, &contents)?; + Ok(std::sync::Arc::new(memory)) + } + + #[cfg(test)] + pub(super) mod tests { + use super::*; + use litebox_broker_core::fs::shared_memory::SharedFileMemoryProvider; + use litebox_platform::buffer::{Buffer, Fault}; + + struct IndirectBuffer(Vec); + + impl Buffer for IndirectBuffer { + fn len(&self) -> usize { + self.0.len() + } + } + + impl ReadBuffer for IndirectBuffer { + fn copy_from_slice_at(&mut self, offset: usize, source: &[u8]) -> Result<(), Fault> { + self.0[offset..][..source.len()].copy_from_slice(source); + Ok(()) + } + + fn copy_from_raw_at( + &mut self, + offset: usize, + source: litebox_platform::buffer::RawBytes<'_>, + ) -> Result<(), Fault> { + source.copy_to_slice(&mut self.0[offset..][..source.len()]); + Ok(()) + } + } + + impl WriteBuffer for IndirectBuffer { + fn copy_to_slice_at(&self, offset: usize, destination: &mut [u8]) -> Result<(), Fault> { + destination.copy_from_slice(&self.0[offset..][..destination.len()]); + Ok(()) + } + + fn copy_to_raw_at( + &self, + offset: usize, + destination: litebox_platform::buffer::RawBytes<'_>, + ) -> Result<(), Fault> { + destination.copy_from_slice(&self.0[offset..][..destination.len()]); + Ok(()) + } + } + + pub(in crate::shared_objects) fn check_reads_writes_and_resizes( + provider: &dyn SharedFileMemoryProvider, + ) { + let memory = provider.create(b"hello").unwrap(); + assert_eq!(memory.size().unwrap(), 5); + let mut bytes = [0; 8]; + assert_eq!(memory.read_at(0, &mut bytes.as_mut_slice()).unwrap(), 5); + assert_eq!(&bytes[..5], b"hello"); + memory.set_len(8).unwrap(); + memory.write_at(6, &b"!!".as_slice()).unwrap(); + assert_eq!(memory.read_at(0, &mut bytes.as_mut_slice()).unwrap(), 8); + assert_eq!(&bytes, b"hello\0!!"); + + let large: Vec = (0..3 * STAGING_SIZE + 5) + .map(|i| u8::try_from(i % 251).unwrap()) + .collect(); + memory.set_len(large.len() + 1).unwrap(); + memory.write_at(1, &IndirectBuffer(large.clone())).unwrap(); + let mut read = IndirectBuffer(vec![0; large.len() + 7]); + assert_eq!(memory.read_at(1, &mut read).unwrap(), large.len()); + assert_eq!(&read.0[..large.len()], large.as_slice()); + + memory.set_len(2).unwrap(); + assert_eq!(memory.size().unwrap(), 2); + assert_eq!(memory.read_at(1, &mut bytes.as_mut_slice()).unwrap(), 1); + assert_eq!(memory.read_at(4, &mut bytes.as_mut_slice()).unwrap(), 0); + } + } +} + +/// Memfd-backed shared file memory for Linux. +#[cfg(target_os = "linux")] +pub mod linux { + use std::fs::File; + use std::io::Error as IoError; + use std::os::fd::FromRawFd as _; + use std::sync::Arc; + + use litebox_broker_core::fs::shared_memory::{ + SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, + }; + + use super::host_file::{create, memory_error}; + + /// Creates memory for files in memfds, accessed only through positional I/O by the broker. + pub struct MemfdFileMemoryProvider; + + fn memfd() -> Result { + // Ask for execution support where the host otherwise seals memfds against execution. + // Older kernels and hosts that forbid it leave a plain memfd as the best available. + let memfd = |flags| { + // SAFETY: The name is a valid C string, and the call creates a new descriptor. + unsafe { libc::memfd_create(c"litebox-shared-file".as_ptr(), flags) } + }; + let mut fd = memfd(libc::MFD_CLOEXEC | libc::MFD_EXEC); + if fd < 0 { + fd = memfd(libc::MFD_CLOEXEC); + } + if fd < 0 { + return Err(memory_error(&IoError::last_os_error())); + } + // SAFETY: `memfd_create` returned a new descriptor that nothing else owns. + Ok(unsafe { File::from_raw_fd(fd) }) + } + + impl SharedFileMemoryProvider for MemfdFileMemoryProvider { + fn create( + &self, + contents: &[u8], + ) -> Result, SharedFileMemoryError> { + create(memfd()?, contents) + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn memfd_memory_reads_writes_and_resizes() { + super::super::host_file::tests::check_reads_writes_and_resizes( + &MemfdFileMemoryProvider, + ); + } + + #[test] + fn reader_handles_are_read_only_and_reused() { + let memory = MemfdFileMemoryProvider.create(b"hello").unwrap(); + let reader = memory.host_handle(false).unwrap(); + assert_eq!(reader.raw(), memory.host_handle(false).unwrap().raw()); + let raw = libc::c_int::try_from(reader.raw()).unwrap(); + // SAFETY: The handle lends a live descriptor. + assert_eq!( + unsafe { libc::fcntl(raw, libc::F_GETFL) } & libc::O_ACCMODE, + libc::O_RDONLY + ); + let writer = memory.host_handle(true).unwrap(); + let raw = libc::c_int::try_from(writer.raw()).unwrap(); + // SAFETY: The handle lends a live descriptor. + assert_eq!( + unsafe { libc::fcntl(raw, libc::F_GETFL) } & libc::O_ACCMODE, + libc::O_RDWR + ); + } + } +} + +/// Temporary-file-backed shared file memory for Windows. +/// +/// Pagefile-backed sections cannot grow, so file contents live in temporary files that are +/// deleted when their storage closes. A later deployment may derive sections from these files. +#[cfg(all(windows, target_arch = "x86_64"))] +pub mod windows { + use std::io::ErrorKind; + use std::os::windows::fs::OpenOptionsExt as _; + use std::sync::Arc; + + use litebox_broker_core::fs::shared_memory::{ + SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, + }; + use windows_sys::Win32::Foundation::{GENERIC_EXECUTE, GENERIC_READ, GENERIC_WRITE}; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, + }; + + use super::host_file::{create, memory_error}; + + /// Creates memory for files in the host's temporary directory. + pub struct TempFileMemoryProvider; + + fn temp_file() -> Result { + let directory = std::env::temp_dir(); + loop { + let mut nonce = [0u8; 8]; + getrandom::fill(&mut nonce).map_err(|_| SharedFileMemoryError::Io)?; + let path = directory.join(format!( + "litebox-shared-file-{}-{:016x}", + std::process::id(), + u64::from_le_bytes(nonce), + )); + match std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .access_mode(GENERIC_READ | GENERIC_WRITE | GENERIC_EXECUTE) + .share_mode(0) + .attributes(FILE_ATTRIBUTE_TEMPORARY) + .custom_flags(FILE_FLAG_DELETE_ON_CLOSE) + .open(path) + { + Ok(file) => return Ok(file), + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(error) => return Err(memory_error(&error)), + } + } + } + + impl SharedFileMemoryProvider for TempFileMemoryProvider { + fn create( + &self, + contents: &[u8], + ) -> Result, SharedFileMemoryError> { + create(temp_file()?, contents) + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn temp_file_memory_reads_writes_and_resizes() { + super::super::host_file::tests::check_reads_writes_and_resizes(&TempFileMemoryProvider); + } + } +} diff --git a/litebox_common_linux/src/errno/mod.rs b/litebox_common_linux/src/errno/mod.rs index 65a46c670..c20eb57bc 100644 --- a/litebox_common_linux/src/errno/mod.rs +++ b/litebox_common_linux/src/errno/mod.rs @@ -196,6 +196,7 @@ impl From for Errno { litebox::fs::errors::OpenError::Io => Errno::EIO, litebox::fs::errors::OpenError::NoSuchDevice => Errno::ENXIO, litebox::fs::errors::OpenError::NoSpace => Errno::ENOSPC, + litebox::fs::errors::OpenError::TruncateError(error) => error.into(), _ => unimplemented!(), } } @@ -926,6 +927,7 @@ impl From for Errno { litebox::fs::errors::TruncateError::NotOpenForWriting => Errno::EBADF, litebox::fs::errors::TruncateError::IsTerminalDevice => Errno::EINVAL, litebox::fs::errors::TruncateError::ClosedFd => Errno::EBADF, + litebox::fs::errors::TruncateError::Busy => Errno::EBUSY, litebox::fs::errors::TruncateError::Io => Errno::EIO, } } From 0e6239349e57fdf265872ef38cb3e01e393f9fb6 Mon Sep 17 00:00:00 2001 From: Weiteng Chen Date: Mon, 2 Nov 2026 00:06:24 -0800 Subject: [PATCH 02/12] Address review of broker file storage Grow shared file memory on writes like pwrite, reporting short writes, and report exhausted storage as NoSpace rather than an I/O error. Document that each storage call is atomic so backends access it without holding their own locks, centralize storage error conversions, and return the descriptor's writability with the shared file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- litebox/src/fs/errors.rs | 5 + litebox/src/fs/file.rs | 31 + litebox_broker_core/src/fs/backend.rs | 17 +- litebox_broker_core/src/fs/composer.rs | 12 +- litebox_broker_core/src/fs/errors.rs | 8 +- litebox_broker_core/src/fs/in_mem.rs | 127 ++-- litebox_broker_core/src/fs/overlay.rs | 25 +- litebox_broker_core/src/fs/resolver.rs | 17 +- litebox_broker_core/src/fs/service.rs | 16 + litebox_broker_core/src/fs/shared_memory.rs | 131 +++- litebox_broker_core/src/fs/tests.rs | 290 +++++++- litebox_broker_core/src/quota.rs | 12 +- litebox_broker_userland/src/fs/mod.rs | 62 +- litebox_broker_userland/src/fs/windows.rs | 5 + litebox_broker_userland/src/main.rs | 3 + litebox_broker_userland/src/shared_objects.rs | 635 +++++++++++++++--- litebox_common_linux/src/errno/mod.rs | 29 +- litebox_common_windows/src/nt_status.rs | 4 + litebox_shim_macos/src/syscalls/file.rs | 3 + litebox_shim_windows/src/syscalls/file.rs | 28 +- litebox_shim_windows/src/syscalls/registry.rs | 8 +- 21 files changed, 1241 insertions(+), 227 deletions(-) diff --git a/litebox/src/fs/errors.rs b/litebox/src/fs/errors.rs index 450381216..40015267d 100644 --- a/litebox/src/fs/errors.rs +++ b/litebox/src/fs/errors.rs @@ -74,6 +74,9 @@ pub enum WriteError { /// Copying from the buffer faulted before anything was written. #[error("copying from the buffer faulted")] Fault, + /// The storage holding the file's contents cannot grow to hold the write. + #[error("no space is left for the file's contents")] + NoSpace, } /// Possible errors from [`super::File::seek`]. @@ -111,6 +114,8 @@ pub enum TruncateError { IsTerminalDevice, #[error("the file cannot shrink while it is mapped")] Busy, + #[error("no space is left for the file's contents")] + NoSpace, #[error("I/O error")] Io, } diff --git a/litebox/src/fs/file.rs b/litebox/src/fs/file.rs index a51ad8074..edd086d36 100644 --- a/litebox/src/fs/file.rs +++ b/litebox/src/fs/file.rs @@ -773,6 +773,7 @@ impl From for WriteError { match error { FileError::NotFile => Self::NotAFile, FileError::AccessNotAllowed | FileError::NotForWriting => Self::NotForWriting, + FileError::NoSpace => Self::NoSpace, _ => Self::Io, } } @@ -799,6 +800,7 @@ impl From for TruncateError { FileError::PathOnly => Self::NotOpenForWriting, FileError::IsTerminalDevice => Self::IsTerminalDevice, FileError::Busy => Self::Busy, + FileError::NoSpace => Self::NoSpace, _ => Self::Io, } } @@ -941,3 +943,32 @@ impl From for WalkError { path_error(error).map_or(Self::Io, Into::into) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn broker_file_size_errors_keep_their_meaning() { + assert!(matches!( + WriteError::from(FileError::NoSpace), + WriteError::NoSpace + )); + assert!(matches!( + TruncateError::from(FileError::Busy), + TruncateError::Busy + )); + assert!(matches!( + TruncateError::from(FileError::NoSpace), + TruncateError::NoSpace + )); + assert!(matches!( + OpenError::from(FileError::Busy), + OpenError::TruncateError(TruncateError::Busy) + )); + assert!(matches!( + OpenError::from(FileError::NoSpace), + OpenError::NoSpace + )); + } +} diff --git a/litebox_broker_core/src/fs/backend.rs b/litebox_broker_core/src/fs/backend.rs index e9319bcb5..4c74d9e5b 100644 --- a/litebox_broker_core/src/fs/backend.rs +++ b/litebox_broker_core/src/fs/backend.rs @@ -17,6 +17,7 @@ use super::errors::{ ReadError, RenameError, ResolutionError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, WriteError, }; +use super::shared_memory::SharedFile; use super::{DirEntry, FileStatus, FileType, Mode, OFlags, UserInfo}; use crate::readiness::ReadinessSource; use crate::terminal::TerminalEndpoint; @@ -150,14 +151,13 @@ pub trait Backend: Send + Sync + Any { /// /// After this succeeds, the backend must keep the file's contents in that object, so that /// every mapping and every read, write, and truncation see the same bytes, and must return - /// the same object for every later call on the same file. + /// the same object for every later call on the same file. A handle that the backend later + /// redirects to another file, as an overlay does once it copies a lower file up, follows + /// that file instead: later calls on it return that file's object, or fail if it has none. /// /// The default implementation returns [`MapSharedError::Unsupported`]. #[expect(unused_variables, reason = "default body, non-underscored param names")] - fn map_shared( - &self, - h: &FileHandle, - ) -> Result, MapSharedError> { + fn map_shared(&self, h: &FileHandle) -> Result, MapSharedError> { Err(MapSharedError::Unsupported) } @@ -165,6 +165,10 @@ pub trait Backend: Send + Sync + Any { /// of bytes written from its start. /// /// See [`Self::read`] on internal offset storage for backends. + /// + /// Fails with [`WriteError::NoSpace`] if the storage holding the file's contents cannot grow + /// to hold `buf`. Like `pwrite`, a write that stops partway after writing some bytes + /// returns how many it wrote rather than failing. // 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 @@ -181,6 +185,9 @@ pub trait Backend: Send + Sync + Any { /// /// If shorter than existing size, extra data is lost. If longer than existing size, resize by /// adding `\0`s. + /// + /// Fails with [`TruncateError::Busy`] if the host cannot shrink the file's storage while it + /// is mapped, and with [`TruncateError::NoSpace`] if the storage cannot grow to `length`. fn truncate(&self, h: &FileHandle, length: usize) -> Result<(), TruncateError>; /// Describe seek behavior for an open file handle. diff --git a/litebox_broker_core/src/fs/composer.rs b/litebox_broker_core/src/fs/composer.rs index 8fee02c8e..efcf0f080 100644 --- a/litebox_broker_core/src/fs/composer.rs +++ b/litebox_broker_core/src/fs/composer.rs @@ -18,11 +18,12 @@ use super::backend::{ PermissionInfo, Resolution, ResolvedDir, ResolvedFile, ResolvedTarget, SeekBehavior, }; use super::errors::{ - ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, - ReadError, RenameError, ResolutionError, RmdirError, SymlinkError, TruncateError, UnlinkError, - WalkError, WriteError, + ChmodError, ChownError, FileStatusError, MapSharedError, MkdirError, OpenError, PathError, + ReadDirError, ReadError, RenameError, ResolutionError, RmdirError, SymlinkError, TruncateError, + UnlinkError, WalkError, WriteError, }; use super::inode_allocator::{InodeAllocator, InodeAllocators}; +use super::shared_memory::SharedFile; use super::{DirEntry, FileStatus, FileType, Mode, NodeInfo, OFlags, UserInfo}; use crate::readiness::ReadinessSource; use thiserror::Error; @@ -735,10 +736,7 @@ impl Backend for Composer { .get_static_backing_data(&h.handle) } - fn map_shared( - &self, - h: &FileHandle, - ) -> Result, super::errors::MapSharedError> { + fn map_shared(&self, h: &FileHandle) -> Result, MapSharedError> { let h = h.get_typed::(); self.mounts[h.mount_index].backend.map_shared(&h.handle) } diff --git a/litebox_broker_core/src/fs/errors.rs b/litebox_broker_core/src/fs/errors.rs index 199d536cc..6b317ca75 100644 --- a/litebox_broker_core/src/fs/errors.rs +++ b/litebox_broker_core/src/fs/errors.rs @@ -72,6 +72,9 @@ pub enum WriteError { Io, #[error("no space is available yet")] WouldBlock, + /// The storage holding the file's contents cannot grow to hold the write. + #[error("no space is left for the file's contents")] + NoSpace, /// The broker failed the write itself, such as when copying from the /// caller's buffer faulted or it ran out of memory. #[error(transparent)] @@ -116,11 +119,14 @@ pub enum TruncateError { IsTerminalDevice, #[error("the file cannot shrink while it is mapped")] Busy, + #[error("no space is left for the file's contents")] + NoSpace, #[error("I/O error")] Io, } -/// Possible errors from [`super::resolver::Resolver::map_shared`] +/// Possible errors from [`super::resolver::Resolver::map_shared`] and +/// [`super::backend::Backend::map_shared`] #[derive(Error, Debug)] pub enum MapSharedError { #[error("file descriptor points to a directory")] diff --git a/litebox_broker_core/src/fs/in_mem.rs b/litebox_broker_core/src/fs/in_mem.rs index fc52a5ec3..e90ce6b62 100644 --- a/litebox_broker_core/src/fs/in_mem.rs +++ b/litebox_broker_core/src/fs/in_mem.rs @@ -8,7 +8,7 @@ use alloc::sync::Arc; use alloc::vec::Vec; use hashbrown::HashMap; -use litebox_platform::buffer::{Fault, ReadBuffer, WriteBuffer}; +use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; use litebox_platform::sync; use super::backend::{ @@ -21,7 +21,7 @@ use super::errors::{ }; use super::inode_allocator::InodeAllocator; use super::shared_memory::{ - SharedFile, SharedFileLimit, SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, + SharedFile, SharedFileLimit, SharedFileMemory, SharedFileMemoryProvider, }; use super::{DirEntry, FileStatus, FileType, Mode, NodeInfo, UserInfo}; @@ -75,6 +75,11 @@ impl InMem { /// /// A file keeps that memory for as long as it exists, and at most as many files as `limit` /// allows may hold some at once; mapping another fails as out of memory. + /// + /// Slots are not reclaimed while a mapped file exists, so a guest that can create and map + /// files can use up `limit`, after which every backend sharing it fails new mappings until + /// such files are deleted. A deployment that must isolate guests from one another gives + /// each its own limit. #[must_use] pub fn with_shared_memory( mut self, @@ -514,15 +519,11 @@ impl super::backend::Backend for InMe offset: usize, ) -> Result { let file = h.get_typed::().file.read(); - let data = match &file.contents { - Contents::Inline(data) => data, - Contents::Shared { memory, .. } => { - return memory.read_at(offset, buf).map_err(|error| match error { - SharedFileMemoryError::Fault => Fault.into(), - _ => ReadError::Io, - }); - } - }; + if let Some(memory) = file.shared_memory() { + drop(file); + return Ok(memory.read_at(offset, buf)?); + } + let data = file.inline_bytes(); let start = offset.min(data.len()); let end = offset.checked_add(buf.len()).unwrap().min(data.len()); debug_assert!(start <= end); @@ -536,31 +537,19 @@ impl super::backend::Backend for InMe buf: &dyn WriteBuffer, offset: usize, ) -> Result { + // Like `pwrite`, writing nothing leaves the file as it is, even past its end. + if buf.is_empty() { + return Ok(0); + } let mut file = h.get_typed::().file.write(); + if let Some(memory) = file.shared_memory() { + drop(file); + // Grows the memory itself and, like `pwrite`, reports a write that fails partway as + // a short write, since the memory already holds the bytes before the failure. + return Ok(memory.write_at(offset, buf)?); + } let end = offset.checked_add(buf.len()).unwrap(); - let data = match &mut file.contents { - Contents::Inline(data) => data, - Contents::Shared { memory, .. } => { - let previous_len = memory.size().map_err(|_| WriteError::Io)?; - if end > previous_len { - // Pads with 0s if the offset is past the end of the file. - memory.set_len(end).map_err(|_| WriteError::Io)?; - } - return match memory.write_at(offset, buf) { - Ok(()) => Ok(buf.len()), - Err(error) => { - if end > previous_len { - let _ = memory.set_len(previous_len); - } - Err(match error { - SharedFileMemoryError::Fault => Fault.into(), - _ => WriteError::Io, - }) - } - }; - } - }; - let data = data.to_mut(); + let data = file.inline_bytes_mut().to_mut(); let previous_len = data.len(); if end > previous_len { // Pads with 0s if the offset is past the end of the file. @@ -575,18 +564,14 @@ impl super::backend::Backend for InMe fn truncate(&self, h: &super::backend::FileHandle, length: usize) -> Result<(), TruncateError> { let mut file = h.get_typed::().file.write(); - let data = match &mut file.contents { - Contents::Inline(data) => data, - Contents::Shared { memory, .. } => { - // Shrinking discards the pages past the end, so runners fault on them, as Linux - // raises `SIGBUS` past the end of a mapped file. Hosts that cannot shrink mapped - // memory refuse instead. - return memory.set_len(length).map_err(|error| match error { - SharedFileMemoryError::Busy => TruncateError::Busy, - _ => TruncateError::Io, - }); - } - }; + if let Some(memory) = file.shared_memory() { + drop(file); + // Shrinking discards the pages past the end, so runners fault on them, as Linux + // raises `SIGBUS` past the end of a mapped file. Hosts that cannot shrink mapped + // memory refuse instead. + return Ok(memory.set_len(length)?); + } + let data = file.inline_bytes_mut(); match length.cmp(&data.len()) { core::cmp::Ordering::Less => match data { alloc::borrow::Cow::Borrowed(d) => *d = &d[..length], @@ -606,15 +591,23 @@ impl super::backend::Backend for InMe match h { super::backend::HandleRef::File(h) => { let file = h.get_typed::().file.read(); - Ok(FileStatus { + let mut status = FileStatus { file_type: file.file_type, mode: file.perms.mode, - size: u64::try_from(file.len().map_err(|_| FileStatusError::Io)?) - .map_err(|_| FileStatusError::Io)?, + size: 0, owner: file.perms.userinfo, node_info: file.node_info, blksize: BLOCK_SIZE, - }) + }; + let size = match file.shared_memory() { + None => file.inline_bytes().len(), + Some(memory) => { + drop(file); + memory.size().map_err(|_| FileStatusError::Io)? + } + }; + status.size = u64::try_from(size).map_err(|_| FileStatusError::Io)?; + Ok(status) } super::backend::HandleRef::Dir(h) => { let dir = h.get_typed::().dir.read(); @@ -937,10 +930,7 @@ impl super::backend::Backend for InMe return Err(MapSharedError::Unsupported); } let slot = limit.reserve().ok_or(MapSharedError::NoMemory)?; - let memory = provider.create(data).map_err(|error| match error { - SharedFileMemoryError::NoMemory => MapSharedError::NoMemory, - _ => MapSharedError::Io, - })?; + let memory = provider.create(data)?; let shared = Arc::new(SharedFile::new(memory.clone(), slot)); file.contents = Contents::Shared { memory, @@ -1035,8 +1025,11 @@ struct FileData { enum Contents { /// Bytes in ordinary memory: a regular file's contents, or a symbolic link's target. Inline(alloc::borrow::Cow<'static, [u8]>), - /// A regular file's contents in host shared memory, once a runner has mapped the file: - /// `memory`, which the file system reads and writes, shared with runners as `file`. + /// A regular file's contents in host shared memory, once a runner has mapped the file. + /// + /// Both fields name the same host object: `memory` is the view the file system reads and + /// writes, and `file` is the shareable view handed to callers, which also holds the + /// object's slot in its [`SharedFileLimit`]. Shared { memory: Arc, file: Arc, @@ -1044,10 +1037,14 @@ enum Contents { } impl FileData { - fn len(&self) -> Result { + /// Returns the host shared memory holding the file's contents, if a runner mapped it. + /// + /// Callers access the memory after releasing the file's lock, since the memory orders the + /// broker's accesses itself (see [`SharedFileMemory`]) and host I/O may be slow. + fn shared_memory(&self) -> Option> { match &self.contents { - Contents::Inline(data) => Ok(data.len()), - Contents::Shared { memory, .. } => memory.size(), + Contents::Inline(_) => None, + Contents::Shared { memory, .. } => Some(Arc::clone(memory)), } } @@ -1062,6 +1059,18 @@ impl FileData { Contents::Shared { .. } => panic!("only regular files move into shared memory"), } } + + /// Returns the bytes of a file that runners never mapped, to change them. + /// + /// # Panics + /// + /// Panics if the file's contents are in host shared memory. + fn inline_bytes_mut(&mut self) -> &mut alloc::borrow::Cow<'static, [u8]> { + match &mut self.contents { + Contents::Inline(data) => data, + Contents::Shared { .. } => panic!("the file's contents are in shared memory"), + } + } } #[derive(Clone, Debug)] diff --git a/litebox_broker_core/src/fs/overlay.rs b/litebox_broker_core/src/fs/overlay.rs index c3365d857..b6cbe12a8 100644 --- a/litebox_broker_core/src/fs/overlay.rs +++ b/litebox_broker_core/src/fs/overlay.rs @@ -31,11 +31,12 @@ use super::backend::{ SeekBehavior, }; use super::errors::{ - ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, - ReadError, RenameError, ResolutionError, RmdirError, SymlinkError, TruncateError, UnlinkError, - WalkError, WriteError, + ChmodError, ChownError, FileStatusError, MapSharedError, MkdirError, OpenError, PathError, + ReadDirError, ReadError, RenameError, ResolutionError, RmdirError, SymlinkError, TruncateError, + UnlinkError, WalkError, WriteError, }; use super::inode_allocator::InodeAllocator; +use super::shared_memory::SharedFile; use super::{DirEntry, FileStatus, FileType, Mode, NodeInfo, OFlags, UserInfo}; use crate::readiness::ReadinessSource; @@ -351,7 +352,10 @@ impl Overlay { let progress = self .upper .write(upper, &&buf[written..count], offset + written) - .map_err(|_| OpenError::Io)?; + .map_err(|error| match error { + WriteError::NoSpace => OpenError::NoSpace, + _ => OpenError::Io, + })?; if progress == 0 { return Err(OpenError::Io); } @@ -1085,14 +1089,13 @@ impl Backend for Overlay { }) } - fn map_shared( - &self, - h: &FileHandle, - ) -> Result, super::errors::MapSharedError> { + fn map_shared(&self, h: &FileHandle) -> Result, MapSharedError> { // A file opened for writing was copied up already. A file still in a lower layer can - // only be mapped readable, so the lower layer's object serves it, if it has one. - // Copying it up later leaves existing mappings on the lower file, as an - // overlayfs mapping of a lower file does. + // only be mapped readable, so the lower layer's object serves it, if it has one; a + // read-only lower layer has none, so mapping fails with `Unsupported`. Once another + // handle copies the file up, this handle follows the upper copy as its reads do, so + // mapping it returns the upper copy's object. Existing mappings stay on the lower + // file, as an overlayfs mapping of a lower file does. self.with_file(h.get_typed::(), |_, backend, handle| { backend.map_shared(handle) }) diff --git a/litebox_broker_core/src/fs/resolver.rs b/litebox_broker_core/src/fs/resolver.rs index adee7b5a1..2c929148c 100644 --- a/litebox_broker_core/src/fs/resolver.rs +++ b/litebox_broker_core/src/fs/resolver.rs @@ -1251,19 +1251,24 @@ impl Resolver, - ) -> Result<(Arc, bool), MapSharedError> { + ) -> Result { + // An `O_PATH` descriptor cannot be mapped whatever it names, as Linux fails `mmap` of + // one with `EBADF`. + if entry.path_only { + return Err(MapSharedError::PathOnly); + } let file = match &entry.handle { Handle::File(file) => file, Handle::Dir(_) => return Err(MapSharedError::IsDirectory), }; - if entry.path_only { - return Err(MapSharedError::PathOnly); - } if !entry.read_allowed { return Err(MapSharedError::NotForReading); } - let contents = self.backend.map_shared(file)?; - Ok((contents, entry.write_allowed)) + let file = self.backend.map_shared(file)?; + Ok(super::shared_memory::SharedMapping { + file, + writable: entry.write_allowed, + }) } } diff --git a/litebox_broker_core/src/fs/service.rs b/litebox_broker_core/src/fs/service.rs index cbf98ed4b..408f3df13 100644 --- a/litebox_broker_core/src/fs/service.rs +++ b/litebox_broker_core/src/fs/service.rs @@ -1011,6 +1011,7 @@ fn file_write_error(error: WriteError) -> FileError { match error { WriteError::NotAFile => FileError::NotFile, WriteError::NotForWriting => FileError::NotForWriting, + WriteError::NoSpace => FileError::NoSpace, WriteError::ClosedFd | WriteError::Io | WriteError::WouldBlock | WriteError::Broker(_) => { FileError::Io } @@ -1034,6 +1035,7 @@ fn file_truncate_error(error: TruncateError) -> FileError { TruncateError::NotOpenForWriting => FileError::PathOnly, TruncateError::IsTerminalDevice => FileError::IsTerminalDevice, TruncateError::Busy => FileError::Busy, + TruncateError::NoSpace => FileError::NoSpace, TruncateError::ClosedFd | TruncateError::Io => FileError::Io, } } @@ -1165,4 +1167,18 @@ mod tests { assert_eq!(status, expected); } + + #[test] + fn file_size_errors_keep_their_meaning() { + assert_eq!(file_write_error(WriteError::NoSpace), FileError::NoSpace); + for (error, expected) in [ + (|| TruncateError::Busy, FileError::Busy), + (|| TruncateError::NoSpace, FileError::NoSpace), + ] as [(fn() -> TruncateError, FileError); 2] + { + assert_eq!(file_truncate_error(error()), expected); + // `open(O_TRUNC)` reports truncation failures the same way. + assert_eq!(file_open_error(OpenError::TruncateError(error())), expected); + } + } } diff --git a/litebox_broker_core/src/fs/shared_memory.rs b/litebox_broker_core/src/fs/shared_memory.rs index c05e066ba..ba88927f5 100644 --- a/litebox_broker_core/src/fs/shared_memory.rs +++ b/litebox_broker_core/src/fs/shared_memory.rs @@ -19,6 +19,8 @@ use core::sync::atomic::{AtomicUsize, Ordering}; use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; use thiserror::Error; +use super::errors::{MapSharedError, ReadError, TruncateError, WriteError}; + /// Error accessing, creating, or exporting a host object holding a file's contents. #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] pub enum SharedFileMemoryError { @@ -45,6 +47,45 @@ impl From for SharedFileMemoryError { } } +impl From for ReadError { + fn from(error: SharedFileMemoryError) -> Self { + match error { + SharedFileMemoryError::Fault => litebox_platform::buffer::Fault.into(), + _ => Self::Io, + } + } +} + +impl From for WriteError { + fn from(error: SharedFileMemoryError) -> Self { + match error { + SharedFileMemoryError::Fault => litebox_platform::buffer::Fault.into(), + SharedFileMemoryError::NoMemory => Self::NoSpace, + _ => Self::Io, + } + } +} + +impl From for TruncateError { + fn from(error: SharedFileMemoryError) -> Self { + match error { + SharedFileMemoryError::Busy => Self::Busy, + SharedFileMemoryError::NoMemory => Self::NoSpace, + _ => Self::Io, + } + } +} + +impl From for MapSharedError { + fn from(error: SharedFileMemoryError) -> Self { + match error { + SharedFileMemoryError::NoMemory => Self::NoMemory, + SharedFileMemoryError::Unsupported => Self::Unsupported, + _ => Self::Io, + } + } +} + /// A host handle to a [`SharedFileObject`], such as a descriptor on Linux or a `HANDLE` on /// Windows, which the object lends for as long as it is borrowed. #[derive(Clone, Copy, Debug)] @@ -88,6 +129,11 @@ pub trait SharedFileObject: Send + Sync { } /// Host memory that holds one file's contents and serves as the backend's storage. +/// +/// Each call is atomic with respect to the other calls on the same memory: a read sees all or +/// none of a concurrent write's bytes, and a resize never lands partway through a write. Backends +/// therefore call it without holding their own locks. Runners' accesses through mappings are +/// not ordered with the broker's. pub trait SharedFileMemory: SharedFileObject { /// Copies bytes from `offset` into `buffer`, returning how many were copied, which is fewer /// than `buffer.len()` only at the end of the memory. @@ -97,8 +143,17 @@ pub trait SharedFileMemory: SharedFileObject { buffer: &mut dyn ReadBuffer, ) -> Result; - /// Copies all of `data` to `offset`. The memory must already be large enough. - fn write_at(&self, offset: usize, data: &dyn WriteBuffer) -> Result<(), SharedFileMemoryError>; + /// Copies `data` to `offset`, growing the memory with zeros to end there if it is shorter, + /// as `pwrite` does, and returns how many bytes were copied. + /// + /// Like `pwrite`, this returns fewer than `data.len()` bytes when an error stops the copy + /// after some bytes were written, which may have grown the memory to end after them; it + /// fails only if nothing was written, leaving the size unchanged. + fn write_at( + &self, + offset: usize, + data: &dyn WriteBuffer, + ) -> Result; /// Returns the size in bytes, including size changes made through an exported handle. fn size(&self) -> Result; @@ -137,12 +192,7 @@ impl SharedFileLimit { /// Reserves room for one more file, or returns `None` if `max` files already hold some. pub fn reserve(self: &Arc) -> Option { - self.live - .try_update(Ordering::Relaxed, Ordering::Relaxed, |live| { - live.checked_add(1).filter(|next| *next <= self.max) - }) - .ok()?; - Some(SharedFileSlot(Arc::clone(self))) + crate::quota::try_increment(&self.live, self.max).then(|| SharedFileSlot(Arc::clone(self))) } /// Returns how many files hold host shared memory now. @@ -182,6 +232,14 @@ impl SharedFile { } } +/// A file's host shared object as one descriptor may map it. +pub struct SharedMapping { + /// The file's host shared object. + pub file: Arc, + /// Whether the descriptor may map the object writable. + pub writable: bool, +} + #[cfg(test)] pub(crate) mod test_memory { //! A [`SharedFileMemory`] in an ordinary vector, for tests. @@ -195,9 +253,39 @@ pub(crate) mod test_memory { use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; use spin::mutex::SpinMutex as Mutex; + /// How many bytes [`VecMemory`] copies at a time, so tests can stop a write partway as hosts + /// that write in chunks may. + const CHUNK_SIZE: usize = 2; + struct VecMemory { data: Mutex>, refuse_shrink: bool, + capacity: usize, + } + + impl VecMemory { + /// Writes one chunk to `memory`, growing it to end after the chunk. + fn write_chunk( + &self, + memory: &mut Vec, + offset: usize, + data: &dyn WriteBuffer, + written: usize, + ) -> Result { + let mut staged = [0; CHUNK_SIZE]; + let staged = &mut staged[..CHUNK_SIZE.min(data.len() - written)]; + data.copy_to_slice_at(written, staged)?; + let start = offset + written; + let end = start + staged.len(); + if end > self.capacity { + return Err(SharedFileMemoryError::NoMemory); + } + if end > memory.len() { + memory.resize(end, 0); + } + memory[start..end].copy_from_slice(staged); + Ok(staged.len()) + } } impl SharedFileObject for VecMemory { @@ -223,14 +311,17 @@ pub(crate) mod test_memory { &self, offset: usize, data: &dyn WriteBuffer, - ) -> Result<(), SharedFileMemoryError> { + ) -> Result { let mut memory = self.data.lock(); - let end = offset + data.len(); - if end > memory.len() { - return Err(SharedFileMemoryError::Io); + let mut written = 0; + while written < data.len() { + match self.write_chunk(&mut memory, offset, data, written) { + Ok(count) => written += count, + Err(_) if written > 0 => break, + Err(error) => return Err(error), + } } - data.copy_to_slice_at(0, &mut memory[offset..end])?; - Ok(()) + Ok(written) } fn size(&self) -> Result { @@ -242,6 +333,9 @@ pub(crate) mod test_memory { if self.refuse_shrink && length < data.len() { return Err(SharedFileMemoryError::Busy); } + if length > self.capacity { + return Err(SharedFileMemoryError::NoMemory); + } data.resize(length, 0); Ok(()) } @@ -250,6 +344,7 @@ pub(crate) mod test_memory { /// Creates vector memory, and remembers it for tests to access as a runner would. pub(crate) struct VecMemoryProvider { refuse_shrink: bool, + capacity: usize, created: Mutex>>, } @@ -258,10 +353,17 @@ pub(crate) mod test_memory { pub(crate) fn new(refuse_shrink: bool) -> Self { Self { refuse_shrink, + capacity: usize::MAX, created: Mutex::new(Vec::new()), } } + /// Makes memory run out of space when growing past `capacity` bytes. + pub(crate) fn with_capacity(mut self, capacity: usize) -> Self { + self.capacity = capacity; + self + } + /// Returns the memory created last. pub(crate) fn last(&self) -> Arc { Arc::clone(self.created.lock().last().expect("no memory was created")) @@ -276,6 +378,7 @@ pub(crate) mod test_memory { let memory: Arc = Arc::new(VecMemory { data: Mutex::new(contents.to_vec()), refuse_shrink: self.refuse_shrink, + capacity: self.capacity, }); self.created.lock().push(Arc::clone(&memory)); Ok(memory) diff --git a/litebox_broker_core/src/fs/tests.rs b/litebox_broker_core/src/fs/tests.rs index ce0dbeebd..fb4517ca2 100644 --- a/litebox_broker_core/src/fs/tests.rs +++ b/litebox_broker_core/src/fs/tests.rs @@ -2587,10 +2587,13 @@ mod composed { mod shared_mapping { use super::{InMem, InodeAllocator, Mode, OFlags, ROOT, Resolver, TestPlatform, USER}; - use crate::fs::errors::{MapSharedError, OpenError, TruncateError}; + use crate::fs::errors::{MapSharedError, OpenError, ReadError, TruncateError, WriteError}; use crate::fs::in_mem::InitialNode; use crate::fs::shared_memory::test_memory::VecMemoryProvider; - use crate::fs::shared_memory::{DEFAULT_MAX_SHARED_FILES, SharedFileLimit, SharedFileMemory}; + use crate::fs::shared_memory::{ + DEFAULT_MAX_SHARED_FILES, SharedFileLimit, SharedFileMemory, SharedMapping, + }; + use crate::test_support::FaultingBuffer; use alloc::borrow::Cow; use alloc::sync::Arc; use alloc::vec; @@ -2629,7 +2632,10 @@ mod shared_mapping { .unwrap(); fs.write(&mut fd, b"hello", None).unwrap(); - let (mapping, writable) = fs.map_shared(&fd).unwrap(); + let SharedMapping { + file: mapping, + writable, + } = fs.map_shared(&fd).unwrap(); assert!(writable); let memory = provider.last(); assert_eq!(contents(&*memory), b"hello"); @@ -2652,7 +2658,10 @@ mod shared_mapping { let fd = fs .open(ROOT, "/file", OFlags::RDONLY, Mode::empty()) .unwrap(); - let (again, writable) = fs.map_shared(&fd).unwrap(); + let SharedMapping { + file: again, + writable, + } = fs.map_shared(&fd).unwrap(); assert!(!writable); assert!(Arc::ptr_eq(&again, &mapping)); } @@ -2680,6 +2689,83 @@ mod shared_mapping { assert_eq!(fs.handle_status(&fd).unwrap().size, 8); } + #[test] + fn failed_writes_to_mapped_files_report_what_they_wrote() { + let (fs, provider) = shared_in_mem(true); + let fs = Resolver::::new(fs); + let mut fd = fs + .open(ROOT, "/file", OFlags::CREAT | OFlags::RDWR, Mode::RWXU) + .unwrap(); + fs.write(&mut fd, b"hello", None).unwrap(); + fs.map_shared(&fd).unwrap(); + + // A fault before anything is written fails and keeps the size. + assert!(matches!( + fs.write(&mut fd, &FaultingBuffer::new(4, 0), Some(8)), + Err(WriteError::Broker(_)) + )); + assert_eq!(contents(&*provider.last()), b"hello"); + assert_eq!(fs.handle_status(&fd).unwrap().size, 5); + assert!(matches!( + fs.read(&mut fd, &mut FaultingBuffer::new(4, 0), Some(0)), + Err(ReadError::Broker(_)) + )); + + // A fault after the first chunk reports a short write, as `pwrite` does, and advances + // the position past the bytes written. + assert_eq!( + fs.write(&mut fd, &FaultingBuffer::new(4, 3), None).unwrap(), + 2 + ); + assert_eq!(contents(&*provider.last()), b"hello\0\0"); + assert_eq!(fs.write(&mut fd, b"!", None).unwrap(), 1); + assert_eq!(contents(&*provider.last()), b"hello\0\0!"); + } + + #[test] + fn mapped_files_out_of_space_keep_their_size() { + let provider = Arc::new(VecMemoryProvider::new(false).with_capacity(8)); + let fs = InMem::::new(InodeAllocator::standalone()).with_shared_memory( + provider.clone(), + Arc::new(SharedFileLimit::new(DEFAULT_MAX_SHARED_FILES)), + ); + let fs = Resolver::::new(fs); + let mut fd = fs + .open(ROOT, "/file", OFlags::CREAT | OFlags::RDWR, Mode::RWXU) + .unwrap(); + fs.write(&mut fd, b"hello", None).unwrap(); + fs.map_shared(&fd).unwrap(); + + assert!(matches!( + fs.write(&mut fd, b"!!", Some(8)), + Err(WriteError::NoSpace) + )); + // Writing nothing past the end leaves the size alone, as `pwrite` does. + assert_eq!(fs.write(&mut fd, b"", Some(9)).unwrap(), 0); + assert!(matches!( + fs.truncate(&mut fd, 9, false), + Err(TruncateError::NoSpace) + )); + assert_eq!(contents(&*provider.last()), b"hello"); + assert_eq!(fs.handle_status(&fd).unwrap().size, 5); + + // Running out partway reports the bytes that fit. + assert_eq!(fs.write(&mut fd, b"abcdef", Some(4)).unwrap(), 4); + assert_eq!(contents(&*provider.last()), b"hellabcd"); + } + + #[test] + fn empty_writes_past_the_end_leave_unmapped_files_alone() { + let fs = super::in_mem_fs(); + let mut fd = fs + .open(ROOT, "/file", OFlags::CREAT | OFlags::RDWR, Mode::RWXU) + .unwrap(); + fs.write(&mut fd, b"hello", None).unwrap(); + // Like `pwrite`, writing nothing past the end leaves the size alone. + assert_eq!(fs.write(&mut fd, b"", Some(9)).unwrap(), 0); + assert_eq!(fs.handle_status(&fd).unwrap().size, 5); + } + #[test] fn mapping_requires_reading_and_support() { let fs = Resolver::::new(shared_in_mem(false).0); @@ -2704,6 +2790,11 @@ mod shared_mapping { fs.map_shared(&path_only), Err(MapSharedError::PathOnly) )); + let path_only_dir = fs.open(ROOT, "/", OFlags::PATH, Mode::empty()).unwrap(); + assert!(matches!( + fs.map_shared(&path_only_dir), + Err(MapSharedError::PathOnly) + )); let fs = super::in_mem_fs(); let fd = fs @@ -2724,7 +2815,7 @@ mod shared_mapping { .unwrap() }; let (first, second, third) = (open("/first"), open("/second"), open("/third")); - let mapping = fs.map_shared(&first).unwrap().0; + let mapping = fs.map_shared(&first).unwrap().file; fs.map_shared(&second).unwrap(); assert_eq!(limit.live(), 2); assert!(matches!( @@ -2779,6 +2870,40 @@ mod shared_mapping { assert_eq!(read, b"hello"); } + #[test] + fn mapped_files_lose_their_static_backing_data() { + let backend = InMem::::new_initialized([ + ( + "/", + InitialNode::Directory { + mode: Mode::RWXU, + owner: ROOT, + }, + ), + ( + "/file", + InitialNode::File { + mode: Mode::RWXU, + owner: ROOT, + data: Cow::Borrowed(b"static"), + }, + ), + ]) + .with_shared_memory( + Arc::new(VecMemoryProvider::new(false)), + Arc::new(SharedFileLimit::new(DEFAULT_MAX_SHARED_FILES)), + ); + let fs = Resolver::::new(backend); + let mut fd = fs.open(ROOT, "/file", OFlags::RDWR, Mode::empty()).unwrap(); + assert_eq!(fs.get_static_backing_data(&fd), Some(&b"static"[..])); + + fs.map_shared(&fd).unwrap(); + assert_eq!(fs.get_static_backing_data(&fd), None); + let mut read = vec![0; 6]; + assert_eq!(fs.read(&mut fd, &mut read, Some(0)).unwrap(), 6); + assert_eq!(read, b"static"); + } + #[test] fn overlay_shares_only_upper_files() { let provider = Arc::new(VecMemoryProvider::new(false)); @@ -2794,7 +2919,7 @@ mod shared_mapping { Arc::new(SharedFileLimit::new(DEFAULT_MAX_SHARED_FILES)), ); let fs = super::overlay_fs(upper, Cow::Borrowed(super::TEST_TAR_FILE)); - let lower = fs + let mut lower = fs .open(ROOT, "/foo", OFlags::RDONLY, Mode::empty()) .unwrap(); assert!(matches!( @@ -2802,14 +2927,161 @@ mod shared_mapping { Err(MapSharedError::Unsupported) )); let upper = fs.open(USER, "/foo", OFlags::RDWR, Mode::empty()).unwrap(); - fs.map_shared(&upper).unwrap(); + let mapping = fs.map_shared(&upper).unwrap().file; let bytes = contents(&*provider.last()); assert_eq!(bytes.len(), 8); + + // The handle opened before the copy-up follows the upper copy, and so does its mapping. let mut read = vec![0; 8]; - let mut lower = fs + assert_eq!(fs.read(&mut lower, &mut read, Some(0)).unwrap(), 8); + assert_eq!(read, bytes); + let SharedMapping { + file: followed, + writable, + } = fs.map_shared(&lower).unwrap(); + assert!(!writable); + assert!(Arc::ptr_eq(&followed, &mapping)); + + let mut reopened = fs .open(ROOT, "/foo", OFlags::RDONLY, Mode::empty()) .unwrap(); - assert_eq!(fs.read(&mut lower, &mut read, Some(0)).unwrap(), 8); + assert_eq!(fs.read(&mut reopened, &mut read, Some(0)).unwrap(), 8); assert_eq!(read, bytes); + assert!(Arc::ptr_eq( + &fs.map_shared(&reopened).unwrap().file, + &mapping + )); + } + + #[test] + fn mapped_files_append_and_truncate_on_open() { + let (fs, provider) = shared_in_mem(false); + let fs = Resolver::::new(fs); + let mut fd = fs + .open(ROOT, "/file", OFlags::CREAT | OFlags::RDWR, Mode::RWXU) + .unwrap(); + fs.write(&mut fd, b"hello", None).unwrap(); + let mapping = fs.map_shared(&fd).unwrap().file; + let memory = provider.last(); + + let mut append = fs + .open( + ROOT, + "/file", + OFlags::WRONLY | OFlags::APPEND, + Mode::empty(), + ) + .unwrap(); + // The memory grows behind the backend's back, and appending still lands at its end. + memory.set_len(6).unwrap(); + assert_eq!(fs.write(&mut append, b"!!", None).unwrap(), 2); + assert_eq!(contents(&*memory), b"hello\0!!"); + + let fd = fs + .open(ROOT, "/file", OFlags::RDWR | OFlags::TRUNC, Mode::empty()) + .unwrap(); + assert_eq!(contents(&*memory), b""); + assert_eq!(fs.handle_status(&fd).unwrap().size, 0); + assert!(Arc::ptr_eq(&fs.map_shared(&fd).unwrap().file, &mapping)); + } + + #[test] + fn composer_shares_files_through_the_mount_holding_them() { + use crate::fs::composer::Composer; + + let provider = Arc::new(VecMemoryProvider::new(false)); + let fs = Resolver::::new( + Composer::builder() + .mount("/", InMem::::new) + .mount("/shared", |allocator| { + InMem::::new(allocator).with_shared_memory( + provider.clone(), + Arc::new(SharedFileLimit::new(DEFAULT_MAX_SHARED_FILES)), + ) + }) + .build() + .unwrap(), + ); + let plain = fs + .open(ROOT, "/file", OFlags::CREAT | OFlags::RDWR, Mode::RWXU) + .unwrap(); + assert!(matches!( + fs.map_shared(&plain), + Err(MapSharedError::Unsupported) + )); + + let mut fd = fs + .open( + ROOT, + "/shared/file", + OFlags::CREAT | OFlags::RDWR, + Mode::RWXU, + ) + .unwrap(); + fs.write(&mut fd, b"hello", None).unwrap(); + let SharedMapping { + file: mapping, + writable, + } = fs.map_shared(&fd).unwrap(); + assert!(writable); + assert_eq!(contents(&*provider.last()), b"hello"); + fs.write(&mut fd, b"J", Some(0)).unwrap(); + assert_eq!(contents(&*provider.last()), b"Jello"); + assert!(Arc::ptr_eq(&fs.map_shared(&fd).unwrap().file, &mapping)); + } + + /// The broker's own stack, a composer over an overlay over shared in-memory files, passes + /// the storage's space and busy errors through unchanged. + #[test] + fn deployment_stack_reports_mapped_file_errors() { + use crate::fs::composer::Composer; + use crate::fs::overlay::Overlay; + use crate::fs::tar_ro::TarRo; + + let provider = Arc::new(VecMemoryProvider::new(true).with_capacity(8)); + let upper = InMem::::new_initialized([( + "/", + InitialNode::Directory { + mode: Mode::RWXU | Mode::RWXG | Mode::RWXO, + owner: ROOT, + }, + )]) + .with_shared_memory( + provider.clone(), + Arc::new(SharedFileLimit::new(DEFAULT_MAX_SHARED_FILES)), + ); + let fs = Resolver::::new( + Composer::builder() + .mount_nestable("/", |allocators| { + Overlay::::new( + upper, + TarRo::new(Cow::Borrowed(super::TEST_TAR_FILE), allocators.next()), + allocators.next(), + ) + }) + .build() + .unwrap(), + ); + let mut fd = fs.open(USER, "/foo", OFlags::RDWR, Mode::empty()).unwrap(); + fs.map_shared(&fd).unwrap(); + assert_eq!(contents(&*provider.last()), b"testfoo\n"); + + assert!(matches!( + fs.write(&mut fd, b"!", Some(8)), + Err(WriteError::NoSpace) + )); + assert!(matches!( + fs.truncate(&mut fd, 9, false), + Err(TruncateError::NoSpace) + )); + assert!(matches!( + fs.truncate(&mut fd, 1, false), + Err(TruncateError::Busy) + )); + assert!(matches!( + fs.open(USER, "/foo", OFlags::RDWR | OFlags::TRUNC, Mode::empty()), + Err(OpenError::TruncateError(TruncateError::Busy)) + )); + assert_eq!(contents(&*provider.last()), b"testfoo\n"); } } diff --git a/litebox_broker_core/src/quota.rs b/litebox_broker_core/src/quota.rs index fa1817bf0..f3b8d5eb2 100644 --- a/litebox_broker_core/src/quota.rs +++ b/litebox_broker_core/src/quota.rs @@ -44,10 +44,18 @@ impl Drop for QuotaReservation { } fn reserve(counter: &AtomicUsize, limit: usize) -> Result<()> { + if try_increment(counter, limit) { + Ok(()) + } else { + Err(BrokerError::ResourceExhausted) + } +} + +/// Adds one to `counter` unless that would take it past `limit`, returning whether it did. +pub(crate) fn try_increment(counter: &AtomicUsize, limit: usize) -> bool { counter .try_update(Ordering::Relaxed, Ordering::Relaxed, |reserved| { reserved.checked_add(1).filter(|next| *next <= limit) }) - .map(|_| ()) - .map_err(|_| BrokerError::ResourceExhausted) + .is_ok() } diff --git a/litebox_broker_userland/src/fs/mod.rs b/litebox_broker_userland/src/fs/mod.rs index dae141a19..19a474e7b 100644 --- a/litebox_broker_userland/src/fs/mod.rs +++ b/litebox_broker_userland/src/fs/mod.rs @@ -38,6 +38,44 @@ pub(crate) mod linux; #[cfg(all(windows, target_arch = "x86_64"))] pub(crate) mod windows; +/// Reads from `file` at `offset` into `buf` with one positioned read, as the bounce path does +/// for buffers that cannot transfer directly. +/// +/// Rejects offsets past `i64::MAX`, which Windows would treat as special values. +pub(crate) fn read_slice_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result { + check_offset(offset)?; + #[cfg(unix)] + { + std::os::unix::fs::FileExt::read_at(file, buf, offset) + } + #[cfg(windows)] + { + std::os::windows::fs::FileExt::seek_read(file, buf, offset) + } +} + +/// Writes `buf` to `file` at `offset` with one positioned write, as the bounce path does for +/// buffers that cannot transfer directly. +/// +/// Rejects offsets past `i64::MAX`, which Windows would treat as special values. +pub(crate) fn write_slice_at(file: &File, buf: &[u8], offset: u64) -> std::io::Result { + check_offset(offset)?; + #[cfg(unix)] + { + std::os::unix::fs::FileExt::write_at(file, buf, offset) + } + #[cfg(windows)] + { + std::os::windows::fs::FileExt::seek_write(file, buf, offset) + } +} + +fn check_offset(offset: u64) -> std::io::Result<()> { + i64::try_from(offset) + .map(drop) + .map_err(|_| std::io::ErrorKind::InvalidInput.into()) +} + #[cfg(unix)] use io_errors::chown_error; use io_errors::{ @@ -752,17 +790,7 @@ impl Backend for LocalFs { return result.map_err(|_| ReadError::Io); } let mut staged = bounce_buffer(buf.len())?; - let result = { - #[cfg(unix)] - { - std::os::unix::fs::FileExt::read_at(&**file, &mut staged, offset) - } - #[cfg(windows)] - { - std::os::windows::fs::FileExt::seek_read(&**file, &mut staged, offset) - } - }; - let read = result.map_err(|_| ReadError::Io)?; + let read = read_slice_at(file, &mut staged, offset).map_err(|_| ReadError::Io)?; buf.copy_from_slice_at(0, &staged[..read])?; Ok(read) } @@ -793,17 +821,7 @@ impl Backend for LocalFs { } let mut staged = bounce_buffer(buf.len())?; buf.copy_to_slice_at(0, &mut staged)?; - let result = { - #[cfg(unix)] - { - std::os::unix::fs::FileExt::write_at(&**file, &staged, offset) - } - #[cfg(windows)] - { - std::os::windows::fs::FileExt::seek_write(&**file, &staged, offset) - } - }; - result.map_err(|_| WriteError::Io) + write_slice_at(file, &staged, offset).map_err(|_| WriteError::Io) } fn truncate(&self, h: &FileHandle, length: usize) -> Result<(), TruncateError> { diff --git a/litebox_broker_userland/src/fs/windows.rs b/litebox_broker_userland/src/fs/windows.rs index c15896772..eaf527601 100644 --- a/litebox_broker_userland/src/fs/windows.rs +++ b/litebox_broker_userland/src/fs/windows.rs @@ -92,6 +92,11 @@ fn transfer( length: usize, call: impl FnOnce(u32, *mut u32, *mut OVERLAPPED) -> BOOL, ) -> Result { + // Windows treats offsets past `i64::MAX` as special values, such as "use the current file + // position", so reject them as Linux rejects them. + if i64::try_from(offset).is_err() { + return Err(Error::from(std::io::ErrorKind::InvalidInput)); + } let mut overlapped = OVERLAPPED::default(); // Splits the offset into its low and high halves. #[allow(clippy::cast_possible_truncation)] diff --git a/litebox_broker_userland/src/main.rs b/litebox_broker_userland/src/main.rs index c435ea7e1..64e7cfd22 100644 --- a/litebox_broker_userland/src/main.rs +++ b/litebox_broker_userland/src/main.rs @@ -317,6 +317,9 @@ where None => Cow::Borrowed(EMPTY_TAR_FILE), }; let in_mem = InMem::::new_initialized(entries); + // One limit bounds the host resources of every guest. Mapped files keep their slots until + // deleted, so one guest can use them all up and make other guests' mappings fail as out of + // memory (see `InMem::with_shared_memory`). #[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] let shared_file_limit = Arc::new( litebox_broker_core::fs::shared_memory::SharedFileLimit::new( diff --git a/litebox_broker_userland/src/shared_objects.rs b/litebox_broker_userland/src/shared_objects.rs index 454842cb5..080e248d1 100644 --- a/litebox_broker_userland/src/shared_objects.rs +++ b/litebox_broker_userland/src/shared_objects.rs @@ -12,36 +12,162 @@ mod host_file { use std::fs::File; use std::io::{Error as IoError, ErrorKind}; + use std::sync::{OnceLock, PoisonError, RwLock}; use litebox_broker_core::fs::shared_memory::{ HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileObject, }; + use litebox_broker_core::transfer::bounce_buffer; use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; - const STAGING_SIZE: usize = 4096; + use crate::fs::{read_slice_at, write_slice_at}; + + /// The most bytes staged at once for buffers that cannot expose their bytes directly. + const STAGING_SIZE: usize = 64 * 1024; pub(super) struct HostFileMemory { file: File, + /// Makes each of the broker's accesses atomic with respect to the others, as + /// [`SharedFileMemory`] requires: reads share it, while writes and resizes take it alone. + io: RwLock<()>, + read_only: OnceLock, + /// A handle without the storage's right to delete the file, for exporting writable. + #[cfg(windows)] + writable: OnceLock, + } + + /// Returns the file in `cell`, opening it with `open` first if it is empty. + fn cached( + cell: &OnceLock, + open: impl FnOnce() -> Result, + ) -> Result<&File, E> { + if let Some(file) = cell.get() { + return Ok(file); + } + let file = open()?; + Ok(cell.get_or_init(|| file)) + } + + /// Returns `offset` as a host file position, or `None` past `i64::MAX`, which hosts reject or, + /// on Windows, treat as special values. + fn position(offset: usize) -> Option { + i64::try_from(offset).ok().map(i64::cast_unsigned) + } + + /// Returns the most bytes the host lets the broker's files hold. + /// + /// On Linux, growing a file past `RLIMIT_FSIZE` would raise `SIGXFSZ`, which ends the broker + /// by default, so the memory never tries to. + fn size_limit() -> u64 { + let limit = i64::MAX.cast_unsigned(); #[cfg(target_os = "linux")] - read_only: std::sync::OnceLock, + { + let mut rlimit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: `rlimit` is valid for the call to fill. + if unsafe { libc::getrlimit(libc::RLIMIT_FSIZE, &raw mut rlimit) } == 0 + && rlimit.rlim_cur != libc::RLIM_INFINITY + { + return limit.min(rlimit.rlim_cur); + } + } + limit } impl HostFileMemory { fn new(file: File) -> Self { Self { file, - #[cfg(target_os = "linux")] - read_only: std::sync::OnceLock::new(), + io: RwLock::new(()), + read_only: OnceLock::new(), + #[cfg(windows)] + writable: OnceLock::new(), + } + } + + /// Opens the contents again with read and execute rights only. + #[cfg(target_os = "linux")] + fn open_read_only(&self) -> Result { + use std::os::fd::AsRawFd as _; + + // Duplication preserves the access mode, so reopen for readers. + File::open(format!("/proc/self/fd/{}", self.file.as_raw_fd())) + .map_err(|_| SharedFileMemoryError::Unsupported) + } + + /// Duplicates the handle with `access` rights only. + #[cfg(windows)] + fn duplicate(&self, access: u32) -> Result { + use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}; + use windows_sys::Win32::Foundation::DuplicateHandle; + use windows_sys::Win32::System::Threading::GetCurrentProcess; + + let mut duplicated = std::ptr::null_mut(); + // SAFETY: `self.file` is a live handle, and the call creates a new handle in this + // process with no more rights than requested. + if unsafe { + DuplicateHandle( + GetCurrentProcess(), + self.file.as_raw_handle(), + GetCurrentProcess(), + &raw mut duplicated, + access, + 0, + 0, + ) + } == 0 + { + return Err(SharedFileMemoryError::Unsupported); + } + // SAFETY: DuplicateHandle returned a new handle that nothing else owns. + Ok(File::from(unsafe { + OwnedHandle::from_raw_handle(duplicated) + })) + } + + /// Returns the handle to export, writable only if `writable` is set. + #[cfg(target_os = "linux")] + fn export(&self, writable: bool) -> Result<&File, SharedFileMemoryError> { + if writable { + Ok(&self.file) + } else { + cached(&self.read_only, || self.open_read_only()) } } + /// Returns the handle to export, writable only if `writable` is set. + #[cfg(windows)] + fn export(&self, writable: bool) -> Result<&File, SharedFileMemoryError> { + use windows_sys::Win32::Storage::FileSystem::{ + FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, + }; + + // The storage's own handle may delete the file, or keep it from being deleted on + // close, so even writers get a duplicate with only the rights to map it. + if writable { + cached(&self.writable, || { + self.duplicate(FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE) + }) + } else { + cached(&self.read_only, || { + self.duplicate(FILE_GENERIC_READ | FILE_GENERIC_EXECUTE) + }) + } + } + + fn len(&self) -> Result { + Ok(self + .file + .metadata() + .map_err(|error| memory_error(&error))? + .len()) + } + fn read_once(&self, buffer: &mut [u8], offset: u64) -> std::io::Result { loop { - #[cfg(target_os = "linux")] - let read = std::os::unix::fs::FileExt::read_at(&self.file, buffer, offset); - #[cfg(windows)] - let read = std::os::windows::fs::FileExt::seek_read(&self.file, buffer, offset); - match read { + match read_slice_at(&self.file, buffer, offset) { Err(error) if error.kind() == ErrorKind::Interrupted => {} #[cfg(windows)] Err(error) @@ -57,17 +183,20 @@ mod host_file { } } - fn write_all(&self, mut data: &[u8], mut offset: u64) -> std::io::Result<()> { + /// Writes all of `data` at `offset`, adding the bytes written to `written` as it goes. + fn write_all( + &self, + mut data: &[u8], + mut offset: u64, + written: &mut usize, + ) -> std::io::Result<()> { while !data.is_empty() { - #[cfg(target_os = "linux")] - let written = std::os::unix::fs::FileExt::write_at(&self.file, data, offset); - #[cfg(windows)] - let written = std::os::windows::fs::FileExt::seek_write(&self.file, data, offset); - match written { + match write_slice_at(&self.file, data, offset) { Ok(0) => return Err(ErrorKind::WriteZero.into()), Ok(count) => { data = &data[count..]; offset += count as u64; + *written += count; } Err(error) if error.kind() == ErrorKind::Interrupted => {} Err(error) => return Err(error), @@ -75,50 +204,162 @@ mod host_file { } Ok(()) } + + /// Writes like [`SharedFileMemory::write_at`], but grows the memory to at most `limit` + /// bytes, writing only the bytes that fit before it, as Linux does at `RLIMIT_FSIZE`. + fn write_limited( + &self, + offset: usize, + data: &dyn WriteBuffer, + limit: u64, + ) -> Result { + if data.is_empty() { + return Ok(0); + } + let position = position(offset) + .filter(|position| *position < limit) + .ok_or(SharedFileMemoryError::NoMemory)?; + let length = + usize::try_from(limit - position).map_or(data.len(), |room| room.min(data.len())); + let _io = self.io.write().unwrap_or_else(PoisonError::into_inner); + // Only a write that fits whole may transfer directly, since it cannot stop early. + let direct = if length == data.len() { + #[cfg(target_os = "linux")] + let direct = crate::fs::linux::write_at(&self.file, data, position); + #[cfg(windows)] + let direct = crate::fs::windows::write_at(&self.file, data, position); + direct + } else { + None + }; + // A short or interrupted direct write continues through the staging buffer. + let mut written = match direct { + Some(Err(error)) if error.kind() == ErrorKind::Interrupted => 0, + Some(written) => written.map_err(|error| memory_error(&error))?, + None => 0, + }; + if written == length { + return Ok(written); + } + let mut staging = match bounce_buffer(STAGING_SIZE.min(length - written)) { + Ok(staging) => staging, + Err(_) if written > 0 => return Ok(written), + Err(_) => return Err(SharedFileMemoryError::NoMemory), + }; + while written < length { + let chunk = &mut staging[..STAGING_SIZE.min(length - written)]; + let result = data + .copy_to_slice_at(written, chunk) + .map_err(SharedFileMemoryError::from) + .and_then(|()| { + self.write_all(chunk, position + written as u64, &mut written) + .map_err(|error| memory_error(&error)) + }); + match result { + Ok(()) => {} + // Like `pwrite`, report the bytes already written rather than the error. + Err(_) if written > 0 => break, + Err(error) => return Err(error), + } + } + Ok(written) + } + + /// Resizes like [`SharedFileMemory::set_len`], but to at most `limit` bytes. + fn set_len_limited(&self, length: usize, limit: u64) -> Result<(), SharedFileMemoryError> { + let length = u64::try_from(length) + .ok() + .filter(|length| *length <= limit) + .ok_or(SharedFileMemoryError::NoMemory)?; + let _io = self.io.write().unwrap_or_else(PoisonError::into_inner); + self.file + .set_len(length) + .map_err(|error| memory_error(&error)) + } } pub(super) fn memory_error(error: &IoError) -> SharedFileMemoryError { let code = error.raw_os_error(); #[cfg(target_os = "linux")] - if code == Some(libc::ENOSPC) || code == Some(libc::ENOMEM) { - return SharedFileMemoryError::NoMemory; + { + if matches!( + code, + Some(libc::ENOSPC | libc::ENOMEM | libc::EFBIG | libc::EMFILE | libc::ENFILE) + ) { + return SharedFileMemoryError::NoMemory; + } + if code == Some(libc::EFAULT) { + return SharedFileMemoryError::Fault; + } } #[cfg(windows)] { use windows_sys::Win32::Foundation::{ - ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, ERROR_USER_MAPPED_FILE, + ERROR_COMMITMENT_LIMIT, ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, ERROR_NOACCESS, + ERROR_NOT_ENOUGH_MEMORY, ERROR_NOT_ENOUGH_QUOTA, ERROR_OUTOFMEMORY, + ERROR_USER_MAPPED_FILE, }; let code = code.and_then(|code| u32::try_from(code).ok()); if code == Some(ERROR_USER_MAPPED_FILE) { return SharedFileMemoryError::Busy; } - if code == Some(ERROR_DISK_FULL) || code == Some(ERROR_HANDLE_DISK_FULL) { + if matches!( + code, + Some( + ERROR_DISK_FULL + | ERROR_HANDLE_DISK_FULL + | ERROR_NOT_ENOUGH_MEMORY + | ERROR_OUTOFMEMORY + | ERROR_COMMITMENT_LIMIT + | ERROR_NOT_ENOUGH_QUOTA + ) + ) { return SharedFileMemoryError::NoMemory; } + if code == Some(ERROR_NOACCESS) { + return SharedFileMemoryError::Fault; + } } SharedFileMemoryError::Io } - fn position(offset: usize) -> Result { - u64::try_from(offset).map_err(|_| SharedFileMemoryError::Io) - } - impl SharedFileMemory for HostFileMemory { fn read_at( &self, offset: usize, buffer: &mut dyn ReadBuffer, ) -> Result { - let position = position(offset)?; + // Nothing lies past the largest position the host accepts. + let Some(position) = position(offset) else { + return Ok(0); + }; + let _io = self.io.read().unwrap_or_else(PoisonError::into_inner); #[cfg(target_os = "linux")] let direct = crate::fs::linux::read_at(&self.file, buffer, position); #[cfg(windows)] let direct = crate::fs::windows::read_at(&self.file, buffer, position); - if let Some(read) = direct { - return read.map_err(|error| memory_error(&error)); + // A short or interrupted direct read continues through the staging buffer, so that + // only the end of the memory stops a read short. + let mut read = match direct { + Some(Ok(0)) => return Ok(0), + Some(Ok(read)) => read, + Some(Err(error)) if error.kind() == ErrorKind::Interrupted => 0, + Some(Err(error)) => return Err(memory_error(&error)), + None => 0, + }; + if read == buffer.len() { + return Ok(read); + } + // A short direct read usually stopped at the end, which the size confirms without + // staging anything. + if read > 0 && position + read as u64 >= self.len()? { + return Ok(read); } - let mut staging = [0; STAGING_SIZE]; - let mut read = 0; + let mut staging = match bounce_buffer(STAGING_SIZE.min(buffer.len() - read)) { + Ok(staging) => staging, + Err(_) if read > 0 => return Ok(read), + Err(_) => return Err(SharedFileMemoryError::NoMemory), + }; while read < buffer.len() { let chunk = &mut staging[..STAGING_SIZE.min(buffer.len() - read)]; let count = self @@ -137,69 +378,28 @@ mod host_file { &self, offset: usize, data: &dyn WriteBuffer, - ) -> Result<(), SharedFileMemoryError> { - let position = position(offset)?; - #[cfg(target_os = "linux")] - let direct = crate::fs::linux::write_at(&self.file, data, position); - #[cfg(windows)] - let direct = crate::fs::windows::write_at(&self.file, data, position); - // A short direct write continues through the stack buffer. - let mut written = match direct { - Some(written) => written.map_err(|error| memory_error(&error))?, - None => 0, - }; - let mut staging = [0; STAGING_SIZE]; - while written < data.len() { - let chunk = &mut staging[..STAGING_SIZE.min(data.len() - written)]; - data.copy_to_slice_at(written, chunk)?; - self.write_all(chunk, position + written as u64) - .map_err(|error| memory_error(&error))?; - written += chunk.len(); - } - Ok(()) + ) -> Result { + self.write_limited(offset, data, size_limit()) } fn size(&self) -> Result { - let length = self - .file - .metadata() - .map_err(|error| memory_error(&error))? - .len(); - usize::try_from(length).map_err(|_| SharedFileMemoryError::Io) + let _io = self.io.read().unwrap_or_else(PoisonError::into_inner); + usize::try_from(self.len()?).map_err(|_| SharedFileMemoryError::Io) } fn set_len(&self, length: usize) -> Result<(), SharedFileMemoryError> { - self.file - .set_len(position(length)?) - .map_err(|error| memory_error(&error)) + self.set_len_limited(length, size_limit()) } } impl SharedFileObject for HostFileMemory { - #[cfg(target_os = "linux")] fn host_handle(&self, writable: bool) -> Result, SharedFileMemoryError> { - use std::os::fd::AsRawFd as _; - - let file = if writable { - &self.file - } else if let Some(read_only) = self.read_only.get() { - read_only - } else { - // Duplication preserves the access mode, so reopen for readers. - let file = File::open(format!("/proc/self/fd/{}", self.file.as_raw_fd())) - .map_err(|_| SharedFileMemoryError::Unsupported)?; - self.read_only.get_or_init(|| file) - }; - let raw = u64::try_from(file.as_raw_fd()).map_err(|_| SharedFileMemoryError::Io)?; - // SAFETY: The descriptor belongs to `self`, which keeps it open while borrowed. - Ok(unsafe { HostHandle::borrow_raw(raw) }) - } - - #[cfg(windows)] - fn host_handle(&self, _writable: bool) -> Result, SharedFileMemoryError> { - use std::os::windows::io::AsRawHandle as _; - - let raw = self.file.as_raw_handle() as usize as u64; + let file = self.export(writable)?; + #[cfg(target_os = "linux")] + let raw = u64::try_from(std::os::fd::AsRawFd::as_raw_fd(file)) + .map_err(|_| SharedFileMemoryError::Io)?; + #[cfg(windows)] + let raw = std::os::windows::io::AsRawHandle::as_raw_handle(file) as usize as u64; // SAFETY: The handle belongs to `self`, which keeps it open while borrowed. Ok(unsafe { HostHandle::borrow_raw(raw) }) } @@ -209,9 +409,14 @@ mod host_file { file: File, contents: &[u8], ) -> Result, SharedFileMemoryError> { + if u64::try_from(contents.len()).map_or(true, |length| length > size_limit()) { + return Err(SharedFileMemoryError::NoMemory); + } let memory = HostFileMemory::new(file); - memory.set_len(contents.len())?; - memory.write_at(0, &contents)?; + // Nothing else can reach the memory yet, so the contents go straight to the file. + memory + .write_all(contents, 0, &mut 0) + .map_err(|error| memory_error(&error))?; Ok(std::sync::Arc::new(memory)) } @@ -219,6 +424,7 @@ mod host_file { pub(super) mod tests { use super::*; use litebox_broker_core::fs::shared_memory::SharedFileMemoryProvider; + use litebox_broker_core::test_support::FaultingBuffer; use litebox_platform::buffer::{Buffer, Fault}; struct IndirectBuffer(Vec); @@ -261,6 +467,84 @@ mod host_file { } } + #[test] + fn host_errors_map_to_memory_errors() { + #[cfg(target_os = "linux")] + let cases = [ + (libc::ENOSPC, SharedFileMemoryError::NoMemory), + (libc::ENOMEM, SharedFileMemoryError::NoMemory), + (libc::EFBIG, SharedFileMemoryError::NoMemory), + (libc::EMFILE, SharedFileMemoryError::NoMemory), + (libc::ENFILE, SharedFileMemoryError::NoMemory), + (libc::EFAULT, SharedFileMemoryError::Fault), + (libc::EIO, SharedFileMemoryError::Io), + ]; + #[cfg(windows)] + let cases = { + use windows_sys::Win32::Foundation::{ + ERROR_COMMITMENT_LIMIT, ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, + ERROR_IO_DEVICE, ERROR_NOACCESS, ERROR_NOT_ENOUGH_MEMORY, + ERROR_NOT_ENOUGH_QUOTA, ERROR_OUTOFMEMORY, ERROR_USER_MAPPED_FILE, + }; + [ + (ERROR_USER_MAPPED_FILE, SharedFileMemoryError::Busy), + (ERROR_DISK_FULL, SharedFileMemoryError::NoMemory), + (ERROR_HANDLE_DISK_FULL, SharedFileMemoryError::NoMemory), + (ERROR_NOT_ENOUGH_MEMORY, SharedFileMemoryError::NoMemory), + (ERROR_OUTOFMEMORY, SharedFileMemoryError::NoMemory), + (ERROR_COMMITMENT_LIMIT, SharedFileMemoryError::NoMemory), + (ERROR_NOT_ENOUGH_QUOTA, SharedFileMemoryError::NoMemory), + (ERROR_NOACCESS, SharedFileMemoryError::Fault), + (ERROR_IO_DEVICE, SharedFileMemoryError::Io), + ] + .map(|(code, error)| (code.cast_signed(), error)) + }; + for (code, expected) in cases { + assert_eq!(memory_error(&IoError::from_raw_os_error(code)), expected); + } + } + + /// Checks that memory in `file` never grows past the host's size limit, here a small + /// one, nor to positions the host cannot address. + pub(in crate::shared_objects) fn check_size_limit(file: File) { + let memory = HostFileMemory::new(file); + // A write crossing the limit writes only what fits, directly or staged, and one at + // the limit fails without growing the memory. + assert_eq!(memory.write_limited(0, &b"hello".as_slice(), 4), Ok(4)); + assert_eq!( + memory.write_limited(2, &IndirectBuffer(b"abc".to_vec()), 4), + Ok(2) + ); + assert_eq!( + memory.write_limited(4, &b"!".as_slice(), 4), + Err(SharedFileMemoryError::NoMemory) + ); + assert_eq!( + memory.set_len_limited(5, 4), + Err(SharedFileMemoryError::NoMemory) + ); + assert_eq!(memory.size(), Ok(4)); + memory.set_len_limited(4, 4).unwrap(); + let mut bytes = [0; 4]; + assert_eq!(memory.read_at(0, &mut bytes.as_mut_slice()), Ok(4)); + assert_eq!(&bytes, b"heab"); + + // Positions past `i64::MAX` hold nothing and cannot be written. + assert_eq!( + memory.write_at(usize::MAX - 1, &b"!".as_slice()), + Err(SharedFileMemoryError::NoMemory) + ); + assert_eq!( + memory.set_len(usize::MAX), + Err(SharedFileMemoryError::NoMemory) + ); + assert_eq!( + memory.read_at(usize::MAX - 1, &mut bytes.as_mut_slice()), + Ok(0) + ); + assert_eq!(memory.size(), Ok(4)); + } + pub(in crate::shared_objects) fn check_reads_writes_and_resizes( provider: &dyn SharedFileMemoryProvider, ) { @@ -269,16 +553,35 @@ mod host_file { let mut bytes = [0; 8]; assert_eq!(memory.read_at(0, &mut bytes.as_mut_slice()).unwrap(), 5); assert_eq!(&bytes[..5], b"hello"); - memory.set_len(8).unwrap(); - memory.write_at(6, &b"!!".as_slice()).unwrap(); + // Writes past the end grow the memory with zeros. + assert_eq!(memory.write_at(6, &b"!!".as_slice()).unwrap(), 2); + assert_eq!(memory.size().unwrap(), 8); assert_eq!(memory.read_at(0, &mut bytes.as_mut_slice()).unwrap(), 8); assert_eq!(&bytes, b"hello\0!!"); + // A fault before anything is written fails without growing the memory, and one + // after the first staged chunk reports the bytes written before it. + assert_eq!( + memory.write_at(9, &FaultingBuffer::new(4, 0)), + Err(SharedFileMemoryError::Fault) + ); + assert_eq!(memory.size().unwrap(), 8); + assert_eq!( + memory + .write_at(8, &FaultingBuffer::new(2 * STAGING_SIZE, STAGING_SIZE + 1)) + .unwrap(), + STAGING_SIZE + ); + assert_eq!(memory.size().unwrap(), 8 + STAGING_SIZE); + let large: Vec = (0..3 * STAGING_SIZE + 5) .map(|i| u8::try_from(i % 251).unwrap()) .collect(); - memory.set_len(large.len() + 1).unwrap(); - memory.write_at(1, &IndirectBuffer(large.clone())).unwrap(); + assert_eq!( + memory.write_at(1, &IndirectBuffer(large.clone())).unwrap(), + large.len() + ); + assert_eq!(memory.size().unwrap(), large.len() + 1); let mut read = IndirectBuffer(vec![0; large.len() + 7]); assert_eq!(memory.read_at(1, &mut read).unwrap(), large.len()); assert_eq!(&read.0[..large.len()], large.as_slice()); @@ -294,9 +597,10 @@ mod host_file { /// Memfd-backed shared file memory for Linux. #[cfg(target_os = "linux")] pub mod linux { - use std::fs::File; + use std::fs::{File, Permissions}; use std::io::Error as IoError; use std::os::fd::FromRawFd as _; + use std::os::unix::fs::PermissionsExt as _; use std::sync::Arc; use litebox_broker_core::fs::shared_memory::{ @@ -323,7 +627,32 @@ pub mod linux { return Err(memory_error(&IoError::last_os_error())); } // SAFETY: `memfd_create` returned a new descriptor that nothing else owns. - Ok(unsafe { File::from_raw_fd(fd) }) + let file = unsafe { File::from_raw_fd(fd) }; + // A runner holding the writable descriptor must not seal the memfd against the broker's + // writes. Memfds created without sealing are sealed against further seals already, but + // hosts that seal memfds against execution allow more seals, so forbid them here. + // SAFETY: `fd` is a live memfd that `file` owns. + unsafe { libc::fcntl(fd, libc::F_ADD_SEALS, libc::F_SEAL_SEAL) }; + // SAFETY: As above. + let seals = unsafe { libc::fcntl(fd, libc::F_GET_SEALS) }; + if seals < 0 || seals & libc::F_SEAL_SEAL == 0 { + return Err(SharedFileMemoryError::Io); + } + // Reopening `/proc//fd/` checks the inode's mode rather than the descriptor's + // access mode, so drop write permission to stop read-only descriptors from being + // reopened for writing. This holds only against openers that cannot bypass or change + // the mode: a process with `CAP_DAC_OVERRIDE` ignores it, and the memfd's owner (the + // broker's uid) or a process with `CAP_FOWNER` can restore it with `fchmod`. Runners + // therefore rely on their seccomp filter, which allows neither `fchmod` nor `open`. + // Execute bits stay, since hosts may seal them. + let mode = file + .metadata() + .map_err(|error| memory_error(&error))? + .permissions() + .mode(); + file.set_permissions(Permissions::from_mode((mode & 0o111) | 0o400)) + .map_err(|error| memory_error(&error))?; + Ok(file) } impl SharedFileMemoryProvider for MemfdFileMemoryProvider { @@ -346,6 +675,21 @@ pub mod linux { ); } + #[test] + fn memfd_memory_stays_within_size_limits() { + super::super::host_file::tests::check_size_limit(memfd().unwrap()); + } + + #[test] + fn memfds_accept_no_seals() { + let memory = MemfdFileMemoryProvider.create(b"hello").unwrap(); + let raw = libc::c_int::try_from(memory.host_handle(true).unwrap().raw()).unwrap(); + // SAFETY: The handle lends a live descriptor. + let seals = unsafe { libc::fcntl(raw, libc::F_GET_SEALS) }; + assert!(seals >= 0); + assert_ne!(seals & libc::F_SEAL_SEAL, 0); + } + #[test] fn reader_handles_are_read_only_and_reused() { let memory = MemfdFileMemoryProvider.create(b"hello").unwrap(); @@ -357,6 +701,13 @@ pub mod linux { unsafe { libc::fcntl(raw, libc::F_GETFL) } & libc::O_ACCMODE, libc::O_RDONLY ); + // Reopening the reader through procfs checks the inode's mode, which must grant no + // write access, whatever capabilities the test runs with. + let mode = std::fs::metadata(format!("/proc/self/fd/{raw}")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o222, 0); let writer = memory.host_handle(true).unwrap(); let raw = libc::c_int::try_from(writer.raw()).unwrap(); // SAFETY: The handle lends a live descriptor. @@ -435,5 +786,113 @@ pub mod windows { fn temp_file_memory_reads_writes_and_resizes() { super::super::host_file::tests::check_reads_writes_and_resizes(&TempFileMemoryProvider); } + + #[test] + fn reader_handles_are_read_only_and_reused() { + use std::mem::ManuallyDrop; + use std::os::windows::fs::FileExt as _; + use std::os::windows::io::FromRawHandle as _; + + let lent = |raw: u64| { + let raw = usize::try_from(raw).unwrap(); + // SAFETY: The handle stays open while `memory` lives, and `ManuallyDrop` leaves + // closing it to `memory`. + ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(raw as _) }) + }; + let memory = TempFileMemoryProvider.create(b"hello").unwrap(); + let reader = memory.host_handle(false).unwrap(); + assert_eq!(reader.raw(), memory.host_handle(false).unwrap().raw()); + let reader = lent(reader.raw()); + let mut bytes = [0; 5]; + assert_eq!(reader.seek_read(&mut bytes, 0).unwrap(), 5); + assert_eq!(&bytes, b"hello"); + assert_eq!( + reader.seek_write(b"x", 0).unwrap_err().kind(), + ErrorKind::PermissionDenied + ); + + let writer = memory.host_handle(true).unwrap(); + assert_ne!(writer.raw(), memory.host_handle(false).unwrap().raw()); + assert_eq!(writer.raw(), memory.host_handle(true).unwrap().raw()); + lent(writer.raw()).seek_write(b"j", 0).unwrap(); + assert_eq!(reader.seek_read(&mut bytes, 0).unwrap(), 5); + assert_eq!(&bytes, b"jello"); + } + + #[test] + fn temp_file_memory_stays_within_size_limits() { + super::super::host_file::tests::check_size_limit(temp_file().unwrap()); + } + + #[test] + fn exported_handles_cannot_keep_or_delete_the_file() { + use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, HANDLE}; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_DISPOSITION_INFO, FileDispositionInfo, SetFileInformationByHandle, + }; + + let memory = TempFileMemoryProvider.create(b"hello").unwrap(); + for writable in [false, true] { + let handle = + usize::try_from(memory.host_handle(writable).unwrap().raw()).unwrap() as HANDLE; + let keep = FILE_DISPOSITION_INFO { DeleteFile: false }; + // SAFETY: `handle` is lent by `memory`, and `keep` is a valid disposition for + // the call to read. + let changed = unsafe { + SetFileInformationByHandle( + handle, + FileDispositionInfo, + (&raw const keep).cast(), + u32::try_from(size_of::()).unwrap(), + ) + }; + assert_eq!(changed, 0); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(ERROR_ACCESS_DENIED.cast_signed()) + ); + } + } + + #[test] + fn shrinking_mapped_memory_reports_busy() { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::System::Memory::{ + CreateFileMappingW, FILE_MAP_READ, MapViewOfFile, PAGE_READONLY, UnmapViewOfFile, + }; + + let memory = TempFileMemoryProvider.create(b"hello").unwrap(); + let handle = + usize::try_from(memory.host_handle(false).unwrap().raw()).unwrap() as HANDLE; + // SAFETY: `handle` is a live file handle lent by `memory`, and the call creates a + // new section handle. + let section = unsafe { + CreateFileMappingW( + handle, + std::ptr::null(), + PAGE_READONLY, + 0, + 0, + std::ptr::null(), + ) + }; + assert!(!section.is_null()); + // SAFETY: `section` is a live section handle, and the call maps a new view of it. + let view = unsafe { MapViewOfFile(section, FILE_MAP_READ, 0, 0, 0) }; + assert!(!view.Value.is_null()); + + assert_eq!(memory.set_len(1), Err(SharedFileMemoryError::Busy)); + assert_eq!(memory.size().unwrap(), 5); + memory.set_len(8).unwrap(); + assert_eq!(memory.size().unwrap(), 8); + + // SAFETY: `view` and `section` were created above and are not used again. + unsafe { + assert_ne!(UnmapViewOfFile(view), 0); + assert_ne!(CloseHandle(section), 0); + } + memory.set_len(1).unwrap(); + assert_eq!(memory.size().unwrap(), 1); + } } } diff --git a/litebox_common_linux/src/errno/mod.rs b/litebox_common_linux/src/errno/mod.rs index c20eb57bc..777d4063f 100644 --- a/litebox_common_linux/src/errno/mod.rs +++ b/litebox_common_linux/src/errno/mod.rs @@ -302,6 +302,7 @@ impl From for Errno { }, litebox::fs::errors::WriteError::Io => Errno::EIO, litebox::fs::errors::WriteError::Fault => Errno::EFAULT, + litebox::fs::errors::WriteError::NoSpace => Errno::ENOSPC, _ => unimplemented!(), } } @@ -927,7 +928,10 @@ impl From for Errno { litebox::fs::errors::TruncateError::NotOpenForWriting => Errno::EBADF, litebox::fs::errors::TruncateError::IsTerminalDevice => Errno::EINVAL, litebox::fs::errors::TruncateError::ClosedFd => Errno::EBADF, - litebox::fs::errors::TruncateError::Busy => Errno::EBUSY, + // `ETXTBSY` is what `truncate` and `open(O_TRUNC)` report for a file the system + // keeps in use, whereas `EBUSY` is not among their documented errors. + litebox::fs::errors::TruncateError::Busy => Errno::ETXTBSY, + litebox::fs::errors::TruncateError::NoSpace => Errno::ENOSPC, litebox::fs::errors::TruncateError::Io => Errno::EIO, } } @@ -974,4 +978,27 @@ mod tests { assert_eq!(Errno::from(error), expected); } } + + #[test] + fn file_size_errors_map_to_linux_errno() { + use litebox::fs::errors::{OpenError, TruncateError, WriteError}; + + let cases: [(fn() -> TruncateError, Errno); 8] = [ + (|| TruncateError::IsDirectory, Errno::EISDIR), + (|| TruncateError::NotForWriting, Errno::EACCES), + (|| TruncateError::NotOpenForWriting, Errno::EBADF), + (|| TruncateError::IsTerminalDevice, Errno::EINVAL), + (|| TruncateError::ClosedFd, Errno::EBADF), + (|| TruncateError::Busy, Errno::ETXTBSY), + (|| TruncateError::NoSpace, Errno::ENOSPC), + (|| TruncateError::Io, Errno::EIO), + ]; + + for (error, expected) in cases { + assert_eq!(Errno::from(error()), expected); + // `open(O_TRUNC)` reports truncation failures the same way. + assert_eq!(Errno::from(OpenError::TruncateError(error())), expected); + } + assert_eq!(Errno::from(WriteError::NoSpace), Errno::ENOSPC); + } } diff --git a/litebox_common_windows/src/nt_status.rs b/litebox_common_windows/src/nt_status.rs index 3c3de0ab1..88c78963b 100644 --- a/litebox_common_windows/src/nt_status.rs +++ b/litebox_common_windows/src/nt_status.rs @@ -219,6 +219,7 @@ impl NtStatus { 0xC0000225 => "STATUS_NOT_FOUND: Not found", 0xC000022D => "STATUS_RETRY: The operation should be retried", 0xC0000235 => "STATUS_HANDLE_NOT_CLOSABLE: Handle not closable", + 0xC0000243 => "STATUS_USER_MAPPED_FILE: The file is mapped by a user section", 0xC00002DF => "STATUS_SAM_NEED_BOOTKEY_PASSWORD: SAM needs boot key password", 0xC00002E0 => "STATUS_SAM_NEED_BOOTKEY_FLOPPY: SAM needs boot key floppy", 0xC0000280 => "STATUS_REPARSE_POINT_NOT_RESOLVED: Reparse point not resolved", @@ -634,6 +635,9 @@ impl NtStatus { /// Handle not closable pub const HANDLE_NOT_CLOSABLE: Self = Self::from_raw(0xC0000235); + /// STATUS_USER_MAPPED_FILE: the file cannot be truncated while a section maps it. + pub const USER_MAPPED_FILE: Self = Self::from_raw(0xC0000243); + /// STATUS_SAM_NEED_BOOTKEY_PASSWORD pub const SAM_NEED_BOOTKEY_PASSWORD: Self = Self::from_raw(0xC00002DF); diff --git a/litebox_shim_macos/src/syscalls/file.rs b/litebox_shim_macos/src/syscalls/file.rs index d61e451ec..f71921abc 100644 --- a/litebox_shim_macos/src/syscalls/file.rs +++ b/litebox_shim_macos/src/syscalls/file.rs @@ -284,6 +284,8 @@ fn open_error(error: OpenError) -> Errno { TruncateError::IsDirectory => Errno::EISDIR, TruncateError::NotForWriting => Errno::EACCES, TruncateError::NotOpenForWriting | TruncateError::IsTerminalDevice => Errno::EINVAL, + TruncateError::Busy => Errno::ETXTBSY, + TruncateError::NoSpace => Errno::ENOSPC, TruncateError::Io => Errno::EIO, }, _ => Errno::EIO, @@ -325,6 +327,7 @@ fn write_error(error: WriteError) -> Errno { WriteError::NotAFile => Errno::EISDIR, WriteError::WouldBlock => Errno::EAGAIN, WriteError::WaitError(_) => Errno::EINTR, + WriteError::NoSpace => Errno::ENOSPC, _ => Errno::EIO, } } diff --git a/litebox_shim_windows/src/syscalls/file.rs b/litebox_shim_windows/src/syscalls/file.rs index 0be05fb8e..d0e4883b1 100644 --- a/litebox_shim_windows/src/syscalls/file.rs +++ b/litebox_shim_windows/src/syscalls/file.rs @@ -10,7 +10,7 @@ use int_enum::IntEnum; use litebox::fd::{FdEnabledSubsystem, FdEnabledSubsystemEntry}; use litebox::fs::errors::{ FileStatusError, MkdirError, OpenError, PathError, ReadDirError, ReadError, SeekError, - WriteError, + TruncateError, WriteError, }; use litebox::platform::{RawConstPointer as _, RawMutPointer as _, RawPointerProvider}; use litebox::utils::TruncateExt as _; @@ -1554,6 +1554,7 @@ impl Task { Err(WriteError::ClosedFd) => IoStatusBlock::failure(NtStatus::INVALID_HANDLE), Err(WriteError::NotForWriting) => IoStatusBlock::failure(NtStatus::ACCESS_DENIED), Err(WriteError::NotAFile) => IoStatusBlock::failure(NtStatus::INVALID_DEVICE_REQUEST), + Err(WriteError::NoSpace) => IoStatusBlock::failure(NtStatus::DISK_FULL), Err(_) => IoStatusBlock::failure(NtStatus::UNSUCCESSFUL), }; self.complete_file_io(event, io_status_block, completion) @@ -2769,6 +2770,10 @@ fn map_open_error(error: OpenError, create_disposition: CreateDisposition) -> Nt NtStatus::ACCESS_DENIED } OpenError::AlreadyExists => NtStatus::OBJECT_NAME_COLLISION, + OpenError::TruncateError(TruncateError::Busy) => NtStatus::USER_MAPPED_FILE, + OpenError::NoSpace | OpenError::TruncateError(TruncateError::NoSpace) => { + NtStatus::DISK_FULL + } _ => NtStatus::UNSUCCESSFUL, } } @@ -2832,6 +2837,27 @@ mod tests { const FILE_CREATE: u32 = 2; const FILE_OVERWRITE: u32 = 4; + #[test] + fn open_size_errors_map_to_nt_status() { + let cases = [ + ( + OpenError::TruncateError(TruncateError::Busy), + NtStatus::USER_MAPPED_FILE, + ), + ( + OpenError::TruncateError(TruncateError::NoSpace), + NtStatus::DISK_FULL, + ), + (OpenError::NoSpace, NtStatus::DISK_FULL), + ]; + for (error, expected) in cases { + assert_eq!( + map_open_error(error, CreateDisposition::Overwrite), + expected + ); + } + } + fn open_object_attributes( path: &str, ) -> ( diff --git a/litebox_shim_windows/src/syscalls/registry.rs b/litebox_shim_windows/src/syscalls/registry.rs index a2cfa7e27..1841cd361 100644 --- a/litebox_shim_windows/src/syscalls/registry.rs +++ b/litebox_shim_windows/src/syscalls/registry.rs @@ -39,7 +39,8 @@ use litebox::event::{ }; use litebox::fd::{FdEnabledSubsystem, FdEnabledSubsystemEntry}; use litebox::fs::errors::{ - FileStatusError, MkdirError, OpenError, PathError, ReadDirError, ReadError, WriteError, + FileStatusError, MkdirError, OpenError, PathError, ReadDirError, ReadError, TruncateError, + WriteError, }; use litebox::platform::{RawConstPointer as _, RawMutPointer as _}; use litebox::sync::Mutex; @@ -2508,6 +2509,10 @@ fn map_open_error(error: OpenError) -> NtStatus { NtStatus::ACCESS_DENIED } OpenError::AlreadyExists => NtStatus::OBJECT_NAME_COLLISION, + OpenError::TruncateError(TruncateError::Busy) => NtStatus::USER_MAPPED_FILE, + OpenError::NoSpace | OpenError::TruncateError(TruncateError::NoSpace) => { + NtStatus::DISK_FULL + } _ => NtStatus::UNSUCCESSFUL, } } @@ -2542,6 +2547,7 @@ fn map_write_error(error: WriteError) -> NtStatus { match error { WriteError::NotForWriting => NtStatus::ACCESS_DENIED, WriteError::NotAFile => NtStatus::OBJECT_TYPE_MISMATCH, + WriteError::NoSpace => NtStatus::DISK_FULL, _ => NtStatus::UNSUCCESSFUL, } } From c4fc276e3be25c17484e83fe62daec2748e5c4bf Mon Sep 17 00:00:00 2001 From: Weiteng Chen Date: Mon, 2 Nov 2026 11:50:38 -0800 Subject: [PATCH 03/12] Lend Windows file storage's own handle for sections Only sections derived from the handle reach runners, and their protection and the rights of the delivered section handle already enforce read-only access, so the read-only and writable duplicates cost two handles per file for nothing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- litebox_broker_userland/src/shared_objects.rs | 158 +++--------------- 1 file changed, 22 insertions(+), 136 deletions(-) diff --git a/litebox_broker_userland/src/shared_objects.rs b/litebox_broker_userland/src/shared_objects.rs index 080e248d1..25a0215c3 100644 --- a/litebox_broker_userland/src/shared_objects.rs +++ b/litebox_broker_userland/src/shared_objects.rs @@ -12,7 +12,9 @@ mod host_file { use std::fs::File; use std::io::{Error as IoError, ErrorKind}; - use std::sync::{OnceLock, PoisonError, RwLock}; + #[cfg(target_os = "linux")] + use std::sync::OnceLock; + use std::sync::{PoisonError, RwLock}; use litebox_broker_core::fs::shared_memory::{ HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileObject, @@ -30,22 +32,9 @@ mod host_file { /// Makes each of the broker's accesses atomic with respect to the others, as /// [`SharedFileMemory`] requires: reads share it, while writes and resizes take it alone. io: RwLock<()>, + /// A read-only open of the file, made the first time a reader exports it. + #[cfg(target_os = "linux")] read_only: OnceLock, - /// A handle without the storage's right to delete the file, for exporting writable. - #[cfg(windows)] - writable: OnceLock, - } - - /// Returns the file in `cell`, opening it with `open` first if it is empty. - fn cached( - cell: &OnceLock, - open: impl FnOnce() -> Result, - ) -> Result<&File, E> { - if let Some(file) = cell.get() { - return Ok(file); - } - let file = open()?; - Ok(cell.get_or_init(|| file)) } /// Returns `offset` as a host file position, or `None` past `i64::MAX`, which hosts reject or, @@ -81,9 +70,8 @@ mod host_file { Self { file, io: RwLock::new(()), + #[cfg(target_os = "linux")] read_only: OnceLock::new(), - #[cfg(windows)] - writable: OnceLock::new(), } } @@ -97,66 +85,21 @@ mod host_file { .map_err(|_| SharedFileMemoryError::Unsupported) } - /// Duplicates the handle with `access` rights only. - #[cfg(windows)] - fn duplicate(&self, access: u32) -> Result { - use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}; - use windows_sys::Win32::Foundation::DuplicateHandle; - use windows_sys::Win32::System::Threading::GetCurrentProcess; - - let mut duplicated = std::ptr::null_mut(); - // SAFETY: `self.file` is a live handle, and the call creates a new handle in this - // process with no more rights than requested. - if unsafe { - DuplicateHandle( - GetCurrentProcess(), - self.file.as_raw_handle(), - GetCurrentProcess(), - &raw mut duplicated, - access, - 0, - 0, - ) - } == 0 - { - return Err(SharedFileMemoryError::Unsupported); - } - // SAFETY: DuplicateHandle returned a new handle that nothing else owns. - Ok(File::from(unsafe { - OwnedHandle::from_raw_handle(duplicated) - })) - } - /// Returns the handle to export, writable only if `writable` is set. + /// + /// The host checks a descriptor's access mode when mapping it, so readers get a read-only + /// open, made once and kept. #[cfg(target_os = "linux")] fn export(&self, writable: bool) -> Result<&File, SharedFileMemoryError> { if writable { - Ok(&self.file) - } else { - cached(&self.read_only, || self.open_read_only()) + return Ok(&self.file); } - } - - /// Returns the handle to export, writable only if `writable` is set. - #[cfg(windows)] - fn export(&self, writable: bool) -> Result<&File, SharedFileMemoryError> { - use windows_sys::Win32::Storage::FileSystem::{ - FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, - }; - - // The storage's own handle may delete the file, or keep it from being deleted on - // close, so even writers get a duplicate with only the rights to map it. - if writable { - cached(&self.writable, || { - self.duplicate(FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE) - }) - } else { - cached(&self.read_only, || { - self.duplicate(FILE_GENERIC_READ | FILE_GENERIC_EXECUTE) - }) + if let Some(file) = self.read_only.get() { + return Ok(file); } + let file = self.open_read_only()?; + Ok(self.read_only.get_or_init(|| file)) } - fn len(&self) -> Result { Ok(self .file @@ -394,12 +337,17 @@ mod host_file { impl SharedFileObject for HostFileMemory { fn host_handle(&self, writable: bool) -> Result, SharedFileMemoryError> { - let file = self.export(writable)?; #[cfg(target_os = "linux")] - let raw = u64::try_from(std::os::fd::AsRawFd::as_raw_fd(file)) + let raw = u64::try_from(std::os::fd::AsRawFd::as_raw_fd(self.export(writable)?)) .map_err(|_| SharedFileMemoryError::Io)?; + // The handle never leaves the broker: the deployment derives a section from it, and + // the section's protection and the rights of the section handle sent to a runner + // enforce `writable`, so readers and writers share the storage's own handle. #[cfg(windows)] - let raw = std::os::windows::io::AsRawHandle::as_raw_handle(file) as usize as u64; + let raw = { + let _ = writable; + std::os::windows::io::AsRawHandle::as_raw_handle(&self.file) as usize as u64 + }; // SAFETY: The handle belongs to `self`, which keeps it open while borrowed. Ok(unsafe { HostHandle::borrow_raw(raw) }) } @@ -787,73 +735,11 @@ pub mod windows { super::super::host_file::tests::check_reads_writes_and_resizes(&TempFileMemoryProvider); } - #[test] - fn reader_handles_are_read_only_and_reused() { - use std::mem::ManuallyDrop; - use std::os::windows::fs::FileExt as _; - use std::os::windows::io::FromRawHandle as _; - - let lent = |raw: u64| { - let raw = usize::try_from(raw).unwrap(); - // SAFETY: The handle stays open while `memory` lives, and `ManuallyDrop` leaves - // closing it to `memory`. - ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(raw as _) }) - }; - let memory = TempFileMemoryProvider.create(b"hello").unwrap(); - let reader = memory.host_handle(false).unwrap(); - assert_eq!(reader.raw(), memory.host_handle(false).unwrap().raw()); - let reader = lent(reader.raw()); - let mut bytes = [0; 5]; - assert_eq!(reader.seek_read(&mut bytes, 0).unwrap(), 5); - assert_eq!(&bytes, b"hello"); - assert_eq!( - reader.seek_write(b"x", 0).unwrap_err().kind(), - ErrorKind::PermissionDenied - ); - - let writer = memory.host_handle(true).unwrap(); - assert_ne!(writer.raw(), memory.host_handle(false).unwrap().raw()); - assert_eq!(writer.raw(), memory.host_handle(true).unwrap().raw()); - lent(writer.raw()).seek_write(b"j", 0).unwrap(); - assert_eq!(reader.seek_read(&mut bytes, 0).unwrap(), 5); - assert_eq!(&bytes, b"jello"); - } - #[test] fn temp_file_memory_stays_within_size_limits() { super::super::host_file::tests::check_size_limit(temp_file().unwrap()); } - #[test] - fn exported_handles_cannot_keep_or_delete_the_file() { - use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, HANDLE}; - use windows_sys::Win32::Storage::FileSystem::{ - FILE_DISPOSITION_INFO, FileDispositionInfo, SetFileInformationByHandle, - }; - - let memory = TempFileMemoryProvider.create(b"hello").unwrap(); - for writable in [false, true] { - let handle = - usize::try_from(memory.host_handle(writable).unwrap().raw()).unwrap() as HANDLE; - let keep = FILE_DISPOSITION_INFO { DeleteFile: false }; - // SAFETY: `handle` is lent by `memory`, and `keep` is a valid disposition for - // the call to read. - let changed = unsafe { - SetFileInformationByHandle( - handle, - FileDispositionInfo, - (&raw const keep).cast(), - u32::try_from(size_of::()).unwrap(), - ) - }; - assert_eq!(changed, 0); - assert_eq!( - std::io::Error::last_os_error().raw_os_error(), - Some(ERROR_ACCESS_DENIED.cast_signed()) - ); - } - } - #[test] fn shrinking_mapped_memory_reports_busy() { use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; From 79d15b269be8bfdb7d931053b19801e5284b4797 Mon Sep 17 00:00:00 2001 From: Weiteng Chen Date: Mon, 2 Nov 2026 14:18:35 -0800 Subject: [PATCH 04/12] Export memfd readers per delivery On Linux, make a reader's read-only reopen of a memfd for each export and close it once delivered, so each file in host shared memory holds one descriptor. HostHandle may now own the handle it exports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- litebox_broker_core/src/fs/shared_memory.rs | 40 ++++++++++-- litebox_broker_userland/src/shared_objects.rs | 64 ++++++++++--------- 2 files changed, 68 insertions(+), 36 deletions(-) diff --git a/litebox_broker_core/src/fs/shared_memory.rs b/litebox_broker_core/src/fs/shared_memory.rs index ba88927f5..827ed75a0 100644 --- a/litebox_broker_core/src/fs/shared_memory.rs +++ b/litebox_broker_core/src/fs/shared_memory.rs @@ -12,6 +12,7 @@ //! [`SharedFileLimit`] bounds how many files hold host resources at once. The file and any //! retained [`SharedFile`] keep their slot until the last reference is dropped. +use alloc::boxed::Box; use alloc::sync::Arc; use core::marker::PhantomData; use core::sync::atomic::{AtomicUsize, Ordering}; @@ -87,14 +88,27 @@ impl From for MapSharedError { } /// A host handle to a [`SharedFileObject`], such as a descriptor on Linux or a `HANDLE` on -/// Windows, which the object lends for as long as it is borrowed. -#[derive(Clone, Copy, Debug)] +/// Windows, which stays open for as long as this value lives. +/// +/// The object either lends one of its own handles, or makes one for this export alone, which +/// closes when this value is dropped, so the object holds no handle for it in between. pub struct HostHandle<'object> { raw: u64, + /// Keeps a handle made for this export open, closing it when dropped. + owner: Option>, _object: PhantomData<&'object ()>, } -impl HostHandle<'_> { +impl core::fmt::Debug for HostHandle<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("HostHandle") + .field("raw", &self.raw) + .field("owned", &self.owner.is_some()) + .finish() + } +} + +impl<'object> HostHandle<'object> { /// Lends the host handle `raw`. /// /// # Safety @@ -103,12 +117,27 @@ impl HostHandle<'_> { pub unsafe fn borrow_raw(raw: u64) -> Self { Self { raw, + owner: None, + _object: PhantomData, + } + } + + /// Exports the host handle `raw`, which `owner` keeps open until the returned value is + /// dropped. + /// + /// # Safety + /// + /// `raw` must name a host handle that stays open for as long as `owner` lives. + pub unsafe fn from_owner(raw: u64, owner: Box) -> Self { + Self { + raw, + owner: Some(owner), _object: PhantomData, } } /// Returns the raw host handle, which stays open for as long as `self` lives. - pub fn raw(self) -> u64 { + pub fn raw(&self) -> u64 { self.raw } } @@ -121,7 +150,8 @@ pub trait SharedFileObject: Send + Sync { /// /// The handle's own rights enforce `writable` where the host checks them when mapping, as /// for a descriptor; otherwise the deployment must derive an object with those rights, - /// such as a section. + /// such as a section. A handle with fewer rights than the object's own may be made for each + /// call, so that the object does not keep one open. /// /// Fails with [`SharedFileMemoryError::Unsupported`] if the host cannot provide such a /// handle. diff --git a/litebox_broker_userland/src/shared_objects.rs b/litebox_broker_userland/src/shared_objects.rs index 25a0215c3..8d8c140e2 100644 --- a/litebox_broker_userland/src/shared_objects.rs +++ b/litebox_broker_userland/src/shared_objects.rs @@ -12,8 +12,6 @@ mod host_file { use std::fs::File; use std::io::{Error as IoError, ErrorKind}; - #[cfg(target_os = "linux")] - use std::sync::OnceLock; use std::sync::{PoisonError, RwLock}; use litebox_broker_core::fs::shared_memory::{ @@ -32,9 +30,6 @@ mod host_file { /// Makes each of the broker's accesses atomic with respect to the others, as /// [`SharedFileMemory`] requires: reads share it, while writes and resizes take it alone. io: RwLock<()>, - /// A read-only open of the file, made the first time a reader exports it. - #[cfg(target_os = "linux")] - read_only: OnceLock, } /// Returns `offset` as a host file position, or `None` past `i64::MAX`, which hosts reject or, @@ -70,8 +65,6 @@ mod host_file { Self { file, io: RwLock::new(()), - #[cfg(target_os = "linux")] - read_only: OnceLock::new(), } } @@ -85,21 +78,6 @@ mod host_file { .map_err(|_| SharedFileMemoryError::Unsupported) } - /// Returns the handle to export, writable only if `writable` is set. - /// - /// The host checks a descriptor's access mode when mapping it, so readers get a read-only - /// open, made once and kept. - #[cfg(target_os = "linux")] - fn export(&self, writable: bool) -> Result<&File, SharedFileMemoryError> { - if writable { - return Ok(&self.file); - } - if let Some(file) = self.read_only.get() { - return Ok(file); - } - let file = self.open_read_only()?; - Ok(self.read_only.get_or_init(|| file)) - } fn len(&self) -> Result { Ok(self .file @@ -338,18 +316,36 @@ mod host_file { impl SharedFileObject for HostFileMemory { fn host_handle(&self, writable: bool) -> Result, SharedFileMemoryError> { #[cfg(target_os = "linux")] - let raw = u64::try_from(std::os::fd::AsRawFd::as_raw_fd(self.export(writable)?)) - .map_err(|_| SharedFileMemoryError::Io)?; + { + use std::os::fd::AsRawFd as _; + + let raw = |file: &File| { + u64::try_from(file.as_raw_fd()).map_err(|_| SharedFileMemoryError::Io) + }; + if writable { + let raw = raw(&self.file)?; + // SAFETY: The memfd belongs to `self`, which keeps it open while borrowed. + return Ok(unsafe { HostHandle::borrow_raw(raw) }); + } + // The host checks a descriptor's access mode when mapping it, so readers get a + // read-only open, made for this export alone so that no extra descriptor stays + // open between deliveries. + let file = self.open_read_only()?; + let raw = raw(&file)?; + // SAFETY: `file` owns the descriptor and keeps it open for as long as it lives. + Ok(unsafe { HostHandle::from_owner(raw, Box::new(file)) }) + } // The handle never leaves the broker: the deployment derives a section from it, and // the section's protection and the rights of the section handle sent to a runner // enforce `writable`, so readers and writers share the storage's own handle. #[cfg(windows)] - let raw = { + { let _ = writable; - std::os::windows::io::AsRawHandle::as_raw_handle(&self.file) as usize as u64 - }; - // SAFETY: The handle belongs to `self`, which keeps it open while borrowed. - Ok(unsafe { HostHandle::borrow_raw(raw) }) + let raw = + std::os::windows::io::AsRawHandle::as_raw_handle(&self.file) as usize as u64; + // SAFETY: The handle belongs to `self`, which keeps it open while borrowed. + Ok(unsafe { HostHandle::borrow_raw(raw) }) + } } } @@ -639,10 +635,11 @@ pub mod linux { } #[test] - fn reader_handles_are_read_only_and_reused() { + fn reader_handles_are_read_only_and_closed_after_export() { + let open_descriptors = || std::fs::read_dir("/proc/self/fd").unwrap().count(); let memory = MemfdFileMemoryProvider.create(b"hello").unwrap(); + let before = open_descriptors(); let reader = memory.host_handle(false).unwrap(); - assert_eq!(reader.raw(), memory.host_handle(false).unwrap().raw()); let raw = libc::c_int::try_from(reader.raw()).unwrap(); // SAFETY: The handle lends a live descriptor. assert_eq!( @@ -656,6 +653,9 @@ pub mod linux { .permissions() .mode(); assert_eq!(mode & 0o222, 0); + drop(reader); + assert_eq!(open_descriptors(), before); + let writer = memory.host_handle(true).unwrap(); let raw = libc::c_int::try_from(writer.raw()).unwrap(); // SAFETY: The handle lends a live descriptor. @@ -663,6 +663,8 @@ pub mod linux { unsafe { libc::fcntl(raw, libc::F_GETFL) } & libc::O_ACCMODE, libc::O_RDWR ); + drop(writer); + assert_eq!(open_descriptors(), before); } } } From bcfaa8b482ccde3dad3ca59bf6dac14b5896ff2e Mon Sep 17 00:00:00 2001 From: Weiteng Chen Date: Mon, 2 Nov 2026 14:22:06 -0800 Subject: [PATCH 05/12] Configure the shared file limit in BrokerCoreLimits Add BrokerCoreLimits::max_shared_files, which the userland broker applies to its in-memory file system, so the limit is set with the broker's other limits. Zero keeps every file in broker memory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- litebox_broker_core/src/lib.rs | 20 +++++++++++ litebox_broker_userland/src/linux.rs | 6 ++-- litebox_broker_userland/src/main.rs | 47 +++++++++++++++----------- litebox_broker_userland/src/windows.rs | 6 ++-- 4 files changed, 55 insertions(+), 24 deletions(-) diff --git a/litebox_broker_core/src/lib.rs b/litebox_broker_core/src/lib.rs index f84e9b312..c4534226c 100644 --- a/litebox_broker_core/src/lib.rs +++ b/litebox_broker_core/src/lib.rs @@ -114,6 +114,14 @@ pub struct BrokerCoreLimits { pub max_child_image_size: u64, /// Maximum total size in bytes of child memory images held by the broker. pub max_total_child_image_size: u64, + /// Maximum files whose contents are in host shared memory at once, so that runners can map + /// them `MAP_SHARED`. + /// + /// Each such file holds a broker descriptor or handle until it is deleted, which may be + /// after every process that mapped it exits, so the limit is broker-wide only. Deployments + /// apply it to the file systems they give the broker (see + /// [`fs::in_mem::InMem::with_shared_memory`]); zero keeps every file in broker memory. + pub max_shared_files: usize, } impl BrokerCoreLimits { @@ -134,6 +142,7 @@ impl BrokerCoreLimits { max_threads_per_process: 1024, max_child_image_size: 1024 * 1024 * 1024, max_total_child_image_size: 4 * 1024 * 1024 * 1024, + max_shared_files: fs::shared_memory::DEFAULT_MAX_SHARED_FILES, }; /// Creates a broker core limit set. @@ -158,6 +167,7 @@ impl BrokerCoreLimits { max_threads_per_process: Self::DEFAULT.max_threads_per_process, max_child_image_size: Self::DEFAULT.max_child_image_size, max_total_child_image_size: Self::DEFAULT.max_total_child_image_size, + max_shared_files: Self::DEFAULT.max_shared_files, } } @@ -188,6 +198,7 @@ impl BrokerCoreLimits { max_threads_per_process: Self::DEFAULT.max_threads_per_process, max_child_image_size: Self::DEFAULT.max_child_image_size, max_total_child_image_size: Self::DEFAULT.max_total_child_image_size, + max_shared_files: Self::DEFAULT.max_shared_files, } } @@ -287,6 +298,15 @@ impl BrokerCoreLimits { ..self } } + + /// Returns these limits with an explicit limit on files in host shared memory. + #[must_use] + pub const fn with_shared_file_limit(self, max_shared_files: usize) -> Self { + Self { + max_shared_files, + ..self + } + } } impl Default for BrokerCoreLimits { diff --git a/litebox_broker_userland/src/linux.rs b/litebox_broker_userland/src/linux.rs index a5577cb6f..fd8030057 100644 --- a/litebox_broker_userland/src/linux.rs +++ b/litebox_broker_userland/src/linux.rs @@ -16,7 +16,7 @@ use std::time::{Duration, Instant}; use clap::Parser as _; use litebox_broker_core::socket::HOST_GATEWAY_IPV4_ADDRESS; use litebox_broker_core::stdio::StdioProvider as _; -use litebox_broker_core::{BrokerCore, ObjectRights, PolicyEngine}; +use litebox_broker_core::{BrokerCore, BrokerCoreLimits, ObjectRights, PolicyEngine}; use litebox_broker_platform_linux_userland::LinuxSyncPrimitivesProvider; use litebox_broker_protocol::shared_buffer::SHARED_BUFFER_POOL_SIZE; use litebox_broker_protocol::vsock_socket::VsockCid; @@ -58,17 +58,19 @@ pub(super) fn run( .with_process_duplication_enabled(args.allow_process_duplication) .with_vsock_policy(super::configured_vsock_policy(args.vsock_uds.is_some())); let terminal = stdio.terminal(); + let limits = BrokerCoreLimits::DEFAULT; let fs = super::create_file_service::( args.fs_initial_files.as_deref(), &args.mounts, &args.read_only_mounts, + limits.max_shared_files, stdio, )?; let vsock_uds = args .vsock_uds .as_deref() .map(|path| (path, VsockCid(args.vsock_guest_cid))); - let build = || super::build_broker(policy, fs, terminal, vsock_uds); + let build = || super::build_broker(policy, limits, fs, terminal, vsock_uds); // The bridge runs until `run` returns. let (broker, _vsock) = if args.in_process_runner { litebox_platform_linux_userland::with_guest_signals_blocked(build)? diff --git a/litebox_broker_userland/src/main.rs b/litebox_broker_userland/src/main.rs index 64e7cfd22..658d09894 100644 --- a/litebox_broker_userland/src/main.rs +++ b/litebox_broker_userland/src/main.rs @@ -25,12 +25,12 @@ use litebox_broker_core::fs::tar_ro::{EMPTY_TAR_FILE, TarRo}; use litebox_broker_core::random::RandomProvider as _; #[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] use litebox_broker_core::terminal::TerminalEndpoint; +#[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] +use litebox_broker_core::{BrokerCoreLimits, PolicyEngine, VsockPolicy}; use litebox_broker_core::{ CallerCredential, DestinationPortRange, DestinationRule, Ipv4Cidr, SocketPolicy, SocketPolicyError, }; -#[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] -use litebox_broker_core::{PolicyEngine, VsockPolicy}; use litebox_broker_protocol::fs::{FileMode as Mode, FileUser as UserInfo}; use litebox_broker_protocol::socket::{Ipv4Address, Port}; #[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] @@ -258,6 +258,7 @@ fn configured_vsock_policy(vsock_uds: bool) -> VsockPolicy { #[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] fn build_broker( policy: PolicyEngine, + limits: BrokerCoreLimits, fs: Arc, terminal: Option>, vsock_uds: Option<(&Path, VsockCid)>, @@ -266,6 +267,7 @@ fn build_broker( .map(|(path, guest_cid)| start_vsock_bridge(path, guest_cid)) .transpose()?; let mut builder = BrokerCoreBuilder::new(policy) + .with_limits(limits) .with_file_service(fs) .with_terminal(terminal); if let Some(vsock) = &vsock { @@ -288,6 +290,7 @@ fn create_file_service( initial_files: Option<&Path>, mounts: &[MountSpec], read_only_mounts: &[MountSpec], + max_shared_files: usize, stdio: Arc, ) -> IoResult> where @@ -317,25 +320,27 @@ where None => Cow::Borrowed(EMPTY_TAR_FILE), }; let in_mem = InMem::::new_initialized(entries); - // One limit bounds the host resources of every guest. Mapped files keep their slots until - // deleted, so one guest can use them all up and make other guests' mappings fail as out of - // memory (see `InMem::with_shared_memory`). + // One limit (`BrokerCoreLimits::max_shared_files`) bounds the host resources of every + // guest. Mapped files keep their slots until deleted, so one guest can use them all up and + // make other guests' mappings fail as out of memory (see `InMem::with_shared_memory`). + // Zero keeps every file in broker memory. #[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] - let shared_file_limit = Arc::new( - litebox_broker_core::fs::shared_memory::SharedFileLimit::new( - litebox_broker_core::fs::shared_memory::DEFAULT_MAX_SHARED_FILES, - ), - ); - #[cfg(target_os = "linux")] - let in_mem = in_mem.with_shared_memory( - Arc::new(litebox_broker_userland::shared_objects::linux::MemfdFileMemoryProvider), - shared_file_limit, - ); - #[cfg(all(windows, target_arch = "x86_64"))] - let in_mem = in_mem.with_shared_memory( - Arc::new(litebox_broker_userland::shared_objects::windows::TempFileMemoryProvider), - shared_file_limit, - ); + let in_mem = if max_shared_files == 0 { + in_mem + } else { + #[cfg(target_os = "linux")] + let provider = litebox_broker_userland::shared_objects::linux::MemfdFileMemoryProvider; + #[cfg(all(windows, target_arch = "x86_64"))] + let provider = litebox_broker_userland::shared_objects::windows::TempFileMemoryProvider; + in_mem.with_shared_memory( + Arc::new(provider), + Arc::new( + litebox_broker_core::fs::shared_memory::SharedFileLimit::new(max_shared_files), + ), + ) + }; + #[cfg(not(any(target_os = "linux", all(windows, target_arch = "x86_64"))))] + let _ = max_shared_files; let mut builder = Composer::builder() .mount_nestable("/", |allocators| { Overlay::::new( @@ -595,6 +600,7 @@ mod cli_tests { None, &args.mounts, &args.read_only_mounts, + litebox_broker_core::BrokerCoreLimits::DEFAULT.max_shared_files, Arc::new(UserlandStdioProvider::default()), ) .unwrap(); @@ -688,6 +694,7 @@ mod cli_tests { build_broker( PolicyEngine::with_host_guaranteed_rights(ObjectRights::all()) .with_vsock_policy(VsockPolicy::any()), + BrokerCoreLimits::DEFAULT, Arc::new(UnsupportedFileService), None, vsock_uds, diff --git a/litebox_broker_userland/src/windows.rs b/litebox_broker_userland/src/windows.rs index 9e2647df0..7b3350103 100644 --- a/litebox_broker_userland/src/windows.rs +++ b/litebox_broker_userland/src/windows.rs @@ -11,7 +11,7 @@ use std::thread::JoinHandle; use std::time::Instant; use clap::Parser as _; -use litebox_broker_core::{BrokerCore, ObjectRights, PolicyEngine}; +use litebox_broker_core::{BrokerCore, BrokerCoreLimits, ObjectRights, PolicyEngine}; use litebox_broker_platform_windows_userland::WindowsSyncPrimitivesProvider; use litebox_broker_protocol::shared_buffer::SHARED_BUFFER_POOL_SIZE; use litebox_broker_protocol::vsock_socket::VsockCid; @@ -35,10 +35,12 @@ pub(super) fn run( )?) .with_process_duplication_enabled(args.allow_process_duplication) .with_vsock_policy(super::configured_vsock_policy(args.vsock_uds.is_some())); + let limits = BrokerCoreLimits::DEFAULT; let fs = super::create_file_service::( args.fs_initial_files.as_deref(), &args.mounts, &args.read_only_mounts, + limits.max_shared_files, stdio, )?; let vsock_uds = args @@ -46,7 +48,7 @@ pub(super) fn run( .as_deref() .map(|path| (path, VsockCid(args.vsock_guest_cid))); // The bridge runs until `run` returns. - let (broker, _vsock) = super::build_broker(policy, fs, None, vsock_uds)?; + let (broker, _vsock) = super::build_broker(policy, limits, fs, None, vsock_uds)?; if args.in_process_runner { debug_assert!(args.unstable); From 6be17a412efd2a01064af66cdd9f78af9952ed00 Mon Sep 17 00:00:00 2001 From: Weiteng Chen Date: Mon, 2 Nov 2026 15:24:42 -0800 Subject: [PATCH 06/12] Truncate mapped Windows files logically Windows refuses to shrink a file while any section of it exists, and runners hold sections for as long as they map the file, so truncating a mapped file failed with EBUSY. Track the file's size in the broker instead: a shrink the host refuses zeroes the bytes past the new end and lowers the size only, and a later resize shrinks the host file once nothing maps it. Bytes past the end that runners write through their mappings are zeroed before the file grows over them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- litebox_broker_userland/src/shared_objects.rs | 278 ++++++++++++++---- 1 file changed, 228 insertions(+), 50 deletions(-) diff --git a/litebox_broker_userland/src/shared_objects.rs b/litebox_broker_userland/src/shared_objects.rs index 8d8c140e2..9848077ea 100644 --- a/litebox_broker_userland/src/shared_objects.rs +++ b/litebox_broker_userland/src/shared_objects.rs @@ -12,12 +12,16 @@ mod host_file { use std::fs::File; use std::io::{Error as IoError, ErrorKind}; + #[cfg(windows)] + use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{PoisonError, RwLock}; use litebox_broker_core::fs::shared_memory::{ HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileObject, }; use litebox_broker_core::transfer::bounce_buffer; + #[cfg(windows)] + use litebox_platform::buffer::Buffer as _; use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; use crate::fs::{read_slice_at, write_slice_at}; @@ -30,6 +34,15 @@ mod host_file { /// Makes each of the broker's accesses atomic with respect to the others, as /// [`SharedFileMemory`] requires: reads share it, while writes and resizes take it alone. io: RwLock<()>, + /// The file's size, changed only while holding `io` alone. + /// + /// Windows refuses to shrink a file while any section of it exists, and runners hold + /// sections for as long as they map the file. A shrink it refuses therefore zeroes the + /// bytes past the new end and lowers this size only, leaving the host file longer until + /// a later resize can shrink it. Runners receive no right to resize sections, so only + /// the broker changes the host file's size. + #[cfg(windows)] + len: AtomicU64, } /// Returns `offset` as a host file position, or `None` past `i64::MAX`, which hosts reject or, @@ -65,6 +78,8 @@ mod host_file { Self { file, io: RwLock::new(()), + #[cfg(windows)] + len: AtomicU64::new(0), } } @@ -78,7 +93,55 @@ mod host_file { .map_err(|_| SharedFileMemoryError::Unsupported) } - fn len(&self) -> Result { + /// Reads at `position` into `buffer`, as [`SharedFileMemory::read_at`] does. Callers hold + /// `io`. + fn read_locked( + &self, + position: u64, + buffer: &mut dyn ReadBuffer, + ) -> Result { + #[cfg(target_os = "linux")] + let direct = crate::fs::linux::read_at(&self.file, buffer, position); + #[cfg(windows)] + let direct = crate::fs::windows::read_at(&self.file, buffer, position); + // A short or interrupted direct read continues through the staging buffer, so that + // only the end of the memory stops a read short. + let mut read = match direct { + Some(Ok(0)) => return Ok(0), + Some(Ok(read)) => read, + Some(Err(error)) if error.kind() == ErrorKind::Interrupted => 0, + Some(Err(error)) => return Err(memory_error(&error)), + None => 0, + }; + if read == buffer.len() { + return Ok(read); + } + // A short direct read usually stopped at the end, which the size confirms without + // staging anything. + if read > 0 && position + read as u64 >= self.len()? { + return Ok(read); + } + let mut staging = match bounce_buffer(STAGING_SIZE.min(buffer.len() - read)) { + Ok(staging) => staging, + Err(_) if read > 0 => return Ok(read), + Err(_) => return Err(SharedFileMemoryError::NoMemory), + }; + while read < buffer.len() { + let chunk = &mut staging[..STAGING_SIZE.min(buffer.len() - read)]; + let count = self + .read_once(chunk, position + read as u64) + .map_err(|error| memory_error(&error))?; + if count == 0 { + break; + } + buffer.copy_from_slice_at(read, &chunk[..count])?; + read += count; + } + Ok(read) + } + + /// Returns the host file's size. + fn host_len(&self) -> Result { Ok(self .file .metadata() @@ -86,6 +149,78 @@ mod host_file { .len()) } + /// Returns the file's size. Callers hold `io`. + #[cfg_attr( + windows, + expect( + clippy::unnecessary_wraps, + reason = "reading the size fails only on Linux" + ) + )] + fn len(&self) -> Result { + #[cfg(windows)] + return Ok(self.len.load(Ordering::Relaxed)); + #[cfg(target_os = "linux")] + self.host_len() + } + + /// Zeroes the host file's bytes from `start` to `end`. Callers hold `io` alone. + #[cfg(windows)] + fn zero(&self, start: u64, end: u64) -> Result<(), SharedFileMemoryError> { + if start >= end { + return Ok(()); + } + let zeros = bounce_buffer( + usize::try_from(end - start) + .map_or(STAGING_SIZE, |length| length.min(STAGING_SIZE)), + ) + .map_err(|_| SharedFileMemoryError::NoMemory)?; + let mut position = start; + while position < end { + let length = usize::try_from(end - position) + .map_or(zeros.len(), |remaining| remaining.min(zeros.len())); + self.write_all(&zeros[..length], position, &mut 0) + .map_err(|error| memory_error(&error))?; + position += length as u64; + } + Ok(()) + } + + /// Resizes the file to `length` bytes on Windows. Callers hold `io` alone. + /// + /// Bytes between the old and the new size that the host file already holds are zeroed, + /// since runners may have written there through mappings past the end of the file, and + /// a shrink the host refuses while the file is mapped leaves them in the host file. + #[cfg(windows)] + fn resize(&self, length: u64) -> Result<(), SharedFileMemoryError> { + let old_len = self.len()?; + let old_host_len = self.host_len()?; + let host_len = if length == old_host_len { + length + } else { + match self.file.set_len(length) { + Ok(()) => length, + Err(error) + if length < old_host_len + && memory_error(&error) == SharedFileMemoryError::Busy => + { + old_host_len + } + Err(error) => return Err(memory_error(&error)), + } + }; + // Bytes the host file had before this resize and still has may be stale. + let stale_end = old_host_len.min(host_len); + let (start, end) = if length > old_len { + (old_len, length) + } else { + (length, old_len) + }; + self.zero(start, end.min(stale_end))?; + self.len.store(length, Ordering::Relaxed); + Ok(()) + } + fn read_once(&self, buffer: &mut [u8], offset: u64) -> std::io::Result { loop { match read_slice_at(&self.file, buffer, offset) { @@ -143,6 +278,31 @@ mod host_file { let length = usize::try_from(limit - position).map_or(data.len(), |room| room.min(data.len())); let _io = self.io.write().unwrap_or_else(PoisonError::into_inner); + // The host file may hold stale bytes between the end of the file and the write. + #[cfg(windows)] + { + let len = self.len()?; + if position > len { + self.zero(len, position.min(self.host_len()?))?; + } + } + let written = self.write_locked(position, data, length)?; + #[cfg(windows)] + if written > 0 { + self.len + .fetch_max(position + written as u64, Ordering::Relaxed); + } + Ok(written) + } + + /// Writes the first `length` bytes of `data` at `position`, as + /// [`SharedFileMemory::write_at`] does. Callers hold `io` alone. + fn write_locked( + &self, + position: u64, + data: &dyn WriteBuffer, + length: usize, + ) -> Result { // Only a write that fits whole may transfer directly, since it cannot stop early. let direct = if length == data.len() { #[cfg(target_os = "linux")] @@ -193,9 +353,13 @@ mod host_file { .filter(|length| *length <= limit) .ok_or(SharedFileMemoryError::NoMemory)?; let _io = self.io.write().unwrap_or_else(PoisonError::into_inner); - self.file + #[cfg(target_os = "linux")] + return self + .file .set_len(length) - .map_err(|error| memory_error(&error)) + .map_err(|error| memory_error(&error)); + #[cfg(windows)] + self.resize(length) } } @@ -255,44 +419,20 @@ mod host_file { return Ok(0); }; let _io = self.io.read().unwrap_or_else(PoisonError::into_inner); - #[cfg(target_os = "linux")] - let direct = crate::fs::linux::read_at(&self.file, buffer, position); + // The host file may hold stale bytes past the end of the file. #[cfg(windows)] - let direct = crate::fs::windows::read_at(&self.file, buffer, position); - // A short or interrupted direct read continues through the staging buffer, so that - // only the end of the memory stops a read short. - let mut read = match direct { - Some(Ok(0)) => return Ok(0), - Some(Ok(read)) => read, - Some(Err(error)) if error.kind() == ErrorKind::Interrupted => 0, - Some(Err(error)) => return Err(memory_error(&error)), - None => 0, - }; - if read == buffer.len() { - return Ok(read); - } - // A short direct read usually stopped at the end, which the size confirms without - // staging anything. - if read > 0 && position + read as u64 >= self.len()? { - return Ok(read); - } - let mut staging = match bounce_buffer(STAGING_SIZE.min(buffer.len() - read)) { - Ok(staging) => staging, - Err(_) if read > 0 => return Ok(read), - Err(_) => return Err(SharedFileMemoryError::NoMemory), - }; - while read < buffer.len() { - let chunk = &mut staging[..STAGING_SIZE.min(buffer.len() - read)]; - let count = self - .read_once(chunk, position + read as u64) - .map_err(|error| memory_error(&error))?; - if count == 0 { - break; + { + let available = self.len()?.saturating_sub(position); + if available < buffer.len() as u64 { + // `available` is less than the buffer's length, so it fits. + let length = usize::try_from(available).unwrap_or(0); + if length == 0 { + return Ok(0); + } + return self.read_locked(position, &mut (&mut *buffer).sub_buffer(0, length)); } - buffer.copy_from_slice_at(read, &chunk[..count])?; - read += count; } - Ok(read) + self.read_locked(position, buffer) } fn write_at( @@ -361,6 +501,8 @@ mod host_file { memory .write_all(contents, 0, &mut 0) .map_err(|error| memory_error(&error))?; + #[cfg(windows)] + memory.len.store(contents.len() as u64, Ordering::Relaxed); Ok(std::sync::Arc::new(memory)) } @@ -743,22 +885,36 @@ pub mod windows { } #[test] - fn shrinking_mapped_memory_reports_busy() { + fn shrinking_mapped_memory_hides_and_zeroes_the_tail() { + use std::mem::ManuallyDrop; + use std::os::windows::io::FromRawHandle as _; use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; use windows_sys::Win32::System::Memory::{ - CreateFileMappingW, FILE_MAP_READ, MapViewOfFile, PAGE_READONLY, UnmapViewOfFile, + CreateFileMappingW, FILE_MAP_WRITE, MapViewOfFile, PAGE_READWRITE, UnmapViewOfFile, }; let memory = TempFileMemoryProvider.create(b"hello").unwrap(); let handle = - usize::try_from(memory.host_handle(false).unwrap().raw()).unwrap() as HANDLE; + usize::try_from(memory.host_handle(true).unwrap().raw()).unwrap() as HANDLE; + let host_len = || { + // SAFETY: The handle stays open while `memory` lives, and `ManuallyDrop` leaves + // closing it to `memory`. + let file = ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) }); + file.metadata().unwrap().len() + }; + let contents = |length: usize| { + let mut bytes = vec![0u8; length + 1]; + let read = memory.read_at(0, &mut bytes.as_mut_slice()).unwrap(); + bytes.truncate(read); + bytes + }; // SAFETY: `handle` is a live file handle lent by `memory`, and the call creates a // new section handle. let section = unsafe { CreateFileMappingW( handle, std::ptr::null(), - PAGE_READONLY, + PAGE_READWRITE, 0, 0, std::ptr::null(), @@ -766,21 +922,43 @@ pub mod windows { }; assert!(!section.is_null()); // SAFETY: `section` is a live section handle, and the call maps a new view of it. - let view = unsafe { MapViewOfFile(section, FILE_MAP_READ, 0, 0, 0) }; + let view = unsafe { MapViewOfFile(section, FILE_MAP_WRITE, 0, 0, 0) }; assert!(!view.Value.is_null()); - - assert_eq!(memory.set_len(1), Err(SharedFileMemoryError::Busy)); - assert_eq!(memory.size().unwrap(), 5); - memory.set_len(8).unwrap(); - assert_eq!(memory.size().unwrap(), 8); + let byte = view.Value.cast::(); + // SAFETY: The view maps the five bytes of the file as they were mapped, which the + // host may change behind the reference-free volatile accesses. + let peek = |index: usize| unsafe { byte.add(index).read_volatile() }; + // SAFETY: As for `peek`. + let poke = |index: usize, value: u8| unsafe { byte.add(index).write_volatile(value) }; + + // The host cannot shrink the mapped file, so the bytes past the new end read as + // gone and become zeros in the mapping. + memory.set_len(1).unwrap(); + assert_eq!(memory.size().unwrap(), 1); + assert_eq!(host_len(), 5); + assert_eq!(contents(8), b"h"); + assert_eq!((1..5).map(peek).collect::>(), [0; 4]); + + // What a runner stores past the end through its mapping is zero once the file grows + // over it, whether by resizing or by writing past it. + poke(3, b'x'); + poke(4, b'y'); + memory.set_len(4).unwrap(); + assert_eq!(contents(8), b"h\0\0\0"); + assert_eq!(memory.write_at(6, &b"!".as_slice()).unwrap(), 1); + assert_eq!(contents(8), b"h\0\0\0\0\0!"); + assert_eq!(peek(4), 0); + assert_eq!(host_len(), 7); // SAFETY: `view` and `section` were created above and are not used again. unsafe { assert_ne!(UnmapViewOfFile(view), 0); assert_ne!(CloseHandle(section), 0); } - memory.set_len(1).unwrap(); - assert_eq!(memory.size().unwrap(), 1); + // Once nothing maps the file, resizing it shrinks the host file too. + memory.set_len(2).unwrap(); + assert_eq!(memory.size().unwrap(), 2); + assert_eq!(host_len(), 2); } } } From efe5e620881a20e759f849df89e650f8ab492cd3 Mon Sep 17 00:00:00 2001 From: Weiteng Chen Date: Mon, 2 Nov 2026 15:57:06 -0800 Subject: [PATCH 07/12] Drop the comment on overlay map_shared The code says what it does: map the layer that holds the file, as reads do. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- litebox_broker_core/src/fs/overlay.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/litebox_broker_core/src/fs/overlay.rs b/litebox_broker_core/src/fs/overlay.rs index b6cbe12a8..07ef05697 100644 --- a/litebox_broker_core/src/fs/overlay.rs +++ b/litebox_broker_core/src/fs/overlay.rs @@ -1090,12 +1090,6 @@ impl Backend for Overlay { } fn map_shared(&self, h: &FileHandle) -> Result, MapSharedError> { - // A file opened for writing was copied up already. A file still in a lower layer can - // only be mapped readable, so the lower layer's object serves it, if it has one; a - // read-only lower layer has none, so mapping fails with `Unsupported`. Once another - // handle copies the file up, this handle follows the upper copy as its reads do, so - // mapping it returns the upper copy's object. Existing mappings stay on the lower - // file, as an overlayfs mapping of a lower file does. self.with_file(h.get_typed::(), |_, backend, handle| { backend.map_shared(handle) }) From 7ebf348ea000d423ade7e17ca9fe256fb70482a8 Mon Sep 17 00:00:00 2001 From: Weiteng Chen Date: Mon, 2 Nov 2026 16:58:50 -0800 Subject: [PATCH 08/12] Make HostHandle a trait A handle that shared file memory exports is now a boxed trait object that knows its own raw value, whether the memory lends one of its own handles or makes one for the export, as BorrowedFd and OwnedFd do. This drops the separate raw value, the optional owner, and the two unsafe constructors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- litebox_broker_core/src/fs/shared_memory.rs | 70 ++++--------------- litebox_broker_userland/src/shared_objects.rs | 50 ++++++++----- 2 files changed, 48 insertions(+), 72 deletions(-) diff --git a/litebox_broker_core/src/fs/shared_memory.rs b/litebox_broker_core/src/fs/shared_memory.rs index 827ed75a0..0f9d5d66d 100644 --- a/litebox_broker_core/src/fs/shared_memory.rs +++ b/litebox_broker_core/src/fs/shared_memory.rs @@ -14,7 +14,6 @@ use alloc::boxed::Box; use alloc::sync::Arc; -use core::marker::PhantomData; use core::sync::atomic::{AtomicUsize, Ordering}; use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; @@ -88,60 +87,14 @@ impl From for MapSharedError { } /// A host handle to a [`SharedFileObject`], such as a descriptor on Linux or a `HANDLE` on -/// Windows, which stays open for as long as this value lives. +/// Windows, open for as long as this value lives. /// -/// The object either lends one of its own handles, or makes one for this export alone, which -/// closes when this value is dropped, so the object holds no handle for it in between. -pub struct HostHandle<'object> { - raw: u64, - /// Keeps a handle made for this export open, closing it when dropped. - owner: Option>, - _object: PhantomData<&'object ()>, +/// An object either lends one of its own handles, or makes one for the export alone, which +/// closes when this value is dropped, so the object keeps no handle open for it in between. +pub trait HostHandle: Send { + /// Returns the raw host handle. + fn raw(&self) -> u64; } - -impl core::fmt::Debug for HostHandle<'_> { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("HostHandle") - .field("raw", &self.raw) - .field("owned", &self.owner.is_some()) - .finish() - } -} - -impl<'object> HostHandle<'object> { - /// Lends the host handle `raw`. - /// - /// # Safety - /// - /// `raw` must name a host handle that stays open for as long as the returned value lives. - pub unsafe fn borrow_raw(raw: u64) -> Self { - Self { - raw, - owner: None, - _object: PhantomData, - } - } - - /// Exports the host handle `raw`, which `owner` keeps open until the returned value is - /// dropped. - /// - /// # Safety - /// - /// `raw` must name a host handle that stays open for as long as `owner` lives. - pub unsafe fn from_owner(raw: u64, owner: Box) -> Self { - Self { - raw, - owner: Some(owner), - _object: PhantomData, - } - } - - /// Returns the raw host handle, which stays open for as long as `self` lives. - pub fn raw(&self) -> u64 { - self.raw - } -} - /// A host object whose pages are one file's contents. /// /// Its size is the file's size. @@ -155,7 +108,10 @@ pub trait SharedFileObject: Send + Sync { /// /// Fails with [`SharedFileMemoryError::Unsupported`] if the host cannot provide such a /// handle. - fn host_handle(&self, writable: bool) -> Result, SharedFileMemoryError>; + fn host_handle( + &self, + writable: bool, + ) -> Result, SharedFileMemoryError>; } /// Host memory that holds one file's contents and serves as the backend's storage. @@ -278,6 +234,7 @@ pub(crate) mod test_memory { HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, SharedFileObject, }; + use alloc::boxed::Box; use alloc::sync::Arc; use alloc::vec::Vec; use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; @@ -319,7 +276,10 @@ pub(crate) mod test_memory { } impl SharedFileObject for VecMemory { - fn host_handle(&self, _writable: bool) -> Result, SharedFileMemoryError> { + fn host_handle( + &self, + _writable: bool, + ) -> Result, SharedFileMemoryError> { Err(SharedFileMemoryError::Unsupported) } } diff --git a/litebox_broker_userland/src/shared_objects.rs b/litebox_broker_userland/src/shared_objects.rs index 9848077ea..0332d49a5 100644 --- a/litebox_broker_userland/src/shared_objects.rs +++ b/litebox_broker_userland/src/shared_objects.rs @@ -453,42 +453,58 @@ mod host_file { } } + /// A host handle exported from shared file memory, either lent by the memory, as a + /// borrowed handle, or made for the export, as an owned one that closes when dropped. + struct Exported(H); + + #[cfg(target_os = "linux")] + impl HostHandle for Exported { + fn raw(&self) -> u64 { + use std::os::fd::AsRawFd as _; + + // Open descriptors are never negative. + u64::from(self.0.as_fd().as_raw_fd().cast_unsigned()) + } + } + + #[cfg(windows)] + impl HostHandle for Exported { + fn raw(&self) -> u64 { + use std::os::windows::io::AsRawHandle as _; + + self.0.as_handle().as_raw_handle() as usize as u64 + } + } + impl SharedFileObject for HostFileMemory { - fn host_handle(&self, writable: bool) -> Result, SharedFileMemoryError> { + fn host_handle( + &self, + writable: bool, + ) -> Result, SharedFileMemoryError> { #[cfg(target_os = "linux")] { - use std::os::fd::AsRawFd as _; + use std::os::fd::AsFd as _; - let raw = |file: &File| { - u64::try_from(file.as_raw_fd()).map_err(|_| SharedFileMemoryError::Io) - }; if writable { - let raw = raw(&self.file)?; - // SAFETY: The memfd belongs to `self`, which keeps it open while borrowed. - return Ok(unsafe { HostHandle::borrow_raw(raw) }); + return Ok(Box::new(Exported(self.file.as_fd()))); } // The host checks a descriptor's access mode when mapping it, so readers get a // read-only open, made for this export alone so that no extra descriptor stays // open between deliveries. - let file = self.open_read_only()?; - let raw = raw(&file)?; - // SAFETY: `file` owns the descriptor and keeps it open for as long as it lives. - Ok(unsafe { HostHandle::from_owner(raw, Box::new(file)) }) + Ok(Box::new(Exported(self.open_read_only()?))) } // The handle never leaves the broker: the deployment derives a section from it, and // the section's protection and the rights of the section handle sent to a runner // enforce `writable`, so readers and writers share the storage's own handle. #[cfg(windows)] { + use std::os::windows::io::AsHandle as _; + let _ = writable; - let raw = - std::os::windows::io::AsRawHandle::as_raw_handle(&self.file) as usize as u64; - // SAFETY: The handle belongs to `self`, which keeps it open while borrowed. - Ok(unsafe { HostHandle::borrow_raw(raw) }) + Ok(Box::new(Exported(self.file.as_handle()))) } } } - pub(super) fn create( file: File, contents: &[u8], From dff5ec3953685fe37aa30dcb9cf0fae57d5a5a7a Mon Sep 17 00:00:00 2001 From: Weiteng Chen Date: Mon, 2 Nov 2026 18:00:42 -0800 Subject: [PATCH 09/12] Split shared_objects into per-module files Move the host-file storage and the Linux and Windows modules of shared_objects.rs into shared_objects/, unchanged, as the runner module is laid out. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- litebox_broker_userland/src/shared_objects.rs | 963 +----------------- .../src/shared_objects/host_file.rs | 684 +++++++++++++ .../src/shared_objects/linux.rs | 124 +++ .../src/shared_objects/windows.rs | 143 +++ 4 files changed, 954 insertions(+), 960 deletions(-) create mode 100644 litebox_broker_userland/src/shared_objects/host_file.rs create mode 100644 litebox_broker_userland/src/shared_objects/linux.rs create mode 100644 litebox_broker_userland/src/shared_objects/windows.rs diff --git a/litebox_broker_userland/src/shared_objects.rs b/litebox_broker_userland/src/shared_objects.rs index 0332d49a5..cd5ed11dc 100644 --- a/litebox_broker_userland/src/shared_objects.rs +++ b/litebox_broker_userland/src/shared_objects.rs @@ -9,972 +9,15 @@ /// A file's contents in a host file, accessed with positional I/O. #[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] -mod host_file { - use std::fs::File; - use std::io::{Error as IoError, ErrorKind}; - #[cfg(windows)] - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::{PoisonError, RwLock}; - - use litebox_broker_core::fs::shared_memory::{ - HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileObject, - }; - use litebox_broker_core::transfer::bounce_buffer; - #[cfg(windows)] - use litebox_platform::buffer::Buffer as _; - use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; - - use crate::fs::{read_slice_at, write_slice_at}; - - /// The most bytes staged at once for buffers that cannot expose their bytes directly. - const STAGING_SIZE: usize = 64 * 1024; - - pub(super) struct HostFileMemory { - file: File, - /// Makes each of the broker's accesses atomic with respect to the others, as - /// [`SharedFileMemory`] requires: reads share it, while writes and resizes take it alone. - io: RwLock<()>, - /// The file's size, changed only while holding `io` alone. - /// - /// Windows refuses to shrink a file while any section of it exists, and runners hold - /// sections for as long as they map the file. A shrink it refuses therefore zeroes the - /// bytes past the new end and lowers this size only, leaving the host file longer until - /// a later resize can shrink it. Runners receive no right to resize sections, so only - /// the broker changes the host file's size. - #[cfg(windows)] - len: AtomicU64, - } - - /// Returns `offset` as a host file position, or `None` past `i64::MAX`, which hosts reject or, - /// on Windows, treat as special values. - fn position(offset: usize) -> Option { - i64::try_from(offset).ok().map(i64::cast_unsigned) - } - - /// Returns the most bytes the host lets the broker's files hold. - /// - /// On Linux, growing a file past `RLIMIT_FSIZE` would raise `SIGXFSZ`, which ends the broker - /// by default, so the memory never tries to. - fn size_limit() -> u64 { - let limit = i64::MAX.cast_unsigned(); - #[cfg(target_os = "linux")] - { - let mut rlimit = libc::rlimit { - rlim_cur: 0, - rlim_max: 0, - }; - // SAFETY: `rlimit` is valid for the call to fill. - if unsafe { libc::getrlimit(libc::RLIMIT_FSIZE, &raw mut rlimit) } == 0 - && rlimit.rlim_cur != libc::RLIM_INFINITY - { - return limit.min(rlimit.rlim_cur); - } - } - limit - } - - impl HostFileMemory { - fn new(file: File) -> Self { - Self { - file, - io: RwLock::new(()), - #[cfg(windows)] - len: AtomicU64::new(0), - } - } - - /// Opens the contents again with read and execute rights only. - #[cfg(target_os = "linux")] - fn open_read_only(&self) -> Result { - use std::os::fd::AsRawFd as _; - - // Duplication preserves the access mode, so reopen for readers. - File::open(format!("/proc/self/fd/{}", self.file.as_raw_fd())) - .map_err(|_| SharedFileMemoryError::Unsupported) - } - - /// Reads at `position` into `buffer`, as [`SharedFileMemory::read_at`] does. Callers hold - /// `io`. - fn read_locked( - &self, - position: u64, - buffer: &mut dyn ReadBuffer, - ) -> Result { - #[cfg(target_os = "linux")] - let direct = crate::fs::linux::read_at(&self.file, buffer, position); - #[cfg(windows)] - let direct = crate::fs::windows::read_at(&self.file, buffer, position); - // A short or interrupted direct read continues through the staging buffer, so that - // only the end of the memory stops a read short. - let mut read = match direct { - Some(Ok(0)) => return Ok(0), - Some(Ok(read)) => read, - Some(Err(error)) if error.kind() == ErrorKind::Interrupted => 0, - Some(Err(error)) => return Err(memory_error(&error)), - None => 0, - }; - if read == buffer.len() { - return Ok(read); - } - // A short direct read usually stopped at the end, which the size confirms without - // staging anything. - if read > 0 && position + read as u64 >= self.len()? { - return Ok(read); - } - let mut staging = match bounce_buffer(STAGING_SIZE.min(buffer.len() - read)) { - Ok(staging) => staging, - Err(_) if read > 0 => return Ok(read), - Err(_) => return Err(SharedFileMemoryError::NoMemory), - }; - while read < buffer.len() { - let chunk = &mut staging[..STAGING_SIZE.min(buffer.len() - read)]; - let count = self - .read_once(chunk, position + read as u64) - .map_err(|error| memory_error(&error))?; - if count == 0 { - break; - } - buffer.copy_from_slice_at(read, &chunk[..count])?; - read += count; - } - Ok(read) - } - - /// Returns the host file's size. - fn host_len(&self) -> Result { - Ok(self - .file - .metadata() - .map_err(|error| memory_error(&error))? - .len()) - } - - /// Returns the file's size. Callers hold `io`. - #[cfg_attr( - windows, - expect( - clippy::unnecessary_wraps, - reason = "reading the size fails only on Linux" - ) - )] - fn len(&self) -> Result { - #[cfg(windows)] - return Ok(self.len.load(Ordering::Relaxed)); - #[cfg(target_os = "linux")] - self.host_len() - } - - /// Zeroes the host file's bytes from `start` to `end`. Callers hold `io` alone. - #[cfg(windows)] - fn zero(&self, start: u64, end: u64) -> Result<(), SharedFileMemoryError> { - if start >= end { - return Ok(()); - } - let zeros = bounce_buffer( - usize::try_from(end - start) - .map_or(STAGING_SIZE, |length| length.min(STAGING_SIZE)), - ) - .map_err(|_| SharedFileMemoryError::NoMemory)?; - let mut position = start; - while position < end { - let length = usize::try_from(end - position) - .map_or(zeros.len(), |remaining| remaining.min(zeros.len())); - self.write_all(&zeros[..length], position, &mut 0) - .map_err(|error| memory_error(&error))?; - position += length as u64; - } - Ok(()) - } - - /// Resizes the file to `length` bytes on Windows. Callers hold `io` alone. - /// - /// Bytes between the old and the new size that the host file already holds are zeroed, - /// since runners may have written there through mappings past the end of the file, and - /// a shrink the host refuses while the file is mapped leaves them in the host file. - #[cfg(windows)] - fn resize(&self, length: u64) -> Result<(), SharedFileMemoryError> { - let old_len = self.len()?; - let old_host_len = self.host_len()?; - let host_len = if length == old_host_len { - length - } else { - match self.file.set_len(length) { - Ok(()) => length, - Err(error) - if length < old_host_len - && memory_error(&error) == SharedFileMemoryError::Busy => - { - old_host_len - } - Err(error) => return Err(memory_error(&error)), - } - }; - // Bytes the host file had before this resize and still has may be stale. - let stale_end = old_host_len.min(host_len); - let (start, end) = if length > old_len { - (old_len, length) - } else { - (length, old_len) - }; - self.zero(start, end.min(stale_end))?; - self.len.store(length, Ordering::Relaxed); - Ok(()) - } - - fn read_once(&self, buffer: &mut [u8], offset: u64) -> std::io::Result { - loop { - match read_slice_at(&self.file, buffer, offset) { - Err(error) if error.kind() == ErrorKind::Interrupted => {} - #[cfg(windows)] - Err(error) - if error.raw_os_error() - == Some( - windows_sys::Win32::Foundation::ERROR_HANDLE_EOF.cast_signed(), - ) => - { - return Ok(0); - } - read => return read, - } - } - } - - /// Writes all of `data` at `offset`, adding the bytes written to `written` as it goes. - fn write_all( - &self, - mut data: &[u8], - mut offset: u64, - written: &mut usize, - ) -> std::io::Result<()> { - while !data.is_empty() { - match write_slice_at(&self.file, data, offset) { - Ok(0) => return Err(ErrorKind::WriteZero.into()), - Ok(count) => { - data = &data[count..]; - offset += count as u64; - *written += count; - } - Err(error) if error.kind() == ErrorKind::Interrupted => {} - Err(error) => return Err(error), - } - } - Ok(()) - } - - /// Writes like [`SharedFileMemory::write_at`], but grows the memory to at most `limit` - /// bytes, writing only the bytes that fit before it, as Linux does at `RLIMIT_FSIZE`. - fn write_limited( - &self, - offset: usize, - data: &dyn WriteBuffer, - limit: u64, - ) -> Result { - if data.is_empty() { - return Ok(0); - } - let position = position(offset) - .filter(|position| *position < limit) - .ok_or(SharedFileMemoryError::NoMemory)?; - let length = - usize::try_from(limit - position).map_or(data.len(), |room| room.min(data.len())); - let _io = self.io.write().unwrap_or_else(PoisonError::into_inner); - // The host file may hold stale bytes between the end of the file and the write. - #[cfg(windows)] - { - let len = self.len()?; - if position > len { - self.zero(len, position.min(self.host_len()?))?; - } - } - let written = self.write_locked(position, data, length)?; - #[cfg(windows)] - if written > 0 { - self.len - .fetch_max(position + written as u64, Ordering::Relaxed); - } - Ok(written) - } - - /// Writes the first `length` bytes of `data` at `position`, as - /// [`SharedFileMemory::write_at`] does. Callers hold `io` alone. - fn write_locked( - &self, - position: u64, - data: &dyn WriteBuffer, - length: usize, - ) -> Result { - // Only a write that fits whole may transfer directly, since it cannot stop early. - let direct = if length == data.len() { - #[cfg(target_os = "linux")] - let direct = crate::fs::linux::write_at(&self.file, data, position); - #[cfg(windows)] - let direct = crate::fs::windows::write_at(&self.file, data, position); - direct - } else { - None - }; - // A short or interrupted direct write continues through the staging buffer. - let mut written = match direct { - Some(Err(error)) if error.kind() == ErrorKind::Interrupted => 0, - Some(written) => written.map_err(|error| memory_error(&error))?, - None => 0, - }; - if written == length { - return Ok(written); - } - let mut staging = match bounce_buffer(STAGING_SIZE.min(length - written)) { - Ok(staging) => staging, - Err(_) if written > 0 => return Ok(written), - Err(_) => return Err(SharedFileMemoryError::NoMemory), - }; - while written < length { - let chunk = &mut staging[..STAGING_SIZE.min(length - written)]; - let result = data - .copy_to_slice_at(written, chunk) - .map_err(SharedFileMemoryError::from) - .and_then(|()| { - self.write_all(chunk, position + written as u64, &mut written) - .map_err(|error| memory_error(&error)) - }); - match result { - Ok(()) => {} - // Like `pwrite`, report the bytes already written rather than the error. - Err(_) if written > 0 => break, - Err(error) => return Err(error), - } - } - Ok(written) - } - - /// Resizes like [`SharedFileMemory::set_len`], but to at most `limit` bytes. - fn set_len_limited(&self, length: usize, limit: u64) -> Result<(), SharedFileMemoryError> { - let length = u64::try_from(length) - .ok() - .filter(|length| *length <= limit) - .ok_or(SharedFileMemoryError::NoMemory)?; - let _io = self.io.write().unwrap_or_else(PoisonError::into_inner); - #[cfg(target_os = "linux")] - return self - .file - .set_len(length) - .map_err(|error| memory_error(&error)); - #[cfg(windows)] - self.resize(length) - } - } - - pub(super) fn memory_error(error: &IoError) -> SharedFileMemoryError { - let code = error.raw_os_error(); - #[cfg(target_os = "linux")] - { - if matches!( - code, - Some(libc::ENOSPC | libc::ENOMEM | libc::EFBIG | libc::EMFILE | libc::ENFILE) - ) { - return SharedFileMemoryError::NoMemory; - } - if code == Some(libc::EFAULT) { - return SharedFileMemoryError::Fault; - } - } - #[cfg(windows)] - { - use windows_sys::Win32::Foundation::{ - ERROR_COMMITMENT_LIMIT, ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, ERROR_NOACCESS, - ERROR_NOT_ENOUGH_MEMORY, ERROR_NOT_ENOUGH_QUOTA, ERROR_OUTOFMEMORY, - ERROR_USER_MAPPED_FILE, - }; - let code = code.and_then(|code| u32::try_from(code).ok()); - if code == Some(ERROR_USER_MAPPED_FILE) { - return SharedFileMemoryError::Busy; - } - if matches!( - code, - Some( - ERROR_DISK_FULL - | ERROR_HANDLE_DISK_FULL - | ERROR_NOT_ENOUGH_MEMORY - | ERROR_OUTOFMEMORY - | ERROR_COMMITMENT_LIMIT - | ERROR_NOT_ENOUGH_QUOTA - ) - ) { - return SharedFileMemoryError::NoMemory; - } - if code == Some(ERROR_NOACCESS) { - return SharedFileMemoryError::Fault; - } - } - SharedFileMemoryError::Io - } - - impl SharedFileMemory for HostFileMemory { - fn read_at( - &self, - offset: usize, - buffer: &mut dyn ReadBuffer, - ) -> Result { - // Nothing lies past the largest position the host accepts. - let Some(position) = position(offset) else { - return Ok(0); - }; - let _io = self.io.read().unwrap_or_else(PoisonError::into_inner); - // The host file may hold stale bytes past the end of the file. - #[cfg(windows)] - { - let available = self.len()?.saturating_sub(position); - if available < buffer.len() as u64 { - // `available` is less than the buffer's length, so it fits. - let length = usize::try_from(available).unwrap_or(0); - if length == 0 { - return Ok(0); - } - return self.read_locked(position, &mut (&mut *buffer).sub_buffer(0, length)); - } - } - self.read_locked(position, buffer) - } - - fn write_at( - &self, - offset: usize, - data: &dyn WriteBuffer, - ) -> Result { - self.write_limited(offset, data, size_limit()) - } - - fn size(&self) -> Result { - let _io = self.io.read().unwrap_or_else(PoisonError::into_inner); - usize::try_from(self.len()?).map_err(|_| SharedFileMemoryError::Io) - } - - fn set_len(&self, length: usize) -> Result<(), SharedFileMemoryError> { - self.set_len_limited(length, size_limit()) - } - } - - /// A host handle exported from shared file memory, either lent by the memory, as a - /// borrowed handle, or made for the export, as an owned one that closes when dropped. - struct Exported(H); - - #[cfg(target_os = "linux")] - impl HostHandle for Exported { - fn raw(&self) -> u64 { - use std::os::fd::AsRawFd as _; - - // Open descriptors are never negative. - u64::from(self.0.as_fd().as_raw_fd().cast_unsigned()) - } - } - - #[cfg(windows)] - impl HostHandle for Exported { - fn raw(&self) -> u64 { - use std::os::windows::io::AsRawHandle as _; - - self.0.as_handle().as_raw_handle() as usize as u64 - } - } - - impl SharedFileObject for HostFileMemory { - fn host_handle( - &self, - writable: bool, - ) -> Result, SharedFileMemoryError> { - #[cfg(target_os = "linux")] - { - use std::os::fd::AsFd as _; - - if writable { - return Ok(Box::new(Exported(self.file.as_fd()))); - } - // The host checks a descriptor's access mode when mapping it, so readers get a - // read-only open, made for this export alone so that no extra descriptor stays - // open between deliveries. - Ok(Box::new(Exported(self.open_read_only()?))) - } - // The handle never leaves the broker: the deployment derives a section from it, and - // the section's protection and the rights of the section handle sent to a runner - // enforce `writable`, so readers and writers share the storage's own handle. - #[cfg(windows)] - { - use std::os::windows::io::AsHandle as _; - - let _ = writable; - Ok(Box::new(Exported(self.file.as_handle()))) - } - } - } - pub(super) fn create( - file: File, - contents: &[u8], - ) -> Result, SharedFileMemoryError> { - if u64::try_from(contents.len()).map_or(true, |length| length > size_limit()) { - return Err(SharedFileMemoryError::NoMemory); - } - let memory = HostFileMemory::new(file); - // Nothing else can reach the memory yet, so the contents go straight to the file. - memory - .write_all(contents, 0, &mut 0) - .map_err(|error| memory_error(&error))?; - #[cfg(windows)] - memory.len.store(contents.len() as u64, Ordering::Relaxed); - Ok(std::sync::Arc::new(memory)) - } - - #[cfg(test)] - pub(super) mod tests { - use super::*; - use litebox_broker_core::fs::shared_memory::SharedFileMemoryProvider; - use litebox_broker_core::test_support::FaultingBuffer; - use litebox_platform::buffer::{Buffer, Fault}; - - struct IndirectBuffer(Vec); - - impl Buffer for IndirectBuffer { - fn len(&self) -> usize { - self.0.len() - } - } - - impl ReadBuffer for IndirectBuffer { - fn copy_from_slice_at(&mut self, offset: usize, source: &[u8]) -> Result<(), Fault> { - self.0[offset..][..source.len()].copy_from_slice(source); - Ok(()) - } - - fn copy_from_raw_at( - &mut self, - offset: usize, - source: litebox_platform::buffer::RawBytes<'_>, - ) -> Result<(), Fault> { - source.copy_to_slice(&mut self.0[offset..][..source.len()]); - Ok(()) - } - } - - impl WriteBuffer for IndirectBuffer { - fn copy_to_slice_at(&self, offset: usize, destination: &mut [u8]) -> Result<(), Fault> { - destination.copy_from_slice(&self.0[offset..][..destination.len()]); - Ok(()) - } - - fn copy_to_raw_at( - &self, - offset: usize, - destination: litebox_platform::buffer::RawBytes<'_>, - ) -> Result<(), Fault> { - destination.copy_from_slice(&self.0[offset..][..destination.len()]); - Ok(()) - } - } - - #[test] - fn host_errors_map_to_memory_errors() { - #[cfg(target_os = "linux")] - let cases = [ - (libc::ENOSPC, SharedFileMemoryError::NoMemory), - (libc::ENOMEM, SharedFileMemoryError::NoMemory), - (libc::EFBIG, SharedFileMemoryError::NoMemory), - (libc::EMFILE, SharedFileMemoryError::NoMemory), - (libc::ENFILE, SharedFileMemoryError::NoMemory), - (libc::EFAULT, SharedFileMemoryError::Fault), - (libc::EIO, SharedFileMemoryError::Io), - ]; - #[cfg(windows)] - let cases = { - use windows_sys::Win32::Foundation::{ - ERROR_COMMITMENT_LIMIT, ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, - ERROR_IO_DEVICE, ERROR_NOACCESS, ERROR_NOT_ENOUGH_MEMORY, - ERROR_NOT_ENOUGH_QUOTA, ERROR_OUTOFMEMORY, ERROR_USER_MAPPED_FILE, - }; - [ - (ERROR_USER_MAPPED_FILE, SharedFileMemoryError::Busy), - (ERROR_DISK_FULL, SharedFileMemoryError::NoMemory), - (ERROR_HANDLE_DISK_FULL, SharedFileMemoryError::NoMemory), - (ERROR_NOT_ENOUGH_MEMORY, SharedFileMemoryError::NoMemory), - (ERROR_OUTOFMEMORY, SharedFileMemoryError::NoMemory), - (ERROR_COMMITMENT_LIMIT, SharedFileMemoryError::NoMemory), - (ERROR_NOT_ENOUGH_QUOTA, SharedFileMemoryError::NoMemory), - (ERROR_NOACCESS, SharedFileMemoryError::Fault), - (ERROR_IO_DEVICE, SharedFileMemoryError::Io), - ] - .map(|(code, error)| (code.cast_signed(), error)) - }; - for (code, expected) in cases { - assert_eq!(memory_error(&IoError::from_raw_os_error(code)), expected); - } - } - - /// Checks that memory in `file` never grows past the host's size limit, here a small - /// one, nor to positions the host cannot address. - pub(in crate::shared_objects) fn check_size_limit(file: File) { - let memory = HostFileMemory::new(file); - // A write crossing the limit writes only what fits, directly or staged, and one at - // the limit fails without growing the memory. - assert_eq!(memory.write_limited(0, &b"hello".as_slice(), 4), Ok(4)); - assert_eq!( - memory.write_limited(2, &IndirectBuffer(b"abc".to_vec()), 4), - Ok(2) - ); - assert_eq!( - memory.write_limited(4, &b"!".as_slice(), 4), - Err(SharedFileMemoryError::NoMemory) - ); - assert_eq!( - memory.set_len_limited(5, 4), - Err(SharedFileMemoryError::NoMemory) - ); - assert_eq!(memory.size(), Ok(4)); - memory.set_len_limited(4, 4).unwrap(); - let mut bytes = [0; 4]; - assert_eq!(memory.read_at(0, &mut bytes.as_mut_slice()), Ok(4)); - assert_eq!(&bytes, b"heab"); - - // Positions past `i64::MAX` hold nothing and cannot be written. - assert_eq!( - memory.write_at(usize::MAX - 1, &b"!".as_slice()), - Err(SharedFileMemoryError::NoMemory) - ); - assert_eq!( - memory.set_len(usize::MAX), - Err(SharedFileMemoryError::NoMemory) - ); - assert_eq!( - memory.read_at(usize::MAX - 1, &mut bytes.as_mut_slice()), - Ok(0) - ); - assert_eq!(memory.size(), Ok(4)); - } - - pub(in crate::shared_objects) fn check_reads_writes_and_resizes( - provider: &dyn SharedFileMemoryProvider, - ) { - let memory = provider.create(b"hello").unwrap(); - assert_eq!(memory.size().unwrap(), 5); - let mut bytes = [0; 8]; - assert_eq!(memory.read_at(0, &mut bytes.as_mut_slice()).unwrap(), 5); - assert_eq!(&bytes[..5], b"hello"); - // Writes past the end grow the memory with zeros. - assert_eq!(memory.write_at(6, &b"!!".as_slice()).unwrap(), 2); - assert_eq!(memory.size().unwrap(), 8); - assert_eq!(memory.read_at(0, &mut bytes.as_mut_slice()).unwrap(), 8); - assert_eq!(&bytes, b"hello\0!!"); - - // A fault before anything is written fails without growing the memory, and one - // after the first staged chunk reports the bytes written before it. - assert_eq!( - memory.write_at(9, &FaultingBuffer::new(4, 0)), - Err(SharedFileMemoryError::Fault) - ); - assert_eq!(memory.size().unwrap(), 8); - assert_eq!( - memory - .write_at(8, &FaultingBuffer::new(2 * STAGING_SIZE, STAGING_SIZE + 1)) - .unwrap(), - STAGING_SIZE - ); - assert_eq!(memory.size().unwrap(), 8 + STAGING_SIZE); - - let large: Vec = (0..3 * STAGING_SIZE + 5) - .map(|i| u8::try_from(i % 251).unwrap()) - .collect(); - assert_eq!( - memory.write_at(1, &IndirectBuffer(large.clone())).unwrap(), - large.len() - ); - assert_eq!(memory.size().unwrap(), large.len() + 1); - let mut read = IndirectBuffer(vec![0; large.len() + 7]); - assert_eq!(memory.read_at(1, &mut read).unwrap(), large.len()); - assert_eq!(&read.0[..large.len()], large.as_slice()); - - memory.set_len(2).unwrap(); - assert_eq!(memory.size().unwrap(), 2); - assert_eq!(memory.read_at(1, &mut bytes.as_mut_slice()).unwrap(), 1); - assert_eq!(memory.read_at(4, &mut bytes.as_mut_slice()).unwrap(), 0); - } - } -} +mod host_file; /// Memfd-backed shared file memory for Linux. #[cfg(target_os = "linux")] -pub mod linux { - use std::fs::{File, Permissions}; - use std::io::Error as IoError; - use std::os::fd::FromRawFd as _; - use std::os::unix::fs::PermissionsExt as _; - use std::sync::Arc; - - use litebox_broker_core::fs::shared_memory::{ - SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, - }; - - use super::host_file::{create, memory_error}; - - /// Creates memory for files in memfds, accessed only through positional I/O by the broker. - pub struct MemfdFileMemoryProvider; - - fn memfd() -> Result { - // Ask for execution support where the host otherwise seals memfds against execution. - // Older kernels and hosts that forbid it leave a plain memfd as the best available. - let memfd = |flags| { - // SAFETY: The name is a valid C string, and the call creates a new descriptor. - unsafe { libc::memfd_create(c"litebox-shared-file".as_ptr(), flags) } - }; - let mut fd = memfd(libc::MFD_CLOEXEC | libc::MFD_EXEC); - if fd < 0 { - fd = memfd(libc::MFD_CLOEXEC); - } - if fd < 0 { - return Err(memory_error(&IoError::last_os_error())); - } - // SAFETY: `memfd_create` returned a new descriptor that nothing else owns. - let file = unsafe { File::from_raw_fd(fd) }; - // A runner holding the writable descriptor must not seal the memfd against the broker's - // writes. Memfds created without sealing are sealed against further seals already, but - // hosts that seal memfds against execution allow more seals, so forbid them here. - // SAFETY: `fd` is a live memfd that `file` owns. - unsafe { libc::fcntl(fd, libc::F_ADD_SEALS, libc::F_SEAL_SEAL) }; - // SAFETY: As above. - let seals = unsafe { libc::fcntl(fd, libc::F_GET_SEALS) }; - if seals < 0 || seals & libc::F_SEAL_SEAL == 0 { - return Err(SharedFileMemoryError::Io); - } - // Reopening `/proc//fd/` checks the inode's mode rather than the descriptor's - // access mode, so drop write permission to stop read-only descriptors from being - // reopened for writing. This holds only against openers that cannot bypass or change - // the mode: a process with `CAP_DAC_OVERRIDE` ignores it, and the memfd's owner (the - // broker's uid) or a process with `CAP_FOWNER` can restore it with `fchmod`. Runners - // therefore rely on their seccomp filter, which allows neither `fchmod` nor `open`. - // Execute bits stay, since hosts may seal them. - let mode = file - .metadata() - .map_err(|error| memory_error(&error))? - .permissions() - .mode(); - file.set_permissions(Permissions::from_mode((mode & 0o111) | 0o400)) - .map_err(|error| memory_error(&error))?; - Ok(file) - } - - impl SharedFileMemoryProvider for MemfdFileMemoryProvider { - fn create( - &self, - contents: &[u8], - ) -> Result, SharedFileMemoryError> { - create(memfd()?, contents) - } - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn memfd_memory_reads_writes_and_resizes() { - super::super::host_file::tests::check_reads_writes_and_resizes( - &MemfdFileMemoryProvider, - ); - } - - #[test] - fn memfd_memory_stays_within_size_limits() { - super::super::host_file::tests::check_size_limit(memfd().unwrap()); - } - - #[test] - fn memfds_accept_no_seals() { - let memory = MemfdFileMemoryProvider.create(b"hello").unwrap(); - let raw = libc::c_int::try_from(memory.host_handle(true).unwrap().raw()).unwrap(); - // SAFETY: The handle lends a live descriptor. - let seals = unsafe { libc::fcntl(raw, libc::F_GET_SEALS) }; - assert!(seals >= 0); - assert_ne!(seals & libc::F_SEAL_SEAL, 0); - } - - #[test] - fn reader_handles_are_read_only_and_closed_after_export() { - let open_descriptors = || std::fs::read_dir("/proc/self/fd").unwrap().count(); - let memory = MemfdFileMemoryProvider.create(b"hello").unwrap(); - let before = open_descriptors(); - let reader = memory.host_handle(false).unwrap(); - let raw = libc::c_int::try_from(reader.raw()).unwrap(); - // SAFETY: The handle lends a live descriptor. - assert_eq!( - unsafe { libc::fcntl(raw, libc::F_GETFL) } & libc::O_ACCMODE, - libc::O_RDONLY - ); - // Reopening the reader through procfs checks the inode's mode, which must grant no - // write access, whatever capabilities the test runs with. - let mode = std::fs::metadata(format!("/proc/self/fd/{raw}")) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o222, 0); - drop(reader); - assert_eq!(open_descriptors(), before); - - let writer = memory.host_handle(true).unwrap(); - let raw = libc::c_int::try_from(writer.raw()).unwrap(); - // SAFETY: The handle lends a live descriptor. - assert_eq!( - unsafe { libc::fcntl(raw, libc::F_GETFL) } & libc::O_ACCMODE, - libc::O_RDWR - ); - drop(writer); - assert_eq!(open_descriptors(), before); - } - } -} +pub mod linux; /// Temporary-file-backed shared file memory for Windows. /// /// Pagefile-backed sections cannot grow, so file contents live in temporary files that are /// deleted when their storage closes. A later deployment may derive sections from these files. #[cfg(all(windows, target_arch = "x86_64"))] -pub mod windows { - use std::io::ErrorKind; - use std::os::windows::fs::OpenOptionsExt as _; - use std::sync::Arc; - - use litebox_broker_core::fs::shared_memory::{ - SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, - }; - use windows_sys::Win32::Foundation::{GENERIC_EXECUTE, GENERIC_READ, GENERIC_WRITE}; - use windows_sys::Win32::Storage::FileSystem::{ - FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, - }; - - use super::host_file::{create, memory_error}; - - /// Creates memory for files in the host's temporary directory. - pub struct TempFileMemoryProvider; - - fn temp_file() -> Result { - let directory = std::env::temp_dir(); - loop { - let mut nonce = [0u8; 8]; - getrandom::fill(&mut nonce).map_err(|_| SharedFileMemoryError::Io)?; - let path = directory.join(format!( - "litebox-shared-file-{}-{:016x}", - std::process::id(), - u64::from_le_bytes(nonce), - )); - match std::fs::OpenOptions::new() - .read(true) - .write(true) - .create_new(true) - .access_mode(GENERIC_READ | GENERIC_WRITE | GENERIC_EXECUTE) - .share_mode(0) - .attributes(FILE_ATTRIBUTE_TEMPORARY) - .custom_flags(FILE_FLAG_DELETE_ON_CLOSE) - .open(path) - { - Ok(file) => return Ok(file), - Err(error) if error.kind() == ErrorKind::AlreadyExists => {} - Err(error) => return Err(memory_error(&error)), - } - } - } - - impl SharedFileMemoryProvider for TempFileMemoryProvider { - fn create( - &self, - contents: &[u8], - ) -> Result, SharedFileMemoryError> { - create(temp_file()?, contents) - } - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn temp_file_memory_reads_writes_and_resizes() { - super::super::host_file::tests::check_reads_writes_and_resizes(&TempFileMemoryProvider); - } - - #[test] - fn temp_file_memory_stays_within_size_limits() { - super::super::host_file::tests::check_size_limit(temp_file().unwrap()); - } - - #[test] - fn shrinking_mapped_memory_hides_and_zeroes_the_tail() { - use std::mem::ManuallyDrop; - use std::os::windows::io::FromRawHandle as _; - use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; - use windows_sys::Win32::System::Memory::{ - CreateFileMappingW, FILE_MAP_WRITE, MapViewOfFile, PAGE_READWRITE, UnmapViewOfFile, - }; - - let memory = TempFileMemoryProvider.create(b"hello").unwrap(); - let handle = - usize::try_from(memory.host_handle(true).unwrap().raw()).unwrap() as HANDLE; - let host_len = || { - // SAFETY: The handle stays open while `memory` lives, and `ManuallyDrop` leaves - // closing it to `memory`. - let file = ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) }); - file.metadata().unwrap().len() - }; - let contents = |length: usize| { - let mut bytes = vec![0u8; length + 1]; - let read = memory.read_at(0, &mut bytes.as_mut_slice()).unwrap(); - bytes.truncate(read); - bytes - }; - // SAFETY: `handle` is a live file handle lent by `memory`, and the call creates a - // new section handle. - let section = unsafe { - CreateFileMappingW( - handle, - std::ptr::null(), - PAGE_READWRITE, - 0, - 0, - std::ptr::null(), - ) - }; - assert!(!section.is_null()); - // SAFETY: `section` is a live section handle, and the call maps a new view of it. - let view = unsafe { MapViewOfFile(section, FILE_MAP_WRITE, 0, 0, 0) }; - assert!(!view.Value.is_null()); - let byte = view.Value.cast::(); - // SAFETY: The view maps the five bytes of the file as they were mapped, which the - // host may change behind the reference-free volatile accesses. - let peek = |index: usize| unsafe { byte.add(index).read_volatile() }; - // SAFETY: As for `peek`. - let poke = |index: usize, value: u8| unsafe { byte.add(index).write_volatile(value) }; - - // The host cannot shrink the mapped file, so the bytes past the new end read as - // gone and become zeros in the mapping. - memory.set_len(1).unwrap(); - assert_eq!(memory.size().unwrap(), 1); - assert_eq!(host_len(), 5); - assert_eq!(contents(8), b"h"); - assert_eq!((1..5).map(peek).collect::>(), [0; 4]); - - // What a runner stores past the end through its mapping is zero once the file grows - // over it, whether by resizing or by writing past it. - poke(3, b'x'); - poke(4, b'y'); - memory.set_len(4).unwrap(); - assert_eq!(contents(8), b"h\0\0\0"); - assert_eq!(memory.write_at(6, &b"!".as_slice()).unwrap(), 1); - assert_eq!(contents(8), b"h\0\0\0\0\0!"); - assert_eq!(peek(4), 0); - assert_eq!(host_len(), 7); - - // SAFETY: `view` and `section` were created above and are not used again. - unsafe { - assert_ne!(UnmapViewOfFile(view), 0); - assert_ne!(CloseHandle(section), 0); - } - // Once nothing maps the file, resizing it shrinks the host file too. - memory.set_len(2).unwrap(); - assert_eq!(memory.size().unwrap(), 2); - assert_eq!(host_len(), 2); - } - } -} +pub mod windows; diff --git a/litebox_broker_userland/src/shared_objects/host_file.rs b/litebox_broker_userland/src/shared_objects/host_file.rs new file mode 100644 index 000000000..c2ea573a1 --- /dev/null +++ b/litebox_broker_userland/src/shared_objects/host_file.rs @@ -0,0 +1,684 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use std::fs::File; +use std::io::{Error as IoError, ErrorKind}; +#[cfg(windows)] +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{PoisonError, RwLock}; + +use litebox_broker_core::fs::shared_memory::{ + HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileObject, +}; +use litebox_broker_core::transfer::bounce_buffer; +#[cfg(windows)] +use litebox_platform::buffer::Buffer as _; +use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; + +use crate::fs::{read_slice_at, write_slice_at}; + +/// The most bytes staged at once for buffers that cannot expose their bytes directly. +const STAGING_SIZE: usize = 64 * 1024; + +pub(super) struct HostFileMemory { + file: File, + /// Makes each of the broker's accesses atomic with respect to the others, as + /// [`SharedFileMemory`] requires: reads share it, while writes and resizes take it alone. + io: RwLock<()>, + /// The file's size, changed only while holding `io` alone. + /// + /// Windows refuses to shrink a file while any section of it exists, and runners hold + /// sections for as long as they map the file. A shrink it refuses therefore zeroes the + /// bytes past the new end and lowers this size only, leaving the host file longer until + /// a later resize can shrink it. Runners receive no right to resize sections, so only + /// the broker changes the host file's size. + #[cfg(windows)] + len: AtomicU64, +} + +/// Returns `offset` as a host file position, or `None` past `i64::MAX`, which hosts reject or, +/// on Windows, treat as special values. +fn position(offset: usize) -> Option { + i64::try_from(offset).ok().map(i64::cast_unsigned) +} + +/// Returns the most bytes the host lets the broker's files hold. +/// +/// On Linux, growing a file past `RLIMIT_FSIZE` would raise `SIGXFSZ`, which ends the broker +/// by default, so the memory never tries to. +fn size_limit() -> u64 { + let limit = i64::MAX.cast_unsigned(); + #[cfg(target_os = "linux")] + { + let mut rlimit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: `rlimit` is valid for the call to fill. + if unsafe { libc::getrlimit(libc::RLIMIT_FSIZE, &raw mut rlimit) } == 0 + && rlimit.rlim_cur != libc::RLIM_INFINITY + { + return limit.min(rlimit.rlim_cur); + } + } + limit +} + +impl HostFileMemory { + fn new(file: File) -> Self { + Self { + file, + io: RwLock::new(()), + #[cfg(windows)] + len: AtomicU64::new(0), + } + } + + /// Opens the contents again with read and execute rights only. + #[cfg(target_os = "linux")] + fn open_read_only(&self) -> Result { + use std::os::fd::AsRawFd as _; + + // Duplication preserves the access mode, so reopen for readers. + File::open(format!("/proc/self/fd/{}", self.file.as_raw_fd())) + .map_err(|_| SharedFileMemoryError::Unsupported) + } + + /// Reads at `position` into `buffer`, as [`SharedFileMemory::read_at`] does. Callers hold + /// `io`. + fn read_locked( + &self, + position: u64, + buffer: &mut dyn ReadBuffer, + ) -> Result { + #[cfg(target_os = "linux")] + let direct = crate::fs::linux::read_at(&self.file, buffer, position); + #[cfg(windows)] + let direct = crate::fs::windows::read_at(&self.file, buffer, position); + // A short or interrupted direct read continues through the staging buffer, so that + // only the end of the memory stops a read short. + let mut read = match direct { + Some(Ok(0)) => return Ok(0), + Some(Ok(read)) => read, + Some(Err(error)) if error.kind() == ErrorKind::Interrupted => 0, + Some(Err(error)) => return Err(memory_error(&error)), + None => 0, + }; + if read == buffer.len() { + return Ok(read); + } + // A short direct read usually stopped at the end, which the size confirms without + // staging anything. + if read > 0 && position + read as u64 >= self.len()? { + return Ok(read); + } + let mut staging = match bounce_buffer(STAGING_SIZE.min(buffer.len() - read)) { + Ok(staging) => staging, + Err(_) if read > 0 => return Ok(read), + Err(_) => return Err(SharedFileMemoryError::NoMemory), + }; + while read < buffer.len() { + let chunk = &mut staging[..STAGING_SIZE.min(buffer.len() - read)]; + let count = self + .read_once(chunk, position + read as u64) + .map_err(|error| memory_error(&error))?; + if count == 0 { + break; + } + buffer.copy_from_slice_at(read, &chunk[..count])?; + read += count; + } + Ok(read) + } + + /// Returns the host file's size. + fn host_len(&self) -> Result { + Ok(self + .file + .metadata() + .map_err(|error| memory_error(&error))? + .len()) + } + + /// Returns the file's size. Callers hold `io`. + #[cfg_attr( + windows, + expect( + clippy::unnecessary_wraps, + reason = "reading the size fails only on Linux" + ) + )] + fn len(&self) -> Result { + #[cfg(windows)] + return Ok(self.len.load(Ordering::Relaxed)); + #[cfg(target_os = "linux")] + self.host_len() + } + + /// Zeroes the host file's bytes from `start` to `end`. Callers hold `io` alone. + #[cfg(windows)] + fn zero(&self, start: u64, end: u64) -> Result<(), SharedFileMemoryError> { + if start >= end { + return Ok(()); + } + let zeros = bounce_buffer( + usize::try_from(end - start).map_or(STAGING_SIZE, |length| length.min(STAGING_SIZE)), + ) + .map_err(|_| SharedFileMemoryError::NoMemory)?; + let mut position = start; + while position < end { + let length = usize::try_from(end - position) + .map_or(zeros.len(), |remaining| remaining.min(zeros.len())); + self.write_all(&zeros[..length], position, &mut 0) + .map_err(|error| memory_error(&error))?; + position += length as u64; + } + Ok(()) + } + + /// Resizes the file to `length` bytes on Windows. Callers hold `io` alone. + /// + /// Bytes between the old and the new size that the host file already holds are zeroed, + /// since runners may have written there through mappings past the end of the file, and + /// a shrink the host refuses while the file is mapped leaves them in the host file. + #[cfg(windows)] + fn resize(&self, length: u64) -> Result<(), SharedFileMemoryError> { + let old_len = self.len()?; + let old_host_len = self.host_len()?; + let host_len = if length == old_host_len { + length + } else { + match self.file.set_len(length) { + Ok(()) => length, + Err(error) + if length < old_host_len + && memory_error(&error) == SharedFileMemoryError::Busy => + { + old_host_len + } + Err(error) => return Err(memory_error(&error)), + } + }; + // Bytes the host file had before this resize and still has may be stale. + let stale_end = old_host_len.min(host_len); + let (start, end) = if length > old_len { + (old_len, length) + } else { + (length, old_len) + }; + self.zero(start, end.min(stale_end))?; + self.len.store(length, Ordering::Relaxed); + Ok(()) + } + + fn read_once(&self, buffer: &mut [u8], offset: u64) -> std::io::Result { + loop { + match read_slice_at(&self.file, buffer, offset) { + Err(error) if error.kind() == ErrorKind::Interrupted => {} + #[cfg(windows)] + Err(error) + if error.raw_os_error() + == Some(windows_sys::Win32::Foundation::ERROR_HANDLE_EOF.cast_signed()) => + { + return Ok(0); + } + read => return read, + } + } + } + + /// Writes all of `data` at `offset`, adding the bytes written to `written` as it goes. + fn write_all( + &self, + mut data: &[u8], + mut offset: u64, + written: &mut usize, + ) -> std::io::Result<()> { + while !data.is_empty() { + match write_slice_at(&self.file, data, offset) { + Ok(0) => return Err(ErrorKind::WriteZero.into()), + Ok(count) => { + data = &data[count..]; + offset += count as u64; + *written += count; + } + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } + Ok(()) + } + + /// Writes like [`SharedFileMemory::write_at`], but grows the memory to at most `limit` + /// bytes, writing only the bytes that fit before it, as Linux does at `RLIMIT_FSIZE`. + fn write_limited( + &self, + offset: usize, + data: &dyn WriteBuffer, + limit: u64, + ) -> Result { + if data.is_empty() { + return Ok(0); + } + let position = position(offset) + .filter(|position| *position < limit) + .ok_or(SharedFileMemoryError::NoMemory)?; + let length = + usize::try_from(limit - position).map_or(data.len(), |room| room.min(data.len())); + let _io = self.io.write().unwrap_or_else(PoisonError::into_inner); + // The host file may hold stale bytes between the end of the file and the write. + #[cfg(windows)] + { + let len = self.len()?; + if position > len { + self.zero(len, position.min(self.host_len()?))?; + } + } + let written = self.write_locked(position, data, length)?; + #[cfg(windows)] + if written > 0 { + self.len + .fetch_max(position + written as u64, Ordering::Relaxed); + } + Ok(written) + } + + /// Writes the first `length` bytes of `data` at `position`, as + /// [`SharedFileMemory::write_at`] does. Callers hold `io` alone. + fn write_locked( + &self, + position: u64, + data: &dyn WriteBuffer, + length: usize, + ) -> Result { + // Only a write that fits whole may transfer directly, since it cannot stop early. + let direct = if length == data.len() { + #[cfg(target_os = "linux")] + let direct = crate::fs::linux::write_at(&self.file, data, position); + #[cfg(windows)] + let direct = crate::fs::windows::write_at(&self.file, data, position); + direct + } else { + None + }; + // A short or interrupted direct write continues through the staging buffer. + let mut written = match direct { + Some(Err(error)) if error.kind() == ErrorKind::Interrupted => 0, + Some(written) => written.map_err(|error| memory_error(&error))?, + None => 0, + }; + if written == length { + return Ok(written); + } + let mut staging = match bounce_buffer(STAGING_SIZE.min(length - written)) { + Ok(staging) => staging, + Err(_) if written > 0 => return Ok(written), + Err(_) => return Err(SharedFileMemoryError::NoMemory), + }; + while written < length { + let chunk = &mut staging[..STAGING_SIZE.min(length - written)]; + let result = data + .copy_to_slice_at(written, chunk) + .map_err(SharedFileMemoryError::from) + .and_then(|()| { + self.write_all(chunk, position + written as u64, &mut written) + .map_err(|error| memory_error(&error)) + }); + match result { + Ok(()) => {} + // Like `pwrite`, report the bytes already written rather than the error. + Err(_) if written > 0 => break, + Err(error) => return Err(error), + } + } + Ok(written) + } + + /// Resizes like [`SharedFileMemory::set_len`], but to at most `limit` bytes. + fn set_len_limited(&self, length: usize, limit: u64) -> Result<(), SharedFileMemoryError> { + let length = u64::try_from(length) + .ok() + .filter(|length| *length <= limit) + .ok_or(SharedFileMemoryError::NoMemory)?; + let _io = self.io.write().unwrap_or_else(PoisonError::into_inner); + #[cfg(target_os = "linux")] + return self + .file + .set_len(length) + .map_err(|error| memory_error(&error)); + #[cfg(windows)] + self.resize(length) + } +} + +pub(super) fn memory_error(error: &IoError) -> SharedFileMemoryError { + let code = error.raw_os_error(); + #[cfg(target_os = "linux")] + { + if matches!( + code, + Some(libc::ENOSPC | libc::ENOMEM | libc::EFBIG | libc::EMFILE | libc::ENFILE) + ) { + return SharedFileMemoryError::NoMemory; + } + if code == Some(libc::EFAULT) { + return SharedFileMemoryError::Fault; + } + } + #[cfg(windows)] + { + use windows_sys::Win32::Foundation::{ + ERROR_COMMITMENT_LIMIT, ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, ERROR_NOACCESS, + ERROR_NOT_ENOUGH_MEMORY, ERROR_NOT_ENOUGH_QUOTA, ERROR_OUTOFMEMORY, + ERROR_USER_MAPPED_FILE, + }; + let code = code.and_then(|code| u32::try_from(code).ok()); + if code == Some(ERROR_USER_MAPPED_FILE) { + return SharedFileMemoryError::Busy; + } + if matches!( + code, + Some( + ERROR_DISK_FULL + | ERROR_HANDLE_DISK_FULL + | ERROR_NOT_ENOUGH_MEMORY + | ERROR_OUTOFMEMORY + | ERROR_COMMITMENT_LIMIT + | ERROR_NOT_ENOUGH_QUOTA + ) + ) { + return SharedFileMemoryError::NoMemory; + } + if code == Some(ERROR_NOACCESS) { + return SharedFileMemoryError::Fault; + } + } + SharedFileMemoryError::Io +} + +impl SharedFileMemory for HostFileMemory { + fn read_at( + &self, + offset: usize, + buffer: &mut dyn ReadBuffer, + ) -> Result { + // Nothing lies past the largest position the host accepts. + let Some(position) = position(offset) else { + return Ok(0); + }; + let _io = self.io.read().unwrap_or_else(PoisonError::into_inner); + // The host file may hold stale bytes past the end of the file. + #[cfg(windows)] + { + let available = self.len()?.saturating_sub(position); + if available < buffer.len() as u64 { + // `available` is less than the buffer's length, so it fits. + let length = usize::try_from(available).unwrap_or(0); + if length == 0 { + return Ok(0); + } + return self.read_locked(position, &mut (&mut *buffer).sub_buffer(0, length)); + } + } + self.read_locked(position, buffer) + } + + fn write_at( + &self, + offset: usize, + data: &dyn WriteBuffer, + ) -> Result { + self.write_limited(offset, data, size_limit()) + } + + fn size(&self) -> Result { + let _io = self.io.read().unwrap_or_else(PoisonError::into_inner); + usize::try_from(self.len()?).map_err(|_| SharedFileMemoryError::Io) + } + + fn set_len(&self, length: usize) -> Result<(), SharedFileMemoryError> { + self.set_len_limited(length, size_limit()) + } +} + +/// A host handle exported from shared file memory, either lent by the memory, as a +/// borrowed handle, or made for the export, as an owned one that closes when dropped. +struct Exported(H); + +#[cfg(target_os = "linux")] +impl HostHandle for Exported { + fn raw(&self) -> u64 { + use std::os::fd::AsRawFd as _; + + // Open descriptors are never negative. + u64::from(self.0.as_fd().as_raw_fd().cast_unsigned()) + } +} + +#[cfg(windows)] +impl HostHandle for Exported { + fn raw(&self) -> u64 { + use std::os::windows::io::AsRawHandle as _; + + self.0.as_handle().as_raw_handle() as usize as u64 + } +} + +impl SharedFileObject for HostFileMemory { + fn host_handle( + &self, + writable: bool, + ) -> Result, SharedFileMemoryError> { + #[cfg(target_os = "linux")] + { + use std::os::fd::AsFd as _; + + if writable { + return Ok(Box::new(Exported(self.file.as_fd()))); + } + // The host checks a descriptor's access mode when mapping it, so readers get a + // read-only open, made for this export alone so that no extra descriptor stays + // open between deliveries. + Ok(Box::new(Exported(self.open_read_only()?))) + } + // The handle never leaves the broker: the deployment derives a section from it, and + // the section's protection and the rights of the section handle sent to a runner + // enforce `writable`, so readers and writers share the storage's own handle. + #[cfg(windows)] + { + use std::os::windows::io::AsHandle as _; + + let _ = writable; + Ok(Box::new(Exported(self.file.as_handle()))) + } + } +} +pub(super) fn create( + file: File, + contents: &[u8], +) -> Result, SharedFileMemoryError> { + if u64::try_from(contents.len()).map_or(true, |length| length > size_limit()) { + return Err(SharedFileMemoryError::NoMemory); + } + let memory = HostFileMemory::new(file); + // Nothing else can reach the memory yet, so the contents go straight to the file. + memory + .write_all(contents, 0, &mut 0) + .map_err(|error| memory_error(&error))?; + #[cfg(windows)] + memory.len.store(contents.len() as u64, Ordering::Relaxed); + Ok(std::sync::Arc::new(memory)) +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + use litebox_broker_core::fs::shared_memory::SharedFileMemoryProvider; + use litebox_broker_core::test_support::FaultingBuffer; + use litebox_platform::buffer::{Buffer, Fault}; + + struct IndirectBuffer(Vec); + + impl Buffer for IndirectBuffer { + fn len(&self) -> usize { + self.0.len() + } + } + + impl ReadBuffer for IndirectBuffer { + fn copy_from_slice_at(&mut self, offset: usize, source: &[u8]) -> Result<(), Fault> { + self.0[offset..][..source.len()].copy_from_slice(source); + Ok(()) + } + + fn copy_from_raw_at( + &mut self, + offset: usize, + source: litebox_platform::buffer::RawBytes<'_>, + ) -> Result<(), Fault> { + source.copy_to_slice(&mut self.0[offset..][..source.len()]); + Ok(()) + } + } + + impl WriteBuffer for IndirectBuffer { + fn copy_to_slice_at(&self, offset: usize, destination: &mut [u8]) -> Result<(), Fault> { + destination.copy_from_slice(&self.0[offset..][..destination.len()]); + Ok(()) + } + + fn copy_to_raw_at( + &self, + offset: usize, + destination: litebox_platform::buffer::RawBytes<'_>, + ) -> Result<(), Fault> { + destination.copy_from_slice(&self.0[offset..][..destination.len()]); + Ok(()) + } + } + + #[test] + fn host_errors_map_to_memory_errors() { + #[cfg(target_os = "linux")] + let cases = [ + (libc::ENOSPC, SharedFileMemoryError::NoMemory), + (libc::ENOMEM, SharedFileMemoryError::NoMemory), + (libc::EFBIG, SharedFileMemoryError::NoMemory), + (libc::EMFILE, SharedFileMemoryError::NoMemory), + (libc::ENFILE, SharedFileMemoryError::NoMemory), + (libc::EFAULT, SharedFileMemoryError::Fault), + (libc::EIO, SharedFileMemoryError::Io), + ]; + #[cfg(windows)] + let cases = { + use windows_sys::Win32::Foundation::{ + ERROR_COMMITMENT_LIMIT, ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, ERROR_IO_DEVICE, + ERROR_NOACCESS, ERROR_NOT_ENOUGH_MEMORY, ERROR_NOT_ENOUGH_QUOTA, ERROR_OUTOFMEMORY, + ERROR_USER_MAPPED_FILE, + }; + [ + (ERROR_USER_MAPPED_FILE, SharedFileMemoryError::Busy), + (ERROR_DISK_FULL, SharedFileMemoryError::NoMemory), + (ERROR_HANDLE_DISK_FULL, SharedFileMemoryError::NoMemory), + (ERROR_NOT_ENOUGH_MEMORY, SharedFileMemoryError::NoMemory), + (ERROR_OUTOFMEMORY, SharedFileMemoryError::NoMemory), + (ERROR_COMMITMENT_LIMIT, SharedFileMemoryError::NoMemory), + (ERROR_NOT_ENOUGH_QUOTA, SharedFileMemoryError::NoMemory), + (ERROR_NOACCESS, SharedFileMemoryError::Fault), + (ERROR_IO_DEVICE, SharedFileMemoryError::Io), + ] + .map(|(code, error)| (code.cast_signed(), error)) + }; + for (code, expected) in cases { + assert_eq!(memory_error(&IoError::from_raw_os_error(code)), expected); + } + } + + /// Checks that memory in `file` never grows past the host's size limit, here a small + /// one, nor to positions the host cannot address. + pub(in crate::shared_objects) fn check_size_limit(file: File) { + let memory = HostFileMemory::new(file); + // A write crossing the limit writes only what fits, directly or staged, and one at + // the limit fails without growing the memory. + assert_eq!(memory.write_limited(0, &b"hello".as_slice(), 4), Ok(4)); + assert_eq!( + memory.write_limited(2, &IndirectBuffer(b"abc".to_vec()), 4), + Ok(2) + ); + assert_eq!( + memory.write_limited(4, &b"!".as_slice(), 4), + Err(SharedFileMemoryError::NoMemory) + ); + assert_eq!( + memory.set_len_limited(5, 4), + Err(SharedFileMemoryError::NoMemory) + ); + assert_eq!(memory.size(), Ok(4)); + memory.set_len_limited(4, 4).unwrap(); + let mut bytes = [0; 4]; + assert_eq!(memory.read_at(0, &mut bytes.as_mut_slice()), Ok(4)); + assert_eq!(&bytes, b"heab"); + + // Positions past `i64::MAX` hold nothing and cannot be written. + assert_eq!( + memory.write_at(usize::MAX - 1, &b"!".as_slice()), + Err(SharedFileMemoryError::NoMemory) + ); + assert_eq!( + memory.set_len(usize::MAX), + Err(SharedFileMemoryError::NoMemory) + ); + assert_eq!( + memory.read_at(usize::MAX - 1, &mut bytes.as_mut_slice()), + Ok(0) + ); + assert_eq!(memory.size(), Ok(4)); + } + + pub(in crate::shared_objects) fn check_reads_writes_and_resizes( + provider: &dyn SharedFileMemoryProvider, + ) { + let memory = provider.create(b"hello").unwrap(); + assert_eq!(memory.size().unwrap(), 5); + let mut bytes = [0; 8]; + assert_eq!(memory.read_at(0, &mut bytes.as_mut_slice()).unwrap(), 5); + assert_eq!(&bytes[..5], b"hello"); + // Writes past the end grow the memory with zeros. + assert_eq!(memory.write_at(6, &b"!!".as_slice()).unwrap(), 2); + assert_eq!(memory.size().unwrap(), 8); + assert_eq!(memory.read_at(0, &mut bytes.as_mut_slice()).unwrap(), 8); + assert_eq!(&bytes, b"hello\0!!"); + + // A fault before anything is written fails without growing the memory, and one + // after the first staged chunk reports the bytes written before it. + assert_eq!( + memory.write_at(9, &FaultingBuffer::new(4, 0)), + Err(SharedFileMemoryError::Fault) + ); + assert_eq!(memory.size().unwrap(), 8); + assert_eq!( + memory + .write_at(8, &FaultingBuffer::new(2 * STAGING_SIZE, STAGING_SIZE + 1)) + .unwrap(), + STAGING_SIZE + ); + assert_eq!(memory.size().unwrap(), 8 + STAGING_SIZE); + + let large: Vec = (0..3 * STAGING_SIZE + 5) + .map(|i| u8::try_from(i % 251).unwrap()) + .collect(); + assert_eq!( + memory.write_at(1, &IndirectBuffer(large.clone())).unwrap(), + large.len() + ); + assert_eq!(memory.size().unwrap(), large.len() + 1); + let mut read = IndirectBuffer(vec![0; large.len() + 7]); + assert_eq!(memory.read_at(1, &mut read).unwrap(), large.len()); + assert_eq!(&read.0[..large.len()], large.as_slice()); + + memory.set_len(2).unwrap(); + assert_eq!(memory.size().unwrap(), 2); + assert_eq!(memory.read_at(1, &mut bytes.as_mut_slice()).unwrap(), 1); + assert_eq!(memory.read_at(4, &mut bytes.as_mut_slice()).unwrap(), 0); + } +} diff --git a/litebox_broker_userland/src/shared_objects/linux.rs b/litebox_broker_userland/src/shared_objects/linux.rs new file mode 100644 index 000000000..0914bd661 --- /dev/null +++ b/litebox_broker_userland/src/shared_objects/linux.rs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use std::fs::{File, Permissions}; +use std::io::Error as IoError; +use std::os::fd::FromRawFd as _; +use std::os::unix::fs::PermissionsExt as _; +use std::sync::Arc; + +use litebox_broker_core::fs::shared_memory::{ + SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, +}; + +use super::host_file::{create, memory_error}; + +/// Creates memory for files in memfds, accessed only through positional I/O by the broker. +pub struct MemfdFileMemoryProvider; + +fn memfd() -> Result { + // Ask for execution support where the host otherwise seals memfds against execution. + // Older kernels and hosts that forbid it leave a plain memfd as the best available. + let memfd = |flags| { + // SAFETY: The name is a valid C string, and the call creates a new descriptor. + unsafe { libc::memfd_create(c"litebox-shared-file".as_ptr(), flags) } + }; + let mut fd = memfd(libc::MFD_CLOEXEC | libc::MFD_EXEC); + if fd < 0 { + fd = memfd(libc::MFD_CLOEXEC); + } + if fd < 0 { + return Err(memory_error(&IoError::last_os_error())); + } + // SAFETY: `memfd_create` returned a new descriptor that nothing else owns. + let file = unsafe { File::from_raw_fd(fd) }; + // A runner holding the writable descriptor must not seal the memfd against the broker's + // writes. Memfds created without sealing are sealed against further seals already, but + // hosts that seal memfds against execution allow more seals, so forbid them here. + // SAFETY: `fd` is a live memfd that `file` owns. + unsafe { libc::fcntl(fd, libc::F_ADD_SEALS, libc::F_SEAL_SEAL) }; + // SAFETY: As above. + let seals = unsafe { libc::fcntl(fd, libc::F_GET_SEALS) }; + if seals < 0 || seals & libc::F_SEAL_SEAL == 0 { + return Err(SharedFileMemoryError::Io); + } + // Reopening `/proc//fd/` checks the inode's mode rather than the descriptor's + // access mode, so drop write permission to stop read-only descriptors from being + // reopened for writing. This holds only against openers that cannot bypass or change + // the mode: a process with `CAP_DAC_OVERRIDE` ignores it, and the memfd's owner (the + // broker's uid) or a process with `CAP_FOWNER` can restore it with `fchmod`. Runners + // therefore rely on their seccomp filter, which allows neither `fchmod` nor `open`. + // Execute bits stay, since hosts may seal them. + let mode = file + .metadata() + .map_err(|error| memory_error(&error))? + .permissions() + .mode(); + file.set_permissions(Permissions::from_mode((mode & 0o111) | 0o400)) + .map_err(|error| memory_error(&error))?; + Ok(file) +} + +impl SharedFileMemoryProvider for MemfdFileMemoryProvider { + fn create(&self, contents: &[u8]) -> Result, SharedFileMemoryError> { + create(memfd()?, contents) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn memfd_memory_reads_writes_and_resizes() { + super::super::host_file::tests::check_reads_writes_and_resizes(&MemfdFileMemoryProvider); + } + + #[test] + fn memfd_memory_stays_within_size_limits() { + super::super::host_file::tests::check_size_limit(memfd().unwrap()); + } + + #[test] + fn memfds_accept_no_seals() { + let memory = MemfdFileMemoryProvider.create(b"hello").unwrap(); + let raw = libc::c_int::try_from(memory.host_handle(true).unwrap().raw()).unwrap(); + // SAFETY: The handle lends a live descriptor. + let seals = unsafe { libc::fcntl(raw, libc::F_GET_SEALS) }; + assert!(seals >= 0); + assert_ne!(seals & libc::F_SEAL_SEAL, 0); + } + + #[test] + fn reader_handles_are_read_only_and_closed_after_export() { + let open_descriptors = || std::fs::read_dir("/proc/self/fd").unwrap().count(); + let memory = MemfdFileMemoryProvider.create(b"hello").unwrap(); + let before = open_descriptors(); + let reader = memory.host_handle(false).unwrap(); + let raw = libc::c_int::try_from(reader.raw()).unwrap(); + // SAFETY: The handle lends a live descriptor. + assert_eq!( + unsafe { libc::fcntl(raw, libc::F_GETFL) } & libc::O_ACCMODE, + libc::O_RDONLY + ); + // Reopening the reader through procfs checks the inode's mode, which must grant no + // write access, whatever capabilities the test runs with. + let mode = std::fs::metadata(format!("/proc/self/fd/{raw}")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o222, 0); + drop(reader); + assert_eq!(open_descriptors(), before); + + let writer = memory.host_handle(true).unwrap(); + let raw = libc::c_int::try_from(writer.raw()).unwrap(); + // SAFETY: The handle lends a live descriptor. + assert_eq!( + unsafe { libc::fcntl(raw, libc::F_GETFL) } & libc::O_ACCMODE, + libc::O_RDWR + ); + drop(writer); + assert_eq!(open_descriptors(), before); + } +} diff --git a/litebox_broker_userland/src/shared_objects/windows.rs b/litebox_broker_userland/src/shared_objects/windows.rs new file mode 100644 index 000000000..b6a4e6204 --- /dev/null +++ b/litebox_broker_userland/src/shared_objects/windows.rs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use std::io::ErrorKind; +use std::os::windows::fs::OpenOptionsExt as _; +use std::sync::Arc; + +use litebox_broker_core::fs::shared_memory::{ + SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, +}; +use windows_sys::Win32::Foundation::{GENERIC_EXECUTE, GENERIC_READ, GENERIC_WRITE}; +use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, +}; + +use super::host_file::{create, memory_error}; + +/// Creates memory for files in the host's temporary directory. +pub struct TempFileMemoryProvider; + +fn temp_file() -> Result { + let directory = std::env::temp_dir(); + loop { + let mut nonce = [0u8; 8]; + getrandom::fill(&mut nonce).map_err(|_| SharedFileMemoryError::Io)?; + let path = directory.join(format!( + "litebox-shared-file-{}-{:016x}", + std::process::id(), + u64::from_le_bytes(nonce), + )); + match std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .access_mode(GENERIC_READ | GENERIC_WRITE | GENERIC_EXECUTE) + .share_mode(0) + .attributes(FILE_ATTRIBUTE_TEMPORARY) + .custom_flags(FILE_FLAG_DELETE_ON_CLOSE) + .open(path) + { + Ok(file) => return Ok(file), + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(error) => return Err(memory_error(&error)), + } + } +} + +impl SharedFileMemoryProvider for TempFileMemoryProvider { + fn create(&self, contents: &[u8]) -> Result, SharedFileMemoryError> { + create(temp_file()?, contents) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn temp_file_memory_reads_writes_and_resizes() { + super::super::host_file::tests::check_reads_writes_and_resizes(&TempFileMemoryProvider); + } + + #[test] + fn temp_file_memory_stays_within_size_limits() { + super::super::host_file::tests::check_size_limit(temp_file().unwrap()); + } + + #[test] + fn shrinking_mapped_memory_hides_and_zeroes_the_tail() { + use std::mem::ManuallyDrop; + use std::os::windows::io::FromRawHandle as _; + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::System::Memory::{ + CreateFileMappingW, FILE_MAP_WRITE, MapViewOfFile, PAGE_READWRITE, UnmapViewOfFile, + }; + + let memory = TempFileMemoryProvider.create(b"hello").unwrap(); + let handle = usize::try_from(memory.host_handle(true).unwrap().raw()).unwrap() as HANDLE; + let host_len = || { + // SAFETY: The handle stays open while `memory` lives, and `ManuallyDrop` leaves + // closing it to `memory`. + let file = ManuallyDrop::new(unsafe { std::fs::File::from_raw_handle(handle) }); + file.metadata().unwrap().len() + }; + let contents = |length: usize| { + let mut bytes = vec![0u8; length + 1]; + let read = memory.read_at(0, &mut bytes.as_mut_slice()).unwrap(); + bytes.truncate(read); + bytes + }; + // SAFETY: `handle` is a live file handle lent by `memory`, and the call creates a + // new section handle. + let section = unsafe { + CreateFileMappingW( + handle, + std::ptr::null(), + PAGE_READWRITE, + 0, + 0, + std::ptr::null(), + ) + }; + assert!(!section.is_null()); + // SAFETY: `section` is a live section handle, and the call maps a new view of it. + let view = unsafe { MapViewOfFile(section, FILE_MAP_WRITE, 0, 0, 0) }; + assert!(!view.Value.is_null()); + let byte = view.Value.cast::(); + // SAFETY: The view maps the five bytes of the file as they were mapped, which the + // host may change behind the reference-free volatile accesses. + let peek = |index: usize| unsafe { byte.add(index).read_volatile() }; + // SAFETY: As for `peek`. + let poke = |index: usize, value: u8| unsafe { byte.add(index).write_volatile(value) }; + + // The host cannot shrink the mapped file, so the bytes past the new end read as + // gone and become zeros in the mapping. + memory.set_len(1).unwrap(); + assert_eq!(memory.size().unwrap(), 1); + assert_eq!(host_len(), 5); + assert_eq!(contents(8), b"h"); + assert_eq!((1..5).map(peek).collect::>(), [0; 4]); + + // What a runner stores past the end through its mapping is zero once the file grows + // over it, whether by resizing or by writing past it. + poke(3, b'x'); + poke(4, b'y'); + memory.set_len(4).unwrap(); + assert_eq!(contents(8), b"h\0\0\0"); + assert_eq!(memory.write_at(6, &b"!".as_slice()).unwrap(), 1); + assert_eq!(contents(8), b"h\0\0\0\0\0!"); + assert_eq!(peek(4), 0); + assert_eq!(host_len(), 7); + + // SAFETY: `view` and `section` were created above and are not used again. + unsafe { + assert_ne!(UnmapViewOfFile(view), 0); + assert_ne!(CloseHandle(section), 0); + } + // Once nothing maps the file, resizing it shrinks the host file too. + memory.set_len(2).unwrap(); + assert_eq!(memory.size().unwrap(), 2); + assert_eq!(host_len(), 2); + } +} From e9018068973de765a432cbdab2ace6abd681e3a9 Mon Sep 17 00:00:00 2001 From: Weiteng Chen Date: Tue, 3 Nov 2026 02:27:14 -0800 Subject: [PATCH 10/12] Move host-specific shared file memory into each host's module HostFileMemory keeps the shared read and write logic in host_file.rs and calls methods that linux.rs and windows.rs each implement on it: the size, resizing, bookkeeping around writes, and exporting handles. Windows's logical size and the error codes of each host move to its module. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- litebox_broker_userland/src/shared_objects.rs | 6 + .../src/shared_objects/host_file.rs | 330 +++--------------- .../src/shared_objects/linux.rs | 118 ++++++- .../src/shared_objects/windows.rs | 190 +++++++++- 4 files changed, 352 insertions(+), 292 deletions(-) diff --git a/litebox_broker_userland/src/shared_objects.rs b/litebox_broker_userland/src/shared_objects.rs index cd5ed11dc..205371980 100644 --- a/litebox_broker_userland/src/shared_objects.rs +++ b/litebox_broker_userland/src/shared_objects.rs @@ -21,3 +21,9 @@ pub mod linux; /// deleted when their storage closes. A later deployment may derive sections from these files. #[cfg(all(windows, target_arch = "x86_64"))] pub mod windows; + +/// The host's module, which provides the host-specific parts of [`host_file`]. +#[cfg(target_os = "linux")] +use linux as platform; +#[cfg(all(windows, target_arch = "x86_64"))] +use windows as platform; diff --git a/litebox_broker_userland/src/shared_objects/host_file.rs b/litebox_broker_userland/src/shared_objects/host_file.rs index c2ea573a1..68e8d998f 100644 --- a/litebox_broker_userland/src/shared_objects/host_file.rs +++ b/litebox_broker_userland/src/shared_objects/host_file.rs @@ -3,37 +3,39 @@ use std::fs::File; use std::io::{Error as IoError, ErrorKind}; -#[cfg(windows)] -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{PoisonError, RwLock}; use litebox_broker_core::fs::shared_memory::{ HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileObject, }; use litebox_broker_core::transfer::bounce_buffer; -#[cfg(windows)] -use litebox_platform::buffer::Buffer as _; -use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; +use litebox_platform::buffer::{Buffer as _, ReadBuffer, WriteBuffer}; +use super::platform::{self, State}; +#[cfg(target_os = "linux")] +use crate::fs::linux as direct; +#[cfg(windows)] +use crate::fs::windows as direct; use crate::fs::{read_slice_at, write_slice_at}; /// The most bytes staged at once for buffers that cannot expose their bytes directly. -const STAGING_SIZE: usize = 64 * 1024; +pub(super) const STAGING_SIZE: usize = 64 * 1024; +/// A file's contents in a host file, accessed with positional I/O. +/// +/// The host-specific parts, such as how the size is kept and which handles are exported, are +/// in each host's module, which implements the methods this module calls on it. pub(super) struct HostFileMemory { - file: File, + pub(super) file: File, /// Makes each of the broker's accesses atomic with respect to the others, as /// [`SharedFileMemory`] requires: reads share it, while writes and resizes take it alone. io: RwLock<()>, - /// The file's size, changed only while holding `io` alone. - /// - /// Windows refuses to shrink a file while any section of it exists, and runners hold - /// sections for as long as they map the file. A shrink it refuses therefore zeroes the - /// bytes past the new end and lowers this size only, leaving the host file longer until - /// a later resize can shrink it. Runners receive no right to resize sections, so only - /// the broker changes the host file's size. - #[cfg(windows)] - len: AtomicU64, + /// Host-specific state, changed only while holding `io` alone. + #[cfg_attr( + target_os = "linux", + expect(dead_code, reason = "Linux keeps no state of its own") + )] + pub(super) state: State, } /// Returns `offset` as a host file position, or `None` past `i64::MAX`, which hosts reject or, @@ -42,48 +44,15 @@ fn position(offset: usize) -> Option { i64::try_from(offset).ok().map(i64::cast_unsigned) } -/// Returns the most bytes the host lets the broker's files hold. -/// -/// On Linux, growing a file past `RLIMIT_FSIZE` would raise `SIGXFSZ`, which ends the broker -/// by default, so the memory never tries to. -fn size_limit() -> u64 { - let limit = i64::MAX.cast_unsigned(); - #[cfg(target_os = "linux")] - { - let mut rlimit = libc::rlimit { - rlim_cur: 0, - rlim_max: 0, - }; - // SAFETY: `rlimit` is valid for the call to fill. - if unsafe { libc::getrlimit(libc::RLIMIT_FSIZE, &raw mut rlimit) } == 0 - && rlimit.rlim_cur != libc::RLIM_INFINITY - { - return limit.min(rlimit.rlim_cur); - } - } - limit -} - impl HostFileMemory { fn new(file: File) -> Self { Self { file, io: RwLock::new(()), - #[cfg(windows)] - len: AtomicU64::new(0), + state: State::default(), } } - /// Opens the contents again with read and execute rights only. - #[cfg(target_os = "linux")] - fn open_read_only(&self) -> Result { - use std::os::fd::AsRawFd as _; - - // Duplication preserves the access mode, so reopen for readers. - File::open(format!("/proc/self/fd/{}", self.file.as_raw_fd())) - .map_err(|_| SharedFileMemoryError::Unsupported) - } - /// Reads at `position` into `buffer`, as [`SharedFileMemory::read_at`] does. Callers hold /// `io`. fn read_locked( @@ -91,10 +60,7 @@ impl HostFileMemory { position: u64, buffer: &mut dyn ReadBuffer, ) -> Result { - #[cfg(target_os = "linux")] - let direct = crate::fs::linux::read_at(&self.file, buffer, position); - #[cfg(windows)] - let direct = crate::fs::windows::read_at(&self.file, buffer, position); + let direct = direct::read_at(&self.file, buffer, position); // A short or interrupted direct read continues through the staging buffer, so that // only the end of the memory stops a read short. let mut read = match direct { @@ -132,7 +98,7 @@ impl HostFileMemory { } /// Returns the host file's size. - fn host_len(&self) -> Result { + pub(super) fn host_len(&self) -> Result { Ok(self .file .metadata() @@ -140,95 +106,18 @@ impl HostFileMemory { .len()) } - /// Returns the file's size. Callers hold `io`. - #[cfg_attr( - windows, - expect( - clippy::unnecessary_wraps, - reason = "reading the size fails only on Linux" - ) - )] - fn len(&self) -> Result { - #[cfg(windows)] - return Ok(self.len.load(Ordering::Relaxed)); - #[cfg(target_os = "linux")] - self.host_len() - } - - /// Zeroes the host file's bytes from `start` to `end`. Callers hold `io` alone. - #[cfg(windows)] - fn zero(&self, start: u64, end: u64) -> Result<(), SharedFileMemoryError> { - if start >= end { - return Ok(()); - } - let zeros = bounce_buffer( - usize::try_from(end - start).map_or(STAGING_SIZE, |length| length.min(STAGING_SIZE)), - ) - .map_err(|_| SharedFileMemoryError::NoMemory)?; - let mut position = start; - while position < end { - let length = usize::try_from(end - position) - .map_or(zeros.len(), |remaining| remaining.min(zeros.len())); - self.write_all(&zeros[..length], position, &mut 0) - .map_err(|error| memory_error(&error))?; - position += length as u64; - } - Ok(()) - } - - /// Resizes the file to `length` bytes on Windows. Callers hold `io` alone. - /// - /// Bytes between the old and the new size that the host file already holds are zeroed, - /// since runners may have written there through mappings past the end of the file, and - /// a shrink the host refuses while the file is mapped leaves them in the host file. - #[cfg(windows)] - fn resize(&self, length: u64) -> Result<(), SharedFileMemoryError> { - let old_len = self.len()?; - let old_host_len = self.host_len()?; - let host_len = if length == old_host_len { - length - } else { - match self.file.set_len(length) { - Ok(()) => length, - Err(error) - if length < old_host_len - && memory_error(&error) == SharedFileMemoryError::Busy => - { - old_host_len - } - Err(error) => return Err(memory_error(&error)), - } - }; - // Bytes the host file had before this resize and still has may be stale. - let stale_end = old_host_len.min(host_len); - let (start, end) = if length > old_len { - (old_len, length) - } else { - (length, old_len) - }; - self.zero(start, end.min(stale_end))?; - self.len.store(length, Ordering::Relaxed); - Ok(()) - } - fn read_once(&self, buffer: &mut [u8], offset: u64) -> std::io::Result { loop { match read_slice_at(&self.file, buffer, offset) { Err(error) if error.kind() == ErrorKind::Interrupted => {} - #[cfg(windows)] - Err(error) - if error.raw_os_error() - == Some(windows_sys::Win32::Foundation::ERROR_HANDLE_EOF.cast_signed()) => - { - return Ok(0); - } + Err(error) if platform::read_reached_end(&error) => return Ok(0), read => return read, } } } /// Writes all of `data` at `offset`, adding the bytes written to `written` as it goes. - fn write_all( + pub(super) fn write_all( &self, mut data: &[u8], mut offset: u64, @@ -266,19 +155,10 @@ impl HostFileMemory { let length = usize::try_from(limit - position).map_or(data.len(), |room| room.min(data.len())); let _io = self.io.write().unwrap_or_else(PoisonError::into_inner); - // The host file may hold stale bytes between the end of the file and the write. - #[cfg(windows)] - { - let len = self.len()?; - if position > len { - self.zero(len, position.min(self.host_len()?))?; - } - } + self.before_write(position)?; let written = self.write_locked(position, data, length)?; - #[cfg(windows)] if written > 0 { - self.len - .fetch_max(position + written as u64, Ordering::Relaxed); + self.after_write(position + written as u64); } Ok(written) } @@ -293,11 +173,7 @@ impl HostFileMemory { ) -> Result { // Only a write that fits whole may transfer directly, since it cannot stop early. let direct = if length == data.len() { - #[cfg(target_os = "linux")] - let direct = crate::fs::linux::write_at(&self.file, data, position); - #[cfg(windows)] - let direct = crate::fs::windows::write_at(&self.file, data, position); - direct + direct::write_at(&self.file, data, position) } else { None }; @@ -341,59 +217,16 @@ impl HostFileMemory { .filter(|length| *length <= limit) .ok_or(SharedFileMemoryError::NoMemory)?; let _io = self.io.write().unwrap_or_else(PoisonError::into_inner); - #[cfg(target_os = "linux")] - return self - .file - .set_len(length) - .map_err(|error| memory_error(&error)); - #[cfg(windows)] self.resize(length) } } +/// Converts a host error into the error it means for shared file memory. pub(super) fn memory_error(error: &IoError) -> SharedFileMemoryError { - let code = error.raw_os_error(); - #[cfg(target_os = "linux")] - { - if matches!( - code, - Some(libc::ENOSPC | libc::ENOMEM | libc::EFBIG | libc::EMFILE | libc::ENFILE) - ) { - return SharedFileMemoryError::NoMemory; - } - if code == Some(libc::EFAULT) { - return SharedFileMemoryError::Fault; - } - } - #[cfg(windows)] - { - use windows_sys::Win32::Foundation::{ - ERROR_COMMITMENT_LIMIT, ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, ERROR_NOACCESS, - ERROR_NOT_ENOUGH_MEMORY, ERROR_NOT_ENOUGH_QUOTA, ERROR_OUTOFMEMORY, - ERROR_USER_MAPPED_FILE, - }; - let code = code.and_then(|code| u32::try_from(code).ok()); - if code == Some(ERROR_USER_MAPPED_FILE) { - return SharedFileMemoryError::Busy; - } - if matches!( - code, - Some( - ERROR_DISK_FULL - | ERROR_HANDLE_DISK_FULL - | ERROR_NOT_ENOUGH_MEMORY - | ERROR_OUTOFMEMORY - | ERROR_COMMITMENT_LIMIT - | ERROR_NOT_ENOUGH_QUOTA - ) - ) { - return SharedFileMemoryError::NoMemory; - } - if code == Some(ERROR_NOACCESS) { - return SharedFileMemoryError::Fault; - } - } - SharedFileMemoryError::Io + error + .raw_os_error() + .and_then(platform::memory_error_for) + .unwrap_or(SharedFileMemoryError::Io) } impl SharedFileMemory for HostFileMemory { @@ -407,10 +240,9 @@ impl SharedFileMemory for HostFileMemory { return Ok(0); }; let _io = self.io.read().unwrap_or_else(PoisonError::into_inner); - // The host file may hold stale bytes past the end of the file. - #[cfg(windows)] - { - let available = self.len()?.saturating_sub(position); + // The host file may hold bytes past the end of the file. + if let Some(len) = self.read_limit() { + let available = len.saturating_sub(position); if available < buffer.len() as u64 { // `available` is less than the buffer's length, so it fits. let length = usize::try_from(available).unwrap_or(0); @@ -428,7 +260,7 @@ impl SharedFileMemory for HostFileMemory { offset: usize, data: &dyn WriteBuffer, ) -> Result { - self.write_limited(offset, data, size_limit()) + self.write_limited(offset, data, platform::size_limit()) } fn size(&self) -> Result { @@ -437,67 +269,30 @@ impl SharedFileMemory for HostFileMemory { } fn set_len(&self, length: usize) -> Result<(), SharedFileMemoryError> { - self.set_len_limited(length, size_limit()) + self.set_len_limited(length, platform::size_limit()) } } /// A host handle exported from shared file memory, either lent by the memory, as a /// borrowed handle, or made for the export, as an owned one that closes when dropped. -struct Exported(H); - -#[cfg(target_os = "linux")] -impl HostHandle for Exported { - fn raw(&self) -> u64 { - use std::os::fd::AsRawFd as _; - - // Open descriptors are never negative. - u64::from(self.0.as_fd().as_raw_fd().cast_unsigned()) - } -} - -#[cfg(windows)] -impl HostHandle for Exported { - fn raw(&self) -> u64 { - use std::os::windows::io::AsRawHandle as _; - - self.0.as_handle().as_raw_handle() as usize as u64 - } -} +/// +/// Each host's module implements [`HostHandle`] for it. +pub(super) struct Exported(pub(super) H); impl SharedFileObject for HostFileMemory { fn host_handle( &self, writable: bool, ) -> Result, SharedFileMemoryError> { - #[cfg(target_os = "linux")] - { - use std::os::fd::AsFd as _; - - if writable { - return Ok(Box::new(Exported(self.file.as_fd()))); - } - // The host checks a descriptor's access mode when mapping it, so readers get a - // read-only open, made for this export alone so that no extra descriptor stays - // open between deliveries. - Ok(Box::new(Exported(self.open_read_only()?))) - } - // The handle never leaves the broker: the deployment derives a section from it, and - // the section's protection and the rights of the section handle sent to a runner - // enforce `writable`, so readers and writers share the storage's own handle. - #[cfg(windows)] - { - use std::os::windows::io::AsHandle as _; - - let _ = writable; - Ok(Box::new(Exported(self.file.as_handle()))) - } + self.export(writable) } } + pub(super) fn create( file: File, contents: &[u8], ) -> Result, SharedFileMemoryError> { - if u64::try_from(contents.len()).map_or(true, |length| length > size_limit()) { + if u64::try_from(contents.len()).map_or(true, |length| length > platform::size_limit()) { return Err(SharedFileMemoryError::NoMemory); } let memory = HostFileMemory::new(file); @@ -505,11 +300,9 @@ pub(super) fn create( memory .write_all(contents, 0, &mut 0) .map_err(|error| memory_error(&error))?; - #[cfg(windows)] - memory.len.store(contents.len() as u64, Ordering::Relaxed); + memory.after_write(contents.len() as u64); Ok(std::sync::Arc::new(memory)) } - #[cfg(test)] pub(super) mod tests { use super::*; @@ -557,43 +350,6 @@ pub(super) mod tests { } } - #[test] - fn host_errors_map_to_memory_errors() { - #[cfg(target_os = "linux")] - let cases = [ - (libc::ENOSPC, SharedFileMemoryError::NoMemory), - (libc::ENOMEM, SharedFileMemoryError::NoMemory), - (libc::EFBIG, SharedFileMemoryError::NoMemory), - (libc::EMFILE, SharedFileMemoryError::NoMemory), - (libc::ENFILE, SharedFileMemoryError::NoMemory), - (libc::EFAULT, SharedFileMemoryError::Fault), - (libc::EIO, SharedFileMemoryError::Io), - ]; - #[cfg(windows)] - let cases = { - use windows_sys::Win32::Foundation::{ - ERROR_COMMITMENT_LIMIT, ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, ERROR_IO_DEVICE, - ERROR_NOACCESS, ERROR_NOT_ENOUGH_MEMORY, ERROR_NOT_ENOUGH_QUOTA, ERROR_OUTOFMEMORY, - ERROR_USER_MAPPED_FILE, - }; - [ - (ERROR_USER_MAPPED_FILE, SharedFileMemoryError::Busy), - (ERROR_DISK_FULL, SharedFileMemoryError::NoMemory), - (ERROR_HANDLE_DISK_FULL, SharedFileMemoryError::NoMemory), - (ERROR_NOT_ENOUGH_MEMORY, SharedFileMemoryError::NoMemory), - (ERROR_OUTOFMEMORY, SharedFileMemoryError::NoMemory), - (ERROR_COMMITMENT_LIMIT, SharedFileMemoryError::NoMemory), - (ERROR_NOT_ENOUGH_QUOTA, SharedFileMemoryError::NoMemory), - (ERROR_NOACCESS, SharedFileMemoryError::Fault), - (ERROR_IO_DEVICE, SharedFileMemoryError::Io), - ] - .map(|(code, error)| (code.cast_signed(), error)) - }; - for (code, expected) in cases { - assert_eq!(memory_error(&IoError::from_raw_os_error(code)), expected); - } - } - /// Checks that memory in `file` never grows past the host's size limit, here a small /// one, nor to positions the host cannot address. pub(in crate::shared_objects) fn check_size_limit(file: File) { diff --git a/litebox_broker_userland/src/shared_objects/linux.rs b/litebox_broker_userland/src/shared_objects/linux.rs index 0914bd661..dc84d53e7 100644 --- a/litebox_broker_userland/src/shared_objects/linux.rs +++ b/litebox_broker_userland/src/shared_objects/linux.rs @@ -8,11 +8,111 @@ use std::os::unix::fs::PermissionsExt as _; use std::sync::Arc; use litebox_broker_core::fs::shared_memory::{ - SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, + HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, }; -use super::host_file::{create, memory_error}; +use super::host_file::{Exported, HostFileMemory, create, memory_error}; +/// Linux keeps no state of its own: a memfd's size is the file's size. +#[derive(Default)] +pub(super) struct State {} + +impl HostFileMemory { + /// Returns the file's size. Callers hold `io`. + pub(super) fn len(&self) -> Result { + self.host_len() + } + + /// Returns the size past which reads stop short of the host file's end, if any. + #[expect(clippy::unused_self, reason = "Windows keeps a size of its own")] + pub(super) fn read_limit(&self) -> Option { + None + } + + /// Resizes the file to `length` bytes. Callers hold `io` alone. + pub(super) fn resize(&self, length: u64) -> Result<(), SharedFileMemoryError> { + self.file + .set_len(length) + .map_err(|error| memory_error(&error)) + } + + /// Prepares for a write at `position`. Callers hold `io` alone. + #[expect( + clippy::unnecessary_wraps, + clippy::unused_self, + reason = "Windows clears stale bytes before writes" + )] + pub(super) fn before_write(&self, _position: u64) -> Result<(), SharedFileMemoryError> { + Ok(()) + } + + /// Records a write that ended at `end`. Callers hold `io` alone. + #[expect(clippy::unused_self, reason = "Windows keeps a size of its own")] + pub(super) fn after_write(&self, _end: u64) {} + + /// Returns a handle for exporting the memfd, writable only if `writable` is set. + pub(super) fn export( + &self, + writable: bool, + ) -> Result, SharedFileMemoryError> { + use std::os::fd::{AsFd as _, AsRawFd as _}; + + if writable { + return Ok(Box::new(Exported(self.file.as_fd()))); + } + // The host checks a descriptor's access mode when mapping it, so readers get a + // read-only open, made for this export alone so that no extra descriptor stays open + // between deliveries. Duplication preserves the access mode, so reopen. + let reader = File::open(format!("/proc/self/fd/{}", self.file.as_raw_fd())) + .map_err(|_| SharedFileMemoryError::Unsupported)?; + Ok(Box::new(Exported(reader))) + } +} + +impl HostHandle for Exported { + fn raw(&self) -> u64 { + use std::os::fd::AsRawFd as _; + + // Open descriptors are never negative. + u64::from(self.0.as_fd().as_raw_fd().cast_unsigned()) + } +} + +/// Returns the most bytes the host lets the broker's files hold. +/// +/// Growing a file past `RLIMIT_FSIZE` would raise `SIGXFSZ`, which ends the broker by default, +/// so the memory never tries to. +pub(super) fn size_limit() -> u64 { + let limit = i64::MAX.cast_unsigned(); + let mut rlimit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: `rlimit` is valid for the call to fill. + if unsafe { libc::getrlimit(libc::RLIMIT_FSIZE, &raw mut rlimit) } == 0 + && rlimit.rlim_cur != libc::RLIM_INFINITY + { + return limit.min(rlimit.rlim_cur); + } + limit +} + +/// Returns what the host error `code` means for shared file memory, if more than an I/O error. +pub(super) fn memory_error_for(code: i32) -> Option { + match code { + libc::ENOSPC | libc::ENOMEM | libc::EFBIG | libc::EMFILE | libc::ENFILE => { + Some(SharedFileMemoryError::NoMemory) + } + libc::EFAULT => Some(SharedFileMemoryError::Fault), + _ => None, + } +} + +/// Returns whether a positional read failed only because it started past the end of the file, +/// which Linux reports as reading nothing instead. +pub(super) fn read_reached_end(_error: &IoError) -> bool { + false +} /// Creates memory for files in memfds, accessed only through positional I/O by the broker. pub struct MemfdFileMemoryProvider; @@ -69,6 +169,20 @@ impl SharedFileMemoryProvider for MemfdFileMemoryProvider { mod tests { use super::*; + #[test] + fn host_errors_map_to_memory_errors() { + for (code, expected) in [ + (libc::ENOSPC, SharedFileMemoryError::NoMemory), + (libc::ENOMEM, SharedFileMemoryError::NoMemory), + (libc::EFBIG, SharedFileMemoryError::NoMemory), + (libc::EMFILE, SharedFileMemoryError::NoMemory), + (libc::ENFILE, SharedFileMemoryError::NoMemory), + (libc::EFAULT, SharedFileMemoryError::Fault), + (libc::EIO, SharedFileMemoryError::Io), + ] { + assert_eq!(memory_error(&IoError::from_raw_os_error(code)), expected); + } + } #[test] fn memfd_memory_reads_writes_and_resizes() { super::super::host_file::tests::check_reads_writes_and_resizes(&MemfdFileMemoryProvider); diff --git a/litebox_broker_userland/src/shared_objects/windows.rs b/litebox_broker_userland/src/shared_objects/windows.rs index b6a4e6204..17512c541 100644 --- a/litebox_broker_userland/src/shared_objects/windows.rs +++ b/litebox_broker_userland/src/shared_objects/windows.rs @@ -1,20 +1,179 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -use std::io::ErrorKind; +use std::io::{Error as IoError, ErrorKind}; use std::os::windows::fs::OpenOptionsExt as _; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use litebox_broker_core::fs::shared_memory::{ - SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, + HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, }; +use litebox_broker_core::transfer::bounce_buffer; use windows_sys::Win32::Foundation::{GENERIC_EXECUTE, GENERIC_READ, GENERIC_WRITE}; use windows_sys::Win32::Storage::FileSystem::{ FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, }; -use super::host_file::{create, memory_error}; +use super::host_file::{Exported, HostFileMemory, STAGING_SIZE, create, memory_error}; +/// The file's size, which the broker keeps itself. +/// +/// Windows refuses to shrink a file while any section of it exists, and runners hold sections +/// for as long as they map the file. A shrink it refuses therefore zeroes the bytes past the new +/// end and lowers this size only, leaving the host file longer until a later resize can shrink +/// it. Runners receive no right to resize sections, so only the broker changes the host file's +/// size. +#[derive(Default)] +pub(super) struct State { + len: AtomicU64, +} + +impl HostFileMemory { + /// Returns the file's size. Callers hold `io`. + #[expect( + clippy::unnecessary_wraps, + reason = "reading the size fails only on Linux" + )] + pub(super) fn len(&self) -> Result { + Ok(self.state.len.load(Ordering::Relaxed)) + } + + /// Returns the size past which reads stop short of the host file's end, since the host + /// file may hold stale bytes past the end of the file. + #[expect( + clippy::unnecessary_wraps, + reason = "only Windows keeps a size of its own" + )] + pub(super) fn read_limit(&self) -> Option { + Some(self.state.len.load(Ordering::Relaxed)) + } + + /// Resizes the file to `length` bytes. Callers hold `io` alone. + /// + /// Bytes between the old and the new size that the host file already holds are zeroed, + /// since runners may have written there through mappings past the end of the file, and a + /// shrink the host refuses while the file is mapped leaves them in the host file. + pub(super) fn resize(&self, length: u64) -> Result<(), SharedFileMemoryError> { + let old_len = self.len()?; + let old_host_len = self.host_len()?; + let host_len = if length == old_host_len { + length + } else { + match self.file.set_len(length) { + Ok(()) => length, + Err(error) + if length < old_host_len + && memory_error(&error) == SharedFileMemoryError::Busy => + { + old_host_len + } + Err(error) => return Err(memory_error(&error)), + } + }; + // Bytes the host file had before this resize and still has may be stale. + let stale_end = old_host_len.min(host_len); + let (start, end) = if length > old_len { + (old_len, length) + } else { + (length, old_len) + }; + self.zero(start, end.min(stale_end))?; + self.state.len.store(length, Ordering::Relaxed); + Ok(()) + } + + /// Zeroes stale bytes the host file may hold between the end of the file and a write at + /// `position`. Callers hold `io` alone. + pub(super) fn before_write(&self, position: u64) -> Result<(), SharedFileMemoryError> { + let len = self.len()?; + if position > len { + self.zero(len, position.min(self.host_len()?))?; + } + Ok(()) + } + + /// Records a write that ended at `end`. Callers hold `io` alone. + pub(super) fn after_write(&self, end: u64) { + self.state.len.fetch_max(end, Ordering::Relaxed); + } + + /// Returns the storage's own handle for exporting it. + /// + /// The handle never leaves the broker: the deployment derives a section from it, and the + /// section's protection and the rights of the section handle sent to a runner enforce + /// `writable`, so readers and writers share the storage's own handle. + #[expect( + clippy::unnecessary_wraps, + reason = "exporting a reader fails only on Linux" + )] + pub(super) fn export( + &self, + _writable: bool, + ) -> Result, SharedFileMemoryError> { + use std::os::windows::io::AsHandle as _; + + Ok(Box::new(Exported(self.file.as_handle()))) + } + + /// Zeroes the host file's bytes from `start` to `end`. Callers hold `io` alone. + fn zero(&self, start: u64, end: u64) -> Result<(), SharedFileMemoryError> { + if start >= end { + return Ok(()); + } + let zeros = bounce_buffer( + usize::try_from(end - start).map_or(STAGING_SIZE, |length| length.min(STAGING_SIZE)), + ) + .map_err(|_| SharedFileMemoryError::NoMemory)?; + let mut position = start; + while position < end { + let length = usize::try_from(end - position) + .map_or(zeros.len(), |remaining| remaining.min(zeros.len())); + self.write_all(&zeros[..length], position, &mut 0) + .map_err(|error| memory_error(&error))?; + position += length as u64; + } + Ok(()) + } +} + +impl HostHandle for Exported { + fn raw(&self) -> u64 { + use std::os::windows::io::AsRawHandle as _; + + self.0.as_handle().as_raw_handle() as usize as u64 + } +} + +/// Returns the most bytes the host lets the broker's files hold: as many as positions address. +pub(super) fn size_limit() -> u64 { + i64::MAX.cast_unsigned() +} + +/// Returns what the host error `code` means for shared file memory, if more than an I/O error. +pub(super) fn memory_error_for(code: i32) -> Option { + use windows_sys::Win32::Foundation::{ + ERROR_COMMITMENT_LIMIT, ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, ERROR_NOACCESS, + ERROR_NOT_ENOUGH_MEMORY, ERROR_NOT_ENOUGH_QUOTA, ERROR_OUTOFMEMORY, ERROR_USER_MAPPED_FILE, + }; + + match u32::try_from(code).ok()? { + ERROR_USER_MAPPED_FILE => Some(SharedFileMemoryError::Busy), + ERROR_DISK_FULL + | ERROR_HANDLE_DISK_FULL + | ERROR_NOT_ENOUGH_MEMORY + | ERROR_OUTOFMEMORY + | ERROR_COMMITMENT_LIMIT + | ERROR_NOT_ENOUGH_QUOTA => Some(SharedFileMemoryError::NoMemory), + ERROR_NOACCESS => Some(SharedFileMemoryError::Fault), + _ => None, + } +} + +/// Returns whether a positional read failed only because it started past the end of the file. +pub(super) fn read_reached_end(error: &IoError) -> bool { + error.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_HANDLE_EOF.cast_signed()) +} /// Creates memory for files in the host's temporary directory. pub struct TempFileMemoryProvider; @@ -55,6 +214,31 @@ impl SharedFileMemoryProvider for TempFileMemoryProvider { mod tests { use super::*; + #[test] + fn host_errors_map_to_memory_errors() { + use windows_sys::Win32::Foundation::{ + ERROR_COMMITMENT_LIMIT, ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, ERROR_IO_DEVICE, + ERROR_NOACCESS, ERROR_NOT_ENOUGH_MEMORY, ERROR_NOT_ENOUGH_QUOTA, ERROR_OUTOFMEMORY, + ERROR_USER_MAPPED_FILE, + }; + + for (code, expected) in [ + (ERROR_USER_MAPPED_FILE, SharedFileMemoryError::Busy), + (ERROR_DISK_FULL, SharedFileMemoryError::NoMemory), + (ERROR_HANDLE_DISK_FULL, SharedFileMemoryError::NoMemory), + (ERROR_NOT_ENOUGH_MEMORY, SharedFileMemoryError::NoMemory), + (ERROR_OUTOFMEMORY, SharedFileMemoryError::NoMemory), + (ERROR_COMMITMENT_LIMIT, SharedFileMemoryError::NoMemory), + (ERROR_NOT_ENOUGH_QUOTA, SharedFileMemoryError::NoMemory), + (ERROR_NOACCESS, SharedFileMemoryError::Fault), + (ERROR_IO_DEVICE, SharedFileMemoryError::Io), + ] { + assert_eq!( + memory_error(&IoError::from_raw_os_error(code.cast_signed())), + expected + ); + } + } #[test] fn temp_file_memory_reads_writes_and_resizes() { super::super::host_file::tests::check_reads_writes_and_resizes(&TempFileMemoryProvider); From 864de6661fb625d9bb05ac27dacdc4e6f11e3799 Mon Sep 17 00:00:00 2001 From: Weiteng Chen Date: Tue, 3 Nov 2026 11:05:20 -0800 Subject: [PATCH 11/12] Drop trivial and overlapping broker file storage tests Remove tests that restate error-mapping match tables, drop mapped-file tests covered elsewhere, and fold the append/truncate-on-open checks into the coherence test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- litebox/src/fs/file.rs | 29 ------- litebox_broker_core/src/fs/service.rs | 14 ---- litebox_broker_core/src/fs/tests.rs | 83 ++++--------------- .../src/shared_objects/linux.rs | 14 ---- .../src/shared_objects/windows.rs | 25 ------ litebox_common_linux/src/errno/mod.rs | 23 ----- litebox_shim_windows/src/syscalls/file.rs | 21 ----- 7 files changed, 18 insertions(+), 191 deletions(-) diff --git a/litebox/src/fs/file.rs b/litebox/src/fs/file.rs index edd086d36..5393a02eb 100644 --- a/litebox/src/fs/file.rs +++ b/litebox/src/fs/file.rs @@ -943,32 +943,3 @@ impl From for WalkError { path_error(error).map_or(Self::Io, Into::into) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn broker_file_size_errors_keep_their_meaning() { - assert!(matches!( - WriteError::from(FileError::NoSpace), - WriteError::NoSpace - )); - assert!(matches!( - TruncateError::from(FileError::Busy), - TruncateError::Busy - )); - assert!(matches!( - TruncateError::from(FileError::NoSpace), - TruncateError::NoSpace - )); - assert!(matches!( - OpenError::from(FileError::Busy), - OpenError::TruncateError(TruncateError::Busy) - )); - assert!(matches!( - OpenError::from(FileError::NoSpace), - OpenError::NoSpace - )); - } -} diff --git a/litebox_broker_core/src/fs/service.rs b/litebox_broker_core/src/fs/service.rs index 408f3df13..22b629321 100644 --- a/litebox_broker_core/src/fs/service.rs +++ b/litebox_broker_core/src/fs/service.rs @@ -1167,18 +1167,4 @@ mod tests { assert_eq!(status, expected); } - - #[test] - fn file_size_errors_keep_their_meaning() { - assert_eq!(file_write_error(WriteError::NoSpace), FileError::NoSpace); - for (error, expected) in [ - (|| TruncateError::Busy, FileError::Busy), - (|| TruncateError::NoSpace, FileError::NoSpace), - ] as [(fn() -> TruncateError, FileError); 2] - { - assert_eq!(file_truncate_error(error()), expected); - // `open(O_TRUNC)` reports truncation failures the same way. - assert_eq!(file_open_error(OpenError::TruncateError(error())), expected); - } - } } diff --git a/litebox_broker_core/src/fs/tests.rs b/litebox_broker_core/src/fs/tests.rs index fb4517ca2..b61687757 100644 --- a/litebox_broker_core/src/fs/tests.rs +++ b/litebox_broker_core/src/fs/tests.rs @@ -2655,6 +2655,19 @@ mod shared_mapping { assert_eq!(contents(&*memory), b"Je"); assert_eq!(fs.handle_status(&fd).unwrap().size, 2); + let mut append = fs + .open( + ROOT, + "/file", + OFlags::WRONLY | OFlags::APPEND, + Mode::empty(), + ) + .unwrap(); + // The memory grows behind the backend's back, and appending still lands at its end. + memory.set_len(3).unwrap(); + assert_eq!(fs.write(&mut append, b"!!", None).unwrap(), 2); + assert_eq!(contents(&*memory), b"Je\0!!"); + let fd = fs .open(ROOT, "/file", OFlags::RDONLY, Mode::empty()) .unwrap(); @@ -2664,29 +2677,13 @@ mod shared_mapping { } = fs.map_shared(&fd).unwrap(); assert!(!writable); assert!(Arc::ptr_eq(&again, &mapping)); - } - #[test] - fn shrinking_mapped_memory_that_refuses_reports_busy() { - let (fs, provider) = shared_in_mem(true); - let fs = Resolver::::new(fs); - let mut fd = fs - .open(ROOT, "/file", OFlags::CREAT | OFlags::RDWR, Mode::RWXU) + let fd = fs + .open(ROOT, "/file", OFlags::RDWR | OFlags::TRUNC, Mode::empty()) .unwrap(); - fs.write(&mut fd, b"hello", None).unwrap(); - fs.map_shared(&fd).unwrap(); - - assert!(matches!( - fs.truncate(&mut fd, 1, false), - Err(TruncateError::Busy) - )); - assert!(matches!( - fs.open(ROOT, "/file", OFlags::RDWR | OFlags::TRUNC, Mode::empty()), - Err(OpenError::TruncateError(TruncateError::Busy)) - )); - assert_eq!(contents(&*provider.last()), b"hello"); - fs.truncate(&mut fd, 8, false).unwrap(); - assert_eq!(fs.handle_status(&fd).unwrap().size, 8); + assert_eq!(contents(&*memory), b""); + assert_eq!(fs.handle_status(&fd).unwrap().size, 0); + assert!(Arc::ptr_eq(&fs.map_shared(&fd).unwrap().file, &mapping)); } #[test] @@ -2754,18 +2751,6 @@ mod shared_mapping { assert_eq!(contents(&*provider.last()), b"hellabcd"); } - #[test] - fn empty_writes_past_the_end_leave_unmapped_files_alone() { - let fs = super::in_mem_fs(); - let mut fd = fs - .open(ROOT, "/file", OFlags::CREAT | OFlags::RDWR, Mode::RWXU) - .unwrap(); - fs.write(&mut fd, b"hello", None).unwrap(); - // Like `pwrite`, writing nothing past the end leaves the size alone. - assert_eq!(fs.write(&mut fd, b"", Some(9)).unwrap(), 0); - assert_eq!(fs.handle_status(&fd).unwrap().size, 5); - } - #[test] fn mapping_requires_reading_and_support() { let fs = Resolver::::new(shared_in_mem(false).0); @@ -2953,38 +2938,6 @@ mod shared_mapping { )); } - #[test] - fn mapped_files_append_and_truncate_on_open() { - let (fs, provider) = shared_in_mem(false); - let fs = Resolver::::new(fs); - let mut fd = fs - .open(ROOT, "/file", OFlags::CREAT | OFlags::RDWR, Mode::RWXU) - .unwrap(); - fs.write(&mut fd, b"hello", None).unwrap(); - let mapping = fs.map_shared(&fd).unwrap().file; - let memory = provider.last(); - - let mut append = fs - .open( - ROOT, - "/file", - OFlags::WRONLY | OFlags::APPEND, - Mode::empty(), - ) - .unwrap(); - // The memory grows behind the backend's back, and appending still lands at its end. - memory.set_len(6).unwrap(); - assert_eq!(fs.write(&mut append, b"!!", None).unwrap(), 2); - assert_eq!(contents(&*memory), b"hello\0!!"); - - let fd = fs - .open(ROOT, "/file", OFlags::RDWR | OFlags::TRUNC, Mode::empty()) - .unwrap(); - assert_eq!(contents(&*memory), b""); - assert_eq!(fs.handle_status(&fd).unwrap().size, 0); - assert!(Arc::ptr_eq(&fs.map_shared(&fd).unwrap().file, &mapping)); - } - #[test] fn composer_shares_files_through_the_mount_holding_them() { use crate::fs::composer::Composer; diff --git a/litebox_broker_userland/src/shared_objects/linux.rs b/litebox_broker_userland/src/shared_objects/linux.rs index dc84d53e7..763801979 100644 --- a/litebox_broker_userland/src/shared_objects/linux.rs +++ b/litebox_broker_userland/src/shared_objects/linux.rs @@ -169,20 +169,6 @@ impl SharedFileMemoryProvider for MemfdFileMemoryProvider { mod tests { use super::*; - #[test] - fn host_errors_map_to_memory_errors() { - for (code, expected) in [ - (libc::ENOSPC, SharedFileMemoryError::NoMemory), - (libc::ENOMEM, SharedFileMemoryError::NoMemory), - (libc::EFBIG, SharedFileMemoryError::NoMemory), - (libc::EMFILE, SharedFileMemoryError::NoMemory), - (libc::ENFILE, SharedFileMemoryError::NoMemory), - (libc::EFAULT, SharedFileMemoryError::Fault), - (libc::EIO, SharedFileMemoryError::Io), - ] { - assert_eq!(memory_error(&IoError::from_raw_os_error(code)), expected); - } - } #[test] fn memfd_memory_reads_writes_and_resizes() { super::super::host_file::tests::check_reads_writes_and_resizes(&MemfdFileMemoryProvider); diff --git a/litebox_broker_userland/src/shared_objects/windows.rs b/litebox_broker_userland/src/shared_objects/windows.rs index 17512c541..ca936eb6d 100644 --- a/litebox_broker_userland/src/shared_objects/windows.rs +++ b/litebox_broker_userland/src/shared_objects/windows.rs @@ -214,31 +214,6 @@ impl SharedFileMemoryProvider for TempFileMemoryProvider { mod tests { use super::*; - #[test] - fn host_errors_map_to_memory_errors() { - use windows_sys::Win32::Foundation::{ - ERROR_COMMITMENT_LIMIT, ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL, ERROR_IO_DEVICE, - ERROR_NOACCESS, ERROR_NOT_ENOUGH_MEMORY, ERROR_NOT_ENOUGH_QUOTA, ERROR_OUTOFMEMORY, - ERROR_USER_MAPPED_FILE, - }; - - for (code, expected) in [ - (ERROR_USER_MAPPED_FILE, SharedFileMemoryError::Busy), - (ERROR_DISK_FULL, SharedFileMemoryError::NoMemory), - (ERROR_HANDLE_DISK_FULL, SharedFileMemoryError::NoMemory), - (ERROR_NOT_ENOUGH_MEMORY, SharedFileMemoryError::NoMemory), - (ERROR_OUTOFMEMORY, SharedFileMemoryError::NoMemory), - (ERROR_COMMITMENT_LIMIT, SharedFileMemoryError::NoMemory), - (ERROR_NOT_ENOUGH_QUOTA, SharedFileMemoryError::NoMemory), - (ERROR_NOACCESS, SharedFileMemoryError::Fault), - (ERROR_IO_DEVICE, SharedFileMemoryError::Io), - ] { - assert_eq!( - memory_error(&IoError::from_raw_os_error(code.cast_signed())), - expected - ); - } - } #[test] fn temp_file_memory_reads_writes_and_resizes() { super::super::host_file::tests::check_reads_writes_and_resizes(&TempFileMemoryProvider); diff --git a/litebox_common_linux/src/errno/mod.rs b/litebox_common_linux/src/errno/mod.rs index 777d4063f..244928bad 100644 --- a/litebox_common_linux/src/errno/mod.rs +++ b/litebox_common_linux/src/errno/mod.rs @@ -978,27 +978,4 @@ mod tests { assert_eq!(Errno::from(error), expected); } } - - #[test] - fn file_size_errors_map_to_linux_errno() { - use litebox::fs::errors::{OpenError, TruncateError, WriteError}; - - let cases: [(fn() -> TruncateError, Errno); 8] = [ - (|| TruncateError::IsDirectory, Errno::EISDIR), - (|| TruncateError::NotForWriting, Errno::EACCES), - (|| TruncateError::NotOpenForWriting, Errno::EBADF), - (|| TruncateError::IsTerminalDevice, Errno::EINVAL), - (|| TruncateError::ClosedFd, Errno::EBADF), - (|| TruncateError::Busy, Errno::ETXTBSY), - (|| TruncateError::NoSpace, Errno::ENOSPC), - (|| TruncateError::Io, Errno::EIO), - ]; - - for (error, expected) in cases { - assert_eq!(Errno::from(error()), expected); - // `open(O_TRUNC)` reports truncation failures the same way. - assert_eq!(Errno::from(OpenError::TruncateError(error())), expected); - } - assert_eq!(Errno::from(WriteError::NoSpace), Errno::ENOSPC); - } } diff --git a/litebox_shim_windows/src/syscalls/file.rs b/litebox_shim_windows/src/syscalls/file.rs index d0e4883b1..3eb6f7132 100644 --- a/litebox_shim_windows/src/syscalls/file.rs +++ b/litebox_shim_windows/src/syscalls/file.rs @@ -2837,27 +2837,6 @@ mod tests { const FILE_CREATE: u32 = 2; const FILE_OVERWRITE: u32 = 4; - #[test] - fn open_size_errors_map_to_nt_status() { - let cases = [ - ( - OpenError::TruncateError(TruncateError::Busy), - NtStatus::USER_MAPPED_FILE, - ), - ( - OpenError::TruncateError(TruncateError::NoSpace), - NtStatus::DISK_FULL, - ), - (OpenError::NoSpace, NtStatus::DISK_FULL), - ]; - for (error, expected) in cases { - assert_eq!( - map_open_error(error, CreateDisposition::Overwrite), - expected - ); - } - } - fn open_object_attributes( path: &str, ) -> ( From c96b851ae3f90daa8bade55327632da03cbe8c05 Mon Sep 17 00:00:00 2001 From: Weiteng Chen Date: Tue, 3 Nov 2026 14:06:47 -0800 Subject: [PATCH 12/12] Simplify broker file storage after review - Match once on in-memory file contents instead of checking for shared memory and then reaching inline bytes that cannot be shared. - Loop over direct reads and writes, staging a chunk only when the rest of a buffer cannot take a direct transfer; direct read_at and write_at take a buffer offset for that. - Stop zeroing bytes past the end of mapped Windows files on growth; a refused shrink still zeroes the tail it cuts off. - Drop the memfd mode change and the F_SEAL_SEAL seal: runners' seccomp filter allows neither open, fchmod, nor F_ADD_SEALS. - Restore the empty-write check for unmapped files, and drop the static backing data test, which the contents enum already guarantees. - Trim shared file storage docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- litebox_broker_core/src/fs/backend.rs | 16 +- litebox_broker_core/src/fs/in_mem.rs | 91 ++++------- litebox_broker_core/src/fs/shared_memory.rs | 35 +---- litebox_broker_core/src/fs/tests.rs | 37 +---- litebox_broker_core/src/lib.rs | 9 +- litebox_broker_userland/src/fs/linux.rs | 42 ++++-- litebox_broker_userland/src/fs/mod.rs | 18 +-- litebox_broker_userland/src/fs/windows.rs | 42 ++++-- .../src/shared_objects/host_file.rs | 141 +++++++++--------- .../src/shared_objects/linux.rs | 58 +------ .../src/shared_objects/windows.rs | 39 +---- 11 files changed, 197 insertions(+), 331 deletions(-) diff --git a/litebox_broker_core/src/fs/backend.rs b/litebox_broker_core/src/fs/backend.rs index 4c74d9e5b..d201eb29e 100644 --- a/litebox_broker_core/src/fs/backend.rs +++ b/litebox_broker_core/src/fs/backend.rs @@ -146,17 +146,14 @@ pub trait Backend: Send + Sync + Any { None } - /// Returns the host shared object that holds the file's contents, moving them there first if - /// necessary, for a deployment to export its storage without copying it. + /// Returns the host shared object that holds the file's contents. /// /// After this succeeds, the backend must keep the file's contents in that object, so that /// every mapping and every read, write, and truncation see the same bytes, and must return - /// the same object for every later call on the same file. A handle that the backend later - /// redirects to another file, as an overlay does once it copies a lower file up, follows - /// that file instead: later calls on it return that file's object, or fail if it has none. + /// the same object for every later call on the same file. /// /// The default implementation returns [`MapSharedError::Unsupported`]. - #[expect(unused_variables, reason = "default body, non-underscored param names")] + #[expect(unused_variables, reason = "default body with unused parameter")] fn map_shared(&self, h: &FileHandle) -> Result, MapSharedError> { Err(MapSharedError::Unsupported) } @@ -165,10 +162,6 @@ pub trait Backend: Send + Sync + Any { /// of bytes written from its start. /// /// See [`Self::read`] on internal offset storage for backends. - /// - /// Fails with [`WriteError::NoSpace`] if the storage holding the file's contents cannot grow - /// to hold `buf`. Like `pwrite`, a write that stops partway after writing some bytes - /// returns how many it wrote rather than failing. // 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 @@ -185,9 +178,6 @@ pub trait Backend: Send + Sync + Any { /// /// If shorter than existing size, extra data is lost. If longer than existing size, resize by /// adding `\0`s. - /// - /// Fails with [`TruncateError::Busy`] if the host cannot shrink the file's storage while it - /// is mapped, and with [`TruncateError::NoSpace`] if the storage cannot grow to `length`. fn truncate(&self, h: &FileHandle, length: usize) -> Result<(), TruncateError>; /// Describe seek behavior for an open file handle. diff --git a/litebox_broker_core/src/fs/in_mem.rs b/litebox_broker_core/src/fs/in_mem.rs index e90ce6b62..a504bde30 100644 --- a/litebox_broker_core/src/fs/in_mem.rs +++ b/litebox_broker_core/src/fs/in_mem.rs @@ -39,8 +39,8 @@ pub struct InMem { // XXX(jayb): serializes directory moves so ancestry stays stable while rename chooses // ancestor-first locks; should be cleaned up as part of broader atomicity fixups. rename_lock: sync::Mutex, - /// Creates the host shared memory that a file's contents move to when a runner maps it, if - /// this deployment supports shared file mappings, and bounds how many files may hold it. + /// Creates the host shared memory that a file's contents move to when a runner maps it, + /// and bounds how many files may hold it. shared_memory: Option<(Arc, Arc)>, } @@ -70,16 +70,7 @@ impl InMem { } } - /// Lets runners map this backend's regular files `MAP_SHARED`, keeping a mapped file's - /// contents in host shared memory from `provider`. - /// - /// A file keeps that memory for as long as it exists, and at most as many files as `limit` - /// allows may hold some at once; mapping another fails as out of memory. - /// - /// Slots are not reclaimed while a mapped file exists, so a guest that can create and map - /// files can use up `limit`, after which every backend sharing it fails new mappings until - /// such files are deleted. A deployment that must isolate guests from one another gives - /// each its own limit. + /// Register a shared memory provider and limit. #[must_use] pub fn with_shared_memory( mut self, @@ -519,11 +510,14 @@ impl super::backend::Backend for InMe offset: usize, ) -> Result { let file = h.get_typed::().file.read(); - if let Some(memory) = file.shared_memory() { - drop(file); - return Ok(memory.read_at(offset, buf)?); - } - let data = file.inline_bytes(); + let data = match &file.contents { + Contents::Inline(data) => data, + Contents::Shared { memory, .. } => { + let memory = Arc::clone(memory); + drop(file); + return Ok(memory.read_at(offset, buf)?); + } + }; let start = offset.min(data.len()); let end = offset.checked_add(buf.len()).unwrap().min(data.len()); debug_assert!(start <= end); @@ -537,19 +531,20 @@ impl super::backend::Backend for InMe buf: &dyn WriteBuffer, offset: usize, ) -> Result { - // Like `pwrite`, writing nothing leaves the file as it is, even past its end. + // Writing nothing leaves the file as it is, even past its end. if buf.is_empty() { return Ok(0); } let mut file = h.get_typed::().file.write(); - if let Some(memory) = file.shared_memory() { - drop(file); - // Grows the memory itself and, like `pwrite`, reports a write that fails partway as - // a short write, since the memory already holds the bytes before the failure. - return Ok(memory.write_at(offset, buf)?); - } + let data = match &mut file.contents { + Contents::Inline(data) => data.to_mut(), + Contents::Shared { memory, .. } => { + let memory = Arc::clone(memory); + drop(file); + return Ok(memory.write_at(offset, buf)?); + } + }; let end = offset.checked_add(buf.len()).unwrap(); - let data = file.inline_bytes_mut().to_mut(); let previous_len = data.len(); if end > previous_len { // Pads with 0s if the offset is past the end of the file. @@ -564,14 +559,14 @@ impl super::backend::Backend for InMe fn truncate(&self, h: &super::backend::FileHandle, length: usize) -> Result<(), TruncateError> { let mut file = h.get_typed::().file.write(); - if let Some(memory) = file.shared_memory() { - drop(file); - // Shrinking discards the pages past the end, so runners fault on them, as Linux - // raises `SIGBUS` past the end of a mapped file. Hosts that cannot shrink mapped - // memory refuse instead. - return Ok(memory.set_len(length)?); - } - let data = file.inline_bytes_mut(); + let data = match &mut file.contents { + Contents::Inline(data) => data, + Contents::Shared { memory, .. } => { + let memory = Arc::clone(memory); + drop(file); + return Ok(memory.set_len(length)?); + } + }; match length.cmp(&data.len()) { core::cmp::Ordering::Less => match data { alloc::borrow::Cow::Borrowed(d) => *d = &d[..length], @@ -599,9 +594,10 @@ impl super::backend::Backend for InMe node_info: file.node_info, blksize: BLOCK_SIZE, }; - let size = match file.shared_memory() { - None => file.inline_bytes().len(), - Some(memory) => { + let size = match &file.contents { + Contents::Inline(data) => data.len(), + Contents::Shared { memory, .. } => { + let memory = Arc::clone(memory); drop(file); memory.size().map_err(|_| FileStatusError::Io)? } @@ -1037,17 +1033,6 @@ enum Contents { } impl FileData { - /// Returns the host shared memory holding the file's contents, if a runner mapped it. - /// - /// Callers access the memory after releasing the file's lock, since the memory orders the - /// broker's accesses itself (see [`SharedFileMemory`]) and host I/O may be slow. - fn shared_memory(&self) -> Option> { - match &self.contents { - Contents::Inline(_) => None, - Contents::Shared { memory, .. } => Some(Arc::clone(memory)), - } - } - /// Returns the bytes of a file that runners never mapped, such as a symbolic link. /// /// # Panics @@ -1059,18 +1044,6 @@ impl FileData { Contents::Shared { .. } => panic!("only regular files move into shared memory"), } } - - /// Returns the bytes of a file that runners never mapped, to change them. - /// - /// # Panics - /// - /// Panics if the file's contents are in host shared memory. - fn inline_bytes_mut(&mut self) -> &mut alloc::borrow::Cow<'static, [u8]> { - match &mut self.contents { - Contents::Inline(data) => data, - Contents::Shared { .. } => panic!("the file's contents are in shared memory"), - } - } } #[derive(Clone, Debug)] diff --git a/litebox_broker_core/src/fs/shared_memory.rs b/litebox_broker_core/src/fs/shared_memory.rs index 0f9d5d66d..e985cb4f5 100644 --- a/litebox_broker_core/src/fs/shared_memory.rs +++ b/litebox_broker_core/src/fs/shared_memory.rs @@ -5,12 +5,9 @@ //! //! A backend moves a file's contents into [`SharedFileMemory`] the first time //! [`Backend::map_shared`](super::backend::Backend::map_shared) is called. Subsequent reads, -//! writes, truncations, and requests for the object use the same storage. A deployment creates -//! that storage through [`SharedFileMemoryProvider`], for example in a memfd or a temporary -//! file. The broker core accesses it through buffers and never maps it itself. +//! writes, truncations, and requests for the object use the same storage. //! -//! [`SharedFileLimit`] bounds how many files hold host resources at once. The file and any -//! retained [`SharedFile`] keep their slot until the last reference is dropped. +//! [`SharedFileLimit`] bounds how many files hold host resources at once. use alloc::boxed::Box; use alloc::sync::Arc; @@ -87,27 +84,17 @@ impl From for MapSharedError { } /// A host handle to a [`SharedFileObject`], such as a descriptor on Linux or a `HANDLE` on -/// Windows, open for as long as this value lives. -/// -/// An object either lends one of its own handles, or makes one for the export alone, which -/// closes when this value is dropped, so the object keeps no handle open for it in between. +/// Windows, which is closed when this value is dropped. pub trait HostHandle: Send { /// Returns the raw host handle. fn raw(&self) -> u64; } + /// A host object whose pages are one file's contents. /// /// Its size is the file's size. pub trait SharedFileObject: Send + Sync { /// Returns a host handle for exporting the object, writable only if `writable` is set. - /// - /// The handle's own rights enforce `writable` where the host checks them when mapping, as - /// for a descriptor; otherwise the deployment must derive an object with those rights, - /// such as a section. A handle with fewer rights than the object's own may be made for each - /// call, so that the object does not keep one open. - /// - /// Fails with [`SharedFileMemoryError::Unsupported`] if the host cannot provide such a - /// handle. fn host_handle( &self, writable: bool, @@ -115,11 +102,6 @@ pub trait SharedFileObject: Send + Sync { } /// Host memory that holds one file's contents and serves as the backend's storage. -/// -/// Each call is atomic with respect to the other calls on the same memory: a read sees all or -/// none of a concurrent write's bytes, and a resize never lands partway through a write. Backends -/// therefore call it without holding their own locks. Runners' accesses through mappings are -/// not ordered with the broker's. pub trait SharedFileMemory: SharedFileObject { /// Copies bytes from `offset` into `buffer`, returning how many were copied, which is fewer /// than `buffer.len()` only at the end of the memory. @@ -130,9 +112,9 @@ pub trait SharedFileMemory: SharedFileObject { ) -> Result; /// Copies `data` to `offset`, growing the memory with zeros to end there if it is shorter, - /// as `pwrite` does, and returns how many bytes were copied. + /// and returns how many bytes were copied. /// - /// Like `pwrite`, this returns fewer than `data.len()` bytes when an error stops the copy + /// This returns fewer than `data.len()` bytes when an error stops the copy /// after some bytes were written, which may have grown the memory to end after them; it /// fails only if nothing was written, leaving the size unchanged. fn write_at( @@ -148,7 +130,7 @@ pub trait SharedFileMemory: SharedFileObject { fn set_len(&self, length: usize) -> Result<(), SharedFileMemoryError>; } -/// Creates [`SharedFileMemory`] for a deployment. +/// Platform provider that creates [`SharedFileMemory`]. pub trait SharedFileMemoryProvider: Send + Sync { /// Creates memory of `contents.len()` bytes that initially holds `contents`. fn create(&self, contents: &[u8]) -> Result, SharedFileMemoryError>; @@ -158,9 +140,6 @@ pub trait SharedFileMemoryProvider: Send + Sync { pub const DEFAULT_MAX_SHARED_FILES: usize = 256; /// Bounds how many files keep their contents in host shared memory at once. -/// -/// Backends that share one limit share its bound, so a deployment makes it broker-wide by -/// passing every backend the same limit. #[derive(Debug)] pub struct SharedFileLimit { live: AtomicUsize, diff --git a/litebox_broker_core/src/fs/tests.rs b/litebox_broker_core/src/fs/tests.rs index b61687757..37e3f1600 100644 --- a/litebox_broker_core/src/fs/tests.rs +++ b/litebox_broker_core/src/fs/tests.rs @@ -102,6 +102,9 @@ mod in_mem { let data = b"Hello, world!"; fs.write(&mut fd, data, None) .expect("Failed to write to file"); + // Like `pwrite`, writing nothing past the end leaves the size alone. + assert_eq!(fs.write(&mut fd, b"", Some(20)).unwrap(), 0); + assert_eq!(fs.handle_status(&fd).unwrap().size, 13); drop(fd); // Read from the file @@ -2855,40 +2858,6 @@ mod shared_mapping { assert_eq!(read, b"hello"); } - #[test] - fn mapped_files_lose_their_static_backing_data() { - let backend = InMem::::new_initialized([ - ( - "/", - InitialNode::Directory { - mode: Mode::RWXU, - owner: ROOT, - }, - ), - ( - "/file", - InitialNode::File { - mode: Mode::RWXU, - owner: ROOT, - data: Cow::Borrowed(b"static"), - }, - ), - ]) - .with_shared_memory( - Arc::new(VecMemoryProvider::new(false)), - Arc::new(SharedFileLimit::new(DEFAULT_MAX_SHARED_FILES)), - ); - let fs = Resolver::::new(backend); - let mut fd = fs.open(ROOT, "/file", OFlags::RDWR, Mode::empty()).unwrap(); - assert_eq!(fs.get_static_backing_data(&fd), Some(&b"static"[..])); - - fs.map_shared(&fd).unwrap(); - assert_eq!(fs.get_static_backing_data(&fd), None); - let mut read = vec![0; 6]; - assert_eq!(fs.read(&mut fd, &mut read, Some(0)).unwrap(), 6); - assert_eq!(read, b"static"); - } - #[test] fn overlay_shares_only_upper_files() { let provider = Arc::new(VecMemoryProvider::new(false)); diff --git a/litebox_broker_core/src/lib.rs b/litebox_broker_core/src/lib.rs index c4534226c..861b09388 100644 --- a/litebox_broker_core/src/lib.rs +++ b/litebox_broker_core/src/lib.rs @@ -114,13 +114,10 @@ pub struct BrokerCoreLimits { pub max_child_image_size: u64, /// Maximum total size in bytes of child memory images held by the broker. pub max_total_child_image_size: u64, - /// Maximum files whose contents are in host shared memory at once, so that runners can map - /// them `MAP_SHARED`. + /// Maximum files whose contents are in host shared memory at once. /// - /// Each such file holds a broker descriptor or handle until it is deleted, which may be - /// after every process that mapped it exits, so the limit is broker-wide only. Deployments - /// apply it to the file systems they give the broker (see - /// [`fs::in_mem::InMem::with_shared_memory`]); zero keeps every file in broker memory. + /// Each such file holds a broker descriptor or handle until it is deleted, + /// so the limit is broker-wide only. Zero keeps every file in broker memory. pub max_shared_files: usize, } diff --git a/litebox_broker_userland/src/fs/linux.rs b/litebox_broker_userland/src/fs/linux.rs index 64e28e495..c00d1c1f5 100644 --- a/litebox_broker_userland/src/fs/linux.rs +++ b/litebox_broker_userland/src/fs/linux.rs @@ -17,14 +17,24 @@ use std::os::fd::AsRawFd; use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; -/// Reads from `file` at `offset` directly into `buf` with one `pread`. +/// Reads from `file` at `offset` directly into `buf`, from `start` to its end, +/// with one `pread`. /// -/// Returns `None` without reading unless `buf` is one contiguous part of raw -/// bytes. -pub(crate) fn read_at(file: &File, buf: &mut dyn ReadBuffer, offset: u64) -> Option> { +/// Returns `None` without reading unless those bytes of `buf` are one +/// contiguous part of raw bytes. +/// +/// # Panics +/// +/// Panics if `start` exceeds `buf`. +pub(crate) fn read_at( + file: &File, + buf: &mut dyn ReadBuffer, + start: usize, + offset: u64, +) -> Option> { let mut result = None; - let length = buf.len(); - buf.read_direct_at(0, length, &mut |parts| { + let length = buf.len() - start; + buf.read_direct_at(start, length, &mut |parts| { let [part] = parts else { return 0 }; let read = transfer(offset, |offset| { // SAFETY: `part` is raw bytes that are writable for the call and @@ -36,13 +46,23 @@ pub(crate) fn read_at(file: &File, buf: &mut dyn ReadBuffer, offset: u64) -> Opt result } -/// Writes `buf` to `file` at `offset` directly with one `pwrite`. +/// Writes `buf`, from `start` to its end, to `file` at `offset` directly with +/// one `pwrite`. +/// +/// Returns `None` without writing unless those bytes of `buf` are one +/// contiguous part of raw bytes. +/// +/// # Panics /// -/// Returns `None` without writing unless `buf` is one contiguous part of raw -/// bytes. -pub(crate) fn write_at(file: &File, buf: &dyn WriteBuffer, offset: u64) -> Option> { +/// Panics if `start` exceeds `buf`. +pub(crate) fn write_at( + file: &File, + buf: &dyn WriteBuffer, + start: usize, + offset: u64, +) -> Option> { let mut result = None; - buf.write_direct_at(0, buf.len(), &mut |parts| { + buf.write_direct_at(start, buf.len() - start, &mut |parts| { let [part] = parts else { return 0 }; let written = transfer(offset, |offset| { // SAFETY: `part` is raw bytes that are readable for the call and diff --git a/litebox_broker_userland/src/fs/mod.rs b/litebox_broker_userland/src/fs/mod.rs index 19a474e7b..ac5122ea4 100644 --- a/litebox_broker_userland/src/fs/mod.rs +++ b/litebox_broker_userland/src/fs/mod.rs @@ -38,10 +38,7 @@ pub(crate) mod linux; #[cfg(all(windows, target_arch = "x86_64"))] pub(crate) mod windows; -/// Reads from `file` at `offset` into `buf` with one positioned read, as the bounce path does -/// for buffers that cannot transfer directly. -/// -/// Rejects offsets past `i64::MAX`, which Windows would treat as special values. +/// Reads from `file` at `offset` into `buf` with one positioned read. pub(crate) fn read_slice_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result { check_offset(offset)?; #[cfg(unix)] @@ -54,10 +51,7 @@ pub(crate) fn read_slice_at(file: &File, buf: &mut [u8], offset: u64) -> std::io } } -/// Writes `buf` to `file` at `offset` with one positioned write, as the bounce path does for -/// buffers that cannot transfer directly. -/// -/// Rejects offsets past `i64::MAX`, which Windows would treat as special values. +/// Writes `buf` to `file` at `offset` with one positioned write. pub(crate) fn write_slice_at(file: &File, buf: &[u8], offset: u64) -> std::io::Result { check_offset(offset)?; #[cfg(unix)] @@ -782,11 +776,11 @@ impl Backend for LocalFs { .ok_or(ReadError::NotForReading)?; let offset = u64::try_from(offset).map_err(|_| ReadError::Io)?; #[cfg(target_os = "linux")] - if let Some(result) = linux::read_at(file, buf, offset) { + if let Some(result) = linux::read_at(file, buf, 0, offset) { return result.map_err(|_| ReadError::Io); } #[cfg(all(windows, target_arch = "x86_64"))] - if let Some(result) = windows::read_at(file, buf, offset) { + if let Some(result) = windows::read_at(file, buf, 0, offset) { return result.map_err(|_| ReadError::Io); } let mut staged = bounce_buffer(buf.len())?; @@ -812,11 +806,11 @@ impl Backend for LocalFs { .ok_or(WriteError::NotForWriting)?; let offset = u64::try_from(offset).map_err(|_| WriteError::Io)?; #[cfg(target_os = "linux")] - if let Some(result) = linux::write_at(file, buf, offset) { + if let Some(result) = linux::write_at(file, buf, 0, offset) { return result.map_err(|_| WriteError::Io); } #[cfg(all(windows, target_arch = "x86_64"))] - if let Some(result) = windows::write_at(file, buf, offset) { + if let Some(result) = windows::write_at(file, buf, 0, offset) { return result.map_err(|_| WriteError::Io); } let mut staged = bounce_buffer(buf.len())?; diff --git a/litebox_broker_userland/src/fs/windows.rs b/litebox_broker_userland/src/fs/windows.rs index eaf527601..1d6d030bc 100644 --- a/litebox_broker_userland/src/fs/windows.rs +++ b/litebox_broker_userland/src/fs/windows.rs @@ -21,14 +21,24 @@ use windows_sys::Win32::Storage::FileSystem::{ReadFile, WriteFile}; use windows_sys::Win32::System::IO::OVERLAPPED; use windows_sys::core::BOOL; -/// Reads from `file` at `offset` directly into `buf` with one `ReadFile`. +/// Reads from `file` at `offset` directly into `buf`, from `start` to its end, +/// with one `ReadFile`. /// -/// Returns `None` without reading unless `buf` is one contiguous part of raw -/// bytes. -pub(crate) fn read_at(file: &File, buf: &mut dyn ReadBuffer, offset: u64) -> Option> { +/// Returns `None` without reading unless those bytes of `buf` are one +/// contiguous part of raw bytes. +/// +/// # Panics +/// +/// Panics if `start` exceeds `buf`. +pub(crate) fn read_at( + file: &File, + buf: &mut dyn ReadBuffer, + start: usize, + offset: u64, +) -> Option> { let mut result = None; - let length = buf.len(); - buf.read_direct_at(0, length, &mut |parts| { + let length = buf.len() - start; + buf.read_direct_at(start, length, &mut |parts| { let [part] = parts else { return 0 }; let read = transfer(offset, part.len(), |length, transferred, overlapped| { // SAFETY: `part` is raw bytes that are writable for `length` bytes @@ -55,13 +65,23 @@ pub(crate) fn read_at(file: &File, buf: &mut dyn ReadBuffer, offset: u64) -> Opt result } -/// Writes `buf` to `file` at `offset` directly with one `WriteFile`. +/// Writes `buf`, from `start` to its end, to `file` at `offset` directly with +/// one `WriteFile`. /// -/// Returns `None` without writing unless `buf` is one contiguous part of raw -/// bytes. -pub(crate) fn write_at(file: &File, buf: &dyn WriteBuffer, offset: u64) -> Option> { +/// Returns `None` without writing unless those bytes of `buf` are one +/// contiguous part of raw bytes. +/// +/// # Panics +/// +/// Panics if `start` exceeds `buf`. +pub(crate) fn write_at( + file: &File, + buf: &dyn WriteBuffer, + start: usize, + offset: u64, +) -> Option> { let mut result = None; - buf.write_direct_at(0, buf.len(), &mut |parts| { + buf.write_direct_at(start, buf.len() - start, &mut |parts| { let [part] = parts else { return 0 }; let written = transfer(offset, part.len(), |length, transferred, overlapped| { // SAFETY: `part` is raw bytes that are readable for `length` bytes diff --git a/litebox_broker_userland/src/shared_objects/host_file.rs b/litebox_broker_userland/src/shared_objects/host_file.rs index 68e8d998f..f62bf3505 100644 --- a/litebox_broker_userland/src/shared_objects/host_file.rs +++ b/litebox_broker_userland/src/shared_objects/host_file.rs @@ -22,13 +22,9 @@ use crate::fs::{read_slice_at, write_slice_at}; pub(super) const STAGING_SIZE: usize = 64 * 1024; /// A file's contents in a host file, accessed with positional I/O. -/// -/// The host-specific parts, such as how the size is kept and which handles are exported, are -/// in each host's module, which implements the methods this module calls on it. pub(super) struct HostFileMemory { pub(super) file: File, - /// Makes each of the broker's accesses atomic with respect to the others, as - /// [`SharedFileMemory`] requires: reads share it, while writes and resizes take it alone. + /// Makes each of the broker's accesses atomic with respect to the others. io: RwLock<()>, /// Host-specific state, changed only while holding `io` alone. #[cfg_attr( @@ -60,38 +56,28 @@ impl HostFileMemory { position: u64, buffer: &mut dyn ReadBuffer, ) -> Result { - let direct = direct::read_at(&self.file, buffer, position); - // A short or interrupted direct read continues through the staging buffer, so that - // only the end of the memory stops a read short. - let mut read = match direct { - Some(Ok(0)) => return Ok(0), - Some(Ok(read)) => read, - Some(Err(error)) if error.kind() == ErrorKind::Interrupted => 0, - Some(Err(error)) => return Err(memory_error(&error)), - None => 0, - }; - if read == buffer.len() { - return Ok(read); - } - // A short direct read usually stopped at the end, which the size confirms without - // staging anything. - if read > 0 && position + read as u64 >= self.len()? { - return Ok(read); - } - let mut staging = match bounce_buffer(STAGING_SIZE.min(buffer.len() - read)) { - Ok(staging) => staging, - Err(_) if read > 0 => return Ok(read), - Err(_) => return Err(SharedFileMemoryError::NoMemory), - }; + // Short and interrupted reads continue, so that only the end of the memory stops a + // read short. + let mut read = 0; + let mut staging = None; while read < buffer.len() { - let chunk = &mut staging[..STAGING_SIZE.min(buffer.len() - read)]; - let count = self - .read_once(chunk, position + read as u64) - .map_err(|error| memory_error(&error))?; + let offset = position + read as u64; + let count = match direct::read_at(&self.file, buffer, read, offset) { + Some(Err(error)) if error.kind() == ErrorKind::Interrupted => continue, + Some(result) => result.map_err(|error| memory_error(&error))?, + // The rest of the buffer cannot take a direct read, so stage a chunk of it. + None => { + let chunk = staging_chunk(&mut staging, buffer.len() - read)?; + let count = self + .read_once(chunk, offset) + .map_err(|error| memory_error(&error))?; + buffer.copy_from_slice_at(read, &chunk[..count])?; + count + } + }; if count == 0 { break; } - buffer.copy_from_slice_at(read, &chunk[..count])?; read += count; } Ok(read) @@ -155,53 +141,26 @@ impl HostFileMemory { let length = usize::try_from(limit - position).map_or(data.len(), |room| room.min(data.len())); let _io = self.io.write().unwrap_or_else(PoisonError::into_inner); - self.before_write(position)?; - let written = self.write_locked(position, data, length)?; + let written = self.write_locked(position, &data.sub_buffer(0, length))?; if written > 0 { self.after_write(position + written as u64); } Ok(written) } - /// Writes the first `length` bytes of `data` at `position`, as - /// [`SharedFileMemory::write_at`] does. Callers hold `io` alone. + /// Writes `data` at `position`, as [`SharedFileMemory::write_at`] does. Callers hold `io` + /// alone. fn write_locked( &self, position: u64, data: &dyn WriteBuffer, - length: usize, ) -> Result { - // Only a write that fits whole may transfer directly, since it cannot stop early. - let direct = if length == data.len() { - direct::write_at(&self.file, data, position) - } else { - None - }; - // A short or interrupted direct write continues through the staging buffer. - let mut written = match direct { - Some(Err(error)) if error.kind() == ErrorKind::Interrupted => 0, - Some(written) => written.map_err(|error| memory_error(&error))?, - None => 0, - }; - if written == length { - return Ok(written); - } - let mut staging = match bounce_buffer(STAGING_SIZE.min(length - written)) { - Ok(staging) => staging, - Err(_) if written > 0 => return Ok(written), - Err(_) => return Err(SharedFileMemoryError::NoMemory), - }; - while written < length { - let chunk = &mut staging[..STAGING_SIZE.min(length - written)]; - let result = data - .copy_to_slice_at(written, chunk) - .map_err(SharedFileMemoryError::from) - .and_then(|()| { - self.write_all(chunk, position + written as u64, &mut written) - .map_err(|error| memory_error(&error)) - }); - match result { - Ok(()) => {} + // Short and interrupted writes continue, so that only an error stops a write short. + let mut written = 0; + let mut staging = None; + while written < data.len() { + match self.write_once(data, written, position + written as u64, &mut staging) { + Ok(count) => written += count, // Like `pwrite`, report the bytes already written rather than the error. Err(_) if written > 0 => break, Err(error) => return Err(error), @@ -210,6 +169,33 @@ impl HostFileMemory { Ok(written) } + /// Writes some of `data` from `start` on at `offset`, directly if those bytes allow it and + /// otherwise through `staging`, and returns how many it wrote. Callers hold `io` alone. + fn write_once( + &self, + data: &dyn WriteBuffer, + start: usize, + offset: u64, + staging: &mut Option>, + ) -> Result { + loop { + let result = if let Some(result) = direct::write_at(&self.file, data, start, offset) { + result + } else { + // The rest of `data` cannot take a direct write, so stage a chunk of it. + let chunk = staging_chunk(staging, data.len() - start)?; + data.copy_to_slice_at(start, chunk)?; + write_slice_at(&self.file, chunk, offset) + }; + match result { + Ok(0) => return Err(SharedFileMemoryError::Io), + Ok(count) => return Ok(count), + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(memory_error(&error)), + } + } + } + /// Resizes like [`SharedFileMemory::set_len`], but to at most `limit` bytes. fn set_len_limited(&self, length: usize, limit: u64) -> Result<(), SharedFileMemoryError> { let length = u64::try_from(length) @@ -229,6 +215,21 @@ pub(super) fn memory_error(error: &IoError) -> SharedFileMemoryError { .unwrap_or(SharedFileMemoryError::Io) } +/// Returns the part of the staging buffer that stages the next of `remaining` bytes, allocating +/// the buffer on first use. +fn staging_chunk( + staging: &mut Option>, + remaining: usize, +) -> Result<&mut [u8], SharedFileMemoryError> { + let length = STAGING_SIZE.min(remaining); + let staging = match staging { + Some(staging) => staging, + // Later chunks are no longer than the first, since `remaining` only shrinks. + None => staging.insert(bounce_buffer(length).map_err(|_| SharedFileMemoryError::NoMemory)?), + }; + Ok(&mut staging[..length]) +} + impl SharedFileMemory for HostFileMemory { fn read_at( &self, @@ -275,8 +276,6 @@ impl SharedFileMemory for HostFileMemory { /// A host handle exported from shared file memory, either lent by the memory, as a /// borrowed handle, or made for the export, as an owned one that closes when dropped. -/// -/// Each host's module implements [`HostHandle`] for it. pub(super) struct Exported(pub(super) H); impl SharedFileObject for HostFileMemory { diff --git a/litebox_broker_userland/src/shared_objects/linux.rs b/litebox_broker_userland/src/shared_objects/linux.rs index 763801979..ad362bf91 100644 --- a/litebox_broker_userland/src/shared_objects/linux.rs +++ b/litebox_broker_userland/src/shared_objects/linux.rs @@ -1,10 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -use std::fs::{File, Permissions}; +use std::fs::File; use std::io::Error as IoError; use std::os::fd::FromRawFd as _; -use std::os::unix::fs::PermissionsExt as _; use std::sync::Arc; use litebox_broker_core::fs::shared_memory::{ @@ -36,16 +35,6 @@ impl HostFileMemory { .map_err(|error| memory_error(&error)) } - /// Prepares for a write at `position`. Callers hold `io` alone. - #[expect( - clippy::unnecessary_wraps, - clippy::unused_self, - reason = "Windows clears stale bytes before writes" - )] - pub(super) fn before_write(&self, _position: u64) -> Result<(), SharedFileMemoryError> { - Ok(()) - } - /// Records a write that ended at `end`. Callers hold `io` alone. #[expect(clippy::unused_self, reason = "Windows keeps a size of its own")] pub(super) fn after_write(&self, _end: u64) {} @@ -113,6 +102,7 @@ pub(super) fn memory_error_for(code: i32) -> Option { pub(super) fn read_reached_end(_error: &IoError) -> bool { false } + /// Creates memory for files in memfds, accessed only through positional I/O by the broker. pub struct MemfdFileMemoryProvider; @@ -131,32 +121,7 @@ fn memfd() -> Result { return Err(memory_error(&IoError::last_os_error())); } // SAFETY: `memfd_create` returned a new descriptor that nothing else owns. - let file = unsafe { File::from_raw_fd(fd) }; - // A runner holding the writable descriptor must not seal the memfd against the broker's - // writes. Memfds created without sealing are sealed against further seals already, but - // hosts that seal memfds against execution allow more seals, so forbid them here. - // SAFETY: `fd` is a live memfd that `file` owns. - unsafe { libc::fcntl(fd, libc::F_ADD_SEALS, libc::F_SEAL_SEAL) }; - // SAFETY: As above. - let seals = unsafe { libc::fcntl(fd, libc::F_GET_SEALS) }; - if seals < 0 || seals & libc::F_SEAL_SEAL == 0 { - return Err(SharedFileMemoryError::Io); - } - // Reopening `/proc//fd/` checks the inode's mode rather than the descriptor's - // access mode, so drop write permission to stop read-only descriptors from being - // reopened for writing. This holds only against openers that cannot bypass or change - // the mode: a process with `CAP_DAC_OVERRIDE` ignores it, and the memfd's owner (the - // broker's uid) or a process with `CAP_FOWNER` can restore it with `fchmod`. Runners - // therefore rely on their seccomp filter, which allows neither `fchmod` nor `open`. - // Execute bits stay, since hosts may seal them. - let mode = file - .metadata() - .map_err(|error| memory_error(&error))? - .permissions() - .mode(); - file.set_permissions(Permissions::from_mode((mode & 0o111) | 0o400)) - .map_err(|error| memory_error(&error))?; - Ok(file) + Ok(unsafe { File::from_raw_fd(fd) }) } impl SharedFileMemoryProvider for MemfdFileMemoryProvider { @@ -179,16 +144,6 @@ mod tests { super::super::host_file::tests::check_size_limit(memfd().unwrap()); } - #[test] - fn memfds_accept_no_seals() { - let memory = MemfdFileMemoryProvider.create(b"hello").unwrap(); - let raw = libc::c_int::try_from(memory.host_handle(true).unwrap().raw()).unwrap(); - // SAFETY: The handle lends a live descriptor. - let seals = unsafe { libc::fcntl(raw, libc::F_GET_SEALS) }; - assert!(seals >= 0); - assert_ne!(seals & libc::F_SEAL_SEAL, 0); - } - #[test] fn reader_handles_are_read_only_and_closed_after_export() { let open_descriptors = || std::fs::read_dir("/proc/self/fd").unwrap().count(); @@ -201,13 +156,6 @@ mod tests { unsafe { libc::fcntl(raw, libc::F_GETFL) } & libc::O_ACCMODE, libc::O_RDONLY ); - // Reopening the reader through procfs checks the inode's mode, which must grant no - // write access, whatever capabilities the test runs with. - let mode = std::fs::metadata(format!("/proc/self/fd/{raw}")) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o222, 0); drop(reader); assert_eq!(open_descriptors(), before); diff --git a/litebox_broker_userland/src/shared_objects/windows.rs b/litebox_broker_userland/src/shared_objects/windows.rs index ca936eb6d..f2d31bb1e 100644 --- a/litebox_broker_userland/src/shared_objects/windows.rs +++ b/litebox_broker_userland/src/shared_objects/windows.rs @@ -23,7 +23,8 @@ use super::host_file::{Exported, HostFileMemory, STAGING_SIZE, create, memory_er /// for as long as they map the file. A shrink it refuses therefore zeroes the bytes past the new /// end and lowers this size only, leaving the host file longer until a later resize can shrink /// it. Runners receive no right to resize sections, so only the broker changes the host file's -/// size. +/// size. Runners may still store past the end through their mappings, and those bytes become +/// part of the file again if it grows over them. #[derive(Default)] pub(super) struct State { len: AtomicU64, @@ -51,9 +52,8 @@ impl HostFileMemory { /// Resizes the file to `length` bytes. Callers hold `io` alone. /// - /// Bytes between the old and the new size that the host file already holds are zeroed, - /// since runners may have written there through mappings past the end of the file, and a - /// shrink the host refuses while the file is mapped leaves them in the host file. + /// A shrink the host refuses while the file is mapped zeroes the bytes past the new size + /// instead, so that mappings no longer see them. pub(super) fn resize(&self, length: u64) -> Result<(), SharedFileMemoryError> { let old_len = self.len()?; let old_host_len = self.host_len()?; @@ -71,25 +71,10 @@ impl HostFileMemory { Err(error) => return Err(memory_error(&error)), } }; - // Bytes the host file had before this resize and still has may be stale. - let stale_end = old_host_len.min(host_len); - let (start, end) = if length > old_len { - (old_len, length) - } else { - (length, old_len) - }; - self.zero(start, end.min(stale_end))?; - self.state.len.store(length, Ordering::Relaxed); - Ok(()) - } - - /// Zeroes stale bytes the host file may hold between the end of the file and a write at - /// `position`. Callers hold `io` alone. - pub(super) fn before_write(&self, position: u64) -> Result<(), SharedFileMemoryError> { - let len = self.len()?; - if position > len { - self.zero(len, position.min(self.host_len()?))?; + if length < old_len { + self.zero(length, old_len.min(host_len))?; } + self.state.len.store(length, Ordering::Relaxed); Ok(()) } @@ -267,8 +252,6 @@ mod tests { // SAFETY: The view maps the five bytes of the file as they were mapped, which the // host may change behind the reference-free volatile accesses. let peek = |index: usize| unsafe { byte.add(index).read_volatile() }; - // SAFETY: As for `peek`. - let poke = |index: usize, value: u8| unsafe { byte.add(index).write_volatile(value) }; // The host cannot shrink the mapped file, so the bytes past the new end read as // gone and become zeros in the mapping. @@ -278,15 +261,9 @@ mod tests { assert_eq!(contents(8), b"h"); assert_eq!((1..5).map(peek).collect::>(), [0; 4]); - // What a runner stores past the end through its mapping is zero once the file grows - // over it, whether by resizing or by writing past it. - poke(3, b'x'); - poke(4, b'y'); - memory.set_len(4).unwrap(); - assert_eq!(contents(8), b"h\0\0\0"); + // Writing past the end grows the mapped file over the zeroed tail. assert_eq!(memory.write_at(6, &b"!".as_slice()).unwrap(), 1); assert_eq!(contents(8), b"h\0\0\0\0\0!"); - assert_eq!(peek(4), 0); assert_eq!(host_len(), 7); // SAFETY: `view` and `section` were created above and are not used again.