Skip to main content

jellytau_lib/thumbnail/
cache.rs

1//! Thumbnail cache manager with LRU eviction
2
3use log::error;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::Mutex;
7
8use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
9
10/// Configuration for the thumbnail cache
11#[derive(Debug, Clone)]
12pub struct CacheConfig {
13    /// Maximum cache size in bytes (0 = unlimited)
14    pub max_size_bytes: u64,
15    /// Subdirectory name for cached thumbnails
16    pub cache_subdir: String,
17    /// Whether caching is enabled
18    pub enabled: bool,
19}
20
21impl Default for CacheConfig {
22    fn default() -> Self {
23        Self {
24            max_size_bytes: 1024 * 1024 * 1024, // 1GB
25            cache_subdir: "thumbnails".to_string(),
26            enabled: true,
27        }
28    }
29}
30
31/// Make one part of a cache filename safe to put in a path.
32///
33/// Every part of the name comes from the caller — the item id and image type are
34/// taken verbatim from Jellyfin JSON — so none of them may contribute a path
35/// separator or a `..`. The rule is the one the image tag has always used
36/// (non-alphanumerics become `_`), applied to all three parts, so values that
37/// were already safe keep producing exactly the filename they did before.
38///
39/// TRACES: | DR-210 | UT-204
40fn safe_component(value: &str) -> String {
41    value.replace(|c: char| !c.is_alphanumeric(), "_")
42}
43
44/// Thumbnail cache with LRU eviction
45pub struct ThumbnailCache {
46    config: Arc<Mutex<CacheConfig>>,
47    cache_dir: PathBuf,
48}
49
50impl ThumbnailCache {
51    /// Create a new thumbnail cache
52    pub fn new(app_data_dir: PathBuf, config: CacheConfig) -> Self {
53        let cache_dir = app_data_dir.join(&config.cache_subdir);
54
55        // Create cache directory if it doesn't exist
56        if let Err(e) = std::fs::create_dir_all(&cache_dir) {
57            error!("Failed to create thumbnail cache directory: {}", e);
58        }
59
60        Self {
61            config: Arc::new(Mutex::new(config)),
62            cache_dir,
63        }
64    }
65
66    /// Resolve a cache filename against the cache directory, refusing anything
67    /// that lands outside it.
68    ///
69    /// `..` is folded away lexically rather than through `canonicalize`, so a
70    /// file that does not exist yet still resolves — the same approach as
71    /// `media_server::resolve_path`. `safe_component` should already have made an
72    /// escape impossible; this is the check at the point of use.
73    ///
74    /// TRACES: | DR-210 | UT-204
75    fn resolve_in_cache_dir(&self, filename: &str) -> Result<PathBuf, String> {
76        let mut resolved = self.cache_dir.clone();
77        for part in Path::new(filename).components() {
78            match part {
79                std::path::Component::ParentDir => {
80                    resolved.pop();
81                }
82                std::path::Component::CurDir => {}
83                other => resolved.push(other),
84            }
85        }
86
87        if resolved.starts_with(&self.cache_dir) {
88            Ok(resolved)
89        } else {
90            Err("Thumbnail path escapes the cache directory".to_string())
91        }
92    }
93
94    /// Check if caching is enabled
95    pub fn is_enabled(&self) -> bool {
96        self.config.lock().map(|c| c.enabled).unwrap_or(true)
97    }
98
99    /// Get cached thumbnail path, or None if not cached
100    /// Updates last_accessed timestamp for LRU tracking
101    pub async fn get_cached_path(
102        &self,
103        db: Arc<RusqliteService>,
104        item_id: &str,
105        image_type: &str,
106        tag: &str,
107    ) -> Option<PathBuf> {
108        // Primary lookup: exact (item_id, image_type, tag) match.
109        let exact = Query::with_params(
110            "SELECT file_path FROM thumbnails
111             WHERE item_id = ? AND image_type = ? AND image_tag = ?",
112            vec![
113                QueryParam::String(item_id.to_string()),
114                QueryParam::String(image_type.to_string()),
115                QueryParam::String(tag.to_string()),
116            ],
117        );
118
119        if let Ok(Some(path_str)) = db
120            .query_optional(exact, |row| row.get::<_, String>(0))
121            .await
122        {
123            let path = PathBuf::from(&path_str);
124            if path.exists() {
125                self.touch(&db, item_id, image_type, Some(tag));
126                return Some(path);
127            }
128            // File gone — drop the stale row and fall through to the tag-agnostic
129            // lookup below (another cached image for this item may still exist).
130            let _ = db
131                .execute(Query::with_params(
132                    "DELETE FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?",
133                    vec![
134                        QueryParam::String(item_id.to_string()),
135                        QueryParam::String(image_type.to_string()),
136                        QueryParam::String(tag.to_string()),
137                    ],
138                ))
139                .await;
140        }
141
142        // Fallback: any cached image for this item + type, newest first. The
143        // `image_tag` is a cache-busting version, and callers don't always pass
144        // the same tag the image was cached under — e.g. the mini player asks for
145        // the album image using the *track's* primary_image_tag. Ignoring the tag
146        // here lets those still resolve offline instead of hitting the server.
147        let any_tag = Query::with_params(
148            "SELECT file_path FROM thumbnails
149             WHERE item_id = ? AND image_type = ?
150             ORDER BY cached_at DESC LIMIT 1",
151            vec![
152                QueryParam::String(item_id.to_string()),
153                QueryParam::String(image_type.to_string()),
154            ],
155        );
156
157        let path_str: String = db.query_optional(any_tag, |row| row.get(0)).await.ok()??;
158        let path = PathBuf::from(&path_str);
159        if path.exists() {
160            self.touch(&db, item_id, image_type, None);
161            Some(path)
162        } else {
163            None
164        }
165    }
166
167    /// Update `last_accessed` for LRU tracking. When `tag` is `Some`, scope to
168    /// that exact row; when `None`, touch every row for the item + type.
169    fn touch(&self, db: &Arc<RusqliteService>, item_id: &str, image_type: &str, tag: Option<&str>) {
170        let query = match tag {
171            Some(tag) => Query::with_params(
172                "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
173                 WHERE item_id = ? AND image_type = ? AND image_tag = ?",
174                vec![
175                    QueryParam::String(item_id.to_string()),
176                    QueryParam::String(image_type.to_string()),
177                    QueryParam::String(tag.to_string()),
178                ],
179            ),
180            None => Query::with_params(
181                "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
182                 WHERE item_id = ? AND image_type = ?",
183                vec![
184                    QueryParam::String(item_id.to_string()),
185                    QueryParam::String(image_type.to_string()),
186                ],
187            ),
188        };
189        // Detached: an LRU timestamp is bookkeeping, and a grid scroll does
190        // one of these per visible poster — awaiting each write held every
191        // thumbnail lookup behind the writer queue.
192        db.execute_detached(query);
193    }
194
195    /// Save thumbnail to cache
196    ///
197    /// TRACES: | DR-210 | UT-204
198    // The arguments are the cache key (item/type/tag) plus the payload and its
199    // dimensions — all independent scalars borrowed from the caller. A parameter
200    // struct would only move the same list one level down.
201    #[allow(clippy::too_many_arguments)]
202    pub async fn save_thumbnail(
203        &self,
204        db: Arc<RusqliteService>,
205        item_id: &str,
206        image_type: &str,
207        tag: &str,
208        data: &[u8],
209        width: Option<i32>,
210        height: Option<i32>,
211    ) -> Result<PathBuf, String> {
212        if !self.is_enabled() {
213            return Err("Thumbnail caching is disabled".to_string());
214        }
215
216        // Generate safe filename. The database keeps the *raw* key below, so the
217        // lookup in `get_cached_path` still matches what the caller asks for; only
218        // the on-disk name is sanitised, and the row records where it landed.
219        let filename = format!(
220            "{}_{}_{}.jpg",
221            safe_component(item_id),
222            safe_component(image_type),
223            safe_component(tag)
224        );
225        let file_path = self.resolve_in_cache_dir(&filename)?;
226
227        // Ensure we have space (evict LRU items if needed)
228        self.ensure_space(db.clone(), data.len() as u64).await?;
229
230        // Write file to disk
231        std::fs::write(&file_path, data).map_err(|e| format!("Failed to write file: {}", e))?;
232
233        // Insert/update database entry
234        let query = Query::with_params(
235            "INSERT INTO thumbnails (item_id, image_type, image_tag, file_path, width, height, file_size, last_accessed, cached_at)
236             VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
237             ON CONFLICT(item_id, image_type, image_tag) DO UPDATE SET
238                file_path = excluded.file_path,
239                width = excluded.width,
240                height = excluded.height,
241                file_size = excluded.file_size,
242                last_accessed = CURRENT_TIMESTAMP",
243            vec![
244                QueryParam::String(item_id.to_string()),
245                QueryParam::String(image_type.to_string()),
246                QueryParam::String(tag.to_string()),
247                QueryParam::String(file_path.to_string_lossy().to_string()),
248                width.map(QueryParam::Int).unwrap_or(QueryParam::Null),
249                height.map(QueryParam::Int).unwrap_or(QueryParam::Null),
250                QueryParam::Int64(data.len() as i64),
251            ],
252        );
253
254        db.execute(query)
255            .await
256            .map_err(|e| format!("Failed to save to database: {}", e))?;
257
258        Ok(file_path)
259    }
260
261    /// Ensure there's enough space by evicting LRU items if needed
262    async fn ensure_space(
263        &self,
264        db: Arc<RusqliteService>,
265        needed_bytes: u64,
266    ) -> Result<(), String> {
267        let max_size = {
268            let config = self.config.lock().map_err(|e| e.to_string())?;
269            config.max_size_bytes
270        };
271
272        if max_size == 0 {
273            return Ok(()); // Unlimited
274        }
275
276        let current_size = self.get_cache_size(db.clone()).await;
277
278        if current_size + needed_bytes <= max_size {
279            return Ok(()); // Enough space
280        }
281
282        // Need to evict LRU items
283        let to_free = (current_size + needed_bytes).saturating_sub(max_size);
284        self.evict_lru(db, to_free).await
285    }
286
287    /// Evict least recently used items to free up space
288    async fn evict_lru(&self, db: Arc<RusqliteService>, to_free: u64) -> Result<(), String> {
289        let mut freed: u64 = 0;
290
291        // Get items ordered by last_accessed (oldest first)
292        let query = Query::new(
293            "SELECT id, file_path, file_size FROM thumbnails
294             ORDER BY last_accessed ASC",
295        );
296
297        let items: Vec<(i64, String, i64)> = db
298            .query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
299            .await
300            .map_err(|e| e.to_string())?;
301
302        for (id, path, size) in items {
303            if freed >= to_free {
304                break;
305            }
306
307            // Delete file from disk
308            let _ = std::fs::remove_file(&path);
309
310            // Delete from database
311            let delete_query = Query::with_params(
312                "DELETE FROM thumbnails WHERE id = ?",
313                vec![QueryParam::Int64(id)],
314            );
315            let _ = db.execute(delete_query).await;
316
317            freed += size as u64;
318        }
319
320        Ok(())
321    }
322
323    /// Get current total cache size in bytes
324    pub async fn get_cache_size(&self, db: Arc<RusqliteService>) -> u64 {
325        let query = Query::new("SELECT COALESCE(SUM(file_size), 0) FROM thumbnails");
326
327        db.query_one(query, |row| row.get::<_, i64>(0))
328            .await
329            .unwrap_or(0) as u64
330    }
331
332    /// Get count of cached items
333    pub async fn get_item_count(&self, db: Arc<RusqliteService>) -> i64 {
334        let query = Query::new("SELECT COUNT(*) FROM thumbnails");
335
336        db.query_one(query, |row| row.get(0)).await.unwrap_or(0)
337    }
338
339    /// Get the current cache limit in bytes
340    pub async fn get_limit(&self, db: Arc<RusqliteService>) -> u64 {
341        let query = Query::with_params(
342            "SELECT value FROM cache_settings WHERE key = ?",
343            vec![QueryParam::String("image_cache_limit_bytes".to_string())],
344        );
345
346        db.query_optional(query, |row| row.get::<_, String>(0))
347            .await
348            .ok()
349            .flatten()
350            .and_then(|s| s.parse().ok())
351            .unwrap_or(1024 * 1024 * 1024) // 1GB default
352    }
353
354    /// Set the cache limit in bytes
355    pub async fn set_limit(
356        &self,
357        db: Arc<RusqliteService>,
358        limit_bytes: u64,
359    ) -> Result<(), String> {
360        // Update database setting
361        let query = Query::with_params(
362            "INSERT OR REPLACE INTO cache_settings (key, value, updated_at)
363             VALUES (?, ?, CURRENT_TIMESTAMP)",
364            vec![
365                QueryParam::String("image_cache_limit_bytes".to_string()),
366                QueryParam::String(limit_bytes.to_string()),
367            ],
368        );
369
370        db.execute(query)
371            .await
372            .map_err(|e| format!("Failed to update setting: {}", e))?;
373
374        // Update in-memory config
375        if let Ok(mut config) = self.config.lock() {
376            config.max_size_bytes = limit_bytes;
377        }
378
379        // If new limit is lower, evict to comply
380        let current_size = self.get_cache_size(db.clone()).await;
381        if limit_bytes > 0 && current_size > limit_bytes {
382            let to_free = current_size - limit_bytes;
383            self.evict_lru(db, to_free).await?;
384        }
385
386        Ok(())
387    }
388
389    /// Clear all cached thumbnails
390    pub async fn clear_cache(&self, db: Arc<RusqliteService>) -> Result<(), String> {
391        // Get all file paths
392        let query = Query::new("SELECT file_path FROM thumbnails");
393
394        let paths: Vec<String> = db
395            .query_many(query, |row| row.get(0))
396            .await
397            .map_err(|e| e.to_string())?;
398
399        // Delete files from disk
400        for path in paths {
401            let _ = std::fs::remove_file(&path);
402        }
403
404        // Clear database
405        let delete_query = Query::new("DELETE FROM thumbnails");
406        db.execute(delete_query)
407            .await
408            .map_err(|e| format!("Failed to clear database: {}", e))?;
409
410        Ok(())
411    }
412
413    /// Delete cached thumbnail for a specific item
414    pub async fn delete_item(&self, db: Arc<RusqliteService>, item_id: &str) -> Result<(), String> {
415        // Get file paths for this item
416        let query = Query::with_params(
417            "SELECT file_path FROM thumbnails WHERE item_id = ?",
418            vec![QueryParam::String(item_id.to_string())],
419        );
420
421        let paths: Vec<String> = db
422            .query_many(query, |row| row.get(0))
423            .await
424            .map_err(|e| e.to_string())?;
425
426        // Delete files
427        for path in paths {
428            let _ = std::fs::remove_file(&path);
429        }
430
431        // Delete from database
432        let delete_query = Query::with_params(
433            "DELETE FROM thumbnails WHERE item_id = ?",
434            vec![QueryParam::String(item_id.to_string())],
435        );
436        db.execute(delete_query)
437            .await
438            .map_err(|e| format!("Failed to delete from database: {}", e))?;
439
440        Ok(())
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::storage::db_service::RusqliteService;
448    use rusqlite::Connection;
449    use std::sync::{Arc, Mutex};
450    use tempfile::TempDir;
451
452    fn setup_test_db() -> (Arc<RusqliteService>, TempDir) {
453        let temp_dir = TempDir::new().unwrap();
454        let conn = Connection::open_in_memory().unwrap();
455
456        // Create tables
457        conn.execute(
458            "CREATE TABLE thumbnails (
459                id INTEGER PRIMARY KEY AUTOINCREMENT,
460                item_id TEXT NOT NULL,
461                image_type TEXT NOT NULL,
462                image_tag TEXT NOT NULL,
463                file_path TEXT NOT NULL,
464                width INTEGER,
465                height INTEGER,
466                file_size INTEGER DEFAULT 0,
467                cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
468                last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,
469                UNIQUE(item_id, image_type, image_tag)
470            )",
471            [],
472        )
473        .unwrap();
474
475        conn.execute(
476            "CREATE TABLE cache_settings (
477                key TEXT PRIMARY KEY,
478                value TEXT NOT NULL,
479                updated_at TEXT DEFAULT CURRENT_TIMESTAMP
480            )",
481            [],
482        )
483        .unwrap();
484
485        let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
486        (db_service, temp_dir)
487    }
488
489    #[test]
490    fn test_cache_creation() {
491        let temp_dir = TempDir::new().unwrap();
492        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
493
494        assert!(cache.cache_dir.exists());
495        assert!(cache.is_enabled());
496    }
497
498    #[tokio::test]
499    async fn test_save_and_get_thumbnail() {
500        let (conn, temp_dir) = setup_test_db();
501        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
502
503        // Save a thumbnail
504        let data = b"fake image data";
505        let path = cache
506            .save_thumbnail(
507                conn.clone(),
508                "item1",
509                "Primary",
510                "tag1",
511                data,
512                Some(100),
513                Some(100),
514            )
515            .await
516            .unwrap();
517
518        assert!(path.exists());
519
520        // Get cached path
521        let cached = cache
522            .get_cached_path(conn.clone(), "item1", "Primary", "tag1")
523            .await;
524        assert!(cached.is_some());
525        assert_eq!(cached.unwrap(), path);
526    }
527
528    #[tokio::test]
529    async fn test_cache_miss() {
530        let (conn, temp_dir) = setup_test_db();
531        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
532
533        let cached = cache
534            .get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1")
535            .await;
536        assert!(cached.is_none());
537    }
538
539    #[tokio::test]
540    async fn test_lru_eviction() {
541        let (conn, temp_dir) = setup_test_db();
542        let config = CacheConfig {
543            max_size_bytes: 100, // Very small limit
544            ..Default::default()
545        };
546        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), config);
547
548        // Add items that exceed limit
549        let data = vec![0u8; 60];
550        cache
551            .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
552            .await
553            .unwrap();
554
555        // Second item should trigger eviction
556        cache
557            .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
558            .await
559            .unwrap();
560
561        // First item should be evicted
562        let cached = cache
563            .get_cached_path(conn.clone(), "item1", "Primary", "tag1")
564            .await;
565        assert!(cached.is_none());
566
567        // Second item should exist
568        let cached = cache
569            .get_cached_path(conn.clone(), "item2", "Primary", "tag2")
570            .await;
571        assert!(cached.is_some());
572    }
573
574    #[tokio::test]
575    async fn test_clear_cache() {
576        let (conn, temp_dir) = setup_test_db();
577        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
578
579        let data = b"fake image data";
580        cache
581            .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, None, None)
582            .await
583            .unwrap();
584        cache
585            .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", data, None, None)
586            .await
587            .unwrap();
588
589        assert_eq!(cache.get_item_count(conn.clone()).await, 2);
590
591        cache.clear_cache(conn.clone()).await.unwrap();
592
593        assert_eq!(cache.get_item_count(conn.clone()).await, 0);
594        assert_eq!(cache.get_cache_size(conn.clone()).await, 0);
595    }
596
597    #[tokio::test]
598    async fn test_set_limit() {
599        let (conn, temp_dir) = setup_test_db();
600        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
601
602        // Save some thumbnails
603        let data = vec![0u8; 50];
604        cache
605            .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
606            .await
607            .unwrap();
608        cache
609            .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
610            .await
611            .unwrap();
612
613        // Set a limit smaller than current size
614        cache.set_limit(conn.clone(), 60).await.unwrap();
615
616        // Some items should be evicted
617        let size = cache.get_cache_size(conn.clone()).await;
618        assert!(size <= 60);
619    }
620
621    /// A traversal-style `item_id` must not steer a cache write out of the cache
622    /// directory. The id reaches `save_thumbnail` verbatim from Jellyfin JSON, so
623    /// it is not ours to trust.
624    ///
625    /// TRACES: | DR-210 | UT-204
626    #[tokio::test]
627    async fn test_save_thumbnail_confines_traversal_item_id() {
628        let (conn, temp_dir) = setup_test_db();
629        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
630
631        let result = cache
632            .save_thumbnail(
633                conn.clone(),
634                "../evil",
635                "Primary",
636                "tag1",
637                b"fake image data",
638                None,
639                None,
640            )
641            .await;
642
643        // Where `../evil` lands if `..` is honoured: the cache dir's parent.
644        let escaped = temp_dir.path().join("evil_Primary_tag1.jpg");
645        assert!(
646            !escaped.exists(),
647            "wrote outside the cache directory: {}",
648            escaped.display()
649        );
650
651        // Refusing is acceptable; succeeding is too, as long as it stayed inside.
652        if let Ok(path) = result {
653            assert!(
654                path.starts_with(&cache.cache_dir) && !path.to_string_lossy().contains(".."),
655                "returned a path outside the cache directory: {}",
656                path.display()
657            );
658            assert!(path.exists());
659        }
660    }
661
662    /// `Path::join` discards the base when handed an absolute path, so an
663    /// absolute `item_id` would otherwise pick the write location outright.
664    ///
665    /// TRACES: | DR-210 | UT-204
666    #[tokio::test]
667    async fn test_save_thumbnail_confines_absolute_item_id() {
668        let (conn, temp_dir) = setup_test_db();
669        let outside = TempDir::new().unwrap();
670        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
671
672        let absolute_id = outside.path().join("evil").to_string_lossy().to_string();
673        let result = cache
674            .save_thumbnail(
675                conn.clone(),
676                &absolute_id,
677                "Primary",
678                "tag1",
679                b"fake image data",
680                None,
681                None,
682            )
683            .await;
684
685        let escaped = outside.path().join("evil_Primary_tag1.jpg");
686        assert!(
687            !escaped.exists(),
688            "wrote outside the cache directory: {}",
689            escaped.display()
690        );
691
692        if let Ok(path) = result {
693            assert!(
694                path.starts_with(&cache.cache_dir),
695                "returned a path outside the cache directory: {}",
696                path.display()
697            );
698        }
699    }
700
701    /// `image_type` is equally unsanitised, and equally caller-supplied.
702    ///
703    /// TRACES: | DR-210 | UT-204
704    #[tokio::test]
705    async fn test_save_thumbnail_confines_traversal_image_type() {
706        let (conn, temp_dir) = setup_test_db();
707        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
708
709        let path = cache
710            .save_thumbnail(
711                conn.clone(),
712                "item1",
713                "../Primary",
714                "tag1",
715                b"fake image data",
716                None,
717                None,
718            )
719            .await
720            .expect("a malformed image_type should be sanitised, not break caching");
721
722        // The file belongs directly in the cache dir — no separator from the
723        // image type may survive into the filename.
724        assert_eq!(path.parent(), Some(cache.cache_dir.as_path()));
725        assert!(path.exists());
726    }
727
728    /// Ids, types and tags that were already filesystem-safe — the overwhelming
729    /// majority — keep producing exactly the filename they did before, so
730    /// sanitising does not orphan existing cache entries.
731    ///
732    /// TRACES: | DR-210 | UT-204
733    #[tokio::test]
734    async fn test_save_thumbnail_filename_unchanged_for_safe_values() {
735        let (conn, temp_dir) = setup_test_db();
736        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
737
738        let path = cache
739            .save_thumbnail(
740                conn.clone(),
741                "a1b2c3d4e5f60718293a4b5c6d7e8f90",
742                "Primary",
743                "abcdef0123456789",
744                b"fake image data",
745                None,
746                None,
747            )
748            .await
749            .unwrap();
750
751        assert_eq!(
752            path,
753            cache
754                .cache_dir
755                .join("a1b2c3d4e5f60718293a4b5c6d7e8f90_Primary_abcdef0123456789.jpg")
756        );
757    }
758
759    /// Sanitising the filename must not desynchronise the write path from the
760    /// read path: the database keeps the raw key and the resolved path, so a
761    /// lookup after a save still finds the file that was written.
762    ///
763    /// TRACES: | DR-210 | UT-204
764    #[tokio::test]
765    async fn test_traversal_item_id_still_round_trips() {
766        let (conn, temp_dir) = setup_test_db();
767        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
768
769        let saved = cache
770            .save_thumbnail(
771                conn.clone(),
772                "../evil",
773                "Primary",
774                "tag1",
775                b"fake image data",
776                None,
777                None,
778            )
779            .await
780            .unwrap();
781
782        let cached = cache
783            .get_cached_path(conn.clone(), "../evil", "Primary", "tag1")
784            .await;
785        assert_eq!(cached, Some(saved));
786    }
787}