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