use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VulnerabilityInfo {
pub cves: Vec<String>,
pub summary: String,
pub severity: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PatchFileInfo {
pub before_hash: String,
pub after_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PatchRecord {
pub uuid: String,
pub exported_at: String,
pub files: HashMap<String, PatchFileInfo>,
pub vulnerabilities: HashMap<String, VulnerabilityInfo>,
pub description: String,
pub license: String,
pub tier: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PatchManifest {
pub patches: HashMap<String, PatchRecord>,
}
impl PatchManifest {
pub fn new() -> Self {
Self {
patches: HashMap::new(),
}
}
}
impl Default for PatchManifest {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_manifest_roundtrip() {
let manifest = PatchManifest::new();
let json = serde_json::to_string_pretty(&manifest).unwrap();
let parsed: PatchManifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.patches.len(), 0);
}
#[test]
fn test_manifest_with_patch_roundtrip() {
let json = r#"{
"patches": {
"pkg:npm/simplehttpserver@0.0.6": {
"uuid": "12345678-1234-1234-1234-123456789abc",
"exportedAt": "2024-01-15T10:00:00Z",
"files": {
"package/lib/server.js": {
"beforeHash": "aaaa000000000000000000000000000000000000000000000000000000000000",
"afterHash": "bbbb000000000000000000000000000000000000000000000000000000000000"
}
},
"vulnerabilities": {
"GHSA-jrhj-2j3q-xf3v": {
"cves": ["CVE-2024-1234"],
"summary": "Path traversal vulnerability",
"severity": "high",
"description": "A path traversal vulnerability exists in simplehttpserver"
}
},
"description": "Fix path traversal vulnerability",
"license": "MIT",
"tier": "free"
}
}
}"#;
let manifest: PatchManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.patches.len(), 1);
let patch = manifest
.patches
.get("pkg:npm/simplehttpserver@0.0.6")
.unwrap();
assert_eq!(patch.uuid, "12345678-1234-1234-1234-123456789abc");
assert_eq!(patch.files.len(), 1);
assert_eq!(patch.vulnerabilities.len(), 1);
assert_eq!(patch.tier, "free");
let file_info = patch.files.get("package/lib/server.js").unwrap();
assert_eq!(
file_info.before_hash,
"aaaa000000000000000000000000000000000000000000000000000000000000"
);
let vuln = patch.vulnerabilities.get("GHSA-jrhj-2j3q-xf3v").unwrap();
assert_eq!(vuln.cves, vec!["CVE-2024-1234"]);
assert_eq!(vuln.severity, "high");
let serialized = serde_json::to_string_pretty(&manifest).unwrap();
let reparsed: PatchManifest = serde_json::from_str(&serialized).unwrap();
assert_eq!(manifest, reparsed);
}
#[test]
fn test_camel_case_serialization() {
let file_info = PatchFileInfo {
before_hash: "aaa".to_string(),
after_hash: "bbb".to_string(),
};
let json = serde_json::to_string(&file_info).unwrap();
assert!(json.contains("beforeHash"));
assert!(json.contains("afterHash"));
assert!(!json.contains("before_hash"));
assert!(!json.contains("after_hash"));
}
#[test]
fn test_patch_record_camel_case() {
let record = PatchRecord {
uuid: "test-uuid".to_string(),
exported_at: "2024-01-01T00:00:00Z".to_string(),
files: HashMap::new(),
vulnerabilities: HashMap::new(),
description: "test".to_string(),
license: "MIT".to_string(),
tier: "free".to_string(),
};
let json = serde_json::to_string(&record).unwrap();
assert!(json.contains("exportedAt"));
assert!(!json.contains("exported_at"));
}
#[test]
fn test_patch_file_info_rejects_snake_case_keys() {
let snake = r#"{"before_hash": "a", "after_hash": "b"}"#;
assert!(
serde_json::from_str::<PatchFileInfo>(snake).is_err(),
"snake_case keys must not deserialize -- the wire contract is camelCase"
);
let camel = r#"{"beforeHash": "a", "afterHash": "b"}"#;
let parsed: PatchFileInfo = serde_json::from_str(camel).unwrap();
assert_eq!(parsed.before_hash, "a");
assert_eq!(parsed.after_hash, "b");
}
#[test]
fn test_patch_record_rejects_snake_case_exported_at() {
let json = r#"{
"uuid": "11111111-1111-4111-8111-111111111111",
"exported_at": "2024-01-01T00:00:00Z",
"files": {},
"vulnerabilities": {},
"description": "d",
"license": "MIT",
"tier": "free"
}"#;
assert!(
serde_json::from_str::<PatchRecord>(json).is_err(),
"exported_at must be rejected; the contract field is exportedAt"
);
}
#[test]
fn test_vulnerability_info_exact_keys_and_empty_cves() {
let json = r#"{
"cves": [],
"summary": "Some vuln",
"severity": "medium",
"description": "A medium severity vulnerability"
}"#;
let vuln: VulnerabilityInfo = serde_json::from_str(json).unwrap();
assert!(vuln.cves.is_empty());
assert_eq!(vuln.severity, "medium");
let serialized = serde_json::to_string(&vuln).unwrap();
for key in ["\"cves\"", "\"summary\"", "\"severity\"", "\"description\""] {
assert!(serialized.contains(key), "missing key {key}");
}
}
#[test]
fn test_patch_record_requires_all_fields() {
let complete = serde_json::json!({
"uuid": "11111111-1111-4111-8111-111111111111",
"exportedAt": "2024-01-01T00:00:00Z",
"files": {},
"vulnerabilities": {},
"description": "d",
"license": "MIT",
"tier": "free"
});
assert!(serde_json::from_value::<PatchRecord>(complete.clone()).is_ok());
for field in [
"uuid",
"exportedAt",
"files",
"vulnerabilities",
"description",
"license",
"tier",
] {
let mut partial = complete.clone();
partial.as_object_mut().unwrap().remove(field);
assert!(
serde_json::from_value::<PatchRecord>(partial).is_err(),
"a record missing `{field}` must be rejected"
);
}
}
#[test]
fn test_multi_patch_manifest_deep_roundtrip() {
let json = r#"{
"patches": {
"pkg:npm/pkg-a@1.0.0": {
"uuid": "550e8400-e29b-41d4-a716-446655440001",
"exportedAt": "2024-01-01T00:00:00Z",
"files": {
"package/lib/index.js": { "beforeHash": "aaa", "afterHash": "bbb" }
},
"vulnerabilities": {},
"description": "Patch A",
"license": "MIT",
"tier": "free"
},
"pkg:npm/pkg-b@2.0.0": {
"uuid": "550e8400-e29b-41d4-a716-446655440002",
"exportedAt": "2024-02-01T00:00:00Z",
"files": {
"package/src/main.js": { "beforeHash": "ccc", "afterHash": "ddd" }
},
"vulnerabilities": {
"GHSA-xxxx-yyyy-zzzz": {
"cves": [],
"summary": "Some vuln",
"severity": "medium",
"description": "A medium severity vulnerability"
}
},
"description": "Patch B",
"license": "Apache-2.0",
"tier": "paid"
}
}
}"#;
let manifest: PatchManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.patches.len(), 2);
let serialized = serde_json::to_string_pretty(&manifest).unwrap();
let reparsed: PatchManifest = serde_json::from_str(&serialized).unwrap();
assert_eq!(manifest, reparsed);
let b = reparsed.patches.get("pkg:npm/pkg-b@2.0.0").unwrap();
assert_eq!(b.license, "Apache-2.0");
assert_eq!(b.tier, "paid");
assert_eq!(b.vulnerabilities.len(), 1);
assert!(b
.vulnerabilities
.get("GHSA-xxxx-yyyy-zzzz")
.unwrap()
.cves
.is_empty());
}
#[test]
fn test_manifest_requires_patches_field() {
assert!(
serde_json::from_str::<PatchManifest>("{}").is_err(),
"a manifest without a `patches` field must be rejected"
);
}
}