diff --git a/issues/build/a-thread-std-detaches-holds-its-place-in-the-process-for-good.md b/issues/build/a-thread-std-detaches-holds-its-place-in-the-process-for-good.md new file mode 100644 index 0000000000..7abe5d449f --- /dev/null +++ b/issues/build/a-thread-std-detaches-holds-its-place-in-the-process-for-good.md @@ -0,0 +1,23 @@ +--- +status: open +kind: defect +opened: 2026-10-04 +--- + +# A thread std detaches holds its place in the process for good + +The kernel keeps an exited thread in its process's table until +`SYS_THREAD_JOIN` collects it, and counts it against +`toyos_abi::syscall::MAX_THREADS` until then +(`kernel::proclife::spawn::Admit::Full`). std's `Thread` +(`rust/library/std/src/sys/thread/toyos.rs`) has no `Drop`, and a `JoinHandle` +dropped without `join` detaches, so nothing ever collects a thread std +detached: a program that detaches threads over its life has `thread::spawn` +refused once those that exited and those still running are +`MAX_THREADS - 1`, though not one of the exited ones runs. libc's +`pthread_detach` joins the thread itself once it has exited +(`userland/libc/src/pthread.rs`, `reap`); std has no such path. + +Owner: orchestrator. Exit: a thread std detaches is collected once it exits, +and a test that spawns and drops more than `MAX_THREADS` handles, each thread +exiting, is refused none of them. diff --git a/issues/kernel/a-process-faulting-in-128-gib-panics-the-kernel.md b/issues/kernel/a-process-faulting-in-128-gib-panics-the-kernel.md new file mode 100644 index 0000000000..3e1ea6ea87 --- /dev/null +++ b/issues/kernel/a-process-faulting-in-128-gib-panics-the-kernel.md @@ -0,0 +1,23 @@ +--- +status: open +kind: defect +opened: 2026-10-04 +--- + +# A process that faults in 128 GiB panics the kernel + +Every demand fault pushes the 2 MiB page it filled onto +`ProcessData::demand_pages` (`kernel/src/process.rs`, `handle_page_fault`), a +`Vec` of 24-byte `PageAlloc`s that nothing bounds but physical memory. The push +from 65,536 entries to 65,537 doubles it to a 3,145,728-byte allocation, past +`mm::MAX_HEAP_ALLOC` (2,093,056), where `KernelAllocator::alloc` asserts: one +unprivileged process touching 128 GiB of its own mappings panics the kernel. + +No machine this tree boots has that much memory — the T14's PMM manages +16,020 MiB — so it is unreached, not unreachable. The 24 bytes are +`size_of::()`, read off a build of this tree; the rest is +arithmetic over them. + +Owner: orchestrator. Exit: the ledger is held in pieces no larger than one +heap allocation, or a fault past a per-process bound is refused by name, and a +constant assertion ties whichever it is to `MAX_HEAP_ALLOC`. diff --git a/issues/kernel/a-processs-mapping-count-is-bounded-only-by-its-address-window.md b/issues/kernel/a-processs-mapping-count-is-bounded-only-by-its-address-window.md deleted file mode 100644 index 2eded65a61..0000000000 --- a/issues/kernel/a-processs-mapping-count-is-bounded-only-by-its-address-window.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-10-03 ---- - -# A process's mapping count is bounded only by its address window - -Every `mmap` adds a `Region` to the address space's map and an `MmapRegion` to -`ProcessData::mmap_regions` (`sys_mmap`, `kernel/src/syscall/vm.rs`), and -nothing bounds how many one process holds but the placement window -(`kernel/src/vma.rs`): 8 GiB to `STACK_BASE` in 2 MiB pages, 260,094 -placements with the 2 MiB guard and 520,188 `FIXED` ones without it. A -`PROT_NONE` mapping pins no physical page, so a loop of them costs the process -nothing and the kernel heap one record in each ledger. - -`mmap_regions` is a `Vec` of 40-byte records (`UserAddr`, `usize`, -`Option`). The push past 32,768 records grows it to 65,536, an -allocation of 2,621,440 bytes, past `mm::MAX_HEAP_ALLOC` (2,093,056), where -`KernelAllocator::alloc` (`kernel/src/mm/alloc.rs`) asserts: a kernel panic from -one unprivileged process. Below that point the records of many such processes -are kernel heap charged to nobody, and a heap that cannot grow answers `alloc` -with a null, which panics too. - -By reading, unmeasured: the 40 bytes are read off the struct, and the counts -are that arithmetic over `vma.rs`'s constants. - -Owner: orchestrator. Exit condition: `mmap` refuses by name the mapping past a -per-process bound, and a test that maps `PROT_NONE` until refused reads that -refusal and a live kernel; it reds today on the panic. diff --git a/issues/kernel/every-mmap-walks-every-region-under-both-locks.md b/issues/kernel/every-mmap-walks-every-region-under-both-locks.md new file mode 100644 index 0000000000..c1ddacfd23 --- /dev/null +++ b/issues/kernel/every-mmap-walks-every-region-under-both-locks.md @@ -0,0 +1,29 @@ +--- +status: open +kind: defect +opened: 2026-10-04 +--- + +# Every `mmap` walks every region its process holds, under both of its locks + +`sys_mmap` (`kernel/src/syscall/vm.rs`) does work in proportion to the regions +the calling process holds, with its process-data lock and its address-space +lock both taken: + +- after each placement, `peak_memory` is a sum over all of + `ProcessData::mmap_regions`; +- `Regions::find_gap` (`kernel/src/vma.rs`) walks the region map from the top + until a gap fits, which in a top-down fill is every region; +- the FIXED arm's `Regions::occupancy` filters every region below the end of + the range it asks about (`overlapping`'s `range(..end)`). + +`toyos_abi::syscall::MAX_REGIONS` (32,768) bounds each walk, and so the cost; +nothing makes it small. A fill to the bound is quadratic: one process mapping +`PROT_NONE` until refused took 70 s of a TCG guest +(`tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs`). And a sibling +thread's page fault spins on that address-space lock with interrupts off for +as long as a walk holds it. + +Owner: orchestrator. Exit: no walk of a process's regions is taken under +either lock in `sys_mmap` — the peak is a running total, and placement and +occupancy are logarithmic in the region count. diff --git a/kernel/pure/proclife/model.rs b/kernel/pure/proclife/model.rs index f2334e8039..97c181e11b 100644 --- a/kernel/pure/proclife/model.rs +++ b/kernel/pure/proclife/model.rs @@ -8,10 +8,6 @@ //! checking are about the order //! those happen in, and a model that only held the two states could not see //! one. -//! -//! A `BTreeMap` where the kernel has a `hashbrown::HashMap`: nothing here -//! depends on the order, and a model whose counter-example is different every -//! run is a model nobody can bisect. use alloc::collections::{BTreeMap, BTreeSet}; use alloc::string::String; @@ -62,6 +58,9 @@ impl Lifecycle for ModelProc { f(tid, at); } } + fn thread_count(&self) -> usize { + self.threads.len() + } fn node(&self) -> &Node { &self.node } diff --git a/kernel/pure/proclife/spawn.rs b/kernel/pure/proclife/spawn.rs index 354731ff1f..dcea4978c8 100644 --- a/kernel/pure/proclife/spawn.rs +++ b/kernel/pure/proclife/spawn.rs @@ -18,6 +18,7 @@ use crate::proclife::table::{Lifecycle, Processes}; use crate::proclife::Pid; +use toyos_abi::syscall::MAX_THREADS; /// Whether a new thread may join a process. #[must_use = "a refused spawn must answer its caller, not fall through"] @@ -30,6 +31,9 @@ pub enum Admit { /// Somebody owns this process's teardown. A thread admitted now would be /// invisible to their retire sweep. TearingDown, + /// The process holds [`MAX_THREADS`] threads. A thread that exits stays + /// one until it is joined, so only a join makes room. + Full, } impl Admit { @@ -70,6 +74,7 @@ fn admit(proc: Option<&P>) -> Admit { match proc { None => Admit::NoSuchProcess, Some(proc) if proc.tearing_down() => Admit::TearingDown, + Some(proc) if proc.thread_count() >= MAX_THREADS => Admit::Full, Some(_) => Admit::Yes, } } @@ -78,7 +83,7 @@ fn admit(proc: Option<&P>) -> Admit { mod tests { use super::*; use crate::proclife::model::World; - use crate::proclife::teardown; + use crate::proclife::{join, teardown, ThreadLocation}; #[test] fn a_live_process_admits_a_thread_at_both_moments() { @@ -117,6 +122,33 @@ mod tests { assert_eq!(admit_thread_insert(&world, pid), Admit::TearingDown); } + /// A zombie holds its place until a join collects it, so the collection is + /// what admits again. + #[test] + fn a_full_process_refuses_at_the_start_until_a_join_collects() { + let mut world = World::new(); + let pid = world.spawn_process(); + let last = (1..MAX_THREADS).map(|_| world.spawn_thread(pid)).last().unwrap(); + assert_eq!(admit_thread_start(&world, pid), Admit::Full); + world.set_location(pid, last, ThreadLocation::Zombie(0)); + assert_eq!(admit_thread_start(&world, pid), Admit::Full); + assert_eq!(join::collect_zombie(&mut world, pid, last), Ok(Some(0))); + assert_eq!(admit_thread_start(&world, pid), Admit::Yes); + } + + /// Two spawns pass the start one thread short of the bound; the one whose + /// sibling inserted first is refused at the insert. + #[cfg(not(feature = "mutate-spawn-skips-the-insert-recheck"))] + #[test] + fn the_insert_refuses_the_spawn_a_sibling_filled_the_process_under() { + let mut world = World::new(); + let pid = world.spawn_process(); + (2..MAX_THREADS).for_each(|_| _ = world.spawn_thread(pid)); + assert_eq!(admit_thread_start(&world, pid), Admit::Yes); + world.spawn_thread(pid); + assert_eq!(admit_thread_insert(&world, pid), Admit::Full); + } + #[cfg(feature = "mutate-spawn-skips-the-insert-recheck")] #[test] fn the_mutation_really_admits_into_a_claimed_teardown() { diff --git a/kernel/pure/proclife/table.rs b/kernel/pure/proclife/table.rs index 6add75acae..f5ce7e039a 100644 --- a/kernel/pure/proclife/table.rs +++ b/kernel/pure/proclife/table.rs @@ -7,11 +7,7 @@ //! of them — which is what makes the host model in `model.rs` a `BTreeMap` and //! not a simulated kernel. //! -//! `each_thread` and `each_pid` take a `&mut dyn FnMut` rather than answering an -//! iterator, because the kernel's two containers are a `hashbrown::HashMap` and -//! this module's model is a `BTreeMap`: an associated iterator type would put -//! both spellings in the trait for no decision's benefit. A caller that needs -//! an order sorts what it collected, and the two that do +//! A caller that needs an order sorts what it collected, and the two that do //! ([`crate::proclife::teardown::retire_set`] and [`crate::proclife::reap::finished_pids`]) say so. use crate::proclife::{Node, Pid, Pids, ThreadLocation, Tid}; @@ -51,6 +47,10 @@ pub trait Lifecycle { /// Every thread of this process, in whatever order the container has. fn each_thread(&self, f: &mut dyn FnMut(Tid, ThreadLocation)); + /// How many threads the process holds, zombies a join has not collected + /// among them. + fn thread_count(&self) -> usize; + /// Where this process stands in the tree. Read and written by /// [`crate::proclife::tree`] alone. fn node(&self) -> &Node; diff --git a/kernel/src/arch/aarch64/paging.rs b/kernel/src/arch/aarch64/paging.rs index 811580f35b..2943851f5e 100644 --- a/kernel/src/arch/aarch64/paging.rs +++ b/kernel/src/arch/aarch64/paging.rs @@ -517,6 +517,10 @@ impl AddressSpace { self.regions.insert(addr, region); } + pub fn has_region_room(&self) -> bool { + self.regions.has_room() + } + pub fn find_region(&self, addr: UserAddr) -> Option<(UserAddr, &Region)> { self.regions.find(addr) } diff --git a/kernel/src/arch/x86_64/paging.rs b/kernel/src/arch/x86_64/paging.rs index 137c4452a1..a91ddcd155 100644 --- a/kernel/src/arch/x86_64/paging.rs +++ b/kernel/src/arch/x86_64/paging.rs @@ -639,6 +639,10 @@ impl AddressSpace { self.regions.insert(addr, region); } + pub fn has_region_room(&self) -> bool { + self.regions.has_room() + } + /// Find the region containing `addr`. Returns (start_addr, region). pub fn find_region(&self, addr: UserAddr) -> Option<(UserAddr, &Region)> { self.regions.find(addr) diff --git a/kernel/src/id_map.rs b/kernel/src/id_map.rs index 90e03c9723..8c9621f681 100644 --- a/kernel/src/id_map.rs +++ b/kernel/src/id_map.rs @@ -1,14 +1,14 @@ -use core::hash::Hash; +use alloc::collections::BTreeMap; use core::ops::Add; -use crate::hasher::HashMap; /// Map with auto-incrementing, never-reused keys. pub struct IdMap { - map: HashMap, + /// A `BTreeMap`, whose nodes are fixed-size allocations: no count it holds grows one allocation towards `mm::MAX_HEAP_ALLOC`. + map: BTreeMap, next: K, } -pub trait IdKey: Copy + Eq + Hash + Ord + Add { +pub trait IdKey: Copy + Ord + Add { const ZERO: Self; const ONE: Self; } @@ -22,7 +22,7 @@ impl IdKey for toyos_abi::Tid { impl IdMap { pub fn new() -> Self { Self { - map: HashMap::default(), + map: BTreeMap::new(), next: K::ZERO, } } @@ -57,6 +57,10 @@ impl IdMap { self.map.remove(&id) } + pub fn len(&self) -> usize { + self.map.len() + } + pub fn iter(&self) -> impl Iterator { self.map.iter().map(|(&k, v)| (k, v)) } diff --git a/kernel/src/process.rs b/kernel/src/process.rs index 8788e7bfd8..b963957716 100644 --- a/kernel/src/process.rs +++ b/kernel/src/process.rs @@ -26,7 +26,7 @@ use crate::loader::{alloc_kernel_stack, thread_start, TlsBlock}; pub use toyos_abi::{Pid, Tid}; pub use crate::scheduler::TaskId; -use toyos_abi::syscall::{EndowEntry, SyscallError}; +use toyos_abi::syscall::{EndowEntry, SyscallError, MAX_LIBRARIES, MAX_REGIONS}; /// The lifecycle's decisions; this file only performs them. pub use kernel::proclife::{ThreadLocation, Watch}; @@ -401,6 +401,9 @@ impl Lifecycle for ProcessEntry { f(tid, thread.state); } } + fn thread_count(&self) -> usize { + self.threads.len() + } fn node(&self) -> &Node { &self.node } fn node_mut(&mut self) -> &mut Node { &mut self.node } } @@ -508,6 +511,9 @@ pub struct ElfInfo { pub lib_paths: Vec, } +// The ledger stops at `MAX_LIBRARIES`, and a doubling from below it stays inside one heap allocation. +const _: () = assert!(2 * MAX_LIBRARIES * core::mem::size_of::() <= crate::mm::MAX_HEAP_ALLOC); + impl ElfInfo { /// The state of a process with no ELF at all (a kernel thread). Not `Default`: `next_tls_module_id`'s only honest default is 1, not 0; written here, not at the one call site, so a field added to [`ElfInfo`] stops this build too. pub fn none() -> Self { @@ -658,6 +664,12 @@ pub struct MmapRegion { pub _pages: Option, } +// One region per record, and a power of two: the ledger's doubling stops at it, inside one heap allocation. +const _: () = assert!( + MAX_REGIONS.is_power_of_two() + && MAX_REGIONS * core::mem::size_of::() <= crate::mm::MAX_HEAP_ALLOC +); + /// Zero-sized proof of running on the per-CPU idle stack; required by `collect_orphan_zombies` so it never drops the thread entry it runs on. #[derive(Clone, Copy)] @@ -921,7 +933,7 @@ pub fn spawn_thread(entry: u64, stack_ptr: u64, arg: u64, stack_base: u64) -> Op // Not `is_yes()`: a missing entry here means this thread's own process was reaped out from under it, which panics rather than refuses. match proclife_spawn::admit_thread_start(table, parent_process) { proclife_spawn::Admit::Yes => {} - proclife_spawn::Admit::TearingDown => return None, + proclife_spawn::Admit::TearingDown | proclife_spawn::Admit::Full => return None, proclife_spawn::Admit::NoSuchProcess => { panic!("spawn_thread: pid {parent_process} is spawning and is not in the table") } diff --git a/kernel/src/syscall/vm.rs b/kernel/src/syscall/vm.rs index 1c6f0a5665..9c6edf668e 100644 --- a/kernel/src/syscall/vm.rs +++ b/kernel/src/syscall/vm.rs @@ -93,6 +93,10 @@ pub(super) fn sys_mmap(req_addr: u64, size: u64, prot: MmapProt, flags: MmapFlag // a partial overlap is refused — `map_range` would otherwise // assert on an already-present PDE. let replacing = match as_guard.occupancy(start, aligned as u64) { + // A replacement (`Whole`) grows no ledger and is never refused here. + Occupancy::Free if !as_guard.has_region_room() => { + return Err(SyscallError::ResourceExhausted) + } Occupancy::Free => None, Occupancy::Whole => { let mine = data @@ -349,6 +353,12 @@ pub(super) fn sys_dlopen(ctx: &crate::user_ptr::SyscallContext, path: &str, init } return idx as u64; } + // Asked here for the same reason, so a load past the bound is refused + // having registered nothing; its mapping goes down with the guard. + if data.elf.loaded_libs.len() >= MAX_LIBRARIES { + drop(data); + return SyscallError::ResourceExhausted.to_u64(); + } mapping.commit(); let idx = data.elf.loaded_libs.len(); diff --git a/kernel/src/vma.rs b/kernel/src/vma.rs index 394c7772c4..bcd8572edb 100644 --- a/kernel/src/vma.rs +++ b/kernel/src/vma.rs @@ -4,6 +4,7 @@ use alloc::sync::Arc; use crate::file_backing::FileBacking; use crate::mm::policy::Prot; use crate::mm::{UserAddr, PAGE_2M}; +use toyos_abi::syscall::MAX_REGIONS; use toyos_userbound::{PageSpan, Window}; /// The stack extends upward to the PIE base, so no usable VA space exists above it. @@ -74,10 +75,17 @@ impl Regions { window().gap(span, taken).map(UserAddr::new) } + pub fn has_room(&self) -> bool { + self.0.len() < MAX_REGIONS + } + /// Allocate a virtual address range and register the region. `size` is /// made a [`PageSpan`] before anything is summed on it. pub fn alloc(&mut self, size: u64, kind: RegionKind) -> Option { let span = window().span(size)?; + if !self.has_room() { + return None; + } let addr = self.find_gap(span)?; self.0.insert(addr, Region { size: span.bytes(), kind }); Some(addr) @@ -86,11 +94,8 @@ impl Regions { /// A [`RegionKind::Mapped`] region for `size` bytes: its address and its /// size in whole pages, which the caller maps. pub fn alloc_mapped(&mut self, size: u64) -> Option<(UserAddr, u64)> { - let span = window().span(size)?; - let addr = self.find_gap(span)?; - let aligned = span.bytes(); - self.0.insert(addr, Region { size: aligned, kind: RegionKind::Mapped }); - Some((addr, aligned)) + let addr = self.alloc(size, RegionKind::Mapped)?; + Some((addr, self.0[&addr].size)) } /// Unregister the region at `addr`, answering its size. @@ -98,9 +103,11 @@ impl Regions { Some(self.0.remove(&addr)?.size) } - /// Insert a region at a specific address (for ELF segments, stack, etc.) + /// Insert a region at a specific address (for ELF segments, stack, etc.); + /// a caller userland drives asks [`has_room`](Self::has_room) first. pub fn insert(&mut self, addr: UserAddr, region: Region) { assert!(self.find(addr).is_none(), "insert_region: address {:#x} already occupied", addr.raw()); + assert!(self.has_room(), "insert_region: {MAX_REGIONS} regions are registered already"); self.0.insert(addr, region); } diff --git a/src/kernelkeys.rs b/src/kernelkeys.rs index 4d28b91a29..b7a86be6fd 100644 --- a/src/kernelkeys.rs +++ b/src/kernelkeys.rs @@ -43,11 +43,6 @@ pub struct Declared { /// Every hashed container in `kernel/src`, with the origin of its keys. pub const DECLARED: &[Declared] = &[ - Declared { - file: "kernel/src/id_map.rs", - ty: "HashMap", - keys: "`IdKey`, which no integer implements: every key is an id this kernel issued", - }, Declared { file: "kernel/src/arch/x86_64/paging.rs", ty: "HashMap", diff --git a/tests/toyos-rust-tests/src/arch/mmap.rs b/tests/toyos-rust-tests/src/arch/mmap.rs new file mode 100644 index 0000000000..dc4aa7cb12 --- /dev/null +++ b/tests/toyos-rust-tests/src/arch/mmap.rs @@ -0,0 +1,35 @@ +//! `SYS_MMAP` past `toyos_abi::syscall::mmap`, which answers every refusal with +//! a null: here the kernel's error comes back by name. + +#[cfg(target_arch = "x86_64")] +pub use x86_64::*; + +#[cfg(target_arch = "x86_64")] +mod x86_64 { + use toyos_abi::syscall::{MmapFlags, MmapProt, SyscallError, SYS_MMAP}; + + /// The mapping's address, or the error the kernel refused it with. + /// + /// # Safety + /// A `FIXED` mapping replaces whatever this process has at `addr`. + pub unsafe fn raw(addr: u64, len: u64, prot: MmapProt, flags: MmapFlags) -> Result { + let ret: u64; + // SAFETY: the caller's for what a `FIXED` mapping replaces; no argument + // is a pointer this call dereferences, and the `syscall` is the ABI's + // (`toyos_abi::syscall`), which clobbers rax, rcx and r11. + unsafe { + core::arch::asm!( + "syscall", + in("rdi") SYS_MMAP, + in("rsi") addr, + in("rdx") len, + in("r8") prot.0, + in("r9") flags.0, + lateout("rax") ret, + out("rcx") _, + out("r11") _, + ); + } + SyscallError::from_u64(ret).map_or(Ok(ret), Err) + } +} diff --git a/tests/toyos-rust-tests/src/bin/abuse_dlopen_ledger.rs b/tests/toyos-rust-tests/src/bin/abuse_dlopen_ledger.rs new file mode 100644 index 0000000000..424464239d --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/abuse_dlopen_ledger.rs @@ -0,0 +1,84 @@ +//! A process's libraries are bounded at `MAX_LIBRARIES`: past it a load of a +//! name it does not hold is refused by name having registered nothing, and a +//! name it holds still answers. + +use toyos_abi::syscall::{self, SyscallError, MAX_LIBRARIES}; + +const DIR: &str = "/tmp/dlopen-ledger"; + +/// Loads attempted: past where a library ledger with no bound outgrows one +/// heap allocation. +const ATTEMPTS: usize = 4 * MAX_LIBRARIES; + +/// One `PT_LOAD`, read-only and exactly 2 MiB long, so the image has no +/// writable window and the shared-object cache keeps none of it: every load is +/// its own, and nothing of this test outlives it. +fn image() -> Vec { + const LEN: usize = 0x1000; + const SPAN: u64 = 2 * 1024 * 1024; + let mut elf = vec![0u8; LEN]; + elf[..8].copy_from_slice(&[0x7f, b'E', b'L', b'F', 2, 1, 1, 0]); + elf[16..18].copy_from_slice(&3u16.to_le_bytes()); // ET_DYN + elf[18..20].copy_from_slice(&62u16.to_le_bytes()); // EM_X86_64 + elf[20..24].copy_from_slice(&1u32.to_le_bytes()); // e_version + elf[32..40].copy_from_slice(&64u64.to_le_bytes()); // e_phoff + elf[52..54].copy_from_slice(&64u16.to_le_bytes()); // e_ehsize + elf[54..56].copy_from_slice(&56u16.to_le_bytes()); // e_phentsize + elf[56..58].copy_from_slice(&1u16.to_le_bytes()); // e_phnum + let ph = &mut elf[64..120]; + ph[0..4].copy_from_slice(&1u32.to_le_bytes()); // PT_LOAD + ph[4..8].copy_from_slice(&5u32.to_le_bytes()); // PF_R | PF_X + ph[32..40].copy_from_slice(&(LEN as u64).to_le_bytes()); // p_filesz + ph[40..48].copy_from_slice(&SPAN.to_le_bytes()); // p_memsz + ph[48..56].copy_from_slice(&0x1000u64.to_le_bytes()); // p_align + elf +} + +/// The libraries this process holds, the executable not among them. +fn libraries() -> usize { + let need = syscall::query_modules(&mut []).expect("the modules' size"); + let mut answer = vec![0u8; need]; + let got = syscall::query_modules(&mut answer).expect("the modules"); + syscall::modules(&answer[..got]).count() - 1 +} + +fn main() { + std::fs::create_dir_all(DIR).unwrap_or_else(|e| panic!("make {DIR}: {e}")); + let lib = format!("{DIR}/lib.so"); + std::fs::write(&lib, image()).unwrap_or_else(|e| panic!("write {lib}: {e}")); + let held = libraries(); + + let mut first = None; + let mut loaded = 0usize; + let mut refusal = None; + for i in 0..ATTEMPTS { + // A name of its own for one file: the ledger is keyed by name. + let name = format!("{DIR}/{i}.so"); + syscall::symlink(lib.as_bytes(), name.as_bytes()).unwrap_or_else(|e| panic!("link {name}: {e:?}")); + match syscall::dl_open(name.as_bytes()) { + Ok(handle) => { + first.get_or_insert((name, handle)); + loaded += 1; + } + Err(e) => { + refusal = Some(e); + break; + } + } + } + let refusal = refusal.unwrap_or_else(|| { + panic!("{loaded} names of one library all loaded: the library count is unbounded") + }); + assert_eq!( + refusal, + SyscallError::ResourceExhausted, + "the load after {loaded} was refused for another reason", + ); + assert_eq!(held + loaded, MAX_LIBRARIES, "refused after {loaded} loads beside the {held} it started with"); + assert_eq!(libraries(), MAX_LIBRARIES, "the refused load is registered"); + + let (name, handle) = first.expect("a library was loaded"); + assert_eq!(syscall::dl_open(name.as_bytes()), Ok(handle), "a name already held was refused at the bound"); + + println!("{loaded} libraries loaded and the next name refused; a name held still answers"); +} diff --git a/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs b/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs new file mode 100644 index 0000000000..4f51761949 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs @@ -0,0 +1,93 @@ +//! A process's regions are bounded at `MAX_REGIONS`: past it every way a +//! mapping enters the address space is refused by name, a replacement — which +//! adds no region — is not, and one region freed is room for one. + +#[path = "../arch/mmap.rs"] +mod mmap; + +use toyos_abi::syscall::{munmap, MmapFlags, MmapProt, SyscallError, MAX_REGIONS}; +use SyscallError::ResourceExhausted; + +const PAGE_2M: u64 = 2 * 1024 * 1024; + +/// The process's own regions — its segments, stack, TLS block and heap — that +/// the fill below never gets. +const OWN_REGIONS: usize = 64; + +/// The fill's first mappings, kept by address: one replaced and one freed at +/// the bound, the rest freed before anything is judged. +const KEPT: usize = 8; + +fn unmap(addr: u64) { + unsafe { munmap(addr as *mut u8, PAGE_2M as usize) } + .unwrap_or_else(|e| panic!("unmapping {addr:#x} was refused: {e:?}")); +} + +fn main() { + let anywhere = MmapFlags::ANONYMOUS | MmapFlags::PRIVATE; + let placed = anywhere | MmapFlags::FIXED; + let rw = MmapProt::READ | MmapProt::WRITE; + + let mut count = 0usize; + let mut kept = [0u64; KEPT]; + let mut lowest = u64::MAX; + let mut refusal = None; + for _ in 0..=MAX_REGIONS { + // SAFETY: not `FIXED`, so nothing of this process's is replaced. + match unsafe { mmap::raw(0, PAGE_2M, MmapProt::NONE, anywhere) } { + Ok(addr) => { + if let Some(slot) = kept.get_mut(count) { + *slot = addr; + } + lowest = lowest.min(addr); + count += 1; + } + Err(e) => { + refusal = Some(e); + break; + } + } + } + let refusal = refusal.unwrap_or_else(|| { + panic!("{count} PROT_NONE mappings were all placed: the region count is unbounded") + }); + + // Placement runs top-down, so nothing is registered below the fill. + let free = lowest - 2 * PAGE_2M; + // SAFETY: a `FIXED` mapping lands at `free`, where nothing is, or over + // `kept[0]`, which this process reads nothing through. + let (placed_free, anywhere_rw, replaced) = unsafe { + ( + mmap::raw(free, PAGE_2M, MmapProt::NONE, placed), + mmap::raw(0, PAGE_2M, rw, anywhere), + mmap::raw(kept[0], PAGE_2M, rw, placed), + ) + }; + unmap(kept[1]); + // SAFETY: as above. + let (placed_free_again, anywhere_rw_again) = + unsafe { (mmap::raw(free, PAGE_2M, MmapProt::NONE, placed), mmap::raw(0, PAGE_2M, rw, anywhere)) }; + placed_free_again.into_iter().chain(anywhere_rw_again).for_each(unmap); + // SAFETY: not `FIXED`. + let served = unsafe { mmap::raw(0, PAGE_2M, rw, anywhere) }; + // Room for this process's own heap, which a failed assertion's message needs. + served.into_iter().chain(kept[2..].iter().copied()).for_each(unmap); + + assert_eq!(refusal, ResourceExhausted, "the mapping after {count} was refused for another reason"); + assert!( + count > MAX_REGIONS - OWN_REGIONS, + "refused after {count} mappings, short of {MAX_REGIONS} by more than this process's own regions", + ); + assert_eq!(placed_free, Err(ResourceExhausted), "a placed mapping at a free address was not refused at the bound"); + assert_eq!(anywhere_rw, Err(ResourceExhausted), "an RW mapping was not refused at the bound"); + assert_eq!( + replaced, + Ok(kept[0]), + "a placed mapping over one of this process's own was refused at the bound, though it adds no region", + ); + assert_eq!(placed_free_again, Ok(free), "the placed mapping found no room a free region made"); + assert_eq!(anywhere_rw_again, Err(ResourceExhausted), "an RW mapping was placed past the bound"); + assert!(served.is_ok(), "an RW mapping found no room a free region made: {served:?}"); + + println!("{count} mappings placed and the next refused; at the bound a replacement maps, and a free region is room for one"); +} diff --git a/tests/toyos-rust-tests/src/bin/abuse_thread_table.rs b/tests/toyos-rust-tests/src/bin/abuse_thread_table.rs new file mode 100644 index 0000000000..679420e831 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/abuse_thread_table.rs @@ -0,0 +1,120 @@ +//! A process's threads are bounded at `MAX_THREADS`, exited ones not yet +//! joined among them: past it a spawn is refused by name, and a join is room +//! for one. + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::{Duration, Instant}; + +use toyos_abi::syscall::{self, MmapFlags, MmapProt, SyscallError, MAX_THREADS}; + +const PAGE_2M: usize = 2 * 1024 * 1024; + +/// One thread's stack. Each gets its own: a thread runs on after it says it +/// ran, so the next one never starts on a stack still in use. +const STACK: usize = 16 * 1024; + +/// Spawns attempted: past where a thread table with no bound outgrows one +/// heap allocation. +const ATTEMPTS: usize = 4 * MAX_THREADS; + +/// The hang ceiling on one thread getting to run. +const RUN_BOUND: Duration = Duration::from_secs(10); + +/// Raw, so a thread costs its kernel record and nothing of std's. +extern "C" fn body(ran: u64) { + // SAFETY: `ran` is the address of `main`'s flag, which outlives every thread. + let ran = unsafe { &*(ran as *const AtomicU32) }; + ran.store(1, Ordering::Release); + // SAFETY: an aligned `u32` the flag owns. + unsafe { syscall::futex_wake(ran.as_ptr(), 1) }; + syscall::thread_exit(0); +} + +/// Fresh stacks carved out of 2 MiB mappings. +struct Stacks { + chunk: usize, + used: usize, +} + +impl Stacks { + /// `(base, top)` of a stack no thread has run on. + fn next(&mut self) -> (u64, u64) { + if self.chunk == 0 || self.used == PAGE_2M { + let chunk = unsafe { + syscall::mmap( + core::ptr::null_mut(), + PAGE_2M, + MmapProt::READ | MmapProt::WRITE, + MmapFlags::ANONYMOUS | MmapFlags::PRIVATE, + ) + }; + assert!(!chunk.is_null(), "no memory for thread stacks"); + self.chunk = chunk as usize; + self.used = 0; + } + let base = (self.chunk + self.used) as u64; + self.used += STACK; + (base, base + STACK as u64) + } +} + +/// Start `body` on a fresh stack and wait for it to run; the refusal if the +/// kernel gave none. +fn spawn(stacks: &mut Stacks, ran: &AtomicU32) -> Result { + let (base, top) = stacks.next(); + ran.store(0, Ordering::Relaxed); + let tid = unsafe { syscall::thread_spawn(body as *const () as u64, top, ran as *const _ as u64, base) }; + if let Some(e) = SyscallError::from_u64(tid) { + return Err(e); + } + let deadline = Instant::now() + RUN_BOUND; + while ran.load(Ordering::Acquire) == 0 { + let left = deadline.checked_duration_since(Instant::now()).unwrap_or_else(|| { + panic!("thread {tid} did not run within {RUN_BOUND:?}"); + }); + unsafe { syscall::futex_wait(ran.as_ptr(), 0, Some(left.as_nanos() as u64)) }; + } + Ok(tid) +} + +fn main() { + let ran = AtomicU32::new(0); + let mut stacks = Stacks { chunk: 0, used: 0 }; + + let mut first = None; + let mut spawned = 0usize; + let mut refusal = None; + for _ in 0..ATTEMPTS { + match spawn(&mut stacks, &ran) { + Ok(tid) => { + first.get_or_insert(tid); + spawned += 1; + } + Err(e) => { + refusal = Some(e); + break; + } + } + } + let refusal = refusal.unwrap_or_else(|| { + panic!("{spawned} threads exited unjoined and none was refused: the thread count is unbounded") + }); + assert_eq!( + refusal, + SyscallError::ResourceExhausted, + "the spawn after {spawned} was refused for another reason", + ); + // This thread is the one more. + assert_eq!(spawned, MAX_THREADS - 1, "refused after {spawned} exited threads beside this one"); + + let first = first.expect("a thread was spawned"); + assert_eq!(syscall::thread_join(first), 0, "joining the first exited thread failed"); + spawn(&mut stacks, &ran).expect("a spawn found no room the join made"); + assert_eq!( + spawn(&mut stacks, &ran), + Err(SyscallError::ResourceExhausted), + "a spawn past the bound was admitted", + ); + + println!("{spawned} threads exited unjoined and the next was refused; a join is room for one"); +} diff --git a/tests/toyos-rust-tests/src/bin/mmap_prot.rs b/tests/toyos-rust-tests/src/bin/mmap_prot.rs index e592f4e703..4cb8b82f4f 100644 --- a/tests/toyos-rust-tests/src/bin/mmap_prot.rs +++ b/tests/toyos-rust-tests/src/bin/mmap_prot.rs @@ -25,7 +25,10 @@ use std::process::{Command, Stdio}; use std::sync::mpsc; use std::thread; -use toyos_abi::syscall::{mmap, munmap, MmapFlags, MmapProt, SyscallError, SYS_MMAP}; +use toyos_abi::syscall::{mmap, munmap, MmapFlags, MmapProt, SyscallError}; + +#[path = "../arch/mmap.rs"] +mod mmap; const SIZE: usize = 4096; /// Well inside the 2 MiB page every mapping is rounded up to. @@ -127,44 +130,23 @@ fn an_undefined_bit_in_either_word_is_refused() { (MmapProt(PROT.0 | UNDEFINED_PROT), FLAGS, "a prot"), (PROT, MmapFlags(FLAGS.0 | UNDEFINED_FLAG), "a flags"), ] { - let refused = mmap_raw(SIZE, prot, flags); + // SAFETY: not `FIXED`, so nothing of this process's is replaced. + let refused = unsafe { mmap::raw(0, SIZE as u64, prot, flags) }; assert_eq!( - SyscallError::from_u64(refused), - Some(SyscallError::InvalidArgument), - "{what} bit this ABI does not define was served: {refused:#x}", + refused, + Err(SyscallError::InvalidArgument), + "{what} bit this ABI does not define was served: {refused:#x?}", ); } // The control: the same request without either bit is the mapping the // refusals above must not have been about. - let served = mmap_raw(SIZE, PROT, FLAGS); - assert_eq!(SyscallError::from_u64(served), None, "the defined request was refused"); + // SAFETY: not `FIXED`. + let served = unsafe { mmap::raw(0, SIZE as u64, PROT, FLAGS) }.expect("the defined request was refused"); unsafe { munmap(served as *mut u8, SIZE) }.expect("unmap the served request"); println!(" PASS: an undefined mmap prot or flags bit is InvalidArgument, and without it the same call maps"); } -/// `syscall::mmap` reports a refusal as a null pointer, which cannot tell -/// `InvalidArgument` from any other error; the raw return can. -fn mmap_raw(size: usize, prot: MmapProt, flags: MmapFlags) -> u64 { - let ret: u64; - // SAFETY: a register-to-register `syscall`; no argument here is a pointer - // this call dereferences. - unsafe { - core::arch::asm!( - "syscall", - in("rdi") SYS_MMAP, - in("rsi") 0u64, - in("rdx") size as u64, - in("r8") prot.0, - in("r9") flags.0, - lateout("rax") ret, - out("rcx") _, - out("r11") _, - ); - } - ret -} - /// Lengths past every placement window. const UNPLACEABLE: [u64; 4] = [u64::MAX - PAGE_2M, u64::MAX - (PAGE_2M - 1), u64::MAX, 1 << 47]; @@ -204,7 +186,8 @@ fn a_length_no_window_holds_is_refused_and_the_space_still_answers() { let mut answers = Vec::with_capacity(2 * UNPLACEABLE.len()); for prot in [MmapProt::NONE, MmapProt::READ | MmapProt::WRITE] { for size in UNPLACEABLE { - answers.push((prot, size, mmap_raw(size as usize, prot, MmapFlags::ANONYMOUS | MmapFlags::PRIVATE))); + // SAFETY: not `FIXED`. + answers.push((prot, size, unsafe { mmap::raw(0, size, prot, MmapFlags::ANONYMOUS | MmapFlags::PRIVATE) })); } } @@ -215,9 +198,9 @@ fn a_length_no_window_holds_is_refused_and_the_space_still_answers() { for (prot, size, ret) in answers { assert_eq!( - SyscallError::from_u64(ret), - Some(SyscallError::InvalidArgument), - "mmap(len={size:#x}, prot={:#x}) answered {ret:#x}", + ret, + Err(SyscallError::InvalidArgument), + "mmap(len={size:#x}, prot={:#x}) answered {ret:#x?}", prot.0, ); } diff --git a/tests/toyos.rs b/tests/toyos.rs index 9e55a58846..e588713d14 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -114,6 +114,12 @@ const RUST_SKIP: &[&str] = &[ "readdir_bound", // Fills the VFS `created_dirs` cap and leaves it there. `mkdir_cap` runs it. "mkdir_cap", + // Each fills a bound of its own process — tens of thousands of mappings, + // thousands of threads, a thousand 2 MiB images — which no shared member's + // allowance is sized for: the `process_bound_*` metal rows run them. + "abuse_mmap_regions", + "abuse_thread_table", + "abuse_dlopen_ledger", // Audio is judged on the T14 and nowhere else: the `hda_client_stall`, // `hda_tone`, `audio_idle_suspend`, `shipped_client_departures` and // `soundserver_log_stall` metal rows run these. @@ -279,6 +285,18 @@ const METAL: &[(&str, metal::Metal)] = &[ judge: |b| b[0].job_passed("test_rs_readdir_bound"), }, ), + ( + "process_bound_regions", + metal::Metal { arms: BOUNDS, judge: |b| b[0].job_passed("test_rs_abuse_mmap_regions") }, + ), + ( + "process_bound_threads", + metal::Metal { arms: BOUNDS, judge: |b| b[0].job_passed("test_rs_abuse_thread_table") }, + ), + ( + "process_bound_libraries", + metal::Metal { arms: BOUNDS, judge: |b| b[0].job_passed("test_rs_abuse_dlopen_ledger") }, + ), ( "wake_storm_cost", metal::Metal { @@ -730,6 +748,15 @@ const TESTCASES_MKDIR: &[metal::Arm] = const TESTCASES_READDIR: &[metal::Arm] = &[metal::once("testcases-readdir", "tests/testcases", &[], &["test_rs_readdir_bound"])]; +/// One boot for the three, which can share it: each fills a bound of its own +/// process, and its exit gives all of it back. +const BOUNDS: &[metal::Arm] = &[metal::once( + "testcases-bounds", + "tests/testcases", + &[], + &["test_rs_abuse_mmap_regions", "test_rs_abuse_thread_table", "test_rs_abuse_dlopen_ledger"], +)]; + const JOBCASE: &[metal::Arm] = &[metal::once("jobcase", "tests/jobcase", &[], &[])]; /// The shipping kernel with the windows' instrument and nothing else, so what diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index 493b2833bd..455a46b965 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -940,6 +940,10 @@ pub fn mark_tty(handle: RawHandle) { syscall(SYS_MARK_TTY, handle.0 as u64, 0, 0, 0); } +/// Threads one process holds, its main thread and every exited one not yet +/// joined among them; [`thread_spawn`] past it is `ResourceExhausted`. +pub const MAX_THREADS: usize = 4096; + /// Spawn a new thread with the given entry point, stack pointer, argument, and stack base. /// `stack_base` is the bottom of the user stack (for stack info queries). /// @@ -2031,6 +2035,10 @@ pub fn readlink(path: &[u8], buf: &mut [u8]) -> Result { check(syscall(SYS_READLINK, path.as_ptr() as u64, path.len() as u64, buf.as_mut_ptr() as u64, buf.len() as u64)).map(|n| n as usize) } +/// Libraries one process holds, `DT_NEEDED` and loaded alike; [`dl_open`] of a +/// name it does not hold past it is `ResourceExhausted`. +pub const MAX_LIBRARIES: usize = 1024; + /// Load a shared library (.so) into the current process. /// Runs .init_array constructors after loading. pub fn dl_open(path: &[u8]) -> Result { @@ -2106,6 +2114,10 @@ pub fn cpu_count() -> u32 { syscall(SYS_CPU_COUNT, 0, 0, 0, 0) as u32 } +/// Regions one address space holds — every mapping, ELF segment, stack, TLS +/// block and library image; a placement past it is `ResourceExhausted`. +pub const MAX_REGIONS: usize = 32_768; + /// Map anonymous memory. Returns pointer on success, null on failure. /// /// If `addr` is null, the kernel chooses the address — and so it does for