1use serde::{Deserialize, Serialize};
13use std::fmt;
14use std::hash::{Hash, Hasher};
15
16#[derive(Debug, Clone, Eq, Serialize, Deserialize)]
22pub struct CanonicalId {
23 value: String,
25 source: IdSource,
27 #[serde(default)]
29 stable: bool,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
34pub enum IdSource {
35 Purl,
37 Cpe,
39 Swid,
41 NameVersion,
43 Synthetic,
45 FormatSpecific,
47}
48
49impl IdSource {
50 pub fn is_stable(&self) -> bool {
52 matches!(
53 self,
54 IdSource::Purl
55 | IdSource::Cpe
56 | IdSource::Swid
57 | IdSource::NameVersion
58 | IdSource::Synthetic
59 )
60 }
61
62 pub fn reliability_rank(&self) -> u8 {
64 match self {
65 IdSource::Purl => 0,
66 IdSource::Cpe => 1,
67 IdSource::Swid => 2,
68 IdSource::NameVersion => 3,
69 IdSource::Synthetic => 4,
70 IdSource::FormatSpecific => 5,
71 }
72 }
73}
74
75impl CanonicalId {
76 pub fn from_purl(purl: &str) -> Self {
78 Self {
79 value: Self::normalize_purl(purl),
80 source: IdSource::Purl,
81 stable: true,
82 }
83 }
84
85 pub fn from_name_version(name: &str, version: Option<&str>) -> Self {
87 let value = match version {
88 Some(v) => format!("{}@{}", name.to_lowercase(), v),
89 None => name.to_lowercase(),
90 };
91 Self {
92 value,
93 source: IdSource::NameVersion,
94 stable: true,
95 }
96 }
97
98 pub fn synthetic(group: Option<&str>, name: &str, version: Option<&str>) -> Self {
103 let value = match (group, version) {
104 (Some(g), Some(v)) => format!("{}:{}@{}", g.to_lowercase(), name.to_lowercase(), v),
105 (Some(g), None) => format!("{}:{}", g.to_lowercase(), name.to_lowercase()),
106 (None, Some(v)) => format!("{}@{}", name.to_lowercase(), v),
107 (None, None) => name.to_lowercase(),
108 };
109 Self {
110 value,
111 source: IdSource::Synthetic,
112 stable: true,
113 }
114 }
115
116 pub fn from_format_id(id: &str) -> Self {
121 let looks_like_uuid = id.len() == 36
123 && id.chars().filter(|c| *c == '-').count() == 4
124 && id.chars().all(|c| c.is_ascii_hexdigit() || c == '-');
125
126 Self {
127 value: id.to_string(),
128 source: IdSource::FormatSpecific,
129 stable: !looks_like_uuid,
130 }
131 }
132
133 pub fn from_cpe(cpe: &str) -> Self {
135 Self {
136 value: cpe.to_lowercase(),
137 source: IdSource::Cpe,
138 stable: true,
139 }
140 }
141
142 pub fn from_swid(swid: &str) -> Self {
144 Self {
145 value: swid.to_string(),
146 source: IdSource::Swid,
147 stable: true,
148 }
149 }
150
151 pub fn value(&self) -> &str {
153 &self.value
154 }
155
156 pub fn source(&self) -> &IdSource {
158 &self.source
159 }
160
161 pub fn is_stable(&self) -> bool {
163 self.stable
164 }
165
166 fn normalize_purl(purl: &str) -> String {
168 let mut normalized = purl.to_lowercase();
170
171 if normalized.starts_with("pkg:pypi/") {
173 normalized = normalized.replace(['_', '.'], "-");
175 } else if normalized.starts_with("pkg:npm/") {
176 normalized = normalized.replace("%40", "@");
178 }
179
180 normalized
181 }
182}
183
184impl PartialEq for CanonicalId {
185 fn eq(&self, other: &Self) -> bool {
186 self.value == other.value
187 }
188}
189
190impl Hash for CanonicalId {
191 fn hash<H: Hasher>(&self, state: &mut H) {
192 self.value.hash(state);
193 }
194}
195
196impl fmt::Display for CanonicalId {
197 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198 write!(f, "{}", self.value)
199 }
200}
201
202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
204pub struct ComponentIdentifiers {
205 pub purl: Option<String>,
207 pub cpe: Vec<String>,
209 pub swid: Option<String>,
211 pub format_id: String,
213 pub aliases: Vec<String>,
215}
216
217#[derive(Debug, Clone)]
219pub struct CanonicalIdResult {
220 pub id: CanonicalId,
222 pub warning: Option<String>,
224}
225
226impl ComponentIdentifiers {
227 pub fn new(format_id: String) -> Self {
229 Self {
230 format_id,
231 ..Default::default()
232 }
233 }
234
235 pub fn canonical_id(&self) -> CanonicalId {
240 if let Some(purl) = &self.purl {
242 CanonicalId::from_purl(purl)
243 } else if let Some(cpe) = self.cpe.first() {
244 CanonicalId::from_cpe(cpe)
245 } else if let Some(swid) = &self.swid {
246 CanonicalId::from_swid(swid)
247 } else {
248 CanonicalId::from_format_id(&self.format_id)
249 }
250 }
251
252 pub fn canonical_id_with_context(
263 &self,
264 name: &str,
265 version: Option<&str>,
266 group: Option<&str>,
267 ) -> CanonicalIdResult {
268 if let Some(purl) = &self.purl {
270 return CanonicalIdResult {
271 id: CanonicalId::from_purl(purl),
272 warning: None,
273 };
274 }
275
276 if let Some(cpe) = self.cpe.first() {
278 return CanonicalIdResult {
279 id: CanonicalId::from_cpe(cpe),
280 warning: None,
281 };
282 }
283
284 if let Some(swid) = &self.swid {
286 return CanonicalIdResult {
287 id: CanonicalId::from_swid(swid),
288 warning: None,
289 };
290 }
291
292 if !name.is_empty() {
295 return CanonicalIdResult {
296 id: CanonicalId::synthetic(group, name, version),
297 warning: Some(format!(
298 "Component '{}' lacks PURL/CPE/SWID identifiers; using synthetic ID. \
299 Consider enriching SBOM with package URLs for accurate diffing.",
300 name
301 )),
302 };
303 }
304
305 let id = CanonicalId::from_format_id(&self.format_id);
307 let warning = if !id.is_stable() {
308 Some(format!(
309 "Component uses unstable format-specific ID '{}'. \
310 This may cause inaccurate diff results across SBOM regenerations.",
311 self.format_id
312 ))
313 } else {
314 Some(format!(
315 "Component uses format-specific ID '{}' without standard identifiers.",
316 self.format_id
317 ))
318 };
319
320 CanonicalIdResult { id, warning }
321 }
322
323 pub fn has_stable_id(&self) -> bool {
325 self.purl.is_some() || !self.cpe.is_empty() || self.swid.is_some()
326 }
327
328 pub fn id_reliability(&self) -> IdReliability {
330 if self.purl.is_some() {
331 IdReliability::High
332 } else if !self.cpe.is_empty() || self.swid.is_some() {
333 IdReliability::Medium
334 } else {
335 IdReliability::Low
336 }
337 }
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
342pub enum IdReliability {
343 High,
345 Medium,
347 Low,
349}
350
351impl fmt::Display for IdReliability {
352 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
353 match self {
354 IdReliability::High => write!(f, "high"),
355 IdReliability::Medium => write!(f, "medium"),
356 IdReliability::Low => write!(f, "low"),
357 }
358 }
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
363pub enum Ecosystem {
364 Npm,
365 PyPi,
366 Cargo,
367 Maven,
368 Golang,
369 Nuget,
370 RubyGems,
371 Composer,
372 CocoaPods,
373 Swift,
374 Hex,
375 Pub,
376 Hackage,
377 Cpan,
378 Cran,
379 Conda,
380 Conan,
381 Deb,
382 Rpm,
383 Apk,
384 Generic,
385 Unknown(String),
386}
387
388impl Ecosystem {
389 pub fn from_purl_type(purl_type: &str) -> Self {
391 match purl_type.to_lowercase().as_str() {
392 "npm" => Ecosystem::Npm,
393 "pypi" => Ecosystem::PyPi,
394 "cargo" => Ecosystem::Cargo,
395 "maven" => Ecosystem::Maven,
396 "golang" | "go" => Ecosystem::Golang,
397 "nuget" => Ecosystem::Nuget,
398 "gem" => Ecosystem::RubyGems,
399 "composer" => Ecosystem::Composer,
400 "cocoapods" => Ecosystem::CocoaPods,
401 "swift" => Ecosystem::Swift,
402 "hex" => Ecosystem::Hex,
403 "pub" => Ecosystem::Pub,
404 "hackage" => Ecosystem::Hackage,
405 "cpan" => Ecosystem::Cpan,
406 "cran" => Ecosystem::Cran,
407 "conda" => Ecosystem::Conda,
408 "conan" => Ecosystem::Conan,
409 "deb" => Ecosystem::Deb,
410 "rpm" => Ecosystem::Rpm,
411 "apk" => Ecosystem::Apk,
412 "generic" => Ecosystem::Generic,
413 other => Ecosystem::Unknown(other.to_string()),
414 }
415 }
416}
417
418impl fmt::Display for Ecosystem {
419 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420 match self {
421 Ecosystem::Npm => write!(f, "npm"),
422 Ecosystem::PyPi => write!(f, "pypi"),
423 Ecosystem::Cargo => write!(f, "cargo"),
424 Ecosystem::Maven => write!(f, "maven"),
425 Ecosystem::Golang => write!(f, "golang"),
426 Ecosystem::Nuget => write!(f, "nuget"),
427 Ecosystem::RubyGems => write!(f, "gem"),
428 Ecosystem::Composer => write!(f, "composer"),
429 Ecosystem::CocoaPods => write!(f, "cocoapods"),
430 Ecosystem::Swift => write!(f, "swift"),
431 Ecosystem::Hex => write!(f, "hex"),
432 Ecosystem::Pub => write!(f, "pub"),
433 Ecosystem::Hackage => write!(f, "hackage"),
434 Ecosystem::Cpan => write!(f, "cpan"),
435 Ecosystem::Cran => write!(f, "cran"),
436 Ecosystem::Conda => write!(f, "conda"),
437 Ecosystem::Conan => write!(f, "conan"),
438 Ecosystem::Deb => write!(f, "deb"),
439 Ecosystem::Rpm => write!(f, "rpm"),
440 Ecosystem::Apk => write!(f, "apk"),
441 Ecosystem::Generic => write!(f, "generic"),
442 Ecosystem::Unknown(s) => write!(f, "{}", s),
443 }
444 }
445}
446
447#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
464pub struct ComponentRef {
465 id: CanonicalId,
467 name: String,
469 #[serde(skip_serializing_if = "Option::is_none")]
471 version: Option<String>,
472}
473
474impl ComponentRef {
475 pub fn new(id: CanonicalId, name: impl Into<String>) -> Self {
477 Self {
478 id,
479 name: name.into(),
480 version: None,
481 }
482 }
483
484 pub fn with_version(id: CanonicalId, name: impl Into<String>, version: Option<String>) -> Self {
486 Self {
487 id,
488 name: name.into(),
489 version,
490 }
491 }
492
493 pub fn from_component(component: &super::Component) -> Self {
495 Self {
496 id: component.canonical_id.clone(),
497 name: component.name.clone(),
498 version: component.version.clone(),
499 }
500 }
501
502 pub fn id(&self) -> &CanonicalId {
504 &self.id
505 }
506
507 pub fn id_str(&self) -> &str {
509 self.id.value()
510 }
511
512 pub fn name(&self) -> &str {
514 &self.name
515 }
516
517 pub fn version(&self) -> Option<&str> {
519 self.version.as_deref()
520 }
521
522 pub fn display_with_version(&self) -> String {
524 match &self.version {
525 Some(v) => format!("{}@{}", self.name, v),
526 None => self.name.clone(),
527 }
528 }
529
530 pub fn matches_id(&self, id: &CanonicalId) -> bool {
532 &self.id == id
533 }
534
535 pub fn matches_id_str(&self, id_str: &str) -> bool {
537 self.id.value() == id_str
538 }
539}
540
541impl fmt::Display for ComponentRef {
542 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
543 write!(f, "{}", self.name)
544 }
545}
546
547impl From<&super::Component> for ComponentRef {
548 fn from(component: &super::Component) -> Self {
549 Self::from_component(component)
550 }
551}
552
553#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
555pub struct VulnerabilityRef2 {
556 pub vuln_id: String,
558 pub component: ComponentRef,
560}
561
562impl VulnerabilityRef2 {
563 pub fn new(vuln_id: impl Into<String>, component: ComponentRef) -> Self {
565 Self {
566 vuln_id: vuln_id.into(),
567 component,
568 }
569 }
570
571 pub fn component_id(&self) -> &CanonicalId {
573 self.component.id()
574 }
575
576 pub fn component_name(&self) -> &str {
578 self.component.name()
579 }
580}