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