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}