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}