Merge main into fix/plugin-grant-module-hash

This commit is contained in:
dotCooCoo
2026-11-08 12:55:59 -08:00
183 changed files with 8378 additions and 941 deletions
+271 -34
View File
@@ -1,20 +1,28 @@
//! What a plug-in may import from the host ([`IMPORTS`]), from plug-in API level 2 on:
//! `vc_progress` reports how far a run has got, `vc_cancelled` tells it the user or a host limit
//! asked it to stop, and `vc_log` writes a log line. A module may import them only when its
//! manifest gives `api` 2 and lists the `progress` permission, and the host checks each import when
//! the module is loaded ([`check_imports`], [`check_granted`]): one it doesn't offer, one with
//! another signature and one the manifest doesn't allow are refused by name.
//! asked it to stop, and `vc_log` writes a log line (the `progress` permission); `vc_settings_*`
//! read and write its settings (`settings`) and `vc_cache_*` its cache (`cache`, [`crate::storage`]).
//! A module may import one only when its manifest gives `api` 2 and lists the import's permission,
//! and the host checks each import when the module is loaded ([`check_imports`], [`check_granted`]):
//! one it doesn't offer, one with another signature and one the manifest doesn't allow are refused
//! by name.
//!
//! Each call's arguments are checked: progress is clamped and passed on only when it changes (with
//! a known total, to another tenth of a percent; past [`MAX_PROGRESS_REPORTS`], once per
//! [`PROGRESS_STRIDE`] changes), log text must lie inside the module's memory and be UTF-8, and
//! each line ([`MAX_LOG_LINE`]) and each run ([`MAX_LOG_LINES`], [`MAX_LOG_BYTES`],
//! [`MAX_LOG_READ`]) are capped. Nothing a module passes can panic the host.
//! [`MAX_LOG_READ`]) are capped. Keys and values must lie inside the module's memory, keys must be
//! [`crate::storage::valid_name`]s, values are capped, and so are each run's storage calls
//! ([`MAX_STORAGE_CALLS`]) and the bytes they move ([`MAX_STORAGE_BYTES`]). Nothing a module passes
//! can panic the host.
use std::sync::Arc;
use wasmi::{Caller, Engine, Extern, ExternType, Linker, Module, StoreLimits, ValType};
use crate::manifest::Manifest;
use crate::permissions::{self, PROGRESS_API, Permission};
use crate::storage::{self, Meter, Storage, StorageError};
use crate::{Error, Result};
/// The module a plug-in imports host functions from (the name a Rust or C toolchain gives
@@ -60,16 +68,51 @@ pub const LOG_NOT_UTF8: i32 = -2;
/// What `vc_log` returns: the level is not 0 to 3.
pub const LOG_BAD_LEVEL: i32 = -3;
/// One host import: its name and its type.
/// What a storage import that writes or removes returns when it did.
pub const STORAGE_DONE: i32 = 0;
/// What a storage import returns: the key or entry has no value.
pub const STORAGE_NOT_FOUND: i32 = -1;
/// What a storage import returns: a key, value or buffer doesn't lie inside the module's memory.
pub const STORAGE_OUTSIDE_MEMORY: i32 = -2;
/// What a storage import returns: the key is not a [`crate::storage::valid_name`].
pub const STORAGE_BAD_NAME: i32 = -3;
/// What a storage import returns: the write would pass a cap ([`crate::storage`]); nothing changed.
pub const STORAGE_FULL: i32 = -4;
/// What a storage import returns: there is nowhere to keep anything (no plug-in store, as on the
/// web, or a module that isn't installed).
pub const STORAGE_UNAVAILABLE: i32 = -5;
/// What a storage import returns: reading or writing the file failed.
pub const STORAGE_FAILED: i32 = -6;
/// What a storage import returns: the run used up its storage calls or bytes
/// ([`MAX_STORAGE_CALLS`], [`MAX_STORAGE_BYTES`]).
pub const STORAGE_BUDGET: i32 = -7;
/// Most storage calls one run makes; later ones return [`STORAGE_BUDGET`].
pub const MAX_STORAGE_CALLS: usize = 10_000;
/// Most bytes one run's storage calls read and write, all together, counting the whole settings
/// file each settings call reads and writes and each cache entry read or written; a call that would
/// pass it returns [`STORAGE_BUDGET`].
pub const MAX_STORAGE_BYTES: usize = 1 << 30;
/// One host import: its name, its type and the permission it needs.
pub struct Import {
pub name: &'static str,
params: &'static [ValType],
results: &'static [ValType],
/// Its type as WebAssembly text writes it.
pub signature: &'static str,
pub permission: Permission,
}
/// The host imports of API level 2, in module [`MODULE`]:
const I32: ValType = ValType::I32;
/// An import that takes `params` i32 arguments and returns an i32.
const fn storage_import(name: &'static str, params: &'static [ValType], signature: &'static str, permission: Permission) -> Import {
Import { name, params, results: &[I32], signature, permission }
}
/// The host imports of API level 2, in module [`MODULE`]. With `progress`:
///
/// - `vc_progress(done: i64, total: i64)`: `done` of `total` steps are finished (`total` 0 or less:
/// not known). Negative values count as 0 and `done` is clamped to `total`;
@@ -77,17 +120,71 @@ pub struct Import {
/// - `vc_log(level: i32, ptr: i32, len: i32) -> i32`: writes the UTF-8 text at `ptr` as a log line
/// of `level` (0 error, 1 warning, 2 information, 3 debug) → [`LOG_WRITTEN`], [`LOG_DROPPED`],
/// [`LOG_OUTSIDE_MEMORY`], [`LOG_NOT_UTF8`] or [`LOG_BAD_LEVEL`].
pub const IMPORTS: [Import; 3] = [
Import { name: "vc_progress", params: &[ValType::I64, ValType::I64], results: &[], signature: "(param i64 i64)" },
Import { name: "vc_cancelled", params: &[], results: &[ValType::I32], signature: "(result i32)" },
///
/// With `settings`, for the key of `key_len` bytes at `key`:
///
/// - `vc_settings_get(key, key_len, out, out_cap) -> i32`: the value's length, its bytes copied to
/// `out` when they fit in `out_cap` (nothing is copied otherwise);
/// - `vc_settings_set(key, key_len, value, value_len) -> i32`: sets it → [`STORAGE_DONE`];
/// - `vc_settings_delete(key, key_len) -> i32`: removes it → [`STORAGE_DONE`].
///
/// With `cache`, the same for the entry named at `name`: `vc_cache_read`, `vc_cache_write` and
/// `vc_cache_clear(name, name_len)`, which removes the entry, or every entry when `name_len` is 0.
///
/// A storage call that fails returns [`STORAGE_NOT_FOUND`], [`STORAGE_OUTSIDE_MEMORY`],
/// [`STORAGE_BAD_NAME`], [`STORAGE_FULL`], [`STORAGE_UNAVAILABLE`], [`STORAGE_FAILED`] or
/// [`STORAGE_BUDGET`].
pub const IMPORTS: [Import; 9] = [
Import {
name: "vc_progress",
params: &[ValType::I64, ValType::I64],
results: &[],
signature: "(param i64 i64)",
permission: Permission::Progress,
},
Import { name: "vc_cancelled", params: &[], results: &[I32], signature: "(result i32)", permission: Permission::Progress },
Import {
name: "vc_log",
params: &[ValType::I32, ValType::I32, ValType::I32],
results: &[ValType::I32],
params: &[I32, I32, I32],
results: &[I32],
signature: "(param i32 i32 i32) (result i32)",
permission: Permission::Progress,
},
storage_import("vc_settings_get", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Settings),
storage_import("vc_settings_set", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Settings),
storage_import("vc_settings_delete", &[I32, I32], "(param i32 i32) (result i32)", Permission::Settings),
storage_import("vc_cache_read", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Cache),
storage_import("vc_cache_write", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Cache),
storage_import("vc_cache_clear", &[I32, I32], "(param i32 i32) (result i32)", Permission::Cache),
];
/// Which of a plug-in's stores a storage import reads or writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Area {
Settings,
Cache,
}
impl Area {
/// The largest value one key or entry holds.
fn max_value(self) -> usize {
match self {
Area::Settings => storage::MAX_SETTING_BYTES,
Area::Cache => storage::MAX_CACHE_ENTRY,
}
}
}
/// What a storage import returns for `e`.
fn storage_code(e: &StorageError) -> i32 {
match e {
StorageError::Full => STORAGE_FULL,
StorageError::Unavailable => STORAGE_UNAVAILABLE,
StorageError::Io(_) => STORAGE_FAILED,
StorageError::Budget => STORAGE_BUDGET,
}
}
/// A log line's level.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LogLevel {
@@ -189,6 +286,11 @@ pub(crate) struct Run {
/// Bytes of log text written (with their escapes) and read.
log_bytes: usize,
log_read: usize,
/// Where the plug-in keeps its settings and cache (none while the module is being loaded), and
/// how many storage calls the run made and bytes they moved.
storage: Option<Arc<dyn Storage>>,
storage_calls: usize,
storage_bytes: usize,
pub(crate) report: Report,
}
@@ -204,10 +306,111 @@ impl Run {
skipped: 0,
log_bytes: 0,
log_read: 0,
storage: None,
storage_calls: 0,
storage_bytes: 0,
report: Report::default(),
}
}
/// The run, with the plug-in's settings and cache kept in `storage`.
pub(crate) fn with_storage(mut self, storage: Option<Arc<dyn Storage>>) -> Self {
self.storage = storage;
self
}
/// Makes one storage call with `call`, on the plug-in's storage and a [`Meter`] holding the
/// run's storage bytes left, and counts the call and the bytes it read and wrote → the code for
/// a run that has used up its calls or has no storage, else what `call` returns.
fn metered<T>(&mut self, call: impl FnOnce(&dyn Storage, &mut Meter) -> std::result::Result<T, StorageError>) -> std::result::Result<T, i32> {
if self.storage_calls >= MAX_STORAGE_CALLS {
return Err(STORAGE_BUDGET);
}
self.storage_calls += 1;
let Some(store) = self.storage.clone() else { return Err(STORAGE_UNAVAILABLE) };
let mut meter = Meter::new(MAX_STORAGE_BYTES.saturating_sub(self.storage_bytes) as u64);
let done = call(store.as_ref(), &mut meter);
// What the call read before it failed counts too.
self.storage_bytes = self.storage_bytes.saturating_add(usize::try_from(meter.used()).unwrap_or(usize::MAX));
done.map_err(|e| storage_code(&e))
}
/// The key or entry name of `len` bytes at `ptr` in `memory`, checked, or the code a storage
/// import returns for it.
fn storage_name(memory: &[u8], ptr: usize, len: usize) -> std::result::Result<String, i32> {
let bytes = ptr.checked_add(len).and_then(|end| memory.get(ptr..end)).ok_or(STORAGE_OUTSIDE_MEMORY)?;
let name = std::str::from_utf8(bytes).ok().filter(|n| storage::valid_name(n)).ok_or(STORAGE_BAD_NAME)?;
Ok(name.to_string())
}
/// `vc_settings_get` and `vc_cache_read`: copies the value of the key at `name` into the
/// `out_cap` bytes at `out` when it fits → its length.
fn storage_read(&mut self, area: Area, memory: &mut [u8], name: (usize, usize), out: (usize, usize)) -> i32 {
let key = match Self::storage_name(memory, name.0, name.1) {
Ok(key) => key,
Err(code) => return code,
};
if out.1 > 0 && out.0.checked_add(out.1).is_none_or(|end| end > memory.len()) {
return STORAGE_OUTSIDE_MEMORY;
}
// The storage counts what it read whether or not the value fits the buffer.
let got = self.metered(|store, meter| match area {
Area::Settings => store.setting(&key, meter),
Area::Cache => store.cache_entry(&key, meter),
});
let value = match got {
Ok(Some(value)) => value,
Ok(None) => return STORAGE_NOT_FOUND,
Err(code) => return code,
};
let Ok(len) = i32::try_from(value.len()) else { return STORAGE_FAILED };
if value.len() <= out.1
&& let Some(dest) = memory.get_mut(out.0..out.0 + value.len())
{
dest.copy_from_slice(&value);
}
len
}
/// `vc_settings_set` and `vc_cache_write`: sets the key at `name` to the `value` bytes.
fn storage_write(&mut self, area: Area, memory: &[u8], name: (usize, usize), value: (usize, usize)) -> i32 {
let key = match Self::storage_name(memory, name.0, name.1) {
Ok(key) => key,
Err(code) => return code,
};
let Some(bytes) = value.0.checked_add(value.1).and_then(|end| memory.get(value.0..end)) else { return STORAGE_OUTSIDE_MEMORY };
if bytes.len() > area.max_value() {
return STORAGE_FULL;
}
let written = self.metered(|store, meter| match area {
Area::Settings => store.set_setting(&key, bytes, meter),
Area::Cache => store.set_cache_entry(&key, bytes, meter),
});
written.map_or_else(|code| code, |()| STORAGE_DONE)
}
/// `vc_settings_delete` and `vc_cache_clear`: removes the key at `name`, or with `vc_cache_clear`
/// and a `name` of no bytes, every cache entry.
fn storage_remove(&mut self, area: Area, memory: &[u8], name: (usize, usize)) -> i32 {
let key = match (area, name.1) {
(Area::Cache, 0) => None,
_ => match Self::storage_name(memory, name.0, name.1) {
Ok(key) => Some(key),
Err(code) => return code,
},
};
let removed = self.metered(|store, meter| match (area, key) {
(Area::Settings, Some(key)) => store.delete_setting(&key, meter),
(Area::Cache, Some(key)) => store.remove_cache_entry(&key),
(_, None) => store.clear_cache().map(|_| true),
});
match removed {
Ok(true) => STORAGE_DONE,
Ok(false) => STORAGE_NOT_FOUND,
Err(code) => code,
}
}
fn progress(&mut self, done: i64, total: i64) {
let total = u64::try_from(total).unwrap_or(0);
let done = u64::try_from(done).unwrap_or(0);
@@ -286,55 +489,89 @@ pub(crate) struct Host {
pub(crate) run: Run,
}
/// A linker that offers the host imports when `granted`, and no import otherwise.
pub(crate) fn linker(engine: &Engine, granted: bool) -> Linker<Host> {
/// The module's memory and the store's state, for an import that reads or writes the memory.
fn memory_and_host<'a>(caller: &'a mut Caller<'_, Host>) -> Option<(&'a mut [u8], &'a mut Host)> {
let Some(Extern::Memory(memory)) = caller.get_export("memory") else { return None };
Some(memory.data_and_store_mut(caller))
}
/// An i32 argument as the unsigned offset or length it stands for.
fn offset(v: i32) -> usize {
v as u32 as usize
}
/// A linker that offers the host imports whose permission is in `granted`, and no other import.
pub(crate) fn linker(engine: &Engine, granted: &[Permission]) -> Linker<Host> {
let mut linker = Linker::<Host>::new(engine);
if granted {
// Each name is defined once, so none of these fails.
// Each name is defined once, so none of these fails.
if granted.contains(&Permission::Progress) {
let _ = linker.func_wrap(MODULE, "vc_progress", |mut caller: Caller<'_, Host>, done: i64, total: i64| {
caller.data_mut().run.progress(done, total);
});
let _ = linker.func_wrap(MODULE, "vc_cancelled", |caller: Caller<'_, Host>| -> i32 { caller.data().run.cancelled() });
let _ = linker.func_wrap(MODULE, "vc_log", |mut caller: Caller<'_, Host>, level: i32, ptr: i32, len: i32| -> i32 {
let Some(Extern::Memory(memory)) = caller.get_export("memory") else { return LOG_OUTSIDE_MEMORY };
let (data, host) = memory.data_and_store_mut(&mut caller);
host.run.log(level, data, ptr as u32 as usize, len as u32 as usize)
let Some((data, host)) = memory_and_host(&mut caller) else { return LOG_OUTSIDE_MEMORY };
host.run.log(level, data, offset(ptr), offset(len))
});
}
for (area, permission, get, set, remove) in [
(Area::Settings, Permission::Settings, "vc_settings_get", "vc_settings_set", "vc_settings_delete"),
(Area::Cache, Permission::Cache, "vc_cache_read", "vc_cache_write", "vc_cache_clear"),
] {
if !granted.contains(&permission) {
continue;
}
let _ = linker.func_wrap(MODULE, get, move |mut caller: Caller<'_, Host>, key: i32, key_len: i32, out: i32, out_cap: i32| -> i32 {
let Some((data, host)) = memory_and_host(&mut caller) else { return STORAGE_OUTSIDE_MEMORY };
host.run.storage_read(area, data, (offset(key), offset(key_len)), (offset(out), offset(out_cap)))
});
let _ = linker.func_wrap(MODULE, set, move |mut caller: Caller<'_, Host>, key: i32, key_len: i32, value: i32, value_len: i32| -> i32 {
let Some((data, host)) = memory_and_host(&mut caller) else { return STORAGE_OUTSIDE_MEMORY };
host.run.storage_write(area, data, (offset(key), offset(key_len)), (offset(value), offset(value_len)))
});
let _ = linker.func_wrap(MODULE, remove, move |mut caller: Caller<'_, Host>, key: i32, key_len: i32| -> i32 {
let Some((data, host)) = memory_and_host(&mut caller) else { return STORAGE_OUTSIDE_MEMORY };
host.run.storage_remove(area, data, (offset(key), offset(key_len)))
});
}
linker
}
/// Checks that every import of `module` is a host import with the host's signature → their names.
/// Any other import is refused, and the error names it.
pub(crate) fn check_imports(module: &Module) -> Result<Vec<&'static str>> {
let mut names = Vec::new();
/// Checks that every import of `module` is a host import with the host's signature → them. Any
/// other import is refused, and the error names it.
pub(crate) fn check_imports(module: &Module) -> Result<Vec<&'static Import>> {
let mut found = Vec::new();
for imp in module.imports() {
let offered = if imp.module() == MODULE { IMPORTS.iter().find(|i| i.name == imp.name()) } else { None };
let (from, name) = (permissions::display_text(imp.module(), 64), permissions::display_text(imp.name(), 64));
let Some(host) = offered else {
return Err(Error::Abi(format!(
"the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network"
"the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network"
)));
};
match imp.ty() {
ExternType::Func(f) if f.params() == host.params && f.results() == host.results => names.push(host.name),
ExternType::Func(f) if f.params() == host.params && f.results() == host.results => found.push(host),
_ => {
let signature = host.signature;
return Err(Error::Abi(format!("the module imports `env.{name}` with another type than the host's: it is {signature}")));
}
}
}
Ok(names)
Ok(found)
}
/// Checks that `manifest` allows the host imports `imports`: `api` 2 and the `progress`
/// permission. The error names the first import it doesn't allow.
pub(crate) fn check_granted(manifest: &Manifest, imports: &[&str]) -> Result<()> {
match imports.first() {
Some(name) if manifest.api < PROGRESS_API || !manifest.permissions().contains(&Permission::Progress) => Err(Error::Abi(format!(
"the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress`"
))),
_ => Ok(()),
/// Checks that `manifest` allows the host imports `imports`: `api` 2 and each import's permission.
/// The error names the first import it doesn't allow.
pub(crate) fn check_granted(manifest: &Manifest, imports: &[&Import]) -> Result<()> {
let granted = manifest.permissions();
match imports.iter().find(|i| manifest.api < PROGRESS_API || !granted.contains(&i.permission)) {
Some(import) => {
let (name, permission) = (import.name, import.permission.name());
Err(Error::Abi(format!(
"the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}`"
)))
}
None => Ok(()),
}
}
+7 -4
View File
@@ -3,14 +3,16 @@
//! A plug-in is a WebAssembly module that implements the VectorCraft plug-in ABI (v1, documented
//! in `docs/plugins.md`). It runs in [`wasmi`], a pure-Rust interpreter, with:
//!
//! - **no host imports but the three of API level 2** ([`host`]: progress, Cancel and log lines,
//! with the `progress` permission): no WASI, file system, network, clock or randomness. A module
//! that imports anything else is rejected;
//! - **no host imports but those of API level 2** ([`host`]: progress, Cancel and log lines with
//! the `progress` permission, and settings and a cache of its own with the `settings` and `cache`
//! permissions, [`storage`]): no WASI, file system, network, clock or randomness. A module that
//! imports anything else is rejected;
//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is
//! also enforced (native builds), so infinite loops end with an error. A filter may ask for a
//! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it;
//! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits;
//! - a **fresh instance per run**, so no state leaks between calls.
//! - a **fresh instance per run**, so no state leaks between calls; what a plug-in keeps is only
//! what it writes to its settings and cache.
//!
//! Every failure (malformed module, trap, exhausted budget, bad output) is an [`Error`]; nothing
//! a module does can panic or hang the host.
@@ -36,6 +38,7 @@ pub mod permissions;
pub mod registry;
mod runtime;
pub mod safe_mode;
pub mod storage;
pub mod wat;
pub use dialog::ParamUi;
+8 -2
View File
@@ -308,10 +308,11 @@ impl Manifest {
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
&& permissions::validate(self.kind, names)?.contains(&Permission::Progress)
&& let Some(host) = permissions::validate(self.kind, names)?.into_iter().find(|p| permissions::HOST.contains(p))
&& self.api < PROGRESS_API
{
return Err(Error::Manifest(format!("permission `progress` needs plug-in API level {PROGRESS_API}: give `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_extensions()?;
match (self.budget, self.kind) {
@@ -658,6 +659,9 @@ mod tests {
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"),
@@ -665,6 +669,8 @@ mod tests {
(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"),
+45 -10
View File
@@ -5,8 +5,9 @@
//! `doc.read`, `doc.write`, `state` and `progress` are the names of the plug-in capability model in
//! storytold/photocraft#662; the other names are VectorCraft's own. A manifest without
//! `permissions` gets the ones its kind implies ([`Permission::implied`]): what every plug-in of
//! that kind could do before manifests listed them. `progress`, which lets a plug-in use the host
//! imports of API level 2 ([`crate::host`]), is never implied: a manifest lists it.
//! that kind could do before manifests listed them. `progress`, `settings` and `cache`, which let a
//! plug-in use the host imports of API level 2 ([`crate::host`]), are never implied: a manifest
//! lists them.
use serde::{Deserialize, Serialize};
@@ -16,8 +17,8 @@ use crate::{Error, Kind, Result};
/// this.
pub const API_LEVEL: u32 = 2;
/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress`
/// permission they need.
/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress`,
/// `settings` and `cache` permissions they need.
pub const PROGRESS_API: u32 = 2;
/// Most permissions a manifest may list.
@@ -56,11 +57,29 @@ pub enum Permission {
/// host imports of API level 2 ([`crate::host::IMPORTS`]).
#[serde(rename = "progress")]
Progress,
/// Keep settings of its own between runs and sessions ([`crate::storage`]).
#[serde(rename = "settings")]
Settings,
/// Keep a cache of its own, which the user can clear ([`crate::storage`]).
#[serde(rename = "cache")]
Cache,
}
/// Every permission this host grants, in the order prompts list them.
pub const ALL: [Permission; 6] =
[Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite, Permission::Progress];
pub const ALL: [Permission; 8] = [
Permission::DocRead,
Permission::DocWrite,
Permission::State,
Permission::EffectRender,
Permission::ExportWrite,
Permission::Progress,
Permission::Settings,
Permission::Cache,
];
/// The permissions of the host imports, which any kind may list and none implies; each needs plug-in
/// API level [`PROGRESS_API`].
pub const HOST: [Permission; 3] = [Permission::Progress, Permission::Settings, Permission::Cache];
impl Permission {
/// Its name in a manifest and in `plugin.install`'s `grant`.
@@ -72,6 +91,8 @@ impl Permission {
Permission::EffectRender => "effect.render",
Permission::ExportWrite => "export.write",
Permission::Progress => "progress",
Permission::Settings => "settings",
Permission::Cache => "cache",
}
}
@@ -89,17 +110,19 @@ impl Permission {
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::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",
}
}
/// May a plug-in of `kind` list it: one its kind implies, or `progress`, which any kind may
/// list.
/// May a plug-in of `kind` list it: one its kind implies, or one of the host imports' ([`HOST`]),
/// which any kind may list.
pub fn applies(self, kind: Kind) -> bool {
self == Permission::Progress || Permission::implied(kind).contains(&self)
HOST.contains(&self) || Permission::implied(kind).contains(&self)
}
/// The permissions a manifest of `kind` without `permissions` gets. A manifest may also list
/// `progress` ([`Permission::applies`]).
/// the host imports' ([`HOST`], [`Permission::applies`]).
pub fn implied(kind: Kind) -> &'static [Permission] {
match kind {
Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State],
@@ -247,10 +270,22 @@ mod tests {
assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render", "progress"])).is_ok());
assert!(validate(Kind::Exporter, &list(&["progress", "export.write", "doc.read"])).is_ok());
assert!([Kind::Filter, Kind::Effect, Kind::Exporter].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress)));
// So may `settings` and `cache`, which no kind implies either.
assert_eq!(
validate(Kind::Effect, &list(&["cache", "settings", "doc.read", "effect.render"])).unwrap(),
[Permission::DocRead, Permission::EffectRender, Permission::Settings, Permission::Cache]
);
assert!(validate(Kind::Exporter, &list(&["settings", "export.write", "doc.read"])).is_ok());
assert_eq!(validate(Kind::Filter, &list(&["cache"])).unwrap(), [Permission::Cache]);
for kind in [Kind::Filter, Kind::Effect, Kind::Exporter] {
assert!(HOST.iter().all(|p| p.applies(kind) && !Permission::implied(kind).contains(p)));
}
for (kind, names, says) in [
(Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"),
(Kind::Filter, &["ui.panel"], "`ui.panel` is reserved"),
(Kind::Filter, &["progress", "progress"], "`progress` is listed twice"),
(Kind::Filter, &["cache", "cache"], "`cache` is listed twice"),
(Kind::Filter, &["storage"], "unknown permission `storage`"),
(Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"),
(Kind::Exporter, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"),
(Kind::Effect, &["doc.read"], "needs the permission `effect.render`"),
+22 -1
View File
@@ -10,12 +10,30 @@ use std::sync::{Arc, Mutex, RwLock};
#[cfg(not(target_arch = "wasm32"))]
use crate::Error;
use crate::permissions::Grant;
use crate::storage::Storage;
use crate::{Limits, Plugin, Result};
static PLUGINS: RwLock<BTreeMap<String, Arc<Plugin>>> = RwLock::new(BTreeMap::new());
static REVISION: AtomicU64 = AtomicU64::new(0);
/// What the user approved, by plug-in id.
static GRANTS: RwLock<BTreeMap<String, Grant>> = RwLock::new(BTreeMap::new());
/// Where each plug-in keeps its settings and cache, by plug-in id.
static STORAGE: RwLock<BTreeMap<String, Arc<dyn Storage>>> = RwLock::new(BTreeMap::new());
/// Keeps the settings and cache of plug-in `id` in `storage` from now on (`None`: nowhere, so its
/// storage calls fail as unavailable). A run of the plug-in reaches only its own id's storage.
pub fn set_storage(id: &str, storage: Option<Arc<dyn Storage>>) {
let mut all = STORAGE.write().unwrap_or_else(|e| e.into_inner());
match storage {
Some(s) => all.insert(id.to_string(), s),
None => all.remove(id),
};
}
/// Where plug-in `id` keeps its settings and cache, if anywhere.
pub fn storage(id: &str) -> Option<Arc<dyn Storage>> {
STORAGE.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned()
}
/// Records what the user approved for a plug-in, replacing an earlier grant for its id.
pub fn grant(g: Grant) {
@@ -47,9 +65,12 @@ pub fn approved(plugin: &Plugin) -> bool {
/// Installs `plugin`, replacing one with the same id.
pub fn install(mut plugin: Plugin) -> Arc<Plugin> {
// The revision moves while the write lock is held, so a cache that reads the new revision and
// then the plug-ins finds the new plug-in among them.
let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner());
plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1);
let p = Arc::new(plugin);
PLUGINS.write().unwrap_or_else(|e| e.into_inner()).insert(p.id().to_string(), p.clone());
plugins.insert(p.id().to_string(), p.clone());
p
}
+10 -8
View File
@@ -154,8 +154,9 @@ impl Plugin {
let hash = crate::permissions::sha256_hex(bytes);
let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), hash, source: None, serial: 0 };
let deadline = Deadline::new(p.limits.wall_time_ms);
// The manifest isn't read yet: every host import is linked, and what it reports is dropped.
let mut live = p.instantiate(Run::new("", false, false, deadline), true, &mut Report::default())?;
// The manifest isn't read yet: every host import is linked, what it reports is dropped and
// there is no storage to reach.
let mut live = p.instantiate(Run::new("", false, false, deadline), &crate::permissions::ALL, &mut Report::default())?;
let version: TypedFunc<(), i32> = live.func("vc_abi_version")?;
let v = p.call(&mut live, &version, (), p.limits.fuel_base, &deadline)?;
if v != ABI_VERSION {
@@ -215,9 +216,9 @@ impl Plugin {
self.serial = serial;
}
/// A new instance for `run`, with the host imports linked when `granted`. When the module's
/// start function fails, what it reported through the host imports goes to `report`.
fn instantiate(&self, run: Run, granted: bool, report: &mut Report) -> Result<Live> {
/// A new instance for `run`, with the host imports of the permissions `granted` linked. When the
/// module's start function fails, what it reported through the host imports goes to `report`.
fn instantiate(&self, run: Run, granted: &[Permission], report: &mut Report) -> Result<Live> {
let limits =
StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build();
let mut store = Store::new(&self.engine, Host { limits, run });
@@ -287,7 +288,8 @@ impl Plugin {
}
/// [`Plugin::run_limited`], and what the run reported through the host imports: its last
/// progress report and its log lines, which also go to the app's log under its id. Inside a
/// progress report and its log lines, which also go to the app's log under its id. Its storage
/// imports reach the storage of its id ([`crate::registry::storage`]). Inside a
/// [`vectorcraft_doc::cancel::scope`] its progress goes to the scope's sink (a live effect's
/// doesn't) and `vc_cancelled` sees the scope's Cancel.
pub fn run_reporting(&self, input: &[u8], params: &[u8], limits: &Limits) -> (Result<Vec<u8>>, Report) {
@@ -306,8 +308,8 @@ impl Plugin {
total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?;
let deadline = Deadline::new(limits.wall_time_ms);
let m = &self.manifest;
let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline);
let mut live = self.instantiate(run, m.permissions().contains(&Permission::Progress), report)?;
let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline).with_storage(crate::registry::storage(&m.id));
let mut live = self.instantiate(run, &m.permissions(), report)?;
let out = self.run_live(&mut live, input, params, limits, &deadline);
*report = std::mem::take(&mut live.store.data_mut().run.report);
out
+151
View File
@@ -0,0 +1,151 @@
//! What a plug-in keeps between runs, from plug-in API level 2 on: **settings**, a few named values
//! (the `settings` permission), and a **cache** of named entries the user can clear (the `cache`
//! permission). A plug-in reads and writes them through the host imports ([`crate::host::IMPORTS`]),
//! naming each by a key ([`valid_name`]), never by a path; the host keeps them per plug-in id
//! ([`Storage`], given with [`crate::registry::set_storage`]), so one plug-in can't reach another's.
//!
//! The caps are VectorCraft's own values: [`MAX_SETTINGS`] keys of at most [`MAX_SETTING_BYTES`]
//! each in a settings file of at most [`MAX_SETTINGS_BYTES`], and cache entries of at most
//! [`MAX_CACHE_ENTRY`] bytes, [`MAX_CACHE_BYTES`] and [`MAX_CACHE_ENTRIES`] in all. A write that
//! would pass one fails with [`StorageError::Full`] and changes nothing.
/// Longest key or cache entry name, in bytes.
pub const MAX_NAME: usize = 64;
/// Most settings a plug-in keeps.
pub const MAX_SETTINGS: usize = 256;
/// Largest value of one setting, in bytes.
pub const MAX_SETTING_BYTES: usize = 64 << 10;
/// Largest settings file, in bytes: the keys and the values in base64, as JSON.
pub const MAX_SETTINGS_BYTES: usize = 1 << 20;
/// Largest cache entry, in bytes.
pub const MAX_CACHE_ENTRY: usize = 64 << 20;
/// Most bytes a plug-in's cache holds, all entries together.
pub const MAX_CACHE_BYTES: u64 = 512 << 20;
/// Most entries a plug-in's cache holds.
pub const MAX_CACHE_ENTRIES: usize = 4096;
/// Why a storage call failed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StorageError {
/// The write would pass a cap; nothing was changed.
Full,
/// There is nowhere to keep anything: no plug-in store is attached, as in the web build.
Unavailable,
/// Reading or writing the file failed, with the system's reason.
Io(String),
/// The call would read or write more bytes than its [`Meter`] has left; nothing was changed.
Budget,
}
/// The bytes a storage call may still read and write, and those it has. The storage charges each
/// file it reads or writes, at its size, before it does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Meter {
left: u64,
used: u64,
}
impl Meter {
/// A meter that lets a call read and write `left` bytes.
pub fn new(left: u64) -> Self {
Meter { left, used: 0 }
}
/// A meter with no limit, for callers that don't count.
pub fn unlimited() -> Self {
Meter::new(u64::MAX)
}
/// Counts `bytes` about to be read or written → [`StorageError::Budget`], counting nothing,
/// when that is more than is left.
pub fn take(&mut self, bytes: u64) -> Result<(), StorageError> {
if bytes > self.left {
return Err(StorageError::Budget);
}
self.left -= bytes;
self.used = self.used.saturating_add(bytes);
Ok(())
}
/// The bytes counted so far.
pub fn used(&self) -> u64 {
self.used
}
}
impl std::fmt::Display for StorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StorageError::Full => write!(f, "the plug-in's storage is full"),
StorageError::Unavailable => write!(f, "no plug-in storage is available"),
StorageError::Io(why) => write!(f, "{why}"),
StorageError::Budget => write!(f, "the run has used up its storage bytes"),
}
}
}
/// Where one plug-in's settings and cache are kept. Every key and name the host passes is a
/// [`valid_name`], and every value is within its cap ([`MAX_SETTING_BYTES`], [`MAX_CACHE_ENTRY`]);
/// the storage enforces the totals. The calls that read or write a file charge `meter` the bytes
/// they read and write ([`Meter::take`]) before they do: a settings call the whole settings file it
/// reads and the whole file it writes, a cache call the entry it reads or writes.
pub trait Storage: Send + Sync {
/// The value of setting `key`, if it has one.
fn setting(&self, key: &str, meter: &mut Meter) -> Result<Option<Vec<u8>>, StorageError>;
/// Sets `key` to `value`.
fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError>;
/// Removes setting `key` → whether it had a value.
fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result<bool, StorageError>;
/// The cache entry `name`, if there is one.
fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result<Option<Vec<u8>>, StorageError>;
/// Writes the cache entry `name`.
fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError>;
/// Removes the cache entry `name` → whether there was one.
fn remove_cache_entry(&self, name: &str) -> Result<bool, StorageError>;
/// Removes every cache entry → the bytes they held.
fn clear_cache(&self) -> Result<u64, StorageError>;
/// The bytes the cache holds now.
fn cache_bytes(&self) -> u64;
}
/// Is `name` a key or cache entry name a plug-in may use: 1 to [`MAX_NAME`] characters from
/// `A-Z a-z 0-9 _ -`, and not a name Windows keeps for a device (`CON`, `PRN`, `AUX`, `NUL`,
/// `COM0` to `COM9`, `LPT0` to `LPT9`, in any case)? Keys differ in case: `a` and `A` are two.
pub fn valid_name(name: &str) -> bool {
(1..=MAX_NAME).contains(&name.len()) && name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) && !device_name(name)
}
/// Is `name` one of the device names Windows keeps?
fn device_name(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
match upper.as_bytes() {
[a, b, c] => matches!(&[*a, *b, *c], b"CON" | b"PRN" | b"AUX" | b"NUL"),
[a, b, c, d] => matches!(&[*a, *b, *c], b"COM" | b"LPT") && d.is_ascii_digit(),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_are_short_plain_and_never_a_device() {
for ok in ["a", "Z", "zoom-level", "tile_12", &"x".repeat(MAX_NAME), "CONSOLE", "COM10", "LPTX", "con1"] {
assert!(valid_name(ok), "{ok}");
}
for bad in ["", "..", ".", "a/b", "a\\b", "a.b", "a b", "a\u{0}", "\u{7}", "é", &"x".repeat(MAX_NAME + 1), "CON", "con", "Nul", "aux", "prn"]
{
assert!(!valid_name(bad), "{bad:?}");
}
for n in 0..10 {
assert!(!valid_name(&format!("COM{n}")) && !valid_name(&format!("lpt{n}")));
}
}
}
+10
View File
@@ -59,6 +59,16 @@ pub const HOST_IMPORTS: &str = r#"(import "env" "vc_progress" (func $vc_progress
(import "env" "vc_cancelled" (func $vc_cancelled (result i32)))
(import "env" "vc_log" (func $vc_log (param i32 i32 i32) (result i32)))"#;
/// The storage imports of plug-in API level 2 ([`crate::host::IMPORTS`]), as `$vc_settings_get`,
/// `$vc_settings_set`, `$vc_settings_delete`, `$vc_cache_read`, `$vc_cache_write` and
/// `$vc_cache_clear`; a manifest that uses them lists `settings` and `cache`.
pub const STORAGE_IMPORTS: &str = r#"(import "env" "vc_settings_get" (func $vc_settings_get (param i32 i32 i32 i32) (result i32)))
(import "env" "vc_settings_set" (func $vc_settings_set (param i32 i32 i32 i32) (result i32)))
(import "env" "vc_settings_delete" (func $vc_settings_delete (param i32 i32) (result i32)))
(import "env" "vc_cache_read" (func $vc_cache_read (param i32 i32 i32 i32) (result i32)))
(import "env" "vc_cache_write" (func $vc_cache_write (param i32 i32 i32 i32) (result i32)))
(import "env" "vc_cache_clear" (func $vc_cache_clear (param i32 i32) (result i32)))"#;
/// A plug-in of API level 2 (its manifest gives `api` 2 and lists `progress`) that imports the
/// host functions ([`HOST_IMPORTS`]) and whose `vc_run` runs `first`, which may call them, then
/// returns `output`.