//! Re-encode decoded attachments that exceed [`MAX_IMAGE_BYTES`], //! [`MAX_ENCODE_PIXELS`], or [`MAX_ENCODE_SIDE_PX`] to fit the conversation //! caps. The primary dimension limit is the v9 pixel-area budget //! ([`MAX_ENCODE_PIXELS`]); [`MAX_ENCODE_SIDE_PX`] is a model-agnostic side //! clamp. Compute is amortised via //! [`NormalizeCache`](crate::session::normalize_cache). use crate::session::normalize_cache::{ HarnessVariant, NormalizeCache, NormalizeError, NormalizedEntry, run_blocking, }; use agent_client_protocol::ImageContent; use base64::Engine as _; use bytes::Bytes; use kigi_tools::util::image_compress::{FilterType, ReEncodeParams, re_encode_under_limit}; use std::borrow::Cow; /// Decoded attachment bytes above this are re-encoded to fit this cap. /// /// Kept low so many images fit under the inference proxy's ~50 MB request-body /// limit before byte-budget image eviction has to kick in downstream. Base64 /// inflates raw bytes by ~4/3, so a 1.5 MB image is ~2 MB on the wire and ~25 /// fit under the limit. A low per-image cost means a conversation rarely /// reaches the eviction threshold, so the server-side KV-cache prefix is rarely /// rewritten. (Was 5 MB, which let only ~7 images reach the limit and then /// forced cache-busting eviction on essentially every subsequent turn.) pub(crate) const MAX_IMAGE_BYTES: usize = 1_500_000; const LIMIT_LABEL: &str = "1.5 MB"; /// Total pixel budget (w*h) before downscaling. Mirrors the v9 tokenizer's /// `image_filter_max_pixels = 2_408_448` — larger images are downsampled /// server-side anyway, so extra pixels only waste request bytes. const MAX_ENCODE_PIXELS: u64 = 2_408_448; /// Max width/height before downscaling. Model-agnostic side clamp because /// images are normalized once at ingest and models can switch mid-session. /// Not a v9 constraint — [`MAX_ENCODE_PIXELS`] is what the v9 encoder enforces. const MAX_ENCODE_SIDE_PX: u32 = 2000; /// External-harness image-resize path caps at 1024px before captioning. const STRICT_MAX_ENCODE_SIDE_PX: u32 = 1024; const MIN_ENCODE_SIDE_PX: u32 = 512; const DOWNSCALE_FILTER: FilterType = FilterType::CatmullRom; const JPEG_QUALITY_STEPS: &[u8] = &[88, 80, 72, 64, 56, 48, 40, 32]; /// Upper bound on decoded pixel count before refusing to decode. Matches /// the API ceiling ([`MAX_VISION_TOTAL_PX`]) so any image the API would /// accept can be decoded for the downscale re-encode — a 20-48 Mpx camera /// photo must not be refused client-side (it downscales to the wire caps /// anyway). Worst case is a transient ~716 MB RGBA bitmap inside /// `spawn_blocking`, one image at a time. const MAX_DECODE_PIXELS: u64 = MAX_VISION_TOTAL_PX; /// Bounded ICO decode for load-time verification: real icons are far /// smaller; bytes claiming more are kept un-verified rather than decoded /// on the session-load path. const MAX_LOAD_ICO_DECODE_PIXELS: u64 = 16_000_000; /// Backend APIs reject images with either side < 8 px. pub(crate) const MIN_VISION_SIDE_PX: u32 = 8; /// Backend APIs also reject images with fewer than 512 total pixels /// (`MIN_IMAGE_PIXELS`); e.g. a 16×16 icon is 256 px and draws a 400 that /// poisons the conversation on every following turn. pub(crate) const MIN_VISION_TOTAL_PX: u64 = 512; /// Backend ceiling (`MAX_IMAGE_PIXELS`), header-checked server-side before /// any resize. Send paths re-encode far below this; only legacy/foreign /// history payloads can exceed it. pub(crate) const MAX_VISION_TOTAL_PX: u64 = 178_956_970; const NORMALIZE_PARAMS: ReEncodeParams = ReEncodeParams { max_bytes: MAX_IMAGE_BYTES, max_side_px: MAX_ENCODE_SIDE_PX, max_pixels: MAX_ENCODE_PIXELS, min_side_px: MIN_ENCODE_SIDE_PX, quality_steps: JPEG_QUALITY_STEPS, filter: DOWNSCALE_FILTER, }; /// Resize target for the stricter image normalization path. const STRICT_NORMALIZE_PARAMS: ReEncodeParams = ReEncodeParams { max_bytes: MAX_IMAGE_BYTES, max_side_px: STRICT_MAX_ENCODE_SIDE_PX, max_pixels: u64::MAX, min_side_px: MIN_ENCODE_SIDE_PX, quality_steps: JPEG_QUALITY_STEPS, filter: DOWNSCALE_FILTER, }; #[derive(Debug, Clone, Copy)] pub struct ImageCompressionInfo { pub index: usize, pub original_bytes: usize, pub compressed_bytes: usize, pub original_width: u32, pub original_height: u32, pub compressed_width: u32, pub compressed_height: u32, pub exceeded_size: bool, pub exceeded_dimensions: bool, } impl ImageCompressionInfo { fn reason_label(&self) -> Cow<'static, str> { match (self.exceeded_size, self.exceeded_dimensions) { (true, true) => Cow::Borrowed("was over the size and resolution limits"), (true, false) => Cow::Owned(format!("was over the {LIMIT_LABEL} attachment limit")), (false, true) => Cow::Borrowed("was over the max input resolution"), (false, false) => Cow::Borrowed("compressed"), } } /// User-facing one-liner (TUI toast, headless events). Leads with the /// outcome, not the limit violation, so it doesn't read as an error; /// the why lives in the model-facing [`render_compression_notice`], /// which keeps [`Self::reason_label`]. pub fn display(&self) -> String { let verb = if self.compressed_width == self.original_width && self.compressed_height == self.original_height { "Compressed" } else { "Downscaled" }; format!( "{verb} Image {}: {} ({}x{}) \u{2192} {} ({}x{})", self.index, format_bytes(self.original_bytes), self.original_width, self.original_height, format_bytes(self.compressed_bytes), self.compressed_width, self.compressed_height, ) } } #[derive(Default)] pub struct NormalizeResult { pub images: Vec, pub compressed: Vec, pub re_encode_fallbacks: Vec, /// Images dropped entirely (integrity failure, too small, etc.). /// Surfaced via [`render_image_dropped_notice`]. pub dropped: Vec, } pub async fn normalize_images(images: Vec, is_cursor: bool) -> NormalizeResult { normalize_images_in(images, is_cursor, NormalizeCache::global()).await } /// [`normalize_images`] with an injected cache (tests use a fresh /// per-case instance to avoid singleton-state leakage). pub(crate) async fn normalize_images_in( images: Vec, is_cursor: bool, cache: &NormalizeCache, ) -> NormalizeResult { let mut out = Vec::with_capacity(images.len()); let mut compressed = Vec::new(); let mut re_encode_fallbacks = Vec::new(); let mut dropped = Vec::new(); for (i, img) in images.into_iter().enumerate() { let one_based = i + 1; match normalize_one_in(img, one_based, is_cursor, cache).await { Outcome::Unchanged(c) => out.push(c), Outcome::ReEncodingOversized(c) => { re_encode_fallbacks .push( format!( "Image {one_based} could not be re-encoded under the {LIMIT_LABEL} limit; the original attachment was kept." ), ); out.push(c); } Outcome::Compressed { content, info } => { out.push(content); compressed.push(info); } Outcome::Failed { index, error } => { tracing::warn!("image {index}: normalization failed: {error}"); dropped.push(format!("Image {index} was dropped before send: {error}.")); } } } NormalizeResult { images: out, compressed, re_encode_fallbacks, dropped, } } fn params_for(harness: HarnessVariant) -> &'static ReEncodeParams { match harness { HarnessVariant::Cursor => &STRICT_NORMALIZE_PARAMS, HarnessVariant::Default => &NORMALIZE_PARAMS, } } /// Resolve the active reminder tag via the canonical constants in /// `kigi_tools::reminders` (free-fn shape because this module has no /// `SessionActor`; see `reminder_wrapper_tag`). fn reminder_tag(is_cursor: bool) -> &'static str { let _ = is_cursor; kigi_tools::reminders::DEFAULT_REMINDER_TAG } fn render_notice(notes: &[String], is_cursor: bool, inner_tag: &str) -> String { if notes.is_empty() { return String::new(); } let tag = reminder_tag(is_cursor); format!( "\n\n<{tag}>\n\ <{inner_tag}>\n\ {}\n\ \n\ ", notes.join("\n"), ) } /// System-reminder for images dropped entirely before send. pub fn render_image_dropped_notice(notes: &[String], is_cursor: bool) -> String { render_notice(notes, is_cursor, "image_dropped_notice") } /// Build the (system-reminder, owned-notes) pair for a /// `NormalizeResult.dropped` list. Shared by the user-attachment flow /// and the tool-result-extraction flow. pub(crate) fn dropped_to_envelope( dropped: Vec, is_cursor: bool, ) -> Option<(String, Vec)> { if dropped.is_empty() { return None; } let notice = render_image_dropped_notice(&dropped, is_cursor); Some((notice, dropped)) } /// System-reminder when oversized attachments were kept after re-encode failure. pub fn render_re_encode_fallback_notice(notes: &[String], is_cursor: bool) -> String { render_notice(notes, is_cursor, "image_re_encode_fallback") } /// System-reminder listing images that were re-encoded under the cap. pub fn render_compression_notice(compressed: &[ImageCompressionInfo], is_cursor: bool) -> String { let notes: Vec = compressed .iter() .map(|c| { format!( "Image {} {} and was re-encoded from {}x{} ({}) \ to {}x{} ({}). Fine details may have been lost.", c.index, c.reason_label(), c.original_width, c.original_height, format_bytes(c.original_bytes), c.compressed_width, c.compressed_height, format_bytes(c.compressed_bytes), ) }) .collect(); let tag = reminder_tag(is_cursor); format!( "\n\n<{tag}>\n\ \n\ {}\n\ \n\ ", notes.join("\n"), ) } /// Why persisted-history image bytes would be rejected by the API, or /// `None` when sendable. Cheap (format sniff + structural walk + header /// dimension probe; pixel decode only for ICO, bounded) — used at session /// load to strip payloads that draw a 400 on every subsequent turn, /// leaving the session bricked. The reason is logged when the loader /// strips an image — the strip is re-persisted (irreversible), so the /// evidence must reach logs. pub(crate) fn persisted_image_reject_reason(bytes: &[u8]) -> Option { use image::ImageFormat as F; use kigi_tools::util::image_validate as iv; let Ok(format) = image::guess_format(bytes) else { return Some(format!("unrecognized format ({} bytes)", bytes.len())); }; match format { F::Ico => { let Ok((w, h, _)) = iv::validate_image_bytes_unrestricted(bytes, false) else { return Some("unreadable Ico header".to_owned()); }; if (w as u64) * (h as u64) > MAX_LOAD_ICO_DECODE_PIXELS { return None; } image::load_from_memory(bytes) .is_err() .then(|| format!("undecodable Ico ({} bytes)", bytes.len())) } F::Gif | F::Bmp | F::Tiff => Some(format!("API-rejected format {format:?}")), F::Jpeg | F::Png | F::WebP => { if !iv::format_structurally_complete(format, bytes) { return Some(format!( "structurally incomplete {format:?} ({} bytes)", bytes.len() )); } let Ok((w, h, _)) = iv::validate_image_bytes_unrestricted(bytes, false) else { return Some(format!("unreadable {format:?} header")); }; let px = (w as u64) * (h as u64); if w < MIN_VISION_SIDE_PX || h < MIN_VISION_SIDE_PX || px < MIN_VISION_TOTAL_PX { return Some(format!("below dimension floor ({w}x{h})")); } (px > MAX_VISION_TOTAL_PX).then(|| format!("above pixel ceiling ({w}x{h})")) } _ => Some(format!("API-rejected format {format:?}")), } } /// Whether a `read_file` image may be attached inline to its tool result. #[derive(Debug, PartialEq, Eq)] pub(crate) enum InlineAttachVerdict { Attach, TooSmall, /// Base64 or header probe failed. Fail closed: an unvalidatable image /// must be withheld, not attached (the API 400s it on every turn). Unreadable, } /// Gate for attaching a `read_file` image to its tool result: enforce the /// API dimension floors on the decoded payload. pub(crate) fn inline_attach_verdict(data_b64: &str) -> InlineAttachVerdict { use base64::Engine as _; let Ok(raw) = base64::engine::general_purpose::STANDARD.decode(data_b64) else { return InlineAttachVerdict::Unreadable; }; let Ok((w, h, _)) = kigi_tools::util::image_validate::validate_image_bytes_with(&raw, false) else { return InlineAttachVerdict::Unreadable; }; if w < MIN_VISION_SIDE_PX || h < MIN_VISION_SIDE_PX || (w as u64) * (h as u64) < MIN_VISION_TOTAL_PX { InlineAttachVerdict::TooSmall } else { InlineAttachVerdict::Attach } } #[derive(Debug)] enum Outcome { Unchanged(ImageContent), /// Re-encode could not meet the byte cap; original bytes are passed through. ReEncodingOversized(ImageContent), Compressed { content: ImageContent, info: ImageCompressionInfo, }, Failed { index: usize, error: String, }, } async fn normalize_one_in( img: ImageContent, index: usize, is_cursor: bool, cache: &NormalizeCache, ) -> Outcome { let raw_bytes = match base64::engine::general_purpose::STANDARD.decode(&img.data) { Ok(b) => b, Err(e) => return fail(index, format!("base64 decode: {e}")), }; use kigi_tools::util::image_validate as iv; let (img, raw_bytes) = if iv::needs_endpoint_transcode(&raw_bytes) { let png = match run_blocking(move || match iv::transcode_to_endpoint_png(&raw_bytes) { Some(r) => r.map_err(|e| NormalizeError(format!("non-native image transcode: {e}"))), None => Err(NormalizeError( "non-native image transcode: format no longer needs conversion".to_owned(), )), }) .await { Ok(png) => png, Err(e) => return fail(index, e.0), }; let converted = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&png), "image/png".to_owned(), ) .uri(img.uri.clone()) .annotations(img.annotations.clone()) .meta(img.meta.clone()); (converted, png) } else { (img, raw_bytes) }; let harness = HarnessVariant::from_is_cursor(is_cursor); let entry_res = cache .get_or_try_insert_with(raw_bytes, harness, move |bytes| { compute_normalized(bytes, harness, index) }) .await; match entry_res { Ok(entry) => entry_to_outcome(img, entry, index), Err(arc_err) => fail(index, arc_err.0.clone()), } } fn entry_to_outcome(orig: ImageContent, entry: NormalizedEntry, index: usize) -> Outcome { match entry { NormalizedEntry::Compressed { bytes, mime, info, .. } => Outcome::Compressed { content: ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&bytes), mime.into_owned(), ) .uri(orig.uri) .annotations(orig.annotations) .meta(orig.meta), info: ImageCompressionInfo { index, ..info }, }, NormalizedEntry::ReEncodingOversized { .. } => Outcome::ReEncodingOversized(orig), NormalizedEntry::Unchanged { .. } => Outcome::Unchanged(orig), } } async fn compute_normalized( raw_bytes: Vec, harness: HarnessVariant, index: usize, ) -> Result { let params = params_for(harness); run_blocking(move || compute_normalized_blocking(raw_bytes, params, index)).await } /// CPU-bound normalize work. `params` widens to `&'static /// ReEncodeParams` (instead of `HarnessVariant`) so tests can inject /// `max_bytes = 0` to drive the `ReEncodingOversized` branch. fn compute_normalized_blocking( raw_bytes: Vec, params: &'static ReEncodeParams, index: usize, ) -> Result { let original_bytes = raw_bytes.len(); let (orig_w, orig_h, orig_mime) = kigi_tools::util::image_validate::validate_image_bytes_with(&raw_bytes, false) .map_err(|e| NormalizeError(format!("validate: {e}")))?; if !kigi_tools::util::image_validate::image_structurally_complete(&raw_bytes) { return Err(NormalizeError( "integrity check failed: image bytes are truncated".to_owned(), )); } if orig_w < MIN_VISION_SIDE_PX || orig_h < MIN_VISION_SIDE_PX { return Err(NormalizeError(format!( "too small ({orig_w}×{orig_h}); images must be at least {MIN_VISION_SIDE_PX}×{MIN_VISION_SIDE_PX} pixels" ))); } let pixels = (orig_w as u64) * (orig_h as u64); if pixels < MIN_VISION_TOTAL_PX { return Err(NormalizeError(format!( "too small ({orig_w}×{orig_h} = {pixels} px); images must have at least {MIN_VISION_TOTAL_PX} total pixels" ))); } let exceeded_size = original_bytes > MAX_IMAGE_BYTES; let exceeded_dimensions = params.exceeds_dimension_caps(orig_w, orig_h); if !exceeded_size && !exceeded_dimensions { if let Err(e) = kigi_tools::util::image_validate::validate_image_bytes(&raw_bytes) { return Err(NormalizeError(format!("integrity check failed: {e}"))); } return Ok(NormalizedEntry::Unchanged { bytes: Bytes::from(raw_bytes), mime: Cow::Borrowed(orig_mime), }); } if pixels > MAX_DECODE_PIXELS { return Err(NormalizeError(format!( "image {orig_w}x{orig_h} exceeds {MAX_DECODE_PIXELS} px decode limit", ))); } let decoded = image::load_from_memory(&raw_bytes).map_err(|e| NormalizeError(format!("decode: {e}")))?; let (buf, new_w, new_h, mime) = match re_encode_under_limit(&decoded, params) { Ok(v) => v, Err(e) => { tracing::warn!( index, bytes = original_bytes, error = % e, "image re-encode failed; keeping original attachment" ); return Ok(NormalizedEntry::ReEncodingOversized { bytes: Bytes::from(raw_bytes), mime: Cow::Borrowed(orig_mime), }); } }; if buf.len() >= original_bytes { return Ok(NormalizedEntry::Unchanged { bytes: Bytes::from(raw_bytes), mime: Cow::Borrowed(orig_mime), }); } let compressed_bytes = buf.len(); Ok(NormalizedEntry::Compressed { bytes: Bytes::from(buf), mime: Cow::Borrowed(mime), info: ImageCompressionInfo { index, original_bytes, compressed_bytes, original_width: orig_w, original_height: orig_h, compressed_width: new_w, compressed_height: new_h, exceeded_size, exceeded_dimensions, }, }) } fn fail(index: usize, error: String) -> Outcome { Outcome::Failed { index, error } } fn format_bytes(bytes: usize) -> String { if bytes >= 1_000_000 { format!("{:.1} MB", bytes as f64 / 1_000_000.0) } else { format!("{:.0} KB", bytes as f64 / 1_000.0) } } #[cfg(test)] mod tests { use super::*; use image::DynamicImage; use image::codecs::jpeg::JpegEncoder; fn fresh_cache() -> NormalizeCache { let cache = NormalizeCache::with_capacity(64 * 1024 * 1024); cache.set_enabled(true); cache } fn make_test_png(width: u32, height: u32) -> Vec { use image::{ImageBuffer, Rgba}; let img: ImageBuffer, Vec> = ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255])); let mut buf = Vec::new(); img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) .unwrap(); buf } fn make_image_content(width: u32, height: u32) -> ImageContent { let png = make_test_png(width, height); ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&png), "image/png", ) } fn make_ico_content(width: u32, height: u32) -> ImageContent { let png = make_test_png(width, height); let buf = kigi_test_utils::image::ico_with_png_frame(&png, width as u8, height as u8); ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&buf), "image/x-icon", ) } /// An attached ICO survives as PNG instead of being dropped by the allow-list. #[tokio::test] async fn ico_attachment_transcoded_to_png() { let cache = fresh_cache(); let content = match normalize_one_in(make_ico_content(16, 16), 1, false, &cache).await { Outcome::Unchanged(c) | Outcome::Compressed { content: c, .. } => c, other => panic!("expected ICO to survive as PNG, got {other:?}"), }; assert_eq!(content.mime_type, "image/png"); let decoded = base64::engine::general_purpose::STANDARD .decode(&content.data) .unwrap(); assert_eq!( image::guess_format(&decoded).unwrap(), image::ImageFormat::Png ); } fn make_gif_content(width: u32, height: u32) -> ImageContent { use image::{ImageBuffer, Rgba}; let img: ImageBuffer, Vec> = ImageBuffer::from_pixel(width, height, Rgba([1u8, 2, 3, 255])); let mut buf = Vec::new(); img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Gif) .unwrap(); ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&buf), "image/gif", ) } /// GIF must be PNG'd before send — engines do not sample GIF on the wire. #[tokio::test] async fn gif_attachment_transcoded_to_png() { let cache = fresh_cache(); let content = match normalize_one_in(make_gif_content(32, 24), 1, false, &cache).await { Outcome::Unchanged(c) | Outcome::Compressed { content: c, .. } => c, other => panic!("expected GIF to survive as PNG, got {other:?}"), }; assert_eq!(content.mime_type, "image/png"); let decoded = base64::engine::general_purpose::STANDARD .decode(&content.data) .unwrap(); assert_eq!( image::guess_format(&decoded).unwrap(), image::ImageFormat::Png ); } #[tokio::test] async fn small_image_unchanged() { let img = make_image_content(100, 80); let original_data = img.data.clone(); let cache = fresh_cache(); match normalize_one_in(img, 1, false, &cache).await { Outcome::Unchanged(c) => assert_eq!(c.data, original_data), other => panic!("expected Unchanged, got {other:?}"), } } #[tokio::test] async fn large_dimensions_resized_when_over_side_limit() { let img = make_image_content(3000, 2000); let cache = fresh_cache(); match normalize_one_in(img, 1, false, &cache).await { Outcome::Compressed { content, info } => { assert_eq!(content.mime_type, "image/png"); assert!(info.compressed_bytes < info.original_bytes); assert!(info.compressed_width <= MAX_ENCODE_SIDE_PX); assert!(info.compressed_height <= MAX_ENCODE_SIDE_PX); let area = u64::from(info.compressed_width) * u64::from(info.compressed_height); assert!(area <= MAX_ENCODE_PIXELS, "area {area} over v9 budget"); } other => panic!("expected Compressed, got {other:?}"), } } /// A flat 1700x1700 attachment is under the 2000px side clamp but its /// 2.89 Mpx area exceeds the v9 pixel budget, so it must be downscaled — /// under a side-only cap this passed through unchanged at full resolution. #[tokio::test] async fn attachment_over_area_cap_is_downscaled() { let img = make_image_content(1700, 1700); let cache = fresh_cache(); match normalize_one_in(img, 1, false, &cache).await { Outcome::Compressed { info, .. } => { assert!( info.exceeded_dimensions, "1700x1700 = 2.89 Mpx exceeds the {MAX_ENCODE_PIXELS} px area cap" ); assert!(info.compressed_width <= MAX_ENCODE_SIDE_PX); assert!(info.compressed_height <= MAX_ENCODE_SIDE_PX); let area = u64::from(info.compressed_width) * u64::from(info.compressed_height); assert!(area <= MAX_ENCODE_PIXELS, "area {area} over v9 budget"); } other => panic!("expected a 2.89 Mpx image to be downscaled, got {other:?}"), } } /// 3438x1830 flat screenshot: aspect > ~1.67, so the 2000px side clamp /// binds before the area cap and the long side lands exactly on 2000. #[tokio::test] async fn wide_screenshot_clamped_to_side_limit_and_area_budget() { let img = make_image_content(3438, 1830); let cache = fresh_cache(); match normalize_one_in(img, 1, false, &cache).await { Outcome::Compressed { info, .. } => { assert!(info.exceeded_dimensions); assert!(!info.exceeded_size, "flat PNG must be small in bytes"); assert_eq!( info.compressed_width.max(info.compressed_height), MAX_ENCODE_SIDE_PX, "long side must land on the 2000px compat clamp" ); let area = u64::from(info.compressed_width) * u64::from(info.compressed_height); assert!(area <= MAX_ENCODE_PIXELS, "area {area} over v9 budget"); } other => panic!("expected Compressed, got {other:?}"), } } /// Near-square 1800x1700 = 3.06 Mpx: sides are within the 2000px clamp, /// so only the v9 area cap triggers; the result stays under 2000 per side. #[tokio::test] async fn near_square_over_area_budget_downscaled_below_side_clamp() { let img = make_image_content(1800, 1700); let cache = fresh_cache(); match normalize_one_in(img, 1, false, &cache).await { Outcome::Compressed { info, .. } => { assert!(info.exceeded_dimensions); assert!(info.compressed_width < MAX_ENCODE_SIDE_PX); assert!(info.compressed_height < MAX_ENCODE_SIDE_PX); let area = u64::from(info.compressed_width) * u64::from(info.compressed_height); assert!(area <= MAX_ENCODE_PIXELS, "area {area} over v9 budget"); let r_in = 1800.0 / 1700.0; let r_out = info.compressed_width as f64 / info.compressed_height as f64; assert!( (r_in - r_out).abs() < 0.05, "aspect ratio {r_in} -> {r_out}" ); } other => panic!("expected Compressed, got {other:?}"), } } /// External-harness pin: the 1024px side-only resize with area cap /// disabled, so behavior matches the pre-v9-area-cap path — /// a 1300x900 paste still lands on a 1024px long side. #[tokio::test] async fn strict_path_still_downscales_to_1024_side_only() { let img = make_image_content(1300, 900); let cache = fresh_cache(); match normalize_one_in(img, 1, true, &cache).await { Outcome::Compressed { info, .. } => { assert!( info.exceeded_dimensions, "1300px exceeds the stricter 1024px resize target" ); assert_eq!( info.compressed_width.max(info.compressed_height), STRICT_MAX_ENCODE_SIDE_PX, "external long side must land exactly on 1024" ); assert!(info.compressed_height <= STRICT_MAX_ENCODE_SIDE_PX); } other => panic!("expected Compressed on the strict path, got {other:?}"), } } /// External-harness pin: within the 1024px side cap nothing triggers — the /// attachment passes through untouched. #[tokio::test] async fn strict_path_passes_through_within_1024_side() { let img = make_image_content(1000, 800); let original_data = img.data.clone(); let cache = fresh_cache(); match normalize_one_in(img, 1, true, &cache).await { Outcome::Unchanged(c) => assert_eq!(c.data, original_data), other => panic!("expected Unchanged on the strict path, got {other:?}"), } } fn jpeg_larger_than_limit() -> Vec { use image::{ImageBuffer, Rgb}; let mut side = 2200u32; for _ in 0..64 { let img: ImageBuffer, Vec> = ImageBuffer::from_fn(side, side, |x, y| { Rgb([ (x.wrapping_mul(17).wrapping_add(y)) as u8, (x.wrapping_mul(31).wrapping_add(y.wrapping_mul(7))) as u8, (x.wrapping_add(y).wrapping_mul(13)) as u8, ]) }); let mut buf = Vec::new(); let mut enc = JpegEncoder::new_with_quality(&mut buf, 98); enc.encode_image(&DynamicImage::ImageRgb8(img)) .expect("encode test JPEG"); if buf.len() > MAX_IMAGE_BYTES { return buf; } side += 250; } panic!("could not synthesize test JPEG above limit"); } #[tokio::test] async fn oversize_bytes_becomes_jpeg_under_limit() { let raw = jpeg_larger_than_limit(); assert!(raw.len() > MAX_IMAGE_BYTES); let img = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&raw), "image/jpeg", ); let cache = fresh_cache(); match normalize_one_in(img, 1, false, &cache).await { Outcome::Compressed { content, info } => { assert_eq!(content.mime_type, "image/jpeg"); assert!(info.compressed_bytes <= MAX_IMAGE_BYTES); assert!(info.compressed_bytes < info.original_bytes); assert!(info.compressed_width <= MAX_ENCODE_SIDE_PX); assert!(info.compressed_height <= MAX_ENCODE_SIDE_PX); let area = u64::from(info.compressed_width) * u64::from(info.compressed_height); assert!(area <= MAX_ENCODE_PIXELS, "area {area} over v9 budget"); } other => panic!("expected Compressed, got {other:?}"), } } #[tokio::test] async fn bad_base64_fails() { let img = ImageContent::new(String::from("!!!"), "image/png"); let cache = fresh_cache(); match normalize_one_in(img, 1, false, &cache).await { Outcome::Failed { index, error } => { assert_eq!(index, 1); assert!( error.contains("base64"), "expected base64 mention in error, got: {error}" ); } other => panic!("expected Failed, got {other:?}"), } } #[tokio::test] async fn oversized_invalid_bytes_fail_decode() { let raw = vec![0u8; MAX_IMAGE_BYTES + 1]; let img = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&raw), "image/png", ); let cache = fresh_cache(); match normalize_one_in(img, 1, false, &cache).await { Outcome::Failed { index, error } => { assert_eq!(index, 1); assert!( error.contains("validate") || error.contains("decode"), "expected validate/decode mention, got: {error}" ); } other => panic!("expected Failed, got {other:?}"), } } #[tokio::test] async fn preserves_index() { let raw = jpeg_larger_than_limit(); let img = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&raw), "image/jpeg", ); let cache = fresh_cache(); match normalize_one_in(img, 7, false, &cache).await { Outcome::Compressed { info, .. } => assert_eq!(info.index, 7), other => panic!("expected Compressed, got {other:?}"), } } #[tokio::test] async fn normalize_images_filters_bad_and_keeps_good() { let good = make_image_content(100, 100); let bad = ImageContent::new(String::from("!!!"), "image/png"); let cache = fresh_cache(); let result = normalize_images_in(vec![good, bad], false, &cache).await; assert_eq!(result.images.len(), 1); assert!(result.compressed.is_empty()); assert!(result.re_encode_fallbacks.is_empty()); assert_eq!(result.dropped.len(), 1, "bad image must surface as dropped"); assert!( result.dropped[0].contains("Image 2"), "drop note must name the per-call index, got: {}", result.dropped[0] ); } /// Wide raster so `resize(max_side, max_side)` must not equal a square output. fn wide_jpeg_above_limit() -> (Vec, u32, u32) { use image::{ImageBuffer, Rgb}; let h = 500u32; let mut w = 3600u32; for _ in 0..48 { let img: ImageBuffer, Vec> = ImageBuffer::from_fn(w, h, |x, y| { Rgb([ (x.wrapping_mul(11).wrapping_add(y)) as u8, (x.wrapping_mul(19)) as u8, (y.wrapping_mul(7)) as u8, ]) }); let mut buf = Vec::new(); let mut enc = JpegEncoder::new_with_quality(&mut buf, 98); enc.encode_image(&DynamicImage::ImageRgb8(img)) .expect("encode wide test JPEG"); if buf.len() > MAX_IMAGE_BYTES { return (buf, w, h); } w += 200; } panic!("could not synthesize wide JPEG above limit"); } #[tokio::test] async fn oversize_wide_image_keeps_aspect_ratio() { let (raw, ow, oh) = wide_jpeg_above_limit(); assert!(raw.len() > MAX_IMAGE_BYTES); let img = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&raw), "image/jpeg", ); let cache = fresh_cache(); match normalize_one_in(img, 1, false, &cache).await { Outcome::Compressed { info, .. } => { let r_in = ow as f64 / oh as f64; let r_out = info.compressed_width as f64 / info.compressed_height as f64; assert!( (r_in - r_out).abs() < 0.2, "aspect ratio {r_in} -> {r_out} ({}x{} -> {}x{})", ow, oh, info.compressed_width, info.compressed_height ); assert_ne!(info.compressed_width, info.compressed_height); assert!(info.compressed_width <= MAX_ENCODE_SIDE_PX); assert!(info.compressed_height <= MAX_ENCODE_SIDE_PX); let area = u64::from(info.compressed_width) * u64::from(info.compressed_height); assert!(area <= MAX_ENCODE_PIXELS, "area {area} over v9 budget"); } other => panic!("expected Compressed, got {other:?}"), } } #[test] fn compression_notice_contains_dimensions() { let info = ImageCompressionInfo { index: 1, original_bytes: 8_200_000, compressed_bytes: 1_400_000, original_width: 5000, original_height: 3000, compressed_width: 2000, compressed_height: 1200, exceeded_size: true, exceeded_dimensions: true, }; let notice = render_compression_notice(std::slice::from_ref(&info), false); assert!(notice.contains("")); assert!(notice.contains("")); assert!(!notice.contains("")); assert!(notice.contains("")); assert!(notice.contains("was over the size and resolution limits")); assert!(notice.contains("5000x3000")); assert!(notice.contains("2000x1200")); assert!(notice.contains("8.2 MB")); assert!(notice.contains("1.4 MB")); let size_only = ImageCompressionInfo { exceeded_size: true, exceeded_dimensions: false, ..info }; assert!( render_compression_notice(&[size_only], false) .contains("was over the 1.5 MB attachment limit") ); let dims_only = ImageCompressionInfo { exceeded_size: false, exceeded_dimensions: true, ..info }; assert!( render_compression_notice(&[dims_only], false) .contains("was over the max input resolution") ); } #[test] fn re_encode_fallback_notice_picks_tag_per_harness() { let notes = vec!["Image 1 could not be re-encoded under the cap.".to_string()]; let grok = render_re_encode_fallback_notice(¬es, false); assert!(grok.contains("")); assert!(grok.contains("")); assert!(!grok.contains("")); assert!(grok.contains("")); } #[test] fn display_format() { let info_both = ImageCompressionInfo { index: 2, original_bytes: 5_000_000, compressed_bytes: 800_000, original_width: 4000, original_height: 3000, compressed_width: 2000, compressed_height: 1500, exceeded_size: true, exceeded_dimensions: true, }; assert_eq!( info_both.display(), "Downscaled Image 2: 5.0 MB (4000x3000) \u{2192} 800 KB (2000x1500)" ); let info_size = ImageCompressionInfo { exceeded_size: true, exceeded_dimensions: false, ..info_both }; assert_eq!(info_size.display(), info_both.display()); let info_size_same_dims = ImageCompressionInfo { compressed_width: 4000, compressed_height: 3000, ..info_size }; assert_eq!( info_size_same_dims.display(), "Compressed Image 2: 5.0 MB (4000x3000) \u{2192} 800 KB (4000x3000)" ); } #[test] fn format_bytes_ranges() { assert_eq!(format_bytes(8_200_000), "8.2 MB"); assert_eq!(format_bytes(1_000_000), "1.0 MB"); assert_eq!(format_bytes(450_000), "450 KB"); assert_eq!(format_bytes(1_000), "1 KB"); } /// CRC-corrupt PNG must be rejected by the Unchanged integrity check. #[tokio::test] async fn crc_corrupt_png_fails_integrity_check() { let mut bytes = make_test_png(64, 64); let tag = b"IDAT"; let pos = bytes .windows(4) .position(|w| w == tag) .expect("IDAT chunk present"); bytes[pos + 12] ^= 0xFF; let img = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&bytes), "image/png", ); let cache = fresh_cache(); match normalize_one_in(img, 3, false, &cache).await { Outcome::Failed { index, error } => { assert_eq!(index, 3); assert!( error.contains("integrity check failed"), "expected integrity-check error, got: {error}" ); } other => panic!("expected Failed, got {other:?}"), } } /// Truncated PNG is rejected by the Unchanged integrity check. #[tokio::test] async fn truncated_png_fails_integrity_check_in_normalize_one() { let mut bytes = make_test_png(64, 64); bytes.truncate(bytes.len() / 2); let img = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&bytes), "image/png", ); let cache = fresh_cache(); match normalize_one_in(img, 11, false, &cache).await { Outcome::Failed { index, error } => { assert_eq!(index, 11); assert!( error.contains("integrity check failed"), "expected integrity-check error, got: {error}" ); } other => panic!("expected Failed, got {other:?}"), } } /// Truncated JPEG is rejected somewhere in the normalize pipeline /// (dimension-probe or integrity-check, depending on the codec). #[tokio::test] async fn corrupt_jpeg_rejected_by_normalize_one() { use image::{ImageBuffer, Rgb}; let img_buf: ImageBuffer, Vec> = ImageBuffer::from_fn(64, 64, |x, y| { Rgb([ (x.wrapping_mul(13) ^ y) as u8, (x.wrapping_mul(7).wrapping_add(y * 3)) as u8, (x.wrapping_add(y).wrapping_mul(11)) as u8, ]) }); let mut bytes = Vec::new(); JpegEncoder::new_with_quality(&mut bytes, 80) .encode_image(&DynamicImage::ImageRgb8(img_buf)) .unwrap(); let sos_pos = bytes .windows(2) .position(|w| w == [0xFF, 0xDA]) .expect("SOS marker present"); bytes.truncate(sos_pos + 12); let img = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&bytes), "image/jpeg", ); let cache = fresh_cache(); match normalize_one_in(img, 12, false, &cache).await { Outcome::Failed { index, error } => { assert_eq!(index, 12); assert!( error.contains("validate") || error.contains("decode") || error.contains("truncated"), "expected validate/decode/truncated error, got: {error}" ); } other => panic!("expected Failed, got {other:?}"), } } /// Entropy-cut JPEG (a data URI sliced mid-payload by tool output): /// zune-jpeg decodes it leniently, so only the structural walk rejects it. #[tokio::test] async fn truncated_jpeg_under_size_limits_is_dropped() { use image::codecs::jpeg::JpegEncoder; use image::{ImageBuffer, Rgb}; let img_buf: ImageBuffer, Vec> = ImageBuffer::from_fn(400, 300, |x, y| { Rgb([(x ^ y) as u8, (x * 7) as u8, (y * 5) as u8]) }); let mut bytes = Vec::new(); JpegEncoder::new_with_quality(&mut bytes, 85) .encode_image(&DynamicImage::ImageRgb8(img_buf)) .unwrap(); bytes.truncate(bytes.len() / 2); assert!( image::load_from_memory(&bytes).is_ok(), "precondition: the lenient decoder accepts the truncated JPEG" ); let img = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&bytes), "image/jpeg", ); let cache = fresh_cache(); match normalize_one_in(img, 4, false, &cache).await { Outcome::Failed { index, error } => { assert_eq!(index, 4); assert!( error.contains("truncated"), "expected truncated error, got: {error}" ); } other => panic!("expected Failed, got {other:?}"), } } /// A 16×16 icon clears the 8px-side floor but violates the API's /// 512-total-pixel floor. #[tokio::test] async fn below_total_pixel_floor_is_dropped() { let img = make_image_content(16, 16); let cache = fresh_cache(); match normalize_one_in(img, 1, false, &cache).await { Outcome::Failed { error, .. } => { assert!( error.contains("total pixels"), "expected total-pixel floor error, got: {error}" ); } other => panic!("expected Failed, got {other:?}"), } let ok = make_image_content(32, 16); match normalize_one_in(ok, 1, false, &fresh_cache()).await { Outcome::Unchanged(_) => {} other => panic!("expected Unchanged at exactly 512 px, got {other:?}"), } } #[test] fn persisted_image_reject_reason_verdicts() { let reason = persisted_image_reject_reason; assert_eq!(reason(&make_test_png(32, 32)), None); let mut jpeg = { use image::codecs::jpeg::JpegEncoder; use image::{ImageBuffer, Rgb}; let img: ImageBuffer, Vec> = ImageBuffer::from_fn(64, 64, |x, y| Rgb([(x ^ y) as u8, x as u8, y as u8])); let mut buf = Vec::new(); JpegEncoder::new_with_quality(&mut buf, 85) .encode_image(&image::DynamicImage::ImageRgb8(img)) .unwrap(); buf }; assert_eq!(reason(&jpeg), None); jpeg.truncate(jpeg.len() / 2); assert!( reason(&jpeg).is_some_and(|r| r.contains("structurally incomplete")), "truncated JPEG must be rejected" ); assert!(reason(&make_test_png(16, 16)).is_some_and(|r| r.contains("dimension floor")),); let mut gif = Vec::new(); image::DynamicImage::ImageRgba8(image::ImageBuffer::from_pixel( 64, 64, image::Rgba([1u8, 2, 3, 255]), )) .write_to(&mut std::io::Cursor::new(&mut gif), image::ImageFormat::Gif) .unwrap(); assert!(reason(&gif).is_some_and(|r| r.contains("format"))); let ico = kigi_test_utils::image::ico_with_png_frame(&make_test_png(16, 16), 16, 16); assert_eq!(reason(&ico), None); let mut cut_ico = ico.clone(); cut_ico.truncate(cut_ico.len() - 8); assert!( reason(&cut_ico).is_some_and(|r| r.contains("Ico")), "truncated ICO must be rejected" ); let mut garbage_ico = vec![0x00, 0x00, 0x01, 0x00]; garbage_ico.extend_from_slice(&[0xAB; 64]); assert!(reason(&garbage_ico).is_some_and(|r| r.contains("Ico"))); assert!(reason(b"not an image").is_some()); } /// Regression: a camera-class photo above the old 16 Mpx decode cap /// (production shape: a 5184×3888 ≈ 20 Mpx attachment refused with /// "exceeds … px decode limit") must normalize via downscale, not be /// rejected — the API accepts up to ~178.9 Mpx. #[tokio::test] async fn camera_sized_photo_is_compressed_not_rejected() { use image::codecs::jpeg::JpegEncoder; use image::{DynamicImage, ImageBuffer, Rgb}; let img: ImageBuffer, Vec> = ImageBuffer::from_pixel(5184, 3888, Rgb([90, 120, 150])); let mut jpeg = Vec::new(); JpegEncoder::new_with_quality(&mut jpeg, 85) .encode_image(&DynamicImage::ImageRgb8(img)) .unwrap(); let img = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&jpeg), "image/jpeg", ); match normalize_one_in(img, 0, false, &fresh_cache()).await { Outcome::Compressed { content, .. } => { assert!(!content.data.is_empty()); } other => panic!("expected Compressed, got {other:?}"), } } /// Above the API ceiling the decode is still refused (the API would /// 400 it regardless). SOF dims are patched — a real fixture that /// large is infeasible to encode. #[tokio::test] async fn above_api_ceiling_is_rejected_by_normalize() { use image::codecs::jpeg::JpegEncoder; use image::{DynamicImage, ImageBuffer, Rgb}; let img: ImageBuffer, Vec> = ImageBuffer::from_pixel(64, 64, Rgb([1, 2, 3])); let mut jpeg = Vec::new(); JpegEncoder::new_with_quality(&mut jpeg, 85) .encode_image(&DynamicImage::ImageRgb8(img)) .unwrap(); let sof = jpeg .windows(2) .position(|w| w == [0xFF, 0xC0]) .expect("baseline SOF0 present"); jpeg[sof + 5..sof + 9].copy_from_slice(&[0x40, 0x00, 0x40, 0x00]); let img = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&jpeg), "image/jpeg", ); match normalize_one_in(img, 0, false, &fresh_cache()).await { Outcome::Failed { error, .. } => { assert!(error.contains("decode limit"), "got: {error}"); } other => panic!("expected Failed, got {other:?}"), } } /// The API also 400s images whose header dims exceed its /// `MAX_IMAGE_PIXELS` ceiling; a kept one would brick the session the /// same way as a below-floor image. (SOF dims are patched because /// encoding a real >178 Mpx fixture is infeasible.) #[test] fn persisted_image_reject_reason_pixel_ceiling() { use image::codecs::jpeg::JpegEncoder; use image::{DynamicImage, ImageBuffer, Rgb}; let img: ImageBuffer, Vec> = ImageBuffer::from_fn(64, 64, |x, y| Rgb([(x ^ y) as u8, x as u8, y as u8])); let mut jpeg = Vec::new(); JpegEncoder::new_with_quality(&mut jpeg, 85) .encode_image(&DynamicImage::ImageRgb8(img)) .unwrap(); let sof = jpeg .windows(2) .position(|w| w == [0xFF, 0xC0]) .expect("baseline SOF0 present"); jpeg[sof + 5..sof + 9].copy_from_slice(&[0x40, 0x00, 0x40, 0x00]); assert!( persisted_image_reject_reason(&jpeg).is_some_and(|r| r.contains("above pixel ceiling")), ); } /// The `read_file` inline-attach gate (the below-floor icon enforcement /// point): floors enforced, and unvalidatable payloads fail closed. #[test] fn inline_attach_verdict_gates_floors_and_fails_closed() { use base64::Engine as _; let b64 = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); assert_eq!( inline_attach_verdict(&b64(&make_test_png(16, 16))), InlineAttachVerdict::TooSmall ); assert_eq!( inline_attach_verdict(&b64(&make_test_png(32, 16))), InlineAttachVerdict::Attach ); assert_eq!( inline_attach_verdict(&b64(&make_test_png(64, 64))), InlineAttachVerdict::Attach ); assert_eq!( inline_attach_verdict(&b64(&make_test_png(4, 200))), InlineAttachVerdict::TooSmall ); assert_eq!( inline_attach_verdict("!!!not base64!!!"), InlineAttachVerdict::Unreadable ); assert_eq!( inline_attach_verdict(&b64(b"not an image")), InlineAttachVerdict::Unreadable ); } /// Oversized truncated JPEG must be dropped too — not silently healed /// into a mostly-grey re-encode. #[tokio::test] async fn truncated_oversized_jpeg_is_dropped() { let mut raw = jpeg_larger_than_limit(); raw.truncate(raw.len() / 2); let img = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&raw), "image/jpeg", ); let cache = fresh_cache(); match normalize_one_in(img, 2, false, &cache).await { Outcome::Failed { error, .. } => { assert!( error.contains("truncated"), "expected truncated error, got: {error}" ); } other => panic!("expected Failed, got {other:?}"), } } #[tokio::test] async fn small_well_formed_png_unchanged_after_integrity_check() { let img = make_image_content(50, 40); let original_data = img.data.clone(); let cache = fresh_cache(); match normalize_one_in(img, 1, false, &cache).await { Outcome::Unchanged(c) => assert_eq!(c.data, original_data), other => panic!("expected Unchanged, got {other:?}"), } } /// Dropped notes propagate to the top-level `NormalizeResult`. #[tokio::test] async fn normalize_images_collects_dropped_notes() { let mut bytes = make_test_png(32, 32); let tag = b"IDAT"; let pos = bytes.windows(4).position(|w| w == tag).unwrap(); bytes[pos + 12] ^= 0xFF; let bad = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&bytes), "image/png", ); let good = make_image_content(60, 40); let cache = fresh_cache(); let result = normalize_images_in(vec![good, bad], false, &cache).await; assert_eq!(result.images.len(), 1, "good image preserved"); assert_eq!(result.dropped.len(), 1, "one drop note"); assert!(result.dropped[0].contains("Image 2"), "drop names index"); } #[test] fn dropped_to_envelope_returns_none_for_empty() { assert!(dropped_to_envelope(Vec::new(), false).is_none()); assert!(dropped_to_envelope(Vec::new(), true).is_none()); } #[test] fn dropped_to_envelope_emits_notice_and_notes() { let notes = vec!["Image 1 was dropped before send: corrupt.".to_string()]; let (notice, returned) = dropped_to_envelope(notes.clone(), false).unwrap(); assert!(notice.contains("")); assert!(notice.contains("")); assert!(notice.contains(¬es[0])); assert_eq!(returned, notes); } #[test] fn image_dropped_notice_picks_tag_per_harness() { let notes = vec!["Image 5 was dropped before send: corrupt".to_string()]; let grok = render_image_dropped_notice(¬es, false); assert!(grok.contains("")); assert!(grok.contains("")); assert!(!grok.contains("")); assert!(grok.contains("")); assert!(grok.contains("Image 5")); assert_eq!(render_image_dropped_notice(&[], false), ""); } /// Large flat-color images compress better as PNG than JPEG; the /// normalizer must pick PNG when it wins. #[tokio::test] async fn flat_color_oversized_picks_png() { use image::{ImageBuffer, Rgb}; let side = 4000u32; let img: ImageBuffer, Vec> = ImageBuffer::from_pixel(side, side, Rgb([40, 80, 120])); let mut png_buf = Vec::new(); img.write_to( &mut std::io::Cursor::new(&mut png_buf), image::ImageFormat::Png, ) .unwrap(); let padding = MAX_IMAGE_BYTES + 1 - png_buf.len(); png_buf.resize(png_buf.len() + padding, 0xAA); assert!(png_buf.len() > MAX_IMAGE_BYTES); let content = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&png_buf), "image/png", ); let cache = fresh_cache(); match normalize_one_in(content, 1, false, &cache).await { Outcome::Compressed { content, info } => { assert_eq!( content.mime_type, "image/png", "flat-color image should pick PNG over JPEG" ); assert!(info.compressed_bytes <= MAX_IMAGE_BYTES); assert!(info.compressed_bytes < info.original_bytes); } other => panic!("expected Compressed, got {other:?}"), } } /// `Bytes::as_ptr` identity + re-stamped per-call `index` together /// prove the second call is a cache hit through `entry_to_outcome`. #[tokio::test] async fn normalize_one_in_uses_cache_for_repeat_input() { let cache = fresh_cache(); let img = make_image_content(48, 48); let raw_decoded = base64::engine::general_purpose::STANDARD .decode(&img.data) .expect("test base64 decode"); let dup = img.clone(); let first_data = match normalize_one_in(img, 1, false, &cache).await { Outcome::Unchanged(c) => c.data, other => panic!("expected Unchanged on first call, got {other:?}"), }; let cached_first = cache .get_for_tests(&raw_decoded, HarnessVariant::Default) .await .expect("first call must populate the cache"); let p1 = match &cached_first { NormalizedEntry::Unchanged { bytes, .. } => bytes.as_ptr(), other => panic!("expected Unchanged in cache, got {other:?}"), }; let second_data = match normalize_one_in(dup, 9, false, &cache).await { Outcome::Unchanged(c) => c.data, other => panic!("expected Unchanged on second call, got {other:?}"), }; assert_eq!( first_data, second_data, "cache-served output must match the first compute" ); let cached_second = cache .get_for_tests(&raw_decoded, HarnessVariant::Default) .await .expect("cache entry survived"); let p2 = match &cached_second { NormalizedEntry::Unchanged { bytes, .. } => bytes.as_ptr(), _ => unreachable!("invariant: variant pinned above"), }; assert_eq!(p1, p2, "`Bytes::as_ptr` identity proves cache hit"); } /// Forces `re_encode_under_limit` to exhaust every step (drives /// the `ReEncodingOversized` path). static UNSATISFIABLE_PARAMS: ReEncodeParams = ReEncodeParams { max_bytes: 0, max_side_px: MAX_ENCODE_SIDE_PX, max_pixels: MAX_ENCODE_PIXELS, min_side_px: MIN_ENCODE_SIDE_PX, quality_steps: JPEG_QUALITY_STEPS, filter: DOWNSCALE_FILTER, }; fn unsatisfiable_params() -> &'static ReEncodeParams { &UNSATISFIABLE_PARAMS } #[tokio::test] async fn re_encoding_oversized_routing() { let raw = jpeg_larger_than_limit(); let entry = compute_normalized_blocking(raw.clone(), unsatisfiable_params(), 5) .expect("blocking compute ok"); match &entry { NormalizedEntry::ReEncodingOversized { bytes, mime } => { assert_eq!(bytes.as_ref(), raw.as_slice(), "original bytes preserved"); assert_eq!(mime.as_ref(), "image/jpeg"); } other => panic!("expected ReEncodingOversized, got {other:?}"), } let orig = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&raw), "image/jpeg", ); match entry_to_outcome(orig.clone(), entry, 5) { Outcome::ReEncodingOversized(c) => { assert_eq!(c.data, orig.data, "original ImageContent passed through"); assert_eq!(c.mime_type, "image/jpeg"); } other => panic!("expected ReEncodingOversized, got {other:?}"), } } #[tokio::test] async fn re_encoding_oversized_round_trips_through_cache() { let raw = jpeg_larger_than_limit(); let cache = fresh_cache(); let params = unsatisfiable_params(); cache .get_or_try_insert_with( raw.clone(), HarnessVariant::Default, move |bytes| async move { compute_normalized_blocking(bytes, params, 1) }, ) .await .expect("seed cache"); let cached = cache .get_for_tests(&raw, HarnessVariant::Default) .await .expect("seed succeeded"); let p1 = match &cached { NormalizedEntry::ReEncodingOversized { bytes, .. } => bytes.as_ptr(), other => panic!("expected ReEncodingOversized in cache, got {other:?}"), }; let input = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&raw), "image/jpeg", ); match normalize_one_in(input, 3, false, &cache).await { Outcome::ReEncodingOversized(c) => assert_eq!(c.mime_type, "image/jpeg"), other => panic!("expected ReEncodingOversized from cache hit, got {other:?}"), } let cached_after = cache .get_for_tests(&raw, HarnessVariant::Default) .await .expect("entry survived"); let p2 = match &cached_after { NormalizedEntry::ReEncodingOversized { bytes, .. } => bytes.as_ptr(), _ => unreachable!(), }; assert_eq!( p1, p2, "ReEncodingOversized cache hit must share backing buffer" ); } #[tokio::test] async fn normalize_images_in_collects_re_encode_fallback_note() { let raw = jpeg_larger_than_limit(); let cache = fresh_cache(); let params = unsatisfiable_params(); cache .get_or_try_insert_with( raw.clone(), HarnessVariant::Default, move |bytes| async move { compute_normalized_blocking(bytes, params, 1) }, ) .await .expect("seed cache"); let img = ImageContent::new( base64::engine::general_purpose::STANDARD.encode(&raw), "image/jpeg", ); let result = normalize_images_in(vec![img], false, &cache).await; assert_eq!(result.images.len(), 1, "image preserved despite oversize"); assert_eq!( result.re_encode_fallbacks.len(), 1, "one fallback note per oversized image" ); assert!( result.re_encode_fallbacks[0].contains("Image 1"), "fallback note must name the per-call index, got: {}", result.re_encode_fallbacks[0], ); } #[tokio::test] async fn sub_8x8_image_is_rejected() { let tiny = make_image_content(4, 3); let ok = make_image_content(30, 30); let cache = fresh_cache(); let result = normalize_images_in(vec![tiny, ok], false, &cache).await; assert_eq!(result.images.len(), 1, "only the >=8x8 image proceeds"); assert_eq!(result.dropped.len(), 1); assert!( result.dropped[0].contains("4×3") && result.dropped[0].contains("8×8"), "dropped note must mention the offending dims and the min: {}", result.dropped[0] ); assert!( result.dropped[0].contains("too small"), "dropped: {}", result.dropped[0] ); } /// Boundary: 8×8 clears the per-side floor but not the API's /// 512-total-pixel floor (64 px would 400 server-side). #[tokio::test] async fn exactly_8x8_is_rejected_by_total_pixel_floor() { let img = make_image_content(8, 8); let cache = fresh_cache(); let result = normalize_images_in(vec![img], false, &cache).await; assert!(result.images.is_empty()); assert_eq!(result.dropped.len(), 1); assert!( result.dropped[0].contains("total pixels"), "dropped: {}", result.dropped[0] ); } /// One dimension below threshold. #[tokio::test] async fn seven_by_eight_is_rejected() { let img = make_image_content(7, 8); let cache = fresh_cache(); let result = normalize_images_in(vec![img], false, &cache).await; assert!(result.images.is_empty()); assert_eq!(result.dropped.len(), 1); assert!(result.dropped[0].contains("7×8")); } }