Files
vectorcraft/crates/plugins/src/host.rs
T
dotCooCoo b071fb109c Plug-ins: the host import fuzz test reaches vc_log's text checks
The fuzz test for hostile host import arguments drew vc_log's level from every i32, so almost
every call stopped at the level check and never read memory. Levels are now also drawn from -1
to 4. The module docs, a test doc comment and docs/plugins.md now say that with an unknown total
each change of done is passed on.
2026-11-08 13:21:16 +07:00

508 lines
22 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. 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.
//!
//! 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.
use wasmi::{Caller, Engine, Extern, ExternType, Linker, Module, StoreLimits, ValType};
use crate::manifest::Manifest;
use crate::permissions::{self, PROGRESS_API, Permission};
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;
/// One host import: its name and its type.
pub struct Import {
pub name: &'static str,
params: &'static [ValType],
results: &'static [ValType],
/// Its type as WebAssembly text writes it.
pub signature: &'static str,
}
/// The host imports of API level 2, in module [`MODULE`]:
///
/// - `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`].
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)" },
Import {
name: "vc_log",
params: &[ValType::I32, ValType::I32, ValType::I32],
results: &[ValType::I32],
signature: "(param i32 i32 i32) (result i32)",
},
];
/// 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,
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,
report: Report::default(),
}
}
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,
}
/// A linker that offers the host imports when `granted`, and no import otherwise.
pub(crate) fn linker(engine: &Engine, granted: bool) -> Linker<Host> {
let mut linker = Linker::<Host>::new(engine);
if granted {
// Each name is defined once, so none of these fails.
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)
});
}
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();
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"
)));
};
match imp.ty() {
ExternType::Func(f) if f.params() == host.params && f.results() == host.results => names.push(host.name),
_ => {
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)
}
/// 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(()),
}
}
#[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);
}
}