//! The pure half of distribution: install links and the download allow-list, theme files, the //! signed multi-app store catalog, install records and version order. Headless and tested here. use crate::plugins::{self, valid_file_name, ValidatedPlugin, MAX_PLUGIN_BYTES}; use base64::Engine; use ed25519_dalek::{Signature, VerifyingKey}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; use url::Url; /// Where the shared wayBetter Store lives. The catalog is `catalog.json` + `catalog.json.sig` under it. pub const DEFAULT_STORE_BASE: &str = "https://waybetter.fyi/store/"; /// The framework's own download hosts: the site, its CDN, and raw files on its code host. pub const DEFAULT_HOSTS: &[AllowedHost] = &[ AllowedHost { host: "waybetter.fyi", raw_files_only: false }, AllowedHost { host: "cdn1.crt.fyi", raw_files_only: false }, AllowedHost { host: "git.crt.fyi", raw_files_only: true }, ]; /// The catalog format this code reads. Format 2: every item names the apps it is for (`apps`). pub const CATALOG_FORMAT: u32 = 2; pub const MAX_THEME_BYTES: usize = 2 * 1024 * 1024; pub const MAX_CATALOG_BYTES: usize = 1024 * 1024; pub const MAX_SIGNATURE_BYTES: usize = 512; pub const MAX_LINK_CHARS: usize = 2048; const MAX_ITEMS: usize = 500; const MAX_ITEM_APPS: usize = 16; /// A host install links and store items may point at. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AllowedHost { pub host: &'static str, /// A code host (Gitea/Forgejo): only raw file paths, `///raw/...`. pub raw_files_only: bool, } /// What an app tells the framework about its store. Everything else is the framework's. #[derive(Clone, Copy, Debug)] pub struct StoreConfig { /// The app's id in the shared catalog (`apps: ["x"]`): lowercase letters, digits and `-`. pub app_id: &'static str, /// Log prefix, e.g. `wayBetter for X`. pub product: &'static str, /// HTTP user agent for store downloads, e.g. `wayBetter-for-X/1.1.0`. pub user_agent: &'static str, /// The URL scheme of install links (`waybetter` → `waybetter://install?...`). pub scheme: &'static str, /// The store folder URL, ending in `/` (usually [`DEFAULT_STORE_BASE`]). pub store_base: &'static str, /// Store public keys (base64 of the raw 32-byte Ed25519 key), compiled into the app. Any of /// them may sign the catalog, so a new key can ship before the catalog is re-signed with it. pub public_keys: &'static [&'static str], /// Hosts links and store items may point at (usually [`DEFAULT_HOSTS`]). pub allowed_hosts: &'static [AllowedHost], } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Kind { Theme, Plugin, /// A string pack: data that replaces the site's own UI strings (`store::strings`). Strings, } impl Kind { pub fn parse(value: &str) -> Result { match value { "theme" => Ok(Kind::Theme), "plugin" => Ok(Kind::Plugin), "strings" => Ok(Kind::Strings), _ => Err("VALIDATION: kind must be \"theme\", \"plugin\" or \"strings\"".into()), } } pub fn as_str(self) -> &'static str { match self { Kind::Theme => "theme", Kind::Plugin => "plugin", Kind::Strings => "strings", } } pub fn max_bytes(self) -> usize { match self { Kind::Theme => MAX_THEME_BYTES, Kind::Plugin => MAX_PLUGIN_BYTES as usize, Kind::Strings => super::strings::MAX_STRINGS_BYTES, } } } pub fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect() } /// A valid app id: 1–32 lowercase ASCII letters, digits or `-`. pub fn valid_app_id(id: &str) -> bool { !id.is_empty() && id.len() <= 32 && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') } /// An https URL on an allowed host that native code may download from: default port, no /// credentials, no fragment; on a raw-files-only host only `///raw/...` paths. pub fn allowed_download_url(hosts: &[AllowedHost], url: &Url) -> bool { if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() || url.port().is_some() || url.fragment().is_some() { return false; } if url.as_str().len() > MAX_LINK_CHARS { return false; } let Some(host) = url.host_str() else { return false }; let Some(rule) = hosts.iter().find(|allowed| allowed.host == host) else { return false }; if rule.raw_files_only { let segments: Vec<&str> = url.path_segments().map(|s| s.collect()).unwrap_or_default(); return segments.len() >= 4 && segments[2] == "raw" && segments.iter().all(|s| !s.is_empty() && *s != ".."); } true } fn hosts_text(hosts: &[AllowedHost]) -> String { let names: Vec<&str> = hosts.iter().map(|h| h.host).collect(); let raw: Vec<&str> = hosts.iter().filter(|h| h.raw_files_only).map(|h| h.host).collect(); if raw.is_empty() { names.join(", ") } else { format!("{} ({}: raw files)", names.join(", "), raw.join(", ")) } } /// What a `waybetter://install` link asks for. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct InstallLink { pub kind: Kind, pub url: String, } /// Parses `://install?kind=theme|plugin&url=`. Anything else (another host or /// path, unknown or repeated parameters, a URL that is not allowed) is refused. pub fn parse_install_link(config: &StoreConfig, link: &str) -> Result { if link.len() > MAX_LINK_CHARS { return Err("LINK: too long".into()); } let parsed = Url::parse(link.trim()).map_err(|_| "LINK: not a URL")?; if parsed.scheme() != config.scheme { return Err(format!("LINK: not a {}:// link", config.scheme)); } // `waybetter://install?...` (host form) and `waybetter:install?...` (path form) both occur. let action = parsed.host_str().map(str::to_owned).unwrap_or_else(|| parsed.path().to_owned()); let action = action.trim_matches('/'); if action != "install" || !matches!(parsed.path(), "" | "/" | "install") { return Err(format!("LINK: only {}://install links are supported", config.scheme)); } let mut kind = None; let mut url = None; for (key, value) in parsed.query_pairs() { let slot = match key.as_ref() { "kind" => &mut kind, "url" => &mut url, _ => return Err("LINK: unknown parameter".into()), }; if slot.replace(value.into_owned()).is_some() { return Err("LINK: repeated parameter".into()); } } let kind = Kind::parse(kind.as_deref().ok_or("LINK: missing kind")?).map_err(|_| "LINK: kind must be theme or plugin")?; let target = Url::parse(url.as_deref().ok_or("LINK: missing url")?).map_err(|_| "LINK: url is not a URL")?; if !allowed_download_url(config.allowed_hosts, &target) { return Err(format!("LINK: only https links on {} can be installed", hosts_text(config.allowed_hosts))); } if kind == Kind::Plugin { plugin_file_from_url(&target)?; } Ok(InstallLink { kind, url: target.to_string() }) } /// The install link for `kind` and `url` (for sites and tests; parsing it applies every rule). pub fn install_link(config: &StoreConfig, kind: Kind, url: &str) -> String { format!("{}://install?kind={}&url={}", config.scheme, kind.as_str(), url::form_urlencoded::byte_serialize(url.as_bytes()).collect::()) } /// The file name a plugin downloaded from `url` is saved under: its last path segment. pub fn plugin_file_from_url(url: &Url) -> Result { let segment = url.path_segments().and_then(|mut s| s.next_back()).unwrap_or_default(); let decoded = percent_decode(segment).ok_or("LINK: invalid file name")?; if !valid_file_name(&decoded) { return Err("LINK: a plugin link must end in a file named like name.plugin.js".into()); } Ok(decoded) } fn percent_decode(value: &str) -> Option { let bytes = value.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' { let hex = std::str::from_utf8(bytes.get(i + 1..i + 3)?).ok()?; out.push(u8::from_str_radix(hex, 16).ok()?); i += 3; } else { out.push(bytes[i]); i += 1; } } String::from_utf8(out).ok() } /// What the confirmation screen shows about a theme file (the shell parses the rest). #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemeInfo { pub name: String, pub author: String, pub description: String, /// Characters of theme CSS (the one part with custom-CSS trust). pub css_chars: usize, } fn short(value: Option<&serde_json::Value>, max: usize) -> String { value.and_then(serde_json::Value::as_str).unwrap_or_default().chars().filter(|ch| !ch.is_control()).take(max).collect() } /// A theme file: at most [`MAX_THEME_BYTES`], UTF-8 JSON, an object with a non-empty `name`. pub fn validate_theme(bytes: &[u8]) -> Result { if bytes.is_empty() { return Err("VALIDATION: the theme file is empty".into()); } if bytes.len() > MAX_THEME_BYTES { return Err("VALIDATION: themes are limited to 2 MiB".into()); } let text = std::str::from_utf8(bytes).map_err(|_| "VALIDATION: a theme must be UTF-8 JSON")?; let value: serde_json::Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|_| "VALIDATION: this file is not valid JSON")?; let object = value.as_object().ok_or("VALIDATION: a theme is a JSON object")?; let name = short(object.get("name"), 60); if name.trim().is_empty() { return Err("VALIDATION: a theme needs a \"name\"".into()); } Ok(ThemeInfo { name, author: short(object.get("author"), 60), description: short(object.get("description"), 200), css_chars: object.get("css").and_then(serde_json::Value::as_str).map(|css| css.chars().count()).unwrap_or(0), }) } /// Validates a plugin for installation (see `plugins::validate_plugin`). pub fn validate_plugin(file: &str, bytes: &[u8]) -> Result { plugins::validate_plugin(file, bytes) } // --------------------------------------------------------------------------------------------- // Store catalog #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Swatch { pub bg: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub surface: Option, pub text: String, pub accent: String, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CatalogItem { /// The wayBetter apps this item is for (app ids, e.g. `["x"]`); never empty. pub apps: Vec, pub kind: Kind, pub id: String, pub name: String, #[serde(default)] pub author: String, pub version: String, #[serde(default)] pub description: String, #[serde(default)] pub category: String, #[serde(default)] pub tags: Vec, pub url: String, /// Plugins: the file name it is installed under. #[serde(default, skip_serializing_if = "Option::is_none")] pub file: Option, pub sha256: String, pub size: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub preview: Option, /// Plugins: declared permissions (`None` = legacy, no `@permissions` line). #[serde(default)] pub permissions: Option>, /// Themes: a few colors for swatches. #[serde(default, skip_serializing_if = "Option::is_none")] pub colors: Option, /// String packs: the language tag of the pack. #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, /// String packs: the licence of the text (an SPDX id such as `CC-BY-4.0`, or a short name). #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option, /// String packs: who translated and reviewed it, plain text. #[serde(default, skip_serializing_if = "Option::is_none")] pub credits: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Catalog { pub format: u32, pub name: String, pub generated_at: String, pub items: Vec, } impl Catalog { /// The items for `app` only (what that app shows, stages and installs). pub fn for_app(mut self, app: &str) -> Catalog { self.items.retain(|item| item.is_for(app)); self } } fn valid_id(id: &str) -> bool { !id.is_empty() && id.len() <= 40 && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') } fn valid_text(value: &str, max: usize) -> bool { value.chars().count() <= max && !value.chars().any(char::is_control) } fn hex_color(value: &str) -> bool { value.len() == 7 && value.starts_with('#') && value[1..].bytes().all(|b| b.is_ascii_hexdigit()) } impl CatalogItem { pub fn is_for(&self, app: &str) -> bool { self.apps.iter().any(|id| id == app) } /// Shape rules every item must follow, whichever app it is for; the download hosts are /// checked against `hosts` (the reading app's allow-list) for that app's own items. fn check(&self, hosts: Option<&[AllowedHost]>) -> Result<(), String> { let bad = |what: &str| Err(format!("STORE: item {}: invalid {what}", self.id.chars().take(40).collect::())); if !valid_id(&self.id) { return bad("id"); } let mut apps = HashSet::new(); if self.apps.is_empty() || self.apps.len() > MAX_ITEM_APPS || !self.apps.iter().all(|app| valid_app_id(app) && apps.insert(app.as_str())) { return bad("apps"); } if self.name.trim().is_empty() || !valid_text(&self.name, 60) || !valid_text(&self.author, 60) || !valid_text(&self.description, 600) { return bad("name, author or description"); } if self.version.is_empty() || !valid_text(&self.version, 32) || !valid_text(&self.category, 32) { return bad("version or category"); } if self.tags.len() > 12 || self.tags.iter().any(|tag| !valid_text(tag, 32)) { return bad("tags"); } let url = Url::parse(&self.url).map_err(|_| format!("STORE: item {}: invalid url", self.id))?; if url.scheme() != "https" || url.as_str().len() > MAX_LINK_CHARS || hosts.is_some_and(|hosts| !allowed_download_url(hosts, &url)) { return bad("url (host not allowed)"); } if let Some(preview) = &self.preview { let ok = Url::parse(preview).is_ok_and(|url| url.scheme() == "https" && hosts.is_none_or(|hosts| allowed_download_url(hosts, &url))); if !ok { return bad("preview"); } } if self.sha256.len() != 64 || !self.sha256.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { return bad("sha256"); } if self.size == 0 || self.size > self.kind.max_bytes() as u64 { return bad("size"); } // Any item may name its licence and credit its authors; only a string pack has a language. if !self.license.as_deref().is_none_or(|v| !v.trim().is_empty() && valid_text(v, 60)) || !self.credits.as_deref().is_none_or(|v| valid_text(v, 600)) { return bad("license or credits"); } if self.kind != Kind::Strings && self.language.is_some() { return bad("language"); } match self.kind { Kind::Plugin => { if !self.file.as_deref().is_some_and(valid_file_name) || self.colors.is_some() { return bad("plugin file"); } if let Some(list) = &self.permissions { let joined = list.join(","); if plugins::parse_permissions(&joined).map(|parsed| parsed.len() != list.len()).unwrap_or(true) { return bad("permissions"); } } } Kind::Strings => { if self.file.is_some() || self.permissions.is_some() || self.colors.is_some() { return bad("string pack fields"); } if !self.language.as_deref().is_some_and(super::strings::valid_language) { return bad("language"); } } Kind::Theme => { if self.file.is_some() || self.permissions.is_some() { return bad("theme fields"); } if let Some(colors) = &self.colors { let ok = [Some(&colors.bg), colors.surface.as_ref(), Some(&colors.text), Some(&colors.accent)].into_iter().flatten().all(|c| hex_color(c)); if !ok { return bad("colors"); } } } } Ok(()) } } /// Decodes a store public key (base64 of the raw 32 bytes). pub fn decode_key(text: &str) -> Result { let bytes = base64::engine::general_purpose::STANDARD.decode(text.trim()).map_err(|_| "STORE: invalid public key")?; let bytes: [u8; 32] = bytes.try_into().map_err(|_| "STORE: invalid public key")?; VerifyingKey::from_bytes(&bytes).map_err(|_| "STORE: invalid public key".into()) } /// Verifies the detached signature (base64 of 64 bytes) over the exact catalog bytes with any of /// the app's public keys, then parses and checks the whole catalog, and returns the items for /// `config.app_id` only ([`Catalog::for_app`]). Nothing in an unverified catalog is looked at; a /// malformed item for another app still refuses the catalog (one signed file, one set of rules). pub fn verify_catalog(config: &StoreConfig, body: &[u8], signature: &str) -> Result { if body.len() > MAX_CATALOG_BYTES { return Err("STORE: catalog too large".into()); } if signature.len() > MAX_SIGNATURE_BYTES { return Err("STORE: signature too large".into()); } let raw = base64::engine::general_purpose::STANDARD.decode(signature.trim()).map_err(|_| "STORE: invalid signature")?; let raw: [u8; 64] = raw.try_into().map_err(|_| "STORE: invalid signature")?; let signature = Signature::from_bytes(&raw); let verified = config .public_keys .iter() .filter_map(|key| decode_key(key).ok()) .any(|key| key.verify_strict(body, &signature).is_ok()); if !verified { return Err("STORE: the catalog signature does not verify".into()); } let catalog: Catalog = serde_json::from_slice(body).map_err(|error| format!("STORE: catalog schema: {error}"))?; if catalog.format != CATALOG_FORMAT { return Err("STORE: unsupported catalog format (update the app)".into()); } if catalog.items.len() > MAX_ITEMS { return Err("STORE: too many items".into()); } let mut ids = HashSet::new(); let mut files = HashSet::new(); for item in &catalog.items { item.check(item.is_for(config.app_id).then_some(config.allowed_hosts))?; for app in &item.apps { if !ids.insert((app.clone(), item.kind, item.id.clone())) { return Err(format!("STORE: duplicate item {}", item.id)); } if let Some(file) = &item.file { if !files.insert((app.clone(), file.to_ascii_lowercase())) { return Err(format!("STORE: duplicate plugin file {file}")); } } } } Ok(catalog.for_app(config.app_id)) } /// A downloaded item must be exactly what the signed catalog describes. pub fn verify_item(item: &CatalogItem, bytes: &[u8]) -> Result<(), String> { if bytes.len() as u64 != item.size { return Err("STORE: the download's size does not match the signed catalog".into()); } if sha256_hex(bytes) != item.sha256 { return Err("STORE: the download's sha256 does not match the signed catalog".into()); } Ok(()) } // --------------------------------------------------------------------------------------------- // Where installed things came from #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SourceKind { Store, Link, File, } /// One install the owner confirmed. Keyed `plugin:` or `theme:` in [`InstallRecords`]. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InstallRecord { pub kind: Kind, pub source: SourceKind, /// The link or store item URL (none for a dropped file). #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, /// Store item id, for update checks. #[serde(default, skip_serializing_if = "Option::is_none")] pub store_id: Option, #[serde(default)] pub version: String, pub sha256: String, pub installed_at: String, } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InstallRecords { #[serde(default)] pub items: BTreeMap, } impl InstallRecords { pub fn key(kind: Kind, name: &str) -> String { format!("{}:{name}", kind.as_str()) } /// Bounded: keeps the newest 1000 records. pub fn insert(&mut self, key: String, record: InstallRecord) { self.items.insert(key, record); while self.items.len() > 1000 { let oldest = self.items.iter().min_by(|a, b| a.1.installed_at.cmp(&b.1.installed_at)).map(|(k, _)| k.clone()); match oldest { Some(key) => self.items.remove(&key), None => break, }; } } } /// `a` is a newer version than `b` (semver-ish: numeric dot parts compared as numbers). pub fn newer_version(a: &str, b: &str) -> bool { let parts = |v: &str| v.split(['.', '-', '+']).map(|p| p.parse::().unwrap_or(0)).collect::>(); let (a, b) = (parts(a), parts(b)); for i in 0..a.len().max(b.len()) { let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0)); if x != y { return x > y; } } false } #[cfg(test)] pub(crate) mod tests { use super::*; pub(crate) fn config(app: &'static str, keys: &[String]) -> &'static StoreConfig { let keys: Vec<&'static str> = keys.iter().map(|key| &*Box::leak(key.clone().into_boxed_str())).collect(); Box::leak(Box::new(StoreConfig { app_id: app, product: "wayBetter test", user_agent: "wayBetter-test/0", scheme: "waybetter", store_base: DEFAULT_STORE_BASE, public_keys: Box::leak(keys.into_boxed_slice()), allowed_hosts: DEFAULT_HOSTS, })) } fn link_config() -> &'static StoreConfig { config("x", &[]) } #[test] fn install_links_accept_only_allow_listed_https_urls() { let c = link_config(); let ok = parse_install_link(c, "waybetter://install?kind=theme&url=https%3A%2F%2Fwaybetter.fyi%2Fstore%2Fthemes%2Fnord.json").unwrap(); assert_eq!(ok, InstallLink { kind: Kind::Theme, url: "https://waybetter.fyi/store/themes/nord.json".into() }); let plugin = parse_install_link(c, "waybetter://install?kind=plugin&url=https://cdn1.crt.fyi/p/focus.plugin.js").unwrap(); assert_eq!(plugin.kind, Kind::Plugin); assert!(parse_install_link(c, "waybetter://install?kind=plugin&url=https://git.crt.fyi/CRT/plugins/raw/branch/main/a.plugin.js").is_ok()); assert_eq!(parse_install_link(c, &install_link(c, Kind::Theme, "https://waybetter.fyi/store/themes/nord.json")).unwrap(), ok); for bad in [ "https://waybetter.fyi/install?kind=theme&url=https://waybetter.fyi/a.json", "waybetter://uninstall?kind=theme&url=https://waybetter.fyi/a.json", "waybetter://install/extra?kind=theme&url=https://waybetter.fyi/a.json", "waybetter://install?kind=script&url=https://waybetter.fyi/a.json", "waybetter://install?kind=theme", "waybetter://install?kind=theme&url=http://waybetter.fyi/a.json", "waybetter://install?kind=theme&url=https://evil.example/a.json", "waybetter://install?kind=theme&url=https://waybetter.fyi.evil.example/a.json", "waybetter://install?kind=theme&url=https://user:pw@waybetter.fyi/a.json", "waybetter://install?kind=theme&url=https://waybetter.fyi:8443/a.json", "waybetter://install?kind=theme&url=https://waybetter.fyi/a.json%23x#frag", "waybetter://install?kind=theme&url=file:///C:/a.json", "waybetter://install?kind=theme&url=https://git.crt.fyi/CRT/repo/src/branch/main/a.json", "waybetter://install?kind=theme&url=https://git.crt.fyi/CRT/repo/raw/../../a.json", "waybetter://install?kind=theme&url=https://waybetter.fyi/a.json&url=https://waybetter.fyi/b.json", "waybetter://install?kind=theme&url=https://waybetter.fyi/a.json&then=run", "waybetter://install?kind=plugin&url=https://waybetter.fyi/store/plugins/tool.js", "waybetter://install?kind=plugin&url=https://waybetter.fyi/store/plugins/..%252Fx.plugin.js", "otherapp://install?kind=theme&url=https://waybetter.fyi/a.json", ] { assert!(parse_install_link(c, bad).is_err(), "accepted {bad}"); } assert!(parse_install_link(c, &format!("waybetter://install?kind=theme&url=https://waybetter.fyi/{}", "a".repeat(MAX_LINK_CHARS))).is_err()); } #[test] fn allow_lists_are_per_app() { const ONLY_CDN: &[AllowedHost] = &[AllowedHost { host: "cdn.example", raw_files_only: false }]; let narrow = StoreConfig { allowed_hosts: ONLY_CDN, scheme: "wbkick", ..*link_config() }; assert!(parse_install_link(&narrow, "wbkick://install?kind=theme&url=https://cdn.example/a.json").is_ok()); assert!(parse_install_link(&narrow, "wbkick://install?kind=theme&url=https://waybetter.fyi/a.json").unwrap_err().contains("cdn.example")); assert!(parse_install_link(&narrow, "waybetter://install?kind=theme&url=https://cdn.example/a.json").unwrap_err().contains("wbkick://")); let url = Url::parse("https://git.crt.fyi/CRT/repo/raw/branch/main/a.json").unwrap(); assert!(allowed_download_url(DEFAULT_HOSTS, &url) && !allowed_download_url(ONLY_CDN, &url)); } #[test] fn plugin_file_names_come_from_the_last_path_segment() { let url = Url::parse("https://waybetter.fyi/store/plugins/My%20Tool.plugin.js").unwrap(); assert_eq!(plugin_file_from_url(&url).unwrap(), "My Tool.plugin.js"); let url = Url::parse("https://waybetter.fyi/store/plugins/%2E%2E%5Cx.plugin.js").unwrap(); assert!(plugin_file_from_url(&url).is_err()); } #[test] fn themes_must_be_named_json_objects_under_the_cap() { let info = validate_theme(br#"{"name":"Nord","author":"me","css":"a{}"}"#).unwrap(); assert_eq!((info.name.as_str(), info.author.as_str(), info.css_chars), ("Nord", "me", 3)); assert!(validate_theme(b"").is_err()); assert!(validate_theme(b"[1,2]").unwrap_err().contains("object")); assert!(validate_theme(br#"{"colors":{}}"#).unwrap_err().contains("name")); assert!(validate_theme(b"{not json").unwrap_err().contains("JSON")); assert!(validate_theme(&[0xff, 0xfe]).unwrap_err().contains("UTF-8")); let big = format!(r#"{{"name":"Big","css":"{}"}}"#, "a".repeat(MAX_THEME_BYTES)); assert!(validate_theme(big.as_bytes()).unwrap_err().contains("2 MiB")); } pub(crate) fn test_key(seed: u8) -> (ed25519_dalek::SigningKey, String) { let key = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]); let public = base64::engine::general_purpose::STANDARD.encode(key.verifying_key().to_bytes()); (key, public) } pub(crate) fn sign(key: &ed25519_dalek::SigningKey, body: &[u8]) -> String { use ed25519_dalek::Signer; base64::engine::general_purpose::STANDARD.encode(key.sign(body).to_bytes()) } pub(crate) fn catalog_json(items: serde_json::Value) -> Vec { serde_json::to_vec(&serde_json::json!({ "format": CATALOG_FORMAT, "name": "Test", "generatedAt": "2026-09-28T00:00:00Z", "items": items })).unwrap() } pub(crate) fn plugin_item(body: &[u8]) -> serde_json::Value { serde_json::json!({ "apps": ["x"], "kind": "plugin", "id": "tool", "name": "Tool", "author": "me", "version": "1.0.0", "description": "d", "category": "tools", "tags": ["x"], "url": "https://waybetter.fyi/store/plugins/tool.plugin.js", "file": "tool.plugin.js", "sha256": sha256_hex(body), "size": body.len(), "preview": "https://waybetter.fyi/store/previews/tool.svg", "permissions": ["ui"] }) } pub(crate) fn theme_item(body: &[u8]) -> serde_json::Value { serde_json::json!({ "apps": ["x"], "kind": "theme", "id": "nord", "name": "Nord", "version": "1.0.0", "category": "dark", "url": "https://waybetter.fyi/store/themes/nord.json", "sha256": sha256_hex(body), "size": body.len(), "colors": { "bg": "#2e3440", "text": "#eceff4", "accent": "#88c0d0" } }) } pub(crate) const PLUGIN: &[u8] = b"/**\n * @name Tool\n * @version 1.0.0\n * @permissions ui\n */\nmodule.exports = class {};"; #[test] fn catalog_signature_and_items_are_verified() { let (key, public) = test_key(7); let body = catalog_json(serde_json::json!([plugin_item(PLUGIN)])); let signature = sign(&key, &body); let c = config("x", std::slice::from_ref(&public)); let catalog = verify_catalog(c, &body, &signature).unwrap(); assert_eq!(catalog.items[0].file.as_deref(), Some("tool.plugin.js")); verify_item(&catalog.items[0], PLUGIN).unwrap(); assert!(verify_item(&catalog.items[0], b"tampered").is_err()); let mut same_size = PLUGIN.to_vec(); same_size[20] ^= 1; assert!(verify_item(&catalog.items[0], &same_size).unwrap_err().contains("sha256")); // One changed byte, another key, a garbage or oversized signature: refused. let mut tampered = body.clone(); let at = tampered.iter().position(|b| *b == b'T').unwrap(); tampered[at] = b'X'; assert!(verify_catalog(c, &tampered, &signature).unwrap_err().contains("does not verify")); let (_, other) = test_key(9); assert!(verify_catalog(config("x", std::slice::from_ref(&other)), &body, &signature).is_err()); assert!(verify_catalog(config("x", &[]), &body, &signature).is_err(), "no keys, nothing verifies"); assert!(verify_catalog(config("x", &["garbage".into()]), &body, &signature).is_err()); assert!(verify_catalog(c, &body, "not base64!").is_err()); assert!(verify_catalog(c, &body, &"A".repeat(MAX_SIGNATURE_BYTES + 1)).is_err()); } #[test] fn keys_rotate_any_listed_key_may_sign() { let (old, old_public) = test_key(7); let (new, new_public) = test_key(11); let body = catalog_json(serde_json::json!([plugin_item(PLUGIN)])); let both = config("x", &[old_public.clone(), new_public.clone()]); // An app that ships both keys accepts the catalog signed with either one... assert!(verify_catalog(both, &body, &sign(&old, &body)).is_ok()); assert!(verify_catalog(both, &body, &sign(&new, &body)).is_ok()); // ...and once the old key is dropped, only the new signature verifies. assert!(verify_catalog(config("x", &[new_public]), &body, &sign(&old, &body)).is_err()); assert!(verify_catalog(config("x", &[old_public]), &body, &sign(&new, &body)).is_err()); } #[test] fn one_catalog_serves_every_app_each_sees_only_its_own_items() { let (key, public) = test_key(7); let mut kick = plugin_item(PLUGIN); kick["apps"] = serde_json::json!(["kick"]); let mut both = theme_item(b"{}"); both["apps"] = serde_json::json!(["x", "kick"]); let body = catalog_json(serde_json::json!([plugin_item(PLUGIN), kick, both])); let signature = sign(&key, &body); let x = verify_catalog(config("x", std::slice::from_ref(&public)), &body, &signature).unwrap(); assert_eq!(x.items.iter().map(|i| i.id.as_str()).collect::>(), ["tool", "nord"]); let kick = verify_catalog(config("kick", std::slice::from_ref(&public)), &body, &signature).unwrap(); assert_eq!(kick.items.iter().map(|i| (i.id.as_str(), i.apps.len())).collect::>(), [("tool", 1), ("nord", 2)]); assert!(verify_catalog(config("whatsapp", &[public]), &body, &signature).unwrap().items.is_empty()); } #[test] fn another_apps_item_is_checked_for_shape_but_not_against_this_apps_hosts() { let (key, public) = test_key(7); let mut other = plugin_item(PLUGIN); other["apps"] = serde_json::json!(["kick"]); other["url"] = serde_json::json!("https://kick-cdn.example/tool.plugin.js"); other["preview"] = serde_json::json!("https://kick-cdn.example/tool.svg"); let body = catalog_json(serde_json::json!([other.clone()])); assert!(verify_catalog(config("x", std::slice::from_ref(&public)), &body, &sign(&key, &body)).is_ok()); // The same item for this app: its host is not on this app's list. other["apps"] = serde_json::json!(["x"]); let body = catalog_json(serde_json::json!([other.clone()])); assert!(verify_catalog(config("x", std::slice::from_ref(&public)), &body, &sign(&key, &body)).unwrap_err().contains("host")); // Not https, or malformed, is refused for every app. other["apps"] = serde_json::json!(["kick"]); other["url"] = serde_json::json!("http://kick-cdn.example/tool.plugin.js"); let body = catalog_json(serde_json::json!([other])); assert!(verify_catalog(config("x", &[public]), &body, &sign(&key, &body)).is_err()); } #[test] fn signed_catalogs_with_bad_items_are_still_refused() { let (key, public) = test_key(7); let c = config("x", &[public]); let good = plugin_item(b"x"); let cases: Vec<(&str, serde_json::Value)> = vec![ ("url", serde_json::json!({ "url": "https://evil.example/tool.plugin.js" })), ("preview", serde_json::json!({ "preview": "http://waybetter.fyi/p.svg" })), ("sha256", serde_json::json!({ "sha256": "ABC" })), ("size", serde_json::json!({ "size": MAX_PLUGIN_BYTES + 1 })), ("file", serde_json::json!({ "file": "../tool.plugin.js" })), ("permissions", serde_json::json!({ "permissions": ["root"] })), ("id", serde_json::json!({ "id": "Tool!" })), ("unknown field", serde_json::json!({ "run": "code" })), ("no apps", serde_json::json!({ "apps": [] })), ("bad app id", serde_json::json!({ "apps": ["X"] })), ("repeated app", serde_json::json!({ "apps": ["x", "x"] })), ("apps not a list", serde_json::json!({ "apps": "x" })), ]; for (what, patch) in cases { let mut item = good.clone(); for (k, v) in patch.as_object().unwrap() { item[k] = v.clone(); } let body = catalog_json(serde_json::json!([item])); assert!(verify_catalog(c, &body, &sign(&key, &body)).is_err(), "accepted bad {what}"); } let mut missing = good.clone(); missing.as_object_mut().unwrap().remove("apps"); let body = catalog_json(serde_json::json!([missing])); assert!(verify_catalog(c, &body, &sign(&key, &body)).is_err(), "accepted an item without apps"); let body = catalog_json(serde_json::json!([good.clone(), good.clone()])); assert!(verify_catalog(c, &body, &sign(&key, &body)).unwrap_err().contains("duplicate")); let mut same_file = good.clone(); same_file["id"] = serde_json::json!("tool-2"); same_file["file"] = serde_json::json!("TOOL.plugin.js"); let body = catalog_json(serde_json::json!([good.clone(), same_file.clone()])); assert!(verify_catalog(c, &body, &sign(&key, &body)).unwrap_err().contains("duplicate plugin file")); // The same id or file for two different apps is fine. same_file["apps"] = serde_json::json!(["kick"]); same_file["id"] = serde_json::json!("tool"); let body = catalog_json(serde_json::json!([good, same_file])); assert!(verify_catalog(c, &body, &sign(&key, &body)).is_ok()); // Format 1 (no apps) is refused with an update hint. let body = serde_json::to_vec(&serde_json::json!({ "format": 1, "name": "Old", "generatedAt": "2026-09-28T00:00:00Z", "items": [] })).unwrap(); assert!(verify_catalog(c, &body, &sign(&key, &body)).unwrap_err().contains("update the app")); } #[test] fn versions_compare_numerically() { assert!(newer_version("1.10.0", "1.9.3")); assert!(newer_version("2.0", "1.9.9")); assert!(!newer_version("1.0.0", "1.0.0")); assert!(!newer_version("1.0.0", "1.0.1")); } #[test] fn install_records_are_bounded() { let mut records = InstallRecords::default(); for i in 0..1005 { records.insert(format!("plugin:{i}.plugin.js"), InstallRecord { kind: Kind::Plugin, source: SourceKind::File, url: None, store_id: None, version: "1".into(), sha256: "0".repeat(64), installed_at: format!("2026-09-28T00:{:02}:{:02}Z", i / 60 % 60, i % 60), }); } assert_eq!(records.items.len(), 1000); } }