Files
vectorcraft/crates/plugins/src/host.rs
T
2026-11-09 02:54:33 +07:00

745 lines
33 KiB
Rust

//! 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`]).
//! 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`]). 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 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,
}
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`].
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: &[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 {
Error,
Warn,
Info,
Debug,
}
impl LogLevel {
/// The level `vc_log`'s `level` names.
fn from_code(code: i32) -> Option<LogLevel> {
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<LogLine>,
/// Did its log budget drop a line?
pub truncated: bool,
}
/// When a run must stop (native: a wall-clock deadline).
#[derive(Clone, Copy)]
pub(crate) struct Deadline {
#[cfg(not(target_arch = "wasm32"))]
at: std::time::Instant,
}
impl Deadline {
pub(crate) fn new(wall_time_ms: u64) -> Self {
#[cfg(target_arch = "wasm32")]
let _ = wall_time_ms;
Self {
#[cfg(not(target_arch = "wasm32"))]
at: std::time::Instant::now() + std::time::Duration::from_millis(wall_time_ms),
}
}
pub(crate) fn passed(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
if std::time::Instant::now() >= self.at {
return true;
}
false
}
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<Arc<dyn Storage>>,
storage_calls: usize,
storage_bytes: usize,
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,
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);
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<Host> {
let mut linker = Linker::<Host>::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)))
});
}
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<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 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))
}
/// 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);
}
}