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.
791 lines
26 KiB
Rust
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));
|
|
}
|
|
}
|