817 lines
38 KiB
Rust
817 lines
38 KiB
Rust
//! 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,
|
|
/// A string pack: data that replaces the site's own UI strings (`store::strings`).
|
|
Strings,
|
|
}
|
|
|
|
impl Kind {
|
|
pub fn parse(value: &str) -> Result<Self, String> {
|
|
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 `/<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>,
|
|
/// String packs: the language tag of the pack.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub language: Option<String>,
|
|
/// 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>,
|
|
/// String packs: who translated and reviewed it, plain text.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub credits: Option<String>,
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
// 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<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);
|
|
}
|
|
}
|