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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum AppleBuildProfile {
22 #[default]
24 Debug,
25 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "kebab-case")]
48pub enum ExtensionKind {
49 WidgetKit,
51 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#[derive(Clone, Debug, PartialEq)]
66pub struct IosSimulatorBuildRequest {
67 pub project_dir: Utf8PathBuf,
69 pub config: FerryConfig,
71 pub binary_name: String,
73 pub package_name: Option<String>,
75 pub profile: AppleBuildProfile,
77 pub cargo_features: Vec<String>,
79 pub dry_run: bool,
81}
82
83impl IosSimulatorBuildRequest {
84 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
104pub struct PlannedCopy {
105 pub source: Utf8PathBuf,
107 pub destination: Utf8PathBuf,
109}
110
111#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
113pub struct IosBuildPlan {
114 pub schema_version: u32,
116 pub rust_target: String,
118 pub profile: AppleBuildProfile,
120 pub generated_root: Utf8PathBuf,
122 pub cargo_target_dir: Utf8PathBuf,
124 pub xcode_derived_data: Utf8PathBuf,
126 pub commands: Vec<CommandSpec>,
128 pub generated_files: Vec<Utf8PathBuf>,
130 pub asset_fingerprint: String,
132 pub asset_packaging: IosAssetPackaging,
134 pub rust_binary_copy: PlannedCopy,
136 pub artifact_path: Utf8PathBuf,
138}
139
140#[derive(Debug)]
142pub struct IosBuildOutcome {
143 plan: IosBuildPlan,
145 artifact: Option<Utf8PathBuf>,
147 validation: Option<IosArtifactValidation>,
149}
150
151impl IosBuildOutcome {
152 #[must_use]
154 pub const fn plan(&self) -> &IosBuildPlan {
155 &self.plan
156 }
157
158 #[must_use]
160 pub fn artifact(&self) -> Option<&Utf8Path> {
161 self.artifact.as_deref()
162 }
163
164 #[must_use]
166 pub const fn validation(&self) -> Option<&IosArtifactValidation> {
167 self.validation.as_ref()
168 }
169}
170
171#[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
341pub 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(¤t) {
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", ¤t, 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}