Skip to main content

fission_command_core/
macos_signing.rs

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}