//! 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()); } }