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