Skip to main content

fission_command_core/
macos_signing.rs

1use anyhow::{bail, Context, Result};
2use serde::Deserialize;
3use std::ffi::OsString;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
9pub struct MacosPackageConfig {
10    pub bundle_id: Option<String>,
11    pub minimum_os: Option<String>,
12    pub entitlements: Option<String>,
13    pub provisioning_profile: Option<String>,
14    pub signing_identity: Option<String>,
15    pub installer_identity: Option<String>,
16    pub notarize: Option<bool>,
17}
18
19#[derive(Debug, Default, Deserialize)]
20struct PackageManifest {
21    package: Option<PackageRoot>,
22    run: Option<RunRoot>,
23}
24
25#[derive(Debug, Default, Deserialize)]
26struct PackageRoot {
27    macos: Option<MacosPackageManifest>,
28}
29
30#[derive(Debug, Default, Deserialize)]
31struct RunRoot {
32    macos: Option<MacosRunConfig>,
33}
34
35#[derive(Debug, Default, Deserialize)]
36struct MacosRunConfig {
37    entitlements: Option<String>,
38    provisioning_profile: Option<String>,
39    signing_identity: Option<String>,
40}
41
42#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
43struct MacosPackageManifest {
44    #[serde(flatten)]
45    base: MacosPackageConfig,
46    release: Option<MacosReleasePackageConfig>,
47}
48
49#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
50struct MacosReleasePackageConfig {
51    entitlements: Option<String>,
52    provisioning_profile: Option<String>,
53    signing_identity: Option<String>,
54    installer_identity: Option<String>,
55    notarize: Option<bool>,
56}
57
58impl MacosPackageManifest {
59    fn effective(&self, release: bool) -> MacosPackageConfig {
60        let mut config = self.base.clone();
61        if release {
62            if let Some(overlay) = &self.release {
63                overlay.apply_to(&mut config);
64            }
65        }
66        config
67    }
68}
69
70impl MacosReleasePackageConfig {
71    fn apply_to(&self, config: &mut MacosPackageConfig) {
72        if self.entitlements.is_some() {
73            config.entitlements.clone_from(&self.entitlements);
74        }
75        if self.provisioning_profile.is_some() {
76            config
77                .provisioning_profile
78                .clone_from(&self.provisioning_profile);
79        }
80        if self.signing_identity.is_some() {
81            config.signing_identity.clone_from(&self.signing_identity);
82        }
83        if self.installer_identity.is_some() {
84            config
85                .installer_identity
86                .clone_from(&self.installer_identity);
87        }
88        if self.notarize.is_some() {
89            config.notarize = self.notarize;
90        }
91    }
92}
93
94pub fn read_macos_package_config(project_dir: &Path) -> Result<MacosPackageConfig> {
95    read_macos_package_config_for_profile(project_dir, false)
96}
97
98pub fn read_macos_package_config_for_profile(
99    project_dir: &Path,
100    release: bool,
101) -> Result<MacosPackageConfig> {
102    let manifest = read_manifest(project_dir)?;
103    Ok(package_config(manifest.package.as_ref(), release))
104}
105
106pub fn read_macos_run_config(project_dir: &Path) -> Result<MacosPackageConfig> {
107    read_macos_run_config_for_profile(project_dir, false)
108}
109
110pub fn read_macos_run_config_for_profile(
111    project_dir: &Path,
112    release: bool,
113) -> Result<MacosPackageConfig> {
114    Ok(run_config(&read_manifest(project_dir)?, release))
115}
116
117fn run_config(manifest: &PackageManifest, release: bool) -> MacosPackageConfig {
118    let run = manifest.run.as_ref().and_then(|run| run.macos.as_ref());
119    let mut config = package_config(manifest.package.as_ref(), release);
120    if let Some(run) = run {
121        if run.entitlements.is_some() {
122            config.entitlements.clone_from(&run.entitlements);
123        }
124        if run.provisioning_profile.is_some() {
125            config
126                .provisioning_profile
127                .clone_from(&run.provisioning_profile);
128        }
129        if run.signing_identity.is_some() {
130            config.signing_identity.clone_from(&run.signing_identity);
131        }
132    }
133    config
134}
135
136fn read_manifest(project_dir: &Path) -> Result<PackageManifest> {
137    let path = project_dir.join("fission.toml");
138    let data =
139        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
140    toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
141}
142
143fn package_config(package: Option<&PackageRoot>, release: bool) -> MacosPackageConfig {
144    package
145        .and_then(|package| package.macos.as_ref())
146        .map(|macos| macos.effective(release))
147        .unwrap_or_default()
148}
149
150pub fn sign_macos_app_if_configured(
151    project_dir: &Path,
152    app_bundle: &Path,
153    macos: &MacosPackageConfig,
154) -> Result<()> {
155    let identity = macos
156        .signing_identity
157        .as_deref()
158        .filter(|value| !value.trim().is_empty());
159    let profile = macos
160        .provisioning_profile
161        .as_deref()
162        .filter(|value| !value.trim().is_empty());
163
164    if profile.is_some() && identity.is_none_or(|value| value == "-") {
165        bail!(
166            "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"
167        );
168    }
169    if let Some(profile) = profile {
170        embed_macos_provisioning_profile(project_dir, app_bundle, profile)?;
171    }
172
173    let Some(identity) = identity else {
174        return Ok(());
175    };
176
177    let status = Command::new("codesign")
178        .args(codesign_arguments(project_dir, identity, macos))
179        .arg(app_bundle)
180        .status()
181        .context("failed to run codesign")?;
182    if !status.success() {
183        bail!("codesign failed with {status}");
184    }
185
186    let verify = Command::new("codesign")
187        .args(["--verify", "--deep", "--strict", "--verbose=2"])
188        .arg(app_bundle)
189        .status()
190        .context("failed to verify macOS code signature")?;
191    if !verify.success() {
192        bail!("codesign verification failed with {verify}");
193    }
194    Ok(())
195}
196
197fn embed_macos_provisioning_profile(
198    project_dir: &Path,
199    app_bundle: &Path,
200    profile: &str,
201) -> Result<()> {
202    let source = resolve_project_path(project_dir, profile);
203    if !source.is_file() {
204        bail!(
205            "macOS provisioning profile does not exist or is not a file: {}",
206            source.display()
207        );
208    }
209
210    let destination = app_bundle.join("Contents/embedded.provisionprofile");
211    fs::copy(&source, &destination).with_context(|| {
212        format!(
213            "failed to embed macOS provisioning profile {} at {}",
214            source.display(),
215            destination.display()
216        )
217    })?;
218    Ok(())
219}
220
221fn codesign_arguments(
222    project_dir: &Path,
223    identity: &str,
224    macos: &MacosPackageConfig,
225) -> Vec<OsString> {
226    let mut arguments = vec![
227        "--force".into(),
228        "--timestamp".into(),
229        "--options".into(),
230        "runtime".into(),
231        "--sign".into(),
232        identity.into(),
233    ];
234    if let Some(entitlements) = macos
235        .entitlements
236        .as_deref()
237        .filter(|value| !value.trim().is_empty())
238    {
239        arguments.push("--entitlements".into());
240        arguments.push(resolve_project_path(project_dir, entitlements).into_os_string());
241    }
242    arguments
243}
244
245fn resolve_project_path(project_dir: &Path, path: &str) -> PathBuf {
246    let path = PathBuf::from(path);
247    if path.is_absolute() {
248        path
249    } else {
250        project_dir.join(path)
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn parses_macos_package_signing_configuration() {
260        let manifest: PackageManifest = toml::from_str(
261            r#"
262[package.macos]
263bundle_id = "com.example.app"
264minimum_os = "14.0"
265entitlements = "platforms/macos/App.entitlements"
266provisioning_profile = "profiles/Developer.provisionprofile"
267signing_identity = "Apple Development"
268installer_identity = "Developer ID Installer"
269notarize = true
270
271[run.macos]
272entitlements = "platforms/macos/Development.entitlements"
273provisioning_profile = "profiles/Developer-Local.provisionprofile"
274signing_identity = "-"
275"#,
276        )
277        .unwrap();
278
279        assert_eq!(
280            package_config(manifest.package.as_ref(), false),
281            MacosPackageConfig {
282                bundle_id: Some("com.example.app".into()),
283                minimum_os: Some("14.0".into()),
284                entitlements: Some("platforms/macos/App.entitlements".into()),
285                provisioning_profile: Some("profiles/Developer.provisionprofile".into()),
286                signing_identity: Some("Apple Development".into()),
287                installer_identity: Some("Developer ID Installer".into()),
288                notarize: Some(true),
289            }
290        );
291        let run = manifest.run.as_ref().unwrap().macos.as_ref().unwrap();
292        assert_eq!(
293            run.entitlements.as_deref(),
294            Some("platforms/macos/Development.entitlements")
295        );
296        assert_eq!(
297            run.provisioning_profile.as_deref(),
298            Some("profiles/Developer-Local.provisionprofile")
299        );
300        assert_eq!(run.signing_identity.as_deref(), Some("-"));
301    }
302
303    #[test]
304    fn release_signing_overlay_is_ignored_for_debug_packages() {
305        let manifest: PackageManifest = toml::from_str(
306            r#"
307[package.macos]
308bundle_id = "com.example.app"
309minimum_os = "14.0"
310entitlements = "platforms/macos/Development.entitlements"
311signing_identity = "-"
312
313[package.macos.release]
314entitlements = "platforms/macos/Release.entitlements"
315provisioning_profile = "profiles/Distribution.provisionprofile"
316signing_identity = "Developer ID Application: Example Ltd"
317installer_identity = "Developer ID Installer: Example Ltd"
318notarize = true
319"#,
320        )
321        .unwrap();
322
323        let debug = package_config(manifest.package.as_ref(), false);
324        assert_eq!(
325            debug.entitlements.as_deref(),
326            Some("platforms/macos/Development.entitlements")
327        );
328        assert_eq!(debug.signing_identity.as_deref(), Some("-"));
329        assert_eq!(debug.provisioning_profile, None);
330        assert_eq!(debug.installer_identity, None);
331        assert_eq!(debug.notarize, None);
332
333        let release = package_config(manifest.package.as_ref(), true);
334        assert_eq!(
335            release.entitlements.as_deref(),
336            Some("platforms/macos/Release.entitlements")
337        );
338        assert_eq!(
339            release.provisioning_profile.as_deref(),
340            Some("profiles/Distribution.provisionprofile")
341        );
342        assert_eq!(
343            release.signing_identity.as_deref(),
344            Some("Developer ID Application: Example Ltd")
345        );
346        assert_eq!(
347            release.installer_identity.as_deref(),
348            Some("Developer ID Installer: Example Ltd")
349        );
350        assert_eq!(release.notarize, Some(true));
351
352        let release_run = run_config(&manifest, true);
353        assert_eq!(
354            release_run.signing_identity.as_deref(),
355            Some("Developer ID Application: Example Ltd")
356        );
357    }
358
359    #[test]
360    fn codesign_arguments_resolve_relative_entitlements() {
361        let config = MacosPackageConfig {
362            entitlements: Some("platforms/macos/App.entitlements".into()),
363            ..Default::default()
364        };
365
366        assert_eq!(
367            codesign_arguments(Path::new("/project"), "-", &config),
368            vec![
369                OsString::from("--force"),
370                OsString::from("--timestamp"),
371                OsString::from("--options"),
372                OsString::from("runtime"),
373                OsString::from("--sign"),
374                OsString::from("-"),
375                OsString::from("--entitlements"),
376                OsString::from("/project/platforms/macos/App.entitlements"),
377            ]
378        );
379    }
380
381    #[test]
382    fn run_signing_overrides_package_signing_only() {
383        let manifest: PackageManifest = toml::from_str(
384            r#"
385[package.macos]
386bundle_id = "com.example.app"
387minimum_os = "14.0"
388entitlements = "platforms/macos/Release.entitlements"
389provisioning_profile = "profiles/Release.provisionprofile"
390signing_identity = "Apple Development"
391
392[run.macos]
393entitlements = "platforms/macos/Development.entitlements"
394provisioning_profile = "profiles/Development.provisionprofile"
395signing_identity = "-"
396"#,
397        )
398        .unwrap();
399        let config = run_config(&manifest, false);
400
401        assert_eq!(config.bundle_id.as_deref(), Some("com.example.app"));
402        assert_eq!(config.minimum_os.as_deref(), Some("14.0"));
403        assert_eq!(
404            config.entitlements.as_deref(),
405            Some("platforms/macos/Development.entitlements")
406        );
407        assert_eq!(
408            config.provisioning_profile.as_deref(),
409            Some("profiles/Development.provisionprofile")
410        );
411        assert_eq!(config.signing_identity.as_deref(), Some("-"));
412    }
413
414    #[test]
415    fn provisioning_profile_rejects_ad_hoc_signing_identity() {
416        let config = MacosPackageConfig {
417            provisioning_profile: Some("profiles/Development.provisionprofile".into()),
418            signing_identity: Some("-".into()),
419            ..Default::default()
420        };
421
422        let error = sign_macos_app_if_configured(
423            Path::new("/project"),
424            Path::new("/project/Demo.app"),
425            &config,
426        )
427        .unwrap_err();
428
429        assert!(error.to_string().contains("ad-hoc signing"));
430    }
431
432    #[test]
433    fn embeds_relative_macos_provisioning_profile() {
434        let root =
435            std::env::temp_dir().join(format!("fission-macos-profile-{}", std::process::id()));
436        let project = root.join("project");
437        let app = root.join("Demo.app");
438        fs::remove_dir_all(&root).ok();
439        fs::create_dir_all(project.join("profiles")).unwrap();
440        fs::create_dir_all(app.join("Contents")).unwrap();
441        fs::write(
442            project.join("profiles/Development.provisionprofile"),
443            b"profile-data",
444        )
445        .unwrap();
446
447        embed_macos_provisioning_profile(&project, &app, "profiles/Development.provisionprofile")
448            .unwrap();
449
450        assert_eq!(
451            fs::read(app.join("Contents/embedded.provisionprofile")).unwrap(),
452            b"profile-data"
453        );
454        fs::remove_dir_all(root).unwrap();
455    }
456
457    #[test]
458    fn provisioning_profile_requires_signing_identity() {
459        let config = MacosPackageConfig {
460            provisioning_profile: Some("profiles/Development.provisionprofile".into()),
461            ..Default::default()
462        };
463
464        let error = sign_macos_app_if_configured(
465            Path::new("/project"),
466            Path::new("/project/Demo.app"),
467            &config,
468        )
469        .unwrap_err();
470
471        assert!(error.to_string().contains("requires"));
472    }
473}