1use once_cell::sync::Lazy;
20use regex::Regex;
21
22use crate::external_models::uri::{validate_uri as validate_url, Uri as Url};
23use crate::models::hash::Hashes;
24use crate::validation::{Validate, ValidationContext, ValidationError, ValidationResult};
25
26use super::bom::SpecVersion;
27
28#[derive(Clone, Debug, PartialEq, Eq, Hash)]
32pub struct ExternalReference {
33 pub external_reference_type: ExternalReferenceType,
34 pub url: Uri,
35 pub comment: Option<String>,
36 pub hashes: Option<Hashes>,
37}
38
39impl ExternalReference {
40 pub fn new(external_reference_type: ExternalReferenceType, url: impl Into<Uri>) -> Self {
49 Self {
50 external_reference_type,
51 url: url.into(),
52 comment: None,
53 hashes: None,
54 }
55 }
56}
57
58impl Validate for ExternalReference {
59 fn validate_version(&self, version: SpecVersion) -> ValidationResult {
60 ValidationContext::new()
61 .add_field(
62 "external_reference_type",
63 &self.external_reference_type,
64 validate_external_reference_type,
65 )
66 .add_field("url", &self.url, |uri| validate_reference_uri(uri, version))
67 .add_list("hashes", &self.hashes, |hash| {
68 hash.validate_version(version)
69 })
70 .into()
71 }
72}
73
74#[derive(Clone, Debug, PartialEq, Eq, Hash)]
75pub struct ExternalReferences(pub Vec<ExternalReference>);
76
77impl Validate for ExternalReferences {
78 fn validate_version(&self, version: SpecVersion) -> ValidationResult {
79 ValidationContext::new()
80 .add_list("inner", &self.0, |reference| {
81 reference.validate_version(version)
82 })
83 .into()
84 }
85}
86
87pub fn validate_external_reference_type(
88 reference_type: &ExternalReferenceType,
89) -> Result<(), ValidationError> {
90 if matches!(
91 reference_type,
92 ExternalReferenceType::UnknownExternalReferenceType(_)
93 ) {
94 return Err("Unknown external reference type".into());
95 }
96 Ok(())
97}
98
99#[derive(Clone, Debug, PartialEq, Eq, Hash, strum::Display)]
101#[strum(serialize_all = "kebab-case")]
102pub enum ExternalReferenceType {
103 Vcs,
104 IssueTracker,
105 Website,
106 Advisories,
107 Bom,
108 MailingList,
109 Social,
110 Chat,
111 Documentation,
112 Support,
113 Distribution,
114 DistributionIntake,
115 License,
116 BuildMeta,
117 BuildSystem,
118 Other,
119 #[doc(hidden)]
120 #[strum(default)]
121 UnknownExternalReferenceType(String),
122 ReleaseNotes,
123 SecurityContact,
124 ModelCard,
125 Log,
126 Configuration,
127 Evidence,
128 Formulation,
129 Attestation,
130 ThreatModel,
131 AdversaryModel,
132 RiskAssessment,
133 VulnerabilityAssertion,
134 ExploitabilityStatement,
135 PentestReport,
136 StaticAnalysisReport,
137 DynamicAnalysisReport,
138 RuntimeAnalysisReport,
139 ComponentAnalysisReport,
140 MaturityReport,
141 CertificationReport,
142 CondifiedInfrastructure,
143 QualityMetrics,
144 Poam,
145}
146
147impl ExternalReferenceType {
148 pub fn new_unchecked<A: AsRef<str>>(value: A) -> Self {
149 match value.as_ref() {
150 "vcs" => Self::Vcs,
151 "issue-tracker" => Self::IssueTracker,
152 "website" => Self::Website,
153 "advisories" => Self::Advisories,
154 "bom" => Self::Bom,
155 "mailing-list" => Self::MailingList,
156 "social" => Self::Social,
157 "chat" => Self::Chat,
158 "documentation" => Self::Documentation,
159 "support" => Self::Support,
160 "distribution" => Self::Distribution,
161 "distribution-intake" => Self::DistributionIntake,
162 "license" => Self::License,
163 "build-meta" => Self::BuildMeta,
164 "build-system" => Self::BuildSystem,
165 "release-notes" => Self::ReleaseNotes,
166 "security-contact" => Self::SecurityContact,
167 "model-card" => Self::ModelCard,
168 "log" => Self::Log,
169 "configuration" => Self::Configuration,
170 "evidence" => Self::Evidence,
171 "formulation" => Self::Formulation,
172 "attestation" => Self::Attestation,
173 "threat-model" => Self::ThreatModel,
174 "adversary-model" => Self::AdversaryModel,
175 "risk-assessment" => Self::RiskAssessment,
176 "vulnerability-assertion" => Self::VulnerabilityAssertion,
177 "exploitability-statement" => Self::ExploitabilityStatement,
178 "pentest-report" => Self::PentestReport,
179 "static-analysis-report" => Self::StaticAnalysisReport,
180 "dynamic-analysis-report" => Self::DynamicAnalysisReport,
181 "runtime-analysis-report" => Self::RuntimeAnalysisReport,
182 "component-analysis-report" => Self::ComponentAnalysisReport,
183 "maturity-report" => Self::MaturityReport,
184 "certification-report" => Self::CertificationReport,
185 "codified-infrastructure" => Self::CondifiedInfrastructure,
186 "quality-metrics" => Self::QualityMetrics,
187 "poam" => Self::Poam,
188 "other" => Self::Other,
189 unknown => Self::UnknownExternalReferenceType(unknown.to_string()),
190 }
191 }
192}
193
194#[derive(Clone, Debug, PartialEq, Eq, Hash)]
195pub enum Uri {
196 Url(Url),
197 BomLink(BomLink),
198}
199
200fn validate_reference_uri(uri: &Uri, version: SpecVersion) -> Result<(), ValidationError> {
202 match uri {
203 Uri::Url(url) => validate_url(url),
204 Uri::BomLink(bom_link) => validate_bom_link(bom_link, version),
205 }
206}
207
208impl From<Url> for Uri {
209 fn from(url: Url) -> Self {
210 if url.is_bomlink() {
211 Self::BomLink(BomLink(url.to_string()))
212 } else {
213 Self::Url(url)
214 }
215 }
216}
217
218impl std::fmt::Display for Uri {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 let s = match self {
221 Uri::Url(uri) => uri.to_string(),
222 Uri::BomLink(link) => link.0.to_string(),
223 };
224 write!(f, "{s}")
225 }
226}
227
228#[derive(Clone, Debug, PartialEq, Eq, Hash)]
229pub struct BomLink(pub String);
230
231fn validate_bom_link(bom_link: &BomLink, version: SpecVersion) -> Result<(), ValidationError> {
232 if version < SpecVersion::V1_5 {
233 return Err("BOM-Link not supported before version 1.5".into());
234 }
235
236 static BOM_LINK_REGEX: Lazy<Regex> = Lazy::new(|| {
237 Regex::new(r"^urn:cdx:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/[1-9][0-9]*(#.+)?$").unwrap()
238 });
239
240 if !BOM_LINK_REGEX.is_match(&bom_link.0) {
241 return Err(ValidationError::new("Invalid BOM-Link"));
242 }
243
244 Ok(())
245}
246
247#[cfg(test)]
248mod test {
249 use crate::{
250 models::hash::{Hash, HashValue},
251 validation,
252 };
253
254 use super::*;
255 use pretty_assertions::assert_eq;
256
257 #[test]
258 fn it_should_convert_url_into_uri() {
259 let url = Url("https://example.com".to_string());
260 assert_eq!(Uri::Url(Url("https://example.com".to_string())), url.into());
261 }
262
263 #[test]
264 fn it_should_convert_url_into_bomlink() {
265 let url = Url("urn:cdx:f08a6ccd-4dce-4759-bd84-c626675d60a7/1".to_string());
266 assert_eq!(
267 Uri::BomLink(BomLink(
268 "urn:cdx:f08a6ccd-4dce-4759-bd84-c626675d60a7/1".to_string()
269 )),
270 url.into()
271 );
272 }
273
274 #[test]
275 fn it_should_validate_external_reference_with_bomlink_correctly() {
276 let url = Uri::BomLink(BomLink(
277 "urn:cdx:f08a6ccd-4dce-4759-bd84-c626675d60a7/1".to_string(),
278 ));
279
280 let external_reference = ExternalReference {
281 external_reference_type: ExternalReferenceType::Bom,
282 url,
283 comment: Some("Comment".to_string()),
284 hashes: Some(Hashes(vec![])),
285 };
286
287 assert!(external_reference
288 .validate_version(SpecVersion::V1_5)
289 .passed());
290 assert!(external_reference
291 .validate_version(SpecVersion::V1_4)
292 .has_errors());
293 assert!(external_reference
294 .validate_version(SpecVersion::V1_3)
295 .has_errors());
296 }
297
298 #[test]
299 fn it_should_pass_validation() {
300 let validation_result = ExternalReferences(vec![
301 ExternalReference {
302 external_reference_type: ExternalReferenceType::Bom,
303 url: Uri::Url(Url("https://example.com".to_string())),
304 comment: Some("Comment".to_string()),
305 hashes: Some(Hashes(vec![])),
306 },
307 ExternalReference {
308 external_reference_type: ExternalReferenceType::Bom,
309 url: Uri::BomLink(BomLink(
310 "urn:cdx:f08a6ccd-4dce-4759-bd84-c626675d60a7/1".to_string(),
311 )),
312 comment: Some("Comment".to_string()),
313 hashes: Some(Hashes(vec![])),
314 },
315 ExternalReference {
316 external_reference_type: ExternalReferenceType::Bom,
317 url: Uri::BomLink(BomLink(
318 "urn:cdx:f08a6ccd-4dce-4759-bd84-c626675d60a7/1#componentA".to_string(),
319 )),
320 comment: Some("Comment".to_string()),
321 hashes: Some(Hashes(vec![])),
322 },
323 ])
324 .validate_version(SpecVersion::V1_5);
325
326 assert!(validation_result.passed());
327 }
328
329 #[test]
330 fn it_should_fail_validation() {
331 let validation_result = ExternalReferences(vec![
332 ExternalReference {
333 external_reference_type: ExternalReferenceType::UnknownExternalReferenceType(
334 "unknown reference type".to_string(),
335 ),
336 url: Uri::Url(Url("invalid uri".to_string())),
337 comment: Some("Comment".to_string()),
338 hashes: Some(Hashes(vec![Hash {
339 alg: crate::models::hash::HashAlgorithm::MD5,
340 content: HashValue("invalid hash".to_string()),
341 }])),
342 },
343 ExternalReference {
344 external_reference_type: ExternalReferenceType::UnknownExternalReferenceType(
345 "unknown reference type".to_string(),
346 ),
347 url: Uri::BomLink(BomLink("invalid bom-link".to_string())),
348 comment: Some("Comment".to_string()),
349 hashes: Some(Hashes(vec![Hash {
350 alg: crate::models::hash::HashAlgorithm::MD5,
351 content: HashValue("invalid hash".to_string()),
352 }])),
353 },
354 ])
355 .validate_version(SpecVersion::V1_5);
356
357 assert_eq!(
358 validation_result,
359 validation::list(
360 "inner",
361 [
362 (
363 0,
364 vec![
365 validation::field(
366 "external_reference_type",
367 "Unknown external reference type"
368 ),
369 validation::field("url", "Uri does not conform to RFC 3986"),
370 validation::list(
371 "hashes",
372 [(
373 0,
374 validation::list(
375 "inner",
376 [(
377 0,
378 validation::field(
379 "content",
380 "HashValue does not match regular expression"
381 )
382 )]
383 )
384 )]
385 )
386 ]
387 ),
388 (
389 1,
390 vec![
391 validation::field(
392 "external_reference_type",
393 "Unknown external reference type"
394 ),
395 validation::field("url", "Invalid BOM-Link"),
396 validation::list(
397 "hashes",
398 [(
399 0,
400 validation::list(
401 "inner",
402 [(
403 0,
404 validation::field(
405 "content",
406 "HashValue does not match regular expression"
407 )
408 )]
409 )
410 )]
411 )
412 ]
413 )
414 ]
415 )
416 );
417 }
418}