feat(apps): permission manager equips apps (manifest+consent+decisions in /system), launches via init w/ file grant; shell unprivileged (/home); install/apps/allow/revoke; revoke stops running app; app-image tool; counter app

This commit is contained in:
2026-10-27 22:56:44 +01:00
parent 9bc31a9bbe
commit 90e11eb2e8
51 changed files with 1766 additions and 644 deletions

49
crates/types/src/apps.rs Normal file
View File

@@ -0,0 +1,49 @@
//! The permission manager's protocol (docs/apps.md), shared by the server
//! and `libkos::apps`. Every request carries the client's request page (a
//! one-page region) unless it says otherwise; text goes through it,
//! NUL-separated, and answers come back in it.
/// Staged for the next `LAUNCH`, carrying the capability: what the app
/// prints to (an endpoint that speaks the log protocol).
pub const PASS_OUTPUT: usize = 1;
/// Staged for the next `LAUNCH`, carrying the capability: a storage grant
/// the app works in (the launcher's current folder).
pub const PASS_FOLDER: usize = 2;
/// The page holds the command and its arguments. Reply `OK` carries the
/// process (call it to wait and kill), with the granted capabilities' bits
/// in word 1, or an error; what was staged is used up either way.
pub const LAUNCH: usize = 3;
/// Carries a read-only storage grant to the folder holding the app (its
/// `manifest.toml` and binary), instead of the page. The owner is asked.
/// Reply `OK` with the granted capabilities' bits in word 1.
pub const INSTALL: usize = 4;
/// Writes one line per installed app into the page: id, command, version,
/// then what it may use. Reply `OK` with the text's length in word 1.
pub const LIST: usize = 5;
/// The page holds an app id; word 1 the capability's catalog index. The
/// owner takes it away (a running app that had it is stopped) or gives it.
pub const REVOKE: usize = 6;
pub const ALLOW: usize = 7;
/// The request page's size.
pub const PAGE_SIZE: usize = 4096;
pub mod reply {
pub const OK: usize = 0;
pub const NOT_INSTALLED: usize = 1;
/// Word 1: the capabilities it requires and does not get.
pub const MISSING: usize = 2;
pub const ALREADY_INSTALLED: usize = 3;
/// Word 1: the required capabilities this device has no server for.
pub const UNAVAILABLE: usize = 4;
pub const DECLINED: usize = 5;
/// Word 1: the manifest line at fault, 0 for none in particular.
pub const BAD_MANIFEST: usize = 6;
/// The app never asked for that capability.
pub const NOT_REQUESTED: usize = 7;
/// No room: too many apps running, or no spawn allowance left.
pub const BUSY: usize = 8;
pub const FAILED: usize = 9;
/// The offered folder has no `manifest.toml`.
pub const NO_MANIFEST: usize = 10;
}

View File

@@ -2,6 +2,7 @@
#![forbid(unsafe_code)]
pub mod address;
pub mod apps;
pub mod badge;
pub mod bootinfo;
pub mod bounded;
@@ -12,7 +13,6 @@ pub mod id;
pub mod input;
pub mod log;
pub mod macros;
pub mod manifest;
pub mod mapping;
pub mod permission;
#[cfg(kani)]

View File

@@ -1,64 +0,0 @@
//! A program's manifest, `/bin/<name>.manifest`: what it needs beyond the
//! terminal and the storage every program gets. Lines of `needs = a, b`;
//! `#` starts a comment. No manifest means nothing more.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Needs {
/// Draws on the screen itself and takes input focus.
pub display: bool,
}
/// A line or need the parser does not know: the program asks for
/// something nobody can say it may have, so it should not run.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Unknown<'a>(pub &'a [u8]);
pub fn parse(text: &[u8]) -> Result<Needs, Unknown<'_>> {
let mut needs = Needs::default();
for line in text.split(|b| *b == b'\n') {
let line = line
.split(|b| *b == b'#')
.next()
.unwrap_or(&[])
.trim_ascii();
if line.is_empty() {
continue;
}
let mut sides = line.splitn(2, |b| *b == b'=');
let (key, value) = (sides.next().unwrap_or(&[]).trim_ascii(), sides.next());
let (b"needs", Some(value)) = (key, value) else {
return Err(Unknown(line));
};
for need in value
.split(|b| *b == b',')
.map(<[u8]>::trim_ascii)
.filter(|n| !n.is_empty())
{
match need {
b"display" => needs.display = true,
other => return Err(Unknown(other)),
}
}
}
Ok(needs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn needs_are_read_and_comments_skipped() {
let text = b"# snake\nneeds = display # the screen\n\n";
assert_eq!(parse(text), Ok(Needs { display: true }));
assert_eq!(parse(b""), Ok(Needs::default()));
assert_eq!(parse(b"needs = "), Ok(Needs::default()));
}
#[test]
fn anything_unknown_is_refused() {
assert_eq!(parse(b"needs = display, network"), Err(Unknown(b"network")));
assert_eq!(parse(b"wants = display"), Err(Unknown(b"wants = display")));
assert_eq!(parse(b"display"), Err(Unknown(b"display")));
}
}

View File

@@ -2,15 +2,16 @@
//! init to load a program into a new, not yet running process; init replies
//! with the process capability and the client wires and starts it.
//!
//! Request: `[SPAWN | name_len << 8, packed limits, name bytes 0..8, 8..16]`.
//! Request: `[SPAWN, packed limits, 0, 0]` carrying a read-only storage grant
//! to the program file itself: init reads it through that, so it needs no
//! path, and nothing is loaded its requester could not read.
//! Reply: `[reply::OK, entry point, child's log slot or NONE, 0]` carrying
//! the process capability, or `[error tag, 0, 0, 0]`.
//! the process capability (which the client may pass on), or
//! `[error tag, 0, 0, 0]`.
use crate::budget::Limits;
pub const SPAWN: usize = 0;
/// The longest program file name a spawn request carries.
pub const MAX_NAME: usize = 16;
/// Where init puts the child's stack top (its stack ends at the image).
pub const STACK_TOP: usize = 0x10000;
/// A slot word meaning "none".
@@ -18,6 +19,7 @@ pub const NONE: usize = usize::MAX;
pub mod reply {
pub const OK: usize = 0;
/// The grant names no file init can read.
pub const NOT_FOUND: usize = 1;
/// The limits do not fit in what is left of the client's allowance.
pub const OVER_ALLOWANCE: usize = 2;
@@ -44,32 +46,13 @@ fn unpack(w: usize) -> Limits {
}
}
pub fn request(name: &[u8], limits: Limits) -> Option<[usize; 4]> {
if name.is_empty() || name.len() > MAX_NAME {
return None;
}
let mut bytes = [0u8; MAX_NAME];
bytes[..name.len()].copy_from_slice(name);
let (lo, hi) = bytes.as_chunks::<8>().0.split_at(1);
Some([
SPAWN | name.len() << 8,
pack(limits)?,
u64::from_le_bytes(lo[0]) as usize,
u64::from_le_bytes(hi[0]) as usize,
])
pub fn request(limits: Limits) -> Option<[usize; 4]> {
Some([SPAWN, pack(limits)?, 0, 0])
}
/// The name (and how much of it counts) and limits of a spawn request.
pub fn parse(words: [usize; 4]) -> Option<([u8; MAX_NAME], usize, Limits)> {
let len = words[0] >> 8;
if words[0] & 0xFF != SPAWN || len == 0 || len > MAX_NAME {
return None;
}
let mut name = [0u8; MAX_NAME];
for (b, w) in name.as_chunks_mut::<8>().0.iter_mut().zip(&words[2..]) {
*b = (*w as u64).to_le_bytes();
}
Some((name, len, unpack(words[1])))
/// The limits of a spawn request.
pub fn parse(words: [usize; 4]) -> Option<Limits> {
(words[0] == SPAWN).then(|| unpack(words[1]))
}
#[cfg(test)]
@@ -84,27 +67,18 @@ mod tests {
notifications: 3,
regions: 255,
};
let (name, len, back) = parse(request(b"bin/hello", l).unwrap()).unwrap();
assert_eq!(&name[..len], b"bin/hello");
assert_eq!(back, l);
assert_eq!(parse(request(l).unwrap()), Some(l));
}
#[test]
fn what_cannot_be_said_is_refused() {
let l = Limits::default();
assert!(request(b"", l).is_none());
assert!(request(&[b'a'; 17], l).is_none());
assert!(
request(
b"x",
Limits {
endpoints: 256,
..l
}
)
request(Limits {
endpoints: 256,
..Limits::default()
})
.is_none()
);
assert!(parse([SPAWN | 17 << 8, 0, 0, 0]).is_none());
assert!(parse([1 | 3 << 8, 0, 0, 0]).is_none());
assert!(parse([1, 0, 0, 0]).is_none());
}
}

View File

@@ -9,6 +9,7 @@ const LOG_SHIFT: u32 = 8;
const STORAGE_SHIFT: u32 = 16;
const ARGS_BIT: usize = 1 << 24;
const DISPLAY_SHIFT: u32 = 32;
const DATA_SHIFT: u32 = 40;
/// Where the arguments page sits: the program's arguments, NUL separated,
/// the first its name. Below the stack, which ends at the image.
@@ -24,6 +25,8 @@ pub struct StartArg {
pub args: bool,
/// The display server, for a program that draws on the screen.
pub display: Option<CapSlot>,
/// The app's own data folder, read-write (its `storage` capability).
pub data: Option<CapSlot>,
}
fn field(arg: usize, shift: u32) -> Option<CapSlot> {
@@ -42,6 +45,7 @@ impl StartArg {
| put(self.storage, STORAGE_SHIFT)
| if self.args { ARGS_BIT } else { 0 }
| put(self.display, DISPLAY_SHIFT)
| put(self.data, DATA_SHIFT)
}
pub fn decode(arg: usize) -> Self {
@@ -51,6 +55,7 @@ impl StartArg {
storage: field(arg, STORAGE_SHIFT),
args: arg & ARGS_BIT != 0,
display: field(arg, DISPLAY_SHIFT),
data: field(arg, DATA_SHIFT),
}
}
}
@@ -67,6 +72,7 @@ mod tests {
storage: CapSlot::new(5),
args: true,
display: CapSlot::new(7),
data: CapSlot::new(62),
};
assert_eq!(StartArg::decode(a.encode()), a);
assert_eq!(StartArg::decode(0), StartArg::default());