//! Theme zip-archive importer. //! //! Players ship //! and install third-party themes as a single `.zip` containing a //! `theme.ron` manifest at the archive root plus the 52 face SVGs and //! one back SVG referenced by that manifest. [`import_theme`] is the //! one-shot entry point: it opens the zip, validates structurally, //! then atomically unpacks into [`crate::assets::user_theme_dir`] — //! never touching the user's themes directory unless every check //! passes. //! //! # Safety guarantees //! //! - **Hard size cap.** The total declared archive size must not //! exceed 20 MB. The cap is checked from the central directory //! (i.e. before any extraction) so a zip-bomb cannot run us out of //! memory or disk just by pretending to be small. //! - **Zip-slip immune.** Every entry path is normalised and rejected //! if it contains `..`, an absolute prefix, or any non-`Normal` //! component. We never trust the OS to clamp `..` for us. //! - **Manifest-driven.** Only paths the manifest declares are //! extracted; every face/back path is also rasterised through //! [`crate::assets::rasterize_svg`] as a structural validity check //! so corrupt SVGs surface here, not in the asset graph. //! - **Atomic install.** Extraction goes to a sibling temp directory //! that's renamed into place only after every byte has been //! written. A failed import leaves the user themes dir untouched. //! - **No id collision.** If the manifest's `meta.id` already names a //! directory in the user themes root, we abort before writing. //! Replacing an existing theme is a deliberate user action handled //! by Phase 6's registry, not a side effect of importing. //! //! # Testing hook //! //! Tests target [`import_theme_into`] directly so they can pass a //! `tempfile::TempDir` as the destination root. The public //! [`import_theme`] is a thin wrapper that resolves the destination //! via [`crate::assets::user_theme_dir`]. use std::fs::{self, File}; use std::io::{self, Read}; use std::path::{Component, Path, PathBuf}; use thiserror::Error; use bevy::math::UVec2; use crate::assets::{SvgLoaderError, rasterize_svg, user_theme_dir}; use super::ThemeMetaError; use super::manifest::{ManifestError, ThemeManifest}; /// Hard cap on the *uncompressed* total of all archive entries. Set /// generously high relative to a realistic 53-SVG theme (~1–2 MB at /// most for vector content) but firmly below anything we'd risk /// extracting blind. pub const MAX_ARCHIVE_BYTES: u64 = 20 * 1024 * 1024; /// Tiny rasterisation target used purely to validate SVG structure. /// The actual asset loader picks a real size at load time; we just /// need `usvg` + `resvg` to accept the bytes here. const SVG_VALIDATION_SIZE: UVec2 = UVec2::new(64, 96); /// Filename of the manifest at the archive root. Must match what /// `CardThemeLoader::extensions()` advertises so the same artefact /// works for both import and load. const MANIFEST_NAME: &str = "theme.ron"; /// Strongly-typed wrapper around a successfully imported theme's /// manifest id. Consumers (notably the Phase 6 registry refresh) /// receive this so they can route directly to the new theme without /// re-parsing the manifest off disk. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ThemeId(pub String); impl ThemeId { /// Borrow the underlying id as `&str` for path joining and lookups. pub fn as_str(&self) -> &str { &self.0 } } /// Errors surfaced by [`import_theme`] / [`import_theme_into`]. /// /// Each variant pinpoints exactly which check rejected the archive /// — the importer never lumps unrelated failures into a single /// generic error so the caller can render a precise message in the UI. #[derive(Debug, Error)] pub enum ImportError { /// The zip file could not be opened or its central directory was /// unreadable. `zip::result::ZipError` covers both I/O failures /// and structurally corrupt archives. #[error("could not open zip archive: {0}")] OpenArchive(#[from] zip::result::ZipError), /// Filesystem failure outside of zip parsing (creating the temp /// dir, writing extracted bytes, the final rename, etc). #[error("io error: {0}")] Io(#[from] io::Error), /// The archive's declared total uncompressed size exceeds /// [`MAX_ARCHIVE_BYTES`]. Checked *before* extraction. #[error("archive declares {total} uncompressed bytes, exceeds the {limit}-byte limit")] Oversized { total: u64, limit: u64 }, /// No `theme.ron` at the archive root. #[error("archive does not contain `theme.ron` at the root")] MissingManifest, /// `theme.ron` present but couldn't be parsed as RON. #[error("manifest parse (RON): {0}")] ManifestParse(#[from] ron::error::SpannedError), /// Manifest parsed but failed structural validation. Wraps the /// 52-faces / unknown-key / duplicate-key diagnostics from /// [`super::manifest`]. #[error("manifest validation: {0}")] Validation(#[from] ManifestError), /// Manifest's `meta` block failed validation in isolation. Kept /// distinct from the above so callers can branch on metadata-only /// problems (id shape, aspect zero, etc.) when wanted. #[error("manifest meta: {0}")] Meta(#[from] ThemeMetaError), /// A face or back path declared in the manifest is not present in /// the zip's entry list. #[error("manifest references file not present in archive: {missing}")] MissingFile { missing: String }, /// One of the referenced SVGs failed to rasterise — `usvg` /// rejected it, the bytes were truncated, etc. #[error("invalid SVG content for {path}: {source}")] InvalidSvg { path: String, #[source] source: SvgLoaderError, }, /// A zip entry's normalised path escapes the archive root (zip /// slip): contains `..`, is absolute, or names something other /// than a normal file component. #[error("zip-slip path traversal attempt: {path}")] ZipSlip { path: String }, /// The manifest's `meta.id` already names a directory under the /// user themes root. #[error("a theme with id {id:?} is already installed")] IdCollision { id: String }, } /// Imports a theme zip into the per-platform user themes directory /// resolved by [`crate::assets::user_theme_dir`]. /// /// Returns the imported theme's manifest id on success. The Phase 6 /// registry is responsible for refreshing its in-memory list — this /// function only writes to disk. /// /// See the module-level docs for the full safety contract. pub fn import_theme(zip_path: &Path) -> Result { import_theme_into(zip_path, &user_theme_dir()) } /// Same as [`import_theme`] but takes the destination root explicitly. /// /// Tests use this directly with a `tempfile::TempDir` so they can /// exercise the full extraction path without touching the global /// [`crate::assets::user_dir::set_user_theme_dir`] override. pub fn import_theme_into(zip_path: &Path, target_root: &Path) -> Result { let file = File::open(zip_path)?; let mut archive = zip::ZipArchive::new(file)?; enforce_archive_size_limit(&mut archive)?; enforce_zip_slip_safe(&mut archive)?; // Parse + validate the manifest in-memory. Anything below this // line works against a known-good `ThemeManifest`. let manifest = read_manifest(&mut archive)?; let face_paths = manifest.validate()?; // Confirm every referenced SVG is in the archive AND structurally // valid before we commit to writing anything to disk. let mut required: Vec = face_paths.values().cloned().collect(); required.push(manifest.back.clone()); for path in &required { let bytes = read_archive_entry(&mut archive, path)?; rasterize_svg(&bytes, SVG_VALIDATION_SIZE).map_err(|source| ImportError::InvalidSvg { path: path.to_string_lossy().into_owned(), source, })?; } let id = manifest.meta.id.clone(); let final_dir = target_root.join(&id); if final_dir.exists() { return Err(ImportError::IdCollision { id }); } // Stage the extraction in a sibling temp dir so a partial write // never reaches `final_dir`. We rename on success; on failure // we wipe the staging dir without ever touching `final_dir`. fs::create_dir_all(target_root)?; let staging = target_root.join(format!(".{id}.tmp")); if staging.exists() { // Leftover from a previous crashed import; safe to remove // because it never made the rename. fs::remove_dir_all(&staging)?; } fs::create_dir_all(&staging)?; let extract_result = (|| -> Result<(), ImportError> { // Always extract the manifest under its canonical name. write_archive_entry(&mut archive, MANIFEST_NAME, &staging)?; for path in &required { write_archive_entry_pathbuf(&mut archive, path, &staging)?; } Ok(()) })(); match extract_result { Ok(()) => match install_atomic(&staging, &final_dir) { Ok(()) => Ok(ThemeId(id)), Err(e) => { // Best-effort cleanup; preserve the original error. let _ = fs::remove_dir_all(&staging); Err(e) } }, Err(e) => { let _ = fs::remove_dir_all(&staging); Err(e) } } } /// Sums every entry's declared uncompressed size and rejects archives /// that overflow [`MAX_ARCHIVE_BYTES`]. Iterates the central /// directory only — does not actually decompress anything. fn enforce_archive_size_limit( archive: &mut zip::ZipArchive, ) -> Result<(), ImportError> { let mut total: u64 = 0; for i in 0..archive.len() { let entry = archive.by_index_raw(i)?; total = total.saturating_add(entry.size()); if total > MAX_ARCHIVE_BYTES { return Err(ImportError::Oversized { total, limit: MAX_ARCHIVE_BYTES, }); } } Ok(()) } /// Walks every entry name and rejects the archive if any path /// (after normalisation) escapes its root. Catches `..`, absolute /// paths, drive prefixes on Windows, and the awkward case where /// `enclosed_name` returns `None` because the entry is suspicious. fn enforce_zip_slip_safe( archive: &mut zip::ZipArchive, ) -> Result<(), ImportError> { for i in 0..archive.len() { let entry = archive.by_index_raw(i)?; let name = entry.name().to_owned(); let normalised = entry .enclosed_name() .ok_or_else(|| ImportError::ZipSlip { path: name.clone() })?; if !is_safe_relative_path(&normalised) { return Err(ImportError::ZipSlip { path: name }); } } Ok(()) } /// True iff `p` is a relative path consisting only of `Normal` /// components — no root, no prefix, no `.` or `..`. The zip crate's /// `enclosed_name` already strips most attacks; this is belt and /// braces. fn is_safe_relative_path(p: &Path) -> bool { if p.is_absolute() { return false; } p.components().all(|c| matches!(c, Component::Normal(_))) } /// Reads `theme.ron` from the archive root and parses it. /// /// Errors: /// - [`ImportError::MissingManifest`] when the archive has no /// `theme.ron` entry at its root. /// - [`ImportError::ManifestParse`] when the bytes don't form valid /// RON for `ThemeManifest`. fn read_manifest( archive: &mut zip::ZipArchive, ) -> Result { // We can't use `?` directly across `by_name` because a missing // file is a domain-level error here, not a generic ZipError. let bytes = match archive.by_name(MANIFEST_NAME) { Ok(mut entry) => { let mut buf = Vec::with_capacity(entry.size() as usize); entry.read_to_end(&mut buf)?; buf } Err(zip::result::ZipError::FileNotFound) => { return Err(ImportError::MissingManifest); } Err(e) => return Err(ImportError::OpenArchive(e)), }; let manifest: ThemeManifest = ron::de::from_bytes(&bytes)?; Ok(manifest) } /// Reads a manifest-declared entry's bytes by `Path`, normalising the /// separators so a manifest authored on Windows still resolves on /// Unix and vice versa. /// /// Returns [`ImportError::MissingFile`] when the archive has no entry /// matching the path. fn read_archive_entry( archive: &mut zip::ZipArchive, path: &Path, ) -> Result, ImportError> { let key = archive_key(path); let mut entry = match archive.by_name(&key) { Ok(e) => e, Err(zip::result::ZipError::FileNotFound) => { return Err(ImportError::MissingFile { missing: key }); } Err(e) => return Err(ImportError::OpenArchive(e)), }; let mut buf = Vec::with_capacity(entry.size() as usize); entry.read_to_end(&mut buf)?; Ok(buf) } /// Joins manifest-declared sub-paths with the archive's root using /// forward slashes — zip uses `/` on every platform. fn archive_key(path: &Path) -> String { path.components() .filter_map(|c| match c { Component::Normal(s) => s.to_str(), _ => None, }) .collect::>() .join("/") } /// Extracts a single entry under the staging directory, creating any /// parent directories as needed. The destination path is rebuilt from /// the safe components we already vetted in /// [`enforce_zip_slip_safe`], not from the raw entry name. fn write_archive_entry( archive: &mut zip::ZipArchive, name: &str, staging: &Path, ) -> Result<(), ImportError> { let mut entry = match archive.by_name(name) { Ok(e) => e, Err(zip::result::ZipError::FileNotFound) => { return Err(ImportError::MissingFile { missing: name.to_owned(), }); } Err(e) => return Err(ImportError::OpenArchive(e)), }; let safe = entry.enclosed_name().ok_or_else(|| ImportError::ZipSlip { path: name.to_owned(), })?; if !is_safe_relative_path(&safe) { return Err(ImportError::ZipSlip { path: name.to_owned(), }); } let dest = staging.join(&safe); if let Some(parent) = dest.parent() { fs::create_dir_all(parent)?; } let mut out = File::create(&dest)?; io::copy(&mut entry, &mut out)?; Ok(()) } /// Variant of [`write_archive_entry`] keyed by `Path` for the /// manifest-declared face/back paths. fn write_archive_entry_pathbuf( archive: &mut zip::ZipArchive, path: &Path, staging: &Path, ) -> Result<(), ImportError> { let key = archive_key(path); write_archive_entry(archive, &key, staging) } /// Promotes the staging directory to its final location. /// /// `fs::rename` is atomic when both paths share a filesystem; if not /// (e.g. `/tmp` on a tmpfs vs. the user data dir on disk) we fall /// back to a recursive copy + remove so the import still completes. fn install_atomic(staging: &Path, final_dir: &Path) -> Result<(), ImportError> { match fs::rename(staging, final_dir) { Ok(()) => Ok(()), Err(rename_err) => { // EXDEV (cross-device link) is the canonical case for // falling back; other rename errors are surfaced after // the fallback so the user sees the original cause if // copy also fails. copy_dir_recursive(staging, final_dir).map_err(|copy_err| { ImportError::Io(io::Error::new( copy_err.kind(), format!( "cross-device install fallback failed: rename={rename_err}, copy={copy_err}" ), )) })?; // Best-effort: if removing the staging dir fails the // import is still a success — the user has the theme. let _ = fs::remove_dir_all(staging); Ok(()) } } } /// Plain recursive copy used by [`install_atomic`] when `rename` /// can't cross filesystem boundaries. fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { fs::create_dir_all(dst)?; for entry in fs::read_dir(src)? { let entry = entry?; let from = entry.path(); let to = dst.join(entry.file_name()); if entry.file_type()?.is_dir() { copy_dir_recursive(&from, &to)?; } else { fs::copy(&from, &to)?; } } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; use std::io::Write; use tempfile::TempDir; use zip::CompressionMethod; use zip::write::SimpleFileOptions; use crate::theme::manifest::ThemeManifest; use crate::theme::{CardKey, ThemeMeta}; /// Smallest non-trivial SVG that round-trips through `usvg` / /// `resvg`. Matches the fixture used by `svg_loader::tests`. const TEST_SVG: &[u8] = br##" "##; fn meta(id: &str) -> ThemeMeta { ThemeMeta { id: id.to_owned(), name: "Test Theme".into(), author: "Tester".into(), version: "1.0.0".into(), card_aspect: (2, 3), } } /// Returns a manifest that maps every card and the back to a /// per-card SVG path inside the archive root. fn full_manifest(id: &str) -> ThemeManifest { let faces: HashMap = CardKey::all() .map(|k| { let name = k.manifest_name(); (name.clone(), PathBuf::from(format!("faces/{name}.svg"))) }) .collect(); ThemeManifest { meta: meta(id), back: PathBuf::from("back.svg"), faces, } } /// Writes a zip archive at `zip_path` containing every entry in /// `entries` (path → bytes). Uses `Stored` so tests don't pull /// the deflate codepath into the assertion semantics. fn write_zip(zip_path: &Path, entries: &[(&str, Vec)]) { let file = File::create(zip_path).expect("create zip"); let mut writer = zip::ZipWriter::new(file); let opts: SimpleFileOptions = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); for (name, bytes) in entries { writer.start_file(*name, opts).expect("start file"); writer.write_all(bytes).expect("write entry"); } writer.finish().expect("finish zip"); } /// Builds a complete, valid theme zip at `zip_path` with the /// given manifest id. fn write_valid_zip(zip_path: &Path, id: &str) { let manifest = full_manifest(id); let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()) .expect("ron serialise"); let mut entries: Vec<(String, Vec)> = Vec::with_capacity(54); entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes())); entries.push(("back.svg".to_owned(), TEST_SVG.to_vec())); for k in CardKey::all() { entries.push(( format!("faces/{}.svg", k.manifest_name()), TEST_SVG.to_vec(), )); } let entries_ref: Vec<(&str, Vec)> = entries .iter() .map(|(k, v)| (k.as_str(), v.clone())) .collect(); write_zip(zip_path, &entries_ref); } #[test] fn valid_zip_imports_and_extracts_files() { let scratch = TempDir::new().expect("scratch"); let zip_path = scratch.path().join("good.zip"); write_valid_zip(&zip_path, "fancy"); let target = TempDir::new().expect("target"); let id = import_theme_into(&zip_path, target.path()).expect("import succeeds"); assert_eq!(id.as_str(), "fancy"); let installed = target.path().join("fancy"); assert!(installed.is_dir(), "theme dir should exist"); assert!(installed.join("theme.ron").is_file(), "manifest extracted"); assert!(installed.join("back.svg").is_file(), "back extracted"); assert!( installed.join("faces/hearts_ace.svg").is_file(), "face extracted" ); } #[test] fn missing_manifest_is_rejected() { let scratch = TempDir::new().expect("scratch"); let zip_path = scratch.path().join("no_manifest.zip"); // No `theme.ron` at root, but plenty of other content. write_zip( &zip_path, &[ ("back.svg", TEST_SVG.to_vec()), ("faces/hearts_ace.svg", TEST_SVG.to_vec()), ], ); let target = TempDir::new().expect("target"); let err = import_theme_into(&zip_path, target.path()).expect_err("expected error"); assert!(matches!(err, ImportError::MissingManifest), "got {err:?}"); assert!( target.path().read_dir().unwrap().next().is_none(), "target untouched" ); } #[test] fn manifest_with_only_51_faces_is_rejected() { let scratch = TempDir::new().expect("scratch"); let zip_path = scratch.path().join("missing_face.zip"); let mut manifest = full_manifest("incomplete"); // Drop one face so validation surfaces MissingFaces. manifest.faces.remove("hearts_ace"); let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()) .expect("ron serialise"); let mut entries: Vec<(String, Vec)> = Vec::new(); entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes())); entries.push(("back.svg".to_owned(), TEST_SVG.to_vec())); for k in CardKey::all() { entries.push(( format!("faces/{}.svg", k.manifest_name()), TEST_SVG.to_vec(), )); } let entries_ref: Vec<(&str, Vec)> = entries .iter() .map(|(k, v)| (k.as_str(), v.clone())) .collect(); write_zip(&zip_path, &entries_ref); let target = TempDir::new().expect("target"); let err = import_theme_into(&zip_path, target.path()).expect_err("expected error"); match err { ImportError::Validation(ManifestError::MissingFaces { missing }) => { assert!(missing.iter().any(|s| s == "hearts_ace")); } other => panic!("expected MissingFaces, got {other:?}"), } assert!( target.path().read_dir().unwrap().next().is_none(), "target untouched" ); } #[test] fn oversized_archive_is_rejected() { let scratch = TempDir::new().expect("scratch"); let zip_path = scratch.path().join("huge.zip"); // Compose a single entry whose declared uncompressed size // exceeds the cap. We use a real Vec here (compressed via // Stored) because zip's central directory mirrors the // payload size we actually wrote. let huge = vec![0u8; (MAX_ARCHIVE_BYTES + 1) as usize]; write_zip( &zip_path, &[(MANIFEST_NAME, b"".to_vec()), ("filler.bin", huge)], ); let target = TempDir::new().expect("target"); let err = import_theme_into(&zip_path, target.path()).expect_err("expected error"); assert!(matches!(err, ImportError::Oversized { .. }), "got {err:?}"); assert!( target.path().read_dir().unwrap().next().is_none(), "target untouched" ); } #[test] fn zip_slip_path_is_rejected() { let scratch = TempDir::new().expect("scratch"); let zip_path = scratch.path().join("slip.zip"); // Build the zip with a deliberately path-traversing entry // name. We bypass `write_zip`'s normal flow because the // SimpleFileOptions API will gladly accept any string. let file = File::create(&zip_path).expect("create zip"); let mut writer = zip::ZipWriter::new(file); let opts: SimpleFileOptions = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); writer .start_file("../etc/passwd", opts) .expect("start file"); writer.write_all(b"not really").expect("write entry"); writer.finish().expect("finish zip"); let target = TempDir::new().expect("target"); let err = import_theme_into(&zip_path, target.path()).expect_err("expected error"); assert!(matches!(err, ImportError::ZipSlip { .. }), "got {err:?}"); assert!( target.path().read_dir().unwrap().next().is_none(), "target untouched" ); } #[test] fn manifest_referenced_face_missing_from_archive_is_rejected() { let scratch = TempDir::new().expect("scratch"); let zip_path = scratch.path().join("missing_file.zip"); // Manifest is well-formed and validates, but we omit one of // the SVGs from the archive to trigger the MissingFile path. let manifest = full_manifest("missing_file_theme"); let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()) .expect("ron serialise"); let mut entries: Vec<(String, Vec)> = Vec::new(); entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes())); entries.push(("back.svg".to_owned(), TEST_SVG.to_vec())); for k in CardKey::all() { // Skip hearts_ace.svg so its manifest entry has no // matching archive payload. if k.manifest_name() == "hearts_ace" { continue; } entries.push(( format!("faces/{}.svg", k.manifest_name()), TEST_SVG.to_vec(), )); } let entries_ref: Vec<(&str, Vec)> = entries .iter() .map(|(k, v)| (k.as_str(), v.clone())) .collect(); write_zip(&zip_path, &entries_ref); let target = TempDir::new().expect("target"); let err = import_theme_into(&zip_path, target.path()).expect_err("expected error"); match err { ImportError::MissingFile { missing } => { assert!( missing.contains("hearts_ace"), "missing path should mention hearts_ace, got {missing}" ); } other => panic!("expected MissingFile, got {other:?}"), } assert!( !target.path().join("missing_file_theme").exists(), "target untouched" ); } #[test] fn id_collision_with_existing_dir_is_rejected() { let scratch = TempDir::new().expect("scratch"); let zip_path = scratch.path().join("collide.zip"); write_valid_zip(&zip_path, "duplicate"); let target = TempDir::new().expect("target"); // Pre-populate the destination so the import path runs // straight into the IdCollision check. fs::create_dir_all(target.path().join("duplicate")).unwrap(); let err = import_theme_into(&zip_path, target.path()).expect_err("expected error"); match err { ImportError::IdCollision { id } => assert_eq!(id, "duplicate"), other => panic!("expected IdCollision, got {other:?}"), } // Existing dir must still be there, but no extracted files // should have been copied into it. let existing = target.path().join("duplicate"); assert!(existing.is_dir()); assert!(!existing.join("theme.ron").exists()); } }