diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 57f0039..9ac1ec1 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -342,6 +342,14 @@ fn validated_result(index: &SearchIndex, path: &str) -> Result Ok(canonical) } +fn preview_pixel_limit(format: Option) -> u64 { + if format == Some(image::ImageFormat::Jpeg) { + 64 * 1024 * 1024 + } else { + 16 * 1024 * 1024 + } +} + #[derive(Serialize)] struct MediaPreview { path: String, @@ -373,21 +381,15 @@ async fn prepare_media_preview( if bytes > 64 * 1024 * 1024 { return Err("Image exceeds the 64 MiB preview limit.".into()); } - let (width, height) = image::ImageReader::open(&canonical) + let reader = image::ImageReader::open(&canonical) .map_err(|error| error.to_string())? .with_guessed_format() - .map_err(|error| error.to_string())? + .map_err(|error| error.to_string())?; + // Resource limits follow decoded format, not a renameable suffix. + let max_pixels = preview_pixel_limit(reader.format()); + let (width, height) = reader .into_dimensions() .map_err(|error| format!("Image preview is unavailable: {error}"))?; - let jpeg = canonical - .extension() - .and_then(|value| value.to_str()) - .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "jpg" | "jpeg")); - let max_pixels = if jpeg { - 64 * 1024 * 1024 - } else { - 16 * 1024 * 1024 - }; if width == 0 || height == 0 || u64::from(width) * u64::from(height) > max_pixels { return Err("Image exceeds the supported preview dimensions.".into()); } @@ -809,4 +811,26 @@ mod tests { assert!(validated_result(&index, &indexed.to_string_lossy()).is_err()); assert!(validated_result(&index, &root.path().to_string_lossy()).is_err()); } + + #[test] + fn renamed_png_cannot_receive_the_larger_jpeg_preview_budget() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("misnamed.jpg"); + let image = image::GrayImage::new(4097, 4096); + image + .save_with_format(&path, image::ImageFormat::Png) + .unwrap(); + let reader = image::ImageReader::open(&path) + .unwrap() + .with_guessed_format() + .unwrap(); + assert_eq!(reader.format(), Some(image::ImageFormat::Png)); + let limit = preview_pixel_limit(reader.format()); + let (width, height) = reader.into_dimensions().unwrap(); + assert!(u64::from(width) * u64::from(height) > limit); + assert!( + u64::from(width) * u64::from(height) + < preview_pixel_limit(Some(image::ImageFormat::Jpeg)) + ); + } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4dd288d..f8bc95c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -53,6 +53,11 @@ release. Keep their evidence and unchecked production gates intact. see [incremental semantic design](INCREMENTAL-SEMANTIC.md). ## 3. Desktop +- [x] Image preview pixel ceilings use actual decoded format; a real PNG renamed + `.jpg` cannot receive the larger JPEG allowance (Windows CPU regression). +- [ ] Replace lifetime-growing native media asset grants with bounded preview + access, including range limits, stale-source handling and selection/reload tests. + Do not use permanent forbidden patterns as revocation. - [x] Official single-instance transport with isolated explicit profiles; Windows CPU duplicate launch exits without a second query worker. See [launch evidence](SINGLE-INSTANCE.md); visual focus and other OS checks remain open.