Skip to main content

rustferry_apple/
device.rs

1use std::fs;
2
3use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
4use rustferry_core::{FerryConfig, ProjectAssets, brand};
5use rustferry_remote::{
6    IosDeviceProductExpectation, UnsignedNestedBundleExpectation, UnsignedNestedBundleKind,
7    UnsignedXcarchiveExpectation, UnsignedXcarchiveInspection, inspect_unsigned_xcarchive,
8};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::{
13    AppleBuildProfile, AppleError, CommandSpec, IOS_DEVICE_TARGET, IosAssetPackaging,
14    IosDeviceToolchain, IosProjectPlatform, IosProjectSpec, PlannedCopy, error::io_error,
15    generate_ios_project_for_platform, run_command, write_ios_project,
16};
17
18const IOS_DEVICE_SDK: &str = "iphoneos";
19const IOS_GENERIC_DESTINATION: &str = "generic/platform=iOS";
20
21/// Inputs for an unsigned physical-iPhone compilation and Xcode archive.
22#[derive(Clone, Debug, PartialEq)]
23pub struct IosDeviceArchiveRequest {
24    /// Rust project containing `Cargo.toml` and `ferry.toml`.
25    pub project_dir: Utf8PathBuf,
26    /// Validated application configuration.
27    pub config: FerryConfig,
28    /// Cargo binary target and final `CFBundleExecutable` name.
29    pub binary_name: String,
30    /// Optional Cargo package selector for workspace projects.
31    pub package_name: Option<String>,
32    /// Cargo/Xcode profile.
33    pub profile: AppleBuildProfile,
34    /// Explicit Cargo features enabled for the target build.
35    pub cargo_features: Vec<String>,
36    /// Preserve Rust DWARF so the worker can produce a matching application dSYM.
37    pub generate_debug_symbols: bool,
38    /// Plan only; perform no writes or subprocess execution.
39    pub dry_run: bool,
40}
41
42impl IosDeviceArchiveRequest {
43    /// Construct a release-mode unsigned physical-device archive request.
44    pub fn new(
45        project_dir: impl Into<Utf8PathBuf>,
46        config: FerryConfig,
47        binary_name: impl Into<String>,
48    ) -> Self {
49        Self {
50            project_dir: project_dir.into(),
51            config,
52            binary_name: binary_name.into(),
53            package_name: None,
54            profile: AppleBuildProfile::Release,
55            cargo_features: Vec::new(),
56            generate_debug_symbols: false,
57            dry_run: false,
58        }
59    }
60
61    /// Request retained DWARF for create-only dSYM generation after archiving.
62    #[must_use]
63    pub fn with_debug_symbols(mut self, enabled: bool) -> Self {
64        self.generate_debug_symbols = enabled;
65        self
66    }
67}
68
69/// Installation status of a physical-device build product.
70#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum IosDeviceArtifactDisposition {
73    /// Compiled device code in an unsigned archive; not installable until separately signed.
74    UnsignedCompileOnly,
75}
76
77/// Commands and invariants used to reject Simulator Mach-O output.
78#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
79pub struct IosDeviceMachOValidationPlan {
80    /// Archived application executable to inspect.
81    pub executable_path: Utf8PathBuf,
82    /// Required thin Mach-O architecture.
83    pub expected_architecture: String,
84    /// Required `LC_BUILD_VERSION` platform reported by `vtool`.
85    pub expected_platform: String,
86    /// Required minimum iOS version reported by `vtool`.
87    pub expected_minimum_os: String,
88    /// Required iPhoneOS SDK version reported by `vtool`.
89    pub expected_sdk: String,
90    /// Tokenized architecture and platform inspection commands.
91    pub commands: Vec<CommandSpec>,
92}
93
94/// Complete serializable plan for an unsigned physical-device Xcode archive.
95#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
96pub struct IosDeviceArchivePlan {
97    /// Stable plan schema version.
98    pub schema_version: u32,
99    /// Rust compilation target.
100    pub rust_target: String,
101    /// Xcode SDK identifier.
102    pub sdk: String,
103    /// Xcode generic destination.
104    pub destination: String,
105    /// Build profile.
106    pub profile: AppleBuildProfile,
107    /// Whether exact Rust DWARF must survive into the archived executable.
108    pub generate_debug_symbols: bool,
109    /// Explicitly non-installable output classification.
110    pub disposition: IosDeviceArtifactDisposition,
111    /// Internal generated Xcode root.
112    pub generated_root: Utf8PathBuf,
113    /// Isolated Cargo target directory.
114    pub cargo_target_dir: Utf8PathBuf,
115    /// Xcode `DerivedData` directory.
116    pub xcode_derived_data: Utf8PathBuf,
117    /// Xcode destination preflight, Rust compilation, and Xcode archive commands, in order.
118    pub commands: Vec<CommandSpec>,
119    /// Deterministic generated file paths.
120    pub generated_files: Vec<Utf8PathBuf>,
121    /// Stable identity of icon and splash bytes embedded as app resources.
122    pub asset_fingerprint: String,
123    /// Rust executable staging copy.
124    pub rust_binary_copy: PlannedCopy,
125    /// Expected unsigned `.xcarchive` output.
126    pub archive_path: Utf8PathBuf,
127    /// Expected unsigned `.app` inside the archive.
128    pub app_path: Utf8PathBuf,
129    /// Post-archive device Mach-O validation hook.
130    pub macho_validation: IosDeviceMachOValidationPlan,
131    /// Cross-platform structural invariants for the unsigned archive and every nested code object.
132    pub archive_expectation: UnsignedXcarchiveExpectation,
133}
134
135/// Evidence that an archived executable is arm64 physical-iOS code.
136#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
137pub struct IosDeviceMachOValidation {
138    /// Validated executable path.
139    pub executable_path: Utf8PathBuf,
140    /// Architecture reported by `lipo`.
141    pub architecture: String,
142    /// Build platform reported by `vtool`.
143    pub platform: String,
144    /// Minimum iOS version reported by `vtool`.
145    pub minimum_os: String,
146    /// iPhoneOS SDK version reported by `vtool`.
147    pub sdk: String,
148}
149
150/// Result of an unsigned physical-device archive operation.
151#[derive(Debug)]
152pub struct IosDeviceArchiveOutcome {
153    /// Exact plan used by the operation.
154    pub plan: IosDeviceArchivePlan,
155    /// Produced archive path, absent for dry-run.
156    pub archive: Option<Utf8PathBuf>,
157    /// Produced application path, absent for dry-run.
158    pub app: Option<Utf8PathBuf>,
159    /// Physical-device Mach-O evidence, absent for dry-run.
160    pub macho_validation: Option<IosDeviceMachOValidation>,
161    /// Full cross-platform archive/app/nested-code evidence, absent for dry-run.
162    pub archive_inspection: Option<UnsignedXcarchiveInspection>,
163}
164
165/// Produce a deterministic, side-effect-free unsigned device archive plan.
166///
167/// # Errors
168///
169/// Returns [`AppleError`] when request/config values are invalid or assets
170/// cannot be loaded safely.
171pub fn plan_ios_device_unsigned(
172    request: &IosDeviceArchiveRequest,
173    toolchain: &IosDeviceToolchain,
174) -> Result<IosDeviceArchivePlan, AppleError> {
175    validate_request(request)?;
176    let assets = ProjectAssets::load(&request.project_dir)?;
177    plan_with_assets(request, toolchain, &assets)
178}
179
180/// Derive the complete client-owned product identity for a physical-iPhone request.
181///
182/// This helper is host-independent and does not inspect Xcode. The remote worker later compares
183/// the generated archive with these pre-submission values.
184///
185/// # Errors
186///
187/// Returns [`AppleError`] when the binary name, configuration, or extension graph is invalid.
188pub fn derive_ios_device_product_expectation(
189    config: &FerryConfig,
190    binary_name: &str,
191) -> Result<IosDeviceProductExpectation, AppleError> {
192    validate_binary_name(binary_name)?;
193    let _ = generate_ios_project_for_platform(
194        &IosProjectSpec::new(config.clone(), binary_name.to_owned()),
195        IosProjectPlatform::DeviceUnsigned,
196    )?;
197    let mut nested_bundles = vec![UnsignedNestedBundleExpectation {
198        relative_path: "Frameworks/FerryRuntimeBridge.framework".to_owned(),
199        bundle_identifier: "org.rustferry.runtime-bridge".to_owned(),
200        executable: "FerryRuntimeBridge".to_owned(),
201        kind: UnsignedNestedBundleKind::Framework,
202    }];
203    if config.extensions.live_activity.enabled {
204        nested_bundles.push(UnsignedNestedBundleExpectation {
205            relative_path: "Frameworks/FerryActivityModel.framework".to_owned(),
206            bundle_identifier: "org.rustferry.activity-model".to_owned(),
207            executable: "FerryActivityModel".to_owned(),
208            kind: UnsignedNestedBundleKind::Framework,
209        });
210        nested_bundles.push(UnsignedNestedBundleExpectation {
211            relative_path: "PlugIns/FerryLiveActivityExtension.appex".to_owned(),
212            bundle_identifier: format!("{}.liveactivity", config.app.identifier),
213            executable: "FerryLiveActivityExtension".to_owned(),
214            kind: UnsignedNestedBundleKind::AppExtension,
215        });
216    }
217    if config.extensions.widget.enabled {
218        nested_bundles.push(UnsignedNestedBundleExpectation {
219            relative_path: "PlugIns/FerryWidgetExtension.appex".to_owned(),
220            bundle_identifier: format!("{}.widget", config.app.identifier),
221            executable: "FerryWidgetExtension".to_owned(),
222            kind: UnsignedNestedBundleKind::AppExtension,
223        });
224    }
225    nested_bundles.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
226    let version = &config.app.version;
227    Ok(IosDeviceProductExpectation {
228        app_directory_name: format!("{binary_name}.app"),
229        executable: binary_name.to_owned(),
230        app_version: config.app.display_version.clone(),
231        build_number: format!("{}.{}.{}", version.major, version.minor, version.patch),
232        nested_bundles,
233    })
234}
235
236fn plan_with_assets(
237    request: &IosDeviceArchiveRequest,
238    toolchain: &IosDeviceToolchain,
239    assets: &ProjectAssets,
240) -> Result<IosDeviceArchivePlan, AppleError> {
241    if parse_apple_version(&toolchain.device_sdk.version).is_none()
242        || toolchain.device_sdk.build_version.trim().is_empty()
243    {
244        return Err(AppleError::InvalidRequest(format!(
245            "selected iPhoneOS SDK evidence is incomplete or invalid: version `{}`, build `{}`",
246            toolchain.device_sdk.version, toolchain.device_sdk.build_version
247        )));
248    }
249    let generated = generate_ios_project_for_platform(
250        &IosProjectSpec::new(request.config.clone(), request.binary_name.clone())
251            .with_assets(assets.clone())
252            .with_asset_packaging(IosAssetPackaging::SdkOnlyResources),
253        IosProjectPlatform::DeviceUnsigned,
254    )?;
255    let device_root = request
256        .project_dir
257        .join("target")
258        .join(brand::TARGET_DIRECTORY)
259        .join("ios")
260        .join("device");
261    let generated_root = device_root.join("generated");
262    let cargo_target_dir = device_root.join("cargo");
263    let profile_directory = profile_directory(request.profile);
264    let xcode_derived_data = device_root.join("xcode").join(profile_directory);
265    let archive_path = device_root
266        .join("archives")
267        .join(profile_directory)
268        .join(format!("{}.xcarchive", request.binary_name));
269    let app_path = archive_path
270        .join("Products")
271        .join("Applications")
272        .join(format!("{}.app", request.binary_name));
273    let cargo_binary = cargo_target_dir
274        .join(IOS_DEVICE_TARGET)
275        .join(profile_directory)
276        .join(&request.binary_name);
277    let staged_binary = generated_root.join(&request.binary_name);
278
279    let archived_executable = app_path.join(&request.binary_name);
280    let destination_preflight = xcode_destination_command(request, toolchain, &generated_root);
281    let cargo = cargo_build_command(request, toolchain, &cargo_target_dir);
282    let xcodebuild = xcode_archive_command(
283        request,
284        toolchain,
285        &generated_root,
286        &xcode_derived_data,
287        &archive_path,
288    );
289    let macho_validation = macho_validation_plan(request, toolchain, archived_executable);
290    let archive_expectation = archive_expectation(request, toolchain, &generated)?;
291
292    Ok(IosDeviceArchivePlan {
293        schema_version: 1,
294        rust_target: IOS_DEVICE_TARGET.to_owned(),
295        sdk: IOS_DEVICE_SDK.to_owned(),
296        destination: IOS_GENERIC_DESTINATION.to_owned(),
297        profile: request.profile,
298        generate_debug_symbols: request.generate_debug_symbols,
299        disposition: IosDeviceArtifactDisposition::UnsignedCompileOnly,
300        generated_root,
301        cargo_target_dir,
302        xcode_derived_data,
303        commands: vec![destination_preflight, cargo, xcodebuild],
304        generated_files: generated
305            .files
306            .keys()
307            .map(|relative| device_root.join("generated").join(relative))
308            .collect(),
309        asset_fingerprint: assets.fingerprint().to_owned(),
310        rust_binary_copy: PlannedCopy {
311            source: cargo_binary,
312            destination: staged_binary,
313        },
314        archive_path,
315        app_path,
316        macho_validation,
317        archive_expectation,
318    })
319}
320
321fn archive_expectation(
322    request: &IosDeviceArchiveRequest,
323    toolchain: &IosDeviceToolchain,
324    generated: &crate::GeneratedAppleProject,
325) -> Result<UnsignedXcarchiveExpectation, AppleError> {
326    let product = derive_ios_device_product_expectation(&request.config, &request.binary_name)?;
327
328    let mut required_resources = std::collections::BTreeMap::new();
329    for relative in ["FerryResources.json", "FerryIcon.png", "FerrySplash.png"] {
330        let bytes = generated
331            .files
332            .get(Utf8Path::new(relative))
333            .ok_or_else(|| {
334                AppleError::InvalidRequest(format!(
335                    "generated physical-iOS project omitted required resource `{relative}`"
336                ))
337            })?;
338        required_resources.insert(relative.to_owned(), hex::encode(Sha256::digest(bytes)));
339    }
340    Ok(UnsignedXcarchiveExpectation {
341        app_directory_name: product.app_directory_name,
342        bundle_identifier: request.config.app.identifier.clone(),
343        executable: product.executable,
344        app_version: product.app_version,
345        build_number: product.build_number,
346        minimum_os: request.config.ios.min_version.clone(),
347        sdk_version: toolchain.device_sdk.version.clone(),
348        sdk_build_version: toolchain.device_sdk.build_version.clone(),
349        nested_bundles: product.nested_bundles,
350        required_resources,
351    })
352}
353
354fn cargo_build_command(
355    request: &IosDeviceArchiveRequest,
356    toolchain: &IosDeviceToolchain,
357    cargo_target_dir: &Utf8Path,
358) -> CommandSpec {
359    let mut command = CommandSpec::new(
360        "cross-compile Rust executable for physical iOS device",
361        &toolchain.cargo,
362        &request.project_dir,
363    );
364    command.args = vec![
365        "build".to_owned(),
366        "--locked".to_owned(),
367        "--target".to_owned(),
368        IOS_DEVICE_TARGET.to_owned(),
369        "--bin".to_owned(),
370        request.binary_name.clone(),
371    ];
372    if let Some(package) = &request.package_name {
373        command.args.push("--package".to_owned());
374        command.args.push(package.clone());
375    }
376    if request.profile == AppleBuildProfile::Release {
377        command.args.push("--release".to_owned());
378    }
379    if !request.cargo_features.is_empty() {
380        command.args.push("--features".to_owned());
381        command.args.push(request.cargo_features.join(","));
382    }
383    command
384        .environment
385        .insert("CARGO_TARGET_DIR".to_owned(), cargo_target_dir.to_string());
386    if request.generate_debug_symbols {
387        let profile = match request.profile {
388            AppleBuildProfile::Debug => "DEV",
389            AppleBuildProfile::Release => "RELEASE",
390        };
391        command
392            .environment
393            .insert(format!("CARGO_PROFILE_{profile}_DEBUG"), "2".to_owned());
394    }
395    command.environment.insert(
396        "IPHONEOS_DEPLOYMENT_TARGET".to_owned(),
397        request.config.ios.min_version.clone(),
398    );
399    command.environment.insert(
400        "DEVELOPER_DIR".to_owned(),
401        toolchain.developer_dir.to_string(),
402    );
403    command
404        .environment
405        .insert("CARGO_TERM_COLOR".to_owned(), "never".to_owned());
406    command
407}
408
409fn xcode_destination_command(
410    request: &IosDeviceArchiveRequest,
411    toolchain: &IosDeviceToolchain,
412    generated_root: &Utf8Path,
413) -> CommandSpec {
414    let mut command = CommandSpec::new(
415        "preflight physical-iOS Xcode destination",
416        &toolchain.xcodebuild,
417        &request.project_dir,
418    );
419    command.args = vec![
420        "-project".to_owned(),
421        generated_root.join("FerryHost.xcodeproj").to_string(),
422        "-scheme".to_owned(),
423        "FerryApp".to_owned(),
424        "-configuration".to_owned(),
425        xcode_configuration(request.profile).to_owned(),
426        "-sdk".to_owned(),
427        IOS_DEVICE_SDK.to_owned(),
428        "-showdestinations".to_owned(),
429    ];
430    command.environment.insert(
431        "DEVELOPER_DIR".to_owned(),
432        toolchain.developer_dir.to_string(),
433    );
434    command
435        .environment
436        .insert("LC_ALL".to_owned(), "C".to_owned());
437    command
438}
439
440fn xcode_archive_command(
441    request: &IosDeviceArchiveRequest,
442    toolchain: &IosDeviceToolchain,
443    generated_root: &Utf8Path,
444    xcode_derived_data: &Utf8Path,
445    archive_path: &Utf8Path,
446) -> CommandSpec {
447    let mut command = CommandSpec::new(
448        "assemble unsigned physical-iOS Xcode archive",
449        &toolchain.xcodebuild,
450        &request.project_dir,
451    );
452    command.args = vec![
453        "-project".to_owned(),
454        generated_root.join("FerryHost.xcodeproj").to_string(),
455        "-scheme".to_owned(),
456        "FerryApp".to_owned(),
457        "-configuration".to_owned(),
458        xcode_configuration(request.profile).to_owned(),
459        "-sdk".to_owned(),
460        IOS_DEVICE_SDK.to_owned(),
461        "-destination".to_owned(),
462        IOS_GENERIC_DESTINATION.to_owned(),
463        "-derivedDataPath".to_owned(),
464        xcode_derived_data.to_string(),
465        "-archivePath".to_owned(),
466        archive_path.to_string(),
467        "AD_HOC_CODE_SIGNING_ALLOWED=NO".to_owned(),
468        "CODE_SIGN_IDENTITY=".to_owned(),
469        "CODE_SIGNING_ALLOWED=NO".to_owned(),
470        "CODE_SIGNING_REQUIRED=NO".to_owned(),
471        "DEVELOPMENT_TEAM=".to_owned(),
472        "PROVISIONING_PROFILE_SPECIFIER=".to_owned(),
473        "ARCHS=arm64".to_owned(),
474        "ONLY_ACTIVE_ARCH=NO".to_owned(),
475        "archive".to_owned(),
476    ];
477    if request.generate_debug_symbols {
478        command.args.pop();
479        command.args.extend([
480            "COPY_PHASE_STRIP=NO".to_owned(),
481            "DEBUG_INFORMATION_FORMAT=dwarf".to_owned(),
482            "DEPLOYMENT_POSTPROCESSING=NO".to_owned(),
483            "STRIP_INSTALLED_PRODUCT=NO".to_owned(),
484            "archive".to_owned(),
485        ]);
486    }
487    command.environment.insert(
488        "DEVELOPER_DIR".to_owned(),
489        toolchain.developer_dir.to_string(),
490    );
491    command
492        .environment
493        .insert("NSUnbufferedIO".to_owned(), "YES".to_owned());
494    command
495}
496
497fn macho_validation_plan(
498    request: &IosDeviceArchiveRequest,
499    toolchain: &IosDeviceToolchain,
500    executable_path: Utf8PathBuf,
501) -> IosDeviceMachOValidationPlan {
502    IosDeviceMachOValidationPlan {
503        expected_architecture: "arm64".to_owned(),
504        expected_platform: "IOS".to_owned(),
505        expected_minimum_os: request.config.ios.min_version.clone(),
506        expected_sdk: toolchain.device_sdk.version.clone(),
507        commands: vec![
508            inspection_command(
509                "validate physical-iOS Mach-O architecture",
510                toolchain,
511                &request.project_dir,
512                vec![
513                    "lipo".to_owned(),
514                    "-archs".to_owned(),
515                    executable_path.to_string(),
516                ],
517            ),
518            inspection_command(
519                "validate physical-iOS Mach-O platform",
520                toolchain,
521                &request.project_dir,
522                vec![
523                    "vtool".to_owned(),
524                    "-show-build".to_owned(),
525                    executable_path.to_string(),
526                ],
527            ),
528        ],
529        executable_path,
530    }
531}
532
533/// Cross-compile, archive without signing, and prove the main Mach-O targets physical iOS.
534///
535/// The returned `.xcarchive` and `.app` are compile evidence only. They are not
536/// installable until a later signing phase applies an Apple development identity
537/// and matching provisioning profile.
538///
539/// # Errors
540///
541/// Returns [`AppleError`] for invalid inputs, unsafe internal paths, command
542/// failures, missing archive products, or Simulator/device Mach-O confusion.
543pub fn build_ios_device_unsigned(
544    request: &IosDeviceArchiveRequest,
545    toolchain: &IosDeviceToolchain,
546) -> Result<IosDeviceArchiveOutcome, AppleError> {
547    validate_request(request)?;
548    let assets = ProjectAssets::load(&request.project_dir)?;
549    let plan = plan_with_assets(request, toolchain, &assets)?;
550    if request.dry_run {
551        return Ok(IosDeviceArchiveOutcome {
552            plan,
553            archive: None,
554            app: None,
555            macho_validation: None,
556            archive_inspection: None,
557        });
558    }
559
560    let logs = request
561        .project_dir
562        .join("target")
563        .join(brand::TARGET_DIRECTORY)
564        .join("ios")
565        .join("device")
566        .join("logs")
567        .join(profile_directory(request.profile));
568    execute_unsigned_archive(request, &assets, &plan, &logs)?;
569    let (archive_inspection, macho_validation) =
570        inspect_unsigned_archive_output(request, &plan, &logs)?;
571
572    Ok(IosDeviceArchiveOutcome {
573        archive: Some(plan.archive_path.clone()),
574        app: Some(plan.app_path.clone()),
575        macho_validation: Some(macho_validation),
576        archive_inspection: Some(archive_inspection),
577        plan,
578    })
579}
580
581fn execute_unsigned_archive(
582    request: &IosDeviceArchiveRequest,
583    assets: &ProjectAssets,
584    plan: &IosDeviceArchivePlan,
585    logs: &Utf8Path,
586) -> Result<(), AppleError> {
587    for directory in [
588        &plan.cargo_target_dir,
589        logs,
590        plan.archive_path.parent().unwrap_or(&plan.archive_path),
591    ] {
592        prepare_directory(&request.project_dir, directory)?;
593    }
594
595    reset_directory(&request.project_dir, &plan.generated_root)?;
596    write_trusted_generated_project(request, assets, &plan.generated_root)?;
597    let destination = run_command(
598        &plan.commands[0],
599        Some(&logs.join("00-xcode-destination-preflight.log")),
600    )?;
601    validate_device_destination(&destination.stdout, &plan.commands[0].program)?;
602
603    run_command(&plan.commands[1], Some(&logs.join("01-cargo-build.log")))?;
604    validate_regular_file(
605        &plan.rust_binary_copy.source,
606        "Cargo-produced physical-iOS executable",
607    )?;
608    for directory in [
609        logs,
610        plan.archive_path.parent().unwrap_or(&plan.archive_path),
611    ] {
612        prepare_directory(&request.project_dir, directory)?;
613    }
614    reset_directory(&request.project_dir, &plan.xcode_derived_data)?;
615
616    // Cargo build scripts are project-controlled. Recreate trusted Xcode input
617    // only after Cargo exits so they cannot persistently alter the generated host.
618    reset_directory(&request.project_dir, &plan.generated_root)?;
619    write_trusted_generated_project(request, assets, &plan.generated_root)?;
620    fs::copy(
621        &plan.rust_binary_copy.source,
622        &plan.rust_binary_copy.destination,
623    )
624    .map_err(|source| {
625        io_error(
626            "stage physical-iOS Rust executable for Xcode",
627            &plan.rust_binary_copy.destination,
628            source,
629        )
630    })?;
631    #[cfg(unix)]
632    make_executable(&plan.rust_binary_copy.destination)?;
633
634    remove_stale_archive(&request.project_dir, &plan.archive_path)?;
635    run_command(
636        &plan.commands[2],
637        Some(&logs.join("02-xcodebuild-archive.log")),
638    )?;
639    Ok(())
640}
641
642fn write_trusted_generated_project(
643    request: &IosDeviceArchiveRequest,
644    assets: &ProjectAssets,
645    generated_root: &Utf8Path,
646) -> Result<(), AppleError> {
647    let generated = generate_ios_project_for_platform(
648        &IosProjectSpec::new(request.config.clone(), request.binary_name.clone())
649            .with_assets(assets.clone())
650            .with_asset_packaging(IosAssetPackaging::SdkOnlyResources),
651        IosProjectPlatform::DeviceUnsigned,
652    )?;
653    write_ios_project(&generated, generated_root)
654}
655
656fn inspect_unsigned_archive_output(
657    request: &IosDeviceArchiveRequest,
658    plan: &IosDeviceArchivePlan,
659    logs: &Utf8Path,
660) -> Result<(UnsignedXcarchiveInspection, IosDeviceMachOValidation), AppleError> {
661    reject_symlink_components(&request.project_dir, &plan.archive_path)?;
662    reject_symlink_components(&request.project_dir, &plan.macho_validation.executable_path)?;
663    validate_real_directory(&plan.archive_path, "unsigned Xcode archive")?;
664    validate_regular_file(&plan.archive_path.join("Info.plist"), "archive Info.plist")?;
665    validate_real_directory(&plan.app_path, "unsigned archived application")?;
666    validate_regular_file(
667        &plan.macho_validation.executable_path,
668        "archived application executable",
669    )?;
670    let archive_inspection =
671        inspect_unsigned_xcarchive(&plan.archive_path, &plan.archive_expectation).map_err(
672            |error| AppleError::InvalidArtifact {
673                path: plan.archive_path.clone(),
674                reason: error.to_string(),
675            },
676        )?;
677
678    let architecture_output = run_command(
679        &plan.macho_validation.commands[0],
680        Some(&logs.join("03-lipo-architecture.log")),
681    )?;
682    let architecture = validate_architecture(
683        &architecture_output.stdout,
684        &plan.macho_validation.expected_architecture,
685        &plan.macho_validation.executable_path,
686    )?;
687    let platform_output = run_command(
688        &plan.macho_validation.commands[1],
689        Some(&logs.join("04-vtool-platform.log")),
690    )?;
691    let (platform, minimum_os, sdk) = validate_platform(
692        &platform_output.stdout,
693        &plan.macho_validation.expected_platform,
694        &plan.macho_validation.expected_minimum_os,
695        &plan.macho_validation.expected_sdk,
696        &plan.macho_validation.executable_path,
697    )?;
698    Ok((
699        archive_inspection,
700        IosDeviceMachOValidation {
701            executable_path: plan.macho_validation.executable_path.clone(),
702            architecture,
703            platform,
704            minimum_os,
705            sdk,
706        },
707    ))
708}
709
710fn inspection_command(
711    stage: &str,
712    toolchain: &IosDeviceToolchain,
713    project_dir: &Utf8Path,
714    args: Vec<String>,
715) -> CommandSpec {
716    let mut command = CommandSpec::new(stage, &toolchain.xcrun, project_dir);
717    command.args = args;
718    command.environment.insert(
719        "DEVELOPER_DIR".to_owned(),
720        toolchain.developer_dir.to_string(),
721    );
722    command
723}
724
725fn validate_request(request: &IosDeviceArchiveRequest) -> Result<(), AppleError> {
726    validate_binary_name(&request.binary_name)?;
727    if !request.project_dir.is_dir() {
728        return Err(AppleError::InvalidRequest(format!(
729            "project directory does not exist: {}",
730            request.project_dir
731        )));
732    }
733    if !request.project_dir.join("Cargo.toml").is_file() {
734        return Err(AppleError::InvalidRequest(format!(
735            "Cargo.toml was not found in project directory {}",
736            request.project_dir
737        )));
738    }
739    if let Some(package) = &request.package_name {
740        validate_cargo_selector("package", package)?;
741    }
742    for feature in &request.cargo_features {
743        validate_cargo_selector("feature", feature)?;
744    }
745    let _ = generate_ios_project_for_platform(
746        &IosProjectSpec::new(request.config.clone(), request.binary_name.clone()),
747        IosProjectPlatform::DeviceUnsigned,
748    )?;
749    Ok(())
750}
751
752fn validate_binary_name(name: &str) -> Result<(), AppleError> {
753    if name.is_empty()
754        || name == "."
755        || name == ".."
756        || !name
757            .chars()
758            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
759    {
760        return Err(AppleError::InvalidRequest(format!(
761            "binary name `{name}` must contain only ASCII letters, digits, `-`, or `_`"
762        )));
763    }
764    Ok(())
765}
766
767fn validate_cargo_selector(kind: &str, value: &str) -> Result<(), AppleError> {
768    if value.is_empty()
769        || !value
770            .chars()
771            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
772    {
773        return Err(AppleError::InvalidRequest(format!(
774            "Cargo {kind} `{value}` must contain only ASCII letters, digits, `-`, or `_`"
775        )));
776    }
777    Ok(())
778}
779
780const fn profile_directory(profile: AppleBuildProfile) -> &'static str {
781    match profile {
782        AppleBuildProfile::Debug => "debug",
783        AppleBuildProfile::Release => "release",
784    }
785}
786
787const fn xcode_configuration(profile: AppleBuildProfile) -> &'static str {
788    match profile {
789        AppleBuildProfile::Debug => "Debug",
790        AppleBuildProfile::Release => "Release",
791    }
792}
793
794fn validate_architecture(
795    output: &[u8],
796    expected: &str,
797    executable: &Utf8Path,
798) -> Result<String, AppleError> {
799    let output = std::str::from_utf8(output).map_err(|_| AppleError::InvalidArtifact {
800        path: executable.to_owned(),
801        reason: "`lipo -archs` emitted non-UTF-8 output".to_owned(),
802    })?;
803    let architectures = output.split_whitespace().collect::<Vec<_>>();
804    if architectures != [expected] {
805        return Err(AppleError::InvalidArtifact {
806            path: executable.to_owned(),
807            reason: format!(
808                "archived Mach-O architectures are {architectures:?}, expected only `{expected}`"
809            ),
810        });
811    }
812    Ok(expected.to_owned())
813}
814
815fn validate_platform(
816    output: &[u8],
817    expected_platform: &str,
818    expected_minimum_os: &str,
819    expected_sdk: &str,
820    executable: &Utf8Path,
821) -> Result<(String, String, String), AppleError> {
822    let output = std::str::from_utf8(output).map_err(|_| AppleError::InvalidArtifact {
823        path: executable.to_owned(),
824        reason: "`vtool -show-build` emitted non-UTF-8 output".to_owned(),
825    })?;
826    let platforms = output
827        .lines()
828        .filter_map(|line| line.trim().strip_prefix("platform "))
829        .collect::<Vec<_>>();
830    let minimum_versions = output
831        .lines()
832        .filter_map(|line| line.trim().strip_prefix("minos "))
833        .collect::<Vec<_>>();
834    let sdk_versions = output
835        .lines()
836        .filter_map(|line| line.trim().strip_prefix("sdk "))
837        .collect::<Vec<_>>();
838    if platforms != [expected_platform] {
839        return Err(AppleError::InvalidArtifact {
840            path: executable.to_owned(),
841            reason: format!(
842                "archived Mach-O build platforms are {platforms:?}, expected only `{expected_platform}`; Simulator output is not a physical-device artifact"
843            ),
844        });
845    }
846    let [minimum_os] = minimum_versions.as_slice() else {
847        return Err(AppleError::InvalidArtifact {
848            path: executable.to_owned(),
849            reason: format!(
850                "archived Mach-O minimum-OS evidence is {minimum_versions:?}, expected exactly one numeric value"
851            ),
852        });
853    };
854    let [sdk] = sdk_versions.as_slice() else {
855        return Err(AppleError::InvalidArtifact {
856            path: executable.to_owned(),
857            reason: format!(
858                "archived Mach-O SDK evidence is {sdk_versions:?}, expected exactly one numeric value"
859            ),
860        });
861    };
862    if parse_apple_version(minimum_os) != parse_apple_version(expected_minimum_os) {
863        return Err(AppleError::InvalidArtifact {
864            path: executable.to_owned(),
865            reason: format!(
866                "archived Mach-O minimum OS is `{minimum_os}`, expected `{expected_minimum_os}`"
867            ),
868        });
869    }
870    if parse_apple_version(sdk) != parse_apple_version(expected_sdk) {
871        return Err(AppleError::InvalidArtifact {
872            path: executable.to_owned(),
873            reason: format!("archived Mach-O SDK is `{sdk}`, expected `{expected_sdk}`"),
874        });
875    }
876    Ok((
877        expected_platform.to_owned(),
878        (*minimum_os).to_owned(),
879        (*sdk).to_owned(),
880    ))
881}
882
883fn validate_device_destination(output: &[u8], xcodebuild: &Utf8Path) -> Result<(), AppleError> {
884    let output = std::str::from_utf8(output).map_err(|_| AppleError::InvalidArtifact {
885        path: xcodebuild.to_owned(),
886        reason: "`xcodebuild -showdestinations` emitted non-UTF-8 output".to_owned(),
887    })?;
888    let available = output
889        .split_once("Available destinations for")
890        .map(|(_, suffix)| suffix)
891        .and_then(|suffix| suffix.split("Ineligible destinations for").next())
892        .unwrap_or_default();
893    let eligible = available.lines().any(|line| {
894        let line = line.trim();
895        line.starts_with('{')
896            && line.ends_with('}')
897            && line.contains("platform:iOS")
898            && (line.contains("name:Any iOS Device") || line.contains("generic:1"))
899            && !line.contains("error:")
900    });
901    if !eligible {
902        return Err(AppleError::InvalidRequest(
903            "Xcode reports no eligible generic physical-iOS destination. Install the matching iOS platform in Xcode Settings > Components; an SDK directory alone is insufficient."
904                .to_owned(),
905        ));
906    }
907    Ok(())
908}
909
910fn parse_apple_version(value: &str) -> Option<[u32; 3]> {
911    let components = value.split('.').collect::<Vec<_>>();
912    if components.is_empty()
913        || components.len() > 3
914        || components.iter().any(|component| {
915            component.is_empty() || !component.bytes().all(|byte| byte.is_ascii_digit())
916        })
917    {
918        return None;
919    }
920    let mut version = [0_u32; 3];
921    for (index, component) in components.iter().enumerate() {
922        version[index] = component.parse().ok()?;
923    }
924    Some(version)
925}
926
927fn prepare_directory(project_dir: &Utf8Path, directory: &Utf8Path) -> Result<(), AppleError> {
928    let ferry_root = project_dir.join("target").join(brand::TARGET_DIRECTORY);
929    ensure_descendant(&ferry_root, directory)?;
930    reject_symlink_components(project_dir, directory)?;
931    match fs::symlink_metadata(directory) {
932        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
933            return Err(AppleError::UnsafeGeneratedPath {
934                path: directory.to_owned(),
935                reason: "internal device build output must be a real directory".to_owned(),
936            });
937        }
938        Ok(_) => return Ok(()),
939        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
940        Err(source) => {
941            return Err(io_error(
942                "inspect physical-iOS output directory",
943                directory,
944                source,
945            ));
946        }
947    }
948    fs::create_dir_all(directory)
949        .map_err(|source| io_error("create physical-iOS output directory", directory, source))
950}
951
952fn reset_directory(project_dir: &Utf8Path, directory: &Utf8Path) -> Result<(), AppleError> {
953    let ferry_root = project_dir.join("target").join(brand::TARGET_DIRECTORY);
954    ensure_descendant(&ferry_root, directory)?;
955    reject_symlink_components(project_dir, directory)?;
956    if directory.exists() {
957        validate_real_directory(directory, "generated physical-iOS project")?;
958        fs::remove_dir_all(directory).map_err(|source| {
959            io_error("replace generated physical-iOS project", directory, source)
960        })?;
961    }
962    fs::create_dir_all(directory)
963        .map_err(|source| io_error("create generated physical-iOS project", directory, source))
964}
965
966fn remove_stale_archive(project_dir: &Utf8Path, archive: &Utf8Path) -> Result<(), AppleError> {
967    let ferry_root = project_dir.join("target").join(brand::TARGET_DIRECTORY);
968    ensure_descendant(&ferry_root, archive)?;
969    reject_symlink_components(project_dir, archive.parent().unwrap_or(archive))?;
970    match fs::symlink_metadata(archive) {
971        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
972            return Err(AppleError::UnsafeGeneratedPath {
973                path: archive.to_owned(),
974                reason: "stale physical-iOS archive must be a real directory".to_owned(),
975            });
976        }
977        Ok(_) => {
978            fs::remove_dir_all(archive)
979                .map_err(|source| io_error("remove stale physical-iOS archive", archive, source))?;
980        }
981        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
982        Err(source) => {
983            return Err(io_error(
984                "inspect stale physical-iOS archive",
985                archive,
986                source,
987            ));
988        }
989    }
990    Ok(())
991}
992
993fn ensure_descendant(root: &Utf8Path, path: &Utf8Path) -> Result<(), AppleError> {
994    let relative = path
995        .strip_prefix(root)
996        .map_err(|_| AppleError::UnsafeGeneratedPath {
997            path: path.to_owned(),
998            reason: format!("path is outside internal root `{root}`"),
999        })?;
1000    if relative.as_str().is_empty()
1001        || relative
1002            .components()
1003            .any(|component| !matches!(component, Utf8Component::Normal(_)))
1004    {
1005        return Err(AppleError::UnsafeGeneratedPath {
1006            path: path.to_owned(),
1007            reason: "path must be a non-empty normalized descendant".to_owned(),
1008        });
1009    }
1010    Ok(())
1011}
1012
1013fn reject_symlink_components(root: &Utf8Path, path: &Utf8Path) -> Result<(), AppleError> {
1014    let relative = path
1015        .strip_prefix(root)
1016        .map_err(|_| AppleError::UnsafeGeneratedPath {
1017            path: path.to_owned(),
1018            reason: format!("path is outside project root `{root}`"),
1019        })?;
1020    let mut current = root.to_owned();
1021    for component in relative.components() {
1022        let Utf8Component::Normal(component) = component else {
1023            return Err(AppleError::UnsafeGeneratedPath {
1024                path: path.to_owned(),
1025                reason: "path contains a traversal component".to_owned(),
1026            });
1027        };
1028        current.push(component);
1029        match fs::symlink_metadata(&current) {
1030            Ok(metadata) if metadata.file_type().is_symlink() => {
1031                return Err(AppleError::UnsafeGeneratedPath {
1032                    path: current,
1033                    reason: "internal device build output cannot traverse a symbolic link"
1034                        .to_owned(),
1035                });
1036            }
1037            Ok(_) => {}
1038            Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
1039            Err(source) => {
1040                return Err(io_error(
1041                    "inspect physical-iOS internal path",
1042                    &current,
1043                    source,
1044                ));
1045            }
1046        }
1047    }
1048    Ok(())
1049}
1050
1051fn validate_real_directory(path: &Utf8Path, kind: &str) -> Result<(), AppleError> {
1052    let metadata = fs::symlink_metadata(path)
1053        .map_err(|source| io_error("inspect physical-iOS archive directory", path, source))?;
1054    if metadata.file_type().is_symlink() || !metadata.is_dir() {
1055        return Err(AppleError::InvalidArtifact {
1056            path: path.to_owned(),
1057            reason: format!("{kind} is not a real directory"),
1058        });
1059    }
1060    Ok(())
1061}
1062
1063fn validate_regular_file(path: &Utf8Path, kind: &str) -> Result<(), AppleError> {
1064    let metadata = fs::symlink_metadata(path)
1065        .map_err(|source| io_error("inspect physical-iOS artifact file", path, source))?;
1066    if metadata.file_type().is_symlink() || !metadata.is_file() {
1067        return Err(AppleError::InvalidArtifact {
1068            path: path.to_owned(),
1069            reason: format!("{kind} is not a regular file"),
1070        });
1071    }
1072    Ok(())
1073}
1074
1075#[cfg(unix)]
1076fn make_executable(path: &Utf8Path) -> Result<(), AppleError> {
1077    use std::os::unix::fs::PermissionsExt;
1078
1079    let mut permissions = fs::metadata(path)
1080        .map_err(|source| io_error("inspect staged physical-iOS executable", path, source))?
1081        .permissions();
1082    permissions.set_mode(permissions.mode() | 0o755);
1083    fs::set_permissions(path, permissions).map_err(|source| {
1084        io_error(
1085            "mark staged physical-iOS executable executable",
1086            path,
1087            source,
1088        )
1089    })
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::*;
1095
1096    #[test]
1097    fn architecture_parser_rejects_extra_slices() {
1098        let executable = Utf8Path::new("Weather.app/weather");
1099        assert_eq!(
1100            validate_architecture(b"arm64\n", "arm64", executable).unwrap(),
1101            "arm64"
1102        );
1103        assert!(
1104            validate_architecture(
1105                b"Architectures in the fat file: weather are: arm64 x86_64\n",
1106                "arm64",
1107                executable
1108            )
1109            .is_err()
1110        );
1111    }
1112
1113    #[test]
1114    fn platform_parser_rejects_simulator_and_unproven_binaries() {
1115        let executable = Utf8Path::new("Weather.app/weather");
1116        assert_eq!(
1117            validate_platform(
1118                b"Load command 1\n      platform IOS\n        minos 16.0\n          sdk 26.0\n",
1119                "IOS",
1120                "16.0",
1121                "26.0",
1122                executable
1123            )
1124            .unwrap(),
1125            ("IOS".to_owned(), "16.0".to_owned(), "26.0".to_owned())
1126        );
1127        assert!(
1128            validate_platform(
1129                b"Load command 1\n      platform IOSSIMULATOR\n        minos 16.0\n          sdk 26.0\n",
1130                "IOS",
1131                "16.0",
1132                "26.0",
1133                executable
1134            )
1135            .is_err()
1136        );
1137        assert!(
1138            validate_platform(
1139                b"LC_VERSION_MIN_IPHONEOS\n",
1140                "IOS",
1141                "16.0",
1142                "26.0",
1143                executable
1144            )
1145            .is_err()
1146        );
1147        assert!(
1148            validate_platform(
1149                b"Load command 1\n      platform IOS\n        minos 15.0\n          sdk 26.0\n",
1150                "IOS",
1151                "16.0",
1152                "26.0",
1153                executable
1154            )
1155            .is_err()
1156        );
1157    }
1158
1159    #[test]
1160    fn destination_parser_requires_eligible_generic_iphoneos() {
1161        let xcodebuild = Utf8Path::new("/Applications/Xcode.app/xcodebuild");
1162        let eligible = b"Available destinations for the \"FerryApp\" scheme:\n\t{ platform:iOS, arch:arm64, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }\n";
1163        validate_device_destination(eligible, xcodebuild).unwrap();
1164
1165        let missing_component = b"Available destinations for the \"FerryApp\" scheme:\n\nIneligible destinations for the \"FerryApp\" scheme:\n\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device, error:iOS 26.5 is not installed. }\n";
1166        assert!(validate_device_destination(missing_component, xcodebuild).is_err());
1167        assert!(validate_device_destination(b"", xcodebuild).is_err());
1168    }
1169
1170    #[cfg(unix)]
1171    #[test]
1172    fn stale_archive_rejects_a_dangling_symlink() {
1173        use std::os::unix::fs::symlink;
1174
1175        let temporary = tempfile::tempdir().unwrap();
1176        let project = Utf8Path::from_path(temporary.path()).unwrap();
1177        let archive = project.join("target/ferry/ios/device/archives/release/app.xcarchive");
1178        fs::create_dir_all(archive.parent().unwrap()).unwrap();
1179        symlink(project.join("missing-outside"), &archive).unwrap();
1180
1181        assert!(matches!(
1182            remove_stale_archive(project, &archive),
1183            Err(AppleError::UnsafeGeneratedPath { .. })
1184        ));
1185    }
1186}