1use std::fs;
2
3use camino::{Utf8Path, Utf8PathBuf};
4use rustferry_core::{ArtifactDigest, ArtifactDigestKind, ProjectAssets, digest_artifact};
5use serde::{Deserialize, Serialize};
6
7use crate::{
8 AppleError, AppleToolchain, CommandSpec, ExtensionKind, IosAssetPackaging, error::io_error,
9 run_command,
10};
11
12const ACTIVITY_MODEL_BUNDLE_IDENTIFIER: &str = "org.rustferry.activity-model";
13const ACTIVITY_MODEL_INSTALL_NAME: &str = "@rpath/FerryActivityModel.framework/FerryActivityModel";
14const RUNTIME_BRIDGE_INSTALL_NAME: &str = "@rpath/FerryRuntimeBridge.framework/FerryRuntimeBridge";
15
16#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
18pub struct IosExtensionExpectation {
19 pub kind: ExtensionKind,
21 pub bundle_name: String,
23 pub bundle_identifier: String,
25 pub executable_name: String,
27 pub app_group: Option<String>,
29}
30
31#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct IosArtifactExpectation {
34 pub app_path: Utf8PathBuf,
36 pub bundle_identifier: String,
38 pub executable_name: String,
40 pub rust_binary: Option<Utf8PathBuf>,
42 pub expected_architectures: Vec<String>,
44 pub extensions: Vec<IosExtensionExpectation>,
46 pub deep_link_schemes: Vec<String>,
48 pub app_group: Option<String>,
50 pub log_dir: Option<Utf8PathBuf>,
52 pub project_assets: Option<ProjectAssets>,
54 pub asset_packaging: Option<IosAssetPackaging>,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
60pub struct IosAssetCatalogValidation {
61 pub compiled_catalog: Utf8PathBuf,
63 pub app_icon_name: String,
65 pub launch_image_name: String,
67}
68
69#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
71pub struct IosSdkOnlyAssetValidation {
72 pub icon: Utf8PathBuf,
74 pub splash: Utf8PathBuf,
76 pub icon_file: String,
78 pub launch_image_name: String,
80}
81
82#[allow(clippy::struct_excessive_bools)]
84#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
85pub struct IosCodeSignatureValidation {
86 pub identifier: String,
88 pub ad_hoc: bool,
90 pub strict_verified: bool,
92 pub deep_verified: bool,
94 pub info_plist_sealed: bool,
96 pub resources_sealed: bool,
98 pub app_groups: Vec<String>,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
104pub struct IosExtensionValidation {
105 pub kind: ExtensionKind,
107 pub path: Utf8PathBuf,
109 pub bundle_identifier: String,
111 pub executable: Utf8PathBuf,
113 pub architectures: Vec<String>,
115 pub extension_point_identifier: String,
117 pub activity_model_linked: bool,
119 pub code_signature: IosCodeSignatureValidation,
121}
122
123#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
125pub struct IosRuntimeBridgeValidation {
126 pub path: Utf8PathBuf,
128 pub executable: Utf8PathBuf,
130 pub bundle_identifier: String,
132 pub architectures: Vec<String>,
134 pub install_name: String,
136 pub application_delegate_hook: bool,
138 pub exported_symbols: Vec<String>,
140 pub code_signature: IosCodeSignatureValidation,
142}
143
144#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
146pub struct IosArtifactValidation {
147 pub schema_version: u32,
149 pub artifact_digest: ArtifactDigest,
151 pub app_path: Utf8PathBuf,
153 pub bundle_identifier: String,
155 pub executable: Utf8PathBuf,
157 pub architectures: Vec<String>,
159 pub rust_binary_embedded: bool,
161 pub resources: Vec<Utf8PathBuf>,
163 pub asset_catalog: Option<IosAssetCatalogValidation>,
165 pub sdk_only_assets: Option<IosSdkOnlyAssetValidation>,
167 pub embedded_frameworks: Vec<Utf8PathBuf>,
169 pub runtime_bridge: IosRuntimeBridgeValidation,
171 pub extensions: Vec<IosExtensionValidation>,
173 pub deep_link_schemes: Vec<String>,
175 pub application_delegate: Option<String>,
177 pub code_signature: IosCodeSignatureValidation,
179}
180
181#[allow(clippy::too_many_lines)]
189pub fn validate_ios_app(
190 expected: &IosArtifactExpectation,
191 toolchain: &AppleToolchain,
192) -> Result<IosArtifactValidation, AppleError> {
193 validate_real_directory(&expected.app_path, "application bundle")?;
194 let validated_digest = ios_artifact_digest(&expected.app_path)?;
195 let info_plist = expected.app_path.join("Info.plist");
196 validate_regular_file(&info_plist, "application Info.plist")?;
197 lint_plist(&info_plist, toolchain, expected.log_dir.as_deref(), 1)?;
198
199 let bundle_identifier = plist_value(
200 &info_plist,
201 "CFBundleIdentifier",
202 toolchain,
203 expected.log_dir.as_deref(),
204 2,
205 )?;
206 if bundle_identifier != expected.bundle_identifier {
207 return invalid(
208 &expected.app_path,
209 format!(
210 "CFBundleIdentifier is `{bundle_identifier}`, expected `{}`",
211 expected.bundle_identifier
212 ),
213 );
214 }
215 let executable_name = plist_value(
216 &info_plist,
217 "CFBundleExecutable",
218 toolchain,
219 expected.log_dir.as_deref(),
220 3,
221 )?;
222 if executable_name != expected.executable_name {
223 return invalid(
224 &expected.app_path,
225 format!(
226 "CFBundleExecutable is `{executable_name}`, expected `{}`",
227 expected.executable_name
228 ),
229 );
230 }
231 let package_type = plist_value(
232 &info_plist,
233 "CFBundlePackageType",
234 toolchain,
235 expected.log_dir.as_deref(),
236 4,
237 )?;
238 if package_type != "APPL" {
239 return invalid(
240 &expected.app_path,
241 format!("CFBundlePackageType is `{package_type}`, expected `APPL`"),
242 );
243 }
244
245 if !expected.deep_link_schemes.is_empty() {
246 for (index, scheme) in expected.deep_link_schemes.iter().enumerate() {
247 let actual = plist_value(
248 &info_plist,
249 &format!("CFBundleURLTypes.0.CFBundleURLSchemes.{index}"),
250 toolchain,
251 expected.log_dir.as_deref(),
252 6 + index,
253 )?;
254 if actual != *scheme {
255 return invalid(
256 &info_plist,
257 format!("custom URL scheme {index} is `{actual}`, expected `{scheme}`"),
258 );
259 }
260 }
261 }
262
263 let executable = expected.app_path.join(&expected.executable_name);
264 validate_executable(&executable)?;
265 let architectures =
266 macho_architectures(&executable, toolchain, expected.log_dir.as_deref(), 5)?;
267 let mut expected_architectures = expected.expected_architectures.clone();
268 expected_architectures.sort();
269 expected_architectures.dedup();
270 if architectures != expected_architectures {
271 return invalid(
272 &executable,
273 format!(
274 "Mach-O architectures are {architectures:?}, expected {expected_architectures:?}"
275 ),
276 );
277 }
278
279 let rust_binary_embedded = if let Some(rust_binary) = &expected.rust_binary {
280 validate_regular_file(rust_binary, "Cargo-produced Rust executable")?;
281 let cargo_uuids = macho_uuids(rust_binary, toolchain, expected.log_dir.as_deref(), 100)?;
282 let embedded_uuids = macho_uuids(&executable, toolchain, expected.log_dir.as_deref(), 101)?;
283 if cargo_uuids != embedded_uuids {
284 return invalid(
285 &executable,
286 format!(
287 "signed executable Mach-O UUIDs {embedded_uuids:?} differ from Cargo output `{rust_binary}` UUIDs {cargo_uuids:?}; Rust linkage cannot be proven"
288 ),
289 );
290 }
291 true
292 } else {
293 false
294 };
295
296 let resources = [
297 "FerryResources.json",
298 "Assets.car",
299 "FerryIcon.png",
300 "FerrySplash.png",
301 ]
302 .into_iter()
303 .map(|name| expected.app_path.join(name))
304 .filter(|path| path.is_file())
305 .collect::<Vec<_>>();
306 if !resources
307 .iter()
308 .any(|path| path.file_name() == Some("FerryResources.json"))
309 {
310 return invalid(
311 &expected.app_path,
312 "generated resource metadata `FerryResources.json` is absent from the application bundle"
313 .to_owned(),
314 );
315 }
316 let (asset_catalog, sdk_only_assets) = match (
317 expected.project_assets.as_ref(),
318 expected.asset_packaging,
319 ) {
320 (None, None) => (None, None),
321 (Some(_), Some(IosAssetPackaging::CompiledCatalog)) => {
322 for name in ["FerryIcon.png", "FerrySplash.png"] {
323 let path = expected.app_path.join(name);
324 if path.exists() {
325 return invalid(
326 &path,
327 "SDK-only image resource is present in a compiled-catalog bundle"
328 .to_owned(),
329 );
330 }
331 }
332 let compiled_catalog = expected.app_path.join("Assets.car");
333 validate_regular_file(&compiled_catalog, "compiled asset catalog")?;
334 let app_icon_name = plist_value(
335 &info_plist,
336 "CFBundleIconName",
337 toolchain,
338 expected.log_dir.as_deref(),
339 102,
340 )?;
341 if app_icon_name != "AppIcon" {
342 return invalid(
343 &info_plist,
344 format!("CFBundleIconName is `{app_icon_name}`, expected `AppIcon`"),
345 );
346 }
347 let launch_image_name = plist_value(
348 &info_plist,
349 "UILaunchScreen.UIImageName",
350 toolchain,
351 expected.log_dir.as_deref(),
352 103,
353 )?;
354 if launch_image_name != "FerryLaunch" {
355 return invalid(
356 &info_plist,
357 format!(
358 "UILaunchScreen UIImageName is `{launch_image_name}`, expected `FerryLaunch`"
359 ),
360 );
361 }
362 (
363 Some(IosAssetCatalogValidation {
364 compiled_catalog,
365 app_icon_name,
366 launch_image_name,
367 }),
368 None,
369 )
370 }
371 (Some(assets), Some(IosAssetPackaging::SdkOnlyResources)) => {
372 let compiled_catalog = expected.app_path.join("Assets.car");
373 if compiled_catalog.exists() {
374 return invalid(
375 &compiled_catalog,
376 "compiled asset catalog is present in an SDK-only resource bundle".to_owned(),
377 );
378 }
379 let icon = expected.app_path.join("FerryIcon.png");
380 let splash = expected.app_path.join("FerrySplash.png");
381 for (path, source_bytes) in [(&icon, assets.icon()), (&splash, assets.splash())] {
382 validate_regular_file(path, "SDK-only project image resource")?;
383 let actual = fs::read(path)
384 .map_err(|source| io_error("read SDK-only project image", path, source))?;
385 if actual != source_bytes {
386 return invalid(
387 path,
388 "embedded image bytes differ from the validated project asset".to_owned(),
389 );
390 }
391 }
392 let icon_file = plist_value(
393 &info_plist,
394 "CFBundleIconFile",
395 toolchain,
396 expected.log_dir.as_deref(),
397 104,
398 )?;
399 if icon_file != "FerryIcon" {
400 return invalid(
401 &info_plist,
402 format!("CFBundleIconFile is `{icon_file}`, expected `FerryIcon`"),
403 );
404 }
405 let legacy_launch_image = plist_value(
406 &info_plist,
407 "UILaunchImageFile",
408 toolchain,
409 expected.log_dir.as_deref(),
410 105,
411 )?;
412 if legacy_launch_image != "FerrySplash" {
413 return invalid(
414 &info_plist,
415 format!("UILaunchImageFile is `{legacy_launch_image}`, expected `FerrySplash`"),
416 );
417 }
418 let launch_image_name = plist_value(
419 &info_plist,
420 "UILaunchScreen.UIImageName",
421 toolchain,
422 expected.log_dir.as_deref(),
423 106,
424 )?;
425 if launch_image_name != "FerrySplash" {
426 return invalid(
427 &info_plist,
428 format!(
429 "UILaunchScreen UIImageName is `{launch_image_name}`, expected `FerrySplash`"
430 ),
431 );
432 }
433 (
434 None,
435 Some(IosSdkOnlyAssetValidation {
436 icon,
437 splash,
438 icon_file,
439 launch_image_name,
440 }),
441 )
442 }
443 _ => {
444 return Err(AppleError::InvalidRequest(
445 "iOS artifact assets and packaging expectation must either both be present or both be absent"
446 .to_owned(),
447 ));
448 }
449 };
450
451 let embedded_frameworks = inspect_frameworks(&expected.app_path)?;
452 let runtime_bridge = validate_runtime_bridge(
453 &expected.app_path,
454 &expected_architectures,
455 toolchain,
456 expected.log_dir.as_deref(),
457 )?;
458 let live_activity_enabled = expected
459 .extensions
460 .iter()
461 .any(|extension| extension.kind == ExtensionKind::ActivityKit);
462 if live_activity_enabled {
463 validate_activity_model(
464 &expected.app_path,
465 &expected_architectures,
466 &runtime_bridge.executable,
467 toolchain,
468 expected.log_dir.as_deref(),
469 )?;
470 } else {
471 reject_unexpected_activity_model(&expected.app_path)?;
472 }
473 let application_delegate = if expected.deep_link_schemes.is_empty() {
474 None
475 } else if runtime_bridge.application_delegate_hook {
476 Some("FerryApplicationDelegate".to_owned())
477 } else {
478 return invalid(
479 &runtime_bridge.executable,
480 "runtime framework does not contain the UIApplication initialization delegate hook"
481 .to_owned(),
482 );
483 };
484 let extensions = validate_extensions(expected, toolchain)?;
485 let plugins = expected.app_path.join("PlugIns");
486 if plugins.is_dir() {
487 let actual_count = fs::read_dir(&plugins)
488 .map_err(|source| io_error("inspect embedded app extensions", &plugins, source))?
489 .filter_map(Result::ok)
490 .filter(|entry| {
491 entry
492 .path()
493 .extension()
494 .is_some_and(|extension| extension == "appex")
495 })
496 .count();
497 if actual_count != expected.extensions.len() {
498 return invalid(
499 &plugins,
500 format!(
501 "found {actual_count} embedded `.appex` bundles, expected {}",
502 expected.extensions.len()
503 ),
504 );
505 }
506 } else if !expected.extensions.is_empty() {
507 return invalid(
508 &plugins,
509 format!(
510 "PlugIns directory is absent; expected {} app extension(s)",
511 expected.extensions.len()
512 ),
513 );
514 }
515 let code_signature = validate_code_signature(
516 &expected.app_path,
517 &expected.bundle_identifier,
518 expected.app_group.as_deref(),
519 true,
520 toolchain,
521 expected.log_dir.as_deref(),
522 110,
523 )?;
524
525 let current_digest = ios_artifact_digest(&expected.app_path)?;
526 if current_digest != validated_digest {
527 return invalid(
528 &expected.app_path,
529 "application bundle changed while platform validation was running".to_owned(),
530 );
531 }
532
533 Ok(IosArtifactValidation {
534 schema_version: 9,
535 artifact_digest: validated_digest,
536 app_path: expected.app_path.clone(),
537 bundle_identifier,
538 executable,
539 architectures,
540 rust_binary_embedded,
541 resources,
542 asset_catalog,
543 sdk_only_assets,
544 embedded_frameworks,
545 runtime_bridge,
546 extensions,
547 deep_link_schemes: expected.deep_link_schemes.clone(),
548 application_delegate,
549 code_signature,
550 })
551}
552
553fn ios_artifact_digest(path: &Utf8Path) -> Result<ArtifactDigest, AppleError> {
554 digest_artifact(path, ArtifactDigestKind::IosSimulatorApp).map_err(|error| {
555 AppleError::InvalidArtifact {
556 path: path.to_owned(),
557 reason: format!("could not bind validation to exact application contents: {error}"),
558 }
559 })
560}
561
562fn validate_extensions(
563 expected: &IosArtifactExpectation,
564 toolchain: &AppleToolchain,
565) -> Result<Vec<IosExtensionValidation>, AppleError> {
566 let mut validations = Vec::new();
567 for (index, extension) in expected.extensions.iter().enumerate() {
568 let path = expected
569 .app_path
570 .join("PlugIns")
571 .join(format!("{}.appex", extension.bundle_name));
572 validations.push(validate_extension_at(
573 &path,
574 extension,
575 &expected.expected_architectures,
576 toolchain,
577 expected.log_dir.as_deref(),
578 30 + index * 20,
579 )?);
580 }
581 Ok(validations)
582}
583
584pub fn validate_ios_extension(
591 path: &Utf8Path,
592 expected: &IosExtensionExpectation,
593 expected_architectures: &[String],
594 toolchain: &AppleToolchain,
595 log_dir: Option<&Utf8Path>,
596) -> Result<IosExtensionValidation, AppleError> {
597 validate_extension_at(
598 path,
599 expected,
600 expected_architectures,
601 toolchain,
602 log_dir,
603 1,
604 )
605}
606
607fn validate_extension_at(
608 path: &Utf8Path,
609 extension: &IosExtensionExpectation,
610 expected_architectures: &[String],
611 toolchain: &AppleToolchain,
612 log_dir: Option<&Utf8Path>,
613 log_index: usize,
614) -> Result<IosExtensionValidation, AppleError> {
615 validate_real_directory(path, "embedded app extension")?;
616 let info_plist = path.join("Info.plist");
617 validate_regular_file(&info_plist, "extension Info.plist")?;
618 lint_plist(&info_plist, toolchain, log_dir, log_index)?;
619 let bundle_identifier = plist_value(
620 &info_plist,
621 "CFBundleIdentifier",
622 toolchain,
623 log_dir,
624 log_index + 1,
625 )?;
626 if bundle_identifier != extension.bundle_identifier {
627 return invalid(
628 path,
629 format!(
630 "extension CFBundleIdentifier is `{bundle_identifier}`, expected `{}`",
631 extension.bundle_identifier
632 ),
633 );
634 }
635 let executable_name = plist_value(
636 &info_plist,
637 "CFBundleExecutable",
638 toolchain,
639 log_dir,
640 log_index + 2,
641 )?;
642 if executable_name != extension.executable_name {
643 return invalid(
644 path,
645 format!(
646 "extension executable is `{executable_name}`, expected `{}`",
647 extension.executable_name
648 ),
649 );
650 }
651 let extension_point_identifier = plist_value(
652 &info_plist,
653 "NSExtension.NSExtensionPointIdentifier",
654 toolchain,
655 log_dir,
656 log_index + 3,
657 )?;
658 if extension_point_identifier != "com.apple.widgetkit-extension" {
659 return invalid(
660 path,
661 format!(
662 "NSExtensionPointIdentifier is `{extension_point_identifier}`, expected `com.apple.widgetkit-extension`"
663 ),
664 );
665 }
666 let executable = path.join(&extension.executable_name);
667 validate_executable(&executable)?;
668 let architectures = macho_architectures(&executable, toolchain, log_dir, log_index + 4)?;
669 let mut required = expected_architectures.to_vec();
670 required.sort();
671 required.dedup();
672 if architectures != required {
673 return invalid(
674 &executable,
675 format!("extension architectures are {architectures:?}, expected {required:?}"),
676 );
677 }
678 let activity_model_linked = if extension.kind == ExtensionKind::ActivityKit {
679 let dependencies = macho_dependencies(&executable, toolchain, log_dir, log_index + 5)?;
680 validate_activity_extension_dependencies(&executable, &dependencies)?
681 } else {
682 false
683 };
684 let code_signature = validate_code_signature(
685 path,
686 &extension.bundle_identifier,
687 extension.app_group.as_deref(),
688 false,
689 toolchain,
690 log_dir,
691 log_index + 6,
692 )?;
693 Ok(IosExtensionValidation {
694 kind: extension.kind,
695 path: path.to_owned(),
696 bundle_identifier,
697 executable,
698 architectures,
699 extension_point_identifier,
700 activity_model_linked,
701 code_signature,
702 })
703}
704
705fn validate_activity_extension_dependencies(
706 executable: &Utf8Path,
707 dependencies: &[String],
708) -> Result<bool, AppleError> {
709 if dependencies
710 .iter()
711 .any(|dependency| dependency == RUNTIME_BRIDGE_INSTALL_NAME)
712 {
713 return invalid(
714 executable,
715 "ActivityKit extension links the app-only FerryRuntimeBridge framework; extensions may only share FerryActivityModel"
716 .to_owned(),
717 );
718 }
719 if !dependencies
720 .iter()
721 .any(|dependency| dependency == ACTIVITY_MODEL_INSTALL_NAME)
722 {
723 return invalid(
724 executable,
725 "ActivityKit extension does not link the extension-safe FerryActivityModel framework"
726 .to_owned(),
727 );
728 }
729 Ok(true)
730}
731
732fn validate_activity_model(
733 app_path: &Utf8Path,
734 expected_architectures: &[String],
735 runtime_bridge_executable: &Utf8Path,
736 toolchain: &AppleToolchain,
737 log_dir: Option<&Utf8Path>,
738) -> Result<(), AppleError> {
739 let path = app_path.join("Frameworks/FerryActivityModel.framework");
740 validate_real_directory(&path, "embedded Live Activity model framework")?;
741 let info_plist = path.join("Info.plist");
742 validate_regular_file(&info_plist, "Live Activity model framework Info.plist")?;
743 lint_plist(&info_plist, toolchain, log_dir, 170)?;
744
745 let bundle_identifier =
746 plist_value(&info_plist, "CFBundleIdentifier", toolchain, log_dir, 171)?;
747 if bundle_identifier != ACTIVITY_MODEL_BUNDLE_IDENTIFIER {
748 return invalid(
749 &path,
750 format!(
751 "Live Activity model framework bundle identifier is `{bundle_identifier}`, expected `{ACTIVITY_MODEL_BUNDLE_IDENTIFIER}`"
752 ),
753 );
754 }
755 let executable_name = plist_value(&info_plist, "CFBundleExecutable", toolchain, log_dir, 172)?;
756 if executable_name != "FerryActivityModel" {
757 return invalid(
758 &path,
759 format!(
760 "Live Activity model framework executable is `{executable_name}`, expected `FerryActivityModel`"
761 ),
762 );
763 }
764 let package_type = plist_value(&info_plist, "CFBundlePackageType", toolchain, log_dir, 173)?;
765 if package_type != "FMWK" {
766 return invalid(
767 &path,
768 format!(
769 "Live Activity model framework package type is `{package_type}`, expected `FMWK`"
770 ),
771 );
772 }
773
774 let executable = path.join(executable_name);
775 validate_executable(&executable)?;
776 let architectures = macho_architectures(&executable, toolchain, log_dir, 174)?;
777 if architectures != expected_architectures {
778 return invalid(
779 &executable,
780 format!(
781 "Live Activity model framework architectures are {architectures:?}, expected {expected_architectures:?}"
782 ),
783 );
784 }
785 let install_name = macho_install_name(&executable, toolchain, log_dir, 175)?;
786 if install_name != ACTIVITY_MODEL_INSTALL_NAME {
787 return invalid(
788 &executable,
789 format!(
790 "Live Activity model framework install name is `{install_name}`, expected `{ACTIVITY_MODEL_INSTALL_NAME}`"
791 ),
792 );
793 }
794
795 let runtime_dependencies =
796 macho_dependencies(runtime_bridge_executable, toolchain, log_dir, 176)?;
797 if !runtime_dependencies
798 .iter()
799 .any(|dependency| dependency == ACTIVITY_MODEL_INSTALL_NAME)
800 {
801 return invalid(
802 runtime_bridge_executable,
803 "FerryRuntimeBridge does not link the shared FerryActivityModel framework".to_owned(),
804 );
805 }
806 let _ = validate_code_signature(
807 &path,
808 ACTIVITY_MODEL_BUNDLE_IDENTIFIER,
809 None,
810 false,
811 toolchain,
812 log_dir,
813 177,
814 )?;
815 Ok(())
816}
817
818fn reject_unexpected_activity_model(app_path: &Utf8Path) -> Result<(), AppleError> {
819 let path = app_path.join("Frameworks/FerryActivityModel.framework");
820 match fs::symlink_metadata(&path) {
821 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
822 Ok(_) => invalid(
823 &path,
824 "Live Activity model framework is embedded but no ActivityKit extension was requested"
825 .to_owned(),
826 ),
827 Err(source) => Err(io_error(
828 "inspect unexpected Live Activity model framework",
829 &path,
830 source,
831 )),
832 }
833}
834
835fn validate_runtime_bridge(
836 app_path: &Utf8Path,
837 expected_architectures: &[String],
838 toolchain: &AppleToolchain,
839 log_dir: Option<&Utf8Path>,
840) -> Result<IosRuntimeBridgeValidation, AppleError> {
841 let path = app_path.join("Frameworks/FerryRuntimeBridge.framework");
842 validate_real_directory(&path, "embedded RustFerry runtime framework")?;
843 let info_plist = path.join("Info.plist");
844 validate_regular_file(&info_plist, "RustFerry runtime framework Info.plist")?;
845 lint_plist(&info_plist, toolchain, log_dir, 10)?;
846 let bundle_identifier = plist_value(&info_plist, "CFBundleIdentifier", toolchain, log_dir, 11)?;
847 if bundle_identifier != "org.rustferry.runtime-bridge" {
848 return invalid(
849 &path,
850 format!(
851 "runtime framework bundle identifier is `{bundle_identifier}`, expected `org.rustferry.runtime-bridge`"
852 ),
853 );
854 }
855 let executable_name = plist_value(&info_plist, "CFBundleExecutable", toolchain, log_dir, 12)?;
856 if executable_name != "FerryRuntimeBridge" {
857 return invalid(
858 &path,
859 format!(
860 "runtime framework executable is `{executable_name}`, expected `FerryRuntimeBridge`"
861 ),
862 );
863 }
864 let executable = path.join(executable_name);
865 validate_executable(&executable)?;
866 let architectures = macho_architectures(&executable, toolchain, log_dir, 13)?;
867 if architectures != expected_architectures {
868 return invalid(
869 &executable,
870 format!(
871 "runtime framework architectures are {architectures:?}, expected {expected_architectures:?}"
872 ),
873 );
874 }
875 let install_name = macho_install_name(&executable, toolchain, log_dir, 14)?;
876 if install_name != RUNTIME_BRIDGE_INSTALL_NAME {
877 return invalid(
878 &executable,
879 format!(
880 "runtime framework install name is `{install_name}`, expected an @rpath framework"
881 ),
882 );
883 }
884 let application_delegate_hook = binary_contains(&executable, b"FerryApplicationDelegate")?
885 && binary_contains(&executable, b"ferry_bridge_init")?;
886 if !application_delegate_hook {
887 return invalid(
888 &executable,
889 "runtime framework is missing the compiled UIApplication delegate-hook markers"
890 .to_owned(),
891 );
892 }
893 let exported_symbols = macho_exported_symbols(&executable, toolchain, log_dir, 15)?;
894 for required in [
895 "_ferry_bridge_call",
896 "_ferry_bridge_free",
897 "_ferry_bridge_init",
898 "_ferry_bridge_install",
899 "_ferry_bridge_with_application",
900 ] {
901 if !exported_symbols.iter().any(|symbol| symbol == required) {
902 return invalid(
903 &executable,
904 format!("runtime framework does not export required C symbol `{required}`"),
905 );
906 }
907 }
908 let code_signature = validate_code_signature(
909 &path,
910 "org.rustferry.runtime-bridge",
911 None,
912 false,
913 toolchain,
914 log_dir,
915 16,
916 )?;
917 Ok(IosRuntimeBridgeValidation {
918 path,
919 executable,
920 bundle_identifier,
921 architectures,
922 install_name,
923 application_delegate_hook,
924 exported_symbols,
925 code_signature,
926 })
927}
928
929fn binary_contains(path: &Utf8Path, needle: &[u8]) -> Result<bool, AppleError> {
930 let bytes = fs::read(path).map_err(|source| io_error("inspect Mach-O bytes", path, source))?;
931 Ok(bytes.windows(needle.len()).any(|window| window == needle))
932}
933
934fn lint_plist(
935 path: &Utf8Path,
936 toolchain: &AppleToolchain,
937 log_dir: Option<&Utf8Path>,
938 index: usize,
939) -> Result<(), AppleError> {
940 let mut command = CommandSpec::new(
941 "validate Apple property list",
942 &toolchain.plutil,
943 path.parent().unwrap_or_else(|| Utf8Path::new(".")),
944 );
945 command.args = vec!["-lint".to_owned(), path.to_string()];
946 run_command(
947 &command,
948 validation_log(log_dir, index, "plist-lint").as_deref(),
949 )
950 .map(|_| ())
951}
952
953fn plist_value(
954 path: &Utf8Path,
955 key_path: &str,
956 toolchain: &AppleToolchain,
957 log_dir: Option<&Utf8Path>,
958 index: usize,
959) -> Result<String, AppleError> {
960 let mut command = CommandSpec::new(
961 format!("read plist key {key_path}"),
962 &toolchain.plutil,
963 path.parent().unwrap_or_else(|| Utf8Path::new(".")),
964 );
965 command.args = vec![
966 "-extract".to_owned(),
967 key_path.to_owned(),
968 "raw".to_owned(),
969 "-o".to_owned(),
970 "-".to_owned(),
971 path.to_string(),
972 ];
973 let output = run_command(
974 &command,
975 validation_log(log_dir, index, "plist-value").as_deref(),
976 )?;
977 let value = String::from_utf8(output.stdout).map_err(|error| AppleError::InvalidArtifact {
978 path: path.to_owned(),
979 reason: format!("plist key `{key_path}` is not UTF-8: {error}"),
980 })?;
981 Ok(value.trim().to_owned())
982}
983
984fn macho_architectures(
985 path: &Utf8Path,
986 toolchain: &AppleToolchain,
987 log_dir: Option<&Utf8Path>,
988 index: usize,
989) -> Result<Vec<String>, AppleError> {
990 let mut command = CommandSpec::new(
991 "inspect Mach-O architectures",
992 &toolchain.xcrun,
993 path.parent().unwrap_or_else(|| Utf8Path::new(".")),
994 );
995 command.environment.insert(
996 "DEVELOPER_DIR".to_owned(),
997 toolchain.developer_dir.to_string(),
998 );
999 command.args = vec!["lipo".to_owned(), "-archs".to_owned(), path.to_string()];
1000 let output = run_command(
1001 &command,
1002 validation_log(log_dir, index, "lipo-archs").as_deref(),
1003 )?;
1004 let source = String::from_utf8(output.stdout).map_err(|error| AppleError::InvalidArtifact {
1005 path: path.to_owned(),
1006 reason: format!("lipo architecture output is not UTF-8: {error}"),
1007 })?;
1008 let mut architectures = source
1009 .split_whitespace()
1010 .filter(|value| {
1011 matches!(
1012 *value,
1013 "arm64" | "arm64e" | "x86_64" | "i386" | "armv7" | "armv7s"
1014 )
1015 })
1016 .map(ToOwned::to_owned)
1017 .collect::<Vec<_>>();
1018 architectures.sort();
1019 architectures.dedup();
1020 if architectures.is_empty() {
1021 return invalid(
1022 path,
1023 format!("lipo reported no recognized architecture: {source:?}"),
1024 );
1025 }
1026 Ok(architectures)
1027}
1028
1029fn macho_uuids(
1030 path: &Utf8Path,
1031 toolchain: &AppleToolchain,
1032 log_dir: Option<&Utf8Path>,
1033 index: usize,
1034) -> Result<Vec<String>, AppleError> {
1035 let mut command = CommandSpec::new(
1036 "inspect Mach-O build UUIDs",
1037 &toolchain.xcrun,
1038 path.parent().unwrap_or_else(|| Utf8Path::new(".")),
1039 );
1040 command.environment.insert(
1041 "DEVELOPER_DIR".to_owned(),
1042 toolchain.developer_dir.to_string(),
1043 );
1044 command.args = vec![
1045 "dwarfdump".to_owned(),
1046 "--uuid".to_owned(),
1047 path.to_string(),
1048 ];
1049 let output = run_command(
1050 &command,
1051 validation_log(log_dir, index, "dwarfdump-uuid").as_deref(),
1052 )?;
1053 let source = String::from_utf8(output.stdout).map_err(|error| AppleError::InvalidArtifact {
1054 path: path.to_owned(),
1055 reason: format!("dwarfdump UUID output is not UTF-8: {error}"),
1056 })?;
1057 parse_macho_uuids(path, &source)
1058}
1059
1060fn parse_macho_uuids(path: &Utf8Path, source: &str) -> Result<Vec<String>, AppleError> {
1061 let mut identities = Vec::new();
1062 for line in source.lines().map(str::trim) {
1063 let Some(rest) = line.strip_prefix("UUID:") else {
1064 continue;
1065 };
1066 let mut fields = rest.split_whitespace();
1067 let uuid = fields.next().unwrap_or_default();
1068 let architecture = fields
1069 .next()
1070 .and_then(|value| value.strip_prefix('('))
1071 .and_then(|value| value.strip_suffix(')'))
1072 .unwrap_or_default();
1073 let uuid_is_valid = uuid.len() == 36
1074 && uuid.chars().enumerate().all(|(index, character)| {
1075 if matches!(index, 8 | 13 | 18 | 23) {
1076 character == '-'
1077 } else {
1078 character.is_ascii_hexdigit()
1079 }
1080 });
1081 if !uuid_is_valid || architecture.is_empty() {
1082 return invalid(
1083 path,
1084 format!("dwarfdump reported a malformed Mach-O UUID line: {line:?}"),
1085 );
1086 }
1087 identities.push(format!("{}:{}", architecture, uuid.to_ascii_uppercase()));
1088 }
1089 identities.sort();
1090 identities.dedup();
1091 if identities.is_empty() {
1092 return invalid(
1093 path,
1094 format!("dwarfdump reported no Mach-O UUIDs: {source:?}"),
1095 );
1096 }
1097 Ok(identities)
1098}
1099
1100fn macho_install_name(
1101 path: &Utf8Path,
1102 toolchain: &AppleToolchain,
1103 log_dir: Option<&Utf8Path>,
1104 index: usize,
1105) -> Result<String, AppleError> {
1106 let mut command = CommandSpec::new(
1107 "inspect Mach-O install name",
1108 &toolchain.xcrun,
1109 path.parent().unwrap_or_else(|| Utf8Path::new(".")),
1110 );
1111 command.environment.insert(
1112 "DEVELOPER_DIR".to_owned(),
1113 toolchain.developer_dir.to_string(),
1114 );
1115 command.args = vec!["otool".to_owned(), "-D".to_owned(), path.to_string()];
1116 let output = run_command(
1117 &command,
1118 validation_log(log_dir, index, "otool-install-name").as_deref(),
1119 )?;
1120 let output = String::from_utf8(output.stdout).map_err(|error| AppleError::InvalidArtifact {
1121 path: path.to_owned(),
1122 reason: format!("otool install-name output is not UTF-8: {error}"),
1123 })?;
1124 output
1125 .lines()
1126 .map(str::trim)
1127 .find(|line| line.starts_with('@'))
1128 .map(ToOwned::to_owned)
1129 .ok_or_else(|| AppleError::InvalidArtifact {
1130 path: path.to_owned(),
1131 reason: format!("otool reported no install name: {output:?}"),
1132 })
1133}
1134
1135fn macho_dependencies(
1136 path: &Utf8Path,
1137 toolchain: &AppleToolchain,
1138 log_dir: Option<&Utf8Path>,
1139 index: usize,
1140) -> Result<Vec<String>, AppleError> {
1141 let mut command = CommandSpec::new(
1142 "inspect Mach-O linked libraries",
1143 &toolchain.xcrun,
1144 path.parent().unwrap_or_else(|| Utf8Path::new(".")),
1145 );
1146 command.environment.insert(
1147 "DEVELOPER_DIR".to_owned(),
1148 toolchain.developer_dir.to_string(),
1149 );
1150 command.args = vec!["otool".to_owned(), "-L".to_owned(), path.to_string()];
1151 let output = run_command(
1152 &command,
1153 validation_log(log_dir, index, "otool-linked-libraries").as_deref(),
1154 )?;
1155 let output = String::from_utf8(output.stdout).map_err(|error| AppleError::InvalidArtifact {
1156 path: path.to_owned(),
1157 reason: format!("otool linked-library output is not UTF-8: {error}"),
1158 })?;
1159 Ok(output
1160 .lines()
1161 .skip(1)
1162 .filter_map(|line| line.split_whitespace().next())
1163 .map(ToOwned::to_owned)
1164 .collect())
1165}
1166
1167fn macho_exported_symbols(
1168 path: &Utf8Path,
1169 toolchain: &AppleToolchain,
1170 log_dir: Option<&Utf8Path>,
1171 index: usize,
1172) -> Result<Vec<String>, AppleError> {
1173 let mut command = CommandSpec::new(
1174 "inspect Mach-O exported symbols",
1175 &toolchain.xcrun,
1176 path.parent().unwrap_or_else(|| Utf8Path::new(".")),
1177 );
1178 command.environment.insert(
1179 "DEVELOPER_DIR".to_owned(),
1180 toolchain.developer_dir.to_string(),
1181 );
1182 command.args = vec!["nm".to_owned(), "-gj".to_owned(), path.to_string()];
1183 let output = run_command(
1184 &command,
1185 validation_log(log_dir, index, "nm-exported-symbols").as_deref(),
1186 )?;
1187 let output = String::from_utf8(output.stdout).map_err(|error| AppleError::InvalidArtifact {
1188 path: path.to_owned(),
1189 reason: format!("nm exported-symbol output is not UTF-8: {error}"),
1190 })?;
1191 Ok(output
1192 .lines()
1193 .map(str::trim)
1194 .filter(|line| !line.is_empty())
1195 .map(ToOwned::to_owned)
1196 .collect())
1197}
1198
1199fn inspect_frameworks(app_path: &Utf8Path) -> Result<Vec<Utf8PathBuf>, AppleError> {
1200 let directory = app_path.join("Frameworks");
1201 if !directory.exists() {
1202 return Ok(Vec::new());
1203 }
1204 validate_real_directory(&directory, "embedded Frameworks directory")?;
1205 let mut entries = fs::read_dir(&directory)
1206 .map_err(|source| io_error("inspect embedded frameworks", &directory, source))?
1207 .map(|entry| {
1208 let entry = entry
1209 .map_err(|source| io_error("inspect embedded framework", &directory, source))?;
1210 let path = Utf8PathBuf::from_path_buf(entry.path()).map_err(AppleError::NonUtf8Path)?;
1211 let metadata = fs::symlink_metadata(&path)
1212 .map_err(|source| io_error("inspect embedded framework metadata", &path, source))?;
1213 if metadata.file_type().is_symlink() {
1214 return invalid::<Utf8PathBuf>(
1215 &path,
1216 "top-level embedded framework/library cannot be a symbolic link".to_owned(),
1217 );
1218 }
1219 if metadata.is_file() && metadata.len() == 0 {
1220 return invalid::<Utf8PathBuf>(&path, "embedded library is empty".to_owned());
1221 }
1222 Ok(path)
1223 })
1224 .collect::<Result<Vec<_>, AppleError>>()?;
1225 entries.sort();
1226 Ok(entries)
1227}
1228
1229fn validate_code_signature(
1230 path: &Utf8Path,
1231 expected_identifier: &str,
1232 expected_app_group: Option<&str>,
1233 deep: bool,
1234 toolchain: &AppleToolchain,
1235 log_dir: Option<&Utf8Path>,
1236 log_index: usize,
1237) -> Result<IosCodeSignatureValidation, AppleError> {
1238 validate_regular_file(
1239 &path.join("_CodeSignature/CodeResources"),
1240 "sealed code-signature resources",
1241 )?;
1242
1243 let mut verify = CommandSpec::new(
1244 "strictly verify iOS bundle code signature",
1245 &toolchain.xcrun,
1246 path.parent().unwrap_or_else(|| Utf8Path::new(".")),
1247 );
1248 verify.environment.insert(
1249 "DEVELOPER_DIR".to_owned(),
1250 toolchain.developer_dir.to_string(),
1251 );
1252 verify.args = vec!["codesign".to_owned(), "--verify".to_owned()];
1253 if deep {
1254 verify.args.push("--deep".to_owned());
1255 }
1256 verify.args.extend([
1257 "--strict".to_owned(),
1258 "--verbose=4".to_owned(),
1259 "--test-requirement".to_owned(),
1260 format!("=identifier \"{expected_identifier}\""),
1261 path.to_string(),
1262 ]);
1263 run_command(
1264 &verify,
1265 validation_log(log_dir, log_index, "codesign-verify").as_deref(),
1266 )?;
1267
1268 let entitlement_file = tempfile::NamedTempFile::new()
1269 .map_err(|source| io_error("create temporary entitlement output", path, source))?;
1270 let entitlement_path = Utf8PathBuf::from_path_buf(entitlement_file.path().to_owned())
1271 .map_err(AppleError::NonUtf8Path)?;
1272 let mut display = CommandSpec::new(
1273 "inspect iOS bundle code signature",
1274 &toolchain.xcrun,
1275 path.parent().unwrap_or_else(|| Utf8Path::new(".")),
1276 );
1277 display.environment.insert(
1278 "DEVELOPER_DIR".to_owned(),
1279 toolchain.developer_dir.to_string(),
1280 );
1281 display.args = vec![
1282 "codesign".to_owned(),
1283 "--display".to_owned(),
1284 "--verbose=4".to_owned(),
1285 "--entitlements".to_owned(),
1286 entitlement_path.to_string(),
1287 "--xml".to_owned(),
1288 path.to_string(),
1289 ];
1290 let output = run_command(
1291 &display,
1292 validation_log(log_dir, log_index + 1, "codesign-display").as_deref(),
1293 )?;
1294 let mut metadata =
1295 String::from_utf8(output.stderr).map_err(|error| AppleError::InvalidArtifact {
1296 path: path.to_owned(),
1297 reason: format!("codesign metadata is not UTF-8: {error}"),
1298 })?;
1299 if !output.stdout.is_empty() {
1300 metadata.push('\n');
1301 metadata.push_str(&String::from_utf8(output.stdout).map_err(|error| {
1302 AppleError::InvalidArtifact {
1303 path: path.to_owned(),
1304 reason: format!("codesign metadata is not UTF-8: {error}"),
1305 }
1306 })?);
1307 }
1308 let displayed = parse_code_signature_display(path, &metadata)?;
1309 if displayed.identifier != expected_identifier {
1310 return invalid(
1311 path,
1312 format!(
1313 "code-signature identifier is `{}`, expected `{expected_identifier}`",
1314 displayed.identifier
1315 ),
1316 );
1317 }
1318 let app_groups =
1319 code_signature_app_groups(path, &entitlement_path, toolchain, log_dir, log_index + 2)?;
1320 let expected_app_groups = expected_app_group
1321 .map(|group| vec![group.to_owned()])
1322 .unwrap_or_default();
1323 if app_groups != expected_app_groups {
1324 return invalid(
1325 path,
1326 format!(
1327 "signed application groups are {app_groups:?}, expected {expected_app_groups:?}"
1328 ),
1329 );
1330 }
1331
1332 Ok(IosCodeSignatureValidation {
1333 identifier: displayed.identifier,
1334 ad_hoc: true,
1335 strict_verified: true,
1336 deep_verified: deep,
1337 info_plist_sealed: true,
1338 resources_sealed: true,
1339 app_groups,
1340 })
1341}
1342
1343struct DisplayedCodeSignature {
1344 identifier: String,
1345}
1346
1347fn parse_code_signature_display(
1348 path: &Utf8Path,
1349 source: &str,
1350) -> Result<DisplayedCodeSignature, AppleError> {
1351 let identifier = source
1352 .lines()
1353 .find_map(|line| line.trim().strip_prefix("Identifier="))
1354 .filter(|value| !value.is_empty())
1355 .map(ToOwned::to_owned)
1356 .ok_or_else(|| AppleError::InvalidArtifact {
1357 path: path.to_owned(),
1358 reason: format!("codesign reported no signature identifier: {source:?}"),
1359 })?;
1360 if !source.lines().any(|line| line.trim() == "Signature=adhoc") {
1361 return invalid(path, "code signature is not ad-hoc".to_owned());
1362 }
1363 if source.lines().any(|line| line.contains("linker-signed")) {
1364 return invalid(
1365 path,
1366 "code has only a linker-generated signature, not a sealed bundle signature".to_owned(),
1367 );
1368 }
1369 if !source
1370 .lines()
1371 .any(|line| line.trim().starts_with("Info.plist entries="))
1372 {
1373 return invalid(path, "code signature does not seal Info.plist".to_owned());
1374 }
1375 if !source
1376 .lines()
1377 .any(|line| line.trim().starts_with("Sealed Resources version="))
1378 {
1379 return invalid(
1380 path,
1381 "code signature does not seal bundle resources".to_owned(),
1382 );
1383 }
1384 Ok(DisplayedCodeSignature { identifier })
1385}
1386
1387fn code_signature_app_groups(
1388 bundle_path: &Utf8Path,
1389 entitlement_path: &Utf8Path,
1390 toolchain: &AppleToolchain,
1391 log_dir: Option<&Utf8Path>,
1392 log_index: usize,
1393) -> Result<Vec<String>, AppleError> {
1394 let metadata = fs::metadata(entitlement_path).map_err(|source| {
1395 io_error(
1396 "inspect extracted code-signature entitlements",
1397 bundle_path,
1398 source,
1399 )
1400 })?;
1401 if metadata.len() == 0 {
1402 return Ok(Vec::new());
1403 }
1404 let mut command = CommandSpec::new(
1405 "convert code-signature entitlements to JSON",
1406 &toolchain.plutil,
1407 bundle_path.parent().unwrap_or_else(|| Utf8Path::new(".")),
1408 );
1409 command.args = vec![
1410 "-convert".to_owned(),
1411 "json".to_owned(),
1412 "-o".to_owned(),
1413 "-".to_owned(),
1414 entitlement_path.to_string(),
1415 ];
1416 let output = run_command(
1417 &command,
1418 validation_log(log_dir, log_index, "entitlements-json").as_deref(),
1419 )?;
1420 let value: serde_json::Value =
1421 serde_json::from_slice(&output.stdout).map_err(|error| AppleError::InvalidArtifact {
1422 path: bundle_path.to_owned(),
1423 reason: format!("signed entitlements are not valid JSON: {error}"),
1424 })?;
1425 let Some(groups) = value.get("com.apple.security.application-groups") else {
1426 return Ok(Vec::new());
1427 };
1428 let groups = groups
1429 .as_array()
1430 .ok_or_else(|| AppleError::InvalidArtifact {
1431 path: bundle_path.to_owned(),
1432 reason: "signed application-group entitlement is not an array".to_owned(),
1433 })?;
1434 let mut groups = groups
1435 .iter()
1436 .map(|value| {
1437 value
1438 .as_str()
1439 .map(ToOwned::to_owned)
1440 .ok_or_else(|| AppleError::InvalidArtifact {
1441 path: bundle_path.to_owned(),
1442 reason: "signed application-group entitlement contains a non-string value"
1443 .to_owned(),
1444 })
1445 })
1446 .collect::<Result<Vec<_>, _>>()?;
1447 groups.sort();
1448 Ok(groups)
1449}
1450
1451fn validate_real_directory(path: &Utf8Path, description: &str) -> Result<(), AppleError> {
1452 let metadata = fs::symlink_metadata(path).map_err(|source| {
1453 if source.kind() == std::io::ErrorKind::NotFound {
1454 AppleError::InvalidArtifact {
1455 path: path.to_owned(),
1456 reason: format!("{description} does not exist"),
1457 }
1458 } else {
1459 io_error("inspect iOS artifact directory", path, source)
1460 }
1461 })?;
1462 if metadata.file_type().is_symlink() || !metadata.is_dir() {
1463 return invalid(path, format!("{description} is not a real directory"));
1464 }
1465 Ok(())
1466}
1467
1468fn validate_regular_file(path: &Utf8Path, description: &str) -> Result<(), AppleError> {
1469 let metadata = fs::symlink_metadata(path).map_err(|source| {
1470 if source.kind() == std::io::ErrorKind::NotFound {
1471 AppleError::InvalidArtifact {
1472 path: path.to_owned(),
1473 reason: format!("{description} does not exist"),
1474 }
1475 } else {
1476 io_error("inspect iOS artifact file", path, source)
1477 }
1478 })?;
1479 if metadata.file_type().is_symlink() || !metadata.is_file() {
1480 return invalid(path, format!("{description} is not a regular file"));
1481 }
1482 if metadata.len() == 0 {
1483 return invalid(path, format!("{description} is empty"));
1484 }
1485 Ok(())
1486}
1487
1488fn validate_executable(path: &Utf8Path) -> Result<(), AppleError> {
1489 validate_regular_file(path, "bundle executable")?;
1490 #[cfg(unix)]
1491 {
1492 use std::os::unix::fs::PermissionsExt;
1493 let mode = fs::metadata(path)
1494 .map_err(|source| io_error("inspect executable permissions", path, source))?
1495 .permissions()
1496 .mode();
1497 if mode & 0o111 == 0 {
1498 return invalid(
1499 path,
1500 "bundle executable has no executable permission bits".to_owned(),
1501 );
1502 }
1503 }
1504 Ok(())
1505}
1506
1507fn validation_log(log_dir: Option<&Utf8Path>, index: usize, name: &str) -> Option<Utf8PathBuf> {
1508 log_dir.map(|directory| directory.join(format!("{index:02}-{name}.log")))
1509}
1510
1511fn invalid<T>(path: &Utf8Path, reason: String) -> Result<T, AppleError> {
1512 Err(AppleError::InvalidArtifact {
1513 path: path.to_owned(),
1514 reason,
1515 })
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520 use super::*;
1521
1522 #[test]
1523 fn parses_macho_uuid_identity_by_architecture() {
1524 let path = Utf8Path::new("FerryApp");
1525 let parsed = parse_macho_uuids(
1526 path,
1527 "UUID: 3ecf92d7-c52e-33a0-9292-5f69e5190057 (arm64) FerryApp\n",
1528 )
1529 .unwrap();
1530 assert_eq!(parsed, ["arm64:3ECF92D7-C52E-33A0-9292-5F69E5190057"]);
1531 assert!(parse_macho_uuids(path, "UUID: malformed (arm64) FerryApp").is_err());
1532 }
1533
1534 #[test]
1535 fn requires_a_sealed_non_linker_adhoc_signature() {
1536 let path = Utf8Path::new("RustFerry.app");
1537 let parsed = parse_code_signature_display(
1538 path,
1539 "Identifier=com.example.ferry\nSignature=adhoc\nInfo.plist entries=28\nSealed Resources version=2 rules=13 files=2\n",
1540 )
1541 .unwrap();
1542 assert_eq!(parsed.identifier, "com.example.ferry");
1543 assert!(
1544 parse_code_signature_display(
1545 path,
1546 "Identifier=ferry-bin\nSignature=adhoc\nCodeDirectory flags=0x20002(adhoc,linker-signed)\nInfo.plist=not bound\nSealed Resources=none\n",
1547 )
1548 .is_err()
1549 );
1550 }
1551
1552 #[test]
1553 fn rejects_symlinked_app_bundle() {
1554 #[cfg(unix)]
1555 {
1556 use std::os::unix::fs::symlink;
1557
1558 let temporary = tempfile::tempdir().unwrap();
1559 let root = Utf8Path::from_path(temporary.path()).unwrap();
1560 fs::create_dir(root.join("real.app")).unwrap();
1561 symlink(root.join("real.app"), root.join("linked.app")).unwrap();
1562 assert!(matches!(
1563 validate_real_directory(&root.join("linked.app"), "app"),
1564 Err(AppleError::InvalidArtifact { .. })
1565 ));
1566 }
1567 }
1568
1569 #[test]
1570 fn activity_extension_requires_only_the_safe_model_framework() {
1571 let executable = Utf8Path::new("FerryLiveActivityExtension");
1572 assert!(
1573 validate_activity_extension_dependencies(
1574 executable,
1575 &[ACTIVITY_MODEL_INSTALL_NAME.to_owned()]
1576 )
1577 .unwrap()
1578 );
1579 assert!(validate_activity_extension_dependencies(executable, &[]).is_err());
1580 assert!(
1581 validate_activity_extension_dependencies(
1582 executable,
1583 &[
1584 ACTIVITY_MODEL_INSTALL_NAME.to_owned(),
1585 RUNTIME_BRIDGE_INSTALL_NAME.to_owned(),
1586 ],
1587 )
1588 .is_err()
1589 );
1590 }
1591
1592 #[test]
1593 fn rejects_stale_activity_model_without_an_activity_extension() {
1594 let temporary = tempfile::tempdir().unwrap();
1595 let app = Utf8Path::from_path(temporary.path()).unwrap();
1596 assert!(reject_unexpected_activity_model(app).is_ok());
1597 fs::create_dir_all(app.join("Frameworks/FerryActivityModel.framework")).unwrap();
1598 assert!(reject_unexpected_activity_model(app).is_err());
1599 }
1600}