feat(kernel): shared ABI decoder+proofs, no-panic gate, syscall storm, kani on shrunk kernel

This commit is contained in:
2026-10-26 23:44:09 +01:00
parent 154599e3af
commit 130a82f50d
41 changed files with 1583 additions and 404 deletions

157
crates/kernel/src/abi.rs Normal file
View File

@@ -0,0 +1,157 @@
//! The syscall register ABI (docs/ipc.md), as pure functions so it can be
//! tested and proved: registers in, `Syscall` out; result out, registers
//! in. Architecture adapters only move registers in and out of a frame.
use domain::{message::Message, syscall::Syscall};
use types::{
address::{DataRef, EntryPoint, StackPointer, VirtualAddress},
badge::Badge,
bounded::{CapSlot, PageCount},
permission::PermissionSet,
syscall_abi::*,
};
use crate::syscall::{SyscallResult, error_code};
fn slot(r: usize) -> Option<CapSlot> {
reg_slot(r)?
}
fn pages(r: usize) -> Option<PageCount> {
PageCount::new(u16::try_from(r).ok()?)
}
fn permissions(r: usize) -> Option<PermissionSet> {
Some(PermissionSet::from_bits(u8::try_from(r).ok()?))
}
/// The message in `a1..a5`.
fn message(a: &[usize; 6]) -> Option<Message> {
let msg = Message::from_words([a[1], a[2], a[3], a[4]]);
Some(match reg_slot(a[5])? {
None => msg,
Some(cap) => msg.with_cap(cap),
})
}
/// Decodes syscall `nr` with arguments `a0..a5`. `None` for an unknown
/// number or an argument with no meaning (e.g. a slot out of range); the
/// caller then gets `UnknownSyscall` and nothing else happens.
pub fn decode(nr: usize, a: &[usize; 6]) -> Option<Syscall> {
Some(match nr {
SYS_EXIT => Syscall::Exit,
SYS_HALT => Syscall::Halt,
SYS_EXIT_THREAD => Syscall::ExitThread,
SYS_YIELD => Syscall::Yield,
SYS_GET_TIME => Syscall::GetTime,
SYS_GET_PROCESS_ID => Syscall::GetProcessId,
SYS_SEND => Syscall::Send {
endpoint: slot(a[0])?,
message: message(a)?,
},
SYS_CALL => Syscall::Call {
endpoint: slot(a[0])?,
message: message(a)?,
},
SYS_RECEIVE => Syscall::Receive {
endpoint: slot(a[0])?,
},
SYS_REPLY => Syscall::Reply {
message: message(a)?,
},
SYS_REPLY_RECV => Syscall::ReplyRecv {
endpoint: slot(a[0])?,
message: message(a)?,
},
SYS_DELEGATE => Syscall::Delegate {
slot: slot(a[0])?,
to_endpoint: slot(a[1])?,
permissions: permissions(a[2])?,
},
SYS_MINT => Syscall::Mint {
slot: slot(a[0])?,
badge: Badge::new(a[1]),
permissions: permissions(a[2])?,
},
SYS_CREATE_NOTIFICATION => Syscall::CreateNotification,
SYS_SIGNAL => Syscall::Signal {
notification: slot(a[0])?,
},
SYS_WAIT => Syscall::Wait {
notification: slot(a[0])?,
},
SYS_POLL => Syscall::Poll {
notification: slot(a[0])?,
},
SYS_BIND_NOTIFICATION => Syscall::BindNotification {
notification: slot(a[0])?,
},
SYS_SET_IRQ_NOTIFICATION => Syscall::SetIrqNotification {
irq: slot(a[0])?,
notification: slot(a[1])?,
},
SYS_ACKNOWLEDGE_INTERRUPT => Syscall::AcknowledgeInterrupt { irq: slot(a[0])? },
SYS_CREATE_PROCESS => Syscall::CreateProcess {
create_cap: slot(a[0])?,
image: DataRef::empty(),
},
SYS_KILL => Syscall::Kill {
process: slot(a[0])?,
},
SYS_REVOKE => Syscall::Revoke { slot: slot(a[0])? },
SYS_DROP_CAP => Syscall::DropCap { slot: slot(a[0])? },
SYS_SPAWN_THREAD => Syscall::SpawnThread {
entry: EntryPoint::from_raw(a[0]),
stack: StackPointer::from_raw(a[1]),
},
SYS_REQUEST_PAGES => Syscall::RequestPages {
count: pages(a[0])?,
},
SYS_RELEASE_PAGES => Syscall::ReleasePages {
addr: VirtualAddress::from_raw(a[0]),
count: pages(a[1])?,
},
SYS_SPAWN_THREAD_IN => Syscall::SpawnThreadIn {
process: slot(a[0])?,
entry: EntryPoint::from_raw(a[1]),
stack: StackPointer::from_raw(a[2]),
},
SYS_CREATE_ENDPOINT => Syscall::CreateEndpoint,
SYS_CREATE_REGION => Syscall::CreateRegion {
pages: pages(a[0])?,
},
SYS_MAP_REGION => Syscall::MapRegion {
region: slot(a[0])?,
va: VirtualAddress::from_raw(a[1]),
perms: reg_perms(a[2])?,
},
SYS_DEBUG_PUTCHAR => Syscall::DebugPutchar { byte: a[0] as u8 },
SYS_MAP_PAGE => Syscall::MapPage {
process: slot(a[0])?,
src_va: VirtualAddress::from_raw(a[1]),
dst_va: VirtualAddress::from_raw(a[2]),
executable: a[3] != 0,
},
_ => return None,
})
}
/// Result registers `a0..a7` for a completed syscall.
pub fn encode(result: &SyscallResult) -> [usize; 8] {
let mut r = [0usize; 8];
match result {
SyscallResult::Ok => {}
SyscallResult::Value(v) => r[1] = *v,
SyscallResult::Slot(s) => r[1] = s.value() as usize,
SyscallResult::Received(d) => r = delivery_regs(d),
SyscallResult::Error(e) => r[0] = error_code(e) as usize,
}
r
}
/// Registers for a syscall number that does not decode.
pub fn unknown_syscall() -> [usize; 8] {
let mut r = [0usize; 8];
r[0] = ErrorCode::UnknownSyscall as usize;
r
}

View File

@@ -101,8 +101,10 @@ impl<P: Platform> Kernel<P> {
let mut children = [false; ALL_CAPS];
self.for_each_cap(|i, _, cap| {
if cap.parent() == Some(me) {
children[i] = true;
if cap.parent() == Some(me)
&& let Some(c) = children.get_mut(i)
{
*c = true;
}
});
for (i, _) in children.iter().enumerate().filter(|(_, c)| **c) {
@@ -131,10 +133,12 @@ impl<P: Platform> Kernel<P> {
// a root rather than trusted.
let mut parents: [Option<u16>; ALL_CAPS] = [None; ALL_CAPS];
self.for_each_cap(|i, _, cap| {
parents[i] = cap
.parent()
.filter(|r| self.resolves(*r))
.map(|r| flat(r.process, r.slot) as u16);
if let Some(p) = parents.get_mut(i) {
*p = cap
.parent()
.filter(|r| self.resolves(*r))
.map(|r| flat(r.process, r.slot) as u16);
}
});
let root = flat(pid, slot);
let marked = descendants(&parents, root);

View File

@@ -1,5 +1,20 @@
#![no_std]
// A kernel panic takes the whole system down, so kernel code may not
// contain any way to panic. Tests and proofs are exempt.
#![cfg_attr(
not(any(test, kani)),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing
)
)]
pub mod abi;
mod captree;
pub mod interrupt;
mod memory;
@@ -136,11 +151,11 @@ impl<P: Platform> Kernel<P> {
}
pub fn pending_result(&self, thread: ThreadId) -> Option<&SyscallResult> {
self.pending_results[thread.raw() as usize].as_ref()
self.pending_results.get(thread.raw() as usize)?.as_ref()
}
pub fn take_pending_result(&mut self, thread: ThreadId) -> Option<SyscallResult> {
self.pending_results[thread.raw() as usize].take()
self.pending_results.get_mut(thread.raw() as usize)?.take()
}
pub fn set_thread_running(
@@ -268,10 +283,8 @@ impl<P: Platform> Kernel<P> {
{
Ok(p) => {
let mut ids = [None; MAX_THREADS_PER_PROCESS];
for (i, tid) in p.thread_ids().enumerate() {
if i < MAX_THREADS_PER_PROCESS {
ids[i] = Some(tid);
}
for (slot, tid) in ids.iter_mut().zip(p.thread_ids()) {
*slot = Some(tid);
}
ids
}
@@ -292,19 +305,19 @@ impl<P: Platform> Kernel<P> {
for tid in thread_ids.iter().flatten() {
self.endpoints.remove_thread_from_all(*tid);
self.notifications.remove_thread_from_all(*tid);
let ipc = core::mem::take(&mut self.ipc[tid.raw() as usize]);
let ipc = self.ipc_mut(*tid).map(core::mem::take).unwrap_or_default();
if let Some(caller) = ipc.reply_to {
self.release_caller(caller);
}
self.scheduler.remove(*tid);
self.pending_results[tid.raw() as usize] = None;
self.set_pending_result(*tid, None);
let _ = self.threads.destroy(*tid);
}
}
/// Destroys every endpoint `owner` created, waking anyone blocked on
/// them with an error instead of leaving them blocked forever.
fn destroy_endpoints_owned_by(&mut self, owner: ProcessId) {
pub(crate) fn destroy_endpoints_owned_by(&mut self, owner: ProcessId) {
let mut owned = [None; domain::endpoint::MAX_ENDPOINTS];
for (slot, id) in owned.iter_mut().zip(self.endpoints.ids_owned_by(owner)) {
*slot = Some(id);
@@ -330,8 +343,10 @@ impl<P: Platform> Kernel<P> {
let Some(mut n) = self.notifications.destroy(id) else {
continue;
};
if let Some(bound) = n.bound() {
self.ipc[bound.raw() as usize].bound = None;
if let Some(bound) = n.bound()
&& let Some(s) = self.ipc_mut(bound)
{
s.bound = None;
}
while let Some(tid) = n.pop_waiter() {
self.wake_with_error(tid, KernelError::NoSuchObject);
@@ -342,7 +357,9 @@ impl<P: Platform> Kernel<P> {
/// Makes a blocked thread runnable with `error` as its syscall result,
/// dropping whatever it was about to send.
fn wake_with_error(&mut self, tid: ThreadId, error: KernelError) {
self.ipc[tid.raw() as usize].outgoing = None;
if let Some(s) = self.ipc_mut(tid) {
s.outgoing = None;
}
self.wake(tid, SyscallResult::Error(error));
}

View File

@@ -124,10 +124,14 @@ impl<P: Platform> Kernel<P> {
}
pub(crate) fn create_address_space(&mut self, pid: ProcessId) -> Result<(), KernelError> {
self.quotas[pid.raw() as usize] = Quota::default();
let quota = self
.quotas
.get_mut(pid.raw() as usize)
.ok_or(KernelError::InvalidProcess(pid))?;
*quota = Quota::default();
let mut source = ChargedFrames {
frames: &mut self.frames,
quota: &mut self.quotas[pid.raw() as usize],
quota,
owner: pid,
};
let created = self
@@ -136,7 +140,9 @@ impl<P: Platform> Kernel<P> {
.map_err(map_error);
if created.is_err() {
self.frames.free_owned_by(pid);
self.quotas[pid.raw() as usize] = Quota::default();
if let Some(q) = self.quotas.get_mut(pid.raw() as usize) {
*q = Quota::default();
}
}
created
}

View File

@@ -0,0 +1,75 @@
use crate::abi::{decode, encode};
use crate::syscall::SyscallResult;
use domain::error::KernelError;
use domain::syscall::Syscall;
use types::bounded::CapSlot;
use types::syscall_abi::*;
/// No register contents can make decoding panic or overflow.
#[kani::proof]
fn decode_is_total() {
let nr: usize = kani::any();
let args: [usize; 6] = kani::any();
let _ = decode(nr, &args);
}
/// A decoded slot argument is exactly the register it came from.
#[kani::proof]
fn decoded_slots_match_their_registers() {
let args: [usize; 6] = kani::any();
if let Some(Syscall::Receive { endpoint }) = decode(SYS_RECEIVE, &args) {
assert!(endpoint.value() as usize == args[0]);
}
if let Some(Syscall::Delegate {
slot, to_endpoint, ..
}) = decode(SYS_DELEGATE, &args)
{
assert!(slot.value() as usize == args[0]);
assert!(to_endpoint.value() as usize == args[1]);
}
}
/// What libkos encodes for a Send is decoded back to the same message.
#[kani::proof]
fn send_roundtrips() {
let ep = CapSlot::new(kani::any::<u8>() % (CapSlot::MAX + 1)).unwrap();
let words: [usize; 4] = kani::any();
let cap = if kani::any() {
CapSlot::new(kani::any::<u8>() % (CapSlot::MAX + 1))
} else {
None
};
let regs = message_regs(ep.value() as usize, words, cap);
match decode(SYS_SEND, &regs) {
Some(Syscall::Send { endpoint, message }) => {
assert!(endpoint == ep);
assert!(*message.words() == words);
assert!(message.cap() == cap);
}
_ => panic!("valid send did not decode"),
}
}
/// A failed syscall always reports a real, non-zero error code.
#[kani::proof]
fn errors_encode_to_valid_codes() {
let e = match kani::any::<u8>() % 6 {
0 => KernelError::NoReply,
1 => KernelError::OutOfMemory,
2 => KernelError::InvalidAddress,
3 => KernelError::WrongObject,
4 => KernelError::NoSuchObject,
_ => KernelError::InvalidSlot(CapSlot::new(0).unwrap()),
};
let regs = encode(&SyscallResult::Error(e));
assert!(regs[0] != 0);
assert!(ErrorCode::from_raw(regs[0]).is_some());
}
#[kani::proof]
fn successes_report_status_zero() {
let v: usize = kani::any();
assert!(encode(&SyscallResult::Ok)[0] == 0);
let regs = encode(&SyscallResult::Value(v));
assert!(regs[0] == 0 && regs[1] == v);
}

View File

@@ -0,0 +1,118 @@
//! Mint creates no authority. The real kernel, with tables shrunk under
//! `cfg(kani)`, runs `mint` with arbitrary arguments in a small state
//! whose capabilities carry arbitrary rights. Afterwards every capability
//! to an object that already existed carries no more rights than some
//! capability to that object carried before.
//!
//! Only this harness is kept. The same check over delegate, delete,
//! revoke, create and kill either exceeded 12 GB in CBMC or tripped
//! modelling artifacts ("unreachable code" on safe `Option` matches that
//! an exhaustive host replay never reaches). Those operations are covered
//! by the syscall storm's global invariants (tests/storm.rs) and the
//! capability-tree model test (tests/captree_model.rs) instead.
use super::platform::KaniPlatform;
use crate::Kernel;
use domain::{capability::MAX_CAPS_PER_PROCESS, process::MAX_PROCESSES, resource::Resource};
use types::{
address::PhysicalAddress,
badge::Badge,
bounded::{CapSlot, Priority},
id::ProcessId,
permission::PermissionSet,
time::TimeSlice,
};
#[derive(Clone, Copy, PartialEq)]
struct Held {
resource: Resource,
perms: PermissionSet,
badge: Badge,
}
type Snapshot = [[Option<Held>; MAX_CAPS_PER_PROCESS]; MAX_PROCESSES];
fn snapshot(k: &Kernel<KaniPlatform>) -> Snapshot {
let mut s = [[None; MAX_CAPS_PER_PROCESS]; MAX_PROCESSES];
for (p, row) in s.iter_mut().enumerate() {
if let Ok(process) = k.processes.get(ProcessId::from_raw(p as u16)) {
for (slot, cap) in process.capabilities().iter() {
row[slot.value() as usize] = Some(Held {
resource: *cap.resource(),
perms: *cap.permissions(),
badge: cap.badge(),
});
}
}
}
s
}
/// Whether `resource` named a live object before the operation.
fn existed(before: &Snapshot, resource: &Resource) -> bool {
before
.iter()
.flatten()
.flatten()
.any(|h| h.resource == *resource)
}
/// Some capability before the operation already granted at least `h`.
fn justified(before: &Snapshot, h: &Held) -> bool {
before.iter().flatten().flatten().any(|b| {
b.resource == h.resource
&& h.perms.is_subset_of(&b.perms)
&& (b.badge == h.badge || b.badge.is_none())
})
}
fn any_perms() -> PermissionSet {
PermissionSet::from_bits(kani::any::<u8>() & 0b1111)
}
fn any_slot() -> CapSlot {
CapSlot::new(kani::any::<u8>() % (CapSlot::MAX + 1)).unwrap()
}
/// Init and one more process, an endpoint, a notification, and caps with
/// arbitrary rights spread over both, including a cap to the other process.
fn small_world() -> Kernel<KaniPlatform> {
let mut k = Kernel::new(KaniPlatform, PhysicalAddress::from_raw(0x8400_0000), 6);
let (init, _) = k.boot(Priority::DEFAULT, TimeSlice::DEFAULT).unwrap();
let b = k.create_process_internal().unwrap();
k.spawn_thread_internal(b).unwrap();
let ep = k.create_endpoint(init).unwrap();
let n = k.create_notification(b).unwrap();
k.grant_capability(init, Resource::Endpoint(ep), any_perms())
.unwrap();
k.grant_capability(init, Resource::Process(b), any_perms())
.unwrap();
k.grant_capability(b, Resource::Endpoint(ep), any_perms())
.unwrap();
k.grant_capability(b, Resource::Notification(n), any_perms())
.unwrap();
k
}
/// The caller is concrete: a symbolic pid turns every table lookup into a
/// symbolic index and blows CBMC's formula up past 30 GB.
#[kani::proof]
#[kani::unwind(8)]
fn mint_creates_no_authority() {
let mut k = small_world();
let (slot, badge, perms) = (any_slot(), Badge::new(kani::any()), any_perms());
let before = snapshot(&k);
let _ = k.mint(ProcessId::from_raw(0), slot, badge, perms);
let after = snapshot(&k);
for (p, row) in after.iter().enumerate() {
for (s, held) in row.iter().enumerate() {
let Some(h) = held else { continue };
if before[p][s] == Some(*h) {
continue; // untouched
}
if existed(&before, &h.resource) {
assert!(justified(&before, h), "authority appeared from nowhere");
}
}
}
}

View File

@@ -25,7 +25,7 @@ fn kernel_delegate_path_never_escalates() {
let original_perms = *cap.permissions();
let restricted = original_perms.restrict_to(&PermissionSet::from_bits(restrict_bits));
let delegated = Capability::new(cap.resource().clone(), restricted, Generation::initial());
let delegated = Capability::new(*cap.resource(), restricted, Generation::initial());
let mut target_table = CapabilityTable::new();
let target_slot = insert_cap(&mut target_table, delegated);
@@ -69,7 +69,7 @@ fn kernel_chained_delegate_never_escalates() {
.restrict_to(&PermissionSet::from_bits(restrict1_bits));
let mut table_b = CapabilityTable::new();
let cap_b = Capability::new(resource.clone(), perms_for_b, Generation::initial());
let cap_b = Capability::new(resource, perms_for_b, Generation::initial());
let slot_b = insert_cap(&mut table_b, cap_b);
let cap_in_b = table_b.get(slot_b).unwrap();

View File

@@ -38,7 +38,7 @@ fn handle_receive_rejects_without_read_permission() {
#[kani::proof]
fn missing_cap_always_rejected() {
let slot_val: u8 = kani::any();
kani::assume(slot_val <= 63);
kani::assume(slot_val <= CapSlot::MAX);
let slot = CapSlot::new(slot_val).unwrap();
let table = CapabilityTable::new();

View File

@@ -1,6 +1,9 @@
mod abi;
mod authority;
mod delegation;
mod mediation;
mod message;
mod platform;
mod revocation;
use domain::capability::{Capability, CapabilityTable};

View File

@@ -0,0 +1,62 @@
//! A platform with no hardware behind it, for running the real kernel
//! under Kani. Page tables cost one frame per address space.
use platform::{FrameSource, MapError, Platform};
use types::{
address::{EntryPoint, PhysicalAddress, StackPointer, VirtualAddress},
id::{CoreId, IrqId, ProcessId},
mapping::PagePerms,
time::{Ticks, TimerDuration},
};
pub struct KaniPlatform;
impl Platform for KaniPlatform {
const USER_VA_START: usize = 0x1000;
const USER_VA_END: usize = 0x0200_0000;
fn create_address_space(
&mut self,
_process: ProcessId,
frames: &mut dyn FrameSource,
) -> Result<(), MapError> {
frames.alloc_table_frame().ok_or(MapError::OutOfFrames)?;
Ok(())
}
fn destroy_address_space(&mut self, _process: ProcessId) {}
fn map_page(
&mut self,
_process: ProcessId,
_va: VirtualAddress,
_pa: PhysicalAddress,
_perms: PagePerms,
_frames: &mut dyn FrameSource,
) -> Result<(), MapError> {
Ok(())
}
fn unmap_page(&mut self, _process: ProcessId, _va: VirtualAddress) {}
fn zero_frame(&mut self, _pa: PhysicalAddress) {}
fn enable_user_mode(&mut self, _entry: EntryPoint, _stack: StackPointer) -> ! {
kani::assume(false);
loop {}
}
fn set_timer(&mut self, _duration: TimerDuration) {}
fn current_ticks(&self) -> Ticks {
Ticks::from_raw(0)
}
fn mask_irq(&mut self, _irq: IrqId) {}
fn unmask_irq(&mut self, _irq: IrqId) {}
fn send_ipi(&mut self, _target: CoreId) {}
fn core_id(&self) -> CoreId {
CoreId::from_raw(0)
}
fn num_cores(&self) -> u8 {
1
}
fn halt(&self) -> ! {
// Halting ends the run; there is no state after it to check.
kani::assume(false);
loop {}
}
fn debug_putchar(&self, _byte: u8) {}
}

View File

@@ -15,7 +15,7 @@ impl<P: Platform> Kernel<P> {
// travel with it; only the permissions narrow.
let copy = match self.check_cap(caller_pid, slot, types::permission::Permission::Delegate) {
Ok(cap) => domain::capability::Capability::new(
cap.resource().clone(),
*cap.resource(),
cap.permissions().restrict_to(&restrict),
*cap.generation(),
)

View File

@@ -56,6 +56,20 @@ fn rw_delegate() -> PermissionSet {
}
impl<P: Platform> Kernel<P> {
pub(crate) fn ipc(&self, tid: ThreadId) -> Option<&IpcState> {
self.ipc.get(tid.raw() as usize)
}
pub(crate) fn ipc_mut(&mut self, tid: ThreadId) -> Option<&mut IpcState> {
self.ipc.get_mut(tid.raw() as usize)
}
pub(crate) fn set_pending_result(&mut self, tid: ThreadId, result: Option<SyscallResult>) {
if let Some(r) = self.pending_results.get_mut(tid.raw() as usize) {
*r = result;
}
}
fn thread_ref(&self, tid: ThreadId) -> Option<ThreadRef> {
Some(ThreadRef {
tid,
@@ -87,7 +101,7 @@ impl<P: Platform> Kernel<P> {
};
let priority = thread.priority();
if thread.unblock().is_ok() {
self.pending_results[tid.raw() as usize] = Some(result);
self.set_pending_result(tid, Some(result));
let core = self.platform.core_id();
self.scheduler.add(tid, priority, core);
}
@@ -170,7 +184,10 @@ impl<P: Platform> Kernel<P> {
_ => None,
};
if kind == DeliveryKind::Call {
self.ipc[receiver.raw() as usize].reply_to = self.thread_ref(sender);
let caller = self.thread_ref(sender);
if let Some(s) = self.ipc_mut(receiver) {
s.reply_to = caller;
}
}
Delivery {
kind,
@@ -237,7 +254,9 @@ impl<P: Platform> Kernel<P> {
if !queued {
return SyscallOutcome::Complete(SyscallResult::Error(KernelError::EndpointFull));
}
self.ipc[tid.raw() as usize].outgoing = Some(out);
if let Some(s) = self.ipc_mut(tid) {
s.outgoing = Some(out);
}
let reason = if is_call {
BlockReason::Calling(ep)
} else {
@@ -258,11 +277,11 @@ impl<P: Platform> Kernel<P> {
Ok(v) => v,
Err(e) => return SyscallOutcome::Complete(SyscallResult::Error(e)),
};
if drop_reply && let Some(pending) = self.ipc[tid.raw() as usize].reply_to.take() {
if drop_reply && let Some(pending) = self.ipc_mut(tid).and_then(|s| s.reply_to.take()) {
self.release_caller(pending);
}
if let Some(n) = self.ipc[tid.raw() as usize].bound
if let Some(n) = self.ipc(tid).and_then(|s| s.bound)
&& let Some(ntfn) = self.notifications.get_mut(n)
&& ntfn.has_bits()
{
@@ -273,7 +292,7 @@ impl<P: Platform> Kernel<P> {
let sender = self.endpoints.get_mut(ep).and_then(|e| e.dequeue_sender());
if let Some(sender) = sender {
let is_call = self.is_blocked_as(sender, |r| matches!(r, BlockReason::Calling(_)));
let Some(out) = self.ipc[sender.raw() as usize].outgoing.take() else {
let Some(out) = self.ipc_mut(sender).and_then(|s| s.outgoing.take()) else {
// A queued sender always has a message; treat a missing one
// as a sender that vanished.
return SyscallOutcome::Complete(SyscallResult::Error(KernelError::WouldBlock));
@@ -310,9 +329,9 @@ impl<P: Platform> Kernel<P> {
pid: ProcessId,
message: Message,
) -> Result<(), KernelError> {
let caller = self.ipc[tid.raw() as usize]
.reply_to
.take()
let caller = self
.ipc_mut(tid)
.and_then(|s| s.reply_to.take())
.ok_or(KernelError::NoReply)?;
let waiting = self.is_live(caller)
&& self.is_blocked_as(caller.tid, |r| *r == BlockReason::AwaitingReply);
@@ -343,7 +362,7 @@ impl<P: Platform> Kernel<P> {
if let Err(e) = self.endpoint_cap(pid, slot, Permission::Read) {
return SyscallOutcome::Complete(SyscallResult::Error(e));
}
if self.ipc[tid.raw() as usize].reply_to.is_some() {
if self.ipc(tid).is_some_and(|s| s.reply_to.is_some()) {
let _ = self.reply(tid, pid, message);
}
self.receive(tid, pid, slot, false)
@@ -367,7 +386,7 @@ impl<P: Platform> Kernel<P> {
return Err(KernelError::InvalidArgument);
}
let minted = Capability::new(
source.resource().clone(),
*source.resource(),
source.permissions().restrict_to(&permissions),
*source.generation(),
)
@@ -475,7 +494,7 @@ impl<P: Platform> Kernel<P> {
slot: CapSlot,
) -> Result<(), KernelError> {
let (id, _, _) = self.notification_cap(pid, slot, Permission::Read)?;
if self.ipc[tid.raw() as usize].bound.is_some() {
if self.ipc(tid).is_none_or(|s| s.bound.is_some()) {
return Err(KernelError::InvalidArgument);
}
let ntfn = self
@@ -485,7 +504,9 @@ impl<P: Platform> Kernel<P> {
if !ntfn.bind(tid) {
return Err(KernelError::InvalidArgument);
}
self.ipc[tid.raw() as usize].bound = Some(id);
if let Some(s) = self.ipc_mut(tid) {
s.bound = Some(id);
}
Ok(())
}

View File

@@ -12,6 +12,7 @@ use types::{bounded::CapSlot, id::ThreadId, syscall_abi::ErrorCode};
use crate::Kernel;
pub use ipc::{IpcState, ThreadRef};
pub use types::syscall_abi::{Delivery, DeliveryKind};
pub enum SyscallOutcome {
Complete(SyscallResult),
@@ -21,38 +22,6 @@ pub enum SyscallOutcome {
TerminateCaller,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliveryKind {
/// A `Send`; no reply expected.
Message,
/// A `Call`; the receiver's reply slot now names the caller.
Call,
/// Notification bits.
Signal,
}
/// What a receiving syscall hands back (docs/ipc.md, register ABI).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Delivery {
pub kind: DeliveryKind,
/// The sender's badge, or the signal bits.
pub badge: usize,
pub words: [usize; 4],
/// Where a transferred capability landed in the receiver's table.
pub cap: Option<CapSlot>,
}
impl Delivery {
pub fn signal(bits: usize) -> Self {
Self {
kind: DeliveryKind::Signal,
badge: bits,
words: [0; 4],
cap: None,
}
}
}
#[derive(Debug)]
pub enum SyscallResult {
Ok,

View File

@@ -0,0 +1,99 @@
use crate::abi::{decode, encode, unknown_syscall};
use crate::syscall::SyscallResult;
use domain::syscall::Syscall;
use types::syscall_abi::*;
/// Every syscall number the ABI defines decodes with ordinary arguments,
/// so the decoder cannot silently drop one.
#[test]
fn every_defined_syscall_decodes() {
let numbers = [
SYS_SEND,
SYS_RECEIVE,
SYS_DELEGATE,
SYS_ACKNOWLEDGE_INTERRUPT,
SYS_CREATE_PROCESS,
SYS_KILL,
SYS_REVOKE,
SYS_SPAWN_THREAD,
SYS_EXIT_THREAD,
SYS_REQUEST_PAGES,
SYS_RELEASE_PAGES,
SYS_GET_TIME,
SYS_GET_PROCESS_ID,
SYS_YIELD,
SYS_EXIT,
SYS_HALT,
SYS_CREATE_ENDPOINT,
SYS_DEBUG_PUTCHAR,
SYS_MAP_PAGE,
SYS_SPAWN_THREAD_IN,
SYS_DROP_CAP,
SYS_CALL,
SYS_REPLY,
SYS_REPLY_RECV,
SYS_MINT,
SYS_CREATE_NOTIFICATION,
SYS_SIGNAL,
SYS_WAIT,
SYS_POLL,
SYS_BIND_NOTIFICATION,
SYS_SET_IRQ_NOTIFICATION,
SYS_CREATE_REGION,
SYS_MAP_REGION,
];
// slot 1, slot 2, small values, no cap
let args = [1, 2, 1, 0, 0, NONE];
for nr in numbers {
assert!(decode(nr, &args).is_some(), "syscall {nr} does not decode");
}
}
#[test]
fn retired_and_unknown_numbers_do_not_decode() {
for nr in [3, 5, 35, 1000, usize::MAX] {
assert!(decode(nr, &[0; 6]).is_none(), "{nr} decoded");
}
}
#[test]
fn out_of_range_arguments_do_not_decode() {
assert!(decode(SYS_RECEIVE, &[64, 0, 0, 0, 0, 0]).is_none());
assert!(
decode(SYS_SEND, &[1, 0, 0, 0, 0, 64]).is_none(),
"bad cap slot"
);
assert!(
decode(SYS_REQUEST_PAGES, &[0, 0, 0, 0, 0, 0]).is_none(),
"zero pages"
);
assert!(decode(SYS_REQUEST_PAGES, &[1025, 0, 0, 0, 0, 0]).is_none());
assert!(
decode(SYS_MAP_REGION, &[1, 0x30000, 3, 0, 0, 0]).is_none(),
"bad perms"
);
assert!(
decode(SYS_DELEGATE, &[1, 2, 256, 0, 0, 0]).is_none(),
"perm bits overflow u8"
);
}
#[test]
fn none_means_no_capability() {
match decode(SYS_SEND, &[1, 9, 8, 7, 6, NONE]) {
Some(Syscall::Send { message, .. }) => {
assert_eq!(message.cap(), None);
assert_eq!(message.words(), &[9, 8, 7, 6]);
}
_ => panic!("send did not decode"),
}
}
#[test]
fn results_follow_the_status_convention() {
assert_eq!(encode(&SyscallResult::Ok), [0; 8]);
assert_eq!(encode(&SyscallResult::Value(7))[..2], [0, 7]);
let err = encode(&SyscallResult::Error(domain::error::KernelError::NoReply));
assert_eq!(err[0], ErrorCode::NoReply as usize);
assert_eq!(unknown_syscall()[0], ErrorCode::UnknownSyscall as usize);
}

View File

@@ -144,6 +144,27 @@ impl Kernel<MockPlatform> {
}
}
/// Whether `slot` of `pid` holds a capability whose object is gone.
pub fn is_dead_cap_for_test(&self, pid: ProcessId, slot: CapSlot) -> bool {
let Ok(p) = self.processes.get(pid) else {
return false;
};
p.capabilities()
.get(slot)
.is_ok_and(|c| self.resource_generation(c.resource()) != Some(*c.generation()))
}
/// Whether `slot` of `pid` holds a capability to a process that is gone.
pub fn is_dead_process_cap_for_test(&self, pid: ProcessId, slot: CapSlot) -> bool {
let Ok(p) = self.processes.get(pid) else {
return false;
};
match p.capabilities().get(slot).map(|c| *c.resource()) {
Ok(Resource::Process(target)) => !self.process_exists(target),
_ => false,
}
}
/// Which slots of `pid`'s capability table are occupied.
pub fn cap_slots_for_test(&self, pid: ProcessId) -> [bool; 64] {
let mut used = [false; 64];

View File

@@ -1,6 +1,7 @@
mod helpers;
mod mock_platform;
mod abi;
mod capability;
mod captree_model;
mod exit;
@@ -13,6 +14,7 @@ mod lifetimes;
mod memory;
mod revocation;
mod simple;
mod storm;
mod tick;
pub use helpers::{kernel_with_process, test_kernel};

View File

@@ -0,0 +1,429 @@
//! Global kernel invariants, and a storm of random syscalls with random
//! (mostly garbage) arguments decoded through the real ABI. The kernel must
//! never panic and every invariant must hold after every single call.
extern crate std;
use std::format;
use std::string::String;
use super::*;
use crate::syscall::ThreadRef;
use domain::memory::{RegionBacking, frame::FrameUse};
use domain::thread::{BlockReason, MAX_THREADS, ThreadState};
use platform::Platform;
use types::address::DataRef;
use types::bounded::CapSlot;
use types::id::ThreadId;
use types::syscall_abi::*;
impl Kernel<MockPlatform> {
fn live_threads(&self) -> impl Iterator<Item = ThreadId> + '_ {
(0..MAX_THREADS as u16)
.map(ThreadId::from_raw)
.filter(|t| self.threads.get(*t).is_ok())
}
/// Every invariant the kernel promises, over every live object.
pub fn check_invariants(&self) -> Result<(), String> {
// ---- memory ----
let mut charged = 0;
for p in 0..domain::process::MAX_PROCESSES as u16 {
let pid = ProcessId::from_raw(p);
let usage = self.page_usage(pid);
charged += usage;
let owned = self
.frames
.iter()
.filter(|(_, u)| {
matches!(u, FrameUse::User { owner, .. } | FrameUse::PageTable { owner } if *owner == pid)
})
.count();
let regions: usize = self
.regions
.iter()
.filter(|(_, r)| r.owner() == pid && r.backing() == RegionBacking::Pool)
.map(|(_, r)| r.pages().value() as usize)
.sum();
if usage != owned + regions {
return Err(format!(
"quota of {pid:?} is {usage}, owns {owned}+{regions}"
));
}
if !self.process_exists(pid) && (usage != 0 || self.platform.mapping_count(pid) != 0) {
return Err(format!("dead {pid:?} still holds memory"));
}
}
if charged + self.free_page_count() != self.frames.capacity() {
return Err(String::from("frames leaked or double counted"));
}
let window = MockPlatform::USER_VA_START..MockPlatform::USER_VA_END;
for (pid, va, pa) in self.platform.mappings() {
if !window.contains(&va) {
return Err(format!("{pid:?} mapped outside the window at {va:#x}"));
}
let v = types::address::VirtualAddress::from_raw(va);
let user = self.frames.find_user(pid, v).map(|(p, _)| p.raw());
let region = self.regions.covering(pid, v).and_then(|id| {
let r = self.regions.get(id)?;
let m = r.mappings().find(|m| m.process == pid)?;
Some(r.base().raw() + (va - m.va.raw()))
});
if user != Some(pa) && region != Some(pa) {
return Err(format!("{pid:?} has unrecorded mapping {va:#x}"));
}
}
for (pa, usage) in self.frames.iter() {
if let FrameUse::User { owner, page, .. } = usage
&& self.platform.mapping(*owner, *page as usize * 4096) != Some(pa.raw())
{
return Err(String::from("recorded user page is not mapped"));
}
}
// ---- capabilities ----
for p in 0..domain::process::MAX_PROCESSES as u16 {
let pid = ProcessId::from_raw(p);
let Ok(process) = self.processes.get(pid) else {
continue;
};
for (slot, cap) in process.capabilities().iter() {
if let Some(parent) = cap.parent()
&& !self.resolves(parent)
{
return Err(format!("{pid:?}/{slot:?} has a dangling parent"));
}
}
}
for (_, region) in self.regions.iter() {
for m in region.mappings() {
if let Some(via) = m.via
&& (!self.resolves(via) || via.process != m.process)
{
return Err(String::from("mapping outlived its capability"));
}
}
}
// ---- queues hold only live threads blocked on that very object ----
let blocked_as = |t: ThreadId, want: BlockReason| {
self.threads.get(t).ok().map(|x| *x.state()) == Some(ThreadState::Blocked(want))
};
for ep in self.endpoints.iter() {
let id = *ep.id();
for t in ep.senders() {
if !blocked_as(t, BlockReason::Sending(id))
&& !blocked_as(t, BlockReason::Calling(id))
{
return Err(format!(
"{t:?} queued as a sender on {id:?} but not blocked there"
));
}
}
for t in ep.receivers() {
if !blocked_as(t, BlockReason::Receiving(id)) {
return Err(format!(
"{t:?} queued as a receiver on {id:?} but not blocked there"
));
}
}
}
for n in self.notifications.iter() {
for t in n.waiters() {
if !blocked_as(t, BlockReason::WaitingNotification(n.id())) {
return Err(format!(
"{t:?} waits on {:?} but is not blocked there",
n.id()
));
}
}
if let Some(b) = n.bound()
&& self.ipc.get(b.raw() as usize).and_then(|s| s.bound) != Some(n.id())
{
return Err(format!(
"{:?} bound to {b:?} which does not know it",
n.id()
));
}
}
// ---- threads, IPC, scheduling ----
for t in self.live_threads() {
let Ok(thread) = self.threads.get(t) else {
continue;
};
let state = *thread.state();
if !self.process_exists(thread.process()) {
return Err(format!("{t:?} outlived its process"));
}
let ipc = self.ipc[t.raw() as usize];
let in_endpoint = |e: types::id::EndpointId| {
self.endpoints
.get(e)
.map(|ep| (ep.is_sender(t), ep.is_receiver(t)))
};
match state {
ThreadState::Blocked(BlockReason::Sending(e) | BlockReason::Calling(e)) => {
if in_endpoint(e) != Some((true, false)) || ipc.outgoing.is_none() {
return Err(format!("{t:?} blocked sending but not queued"));
}
}
ThreadState::Blocked(BlockReason::Receiving(e)) => {
if in_endpoint(e) != Some((false, true)) {
return Err(format!("{t:?} blocked receiving but not queued"));
}
}
ThreadState::Blocked(BlockReason::WaitingNotification(n)) => {
if !self.notifications.get(n).is_some_and(|x| x.is_waiter(t)) {
return Err(format!("{t:?} waits on a notification it is not queued on"));
}
}
ThreadState::Blocked(BlockReason::AwaitingReply) => {
let me = ThreadRef {
tid: t,
generation: self
.threads
.generation(t)
.unwrap_or(types::generation::Generation::initial()),
};
let holders = self
.live_threads()
.filter(|s| self.ipc[s.raw() as usize].reply_to == Some(me))
.count();
if holders != 1 {
return Err(format!("{t:?} awaits a reply held by {holders} threads"));
}
}
_ => {
if ipc.outgoing.is_some() {
return Err(format!("{t:?} runnable with a queued message"));
}
}
}
if self.scheduler.is_queued(t) != matches!(state, ThreadState::Ready) {
return Err(format!("{t:?} in {state:?} has wrong run-queue membership"));
}
if let Some(r) = ipc.reply_to
&& self.threads.generation(r.tid) == Some(r.generation)
&& self.threads.get(r.tid).ok().map(|x| *x.state())
!= Some(ThreadState::Blocked(BlockReason::AwaitingReply))
{
return Err(format!("{t:?} holds a reply for a thread not waiting"));
}
}
Ok(())
}
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
/// An argument that is usually meaningful and sometimes garbage.
fn arg(&mut self) -> usize {
match self.below(9) {
0..=2 => self.below(8), // small slots and counts
8 => self.below(3), // the first few slots
3 => NONE,
4 => 0x40000 + self.below(64) * 4096, // heap-ish addresses
5 => 0x10000 + self.below(32) * 4096,
6 => self.below(70), // slots just past the end
_ => self.next() as usize, // anything at all
}
}
}
/// Sets up a fresh round: a hub process owning the shared endpoints,
/// notification and region; two children with two threads each; and caps
/// to all shared objects for init and the children.
fn new_round(k: &mut Kernel<MockPlatform>, init: ProcessId, init_thread: ThreadId) {
use domain::resource::Resource;
let all = types::permission::PermissionSet::empty()
.with(types::permission::Permission::Read)
.with(types::permission::Permission::Write)
.with(types::permission::Permission::Execute)
.with(types::permission::Permission::Delegate);
// init's caps to anything that died last round would fill its table.
for slot in 1..=CapSlot::MAX {
let slot = CapSlot::new(slot).unwrap();
if k.is_dead_cap_for_test(init, slot) {
let _ = k.delete_cap(init, slot);
}
}
let Some(hub) = k.create_process_internal() else {
if std::env::var("STORM_DEBUG").is_ok() {
std::eprintln!("no hub: free pages {}", k.free_page_count());
}
return;
};
let (Ok(e0), Ok(e1), Ok(n)) = (
k.create_endpoint(hub),
k.create_endpoint(hub),
k.create_notification(hub),
) else {
return;
};
let region = k
.create_pool_region(hub, types::bounded::PageCount::new(2).unwrap())
.ok();
let mut members = std::vec![init];
for _ in 0..2 {
k.switch_to_thread_for_test(init_thread);
if let crate::SyscallOutcome::Complete(crate::SyscallResult::Slot(cap)) =
k.handle_syscall(domain::syscall::Syscall::CreateProcess {
create_cap: CapSlot::new(0).unwrap(),
image: DataRef::empty(),
})
{
let pid = k.process_of_cap_for_test(cap);
for _ in 0..2 {
k.spawn_thread_for_test(pid, Priority::DEFAULT, TimeSlice::DEFAULT);
}
members.push(pid);
} else if std::env::var("STORM_DEBUG").is_ok() {
std::eprintln!(
"create child failed; init thread state {:?}",
k.thread_state_for_test(init_thread)
);
}
}
for pid in members {
for ep in [e0, e1] {
let _ = k.grant_capability(pid, Resource::Endpoint(ep), all);
}
let _ = k.grant_capability(pid, Resource::Notification(n), all);
if let Some(r) = region {
let _ = k.grant_capability(pid, Resource::Memory(r), all);
}
}
}
#[test]
fn the_kernel_survives_a_syscall_storm() {
let mut total = [0usize; 36];
for seed in [17u64, 0xABCD_EF01, 4242, 0x1357_9BDF, 8] {
let (mut k, init) = kernel_with_process();
let init_thread = k.current_thread().unwrap();
k.spawn_thread_for_test(init, Priority::DEFAULT, TimeSlice::DEFAULT);
let mut rng = Rng(seed);
let mut executed = 0;
let mut rounds = 0;
let mut succeeded = [0usize; 36];
new_round(&mut k, init, init_thread);
let mut step = 0;
while executed < 4000 && step < 200_000 {
step += 1;
let runnable: std::vec::Vec<ThreadId> = k
.live_threads()
.filter(|t| {
matches!(
k.thread_state_for_test(*t),
Some(ThreadState::Ready | ThreadState::Running(_))
)
})
.collect();
if runnable.is_empty() || rng.below(300) == 0 {
// Kill everyone but init, hub included: destroying the shared
// objects wakes whoever was blocked on them.
for p in 1..domain::process::MAX_PROCESSES as u16 {
k.destroy_process(ProcessId::from_raw(p));
}
// init may be blocked on objects it made itself.
k.destroy_endpoints_owned_by(init);
k.destroy_notifications_owned_by(init);
k.check_invariants()
.unwrap_or_else(|e| panic!("seed {seed:#x} step {step} after teardown: {e}"));
new_round(&mut k, init, init_thread);
rounds += 1;
continue;
}
let tid = runnable[rng.below(runnable.len())];
let is_init = k.thread_process(tid) == Some(init);
let nr = rng.below(36);
// Halting stops the machine and init exiting ends the test.
if nr == SYS_HALT || (is_init && (nr == SYS_EXIT || nr == SYS_EXIT_THREAD)) {
continue;
}
let args: [usize; 6] = core::array::from_fn(|_| rng.arg());
// init keeps the capability it creates processes with.
if is_init && nr == SYS_DROP_CAP && args[0] == 0 {
continue;
}
let Some(syscall) = crate::abi::decode(nr, &args) else {
continue;
};
k.switch_to_thread_for_test(tid);
k.take_pending_result(tid);
let out = k.handle_syscall(syscall);
executed += 1;
if !matches!(
out,
crate::SyscallOutcome::Complete(crate::SyscallResult::Error(_))
) {
succeeded[nr] += 1;
}
k.check_invariants()
.unwrap_or_else(|e| panic!("seed {seed:#x} step {step} (syscall {nr}): {e}"));
assert!(
k.process_exists(init),
"seed {seed:#x}: init died at step {step}"
);
}
if std::env::var("STORM_STATS").is_ok() {
std::eprintln!(
"seed {seed:#x}: executed {executed} rounds {rounds} ok per syscall {succeeded:?}"
);
}
assert!(
executed >= 4000,
"seed {seed:#x}: only {executed} syscalls ran"
);
assert!(
rounds < executed / 4,
"seed {seed:#x}: rounds too short ({rounds} rounds)"
);
for (t, n) in total.iter_mut().zip(succeeded) {
*t += n;
}
}
let exercised = [
SYS_SEND,
SYS_RECEIVE,
SYS_CALL,
SYS_REPLY,
SYS_REPLY_RECV,
SYS_SIGNAL,
SYS_WAIT,
SYS_POLL,
SYS_BIND_NOTIFICATION,
SYS_MINT,
SYS_DELEGATE,
SYS_REVOKE,
SYS_DROP_CAP,
SYS_REQUEST_PAGES,
SYS_RELEASE_PAGES,
SYS_CREATE_REGION,
SYS_MAP_REGION,
SYS_KILL,
SYS_CREATE_PROCESS,
SYS_SPAWN_THREAD,
SYS_SPAWN_THREAD_IN,
];
for nr in exercised {
assert!(
total[nr] > 0,
"syscall {nr} never succeeded in the whole storm"
);
}
}