diff --git a/dev_tests/src/boilerplate.rs b/dev_tests/src/boilerplate.rs index c44ae4652..91c50f501 100644 --- a/dev_tests/src/boilerplate.rs +++ b/dev_tests/src/boilerplate.rs @@ -136,6 +136,8 @@ const SKIP_FILES: &[&str] = &[ "litebox_runner_linux_on_macos_userland/tests/test-bins/hello_thread", "litebox_runner_linux_on_macos_userland/tests/test-bins/hello_world_dyn", "litebox_runner_linux_on_macos_userland/tests/test-bins/ld-linux-aarch64.so.1", + "litebox_runner_linux_on_windows_userland/tests/test-bins/fork_parent", + "litebox_runner_linux_on_windows_userland/tests/test-bins/fork_threads_parent", "litebox_runner_linux_on_windows_userland/tests/test-bins/hello_exec_nolibc", "litebox_runner_linux_on_windows_userland/tests/test-bins/hello_thread", "litebox_runner_linux_on_windows_userland/tests/test-bins/hello_thread_static", @@ -143,6 +145,8 @@ const SKIP_FILES: &[&str] = &[ "litebox_runner_linux_on_windows_userland/tests/test-bins/hello_world_static", "litebox_runner_linux_on_windows_userland/tests/test-bins/pipe_broker", "litebox_runner_linux_on_windows_userland/tests/test-bins/thread_static", + "litebox_runner_linux_on_windows_userland/tests/test-bins/vfork_exec_child", + "litebox_runner_linux_on_windows_userland/tests/test-bins/vfork_exec_parent", "litebox_syscall_rewriter/tests/hello", "litebox_syscall_rewriter/tests/hello-32", "litebox_syscall_rewriter/tests/hello-aarch64", diff --git a/litebox_broker_core/src/process.rs b/litebox_broker_core/src/process.rs index b6adc11ba..fa60ae05d 100644 --- a/litebox_broker_core/src/process.rs +++ b/litebox_broker_core/src/process.rs @@ -559,15 +559,15 @@ impl BrokerProcess { /// Lets `write` store `length` bytes at `offset` in the memory image of /// the pending child selected by `child_process_id`. /// - /// The first write creates the image with `create`. The image ends no - /// later than the broker's child image size limit, and all child images - /// together stay within the broker's total child image size limit. + /// The first write creates the image with `create`, given the broker's + /// child image size limit, which the image never grows past. All child + /// images together stay within the broker's total child image size limit. pub fn write_child_memory>( &self, child_process_id: ProcessId, offset: u64, length: u64, - create: impl FnOnce() -> Result>, + create: impl FnOnce(u64) -> Result>, write: impl FnOnce(&mut dyn ProcessImage) -> core::result::Result<(), E>, ) -> core::result::Result<(), E> { let limits = self.core.limits; @@ -581,7 +581,7 @@ impl BrokerProcess { let image = match &mut pending.image { Some(image) => image, None => pending.image.insert(ChildImage::new( - create()?, + create(limits.max_child_image_size)?, Arc::clone(&self.core.reserved_child_image_size), )), }; @@ -2370,7 +2370,8 @@ mod tests { .process_id; let writes = Arc::new(std::sync::Mutex::new(Vec::new())); let created = AtomicUsize::new(0); - let create = || { + let create = |capacity| { + assert_eq!(capacity, 8); created.fetch_add(1, Ordering::Relaxed); Ok(Box::new(TestImage(Arc::clone(&writes))) as Box) }; @@ -2448,7 +2449,7 @@ mod tests { child, offset, length, - || Ok(Box::new(TestImage)), + |_| Ok(Box::new(TestImage)), |_| Ok(()), ) }; diff --git a/litebox_broker_host/src/lib.rs b/litebox_broker_host/src/lib.rs index 560a45716..6d604696d 100644 --- a/litebox_broker_host/src/lib.rs +++ b/litebox_broker_host/src/lib.rs @@ -898,12 +898,12 @@ pub trait ProcessLauncher: Send + Sync { image: Option, ) -> core::result::Result<(), BrokerError>; - /// Creates an empty memory image for a pending child. - /// - /// Platforms that cannot pass images to runners reject the request. - fn create_image(&self) -> core::result::Result, BrokerError> { - Err(BrokerError::UnsupportedOperation) - } + /// Creates an empty memory image for a pending child, which never grows + /// past `capacity` bytes. + fn create_image( + &self, + capacity: u64, + ) -> core::result::Result, BrokerError>; } /// Handles a process operation using the configured platform launcher. @@ -975,7 +975,7 @@ where child_process_id, offset, u64::from(data.length()), - || launcher.create_image(), + |capacity| launcher.create_image(capacity), |image| { let mut image_offset = offset; for range in ranges { diff --git a/litebox_broker_local_userland/src/windows.rs b/litebox_broker_local_userland/src/windows.rs index bd7906b8c..1f33f25a3 100644 --- a/litebox_broker_local_userland/src/windows.rs +++ b/litebox_broker_local_userland/src/windows.rs @@ -15,6 +15,7 @@ use litebox_broker_transport_windows_userland::control_ring::{ WindowsControlRingLocalCallChannel, WindowsControlRingLocalNotificationChannel, }; use litebox_broker_transport_windows_userland::named_pipe::WindowsNamedPipeLocalSetupChannel; +use litebox_broker_transport_windows_userland::process_image::WindowsReceivedProcessImage; const SETUP_TIMEOUT: Duration = Duration::from_secs(5); @@ -24,6 +25,8 @@ pub struct BrokerConnection { pub local: BrokerLocal, /// Asynchronous broker notification channel. pub notifications: BrokerNotifications, + /// Memory image this process starts from, if its parent wrote one. + pub process_image: Option, } /// Connects to and negotiates an association with a Windows-userland broker. @@ -37,23 +40,26 @@ pub fn connect(control_pipe: &OsStr) -> Result<(BrokerConnection, Option, + ) -> IoResult<()> { + let transfer = match image.filter(|(image, _)| !image.is_empty()) { + Some((image, runner_process)) => image.duplicate_to_process(runner_process)?, + None => TransferredSharedMemory { + length: 0, + handles: Vec::new(), + }, + }; + write_frame( + file_handle(&self.stream), + &encode_transfer(&transfer)?, + self.setup_deadline, + ) + } + /// Activates host request, response, and notification control-ring endpoints. pub fn into_active( self, diff --git a/litebox_broker_transport_windows_userland/src/lib.rs b/litebox_broker_transport_windows_userland/src/lib.rs index 090c597a5..55fec6561 100644 --- a/litebox_broker_transport_windows_userland/src/lib.rs +++ b/litebox_broker_transport_windows_userland/src/lib.rs @@ -13,5 +13,6 @@ mod host; mod local; pub mod named_pipe; mod pending_calls; +pub mod process_image; mod setup; pub mod shared_memory; diff --git a/litebox_broker_transport_windows_userland/src/local.rs b/litebox_broker_transport_windows_userland/src/local.rs index a1fca4b6b..8d6492345 100644 --- a/litebox_broker_transport_windows_userland/src/local.rs +++ b/litebox_broker_transport_windows_userland/src/local.rs @@ -33,6 +33,7 @@ use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OVERLAPPED; use crate::control_ring::PipeLiveness; use crate::named_pipe::{TRANSFER_FRAME_TAG, WindowsNamedPipeStream}; use crate::pending_calls::{PendingCalls, pending_calls_error}; +use crate::process_image::WindowsReceivedProcessImage; use crate::setup::{ copy_io_error, invalid_data, read_frame, read_pipe_until_cancelled, ring_error, wire_error, write_frame, @@ -137,6 +138,22 @@ impl WindowsNamedPipeLocalSetupChannel { unsafe { WindowsSharedMemory::control_ring_from_transferred(transfer) } } + /// Receives and maps the optional process image the broker sent with + /// [`WindowsNamedPipeHostSetupChannel::send_process_image`](crate::named_pipe::WindowsNamedPipeHostSetupChannel::send_process_image). + pub fn receive_process_image(&mut self) -> IoResult> { + let frame = + read_frame(file_handle(&self.stream), self.setup_deadline)?.ok_or_else(|| { + Error::new( + ErrorKind::UnexpectedEof, + "broker closed before transferring the process image", + ) + })?; + let transfer = decode_transfer(&frame)?; + // SAFETY: The authenticated broker duplicated these handles into this process and encoded + // their target-process values in the setup frame. + unsafe { WindowsReceivedProcessImage::from_transferred(transfer) } + } + /// Activates calls and notifications over the transferred shared control ring. pub fn into_active( self, diff --git a/litebox_broker_transport_windows_userland/src/process_image.rs b/litebox_broker_transport_windows_userland/src/process_image.rs new file mode 100644 index 000000000..7abb04084 --- /dev/null +++ b/litebox_broker_transport_windows_userland/src/process_image.rs @@ -0,0 +1,340 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Page-file-backed process memory images for runners being started. + +use std::io::{Error, ErrorKind, Result as IoResult}; +use std::ops::Range; +use std::ptr::NonNull; + +use litebox_broker_transport::shared_memory::SharedMemory; +use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; +use windows_sys::Win32::System::Memory::{ + CreateFileMappingW, FILE_MAP, FILE_MAP_ALL_ACCESS, FILE_MAP_READ, MEM_COMMIT, + MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, PAGE_READWRITE, SEC_RESERVE, UnmapViewOfFile, + VirtualAlloc, +}; +use windows_sys::Win32::System::SystemInformation::{GetSystemInfo, SYSTEM_INFO}; + +use crate::shared_memory::{ + OwnedHandle, TransferredSharedMemory, WindowsSharedMemory, duplicate_handle_to_process, +}; + +/// A view of a section, which is unmapped on drop. +struct SectionView(NonNull); + +impl SectionView { + /// Maps `length` bytes of `mapping` from `offset`, a multiple of the allocation granularity, + /// with `access`. + fn map( + mapping: &OwnedHandle, + access: FILE_MAP, + offset: usize, + length: usize, + ) -> IoResult { + let (high, low) = high_low(offset)?; + // SAFETY: `mapping` is a live section handle. Windows fails the call if the handle lacks + // `access` or the view does not fit in the section. + let view = unsafe { MapViewOfFile(mapping.0, access, high, low, length) }; + NonNull::new(view.Value.cast::()) + .map(Self) + .ok_or_else(Error::last_os_error) + } +} + +impl Drop for SectionView { + fn drop(&mut self) { + // SAFETY: The address is the live view MapViewOfFile returned for this owner. + unsafe { + UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { + Value: self.0.as_ptr().cast(), + }) + }; + } +} + +/// Page-file-backed section holding a process memory image for a runner being started. +/// +/// The section reserves the image's capacity and commits pages as writes reach them. The broker +/// writes the image, then +/// [`WindowsNamedPipeHostSetupChannel::send_process_image`](crate::named_pipe::WindowsNamedPipeHostSetupChannel::send_process_image) +/// passes the runner a handle that can only read it. +pub struct WindowsProcessImage { + mapping: OwnedHandle, + view: SectionView, + capacity: usize, + /// End of the furthest write; every page before it is committed. + length: usize, +} + +// SAFETY: The view stays mapped until drop, and only `&mut self` methods write through it. +unsafe impl Send for WindowsProcessImage {} + +impl WindowsProcessImage { + /// Creates an empty image that holds at most `capacity` bytes. + pub fn create(capacity: usize) -> IoResult { + if capacity == 0 { + return Err(Error::new( + ErrorKind::InvalidInput, + "process image capacity must be nonzero", + )); + } + let (high, low) = high_low(capacity)?; + // SAFETY: The section is anonymous, has no name or security descriptor, and its size was + // split into the documented high and low u32 fields. `SEC_RESERVE` only reserves it. + let mapping = unsafe { + CreateFileMappingW( + INVALID_HANDLE_VALUE, + std::ptr::null(), + PAGE_READWRITE | SEC_RESERVE, + high, + low, + std::ptr::null(), + ) + }; + if mapping.is_null() { + return Err(Error::last_os_error()); + } + let mapping = OwnedHandle(mapping); + Ok(Self { + view: SectionView::map(&mapping, FILE_MAP_ALL_ACCESS, 0, capacity)?, + mapping, + capacity, + length: 0, + }) + } + + /// Returns the end of the furthest write. + pub fn len(&self) -> usize { + self.length + } + + /// Returns whether nothing was written. + pub fn is_empty(&self) -> bool { + self.length == 0 + } + + /// Writes `data` at `offset`, extending the image as needed. + pub fn write(&mut self, offset: u64, data: &[u8]) -> IoResult<()> { + let destination = self.extend(offset, data.len())?; + // SAFETY: `extend` committed `data.len()` bytes at `destination` in this image's view, + // which `data` cannot overlap. + unsafe { std::ptr::copy_nonoverlapping(data.as_ptr(), destination, data.len()) }; + Ok(()) + } + + /// Copies the bytes at `range` in `memory` to `offset`, extending the image as needed. + pub fn write_from_shared( + &mut self, + offset: u64, + memory: &WindowsSharedMemory, + range: Range, + ) -> IoResult<()> { + let destination = self.extend(offset, range.len())?; + // SAFETY: `extend` committed `range.len()` bytes at `destination` in this image's view. + // `&mut self` excludes other access to them in this process, and other processes can + // only read the section once it is sent, after the broker stops writing it. + let destination = unsafe { std::slice::from_raw_parts_mut(destination, range.len()) }; + memory.read(range.start, destination).map_err(|error| { + Error::new( + ErrorKind::InvalidInput, + format!("failed to read shared memory: {error:?}"), + ) + }) + } + + /// Commits the image through `offset + length` and returns the address of `offset`. + fn extend(&mut self, offset: u64, length: usize) -> IoResult<*mut u8> { + let (start, end) = usize::try_from(offset) + .ok() + .and_then(|start| Some((start, start.checked_add(length)?))) + .filter(|(_, end)| *end <= self.capacity) + .ok_or_else(|| { + Error::new( + ErrorKind::InvalidInput, + "process image write exceeds its capacity", + ) + })?; + let base = self.view.0.as_ptr(); + if end > self.length { + // SAFETY: `self.length..end` lies within the view of the reserved section. Pages that + // are already committed stay as they are. + let committed = unsafe { + VirtualAlloc( + base.wrapping_add(self.length).cast(), + end - self.length, + MEM_COMMIT, + PAGE_READWRITE, + ) + }; + if committed.is_null() { + return Err(Error::last_os_error()); + } + self.length = end; + } + Ok(base.wrapping_add(start)) + } + + /// Duplicates a handle that can only read the image into `target_process`. + pub(crate) fn duplicate_to_process( + &self, + target_process: HANDLE, + ) -> IoResult { + Ok(TransferredSharedMemory { + length: self.length, + handles: vec![duplicate_handle_to_process( + self.mapping.0, + target_process, + Some(FILE_MAP_READ), + )?], + }) + } +} + +/// Process memory image a broker sent to this runner, which it can only read. +/// +/// Each read maps the part it needs only while copying it, so the image takes no lasting address +/// space that the process's memory might need. +pub struct WindowsReceivedProcessImage { + mapping: OwnedHandle, + length: usize, +} + +impl WindowsReceivedProcessImage { + /// Takes the image transferred by + /// [`WindowsNamedPipeHostSetupChannel::send_process_image`](crate::named_pipe::WindowsNamedPipeHostSetupChannel::send_process_image), + /// if any. + /// + /// # Safety + /// + /// Every handle must be live, owned by the caller, and valid in the current process. + pub(crate) unsafe fn from_transferred( + transfer: TransferredSharedMemory, + ) -> IoResult> { + let length = transfer.length; + let mut handles = transfer + .handles + .into_iter() + .map(|handle| OwnedHandle(handle as HANDLE)) + .collect::>(); + if handles.is_empty() && length == 0 { + return Ok(None); + } + if handles.len() != 1 || handles[0].0.is_null() || length == 0 { + return Err(Error::new( + ErrorKind::InvalidData, + "invalid process image setup data", + )); + } + Ok(Some(Self { + mapping: handles.remove(0), + length, + })) + } + + /// Copies the image's bytes from `offset` into `destination`, leaving the part of + /// `destination` past the image's end as it is. + pub fn read(&self, offset: u64, destination: &mut [u8]) -> IoResult<()> { + let Some((offset, available)) = usize::try_from(offset) + .ok() + .and_then(|offset| Some((offset, self.length.checked_sub(offset)?))) + else { + return Ok(()); + }; + let length = destination.len().min(available); + if length == 0 { + return Ok(()); + } + let skipped = offset % allocation_granularity(); + let view = SectionView::map( + &self.mapping, + FILE_MAP_READ, + offset - skipped, + skipped + length, + )?; + // SAFETY: The view holds `skipped + length` bytes. Raw copies form no Rust reference into + // the section, which the broker also maps. + unsafe { + std::ptr::copy_nonoverlapping( + view.0.as_ptr().add(skipped), + destination.as_mut_ptr(), + length, + ); + } + Ok(()) + } +} + +/// Splits a section size or offset into the high and low halves Windows takes. +fn high_low(value: usize) -> IoResult<(u32, u32)> { + let too_large = || Error::new(ErrorKind::InvalidInput, "process image is too large"); + let value = u64::try_from(value).map_err(|_| too_large())?; + Ok(( + u32::try_from(value >> 32).map_err(|_| too_large())?, + u32::try_from(value & u64::from(u32::MAX)).map_err(|_| too_large())?, + )) +} + +/// Returns the alignment of section view offsets. +fn allocation_granularity() -> usize { + let mut info = SYSTEM_INFO::default(); + // SAFETY: `info` is writable storage for the system information. + unsafe { GetSystemInfo(&raw mut info) }; + info.dwAllocationGranularity as usize +} + +#[cfg(test)] +mod tests { + use super::*; + use windows_sys::Win32::System::Threading::GetCurrentProcess; + + fn receive(image: &WindowsProcessImage) -> Option { + // SAFETY: GetCurrentProcess returns a pseudo-handle that need not be closed. + let transfer = image + .duplicate_to_process(unsafe { GetCurrentProcess() }) + .unwrap(); + // SAFETY: The handle was just duplicated into this process. + unsafe { WindowsReceivedProcessImage::from_transferred(transfer) }.unwrap() + } + + #[test] + fn received_image_reads_writes_and_zero_holes() { + let shared = WindowsSharedMemory::create(0x1000).unwrap(); + shared.write(0x10, b"shared").unwrap(); + let mut image = WindowsProcessImage::create(0x10_0000).unwrap(); + // Past the first allocation-granularity unit, so reads map views at nonzero offsets. + image.write(0x1_2345, b"tail").unwrap(); + image.write(1, b"head").unwrap(); + image + .write_from_shared(0x2000, &shared, 0x10..0x16) + .unwrap(); + assert_eq!(image.len(), 0x1_2349); + assert!(image.write(0x10_0000 - 1, b"xy").is_err()); + + let received = receive(&image).unwrap(); + let read = |offset| { + let mut bytes = [0xff; 8]; + received.read(offset, &mut bytes).unwrap(); + bytes + }; + assert_eq!(&read(0), b"\0head\0\0\0"); + assert_eq!(&read(0x2000), b"shared\0\0"); + assert_eq!(&read(0x1_2341), b"\0\0\0\0tail"); + assert_eq!(&read(0x1_2345), b"tail\xff\xff\xff\xff"); + assert_eq!(&read(0x1_2349), b"\xff\xff\xff\xff\xff\xff\xff\xff"); + assert_eq!(&read(u64::MAX), b"\xff\xff\xff\xff\xff\xff\xff\xff"); + } + + #[test] + fn received_image_cannot_be_written() { + let mut image = WindowsProcessImage::create(0x1000).unwrap(); + image.write(0, b"x").unwrap(); + // SAFETY: GetCurrentProcess returns a pseudo-handle that need not be closed. + let transfer = image + .duplicate_to_process(unsafe { GetCurrentProcess() }) + .unwrap(); + let mapping = OwnedHandle(transfer.handles[0] as HANDLE); + assert!(SectionView::map(&mapping, FILE_MAP_ALL_ACCESS, 0, 1).is_err()); + assert!(SectionView::map(&mapping, FILE_MAP_READ, 0, 1).is_ok()); + } +} diff --git a/litebox_broker_transport_windows_userland/src/shared_memory.rs b/litebox_broker_transport_windows_userland/src/shared_memory.rs index 88d6fd029..cd8bc5de1 100644 --- a/litebox_broker_transport_windows_userland/src/shared_memory.rs +++ b/litebox_broker_transport_windows_userland/src/shared_memory.rs @@ -36,7 +36,7 @@ const CONTROL_RING_WAKE_OFFSETS: [usize; 6] = [ ControlRingDirection::Notifications.consumer_epoch_offset(), ]; -struct OwnedHandle(HANDLE); +pub(crate) struct OwnedHandle(pub(crate) HANDLE); impl Drop for OwnedHandle { fn drop(&mut self) { @@ -158,10 +158,18 @@ impl WindowsSharedMemory { .as_ref() .map_or(0, |handles| handles.len()), ); - handles.push(duplicate_handle_to_process(self.mapping.0, target_process)?); + handles.push(duplicate_handle_to_process( + self.mapping.0, + target_process, + None, + )?); if let Some(wake_handles) = &self.wake_handles { for wake_handle in wake_handles.iter() { - handles.push(duplicate_handle_to_process(wake_handle.0, target_process)?); + handles.push(duplicate_handle_to_process( + wake_handle.0, + target_process, + None, + )?); } } Ok(TransferredSharedMemory { @@ -303,7 +311,13 @@ fn create_wake_handles() -> IoResult<[OwnedHandle; CONTROL_RING_WAKE_OFFSETS.len .map_err(|_| Error::other("incorrect control-ring wake handle count")) } -fn duplicate_handle_to_process(handle: HANDLE, target_process: HANDLE) -> IoResult { +/// Duplicates `handle` into `target_process` with `access`, or with the same access if `None`, +/// and returns the duplicate's value there. +pub(crate) fn duplicate_handle_to_process( + handle: HANDLE, + target_process: HANDLE, + access: Option, +) -> IoResult { let mut duplicate = std::ptr::null_mut(); // SAFETY: The source handle and both process handles are live. The output points to writable // storage for the target-process handle value. @@ -313,9 +327,13 @@ fn duplicate_handle_to_process(handle: HANDLE, target_process: HANDLE) -> IoResu handle, target_process, &raw mut duplicate, + access.unwrap_or(0), 0, - 0, - DUPLICATE_SAME_ACCESS, + if access.is_some() { + 0 + } else { + DUPLICATE_SAME_ACCESS + }, ) }; if succeeded == 0 { diff --git a/litebox_broker_userland/src/main.rs b/litebox_broker_userland/src/main.rs index 778d28d3b..3b5bb1e3d 100644 --- a/litebox_broker_userland/src/main.rs +++ b/litebox_broker_userland/src/main.rs @@ -112,7 +112,7 @@ struct CliArgs { #[arg(long, hide = true, requires = "unstable", conflicts_with = "runner")] in_process_runner: bool, /// Enable the experimental constrained process-duplication path. - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] #[arg( long, hide = true, @@ -375,7 +375,7 @@ mod cli_tests { assert_eq!(args.allow_udp_destination.len(), 1); } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", all(windows, target_arch = "x86_64")))] #[test] fn cli_rejects_process_duplication_with_in_process_runner() { let error = CliArgs::try_parse_from([ diff --git a/litebox_broker_userland/src/process_launcher.rs b/litebox_broker_userland/src/process_launcher.rs index 2e5401e5e..929aacf8a 100644 --- a/litebox_broker_userland/src/process_launcher.rs +++ b/litebox_broker_userland/src/process_launcher.rs @@ -223,9 +223,11 @@ impl ProcessLauncher for UserlandProcessLauncher { ) } - #[cfg(target_os = "linux")] - fn create_image(&self) -> Result, BrokerError> { - crate::runner::create_image() + fn create_image( + &self, + capacity: u64, + ) -> Result, BrokerError> { + crate::runner::create_image(capacity) } } diff --git a/litebox_broker_userland/src/runner.rs b/litebox_broker_userland/src/runner.rs index 9f05c1d48..70c39596b 100644 --- a/litebox_broker_userland/src/runner.rs +++ b/litebox_broker_userland/src/runner.rs @@ -31,6 +31,8 @@ use linux::wait_for_runner_event; #[cfg(all(windows, target_arch = "x86_64"))] use windows::PlatformRunnerEndpoint; #[cfg(all(windows, target_arch = "x86_64"))] +pub(crate) use windows::create_image; +#[cfg(all(windows, target_arch = "x86_64"))] use windows::wait_for_runner_event; const SETUP_TIMEOUT: Duration = Duration::from_secs(5); diff --git a/litebox_broker_userland/src/runner/linux.rs b/litebox_broker_userland/src/runner/linux.rs index 6e651c9f3..f0a4432b5 100644 --- a/litebox_broker_userland/src/runner/linux.rs +++ b/litebox_broker_userland/src/runner/linux.rs @@ -55,7 +55,8 @@ impl ProcessImage for RunnerProcessImage { } } -pub(crate) fn create_image() -> Result, BrokerError> { +/// Creates an empty child memory image, a memfd, which grows as it is written. +pub(crate) fn create_image(_capacity: u64) -> Result, BrokerError> { MemfdProcessImage::create() .map(|image| Box::new(RunnerProcessImage(image)) as Box) .map_err(|_| BrokerError::OutOfMemory) diff --git a/litebox_broker_userland/src/runner/windows.rs b/litebox_broker_userland/src/runner/windows.rs index 34431534b..474e77e14 100644 --- a/litebox_broker_userland/src/runner/windows.rs +++ b/litebox_broker_userland/src/runner/windows.rs @@ -1,18 +1,21 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +use std::any::Any; use std::ffi::OsString; use std::io::Result as IoResult; +use std::ops::Range; use std::os::windows::io::AsRawHandle; use std::process::Child; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use litebox_broker_core::BrokerCore; +use litebox_broker_core::{BrokerCore, BrokerError, ProcessImage}; use litebox_broker_protocol::shared_buffer::SHARED_BUFFER_POOL_SIZE; use litebox_broker_transport_windows_userland::named_pipe::{ WindowsNamedPipeHostSetupChannel, WindowsNamedPipeListener, validate_client_process, }; +use litebox_broker_transport_windows_userland::process_image::WindowsProcessImage; use litebox_broker_transport_windows_userland::shared_memory::WindowsSharedMemory; use super::{ @@ -21,6 +24,44 @@ use super::{ }; use crate::runtime::{AssociationOutcome, is_peer_closed_error}; +/// A child's memory image held for its runner. +struct RunnerProcessImage(WindowsProcessImage); + +impl ProcessImage for RunnerProcessImage { + fn write(&mut self, offset: u64, data: &[u8]) -> Result<(), BrokerError> { + self.0 + .write(offset, data) + .map_err(|_| BrokerError::OutOfMemory) + } + + fn write_from_shared( + &mut self, + offset: u64, + memory: &dyn Any, + range: Range, + ) -> Option> { + let memory = memory.downcast_ref::()?; + Some( + self.0 + .write_from_shared(offset, memory, range) + .map_err(|_| BrokerError::OutOfMemory), + ) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +/// Creates an empty child memory image, a section that reserves `capacity` +/// bytes and commits them as they are written. +pub(crate) fn create_image(capacity: u64) -> Result, BrokerError> { + let capacity = usize::try_from(capacity).map_err(|_| BrokerError::OutOfMemory)?; + WindowsProcessImage::create(capacity) + .map(|image| Box::new(RunnerProcessImage(image)) as Box) + .map_err(|_| BrokerError::OutOfMemory) +} + pub(super) struct PlatformRunnerEndpoint { pipe_name: OsString, listener: Option, @@ -74,10 +115,6 @@ fn serve_association( launcher: Arc, ) -> AssociationOutcome { let image = startup.take_image(); - debug_assert!( - image.is_none(), - "Windows runners do not support process images" - ); let shutdown_was_expected = startup.process.shutdown_was_expected(); let control_channel = match accept_control_channel(control_listener, runner, setup_deadline) { Ok(connection) => connection, @@ -100,9 +137,20 @@ fn serve_association( control_channel, || WindowsSharedMemory::create(SHARED_BUFFER_POOL_SIZE), WindowsSharedMemory::create_control_ring, - |channel, shared_memory, control_memory| { + // Moving `image` into this one-shot closure releases the broker's + // snapshot as soon as setup ends; the runner owns its copy after that. + move |channel, shared_memory, control_memory| { channel.send_shared_memory(shared_memory, runner_process)?; - channel.send_shared_memory(control_memory, runner_process) + channel.send_shared_memory(control_memory, runner_process)?; + let image = image.as_ref().map(|image| { + &image + .image() + .as_any() + .downcast_ref::() + .expect("the userland launcher creates every process image") + .0 + }); + channel.send_process_image(image.map(|image| (image, runner_process))) }, WindowsNamedPipeHostSetupChannel::into_active, launcher, diff --git a/litebox_broker_userland/src/windows.rs b/litebox_broker_userland/src/windows.rs index a9c84a301..5d28ed3d5 100644 --- a/litebox_broker_userland/src/windows.rs +++ b/litebox_broker_userland/src/windows.rs @@ -28,9 +28,12 @@ pub(super) fn run( args: super::CliArgs, stdio: Arc, ) -> Result<(), Box> { - let policy = PolicyEngine::with_host_guaranteed_rights(ObjectRights::all()).with_socket_policy( - configured_socket_policy(&args.allow_tcp_destination, &args.allow_udp_destination)?, - ); + let policy = PolicyEngine::with_host_guaranteed_rights(ObjectRights::all()) + .with_socket_policy(configured_socket_policy( + &args.allow_tcp_destination, + &args.allow_udp_destination, + )?) + .with_process_duplication_enabled(args.allow_process_duplication); let fs = super::create_file_service::( args.fs_initial_files.as_deref(), stdio, @@ -87,7 +90,8 @@ fn serve_runner_in_process( setup_deadline, |channel, shared_memory, control_memory| { channel.send_shared_memory_to_current_process(shared_memory)?; - channel.send_shared_memory_to_current_process(control_memory) + channel.send_shared_memory_to_current_process(control_memory)?; + channel.send_process_image(None) }, ) } diff --git a/litebox_runner_linux_on_windows_userland/src/lib.rs b/litebox_runner_linux_on_windows_userland/src/lib.rs index 901973983..2ed0a3333 100644 --- a/litebox_runner_linux_on_windows_userland/src/lib.rs +++ b/litebox_runner_linux_on_windows_userland/src/lib.rs @@ -10,6 +10,7 @@ extern crate alloc; use anyhow::{Context as _, Result}; use clap::Parser; use litebox_broker_local_userland as broker; +use litebox_common_linux::program_startup::{LinuxProcessStartup, LinuxProgramStartup}; use litebox_platform_windows_userland::{GuestTlsMode, WindowsUserland}; type Platform = WindowsUserland<{ litebox_common_linux::vmem::PAGE_SIZE }>; @@ -23,8 +24,10 @@ pub struct CliArgs { /// The program and arguments passed to it (e.g., `/bin/ls --color`). /// /// The program path refers to a path inside the broker-owned file system. - /// All binaries must be pre-rewritten with the syscall rewriter. - #[arg(required = true, trailing_var_arg = true, value_hint = clap::ValueHint::CommandWithArguments)] + /// All binaries must be pre-rewritten with the syscall rewriter. This is + /// omitted when the broker starts this runner for a forked or exec'd child, + /// which gets what to run from the broker instead. + #[arg(trailing_var_arg = true, value_hint = clap::ValueHint::CommandWithArguments)] pub program_and_arguments: Vec, /// Environment variables passed to the program (`K=V` pairs; can be invoked multiple times) #[arg(long = "env")] @@ -71,12 +74,10 @@ pub fn run(cli_args: CliArgs) -> Result<()> { .as_deref() .context("file operations require --broker-control-channel")?; let (connection, startup) = broker::connect(control_pipe)?; - if startup.is_some() { - anyhow::bail!("unsupported child Linux process startup"); - } let broker::BrokerConnection { local, notifications, + process_image, } = connection; let (litebox, process_id, initial_thread) = litebox::LiteBox::new_process_with_broker_local(platform, local); @@ -89,57 +90,134 @@ pub fn run(cli_args: CliArgs) -> Result<()> { let shim_builder = litebox_shim_linux::LinuxShimBuilder::new_with_litebox(platform, litebox, process_id); - // The program path is a Unix-style path inside the tar archive. - let prog_path = &cli_args.program_and_arguments[0]; - let shim = shim_builder.build(); - let argv = cli_args - .program_and_arguments - .iter() - .map(|x| std::ffi::CString::new(x.bytes().collect::>()).unwrap()) - .collect(); - let envp: Vec<_> = cli_args - .environment_variables - .iter() - .map(|x| std::ffi::CString::new(x.bytes().collect::>()).unwrap()) - .collect(); - let envp = if cli_args.forward_environment_variables { - envp.into_iter() - .chain(std::env::vars().map(|(k, v)| { - std::ffi::CString::new(k.bytes().chain(*b"=").chain(v.bytes()).collect::>()) - .unwrap() - })) - .collect() - } else { - envp + let startup = startup + .map(|startup| LinuxProcessStartup::decode(&startup.payload)) + .transpose() + .context("invalid child Linux process startup")?; + let (task_params, prog_path, argv, envp) = match startup { + Some(LinuxProcessStartup::Fork(startup)) => { + // The child continues at its parent's syscall entry point in this runner. Windows + // shares an executable's relocated image among running instances, so this runner + // normally loads where its live parent's did; `restore_fork` rejects it otherwise. + let program = shim + .restore_fork(*startup, initial_thread, |offset, pages| { + process_image.as_ref().map_or(Ok(()), |image| { + image + .read(offset, pages) + .map_err(|_| litebox_common_linux::errno::Errno::EIO) + }) + }) + .context("failed to continue the forked process")?; + drop(process_image); + run_program(&shim, program) + } + Some(LinuxProcessStartup::Program(startup)) => { + let LinuxProgramStartup { + parent_process_id, + uid, + euid, + gid, + egid, + blocked_signals, + ignored_signals, + umask, + path, + cwd, + argv, + envp, + inherited_fds, + } = startup; + ( + litebox_common_linux::TaskParams { + pid: process_id, + ppid: parent_process_id, + uid, + euid, + gid, + egid, + blocked_signals, + ignored_signals, + inherited_fds: Some(inherited_fds), + cwd: Some(cwd), + umask: Some(umask), + }, + path, + argv, + envp, + ) + } + None => { + // The program path is a Unix-style path inside the tar archive. + let prog_path = cli_args + .program_and_arguments + .first() + .context("program path missing")? + .clone(); + let argv = cli_args + .program_and_arguments + .iter() + .map(|x| std::ffi::CString::new(x.bytes().collect::>()).unwrap()) + .collect(); + let envp: Vec<_> = cli_args + .environment_variables + .iter() + .map(|x| std::ffi::CString::new(x.bytes().collect::>()).unwrap()) + .collect(); + let envp = if cli_args.forward_environment_variables { + envp.into_iter() + .chain(std::env::vars().map(|(k, v)| { + std::ffi::CString::new( + k.bytes().chain(*b"=").chain(v.bytes()).collect::>(), + ) + .unwrap() + })) + .collect() + } else { + envp + }; + ( + litebox_common_linux::TaskParams { + pid: process_id, + ppid: 0, + uid: 1000, + gid: 1000, + euid: 1000, + egid: 1000, + blocked_signals: litebox_common_linux::signal::SigSet::empty(), + ignored_signals: litebox_common_linux::signal::SigSet::empty(), + inherited_fds: None, + cwd: None, + umask: None, + }, + prog_path, + argv, + envp, + ) + } }; let program = shim - .load_program( - litebox_common_linux::TaskParams { - pid: process_id, - ppid: 0, - uid: 1000, - gid: 1000, - euid: 1000, - egid: 1000, - blocked_signals: litebox_common_linux::signal::SigSet::empty(), - ignored_signals: litebox_common_linux::signal::SigSet::empty(), - inherited_fds: None, - cwd: None, - umask: None, - }, - initial_thread, - prog_path, - argv, - envp, - ) + .load_program(task_params, initial_thread, &prog_path, argv, envp) .unwrap(); + run_program(&shim, program) +} + +/// Runs the loaded `program` until it exits, then exits this runner. +fn run_program( + shim: &litebox_shim_linux::LinuxShim, + program: litebox_shim_linux::LoadedProgram, +) -> ! { unsafe { litebox_platform_windows_userland::run_thread( program.entrypoints, &mut litebox_common_linux::PtRegs::default(), ); } + // Report the guest status for the parent to observe. If the report fails, + // the broker falls back to the runner's host exit status. + let _ = shim + .litebox() + .report_exit_status(program.process.wait_for_exit_status()); std::process::exit(program.process.wait()) } diff --git a/litebox_runner_linux_on_windows_userland/tests/loader.rs b/litebox_runner_linux_on_windows_userland/tests/loader.rs index 12951fe57..b04b4c65b 100644 --- a/litebox_runner_linux_on_windows_userland/tests/loader.rs +++ b/litebox_runner_linux_on_windows_userland/tests/loader.rs @@ -171,6 +171,71 @@ fn test_programs_with_windows_broker() { run_prog_with_windows_broker(&broker, &runner, "hello_thread", &DYNAMIC_LIBS, None); } +/// Runs `fork_parent`, whose children resume from snapshots of their parents' memory. +/// +/// Built with `gcc -static -m64` from `litebox_runner_linux_userland/tests/fork_parent.c`. +#[test] +fn test_fork_with_windows_broker() { + let (broker, runner) = build_windows_broker(); + let output = run_progs_with_process_duplication(&broker, &runner, &["fork_parent"], &[]); + + let parent = output_line(&output, "parent "); + assert_eq!(numeric_field(parent, "global="), 2); + assert_eq!(numeric_field(parent, "intact="), 1); + assert_eq!(numeric_field(parent, "echild="), 1); + let fork = output_line(&output, "fork "); + let child = numeric_field(fork, "child="); + assert_ne!(child, numeric_field(parent, "pid=")); + assert_eq!(numeric_field(fork, "waited="), child); + // The child and its own forked child passed every check. + assert_eq!(numeric_field(fork, "code="), 7); + assert_eq!(numeric_field(output_line(&output, "pipe "), "failures="), 0); + assert_eq!(numeric_field(output_line(&output, "raw-fork "), "code="), 9); +} + +/// Runs `fork_threads_parent`, which forks while sibling threads run guest code, block, and +/// sleep, and forks from several threads at once. +/// +/// Built with `gcc -static -m64` from `litebox_runner_linux_userland/tests/fork_threads_parent.c`. +#[test] +fn test_fork_with_threads_with_windows_broker() { + let (broker, runner) = build_windows_broker(); + let output = + run_progs_with_process_duplication(&broker, &runner, &["fork_threads_parent"], &[]); + + let line = output_line(&output, "threads-fork "); + assert_eq!(numeric_field(line, "code="), 7); + assert_eq!(numeric_field(line, "failures="), 0); + assert_eq!(numeric_field(line, "read="), 1); + assert_eq!(numeric_field(line, "slept="), 1); +} + +/// Runs `vfork_exec_parent`, whose child execs `vfork_exec_child` in a new runner. +/// +/// Built with `gcc -static -m64` from `litebox_runner_linux_userland/tests/vfork_exec_*.c`. +#[test] +fn test_vfork_exec_with_windows_broker() { + let (broker, runner) = build_windows_broker(); + let output = run_progs_with_process_duplication( + &broker, + &runner, + &["vfork_exec_parent", "vfork_exec_child"], + &["/bin/vfork_exec_child.hooked"], + ); + + let parent = output_line(&output, "parent "); + let child = numeric_field(parent, "child="); + assert_eq!(numeric_field(parent, "waited="), child); + assert_eq!(numeric_field(parent, "code="), 42); + let child_line = output_line(&output, "child "); + assert_eq!(numeric_field(child_line, "pid="), child); + assert_eq!( + numeric_field(child_line, "ppid="), + numeric_field(parent, "before=") + ); + assert!(child_line.contains("marker=from-vfork env=1")); +} + const DYNAMIC_LIBS: [(&str, &str); 2] = [ ("libc.so.6", "/lib/x86_64-linux-gnu"), ("ld-linux-x86-64.so.2", "/lib64"), @@ -256,6 +321,64 @@ fn run_prog_with_windows_broker( assert!(status.success(), "litebox-broker-userland failed: {status}"); } +/// Runs the first of `programs` with `arguments` and process duplication allowed, and returns its +/// standard output. Each program is at `/bin/.hooked`. +fn run_progs_with_process_duplication( + broker: &std::path::Path, + runner: &std::path::Path, + programs: &[&str], + arguments: &[&str], +) -> String { + let test_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/test-bins"); + let tar_path = + std::path::Path::new(env!("OUT_DIR")).join(format!("broker_{}_rootfs.tar", programs[0])); + let mut tar = tar::Builder::new(std::fs::File::create(&tar_path).unwrap()); + for program in programs { + append_rewritten_file( + &mut tar, + &test_dir.join(program), + &format!("bin/{program}.hooked"), + ); + } + tar.finish().unwrap(); + drop(tar); + + let output = std::process::Command::new(broker) + .args([ + "--unstable", + "--allow-process-duplication", + "--fs-initial-files", + ]) + .arg(&tar_path) + .arg("--runner") + .arg(runner) + .arg(format!("/bin/{}.hooked", programs[0])) + .args(arguments) + .stderr(std::process::Stdio::inherit()) + .output() + .expect("failed to run litebox-broker-userland"); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + output.status.success(), + "litebox-broker-userland failed: {}\n{stdout}", + output.status + ); + stdout +} + +fn output_line<'a>(output: &'a str, prefix: &str) -> &'a str { + output + .lines() + .find(|line| line.starts_with(prefix)) + .unwrap_or_else(|| panic!("missing {prefix:?} output in {output:?}")) +} + +fn numeric_field(line: &str, name: &str) -> i32 { + line.split_whitespace() + .find_map(|field| field.strip_prefix(name)?.parse().ok()) + .unwrap_or_else(|| panic!("missing {name} in {line:?}")) +} + fn append_rewritten_file( tar: &mut tar::Builder, source: &std::path::Path, diff --git a/litebox_runner_linux_on_windows_userland/tests/test-bins/fork_parent b/litebox_runner_linux_on_windows_userland/tests/test-bins/fork_parent new file mode 100644 index 000000000..ae949bd47 Binary files /dev/null and b/litebox_runner_linux_on_windows_userland/tests/test-bins/fork_parent differ diff --git a/litebox_runner_linux_on_windows_userland/tests/test-bins/fork_threads_parent b/litebox_runner_linux_on_windows_userland/tests/test-bins/fork_threads_parent new file mode 100644 index 000000000..b6b58dfa1 Binary files /dev/null and b/litebox_runner_linux_on_windows_userland/tests/test-bins/fork_threads_parent differ diff --git a/litebox_runner_linux_on_windows_userland/tests/test-bins/vfork_exec_child b/litebox_runner_linux_on_windows_userland/tests/test-bins/vfork_exec_child new file mode 100644 index 000000000..49adf4f5f Binary files /dev/null and b/litebox_runner_linux_on_windows_userland/tests/test-bins/vfork_exec_child differ diff --git a/litebox_runner_linux_on_windows_userland/tests/test-bins/vfork_exec_parent b/litebox_runner_linux_on_windows_userland/tests/test-bins/vfork_exec_parent new file mode 100644 index 000000000..29c2d7836 Binary files /dev/null and b/litebox_runner_linux_on_windows_userland/tests/test-bins/vfork_exec_parent differ diff --git a/litebox_runner_windows_userland/src/lib.rs b/litebox_runner_windows_userland/src/lib.rs index 974c88c0f..e27a71cfe 100644 --- a/litebox_runner_windows_userland/src/lib.rs +++ b/litebox_runner_windows_userland/src/lib.rs @@ -70,6 +70,7 @@ pub fn run(cli_args: CliArgs) -> Result { let broker::BrokerConnection { local, notifications, + process_image: _, } = connection; let (litebox, process_id, initial_thread) = litebox::LiteBox::new_process_with_broker_local(platform, local);