1use std::env;
2
3use camino::{Utf8Path, Utf8PathBuf};
4use serde::{Deserialize, Serialize};
5
6use crate::{AppleError, CommandSpec, IOS_DEVICE_TARGET, IOS_SIMULATOR_TARGET, run_command};
7
8#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct AppleDiscoveryOptions {
11 pub developer_dir: Option<Utf8PathBuf>,
13 pub executable_search_paths: Vec<Utf8PathBuf>,
15 pub current_dir: Utf8PathBuf,
17 pub host_os: String,
19 pub host_arch: String,
21}
22
23impl AppleDiscoveryOptions {
24 pub fn from_environment() -> Self {
26 let developer_dir = env::var_os("DEVELOPER_DIR")
27 .and_then(|value| Utf8PathBuf::from_path_buf(value.into()).ok());
28 let executable_search_paths = env::var_os("PATH")
29 .map(|value| {
30 env::split_paths(&value)
31 .filter_map(|path| Utf8PathBuf::from_path_buf(path).ok())
32 .collect()
33 })
34 .unwrap_or_default();
35 let current_dir = env::current_dir()
36 .ok()
37 .and_then(|path| Utf8PathBuf::from_path_buf(path).ok())
38 .unwrap_or_else(|| Utf8PathBuf::from("."));
39 Self {
40 developer_dir,
41 executable_search_paths,
42 current_dir,
43 host_os: env::consts::OS.to_owned(),
44 host_arch: env::consts::ARCH.to_owned(),
45 }
46 }
47}
48
49impl Default for AppleDiscoveryOptions {
50 fn default() -> Self {
51 Self::from_environment()
52 }
53}
54
55#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
57pub struct AppleHostTools {
58 pub cargo: Option<Utf8PathBuf>,
60 pub rustup: Option<Utf8PathBuf>,
62 pub xcode_select: Option<Utf8PathBuf>,
64 pub xcodebuild: Option<Utf8PathBuf>,
66 pub xcrun: Option<Utf8PathBuf>,
68 pub plutil: Option<Utf8PathBuf>,
70}
71
72#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
74pub struct SimulatorSdk {
75 pub path: Utf8PathBuf,
77 pub version: String,
79 pub build_version: Option<String>,
81}
82
83#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
85pub struct IosDeviceSdk {
86 pub path: Utf8PathBuf,
88 pub version: String,
90 pub build_version: String,
92}
93
94#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
96pub struct SimulatorRuntime {
97 pub identifier: String,
99 pub name: String,
101 pub version: String,
103 pub available: bool,
105}
106
107#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
109pub struct AppleDiscovery {
110 pub host_os: String,
112 pub host_arch: String,
114 pub developer_dir: Option<Utf8PathBuf>,
116 pub xcode_version: Option<String>,
118 pub simulator_sdk: Option<SimulatorSdk>,
120 pub device_sdk: Option<IosDeviceSdk>,
122 pub simulator_runtimes: Vec<SimulatorRuntime>,
124 pub installed_rust_targets: Vec<String>,
126 pub host_tools: AppleHostTools,
128 pub executable_search_paths: Vec<Utf8PathBuf>,
130}
131
132impl AppleDiscovery {
133 pub fn select_toolchain(&self) -> Result<AppleToolchain, AppleError> {
140 if self.host_os != "macos" {
141 return Err(AppleError::UnsupportedHost {
142 host: self.host_os.clone(),
143 });
144 }
145 let developer_dir = self
146 .developer_dir
147 .clone()
148 .filter(|path| is_full_xcode(path))
149 .ok_or_else(|| AppleError::XcodeMissing {
150 developer_dir: self.developer_dir.clone(),
151 fix: "Install full Xcode, run `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer` yourself if needed, then `cargo ferry doctor`. cargo-ferry never invokes sudo.".to_owned(),
152 })?;
153 let simulator_sdk = self
154 .simulator_sdk
155 .clone()
156 .ok_or_else(|| AppleError::SimulatorSdkMissing {
157 fix: "Open Xcode Settings > Platforms, install an iOS platform SDK, then run `cargo ferry doctor`. A Simulator device/runtime is not required for build-only use.".to_owned(),
158 })?;
159 let cargo = require_tool(
160 "cargo",
161 self.host_tools.cargo.as_ref(),
162 &self.executable_search_paths,
163 "Install Rust with rustup, then run `cargo ferry doctor`.",
164 )?;
165 let rustup = require_tool(
166 "rustup",
167 self.host_tools.rustup.as_ref(),
168 &self.executable_search_paths,
169 "Install rustup so the iOS Simulator Rust target can be verified.",
170 )?;
171 let xcodebuild = require_tool(
172 "xcodebuild",
173 self.host_tools.xcodebuild.as_ref(),
174 &self.executable_search_paths,
175 "Install full Xcode and select its developer directory.",
176 )?;
177 let xcrun = require_tool(
178 "xcrun",
179 self.host_tools.xcrun.as_ref(),
180 &self.executable_search_paths,
181 "Install full Xcode and select its developer directory.",
182 )?;
183 let plutil = require_tool(
184 "plutil",
185 self.host_tools.plutil.as_ref(),
186 &self.executable_search_paths,
187 "Restore the macOS command-line tools or reinstall Xcode.",
188 )?;
189 if !self
190 .installed_rust_targets
191 .iter()
192 .any(|target| target == IOS_SIMULATOR_TARGET)
193 {
194 return Err(AppleError::RustTargetMissing {
195 target: IOS_SIMULATOR_TARGET.to_owned(),
196 });
197 }
198 Ok(AppleToolchain {
199 developer_dir,
200 xcode_version: self.xcode_version.clone().unwrap_or_default(),
201 simulator_sdk,
202 simulator_runtime_available: has_available_ios_runtime(&self.simulator_runtimes),
203 cargo,
204 rustup,
205 xcodebuild,
206 xcrun,
207 plutil,
208 host_arch: self.host_arch.clone(),
209 })
210 }
211
212 pub fn select_device_toolchain(&self) -> Result<IosDeviceToolchain, AppleError> {
221 if self.host_os != "macos" {
222 return Err(AppleError::UnsupportedHost {
223 host: self.host_os.clone(),
224 });
225 }
226 let developer_dir = self
227 .developer_dir
228 .as_ref()
229 .and_then(|path| path.canonicalize_utf8().ok())
230 .filter(|path| is_full_device_xcode(path))
231 .ok_or_else(|| AppleError::XcodeMissing {
232 developer_dir: self.developer_dir.clone(),
233 fix: "Install full Xcode with the iOS platform, then select that exact developer directory. cargo-ferry never invokes sudo or changes xcode-select.".to_owned(),
234 })?;
235 let device_sdk = self
236 .device_sdk
237 .as_ref()
238 .and_then(|sdk| canonical_device_sdk(sdk, &developer_dir))
239 .ok_or_else(|| {
240 AppleError::InvalidRequest(
241 "an iPhoneOS SDK with path, version, and build-version evidence was not found through `xcrun --sdk iphoneos`; install the iOS platform in the selected Xcode installation"
242 .to_owned(),
243 )
244 })?;
245 let xcode_version = self
246 .xcode_version
247 .clone()
248 .filter(|value| !value.trim().is_empty())
249 .ok_or_else(|| {
250 AppleError::InvalidRequest(
251 "the selected Xcode installation did not report version evidence".to_owned(),
252 )
253 })?;
254 let cargo = require_absolute_tool(
255 "cargo",
256 self.host_tools.cargo.as_ref(),
257 &self.executable_search_paths,
258 "Install Rust with rustup and expose cargo through an absolute PATH entry.",
259 )?;
260 let rustup = require_absolute_tool(
261 "rustup",
262 self.host_tools.rustup.as_ref(),
263 &self.executable_search_paths,
264 "Install rustup and expose it through an absolute PATH entry so the physical-iPhone target can be verified.",
265 )?;
266 let xcodebuild = require_absolute_tool(
267 "xcodebuild",
268 self.host_tools.xcodebuild.as_ref(),
269 &self.executable_search_paths,
270 "Install full Xcode and expose xcodebuild through an absolute PATH entry.",
271 )?;
272 let xcrun = require_absolute_tool(
273 "xcrun",
274 self.host_tools.xcrun.as_ref(),
275 &self.executable_search_paths,
276 "Restore the macOS command-line tools and expose xcrun through an absolute PATH entry.",
277 )?;
278 let plutil = require_absolute_tool(
279 "plutil",
280 self.host_tools.plutil.as_ref(),
281 &self.executable_search_paths,
282 "Restore the macOS command-line tools and expose plutil through an absolute PATH entry.",
283 )?;
284 if !self
285 .installed_rust_targets
286 .iter()
287 .any(|target| target == IOS_DEVICE_TARGET)
288 {
289 return Err(AppleError::RustTargetMissing {
290 target: IOS_DEVICE_TARGET.to_owned(),
291 });
292 }
293 Ok(IosDeviceToolchain {
294 developer_dir,
295 xcode_version,
296 device_sdk,
297 cargo,
298 rustup,
299 xcodebuild,
300 xcrun,
301 plutil,
302 host_arch: self.host_arch.clone(),
303 })
304 }
305}
306
307#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
309pub struct AppleToolchain {
310 pub developer_dir: Utf8PathBuf,
312 pub xcode_version: String,
314 pub simulator_sdk: SimulatorSdk,
316 pub simulator_runtime_available: bool,
318 pub cargo: Utf8PathBuf,
320 pub rustup: Utf8PathBuf,
322 pub xcodebuild: Utf8PathBuf,
324 pub xcrun: Utf8PathBuf,
326 pub plutil: Utf8PathBuf,
328 pub host_arch: String,
330}
331
332#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
334pub struct IosDeviceToolchain {
335 pub developer_dir: Utf8PathBuf,
337 pub xcode_version: String,
339 pub device_sdk: IosDeviceSdk,
341 pub cargo: Utf8PathBuf,
343 pub rustup: Utf8PathBuf,
345 pub xcodebuild: Utf8PathBuf,
347 pub xcrun: Utf8PathBuf,
349 pub plutil: Utf8PathBuf,
351 pub host_arch: String,
353}
354
355#[allow(clippy::too_many_lines)]
361pub fn discover_apple(options: &AppleDiscoveryOptions) -> Result<AppleDiscovery, AppleError> {
362 if !options.current_dir.is_dir() {
363 return Err(AppleError::InvalidRequest(format!(
364 "Apple discovery working directory does not exist: {}",
365 options.current_dir
366 )));
367 }
368 let mut search_paths = options.executable_search_paths.clone();
369 for conventional in ["/usr/bin", "/bin", "/usr/local/bin", "/opt/homebrew/bin"] {
370 let path = Utf8PathBuf::from(conventional);
371 if !search_paths.contains(&path) {
372 search_paths.push(path);
373 }
374 }
375 let mut tools = AppleHostTools {
376 cargo: find_executable("cargo", &search_paths),
377 rustup: find_executable("rustup", &search_paths),
378 xcode_select: find_executable("xcode-select", &search_paths),
379 xcodebuild: find_executable("xcodebuild", &search_paths),
380 xcrun: find_executable("xcrun", &search_paths),
381 plutil: find_executable("plutil", &search_paths),
382 };
383
384 let developer_dir = options.developer_dir.clone().or_else(|| {
385 tools.xcode_select.as_ref().and_then(|program| {
386 probe_text(
387 "select Xcode developer directory",
388 program,
389 &["-p"],
390 &options.current_dir,
391 None,
392 )
393 .map(Utf8PathBuf::from)
394 })
395 });
396 let developer_environment = developer_dir
397 .as_ref()
398 .map(|path| ("DEVELOPER_DIR", path.as_str()));
399
400 let xcode_version = tools.xcodebuild.as_ref().and_then(|program| {
401 probe_text(
402 "read Xcode version",
403 program,
404 &["-version"],
405 &options.current_dir,
406 developer_environment,
407 )
408 });
409 let simulator_sdk = tools.xcrun.as_ref().and_then(|xcrun| {
410 discover_simulator_sdk(xcrun, &options.current_dir, developer_environment)
411 });
412 let device_sdk = tools
413 .xcrun
414 .as_ref()
415 .and_then(|xcrun| discover_device_sdk(xcrun, &options.current_dir, developer_environment));
416 let simulator_runtimes = tools
417 .xcrun
418 .as_ref()
419 .and_then(|xcrun| {
420 probe_text(
421 "list iOS Simulator runtimes",
422 xcrun,
423 &["simctl", "list", "runtimes", "--json"],
424 &options.current_dir,
425 developer_environment,
426 )
427 })
428 .map_or_else(Vec::new, |source| parse_runtimes(&source));
429 if let Some(rustup) = tools.rustup.as_ref()
430 && let Some(cargo) = probe_text(
431 "locate active Cargo executable",
432 rustup,
433 &["which", "cargo"],
434 &options.current_dir,
435 None,
436 )
437 .map(Utf8PathBuf::from)
438 .filter(|path| path.is_absolute() && is_executable_file(path))
439 {
440 tools.cargo = Some(cargo);
441 }
442 let installed_rust_targets = tools
443 .rustup
444 .as_ref()
445 .and_then(|rustup| {
446 probe_text(
447 "list installed Rust targets",
448 rustup,
449 &["target", "list", "--installed"],
450 &options.current_dir,
451 None,
452 )
453 })
454 .map(|output| {
455 output
456 .lines()
457 .map(str::trim)
458 .filter(|line| !line.is_empty())
459 .map(ToOwned::to_owned)
460 .collect()
461 })
462 .unwrap_or_default();
463
464 if tools.plutil.is_none()
465 && let Some(xcrun) = &tools.xcrun
466 {
467 tools.plutil = probe_text(
468 "locate plutil",
469 xcrun,
470 &["--find", "plutil"],
471 &options.current_dir,
472 developer_environment,
473 )
474 .map(Utf8PathBuf::from)
475 .filter(|path| path.is_file());
476 }
477
478 Ok(AppleDiscovery {
479 host_os: options.host_os.clone(),
480 host_arch: options.host_arch.clone(),
481 developer_dir,
482 xcode_version,
483 simulator_sdk,
484 device_sdk,
485 simulator_runtimes,
486 installed_rust_targets,
487 host_tools: tools,
488 executable_search_paths: search_paths,
489 })
490}
491
492fn discover_device_sdk(
493 xcrun: &Utf8Path,
494 current_dir: &Utf8Path,
495 developer_environment: Option<(&str, &str)>,
496) -> Option<IosDeviceSdk> {
497 let path = probe_text(
498 "locate physical-iPhone SDK",
499 xcrun,
500 &["--sdk", "iphoneos", "--show-sdk-path"],
501 current_dir,
502 developer_environment,
503 )
504 .map(Utf8PathBuf::from)
505 .filter(|path| path.is_absolute())?
506 .canonicalize_utf8()
507 .ok()
508 .filter(|path| path.is_dir())?;
509 let version = probe_text(
510 "read physical-iPhone SDK version",
511 xcrun,
512 &["--sdk", "iphoneos", "--show-sdk-version"],
513 current_dir,
514 developer_environment,
515 )?;
516 let build_version = probe_text(
517 "read physical-iPhone SDK build version",
518 xcrun,
519 &["--sdk", "iphoneos", "--show-sdk-build-version"],
520 current_dir,
521 developer_environment,
522 )?;
523 Some(IosDeviceSdk {
524 path,
525 version,
526 build_version,
527 })
528}
529
530fn discover_simulator_sdk(
531 xcrun: &Utf8Path,
532 current_dir: &Utf8Path,
533 developer_environment: Option<(&str, &str)>,
534) -> Option<SimulatorSdk> {
535 let path = probe_text(
536 "locate iPhone Simulator SDK",
537 xcrun,
538 &["--sdk", "iphonesimulator", "--show-sdk-path"],
539 current_dir,
540 developer_environment,
541 )
542 .map(Utf8PathBuf::from)
543 .filter(|path| path.is_dir())?;
544 let version = probe_text(
545 "read iPhone Simulator SDK version",
546 xcrun,
547 &["--sdk", "iphonesimulator", "--show-sdk-version"],
548 current_dir,
549 developer_environment,
550 )?;
551 let build_version = probe_text(
552 "read iPhone Simulator SDK build version",
553 xcrun,
554 &["--sdk", "iphonesimulator", "--show-sdk-build-version"],
555 current_dir,
556 developer_environment,
557 );
558 Some(SimulatorSdk {
559 path,
560 version,
561 build_version,
562 })
563}
564
565fn parse_runtimes(source: &str) -> Vec<SimulatorRuntime> {
566 let Ok(value) = serde_json::from_str::<serde_json::Value>(source) else {
567 return Vec::new();
568 };
569 let mut runtimes = value
570 .get("runtimes")
571 .and_then(serde_json::Value::as_array)
572 .into_iter()
573 .flatten()
574 .filter_map(|runtime| {
575 Some(SimulatorRuntime {
576 identifier: runtime.get("identifier")?.as_str()?.to_owned(),
577 name: runtime.get("name")?.as_str()?.to_owned(),
578 version: runtime
579 .get("version")
580 .and_then(serde_json::Value::as_str)
581 .unwrap_or_default()
582 .to_owned(),
583 available: runtime
584 .get("isAvailable")
585 .and_then(serde_json::Value::as_bool)
586 .unwrap_or(false),
587 })
588 })
589 .collect::<Vec<_>>();
590 runtimes.sort_by(|left, right| left.identifier.cmp(&right.identifier));
591 runtimes
592}
593
594fn has_available_ios_runtime(runtimes: &[SimulatorRuntime]) -> bool {
595 runtimes.iter().any(|runtime| {
596 runtime.available
597 && runtime
598 .identifier
599 .starts_with("com.apple.CoreSimulator.SimRuntime.iOS-")
600 })
601}
602
603fn probe_text(
604 stage: &str,
605 program: &Utf8Path,
606 args: &[&str],
607 current_dir: &Utf8Path,
608 environment: Option<(&str, &str)>,
609) -> Option<String> {
610 let mut spec = CommandSpec::new(stage, program, current_dir);
611 spec.args = args.iter().map(|argument| (*argument).to_owned()).collect();
612 spec.timeout_seconds = 30;
613 if let Some((name, value)) = environment {
614 spec.environment.insert(name.to_owned(), value.to_owned());
615 }
616 let output = run_command(&spec, None).ok()?;
617 let value = String::from_utf8(output.stdout).ok()?;
618 let trimmed = value.trim();
619 (!trimmed.is_empty()).then(|| trimmed.to_owned())
620}
621
622fn find_executable(name: &str, search_paths: &[Utf8PathBuf]) -> Option<Utf8PathBuf> {
623 search_paths
624 .iter()
625 .map(|directory| directory.join(name))
626 .find(|candidate| candidate.is_file())
627}
628
629fn is_full_xcode(developer_dir: &Utf8Path) -> bool {
630 developer_dir
631 .join("Platforms/iPhoneSimulator.platform")
632 .is_dir()
633 && (developer_dir.join("SDKs").is_dir()
634 || developer_dir
635 .join("Platforms/iPhoneSimulator.platform/Developer/SDKs")
636 .is_dir())
637}
638
639fn is_full_device_xcode(developer_dir: &Utf8Path) -> bool {
640 developer_dir.is_absolute()
641 && developer_dir.is_dir()
642 && developer_dir.join("Platforms/iPhoneOS.platform").is_dir()
643 && developer_dir
644 .join("Platforms/iPhoneOS.platform/Developer/SDKs")
645 .is_dir()
646}
647
648fn canonical_device_sdk(sdk: &IosDeviceSdk, developer_dir: &Utf8Path) -> Option<IosDeviceSdk> {
649 if sdk.version.trim().is_empty() || sdk.build_version.trim().is_empty() {
650 return None;
651 }
652 let path = sdk.path.canonicalize_utf8().ok()?;
653 if !path.is_dir() || !path.starts_with(developer_dir) {
654 return None;
655 }
656 Some(IosDeviceSdk {
657 path,
658 version: sdk.version.clone(),
659 build_version: sdk.build_version.clone(),
660 })
661}
662
663fn require_tool(
664 name: &str,
665 value: Option<&Utf8PathBuf>,
666 searched: &[Utf8PathBuf],
667 fix: &str,
668) -> Result<Utf8PathBuf, AppleError> {
669 value.cloned().ok_or_else(|| AppleError::ToolMissing {
670 tool: name.to_owned(),
671 searched: searched
672 .iter()
673 .map(|directory| directory.join(name))
674 .collect(),
675 fix: fix.to_owned(),
676 })
677}
678
679fn require_absolute_tool(
680 name: &str,
681 value: Option<&Utf8PathBuf>,
682 searched: &[Utf8PathBuf],
683 fix: &str,
684) -> Result<Utf8PathBuf, AppleError> {
685 value
686 .filter(|path| path.is_absolute() && is_executable_file(path))
687 .cloned()
688 .ok_or_else(|| AppleError::ToolMissing {
689 tool: name.to_owned(),
690 searched: searched
691 .iter()
692 .map(|directory| directory.join(name))
693 .collect(),
694 fix: fix.to_owned(),
695 })
696}
697
698fn is_executable_file(path: &Utf8Path) -> bool {
699 let Ok(metadata) = path.metadata() else {
700 return false;
701 };
702 if !metadata.is_file() {
703 return false;
704 }
705 #[cfg(unix)]
706 {
707 use std::os::unix::fs::PermissionsExt as _;
708 metadata.permissions().mode() & 0o111 != 0
709 }
710 #[cfg(not(unix))]
711 true
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 fn executable(path: &Utf8Path) {
719 std::fs::write(path, "fixture").unwrap();
720 #[cfg(unix)]
721 {
722 use std::os::unix::fs::PermissionsExt as _;
723
724 let mut permissions = path.metadata().unwrap().permissions();
725 permissions.set_mode(0o755);
726 std::fs::set_permissions(path, permissions).unwrap();
727 }
728 }
729
730 fn device_discovery_fixture() -> (tempfile::TempDir, AppleDiscovery) {
731 let temporary = tempfile::tempdir().unwrap();
732 let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
733 let developer_dir = root.join("Xcode.app/Contents/Developer");
734 let sdk = developer_dir.join("Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk");
735 std::fs::create_dir_all(&sdk).unwrap();
736 let bin = root.join("bin");
737 std::fs::create_dir(&bin).unwrap();
738 for tool in ["cargo", "rustup", "xcodebuild", "xcrun", "plutil"] {
739 executable(&bin.join(tool));
740 }
741 let discovery = AppleDiscovery {
742 host_os: "macos".to_owned(),
743 host_arch: "arm64".to_owned(),
744 developer_dir: Some(developer_dir),
745 xcode_version: Some("Xcode 26.0\nBuild version 23A1".to_owned()),
746 simulator_sdk: None,
747 device_sdk: Some(IosDeviceSdk {
748 path: sdk,
749 version: "26.0".to_owned(),
750 build_version: "23A1".to_owned(),
751 }),
752 simulator_runtimes: Vec::new(),
753 installed_rust_targets: vec![IOS_DEVICE_TARGET.to_owned()],
754 host_tools: AppleHostTools {
755 cargo: Some(bin.join("cargo")),
756 rustup: Some(bin.join("rustup")),
757 xcode_select: None,
758 xcodebuild: Some(bin.join("xcodebuild")),
759 xcrun: Some(bin.join("xcrun")),
760 plutil: Some(bin.join("plutil")),
761 },
762 executable_search_paths: vec![bin],
763 };
764 (temporary, discovery)
765 }
766
767 #[test]
768 fn parses_available_and_unavailable_runtimes() {
769 let source = r#"{"runtimes":[{"identifier":"com.apple.CoreSimulator.SimRuntime.iOS-18-0","name":"iOS 18","version":"18.0","isAvailable":false},{"identifier":"com.apple.CoreSimulator.SimRuntime.iOS-17-5","name":"iOS 17","version":"17.5","isAvailable":true}]}"#;
770 let runtimes = parse_runtimes(source);
771 assert_eq!(
772 runtimes[0].identifier,
773 "com.apple.CoreSimulator.SimRuntime.iOS-17-5"
774 );
775 assert!(runtimes[0].available);
776 assert!(!runtimes[1].available);
777 assert!(has_available_ios_runtime(&runtimes));
778 }
779
780 #[test]
781 fn non_ios_and_unavailable_runtimes_do_not_enable_asset_compilation() {
782 let runtimes = parse_runtimes(
783 r#"{"runtimes":[{"identifier":"com.apple.CoreSimulator.SimRuntime.tvOS-26-0","name":"tvOS 26","version":"26.0","isAvailable":true},{"identifier":"com.apple.CoreSimulator.SimRuntime.iOS-26-0","name":"iOS 26","version":"26.0","isAvailable":false}]}"#,
784 );
785 assert!(!has_available_ios_runtime(&runtimes));
786 }
787
788 #[test]
789 fn command_discovery_does_not_interpret_shell_characters() {
790 use std::fs;
791
792 let directory = tempfile::tempdir().unwrap();
793 let directory = Utf8Path::from_path(directory.path()).unwrap();
794 fs::write(directory.join("cargo;echo-pwned"), "not executable").unwrap();
795 assert!(find_executable("cargo", &[directory.to_owned()]).is_none());
796 }
797
798 #[test]
799 fn device_selection_is_independent_of_simulator_sdk_and_target() {
800 let (_temporary, discovery) = device_discovery_fixture();
801
802 let selected = discovery.select_device_toolchain().unwrap();
803
804 assert_eq!(selected.device_sdk.version, "26.0");
805 assert_eq!(selected.device_sdk.build_version, "23A1");
806 assert!(selected.device_sdk.path.is_absolute());
807 assert!(
808 selected
809 .device_sdk
810 .path
811 .starts_with(&selected.developer_dir)
812 );
813 assert_eq!(selected.host_arch, "arm64");
814 for tool in [
815 &selected.cargo,
816 &selected.rustup,
817 &selected.xcodebuild,
818 &selected.xcrun,
819 &selected.plutil,
820 ] {
821 assert!(tool.is_absolute());
822 }
823 }
824
825 #[test]
826 fn device_selection_requires_exact_physical_rust_target() {
827 let (_temporary, mut discovery) = device_discovery_fixture();
828 discovery.installed_rust_targets = vec![IOS_SIMULATOR_TARGET.to_owned()];
829
830 assert!(matches!(
831 discovery.select_device_toolchain(),
832 Err(AppleError::RustTargetMissing { target }) if target == IOS_DEVICE_TARGET
833 ));
834 }
835
836 #[test]
837 fn device_selection_distinguishes_missing_iphoneos_sdk() {
838 let (_temporary, mut discovery) = device_discovery_fixture();
839 discovery.device_sdk = None;
840
841 assert!(matches!(
842 discovery.select_device_toolchain(),
843 Err(AppleError::InvalidRequest(message)) if message.contains("iPhoneOS SDK")
844 ));
845 }
846
847 #[test]
848 fn device_selection_rejects_sdk_from_another_developer_directory() {
849 let (_temporary, mut discovery) = device_discovery_fixture();
850 let developer_dir = discovery.developer_dir.as_ref().unwrap();
851 let outside = developer_dir
852 .parent()
853 .unwrap()
854 .join("OtherXcode/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk");
855 std::fs::create_dir_all(&outside).unwrap();
856 discovery.device_sdk.as_mut().unwrap().path = outside;
857
858 assert!(matches!(
859 discovery.select_device_toolchain(),
860 Err(AppleError::InvalidRequest(message)) if message.contains("iPhoneOS SDK")
861 ));
862 }
863
864 #[test]
865 fn device_selection_rejects_relative_tool_paths() {
866 let (_temporary, mut discovery) = device_discovery_fixture();
867 discovery.host_tools.cargo = Some(Utf8PathBuf::from("bin/cargo"));
868
869 assert!(matches!(
870 discovery.select_device_toolchain(),
871 Err(AppleError::ToolMissing { tool, .. }) if tool == "cargo"
872 ));
873 }
874}