From 3509d835357b6cef0dadec6ad79913564604273a Mon Sep 17 00:00:00 2001 From: CRT Date: Tue, 29 Sep 2026 10:59:20 +0700 Subject: [PATCH] host: saved workspaces validation Named tab/pane/deck layouts, bounded and well-formed (slug ids, 1-60 character names, known tab kinds, http(s) URLs, size caps), moved from the wayBetter for X app unchanged with its tests. --- crates/waybetter-host/src/lib.rs | 2 + crates/waybetter-host/src/workspaces.rs | 217 ++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 crates/waybetter-host/src/workspaces.rs diff --git a/crates/waybetter-host/src/lib.rs b/crates/waybetter-host/src/lib.rs index 20fcef4..5f0fdbb 100644 --- a/crates/waybetter-host/src/lib.rs +++ b/crates/waybetter-host/src/lib.rs @@ -17,6 +17,7 @@ //! - [`i18n`]: the strings native UI and site tabs get in the owner's language (validated data), OS locale. //! - [`backup`]: the settings backup envelope, secret scrubbing and the plugin/account sections. //! - [`sync`]: encrypted settings sync through a folder the owner syncs (Argon2id + XChaCha20-Poly1305). +//! - [`workspaces`]: saved tab/pane/deck layouts, bounded and well-formed (never evaluated). //! //! With the `tauri` feature, [`host`] adds the Tauri glue that does not depend on app state. @@ -31,6 +32,7 @@ pub mod site; pub mod sync; pub mod tabs; pub mod update; +pub mod workspaces; #[cfg(feature = "tauri")] pub mod host; diff --git a/crates/waybetter-host/src/workspaces.rs b/crates/waybetter-host/src/workspaces.rs new file mode 100644 index 0000000..9494f3d --- /dev/null +++ b/crates/waybetter-host/src/workspaces.rs @@ -0,0 +1,217 @@ +//! Saved workspaces (named tab/pane/deck layouts the shell builds), stored natively in app data +//! `workspaces.json` through the app's shell-only `workspaces_get` / `workspaces_set` commands. +//! +//! The shell builds and restores them; native code only keeps them bounded and well-formed: at most +//! [`MAX_WORKSPACES`], slug ids, names of 1–60 characters, each at most [`MAX_WORKSPACE_BYTES`], +//! at most [`MAX_TABS`] tabs, known tab kinds and http(s) URLs only, theme ids as slugs, and a +//! `default` that names one of them. Nothing here is ever evaluated. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub const FILE: &str = "workspaces.json"; +pub const MAX_WORKSPACES: usize = 20; +pub const MAX_TABS: usize = 64; +pub const MAX_WORKSPACE_BYTES: usize = 256 * 1024; +const MAX_NAME: usize = 60; +const KINDS: [&str; 4] = ["web", "panel", "start", "deck"]; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkspaceStore { + #[serde(default = "version")] + pub version: u32, + #[serde(default)] + pub default: Option, + #[serde(default)] + pub items: Vec, +} + +fn version() -> u32 { + 1 +} + +/// No workspaces yet (what `workspaces_get` returns before the first save). +impl Default for WorkspaceStore { + fn default() -> Self { + Self { version: version(), default: None, items: Vec::new() } + } +} + +fn slug(id: &str, max: usize) -> bool { + !id.is_empty() && id.len() <= max && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +fn tab_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +fn http_url(value: &str) -> bool { + value.len() <= 4096 && url::Url::parse(value).is_ok_and(|url| matches!(url.scheme(), "http" | "https")) +} + +fn validate_panel(id: &str, panel: &Value) -> Result<(), String> { + let panel = panel.as_object().ok_or("VALIDATION: a workspace tab must be an object")?; + if !tab_id(id) || panel.get("id").and_then(Value::as_str) != Some(id) { + return Err("VALIDATION: invalid workspace tab id".into()); + } + let params = panel.get("params").and_then(Value::as_object).ok_or("VALIDATION: a workspace tab needs params")?; + let kind = params.get("kind").and_then(Value::as_str).unwrap_or(""); + if !KINDS.contains(&kind) { + return Err("VALIDATION: unknown workspace tab kind".into()); + } + for (key, value) in params { + match (key.as_str(), value) { + ("kind", _) => {} + ("url", Value::String(url)) if http_url(url) => {} + ("pinned", Value::Bool(_)) => {} + ("title", Value::String(title)) if title.chars().count() <= 300 => {} + ("profile" | "section" | "deckId", Value::String(text)) if text.len() <= 64 => {} + _ => return Err(format!("VALIDATION: workspace tab field \"{}\" is not allowed", key.chars().take(32).collect::())), + } + } + if kind == "web" && !params.contains_key("url") { + return Err("VALIDATION: a web tab needs a URL".into()); + } + Ok(()) +} + +fn validate_item(item: &Value) -> Result { + let object = item.as_object().ok_or("VALIDATION: a workspace must be an object")?; + let id = object.get("id").and_then(Value::as_str).ok_or("VALIDATION: a workspace needs an id")?; + if !slug(id, 40) { + return Err("VALIDATION: workspace ids are lowercase slugs".into()); + } + let name = object.get("name").and_then(Value::as_str).unwrap_or(""); + if name.trim().is_empty() || name.chars().count() > MAX_NAME || name.chars().any(char::is_control) { + return Err("VALIDATION: workspace names are 1-60 characters".into()); + } + for key in object.keys() { + if !["id", "name", "savedAt", "layout", "paneThemes", "decks"].contains(&key.as_str()) { + return Err("VALIDATION: unknown workspace field".into()); + } + } + let layout = object.get("layout").and_then(Value::as_object).ok_or("VALIDATION: a workspace needs a layout")?; + if !layout.get("grid").is_some_and(Value::is_object) { + return Err("VALIDATION: a workspace layout needs a grid".into()); + } + let panels = layout.get("panels").and_then(Value::as_object).ok_or("VALIDATION: a workspace layout needs panels")?; + if panels.is_empty() || panels.len() > MAX_TABS { + return Err(format!("VALIDATION: a workspace holds 1-{MAX_TABS} tabs")); + } + for (id, panel) in panels { + validate_panel(id, panel)?; + } + let themes = object.get("paneThemes").cloned().unwrap_or(Value::Object(Map::new())); + let themes = themes.as_object().ok_or("VALIDATION: pane themes must be an object")?; + for (tab, theme) in themes { + if !panels.contains_key(tab) || !theme.as_str().is_some_and(|theme| slug(theme, 40)) { + return Err("VALIDATION: invalid pane theme in workspace".into()); + } + } + if object.get("decks").is_some_and(|decks| !decks.is_array()) { + return Err("VALIDATION: workspace decks must be a list".into()); + } + if serde_json::to_vec(item).map(|bytes| bytes.len()).unwrap_or(usize::MAX) > MAX_WORKSPACE_BYTES { + return Err("VALIDATION: workspace is too large".into()); + } + Ok(id.to_owned()) +} + +impl WorkspaceStore { + pub fn validated(self) -> Result { + if self.version != 1 { + return Err("VALIDATION: unknown workspaces version".into()); + } + if self.items.len() > MAX_WORKSPACES { + return Err(format!("VALIDATION: at most {MAX_WORKSPACES} workspaces")); + } + let mut ids: Vec = Vec::new(); + for item in &self.items { + let id = validate_item(item)?; + if ids.contains(&id) { + return Err("VALIDATION: workspace ids must be unique".into()); + } + ids.push(id); + } + if let Some(default) = &self.default { + if !ids.contains(default) { + return Err("VALIDATION: the default workspace does not exist".into()); + } + } + Ok(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn workspace(id: &str) -> Value { + json!({ + "id": id, "name": "Launch day", "savedAt": "2026-09-29T10:00:00.000Z", + "layout": { + "grid": { "root": { "type": "branch", "data": [{ "type": "leaf", "data": { "views": ["t1", "deck-d1"], "activeView": "t1", "id": "1" } }] } }, + "panels": { + "t1": { "id": "t1", "contentComponent": "web", "params": { "kind": "web", "url": "https://x.com/home", "profile": "work" } }, + "deck-d1": { "id": "deck-d1", "contentComponent": "deck", "params": { "kind": "deck", "deckId": "d1" } } + } + }, + "paneThemes": { "t1": "nord" }, + "decks": [{ "id": "d1", "name": "Deck", "columns": [] }] + }) + } + + #[test] + fn the_empty_store_round_trips() { + // What workspaces_get returns before the first save must be accepted by workspaces_set. + let empty = WorkspaceStore::default(); + assert_eq!(empty.clone().validated().unwrap(), empty); + let parsed: WorkspaceStore = serde_json::from_value(serde_json::to_value(&empty).unwrap()).unwrap(); + assert!(parsed.validated().is_ok()); + } + + #[test] + fn accepts_bounded_layouts_and_a_default_that_exists() { + let store = WorkspaceStore { version: 1, default: Some("launch".into()), items: vec![workspace("launch"), workspace("night")] }; + assert!(store.clone().validated().is_ok()); + let missing = WorkspaceStore { default: Some("gone".into()), ..store.clone() }; + assert!(missing.validated().is_err()); + let twice = WorkspaceStore { default: None, items: vec![workspace("a"), workspace("a")], ..store }; + assert!(twice.validated().is_err()); + } + + #[test] + fn rejects_code_urls_unknown_fields_and_oversized_input() { + let with = |path: &[&str], value: Value| { + let mut item = workspace("a"); + let mut target = &mut item; + for key in &path[..path.len() - 1] { + target = target.get_mut(*key).unwrap(); + } + target[path[path.len() - 1]] = value; + WorkspaceStore { version: 1, default: None, items: vec![item] }.validated() + }; + assert!(with(&["layout", "panels", "t1", "params", "url"], json!("javascript:alert(1)")).is_err()); + assert!(with(&["layout", "panels", "t1", "params", "url"], json!("file:///C:/secret")).is_err()); + assert!(with(&["layout", "panels", "t1", "params", "cookie"], json!("sid=1")).is_err()); + assert!(with(&["layout", "panels", "t1", "params", "kind"], json!("script")).is_err()); + assert!(with(&["layout", "panels", "t1", "id"], json!("other")).is_err()); + assert!(with(&["paneThemes", "t1"], json!("Not A Slug")).is_err()); + assert!(with(&["paneThemes"], json!({ "t9": "nord" })).is_err(), "theme for a tab that is not in the layout"); + assert!(with(&["name"], json!("")).is_err()); + assert!(with(&["name"], json!("x".repeat(61))).is_err()); + assert!(with(&["extra"], json!(1)).is_err()); + assert!(with(&["decks"], json!([{ "notes": "y".repeat(MAX_WORKSPACE_BYTES) }])).is_err()); + let many = WorkspaceStore { version: 1, default: None, items: (0..=MAX_WORKSPACES).map(|n| workspace(&format!("w{n}"))).collect() }; + assert!(many.validated().is_err()); + let mut tabs = workspace("a"); + let panels = tabs["layout"]["panels"].as_object_mut().unwrap(); + for n in 0..MAX_TABS { + let id = format!("x{n}"); + panels.insert(id.clone(), json!({ "id": id, "params": { "kind": "start" } })); + } + assert!(WorkspaceStore { version: 1, default: None, items: vec![tabs] }.validated().is_err()); + } +}