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}