86 lines
5 KiB
Rust
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}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|