mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 09:20:56 -06:00
Merge main into feat/plugin-effect-paint
This commit is contained in:
@@ -75,7 +75,8 @@ pub struct Target<'a> {
|
||||
|
||||
/// The limits of an export by `plugin`: its own, with the budgets of an export.
|
||||
pub fn limits(plugin: &Plugin) -> Limits {
|
||||
Limits { fuel_base: FUEL, wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() }
|
||||
let own = plugin.limits();
|
||||
Limits { fuel_base: own.export_fuel, wall_time_ms: own.export_wall_time_ms, ..own.clone() }
|
||||
}
|
||||
|
||||
/// Runs exporter `plugin` on `document`, the native file's JSON, with `params` (checked against
|
||||
@@ -83,7 +84,7 @@ pub fn limits(plugin: &Plugin) -> Limits {
|
||||
pub fn run(plugin: &Plugin, document: &[u8], params: &Value, target: Target) -> Result<Exported> {
|
||||
let m = plugin.manifest();
|
||||
if m.kind != Kind::Exporter {
|
||||
return Err(Error::Params(format!("`{}` is a {}, not an exporter", m.id, m.kind.name())));
|
||||
return Err(Error::Params(format!("`{}` is a plug-in of kind `{}`, not an exporter", m.id, m.kind.name())));
|
||||
}
|
||||
let mut params = Value::Object(m.resolve_params(params)?);
|
||||
params["_context"] = json!({"mode": "export", "extension": target.extension, "name": target.name});
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Importer plug-ins: a plug-in of kind `importer` reads files in a format of its own, such as a
|
||||
//! pen plotter's or a cutting machine's, into a new document.
|
||||
//!
|
||||
//! File › Open opens a file with an importer only when no built-in format reads it: the built-in
|
||||
//! formats are found by the file's content first and then by its extension, and an importer is
|
||||
//! picked by an extension it lists. When several importers list the extension, the one whose id
|
||||
//! sorts first opens it ([`for_extension`]). File › Open With and `document.open {importer}` pick an
|
||||
//! importer by its format id (`plugin.<id>`), also for an extension a built-in format reads.
|
||||
//!
|
||||
//! The host hands the importer the file's bytes as they are, at most [`MAX_INPUT_BYTES`] of them,
|
||||
//! and reads back the objects of the new document in the same form filters return them
|
||||
//! ([`crate::objects`]): `{"objects": [...], "warnings"?: ["…"]}`, or `{"error": "…"}` to decline.
|
||||
//! An import is one run the user asked for, so it gets an export's budget ([`crate::export::FUEL`]
|
||||
//! and [`crate::export::WALL_TIME_MS`]), and Cancel stops it between two slices of the budget
|
||||
//! ([`Error::Cancelled`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::objects::{OutObject, declined, printable};
|
||||
use crate::{Error, Kind, Limits, Plugin, Result, registry};
|
||||
|
||||
/// The largest file an importer is given, in bytes: 128 MiB, a quarter of a plug-in's memory
|
||||
/// limit, so the file and the objects made from it fit beside each other.
|
||||
pub const MAX_INPUT_BYTES: usize = 128 << 20;
|
||||
|
||||
/// The keys an importer's output may have.
|
||||
const OUTPUT_KEYS: &[&str] = &["objects", "warnings", "error"];
|
||||
|
||||
/// The installed importers, by plug-in id.
|
||||
pub fn installed() -> Vec<Arc<Plugin>> {
|
||||
registry::list().into_iter().filter(|p| p.manifest().kind == Kind::Importer).collect()
|
||||
}
|
||||
|
||||
/// The installed importer of format id `format_id` (`plugin.<id>`).
|
||||
pub fn by_format(format_id: &str) -> Option<Arc<Plugin>> {
|
||||
registry::get(crate::export::plugin_id(format_id)?).filter(|p| p.manifest().kind == Kind::Importer)
|
||||
}
|
||||
|
||||
/// The installed importer `importer` names: its format id (`plugin.<id>`) or its plug-in id. A
|
||||
/// plug-in of another kind, and one that isn't installed, are errors that say so.
|
||||
pub fn named(importer: &str) -> Result<Arc<Plugin>> {
|
||||
let id = crate::export::plugin_id(importer).unwrap_or(importer);
|
||||
let plugin = registry::get(id).ok_or_else(|| Error::NotFound(id.into()))?;
|
||||
match plugin.manifest().kind {
|
||||
Kind::Importer => Ok(plugin),
|
||||
_ => Err(not_importer(plugin.manifest())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The error of plug-in `m`, which is not an importer, asked to import.
|
||||
fn not_importer(m: &crate::Manifest) -> Error {
|
||||
Error::Params(format!("`{}` is a plug-in of kind `{}`, not an importer", m.id, m.kind.name()))
|
||||
}
|
||||
|
||||
/// The installed importers that list extension `ext` (any case, leading dot allowed), by plug-in
|
||||
/// id: the first is the one File › Open uses.
|
||||
pub fn for_extension(ext: &str) -> Vec<Arc<Plugin>> {
|
||||
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
|
||||
installed().into_iter().filter(|p| p.manifest().extensions.contains(&ext)).collect()
|
||||
}
|
||||
|
||||
/// What an importer returned.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Imported {
|
||||
/// The objects of the new document, in paint order (bottom first), checked as a filter's are.
|
||||
pub objects: Vec<OutObject>,
|
||||
/// What the import left out or changed, for the user (at most
|
||||
/// [`crate::export::MAX_WARNINGS`]).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The file an import reads, as the importer is told in `_context`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Source<'a> {
|
||||
/// Its extension, without the dot: one the importer lists.
|
||||
pub extension: &'a str,
|
||||
/// Its name without the folder and extension.
|
||||
pub name: &'a str,
|
||||
}
|
||||
|
||||
/// The error of a file larger than [`MAX_INPUT_BYTES`], which no importer is given.
|
||||
pub fn too_large() -> Error {
|
||||
Error::Io(format!("the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads"))
|
||||
}
|
||||
|
||||
/// The limits of an import by `plugin`: its own, with the budgets of an export.
|
||||
pub fn limits(plugin: &Plugin) -> Limits {
|
||||
crate::export::limits(plugin)
|
||||
}
|
||||
|
||||
/// Runs importer `plugin` on `file`, the bytes of the file being opened, with `params` (checked
|
||||
/// against its manifest: missing ones take their defaults) for the file `source`.
|
||||
pub fn run(plugin: &Plugin, file: &[u8], params: &Value, source: Source) -> Result<Imported> {
|
||||
let m = plugin.manifest();
|
||||
if m.kind != Kind::Importer {
|
||||
return Err(not_importer(m));
|
||||
}
|
||||
if file.len() > MAX_INPUT_BYTES {
|
||||
return Err(too_large());
|
||||
}
|
||||
let mut params = Value::Object(m.resolve_params(params)?);
|
||||
params["_context"] = json!({"mode": "import", "extension": source.extension, "name": source.name});
|
||||
let params = serde_json::to_vec(¶ms).map_err(|e| Error::Params(e.to_string()))?;
|
||||
decode(&plugin.run_limited(file, ¶ms, &limits(plugin))?)
|
||||
}
|
||||
|
||||
/// An importer's output: `{"objects": [...], "warnings"?: ["…"]}`, or `{"error": "…"}` to decline
|
||||
/// with a message for the user. The objects are new ones (no `id`), checked as a filter's are; an
|
||||
/// output without any, or with keys of its own, is refused.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Imported> {
|
||||
let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
|
||||
if let Some(e) = declined(&v) {
|
||||
return Err(e);
|
||||
}
|
||||
if let Some(key) = v.as_object().and_then(|o| o.keys().find(|k| !OUTPUT_KEYS.contains(&k.as_str()))) {
|
||||
let key = printable(&json!(key), 64);
|
||||
return Err(Error::Output(format!("an importer returns `objects` and `warnings`, not `{key}`")));
|
||||
}
|
||||
let objects = crate::objects::decode(bytes)?;
|
||||
if objects.is_empty() {
|
||||
return Err(Error::Output("the importer returned no objects".into()));
|
||||
}
|
||||
let warnings = match v.get("warnings") {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(Value::Array(w)) => w.iter().take(crate::export::MAX_WARNINGS).map(|w| printable(w, crate::export::MAX_WARNING_CHARS)).collect(),
|
||||
Some(_) => return Err(Error::Output("`warnings` must be a list of strings".into())),
|
||||
};
|
||||
Ok(Imported { objects, warnings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SQUARE: &str = r#"{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[10,0]},{"p":[10,10]}]}]}}"#;
|
||||
|
||||
#[test]
|
||||
fn output_is_the_objects_and_the_warnings_or_a_decline() {
|
||||
let out = decode(format!(r#"{{"objects":[{SQUARE}],"warnings":["a",2]}}"#).as_bytes()).unwrap();
|
||||
assert_eq!((out.objects.len(), out.warnings), (1, vec!["a".to_string(), "2".to_string()]));
|
||||
assert!(out.objects.iter().all(|o| o.id.is_none() && o.path.is_some()));
|
||||
assert_eq!(decode(format!(r#"{{"objects":[{SQUARE}]}}"#).as_bytes()).unwrap().warnings, Vec::<String>::new());
|
||||
assert_eq!(decode(br#"{"error": "Not a\nplot file."}"#), Err(Error::Failed("Not aplot file.".into())));
|
||||
// A decline is reported as one, also with keys the host doesn't read beside it.
|
||||
assert_eq!(decode(br#"{"error": "No.", "layers": []}"#), Err(Error::Failed("No.".into())));
|
||||
}
|
||||
|
||||
/// Nothing an importer returns reaches a document unchecked: malformed JSON, no objects, keys
|
||||
/// the host doesn't read, bad warnings and bad objects (coordinates out of range, unknown paints)
|
||||
/// are all errors.
|
||||
#[test]
|
||||
fn bad_output_is_refused() {
|
||||
let far = r#"{"objects":[{"path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[1e9,0]}]}]}}]}"#;
|
||||
for (bad, says) in [
|
||||
(&b"[]"[..], "objects"),
|
||||
(b"{}", "objects"),
|
||||
(b"not json", "not JSON"),
|
||||
(br#"{"objects":[]}"#, "no objects"),
|
||||
(br#"{"objects":{}}"#, "objects"),
|
||||
(br#"{"objects":[1]}"#, "JSON object"),
|
||||
(far.as_bytes(), "out of range"),
|
||||
(br#"{"objects":[{"path":{"subpaths":[]},"fills":[{"type":"hatch"}]}]}"#, "objects[0]"),
|
||||
(br#"{"objects":[],"artboard":[0,0,1,1]}"#, "not `artboard`"),
|
||||
] {
|
||||
let e = decode(bad).unwrap_err().to_string();
|
||||
assert!(e.contains(says), "{}: {e}", String::from_utf8_lossy(bad));
|
||||
}
|
||||
let warns = format!(r#"{{"objects":[{SQUARE}],"warnings":"x"}}"#);
|
||||
assert!(decode(warns.as_bytes()).unwrap_err().to_string().contains("`warnings`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warnings_are_capped_in_number_and_length() {
|
||||
let many: Vec<String> = (0..crate::export::MAX_WARNINGS + 5).map(|i| format!("w{i}")).collect();
|
||||
let square: Value = serde_json::from_str(SQUARE).unwrap();
|
||||
let out = decode(json!({"objects": [square], "warnings": many}).to_string().as_bytes()).unwrap();
|
||||
assert_eq!(out.warnings.len(), crate::export::MAX_WARNINGS);
|
||||
let long = "x".repeat(crate::export::MAX_WARNING_CHARS + 1);
|
||||
let out = decode(json!({"objects": [square], "warnings": [long]}).to_string().as_bytes()).unwrap();
|
||||
assert_eq!(out.warnings.first().map(|w| w.chars().count()), Some(crate::export::MAX_WARNING_CHARS));
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@
|
||||
//! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each
|
||||
//! time it is drawn, from an appearance stack, and at `api` 2 may paint what it returns
|
||||
//! ([`painted`]); an **exporter** ([`export`]) writes the whole
|
||||
//! document in a file format of its own. The process-wide [`registry`] holds the installed
|
||||
//! document in a file format of its own; an **importer** ([`import`]) reads a file in a format of
|
||||
//! its own into a new document. The process-wide [`registry`] holds the installed
|
||||
//! plug-ins; in [`safe_mode`] none of them loads or runs. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show
|
||||
//! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is
|
||||
//! the set of [`permissions`] its manifest lists, which the user approves when it is installed. A
|
||||
@@ -35,6 +36,7 @@ pub mod dialog;
|
||||
pub mod effect;
|
||||
pub mod export;
|
||||
pub mod host;
|
||||
pub mod import;
|
||||
pub mod manifest;
|
||||
pub mod objects;
|
||||
pub mod painted;
|
||||
@@ -51,7 +53,7 @@ pub use manifest::{Budget, Command, Kind, Manifest, Param, ParamSpec};
|
||||
pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms};
|
||||
|
||||
/// What the host puts before a plug-in's id to name what it adds: its live effect
|
||||
/// (`plugin.<id>`, [`effect`]) or its file format ([`export`]).
|
||||
/// (`plugin.<id>`, [`effect`]) or its file format ([`export`], [`import`]).
|
||||
pub const PREFIX: &str = "plugin.";
|
||||
|
||||
/// A plug-in error. Every way a module can misbehave ends here, never in a panic.
|
||||
|
||||
@@ -18,7 +18,7 @@ pub const MAX_UNGROUPED: usize = 64;
|
||||
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 may declare.
|
||||
/// 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;
|
||||
@@ -38,6 +38,9 @@ pub enum Kind {
|
||||
/// 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 {
|
||||
@@ -46,6 +49,7 @@ impl Kind {
|
||||
Kind::Filter => "filter",
|
||||
Kind::Effect => "effect",
|
||||
Kind::Exporter => "exporter",
|
||||
Kind::Importer => "importer",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,8 +285,8 @@ pub struct Manifest {
|
||||
/// 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 file extensions, lower case and without the dot; the first is the one its
|
||||
/// files get. Only exporters declare them.
|
||||
/// 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`].
|
||||
@@ -378,6 +382,9 @@ impl Manifest {
|
||||
(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()
|
||||
@@ -405,7 +412,7 @@ impl Manifest {
|
||||
if self.commands.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.kind == Kind::Exporter {
|
||||
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 {
|
||||
@@ -439,17 +446,17 @@ impl Manifest {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An exporter lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 lower-case
|
||||
/// letters and digits; other kinds list none.
|
||||
/// 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 => {}
|
||||
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 declares `extensions`".into())),
|
||||
}
|
||||
if exts.is_empty() || exts.len() > MAX_EXTENSIONS {
|
||||
return Err(Error::Manifest(format!("an exporter declares 1 to {MAX_EXTENSIONS} `extensions`")));
|
||||
_ => 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()) {
|
||||
@@ -830,6 +837,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -54,6 +54,9 @@ pub enum Permission {
|
||||
/// Write the document as a file in its own format (an exporter).
|
||||
#[serde(rename = "export.write")]
|
||||
ExportWrite,
|
||||
/// Read a file the user opens and make a new document from it (an importer).
|
||||
#[serde(rename = "import.read")]
|
||||
ImportRead,
|
||||
/// Report how far a run has got, see that the user asked it to stop and write log lines: the
|
||||
/// host imports of API level 2 ([`crate::host::IMPORTS`]).
|
||||
#[serde(rename = "progress")]
|
||||
@@ -67,12 +70,13 @@ pub enum Permission {
|
||||
}
|
||||
|
||||
/// Every permission this host grants, in the order prompts list them.
|
||||
pub const ALL: [Permission; 8] = [
|
||||
pub const ALL: [Permission; 9] = [
|
||||
Permission::DocRead,
|
||||
Permission::DocWrite,
|
||||
Permission::State,
|
||||
Permission::EffectRender,
|
||||
Permission::ExportWrite,
|
||||
Permission::ImportRead,
|
||||
Permission::Progress,
|
||||
Permission::Settings,
|
||||
Permission::Cache,
|
||||
@@ -91,6 +95,7 @@ impl Permission {
|
||||
Permission::State => "state",
|
||||
Permission::EffectRender => "effect.render",
|
||||
Permission::ExportWrite => "export.write",
|
||||
Permission::ImportRead => "import.read",
|
||||
Permission::Progress => "progress",
|
||||
Permission::Settings => "settings",
|
||||
Permission::Cache => "cache",
|
||||
@@ -110,6 +115,7 @@ impl Permission {
|
||||
Permission::State => "Keep data of its own with the document",
|
||||
Permission::EffectRender => "Change how the objects its effect is applied to are drawn",
|
||||
Permission::ExportWrite => "Write the document as a file in its own format",
|
||||
Permission::ImportRead => "Read files you open in its format and make documents from them",
|
||||
Permission::Progress => "Show its progress, see Cancel and write log lines",
|
||||
Permission::Settings => "Keep settings of its own",
|
||||
Permission::Cache => "Keep a cache of its own, which you can clear",
|
||||
@@ -129,6 +135,7 @@ impl Permission {
|
||||
Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State],
|
||||
Kind::Effect => &[Permission::DocRead, Permission::EffectRender],
|
||||
Kind::Exporter => &[Permission::DocRead, Permission::ExportWrite],
|
||||
Kind::Importer => &[Permission::ImportRead],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,12 +151,12 @@ impl Permission {
|
||||
}
|
||||
|
||||
/// The permissions a plug-in of `kind` can't work without, which its list must hold: a live
|
||||
/// effect and an exporter are always handed objects, and only draw or write. A filter may
|
||||
/// leave any of its permissions out.
|
||||
/// effect and an exporter are always handed objects, and only draw or write; an importer is
|
||||
/// always handed the file being opened. A filter may leave any of its permissions out.
|
||||
fn required(kind: Kind) -> &'static [Permission] {
|
||||
match kind {
|
||||
Kind::Filter => &[],
|
||||
Kind::Effect | Kind::Exporter => Permission::implied(kind),
|
||||
Kind::Effect | Kind::Exporter | Kind::Importer => Permission::implied(kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,6 +242,11 @@ pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Is `s` a SHA-256 as [`sha256_hex`] writes it: 64 lower-case hexadecimal digits?
|
||||
fn is_sha256_hex(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
|
||||
/// What the user approved for a plug-in: its id, the SHA-256 of the module they approved
|
||||
/// ([`sha256_hex`]) and the permissions it was granted.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -245,9 +257,10 @@ pub struct Grant {
|
||||
}
|
||||
|
||||
impl Grant {
|
||||
/// Does it grant every one of `wanted`?
|
||||
pub fn covers(&self, wanted: &[Permission]) -> bool {
|
||||
wanted.iter().all(|w| self.permissions.contains(w))
|
||||
/// Does it grant every one of `wanted` to the module with this `id` and SHA-256 `hash`? A grant
|
||||
/// whose own hash is not a valid SHA-256 ([`sha256_hex`]) covers nothing.
|
||||
pub fn covers(&self, id: &str, hash: &str, wanted: &[Permission]) -> bool {
|
||||
self.id == id && is_sha256_hex(&self.hash) && self.hash == hash && wanted.iter().all(|w| self.permissions.contains(w))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,12 +285,16 @@ mod tests {
|
||||
assert_eq!(validate(Kind::Filter, false, &[]).unwrap(), []);
|
||||
assert!(validate(Kind::Effect, false, &list(&["doc.read", "effect.render"])).is_ok());
|
||||
assert!(validate(Kind::Exporter, false, &list(&["export.write", "doc.read"])).is_ok());
|
||||
assert_eq!(validate(Kind::Importer, false, &list(&["import.read"])).unwrap(), [Permission::ImportRead]);
|
||||
// Any kind may list `progress`, which no kind implies.
|
||||
assert_eq!(validate(Kind::Filter, false, &list(&["progress", "doc.read"])).unwrap(), [Permission::DocRead, Permission::Progress]);
|
||||
assert!(validate(Kind::Effect, false, &list(&["doc.read", "effect.render", "progress"])).is_ok());
|
||||
assert!(validate(Kind::Effect, true, &list(&["doc.read", "effect.render", "progress"])).is_ok());
|
||||
assert!(validate(Kind::Exporter, false, &list(&["progress", "export.write", "doc.read"])).is_ok());
|
||||
assert!([Kind::Filter, Kind::Effect, Kind::Exporter].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress)));
|
||||
assert_eq!(validate(Kind::Importer, false, &list(&["progress", "import.read"])).unwrap(), [Permission::ImportRead, Permission::Progress]);
|
||||
assert!(
|
||||
[Kind::Filter, Kind::Effect, Kind::Exporter, Kind::Importer].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress))
|
||||
);
|
||||
// So may `settings` and `cache`, which no kind implies either.
|
||||
assert_eq!(
|
||||
validate(Kind::Effect, false, &list(&["cache", "settings", "doc.read", "effect.render"])).unwrap(),
|
||||
@@ -285,7 +302,7 @@ mod tests {
|
||||
);
|
||||
assert!(validate(Kind::Exporter, false, &list(&["settings", "export.write", "doc.read"])).is_ok());
|
||||
assert_eq!(validate(Kind::Filter, false, &list(&["cache"])).unwrap(), [Permission::Cache]);
|
||||
for kind in [Kind::Filter, Kind::Effect, Kind::Exporter] {
|
||||
for kind in [Kind::Filter, Kind::Effect, Kind::Exporter, Kind::Importer] {
|
||||
for commands in [false, true] {
|
||||
assert!(HOST.iter().all(|p| p.applies(kind, commands) && !Permission::implied(kind).contains(p)));
|
||||
}
|
||||
@@ -311,6 +328,10 @@ mod tests {
|
||||
(Kind::Exporter, false, &["export.write"], "needs the permission `doc.read`"),
|
||||
(Kind::Filter, false, &["cache", "cache"], "`cache` is listed twice"),
|
||||
(Kind::Filter, false, &["storage"], "unknown permission `storage`"),
|
||||
(Kind::Importer, false, &["import.read", "doc.read"], "`doc.read` doesn't apply to a plug-in of kind `importer`"),
|
||||
(Kind::Importer, true, &["import.read", "doc.write"], "`doc.write` doesn't apply to a plug-in of kind `importer`"),
|
||||
(Kind::Importer, false, &[], "needs the permission `import.read`"),
|
||||
(Kind::Exporter, false, &["export.write", "doc.read", "import.read"], "`import.read` doesn't apply to a plug-in of kind `exporter`"),
|
||||
] {
|
||||
let e = validate(kind, commands, &list(names)).unwrap_err().to_string();
|
||||
assert!(e.contains(says), "{names:?}: {e}");
|
||||
@@ -343,10 +364,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_grant_covers_its_permissions_and_no_more() {
|
||||
let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] };
|
||||
assert!(g.covers(&[Permission::DocRead]));
|
||||
assert!(g.covers(&[]));
|
||||
assert!(!g.covers(&[Permission::State]));
|
||||
assert!(!g.covers(&[Permission::DocRead, Permission::State]));
|
||||
let hash = sha256_hex(b"module");
|
||||
let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead, Permission::DocWrite] };
|
||||
assert!(g.covers("a", &hash, &[Permission::DocRead]));
|
||||
assert!(g.covers("a", &hash, &[]));
|
||||
assert!(!g.covers("a", &hash, &[Permission::State]));
|
||||
assert!(!g.covers("a", &hash, &[Permission::DocRead, Permission::State]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_covers_only_the_module_that_was_approved() {
|
||||
let hash = sha256_hex(b"module");
|
||||
let wanted = [Permission::DocRead];
|
||||
let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead] };
|
||||
assert!(g.covers("a", &hash, &wanted));
|
||||
assert!(!g.covers("b", &hash, &wanted), "another id");
|
||||
assert!(!g.covers("a", &sha256_hex(b"other"), &wanted), "other bytes");
|
||||
assert!(!g.covers("a", &sha256_hex(b"other"), &[]), "other bytes, even when nothing is wanted");
|
||||
}
|
||||
|
||||
/// Damaged data can't stand for every module, even when the hash asked about is just as damaged.
|
||||
#[test]
|
||||
fn a_grant_with_an_invalid_hash_covers_nothing() {
|
||||
let upper = sha256_hex(b"module").to_uppercase();
|
||||
let short = "ab".repeat(31);
|
||||
let long = "ab".repeat(33);
|
||||
let not_hex = "g".repeat(64);
|
||||
let wide = "é".repeat(32);
|
||||
for hash in ["", "00", upper.as_str(), short.as_str(), long.as_str(), not_hex.as_str(), wide.as_str()] {
|
||||
let g = Grant { id: "a".into(), hash: hash.into(), permissions: vec![Permission::DocRead] };
|
||||
assert!(!g.covers("a", hash, &[Permission::DocRead]), "{hash:?}");
|
||||
assert!(!g.covers("a", hash, &[]), "{hash:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,11 @@ pub fn revoke(id: &str) -> bool {
|
||||
}
|
||||
|
||||
/// May `plugin` be installed without asking the user: does it list no permissions, or did the user
|
||||
/// already grant its id every permission it lists? A module with other bytes but no new permission
|
||||
/// needs no new approval.
|
||||
/// already grant this very module (its id and SHA-256) every permission it lists? A module with
|
||||
/// other bytes needs a new approval, even when it lists the same permissions.
|
||||
pub fn approved(plugin: &Plugin) -> bool {
|
||||
let m = plugin.manifest();
|
||||
!m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.permissions()))
|
||||
!m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions()))
|
||||
}
|
||||
|
||||
/// Installs `plugin`, replacing one with the same id.
|
||||
@@ -153,7 +153,7 @@ pub struct FolderReport {
|
||||
/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is
|
||||
/// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]),
|
||||
/// which installs only once the user agrees, and one that lists permissions the user has not
|
||||
/// granted its id ([`approved`]).
|
||||
/// granted this very module ([`approved`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
|
||||
load_folder_where(dir, |_| Ok(()), approved)
|
||||
@@ -213,3 +213,27 @@ pub fn load_folder_where(
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A cache keyed by the revision can't store the old list under the new revision: an install
|
||||
/// waiting for the set leaves the revision alone until it holds the set.
|
||||
#[test]
|
||||
fn an_install_changes_the_revision_only_while_it_holds_the_set() {
|
||||
let plugin = Plugin::load(include_bytes!("../tests/fixtures/desaturate.wasm"), Limits::default()).unwrap();
|
||||
let id = plugin.id().to_string();
|
||||
let held = PLUGINS.read().unwrap_or_else(|e| e.into_inner());
|
||||
let before = revision();
|
||||
let installing = std::thread::spawn(move || install(plugin));
|
||||
let moved = (0..1_000_000).any(|_| {
|
||||
std::thread::yield_now();
|
||||
revision() != before
|
||||
});
|
||||
drop(held);
|
||||
installing.join().unwrap();
|
||||
assert!(!moved, "the revision changed while the install waited for the set");
|
||||
assert!(revision() > before && remove(&id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ pub struct Limits {
|
||||
/// raise. Set them to `fuel_base` and `wall_time_ms` to give heavy filters nothing more.
|
||||
pub heavy_fuel: u64,
|
||||
pub heavy_wall_time_ms: u64,
|
||||
/// What one export or import is given instead of `fuel_base` and `wall_time_ms`
|
||||
/// ([`crate::export::limits`]).
|
||||
pub export_fuel: u64,
|
||||
pub export_wall_time_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
@@ -75,6 +79,8 @@ impl Default for Limits {
|
||||
wall_time_ms: 60_000,
|
||||
heavy_fuel: HEAVY_FUEL,
|
||||
heavy_wall_time_ms: HEAVY_WALL_TIME_MS,
|
||||
export_fuel: crate::export::FUEL,
|
||||
export_wall_time_ms: crate::export::WALL_TIME_MS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,11 @@ pub fn exporter(id: &str, extensions: &str, params: &str) -> String {
|
||||
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"exporter","extensions":{extensions},"params":{params}}}"#)
|
||||
}
|
||||
|
||||
/// The manifest of a test importer `id` reading `extensions` (a JSON list) with `params`.
|
||||
pub fn importer(id: &str, extensions: &str, params: &str) -> String {
|
||||
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"importer","extensions":{extensions},"params":{params}}}"#)
|
||||
}
|
||||
|
||||
/// What [`echo_exporter`] writes as its file.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Echo {
|
||||
|
||||
Reference in New Issue
Block a user