host: store module: install links, allow-list, signed multi-app catalog, staging and installs

Moved from wayBetter for X (distribution.rs + desktop_store.rs) so every wayBetter app gets the
Store; the app keeps its Tauri command wrappers and a `StoreConfig`.

- store::catalog (pure): `StoreConfig` (app id in the shared catalog, product, user agent, link
  scheme, store URL, public keys, allowed hosts), `AllowedHost` (raw-files-only rule for a code
  host; DEFAULT_HOSTS = waybetter.fyi, cdn1.crt.fyi, git.crt.fyi raw), install-link parsing,
  download allow-list, theme validation, catalog format 2: every item names its `apps`; the whole
  catalog is verified (Ed25519 over the exact bytes with any listed key, so keys rotate; strict
  schema; ids/files unique per app) and each app gets only its own items (another app's item is
  shape-checked but not held to this app's hosts); per-item sha256/size, install records, versions.
- store::install: `Store` (pending links, staged installs with TTL/bound, MIN_REVIEW before a
  commit, commit writes exactly the staged bytes after re-validation via PluginStore::install,
  plugin remove, records file, verified catalog + offline copy verified again when read back,
  StoreView). Plugin start/stop scripts run through a function the app passes.
- store::net (`net` feature): no-redirect, no-cookie reqwest fetch with caps; stage_link,
  stage_item, catalog(refresh).
- Tests: links, per-app allow-lists and schemes, themes, signatures incl. tampering and key
  rotation, multi-app filtering, bad items, staging/commit/remove/records/offline copy on disk,
  fetch refuses anything off the allow-list. test:rust and check:tauri build with `net`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-09-29 11:05:25 +07:00
parent 63e8a9c0b6
commit 6a72e81256
8 changed files with 1950 additions and 7 deletions

View file

@ -0,0 +1,788 @@
//! 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, `/<owner>/<repo>/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,
}
impl Kind {
pub fn parse(value: &str) -> Result<Self, String> {
match value {
"theme" => Ok(Kind::Theme),
"plugin" => Ok(Kind::Plugin),
_ => Err("VALIDATION: kind must be \"theme\" or \"plugin\"".into()),
}
}
pub fn as_str(self) -> &'static str {
match self {
Kind::Theme => "theme",
Kind::Plugin => "plugin",
}
}
pub fn max_bytes(self) -> usize {
match self {
Kind::Theme => MAX_THEME_BYTES,
Kind::Plugin => MAX_PLUGIN_BYTES as usize,
}
}
}
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 `/<owner>/<repo>/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 `<scheme>://install?kind=theme|plugin&url=<https 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<InstallLink, String> {
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::<String>())
}
/// The file name a plugin downloaded from `url` is saved under: its last path segment.
pub fn plugin_file_from_url(url: &Url) -> Result<String, String> {
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<String> {
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<ThemeInfo, String> {
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<ValidatedPlugin, String> {
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<String>,
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<String>,
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<String>,
pub url: String,
/// Plugins: the file name it is installed under.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
pub sha256: String,
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// Plugins: declared permissions (`None` = legacy, no `@permissions` line).
#[serde(default)]
pub permissions: Option<Vec<String>>,
/// Themes: a few colors for swatches.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub colors: Option<Swatch>,
}
#[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<CatalogItem>,
}
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::<String>()));
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");
}
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::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<VerifyingKey, String> {
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<Catalog, String> {
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:<file>` or `theme:<id>` 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<String>,
/// Store item id, for update checks.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store_id: Option<String>,
#[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<String, InstallRecord>,
}
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::<u64>().unwrap_or(0)).collect::<Vec<_>>();
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<u8> {
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::<Vec<_>>(), ["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::<Vec<_>>(), [("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);
}
}