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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-09-29 10:59:20 +07:00
parent 8ff27a629f
commit 0d6e7d3323
2 changed files with 219 additions and 0 deletions

View file

@ -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<String>,
#[serde(default)]
pub items: Vec<Value>,
}
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::<String>())),
}
}
if kind == "web" && !params.contains_key("url") {
return Err("VALIDATION: a web tab needs a URL".into());
}
Ok(())
}
fn validate_item(item: &Value) -> Result<String, String> {
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<Self, String> {
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<String> = 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());
}
}