diff --git a/litebox/src/fs/errors.rs b/litebox/src/fs/errors.rs index 8c34184d1c..40015267d1 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`]. @@ -109,6 +112,10 @@ pub enum TruncateError { NotOpenForWriting, #[error("file descriptor points to a terminal device")] 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 3ad405dd9b..5393a02ebc 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, @@ -772,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, } } @@ -797,6 +799,8 @@ impl From for TruncateError { FileError::AccessNotAllowed | FileError::NotForWriting => Self::NotForWriting, FileError::PathOnly => Self::NotOpenForWriting, FileError::IsTerminalDevice => Self::IsTerminalDevice, + FileError::Busy => Self::Busy, + FileError::NoSpace => Self::NoSpace, _ => Self::Io, } } diff --git a/litebox_broker_core/src/fs/backend.rs b/litebox_broker_core/src/fs/backend.rs index 57bbc0100d..d201eb29e6 100644 --- a/litebox_broker_core/src/fs/backend.rs +++ b/litebox_broker_core/src/fs/backend.rs @@ -13,10 +13,11 @@ 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::shared_memory::SharedFile; use super::{DirEntry, FileStatus, FileType, Mode, OFlags, UserInfo}; use crate::readiness::ReadinessSource; use crate::terminal::TerminalEndpoint; @@ -145,6 +146,18 @@ pub trait Backend: Send + Sync + Any { None } + /// 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. + /// + /// The default implementation returns [`MapSharedError::Unsupported`]. + #[expect(unused_variables, reason = "default body with unused parameter")] + 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 fbb5b8c236..efcf0f080b 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,6 +736,11 @@ impl Backend for Composer { .get_static_backing_data(&h.handle) } + fn map_shared(&self, h: &FileHandle) -> Result, 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 a55ce896dd..6b317ca75d 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)] @@ -114,6 +117,28 @@ pub enum TruncateError { NotOpenForWriting, #[error("file descriptor points to a terminal device")] 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`] and +/// [`super::backend::Backend::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 b1b889284a..a504bde305 100644 --- a/litebox_broker_core/src/fs/in_mem.rs +++ b/litebox_broker_core/src/fs/in_mem.rs @@ -15,11 +15,14 @@ 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, 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, + /// and bounds how many files may hold it. + shared_memory: Option<(Arc, Arc)>, } impl InMem { @@ -60,9 +66,21 @@ impl InMem { root, inode_allocator, rename_lock: sync::Mutex::new(()), + shared_memory: None, } } + /// Register a shared memory provider and limit. + #[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 +172,7 @@ impl InMem { mode, userinfo: owner, }; - existing.data = data; + existing.contents = Contents::Inline(data); } ( Some(Node::File(existing)), @@ -174,7 +192,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 +214,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 +233,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 +281,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 +430,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 +510,19 @@ 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, .. } => { + 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); - 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( @@ -506,10 +531,21 @@ impl super::backend::Backend for InMe buf: &dyn WriteBuffer, offset: usize, ) -> Result { + // 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(); - let data = file.data.to_mut(); - let previous_len = data.len(); + 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 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 +559,21 @@ 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, .. } => { + 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], 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(()) } @@ -542,14 +586,24 @@ 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.data.len()).map_err(|_| FileStatusError::Io)?, + size: 0, owner: file.perms.userinfo, node_info: file.node_info, blksize: BLOCK_SIZE, - }) + }; + 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)? + } + }; + status.size = u64::try_from(size).map_err(|_| FileStatusError::Io)?; + Ok(status) } super::backend::HandleRef::Dir(h) => { let dir = h.get_typed::().dir.read(); @@ -595,7 +649,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 +679,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,11 +903,37 @@ 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)?; + let shared = Arc::new(SharedFile::new(memory.clone(), slot)); + file.contents = Contents::Shared { + memory, + file: Arc::clone(&shared), + }; + Ok(shared) + } } /// Flags this backend knows how to honor when opening files/directories. @@ -933,10 +1013,39 @@ 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. + /// + /// 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, + }, +} + +impl FileData { + /// 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 +1134,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 7321182a82..06d89aca95 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 20ff550f5e..07ef056976 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,6 +1089,12 @@ impl Backend for Overlay { }) } + fn map_shared(&self, h: &FileHandle) -> Result, MapSharedError> { + 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 a94cf25a0e..2c929148c7 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,35 @@ 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 { + // 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.read_allowed { + return Err(MapSharedError::NotForReading); + } + let file = self.backend.map_shared(file)?; + Ok(super::shared_memory::SharedMapping { + file, + writable: entry.write_allowed, + }) + } } #[expect( diff --git a/litebox_broker_core/src/fs/service.rs b/litebox_broker_core/src/fs/service.rs index fff9c3296e..22b6293215 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 } @@ -1033,6 +1034,8 @@ fn file_truncate_error(error: TruncateError) -> FileError { TruncateError::NotForWriting => FileError::NotForWriting, TruncateError::NotOpenForWriting => FileError::PathOnly, TruncateError::IsTerminalDevice => FileError::IsTerminalDevice, + TruncateError::Busy => FileError::Busy, + TruncateError::NoSpace => FileError::NoSpace, 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 0000000000..e985cb4f58 --- /dev/null +++ b/litebox_broker_core/src/fs/shared_memory.rs @@ -0,0 +1,356 @@ +// 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. +//! +//! [`SharedFileLimit`] bounds how many files hold host resources at once. + +use alloc::boxed::Box; +use alloc::sync::Arc; +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 { + /// 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 + } +} + +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 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. + 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 `data` to `offset`, growing the memory with zeros to end there if it is shorter, + /// and returns how many bytes were copied. + /// + /// 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; + + /// Changes the size to `length` bytes, zero-filling any added bytes. + fn set_len(&self, length: usize) -> Result<(), SharedFileMemoryError>; +} + +/// 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>; +} + +/// 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. +#[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 { + crate::quota::try_increment(&self.live, self.max).then(|| 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 + } +} + +/// 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. + + use super::{ + HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, + SharedFileObject, + }; + use alloc::boxed::Box; + use alloc::sync::Arc; + use alloc::vec::Vec; + 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 { + 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 { + let mut memory = self.data.lock(); + 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), + } + } + Ok(written) + } + + 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); + } + if length > self.capacity { + return Err(SharedFileMemoryError::NoMemory); + } + 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, + capacity: usize, + 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, + 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")) + } + } + + 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, + 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 4508edee09..37e3f16007 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 @@ -2584,3 +2587,423 @@ mod composed { ); } } + +mod shared_mapping { + use super::{InMem, InodeAllocator, Mode, OFlags, ROOT, Resolver, TestPlatform, USER}; + 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, SharedMapping, + }; + use crate::test_support::FaultingBuffer; + 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 SharedMapping { + file: 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 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(); + let SharedMapping { + file: again, + writable, + } = fs.map_shared(&fd).unwrap(); + assert!(!writable); + assert!(Arc::ptr_eq(&again, &mapping)); + + 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 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 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 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 + .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().file; + 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 mut 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(); + 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]; + 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 reopened, &mut read, Some(0)).unwrap(), 8); + assert_eq!(read, bytes); + assert!(Arc::ptr_eq( + &fs.map_shared(&reopened).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/lib.rs b/litebox_broker_core/src/lib.rs index f84e9b312a..861b093881 100644 --- a/litebox_broker_core/src/lib.rs +++ b/litebox_broker_core/src/lib.rs @@ -114,6 +114,11 @@ 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. + /// + /// 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, } impl BrokerCoreLimits { @@ -134,6 +139,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 +164,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 +195,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 +295,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_core/src/quota.rs b/litebox_broker_core/src/quota.rs index fa1817bf01..f3b8d5eb20 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/linux.rs b/litebox_broker_userland/src/fs/linux.rs index 2a6b18cb7e..c00d1c1f56 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(super) 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(super) 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(super) 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 ceecb8ee60..ac5122ea46 100644 --- a/litebox_broker_userland/src/fs/mod.rs +++ b/litebox_broker_userland/src/fs/mod.rs @@ -34,9 +34,41 @@ 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; + +/// 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)] + { + 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. +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; @@ -744,25 +776,15 @@ 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())?; - 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) } @@ -784,26 +806,16 @@ 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())?; 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 572f995c14..1d6d030bc0 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(super) 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(super) 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 those bytes of `buf` are one +/// contiguous part of raw bytes. /// -/// 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> { +/// # 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 @@ -92,6 +112,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/lib.rs b/litebox_broker_userland/src/lib.rs index ffecb577c4..2f6971ef34 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/linux.rs b/litebox_broker_userland/src/linux.rs index a5577cb6f9..fd8030057e 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 3b2ac51c6b..658d09894b 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,6 +320,27 @@ where None => Cow::Borrowed(EMPTY_TAR_FILE), }; let in_mem = InMem::::new_initialized(entries); + // 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 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( @@ -576,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(); @@ -669,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/shared_objects.rs b/litebox_broker_userland/src/shared_objects.rs new file mode 100644 index 0000000000..205371980a --- /dev/null +++ b/litebox_broker_userland/src/shared_objects.rs @@ -0,0 +1,29 @@ +// 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; + +/// Memfd-backed shared file memory for Linux. +#[cfg(target_os = "linux")] +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; + +/// 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 new file mode 100644 index 0000000000..f62bf35050 --- /dev/null +++ b/litebox_broker_userland/src/shared_objects/host_file.rs @@ -0,0 +1,439 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use std::fs::File; +use std::io::{Error as IoError, ErrorKind}; +use std::sync::{PoisonError, RwLock}; + +use litebox_broker_core::fs::shared_memory::{ + HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileObject, +}; +use litebox_broker_core::transfer::bounce_buffer; +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. +pub(super) const STAGING_SIZE: usize = 64 * 1024; + +/// A file's contents in a host file, accessed with positional I/O. +pub(super) struct HostFileMemory { + pub(super) file: File, + /// 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( + 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, +/// on Windows, treat as special values. +fn position(offset: usize) -> Option { + i64::try_from(offset).ok().map(i64::cast_unsigned) +} + +impl HostFileMemory { + fn new(file: File) -> Self { + Self { + file, + io: RwLock::new(()), + state: State::default(), + } + } + + /// Reads at `position` into `buffer`, as [`SharedFileMemory::read_at`] does. Callers hold + /// `io`. + fn read_locked( + &self, + position: u64, + buffer: &mut dyn ReadBuffer, + ) -> Result { + // 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 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; + } + read += count; + } + Ok(read) + } + + /// Returns the host file's size. + pub(super) fn host_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 { + match read_slice_at(&self.file, buffer, offset) { + Err(error) if error.kind() == ErrorKind::Interrupted => {} + 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. + pub(super) 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); + let written = self.write_locked(position, &data.sub_buffer(0, length))?; + if written > 0 { + self.after_write(position + written as u64); + } + Ok(written) + } + + /// Writes `data` at `position`, as [`SharedFileMemory::write_at`] does. Callers hold `io` + /// alone. + fn write_locked( + &self, + position: u64, + data: &dyn WriteBuffer, + ) -> Result { + // 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), + } + } + 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) + .ok() + .filter(|length| *length <= limit) + .ok_or(SharedFileMemoryError::NoMemory)?; + let _io = self.io.write().unwrap_or_else(PoisonError::into_inner); + self.resize(length) + } +} + +/// Converts a host error into the error it means for shared file memory. +pub(super) fn memory_error(error: &IoError) -> SharedFileMemoryError { + error + .raw_os_error() + .and_then(platform::memory_error_for) + .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, + 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 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); + 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, platform::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, 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. +pub(super) struct Exported(pub(super) H); + +impl SharedFileObject for HostFileMemory { + fn host_handle( + &self, + writable: bool, + ) -> Result, SharedFileMemoryError> { + self.export(writable) + } +} + +pub(super) fn create( + file: File, + contents: &[u8], +) -> Result, SharedFileMemoryError> { + if u64::try_from(contents.len()).map_or(true, |length| length > platform::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))?; + memory.after_write(contents.len() as u64); + 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(()) + } + } + + /// 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 0000000000..ad362bf912 --- /dev/null +++ b/litebox_broker_userland/src/shared_objects/linux.rs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +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::{ + HostHandle, SharedFileMemory, SharedFileMemoryError, SharedFileMemoryProvider, +}; + +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)) + } + + /// 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; + +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 memfd_memory_stays_within_size_limits() { + super::super::host_file::tests::check_size_limit(memfd().unwrap()); + } + + #[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 + ); + 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 0000000000..f2d31bb1e9 --- /dev/null +++ b/litebox_broker_userland/src/shared_objects/windows.rs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +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::{ + 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::{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. 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, +} + +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. + /// + /// 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()?; + 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)), + } + }; + if length < old_len { + self.zero(length, old_len.min(host_len))?; + } + self.state.len.store(length, Ordering::Relaxed); + 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; + +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() }; + + // 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]); + + // 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!(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); + } +} diff --git a/litebox_broker_userland/src/windows.rs b/litebox_broker_userland/src/windows.rs index 9e2647df03..7b3350103c 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); diff --git a/litebox_common_linux/src/errno/mod.rs b/litebox_common_linux/src/errno/mod.rs index 65a46c6703..244928bade 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!(), } } @@ -301,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!(), } } @@ -926,6 +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, + // `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, } } diff --git a/litebox_common_windows/src/nt_status.rs b/litebox_common_windows/src/nt_status.rs index 3c3de0ab15..88c78963b3 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 d61e451ecd..f71921abc5 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 0be05fb8e7..3eb6f71328 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, } } diff --git a/litebox_shim_windows/src/syscalls/registry.rs b/litebox_shim_windows/src/syscalls/registry.rs index a2cfa7e27e..1841cd361d 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, } }