mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 06:05:08 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user