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`.
This commit is contained in:
CRTkafa 2026-09-29 11:05:25 +07:00
parent e510ab8d59
commit ffab9eda2f
8 changed files with 1950 additions and 7 deletions

View file

@ -0,0 +1,114 @@
//! Store downloads (`net` feature): allow-listed https only, no redirects, no cookies, size caps.
use super::catalog::{self as dist, StoreConfig, MAX_CATALOG_BYTES, MAX_SIGNATURE_BYTES};
use super::install::{InstallPreview, Store, StoreItemRef, StoreView};
use std::sync::OnceLock;
use std::time::Duration;
use url::Url;
fn client(config: &StoreConfig) -> Result<&'static reqwest::Client, String> {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
if let Some(client) = CLIENT.get() {
return Ok(client);
}
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
// No cookie store is configured: requests carry no cookies and keep none.
let client = reqwest::Client::builder()
.user_agent(config.user_agent)
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| format!("NETWORK: {e}"))?;
Ok(CLIENT.get_or_init(|| client))
}
/// Downloads an allow-listed URL (re-checked here), no redirects or cookies, at most `max` bytes.
pub async fn fetch(config: &StoreConfig, url: &str, max: usize) -> Result<Vec<u8>, String> {
let url = Url::parse(url).map_err(|_| "VALIDATION: invalid URL")?;
if !dist::allowed_download_url(config.allowed_hosts, &url) {
return Err("VALIDATION: this address is not allowed".into());
}
let mut response = client(config)?.get(url).send().await.map_err(|e| format!("NETWORK: {e}"))?;
let status = response.status().as_u16();
if status != 200 {
return Err(format!("NETWORK: the server answered {status}"));
}
if response.content_length().is_some_and(|len| len as usize > max) {
return Err("VALIDATION: the file is larger than allowed".into());
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|e| format!("NETWORK: {e}"))? {
if bytes.len() + chunk.len() > max {
return Err("VALIDATION: the file is larger than allowed".into());
}
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
}
impl Store {
/// Downloads what an install link points at and stages it for the confirmation screen.
pub async fn stage_link(&self, kind: &str, url: &str) -> Result<InstallPreview, String> {
let (link, file) = self.check_link(kind, url)?;
let bytes = fetch(self.config(), &link.url, link.kind.max_bytes()).await?;
self.stage_link_download(&link, file, bytes)
}
/// Downloads a store item, checks it against the signed catalog (size + sha256) and stages it.
pub async fn stage_item(&self, item: &StoreItemRef) -> Result<InstallPreview, String> {
let entry = self.catalog_item(item)?;
let bytes = fetch(self.config(), &entry.url, entry.kind.max_bytes()).await?;
self.stage_store_download(entry, bytes)
}
/// The signed catalog (fetched at most every [`super::CATALOG_TTL`] unless `refresh`), with
/// what is installed. Offline, the last verified copy is shown with the error.
pub async fn catalog(&self, refresh: bool) -> StoreView {
if !refresh && self.catalog_is_fresh() {
return self.view(None);
}
let error = match self.fetch_catalog().await {
Ok(()) => None,
Err(error) => {
self.use_cached_catalog();
Some(error)
}
};
self.view(error)
}
async fn fetch_catalog(&self) -> Result<(), String> {
let base = self.config().store_base;
let body = fetch(self.config(), &format!("{base}catalog.json"), MAX_CATALOG_BYTES).await?;
let signature = fetch(self.config(), &format!("{base}catalog.json.sig"), MAX_SIGNATURE_BYTES).await?;
let signature = String::from_utf8(signature).map_err(|_| "STORE: invalid signature")?;
self.accept_catalog(&body, &signature)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::catalog::Kind;
#[test]
fn nothing_off_the_allow_list_is_requested() {
let config = StoreConfig {
app_id: "x",
product: "t",
user_agent: "t/0",
scheme: "waybetter",
store_base: dist::DEFAULT_STORE_BASE,
public_keys: &[],
allowed_hosts: dist::DEFAULT_HOSTS,
};
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
for url in ["http://waybetter.fyi/a.json", "https://evil.example/a.json", "https://waybetter.fyi:444/a.json", "not a url"] {
let error = runtime.block_on(fetch(&config, url, 10)).unwrap_err();
assert!(error.starts_with("VALIDATION:"), "{url}: {error}");
}
assert_eq!(Kind::Theme.max_bytes(), dist::MAX_THEME_BYTES);
}
}