Plug-ins: tests hold an importer to its budget and hostile extensions in the manifest fuzz

An import's instruction and time budgets are now host limits on Limits, as a heavy filter's are, so a test can give an importer a small budget. An importer past its instruction or time limit, and a file past the input limit, return their error and leave an open document and its undo history unchanged. The manifest fuzz varies extensions (missing, dotted, mixed-case, repeated, too long and too many).
This commit is contained in:
dotCooCoo
2026-11-08 12:29:27 -08:00
parent 94dcb63c01
commit 26804fb0c4
6 changed files with 161 additions and 24 deletions
+53 -11
View File
@@ -15,7 +15,7 @@ use proptest::prelude::*;
use serde_json::{Map, Value, json};
use vectorcraft_engine::Session;
use vectorcraft_plugins::wat as tpl;
use vectorcraft_plugins::{Error, Limits, Plugin};
use vectorcraft_plugins::{Error, Kind, Limits, Plugin};
use vectorcraft_testkit::catch_quiet;
use vectorcraft_testkit::fixtures::rich_session;
@@ -66,6 +66,29 @@ fn arb_junk() -> impl Strategy<Value = Value> {
})
}
/// One entry of a manifest's `extensions`: valid, dotted, mixed-case, empty or too long, or junk.
fn arb_extension() -> impl Strategy<Value = Value> {
prop_oneof![
4 => "[a-z0-9]{1,16}".prop_map(Value::from),
1 => prop::sample::select(vec![".fz", "FZ", "fZ", "a b", "", "x.y", "é", "a\u{202E}", "abcdefghijklmnopq"]).prop_map(Value::from),
1 => "[a-z0-9]{17,32}".prop_map(Value::from),
1 => arb_junk(),
]
}
/// A manifest's `extensions`: `None` leaves the key out; otherwise an empty list, up to the eight
/// allowed, too many, one repeated, or junk.
fn arb_extensions() -> impl Strategy<Value = Option<Value>> {
prop_oneof![
1 => Just(None),
1 => Just(Some(json!([]))),
4 => prop::collection::vec(arb_extension(), 1..=8).prop_map(|l| Some(Value::Array(l))),
1 => prop::collection::vec(arb_extension(), 9..=12).prop_map(|l| Some(Value::Array(l))),
1 => "[a-z0-9]{1,8}".prop_map(|e| Some(json!([e, "fz", e]))),
1 => arb_junk().prop_map(Some),
]
}
/// Numbers that tend to break arithmetic.
fn arb_num() -> impl Strategy<Value = Value> {
prop_oneof![
@@ -583,10 +606,11 @@ proptest! {
renders(&s, "after mutated modules")?;
}
/// Manifests with hostile `permissions`, `api` and names (unknown, reserved and repeated
/// permissions, junk of every type, control and bidirectional override characters) parse to
/// an error or to a manifest whose permissions fit its kind, and installing one with a junk
/// `grant` ends as an error or an install, never a panic. A prompt never shows a hidden
/// Manifests with hostile `permissions`, `api`, names and `extensions` (unknown, reserved and
/// repeated permissions, junk of every type, control and bidirectional override characters,
/// extensions missing, dotted, mixed-case, repeated, too long or too many) parse to an error
/// or to a manifest whose permissions and extensions fit its kind, and installing one with a
/// junk `grant` ends as an error or an install, never a panic. A prompt never shows a hidden
/// character from the name.
#[test]
fn hostile_manifest_permissions_never_crash(
@@ -607,20 +631,38 @@ proptest! {
arb_junk(),
Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read", "progress"]))
],
extensions in prop::option::weighted(0.5, arb_extensions()),
) {
let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api});
if matches!(kind, "exporter" | "importer") {
m["extensions"] = json!(["fz"]);
// Hostile extensions come with a valid `api` and no `permissions`, so the parse reaches
// them. The other half of the cases keep a valid list and reach the other fields.
let hostile_extensions = extensions.is_some();
match extensions {
None if matches!(kind, "exporter" | "importer") => m["extensions"] = json!(["fz"]),
None | Some(None) => {}
Some(Some(hostile)) => m["extensions"] = hostile,
}
if hostile_extensions {
m["api"] = json!(1);
} else {
m["permissions"] = match permissions {
Some(Value::Null) | None => json!(names),
Some(junk) => junk,
};
}
m["permissions"] = match permissions {
Some(Value::Null) | None => json!(names),
Some(junk) => junk,
};
let text = m.to_string();
let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes()));
let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?;
if let Ok(man) = &parsed {
prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind)), "{}", text);
let exts = &man.extensions;
if matches!(man.kind, Kind::Exporter | Kind::Importer) {
prop_assert!((1..=8).contains(&exts.len()), "{}", text);
prop_assert!(exts.iter().all(|e| (1..=16).contains(&e.len()) && e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())), "{}", text);
prop_assert!(exts.iter().enumerate().all(|(i, e)| !exts[..i].contains(e)), "{}", text);
} else {
prop_assert!(exts.is_empty(), "{}", text);
}
let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown);
prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
+63 -12
View File
@@ -13,6 +13,7 @@ use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::{EngineError, Session};
use vectorcraft_plugins::wat::{self as tpl, Given};
use vectorcraft_plugins::{Limits, Plugin};
/// An importer's output: one red rectangle from (10, 20) to (110, 70) and a warning.
const RECT: &str = r#"{"objects":[{"type":"path","name":"Box","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]},"fills":[{"type":"solid","color":{"model":"rgb","r":1,"g":0,"b":0}}]}],"warnings":["2 layers merged"]}"#;
@@ -20,6 +21,9 @@ const RECT: &str = r#"{"objects":[{"type":"path","name":"Box","path":{"subpaths"
/// An importer's output: a horizontal line from (0, 5) to (50, 5).
const LINE: &str = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,5]},{"p":[50,5]}]}]}}]}"#;
/// The `vc_run` body of an importer that never returns.
const SPINS: &str = "(loop $l (br $l)) (i64.const 0)";
/// Installs the plug-in written in WAT `src` → its `plugin.list` entry.
fn install(s: &mut Session, src: &str) -> Value {
let wasm = wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}"));
@@ -59,6 +63,19 @@ fn state(s: &mut Session) -> (usize, Option<usize>, usize, usize, bool, Value) {
(s.documents().len(), s.active_index(), d.history.undo.len(), d.history.redo.len(), d.is_dirty(), content)
}
/// Opens a document through a new importer `id`, with an undo step and a redo step → its [`state`].
fn open_edited(s: &mut Session, id: &str) -> (usize, Option<usize>, usize, usize, bool, Value) {
install(s, &tpl::returning(&tpl::importer(id, r#"["tsk"]"#, "{}"), RECT));
open(s, "Kept.tsk", b"?", json!({"importer": format!("plugin.{id}")})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let before = state(s);
assert!(before.2 > 0 && before.3 > 0, "{before:?}");
before
}
/// A fresh folder for files this test writes.
fn tmp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vc-plugin-import-{tag}-{}", std::process::id()));
@@ -269,15 +286,8 @@ fn failures_open_nothing() {
assert!(s.documents().is_empty(), "nothing opened");
// A document that is already open keeps its tab, content and undo and redo steps.
install(&mut s, &tpl::returning(&tpl::importer("test.fail.opens", r#"["tsk"]"#, "{}"), RECT));
install(&mut s, &tpl::module(&tpl::importer("test.fail.spins", r#"["tsf"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1));
open(&mut s, "Kept.tsk", b"?", json!({})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let before = state(&mut s);
assert!(before.2 > 0 && before.3 > 0, "{before:?}");
install(&mut s, &tpl::module(&tpl::importer("test.fail.spins", r#"["tsf"]"#, "{}"), SPINS, "", 1));
let before = open_edited(&mut s, "test.fail.opens");
for id in cases.iter().map(|c| c.0).chain(["test.fail.traps"]) {
assert!(open(&mut s, "x.tsf", b"?", json!({"importer": format!("plugin.{id}")})).is_err(), "{id}");
assert_eq!(state(&mut s), before, "{id}");
@@ -294,7 +304,7 @@ fn failures_open_nothing() {
#[test]
fn a_cancelled_open_stops_the_importer() {
let mut s = Session::new();
install(&mut s, &tpl::module(&tpl::importer("test.spins", r#"["tss"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1));
install(&mut s, &tpl::module(&tpl::importer("test.spins", r#"["tss"]"#, "{}"), SPINS, "", 1));
let token = vectorcraft_doc::cancel::CancelToken::new();
token.cancel();
let r = vectorcraft_doc::cancel::scope(&token, None, || open(&mut s, "x.tss", b"?", json!({})));
@@ -302,18 +312,59 @@ fn a_cancelled_open_stops_the_importer() {
assert!(s.documents().is_empty());
}
/// A file past the importer's input limit is not read past it, and doesn't open.
/// An importer that never returns, held to a small import budget, ends at its instruction limit
/// or at its time limit (0 ms, which has passed at the first check between two slices of the
/// budget). The open reports the limit, opens no document, and leaves an open document's content
/// and undo and redo steps as they were.
#[test]
fn an_importer_past_its_budget_opens_nothing() {
let cases = [
("test.budget.fuel", Limits { export_fuel: 1_000_000, ..Limits::default() }, "instruction budget"),
("test.budget.time", Limits { export_fuel: 1_000_000_000, export_wall_time_ms: 0, ..Limits::default() }, "time budget"),
];
for (id, limits, _) in &cases {
let wasm = wat::parse_str(tpl::module(&tpl::importer(id, r#"["tbg"]"#, "{}"), SPINS, "", 1)).unwrap();
vectorcraft_plugins::registry::install(Plugin::load(&wasm, limits.clone()).unwrap());
}
let mut s = Session::new();
for (id, _, why) in &cases {
let e = open(&mut s, "x.tbg", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err();
assert!(e.to_string().contains(why) && e.to_string().contains("x.tbg"), "{id}: {e}");
assert!(s.documents().is_empty(), "{id}");
}
let before = open_edited(&mut s, "test.budget.opens");
for (id, _, why) in &cases {
let e = open(&mut s, "x.tbg", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err();
assert!(e.to_string().contains(why), "{id}: {e}");
assert_eq!(state(&mut s), before, "{id}");
}
for (id, _, _) in &cases {
vectorcraft_plugins::registry::remove(id);
}
}
/// A file past the importer's input limit is not read past it, and doesn't open: an open document
/// keeps its content and its undo and redo steps. Given as data, it is refused the same way.
#[test]
fn a_file_past_the_input_limit_is_refused() {
let dir = tmp_dir("large");
let mut s = Session::new();
echo(&mut s, "test.large", r#"["tsb"]"#);
let path = dir.join("big.tsb");
std::fs::File::create(&path).unwrap().set_len(vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 + 1).unwrap();
let max = vectorcraft_plugins::import::MAX_INPUT_BYTES;
std::fs::File::create(&path).unwrap().set_len(max as u64 + 1).unwrap();
let e = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap_err();
assert!(e.to_string().contains("larger than"), "{e}");
assert!(e.to_string().contains("big.tsb"), "{e}");
assert!(s.documents().is_empty());
let before = open_edited(&mut s, "test.large.opens");
let e = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap_err();
assert!(e.to_string().contains("larger than"), "{e}");
assert_eq!(state(&mut s), before, "by path");
let e = open(&mut s, "big.tsb", &vec![0; max + 1], json!({})).unwrap_err();
assert!(e.to_string().contains("larger than"), "{e}");
assert_eq!(state(&mut s), before, "as data");
let _ = std::fs::remove_dir_all(&dir);
}
+2 -1
View File
@@ -75,7 +75,8 @@ pub struct Target<'a> {
/// The limits of an export by `plugin`: its own, with the budgets of an export.
pub fn limits(plugin: &Plugin) -> Limits {
Limits { fuel_base: FUEL, wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() }
let own = plugin.limits();
Limits { fuel_base: own.export_fuel, wall_time_ms: own.export_wall_time_ms, ..own.clone() }
}
/// Runs exporter `plugin` on `document`, the native file's JSON, with `params` (checked against
+2
View File
@@ -143,6 +143,8 @@ mod tests {
assert!(out.objects.iter().all(|o| o.id.is_none() && o.path.is_some()));
assert_eq!(decode(format!(r#"{{"objects":[{SQUARE}]}}"#).as_bytes()).unwrap().warnings, Vec::<String>::new());
assert_eq!(decode(br#"{"error": "Not a\nplot file."}"#), Err(Error::Failed("Not aplot file.".into())));
// A decline is reported as one, also with keys the host doesn't read beside it.
assert_eq!(decode(br#"{"error": "No.", "layers": []}"#), Err(Error::Failed("No.".into())));
}
/// Nothing an importer returns reaches a document unchecked: malformed JSON, no objects, keys
+6
View File
@@ -61,6 +61,10 @@ pub struct Limits {
/// raise. Set them to `fuel_base` and `wall_time_ms` to give heavy filters nothing more.
pub heavy_fuel: u64,
pub heavy_wall_time_ms: u64,
/// What one export or import is given instead of `fuel_base` and `wall_time_ms`
/// ([`crate::export::limits`]).
pub export_fuel: u64,
pub export_wall_time_ms: u64,
}
impl Default for Limits {
@@ -75,6 +79,8 @@ impl Default for Limits {
wall_time_ms: 60_000,
heavy_fuel: HEAVY_FUEL,
heavy_wall_time_ms: HEAVY_WALL_TIME_MS,
export_fuel: crate::export::FUEL,
export_wall_time_ms: crate::export::WALL_TIME_MS,
}
}
}
+35
View File
@@ -279,6 +279,41 @@ fn an_exporter_returns_its_file_with_an_export_s_budget() {
assert!(e.to_string().contains("is a plug-in of kind `importer`, not an exporter"), "{e}");
}
/// An importer runs on an import's budget, which replaces its own instruction and time budgets and
/// keeps its other limits: a run that needs more than `fuel_base` finishes within `export_fuel`,
/// and a run that needs more than `export_fuel` ends there, however large `fuel_base` is.
#[test]
fn an_importer_runs_on_an_import_s_budget() {
use vectorcraft_plugins::export;
use vectorcraft_plugins::import::{self, Source};
let run = format!(
"(local $n i32) (local.set $n (i32.const 100000)) \
(loop $work (local.set $n (i32.sub (local.get $n) (i32.const 1))) (br_if $work (local.get $n))) {}",
tpl::ECHO
);
let wasm = build(&tpl::module(&tpl::importer("test.import.budget", r#"["tst"]"#, "{}"), &run, "", 1));
let file = br#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[1,0]}]}]}}]}"#;
let source = Source { extension: "tst", name: "a" };
let limits = import::limits(&Plugin::load(&wasm, Limits::default()).unwrap());
assert_eq!((limits.fuel_base, limits.wall_time_ms), (export::FUEL, export::WALL_TIME_MS));
let own = Limits {
fuel_base: 9_000,
fuel_per_byte: 0,
wall_time_ms: 8_000,
export_fuel: 10_000_000,
export_wall_time_ms: 7_000,
max_output_bytes: 6_000,
..Limits::default()
};
let p = Plugin::load(&wasm, own.clone()).unwrap();
assert_eq!(import::limits(&p), Limits { fuel_base: 10_000_000, wall_time_ms: 7_000, ..own });
assert_eq!(import::run(&p, file, &Value::Null, source).map(|i| i.objects.len()), Ok(1));
let p = Plugin::load(&wasm, Limits { fuel_base: 10_000_000, fuel_per_byte: 0, export_fuel: 9_000, ..Limits::default() }).unwrap();
assert_eq!(import::run(&p, file, &Value::Null, source), Err(Error::Limit("instruction budget".into())));
}
/// The echo exporter the engine and UI tests read back what they hand a plug-in with: its file is
/// its input, or its parameters, byte for byte, whatever their length modulo three.
#[test]