feat(manifest): app manifest + decision crate (strict TOML subset, checked newtypes, capability catalog); docs/apps.md spec

This commit is contained in:
2026-10-27 22:32:40 +01:00
parent ec3bc0d2a1
commit 9bc31a9bbe
11 changed files with 1016 additions and 0 deletions

4
Cargo.lock generated
View File

@@ -240,6 +240,10 @@ dependencies = [
"types",
]
[[package]]
name = "manifest"
version = "0.1.0"
[[package]]
name = "media-index-server"
version = "0.1.0"

View File

@@ -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" }

View File

@@ -0,0 +1,6 @@
[package]
name = "manifest"
version = "0.1.0"
edition = "2024"
[dependencies]

View File

@@ -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<Self> {
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<Item = Capability> {
Capability::ALL
.iter()
.copied()
.filter(move |c| self.contains(*c))
}
}
impl FromIterator<Capability> for Capabilities {
fn from_iter<I: IntoIterator<Item = Capability>>(iter: I) -> Self {
iter.into_iter().fold(Self::NONE, Self::with)
}
}

View File

@@ -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<Self, Error> {
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;
}
}

View File

@@ -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<const N: usize> {
bytes: [u8; N],
len: u8,
}
impl<const N: usize> Text<N> {
/// `None` if `s` is empty or longer than `N`.
pub fn new(s: &str) -> Option<Self> {
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<const N: usize> core::fmt::Debug for Text<N> {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
write!(f, "{:?}", self.as_str())
}
}
impl<const N: usize> core::fmt::Display for Text<N> {
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<Self> {
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<Self> {
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<Self> {
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<Self> {
let mut parts = s.split('.');
let mut next = || -> Option<u16> {
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<Self> {
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)
}
}

View File

@@ -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,
}

View File

@@ -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<Text<64>>,
pub icon: Option<FileName>,
/// Denied = not installed.
pub required: Capabilities,
/// The owner decides each.
pub optional: Capabilities,
pub accent: Option<Accent>,
}
/// Fields as they are read, before the required ones are known present.
#[derive(Default)]
struct Draft {
id: Option<AppId>,
name: Option<Text<64>>,
version: Option<Version>,
binary: Option<FileName>,
command: Option<Command>,
author: Option<Text<64>>,
icon: Option<FileName>,
required: Option<Capabilities>,
optional: Option<Capabilities>,
accent: Option<Accent>,
}
/// Sets `slot` once; a second value for the same key is an error.
fn set<T>(slot: &mut Option<T>, value: Option<T>, 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<Capabilities, Error> {
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<Self, Error> {
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)
}
}

View File

@@ -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::<std::vec::Vec<_>>()
.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
);
}

135
crates/manifest/src/toml.rs Normal file
View File

@@ -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<Item = &'a str> {
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<Value<'_>, 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)))
}

149
docs/apps.md Normal file
View File

@@ -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/<id>/ an installed app: manifest.toml and its binary
/data/<id>/ 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/<id>/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/<id>/`, read-write (its own folder only) |
## Decisions
`/system/permissions/<id>`, 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/<id>/`, create `/data/<id>/` 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.