1use std::borrow::Cow;
2use std::path::{Component, Path, PathBuf};
3use std::sync::{Arc, RwLock};
4use tauri::utils::assets::{AssetKey, AssetsIter, CspHash};
5use tauri::Runtime;
6
7pub type AssetDirHandle = Arc<RwLock<Option<PathBuf>>>;
12
13pub struct HotswapAssets<R: Runtime> {
16 embedded: Box<dyn tauri::Assets<R>>,
18 ota_dir: AssetDirHandle,
22}
23
24impl<R: Runtime> HotswapAssets<R> {
25 pub fn new(embedded: Box<dyn tauri::Assets<R>>, ota_dir: AssetDirHandle) -> Self {
30 if let Ok(guard) = ota_dir.read() {
31 if let Some(ref path) = *guard {
32 log::info!("[hotswap] Serving assets from: {}", path.display());
33 } else {
34 log::info!("[hotswap] No cached assets found, using embedded assets");
35 }
36 }
37 Self { embedded, ota_dir }
38 }
39}
40
41fn validate_asset_key(key: &str) -> Option<&str> {
44 let relative = key.trim_start_matches('/');
45 if relative.is_empty() {
46 return None;
47 }
48
49 let path = Path::new(relative);
51 for component in path.components() {
52 match component {
53 Component::Normal(_) => {}
54 _ => return None,
56 }
57 }
58
59 Some(relative)
60}
61
62fn try_read(dir: &Path, relative: &str) -> Option<Vec<u8>> {
64 let path = dir.join(relative);
65 if path.is_file() {
66 std::fs::read(&path).ok()
67 } else {
68 None
69 }
70}
71
72impl<R: Runtime> tauri::Assets<R> for HotswapAssets<R> {
73 fn setup(&self, app: &tauri::App<R>) {
74 self.embedded.setup(app);
75 }
76
77 fn get(&self, key: &AssetKey) -> Option<Cow<'_, [u8]>> {
78 let key_str = key.as_ref();
79 if let Ok(guard) = self.ota_dir.read() {
83 if let Some(ref dir) = *guard {
84 log::debug!(
85 "[hotswap] Asset request: key={:?}, dir={}",
86 key_str,
87 dir.display()
88 );
89 if let Some(relative) = validate_asset_key(key_str) {
90 log::debug!("[hotswap] Validated key -> relative={:?}", relative);
91 if let Some(data) = try_read(dir, relative) {
93 log::debug!("[hotswap] Serving from OTA: {}", relative);
94 return Some(Cow::Owned(data));
95 }
96
97 let html_key = format!("{}.html", relative);
99 if let Some(data) = try_read(dir, &html_key) {
100 log::debug!("[hotswap] Serving from OTA (html fallback): {}", html_key);
101 return Some(Cow::Owned(data));
102 }
103
104 let index_key = format!("{}/index.html", relative);
106 if let Some(data) = try_read(dir, &index_key) {
107 log::debug!("[hotswap] Serving from OTA (index fallback): {}", index_key);
108 return Some(Cow::Owned(data));
109 }
110
111 log::debug!(
112 "[hotswap] OTA miss, falling back to embedded: {:?}",
113 relative
114 );
115 } else {
116 log::debug!("[hotswap] Key validation failed for: {:?}", key_str);
117 }
118 }
119 }
120
121 self.embedded.get(key)
123 }
124
125 fn iter(&self) -> Box<AssetsIter<'_>> {
126 self.embedded.iter()
127 }
128
129 fn csp_hashes(&self, html_path: &AssetKey) -> Box<dyn Iterator<Item = CspHash<'_>> + '_> {
130 self.embedded.csp_hashes(html_path)
131 }
132}
133
134pub(crate) struct EmptyAssets;
137
138impl<R: Runtime> tauri::Assets<R> for EmptyAssets {
139 fn get(&self, _key: &AssetKey) -> Option<Cow<'_, [u8]>> {
140 None
141 }
142
143 fn iter(&self) -> Box<AssetsIter<'_>> {
144 Box::new(std::iter::empty())
145 }
146
147 fn csp_hashes(&self, _html_path: &AssetKey) -> Box<dyn Iterator<Item = CspHash<'_>> + '_> {
148 Box::new(std::iter::empty())
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use tauri::Assets;
156
157 #[test]
158 fn test_validate_asset_key_normal() {
159 assert_eq!(validate_asset_key("/index.html"), Some("index.html"));
160 assert_eq!(validate_asset_key("index.html"), Some("index.html"));
161 }
162
163 #[test]
164 fn test_validate_asset_key_nested() {
165 assert_eq!(
166 validate_asset_key("/assets/css/style.css"),
167 Some("assets/css/style.css")
168 );
169 }
170
171 #[test]
172 fn test_validate_asset_key_rejects_traversal() {
173 assert!(validate_asset_key("/../../../etc/passwd").is_none());
174 assert!(validate_asset_key("/foo/../../etc/passwd").is_none());
175 assert!(validate_asset_key("../escape").is_none());
176 }
177
178 #[test]
179 fn test_validate_asset_key_rejects_empty() {
180 assert!(validate_asset_key("/").is_none());
181 assert!(validate_asset_key("").is_none());
182 }
183
184 #[test]
185 fn test_validate_asset_key_rejects_curdir() {
186 assert!(validate_asset_key("./file.txt").is_none());
187 }
188
189 #[test]
192 fn test_try_read_existing_file() {
193 let dir = tempfile::tempdir().unwrap();
194 std::fs::write(dir.path().join("hello.txt"), b"world").unwrap();
195 assert_eq!(try_read(dir.path(), "hello.txt"), Some(b"world".to_vec()));
196 }
197
198 #[test]
199 fn test_try_read_missing_file() {
200 let dir = tempfile::tempdir().unwrap();
201 assert_eq!(try_read(dir.path(), "nope.txt"), None);
202 }
203
204 #[test]
205 fn test_try_read_directory_not_file() {
206 let dir = tempfile::tempdir().unwrap();
207 std::fs::create_dir(dir.path().join("subdir")).unwrap();
208 assert_eq!(try_read(dir.path(), "subdir"), None);
209 }
210
211 #[test]
212 fn test_try_read_nested_path() {
213 let dir = tempfile::tempdir().unwrap();
214 std::fs::create_dir_all(dir.path().join("assets/css")).unwrap();
215 std::fs::write(dir.path().join("assets/css/style.css"), b"body{}").unwrap();
216 assert_eq!(
217 try_read(dir.path(), "assets/css/style.css"),
218 Some(b"body{}".to_vec())
219 );
220 }
221
222 struct MockAssets {
226 entries: std::collections::HashMap<String, Vec<u8>>,
227 }
228
229 impl MockAssets {
230 fn new(entries: Vec<(&str, &[u8])>) -> Self {
231 Self {
232 entries: entries
233 .into_iter()
234 .map(|(k, v)| (k.to_string(), v.to_vec()))
235 .collect(),
236 }
237 }
238 }
239
240 impl<R: Runtime> tauri::Assets<R> for MockAssets {
241 fn get(&self, key: &AssetKey) -> Option<Cow<'_, [u8]>> {
242 self.entries
243 .get(key.as_ref())
244 .map(|v| Cow::Borrowed(v.as_slice()))
245 }
246
247 fn iter(&self) -> Box<AssetsIter<'_>> {
248 Box::new(std::iter::empty())
249 }
250
251 fn csp_hashes(&self, _html_path: &AssetKey) -> Box<dyn Iterator<Item = CspHash<'_>> + '_> {
252 Box::new(std::iter::empty())
253 }
254 }
255
256 type TestAssets = HotswapAssets<tauri::test::MockRuntime>;
257
258 fn make_assets(ota_dir: AssetDirHandle, embedded_entries: Vec<(&str, &[u8])>) -> TestAssets {
259 HotswapAssets::new(Box::new(MockAssets::new(embedded_entries)), ota_dir)
260 }
261
262 fn asset_key(s: &str) -> AssetKey {
263 AssetKey::from(Path::new(s))
264 }
265
266 #[test]
267 fn test_get_serves_from_ota_when_file_exists() {
268 let dir = tempfile::tempdir().unwrap();
269 std::fs::write(dir.path().join("app.js"), b"ota-content").unwrap();
270
271 let handle: AssetDirHandle = Arc::new(RwLock::new(Some(dir.path().to_path_buf())));
272 let assets = make_assets(handle, vec![("/app.js", b"embedded-content")]);
273
274 let result = assets.get(&asset_key("app.js"));
275 assert!(result.is_some());
276 let cow = result.unwrap();
278 assert!(matches!(cow, Cow::Owned(_)));
279 assert_eq!(cow.as_ref(), b"ota-content");
280 }
281
282 #[test]
283 fn test_get_html_fallback() {
284 let dir = tempfile::tempdir().unwrap();
285 std::fs::write(dir.path().join("about.html"), b"ota-about-html").unwrap();
287
288 let handle: AssetDirHandle = Arc::new(RwLock::new(Some(dir.path().to_path_buf())));
289 let assets = make_assets(handle, vec![]);
290
291 let result = assets.get(&asset_key("about"));
292 assert!(result.is_some());
293 assert_eq!(result.unwrap().as_ref(), b"ota-about-html");
294 }
295
296 #[test]
297 fn test_get_index_html_fallback() {
298 let dir = tempfile::tempdir().unwrap();
299 std::fs::create_dir(dir.path().join("docs")).unwrap();
301 std::fs::write(dir.path().join("docs/index.html"), b"ota-docs-index").unwrap();
302
303 let handle: AssetDirHandle = Arc::new(RwLock::new(Some(dir.path().to_path_buf())));
304 let assets = make_assets(handle, vec![]);
305
306 let result = assets.get(&asset_key("docs"));
307 assert!(result.is_some());
308 assert_eq!(result.unwrap().as_ref(), b"ota-docs-index");
309 }
310
311 #[test]
312 fn test_get_all_fallbacks_miss_serves_embedded() {
313 let dir = tempfile::tempdir().unwrap();
314 let handle: AssetDirHandle = Arc::new(RwLock::new(Some(dir.path().to_path_buf())));
317 let assets = make_assets(handle, vec![("/missing.js", b"from-embedded")]);
318
319 let result = assets.get(&asset_key("missing.js"));
320 assert!(result.is_some());
321 let cow = result.unwrap();
323 assert!(matches!(cow, Cow::Borrowed(_)));
324 assert_eq!(cow.as_ref(), b"from-embedded");
325 }
326
327 #[test]
328 fn test_get_invalid_key_skips_ota() {
329 let dir = tempfile::tempdir().unwrap();
330 std::fs::write(dir.path().join("secret.txt"), b"ota-secret").unwrap();
332
333 let handle: AssetDirHandle = Arc::new(RwLock::new(Some(dir.path().to_path_buf())));
334 let assets = make_assets(handle, vec![("../secret.txt", b"embedded-fallback")]);
335
336 let result = assets.get(&asset_key("../secret.txt"));
337 if let Some(cow) = result {
342 assert_ne!(cow.as_ref(), b"ota-secret" as &[u8]);
343 }
344 }
345
346 #[test]
347 fn test_get_ota_dir_none_serves_embedded() {
348 let handle: AssetDirHandle = Arc::new(RwLock::new(None));
349 let assets = make_assets(handle, vec![("/index.html", b"embedded-index")]);
350
351 let result = assets.get(&asset_key("index.html"));
352 assert!(result.is_some());
353 assert_eq!(result.unwrap().as_ref(), b"embedded-index");
354 }
355
356 #[test]
357 fn test_get_runtime_swap_of_ota_dir() {
358 let dir_v1 = tempfile::tempdir().unwrap();
359 std::fs::write(dir_v1.path().join("app.js"), b"version-1").unwrap();
360
361 let dir_v2 = tempfile::tempdir().unwrap();
362 std::fs::write(dir_v2.path().join("app.js"), b"version-2").unwrap();
363
364 let handle: AssetDirHandle = Arc::new(RwLock::new(Some(dir_v1.path().to_path_buf())));
365 let assets = make_assets(handle.clone(), vec![]);
366
367 let result = assets.get(&asset_key("app.js"));
369 assert_eq!(result.unwrap().as_ref(), b"version-1");
370
371 {
373 let mut guard = handle.write().unwrap();
374 *guard = Some(dir_v2.path().to_path_buf());
375 }
376
377 let result = assets.get(&asset_key("app.js"));
379 assert_eq!(result.unwrap().as_ref(), b"version-2");
380 }
381
382 #[test]
383 fn test_get_runtime_swap_to_none() {
384 let dir = tempfile::tempdir().unwrap();
385 std::fs::write(dir.path().join("app.js"), b"ota-content").unwrap();
386
387 let handle: AssetDirHandle = Arc::new(RwLock::new(Some(dir.path().to_path_buf())));
388 let assets = make_assets(handle.clone(), vec![("/app.js", b"embedded-content")]);
389
390 let result = assets.get(&asset_key("app.js"));
392 assert_eq!(result.unwrap().as_ref(), b"ota-content");
393
394 {
396 let mut guard = handle.write().unwrap();
397 *guard = None;
398 }
399
400 let result = assets.get(&asset_key("app.js"));
402 assert_eq!(result.unwrap().as_ref(), b"embedded-content");
403 }
404
405 #[test]
406 fn test_get_fallback_priority_exact_over_html() {
407 let dir = tempfile::tempdir().unwrap();
408 std::fs::write(dir.path().join("about"), b"exact-match").unwrap();
410 std::fs::write(dir.path().join("about.html"), b"html-fallback").unwrap();
411
412 let handle: AssetDirHandle = Arc::new(RwLock::new(Some(dir.path().to_path_buf())));
413 let assets = make_assets(handle, vec![]);
414
415 let result = assets.get(&asset_key("about"));
416 assert_eq!(result.unwrap().as_ref(), b"exact-match");
417 }
418}