tui/src/pets/asset_pack.rs
190 lines
//! Built-in pet asset acquisition and cache ownership.//!//! Unlike custom pets, built-in pets are not checked into the TUI package as//! local spritesheets. The TUI resolves them from the public Codex pets CDN on//! first use, verifies that the downloaded file has the expected spritesheet//! geometry, and installs it into a versioned cache under CODEX_HOME.//!//! This module deliberately stops at "a validated spritesheet exists at this//! path". Higher layers remain responsible for deciding when downloads are//! allowed, when previews should block on them, and when a successfully loaded//! built-in pet is safe to persist to config.use std::fs;use std::io::Read;use std::path::Path;use std::path::PathBuf;use std::time::Duration;use anyhow::Context;use anyhow::Result;use anyhow::bail;use url::Url;use uuid::Uuid;use super::catalog;const PET_PACK_VERSION: &str = "v1";const PET_PACK_DIR: &str = "cache/tui-pets";const PET_CDN_BASE_URL: &str = "https://persistent.oaistatic.com/codex/pets/v1";const PET_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60);const PET_MAX_DOWNLOAD_BYTES: u64 = 4 * 1024 * 1024;pub(crate) fn builtin_spritesheet_path(codex_home: &Path, file: &str) -> PathBuf { pack_dir(codex_home).join("assets").join(file)}/// Ensure that a built-in pet's spritesheet is present and structurally valid.////// The cache key is the CDN-facing filename, so updating a built-in pet means/// publishing a new versioned filename rather than mutating an existing one in/// place. If a cached file is missing or invalid, this downloads a fresh copy,/// validates the decoded image dimensions, and installs it atomically. Callers/// should treat any error here as "the asset is unavailable", not as a partial/// install they can safely ignore.pub(crate) fn ensure_builtin_pet(codex_home: &Path, pet: catalog::BuiltinPet) -> Result<()> { let destination = builtin_spritesheet_path(codex_home, pet.spritesheet_file); if validate_cached_spritesheet(&destination).is_ok() { return Ok(()); } let url = builtin_pet_url(pet)?; let bytes = download_bytes_with_limit(&url, PET_MAX_DOWNLOAD_BYTES)?; let parent = destination .parent() .context("pet spritesheet path should include an assets directory")?; fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; let staging = destination.with_file_name(format!( ".{}.download-{}.webp", pet.spritesheet_file, Uuid::new_v4() )); fs::write(&staging, &bytes).with_context(|| format!("write {}", staging.display()))?; if let Err(err) = validate_cached_spritesheet(&staging) { let _ = fs::remove_file(&staging); return Err(err); } if install_downloaded_spritesheet(&staging, &destination).is_ok() { return Ok(()); } if validate_cached_spritesheet(&destination).is_ok() { let _ = fs::remove_file(&staging); return Ok(()); } if destination.exists() { fs::remove_file(&destination) .with_context(|| format!("remove {}", destination.display()))?; } install_downloaded_spritesheet(&staging, &destination)}fn builtin_pet_url(pet: catalog::BuiltinPet) -> Result<String> { let url = format!("{PET_CDN_BASE_URL}/{}", pet.spritesheet_file); validate_download_url(&url)?; Ok(url)}fn pack_dir(codex_home: &Path) -> PathBuf { codex_home.join(PET_PACK_DIR).join(PET_PACK_VERSION)}fn download_bytes_with_limit(url: &str, max_bytes: u64) -> Result<Vec<u8>> { validate_download_url(url)?; let response = reqwest::blocking::Client::builder() .timeout(PET_DOWNLOAD_TIMEOUT) .build() .context("build pet asset download client")? .get(url) .send() .with_context(|| format!("download pet asset from {url}"))? .error_for_status() .with_context(|| format!("download pet asset from {url}"))?; validate_download_url(response.url().as_str())?; if response.content_length().is_some_and(|len| len > max_bytes) { bail!("pet asset download from {url} exceeded {max_bytes} bytes"); } let mut bytes = Vec::new(); response .take(max_bytes.saturating_add(/*rhs*/ 1)) .read_to_end(&mut bytes) .with_context(|| format!("read pet asset download from {url}"))?; if bytes.len() as u64 > max_bytes { bail!("pet asset download from {url} exceeded {max_bytes} bytes"); } Ok(bytes)}fn install_downloaded_spritesheet(staging: &Path, destination: &Path) -> Result<()> { fs::rename(staging, destination).with_context(|| format!("install {}", destination.display()))}fn validate_download_url(value: &str) -> Result<()> { let url = Url::parse(value).with_context(|| format!("parse pet asset download URL {value}"))?; if url.scheme() != "https" { bail!("unsupported pet asset download URL scheme {}", url.scheme()); } Ok(())}fn validate_cached_spritesheet(path: &Path) -> Result<()> { let (width, height) = image::image_dimensions(path).with_context(|| format!("read {}", path.display()))?; if width != catalog::SPRITESHEET_WIDTH || height != catalog::SPRITESHEET_HEIGHT { bail!( "invalid pet spritesheet dimensions for {}: expected {}x{}, got {}x{}", path.display(), catalog::SPRITESHEET_WIDTH, catalog::SPRITESHEET_HEIGHT, width, height ); } Ok(())}#[cfg(test)]pub(crate) fn write_test_pack(codex_home: &Path) { let assets_dir = pack_dir(codex_home).join("assets"); fs::create_dir_all(&assets_dir).unwrap(); for pet in catalog::BUILTIN_PETS { let path = assets_dir.join(pet.spritesheet_file); catalog::write_test_spritesheet(&path); }}#[cfg(test)]mod tests { use super::*; use pretty_assertions::assert_eq; #[test] fn builtin_pet_url_uses_public_cdn_path() { let pet = catalog::builtin_pet("dewey").unwrap(); let url = builtin_pet_url(pet).unwrap(); assert_eq!( url, "https://persistent.oaistatic.com/codex/pets/v1/dewey-spritesheet-v4.webp" ); } #[test] fn write_test_pack_installs_all_builtins() { let dir = tempfile::tempdir().unwrap(); write_test_pack(dir.path()); for pet in catalog::BUILTIN_PETS { let path = builtin_spritesheet_path(dir.path(), pet.spritesheet_file); assert!(path.is_file()); validate_cached_spritesheet(&path).unwrap(); } }}