1use anyhow::{Context as _, Result, bail};
15
16use anodizer_core::context::Context;
17use anodizer_core::stage::Stage;
18
19mod retry;
20mod run;
21mod secret;
22
23use run::{run_cross_platform, run_native};
24use secret::refresh_artifact_checksums;
25
26#[cfg(all(test, unix))]
31use retry::{is_retriable_notarize_output, run_with_retry};
32#[cfg(test)]
33use secret::matches_ids;
34
35pub struct NotarizeStage;
36
37impl Stage for NotarizeStage {
38 fn name(&self) -> &str {
39 "notarize"
40 }
41
42 fn run(&self, ctx: &mut Context) -> Result<()> {
43 let log = ctx.logger("notarize");
44 let dry_run = ctx.options.dry_run;
45
46 let notarize_config = match ctx.config.notarize {
47 Some(ref cfg) => cfg,
48 None => return Ok(()),
49 };
50
51 if let Some(ref d) = notarize_config.skip
55 && d.try_evaluates_to_true(|s| ctx.render_template(s))
56 .with_context(|| "notarize: evaluate top-level skip expression")?
57 {
58 log.skip_line(ctx.options.show_skipped, "notarization skipped");
59 return Ok(());
60 }
61
62 let has_cross = notarize_config
67 .macos
68 .as_ref()
69 .is_some_and(|v| !v.is_empty());
70 let has_native = notarize_config
71 .macos_native
72 .as_ref()
73 .is_some_and(|v| !v.is_empty());
74 if has_cross && has_native {
75 bail!(
76 "notarize: 'macos' and 'macos_native' cannot both be populated — \
77 they sign and notarize the same artifacts via different toolchains. \
78 Pick one (rcodesign for macos, codesign+notarytool for macos_native)."
79 );
80 }
81
82 if let Some(ref macos_configs) = notarize_config.macos {
84 for (idx, cfg) in macos_configs.iter().enumerate() {
85 run_cross_platform(ctx, cfg, idx, dry_run, &log)?;
86 }
87 }
88
89 if let Some(ref native_configs) = notarize_config.macos_native {
91 for (idx, cfg) in native_configs.iter().enumerate() {
92 run_native(ctx, cfg, idx, dry_run, &log)?;
93 }
94 }
95
96 if !dry_run {
99 refresh_artifact_checksums(ctx, &log);
100 }
101
102 Ok(())
103 }
104}
105
106pub fn env_requirements(
117 ctx: &anodizer_core::context::Context,
118) -> Vec<anodizer_core::EnvRequirement> {
119 use anodizer_core::env_preflight::template_env_refs;
120 let Some(cfg) = ctx.config.notarize.as_ref() else {
121 return Vec::new();
122 };
123 if anodizer_core::env_preflight::entry_inactive(ctx, cfg.skip.as_ref(), None, None) {
124 return Vec::new();
125 }
126 let mut out = Vec::new();
127 let push_refs = |out: &mut Vec<anodizer_core::EnvRequirement>, value: Option<&str>| {
128 if let Some(v) = value {
129 let refs = template_env_refs(v);
130 if !refs.is_empty() {
131 out.push(anodizer_core::EnvRequirement::EnvAllOf { vars: refs });
132 }
133 }
134 };
135 for entry in cfg.macos.iter().flatten() {
136 if entry
138 .should_skip(|s| ctx.render_template(s))
139 .unwrap_or(false)
140 {
141 continue;
142 }
143 out.push(anodizer_core::EnvRequirement::Tool {
144 name: "rcodesign".to_string(),
145 });
146 if let Some(sign) = entry.sign.as_ref() {
147 push_refs(&mut out, sign.certificate.as_deref());
148 push_refs(&mut out, sign.password.as_deref());
149 }
150 if let Some(notarize) = entry.notarize.as_ref() {
151 push_refs(&mut out, notarize.issuer_id.as_deref());
152 push_refs(&mut out, notarize.key.as_deref());
153 push_refs(&mut out, notarize.key_id.as_deref());
154 }
155 }
156 for entry in cfg.macos_native.iter().flatten() {
157 if entry
158 .should_skip(|s| ctx.render_template(s))
159 .unwrap_or(false)
160 {
161 continue;
162 }
163 out.push(anodizer_core::EnvRequirement::Tool {
164 name: "codesign".to_string(),
165 });
166 out.push(anodizer_core::EnvRequirement::Tool {
167 name: "xcrun".to_string(),
168 });
169 if let Some(sign) = entry.sign.as_ref() {
170 push_refs(&mut out, sign.identity.as_deref());
171 push_refs(&mut out, sign.keychain.as_deref());
172 }
173 if let Some(notarize) = entry.notarize.as_ref() {
174 push_refs(&mut out, notarize.profile_name.as_deref());
175 }
176 }
177 out
178}
179
180#[cfg(test)]
181#[allow(clippy::field_reassign_with_default)]
182mod tests {
183 use super::*;
184 use std::collections::HashMap;
185 use std::path::PathBuf;
186
187 use anodizer_core::artifact::{Artifact, ArtifactKind};
188 use anodizer_core::config::{
189 Config, MacOSNativeNotarizeConfig, MacOSNativeSignConfig, MacOSNativeSignNotarizeConfig,
190 MacOSNotarizeApiConfig, MacOSSignConfig, MacOSSignNotarizeConfig, NotarizeConfig,
191 StringOrBool,
192 };
193 use anodizer_core::context::{Context, ContextOptions};
194
195 #[test]
200 fn test_cross_platform_config_deserializes() {
201 let yaml = r#"
204notarize:
205 macos:
206 - ids: [myapp]
207 sign:
208 certificate: /path/to/cert.p12
209 password: "s3cret"
210 entitlements: entitlements.xml
211 notarize:
212 issuer_id: "abc-123"
213 key: /path/to/key.p8
214 key_id: "KEY123"
215 timeout: "15m"
216 wait: true
217"#;
218 let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
219 let notarize = config.notarize.unwrap();
220 let macos = notarize.macos.unwrap();
221 assert_eq!(macos.len(), 1);
222
223 let entry = &macos[0];
224 assert_eq!(entry.skip, None);
225 assert_eq!(entry.ids, Some(vec!["myapp".to_string()]));
226
227 let sign = entry.sign.as_ref().unwrap();
228 assert_eq!(sign.certificate, Some("/path/to/cert.p12".to_string()));
229 assert_eq!(sign.password, Some("s3cret".to_string()));
230 assert_eq!(sign.entitlements, Some("entitlements.xml".to_string()));
231
232 let notarize_api = entry.notarize.as_ref().unwrap();
233 assert_eq!(notarize_api.issuer_id, Some("abc-123".to_string()));
234 assert_eq!(notarize_api.key, Some("/path/to/key.p8".to_string()));
235 assert_eq!(notarize_api.key_id, Some("KEY123".to_string()));
236 assert_eq!(
237 notarize_api.timeout.map(|d| d.as_humantime_string()),
238 Some("15m".to_string())
239 );
240 assert_eq!(notarize_api.wait, Some(true));
241 }
242
243 #[test]
244 fn test_native_config_deserializes() {
245 let yaml = r#"
246notarize:
247 macos_native:
248 - use: dmg
249 ids: [myapp]
250 sign:
251 identity: "Developer ID Application: Example"
252 keychain: /path/to/keychain
253 options: [runtime]
254 entitlements: entitlements.xml
255 notarize:
256 profile_name: "my-profile"
257 wait: true
258"#;
259 let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
260 let notarize = config.notarize.unwrap();
261 let native = notarize.macos_native.unwrap();
262 assert_eq!(native.len(), 1);
263
264 let entry = &native[0];
265 assert_eq!(entry.skip, None);
266 assert_eq!(
267 entry.use_,
268 Some(anodizer_core::config::MacOSNativeArtifactKind::Dmg)
269 );
270 assert_eq!(entry.ids, Some(vec!["myapp".to_string()]));
271
272 let sign = entry.sign.as_ref().unwrap();
273 assert_eq!(
274 sign.identity,
275 Some("Developer ID Application: Example".to_string())
276 );
277 assert_eq!(sign.keychain, Some("/path/to/keychain".to_string()));
278 assert_eq!(sign.options, Some(vec!["runtime".to_string()]));
279 assert_eq!(sign.entitlements, Some("entitlements.xml".to_string()));
280
281 let notarize_cfg = entry.notarize.as_ref().unwrap();
282 assert_eq!(notarize_cfg.profile_name, Some("my-profile".to_string()));
283 assert_eq!(notarize_cfg.wait, Some(true));
284 }
285
286 #[test]
287 fn test_native_config_pkg_mode_deserializes() {
288 let yaml = r#"
289notarize:
290 macos_native:
291 - use: pkg
292 sign:
293 identity: "Developer ID Installer: Example"
294 notarize:
295 profile_name: "my-profile"
296"#;
297 let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
298 let notarize = config.notarize.unwrap();
299 let native = notarize.macos_native.unwrap();
300 assert_eq!(
301 native[0].use_,
302 Some(anodizer_core::config::MacOSNativeArtifactKind::Pkg)
303 );
304 }
305
306 #[test]
307 fn test_skip_string_template_deserializes() {
308 let yaml = r#"
311notarize:
312 macos:
313 - skip: "{{ if .IsSnapshot }}true{{ endif }}"
314 sign:
315 certificate: cert.p12
316 password: pass
317"#;
318 let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
319 let macos = config.notarize.unwrap().macos.unwrap();
320 match &macos[0].skip {
321 Some(StringOrBool::String(s)) => {
322 assert_eq!(s, "{{ if .IsSnapshot }}true{{ endif }}")
323 }
324 other => panic!("expected StringOrBool::String, got {:?}", other),
325 }
326 }
327
328 #[test]
329 fn test_both_modes_in_single_config() {
330 let yaml = r#"
331notarize:
332 macos:
333 - sign:
334 certificate: cert.p12
335 password: pass
336 macos_native:
337 - sign:
338 identity: "Developer ID Application: Test"
339 notarize:
340 profile_name: test-profile
341"#;
342 let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
343 let notarize = config.notarize.unwrap();
344 assert!(notarize.macos.is_some());
345 assert!(notarize.macos_native.is_some());
346 }
347
348 #[test]
349 fn test_empty_notarize_config_deserializes() {
350 let yaml = r#"
351notarize: {}
352"#;
353 let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
354 let notarize = config.notarize.unwrap();
355 assert!(notarize.macos.is_none());
356 assert!(notarize.macos_native.is_none());
357 }
358
359 fn make_ctx_with_notarize(config: Config) -> Context {
364 Context::new(
365 config,
366 ContextOptions {
367 dry_run: true,
368 ..Default::default()
369 },
370 )
371 }
372
373 #[test]
374 fn test_stage_skips_when_no_notarize_config() {
375 let config = Config::default();
376 let mut ctx = make_ctx_with_notarize(config);
377
378 let stage = NotarizeStage;
379 stage.run(&mut ctx).unwrap();
380 }
382
383 #[test]
384 fn test_stage_skips_disabled_cross_platform() {
385 let mut config = Config::default();
386 config.project_name = "myapp".to_string();
387 config.notarize = Some(NotarizeConfig {
388 skip: None,
389 macos: Some(vec![MacOSSignNotarizeConfig {
390 skip: Some(StringOrBool::Bool(true)),
391 sign: Some(MacOSSignConfig {
392 certificate: Some("cert.p12".to_string()),
393 password: Some("pass".to_string()),
394 ..Default::default()
395 }),
396 ..Default::default()
397 }]),
398 macos_native: None,
399 });
400
401 let mut ctx = make_ctx_with_notarize(config);
402 let stage = NotarizeStage;
403 stage.run(&mut ctx).unwrap();
404 }
406
407 #[test]
408 fn test_stage_skips_disabled_native() {
409 let mut config = Config::default();
410 config.project_name = "myapp".to_string();
411 config.notarize = Some(NotarizeConfig {
412 skip: None,
413 macos: None,
414 macos_native: Some(vec![MacOSNativeSignNotarizeConfig {
415 skip: Some(StringOrBool::Bool(true)),
416 sign: Some(MacOSNativeSignConfig {
417 identity: Some("Developer ID".to_string()),
418 ..Default::default()
419 }),
420 notarize: Some(MacOSNativeNotarizeConfig {
421 profile_name: Some("profile".to_string()),
422 ..Default::default()
423 }),
424 ..Default::default()
425 }]),
426 });
427
428 let mut ctx = make_ctx_with_notarize(config);
429 let stage = NotarizeStage;
430 stage.run(&mut ctx).unwrap();
431 }
432
433 #[test]
434 fn test_stage_skips_when_enabled_is_none() {
435 let mut config = Config::default();
436 config.notarize = Some(NotarizeConfig {
437 skip: None,
438 macos: Some(vec![MacOSSignNotarizeConfig {
439 skip: Some(StringOrBool::Bool(true)),
440 sign: Some(MacOSSignConfig {
441 certificate: Some("cert.p12".to_string()),
442 password: Some("pass".to_string()),
443 ..Default::default()
444 }),
445 ..Default::default()
446 }]),
447 macos_native: None,
448 });
449
450 let mut ctx = make_ctx_with_notarize(config);
451 let stage = NotarizeStage;
452 stage.run(&mut ctx).unwrap();
454 }
455
456 #[test]
461 fn test_cross_platform_requires_sign_config() {
462 let mut config = Config::default();
463 config.notarize = Some(NotarizeConfig {
464 skip: None,
465 macos: Some(vec![MacOSSignNotarizeConfig {
466 skip: None,
467 sign: None,
468 ..Default::default()
469 }]),
470 macos_native: None,
471 });
472
473 let mut ctx = make_ctx_with_notarize(config);
474 let stage = NotarizeStage;
475 let result = stage.run(&mut ctx);
476 assert!(result.is_err());
477 assert!(
478 result
479 .unwrap_err()
480 .to_string()
481 .contains("requires a 'sign'"),
482 "error should mention missing sign config"
483 );
484 }
485
486 #[test]
487 fn test_cross_platform_requires_certificate() {
488 let mut config = Config::default();
489 config.notarize = Some(NotarizeConfig {
490 skip: None,
491 macos: Some(vec![MacOSSignNotarizeConfig {
492 skip: None,
493 sign: Some(MacOSSignConfig {
494 certificate: None,
495 password: Some("pass".to_string()),
496 ..Default::default()
497 }),
498 ..Default::default()
499 }]),
500 macos_native: None,
501 });
502
503 let mut ctx = make_ctx_with_notarize(config);
504 let stage = NotarizeStage;
505 let result = stage.run(&mut ctx);
506 assert!(result.is_err());
507 assert!(
508 result
509 .unwrap_err()
510 .to_string()
511 .contains("sign.certificate is required"),
512 );
513 }
514
515 #[test]
516 fn test_cross_platform_requires_password() {
517 let mut config = Config::default();
518 config.notarize = Some(NotarizeConfig {
519 skip: None,
520 macos: Some(vec![MacOSSignNotarizeConfig {
521 skip: None,
522 sign: Some(MacOSSignConfig {
523 certificate: Some("cert.p12".to_string()),
524 password: None,
525 ..Default::default()
526 }),
527 ..Default::default()
528 }]),
529 macos_native: None,
530 });
531
532 let mut ctx = make_ctx_with_notarize(config);
533 let stage = NotarizeStage;
534 let result = stage.run(&mut ctx);
535 assert!(result.is_err());
536 assert!(
537 result
538 .unwrap_err()
539 .to_string()
540 .contains("sign.password is required"),
541 );
542 }
543
544 #[test]
545 fn test_native_requires_sign_config() {
546 let mut config = Config::default();
547 config.notarize = Some(NotarizeConfig {
548 skip: None,
549 macos: None,
550 macos_native: Some(vec![MacOSNativeSignNotarizeConfig {
551 skip: None,
552 sign: None,
553 notarize: Some(MacOSNativeNotarizeConfig {
554 profile_name: Some("profile".to_string()),
555 ..Default::default()
556 }),
557 ..Default::default()
558 }]),
559 });
560
561 let mut ctx = make_ctx_with_notarize(config);
562 let stage = NotarizeStage;
563 let result = stage.run(&mut ctx);
564 assert!(result.is_err());
565 assert!(
566 result
567 .unwrap_err()
568 .to_string()
569 .contains("requires a 'sign'"),
570 );
571 }
572
573 #[test]
574 fn test_native_requires_identity() {
575 let mut config = Config::default();
576 config.notarize = Some(NotarizeConfig {
577 skip: None,
578 macos: None,
579 macos_native: Some(vec![MacOSNativeSignNotarizeConfig {
580 skip: None,
581 sign: Some(MacOSNativeSignConfig {
582 identity: None,
583 ..Default::default()
584 }),
585 notarize: Some(MacOSNativeNotarizeConfig {
586 profile_name: Some("profile".to_string()),
587 ..Default::default()
588 }),
589 ..Default::default()
590 }]),
591 });
592
593 let mut ctx = make_ctx_with_notarize(config);
594 let stage = NotarizeStage;
595 let result = stage.run(&mut ctx);
596 assert!(result.is_err());
597 assert!(
598 result
599 .unwrap_err()
600 .to_string()
601 .contains("sign.identity is required"),
602 );
603 }
604
605 #[test]
606 fn test_native_requires_notarize_config() {
607 let mut config = Config::default();
608 config.notarize = Some(NotarizeConfig {
609 skip: None,
610 macos: None,
611 macos_native: Some(vec![MacOSNativeSignNotarizeConfig {
612 skip: None,
613 sign: Some(MacOSNativeSignConfig {
614 identity: Some("Developer ID".to_string()),
615 ..Default::default()
616 }),
617 notarize: None,
618 ..Default::default()
619 }]),
620 });
621
622 let mut ctx = make_ctx_with_notarize(config);
623 let stage = NotarizeStage;
624 let result = stage.run(&mut ctx);
625 assert!(result.is_err());
626 assert!(
627 result
628 .unwrap_err()
629 .to_string()
630 .contains("requires a 'notarize'"),
631 );
632 }
633
634 #[test]
635 fn test_native_requires_profile_name() {
636 let mut config = Config::default();
637 config.notarize = Some(NotarizeConfig {
638 skip: None,
639 macos: None,
640 macos_native: Some(vec![MacOSNativeSignNotarizeConfig {
641 skip: None,
642 sign: Some(MacOSNativeSignConfig {
643 identity: Some("Developer ID".to_string()),
644 ..Default::default()
645 }),
646 notarize: Some(MacOSNativeNotarizeConfig {
647 profile_name: None,
648 ..Default::default()
649 }),
650 ..Default::default()
651 }]),
652 });
653
654 let mut ctx = make_ctx_with_notarize(config);
655 let stage = NotarizeStage;
656 let result = stage.run(&mut ctx);
657 assert!(result.is_err());
658 assert!(
659 result
660 .unwrap_err()
661 .to_string()
662 .contains("notarize.profile_name is required"),
663 );
664 }
665
666 #[test]
667 fn test_native_rejects_unsupported_use_type_at_parse_time() {
668 let yaml = r#"
671notarize:
672 macos_native:
673 - use: zip
674 sign:
675 identity: "Developer ID"
676 notarize:
677 profile_name: "profile"
678crates: []
679"#;
680 let result: std::result::Result<Config, _> = serde_yaml_ng::from_str(yaml);
681 assert!(
682 result.is_err(),
683 "macos_native.use: zip must be rejected (only 'dmg' / 'pkg' allowed)"
684 );
685 }
686
687 #[test]
692 fn test_cross_platform_dry_run_with_darwin_binaries() {
693 let mut config = Config::default();
694 config.project_name = "myapp".to_string();
695 config.notarize = Some(NotarizeConfig {
696 skip: None,
697 macos: Some(vec![MacOSSignNotarizeConfig {
698 skip: None,
699 sign: Some(MacOSSignConfig {
700 certificate: Some("cert.p12".to_string()),
701 password: Some("pass".to_string()),
702 entitlements: Some("ent.xml".to_string()),
703 ..Default::default()
704 }),
705 notarize: Some(MacOSNotarizeApiConfig {
706 issuer_id: Some("issuer-123".to_string()),
707 key: Some("key.p8".to_string()),
708 key_id: Some("KEY1".to_string()),
709 wait: Some(true),
710 timeout: Some(anodizer_core::config::HumanDuration(
711 std::time::Duration::from_secs(20 * 60),
712 )),
713 }),
714 ..Default::default()
715 }]),
716 macos_native: None,
717 });
718
719 let mut ctx = Context::new(
720 config,
721 ContextOptions {
722 dry_run: true,
723 ..Default::default()
724 },
725 );
726 ctx.template_vars_mut().set("Version", "1.0.0");
727
728 ctx.artifacts.add(Artifact {
730 kind: ArtifactKind::Binary,
731 name: String::new(),
732 path: PathBuf::from("dist/myapp"),
733 target: Some("aarch64-apple-darwin".to_string()),
734 crate_name: "myapp".to_string(),
735 metadata: Default::default(),
736 size: None,
737 });
738 ctx.artifacts.add(Artifact {
739 kind: ArtifactKind::Binary,
740 name: String::new(),
741 path: PathBuf::from("dist/myapp_x86"),
742 target: Some("x86_64-apple-darwin".to_string()),
743 crate_name: "myapp".to_string(),
744 metadata: Default::default(),
745 size: None,
746 });
747
748 ctx.artifacts.add(Artifact {
750 kind: ArtifactKind::Binary,
751 name: String::new(),
752 path: PathBuf::from("dist/myapp_linux"),
753 target: Some("x86_64-unknown-linux-gnu".to_string()),
754 crate_name: "myapp".to_string(),
755 metadata: Default::default(),
756 size: None,
757 });
758
759 let stage = NotarizeStage;
760 stage.run(&mut ctx).unwrap();
762 }
763
764 #[test]
765 fn test_cross_platform_dry_run_sign_only_no_notarize() {
766 let mut config = Config::default();
767 config.project_name = "myapp".to_string();
768 config.notarize = Some(NotarizeConfig {
769 skip: None,
770 macos: Some(vec![MacOSSignNotarizeConfig {
771 skip: None,
772 sign: Some(MacOSSignConfig {
773 certificate: Some("cert.p12".to_string()),
774 password: Some("pass".to_string()),
775 ..Default::default()
776 }),
777 notarize: None, ..Default::default()
779 }]),
780 macos_native: None,
781 });
782
783 let mut ctx = Context::new(
784 config,
785 ContextOptions {
786 dry_run: true,
787 ..Default::default()
788 },
789 );
790
791 ctx.artifacts.add(Artifact {
792 kind: ArtifactKind::Binary,
793 name: String::new(),
794 path: PathBuf::from("dist/myapp"),
795 target: Some("aarch64-apple-darwin".to_string()),
796 crate_name: "myapp".to_string(),
797 metadata: Default::default(),
798 size: None,
799 });
800
801 let stage = NotarizeStage;
802 stage.run(&mut ctx).unwrap();
803 }
804
805 #[test]
806 fn test_cross_platform_no_darwin_binaries_is_noop() {
807 let mut config = Config::default();
808 config.project_name = "myapp".to_string();
809 config.notarize = Some(NotarizeConfig {
810 skip: None,
811 macos: Some(vec![MacOSSignNotarizeConfig {
812 skip: None,
813 sign: Some(MacOSSignConfig {
814 certificate: Some("cert.p12".to_string()),
815 password: Some("pass".to_string()),
816 ..Default::default()
817 }),
818 ..Default::default()
819 }]),
820 macos_native: None,
821 });
822
823 let mut ctx = Context::new(
824 config,
825 ContextOptions {
826 dry_run: true,
827 ..Default::default()
828 },
829 );
830
831 ctx.artifacts.add(Artifact {
833 kind: ArtifactKind::Binary,
834 name: String::new(),
835 path: PathBuf::from("dist/myapp"),
836 target: Some("x86_64-unknown-linux-gnu".to_string()),
837 crate_name: "myapp".to_string(),
838 metadata: Default::default(),
839 size: None,
840 });
841
842 let stage = NotarizeStage;
843 stage.run(&mut ctx).unwrap();
844 }
845
846 #[test]
847 fn test_native_dmg_dry_run_with_artifacts() {
848 let mut config = Config::default();
849 config.project_name = "myapp".to_string();
850 config.notarize = Some(NotarizeConfig {
851 skip: None,
852 macos: None,
853 macos_native: Some(vec![MacOSNativeSignNotarizeConfig {
854 skip: None,
855 use_: Some(anodizer_core::config::MacOSNativeArtifactKind::Dmg),
856 sign: Some(MacOSNativeSignConfig {
857 identity: Some("Developer ID Application: Test".to_string()),
858 keychain: Some("/path/to/kc".to_string()),
859 options: Some(vec!["runtime".to_string()]),
860 entitlements: Some("ent.xml".to_string()),
861 }),
862 notarize: Some(MacOSNativeNotarizeConfig {
863 profile_name: Some("my-profile".to_string()),
864 wait: Some(true),
865 ..Default::default()
866 }),
867 ..Default::default()
868 }]),
869 });
870
871 let mut ctx = Context::new(
872 config,
873 ContextOptions {
874 dry_run: true,
875 ..Default::default()
876 },
877 );
878
879 ctx.artifacts.add(Artifact {
881 kind: ArtifactKind::Installer,
882 name: String::new(),
883 path: PathBuf::from("dist/MyApp.app"),
884 target: Some("aarch64-apple-darwin".to_string()),
885 crate_name: "myapp".to_string(),
886 metadata: HashMap::from([("format".to_string(), "appbundle".to_string())]),
887 size: None,
888 });
889
890 ctx.artifacts.add(Artifact {
892 kind: ArtifactKind::DiskImage,
893 name: String::new(),
894 path: PathBuf::from("dist/MyApp.dmg"),
895 target: Some("aarch64-apple-darwin".to_string()),
896 crate_name: "myapp".to_string(),
897 metadata: HashMap::from([("format".to_string(), "dmg".to_string())]),
898 size: None,
899 });
900
901 let stage = NotarizeStage;
902 stage.run(&mut ctx).unwrap();
903 }
904
905 #[test]
906 fn test_native_pkg_dry_run_with_artifacts() {
907 let mut config = Config::default();
908 config.project_name = "myapp".to_string();
909 config.notarize = Some(NotarizeConfig {
910 skip: None,
911 macos: None,
912 macos_native: Some(vec![MacOSNativeSignNotarizeConfig {
913 skip: None,
914 use_: Some(anodizer_core::config::MacOSNativeArtifactKind::Pkg),
915 sign: Some(MacOSNativeSignConfig {
916 identity: Some("Developer ID Installer: Test".to_string()),
917 ..Default::default()
918 }),
919 notarize: Some(MacOSNativeNotarizeConfig {
920 profile_name: Some("my-profile".to_string()),
921 wait: Some(false),
922 ..Default::default()
923 }),
924 ..Default::default()
925 }]),
926 });
927
928 let mut ctx = Context::new(
929 config,
930 ContextOptions {
931 dry_run: true,
932 ..Default::default()
933 },
934 );
935
936 ctx.artifacts.add(Artifact {
938 kind: ArtifactKind::MacOsPackage,
939 name: String::new(),
940 path: PathBuf::from("dist/MyApp.pkg"),
941 target: Some("aarch64-apple-darwin".to_string()),
942 crate_name: "myapp".to_string(),
943 metadata: HashMap::from([
944 ("format".to_string(), "pkg".to_string()),
945 ("identifier".to_string(), "com.example.myapp".to_string()),
946 ]),
947 size: None,
948 });
949
950 let stage = NotarizeStage;
951 stage.run(&mut ctx).unwrap();
952 }
953
954 #[test]
955 fn test_native_dmg_no_matching_artifacts_is_noop() {
956 let mut config = Config::default();
957 config.project_name = "myapp".to_string();
958 config.notarize = Some(NotarizeConfig {
959 skip: None,
960 macos: None,
961 macos_native: Some(vec![MacOSNativeSignNotarizeConfig {
962 skip: None,
963 use_: Some(anodizer_core::config::MacOSNativeArtifactKind::Dmg),
964 sign: Some(MacOSNativeSignConfig {
965 identity: Some("Developer ID Application: Test".to_string()),
966 ..Default::default()
967 }),
968 notarize: Some(MacOSNativeNotarizeConfig {
969 profile_name: Some("my-profile".to_string()),
970 ..Default::default()
971 }),
972 ..Default::default()
973 }]),
974 });
975
976 let mut ctx = Context::new(
977 config,
978 ContextOptions {
979 dry_run: true,
980 ..Default::default()
981 },
982 );
983
984 let stage = NotarizeStage;
986 stage.run(&mut ctx).unwrap();
987 }
988
989 #[test]
990 fn test_native_pkg_no_matching_artifacts_is_noop() {
991 let mut config = Config::default();
992 config.project_name = "myapp".to_string();
993 config.notarize = Some(NotarizeConfig {
994 skip: None,
995 macos: None,
996 macos_native: Some(vec![MacOSNativeSignNotarizeConfig {
997 skip: None,
998 use_: Some(anodizer_core::config::MacOSNativeArtifactKind::Pkg),
999 sign: Some(MacOSNativeSignConfig {
1000 identity: Some("Developer ID Installer: Test".to_string()),
1001 ..Default::default()
1002 }),
1003 notarize: Some(MacOSNativeNotarizeConfig {
1004 profile_name: Some("my-profile".to_string()),
1005 ..Default::default()
1006 }),
1007 ..Default::default()
1008 }]),
1009 });
1010
1011 let mut ctx = Context::new(
1012 config,
1013 ContextOptions {
1014 dry_run: true,
1015 ..Default::default()
1016 },
1017 );
1018
1019 let stage = NotarizeStage;
1020 stage.run(&mut ctx).unwrap();
1021 }
1022
1023 #[test]
1028 fn test_cross_platform_ids_filter() {
1029 let mut config = Config::default();
1030 config.project_name = "myapp".to_string();
1031 config.notarize = Some(NotarizeConfig {
1032 skip: None,
1033 macos: Some(vec![MacOSSignNotarizeConfig {
1034 skip: None,
1035 ids: Some(vec!["other-crate".to_string()]),
1036 sign: Some(MacOSSignConfig {
1037 certificate: Some("cert.p12".to_string()),
1038 password: Some("pass".to_string()),
1039 ..Default::default()
1040 }),
1041 ..Default::default()
1042 }]),
1043 macos_native: None,
1044 });
1045
1046 let mut ctx = Context::new(
1047 config,
1048 ContextOptions {
1049 dry_run: true,
1050 ..Default::default()
1051 },
1052 );
1053
1054 ctx.artifacts.add(Artifact {
1056 kind: ArtifactKind::Binary,
1057 name: String::new(),
1058 path: PathBuf::from("dist/myapp"),
1059 target: Some("aarch64-apple-darwin".to_string()),
1060 crate_name: "myapp".to_string(),
1061 metadata: Default::default(),
1062 size: None,
1063 });
1064
1065 let stage = NotarizeStage;
1066 stage.run(&mut ctx).unwrap();
1068 }
1069
1070 #[test]
1071 fn test_matches_ids_helper_no_filter() {
1072 let artifact = Artifact {
1073 kind: ArtifactKind::Binary,
1074 name: "test".to_string(),
1075 path: PathBuf::from("dist/test"),
1076 target: None,
1077 crate_name: "myapp".to_string(),
1078 metadata: Default::default(),
1079 size: None,
1080 };
1081
1082 assert!(matches_ids(&artifact, &None));
1083 assert!(matches_ids(&artifact, &Some(vec![])));
1084 }
1085
1086 #[test]
1087 fn test_matches_ids_helper_no_id_metadata_does_not_match() {
1088 let artifact = Artifact {
1089 kind: ArtifactKind::Binary,
1090 name: "test".to_string(),
1091 path: PathBuf::from("dist/test"),
1092 target: None,
1093 crate_name: "myapp".to_string(),
1094 metadata: Default::default(),
1095 size: None,
1096 };
1097
1098 assert!(!matches_ids(&artifact, &Some(vec!["myapp".to_string()])));
1099 assert!(!matches_ids(&artifact, &Some(vec!["other".to_string()])));
1100 }
1101
1102 #[test]
1103 fn test_matches_ids_helper_by_metadata_id() {
1104 let artifact = Artifact {
1105 kind: ArtifactKind::Binary,
1106 name: "test".to_string(),
1107 path: PathBuf::from("dist/test"),
1108 target: None,
1109 crate_name: "myapp".to_string(),
1110 metadata: HashMap::from([("id".to_string(), "build-arm".to_string())]),
1111 size: None,
1112 };
1113
1114 assert!(matches_ids(&artifact, &Some(vec!["build-arm".to_string()])));
1115 assert!(!matches_ids(&artifact, &Some(vec!["myapp".to_string()])));
1116 }
1117
1118 #[test]
1119 fn test_cross_platform_filters_non_darwin_artifacts() {
1120 let mut config = Config::default();
1121 config.project_name = "myapp".to_string();
1122 config.notarize = Some(NotarizeConfig {
1123 skip: None,
1124 macos: Some(vec![MacOSSignNotarizeConfig {
1125 skip: None,
1126 sign: Some(MacOSSignConfig {
1127 certificate: Some("cert.p12".to_string()),
1128 password: Some("pass".to_string()),
1129 ..Default::default()
1130 }),
1131 ..Default::default()
1132 }]),
1133 macos_native: None,
1134 });
1135
1136 let mut ctx = Context::new(
1137 config,
1138 ContextOptions {
1139 dry_run: true,
1140 ..Default::default()
1141 },
1142 );
1143
1144 ctx.artifacts.add(Artifact {
1146 kind: ArtifactKind::Binary,
1147 name: String::new(),
1148 path: PathBuf::from("dist/myapp"),
1149 target: Some("x86_64-unknown-linux-gnu".to_string()),
1150 crate_name: "myapp".to_string(),
1151 metadata: Default::default(),
1152 size: None,
1153 });
1154 ctx.artifacts.add(Artifact {
1155 kind: ArtifactKind::Binary,
1156 name: String::new(),
1157 path: PathBuf::from("dist/myapp.exe"),
1158 target: Some("x86_64-pc-windows-msvc".to_string()),
1159 crate_name: "myapp".to_string(),
1160 metadata: Default::default(),
1161 size: None,
1162 });
1163
1164 let stage = NotarizeStage;
1165 stage.run(&mut ctx).unwrap();
1166 }
1168
1169 #[test]
1170 fn test_native_dmg_filters_appbundle_by_ids() {
1171 let mut config = Config::default();
1172 config.project_name = "myapp".to_string();
1173 config.notarize = Some(NotarizeConfig {
1174 skip: None,
1175 macos: None,
1176 macos_native: Some(vec![MacOSNativeSignNotarizeConfig {
1177 skip: None,
1178 ids: Some(vec!["other".to_string()]),
1179 sign: Some(MacOSNativeSignConfig {
1180 identity: Some("Developer ID".to_string()),
1181 ..Default::default()
1182 }),
1183 notarize: Some(MacOSNativeNotarizeConfig {
1184 profile_name: Some("profile".to_string()),
1185 ..Default::default()
1186 }),
1187 ..Default::default()
1188 }]),
1189 });
1190
1191 let mut ctx = Context::new(
1192 config,
1193 ContextOptions {
1194 dry_run: true,
1195 ..Default::default()
1196 },
1197 );
1198
1199 ctx.artifacts.add(Artifact {
1201 kind: ArtifactKind::Installer,
1202 name: String::new(),
1203 path: PathBuf::from("dist/MyApp.app"),
1204 target: Some("aarch64-apple-darwin".to_string()),
1205 crate_name: "myapp".to_string(),
1206 metadata: HashMap::from([("format".to_string(), "appbundle".to_string())]),
1207 size: None,
1208 });
1209
1210 let stage = NotarizeStage;
1211 stage.run(&mut ctx).unwrap();
1213 }
1214
1215 fn should_skip_with(skip: Option<StringOrBool>, is_snapshot: bool) -> anyhow::Result<bool> {
1222 let mut cfg = MacOSSignNotarizeConfig::default();
1223 cfg.skip = skip;
1224 let mut ctx = Context::new(Config::default(), ContextOptions::default());
1225 ctx.template_vars_mut()
1226 .set("IsSnapshot", if is_snapshot { "true" } else { "false" });
1227 cfg.should_skip(|s| ctx.render_template(s))
1228 }
1229
1230 #[test]
1231 fn test_should_skip_none_runs() {
1232 assert!(!should_skip_with(None, false).unwrap());
1234 }
1235
1236 #[test]
1237 fn test_should_skip_bool_true_skips() {
1238 assert!(should_skip_with(Some(StringOrBool::Bool(true)), false).unwrap());
1239 }
1240
1241 #[test]
1242 fn test_should_skip_bool_false_runs() {
1243 assert!(!should_skip_with(Some(StringOrBool::Bool(false)), false).unwrap());
1244 }
1245
1246 #[test]
1247 fn test_should_skip_string_true_skips() {
1248 assert!(should_skip_with(Some(StringOrBool::String("true".into())), false).unwrap());
1249 }
1250
1251 #[test]
1252 fn test_should_skip_string_false_runs() {
1253 assert!(!should_skip_with(Some(StringOrBool::String("false".into())), false).unwrap());
1254 }
1255
1256 fn should_skip_marker(skip: &str, marker: &str) -> anyhow::Result<bool> {
1259 let mut cfg = MacOSSignNotarizeConfig::default();
1260 cfg.skip = Some(StringOrBool::String(skip.into()));
1261 let mut ctx = Context::new(Config::default(), ContextOptions::default());
1262 ctx.template_vars_mut().set("Marker", marker);
1263 cfg.should_skip(|s| ctx.render_template(s))
1264 }
1265
1266 #[test]
1267 fn test_should_skip_direct_template_uses_sibling_truthy() {
1268 assert!(
1273 should_skip_marker("{{ .Marker }}", "1").unwrap(),
1274 "direct skip rendering '1' must skip (sibling truthy)"
1275 );
1276 assert!(
1277 !should_skip_marker("{{ .Marker }}", "yes").unwrap(),
1278 "direct skip rendering 'yes' must RUN (not a sibling-truthy value)"
1279 );
1280 assert!(
1281 !should_skip_marker("{{ .Marker }}", "on").unwrap(),
1282 "direct skip rendering 'on' must RUN (not a sibling-truthy value)"
1283 );
1284 }
1285
1286 #[test]
1287 fn test_inverted_enabled_uses_wider_falsy_set() {
1288 assert!(
1292 !enabled_should_skip_marker("{{ .Marker }}", "yes").unwrap(),
1293 "enabled rendering 'yes' is truthy → run (must not skip)"
1294 );
1295 assert!(
1296 enabled_should_skip_marker("{{ .Marker }}", "no").unwrap(),
1297 "enabled rendering 'no' is falsy → skip"
1298 );
1299 }
1300
1301 fn enabled_should_skip_marker(enabled: &str, marker: &str) -> anyhow::Result<bool> {
1304 let yaml = format!(
1305 "notarize:\n macos:\n - enabled: \"{enabled}\"\n sign:\n certificate: /tmp/c.p12\n password: pw\ncrates: []\n"
1306 );
1307 let cfg: Config = serde_yaml_ng::from_str(&yaml).expect("enabled alias should parse");
1308 let entry = cfg.notarize.unwrap().macos.unwrap().remove(0);
1309 let mut ctx = Context::new(Config::default(), ContextOptions::default());
1310 ctx.template_vars_mut().set("Marker", marker);
1311 entry.should_skip(|s| ctx.render_template(s))
1312 }
1313
1314 #[test]
1315 fn test_should_skip_template_skip_truthy() {
1316 let r = should_skip_with(
1318 Some(StringOrBool::String(
1319 "{{ if .IsSnapshot }}true{{ end }}".into(),
1320 )),
1321 true,
1322 )
1323 .unwrap();
1324 assert!(r, "skip template rendering truthy must skip");
1325 }
1326
1327 #[test]
1328 fn test_should_skip_malformed_skip_template_fails_closed() {
1329 let r = should_skip_with(Some(StringOrBool::String("{{ broken".into())), false);
1332 assert!(r.is_err(), "malformed skip template must error, not run");
1333 }
1334
1335 fn enabled_should_skip(enabled_yaml: &str, is_snapshot: bool) -> anyhow::Result<bool> {
1342 let yaml = format!(
1343 "notarize:\n macos:\n - enabled: {enabled_yaml}\n sign:\n certificate: /tmp/c.p12\n password: pw\ncrates: []\n"
1344 );
1345 let cfg: Config = serde_yaml_ng::from_str(&yaml).expect("enabled alias should parse");
1346 let entry = cfg.notarize.unwrap().macos.unwrap().remove(0);
1347 let mut ctx = Context::new(Config::default(), ContextOptions::default());
1348 ctx.template_vars_mut()
1349 .set("IsSnapshot", if is_snapshot { "true" } else { "false" });
1350 entry.should_skip(|s| ctx.render_template(s))
1351 }
1352
1353 #[test]
1354 fn test_enabled_literal_false_disables() {
1355 assert!(
1357 enabled_should_skip("\"false\"", false).unwrap(),
1358 "enabled: false must skip"
1359 );
1360 }
1361
1362 #[test]
1363 fn test_enabled_template_falsy_disables() {
1364 assert!(
1367 enabled_should_skip("\"{{ .IsSnapshot }}\"", false).unwrap(),
1368 "templated enabled rendering falsy must skip (not silently run)"
1369 );
1370 }
1371
1372 #[test]
1373 fn test_enabled_template_truthy_runs() {
1374 assert!(
1376 !enabled_should_skip("\"{{ .IsSnapshot }}\"", true).unwrap(),
1377 "templated enabled rendering truthy must run"
1378 );
1379 }
1380
1381 #[test]
1382 fn test_enabled_malformed_template_fails_closed() {
1383 let r = enabled_should_skip("\"{{ broken\"", false);
1388 assert!(
1389 r.is_err(),
1390 "malformed enabled template must error, not silently enable notarization"
1391 );
1392 }
1393
1394 #[test]
1399 fn test_native_defaults_to_dmg_when_use_is_none() {
1400 let mut config = Config::default();
1401 config.project_name = "myapp".to_string();
1402 config.notarize = Some(NotarizeConfig {
1403 skip: None,
1404 macos: None,
1405 macos_native: Some(vec![MacOSNativeSignNotarizeConfig {
1406 skip: None,
1407 use_: None, sign: Some(MacOSNativeSignConfig {
1409 identity: Some("Developer ID Application: Test".to_string()),
1410 ..Default::default()
1411 }),
1412 notarize: Some(MacOSNativeNotarizeConfig {
1413 profile_name: Some("my-profile".to_string()),
1414 ..Default::default()
1415 }),
1416 ..Default::default()
1417 }]),
1418 });
1419
1420 let mut ctx = Context::new(
1421 config,
1422 ContextOptions {
1423 dry_run: true,
1424 ..Default::default()
1425 },
1426 );
1427
1428 ctx.artifacts.add(Artifact {
1430 kind: ArtifactKind::DiskImage,
1431 name: String::new(),
1432 path: PathBuf::from("dist/MyApp.dmg"),
1433 target: Some("aarch64-apple-darwin".to_string()),
1434 crate_name: "myapp".to_string(),
1435 metadata: HashMap::from([("format".to_string(), "dmg".to_string())]),
1436 size: None,
1437 });
1438
1439 let stage = NotarizeStage;
1440 stage.run(&mut ctx).unwrap();
1442 }
1443
1444 #[cfg(unix)]
1453 fn fake_output(stderr: &str, code: i32) -> std::process::Output {
1454 use std::os::unix::process::ExitStatusExt;
1455 std::process::Output {
1456 status: std::process::ExitStatus::from_raw(code << 8),
1457 stdout: Vec::new(),
1458 stderr: stderr.as_bytes().to_vec(),
1459 }
1460 }
1461
1462 fn test_logger() -> anodizer_core::log::StageLogger {
1463 anodizer_core::log::StageLogger::new("notarize", anodizer_core::log::Verbosity::Quiet)
1464 }
1465
1466 #[cfg(unix)]
1467 #[test]
1468 fn test_is_retriable_notarize_output_network_markers() {
1469 let log = test_logger();
1471 for marker in [
1472 "tls: bad record",
1473 "i/o timeout",
1474 "could not resolve host",
1475 "503 service unavailable",
1476 "429 too many requests",
1477 "dial tcp: connection refused",
1478 "connection reset by peer",
1479 ] {
1480 let out = fake_output(marker, 1);
1481 assert!(
1482 is_retriable_notarize_output(&out, &log),
1483 "should retry on '{marker}'"
1484 );
1485 }
1486 }
1487
1488 #[cfg(unix)]
1489 #[test]
1490 fn test_is_retriable_notarize_output_apple_rejection_is_terminal() {
1491 let log = test_logger();
1495 for marker in [
1496 "status: Invalid",
1497 "Invalid submission",
1498 "status: Rejected",
1499 "submission rejected by Apple",
1500 ] {
1501 let out = fake_output(marker, 1);
1502 assert!(
1503 !is_retriable_notarize_output(&out, &log),
1504 "must NOT retry on '{marker}'"
1505 );
1506 }
1507 }
1508
1509 #[cfg(unix)]
1510 #[test]
1511 fn test_is_retriable_notarize_output_unknown_failure_is_terminal() {
1512 let out = fake_output("error: --p12-file: no such file", 64);
1516 assert!(!is_retriable_notarize_output(&out, &test_logger()));
1517 }
1518
1519 #[cfg(unix)]
1520 #[test]
1521 fn test_run_with_retry_returns_immediately_on_terminal_error() {
1522 let log = anodizer_core::log::StageLogger::new(
1528 "notarize-test",
1529 anodizer_core::log::Verbosity::Quiet,
1530 );
1531 let no_delay = |_d: std::time::Duration| {};
1532 let args = vec!["false".to_string()];
1533 let result = run_with_retry(&args, "false-cmd", &log, &no_delay).unwrap();
1534 assert!(!result.status.success());
1535 }
1536
1537 #[test]
1542 fn refresh_artifact_checksums_covers_dmg_and_pkg() {
1543 use anodizer_core::config::Config;
1544 use anodizer_core::context::{Context, ContextOptions};
1545
1546 let tmp = tempfile::tempdir().unwrap();
1547
1548 let dmg_path = tmp.path().join("app.dmg");
1549 std::fs::write(&dmg_path, b"signed-dmg-bytes").unwrap();
1550 let pkg_path = tmp.path().join("app.pkg");
1551 std::fs::write(&pkg_path, b"signed-pkg-bytes").unwrap();
1552
1553 let mut dmg_md = HashMap::new();
1554 dmg_md.insert("sha256".to_string(), "stale".to_string());
1555 let mut pkg_md = HashMap::new();
1556 pkg_md.insert("sha256".to_string(), "stale".to_string());
1557
1558 let mut config = Config::default();
1559 config.project_name = "p".to_string();
1560 let mut ctx = Context::new(
1561 config,
1562 ContextOptions {
1563 dry_run: false,
1564 ..Default::default()
1565 },
1566 );
1567 ctx.artifacts.add(Artifact {
1568 name: "app.dmg".to_string(),
1569 path: PathBuf::from(&dmg_path),
1570 kind: ArtifactKind::DiskImage,
1571 target: Some("aarch64-apple-darwin".to_string()),
1572 crate_name: "p".to_string(),
1573 metadata: dmg_md,
1574 size: None,
1575 });
1576 ctx.artifacts.add(Artifact {
1577 name: "app.pkg".to_string(),
1578 path: PathBuf::from(&pkg_path),
1579 kind: ArtifactKind::MacOsPackage,
1580 target: Some("aarch64-apple-darwin".to_string()),
1581 crate_name: "p".to_string(),
1582 metadata: pkg_md,
1583 size: None,
1584 });
1585
1586 let log = test_logger();
1587 refresh_artifact_checksums(&mut ctx, &log);
1588
1589 for art in ctx.artifacts.all() {
1590 let sha = art.metadata.get("sha256").expect("sha256 set");
1591 assert_ne!(sha, "stale", "{} sha256 must be refreshed", art.name);
1592 assert_eq!(sha.len(), 64, "sha256 must be 64 hex chars");
1593 }
1594 }
1595}