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// ---------------------------------------------------------------------------
26// gpg --faked-system-time capability probe
27// ---------------------------------------------------------------------------
28
29/// Argv passed to `gpg` for the `--faked-system-time` capability probe.
30///
31/// Pinned as a single constant so the production path
32/// ([`gpg_supports_faked_system_time`]) and the test-only injection
33/// seam ([`gpg_supports_faked_system_time_with`]) share the exact same
34/// invocation. A unit test in this module's `#[cfg(test)]` block
35/// asserts the exact contents so a future contributor changing the
36/// argv (e.g. dropping the `!` suffix, reordering flags) updates one
37/// place and the test catches drift.
38pub(crate) const GPG_PROBE_ARGS: &[&str] = &["--faked-system-time", "0!", "--version"];
39
40/// Probe whether the local `gpg` binary supports `--faked-system-time`.
41///
42/// `--faked-system-time <epoch>!` is the documented way to make gpg emit
43/// a signature with a deterministic timestamp. Older gpg builds (and
44/// some macOS packagers) do not support it. We probe by invoking
45/// `gpg --faked-system-time 0! --version`; exit 0 means supported,
46/// anything else (including gpg-not-on-PATH) means unsupported.
47///
48/// The preflight stage calls this once at pipeline start. When it
49/// returns `false` AND the config has gpg signing configured, the
50/// preflight stage adds a compile-time allow-list entry for
51/// `gpg-signature.asc` so the determinism harness excludes gpg
52/// signatures from drift detection, and emits a warning.
53pub fn gpg_supports_faked_system_time() -> bool {
54    // Delegates to the allow-listed `tool_detect` module so the
55    // `Command::new` shell-out lives at an approved boundary. The
56    // `_with` seam below is *not* on this path — it exists solely
57    // for unit-test mocking.
58    crate::tool_detect::tool_runs_with_args("gpg", GPG_PROBE_ARGS)
59}
60
61/// Probe with an injected command runner — kept as a test seam.
62///
63/// The public [`gpg_supports_faked_system_time`] no longer routes
64/// through this function (it now delegates to
65/// `tool_detect::tool_runs_with_args` to satisfy the module-boundaries
66/// rule). This `_with` variant exists solely so the unit tests below,
67/// plus dependent-crate tests that need to mock the probe without
68/// spawning real `gpg`, can supply a canned
69/// [`std::process::Output`] (or an `io::Error`). Exposed (not
70/// `cfg(test)`) so those dependent-crate tests can reuse the seam
71/// without needing `anodizer-core`'s test config.
72pub fn gpg_supports_faked_system_time_with<F>(probe: F) -> bool
73where
74    F: FnOnce(&[&str]) -> std::io::Result<std::process::Output>,
75{
76    match probe(GPG_PROBE_ARGS) {
77        Ok(out) => out.status.success(),
78        Err(_) => false, // gpg not on PATH or transient io error
79    }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
83#[serde(default, deny_unknown_fields)]
84pub struct SignConfig {
85    /// Unique identifier for this sign config.
86    pub id: Option<String>,
87    /// Artifact types to sign: "all", "archive", "binary", "checksum", "package", "sbom" (default: "none").
88    pub artifacts: Option<String>,
89    /// Signing command to invoke (default: "cosign" or "gpg").
90    pub cmd: Option<String>,
91    /// Arguments passed to the signing command (supports templates with ${artifact} and ${signature}).
92    pub args: Option<Vec<String>>,
93    /// Signature output filename template (supports templates).
94    pub signature: Option<String>,
95    /// Content written to the signing command's stdin.
96    pub stdin: Option<String>,
97    /// Path to a file whose content is written to the signing command's stdin.
98    pub stdin_file: Option<String>,
99    /// Build IDs filter: only sign artifacts from builds whose `id` is in this list.
100    pub ids: Option<Vec<String>>,
101    /// Environment variables passed to the signing command.
102    #[serde(default)]
103    pub env: Option<Vec<String>>,
104    /// Certificate file to embed in the signature (Cosign bundle signing).
105    pub certificate: Option<String>,
106    /// Capture and log stdout/stderr of the signing command.
107    /// Accepts bool or template string (e.g., "{{ IsSnapshot }}").
108    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
109    pub output: Option<StringOrBool>,
110    /// Authenticode (Windows PE/MSI) signing backend. When set, this sign
111    /// config signs Windows artifacts in place via osslsigncode (Linux/cross)
112    /// or signtool (Windows) instead of producing a detached cosign/gpg
113    /// signature. The signing command, argv, timestamp URL, and artifact
114    /// selector are all derived; supply only the cert (a secret).
115    pub authenticode: Option<AuthenticodeConfig>,
116    /// Template-conditional: skip this sign config if rendered result is "false" or empty.
117    #[serde(rename = "if")]
118    pub if_condition: Option<String>,
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    /// `true` when this sign config will invoke gpg.
340    ///
341    /// The top-level `signs:` driver defaults to gpg when `cmd:` is unset
342    /// (see `stage-sign::helpers::default_sign_cmd` which falls back to
343    /// `git config gpg.program` then to literal `"gpg"`). We treat any
344    /// cmd whose basename starts with `gpg` (e.g., `gpg`, `gpg2`,
345    /// `/usr/local/bin/gpg`) as a gpg invocation. A cmd of `"cosign"`,
346    /// `"notation"`, etc. returns false.
347    ///
348    /// Entries with `artifacts: "none"` (the default for top-level
349    /// `signs:`) are treated as not-configured — the loop never fires.
350    pub fn is_gpg(&self) -> bool {
351        // Effectively-disabled entries don't count as configured.
352        let artifacts = self.resolved_artifacts(Self::DEFAULT_ARTIFACTS);
353        if artifacts == "none" {
354            return false;
355        }
356        match self.cmd.as_deref() {
357            None => true, // default cmd is gpg
358            Some(cmd) => {
359                let basename = std::path::Path::new(cmd)
360                    .file_name()
361                    .and_then(|s| s.to_str())
362                    .unwrap_or(cmd);
363                basename.starts_with("gpg")
364            }
365        }
366    }
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
370#[serde(default, deny_unknown_fields)]
371pub struct DockerSignConfig {
372    /// Unique identifier for this docker sign config.
373    pub id: Option<String>,
374    /// Docker artifact types to sign: "all", "image", or "manifest" (default: "none").
375    pub artifacts: Option<String>,
376    /// Signing command to invoke (default: "cosign").
377    pub cmd: Option<String>,
378    /// Arguments passed to the signing command (supports templates).
379    pub args: Option<Vec<String>>,
380    /// Signature output filename template (supports templates).
381    pub signature: Option<String>,
382    /// Certificate file to embed in the signature (Cosign bundle signing).
383    pub certificate: Option<String>,
384    /// Docker config IDs filter: only sign images from configs whose `id` is in this list.
385    pub ids: Option<Vec<String>>,
386    /// Content written to the signing command's stdin.
387    pub stdin: Option<String>,
388    /// Path to a file whose content is written to the signing command's stdin.
389    pub stdin_file: Option<String>,
390    /// Environment variables passed to the signing command.
391    #[serde(default)]
392    pub env: Option<Vec<String>>,
393    /// Capture and log stdout/stderr of the docker signing command.
394    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
395    pub output: Option<StringOrBool>,
396    /// Template-conditional: skip this docker sign config if rendered result is "false" or empty.
397    #[serde(rename = "if")]
398    pub if_condition: Option<String>,
399}
400
401impl DockerSignConfig {
402    /// Default `id` when a docker-sign config has none (`"default"`).
403    pub const DEFAULT_ID: &'static str = "default";
404
405    /// Default signing `cmd`
406    /// (`cfg.Cmd = "cosign"`). Unlike top-level `signs:[]` (which falls
407    /// back to git's `gpg.program` config), docker signing only ever
408    /// targets cosign, so the default is a static literal.
409    pub const DEFAULT_CMD: &'static str = "cosign";
410
411    /// Default `artifacts` filter when unset. Empty string is treated by
412    /// the docker-sign driver as "DockerImageV2 only" (post-buildx
413    /// canonical case). An empty `artifacts` is treated identically.
414    pub const DEFAULT_ARTIFACTS: &'static str = "";
415
416    /// Default `args` for `docker_signs:[]`
417    /// (`["sign", "--key=cosign.key",
418    /// "${artifact}@${digest}", "--yes"]`). Anodize substitutes
419    /// `${artifact}@${digest}` for the Tera-rewritten
420    /// `{{ .Artifact }}@{{ .Digest }}` placeholders.
421    pub const DEFAULT_ARGS: &[&'static str] = &[
422        "sign",
423        "--key=cosign.key",
424        "{{ .Artifact }}@{{ .Digest }}",
425        "--yes",
426    ];
427
428    /// Resolve the docker-sign id, falling back to `"default"`.
429    pub fn resolved_id(&self) -> &str {
430        self.id.as_deref().unwrap_or(Self::DEFAULT_ID)
431    }
432
433    /// Resolve the signing command, falling back to `"cosign"`.
434    pub fn resolved_cmd(&self) -> &str {
435        self.cmd.as_deref().unwrap_or(Self::DEFAULT_CMD)
436    }
437
438    /// Resolve the `artifacts` filter, falling back to `""` (DockerImageV2 only).
439    pub fn resolved_artifacts(&self) -> &str {
440        self.artifacts.as_deref().unwrap_or(Self::DEFAULT_ARTIFACTS)
441    }
442
443    /// Resolve `args`, materializing the [`Self::DEFAULT_ARGS`] const into
444    /// a `Vec<String>` when the user left `args:` unset.
445    pub fn resolved_args(&self) -> Vec<String> {
446        self.args.clone().unwrap_or_else(|| {
447            Self::DEFAULT_ARGS
448                .iter()
449                .map(|s| (*s).to_string())
450                .collect()
451        })
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    // ---- SignConfig::resolved_*() (lazy-defaults policy) ----
460
461    #[test]
462    fn sign_resolved_id_default() {
463        assert_eq!(SignConfig::default().resolved_id(), "default");
464    }
465
466    #[test]
467    fn sign_resolved_id_user_value_wins() {
468        let cfg = SignConfig {
469            id: Some("cosign".to_string()),
470            ..Default::default()
471        };
472        assert_eq!(cfg.resolved_id(), "cosign");
473    }
474
475    #[test]
476    fn sign_resolved_artifacts_falls_back_to_supplied_default() {
477        let cfg = SignConfig::default();
478        assert_eq!(
479            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS),
480            "none"
481        );
482        assert_eq!(
483            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS_BINARY),
484            "binary"
485        );
486    }
487
488    #[test]
489    fn sign_resolved_artifacts_user_value_wins_over_fallback() {
490        let cfg = SignConfig {
491            artifacts: Some("checksum".to_string()),
492            ..Default::default()
493        };
494        assert_eq!(
495            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS),
496            "checksum"
497        );
498        assert_eq!(
499            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS_BINARY),
500            "checksum"
501        );
502    }
503
504    #[test]
505    fn sign_resolved_signature_template_default_paths() {
506        let cfg = SignConfig::default();
507        assert_eq!(
508            cfg.resolved_signature_template(SignConfig::DEFAULT_SIGNATURE_TEMPLATE),
509            "{{ .Artifact }}.sig"
510        );
511        // Binary default now equals the simple .sig template — flat layout means
512        // binary names already carry the platform suffix.
513        assert_eq!(
514            cfg.resolved_signature_template(SignConfig::DEFAULT_BINARY_SIGNATURE_TEMPLATE),
515            "{{ .Artifact }}.sig"
516        );
517    }
518
519    #[test]
520    fn sign_resolved_signature_template_user_value_wins() {
521        let cfg = SignConfig {
522            signature: Some("custom-{{ .Artifact }}.asc".to_string()),
523            ..Default::default()
524        };
525        assert_eq!(
526            cfg.resolved_signature_template(SignConfig::DEFAULT_SIGNATURE_TEMPLATE),
527            "custom-{{ .Artifact }}.asc"
528        );
529    }
530
531    #[test]
532    fn sign_resolved_args_default_matches_goreleaser() {
533        let cfg = SignConfig::default();
534        assert_eq!(
535            cfg.resolved_args(),
536            vec![
537                "--output".to_string(),
538                "{{ .Signature }}".to_string(),
539                "--detach-sig".to_string(),
540                "{{ .Artifact }}".to_string(),
541            ]
542        );
543    }
544
545    #[test]
546    fn sign_resolved_args_user_value_wins() {
547        let custom = vec!["sign".to_string(), "--key=k".to_string()];
548        let cfg = SignConfig {
549            args: Some(custom.clone()),
550            ..Default::default()
551        };
552        assert_eq!(cfg.resolved_args(), custom);
553    }
554
555    // ---- DockerSignConfig::resolved_*() ----
556
557    #[test]
558    fn docker_sign_resolved_id_default() {
559        assert_eq!(DockerSignConfig::default().resolved_id(), "default");
560    }
561
562    #[test]
563    fn docker_sign_resolved_id_user_value_wins() {
564        let cfg = DockerSignConfig {
565            id: Some("custom".to_string()),
566            ..Default::default()
567        };
568        assert_eq!(cfg.resolved_id(), "custom");
569    }
570
571    #[test]
572    fn docker_sign_resolved_cmd_default() {
573        assert_eq!(DockerSignConfig::default().resolved_cmd(), "cosign");
574    }
575
576    #[test]
577    fn docker_sign_resolved_cmd_user_value_wins() {
578        let cfg = DockerSignConfig {
579            cmd: Some("notation".to_string()),
580            ..Default::default()
581        };
582        assert_eq!(cfg.resolved_cmd(), "notation");
583    }
584
585    #[test]
586    fn docker_sign_resolved_artifacts_default() {
587        assert_eq!(DockerSignConfig::default().resolved_artifacts(), "");
588    }
589
590    #[test]
591    fn docker_sign_resolved_artifacts_user_value_wins() {
592        let cfg = DockerSignConfig {
593            artifacts: Some("manifests".to_string()),
594            ..Default::default()
595        };
596        assert_eq!(cfg.resolved_artifacts(), "manifests");
597    }
598
599    #[test]
600    fn docker_sign_resolved_args_default_matches_goreleaser() {
601        assert_eq!(
602            DockerSignConfig::default().resolved_args(),
603            vec![
604                "sign".to_string(),
605                "--key=cosign.key".to_string(),
606                "{{ .Artifact }}@{{ .Digest }}".to_string(),
607                "--yes".to_string(),
608            ]
609        );
610    }
611
612    #[test]
613    fn docker_sign_resolved_args_user_value_wins() {
614        let custom = vec!["verify".to_string(), "--cert=c".to_string()];
615        let cfg = DockerSignConfig {
616            args: Some(custom.clone()),
617            ..Default::default()
618        };
619        assert_eq!(cfg.resolved_args(), custom);
620    }
621
622    // ---- gpg --faked-system-time capability probe ----
623
624    use std::process::{ExitStatus, Output};
625
626    #[cfg(unix)]
627    fn mk_exit_status(success: bool) -> ExitStatus {
628        use std::os::unix::process::ExitStatusExt;
629        if success {
630            ExitStatus::from_raw(0)
631        } else {
632            ExitStatus::from_raw(1 << 8)
633        }
634    }
635
636    #[cfg(windows)]
637    fn mk_exit_status(success: bool) -> ExitStatus {
638        use std::os::windows::process::ExitStatusExt;
639        ExitStatus::from_raw(if success { 0 } else { 1 })
640    }
641
642    fn mk_output(success: bool) -> Output {
643        Output {
644            status: mk_exit_status(success),
645            stdout: Vec::new(),
646            stderr: Vec::new(),
647        }
648    }
649
650    /// Pins the exact argv shared by the prod path
651    /// (`gpg_supports_faked_system_time`) and the `_with` test seam.
652    /// The seam tests below mock the *return value* of the probe, not
653    /// the argv it receives, so without this test a future refactor
654    /// that quietly changed the flag order or dropped the trailing
655    /// `!` would slip past green CI. Anchoring against the literal
656    /// list (not `GPG_PROBE_ARGS == GPG_PROBE_ARGS`, which is a
657    /// tautology) catches that drift.
658    #[test]
659    fn gpg_probe_argv_is_pinned() {
660        assert_eq!(
661            super::GPG_PROBE_ARGS,
662            &["--faked-system-time", "0!", "--version"]
663        );
664    }
665
666    #[test]
667    fn gpg_faked_time_supported_returns_true_when_probe_succeeds() {
668        let supported = gpg_supports_faked_system_time_with(|args| {
669            assert_eq!(args, &["--faked-system-time", "0!", "--version"]);
670            Ok(mk_output(true))
671        });
672        assert!(supported);
673    }
674
675    #[test]
676    fn gpg_faked_time_unsupported_returns_false_when_probe_fails() {
677        let supported = gpg_supports_faked_system_time_with(|_| Ok(mk_output(false)));
678        assert!(!supported);
679    }
680
681    #[test]
682    fn gpg_faked_time_returns_false_when_probe_errors() {
683        let supported = gpg_supports_faked_system_time_with(|_| {
684            Err(std::io::Error::new(
685                std::io::ErrorKind::NotFound,
686                "gpg not on PATH",
687            ))
688        });
689        assert!(!supported);
690    }
691
692    // ---- SignConfig::is_gpg() ---------------------------------------
693
694    #[test]
695    fn is_gpg_default_cmd_with_signing_artifacts_is_true() {
696        // No cmd set + artifacts set to something other than "none" =
697        // default gpg invocation, treated as gpg-configured.
698        let cfg = SignConfig {
699            artifacts: Some("all".to_string()),
700            ..Default::default()
701        };
702        assert!(cfg.is_gpg());
703    }
704
705    #[test]
706    fn is_gpg_default_artifacts_none_is_false() {
707        // Default artifacts filter is "none" — entry is effectively
708        // disabled, so it does not count as gpg-configured.
709        let cfg = SignConfig::default();
710        assert!(!cfg.is_gpg());
711    }
712
713    #[test]
714    fn is_gpg_cosign_cmd_is_false() {
715        let cfg = SignConfig {
716            artifacts: Some("all".to_string()),
717            cmd: Some("cosign".to_string()),
718            ..Default::default()
719        };
720        assert!(!cfg.is_gpg());
721    }
722
723    #[test]
724    fn is_gpg_gpg2_cmd_is_true() {
725        let cfg = SignConfig {
726            artifacts: Some("checksum".to_string()),
727            cmd: Some("gpg2".to_string()),
728            ..Default::default()
729        };
730        assert!(cfg.is_gpg());
731    }
732
733    #[test]
734    fn is_gpg_absolute_gpg_path_is_true() {
735        let cfg = SignConfig {
736            artifacts: Some("binary".to_string()),
737            cmd: Some("/usr/local/bin/gpg".to_string()),
738            ..Default::default()
739        };
740        assert!(cfg.is_gpg());
741    }
742
743    // ---- AuthenticodeConfig::resolved_*() (lazy-defaults policy) ----
744
745    #[test]
746    fn authenticode_resolved_cert_env_default() {
747        assert_eq!(
748            AuthenticodeConfig::default().resolved_cert_env(),
749            "WINDOWS_CERT_FILE"
750        );
751        assert_eq!(AuthenticodeConfig::DEFAULT_CERT_ENV, "WINDOWS_CERT_FILE");
752    }
753
754    #[test]
755    fn authenticode_resolved_cert_env_user_value_wins() {
756        let cfg = AuthenticodeConfig {
757            cert_env: Some("MY_CERT_PATH".to_string()),
758            ..Default::default()
759        };
760        assert_eq!(cfg.resolved_cert_env(), "MY_CERT_PATH");
761    }
762
763    #[test]
764    fn authenticode_resolved_password_env_default() {
765        assert_eq!(
766            AuthenticodeConfig::default().resolved_password_env(),
767            "WINDOWS_CERT_PASSWORD"
768        );
769        assert_eq!(
770            AuthenticodeConfig::DEFAULT_PASSWORD_ENV,
771            "WINDOWS_CERT_PASSWORD"
772        );
773    }
774
775    #[test]
776    fn authenticode_resolved_password_env_user_value_wins() {
777        let cfg = AuthenticodeConfig {
778            password_env: Some("CERT_PW".to_string()),
779            ..Default::default()
780        };
781        assert_eq!(cfg.resolved_password_env(), "CERT_PW");
782    }
783
784    #[test]
785    fn authenticode_resolved_timestamp_url_default() {
786        assert_eq!(
787            AuthenticodeConfig::default().resolved_timestamp_url(),
788            "http://timestamp.digicert.com"
789        );
790        assert_eq!(
791            AuthenticodeConfig::DEFAULT_TIMESTAMP_URL,
792            "http://timestamp.digicert.com"
793        );
794    }
795
796    #[test]
797    fn authenticode_resolved_timestamp_url_user_value_wins() {
798        let cfg = AuthenticodeConfig {
799            timestamp_url: Some("http://timestamp.sectigo.com".to_string()),
800            ..Default::default()
801        };
802        assert_eq!(cfg.resolved_timestamp_url(), "http://timestamp.sectigo.com");
803    }
804
805    #[test]
806    fn authenticode_resolved_artifacts_default() {
807        assert_eq!(
808            AuthenticodeConfig::default().resolved_artifacts(),
809            "windows"
810        );
811        assert_eq!(AuthenticodeConfig::DEFAULT_ARTIFACTS, "windows");
812    }
813
814    #[test]
815    fn authenticode_resolved_artifacts_user_value_wins() {
816        let cfg = AuthenticodeConfig {
817            artifacts: Some("binary".to_string()),
818            ..Default::default()
819        };
820        assert_eq!(cfg.resolved_artifacts(), "binary");
821    }
822
823    #[test]
824    fn authenticode_resolved_tool_host_default() {
825        // The host-derived default is signtool on Windows, osslsigncode
826        // elsewhere — assert whichever this build targets.
827        let expected = if cfg!(windows) {
828            "signtool"
829        } else {
830            "osslsigncode"
831        };
832        assert_eq!(AuthenticodeConfig::default().resolved_tool(), expected);
833    }
834
835    #[test]
836    fn authenticode_resolved_tool_user_value_wins() {
837        let cfg = AuthenticodeConfig {
838            tool: Some("osslsigncode".to_string()),
839            ..Default::default()
840        };
841        assert_eq!(cfg.resolved_tool(), "osslsigncode");
842    }
843
844    #[test]
845    fn authenticode_is_required_default_false() {
846        assert!(!AuthenticodeConfig::default().is_required());
847    }
848
849    #[test]
850    fn authenticode_is_required_user_value_wins() {
851        let cfg = AuthenticodeConfig {
852            required: Some(true),
853            ..Default::default()
854        };
855        assert!(cfg.is_required());
856    }
857}