Skip to main content

tauri_plugin_hotswap/
assets.rs

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
7/// Shared handle to the active asset directory.
8/// Updated by `applyUpdate()` / `activateUpdate()` / `rollback()` commands
9/// so that `window.location.reload()` immediately serves the new assets
10/// without requiring an app restart.
11pub type AssetDirHandle = Arc<RwLock<Option<PathBuf>>>;
12
13/// Custom Assets implementation that checks the filesystem first,
14/// then falls back to the embedded assets from the binary.
15pub struct HotswapAssets<R: Runtime> {
16    /// The original embedded assets compiled into the binary.
17    embedded: Box<dyn tauri::Assets<R>>,
18    /// Shared handle to the active asset directory.
19    /// `None` → serve embedded assets only.
20    /// `Some(path)` → try filesystem first, fall back to embedded.
21    ota_dir: AssetDirHandle,
22}
23
24impl<R: Runtime> HotswapAssets<R> {
25    /// Create a new asset provider.
26    ///
27    /// The `ota_dir` handle is shared with `HotswapState` so that commands
28    /// can update the active directory at runtime.
29    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
41/// Validate an asset key and return the sanitized relative path.
42/// Returns None if the key contains unsafe components.
43fn 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    // Reject any component that is not a normal filename
50    let path = Path::new(relative);
51    for component in path.components() {
52        match component {
53            Component::Normal(_) => {}
54            // Reject ParentDir (..), CurDir (.), RootDir (/), Prefix (C:\)
55            _ => return None,
56        }
57    }
58
59    Some(relative)
60}
61
62/// Try to read a file from a directory. Returns the contents if found.
63fn 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        // Read the current ota_dir from the shared handle.
80        // This is re-read on every request so that apply/activate/rollback
81        // take effect immediately without an app restart.
82        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                    // Try exact path
92                    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                    // Try {path}.html fallback (matches Tauri's resolution chain)
98                    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                    // Try {path}/index.html fallback
105                    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        // Fall back to embedded assets
122        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
134/// Empty assets implementation used only as a temporary placeholder
135/// during the context asset swap. Never serves actual requests.
136pub(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    // --- try_read tests ---
190
191    #[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    // --- HotswapAssets::get() tests ---
223
224    /// Mock embedded assets that returns known data for specific keys.
225    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        // OTA content should be Cow::Owned
277        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        // No "about" file, but "about.html" exists
286        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        // No "docs" file, no "docs.html", but "docs/index.html" exists
300        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        // OTA dir exists but has nothing
315
316        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        // Embedded returns Cow::Borrowed
322        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        // Even if a file existed via traversal, it should be rejected
331        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        // validate_asset_key rejects "..", so it skips OTA and goes to embedded
338        // AssetKey::from normalizes the path, so embedded lookup may or may not match.
339        // The key point is that OTA is NOT consulted for traversal paths.
340        // We verify by checking we don't get "ota-secret".
341        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        // First request serves from v1
368        let result = assets.get(&asset_key("app.js"));
369        assert_eq!(result.unwrap().as_ref(), b"version-1");
370
371        // Swap the live_asset_dir to v2 (simulates activate/apply)
372        {
373            let mut guard = handle.write().unwrap();
374            *guard = Some(dir_v2.path().to_path_buf());
375        }
376
377        // Next request should serve from v2 without recreating HotswapAssets
378        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        // Initially serves from OTA
391        let result = assets.get(&asset_key("app.js"));
392        assert_eq!(result.unwrap().as_ref(), b"ota-content");
393
394        // Swap to None (simulates rollback to embedded)
395        {
396            let mut guard = handle.write().unwrap();
397            *guard = None;
398        }
399
400        // Now should serve from embedded
401        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        // Both "about" and "about.html" exist — exact match should win
409        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}