Skip to main content

rustferry_codegen/
assets.rs

1use std::fs::{self, OpenOptions};
2use std::io::Write as _;
3
4use camino::{Utf8Path, Utf8PathBuf};
5use image::{
6    DynamicImage, ExtendedColorType, ImageEncoder as _, ImageFormat,
7    codecs::png::{CompressionType, FilterType as PngFilterType, PngEncoder},
8    imageops::FilterType as ResizeFilterType,
9};
10use rustferry_core::{AssetError, PngMetadata, ProjectAssets};
11use serde::Serialize;
12use sha2::{Digest, Sha256};
13use thiserror::Error;
14
15const MAX_SOURCE_BYTES: u64 = 16 * 1024 * 1024;
16const MIN_SOURCE_EDGE: u32 = 1_024;
17
18const ANDROID_ICON_SIZES: [(&str, u32); 5] = [
19    ("mipmap-mdpi/ferry_icon.png", 48),
20    ("mipmap-hdpi/ferry_icon.png", 72),
21    ("mipmap-xhdpi/ferry_icon.png", 96),
22    ("mipmap-xxhdpi/ferry_icon.png", 144),
23    ("mipmap-xxxhdpi/ferry_icon.png", 192),
24];
25
26const IOS_ICON_SIZES: [IosIcon; 11] = [
27    IosIcon::new("20", "2x", "iphone", 40),
28    IosIcon::new("20", "3x", "iphone", 60),
29    IosIcon::new("29", "2x", "iphone", 58),
30    IosIcon::new("29", "3x", "iphone", 87),
31    IosIcon::new("40", "2x", "iphone", 80),
32    IosIcon::new("40", "3x", "iphone", 120),
33    IosIcon::new("60", "2x", "iphone", 120),
34    IosIcon::new("60", "3x", "iphone", 180),
35    IosIcon::new("76", "2x", "ipad", 152),
36    IosIcon::new("83.5", "2x", "ipad", 167),
37    IosIcon::new("1024", "1x", "ios-marketing", 1_024),
38];
39
40/// One actionable release-readiness problem in the project source assets.
41#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
42pub struct AssetIssue {
43    /// Stable diagnostic identifier.
44    pub code: &'static str,
45    /// Exact failed invariant.
46    pub message: String,
47    /// Concrete remediation.
48    pub help: &'static str,
49}
50
51/// Validation result for the user-owned icon and splash sources.
52#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
53pub struct AssetCheck {
54    /// Project root containing `assets/`.
55    pub project: Utf8PathBuf,
56    /// Icon PNG metadata.
57    pub icon: PngMetadata,
58    /// Splash PNG metadata.
59    pub splash: PngMetadata,
60    /// Stable digest of both source files.
61    pub fingerprint: String,
62    /// Whether generation can produce release-ready platform inputs.
63    pub release_ready: bool,
64    /// Actionable validation problems.
65    pub issues: Vec<AssetIssue>,
66}
67
68/// Deterministic generated platform asset set below `target/ferry/assets`.
69#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
70pub struct GeneratedAssetSet {
71    /// Stable source fingerprint.
72    pub fingerprint: String,
73    /// Generated asset-catalog root.
74    pub root: Utf8PathBuf,
75    /// Generated paths relative to `root`.
76    pub files: Vec<Utf8PathBuf>,
77    /// Whether an existing complete generated set was reused.
78    pub cache_hit: bool,
79}
80
81/// Deterministic in-memory platform derivatives from one validated source snapshot.
82#[derive(Clone, Debug, Eq, PartialEq)]
83pub struct RenderedPlatformAssets {
84    /// Stable source fingerprint.
85    pub fingerprint: String,
86    /// Generated paths and bytes relative to the asset-set root.
87    pub files: Vec<(Utf8PathBuf, Vec<u8>)>,
88}
89
90/// Platform subtree within a generated asset set.
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum GeneratedAssetPlatform {
93    /// Android density-aware resources.
94    Android,
95    /// Apple asset catalog.
96    Ios,
97}
98
99impl GeneratedAssetPlatform {
100    const fn directory(self) -> &'static str {
101        match self {
102            Self::Android => "android",
103            Self::Ios => "ios",
104        }
105    }
106}
107
108/// Validate source dimensions, color representation, and release constraints.
109///
110/// # Errors
111///
112/// Returns a typed error when either source file is missing, unsafe, oversized, or malformed.
113pub fn check_project_assets(project: &Utf8Path) -> Result<AssetCheck, AssetPipelineError> {
114    let project = canonical_project(project)?;
115    let assets = ProjectAssets::load(&project)?;
116    let icon = assets.icon_metadata();
117    let splash = assets.splash_metadata();
118    let mut issues = icon_issues(icon);
119    if splash.width < MIN_SOURCE_EDGE || splash.height < MIN_SOURCE_EDGE {
120        issues.push(AssetIssue {
121            code: "ferry.assets.splash_too_small",
122            message: format!(
123                "splash is {}x{}; both edges must be at least {MIN_SOURCE_EDGE}px",
124                splash.width, splash.height
125            ),
126            help: "Replace assets/splash.png with a larger RGB PNG, then run `cargo ferry assets check`.",
127        });
128    }
129    Ok(AssetCheck {
130        project,
131        icon,
132        splash,
133        fingerprint: assets.fingerprint().to_owned(),
134        release_ready: issues.is_empty(),
135        issues,
136    })
137}
138
139/// Generate Android density images and an iOS asset catalog atomically.
140///
141/// When `source` is supplied it becomes the source for both icon and splash derivatives without
142/// modifying the user's source tree. Otherwise `assets/icon.png` and `assets/splash.png` are used.
143///
144/// # Errors
145///
146/// Returns a typed error for unsafe roots, invalid source images, release-readiness failures, or
147/// filesystem writes. Existing complete output is reused; partial output is never overwritten.
148pub fn generate_platform_assets(
149    project: &Utf8Path,
150    source: Option<&Utf8Path>,
151) -> Result<GeneratedAssetSet, AssetPipelineError> {
152    let project = canonical_project(project)?;
153    let project_assets = ProjectAssets::load(&project)?;
154    let (icon_bytes, splash_bytes, fingerprint) = if let Some(source) = source {
155        let source = canonical_source(&project, source)?;
156        let bytes = read_source(&source)?;
157        let fingerprint = source_fingerprint(&bytes);
158        (bytes.clone(), bytes, fingerprint)
159    } else {
160        (
161            project_assets.icon().to_vec(),
162            project_assets.splash().to_vec(),
163            project_assets.fingerprint().to_owned(),
164        )
165    };
166
167    let (icon, splash) = decode_and_validate_sources(&icon_bytes, &splash_bytes)?;
168    let asset_root = safe_asset_root(&project)?;
169    let destination = asset_root.join(&fingerprint);
170    if destination.exists() {
171        let files = validate_cached_set(&destination, &fingerprint)?;
172        return Ok(GeneratedAssetSet {
173            fingerprint,
174            root: destination,
175            files,
176            cache_hit: true,
177        });
178    }
179    let rendered = render_decoded_platform_assets(&icon, &splash, fingerprint, None)?;
180    let fingerprint = rendered.fingerprint.clone();
181
182    let temporary = tempfile::Builder::new()
183        .prefix(".cargo-ferry-assets-")
184        .tempdir_in(&asset_root)
185        .map_err(|source| AssetPipelineError::Io {
186            operation: "create asset staging directory",
187            path: asset_root.clone(),
188            source,
189        })?;
190    let staging = Utf8PathBuf::from_path_buf(temporary.path().to_path_buf())
191        .map_err(AssetPipelineError::NonUtf8Path)?;
192
193    let mut files = Vec::with_capacity(rendered.files.len());
194    let mut manifest_files = Vec::with_capacity(rendered.files.len());
195    for (relative, bytes) in rendered.files {
196        write_new(&staging, &relative, &bytes)?;
197        manifest_files.push(AssetManifestFile {
198            path: relative.clone(),
199            sha256: sha256_bytes(&bytes),
200        });
201        files.push(relative);
202    }
203    let manifest_path = Utf8PathBuf::from("manifest.json");
204    write_json(
205        &staging,
206        &manifest_path,
207        &AssetManifest {
208            schema_version: 2,
209            fingerprint: fingerprint.clone(),
210            files: manifest_files,
211        },
212    )?;
213    files.push(manifest_path);
214
215    let staging = Utf8Path::from_path(temporary.path())
216        .ok_or_else(|| AssetPipelineError::NonUtf8Path(temporary.path().to_path_buf()))?;
217    let cache_hit = match fs::rename(staging, &destination) {
218        Ok(()) => false,
219        Err(publish_error) => {
220            drop(temporary);
221            match fs::symlink_metadata(&destination) {
222                Ok(_) => {
223                    files = validate_cached_set(&destination, &fingerprint)?;
224                    true
225                }
226                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
227                    return Err(AssetPipelineError::Io {
228                        operation: "commit generated asset set",
229                        path: destination,
230                        source: publish_error,
231                    });
232                }
233                Err(source) => {
234                    return Err(AssetPipelineError::Io {
235                        operation: "inspect concurrent generated asset set",
236                        path: destination,
237                        source,
238                    });
239                }
240            }
241        }
242    };
243
244    Ok(GeneratedAssetSet {
245        fingerprint,
246        root: destination,
247        files,
248        cache_hit,
249    })
250}
251
252/// Render all platform derivatives without writing generated output.
253///
254/// # Errors
255///
256/// Returns a typed error when the validated PNG snapshot cannot produce release-ready outputs.
257pub fn render_platform_assets(
258    assets: &ProjectAssets,
259) -> Result<RenderedPlatformAssets, AssetPipelineError> {
260    render_platform_asset_bytes(
261        assets.icon(),
262        assets.splash(),
263        assets.fingerprint().to_owned(),
264    )
265}
266
267/// Render one platform's derivatives without writing generated output.
268///
269/// # Errors
270///
271/// Returns a typed error when the validated PNG snapshot cannot produce release-ready outputs.
272pub fn render_platform_assets_for(
273    assets: &ProjectAssets,
274    platform: GeneratedAssetPlatform,
275) -> Result<RenderedPlatformAssets, AssetPipelineError> {
276    let (icon, splash) = decode_and_validate_sources(assets.icon(), assets.splash())?;
277    render_decoded_platform_assets(
278        &icon,
279        &splash,
280        assets.fingerprint().to_owned(),
281        Some(platform),
282    )
283}
284
285/// Read one platform subtree from a complete generated set.
286///
287/// Returned paths are relative to the platform directory. Every path component is checked again
288/// immediately before reading, so a modified cache cannot redirect packaging through a symlink.
289///
290/// # Errors
291///
292/// Returns a typed error when the cache is incomplete, unsafe, or unreadable.
293pub fn read_generated_platform_assets(
294    generated: &GeneratedAssetSet,
295    platform: GeneratedAssetPlatform,
296) -> Result<Vec<(Utf8PathBuf, Vec<u8>)>, AssetPipelineError> {
297    let files = validate_cached_set(&generated.root, &generated.fingerprint)?;
298    let prefix = Utf8Path::new(platform.directory());
299    let mut loaded = Vec::new();
300    for relative in files {
301        let Ok(platform_relative) = relative.strip_prefix(prefix) else {
302            continue;
303        };
304        if platform_relative.as_str().is_empty() {
305            continue;
306        }
307        let path = generated.root.join(&relative);
308        let bytes = fs::read(&path).map_err(|source| AssetPipelineError::Io {
309            operation: "read generated platform asset",
310            path,
311            source,
312        })?;
313        loaded.push((platform_relative.to_owned(), bytes));
314    }
315    loaded.sort_by(|left, right| left.0.cmp(&right.0));
316    Ok(loaded)
317}
318
319fn render_platform_asset_bytes(
320    icon_bytes: &[u8],
321    splash_bytes: &[u8],
322    fingerprint: String,
323) -> Result<RenderedPlatformAssets, AssetPipelineError> {
324    let (icon, splash) = decode_and_validate_sources(icon_bytes, splash_bytes)?;
325    render_decoded_platform_assets(&icon, &splash, fingerprint, None)
326}
327
328fn decode_and_validate_sources(
329    icon_bytes: &[u8],
330    splash_bytes: &[u8],
331) -> Result<(DynamicImage, DynamicImage), AssetPipelineError> {
332    let icon = decode_source(icon_bytes, "icon")?;
333    let splash = decode_source(splash_bytes, "splash")?;
334    let issues = decoded_issues(&icon, &splash);
335    if !issues.is_empty() {
336        return Err(AssetPipelineError::NotReleaseReady { issues });
337    }
338    Ok((icon, splash))
339}
340
341fn render_decoded_platform_assets(
342    icon: &DynamicImage,
343    splash: &DynamicImage,
344    fingerprint: String,
345    platform: Option<GeneratedAssetPlatform>,
346) -> Result<RenderedPlatformAssets, AssetPipelineError> {
347    let mut files = Vec::new();
348    if platform.is_none() || platform == Some(GeneratedAssetPlatform::Android) {
349        for (relative, size) in ANDROID_ICON_SIZES {
350            let relative = Utf8PathBuf::from(format!("android/{relative}"));
351            files.push((relative.clone(), encode_png(&relative, icon, size)?));
352        }
353        let android_splash = Utf8PathBuf::from("android/drawable-nodpi/ferry_splash.png");
354        files.push((
355            android_splash.clone(),
356            encode_png(&android_splash, splash, 1_024)?,
357        ));
358    }
359    if platform.is_none() || platform == Some(GeneratedAssetPlatform::Ios) {
360        let mut ios_images = Vec::new();
361        for icon_spec in IOS_ICON_SIZES {
362            let filename = icon_spec.filename();
363            let relative =
364                Utf8PathBuf::from(format!("ios/Assets.xcassets/AppIcon.appiconset/{filename}"));
365            files.push((
366                relative.clone(),
367                encode_png(&relative, icon, icon_spec.pixels)?,
368            ));
369            ios_images.push(IosImageEntry {
370                filename,
371                idiom: icon_spec.idiom,
372                scale: icon_spec.scale,
373                size: Some(format!("{}x{}", icon_spec.points, icon_spec.points)),
374            });
375        }
376        let app_icon_contents =
377            Utf8PathBuf::from("ios/Assets.xcassets/AppIcon.appiconset/Contents.json");
378        files.push((
379            app_icon_contents,
380            json_bytes(&IosContents::new(ios_images))?,
381        ));
382
383        let mut launch_images = Vec::new();
384        for (scale, pixels) in [("1x", 512), ("2x", 1_024), ("3x", 1_536)] {
385            let filename = format!("FerryLaunch-{scale}.png");
386            let relative = Utf8PathBuf::from(format!(
387                "ios/Assets.xcassets/FerryLaunch.imageset/{filename}"
388            ));
389            files.push((relative.clone(), encode_png(&relative, splash, pixels)?));
390            launch_images.push(IosImageEntry {
391                filename,
392                idiom: "universal",
393                scale,
394                size: None,
395            });
396        }
397        let launch_contents =
398            Utf8PathBuf::from("ios/Assets.xcassets/FerryLaunch.imageset/Contents.json");
399        files.push((
400            launch_contents,
401            json_bytes(&IosContents::new(launch_images))?,
402        ));
403        files.push((
404            Utf8PathBuf::from("ios/Assets.xcassets/Contents.json"),
405            json_bytes(&IosContents::new(Vec::new()))?,
406        ));
407    }
408    files.sort_by(|left, right| left.0.cmp(&right.0));
409
410    Ok(RenderedPlatformAssets { fingerprint, files })
411}
412
413fn canonical_project(project: &Utf8Path) -> Result<Utf8PathBuf, AssetPipelineError> {
414    let metadata = fs::symlink_metadata(project).map_err(|source| AssetPipelineError::Io {
415        operation: "inspect project root",
416        path: project.to_owned(),
417        source,
418    })?;
419    if metadata.file_type().is_symlink() || !metadata.is_dir() {
420        return Err(AssetPipelineError::UnsafePath(project.to_owned()));
421    }
422    project
423        .canonicalize_utf8()
424        .map_err(|source| AssetPipelineError::Io {
425            operation: "canonicalize project root",
426            path: project.to_owned(),
427            source,
428        })
429}
430
431fn canonical_source(
432    project: &Utf8Path,
433    source: &Utf8Path,
434) -> Result<Utf8PathBuf, AssetPipelineError> {
435    let candidate = if source.is_absolute() {
436        source.to_owned()
437    } else {
438        project.join(source)
439    };
440    let metadata = fs::symlink_metadata(&candidate).map_err(|source| AssetPipelineError::Io {
441        operation: "inspect asset source",
442        path: candidate.clone(),
443        source,
444    })?;
445    if metadata.file_type().is_symlink() || !metadata.is_file() {
446        return Err(AssetPipelineError::UnsafePath(candidate));
447    }
448    let canonical = candidate
449        .canonicalize_utf8()
450        .map_err(|source| AssetPipelineError::Io {
451            operation: "canonicalize asset source",
452            path: candidate.clone(),
453            source,
454        })?;
455    if !canonical.starts_with(project) {
456        return Err(AssetPipelineError::SourceOutsideProject(canonical));
457    }
458    Ok(canonical)
459}
460
461fn safe_asset_root(project: &Utf8Path) -> Result<Utf8PathBuf, AssetPipelineError> {
462    let mut current = project.to_owned();
463    for component in ["target", "ferry", "assets"] {
464        current.push(component);
465        match fs::symlink_metadata(&current) {
466            Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
467                return Err(AssetPipelineError::UnsafePath(current));
468            }
469            Ok(_) => {}
470            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
471                match fs::create_dir(&current) {
472                    Ok(()) => {}
473                    Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
474                        let metadata = fs::symlink_metadata(&current).map_err(|source| {
475                            AssetPipelineError::Io {
476                                operation: "inspect concurrently created asset directory",
477                                path: current.clone(),
478                                source,
479                            }
480                        })?;
481                        if metadata.file_type().is_symlink() || !metadata.is_dir() {
482                            return Err(AssetPipelineError::UnsafePath(current));
483                        }
484                    }
485                    Err(source) => {
486                        return Err(AssetPipelineError::Io {
487                            operation: "create generated asset directory",
488                            path: current,
489                            source,
490                        });
491                    }
492                }
493            }
494            Err(source) => {
495                return Err(AssetPipelineError::Io {
496                    operation: "inspect generated asset directory",
497                    path: current,
498                    source,
499                });
500            }
501        }
502    }
503    Ok(current)
504}
505
506fn read_source(path: &Utf8Path) -> Result<Vec<u8>, AssetPipelineError> {
507    let size = fs::metadata(path)
508        .map_err(|source| AssetPipelineError::Io {
509            operation: "inspect asset source",
510            path: path.to_owned(),
511            source,
512        })?
513        .len();
514    if size > MAX_SOURCE_BYTES {
515        return Err(AssetPipelineError::SourceTooLarge {
516            path: path.to_owned(),
517            size,
518        });
519    }
520    fs::read(path).map_err(|source| AssetPipelineError::Io {
521        operation: "read asset source",
522        path: path.to_owned(),
523        source,
524    })
525}
526
527fn decode_source(bytes: &[u8], role: &'static str) -> Result<DynamicImage, AssetPipelineError> {
528    image::load_from_memory_with_format(bytes, ImageFormat::Png)
529        .map_err(|source| AssetPipelineError::Decode { role, source })
530}
531
532fn decoded_issues(icon: &DynamicImage, splash: &DynamicImage) -> Vec<AssetIssue> {
533    let mut issues = icon_issues(PngMetadata {
534        width: icon.width(),
535        height: icon.height(),
536        bit_depth: 8,
537        color_type: png_color_type(icon.color()),
538        has_alpha: icon.color().has_alpha(),
539    });
540    if splash.width() < MIN_SOURCE_EDGE || splash.height() < MIN_SOURCE_EDGE {
541        issues.push(AssetIssue {
542            code: "ferry.assets.splash_too_small",
543            message: format!(
544                "splash is {}x{}; both edges must be at least {MIN_SOURCE_EDGE}px",
545                splash.width(),
546                splash.height()
547            ),
548            help: "Provide a larger RGB PNG and rerun `cargo ferry assets generate`.",
549        });
550    }
551    issues
552}
553
554fn icon_issues(icon: PngMetadata) -> Vec<AssetIssue> {
555    let mut issues = Vec::new();
556    if icon.width != icon.height {
557        issues.push(AssetIssue {
558            code: "ferry.assets.icon_not_square",
559            message: format!("icon is {}x{}; it must be square", icon.width, icon.height),
560            help: "Use a square source PNG, ideally 1024x1024.",
561        });
562    }
563    if icon.width < MIN_SOURCE_EDGE || icon.height < MIN_SOURCE_EDGE {
564        issues.push(AssetIssue {
565            code: "ferry.assets.icon_too_small",
566            message: format!(
567                "icon is {}x{}; both edges must be at least {MIN_SOURCE_EDGE}px",
568                icon.width, icon.height
569            ),
570            help: "Replace assets/icon.png with a 1024x1024 or larger RGB PNG.",
571        });
572    }
573    if icon.has_alpha {
574        issues.push(AssetIssue {
575            code: "ferry.assets.icon_has_alpha",
576            message: "icon contains an alpha channel or transparency chunk".to_owned(),
577            help: "Flatten the icon onto an opaque background before generating iOS assets.",
578        });
579    }
580    issues
581}
582
583const fn png_color_type(color: image::ColorType) -> u8 {
584    match color {
585        image::ColorType::L8 | image::ColorType::L16 => 0,
586        image::ColorType::Rgb8 | image::ColorType::Rgb16 | image::ColorType::Rgb32F => 2,
587        image::ColorType::La8 | image::ColorType::La16 => 4,
588        _ => 6,
589    }
590}
591
592fn encode_png(
593    relative: &Utf8Path,
594    source: &DynamicImage,
595    size: u32,
596) -> Result<Vec<u8>, AssetPipelineError> {
597    let resized = source
598        .resize_exact(size, size, ResizeFilterType::Triangle)
599        .to_rgb8();
600    let mut encoded = Vec::new();
601    PngEncoder::new_with_quality(&mut encoded, CompressionType::Fast, PngFilterType::NoFilter)
602        .write_image(
603            resized.as_raw(),
604            resized.width(),
605            resized.height(),
606            ExtendedColorType::Rgb8,
607        )
608        .map_err(|source| AssetPipelineError::Encode {
609            path: relative.to_owned(),
610            source,
611        })?;
612    Ok(encoded)
613}
614
615fn json_bytes(value: &impl Serialize) -> Result<Vec<u8>, AssetPipelineError> {
616    let mut bytes = serde_json::to_vec_pretty(value).map_err(AssetPipelineError::Json)?;
617    bytes.push(b'\n');
618    Ok(bytes)
619}
620
621fn write_json(
622    root: &Utf8Path,
623    relative: &Utf8Path,
624    value: &impl Serialize,
625) -> Result<(), AssetPipelineError> {
626    let bytes = json_bytes(value)?;
627    write_new(root, relative, &bytes)
628}
629
630fn write_new(root: &Utf8Path, relative: &Utf8Path, bytes: &[u8]) -> Result<(), AssetPipelineError> {
631    if relative.is_absolute()
632        || relative
633            .components()
634            .any(|component| matches!(component, camino::Utf8Component::ParentDir))
635    {
636        return Err(AssetPipelineError::UnsafePath(relative.to_owned()));
637    }
638    let path = root.join(relative);
639    if let Some(parent) = path.parent() {
640        fs::create_dir_all(parent).map_err(|source| AssetPipelineError::Io {
641            operation: "create generated asset parent",
642            path: parent.to_owned(),
643            source,
644        })?;
645    }
646    let mut file = OpenOptions::new()
647        .write(true)
648        .create_new(true)
649        .open(&path)
650        .map_err(|source| AssetPipelineError::Io {
651            operation: "create generated asset",
652            path: path.clone(),
653            source,
654        })?;
655    file.write_all(bytes)
656        .and_then(|()| file.sync_all())
657        .map_err(|source| AssetPipelineError::Io {
658            operation: "write generated asset",
659            path,
660            source,
661        })
662}
663
664fn source_fingerprint(bytes: &[u8]) -> String {
665    let mut hasher = Sha256::new();
666    hasher.update(b"cargo-ferry-generated-assets-v1\0");
667    hasher.update(bytes);
668    hex::encode(&hasher.finalize()[..12])
669}
670
671fn sha256_bytes(bytes: &[u8]) -> String {
672    hex::encode(Sha256::digest(bytes))
673}
674
675fn validate_cached_set(
676    root: &Utf8Path,
677    fingerprint: &str,
678) -> Result<Vec<Utf8PathBuf>, AssetPipelineError> {
679    let metadata = fs::symlink_metadata(root).map_err(|source| AssetPipelineError::Io {
680        operation: "inspect cached asset set",
681        path: root.to_owned(),
682        source,
683    })?;
684    if metadata.file_type().is_symlink() || !metadata.is_dir() {
685        return Err(AssetPipelineError::UnsafePath(root.to_owned()));
686    }
687    let manifest_relative = Utf8Path::new("manifest.json");
688    validate_cached_file(root, manifest_relative)?;
689    let path = root.join(manifest_relative);
690    let bytes = fs::read(&path).map_err(|source| AssetPipelineError::Io {
691        operation: "read cached asset manifest",
692        path: path.clone(),
693        source,
694    })?;
695    let manifest: AssetManifest =
696        serde_json::from_slice(&bytes).map_err(AssetPipelineError::Json)?;
697    let manifest_paths = manifest
698        .files
699        .iter()
700        .map(|file| file.path.clone())
701        .collect::<Vec<_>>();
702    if manifest.schema_version != 2
703        || manifest.fingerprint != fingerprint
704        || manifest_paths != expected_asset_paths()
705    {
706        return Err(AssetPipelineError::InvalidCache(root.to_owned()));
707    }
708    for file in &manifest.files {
709        validate_cached_file(root, &file.path)?;
710        let path = root.join(&file.path);
711        let bytes = fs::read(&path).map_err(|source| AssetPipelineError::Io {
712            operation: "read cached asset for integrity validation",
713            path,
714            source,
715        })?;
716        if sha256_bytes(&bytes) != file.sha256 {
717            return Err(AssetPipelineError::InvalidCache(root.to_owned()));
718        }
719    }
720    let mut files = manifest_paths;
721    files.push(Utf8PathBuf::from("manifest.json"));
722    Ok(files)
723}
724
725fn expected_asset_paths() -> Vec<Utf8PathBuf> {
726    let mut paths = ANDROID_ICON_SIZES
727        .into_iter()
728        .map(|(relative, _)| Utf8PathBuf::from(format!("android/{relative}")))
729        .collect::<Vec<_>>();
730    paths.push(Utf8PathBuf::from("android/drawable-nodpi/ferry_splash.png"));
731    paths.extend(IOS_ICON_SIZES.into_iter().map(|icon| {
732        Utf8PathBuf::from(format!(
733            "ios/Assets.xcassets/AppIcon.appiconset/{}",
734            icon.filename()
735        ))
736    }));
737    paths.push(Utf8PathBuf::from(
738        "ios/Assets.xcassets/AppIcon.appiconset/Contents.json",
739    ));
740    paths.extend(["1x", "2x", "3x"].into_iter().map(|scale| {
741        Utf8PathBuf::from(format!(
742            "ios/Assets.xcassets/FerryLaunch.imageset/FerryLaunch-{scale}.png"
743        ))
744    }));
745    paths.extend([
746        Utf8PathBuf::from("ios/Assets.xcassets/FerryLaunch.imageset/Contents.json"),
747        Utf8PathBuf::from("ios/Assets.xcassets/Contents.json"),
748    ]);
749    paths.sort();
750    paths
751}
752
753fn validate_cached_file(root: &Utf8Path, relative: &Utf8Path) -> Result<(), AssetPipelineError> {
754    if relative.as_str().is_empty() || relative.is_absolute() {
755        return Err(AssetPipelineError::InvalidCache(root.to_owned()));
756    }
757    let components = relative.components().collect::<Vec<_>>();
758    if components
759        .iter()
760        .any(|component| !matches!(component, camino::Utf8Component::Normal(_)))
761    {
762        return Err(AssetPipelineError::InvalidCache(root.to_owned()));
763    }
764    let mut current = root.to_owned();
765    for (index, component) in components.iter().enumerate() {
766        let camino::Utf8Component::Normal(component) = component else {
767            unreachable!("validated normal path component")
768        };
769        current.push(component);
770        let metadata = match fs::symlink_metadata(&current) {
771            Ok(metadata) => metadata,
772            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
773                return Err(AssetPipelineError::InvalidCache(root.to_owned()));
774            }
775            Err(source) => {
776                return Err(AssetPipelineError::Io {
777                    operation: "inspect cached asset path",
778                    path: current,
779                    source,
780                });
781            }
782        };
783        if metadata.file_type().is_symlink() {
784            return Err(AssetPipelineError::UnsafePath(current));
785        }
786        let final_component = index + 1 == components.len();
787        if (final_component && !metadata.is_file()) || (!final_component && !metadata.is_dir()) {
788            return Err(AssetPipelineError::InvalidCache(root.to_owned()));
789        }
790    }
791    Ok(())
792}
793
794#[derive(Clone, Copy)]
795struct IosIcon {
796    points: &'static str,
797    scale: &'static str,
798    idiom: &'static str,
799    pixels: u32,
800}
801
802impl IosIcon {
803    const fn new(
804        points: &'static str,
805        scale: &'static str,
806        idiom: &'static str,
807        pixels: u32,
808    ) -> Self {
809        Self {
810            points,
811            scale,
812            idiom,
813            pixels,
814        }
815    }
816
817    fn filename(self) -> String {
818        format!(
819            "AppIcon-{}-{}.png",
820            self.points.replace('.', "_"),
821            self.scale
822        )
823    }
824}
825
826#[derive(Serialize)]
827struct IosContents<'a> {
828    #[serde(skip_serializing_if = "Vec::is_empty")]
829    images: Vec<IosImageEntry<'a>>,
830    info: IosInfo<'a>,
831}
832
833impl<'a> IosContents<'a> {
834    fn new(images: Vec<IosImageEntry<'a>>) -> Self {
835        Self {
836            images,
837            info: IosInfo {
838                author: "cargo-ferry",
839                version: 1,
840            },
841        }
842    }
843}
844
845#[derive(Serialize)]
846struct IosImageEntry<'a> {
847    filename: String,
848    idiom: &'a str,
849    scale: &'a str,
850    #[serde(skip_serializing_if = "Option::is_none")]
851    size: Option<String>,
852}
853
854#[derive(Serialize)]
855struct IosInfo<'a> {
856    author: &'a str,
857    version: u8,
858}
859
860#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, Serialize)]
861struct AssetManifest {
862    schema_version: u8,
863    fingerprint: String,
864    files: Vec<AssetManifestFile>,
865}
866
867#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, Serialize)]
868struct AssetManifestFile {
869    path: Utf8PathBuf,
870    sha256: String,
871}
872
873/// Asset validation or deterministic platform generation failure.
874#[derive(Debug, Error)]
875pub enum AssetPipelineError {
876    /// Project source assets are missing or malformed.
877    #[error(transparent)]
878    Asset(#[from] AssetError),
879    /// A path could not be represented in protocol and manifest output.
880    #[error("path is not valid UTF-8: {0:?}")]
881    NonUtf8Path(std::path::PathBuf),
882    /// A source path escaped the selected project.
883    #[error("asset source must stay inside the project: {0}")]
884    SourceOutsideProject(Utf8PathBuf),
885    /// A source or generated path crossed a symlink or non-directory boundary.
886    #[error("refusing unsafe asset path: {0}")]
887    UnsafePath(Utf8PathBuf),
888    /// A custom source exceeded the bounded decoder input.
889    #[error("asset source {path} is {size} bytes; maximum is {MAX_SOURCE_BYTES} bytes")]
890    SourceTooLarge {
891        /// Rejected source.
892        path: Utf8PathBuf,
893        /// Observed size.
894        size: u64,
895    },
896    /// A source was structurally readable but not a supported PNG.
897    #[error("could not decode {role} PNG: {source}")]
898    Decode {
899        /// Source role.
900        role: &'static str,
901        /// Decoder failure.
902        #[source]
903        source: image::ImageError,
904    },
905    /// A generated derivative could not be encoded.
906    #[error("could not encode generated asset {path}: {source}")]
907    Encode {
908        /// Generated relative path.
909        path: Utf8PathBuf,
910        /// Encoder failure.
911        #[source]
912        source: image::ImageError,
913    },
914    /// Sources are valid PNG files but not release-ready.
915    #[error("project assets are not release-ready")]
916    NotReleaseReady {
917        /// Exact actionable failures.
918        issues: Vec<AssetIssue>,
919    },
920    /// An existing fingerprint directory was incomplete or inconsistent.
921    #[error("generated asset cache is incomplete or inconsistent: {0}")]
922    InvalidCache(Utf8PathBuf),
923    /// JSON manifest/catalog serialization or parsing failed.
924    #[error("could not process generated asset metadata: {0}")]
925    Json(#[from] serde_json::Error),
926    /// Filesystem operation failed.
927    #[error("could not {operation} at {path}: {source}")]
928    Io {
929        /// Exact operation.
930        operation: &'static str,
931        /// Affected path.
932        path: Utf8PathBuf,
933        /// Operating-system failure.
934        #[source]
935        source: std::io::Error,
936    },
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942
943    const ICON: &[u8] = include_bytes!("../assets/default-icon.png");
944    const SPLASH: &[u8] = include_bytes!("../assets/default-splash.png");
945
946    fn fixture() -> (tempfile::TempDir, Utf8PathBuf) {
947        let temporary = tempfile::tempdir().unwrap();
948        let root = Utf8PathBuf::from_path_buf(temporary.path().to_path_buf()).unwrap();
949        fs::create_dir(root.join("assets")).unwrap();
950        fs::write(root.join("assets/icon.png"), ICON).unwrap();
951        fs::write(root.join("assets/splash.png"), SPLASH).unwrap();
952        (temporary, root)
953    }
954
955    #[test]
956    fn default_sources_are_release_ready_and_generate_deterministically() {
957        let (_temporary, root) = fixture();
958        let checked = check_project_assets(&root).unwrap();
959        assert!(checked.release_ready, "{:?}", checked.issues);
960        assert_eq!((checked.icon.width, checked.icon.height), (1_024, 1_024));
961        assert!(!checked.icon.has_alpha);
962
963        let first = generate_platform_assets(&root, None).unwrap();
964        assert!(!first.cache_hit);
965        assert!(
966            first
967                .root
968                .join("ios/Assets.xcassets/AppIcon.appiconset/Contents.json")
969                .is_file()
970        );
971        assert!(
972            first
973                .root
974                .join("android/mipmap-xxxhdpi/ferry_icon.png")
975                .is_file()
976        );
977        let second = generate_platform_assets(&root, None).unwrap();
978        assert!(second.cache_hit);
979        assert_eq!(first.fingerprint, second.fingerprint);
980        assert_eq!(first.files, second.files);
981    }
982
983    #[test]
984    fn modified_cached_bytes_are_rejected() {
985        let (_temporary, root) = fixture();
986        let generated = generate_platform_assets(&root, None).unwrap();
987        let icon = generated.root.join("android/mipmap-mdpi/ferry_icon.png");
988        let original = fs::read(&icon).unwrap();
989        fs::write(&icon, b"tampered").unwrap();
990
991        assert!(matches!(
992            generate_platform_assets(&root, None),
993            Err(AssetPipelineError::InvalidCache(path)) if path == generated.root
994        ));
995
996        fs::write(icon, original).unwrap();
997        let manifest_path = generated.root.join("manifest.json");
998        let mut manifest: AssetManifest =
999            serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
1000        manifest.files[0].sha256 = "0".repeat(64);
1001        let mut manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap();
1002        manifest_bytes.push(b'\n');
1003        fs::write(manifest_path, manifest_bytes).unwrap();
1004        assert!(matches!(
1005            generate_platform_assets(&root, None),
1006            Err(AssetPipelineError::InvalidCache(path)) if path == generated.root
1007        ));
1008    }
1009
1010    #[test]
1011    fn concurrent_generation_publishes_one_complete_cache() {
1012        use std::sync::{Arc, Barrier};
1013
1014        const WORKERS: usize = 2;
1015        let (_temporary, root) = fixture();
1016        let barrier = Arc::new(Barrier::new(WORKERS));
1017        let handles = (0..WORKERS)
1018            .map(|_| {
1019                let root = root.clone();
1020                let barrier = Arc::clone(&barrier);
1021                std::thread::spawn(move || {
1022                    barrier.wait();
1023                    generate_platform_assets(&root, None)
1024                })
1025            })
1026            .collect::<Vec<_>>();
1027        let generated = handles
1028            .into_iter()
1029            .map(|handle| handle.join().unwrap().unwrap())
1030            .collect::<Vec<_>>();
1031
1032        assert_eq!(
1033            generated.iter().filter(|assets| !assets.cache_hit).count(),
1034            1
1035        );
1036        for assets in &generated[1..] {
1037            assert_eq!(assets.root, generated[0].root);
1038            assert_eq!(assets.files, generated[0].files);
1039        }
1040        let asset_root = root.join("target/ferry/assets");
1041        let entries = fs::read_dir(asset_root)
1042            .unwrap()
1043            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1044            .collect::<Vec<_>>();
1045        assert_eq!(entries, [generated[0].fingerprint.clone()]);
1046    }
1047
1048    #[test]
1049    fn custom_source_must_stay_inside_project() {
1050        let (temporary, root) = fixture();
1051        let outside = Utf8PathBuf::from_path_buf(temporary.path().with_extension("png")).unwrap();
1052        fs::write(&outside, ICON).unwrap();
1053        assert!(matches!(
1054            generate_platform_assets(&root, Some(&outside)),
1055            Err(AssetPipelineError::SourceOutsideProject(_))
1056        ));
1057        fs::remove_file(outside).unwrap();
1058    }
1059
1060    #[cfg(unix)]
1061    #[test]
1062    fn generated_root_rejects_symlinks() {
1063        use std::os::unix::fs::symlink;
1064
1065        let (_temporary, root) = fixture();
1066        let outside = tempfile::tempdir().unwrap();
1067        symlink(outside.path(), root.join("target")).unwrap();
1068        assert!(matches!(
1069            generate_platform_assets(&root, None),
1070            Err(AssetPipelineError::UnsafePath(_))
1071        ));
1072    }
1073}