Skip to main content

rustferry_remote/
artifact.rs

1//! Cross-platform manifests and independent iPhone artifact inspection.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    fs::{self, File, Metadata},
6    io::{Cursor, Read},
7};
8
9#[cfg(windows)]
10use std::os::windows::io::AsHandle as _;
11
12use camino::{Utf8Path, Utf8PathBuf};
13use cap_std::{ambient_authority, fs::Dir};
14use goblin::mach::{
15    Mach, MachO, SingleArch,
16    cputype::{
17        CPU_SUBTYPE_ARM64_ALL, CPU_SUBTYPE_ARM64_V8, CPU_TYPE_ARM, CPU_TYPE_ARM64, CPU_TYPE_X86,
18        CPU_TYPE_X86_64,
19    },
20    header::{MH_DYLIB, MH_EXECUTE},
21    load_command::CommandVariant,
22};
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26use thiserror::Error;
27use unicode_normalization::UnicodeNormalization;
28use zip::ZipArchive;
29
30#[cfg(windows)]
31use rustferry_core::windows_private_directory::regular_file_link_count;
32
33use crate::signing::{SigningMode, SigningStatus};
34
35/// Current artifact-manifest schema.
36pub const ARTIFACT_MANIFEST_SCHEMA_VERSION: u32 = 1;
37/// Exact secret-free success log emitted after protected signing cleanup.
38pub const PROTECTED_SIGNING_SANITIZED_LOG_V1: &[u8] =
39    b"RustFerry protected manual-development signing, IPA export, validation, and signing-material cleanup completed.\n";
40
41const MAX_IPA_ENTRIES: usize = 50_000;
42const MAX_IPA_ENTRY_NAME: usize = 4_096;
43const MAX_IPA_ENTRY_SIZE: u64 = 512 * 1024 * 1024;
44const MAX_IPA_TOTAL_SIZE: u64 = 2 * 1024 * 1024 * 1024;
45const MAX_COMPRESSION_RATIO: u64 = 200;
46const MAX_BUNDLE_DEPTH: usize = 128;
47const RUNTIME_BRIDGE_BUNDLE_PATH: &str = "Frameworks/FerryRuntimeBridge.framework";
48const RUNTIME_BRIDGE_INSTALL_NAME: &str = "@rpath/FerryRuntimeBridge.framework/FerryRuntimeBridge";
49const ACTIVITY_MODEL_BUNDLE_PATH: &str = "Frameworks/FerryActivityModel.framework";
50const ACTIVITY_MODEL_INSTALL_NAME: &str = "@rpath/FerryActivityModel.framework/FerryActivityModel";
51const LIVE_ACTIVITY_BUNDLE_PATH: &str = "PlugIns/FerryLiveActivityExtension.appex";
52
53/// One validation level proven by a concrete build or inspection step.
54#[derive(
55    Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize, JsonSchema,
56)]
57#[serde(rename_all = "snake_case")]
58pub enum ValidationLevel {
59    /// Source paths and hashes were verified.
60    SourceValidated,
61    /// Remote builder identity and tools were inspected.
62    RemoteBuilderValidated,
63    /// Rust compiled for the physical-device target.
64    DeviceTargetCompiled,
65    /// A device Mach-O binary was produced.
66    DeviceBinaryBuilt,
67    /// A physical-device application bundle was produced.
68    AppBundleBuilt,
69    /// An Xcode archive was produced and inspected.
70    ArchiveBuilt,
71    /// Development certificate metadata was validated.
72    CertificateValidated,
73    /// Provisioning profile metadata was validated.
74    ProvisioningValidated,
75    /// Every nested code target was signed and checked.
76    NestedCodeSigned,
77    /// The main application signature was checked.
78    ApplicationSigned,
79    /// An IPA was exported by the Apple toolchain.
80    IpaExported,
81    /// Independent remote artifact validation passed.
82    ArtifactValidated,
83    /// Expected bytes and SHA-256 were verified on the client.
84    DownloadedToClient,
85    /// The application was installed on a physical device.
86    InstallValidated,
87    /// The application launched on a physical device.
88    LaunchValidated,
89    /// Runtime behavior was observed on a physical device.
90    RuntimeValidated,
91}
92
93/// Artifact kind returned by a remote build.
94#[derive(
95    Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize, JsonSchema,
96)]
97#[serde(rename_all = "snake_case")]
98pub enum ArtifactKind {
99    /// Built `.app` directory, normally transported as an archive.
100    App,
101    /// Xcode `.xcarchive` directory, normally transported as an archive.
102    Xcarchive,
103    /// Installable iPhone application archive.
104    Ipa,
105    /// Compressed debug symbols.
106    Dsym,
107    /// Artifact manifest.
108    Manifest,
109    /// Signing evidence without secret material.
110    SigningReport,
111    /// Independent validation evidence.
112    ValidationReport,
113    /// Sanitized build log.
114    SanitizedLog,
115}
116
117/// One downloadable artifact and its integrity metadata.
118#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
119#[serde(deny_unknown_fields)]
120pub struct ArtifactRecord {
121    /// Provider-scoped immutable artifact identifier.
122    pub artifact_id: String,
123    /// Artifact kind.
124    pub kind: ArtifactKind,
125    /// Portable filename proposed to the client.
126    pub file_name: String,
127    /// Byte length reported by the producer.
128    pub size: u64,
129    /// Lowercase SHA-256 hex digest.
130    pub sha256: String,
131    /// Optional content type.
132    pub media_type: Option<String>,
133}
134
135/// Worker and Apple toolchain evidence.
136#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
137#[serde(deny_unknown_fields)]
138pub struct AppleToolchainEvidence {
139    /// Worker operating-system version.
140    pub worker_os: String,
141    /// Worker architecture.
142    pub worker_architecture: String,
143    /// Xcode version selected for the job.
144    pub xcode_version: String,
145    /// iPhoneOS SDK version selected for the job.
146    pub iphoneos_sdk_version: String,
147    /// Rust compiler version.
148    pub rust_version: String,
149    /// Rust target triple; physical builds require `aarch64-apple-ios`.
150    pub rust_target: String,
151}
152
153/// Public, non-secret signing evidence.
154#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
155#[serde(deny_unknown_fields)]
156pub struct ArtifactSigningEvidence {
157    /// Signing mode used by the worker.
158    pub mode: SigningMode,
159    /// Most advanced independently proven signing state.
160    pub status: SigningStatus,
161    /// Apple development Team ID.
162    pub team_id: Option<String>,
163    /// SHA-256 certificate fingerprint.
164    pub certificate_fingerprint: Option<String>,
165    /// Provisioning profile UUID.
166    pub profile_uuid: Option<String>,
167    /// RFC 3339 profile expiry.
168    pub profile_expiration: Option<String>,
169    /// SHA-256 of canonical entitlements.
170    pub entitlements_sha256: Option<String>,
171}
172
173/// Cleanup result from the remote worker.
174#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
175#[serde(rename_all = "snake_case")]
176pub enum CleanupStatus {
177    /// Cleanup has not run yet.
178    Pending,
179    /// Source, signing inputs, and temporary keychain were removed.
180    Confirmed,
181    /// Cleanup completed with a safe warning.
182    Warning,
183    /// Cleanup could not be proven.
184    Failed,
185}
186
187/// Immutable manifest binding source, worker, signing, and downloaded bytes.
188#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
189#[serde(deny_unknown_fields)]
190pub struct ArtifactManifest {
191    /// Artifact schema version.
192    pub schema_version: u32,
193    /// Client operation ID.
194    pub operation_id: String,
195    /// Provider job ID.
196    pub job_id: String,
197    /// Stable project ID.
198    pub project_id: String,
199    /// Source repository URL when Git source mode is used.
200    pub source_repository: Option<String>,
201    /// Exact commit SHA when Git source mode is used.
202    pub source_revision: Option<String>,
203    /// Whether explicit snapshot mode represented local changes.
204    pub source_snapshot: bool,
205    /// Deterministic source-manifest digest.
206    pub source_sha256: String,
207    /// Cargo lockfile digest.
208    pub cargo_lock_sha256: String,
209    /// Project configuration digest.
210    pub config_sha256: String,
211    /// `RustFerry` client version.
212    pub rustferry_version: String,
213    /// Remote worker version.
214    pub worker_version: String,
215    /// Provider identifier.
216    pub provider: String,
217    /// Apple and Rust toolchain evidence.
218    pub toolchain: AppleToolchainEvidence,
219    /// Application display name.
220    pub app_name: String,
221    /// Application semantic version.
222    pub app_version: String,
223    /// Application build number.
224    pub build_number: String,
225    /// Main bundle identifier.
226    pub bundle_identifier: String,
227    /// Cargo/Xcode build profile.
228    pub build_profile: String,
229    /// Required device architecture.
230    pub architecture: String,
231    /// Public signing evidence.
232    pub signing: ArtifactSigningEvidence,
233    /// Embedded extension bundle identifiers.
234    pub extensions: Vec<String>,
235    /// Downloadable artifacts.
236    pub artifacts: Vec<ArtifactRecord>,
237    /// Individually proven validation levels.
238    pub validation_levels: BTreeSet<ValidationLevel>,
239    /// RFC 3339 job start time.
240    pub started_at: String,
241    /// RFC 3339 job completion time.
242    pub finished_at: String,
243    /// Worker cleanup status.
244    pub cleanup_status: CleanupStatus,
245}
246
247impl ArtifactManifest {
248    /// Construct an empty versioned manifest for a job.
249    #[must_use]
250    pub fn new(operation_id: impl Into<String>, job_id: impl Into<String>) -> Self {
251        Self {
252            schema_version: ARTIFACT_MANIFEST_SCHEMA_VERSION,
253            operation_id: operation_id.into(),
254            job_id: job_id.into(),
255            project_id: String::new(),
256            source_repository: None,
257            source_revision: None,
258            source_snapshot: false,
259            source_sha256: String::new(),
260            cargo_lock_sha256: String::new(),
261            config_sha256: String::new(),
262            rustferry_version: String::new(),
263            worker_version: String::new(),
264            provider: String::new(),
265            toolchain: AppleToolchainEvidence {
266                worker_os: String::new(),
267                worker_architecture: String::new(),
268                xcode_version: String::new(),
269                iphoneos_sdk_version: String::new(),
270                rust_version: String::new(),
271                rust_target: String::new(),
272            },
273            app_name: String::new(),
274            app_version: String::new(),
275            build_number: String::new(),
276            bundle_identifier: String::new(),
277            build_profile: String::new(),
278            architecture: String::new(),
279            signing: ArtifactSigningEvidence {
280                mode: SigningMode::UnsignedCompileOnly,
281                status: SigningStatus::Unsigned,
282                team_id: None,
283                certificate_fingerprint: None,
284                profile_uuid: None,
285                profile_expiration: None,
286                entitlements_sha256: None,
287            },
288            extensions: Vec::new(),
289            artifacts: Vec::new(),
290            validation_levels: BTreeSet::new(),
291            started_at: String::new(),
292            finished_at: String::new(),
293            cleanup_status: CleanupStatus::Pending,
294        }
295    }
296
297    /// Find exactly one artifact of the requested kind.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error when the artifact is missing or ambiguous.
302    pub fn one_artifact(&self, kind: ArtifactKind) -> Result<&ArtifactRecord, ArtifactError> {
303        let mut matches = self
304            .artifacts
305            .iter()
306            .filter(|artifact| artifact.kind == kind);
307        let artifact = matches
308            .next()
309            .ok_or(ArtifactError::ManifestArtifactMissing { kind })?;
310        if matches.next().is_some() {
311            return Err(ArtifactError::ManifestArtifactAmbiguous { kind });
312        }
313        Ok(artifact)
314    }
315}
316
317/// Apple platform encoded in `LC_BUILD_VERSION`.
318#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
319#[serde(rename_all = "snake_case")]
320pub enum ApplePlatform {
321    /// Physical iOS device.
322    Ios,
323    /// iOS Simulator.
324    IosSimulator,
325    /// macOS.
326    Macos,
327    /// Mac Catalyst.
328    MacCatalyst,
329    /// visionOS device.
330    VisionOs,
331    /// visionOS Simulator.
332    VisionOsSimulator,
333    /// Another known Apple platform represented by its numeric value.
334    Other(u32),
335    /// Only legacy `LC_VERSION_MIN_IPHONEOS` was present; device status is not proven.
336    LegacyIphoneOs,
337}
338
339/// Cross-platform Mach-O platform evidence for one architecture slice.
340#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
341#[serde(deny_unknown_fields)]
342pub struct MachOSliceEvidence {
343    /// Architecture name.
344    pub architecture: String,
345    /// Apple platform.
346    pub platform: ApplePlatform,
347    /// Minimum operating-system version.
348    pub minimum_os: Option<String>,
349    /// SDK version recorded by the linker.
350    pub sdk: Option<String>,
351}
352
353/// Expected metadata for client-side IPA inspection.
354#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
355#[serde(deny_unknown_fields)]
356pub struct IpaExpectation {
357    /// Exact main application directory name, including `.app`.
358    pub app_directory_name: String,
359    /// Main bundle identifier.
360    pub bundle_identifier: String,
361    /// `CFBundleExecutable`.
362    pub executable: String,
363    /// Optional expected short version.
364    pub app_version: Option<String>,
365    /// Optional expected build number.
366    pub build_number: Option<String>,
367    /// Configured minimum iOS version.
368    pub minimum_os: String,
369    /// Exact generated extension and framework set.
370    pub nested_bundles: Vec<UnsignedNestedBundleExpectation>,
371    /// Whether a development provisioning profile must be embedded.
372    pub provisioning_required: bool,
373}
374
375/// Evidence produced by cross-platform IPA inspection.
376#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
377#[serde(deny_unknown_fields)]
378pub struct IpaInspection {
379    /// Main application path under `Payload/`.
380    pub app_path: String,
381    /// Main bundle identifier.
382    pub bundle_identifier: String,
383    /// Main executable name.
384    pub executable: String,
385    /// Main executable platform evidence.
386    pub main_executable: Vec<MachOSliceEvidence>,
387    /// Nested executable path to platform evidence.
388    pub nested_executables: BTreeMap<String, Vec<MachOSliceEvidence>>,
389    /// Embedded extension bundle identifiers.
390    pub extensions: Vec<String>,
391    /// Whether the main provisioning profile exists.
392    pub provisioning_profile_present: bool,
393    /// Sorted archive entries.
394    pub entries: Vec<String>,
395    /// IPA SHA-256.
396    pub sha256: String,
397    /// IPA byte size.
398    pub size: u64,
399}
400
401/// Kind and expected package type of code nested inside an application bundle.
402#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
403#[serde(rename_all = "snake_case")]
404pub enum UnsignedNestedBundleKind {
405    /// An iOS application extension (`.appex`, package type `XPC!`).
406    AppExtension,
407    /// An embedded dynamic framework (`.framework`, package type `FMWK`).
408    Framework,
409}
410
411impl UnsignedNestedBundleKind {
412    const fn package_type(self) -> &'static str {
413        match self {
414            Self::AppExtension => "XPC!",
415            Self::Framework => "FMWK",
416        }
417    }
418
419    const fn path_extension(self) -> &'static str {
420        match self {
421            Self::AppExtension => "appex",
422            Self::Framework => "framework",
423        }
424    }
425}
426
427/// Expected identity of one generated extension or framework in an unsigned app.
428#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
429#[serde(deny_unknown_fields)]
430pub struct UnsignedNestedBundleExpectation {
431    /// Slash-separated path relative to the main `.app` root.
432    pub relative_path: String,
433    /// Exact nested bundle identifier.
434    pub bundle_identifier: String,
435    /// Exact `CFBundleExecutable` and executable filename.
436    pub executable: String,
437    /// Expected nested code kind.
438    pub kind: UnsignedNestedBundleKind,
439}
440
441/// Client-owned product identity used to validate both unsigned and signed outputs.
442///
443/// Unlike [`UnsignedXcarchiveExpectation`], this contains no worker-selected SDK fields. Every
444/// value is derived before provider submission and is therefore safe to use as an independent
445/// artifact expectation.
446#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
447#[serde(deny_unknown_fields)]
448pub struct IosDeviceProductExpectation {
449    /// Exact main application directory name, including `.app`.
450    pub app_directory_name: String,
451    /// Exact main `CFBundleExecutable` and executable filename.
452    pub executable: String,
453    /// Exact `CFBundleShortVersionString`.
454    pub app_version: String,
455    /// Exact `CFBundleVersion`.
456    pub build_number: String,
457    /// Exact generated extension and framework set.
458    pub nested_bundles: Vec<UnsignedNestedBundleExpectation>,
459}
460
461/// Exact generated-product invariants for an unsigned physical-iPhone archive.
462#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
463#[serde(deny_unknown_fields)]
464pub struct UnsignedXcarchiveExpectation {
465    /// Exact application directory name, including `.app`.
466    pub app_directory_name: String,
467    /// Main application bundle identifier.
468    pub bundle_identifier: String,
469    /// Exact `CFBundleExecutable` and executable filename.
470    pub executable: String,
471    /// Exact `CFBundleShortVersionString`.
472    pub app_version: String,
473    /// Exact `CFBundleVersion`.
474    pub build_number: String,
475    /// Configured minimum iOS version.
476    pub minimum_os: String,
477    /// Exact selected iPhoneOS SDK version encoded by newly built code.
478    pub sdk_version: String,
479    /// Exact selected iPhoneOS SDK build version injected into processed plists.
480    pub sdk_build_version: String,
481    /// Exact generated extension and framework set.
482    pub nested_bundles: Vec<UnsignedNestedBundleExpectation>,
483    /// Required app-relative resource path to lowercase SHA-256.
484    pub required_resources: BTreeMap<String, String>,
485}
486
487/// Cross-platform evidence for one unsigned physical-iPhone `.app` directory.
488#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
489#[serde(deny_unknown_fields)]
490pub struct UnsignedAppInspection {
491    /// Exact application directory name.
492    pub app_directory_name: String,
493    /// Main application bundle identifier.
494    pub bundle_identifier: String,
495    /// Main executable name.
496    pub executable: String,
497    /// Main executable device-platform evidence.
498    pub main_executable: Vec<MachOSliceEvidence>,
499    /// Every other Mach-O path relative to the app and its device evidence.
500    pub nested_executables: BTreeMap<String, Vec<MachOSliceEvidence>>,
501    /// Sorted extension bundle identifiers.
502    pub extensions: Vec<String>,
503    /// Verified required resource hashes.
504    pub resources: BTreeMap<String, String>,
505    /// Sorted app-relative files and directories inspected.
506    pub entries: Vec<String>,
507}
508
509/// Cross-platform evidence for an unsigned physical-iPhone `.xcarchive`.
510#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
511#[serde(deny_unknown_fields)]
512pub struct UnsignedXcarchiveInspection {
513    /// Main application path relative to `Products/`.
514    pub application_path: String,
515    /// Architectures declared by the archive metadata.
516    pub architectures: Vec<String>,
517    /// Independently inspected application evidence.
518    pub app: UnsignedAppInspection,
519    /// Sorted archive-relative files and directories inspected.
520    pub entries: Vec<String>,
521}
522
523/// Artifact or integrity validation failure.
524#[derive(Debug, Error)]
525pub enum ArtifactError {
526    /// File-system operation failed.
527    #[error("{action} `{path}` failed: {message}")]
528    Io {
529        /// Operation description.
530        action: &'static str,
531        /// Safe artifact path.
532        path: Utf8PathBuf,
533        /// I/O error text.
534        message: String,
535    },
536    /// ZIP parsing or entry access failed.
537    #[error("IPA `{path}` is not a valid ZIP archive: {message}")]
538    Zip {
539        /// IPA path.
540        path: Utf8PathBuf,
541        /// ZIP error text.
542        message: String,
543    },
544    /// Archive safety or structure invariant failed.
545    #[error("IPA `{path}` failed validation: {reason}")]
546    InvalidIpa {
547        /// IPA path.
548        path: Utf8PathBuf,
549        /// Secret-safe reason.
550        reason: String,
551    },
552    /// Unsigned app or Xcode archive structure failed validation.
553    #[error("Apple bundle `{path}` failed validation: {reason}")]
554    InvalidAppleBundle {
555        /// App or archive root.
556        path: Utf8PathBuf,
557        /// Secret-safe failed invariant.
558        reason: String,
559    },
560    /// Mach-O parsing failed.
561    #[error("Mach-O validation failed: {0}")]
562    InvalidMachO(String),
563    /// A Simulator binary was found where a device binary was required.
564    #[error("This artifact targets iOS Simulator, not a physical iPhone")]
565    SimulatorBinary,
566    /// No arm64 slice was found.
567    #[error("physical-iPhone executable does not contain arm64")]
568    Arm64Missing,
569    /// Platform evidence was missing or not physical iOS.
570    #[error("Mach-O platform is not proven to be physical iOS: {0}")]
571    DevicePlatformUnproven(String),
572    /// Artifact manifest omitted an expected kind.
573    #[error("artifact manifest does not contain {kind:?}")]
574    ManifestArtifactMissing {
575        /// Missing kind.
576        kind: ArtifactKind,
577    },
578    /// Artifact manifest contains duplicate records for a kind.
579    #[error("artifact manifest contains multiple {kind:?} records")]
580    ManifestArtifactAmbiguous {
581        /// Ambiguous kind.
582        kind: ArtifactKind,
583    },
584    /// Downloaded bytes did not match the manifest.
585    #[error("Downloaded artifact failed integrity verification: {reason}")]
586    Integrity {
587        /// Integrity mismatch.
588        reason: String,
589    },
590}
591
592/// Inspect a Mach-O and require physical-iPhone arm64 platform evidence.
593///
594/// # Errors
595///
596/// Rejects malformed binaries, archives embedded as fat slices, Simulator platforms, missing
597/// `LC_BUILD_VERSION`, non-iOS slices, or a missing arm64 slice.
598pub fn inspect_physical_iphone_macho(
599    bytes: &[u8],
600) -> Result<Vec<MachOSliceEvidence>, ArtifactError> {
601    let parsed =
602        Mach::parse(bytes).map_err(|error| ArtifactError::InvalidMachO(error.to_string()))?;
603    let mut evidence = Vec::new();
604    match parsed {
605        Mach::Binary(binary) => evidence.push(inspect_macho_slice(&binary)?),
606        Mach::Fat(container) => {
607            for entry in &container {
608                match entry.map_err(|error| ArtifactError::InvalidMachO(error.to_string()))? {
609                    SingleArch::MachO(binary) => evidence.push(inspect_macho_slice(&binary)?),
610                    SingleArch::Archive(_) => {
611                        return Err(ArtifactError::InvalidMachO(
612                            "fat executable contains a static archive slice".to_owned(),
613                        ));
614                    }
615                }
616            }
617        }
618    }
619    if !evidence.iter().any(|slice| slice.architecture == "arm64") {
620        return Err(ArtifactError::Arm64Missing);
621    }
622    for slice in &evidence {
623        match slice.platform {
624            ApplePlatform::Ios => {}
625            ApplePlatform::IosSimulator => return Err(ArtifactError::SimulatorBinary),
626            platform => {
627                return Err(ArtifactError::DevicePlatformUnproven(format!(
628                    "{} slice reports {platform:?}",
629                    slice.architecture
630                )));
631            }
632        }
633    }
634    evidence.sort_by(|left, right| left.architecture.cmp(&right.architecture));
635    Ok(evidence)
636}
637
638fn inspect_macho_slice(binary: &MachO<'_>) -> Result<MachOSliceEvidence, ArtifactError> {
639    let architecture = architecture_name(binary.header.cputype);
640    let mut platform = None;
641    let mut minimum_os = None;
642    let mut sdk = None;
643    for command in &binary.load_commands {
644        match &command.command {
645            CommandVariant::BuildVersion(build) => {
646                let parsed_platform = apple_platform(build.platform);
647                if platform.replace(parsed_platform).is_some() {
648                    return Err(ArtifactError::InvalidMachO(format!(
649                        "{architecture} slice has multiple LC_BUILD_VERSION commands"
650                    )));
651                }
652                minimum_os = Some(decode_apple_version(build.minos));
653                sdk = Some(decode_apple_version(build.sdk));
654            }
655            CommandVariant::VersionMinIphoneos(version) if platform.is_none() => {
656                platform = Some(ApplePlatform::LegacyIphoneOs);
657                minimum_os = Some(decode_apple_version(version.version));
658                sdk = Some(decode_apple_version(version.sdk));
659            }
660            _ => {}
661        }
662    }
663    let platform = platform.ok_or_else(|| {
664        ArtifactError::DevicePlatformUnproven(format!(
665            "{architecture} slice has no Apple platform load command"
666        ))
667    })?;
668    Ok(MachOSliceEvidence {
669        architecture,
670        platform,
671        minimum_os,
672        sdk,
673    })
674}
675
676fn apple_platform(value: u32) -> ApplePlatform {
677    match value {
678        1 => ApplePlatform::Macos,
679        2 => ApplePlatform::Ios,
680        6 => ApplePlatform::MacCatalyst,
681        7 => ApplePlatform::IosSimulator,
682        11 => ApplePlatform::VisionOs,
683        12 => ApplePlatform::VisionOsSimulator,
684        other => ApplePlatform::Other(other),
685    }
686}
687
688fn architecture_name(cputype: u32) -> String {
689    match cputype {
690        CPU_TYPE_ARM64 => "arm64".to_owned(),
691        CPU_TYPE_ARM => "arm".to_owned(),
692        CPU_TYPE_X86_64 => "x86_64".to_owned(),
693        CPU_TYPE_X86 => "x86".to_owned(),
694        other => format!("cpu-{other:#x}"),
695    }
696}
697
698fn decode_apple_version(value: u32) -> String {
699    format!(
700        "{}.{}.{}",
701        (value >> 16) & 0xffff,
702        (value >> 8) & 0xff,
703        value & 0xff
704    )
705}
706
707/// Verify a downloaded file's expected size and SHA-256.
708///
709/// # Errors
710///
711/// Returns an integrity error when metadata or bytes differ.
712pub fn verify_downloaded_file(
713    path: &Utf8Path,
714    expected: &ArtifactRecord,
715) -> Result<(), ArtifactError> {
716    let metadata = path.metadata().map_err(|error| ArtifactError::Io {
717        action: "read metadata for",
718        path: path.to_owned(),
719        message: error.to_string(),
720    })?;
721    if !metadata.is_file() {
722        return Err(ArtifactError::Integrity {
723            reason: format!("`{path}` is not a regular file"),
724        });
725    }
726    if metadata.len() != expected.size {
727        return Err(ArtifactError::Integrity {
728            reason: format!(
729                "expected {} bytes for {}, downloaded {}",
730                expected.size,
731                expected.file_name,
732                metadata.len()
733            ),
734        });
735    }
736    let actual = sha256_file(path)?;
737    if !constant_time_ascii_eq(actual.as_bytes(), expected.sha256.as_bytes()) {
738        return Err(ArtifactError::Integrity {
739            reason: format!("SHA-256 mismatch for {}", expected.file_name),
740        });
741    }
742    Ok(())
743}
744
745/// Inspect ZIP safety, payload metadata, and every discoverable nested Mach-O without Apple tools.
746///
747/// Remote `codesign` and profile validation remain separate required evidence. This client check
748/// binds downloaded bytes to the manifest and rejects Simulator binaries; it does not claim to
749/// reproduce Apple's signature verifier.
750///
751/// # Errors
752///
753/// Rejects unsafe or malformed ZIPs, structure/metadata mismatches, missing profiles, Simulator
754/// code, unexpected source/signing material, or violated resource limits.
755#[allow(clippy::too_many_lines)]
756pub fn inspect_ipa(
757    path: &Utf8Path,
758    expectation: &IpaExpectation,
759) -> Result<IpaInspection, ArtifactError> {
760    let size = path
761        .metadata()
762        .map_err(|error| ArtifactError::Io {
763            action: "read metadata for",
764            path: path.to_owned(),
765            message: error.to_string(),
766        })?
767        .len();
768    let sha256 = sha256_file(path)?;
769    let file = File::open(path).map_err(|error| ArtifactError::Io {
770        action: "open",
771        path: path.to_owned(),
772        message: error.to_string(),
773    })?;
774    let mut archive = ZipArchive::new(file).map_err(|error| ArtifactError::Zip {
775        path: path.to_owned(),
776        message: error.to_string(),
777    })?;
778    if archive.len() > MAX_IPA_ENTRIES {
779        return invalid_ipa(
780            path,
781            format!("archive has more than {MAX_IPA_ENTRIES} entries"),
782        );
783    }
784
785    let mut entries = Vec::with_capacity(archive.len());
786    let mut exact = BTreeSet::new();
787    let mut folded = BTreeMap::<String, String>::new();
788    let mut total_size = 0_u64;
789    let mut app_info_plists = Vec::new();
790    for index in 0..archive.len() {
791        let mut entry = archive
792            .by_index(index)
793            .map_err(|error| ArtifactError::Zip {
794                path: path.to_owned(),
795                message: error.to_string(),
796            })?;
797        let name = entry.name().to_owned();
798        validate_zip_name(path, &name)?;
799        if !exact.insert(name.clone()) {
800            return invalid_ipa(path, format!("duplicate ZIP entry `{name}`"));
801        }
802        let fold = canonical_archive_key(&name);
803        if let Some(previous) = folded.insert(fold, name.clone())
804            && previous != name
805        {
806            return invalid_ipa(
807                path,
808                format!("case-colliding ZIP entries `{previous}` and `{name}`"),
809            );
810        }
811        if entry.size() > MAX_IPA_ENTRY_SIZE {
812            return invalid_ipa(path, format!("entry `{name}` exceeds the per-file limit"));
813        }
814        total_size =
815            total_size
816                .checked_add(entry.size())
817                .ok_or_else(|| ArtifactError::InvalidIpa {
818                    path: path.to_owned(),
819                    reason: "uncompressed size overflow".to_owned(),
820                })?;
821        if total_size > MAX_IPA_TOTAL_SIZE {
822            return invalid_ipa(
823                path,
824                "uncompressed IPA exceeds the total-size limit".to_owned(),
825            );
826        }
827        if entry.compressed_size() > 0
828            && entry.size() > 1024 * 1024
829            && entry.size() / entry.compressed_size() > MAX_COMPRESSION_RATIO
830        {
831            return invalid_ipa(
832                path,
833                format!("entry `{name}` exceeds the compression-ratio limit"),
834            );
835        }
836        if let Some(mode) = entry.unix_mode() {
837            let kind = mode & 0o170_000;
838            if kind != 0 && kind != 0o040_000 && kind != 0o100_000 {
839                return invalid_ipa(path, format!("entry `{name}` is a link or special file"));
840            }
841        }
842        reject_sensitive_or_generated_name(path, &name)?;
843        if is_main_info_plist(&name) {
844            app_info_plists.push(name.clone());
845        }
846        if !entry.is_dir() {
847            let declared_size = entry.size();
848            let unpacked_size =
849                std::io::copy(&mut entry, &mut std::io::sink()).map_err(|error| {
850                    ArtifactError::InvalidIpa {
851                        path: path.to_owned(),
852                        reason: format!(
853                            "entry `{name}` failed ZIP integrity verification: {error}"
854                        ),
855                    }
856                })?;
857            if unpacked_size != declared_size {
858                return invalid_ipa(
859                    path,
860                    format!(
861                        "entry `{name}` declared {declared_size} bytes but unpacked {unpacked_size}"
862                    ),
863                );
864            }
865        }
866        entries.push(name);
867    }
868    if app_info_plists.len() != 1 {
869        return invalid_ipa(
870            path,
871            format!(
872                "expected exactly one Payload/<App>.app/Info.plist, found {}",
873                app_info_plists.len()
874            ),
875        );
876    }
877    let info_path = app_info_plists.pop().unwrap_or_default();
878    let app_path = info_path.trim_end_matches("/Info.plist").to_owned();
879    let expected_app_path = format!("Payload/{}", expectation.app_directory_name);
880    if app_path != expected_app_path {
881        return invalid_ipa(
882            path,
883            format!("application path is `{app_path}`, expected `{expected_app_path}`"),
884        );
885    }
886    let info = read_zip_plist(&mut archive, path, &info_path)?;
887    let bundle_identifier = plist_string(path, &info, "CFBundleIdentifier")?;
888    let executable = plist_string(path, &info, "CFBundleExecutable")?;
889    if bundle_identifier != expectation.bundle_identifier {
890        return invalid_ipa(
891            path,
892            format!(
893                "CFBundleIdentifier mismatch: expected `{}`, found `{bundle_identifier}`",
894                expectation.bundle_identifier
895            ),
896        );
897    }
898    if executable != expectation.executable {
899        return invalid_ipa(
900            path,
901            format!(
902                "CFBundleExecutable mismatch: expected `{}`, found `{executable}`",
903                expectation.executable
904            ),
905        );
906    }
907    if let Some(expected) = &expectation.app_version {
908        let actual = plist_string(path, &info, "CFBundleShortVersionString")?;
909        if &actual != expected {
910            return invalid_ipa(
911                path,
912                format!("app version mismatch: expected `{expected}`, found `{actual}`"),
913            );
914        }
915    }
916    if let Some(expected) = &expectation.build_number {
917        let actual = plist_string(path, &info, "CFBundleVersion")?;
918        if &actual != expected {
919            return invalid_ipa(
920                path,
921                format!("build number mismatch: expected `{expected}`, found `{actual}`"),
922            );
923        }
924    }
925    let minimum_os = plist_string(path, &info, "MinimumOSVersion")?;
926    validate_ipa_minimum_os(path, "application", &minimum_os, &expectation.minimum_os)?;
927
928    let executable_path = format!("{app_path}/{executable}");
929    let main_bytes = read_zip_entry(&mut archive, path, &executable_path)?;
930    let main_executable = inspect_physical_iphone_macho(&main_bytes)?;
931    validate_ipa_macho_minimum_os(
932        path,
933        &executable_path,
934        &main_executable,
935        &expectation.minimum_os,
936    )?;
937    let profile_path = format!("{app_path}/embedded.mobileprovision");
938    let provisioning_profile_present = exact.contains(&profile_path);
939    if expectation.provisioning_required && !provisioning_profile_present {
940        return invalid_ipa(path, "embedded.mobileprovision is missing".to_owned());
941    }
942
943    let mut extensions = Vec::new();
944    let mut nested_executables = BTreeMap::new();
945    let mut expected_nested = BTreeMap::new();
946    for expected in &expectation.nested_bundles {
947        if expected_nested
948            .insert(expected.relative_path.as_str(), expected)
949            .is_some()
950        {
951            return invalid_ipa(
952                path,
953                format!(
954                    "duplicate nested bundle expectation `{}`",
955                    expected.relative_path
956                ),
957            );
958        }
959    }
960    let mut actual_nested = BTreeSet::new();
961    let nested_plists = entries
962        .iter()
963        .filter(|name| {
964            name.starts_with(&format!("{app_path}/"))
965                && (name.ends_with(".appex/Info.plist") || name.ends_with(".framework/Info.plist"))
966        })
967        .cloned()
968        .collect::<Vec<_>>();
969    for nested_info_path in nested_plists {
970        let nested_info = read_zip_plist(&mut archive, path, &nested_info_path)?;
971        let nested_executable = plist_string(path, &nested_info, "CFBundleExecutable")?;
972        let nested_root = nested_info_path.trim_end_matches("/Info.plist");
973        let relative_root = nested_root
974            .strip_prefix(&format!("{app_path}/"))
975            .ok_or_else(|| ArtifactError::InvalidIpa {
976                path: path.to_owned(),
977                reason: format!("nested bundle `{nested_root}` escaped the main application"),
978            })?;
979        let expected =
980            expected_nested
981                .get(relative_root)
982                .ok_or_else(|| ArtifactError::InvalidIpa {
983                    path: path.to_owned(),
984                    reason: format!("unexpected nested bundle `{relative_root}`"),
985                })?;
986        let actual_kind = if nested_info_path.ends_with(".appex/Info.plist") {
987            UnsignedNestedBundleKind::AppExtension
988        } else {
989            UnsignedNestedBundleKind::Framework
990        };
991        let nested_bundle_identifier = plist_string(path, &nested_info, "CFBundleIdentifier")?;
992        if expected.kind != actual_kind
993            || expected.executable != nested_executable
994            || expected.bundle_identifier != nested_bundle_identifier
995        {
996            return invalid_ipa(
997                path,
998                format!("nested bundle `{relative_root}` differs from its request expectation"),
999            );
1000        }
1001        if let Some(expected_version) = &expectation.app_version {
1002            let actual_version = plist_string(path, &nested_info, "CFBundleShortVersionString")?;
1003            if actual_version != *expected_version {
1004                return invalid_ipa(
1005                    path,
1006                    format!("nested bundle `{relative_root}` has an unexpected app version"),
1007                );
1008            }
1009        }
1010        if let Some(expected_build) = &expectation.build_number {
1011            let actual_build = plist_string(path, &nested_info, "CFBundleVersion")?;
1012            if actual_build != *expected_build {
1013                return invalid_ipa(
1014                    path,
1015                    format!("nested bundle `{relative_root}` has an unexpected build number"),
1016                );
1017            }
1018        }
1019        let nested_minimum_os = plist_string(path, &nested_info, "MinimumOSVersion")?;
1020        validate_ipa_minimum_os(
1021            path,
1022            relative_root,
1023            &nested_minimum_os,
1024            &expectation.minimum_os,
1025        )?;
1026        let nested_path = format!("{nested_root}/{nested_executable}");
1027        let nested_bytes = read_zip_entry(&mut archive, path, &nested_path)?;
1028        let nested_evidence = inspect_physical_iphone_macho(&nested_bytes)?;
1029        validate_ipa_macho_minimum_os(
1030            path,
1031            &nested_path,
1032            &nested_evidence,
1033            &expectation.minimum_os,
1034        )?;
1035        if actual_kind == UnsignedNestedBundleKind::AppExtension {
1036            extensions.push(nested_bundle_identifier);
1037        }
1038        actual_nested.insert(relative_root.to_owned());
1039        nested_executables.insert(nested_path, nested_evidence);
1040    }
1041    if actual_nested
1042        != expected_nested
1043            .keys()
1044            .map(|path| (*path).to_owned())
1045            .collect()
1046    {
1047        return invalid_ipa(
1048            path,
1049            "nested bundle set differs from the request".to_owned(),
1050        );
1051    }
1052    for name in entries.iter().filter(|name| {
1053        name.starts_with(&format!("{app_path}/Frameworks/")) && has_ascii_extension(name, "dylib")
1054    }) {
1055        let bytes = read_zip_entry(&mut archive, path, name)?;
1056        nested_executables.insert(name.clone(), inspect_physical_iphone_macho(&bytes)?);
1057    }
1058    extensions.sort();
1059    entries.sort();
1060    Ok(IpaInspection {
1061        app_path,
1062        bundle_identifier,
1063        executable,
1064        main_executable,
1065        nested_executables,
1066        extensions,
1067        provisioning_profile_present,
1068        entries,
1069        sha256,
1070        size,
1071    })
1072}
1073
1074fn validate_ipa_minimum_os(
1075    path: &Utf8Path,
1076    context: &str,
1077    actual: &str,
1078    expected: &str,
1079) -> Result<(), ArtifactError> {
1080    let actual = parse_apple_version(actual);
1081    let expected = parse_apple_version(expected);
1082    if actual.is_none() || actual != expected {
1083        return invalid_ipa(
1084            path,
1085            format!("{context} minimum iOS version does not match the request"),
1086        );
1087    }
1088    Ok(())
1089}
1090
1091fn validate_ipa_macho_minimum_os(
1092    path: &Utf8Path,
1093    relative: &str,
1094    evidence: &[MachOSliceEvidence],
1095    expected: &str,
1096) -> Result<(), ArtifactError> {
1097    let expected = parse_apple_version(expected).ok_or_else(|| ArtifactError::InvalidIpa {
1098        path: path.to_owned(),
1099        reason: "request minimum iOS version is invalid".to_owned(),
1100    })?;
1101    if evidence
1102        .iter()
1103        .any(|slice| slice.minimum_os.as_deref().and_then(parse_apple_version) != Some(expected))
1104    {
1105        return invalid_ipa(
1106            path,
1107            format!("Mach-O `{relative}` minimum iOS version does not match the request"),
1108        );
1109    }
1110    Ok(())
1111}
1112
1113/// Inspect an unsigned physical-iPhone `.xcarchive` without trusting Xcode's exit status.
1114///
1115/// The archive must contain exactly one generated application below `Products/Applications`.
1116/// Archive and app metadata, generated resources, all nested bundles, and every runtime Mach-O
1117/// are checked independently. Signing material and non-empty Mach-O signature payloads are
1118/// forbidden because this function validates the credential-free compilation phase.
1119///
1120/// # Errors
1121///
1122/// Rejects generic or ambiguous archives, unsafe filesystem objects, metadata/resource drift,
1123/// unexpected nested products or code, Simulator/non-arm64 code, and signing material.
1124pub fn inspect_unsigned_xcarchive(
1125    path: &Utf8Path,
1126    expectation: &UnsignedXcarchiveExpectation,
1127) -> Result<UnsignedXcarchiveInspection, ArtifactError> {
1128    validate_unsigned_expectation(path, expectation)?;
1129    let filesystem = ArtifactFilesystem::open_ambient(path)?;
1130    let tree = scan_real_tree(&filesystem)?;
1131    let expected_app = format!("Products/Applications/{}", expectation.app_directory_name);
1132
1133    let product_children = direct_children(&tree, "Products");
1134    if product_children != BTreeSet::from(["Products/Applications".to_owned()]) {
1135        return invalid_apple_bundle(
1136            path,
1137            format!(
1138                "Products must contain only Applications, found {product_children:?}; generic or multi-product archives are not accepted"
1139            ),
1140        );
1141    }
1142    let application_children = direct_children(&tree, "Products/Applications");
1143    if application_children != BTreeSet::from([expected_app.clone()]) {
1144        return invalid_apple_bundle(
1145            path,
1146            format!("expected exactly `{expected_app}`, found {application_children:?}"),
1147        );
1148    }
1149    require_tree_directory(path, &tree, &expected_app)?;
1150
1151    let archive_info = read_filesystem_plist(&filesystem, Utf8Path::new("Info.plist"))?;
1152    let properties = archive_info
1153        .get("ApplicationProperties")
1154        .and_then(plist::Value::as_dictionary)
1155        .ok_or_else(|| ArtifactError::InvalidAppleBundle {
1156            path: path.to_owned(),
1157            reason: "archive Info.plist has no ApplicationProperties dictionary; this is not an Organizer-style app archive".to_owned(),
1158        })?;
1159    let application_path = required_plist_string(
1160        path,
1161        properties,
1162        "ApplicationPath",
1163        "archive ApplicationProperties",
1164    )?;
1165    let expected_application_path = format!("Applications/{}", expectation.app_directory_name);
1166    if application_path != expected_application_path {
1167        return invalid_apple_bundle(
1168            path,
1169            format!(
1170                "archive ApplicationPath is `{application_path}`, expected `{expected_application_path}`"
1171            ),
1172        );
1173    }
1174    validate_plist_identity(
1175        path,
1176        properties,
1177        "archive ApplicationProperties",
1178        &expectation.bundle_identifier,
1179        &expectation.app_version,
1180        &expectation.build_number,
1181    )?;
1182    let architectures = plist_string_array(
1183        path,
1184        properties,
1185        "Architectures",
1186        "archive ApplicationProperties",
1187    )?;
1188    if architectures != ["arm64"] {
1189        return invalid_apple_bundle(
1190            path,
1191            format!("archive architectures are {architectures:?}, expected exactly [\"arm64\"]"),
1192        );
1193    }
1194    for key in [
1195        "SigningIdentity",
1196        "Team",
1197        "ProvisioningProfile",
1198        "ApplicationIdentifier",
1199    ] {
1200        if let Some(value) = properties.get(key)
1201            && !plist_value_is_empty(value)
1202        {
1203            return invalid_apple_bundle(
1204                path,
1205                format!("unsigned archive contains non-empty signing metadata `{key}`"),
1206            );
1207        }
1208    }
1209
1210    let app_filesystem = filesystem.open_subdirectory(Utf8Path::new(&expected_app))?;
1211    let app = inspect_unsigned_app_bundle_from_capability(&app_filesystem, expectation)?;
1212    app_filesystem.verify_ambient_binding()?;
1213    let inspection = UnsignedXcarchiveInspection {
1214        application_path,
1215        architectures,
1216        app,
1217        entries: tree.relative_paths(),
1218    };
1219    filesystem.verify_ambient_binding()?;
1220    Ok(inspection)
1221}
1222
1223/// Inspect an unsigned physical-iPhone `.app` directory and every runtime code object.
1224///
1225/// # Errors
1226///
1227/// Rejects unsafe filesystem objects, identity/resource mismatches, an unexpected bundle or
1228/// executable, signing material, non-arm64 slices, or non-device Apple platforms.
1229#[allow(clippy::too_many_lines)]
1230pub fn inspect_unsigned_app_bundle(
1231    path: &Utf8Path,
1232    expectation: &UnsignedXcarchiveExpectation,
1233) -> Result<UnsignedAppInspection, ArtifactError> {
1234    validate_unsigned_expectation(path, expectation)?;
1235    let filesystem = ArtifactFilesystem::open_ambient(path)?;
1236    let inspection = inspect_unsigned_app_bundle_from_capability(&filesystem, expectation)?;
1237    filesystem.verify_ambient_binding()?;
1238    Ok(inspection)
1239}
1240
1241#[allow(clippy::too_many_lines)]
1242fn inspect_unsigned_app_bundle_from_capability(
1243    filesystem: &ArtifactFilesystem,
1244    expectation: &UnsignedXcarchiveExpectation,
1245) -> Result<UnsignedAppInspection, ArtifactError> {
1246    let path = filesystem.display_path();
1247    if path.file_name() != Some(expectation.app_directory_name.as_str()) {
1248        return invalid_apple_bundle(
1249            path,
1250            format!(
1251                "application directory name is {:?}, expected `{}`",
1252                path.file_name(),
1253                expectation.app_directory_name
1254            ),
1255        );
1256    }
1257    let tree = scan_real_tree(filesystem)?;
1258    let info = read_filesystem_plist(filesystem, Utf8Path::new("Info.plist"))?;
1259    validate_plist_identity(
1260        path,
1261        &info,
1262        "application Info.plist",
1263        &expectation.bundle_identifier,
1264        &expectation.app_version,
1265        &expectation.build_number,
1266    )?;
1267    validate_plist_string(
1268        path,
1269        &info,
1270        "application Info.plist",
1271        "CFBundleExecutable",
1272        &expectation.executable,
1273    )?;
1274    validate_plist_string(
1275        path,
1276        &info,
1277        "application Info.plist",
1278        "CFBundlePackageType",
1279        "APPL",
1280    )?;
1281    validate_minimum_os(
1282        path,
1283        &info,
1284        "application Info.plist",
1285        &expectation.minimum_os,
1286    )?;
1287    if info
1288        .get("LSRequiresIPhoneOS")
1289        .and_then(plist::Value::as_boolean)
1290        != Some(true)
1291    {
1292        return invalid_apple_bundle(path, "application does not require iPhoneOS".to_owned());
1293    }
1294    validate_supported_platform(path, &info, "application Info.plist", expectation)?;
1295    let capabilities = plist_string_array(
1296        path,
1297        &info,
1298        "UIRequiredDeviceCapabilities",
1299        "application Info.plist",
1300    )?;
1301    if !capabilities.iter().any(|capability| capability == "arm64") {
1302        return invalid_apple_bundle(
1303            path,
1304            "application does not declare the arm64 device capability".to_owned(),
1305        );
1306    }
1307
1308    let expected_nested = expectation
1309        .nested_bundles
1310        .iter()
1311        .map(|bundle| (bundle.relative_path.clone(), bundle))
1312        .collect::<BTreeMap<_, _>>();
1313    let live_activity_enabled = expected_nested.contains_key(LIVE_ACTIVITY_BUNDLE_PATH);
1314    let actual_nested = tree
1315        .entries
1316        .iter()
1317        .filter(|entry| entry.kind == TreeEntryKind::Directory)
1318        .filter(|entry| {
1319            ["appex", "framework"]
1320                .iter()
1321                .any(|extension| has_ascii_extension(&entry.relative, extension))
1322        })
1323        .map(|entry| entry.relative.clone())
1324        .collect::<BTreeSet<_>>();
1325    let expected_nested_paths = expected_nested.keys().cloned().collect::<BTreeSet<_>>();
1326    if actual_nested != expected_nested_paths {
1327        return invalid_apple_bundle(
1328            path,
1329            format!(
1330                "nested bundle set mismatch: expected {expected_nested_paths:?}, found {actual_nested:?}"
1331            ),
1332        );
1333    }
1334    for entry in &tree.entries {
1335        if entry.kind == TreeEntryKind::Directory
1336            && (has_ascii_extension(&entry.relative, "app")
1337                || has_ascii_extension(&entry.relative, "xpc"))
1338        {
1339            return invalid_apple_bundle(
1340                path,
1341                format!("unexpected nested product `{}`", entry.relative),
1342            );
1343        }
1344    }
1345
1346    let mut extensions = Vec::new();
1347    let mut expected_code = BTreeMap::new();
1348    expected_code.insert(
1349        expectation.executable.clone(),
1350        ExpectedMachOKind::Executable,
1351    );
1352    for (relative, expected) in &expected_nested {
1353        let nested_info_path = Utf8PathBuf::from(format!("{relative}/Info.plist"));
1354        let nested_info = read_filesystem_plist(filesystem, &nested_info_path)?;
1355        validate_plist_string(
1356            path,
1357            &nested_info,
1358            relative,
1359            "CFBundleIdentifier",
1360            &expected.bundle_identifier,
1361        )?;
1362        validate_plist_string(
1363            path,
1364            &nested_info,
1365            relative,
1366            "CFBundleExecutable",
1367            &expected.executable,
1368        )?;
1369        validate_plist_string(
1370            path,
1371            &nested_info,
1372            relative,
1373            "CFBundlePackageType",
1374            expected.kind.package_type(),
1375        )?;
1376        let (nested_version, nested_build) = match expected.kind {
1377            UnsignedNestedBundleKind::AppExtension => (
1378                expectation.app_version.as_str(),
1379                expectation.build_number.as_str(),
1380            ),
1381            UnsignedNestedBundleKind::Framework => ("1.0", "1"),
1382        };
1383        validate_plist_string(
1384            path,
1385            &nested_info,
1386            relative,
1387            "CFBundleShortVersionString",
1388            nested_version,
1389        )?;
1390        validate_plist_string(
1391            path,
1392            &nested_info,
1393            relative,
1394            "CFBundleVersion",
1395            nested_build,
1396        )?;
1397        validate_minimum_os(path, &nested_info, relative, &expectation.minimum_os)?;
1398        validate_supported_platform(path, &nested_info, relative, expectation)?;
1399        if expected.kind == UnsignedNestedBundleKind::AppExtension {
1400            validate_extension_point(path, &nested_info, relative)?;
1401            extensions.push(expected.bundle_identifier.clone());
1402        }
1403        let executable_path = format!("{relative}/{}", expected.executable);
1404        let kind = match expected.kind {
1405            UnsignedNestedBundleKind::AppExtension => ExpectedMachOKind::Executable,
1406            UnsignedNestedBundleKind::Framework => ExpectedMachOKind::DynamicLibrary,
1407        };
1408        expected_code.insert(executable_path, kind);
1409    }
1410
1411    let mut resources = BTreeMap::new();
1412    for (relative, expected_sha256) in &expectation.required_resources {
1413        let bytes = read_checked_file(filesystem, Utf8Path::new(relative), MAX_IPA_ENTRY_SIZE)?;
1414        let actual_sha256 = sha256_bytes(&bytes);
1415        if !constant_time_ascii_eq(actual_sha256.as_bytes(), expected_sha256.as_bytes()) {
1416            return invalid_apple_bundle(
1417                path,
1418                format!("required resource `{relative}` has the wrong SHA-256"),
1419            );
1420        }
1421        if relative == "FerryResources.json" {
1422            validate_ferry_resource_metadata(path, &bytes, expectation)?;
1423        }
1424        resources.insert(relative.clone(), actual_sha256);
1425    }
1426
1427    let mut discovered_code = BTreeMap::new();
1428    for entry in tree
1429        .entries
1430        .iter()
1431        .filter(|entry| entry.kind == TreeEntryKind::File)
1432    {
1433        let file_path = Utf8Path::new(&entry.relative);
1434        let is_macho = file_has_macho_magic(filesystem, file_path)?;
1435        let expected_kind = expected_code.get(&entry.relative).copied();
1436        let swift_runtime = is_permitted_swift_runtime(&entry.relative);
1437        if !is_macho {
1438            if expected_kind.is_some() || has_ascii_extension(&entry.relative, "dylib") {
1439                return invalid_apple_bundle(
1440                    path,
1441                    format!("expected Mach-O code at `{}`", entry.relative),
1442                );
1443            }
1444            continue;
1445        }
1446        let Some(kind) =
1447            expected_kind.or(swift_runtime.then_some(ExpectedMachOKind::DynamicLibrary))
1448        else {
1449            return invalid_apple_bundle(
1450                path,
1451                format!("unexpected Mach-O code hidden at `{}`", entry.relative),
1452            );
1453        };
1454        #[cfg(unix)]
1455        validate_executable_mode(filesystem, &entry.relative, file_path)?;
1456        let bytes = read_checked_file(filesystem, file_path, MAX_IPA_ENTRY_SIZE)?;
1457        let evidence = inspect_expected_unsigned_macho(
1458            path,
1459            &entry.relative,
1460            &bytes,
1461            ExpectedUnsignedMachO {
1462                kind,
1463                linkage: ferry_macho_linkage(&entry.relative, live_activity_enabled),
1464                minimum_os: &expectation.minimum_os,
1465                sdk: &expectation.sdk_version,
1466                prebuilt_swift_runtime: swift_runtime,
1467            },
1468        )?;
1469        discovered_code.insert(entry.relative.clone(), evidence);
1470    }
1471    for expected_path in expected_code.keys() {
1472        if !discovered_code.contains_key(expected_path) {
1473            return invalid_apple_bundle(
1474                path,
1475                format!("expected executable `{expected_path}` is missing"),
1476            );
1477        }
1478    }
1479    let main_executable = discovered_code
1480        .remove(&expectation.executable)
1481        .ok_or_else(|| ArtifactError::InvalidAppleBundle {
1482            path: path.to_owned(),
1483            reason: format!("main executable `{}` is missing", expectation.executable),
1484        })?;
1485    extensions.sort();
1486    Ok(UnsignedAppInspection {
1487        app_directory_name: expectation.app_directory_name.clone(),
1488        bundle_identifier: expectation.bundle_identifier.clone(),
1489        executable: expectation.executable.clone(),
1490        main_executable,
1491        nested_executables: discovered_code,
1492        extensions,
1493        resources,
1494        entries: tree.relative_paths(),
1495    })
1496}
1497
1498#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1499enum TreeEntryKind {
1500    Directory,
1501    File,
1502}
1503
1504#[derive(Clone, Debug)]
1505struct TreeEntry {
1506    relative: String,
1507    kind: TreeEntryKind,
1508}
1509
1510#[derive(Debug)]
1511struct TreeIndex {
1512    entries: Vec<TreeEntry>,
1513}
1514
1515impl TreeIndex {
1516    fn relative_paths(&self) -> Vec<String> {
1517        self.entries
1518            .iter()
1519            .map(|entry| entry.relative.clone())
1520            .collect()
1521    }
1522}
1523
1524struct ArtifactFilesystem {
1525    display_path: Utf8PathBuf,
1526    directory: Dir,
1527    binding: ArtifactBinding,
1528}
1529
1530struct ArtifactBinding {
1531    root_display_path: Utf8PathBuf,
1532    root_directory: Dir,
1533    root_identity: same_file::Handle,
1534    relative: Utf8PathBuf,
1535    expected_identity: same_file::Handle,
1536}
1537
1538impl ArtifactFilesystem {
1539    fn open_ambient(display_path: &Utf8Path) -> Result<Self, ArtifactError> {
1540        validate_ambient_directory(display_path)?;
1541        let directory =
1542            Dir::open_ambient_dir(display_path, ambient_authority()).map_err(|error| {
1543                ArtifactError::Io {
1544                    action: "open artifact directory",
1545                    path: display_path.to_owned(),
1546                    message: error.to_string(),
1547                }
1548            })?;
1549        let root_identity = directory_handle(&directory).map_err(|error| ArtifactError::Io {
1550            action: "identify open artifact directory",
1551            path: display_path.to_owned(),
1552            message: error.to_string(),
1553        })?;
1554        let expected_identity =
1555            directory_handle(&directory).map_err(|error| ArtifactError::Io {
1556                action: "identify open artifact directory",
1557                path: display_path.to_owned(),
1558                message: error.to_string(),
1559            })?;
1560        let root_directory = directory.try_clone().map_err(|error| ArtifactError::Io {
1561            action: "clone open artifact directory",
1562            path: display_path.to_owned(),
1563            message: error.to_string(),
1564        })?;
1565        let filesystem = Self {
1566            display_path: display_path.to_owned(),
1567            directory,
1568            binding: ArtifactBinding {
1569                root_display_path: display_path.to_owned(),
1570                root_directory,
1571                root_identity,
1572                relative: Utf8PathBuf::from("."),
1573                expected_identity,
1574            },
1575        };
1576        filesystem.verify_ambient_binding()?;
1577        Ok(filesystem)
1578    }
1579
1580    fn open_subdirectory(&self, relative: &Utf8Path) -> Result<Self, ArtifactError> {
1581        validate_portable_relative_path(
1582            self.display_path(),
1583            "artifact subdirectory",
1584            relative.as_str(),
1585        )?;
1586        let mut directory = self
1587            .directory
1588            .try_clone()
1589            .map_err(|error| ArtifactError::Io {
1590                action: "clone artifact directory handle",
1591                path: self.display_path.clone(),
1592                message: error.to_string(),
1593            })?;
1594        let mut display_path = self.display_path.clone();
1595        for component in relative.components() {
1596            let camino::Utf8Component::Normal(component) = component else {
1597                return invalid_apple_bundle(
1598                    self.display_path(),
1599                    format!("unsafe artifact subdirectory `{relative}`"),
1600                );
1601            };
1602            display_path.push(component);
1603            directory =
1604                open_child_directory(self.display_path(), &display_path, &directory, component)?;
1605        }
1606        let expected_identity =
1607            directory_handle(&directory).map_err(|error| ArtifactError::Io {
1608                action: "identify open artifact subdirectory",
1609                path: display_path.clone(),
1610                message: error.to_string(),
1611            })?;
1612        let binding_relative = if self.binding.relative.as_str() == "." {
1613            relative.to_owned()
1614        } else {
1615            self.binding.relative.join(relative)
1616        };
1617        let root_directory =
1618            self.binding
1619                .root_directory
1620                .try_clone()
1621                .map_err(|error| ArtifactError::Io {
1622                    action: "clone artifact binding root",
1623                    path: self.binding.root_display_path.clone(),
1624                    message: error.to_string(),
1625                })?;
1626        let root_identity =
1627            directory_handle(&root_directory).map_err(|error| ArtifactError::Io {
1628                action: "identify artifact binding root",
1629                path: self.binding.root_display_path.clone(),
1630                message: error.to_string(),
1631            })?;
1632        Ok(Self {
1633            display_path,
1634            directory,
1635            binding: ArtifactBinding {
1636                root_display_path: self.binding.root_display_path.clone(),
1637                root_directory,
1638                root_identity,
1639                relative: binding_relative,
1640                expected_identity,
1641            },
1642        })
1643    }
1644
1645    fn display_path(&self) -> &Utf8Path {
1646        &self.display_path
1647    }
1648
1649    fn display_entry(&self, relative: &Utf8Path) -> Utf8PathBuf {
1650        self.display_path.join(relative)
1651    }
1652
1653    fn verify_ambient_binding(&self) -> Result<(), ArtifactError> {
1654        validate_ambient_directory(&self.binding.root_display_path)?;
1655        let current_root =
1656            same_file::Handle::from_path(&self.binding.root_display_path).map_err(|error| {
1657                ArtifactError::Io {
1658                    action: "reidentify artifact root path",
1659                    path: self.binding.root_display_path.clone(),
1660                    message: error.to_string(),
1661                }
1662            })?;
1663        let open_root =
1664            directory_handle(&self.binding.root_directory).map_err(|error| ArtifactError::Io {
1665                action: "reidentify open artifact root",
1666                path: self.binding.root_display_path.clone(),
1667                message: error.to_string(),
1668            })?;
1669        if current_root != self.binding.root_identity || open_root != self.binding.root_identity {
1670            return invalid_apple_bundle(
1671                &self.binding.root_display_path,
1672                "artifact root path was replaced during validation".to_owned(),
1673            );
1674        }
1675        let open_expected =
1676            directory_handle(&self.directory).map_err(|error| ArtifactError::Io {
1677                action: "reidentify open artifact directory",
1678                path: self.display_path.clone(),
1679                message: error.to_string(),
1680            })?;
1681        if open_expected != self.binding.expected_identity {
1682            return invalid_apple_bundle(
1683                &self.binding.root_display_path,
1684                "open artifact directory identity changed during validation".to_owned(),
1685            );
1686        }
1687        if self.binding.relative.as_str() != "." {
1688            let mut current =
1689                self.binding
1690                    .root_directory
1691                    .try_clone()
1692                    .map_err(|error| ArtifactError::Io {
1693                        action: "clone artifact root for path rebinding",
1694                        path: self.binding.root_display_path.clone(),
1695                        message: error.to_string(),
1696                    })?;
1697            let mut display_path = self.binding.root_display_path.clone();
1698            for component in self.binding.relative.components() {
1699                let camino::Utf8Component::Normal(component) = component else {
1700                    return invalid_apple_bundle(
1701                        &self.binding.root_display_path,
1702                        "artifact binding contains an unsafe component".to_owned(),
1703                    );
1704                };
1705                display_path.push(component);
1706                current = open_child_directory(
1707                    &self.binding.root_display_path,
1708                    &display_path,
1709                    &current,
1710                    component,
1711                )?;
1712            }
1713            let current_identity =
1714                directory_handle(&current).map_err(|error| ArtifactError::Io {
1715                    action: "reidentify rebound artifact directory",
1716                    path: display_path,
1717                    message: error.to_string(),
1718                })?;
1719            if current_identity != self.binding.expected_identity {
1720                return invalid_apple_bundle(
1721                    &self.binding.root_display_path,
1722                    format!(
1723                        "artifact subdirectory `{}` was replaced during validation",
1724                        self.binding.relative
1725                    ),
1726                );
1727            }
1728        }
1729        Ok(())
1730    }
1731}
1732
1733fn validate_ambient_directory(path: &Utf8Path) -> Result<(), ArtifactError> {
1734    let metadata = fs::symlink_metadata(path).map_err(|error| ArtifactError::Io {
1735        action: "inspect artifact directory path",
1736        path: path.to_owned(),
1737        message: error.to_string(),
1738    })?;
1739    if metadata.file_type().is_symlink() || !metadata.is_dir() {
1740        return invalid_apple_bundle(path, "artifact root is not a real directory".to_owned());
1741    }
1742    Ok(())
1743}
1744
1745fn directory_handle(directory: &Dir) -> std::io::Result<same_file::Handle> {
1746    same_file::Handle::from_file(directory.try_clone()?.into_std_file())
1747}
1748
1749fn open_child_directory(
1750    root: &Utf8Path,
1751    display_path: &Utf8Path,
1752    parent: &Dir,
1753    component: &str,
1754) -> Result<Dir, ArtifactError> {
1755    let before = parent
1756        .symlink_metadata(component)
1757        .map_err(|error| ArtifactError::Io {
1758            action: "inspect artifact subdirectory",
1759            path: display_path.to_owned(),
1760            message: error.to_string(),
1761        })?;
1762    if before.file_type().is_symlink() || !before.is_dir() {
1763        return invalid_apple_bundle(
1764            root,
1765            format!("artifact entry `{display_path}` is a link or not a directory"),
1766        );
1767    }
1768    let opened = parent
1769        .open_dir(component)
1770        .map_err(|error| ArtifactError::Io {
1771            action: "open artifact subdirectory",
1772            path: display_path.to_owned(),
1773            message: error.to_string(),
1774        })?;
1775    let opened_handle = directory_handle(&opened).map_err(|error| ArtifactError::Io {
1776        action: "identify open artifact subdirectory",
1777        path: display_path.to_owned(),
1778        message: error.to_string(),
1779    })?;
1780    let after = parent
1781        .symlink_metadata(component)
1782        .map_err(|error| ArtifactError::Io {
1783            action: "reinspect artifact subdirectory",
1784            path: display_path.to_owned(),
1785            message: error.to_string(),
1786        })?;
1787    if after.file_type().is_symlink() || !after.is_dir() {
1788        return invalid_apple_bundle(
1789            root,
1790            format!("artifact entry `{display_path}` changed type"),
1791        );
1792    }
1793    let current = parent
1794        .open_dir(component)
1795        .map_err(|error| ArtifactError::Io {
1796            action: "reopen artifact subdirectory",
1797            path: display_path.to_owned(),
1798            message: error.to_string(),
1799        })?;
1800    let current_handle = directory_handle(&current).map_err(|error| ArtifactError::Io {
1801        action: "reidentify artifact subdirectory",
1802        path: display_path.to_owned(),
1803        message: error.to_string(),
1804    })?;
1805    if opened_handle != current_handle {
1806        return invalid_apple_bundle(
1807            root,
1808            format!("artifact subdirectory `{display_path}` changed while being opened"),
1809        );
1810    }
1811    Ok(opened)
1812}
1813
1814#[derive(Clone, Copy, Debug)]
1815enum ExpectedMachOKind {
1816    Executable,
1817    DynamicLibrary,
1818}
1819
1820#[derive(Clone, Copy, Debug)]
1821enum FerryMachOLinkage {
1822    None,
1823    RuntimeBridge { activity_model_required: bool },
1824    ActivityModel,
1825    LiveActivityExtension,
1826}
1827
1828#[derive(Clone, Copy, Debug)]
1829struct ExpectedUnsignedMachO<'a> {
1830    kind: ExpectedMachOKind,
1831    linkage: FerryMachOLinkage,
1832    minimum_os: &'a str,
1833    sdk: &'a str,
1834    prebuilt_swift_runtime: bool,
1835}
1836
1837fn ferry_macho_linkage(relative: &str, live_activity_enabled: bool) -> FerryMachOLinkage {
1838    match Utf8Path::new(relative).parent().map(Utf8Path::as_str) {
1839        Some(RUNTIME_BRIDGE_BUNDLE_PATH) => FerryMachOLinkage::RuntimeBridge {
1840            activity_model_required: live_activity_enabled,
1841        },
1842        Some(ACTIVITY_MODEL_BUNDLE_PATH) => FerryMachOLinkage::ActivityModel,
1843        Some(LIVE_ACTIVITY_BUNDLE_PATH) => FerryMachOLinkage::LiveActivityExtension,
1844        _ => FerryMachOLinkage::None,
1845    }
1846}
1847
1848fn validate_unsigned_expectation(
1849    root: &Utf8Path,
1850    expectation: &UnsignedXcarchiveExpectation,
1851) -> Result<(), ArtifactError> {
1852    validate_expectation_identity(root, expectation)?;
1853    validate_nested_expectations(root, expectation)?;
1854    validate_resource_expectations(root, expectation)
1855}
1856
1857fn validate_expectation_identity(
1858    root: &Utf8Path,
1859    expectation: &UnsignedXcarchiveExpectation,
1860) -> Result<(), ArtifactError> {
1861    validate_portable_component(
1862        root,
1863        "application directory",
1864        &expectation.app_directory_name,
1865    )?;
1866    if !has_ascii_extension(&expectation.app_directory_name, "app") {
1867        return invalid_apple_bundle(
1868            root,
1869            "expected application directory must end with `.app`".to_owned(),
1870        );
1871    }
1872    validate_portable_component(root, "main executable", &expectation.executable)?;
1873    for (label, value) in [
1874        ("bundle identifier", expectation.bundle_identifier.as_str()),
1875        ("app version", expectation.app_version.as_str()),
1876        ("build number", expectation.build_number.as_str()),
1877        ("SDK build version", expectation.sdk_build_version.as_str()),
1878    ] {
1879        if value.trim().is_empty() || value.chars().any(char::is_control) {
1880            return invalid_apple_bundle(root, format!("expected {label} is empty or unsafe"));
1881        }
1882    }
1883    parse_apple_version(&expectation.minimum_os).ok_or_else(|| {
1884        ArtifactError::InvalidAppleBundle {
1885            path: root.to_owned(),
1886            reason: format!(
1887                "expected minimum iOS version `{}` is not numeric",
1888                expectation.minimum_os
1889            ),
1890        }
1891    })?;
1892    parse_apple_version(&expectation.sdk_version).ok_or_else(|| {
1893        ArtifactError::InvalidAppleBundle {
1894            path: root.to_owned(),
1895            reason: format!(
1896                "expected iPhoneOS SDK version `{}` is not numeric",
1897                expectation.sdk_version
1898            ),
1899        }
1900    })?;
1901    Ok(())
1902}
1903
1904fn validate_nested_expectations(
1905    root: &Utf8Path,
1906    expectation: &UnsignedXcarchiveExpectation,
1907) -> Result<(), ArtifactError> {
1908    let mut canonical_paths = BTreeMap::new();
1909    let mut bundle_identifiers = BTreeSet::from([expectation.bundle_identifier.clone()]);
1910    for nested in &expectation.nested_bundles {
1911        validate_portable_relative_path(root, "nested bundle", &nested.relative_path)?;
1912        validate_portable_component(root, "nested executable", &nested.executable)?;
1913        if !has_ascii_extension(&nested.relative_path, nested.kind.path_extension()) {
1914            return invalid_apple_bundle(
1915                root,
1916                format!(
1917                    "nested {:?} path `{}` has the wrong extension",
1918                    nested.kind, nested.relative_path
1919                ),
1920            );
1921        }
1922        let relative = Utf8Path::new(&nested.relative_path);
1923        let expected_parent = match nested.kind {
1924            UnsignedNestedBundleKind::AppExtension => Utf8Path::new("PlugIns"),
1925            UnsignedNestedBundleKind::Framework => Utf8Path::new("Frameworks"),
1926        };
1927        if relative.parent() != Some(expected_parent) {
1928            return invalid_apple_bundle(
1929                root,
1930                format!(
1931                    "nested {:?} `{}` is not directly below `{expected_parent}`",
1932                    nested.kind, nested.relative_path
1933                ),
1934            );
1935        }
1936        let canonical = canonical_archive_key(&nested.relative_path);
1937        if let Some(previous) = canonical_paths.insert(canonical, nested.relative_path.clone()) {
1938            return invalid_apple_bundle(
1939                root,
1940                format!(
1941                    "nested bundle paths `{previous}` and `{}` collide",
1942                    nested.relative_path
1943                ),
1944            );
1945        }
1946        if nested.bundle_identifier.trim().is_empty()
1947            || !bundle_identifiers.insert(nested.bundle_identifier.clone())
1948        {
1949            return invalid_apple_bundle(
1950                root,
1951                format!(
1952                    "nested bundle identifier `{}` is empty or duplicated",
1953                    nested.bundle_identifier
1954                ),
1955            );
1956        }
1957    }
1958    if !expectation
1959        .nested_bundles
1960        .iter()
1961        .any(|nested| nested.relative_path == RUNTIME_BRIDGE_BUNDLE_PATH)
1962    {
1963        return invalid_apple_bundle(
1964            root,
1965            format!("generated archive expectation omitted `{RUNTIME_BRIDGE_BUNDLE_PATH}`"),
1966        );
1967    }
1968    let has_activity_model = expectation
1969        .nested_bundles
1970        .iter()
1971        .any(|nested| nested.relative_path == ACTIVITY_MODEL_BUNDLE_PATH);
1972    let has_live_activity = expectation
1973        .nested_bundles
1974        .iter()
1975        .any(|nested| nested.relative_path == LIVE_ACTIVITY_BUNDLE_PATH);
1976    if has_activity_model != has_live_activity {
1977        return invalid_apple_bundle(
1978            root,
1979            "FerryActivityModel and FerryLiveActivityExtension expectations must be present together"
1980                .to_owned(),
1981        );
1982    }
1983    Ok(())
1984}
1985
1986fn validate_resource_expectations(
1987    root: &Utf8Path,
1988    expectation: &UnsignedXcarchiveExpectation,
1989) -> Result<(), ArtifactError> {
1990    for required in ["FerryResources.json", "FerryIcon.png", "FerrySplash.png"] {
1991        if !expectation.required_resources.contains_key(required) {
1992            return invalid_apple_bundle(
1993                root,
1994                format!("required generated resource expectation `{required}` is missing"),
1995            );
1996        }
1997    }
1998    for (relative, digest) in &expectation.required_resources {
1999        validate_portable_relative_path(root, "required resource", relative)?;
2000        if digest.len() != 64
2001            || !digest
2002                .bytes()
2003                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2004        {
2005            return invalid_apple_bundle(
2006                root,
2007                format!("resource `{relative}` has a non-canonical SHA-256 expectation"),
2008            );
2009        }
2010    }
2011    Ok(())
2012}
2013
2014fn validate_portable_component(
2015    root: &Utf8Path,
2016    label: &str,
2017    component: &str,
2018) -> Result<(), ArtifactError> {
2019    if component.is_empty()
2020        || component == "."
2021        || component == ".."
2022        || component.len() > MAX_IPA_ENTRY_NAME
2023        || component.contains(['/', '\\', '\0', ':'])
2024        || component.chars().any(char::is_control)
2025    {
2026        return invalid_apple_bundle(root, format!("{label} `{component}` is not portable"));
2027    }
2028    Ok(())
2029}
2030
2031fn validate_portable_relative_path(
2032    root: &Utf8Path,
2033    label: &str,
2034    relative: &str,
2035) -> Result<(), ArtifactError> {
2036    if relative.is_empty()
2037        || relative.len() > MAX_IPA_ENTRY_NAME
2038        || relative.starts_with(['/', '\\'])
2039        || relative.contains(['\\', '\0'])
2040        || relative.chars().any(char::is_control)
2041    {
2042        return invalid_apple_bundle(root, format!("{label} path `{relative}` is unsafe"));
2043    }
2044    for component in relative.split('/') {
2045        validate_portable_component(root, label, component)?;
2046    }
2047    Ok(())
2048}
2049
2050#[allow(clippy::too_many_lines)]
2051fn scan_real_tree(filesystem: &ArtifactFilesystem) -> Result<TreeIndex, ArtifactError> {
2052    let root = filesystem.display_path();
2053    let mut entries = Vec::new();
2054    let root_directory = filesystem
2055        .directory
2056        .try_clone()
2057        .map_err(|error| ArtifactError::Io {
2058            action: "clone artifact root handle",
2059            path: root.to_owned(),
2060            message: error.to_string(),
2061        })?;
2062    let mut stack = vec![(root_directory, String::new(), 0_usize)];
2063    let mut exact = BTreeSet::new();
2064    let mut canonical = BTreeMap::<String, String>::new();
2065    let mut total_size = 0_u64;
2066    while let Some((directory, prefix, depth)) = stack.pop() {
2067        let display_directory = if prefix.is_empty() {
2068            root.to_owned()
2069        } else {
2070            root.join(&prefix)
2071        };
2072        let iterator = directory.entries().map_err(|error| ArtifactError::Io {
2073            action: "read directory",
2074            path: display_directory.clone(),
2075            message: error.to_string(),
2076        })?;
2077        let mut children = Vec::new();
2078        for child in iterator {
2079            let child = child.map_err(|error| ArtifactError::Io {
2080                action: "read entry from",
2081                path: display_directory.clone(),
2082                message: error.to_string(),
2083            })?;
2084            let name = child.file_name().into_string().map_err(|name| {
2085                ArtifactError::InvalidAppleBundle {
2086                    path: root.to_owned(),
2087                    reason: format!(
2088                        "artifact contains a non-UTF-8 name: {}",
2089                        name.to_string_lossy()
2090                    ),
2091                }
2092            })?;
2093            children.push((name, child));
2094        }
2095        children.sort_by(|left, right| left.0.cmp(&right.0));
2096        for (name, child) in children {
2097            validate_portable_component(root, "artifact entry", &name)?;
2098            let relative = if prefix.is_empty() {
2099                name.clone()
2100            } else {
2101                format!("{prefix}/{name}")
2102            };
2103            validate_portable_relative_path(root, "artifact entry", &relative)?;
2104            if !exact.insert(relative.clone()) {
2105                return invalid_apple_bundle(root, format!("duplicate entry `{relative}`"));
2106            }
2107            let collision_key = canonical_archive_key(&relative);
2108            if let Some(previous) = canonical.insert(collision_key, relative.clone())
2109                && previous != relative
2110            {
2111                return invalid_apple_bundle(
2112                    root,
2113                    format!("case/Unicode-colliding entries `{previous}` and `{relative}`"),
2114                );
2115            }
2116            if entries.len() >= MAX_IPA_ENTRIES {
2117                return invalid_apple_bundle(
2118                    root,
2119                    format!("artifact has more than {MAX_IPA_ENTRIES} entries"),
2120                );
2121            }
2122            let display_path = root.join(&relative);
2123            let file_type = child.file_type().map_err(|error| ArtifactError::Io {
2124                action: "inspect artifact entry type",
2125                path: display_path.clone(),
2126                message: error.to_string(),
2127            })?;
2128            reject_forbidden_bundle_entry(root, &relative)?;
2129            let kind = if file_type.is_dir() {
2130                if depth + 1 > MAX_BUNDLE_DEPTH {
2131                    return invalid_apple_bundle(
2132                        root,
2133                        format!("entry `{relative}` exceeds maximum nesting depth"),
2134                    );
2135                }
2136                let child_directory = open_child_directory(root, &display_path, &directory, &name)?;
2137                #[cfg(unix)]
2138                let metadata = child_directory
2139                    .try_clone()
2140                    .and_then(|directory| directory.into_std_file().metadata())
2141                    .map_err(|error| ArtifactError::Io {
2142                        action: "inspect open artifact directory",
2143                        path: display_path.clone(),
2144                        message: error.to_string(),
2145                    })?;
2146                #[cfg(unix)]
2147                reject_dangerous_mode(root, &relative, &metadata)?;
2148                stack.push((child_directory, relative.clone(), depth + 1));
2149                TreeEntryKind::Directory
2150            } else if file_type.is_file() {
2151                let file = open_scanned_file(root, &display_path, &directory, &child)?;
2152                let metadata = file.metadata().map_err(|error| ArtifactError::Io {
2153                    action: "inspect open artifact file",
2154                    path: display_path.clone(),
2155                    message: error.to_string(),
2156                })?;
2157                #[cfg(unix)]
2158                reject_dangerous_mode(root, &relative, &metadata)?;
2159                reject_hardlinked_file(root, &relative, &file, &metadata)?;
2160                if metadata.len() > MAX_IPA_ENTRY_SIZE {
2161                    return invalid_apple_bundle(
2162                        root,
2163                        format!("entry `{relative}` exceeds the per-file limit"),
2164                    );
2165                }
2166                total_size = total_size.checked_add(metadata.len()).ok_or_else(|| {
2167                    ArtifactError::InvalidAppleBundle {
2168                        path: root.to_owned(),
2169                        reason: "artifact logical size overflow".to_owned(),
2170                    }
2171                })?;
2172                if total_size > MAX_IPA_TOTAL_SIZE {
2173                    return invalid_apple_bundle(
2174                        root,
2175                        "artifact exceeds the total logical-size limit".to_owned(),
2176                    );
2177                }
2178                TreeEntryKind::File
2179            } else {
2180                return invalid_apple_bundle(
2181                    root,
2182                    format!("entry `{relative}` is a link or special file"),
2183                );
2184            };
2185            entries.push(TreeEntry { relative, kind });
2186        }
2187    }
2188    entries.sort_by(|left, right| left.relative.cmp(&right.relative));
2189    Ok(TreeIndex { entries })
2190}
2191
2192fn open_scanned_file(
2193    root: &Utf8Path,
2194    display_path: &Utf8Path,
2195    parent: &Dir,
2196    entry: &cap_std::fs::DirEntry,
2197) -> Result<File, ArtifactError> {
2198    let file = entry
2199        .open()
2200        .map(cap_std::fs::File::into_std)
2201        .map_err(|error| ArtifactError::Io {
2202            action: "open artifact file",
2203            path: display_path.to_owned(),
2204            message: error.to_string(),
2205        })?;
2206    let metadata = file.metadata().map_err(|error| ArtifactError::Io {
2207        action: "inspect open artifact file",
2208        path: display_path.to_owned(),
2209        message: error.to_string(),
2210    })?;
2211    if !metadata.is_file() {
2212        return invalid_apple_bundle(
2213            root,
2214            format!("artifact entry `{display_path}` changed type"),
2215        );
2216    }
2217    let name = entry.file_name();
2218    let after = parent
2219        .symlink_metadata(&name)
2220        .map_err(|error| ArtifactError::Io {
2221            action: "reinspect artifact file",
2222            path: display_path.to_owned(),
2223            message: error.to_string(),
2224        })?;
2225    if after.file_type().is_symlink() || !after.is_file() {
2226        return invalid_apple_bundle(
2227            root,
2228            format!("artifact entry `{display_path}` changed type"),
2229        );
2230    }
2231    let current = parent
2232        .open(&name)
2233        .map(cap_std::fs::File::into_std)
2234        .map_err(|error| ArtifactError::Io {
2235            action: "reopen artifact file",
2236            path: display_path.to_owned(),
2237            message: error.to_string(),
2238        })?;
2239    if !open_files_match(&file, &current).map_err(|error| ArtifactError::Io {
2240        action: "reidentify artifact file",
2241        path: display_path.to_owned(),
2242        message: error.to_string(),
2243    })? {
2244        return invalid_apple_bundle(
2245            root,
2246            format!("artifact file `{display_path}` changed while being opened"),
2247        );
2248    }
2249    Ok(file)
2250}
2251
2252fn direct_children(tree: &TreeIndex, parent: &str) -> BTreeSet<String> {
2253    tree.entries
2254        .iter()
2255        .filter(|entry| Utf8Path::new(&entry.relative).parent() == Some(Utf8Path::new(parent)))
2256        .map(|entry| entry.relative.clone())
2257        .collect()
2258}
2259
2260fn require_tree_directory(
2261    root: &Utf8Path,
2262    tree: &TreeIndex,
2263    relative: &str,
2264) -> Result<(), ArtifactError> {
2265    if tree
2266        .entries
2267        .iter()
2268        .any(|entry| entry.relative == relative && entry.kind == TreeEntryKind::Directory)
2269    {
2270        Ok(())
2271    } else {
2272        invalid_apple_bundle(root, format!("required directory `{relative}` is missing"))
2273    }
2274}
2275
2276fn reject_forbidden_bundle_entry(root: &Utf8Path, relative: &str) -> Result<(), ArtifactError> {
2277    let lower = relative.to_lowercase();
2278    let components = lower.split('/').collect::<Vec<_>>();
2279    let last = components.last().copied().unwrap_or_default();
2280    let forbidden_suffixes = [
2281        ".p12",
2282        ".p8",
2283        ".key",
2284        ".pem",
2285        ".mobileprovision",
2286        ".entitlements",
2287        ".xcent",
2288        ".swift",
2289        ".m",
2290        ".mm",
2291        ".a",
2292    ];
2293    if components.iter().any(|component| {
2294        matches!(
2295            *component,
2296            "_codesignature" | "sc_info" | "keychains" | "credentials" | "secrets"
2297        )
2298    }) || last == "embedded.mobileprovision"
2299        || last == "project.pbxproj"
2300        || forbidden_suffixes
2301            .iter()
2302            .any(|suffix| last.ends_with(suffix))
2303    {
2304        return invalid_apple_bundle(
2305            root,
2306            format!("forbidden signing, secret, source, or generated entry `{relative}`"),
2307        );
2308    }
2309    Ok(())
2310}
2311
2312#[cfg(unix)]
2313fn reject_dangerous_mode(
2314    root: &Utf8Path,
2315    relative: &str,
2316    metadata: &Metadata,
2317) -> Result<(), ArtifactError> {
2318    use std::os::unix::fs::MetadataExt as _;
2319
2320    if metadata.mode() & 0o6000 != 0 {
2321        return invalid_apple_bundle(
2322            root,
2323            format!("entry `{relative}` has setuid or setgid mode bits"),
2324        );
2325    }
2326    Ok(())
2327}
2328
2329#[cfg(unix)]
2330fn validate_executable_mode(
2331    filesystem: &ArtifactFilesystem,
2332    relative: &str,
2333    path: &Utf8Path,
2334) -> Result<(), ArtifactError> {
2335    use std::os::unix::fs::MetadataExt as _;
2336
2337    let root = filesystem.display_path();
2338    let display_path = filesystem.display_entry(path);
2339    let (file, _) = open_checked_file(filesystem, path, MAX_IPA_ENTRY_SIZE)?;
2340    let metadata = file.metadata().map_err(|error| ArtifactError::Io {
2341        action: "inspect executable mode for",
2342        path: display_path,
2343        message: error.to_string(),
2344    })?;
2345    if metadata.mode() & 0o111 == 0 {
2346        return invalid_apple_bundle(
2347            root,
2348            format!("Mach-O `{relative}` has no executable mode bit"),
2349        );
2350    }
2351    Ok(())
2352}
2353
2354#[cfg(unix)]
2355fn reject_hardlinked_file(
2356    root: &Utf8Path,
2357    relative: &str,
2358    _file: &File,
2359    metadata: &Metadata,
2360) -> Result<(), ArtifactError> {
2361    use std::os::unix::fs::MetadataExt as _;
2362
2363    if metadata.nlink() != 1 {
2364        return invalid_apple_bundle(
2365            root,
2366            format!("entry `{relative}` is a hard-linked regular file"),
2367        );
2368    }
2369    Ok(())
2370}
2371
2372#[cfg(windows)]
2373fn reject_hardlinked_file(
2374    root: &Utf8Path,
2375    relative: &str,
2376    file: &File,
2377    _metadata: &Metadata,
2378) -> Result<(), ArtifactError> {
2379    let links = regular_file_link_count(file.as_handle()).map_err(|error| {
2380        ArtifactError::InvalidAppleBundle {
2381            path: root.to_owned(),
2382            reason: format!("cannot inspect hard-link count for entry `{relative}`: {error}"),
2383        }
2384    })?;
2385    if links != 1 {
2386        return invalid_apple_bundle(
2387            root,
2388            format!("entry `{relative}` is a hard-linked regular file"),
2389        );
2390    }
2391    Ok(())
2392}
2393
2394#[cfg(not(any(unix, windows)))]
2395fn reject_hardlinked_file(
2396    root: &Utf8Path,
2397    relative: &str,
2398    _file: &File,
2399    _metadata: &Metadata,
2400) -> Result<(), ArtifactError> {
2401    invalid_apple_bundle(
2402        root,
2403        format!("cannot inspect hard-link count for entry `{relative}`"),
2404    )
2405}
2406
2407fn read_checked_file(
2408    filesystem: &ArtifactFilesystem,
2409    relative: &Utf8Path,
2410    limit: u64,
2411) -> Result<Vec<u8>, ArtifactError> {
2412    let root = filesystem.display_path();
2413    let display_path = filesystem.display_entry(relative);
2414    let (mut file, before) = open_checked_file(filesystem, relative, limit)?;
2415    let capacity =
2416        usize::try_from(before.len()).map_err(|_| ArtifactError::InvalidAppleBundle {
2417            path: root.to_owned(),
2418            reason: format!("file `{display_path}` is too large for this worker"),
2419        })?;
2420    let mut bytes = Vec::with_capacity(capacity);
2421    file.by_ref()
2422        .take(limit + 1)
2423        .read_to_end(&mut bytes)
2424        .map_err(|error| ArtifactError::Io {
2425            action: "read",
2426            path: display_path.clone(),
2427            message: error.to_string(),
2428        })?;
2429    if bytes.len() as u64 != before.len() {
2430        return invalid_apple_bundle(
2431            root,
2432            format!("file `{display_path}` changed size while it was read"),
2433        );
2434    }
2435    verify_open_file_stable(filesystem, relative, &file, &before)?;
2436    Ok(bytes)
2437}
2438
2439fn file_has_macho_magic(
2440    filesystem: &ArtifactFilesystem,
2441    relative: &Utf8Path,
2442) -> Result<bool, ArtifactError> {
2443    let display_path = filesystem.display_entry(relative);
2444    let (mut file, before) = open_checked_file(filesystem, relative, MAX_IPA_ENTRY_SIZE)?;
2445    let mut magic = [0_u8; 4];
2446    let mut filled = 0;
2447    while filled < magic.len() {
2448        let count = file
2449            .read(&mut magic[filled..])
2450            .map_err(|error| ArtifactError::Io {
2451                action: "read Mach-O magic from",
2452                path: display_path.clone(),
2453                message: error.to_string(),
2454            })?;
2455        if count == 0 {
2456            break;
2457        }
2458        filled += count;
2459    }
2460    verify_open_file_stable(filesystem, relative, &file, &before)?;
2461    Ok(filled == 4
2462        && matches!(
2463            magic,
2464            [0xfe, 0xed, 0xfa, 0xce | 0xcf]
2465                | [0xce | 0xcf, 0xfa, 0xed, 0xfe]
2466                | [0xca, 0xfe, 0xba, 0xbe | 0xbf]
2467                | [0xbe | 0xbf, 0xba, 0xfe, 0xca]
2468        ))
2469}
2470
2471fn open_checked_file(
2472    filesystem: &ArtifactFilesystem,
2473    relative: &Utf8Path,
2474    limit: u64,
2475) -> Result<(File, Metadata), ArtifactError> {
2476    let root = filesystem.display_path();
2477    validate_portable_relative_path(root, "artifact file", relative.as_str())?;
2478    let display_path = filesystem.display_entry(relative);
2479    let before = filesystem
2480        .directory
2481        .symlink_metadata(relative)
2482        .map_err(|error| ArtifactError::Io {
2483            action: "inspect artifact file",
2484            path: display_path.clone(),
2485            message: error.to_string(),
2486        })?;
2487    if before.file_type().is_symlink() || !before.is_file() {
2488        return invalid_apple_bundle(root, format!("`{display_path}` is not a regular file"));
2489    }
2490    let file = filesystem
2491        .directory
2492        .open(relative)
2493        .map(cap_std::fs::File::into_std)
2494        .map_err(|error| ArtifactError::Io {
2495            action: "open artifact file through root capability",
2496            path: display_path.clone(),
2497            message: error.to_string(),
2498        })?;
2499    let opened = file.metadata().map_err(|error| ArtifactError::Io {
2500        action: "inspect open file",
2501        path: display_path.clone(),
2502        message: error.to_string(),
2503    })?;
2504    #[cfg(unix)]
2505    reject_dangerous_mode(root, relative.as_str(), &opened)?;
2506    reject_hardlinked_file(root, relative.as_str(), &file, &opened)?;
2507    if !opened.is_file() || opened.len() > limit {
2508        return invalid_apple_bundle(
2509            root,
2510            format!("file `{display_path}` exceeds the read limit"),
2511        );
2512    }
2513    verify_capability_file_path(filesystem, relative, &file)?;
2514    Ok((file, opened))
2515}
2516
2517fn verify_open_file_stable(
2518    filesystem: &ArtifactFilesystem,
2519    relative: &Utf8Path,
2520    file: &File,
2521    before: &Metadata,
2522) -> Result<(), ArtifactError> {
2523    let root = filesystem.display_path();
2524    let display_path = filesystem.display_entry(relative);
2525    let opened_after = file.metadata().map_err(|error| ArtifactError::Io {
2526        action: "reinspect open file",
2527        path: display_path.clone(),
2528        message: error.to_string(),
2529    })?;
2530    reject_hardlinked_file(root, relative.as_str(), file, &opened_after)?;
2531    if !same_file_identity(before, &opened_after)
2532        || before.len() != opened_after.len()
2533        || before.modified().ok() != opened_after.modified().ok()
2534    {
2535        return invalid_apple_bundle(
2536            root,
2537            format!("file `{display_path}` changed while being read"),
2538        );
2539    }
2540    verify_capability_file_path(filesystem, relative, file)
2541}
2542
2543fn verify_capability_file_path(
2544    filesystem: &ArtifactFilesystem,
2545    relative: &Utf8Path,
2546    opened: &File,
2547) -> Result<(), ArtifactError> {
2548    let root = filesystem.display_path();
2549    let display_path = filesystem.display_entry(relative);
2550    let current_metadata = filesystem
2551        .directory
2552        .symlink_metadata(relative)
2553        .map_err(|error| ArtifactError::Io {
2554            action: "reinspect artifact file through root capability",
2555            path: display_path.clone(),
2556            message: error.to_string(),
2557        })?;
2558    if current_metadata.file_type().is_symlink() || !current_metadata.is_file() {
2559        return invalid_apple_bundle(root, format!("artifact file `{display_path}` changed type"));
2560    }
2561    let current = filesystem
2562        .directory
2563        .open(relative)
2564        .map(cap_std::fs::File::into_std)
2565        .map_err(|error| ArtifactError::Io {
2566            action: "reopen artifact file through root capability",
2567            path: display_path.clone(),
2568            message: error.to_string(),
2569        })?;
2570    if !open_files_match(opened, &current).map_err(|error| ArtifactError::Io {
2571        action: "reidentify artifact file through root capability",
2572        path: display_path.clone(),
2573        message: error.to_string(),
2574    })? {
2575        return invalid_apple_bundle(
2576            root,
2577            format!("artifact file `{display_path}` changed while being accessed"),
2578        );
2579    }
2580    Ok(())
2581}
2582
2583#[cfg(unix)]
2584fn same_file_identity(left: &Metadata, right: &Metadata) -> bool {
2585    use std::os::unix::fs::MetadataExt as _;
2586    left.dev() == right.dev() && left.ino() == right.ino()
2587}
2588
2589#[cfg(windows)]
2590fn same_file_identity(left: &Metadata, right: &Metadata) -> bool {
2591    left.len() == right.len() && left.modified().ok() == right.modified().ok()
2592}
2593
2594#[cfg(not(any(unix, windows)))]
2595fn same_file_identity(_left: &Metadata, _right: &Metadata) -> bool {
2596    true
2597}
2598
2599fn open_files_match(left: &File, right: &File) -> std::io::Result<bool> {
2600    let left = same_file::Handle::from_file(left.try_clone()?)?;
2601    let right = same_file::Handle::from_file(right.try_clone()?)?;
2602    Ok(left == right)
2603}
2604
2605fn read_filesystem_plist(
2606    filesystem: &ArtifactFilesystem,
2607    relative: &Utf8Path,
2608) -> Result<plist::Dictionary, ArtifactError> {
2609    let root = filesystem.display_path();
2610    let display_path = filesystem.display_entry(relative);
2611    let bytes = read_checked_file(filesystem, relative, 16 * 1024 * 1024)?;
2612    let value = plist::Value::from_reader(Cursor::new(bytes)).map_err(|error| {
2613        ArtifactError::InvalidAppleBundle {
2614            path: root.to_owned(),
2615            reason: format!("plist `{display_path}` is invalid: {error}"),
2616        }
2617    })?;
2618    value
2619        .into_dictionary()
2620        .ok_or_else(|| ArtifactError::InvalidAppleBundle {
2621            path: root.to_owned(),
2622            reason: format!("plist `{display_path}` is not a dictionary"),
2623        })
2624}
2625
2626fn required_plist_string(
2627    root: &Utf8Path,
2628    dictionary: &plist::Dictionary,
2629    key: &str,
2630    context: &str,
2631) -> Result<String, ArtifactError> {
2632    dictionary
2633        .get(key)
2634        .and_then(plist::Value::as_string)
2635        .map(ToOwned::to_owned)
2636        .ok_or_else(|| ArtifactError::InvalidAppleBundle {
2637            path: root.to_owned(),
2638            reason: format!("{context} has no string `{key}`"),
2639        })
2640}
2641
2642fn validate_plist_string(
2643    root: &Utf8Path,
2644    dictionary: &plist::Dictionary,
2645    context: &str,
2646    key: &str,
2647    expected: &str,
2648) -> Result<(), ArtifactError> {
2649    let actual = required_plist_string(root, dictionary, key, context)?;
2650    if actual != expected {
2651        return invalid_apple_bundle(
2652            root,
2653            format!("{context} `{key}` is `{actual}`, expected `{expected}`"),
2654        );
2655    }
2656    Ok(())
2657}
2658
2659fn validate_plist_identity(
2660    root: &Utf8Path,
2661    dictionary: &plist::Dictionary,
2662    context: &str,
2663    bundle_identifier: &str,
2664    app_version: &str,
2665    build_number: &str,
2666) -> Result<(), ArtifactError> {
2667    for (key, expected) in [
2668        ("CFBundleIdentifier", bundle_identifier),
2669        ("CFBundleShortVersionString", app_version),
2670        ("CFBundleVersion", build_number),
2671    ] {
2672        validate_plist_string(root, dictionary, context, key, expected)?;
2673    }
2674    Ok(())
2675}
2676
2677fn plist_string_array(
2678    root: &Utf8Path,
2679    dictionary: &plist::Dictionary,
2680    key: &str,
2681    context: &str,
2682) -> Result<Vec<String>, ArtifactError> {
2683    let values = dictionary
2684        .get(key)
2685        .and_then(plist::Value::as_array)
2686        .ok_or_else(|| ArtifactError::InvalidAppleBundle {
2687            path: root.to_owned(),
2688            reason: format!("{context} has no array `{key}`"),
2689        })?;
2690    values
2691        .iter()
2692        .map(|value| {
2693            value.as_string().map(ToOwned::to_owned).ok_or_else(|| {
2694                ArtifactError::InvalidAppleBundle {
2695                    path: root.to_owned(),
2696                    reason: format!("{context} `{key}` contains a non-string value"),
2697                }
2698            })
2699        })
2700        .collect()
2701}
2702
2703fn validate_minimum_os(
2704    root: &Utf8Path,
2705    dictionary: &plist::Dictionary,
2706    context: &str,
2707    expected: &str,
2708) -> Result<(), ArtifactError> {
2709    let actual = required_plist_string(root, dictionary, "MinimumOSVersion", context)?;
2710    if parse_apple_version(&actual) != parse_apple_version(expected) {
2711        return invalid_apple_bundle(
2712            root,
2713            format!("{context} MinimumOSVersion is `{actual}`, expected `{expected}`"),
2714        );
2715    }
2716    Ok(())
2717}
2718
2719fn validate_supported_platform(
2720    root: &Utf8Path,
2721    dictionary: &plist::Dictionary,
2722    context: &str,
2723    expectation: &UnsignedXcarchiveExpectation,
2724) -> Result<(), ArtifactError> {
2725    let platforms = plist_string_array(root, dictionary, "CFBundleSupportedPlatforms", context)?;
2726    if platforms != ["iPhoneOS"] {
2727        return invalid_apple_bundle(
2728            root,
2729            format!("{context} supported platforms are {platforms:?}, expected iPhoneOS"),
2730        );
2731    }
2732    validate_plist_string(root, dictionary, context, "DTPlatformName", "iphoneos")?;
2733    let sdk_name = required_plist_string(root, dictionary, "DTSDKName", context)?;
2734    let Some(sdk_suffix) = sdk_name.strip_prefix("iphoneos") else {
2735        return invalid_apple_bundle(
2736            root,
2737            format!("{context} DTSDKName `{sdk_name}` is not an iPhoneOS SDK"),
2738        );
2739    };
2740    if parse_apple_version(sdk_suffix) != parse_apple_version(&expectation.sdk_version) {
2741        return invalid_apple_bundle(
2742            root,
2743            format!(
2744                "{context} DTSDKName is `{sdk_name}`, expected iphoneos{}",
2745                expectation.sdk_version
2746            ),
2747        );
2748    }
2749    validate_plist_string(
2750        root,
2751        dictionary,
2752        context,
2753        "DTSDKBuild",
2754        &expectation.sdk_build_version,
2755    )
2756}
2757
2758fn validate_extension_point(
2759    root: &Utf8Path,
2760    dictionary: &plist::Dictionary,
2761    context: &str,
2762) -> Result<(), ArtifactError> {
2763    let extension = dictionary
2764        .get("NSExtension")
2765        .and_then(plist::Value::as_dictionary)
2766        .ok_or_else(|| ArtifactError::InvalidAppleBundle {
2767            path: root.to_owned(),
2768            reason: format!("extension `{context}` has no NSExtension dictionary"),
2769        })?;
2770    validate_plist_string(
2771        root,
2772        extension,
2773        context,
2774        "NSExtensionPointIdentifier",
2775        "com.apple.widgetkit-extension",
2776    )
2777}
2778
2779fn plist_value_is_empty(value: &plist::Value) -> bool {
2780    match value {
2781        plist::Value::String(value) => value.trim().is_empty(),
2782        plist::Value::Array(value) => value.is_empty(),
2783        plist::Value::Dictionary(value) => value.is_empty(),
2784        _ => false,
2785    }
2786}
2787
2788fn validate_ferry_resource_metadata(
2789    root: &Utf8Path,
2790    bytes: &[u8],
2791    expectation: &UnsignedXcarchiveExpectation,
2792) -> Result<(), ArtifactError> {
2793    let value: serde_json::Value =
2794        serde_json::from_slice(bytes).map_err(|error| ArtifactError::InvalidAppleBundle {
2795            path: root.to_owned(),
2796            reason: format!("FerryResources.json is invalid JSON: {error}"),
2797        })?;
2798    let object = value
2799        .as_object()
2800        .ok_or_else(|| ArtifactError::InvalidAppleBundle {
2801            path: root.to_owned(),
2802            reason: "FerryResources.json is not an object".to_owned(),
2803        })?;
2804    let expected_strings = [
2805        ("generator", "cargo-ferry"),
2806        ("rust_target", "aarch64-apple-ios"),
2807        ("bundle_identifier", expectation.bundle_identifier.as_str()),
2808    ];
2809    if object
2810        .get("schema_version")
2811        .and_then(serde_json::Value::as_u64)
2812        != Some(1)
2813        || expected_strings.iter().any(|(key, expected)| {
2814            object.get(*key).and_then(serde_json::Value::as_str) != Some(*expected)
2815        })
2816        || object
2817            .get("ui_backend")
2818            .and_then(serde_json::Value::as_str)
2819            .is_none_or(str::is_empty)
2820    {
2821        return invalid_apple_bundle(
2822            root,
2823            "FerryResources.json does not describe the expected physical-iOS build".to_owned(),
2824        );
2825    }
2826    Ok(())
2827}
2828
2829fn is_permitted_swift_runtime(relative: &str) -> bool {
2830    let path = Utf8Path::new(relative);
2831    path.parent() == Some(Utf8Path::new("Frameworks"))
2832        && path
2833            .file_name()
2834            .is_some_and(|name| name.starts_with("libswift") && has_ascii_extension(name, "dylib"))
2835}
2836
2837fn inspect_expected_unsigned_macho(
2838    root: &Utf8Path,
2839    relative: &str,
2840    bytes: &[u8],
2841    expected: ExpectedUnsignedMachO<'_>,
2842) -> Result<Vec<MachOSliceEvidence>, ArtifactError> {
2843    let evidence = inspect_physical_iphone_macho(bytes).map_err(|error| {
2844        ArtifactError::InvalidAppleBundle {
2845            path: root.to_owned(),
2846            reason: format!("Mach-O `{relative}` is not physical-iPhone code: {error}"),
2847        }
2848    })?;
2849    if evidence.len() != 1 || evidence[0].architecture != "arm64" {
2850        return invalid_apple_bundle(
2851            root,
2852            format!(
2853                "Mach-O `{relative}` slices are {:?}, expected exactly one arm64 slice",
2854                evidence
2855                    .iter()
2856                    .map(|slice| slice.architecture.as_str())
2857                    .collect::<Vec<_>>()
2858            ),
2859        );
2860    }
2861    validate_unsigned_macho_header(root, relative, bytes, expected.kind, expected.linkage)?;
2862    validate_recorded_macho_versions(
2863        root,
2864        relative,
2865        &evidence[0],
2866        expected.minimum_os,
2867        expected.sdk,
2868        expected.prebuilt_swift_runtime,
2869    )?;
2870    Ok(evidence)
2871}
2872
2873fn validate_unsigned_macho_header(
2874    root: &Utf8Path,
2875    relative: &str,
2876    bytes: &[u8],
2877    expected_kind: ExpectedMachOKind,
2878    expected_linkage: FerryMachOLinkage,
2879) -> Result<(), ArtifactError> {
2880    let parsed = Mach::parse(bytes).map_err(|error| ArtifactError::InvalidAppleBundle {
2881        path: root.to_owned(),
2882        reason: format!("Mach-O `{relative}` is malformed: {error}"),
2883    })?;
2884    let mut slice_count = 0_usize;
2885    match parsed {
2886        Mach::Binary(binary) => {
2887            slice_count += 1;
2888            validate_macho_binary_header(root, relative, &binary, expected_kind, expected_linkage)?;
2889        }
2890        Mach::Fat(container) => {
2891            for entry in &container {
2892                match entry.map_err(|error| ArtifactError::InvalidAppleBundle {
2893                    path: root.to_owned(),
2894                    reason: format!("Mach-O `{relative}` fat slice is malformed: {error}"),
2895                })? {
2896                    SingleArch::MachO(binary) => {
2897                        slice_count += 1;
2898                        validate_macho_binary_header(
2899                            root,
2900                            relative,
2901                            &binary,
2902                            expected_kind,
2903                            expected_linkage,
2904                        )?;
2905                    }
2906                    SingleArch::Archive(_) => {
2907                        return invalid_apple_bundle(
2908                            root,
2909                            format!("Mach-O `{relative}` contains a static archive slice"),
2910                        );
2911                    }
2912                }
2913            }
2914        }
2915    }
2916    if slice_count != 1 {
2917        return invalid_apple_bundle(
2918            root,
2919            format!("Mach-O `{relative}` does not have exactly one slice"),
2920        );
2921    }
2922    Ok(())
2923}
2924
2925fn validate_macho_binary_header(
2926    root: &Utf8Path,
2927    relative: &str,
2928    binary: &MachO<'_>,
2929    expected_kind: ExpectedMachOKind,
2930    expected_linkage: FerryMachOLinkage,
2931) -> Result<(), ArtifactError> {
2932    if binary.header.cputype != CPU_TYPE_ARM64
2933        || !matches!(
2934            binary.header.cpusubtype(),
2935            CPU_SUBTYPE_ARM64_ALL | CPU_SUBTYPE_ARM64_V8
2936        )
2937    {
2938        return invalid_apple_bundle(
2939            root,
2940            format!("Mach-O `{relative}` does not use the expected arm64 CPU subtype"),
2941        );
2942    }
2943    let expected_filetype = match expected_kind {
2944        ExpectedMachOKind::Executable => MH_EXECUTE,
2945        ExpectedMachOKind::DynamicLibrary => MH_DYLIB,
2946    };
2947    if binary.header.filetype != expected_filetype {
2948        return invalid_apple_bundle(
2949            root,
2950            format!(
2951                "Mach-O `{relative}` file type is {:#x}, expected {expected_filetype:#x}",
2952                binary.header.filetype
2953            ),
2954        );
2955    }
2956    for command in &binary.load_commands {
2957        if let CommandVariant::CodeSignature(signature) = &command.command
2958            && signature.datasize != 0
2959        {
2960            return invalid_apple_bundle(
2961                root,
2962                format!("unsigned Mach-O `{relative}` contains a non-empty code signature"),
2963            );
2964        }
2965    }
2966    validate_ferry_macho_linkage(root, relative, binary, expected_linkage)
2967}
2968
2969fn validate_ferry_macho_linkage(
2970    root: &Utf8Path,
2971    relative: &str,
2972    binary: &MachO<'_>,
2973    expected: FerryMachOLinkage,
2974) -> Result<(), ArtifactError> {
2975    let expected_install_name = match expected {
2976        FerryMachOLinkage::RuntimeBridge { .. } => Some(RUNTIME_BRIDGE_INSTALL_NAME),
2977        FerryMachOLinkage::ActivityModel => Some(ACTIVITY_MODEL_INSTALL_NAME),
2978        FerryMachOLinkage::None | FerryMachOLinkage::LiveActivityExtension => None,
2979    };
2980    if let Some(expected_install_name) = expected_install_name {
2981        let id_commands = binary
2982            .load_commands
2983            .iter()
2984            .filter(|command| matches!(&command.command, CommandVariant::IdDylib(_)))
2985            .count();
2986        if id_commands != 1 || binary.name != Some(expected_install_name) {
2987            return invalid_apple_bundle(
2988                root,
2989                format!(
2990                    "Mach-O `{relative}` dylib ID is {:?} across {id_commands} LC_ID_DYLIB commands, expected exactly `{expected_install_name}`",
2991                    binary.name
2992                ),
2993            );
2994        }
2995    }
2996
2997    let expected_dependencies: Option<&[&str]> = match expected {
2998        FerryMachOLinkage::RuntimeBridge {
2999            activity_model_required: true,
3000        }
3001        | FerryMachOLinkage::LiveActivityExtension => Some(&[ACTIVITY_MODEL_INSTALL_NAME]),
3002        FerryMachOLinkage::RuntimeBridge {
3003            activity_model_required: false,
3004        }
3005        | FerryMachOLinkage::ActivityModel => Some(&[]),
3006        FerryMachOLinkage::None => None,
3007    };
3008    let Some(expected_dependencies) = expected_dependencies else {
3009        return Ok(());
3010    };
3011    let mut actual_dependencies = binary
3012        .libs
3013        .iter()
3014        .skip(1)
3015        .copied()
3016        .filter(|dependency| is_ferry_framework_dependency(dependency))
3017        .collect::<Vec<_>>();
3018    actual_dependencies.sort_unstable();
3019    let mut expected_dependencies = expected_dependencies.to_vec();
3020    expected_dependencies.sort_unstable();
3021    if actual_dependencies != expected_dependencies {
3022        return invalid_apple_bundle(
3023            root,
3024            format!(
3025                "Mach-O `{relative}` Ferry framework dependencies are {actual_dependencies:?}, expected {expected_dependencies:?}"
3026            ),
3027        );
3028    }
3029    Ok(())
3030}
3031
3032fn is_ferry_framework_dependency(dependency: &str) -> bool {
3033    dependency.split('/').any(|component| {
3034        matches!(
3035            component,
3036            "FerryRuntimeBridge.framework" | "FerryActivityModel.framework"
3037        )
3038    })
3039}
3040
3041fn validate_recorded_macho_versions(
3042    root: &Utf8Path,
3043    relative: &str,
3044    evidence: &MachOSliceEvidence,
3045    expected_minimum_os: &str,
3046    expected_sdk: &str,
3047    prebuilt_swift_runtime: bool,
3048) -> Result<(), ArtifactError> {
3049    let expected_minimum = parse_apple_version(expected_minimum_os).ok_or_else(|| {
3050        ArtifactError::InvalidAppleBundle {
3051            path: root.to_owned(),
3052            reason: format!("invalid expected minimum OS `{expected_minimum_os}`"),
3053        }
3054    })?;
3055    let expected_sdk =
3056        parse_apple_version(expected_sdk).ok_or_else(|| ArtifactError::InvalidAppleBundle {
3057            path: root.to_owned(),
3058            reason: format!("invalid expected SDK `{expected_sdk}`"),
3059        })?;
3060    let actual_minimum = evidence
3061        .minimum_os
3062        .as_deref()
3063        .and_then(parse_apple_version)
3064        .ok_or_else(|| ArtifactError::InvalidAppleBundle {
3065            path: root.to_owned(),
3066            reason: format!("Mach-O `{relative}` has no numeric minimum OS"),
3067        })?;
3068    let actual_sdk = evidence
3069        .sdk
3070        .as_deref()
3071        .and_then(parse_apple_version)
3072        .ok_or_else(|| ArtifactError::InvalidAppleBundle {
3073            path: root.to_owned(),
3074            reason: format!("Mach-O `{relative}` has no numeric SDK"),
3075        })?;
3076    let versions_match = if prebuilt_swift_runtime {
3077        actual_minimum <= expected_minimum && actual_sdk <= expected_sdk
3078    } else {
3079        actual_minimum == expected_minimum && actual_sdk == expected_sdk
3080    };
3081    if !versions_match {
3082        return invalid_apple_bundle(
3083            root,
3084            format!(
3085                "Mach-O `{relative}` records minOS {:?} and SDK {:?}; expected minOS {:?} and SDK {:?}{}",
3086                actual_minimum,
3087                actual_sdk,
3088                expected_minimum,
3089                expected_sdk,
3090                if prebuilt_swift_runtime {
3091                    " or older compatible Swift-runtime values"
3092                } else {
3093                    ""
3094                }
3095            ),
3096        );
3097    }
3098    Ok(())
3099}
3100
3101fn parse_apple_version(value: &str) -> Option<[u32; 3]> {
3102    let components = value.split('.').collect::<Vec<_>>();
3103    if components.is_empty()
3104        || components.len() > 3
3105        || components.iter().any(|component| {
3106            component.is_empty() || !component.bytes().all(|byte| byte.is_ascii_digit())
3107        })
3108    {
3109        return None;
3110    }
3111    let mut version = [0_u32; 3];
3112    for (index, component) in components.iter().enumerate() {
3113        version[index] = component.parse().ok()?;
3114    }
3115    Some(version)
3116}
3117
3118fn sha256_bytes(bytes: &[u8]) -> String {
3119    lower_hex(&Sha256::digest(bytes))
3120}
3121
3122fn invalid_apple_bundle<T>(path: &Utf8Path, reason: String) -> Result<T, ArtifactError> {
3123    Err(ArtifactError::InvalidAppleBundle {
3124        path: path.to_owned(),
3125        reason,
3126    })
3127}
3128
3129fn read_zip_plist<R: Read + std::io::Seek>(
3130    archive: &mut ZipArchive<R>,
3131    path: &Utf8Path,
3132    name: &str,
3133) -> Result<plist::Dictionary, ArtifactError> {
3134    let bytes = read_zip_entry(archive, path, name)?;
3135    let value = plist::Value::from_reader(Cursor::new(bytes)).map_err(|error| {
3136        ArtifactError::InvalidIpa {
3137            path: path.to_owned(),
3138            reason: format!("plist `{name}` is invalid: {error}"),
3139        }
3140    })?;
3141    value
3142        .into_dictionary()
3143        .ok_or_else(|| ArtifactError::InvalidIpa {
3144            path: path.to_owned(),
3145            reason: format!("plist `{name}` is not a dictionary"),
3146        })
3147}
3148
3149fn read_zip_entry<R: Read + std::io::Seek>(
3150    archive: &mut ZipArchive<R>,
3151    path: &Utf8Path,
3152    name: &str,
3153) -> Result<Vec<u8>, ArtifactError> {
3154    let entry = archive
3155        .by_name(name)
3156        .map_err(|error| ArtifactError::InvalidIpa {
3157            path: path.to_owned(),
3158            reason: format!("required entry `{name}` is missing: {error}"),
3159        })?;
3160    if entry.size() > MAX_IPA_ENTRY_SIZE {
3161        return invalid_ipa(path, format!("entry `{name}` exceeds the read limit"));
3162    }
3163    let capacity = usize::try_from(entry.size()).map_err(|_| ArtifactError::InvalidIpa {
3164        path: path.to_owned(),
3165        reason: format!("entry `{name}` is too large for this client"),
3166    })?;
3167    let mut bytes = Vec::with_capacity(capacity);
3168    entry
3169        .take(MAX_IPA_ENTRY_SIZE + 1)
3170        .read_to_end(&mut bytes)
3171        .map_err(|error| ArtifactError::Io {
3172            action: "read entry from",
3173            path: path.to_owned(),
3174            message: error.to_string(),
3175        })?;
3176    if bytes.len() > capacity {
3177        return invalid_ipa(path, format!("entry `{name}` exceeded its declared size"));
3178    }
3179    Ok(bytes)
3180}
3181
3182fn plist_string(
3183    path: &Utf8Path,
3184    dictionary: &plist::Dictionary,
3185    key: &str,
3186) -> Result<String, ArtifactError> {
3187    dictionary
3188        .get(key)
3189        .and_then(plist::Value::as_string)
3190        .map(ToOwned::to_owned)
3191        .ok_or_else(|| ArtifactError::InvalidIpa {
3192            path: path.to_owned(),
3193            reason: format!("Info.plist has no string `{key}`"),
3194        })
3195}
3196
3197fn is_main_info_plist(name: &str) -> bool {
3198    let components = name.split('/').collect::<Vec<_>>();
3199    components.len() == 3
3200        && components[0] == "Payload"
3201        && has_ascii_extension(components[1], "app")
3202        && components[2] == "Info.plist"
3203}
3204
3205fn validate_zip_name(path: &Utf8Path, name: &str) -> Result<(), ArtifactError> {
3206    if name.is_empty()
3207        || name.len() > MAX_IPA_ENTRY_NAME
3208        || name.starts_with('/')
3209        || name.starts_with('\\')
3210        || name.contains('\\')
3211        || name.contains('\0')
3212        || name.chars().any(char::is_control)
3213    {
3214        return invalid_ipa(path, format!("unsafe ZIP entry name `{name}`"));
3215    }
3216    let trimmed = name.trim_end_matches('/');
3217    if trimmed.is_empty() {
3218        return invalid_ipa(path, format!("unsafe ZIP entry name `{name}`"));
3219    }
3220    for (index, component) in trimmed.split('/').enumerate() {
3221        if component.is_empty() || component == "." || component == ".." {
3222            return invalid_ipa(path, format!("unsafe ZIP entry name `{name}`"));
3223        }
3224        if index == 0 && component.len() >= 2 && component.as_bytes()[1] == b':' {
3225            return invalid_ipa(path, format!("Windows drive path in ZIP entry `{name}`"));
3226        }
3227    }
3228    Ok(())
3229}
3230
3231fn canonical_archive_key(name: &str) -> String {
3232    name.nfc().flat_map(char::to_lowercase).collect()
3233}
3234
3235fn has_ascii_extension(path: &str, expected: &str) -> bool {
3236    path.rsplit_once('.')
3237        .is_some_and(|(_, extension)| extension.eq_ignore_ascii_case(expected))
3238}
3239
3240fn reject_sensitive_or_generated_name(path: &Utf8Path, name: &str) -> Result<(), ArtifactError> {
3241    let lower = name.to_lowercase();
3242    let forbidden_suffixes = [
3243        ".p12",
3244        ".p8",
3245        ".key",
3246        ".pem",
3247        ".mobileprovision.bak",
3248        ".xcodeproj/project.pbxproj",
3249        ".swift",
3250        ".m",
3251        ".mm",
3252    ];
3253    if forbidden_suffixes
3254        .iter()
3255        .any(|suffix| lower.ends_with(suffix))
3256        || lower.contains("/keychains/")
3257        || lower.contains("/credentials/")
3258        || lower.contains("/secrets/")
3259    {
3260        return invalid_ipa(
3261            path,
3262            format!("forbidden sensitive/generated entry `{name}`"),
3263        );
3264    }
3265    Ok(())
3266}
3267
3268fn sha256_file(path: &Utf8Path) -> Result<String, ArtifactError> {
3269    let mut file = File::open(path).map_err(|error| ArtifactError::Io {
3270        action: "open",
3271        path: path.to_owned(),
3272        message: error.to_string(),
3273    })?;
3274    let mut digest = Sha256::new();
3275    let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
3276    loop {
3277        let count = file.read(&mut buffer).map_err(|error| ArtifactError::Io {
3278            action: "hash",
3279            path: path.to_owned(),
3280            message: error.to_string(),
3281        })?;
3282        if count == 0 {
3283            break;
3284        }
3285        digest.update(&buffer[..count]);
3286    }
3287    Ok(lower_hex(&digest.finalize()))
3288}
3289
3290fn lower_hex(bytes: &[u8]) -> String {
3291    const HEX: &[u8; 16] = b"0123456789abcdef";
3292    let mut output = String::with_capacity(bytes.len() * 2);
3293    for byte in bytes {
3294        output.push(char::from(HEX[usize::from(byte >> 4)]));
3295        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
3296    }
3297    output
3298}
3299
3300fn constant_time_ascii_eq(left: &[u8], right: &[u8]) -> bool {
3301    if left.len() != right.len() {
3302        return false;
3303    }
3304    let mut difference = 0_u8;
3305    for (left, right) in left.iter().zip(right) {
3306        difference |= left ^ right;
3307    }
3308    difference == 0
3309}
3310
3311fn invalid_ipa<T>(path: &Utf8Path, reason: String) -> Result<T, ArtifactError> {
3312    Err(ArtifactError::InvalidIpa {
3313        path: path.to_owned(),
3314        reason,
3315    })
3316}
3317
3318#[cfg(test)]
3319mod tests {
3320    use super::*;
3321
3322    fn thin_arm64(platform: u32) -> Vec<u8> {
3323        let mut bytes = Vec::new();
3324        for value in [
3325            0xfeed_facfu32,
3326            CPU_TYPE_ARM64,
3327            0,
3328            2,
3329            1,
3330            24,
3331            0,
3332            0,
3333            0x32,
3334            24,
3335            platform,
3336            0x0011_0000,
3337            0x0012_0200,
3338            0,
3339        ] {
3340            bytes.extend_from_slice(&value.to_le_bytes());
3341        }
3342        bytes
3343    }
3344
3345    #[test]
3346    fn device_and_simulator_arm64_are_not_confused() {
3347        let device = inspect_physical_iphone_macho(&thin_arm64(2)).unwrap();
3348        assert_eq!(device[0].architecture, "arm64");
3349        assert_eq!(device[0].platform, ApplePlatform::Ios);
3350
3351        let simulator = inspect_physical_iphone_macho(&thin_arm64(7)).unwrap_err();
3352        assert!(matches!(simulator, ArtifactError::SimulatorBinary));
3353    }
3354
3355    #[test]
3356    fn legacy_iphoneos_command_is_not_promoted_to_device_proof() {
3357        let mut bytes = thin_arm64(2);
3358        bytes[32..36].copy_from_slice(&0x25_u32.to_le_bytes());
3359        bytes[36..40].copy_from_slice(&16_u32.to_le_bytes());
3360        bytes.truncate(48);
3361        bytes[20..24].copy_from_slice(&16_u32.to_le_bytes());
3362        let error = inspect_physical_iphone_macho(&bytes).unwrap_err();
3363        assert!(matches!(error, ArtifactError::DevicePlatformUnproven(_)));
3364    }
3365
3366    #[test]
3367    fn artifact_kind_must_be_unique() {
3368        let mut manifest = ArtifactManifest::new("operation", "job");
3369        assert!(matches!(
3370            manifest.one_artifact(ArtifactKind::Ipa),
3371            Err(ArtifactError::ManifestArtifactMissing { .. })
3372        ));
3373        let record = ArtifactRecord {
3374            artifact_id: "ipa".to_owned(),
3375            kind: ArtifactKind::Ipa,
3376            file_name: "Weather-development.ipa".to_owned(),
3377            size: 1,
3378            sha256: "00".repeat(32),
3379            media_type: Some("application/zip".to_owned()),
3380        };
3381        manifest.artifacts.push(record.clone());
3382        assert_eq!(manifest.one_artifact(ArtifactKind::Ipa).unwrap(), &record);
3383        manifest.artifacts.push(record);
3384        assert!(matches!(
3385            manifest.one_artifact(ArtifactKind::Ipa),
3386            Err(ArtifactError::ManifestArtifactAmbiguous { .. })
3387        ));
3388    }
3389
3390    #[test]
3391    fn unsafe_zip_names_are_rejected() {
3392        let path = Utf8Path::new("fixture.ipa");
3393        for name in ["../secret", "/absolute", "C:/windows", "Payload\\bad"] {
3394            assert!(validate_zip_name(path, name).is_err(), "accepted {name}");
3395        }
3396    }
3397
3398    #[test]
3399    fn archive_collision_key_normalizes_unicode_and_case() {
3400        assert_eq!(
3401            canonical_archive_key("Payload/Café.app/FILE"),
3402            canonical_archive_key("payload/Cafe\u{301}.app/file")
3403        );
3404    }
3405
3406    #[cfg(windows)]
3407    #[test]
3408    fn artifact_scan_rejects_a_hard_link_outside_the_bundle() {
3409        let temporary = tempfile::tempdir().unwrap();
3410        let parent = Utf8Path::from_path(temporary.path()).unwrap();
3411        let root = parent.join("artifact.app");
3412        fs::create_dir(&root).unwrap();
3413        fs::write(root.join("payload"), b"trusted bytes").unwrap();
3414        fs::hard_link(root.join("payload"), parent.join("outside-link")).unwrap();
3415
3416        let filesystem = ArtifactFilesystem::open_ambient(&root).unwrap();
3417        let error = scan_real_tree(&filesystem).unwrap_err();
3418
3419        assert!(matches!(error, ArtifactError::InvalidAppleBundle { .. }));
3420        assert!(error.to_string().contains("hard-linked regular file"));
3421    }
3422
3423    #[cfg(windows)]
3424    #[test]
3425    fn retained_artifact_handle_detects_a_hard_link_added_after_open() {
3426        let temporary = tempfile::tempdir().unwrap();
3427        let parent = Utf8Path::from_path(temporary.path()).unwrap();
3428        let root = parent.join("artifact.app");
3429        fs::create_dir(&root).unwrap();
3430        fs::write(root.join("payload"), b"trusted bytes").unwrap();
3431
3432        let filesystem = ArtifactFilesystem::open_ambient(&root).unwrap();
3433        let (file, before) =
3434            open_checked_file(&filesystem, Utf8Path::new("payload"), 1024).unwrap();
3435        fs::hard_link(root.join("payload"), parent.join("outside-link")).unwrap();
3436
3437        let error = verify_open_file_stable(&filesystem, Utf8Path::new("payload"), &file, &before)
3438            .unwrap_err();
3439        assert!(matches!(error, ArtifactError::InvalidAppleBundle { .. }));
3440        assert!(error.to_string().contains("hard-linked regular file"));
3441    }
3442
3443    #[cfg(unix)]
3444    #[test]
3445    fn capability_reads_cannot_escape_or_validate_a_replaced_root() {
3446        use std::os::unix::fs::symlink;
3447
3448        for replacement_is_symlink in [false, true] {
3449            let temporary = tempfile::tempdir().unwrap();
3450            let parent = Utf8Path::from_path(temporary.path()).unwrap();
3451            let root = parent.join("artifact.app");
3452            let moved = parent.join("opened-artifact.app");
3453            let outside = parent.join("outside");
3454            fs::create_dir(&root).unwrap();
3455            fs::create_dir(&outside).unwrap();
3456            fs::write(root.join("payload"), b"trusted bytes").unwrap();
3457            fs::write(outside.join("payload"), b"outside bytes").unwrap();
3458
3459            let filesystem = ArtifactFilesystem::open_ambient(&root).unwrap();
3460            let scanned = scan_real_tree(&filesystem).unwrap();
3461            assert_eq!(scanned.relative_paths(), ["payload"]);
3462            fs::rename(&root, &moved).unwrap();
3463            if replacement_is_symlink {
3464                symlink(&outside, &root).unwrap();
3465            } else {
3466                fs::create_dir(&root).unwrap();
3467                fs::write(root.join("payload"), b"outside bytes").unwrap();
3468            }
3469
3470            let bytes = read_checked_file(&filesystem, Utf8Path::new("payload"), 1024).unwrap();
3471            assert_eq!(bytes, b"trusted bytes");
3472            assert!(filesystem.verify_ambient_binding().is_err());
3473        }
3474    }
3475
3476    #[cfg(unix)]
3477    #[test]
3478    fn capability_subdirectory_cannot_validate_a_replaced_nested_path() {
3479        let temporary = tempfile::tempdir().unwrap();
3480        let parent = Utf8Path::from_path(temporary.path()).unwrap();
3481        let root = parent.join("artifact.xcarchive");
3482        let original = root.join("Products/Applications/App.app");
3483        let moved = root.join("Products/Applications/opened-App.app");
3484        fs::create_dir_all(&original).unwrap();
3485        fs::write(original.join("payload"), b"trusted bytes").unwrap();
3486
3487        let filesystem = ArtifactFilesystem::open_ambient(&root).unwrap();
3488        let app = filesystem
3489            .open_subdirectory(Utf8Path::new("Products/Applications/App.app"))
3490            .unwrap();
3491        fs::rename(&original, &moved).unwrap();
3492        fs::create_dir(&original).unwrap();
3493        fs::write(original.join("payload"), b"replacement bytes").unwrap();
3494
3495        let bytes = read_checked_file(&app, Utf8Path::new("payload"), 1024).unwrap();
3496        assert_eq!(bytes, b"trusted bytes");
3497        assert!(app.verify_ambient_binding().is_err());
3498        assert!(filesystem.verify_ambient_binding().is_ok());
3499    }
3500}