From 935fabeb18286a1f4368196f2fb5151874ff87e5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:35:25 +0200 Subject: [PATCH] Preview disk cache: ignore symlinked shard directories (#1504) --- crates/preview/src/disk.rs | 14 ++++++++++++-- crates/preview/src/tests.rs | 27 +++++++++++++++++++++++++++ docs/whats-new.md | 6 ++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs index 26d180ec9..e8f4e80bf 100644 --- a/crates/preview/src/disk.rs +++ b/crates/preview/src/disk.rs @@ -9,6 +9,7 @@ //! (`<2 hex>/<32 hex>.jpg` and the `<32 hex>.tmp…` temp files of [`DiskCache::put`], see //! [`is_cache_file`]): the directory may be shared with other files (e.g. a library opened on a //! photo folder that already has a `thumbs/` folder), and those are never touched (issue #98). +//! A shard directory that is a symlink or junction is not followed (issue #1504). use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -65,7 +66,7 @@ impl DiskCache { pub fn get(&self, key: Hash128) -> Option { let p = self.path(key); - let Ok(bytes) = std::fs::read(&p) else { + let Some(bytes) = p.parent().filter(|d| is_real_dir(d)).and_then(|_| std::fs::read(&p).ok()) else { self.misses.fetch_add(1, Ordering::Relaxed); return None; }; @@ -91,7 +92,7 @@ impl DiskCache { let Some(bytes) = encode_jpeg(img) else { return }; let p = self.path(key); let Some(parent) = p.parent() else { return }; - if std::fs::create_dir_all(parent).is_err() { + if std::fs::create_dir_all(parent).is_err() || !is_real_dir(parent) { return; } // Serialize the file replacement and its size accounting. A repeated render of @@ -124,6 +125,10 @@ impl DiskCache { if !is_shard_name(sub_name) { continue; } + // a shard that is a symlink (or junction) leads outside the cache: never walk it + if !is_real_dir(&sub.path()) { + continue; + } let Ok(files) = std::fs::read_dir(sub.path()) else { continue }; for f in files.flatten() { let name = f.file_name(); @@ -173,6 +178,11 @@ impl DiskCache { } } +/// Whether `p` is a directory itself, not a symlink or Windows reparse point to one. +fn is_real_dir(p: &Path) -> bool { + std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir()) +} + fn is_lower_hex(s: &str) -> bool { s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index 3179b91ec..6c93e51af 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -380,6 +380,33 @@ fn disk_cache_never_touches_foreign_files() { let _ = std::fs::remove_dir_all(&dir); } +/// A shard directory that is a symlink to a folder outside the cache is neither listed, cleared, +/// pruned, read nor written (issue #1504). +#[cfg(unix)] +#[test] +fn disk_cache_ignores_symlinked_shard_directories() { + let dir = temp_dir("symlink-shard"); + let outside = temp_dir("symlink-outside"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let key = hash_bytes(&[9, 9]); + let hex = key.to_string(); + let sentinel = outside.join(format!("{hex}.jpg")); + std::fs::write(&sentinel, vec![7u8; 50_000]).unwrap(); + std::os::unix::fs::symlink(&outside, dir.join(&hex[..2])).unwrap(); + let d = DiskCache::new(&dir, 10); + assert_eq!(d.size(), 0, "files behind the link are not the cache's"); + d.prune(); + assert!(sentinel.exists(), "prune followed the link"); + assert!(d.get(key).is_none(), "get read through the link"); + d.put(key, &gradient(16, 16, 1)); + assert_eq!(std::fs::metadata(&sentinel).unwrap().len(), 50_000, "put wrote through the link"); + d.clear(); + assert!(sentinel.exists(), "clear followed the link"); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&outside); +} + /// [`JobPool::wait`] returns once a job finishes, which [`JobPool::try_recv`] then hands out, or /// after the timeout. #[test] diff --git a/docs/whats-new.md b/docs/whats-new.md index b5c2ac68c..62f413620 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-13 +### Thumbnail cache stays in its folder +- The thumbnail cache no longer follows a shard folder (`thumbs/ab/`) that is a symlink or junction to somewhere + else (issue #1504): trimming or clearing the cache can't delete files outside it, and thumbnails are never read + from or written through such a link. +## 2026-10-13 + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's