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, deny_unknown_fields)]
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    /// Accepts the legacy `disable:` spelling via serde alias for back-compat.
31    #[serde(
32        alias = "disable",
33        deserialize_with = "deserialize_string_or_bool_opt",
34        default
35    )]
36    pub skip: Option<StringOrBool>,
37}
38
39impl SbomConfig {
40    /// Default `id` when an SBOM config has none (`"default"`).
41    pub const DEFAULT_ID: &'static str = "default";
42
43    /// Default SBOM-generation command
44    /// (`cfg.Cmd = "syft"`).
45    pub const DEFAULT_CMD: &'static str = "syft";
46
47    /// Default `artifacts` filter
48    /// (`cfg.Artifacts = "archive"`).
49    pub const DEFAULT_ARTIFACTS: &'static str = "archive";
50
51    /// Default document-path template when `artifacts: binary`. Includes
52    /// per-target Os/Arch suffix so per-arch SBOMs don't collide.
53    /// Default value.
54    pub const DEFAULT_DOCUMENT_BINARY: &'static str =
55        "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.sbom.json";
56
57    /// Default document-path template for any non-binary, non-any
58    /// `artifacts:` filter.
59    pub const DEFAULT_DOCUMENT_OTHER: &'static str = "{{ .ArtifactName }}.sbom.json";
60
61    /// Default `args` for the syft command, using shell-style `$artifact` /
62    /// `$document` placeholders verbatim — the arg-renderer rewrites
63    /// these to per-artifact values at execution time.
64    pub const DEFAULT_SYFT_ARGS: &[&'static str] = &[
65        "$artifact",
66        "--output",
67        "spdx-json=$document",
68        "--enrich",
69        "all",
70    ];
71
72    /// Env entry that syft requires to emit file paths in the SBOM
73    /// when cataloging archives or source.
74    pub const DEFAULT_SYFT_ENV_KEY: &'static str = "SYFT_FILE_METADATA_CATALOGER_ENABLED";
75    pub const DEFAULT_SYFT_ENV_VAL: &'static str = "true";
76
77    /// Resolve the SBOM-config id, falling back to `"default"`.
78    pub fn resolved_id(&self) -> &str {
79        self.id.as_deref().unwrap_or(Self::DEFAULT_ID)
80    }
81
82    /// Resolve the SBOM command, falling back to `"syft"`.
83    pub fn resolved_cmd(&self) -> &str {
84        self.cmd.as_deref().unwrap_or(Self::DEFAULT_CMD)
85    }
86
87    /// Resolve the `artifacts:` filter, falling back to `"archive"`.
88    pub fn resolved_artifacts(&self) -> &str {
89        self.artifacts.as_deref().unwrap_or(Self::DEFAULT_ARTIFACTS)
90    }
91
92    /// Resolve `documents`, falling back to the artifact-type-specific
93    /// default when unset. Caller should pass the result of
94    /// [`Self::resolved_artifacts`] for `artifacts`.
95    pub fn resolved_documents(&self, artifacts: &str) -> Vec<String> {
96        self.documents.clone().unwrap_or_else(|| match artifacts {
97            "binary" => vec![Self::DEFAULT_DOCUMENT_BINARY.to_string()],
98            "any" => vec![],
99            _ => vec![Self::DEFAULT_DOCUMENT_OTHER.to_string()],
100        })
101    }
102
103    /// Resolve `args`, falling back to [`Self::DEFAULT_SYFT_ARGS`] when
104    /// `cmd` is `"syft"`; empty vec otherwise (args are only initialized
105    /// when cmd is syft, and left
106    /// args empty for other cmds).
107    pub fn resolved_args(&self, cmd: &str) -> Vec<String> {
108        self.args.clone().unwrap_or_else(|| {
109            if cmd == Self::DEFAULT_CMD {
110                Self::DEFAULT_SYFT_ARGS
111                    .iter()
112                    .map(|s| (*s).to_string())
113                    .collect()
114            } else {
115                Vec::new()
116            }
117        })
118    }
119
120    /// Default env additions for the syft sub-process. Empty unless cmd
121    /// is syft AND artifacts is source/archive — in which case syft
122    /// needs the file-metadata cataloger enabled to produce file paths
123    /// in the SBOM.
124    pub fn default_syft_env_for(cmd: &str, artifacts: &str) -> Vec<(String, String)> {
125        if cmd == Self::DEFAULT_CMD && matches!(artifacts, "source" | "archive") {
126            vec![(
127                Self::DEFAULT_SYFT_ENV_KEY.to_string(),
128                Self::DEFAULT_SYFT_ENV_VAL.to_string(),
129            )]
130        } else {
131            Vec::new()
132        }
133    }
134}
135
136/// Custom deserializer for the `sboms` / `sbom` field.
137/// Accepts:
138///   - null/missing → empty vec (via serde default)
139///   - a single object → vec of one SbomConfig
140///   - an array → vec of SbomConfig
141pub(super) fn deserialize_sboms<'de, D>(deserializer: D) -> Result<Vec<SbomConfig>, D::Error>
142where
143    D: Deserializer<'de>,
144{
145    use serde::de::{self, Visitor};
146
147    struct SbomsVisitor;
148
149    impl<'de> Visitor<'de> for SbomsVisitor {
150        type Value = Vec<SbomConfig>;
151
152        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153            f.write_str("an SBOM config object or an array of SBOM config objects")
154        }
155
156        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
157            let mut configs = Vec::new();
158            while let Some(item) = seq.next_element::<SbomConfig>()? {
159                configs.push(item);
160            }
161            Ok(configs)
162        }
163
164        fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
165            let config = SbomConfig::deserialize(de::value::MapAccessDeserializer::new(map))?;
166            Ok(vec![config])
167        }
168
169        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
170            Ok(Vec::new())
171        }
172
173        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
174            Ok(Vec::new())
175        }
176    }
177
178    deserializer.deserialize_any(SbomsVisitor)
179}
180
181#[cfg(test)]
182mod sbom_resolver_tests {
183    use super::*;
184
185    #[test]
186    fn resolved_args_falls_back_to_syft_defaults_only_for_syft_cmd() {
187        let cfg = SbomConfig::default();
188        // syft cmd with no explicit args ⇒ the built-in syft argv (non-empty,
189        // carrying the output placeholder), NOT an empty vec.
190        let syft = cfg.resolved_args("syft");
191        assert!(!syft.is_empty(), "syft default args must be populated");
192        assert!(
193            syft.iter().any(|a| a.contains("$artifact")),
194            "syft default args reference the per-artifact placeholder: {syft:?}"
195        );
196        // A non-syft cmd gets NO default args (a foreign tool's flags are
197        // unknown), so the fallback yields an empty vec.
198        assert!(
199            cfg.resolved_args("mytool").is_empty(),
200            "non-syft cmd must not inherit syft's argv"
201        );
202    }
203
204    #[test]
205    fn resolved_args_returns_explicit_args_verbatim() {
206        let cfg = SbomConfig {
207            args: Some(vec!["--only".to_string(), "this".to_string()]),
208            ..Default::default()
209        };
210        // Explicit args short-circuit the cmd-based fallback entirely.
211        assert_eq!(cfg.resolved_args("syft"), vec!["--only", "this"]);
212        assert_eq!(cfg.resolved_args("other"), vec!["--only", "this"]);
213    }
214
215    #[derive(serde::Deserialize)]
216    struct SbomsWrapper {
217        #[serde(deserialize_with = "deserialize_sboms", default)]
218        sboms: Vec<SbomConfig>,
219    }
220
221    #[test]
222    fn deserialize_sboms_accepts_single_object() {
223        // A single mapping is wrapped into a one-element vec (visit_map).
224        let w: SbomsWrapper = serde_yaml_ng::from_str("sboms:\n  cmd: trivy\n").unwrap();
225        assert_eq!(w.sboms.len(), 1);
226        assert_eq!(w.sboms[0].cmd.as_deref(), Some("trivy"));
227    }
228
229    #[test]
230    fn deserialize_sboms_accepts_array() {
231        // A sequence yields one config per element (visit_seq).
232        let w: SbomsWrapper =
233            serde_yaml_ng::from_str("sboms:\n  - cmd: syft\n  - cmd: trivy\n").unwrap();
234        assert_eq!(w.sboms.len(), 2);
235        assert_eq!(w.sboms[0].cmd.as_deref(), Some("syft"));
236        assert_eq!(w.sboms[1].cmd.as_deref(), Some("trivy"));
237    }
238
239    #[test]
240    fn deserialize_sboms_null_is_empty() {
241        // An explicit null collapses to an empty vec (visit_unit).
242        let w: SbomsWrapper = serde_yaml_ng::from_str("sboms: null\n").unwrap();
243        assert!(w.sboms.is_empty());
244    }
245}