//! Plugin API 4: what it adds to widget descriptions and panels is validated and capped, read as //! before by plugins written for API 1–3 (ignored where they ignored it, refused where they //! refused it), `widget_timer` is bounded and needs API 4, and junk (rows of it, mutations of a //! good description, random bytes) is refused without a panic. use serde_json::{Value, json}; use crate::widget::{MAX_MENU_ITEMS, MAX_PATH_BYTES, MAX_VECTORS, MenuKind, Sides}; use crate::{Host, Instance, Limits, Manifest, Panel, ParamKind, WidgetKind, WidgetRender}; struct Quiet; impl Host for Quiet { fn call(&mut self, _command: &str, _params: &str) -> Result { Ok("{}".into()) } fn selection(&mut self) -> String { "[]".into() } fn notify(&mut self, _message: &str) {} } fn widget(v: &Value, api: u32) -> Result { WidgetRender::parse(&v.to_string(), api) } /// A description using everything API 4 added. fn rich() -> Value { json!({"state": {"n": 1}, "menu": [ {"type": "action", "name": "reset", "label": " Reset "}, {"type": "toggle", "name": "pinned"}, {"type": "dropdown", "name": "size", "options": ["S", "M"], "value": "XL"}, {"type": "color", "name": "tint", "options": ["#f00", "00ff00"], "value": "#00FF00"}, {"type": "separator"}, {"type": "link", "name": "docs", "label": "Docs", "url": "https://example.org/a?b=c"} ], "root": {"type": "frame", "layout": "vertical", "width": 200, "height": 300, "padding": [1, 2, 3, 4], "radius": [4, 3, 2, 1], "gradient": {"type": "radial", "stops": [{"at": 0, "color": "#fff"}, {"at": 1, "color": "#000000"}]}, "shadow": {"y": 2, "blur": 8, "color": "#0004"}, "hover": {"opacity": 0.5}, "tooltip": " A card ", "children": [ {"type": "text", "text": "Title", "input": "title", "maxLength": 20, "weight": 600, "textAlign": "right", "maxLines": 1, "width": 80, "hover": {"fill": "#f00", "stroke": "#00f"}}, {"type": "text", "text": "Notes", "input": "notes", "multiline": true}, {"type": "line", "width": 40, "stroke": "#ccc", "fillWidth": true}, {"type": "vector", "path": "M0 0 L10 0 L5 8 Z", "fill": "#111", "fillHeight": true}, {"type": "image", "data": "AAAA", "fit": "crop"} ]}}) } #[test] fn what_api_4_added_is_checked_and_normalised() { let w = widget(&rich(), 4).unwrap(); let menu = w.menu.unwrap(); assert_eq!(menu.len(), 6); assert_eq!((menu[0].title(), menu[1].title(), menu[1].value.clone()), ("Reset", "pinned", Some(json!(false)))); assert_eq!(menu[2].value, Some(json!("S")), "a value that isn't an option: the first"); assert_eq!((menu[3].options.clone(), menu[3].value.clone()), (vec!["#FF0000".to_string(), "#00FF00".into()], Some(json!("#00FF00")))); assert_eq!((menu[4].kind, menu[5].kind), (MenuKind::Separator, MenuKind::Link)); let root = &w.root; assert_eq!((root.padding, root.radius), (Some(Sides::Each([1.0, 2.0, 3.0, 4.0])), Some(Sides::Each([4.0, 3.0, 2.0, 1.0])))); assert_eq!(root.gradient.as_ref().unwrap().stops[0].color, "#FFFFFF"); assert_eq!((root.shadow.as_ref().unwrap().color.as_deref(), root.tooltip.as_str()), (Some("#00000044"), "A card")); assert_eq!(root.children[0].hover.as_ref().unwrap().fill.as_deref(), Some("#FF0000")); assert_eq!((root.children[2].kind, root.children[3].kind), (WidgetKind::Line, WidgetKind::Vector)); // Menu values: an action has none, a toggle flips or takes a boolean, the rest one option. assert_eq!(menu[0].coerce(Some(&json!(5))), Ok(Value::Null)); assert_eq!((menu[1].coerce(None), menu[1].coerce(Some(&json!(false)))), (Ok(json!(true)), Ok(json!(false)))); assert!(menu[1].coerce(Some(&json!("on"))).is_err()); assert_eq!((menu[2].coerce(Some(&json!("M"))), menu[3].coerce(Some(&json!("#ff0000")))), (Ok(json!("M")), Ok(json!("#FF0000")))); assert!( menu[2].coerce(Some(&json!("m"))).is_err() && menu[3].coerce(None).is_err() && menu[4].coerce(None).is_err() && menu[5].coerce(None).is_err() ); } #[test] fn older_apis_read_descriptions_as_they_always_did() { // Fields API 3 didn't know are dropped (it ignored them); kinds and shapes it refused are refused. let mut old = rich(); old["root"]["padding"] = json!(4); old["root"]["radius"] = json!(2); old["root"]["children"].as_array_mut().unwrap().retain(|c| c["type"] != "line" && c["type"] != "vector"); let w = widget(&old, 3).unwrap(); assert!(w.menu.is_none()); let plain = widget(&json!({"state": {"n": 1}, "root": {"type": "frame", "layout": "vertical", "width": 200, "height": 300, "padding": 4, "radius": 2, "children": [ {"type": "text", "text": "Title", "width": 80}, {"type": "text", "text": "Notes"}, {"type": "image", "data": "AAAA"}]}}), 3) .unwrap(); assert_eq!(w, plain, "nothing API 4 added is left"); for api in 1..=3 { assert!(widget(&rich(), api).unwrap_err().contains("plugin API 4"), "four paddings"); assert!( widget(&json!({"root": {"type": "frame", "children": [{"type": "line"}]}}), api).unwrap_err().contains("a line layer needs plugin API 4") ); assert!(widget(&json!({"root": {"type": "frame", "children": [{"type": "vector", "path": "M0 0"}]}}), api).is_err()); // Junk in a field that's ignored is ignored too, as an unknown field was. assert!(widget(&json!({"root": {"type": "frame", "tooltip": "x".repeat(5000), "input": "a b"}, "menu": [{"type": "link"}]}), api).is_ok()); } // Panels: the controls API 4 added are refused, its fields on old controls dropped. let spec = json!({"controls": [{"type": "number", "name": "n", "step": 5, "disabled": true, "error": "no", "value": 3}]}).to_string(); let (old, new) = (Panel::parse(&spec, 2).unwrap(), Panel::parse(&spec, 4).unwrap()); assert_eq!((old.controls[0].step, old.controls[0].disabled, old.controls[0].error.as_str()), (None, false, "")); assert_eq!( (new.controls[0].step, new.controls[0].disabled, new.controls[0].error.as_str(), new.controls[0].default.clone()), (Some(5.0), true, "no", Some(json!(3.0))) ); for kind in ["slider", "image", "progress", "divider", "section"] { let spec = json!({"controls": [{"type": kind, "name": "x", "data": "AAAA"}]}).to_string(); assert!(Panel::parse(&spec, 3).unwrap_err().contains("needs plugin API 4"), "{kind}"); } assert!(Panel::parse(&format!(r#"{{"controls": [], "title": "{}"}}"#, " ".repeat(70_000)), 3).unwrap_err().contains("64 KB")); assert!(Panel::parse(&format!(r#"{{"controls": [], "title": "{}"}}"#, " ".repeat(70_000)), 4).is_ok(), "API 4 panels may carry images"); // Manifests: a slider parameter needs API 4; display controls never go in a manifest. let manifest = |api: u32, kind: &str| { Manifest::from_value( &json!({"id": "p", "name": "P", "main": "p.wasm", "api": api, "commands": [{"id": "c", "label": "C", "params": [{"name": "x", "type": kind}]}]}), ) }; assert!(manifest(3, "slider").unwrap_err().contains("needs plugin API 4")); assert_eq!(manifest(4, "slider").unwrap().commands[0].params[0].default_value(), json!(0.0)); assert!(manifest(4, "divider").unwrap_err().contains("only go in panels")); assert!(manifest(5, "text").is_err(), "a newer API than this build's is refused"); } #[test] fn api_4_panel_controls_are_checked() { let p = Panel::parse( &json!({"controls": [ {"type": "slider", "name": "a", "min": 1, "max": 2, "step": 0.25, "value": 1.6}, {"type": "slider", "name": "b", "value": 250}, {"type": "image", "label": "Pic", "data": "AAAA", "height": 64, "fit": "fill"}, {"type": "progress", "value": 7}, {"type": "progress", "value": "x"}, {"type": "divider"}, {"type": "section", "label": "More"}, {"type": "section", "label": "Shut", "value": false} ]}) .to_string(), 4, ) .unwrap(); let c = &p.controls; assert_eq!((c[0].default.clone(), c[1].default.clone(), c[1].max), (Some(json!(1.5)), Some(json!(100.0)), Some(100.0))); assert_eq!((c[0].coerce(&json!(1.13)), c[0].coerce(&json!(-4)), c[0].coerce(&json!("1.9"))), (Ok(json!(1.25)), Ok(json!(1.0)), Ok(json!(2.0)))); assert_eq!( (c[3].default.clone(), c[4].default.clone(), c[6].default.clone(), c[7].default.clone()), (Some(json!(1.0)), Some(json!(0.0)), None, Some(json!(false))) ); assert!(c.iter().filter(|c| c.is_field()).map(|c| c.name.as_str()).eq(["a", "b"]), "only sliders hold a value here"); assert_eq!(c[2].kind, ParamKind::Image); assert_eq!(p.values(&serde_json::Map::new()).len(), 2); let image = "A".repeat(400_000); for (controls, why) in [ (json!([{"type": "slider", "name": "s", "step": 0}]), "bad step"), (json!([{"type": "slider", "name": "s", "step": -1}]), "bad step"), (json!([{"type": "slider", "name": "s", "min": 5, "max": 1}]), "bad bounds"), (json!([{"type": "slider"}]), "parameter name"), (json!([{"type": "image", "label": "No data"}]), "`data`"), (json!([{"type": "button", "name": "b", "data": "AAAA"}]), "`data`"), (json!([{"type": "image", "data": "AAAA", "height": 8}]), "high"), (json!([{"type": "image", "data": "AAAA", "fit": "stretch"}]), "unknown variant"), (json!([{"type": "image", "data": image}, {"type": "image", "data": image}]), "larger than 512 KB together"), (json!([{"type": "text", "name": "t", "error": "x".repeat(201)}]), "longer than"), (json!([{"type": "text", "name": "t", "error": "a\u{7}"}]), "control characters"), ] { let e = Panel::parse(&json!({ "controls": controls }).to_string(), 4).unwrap_err(); assert!(e.contains(why), "{e}"); } // A step can't push a value outside its bounds or to infinity. let odd = Panel::parse(&json!({"controls": [{"type": "slider", "name": "s", "min": 0, "max": 10, "step": 7, "value": 9}, {"type": "number", "name": "n", "min": -1e308, "step": 1e-300, "value": 1e308}]}).to_string(), 4).unwrap(); assert_eq!(odd.controls[0].default, Some(json!(7.0))); assert!(odd.controls[1].default.as_ref().and_then(Value::as_f64).is_some_and(f64::is_finite)); } #[test] fn junk_in_what_api_4_added_is_refused() { let child = |c: Value| json!({"root": {"type": "frame", "layout": "vertical", "children": [c]}}); let root = |mut extra: Value| { extra["type"] = json!("frame"); json!({ "root": extra }) }; let menu = |m: Value| json!({"root": {"type": "frame"}, "menu": m}); let stops = |n: usize| (0..n).map(|i| json!({"at": i as f64 / n.max(2) as f64, "color": "#000"})).collect::>(); let junk = [ root(json!({"padding": [1, 2, 3]})), root(json!({"padding": [1, 2, 3, -4]})), root(json!({"radius": [1, 2, 3, 1e9]})), root(json!({"radius": "8"})), root(json!({"gradient": {"stops": stops(1)}})), root(json!({"gradient": {"stops": stops(9)}})), root(json!({"gradient": {"type": "conic", "stops": stops(2)}})), root(json!({"gradient": {"angle": 720, "stops": stops(2)}})), root(json!({"gradient": {"stops": [{"at": 2, "color": "#000"}, {"at": 0, "color": "#000"}]}})), root(json!({"gradient": {"stops": [{"at": 0, "color": "red"}, {"at": 1, "color": "#000"}]}})), root(json!({"shadow": {"blur": -1}})), root(json!({"shadow": {"x": 1e9}})), root(json!({"shadow": {"color": "shadow"}})), root(json!({"hover": {"fill": "nope"}})), root(json!({"hover": {"opacity": 3}})), root(json!({"tooltip": "x".repeat(201)})), root(json!({"tooltip": "a\nb"})), root(json!({"input": "title"})), root(json!({"path": "M0 0"})), root(json!({"weight": 400})), root(json!({"fit": "fill"})), // Nothing can fill a frame that hugs it, one without auto layout, or the canvas. child(json!({"type": "rectangle", "fillWidth": true})), child(json!({"type": "frame", "layout": "horizontal", "width": 50, "children": [{"type": "rectangle", "fillHeight": true}]})), child(json!({"type": "frame", "width": 50, "height": 50, "children": [{"type": "rectangle", "fillWidth": true}]})), root(json!({"layout": "vertical", "width": 100, "fillWidth": true})), child(json!({"type": "text", "input": "a b"})), child(json!({"type": "text", "input": "t", "maxLength": 0})), child(json!({"type": "text", "input": "t", "maxLength": 1001})), child(json!({"type": "rectangle", "maxLength": 5})), child(json!({"type": "rectangle", "multiline": true})), child(json!({"type": "text", "weight": 50})), child(json!({"type": "text", "weight": 1000})), child(json!({"type": "text", "textAlign": "justify"})), child(json!({"type": "text", "maxLines": 0})), child(json!({"type": "text", "gradient": {"stops": stops(2)}})), child(json!({"type": "image", "data": "AAAA", "fit": "stretch"})), child(json!({"type": "image", "data": "AAAA", "gradient": {"stops": stops(2)}})), child(json!({"type": "vector"})), child(json!({"type": "vector", "path": " "})), child(json!({"type": "vector", "path": "M0 0 L1 1