//! 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, #[serde(default)] default: Option, }, } 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. pub fn default_value(&self) -> Value { match self { ParamSpec::Number { default, .. } => json!(default), ParamSpec::Int { default, .. } => json!(default), 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 { 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 = self .params .iter() .map(|(k, spec)| match spec { ParamSpec::Number { min, max, default } => format!("{k}: {min}..{max} ({default})"), ParamSpec::Int { min, max, default } => format!("{k}: int {min}..{max} ({default})"), ParamSpec::Bool { default } => format!("{k}: bool ({default})"), ParamSpec::Choice { options, .. } => { let opts: Vec = 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 { 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> { 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, 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(self) -> std::result::Result { Ok(Vec::new()) } fn visit_map>(self, mut map: A) -> std::result::Result { let mut out: Vec<(String, ParamSpec)> = Vec::new(); while let Some((k, v)) = map.next_entry::()? { 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(p: &[(String, ParamSpec)], s: S) -> std::result::Result { 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::>(), ["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 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()); } }