use std::borrow::Cow;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, RwLock};
use tauri::utils::assets::{AssetKey, AssetsIter, CspHash};
use tauri::Runtime;
pub type AssetDirHandle = Arc<RwLock<Option<PathBuf>>>;
pub struct HotswapAssets<R: Runtime> {
embedded: Box<dyn tauri::Assets<R>>,
ota_dir: AssetDirHandle,
}
impl<R: Runtime> HotswapAssets<R> {
pub fn new(embedded: Box<dyn tauri::Assets<R>>, ota_dir: AssetDirHandle) -> Self {
if let Ok(guard) = ota_dir.read() {
if let Some(ref path) = *guard {
log::info!("[hotswap] Serving assets from: {}", path.display());
} else {
log::info!("[hotswap] No cached assets found, using embedded assets");
}
}
Self { embedded, ota_dir }
}
}
fn validate_asset_key(key: &str) -> Option<&str> {
let relative = key.trim_start_matches('/');
if relative.is_empty() {
return None;
}
let path = Path::new(relative);
for component in path.components() {
match component {
Component::Normal(_) => {}
_ => return None,
}
}
Some(relative)
}
fn try_read(dir: &Path, relative: &str) -> Option<Vec<u8>> {
let path = dir.join(relative);
if path.is_file() {
std::fs::read(&path).ok()
} else {
None
}
}
impl<R: Runtime> tauri::Assets<R> for HotswapAssets<R> {
fn setup(&self, app: &tauri::App<R>) {
self.embedded.setup(app);
}
fn get(&self, key: &AssetKey) -> Option<Cow<'_, [u8]>> {
if let Ok(guard) = self.ota_dir.read() {
if let Some(ref dir) = *guard {
if let Some(relative) = validate_asset_key(key.as_ref()) {
if let Some(data) = try_read(dir, relative) {
return Some(Cow::Owned(data));
}
let html_key = format!("{}.html", relative);
if let Some(data) = try_read(dir, &html_key) {
return Some(Cow::Owned(data));
}
let index_key = format!("{}/index.html", relative);
if let Some(data) = try_read(dir, &index_key) {
return Some(Cow::Owned(data));
}
}
}
}
self.embedded.get(key)
}
fn iter(&self) -> Box<AssetsIter<'_>> {
self.embedded.iter()
}
fn csp_hashes(&self, html_path: &AssetKey) -> Box<dyn Iterator<Item = CspHash<'_>> + '_> {
self.embedded.csp_hashes(html_path)
}
}
pub(crate) struct EmptyAssets;
impl<R: Runtime> tauri::Assets<R> for EmptyAssets {
fn get(&self, _key: &AssetKey) -> Option<Cow<'_, [u8]>> {
None
}
fn iter(&self) -> Box<AssetsIter<'_>> {
Box::new(std::iter::empty())
}
fn csp_hashes(&self, _html_path: &AssetKey) -> Box<dyn Iterator<Item = CspHash<'_>> + '_> {
Box::new(std::iter::empty())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_asset_key_normal() {
assert_eq!(validate_asset_key("/index.html"), Some("index.html"));
assert_eq!(validate_asset_key("index.html"), Some("index.html"));
}
#[test]
fn test_validate_asset_key_nested() {
assert_eq!(
validate_asset_key("/assets/css/style.css"),
Some("assets/css/style.css")
);
}
#[test]
fn test_validate_asset_key_rejects_traversal() {
assert!(validate_asset_key("/../../../etc/passwd").is_none());
assert!(validate_asset_key("/foo/../../etc/passwd").is_none());
assert!(validate_asset_key("../escape").is_none());
}
#[test]
fn test_validate_asset_key_rejects_empty() {
assert!(validate_asset_key("/").is_none());
assert!(validate_asset_key("").is_none());
}
#[test]
fn test_validate_asset_key_rejects_curdir() {
assert!(validate_asset_key("./file.txt").is_none());
}
}