fix(analysis): cap decoded pixels on the vision backend too

The vision prep pass decoded pages with bare `image::load_from_memory`,
which applies no allocation bound. `max_pixels` only downscales after a
full decode, so a tiny WebP/JPEG header declaring huge dimensions could
OOM the blocking worker — the same decompression-bomb the OCR backend
already guards against. Manga pages are commonly WebP/JPEG, where the
format's own self-limits are weaker than PNG's.

Route the decode through `decode_within`, applying an `image::Limits`
alloc cap sized from the shared `ocr_max_decode_pixels`
(ANALYSIS_OCR_MAX_DECODE_PIXELS). Add real-WebP bomb coverage asserting
both the helper and the end-to-end Undecodable fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-03 21:13:27 +02:00
parent 3a9e7ca2da
commit a141d65db1
4 changed files with 63 additions and 5 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.93.6"
version = "0.93.7"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.93.6"
version = "0.93.7"
edition = "2021"
default-run = "mangalord"

View File

@@ -55,6 +55,12 @@ struct SliceParams {
overlap: f64,
tall_threshold: f64,
max_slices: usize,
/// Hard cap on the **decoded** pixel count, enforced as an allocation
/// limit on the decoder itself. `max_pixels` only downscales *after* a
/// full decode, so without this a tiny WebP/JPEG/PNG declaring huge
/// dimensions would OOM the blocking worker (decompression bomb).
/// Shared with the OCR backend's cap (`ANALYSIS_OCR_MAX_DECODE_PIXELS`).
max_decode_pixels: u64,
}
/// Result of the (blocking-pool) prep pass: a self-contained set of byte
@@ -86,11 +92,30 @@ enum PreparedAnalysis {
/// JPEG-encoder regression indistinguishable from "the page was just
/// garbage." Emit a `warn` on each fallback so an operator can grep
/// for "vision prep fell through" and tell the two apart.
/// Decode an encoded page image with a hard allocation cap so a
/// decompression bomb — a tiny WebP/JPEG/PNG header declaring enormous
/// dimensions — can't OOM the blocking worker before we get a chance to
/// downscale. The `image` crate's default reader applies no such bound;
/// format-specific self-limits (strongest for PNG) don't cover WebP/JPEG,
/// which manga pages commonly use. `4 bytes/px` (RGBA) leaves headroom
/// over any single intermediate the decoder allocates per pixel. Mirrors
/// `ocr::decode_rgb8_within`.
fn decode_within(image: &[u8], max_decode_pixels: u64) -> Option<DynamicImage> {
use std::io::Cursor;
let mut reader = image::ImageReader::new(Cursor::new(image))
.with_guessed_format()
.ok()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(max_decode_pixels.saturating_mul(4));
reader.limits(limits);
reader.decode().ok()
}
fn prepare_analysis(image: &[u8], params: SliceParams) -> PreparedAnalysis {
let Some(img) = image::load_from_memory(image).ok() else {
let Some(img) = decode_within(image, params.max_decode_pixels) else {
tracing::warn!(
bytes = image.len(),
"vision prep fell through to Undecodable: image::load_from_memory failed"
"vision prep fell through to Undecodable: decode failed or exceeded pixel cap"
);
return PreparedAnalysis::Undecodable;
};
@@ -162,6 +187,7 @@ impl VisionClient {
overlap: cfg.slice_overlap,
tall_threshold: cfg.tall_aspect_threshold,
max_slices: cfg.max_slices,
max_decode_pixels: cfg.ocr_max_decode_pixels,
},
}
}
@@ -779,9 +805,40 @@ mod tests {
overlap: 0.12,
tall_threshold: 1.6,
max_slices: 16,
max_decode_pixels: 100_000_000,
}
}
fn encode_webp(w: u32, h: u32) -> Vec<u8> {
use image::{DynamicImage, ImageFormat, RgbImage};
let img = DynamicImage::ImageRgb8(RgbImage::new(w, h));
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, ImageFormat::WebP).expect("encode webp");
buf.into_inner()
}
#[test]
fn decode_within_rejects_oversize_webp() {
// Non-PNG decompression-bomb coverage: a real 2000×2000 WebP (4 MP)
// must be refused when the decode cap is set below its pixel count.
// The allocation limit trips inside the decoder rather than the
// full frame being materialized. Manga pages are commonly WebP/JPEG,
// where the format's own self-limits are weaker than PNG's.
let webp = encode_webp(2000, 2000);
assert!(decode_within(&webp, 1_000).is_none(), "cap below pixels must reject");
// A generous cap decodes the same image fine.
assert!(decode_within(&webp, 100_000_000).is_some(), "within cap must decode");
}
#[test]
fn prepare_analysis_falls_back_to_undecodable_on_decode_bomb() {
// End-to-end: an over-cap image drives the prep pass to the
// Undecodable fallback (raw-bytes path) instead of decoding it.
let webp = encode_webp(2000, 2000);
let p = SliceParams { max_decode_pixels: 1_000, ..params() };
assert!(matches!(prepare_analysis(&webp, p), PreparedAnalysis::Undecodable));
}
fn ocr(text: &str, kind: &str) -> OcrResult {
OcrResult {
text: text.into(),
@@ -1136,6 +1193,7 @@ mod tests {
overlap: 0.05,
tall_threshold: 1.8,
max_slices: 6,
max_decode_pixels: 100_000_000,
}
}