Skip to main content

use_sbom/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3#![allow(clippy::module_name_repetitions)]
4
5use core::{fmt, str::FromStr};
6use std::error::Error;
7
8/// Error returned when SBOM text metadata is invalid.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum SbomTextError {
11    Empty,
12    ContainsWhitespace,
13    InvalidPackageUrl,
14}
15
16impl fmt::Display for SbomTextError {
17    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::Empty => formatter.write_str("SBOM metadata text cannot be empty"),
20            Self::ContainsWhitespace => {
21                formatter.write_str("SBOM metadata text cannot contain whitespace")
22            }
23            Self::InvalidPackageUrl => formatter.write_str("SBOM package URL must start with pkg:"),
24        }
25    }
26}
27
28impl Error for SbomTextError {}
29
30/// Error returned when an SBOM label cannot be parsed.
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum SbomParseError {
33    Empty,
34    Unknown,
35}
36
37impl fmt::Display for SbomParseError {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::Empty => formatter.write_str("SBOM label cannot be empty"),
41            Self::Unknown => formatter.write_str("unknown SBOM label"),
42        }
43    }
44}
45
46impl Error for SbomParseError {}
47
48macro_rules! text_newtype {
49    ($name:ident) => {
50        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
51        pub struct $name(String);
52
53        impl $name {
54            /// Creates non-empty SBOM text metadata.
55            pub fn new(input: impl AsRef<str>) -> Result<Self, SbomTextError> {
56                let trimmed = input.as_ref().trim();
57                if trimmed.is_empty() {
58                    Err(SbomTextError::Empty)
59                } else {
60                    Ok(Self(trimmed.to_owned()))
61                }
62            }
63
64            /// Returns the stored text.
65            #[must_use]
66            pub fn as_str(&self) -> &str {
67                &self.0
68            }
69        }
70
71        impl fmt::Display for $name {
72            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73                formatter.write_str(self.as_str())
74            }
75        }
76
77        impl FromStr for $name {
78            type Err = SbomTextError;
79
80            fn from_str(input: &str) -> Result<Self, Self::Err> {
81                Self::new(input)
82            }
83        }
84
85        impl TryFrom<&str> for $name {
86            type Error = SbomTextError;
87
88            fn try_from(value: &str) -> Result<Self, Self::Error> {
89                Self::new(value)
90            }
91        }
92    };
93}
94
95macro_rules! label_enum {
96    ($name:ident { $($variant:ident => $label:literal),+ $(,)? }) => {
97        impl $name {
98            /// Returns the stable label.
99            #[must_use]
100            pub const fn as_str(self) -> &'static str {
101                match self {
102                    $(Self::$variant => $label,)+
103                }
104            }
105        }
106
107        impl fmt::Display for $name {
108            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109                formatter.write_str(self.as_str())
110            }
111        }
112
113        impl FromStr for $name {
114            type Err = SbomParseError;
115
116            fn from_str(input: &str) -> Result<Self, Self::Err> {
117                let trimmed = input.trim();
118                if trimmed.is_empty() {
119                    return Err(SbomParseError::Empty);
120                }
121                let normalized = trimmed.to_ascii_lowercase();
122                match normalized.as_str() {
123                    $($label => Ok(Self::$variant),)+
124                    _ => Err(SbomParseError::Unknown),
125                }
126            }
127        }
128    };
129}
130
131text_newtype!(SbomComponentName);
132text_newtype!(SbomComponentVersion);
133text_newtype!(SbomDigest);
134text_newtype!(SbomLicenseExpression);
135
136/// A package URL metadata value.
137#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
138pub struct SbomPackageUrl(String);
139
140impl SbomPackageUrl {
141    /// Creates a package URL metadata value that starts with `pkg:`.
142    pub fn new(input: impl AsRef<str>) -> Result<Self, SbomTextError> {
143        let trimmed = input.as_ref().trim();
144        if trimmed.is_empty() {
145            return Err(SbomTextError::Empty);
146        }
147        if trimmed.chars().any(char::is_whitespace) {
148            return Err(SbomTextError::ContainsWhitespace);
149        }
150        if !trimmed.starts_with("pkg:") {
151            return Err(SbomTextError::InvalidPackageUrl);
152        }
153        Ok(Self(trimmed.to_owned()))
154    }
155
156    /// Returns the package URL metadata.
157    #[must_use]
158    pub fn as_str(&self) -> &str {
159        &self.0
160    }
161}
162
163impl fmt::Display for SbomPackageUrl {
164    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
165        formatter.write_str(self.as_str())
166    }
167}
168
169impl FromStr for SbomPackageUrl {
170    type Err = SbomTextError;
171
172    fn from_str(input: &str) -> Result<Self, Self::Err> {
173        Self::new(input)
174    }
175}
176
177impl TryFrom<&str> for SbomPackageUrl {
178    type Error = SbomTextError;
179
180    fn try_from(value: &str) -> Result<Self, Self::Error> {
181        Self::new(value)
182    }
183}
184
185/// SBOM format labels.
186#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
187pub enum SbomFormat {
188    CycloneDx,
189    Spdx,
190    Custom,
191}
192
193label_enum!(SbomFormat {
194    CycloneDx => "cyclonedx",
195    Spdx => "spdx",
196    Custom => "custom",
197});
198
199/// SBOM component metadata.
200#[derive(Clone, Debug, Eq, PartialEq)]
201pub struct SbomComponent {
202    name: SbomComponentName,
203    version: SbomComponentVersion,
204}
205
206impl SbomComponent {
207    /// Creates SBOM component metadata.
208    #[must_use]
209    pub const fn new(name: SbomComponentName, version: SbomComponentVersion) -> Self {
210        Self { name, version }
211    }
212
213    /// Returns the component name.
214    #[must_use]
215    pub const fn name(&self) -> &SbomComponentName {
216        &self.name
217    }
218
219    /// Returns the component version.
220    #[must_use]
221    pub const fn version(&self) -> &SbomComponentVersion {
222        &self.version
223    }
224}
225
226/// SBOM relationship labels.
227#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
228pub enum SbomRelationshipKind {
229    Contains,
230    DependsOn,
231    DependencyOf,
232    Describes,
233    GeneratedFrom,
234    DistributedWith,
235    Unknown,
236}
237
238label_enum!(SbomRelationshipKind {
239    Contains => "contains",
240    DependsOn => "depends-on",
241    DependencyOf => "dependency-of",
242    Describes => "describes",
243    GeneratedFrom => "generated-from",
244    DistributedWith => "distributed-with",
245    Unknown => "unknown",
246});
247
248/// Supply-chain risk labels.
249#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
250pub enum SupplyChainRiskKind {
251    VulnerableDependency,
252    OutdatedDependency,
253    MaliciousPackage,
254    Typosquatting,
255    DependencyConfusion,
256    UnpinnedDependency,
257    UnknownProvenance,
258    Other,
259}
260
261label_enum!(SupplyChainRiskKind {
262    VulnerableDependency => "vulnerable-dependency",
263    OutdatedDependency => "outdated-dependency",
264    MaliciousPackage => "malicious-package",
265    Typosquatting => "typosquatting",
266    DependencyConfusion => "dependency-confusion",
267    UnpinnedDependency => "unpinned-dependency",
268    UnknownProvenance => "unknown-provenance",
269    Other => "other",
270});
271
272#[cfg(test)]
273mod tests {
274    use super::{
275        SbomComponent, SbomComponentName, SbomComponentVersion, SbomFormat, SbomPackageUrl,
276        SbomTextError, SupplyChainRiskKind,
277    };
278
279    #[test]
280    fn validates_component_text() {
281        let component = SbomComponent::new(
282            SbomComponentName::new("example").expect("name"),
283            SbomComponentVersion::new("1.0.0").expect("version"),
284        );
285
286        assert_eq!(component.name().as_str(), "example");
287        assert!(SbomComponentName::new(" ").is_err());
288    }
289
290    #[test]
291    fn validates_package_url() {
292        let package_url = SbomPackageUrl::new("pkg:cargo/use-sbom@0.0.1").expect("purl");
293
294        assert_eq!(package_url.as_str(), "pkg:cargo/use-sbom@0.0.1");
295        assert_eq!(
296            SbomPackageUrl::new("cargo/use-sbom"),
297            Err(SbomTextError::InvalidPackageUrl)
298        );
299    }
300
301    #[test]
302    fn parses_and_displays_labels() {
303        assert_eq!(
304            "spdx".parse::<SbomFormat>().expect("format"),
305            SbomFormat::Spdx
306        );
307        assert_eq!(
308            SupplyChainRiskKind::DependencyConfusion.to_string(),
309            "dependency-confusion"
310        );
311    }
312}