//! 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 (the `progress` permission); `vc_settings_*` //! read and write its settings (`settings`) and `vc_cache_*` its cache (`cache`, [`crate::storage`]); //! `vc_call` runs a command its manifest's `commandAllowlist` names and `vc_call_result` reads that //! command's result (`commands`, through the [`Bridge`] the run is given). 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. 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`]). Command calls are capped //! per call ([`MAX_CALL_DATA`]) and per run ([`MAX_CALLS`], [`MAX_CALL_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 /// `extern` functions by default). pub const MODULE: &str = "env"; /// What the host passes as the step's description with a plug-in's progress /// ([`vectorcraft_doc::cancel::progress`]), so a progress window shows a share rather than a count /// of artboards or files. pub const PROGRESS_STEP: &str = "plug-in"; /// Longest log line: its first 1,024 bytes of text, shown as at most 1,024 characters with hidden /// characters escaped. A longer one is cut and ends with `…`. pub const MAX_LOG_LINE: usize = 1024; /// Most log lines one run writes; later ones are dropped, after one "log truncated" line. pub const MAX_LOG_LINES: usize = 200; /// Most bytes of log text, as written with its escapes, one run writes; lines past it are dropped /// as past [`MAX_LOG_LINES`]. pub const MAX_LOG_BYTES: usize = 32 * 1024; /// Most bytes of text the host reads from a run's `vc_log` calls, checked or not: a call that /// would read past it is dropped as past [`MAX_LOG_LINES`]. pub const MAX_LOG_READ: usize = 1024 * 1024; /// Most progress reports one run passes on at the full rate; after them, one changed report in /// [`PROGRESS_STRIDE`] is, and so is the first after a total becomes known or unknown. The run's /// last report is always kept ([`Report::progress`]). pub const MAX_PROGRESS_REPORTS: usize = 2000; /// Past [`MAX_PROGRESS_REPORTS`], how many changed reports go by for each one passed on. pub const PROGRESS_STRIDE: usize = 1000; /// What `vc_log` returns: the line was written. pub const LOG_WRITTEN: i32 = 0; /// What `vc_log` returns: the run's log budget is spent, so the line was dropped. pub const LOG_DROPPED: i32 = 1; /// What `vc_log` returns: the text doesn't lie inside the module's memory. pub const LOG_OUTSIDE_MEMORY: i32 = -1; /// What `vc_log` returns: the text is not UTF-8. 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; /// 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 time it is read or written and each cache entry read or written; a call that would /// pass it returns [`STORAGE_BUDGET`]. pub const MAX_STORAGE_BYTES: usize = 1 << 30; /// What `vc_call` returns: the command ran, and `vc_call_result` reads its result. pub const CALL_OK: i32 = 0; /// What `vc_call` or `vc_call_result` returns: the id, the arguments or the buffer doesn't lie /// inside the module's memory. pub const CALL_OUTSIDE_MEMORY: i32 = -1; /// What `vc_call` returns: the id or the arguments are not UTF-8. pub const CALL_BAD_UTF8: i32 = -2; /// What `vc_call` returns: the arguments are not a JSON object. pub const CALL_BAD_JSON: i32 = -3; /// What `vc_call` returns: the manifest's `commandAllowlist` doesn't list the command. pub const CALL_NOT_ALLOWED: i32 = -4; /// What `vc_call` returns: no command has the id. pub const CALL_UNKNOWN_COMMAND: i32 = -5; /// What `vc_call` returns: a plug-in may not call the command, whatever its manifest lists. pub const CALL_DENIED_COMMAND: i32 = -6; /// What `vc_call` returns: the command can't run now (no document, nothing selected). pub const CALL_DISABLED: i32 = -7; /// What `vc_call` returns: the command ran and failed. pub const CALL_COMMAND_FAILED: i32 = -8; /// What `vc_call` returns: the arguments or the result are larger than [`MAX_CALL_DATA`], or the /// run made [`MAX_CALLS`] calls or moved [`MAX_CALL_BYTES`] with them. pub const CALL_LIMIT: i32 = -9; /// What `vc_call_result` returns: no call has a result yet. pub const CALL_NO_RESULT: i32 = -10; /// What `vc_call` returns: nothing can be called now (while the module is being loaded, in a run /// that isn't a filter's run on a document, or after a call in the run failed). In a run, this /// fails it too. pub const CALL_NO_CONTEXT: i32 = -11; /// Most `vc_call` calls one run makes. pub const MAX_CALLS: usize = 1_000; /// Largest arguments or result of one `vc_call`, in bytes. pub const MAX_CALL_DATA: usize = 8 << 20; /// Most bytes of arguments and results one run's `vc_call` calls move, all together. pub const MAX_CALL_BYTES: usize = 64 << 20; /// Instructions each `vc_call` costs the run, besides one for every 64 bytes of its arguments and /// result: [`MAX_CALLS`] calls cost a fifth of a filter's usual budget. pub const CALL_FUEL: u64 = 10_000; /// Why a command a plug-in called with `vc_call` didn't run, or failed: the code `vc_call` returns /// and the reason, which the run's error gives. #[derive(Clone, Debug, PartialEq, Eq)] pub struct CallFailure { pub code: i32, pub why: String, } /// What runs the commands a plug-in calls: a command id and its arguments (a JSON object) → the /// command's result, or why it didn't run. pub type Bridge<'a> = dyn FnMut(&str, &serde_json::Value) -> std::result::Result + 'a; /// What `vc_call` pauses the run with, for the host to make the call ([`Run::pending`]). #[derive(Debug)] pub(crate) struct CallPending; impl std::fmt::Display for CallPending { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("vc_call") } } impl wasmi::errors::HostError for CallPending {} /// 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, } 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`; /// - `vc_cancelled() -> i32`: 1 once the user cancelled the run or its time budget ran out, else 0; /// - `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`]. /// /// 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`]. /// /// With `commands`, for a filter whose manifest's `commandAllowlist` names the command: /// /// - `vc_call(id, id_len, args, args_len) -> i32`: runs the command with the UTF-8 id of `id_len` /// bytes at `id` and the arguments at `args` (a UTF-8 JSON object; no bytes: `{}`) on the document /// the run is for → [`CALL_OK`] or why it didn't run or failed. A failed call fails the run, and /// nothing the run did stays; /// - `vc_call_result(out, out_cap) -> i32`: the length of the last successful call's result (JSON), /// copied to `out` when it fits in `out_cap` (nothing is copied otherwise), or /// [`CALL_NO_RESULT`]. pub const IMPORTS: [Import; 11] = [ 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: &[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), storage_import("vc_call", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Commands), storage_import("vc_call_result", &[I32, I32], "(param i32 i32) (result i32)", Permission::Commands), ]; /// 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 { Error, Warn, Info, Debug, } impl LogLevel { /// The level `vc_log`'s `level` names. fn from_code(code: i32) -> Option { match code { 0 => Some(LogLevel::Error), 1 => Some(LogLevel::Warn), 2 => Some(LogLevel::Info), 3 => Some(LogLevel::Debug), _ => None, } } fn log_level(self) -> log::Level { match self { LogLevel::Error => log::Level::Error, LogLevel::Warn => log::Level::Warn, LogLevel::Info => log::Level::Info, LogLevel::Debug => log::Level::Debug, } } } /// A line a plug-in wrote with `vc_log`: its control and bidirectional formatting characters /// escaped ([`permissions::display_text`]), and cut to [`MAX_LOG_LINE`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct LogLine { pub level: LogLevel, pub text: String, } /// The text of the line that stands for the lines a run's log budget dropped. pub const LOG_TRUNCATED: &str = "log truncated: later lines were dropped"; /// What a run reported through the host imports. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Report { /// Its last progress report, clamped: `(done, total)`, `total` 0 when not known. pub progress: Option<(u64, u64)>, /// The lines it wrote, then [`LOG_TRUNCATED`] once its budget dropped one. pub lines: Vec, /// Did its log budget drop a line? pub truncated: bool, } /// The limit a run past its wall-clock deadline exceeded ([`Error::Limit`]). pub(crate) const TIME_BUDGET: &str = "time budget"; /// When a run must stop: a deadline on the monotonic clock (`web_time::Instant`, the page's /// `performance.now()` in the web build). #[derive(Clone, Copy)] pub(crate) struct Deadline { at: web_time::Instant, } impl Deadline { pub(crate) fn new(wall_time_ms: u64) -> Self { Self { at: web_time::Instant::now() + std::time::Duration::from_millis(wall_time_ms) } } pub(crate) fn passed(&self) -> bool { web_time::Instant::now() >= self.at } pub(crate) fn check(&self) -> Result<()> { if self.passed() { Err(Error::Limit(TIME_BUDGET.into())) } else { Ok(()) } } } /// One run's state that the host imports read and write. pub(crate) struct Run { /// The plug-in's id, which its log lines carry. id: String, /// Are its progress reports passed on ([`vectorcraft_doc::cancel::progress`])? A live /// effect's are not: it is drawn as part of something else, such as an export. forward: bool, /// Are its log lines written to the app's log? Not while the module is being loaded. logged: bool, deadline: Deadline, /// The last report passed on, as per mille of a known total (with 1) or the count of an /// unknown one (with 0), how many were, and how many changed ones went by since the last. last: Option<(u64, u64)>, reports: usize, skipped: usize, /// 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>, storage_calls: usize, storage_bytes: usize, /// The commands `vc_call` may run (the manifest's `commandAllowlist`), once the module has /// loaded and when the run has something to run them ([`Run::allow_calls`]); `None` otherwise. allowed: Option>, /// How many `vc_call` calls the run made, and the bytes of arguments and results they moved. calls: usize, call_bytes: usize, /// The call `vc_call` paused the run for: its command id, its arguments and their size in bytes. pub(crate) pending: Option<(String, serde_json::Value, usize)>, /// The last successful call's result, as JSON text. pub(crate) call_result: Option>, /// The run's first failed call, which fails the run. pub(crate) call_failure: Option, pub(crate) report: Report, } impl Run { pub(crate) fn new(id: &str, forward: bool, logged: bool, deadline: Deadline) -> Self { Run { id: id.to_string(), forward, logged, deadline, last: None, reports: 0, skipped: 0, log_bytes: 0, log_read: 0, storage: None, storage_calls: 0, storage_bytes: 0, allowed: None, calls: 0, call_bytes: 0, pending: None, call_result: None, call_failure: None, 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>) -> Self { self.storage = storage; self } /// From now on, `vc_call` runs the commands in `allowed`. pub(crate) fn allow_calls(&mut self, allowed: Vec) { self.allowed = Some(allowed); } /// Records the run's first failed call, `e`, and returns `code`. fn fail(&mut self, code: i32, e: Error) -> i32 { self.call_failure.get_or_insert(e); code } /// `vc_call`: checks the call to the command at `id` with the arguments at `args` in `memory` /// and leaves it in [`Run::pending`] for the host to make, or → the code for why it can't be /// made, which fails the run. The size of the arguments is checked before they are read. After /// the run's first failed call, every call returns [`CALL_NO_CONTEXT`] and none is made. fn call_request(&mut self, memory: &[u8], id: (usize, usize), args: (usize, usize)) -> std::result::Result<(), i32> { self.call_result = None; // No call is made after the run's first failed call. if self.call_failure.is_some() { return Err(CALL_NO_CONTEXT); } if self.allowed.is_none() { let why = "vc_call: no command can be called here (while the module loads, or in a run that can't make calls)"; return Err(self.fail(CALL_NO_CONTEXT, Error::Abi(why.into()))); } let shown = || { id.0.checked_add(id.1) .filter(|_| id.1 <= crate::manifest::MAX_ALLOWED_ID) .and_then(|end| memory.get(id.0..end)) .map_or_else(|| "?".to_string(), |b| permissions::display_text(&String::from_utf8_lossy(b), permissions::MAX_SHOWN_FIELD)) }; if args.1 > MAX_CALL_DATA { let (shown, len) = (shown(), args.1); return Err(self.fail(CALL_LIMIT, Error::Limit(format!("command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA})")))); } if self.calls >= MAX_CALLS || self.call_bytes.saturating_add(args.1) > MAX_CALL_BYTES { return Err(self.fail(CALL_LIMIT, Error::Limit(format!("command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run)")))); } self.calls += 1; self.call_bytes += args.1; let slice = |(at, len): (usize, usize)| at.checked_add(len).and_then(|end| memory.get(at..end)); let (Some(id_bytes), Some(arg_bytes)) = (slice(id).filter(|_| id.1 <= crate::manifest::MAX_ALLOWED_ID), slice(args)) else { if id.1 > crate::manifest::MAX_ALLOWED_ID { let shown = shown(); return Err(self.fail(CALL_NOT_ALLOWED, Error::Abi(format!("vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it")))); } let shown = shown(); return Err( self.fail(CALL_OUTSIDE_MEMORY, Error::Abi(format!("vc_call `{shown}`: the id or the arguments lie outside the module's memory"))) ); }; let (Ok(name), Ok(text)) = (std::str::from_utf8(id_bytes), std::str::from_utf8(arg_bytes)) else { let shown = shown(); return Err(self.fail(CALL_BAD_UTF8, Error::Abi(format!("vc_call `{shown}`: the id or the arguments are not UTF-8")))); }; if !self.allowed.as_ref().is_some_and(|allowed| allowed.iter().any(|a| a == name)) { let shown = shown(); return Err(self.fail(CALL_NOT_ALLOWED, Error::Abi(format!("vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it")))); } let parsed = if text.is_empty() { Ok(serde_json::Value::Object(serde_json::Map::new())) } else { serde_json::from_str(text) }; match parsed { Ok(v @ serde_json::Value::Object(_)) => { self.pending = Some((name.to_string(), v, args.1)); Ok(()) } _ => { let shown = shown(); Err(self.fail(CALL_BAD_JSON, Error::Abi(format!("vc_call `{shown}`: the arguments are not a JSON object")))) } } } /// Takes the host's answer to the call in [`Run::pending`], to command `id`: keeps its result for /// `vc_call_result`, or records the failure → what `vc_call` returns. pub(crate) fn call_answered(&mut self, id: &str, answer: std::result::Result) -> i32 { let shown = permissions::display_text(id, permissions::MAX_SHOWN_FIELD); let value = match answer { Ok(value) => value, Err(f) => { let why = f.why; return self.fail(f.code, Error::Failed(format!("vc_call `{shown}`: {why}"))); } }; let text = serde_json::to_vec(&value).unwrap_or_default(); let len = text.len(); if len > MAX_CALL_DATA { return self.fail(CALL_LIMIT, Error::Limit(format!("command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA})"))); } if self.call_bytes.saturating_add(len) > MAX_CALL_BYTES { return self.fail(CALL_LIMIT, Error::Limit(format!("command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run)"))); } self.call_bytes += len; self.call_result = Some(text); CALL_OK } /// `vc_call_result`: copies the last successful call's result into the `out_cap` bytes at `out` /// when it fits → its length, or [`CALL_NO_RESULT`] or [`CALL_OUTSIDE_MEMORY`]. Neither fails /// the run: a plug-in asks again with a larger buffer. fn call_result(&self, memory: &mut [u8], out: (usize, usize)) -> i32 { let Some(result) = &self.call_result else { return CALL_NO_RESULT }; if out.1 > 0 && out.0.checked_add(out.1).is_none_or(|end| end > memory.len()) { return CALL_OUTSIDE_MEMORY; } if result.len() <= out.1 && let Some(dest) = memory.get_mut(out.0..out.0 + result.len()) { dest.copy_from_slice(result); } // At most MAX_CALL_DATA, which an i32 holds. i32::try_from(result.len()).unwrap_or(CALL_LIMIT) } /// 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(&mut self, call: impl FnOnce(&dyn Storage, &mut Meter) -> std::result::Result) -> std::result::Result { 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 { 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); let done = if total > 0 { done.min(total) } else { done }; self.report.progress = Some((done, total)); let key = if total > 0 { ((u128::from(done) * 1000 / u128::from(total)) as u64, 1) } else { (done, 0) }; if !self.forward || self.last == Some(key) { return; } let switched = self.last.is_some_and(|(_, t)| (t > 0) != (total > 0)); if self.reports >= MAX_PROGRESS_REPORTS && !switched { self.skipped += 1; if self.skipped < PROGRESS_STRIDE { return; } } self.skipped = 0; self.last = Some(key); self.reports += 1; vectorcraft_doc::cancel::progress(done, total, PROGRESS_STEP); } fn cancelled(&self) -> i32 { i32::from(vectorcraft_doc::cancel::is_cancelled() || self.deadline.passed()) } /// Writes the `len` bytes at `ptr` in `memory` as a line of `level` → what `vc_log` returns. fn log(&mut self, level: i32, memory: &[u8], ptr: usize, len: usize) -> i32 { let Some(level) = LogLevel::from_code(level) else { return LOG_BAD_LEVEL }; let Some(bytes) = ptr.checked_add(len).and_then(|end| memory.get(ptr..end)) else { return LOG_OUTSIDE_MEMORY }; if self.report.truncated { return LOG_DROPPED; } if self.log_read.saturating_add(len) > MAX_LOG_READ { return self.truncate(); } self.log_read += len; let Ok(text) = std::str::from_utf8(bytes) else { return LOG_NOT_UTF8 }; // A character the cut splits is left out. let head = text.get(..text.floor_char_boundary(MAX_LOG_LINE)).unwrap_or(text); let shown = if head.len() < text.len() { let mut shown = permissions::display_text(head, MAX_LOG_LINE - 1); if !shown.ends_with('…') { shown.push('…'); } shown } else { permissions::display_text(head, MAX_LOG_LINE) }; if self.report.lines.len() >= MAX_LOG_LINES || self.log_bytes + shown.len() > MAX_LOG_BYTES { return self.truncate(); } self.log_bytes += shown.len(); self.write(level, shown); LOG_WRITTEN } /// Drops the line and the run's later ones, after one line that says so → [`LOG_DROPPED`]. fn truncate(&mut self) -> i32 { self.report.truncated = true; self.write(LogLevel::Warn, LOG_TRUNCATED.to_string()); LOG_DROPPED } fn write(&mut self, level: LogLevel, text: String) { if self.logged { log::log!(target: "vectorcraft_plugins", level.log_level(), "{}: {text}", self.id); } self.report.lines.push(LogLine { level, text }); } } /// What a store holds: its memory limits and the run's state. pub(crate) struct Host { pub(crate) limits: StoreLimits, pub(crate) run: Run, } /// 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 { let mut linker = Linker::::new(engine); // 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((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))) }); } if granted.contains(&Permission::Commands) { // A call that can be made pauses the run: the host makes it with what the run was given to // make calls with ([`crate::Plugin::run_calling`]), and resumes the run with the code. let _ = linker.func_wrap( MODULE, "vc_call", |mut caller: Caller<'_, Host>, id: i32, id_len: i32, args: i32, args_len: i32| -> std::result::Result { let Some((data, host)) = memory_and_host(&mut caller) else { return Ok(CALL_OUTSIDE_MEMORY) }; match host.run.call_request(data, (offset(id), offset(id_len)), (offset(args), offset(args_len))) { Ok(()) => Err(wasmi::Error::host(CallPending)), Err(code) => Ok(code), } }, ); let _ = linker.func_wrap(MODULE, "vc_call_result", |mut caller: Caller<'_, Host>, out: i32, out_cap: i32| -> i32 { let Some((data, host)) = memory_and_host(&mut caller) else { return CALL_OUTSIDE_MEMORY }; host.run.call_result(data, (offset(out), offset(out_cap))) }); } linker } /// 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> { 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 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 => 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(found) } /// 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(()), } } #[cfg(test)] mod tests { use super::*; fn run() -> Run { Run::new("test", true, false, Deadline::new(60_000)) } /// A deadline reads the monotonic clock that the web build also reads: one of 0 ms has /// passed, and one a day away has not. #[test] fn a_deadline_passes_on_the_monotonic_clock() { assert!(Deadline::new(0).passed()); assert!(matches!(Deadline::new(0).check(), Err(Error::Limit(l)) if l == TIME_BUDGET)); assert!(!Deadline::new(86_400_000).passed()); } /// Progress is clamped: negative counts are 0 and `done` never passes a known total. #[test] fn progress_is_clamped() { let mut r = run(); for ((done, total), want) in [((5, 3), (3, 3)), ((-4, 10), (0, 10)), ((7, -1), (7, 0)), ((i64::MAX, i64::MAX), (i64::MAX as u64, i64::MAX as u64))] { r.progress(done, total); assert_eq!(r.report.progress, Some(want), "{done} of {total}"); } } /// Reports are passed on only when they change (with a known total, to another tenth of a /// percent, however the total changes; with an unknown total, each change of `done`), and past /// [`MAX_PROGRESS_REPORTS`] once per [`PROGRESS_STRIDE`] changes, except the first after the /// total becomes known; the last one is always kept. #[test] fn progress_is_passed_on_at_a_bounded_rate() { use std::cell::RefCell; use std::rc::Rc; let seen = Rc::new(RefCell::new(Vec::new())); let sink: vectorcraft_doc::cancel::ProgressSink = { let seen = seen.clone(); Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string()))) }; let token = vectorcraft_doc::cancel::CancelToken::new(); let mut r = run(); vectorcraft_doc::cancel::scope(&token, Some(sink), || { for done in 0..=1_000_000 { r.progress(done, 1_000_000); } }); let seen = seen.borrow(); assert_eq!(seen.len(), 1001, "one per tenth of a percent"); assert_eq!(seen.last(), Some(&(1_000_000, 1_000_000, PROGRESS_STEP.to_string()))); let mut unknown = run(); let later = Rc::new(RefCell::new(Vec::new())); let sink: vectorcraft_doc::cancel::ProgressSink = { let later = later.clone(); Rc::new(move |d, t, _| later.borrow_mut().push((d, t))) }; vectorcraft_doc::cancel::scope(&token, Some(sink), || { for done in 0..10_000 { unknown.progress(done, 0); } assert_eq!(unknown.reports, MAX_PROGRESS_REPORTS + (10_000 - MAX_PROGRESS_REPORTS) / PROGRESS_STRIDE); assert_eq!(unknown.report.progress, Some((9_999, 0)), "the last report is kept"); // A total that becomes known is passed on at once, and the reports after it keep // coming at the slower rate. unknown.progress(10, 100); assert_eq!(later.borrow().last(), Some(&(10, 100))); for done in 11..=100 { unknown.progress(done, 100); } }); assert_eq!(later.borrow().last(), Some(&(10, 100)), "90 changes are fewer than the stride"); assert_eq!(unknown.report.progress, Some((100, 100))); // A new total at the same tenth of a percent is not a new step. let mut retotalled = run(); let steps = Rc::new(RefCell::new(Vec::new())); let sink: vectorcraft_doc::cancel::ProgressSink = { let steps = steps.clone(); Rc::new(move |d, t, _| steps.borrow_mut().push((d, t))) }; vectorcraft_doc::cancel::scope(&token, Some(sink), || { for total in 10..2_010 { retotalled.progress(total / 10, total - total % 10); } }); assert_eq!(*steps.borrow(), [(1, 10)]); assert_eq!(retotalled.report.progress, Some((200, 2_000))); } /// Text outside memory, text that isn't UTF-8 and an unknown level are errors; a long line is /// cut, also inside a character, and hidden characters are escaped. #[test] fn log_text_is_checked_and_cut() { let mut r = run(); let memory = b"hello\xff\xfe\nworld"; assert_eq!(r.log(2, memory, 0, 5), LOG_WRITTEN); assert_eq!(r.log(2, memory, 0, memory.len() + 1), LOG_OUTSIDE_MEMORY); assert_eq!(r.log(2, memory, usize::MAX, 2), LOG_OUTSIDE_MEMORY); assert_eq!(r.log(2, memory, 4, 3), LOG_NOT_UTF8); assert_eq!(r.log(4, memory, 0, 5), LOG_BAD_LEVEL); assert_eq!(r.log(-1, memory, 0, 5), LOG_BAD_LEVEL); assert_eq!(r.log(0, memory, 7, 6), LOG_WRITTEN); // The cut falls inside the 512th "é", which is left out. let long = format!("a{}", "é".repeat(MAX_LOG_LINE)); assert_eq!(r.log(1, long.as_bytes(), 0, long.len()), LOG_WRITTEN); let lines = &r.report.lines; assert_eq!(lines[0], LogLine { level: LogLevel::Info, text: "hello".into() }); assert_eq!(lines[1], LogLine { level: LogLevel::Error, text: "\\u{000A}world".into() }); assert_eq!(lines[2], LogLine { level: LogLevel::Warn, text: format!("a{}…", "é".repeat(MAX_LOG_LINE / 2 - 1)) }); assert!(!r.report.truncated); // Text past the cut is checked too. let bad_tail = [vec![b'a'; MAX_LOG_LINE * 2], vec![0xff]].concat(); assert_eq!(r.log(2, &bad_tail, 0, bad_tail.len()), LOG_NOT_UTF8); // A cut line of escapes is at most MAX_LOG_LINE characters, its ellipsis included. let controls = vec![1u8; MAX_LOG_LINE * 2]; assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_WRITTEN); let text = &r.report.lines[3].text; assert!(text.starts_with("\\u{0001}") && text.ends_with('…') && text.chars().count() <= MAX_LOG_LINE, "{text}"); } /// The byte budget counts a line as written, with its escapes, so short lines of hidden /// characters can't write more than [`MAX_LOG_BYTES`]. #[test] fn log_budget_counts_escaped_text() { let mut r = run(); let controls = [1u8; 100]; let shown = "\\u{0001}".len() * controls.len(); let fit = MAX_LOG_BYTES / shown; for i in 0..fit { assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_WRITTEN, "line {i}"); } assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_DROPPED); let written: usize = r.report.lines.iter().filter(|l| l.text != LOG_TRUNCATED).map(|l| l.text.len()).sum(); assert!(written <= MAX_LOG_BYTES, "{written}"); } /// The host reads at most [`MAX_LOG_READ`] bytes of log text in a run, so long texts can't /// make it check more; once past it, later calls are dropped without reading. #[test] fn log_text_read_is_bounded() { let mut r = run(); let big = vec![b'a'; MAX_LOG_READ / 4]; for _ in 0..4 { assert_eq!(r.log(2, &big, 0, big.len()), LOG_WRITTEN); } assert_eq!(r.log(2, b"x", 0, 1), LOG_DROPPED); assert!(r.report.truncated); let mut huge = run(); let over = vec![0xffu8; MAX_LOG_READ + 1]; assert_eq!(huge.log(2, &over, 0, over.len()), LOG_DROPPED, "dropped before it is checked"); assert_eq!(huge.log(2, b"x", 0, 1), LOG_DROPPED); } /// Lines past the run's budget are dropped, with one line that says so. #[test] fn log_lines_past_the_budget_are_dropped_with_one_truncation_line() { let mut r = run(); for i in 0..MAX_LOG_LINES + 50 { let want = if i < MAX_LOG_LINES { LOG_WRITTEN } else { LOG_DROPPED }; assert_eq!(r.log(2, b"line", 0, 4), want, "line {i}"); } assert_eq!(r.report.lines.len(), MAX_LOG_LINES + 1); assert_eq!(r.report.lines.last(), Some(&LogLine { level: LogLevel::Warn, text: LOG_TRUNCATED.into() })); assert!(r.report.truncated); // So is text past the run's bytes. let mut bytes = run(); let big = vec![b'x'; MAX_LOG_LINE]; let fit = MAX_LOG_BYTES / MAX_LOG_LINE; for _ in 0..fit { assert_eq!(bytes.log(2, &big, 0, big.len()), LOG_WRITTEN); } assert_eq!(bytes.log(2, b"x", 0, 1), LOG_DROPPED); assert_eq!(bytes.report.lines.len(), fit + 1); } }