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#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
26#[serde(default, deny_unknown_fields)]
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 /// Authenticode (Windows PE/MSI) signing backend. When set, this sign
54 /// config signs Windows artifacts in place via osslsigncode (Linux/cross)
55 /// or signtool (Windows) instead of producing a detached cosign/gpg
56 /// signature. The signing command, argv, timestamp URL, and artifact
57 /// selector are all derived; supply only the cert (a secret).
58 pub authenticode: Option<AuthenticodeConfig>,
59 /// Post-sign verification knobs. Verification is ON by default wherever
60 /// its inputs are derivable (keyed cosign, keyless cosign on GitHub
61 /// Actions, gpg); set `verify: { enabled: false }` to disable, or supply
62 /// the keyless certificate identity / issuer when they cannot be derived
63 /// from the environment.
64 pub verify: Option<SignVerifyConfig>,
65 /// Template-conditional: skip this sign config if rendered result is "false" or empty.
66 #[serde(rename = "if")]
67 pub if_condition: Option<String>,
68}
69
70/// Post-sign verification settings shared by [`SignConfig`] and
71/// [`DockerSignConfig`].
72///
73/// After each signature is produced, the sign stage re-verifies it with the
74/// matching verifier (`cosign verify-blob` for detached cosign signatures,
75/// `cosign verify` for registry-attached docker signatures, `gpg --verify`
76/// for gpg) so "the signer exited 0" is upgraded to "the signature actually
77/// verifies". Everything is derived when possible:
78///
79/// - **keyed cosign** — the public key is derived once per run via
80/// `cosign public-key --key <ref>`; nothing to configure.
81/// - **keyless cosign** — the certificate identity/issuer are derived from
82/// the ambient GitHub Actions OIDC environment
83/// (`GITHUB_SERVER_URL`/`GITHUB_WORKFLOW_REF`); outside GitHub Actions,
84/// supply them here or verification skips with a named reason.
85/// - **gpg** — verified against the same keyring that signed.
86///
87/// ```yaml
88/// signs:
89/// - cmd: cosign
90/// args: ["sign-blob", "--bundle={{ Signature }}", "--yes", "{{ Artifact }}"]
91/// verify:
92/// certificate_identity: "https://github.com/acme/app/.github/workflows/release.yml@refs/tags/v1.0.0"
93/// certificate_oidc_issuer: "https://token.actions.githubusercontent.com"
94/// ```
95#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
96#[serde(default, deny_unknown_fields)]
97pub struct SignVerifyConfig {
98 /// Whether to verify each produced signature (default: `true`).
99 pub enabled: Option<bool>,
100 /// Exact certificate identity (SAN) expected in a keyless signing
101 /// certificate (`cosign --certificate-identity`). Overrides the value
102 /// derived from the GitHub Actions environment.
103 pub certificate_identity: Option<String>,
104 /// Regular expression matched against the keyless certificate identity
105 /// (`cosign --certificate-identity-regexp`). Ignored when
106 /// [`certificate_identity`](Self::certificate_identity) is set.
107 pub certificate_identity_regexp: Option<String>,
108 /// Expected OIDC issuer of the keyless signing certificate
109 /// (`cosign --certificate-oidc-issuer`). Overrides the derived
110 /// GitHub Actions issuer.
111 pub certificate_oidc_issuer: Option<String>,
112}
113
114impl SignVerifyConfig {
115 /// Whether verification is enabled (default: `true`).
116 pub fn is_enabled(&self) -> bool {
117 self.enabled.unwrap_or(true)
118 }
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 /// Whether post-sign verification is enabled (default: `true`; an
340 /// absent `verify:` block means "verify with derived inputs").
341 pub fn verify_enabled(&self) -> bool {
342 self.verify
343 .as_ref()
344 .is_none_or(SignVerifyConfig::is_enabled)
345 }
346
347 /// `true` when this sign config will invoke gpg.
348 ///
349 /// The top-level `signs:` driver defaults to gpg when `cmd:` is unset
350 /// (see `stage-sign::helpers::default_sign_cmd` which falls back to
351 /// `git config gpg.program` then to literal `"gpg"`). We treat any
352 /// cmd whose basename starts with `gpg` (e.g., `gpg`, `gpg2`,
353 /// `/usr/local/bin/gpg`) as a gpg invocation. A cmd of `"cosign"`,
354 /// `"notation"`, etc. returns false.
355 ///
356 /// Entries with `artifacts: "none"` (the default for top-level
357 /// `signs:`) are treated as not-configured — the loop never fires.
358 pub fn is_gpg(&self) -> bool {
359 // Effectively-disabled entries don't count as configured.
360 let artifacts = self.resolved_artifacts(Self::DEFAULT_ARTIFACTS);
361 if artifacts == "none" {
362 return false;
363 }
364 match self.cmd.as_deref() {
365 None => true, // default cmd is gpg
366 Some(cmd) => is_gpg_command(cmd),
367 }
368 }
369}
370
371/// True when `cmd` names a gpg binary: its basename starts with `gpg`,
372/// so `gpg`, `gpg2`, `gpg.exe`, and absolute paths thereto all qualify
373/// while `cosign`, `notation`, etc. do not.
374///
375/// The single gpg-detection predicate: every site that branches on
376/// "is this signing command gpg" (config classification, signature
377/// verification arg derivation, deterministic-timestamp injection)
378/// must route through it so a `gpg2`/absolute-path cmd behaves like
379/// plain `gpg` everywhere.
380pub fn is_gpg_command(cmd: &str) -> bool {
381 std::path::Path::new(cmd)
382 .file_name()
383 .and_then(|s| s.to_str())
384 .unwrap_or(cmd)
385 .starts_with("gpg")
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
389#[serde(default, deny_unknown_fields)]
390pub struct DockerSignConfig {
391 /// Unique identifier for this docker sign config.
392 pub id: Option<String>,
393 /// Docker artifact types to sign: "all", "image", or "manifest" (default: "none").
394 pub artifacts: Option<String>,
395 /// Signing command to invoke (default: "cosign").
396 pub cmd: Option<String>,
397 /// Arguments passed to the signing command (supports templates).
398 pub args: Option<Vec<String>>,
399 /// Signature output filename template (supports templates).
400 pub signature: Option<String>,
401 /// Certificate file to embed in the signature (Cosign bundle signing).
402 pub certificate: Option<String>,
403 /// Docker config IDs filter: only sign images from configs whose `id` is in this list.
404 pub ids: Option<Vec<String>>,
405 /// Content written to the signing command's stdin.
406 pub stdin: Option<String>,
407 /// Path to a file whose content is written to the signing command's stdin.
408 pub stdin_file: Option<String>,
409 /// Environment variables passed to the signing command.
410 #[serde(default)]
411 pub env: Option<Vec<String>>,
412 /// Capture and log stdout/stderr of the docker signing command.
413 #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
414 pub output: Option<StringOrBool>,
415 /// Post-sign verification knobs — see [`SignVerifyConfig`]. Docker
416 /// signatures are verified with `cosign verify` against the registry
417 /// the sign just pushed to.
418 pub verify: Option<SignVerifyConfig>,
419 /// Template-conditional: skip this docker sign config if rendered result is "false" or empty.
420 #[serde(rename = "if")]
421 pub if_condition: Option<String>,
422}
423
424impl DockerSignConfig {
425 /// Default `id` when a docker-sign config has none (`"default"`).
426 pub const DEFAULT_ID: &'static str = "default";
427
428 /// Default signing `cmd`
429 /// (`cfg.Cmd = "cosign"`). Unlike top-level `signs:[]` (which falls
430 /// back to git's `gpg.program` config), docker signing only ever
431 /// targets cosign, so the default is a static literal.
432 pub const DEFAULT_CMD: &'static str = "cosign";
433
434 /// Default `artifacts` filter when unset. Empty string is treated by
435 /// the docker-sign driver as "DockerImageV2 only" (post-buildx
436 /// canonical case). An empty `artifacts` is treated identically.
437 pub const DEFAULT_ARTIFACTS: &'static str = "";
438
439 /// Default `args` for `docker_signs:[]`
440 /// (`["sign", "--key=cosign.key",
441 /// "${artifact}@${digest}", "--yes"]`). Anodize substitutes
442 /// `${artifact}@${digest}` for the Tera-rewritten
443 /// `{{ .Artifact }}@{{ .Digest }}` placeholders.
444 pub const DEFAULT_ARGS: &[&'static str] = &[
445 "sign",
446 "--key=cosign.key",
447 "{{ .Artifact }}@{{ .Digest }}",
448 "--yes",
449 ];
450
451 /// Resolve the docker-sign id, falling back to `"default"`.
452 pub fn resolved_id(&self) -> &str {
453 self.id.as_deref().unwrap_or(Self::DEFAULT_ID)
454 }
455
456 /// Resolve the signing command, falling back to `"cosign"`.
457 pub fn resolved_cmd(&self) -> &str {
458 self.cmd.as_deref().unwrap_or(Self::DEFAULT_CMD)
459 }
460
461 /// Resolve the `artifacts` filter, falling back to `""` (DockerImageV2 only).
462 pub fn resolved_artifacts(&self) -> &str {
463 self.artifacts.as_deref().unwrap_or(Self::DEFAULT_ARTIFACTS)
464 }
465
466 /// Resolve `args`, materializing the [`Self::DEFAULT_ARGS`] const into
467 /// a `Vec<String>` when the user left `args:` unset.
468 pub fn resolved_args(&self) -> Vec<String> {
469 self.args.clone().unwrap_or_else(|| {
470 Self::DEFAULT_ARGS
471 .iter()
472 .map(|s| (*s).to_string())
473 .collect()
474 })
475 }
476
477 /// Whether post-sign verification is enabled (default: `true`).
478 pub fn verify_enabled(&self) -> bool {
479 self.verify
480 .as_ref()
481 .is_none_or(SignVerifyConfig::is_enabled)
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 // ---- SignConfig::resolved_*() (lazy-defaults policy) ----
490
491 #[test]
492 fn sign_resolved_id_default() {
493 assert_eq!(SignConfig::default().resolved_id(), "default");
494 }
495
496 #[test]
497 fn sign_resolved_id_user_value_wins() {
498 let cfg = SignConfig {
499 id: Some("cosign".to_string()),
500 ..Default::default()
501 };
502 assert_eq!(cfg.resolved_id(), "cosign");
503 }
504
505 #[test]
506 fn sign_resolved_artifacts_falls_back_to_supplied_default() {
507 let cfg = SignConfig::default();
508 assert_eq!(
509 cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS),
510 "none"
511 );
512 assert_eq!(
513 cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS_BINARY),
514 "binary"
515 );
516 }
517
518 #[test]
519 fn sign_resolved_artifacts_user_value_wins_over_fallback() {
520 let cfg = SignConfig {
521 artifacts: Some("checksum".to_string()),
522 ..Default::default()
523 };
524 assert_eq!(
525 cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS),
526 "checksum"
527 );
528 assert_eq!(
529 cfg.resolved_artifacts(SignConfig::DEFAULT_ARTIFACTS_BINARY),
530 "checksum"
531 );
532 }
533
534 #[test]
535 fn sign_resolved_signature_template_default_paths() {
536 let cfg = SignConfig::default();
537 assert_eq!(
538 cfg.resolved_signature_template(SignConfig::DEFAULT_SIGNATURE_TEMPLATE),
539 "{{ .Artifact }}.sig"
540 );
541 // Binary default now equals the simple .sig template — flat layout means
542 // binary names already carry the platform suffix.
543 assert_eq!(
544 cfg.resolved_signature_template(SignConfig::DEFAULT_BINARY_SIGNATURE_TEMPLATE),
545 "{{ .Artifact }}.sig"
546 );
547 }
548
549 #[test]
550 fn sign_resolved_signature_template_user_value_wins() {
551 let cfg = SignConfig {
552 signature: Some("custom-{{ .Artifact }}.asc".to_string()),
553 ..Default::default()
554 };
555 assert_eq!(
556 cfg.resolved_signature_template(SignConfig::DEFAULT_SIGNATURE_TEMPLATE),
557 "custom-{{ .Artifact }}.asc"
558 );
559 }
560
561 #[test]
562 fn sign_resolved_args_default_matches_goreleaser() {
563 let cfg = SignConfig::default();
564 assert_eq!(
565 cfg.resolved_args(),
566 vec![
567 "--output".to_string(),
568 "{{ .Signature }}".to_string(),
569 "--detach-sig".to_string(),
570 "{{ .Artifact }}".to_string(),
571 ]
572 );
573 }
574
575 #[test]
576 fn sign_resolved_args_user_value_wins() {
577 let custom = vec!["sign".to_string(), "--key=k".to_string()];
578 let cfg = SignConfig {
579 args: Some(custom.clone()),
580 ..Default::default()
581 };
582 assert_eq!(cfg.resolved_args(), custom);
583 }
584
585 // ---- DockerSignConfig::resolved_*() ----
586
587 #[test]
588 fn docker_sign_resolved_id_default() {
589 assert_eq!(DockerSignConfig::default().resolved_id(), "default");
590 }
591
592 #[test]
593 fn docker_sign_resolved_id_user_value_wins() {
594 let cfg = DockerSignConfig {
595 id: Some("custom".to_string()),
596 ..Default::default()
597 };
598 assert_eq!(cfg.resolved_id(), "custom");
599 }
600
601 #[test]
602 fn docker_sign_resolved_cmd_default() {
603 assert_eq!(DockerSignConfig::default().resolved_cmd(), "cosign");
604 }
605
606 #[test]
607 fn docker_sign_resolved_cmd_user_value_wins() {
608 let cfg = DockerSignConfig {
609 cmd: Some("notation".to_string()),
610 ..Default::default()
611 };
612 assert_eq!(cfg.resolved_cmd(), "notation");
613 }
614
615 #[test]
616 fn docker_sign_resolved_artifacts_default() {
617 assert_eq!(DockerSignConfig::default().resolved_artifacts(), "");
618 }
619
620 #[test]
621 fn docker_sign_resolved_artifacts_user_value_wins() {
622 let cfg = DockerSignConfig {
623 artifacts: Some("manifests".to_string()),
624 ..Default::default()
625 };
626 assert_eq!(cfg.resolved_artifacts(), "manifests");
627 }
628
629 #[test]
630 fn docker_sign_resolved_args_default_matches_goreleaser() {
631 assert_eq!(
632 DockerSignConfig::default().resolved_args(),
633 vec![
634 "sign".to_string(),
635 "--key=cosign.key".to_string(),
636 "{{ .Artifact }}@{{ .Digest }}".to_string(),
637 "--yes".to_string(),
638 ]
639 );
640 }
641
642 #[test]
643 fn docker_sign_resolved_args_user_value_wins() {
644 let custom = vec!["verify".to_string(), "--cert=c".to_string()];
645 let cfg = DockerSignConfig {
646 args: Some(custom.clone()),
647 ..Default::default()
648 };
649 assert_eq!(cfg.resolved_args(), custom);
650 }
651 // ---- SignConfig::is_gpg() ---------------------------------------
652
653 #[test]
654 fn is_gpg_default_cmd_with_signing_artifacts_is_true() {
655 // No cmd set + artifacts set to something other than "none" =
656 // default gpg invocation, treated as gpg-configured.
657 let cfg = SignConfig {
658 artifacts: Some("all".to_string()),
659 ..Default::default()
660 };
661 assert!(cfg.is_gpg());
662 }
663
664 #[test]
665 fn is_gpg_default_artifacts_none_is_false() {
666 // Default artifacts filter is "none" — entry is effectively
667 // disabled, so it does not count as gpg-configured.
668 let cfg = SignConfig::default();
669 assert!(!cfg.is_gpg());
670 }
671
672 #[test]
673 fn is_gpg_cosign_cmd_is_false() {
674 let cfg = SignConfig {
675 artifacts: Some("all".to_string()),
676 cmd: Some("cosign".to_string()),
677 ..Default::default()
678 };
679 assert!(!cfg.is_gpg());
680 }
681
682 #[test]
683 fn is_gpg_gpg2_cmd_is_true() {
684 let cfg = SignConfig {
685 artifacts: Some("checksum".to_string()),
686 cmd: Some("gpg2".to_string()),
687 ..Default::default()
688 };
689 assert!(cfg.is_gpg());
690 }
691
692 #[test]
693 fn is_gpg_absolute_gpg_path_is_true() {
694 let cfg = SignConfig {
695 artifacts: Some("binary".to_string()),
696 cmd: Some("/usr/local/bin/gpg".to_string()),
697 ..Default::default()
698 };
699 assert!(cfg.is_gpg());
700 }
701
702 // ---- is_gpg_command() -------------------------------------------
703
704 #[test]
705 fn is_gpg_command_matches_gpg_variants() {
706 for cmd in [
707 "gpg",
708 "gpg2",
709 "gpg.exe",
710 "/usr/bin/gpg",
711 "/usr/local/bin/gpg2",
712 ] {
713 assert!(is_gpg_command(cmd), "{cmd} must classify as gpg");
714 }
715 }
716
717 #[test]
718 fn is_gpg_command_rejects_non_gpg() {
719 for cmd in [
720 "cosign",
721 "notation",
722 "/usr/bin/cosign",
723 "mygpg-wrapper/cosign",
724 ] {
725 assert!(!is_gpg_command(cmd), "{cmd} must not classify as gpg");
726 }
727 }
728
729 // ---- AuthenticodeConfig::resolved_*() (lazy-defaults policy) ----
730
731 #[test]
732 fn authenticode_resolved_cert_env_default() {
733 assert_eq!(
734 AuthenticodeConfig::default().resolved_cert_env(),
735 "WINDOWS_CERT_FILE"
736 );
737 assert_eq!(AuthenticodeConfig::DEFAULT_CERT_ENV, "WINDOWS_CERT_FILE");
738 }
739
740 #[test]
741 fn authenticode_resolved_cert_env_user_value_wins() {
742 let cfg = AuthenticodeConfig {
743 cert_env: Some("MY_CERT_PATH".to_string()),
744 ..Default::default()
745 };
746 assert_eq!(cfg.resolved_cert_env(), "MY_CERT_PATH");
747 }
748
749 #[test]
750 fn authenticode_resolved_password_env_default() {
751 assert_eq!(
752 AuthenticodeConfig::default().resolved_password_env(),
753 "WINDOWS_CERT_PASSWORD"
754 );
755 assert_eq!(
756 AuthenticodeConfig::DEFAULT_PASSWORD_ENV,
757 "WINDOWS_CERT_PASSWORD"
758 );
759 }
760
761 #[test]
762 fn authenticode_resolved_password_env_user_value_wins() {
763 let cfg = AuthenticodeConfig {
764 password_env: Some("CERT_PW".to_string()),
765 ..Default::default()
766 };
767 assert_eq!(cfg.resolved_password_env(), "CERT_PW");
768 }
769
770 #[test]
771 fn authenticode_resolved_timestamp_url_default() {
772 assert_eq!(
773 AuthenticodeConfig::default().resolved_timestamp_url(),
774 "http://timestamp.digicert.com"
775 );
776 assert_eq!(
777 AuthenticodeConfig::DEFAULT_TIMESTAMP_URL,
778 "http://timestamp.digicert.com"
779 );
780 }
781
782 #[test]
783 fn authenticode_resolved_timestamp_url_user_value_wins() {
784 let cfg = AuthenticodeConfig {
785 timestamp_url: Some("http://timestamp.sectigo.com".to_string()),
786 ..Default::default()
787 };
788 assert_eq!(cfg.resolved_timestamp_url(), "http://timestamp.sectigo.com");
789 }
790
791 #[test]
792 fn authenticode_resolved_artifacts_default() {
793 assert_eq!(
794 AuthenticodeConfig::default().resolved_artifacts(),
795 "windows"
796 );
797 assert_eq!(AuthenticodeConfig::DEFAULT_ARTIFACTS, "windows");
798 }
799
800 #[test]
801 fn authenticode_resolved_artifacts_user_value_wins() {
802 let cfg = AuthenticodeConfig {
803 artifacts: Some("binary".to_string()),
804 ..Default::default()
805 };
806 assert_eq!(cfg.resolved_artifacts(), "binary");
807 }
808
809 #[test]
810 fn authenticode_resolved_tool_host_default() {
811 // The host-derived default is signtool on Windows, osslsigncode
812 // elsewhere — assert whichever this build targets.
813 let expected = if cfg!(windows) {
814 "signtool"
815 } else {
816 "osslsigncode"
817 };
818 assert_eq!(AuthenticodeConfig::default().resolved_tool(), expected);
819 }
820
821 #[test]
822 fn authenticode_resolved_tool_user_value_wins() {
823 let cfg = AuthenticodeConfig {
824 tool: Some("osslsigncode".to_string()),
825 ..Default::default()
826 };
827 assert_eq!(cfg.resolved_tool(), "osslsigncode");
828 }
829
830 #[test]
831 fn authenticode_is_required_default_false() {
832 assert!(!AuthenticodeConfig::default().is_required());
833 }
834
835 #[test]
836 fn authenticode_is_required_user_value_wins() {
837 let cfg = AuthenticodeConfig {
838 required: Some(true),
839 ..Default::default()
840 };
841 assert!(cfg.is_required());
842 }
843}