//! HTTP client for the server's theme-store endpoints. //! //! Fetches the catalog (`GET /api/themes`) and downloads theme //! archives, verifying each download's size and SHA-256 against the //! catalog entry before returning the bytes. The caller (the engine's //! theme-store UI) hands verified bytes to the theme importer, which //! independently re-validates the archive's structure — the store //! pipeline never trusts a byte the importer hasn't checked. //! //! Native-only: gated out on wasm32 alongside the other `reqwest` //! consumers in this crate. use sha2::{Digest, Sha256}; use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse}; use thiserror::Error; /// Hard cap on a theme archive download, matching the engine /// importer's `MAX_ARCHIVE_BYTES` — anything larger can never import, /// so downloading it is pure waste. pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024; /// Errors surfaced by [`ThemeStoreClient`]. #[derive(Debug, Error)] pub enum ThemeStoreError { /// The request could not be sent or the response body not read. #[error("network error: {0}")] Network(String), /// The server answered with a non-success status code. #[error("server returned HTTP {0}")] Http(u16), /// The catalog JSON did not parse. #[error("malformed catalog: {0}")] MalformedCatalog(String), /// The archive is larger than [`MAX_THEME_DOWNLOAD_BYTES`] or its /// catalog-declared size. #[error("archive size {got} exceeds the expected {expected} bytes")] Oversized { expected: u64, got: u64 }, /// The downloaded bytes do not hash to the catalog's SHA-256 — /// the file changed on the server or was corrupted in transit. #[error("archive checksum mismatch (expected {expected}, got {got})")] ChecksumMismatch { expected: String, got: String }, } /// Client for one server's theme store. /// /// Unauthenticated: the catalog and downloads are public endpoints, /// so unlike `SolitaireServerClient` there is no token handling. pub struct ThemeStoreClient { /// Base URL of the server, trailing slash stripped. base_url: String, client: reqwest::Client, } impl ThemeStoreClient { /// Construct a client for the server at `base_url` /// (e.g. `"https://solitaire.example.com"`). pub fn new(base_url: impl Into) -> Self { Self { base_url: base_url.into().trim_end_matches('/').to_owned(), client: reqwest::Client::new(), } } /// Fetch the theme catalog, sorted by display name (server-side). pub async fn fetch_catalog(&self) -> Result, ThemeStoreError> { let resp = self .client .get(format!("{}/api/themes", self.base_url)) .send() .await .map_err(|e| ThemeStoreError::Network(e.to_string()))?; if !resp.status().is_success() { return Err(ThemeStoreError::Http(resp.status().as_u16())); } let catalog: ThemeCatalogResponse = resp .json() .await .map_err(|e| ThemeStoreError::MalformedCatalog(e.to_string()))?; Ok(catalog.themes) } /// Download `entry`'s archive and verify it against the catalog's /// size and SHA-256. Returns the verified `.zip` bytes, ready for /// the engine's theme importer. pub async fn download_theme( &self, entry: &ThemeCatalogEntry, ) -> Result, ThemeStoreError> { // The catalog itself could name an absurd size; refuse before // buffering anything. if entry.size_bytes > MAX_THEME_DOWNLOAD_BYTES { return Err(ThemeStoreError::Oversized { expected: MAX_THEME_DOWNLOAD_BYTES, got: entry.size_bytes, }); } let resp = self .client .get(format!("{}{}", self.base_url, entry.download_url)) .send() .await .map_err(|e| ThemeStoreError::Network(e.to_string()))?; if !resp.status().is_success() { return Err(ThemeStoreError::Http(resp.status().as_u16())); } let bytes = resp .bytes() .await .map_err(|e| ThemeStoreError::Network(e.to_string()))?; verify_archive(&bytes, entry)?; Ok(bytes.to_vec()) } } /// Checks downloaded `bytes` against the catalog `entry`'s declared /// size and SHA-256. Pure so it can be unit-tested without a server. fn verify_archive(bytes: &[u8], entry: &ThemeCatalogEntry) -> Result<(), ThemeStoreError> { if bytes.len() as u64 != entry.size_bytes { return Err(ThemeStoreError::Oversized { expected: entry.size_bytes, got: bytes.len() as u64, }); } let got = hex_digest(bytes); // Case-insensitive: the server emits lowercase hex, but a // hand-authored catalog mirror shouldn't fail on case alone. if !got.eq_ignore_ascii_case(&entry.sha256) { return Err(ThemeStoreError::ChecksumMismatch { expected: entry.sha256.clone(), got, }); } Ok(()) } /// Lowercase-hex SHA-256 of `bytes`. Mirrors the server's digest /// encoding in `solitaire_server::theme_store`. fn hex_digest(bytes: &[u8]) -> String { let digest = Sha256::digest(bytes); let mut out = String::with_capacity(digest.len() * 2); for byte in digest { out.push_str(&format!("{byte:02x}")); } out } #[cfg(test)] mod tests { use super::*; fn entry_for(bytes: &[u8]) -> ThemeCatalogEntry { ThemeCatalogEntry { id: "neon".into(), name: "Neon".into(), author: "Test".into(), version: "1.0.0".into(), card_aspect: (2, 3), size_bytes: bytes.len() as u64, sha256: hex_digest(bytes), download_url: "/api/themes/neon/download".into(), preview_url: None, } } #[test] fn verify_accepts_matching_bytes() { let bytes = b"theme archive bytes"; assert!(verify_archive(bytes, &entry_for(bytes)).is_ok()); } #[test] fn verify_accepts_uppercase_catalog_hash() { let bytes = b"theme archive bytes"; let mut entry = entry_for(bytes); entry.sha256 = entry.sha256.to_uppercase(); assert!(verify_archive(bytes, &entry).is_ok()); } #[test] fn verify_rejects_size_mismatch() { let bytes = b"theme archive bytes"; let mut entry = entry_for(bytes); entry.size_bytes += 1; assert!(matches!( verify_archive(bytes, &entry), Err(ThemeStoreError::Oversized { .. }) )); } #[test] fn verify_rejects_checksum_mismatch() { let bytes = b"theme archive bytes"; let mut entry = entry_for(bytes); entry.sha256 = "0".repeat(64); assert!(matches!( verify_archive(bytes, &entry), Err(ThemeStoreError::ChecksumMismatch { .. }) )); } }