Skip to main content

resopt/
optimizer.rs

1use anyhow::{Context, Result, bail, ensure};
2use serde::{Deserialize, Serialize};
3use std::{io::Cursor, time::Duration};
4
5pub(crate) const MAX_INPUT: usize = 64 * 1024 * 1024;
6const MAX_DECODED: usize = 256 * 1024 * 1024;
7
8/// Lossless PNG policy. Unknown fields are rejected (including lossy settings).
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(default, deny_unknown_fields)]
11pub struct Policy {
12    pub png_level: u8,
13    pub min_input_bytes: u64,
14    pub min_savings_bytes: u64,
15    pub min_savings_percent: f64,
16}
17
18impl Default for Policy {
19    fn default() -> Self {
20        Self {
21            png_level: 2,
22            min_input_bytes: 50 * 1024,
23            min_savings_bytes: 1024,
24            min_savings_percent: 1.0,
25        }
26    }
27}
28
29impl Policy {
30    pub fn validate(&self) -> Result<()> {
31        ensure!(
32            self.png_level <= 6,
33            "png_level must be 0..=6 (effort, not quality)"
34        );
35        ensure!(
36            self.min_savings_percent.is_finite()
37                && (0.0..=100.0).contains(&self.min_savings_percent),
38            "min_savings_percent must be 0..=100"
39        );
40        Ok(())
41    }
42}
43
44pub(crate) fn optimize(original: &[u8], policy: &Policy) -> Result<Vec<u8>> {
45    policy.validate()?;
46    ensure!(original.len() <= MAX_INPUT, "input exceeds 64 MiB limit");
47    let chunks = chunks(original)?;
48    ensure!(
49        !chunks.iter().any(|(kind, _)| kind == b"acTL"),
50        "animated PNG is not supported yet"
51    );
52    let mut options = oxipng::Options::from_preset(policy.png_level);
53    options.optimize_alpha = false;
54    options.bit_depth_reduction = false;
55    options.color_type_reduction = false;
56    options.palette_reduction = false;
57    options.grayscale_reduction = false;
58    options.scale_16 = false;
59    options.interlace = None;
60    options.strip = oxipng::StripChunks::None;
61    options.max_decompressed_size = Some(MAX_DECODED);
62    options.timeout = Some(Duration::from_secs(30));
63    let candidate = oxipng::optimize_from_memory(original, &options)?;
64    verify(original, &candidate)?;
65    Ok(candidate)
66}
67
68/// Independent decoding plus exact non-IDAT chunk comparison. This deliberately
69/// rejects candidates that rewrite metadata, even if the rewrite seems harmless.
70pub(crate) fn verify(original: &[u8], candidate: &[u8]) -> Result<()> {
71    ensure!(
72        original.len() <= MAX_INPUT && candidate.len() <= MAX_INPUT,
73        "input exceeds 64 MiB limit"
74    );
75    let original_chunks = chunks(original)?;
76    ensure!(
77        !original_chunks.iter().any(|(kind, _)| kind == b"acTL"),
78        "animated PNG is not supported yet"
79    );
80    ensure!(
81        original_chunks == chunks(candidate)?,
82        "non-IDAT chunks changed; candidate rejected"
83    );
84    ensure!(
85        decode(original)? == decode(candidate)?,
86        "decoded pixels changed; candidate rejected"
87    );
88    Ok(())
89}
90
91type Chunk<'a> = ([u8; 4], &'a [u8]);
92
93fn chunks(bytes: &[u8]) -> Result<Vec<Chunk<'_>>> {
94    ensure!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "not a PNG");
95    let mut offset = 8;
96    let mut result = Vec::new();
97    let mut saw_idat = false;
98    while offset < bytes.len() {
99        ensure!(bytes.len() - offset >= 12, "truncated PNG chunk");
100        let length = u32::from_be_bytes(bytes[offset..offset + 4].try_into()?) as usize;
101        let end = offset
102            .checked_add(length)
103            .and_then(|end| end.checked_add(12))
104            .context("PNG chunk overflow")?;
105        ensure!(end <= bytes.len(), "truncated PNG chunk payload");
106        let kind: [u8; 4] = bytes[offset + 4..offset + 8].try_into()?;
107        if kind == *b"IDAT" && !saw_idat {
108            result.push((kind, &bytes[0..0]));
109            saw_idat = true;
110        } else if kind != *b"IDAT" {
111            result.push((kind, &bytes[offset + 8..end - 4]));
112        }
113        offset = end;
114        if kind == *b"IEND" {
115            ensure!(offset == bytes.len(), "data after IEND is not supported");
116            return Ok(result);
117        }
118    }
119    bail!("PNG has no IEND")
120}
121
122fn decode(bytes: &[u8]) -> Result<Vec<u8>> {
123    let mut decoder = png::Decoder::new(Cursor::new(bytes));
124    decoder.set_limits(png::Limits { bytes: MAX_DECODED });
125    let mut reader = decoder.read_info()?;
126    let size = reader
127        .output_buffer_size()
128        .context("PNG output buffer overflow")?;
129    ensure!(size <= MAX_DECODED, "decoded image exceeds 256 MiB limit");
130    let mut buffer = vec![0; size];
131    let frame = reader.next_frame(&mut buffer)?;
132    buffer.truncate(frame.buffer_size());
133    reader.finish()?;
134    Ok(buffer)
135}