Skip to main content

skilllite_core/skill/
manifest.rs

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    /// 准入扫描结果:safe/suspicious/malicious(仅 skill add 时写入,存量无此项)
48    #[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
114/// 同 upsert_installed_skill,可传入准入扫描结果(safe/suspicious/malicious)
115pub 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
132/// 仅更新已有 entry 的 admission_risk 字段,不重建整个 entry
133pub 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, &current_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        // No baseline fingerprint in manifest but signed payload exists.
174        // Treat as changed so execution requires an explicit re-install/update.
175        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}