anodizer_core/config/attestation.rs
1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::{StringOrBool, deserialize_string_or_bool_opt};
5
6// ---------------------------------------------------------------------------
7// AttestationConfig
8// ---------------------------------------------------------------------------
9
10/// SLSA build-provenance / attestation configuration for binaries and archives.
11///
12/// Two modes select how anodizer participates in attestation:
13///
14/// - [`AttestationMode::Subjects`] (the default) emits a **subjects manifest**
15/// (`dist/attestation-subjects.json`) that `anodizer-action` feeds to
16/// GitHub's `actions/attest-build-provenance`. anodizer does NOT mint a
17/// GitHub-trusted attestation itself in this mode — the Action's OIDC
18/// identity does. This is the path fd / biome / gping use.
19/// - [`AttestationMode::Emit`] generates a self-contained in-toto v1 statement
20/// carrying an SLSA provenance v1 predicate over the selected artifacts,
21/// writes it as a release asset (`attestation.intoto.jsonl`), and lets the
22/// existing `signs:` stage sign it (keyed, not OIDC). This is for users who
23/// can't run the Action (the `--with-provenance` toggle).
24///
25/// YAML:
26/// ```yaml
27/// attestations:
28/// enabled: true
29/// mode: subjects # or: emit ; default = subjects
30/// artifacts: [archive, binary, checksum]
31/// ```
32#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
33#[serde(default, deny_unknown_fields)]
34pub struct AttestationConfig {
35 /// Enable attestation. When false (the default), the stage is a no-op.
36 pub enabled: bool,
37 /// Participation mode: `subjects` (default) writes a manifest for
38 /// `actions/attest-build-provenance`; `emit` generates and signs an
39 /// in-toto SLSA provenance statement as a release asset.
40 pub mode: Option<AttestationMode>,
41 /// Which produced-artifact kinds to attest. Each entry selects a KIND
42 /// (`archive`, `binary`, `checksum`); the concrete subject set (filenames
43 /// + sha256) is DERIVED from the artifacts anodizer already produced.
44 ///
45 /// Defaults to `[archive, binary, checksum]` when omitted.
46 pub artifacts: Option<Vec<AttestationArtifactKind>>,
47 /// Skip the attestation stage. Accepts a bool or a template string.
48 #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
49 pub skip: Option<StringOrBool>,
50}
51
52/// Attestation participation mode. See [`AttestationConfig`].
53#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
54#[serde(rename_all = "snake_case")]
55pub enum AttestationMode {
56 /// Emit a subjects manifest for `actions/attest-build-provenance` (OIDC).
57 Subjects,
58 /// Generate + sign a self-contained in-toto SLSA provenance statement.
59 Emit,
60}
61
62/// A selectable artifact KIND for attestation. Each variant maps to one or
63/// more concrete [`crate::artifact::ArtifactKind`] values at subject-collection
64/// time; together the variants cover the full release-uploadable surface so any
65/// artifact that lands on the release can be attested.
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
67#[serde(rename_all = "snake_case")]
68pub enum AttestationArtifactKind {
69 /// Packaged archives (`.tar.gz`, `.zip`, ...) and self-extracting archives.
70 Archive,
71 /// Raw uploadable binaries (uploaded as bare release assets).
72 Binary,
73 /// Checksum file(s) (`checksums.txt` and split sidecars).
74 Checksum,
75 /// Linux packages (`.deb` / `.rpm` / `.apk`) and source RPMs.
76 Package,
77 /// Source archives (`source:` tarball).
78 Source,
79 /// Generated SBOM documents.
80 Sbom,
81 /// OS installers: Windows MSI/NSIS, macOS DMG (disk image), and macOS PKG.
82 Installer,
83}
84
85impl AttestationConfig {
86 /// Filename of the subjects manifest written in `subjects` mode (single
87 /// crate / lockstep). Per-crate workspace mode prefixes the crate name.
88 pub const SUBJECTS_MANIFEST_NAME: &'static str = "attestation-subjects.json";
89
90 /// Filename of the in-toto statement written in `emit` mode (single crate
91 /// / lockstep). Per-crate workspace mode prefixes the crate name.
92 pub const STATEMENT_NAME: &'static str = "attestation.intoto.jsonl";
93
94 /// Resolve the participation mode, defaulting to `subjects`.
95 pub fn resolved_mode(&self) -> AttestationMode {
96 self.mode.unwrap_or(AttestationMode::Subjects)
97 }
98
99 /// The configured artifact-kind selection, or `None` when `artifacts:` is
100 /// omitted.
101 ///
102 /// `None` is NOT a hand-curated subset — the stage interprets it as "attest
103 /// every release-uploadable artifact" (the full `release_uploadable_kinds()`
104 /// set minus signatures/certificates and the attestation outputs
105 /// themselves), so a `.deb`/`.rpm`/SBOM/installer the user ships is attested
106 /// by default rather than silently dropped.
107 pub fn resolved_artifacts(&self) -> Option<Vec<AttestationArtifactKind>> {
108 self.artifacts.clone()
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 #[test]
117 fn default_mode_is_subjects() {
118 let cfg = AttestationConfig::default();
119 assert_eq!(cfg.resolved_mode(), AttestationMode::Subjects);
120 }
121
122 #[test]
123 fn omitted_artifacts_resolve_to_none_meaning_attest_everything() {
124 // None signals the stage to attest the full release-uploadable set,
125 // not a hand-curated subset.
126 let cfg = AttestationConfig::default();
127 assert_eq!(cfg.resolved_artifacts(), None);
128 }
129
130 #[test]
131 fn parses_newly_selectable_kinds() {
132 let yaml = "enabled: true\nartifacts: [package, source, sbom, installer]\n";
133 let cfg: AttestationConfig = serde_yaml_ng::from_str(yaml).expect("parse");
134 assert_eq!(
135 cfg.resolved_artifacts(),
136 Some(vec![
137 AttestationArtifactKind::Package,
138 AttestationArtifactKind::Source,
139 AttestationArtifactKind::Sbom,
140 AttestationArtifactKind::Installer,
141 ])
142 );
143 }
144
145 #[test]
146 fn default_is_disabled() {
147 assert!(!AttestationConfig::default().enabled);
148 }
149
150 #[test]
151 fn parses_yaml_with_explicit_mode_and_artifacts() {
152 let yaml = "enabled: true\nmode: emit\nartifacts: [archive, binary]\n";
153 let cfg: AttestationConfig = serde_yaml_ng::from_str(yaml).expect("parse");
154 assert!(cfg.enabled);
155 assert_eq!(cfg.resolved_mode(), AttestationMode::Emit);
156 assert_eq!(
157 cfg.resolved_artifacts(),
158 Some(vec![
159 AttestationArtifactKind::Archive,
160 AttestationArtifactKind::Binary
161 ])
162 );
163 }
164
165 #[test]
166 fn rejects_unknown_field() {
167 let yaml = "enabled: true\nbogus: 1\n";
168 assert!(serde_yaml_ng::from_str::<AttestationConfig>(yaml).is_err());
169 }
170
171 #[test]
172 fn rejects_unknown_mode() {
173 let yaml = "enabled: true\nmode: sideways\n";
174 assert!(serde_yaml_ng::from_str::<AttestationConfig>(yaml).is_err());
175 }
176}