Files
jellytau/src-tauri/src/thumbnail/cache.rs
T
dtourolle e12f0065a6 fix(thumbnails): confine cache writes to the cache directory
The thumbnail cache built its filename from `item_id`, `image_type` and
`tag`, but only sanitised the tag. `Path::join` neither folds `..` nor
keeps its base when handed an absolute path, so a malformed id could
place a cache write outside the cache directory.

Sanitise all three parts through one helper using the rule the tag
already used (non-alphanumerics become `_`), so ids and types that were
already safe keep producing exactly the same filename, and resolve the
result against the cache dir with a lexical `..` fold plus a
`starts_with` check, modelled on `media_server::resolve_path`.

The database still stores the raw key and the resolved path, so the
lookup in `get_cached_path` keeps matching what the caller asks for.
2026-08-20 19:56:23 +02:00

791 lines
26 KiB
Rust

//! Thumbnail cache manager with LRU eviction
use log::error;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::Mutex;
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
/// Configuration for the thumbnail cache
#[derive(Debug, Clone)]
pub struct CacheConfig {
/// Maximum cache size in bytes (0 = unlimited)
pub max_size_bytes: u64,
/// Subdirectory name for cached thumbnails
pub cache_subdir: String,
/// Whether caching is enabled
pub enabled: bool,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
max_size_bytes: 1024 * 1024 * 1024, // 1GB
cache_subdir: "thumbnails".to_string(),
enabled: true,
}
}
}
/// Make one part of a cache filename safe to put in a path.
///
/// Every part of the name comes from the caller — the item id and image type are
/// taken verbatim from Jellyfin JSON — so none of them may contribute a path
/// separator or a `..`. The rule is the one the image tag has always used
/// (non-alphanumerics become `_`), applied to all three parts, so values that
/// were already safe keep producing exactly the filename they did before.
///
/// TRACES: | DR-210 | UT-204
fn safe_component(value: &str) -> String {
value.replace(|c: char| !c.is_alphanumeric(), "_")
}
/// Thumbnail cache with LRU eviction
pub struct ThumbnailCache {
config: Arc<Mutex<CacheConfig>>,
cache_dir: PathBuf,
}
impl ThumbnailCache {
/// Create a new thumbnail cache
pub fn new(app_data_dir: PathBuf, config: CacheConfig) -> Self {
let cache_dir = app_data_dir.join(&config.cache_subdir);
// Create cache directory if it doesn't exist
if let Err(e) = std::fs::create_dir_all(&cache_dir) {
error!("Failed to create thumbnail cache directory: {}", e);
}
Self {
config: Arc::new(Mutex::new(config)),
cache_dir,
}
}
/// Resolve a cache filename against the cache directory, refusing anything
/// that lands outside it.
///
/// `..` is folded away lexically rather than through `canonicalize`, so a
/// file that does not exist yet still resolves — the same approach as
/// `media_server::resolve_path`. `safe_component` should already have made an
/// escape impossible; this is the check at the point of use.
///
/// TRACES: | DR-210 | UT-204
fn resolve_in_cache_dir(&self, filename: &str) -> Result<PathBuf, String> {
let mut resolved = self.cache_dir.clone();
for part in Path::new(filename).components() {
match part {
std::path::Component::ParentDir => {
resolved.pop();
}
std::path::Component::CurDir => {}
other => resolved.push(other),
}
}
if resolved.starts_with(&self.cache_dir) {
Ok(resolved)
} else {
Err("Thumbnail path escapes the cache directory".to_string())
}
}
/// Check if caching is enabled
pub fn is_enabled(&self) -> bool {
self.config.lock().map(|c| c.enabled).unwrap_or(true)
}
/// Get cached thumbnail path, or None if not cached
/// Updates last_accessed timestamp for LRU tracking
pub async fn get_cached_path(
&self,
db: Arc<RusqliteService>,
item_id: &str,
image_type: &str,
tag: &str,
) -> Option<PathBuf> {
// Primary lookup: exact (item_id, image_type, tag) match.
let exact = Query::with_params(
"SELECT file_path FROM thumbnails
WHERE item_id = ? AND image_type = ? AND image_tag = ?",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(image_type.to_string()),
QueryParam::String(tag.to_string()),
],
);
if let Ok(Some(path_str)) = db
.query_optional(exact, |row| row.get::<_, String>(0))
.await
{
let path = PathBuf::from(&path_str);
if path.exists() {
self.touch(&db, item_id, image_type, Some(tag)).await;
return Some(path);
}
// File gone — drop the stale row and fall through to the tag-agnostic
// lookup below (another cached image for this item may still exist).
let _ = db
.execute(Query::with_params(
"DELETE FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(image_type.to_string()),
QueryParam::String(tag.to_string()),
],
))
.await;
}
// Fallback: any cached image for this item + type, newest first. The
// `image_tag` is a cache-busting version, and callers don't always pass
// the same tag the image was cached under — e.g. the mini player asks for
// the album image using the *track's* primary_image_tag. Ignoring the tag
// here lets those still resolve offline instead of hitting the server.
let any_tag = Query::with_params(
"SELECT file_path FROM thumbnails
WHERE item_id = ? AND image_type = ?
ORDER BY cached_at DESC LIMIT 1",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(image_type.to_string()),
],
);
let path_str: String = db.query_optional(any_tag, |row| row.get(0)).await.ok()??;
let path = PathBuf::from(&path_str);
if path.exists() {
self.touch(&db, item_id, image_type, None).await;
Some(path)
} else {
None
}
}
/// Update `last_accessed` for LRU tracking. When `tag` is `Some`, scope to
/// that exact row; when `None`, touch every row for the item + type.
async fn touch(
&self,
db: &Arc<RusqliteService>,
item_id: &str,
image_type: &str,
tag: Option<&str>,
) {
let query = match tag {
Some(tag) => Query::with_params(
"UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
WHERE item_id = ? AND image_type = ? AND image_tag = ?",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(image_type.to_string()),
QueryParam::String(tag.to_string()),
],
),
None => Query::with_params(
"UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
WHERE item_id = ? AND image_type = ?",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(image_type.to_string()),
],
),
};
let _ = db.execute(query).await;
}
/// Save thumbnail to cache
///
/// TRACES: | DR-210 | UT-204
// The arguments are the cache key (item/type/tag) plus the payload and its
// dimensions — all independent scalars borrowed from the caller. A parameter
// struct would only move the same list one level down.
#[allow(clippy::too_many_arguments)]
pub async fn save_thumbnail(
&self,
db: Arc<RusqliteService>,
item_id: &str,
image_type: &str,
tag: &str,
data: &[u8],
width: Option<i32>,
height: Option<i32>,
) -> Result<PathBuf, String> {
if !self.is_enabled() {
return Err("Thumbnail caching is disabled".to_string());
}
// Generate safe filename. The database keeps the *raw* key below, so the
// lookup in `get_cached_path` still matches what the caller asks for; only
// the on-disk name is sanitised, and the row records where it landed.
let filename = format!(
"{}_{}_{}.jpg",
safe_component(item_id),
safe_component(image_type),
safe_component(tag)
);
let file_path = self.resolve_in_cache_dir(&filename)?;
// Ensure we have space (evict LRU items if needed)
self.ensure_space(db.clone(), data.len() as u64).await?;
// Write file to disk
std::fs::write(&file_path, data).map_err(|e| format!("Failed to write file: {}", e))?;
// Insert/update database entry
let query = Query::with_params(
"INSERT INTO thumbnails (item_id, image_type, image_tag, file_path, width, height, file_size, last_accessed, cached_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(item_id, image_type, image_tag) DO UPDATE SET
file_path = excluded.file_path,
width = excluded.width,
height = excluded.height,
file_size = excluded.file_size,
last_accessed = CURRENT_TIMESTAMP",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(image_type.to_string()),
QueryParam::String(tag.to_string()),
QueryParam::String(file_path.to_string_lossy().to_string()),
width.map(QueryParam::Int).unwrap_or(QueryParam::Null),
height.map(QueryParam::Int).unwrap_or(QueryParam::Null),
QueryParam::Int64(data.len() as i64),
],
);
db.execute(query)
.await
.map_err(|e| format!("Failed to save to database: {}", e))?;
Ok(file_path)
}
/// Ensure there's enough space by evicting LRU items if needed
async fn ensure_space(
&self,
db: Arc<RusqliteService>,
needed_bytes: u64,
) -> Result<(), String> {
let max_size = {
let config = self.config.lock().map_err(|e| e.to_string())?;
config.max_size_bytes
};
if max_size == 0 {
return Ok(()); // Unlimited
}
let current_size = self.get_cache_size(db.clone()).await;
if current_size + needed_bytes <= max_size {
return Ok(()); // Enough space
}
// Need to evict LRU items
let to_free = (current_size + needed_bytes).saturating_sub(max_size);
self.evict_lru(db, to_free).await
}
/// Evict least recently used items to free up space
async fn evict_lru(&self, db: Arc<RusqliteService>, to_free: u64) -> Result<(), String> {
let mut freed: u64 = 0;
// Get items ordered by last_accessed (oldest first)
let query = Query::new(
"SELECT id, file_path, file_size FROM thumbnails
ORDER BY last_accessed ASC",
);
let items: Vec<(i64, String, i64)> = db
.query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.await
.map_err(|e| e.to_string())?;
for (id, path, size) in items {
if freed >= to_free {
break;
}
// Delete file from disk
let _ = std::fs::remove_file(&path);
// Delete from database
let delete_query = Query::with_params(
"DELETE FROM thumbnails WHERE id = ?",
vec![QueryParam::Int64(id)],
);
let _ = db.execute(delete_query).await;
freed += size as u64;
}
Ok(())
}
/// Get current total cache size in bytes
pub async fn get_cache_size(&self, db: Arc<RusqliteService>) -> u64 {
let query = Query::new("SELECT COALESCE(SUM(file_size), 0) FROM thumbnails");
db.query_one(query, |row| row.get::<_, i64>(0))
.await
.unwrap_or(0) as u64
}
/// Get count of cached items
pub async fn get_item_count(&self, db: Arc<RusqliteService>) -> i64 {
let query = Query::new("SELECT COUNT(*) FROM thumbnails");
db.query_one(query, |row| row.get(0)).await.unwrap_or(0)
}
/// Get the current cache limit in bytes
pub async fn get_limit(&self, db: Arc<RusqliteService>) -> u64 {
let query = Query::with_params(
"SELECT value FROM cache_settings WHERE key = ?",
vec![QueryParam::String("image_cache_limit_bytes".to_string())],
);
db.query_optional(query, |row| row.get::<_, String>(0))
.await
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(1024 * 1024 * 1024) // 1GB default
}
/// Set the cache limit in bytes
pub async fn set_limit(
&self,
db: Arc<RusqliteService>,
limit_bytes: u64,
) -> Result<(), String> {
// Update database setting
let query = Query::with_params(
"INSERT OR REPLACE INTO cache_settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)",
vec![
QueryParam::String("image_cache_limit_bytes".to_string()),
QueryParam::String(limit_bytes.to_string()),
],
);
db.execute(query)
.await
.map_err(|e| format!("Failed to update setting: {}", e))?;
// Update in-memory config
if let Ok(mut config) = self.config.lock() {
config.max_size_bytes = limit_bytes;
}
// If new limit is lower, evict to comply
let current_size = self.get_cache_size(db.clone()).await;
if limit_bytes > 0 && current_size > limit_bytes {
let to_free = current_size - limit_bytes;
self.evict_lru(db, to_free).await?;
}
Ok(())
}
/// Clear all cached thumbnails
pub async fn clear_cache(&self, db: Arc<RusqliteService>) -> Result<(), String> {
// Get all file paths
let query = Query::new("SELECT file_path FROM thumbnails");
let paths: Vec<String> = db
.query_many(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
// Delete files from disk
for path in paths {
let _ = std::fs::remove_file(&path);
}
// Clear database
let delete_query = Query::new("DELETE FROM thumbnails");
db.execute(delete_query)
.await
.map_err(|e| format!("Failed to clear database: {}", e))?;
Ok(())
}
/// Delete cached thumbnail for a specific item
pub async fn delete_item(&self, db: Arc<RusqliteService>, item_id: &str) -> Result<(), String> {
// Get file paths for this item
let query = Query::with_params(
"SELECT file_path FROM thumbnails WHERE item_id = ?",
vec![QueryParam::String(item_id.to_string())],
);
let paths: Vec<String> = db
.query_many(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
// Delete files
for path in paths {
let _ = std::fs::remove_file(&path);
}
// Delete from database
let delete_query = Query::with_params(
"DELETE FROM thumbnails WHERE item_id = ?",
vec![QueryParam::String(item_id.to_string())],
);
db.execute(delete_query)
.await
.map_err(|e| format!("Failed to delete from database: {}", e))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::db_service::RusqliteService;
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
fn setup_test_db() -> (Arc<RusqliteService>, TempDir) {
let temp_dir = TempDir::new().unwrap();
let conn = Connection::open_in_memory().unwrap();
// Create tables
conn.execute(
"CREATE TABLE thumbnails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
image_type TEXT NOT NULL,
image_tag TEXT NOT NULL,
file_path TEXT NOT NULL,
width INTEGER,
height INTEGER,
file_size INTEGER DEFAULT 0,
cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(item_id, image_type, image_tag)
)",
[],
)
.unwrap();
conn.execute(
"CREATE TABLE cache_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)",
[],
)
.unwrap();
let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
(db_service, temp_dir)
}
#[test]
fn test_cache_creation() {
let temp_dir = TempDir::new().unwrap();
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
assert!(cache.cache_dir.exists());
assert!(cache.is_enabled());
}
#[tokio::test]
async fn test_save_and_get_thumbnail() {
let (conn, temp_dir) = setup_test_db();
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
// Save a thumbnail
let data = b"fake image data";
let path = cache
.save_thumbnail(
conn.clone(),
"item1",
"Primary",
"tag1",
data,
Some(100),
Some(100),
)
.await
.unwrap();
assert!(path.exists());
// Get cached path
let cached = cache
.get_cached_path(conn.clone(), "item1", "Primary", "tag1")
.await;
assert!(cached.is_some());
assert_eq!(cached.unwrap(), path);
}
#[tokio::test]
async fn test_cache_miss() {
let (conn, temp_dir) = setup_test_db();
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
let cached = cache
.get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1")
.await;
assert!(cached.is_none());
}
#[tokio::test]
async fn test_lru_eviction() {
let (conn, temp_dir) = setup_test_db();
let config = CacheConfig {
max_size_bytes: 100, // Very small limit
..Default::default()
};
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), config);
// Add items that exceed limit
let data = vec![0u8; 60];
cache
.save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
.await
.unwrap();
// Second item should trigger eviction
cache
.save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
.await
.unwrap();
// First item should be evicted
let cached = cache
.get_cached_path(conn.clone(), "item1", "Primary", "tag1")
.await;
assert!(cached.is_none());
// Second item should exist
let cached = cache
.get_cached_path(conn.clone(), "item2", "Primary", "tag2")
.await;
assert!(cached.is_some());
}
#[tokio::test]
async fn test_clear_cache() {
let (conn, temp_dir) = setup_test_db();
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
let data = b"fake image data";
cache
.save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, None, None)
.await
.unwrap();
cache
.save_thumbnail(conn.clone(), "item2", "Primary", "tag2", data, None, None)
.await
.unwrap();
assert_eq!(cache.get_item_count(conn.clone()).await, 2);
cache.clear_cache(conn.clone()).await.unwrap();
assert_eq!(cache.get_item_count(conn.clone()).await, 0);
assert_eq!(cache.get_cache_size(conn.clone()).await, 0);
}
#[tokio::test]
async fn test_set_limit() {
let (conn, temp_dir) = setup_test_db();
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
// Save some thumbnails
let data = vec![0u8; 50];
cache
.save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
.await
.unwrap();
cache
.save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
.await
.unwrap();
// Set a limit smaller than current size
cache.set_limit(conn.clone(), 60).await.unwrap();
// Some items should be evicted
let size = cache.get_cache_size(conn.clone()).await;
assert!(size <= 60);
}
/// A traversal-style `item_id` must not steer a cache write out of the cache
/// directory. The id reaches `save_thumbnail` verbatim from Jellyfin JSON, so
/// it is not ours to trust.
///
/// TRACES: | DR-210 | UT-204
#[tokio::test]
async fn test_save_thumbnail_confines_traversal_item_id() {
let (conn, temp_dir) = setup_test_db();
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
let result = cache
.save_thumbnail(
conn.clone(),
"../evil",
"Primary",
"tag1",
b"fake image data",
None,
None,
)
.await;
// Where `../evil` lands if `..` is honoured: the cache dir's parent.
let escaped = temp_dir.path().join("evil_Primary_tag1.jpg");
assert!(
!escaped.exists(),
"wrote outside the cache directory: {}",
escaped.display()
);
// Refusing is acceptable; succeeding is too, as long as it stayed inside.
if let Ok(path) = result {
assert!(
path.starts_with(&cache.cache_dir) && !path.to_string_lossy().contains(".."),
"returned a path outside the cache directory: {}",
path.display()
);
assert!(path.exists());
}
}
/// `Path::join` discards the base when handed an absolute path, so an
/// absolute `item_id` would otherwise pick the write location outright.
///
/// TRACES: | DR-210 | UT-204
#[tokio::test]
async fn test_save_thumbnail_confines_absolute_item_id() {
let (conn, temp_dir) = setup_test_db();
let outside = TempDir::new().unwrap();
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
let absolute_id = outside.path().join("evil").to_string_lossy().to_string();
let result = cache
.save_thumbnail(
conn.clone(),
&absolute_id,
"Primary",
"tag1",
b"fake image data",
None,
None,
)
.await;
let escaped = outside.path().join("evil_Primary_tag1.jpg");
assert!(
!escaped.exists(),
"wrote outside the cache directory: {}",
escaped.display()
);
if let Ok(path) = result {
assert!(
path.starts_with(&cache.cache_dir),
"returned a path outside the cache directory: {}",
path.display()
);
}
}
/// `image_type` is equally unsanitised, and equally caller-supplied.
///
/// TRACES: | DR-210 | UT-204
#[tokio::test]
async fn test_save_thumbnail_confines_traversal_image_type() {
let (conn, temp_dir) = setup_test_db();
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
let path = cache
.save_thumbnail(
conn.clone(),
"item1",
"../Primary",
"tag1",
b"fake image data",
None,
None,
)
.await
.expect("a malformed image_type should be sanitised, not break caching");
// The file belongs directly in the cache dir — no separator from the
// image type may survive into the filename.
assert_eq!(path.parent(), Some(cache.cache_dir.as_path()));
assert!(path.exists());
}
/// Ids, types and tags that were already filesystem-safe — the overwhelming
/// majority — keep producing exactly the filename they did before, so
/// sanitising does not orphan existing cache entries.
///
/// TRACES: | DR-210 | UT-204
#[tokio::test]
async fn test_save_thumbnail_filename_unchanged_for_safe_values() {
let (conn, temp_dir) = setup_test_db();
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
let path = cache
.save_thumbnail(
conn.clone(),
"a1b2c3d4e5f60718293a4b5c6d7e8f90",
"Primary",
"abcdef0123456789",
b"fake image data",
None,
None,
)
.await
.unwrap();
assert_eq!(
path,
cache
.cache_dir
.join("a1b2c3d4e5f60718293a4b5c6d7e8f90_Primary_abcdef0123456789.jpg")
);
}
/// Sanitising the filename must not desynchronise the write path from the
/// read path: the database keeps the raw key and the resolved path, so a
/// lookup after a save still finds the file that was written.
///
/// TRACES: | DR-210 | UT-204
#[tokio::test]
async fn test_traversal_item_id_still_round_trips() {
let (conn, temp_dir) = setup_test_db();
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
let saved = cache
.save_thumbnail(
conn.clone(),
"../evil",
"Primary",
"tag1",
b"fake image data",
None,
None,
)
.await
.unwrap();
let cached = cache
.get_cached_path(conn.clone(), "../evil", "Primary", "tag1")
.await;
assert_eq!(cached, Some(saved));
}
}