use crate::error::{Error, Result};
use crate::parser_config::ParserOptions;
mod ascii85;
mod ascii_hex;
mod brotli;
pub(crate) mod ccitt;
mod dct;
mod flate;
mod jbig2;
pub(crate) mod jpx;
mod lzw;
mod predictor;
mod runlength;
pub use ascii_hex::AsciiHexDecoder;
pub use ascii85::Ascii85Decoder;
pub use brotli::BrotliDecoder;
pub use ccitt::CcittFaxDecoder;
pub use dct::DctDecoder;
pub use flate::FlateDecoder;
pub use jbig2::Jbig2Decoder;
pub use lzw::LzwDecoder;
pub use predictor::{CcittParams, DecodeParams, PngPredictor, decode_predictor};
pub use runlength::RunLengthDecoder;
const DEFAULT_MAX_DECOMPRESSION_RATIO: u32 = 100;
const DEFAULT_MAX_DECOMPRESSED_SIZE: usize = 100 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Filter {
FlateDecode,
ASCIIHexDecode,
ASCII85Decode,
LZWDecode,
RunLengthDecode,
DCTDecode,
CCITTFaxDecode,
JBIG2Decode,
JPXDecode,
BrotliDecode,
}
pub trait StreamDecoder {
fn decode(&self, input: &[u8]) -> Result<Vec<u8>>;
fn name(&self) -> &str;
}
fn normalize_filter_name(name: &str) -> Result<&'static str> {
match name {
"FlateDecode" => return Ok("FlateDecode"),
"ASCIIHexDecode" => return Ok("ASCIIHexDecode"),
"ASCII85Decode" => return Ok("ASCII85Decode"),
"LZWDecode" => return Ok("LZWDecode"),
"RunLengthDecode" => return Ok("RunLengthDecode"),
"DCTDecode" => return Ok("DCTDecode"),
"CCITTFaxDecode" => return Ok("CCITTFaxDecode"),
"JBIG2Decode" => return Ok("JBIG2Decode"),
"JPXDecode" => return Ok("JPXDecode"),
"BrotliDecode" => return Ok("BrotliDecode"),
_ => {}
}
match name {
"Fl" => return Ok("FlateDecode"),
"AHx" => return Ok("ASCIIHexDecode"),
"A85" => return Ok("ASCII85Decode"),
"LZW" => return Ok("LZWDecode"),
"RL" => return Ok("RunLengthDecode"),
"DCT" => return Ok("DCTDecode"),
"CCF" => return Ok("CCITTFaxDecode"),
_ => {}
}
let lower = name.to_ascii_lowercase();
match lower.as_str() {
"flatedecode" => Ok("FlateDecode"),
"asciihexdecode" => Ok("ASCIIHexDecode"),
"ascii85decode" => Ok("ASCII85Decode"),
"lzwdecode" => Ok("LZWDecode"),
"runlengthdecode" => Ok("RunLengthDecode"),
"dctdecode" => Ok("DCTDecode"),
"ccittfaxdecode" => Ok("CCITTFaxDecode"),
"jbig2decode" => Ok("JBIG2Decode"),
"jpxdecode" => Ok("JPXDecode"),
"brotlidecode" => Ok("BrotliDecode"),
_ => Err(Error::UnsupportedFilter(name.to_string())),
}
}
fn create_decoder(filter_name: &str) -> Result<Box<dyn StreamDecoder>> {
let canonical = normalize_filter_name(filter_name)?;
Ok(match canonical {
"FlateDecode" => Box::new(FlateDecoder::default()),
"ASCIIHexDecode" => Box::new(AsciiHexDecoder),
"ASCII85Decode" => Box::new(Ascii85Decoder),
"LZWDecode" => Box::new(LzwDecoder),
"RunLengthDecode" => Box::new(RunLengthDecoder),
"DCTDecode" => Box::new(DctDecoder),
"CCITTFaxDecode" => Box::new(CcittFaxDecoder),
"JBIG2Decode" => Box::new(Jbig2Decoder),
"JPXDecode" => Box::new(jpx::JpxDecoder),
"BrotliDecode" => Box::new(BrotliDecoder),
_ => unreachable!(),
})
}
pub fn decode_stream(data: &[u8], filters: &[String]) -> Result<Vec<u8>> {
decode_stream_with_params(data, filters, None)
}
pub fn decode_stream_with_options(
data: &[u8],
filters: &[String],
params: Option<&DecodeParams>,
options: Option<&ParserOptions>,
) -> Result<Vec<u8>> {
decode_stream_with_options_and_expected_size(data, filters, params, options, None)
}
fn decode_stream_with_options_and_expected_size(
data: &[u8],
filters: &[String],
params: Option<&DecodeParams>,
options: Option<&ParserOptions>,
expected_filter_output_size: Option<usize>,
) -> Result<Vec<u8>> {
let max_ratio = options
.map(|o| o.max_decompression_ratio)
.unwrap_or(DEFAULT_MAX_DECOMPRESSION_RATIO);
let max_size = options
.map(|o| o.max_decompressed_size)
.unwrap_or(DEFAULT_MAX_DECOMPRESSED_SIZE);
let compressed_size = data.len();
let mut current = data.to_vec();
for (filter_index, filter_name) in filters.iter().enumerate() {
let decoder = create_decoder(filter_name)?;
current = decoder.decode(¤t)?;
let is_final_filter = filter_index + 1 == filters.len();
let matches_expected_size = is_final_filter && expected_filter_output_size == Some(current.len());
if max_ratio > 0 && compressed_size > 0 && !matches_expected_size {
let ratio = current.len() as u64 / compressed_size.max(1) as u64;
if ratio > max_ratio as u64 {
return Err(Error::Decode(format!(
"Decompression bomb detected: ratio {}:1 exceeds limit {}:1 (compressed: {} bytes, decompressed: {} bytes)",
ratio,
max_ratio,
compressed_size,
current.len()
)));
}
}
if max_size > 0 && current.len() > max_size {
return Err(Error::Decode(format!(
"Decompression bomb detected: decompressed size {} bytes exceeds limit {} bytes",
current.len(),
max_size
)));
}
}
if let Some(params) = params
&& params.predictor != 1
{
current = decode_predictor(¤t, params)?;
}
Ok(current)
}
pub fn decode_stream_with_params(data: &[u8], filters: &[String], params: Option<&DecodeParams>) -> Result<Vec<u8>> {
decode_stream_with_options(data, filters, params, None)
}
pub(crate) fn decode_stream_with_params_and_expected_size(
data: &[u8],
filters: &[String],
params: Option<&DecodeParams>,
expected_filter_output_size: usize,
) -> Result<Vec<u8>> {
decode_stream_with_options_and_expected_size(data, filters, params, None, Some(expected_filter_output_size))
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::Compression;
use flate2::write::ZlibEncoder;
use std::io::Write;
#[test]
fn test_decode_stream_no_filters() {
let data = b"Hello, World!";
let result = decode_stream(data, &[]).unwrap();
assert_eq!(result, data);
}
#[test]
fn test_decode_stream_unsupported_filter() {
let data = b"test";
let filters = vec!["UnsupportedFilter".to_string()];
let result = decode_stream(data, &filters);
assert!(result.is_err());
match result {
Err(crate::error::Error::UnsupportedFilter(name)) => {
assert_eq!(name, "UnsupportedFilter");
}
_ => panic!("Expected UnsupportedFilter error"),
}
}
#[test]
fn test_decode_stream_pipeline() {
let data = b"48656C6C6F";
let filters = vec!["ASCIIHexDecode".to_string()];
let result = decode_stream(data, &filters).unwrap();
assert_eq!(result, b"Hello");
}
#[test]
fn test_normalize_filter_abbreviations() {
assert_eq!(normalize_filter_name("A85").unwrap(), "ASCII85Decode");
assert_eq!(normalize_filter_name("AHx").unwrap(), "ASCIIHexDecode");
assert_eq!(normalize_filter_name("LZW").unwrap(), "LZWDecode");
assert_eq!(normalize_filter_name("Fl").unwrap(), "FlateDecode");
assert_eq!(normalize_filter_name("RL").unwrap(), "RunLengthDecode");
assert_eq!(normalize_filter_name("CCF").unwrap(), "CCITTFaxDecode");
assert_eq!(normalize_filter_name("DCT").unwrap(), "DCTDecode");
}
#[test]
fn test_normalize_filter_case_insensitive() {
assert_eq!(normalize_filter_name("Flatedecode").unwrap(), "FlateDecode");
assert_eq!(normalize_filter_name("FLATEDECODE").unwrap(), "FlateDecode");
assert_eq!(normalize_filter_name("flatedecode").unwrap(), "FlateDecode");
assert_eq!(normalize_filter_name("ascii85decode").unwrap(), "ASCII85Decode");
assert_eq!(normalize_filter_name("ASCIIHEXDECODE").unwrap(), "ASCIIHexDecode");
}
#[test]
fn test_normalize_filter_unknown() {
let result = normalize_filter_name("BogusFilter");
assert!(result.is_err());
match result {
Err(crate::error::Error::UnsupportedFilter(name)) => {
assert_eq!(name, "BogusFilter");
}
_ => panic!("Expected UnsupportedFilter error"),
}
}
#[test]
fn test_decode_stream_with_abbreviation() {
let data = b"48656C6C6F";
let filters = vec!["AHx".to_string()];
let result = decode_stream(data, &filters).unwrap();
assert_eq!(result, b"Hello");
}
#[test]
fn small_high_ratio_image_stream_decodes_despite_ratio_over_limit() {
const DECOMPRESSED_LEN: usize = 4_200_000;
let original = vec![0xFFu8; DECOMPRESSED_LEN];
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&original).unwrap();
let compressed = encoder.finish().unwrap();
let ratio = DECOMPRESSED_LEN as u64 / compressed.len().max(1) as u64;
assert!(
ratio > DEFAULT_MAX_DECOMPRESSION_RATIO as u64,
"test setup must produce a ratio over the default limit to be meaningful, got {ratio}:1"
);
let filters = vec!["FlateDecode".to_string()];
let result = decode_stream_with_params_and_expected_size(&compressed, &filters, None, DECOMPRESSED_LEN)
.expect("a 4.2 MB image stream must decode despite exceeding the ratio limit");
assert_eq!(
result.len(),
DECOMPRESSED_LEN,
"decoded output must be the full 4,200,000-byte raster, not truncated or rejected"
);
}
#[test]
fn high_ratio_stream_with_mismatched_image_size_is_rejected() {
const DECOMPRESSED_LEN: usize = 4_200_000;
let original = vec![0xFFu8; DECOMPRESSED_LEN];
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&original).unwrap();
let compressed = encoder.finish().unwrap();
let filters = vec!["FlateDecode".to_string()];
let error = decode_stream_with_params_and_expected_size(&compressed, &filters, None, DECOMPRESSED_LEN + 1)
.expect_err("a high-ratio stream that does not match the raster dimensions must be rejected");
assert!(error.to_string().contains("Decompression bomb detected: ratio"));
}
#[test]
fn a4_rgb_scan_decodes_despite_ratio_over_limit() {
const WIDTH: usize = 2480;
const HEIGHT: usize = 3506;
const RGB_COMPONENTS: usize = 3;
const DECOMPRESSED_LEN: usize = WIDTH * HEIGHT * RGB_COMPONENTS;
let original = vec![0xFFu8; DECOMPRESSED_LEN];
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&original).unwrap();
let compressed = encoder.finish().unwrap();
let ratio = DECOMPRESSED_LEN as u64 / compressed.len().max(1) as u64;
assert!(ratio > DEFAULT_MAX_DECOMPRESSION_RATIO as u64);
let filters = vec!["FlateDecode".to_string()];
let result = decode_stream_with_params_and_expected_size(&compressed, &filters, None, DECOMPRESSED_LEN)
.expect("a bounded 300-dpi RGB scan must not be rejected as a decompression bomb");
assert_eq!(result.len(), DECOMPRESSED_LEN);
}
#[test]
fn generic_bomb_below_absolute_cap_is_rejected_by_ratio_check() {
const DECOMPRESSED_LEN: usize = 64 * 1024 * 1024;
const { assert!(DECOMPRESSED_LEN < DEFAULT_MAX_DECOMPRESSED_SIZE) };
let original = vec![0u8; DECOMPRESSED_LEN];
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&original).unwrap();
let compressed = encoder.finish().unwrap();
let filters = vec!["FlateDecode".to_string()];
let result = decode_stream_with_options(&compressed, &filters, None, None);
match result {
Err(Error::Decode(message)) => {
assert!(
message.contains("Decompression bomb detected: ratio"),
"a bomb above the floor and below the absolute cap must be caught by the \
ratio check specifically, got: {message}"
);
}
other => panic!("expected a ratio-based Decode error, got: {other:?}"),
}
}
}