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]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.93.6"
|
version = "0.93.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.93.6"
|
version = "0.93.7"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ struct SliceParams {
|
|||||||
overlap: f64,
|
overlap: f64,
|
||||||
tall_threshold: f64,
|
tall_threshold: f64,
|
||||||
max_slices: usize,
|
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
|
/// 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
|
/// JPEG-encoder regression indistinguishable from "the page was just
|
||||||
/// garbage." Emit a `warn` on each fallback so an operator can grep
|
/// garbage." Emit a `warn` on each fallback so an operator can grep
|
||||||
/// for "vision prep fell through" and tell the two apart.
|
/// 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 {
|
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!(
|
tracing::warn!(
|
||||||
bytes = image.len(),
|
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;
|
return PreparedAnalysis::Undecodable;
|
||||||
};
|
};
|
||||||
@@ -162,6 +187,7 @@ impl VisionClient {
|
|||||||
overlap: cfg.slice_overlap,
|
overlap: cfg.slice_overlap,
|
||||||
tall_threshold: cfg.tall_aspect_threshold,
|
tall_threshold: cfg.tall_aspect_threshold,
|
||||||
max_slices: cfg.max_slices,
|
max_slices: cfg.max_slices,
|
||||||
|
max_decode_pixels: cfg.ocr_max_decode_pixels,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -779,9 +805,40 @@ mod tests {
|
|||||||
overlap: 0.12,
|
overlap: 0.12,
|
||||||
tall_threshold: 1.6,
|
tall_threshold: 1.6,
|
||||||
max_slices: 16,
|
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 {
|
fn ocr(text: &str, kind: &str) -> OcrResult {
|
||||||
OcrResult {
|
OcrResult {
|
||||||
text: text.into(),
|
text: text.into(),
|
||||||
@@ -1136,6 +1193,7 @@ mod tests {
|
|||||||
overlap: 0.05,
|
overlap: 0.05,
|
||||||
tall_threshold: 1.8,
|
tall_threshold: 1.8,
|
||||||
max_slices: 6,
|
max_slices: 6,
|
||||||
|
max_decode_pixels: 100_000_000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.93.6",
|
"version": "0.93.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user