Skip to main content

rustferry_core/
assets.rs

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/// Validated project icon and splash image bytes.
15#[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/// Metadata read from a validated PNG without trusting filename extensions.
25#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)]
26pub struct PngMetadata {
27    /// Pixel width.
28    pub width: u32,
29    /// Pixel height.
30    pub height: u32,
31    /// PNG bit depth.
32    pub bit_depth: u8,
33    /// PNG color-type number from the IHDR chunk.
34    pub color_type: u8,
35    /// Whether pixels can carry transparency through alpha or a `tRNS` chunk.
36    pub has_alpha: bool,
37}
38
39impl ProjectAssets {
40    /// Load `assets/icon.png` and `assets/splash.png` from a project.
41    ///
42    /// # Errors
43    ///
44    /// Returns a typed error when either file is missing, unsafe, oversized, unreadable, or not
45    /// a structurally valid PNG.
46    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    /// Validated icon PNG bytes.
68    pub fn icon(&self) -> &[u8] {
69        &self.icon
70    }
71
72    /// Validated icon dimensions and color representation.
73    pub const fn icon_metadata(&self) -> PngMetadata {
74        self.icon_metadata
75    }
76
77    /// Validated splash PNG bytes.
78    pub fn splash(&self) -> &[u8] {
79        &self.splash
80    }
81
82    /// Validated splash dimensions and color representation.
83    pub const fn splash_metadata(&self) -> PngMetadata {
84        self.splash_metadata
85    }
86
87    /// Stable digest of both filenames and file contents.
88    pub fn fingerprint(&self) -> &str {
89        &self.fingerprint
90    }
91}
92
93/// Project asset loading or validation failure.
94#[derive(Debug, Error)]
95pub enum AssetError {
96    /// An asset path is absent or is not a regular file.
97    #[error("project {kind} asset must be a regular PNG file at `{path}`")]
98    NotRegular {
99        /// Asset role.
100        kind: &'static str,
101        /// Expected path.
102        path: Utf8PathBuf,
103    },
104    /// An asset exceeds the bounded input size.
105    #[error("project {kind} asset `{path}` is {size} bytes; maximum is {maximum} bytes")]
106    TooLarge {
107        /// Asset role.
108        kind: &'static str,
109        /// Asset path.
110        path: Utf8PathBuf,
111        /// Observed file size.
112        size: u64,
113        /// Maximum accepted file size.
114        maximum: u64,
115    },
116    /// An asset is not a minimally valid, bounded PNG.
117    #[error("project {kind} asset `{path}` is not a valid PNG: {reason}")]
118    InvalidPng {
119        /// Asset role.
120        kind: &'static str,
121        /// Asset path.
122        path: Utf8PathBuf,
123        /// Failed structural invariant.
124        reason: &'static str,
125    },
126    /// A filesystem operation failed.
127    #[error("could not {operation} project {kind} asset `{path}`: {source}")]
128    Io {
129        /// Operation being attempted.
130        operation: &'static str,
131        /// Asset role.
132        kind: &'static str,
133        /// Asset path.
134        path: Utf8PathBuf,
135        /// Operating-system error.
136        #[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}