//! 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 = 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, 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 { 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 { 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); } }