Skip to main content

rustferry_apple/
build.rs

1use std::{fmt, fs};
2
3use camino::{Utf8Path, Utf8PathBuf};
4use rustferry_codegen::generate_platform_assets;
5use rustferry_core::{FerryConfig, ProjectAssets, brand};
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    AppleDiscoveryOptions, AppleError, AppleToolchain, CommandSpec, IOS_SIMULATOR_TARGET,
10    IosArtifactExpectation, IosArtifactValidation, IosAssetPackaging, IosExtensionExpectation,
11    IosProjectSpec, discover_apple,
12    error::io_error,
13    generate_ios_project,
14    project::{generate_ios_project_from_asset_set, validate_binary_name},
15    run_command, validate_ios_app, write_ios_project,
16};
17
18/// Cargo/Xcode build profile.
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum AppleBuildProfile {
22    /// Unoptimized development artifact.
23    #[default]
24    Debug,
25    /// Optimized distribution-style artifact without device signing.
26    Release,
27}
28
29impl AppleBuildProfile {
30    fn cargo_directory(self) -> &'static str {
31        match self {
32            Self::Debug => "debug",
33            Self::Release => "release",
34        }
35    }
36
37    fn xcode_configuration(self) -> &'static str {
38        match self {
39            Self::Debug => "Debug",
40            Self::Release => "Release",
41        }
42    }
43}
44
45/// Apple extension family generated as a native Xcode target.
46#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "kebab-case")]
48pub enum ExtensionKind {
49    /// `WidgetKit` home-screen widget.
50    WidgetKit,
51    /// `ActivityKit` Live Activity and Dynamic Island presentation.
52    ActivityKit,
53}
54
55impl fmt::Display for ExtensionKind {
56    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            Self::WidgetKit => formatter.write_str("WidgetKit extension"),
59            Self::ActivityKit => formatter.write_str("ActivityKit extension"),
60        }
61    }
62}
63
64/// Inputs for a build-only iOS Simulator application.
65#[derive(Clone, Debug, PartialEq)]
66pub struct IosSimulatorBuildRequest {
67    /// Rust project containing `Cargo.toml` and `ferry.toml`.
68    pub project_dir: Utf8PathBuf,
69    /// Validated application configuration.
70    pub config: FerryConfig,
71    /// Cargo binary target and final `CFBundleExecutable` name.
72    pub binary_name: String,
73    /// Optional Cargo package selector for workspace projects.
74    pub package_name: Option<String>,
75    /// Cargo/Xcode profile.
76    pub profile: AppleBuildProfile,
77    /// Explicit Cargo features enabled for the target build.
78    pub cargo_features: Vec<String>,
79    /// Plan only; perform no writes or subprocess execution after discovery.
80    pub dry_run: bool,
81}
82
83impl IosSimulatorBuildRequest {
84    /// Construct a debug simulator request using Cargo's selected package.
85    pub fn new(
86        project_dir: impl Into<Utf8PathBuf>,
87        config: FerryConfig,
88        binary_name: impl Into<String>,
89    ) -> Self {
90        Self {
91            project_dir: project_dir.into(),
92            config,
93            binary_name: binary_name.into(),
94            package_name: None,
95            profile: AppleBuildProfile::Debug,
96            cargo_features: Vec::new(),
97            dry_run: false,
98        }
99    }
100}
101
102/// One internal file copy represented explicitly in a dry-run plan.
103#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
104pub struct PlannedCopy {
105    /// Existing Cargo-produced file.
106    pub source: Utf8PathBuf,
107    /// Destination consumed by the generated Xcode build phase.
108    pub destination: Utf8PathBuf,
109}
110
111/// Complete, serializable simulator command and generated-file plan.
112#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
113pub struct IosBuildPlan {
114    /// Stable plan schema version.
115    pub schema_version: u32,
116    /// Rust compilation target.
117    pub rust_target: String,
118    /// Build profile.
119    pub profile: AppleBuildProfile,
120    /// Internal generated Xcode root.
121    pub generated_root: Utf8PathBuf,
122    /// Isolated Cargo target directory.
123    pub cargo_target_dir: Utf8PathBuf,
124    /// Xcode `DerivedData` directory.
125    pub xcode_derived_data: Utf8PathBuf,
126    /// Redacted command specifications, executed in order.
127    pub commands: Vec<CommandSpec>,
128    /// Deterministic generated file paths.
129    pub generated_files: Vec<Utf8PathBuf>,
130    /// Stable identity of the icon and splash bytes embedded as app resources.
131    pub asset_fingerprint: String,
132    /// Asset representation selected from installed Simulator capabilities.
133    pub asset_packaging: IosAssetPackaging,
134    /// Rust executable staging copy.
135    pub rust_binary_copy: PlannedCopy,
136    /// Expected final `.app` bundle.
137    pub artifact_path: Utf8PathBuf,
138}
139
140/// Build result. Dry-run results deliberately contain no artifact or validation claim.
141#[derive(Debug)]
142pub struct IosBuildOutcome {
143    /// Exact plan used by the operation.
144    plan: IosBuildPlan,
145    /// Built application path, absent for dry-run.
146    artifact: Option<Utf8PathBuf>,
147    /// Independent metadata/binary validation, absent for dry-run.
148    validation: Option<IosArtifactValidation>,
149}
150
151impl IosBuildOutcome {
152    /// Exact plan used by the operation.
153    #[must_use]
154    pub const fn plan(&self) -> &IosBuildPlan {
155        &self.plan
156    }
157
158    /// Built application path, absent for dry-run.
159    #[must_use]
160    pub fn artifact(&self) -> Option<&Utf8Path> {
161        self.artifact.as_deref()
162    }
163
164    /// Independent metadata and binary validation, absent for dry-run.
165    #[must_use]
166    pub const fn validation(&self) -> Option<&IosArtifactValidation> {
167        self.validation.as_ref()
168    }
169}
170
171/// Produce a deterministic, side-effect-free command plan using an already selected toolchain.
172///
173/// # Errors
174///
175/// Returns [`AppleError`] when request/config values are invalid or cannot be
176/// represented safely in Cargo, plist, or Xcode inputs.
177#[allow(clippy::too_many_lines)]
178pub fn plan_ios_simulator(
179    request: &IosSimulatorBuildRequest,
180    toolchain: &AppleToolchain,
181) -> Result<IosBuildPlan, AppleError> {
182    validate_request(request)?;
183    let assets = ProjectAssets::load(&request.project_dir)?;
184    plan_ios_simulator_with_assets(request, toolchain, &assets)
185}
186
187#[allow(clippy::too_many_lines)]
188fn plan_ios_simulator_with_assets(
189    request: &IosSimulatorBuildRequest,
190    toolchain: &AppleToolchain,
191    assets: &ProjectAssets,
192) -> Result<IosBuildPlan, AppleError> {
193    let asset_packaging = if toolchain.simulator_runtime_available {
194        IosAssetPackaging::CompiledCatalog
195    } else {
196        IosAssetPackaging::SdkOnlyResources
197    };
198    let generated = generate_ios_project(
199        &IosProjectSpec::new(request.config.clone(), request.binary_name.clone())
200            .with_assets(assets.clone())
201            .with_asset_packaging(asset_packaging),
202    )?;
203    let ios_root = request
204        .project_dir
205        .join("target")
206        .join(brand::TARGET_DIRECTORY)
207        .join("ios");
208    let generated_root = ios_root.join("generated");
209    let cargo_target_dir = ios_root.join("cargo");
210    let asset_cache_directory = match asset_packaging {
211        IosAssetPackaging::CompiledCatalog => "compiled-catalog",
212        IosAssetPackaging::SdkOnlyResources => "sdk-only-resources",
213    };
214    let xcode_derived_data = ios_root
215        .join("xcode")
216        .join(request.profile.cargo_directory())
217        .join(asset_cache_directory);
218    let artifact_directory = ios_root.join(request.profile.cargo_directory());
219    let artifact_path = artifact_directory.join(format!("{}.app", request.binary_name));
220    let cargo_binary = cargo_target_dir
221        .join(IOS_SIMULATOR_TARGET)
222        .join(request.profile.cargo_directory())
223        .join(&request.binary_name);
224    let staged_binary = generated_root.join(&request.binary_name);
225
226    let mut cargo = CommandSpec::new(
227        "cross-compile Rust executable for iOS Simulator",
228        &toolchain.cargo,
229        &request.project_dir,
230    );
231    cargo.args = vec![
232        "build".to_owned(),
233        "--target".to_owned(),
234        IOS_SIMULATOR_TARGET.to_owned(),
235        "--bin".to_owned(),
236        request.binary_name.clone(),
237    ];
238    if let Some(package) = &request.package_name {
239        cargo.args.push("--package".to_owned());
240        cargo.args.push(package.clone());
241    }
242    if request.profile == AppleBuildProfile::Release {
243        cargo.args.push("--release".to_owned());
244    }
245    if !request.cargo_features.is_empty() {
246        cargo.args.push("--features".to_owned());
247        cargo.args.push(request.cargo_features.join(","));
248    }
249    cargo
250        .environment
251        .insert("CARGO_TARGET_DIR".to_owned(), cargo_target_dir.to_string());
252    cargo.environment.insert(
253        "IPHONEOS_DEPLOYMENT_TARGET".to_owned(),
254        request.config.ios.min_version.clone(),
255    );
256    cargo.environment.insert(
257        "DEVELOPER_DIR".to_owned(),
258        toolchain.developer_dir.to_string(),
259    );
260    cargo
261        .environment
262        .insert("CARGO_TERM_COLOR".to_owned(), "never".to_owned());
263
264    let mut xcodebuild = CommandSpec::new(
265        "assemble and ad-hoc sign iOS Simulator application",
266        &toolchain.xcodebuild,
267        &request.project_dir,
268    );
269    xcodebuild.args = vec![
270        "-project".to_owned(),
271        generated_root.join("FerryHost.xcodeproj").to_string(),
272        "-target".to_owned(),
273        "FerryApp".to_owned(),
274        "-configuration".to_owned(),
275        request.profile.xcode_configuration().to_owned(),
276        "-sdk".to_owned(),
277        "iphonesimulator".to_owned(),
278        "AD_HOC_CODE_SIGNING_ALLOWED=YES".to_owned(),
279        "CODE_SIGN_IDENTITY=-".to_owned(),
280        "CODE_SIGNING_ALLOWED=YES".to_owned(),
281        "CODE_SIGNING_REQUIRED=YES".to_owned(),
282        "ARCHS=arm64".to_owned(),
283        "ONLY_ACTIVE_ARCH=NO".to_owned(),
284        format!("SYMROOT={xcode_derived_data}"),
285        format!("OBJROOT={}", xcode_derived_data.join("Intermediates")),
286        format!("CONFIGURATION_BUILD_DIR={artifact_directory}"),
287        "build".to_owned(),
288    ];
289    xcodebuild.environment.insert(
290        "DEVELOPER_DIR".to_owned(),
291        toolchain.developer_dir.to_string(),
292    );
293    xcodebuild
294        .environment
295        .insert("NSUnbufferedIO".to_owned(), "YES".to_owned());
296
297    let mut commands = vec![cargo, xcodebuild];
298    if request.config.extensions.widget.enabled
299        && request.config.extensions.widget.app_group.is_some()
300    {
301        commands.push(entitlement_resign_command(
302            "re-sign WidgetKit extension with application-group entitlements",
303            toolchain,
304            &request.project_dir,
305            &generated_root.join("WidgetExtension/Widget.entitlements"),
306            &artifact_path.join("PlugIns/FerryWidgetExtension.appex"),
307        ));
308        commands.push(entitlement_resign_command(
309            "re-sign iOS Simulator application with application-group entitlements",
310            toolchain,
311            &request.project_dir,
312            &generated_root.join("App.entitlements"),
313            &artifact_path,
314        ));
315    }
316
317    let generated_files = generated
318        .files
319        .keys()
320        .map(|relative| generated_root.join(relative))
321        .collect();
322    Ok(IosBuildPlan {
323        schema_version: 2,
324        rust_target: IOS_SIMULATOR_TARGET.to_owned(),
325        profile: request.profile,
326        generated_root,
327        cargo_target_dir,
328        xcode_derived_data,
329        commands,
330        generated_files,
331        asset_fingerprint: assets.fingerprint().to_owned(),
332        asset_packaging,
333        rust_binary_copy: PlannedCopy {
334            source: cargo_binary,
335            destination: staged_binary,
336        },
337        artifact_path,
338    })
339}
340
341/// Cross-compile the Rust executable, assemble a real `.app`, and validate it.
342///
343/// # Errors
344///
345/// Returns [`AppleError`] for invalid inputs, missing toolchain components,
346/// command failures/timeouts, unsafe generated paths, I/O failures, or an
347/// artifact that fails independent validation.
348pub fn build_ios_simulator(
349    request: &IosSimulatorBuildRequest,
350) -> Result<IosBuildOutcome, AppleError> {
351    validate_request(request)?;
352    let discovery = discover_apple(&AppleDiscoveryOptions {
353        current_dir: request.project_dir.clone(),
354        ..AppleDiscoveryOptions::from_environment()
355    })?;
356    let toolchain = discovery.select_toolchain()?;
357    let project_assets = ProjectAssets::load(&request.project_dir)?;
358    let plan = plan_ios_simulator_with_assets(request, &toolchain, &project_assets)?;
359    if request.dry_run {
360        return Ok(IosBuildOutcome {
361            plan,
362            artifact: None,
363            validation: None,
364        });
365    }
366    let logs = request
367        .project_dir
368        .join("target")
369        .join(brand::TARGET_DIRECTORY)
370        .join("ios")
371        .join("logs")
372        .join(request.profile.cargo_directory());
373    for directory in [
374        &plan.cargo_target_dir,
375        &plan.xcode_derived_data,
376        &logs,
377        plan.artifact_path.parent().unwrap_or(&plan.artifact_path),
378    ] {
379        prepare_output_directory(&request.project_dir, directory)?;
380    }
381    prepare_generated_root(&request.project_dir, &plan.generated_root)?;
382    let project_spec = IosProjectSpec::new(request.config.clone(), request.binary_name.clone())
383        .with_assets(project_assets.clone())
384        .with_asset_packaging(plan.asset_packaging);
385    let generated = match plan.asset_packaging {
386        IosAssetPackaging::CompiledCatalog => {
387            let generated_assets = generate_platform_assets(&request.project_dir, None)?;
388            generate_ios_project_from_asset_set(&project_spec, &generated_assets)?
389        }
390        IosAssetPackaging::SdkOnlyResources => generate_ios_project(&project_spec)?,
391    };
392    write_ios_project(&generated, &plan.generated_root)?;
393
394    run_command(&plan.commands[0], Some(&logs.join("01-cargo-build.log")))?;
395    if !plan.rust_binary_copy.source.is_file() {
396        return Err(AppleError::InvalidArtifact {
397            path: plan.rust_binary_copy.source.clone(),
398            reason: format!(
399                "Cargo succeeded but did not produce binary `{}` for target {IOS_SIMULATOR_TARGET}",
400                request.binary_name
401            ),
402        });
403    }
404    fs::copy(
405        &plan.rust_binary_copy.source,
406        &plan.rust_binary_copy.destination,
407    )
408    .map_err(|source| {
409        io_error(
410            "stage Rust executable for Xcode",
411            &plan.rust_binary_copy.destination,
412            source,
413        )
414    })?;
415    #[cfg(unix)]
416    make_executable(&plan.rust_binary_copy.destination)?;
417
418    remove_stale_artifact(&request.project_dir, &plan.artifact_path)?;
419    run_command(&plan.commands[1], Some(&logs.join("02-xcodebuild.log")))?;
420    for (index, command) in plan.commands.iter().enumerate().skip(2) {
421        run_command(
422            command,
423            Some(&logs.join(format!("{:02}-post-xcode-sign.log", index + 1))),
424        )?;
425    }
426    let validation = validate_ios_app(
427        &IosArtifactExpectation {
428            app_path: plan.artifact_path.clone(),
429            bundle_identifier: request.config.app.identifier.clone(),
430            executable_name: request.binary_name.clone(),
431            rust_binary: Some(plan.rust_binary_copy.source.clone()),
432            expected_architectures: vec!["arm64".to_owned()],
433            extensions: extension_expectations(&request.config),
434            deep_link_schemes: request.config.capabilities.deep_links.schemes.clone(),
435            app_group: request
436                .config
437                .extensions
438                .widget
439                .enabled
440                .then(|| request.config.extensions.widget.app_group.clone())
441                .flatten(),
442            log_dir: Some(logs.join("validation")),
443            project_assets: Some(project_assets),
444            asset_packaging: Some(plan.asset_packaging),
445        },
446        &toolchain,
447    )?;
448    Ok(IosBuildOutcome {
449        artifact: Some(plan.artifact_path.clone()),
450        plan,
451        validation: Some(validation),
452    })
453}
454
455fn entitlement_resign_command(
456    stage: &str,
457    toolchain: &AppleToolchain,
458    project_dir: &Utf8Path,
459    entitlements: &Utf8Path,
460    bundle: &Utf8Path,
461) -> CommandSpec {
462    let mut command = CommandSpec::new(stage, &toolchain.xcrun, project_dir);
463    command.environment.insert(
464        "DEVELOPER_DIR".to_owned(),
465        toolchain.developer_dir.to_string(),
466    );
467    command.args = vec![
468        "codesign".to_owned(),
469        "--force".to_owned(),
470        "--sign".to_owned(),
471        "-".to_owned(),
472        "--entitlements".to_owned(),
473        entitlements.to_string(),
474        "--timestamp=none".to_owned(),
475        "--generate-entitlement-der".to_owned(),
476        bundle.to_string(),
477    ];
478    command
479}
480
481fn validate_request(request: &IosSimulatorBuildRequest) -> Result<(), AppleError> {
482    validate_binary_name(&request.binary_name)?;
483    if !request.project_dir.is_dir() {
484        return Err(AppleError::InvalidRequest(format!(
485            "project directory does not exist: {}",
486            request.project_dir
487        )));
488    }
489    if !request.project_dir.join("Cargo.toml").is_file() {
490        return Err(AppleError::InvalidRequest(format!(
491            "Cargo.toml was not found in project directory {}",
492            request.project_dir
493        )));
494    }
495    if let Some(package) = &request.package_name {
496        validate_cargo_selector("package", package)?;
497    }
498    for feature in &request.cargo_features {
499        validate_cargo_selector("feature", feature)?;
500    }
501    let _ = generate_ios_project(&IosProjectSpec::new(
502        request.config.clone(),
503        request.binary_name.clone(),
504    ))?;
505    Ok(())
506}
507
508fn validate_cargo_selector(kind: &str, value: &str) -> Result<(), AppleError> {
509    if value.is_empty()
510        || !value
511            .chars()
512            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
513    {
514        return Err(AppleError::InvalidRequest(format!(
515            "Cargo {kind} `{value}` must contain only ASCII letters, digits, `-`, or `_`"
516        )));
517    }
518    Ok(())
519}
520
521fn extension_expectations(config: &FerryConfig) -> Vec<IosExtensionExpectation> {
522    let mut extensions = Vec::new();
523    if config.extensions.widget.enabled {
524        extensions.push(IosExtensionExpectation {
525            kind: ExtensionKind::WidgetKit,
526            bundle_name: "FerryWidgetExtension".to_owned(),
527            bundle_identifier: format!("{}.widget", config.app.identifier),
528            executable_name: "FerryWidgetExtension".to_owned(),
529            app_group: config.extensions.widget.app_group.clone(),
530        });
531    }
532    if config.extensions.live_activity.enabled {
533        extensions.push(IosExtensionExpectation {
534            kind: ExtensionKind::ActivityKit,
535            bundle_name: "FerryLiveActivityExtension".to_owned(),
536            bundle_identifier: format!("{}.liveactivity", config.app.identifier),
537            executable_name: "FerryLiveActivityExtension".to_owned(),
538            app_group: None,
539        });
540    }
541    extensions
542}
543
544fn prepare_generated_root(
545    project_dir: &Utf8Path,
546    generated_root: &Utf8Path,
547) -> Result<(), AppleError> {
548    let ferry_root = project_dir.join("target").join(brand::TARGET_DIRECTORY);
549    ensure_exact_descendant(&ferry_root, generated_root)?;
550    reject_symlink_components(project_dir, &ferry_root)?;
551    if generated_root.exists() {
552        let metadata = fs::symlink_metadata(generated_root)
553            .map_err(|source| io_error("inspect generated iOS root", generated_root, source))?;
554        if metadata.file_type().is_symlink() || !metadata.is_dir() {
555            return Err(AppleError::UnsafeGeneratedPath {
556                path: generated_root.to_owned(),
557                reason: "expected a real generated directory before replacement".to_owned(),
558            });
559        }
560        fs::remove_dir_all(generated_root).map_err(|source| {
561            io_error("replace generated iOS directory", generated_root, source)
562        })?;
563    }
564    fs::create_dir_all(generated_root)
565        .map_err(|source| io_error("create generated iOS directory", generated_root, source))
566}
567
568fn prepare_output_directory(
569    project_dir: &Utf8Path,
570    directory: &Utf8Path,
571) -> Result<(), AppleError> {
572    let ferry_root = project_dir.join("target").join(brand::TARGET_DIRECTORY);
573    ensure_exact_descendant(&ferry_root, directory)?;
574    reject_symlink_components(project_dir, directory)?;
575    match fs::symlink_metadata(directory) {
576        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
577            return Err(AppleError::UnsafeGeneratedPath {
578                path: directory.to_owned(),
579                reason: "internal build output must be a real directory".to_owned(),
580            });
581        }
582        Ok(_) => return Ok(()),
583        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
584        Err(source) => return Err(io_error("inspect iOS output directory", directory, source)),
585    }
586    fs::create_dir_all(directory)
587        .map_err(|source| io_error("create iOS output directory", directory, source))?;
588    let metadata = fs::symlink_metadata(directory)
589        .map_err(|source| io_error("inspect created iOS output directory", directory, source))?;
590    if metadata.file_type().is_symlink() || !metadata.is_dir() {
591        return Err(AppleError::UnsafeGeneratedPath {
592            path: directory.to_owned(),
593            reason: "created internal build output is not a real directory".to_owned(),
594        });
595    }
596    Ok(())
597}
598
599fn remove_stale_artifact(project_dir: &Utf8Path, artifact: &Utf8Path) -> Result<(), AppleError> {
600    let ferry_root = project_dir.join("target").join(brand::TARGET_DIRECTORY);
601    ensure_exact_descendant(&ferry_root, artifact)?;
602    reject_symlink_components(project_dir, artifact.parent().unwrap_or(artifact))?;
603    if artifact.exists() {
604        let metadata = fs::symlink_metadata(artifact)
605            .map_err(|source| io_error("inspect stale iOS artifact", artifact, source))?;
606        if metadata.file_type().is_symlink() || !metadata.is_dir() {
607            return Err(AppleError::UnsafeGeneratedPath {
608                path: artifact.to_owned(),
609                reason: "stale artifact is not a real application directory".to_owned(),
610            });
611        }
612        fs::remove_dir_all(artifact)
613            .map_err(|source| io_error("remove stale iOS artifact", artifact, source))?;
614    }
615    Ok(())
616}
617
618fn ensure_exact_descendant(root: &Utf8Path, path: &Utf8Path) -> Result<(), AppleError> {
619    let relative = path
620        .strip_prefix(root)
621        .map_err(|_| AppleError::UnsafeGeneratedPath {
622            path: path.to_owned(),
623            reason: format!("path is outside internal root `{root}`"),
624        })?;
625    if relative.as_str().is_empty()
626        || relative
627            .components()
628            .any(|component| !matches!(component, camino::Utf8Component::Normal(_)))
629    {
630        return Err(AppleError::UnsafeGeneratedPath {
631            path: path.to_owned(),
632            reason: "path must be a non-empty normalized descendant".to_owned(),
633        });
634    }
635    Ok(())
636}
637
638fn reject_symlink_components(root: &Utf8Path, path: &Utf8Path) -> Result<(), AppleError> {
639    let relative = path
640        .strip_prefix(root)
641        .map_err(|_| AppleError::UnsafeGeneratedPath {
642            path: path.to_owned(),
643            reason: format!("path is outside project root `{root}`"),
644        })?;
645    let mut current = root.to_owned();
646    for component in relative.components() {
647        let camino::Utf8Component::Normal(component) = component else {
648            return Err(AppleError::UnsafeGeneratedPath {
649                path: path.to_owned(),
650                reason: "path contains a traversal component".to_owned(),
651            });
652        };
653        current.push(component);
654        match fs::symlink_metadata(&current) {
655            Ok(metadata) if metadata.file_type().is_symlink() => {
656                return Err(AppleError::UnsafeGeneratedPath {
657                    path: current,
658                    reason: "internal build output cannot traverse a symbolic link".to_owned(),
659                });
660            }
661            Ok(_) => {}
662            Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
663            Err(source) => return Err(io_error("inspect internal build path", &current, source)),
664        }
665    }
666    Ok(())
667}
668
669#[cfg(unix)]
670fn make_executable(path: &Utf8Path) -> Result<(), AppleError> {
671    use std::os::unix::fs::PermissionsExt;
672
673    let mut permissions = fs::metadata(path)
674        .map_err(|source| io_error("inspect staged Rust executable", path, source))?
675        .permissions();
676    permissions.set_mode(permissions.mode() | 0o755);
677    fs::set_permissions(path, permissions)
678        .map_err(|source| io_error("mark staged Rust binary executable", path, source))
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684    use crate::{SimulatorSdk, generate_ios_project};
685
686    mod png_fixture {
687        include!(concat!(
688            env!("CARGO_MANIFEST_DIR"),
689            "/tests/fixtures/opaque_png.rs"
690        ));
691    }
692
693    use png_fixture::OPAQUE_1024_PNG as PNG;
694
695    fn fake_toolchain(root: &Utf8Path) -> AppleToolchain {
696        AppleToolchain {
697            developer_dir: root.join("Xcode.app/Contents/Developer"),
698            xcode_version: "Xcode 26.0".into(),
699            simulator_sdk: SimulatorSdk {
700                path: root.join("iPhoneSimulator.sdk"),
701                version: "26.0".into(),
702                build_version: Some("23A1".into()),
703            },
704            simulator_runtime_available: true,
705            cargo: root.join("bin/cargo"),
706            rustup: root.join("bin/rustup"),
707            xcodebuild: root.join("bin/xcodebuild"),
708            xcrun: root.join("bin/xcrun"),
709            plutil: root.join("bin/plutil"),
710            host_arch: "aarch64".into(),
711        }
712    }
713
714    fn request(root: &Utf8Path) -> IosSimulatorBuildRequest {
715        fs::write(
716            root.join("Cargo.toml"),
717            "[package]\nname='weather'\nversion='0.1.0'\n",
718        )
719        .unwrap();
720        fs::create_dir_all(root.join("assets")).unwrap();
721        fs::write(root.join("assets/icon.png"), PNG).unwrap();
722        fs::write(root.join("assets/splash.png"), PNG).unwrap();
723        IosSimulatorBuildRequest::new(
724            root,
725            FerryConfig::starter("Weather", "com.example.weather"),
726            "weather",
727        )
728    }
729
730    fn png_with_text_chunk(png: &[u8]) -> Vec<u8> {
731        const TEXT_CHUNK: &[u8] = &[
732            0, 0, 0, 3, b't', b'E', b'X', b't', b'x', 0, b'y', 0x45, 0xdb, 0xf3, 0x28,
733        ];
734        let iend_offset = png.len().checked_sub(12).expect("PNG IEND chunk");
735        let mut changed = png[..iend_offset].to_vec();
736        changed.extend_from_slice(TEXT_CHUNK);
737        changed.extend_from_slice(&png[iend_offset..]);
738        changed
739    }
740
741    #[test]
742    fn dry_run_plan_uses_argument_arrays_and_internal_paths() {
743        let temporary = tempfile::tempdir().unwrap();
744        let root = Utf8Path::from_path(temporary.path()).unwrap();
745        let plan = plan_ios_simulator(&request(root), &fake_toolchain(root)).unwrap();
746        assert_eq!(plan.commands.len(), 2);
747        assert_eq!(plan.commands[0].args[0], "build");
748        assert_eq!(plan.commands[0].args[2], IOS_SIMULATOR_TARGET);
749        assert!(
750            plan.commands[1]
751                .args
752                .contains(&"iphonesimulator".to_owned())
753        );
754        assert!(!plan.commands[1].args.contains(&"-destination".to_owned()));
755        for setting in [
756            "AD_HOC_CODE_SIGNING_ALLOWED=YES",
757            "CODE_SIGN_IDENTITY=-",
758            "CODE_SIGNING_ALLOWED=YES",
759            "CODE_SIGNING_REQUIRED=YES",
760        ] {
761            assert!(
762                plan.commands[1]
763                    .args
764                    .iter()
765                    .any(|argument| argument == setting)
766            );
767        }
768        assert!(!plan.commands[1].args.iter().any(|argument| matches!(
769            argument.as_str(),
770            "AD_HOC_CODE_SIGNING_ALLOWED=NO"
771                | "CODE_SIGNING_ALLOWED=NO"
772                | "CODE_SIGNING_REQUIRED=NO"
773        )));
774        assert!(plan.generated_root.starts_with(root.join("target/ferry")));
775        assert!(!plan.generated_files.is_empty());
776        assert_eq!(plan.asset_packaging, IosAssetPackaging::CompiledCatalog);
777    }
778
779    #[test]
780    fn missing_simulator_runtime_selects_validated_sdk_only_assets() {
781        let temporary = tempfile::tempdir().unwrap();
782        let root = Utf8Path::from_path(temporary.path()).unwrap();
783        let compiled_plan = plan_ios_simulator(&request(root), &fake_toolchain(root)).unwrap();
784        let mut toolchain = fake_toolchain(root);
785        toolchain.simulator_runtime_available = false;
786        let plan = plan_ios_simulator(&request(root), &toolchain).unwrap();
787
788        assert_eq!(plan.asset_packaging, IosAssetPackaging::SdkOnlyResources);
789        assert_ne!(plan.xcode_derived_data, compiled_plan.xcode_derived_data);
790        assert!(
791            plan.generated_files
792                .iter()
793                .any(|path| path.file_name() == Some("FerryIcon.png"))
794        );
795        assert!(
796            plan.generated_files
797                .iter()
798                .any(|path| path.file_name() == Some("FerrySplash.png"))
799        );
800        assert!(
801            !plan
802                .generated_files
803                .iter()
804                .any(|path| path.as_str().contains("Assets.xcassets"))
805        );
806    }
807
808    #[test]
809    fn plan_and_generation_share_one_asset_snapshot() {
810        let temporary = tempfile::tempdir().unwrap();
811        let root = Utf8Path::from_path(temporary.path()).unwrap();
812        let request = request(root);
813        let assets = ProjectAssets::load(root).unwrap();
814        let planned_fingerprint = assets.fingerprint().to_owned();
815        let planned_catalog = rustferry_codegen::render_platform_assets_for(
816            &assets,
817            rustferry_codegen::GeneratedAssetPlatform::Ios,
818        )
819        .unwrap()
820        .files;
821        let plan =
822            plan_ios_simulator_with_assets(&request, &fake_toolchain(root), &assets).unwrap();
823
824        fs::write(
825            root.join("assets/icon.png"),
826            png_with_text_chunk(assets.icon()),
827        )
828        .unwrap();
829        let changed_assets = ProjectAssets::load(root).unwrap();
830        assert_ne!(changed_assets.fingerprint(), planned_fingerprint);
831
832        let generated = generate_ios_project(
833            &IosProjectSpec::new(request.config, request.binary_name).with_assets(assets),
834        )
835        .unwrap();
836        assert_eq!(plan.asset_fingerprint, planned_fingerprint);
837        let expected_icon = planned_catalog
838            .into_iter()
839            .find_map(|(path, bytes)| {
840                (path == "ios/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-1x.png")
841                    .then_some(bytes)
842            })
843            .unwrap();
844        assert_eq!(
845            generated.files
846                [Utf8Path::new("Assets.xcassets/AppIcon.appiconset/AppIcon-1024-1x.png")],
847            expected_icon
848        );
849    }
850
851    #[test]
852    fn extension_plan_includes_native_targets_without_runtime_blockers() {
853        let temporary = tempfile::tempdir().unwrap();
854        let root = Utf8Path::from_path(temporary.path()).unwrap();
855        let mut request = request(root);
856        request.config.extensions.widget.enabled = true;
857        request.config.extensions.widget.app_group = Some("group.com.example.weather".into());
858        let plan = plan_ios_simulator(&request, &fake_toolchain(root)).unwrap();
859        assert_eq!(plan.commands.len(), 4);
860        let widget_sign = &plan.commands[2];
861        let app_sign = &plan.commands[3];
862        assert!(widget_sign.stage.contains("WidgetKit extension"));
863        assert!(app_sign.stage.contains("Simulator application"));
864        for command in [widget_sign, app_sign] {
865            assert_eq!(command.program, root.join("bin/xcrun"));
866            assert_eq!(command.args[0], "codesign");
867            assert_eq!(command.args[1..4], ["--force", "--sign", "-"]);
868            assert!(command.args.contains(&"--timestamp=none".to_owned()));
869            assert!(
870                command
871                    .args
872                    .contains(&"--generate-entitlement-der".to_owned())
873            );
874            assert!(!command.args.contains(&"--deep".to_owned()));
875            assert_eq!(
876                command.timeout_seconds,
877                crate::DEFAULT_COMMAND_TIMEOUT.as_secs()
878            );
879            assert!(command.redacted_args.is_empty());
880            assert!(command.redacted_environment.is_empty());
881        }
882        assert_eq!(
883            widget_sign.args[5],
884            plan.generated_root
885                .join("WidgetExtension/Widget.entitlements")
886                .to_string()
887        );
888        assert_eq!(
889            widget_sign.args[8],
890            plan.artifact_path
891                .join("PlugIns/FerryWidgetExtension.appex")
892                .to_string()
893        );
894        assert_eq!(
895            app_sign.args[5],
896            plan.generated_root.join("App.entitlements").to_string()
897        );
898        assert_eq!(app_sign.args[8], plan.artifact_path.to_string());
899        assert!(
900            plan.generated_files
901                .iter()
902                .any(|path| path.ends_with("WidgetExtension/Widget.swift"))
903        );
904        let generated =
905            generate_ios_project(&IosProjectSpec::new(request.config, request.binary_name))
906                .unwrap();
907        assert!(
908            generated
909                .files
910                .contains_key(Utf8Path::new("WidgetExtension/Widget.swift"))
911        );
912    }
913
914    #[test]
915    fn rejects_feature_argument_injection() {
916        let temporary = tempfile::tempdir().unwrap();
917        let root = Utf8Path::from_path(temporary.path()).unwrap();
918        let mut request = request(root);
919        request.cargo_features.push("safe --release".into());
920        assert!(matches!(
921            plan_ios_simulator(&request, &fake_toolchain(root)),
922            Err(AppleError::InvalidRequest(_))
923        ));
924    }
925
926    #[cfg(unix)]
927    #[test]
928    fn output_preparation_rejects_symlinked_cargo_directory() {
929        use std::os::unix::fs::symlink;
930
931        let temporary = tempfile::tempdir().unwrap();
932        let root = Utf8Path::from_path(temporary.path()).unwrap();
933        fs::create_dir_all(root.join("target/ferry/ios")).unwrap();
934        let outside = root.join("outside");
935        fs::create_dir(&outside).unwrap();
936        fs::write(outside.join("marker"), "preserve").unwrap();
937        symlink(&outside, root.join("target/ferry/ios/cargo")).unwrap();
938
939        let error = prepare_output_directory(root, &root.join("target/ferry/ios/cargo"))
940            .expect_err("symlinked Cargo output must be rejected");
941        assert!(matches!(error, AppleError::UnsafeGeneratedPath { .. }));
942        assert_eq!(
943            fs::read_to_string(outside.join("marker")).unwrap(),
944            "preserve"
945        );
946    }
947}