use std::fs::File; use std::io::{Cursor, Read}; use std::path::Path; use std::time::Duration; use anyhow::{Context, Result, bail}; use image::codecs::gif::GifDecoder; use image::codecs::webp::WebPDecoder; use image::metadata::LoopCount; use image::{AnimationDecoder, ImageFormat, ImageReader}; use zip::ZipArchive; use crate::browser::natural_cmp_with_case; const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024; const MAX_IMAGE_DIMENSION: u32 = 30_000; const MAX_IMAGE_PIXELS: u64 = 100_000_000; const MAX_ANIMATION_FRAMES: usize = 1_000; const MAX_ANIMATION_BYTES: usize = 512 * 1024 * 1024; const MIN_FRAME_DELAY: Duration = Duration::from_millis(10); #[derive(Debug, Clone)] pub struct ArchivePage { pub index: usize, pub name: String, } pub struct DecodedPage { pub width: u32, pub height: u32, pub frames: Vec, pub loop_count: Option, } pub struct DecodedFrame { pub rgba: Vec, pub delay: Duration, } pub fn list_pages(path: &Path, case_insensitive: bool) -> Result> { let file = File::open(path) .with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?; let mut archive = ZipArchive::new(file) .with_context(|| format!("올바른 ZIP 파일이 아닙니다: {}", path.display()))?; let mut pages = Vec::new(); for index in 0..archive.len() { let entry = archive.by_index(index)?; if entry.is_file() && is_supported_image_name(entry.name()) { pages.push(ArchivePage { index, name: entry.name().to_owned(), }); } } pages.sort_by(|left, right| natural_cmp_with_case(&left.name, &right.name, case_insensitive)); Ok(pages) } pub fn decode_page(path: &Path, page: &ArchivePage) -> Result { let file = File::open(path) .with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?; let mut archive = ZipArchive::new(file)?; let entry = archive .by_index(page.index) .with_context(|| format!("ZIP 항목을 찾을 수 없습니다: {}", page.name))?; if entry.size() > MAX_ENTRY_SIZE { bail!( "이미지 압축 해제 크기가 제한을 초과합니다: {} (최대 256MB)", page.name ); } let capacity = usize::try_from(entry.size()).context("이미지 크기를 처리할 수 없습니다")?; let mut encoded = Vec::with_capacity(capacity); entry .take(MAX_ENTRY_SIZE + 1) .read_to_end(&mut encoded) .with_context(|| format!("ZIP 이미지를 읽을 수 없습니다: {}", page.name))?; if encoded.len() as u64 > MAX_ENTRY_SIZE { bail!("실제 압축 해제 크기가 제한을 초과합니다: {}", page.name); } decode_image_bytes(&encoded, &page.name) } pub fn decode_image_file(path: &Path, display_name: &str) -> Result { let file = File::open(path) .with_context(|| format!("이미지 파일을 열 수 없습니다: {display_name}"))?; let mut encoded = Vec::new(); file.take(MAX_ENTRY_SIZE + 1) .read_to_end(&mut encoded) .with_context(|| format!("이미지 파일을 읽을 수 없습니다: {display_name}"))?; if encoded.len() as u64 > MAX_ENTRY_SIZE { bail!("이미지 크기가 제한을 초과합니다: {display_name} (최대 256MB)"); } decode_image_bytes(&encoded, display_name) } fn decode_image_bytes(encoded: &[u8], name: &str) -> Result { let reader = ImageReader::new(Cursor::new(encoded)) .with_guessed_format() .with_context(|| format!("이미지 형식을 확인할 수 없습니다: {name}"))?; let format = reader.format(); let (width, height) = reader .into_dimensions() .with_context(|| format!("이미지 크기를 읽을 수 없습니다: {name}"))?; validate_dimensions(width, height, name)?; let animated = match format { Some(ImageFormat::Gif) => { let decoder = GifDecoder::new(Cursor::new(encoded)) .with_context(|| format!("GIF를 디코딩할 수 없습니다: {name}"))?; let loop_count = animation_loop_count(decoder.loop_count()); Some((decoder.into_frames(), loop_count)) } Some(ImageFormat::WebP) => { let decoder = WebPDecoder::new(Cursor::new(encoded)) .with_context(|| format!("WebP를 디코딩할 수 없습니다: {name}"))?; decoder.has_animation().then(|| { let loop_count = animation_loop_count(decoder.loop_count()); (decoder.into_frames(), loop_count) }) } _ => None, }; if let Some((frames, loop_count)) = animated { return decoded_animation(width, height, frames, loop_count, name); } let image = image::load_from_memory(encoded) .with_context(|| format!("이미지를 디코딩할 수 없습니다: {name}"))?; let rgba = image.into_rgba8(); Ok(DecodedPage { width, height, frames: vec![DecodedFrame { rgba: rgba.into_raw(), delay: Duration::ZERO, }], loop_count: Some(1), }) } fn decoded_animation( width: u32, height: u32, frames: impl Iterator>, loop_count: Option, name: &str, ) -> Result { let expected_bytes = usize::try_from(width)? .checked_mul(usize::try_from(height)?) .and_then(|pixels| pixels.checked_mul(4)) .context("애니메이션 프레임 크기를 처리할 수 없습니다")?; let mut decoded = Vec::new(); let mut total_bytes = 0_usize; for (index, frame) in frames.enumerate() { if index >= MAX_ANIMATION_FRAMES { bail!( "애니메이션 프레임 수가 제한을 초과합니다: {name} (최대 {MAX_ANIMATION_FRAMES}개)" ); } let frame = frame.with_context(|| format!("애니메이션 프레임을 디코딩할 수 없습니다: {name}"))?; total_bytes = total_bytes .checked_add(expected_bytes) .context("애니메이션 전체 크기를 처리할 수 없습니다")?; if total_bytes > MAX_ANIMATION_BYTES { bail!("애니메이션 디코딩 크기가 제한을 초과합니다: {name} (최대 512MB)"); } let delay = Duration::from(frame.delay()).max(MIN_FRAME_DELAY); let rgba = frame.into_buffer(); if rgba.dimensions() != (width, height) || rgba.len() != expected_bytes { bail!("애니메이션 프레임 크기가 일치하지 않습니다: {name}"); } decoded.push(DecodedFrame { rgba: rgba.into_raw(), delay, }); } if decoded.is_empty() { bail!("애니메이션에 표시할 프레임이 없습니다: {name}"); } Ok(DecodedPage { width, height, frames: decoded, loop_count, }) } fn animation_loop_count(loop_count: LoopCount) -> Option { match loop_count { LoopCount::Infinite => None, LoopCount::Finite(count) => Some(count.get()), } } fn validate_dimensions(width: u32, height: u32, name: &str) -> Result<()> { let pixels = u64::from(width) * u64::from(height); if width == 0 || height == 0 || width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION || pixels > MAX_IMAGE_PIXELS { bail!("이미지 크기가 안전 제한을 초과합니다: {name} ({width}x{height})"); } Ok(()) } fn is_supported_image_name(name: &str) -> bool { let extension = Path::new(name) .extension() .and_then(|extension| extension.to_str()); extension.is_some_and(|extension| { matches!( extension.to_ascii_lowercase().as_str(), "jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "avif" ) }) } #[cfg(test)] mod tests { use std::io::Write; use super::*; #[test] fn filters_supported_image_extensions_case_insensitively() { for name in [ "1.JPG", "2.jpeg", "3.png", "4.WebP", "5.gif", "6.bmp", "7.avif", ] { assert!(is_supported_image_name(name), "{name}"); } assert!(!is_supported_image_name("notes.txt")); assert!(!is_supported_image_name("folder/")); } #[test] fn rejects_oversized_dimensions() { assert!(validate_dimensions(10_000, 10_000, "valid.png").is_ok()); assert!(validate_dimensions(10_001, 10_000, "large.png").is_err()); assert!(validate_dimensions(30_001, 1, "wide.png").is_err()); } #[test] fn lists_and_decodes_images_from_a_zip() { let path = std::env::temp_dir().join(format!( "comicviewer-archive-test-{}.zip", std::process::id() )); let mut encoded_png = Cursor::new(Vec::new()); let image = image::RgbaImage::from_pixel(2, 3, image::Rgba([10, 20, 30, 255])); image::DynamicImage::ImageRgba8(image) .write_to(&mut encoded_png, image::ImageFormat::Png) .unwrap(); let file = File::create(&path).unwrap(); let mut archive = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default(); archive.start_file("page10.png", options).unwrap(); archive.write_all(encoded_png.get_ref()).unwrap(); archive.start_file("page2.png", options).unwrap(); archive.write_all(encoded_png.get_ref()).unwrap(); archive.start_file("notes.txt", options).unwrap(); archive.write_all(b"ignored").unwrap(); archive.finish().unwrap(); let pages = list_pages(&path, true).unwrap(); assert_eq!( pages .iter() .map(|page| page.name.as_str()) .collect::>(), ["page2.png", "page10.png"] ); let decoded = decode_page(&path, &pages[0]).unwrap(); assert_eq!((decoded.width, decoded.height), (2, 3)); assert_eq!(decoded.frames[0].rgba.len(), 2 * 3 * 4); std::fs::remove_file(path).unwrap(); } #[test] fn decodes_a_standalone_image() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("photo.png"); let image = image::RgbaImage::from_pixel(4, 5, image::Rgba([1, 2, 3, 255])); image.save(&path).unwrap(); let decoded = decode_image_file(&path, "photo.png").unwrap(); assert_eq!((decoded.width, decoded.height), (4, 5)); assert_eq!(decoded.frames[0].rgba.len(), 4 * 5 * 4); } #[test] fn decodes_all_animated_gif_frames() { let mut encoded = Vec::new(); let first = image::Frame::from_parts( image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255])), 0, 0, image::Delay::from_numer_denom_ms(20, 1), ); let second = image::Frame::from_parts( image::RgbaImage::from_pixel(2, 1, image::Rgba([0, 0, 255, 255])), 0, 0, image::Delay::from_numer_denom_ms(30, 1), ); let mut encoder = image::codecs::gif::GifEncoder::new(&mut encoded); encoder .set_repeat(image::codecs::gif::Repeat::Finite(2)) .unwrap(); encoder.encode_frames([first, second]).unwrap(); drop(encoder); let decoded = decode_image_bytes(&encoded, "animated.gif").unwrap(); assert_eq!(decoded.frames.len(), 2); assert_eq!(decoded.frames[0].delay, Duration::from_millis(20)); assert_eq!(decoded.frames[1].delay, Duration::from_millis(30)); assert_eq!(decoded.loop_count, Some(2)); assert_ne!(decoded.frames[0].rgba, decoded.frames[1].rgba); } }