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 pub timestamp: String,
61 #[serde(skip_serializing_if = "Option::is_none", default)]
63 pub last_updated: Option<String>,
64 pub products: Vec<Product>,
65 pub status: Status,
66 #[serde(skip_serializing_if = "Option::is_none", default)]
69 pub supplier: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none", default)]
73 pub justification: Option<Justification>,
74 #[serde(skip_serializing_if = "Option::is_none", default)]
76 pub impact_statement: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none", default)]
81 pub action_statement: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
87pub struct Vulnerability {
88 pub name: String,
89 #[serde(skip_serializing_if = "Vec::is_empty", default)]
90 pub aliases: Vec<String>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct Product {
97 #[serde(rename = "@id")]
98 pub id: String,
99 #[serde(skip_serializing_if = "Option::is_none", default)]
103 pub identifiers: Option<BTreeMap<String, String>>,
104 #[serde(skip_serializing_if = "Option::is_none", default)]
107 pub hashes: Option<BTreeMap<String, String>>,
108 #[serde(skip_serializing_if = "Vec::is_empty", default)]
109 pub subcomponents: Vec<Subcomponent>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115pub struct Subcomponent {
116 #[serde(rename = "@id")]
117 pub id: String,
118 #[serde(skip_serializing_if = "Option::is_none", default)]
119 pub identifiers: Option<BTreeMap<String, String>>,
120 #[serde(skip_serializing_if = "Option::is_none", default)]
121 pub hashes: Option<BTreeMap<String, String>>,
122}
123
124#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "snake_case")]
127pub enum Status {
128 NotAffected,
129 Affected,
130 Fixed,
131 UnderInvestigation,
132}
133
134#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
138#[serde(rename_all = "snake_case")]
139pub enum Justification {
140 ComponentNotPresent,
141 VulnerableCodeNotPresent,
142 VulnerableCodeNotInExecutePath,
143 VulnerableCodeCannotBeControlledByAdversary,
144 InlineMitigationsAlreadyExist,
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 const STATUS_LITERALS: &[(Status, &str)] = &[
157 (Status::NotAffected, "not_affected"),
158 (Status::Affected, "affected"),
159 (Status::Fixed, "fixed"),
160 (Status::UnderInvestigation, "under_investigation"),
161 ];
162
163 #[test]
164 fn every_status_variant_serializes_to_spec_literal() {
165 for (variant, literal) in STATUS_LITERALS {
166 let json = serde_json::to_string(variant).unwrap();
167 assert_eq!(json, format!("\"{literal}\""), "variant {variant:?}");
168 }
169 }
170
171 #[test]
172 fn every_status_variant_deserializes_from_spec_literal() {
173 for (variant, literal) in STATUS_LITERALS {
174 let parsed: Status =
175 serde_json::from_str(&format!("\"{literal}\"")).unwrap();
176 assert_eq!(parsed, *variant, "literal {literal:?}");
177 }
178 }
179
180 #[test]
181 fn status_rejects_unknown_literal() {
182 let r: Result<Status, _> = serde_json::from_str("\"pending\"");
183 assert!(r.is_err(), "unknown status literal must fail to parse");
184 }
185
186 const JUSTIFICATION_LITERALS: &[(Justification, &str)] = &[
189 (Justification::ComponentNotPresent, "component_not_present"),
190 (
191 Justification::VulnerableCodeNotPresent,
192 "vulnerable_code_not_present",
193 ),
194 (
195 Justification::VulnerableCodeNotInExecutePath,
196 "vulnerable_code_not_in_execute_path",
197 ),
198 (
199 Justification::VulnerableCodeCannotBeControlledByAdversary,
200 "vulnerable_code_cannot_be_controlled_by_adversary",
201 ),
202 (
203 Justification::InlineMitigationsAlreadyExist,
204 "inline_mitigations_already_exist",
205 ),
206 ];
207
208 #[test]
209 fn every_justification_variant_serializes_to_spec_literal() {
210 for (variant, literal) in JUSTIFICATION_LITERALS {
211 let json = serde_json::to_string(variant).unwrap();
212 assert_eq!(json, format!("\"{literal}\""), "variant {variant:?}");
213 }
214 }
215
216 #[test]
217 fn every_justification_variant_deserializes_from_spec_literal() {
218 for (variant, literal) in JUSTIFICATION_LITERALS {
219 let parsed: Justification =
220 serde_json::from_str(&format!("\"{literal}\"")).unwrap();
221 assert_eq!(parsed, *variant, "literal {literal:?}");
222 }
223 }
224
225 #[test]
226 fn justification_rejects_unknown_literal() {
227 let r: Result<Justification, _> =
228 serde_json::from_str("\"hand_waving\"");
229 assert!(r.is_err());
230 }
231
232 fn empty_doc() -> Document {
235 Document {
236 context: OPENVEX_CONTEXT_V0_2_0.to_string(),
237 id: "urn:uuid:1111".to_string(),
238 author: "Socket".to_string(),
239 role: None,
240 timestamp: "2024-01-01T00:00:00Z".to_string(),
241 last_updated: None,
242 version: 1,
243 tooling: None,
244 statements: Vec::new(),
245 }
246 }
247
248 #[test]
249 fn document_renames_context_and_id() {
250 let v = serde_json::to_value(empty_doc()).unwrap();
251 assert_eq!(v["@context"], OPENVEX_CONTEXT_V0_2_0);
252 assert_eq!(v["@id"], "urn:uuid:1111");
253 let obj = v.as_object().unwrap();
254 assert!(obj.get("context").is_none(), "raw `context` must not leak");
255 assert!(obj.get("id").is_none(), "raw `id` must not leak");
256 }
257
258 #[test]
259 fn document_omits_all_optional_fields_when_none() {
260 let v = serde_json::to_value(empty_doc()).unwrap();
261 let obj = v.as_object().unwrap();
262 for key in ["role", "last_updated", "tooling"] {
263 assert!(
264 !obj.contains_key(key),
265 "key {key:?} must be omitted when None"
266 );
267 }
268 }
269
270 #[test]
271 fn document_emits_optional_fields_when_some() {
272 let mut doc = empty_doc();
273 doc.role = Some("publisher".to_string());
274 doc.last_updated = Some("2024-02-01T00:00:00Z".to_string());
275 doc.tooling = Some("socket-patch 3.0.0".to_string());
276
277 let v = serde_json::to_value(&doc).unwrap();
278 assert_eq!(v["role"], "publisher");
279 assert_eq!(v["last_updated"], "2024-02-01T00:00:00Z");
280 assert_eq!(v["tooling"], "socket-patch 3.0.0");
281 }
282
283 #[test]
284 fn document_version_round_trips_arbitrary_u32() {
285 for v in [1u32, 2, 7, 42, u32::MAX] {
286 let mut doc = empty_doc();
287 doc.version = v;
288 let json = serde_json::to_string(&doc).unwrap();
289 let parsed: Document = serde_json::from_str(&json).unwrap();
290 assert_eq!(parsed.version, v);
291 }
292 }
293
294 #[test]
295 fn document_rejects_missing_required_fields() {
296 let bad = r#"{
298 "@id": "urn:uuid:1",
299 "author": "Socket",
300 "timestamp": "2024-01-01T00:00:00Z",
301 "version": 1,
302 "statements": []
303 }"#;
304 let r: Result<Document, _> = serde_json::from_str(bad);
305 assert!(r.is_err());
306 }
307
308 fn minimal_statement() -> Statement {
311 Statement {
312 id: None,
313 vulnerability: Vulnerability {
314 name: "GHSA-xxxx".to_string(),
315 aliases: Vec::new(),
316 },
317 timestamp: "2024-01-01T00:00:00Z".to_string(),
318 last_updated: None,
319 products: vec![Product {
320 id: "pkg:npm/app@1.0.0".to_string(),
321 identifiers: None,
322 hashes: None,
323 subcomponents: Vec::new(),
324 }],
325 status: Status::NotAffected,
326 supplier: None,
327 justification: None,
328 impact_statement: None,
329 action_statement: None,
330 }
331 }
332
333 #[test]
334 fn statement_omits_all_optional_fields_when_none() {
335 let v = serde_json::to_value(minimal_statement()).unwrap();
336 let obj = v.as_object().unwrap();
337 for key in [
338 "@id",
339 "last_updated",
340 "supplier",
341 "justification",
342 "impact_statement",
343 "action_statement",
344 ] {
345 assert!(
346 !obj.contains_key(key),
347 "key {key:?} must be omitted when None"
348 );
349 }
350 assert!(
352 v["vulnerability"]
353 .as_object()
354 .unwrap()
355 .get("aliases")
356 .is_none(),
357 "empty aliases must omit the key"
358 );
359 }
360
361 #[test]
362 fn statement_emits_id_under_at_prefix_and_other_optional_fields() {
363 let mut s = minimal_statement();
364 s.id = Some("urn:uuid:stmt-1".to_string());
365 s.last_updated = Some("2024-02-01T00:00:00Z".to_string());
366 s.supplier = Some("https://example.com/supplier".to_string());
367 s.justification = Some(Justification::InlineMitigationsAlreadyExist);
368 s.impact_statement = Some("Patched via Socket".to_string());
369 s.action_statement = Some("Apply socket-patch <uuid>".to_string());
370
371 let v = serde_json::to_value(&s).unwrap();
372 assert_eq!(v["@id"], "urn:uuid:stmt-1");
374 assert!(v.as_object().unwrap().get("id").is_none());
375
376 assert_eq!(v["last_updated"], "2024-02-01T00:00:00Z");
377 assert_eq!(v["supplier"], "https://example.com/supplier");
378 assert_eq!(v["justification"], "inline_mitigations_already_exist");
379 assert_eq!(v["impact_statement"], "Patched via Socket");
380 assert_eq!(v["action_statement"], "Apply socket-patch <uuid>");
381 }
382
383 #[test]
384 fn statement_with_both_justification_and_impact_emits_both_keys() {
385 let mut s = minimal_statement();
386 s.justification = Some(Justification::ComponentNotPresent);
387 s.impact_statement = Some("Component is not bundled".to_string());
388 let v = serde_json::to_value(&s).unwrap();
389 assert_eq!(v["justification"], "component_not_present");
390 assert_eq!(v["impact_statement"], "Component is not bundled");
391 }
392
393 #[test]
396 fn vulnerability_with_zero_aliases_omits_key() {
397 let v = serde_json::to_value(Vulnerability {
398 name: "GHSA-x".to_string(),
399 aliases: Vec::new(),
400 })
401 .unwrap();
402 assert!(v.as_object().unwrap().get("aliases").is_none());
403 assert_eq!(v["name"], "GHSA-x");
404 }
405
406 #[test]
407 fn vulnerability_with_one_alias() {
408 let v = serde_json::to_value(Vulnerability {
409 name: "GHSA-x".to_string(),
410 aliases: vec!["CVE-2024-1".to_string()],
411 })
412 .unwrap();
413 let arr = v["aliases"].as_array().unwrap();
414 assert_eq!(arr.len(), 1);
415 assert_eq!(arr[0], "CVE-2024-1");
416 }
417
418 #[test]
419 fn vulnerability_with_many_aliases_preserves_order() {
420 let aliases = vec![
423 "CVE-Z".to_string(),
424 "CVE-A".to_string(),
425 "CVE-M".to_string(),
426 ];
427 let v = serde_json::to_value(Vulnerability {
428 name: "GHSA-x".to_string(),
429 aliases: aliases.clone(),
430 })
431 .unwrap();
432 let arr = v["aliases"].as_array().unwrap();
433 assert_eq!(arr.len(), 3);
434 for (i, want) in aliases.iter().enumerate() {
435 assert_eq!(arr[i], *want);
436 }
437 }
438
439 #[test]
442 fn product_renames_id_and_omits_empty_subcomponents() {
443 let p = Product {
444 id: "pkg:npm/app@1.0.0".to_string(),
445 identifiers: None,
446 hashes: None,
447 subcomponents: Vec::new(),
448 };
449 let v = serde_json::to_value(&p).unwrap();
450 assert_eq!(v["@id"], "pkg:npm/app@1.0.0");
451 let obj = v.as_object().unwrap();
452 assert!(obj.get("subcomponents").is_none());
453 assert!(obj.get("identifiers").is_none());
454 assert!(obj.get("hashes").is_none());
455 }
456
457 #[test]
458 fn product_serializes_identifiers_and_hashes_when_set() {
459 let mut idents = BTreeMap::new();
460 idents.insert("purl".to_string(), "pkg:npm/app@1.0.0".to_string());
461 idents.insert("cpe23".to_string(), "cpe:2.3:a:foo:bar:1.0".to_string());
462
463 let mut hashes = BTreeMap::new();
464 hashes.insert("sha256".to_string(), "deadbeef".to_string());
465
466 let p = Product {
467 id: "pkg:npm/app@1.0.0".to_string(),
468 identifiers: Some(idents),
469 hashes: Some(hashes),
470 subcomponents: Vec::new(),
471 };
472 let v = serde_json::to_value(&p).unwrap();
473 assert_eq!(v["identifiers"]["cpe23"], "cpe:2.3:a:foo:bar:1.0");
475 assert_eq!(v["identifiers"]["purl"], "pkg:npm/app@1.0.0");
476 assert_eq!(v["hashes"]["sha256"], "deadbeef");
477 }
478
479 #[test]
480 fn product_serializes_subcomponents_in_input_order() {
481 let p = Product {
482 id: "pkg:npm/app@1.0.0".to_string(),
483 identifiers: None,
484 hashes: None,
485 subcomponents: vec![
486 Subcomponent {
487 id: "pkg:npm/z@1.0".to_string(),
488 identifiers: None,
489 hashes: None,
490 },
491 Subcomponent {
492 id: "pkg:npm/a@1.0".to_string(),
493 identifiers: None,
494 hashes: None,
495 },
496 ],
497 };
498 let v = serde_json::to_value(&p).unwrap();
499 let arr = v["subcomponents"].as_array().unwrap();
500 assert_eq!(arr.len(), 2);
501 assert_eq!(arr[0]["@id"], "pkg:npm/z@1.0");
502 assert_eq!(arr[1]["@id"], "pkg:npm/a@1.0");
503 }
504
505 #[test]
506 fn subcomponent_with_identifiers_and_hashes_round_trips() {
507 let mut idents = BTreeMap::new();
508 idents.insert("purl".to_string(), "pkg:npm/lodash@4.17.21".to_string());
509 let mut hashes = BTreeMap::new();
510 hashes.insert("sha256".to_string(), "abc123".to_string());
511
512 let sub = Subcomponent {
513 id: "pkg:npm/lodash@4.17.21".to_string(),
514 identifiers: Some(idents),
515 hashes: Some(hashes),
516 };
517 let json = serde_json::to_string(&sub).unwrap();
518 let parsed: Subcomponent = serde_json::from_str(&json).unwrap();
519 assert_eq!(sub, parsed);
520 }
521
522 #[test]
525 fn document_roundtrips_minimal() {
526 let doc = empty_doc();
527 let json = serde_json::to_string(&doc).unwrap();
528 let parsed: Document = serde_json::from_str(&json).unwrap();
529 assert_eq!(doc, parsed);
530 }
531
532 #[test]
533 fn document_roundtrips_with_all_fields_populated() {
534 let mut idents = BTreeMap::new();
535 idents.insert("purl".to_string(), "pkg:npm/app@1.0.0".to_string());
536 let mut hashes = BTreeMap::new();
537 hashes.insert("sha256".to_string(), "deadbeef".to_string());
538
539 let doc = Document {
540 context: OPENVEX_CONTEXT_V0_2_0.to_string(),
541 id: "urn:uuid:abc".to_string(),
542 author: "Socket".to_string(),
543 role: Some("publisher".to_string()),
544 timestamp: "2024-01-01T00:00:00Z".to_string(),
545 last_updated: Some("2024-06-01T00:00:00Z".to_string()),
546 version: 3,
547 tooling: Some("socket-patch 3.0.0".to_string()),
548 statements: vec![Statement {
549 id: Some("urn:uuid:stmt-1".to_string()),
550 vulnerability: Vulnerability {
551 name: "GHSA-xxx".to_string(),
552 aliases: vec!["CVE-2024-0001".to_string()],
553 },
554 timestamp: "2024-01-01T00:00:00Z".to_string(),
555 last_updated: Some("2024-06-01T00:00:00Z".to_string()),
556 products: vec![Product {
557 id: "pkg:npm/app@1.0.0".to_string(),
558 identifiers: Some(idents.clone()),
559 hashes: Some(hashes.clone()),
560 subcomponents: vec![Subcomponent {
561 id: "pkg:npm/lodash@4.17.21".to_string(),
562 identifiers: Some(idents.clone()),
563 hashes: Some(hashes.clone()),
564 }],
565 }],
566 status: Status::NotAffected,
567 supplier: Some("https://example.com/supplier".to_string()),
568 justification: Some(Justification::InlineMitigationsAlreadyExist),
569 impact_statement: Some("Patched via Socket".to_string()),
570 action_statement: Some("Apply socket-patch <uuid>".to_string()),
571 }],
572 };
573 let json = serde_json::to_string_pretty(&doc).unwrap();
574 let parsed: Document = serde_json::from_str(&json).unwrap();
575 assert_eq!(doc, parsed);
576 }
577
578 #[test]
579 fn parsing_a_doc_without_optional_fields_succeeds_via_default() {
580 let minimal = r#"{
583 "@context": "https://openvex.dev/ns/v0.2.0",
584 "@id": "urn:uuid:1",
585 "author": "Socket",
586 "timestamp": "2024-01-01T00:00:00Z",
587 "version": 1,
588 "statements": [
589 {
590 "vulnerability": {"name": "GHSA-x"},
591 "timestamp": "2024-01-01T00:00:00Z",
592 "products": [{"@id": "pkg:npm/app@1.0.0"}],
593 "status": "not_affected"
594 }
595 ]
596 }"#;
597 let doc: Document = serde_json::from_str(minimal).unwrap();
598 assert!(doc.role.is_none());
599 assert!(doc.last_updated.is_none());
600 assert!(doc.tooling.is_none());
601 let st = &doc.statements[0];
602 assert!(st.id.is_none());
603 assert!(st.last_updated.is_none());
604 assert!(st.supplier.is_none());
605 assert!(st.action_statement.is_none());
606 }
607}