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#[derive(Clone, Debug, PartialEq)]
23pub struct IosDeviceArchiveRequest {
24 pub project_dir: Utf8PathBuf,
26 pub config: FerryConfig,
28 pub binary_name: String,
30 pub package_name: Option<String>,
32 pub profile: AppleBuildProfile,
34 pub cargo_features: Vec<String>,
36 pub generate_debug_symbols: bool,
38 pub dry_run: bool,
40}
41
42impl IosDeviceArchiveRequest {
43 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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum IosDeviceArtifactDisposition {
73 UnsignedCompileOnly,
75}
76
77#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
79pub struct IosDeviceMachOValidationPlan {
80 pub executable_path: Utf8PathBuf,
82 pub expected_architecture: String,
84 pub expected_platform: String,
86 pub expected_minimum_os: String,
88 pub expected_sdk: String,
90 pub commands: Vec<CommandSpec>,
92}
93
94#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
96pub struct IosDeviceArchivePlan {
97 pub schema_version: u32,
99 pub rust_target: String,
101 pub sdk: String,
103 pub destination: String,
105 pub profile: AppleBuildProfile,
107 pub generate_debug_symbols: bool,
109 pub disposition: IosDeviceArtifactDisposition,
111 pub generated_root: Utf8PathBuf,
113 pub cargo_target_dir: Utf8PathBuf,
115 pub xcode_derived_data: Utf8PathBuf,
117 pub commands: Vec<CommandSpec>,
119 pub generated_files: Vec<Utf8PathBuf>,
121 pub asset_fingerprint: String,
123 pub rust_binary_copy: PlannedCopy,
125 pub archive_path: Utf8PathBuf,
127 pub app_path: Utf8PathBuf,
129 pub macho_validation: IosDeviceMachOValidationPlan,
131 pub archive_expectation: UnsignedXcarchiveExpectation,
133}
134
135#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
137pub struct IosDeviceMachOValidation {
138 pub executable_path: Utf8PathBuf,
140 pub architecture: String,
142 pub platform: String,
144 pub minimum_os: String,
146 pub sdk: String,
148}
149
150#[derive(Debug)]
152pub struct IosDeviceArchiveOutcome {
153 pub plan: IosDeviceArchivePlan,
155 pub archive: Option<Utf8PathBuf>,
157 pub app: Option<Utf8PathBuf>,
159 pub macho_validation: Option<IosDeviceMachOValidation>,
161 pub archive_inspection: Option<UnsignedXcarchiveInspection>,
163}
164
165pub 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
180pub 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
533pub 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 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(¤t) {
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 ¤t,
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}