waybetter-for-x/apps/desktop/src-tauri/src/distribution.rs
2026-10-01 06:01:13 +07:00

86 lines
5 KiB
Rust

//! wayBetter for X's part of distribution (owner-approved widening, 2026-09-28): installing themes
//! and plugins from a `waybetter://install?kind=theme|plugin&url=<https URL>` link, a dropped file,
//! or the signed wayBetter Store. The rules, the catalog format and the install state are the
//! framework's (waybetter-host `store`, docs in waybetter/docs/STORE.md); this app supplies only its
//! settings below, and the framework serves the shell-only Tauri commands (waybetter-host
//! `store_commands`, enabled by `AppConfig::with_store`).
//!
//! Trust model (see waybetter-host `store` for the details):
//! - Nothing installs without the shell's confirmation screen (name, author, version, source,
//! sha256, permissions, code/CSS preview). Only the local shell webview can call the install
//! commands; X tabs and web pages cannot. A web page can at most open a `waybetter://` link,
//! which only *proposes* an install.
//! - Links may only point at https URLs on [`STORE`]'s hosts (waybetter.fyi, cdn1.crt.fyi, and
//! git.crt.fyi only for raw files), default port, no credentials; native code fetches them
//! without redirects or cookies and with size caps.
//! - The store catalog carries a detached Ed25519 signature made offline with the store key
//! (`waybetter store sign`, via `scripts/sign-store.mjs`); the app verifies it with the public key
//! compiled in (`keys/store.pub`), keeps only items for app id "x", and then checks every
//! downloaded item against the catalog's sha256 and size.
pub use waybetter_host::store::*;
/// Store public keys (base64 of the raw 32-byte Ed25519 key). More than one allows rotation.
pub const STORE_PUBKEYS: &[&str] = &[include_str!("../keys/store.pub")];
/// wayBetter for X in the shared store: its id in the catalog and where it may download from.
pub static STORE: StoreConfig = StoreConfig {
app_id: "x",
product: "wayBetter for X",
user_agent: concat!("wayBetter-for-X/", env!("CARGO_PKG_VERSION")),
scheme: "waybetter",
store_base: DEFAULT_STORE_BASE,
public_keys: STORE_PUBKEYS,
allowed_hosts: DEFAULT_HOSTS,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn production_store_key_parses() {
assert!(decode_key(STORE_PUBKEYS[0]).is_ok());
}
#[test]
fn x_links_keep_the_hosts_and_scheme_they_had() {
let hosts: Vec<(&str, bool)> = STORE.allowed_hosts.iter().map(|h| (h.host, h.raw_files_only)).collect();
assert_eq!(hosts, [("waybetter.fyi", false), ("cdn1.crt.fyi", false), ("git.crt.fyi", true)]);
assert_eq!((STORE.app_id, STORE.scheme, STORE.store_base), ("x", "waybetter", "https://waybetter.fyi/store/"));
assert!(parse_install_link(&STORE, "waybetter://install?kind=theme&url=https://waybetter.fyi/store/themes/nord.json").is_ok());
assert!(parse_install_link(&STORE, "waybetter://install?kind=theme&url=https://evil.example/a.json").is_err());
}
/// `waybetter store sign` (Node crypto) and this verifier agree: the committed store catalog
/// verifies with the production key, and every X item file matches its catalog entry.
#[test]
fn committed_store_catalog_verifies_with_the_production_key() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/public/store");
let Ok(body) = std::fs::read(dir.join("catalog.json")) else {
panic!("apps/web/public/store/catalog.json missing: run node scripts/build-store.mjs && node scripts/sign-store.mjs");
};
let signature = std::fs::read_to_string(dir.join("catalog.json.sig")).expect("catalog.json.sig");
let catalog = verify_catalog(&STORE, &body, &signature).expect("store catalog must verify: rebuild and re-sign it");
assert!(catalog.items.iter().any(|item| item.kind == Kind::Plugin) && catalog.items.iter().any(|item| item.kind == Kind::Theme));
for item in &catalog.items {
assert!(item.is_for("x"));
let relative = item.url.strip_prefix(STORE.store_base).expect("store items live under the store base");
let bytes = std::fs::read(dir.join(relative)).unwrap_or_else(|_| panic!("{relative} missing"));
verify_item(item, &bytes).unwrap_or_else(|e| panic!("{relative}: {e}"));
match item.kind {
Kind::Plugin => {
let validated = validate_plugin(item.file.as_deref().unwrap(), &bytes).unwrap_or_else(|e| panic!("{relative}: {e}"));
assert_eq!(validated.meta.version, item.version, "{relative}: catalog version = header version");
assert_eq!(validated.meta.permissions, item.permissions, "{relative}: catalog permissions = header");
}
Kind::Theme => {
validate_theme(&bytes).unwrap_or_else(|e| panic!("{relative}: {e}"));
}
Kind::Strings => {
validate_strings(&bytes).unwrap_or_else(|e| panic!("{relative}: {e}"));
}
}
}
}
}