Files
vectorcraft/crates/plugins/src/manifest.rs
T

382 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The plug-in manifest (`vc_manifest`) and parameter schema.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::{Error, Result};
/// Longest manifest the host reads.
pub const MAX_MANIFEST_BYTES: usize = 64 * 1024;
/// Most parameters a plug-in may declare.
pub const MAX_PARAMS: usize = 64;
/// What a plug-in is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Kind {
/// An object filter: reads the selected paths and returns the objects to keep, change or add
/// (Object › Plug-ins, `plugin.run`).
Filter,
/// A live effect: rewrites one object's geometry each time it is drawn, from its appearance
/// stack (Effect › Plug-ins, `effect.apply`).
Effect,
}
impl Kind {
pub fn name(self) -> &'static str {
match self {
Kind::Filter => "filter",
Kind::Effect => "effect",
}
}
}
/// One declared parameter.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ParamSpec {
/// A real number in `min..=max`.
Number { min: f64, max: f64, default: f64 },
/// A whole number in `min..=max`.
Int {
#[serde(default = "int_min")]
min: i64,
#[serde(default = "int_max")]
max: i64,
#[serde(default)]
default: i64,
},
/// A checkbox.
Bool {
#[serde(default)]
default: bool,
},
/// One of `options` (a dropdown).
Choice {
options: Vec<String>,
#[serde(default)]
default: Option<String>,
},
}
fn int_min() -> i64 {
-1_000_000
}
fn int_max() -> i64 {
1_000_000
}
impl ParamSpec {
/// The value a parameter takes when it isn't given (numeric defaults clamped into range).
pub fn default_value(&self) -> Value {
match self {
ParamSpec::Number { min, max, default } => json!(default.clamp(*min, *max)),
ParamSpec::Int { min, max, default } => json!((*default).clamp(*min, *max)),
ParamSpec::Bool { default } => json!(default),
ParamSpec::Choice { options, default } => json!(default.clone().or_else(|| options.first().cloned()).unwrap_or_default()),
}
}
}
/// The manifest a plug-in returns from `vc_manifest` (UTF-8 JSON).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
/// Stable id, `[A-Za-z0-9._-]{1,64}` (reverse-DNS style is recommended: `org.example.tidy`).
pub id: String,
/// Menu label.
pub name: String,
#[serde(default)]
pub version: String,
pub kind: Kind,
#[serde(default)]
pub description: String,
#[serde(default)]
pub author: String,
/// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest.
#[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")]
pub params: Vec<(String, ParamSpec)>,
}
impl Manifest {
/// Parses and validates manifest JSON.
pub fn parse(bytes: &[u8]) -> Result<Manifest> {
if bytes.len() > MAX_MANIFEST_BYTES {
return Err(Error::Manifest(format!("manifest is {} bytes (limit {MAX_MANIFEST_BYTES})", bytes.len())));
}
let text = std::str::from_utf8(bytes).map_err(|_| Error::Manifest("manifest is not UTF-8".into()))?;
let man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?;
man.validate()?;
Ok(man)
}
/// The manifest of a module still being loaded.
pub(crate) fn placeholder() -> Manifest {
Manifest {
id: String::new(),
name: String::new(),
version: String::new(),
kind: Kind::Filter,
description: String::new(),
author: String::new(),
params: Vec::new(),
}
}
fn validate(&self) -> Result<()> {
if !valid_id(&self.id) {
return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id)));
}
let name = self.name.trim();
if name.is_empty() || name.chars().count() > 64 || name.chars().any(char::is_control) {
return Err(Error::Manifest("`name` must be 1-64 printable characters".into()));
}
if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 {
return Err(Error::Manifest("`version`, `description` or `author` is too long".into()));
}
for (k, spec) in &self.params {
if !valid_key(k) {
return Err(Error::Manifest(format!("invalid parameter name {k:?}")));
}
match spec {
ParamSpec::Number { min, max, default } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => {
return Err(Error::Manifest(format!("parameter `{k}`: bad range")));
}
ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))),
ParamSpec::Choice { options, default } => {
if options.is_empty()
|| options.len() > 64
|| options.iter().any(|o| o.is_empty() || o.len() > 64 || o.chars().any(char::is_control))
{
return Err(Error::Manifest(format!("parameter `{k}`: choices must be 1-64 non-empty, printable strings")));
}
if default.as_ref().is_some_and(|d| !options.contains(d)) {
return Err(Error::Manifest(format!("parameter `{k}`: default is not one of the options")));
}
}
_ => {}
}
}
Ok(())
}
/// The parameters documented the way command and effect parameters are
/// (`{amount: 0..100 (50), mode: "soft"|"hard" (soft), mono: bool (false)}`).
pub fn params_doc(&self) -> String {
let parts: Vec<String> = self
.params
.iter()
.map(|(k, spec)| match spec {
ParamSpec::Number { min, max, default } => format!("{k}: {min}..{max} ({})", default.clamp(*min, *max)),
ParamSpec::Int { min, max, default } => format!("{k}: int {min}..{max} ({})", (*default).clamp(*min, *max)),
ParamSpec::Bool { default } => format!("{k}: bool ({default})"),
ParamSpec::Choice { options, .. } => {
let opts: Vec<String> = options.iter().map(|o| format!("{o:?}")).collect();
format!("{k}: {} ({})", opts.join("|"), spec.default_value().as_str().unwrap_or_default())
}
})
.collect();
format!("{{{}}}", parts.join(", "))
}
/// Every parameter at its default value.
pub fn defaults(&self) -> Map<String, Value> {
self.params.iter().map(|(k, spec)| (k.clone(), spec.default_value())).collect()
}
/// Validates user `params` against the schema: missing keys take their defaults, numbers are
/// clamped into range (whole numbers rounded), unknown keys are dropped; a value of the wrong
/// type is an error.
pub fn resolve_params(&self, params: &Value) -> Result<Map<String, Value>> {
let given = match params {
Value::Null => &Map::new(),
Value::Object(m) => m,
_ => return Err(Error::Params("plug-in parameters must be a JSON object".into())),
};
let mut out = Map::new();
for (k, spec) in &self.params {
let Some(v) = given.get(k).filter(|v| !v.is_null()) else {
out.insert(k.clone(), spec.default_value());
continue;
};
let wrong = |what: &str| Error::Params(format!("`{k}` must be {what}"));
let resolved = match spec {
ParamSpec::Number { min, max, .. } => json!(v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?.clamp(*min, *max)),
ParamSpec::Int { min, max, .. } => {
// Keep JSON integers exact: f64 cannot represent every i64 or its bounds.
let value = if let Some(i) = v.as_i64() {
i.clamp(*min, *max)
} else if v.is_u64() {
// Past i64::MAX, so past any maximum.
*max
} else {
let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?;
(f.round() as i64).clamp(*min, *max)
};
json!(value)
}
ParamSpec::Bool { .. } => json!(v.as_bool().ok_or_else(|| wrong("true or false"))?),
ParamSpec::Choice { options, .. } => {
let s = v.as_str().ok_or_else(|| wrong("a string"))?;
if !options.iter().any(|o| o == s) {
return Err(Error::Params(format!("`{k}` must be one of {}", options.join(", "))));
}
json!(s)
}
};
out.insert(k.clone(), resolved);
}
Ok(out)
}
}
/// Reads the `params` object keeping its key order (serde_json's `Map` would sort it).
fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<(String, ParamSpec)>, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = Vec<(String, ParamSpec)>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("an object of parameter specs")
}
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(Vec::new())
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<Self::Value, A::Error> {
let mut out: Vec<(String, ParamSpec)> = Vec::new();
while let Some((k, v)) = map.next_entry::<String, ParamSpec>()? {
if out.len() >= MAX_PARAMS {
return Err(serde::de::Error::custom(format!("too many parameters (limit {MAX_PARAMS})")));
}
if out.iter().any(|(o, _)| *o == k) {
return Err(serde::de::Error::custom(format!("parameter `{k}` is declared twice")));
}
out.push((k, v));
}
Ok(out)
}
}
d.deserialize_any(V)
}
fn params_as_object<S: serde::Serializer>(p: &[(String, ParamSpec)], s: S) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut m = s.serialize_map(Some(p.len()))?;
for (k, v) in p {
m.serialize_entry(k, v)?;
}
m.end()
}
/// `[A-Za-z0-9._-]{1,64}`.
pub fn valid_id(id: &str) -> bool {
(1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
/// `[A-Za-z0-9_]{1,64}`, not starting with `_` and not a key the commands and dialogs use
/// themselves.
fn valid_key(k: &str) -> bool {
(1..=64).contains(&k.len())
&& k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
&& !k.starts_with('_')
&& !matches!(k, "id" | "ids" | "params" | "preview")
}
#[cfg(test)]
mod tests {
use super::*;
const FULL: &str = r#"{"id":"org.example.tone","name":"Tone","version":"1.2","kind":"effect",
"params":{"amount":{"type":"number","min":0,"max":100,"default":50},
"mode":{"type":"choice","options":["soft","hard"],"default":"hard"},
"mono":{"type":"bool"},"seed":{"type":"int","min":0,"max":9}}}"#;
#[test]
fn parses_and_keeps_param_order() {
let m = Manifest::parse(FULL.as_bytes()).unwrap();
assert_eq!(m.id, "org.example.tone");
assert_eq!(m.kind, Kind::Effect);
assert_eq!(m.params.iter().map(|p| p.0.as_str()).collect::<Vec<_>>(), ["amount", "mode", "mono", "seed"]);
assert_eq!(m.params_doc(), r#"{amount: 0..100 (50), mode: "soft"|"hard" (hard), mono: bool (false), seed: int 0..9 (0)}"#);
assert_eq!(Value::Object(m.defaults()), json!({"amount": 50.0, "mode": "hard", "mono": false, "seed": 0}));
}
#[test]
fn resolves_defaults_clamps_and_rejects_wrong_types() {
let m = Manifest::parse(FULL.as_bytes()).unwrap();
let p = m.resolve_params(&json!({"amount": 500, "seed": 3.6, "junk": 1})).unwrap();
assert_eq!(Value::Object(p), json!({"amount": 100.0, "mode": "hard", "mono": false, "seed": 4}));
assert!(m.resolve_params(&json!({"amount": "lots"})).is_err());
assert!(m.resolve_params(&json!({"mode": "medium"})).is_err());
assert!(m.resolve_params(&json!({"mono": 1})).is_err());
assert!(m.resolve_params(&json!([1, 2])).is_err());
assert!(m.resolve_params(&Value::Null).is_ok());
}
#[test]
fn integer_parameters_preserve_precision_and_exact_bounds() {
let m = Manifest::parse(
br#"{"id":"org.example.generative","name":"Generative Pattern","kind":"filter",
"params":{"seed":{"type":"int","min":-9007199254740995,"max":9007199254740995}}}"#,
)
.unwrap();
for (given, expected) in [
(json!(9007199254740992_i64), 9007199254740992_i64),
(json!(9007199254740993_i64), 9007199254740993_i64),
(json!(-9007199254740993_i64), -9007199254740993_i64),
(json!(i64::MAX), 9007199254740995_i64),
(json!(i64::MIN), -9007199254740995_i64),
(json!(u64::MAX), 9007199254740995_i64),
(json!(1e30), 9007199254740995_i64),
(json!(-1e30), -9007199254740995_i64),
(json!(3.6), 4_i64),
] {
let resolved = m.resolve_params(&json!({"seed": given})).unwrap();
assert_eq!(resolved["seed"], json!(expected), "input: {given}");
}
}
#[test]
fn numeric_defaults_stay_in_the_declared_range() {
let m = Manifest::parse(
br#"{"id":"org.example.defaults","name":"Defaults","kind":"filter",
"params":{"count":{"type":"int","min":1,"max":10},
"seed":{"type":"int","min":-9007199254740995,"max":-9007199254740993,"default":0},
"amount":{"type":"number","min":0,"max":100,"default":500}}}"#,
)
.unwrap();
let expected = json!({"count": 1, "seed": -9007199254740993_i64, "amount": 100.0});
assert_eq!(Value::Object(m.defaults()), expected);
assert_eq!(
m.params_doc(),
"{count: int 1..10 (1), seed: int -9007199254740995..-9007199254740993 (-9007199254740993), amount: 0..100 (100)}"
);
for given in [Value::Null, json!({}), json!({"count": null, "seed": null, "amount": null})] {
assert_eq!(Value::Object(m.resolve_params(&given).unwrap()), expected);
}
}
#[test]
fn rejects_bad_manifests() {
for bad in [
"",
"[]",
"not json",
r#"{"id":"","name":"x","kind":"filter"}"#,
r#"{"id":"a b","name":"x","kind":"filter"}"#,
r#"{"id":"a","name":"","kind":"filter"}"#,
r#"{"id":"a","name":"x","kind":"panel"}"#,
r#"{"id":"a","name":"x"}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"c":{"type":"choice","options":[]}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"__x":{"type":"bool"}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"preview":{"type":"bool"}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"a":{"type":"bool"},"a":{"type":"bool"}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":[1]}"#,
] {
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
}
assert!(Manifest::parse(&[0xff, 0xfe]).is_err());
assert!(Manifest::parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]).is_err());
}
}