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)]
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    /// Template-conditional: skip this sign config if rendered result is "false" or empty.
111    #[serde(rename = "if")]
112    pub if_condition: Option<String>,
113}
114
115impl SignConfig {
116    /// Default `id` when a sign config has none. Mirrors GoReleaser
117    /// `internal/pipe/sign/sign.go` (`cfg.ID = "default"`). Used to
118    /// label log lines and uniqueness-error messages.
119    pub const DEFAULT_ID: &'static str = "default";
120
121    /// Default `artifacts` filter for top-level `signs:[]`. Mirrors
122    /// GoReleaser `sign.go` (`cfg.Artifacts = "none"`) — by default
123    /// nothing is signed unless the user opts in.
124    pub const DEFAULT_ARTIFACTS: &'static str = "none";
125
126    /// Default `artifacts` filter for `binary_signs:[]`. The binary-only
127    /// driver always restricts the artifact-kind filter to binaries even
128    /// when the user leaves `artifacts:` unset. Anodize-specific helper
129    /// (no GoReleaser equivalent — GR uses a different config type for
130    /// binary signing) but kept on `SignConfig` because anodize unifies
131    /// `signs[]` and `binary_signs[]` into one struct.
132    pub const DEFAULT_ARTIFACTS_BINARY: &'static str = "binary";
133
134    /// Default `signature` template for top-level `signs:[]`. Mirrors
135    /// GoReleaser `sign.go` (`cfg.Signature = "${artifact}.sig"`).
136    /// Anodize uses Tera-style `{{ .Artifact }}` placeholders that the
137    /// arg-resolver rewrites to the same path at execution time.
138    pub const DEFAULT_SIGNATURE_TEMPLATE: &'static str = "{{ .Artifact }}.sig";
139
140    /// Default `signature` template for `binary_signs:[]`.
141    ///
142    /// Intentionally **diverges from GoReleaser** `sign_binary.go:16`: GR
143    /// stores binaries under per-target subdirectories
144    /// (`dist/linux_amd64/binname`), so its template appends `_{{ .Os }}_{{ .Arch }}`
145    /// to the bare binary name without collision. Anodize uses a flat `dist/`
146    /// layout where stage-build already names binaries with the platform
147    /// suffix (`myapp_linux_amd64`, `myapp_darwin_arm64`, etc.). Appending
148    /// Os/Arch again would produce `myapp_linux_amd64_linux_amd64` with no
149    /// `.sig` extension — a double-suffix bug.
150    ///
151    /// The correct default for anodize's layout is `{{ .Artifact }}.sig` —
152    /// identical to `DEFAULT_SIGNATURE_TEMPLATE`. Binary names are already
153    /// unique per target, so no collision risk exists. Users who want an
154    /// explicit per-target suffix can set `signature:` in `binary_signs:`.
155    pub const DEFAULT_BINARY_SIGNATURE_TEMPLATE: &'static str = "{{ .Artifact }}.sig";
156
157    /// Default `args` for top-level `signs:[]`. Mirrors GoReleaser
158    /// `sign.go` (`["--output", "$signature", "--detach-sig", "$artifact"]`).
159    /// Anodize substitutes `$signature` / `$artifact` for `{{ .Signature }}`
160    /// / `{{ .Artifact }}` Tera placeholders that the arg-resolver
161    /// rewrites; the wire-level invocation matches GR exactly.
162    pub const DEFAULT_ARGS: &[&'static str] = &[
163        "--output",
164        "{{ .Signature }}",
165        "--detach-sig",
166        "{{ .Artifact }}",
167    ];
168
169    /// Resolve the sign-config id, falling back to `"default"` (GoReleaser-canonical).
170    pub fn resolved_id(&self) -> &str {
171        self.id.as_deref().unwrap_or(Self::DEFAULT_ID)
172    }
173
174    /// Resolve the `artifacts` filter, falling back to the supplied
175    /// `fallback` (`Self::DEFAULT_ARTIFACTS` for `signs[]`,
176    /// `Self::DEFAULT_ARTIFACTS_BINARY` for `binary_signs[]`).
177    pub fn resolved_artifacts<'a>(&'a self, fallback: &'a str) -> &'a str {
178        self.artifacts.as_deref().unwrap_or(fallback)
179    }
180
181    /// Resolve the `signature` template, falling back to the supplied
182    /// `default` (`Self::DEFAULT_SIGNATURE_TEMPLATE` for `signs[]`,
183    /// `Self::DEFAULT_BINARY_SIGNATURE_TEMPLATE` for `binary_signs[]`).
184    pub fn resolved_signature_template<'a>(&'a self, default: &'a str) -> &'a str {
185        self.signature.as_deref().unwrap_or(default)
186    }
187
188    /// Resolve `args`, materializing the [`Self::DEFAULT_ARGS`] const into
189    /// a `Vec<String>` when the user left `args:` unset. Returns a clone
190    /// of the user-supplied list otherwise.
191    pub fn resolved_args(&self) -> Vec<String> {
192        self.args.clone().unwrap_or_else(|| {
193            Self::DEFAULT_ARGS
194                .iter()
195                .map(|s| (*s).to_string())
196                .collect()
197        })
198    }
199
200    /// `true` when this sign config will invoke gpg.
201    ///
202    /// The top-level `signs:` driver defaults to gpg when `cmd:` is unset
203    /// (see `stage-sign::helpers::default_sign_cmd` which falls back to
204    /// `git config gpg.program` then to literal `"gpg"`). We treat any
205    /// cmd whose basename starts with `gpg` (e.g., `gpg`, `gpg2`,
206    /// `/usr/local/bin/gpg`) as a gpg invocation. A cmd of `"cosign"`,
207    /// `"notation"`, etc. returns false.
208    ///
209    /// Entries with `artifacts: "none"` (the default for top-level
210    /// `signs:`) are treated as not-configured — the loop never fires.
211    pub fn is_gpg(&self) -> bool {
212        // Effectively-disabled entries don't count as configured.
213        let artifacts = self.resolved_artifacts(Self::DEFAULT_ARTIFACTS);
214        if artifacts == "none" {
215            return false;
216        }
217        match self.cmd.as_deref() {
218            None => true, // default cmd is gpg
219            Some(cmd) => {
220                let basename = std::path::Path::new(cmd)
221                    .file_name()
222                    .and_then(|s| s.to_str())
223                    .unwrap_or(cmd);
224                basename.starts_with("gpg")
225            }
226        }
227    }
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
231#[serde(default)]
232pub struct DockerSignConfig {
233    /// Unique identifier for this docker sign config.
234    pub id: Option<String>,
235    /// Docker artifact types to sign: "all", "image", or "manifest" (default: "none").
236    pub artifacts: Option<String>,
237    /// Signing command to invoke (default: "cosign").
238    pub cmd: Option<String>,
239    /// Arguments passed to the signing command (supports templates).
240    pub args: Option<Vec<String>>,
241    /// Signature output filename template (supports templates).
242    pub signature: Option<String>,
243    /// Certificate file to embed in the signature (Cosign bundle signing).
244    pub certificate: Option<String>,
245    /// Docker config IDs filter: only sign images from configs whose `id` is in this list.
246    pub ids: Option<Vec<String>>,
247    /// Content written to the signing command's stdin.
248    pub stdin: Option<String>,
249    /// Path to a file whose content is written to the signing command's stdin.
250    pub stdin_file: Option<String>,
251    /// Environment variables passed to the signing command.
252    #[serde(default)]
253    pub env: Option<Vec<String>>,
254    /// Capture and log stdout/stderr of the docker signing command.
255    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
256    pub output: Option<StringOrBool>,
257    /// Template-conditional: skip this docker sign config if rendered result is "false" or empty.
258    #[serde(rename = "if")]
259    pub if_condition: Option<String>,
260}
261
262impl DockerSignConfig {
263    /// Default `id` when a docker-sign config has none. Mirrors GoReleaser
264    /// `internal/pipe/sign/sign_docker.go` (`cfg.ID = "default"`).
265    pub const DEFAULT_ID: &'static str = "default";
266
267    /// Default signing `cmd`. Mirrors GoReleaser `sign_docker.go`
268    /// (`cfg.Cmd = "cosign"`). Unlike top-level `signs:[]` (which falls
269    /// back to git's `gpg.program` config), docker signing only ever
270    /// targets cosign, so the default is a static literal.
271    pub const DEFAULT_CMD: &'static str = "cosign";
272
273    /// Default `artifacts` filter when unset. Empty string is treated by
274    /// the docker-sign driver as "DockerImageV2 only" (post-buildx
275    /// canonical case). Mirrors GR's lack of an explicit fallback —
276    /// GR's switch on `cfg.Artifacts` treats `""` identically.
277    pub const DEFAULT_ARTIFACTS: &'static str = "";
278
279    /// Default `args` for `docker_signs:[]`. Mirrors GoReleaser
280    /// `sign_docker.go` (`["sign", "--key=cosign.key",
281    /// "${artifact}@${digest}", "--yes"]`). Anodize substitutes
282    /// `${artifact}@${digest}` for the Tera-rewritten
283    /// `{{ .Artifact }}@{{ .Digest }}` placeholders.
284    pub const DEFAULT_ARGS: &[&'static str] = &[
285        "sign",
286        "--key=cosign.key",
287        "{{ .Artifact }}@{{ .Digest }}",
288        "--yes",
289    ];
290
291    /// Resolve the docker-sign id, falling back to `"default"` (GR-canonical).
292    pub fn resolved_id(&self) -> &str {
293        self.id.as_deref().unwrap_or(Self::DEFAULT_ID)
294    }
295
296    /// Resolve the signing command, falling back to `"cosign"` (GR-canonical).
297    pub fn resolved_cmd(&self) -> &str {
298        self.cmd.as_deref().unwrap_or(Self::DEFAULT_CMD)
299    }
300
301    /// Resolve the `artifacts` filter, falling back to `""` (DockerImageV2 only).
302    pub fn resolved_artifacts(&self) -> &str {
303        self.artifacts.as_deref().unwrap_or(Self::DEFAULT_ARTIFACTS)
304    }
305
306    /// Resolve `args`, materializing the [`Self::DEFAULT_ARGS`] const into
307    /// a `Vec<String>` when the user left `args:` unset.
308    pub fn resolved_args(&self) -> Vec<String> {
309        self.args.clone().unwrap_or_else(|| {
310            Self::DEFAULT_ARGS
311                .iter()
312                .map(|s| (*s).to_string())
313                .collect()
314        })
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    // ---- SignConfig::resolved_*() (lazy-defaults policy) ----
323
324    #[test]
325    fn sign_resolved_id_default() {
326        assert_eq!(SignConfig::default().resolved_id(), "default");
327    }
328
329    #[test]
330    fn sign_resolved_id_user_value_wins() {
331        let cfg = SignConfig {
332            id: Some("cosign".to_string()),
333            ..Default::default()
334        };
335        assert_eq!(cfg.resolved_id(), "cosign");
336    }
337
338    #[test]
339    fn sign_resolved_artifacts_falls_back_to_supplied_default() {
340        let cfg = SignConfig::default();
341        assert_eq!(
342            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS),
343            "none"
344        );
345        assert_eq!(
346            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS_BINARY),
347            "binary"
348        );
349    }
350
351    #[test]
352    fn sign_resolved_artifacts_user_value_wins_over_fallback() {
353        let cfg = SignConfig {
354            artifacts: Some("checksum".to_string()),
355            ..Default::default()
356        };
357        assert_eq!(
358            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS),
359            "checksum"
360        );
361        assert_eq!(
362            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS_BINARY),
363            "checksum"
364        );
365    }
366
367    #[test]
368    fn sign_resolved_signature_template_default_paths() {
369        let cfg = SignConfig::default();
370        assert_eq!(
371            cfg.resolved_signature_template(SignConfig::DEFAULT_SIGNATURE_TEMPLATE),
372            "{{ .Artifact }}.sig"
373        );
374        // Binary default now equals the simple .sig template — flat layout means
375        // binary names already carry the platform suffix.
376        assert_eq!(
377            cfg.resolved_signature_template(SignConfig::DEFAULT_BINARY_SIGNATURE_TEMPLATE),
378            "{{ .Artifact }}.sig"
379        );
380    }
381
382    #[test]
383    fn sign_resolved_signature_template_user_value_wins() {
384        let cfg = SignConfig {
385            signature: Some("custom-{{ .Artifact }}.asc".to_string()),
386            ..Default::default()
387        };
388        assert_eq!(
389            cfg.resolved_signature_template(SignConfig::DEFAULT_SIGNATURE_TEMPLATE),
390            "custom-{{ .Artifact }}.asc"
391        );
392    }
393
394    #[test]
395    fn sign_resolved_args_default_matches_goreleaser() {
396        let cfg = SignConfig::default();
397        assert_eq!(
398            cfg.resolved_args(),
399            vec![
400                "--output".to_string(),
401                "{{ .Signature }}".to_string(),
402                "--detach-sig".to_string(),
403                "{{ .Artifact }}".to_string(),
404            ]
405        );
406    }
407
408    #[test]
409    fn sign_resolved_args_user_value_wins() {
410        let custom = vec!["sign".to_string(), "--key=k".to_string()];
411        let cfg = SignConfig {
412            args: Some(custom.clone()),
413            ..Default::default()
414        };
415        assert_eq!(cfg.resolved_args(), custom);
416    }
417
418    // ---- DockerSignConfig::resolved_*() ----
419
420    #[test]
421    fn docker_sign_resolved_id_default() {
422        assert_eq!(DockerSignConfig::default().resolved_id(), "default");
423    }
424
425    #[test]
426    fn docker_sign_resolved_id_user_value_wins() {
427        let cfg = DockerSignConfig {
428            id: Some("custom".to_string()),
429            ..Default::default()
430        };
431        assert_eq!(cfg.resolved_id(), "custom");
432    }
433
434    #[test]
435    fn docker_sign_resolved_cmd_default() {
436        assert_eq!(DockerSignConfig::default().resolved_cmd(), "cosign");
437    }
438
439    #[test]
440    fn docker_sign_resolved_cmd_user_value_wins() {
441        let cfg = DockerSignConfig {
442            cmd: Some("notation".to_string()),
443            ..Default::default()
444        };
445        assert_eq!(cfg.resolved_cmd(), "notation");
446    }
447
448    #[test]
449    fn docker_sign_resolved_artifacts_default() {
450        assert_eq!(DockerSignConfig::default().resolved_artifacts(), "");
451    }
452
453    #[test]
454    fn docker_sign_resolved_artifacts_user_value_wins() {
455        let cfg = DockerSignConfig {
456            artifacts: Some("manifests".to_string()),
457            ..Default::default()
458        };
459        assert_eq!(cfg.resolved_artifacts(), "manifests");
460    }
461
462    #[test]
463    fn docker_sign_resolved_args_default_matches_goreleaser() {
464        assert_eq!(
465            DockerSignConfig::default().resolved_args(),
466            vec![
467                "sign".to_string(),
468                "--key=cosign.key".to_string(),
469                "{{ .Artifact }}@{{ .Digest }}".to_string(),
470                "--yes".to_string(),
471            ]
472        );
473    }
474
475    #[test]
476    fn docker_sign_resolved_args_user_value_wins() {
477        let custom = vec!["verify".to_string(), "--cert=c".to_string()];
478        let cfg = DockerSignConfig {
479            args: Some(custom.clone()),
480            ..Default::default()
481        };
482        assert_eq!(cfg.resolved_args(), custom);
483    }
484
485    // ---- gpg --faked-system-time capability probe ----
486
487    use std::process::{ExitStatus, Output};
488
489    #[cfg(unix)]
490    fn mk_exit_status(success: bool) -> ExitStatus {
491        use std::os::unix::process::ExitStatusExt;
492        if success {
493            ExitStatus::from_raw(0)
494        } else {
495            ExitStatus::from_raw(1 << 8)
496        }
497    }
498
499    #[cfg(windows)]
500    fn mk_exit_status(success: bool) -> ExitStatus {
501        use std::os::windows::process::ExitStatusExt;
502        ExitStatus::from_raw(if success { 0 } else { 1 })
503    }
504
505    fn mk_output(success: bool) -> Output {
506        Output {
507            status: mk_exit_status(success),
508            stdout: Vec::new(),
509            stderr: Vec::new(),
510        }
511    }
512
513    /// Pins the exact argv shared by the prod path
514    /// (`gpg_supports_faked_system_time`) and the `_with` test seam.
515    /// The seam tests below mock the *return value* of the probe, not
516    /// the argv it receives, so without this test a future refactor
517    /// that quietly changed the flag order or dropped the trailing
518    /// `!` would slip past green CI. Anchoring against the literal
519    /// list (not `GPG_PROBE_ARGS == GPG_PROBE_ARGS`, which is a
520    /// tautology) catches that drift.
521    #[test]
522    fn gpg_probe_argv_is_pinned() {
523        assert_eq!(
524            super::GPG_PROBE_ARGS,
525            &["--faked-system-time", "0!", "--version"]
526        );
527    }
528
529    #[test]
530    fn gpg_faked_time_supported_returns_true_when_probe_succeeds() {
531        let supported = gpg_supports_faked_system_time_with(|args| {
532            assert_eq!(args, &["--faked-system-time", "0!", "--version"]);
533            Ok(mk_output(true))
534        });
535        assert!(supported);
536    }
537
538    #[test]
539    fn gpg_faked_time_unsupported_returns_false_when_probe_fails() {
540        let supported = gpg_supports_faked_system_time_with(|_| Ok(mk_output(false)));
541        assert!(!supported);
542    }
543
544    #[test]
545    fn gpg_faked_time_returns_false_when_probe_errors() {
546        let supported = gpg_supports_faked_system_time_with(|_| {
547            Err(std::io::Error::new(
548                std::io::ErrorKind::NotFound,
549                "gpg not on PATH",
550            ))
551        });
552        assert!(!supported);
553    }
554
555    // ---- SignConfig::is_gpg() ---------------------------------------
556
557    #[test]
558    fn is_gpg_default_cmd_with_signing_artifacts_is_true() {
559        // No cmd set + artifacts set to something other than "none" =
560        // default gpg invocation, treated as gpg-configured.
561        let cfg = SignConfig {
562            artifacts: Some("all".to_string()),
563            ..Default::default()
564        };
565        assert!(cfg.is_gpg());
566    }
567
568    #[test]
569    fn is_gpg_default_artifacts_none_is_false() {
570        // Default artifacts filter is "none" — entry is effectively
571        // disabled, so it does not count as gpg-configured.
572        let cfg = SignConfig::default();
573        assert!(!cfg.is_gpg());
574    }
575
576    #[test]
577    fn is_gpg_cosign_cmd_is_false() {
578        let cfg = SignConfig {
579            artifacts: Some("all".to_string()),
580            cmd: Some("cosign".to_string()),
581            ..Default::default()
582        };
583        assert!(!cfg.is_gpg());
584    }
585
586    #[test]
587    fn is_gpg_gpg2_cmd_is_true() {
588        let cfg = SignConfig {
589            artifacts: Some("checksum".to_string()),
590            cmd: Some("gpg2".to_string()),
591            ..Default::default()
592        };
593        assert!(cfg.is_gpg());
594    }
595
596    #[test]
597    fn is_gpg_absolute_gpg_path_is_true() {
598        let cfg = SignConfig {
599            artifacts: Some("binary".to_string()),
600            cmd: Some("/usr/local/bin/gpg".to_string()),
601            ..Default::default()
602        };
603        assert!(cfg.is_gpg());
604    }
605}