1use crate::NativeVariant;
2use anyhow::{bail, Context, Result};
3use serde::Deserialize;
4#[cfg(any(target_os = "macos", test))]
5use sha1::{Digest as _, Sha1};
6use std::collections::BTreeMap;
7use std::ffi::OsString;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11#[cfg(any(target_os = "macos", test))]
12use std::time::SystemTime;
13
14#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
15pub struct MacosPackageConfig {
16 pub bundle_id: Option<String>,
17 pub team_id: Option<String>,
18 pub minimum_os: Option<String>,
19 pub application_category: Option<String>,
20 pub entitlements: Option<String>,
21 pub provisioning_profile: Option<String>,
22 pub signing_identity: Option<String>,
23 pub installer_identity: Option<String>,
24 pub notarize: Option<bool>,
25 pub pkg_builder: Option<String>,
26 #[serde(default)]
27 pub cargo_features: Vec<String>,
28 #[serde(default)]
29 pub cargo_no_default_features: bool,
30}
31
32#[derive(Debug, Default, Deserialize)]
33struct PackageManifest {
34 package: Option<PackageRoot>,
35 run: Option<RunRoot>,
36}
37
38#[derive(Debug, Default, Deserialize)]
39struct PackageRoot {
40 macos: Option<MacosPackageManifest>,
41}
42
43#[derive(Debug, Default, Deserialize)]
44struct RunRoot {
45 macos: Option<MacosRunConfig>,
46}
47
48#[derive(Debug, Default, Deserialize)]
49struct MacosRunConfig {
50 entitlements: Option<String>,
51 provisioning_profile: Option<String>,
52 signing_identity: Option<String>,
53}
54
55#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
56struct MacosPackageManifest {
57 #[serde(flatten)]
58 base: MacosPackageConfig,
59 release: Option<MacosPackageOverlay>,
60 #[serde(default)]
61 variants: BTreeMap<NativeVariant, MacosPackageOverlay>,
62}
63
64#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
65struct MacosPackageOverlay {
66 application_category: Option<String>,
67 entitlements: Option<String>,
68 provisioning_profile: Option<String>,
69 signing_identity: Option<String>,
70 installer_identity: Option<String>,
71 notarize: Option<bool>,
72 pkg_builder: Option<String>,
73 cargo_features: Option<Vec<String>>,
74 cargo_no_default_features: Option<bool>,
75}
76
77impl MacosPackageManifest {
78 fn effective(&self, release: bool, variant: Option<&NativeVariant>) -> MacosPackageConfig {
79 let mut config = self.base.clone();
80 if release {
81 if let Some(overlay) = &self.release {
82 overlay.apply_to(&mut config);
83 }
84 }
85 if let Some(overlay) = variant.and_then(|variant| self.variants.get(variant)) {
86 overlay.apply_to(&mut config);
87 }
88 config
89 }
90}
91
92impl MacosPackageOverlay {
93 fn apply_to(&self, config: &mut MacosPackageConfig) {
94 if self.application_category.is_some() {
95 config
96 .application_category
97 .clone_from(&self.application_category);
98 }
99 if self.entitlements.is_some() {
100 config.entitlements.clone_from(&self.entitlements);
101 }
102 if self.provisioning_profile.is_some() {
103 config
104 .provisioning_profile
105 .clone_from(&self.provisioning_profile);
106 }
107 if self.signing_identity.is_some() {
108 config.signing_identity.clone_from(&self.signing_identity);
109 }
110 if self.installer_identity.is_some() {
111 config
112 .installer_identity
113 .clone_from(&self.installer_identity);
114 }
115 if self.notarize.is_some() {
116 config.notarize = self.notarize;
117 }
118 if self.pkg_builder.is_some() {
119 config.pkg_builder.clone_from(&self.pkg_builder);
120 }
121 if let Some(features) = &self.cargo_features {
122 config.cargo_features.clone_from(features);
123 }
124 if let Some(no_default_features) = self.cargo_no_default_features {
125 config.cargo_no_default_features = no_default_features;
126 }
127 }
128}
129
130pub fn read_macos_package_config(project_dir: &Path) -> Result<MacosPackageConfig> {
131 read_macos_package_config_for_profile(project_dir, false)
132}
133
134pub fn read_macos_package_config_for_profile(
135 project_dir: &Path,
136 release: bool,
137) -> Result<MacosPackageConfig> {
138 read_macos_package_config_for_profile_and_variant(project_dir, release, None)
139}
140
141pub fn read_macos_package_config_for_profile_and_variant(
142 project_dir: &Path,
143 release: bool,
144 variant: Option<&NativeVariant>,
145) -> Result<MacosPackageConfig> {
146 let manifest = read_manifest(project_dir)?;
147 Ok(package_config(manifest.package.as_ref(), release, variant))
148}
149
150pub fn read_macos_run_config(project_dir: &Path) -> Result<MacosPackageConfig> {
151 read_macos_run_config_for_profile(project_dir, false)
152}
153
154pub fn read_macos_run_config_for_profile(
155 project_dir: &Path,
156 release: bool,
157) -> Result<MacosPackageConfig> {
158 Ok(run_config(&read_manifest(project_dir)?, release))
159}
160
161fn run_config(manifest: &PackageManifest, release: bool) -> MacosPackageConfig {
162 let run = manifest.run.as_ref().and_then(|run| run.macos.as_ref());
163 let mut config = package_config(manifest.package.as_ref(), release, None);
164 if let Some(run) = run {
165 if run.entitlements.is_some() {
166 config.entitlements.clone_from(&run.entitlements);
167 }
168 if run.provisioning_profile.is_some() {
169 config
170 .provisioning_profile
171 .clone_from(&run.provisioning_profile);
172 }
173 if run.signing_identity.is_some() {
174 config.signing_identity.clone_from(&run.signing_identity);
175 }
176 }
177 config
178}
179
180fn read_manifest(project_dir: &Path) -> Result<PackageManifest> {
181 let path = project_dir.join("fission.toml");
182 let data =
183 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
184 toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
185}
186
187fn package_config(
188 package: Option<&PackageRoot>,
189 release: bool,
190 variant: Option<&NativeVariant>,
191) -> MacosPackageConfig {
192 package
193 .and_then(|package| package.macos.as_ref())
194 .map(|macos| macos.effective(release, variant))
195 .unwrap_or_default()
196}
197
198pub fn sign_macos_app_if_configured(
199 project_dir: &Path,
200 app_bundle: &Path,
201 macos: &MacosPackageConfig,
202) -> Result<()> {
203 let identity = macos
204 .signing_identity
205 .as_deref()
206 .filter(|value| !value.trim().is_empty());
207 let profile = macos
208 .provisioning_profile
209 .as_deref()
210 .filter(|value| !value.trim().is_empty());
211
212 if profile.is_some() && identity.is_none_or(|value| value == "-") {
213 bail!(
214 "macOS provisioning_profile requires a real effective package.macos signing identity or run.macos.signing_identity; ad-hoc signing with `-` cannot embed a provisioning profile"
215 );
216 }
217 if let Some(profile) = profile {
218 validate_macos_provisioning_profile(project_dir, macos, identity.expect("validated"))?;
219 embed_macos_provisioning_profile(project_dir, app_bundle, profile)?;
220 }
221 remove_macos_bundle_extended_attributes(app_bundle)?;
222 make_macos_bundle_world_readable(app_bundle)?;
223
224 let Some(identity) = identity else {
225 return Ok(());
226 };
227
228 let status = Command::new("codesign")
229 .args(codesign_arguments(project_dir, identity, macos))
230 .arg(app_bundle)
231 .status()
232 .context("failed to run codesign")?;
233 if !status.success() {
234 bail!("codesign failed with {status}");
235 }
236
237 let verify = Command::new("codesign")
238 .args(["--verify", "--deep", "--strict", "--verbose=2"])
239 .arg(app_bundle)
240 .status()
241 .context("failed to verify macOS code signature")?;
242 if !verify.success() {
243 bail!("codesign verification failed with {verify}");
244 }
245 Ok(())
246}
247
248#[cfg(any(target_os = "macos", test))]
249#[derive(Debug, Deserialize)]
250struct DecodedMacosProvisioningProfile {
251 #[serde(rename = "TeamIdentifier")]
252 team_identifiers: Vec<String>,
253 #[serde(rename = "DeveloperCertificates")]
254 developer_certificates: Vec<plist::Value>,
255 #[serde(rename = "ProvisionedDevices", default)]
256 provisioned_devices: Vec<String>,
257 #[serde(rename = "ExpirationDate")]
258 expiration_date: plist::Date,
259 #[serde(rename = "Entitlements")]
260 entitlements: BTreeMap<String, plist::Value>,
261}
262
263#[cfg(target_os = "macos")]
264fn validate_macos_provisioning_profile(
265 project_dir: &Path,
266 macos: &MacosPackageConfig,
267 signing_identity: &str,
268) -> Result<()> {
269 let profile = macos
270 .provisioning_profile
271 .as_deref()
272 .expect("profile validation is called only when configured");
273 let profile_path = resolve_project_path(project_dir, profile);
274 if !profile_path.is_file() {
275 bail!(
276 "macOS provisioning profile does not exist or is not a file: {}",
277 profile_path.display()
278 );
279 }
280 let decoded = Command::new("security")
281 .args(["cms", "-D", "-i"])
282 .arg(&profile_path)
283 .output()
284 .context("failed to decode the macOS provisioning profile with `security cms`")?;
285 if !decoded.status.success() {
286 bail!(
287 "macOS provisioning profile could not be verified by `security cms`: {}",
288 String::from_utf8_lossy(&decoded.stderr).trim()
289 );
290 }
291 let profile: DecodedMacosProvisioningProfile = plist::from_bytes(&decoded.stdout)
292 .context("failed to parse decoded provisioning profile")?;
293 let identity = resolve_codesigning_identity(signing_identity)?;
294 let host_udid = current_macos_provisioning_udid()?;
295 validate_macos_profile_bindings(&profile, macos, &identity, host_udid.as_deref())
296}
297
298#[cfg(not(target_os = "macos"))]
299fn validate_macos_provisioning_profile(
300 _project_dir: &Path,
301 _macos: &MacosPackageConfig,
302 _signing_identity: &str,
303) -> Result<()> {
304 Ok(())
305}
306
307#[cfg(any(target_os = "macos", test))]
308#[derive(Debug, Eq, PartialEq)]
309struct ResolvedCodesigningIdentity {
310 certificate_sha1: String,
311 display_name: String,
312}
313
314#[cfg(target_os = "macos")]
315fn resolve_codesigning_identity(requested: &str) -> Result<ResolvedCodesigningIdentity> {
316 let output = Command::new("security")
317 .args(["find-identity", "-v", "-p", "codesigning"])
318 .output()
319 .context("failed to query macOS code-signing identities")?;
320 if !output.status.success() {
321 bail!("macOS code-signing identities could not be queried");
322 }
323 let mut matches =
324 matching_codesigning_identities(&String::from_utf8_lossy(&output.stdout), requested);
325 if matches.len() != 1 {
326 bail!(
327 "macOS signing identity `{requested}` resolved to {} valid identities; configure one unambiguous certificate name or SHA-1 fingerprint",
328 matches.len()
329 );
330 }
331 Ok(matches.remove(0))
332}
333
334#[cfg(any(target_os = "macos", test))]
335fn parse_codesigning_identity(line: &str) -> Option<ResolvedCodesigningIdentity> {
336 let trimmed = line.trim();
337 let (_, after_index) = trimmed.split_once(')')?;
338 let (fingerprint, quoted_name) = after_index.trim().split_once(' ')?;
339 let display_name = quoted_name.strip_prefix('"')?.strip_suffix('"')?;
340 if fingerprint.len() != 40
341 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit())
342 || display_name.is_empty()
343 {
344 return None;
345 }
346 Some(ResolvedCodesigningIdentity {
347 certificate_sha1: fingerprint.to_ascii_uppercase(),
348 display_name: display_name.to_owned(),
349 })
350}
351
352#[cfg(any(target_os = "macos", test))]
353fn matching_codesigning_identities(
354 output: &str,
355 requested: &str,
356) -> Vec<ResolvedCodesigningIdentity> {
357 let mut matches = Vec::new();
358 for identity in output.lines().filter_map(parse_codesigning_identity) {
359 let is_match = identity.certificate_sha1.eq_ignore_ascii_case(requested)
360 || identity.display_name == requested
361 || identity.display_name.contains(requested);
362 let certificate_already_matched =
363 matches.iter().any(|matched: &ResolvedCodesigningIdentity| {
364 matched
365 .certificate_sha1
366 .eq_ignore_ascii_case(&identity.certificate_sha1)
367 });
368 if is_match && !certificate_already_matched {
369 matches.push(identity);
370 }
371 }
372 matches
373}
374
375#[cfg(target_os = "macos")]
376fn current_macos_provisioning_udid() -> Result<Option<String>> {
377 let output = Command::new("system_profiler")
378 .arg("SPHardwareDataType")
379 .output()
380 .context("failed to query this Mac's Provisioning UDID")?;
381 if !output.status.success() {
382 bail!("this Mac's Provisioning UDID could not be queried");
383 }
384 Ok(String::from_utf8_lossy(&output.stdout)
385 .lines()
386 .find_map(|line| line.trim().strip_prefix("Provisioning UDID:"))
387 .map(str::trim)
388 .filter(|value| !value.is_empty())
389 .map(str::to_owned))
390}
391
392#[cfg(any(target_os = "macos", test))]
393fn validate_macos_profile_bindings(
394 profile: &DecodedMacosProvisioningProfile,
395 macos: &MacosPackageConfig,
396 identity: &ResolvedCodesigningIdentity,
397 host_udid: Option<&str>,
398) -> Result<()> {
399 if SystemTime::from(profile.expiration_date) <= SystemTime::now() {
400 bail!("macOS provisioning profile has expired");
401 }
402 let bundle_id = macos
403 .bundle_id
404 .as_deref()
405 .filter(|value| !value.trim().is_empty())
406 .context("macOS provisioning profile validation requires package.macos.bundle_id")?;
407 let profile_team = profile
408 .team_identifiers
409 .first()
410 .filter(|value| !value.trim().is_empty())
411 .context("macOS provisioning profile has no TeamIdentifier")?;
412 if profile
413 .team_identifiers
414 .iter()
415 .any(|team| team != profile_team)
416 {
417 bail!("macOS provisioning profile contains conflicting team identifiers");
418 }
419 if let Some(configured_team) = macos
420 .team_id
421 .as_deref()
422 .filter(|value| !value.trim().is_empty())
423 {
424 if configured_team != profile_team {
425 bail!(
426 "macOS provisioning profile team `{profile_team}` does not match configured team `{configured_team}`"
427 );
428 }
429 }
430 let application_identifier = profile
431 .entitlements
432 .get("com.apple.application-identifier")
433 .and_then(plist::Value::as_string)
434 .context("macOS provisioning profile has no application identifier entitlement")?;
435 let expected_application_identifier = format!("{profile_team}.{bundle_id}");
436 let application_matches = application_identifier == expected_application_identifier
437 || application_identifier
438 .strip_suffix('*')
439 .is_some_and(|prefix| expected_application_identifier.starts_with(prefix));
440 if !application_matches {
441 bail!(
442 "macOS provisioning profile application identifier `{application_identifier}` does not authorize `{expected_application_identifier}`"
443 );
444 }
445 let identity_in_profile = profile
446 .developer_certificates
447 .iter()
448 .filter_map(plist::Value::as_data)
449 .any(|certificate| format!("{:X}", Sha1::digest(certificate)) == identity.certificate_sha1);
450 if !identity_in_profile {
451 bail!(
452 "macOS provisioning profile does not include signing certificate `{}` ({})",
453 identity.display_name,
454 identity.certificate_sha1
455 );
456 }
457 if !profile.provisioned_devices.is_empty() {
458 let host_udid = host_udid.context(
459 "macOS development provisioning profile is device-bound but this Mac's Provisioning UDID is unavailable",
460 )?;
461 if !profile
462 .provisioned_devices
463 .iter()
464 .any(|device| device == host_udid)
465 {
466 bail!(
467 "macOS development provisioning profile does not include this Mac's Provisioning UDID `{host_udid}`"
468 );
469 }
470 }
471 Ok(())
472}
473
474fn embed_macos_provisioning_profile(
475 project_dir: &Path,
476 app_bundle: &Path,
477 profile: &str,
478) -> Result<()> {
479 let source = resolve_project_path(project_dir, profile);
480 if !source.is_file() {
481 bail!(
482 "macOS provisioning profile does not exist or is not a file: {}",
483 source.display()
484 );
485 }
486
487 let destination = app_bundle.join("Contents/embedded.provisionprofile");
488 fs::copy(&source, &destination).with_context(|| {
489 format!(
490 "failed to embed macOS provisioning profile {} at {}",
491 source.display(),
492 destination.display()
493 )
494 })?;
495 Ok(())
496}
497
498fn remove_macos_bundle_extended_attributes(_app_bundle: &Path) -> Result<()> {
499 #[cfg(target_os = "macos")]
500 {
501 let status = Command::new("xattr")
502 .args(["-c", "-r"])
503 .arg(_app_bundle)
504 .status()
505 .context("failed to remove extended attributes from macOS app bundle")?;
506 if !status.success() {
507 bail!("xattr failed with {status}");
508 }
509 }
510 Ok(())
511}
512
513fn make_macos_bundle_world_readable(app_bundle: &Path) -> Result<()> {
514 let mut pending = vec![app_bundle.to_path_buf()];
515 while let Some(path) = pending.pop() {
516 let metadata = fs::symlink_metadata(&path)
517 .with_context(|| format!("failed to inspect macOS bundle path {}", path.display()))?;
518 if metadata.file_type().is_symlink() {
519 continue;
520 }
521 if metadata.is_dir() {
522 for entry in fs::read_dir(&path)
523 .with_context(|| format!("failed to read macOS bundle path {}", path.display()))?
524 {
525 pending.push(entry?.path());
526 }
527 }
528 make_world_readable(&path, metadata.permissions())?;
529 }
530 Ok(())
531}
532
533#[cfg(unix)]
534fn make_world_readable(path: &Path, mut permissions: fs::Permissions) -> Result<()> {
535 use std::os::unix::fs::PermissionsExt;
536
537 let readable = if path.is_dir() { 0o0555 } else { 0o0444 };
538 permissions.set_mode(permissions.mode() | readable);
539 fs::set_permissions(path, permissions).with_context(|| {
540 format!(
541 "failed to make macOS bundle path readable: {}",
542 path.display()
543 )
544 })
545}
546
547#[cfg(not(unix))]
548fn make_world_readable(_path: &Path, _permissions: fs::Permissions) -> Result<()> {
549 Ok(())
550}
551
552fn codesign_arguments(
553 project_dir: &Path,
554 identity: &str,
555 macos: &MacosPackageConfig,
556) -> Vec<OsString> {
557 let mut arguments = vec![
558 "--force".into(),
559 "--timestamp".into(),
560 "--options".into(),
561 "runtime".into(),
562 "--sign".into(),
563 identity.into(),
564 ];
565 if let Some(entitlements) = macos
566 .entitlements
567 .as_deref()
568 .filter(|value| !value.trim().is_empty())
569 {
570 arguments.push("--entitlements".into());
571 arguments.push(resolve_project_path(project_dir, entitlements).into_os_string());
572 }
573 arguments
574}
575
576fn resolve_project_path(project_dir: &Path, path: &str) -> PathBuf {
577 let path = PathBuf::from(path);
578 if path.is_absolute() {
579 path
580 } else {
581 project_dir.join(path)
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use std::time::Duration;
589
590 #[test]
591 fn parses_macos_package_signing_configuration() {
592 let manifest: PackageManifest = toml::from_str(
593 r#"
594[package.macos]
595bundle_id = "com.example.app"
596minimum_os = "14.0"
597application_category = "public.app-category.developer-tools"
598entitlements = "platforms/macos/App.entitlements"
599provisioning_profile = "profiles/Developer.provisionprofile"
600signing_identity = "Apple Development"
601installer_identity = "Developer ID Installer"
602notarize = true
603
604[run.macos]
605entitlements = "platforms/macos/Development.entitlements"
606provisioning_profile = "profiles/Developer-Local.provisionprofile"
607signing_identity = "-"
608"#,
609 )
610 .unwrap();
611
612 assert_eq!(
613 package_config(manifest.package.as_ref(), false, None),
614 MacosPackageConfig {
615 bundle_id: Some("com.example.app".into()),
616 minimum_os: Some("14.0".into()),
617 application_category: Some("public.app-category.developer-tools".into()),
618 entitlements: Some("platforms/macos/App.entitlements".into()),
619 provisioning_profile: Some("profiles/Developer.provisionprofile".into()),
620 signing_identity: Some("Apple Development".into()),
621 installer_identity: Some("Developer ID Installer".into()),
622 notarize: Some(true),
623 ..Default::default()
624 }
625 );
626 let run = manifest.run.as_ref().unwrap().macos.as_ref().unwrap();
627 assert_eq!(
628 run.entitlements.as_deref(),
629 Some("platforms/macos/Development.entitlements")
630 );
631 assert_eq!(
632 run.provisioning_profile.as_deref(),
633 Some("profiles/Developer-Local.provisionprofile")
634 );
635 assert_eq!(run.signing_identity.as_deref(), Some("-"));
636 }
637
638 #[test]
639 fn parses_codesigning_identity_output() {
640 assert_eq!(
641 parse_codesigning_identity(
642 r#" 1) A678CDF82B5E6D0031FB0690F74FD365C01FE43D "Apple Development: Example (TEAM123)""#,
643 ),
644 Some(ResolvedCodesigningIdentity {
645 certificate_sha1: "A678CDF82B5E6D0031FB0690F74FD365C01FE43D".into(),
646 display_name: "Apple Development: Example (TEAM123)".into(),
647 })
648 );
649 assert!(parse_codesigning_identity("0 valid identities found").is_none());
650 }
651
652 #[test]
653 fn duplicate_keychain_results_are_one_codesigning_identity() {
654 let output = r#"
655 1) 00112233445566778899AABBCCDDEEFF00112233 "Developer ID Application: Example (TEAM123)"
656 2) 00112233445566778899AABBCCDDEEFF00112233 "Developer ID Application: Example Duplicate Label (TEAM123)"
657 3) FFEEDDCCBBAA99887766554433221100FFEEDDCC "Apple Development: Example (TEAM123)"
658 3 valid identities found
659"#;
660
661 assert_eq!(
662 matching_codesigning_identities(output, "00112233445566778899AABBCCDDEEFF00112233"),
663 vec![ResolvedCodesigningIdentity {
664 certificate_sha1: "00112233445566778899AABBCCDDEEFF00112233".into(),
665 display_name: "Developer ID Application: Example (TEAM123)".into(),
666 }]
667 );
668 }
669
670 #[test]
671 fn provisioning_profile_parses_apple_certificate_data_values() {
672 let decoded: DecodedMacosProvisioningProfile = plist::from_bytes(
673 br#"<?xml version="1.0" encoding="UTF-8"?>
674<plist version="1.0"><dict>
675<key>TeamIdentifier</key><array><string>TEAM123</string></array>
676<key>DeveloperCertificates</key><array><data>Y2VydGlmaWNhdGU=</data></array>
677<key>ExpirationDate</key><date>2030-01-01T00:00:00Z</date>
678<key>Entitlements</key><dict>
679<key>com.apple.application-identifier</key><string>TEAM123.com.example.app</string>
680</dict>
681</dict></plist>"#,
682 )
683 .expect("Apple profile data should deserialize");
684
685 assert_eq!(
686 decoded.developer_certificates[0].as_data(),
687 Some(b"certificate".as_slice())
688 );
689 }
690
691 #[test]
692 fn profile_bindings_require_bundle_team_certificate_and_device() {
693 let certificate = b"certificate-der".to_vec();
694 let identity = ResolvedCodesigningIdentity {
695 certificate_sha1: format!("{:X}", Sha1::digest(&certificate)),
696 display_name: "Apple Development: Example (TEAM123)".into(),
697 };
698 let mut profile = DecodedMacosProvisioningProfile {
699 team_identifiers: vec!["TEAM123".into()],
700 developer_certificates: vec![plist::Value::Data(certificate)],
701 provisioned_devices: vec!["MAC-UDID".into()],
702 expiration_date: (SystemTime::now() + Duration::from_secs(3_600)).into(),
703 entitlements: BTreeMap::from([(
704 "com.apple.application-identifier".into(),
705 plist::Value::String("TEAM123.com.example.app".into()),
706 )]),
707 };
708 let config = MacosPackageConfig {
709 bundle_id: Some("com.example.app".into()),
710 team_id: Some("TEAM123".into()),
711 ..Default::default()
712 };
713
714 validate_macos_profile_bindings(&profile, &config, &identity, Some("MAC-UDID")).unwrap();
715
716 let error =
717 validate_macos_profile_bindings(&profile, &config, &identity, Some("OTHER-MAC"))
718 .unwrap_err();
719 assert!(error.to_string().contains("Provisioning UDID"));
720
721 profile.entitlements.insert(
722 "com.apple.application-identifier".into(),
723 plist::Value::String("TEAM123.com.other.app".into()),
724 );
725 let error = validate_macos_profile_bindings(&profile, &config, &identity, Some("MAC-UDID"))
726 .unwrap_err();
727 assert!(error.to_string().contains("does not authorize"));
728 }
729
730 #[test]
731 fn profile_bindings_reject_a_certificate_not_embedded_in_the_profile() {
732 let profile = DecodedMacosProvisioningProfile {
733 team_identifiers: vec!["TEAM123".into()],
734 developer_certificates: vec![plist::Value::Data(b"different-certificate".to_vec())],
735 provisioned_devices: Vec::new(),
736 expiration_date: (SystemTime::now() + Duration::from_secs(3_600)).into(),
737 entitlements: BTreeMap::from([(
738 "com.apple.application-identifier".into(),
739 plist::Value::String("TEAM123.com.example.app".into()),
740 )]),
741 };
742 let identity = ResolvedCodesigningIdentity {
743 certificate_sha1: format!("{:X}", Sha1::digest(b"selected-certificate")),
744 display_name: "Apple Development: Example (TEAM123)".into(),
745 };
746 let config = MacosPackageConfig {
747 bundle_id: Some("com.example.app".into()),
748 team_id: Some("TEAM123".into()),
749 ..Default::default()
750 };
751
752 let error =
753 validate_macos_profile_bindings(&profile, &config, &identity, None).unwrap_err();
754 assert!(error
755 .to_string()
756 .contains("does not include signing certificate"));
757 }
758
759 #[test]
760 fn release_signing_overlay_is_ignored_for_debug_packages() {
761 let manifest: PackageManifest = toml::from_str(
762 r#"
763[package.macos]
764bundle_id = "com.example.app"
765minimum_os = "14.0"
766entitlements = "platforms/macos/Development.entitlements"
767signing_identity = "-"
768
769[package.macos.release]
770application_category = "public.app-category.utilities"
771entitlements = "platforms/macos/Release.entitlements"
772provisioning_profile = "profiles/Distribution.provisionprofile"
773signing_identity = "Developer ID Application: Example Ltd"
774installer_identity = "Developer ID Installer: Example Ltd"
775notarize = true
776"#,
777 )
778 .unwrap();
779
780 let debug = package_config(manifest.package.as_ref(), false, None);
781 assert_eq!(
782 debug.entitlements.as_deref(),
783 Some("platforms/macos/Development.entitlements")
784 );
785 assert_eq!(debug.signing_identity.as_deref(), Some("-"));
786 assert_eq!(debug.provisioning_profile, None);
787 assert_eq!(debug.installer_identity, None);
788 assert_eq!(debug.notarize, None);
789
790 let release = package_config(manifest.package.as_ref(), true, None);
791 assert_eq!(
792 release.entitlements.as_deref(),
793 Some("platforms/macos/Release.entitlements")
794 );
795 assert_eq!(
796 release.application_category.as_deref(),
797 Some("public.app-category.utilities")
798 );
799 assert_eq!(
800 release.provisioning_profile.as_deref(),
801 Some("profiles/Distribution.provisionprofile")
802 );
803 assert_eq!(
804 release.signing_identity.as_deref(),
805 Some("Developer ID Application: Example Ltd")
806 );
807 assert_eq!(
808 release.installer_identity.as_deref(),
809 Some("Developer ID Installer: Example Ltd")
810 );
811 assert_eq!(release.notarize, Some(true));
812
813 let release_run = run_config(&manifest, true);
814 assert_eq!(
815 release_run.signing_identity.as_deref(),
816 Some("Developer ID Application: Example Ltd")
817 );
818 }
819
820 #[test]
821 fn selected_variant_overrides_effective_release_signing() {
822 let manifest: PackageManifest = toml::from_str(
823 r#"
824[package.macos]
825bundle_id = "com.example.app"
826signing_identity = "-"
827
828[package.macos.release]
829entitlements = "platforms/macos/DeveloperId.entitlements"
830provisioning_profile = "profiles/DeveloperId.provisionprofile"
831signing_identity = "Developer ID Application: Example Ltd"
832installer_identity = "Developer ID Installer: Example Ltd"
833notarize = true
834
835[package.macos.variants.app-store]
836entitlements = "platforms/macos/AppStore.entitlements"
837provisioning_profile = "profiles/AppStore.provisionprofile"
838signing_identity = "Apple Distribution: Example Ltd"
839installer_identity = "3rd Party Mac Developer Installer: Example Ltd"
840notarize = false
841pkg_builder = "productbuild"
842cargo_features = ["macos-app-store"]
843cargo_no_default_features = true
844"#,
845 )
846 .unwrap();
847 let variant: NativeVariant = "app-store".parse().unwrap();
848
849 let config = package_config(manifest.package.as_ref(), true, Some(&variant));
850
851 assert_eq!(
852 config.entitlements.as_deref(),
853 Some("platforms/macos/AppStore.entitlements")
854 );
855 assert_eq!(
856 config.provisioning_profile.as_deref(),
857 Some("profiles/AppStore.provisionprofile")
858 );
859 assert_eq!(
860 config.signing_identity.as_deref(),
861 Some("Apple Distribution: Example Ltd")
862 );
863 assert_eq!(
864 config.installer_identity.as_deref(),
865 Some("3rd Party Mac Developer Installer: Example Ltd")
866 );
867 assert_eq!(config.notarize, Some(false));
868 assert_eq!(config.pkg_builder.as_deref(), Some("productbuild"));
869 assert_eq!(config.cargo_features, ["macos-app-store"]);
870 assert!(config.cargo_no_default_features);
871 }
872
873 #[test]
874 fn codesign_arguments_resolve_relative_entitlements() {
875 let config = MacosPackageConfig {
876 entitlements: Some("platforms/macos/App.entitlements".into()),
877 ..Default::default()
878 };
879
880 assert_eq!(
881 codesign_arguments(Path::new("/project"), "-", &config),
882 vec![
883 OsString::from("--force"),
884 OsString::from("--timestamp"),
885 OsString::from("--options"),
886 OsString::from("runtime"),
887 OsString::from("--sign"),
888 OsString::from("-"),
889 OsString::from("--entitlements"),
890 OsString::from("/project/platforms/macos/App.entitlements"),
891 ]
892 );
893 }
894
895 #[test]
896 fn run_signing_overrides_package_signing_only() {
897 let manifest: PackageManifest = toml::from_str(
898 r#"
899[package.macos]
900bundle_id = "com.example.app"
901minimum_os = "14.0"
902entitlements = "platforms/macos/Release.entitlements"
903provisioning_profile = "profiles/Release.provisionprofile"
904signing_identity = "Apple Development"
905
906[run.macos]
907entitlements = "platforms/macos/Development.entitlements"
908provisioning_profile = "profiles/Development.provisionprofile"
909signing_identity = "-"
910"#,
911 )
912 .unwrap();
913 let config = run_config(&manifest, false);
914
915 assert_eq!(config.bundle_id.as_deref(), Some("com.example.app"));
916 assert_eq!(config.minimum_os.as_deref(), Some("14.0"));
917 assert_eq!(
918 config.entitlements.as_deref(),
919 Some("platforms/macos/Development.entitlements")
920 );
921 assert_eq!(
922 config.provisioning_profile.as_deref(),
923 Some("profiles/Development.provisionprofile")
924 );
925 assert_eq!(config.signing_identity.as_deref(), Some("-"));
926 }
927
928 #[test]
929 fn provisioning_profile_rejects_ad_hoc_signing_identity() {
930 let config = MacosPackageConfig {
931 provisioning_profile: Some("profiles/Development.provisionprofile".into()),
932 signing_identity: Some("-".into()),
933 ..Default::default()
934 };
935
936 let error = sign_macos_app_if_configured(
937 Path::new("/project"),
938 Path::new("/project/Demo.app"),
939 &config,
940 )
941 .unwrap_err();
942
943 assert!(error.to_string().contains("ad-hoc signing"));
944 }
945
946 #[test]
947 fn embeds_relative_macos_provisioning_profile() {
948 let root =
949 std::env::temp_dir().join(format!("fission-macos-profile-{}", std::process::id()));
950 let project = root.join("project");
951 let app = root.join("Demo.app");
952 fs::remove_dir_all(&root).ok();
953 fs::create_dir_all(project.join("profiles")).unwrap();
954 fs::create_dir_all(app.join("Contents")).unwrap();
955 fs::write(
956 project.join("profiles/Development.provisionprofile"),
957 b"profile-data",
958 )
959 .unwrap();
960
961 embed_macos_provisioning_profile(&project, &app, "profiles/Development.provisionprofile")
962 .unwrap();
963
964 assert_eq!(
965 fs::read(app.join("Contents/embedded.provisionprofile")).unwrap(),
966 b"profile-data"
967 );
968 fs::remove_dir_all(root).unwrap();
969 }
970
971 #[cfg(unix)]
972 #[test]
973 fn macos_bundle_resources_are_readable_by_non_root_users() {
974 use std::os::unix::fs::PermissionsExt;
975
976 let root = std::env::temp_dir().join(format!(
977 "fission-macos-readable-bundle-{}",
978 std::process::id()
979 ));
980 let app = root.join("Demo.app");
981 let resource = app.join("Contents/Resources/private.dat");
982 fs::remove_dir_all(&root).ok();
983 fs::create_dir_all(resource.parent().unwrap()).unwrap();
984 fs::write(&resource, b"resource").unwrap();
985 fs::set_permissions(&resource, fs::Permissions::from_mode(0o600)).unwrap();
986
987 make_macos_bundle_world_readable(&app).unwrap();
988
989 assert_eq!(
990 fs::metadata(&resource).unwrap().permissions().mode() & 0o004,
991 0o004
992 );
993 assert_eq!(
994 fs::metadata(resource.parent().unwrap())
995 .unwrap()
996 .permissions()
997 .mode()
998 & 0o001,
999 0o001
1000 );
1001 fs::remove_dir_all(root).unwrap();
1002 }
1003
1004 #[cfg(target_os = "macos")]
1005 #[test]
1006 fn macos_bundle_extended_attributes_are_removed_before_signing() {
1007 let root =
1008 std::env::temp_dir().join(format!("fission-macos-clean-bundle-{}", std::process::id()));
1009 let app = root.join("Demo.app");
1010 let resource = app.join("Contents/Resources/downloaded.dat");
1011 fs::remove_dir_all(&root).ok();
1012 fs::create_dir_all(resource.parent().unwrap()).unwrap();
1013 fs::write(&resource, b"resource").unwrap();
1014 let status = Command::new("xattr")
1015 .args(["-w", "com.apple.quarantine", "0081;test;Fission;"])
1016 .arg(&resource)
1017 .status()
1018 .unwrap();
1019 assert!(status.success());
1020
1021 remove_macos_bundle_extended_attributes(&app).unwrap();
1022
1023 let status = Command::new("xattr")
1024 .args(["-p", "com.apple.quarantine"])
1025 .arg(&resource)
1026 .status()
1027 .unwrap();
1028 assert!(!status.success());
1029 fs::remove_dir_all(root).unwrap();
1030 }
1031
1032 #[test]
1033 fn provisioning_profile_requires_signing_identity() {
1034 let config = MacosPackageConfig {
1035 provisioning_profile: Some("profiles/Development.provisionprofile".into()),
1036 ..Default::default()
1037 };
1038
1039 let error = sign_macos_app_if_configured(
1040 Path::new("/project"),
1041 Path::new("/project/Demo.app"),
1042 &config,
1043 )
1044 .unwrap_err();
1045
1046 assert!(error.to_string().contains("requires"));
1047 }
1048}