feat(manifest): app manifest + decision crate (strict TOML subset, checked newtypes, capability catalog); docs/apps.md spec
This commit is contained in:
6
crates/manifest/Cargo.toml
Normal file
6
crates/manifest/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "manifest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
109
crates/manifest/src/capability.rs
Normal file
109
crates/manifest/src/capability.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
59
crates/manifest/src/decision.rs
Normal file
59
crates/manifest/src/decision.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
145
crates/manifest/src/fields.rs
Normal file
145
crates/manifest/src/fields.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
37
crates/manifest/src/lib.rs
Normal file
37
crates/manifest/src/lib.rs
Normal 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,
|
||||
}
|
||||
129
crates/manifest/src/manifest.rs
Normal file
129
crates/manifest/src/manifest.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
241
crates/manifest/src/tests.rs
Normal file
241
crates/manifest/src/tests.rs
Normal 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
135
crates/manifest/src/toml.rs
Normal 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)))
|
||||
}
|
||||
Reference in New Issue
Block a user