Skip to main content

anodizer_stage_notarize/
lib.rs

1//! macOS code-signing + notarization stage.
2//!
3//! Split into focused submodules:
4//!
5//! - [`secret`] — checksum refresh, skip/id gating, base64 secret
6//!   materialization + arg redaction.
7//! - [`retry`] — notarytool / rcodesign invocation with bounded transient
8//!   retry and output checking.
9//! - [`run`] — the cross-platform (rcodesign) and native (codesign + xcrun
10//!   notarytool) per-config run paths.
11//!
12//! The [`NotarizeStage`] entry point and its [`Stage`] impl live here.
13
14use 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// Exercised only by the unix-gated tests below (they fabricate an
27// `ExitStatus` via the unix-only `ExitStatusExt::from_raw`, and the retry
28// logic itself guards macOS-only notarization), so the import must carry the
29// same `unix` gate or it reads as unused on a Windows build.
30#[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        // Respect top-level skip flag. Use try_evaluates_to_true so a malformed
52        // skip: template surfaces as Err instead of silently evaluating
53        // false and running notarization the user thought was suppressed.
54        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        // `macos` and `macos_native` are mutually exclusive — they sign and
63        // notarize the same artifacts via different toolchains. Refuse a
64        // config that populates both so a binary doesn't get signed twice
65        // (the second pass would invalidate the first signature).
66        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        // Cross-platform signing/notarization (rcodesign)
83        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        // Native signing/notarization (codesign + xcrun notarytool)
90        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        // Refresh artifact checksums after signing.
97        // Signing modifies binaries in-place, so SHA256 metadata becomes stale.
98        if !dry_run {
99            refresh_artifact_checksums(ctx, &log);
100        }
101
102        Ok(())
103    }
104}
105
106/// Environment requirements for the notarize stage, mirroring its run
107/// gates: nothing when the top-level `skip:` is truthy; per active
108/// `macos:` entry the cross-platform `rcodesign` plus the env refs of the
109/// templated certificate / password / App Store Connect fields; per active
110/// `macos_native:` entry `codesign` + `xcrun` plus the env refs of the
111/// templated identity / keychain / profile fields. Both toolchains run on
112/// whatever host executes the release (rcodesign is cross-platform; a
113/// `macos_native` config on a non-mac host would fail at run time, and
114/// preflight reports exactly that). Values are never echoed — only
115/// referenced env-var names.
116pub 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        // Unrenderable skip counts as active: over-collect.
137        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    // -----------------------------------------------------------------------
196    // Config deserialization tests
197    // -----------------------------------------------------------------------
198
199    #[test]
200    fn test_cross_platform_config_deserializes() {
201        // Per-config gating uses the canonical `skip:` field; the block
202        // below opts in implicitly (no `skip:` = run).
203        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        // The template form of `skip:` still parses on per-config
309        // notarize blocks.
310        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    // -----------------------------------------------------------------------
360    // Stage skipping / enabled logic tests
361    // -----------------------------------------------------------------------
362
363    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        // Should succeed with no-op
381    }
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        // Should succeed without errors (disabled)
405    }
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        // Should skip because enabled defaults to false
453        stage.run(&mut ctx).unwrap();
454    }
455
456    // -----------------------------------------------------------------------
457    // Required field validation tests
458    // -----------------------------------------------------------------------
459
460    #[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        // `notarize.macos_native.use` is a typed enum; unsupported values
669        // must fail at parse time instead of producing a silent no-op.
670        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    // -----------------------------------------------------------------------
688    // Dry-run behavior tests
689    // -----------------------------------------------------------------------
690
691    #[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        // Register darwin binary artifacts
729        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        // Also register a linux binary that should be ignored
749        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        // Should succeed without actually invoking rcodesign
761        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, // sign-only
778                ..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        // Only register Linux binaries
832        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        // Register an app bundle artifact
880        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        // Register a DMG artifact
891        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        // Register a MacOsPackage artifact (not appbundle)
937        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        // No artifacts registered at all
985        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    // -----------------------------------------------------------------------
1024    // Artifact filtering tests
1025    // -----------------------------------------------------------------------
1026
1027    #[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        // This binary is for "myapp" but ids filter is ["other-crate"]
1055        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        // Should succeed with no-op since id doesn't match
1067        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        // Only non-darwin targets
1145        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        // No darwin artifacts, so this is a no-op
1167    }
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        // This artifact has crate_name "myapp" but ids filter is ["other"]
1200        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        // Should succeed as no-op since ids don't match
1212        stage.run(&mut ctx).unwrap();
1213    }
1214
1215    // -----------------------------------------------------------------------
1216    // should_skip gating tests (per-config `skip:` / inverted `enabled:`)
1217    // -----------------------------------------------------------------------
1218
1219    /// Build a `MacOSSignNotarizeConfig` with the given `skip` and a render
1220    /// context exposing `IsSnapshot`, returning the `should_skip` result.
1221    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        // None -> run (default opt-in once notarize block is present).
1233        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    /// Build a `MacOSSignNotarizeConfig` with the given direct `skip:` value
1257    /// and a context where `{{ .Marker }}` renders to `marker`.
1258    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        // A DIRECT `skip:` template must use the sibling truthy convention
1269        // (`try_evaluates_to_true`: only "true"/"1" are truthy), NOT the wider
1270        // inverted-`enabled:` falsy blacklist. `1` skips; `yes`/`on` do not —
1271        // matching `should_skip_upload` and every other publisher gate.
1272        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        // The inverted `enabled:` path keeps the wider falsy blacklist: a
1289        // non-falsy render (e.g. `yes`/`on`/`1`) means enabled → RUN; only
1290        // ""/false/0/no disable. Contrast with the direct `skip:` path above.
1291        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    /// `enabled:`-alias variant of [`should_skip_marker`] — renders
1302    /// `{{ .Marker }}` to `marker` through the inverted-enabled path.
1303    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        // A direct `skip:` template that renders truthy skips.
1317        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        // A malformed `skip:` template must surface as Err (fail closed),
1330        // not silently evaluate false and run.
1331        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    // -----------------------------------------------------------------------
1336    // Inverted `enabled:` — must NOT fail open
1337    // -----------------------------------------------------------------------
1338
1339    /// Parse a one-entry `notarize.macos` block carrying the given `enabled:`
1340    /// value and return its `should_skip` against a context with `IsSnapshot`.
1341    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        // `enabled: "false"` must DISABLE (skip), not run.
1356        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        // `enabled: "{{ <falsy> }}"` must DISABLE. IsSnapshot=false renders
1365        // the expression to "false" → enabled falsy → skip.
1366        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        // Same template, IsSnapshot=true → enabled truthy → run.
1375        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        // A malformed `enabled:` template must NOT silently enable. It must
1384        // surface as Err (the caller treats the entry as skipped / aborts) —
1385        // the prior `{% if {{ … }} %}` construction produced malformed Tera
1386        // that errored and was swallowed as "run" (fail-open safety hole).
1387        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    // -----------------------------------------------------------------------
1395    // Native DMG mode defaults to "dmg" when use_ is None
1396    // -----------------------------------------------------------------------
1397
1398    #[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, // should default to "dmg"
1408                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        // Register a DMG so the stage has something to find (or not)
1429        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        // Should succeed because it defaults to DMG mode
1441        stage.run(&mut ctx).unwrap();
1442    }
1443
1444    // -----------------------------------------------------------------------
1445    // M6: notarize retry tests
1446    // -----------------------------------------------------------------------
1447
1448    /// Build a synthetic `Output` with a non-zero exit and the given stderr,
1449    /// useful for exercising `is_retriable_notarize_output` without actually
1450    /// running a process. The exit status is constructed via the os-specific
1451    /// `from_raw` helpers so we don't need to depend on a child process.
1452    #[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        // Network-side blips: must classify as retriable.
1470        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        // Apple-side hard rejections: must NOT retry. Re-submitting an
1492        // invalid bundle is wasted API quota and worse UX (multi-minute
1493        // delays before the user sees the real error).
1494        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        // An exit failure with no recognised network marker (e.g. malformed
1513        // CLI args, certificate not found) is treated as terminal — retrying
1514        // will not help.
1515        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        // Drive `run_with_retry` through `false`, which exits 1 with no
1523        // stderr — classifies as non-retriable and should return on the
1524        // first attempt without invoking the delay function. A no-op delay
1525        // closure ensures the test cannot accidentally sleep 30s if the
1526        // classification logic ever drifts.
1527        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    /// `refresh_artifact_checksums` must cover signed DMG and PKG artifacts
1538    /// in addition to binaries — productsign and stapler rewrite bytes
1539    /// in place, so any cached `sha256` metadata is stale unless we
1540    /// recompute it after the signing pipeline.
1541    #[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}