Skip to main content

anodizer_core/
signing.rs

1//! Sign / docker-sign config types.
2//!
3//! Lifted out of the monolithic `crate::config` module per the WAVE 5
4//! split (see `.claude/known-bugs.md`'s "WAVE 5 deferred" entry). The
5//! historical `anodizer_core::config::{SignConfig, DockerSignConfig}`
6//! import path is preserved by re-exports at the bottom of `config.rs`.
7//!
8//! ## Default-resolution policy
9//!
10//! Both [`SignConfig`] and [`DockerSignConfig`] keep their fields as
11//! `Option<T>` so the schema can distinguish "user set this explicitly"
12//! from "user left it default" (preserves YAML round-trip identity and
13//! lets a future override-resolution step inject values without losing
14//! provenance). Stages MUST read defaults through the `resolved_*()`
15//! accessors below — no inline `unwrap_or_else(|| "cosign".to_string())`
16//! at call sites — so the answer to "what's the default?" lives in one
17//! place per stage and a future default change (or override resolution)
18//! lands in one place too. This is the lazy-vs-eager defaults policy
19//! settled in Session C; precedent commit `ff3be47` (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)]
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    /// Template-conditional: skip this sign config if rendered result is "false" or empty.
54    #[serde(rename = "if")]
55    pub if_condition: Option<String>,
56}
57
58impl SignConfig {
59    /// Default `id` when a sign config has none. Mirrors GoReleaser
60    /// `internal/pipe/sign/sign.go` (`cfg.ID = "default"`). Used to
61    /// label log lines and uniqueness-error messages.
62    pub const DEFAULT_ID: &'static str = "default";
63
64    /// Default `artifacts` filter for top-level `signs:[]`. Mirrors
65    /// GoReleaser `sign.go` (`cfg.Artifacts = "none"`) — by default
66    /// nothing is signed unless the user opts in.
67    pub const DEFAULT_ARTIFACTS: &'static str = "none";
68
69    /// Default `artifacts` filter for `binary_signs:[]`. The binary-only
70    /// driver always restricts the artifact-kind filter to binaries even
71    /// when the user leaves `artifacts:` unset. Anodize-specific helper
72    /// (no GoReleaser equivalent — GR uses a different config type for
73    /// binary signing) but kept on `SignConfig` because anodize unifies
74    /// `signs[]` and `binary_signs[]` into one struct.
75    pub const DEFAULT_ARTIFACTS_BINARY: &'static str = "binary";
76
77    /// Default `signature` template for top-level `signs:[]`. Mirrors
78    /// GoReleaser `sign.go` (`cfg.Signature = "${artifact}.sig"`).
79    /// Anodize uses Tera-style `{{ .Artifact }}` placeholders that the
80    /// arg-resolver rewrites to the same path at execution time.
81    pub const DEFAULT_SIGNATURE_TEMPLATE: &'static str = "{{ .Artifact }}.sig";
82
83    /// Default `signature` template for `binary_signs:[]`. Mirrors
84    /// GoReleaser `internal/pipe/sign/sign_binary.go` `defaultSignatureName`
85    /// — emits a per-target filename including Os/Arch/Arm/Mips/Amd64
86    /// suffixes so signatures don't collide across architectures.
87    pub const DEFAULT_BINARY_SIGNATURE_TEMPLATE: &'static str = "{{ .Artifact }}_{{ Os }}_{{ Arch }}{% if Arm %}v{{ Arm }}{% endif %}{% if Mips %}_{{ Mips }}{% endif %}{% if Amd64 and Amd64 != \"v1\" %}{{ Amd64 }}{% endif %}";
88
89    /// Default `args` for top-level `signs:[]`. Mirrors GoReleaser
90    /// `sign.go` (`["--output", "$signature", "--detach-sig", "$artifact"]`).
91    /// Anodize substitutes `$signature` / `$artifact` for `{{ .Signature }}`
92    /// / `{{ .Artifact }}` Tera placeholders that the arg-resolver
93    /// rewrites; the wire-level invocation matches GR exactly.
94    pub const DEFAULT_ARGS: &[&'static str] = &[
95        "--output",
96        "{{ .Signature }}",
97        "--detach-sig",
98        "{{ .Artifact }}",
99    ];
100
101    /// Resolve the sign-config id, falling back to `"default"` (GoReleaser-canonical).
102    pub fn resolved_id(&self) -> &str {
103        self.id.as_deref().unwrap_or(Self::DEFAULT_ID)
104    }
105
106    /// Resolve the `artifacts` filter, falling back to the supplied
107    /// `fallback` (`Self::DEFAULT_ARTIFACTS` for `signs[]`,
108    /// `Self::DEFAULT_ARTIFACTS_BINARY` for `binary_signs[]`).
109    pub fn resolved_artifacts<'a>(&'a self, fallback: &'a str) -> &'a str {
110        self.artifacts.as_deref().unwrap_or(fallback)
111    }
112
113    /// Resolve the `signature` template, falling back to the supplied
114    /// `default` (`Self::DEFAULT_SIGNATURE_TEMPLATE` for `signs[]`,
115    /// `Self::DEFAULT_BINARY_SIGNATURE_TEMPLATE` for `binary_signs[]`).
116    pub fn resolved_signature_template<'a>(&'a self, default: &'a str) -> &'a str {
117        self.signature.as_deref().unwrap_or(default)
118    }
119
120    /// Resolve `args`, materializing the [`Self::DEFAULT_ARGS`] const into
121    /// a `Vec<String>` when the user left `args:` unset. Returns a clone
122    /// of the user-supplied list otherwise.
123    pub fn resolved_args(&self) -> Vec<String> {
124        self.args.clone().unwrap_or_else(|| {
125            Self::DEFAULT_ARGS
126                .iter()
127                .map(|s| (*s).to_string())
128                .collect()
129        })
130    }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
134#[serde(default)]
135pub struct DockerSignConfig {
136    /// Unique identifier for this docker sign config.
137    pub id: Option<String>,
138    /// Docker artifact types to sign: "all", "image", or "manifest" (default: "none").
139    pub artifacts: Option<String>,
140    /// Signing command to invoke (default: "cosign").
141    pub cmd: Option<String>,
142    /// Arguments passed to the signing command (supports templates).
143    pub args: Option<Vec<String>>,
144    /// Signature output filename template (supports templates).
145    pub signature: Option<String>,
146    /// Certificate file to embed in the signature (Cosign bundle signing).
147    pub certificate: Option<String>,
148    /// Docker config IDs filter: only sign images from configs whose `id` is in this list.
149    pub ids: Option<Vec<String>>,
150    /// Content written to the signing command's stdin.
151    pub stdin: Option<String>,
152    /// Path to a file whose content is written to the signing command's stdin.
153    pub stdin_file: Option<String>,
154    /// Environment variables passed to the signing command.
155    #[serde(default)]
156    pub env: Option<Vec<String>>,
157    /// Capture and log stdout/stderr of the docker signing command.
158    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
159    pub output: Option<StringOrBool>,
160    /// Template-conditional: skip this docker sign config if rendered result is "false" or empty.
161    #[serde(rename = "if")]
162    pub if_condition: Option<String>,
163}
164
165impl DockerSignConfig {
166    /// Default `id` when a docker-sign config has none. Mirrors GoReleaser
167    /// `internal/pipe/sign/sign_docker.go` (`cfg.ID = "default"`).
168    pub const DEFAULT_ID: &'static str = "default";
169
170    /// Default signing `cmd`. Mirrors GoReleaser `sign_docker.go`
171    /// (`cfg.Cmd = "cosign"`). Unlike top-level `signs:[]` (which falls
172    /// back to git's `gpg.program` config), docker signing only ever
173    /// targets cosign, so the default is a static literal.
174    pub const DEFAULT_CMD: &'static str = "cosign";
175
176    /// Default `artifacts` filter when unset. Empty string is treated by
177    /// the docker-sign driver as "DockerImageV2 only" (post-buildx
178    /// canonical case). Mirrors GR's lack of an explicit fallback —
179    /// GR's switch on `cfg.Artifacts` treats `""` identically.
180    pub const DEFAULT_ARTIFACTS: &'static str = "";
181
182    /// Default `args` for `docker_signs:[]`. Mirrors GoReleaser
183    /// `sign_docker.go` (`["sign", "--key=cosign.key",
184    /// "${artifact}@${digest}", "--yes"]`). Anodize substitutes
185    /// `${artifact}@${digest}` for the Tera-rewritten
186    /// `{{ .Artifact }}@{{ .Digest }}` placeholders.
187    pub const DEFAULT_ARGS: &[&'static str] = &[
188        "sign",
189        "--key=cosign.key",
190        "{{ .Artifact }}@{{ .Digest }}",
191        "--yes",
192    ];
193
194    /// Resolve the docker-sign id, falling back to `"default"` (GR-canonical).
195    pub fn resolved_id(&self) -> &str {
196        self.id.as_deref().unwrap_or(Self::DEFAULT_ID)
197    }
198
199    /// Resolve the signing command, falling back to `"cosign"` (GR-canonical).
200    pub fn resolved_cmd(&self) -> &str {
201        self.cmd.as_deref().unwrap_or(Self::DEFAULT_CMD)
202    }
203
204    /// Resolve the `artifacts` filter, falling back to `""` (DockerImageV2 only).
205    pub fn resolved_artifacts(&self) -> &str {
206        self.artifacts.as_deref().unwrap_or(Self::DEFAULT_ARTIFACTS)
207    }
208
209    /// Resolve `args`, materializing the [`Self::DEFAULT_ARGS`] const into
210    /// a `Vec<String>` when the user left `args:` unset.
211    pub fn resolved_args(&self) -> Vec<String> {
212        self.args.clone().unwrap_or_else(|| {
213            Self::DEFAULT_ARGS
214                .iter()
215                .map(|s| (*s).to_string())
216                .collect()
217        })
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    // ---- SignConfig::resolved_*() (Session C lazy-defaults policy) ----
226
227    #[test]
228    fn sign_resolved_id_default() {
229        assert_eq!(SignConfig::default().resolved_id(), "default");
230    }
231
232    #[test]
233    fn sign_resolved_id_user_value_wins() {
234        let cfg = SignConfig {
235            id: Some("cosign".to_string()),
236            ..Default::default()
237        };
238        assert_eq!(cfg.resolved_id(), "cosign");
239    }
240
241    #[test]
242    fn sign_resolved_artifacts_falls_back_to_supplied_default() {
243        let cfg = SignConfig::default();
244        assert_eq!(
245            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS),
246            "none"
247        );
248        assert_eq!(
249            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS_BINARY),
250            "binary"
251        );
252    }
253
254    #[test]
255    fn sign_resolved_artifacts_user_value_wins_over_fallback() {
256        let cfg = SignConfig {
257            artifacts: Some("checksum".to_string()),
258            ..Default::default()
259        };
260        assert_eq!(
261            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS),
262            "checksum"
263        );
264        assert_eq!(
265            cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS_BINARY),
266            "checksum"
267        );
268    }
269
270    #[test]
271    fn sign_resolved_signature_template_default_paths() {
272        let cfg = SignConfig::default();
273        assert_eq!(
274            cfg.resolved_signature_template(SignConfig::DEFAULT_SIGNATURE_TEMPLATE),
275            "{{ .Artifact }}.sig"
276        );
277        assert_eq!(
278            cfg.resolved_signature_template(SignConfig::DEFAULT_BINARY_SIGNATURE_TEMPLATE),
279            SignConfig::DEFAULT_BINARY_SIGNATURE_TEMPLATE
280        );
281    }
282
283    #[test]
284    fn sign_resolved_signature_template_user_value_wins() {
285        let cfg = SignConfig {
286            signature: Some("custom-{{ .Artifact }}.asc".to_string()),
287            ..Default::default()
288        };
289        assert_eq!(
290            cfg.resolved_signature_template(SignConfig::DEFAULT_SIGNATURE_TEMPLATE),
291            "custom-{{ .Artifact }}.asc"
292        );
293    }
294
295    #[test]
296    fn sign_resolved_args_default_matches_goreleaser() {
297        let cfg = SignConfig::default();
298        assert_eq!(
299            cfg.resolved_args(),
300            vec![
301                "--output".to_string(),
302                "{{ .Signature }}".to_string(),
303                "--detach-sig".to_string(),
304                "{{ .Artifact }}".to_string(),
305            ]
306        );
307    }
308
309    #[test]
310    fn sign_resolved_args_user_value_wins() {
311        let custom = vec!["sign".to_string(), "--key=k".to_string()];
312        let cfg = SignConfig {
313            args: Some(custom.clone()),
314            ..Default::default()
315        };
316        assert_eq!(cfg.resolved_args(), custom);
317    }
318
319    // ---- DockerSignConfig::resolved_*() ----
320
321    #[test]
322    fn docker_sign_resolved_id_default() {
323        assert_eq!(DockerSignConfig::default().resolved_id(), "default");
324    }
325
326    #[test]
327    fn docker_sign_resolved_id_user_value_wins() {
328        let cfg = DockerSignConfig {
329            id: Some("custom".to_string()),
330            ..Default::default()
331        };
332        assert_eq!(cfg.resolved_id(), "custom");
333    }
334
335    #[test]
336    fn docker_sign_resolved_cmd_default() {
337        assert_eq!(DockerSignConfig::default().resolved_cmd(), "cosign");
338    }
339
340    #[test]
341    fn docker_sign_resolved_cmd_user_value_wins() {
342        let cfg = DockerSignConfig {
343            cmd: Some("notation".to_string()),
344            ..Default::default()
345        };
346        assert_eq!(cfg.resolved_cmd(), "notation");
347    }
348
349    #[test]
350    fn docker_sign_resolved_artifacts_default() {
351        assert_eq!(DockerSignConfig::default().resolved_artifacts(), "");
352    }
353
354    #[test]
355    fn docker_sign_resolved_artifacts_user_value_wins() {
356        let cfg = DockerSignConfig {
357            artifacts: Some("manifests".to_string()),
358            ..Default::default()
359        };
360        assert_eq!(cfg.resolved_artifacts(), "manifests");
361    }
362
363    #[test]
364    fn docker_sign_resolved_args_default_matches_goreleaser() {
365        assert_eq!(
366            DockerSignConfig::default().resolved_args(),
367            vec![
368                "sign".to_string(),
369                "--key=cosign.key".to_string(),
370                "{{ .Artifact }}@{{ .Digest }}".to_string(),
371                "--yes".to_string(),
372            ]
373        );
374    }
375
376    #[test]
377    fn docker_sign_resolved_args_user_value_wins() {
378        let custom = vec!["verify".to_string(), "--cert=c".to_string()];
379        let cfg = DockerSignConfig {
380            args: Some(custom.clone()),
381            ..Default::default()
382        };
383        assert_eq!(cfg.resolved_args(), custom);
384    }
385}