Skip to main content

rustferry_apple/
artifact.rs

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/// Expected embedded app extension.
17#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
18pub struct IosExtensionExpectation {
19    /// Extension family.
20    pub kind: ExtensionKind,
21    /// Bundle directory name without `.appex`.
22    pub bundle_name: String,
23    /// Exact extension bundle identifier.
24    pub bundle_identifier: String,
25    /// Exact extension executable name.
26    pub executable_name: String,
27    /// Application group that must be embedded in the code signature.
28    pub app_group: Option<String>,
29}
30
31/// Independent validation inputs for a built iOS application.
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct IosArtifactExpectation {
34    /// Built `.app` directory.
35    pub app_path: Utf8PathBuf,
36    /// Exact `CFBundleIdentifier`.
37    pub bundle_identifier: String,
38    /// Exact `CFBundleExecutable` and executable filename.
39    pub executable_name: String,
40    /// Cargo binary whose Mach-O identity must match the signed embedded executable.
41    pub rust_binary: Option<Utf8PathBuf>,
42    /// Exact Mach-O architectures expected from `lipo -archs`.
43    pub expected_architectures: Vec<String>,
44    /// Expected embedded extensions.
45    pub extensions: Vec<IosExtensionExpectation>,
46    /// Custom URL schemes expected to route through the generated application delegate.
47    pub deep_link_schemes: Vec<String>,
48    /// Application group that must be embedded in the application signature.
49    pub app_group: Option<String>,
50    /// Optional destination for redacted validation logs.
51    pub log_dir: Option<Utf8PathBuf>,
52    /// Validated source snapshot requiring a compiled app-icon and launch-image catalog.
53    pub project_assets: Option<ProjectAssets>,
54    /// Expected asset representation, absent only when no project assets are required.
55    pub asset_packaging: Option<IosAssetPackaging>,
56}
57
58/// Evidence that the app consumes the generated asset catalog.
59#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
60pub struct IosAssetCatalogValidation {
61    /// Compiled catalog sealed into the application bundle.
62    pub compiled_catalog: Utf8PathBuf,
63    /// App-icon set selected by processed bundle metadata.
64    pub app_icon_name: String,
65    /// Launch-screen image set selected by processed bundle metadata.
66    pub launch_image_name: String,
67}
68
69/// Evidence for the runtime-free, SDK-only image resource fallback.
70#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
71pub struct IosSdkOnlyAssetValidation {
72    /// Exact validated icon resource sealed into the application bundle.
73    pub icon: Utf8PathBuf,
74    /// Exact validated splash resource sealed into the application bundle.
75    pub splash: Utf8PathBuf,
76    /// Legacy plist icon resource selected by the SDK-only host.
77    pub icon_file: String,
78    /// Launch-screen image resource selected by the SDK-only host.
79    pub launch_image_name: String,
80}
81
82/// Validated ad-hoc code-signature metadata.
83#[allow(clippy::struct_excessive_bools)]
84#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
85pub struct IosCodeSignatureValidation {
86    /// Exact identifier sealed into the signature.
87    pub identifier: String,
88    /// Whether the signature uses the local ad-hoc identity.
89    pub ad_hoc: bool,
90    /// Whether strict signature verification passed.
91    pub strict_verified: bool,
92    /// Whether recursive strict verification passed for this bundle.
93    pub deep_verified: bool,
94    /// Whether the bundle's `Info.plist` is sealed.
95    pub info_plist_sealed: bool,
96    /// Whether the bundle resources are sealed.
97    pub resources_sealed: bool,
98    /// Application groups embedded in the signature entitlements.
99    pub app_groups: Vec<String>,
100}
101
102/// Validated embedded extension metadata.
103#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
104pub struct IosExtensionValidation {
105    /// Extension family.
106    pub kind: ExtensionKind,
107    /// Validated `.appex` path.
108    pub path: Utf8PathBuf,
109    /// Validated bundle identifier.
110    pub bundle_identifier: String,
111    /// Validated executable path.
112    pub executable: Utf8PathBuf,
113    /// Validated Mach-O architectures.
114    pub architectures: Vec<String>,
115    /// Validated extension-point identifier.
116    pub extension_point_identifier: String,
117    /// Whether an `ActivityKit` extension links the extension-safe shared model framework.
118    pub activity_model_linked: bool,
119    /// Strictly verified extension code signature.
120    pub code_signature: IosCodeSignatureValidation,
121}
122
123/// Validated generated native runtime framework metadata.
124#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
125pub struct IosRuntimeBridgeValidation {
126    /// Embedded framework bundle path.
127    pub path: Utf8PathBuf,
128    /// Framework executable path.
129    pub executable: Utf8PathBuf,
130    /// Exact framework bundle identifier.
131    pub bundle_identifier: String,
132    /// Validated Mach-O architectures.
133    pub architectures: Vec<String>,
134    /// Mach-O install name used by the app and extensions.
135    pub install_name: String,
136    /// Whether the compiled framework contains the pre-`UIApplicationMain` delegate hook.
137    pub application_delegate_hook: bool,
138    /// Required C ABI entrypoints exported for Rust's dynamic loader.
139    pub exported_symbols: Vec<String>,
140    /// Strictly verified framework code signature.
141    pub code_signature: IosCodeSignatureValidation,
142}
143
144/// Evidence collected from a successfully validated `.app`.
145#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
146pub struct IosArtifactValidation {
147    /// Stable validation schema version.
148    pub schema_version: u32,
149    /// SHA-256 identity of the exact application tree inspected by the platform validator.
150    pub artifact_digest: ArtifactDigest,
151    /// Validated application path.
152    pub app_path: Utf8PathBuf,
153    /// Validated bundle identifier.
154    pub bundle_identifier: String,
155    /// Validated executable path.
156    pub executable: Utf8PathBuf,
157    /// Validated Mach-O architectures.
158    pub architectures: Vec<String>,
159    /// Whether the signed executable retains Cargo's Mach-O build identity.
160    pub rust_binary_embedded: bool,
161    /// Required resources found in the app.
162    pub resources: Vec<Utf8PathBuf>,
163    /// Compiled asset-catalog and processed metadata evidence.
164    pub asset_catalog: Option<IosAssetCatalogValidation>,
165    /// Validated raw-resource fallback used when no Simulator runtime is installed.
166    pub sdk_only_assets: Option<IosSdkOnlyAssetValidation>,
167    /// Inspected embedded frameworks and dynamic libraries.
168    pub embedded_frameworks: Vec<Utf8PathBuf>,
169    /// Generated native runtime framework evidence.
170    pub runtime_bridge: IosRuntimeBridgeValidation,
171    /// Validated embedded extensions.
172    pub extensions: Vec<IosExtensionValidation>,
173    /// Validated custom URL schemes.
174    pub deep_link_schemes: Vec<String>,
175    /// Generated `UIApplication` delegate class when deep links are enabled.
176    pub application_delegate: Option<String>,
177    /// Strictly and recursively verified application code signature.
178    pub code_signature: IosCodeSignatureValidation,
179}
180
181/// Validate plist metadata, executable identity/architecture, resources, libraries, and extensions.
182///
183/// # Errors
184///
185/// Returns [`AppleError`] when the bundle is missing, malformed, has unexpected
186/// metadata, architecture, code signature, or Cargo build identity, or a validation
187/// tool cannot run successfully.
188#[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
584/// Validate one standalone generated `.appex` product.
585///
586/// # Errors
587///
588/// Returns [`AppleError`] when the extension bundle, plist metadata,
589/// executable, extension point, or architecture does not match `expected`.
590pub 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}