Merge main into feat/plugin-effect-paint

This commit is contained in:
dotCooCoo
2026-11-08 17:59:33 -08:00
145 changed files with 5805 additions and 400 deletions
+3 -2
View File
@@ -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});
+184
View File
@@ -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(&params).map_err(|e| Error::Params(e.to_string()))?;
decode(&plugin.run_limited(file, &params, &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));
}
}
+4 -2
View File
@@ -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.
+43 -11
View File
@@ -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]
+62 -14
View File
@@ -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:?}");
}
}
}
+28 -4
View File
@@ -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));
}
}
+6
View File
@@ -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,
}
}
}
+5
View File
@@ -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 {