feat(kernel): derivation-tree revocation, region caps

- caps record parent (mint/delegate/ipc), CapRef w/ pid+slot gens
- Revoke = delete subtree, keep self; drop/death re-parents children
- CreateRegion/MapRegion; mapping lives only as long as its cap
- drop Permission::Revoke + object-wide revoke
- mint any cap (badge only ep/ntfn), process caps delegable
- kani: descendants == subtree; model-based fuzz vs spec
This commit is contained in:
2026-10-26 20:41:03 +01:00
parent 76f10ff1f1
commit 154599e3af
32 changed files with 1302 additions and 239 deletions

View File

@@ -0,0 +1,168 @@
//! The capability derivation tree. Every derived capability (Mint,
//! Delegate, IPC transfer) records its parent; `Revoke` deletes a subtree
//! and deleting a capability re-parents its children, so the tree can only
//! shrink by explicit deletion, never by a middleman stepping out.
use domain::{
capability::{CapRef, Capability, MAX_CAPS_PER_PROCESS, derivation::descendants},
error::KernelError,
process::MAX_PROCESSES,
};
use platform::Platform;
use types::{
bounded::CapSlot,
id::{ProcessId, RegionId},
};
use crate::Kernel;
/// Every capability slot in the system, flattened.
const ALL_CAPS: usize = MAX_PROCESSES * MAX_CAPS_PER_PROCESS;
fn flat(pid: ProcessId, slot: CapSlot) -> usize {
pid.raw() as usize * MAX_CAPS_PER_PROCESS + slot.value() as usize
}
fn unflat(i: usize) -> Option<(ProcessId, CapSlot)> {
let pid = ProcessId::from_raw((i / MAX_CAPS_PER_PROCESS) as u16);
let slot = CapSlot::new((i % MAX_CAPS_PER_PROCESS) as u8)?;
Some((pid, slot))
}
impl<P: Platform> Kernel<P> {
/// A reference to the capability currently in `slot` of `pid`.
pub(crate) fn cap_ref(&self, pid: ProcessId, slot: CapSlot) -> Option<CapRef> {
let process = self.processes.get(pid).ok()?;
Some(CapRef {
process: pid,
process_generation: self.processes.generation(pid)?,
slot,
slot_generation: process.capabilities().slot_generation(slot)?,
})
}
/// Whether `r` still names a live capability.
pub(crate) fn resolves(&self, r: CapRef) -> bool {
self.cap_ref(r.process, r.slot) == Some(r)
}
/// Stores `cap` in `to`'s table as a child of `parent`.
pub(crate) fn insert_derived(
&mut self,
to: ProcessId,
cap: Capability,
parent: Option<CapRef>,
) -> Result<CapSlot, KernelError> {
self.processes
.get_mut(to)?
.capabilities_mut()
.insert(cap.with_parent(parent))
}
/// Calls `f` with every live capability's flat index and reference.
fn for_each_cap(&self, mut f: impl FnMut(usize, CapRef, &Capability)) {
for p in 0..MAX_PROCESSES {
let pid = ProcessId::from_raw(p as u16);
let Ok(process) = self.processes.get(pid) else {
continue;
};
for (slot, cap) in process.capabilities().iter() {
if let Some(r) = self.cap_ref(pid, slot) {
f(flat(pid, slot), r, cap);
}
}
}
}
/// Removes every region mapping made through `via`.
fn unmap_mappings_via(&mut self, via: CapRef) {
let mut doomed: [Option<RegionId>; domain::memory::MAX_REGIONS] =
[None; domain::memory::MAX_REGIONS];
for (slot, (id, _)) in doomed.iter_mut().zip(
self.regions
.iter()
.filter(|(_, r)| r.mappings().any(|m| m.via == Some(via))),
) {
*slot = Some(id);
}
for id in doomed.into_iter().flatten() {
self.unmap_region_from(id, via.process);
}
}
/// Removes one capability: its mappings go, its children are adopted
/// by its parent.
pub(crate) fn delete_cap(&mut self, pid: ProcessId, slot: CapSlot) -> Result<(), KernelError> {
let me = self
.cap_ref(pid, slot)
.ok_or(KernelError::InvalidSlot(slot))?;
let grandparent = self.processes.get(pid)?.capabilities().get(slot)?.parent();
self.unmap_mappings_via(me);
let mut children = [false; ALL_CAPS];
self.for_each_cap(|i, _, cap| {
if cap.parent() == Some(me) {
children[i] = true;
}
});
for (i, _) in children.iter().enumerate().filter(|(_, c)| **c) {
if let Some((cpid, cslot)) = unflat(i)
&& let Ok(p) = self.processes.get_mut(cpid)
&& let Ok(child) = p.capabilities_mut().get_mut(cslot)
{
child.set_parent(grandparent);
}
}
self.processes.get_mut(pid)?.capabilities_mut().remove(slot)
}
/// Deletes every capability derived from `slot` of `pid`, transitively,
/// and keeps `slot` itself.
pub(crate) fn revoke_subtree(
&mut self,
pid: ProcessId,
slot: CapSlot,
) -> Result<(), KernelError> {
if self.cap_ref(pid, slot).is_none() {
return Err(KernelError::InvalidSlot(slot));
}
// Snapshot the tree as parent indices. A parent that no longer
// resolves cannot exist (deletion re-parents), but is treated as
// 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);
});
let root = flat(pid, slot);
let marked = descendants(&parents, root);
for (i, _) in marked.iter().enumerate().filter(|(i, m)| **m && *i != root) {
if let Some((dpid, dslot)) = unflat(i)
&& let Some(r) = self.cap_ref(dpid, dslot)
{
self.unmap_mappings_via(r);
let _ = self
.processes
.get_mut(dpid)
.map(|p| p.capabilities_mut().remove(dslot));
}
}
Ok(())
}
/// Deletes all of a dying process's capabilities, re-parenting their
/// children so revocation still reaches them.
pub(crate) fn delete_all_caps(&mut self, pid: ProcessId) {
let mut slots = [None; MAX_CAPS_PER_PROCESS];
if let Ok(p) = self.processes.get(pid) {
for (out, (slot, _)) in slots.iter_mut().zip(p.capabilities().iter()) {
*out = Some(slot);
}
}
for slot in slots.into_iter().flatten() {
let _ = self.delete_cap(pid, slot);
}
}
}

View File

@@ -1,5 +1,6 @@
#![no_std]
mod captree;
pub mod interrupt;
mod memory;
#[cfg(kani)]
@@ -278,6 +279,7 @@ impl<P: Platform> Kernel<P> {
};
self.cleanup_threads(&thread_ids);
self.delete_all_caps(pid);
self.destroy_endpoints_owned_by(pid);
self.destroy_notifications_owned_by(pid);
self.teardown_memory(pid);
@@ -373,15 +375,6 @@ impl<P: Platform> Kernel<P> {
}
}
pub fn revoke_resource(&mut self, resource: &Resource) {
match resource {
Resource::Endpoint(id) => self.endpoints.revoke(*id),
Resource::Memory(id) => self.regions.revoke(*id),
Resource::Notification(id) => self.notifications.revoke(*id),
_ => {}
}
}
fn grant_cap(
&mut self,
to: ProcessId,

View File

@@ -334,12 +334,28 @@ impl<P: Platform> Kernel<P> {
}
/// Maps all of region `id` at `va` in `pid`, all or nothing.
/// Maps region `id` for boot code; the mapping is not tied to any
/// capability and lasts until the region or the process dies.
pub fn map_region(
&mut self,
id: RegionId,
pid: ProcessId,
va: VirtualAddress,
perms: PagePerms,
) -> Result<(), KernelError> {
self.map_region_via(id, pid, va, perms, None)
}
/// Maps all of region `id` at `va` in `pid`, all or nothing. A mapping
/// made through a capability (`via`) is removed when that capability
/// is deleted.
pub(crate) fn map_region_via(
&mut self,
id: RegionId,
pid: ProcessId,
va: VirtualAddress,
perms: PagePerms,
via: Option<domain::capability::CapRef>,
) -> Result<(), KernelError> {
self.processes.get(pid)?;
let region = self.regions.get(id).ok_or(KernelError::InvalidRegion)?;
@@ -366,6 +382,7 @@ impl<P: Platform> Kernel<P> {
process: pid,
va,
perms,
via,
})
});
if !recorded {
@@ -377,6 +394,19 @@ impl<P: Platform> Kernel<P> {
Ok(())
}
/// Removes `pid`'s mapping of region `id`, if it has one.
pub(crate) fn unmap_region_from(&mut self, id: RegionId, pid: ProcessId) {
let Some(region) = self.regions.get_mut(id) else {
return;
};
let pages = region.pages().value() as usize;
if let Some(m) = region.remove_mapping(pid) {
for i in 0..pages {
self.platform.unmap_page(pid, page_at(m.va, i));
}
}
}
/// Frees regions owned by `pid` (unmapping them from every other
/// process) and forgets `pid`'s mappings of other regions.
fn teardown_regions(&mut self, pid: ProcessId) {

View File

@@ -45,9 +45,6 @@ fn kernel_delegate_path_never_escalates() {
if !original_perms.allows(Permission::Delegate) {
assert!(!result.permissions().allows(Permission::Delegate));
}
if !original_perms.allows(Permission::Revoke) {
assert!(!result.permissions().allows(Permission::Revoke));
}
}
#[kani::proof]

View File

@@ -66,5 +66,4 @@ fn check_cap_faithfully_reflects_stored_permissions() {
assert!(
retrieved.permissions().allows(Permission::Delegate) == perms.allows(Permission::Delegate)
);
assert!(retrieved.permissions().allows(Permission::Revoke) == perms.allows(Permission::Revoke));
}

View File

@@ -42,10 +42,8 @@ impl<P: Platform> Kernel<P> {
Err(e) => return SyscallOutcome::Complete(SyscallResult::Error(e)),
};
let inserted = self
.processes
.get_mut(target_pid)
.and_then(|p| p.capabilities_mut().insert(copy));
let parent = self.cap_ref(caller_pid, slot);
let inserted = self.insert_derived(target_pid, copy, parent);
if let Err(e) = inserted {
return SyscallOutcome::Complete(SyscallResult::Error(e));
}
@@ -58,14 +56,10 @@ impl<P: Platform> Kernel<P> {
caller_pid: types::id::ProcessId,
slot: types::bounded::CapSlot,
) -> SyscallOutcome {
let resource = match self.check_cap(caller_pid, slot, types::permission::Permission::Revoke)
{
Ok(cap) => cap.resource().clone(),
Err(e) => return SyscallOutcome::Complete(SyscallResult::Error(e)),
};
self.revoke_resource(&resource);
SyscallOutcome::Complete(SyscallResult::Ok)
SyscallOutcome::Complete(match self.revoke_subtree(caller_pid, slot) {
Ok(()) => SyscallResult::Ok,
Err(e) => SyscallResult::Error(e),
})
}
pub(crate) fn handle_drop_cap(
@@ -73,11 +67,7 @@ impl<P: Platform> Kernel<P> {
caller_pid: types::id::ProcessId,
slot: types::bounded::CapSlot,
) -> SyscallOutcome {
let result = self
.processes
.get_mut(caller_pid)
.and_then(|p| p.capabilities_mut().remove(slot));
SyscallOutcome::Complete(match result {
SyscallOutcome::Complete(match self.delete_cap(caller_pid, slot) {
Ok(()) => SyscallResult::Ok,
Err(e) => SyscallResult::Error(e),
})

View File

@@ -150,12 +150,8 @@ impl<P: Platform> Kernel<P> {
.check_cap(from, slot, Permission::Delegate)
.ok()?
.clone();
self.processes
.get_mut(to)
.ok()?
.capabilities_mut()
.insert(copy)
.ok()
let parent = self.cap_ref(from, slot);
self.insert_derived(to, copy, parent).ok()
}
/// Hands `out` from `sender` to `receiver`. For a call, the receiver's
@@ -361,13 +357,13 @@ impl<P: Platform> Kernel<P> {
permissions: PermissionSet,
) -> Result<CapSlot, KernelError> {
let source = self.check_cap(pid, slot, Permission::Delegate)?;
if !matches!(
let badgeable = matches!(
source.resource(),
Resource::Endpoint(_) | Resource::Notification(_)
) {
return Err(KernelError::WrongObject);
}
if !source.badge().is_none() {
);
// Any capability can be copied with narrower rights; only those a
// receiver can observe the badge of take one, and only once.
if (!badgeable && !badge.is_none()) || !source.badge().is_none() {
return Err(KernelError::InvalidArgument);
}
let minted = Capability::new(
@@ -376,10 +372,8 @@ impl<P: Platform> Kernel<P> {
*source.generation(),
)
.with_badge(badge);
self.processes
.get_mut(pid)?
.capabilities_mut()
.insert(minted)
let parent = self.cap_ref(pid, slot);
self.insert_derived(pid, minted, parent)
}
pub(crate) fn handle_create_endpoint(&mut self, pid: ProcessId) -> SyscallOutcome {

View File

@@ -65,9 +65,12 @@ impl<P: Platform> Kernel<P> {
match self.grant_cap(
caller_pid,
domain::resource::Resource::Process(new_pid),
// The creator has full authority over what it creates,
// including handing that authority on.
types::permission::PermissionSet::empty()
.with(types::permission::Permission::Execute)
.with(types::permission::Permission::Read),
.with(types::permission::Permission::Read)
.with(types::permission::Permission::Delegate),
) {
Ok(slot) => SyscallOutcome::Complete(SyscallResult::Slot(slot)),
Err(e) => {

View File

@@ -75,4 +75,44 @@ impl<P: Platform> Kernel<P> {
.map(|()| SyscallResult::Ok),
)
}
pub(crate) fn create_region_cap(
&mut self,
pid: ProcessId,
pages: PageCount,
) -> Result<CapSlot, KernelError> {
let id = self.create_pool_region(pid, pages)?;
let all = types::permission::PermissionSet::empty()
.with(Permission::Read)
.with(Permission::Write)
.with(Permission::Execute)
.with(Permission::Delegate);
self.grant_cap(pid, Resource::Memory(id), all)
}
/// Maps the region behind `slot` into the caller. The capability must
/// allow every access `perms` grants; the mapping lasts as long as
/// the capability.
pub(crate) fn map_region_cap(
&mut self,
pid: ProcessId,
slot: CapSlot,
va: VirtualAddress,
perms: PagePerms,
) -> Result<(), KernelError> {
let cap = self.check_cap(pid, slot, Permission::Read)?;
let needed = match perms {
PagePerms::ReadOnly => Permission::Read,
PagePerms::ReadWrite => Permission::Write,
PagePerms::ReadExecute => Permission::Execute,
};
if !cap.permissions().allows(needed) {
return Err(KernelError::PermissionDenied { slot, needed });
}
let Resource::Memory(id) = *cap.resource() else {
return Err(KernelError::WrongObject);
};
let via = self.cap_ref(pid, slot);
self.map_region_via(id, pid, va, perms, via)
}
}

View File

@@ -116,6 +116,13 @@ impl<P: Platform> Kernel<P> {
Syscall::Kill { process } => self.handle_kill(pid, process),
Syscall::Revoke { slot } => self.handle_revoke(pid, slot),
Syscall::DropCap { slot } => self.handle_drop_cap(pid, slot),
Syscall::CreateRegion { pages } => {
complete(self.create_region_cap(pid, pages).map(SyscallResult::Slot))
}
Syscall::MapRegion { region, va, perms } => complete(
self.map_region_cap(pid, region, va, perms)
.map(|()| SyscallResult::Ok),
),
Syscall::Delegate {
slot,
to_endpoint,

View File

@@ -1,10 +1,9 @@
use super::*;
use crate::{SyscallOutcome, SyscallResult};
use domain::resource::Resource;
use domain::syscall::Syscall;
use types::address::DataRef;
use types::bounded::CapSlot;
use types::permission::{Permission, PermissionSet};
use types::permission::PermissionSet;
#[test]
fn delegate_without_delegate_permission_fails() {
@@ -57,147 +56,6 @@ fn delegate_with_valid_cap_succeeds() {
// --- Generation-based revocation ---
#[test]
fn revoke_resource_invalidates_all_caps() {
let (mut k, pid) = kernel_with_process();
let ep_id = k.create_endpoint_for_test();
let cap_slot = k.grant_cap_for_test(
pid,
Resource::Endpoint(ep_id),
PermissionSet::empty().with(Permission::Read),
);
// Revoke the endpoint
k.revoke_resource(&Resource::Endpoint(ep_id));
// Cap is now invalid — check_cap should reject with RevokedCapability
let outcome = k.handle_syscall(Syscall::Receive { endpoint: cap_slot });
assert!(matches!(
outcome,
SyscallOutcome::Complete(SyscallResult::Error(
domain::error::KernelError::RevokedCapability(_)
))
));
}
#[test]
fn new_cap_after_revoke_uses_current_generation() {
let (mut k, pid) = kernel_with_process();
let ep_id = k.create_endpoint_for_test();
// Grant, revoke, grant again
let old_slot = k.grant_cap_for_test(
pid,
Resource::Endpoint(ep_id),
PermissionSet::empty().with(Permission::Read),
);
k.revoke_resource(&Resource::Endpoint(ep_id));
// Old cap is dead
let outcome = k.handle_syscall(Syscall::Receive { endpoint: old_slot });
assert!(matches!(
outcome,
SyscallOutcome::Complete(SyscallResult::Error(
domain::error::KernelError::RevokedCapability(_)
))
));
// New cap with current generation works
let new_slot = k.grant_cap_for_test(
pid,
Resource::Endpoint(ep_id),
PermissionSet::empty().with(Permission::Read),
);
let outcome = k.handle_syscall(Syscall::Receive { endpoint: new_slot });
// Should block (no sender), not error — proving the cap is valid
assert!(matches!(outcome, SyscallOutcome::BlockCaller(_)));
}
#[test]
fn revoke_does_not_affect_other_resources() {
let (mut k, pid) = kernel_with_process();
let ep1 = k.create_endpoint_for_test();
let ep2 = k.create_endpoint_for_test();
let slot1 = k.grant_cap_for_test(
pid,
Resource::Endpoint(ep1),
PermissionSet::empty().with(Permission::Read),
);
let slot2 = k.grant_cap_for_test(
pid,
Resource::Endpoint(ep2),
PermissionSet::empty().with(Permission::Read),
);
// Revoke only ep1
k.revoke_resource(&Resource::Endpoint(ep1));
// ep1 cap is revoked
let outcome = k.handle_syscall(Syscall::Receive { endpoint: slot1 });
assert!(matches!(
outcome,
SyscallOutcome::Complete(SyscallResult::Error(
domain::error::KernelError::RevokedCapability(_)
))
));
// ep2 cap still works
let outcome = k.handle_syscall(Syscall::Receive { endpoint: slot2 });
assert!(matches!(outcome, SyscallOutcome::BlockCaller(_)));
}
#[test]
fn revoke_syscall_invalidates_via_generation() {
let (mut k, pid) = kernel_with_process();
let ep_id = k.create_endpoint_for_test();
let cap_slot = k.grant_cap_for_test(
pid,
Resource::Endpoint(ep_id),
PermissionSet::empty()
.with(Permission::Read)
.with(Permission::Revoke),
);
// Revoke via syscall
let outcome = k.handle_syscall(Syscall::Revoke { slot: cap_slot });
assert!(matches!(
outcome,
SyscallOutcome::Complete(SyscallResult::Ok)
));
// The cap itself is now revoked (generation mismatch)
let outcome = k.handle_syscall(Syscall::Receive { endpoint: cap_slot });
assert!(matches!(
outcome,
SyscallOutcome::Complete(SyscallResult::Error(
domain::error::KernelError::RevokedCapability(_)
))
));
}
#[test]
fn revoke_syscall_without_revoke_permission_fails() {
let (mut k, pid) = kernel_with_process();
let ep_id = k.create_endpoint_for_test();
let cap_slot = k.grant_cap_for_test(
pid,
Resource::Endpoint(ep_id),
PermissionSet::empty().with(Permission::Read),
);
let outcome = k.handle_syscall(Syscall::Revoke { slot: cap_slot });
assert!(matches!(
outcome,
SyscallOutcome::Complete(SyscallResult::Error(_))
));
}
#[test]
fn every_representable_slot_is_rejected_not_panicking() {
let (mut k, _) = kernel_with_process();

View File

@@ -0,0 +1,326 @@
//! Randomized check of the derivation tree against an independent model.
//! The model applies the rules of docs/ipc.md (derive records a parent,
//! delete re-parents, revoke removes the subtree, death deletes everything
//! a process holds) to its own data; after every step the kernel must hold
//! exactly the capabilities the model says are alive.
extern crate std;
use std::collections::HashMap;
use std::vec::Vec;
use super::*;
use crate::{SyscallOutcome, SyscallResult};
use domain::syscall::Syscall;
use types::address::{DataRef, VirtualAddress};
use types::badge::Badge;
use types::bounded::{CapSlot, PageCount};
use types::id::ThreadId;
use types::mapping::PagePerms;
use types::permission::{Permission, PermissionSet};
const STEPS: usize = 1500;
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
}
fn pick<T: Copy>(&mut self, xs: &[T]) -> Option<T> {
(!xs.is_empty()).then(|| xs[self.below(xs.len())])
}
}
#[derive(Clone, Copy)]
struct Node {
parent: Option<usize>,
is_region: bool,
}
/// The reference model: logical capabilities and where each one lives.
#[derive(Default)]
struct Model {
nodes: Vec<Node>,
alive: HashMap<usize, (ProcessId, CapSlot)>,
}
impl Model {
fn add(&mut self, at: (ProcessId, CapSlot), parent: Option<usize>, is_region: bool) -> usize {
self.nodes.push(Node { parent, is_region });
let id = self.nodes.len() - 1;
self.alive.insert(id, at);
id
}
fn delete(&mut self, id: usize) {
let grandparent = self.nodes[id].parent;
for n in 0..self.nodes.len() {
if self.alive.contains_key(&n) && self.nodes[n].parent == Some(id) {
self.nodes[n].parent = grandparent;
}
}
self.alive.remove(&id);
}
fn is_descendant(&self, mut n: usize, root: usize) -> bool {
while let Some(p) = self.nodes[n].parent {
if p == root {
return true;
}
n = p;
}
false
}
fn revoke(&mut self, root: usize) {
let doomed: Vec<usize> = self
.alive
.keys()
.copied()
.filter(|&n| self.is_descendant(n, root))
.collect();
for n in doomed {
self.alive.remove(&n);
}
}
fn held_by(&self, pid: ProcessId) -> Vec<(usize, CapSlot)> {
self.alive
.iter()
.filter(|(_, (p, _))| *p == pid)
.map(|(id, (_, s))| (*id, *s))
.collect()
}
}
fn all() -> PermissionSet {
PermissionSet::empty()
.with(Permission::Read)
.with(Permission::Write)
.with(Permission::Execute)
.with(Permission::Delegate)
}
struct World {
k: Kernel<MockPlatform>,
m: Model,
pids: [ProcessId; 3],
tids: [ThreadId; 3],
}
impl World {
fn new() -> Self {
let (k, a) = kernel_with_process();
let ta = k.current_thread().unwrap();
let mut w = Self {
k,
m: Model::default(),
pids: [a; 3],
tids: [ta; 3],
};
// init's own boot capability (ProcessCreate) is a kernel root.
let create = CapSlot::new(0).unwrap();
w.m.add((a, create), None, false);
for i in 1..3 {
w.spawn(i);
}
for call in [Syscall::CreateEndpoint, Syscall::CreateNotification] {
let s = w.slot(0, call);
w.m.add((a, s), None, false);
}
let r = w.slot(
0,
Syscall::CreateRegion {
pages: PageCount::new(1).unwrap(),
},
);
w.m.add((a, r), None, true);
w
}
fn slot(&mut self, who: usize, call: Syscall) -> CapSlot {
self.k.switch_to_thread_for_test(self.tids[who]);
match self.k.handle_syscall(call) {
SyscallOutcome::Complete(SyscallResult::Slot(s)) => s,
_ => panic!("expected a slot"),
}
}
/// (Re)creates process `i` from init; init gets a root process cap.
fn spawn(&mut self, i: usize) {
let cap = self.slot(
0,
Syscall::CreateProcess {
create_cap: CapSlot::new(0).unwrap(),
image: DataRef::empty(),
},
);
self.pids[i] = self.k.process_of_cap_for_test(cap);
self.tids[i] =
self.k
.spawn_thread_for_test(self.pids[i], Priority::DEFAULT, TimeSlice::DEFAULT);
self.m.add((self.pids[0], cap), None, false);
}
fn run(&mut self, who: usize, call: Syscall) -> bool {
self.k.switch_to_thread_for_test(self.tids[who]);
!matches!(
self.k.handle_syscall(call),
SyscallOutcome::Complete(SyscallResult::Error(_))
)
}
fn step(&mut self, rng: &mut Rng) {
let who = rng.below(3);
let pid = self.pids[who];
let held = self.m.held_by(pid);
let Some((id, slot)) = rng.pick(&held) else {
return;
};
match rng.below(10) {
0..=2 => {
// Badges only go on endpoints and notifications; the kernel
// must refuse the rest, so try both.
let badge = Badge::new(rng.below(2));
let before = self.k.cap_slots_for_test(pid);
let ok = self.run(
who,
Syscall::Mint {
slot,
badge,
permissions: all(),
},
);
if ok {
let new = newly_used(before, self.k.cap_slots_for_test(pid));
let is_region = self.m.nodes[id].is_region;
self.m.add((pid, new), Some(id), is_region);
}
}
3..=4 => {
// Delegate through any process cap the caller holds.
let proc_caps: Vec<(CapSlot, ProcessId)> = held
.iter()
.filter_map(|(_, s)| {
self.k.process_cap_target_for_test(pid, *s).map(|t| (*s, t))
})
.collect();
if let Some((to, target)) = rng.pick(&proc_caps) {
let before = self.k.cap_slots_for_test(target);
let ok = self.run(
who,
Syscall::Delegate {
slot,
to_endpoint: to,
permissions: all(),
},
);
if ok {
let new = newly_used(before, self.k.cap_slots_for_test(target));
let is_region = self.m.nodes[id].is_region;
self.m.add((target, new), Some(id), is_region);
}
}
}
// init keeps its ProcessCreate cap so processes can be re-made.
5 if !(who == 0 && slot == CapSlot::new(0).unwrap()) => {
if self.run(who, Syscall::DropCap { slot }) {
self.m.delete(id);
}
}
6..=7 => {
assert!(
self.run(who, Syscall::Revoke { slot }),
"revoking a held cap failed"
);
self.m.revoke(id);
}
8 => {
let va = VirtualAddress::from_raw(0x30000 + rng.below(16) * 0x1000);
let _ = self.run(
who,
Syscall::MapRegion {
region: slot,
va,
perms: PagePerms::ReadWrite,
},
);
}
_ if who == 0 && rng.below(4) == 0 => {
let victim = 1 + rng.below(2);
let vp = self.pids[victim];
// init's cap to the victim may already be revoked; kill it
// directly, as a supervisor holding a cap would.
for (n, _) in self.m.held_by(vp) {
self.m.delete(n);
}
self.k.destroy_process(vp);
self.spawn(victim);
}
_ => {}
}
}
fn check(&self) {
for &pid in &self.pids {
let used = self.k.cap_slots_for_test(pid);
let mut expected = [false; 64];
for (_, s) in self.m.held_by(pid) {
expected[s.value() as usize] = true;
}
assert_eq!(used, expected, "{pid:?} holds the wrong capabilities");
}
// Every capability-backed mapping is backed by a live capability
// of the mapping process.
for (_, region) in self.k.regions.iter() {
for m in region.mappings() {
if let Some(via) = m.via {
assert!(self.k.resolves(via), "mapping outlived its capability");
assert_eq!(via.process, m.process);
}
}
}
}
}
fn newly_used(before: [bool; 64], after: [bool; 64]) -> CapSlot {
(0..64)
.find(|&i| after[i] && !before[i])
.and_then(|i| CapSlot::new(i as u8))
.expect("a new slot")
}
#[test]
fn derivation_tree_matches_the_model() {
for seed in [5u64, 0xBADC0DE, 31337, 0x5EED, 2024] {
let mut world = World::new();
let mut rng = Rng(seed);
let mut revokes = 0;
for _ in 0..STEPS {
let before = world.m.alive.len();
world.step(&mut rng);
if world.m.alive.len() < before {
revokes += 1;
}
world.check();
}
assert!(
revokes > 20,
"seed {seed:#x}: too little deletion exercised"
);
assert!(
world.m.nodes.len() > 100,
"seed {seed:#x}: too little derivation"
);
}
}

View File

@@ -128,6 +128,33 @@ impl Kernel<MockPlatform> {
self.destroy_notifications_owned_by(owner);
}
/// The process a Process cap in `pid`'s table names, if it is one.
pub fn process_cap_target_for_test(&self, pid: ProcessId, slot: CapSlot) -> Option<ProcessId> {
match self
.processes
.get(pid)
.ok()?
.capabilities()
.get(slot)
.ok()?
.resource()
{
Resource::Process(target) if self.process_exists(*target) => Some(*target),
_ => None,
}
}
/// 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];
if let Ok(p) = self.processes.get(pid) {
for (slot, _) in p.capabilities().iter() {
used[slot.value() as usize] = true;
}
}
used
}
pub fn thread_state_for_test(&self, tid: ThreadId) -> Option<ThreadState> {
self.threads.get(tid).ok().map(|t| *t.state())
}

View File

@@ -613,7 +613,7 @@ fn transfer_needs_delegate_on_the_transferred_cap() {
}
#[test]
fn a_cap_revoked_while_the_sender_waits_is_not_delivered() {
fn a_cap_dropped_while_the_sender_waits_is_not_delivered() {
let mut t = Ipc::new();
let (a, ta, tb, a_send, b_recv) = (t.a, t.ta, t.tb, t.a_send, t.b_recv);
let other = t.k.create_endpoint(a).unwrap();
@@ -626,7 +626,8 @@ fn a_cap_revoked_while_the_sender_waits_is_not_delivered() {
message: msg([0; 4]).with_cap(gift),
},
);
t.k.revoke_resource(&Resource::Endpoint(other));
// A is blocked sending; delete the cap as its death or a revoke would.
t.k.delete_cap(a, gift).unwrap();
let d = delivered(&t.run(tb, Syscall::Receive { endpoint: b_recv })).unwrap();
assert_eq!(d.cap, None);
}

View File

@@ -2,6 +2,7 @@ mod helpers;
mod mock_platform;
mod capability;
mod captree_model;
mod exit;
mod invariants;
mod ipc;
@@ -10,6 +11,7 @@ mod isolation;
mod lifecycle;
mod lifetimes;
mod memory;
mod revocation;
mod simple;
mod tick;

View File

@@ -0,0 +1,365 @@
//! Revocation through the capability derivation tree: `Revoke(cap)`
//! deletes everything derived from `cap`, transitively, across processes,
//! and keeps `cap`. Mappings made through a deleted capability go with it.
use super::*;
use crate::{SyscallOutcome, SyscallResult};
use domain::message::Message;
use domain::resource::Resource;
use domain::syscall::Syscall;
use types::address::{DataRef, VirtualAddress};
use types::badge::Badge;
use types::bounded::{CapSlot, PageCount};
use types::id::ThreadId;
use types::mapping::PagePerms;
use types::permission::{Permission, PermissionSet};
fn perms(ps: &[Permission]) -> PermissionSet {
ps.iter().fold(PermissionSet::empty(), |s, p| s.with(*p))
}
fn all() -> PermissionSet {
perms(&[
Permission::Read,
Permission::Write,
Permission::Execute,
Permission::Delegate,
])
}
fn ok(outcome: &SyscallOutcome) -> bool {
!matches!(outcome, SyscallOutcome::Complete(SyscallResult::Error(_)))
}
fn slot_of(outcome: SyscallOutcome) -> CapSlot {
match outcome {
SyscallOutcome::Complete(SyscallResult::Slot(s)) => s,
_ => panic!("expected a slot"),
}
}
/// Init (A) plus B and C, each with one thread; A holds process caps to
/// both and a root capability to an endpoint it owns.
struct Tree {
k: Kernel<MockPlatform>,
a: ProcessId,
b: ProcessId,
c: ProcessId,
ta: ThreadId,
tb: ThreadId,
a_to_b: CapSlot,
a_to_c: CapSlot,
root: CapSlot,
}
impl Tree {
fn new() -> Self {
let (mut k, a) = kernel_with_process();
let ta = k.current_thread().unwrap();
let create = || Syscall::CreateProcess {
create_cap: CapSlot::new(0).unwrap(),
image: DataRef::empty(),
};
let a_to_b = slot_of(k.handle_syscall(create()));
let a_to_c = slot_of(k.handle_syscall(create()));
let (b, c) = (ProcessId::from_raw(1), ProcessId::from_raw(2));
let tb = k.spawn_thread_for_test(b, Priority::DEFAULT, TimeSlice::DEFAULT);
k.spawn_thread_for_test(c, Priority::DEFAULT, TimeSlice::DEFAULT);
let root = slot_of(k.handle_syscall(Syscall::CreateEndpoint));
Self {
k,
a,
b,
c,
ta,
tb,
a_to_b,
a_to_c,
root,
}
}
fn run(&mut self, tid: ThreadId, call: Syscall) -> SyscallOutcome {
self.k.switch_to_thread_for_test(tid);
self.k.handle_syscall(call)
}
fn mint(&mut self, tid: ThreadId, slot: CapSlot, badge: usize) -> CapSlot {
slot_of(self.run(
tid,
Syscall::Mint {
slot,
badge: Badge::new(badge),
permissions: all(),
},
))
}
/// Gives `slot` (in the caller's table) to the process behind `to`;
/// returns where it landed there.
fn give(&mut self, tid: ThreadId, slot: CapSlot, to: CapSlot, target: ProcessId) -> CapSlot {
let before = self.k.cap_slots_for_test(target);
assert!(ok(&self.run(
tid,
Syscall::Delegate {
slot,
to_endpoint: to,
permissions: all()
}
)));
let after = self.k.cap_slots_for_test(target);
(0..=CapSlot::MAX)
.map(|n| CapSlot::new(n).unwrap())
.find(|s| after[s.value() as usize] && !before[s.value() as usize])
.expect("delegated cap")
}
fn holds(&self, pid: ProcessId, slot: CapSlot) -> bool {
self.k.cap_slots_for_test(pid)[slot.value() as usize]
}
}
#[test]
fn revoke_deletes_minted_children_but_keeps_the_cap_itself() {
let mut t = Tree::new();
let (a, ta, root) = (t.a, t.ta, t.root);
let m1 = t.mint(ta, root, 1);
let m2 = t.mint(ta, root, 2);
assert!(ok(&t.run(ta, Syscall::Revoke { slot: root })));
assert!(!t.holds(a, m1) && !t.holds(a, m2));
assert!(t.holds(a, root));
// The root still works.
assert!(ok(&t.run(
ta,
Syscall::Mint {
slot: root,
badge: Badge::new(3),
permissions: all()
}
)));
}
#[test]
fn revoke_reaches_grandchildren_in_other_processes() {
let mut t = Tree::new();
let (a, b, c, ta, tb, root, a_to_b) = (t.a, t.b, t.c, t.ta, t.tb, t.root, t.a_to_b);
let m = t.mint(ta, root, 7);
let in_b = t.give(ta, m, a_to_b, b);
// B passes it on to C through a process cap A delegated to B.
let b_to_c = t.give(ta, t.a_to_c, a_to_b, b);
let in_c = t.give(tb, in_b, b_to_c, c);
assert!(ok(&t.run(ta, Syscall::Revoke { slot: m })));
assert!(!t.holds(b, in_b), "child survived");
assert!(!t.holds(c, in_c), "grandchild survived");
assert!(t.holds(a, m) && t.holds(a, root));
}
#[test]
fn revoking_one_client_spares_another() {
let mut t = Tree::new();
let (b, c, ta, root, a_to_b, a_to_c) = (t.b, t.c, t.ta, t.root, t.a_to_b, t.a_to_c);
let for_b = t.mint(ta, root, 1);
let for_c = t.mint(ta, root, 2);
let in_b = t.give(ta, for_b, a_to_b, b);
let in_c = t.give(ta, for_c, a_to_c, c);
assert!(ok(&t.run(ta, Syscall::Revoke { slot: for_b })));
assert!(!t.holds(b, in_b));
assert!(t.holds(c, in_c));
}
#[test]
fn dropping_a_middleman_does_not_shield_the_grandchild() {
let mut t = Tree::new();
let (b, c, ta, tb, root, a_to_b) = (t.b, t.c, t.ta, t.tb, t.root, t.a_to_b);
let m = t.mint(ta, root, 7);
let in_b = t.give(ta, m, a_to_b, b);
let b_to_c = t.give(ta, t.a_to_c, a_to_b, b);
let in_c = t.give(tb, in_b, b_to_c, c);
assert!(ok(&t.run(tb, Syscall::DropCap { slot: in_b })));
assert!(ok(&t.run(ta, Syscall::Revoke { slot: m })));
assert!(!t.holds(c, in_c), "grandchild escaped revocation");
}
#[test]
fn a_dying_middleman_does_not_shield_the_grandchild() {
let mut t = Tree::new();
let (b, c, ta, tb, root, a_to_b) = (t.b, t.c, t.ta, t.tb, t.root, t.a_to_b);
let m = t.mint(ta, root, 7);
let in_b = t.give(ta, m, a_to_b, b);
let b_to_c = t.give(ta, t.a_to_c, a_to_b, b);
let in_c = t.give(tb, in_b, b_to_c, c);
assert!(ok(&t.run(ta, Syscall::Kill { process: a_to_b })));
assert!(ok(&t.run(ta, Syscall::Revoke { slot: m })));
assert!(!t.holds(c, in_c), "grandchild escaped revocation");
}
#[test]
fn a_capability_sent_over_ipc_is_revocable() {
let mut t = Tree::new();
let (a, b, ta, tb, root, a_to_b) = (t.a, t.b, t.ta, t.tb, t.root, t.a_to_b);
let ep = t.k.create_endpoint(a).unwrap();
let send = t.k.grant_cap_for_test(a, Resource::Endpoint(ep), all());
let recv = t.k.grant_cap_for_test(b, Resource::Endpoint(ep), all());
let gift = t.mint(ta, root, 9);
t.run(tb, Syscall::Receive { endpoint: recv });
t.run(
ta,
Syscall::Send {
endpoint: send,
message: Message::empty().with_cap(gift),
},
);
let received = match t.k.take_pending_result(tb) {
Some(SyscallResult::Received(d)) => d.cap.expect("cap transferred"),
_ => panic!("B got nothing"),
};
assert!(ok(&t.run(ta, Syscall::Revoke { slot: gift })));
assert!(!t.holds(b, received));
let _ = a_to_b;
}
#[test]
fn a_holder_can_only_revoke_its_own_subtree() {
let mut t = Tree::new();
let (a, b, c, ta, tb, root, a_to_b) = (t.a, t.b, t.c, t.ta, t.tb, t.root, t.a_to_b);
let m = t.mint(ta, root, 7);
let in_b = t.give(ta, m, a_to_b, b);
let b_to_c = t.give(ta, t.a_to_c, a_to_b, b);
let in_c = t.give(tb, in_b, b_to_c, c);
let sibling = t.mint(ta, root, 8);
assert!(ok(&t.run(tb, Syscall::Revoke { slot: in_b })));
assert!(!t.holds(c, in_c));
assert!(t.holds(b, in_b));
assert!(t.holds(a, m) && t.holds(a, sibling) && t.holds(a, root));
}
#[test]
fn revoking_an_empty_slot_is_an_error() {
let mut t = Tree::new();
let ta = t.ta;
assert!(!ok(&t.run(
ta,
Syscall::Revoke {
slot: CapSlot::new(40).unwrap()
}
)));
}
// ---- memory ----
fn region(t: &mut Tree, tid: ThreadId, pages: u16) -> CapSlot {
slot_of(t.run(
tid,
Syscall::CreateRegion {
pages: PageCount::new(pages).unwrap(),
},
))
}
fn map(t: &mut Tree, tid: ThreadId, cap: CapSlot, va: usize, p: PagePerms) -> bool {
ok(&t.run(
tid,
Syscall::MapRegion {
region: cap,
va: VirtualAddress::from_raw(va),
perms: p,
},
))
}
#[test]
fn map_region_maps_every_page() {
let mut t = Tree::new();
let (a, ta) = (t.a, t.ta);
let r = region(&mut t, ta, 2);
assert!(map(&mut t, ta, r, 0x30000, PagePerms::ReadWrite));
let p0 = t.k.platform_mut().mapping(a, 0x30000).unwrap();
assert_eq!(t.k.platform_mut().mapping(a, 0x31000), Some(p0 + 4096));
}
#[test]
fn map_region_needs_the_matching_permissions() {
let mut t = Tree::new();
let (a, ta) = (t.a, t.ta);
let r = region(&mut t, ta, 1);
let read_only = slot_of(t.run(
ta,
Syscall::Mint {
slot: r,
badge: Badge::NONE,
permissions: perms(&[Permission::Read]),
},
));
assert!(!map(&mut t, ta, read_only, 0x30000, PagePerms::ReadWrite));
assert!(!map(&mut t, ta, read_only, 0x30000, PagePerms::ReadExecute));
assert!(map(&mut t, ta, read_only, 0x30000, PagePerms::ReadOnly));
assert_eq!(
t.k.platform_mut().perms(a, 0x30000),
Some(PagePerms::ReadOnly)
);
}
#[test]
fn revoking_a_region_cap_unmaps_it_from_every_derived_holder() {
let mut t = Tree::new();
let (a, b, ta, tb, a_to_b) = (t.a, t.b, t.ta, t.tb, t.a_to_b);
let r = region(&mut t, ta, 1);
assert!(map(&mut t, ta, r, 0x30000, PagePerms::ReadWrite));
let in_b = t.give(ta, r, a_to_b, b);
assert!(map(&mut t, tb, in_b, 0x50000, PagePerms::ReadWrite));
assert!(t.k.platform_mut().mapping(b, 0x50000).is_some());
assert!(ok(&t.run(ta, Syscall::Revoke { slot: r })));
assert_eq!(
t.k.platform_mut().mapping(b, 0x50000),
None,
"revoked mapping still live"
);
assert!(
t.k.platform_mut().mapping(a, 0x30000).is_some(),
"revoker lost its own mapping"
);
}
#[test]
fn dropping_the_cap_a_mapping_came_through_unmaps_it() {
let mut t = Tree::new();
let (a, ta) = (t.a, t.ta);
let r = region(&mut t, ta, 1);
let copy = slot_of(t.run(
ta,
Syscall::Mint {
slot: r,
badge: Badge::NONE,
permissions: all(),
},
));
assert!(map(&mut t, ta, copy, 0x30000, PagePerms::ReadWrite));
assert!(ok(&t.run(ta, Syscall::DropCap { slot: copy })));
assert_eq!(t.k.platform_mut().mapping(a, 0x30000), None);
}
#[test]
fn a_region_can_be_remapped_after_its_mapping_was_revoked() {
let mut t = Tree::new();
let (b, ta, tb, a_to_b) = (t.b, t.ta, t.tb, t.a_to_b);
let r = region(&mut t, ta, 1);
let in_b = t.give(ta, r, a_to_b, b);
assert!(map(&mut t, tb, in_b, 0x50000, PagePerms::ReadWrite));
t.run(ta, Syscall::Revoke { slot: r });
let again = t.give(ta, r, a_to_b, b);
assert!(map(&mut t, tb, again, 0x50000, PagePerms::ReadWrite));
}
#[test]
fn create_region_is_charged_to_the_caller() {
let mut t = Tree::new();
let (a, ta) = (t.a, t.ta);
let before = t.k.page_usage(a);
region(&mut t, ta, 3);
assert_eq!(t.k.page_usage(a), before + 3);
}