anodizer_core/config/archives.rs
1use std::collections::HashMap;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Deserializer, Serialize};
5
6use super::{
7 ArchiveHooksConfig, SignConfig, StringOrBool, StringOrU32, deserialize_string_or_bool_opt,
8};
9
10// ---------------------------------------------------------------------------
11// ArchivesConfig — untagged enum: false => Disabled, array => Configs
12// ---------------------------------------------------------------------------
13
14#[derive(Debug, Clone, JsonSchema)]
15pub enum ArchivesConfig {
16 Disabled,
17 Configs(Vec<ArchiveConfig>),
18}
19
20impl Serialize for ArchivesConfig {
21 fn serialize<S: serde::Serializer>(
22 &self,
23 serializer: S,
24 ) -> std::result::Result<S::Ok, S::Error> {
25 match self {
26 ArchivesConfig::Disabled => serializer.serialize_bool(false),
27 ArchivesConfig::Configs(configs) => configs.serialize(serializer),
28 }
29 }
30}
31
32impl Default for ArchivesConfig {
33 fn default() -> Self {
34 ArchivesConfig::Configs(vec![])
35 }
36}
37
38/// Custom deserializer for ArchivesConfig.
39/// Accepts:
40/// - boolean `false` → Disabled
41/// - array → Configs(...)
42/// - missing/null → Configs([]) (via serde default)
43pub(super) fn deserialize_archives_config<'de, D>(
44 deserializer: D,
45) -> Result<ArchivesConfig, D::Error>
46where
47 D: Deserializer<'de>,
48{
49 use serde::de::{self, Visitor};
50
51 struct ArchivesVisitor;
52
53 impl<'de> Visitor<'de> for ArchivesVisitor {
54 type Value = ArchivesConfig;
55
56 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.write_str("false or a list of archive configs")
58 }
59
60 fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
61 if !v {
62 Ok(ArchivesConfig::Disabled)
63 } else {
64 Err(E::custom(
65 "archives: true is not valid; use false or a list",
66 ))
67 }
68 }
69
70 fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
71 let mut configs = Vec::new();
72 while let Some(item) = seq.next_element::<ArchiveConfig>()? {
73 configs.push(item);
74 }
75 Ok(ArchivesConfig::Configs(configs))
76 }
77
78 // Handle YAML null / missing when serde calls the deserializer explicitly.
79 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
80 Ok(ArchivesConfig::Configs(vec![]))
81 }
82
83 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
84 Ok(ArchivesConfig::Configs(vec![]))
85 }
86 }
87
88 deserializer.deserialize_any(ArchivesVisitor)
89}
90
91/// Custom deserializer for the `signs` / `sign` field.
92/// Accepts:
93/// - null/missing → empty vec (via serde default)
94/// - a single object → vec of one SignConfig
95/// - an array → vec of SignConfig
96pub(super) fn deserialize_signs<'de, D>(deserializer: D) -> Result<Vec<SignConfig>, D::Error>
97where
98 D: Deserializer<'de>,
99{
100 use serde::de::{self, Visitor};
101
102 struct SignsVisitor;
103
104 impl<'de> Visitor<'de> for SignsVisitor {
105 type Value = Vec<SignConfig>;
106
107 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.write_str("a sign config object or an array of sign config objects")
109 }
110
111 fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
112 let mut configs = Vec::new();
113 while let Some(item) = seq.next_element::<SignConfig>()? {
114 configs.push(item);
115 }
116 Ok(configs)
117 }
118
119 fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
120 let config = SignConfig::deserialize(de::value::MapAccessDeserializer::new(map))?;
121 Ok(vec![config])
122 }
123
124 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
125 Ok(Vec::new())
126 }
127
128 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
129 Ok(Vec::new())
130 }
131 }
132
133 deserializer.deserialize_any(SignsVisitor)
134}
135
136// `binary_signs[].artifacts` is constrained at deserialize time (not as a
137// serde-typed enum) because `SignConfig` is shared with the top-level `signs:`
138// field, which legitimately accepts a wider set (`all`, `archive`, `binary`,
139// `checksum`, `package`, `sbom`, `none`). Promoting `artifacts` to an enum
140// would either narrow that surface or require a parallel `BinarySignConfig`
141// type duplicating every `SignConfig` field — the runtime check below keeps
142// `SignConfig` a single shared shape while still rejecting misconfigured
143// `binary_signs` entries at config-load time.
144//
145// The JSON schema for `binary_signs[]` therefore inherits `SignConfig`'s
146// unconstrained `artifacts: Option<String>` — the constraint lives in the
147// custom deserializer below and is exercised by the parse-time tests
148// `test_binary_signs_artifacts_*` further down this file.
149
150/// Wraps [`deserialize_signs`] and enforces that each entry's `artifacts`
151/// is one of the binary-only allowed values (`binary`, `none`, or omitted).
152/// Catches misconfiguration at load time instead of producing a silent
153/// no-op signing pipe.
154pub(super) fn deserialize_binary_signs<'de, D>(deserializer: D) -> Result<Vec<SignConfig>, D::Error>
155where
156 D: Deserializer<'de>,
157{
158 let configs = deserialize_signs(deserializer)?;
159 for (idx, cfg) in configs.iter().enumerate() {
160 if let Some(art) = cfg.artifacts.as_deref()
161 && art != "binary"
162 && art != "none"
163 {
164 return Err(serde::de::Error::custom(format!(
165 "binary_signs[{idx}].artifacts: '{art}' is not allowed; \
166 binary_signs accepts only 'binary' or 'none' (use top-level \
167 `signs:` for broader artifact filters)"
168 )));
169 }
170 }
171 Ok(configs)
172}
173
174// ---------------------------------------------------------------------------
175// WrapInDirectory – accepts bool (true = default dir name) or string
176// ---------------------------------------------------------------------------
177
178#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
179#[serde(untagged)]
180pub enum WrapInDirectory {
181 Bool(bool),
182 Name(String),
183}
184
185impl<'de> serde::Deserialize<'de> for WrapInDirectory {
186 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
187 let value = serde_yaml_ng::Value::deserialize(deserializer)?;
188 match value {
189 serde_yaml_ng::Value::Bool(b) => Ok(WrapInDirectory::Bool(b)),
190 serde_yaml_ng::Value::String(s) => Ok(WrapInDirectory::Name(s)),
191 _ => Err(serde::de::Error::custom("expected bool or string")),
192 }
193 }
194}
195
196impl WrapInDirectory {
197 /// Resolve the directory name to wrap archive contents in.
198 ///
199 /// When `true`, uses `default_name` (typically the archive stem).
200 /// When `false` or an empty string, returns `None` (no wrapping).
201 /// Otherwise returns the custom name.
202 pub fn directory_name(&self, default_name: &str) -> Option<String> {
203 match self {
204 WrapInDirectory::Bool(true) => Some(default_name.to_string()),
205 WrapInDirectory::Bool(false) => None,
206 WrapInDirectory::Name(s) if s.is_empty() => None,
207 WrapInDirectory::Name(s) => Some(s.clone()),
208 }
209 }
210}
211
212// ---------------------------------------------------------------------------
213// ArchiveConfig
214// ---------------------------------------------------------------------------
215
216#[derive(Debug, Clone, Serialize, Default, JsonSchema)]
217#[serde(deny_unknown_fields)]
218pub struct ArchiveConfig {
219 /// Unique identifier for cross-referencing this archive from other configs.
220 /// Defaults to `"default"` so a parse->serialise->reparse round-trip is
221 /// stable (stored verbatim, not as an Option).
222 pub id: Option<String>,
223 /// Archive filename template (supports templates, e.g., "{{ ProjectName }}_{{ Version }}_{{ Os }}_{{ Arch }}").
224 pub name_template: Option<String>,
225 /// Archive formats: tar.gz, tar.xz, tar.zst, tar, zip, gz, xz, or binary.
226 /// `gz` and `xz` are single-file compressors — supplying multiple input
227 /// files errors. Plural list; one archive per format is produced for each
228 /// target.
229 pub formats: Option<Vec<String>>,
230 /// Per-OS format overrides for this archive config.
231 pub format_overrides: Option<Vec<FormatOverride>>,
232 /// Extra files to include in the archive (glob patterns or detailed src/dst specs).
233 pub files: Option<Vec<ArchiveFileSpec>>,
234 /// Binary names to include (defaults to all binaries from matched builds).
235 pub binaries: Option<Vec<String>>,
236 /// When set, wrap archive contents in a top-level directory.
237 /// Accepts `true` (use archive stem as directory name), `false` (no wrapping),
238 /// or a string template for a custom directory name.
239 pub wrap_in_directory: Option<WrapInDirectory>,
240 /// Build IDs filter: only include artifacts from builds whose `id` is in this list.
241 pub ids: Option<Vec<String>>,
242 /// When true, create archive with no binaries (metadata-only).
243 pub meta: Option<bool>,
244 /// File permissions applied to binaries in archives.
245 pub builds_info: Option<ArchiveFileInfo>,
246 /// Strip binary parent directory in archive (place binaries at archive root).
247 pub strip_binary_directory: Option<bool>,
248 /// Allow different binary counts across targets. Default false (warn on mismatch).
249 pub allow_different_binary_count: Option<bool>,
250 /// Pre/post archive hooks (`before`/`after`).
251 pub hooks: Option<ArchiveHooksConfig>,
252 /// Templated files scoped to this archive entry. Rendered per-archive
253 /// (so each entry's `dst:` and contents see `.Os`, `.Arch`, `.Target`,
254 /// `.Format`, etc.) and packed into the archive at the rendered `dst:`
255 /// path. The `archives[].templated_files:` field.
256 pub templated_files: Option<Vec<super::TemplateFileConfig>>,
257 /// Template-conditional gate: when the rendered result is falsy
258 /// (`"false"` / `"0"` / `"no"` / empty), the archive entry is skipped
259 /// entirely (no archives produced for this `id`). Render failure
260 /// hard-errors. "Filter artifacts with `if` statements" is listed as a
261 /// blanket promise — anodizer surfaces it explicitly to keep imported
262 /// configs portable).
263 #[serde(rename = "if")]
264 pub if_condition: Option<String>,
265 /// Turnkey shell-completion generation: auto-generate (or harvest, or
266 /// copy) completion files and bundle them into every archive produced by
267 /// this entry. See [`CompletionsConfig`] for the three generation modes.
268 pub completions: Option<super::CompletionsConfig>,
269 /// Turnkey man-page generation: auto-generate (or harvest, or copy) man
270 /// pages and bundle them into every archive produced by this entry. See
271 /// [`ManpagesConfig`] for the three generation modes.
272 pub manpages: Option<super::ManpagesConfig>,
273}
274
275/// Fold a deprecated singular `format: tar.gz` into the canonical
276/// `formats: [tar.gz]` list, emitting a `tracing::warn!` deprecation notice
277/// keyed by `context_label` (the archive id or override `os=` so the user
278/// can locate the offending entry). Returns the folded list (creating one
279/// if `formats` was `None` and `legacy` is `Some`).
280///
281/// Shared by `ArchiveConfig` and `FormatOverride` to keep the deprecation
282/// message + fold semantics in one place.
283fn fold_format_into_formats(
284 context_label: &str,
285 context_kind: &str,
286 formats: Option<Vec<String>>,
287 legacy: Option<String>,
288) -> Option<Vec<String>> {
289 let mut formats = formats;
290 if let Some(legacy) = legacy {
291 tracing::warn!(
292 "DEPRECATION: {}[{}]: 'format: {}' is deprecated; \
293 use 'formats: [{}]' instead.",
294 context_kind,
295 context_label,
296 legacy,
297 legacy
298 );
299 formats.get_or_insert_with(Vec::new).push(legacy);
300 }
301 formats
302}
303
304// Custom Deserialize that accepts deprecated aliases:
305// - `format: tar.gz` (singular String) folded into `formats: [tar.gz]`
306//
307// - `builds: [foo]` folded into `ids: [foo]`
308//
309// Each alias hit emits a `tracing::warn!` deprecation notice.
310impl<'de> Deserialize<'de> for ArchiveConfig {
311 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
312 where
313 D: Deserializer<'de>,
314 {
315 #[derive(Deserialize, Default)]
316 #[serde(default, deny_unknown_fields)]
317 struct Raw {
318 id: Option<String>,
319 name_template: Option<String>,
320 formats: Option<Vec<String>>,
321 format: Option<String>,
322 format_overrides: Option<Vec<FormatOverride>>,
323 files: Option<Vec<ArchiveFileSpec>>,
324 binaries: Option<Vec<String>>,
325 wrap_in_directory: Option<WrapInDirectory>,
326 ids: Option<Vec<String>>,
327 builds: Option<Vec<String>>,
328 meta: Option<bool>,
329 builds_info: Option<ArchiveFileInfo>,
330 strip_binary_directory: Option<bool>,
331 allow_different_binary_count: Option<bool>,
332 hooks: Option<ArchiveHooksConfig>,
333 templated_files: Option<Vec<super::TemplateFileConfig>>,
334 #[serde(rename = "if")]
335 if_condition: Option<String>,
336 completions: Option<super::CompletionsConfig>,
337 manpages: Option<super::ManpagesConfig>,
338 }
339
340 let raw = Raw::deserialize(deserializer)?;
341
342 let id_label = raw.id.clone().unwrap_or_else(|| "default".to_string());
343 let formats = fold_format_into_formats(
344 &format!("id={}", id_label),
345 "archives",
346 raw.formats,
347 raw.format,
348 );
349 let mut ids = raw.ids;
350 if let Some(legacy) = raw.builds {
351 tracing::warn!(
352 "DEPRECATION: archives[id={}]: 'builds: {:?}' is deprecated; \
353 use 'ids: [...]' instead.",
354 id_label,
355 legacy
356 );
357 let target = ids.get_or_insert_with(Vec::new);
358 target.extend(legacy);
359 }
360
361 Ok(ArchiveConfig {
362 id: raw.id.or_else(|| Some("default".to_string())),
363 name_template: raw.name_template,
364 formats,
365 format_overrides: raw.format_overrides,
366 files: raw.files,
367 binaries: raw.binaries,
368 wrap_in_directory: raw.wrap_in_directory,
369 ids,
370 meta: raw.meta,
371 builds_info: raw.builds_info,
372 strip_binary_directory: raw.strip_binary_directory,
373 allow_different_binary_count: raw.allow_different_binary_count,
374 hooks: raw.hooks,
375 templated_files: raw.templated_files,
376 if_condition: raw.if_condition,
377 completions: raw.completions,
378 manpages: raw.manpages,
379 })
380 }
381}
382
383#[derive(Debug, Clone, Serialize, JsonSchema)]
384#[serde(deny_unknown_fields)]
385pub struct FormatOverride {
386 /// Operating system this override applies to (e.g., "windows", "darwin", "linux").
387 pub os: String,
388 /// Plural format overrides for this OS: tar.gz, tar.xz, tar.zst, tar, zip,
389 /// gz, xz, or binary.
390 pub formats: Option<Vec<String>>,
391}
392
393// Custom Deserialize that accepts both `formats: [tar.gz]` (canonical) and
394// the deprecated singular `format: tar.gz`. The legacy spelling is folded
395// into `formats` at parse time via the shared `fold_format_into_formats`
396// helper, which also emits the deprecation warning.
397impl<'de> Deserialize<'de> for FormatOverride {
398 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
399 where
400 D: Deserializer<'de>,
401 {
402 #[derive(Deserialize, Default)]
403 #[serde(default, deny_unknown_fields)]
404 struct Raw {
405 os: String,
406 formats: Option<Vec<String>>,
407 format: Option<String>,
408 }
409 let raw = Raw::deserialize(deserializer)?;
410 let formats = fold_format_into_formats(
411 &format!("os={}", raw.os),
412 "archives.format_overrides",
413 raw.formats,
414 raw.format,
415 );
416 Ok(FormatOverride {
417 os: raw.os,
418 formats,
419 })
420 }
421}
422
423/// Specifies a file to include in archives. Can be a simple glob string or a
424/// detailed object with src/dst/info fields for controlling archive placement
425/// and file metadata.
426///
427/// NOTE: This is intentionally a separate type from [`ExtraFileSpec`] (used for
428/// checksum/release extra_files). `ArchiveFileSpec` needs `src`/`dst`/`info`
429/// fields for archive placement and file metadata (owner, group, mode, mtime),
430/// while `ExtraFileSpec` needs `glob`/`name_template` for checksumming and
431/// upload renaming. The fields and semantics are different enough that a unified
432/// type would be confusing.
433#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
434#[serde(untagged)]
435pub enum ArchiveFileSpec {
436 Glob(String),
437 Detailed {
438 src: String,
439 dst: Option<String>,
440 info: Option<ArchiveFileInfo>,
441 /// When true, strip the parent directory from the file path in the archive.
442 strip_parent: Option<bool>,
443 },
444}
445
446impl PartialEq<&str> for ArchiveFileSpec {
447 fn eq(&self, other: &&str) -> bool {
448 match self {
449 ArchiveFileSpec::Glob(s) => s.as_str() == *other,
450 _ => false,
451 }
452 }
453}
454
455/// Shared file metadata (owner, group, mode, mtime) used by both archive entries
456/// and nFPM package contents. Previously duplicated as `ArchiveFileInfo` and
457/// `NfpmFileInfo`; now unified.
458#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
459#[serde(default, deny_unknown_fields)]
460pub struct FileInfo {
461 /// File owner name (e.g., "root").
462 pub owner: Option<String>,
463 /// File group name (e.g., "root").
464 pub group: Option<String>,
465 /// File permission mode. Accepts a YAML int (decimal, e.g. `420` for
466 /// `0o644`) or an octal-prefixed string (`"0o644"`, `"0644"`). This
467 /// a `uint32` type for `Mode` on archive/nfpm contents
468 /// while letting users spell octal naturally in YAML.
469 pub mode: Option<StringOrU32>,
470 /// File modification time in RFC3339 format (e.g., "2024-01-01T00:00:00Z").
471 pub mtime: Option<String>,
472}
473
474/// Backward-compatible alias for archive code.
475pub type ArchiveFileInfo = FileInfo;
476
477/// Parse an octal mode string into a `u32`, handling common YAML-friendly
478/// representations: `"0755"`, `"0o755"`, `"0O755"`, `"755"`, and `"0"`.
479pub fn parse_octal_mode(s: &str) -> Option<u32> {
480 let cleaned = s
481 .strip_prefix("0o")
482 .or_else(|| s.strip_prefix("0O"))
483 .unwrap_or(s);
484 let cleaned = if cleaned.is_empty() { "0" } else { cleaned };
485 u32::from_str_radix(cleaned, 8).ok()
486}
487
488/// The set of archive format strings recognised by the archive stage.
489/// Used for early validation so typos are caught at config load time rather
490/// than mid-pipeline.
491pub const VALID_ARCHIVE_FORMATS: &[&str] = &[
492 "tar.gz", "tgz", "tar.xz", "txz", "tar.zst", "tzst", "tar", "zip", "gz", "xz", "binary", "none",
493];
494
495// ---------------------------------------------------------------------------
496// ChecksumConfig
497// ---------------------------------------------------------------------------
498
499/// Specifies an extra file to include in checksums or release uploads. Can be a
500/// simple glob string or a detailed object with glob and name_template fields.
501///
502/// See [`ArchiveFileSpec`] doc comment for why this is a separate type.
503#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
504#[serde(untagged)]
505pub enum ExtraFileSpec {
506 Glob(String),
507 Detailed {
508 glob: String,
509 /// Optional override for the upload filename.
510 #[serde(default)]
511 name_template: Option<String>,
512 /// When true, treat a glob that matches zero files as a no-op
513 /// rather than a hard error. Useful for assets produced only in
514 /// CI (e.g. signing public keys derived from a secret) that
515 /// must not break local snapshot/dry-run flows. Defaults to
516 /// false, matching the prior fail-fast behavior.
517 #[serde(default)]
518 allow_empty: bool,
519 },
520}
521
522impl ExtraFileSpec {
523 /// Return the glob pattern for this spec.
524 pub fn glob(&self) -> &str {
525 match self {
526 ExtraFileSpec::Glob(s) => s,
527 ExtraFileSpec::Detailed { glob, .. } => glob,
528 }
529 }
530
531 /// Return the optional name_template (only present in Detailed variant).
532 pub fn name_template(&self) -> Option<&str> {
533 match self {
534 ExtraFileSpec::Glob(_) => None,
535 ExtraFileSpec::Detailed { name_template, .. } => name_template.as_deref(),
536 }
537 }
538
539 /// Return whether this spec allows a zero-match glob without erroring
540 /// (Detailed variant only; the bare string form is always fail-fast).
541 pub fn allow_empty(&self) -> bool {
542 match self {
543 ExtraFileSpec::Glob(_) => false,
544 ExtraFileSpec::Detailed { allow_empty, .. } => *allow_empty,
545 }
546 }
547}
548
549/// A file whose contents are rendered through the template engine before use.
550/// Used by `templated_extra_files` across multiple stages.
551#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
552#[serde(default, deny_unknown_fields)]
553pub struct TemplatedExtraFile {
554 /// Source template file path.
555 pub src: String,
556 /// Destination filename for the rendered output.
557 /// Supports template variables (e.g. `"{{ ProjectName }}-NOTES.txt"`).
558 pub dst: Option<String>,
559 /// File permissions in octal notation as a string, e.g. `"0755"`.
560 /// Parsed at runtime via `parse_octal_mode()` to avoid YAML interpreting as decimal.
561 pub mode: Option<String>,
562}
563
564/// Content format for per-artifact sidecars written in `split` mode.
565#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
566#[serde(rename_all = "lowercase")]
567pub enum ChecksumSplitFormat {
568 /// Only the raw hex hash, no filename, no trailing newline. Matches
569 /// GoReleaser's split-checksum output. Default.
570 #[default]
571 Bare,
572 /// `<hash> <filename>` with a trailing newline — the coreutils / BSD
573 /// digest format, so the sidecar verifies directly with
574 /// `shasum -c` / `sha256sum -c` from the directory holding the artifact.
575 Coreutils,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
579#[serde(default, deny_unknown_fields)]
580pub struct ChecksumConfig {
581 /// Checksum filename template (default: "{{ ProjectName }}_{{ Version }}_checksums.txt").
582 pub name_template: Option<String>,
583 /// Hash algorithm (default: `sha256`). Accepted values: `sha1`, `sha224`,
584 /// `sha256`, `sha384`, `sha512`, `sha3-224`, `sha3-256`, `sha3-384`,
585 /// `sha3-512`, `blake2b`, `blake2s`, `blake3`, `crc32`, `md5`. An
586 /// unrecognized value is rejected at checksum-stage entry. The authoritative
587 /// set is [`ChecksumConfig::SUPPORTED_ALGORITHMS`].
588 pub algorithm: Option<String>,
589 /// Disable checksums. Accepts bool or template string.
590 /// Accepts the legacy `disable:` spelling via serde alias for back-compat.
591 #[serde(
592 alias = "disable",
593 deserialize_with = "deserialize_string_or_bool_opt",
594 default
595 )]
596 pub skip: Option<StringOrBool>,
597 /// Extra files to include in the checksum file (beyond build artifacts).
598 pub extra_files: Option<Vec<ExtraFileSpec>>,
599 /// Extra files whose contents are rendered through the template engine before inclusion.
600 /// Unlike `extra_files` which copy as-is, template variables like `{{ Tag }}` are expanded.
601 pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
602 /// Build IDs filter: only checksum artifacts from builds whose `id` is in this list.
603 pub ids: Option<Vec<String>>,
604 /// When true, produce one checksum file per artifact instead of a combined file.
605 pub split: Option<bool>,
606 /// Sidecar content format when `split: true` (default: `bare`). Set to
607 /// `coreutils` to write `<hash> <filename>` so each sidecar verifies with
608 /// `shasum -c`. Ignored in combined mode (the combined file is always
609 /// coreutils-format).
610 pub split_format: Option<ChecksumSplitFormat>,
611}
612
613impl ChecksumConfig {
614 /// Default checksum filename template (combined mode). Mirrors
615 /// the checksums config.
616 pub const DEFAULT_NAME_TEMPLATE: &'static str = "{{ ProjectName }}_{{ Version }}_checksums.txt";
617
618 /// Default hash algorithm (`sha256`).
619 pub const DEFAULT_ALGORITHM: &'static str = "sha256";
620
621 /// The closed set of accepted [`Self::algorithm`] values. This is the
622 /// authoritative list the checksum stage's hash dispatch and
623 /// `validate_algorithm` are kept in sync with (a `stage-checksum`
624 /// drift-guard test asserts the two never diverge), so the config rustdoc
625 /// can name the full set without hand-copying a list that rots.
626 pub const SUPPORTED_ALGORITHMS: &'static [&'static str] = &[
627 "sha1", "sha224", "sha256", "sha384", "sha512", "sha3-224", "sha3-256", "sha3-384",
628 "sha3-512", "blake2b", "blake2s", "blake3", "crc32", "md5",
629 ];
630
631 /// Resolve the hash algorithm, falling back to the project default
632 /// when the user did not specify one. Stages MUST call this rather
633 /// than reading `self.algorithm` directly, so a future default change
634 /// (or user-facing override resolution) lands in one place.
635 pub fn resolved_algorithm(&self) -> &str {
636 self.algorithm.as_deref().unwrap_or(Self::DEFAULT_ALGORITHM)
637 }
638
639 /// Whether split-mode (one sidecar per artifact) is requested.
640 /// Defaults to `false` (combined-file mode).
641 pub fn resolved_split(&self) -> bool {
642 self.split.unwrap_or(false)
643 }
644
645 /// Resolve the combined-mode checksum filename template, falling back
646 /// to the canonical default. Returns the raw template
647 /// string; the caller still renders it through Tera.
648 ///
649 /// Split mode constructs sidecar names per-artifact at the call site
650 /// (`<artifact>.<algo>` literal format) and intentionally does NOT
651 /// route through this accessor — that path needs no template rendering.
652 pub fn resolved_combined_name_template(&self) -> &str {
653 self.name_template
654 .as_deref()
655 .unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
656 }
657
658 /// Resolve the combined-checksums `name_template` for a crate, applying the
659 /// canonical precedence — the crate's own `checksum.name_template`, then the
660 /// global `defaults.checksum.name_template`, then [`Self::DEFAULT_NAME_TEMPLATE`].
661 ///
662 /// The single source of truth shared by the checksum stage (which writes the
663 /// file) and the install-script stage (which references it in the generated
664 /// `install.sh`), so the two can never derive different names.
665 pub fn resolve_combined_name_template<'a>(
666 crate_checksum: Option<&'a ChecksumConfig>,
667 global_checksum: Option<&'a ChecksumConfig>,
668 ) -> &'a str {
669 crate_checksum
670 .and_then(|c| c.name_template.as_deref())
671 .or_else(|| global_checksum.and_then(|c| c.name_template.as_deref()))
672 .unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
673 }
674}
675
676// ---------------------------------------------------------------------------
677// ContentSource — inline string, from_file, or from_url
678// ---------------------------------------------------------------------------
679
680/// A content source that can be an inline string, read from a file, or fetched
681/// from a URL. Used for release header/footer values.
682///
683/// YAML examples:
684/// header: "inline text"
685/// header:
686/// from_file: ./RELEASE_HEADER.md
687/// header:
688/// from_url: https://example.com/header.md
689/// header:
690/// from_url: https://example.com/header.md
691/// headers:
692/// X-API-Token: "{{ Env.API_TOKEN }}"
693/// Accept: "text/markdown"
694///
695/// Both `from_file` path and `from_url` URL are template-rendered before use.
696/// Header values are template-rendered.
697#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
698#[serde(untagged)]
699pub enum ContentSource {
700 Inline(String),
701 FromFile {
702 from_file: String,
703 },
704 FromUrl {
705 from_url: String,
706 /// Optional HTTP headers (value templates allowed). Enables private
707 /// mirrors and authenticated endpoints.
708 #[serde(default, skip_serializing_if = "Option::is_none")]
709 headers: Option<HashMap<String, String>>,
710 },
711}
712
713impl PartialEq for ContentSource {
714 fn eq(&self, other: &Self) -> bool {
715 match (self, other) {
716 (Self::Inline(a), Self::Inline(b)) => a == b,
717 (Self::FromFile { from_file: a }, Self::FromFile { from_file: b }) => a == b,
718 (
719 Self::FromUrl {
720 from_url: a,
721 headers: ha,
722 },
723 Self::FromUrl {
724 from_url: b,
725 headers: hb,
726 },
727 ) => a == b && ha == hb,
728 _ => false,
729 }
730 }
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 // The `archives`/`signs`/`binary_signs` fields use hand-written
738 // deserializers (untagged shapes serde can't derive). Each visitor arm —
739 // bool, sequence, single-map, null — is driven here through a wrapper
740 // struct that mirrors the real field attributes.
741
742 #[derive(Deserialize)]
743 struct ArchivesWrapper {
744 #[serde(default, deserialize_with = "deserialize_archives_config")]
745 archives: ArchivesConfig,
746 }
747
748 #[test]
749 fn archives_false_is_disabled() {
750 let w: ArchivesWrapper = serde_yaml_ng::from_str("archives: false").unwrap();
751 assert!(matches!(w.archives, ArchivesConfig::Disabled));
752 }
753
754 #[test]
755 fn archives_true_is_rejected() {
756 // `true` is meaningless for archives — only `false` (disable) or a list.
757 let r: Result<ArchivesWrapper, _> = serde_yaml_ng::from_str("archives: true");
758 assert!(r.is_err(), "archives: true must be rejected");
759 }
760
761 #[test]
762 fn archives_list_becomes_configs() {
763 let w: ArchivesWrapper =
764 serde_yaml_ng::from_str("archives:\n - id: a\n - id: b\n").unwrap();
765 match w.archives {
766 ArchivesConfig::Configs(c) => assert_eq!(c.len(), 2),
767 other => panic!("expected Configs, got {other:?}"),
768 }
769 }
770
771 #[test]
772 fn archives_null_defaults_to_empty_configs() {
773 let w: ArchivesWrapper = serde_yaml_ng::from_str("archives: null").unwrap();
774 match w.archives {
775 ArchivesConfig::Configs(c) => assert!(c.is_empty()),
776 other => panic!("expected empty Configs, got {other:?}"),
777 }
778 }
779
780 #[derive(Deserialize)]
781 struct SignsWrapper {
782 #[serde(default, deserialize_with = "deserialize_signs")]
783 signs: Vec<SignConfig>,
784 }
785
786 #[test]
787 fn signs_single_object_becomes_one_element_vec() {
788 // A single sign-config map (not wrapped in a list) is accepted.
789 let w: SignsWrapper = serde_yaml_ng::from_str("signs:\n artifacts: all\n").unwrap();
790 assert_eq!(w.signs.len(), 1);
791 assert_eq!(w.signs[0].artifacts.as_deref(), Some("all"));
792 }
793
794 #[test]
795 fn signs_sequence_collects_all() {
796 let w: SignsWrapper =
797 serde_yaml_ng::from_str("signs:\n - artifacts: all\n - artifacts: checksum\n")
798 .unwrap();
799 assert_eq!(w.signs.len(), 2);
800 }
801
802 #[test]
803 fn signs_null_is_empty_vec() {
804 let w: SignsWrapper = serde_yaml_ng::from_str("signs: null").unwrap();
805 assert!(w.signs.is_empty());
806 }
807
808 #[derive(Deserialize)]
809 struct BinarySignsWrapper {
810 #[serde(default, deserialize_with = "deserialize_binary_signs")]
811 binary_signs: Vec<SignConfig>,
812 }
813
814 #[test]
815 fn binary_signs_accepts_binary_and_none() {
816 let w: BinarySignsWrapper =
817 serde_yaml_ng::from_str("binary_signs:\n - artifacts: binary\n").unwrap();
818 assert_eq!(w.binary_signs.len(), 1);
819 let w2: BinarySignsWrapper =
820 serde_yaml_ng::from_str("binary_signs:\n - artifacts: none\n").unwrap();
821 assert_eq!(w2.binary_signs.len(), 1);
822 }
823
824 #[test]
825 fn binary_signs_rejects_broad_artifact_filter() {
826 // `all` is valid for top-level `signs:` but not the binary-only field.
827 let r: Result<BinarySignsWrapper, _> =
828 serde_yaml_ng::from_str("binary_signs:\n - artifacts: all\n");
829 assert!(
830 r.is_err(),
831 "binary_signs must reject a non-binary artifact filter"
832 );
833 }
834}