1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::{BTreeMap, HashSet};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::skill::metadata;
10use crate::skill::trust::{self, IntegritySignal, SignatureSignal, TrustDecision, TrustTier};
11
12const MANIFEST_FILE_NAME: &str = ".skilllite-manifest.json";
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
16pub enum SignatureStatus {
17 Unsigned,
18 Valid,
19 Invalid,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
24pub enum SkillIntegrityStatus {
25 Ok,
26 HashChanged,
27 SignatureInvalid,
28 Unsigned,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SkillManifestEntry {
33 pub name: String,
34 pub source: String,
35 pub version: Option<String>,
36 pub hash: String,
37 pub signature_status: SignatureStatus,
38 #[serde(default)]
39 pub trust_tier: TrustTier,
40 #[serde(default)]
41 pub trust_score: u8,
42 #[serde(default)]
43 pub tier_reason: Vec<String>,
44 #[serde(default)]
45 pub tier_updated_at: Option<DateTime<Utc>>,
46 pub installed_at: DateTime<Utc>,
47 #[serde(default)]
49 pub admission_risk: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SkillManifest {
54 pub version: u32,
55 pub skills: BTreeMap<String, SkillManifestEntry>,
56}
57
58impl Default for SkillManifest {
59 fn default() -> Self {
60 Self {
61 version: 1,
62 skills: BTreeMap::new(),
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
68pub struct SkillIntegrityReport {
69 pub status: SkillIntegrityStatus,
70 pub current_hash: String,
71 pub signature_status: SignatureStatus,
72 pub entry: Option<SkillManifestEntry>,
73 pub trust_tier: TrustTier,
74 pub trust_score: u8,
75 pub trust_decision: TrustDecision,
76 pub trust_reasons: Vec<String>,
77}
78
79pub fn manifest_path(skills_dir: &Path) -> PathBuf {
80 skills_dir.join(MANIFEST_FILE_NAME)
81}
82
83pub fn load_manifest(skills_dir: &Path) -> Result<SkillManifest> {
84 let path = manifest_path(skills_dir);
85 if !path.exists() {
86 return Ok(SkillManifest::default());
87 }
88
89 let content = fs::read_to_string(&path)
90 .with_context(|| format!("Failed to read manifest: {}", path.display()))?;
91 let manifest: SkillManifest = serde_json::from_str(&content)
92 .with_context(|| format!("Failed to parse manifest JSON: {}", path.display()))?;
93 Ok(manifest)
94}
95
96pub fn save_manifest(skills_dir: &Path, manifest: &SkillManifest) -> Result<()> {
97 fs::create_dir_all(skills_dir)
98 .with_context(|| format!("Failed to create skills dir: {}", skills_dir.display()))?;
99 let path = manifest_path(skills_dir);
100 let data = serde_json::to_string_pretty(manifest)?;
101 fs::write(&path, data)
102 .with_context(|| format!("Failed to write manifest: {}", path.display()))?;
103 Ok(())
104}
105
106pub fn upsert_installed_skill(
107 skills_dir: &Path,
108 skill_dir: &Path,
109 source: &str,
110) -> Result<SkillManifestEntry> {
111 upsert_installed_skill_with_admission(skills_dir, skill_dir, source, None)
112}
113
114pub fn upsert_installed_skill_with_admission(
116 skills_dir: &Path,
117 skill_dir: &Path,
118 source: &str,
119 admission_risk: Option<&str>,
120) -> Result<SkillManifestEntry> {
121 let mut manifest = load_manifest(skills_dir)?;
122 let mut entry = build_entry(skill_dir, source)?;
123 if let Some(r) = admission_risk {
124 entry.admission_risk = Some(r.to_string());
125 }
126 let key = skill_key(skill_dir)?;
127 manifest.skills.insert(key, entry.clone());
128 save_manifest(skills_dir, &manifest)?;
129 Ok(entry)
130}
131
132pub fn update_admission_risk(skills_dir: &Path, skill_dir: &Path, risk: &str) -> Result<()> {
134 let mut manifest = load_manifest(skills_dir)?;
135 let key = skill_key(skill_dir)?;
136 if let Some(entry) = manifest.skills.get_mut(&key) {
137 entry.admission_risk = Some(risk.to_string());
138 save_manifest(skills_dir, &manifest)?;
139 }
140 Ok(())
141}
142
143pub fn remove_skill_entry(skills_dir: &Path, skill_dir: &Path) -> Result<bool> {
144 let mut manifest = load_manifest(skills_dir)?;
145 let key = skill_key(skill_dir)?;
146 let removed = manifest.skills.remove(&key).is_some();
147 if removed {
148 save_manifest(skills_dir, &manifest)?;
149 }
150 Ok(removed)
151}
152
153pub fn evaluate_skill_status(skills_dir: &Path, skill_dir: &Path) -> Result<SkillIntegrityReport> {
154 let manifest = load_manifest(skills_dir)?;
155 let key = skill_key(skill_dir)?;
156 let entry = manifest.skills.get(&key).cloned();
157 let current_hash = compute_skill_fingerprint(skill_dir)?;
158 let signature_status = read_signature_status(skill_dir, ¤t_hash)?;
159
160 let status = if signature_status == SignatureStatus::Invalid {
161 SkillIntegrityStatus::SignatureInvalid
162 } else if let Some(ref item) = entry {
163 if item.hash != current_hash {
164 SkillIntegrityStatus::HashChanged
165 } else if signature_status == SignatureStatus::Unsigned {
166 SkillIntegrityStatus::Unsigned
167 } else {
168 SkillIntegrityStatus::Ok
169 }
170 } else if signature_status == SignatureStatus::Unsigned {
171 SkillIntegrityStatus::Unsigned
172 } else {
173 SkillIntegrityStatus::HashChanged
176 };
177
178 let source = entry.as_ref().map(|e| e.source.as_str());
179 let assessment = trust::assess_skill_trust(
180 source,
181 map_signature_signal(&signature_status),
182 map_integrity_signal(&status),
183 false,
184 false,
185 );
186
187 Ok(SkillIntegrityReport {
188 status,
189 current_hash,
190 signature_status,
191 entry,
192 trust_tier: assessment.tier,
193 trust_score: assessment.score,
194 trust_decision: assessment.decision,
195 trust_reasons: assessment.reasons,
196 })
197}
198
199fn build_entry(skill_dir: &Path, source: &str) -> Result<SkillManifestEntry> {
200 let meta = metadata::parse_skill_metadata(skill_dir)?;
201 let hash = compute_skill_fingerprint(skill_dir)?;
202 let signature_status = read_signature_status(skill_dir, &hash)?;
203 let integrity_status = match signature_status {
204 SignatureStatus::Invalid => SkillIntegrityStatus::SignatureInvalid,
205 SignatureStatus::Unsigned => SkillIntegrityStatus::Unsigned,
206 SignatureStatus::Valid => SkillIntegrityStatus::Ok,
207 };
208 let assessment = trust::assess_skill_trust(
209 Some(source),
210 map_signature_signal(&signature_status),
211 map_integrity_signal(&integrity_status),
212 false,
213 false,
214 );
215 Ok(SkillManifestEntry {
216 name: if meta.name.is_empty() {
217 skill_key(skill_dir)?
218 } else {
219 meta.name
220 },
221 source: source.to_string(),
222 version: meta.version,
223 hash,
224 signature_status,
225 trust_tier: assessment.tier,
226 trust_score: assessment.score,
227 tier_reason: assessment.reasons,
228 tier_updated_at: Some(Utc::now()),
229 installed_at: Utc::now(),
230 admission_risk: None,
231 })
232}
233
234fn map_signature_signal(signature_status: &SignatureStatus) -> SignatureSignal {
235 match signature_status {
236 SignatureStatus::Unsigned => SignatureSignal::Unsigned,
237 SignatureStatus::Valid => SignatureSignal::Valid,
238 SignatureStatus::Invalid => SignatureSignal::Invalid,
239 }
240}
241
242fn map_integrity_signal(status: &SkillIntegrityStatus) -> IntegritySignal {
243 match status {
244 SkillIntegrityStatus::Ok => IntegritySignal::Ok,
245 SkillIntegrityStatus::HashChanged => IntegritySignal::HashChanged,
246 SkillIntegrityStatus::SignatureInvalid => IntegritySignal::SignatureInvalid,
247 SkillIntegrityStatus::Unsigned => IntegritySignal::Unsigned,
248 }
249}
250
251fn skill_key(skill_dir: &Path) -> Result<String> {
252 skill_dir
253 .file_name()
254 .and_then(|n| n.to_str())
255 .map(|s| s.to_string())
256 .ok_or_else(|| anyhow::anyhow!("Invalid skill directory: {}", skill_dir.display()))
257}
258
259fn read_signature_status(skill_dir: &Path, hash: &str) -> Result<SignatureStatus> {
260 let sig_path = skill_dir.join("SKILL.sig");
261 if !sig_path.exists() {
262 return Ok(SignatureStatus::Unsigned);
263 }
264
265 let expected = fs::read_to_string(&sig_path)
266 .with_context(|| format!("Failed to read signature file: {}", sig_path.display()))?;
267 let expected = expected.trim();
268 if expected.is_empty() {
269 return Ok(SignatureStatus::Invalid);
270 }
271
272 if expected.eq_ignore_ascii_case(hash) {
273 Ok(SignatureStatus::Valid)
274 } else {
275 Ok(SignatureStatus::Invalid)
276 }
277}
278
279pub fn compute_skill_fingerprint(skill_dir: &Path) -> Result<String> {
280 let mut files = Vec::new();
281 collect_files(skill_dir, skill_dir, &mut files)?;
282 files.sort();
283
284 let mut hasher = Sha256::new();
285 for rel in files {
286 let file_path = skill_dir.join(&rel);
287 let content = fs::read(&file_path)
288 .with_context(|| format!("Failed to read file for hashing: {}", file_path.display()))?;
289 hasher.update(rel.as_bytes());
290 hasher.update([0u8]);
291 hasher.update(&content);
292 hasher.update([0u8]);
293 }
294 Ok(hex::encode(hasher.finalize()))
295}
296
297fn collect_files(root: &Path, current: &Path, out: &mut Vec<String>) -> Result<()> {
298 let mut entries: Vec<_> = fs::read_dir(current)
299 .with_context(|| format!("Failed to read directory: {}", current.display()))?
300 .flatten()
301 .collect();
302 entries.sort_by_key(|e| e.file_name());
303
304 let ignored_dirs: HashSet<&str> = HashSet::from([
305 ".git",
306 "__pycache__",
307 "node_modules",
308 "dist",
309 "build",
310 ".venv",
311 "venv",
312 ]);
313
314 for entry in entries {
315 let path = entry.path();
316 let name = entry.file_name();
317 let name = name.to_string_lossy();
318 if path.is_dir() {
319 if ignored_dirs.contains(name.as_ref()) {
320 continue;
321 }
322 collect_files(root, &path, out)?;
323 continue;
324 }
325 if !path.is_file() {
326 continue;
327 }
328 if name == MANIFEST_FILE_NAME || name == ".DS_Store" {
329 continue;
330 }
331 let rel = path
332 .strip_prefix(root)
333 .map(|p| p.to_string_lossy().replace('\\', "/"))
334 .unwrap_or_else(|_| path.to_string_lossy().replace('\\', "/"));
335 out.push(rel);
336 }
337 Ok(())
338}