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)).await;
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).await;
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    async fn touch(
170        &self,
171        db: &Arc<RusqliteService>,
172        item_id: &str,
173        image_type: &str,
174        tag: Option<&str>,
175    ) {
176        let query = match tag {
177            Some(tag) => Query::with_params(
178                "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
179                 WHERE item_id = ? AND image_type = ? AND image_tag = ?",
180                vec![
181                    QueryParam::String(item_id.to_string()),
182                    QueryParam::String(image_type.to_string()),
183                    QueryParam::String(tag.to_string()),
184                ],
185            ),
186            None => Query::with_params(
187                "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
188                 WHERE item_id = ? AND image_type = ?",
189                vec![
190                    QueryParam::String(item_id.to_string()),
191                    QueryParam::String(image_type.to_string()),
192                ],
193            ),
194        };
195        let _ = db.execute(query).await;
196    }
197
198    /// Save thumbnail to cache
199    ///
200    /// TRACES: | DR-210 | UT-204
201    // The arguments are the cache key (item/type/tag) plus the payload and its
202    // dimensions — all independent scalars borrowed from the caller. A parameter
203    // struct would only move the same list one level down.
204    #[allow(clippy::too_many_arguments)]
205    pub async fn save_thumbnail(
206        &self,
207        db: Arc<RusqliteService>,
208        item_id: &str,
209        image_type: &str,
210        tag: &str,
211        data: &[u8],
212        width: Option<i32>,
213        height: Option<i32>,
214    ) -> Result<PathBuf, String> {
215        if !self.is_enabled() {
216            return Err("Thumbnail caching is disabled".to_string());
217        }
218
219        // Generate safe filename. The database keeps the *raw* key below, so the
220        // lookup in `get_cached_path` still matches what the caller asks for; only
221        // the on-disk name is sanitised, and the row records where it landed.
222        let filename = format!(
223            "{}_{}_{}.jpg",
224            safe_component(item_id),
225            safe_component(image_type),
226            safe_component(tag)
227        );
228        let file_path = self.resolve_in_cache_dir(&filename)?;
229
230        // Ensure we have space (evict LRU items if needed)
231        self.ensure_space(db.clone(), data.len() as u64).await?;
232
233        // Write file to disk
234        std::fs::write(&file_path, data).map_err(|e| format!("Failed to write file: {}", e))?;
235
236        // Insert/update database entry
237        let query = Query::with_params(
238            "INSERT INTO thumbnails (item_id, image_type, image_tag, file_path, width, height, file_size, last_accessed, cached_at)
239             VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
240             ON CONFLICT(item_id, image_type, image_tag) DO UPDATE SET
241                file_path = excluded.file_path,
242                width = excluded.width,
243                height = excluded.height,
244                file_size = excluded.file_size,
245                last_accessed = CURRENT_TIMESTAMP",
246            vec![
247                QueryParam::String(item_id.to_string()),
248                QueryParam::String(image_type.to_string()),
249                QueryParam::String(tag.to_string()),
250                QueryParam::String(file_path.to_string_lossy().to_string()),
251                width.map(QueryParam::Int).unwrap_or(QueryParam::Null),
252                height.map(QueryParam::Int).unwrap_or(QueryParam::Null),
253                QueryParam::Int64(data.len() as i64),
254            ],
255        );
256
257        db.execute(query)
258            .await
259            .map_err(|e| format!("Failed to save to database: {}", e))?;
260
261        Ok(file_path)
262    }
263
264    /// Ensure there's enough space by evicting LRU items if needed
265    async fn ensure_space(
266        &self,
267        db: Arc<RusqliteService>,
268        needed_bytes: u64,
269    ) -> Result<(), String> {
270        let max_size = {
271            let config = self.config.lock().map_err(|e| e.to_string())?;
272            config.max_size_bytes
273        };
274
275        if max_size == 0 {
276            return Ok(()); // Unlimited
277        }
278
279        let current_size = self.get_cache_size(db.clone()).await;
280
281        if current_size + needed_bytes <= max_size {
282            return Ok(()); // Enough space
283        }
284
285        // Need to evict LRU items
286        let to_free = (current_size + needed_bytes).saturating_sub(max_size);
287        self.evict_lru(db, to_free).await
288    }
289
290    /// Evict least recently used items to free up space
291    async fn evict_lru(&self, db: Arc<RusqliteService>, to_free: u64) -> Result<(), String> {
292        let mut freed: u64 = 0;
293
294        // Get items ordered by last_accessed (oldest first)
295        let query = Query::new(
296            "SELECT id, file_path, file_size FROM thumbnails
297             ORDER BY last_accessed ASC",
298        );
299
300        let items: Vec<(i64, String, i64)> = db
301            .query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
302            .await
303            .map_err(|e| e.to_string())?;
304
305        for (id, path, size) in items {
306            if freed >= to_free {
307                break;
308            }
309
310            // Delete file from disk
311            let _ = std::fs::remove_file(&path);
312
313            // Delete from database
314            let delete_query = Query::with_params(
315                "DELETE FROM thumbnails WHERE id = ?",
316                vec![QueryParam::Int64(id)],
317            );
318            let _ = db.execute(delete_query).await;
319
320            freed += size as u64;
321        }
322
323        Ok(())
324    }
325
326    /// Get current total cache size in bytes
327    pub async fn get_cache_size(&self, db: Arc<RusqliteService>) -> u64 {
328        let query = Query::new("SELECT COALESCE(SUM(file_size), 0) FROM thumbnails");
329
330        db.query_one(query, |row| row.get::<_, i64>(0))
331            .await
332            .unwrap_or(0) as u64
333    }
334
335    /// Get count of cached items
336    pub async fn get_item_count(&self, db: Arc<RusqliteService>) -> i64 {
337        let query = Query::new("SELECT COUNT(*) FROM thumbnails");
338
339        db.query_one(query, |row| row.get(0)).await.unwrap_or(0)
340    }
341
342    /// Get the current cache limit in bytes
343    pub async fn get_limit(&self, db: Arc<RusqliteService>) -> u64 {
344        let query = Query::with_params(
345            "SELECT value FROM cache_settings WHERE key = ?",
346            vec![QueryParam::String("image_cache_limit_bytes".to_string())],
347        );
348
349        db.query_optional(query, |row| row.get::<_, String>(0))
350            .await
351            .ok()
352            .flatten()
353            .and_then(|s| s.parse().ok())
354            .unwrap_or(1024 * 1024 * 1024) // 1GB default
355    }
356
357    /// Set the cache limit in bytes
358    pub async fn set_limit(
359        &self,
360        db: Arc<RusqliteService>,
361        limit_bytes: u64,
362    ) -> Result<(), String> {
363        // Update database setting
364        let query = Query::with_params(
365            "INSERT OR REPLACE INTO cache_settings (key, value, updated_at)
366             VALUES (?, ?, CURRENT_TIMESTAMP)",
367            vec![
368                QueryParam::String("image_cache_limit_bytes".to_string()),
369                QueryParam::String(limit_bytes.to_string()),
370            ],
371        );
372
373        db.execute(query)
374            .await
375            .map_err(|e| format!("Failed to update setting: {}", e))?;
376
377        // Update in-memory config
378        if let Ok(mut config) = self.config.lock() {
379            config.max_size_bytes = limit_bytes;
380        }
381
382        // If new limit is lower, evict to comply
383        let current_size = self.get_cache_size(db.clone()).await;
384        if limit_bytes > 0 && current_size > limit_bytes {
385            let to_free = current_size - limit_bytes;
386            self.evict_lru(db, to_free).await?;
387        }
388
389        Ok(())
390    }
391
392    /// Clear all cached thumbnails
393    pub async fn clear_cache(&self, db: Arc<RusqliteService>) -> Result<(), String> {
394        // Get all file paths
395        let query = Query::new("SELECT file_path FROM thumbnails");
396
397        let paths: Vec<String> = db
398            .query_many(query, |row| row.get(0))
399            .await
400            .map_err(|e| e.to_string())?;
401
402        // Delete files from disk
403        for path in paths {
404            let _ = std::fs::remove_file(&path);
405        }
406
407        // Clear database
408        let delete_query = Query::new("DELETE FROM thumbnails");
409        db.execute(delete_query)
410            .await
411            .map_err(|e| format!("Failed to clear database: {}", e))?;
412
413        Ok(())
414    }
415
416    /// Delete cached thumbnail for a specific item
417    pub async fn delete_item(&self, db: Arc<RusqliteService>, item_id: &str) -> Result<(), String> {
418        // Get file paths for this item
419        let query = Query::with_params(
420            "SELECT file_path FROM thumbnails WHERE item_id = ?",
421            vec![QueryParam::String(item_id.to_string())],
422        );
423
424        let paths: Vec<String> = db
425            .query_many(query, |row| row.get(0))
426            .await
427            .map_err(|e| e.to_string())?;
428
429        // Delete files
430        for path in paths {
431            let _ = std::fs::remove_file(&path);
432        }
433
434        // Delete from database
435        let delete_query = Query::with_params(
436            "DELETE FROM thumbnails WHERE item_id = ?",
437            vec![QueryParam::String(item_id.to_string())],
438        );
439        db.execute(delete_query)
440            .await
441            .map_err(|e| format!("Failed to delete from database: {}", e))?;
442
443        Ok(())
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::storage::db_service::RusqliteService;
451    use rusqlite::Connection;
452    use std::sync::{Arc, Mutex};
453    use tempfile::TempDir;
454
455    fn setup_test_db() -> (Arc<RusqliteService>, TempDir) {
456        let temp_dir = TempDir::new().unwrap();
457        let conn = Connection::open_in_memory().unwrap();
458
459        // Create tables
460        conn.execute(
461            "CREATE TABLE thumbnails (
462                id INTEGER PRIMARY KEY AUTOINCREMENT,
463                item_id TEXT NOT NULL,
464                image_type TEXT NOT NULL,
465                image_tag TEXT NOT NULL,
466                file_path TEXT NOT NULL,
467                width INTEGER,
468                height INTEGER,
469                file_size INTEGER DEFAULT 0,
470                cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
471                last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,
472                UNIQUE(item_id, image_type, image_tag)
473            )",
474            [],
475        )
476        .unwrap();
477
478        conn.execute(
479            "CREATE TABLE cache_settings (
480                key TEXT PRIMARY KEY,
481                value TEXT NOT NULL,
482                updated_at TEXT DEFAULT CURRENT_TIMESTAMP
483            )",
484            [],
485        )
486        .unwrap();
487
488        let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
489        (db_service, temp_dir)
490    }
491
492    #[test]
493    fn test_cache_creation() {
494        let temp_dir = TempDir::new().unwrap();
495        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
496
497        assert!(cache.cache_dir.exists());
498        assert!(cache.is_enabled());
499    }
500
501    #[tokio::test]
502    async fn test_save_and_get_thumbnail() {
503        let (conn, temp_dir) = setup_test_db();
504        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
505
506        // Save a thumbnail
507        let data = b"fake image data";
508        let path = cache
509            .save_thumbnail(
510                conn.clone(),
511                "item1",
512                "Primary",
513                "tag1",
514                data,
515                Some(100),
516                Some(100),
517            )
518            .await
519            .unwrap();
520
521        assert!(path.exists());
522
523        // Get cached path
524        let cached = cache
525            .get_cached_path(conn.clone(), "item1", "Primary", "tag1")
526            .await;
527        assert!(cached.is_some());
528        assert_eq!(cached.unwrap(), path);
529    }
530
531    #[tokio::test]
532    async fn test_cache_miss() {
533        let (conn, temp_dir) = setup_test_db();
534        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
535
536        let cached = cache
537            .get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1")
538            .await;
539        assert!(cached.is_none());
540    }
541
542    #[tokio::test]
543    async fn test_lru_eviction() {
544        let (conn, temp_dir) = setup_test_db();
545        let config = CacheConfig {
546            max_size_bytes: 100, // Very small limit
547            ..Default::default()
548        };
549        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), config);
550
551        // Add items that exceed limit
552        let data = vec![0u8; 60];
553        cache
554            .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
555            .await
556            .unwrap();
557
558        // Second item should trigger eviction
559        cache
560            .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
561            .await
562            .unwrap();
563
564        // First item should be evicted
565        let cached = cache
566            .get_cached_path(conn.clone(), "item1", "Primary", "tag1")
567            .await;
568        assert!(cached.is_none());
569
570        // Second item should exist
571        let cached = cache
572            .get_cached_path(conn.clone(), "item2", "Primary", "tag2")
573            .await;
574        assert!(cached.is_some());
575    }
576
577    #[tokio::test]
578    async fn test_clear_cache() {
579        let (conn, temp_dir) = setup_test_db();
580        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
581
582        let data = b"fake image data";
583        cache
584            .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, None, None)
585            .await
586            .unwrap();
587        cache
588            .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", data, None, None)
589            .await
590            .unwrap();
591
592        assert_eq!(cache.get_item_count(conn.clone()).await, 2);
593
594        cache.clear_cache(conn.clone()).await.unwrap();
595
596        assert_eq!(cache.get_item_count(conn.clone()).await, 0);
597        assert_eq!(cache.get_cache_size(conn.clone()).await, 0);
598    }
599
600    #[tokio::test]
601    async fn test_set_limit() {
602        let (conn, temp_dir) = setup_test_db();
603        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
604
605        // Save some thumbnails
606        let data = vec![0u8; 50];
607        cache
608            .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
609            .await
610            .unwrap();
611        cache
612            .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
613            .await
614            .unwrap();
615
616        // Set a limit smaller than current size
617        cache.set_limit(conn.clone(), 60).await.unwrap();
618
619        // Some items should be evicted
620        let size = cache.get_cache_size(conn.clone()).await;
621        assert!(size <= 60);
622    }
623
624    /// A traversal-style `item_id` must not steer a cache write out of the cache
625    /// directory. The id reaches `save_thumbnail` verbatim from Jellyfin JSON, so
626    /// it is not ours to trust.
627    ///
628    /// TRACES: | DR-210 | UT-204
629    #[tokio::test]
630    async fn test_save_thumbnail_confines_traversal_item_id() {
631        let (conn, temp_dir) = setup_test_db();
632        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
633
634        let result = cache
635            .save_thumbnail(
636                conn.clone(),
637                "../evil",
638                "Primary",
639                "tag1",
640                b"fake image data",
641                None,
642                None,
643            )
644            .await;
645
646        // Where `../evil` lands if `..` is honoured: the cache dir's parent.
647        let escaped = temp_dir.path().join("evil_Primary_tag1.jpg");
648        assert!(
649            !escaped.exists(),
650            "wrote outside the cache directory: {}",
651            escaped.display()
652        );
653
654        // Refusing is acceptable; succeeding is too, as long as it stayed inside.
655        if let Ok(path) = result {
656            assert!(
657                path.starts_with(&cache.cache_dir) && !path.to_string_lossy().contains(".."),
658                "returned a path outside the cache directory: {}",
659                path.display()
660            );
661            assert!(path.exists());
662        }
663    }
664
665    /// `Path::join` discards the base when handed an absolute path, so an
666    /// absolute `item_id` would otherwise pick the write location outright.
667    ///
668    /// TRACES: | DR-210 | UT-204
669    #[tokio::test]
670    async fn test_save_thumbnail_confines_absolute_item_id() {
671        let (conn, temp_dir) = setup_test_db();
672        let outside = TempDir::new().unwrap();
673        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
674
675        let absolute_id = outside.path().join("evil").to_string_lossy().to_string();
676        let result = cache
677            .save_thumbnail(
678                conn.clone(),
679                &absolute_id,
680                "Primary",
681                "tag1",
682                b"fake image data",
683                None,
684                None,
685            )
686            .await;
687
688        let escaped = outside.path().join("evil_Primary_tag1.jpg");
689        assert!(
690            !escaped.exists(),
691            "wrote outside the cache directory: {}",
692            escaped.display()
693        );
694
695        if let Ok(path) = result {
696            assert!(
697                path.starts_with(&cache.cache_dir),
698                "returned a path outside the cache directory: {}",
699                path.display()
700            );
701        }
702    }
703
704    /// `image_type` is equally unsanitised, and equally caller-supplied.
705    ///
706    /// TRACES: | DR-210 | UT-204
707    #[tokio::test]
708    async fn test_save_thumbnail_confines_traversal_image_type() {
709        let (conn, temp_dir) = setup_test_db();
710        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
711
712        let path = cache
713            .save_thumbnail(
714                conn.clone(),
715                "item1",
716                "../Primary",
717                "tag1",
718                b"fake image data",
719                None,
720                None,
721            )
722            .await
723            .expect("a malformed image_type should be sanitised, not break caching");
724
725        // The file belongs directly in the cache dir — no separator from the
726        // image type may survive into the filename.
727        assert_eq!(path.parent(), Some(cache.cache_dir.as_path()));
728        assert!(path.exists());
729    }
730
731    /// Ids, types and tags that were already filesystem-safe — the overwhelming
732    /// majority — keep producing exactly the filename they did before, so
733    /// sanitising does not orphan existing cache entries.
734    ///
735    /// TRACES: | DR-210 | UT-204
736    #[tokio::test]
737    async fn test_save_thumbnail_filename_unchanged_for_safe_values() {
738        let (conn, temp_dir) = setup_test_db();
739        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
740
741        let path = cache
742            .save_thumbnail(
743                conn.clone(),
744                "a1b2c3d4e5f60718293a4b5c6d7e8f90",
745                "Primary",
746                "abcdef0123456789",
747                b"fake image data",
748                None,
749                None,
750            )
751            .await
752            .unwrap();
753
754        assert_eq!(
755            path,
756            cache
757                .cache_dir
758                .join("a1b2c3d4e5f60718293a4b5c6d7e8f90_Primary_abcdef0123456789.jpg")
759        );
760    }
761
762    /// Sanitising the filename must not desynchronise the write path from the
763    /// read path: the database keeps the raw key and the resolved path, so a
764    /// lookup after a save still finds the file that was written.
765    ///
766    /// TRACES: | DR-210 | UT-204
767    #[tokio::test]
768    async fn test_traversal_item_id_still_round_trips() {
769        let (conn, temp_dir) = setup_test_db();
770        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
771
772        let saved = cache
773            .save_thumbnail(
774                conn.clone(),
775                "../evil",
776                "Primary",
777                "tag1",
778                b"fake image data",
779                None,
780                None,
781            )
782            .await
783            .unwrap();
784
785        let cached = cache
786            .get_cached_path(conn.clone(), "../evil", "Primary", "tag1")
787            .await;
788        assert_eq!(cached, Some(saved));
789    }
790}