diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index ede412d698..70e1014ad4 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -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 { }) } +/// One entry of a manifest's `extensions`: valid, dotted, mixed-case, empty or too long, or junk. +fn arb_extension() -> impl Strategy { + 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> { + 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 { 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); diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index 2abe7f3208..120bcebd02 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -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, 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, 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); } diff --git a/crates/plugins/src/export.rs b/crates/plugins/src/export.rs index 300bd80d22..b51fcfdb97 100644 --- a/crates/plugins/src/export.rs +++ b/crates/plugins/src/export.rs @@ -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 diff --git a/crates/plugins/src/import.rs b/crates/plugins/src/import.rs index 6229d89597..599470e0aa 100644 --- a/crates/plugins/src/import.rs +++ b/crates/plugins/src/import.rs @@ -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::::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 diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 7059aed15a..9a81f4f35c 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -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, } } } diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index 1463f9916c..1d8e64dce3 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -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]