Files
Voiced/src-tauri/src/commands/assets.rs
T
xavierk 2ade618fb5 Add PDF archive store, raw-body IPC and export file helpers
The searchable PDF can be archived content-addressed under the local data
dir with its sha256 and layout fingerprint; a different PDF for an archived
invoice is refused and reads verify the hash. Export writes go through a
raw-body command that remembers the last folder; reveal-in-folder and open
fall back to xdg-open / explorer. Adds a Windows-safe export file name
builder. Migration M4 adds the archive columns and last export dir.
2026-10-04 05:54:26 +05:30

236 lines
9.4 KiB
Rust

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::<String>()
.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<PathBuf, String> {
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 `<data_dir>/assets`. `..`, symlink escapes and anything
/// outside the assets directory are rejected.
pub fn resolve_asset(data_dir: &Path, path: &str) -> Result<PathBuf, String> {
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/<file>` relative to the data dir, with
/// forward slashes on every platform.
pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result<String, String> {
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::<Vec<_>>()
.join("/"))
}
fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, String> {
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}"))
}
fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
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<AppState>,
source_path: String,
kind: String,
) -> Result<String, String> {
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<AppState>,
kind: String,
#[allow(unused_variables)] file_name: String,
data_base64: String,
) -> Result<String, String> {
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<AppState>, path: String) -> Result<String, String> {
asset_data_uri(&state.data_dir, &path)
}
#[tauri::command]
pub fn remove_asset(state: State<AppState>, 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"<svg xmlns='http://www.w3.org/2000/svg'/>").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 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());
}
}