use std::os::unix::fs::PermissionsExt;
use std::os::unix::fs::symlink;
use tempfile::TempDir;
use xdg_thumbnail::{
CacheDirectoryProblem, CacheNamespace, ParsedThumbnailPng, PersonalCacheRoot,
PersonalOriginalIdentity, PersonalOriginalUri, RawThumbnailImage, RawThumbnailPixelFormat,
ReadablePersonalOriginalIdentity, ThumbnailError, ThumbnailPngBitDepth, ThumbnailPngColorType,
ThumbnailSize, UnixMtimeSeconds,
};
#[test]
fn installs_normalized_downscaled_personal_thumbnail_atomically() {
let temp = TempDir::new().unwrap();
let root = PersonalCacheRoot::new(temp.path().join("thumbnails")).unwrap();
let original = readable_original();
let rendered = png_without_metadata(300, 150, png::ColorType::Rgb);
let installed = root
.install_thumbnail_returning_png_bytes(&original, ThumbnailSize::Normal, &rendered)
.unwrap();
let expected_path = root.cache_entry_path(
original.identity().uri(),
&CacheNamespace::Size(ThumbnailSize::Normal),
);
assert_eq!(installed.path(), expected_path.as_path());
assert_eq!(
std::fs::read(&expected_path).unwrap(),
installed.png_bytes()
);
let parsed = ParsedThumbnailPng::parse(installed.png_bytes()).unwrap();
assert_eq!(parsed.width(), 128);
assert_eq!(parsed.height(), 64);
assert_eq!(parsed.bit_depth(), ThumbnailPngBitDepth::Eight);
assert_eq!(parsed.color_type(), ThumbnailPngColorType::Rgba);
assert!(!parsed.interlaced());
assert_eq!(
parsed.metadata().thumb_uri(),
Some("file:///home/alice/photo.png")
);
assert_eq!(
parsed.metadata().thumb_mtime_lossy(),
Some(UnixMtimeSeconds::new(42))
);
assert_eq!(parsed.metadata().thumb_size_lossy(), Some(12));
assert_eq!(parsed.metadata().thumb_mime_type(), Some("image/png"));
let dir_mode = std::fs::metadata(expected_path.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777;
let file_mode = std::fs::metadata(&expected_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700);
assert_eq!(file_mode, 0o600);
}
#[test]
fn path_install_variant_returns_only_installed_path() {
let temp = TempDir::new().unwrap();
let root = PersonalCacheRoot::new(temp.path().join("thumbnails")).unwrap();
let original = readable_original();
let rendered = png_without_metadata(2, 1, png::ColorType::Rgba);
let installed = root
.install_thumbnail_returning_path(&original, ThumbnailSize::Normal, &rendered)
.unwrap();
let expected_path = root.cache_entry_path(
original.identity().uri(),
&CacheNamespace::Size(ThumbnailSize::Normal),
);
assert_eq!(installed.path(), expected_path.as_path());
assert!(expected_path.exists());
}
#[test]
fn install_rejects_insecure_existing_cache_directories() {
let temp = TempDir::new().unwrap();
let root = PersonalCacheRoot::new(temp.path().join("thumbnails")).unwrap();
let target_dir = root.as_path().join("normal");
std::fs::create_dir_all(&target_dir).unwrap();
std::fs::set_permissions(root.as_path(), std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
let error = root
.install_thumbnail_returning_png_bytes(
&readable_original(),
ThumbnailSize::Normal,
&png_without_metadata(2, 1, png::ColorType::Rgba),
)
.unwrap_err();
assert!(matches!(
error,
ThumbnailError::InsecureCacheDirectory {
ref path,
problem: CacheDirectoryProblem::GroupOrOtherAccessible,
..
} if path == &target_dir
));
}
#[test]
fn install_rejects_symlinked_cache_directories() {
let temp = TempDir::new().unwrap();
let root = PersonalCacheRoot::new(temp.path().join("thumbnails")).unwrap();
let outside = temp.path().join("outside");
std::fs::create_dir_all(&outside).unwrap();
std::fs::create_dir_all(root.as_path()).unwrap();
std::fs::set_permissions(root.as_path(), std::fs::Permissions::from_mode(0o700)).unwrap();
symlink(&outside, root.as_path().join("normal")).unwrap();
let error = root
.install_thumbnail_returning_png_bytes(
&readable_original(),
ThumbnailSize::Normal,
&png_without_metadata(2, 1, png::ColorType::Rgba),
)
.unwrap_err();
assert!(matches!(
error,
ThumbnailError::InsecureCacheDirectory {
ref path,
problem: CacheDirectoryProblem::Symlink,
..
} if path == &root.as_path().join("normal")
));
assert!(std::fs::read_dir(&outside).unwrap().next().is_none());
}
#[test]
fn install_rejects_cache_namespace_paths_that_are_not_directories() {
let temp = TempDir::new().unwrap();
let root = PersonalCacheRoot::new(temp.path().join("thumbnails")).unwrap();
std::fs::create_dir_all(root.as_path()).unwrap();
std::fs::set_permissions(root.as_path(), std::fs::Permissions::from_mode(0o700)).unwrap();
let target_path = root.as_path().join("normal");
std::fs::write(&target_path, b"not a directory").unwrap();
let error = root
.install_thumbnail_returning_png_bytes(
&readable_original(),
ThumbnailSize::Normal,
&png_without_metadata(2, 1, png::ColorType::Rgba),
)
.unwrap_err();
assert!(matches!(
error,
ThumbnailError::InsecureCacheDirectory {
ref path,
problem: CacheDirectoryProblem::NotDirectory,
..
} if path == &target_path
));
}
#[test]
fn installs_rgb8_raw_thumbnail_with_opaque_alpha() {
let temp = TempDir::new().unwrap();
let root = PersonalCacheRoot::new(temp.path().join("thumbnails")).unwrap();
let original = readable_original();
let pixels = [10, 20, 30, 40, 50, 60];
let image = RawThumbnailImage::new(2, 1, 6, RawThumbnailPixelFormat::Rgb8, &pixels).unwrap();
let installed = root
.install_raw_thumbnail_returning_png_bytes(&original, ThumbnailSize::Normal, image)
.unwrap();
let expected_path = root.cache_entry_path(
original.identity().uri(),
&CacheNamespace::Size(ThumbnailSize::Normal),
);
assert_eq!(installed.path(), expected_path.as_path());
assert_eq!(
std::fs::read(&expected_path).unwrap(),
installed.png_bytes()
);
let (width, height, rgba) = decode_rgba(installed.png_bytes());
assert_eq!((width, height), (2, 1));
assert_eq!(rgba, [10, 20, 30, 255, 40, 50, 60, 255]);
assert_eq!(
ParsedThumbnailPng::parse(installed.png_bytes())
.unwrap()
.metadata()
.thumb_uri(),
Some("file:///home/alice/photo.png")
);
}
#[test]
fn installs_rgba8_raw_thumbnail_preserving_alpha() {
let temp = TempDir::new().unwrap();
let root = PersonalCacheRoot::new(temp.path().join("thumbnails")).unwrap();
let original = readable_original();
let pixels = [10, 20, 30, 11, 40, 50, 60, 77];
let image = RawThumbnailImage::new(2, 1, 8, RawThumbnailPixelFormat::Rgba8, &pixels).unwrap();
let installed = root
.install_raw_thumbnail_returning_png_bytes(&original, ThumbnailSize::Normal, image)
.unwrap();
let (width, height, rgba) = decode_rgba(installed.png_bytes());
assert_eq!((width, height), (2, 1));
assert_eq!(rgba, pixels);
}
#[test]
fn raw_thumbnail_stride_padding_is_skipped() {
let temp = TempDir::new().unwrap();
let root = PersonalCacheRoot::new(temp.path().join("thumbnails")).unwrap();
let original = readable_original();
let pixels = [1, 2, 3, 4, 5, 6, 99, 99, 7, 8, 9, 10, 11, 12, 88, 88];
let image = RawThumbnailImage::new(2, 2, 8, RawThumbnailPixelFormat::Rgb8, &pixels).unwrap();
let installed = root
.install_raw_thumbnail_returning_png_bytes(&original, ThumbnailSize::Normal, image)
.unwrap();
let (width, height, rgba) = decode_rgba(installed.png_bytes());
assert_eq!((width, height), (2, 2));
assert_eq!(
rgba,
[1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255]
);
}
#[test]
fn raw_thumbnail_oversized_input_is_downscaled() {
let temp = TempDir::new().unwrap();
let root = PersonalCacheRoot::new(temp.path().join("thumbnails")).unwrap();
let original = readable_original();
let width = 300;
let height = 150;
let stride = width * 3;
let pixels = vec![128; stride as usize * height as usize];
let image = RawThumbnailImage::new(
width,
height,
stride as usize,
RawThumbnailPixelFormat::Rgb8,
&pixels,
)
.unwrap();
let installed = root
.install_raw_thumbnail_returning_png_bytes(&original, ThumbnailSize::Normal, image)
.unwrap();
let parsed = ParsedThumbnailPng::parse(installed.png_bytes()).unwrap();
assert_eq!(parsed.width(), 128);
assert_eq!(parsed.height(), 64);
assert_eq!(parsed.color_type(), ThumbnailPngColorType::Rgba);
}
#[test]
fn raw_thumbnail_rejects_invalid_stride() {
let pixels = [0; 6];
let error =
RawThumbnailImage::new(2, 1, 5, RawThumbnailPixelFormat::Rgb8, &pixels).unwrap_err();
assert!(matches!(
error,
ThumbnailError::UnsupportedRenderedThumbnail {
reason: "raw thumbnail stride is too small",
..
}
));
}
#[test]
fn raw_thumbnail_rejects_short_buffer() {
let pixels = [0; 13];
let error =
RawThumbnailImage::new(2, 2, 8, RawThumbnailPixelFormat::Rgb8, &pixels).unwrap_err();
assert!(matches!(
error,
ThumbnailError::UnsupportedRenderedThumbnail {
reason: "raw thumbnail buffer is too short",
..
}
));
}
fn readable_original() -> ReadablePersonalOriginalIdentity {
ReadablePersonalOriginalIdentity::assume_readable(
PersonalOriginalIdentity::new(
PersonalOriginalUri::from_absolute_path_bytes(b"/home/alice/photo.png").unwrap(),
UnixMtimeSeconds::new(42),
)
.with_original_byte_size(12)
.with_mime_type("image/png")
.unwrap(),
)
}
fn png_without_metadata(width: u32, height: u32, color_type: png::ColorType) -> Vec<u8> {
let channels = match color_type {
png::ColorType::Rgb => 3,
png::ColorType::Rgba => 4,
_ => unimplemented!("test helper only supports RGB/RGBA"),
};
let pixels = vec![255; width as usize * height as usize * channels];
let mut output = Vec::new();
{
let mut encoder = png::Encoder::new(&mut output, width, height);
encoder.set_color(color_type);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&pixels).unwrap();
}
output
}
fn decode_rgba(bytes: &[u8]) -> (u32, u32, Vec<u8>) {
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::ALPHA);
let mut reader = decoder.read_info().unwrap();
let mut output = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut output).unwrap();
assert_eq!(info.color_type, png::ColorType::Rgba);
assert_eq!(info.bit_depth, png::BitDepth::Eight);
output.truncate(info.buffer_size());
(info.width, info.height, output)
}