1use std::{
2 fs,
3 io::{self, Read as _},
4};
5
6use camino::{Utf8Path, Utf8PathBuf};
7use sha2::{Digest, Sha256};
8use thiserror::Error;
9
10const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
11const MAX_ASSET_BYTES: u64 = 16 * 1024 * 1024;
12const MAX_DECODED_ASSET_BYTES: u64 = 128 * 1024 * 1024;
13
14#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct ProjectAssets {
17 icon: Vec<u8>,
18 icon_metadata: PngMetadata,
19 splash: Vec<u8>,
20 splash_metadata: PngMetadata,
21 fingerprint: String,
22}
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)]
26pub struct PngMetadata {
27 pub width: u32,
29 pub height: u32,
31 pub bit_depth: u8,
33 pub color_type: u8,
35 pub has_alpha: bool,
37}
38
39impl ProjectAssets {
40 pub fn load(project_dir: &Utf8Path) -> Result<Self, AssetError> {
47 let icon_path = project_dir.join("assets/icon.png");
48 let splash_path = project_dir.join("assets/splash.png");
49 let (icon, icon_metadata) = read_png(&icon_path, "icon")?;
50 let (splash, splash_metadata) = read_png(&splash_path, "splash")?;
51 let mut hasher = Sha256::new();
52 hasher.update(b"cargo-ferry-project-assets-v1");
53 hasher.update(b"icon.png\0");
54 hasher.update(&icon);
55 hasher.update(b"splash.png\0");
56 hasher.update(&splash);
57 let fingerprint = hex::encode(&hasher.finalize()[..12]);
58 Ok(Self {
59 icon,
60 icon_metadata,
61 splash,
62 splash_metadata,
63 fingerprint,
64 })
65 }
66
67 pub fn icon(&self) -> &[u8] {
69 &self.icon
70 }
71
72 pub const fn icon_metadata(&self) -> PngMetadata {
74 self.icon_metadata
75 }
76
77 pub fn splash(&self) -> &[u8] {
79 &self.splash
80 }
81
82 pub const fn splash_metadata(&self) -> PngMetadata {
84 self.splash_metadata
85 }
86
87 pub fn fingerprint(&self) -> &str {
89 &self.fingerprint
90 }
91}
92
93#[derive(Debug, Error)]
95pub enum AssetError {
96 #[error("project {kind} asset must be a regular PNG file at `{path}`")]
98 NotRegular {
99 kind: &'static str,
101 path: Utf8PathBuf,
103 },
104 #[error("project {kind} asset `{path}` is {size} bytes; maximum is {maximum} bytes")]
106 TooLarge {
107 kind: &'static str,
109 path: Utf8PathBuf,
111 size: u64,
113 maximum: u64,
115 },
116 #[error("project {kind} asset `{path}` is not a valid PNG: {reason}")]
118 InvalidPng {
119 kind: &'static str,
121 path: Utf8PathBuf,
123 reason: &'static str,
125 },
126 #[error("could not {operation} project {kind} asset `{path}`: {source}")]
128 Io {
129 operation: &'static str,
131 kind: &'static str,
133 path: Utf8PathBuf,
135 #[source]
137 source: io::Error,
138 },
139}
140
141fn read_png(path: &Utf8Path, kind: &'static str) -> Result<(Vec<u8>, PngMetadata), AssetError> {
142 let metadata = fs::symlink_metadata(path).map_err(|source| {
143 if source.kind() == io::ErrorKind::NotFound {
144 AssetError::NotRegular {
145 kind,
146 path: path.to_owned(),
147 }
148 } else {
149 AssetError::Io {
150 operation: "inspect",
151 kind,
152 path: path.to_owned(),
153 source,
154 }
155 }
156 })?;
157 if metadata.file_type().is_symlink() || !metadata.is_file() {
158 return Err(AssetError::NotRegular {
159 kind,
160 path: path.to_owned(),
161 });
162 }
163 if metadata.len() > MAX_ASSET_BYTES {
164 return Err(AssetError::TooLarge {
165 kind,
166 path: path.to_owned(),
167 size: metadata.len(),
168 maximum: MAX_ASSET_BYTES,
169 });
170 }
171 let bytes = fs::read(path).map_err(|source| AssetError::Io {
172 operation: "read",
173 kind,
174 path: path.to_owned(),
175 source,
176 })?;
177 let png_metadata = validate_png(&bytes, path, kind)?;
178 Ok((bytes, png_metadata))
179}
180
181fn validate_png(
182 bytes: &[u8],
183 path: &Utf8Path,
184 kind: &'static str,
185) -> Result<PngMetadata, AssetError> {
186 let invalid = |reason| AssetError::InvalidPng {
187 kind,
188 path: path.to_owned(),
189 reason,
190 };
191 if bytes.len() < 57 || bytes.get(..8) != Some(PNG_SIGNATURE) {
192 return Err(invalid("missing PNG signature or required chunks"));
193 }
194 let mut offset = PNG_SIGNATURE.len();
195 let mut png_metadata = None;
196 let mut transparency = false;
197 let mut idat = Vec::new();
198 let mut finished = false;
199 while offset < bytes.len() {
200 let header_end = offset
201 .checked_add(8)
202 .filter(|end| *end <= bytes.len())
203 .ok_or_else(|| invalid("truncated chunk header"))?;
204 let length = usize::try_from(u32::from_be_bytes(
205 bytes[offset..offset + 4]
206 .try_into()
207 .expect("bounded chunk length"),
208 ))
209 .map_err(|_| invalid("chunk length is not representable"))?;
210 let data_end = header_end
211 .checked_add(length)
212 .filter(|end| {
213 end.checked_add(4)
214 .is_some_and(|crc_end| crc_end <= bytes.len())
215 })
216 .ok_or_else(|| invalid("truncated or oversized chunk"))?;
217 let chunk_end = data_end + 4;
218 let chunk_type = &bytes[offset + 4..header_end];
219 let expected_crc = u32::from_be_bytes(
220 bytes[data_end..chunk_end]
221 .try_into()
222 .expect("bounded chunk CRC"),
223 );
224 if png_crc32(&bytes[offset + 4..data_end]) != expected_crc {
225 return Err(invalid("chunk CRC does not match"));
226 }
227 match chunk_type {
228 b"IHDR" if offset == PNG_SIGNATURE.len() && length == 13 => {
229 let data = &bytes[header_end..data_end];
230 let width = u32::from_be_bytes(data[..4].try_into().expect("bounded width"));
231 let height = u32::from_be_bytes(data[4..8].try_into().expect("bounded height"));
232 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
233 return Err(invalid(
234 "image dimensions must be between 1 and 16384 pixels",
235 ));
236 }
237 if !valid_png_color_format(data[8], data[9])
238 || data[10] != 0
239 || data[11] != 0
240 || data[12] > 1
241 {
242 return Err(invalid("unsupported or invalid IHDR encoding"));
243 }
244 png_metadata = Some(PngMetadata {
245 width,
246 height,
247 bit_depth: data[8],
248 color_type: data[9],
249 has_alpha: matches!(data[9], 4 | 6),
250 });
251 }
252 b"IHDR" => return Err(invalid("first chunk is not a 13-byte IHDR")),
253 b"tRNS" if png_metadata.is_some() && idat.is_empty() && !finished => {
254 transparency = true;
255 }
256 b"IDAT" if png_metadata.is_some() && !finished => {
257 idat.extend_from_slice(&bytes[header_end..data_end]);
258 }
259 b"IEND" if length == 0 && png_metadata.is_some() && !idat.is_empty() => {
260 finished = true;
261 offset = chunk_end;
262 break;
263 }
264 b"IEND" => return Err(invalid("invalid IEND ordering or length")),
265 _ if png_metadata.is_none() => return Err(invalid("IHDR is not the first chunk")),
266 _ => {}
267 }
268 offset = chunk_end;
269 }
270 if !finished || offset != bytes.len() {
271 return Err(invalid("final chunk is not IEND"));
272 }
273 let mut decoded = Vec::new();
274 flate2::read::ZlibDecoder::new(idat.as_slice())
275 .take(MAX_DECODED_ASSET_BYTES + 1)
276 .read_to_end(&mut decoded)
277 .map_err(|_| invalid("IDAT data is not a valid zlib stream"))?;
278 if decoded.is_empty()
279 || u64::try_from(decoded.len()).unwrap_or(u64::MAX) > MAX_DECODED_ASSET_BYTES
280 {
281 return Err(invalid("decoded image data is empty or exceeds 128 MiB"));
282 }
283 let mut png_metadata = png_metadata.expect("validated PNG contains IHDR");
284 png_metadata.has_alpha |= transparency;
285 Ok(png_metadata)
286}
287
288fn valid_png_color_format(bit_depth: u8, color_type: u8) -> bool {
289 match color_type {
290 0 => matches!(bit_depth, 1 | 2 | 4 | 8 | 16),
291 2 | 4 | 6 => matches!(bit_depth, 8 | 16),
292 3 => matches!(bit_depth, 1 | 2 | 4 | 8),
293 _ => false,
294 }
295}
296
297fn png_crc32(bytes: &[u8]) -> u32 {
298 let mut crc = u32::MAX;
299 for byte in bytes {
300 crc ^= u32::from(*byte);
301 for _ in 0..8 {
302 crc = if crc & 1 == 0 {
303 crc >> 1
304 } else {
305 (crc >> 1) ^ 0xedb_88320
306 };
307 }
308 }
309 !crc
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 mod png_fixture {
317 include!(concat!(
318 env!("CARGO_MANIFEST_DIR"),
319 "/tests/fixtures/opaque_png.rs"
320 ));
321 }
322
323 use png_fixture::OPAQUE_1024_PNG as PNG;
324
325 fn fixture() -> (tempfile::TempDir, Utf8PathBuf) {
326 let temporary = tempfile::tempdir().unwrap();
327 let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
328 fs::create_dir(root.join("assets")).unwrap();
329 fs::write(root.join("assets/icon.png"), PNG).unwrap();
330 fs::write(root.join("assets/splash.png"), PNG).unwrap();
331 (temporary, root)
332 }
333
334 #[test]
335 fn loads_and_fingerprints_both_assets() {
336 let (_temporary, root) = fixture();
337 let first = ProjectAssets::load(&root).unwrap();
338 let mut malformed = PNG.to_vec();
339 malformed.push(0);
340 fs::write(root.join("assets/splash.png"), malformed).unwrap();
341 assert!(matches!(
342 ProjectAssets::load(&root),
343 Err(AssetError::InvalidPng { .. })
344 ));
345 fs::write(root.join("assets/splash.png"), PNG).unwrap();
346 let second = ProjectAssets::load(&root).unwrap();
347 assert_eq!(first.fingerprint(), second.fingerprint());
348 assert_eq!(first.icon(), PNG);
349 assert_eq!(
350 (first.icon_metadata().width, first.icon_metadata().height),
351 (1024, 1024)
352 );
353 assert!(!first.icon_metadata().has_alpha);
354 }
355
356 #[test]
357 fn rejects_missing_and_malformed_assets() {
358 let (_temporary, root) = fixture();
359 fs::write(root.join("assets/icon.png"), b"not a png").unwrap();
360 assert!(matches!(
361 ProjectAssets::load(&root),
362 Err(AssetError::InvalidPng { kind: "icon", .. })
363 ));
364 fs::remove_file(root.join("assets/icon.png")).unwrap();
365 assert!(matches!(
366 ProjectAssets::load(&root),
367 Err(AssetError::NotRegular { kind: "icon", .. })
368 ));
369 }
370
371 #[test]
372 fn rejects_png_with_invalid_compressed_pixels() {
373 const INVALID_ZLIB_PNG: &[u8] = &[
374 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48,
375 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
376 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x08,
377 0x1d, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x80, 0x01, 0xff, 0x89, 0x99,
378 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
379 ];
380 let (_temporary, root) = fixture();
381 fs::write(root.join("assets/icon.png"), INVALID_ZLIB_PNG).unwrap();
382 assert!(matches!(
383 ProjectAssets::load(&root),
384 Err(AssetError::InvalidPng { kind: "icon", .. })
385 ));
386 }
387
388 #[cfg(unix)]
389 #[test]
390 fn rejects_asset_symlinks() {
391 use std::os::unix::fs::symlink;
392
393 let (_temporary, root) = fixture();
394 fs::rename(
395 root.join("assets/icon.png"),
396 root.join("assets/real-icon.png"),
397 )
398 .unwrap();
399 symlink("real-icon.png", root.join("assets/icon.png")).unwrap();
400 assert!(matches!(
401 ProjectAssets::load(&root),
402 Err(AssetError::NotRegular { kind: "icon", .. })
403 ));
404 }
405}