feat(storage): scoped grants (subtree, ro/rw), narrow-only, release revokes all holders; IPC KEEP_DELEGATE opt-in; shell cd/pwd, programs get ro cwd

This commit is contained in:
2026-10-27 20:20:34 +01:00
parent c603a578c0
commit 1fc5a54f06
38 changed files with 1237 additions and 170 deletions

View File

@@ -1,4 +1,5 @@
mod ext4;
mod ext4_real;
mod protocol;
mod scope;
mod server;

View File

@@ -16,6 +16,15 @@ fn every_request_roundtrips() {
StorageRequest::Create { path_len: 5 },
StorageRequest::Delete { path_len: 5 },
StorageRequest::Mkdir { path_len: 4 },
StorageRequest::Grant {
path_len: 4,
write: true,
},
StorageRequest::Grant {
path_len: 0,
write: false,
},
StorageRequest::Release,
] {
assert_eq!(StorageRequest::decode(req.encode()), Some(req));
}
@@ -35,6 +44,8 @@ fn every_response_roundtrips() {
StorageResponse::Error,
StorageResponse::ReadOnly,
StorageResponse::NotAttached,
StorageResponse::Granted,
StorageResponse::Denied,
] {
assert_eq!(StorageResponse::decode(resp.encode()), Some(resp));
}

View File

@@ -0,0 +1,46 @@
extern crate std;
use crate::protocol::MAX_PATH;
use crate::scope::Scope;
fn resolved(scope: &Scope, path: &[u8]) -> Option<std::vec::Vec<u8>> {
scope.resolve(path).map(|p| p.as_bytes().to_vec())
}
#[test]
fn paths_are_joined_one_component_at_a_time() {
let docs = Scope::EVERYTHING.narrow(b"/docs/", true).unwrap();
assert_eq!(
resolved(&docs, b"a//b/./c/").as_deref(),
Some(&b"docs/a/b/c"[..])
);
assert_eq!(resolved(&docs, b"").as_deref(), Some(&b"docs"[..]));
assert_eq!(
resolved(&Scope::EVERYTHING, b"/").as_deref(),
Some(&b""[..])
);
}
#[test]
fn dot_dot_is_refused_anywhere() {
for path in [b"..".as_slice(), b"a/..", b"a/../b", b"../.."] {
assert_eq!(resolved(&Scope::EVERYTHING, path), None, "{path:?}");
}
// A name that merely starts with dots is a name.
assert!(resolved(&Scope::EVERYTHING, b"...").is_some());
}
#[test]
fn a_path_too_long_for_the_disk_is_refused() {
let long = [b'a'; MAX_PATH];
assert!(Scope::EVERYTHING.resolve(&long).is_some());
let docs = Scope::EVERYTHING.narrow(b"docs", true).unwrap();
assert!(docs.resolve(&long).is_none());
}
#[test]
fn narrowing_never_adds_write() {
let ro = Scope::EVERYTHING.narrow(b"", false).unwrap();
assert!(!ro.narrow(b"x", true).unwrap().write);
assert!(Scope::EVERYTHING.narrow(b"x", true).unwrap().write);
}

View File

@@ -3,31 +3,59 @@ extern crate std;
use crate::adapters::mock_fs::MockFileSystem;
use crate::filesystem::FileHandle;
use crate::protocol::*;
use crate::server::{Buffers, StorageServer};
use crate::server::{Host, StorageServer};
use server_framework::{Decode, Encode, MALFORMED, Server};
use std::boxed::Box;
use std::vec;
use std::vec::Vec;
use types::bounded::CapSlot;
/// Plain memory instead of regions; the "capability" is a fixed slot.
struct TestBuffers;
/// Plain memory instead of regions; the buffer "capability" is a fixed
/// slot, a grant's is its badge past `GRANT_SLOTS`.
#[derive(Default)]
struct TestHost {
reclaimed: Vec<CapSlot>,
revoked: Vec<CapSlot>,
}
impl Buffers for TestBuffers {
const BUFFER: Option<CapSlot> = Some(CapSlot::const_new(9));
const GRANT_SLOTS: usize = 32;
fn badge_of(grant: CapSlot) -> usize {
grant.value() as usize - GRANT_SLOTS
}
impl Host for TestHost {
fn create(&mut self, _client: usize) -> Option<(&'static mut [u8], Option<CapSlot>)> {
Some((Box::leak(vec![0u8; 4096].into_boxed_slice()), CapSlot::new(9)))
Some((Box::leak(vec![0u8; 4096].into_boxed_slice()), BUFFER))
}
fn reclaim(&mut self, buffer: CapSlot) {
self.reclaimed.push(buffer);
}
fn mint(&mut self, badge: usize) -> Option<CapSlot> {
CapSlot::new(u8::try_from(GRANT_SLOTS + badge).ok()?)
}
fn revoke(&mut self, grant: CapSlot) {
self.revoked.push(grant);
}
}
type Srv = StorageServer<MockFileSystem, TestBuffers>;
type Srv = StorageServer<MockFileSystem, TestHost>;
fn server() -> Srv {
StorageServer::new(MockFileSystem::new(), TestBuffers)
StorageServer::new(MockFileSystem::new(), TestHost::default())
}
/// One request from `badge`, returning the decoded reply and any cap.
fn call(s: &mut Srv, badge: usize, req: StorageRequest) -> (StorageResponse, Option<CapSlot>) {
let (words, cap) = s.handle_call(badge, req.encode(), None);
(StorageResponse::decode(words).expect("valid reply"), cap)
(
StorageResponse::decode(words).expect("valid reply"),
cap.map(|g| g.slot),
)
}
struct Client {
@@ -38,10 +66,37 @@ impl Client {
fn attach(s: &mut Srv, badge: usize) -> Self {
let (resp, cap) = call(s, badge, StorageRequest::Attach);
assert_eq!(resp, StorageResponse::Attached { size: 4096 });
assert_eq!(cap, CapSlot::new(9), "the reply carries the buffer");
assert_eq!(cap, BUFFER, "the reply carries the buffer");
Self { badge }
}
/// The badge of a new grant to `path`, narrowed from this client's.
fn grant(&self, s: &mut Srv, path: &[u8], write: bool) -> usize {
self.put(s, path);
let (words, cap) = s.handle_call(
self.badge,
StorageRequest::Grant {
path_len: path.len(),
write,
}
.encode(),
None,
);
assert_eq!(
StorageResponse::decode(words),
Some(StorageResponse::Granted)
);
let cap = cap.expect("the reply carries the grant");
assert!(cap.keep_delegate, "a grant can be passed on");
badge_of(cap.slot)
}
/// Attaches through a new grant to `path`.
fn narrowed(&self, s: &mut Srv, path: &[u8], write: bool) -> Client {
let badge = self.grant(s, path, write);
Client::attach(s, badge)
}
fn put(&self, s: &mut Srv, bytes: &[u8]) {
s.client_buffer_for_test(self.badge).unwrap()[..bytes.len()].copy_from_slice(bytes);
}
@@ -50,13 +105,26 @@ impl Client {
s.client_buffer_for_test(self.badge).unwrap()[..len].to_vec()
}
fn create(&self, s: &mut Srv, path: &[u8]) -> FileHandle {
fn with_path(
&self,
s: &mut Srv,
path: &[u8],
req: fn(usize) -> StorageRequest,
) -> StorageResponse {
self.put(s, path);
match call(s, self.badge, StorageRequest::Create { path_len: path.len() }).0 {
call(s, self.badge, req(path.len())).0
}
fn create(&self, s: &mut Srv, path: &[u8]) -> FileHandle {
match self.with_path(s, path, |path_len| StorageRequest::Create { path_len }) {
StorageResponse::Handle(h) => h,
other => panic!("create: {other:?}"),
}
}
fn open(&self, s: &mut Srv, path: &[u8]) -> StorageResponse {
self.with_path(s, path, |path_len| StorageRequest::Open { path_len })
}
}
#[test]
@@ -69,11 +137,12 @@ fn nothing_works_before_attaching() {
#[test]
fn attaching_again_hands_back_the_same_buffer() {
let mut s = server();
let root = Client::attach(&mut s, 0);
let (first, cap) = call(&mut s, 0, StorageRequest::Attach);
assert_eq!(call(&mut s, 0, StorageRequest::Attach), (first, cap));
// Not a new client: the other seven places are still free.
for badge in 1..8 {
assert!(matches!(call(&mut s, badge, StorageRequest::Attach).0, StorageResponse::Attached { .. }));
for _ in 1..8 {
root.narrowed(&mut s, b"", true);
}
}
@@ -87,13 +156,19 @@ fn data_goes_through_the_buffer_both_ways() {
call(&mut s, 0, StorageRequest::Write { handle: h, len: 14 }).0,
StorageResponse::Written { bytes: 14 }
);
assert_eq!(call(&mut s, 0, StorageRequest::Close { handle: h }).0, StorageResponse::Ok);
assert_eq!(
call(&mut s, 0, StorageRequest::Close { handle: h }).0,
StorageResponse::Ok
);
c.put(&mut s, b"note.txt");
let StorageResponse::Handle(h) = call(&mut s, 0, StorageRequest::Open { path_len: 8 }).0 else {
panic!("open");
};
assert_eq!(call(&mut s, 0, StorageRequest::FileSize { handle: h }).0, StorageResponse::Size(14));
assert_eq!(
call(&mut s, 0, StorageRequest::FileSize { handle: h }).0,
StorageResponse::Size(14)
);
c.put(&mut s, &[0; 32]);
assert_eq!(
call(&mut s, 0, StorageRequest::Read { handle: h, len: 64 }).0,
@@ -105,8 +180,9 @@ fn data_goes_through_the_buffer_both_ways() {
#[test]
fn a_handle_works_only_for_the_client_that_opened_it() {
let mut s = server();
let a = Client::attach(&mut s, 1);
Client::attach(&mut s, 2);
let root = Client::attach(&mut s, 0);
let a = root.narrowed(&mut s, b"", true);
let b = root.narrowed(&mut s, b"", true);
let h = a.create(&mut s, b"mine");
for req in [
StorageRequest::Read { handle: h, len: 4 },
@@ -114,9 +190,16 @@ fn a_handle_works_only_for_the_client_that_opened_it() {
StorageRequest::FileSize { handle: h },
StorageRequest::Close { handle: h },
] {
assert_eq!(call(&mut s, 2, req).0, StorageResponse::Error, "{req:?}");
assert_eq!(
call(&mut s, b.badge, req).0,
StorageResponse::Denied,
"{req:?}"
);
}
assert_eq!(call(&mut s, 1, StorageRequest::FileSize { handle: h }).0, StorageResponse::Size(0));
assert_eq!(
call(&mut s, a.badge, StorageRequest::FileSize { handle: h }).0,
StorageResponse::Size(0)
);
}
#[test]
@@ -125,10 +208,22 @@ fn hostile_lengths_are_clamped_to_the_buffer() {
let c = Client::attach(&mut s, 0);
let h = c.create(&mut s, b"x");
for len in [4097, usize::MAX] {
assert!(matches!(call(&mut s, 0, StorageRequest::Write { handle: h, len }).0, StorageResponse::Written { .. }));
assert!(matches!(call(&mut s, 0, StorageRequest::Read { handle: h, len }).0, StorageResponse::Data { .. }));
assert_eq!(call(&mut s, 0, StorageRequest::Open { path_len: len }).0, StorageResponse::NotFound);
assert!(matches!(call(&mut s, 0, StorageRequest::ListDir { path_len: len }).0, StorageResponse::DirEntries { .. }));
assert!(matches!(
call(&mut s, 0, StorageRequest::Write { handle: h, len }).0,
StorageResponse::Written { .. }
));
assert!(matches!(
call(&mut s, 0, StorageRequest::Read { handle: h, len }).0,
StorageResponse::Data { .. }
));
assert_eq!(
call(&mut s, 0, StorageRequest::Open { path_len: len }).0,
StorageResponse::NotFound
);
assert!(matches!(
call(&mut s, 0, StorageRequest::ListDir { path_len: len }).0,
StorageResponse::DirEntries { .. }
));
}
}
@@ -138,24 +233,30 @@ fn handles_never_opened_are_refused() {
Client::attach(&mut s, 0);
for raw in [0, 15, 16, u16::MAX] {
let h = FileHandle::new(raw);
assert_eq!(call(&mut s, 0, StorageRequest::Read { handle: h, len: 1 }).0, StorageResponse::Error);
assert_eq!(
call(&mut s, 0, StorageRequest::Read { handle: h, len: 1 }).0,
StorageResponse::Denied
);
}
}
#[test]
fn a_read_only_disk_refuses_every_write_up_front() {
let mut s = StorageServer::new(MockFileSystem::new().read_only(), TestBuffers);
let mut s = StorageServer::new(MockFileSystem::new().read_only(), TestHost::default());
let c = Client::attach(&mut s, 0);
c.put(&mut s, b"f");
for req in [
StorageRequest::Create { path_len: 1 },
StorageRequest::Delete { path_len: 1 },
StorageRequest::Mkdir { path_len: 1 },
StorageRequest::Write { handle: FileHandle::new(0), len: 1 },
StorageRequest::Write {
handle: FileHandle::new(0),
len: 1,
},
] {
let expect = match req {
// A handle nobody opened is refused before the disk is asked.
StorageRequest::Write { .. } => StorageResponse::Error,
StorageRequest::Write { .. } => StorageResponse::Denied,
_ => StorageResponse::ReadOnly,
};
assert_eq!(call(&mut s, 0, req).0, expect, "{req:?}");
@@ -171,7 +272,10 @@ fn listings_are_records_in_the_buffer() {
call(&mut s, 0, StorageRequest::Close { handle: h });
}
c.put(&mut s, b"/");
assert_eq!(call(&mut s, 0, StorageRequest::ListDir { path_len: 1 }).0, StorageResponse::DirEntries { count: 2 });
assert_eq!(
call(&mut s, 0, StorageRequest::ListDir { path_len: 1 }).0,
StorageResponse::DirEntries { count: 2 }
);
let records = c.get(&mut s, 2 * DIR_ENTRY_SIZE);
assert_eq!(&records[..5], b"a.txt");
assert_eq!(records[5], 0);
@@ -199,3 +303,172 @@ fn only_the_unbadged_holder_sets_the_log_and_only_once() {
assert_eq!(s.log(), log);
assert_eq!(set(&mut s, 0), StorageResponse::Error);
}
#[test]
fn a_badge_storage_never_issued_is_no_authority() {
let mut s = server();
for req in [
StorageRequest::Attach,
StorageRequest::Open { path_len: 1 },
StorageRequest::Release,
] {
assert_eq!(call(&mut s, 5, req).0, StorageResponse::Denied, "{req:?}");
}
}
#[test]
fn paths_resolve_inside_the_scope() {
let mut s = server();
let root = Client::attach(&mut s, 0);
let h = root.create(&mut s, b"docs/a.txt");
call(&mut s, 0, StorageRequest::Close { handle: h });
let h = root.create(&mut s, b"secret");
call(&mut s, 0, StorageRequest::Close { handle: h });
let docs = root.narrowed(&mut s, b"docs", false);
assert!(matches!(
docs.open(&mut s, b"a.txt"),
StorageResponse::Handle(_)
));
assert!(
matches!(docs.open(&mut s, b"/a.txt"), StorageResponse::Handle(_)),
"/ is the scope's root"
);
assert_eq!(
docs.open(&mut s, b"secret"),
StorageResponse::NotFound,
"only docs/secret, which is not there"
);
for escape in [
b"../secret".as_slice(),
b"./../secret",
b"x/../../secret",
b"..",
] {
assert_eq!(
docs.open(&mut s, escape),
StorageResponse::Denied,
"{escape:?}"
);
}
}
#[test]
fn a_read_only_scope_changes_nothing() {
let mut s = server();
let root = Client::attach(&mut s, 0);
let h = root.create(&mut s, b"f");
call(&mut s, 0, StorageRequest::Close { handle: h });
let ro = root.narrowed(&mut s, b"", false);
for req in [
|path_len| StorageRequest::Create { path_len },
|path_len| StorageRequest::Delete { path_len },
|path_len| StorageRequest::Mkdir { path_len },
] {
assert_eq!(ro.with_path(&mut s, b"f", req), StorageResponse::Denied);
}
// It can open the file, but the handle cannot write.
let StorageResponse::Handle(h) = ro.open(&mut s, b"f") else {
panic!("open")
};
assert_eq!(
call(
&mut s,
ro.badge,
StorageRequest::Write { handle: h, len: 1 }
)
.0,
StorageResponse::Denied
);
assert!(matches!(
call(&mut s, ro.badge, StorageRequest::Read { handle: h, len: 1 }).0,
StorageResponse::Data { .. }
));
}
#[test]
fn a_grant_is_never_wider_than_its_holder() {
let mut s = server();
let root = Client::attach(&mut s, 0);
let docs = root.narrowed(&mut s, b"docs", false);
// Asking for write from a read-only scope yields read-only.
let sub = docs.narrowed(&mut s, b"sub", true);
assert_eq!(
sub.with_path(&mut s, b"x", |path_len| StorageRequest::Create { path_len }),
StorageResponse::Denied
);
docs.put(&mut s, b"../");
assert_eq!(
call(
&mut s,
docs.badge,
StorageRequest::Grant {
path_len: 3,
write: false
}
)
.0,
StorageResponse::Denied
);
}
#[test]
fn releasing_takes_back_the_grant_and_everything_from_it() {
let mut s = server();
let root = Client::attach(&mut s, 0);
let f = root.create(&mut s, b"f");
call(&mut s, 0, StorageRequest::Close { handle: f });
let shell = root.narrowed(&mut s, b"", true);
let program = shell.narrowed(&mut s, b"", false);
let grandchild = program.grant(&mut s, b"", false);
let sibling = root.narrowed(&mut s, b"", true);
let StorageResponse::Handle(h) = program.open(&mut s, b"f") else {
panic!("open")
};
program.put(&mut s, b"leftover");
assert_eq!(
call(&mut s, program.badge, StorageRequest::Release).0,
StorageResponse::Ok
);
for badge in [program.badge, grandchild] {
assert_eq!(
call(&mut s, badge, StorageRequest::Attach).0,
StorageResponse::Denied
);
}
assert_eq!(
call(
&mut s,
program.badge,
StorageRequest::FileSize { handle: h }
)
.0,
StorageResponse::Denied
);
// The kernel takes the capabilities back, and the buffer is unmapped.
let revoked: Vec<usize> = s.host().revoked.iter().map(|c| badge_of(*c)).collect();
assert_eq!(revoked, [program.badge, grandchild]);
assert_eq!(s.host().reclaimed, [BUFFER.unwrap()]);
// The rest are untouched, and the freed buffer is reused, wiped.
assert!(matches!(
shell.open(&mut s, b"f"),
StorageResponse::Handle(_)
));
assert!(matches!(
sibling.open(&mut s, b"f"),
StorageResponse::Handle(_)
));
let next = shell.narrowed(&mut s, b"", false);
assert_eq!(next.get(&mut s, 8), [0; 8]);
}
#[test]
fn the_unbadged_holder_cannot_be_released() {
let mut s = server();
Client::attach(&mut s, 0);
assert_eq!(
call(&mut s, 0, StorageRequest::Release).0,
StorageResponse::Error
);
}