d87397b382
Test / test (pull_request) Successful in 10m15s
Phase 1+2 of the theme-store roadmap: a free catalog served by
solitaire_server and an in-app browse/install flow, making custom
themes installable on Android for the first time (the manual
drop-a-zip flow can't reach the app-private themes dir there).
- solitaire_sync: ThemeCatalogEntry/ThemeCatalogResponse wire types
(additive module; SyncPayload and SyncProvider untouched)
- solitaire_server: THEME_STORE_DIR scan at startup (meta-only
theme.ron parse, sha256, 20 MiB cap, best-effort per archive);
public GET /api/themes, /api/themes/{id}/download, /{id}/preview;
compose volume + README_SERVER docs
- solitaire_data: ThemeStoreClient — catalog fetch + download with
mandatory size/sha256 verification before bytes are released
- solitaire_engine: ThemeStorePlugin — 'Browse theme store' button in
Settings → Cosmetic, modal catalog (leaderboard-style rebuild),
download on AsyncComputeTaskPool, atomic .tmp+rename write into
user_theme_dir, then the existing hardened import_theme pipeline and
an in-place registry refresh
New deps: sha2 (workspace, server+data); ron/zip reused in server;
serde_json added to solitaire_sync dev-deps for DTO round-trip tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
201 lines
6.9 KiB
Rust
201 lines
6.9 KiB
Rust
//! 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<String>) -> 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<Vec<ThemeCatalogEntry>, 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<Vec<u8>, 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 { .. })
|
|
));
|
|
}
|
|
}
|