From 9bc31a9bbe558950d025aa9ceb6c149210b2324d Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 27 Oct 2026 22:32:40 +0100 Subject: [PATCH] feat(manifest): app manifest + decision crate (strict TOML subset, checked newtypes, capability catalog); docs/apps.md spec --- Cargo.lock | 4 + Cargo.toml | 2 + crates/manifest/Cargo.toml | 6 + crates/manifest/src/capability.rs | 109 ++++++++++++++ crates/manifest/src/decision.rs | 59 ++++++++ crates/manifest/src/fields.rs | 145 ++++++++++++++++++ crates/manifest/src/lib.rs | 37 +++++ crates/manifest/src/manifest.rs | 129 ++++++++++++++++ crates/manifest/src/tests.rs | 241 ++++++++++++++++++++++++++++++ crates/manifest/src/toml.rs | 135 +++++++++++++++++ docs/apps.md | 149 ++++++++++++++++++ 11 files changed, 1016 insertions(+) create mode 100644 crates/manifest/Cargo.toml create mode 100644 crates/manifest/src/capability.rs create mode 100644 crates/manifest/src/decision.rs create mode 100644 crates/manifest/src/fields.rs create mode 100644 crates/manifest/src/lib.rs create mode 100644 crates/manifest/src/manifest.rs create mode 100644 crates/manifest/src/tests.rs create mode 100644 crates/manifest/src/toml.rs create mode 100644 docs/apps.md diff --git a/Cargo.lock b/Cargo.lock index 82eb443..3b8906f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,6 +240,10 @@ dependencies = [ "types", ] +[[package]] +name = "manifest" +version = "0.1.0" + [[package]] name = "media-index-server" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 42be4f7..cca0f74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/apps/snake", "crates/domain", "crates/elf", + "crates/manifest", "crates/init", "crates/kernel", "crates/platform", @@ -68,6 +69,7 @@ resolver = "2" types = { path = "crates/types" } domain = { path = "crates/domain" } elf = { path = "crates/elf" } +manifest = { path = "crates/manifest" } platform = { path = "crates/platform" } kernel = { path = "crates/kernel" } server-framework = { path = "crates/server-framework" } diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml new file mode 100644 index 0000000..4d6ad0f --- /dev/null +++ b/crates/manifest/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "manifest" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/crates/manifest/src/capability.rs b/crates/manifest/src/capability.rs new file mode 100644 index 0000000..2b395ed --- /dev/null +++ b/crates/manifest/src/capability.rs @@ -0,0 +1,109 @@ +//! What an app can ask for: one name per app-facing server in the +//! catalog (see the vision's Servers note). Whether this device has the +//! server is a separate question, asked when an app is installed. + +macro_rules! catalog { + ($($variant:ident = $name:literal,)*) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum Capability { + $($variant,)* + } + + impl Capability { + pub const ALL: &[Capability] = &[$(Capability::$variant,)*]; + + pub fn from_name(name: &str) -> Option { + match name { + $($name => Some(Capability::$variant),)* + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + $(Capability::$variant => $name,)* + } + } + } + }; +} + +catalog! { + Display = "display", + Storage = "storage", + AudioPlayback = "audio_playback", + AudioCapture = "audio_capture", + Camera = "camera", + Network = "network", + Telephony = "telephony", + Bluetooth = "bluetooth", + Gps = "gps", + Usb = "usb", + Power = "power", + Notifications = "notifications", + Clipboard = "clipboard", + Contacts = "contacts", + Calendar = "calendar", + Alarms = "alarms", + MediaIndex = "media_index", + Keychain = "keychain", + Sync = "sync", + Accessibility = "accessibility", + Print = "print", +} + +impl Capability { + fn bit(self) -> u32 { + 1 << (self as u32) + } +} + +/// A set of capabilities. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Capabilities(u32); + +impl Capabilities { + pub const NONE: Self = Self(0); + + pub fn with(self, c: Capability) -> Self { + Self(self.0 | c.bit()) + } + + pub fn without(self, c: Capability) -> Self { + Self(self.0 & !c.bit()) + } + + pub fn contains(self, c: Capability) -> bool { + self.0 & c.bit() != 0 + } + + pub fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + pub fn intersect(self, other: Self) -> Self { + Self(self.0 & other.0) + } + + pub fn is_empty(self) -> bool { + self.0 == 0 + } + + /// Whether every capability in `self` is in `other`. + pub fn is_subset(self, other: Self) -> bool { + self.0 & !other.0 == 0 + } + + pub fn iter(self) -> impl Iterator { + Capability::ALL + .iter() + .copied() + .filter(move |c| self.contains(*c)) + } +} + +impl FromIterator for Capabilities { + fn from_iter>(iter: I) -> Self { + iter.into_iter().fold(Self::NONE, Self::with) + } +} diff --git a/crates/manifest/src/decision.rs b/crates/manifest/src/decision.rs new file mode 100644 index 0000000..f885157 --- /dev/null +++ b/crates/manifest/src/decision.rs @@ -0,0 +1,59 @@ +//! The owner's decision about one app: what it was granted. + +use crate::Error; +use crate::capability::{Capabilities, Capability}; +use crate::toml; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Decision { + pub granted: Capabilities, +} + +/// Longest decision file `write` produces: the header and every name. +pub const MAX_TEXT: usize = 512; + +impl Decision { + pub fn parse(text: &str) -> Result { + let mut granted = None; + toml::parse(text, |e| match (e.table, e.key) { + ("decision", "granted") if granted.is_none() => { + granted = Some(crate::manifest::capabilities(e.value, e.line)?); + Ok(()) + } + ("decision", "granted") => Err(Error::Duplicate { line: e.line }), + _ => Err(Error::Unknown { line: e.line }), + })?; + Ok(Self { + granted: granted.ok_or(Error::Missing("granted"))?, + }) + } + + /// The file this decision is stored as, in `out`; its length. + pub fn write(&self, out: &mut [u8; MAX_TEXT]) -> usize { + let mut w = Writer { out, len: 0 }; + w.put("[decision]\ngranted = ["); + for (i, c) in self.granted.iter().enumerate() { + if i > 0 { + w.put(", "); + } + w.put("\""); + w.put(Capability::name(c)); + w.put("\""); + } + w.put("]\n"); + w.len + } +} + +struct Writer<'a> { + out: &'a mut [u8; MAX_TEXT], + len: usize, +} + +impl Writer<'_> { + fn put(&mut self, s: &str) { + let end = (self.len + s.len()).min(MAX_TEXT); + self.out[self.len..end].copy_from_slice(&s.as_bytes()[..end - self.len]); + self.len = end; + } +} diff --git a/crates/manifest/src/fields.rs b/crates/manifest/src/fields.rs new file mode 100644 index 0000000..8566a77 --- /dev/null +++ b/crates/manifest/src/fields.rs @@ -0,0 +1,145 @@ +//! The values a manifest holds, each checked when it is made: an app id +//! that is not a reverse-DNS name, say, cannot exist. + +/// Up to `N` bytes of printable ASCII, held inline (no allocation). +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Text { + bytes: [u8; N], + len: u8, +} + +impl Text { + /// `None` if `s` is empty or longer than `N`. + pub fn new(s: &str) -> Option { + if s.is_empty() || s.len() > N || N > u8::MAX as usize { + return None; + } + let mut bytes = [0; N]; + bytes[..s.len()].copy_from_slice(s.as_bytes()); + Some(Self { + bytes, + len: s.len() as u8, + }) + } + + pub fn as_str(&self) -> &str { + // Made only from a &str, so always UTF-8. + core::str::from_utf8(&self.bytes[..self.len as usize]).unwrap_or("") + } +} + +impl core::fmt::Debug for Text { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + write!(f, "{:?}", self.as_str()) + } +} + +impl core::fmt::Display for Text { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// What identifies an app everywhere: lowercase reverse-DNS, two or more +/// dot-separated parts of `a-z`, `0-9` and `-` (`kos.snake`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppId(Text<64>); + +impl AppId { + pub fn new(s: &str) -> Option { + let part = |p: &str| { + !p.is_empty() + && p.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }; + let parts = s.split('.'); + (s.split('.').count() >= 2 && parts.clone().all(part)).then(|| Text::new(s).map(Self))? + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl core::fmt::Display for AppId { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A file directly in the app's folder: no `/`, not `.` or `..`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FileName(Text<32>); + +impl FileName { + pub fn new(s: &str) -> Option { + let ok = s + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + && s != "." + && s != ".."; + ok.then(|| Text::new(s).map(Self))? + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +/// What the shell runs an app as: `a-z`, `0-9`, `-` and `_`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Command(Text<16>); + +impl Command { + pub fn new(s: &str) -> Option { + let ok = s + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_'); + ok.then(|| Text::new(s).map(Self))? + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +/// `major.minor.patch`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version { + pub major: u16, + pub minor: u16, + pub patch: u16, +} + +impl Version { + pub fn parse(s: &str) -> Option { + let mut parts = s.split('.'); + let mut next = || -> Option { + let p = parts.next()?; + // No signs, no leading zeros (other than 0 itself). + let digits = !p.is_empty() + && p.bytes().all(|b| b.is_ascii_digit()) + && (p == "0" || !p.starts_with('0')); + digits.then(|| p.parse().ok())? + }; + let v = Self { + major: next()?, + minor: next()?, + patch: next()?, + }; + parts.next().is_none().then_some(v) + } +} + +/// An app's brand colour, `#rrggbb`. Contrast is K-UI's to judge. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Accent(pub u32); + +impl Accent { + pub fn parse(s: &str) -> Option { + let hex = s + .strip_prefix('#') + .filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?; + u32::from_str_radix(hex, 16).ok().map(Self) + } +} diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs new file mode 100644 index 0000000..503b39b --- /dev/null +++ b/crates/manifest/src/lib.rs @@ -0,0 +1,37 @@ +//! App manifests and the owner's decisions about them (see docs/apps.md): +//! parsed strictly from a TOML subset, into checked values. Pure: no +//! hardware, no kernel, no allocation. + +#![no_std] +#![forbid(unsafe_code)] + +mod capability; +mod decision; +mod fields; +mod manifest; +mod toml; + +#[cfg(test)] +mod tests; + +pub use capability::{Capabilities, Capability}; +pub use decision::{Decision, MAX_TEXT as MAX_DECISION_TEXT}; +pub use fields::{Accent, AppId, Command, FileName, Text, Version}; +pub use manifest::Manifest; + +/// Why a manifest or decision was refused; `line` is 1-based. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Error { + /// Not the TOML subset. + Syntax { line: usize }, + /// A table, key or capability given twice. + Duplicate { line: usize }, + /// A table, key or capability name that does not exist. + Unknown { line: usize }, + /// A value of the right kind but not a valid one (a bad id, say). + Invalid { line: usize }, + /// A required key is absent. + Missing(&'static str), + /// A capability both required and optional. + Conflict, +} diff --git a/crates/manifest/src/manifest.rs b/crates/manifest/src/manifest.rs new file mode 100644 index 0000000..d155012 --- /dev/null +++ b/crates/manifest/src/manifest.rs @@ -0,0 +1,129 @@ +//! An app's manifest: who it is and what it asks for. + +use crate::Error; +use crate::capability::{Capabilities, Capability}; +use crate::fields::{Accent, AppId, Command, FileName, Text, Version}; +use crate::toml::{self, Entry, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Manifest { + pub id: AppId, + pub name: Text<64>, + pub version: Version, + pub binary: FileName, + /// What the shell runs it as. + pub command: Command, + pub author: Option>, + pub icon: Option, + /// Denied = not installed. + pub required: Capabilities, + /// The owner decides each. + pub optional: Capabilities, + pub accent: Option, +} + +/// Fields as they are read, before the required ones are known present. +#[derive(Default)] +struct Draft { + id: Option, + name: Option>, + version: Option, + binary: Option, + command: Option, + author: Option>, + icon: Option, + required: Option, + optional: Option, + accent: Option, +} + +/// Sets `slot` once; a second value for the same key is an error. +fn set(slot: &mut Option, value: Option, line: usize) -> Result<(), Error> { + if slot.is_some() { + return Err(Error::Duplicate { line }); + } + *slot = Some(value.ok_or(Error::Invalid { line })?); + Ok(()) +} + +fn text<'a>(value: Value<'a>) -> Option<&'a str> { + match value { + Value::Str(s) => Some(s), + Value::List(_) => None, + } +} + +/// A list of capability names, each known and listed once. +pub(crate) fn capabilities(value: Value, line: usize) -> Result { + let Value::List(list) = value else { + return Err(Error::Invalid { line }); + }; + let mut set = Capabilities::NONE; + for name in list.iter() { + let c = Capability::from_name(name).ok_or(Error::Unknown { line })?; + if set.contains(c) { + return Err(Error::Duplicate { line }); + } + set = set.with(c); + } + Ok(set) +} + +impl Draft { + fn take(&mut self, e: Entry) -> Result<(), Error> { + let l = e.line; + let s = text(e.value); + match (e.table, e.key) { + ("app", "id") => set(&mut self.id, s.and_then(AppId::new), l), + ("app", "name") => set(&mut self.name, s.and_then(Text::new), l), + ("app", "version") => set(&mut self.version, s.and_then(Version::parse), l), + ("app", "binary") => set(&mut self.binary, s.and_then(FileName::new), l), + ("app", "command") => set(&mut self.command, s.and_then(Command::new), l), + ("app", "author") => set(&mut self.author, s.and_then(Text::new), l), + ("app", "icon") => set(&mut self.icon, s.and_then(FileName::new), l), + ("capabilities", "required") => { + set(&mut self.required, Some(capabilities(e.value, l)?), l) + } + ("capabilities", "optional") => { + set(&mut self.optional, Some(capabilities(e.value, l)?), l) + } + ("appearance", "accent") => set(&mut self.accent, s.and_then(Accent::parse), l), + _ => Err(Error::Unknown { line: l }), + } + } +} + +impl Manifest { + pub fn parse(text: &str) -> Result { + let mut d = Draft::default(); + toml::parse(text, |e| d.take(e))?; + let binary = d.binary.ok_or(Error::Missing("binary"))?; + let command = match d.command { + Some(c) => c, + // A binary name is a command name when it fits the stricter rules. + None => Command::new(binary.as_str()).ok_or(Error::Missing("command"))?, + }; + let required = d.required.unwrap_or_default(); + let optional = d.optional.unwrap_or_default(); + if !required.intersect(optional).is_empty() { + return Err(Error::Conflict); + } + Ok(Self { + id: d.id.ok_or(Error::Missing("id"))?, + name: d.name.ok_or(Error::Missing("name"))?, + version: d.version.ok_or(Error::Missing("version"))?, + binary, + command, + author: d.author, + icon: d.icon, + required, + optional, + accent: d.accent, + }) + } + + /// Everything it asks for. + pub fn requested(&self) -> Capabilities { + self.required.union(self.optional) + } +} diff --git a/crates/manifest/src/tests.rs b/crates/manifest/src/tests.rs new file mode 100644 index 0000000..65b9553 --- /dev/null +++ b/crates/manifest/src/tests.rs @@ -0,0 +1,241 @@ +use crate::*; + +const SNAKE: &str = r##" +# the game +[app] +id = "kos.snake" +name = "Snake" +version = "1.0.0" +binary = "snake" +author = "K-OS" + +[capabilities] +required = ["display"] +optional = ["storage",] # high scores + +[appearance] +accent = "#e91e63" +"##; + +fn with(line: &str, replacing: &str) -> std::string::String { + SNAKE.replace(replacing, line) +} + +extern crate std; + +#[test] +fn a_full_manifest_reads_into_checked_values() { + let m = Manifest::parse(SNAKE).unwrap(); + assert_eq!(m.id.as_str(), "kos.snake"); + assert_eq!(m.name.as_str(), "Snake"); + assert_eq!( + m.version, + Version { + major: 1, + minor: 0, + patch: 0 + } + ); + assert_eq!(m.binary.as_str(), "snake"); + assert_eq!(m.command.as_str(), "snake", "the binary's name by default"); + assert_eq!(m.author.unwrap().as_str(), "K-OS"); + assert_eq!(m.required, Capabilities::NONE.with(Capability::Display)); + assert_eq!(m.optional, Capabilities::NONE.with(Capability::Storage)); + assert_eq!(m.accent, Some(Accent(0xe91e63))); +} + +#[test] +fn only_app_identity_is_required() { + let m = Manifest::parse( + "[app]\nid = \"kos.cat\"\nname = \"cat\"\nversion = \"0.1.0\"\nbinary = \"cat\"\n", + ) + .unwrap(); + assert!(m.requested().is_empty()); + for key in ["id", "name", "version", "binary"] { + let text = std::format!( + "[app]\n{}", + [ + "id = \"a.b\"", + "name = \"n\"", + "version = \"1.0.0\"", + "binary = \"b\"" + ] + .iter() + .filter(|l| !l.starts_with(key)) + .copied() + .collect::>() + .join("\n") + ); + assert_eq!(Manifest::parse(&text), Err(Error::Missing(key)), "{key}"); + } +} + +#[test] +fn anything_unknown_is_refused_not_ignored() { + assert_eq!( + Manifest::parse(&with( + "homepage = \"x\"\nauthor = \"K-OS\"", + "author = \"K-OS\"" + )), + Err(Error::Unknown { line: 8 }) + ); + assert_eq!( + Manifest::parse(&with( + "required = [\"teleport\"]", + "required = [\"display\"]" + )), + Err(Error::Unknown { line: 11 }) + ); + assert!(matches!( + Manifest::parse(&with("[extras]\nx = \"y\"\n[appearance]", "[appearance]")), + Err(Error::Unknown { .. }) + )); +} + +#[test] +fn duplicates_are_refused() { + assert!(matches!( + Manifest::parse(&with( + "name = \"Snake\"\nname = \"Other\"", + "name = \"Snake\"" + )), + Err(Error::Duplicate { .. }) + )); + assert!(matches!( + Manifest::parse(&with( + "required = [\"display\", \"display\"]", + "required = [\"display\"]" + )), + Err(Error::Duplicate { .. }) + )); + assert!(matches!( + Manifest::parse(&std::format!("{SNAKE}\n[app]\n")), + Err(Error::Duplicate { .. }) + )); + assert_eq!( + Manifest::parse(&with( + "required = [\"storage\"]", + "required = [\"display\"]" + )), + Err(Error::Conflict) + ); +} + +#[test] +fn values_are_checked() { + for (bad, good) in [ + ("id = \"Snake\"", "id = \"kos.snake\""), + ("id = \"snake\"", "id = \"kos.snake\""), + ("id = \"kos..snake\"", "id = \"kos.snake\""), + ("version = \"1.0\"", "version = \"1.0.0\""), + ("version = \"01.0.0\"", "version = \"1.0.0\""), + ("version = \"1.0.0.0\"", "version = \"1.0.0\""), + ("binary = \"../shell\"", "binary = \"snake\""), + ("binary = \"bin/snake\"", "binary = \"snake\""), + ("binary = \"..\"", "binary = \"snake\""), + ("icon = \".\"", "author = \"K-OS\""), + ("accent = \"e91e63\"", "accent = \"#e91e63\""), + ("name = [\"Snake\"]", "name = \"Snake\""), + ] { + assert!( + matches!( + Manifest::parse(&with(bad, good)), + Err(Error::Invalid { .. }) + ), + "{bad}" + ); + } + // A binary that is no valid command needs one spelled out. + let odd = with("binary = \"Snake.bin\"", "binary = \"snake\""); + assert_eq!(Manifest::parse(&odd), Err(Error::Missing("command"))); + let named = with( + "binary = \"Snake.bin\"\ncommand = \"snake\"", + "binary = \"snake\"", + ); + assert_eq!( + Manifest::parse(&named).unwrap().binary.as_str(), + "Snake.bin" + ); +} + +#[test] +fn only_the_subset_parses() { + for bad in [ + "id = 'kos.snake'", // single quotes + "id = \"kos.\\snake\"", // escapes + "id = \"kos.snake", // unclosed + "id = kos.snake", // bare value + "id \"kos.snake\"", // no = + "[app", // unclosed table + ] { + assert!( + matches!( + Manifest::parse(&with(bad, "id = \"kos.snake\"")), + Err(Error::Syntax { .. }) + ), + "{bad}" + ); + } + assert!( + matches!( + Manifest::parse("id = \"a.b\"\n"), + Err(Error::Syntax { line: 1 }) + ), + "a key outside any table" + ); + // A comma inside a list item cannot be split wrongly: refused. + assert!(matches!( + Manifest::parse(&with( + "optional = [\"a, b\"]", + "optional = [\"storage\",] # high scores" + )), + Err(Error::Syntax { .. }) + )); + // `#` inside a string is not a comment. + let hash = with("name = \"Snake #1\"", "name = \"Snake\""); + assert_eq!(Manifest::parse(&hash).unwrap().name.as_str(), "Snake #1"); +} + +#[test] +fn decisions_round_trip() { + let d = Decision { + granted: Capabilities::NONE + .with(Capability::Display) + .with(Capability::Storage), + }; + let mut buf = [0; MAX_DECISION_TEXT]; + let n = d.write(&mut buf); + let text = core::str::from_utf8(&buf[..n]).unwrap(); + assert_eq!(text, "[decision]\ngranted = [\"display\", \"storage\"]\n"); + assert_eq!(Decision::parse(text), Ok(d)); + assert_eq!( + Decision::parse("[decision]\ngranted = []\n"), + Ok(Decision::default()) + ); + assert_eq!( + Decision::parse("[decision]\n"), + Err(Error::Missing("granted")) + ); + assert!(matches!( + Decision::parse("[decision]\ngranted = [\"x\"]\n"), + Err(Error::Unknown { .. }) + )); +} + +#[test] +fn every_capability_name_round_trips_and_fits_the_set() { + assert!(Capability::ALL.len() <= 32); + for c in Capability::ALL { + assert_eq!(Capability::from_name(c.name()), Some(*c)); + } + let all: Capabilities = Capability::ALL.iter().copied().collect(); + let mut buf = [0; MAX_DECISION_TEXT]; + let n = Decision { granted: all }.write(&mut buf); + assert!(n < MAX_DECISION_TEXT, "every name fits a decision file"); + assert_eq!( + Decision::parse(core::str::from_utf8(&buf[..n]).unwrap()) + .unwrap() + .granted, + all + ); +} diff --git a/crates/manifest/src/toml.rs b/crates/manifest/src/toml.rs new file mode 100644 index 0000000..ad4b1fe --- /dev/null +++ b/crates/manifest/src/toml.rs @@ -0,0 +1,135 @@ +//! The strict TOML subset manifests and decisions are written in: +//! `[table]` headers, `key = "string"`, `key = ["a", "b"]` on one line, +//! `#` comments. Strings are printable ASCII without `"` or `\`. + +use crate::Error; + +/// One `key = value` line, with the table it is in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Entry<'a> { + pub line: usize, + pub table: &'a str, + pub key: &'a str, + pub value: Value<'a>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Value<'a> { + Str(&'a str), + /// A one-line array of strings, already checked. + List(List<'a>), +} + +/// The inside of `[...]`: iterate it for its strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct List<'a>(&'a str); + +impl<'a> List<'a> { + pub fn iter(&self) -> impl Iterator { + self.0 + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| &s[1..s.len() - 1]) + } +} + +/// A table may be opened only once. +const MAX_TABLES: usize = 8; + +/// Calls `entry` with every `key = value` in `text`, in order; stops at +/// the first malformed line or the first error `entry` returns. +pub fn parse<'a>( + text: &'a str, + mut entry: impl FnMut(Entry<'a>) -> Result<(), Error>, +) -> Result<(), Error> { + let mut table = ""; + let mut seen = [""; MAX_TABLES]; + let mut tables = 0; + for (i, raw) in text.lines().enumerate() { + let line = i + 1; + let content = strip_comment(raw, line)?.trim(); + if content.is_empty() { + continue; + } + if let Some(name) = content.strip_prefix('[') { + let name = name + .strip_suffix(']') + .filter(|n| is_key(n)) + .ok_or(Error::Syntax { line })?; + if seen[..tables].contains(&name) { + return Err(Error::Duplicate { line }); + } + *seen.get_mut(tables).ok_or(Error::Syntax { line })? = name; + tables += 1; + table = name; + continue; + } + let (key, value) = content.split_once('=').ok_or(Error::Syntax { line })?; + let (key, value) = (key.trim(), value.trim()); + if table.is_empty() || !is_key(key) { + return Err(Error::Syntax { line }); + } + entry(Entry { + line, + table, + key, + value: value_of(value, line)?, + })?; + } + Ok(()) +} + +/// The line up to a `#` outside a string. +fn strip_comment(raw: &str, line: usize) -> Result<&str, Error> { + let mut in_string = false; + for (i, c) in raw.char_indices() { + match c { + '"' => in_string = !in_string, + '#' if !in_string => return Ok(&raw[..i]), + _ => {} + } + } + if in_string { + Err(Error::Syntax { line }) + } else { + Ok(raw) + } +} + +fn is_key(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} + +fn string(s: &str, line: usize) -> Result<&str, Error> { + let inner = s + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .ok_or(Error::Syntax { line })?; + let printable = inner + .bytes() + .all(|b| (b' '..=b'~').contains(&b) && b != b'"' && b != b'\\'); + if printable { + Ok(inner) + } else { + Err(Error::Syntax { line }) + } +} + +fn value_of(s: &str, line: usize) -> Result, Error> { + let Some(inner) = s.strip_prefix('[') else { + return string(s, line).map(Value::Str); + }; + let inner = inner.strip_suffix(']').ok_or(Error::Syntax { line })?; + let items: &str = inner.trim(); + // A trailing comma is allowed; an empty item is not. + let body = items.strip_suffix(',').unwrap_or(items); + if !body.trim().is_empty() { + for item in body.split(',') { + string(item.trim(), line)?; + } + } + Ok(Value::List(List(body))) +} diff --git a/docs/apps.md b/docs/apps.md new file mode 100644 index 0000000..86bbdfd --- /dev/null +++ b/docs/apps.md @@ -0,0 +1,149 @@ +# Apps, manifests and the permission manager + +How a program gets its authority. The kernel only knows capabilities; this +is the userspace layer that decides who holds which, on the owner's behalf +(see the vision's Capability Management and App Manifest notes). + +## Apps + +Every program the owner runs is an **app**: a manifest plus a binary. +An app is identified by its **id** (`kos.snake`), never by a file name or a +process id. The id is what decisions, the app's data folder and (later) its +encryption keys hang off. + +System servers (log, display, terminal, storage, …) are not apps: init +starts them from its own table, with exactly the capabilities listed there. + +## Where authority comes from + +An app's capabilities have exactly two sources: + +1. **Its manifest and the owner's consent.** Services the app declared + (`display`, `storage`, …), granted only by the permission manager, only + as far as the owner approved. +2. **Its launcher.** Whoever starts an app may pass along what it already + holds: the terminal as its output, a read-only grant to the current + folder, a file the owner picked. A launcher can only give what it has. + +`cat` declares nothing and gets only what the shell passes it. Snake +declares `display`. Nothing gets anything "just in case". + +## Disk layout + +``` +/home/ the owner's files; the shell's `/` +/apps// an installed app: manifest.toml and its binary +/data// the app's private folder (its `storage` capability) +/system/permissions/ the owner's decisions, one file per app id +/bin/ system servers and the shell (init starts these) +``` + +Only the permission manager reaches `/apps`, `/data` and `/system`. The +shell's storage grant is `/home`, so neither it nor anything it runs can +read another app's data or rewrite a decision. + +## Manifest + +`/apps//manifest.toml`, a strict subset of TOML: + +```toml +[app] +id = "kos.snake" # required: lowercase reverse-DNS +name = "Snake" # required +version = "1.0.0" # required: major.minor.patch +binary = "snake" # required: a file in the app's folder +command = "snake" # optional: what the shell runs it as (default: binary) +author = "K-OS" # optional +icon = "icon.png" # optional: a file in the app's folder + +[capabilities] +required = ["display"] # cannot work without; denied = not installed +optional = [] # works without; the owner decides each + +[appearance] +accent = "#e91e63" # optional: checked for format here, contrast by K-UI +``` + +The subset: `[table]` headers, `key = "string"`, `key = ["a", "b"]` on one +line, `#` comments. Strings hold printable ASCII without `"` or `\`. It is +strict: an unknown table, key or capability name, a duplicate, or a +missing required key rejects the manifest. Nothing is ignored silently. + +Capability names come from the server catalog (`display`, `storage`, +`network`, `telephony`, …). A name is valid even if this device lacks the +server; a required one it lacks means the app cannot be installed here, an +optional one is simply not granted. + +| Capability | What the app gets | +|---|---| +| `display` | the display server: the screen, and the keyboard while in focus | +| `storage` | `/data//`, read-write (its own folder only) | + +## Decisions + +`/system/permissions/`, the same TOML subset: + +```toml +[decision] +granted = ["display"] +``` + +Written by the permission manager when the owner decides; apps on the +system image ship with their decisions already recorded (pre-approved by +whoever built the image). Changing one later is the owner's call, through +the permission manager. + +## Permission manager + +A system server with no hardware. Its logic (checking manifests, deciding +what an app gets, keeping decisions) is pure and generic over two ports: + +- `Consent`: asks the owner. Today: a yes/no prompt on the terminal. +- `Store`: reads manifests and decisions, writes decisions, installs files. + Today: the storage server. + +Both have mocks; the logic is tested on the host with `cargo test`. + +### Install + +From a folder the launcher passes as a read-only grant: + +1. Read and check its `manifest.toml`; refuse if the id is installed. +2. Required capabilities this device lacks: refuse. +3. Ask the owner about the required ones together (no = not installed), + then each optional one. +4. Copy the binary and manifest to `/apps//`, create `/data//` if + `storage` was granted, record the decision. + +### Launch + +The launcher names an app (by command or id), passes its arguments and the +capabilities it wants to pass along (output, current folder). The +permission manager: + +1. Looks the app up and reads its decision. +2. Gives init a read-only grant to the binary; init loads it from that + grant into a new, unstarted process (init needs no path, and nothing is + spawned that its requester could not read). +3. Gives the process the granted services and what the launcher passed, + writes its arguments page, starts it. +4. Watches it die, then releases its storage grants. +5. Replies with a handle (the process capability, call-only) the launcher + can wait on and kill. + +### Revoke + +The owner withdraws a capability: the decision changes and the next launch +gets less. A running app that held it is **stopped**: all its access dies +at once. (Endpoint capabilities like `display` are copies the permission +manager cannot take back from one app alone yet; once servers issue +per-client badges, as storage does, revoking a single capability from a +running app will not need to stop it.) + +### Not yet + +- A trusted path for the consent prompt: today an app could draw a fake + prompt on the terminal (it cannot answer one; apps cannot type). +- Suspicious-combination warnings, simple/granular consent, per-host or + other server-side scopes. +- Signing, updates, uninstall.