electron-cli 0.3.0-alpha.16 → 0.3.0-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,10 @@ use std::{
5
5
  };
6
6
 
7
7
  use anyhow::{bail, Context, Result};
8
+ use apple_codesign::{
9
+ cryptography::{parse_pfx_data, PrivateKey},
10
+ BundleSigner, CodeSignatureFlags, SettingsScope, SigningSettings,
11
+ };
8
12
  use camino::Utf8PathBuf;
9
13
  use plist::{Dictionary as PlistDictionary, Value as PlistValue};
10
14
  use serde::Serialize;
@@ -71,7 +75,15 @@ struct MacosSigningPlan {
71
75
  struct MacosSignPlan {
72
76
  configured: bool,
73
77
  enabled: bool,
78
+ will_execute: bool,
79
+ method: Option<String>,
74
80
  identity: Option<String>,
81
+ p12_file: Option<Utf8PathBuf>,
82
+ p12_password_source: Option<String>,
83
+ p12_password_env: Option<String>,
84
+ p12_password_file: Option<Utf8PathBuf>,
85
+ #[serde(skip)]
86
+ p12_password: RedactedSecret,
75
87
  entitlements: Vec<Utf8PathBuf>,
76
88
  entitlements_inherit: Option<Utf8PathBuf>,
77
89
  hardened_runtime: Option<bool>,
@@ -87,6 +99,29 @@ struct MacosNotarizePlan {
87
99
  keychain: Option<String>,
88
100
  }
89
101
 
102
+ #[derive(Clone, Default)]
103
+ struct RedactedSecret(Option<String>);
104
+
105
+ impl std::fmt::Debug for RedactedSecret {
106
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107
+ if self.0.is_some() {
108
+ formatter.write_str("<redacted>")
109
+ } else {
110
+ formatter.write_str("<unset>")
111
+ }
112
+ }
113
+ }
114
+
115
+ impl RedactedSecret {
116
+ fn new(value: Option<String>) -> Self {
117
+ Self(value)
118
+ }
119
+
120
+ fn as_deref(&self) -> Option<&str> {
121
+ self.0.as_deref()
122
+ }
123
+ }
124
+
90
125
  #[derive(Clone, Copy, Debug, Serialize)]
91
126
  #[serde(rename_all = "kebab-case")]
92
127
  enum PackageStatus {
@@ -124,6 +159,10 @@ struct MacosSignConfig {
124
159
  enabled: bool,
125
160
  invalid_type: bool,
126
161
  identity: Option<String>,
162
+ p12_file: Option<String>,
163
+ p12_password: Option<String>,
164
+ p12_password_env: Option<String>,
165
+ p12_password_file: Option<String>,
127
166
  entitlements: Vec<String>,
128
167
  entitlements_inherit: Option<String>,
129
168
  hardened_runtime: Option<bool>,
@@ -363,10 +402,163 @@ pub(crate) fn execute_package(report: &PackageReport, force: bool) -> Result<()>
363
402
  &app_dir,
364
403
  &report.project,
365
404
  )?;
405
+ execute_macos_signing(report)?;
406
+
407
+ Ok(())
408
+ }
409
+
410
+ fn execute_macos_signing(report: &PackageReport) -> Result<()> {
411
+ if report.platform != "darwin" || !report.signing.macos.sign.will_execute {
412
+ return Ok(());
413
+ }
414
+
415
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
416
+ let bundle_parent = bundle_dir
417
+ .parent()
418
+ .context("macOS bundle output has no parent directory")?;
419
+ let bundle_name = bundle_dir
420
+ .file_name()
421
+ .context("macOS bundle output has no bundle directory name")?;
422
+ let unique_suffix = std::time::SystemTime::now()
423
+ .duration_since(std::time::UNIX_EPOCH)
424
+ .context("system clock is before the Unix epoch")?
425
+ .as_nanos();
426
+ let signing_parent = bundle_parent.join(format!(
427
+ ".electron-cli-signing-{}-{unique_suffix}",
428
+ std::process::id()
429
+ ));
430
+ let signed_bundle_dir = signing_parent.join(bundle_name);
431
+
432
+ if signing_parent.exists() {
433
+ fs::remove_dir_all(&signing_parent)
434
+ .with_context(|| format!("Could not remove {}", signing_parent.display()))?;
435
+ }
436
+
437
+ let signing_result = (|| -> Result<()> {
438
+ let mut signer = BundleSigner::new_from_path(bundle_dir).with_context(|| {
439
+ format!(
440
+ "Could not prepare macOS bundle signing for {}",
441
+ bundle_dir.display()
442
+ )
443
+ })?;
444
+ signer
445
+ .collect_nested_bundles()
446
+ .context("Could not discover nested macOS bundles for signing")?;
447
+
448
+ let mut settings = macos_signing_settings(report)?;
449
+ if let Some(p12_file) = &report.signing.macos.sign.p12_file {
450
+ let p12_path = Path::new(p12_file.as_str());
451
+ let p12_data = fs::read(p12_path)
452
+ .with_context(|| format!("Could not read {}", p12_path.display()))?;
453
+ let password = macos_p12_password(&report.signing.macos.sign)?;
454
+ let (certificate, signing_key) = parse_pfx_data(&p12_data, &password)
455
+ .with_context(|| format!("Could not parse {}", p12_path.display()))?;
456
+
457
+ settings.set_signing_key(signing_key.as_key_info_signer(), certificate);
458
+ settings.chain_apple_certificates();
459
+ settings.set_team_id_from_signing_certificate();
460
+ signer
461
+ .write_signed_bundle(&signed_bundle_dir, &settings)
462
+ .with_context(|| {
463
+ format!(
464
+ "Could not write signed macOS bundle to {}",
465
+ signed_bundle_dir.display()
466
+ )
467
+ })?;
468
+ } else {
469
+ signer
470
+ .write_signed_bundle(&signed_bundle_dir, &settings)
471
+ .with_context(|| {
472
+ format!(
473
+ "Could not write signed macOS bundle to {}",
474
+ signed_bundle_dir.display()
475
+ )
476
+ })?;
477
+ }
478
+
479
+ Ok(())
480
+ })();
481
+
482
+ if let Err(error) = signing_result {
483
+ let _ = fs::remove_dir_all(&signing_parent);
484
+ return Err(error);
485
+ }
486
+
487
+ fs::remove_dir_all(bundle_dir)
488
+ .with_context(|| format!("Could not remove {}", bundle_dir.display()))?;
489
+ fs::rename(&signed_bundle_dir, bundle_dir).with_context(|| {
490
+ format!(
491
+ "Could not move signed macOS bundle from {} to {}",
492
+ signed_bundle_dir.display(),
493
+ bundle_dir.display()
494
+ )
495
+ })?;
496
+ let _ = fs::remove_dir_all(&signing_parent);
366
497
 
367
498
  Ok(())
368
499
  }
369
500
 
501
+ fn macos_signing_settings<'key>(report: &PackageReport) -> Result<SigningSettings<'key>> {
502
+ let sign = &report.signing.macos.sign;
503
+ let mut settings = SigningSettings::default();
504
+ settings.set_binary_identifier(SettingsScope::Main, &report.metadata.bundle_identifier);
505
+
506
+ if sign.hardened_runtime.unwrap_or(false) {
507
+ settings.add_code_signature_flags(SettingsScope::Main, CodeSignatureFlags::RUNTIME);
508
+ }
509
+
510
+ if let Some(entitlements) = sign.entitlements.first() {
511
+ let entitlements_path = Path::new(entitlements.as_str());
512
+ let entitlements_xml = fs::read_to_string(entitlements_path).with_context(|| {
513
+ format!(
514
+ "Could not read macOS entitlements file {}",
515
+ entitlements_path.display()
516
+ )
517
+ })?;
518
+ settings
519
+ .set_entitlements_xml(SettingsScope::Main, entitlements_xml)
520
+ .with_context(|| {
521
+ format!(
522
+ "Could not parse macOS entitlements file {}",
523
+ entitlements_path.display()
524
+ )
525
+ })?;
526
+ }
527
+
528
+ Ok(settings)
529
+ }
530
+
531
+ fn macos_p12_password(sign: &MacosSignPlan) -> Result<String> {
532
+ if let Some(password) = sign.p12_password.as_deref() {
533
+ return Ok(password.to_string());
534
+ }
535
+
536
+ if let Some(env_name) = &sign.p12_password_env {
537
+ return std::env::var(env_name)
538
+ .with_context(|| format!("Could not read macOS signing p12 password env {env_name}"));
539
+ }
540
+
541
+ if let Some(path) = &sign.p12_password_file {
542
+ let password_path = Path::new(path.as_str());
543
+ return fs::read_to_string(password_path)
544
+ .with_context(|| {
545
+ format!(
546
+ "Could not read macOS signing p12 password file {}",
547
+ password_path.display()
548
+ )
549
+ })
550
+ .and_then(|contents| {
551
+ contents
552
+ .lines()
553
+ .next()
554
+ .map(str::to_string)
555
+ .context("macOS signing p12 password file is empty")
556
+ });
557
+ }
558
+
559
+ Ok(String::new())
560
+ }
561
+
370
562
  fn print_report(report: &PackageReport, json: bool) -> Result<()> {
371
563
  if json {
372
564
  return output::json(report);
@@ -403,6 +595,23 @@ fn print_report(report: &PackageReport, json: bool) -> Result<()> {
403
595
  if let Some(identity) = &report.signing.macos.sign.identity {
404
596
  println!(" identity: {identity}");
405
597
  }
598
+ if let Some(path) = &report.signing.macos.sign.p12_file {
599
+ println!(" p12 file: {path}");
600
+ }
601
+ if let Some(source) = &report.signing.macos.sign.p12_password_source {
602
+ println!(" p12 password: {source}");
603
+ }
604
+ if let Some(method) = &report.signing.macos.sign.method {
605
+ println!(" signing method: {method}");
606
+ }
607
+ println!(
608
+ " signing execution: {}",
609
+ if report.signing.macos.sign.will_execute {
610
+ "enabled"
611
+ } else {
612
+ "not available"
613
+ }
614
+ );
406
615
  println!(
407
616
  " macOS notarization: {}",
408
617
  if report.signing.macos.notarize.enabled {
@@ -533,6 +742,26 @@ fn parse_macos_sign_config(value: Option<&JsonValue>) -> MacosSignConfig {
533
742
  .or_else(|| object.get("identityName"))
534
743
  .and_then(JsonValue::as_str)
535
744
  .map(ToOwned::to_owned),
745
+ p12_file: object
746
+ .get("p12File")
747
+ .or_else(|| object.get("pfxFile"))
748
+ .and_then(JsonValue::as_str)
749
+ .map(ToOwned::to_owned),
750
+ p12_password: object
751
+ .get("p12Password")
752
+ .or_else(|| object.get("pfxPassword"))
753
+ .and_then(JsonValue::as_str)
754
+ .map(ToOwned::to_owned),
755
+ p12_password_env: object
756
+ .get("p12PasswordEnv")
757
+ .or_else(|| object.get("pfxPasswordEnv"))
758
+ .and_then(JsonValue::as_str)
759
+ .map(ToOwned::to_owned),
760
+ p12_password_file: object
761
+ .get("p12PasswordFile")
762
+ .or_else(|| object.get("pfxPasswordFile"))
763
+ .and_then(JsonValue::as_str)
764
+ .map(ToOwned::to_owned),
536
765
  entitlements,
537
766
  entitlements_inherit: object
538
767
  .get("entitlementsInherit")
@@ -720,21 +949,119 @@ fn macos_sign_plan(
720
949
  .filter(|path| !path.trim().is_empty())
721
950
  .map(|path| utf8_path(resolve_project_path(root, path)))
722
951
  .transpose()?;
952
+ if let Some(path) = &entitlements_inherit {
953
+ if !Path::new(path.as_str()).exists() {
954
+ warnings.push(format!(
955
+ "Configured macOS inherited entitlements file does not exist: {}.",
956
+ path
957
+ ));
958
+ }
959
+ }
960
+
961
+ let p12_file = config
962
+ .p12_file
963
+ .as_deref()
964
+ .filter(|path| !path.trim().is_empty())
965
+ .map(|path| utf8_path(resolve_project_path(root, path)))
966
+ .transpose()?;
967
+ if let Some(path) = &p12_file {
968
+ if !Path::new(path.as_str()).exists() {
969
+ warnings.push(format!(
970
+ "Configured macOS signing p12 file does not exist: {}.",
971
+ path
972
+ ));
973
+ }
974
+ }
975
+ let p12_password_file = config
976
+ .p12_password_file
977
+ .as_deref()
978
+ .filter(|path| !path.trim().is_empty())
979
+ .map(|path| utf8_path(resolve_project_path(root, path)))
980
+ .transpose()?;
981
+ if let Some(path) = &p12_password_file {
982
+ if !Path::new(path.as_str()).exists() {
983
+ warnings.push(format!(
984
+ "Configured macOS signing p12 password file does not exist: {}.",
985
+ path
986
+ ));
987
+ }
988
+ }
989
+ let p12_password_source = if p12_file.is_some() {
990
+ if config.p12_password.is_some() {
991
+ Some("config".to_string())
992
+ } else if let Some(env_name) = config
993
+ .p12_password_env
994
+ .as_deref()
995
+ .filter(|name| !name.trim().is_empty())
996
+ {
997
+ Some(format!("env:{env_name}"))
998
+ } else if let Some(path) = &p12_password_file {
999
+ Some(format!("file:{path}"))
1000
+ } else {
1001
+ Some("empty".to_string())
1002
+ }
1003
+ } else {
1004
+ None
1005
+ };
1006
+
1007
+ let identity = config.identity.as_deref().map(str::trim);
1008
+ let ad_hoc_identity = matches!(identity, None | Some("-"));
1009
+ let p12_identity = p12_file.is_some();
1010
+ let will_execute = config.enabled && platform == "darwin" && (ad_hoc_identity || p12_identity);
1011
+ let method = if config.enabled && platform == "darwin" {
1012
+ if p12_identity {
1013
+ Some("certificate-p12".to_string())
1014
+ } else if ad_hoc_identity {
1015
+ Some("ad-hoc".to_string())
1016
+ } else {
1017
+ Some("certificate-identity".to_string())
1018
+ }
1019
+ } else {
1020
+ None
1021
+ };
723
1022
 
724
1023
  if config.configured && platform != "darwin" {
725
1024
  warnings.push(format!(
726
1025
  "macOS signing is configured but ignored for target platform {platform}."
727
1026
  ));
728
- } else if config.enabled {
1027
+ } else if config.enabled && !will_execute {
729
1028
  warnings.push(
730
- "macOS signing is configured, but Rust-native signing is not implemented yet; package output will be unsigned.".to_string(),
1029
+ "macOS signing identity is configured, but Rust-native keychain identity signing is not implemented yet; package output will be unsigned. Use p12File for certificate signing, or identity '-' / omit identity for experimental ad-hoc signing.".to_string(),
731
1030
  );
1031
+ } else if will_execute {
1032
+ if p12_identity && identity.is_some() {
1033
+ warnings.push(
1034
+ "packagerConfig.osxSign.p12File supplies the signing certificate; identity is reported but not used for keychain lookup.".to_string(),
1035
+ );
1036
+ }
1037
+ if config.entitlements.len() > 1 {
1038
+ warnings.push(
1039
+ "Rust-native macOS signing applies the first macOS entitlements file only; inherited/login-helper entitlement scoping is not implemented yet.".to_string(),
1040
+ );
1041
+ }
1042
+ if config.entitlements_inherit.is_some() {
1043
+ warnings.push(
1044
+ "packagerConfig.osxSign.entitlementsInherit is recognized but not applied to nested bundles by Rust-native signing yet.".to_string(),
1045
+ );
1046
+ }
1047
+ if config.gatekeeper_assess.is_some() {
1048
+ warnings.push(
1049
+ "packagerConfig.osxSign.gatekeeperAssess is recognized but Gatekeeper assessment is not implemented yet.".to_string(),
1050
+ );
1051
+ }
732
1052
  }
733
1053
 
734
1054
  Ok(MacosSignPlan {
735
1055
  configured: config.configured,
736
1056
  enabled: config.enabled,
1057
+ will_execute,
1058
+ method,
737
1059
  identity: config.identity.clone(),
1060
+ p12_file,
1061
+ p12_password_source,
1062
+ p12_password_env: config.p12_password_env.clone(),
1063
+ p12_password_file,
1064
+ p12_password: RedactedSecret::new(config.p12_password.clone()),
738
1065
  entitlements,
739
1066
  entitlements_inherit,
740
1067
  hardened_runtime: config.hardened_runtime,
@@ -769,6 +1096,24 @@ fn macos_notarize_plan(
769
1096
  "macOS notarization requires packagerConfig.osxSign to be enabled first.".to_string(),
770
1097
  );
771
1098
  }
1099
+ if config.enabled
1100
+ && platform == "darwin"
1101
+ && package_config.packager.osx_sign.enabled
1102
+ && package_config.packager.osx_sign.p12_file.is_none()
1103
+ && matches!(
1104
+ package_config
1105
+ .packager
1106
+ .osx_sign
1107
+ .identity
1108
+ .as_deref()
1109
+ .map(str::trim),
1110
+ None | Some("-")
1111
+ )
1112
+ {
1113
+ warnings.push(
1114
+ "macOS notarization requires a Developer ID signature; Rust-native ad-hoc signing is not notarizable.".to_string(),
1115
+ );
1116
+ }
772
1117
  if config.enabled && auth_method.is_none() {
773
1118
  warnings.push(
774
1119
  "macOS notarization config is missing a complete notarytool authentication set: appleId/appleIdPassword/teamId, appleApiKey/appleApiKeyId/appleApiIssuer, or keychainProfile.".to_string(),
@@ -1134,6 +1479,7 @@ fn apply_macos_metadata(report: &PackageReport) -> Result<()> {
1134
1479
  "CFBundleIdentifier",
1135
1480
  &report.metadata.bundle_identifier,
1136
1481
  );
1482
+ set_plist_string(&mut dictionary, "CFBundlePackageType", "APPL");
1137
1483
 
1138
1484
  if let Some(version) = &report.metadata.app_version {
1139
1485
  set_plist_string(&mut dictionary, "CFBundleShortVersionString", version);
@@ -1816,6 +2162,11 @@ mod tests {
1816
2162
 
1817
2163
  assert!(report.signing.macos.sign.configured);
1818
2164
  assert!(report.signing.macos.sign.enabled);
2165
+ assert!(!report.signing.macos.sign.will_execute);
2166
+ assert_eq!(
2167
+ report.signing.macos.sign.method.as_deref(),
2168
+ Some("certificate-identity")
2169
+ );
1819
2170
  assert_eq!(
1820
2171
  report.signing.macos.sign.identity.as_deref(),
1821
2172
  Some("Developer ID Application: Example, Inc. (TEAMID1234)")
@@ -1831,7 +2182,7 @@ mod tests {
1831
2182
  assert!(report
1832
2183
  .warnings
1833
2184
  .iter()
1834
- .any(|warning| warning.contains("Rust-native signing is not implemented")));
2185
+ .any(|warning| warning.contains("Rust-native keychain identity signing")));
1835
2186
  assert!(report
1836
2187
  .warnings
1837
2188
  .iter()
@@ -1845,6 +2196,115 @@ mod tests {
1845
2196
  let _ = fs::remove_dir_all(root);
1846
2197
  }
1847
2198
 
2199
+ #[test]
2200
+ fn plans_macos_ad_hoc_signing_execution() {
2201
+ let root = unique_temp_dir("macos-ad-hoc-signing-plan");
2202
+ write_package_json(&root);
2203
+ fs::write(
2204
+ root.join("forge.config.js"),
2205
+ r#"
2206
+ module.exports = {
2207
+ packagerConfig: {
2208
+ osxSign: {
2209
+ identity: '-',
2210
+ hardenedRuntime: true,
2211
+ },
2212
+ },
2213
+ };
2214
+ "#,
2215
+ )
2216
+ .expect("forge config should be written");
2217
+ write_app_file(&root);
2218
+ write_fake_electron_dist(&root);
2219
+
2220
+ let args = PackageArgs {
2221
+ cwd: root.clone(),
2222
+ out_dir: PathBuf::from("out"),
2223
+ name: None,
2224
+ platform: Some("darwin".to_string()),
2225
+ arch: Some("arm64".to_string()),
2226
+ force: false,
2227
+ dry_run: true,
2228
+ json: true,
2229
+ };
2230
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2231
+ let report = build_report(snapshot, &args).expect("report should build");
2232
+
2233
+ assert!(report.signing.macos.sign.configured);
2234
+ assert!(report.signing.macos.sign.enabled);
2235
+ assert!(report.signing.macos.sign.will_execute);
2236
+ assert_eq!(report.signing.macos.sign.method.as_deref(), Some("ad-hoc"));
2237
+ assert_eq!(report.signing.macos.sign.identity.as_deref(), Some("-"));
2238
+ assert_eq!(report.signing.macos.sign.hardened_runtime, Some(true));
2239
+ assert!(!report.warnings.iter().any(|warning| {
2240
+ warning.contains("Rust-native keychain identity signing")
2241
+ || warning.contains("Rust-native signing is not implemented")
2242
+ }));
2243
+
2244
+ let _ = fs::remove_dir_all(root);
2245
+ }
2246
+
2247
+ #[test]
2248
+ fn plans_macos_p12_signing_without_serializing_password() {
2249
+ let root = unique_temp_dir("macos-p12-signing-plan");
2250
+ write_package_json(&root);
2251
+ fs::write(root.join("developer-id.p12"), "not a real p12")
2252
+ .expect("p12 placeholder should be written");
2253
+ fs::write(
2254
+ root.join("forge.config.js"),
2255
+ r#"
2256
+ module.exports = {
2257
+ packagerConfig: {
2258
+ osxSign: {
2259
+ identity: 'Developer ID Application: Example, Inc. (TEAMID1234)',
2260
+ p12File: 'developer-id.p12',
2261
+ p12Password: 'p12-secret',
2262
+ hardenedRuntime: true,
2263
+ },
2264
+ },
2265
+ };
2266
+ "#,
2267
+ )
2268
+ .expect("forge config should be written");
2269
+ write_app_file(&root);
2270
+ write_fake_electron_dist(&root);
2271
+
2272
+ let args = PackageArgs {
2273
+ cwd: root.clone(),
2274
+ out_dir: PathBuf::from("out"),
2275
+ name: None,
2276
+ platform: Some("darwin".to_string()),
2277
+ arch: Some("arm64".to_string()),
2278
+ force: false,
2279
+ dry_run: true,
2280
+ json: true,
2281
+ };
2282
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2283
+ let report = build_report(snapshot, &args).expect("report should build");
2284
+
2285
+ assert!(report.signing.macos.sign.configured);
2286
+ assert!(report.signing.macos.sign.enabled);
2287
+ assert!(report.signing.macos.sign.will_execute);
2288
+ assert_eq!(
2289
+ report.signing.macos.sign.method.as_deref(),
2290
+ Some("certificate-p12")
2291
+ );
2292
+ assert_eq!(
2293
+ report.signing.macos.sign.p12_password_source.as_deref(),
2294
+ Some("config")
2295
+ );
2296
+ assert!(report.signing.macos.sign.p12_file.is_some());
2297
+ assert!(report
2298
+ .warnings
2299
+ .iter()
2300
+ .any(|warning| { warning.contains("p12File supplies the signing certificate") }));
2301
+
2302
+ let json = serde_json::to_string(&report).expect("report should serialize");
2303
+ assert!(!json.contains("p12-secret"));
2304
+
2305
+ let _ = fs::remove_dir_all(root);
2306
+ }
2307
+
1848
2308
  #[test]
1849
2309
  fn warns_when_macos_notarization_is_configured_without_signing() {
1850
2310
  let root = unique_temp_dir("notarize-without-sign");
@@ -1947,6 +2407,10 @@ mod tests {
1947
2407
  plist_string(dictionary, "CFBundleIdentifier"),
1948
2408
  Some("com.example.starter")
1949
2409
  );
2410
+ assert_eq!(
2411
+ plist_string(dictionary, "CFBundlePackageType"),
2412
+ Some("APPL")
2413
+ );
1950
2414
  assert_eq!(
1951
2415
  plist_string(dictionary, "CFBundleShortVersionString"),
1952
2416
  Some("2.3.4")
@@ -1970,6 +2434,122 @@ mod tests {
1970
2434
  let _ = fs::remove_dir_all(root);
1971
2435
  }
1972
2436
 
2437
+ #[test]
2438
+ fn packages_macos_bundle_with_ad_hoc_signature() {
2439
+ if current_platform() != "darwin" {
2440
+ return;
2441
+ }
2442
+
2443
+ let root = unique_temp_dir("macos-ad-hoc-signing-execute");
2444
+ fs::write(
2445
+ root.join("package.json"),
2446
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","devDependencies":{"electron":"30.0.0"},"electronCli":{"packagerConfig":{"appBundleId":"com.example.signed","osxSign":true}}}"#,
2447
+ )
2448
+ .expect("package.json should be written");
2449
+ write_app_file(&root);
2450
+ write_macho_electron_dist(&root);
2451
+
2452
+ let args = PackageArgs {
2453
+ cwd: root.clone(),
2454
+ out_dir: PathBuf::from("out"),
2455
+ name: None,
2456
+ platform: None,
2457
+ arch: None,
2458
+ force: false,
2459
+ dry_run: false,
2460
+ json: false,
2461
+ };
2462
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2463
+ let report = build_report(snapshot, &args).expect("report should build");
2464
+
2465
+ assert!(report.signing.macos.sign.will_execute);
2466
+ assert_eq!(report.signing.macos.sign.method.as_deref(), Some("ad-hoc"));
2467
+ assert!(report.warnings.is_empty());
2468
+
2469
+ execute_package(&report, false).expect("package should succeed");
2470
+
2471
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
2472
+ assert!(bundle_dir
2473
+ .join("Contents/_CodeSignature/CodeResources")
2474
+ .exists());
2475
+
2476
+ let executable = bundle_dir
2477
+ .join("Contents/MacOS")
2478
+ .join(&report.executable_name);
2479
+ let executable_data = fs::read(executable).expect("signed executable should read");
2480
+ let macho = apple_codesign::MachFile::parse(&executable_data)
2481
+ .expect("signed executable should parse as Mach-O");
2482
+ assert!(macho.iter_macho().all(|binary| binary
2483
+ .code_signature()
2484
+ .expect("code signature should parse")
2485
+ .is_some()));
2486
+
2487
+ let _ = fs::remove_dir_all(root);
2488
+ }
2489
+
2490
+ #[test]
2491
+ fn packages_macos_bundle_with_p12_certificate_signature() {
2492
+ if current_platform() != "darwin" {
2493
+ return;
2494
+ }
2495
+
2496
+ let Some(p12_fixture) = apple_codesign_test_fixture("apple-codesign-testuser.p12") else {
2497
+ return;
2498
+ };
2499
+
2500
+ let root = unique_temp_dir("macos-p12-signing-execute");
2501
+ fs::copy(&p12_fixture, root.join("developer-id.p12"))
2502
+ .expect("p12 fixture should be copied");
2503
+ fs::write(
2504
+ root.join("package.json"),
2505
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","devDependencies":{"electron":"30.0.0"},"electronCli":{"packagerConfig":{"appBundleId":"com.example.p12-signed","osxSign":{"p12File":"developer-id.p12","p12Password":"password123","hardenedRuntime":true}}}}"#,
2506
+ )
2507
+ .expect("package.json should be written");
2508
+ write_app_file(&root);
2509
+ write_macho_electron_dist(&root);
2510
+
2511
+ let args = PackageArgs {
2512
+ cwd: root.clone(),
2513
+ out_dir: PathBuf::from("out"),
2514
+ name: None,
2515
+ platform: None,
2516
+ arch: None,
2517
+ force: false,
2518
+ dry_run: false,
2519
+ json: false,
2520
+ };
2521
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2522
+ let report = build_report(snapshot, &args).expect("report should build");
2523
+
2524
+ assert!(report.signing.macos.sign.will_execute);
2525
+ assert_eq!(
2526
+ report.signing.macos.sign.method.as_deref(),
2527
+ Some("certificate-p12")
2528
+ );
2529
+ assert!(report.warnings.is_empty());
2530
+
2531
+ execute_package(&report, false).expect("package should succeed");
2532
+
2533
+ let executable = Path::new(report.bundle_dir.as_str())
2534
+ .join("Contents/MacOS")
2535
+ .join(&report.executable_name);
2536
+ let executable_data = fs::read(executable).expect("signed executable should read");
2537
+ let macho = apple_codesign::MachFile::parse(&executable_data)
2538
+ .expect("signed executable should parse as Mach-O");
2539
+ assert!(macho.iter_macho().all(|binary| {
2540
+ let signature = binary
2541
+ .code_signature()
2542
+ .expect("code signature should parse")
2543
+ .expect("code signature should exist");
2544
+ signature
2545
+ .signature_data()
2546
+ .expect("CMS signature should parse")
2547
+ .is_some_and(|data| !data.is_empty())
2548
+ }));
2549
+
2550
+ let _ = fs::remove_dir_all(root);
2551
+ }
2552
+
1973
2553
  #[test]
1974
2554
  fn missing_required_runtime_dependency_fails() {
1975
2555
  let root = unique_temp_dir("runtime-deps");
@@ -2123,6 +2703,41 @@ mod tests {
2123
2703
  }
2124
2704
  }
2125
2705
 
2706
+ fn write_macho_electron_dist(root: &Path) {
2707
+ let app = root.join("node_modules/electron/dist/Electron.app/Contents/MacOS");
2708
+ fs::create_dir_all(&app).expect("macOS Electron app should be created");
2709
+ fs::copy(
2710
+ std::env::current_exe().expect("current test executable should resolve"),
2711
+ app.join("Electron"),
2712
+ )
2713
+ .expect("Mach-O test executable should be copied");
2714
+ }
2715
+
2716
+ fn apple_codesign_test_fixture(file_name: &str) -> Option<PathBuf> {
2717
+ let cargo_home = std::env::var_os("CARGO_HOME")
2718
+ .map(PathBuf::from)
2719
+ .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")))?;
2720
+ let registry_src = cargo_home.join("registry/src");
2721
+ for index_dir in fs::read_dir(registry_src).ok()? {
2722
+ let index_dir = index_dir.ok()?;
2723
+ for crate_dir in fs::read_dir(index_dir.path()).ok()? {
2724
+ let crate_dir = crate_dir.ok()?;
2725
+ let file_name_matches = crate_dir
2726
+ .file_name()
2727
+ .to_str()
2728
+ .is_some_and(|name| name.starts_with("apple-codesign-"));
2729
+ if file_name_matches {
2730
+ let candidate = crate_dir.path().join("src").join(file_name);
2731
+ if candidate.exists() {
2732
+ return Some(candidate);
2733
+ }
2734
+ }
2735
+ }
2736
+ }
2737
+
2738
+ None
2739
+ }
2740
+
2126
2741
  fn unique_temp_dir(label: &str) -> PathBuf {
2127
2742
  let nanos = std::time::SystemTime::now()
2128
2743
  .duration_since(std::time::UNIX_EPOCH)