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`.
114 lines
4.8 KiB
Rust
114 lines
4.8 KiB
Rust
//! 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);
|
|
}
|
|
}
|