1use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct PackIndex {
13 pub schema_version: u32,
14 #[serde(default)]
15 pub generated_at: Option<String>,
16 #[serde(default)]
17 pub packs: Vec<PackIndexEntry>,
18}
19
20impl PackIndex {
21 #[must_use]
23 pub fn find(&self, pack_id: &str) -> Option<&PackIndexEntry> {
24 let needle = pack_id.trim();
25 self.packs.iter().find(|p| p.id == needle)
26 }
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct PackIndexEntry {
34 pub id: String,
35 pub name: String,
36 pub latest: String,
38 #[serde(default)]
40 pub versions: std::collections::BTreeMap<String, PackIndexVersion>,
41 #[serde(default)]
42 pub target: Option<PackTarget>,
43 #[serde(default)]
44 pub maintainer: Option<PackMaintainer>,
45 #[serde(default)]
46 pub license: Option<String>,
47}
48
49impl PackIndexEntry {
50 #[must_use]
53 pub fn resolve_version(&self, requested: Option<&str>) -> Option<(String, &PackIndexVersion)> {
54 let version = requested.map_or_else(|| self.latest.clone(), ToOwned::to_owned);
55 self.versions.get(&version).map(|v| (version, v))
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct PackIndexVersion {
62 pub manifest: String,
64 pub sha256: String,
66 #[serde(default)]
67 pub rule_count: Option<u32>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct PackManifest {
74 pub schema_version: u32,
75 pub id: String,
77 pub name: String,
78 pub version: String,
79 #[serde(default)]
80 pub description: Option<String>,
81 #[serde(default)]
82 pub target: Option<PackTarget>,
83 #[serde(default)]
84 pub maintainer: Option<PackMaintainer>,
85 #[serde(default)]
86 pub license: Option<String>,
87 #[serde(default)]
88 pub provenance: Option<PackProvenance>,
89 #[serde(default)]
90 pub rules: Vec<PackRule>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct PackTarget {
96 #[serde(default)]
99 pub languages: Vec<String>,
100 #[serde(default)]
102 pub frameworks: Vec<String>,
103 #[serde(default)]
105 pub file_globs: Vec<String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct PackMaintainer {
111 pub name: String,
112 #[serde(default)]
113 pub url: Option<String>,
114 #[serde(default)]
118 pub verified: bool,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct PackProvenance {
127 pub kind: String,
128 #[serde(default)]
129 pub summary: Option<String>,
130 #[serde(default)]
131 pub sources: Vec<PackProvenanceSource>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct PackProvenanceSource {
137 pub label: String,
138 #[serde(default)]
139 pub url: Option<String>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct PackRule {
147 pub id: String,
149 pub title: String,
150 #[serde(default)]
153 pub severity: Option<String>,
154 #[serde(default)]
157 pub file_globs: Vec<String>,
158 #[serde(default)]
159 pub tags: Vec<String>,
160 #[serde(default)]
164 pub body: Option<String>,
165 #[serde(default)]
166 pub examples: Option<PackRuleExamples>,
167 #[serde(default)]
169 pub provenance: Option<PackRuleProvenance>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct PackRuleExamples {
175 #[serde(default)]
176 pub bad: Option<String>,
177 #[serde(default)]
178 pub good: Option<String>,
179 #[serde(default)]
181 pub description: Option<String>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct PackRuleProvenance {
187 pub kind: String,
188 #[serde(default)]
189 pub attribution: Option<String>,
190 #[serde(default)]
191 pub source_url: Option<String>,
192}
193
194#[must_use]
198pub fn manifest_sha256(bytes: &[u8]) -> String {
199 let mut hasher = Sha256::new();
200 hasher.update(bytes);
201 let digest = hasher.finalize();
202 let mut hex = String::with_capacity(digest.len() * 2);
203 for byte in digest {
204 hex.push_str(&format!("{byte:02x}"));
205 }
206 hex
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 const SAMPLE_INDEX: &str = r#"{
214 "schemaVersion": 1,
215 "generatedAt": "2026-06-01T00:00:00Z",
216 "packs": [
217 {
218 "id": "difflore/go-http-safety",
219 "name": "Go HTTP handler safety",
220 "latest": "1.0.0",
221 "versions": {
222 "1.0.0": {
223 "manifest": "packs/difflore/go-http-safety/pack.json",
224 "sha256": "deadbeef",
225 "ruleCount": 6
226 }
227 },
228 "target": { "languages": ["go"], "frameworks": ["net/http"] },
229 "maintainer": { "name": "DiffLore", "verified": true },
230 "license": "CC-BY-4.0"
231 }
232 ]
233 }"#;
234
235 #[test]
236 fn index_round_trips_and_finds_entry() {
237 let index: PackIndex = serde_json::from_str(SAMPLE_INDEX).expect("parse index");
238 assert_eq!(index.schema_version, 1);
239 let entry = index.find("difflore/go-http-safety").expect("entry");
240 assert_eq!(entry.name, "Go HTTP handler safety");
241 assert_eq!(entry.latest, "1.0.0");
242 let (resolved, version) = entry.resolve_version(None).expect("latest");
243 assert_eq!(resolved, "1.0.0");
244 assert_eq!(version.sha256, "deadbeef");
245 assert_eq!(version.rule_count, Some(6));
246 }
247
248 #[test]
249 fn resolve_version_pins_explicit_request() {
250 let index: PackIndex = serde_json::from_str(SAMPLE_INDEX).expect("parse index");
251 let entry = index.find("difflore/go-http-safety").expect("entry");
252 assert!(entry.resolve_version(Some("9.9.9")).is_none());
253 assert!(entry.resolve_version(Some("1.0.0")).is_some());
254 }
255
256 #[test]
257 fn manifest_sha256_is_deterministic_hex() {
258 let a = manifest_sha256(b"hello");
259 let b = manifest_sha256(b"hello");
260 assert_eq!(a, b);
261 assert_eq!(a.len(), 64);
262 assert_ne!(a, manifest_sha256(b"world"));
263 }
264
265 #[test]
266 fn manifest_parses_minimal_pack() {
267 let raw = r#"{
268 "schemaVersion": 1,
269 "id": "difflore/go-http-safety",
270 "name": "Go HTTP handler safety",
271 "version": "1.0.0",
272 "target": { "languages": ["go"], "fileGlobs": ["**/*.go"] },
273 "provenance": { "kind": "curated" },
274 "rules": [
275 {
276 "id": "go-http-safety/413-body-limit",
277 "title": "Return 413 when a request body exceeds the size limit",
278 "severity": "error",
279 "body": "Enforce a maximum request body size.",
280 "examples": { "bad": "x", "good": "y" }
281 }
282 ]
283 }"#;
284 let manifest: PackManifest = serde_json::from_str(raw).expect("parse manifest");
285 assert_eq!(manifest.id, "difflore/go-http-safety");
286 assert_eq!(manifest.rules.len(), 1);
287 let rule = &manifest.rules[0];
288 assert_eq!(rule.severity.as_deref(), Some("error"));
289 assert_eq!(
290 manifest.target.as_ref().unwrap().file_globs,
291 vec!["**/*.go".to_owned()]
292 );
293 }
294}