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#[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
68pub(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}