use crate::AppState; use base64::{engine::general_purpose::STANDARD, Engine}; use std::path::{Component, Path, PathBuf}; use tauri::State; const FORMAT_ERROR: &str = "Use a PNG or JPEG image"; fn safe_kind(kind: &str) -> String { kind.chars() .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') .collect::() .to_lowercase() } /// Identify an image by its magic bytes. react-pdf only renders PNG and JPEG and /// silently drops everything else, so nothing else is accepted. /// Returns (extension, MIME type). pub fn sniff_image(bytes: &[u8]) -> Result<(&'static str, &'static str), String> { if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { Ok(("png", "image/png")) } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { Ok(("jpg", "image/jpeg")) } else { Err(FORMAT_ERROR.to_string()) } } fn assets_dir(data_dir: &Path) -> Result { let dir = data_dir.join("assets"); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; Ok(dir) } /// Resolve a stored asset path (relative to the data dir, or a legacy absolute one) /// to a real file inside `/assets`. `..`, symlink escapes and anything /// outside the assets directory are rejected. pub fn resolve_asset(data_dir: &Path, path: &str) -> Result { let raw = Path::new(path); if path.trim().is_empty() { return Err("Asset path is empty".into()); } if raw.components().any(|c| matches!(c, Component::ParentDir)) { return Err("Asset path must not contain '..'".into()); } let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) }; let root = data_dir .join("assets") .canonicalize() .map_err(|e| format!("Asset store is unavailable: {e}"))?; let canonical = full .canonicalize() .map_err(|e| format!("Could not open asset {path}: {e}"))?; if canonical.starts_with(&root) && canonical != root { Ok(canonical) } else { Err("Asset path is outside the asset store".into()) } } /// The form stored in the database: `assets/` relative to the data dir, with /// forward slashes on every platform. pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result { let canonical = resolve_asset(data_dir, path)?; let base = data_dir.canonicalize().map_err(|e| e.to_string())?; let rel = canonical.strip_prefix(&base).map_err(|e| e.to_string())?; Ok(rel .components() .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect::>() .join("/")) } fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result { let (ext, _) = sniff_image(bytes)?; let dir = assets_dir(data_dir)?; let file = format!("{}-{}.{}", safe_kind(kind), uuid::Uuid::new_v4().simple(), ext); std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?; Ok(format!("assets/{file}")) } /// Write `assets/` unless it already exists. For content-addressed derivatives, where the /// same name always means the same bytes. The name must be a plain `[a-z0-9._-]` file name. pub fn write_named_asset(data_dir: &Path, file_name: &str, bytes: &[u8]) -> Result { let plain = !file_name.is_empty() && !file_name.starts_with('.') && file_name .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | '_')); if !plain { return Err(format!("Invalid asset name: {file_name}")); } let target = assets_dir(data_dir)?.join(file_name); if !target.exists() { std::fs::write(&target, bytes).map_err(|e| e.to_string())?; } Ok(format!("assets/{file_name}")) } fn asset_data_uri(data_dir: &Path, path: &str) -> Result { let file = resolve_asset(data_dir, path)?; let bytes = std::fs::read(&file).map_err(|e| e.to_string())?; let (_, mime) = sniff_image(&bytes)?; Ok(format!("data:{};base64,{}", mime, STANDARD.encode(bytes))) } fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> { let raw = Path::new(path); let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) }; // Removing something that is already gone is fine, but an escape attempt is not. if std::fs::symlink_metadata(&full).is_err() { return Ok(()); } let target = resolve_asset(data_dir, path)?; std::fs::remove_file(target).map_err(|e| e.to_string()) } /// Copy a user-picked file into the app's asset store and return the stored path. #[tauri::command] pub fn import_asset( state: State, source_path: String, kind: String, ) -> Result { let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?; write_asset(&state.data_dir, &kind, &bytes) } /// Used when the frontend already holds the bytes (base64) instead of a path. /// The file name is ignored: the type comes from the bytes. #[tauri::command] pub fn save_asset_bytes( state: State, kind: String, #[allow(unused_variables)] file_name: String, data_base64: String, ) -> Result { let bytes = STANDARD .decode(data_base64.as_bytes()) .map_err(|e| e.to_string())?; write_asset(&state.data_dir, &kind, &bytes) } /// Read a stored asset back as a data URI so it can be embedded in the PDF. #[tauri::command] pub fn read_asset_data_uri(state: State, path: String) -> Result { asset_data_uri(&state.data_dir, &path) } #[tauri::command] pub fn remove_asset(state: State, path: String) -> Result<(), String> { remove_asset_file(&state.data_dir, &path) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0]; const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, b'J', b'F', b'I', b'F']; #[test] fn sniffing_accepts_only_png_and_jpeg() { assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png")); assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg")); assert_eq!(sniff_image(b"GIF89a....").unwrap_err(), "Use a PNG or JPEG image"); assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err()); assert!(sniff_image(b"").is_err()); assert!(sniff_image(b"").is_err()); } #[test] fn write_ignores_the_name_and_stores_a_relative_path() { let dir = tempdir().unwrap(); let stored = write_asset(dir.path(), "Logo", JPEG).unwrap(); assert!(stored.starts_with("assets/logo-") && stored.ends_with(".jpg"), "{stored}"); assert!(dir.path().join(&stored).is_file()); assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err()); let uri = asset_data_uri(dir.path(), &stored).unwrap(); assert!(uri.starts_with("data:image/jpeg;base64,")); } #[test] fn named_assets_are_plain_names_and_not_overwritten() { let dir = tempdir().unwrap(); let stored = write_named_asset(dir.path(), "logo-ab12cd34-print.png", b"one").unwrap(); assert_eq!(stored, "assets/logo-ab12cd34-print.png"); write_named_asset(dir.path(), "logo-ab12cd34-print.png", b"two").unwrap(); assert_eq!(std::fs::read(dir.path().join(&stored)).unwrap(), b"one"); for bad in ["", ".hidden", "../x.png", "a/b.png", "A.png", "x y.png"] { assert!(write_named_asset(dir.path(), bad, b"x").is_err(), "{bad}"); } } #[test] fn data_uri_mime_comes_from_the_bytes_not_the_extension() { let dir = tempdir().unwrap(); let assets = dir.path().join("assets"); std::fs::create_dir_all(&assets).unwrap(); std::fs::write(assets.join("liar.jpg"), PNG).unwrap(); let uri = asset_data_uri(dir.path(), "assets/liar.jpg").unwrap(); assert!(uri.starts_with("data:image/png;base64,")); } #[test] fn resolver_accepts_relative_and_inside_absolute_paths() { let dir = tempdir().unwrap(); let stored = write_asset(dir.path(), "logo", PNG).unwrap(); let by_relative = resolve_asset(dir.path(), &stored).unwrap(); let absolute = dir.path().join(&stored); let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap(); assert_eq!(by_relative, by_absolute); assert_eq!(relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(), stored); } #[test] fn resolver_rejects_escapes() { let dir = tempdir().unwrap(); let stored = write_asset(dir.path(), "logo", PNG).unwrap(); std::fs::write(dir.path().join("voiced.db"), b"secret").unwrap(); let outside = tempdir().unwrap(); std::fs::write(outside.path().join("x.png"), PNG).unwrap(); assert!(resolve_asset(dir.path(), "../voiced.db").is_err()); assert!(resolve_asset(dir.path(), "assets/../voiced.db").is_err()); assert!(resolve_asset(dir.path(), "voiced.db").is_err()); assert!(resolve_asset(dir.path(), "assets").is_err()); assert!(resolve_asset(dir.path(), "").is_err()); let abs_outside = outside.path().join("x.png"); assert!(resolve_asset(dir.path(), abs_outside.to_str().unwrap()).is_err()); let abs_db = dir.path().join("voiced.db"); assert!(resolve_asset(dir.path(), abs_db.to_str().unwrap()).is_err()); assert!(resolve_asset(dir.path(), "assets/missing.png").is_err()); assert!(resolve_asset(dir.path(), &stored).is_ok()); } #[cfg(unix)] #[test] fn resolver_rejects_symlink_escapes() { let dir = tempdir().unwrap(); let _ = write_asset(dir.path(), "logo", PNG).unwrap(); let outside = tempdir().unwrap(); let target = outside.path().join("x.png"); std::fs::write(&target, PNG).unwrap(); std::os::unix::fs::symlink(&target, dir.path().join("assets/link.png")).unwrap(); std::os::unix::fs::symlink(outside.path(), dir.path().join("assets/dir")).unwrap(); assert!(resolve_asset(dir.path(), "assets/link.png").is_err()); assert!(resolve_asset(dir.path(), "assets/dir/x.png").is_err()); assert!(remove_asset_file(dir.path(), "assets/link.png").is_err()); assert!(target.exists()); } #[test] fn remove_deletes_inside_and_tolerates_missing() { let dir = tempdir().unwrap(); let stored = write_asset(dir.path(), "logo", PNG).unwrap(); remove_asset_file(dir.path(), &stored).unwrap(); assert!(!dir.path().join(&stored).exists()); remove_asset_file(dir.path(), &stored).unwrap(); std::fs::write(dir.path().join("voiced.db"), b"x").unwrap(); assert!(remove_asset_file(dir.path(), "voiced.db").is_err()); assert!(dir.path().join("voiced.db").exists()); } }