Skip to main content

anodizer_core/
signing.rs

1//! Sign / docker-sign config types.
2//!
3//! Lifted out of the monolithic `crate::config` module. The historical
4//! `anodizer_core::config::{SignConfig, DockerSignConfig}` import path
5//! is preserved by re-exports at the bottom of `config.rs`.
6//!
7//! ## Default-resolution policy
8//!
9//! Both [`SignConfig`] and [`DockerSignConfig`] keep their fields as
10//! `Option<T>` so the schema can distinguish "user set this explicitly"
11//! from "user left it default" (preserves YAML round-trip identity and
12//! lets a future override-resolution step inject values without losing
13//! provenance). Stages MUST read defaults through the `resolved_*()`
14//! accessors below — no inline `unwrap_or_else(|| "cosign".to_string())`
15//! at call sites — so the answer to "what's the default?" lives in one
16//! place per stage and a future default change (or override resolution)
17//! lands in one place too. This is the lazy-vs-eager defaults policy
18//! anodizer uses across stage configs; precedent commit `ff3be47`
19//! (stage-checksum).
20
21use crate::config::{StringOrBool, deserialize_string_or_bool_opt};
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
26#[serde(default, deny_unknown_fields)]
27pub struct SignConfig {
28    /// Unique identifier for this sign config.
29    pub id: Option<String>,
30    /// Artifact types to sign: "all", "archive", "binary", "checksum", "package", "sbom" (default: "none").
31    pub artifacts: Option<String>,
32    /// Signing command to invoke (default: "cosign" or "gpg").
33    pub cmd: Option<String>,
34    /// Arguments passed to the signing command (supports templates with ${artifact} and ${signature}).
35    pub args: Option<Vec<String>>,
36    /// Signature output filename template (supports templates).
37    pub signature: Option<String>,
38    /// Content written to the signing command's stdin.
39    pub stdin: Option<String>,
40    /// Path to a file whose content is written to the signing command's stdin.
41    pub stdin_file: Option<String>,
42    /// Build IDs filter: only sign artifacts from builds whose `id` is in this list.
43    pub ids: Option<Vec<String>>,
44    /// Environment variables passed to the signing command.
45    #[serde(default)]
46    pub env: Option<Vec<String>>,
47    /// Certificate file to embed in the signature (Cosign bundle signing).
48    pub certificate: Option<String>,
49    /// Capture and log stdout/stderr of the signing command.
50    /// Accepts bool or template string (e.g., "{{ IsSnapshot }}").
51    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
52    pub output: Option<StringOrBool>,
53    /// Authenticode (Windows PE/MSI) signing backend. When set, this sign
54    /// config signs Windows artifacts in place via osslsigncode (Linux/cross)
55    /// or signtool (Windows) instead of producing a detached cosign/gpg
56    /// signature. The signing command, argv, timestamp URL, and artifact
57    /// selector are all derived; supply only the cert (a secret).
58    pub authenticode: Option<AuthenticodeConfig>,
59    /// Post-sign verification knobs. Verification is ON by default wherever
60    /// its inputs are derivable (keyed cosign, keyless cosign on GitHub
61    /// Actions, gpg); set `verify: { enabled: false }` to disable, or supply
62    /// the keyless certificate identity / issuer when they cannot be derived
63    /// from the environment.
64    pub verify: Option<SignVerifyConfig>,
65    /// Template-conditional: skip this sign config if rendered result is "false" or empty.
66    #[serde(rename = "if")]
67    pub if_condition: Option<String>,
68}
69
70/// Post-sign verification settings shared by [`SignConfig`] and
71/// [`DockerSignConfig`].
72///
73/// After each signature is produced, the sign stage re-verifies it with the
74/// matching verifier (`cosign verify-blob` for detached cosign signatures,
75/// `cosign verify` for registry-attached docker signatures, `gpg --verify`
76/// for gpg) so "the signer exited 0" is upgraded to "the signature actually
77/// verifies". Everything is derived when possible:
78///
79/// - **keyed cosign** — the public key is derived once per run via
80///   `cosign public-key --key <ref>`; nothing to configure.
81/// - **keyless cosign** — the certificate identity/issuer are derived from
82///   the ambient GitHub Actions OIDC environment
83///   (`GITHUB_SERVER_URL`/`GITHUB_WORKFLOW_REF`); outside GitHub Actions,
84///   supply them here or verification skips with a named reason.
85/// - **gpg** — verified against the same keyring that signed.
86///
87/// ```yaml
88/// signs:
89///   - cmd: cosign
90///     args: ["sign-blob", "--bundle={{ Signature }}", "--yes", "{{ Artifact }}"]
91///     verify:
92///       certificate_identity: "https://github.com/acme/app/.github/workflows/release.yml@refs/tags/v1.0.0"
93///       certificate_oidc_issuer: "https://token.actions.githubusercontent.com"
94/// ```
95#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
96#[serde(default, deny_unknown_fields)]
97pub struct SignVerifyConfig {
98    /// Whether to verify each produced signature (default: `true`).
99    pub enabled: Option<bool>,
100    /// Exact certificate identity (SAN) expected in a keyless signing
101    /// certificate (`cosign --certificate-identity`). Overrides the value
102    /// derived from the GitHub Actions environment.
103    pub certificate_identity: Option<String>,
104    /// Regular expression matched against the keyless certificate identity
105    /// (`cosign --certificate-identity-regexp`). Ignored when
106    /// [`certificate_identity`](Self::certificate_identity) is set.
107    pub certificate_identity_regexp: Option<String>,
108    /// Expected OIDC issuer of the keyless signing certificate
109    /// (`cosign --certificate-oidc-issuer`). Overrides the derived
110    /// GitHub Actions issuer.
111    pub certificate_oidc_issuer: Option<String>,
112}
113
114impl SignVerifyConfig {
115    /// Whether verification is enabled (default: `true`).
116    pub fn is_enabled(&self) -> bool {
117        self.enabled.unwrap_or(true)
118    }
119}
120
121/// Authenticode (Windows PE/MSI/DLL) signing backend for a [`SignConfig`].
122///
123/// Unlike the generic cosign/gpg `signs:` path — which produces a *detached*
124/// `.sig` next to the artifact — Authenticode signing **embeds** the signature
125/// into the PE/MSI container, mutating the artifact in place. Downstream
126/// checksums and archives then pick up the signed bytes; no separate signature
127/// artifact is registered.
128///
129/// Everything is derived so the opt-in is minimal — `authenticode: {}` plus a
130/// `WINDOWS_CERT_FILE` (and optional `WINDOWS_CERT_PASSWORD`) env var is enough:
131///
132/// ```yaml
133/// signs:
134///   - authenticode: {}   # signs every .exe/.msi/.dll via osslsigncode/signtool
135/// ```
136///
137/// A fully-specified config overrides the derived defaults:
138///
139/// ```yaml
140/// signs:
141///   - id: authenticode
142///     authenticode:
143///       cert_file: "{{ .Env.MY_CERT }}"     # or cert_env: MY_CERT_PATH
144///       password_env: MY_CERT_PASSWORD
145///       timestamp_url: "http://timestamp.sectigo.com"
146///       name: "Acme Corp"
147///       url: "https://acme.example"
148///       tool: osslsigncode
149///       artifacts: windows
150///       required: true
151/// ```
152#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
153#[serde(default, deny_unknown_fields)]
154pub struct AuthenticodeConfig {
155    /// Literal path to the PKCS#12 (`.p12` / `.pfx`) cert file. May be a
156    /// template (e.g. `"{{ .Env.MY_CERT }}"`). For a non-secret cert path
157    /// checked into config; for a secret path prefer [`cert_env`](Self::cert_env).
158    pub cert_file: Option<String>,
159    /// Name of the env var holding the **path** to the PKCS#12 cert file (never
160    /// the cert bytes). Defaults to `WINDOWS_CERT_FILE` when neither this nor
161    /// [`cert_file`](Self::cert_file) is set.
162    pub cert_env: Option<String>,
163    /// Name of the env var holding the cert password. Defaults to
164    /// `WINDOWS_CERT_PASSWORD`. The value is read at execution time, passed to
165    /// the signer, and redacted from all logs.
166    pub password_env: Option<String>,
167    /// RFC 3161 timestamp server URL. Defaults to
168    /// [`DEFAULT_TIMESTAMP_URL`](AuthenticodeConfig::DEFAULT_TIMESTAMP_URL).
169    pub timestamp_url: Option<String>,
170    /// Product / publisher name embedded in the signature (osslsigncode `-n`,
171    /// signtool `/d`). Templated. Derived from the project name when unset.
172    pub name: Option<String>,
173    /// Info URL embedded in the signature (osslsigncode `-i`, signtool `/du`).
174    /// Omitted when unset.
175    pub url: Option<String>,
176    /// Override the signer binary. Defaults to `signtool` on a Windows host,
177    /// `osslsigncode` elsewhere.
178    pub tool: Option<String>,
179    /// Artifact selector. Defaults to `"windows"` — Binary/Installer/Library
180    /// artifacts whose path ends in `.exe`, `.msi`, or `.dll`.
181    pub artifacts: Option<String>,
182    /// When `true`, a missing cert HARD-FAILS the sign stage. When `false`
183    /// (the default), a missing cert SKIPS gracefully (mirrors the
184    /// keyless-cosign-under-harness skip).
185    pub required: Option<bool>,
186}
187
188impl AuthenticodeConfig {
189    /// Default env var naming the cert **path** when neither `cert_file` nor
190    /// `cert_env` is set (`"WINDOWS_CERT_FILE"`).
191    pub const DEFAULT_CERT_ENV: &'static str = "WINDOWS_CERT_FILE";
192
193    /// Default env var holding the cert password (`"WINDOWS_CERT_PASSWORD"`).
194    pub const DEFAULT_PASSWORD_ENV: &'static str = "WINDOWS_CERT_PASSWORD";
195
196    /// Default RFC 3161 timestamp server (`"http://timestamp.digicert.com"`).
197    pub const DEFAULT_TIMESTAMP_URL: &'static str = "http://timestamp.digicert.com";
198
199    /// Default artifact selector (`"windows"`).
200    pub const DEFAULT_ARTIFACTS: &'static str = "windows";
201
202    /// Signer binary on a Windows host (`"signtool"`).
203    pub const DEFAULT_TOOL_WINDOWS: &'static str = "signtool";
204
205    /// Signer binary on a non-Windows host (`"osslsigncode"`).
206    pub const DEFAULT_TOOL_UNIX: &'static str = "osslsigncode";
207
208    /// Resolve the env var naming the cert path, falling back to
209    /// [`DEFAULT_CERT_ENV`](Self::DEFAULT_CERT_ENV).
210    pub fn resolved_cert_env(&self) -> &str {
211        self.cert_env.as_deref().unwrap_or(Self::DEFAULT_CERT_ENV)
212    }
213
214    /// Resolve the env var holding the cert password, falling back to
215    /// [`DEFAULT_PASSWORD_ENV`](Self::DEFAULT_PASSWORD_ENV).
216    pub fn resolved_password_env(&self) -> &str {
217        self.password_env
218            .as_deref()
219            .unwrap_or(Self::DEFAULT_PASSWORD_ENV)
220    }
221
222    /// Resolve the RFC 3161 timestamp URL, falling back to
223    /// [`DEFAULT_TIMESTAMP_URL`](Self::DEFAULT_TIMESTAMP_URL).
224    pub fn resolved_timestamp_url(&self) -> &str {
225        self.timestamp_url
226            .as_deref()
227            .unwrap_or(Self::DEFAULT_TIMESTAMP_URL)
228    }
229
230    /// Resolve the artifact selector, falling back to
231    /// [`DEFAULT_ARTIFACTS`](Self::DEFAULT_ARTIFACTS).
232    pub fn resolved_artifacts(&self) -> &str {
233        self.artifacts.as_deref().unwrap_or(Self::DEFAULT_ARTIFACTS)
234    }
235
236    /// Resolve the signer binary, falling back to the host-appropriate default
237    /// (`signtool` on Windows, `osslsigncode` elsewhere).
238    pub fn resolved_tool(&self) -> &str {
239        self.tool.as_deref().unwrap_or({
240            if cfg!(windows) {
241                Self::DEFAULT_TOOL_WINDOWS
242            } else {
243                Self::DEFAULT_TOOL_UNIX
244            }
245        })
246    }
247
248    /// Whether a missing cert HARD-FAILS (`true`) versus skips gracefully
249    /// (`false`, the default).
250    pub fn is_required(&self) -> bool {
251        self.required.unwrap_or(false)
252    }
253}
254
255impl SignConfig {
256    /// Default `id` when a sign config has none (`"default"`). Used to
257    /// label log lines and uniqueness-error messages.
258    pub const DEFAULT_ID: &'static str = "default";
259
260    /// Default `artifacts` filter for top-level `signs:[]`. Mirrors
261    /// the canonical `artifacts = "none"` — by default
262    /// nothing is signed unless the user opts in.
263    pub const DEFAULT_ARTIFACTS: &'static str = "none";
264
265    /// Default `artifacts` filter for `binary_signs:[]`. The binary-only
266    /// driver always restricts the artifact-kind filter to binaries even
267    /// when the user leaves `artifacts:` unset. Anodize-specific helper
268    /// (anodizer-specific — distinct config type for
269    /// binary signing) but kept on `SignConfig` because anodize unifies
270    /// `signs[]` and `binary_signs[]` into one struct.
271    pub const DEFAULT_ARTIFACTS_BINARY: &'static str = "binary";
272
273    /// Default `signature` template for top-level `signs:[]`. Mirrors
274    /// the canonical `signature = "${artifact}.sig"`.
275    /// Anodize uses Tera-style `{{ .Artifact }}` placeholders that the
276    /// arg-resolver rewrites to the same path at execution time.
277    pub const DEFAULT_SIGNATURE_TEMPLATE: &'static str = "{{ .Artifact }}.sig";
278
279    /// Default `signature` template for `binary_signs:[]`.
280    ///
281    /// Intentional **divergence** from the binary-sign default: the upstream
282    /// stores binaries under per-target subdirectories
283    /// (`dist/linux_amd64/binname`), so its template appends `_{{ .Os }}_{{ .Arch }}`
284    /// to the bare binary name without collision. Anodize uses a flat `dist/`
285    /// layout where stage-build already names binaries with the platform
286    /// suffix (`myapp_linux_amd64`, `myapp_darwin_arm64`, etc.). Appending
287    /// Os/Arch again would produce `myapp_linux_amd64_linux_amd64` with no
288    /// `.sig` extension — a double-suffix bug.
289    ///
290    /// The correct default for anodize's layout is `{{ .Artifact }}.sig` —
291    /// identical to `DEFAULT_SIGNATURE_TEMPLATE`. Binary names are already
292    /// unique per target, so no collision risk exists. Users who want an
293    /// explicit per-target suffix can set `signature:` in `binary_signs:`.
294    pub const DEFAULT_BINARY_SIGNATURE_TEMPLATE: &'static str = "{{ .Artifact }}.sig";
295
296    /// Default `args` for top-level `signs:[]`
297    /// (`["--output", "$signature", "--detach-sig", "$artifact"]`).
298    /// Anodize substitutes `$signature` / `$artifact` for `{{ .Signature }}`
299    /// / `{{ .Artifact }}` Tera placeholders that the arg-resolver
300    /// rewrites; the wire-level invocation is unchanged.
301    pub const DEFAULT_ARGS: &[&'static str] = &[
302        "--output",
303        "{{ .Signature }}",
304        "--detach-sig",
305        "{{ .Artifact }}",
306    ];
307
308    /// Resolve the sign-config id, falling back to `"default"`.
309    pub fn resolved_id(&self) -> &str {
310        self.id.as_deref().unwrap_or(Self::DEFAULT_ID)
311    }
312
313    /// Resolve the `artifacts` filter, falling back to the supplied
314    /// `fallback` (`Self::DEFAULT_ARTIFACTS` for `signs[]`,
315    /// `Self::DEFAULT_ARTIFACTS_BINARY` for `binary_signs[]`).
316    pub fn resolved_artifacts<'a>(&'a self, fallback: &'a str) -> &'a str {
317        self.artifacts.as_deref().unwrap_or(fallback)
318    }
319
320    /// Resolve the `signature` template, falling back to the supplied
321    /// `default` (`Self::DEFAULT_SIGNATURE_TEMPLATE` for `signs[]`,
322    /// `Self::DEFAULT_BINARY_SIGNATURE_TEMPLATE` for `binary_signs[]`).
323    pub fn resolved_signature_template<'a>(&'a self, default: &'a str) -> &'a str {
324        self.signature.as_deref().unwrap_or(default)
325    }
326
327    /// Resolve `args`, materializing the [`Self::DEFAULT_ARGS`] const into
328    /// a `Vec<String>` when the user left `args:` unset. Returns a clone
329    /// of the user-supplied list otherwise.
330    pub fn resolved_args(&self) -> Vec<String> {
331        self.args.clone().unwrap_or_else(|| {
332            Self::DEFAULT_ARGS
333                .iter()
334                .map(|s| (*s).to_string())
335                .collect()
336        })
337    }
338
339    /// Whether post-sign verification is enabled (default: `true`; an
340    /// absent `verify:` block means "verify with derived inputs").
341    pub fn verify_enabled(&self) -> bool {
342        self.verify
343            .as_ref()
344            .is_none_or(SignVerifyConfig::is_enabled)
345    }
346
347    /// `true` when this sign config will invoke gpg.
348    ///
349    /// The top-level `signs:` driver defaults to gpg when `cmd:` is unset
350    /// (see `stage-sign::helpers::default_sign_cmd` which falls back to
351    /// `git config gpg.program` then to literal `"gpg"`). We treat any
352    /// cmd whose basename starts with `gpg` (e.g., `gpg`, `gpg2`,
353    /// `/usr/local/bin/gpg`) as a gpg invocation. A cmd of `"cosign"`,
354    /// `"notation"`, etc. returns false.
355    ///
356    /// Entries with `artifacts: "none"` (the default for top-level
357    /// `signs:`) are treated as not-configured — the loop never fires.
358    pub fn is_gpg(&self) -> bool {
359        // Effectively-disabled entries don't count as configured.
360        let artifacts = self.resolved_artifacts(Self::DEFAULT_ARTIFACTS);
361        if artifacts == "none" {
362            return false;
363        }
364        match self.cmd.as_deref() {
365            None => true, // default cmd is gpg
366            Some(cmd) => is_gpg_command(cmd),
367        }
368    }
369}
370
371/// True when `cmd` names a gpg binary: its basename starts with `gpg`,
372/// so `gpg`, `gpg2`, `gpg.exe`, and absolute paths thereto all qualify
373/// while `cosign`, `notation`, etc. do not.
374///
375/// The single gpg-detection predicate: every site that branches on
376/// "is this signing command gpg" (config classification, signature
377/// verification arg derivation, deterministic-timestamp injection)
378/// must route through it so a `gpg2`/absolute-path cmd behaves like
379/// plain `gpg` everywhere.
380pub fn is_gpg_command(cmd: &str) -> bool {
381    std::path::Path::new(cmd)
382        .file_name()
383        .and_then(|s| s.to_str())
384        .unwrap_or(cmd)
385        .starts_with("gpg")
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
389#[serde(default, deny_unknown_fields)]
390pub struct DockerSignConfig {
391    /// Unique identifier for this docker sign config.
392    pub id: Option<String>,
393    /// Docker artifact types to sign: "all", "image", or "manifest" (default: "none").
394    pub artifacts: Option<String>,
395    /// Signing command to invoke (default: "cosign").
396    pub cmd: Option<String>,
397    /// Arguments passed to the signing command (supports templates).
398    pub args: Option<Vec<String>>,
399    /// Signature output filename template (supports templates).
400    pub signature: Option<String>,
401    /// Certificate file to embed in the signature (Cosign bundle signing).
402    pub certificate: Option<String>,
403    /// Docker config IDs filter: only sign images from configs whose `id` is in this list.
404    pub ids: Option<Vec<String>>,
405    /// Content written to the signing command's stdin.
406    pub stdin: Option<String>,
407    /// Path to a file whose content is written to the signing command's stdin.
408    pub stdin_file: Option<String>,
409    /// Environment variables passed to the signing command.
410    #[serde(default)]
411    pub env: Option<Vec<String>>,
412    /// Capture and log stdout/stderr of the docker signing command.
413    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
414    pub output: Option<StringOrBool>,
415    /// Post-sign verification knobs — see [`SignVerifyConfig`]. Docker
416    /// signatures are verified with `cosign verify` against the registry
417    /// the sign just pushed to.
418    pub verify: Option<SignVerifyConfig>,
419    /// Template-conditional: skip this docker sign config if rendered result is "false" or empty.
420    #[serde(rename = "if")]
421    pub if_condition: Option<String>,
422}
423
424impl DockerSignConfig {
425    /// Default `id` when a docker-sign config has none (`"default"`).
426    pub const DEFAULT_ID: &'static str = "default";
427
428    /// Default signing `cmd`
429    /// (`cfg.Cmd = "cosign"`). Unlike top-level `signs:[]` (which falls
430    /// back to git's `gpg.program` config), docker signing only ever
431    /// targets cosign, so the default is a static literal.
432    pub const DEFAULT_CMD: &'static str = "cosign";
433
434    /// Default `artifacts` filter when unset. Empty string is treated by
435    /// the docker-sign driver as "DockerImageV2 only" (post-buildx
436    /// canonical case). An empty `artifacts` is treated identically.
437    pub const DEFAULT_ARTIFACTS: &'static str = "";
438
439    /// Default `args` for `docker_signs:[]`
440    /// (`["sign", "--key=cosign.key",
441    /// "${artifact}@${digest}", "--yes"]`). Anodize substitutes
442    /// `${artifact}@${digest}` for the Tera-rewritten
443    /// `{{ .Artifact }}@{{ .Digest }}` placeholders.
444    pub const DEFAULT_ARGS: &[&'static str] = &[
445        "sign",
446        "--key=cosign.key",
447        "{{ .Artifact }}@{{ .Digest }}",
448        "--yes",
449    ];
450
451    /// Resolve the docker-sign id, falling back to `"default"`.
452    pub fn resolved_id(&self) -> &str {
453        self.id.as_deref().unwrap_or(Self::DEFAULT_ID)
454    }
455
456    /// Resolve the signing command, falling back to `"cosign"`.
457    pub fn resolved_cmd(&self) -> &str {
458        self.cmd.as_deref().unwrap_or(Self::DEFAULT_CMD)
459    }
460
461    /// Resolve the `artifacts` filter, falling back to `""` (DockerImageV2 only).
462    pub fn resolved_artifacts(&self) -> &str {
463        self.artifacts.as_deref().unwrap_or(Self::DEFAULT_ARTIFACTS)
464    }
465
466    /// Resolve `args`, materializing the [`Self::DEFAULT_ARGS`] const into
467    /// a `Vec<String>` when the user left `args:` unset.
468    pub fn resolved_args(&self) -> Vec<String> {
469        self.args.clone().unwrap_or_else(|| {
470            Self::DEFAULT_ARGS
471                .iter()
472                .map(|s| (*s).to_string())
473                .collect()
474        })
475    }
476
477    /// Whether post-sign verification is enabled (default: `true`).
478    pub fn verify_enabled(&self) -> bool {
479        self.verify
480            .as_ref()
481            .is_none_or(SignVerifyConfig::is_enabled)
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    // ---- SignConfig::resolved_*() (lazy-defaults policy) ----
490
491    #[test]
492    fn sign_resolved_id_default() {
493        assert_eq!(SignConfig::default().resolved_id(), "default");
494    }
495
496    #[test]
497    fn sign_resolved_id_user_value_wins() {
498        let cfg = SignConfig {
499            id: Some("cosign".to_string()),
500            ..Default::default()
501        };
502        assert_eq!(cfg.resolved_id(), "cosign");
503    }
504
505    #[test]
506    fn sign_resolved_artifacts_falls_back_to_supplied_default() {
507        let cfg = SignConfig::default();
508        assert_eq!(
509            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS),
510            "none"
511        );
512        assert_eq!(
513            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS_BINARY),
514            "binary"
515        );
516    }
517
518    #[test]
519    fn sign_resolved_artifacts_user_value_wins_over_fallback() {
520        let cfg = SignConfig {
521            artifacts: Some("checksum".to_string()),
522            ..Default::default()
523        };
524        assert_eq!(
525            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS),
526            "checksum"
527        );
528        assert_eq!(
529            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS_BINARY),
530            "checksum"
531        );
532    }
533
534    #[test]
535    fn sign_resolved_signature_template_default_paths() {
536        let cfg = SignConfig::default();
537        assert_eq!(
538            cfg.resolved_signature_template(SignConfig::DEFAULT_SIGNATURE_TEMPLATE),
539            "{{ .Artifact }}.sig"
540        );
541        // Binary default now equals the simple .sig template — flat layout means
542        // binary names already carry the platform suffix.
543        assert_eq!(
544            cfg.resolved_signature_template(SignConfig::DEFAULT_BINARY_SIGNATURE_TEMPLATE),
545            "{{ .Artifact }}.sig"
546        );
547    }
548
549    #[test]
550    fn sign_resolved_signature_template_user_value_wins() {
551        let cfg = SignConfig {
552            signature: Some("custom-{{ .Artifact }}.asc".to_string()),
553            ..Default::default()
554        };
555        assert_eq!(
556            cfg.resolved_signature_template(SignConfig::DEFAULT_SIGNATURE_TEMPLATE),
557            "custom-{{ .Artifact }}.asc"
558        );
559    }
560
561    #[test]
562    fn sign_resolved_args_default_matches_goreleaser() {
563        let cfg = SignConfig::default();
564        assert_eq!(
565            cfg.resolved_args(),
566            vec![
567                "--output".to_string(),
568                "{{ .Signature }}".to_string(),
569                "--detach-sig".to_string(),
570                "{{ .Artifact }}".to_string(),
571            ]
572        );
573    }
574
575    #[test]
576    fn sign_resolved_args_user_value_wins() {
577        let custom = vec!["sign".to_string(), "--key=k".to_string()];
578        let cfg = SignConfig {
579            args: Some(custom.clone()),
580            ..Default::default()
581        };
582        assert_eq!(cfg.resolved_args(), custom);
583    }
584
585    // ---- DockerSignConfig::resolved_*() ----
586
587    #[test]
588    fn docker_sign_resolved_id_default() {
589        assert_eq!(DockerSignConfig::default().resolved_id(), "default");
590    }
591
592    #[test]
593    fn docker_sign_resolved_id_user_value_wins() {
594        let cfg = DockerSignConfig {
595            id: Some("custom".to_string()),
596            ..Default::default()
597        };
598        assert_eq!(cfg.resolved_id(), "custom");
599    }
600
601    #[test]
602    fn docker_sign_resolved_cmd_default() {
603        assert_eq!(DockerSignConfig::default().resolved_cmd(), "cosign");
604    }
605
606    #[test]
607    fn docker_sign_resolved_cmd_user_value_wins() {
608        let cfg = DockerSignConfig {
609            cmd: Some("notation".to_string()),
610            ..Default::default()
611        };
612        assert_eq!(cfg.resolved_cmd(), "notation");
613    }
614
615    #[test]
616    fn docker_sign_resolved_artifacts_default() {
617        assert_eq!(DockerSignConfig::default().resolved_artifacts(), "");
618    }
619
620    #[test]
621    fn docker_sign_resolved_artifacts_user_value_wins() {
622        let cfg = DockerSignConfig {
623            artifacts: Some("manifests".to_string()),
624            ..Default::default()
625        };
626        assert_eq!(cfg.resolved_artifacts(), "manifests");
627    }
628
629    #[test]
630    fn docker_sign_resolved_args_default_matches_goreleaser() {
631        assert_eq!(
632            DockerSignConfig::default().resolved_args(),
633            vec![
634                "sign".to_string(),
635                "--key=cosign.key".to_string(),
636                "{{ .Artifact }}@{{ .Digest }}".to_string(),
637                "--yes".to_string(),
638            ]
639        );
640    }
641
642    #[test]
643    fn docker_sign_resolved_args_user_value_wins() {
644        let custom = vec!["verify".to_string(), "--cert=c".to_string()];
645        let cfg = DockerSignConfig {
646            args: Some(custom.clone()),
647            ..Default::default()
648        };
649        assert_eq!(cfg.resolved_args(), custom);
650    }
651    // ---- SignConfig::is_gpg() ---------------------------------------
652
653    #[test]
654    fn is_gpg_default_cmd_with_signing_artifacts_is_true() {
655        // No cmd set + artifacts set to something other than "none" =
656        // default gpg invocation, treated as gpg-configured.
657        let cfg = SignConfig {
658            artifacts: Some("all".to_string()),
659            ..Default::default()
660        };
661        assert!(cfg.is_gpg());
662    }
663
664    #[test]
665    fn is_gpg_default_artifacts_none_is_false() {
666        // Default artifacts filter is "none" — entry is effectively
667        // disabled, so it does not count as gpg-configured.
668        let cfg = SignConfig::default();
669        assert!(!cfg.is_gpg());
670    }
671
672    #[test]
673    fn is_gpg_cosign_cmd_is_false() {
674        let cfg = SignConfig {
675            artifacts: Some("all".to_string()),
676            cmd: Some("cosign".to_string()),
677            ..Default::default()
678        };
679        assert!(!cfg.is_gpg());
680    }
681
682    #[test]
683    fn is_gpg_gpg2_cmd_is_true() {
684        let cfg = SignConfig {
685            artifacts: Some("checksum".to_string()),
686            cmd: Some("gpg2".to_string()),
687            ..Default::default()
688        };
689        assert!(cfg.is_gpg());
690    }
691
692    #[test]
693    fn is_gpg_absolute_gpg_path_is_true() {
694        let cfg = SignConfig {
695            artifacts: Some("binary".to_string()),
696            cmd: Some("/usr/local/bin/gpg".to_string()),
697            ..Default::default()
698        };
699        assert!(cfg.is_gpg());
700    }
701
702    // ---- is_gpg_command() -------------------------------------------
703
704    #[test]
705    fn is_gpg_command_matches_gpg_variants() {
706        for cmd in [
707            "gpg",
708            "gpg2",
709            "gpg.exe",
710            "/usr/bin/gpg",
711            "/usr/local/bin/gpg2",
712        ] {
713            assert!(is_gpg_command(cmd), "{cmd} must classify as gpg");
714        }
715    }
716
717    #[test]
718    fn is_gpg_command_rejects_non_gpg() {
719        for cmd in [
720            "cosign",
721            "notation",
722            "/usr/bin/cosign",
723            "mygpg-wrapper/cosign",
724        ] {
725            assert!(!is_gpg_command(cmd), "{cmd} must not classify as gpg");
726        }
727    }
728
729    // ---- AuthenticodeConfig::resolved_*() (lazy-defaults policy) ----
730
731    #[test]
732    fn authenticode_resolved_cert_env_default() {
733        assert_eq!(
734            AuthenticodeConfig::default().resolved_cert_env(),
735            "WINDOWS_CERT_FILE"
736        );
737        assert_eq!(AuthenticodeConfig::DEFAULT_CERT_ENV, "WINDOWS_CERT_FILE");
738    }
739
740    #[test]
741    fn authenticode_resolved_cert_env_user_value_wins() {
742        let cfg = AuthenticodeConfig {
743            cert_env: Some("MY_CERT_PATH".to_string()),
744            ..Default::default()
745        };
746        assert_eq!(cfg.resolved_cert_env(), "MY_CERT_PATH");
747    }
748
749    #[test]
750    fn authenticode_resolved_password_env_default() {
751        assert_eq!(
752            AuthenticodeConfig::default().resolved_password_env(),
753            "WINDOWS_CERT_PASSWORD"
754        );
755        assert_eq!(
756            AuthenticodeConfig::DEFAULT_PASSWORD_ENV,
757            "WINDOWS_CERT_PASSWORD"
758        );
759    }
760
761    #[test]
762    fn authenticode_resolved_password_env_user_value_wins() {
763        let cfg = AuthenticodeConfig {
764            password_env: Some("CERT_PW".to_string()),
765            ..Default::default()
766        };
767        assert_eq!(cfg.resolved_password_env(), "CERT_PW");
768    }
769
770    #[test]
771    fn authenticode_resolved_timestamp_url_default() {
772        assert_eq!(
773            AuthenticodeConfig::default().resolved_timestamp_url(),
774            "http://timestamp.digicert.com"
775        );
776        assert_eq!(
777            AuthenticodeConfig::DEFAULT_TIMESTAMP_URL,
778            "http://timestamp.digicert.com"
779        );
780    }
781
782    #[test]
783    fn authenticode_resolved_timestamp_url_user_value_wins() {
784        let cfg = AuthenticodeConfig {
785            timestamp_url: Some("http://timestamp.sectigo.com".to_string()),
786            ..Default::default()
787        };
788        assert_eq!(cfg.resolved_timestamp_url(), "http://timestamp.sectigo.com");
789    }
790
791    #[test]
792    fn authenticode_resolved_artifacts_default() {
793        assert_eq!(
794            AuthenticodeConfig::default().resolved_artifacts(),
795            "windows"
796        );
797        assert_eq!(AuthenticodeConfig::DEFAULT_ARTIFACTS, "windows");
798    }
799
800    #[test]
801    fn authenticode_resolved_artifacts_user_value_wins() {
802        let cfg = AuthenticodeConfig {
803            artifacts: Some("binary".to_string()),
804            ..Default::default()
805        };
806        assert_eq!(cfg.resolved_artifacts(), "binary");
807    }
808
809    #[test]
810    fn authenticode_resolved_tool_host_default() {
811        // The host-derived default is signtool on Windows, osslsigncode
812        // elsewhere — assert whichever this build targets.
813        let expected = if cfg!(windows) {
814            "signtool"
815        } else {
816            "osslsigncode"
817        };
818        assert_eq!(AuthenticodeConfig::default().resolved_tool(), expected);
819    }
820
821    #[test]
822    fn authenticode_resolved_tool_user_value_wins() {
823        let cfg = AuthenticodeConfig {
824            tool: Some("osslsigncode".to_string()),
825            ..Default::default()
826        };
827        assert_eq!(cfg.resolved_tool(), "osslsigncode");
828    }
829
830    #[test]
831    fn authenticode_is_required_default_false() {
832        assert!(!AuthenticodeConfig::default().is_required());
833    }
834
835    #[test]
836    fn authenticode_is_required_user_value_wins() {
837        let cfg = AuthenticodeConfig {
838            required: Some(true),
839            ..Default::default()
840        };
841        assert!(cfg.is_required());
842    }
843}