Skip to main content

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}