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#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
42pub struct AssetIssue {
43 pub code: &'static str,
45 pub message: String,
47 pub help: &'static str,
49}
50
51#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
53pub struct AssetCheck {
54 pub project: Utf8PathBuf,
56 pub icon: PngMetadata,
58 pub splash: PngMetadata,
60 pub fingerprint: String,
62 pub release_ready: bool,
64 pub issues: Vec<AssetIssue>,
66}
67
68#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
70pub struct GeneratedAssetSet {
71 pub fingerprint: String,
73 pub root: Utf8PathBuf,
75 pub files: Vec<Utf8PathBuf>,
77 pub cache_hit: bool,
79}
80
81#[derive(Clone, Debug, Eq, PartialEq)]
83pub struct RenderedPlatformAssets {
84 pub fingerprint: String,
86 pub files: Vec<(Utf8PathBuf, Vec<u8>)>,
88}
89
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum GeneratedAssetPlatform {
93 Android,
95 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
108pub 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
139pub 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
252pub 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
267pub 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
285pub 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(¤t) {
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(¤t) {
472 Ok(()) => {}
473 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
474 let metadata = fs::symlink_metadata(¤t).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(¤t) {
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#[derive(Debug, Error)]
875pub enum AssetPipelineError {
876 #[error(transparent)]
878 Asset(#[from] AssetError),
879 #[error("path is not valid UTF-8: {0:?}")]
881 NonUtf8Path(std::path::PathBuf),
882 #[error("asset source must stay inside the project: {0}")]
884 SourceOutsideProject(Utf8PathBuf),
885 #[error("refusing unsafe asset path: {0}")]
887 UnsafePath(Utf8PathBuf),
888 #[error("asset source {path} is {size} bytes; maximum is {MAX_SOURCE_BYTES} bytes")]
890 SourceTooLarge {
891 path: Utf8PathBuf,
893 size: u64,
895 },
896 #[error("could not decode {role} PNG: {source}")]
898 Decode {
899 role: &'static str,
901 #[source]
903 source: image::ImageError,
904 },
905 #[error("could not encode generated asset {path}: {source}")]
907 Encode {
908 path: Utf8PathBuf,
910 #[source]
912 source: image::ImageError,
913 },
914 #[error("project assets are not release-ready")]
916 NotReleaseReady {
917 issues: Vec<AssetIssue>,
919 },
920 #[error("generated asset cache is incomplete or inconsistent: {0}")]
922 InvalidCache(Utf8PathBuf),
923 #[error("could not process generated asset metadata: {0}")]
925 Json(#[from] serde_json::Error),
926 #[error("could not {operation} at {path}: {source}")]
928 Io {
929 operation: &'static str,
931 path: Utf8PathBuf,
933 #[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}