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#[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#[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 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 #[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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
138pub struct SbomPackageUrl(String);
139
140impl SbomPackageUrl {
141 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 #[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#[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#[derive(Clone, Debug, Eq, PartialEq)]
201pub struct SbomComponent {
202 name: SbomComponentName,
203 version: SbomComponentVersion,
204}
205
206impl SbomComponent {
207 #[must_use]
209 pub const fn new(name: SbomComponentName, version: SbomComponentVersion) -> Self {
210 Self { name, version }
211 }
212
213 #[must_use]
215 pub const fn name(&self) -> &SbomComponentName {
216 &self.name
217 }
218
219 #[must_use]
221 pub const fn version(&self) -> &SbomComponentVersion {
222 &self.version
223 }
224}
225
226#[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#[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}