Skip to main content

anodizer_core/config/
sbom.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4use super::{StringOrBool, deserialize_string_or_bool_opt};
5
6// ---------------------------------------------------------------------------
7// SbomConfig
8// ---------------------------------------------------------------------------
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
11#[serde(default)]
12pub struct SbomConfig {
13    /// Unique identifier for this SBOM config (default: "default").
14    pub id: Option<String>,
15    /// Command to run for SBOM generation (default: "syft").
16    pub cmd: Option<String>,
17    /// Environment variables to pass to the command, as `KEY=VALUE` strings.
18    /// Order is preserved. Values are template-rendered before being set.
19    #[serde(default)]
20    pub env: Option<Vec<String>>,
21    /// Command-line arguments (supports templates and $artifact, $document vars).
22    pub args: Option<Vec<String>>,
23    /// Output document path templates (supports templates).
24    pub documents: Option<Vec<String>>,
25    /// Which artifacts to catalog: "source", "archive", "binary", "package", "diskimage", "installer", "any" (default: "archive").
26    pub artifacts: Option<String>,
27    /// Filter by artifact IDs (ignored if artifacts="source").
28    pub ids: Option<Vec<String>>,
29    /// Skip this SBOM config. Accepts bool or template string.
30    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
31    pub skip: Option<StringOrBool>,
32}
33
34impl SbomConfig {
35    /// Default `id` when an SBOM config has none. Mirrors GoReleaser
36    /// `internal/pipe/sbom/sbom.go` (`cfg.ID = "default"`).
37    pub const DEFAULT_ID: &'static str = "default";
38
39    /// Default SBOM-generation command. Mirrors GoReleaser `sbom.go`
40    /// (`cfg.Cmd = "syft"`).
41    pub const DEFAULT_CMD: &'static str = "syft";
42
43    /// Default `artifacts` filter. Mirrors GoReleaser `sbom.go`
44    /// (`cfg.Artifacts = "archive"`).
45    pub const DEFAULT_ARTIFACTS: &'static str = "archive";
46
47    /// Default document-path template when `artifacts: binary`. Includes
48    /// per-target Os/Arch suffix so per-arch SBOMs don't collide.
49    /// Mirrors GoReleaser `sbom.go`.
50    pub const DEFAULT_DOCUMENT_BINARY: &'static str =
51        "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.sbom.json";
52
53    /// Default document-path template for any non-binary, non-any
54    /// `artifacts:` filter. Mirrors GoReleaser `sbom.go`.
55    pub const DEFAULT_DOCUMENT_OTHER: &'static str = "{{ .ArtifactName }}.sbom.json";
56
57    /// Default `args` for the syft command. Mirrors GoReleaser
58    /// `sbom.go`. Anodize matches GR's shell-style `$artifact` /
59    /// `$document` placeholders verbatim — the arg-renderer rewrites
60    /// these to per-artifact values at execution time.
61    pub const DEFAULT_SYFT_ARGS: &[&'static str] = &[
62        "$artifact",
63        "--output",
64        "spdx-json=$document",
65        "--enrich",
66        "all",
67    ];
68
69    /// Env entry that syft requires to emit file paths in the SBOM
70    /// when cataloging archives or source. Mirrors GoReleaser `sbom.go`.
71    pub const DEFAULT_SYFT_ENV_KEY: &'static str = "SYFT_FILE_METADATA_CATALOGER_ENABLED";
72    pub const DEFAULT_SYFT_ENV_VAL: &'static str = "true";
73
74    /// Resolve the SBOM-config id, falling back to `"default"`.
75    pub fn resolved_id(&self) -> &str {
76        self.id.as_deref().unwrap_or(Self::DEFAULT_ID)
77    }
78
79    /// Resolve the SBOM command, falling back to `"syft"`.
80    pub fn resolved_cmd(&self) -> &str {
81        self.cmd.as_deref().unwrap_or(Self::DEFAULT_CMD)
82    }
83
84    /// Resolve the `artifacts:` filter, falling back to `"archive"`.
85    pub fn resolved_artifacts(&self) -> &str {
86        self.artifacts.as_deref().unwrap_or(Self::DEFAULT_ARTIFACTS)
87    }
88
89    /// Resolve `documents`, falling back to the artifact-type-specific
90    /// default when unset. Caller should pass the result of
91    /// [`Self::resolved_artifacts`] for `artifacts`.
92    pub fn resolved_documents(&self, artifacts: &str) -> Vec<String> {
93        self.documents.clone().unwrap_or_else(|| match artifacts {
94            "binary" => vec![Self::DEFAULT_DOCUMENT_BINARY.to_string()],
95            "any" => vec![],
96            _ => vec![Self::DEFAULT_DOCUMENT_OTHER.to_string()],
97        })
98    }
99
100    /// Resolve `args`, falling back to [`Self::DEFAULT_SYFT_ARGS`] when
101    /// `cmd` is `"syft"`; empty vec otherwise (matches GoReleaser:
102    /// `sbom.go` only initializes args when cmd is syft, and leaves
103    /// args empty for other cmds).
104    pub fn resolved_args(&self, cmd: &str) -> Vec<String> {
105        self.args.clone().unwrap_or_else(|| {
106            if cmd == Self::DEFAULT_CMD {
107                Self::DEFAULT_SYFT_ARGS
108                    .iter()
109                    .map(|s| (*s).to_string())
110                    .collect()
111            } else {
112                Vec::new()
113            }
114        })
115    }
116
117    /// Default env additions for the syft sub-process. Empty unless cmd
118    /// is syft AND artifacts is source/archive — in which case syft
119    /// needs the file-metadata cataloger enabled to produce file paths
120    /// in the SBOM. Mirrors GoReleaser `sbom.go`.
121    pub fn default_syft_env_for(cmd: &str, artifacts: &str) -> Vec<(String, String)> {
122        if cmd == Self::DEFAULT_CMD && matches!(artifacts, "source" | "archive") {
123            vec![(
124                Self::DEFAULT_SYFT_ENV_KEY.to_string(),
125                Self::DEFAULT_SYFT_ENV_VAL.to_string(),
126            )]
127        } else {
128            Vec::new()
129        }
130    }
131}
132
133/// Custom deserializer for the `sboms` / `sbom` field.
134/// Accepts:
135///   - null/missing → empty vec (via serde default)
136///   - a single object → vec of one SbomConfig
137///   - an array → vec of SbomConfig
138pub(super) fn deserialize_sboms<'de, D>(deserializer: D) -> Result<Vec<SbomConfig>, D::Error>
139where
140    D: Deserializer<'de>,
141{
142    use serde::de::{self, Visitor};
143
144    struct SbomsVisitor;
145
146    impl<'de> Visitor<'de> for SbomsVisitor {
147        type Value = Vec<SbomConfig>;
148
149        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150            f.write_str("an SBOM config object or an array of SBOM config objects")
151        }
152
153        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
154            let mut configs = Vec::new();
155            while let Some(item) = seq.next_element::<SbomConfig>()? {
156                configs.push(item);
157            }
158            Ok(configs)
159        }
160
161        fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
162            let config = SbomConfig::deserialize(de::value::MapAccessDeserializer::new(map))?;
163            Ok(vec![config])
164        }
165
166        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
167            Ok(Vec::new())
168        }
169
170        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
171            Ok(Vec::new())
172        }
173    }
174
175    deserializer.deserialize_any(SbomsVisitor)
176}