Files
vectorcraft/crates/engine/tests/plugin_import.rs
T
dotCooCoo 2ecd593e61 Plug-ins: a file past an importer's limit is refused from its size, without reading it
document.open by path reads the first 64 KiB of a file only an importer
reads to look for a built-in format. A larger file is read to the limit
only when its size is within the limit, so an over-limit file costs no
128 MiB read and a built-in file over the limit is not held in memory
twice.
2026-11-08 10:20:53 -08:00

339 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Importer plug-ins (`"kind": "importer"`, docs/plugins.md › Importers) through the engine: the
//! formats they add to `document.formats` and `document.open`, which files they are given, the
//! document their objects become, and what happens when they fail, decline or are cancelled.
//!
//! A test binary of its own: installed plug-ins are process-wide, and the unit tests that check the
//! exact list of formats would see these ones.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::{EngineError, Session};
use vectorcraft_plugins::wat::{self as tpl, Given};
/// 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"]}"#;
/// 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]}]}]}}]}"#;
/// 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}"));
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap()
}
/// An importer `id` reading `extensions` (a JSON list) whose objects are the file it is given.
fn echo(s: &mut Session, id: &str, extensions: &str) -> Value {
install(s, &tpl::module(&tpl::importer(id, extensions, "{}"), tpl::ECHO, "", 1))
}
/// `document.open` of a file `name` holding `bytes`, with more params `extra`.
fn open(s: &mut Session, name: &str, bytes: &[u8], extra: Value) -> Result<Value, EngineError> {
let mut p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
s.execute("document.open", &p)
}
/// The active document's first artboard, as `(x0, y0, x1, y1)`.
fn artboard(s: &Session) -> (f64, f64, f64, f64) {
let r = s.doc().unwrap().doc.artboards[0].rect;
(r.x0, r.y0, r.x1, r.y1)
}
/// How many objects the active document's first layer holds.
fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.layers[0].children().unwrap().len()
}
/// The open documents' count, the active one's index, its undo and redo steps, whether it has
/// unsaved changes, and its content.
fn state(s: &mut Session) -> (usize, Option<usize>, usize, usize, bool, Value) {
let content = s.execute("document.inspect", &json!({})).unwrap();
let d = s.doc().unwrap();
(s.documents().len(), s.active_index(), d.history.undo.len(), d.history.redo.len(), d.is_dirty(), content)
}
/// 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()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn path_of(p: &std::path::Path) -> String {
p.to_string_lossy().into_owned()
}
#[test]
fn an_importer_adds_a_read_only_format_while_it_is_installed() {
let mut s = Session::new();
let entry = echo(&mut s, "test.list", r#"["tsl","tsl2"]"#);
assert_eq!((&entry["kind"], &entry["format"], &entry["extensions"]), (&json!("importer"), &json!("plugin.test.list"), &json!(["tsl", "tsl2"])));
assert_eq!(entry["permissions"], json!(["import.read"]));
let formats = s.execute("document.formats", &json!({})).unwrap();
let listed = formats["formats"].as_array().unwrap().iter().find(|f| f["id"] == "plugin.test.list").expect("listed");
assert_eq!((&listed["label"], &listed["extensions"]), (&json!("Test test.list"), &json!(["tsl", "tsl2"])));
assert_eq!((&listed["read"], &listed["write"], &listed["plugin"]), (&json!(true), &json!(false), &json!("test.list")));
assert!(formats["readable"].as_array().unwrap().contains(&json!("plugin.test.list")));
assert!(!formats["writable"].as_array().unwrap().contains(&json!("plugin.test.list")));
assert!(formats["openExtensions"].as_array().unwrap().contains(&json!("tsl2")));
assert!(fileio::open_extensions().contains(&"tsl"));
assert!(!fileio::export_extensions().contains(&"tsl"));
assert_eq!(fileio::format(".TSL2").map(|f| f.id), Some("plugin.test.list"), "by any of its extensions, in any case");
s.execute("plugin.remove", &json!({"id": "test.list"})).unwrap();
let formats = s.execute("document.formats", &json!({})).unwrap();
assert!(!formats["readable"].as_array().unwrap().contains(&json!("plugin.test.list")));
assert!(!formats["openExtensions"].as_array().unwrap().contains(&json!("tsl")));
assert!(fileio::format("tsl").is_none());
assert!(open(&mut s, "a.tsl", RECT.as_bytes(), json!({})).is_err());
assert!(s.documents().is_empty());
}
/// A file of an extension only the importer lists opens as a new document of editable art, its
/// artboard the art's bounds, with the importer's warnings; Save then asks for a name.
#[test]
fn a_file_an_importer_reads_opens_as_editable_art() {
let dir = tmp_dir("open");
let mut s = Session::new();
echo(&mut s, "test.open", r#"["tso"]"#);
let r = open(&mut s, "Shapes.tso", RECT.as_bytes(), json!({})).unwrap();
assert_eq!((&r["format"], &r["warnings"], &r["restored"]), (&json!("plugin.test.open"), &json!(["2 layers merged"]), &json!(false)));
assert_eq!(s.documents().len(), 1);
assert_eq!(objects(&s), 1);
assert_eq!(artboard(&s), (10.0, 20.0, 110.0, 70.0));
// Editable: the rectangle moves like any path.
s.execute("select.all", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
// A line's artboard is at least 1 pt high.
open(&mut s, "Line.tso", LINE.as_bytes(), json!({})).unwrap();
assert_eq!(artboard(&s), (0.0, 4.5, 50.0, 5.5));
// Art at the edge of the canvas keeps its artboard within ±4,000,000 pt, so the document can
// still be edited: a vertical line at x = 4,000,000 (its 1 pt moves inward) and a stroked line
// along y = -4,000,000 (its stroke passes the edge).
let edge = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[4000000,0]},{"p":[4000000,50]}]}]}}]}"#;
open(&mut s, "Edge.tso", edge.as_bytes(), json!({})).unwrap();
assert_eq!(artboard(&s), (3_999_999.0, 0.0, 4_000_000.0, 50.0));
s.execute("select.all", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": -5, "dy": 0})).unwrap();
let stroked = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,-4000000]},{"p":[50,-4000000]}]}]},"strokes":[{"paint":{"type":"solid","color":{"model":"rgb","r":0,"g":0,"b":0}},"width":10}]}]}"#;
open(&mut s, "Stroked.tso", stroked.as_bytes(), json!({})).unwrap();
let (x0, y0, x1, y1) = artboard(&s);
assert!(x0 < 0.0 && x1 > 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s));
s.execute("select.all", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap();
// From a path: the document keeps no path, so Save asks for a name.
let path = dir.join("Shapes.tso");
std::fs::write(&path, RECT).unwrap();
let r = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap();
assert_eq!(r["format"], "plugin.test.open");
assert_eq!(s.doc().unwrap().path, None);
assert!(s.doc().unwrap().doc.title.contains("Shapes"), "{}", s.doc().unwrap().doc.title);
let _ = std::fs::remove_dir_all(&dir);
}
/// A built-in format keeps its files: an importer that lists `svg` is given an `.svg` only when
/// `importer` names it, and a file with a built-in format's content opens with the built-in reader
/// whatever its extension.
#[test]
fn built_in_formats_keep_their_files() {
let mut s = Session::new();
install(&mut s, &tpl::returning(&tpl::importer("test.svgtoo", r#"["tsv","svg"]"#, "{}"), RECT));
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="50" height="40"><rect width="10" height="10"/></svg>"#;
assert_eq!(open(&mut s, "a.svg", svg, json!({})).unwrap()["format"], "svg");
// A damaged SVG reports the SVG reader's error; the importer isn't tried instead.
match open(&mut s, "b.svg", b"<svg not closed", json!({})) {
Ok(r) => assert_eq!(r["format"], "svg"),
Err(e) => assert!(!e.to_string().contains("importer"), "{e}"),
}
// Named, the importer opens an .svg.
let r = open(&mut s, "c.svg", svg, json!({"importer": "plugin.test.svgtoo"})).unwrap();
assert_eq!(r["format"], "plugin.test.svgtoo");
// An SVG named as the importer's file is still an SVG.
assert_eq!(open(&mut s, "d.tsv", svg, json!({})).unwrap()["format"], "svg");
assert_eq!(fileio::format("svg").map(|f| f.id), Some("svg"));
assert_eq!(fileio::open_extensions().iter().filter(|e| **e == "svg").count(), 1);
}
/// File › Open lists an importer's extensions only where it would open the file with the importer:
/// not those a built-in format reads, and not those it opens as a plug-in, a shortcut set, a
/// script, presets or a library. An importer with none of its own gets no filter.
#[test]
fn file_open_lists_only_the_extensions_it_opens_with_an_importer() {
let mut s = Session::new();
install(&mut s, &tpl::returning(&tpl::importer("test.claimed", r#"["tcl","ase","wasm","kys","jsx","vcstyles","svg"]"#, "{}"), RECT));
install(&mut s, &tpl::returning(&tpl::importer("test.claimedonly", r#"["ase","gpl"]"#, "{}"), RECT));
for ext in ["ase", "gpl", "wasm", "kys", "jsx", "vcstyles"] {
assert!(fileio::opens_as_other(ext), "{ext}");
assert!(!fileio::open_extensions().contains(&ext), "All readable files: {ext}");
}
assert!(!fileio::opens_as_other("tcl") && !fileio::opens_as_other("svg"));
assert!(fileio::open_extensions().contains(&"tcl"));
let filters: Vec<_> = fileio::open_filters().collect();
assert!(filters.contains(&("Test test.claimed", &["tcl"][..])), "{filters:?}");
assert!(!filters.iter().any(|(label, _)| *label == "Test test.claimedonly"), "{filters:?}");
s.execute("plugin.remove", &json!({"id": "test.claimed"})).unwrap();
s.execute("plugin.remove", &json!({"id": "test.claimedonly"})).unwrap();
}
/// Several importers list one extension: the one whose id sorts first opens the file, and
/// `importer` picks another (by its format id or its id).
#[test]
fn the_first_importer_by_id_opens_a_shared_extension() {
let mut s = Session::new();
install(&mut s, &tpl::returning(&tpl::importer("test.shared.b", r#"["tsh"]"#, "{}"), LINE));
install(&mut s, &tpl::returning(&tpl::importer("test.shared.a", r#"["tsh"]"#, "{}"), RECT));
assert_eq!(open(&mut s, "x.tsh", b"?", json!({})).unwrap()["format"], "plugin.test.shared.a");
assert_eq!(open(&mut s, "x.tsh", b"?", json!({"importer": "plugin.test.shared.b"})).unwrap()["format"], "plugin.test.shared.b");
assert_eq!(open(&mut s, "x.tsh", b"?", json!({"importer": "test.shared.b"})).unwrap()["format"], "plugin.test.shared.b");
}
#[test]
fn importer_names_that_do_not_fit_are_errors() {
let mut s = Session::new();
echo(&mut s, "test.names", r#"["tsn"]"#);
install(&mut s, &tpl::returning(&tpl::manifest("test.names.filter", "filter", "{}"), r#"{"objects":[]}"#));
install(&mut s, &tpl::returning(&tpl::exporter("test.names.exporter", r#"["tsx"]"#, "{}"), r#"{"dataBase64":""}"#));
let cases = [
(json!({"importer": "plugin.test.names"}), "x.png", "opens .tsn files, not `x.png`"),
(json!({"importer": "plugin.test.names.filter"}), "x.tsn", "is a plug-in of kind `filter`, not an importer"),
(json!({"importer": "plugin.test.names.exporter"}), "x.tsn", "is a plug-in of kind `exporter`, not an importer"),
(json!({"importer": "plugin.test.nothere"}), "x.tsn", "test.nothere"),
(json!({"importer": 3}), "x.tsn", "`importer` must be a plug-in format id"),
];
for (extra, name, why) in cases {
let e = open(&mut s, name, RECT.as_bytes(), extra.clone()).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{extra}: {e}");
assert!(e.to_string().contains(why), "{extra}: {e}");
}
assert!(s.documents().is_empty());
// Without an importer, a file nothing reads stays unsupported.
assert!(open(&mut s, "x.tsunknown", RECT.as_bytes(), json!({})).is_err());
// An importer is not a filter.
s.execute("file.new", &json!({})).unwrap();
let e = s.execute("plugin.run", &json!({"id": "test.names"})).unwrap_err();
assert!(e.to_string().contains("document.open"), "{e}");
}
/// The importer is given its parameters, checked against its manifest, and `_context` with the
/// file's extension and name.
#[test]
fn the_importer_is_given_its_parameters_and_the_file_s_name() {
let mut s = Session::new();
let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#;
let needles = [r#""mode":"import""#, r#""extension":"tsc""#, r#""name":"Shapes""#, r#""density":10.0"#];
for needle in needles {
// RECT when the parameters hold the needle; the file, which isn't JSON, otherwise.
install(&mut s, &tpl::when_given(&tpl::importer("test.params", r#"["tsc"]"#, params), Given::Params, needle, RECT));
let r = open(&mut s, "Shapes.tsc", b"not json", json!({"params": {"density": 12}}));
assert_eq!(r.map(|r| r["format"].clone()).map_err(|e| e.to_string()), Ok(json!("plugin.test.params")), "{needle}");
}
let before = s.documents().len();
let e = open(&mut s, "Shapes.tsc", b"", json!({"params": {"density": "lots"}})).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{e}");
assert_eq!(s.documents().len(), before);
}
#[test]
fn failures_open_nothing() {
let mut s = Session::new();
let far = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[9000000,0]}]}]}}]}"#;
let bad_warnings = LINE.replace("]}]}}]}", r#"]}]}}],"warnings":"no"}"#);
let cases = [
("test.fail.declines", r#"{"error":"Not a drawing."}"#, "Not a drawing."),
("test.fail.notjson", "not json", "JSON"),
("test.fail.empty", r#"{"objects":[]}"#, "the importer returned no objects"),
("test.fail.key", r#"{"objects":[],"layers":[]}"#, "an importer returns `objects` and `warnings`, not `layers`"),
("test.fail.id", r#"{"objects":[{"id":3}]}"#, "is not the id of an input object"),
("test.fail.far", far, "4000000"),
("test.fail.warnings", bad_warnings.as_str(), "`warnings` must be a list of strings"),
];
for (id, output, why) in cases {
install(&mut s, &tpl::returning(&tpl::importer(id, r#"["tsf"]"#, "{}"), output));
let e = open(&mut s, "x.tsf", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err();
assert!(e.to_string().contains(why), "{id}: {e}");
assert!(e.to_string().contains("x.tsf"), "{id}: names the file: {e}");
}
install(&mut s, &tpl::module(&tpl::importer("test.fail.traps", r#"["tsf"]"#, "{}"), "(unreachable)", "", 1));
assert!(open(&mut s, "x.tsf", b"?", json!({"importer": "plugin.test.fail.traps"})).is_err());
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:?}");
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}");
}
let token = vectorcraft_doc::cancel::CancelToken::new();
token.cancel();
let r = vectorcraft_doc::cancel::scope(&token, None, || open(&mut s, "x.tsf", b"?", json!({"importer": "plugin.test.fail.spins"})));
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
assert_eq!(state(&mut s), before, "cancelled");
}
/// Cancel stops an importer between two slices of its budget, and nothing opens: here one that
/// never returns, run with a token cancelled beforehand.
#[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));
let token = vectorcraft_doc::cancel::CancelToken::new();
token.cancel();
let r = vectorcraft_doc::cancel::scope(&token, None, || open(&mut s, "x.tss", b"?", json!({})));
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
assert!(s.documents().is_empty());
}
/// A file past the importer's input limit is not read past it, and doesn't open.
#[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 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 _ = std::fs::remove_dir_all(&dir);
}
/// A file past the importer's input limit whose start shows a built-in format opens as that format,
/// whatever its extension; named, the importer's limit applies to it.
#[test]
fn a_built_in_file_past_the_input_limit_still_opens() {
let dir = tmp_dir("largebuiltin");
let mut s = Session::new();
echo(&mut s, "test.largepng", r#"["tsp"]"#);
let path = dir.join("big.tsp");
let mut png = std::io::Cursor::new(vec![]);
image::RgbaImage::new(2, 2).write_to(&mut png, image::ImageFormat::Png).unwrap();
std::fs::write(&path, png.get_ref()).unwrap();
std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_len(vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 + 1).unwrap();
let e = s.execute("document.open", &json!({"path": path_of(&path), "importer": "plugin.test.largepng"})).unwrap_err();
assert!(e.to_string().contains("larger than"), "{e}");
assert!(s.documents().is_empty());
let r = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap();
assert_eq!(r["format"], "png");
let _ = std::fs::remove_dir_all(&dir);
}