rskit-media-image 0.2.0-alpha.3

Native image processing backend using the image crate
Documentation
//! Bounded image source loading and decoding.
//!
//! The image adapter intentionally centralizes all read/decode limits here so probe
//! and executor paths cannot drift in how they handle untrusted media.

use std::io::{Cursor, ErrorKind, Read as _};

use image::{DynamicImage, ImageFormat, ImageReader};
use rskit_errors::{AppError, AppResult, ErrorCode};
use rskit_media::spatial::Resolution;
use rskit_storage::FileSource;

use crate::config::Config;

pub(crate) struct DecodedImage {
    pub(crate) image: DynamicImage,
    pub(crate) format: Option<ImageFormat>,
}

pub(crate) fn load_data(source: &FileSource, config: &Config) -> AppResult<Vec<u8>> {
    match source {
        FileSource::Path(path) => read_path_bounded(path, config.max_source_bytes),
        FileSource::Bytes(bytes) => {
            ensure_source_len(bytes.len() as u64, config)?;
            Ok(bytes.to_vec())
        }
        FileSource::Temp(temp) => read_path_bounded(temp.path(), config.max_source_bytes),
        FileSource::Url(_) => Err(AppError::new(
            ErrorCode::InvalidInput,
            "URL sources not supported; use to_local_path() first",
        )),
    }
}

pub(crate) fn decode_image(data: &[u8], config: &Config) -> AppResult<DecodedImage> {
    ensure_source_len(data.len() as u64, config)?;
    let reader = reader_for(data, config)?;
    let format = reader.format();
    let (width, height) = dimensions_for(data, config)?;
    ensure_dimensions(width, height, config)?;

    let image = reader.decode().map_err(decode_image_error)?;
    ensure_decoded_ratio(&image, data.len() as u64, config)?;
    ensure_image_dimensions(&image, config)?;
    Ok(DecodedImage { image, format })
}

pub(crate) fn dimensions_for(data: &[u8], config: &Config) -> AppResult<(u32, u32)> {
    let reader = reader_for(data, config)?;
    reader.into_dimensions().map_err(|error| {
        AppError::new(
            ErrorCode::InvalidFormat,
            format!("failed to read image dimensions: {error}"),
        )
    })
}

fn reader_for<'a>(data: &'a [u8], config: &Config) -> AppResult<ImageReader<Cursor<&'a [u8]>>> {
    let mut reader = ImageReader::new(Cursor::new(data))
        .with_guessed_format()
        .map_err(detect_format_error)?;
    reader.limits(config.image_limits());
    Ok(reader)
}

fn decode_image_error(error: image::ImageError) -> AppError {
    AppError::new(
        ErrorCode::InvalidFormat,
        format!("failed to decode image: {error}"),
    )
}

fn detect_format_error(error: std::io::Error) -> AppError {
    AppError::new(
        ErrorCode::InvalidFormat,
        format!("failed to detect image format: {error}"),
    )
}

fn read_path_bounded(path: &std::path::Path, max_bytes: u64) -> AppResult<Vec<u8>> {
    let mut file = std::fs::File::open(path).map_err(|error| {
        AppError::new(
            open_error_code(error.kind()),
            format!("failed to open image {}: {error}", path.display()),
        )
    })?;
    let mut data = Vec::new();
    file.by_ref()
        .take(max_bytes.saturating_add(1))
        .read_to_end(&mut data)
        .map_err(|error| {
            AppError::new(
                ErrorCode::Internal,
                format!("failed to read image {}: {error}", path.display()),
            )
        })?;
    if data.len() as u64 > max_bytes {
        return Err(AppError::new(
            ErrorCode::InvalidInput,
            format!(
                "image source {} exceeds max_source_bytes={max_bytes}",
                path.display()
            ),
        ));
    }
    Ok(data)
}

fn open_error_code(kind: ErrorKind) -> ErrorCode {
    match kind {
        ErrorKind::NotFound => ErrorCode::NotFound,
        _ => ErrorCode::Internal,
    }
}

fn ensure_source_len(len: u64, config: &Config) -> AppResult<()> {
    if len > config.max_source_bytes {
        return Err(AppError::new(
            ErrorCode::InvalidInput,
            format!(
                "image source is {len} bytes, exceeding max_source_bytes={}",
                config.max_source_bytes
            ),
        ));
    }
    Ok(())
}

pub(crate) fn ensure_resolution(resolution: Resolution, config: &Config) -> AppResult<()> {
    ensure_dimensions(resolution.width, resolution.height, config)
}

pub(crate) fn ensure_image_dimensions(image: &DynamicImage, config: &Config) -> AppResult<()> {
    ensure_dimensions(image.width(), image.height(), config)
}

pub(crate) fn scaled_dimension(source: u32, target: u32, reference: u32) -> u32 {
    if reference == 0 {
        return 1;
    }
    ((f64::from(source) * f64::from(target)) / f64::from(reference))
        .round()
        .max(1.0) as u32
}

fn ensure_dimensions(width: u32, height: u32, config: &Config) -> AppResult<()> {
    if width == 0 || height == 0 {
        return Err(AppError::new(
            ErrorCode::InvalidInput,
            format!("image dimensions must be non-zero, got {width}x{height}"),
        ));
    }
    let pixels = u64::from(width).saturating_mul(u64::from(height));
    if pixels > config.max_pixels {
        return Err(AppError::new(
            ErrorCode::InvalidInput,
            format!(
                "image dimensions {width}x{height} contain {pixels} pixels, exceeding max_pixels={}",
                config.max_pixels
            ),
        ));
    }
    Ok(())
}

fn ensure_decoded_ratio(
    image: &DynamicImage,
    compressed_bytes: u64,
    config: &Config,
) -> AppResult<()> {
    let decoded_bytes = u64::from(image.width())
        .saturating_mul(u64::from(image.height()))
        .saturating_mul(u64::from(image.color().bytes_per_pixel()));
    let allowed = compressed_bytes
        .max(1)
        .saturating_mul(config.max_decode_ratio.max(1));
    if decoded_bytes > allowed {
        return Err(AppError::new(
            ErrorCode::InvalidInput,
            format!(
                "decoded image expands to {decoded_bytes} bytes from {compressed_bytes} bytes, \
                 exceeding max_decode_ratio={}",
                config.max_decode_ratio
            ),
        ));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use rskit_errors::ErrorCode;
    use rskit_media::spatial::Resolution;

    use super::{
        decode_image_error, detect_format_error, ensure_resolution, read_path_bounded,
        scaled_dimension,
    };

    #[test]
    fn read_path_bounded_reports_missing_paths_as_not_found() {
        let dir = rskit_storage::TempDir::new().unwrap();

        let error = read_path_bounded(&dir.path().join("missing.png"), 1024).unwrap_err();

        assert_eq!(error.code(), ErrorCode::NotFound);
    }

    #[test]
    fn read_path_bounded_reports_non_not_found_open_errors_as_internal() {
        let dir = rskit_storage::TempDir::new().unwrap();
        let file = dir.path().join("file.txt");
        std::fs::write(&file, b"not a dir").unwrap();

        let error = read_path_bounded(&file.join(Path::new("child.png")), 1024).unwrap_err();

        assert_eq!(error.code(), ErrorCode::Internal);
    }

    #[test]
    fn ensure_resolution_rejects_zero_width() {
        let error =
            ensure_resolution(Resolution::new(0, 10), &crate::Config::default()).unwrap_err();

        assert_eq!(error.code(), ErrorCode::InvalidInput);
    }

    #[test]
    fn ensure_resolution_rejects_zero_height() {
        let error =
            ensure_resolution(Resolution::new(10, 0), &crate::Config::default()).unwrap_err();

        assert_eq!(error.code(), ErrorCode::InvalidInput);
    }

    #[test]
    fn scaled_dimension_rounds_and_clamps_to_one() {
        assert_eq!(scaled_dimension(10, 10, 0), 1);
        assert_eq!(scaled_dimension(1, 1, 100), 1);
        assert_eq!(scaled_dimension(3, 2, 4), 2);
    }

    #[test]
    fn image_format_errors_preserve_invalid_format_code() {
        let decode = decode_image_error(image::ImageError::IoError(std::io::Error::other(
            "decode failed",
        )));
        let detect = detect_format_error(std::io::Error::other("detect failed"));

        assert_eq!(decode.code(), ErrorCode::InvalidFormat);
        assert!(decode.to_string().contains("failed to decode image"));
        assert_eq!(detect.code(), ErrorCode::InvalidFormat);
        assert!(detect.to_string().contains("failed to detect image format"));
    }
}