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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.93.6"
|
||||
version = "0.93.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.93.6"
|
||||
version = "0.93.7"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.93.6",
|
||||
"version": "0.93.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user