socket_patch_core/manifest/
schema.rs1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23#[serde(rename_all = "camelCase")]
24pub struct PatchRecord {
25 pub uuid: String,
26 pub exported_at: String,
27 pub files: HashMap<String, PatchFileInfo>,
29 pub vulnerabilities: HashMap<String, VulnerabilityInfo>,
31 pub description: String,
32 pub license: String,
33 pub tier: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub struct PatchManifest {
40 pub patches: HashMap<String, PatchRecord>,
42}
43
44impl PatchManifest {
45 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 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 #[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 #[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 #[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 #[test]
225 fn test_patch_record_requires_all_fields() {
226 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 #[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 #[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}