1use serde::{Deserialize, Serialize};
25use std::collections::BTreeMap;
26
27pub const OPENVEX_CONTEXT_V0_2_0: &str = "https://openvex.dev/ns/v0.2.0";
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct Document {
32 #[serde(rename = "@context")]
33 pub context: String,
34 #[serde(rename = "@id")]
35 pub id: String,
36 pub author: String,
37 #[serde(skip_serializing_if = "Option::is_none", default)]
39 pub role: Option<String>,
40 pub timestamp: String,
41 #[serde(skip_serializing_if = "Option::is_none", default)]
44 pub last_updated: Option<String>,
45 pub version: u32,
46 #[serde(skip_serializing_if = "Option::is_none", default)]
47 pub tooling: Option<String>,
48 pub statements: Vec<Statement>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub struct Statement {
55 #[serde(rename = "@id", skip_serializing_if = "Option::is_none", default)]
58 pub id: Option<String>,
59 pub vulnerability: Vulnerability,
60 #[serde(skip_serializing_if = "Option::is_none", default)]
68 pub timestamp: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none", default)]
71 pub last_updated: Option<String>,
72 pub products: Vec<Product>,
73 pub status: Status,
74 #[serde(skip_serializing_if = "Option::is_none", default)]
77 pub supplier: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none", default)]
81 pub justification: Option<Justification>,
82 #[serde(skip_serializing_if = "Option::is_none", default)]
84 pub impact_statement: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none", default)]
89 pub action_statement: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct Vulnerability {
96 pub name: String,
97 #[serde(skip_serializing_if = "Vec::is_empty", default)]
98 pub aliases: Vec<String>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
104pub struct Product {
105 #[serde(rename = "@id")]
106 pub id: String,
107 #[serde(skip_serializing_if = "Option::is_none", default)]
111 pub identifiers: Option<BTreeMap<String, String>>,
112 #[serde(skip_serializing_if = "Option::is_none", default)]
115 pub hashes: Option<BTreeMap<String, String>>,
116 #[serde(skip_serializing_if = "Vec::is_empty", default)]
117 pub subcomponents: Vec<Subcomponent>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
123pub struct Subcomponent {
124 #[serde(rename = "@id")]
125 pub id: String,
126 #[serde(skip_serializing_if = "Option::is_none", default)]
127 pub identifiers: Option<BTreeMap<String, String>>,
128 #[serde(skip_serializing_if = "Option::is_none", default)]
129 pub hashes: Option<BTreeMap<String, String>>,
130}
131
132#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
134#[serde(rename_all = "snake_case")]
135pub enum Status {
136 NotAffected,
137 Affected,
138 Fixed,
139 UnderInvestigation,
140}
141
142#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(rename_all = "snake_case")]
147pub enum Justification {
148 ComponentNotPresent,
149 VulnerableCodeNotPresent,
150 VulnerableCodeNotInExecutePath,
151 VulnerableCodeCannotBeControlledByAdversary,
152 InlineMitigationsAlreadyExist,
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 const STATUS_LITERALS: &[(Status, &str)] = &[
165 (Status::NotAffected, "not_affected"),
166 (Status::Affected, "affected"),
167 (Status::Fixed, "fixed"),
168 (Status::UnderInvestigation, "under_investigation"),
169 ];
170
171 #[test]
172 fn every_status_variant_serializes_to_spec_literal() {
173 for (variant, literal) in STATUS_LITERALS {
174 let json = serde_json::to_string(variant).unwrap();
175 assert_eq!(json, format!("\"{literal}\""), "variant {variant:?}");
176 }
177 }
178
179 #[test]
180 fn every_status_variant_deserializes_from_spec_literal() {
181 for (variant, literal) in STATUS_LITERALS {
182 let parsed: Status = serde_json::from_str(&format!("\"{literal}\"")).unwrap();
183 assert_eq!(parsed, *variant, "literal {literal:?}");
184 }
185 }
186
187 #[test]
188 fn status_rejects_unknown_literal() {
189 let r: Result<Status, _> = serde_json::from_str("\"pending\"");
190 assert!(r.is_err(), "unknown status literal must fail to parse");
191 }
192
193 const JUSTIFICATION_LITERALS: &[(Justification, &str)] = &[
196 (Justification::ComponentNotPresent, "component_not_present"),
197 (
198 Justification::VulnerableCodeNotPresent,
199 "vulnerable_code_not_present",
200 ),
201 (
202 Justification::VulnerableCodeNotInExecutePath,
203 "vulnerable_code_not_in_execute_path",
204 ),
205 (
206 Justification::VulnerableCodeCannotBeControlledByAdversary,
207 "vulnerable_code_cannot_be_controlled_by_adversary",
208 ),
209 (
210 Justification::InlineMitigationsAlreadyExist,
211 "inline_mitigations_already_exist",
212 ),
213 ];
214
215 #[test]
216 fn every_justification_variant_serializes_to_spec_literal() {
217 for (variant, literal) in JUSTIFICATION_LITERALS {
218 let json = serde_json::to_string(variant).unwrap();
219 assert_eq!(json, format!("\"{literal}\""), "variant {variant:?}");
220 }
221 }
222
223 #[test]
224 fn every_justification_variant_deserializes_from_spec_literal() {
225 for (variant, literal) in JUSTIFICATION_LITERALS {
226 let parsed: Justification = serde_json::from_str(&format!("\"{literal}\"")).unwrap();
227 assert_eq!(parsed, *variant, "literal {literal:?}");
228 }
229 }
230
231 #[test]
232 fn justification_rejects_unknown_literal() {
233 let r: Result<Justification, _> = serde_json::from_str("\"hand_waving\"");
234 assert!(r.is_err());
235 }
236
237 fn empty_doc() -> Document {
240 Document {
241 context: OPENVEX_CONTEXT_V0_2_0.to_string(),
242 id: "urn:uuid:1111".to_string(),
243 author: "Socket".to_string(),
244 role: None,
245 timestamp: "2024-01-01T00:00:00Z".to_string(),
246 last_updated: None,
247 version: 1,
248 tooling: None,
249 statements: Vec::new(),
250 }
251 }
252
253 #[test]
254 fn document_renames_context_and_id() {
255 let v = serde_json::to_value(empty_doc()).unwrap();
256 assert_eq!(v["@context"], OPENVEX_CONTEXT_V0_2_0);
257 assert_eq!(v["@id"], "urn:uuid:1111");
258 let obj = v.as_object().unwrap();
259 assert!(obj.get("context").is_none(), "raw `context` must not leak");
260 assert!(obj.get("id").is_none(), "raw `id` must not leak");
261 }
262
263 #[test]
264 fn document_omits_all_optional_fields_when_none() {
265 let v = serde_json::to_value(empty_doc()).unwrap();
266 let obj = v.as_object().unwrap();
267 for key in ["role", "last_updated", "tooling"] {
268 assert!(
269 !obj.contains_key(key),
270 "key {key:?} must be omitted when None"
271 );
272 }
273 }
274
275 #[test]
276 fn document_emits_optional_fields_when_some() {
277 let mut doc = empty_doc();
278 doc.role = Some("publisher".to_string());
279 doc.last_updated = Some("2024-02-01T00:00:00Z".to_string());
280 doc.tooling = Some("socket-patch 3.0.0".to_string());
281
282 let v = serde_json::to_value(&doc).unwrap();
283 assert_eq!(v["role"], "publisher");
284 assert_eq!(v["last_updated"], "2024-02-01T00:00:00Z");
285 assert_eq!(v["tooling"], "socket-patch 3.0.0");
286 }
287
288 #[test]
289 fn document_version_round_trips_arbitrary_u32() {
290 for v in [1u32, 2, 7, 42, u32::MAX] {
291 let mut doc = empty_doc();
292 doc.version = v;
293 let json = serde_json::to_string(&doc).unwrap();
294 let parsed: Document = serde_json::from_str(&json).unwrap();
295 assert_eq!(parsed.version, v);
296 }
297 }
298
299 #[test]
300 fn document_rejects_missing_required_fields() {
301 let bad = r#"{
303 "@id": "urn:uuid:1",
304 "author": "Socket",
305 "timestamp": "2024-01-01T00:00:00Z",
306 "version": 1,
307 "statements": []
308 }"#;
309 let r: Result<Document, _> = serde_json::from_str(bad);
310 assert!(r.is_err());
311 }
312
313 fn minimal_statement() -> Statement {
316 Statement {
317 id: None,
318 vulnerability: Vulnerability {
319 name: "GHSA-xxxx".to_string(),
320 aliases: Vec::new(),
321 },
322 timestamp: Some("2024-01-01T00:00:00Z".to_string()),
323 last_updated: None,
324 products: vec![Product {
325 id: "pkg:npm/app@1.0.0".to_string(),
326 identifiers: None,
327 hashes: None,
328 subcomponents: Vec::new(),
329 }],
330 status: Status::NotAffected,
331 supplier: None,
332 justification: None,
333 impact_statement: None,
334 action_statement: None,
335 }
336 }
337
338 #[test]
339 fn statement_omits_all_optional_fields_when_none() {
340 let v = serde_json::to_value(minimal_statement()).unwrap();
341 let obj = v.as_object().unwrap();
342 for key in [
343 "@id",
344 "last_updated",
345 "supplier",
346 "justification",
347 "impact_statement",
348 "action_statement",
349 ] {
350 assert!(
351 !obj.contains_key(key),
352 "key {key:?} must be omitted when None"
353 );
354 }
355 assert!(
357 v["vulnerability"]
358 .as_object()
359 .unwrap()
360 .get("aliases")
361 .is_none(),
362 "empty aliases must omit the key"
363 );
364 }
365
366 #[test]
367 fn statement_emits_id_under_at_prefix_and_other_optional_fields() {
368 let mut s = minimal_statement();
369 s.id = Some("urn:uuid:stmt-1".to_string());
370 s.last_updated = Some("2024-02-01T00:00:00Z".to_string());
371 s.supplier = Some("https://example.com/supplier".to_string());
372 s.justification = Some(Justification::InlineMitigationsAlreadyExist);
373 s.impact_statement = Some("Patched via Socket".to_string());
374 s.action_statement = Some("Apply socket-patch <uuid>".to_string());
375
376 let v = serde_json::to_value(&s).unwrap();
377 assert_eq!(v["@id"], "urn:uuid:stmt-1");
379 assert!(v.as_object().unwrap().get("id").is_none());
380
381 assert_eq!(v["last_updated"], "2024-02-01T00:00:00Z");
382 assert_eq!(v["supplier"], "https://example.com/supplier");
383 assert_eq!(v["justification"], "inline_mitigations_already_exist");
384 assert_eq!(v["impact_statement"], "Patched via Socket");
385 assert_eq!(v["action_statement"], "Apply socket-patch <uuid>");
386 }
387
388 #[test]
389 fn statement_with_both_justification_and_impact_emits_both_keys() {
390 let mut s = minimal_statement();
391 s.justification = Some(Justification::ComponentNotPresent);
392 s.impact_statement = Some("Component is not bundled".to_string());
393 let v = serde_json::to_value(&s).unwrap();
394 assert_eq!(v["justification"], "component_not_present");
395 assert_eq!(v["impact_statement"], "Component is not bundled");
396 }
397
398 #[test]
401 fn vulnerability_with_zero_aliases_omits_key() {
402 let v = serde_json::to_value(Vulnerability {
403 name: "GHSA-x".to_string(),
404 aliases: Vec::new(),
405 })
406 .unwrap();
407 assert!(v.as_object().unwrap().get("aliases").is_none());
408 assert_eq!(v["name"], "GHSA-x");
409 }
410
411 #[test]
412 fn vulnerability_with_one_alias() {
413 let v = serde_json::to_value(Vulnerability {
414 name: "GHSA-x".to_string(),
415 aliases: vec!["CVE-2024-1".to_string()],
416 })
417 .unwrap();
418 let arr = v["aliases"].as_array().unwrap();
419 assert_eq!(arr.len(), 1);
420 assert_eq!(arr[0], "CVE-2024-1");
421 }
422
423 #[test]
424 fn vulnerability_with_many_aliases_preserves_order() {
425 let aliases = vec![
428 "CVE-Z".to_string(),
429 "CVE-A".to_string(),
430 "CVE-M".to_string(),
431 ];
432 let v = serde_json::to_value(Vulnerability {
433 name: "GHSA-x".to_string(),
434 aliases: aliases.clone(),
435 })
436 .unwrap();
437 let arr = v["aliases"].as_array().unwrap();
438 assert_eq!(arr.len(), 3);
439 for (i, want) in aliases.iter().enumerate() {
440 assert_eq!(arr[i], *want);
441 }
442 }
443
444 #[test]
447 fn product_renames_id_and_omits_empty_subcomponents() {
448 let p = Product {
449 id: "pkg:npm/app@1.0.0".to_string(),
450 identifiers: None,
451 hashes: None,
452 subcomponents: Vec::new(),
453 };
454 let v = serde_json::to_value(&p).unwrap();
455 assert_eq!(v["@id"], "pkg:npm/app@1.0.0");
456 let obj = v.as_object().unwrap();
457 assert!(obj.get("subcomponents").is_none());
458 assert!(obj.get("identifiers").is_none());
459 assert!(obj.get("hashes").is_none());
460 }
461
462 #[test]
463 fn product_serializes_identifiers_and_hashes_when_set() {
464 let mut idents = BTreeMap::new();
465 idents.insert("purl".to_string(), "pkg:npm/app@1.0.0".to_string());
466 idents.insert("cpe23".to_string(), "cpe:2.3:a:foo:bar:1.0".to_string());
467
468 let mut hashes = BTreeMap::new();
469 hashes.insert("sha256".to_string(), "deadbeef".to_string());
470
471 let p = Product {
472 id: "pkg:npm/app@1.0.0".to_string(),
473 identifiers: Some(idents),
474 hashes: Some(hashes),
475 subcomponents: Vec::new(),
476 };
477 let v = serde_json::to_value(&p).unwrap();
478 assert_eq!(v["identifiers"]["cpe23"], "cpe:2.3:a:foo:bar:1.0");
480 assert_eq!(v["identifiers"]["purl"], "pkg:npm/app@1.0.0");
481 assert_eq!(v["hashes"]["sha256"], "deadbeef");
482 }
483
484 #[test]
485 fn product_serializes_subcomponents_in_input_order() {
486 let p = Product {
487 id: "pkg:npm/app@1.0.0".to_string(),
488 identifiers: None,
489 hashes: None,
490 subcomponents: vec![
491 Subcomponent {
492 id: "pkg:npm/z@1.0".to_string(),
493 identifiers: None,
494 hashes: None,
495 },
496 Subcomponent {
497 id: "pkg:npm/a@1.0".to_string(),
498 identifiers: None,
499 hashes: None,
500 },
501 ],
502 };
503 let v = serde_json::to_value(&p).unwrap();
504 let arr = v["subcomponents"].as_array().unwrap();
505 assert_eq!(arr.len(), 2);
506 assert_eq!(arr[0]["@id"], "pkg:npm/z@1.0");
507 assert_eq!(arr[1]["@id"], "pkg:npm/a@1.0");
508 }
509
510 #[test]
511 fn subcomponent_with_identifiers_and_hashes_round_trips() {
512 let mut idents = BTreeMap::new();
513 idents.insert("purl".to_string(), "pkg:npm/lodash@4.17.21".to_string());
514 let mut hashes = BTreeMap::new();
515 hashes.insert("sha256".to_string(), "abc123".to_string());
516
517 let sub = Subcomponent {
518 id: "pkg:npm/lodash@4.17.21".to_string(),
519 identifiers: Some(idents),
520 hashes: Some(hashes),
521 };
522 let json = serde_json::to_string(&sub).unwrap();
523 let parsed: Subcomponent = serde_json::from_str(&json).unwrap();
524 assert_eq!(sub, parsed);
525 }
526
527 #[test]
530 fn document_roundtrips_minimal() {
531 let doc = empty_doc();
532 let json = serde_json::to_string(&doc).unwrap();
533 let parsed: Document = serde_json::from_str(&json).unwrap();
534 assert_eq!(doc, parsed);
535 }
536
537 #[test]
538 fn document_roundtrips_with_all_fields_populated() {
539 let mut idents = BTreeMap::new();
540 idents.insert("purl".to_string(), "pkg:npm/app@1.0.0".to_string());
541 let mut hashes = BTreeMap::new();
542 hashes.insert("sha256".to_string(), "deadbeef".to_string());
543
544 let doc = Document {
545 context: OPENVEX_CONTEXT_V0_2_0.to_string(),
546 id: "urn:uuid:abc".to_string(),
547 author: "Socket".to_string(),
548 role: Some("publisher".to_string()),
549 timestamp: "2024-01-01T00:00:00Z".to_string(),
550 last_updated: Some("2024-06-01T00:00:00Z".to_string()),
551 version: 3,
552 tooling: Some("socket-patch 3.0.0".to_string()),
553 statements: vec![Statement {
554 id: Some("urn:uuid:stmt-1".to_string()),
555 vulnerability: Vulnerability {
556 name: "GHSA-xxx".to_string(),
557 aliases: vec!["CVE-2024-0001".to_string()],
558 },
559 timestamp: Some("2024-01-01T00:00:00Z".to_string()),
560 last_updated: Some("2024-06-01T00:00:00Z".to_string()),
561 products: vec![Product {
562 id: "pkg:npm/app@1.0.0".to_string(),
563 identifiers: Some(idents.clone()),
564 hashes: Some(hashes.clone()),
565 subcomponents: vec![Subcomponent {
566 id: "pkg:npm/lodash@4.17.21".to_string(),
567 identifiers: Some(idents.clone()),
568 hashes: Some(hashes.clone()),
569 }],
570 }],
571 status: Status::NotAffected,
572 supplier: Some("https://example.com/supplier".to_string()),
573 justification: Some(Justification::InlineMitigationsAlreadyExist),
574 impact_statement: Some("Patched via Socket".to_string()),
575 action_statement: Some("Apply socket-patch <uuid>".to_string()),
576 }],
577 };
578 let json = serde_json::to_string_pretty(&doc).unwrap();
579 let parsed: Document = serde_json::from_str(&json).unwrap();
580 assert_eq!(doc, parsed);
581 }
582
583 #[test]
584 fn parsing_a_doc_without_optional_fields_succeeds_via_default() {
585 let minimal = r#"{
588 "@context": "https://openvex.dev/ns/v0.2.0",
589 "@id": "urn:uuid:1",
590 "author": "Socket",
591 "timestamp": "2024-01-01T00:00:00Z",
592 "version": 1,
593 "statements": [
594 {
595 "vulnerability": {"name": "GHSA-x"},
596 "timestamp": "2024-01-01T00:00:00Z",
597 "products": [{"@id": "pkg:npm/app@1.0.0"}],
598 "status": "not_affected"
599 }
600 ]
601 }"#;
602 let doc: Document = serde_json::from_str(minimal).unwrap();
603 assert!(doc.role.is_none());
604 assert!(doc.last_updated.is_none());
605 assert!(doc.tooling.is_none());
606 let st = &doc.statements[0];
607 assert!(st.id.is_none());
608 assert!(st.last_updated.is_none());
609 assert!(st.supplier.is_none());
610 assert!(st.action_statement.is_none());
611 }
612
613 #[test]
622 fn statement_without_timestamp_parses_and_leaves_it_none() {
623 let doc_json = r#"{
624 "@context": "https://openvex.dev/ns/v0.2.0",
625 "@id": "urn:uuid:1",
626 "author": "Socket",
627 "timestamp": "2024-01-01T00:00:00Z",
628 "version": 1,
629 "statements": [
630 {
631 "vulnerability": {"name": "CVE-2014-123456"},
632 "products": [{"@id": "pkg:apk/wolfi/bash@1.0.0"}],
633 "status": "fixed"
634 }
635 ]
636 }"#;
637 let doc: Document =
638 serde_json::from_str(doc_json).expect("statement may omit timestamp (inherited)");
639 assert_eq!(doc.statements.len(), 1);
640 assert!(
641 doc.statements[0].timestamp.is_none(),
642 "omitted statement timestamp must deserialize to None, not error"
643 );
644 }
645
646 #[test]
650 fn statement_timestamp_some_emits_none_omits() {
651 let mut s = minimal_statement(); let v = serde_json::to_value(&s).unwrap();
653 assert_eq!(v["timestamp"], "2024-01-01T00:00:00Z");
654
655 s.timestamp = None;
656 let v = serde_json::to_value(&s).unwrap();
657 assert!(
658 v.as_object().unwrap().get("timestamp").is_none(),
659 "None timestamp must be omitted, never serialized as null/empty"
660 );
661 }
662
663 #[test]
673 fn parsing_tolerates_unmodeled_spec_fields() {
674 let doc_json = r#"{
675 "@context": "https://openvex.dev/ns/v0.2.0",
676 "@id": "urn:uuid:1",
677 "author": "Socket",
678 "timestamp": "2024-01-01T00:00:00Z",
679 "version": 1,
680 "extra_doc_field": "ignored",
681 "statements": [
682 {
683 "@id": "urn:uuid:stmt-1",
684 "version": 2,
685 "vulnerability": {
686 "@id": "https://nvd.example/CVE-2024-1",
687 "name": "GHSA-x",
688 "description": "an unmodeled field",
689 "aliases": ["CVE-2024-1"]
690 },
691 "timestamp": "2024-01-01T00:00:00Z",
692 "status_notes": "determined by hand",
693 "products": [{
694 "@id": "pkg:npm/app@1.0.0",
695 "subcomponents": [{"@id": "pkg:npm/lodash@4.17.21"}]
696 }],
697 "status": "not_affected",
698 "justification": "inline_mitigations_already_exist",
699 "action_statement_timestamp": "2024-01-02T00:00:00Z"
700 }
701 ]
702 }"#;
703 let doc: Document =
704 serde_json::from_str(doc_json).expect("unmodeled spec fields must be ignored");
705 assert_eq!(doc.statements.len(), 1);
706 let st = &doc.statements[0];
707 assert_eq!(st.vulnerability.name, "GHSA-x");
708 assert_eq!(st.vulnerability.aliases, vec!["CVE-2024-1".to_string()]);
709 assert_eq!(st.status, Status::NotAffected);
710 assert_eq!(st.products[0].subcomponents[0].id, "pkg:npm/lodash@4.17.21");
711 }
712
713 #[test]
722 fn statement_multiword_keys_emit_in_snake_case() {
723 let mut s = minimal_statement();
724 s.last_updated = Some("2024-02-01T00:00:00Z".to_string());
725 s.impact_statement = Some("x".to_string());
726 s.action_statement = Some("y".to_string());
727 let v = serde_json::to_value(&s).unwrap();
728 let obj = v.as_object().unwrap();
729 for snake in ["last_updated", "impact_statement", "action_statement"] {
730 assert!(obj.contains_key(snake), "missing snake_case key {snake:?}");
731 }
732 for camel in ["lastUpdated", "impactStatement", "actionStatement"] {
733 assert!(
734 !obj.contains_key(camel),
735 "camelCase key {camel:?} must never be emitted"
736 );
737 }
738 }
739}