Skip to main content

socket_patch_core/vex/
schema.rs

1//! OpenVEX 0.2.0 schema types.
2//!
3//! Hand-rolled from the OpenVEX 0.2.0 spec
4//! (<https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md>) and
5//! cross-checked against the Go reference implementation
6//! (<https://github.com/openvex/go-vex/tree/main/pkg/vex>). The serde
7//! representation must match the spec verbatim; the `vexctl merge`
8//! step in our e2e suite is what catches drift.
9//!
10//! Field-level notes:
11//! * `@context` / `@id` use serde renames because JSON-LD requires the
12//!   literal `@`-prefixed keys.
13//! * Optional fields use `Option<T>` + `skip_serializing_if = "Option::is_none"`
14//!   so the emitted JSON omits them rather than emitting `null`. Matches
15//!   the Go implementation's `omitempty` behavior.
16//! * `version` is the OpenVEX document revision counter (integer,
17//!   starts at 1). NOT the schema version.
18//! * `Vec<Statement>` is always present (the spec allows it to be empty
19//!   in principle, but our generator errors out before that state).
20//! * `Product.identifiers` / `Product.hashes` (and same on
21//!   `Subcomponent`) use `BTreeMap` instead of `HashMap` for
22//!   deterministic key ordering — easier diffing across runs.
23
24use 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/// Top-level OpenVEX document.
30#[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    /// Optional role declaration for `author`. Free-form per spec.
38    #[serde(skip_serializing_if = "Option::is_none", default)]
39    pub role: Option<String>,
40    pub timestamp: String,
41    /// RFC 3339 timestamp of the most recent revision of this doc.
42    /// Optional; absent in newly-issued documents.
43    #[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/// One VEX statement — the unit of "I am asserting that vulnerability X
52/// has status S relative to product P".
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub struct Statement {
55    /// Optional per-statement identifier. When present, must be unique
56    /// within the document. Spec says it's used to track revisions.
57    #[serde(rename = "@id", skip_serializing_if = "Option::is_none", default)]
58    pub id: Option<String>,
59    pub vulnerability: Vulnerability,
60    /// RFC 3339 timestamp the statement's assertion was known true.
61    /// Optional per spec — it cascades down from the document when a
62    /// statement omits it (see OpenVEX inheritance rules), so a
63    /// spec-valid document may legitimately leave it out. We always
64    /// emit one (the builder clones the document timestamp), but the
65    /// type must still accept its absence on parse, mirroring the
66    /// sibling `last_updated` field below.
67    #[serde(skip_serializing_if = "Option::is_none", default)]
68    pub timestamp: Option<String>,
69    /// RFC 3339 timestamp of the most recent revision of this statement.
70    #[serde(skip_serializing_if = "Option::is_none", default)]
71    pub last_updated: Option<String>,
72    pub products: Vec<Product>,
73    pub status: Status,
74    /// Optional supplier IRI overriding the document-level author for
75    /// this statement.
76    #[serde(skip_serializing_if = "Option::is_none", default)]
77    pub supplier: Option<String>,
78    /// Required when `status == not_affected` (per spec; we don't
79    /// enforce at the type level — see `vex::conformance_tests`).
80    #[serde(skip_serializing_if = "Option::is_none", default)]
81    pub justification: Option<Justification>,
82    /// Free-form explanation paired with `not_affected`.
83    #[serde(skip_serializing_if = "Option::is_none", default)]
84    pub impact_statement: Option<String>,
85    /// Canonical companion to `status == affected` (per spec).
86    /// We never emit `affected` today, but the field exists so the type
87    /// round-trips a richer doc through our parser.
88    #[serde(skip_serializing_if = "Option::is_none", default)]
89    pub action_statement: Option<String>,
90}
91
92/// Vulnerability identifier. `name` is the primary ID (we use the GHSA),
93/// `aliases` holds the CVE list.
94#[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/// A product the statement applies to. `@id` is a PURL or any URI; the
102/// subcomponent list pinpoints the vulnerable transitive dep.
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
104pub struct Product {
105    #[serde(rename = "@id")]
106    pub id: String,
107    /// Optional auxiliary identifiers (PURL, CPE 2.2, CPE 2.3, etc.).
108    /// Keys are the identifier type (e.g. `"purl"`, `"cpe23"`),
109    /// values are the literal identifier strings.
110    #[serde(skip_serializing_if = "Option::is_none", default)]
111    pub identifiers: Option<BTreeMap<String, String>>,
112    /// Optional content hashes that pin the product to specific bytes.
113    /// Keys are hash algorithms (e.g. `"sha256"`), values are hex.
114    #[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/// A subcomponent of the product — i.e. the actual vulnerable dependency
121/// the patch covers.
122#[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/// VEX status. Spec defines exactly these four values.
133#[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/// VEX `justification` enum — only required when `status = not_affected`.
143/// Spec lists five canonical values; we expose them all even though
144/// `socket-patch` only emits `InlineMitigationsAlreadyExist` today.
145#[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    // ── Status enum: every variant round-trips ─────────────────────
160
161    /// Spec strings for `Status`. The list IS the contract — keep it
162    /// matched against the OpenVEX 0.2.0 spec section "Statement
163    /// Properties → status".
164    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    // ── Justification enum: every variant round-trips ──────────────
194
195    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    // ── Document field shape ──────────────────────────────────────
238
239    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        // Drop the `@context` key — required field, parser must error.
302        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    // ── Statement field shape ─────────────────────────────────────
314
315    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        // The `aliases` key on the inner vulnerability also omits-empty.
356        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        // `@id` not raw `id`.
378        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    // ── Vulnerability shape ───────────────────────────────────────
399
400    #[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        // Builder sorts aliases, but the type itself preserves input
426        // order — important so callers can rely on Vec semantics.
427        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    // ── Product / Subcomponent shape ──────────────────────────────
445
446    #[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        // BTreeMap → keys appear in sorted order in the JSON.
479        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    // ── Full-document round-trips ─────────────────────────────────
528
529    #[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        // Spec consumers will hand us docs that omit our new optional
586        // fields. Defaulting must work end-to-end.
587        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    // ── Statement timestamp is optional/inheritable per spec ───────
614
615    /// Regression: the statement-level `timestamp` is OPTIONAL in
616    /// OpenVEX 0.2.0 — it cascades from the document when omitted. A
617    /// spec-valid statement that leaves it out (the canonical spec
618    /// example does exactly this for a `fixed` statement) MUST parse,
619    /// not error with "missing field `timestamp`". Previously the
620    /// field was a required `String`, so this document was rejected.
621    #[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    /// A statement timestamp that IS present round-trips through the
647    /// `Option<String>` field, and an absent one is omitted from the
648    /// serialized JSON (no `null`, no empty string).
649    #[test]
650    fn statement_timestamp_some_emits_none_omits() {
651        let mut s = minimal_statement(); // carries Some(timestamp)
652        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    // ── Forward-compat: unmodeled spec fields are tolerated ────────
664
665    /// OpenVEX 0.2.0 carries fields we intentionally don't model
666    /// (statement-level `version`, `status_notes`,
667    /// `action_statement_timestamp`, vulnerability `@id`/`description`).
668    /// Real documents and future spec revisions will include them.
669    /// Because no struct uses `#[serde(deny_unknown_fields)]`, parsing
670    /// MUST ignore them rather than erroring — pin that so a future
671    /// `deny_unknown_fields` (which would break interop) regresses here.
672    #[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    // ── Wire format: multi-word keys stay snake_case ───────────────
714
715    /// The statement-level multi-word keys MUST be emitted in the
716    /// OpenVEX snake_case spelling. `Statement` has no `rename_all`, so
717    /// this relies on the field idents already being snake_case.
718    /// Round-trip tests can't catch a switch to
719    /// `rename_all = "camelCase"` (ser/de would stay symmetric), so pin
720    /// the exact emitted keys — and assert the camelCase forms are absent.
721    #[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}