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.
This commit is contained in:
parent
59e7bffeb9
commit
3509d83535
2 changed files with 219 additions and 0 deletions
|
|
@ -17,6 +17,7 @@
|
||||||
//! - [`i18n`]: the strings native UI and site tabs get in the owner's language (validated data), OS locale.
|
//! - [`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.
|
//! - [`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).
|
//! - [`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.
|
//! 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 sync;
|
||||||
pub mod tabs;
|
pub mod tabs;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
pub mod workspaces;
|
||||||
|
|
||||||
#[cfg(feature = "tauri")]
|
#[cfg(feature = "tauri")]
|
||||||
pub mod host;
|
pub mod host;
|
||||||
|
|
|
||||||
217
crates/waybetter-host/src/workspaces.rs
Normal file
217
crates/waybetter-host/src/workspaces.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue