Skip to main content

socket_patch_core/manifest/
schema.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// Information about a vulnerability fixed by a patch.
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6pub struct VulnerabilityInfo {
7    pub cves: Vec<String>,
8    pub summary: String,
9    pub severity: String,
10    pub description: String,
11}
12
13/// Hash information for a single patched file.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "camelCase")]
16pub struct PatchFileInfo {
17    pub before_hash: String,
18    pub after_hash: String,
19}
20
21/// A single patch record in the manifest.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23#[serde(rename_all = "camelCase")]
24pub struct PatchRecord {
25    pub uuid: String,
26    pub exported_at: String,
27    /// Maps relative file path -> hash info.
28    pub files: HashMap<String, PatchFileInfo>,
29    /// Maps vulnerability ID (e.g., "GHSA-...") -> vulnerability info.
30    pub vulnerabilities: HashMap<String, VulnerabilityInfo>,
31    pub description: String,
32    pub license: String,
33    pub tier: String,
34}
35
36/// The top-level patch manifest structure.
37/// Stored as `.socket/manifest.json`.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub struct PatchManifest {
40    /// Maps package PURL (e.g., "pkg:npm/lodash@4.17.21") -> patch record.
41    pub patches: HashMap<String, PatchRecord>,
42}
43
44impl PatchManifest {
45    /// Create an empty manifest.
46    pub fn new() -> Self {
47        Self {
48            patches: HashMap::new(),
49        }
50    }
51}
52
53impl Default for PatchManifest {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    #[test]
64    fn test_empty_manifest_roundtrip() {
65        let manifest = PatchManifest::new();
66        let json = serde_json::to_string_pretty(&manifest).unwrap();
67        let parsed: PatchManifest = serde_json::from_str(&json).unwrap();
68        assert_eq!(parsed.patches.len(), 0);
69    }
70
71    #[test]
72    fn test_manifest_with_patch_roundtrip() {
73        let json = r#"{
74  "patches": {
75    "pkg:npm/simplehttpserver@0.0.6": {
76      "uuid": "12345678-1234-1234-1234-123456789abc",
77      "exportedAt": "2024-01-15T10:00:00Z",
78      "files": {
79        "package/lib/server.js": {
80          "beforeHash": "aaaa000000000000000000000000000000000000000000000000000000000000",
81          "afterHash": "bbbb000000000000000000000000000000000000000000000000000000000000"
82        }
83      },
84      "vulnerabilities": {
85        "GHSA-jrhj-2j3q-xf3v": {
86          "cves": ["CVE-2024-1234"],
87          "summary": "Path traversal vulnerability",
88          "severity": "high",
89          "description": "A path traversal vulnerability exists in simplehttpserver"
90        }
91      },
92      "description": "Fix path traversal vulnerability",
93      "license": "MIT",
94      "tier": "free"
95    }
96  }
97}"#;
98
99        let manifest: PatchManifest = serde_json::from_str(json).unwrap();
100        assert_eq!(manifest.patches.len(), 1);
101
102        let patch = manifest
103            .patches
104            .get("pkg:npm/simplehttpserver@0.0.6")
105            .unwrap();
106        assert_eq!(patch.uuid, "12345678-1234-1234-1234-123456789abc");
107        assert_eq!(patch.files.len(), 1);
108        assert_eq!(patch.vulnerabilities.len(), 1);
109        assert_eq!(patch.tier, "free");
110
111        let file_info = patch.files.get("package/lib/server.js").unwrap();
112        assert_eq!(
113            file_info.before_hash,
114            "aaaa000000000000000000000000000000000000000000000000000000000000"
115        );
116
117        let vuln = patch.vulnerabilities.get("GHSA-jrhj-2j3q-xf3v").unwrap();
118        assert_eq!(vuln.cves, vec!["CVE-2024-1234"]);
119        assert_eq!(vuln.severity, "high");
120
121        // Verify round-trip
122        let serialized = serde_json::to_string_pretty(&manifest).unwrap();
123        let reparsed: PatchManifest = serde_json::from_str(&serialized).unwrap();
124        assert_eq!(manifest, reparsed);
125    }
126
127    #[test]
128    fn test_camel_case_serialization() {
129        let file_info = PatchFileInfo {
130            before_hash: "aaa".to_string(),
131            after_hash: "bbb".to_string(),
132        };
133        let json = serde_json::to_string(&file_info).unwrap();
134        assert!(json.contains("beforeHash"));
135        assert!(json.contains("afterHash"));
136        assert!(!json.contains("before_hash"));
137        assert!(!json.contains("after_hash"));
138    }
139
140    #[test]
141    fn test_patch_record_camel_case() {
142        let record = PatchRecord {
143            uuid: "test-uuid".to_string(),
144            exported_at: "2024-01-01T00:00:00Z".to_string(),
145            files: HashMap::new(),
146            vulnerabilities: HashMap::new(),
147            description: "test".to_string(),
148            license: "MIT".to_string(),
149            tier: "free".to_string(),
150        };
151        let json = serde_json::to_string(&record).unwrap();
152        assert!(json.contains("exportedAt"));
153        assert!(!json.contains("exported_at"));
154    }
155
156    // ── Regression: pin the on-the-wire JSON contract with the TS schema ──
157    //
158    // schema.rs is a pure serde DTO whose only job is to match the
159    // camelCase shape that the legacy TS tool (manifest-schema.ts) reads and
160    // writes. The tests below lock that contract so a dropped or mistyped
161    // `rename_all`, a renamed field, or a removed field fails loudly rather
162    // than silently producing a manifest the TS tooling can't read.
163
164    // The camelCase rename must be ENFORCED on input, not merely emitted on
165    // output. A manifest carrying snake_case keys (as a naive serializer
166    // without `rename_all` would produce) must be rejected, otherwise the two
167    // implementations could silently drift apart.
168    #[test]
169    fn test_patch_file_info_rejects_snake_case_keys() {
170        let snake = r#"{"before_hash": "a", "after_hash": "b"}"#;
171        assert!(
172            serde_json::from_str::<PatchFileInfo>(snake).is_err(),
173            "snake_case keys must not deserialize -- the wire contract is camelCase"
174        );
175
176        let camel = r#"{"beforeHash": "a", "afterHash": "b"}"#;
177        let parsed: PatchFileInfo = serde_json::from_str(camel).unwrap();
178        assert_eq!(parsed.before_hash, "a");
179        assert_eq!(parsed.after_hash, "b");
180    }
181
182    // Likewise for `exportedAt` on a record: snake_case must be rejected.
183    #[test]
184    fn test_patch_record_rejects_snake_case_exported_at() {
185        let json = r#"{
186            "uuid": "11111111-1111-4111-8111-111111111111",
187            "exported_at": "2024-01-01T00:00:00Z",
188            "files": {},
189            "vulnerabilities": {},
190            "description": "d",
191            "license": "MIT",
192            "tier": "free"
193        }"#;
194        assert!(
195            serde_json::from_str::<PatchRecord>(json).is_err(),
196            "exported_at must be rejected; the contract field is exportedAt"
197        );
198    }
199
200    // VulnerabilityInfo intentionally has NO `rename_all` (all fields are
201    // single lowercase words). Pin its exact keys so nobody "helpfully" adds a
202    // rename that would break the contract, and exercise an empty `cves` array
203    // (the medium-severity shape from the TS test suite).
204    #[test]
205    fn test_vulnerability_info_exact_keys_and_empty_cves() {
206        let json = r#"{
207            "cves": [],
208            "summary": "Some vuln",
209            "severity": "medium",
210            "description": "A medium severity vulnerability"
211        }"#;
212        let vuln: VulnerabilityInfo = serde_json::from_str(json).unwrap();
213        assert!(vuln.cves.is_empty());
214        assert_eq!(vuln.severity, "medium");
215
216        let serialized = serde_json::to_string(&vuln).unwrap();
217        for key in ["\"cves\"", "\"summary\"", "\"severity\"", "\"description\""] {
218            assert!(serialized.contains(key), "missing key {key}");
219        }
220    }
221
222    // Every PatchRecord field is required (mirroring the TS zod schema, which
223    // rejects records missing any field). Dropping any one must fail.
224    #[test]
225    fn test_patch_record_requires_all_fields() {
226        // A complete record, used as the baseline.
227        let complete = serde_json::json!({
228            "uuid": "11111111-1111-4111-8111-111111111111",
229            "exportedAt": "2024-01-01T00:00:00Z",
230            "files": {},
231            "vulnerabilities": {},
232            "description": "d",
233            "license": "MIT",
234            "tier": "free"
235        });
236        assert!(serde_json::from_value::<PatchRecord>(complete.clone()).is_ok());
237
238        for field in [
239            "uuid",
240            "exportedAt",
241            "files",
242            "vulnerabilities",
243            "description",
244            "license",
245            "tier",
246        ] {
247            let mut partial = complete.clone();
248            partial.as_object_mut().unwrap().remove(field);
249            assert!(
250                serde_json::from_value::<PatchRecord>(partial).is_err(),
251                "a record missing `{field}` must be rejected"
252            );
253        }
254    }
255
256    // A multi-patch manifest mirroring the TS test suite (a free/MIT patch and
257    // a paid/Apache-2.0 patch) must survive a full deserialize -> serialize ->
258    // deserialize round-trip with deep equality, guarding against a serializer
259    // that drops nested records, files, or vulnerabilities.
260    #[test]
261    fn test_multi_patch_manifest_deep_roundtrip() {
262        let json = r#"{
263  "patches": {
264    "pkg:npm/pkg-a@1.0.0": {
265      "uuid": "550e8400-e29b-41d4-a716-446655440001",
266      "exportedAt": "2024-01-01T00:00:00Z",
267      "files": {
268        "package/lib/index.js": { "beforeHash": "aaa", "afterHash": "bbb" }
269      },
270      "vulnerabilities": {},
271      "description": "Patch A",
272      "license": "MIT",
273      "tier": "free"
274    },
275    "pkg:npm/pkg-b@2.0.0": {
276      "uuid": "550e8400-e29b-41d4-a716-446655440002",
277      "exportedAt": "2024-02-01T00:00:00Z",
278      "files": {
279        "package/src/main.js": { "beforeHash": "ccc", "afterHash": "ddd" }
280      },
281      "vulnerabilities": {
282        "GHSA-xxxx-yyyy-zzzz": {
283          "cves": [],
284          "summary": "Some vuln",
285          "severity": "medium",
286          "description": "A medium severity vulnerability"
287        }
288      },
289      "description": "Patch B",
290      "license": "Apache-2.0",
291      "tier": "paid"
292    }
293  }
294}"#;
295
296        let manifest: PatchManifest = serde_json::from_str(json).unwrap();
297        assert_eq!(manifest.patches.len(), 2);
298
299        let serialized = serde_json::to_string_pretty(&manifest).unwrap();
300        let reparsed: PatchManifest = serde_json::from_str(&serialized).unwrap();
301        assert_eq!(manifest, reparsed);
302
303        let b = reparsed.patches.get("pkg:npm/pkg-b@2.0.0").unwrap();
304        assert_eq!(b.license, "Apache-2.0");
305        assert_eq!(b.tier, "paid");
306        assert_eq!(b.vulnerabilities.len(), 1);
307        assert!(b
308            .vulnerabilities
309            .get("GHSA-xxxx-yyyy-zzzz")
310            .unwrap()
311            .cves
312            .is_empty());
313    }
314
315    // A manifest missing the top-level `patches` key must be rejected (the TS
316    // schema requires it; `{}` is not a valid manifest).
317    #[test]
318    fn test_manifest_requires_patches_field() {
319        assert!(
320            serde_json::from_str::<PatchManifest>("{}").is_err(),
321            "a manifest without a `patches` field must be rejected"
322        );
323    }
324}