mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 18:54:55 +00:00
A filter at plug-in API level 2 with the new `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call runs through Session::execute on the run's document and sees what earlier calls changed; the calls and the filter's output are one undo step, and a run that fails, is cancelled or makes a failed call leaves the documents, their history and the session state the commands change as they were. Commands that touch files, the clipboard, preferences, the view, scripts, actions, plug-ins, open documents or the undo history are never callable, and every other command must be listed as allowed in cmd/plugin_call/reviewed.rs, so a new command is refused until placed. Calls are capped at 1,000 a run, 8 MiB a call and 64 MiB a run, and each costs instructions from the run's budget. plugin.install refuses a manifest that lists a refused command and reports unknown ids; plugin.inspect lists both, and the approval dialog lists the commands. Refs #2186
1365 lines
72 KiB
Rust
1365 lines
72 KiB
Rust
//! The plug-in manifest (`vc_manifest`) and parameter schema: what each parameter holds
|
||
//! ([`ParamSpec`]) and how dialogs show it ([`ParamUi`], in [`crate::dialog`]).
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Map, Value, json};
|
||
|
||
use crate::dialog::ParamUi;
|
||
use crate::permissions::{self, API_LEVEL, INPUTS_API, PROGRESS_API, Permission};
|
||
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 = 256;
|
||
/// Most parameters outside groups (`group`): a dialog of more is sorted into sections.
|
||
pub const MAX_UNGROUPED: usize = 64;
|
||
/// Longest text a `string` parameter may hold, in characters.
|
||
pub const MAX_TEXT: usize = 4_096;
|
||
/// Most numbers a `numberList` parameter may hold.
|
||
pub const MAX_LIST: usize = 256;
|
||
/// Most file extensions an exporter or an importer may declare.
|
||
pub const MAX_EXTENSIONS: usize = 8;
|
||
/// Most menu commands a plug-in may add (`commands`).
|
||
pub const MAX_COMMANDS: usize = 32;
|
||
/// Longest command id, in characters.
|
||
pub const MAX_COMMAND_ID: usize = 32;
|
||
|
||
/// Most commands a manifest's `commandAllowlist` names.
|
||
pub const MAX_ALLOWED_COMMANDS: usize = 256;
|
||
|
||
/// Longest command id a manifest's `commandAllowlist` names, in bytes.
|
||
pub const MAX_ALLOWED_ID: usize = 128;
|
||
|
||
/// 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,
|
||
/// An exporter: writes the document in a file format of its own, whose extensions the manifest
|
||
/// lists (File › Export › Export As, `document.export`; see [`crate::export`]).
|
||
Exporter,
|
||
/// An importer: reads a file in a format of its own, whose extensions the manifest lists, into
|
||
/// a new document (File › Open, `document.open`; see [`crate::import`]).
|
||
Importer,
|
||
}
|
||
|
||
impl Kind {
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
Kind::Filter => "filter",
|
||
Kind::Effect => "effect",
|
||
Kind::Exporter => "exporter",
|
||
Kind::Importer => "importer",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A kind of object a filter may ask to be given besides paths and compound paths (its manifest's
|
||
/// `inputs`, plug-in API level 2). The filter reads these objects and returns each one as it was
|
||
/// given: it can't change or delete them ([`crate::objects::decode_output_with`]).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
pub enum InputKind {
|
||
/// Point, area and path type: its text runs with their character attributes, and its
|
||
/// paragraph attributes.
|
||
Type,
|
||
/// An embedded or linked image: its size, color mode and, when small, its pixels.
|
||
Image,
|
||
/// A symbol instance: the symbol it shows and its transform.
|
||
SymbolInstance,
|
||
}
|
||
|
||
impl InputKind {
|
||
/// Every kind, in the order the docs list them.
|
||
pub const ALL: [InputKind; 3] = [InputKind::Type, InputKind::Image, InputKind::SymbolInstance];
|
||
|
||
/// Its name in `inputs`, which is also the `type` of such an object in a filter's input.
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
InputKind::Type => "type",
|
||
InputKind::Image => "image",
|
||
InputKind::SymbolInstance => "symbolInstance",
|
||
}
|
||
}
|
||
|
||
/// The kind named `name`.
|
||
pub fn parse(name: &str) -> Option<InputKind> {
|
||
InputKind::ALL.into_iter().find(|k| k.name() == name)
|
||
}
|
||
}
|
||
|
||
/// How much one run of a plug-in may compute.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum Budget {
|
||
/// The usual budget ([`crate::Limits::default`]).
|
||
#[default]
|
||
Light,
|
||
/// A filter's larger budget for heavy but legitimate work (routing, packing, simulation):
|
||
/// [`crate::HEAVY_FUEL`] instructions and [`crate::HEAVY_WALL_TIME_MS`]. The
|
||
/// user allows it once, when the plug-in is installed, and the app runs it in the background
|
||
/// with Cancel.
|
||
Heavy,
|
||
}
|
||
|
||
impl Budget {
|
||
/// The usual budget, which a manifest written back leaves out (`skip_serializing_if`).
|
||
fn is_light(&self) -> bool {
|
||
*self == Budget::Light
|
||
}
|
||
}
|
||
|
||
/// 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>,
|
||
},
|
||
/// Text of at most `max_length` characters (a text field).
|
||
#[serde(rename = "string")]
|
||
Text {
|
||
#[serde(default)]
|
||
default: String,
|
||
#[serde(default = "text_max", rename = "maxLength")]
|
||
max_length: usize,
|
||
},
|
||
/// `min_items` to `max_items` numbers, each in `min..=max` (a field of numbers separated by
|
||
/// commas or spaces).
|
||
#[serde(rename_all = "camelCase")]
|
||
NumberList {
|
||
min: f64,
|
||
max: f64,
|
||
#[serde(default)]
|
||
default: Vec<f64>,
|
||
#[serde(default)]
|
||
min_items: usize,
|
||
#[serde(default = "list_max")]
|
||
max_items: usize,
|
||
},
|
||
/// A color (a color button that opens a color picker). Its default, and a value passed to
|
||
/// `plugin.run`, is a color object as paints hold one (`{"model": "rgb", "r", "g", "b"}`, or
|
||
/// `cmyk`, `gray`, `lab`) or a hex string (`"#3a7bd5"`); the plug-in always gets a color object.
|
||
Color {
|
||
/// As the manifest gives it (`null`: black), read with [`color_value`] when used, so a hex
|
||
/// default follows the working RGB space in use then, not the one at install.
|
||
#[serde(default)]
|
||
default: Value,
|
||
},
|
||
}
|
||
|
||
/// A color parameter's value: a color object, or a hex string (display sRGB, converted into the
|
||
/// active working RGB space as the color picker's choices are), its components clamped into range.
|
||
pub fn color_value(v: &Value) -> Option<vectorcraft_color::Color> {
|
||
use vectorcraft_color::{Color, cms};
|
||
let c = match v {
|
||
Value::String(s) => match Color::from_hex(s)? {
|
||
Color::Rgb { r, g, b } => {
|
||
let [r, g, b] = cms::active().srgb_to_rgb([r, g, b]);
|
||
Color::rgb(r, g, b)
|
||
}
|
||
other => other,
|
||
},
|
||
Value::Object(_) => serde_json::from_value(v.clone()).ok()?,
|
||
_ => return None,
|
||
};
|
||
Some(crate::objects::clamp_color(c))
|
||
}
|
||
|
||
fn int_min() -> i64 {
|
||
-1_000_000
|
||
}
|
||
fn int_max() -> i64 {
|
||
1_000_000
|
||
}
|
||
fn text_max() -> usize {
|
||
256
|
||
}
|
||
fn list_max() -> usize {
|
||
16
|
||
}
|
||
fn first_api() -> u32 {
|
||
1
|
||
}
|
||
fn is_first_api(api: &u32) -> bool {
|
||
*api == 1
|
||
}
|
||
|
||
/// One declared parameter: what it holds, and how dialogs show it. In the manifest both are keys of
|
||
/// the same object: `{"type": "number", "min": 0, "max": 10, "default": 1, "unit": "mm"}`.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct Param {
|
||
#[serde(flatten)]
|
||
pub spec: ParamSpec,
|
||
#[serde(flatten)]
|
||
pub ui: ParamUi,
|
||
}
|
||
|
||
/// A menu command a plug-in adds (`commands`): the host lists it under Object › Plug-ins › the
|
||
/// plug-in's name (Effect › Plug-ins for a live effect's), runs it as the command
|
||
/// `plugin.<plug-in id>.<id>`, and calls the plug-in's `vc_run` with `_context.command` set to
|
||
/// `id` and the parameters it declares.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct Command {
|
||
/// `[a-z][a-z0-9_]*`, at most [`MAX_COMMAND_ID`] characters, unique within the manifest.
|
||
pub id: String,
|
||
/// The menu label: 1 to 64 printable characters, shown with bidirectional characters escaped
|
||
/// ([`crate::permissions::display_text`]).
|
||
pub label: String,
|
||
/// Parameters in declaration (dialog) order, declared as the manifest's own are.
|
||
#[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")]
|
||
pub params: Vec<(String, Param)>,
|
||
}
|
||
|
||
impl Command {
|
||
/// The label as menus and errors show it: control and bidirectional characters escaped, at
|
||
/// most [`crate::permissions::MAX_SHOWN_NAME`] characters.
|
||
pub fn shown_label(&self) -> String {
|
||
crate::permissions::display_text(self.label.trim(), crate::permissions::MAX_SHOWN_NAME)
|
||
}
|
||
}
|
||
|
||
impl From<ParamSpec> for Param {
|
||
fn from(spec: ParamSpec) -> Self {
|
||
Param { spec, ui: ParamUi::default() }
|
||
}
|
||
}
|
||
|
||
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()),
|
||
ParamSpec::Text { default, max_length } => json!(default.chars().take(*max_length).collect::<String>()),
|
||
ParamSpec::NumberList { min, max, default, .. } => json!(default.iter().map(|x| x.clamp(*min, *max)).collect::<Vec<f64>>()),
|
||
ParamSpec::Color { default } => serde_json::to_value(color_value(default).unwrap_or_default()).unwrap_or_default(),
|
||
}
|
||
}
|
||
|
||
/// Checks the spec of parameter `k`.
|
||
fn validate(&self, k: &str) -> Result<()> {
|
||
let bad = |what: &str| Err(Error::Manifest(format!("parameter `{k}`: {what}")));
|
||
match self {
|
||
ParamSpec::Number { min, max, default } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => bad("bad range"),
|
||
ParamSpec::Int { min, max, .. } if min > max => bad("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 bad("choices must be 1-64 non-empty, printable strings");
|
||
}
|
||
if default.as_ref().is_some_and(|d| !options.contains(d)) {
|
||
return bad("default is not one of the options");
|
||
}
|
||
Ok(())
|
||
}
|
||
ParamSpec::Color { default } if !default.is_null() && color_value(default).is_none() => {
|
||
bad("a color default must be a color object or a hex string such as \"#3a7bd5\"")
|
||
}
|
||
ParamSpec::Text { default, max_length } if !(1..=MAX_TEXT).contains(max_length) || default.chars().count() > *max_length => {
|
||
bad(&format!("`maxLength` must be 1-{MAX_TEXT}, and the default no longer"))
|
||
}
|
||
ParamSpec::NumberList { min, max, default, min_items, max_items } => {
|
||
if !(min.is_finite() && max.is_finite()) || min > max || default.iter().any(|x| !x.is_finite()) {
|
||
return bad("bad range");
|
||
}
|
||
if !(1..=MAX_LIST).contains(max_items) || min_items > max_items || !(*min_items..=*max_items).contains(&default.len()) {
|
||
return bad(&format!("`minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many"));
|
||
}
|
||
Ok(())
|
||
}
|
||
_ => Ok(()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
/// The plug-in API level it was built for, 1 to [`API_LEVEL`] (1 when left out).
|
||
#[serde(default = "first_api", skip_serializing_if = "is_first_api")]
|
||
pub api: u32,
|
||
/// The permissions it asks for ([`crate::permissions`]), which the user approves when it is
|
||
/// installed. Left out, it gets the ones its kind implies ([`Permission::implied`]).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub permissions: Option<Vec<String>>,
|
||
/// The kinds of object a filter asks to be given besides paths and compound paths, by
|
||
/// [`InputKind::name`]: only a filter at `api` 2 or later whose `permissions` list `doc.read`
|
||
/// lists them. Left out or empty, it is given paths and compound paths alone.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "input_names")]
|
||
pub inputs: Vec<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, Param)>,
|
||
/// An exporter's or an importer's file extensions, lower case and without the dot; the first is
|
||
/// the one an exporter's files get. Only exporters and importers declare them.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub extensions: Vec<String>,
|
||
/// How much a run may compute: only a filter may be [`Budget::Heavy`].
|
||
#[serde(default, skip_serializing_if = "Budget::is_light")]
|
||
pub budget: Budget,
|
||
/// A live effect at `api` 2 that returns painted pieces ([`crate::painted`]): `true` when the
|
||
/// manifest has `"paintOutput": true`, the only value the key may hold.
|
||
#[serde(default, rename = "paintOutput", skip_serializing_if = "std::ops::Not::not")]
|
||
pub paint_output: bool,
|
||
/// The menu commands it adds, in menu order (a filter or a live effect only).
|
||
#[serde(default, deserialize_with = "ordered_commands", skip_serializing_if = "Vec::is_empty")]
|
||
pub commands: Vec<Command>,
|
||
/// The VectorCraft commands a run may call with `vc_call` (exact command ids, such as
|
||
/// `object.transform`): only a filter with the usual budget whose `permissions` list `commands`
|
||
/// lists any ([`Manifest::validate`]).
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub command_allowlist: Vec<String>,
|
||
}
|
||
|
||
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()))?;
|
||
if serde_json::from_str::<Value>(text).is_ok_and(|v| v.get("paintOutput").is_some_and(|p| *p != Value::Bool(true))) {
|
||
return Err(Error::Manifest("`paintOutput` may only be true".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(),
|
||
api: 1,
|
||
permissions: None,
|
||
inputs: Vec::new(),
|
||
params: Vec::new(),
|
||
extensions: Vec::new(),
|
||
budget: Budget::Light,
|
||
paint_output: false,
|
||
commands: Vec::new(),
|
||
command_allowlist: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// The manifest command `c` runs with: this one with `c`'s label as its name and `c`'s
|
||
/// parameters as its own, so dialogs show them and [`Manifest::resolve_params`] checks them.
|
||
pub fn for_command(&self, c: &Command) -> Manifest {
|
||
Manifest { name: c.label.clone(), params: c.params.clone(), commands: Vec::new(), ..self.clone() }
|
||
}
|
||
|
||
/// The command with id `id`.
|
||
pub fn command(&self, id: &str) -> Option<&Command> {
|
||
self.commands.iter().find(|c| c.id == id)
|
||
}
|
||
|
||
/// Checks what the module declared: its id, name and text lengths, its extensions, a budget its
|
||
/// kind may have (only a filter may be heavy), and each parameter and its dialog layout.
|
||
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()));
|
||
}
|
||
if !(1..=API_LEVEL).contains(&self.api) {
|
||
return Err(Error::Manifest(format!("`api` is {}; this host implements plug-in API levels 1 to {API_LEVEL}", self.api)));
|
||
}
|
||
if let Some(names) = &self.permissions
|
||
&& let Some(host) =
|
||
permissions::validate(self.kind, !self.commands.is_empty(), names)?.into_iter().find(|p| permissions::HOST.contains(p))
|
||
&& self.api < PROGRESS_API
|
||
{
|
||
let permission = host.name();
|
||
return Err(Error::Manifest(format!("permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}")));
|
||
}
|
||
self.validate_inputs()?;
|
||
if self.paint_output && (self.kind != Kind::Effect || self.api < 2) {
|
||
return Err(Error::Manifest("`paintOutput` is only for a live effect (`kind` \"effect\") at `api` 2".into()));
|
||
}
|
||
self.validate_extensions()?;
|
||
match (self.budget, self.kind) {
|
||
(Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {}
|
||
(Budget::Heavy, Kind::Effect) => {
|
||
return Err(Error::Manifest("a live effect runs while the document is drawn, so it can't be `heavy`".into()));
|
||
}
|
||
(Budget::Heavy, Kind::Exporter) => {
|
||
return Err(Error::Manifest("an exporter always gets an export's budget: leave `budget` out".into()));
|
||
}
|
||
(Budget::Heavy, Kind::Importer) => {
|
||
return Err(Error::Manifest("an importer always gets an import's budget: leave `budget` out".into()));
|
||
}
|
||
}
|
||
self.validate_params()?;
|
||
self.validate_commands()?;
|
||
self.validate_allowlist()
|
||
}
|
||
|
||
/// `commandAllowlist` lists at most [`MAX_ALLOWED_COMMANDS`] distinct command ids
|
||
/// ([`valid_allowed_id`]), and only a filter with the usual budget lists any: a heavy one runs in
|
||
/// the background, away from the document its commands would change. A list needs the
|
||
/// permission `commands`, and `commands` needs a list.
|
||
fn validate_allowlist(&self) -> Result<()> {
|
||
let listed = self.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == Permission::Commands.name()));
|
||
if self.command_allowlist.is_empty() == listed {
|
||
return Err(Error::Manifest("`commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist`".into()));
|
||
}
|
||
if self.command_allowlist.is_empty() {
|
||
return Ok(());
|
||
}
|
||
if self.kind != Kind::Filter || self.budget == Budget::Heavy {
|
||
return Err(Error::Manifest("only a filter with the usual budget declares `commandAllowlist`".into()));
|
||
}
|
||
if self.command_allowlist.len() > MAX_ALLOWED_COMMANDS {
|
||
return Err(Error::Manifest(format!("at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist`")));
|
||
}
|
||
for (i, id) in self.command_allowlist.iter().enumerate() {
|
||
if !valid_allowed_id(id) || self.command_allowlist.get(..i).is_some_and(|before| before.contains(id)) {
|
||
let shown = permissions::display_text(id, permissions::MAX_SHOWN_FIELD);
|
||
return Err(Error::Manifest(format!("`commandAllowlist` names `{shown}`, which is not a command id or is listed twice")));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// May a run call command `id` ([`Manifest::command_allowlist`])?
|
||
pub fn allows_call(&self, id: &str) -> bool {
|
||
self.command_allowlist.iter().any(|a| a == id)
|
||
}
|
||
|
||
/// Checks each parameter and its dialog layout.
|
||
fn validate_params(&self) -> Result<()> {
|
||
for (i, (k, p)) in self.params.iter().enumerate() {
|
||
if !valid_key(k) {
|
||
return Err(Error::Manifest(format!("invalid parameter name {k:?}")));
|
||
}
|
||
p.spec.validate(k)?;
|
||
p.ui.validate(self, i, k)?;
|
||
}
|
||
if self.params.iter().filter(|(_, p)| p.ui.group.is_none()).count() > MAX_UNGROUPED {
|
||
return Err(Error::Manifest(format!("at most {MAX_UNGROUPED} parameters outside groups: give the others a `group`")));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// `inputs` names each [`InputKind`] at most once, and only a filter at `api` 2 or later lists
|
||
/// any, with a `permissions` list that holds `doc.read`: installing it then asks the user to
|
||
/// grant them ([`Manifest::asks_permissions`]).
|
||
fn validate_inputs(&self) -> Result<()> {
|
||
if self.inputs.is_empty() {
|
||
return Ok(());
|
||
}
|
||
if self.kind != Kind::Filter {
|
||
return Err(Error::Manifest("only a filter declares `inputs`".into()));
|
||
}
|
||
for (i, name) in self.inputs.iter().enumerate() {
|
||
let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD);
|
||
if InputKind::parse(name).is_none() {
|
||
return Err(Error::Manifest(format!("`inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance")));
|
||
}
|
||
if self.inputs.get(..i).is_some_and(|before| before.contains(name)) {
|
||
return Err(Error::Manifest(format!("`inputs` lists `{shown}` twice")));
|
||
}
|
||
}
|
||
if self.api < INPUTS_API {
|
||
return Err(Error::Manifest("`inputs` needs `api` 2 or later".into()));
|
||
}
|
||
if !self.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == Permission::DocRead.name())) {
|
||
return Err(Error::Manifest("`inputs` needs the permission `doc.read`".into()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A filter or a live effect lists at most [`MAX_COMMANDS`] commands, each with a distinct
|
||
/// id, a printable label and parameters declared as the manifest's own are. A heavy filter
|
||
/// lists none. The error names the command: its id, or its place in the list.
|
||
fn validate_commands(&self) -> Result<()> {
|
||
if self.commands.is_empty() {
|
||
return Ok(());
|
||
}
|
||
if matches!(self.kind, Kind::Exporter | Kind::Importer) {
|
||
return Err(Error::Manifest("only a filter or a live effect declares `commands`".into()));
|
||
}
|
||
if self.budget == Budget::Heavy {
|
||
return Err(Error::Manifest("a heavy filter runs in the background, so it can't declare `commands`".into()));
|
||
}
|
||
if self.commands.len() > MAX_COMMANDS {
|
||
return Err(Error::Manifest(format!("at most {MAX_COMMANDS} `commands`")));
|
||
}
|
||
for (i, c) in self.commands.iter().enumerate() {
|
||
let name = command_name(i, &c.id);
|
||
if !valid_command_id(&c.id) {
|
||
return Err(Error::Manifest(format!(
|
||
"command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter"
|
||
)));
|
||
}
|
||
let label = c.label.trim();
|
||
if label.is_empty() || label.chars().count() > 64 || label.chars().any(char::is_control) {
|
||
return Err(Error::Manifest(format!("command {name}: `label` must be 1-64 printable characters")));
|
||
}
|
||
if self.commands.get(..i).is_some_and(|before| before.iter().any(|b| b.id == c.id)) {
|
||
return Err(Error::Manifest(format!("command {name} is declared twice")));
|
||
}
|
||
self.for_command(c).validate_params().map_err(|e| {
|
||
let e = match e {
|
||
Error::Manifest(why) => why,
|
||
other => other.to_string(),
|
||
};
|
||
Error::Manifest(format!("command {name}: {e}"))
|
||
})?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The kinds of object besides paths and compound paths it asks to be given (`inputs`).
|
||
pub fn input_kinds(&self) -> Vec<InputKind> {
|
||
self.inputs.iter().filter_map(|n| InputKind::parse(n)).collect()
|
||
}
|
||
|
||
/// An exporter or an importer lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16
|
||
/// lower-case letters and digits; other kinds list none.
|
||
fn validate_extensions(&self) -> Result<()> {
|
||
let exts = &self.extensions;
|
||
let count_ok = !exts.is_empty() && exts.len() <= MAX_EXTENSIONS;
|
||
match self.kind {
|
||
Kind::Exporter if !count_ok => return Err(Error::Manifest(format!("an exporter declares 1 to {MAX_EXTENSIONS} `extensions`"))),
|
||
Kind::Importer if !count_ok => return Err(Error::Manifest(format!("an importer declares 1 to {MAX_EXTENSIONS} `extensions`"))),
|
||
Kind::Exporter | Kind::Importer => {}
|
||
_ if exts.is_empty() => return Ok(()),
|
||
_ => return Err(Error::Manifest("only an exporter or an importer declares `extensions`".into())),
|
||
}
|
||
for (i, e) in exts.iter().enumerate() {
|
||
if !(1..=16).contains(&e.len()) || !e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) {
|
||
return Err(Error::Manifest(format!("extension {e:?}: use 1-16 lower-case letters and digits, without the dot")));
|
||
}
|
||
if exts.get(..i).is_some_and(|before| before.contains(e)) {
|
||
return Err(Error::Manifest(format!("extension {e:?} is listed twice")));
|
||
}
|
||
}
|
||
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, p)| match &p.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("|"), p.spec.default_value().as_str().unwrap_or_default())
|
||
}
|
||
ParamSpec::Text { max_length, .. } => format!("{k}: string of at most {max_length} ({})", p.spec.default_value()),
|
||
ParamSpec::NumberList { min, max, min_items, max_items, .. } => {
|
||
format!("{k}: {min_items} to {max_items} numbers {min}..{max} ({})", p.spec.default_value())
|
||
}
|
||
ParamSpec::Color { default } => format!("{k}: color object or hex ({})", color_value(default).unwrap_or_default().to_hex()),
|
||
})
|
||
.collect();
|
||
format!("{{{}}}", parts.join(", "))
|
||
}
|
||
|
||
/// Every parameter at its default value.
|
||
pub fn defaults(&self) -> Map<String, Value> {
|
||
self.params.iter().map(|(k, p)| (k.clone(), p.spec.default_value())).collect()
|
||
}
|
||
|
||
/// What the plug-in may do: the permissions it lists, else every one its kind may list
|
||
/// ([`Permission::allowed`]: a live effect with commands also changes the document and keeps
|
||
/// data with it).
|
||
pub fn permissions(&self) -> Vec<Permission> {
|
||
let commands = !self.commands.is_empty();
|
||
match &self.permissions {
|
||
Some(names) => permissions::validate(self.kind, commands, names).unwrap_or_default(),
|
||
None => Permission::allowed(self.kind, commands).to_vec(),
|
||
}
|
||
}
|
||
|
||
/// Does installing it need the user's approval: does it list permissions?
|
||
pub fn asks_permissions(&self) -> bool {
|
||
self.permissions.as_ref().is_some_and(|p| !p.is_empty())
|
||
}
|
||
|
||
/// 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, p) in &self.params {
|
||
let spec = &p.spec;
|
||
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)
|
||
}
|
||
ParamSpec::Text { max_length, .. } => {
|
||
json!(v.as_str().ok_or_else(|| wrong("a string"))?.chars().take(*max_length).collect::<String>())
|
||
}
|
||
ParamSpec::NumberList { min, max, min_items, max_items, .. } => {
|
||
let items = v.as_array().ok_or_else(|| wrong("a list of numbers"))?;
|
||
if !(*min_items..=*max_items).contains(&items.len()) {
|
||
return Err(wrong(&format!("a list of {min_items} to {max_items} numbers")));
|
||
}
|
||
let numbers: Option<Vec<f64>> = items.iter().map(|x| x.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(*min, *max))).collect();
|
||
json!(numbers.ok_or_else(|| wrong("a list of numbers"))?)
|
||
}
|
||
ParamSpec::Color { .. } => {
|
||
let c = color_value(v).ok_or_else(|| wrong("a color object or a hex string such as \"#3a7bd5\""))?;
|
||
serde_json::to_value(c).map_err(|e| Error::Params(format!("`{k}`: {e}")))?
|
||
}
|
||
};
|
||
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, Param)>, D::Error> {
|
||
struct V;
|
||
impl<'de> serde::de::Visitor<'de> for V {
|
||
type Value = Vec<(String, Param)>;
|
||
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, Param)> = Vec::new();
|
||
while let Some((k, v)) = map.next_entry::<String, Param>()? {
|
||
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)
|
||
}
|
||
|
||
/// Reads `inputs`: a list of names (`null`: none). Anything else is an error that names the key, as
|
||
/// [`Manifest::validate_inputs`] names a wrong entry.
|
||
fn input_names<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<String>, D::Error> {
|
||
let wrong = || serde::de::Error::custom("`inputs` must be a list of object kinds: type, image or symbolInstance");
|
||
match Value::deserialize(d)? {
|
||
Value::Null => Ok(Vec::new()),
|
||
Value::Array(names) => names.into_iter().map(|n| if let Value::String(s) = n { Ok(s) } else { Err(wrong()) }).collect(),
|
||
_ => Err(wrong()),
|
||
}
|
||
}
|
||
|
||
/// Reads the `commands` list one entry at a time from the manifest itself, so each command's
|
||
/// `params` keep their declaration order and a repeated parameter is refused. A malformed entry
|
||
/// is an error that names it (its id when the id comes before the fault, otherwise its place in
|
||
/// the list), and a list is refused at its first entry past [`MAX_COMMANDS`].
|
||
fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<Command>, D::Error> {
|
||
use serde::de::{Error as _, IgnoredAny, MapAccess, SeqAccess, Visitor};
|
||
|
||
/// A command's `params`, read in declaration order.
|
||
struct Params(Vec<(String, Param)>);
|
||
impl<'de> Deserialize<'de> for Params {
|
||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||
ordered_params(d).map(Params)
|
||
}
|
||
}
|
||
|
||
/// The entry at place `.0` in the list.
|
||
struct Entry(usize);
|
||
impl<'de> serde::de::DeserializeSeed<'de> for Entry {
|
||
type Value = Command;
|
||
fn deserialize<D: serde::Deserializer<'de>>(self, d: D) -> std::result::Result<Command, D::Error> {
|
||
d.deserialize_any(self)
|
||
}
|
||
}
|
||
impl<'de> Visitor<'de> for Entry {
|
||
type Value = Command;
|
||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||
write!(f, "command [{}] as {{id, label, params?}}", self.0)
|
||
}
|
||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> std::result::Result<Command, A::Error> {
|
||
let (mut id, mut label, mut params) = (None::<String>, None::<String>, None::<Params>);
|
||
let named = |id: &Option<String>, e: &dyn std::fmt::Display| {
|
||
A::Error::custom(format!("command {}: {e}", command_name(self.0, id.as_deref().unwrap_or_default())))
|
||
};
|
||
while let Some(key) = map.next_key::<String>().map_err(|e| named(&id, &e))? {
|
||
let repeated = match key.as_str() {
|
||
"id" if id.is_none() => {
|
||
id = Some(map.next_value().map_err(|e| named(&id, &e))?);
|
||
false
|
||
}
|
||
"label" if label.is_none() => {
|
||
label = Some(map.next_value().map_err(|e| named(&id, &e))?);
|
||
false
|
||
}
|
||
"params" if params.is_none() => {
|
||
params = Some(map.next_value().map_err(|e| named(&id, &e))?);
|
||
false
|
||
}
|
||
"id" | "label" | "params" => true,
|
||
_ => {
|
||
map.next_value::<IgnoredAny>().map_err(|e| named(&id, &e))?;
|
||
false
|
||
}
|
||
};
|
||
if repeated {
|
||
return Err(named(&id, &format_args!("duplicate field `{key}`")));
|
||
}
|
||
}
|
||
let Some(id) = id else {
|
||
return Err(named(&None, &"missing field `id`"));
|
||
};
|
||
let Some(label) = label else {
|
||
return Err(named(&Some(id), &"missing field `label`"));
|
||
};
|
||
Ok(Command { id, label, params: params.map(|p| p.0).unwrap_or_default() })
|
||
}
|
||
}
|
||
|
||
struct List;
|
||
impl<'de> Visitor<'de> for List {
|
||
type Value = Vec<Command>;
|
||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||
f.write_str("`commands` as a list of {id, label, params?}")
|
||
}
|
||
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
|
||
Ok(Vec::new())
|
||
}
|
||
fn visit_map<A: MapAccess<'de>>(self, _: A) -> std::result::Result<Self::Value, A::Error> {
|
||
Err(A::Error::custom("`commands` must be a list of {id, label, params?}"))
|
||
}
|
||
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error> {
|
||
let mut out = Vec::new();
|
||
while out.len() < MAX_COMMANDS {
|
||
match seq.next_element_seed(Entry(out.len()))? {
|
||
Some(c) => out.push(c),
|
||
None => return Ok(out),
|
||
}
|
||
}
|
||
match seq.next_element::<IgnoredAny>()? {
|
||
Some(_) => Err(A::Error::custom(format!("at most {MAX_COMMANDS} `commands`"))),
|
||
None => Ok(out),
|
||
}
|
||
}
|
||
}
|
||
|
||
d.deserialize_any(List)
|
||
}
|
||
|
||
/// How errors name the command at place `i` with id `id`: the id in backticks, escaped and capped
|
||
/// as prompts show text, or `[i]` when it has none.
|
||
fn command_name(i: usize, id: &str) -> String {
|
||
match id {
|
||
"" => format!("[{i}]"),
|
||
id => format!("`{}`", crate::permissions::display_text(id, crate::permissions::MAX_SHOWN_FIELD)),
|
||
}
|
||
}
|
||
|
||
/// `[a-z][a-z0-9_]*`, 1 to [`MAX_COMMAND_ID`] characters.
|
||
fn valid_command_id(id: &str) -> bool {
|
||
id.len() <= MAX_COMMAND_ID
|
||
&& id.bytes().next().is_some_and(|b| b.is_ascii_lowercase())
|
||
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
|
||
}
|
||
|
||
fn params_as_object<S: serde::Serializer>(p: &[(String, Param)], 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 command id as a `commandAllowlist` names it: 1 to [`MAX_ALLOWED_ID`] bytes from
|
||
/// `A-Za-z0-9._-`, starting with a letter, with no empty part between dots.
|
||
pub fn valid_allowed_id(id: &str) -> bool {
|
||
(1..=MAX_ALLOWED_ID).contains(&id.len())
|
||
&& id.bytes().next().is_some_and(|b| b.is_ascii_alphabetic())
|
||
&& id.split('.').all(|part| !part.is_empty())
|
||
&& id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||
}
|
||
|
||
/// `[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 an_exporter_lists_its_extensions_and_other_kinds_list_none() {
|
||
let m = Manifest::parse(br#"{"id":"org.example.plotter","name":"Plotter","kind":"exporter","extensions":["plt","plot"]}"#).unwrap();
|
||
assert_eq!((m.kind, m.extensions.as_slice()), (Kind::Exporter, &["plt".to_string(), "plot".to_string()][..]));
|
||
assert_eq!(serde_json::to_value(&m).unwrap()["extensions"], json!(["plt", "plot"]));
|
||
let filter = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap();
|
||
assert!(serde_json::to_value(&filter).unwrap().get("extensions").is_none());
|
||
for bad in [
|
||
r#"{"id":"a","name":"x","kind":"exporter"}"#,
|
||
r#"{"id":"a","name":"x","kind":"exporter","extensions":[]}"#,
|
||
r#"{"id":"a","name":"x","kind":"exporter","extensions":["PLT"]}"#,
|
||
r#"{"id":"a","name":"x","kind":"exporter","extensions":[".plt"]}"#,
|
||
r#"{"id":"a","name":"x","kind":"exporter","extensions":[""]}"#,
|
||
r#"{"id":"a","name":"x","kind":"exporter","extensions":["abcdefghijklmnopq"]}"#,
|
||
r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt","plt"]}"#,
|
||
r#"{"id":"a","name":"x","kind":"exporter","extensions":["a","b","c","d","e","f","g","h","i"]}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","extensions":["plt"]}"#,
|
||
r#"{"id":"a","name":"x","kind":"effect","extensions":["plt"]}"#,
|
||
] {
|
||
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
|
||
}
|
||
}
|
||
|
||
/// An importer lists the extensions it reads by the exporter's rules, and gets an import's
|
||
/// budget and the `import.read` permission.
|
||
#[test]
|
||
fn an_importer_lists_its_extensions() {
|
||
let m = Manifest::parse(br#"{"id":"org.example.plotter","name":"Plotter","kind":"importer","extensions":["plt","hpgl"]}"#).unwrap();
|
||
assert_eq!((m.kind, m.extensions.as_slice()), (Kind::Importer, &["plt".to_string(), "hpgl".to_string()][..]));
|
||
assert_eq!(serde_json::to_value(&m).unwrap()["kind"], json!("importer"));
|
||
assert_eq!(m.permissions(), [Permission::ImportRead]);
|
||
for (bad, says) in [
|
||
(r#"{"id":"a","name":"x","kind":"importer"}"#, "an importer declares 1 to 8"),
|
||
(r#"{"id":"a","name":"x","kind":"importer","extensions":[]}"#, "an importer declares 1 to 8"),
|
||
(r#"{"id":"a","name":"x","kind":"importer","extensions":["a","b","c","d","e","f","g","h","i"]}"#, "an importer declares 1 to 8"),
|
||
(r#"{"id":"a","name":"x","kind":"importer","extensions":["Plt"]}"#, "lower-case"),
|
||
(r#"{"id":"a","name":"x","kind":"importer","extensions":[".plt"]}"#, "without the dot"),
|
||
(r#"{"id":"a","name":"x","kind":"importer","extensions":["abcdefghijklmnopq"]}"#, "1-16"),
|
||
(r#"{"id":"a","name":"x","kind":"importer","extensions":["plt","plt"]}"#, "listed twice"),
|
||
(r#"{"id":"a","name":"x","kind":"importer","extensions":["plt"],"budget":"heavy"}"#, "an import's budget"),
|
||
(r#"{"id":"a","name":"x","kind":"importer","extensions":["plt"],"permissions":["doc.read"]}"#, "doesn't apply"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","extensions":["plt"]}"#, "only an exporter or an importer"),
|
||
] {
|
||
let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string();
|
||
assert!(e.contains(says), "{bad}: {e}");
|
||
}
|
||
}
|
||
|
||
/// A filter may ask for a heavy budget; a live effect runs while the document is drawn and an
|
||
/// exporter has an export's budget, so they can't. Light, the default, isn't written back.
|
||
#[test]
|
||
fn only_a_filter_asks_for_a_heavy_budget() {
|
||
let heavy = Manifest::parse(br#"{"id":"org.example.route","name":"Route","kind":"filter","budget":"heavy"}"#).unwrap();
|
||
assert_eq!(heavy.budget, Budget::Heavy);
|
||
assert_eq!(serde_json::to_value(&heavy).unwrap()["budget"], json!("heavy"));
|
||
for light in [r#"{"id":"a","name":"x","kind":"filter"}"#, r#"{"id":"a","name":"x","kind":"effect","budget":"light"}"#] {
|
||
let m = Manifest::parse(light.as_bytes()).unwrap();
|
||
assert_eq!(m.budget, Budget::Light);
|
||
assert!(serde_json::to_value(&m).unwrap().get("budget").is_none(), "{light}");
|
||
}
|
||
for bad in [
|
||
r#"{"id":"a","name":"x","kind":"effect","budget":"heavy"}"#,
|
||
r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"budget":"heavy"}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","budget":"huge"}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","budget":true}"#,
|
||
] {
|
||
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
|
||
}
|
||
}
|
||
|
||
/// A manifest lists the permissions it asks for, checked against its kind; without the key it
|
||
/// gets the ones its kind implies, as plug-ins did before manifests listed them, and needs no
|
||
/// approval. `api` is 1 when left out, and a later level is refused.
|
||
#[test]
|
||
fn permissions_and_api_level() {
|
||
let listed = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["doc.write","doc.read"]}"#).unwrap();
|
||
assert_eq!(listed.permissions(), [Permission::DocRead, Permission::DocWrite]);
|
||
assert!(listed.asks_permissions());
|
||
assert_eq!(serde_json::to_value(&listed).unwrap()["permissions"], json!(["doc.write", "doc.read"]));
|
||
assert!(serde_json::to_value(&listed).unwrap().get("api").is_none(), "level 1 isn't written back");
|
||
let plain = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap();
|
||
assert_eq!((plain.api, plain.asks_permissions()), (1, false));
|
||
assert_eq!(plain.permissions(), [Permission::DocRead, Permission::DocWrite, Permission::State]);
|
||
assert!(serde_json::to_value(&plain).unwrap().get("permissions").is_none());
|
||
let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect"}"#).unwrap();
|
||
assert_eq!(effect.permissions(), [Permission::DocRead, Permission::EffectRender]);
|
||
// A live effect's commands may change the document: one with commands gets `doc.write`
|
||
// and `state` too, and may list them.
|
||
let commands = br#"{"id":"a","name":"x","kind":"effect","commands":[{"id":"c","label":"C"}]}"#;
|
||
let with_commands = Manifest::parse(commands).unwrap();
|
||
assert_eq!(with_commands.permissions(), [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender]);
|
||
let listing =
|
||
br#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read","effect.render","doc.write"],"commands":[{"id":"c","label":"C"}]}"#;
|
||
assert_eq!(Manifest::parse(listing).unwrap().permissions(), [Permission::DocRead, Permission::DocWrite, Permission::EffectRender]);
|
||
let without = br#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read","effect.render","doc.write"]}"#;
|
||
assert!(Manifest::parse(without).unwrap_err().to_string().contains("`doc.write` doesn't apply"));
|
||
let exporter = Manifest::parse(br#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"]}"#).unwrap();
|
||
assert_eq!(exporter.permissions(), [Permission::DocRead, Permission::ExportWrite]);
|
||
assert!(!Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","permissions":[]}"#).unwrap().asks_permissions());
|
||
// Level 2 adds `progress`, which only a level-2 manifest may list.
|
||
let level2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["progress","doc.read"]}"#).unwrap();
|
||
assert_eq!((level2.api, level2.permissions()), (2, vec![Permission::DocRead, Permission::Progress]));
|
||
assert_eq!(serde_json::to_value(&level2).unwrap()["api"], json!(2));
|
||
let plain2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2}"#).unwrap();
|
||
assert!(!plain2.permissions().contains(&Permission::Progress), "never implied");
|
||
let storing = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["cache","settings"]}"#).unwrap();
|
||
assert_eq!(storing.permissions(), [Permission::Settings, Permission::Cache]);
|
||
assert!(!plain2.permissions().iter().any(|p| permissions::HOST.contains(p)), "never implied");
|
||
for (bad, says) in [
|
||
(r#"{"id":"a","name":"x","kind":"filter","permissions":["doc.read","net.fetch"]}"#, "unknown permission `net.fetch`"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","permissions":["ui.panel"]}"#, "reserved"),
|
||
(r#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read"]}"#, "effect.render"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","permissions":"doc.read"}"#, "manifest"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","permissions":["progress"]}"#, "`progress` needs plug-in API level 2"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["progress"]}"#, "`progress` needs plug-in API level 2"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","permissions":["settings"]}"#, "`settings` needs plug-in API level 2"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["doc.read","cache"]}"#, "`cache` needs plug-in API level 2"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":3}"#, "`api` is 3"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":0}"#, "`api` is 0"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":-1}"#, "manifest"),
|
||
] {
|
||
let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string();
|
||
assert!(e.contains(says), "{bad}: {e}");
|
||
}
|
||
}
|
||
|
||
/// A filter at `api` 2 may ask for type, images and symbol instances (`inputs`), each kind once,
|
||
/// with a `permissions` list holding `doc.read`, so installing it asks for approval. Left out
|
||
/// or empty, it asks for none and isn't written back. Anything else is refused naming `inputs`.
|
||
#[test]
|
||
fn a_filter_at_api_2_lists_the_inputs_it_reads() {
|
||
let m = Manifest::parse(
|
||
br#"{"id":"org.example.inspect","name":"Inspect","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type","image","symbolInstance"]}"#,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(m.input_kinds(), InputKind::ALL);
|
||
assert!(m.asks_permissions());
|
||
assert_eq!(serde_json::to_value(&m).unwrap()["inputs"], json!(["type", "image", "symbolInstance"]));
|
||
let some = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.read"],"inputs":["image"]}"#).unwrap();
|
||
assert_eq!(some.input_kinds(), [InputKind::Image]);
|
||
assert!(some.asks_permissions());
|
||
for none in [
|
||
r#"{"id":"a","name":"x","kind":"filter"}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","inputs":[]}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","inputs":null}"#,
|
||
r#"{"id":"a","name":"x","kind":"effect","inputs":[]}"#,
|
||
] {
|
||
let m = Manifest::parse(none.as_bytes()).unwrap();
|
||
assert!(m.input_kinds().is_empty(), "{none}");
|
||
assert!(serde_json::to_value(&m).unwrap().get("inputs").is_none(), "{none}");
|
||
}
|
||
for (bad, says) in [
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["text"]}"#, "`inputs` names an unknown object kind `text`"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["Type"]}"#, "unknown object kind `Type`"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["image","image"]}"#, "`inputs` lists `image` twice"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":"type"}"#, "`inputs` must be a list"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":[1]}"#, "`inputs` must be a list"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":{"type":true}}"#, "`inputs` must be a list"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","inputs":["type"]}"#, "`inputs` needs `api` 2"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":1,"inputs":["type"]}"#, "`inputs` needs `api` 2"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.write"],"inputs":["type"]}"#, "needs the permission `doc.read`"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["type"]}"#, "`inputs` needs the permission `doc.read`"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":[],"inputs":["image"]}"#, "`inputs` needs the permission `doc.read`"),
|
||
(r#"{"id":"a","name":"x","kind":"effect","api":2,"inputs":["type"]}"#, "only a filter declares `inputs`"),
|
||
(r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"api":2,"inputs":["image"]}"#, "only a filter declares `inputs`"),
|
||
] {
|
||
let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string();
|
||
assert!(e.contains(says), "{bad}: {e}");
|
||
}
|
||
let long = format!(r#"{{"id":"a","name":"x","kind":"filter","api":2,"inputs":[{}]}}"#, vec![r#""type""#; 1000].join(","));
|
||
assert!(Manifest::parse(long.as_bytes()).unwrap_err().to_string().contains("twice"));
|
||
}
|
||
|
||
/// A live effect at `api` 2 may declare `"paintOutput": true`, and only that value; any other
|
||
/// kind or level, and `false`, `null` or another type, is refused naming the key. Without the
|
||
/// key, at any level, the effect returns geometry only.
|
||
#[test]
|
||
fn only_a_level_2_live_effect_declares_paint_output() {
|
||
let painted = Manifest::parse(br#"{"id":"org.example.tone","name":"Tone","kind":"effect","api":2,"paintOutput":true}"#).unwrap();
|
||
assert!(painted.paint_output);
|
||
assert_eq!(serde_json::to_value(&painted).unwrap()["paintOutput"], json!(true));
|
||
let listed =
|
||
Manifest::parse(br#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":true,"permissions":["doc.read","effect.render"]}"#)
|
||
.unwrap();
|
||
assert!(listed.paint_output);
|
||
for plain in [r#"{"id":"a","name":"x","kind":"effect","api":2}"#, r#"{"id":"a","name":"x","kind":"effect"}"#] {
|
||
let m = Manifest::parse(plain.as_bytes()).unwrap();
|
||
assert!(!m.paint_output, "{plain}");
|
||
assert!(serde_json::to_value(&m).unwrap().get("paintOutput").is_none(), "{plain}");
|
||
}
|
||
for bad in [
|
||
r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":false}"#,
|
||
r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":null}"#,
|
||
r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":1}"#,
|
||
r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":"true"}"#,
|
||
r#"{"id":"a","name":"x","kind":"effect","paintOutput":true}"#,
|
||
r#"{"id":"a","name":"x","kind":"effect","api":1,"paintOutput":true}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","api":2,"paintOutput":true}"#,
|
||
r#"{"id":"a","name":"x","kind":"exporter","api":2,"extensions":["plt"],"paintOutput":true}"#,
|
||
r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":true,"permissions":["doc.read"]}"#,
|
||
] {
|
||
let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string();
|
||
assert!(e.contains("paintOutput") || e.contains("effect.render"), "{bad}: {e}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn commands_and_paint_output_may_both_be_declared_on_a_live_effect() {
|
||
let m = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":true,"commands":[{"id":"c","label":"C"}]}"#).unwrap();
|
||
assert!(m.paint_output);
|
||
assert_eq!(m.commands.len(), 1);
|
||
}
|
||
|
||
/// A filter or a live effect lists menu commands with their own parameters; a `menu` key is
|
||
/// ignored. A malformed entry, a bad or repeated id, a label that isn't printable and too many
|
||
/// commands are refused, and the error names the command.
|
||
#[test]
|
||
fn commands_are_checked_and_errors_name_them() {
|
||
let m = Manifest::parse(
|
||
br#"{"id":"org.example.tidy","name":"Tidy","kind":"filter","commands":[
|
||
{"id":"tidy_all","label":"Tidy All","menu":"File"},
|
||
{"id":"grow","label":"Grow","params":{"mode":{"type":"bool"},"by":{"type":"number","min":0,"max":10,"default":2}}}]}"#,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(m.commands.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ["tidy_all", "grow"]);
|
||
let grow = m.command("grow").unwrap();
|
||
assert_eq!(grow.params.iter().map(|p| p.0.as_str()).collect::<Vec<_>>(), ["mode", "by"], "declaration order");
|
||
let run = m.for_command(grow);
|
||
assert_eq!((run.name.as_str(), run.id.as_str()), ("Grow", "org.example.tidy"));
|
||
assert_eq!(Value::Object(run.resolve_params(&json!({"by": 50})).unwrap()), json!({"by": 10.0, "mode": false}));
|
||
let written = serde_json::to_value(&m).unwrap();
|
||
assert_eq!(written["commands"][1]["params"]["by"]["max"], json!(10.0));
|
||
assert!(written["commands"][0].get("menu").is_none(), "a menu key is not kept");
|
||
assert!(serde_json::to_value(Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap()).unwrap().get("commands").is_none());
|
||
let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect","commands":[{"id":"c","label":"C"}]}"#).unwrap();
|
||
assert_eq!(effect.commands.len(), 1);
|
||
let label = |l: &str| Command { id: "c".into(), label: l.into(), params: Vec::new() }.shown_label();
|
||
assert_eq!(label("Flip\u{202E}txt.exe"), "Flip\\u{202E}txt.exe");
|
||
|
||
let many: Vec<String> = (0..=MAX_COMMANDS).map(|i| format!(r#"{{"id":"c{i}","label":"C"}}"#)).collect();
|
||
let too_many = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{}]}}"#, many.join(","));
|
||
for (bad, says) in [
|
||
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"ok","label":"A"},{"label":"B"}]}"#, "command [1]: missing field `id`"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"nolabel"}]}"#, "command `nolabel`: missing field `label`"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"Upper","label":"A"}]}"#, "command `Upper`: use an id"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"9lives","label":"A"}]}"#, "command `9lives`: use an id"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"a.b","label":"A"}]}"#, "command `a.b`: use an id"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"","label":"A"}]}"#, "command [0]: use an id"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":" "}]}"#, "command `x`: `label`"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"a\nb"}]}"#, "command `x`: `label`"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A"},{"id":"x","label":"B"}]}"#, "command `x` is declared twice"),
|
||
(
|
||
r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}]}"#,
|
||
"command `x`: parameter `r`: bad range",
|
||
),
|
||
(
|
||
r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"id":{"type":"bool"}}}]}"#,
|
||
"command `x`: invalid parameter",
|
||
),
|
||
(
|
||
r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"r":{"type":"bool"},"r":{"type":"int"}}}]}"#,
|
||
"command `x`: parameter `r` is declared twice",
|
||
),
|
||
(r#"{"id":"a","name":"x","kind":"filter","commands":[1]}"#, "command [0]"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","commands":{"id":"x"}}"#, "must be a list"),
|
||
(r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"commands":[{"id":"x","label":"A"}]}"#, "only a filter or a live effect"),
|
||
(r#"{"id":"a","name":"x","kind":"filter","budget":"heavy","commands":[{"id":"x","label":"A"}]}"#, "heavy filter"),
|
||
(too_many.as_str(), "at most 32 `commands`"),
|
||
] {
|
||
let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string();
|
||
assert!(e.contains(says), "{bad}: {e}");
|
||
}
|
||
let long_id = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{{"id":"{}","label":"A"}}]}}"#, "c".repeat(MAX_COMMAND_ID + 1));
|
||
assert!(Manifest::parse(long_id.as_bytes()).is_err());
|
||
let hidden =
|
||
Manifest::parse("{\"id\":\"a\",\"name\":\"x\",\"kind\":\"filter\",\"commands\":[{\"id\":\"x\u{202E}\",\"label\":\"A\"}]}".as_bytes());
|
||
assert!(hidden.unwrap_err().to_string().contains("command `x\\u{202E}`"), "an id in an error is escaped");
|
||
}
|
||
|
||
#[test]
|
||
fn strings_and_number_lists_resolve_like_the_other_types() {
|
||
let m = Manifest::parse(
|
||
br#"{"id":"org.example.text","name":"Text","kind":"filter","params":{
|
||
"label":{"type":"string","default":"Rose","maxLength":8},
|
||
"angles":{"type":"numberList","min":0,"max":360,"default":[45,135],"minItems":1,"maxItems":3},
|
||
"free":{"type":"string"}}}"#,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(Value::Object(m.defaults()), json!({"label": "Rose", "angles": [45.0, 135.0], "free": ""}));
|
||
let p = m.resolve_params(&json!({"label": "Sunflowers", "angles": [-10, 90, 400]})).unwrap();
|
||
assert_eq!((&p["label"], &p["angles"]), (&json!("Sunflowe"), &json!([0.0, 90.0, 360.0])), "cut and clamped");
|
||
for bad in [json!({"label": 3}), json!({"angles": []}), json!({"angles": [1, 2, 3, 4]}), json!({"angles": [1, "x"]}), json!({"angles": 5})] {
|
||
assert!(m.resolve_params(&bad).is_err(), "{bad}");
|
||
}
|
||
assert_eq!(
|
||
m.params_doc(),
|
||
r#"{label: string of at most 8 ("Rose"), angles: 1 to 3 numbers 0..360 ([45.0,135.0]), free: string of at most 256 ("")}"#
|
||
);
|
||
}
|
||
|
||
/// A color's default and value may be a color object or a hex string; the plug-in always gets
|
||
/// a color object, its components clamped. Without a default it is black.
|
||
#[test]
|
||
fn colors_resolve_to_color_objects() {
|
||
let m = Manifest::parse(
|
||
br##"{"id":"org.example.grid","name":"Grid","kind":"filter","params":{
|
||
"line":{"type":"color","default":"#ff0000"},
|
||
"ink":{"type":"color","default":{"model":"cmyk","c":1,"m":0,"y":0,"k":2}},
|
||
"plain":{"type":"color"}}}"##,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
Value::Object(m.defaults()),
|
||
json!({"line": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0},
|
||
"ink": {"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 1.0},
|
||
"plain": {"model": "rgb", "r": 0.0, "g": 0.0, "b": 0.0}})
|
||
);
|
||
let p = m.resolve_params(&json!({"line": "#0000ff", "ink": {"model": "gray", "k": 0.5}})).unwrap();
|
||
assert_eq!(p["line"], json!({"model": "rgb", "r": 0.0, "g": 0.0, "b": 1.0}));
|
||
assert_eq!(p["ink"], json!({"model": "gray", "k": 0.5}));
|
||
for bad in [json!({"line": "not a color"}), json!({"line": 3}), json!({"line": {"model": "hsv", "h": 1}})] {
|
||
assert!(m.resolve_params(&bad).is_err(), "{bad}");
|
||
}
|
||
assert!(m.params_doc().starts_with("{line: color object or hex (#ff0000)"));
|
||
assert!(Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","params":{"c":{"type":"color","default":"nope"}}}"#).is_err());
|
||
}
|
||
|
||
/// A hex color is display sRGB: in a wide-gamut working RGB space it becomes the working RGB
|
||
/// color that shows as that hex, read when used, while the manifest keeps the default as written.
|
||
#[test]
|
||
fn hex_colors_follow_the_working_rgb_space() {
|
||
const CHILD: &str = "VECTORCRAFT_TEST_PLUGIN_COLOR_PROFILE";
|
||
if std::env::var_os(CHILD).is_none() {
|
||
// Color settings are process-wide: exercise this profile in a separate test process.
|
||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||
.args(["--exact", "manifest::tests::hex_colors_follow_the_working_rgb_space", "--nocapture"])
|
||
.env(CHILD, "1")
|
||
.output()
|
||
.unwrap();
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
assert!(
|
||
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
|
||
"isolated color profile test failed:\n{stdout}\n{}",
|
||
String::from_utf8_lossy(&output.stderr),
|
||
);
|
||
return;
|
||
}
|
||
use vectorcraft_color::{Color, cms};
|
||
let m =
|
||
Manifest::parse(br##"{"id":"org.example.grid","name":"Grid","kind":"filter","params":{"line":{"type":"color","default":"#3a7bd5"}}}"##)
|
||
.unwrap();
|
||
cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap();
|
||
for c in [m.defaults()["line"].clone(), m.resolve_params(&json!({"line": "#3a7bd5"})).unwrap()["line"].clone()] {
|
||
let c: Color = serde_json::from_value(c).unwrap();
|
||
assert_ne!(c, Color::rgb8(0x3a, 0x7b, 0xd5), "converted into the working space");
|
||
assert_eq!(c.to_hex(), "#3a7bd5", "shows as the hex given");
|
||
}
|
||
assert_eq!(serde_json::to_value(&m).unwrap()["params"]["line"]["default"], json!("#3a7bd5"), "kept as written");
|
||
cms::set_active(&cms::ColorSettings::default()).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn up_to_256_parameters_with_at_most_64_outside_groups() {
|
||
let manifest = |n: usize, grouped: usize| {
|
||
let params: Vec<String> = (0..n)
|
||
.map(|i| match i < grouped {
|
||
true => format!(r#""p{i}":{{"type":"bool","group":"G{}"}}"#, i % 4),
|
||
false => format!(r#""p{i}":{{"type":"bool"}}"#),
|
||
})
|
||
.collect();
|
||
format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{{}}}}}"#, params.join(","))
|
||
};
|
||
assert!(Manifest::parse(manifest(MAX_UNGROUPED, 0).as_bytes()).is_ok());
|
||
assert!(Manifest::parse(manifest(MAX_UNGROUPED + 1, 0).as_bytes()).is_err(), "too many outside groups");
|
||
let all = Manifest::parse(manifest(MAX_PARAMS, MAX_PARAMS - MAX_UNGROUPED).as_bytes()).unwrap();
|
||
assert_eq!(all.params.len(), MAX_PARAMS);
|
||
assert!(Manifest::parse(manifest(MAX_PARAMS + 1, MAX_PARAMS).as_bytes()).is_err(), "too many in all");
|
||
}
|
||
|
||
#[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]}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":{"t":{"type":"string","maxLength":0}}}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":{"t":{"type":"string","default":"abc","maxLength":2}}}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":1,"max":0}}}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":0,"max":1,"maxItems":0}}}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":0,"max":1,"maxItems":257}}}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":0,"max":1,"minItems":2,"maxItems":1}}}"#,
|
||
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":0,"max":1,"minItems":2,"default":[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());
|
||
}
|
||
}
|