1use schemapin::pinning::KeyPinStore;
2use schemapin::skill::{load_signature, parse_skill_name, verify_skill_offline};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use super::config::SkillsConfig;
7use super::scanner::{ScanResult, SkillScanner};
8
9#[derive(Debug, Clone)]
11pub enum SignatureStatus {
12 Verified {
13 domain: String,
14 developer: Option<String>,
15 },
16 Pinned {
17 domain: String,
18 developer: Option<String>,
19 },
20 Unsigned,
21 Invalid {
22 reason: String,
23 },
24 Revoked {
25 reason: String,
26 },
27}
28
29impl std::fmt::Display for SignatureStatus {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 match self {
32 SignatureStatus::Verified { domain, .. } => write!(f, "Verified ({})", domain),
33 SignatureStatus::Pinned { domain, .. } => write!(f, "Pinned ({})", domain),
34 SignatureStatus::Unsigned => write!(f, "Unsigned"),
35 SignatureStatus::Invalid { reason } => write!(f, "Invalid: {}", reason),
36 SignatureStatus::Revoked { reason } => write!(f, "Revoked: {}", reason),
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct LoadedSkill {
44 pub name: String,
45 pub path: PathBuf,
46 pub signature_status: SignatureStatus,
47 pub content: String,
48 pub metadata: SkillMetadata,
49 pub scan_result: Option<ScanResult>,
50}
51
52#[derive(Debug, Clone)]
54pub struct SkillMetadata {
55 pub name: String,
56 pub description: Option<String>,
57 pub raw_frontmatter: HashMap<String, String>,
58}
59
60#[derive(Debug, thiserror::Error)]
62pub enum SkillLoadError {
63 #[error("IO error: {0}")]
64 Io(#[from] std::io::Error),
65 #[error("SKILL.md not found in {0}")]
66 MissingSkillMd(PathBuf),
67 #[error("Signature error: {0}")]
68 Signature(String),
69}
70
71pub struct SkillLoader {
73 config: SkillsConfig,
74 pin_store: KeyPinStore,
75 scanner: Option<SkillScanner>,
76}
77
78impl SkillLoader {
79 pub fn new(config: SkillsConfig) -> Self {
81 let scanner = if config.scan_enabled {
82 let custom_rules = config
83 .custom_deny_patterns
84 .iter()
85 .map(|p| super::scanner::ScanRule::DenyContentPattern(p.clone()))
86 .collect();
87 Some(SkillScanner::with_custom_rules(custom_rules))
88 } else {
89 None
90 };
91
92 Self {
93 config,
94 pin_store: KeyPinStore::new(),
95 scanner,
96 }
97 }
98
99 pub fn load_all(&mut self) -> Vec<LoadedSkill> {
101 let mut skills = Vec::new();
102
103 for load_path in self.config.load_paths.clone() {
104 if !load_path.exists() || !load_path.is_dir() {
105 continue;
106 }
107
108 if let Ok(entries) = std::fs::read_dir(&load_path) {
109 for entry in entries.filter_map(Result::ok) {
110 let path = entry.path();
111 if !path.is_dir() {
112 continue;
113 }
114 if path.join("SKILL.md").exists() {
116 match self.load_skill(&path) {
117 Ok(skill) => skills.push(skill),
118 Err(e) => {
119 tracing::warn!("Failed to load skill at {:?}: {}", path, e);
120 }
121 }
122 }
123 }
124 }
125 }
126
127 skills
128 }
129
130 pub fn load_skill(&mut self, path: &Path) -> Result<LoadedSkill, SkillLoadError> {
132 let skill_md = path.join("SKILL.md");
133 if !skill_md.exists() {
134 return Err(SkillLoadError::MissingSkillMd(path.to_path_buf()));
135 }
136
137 let content = std::fs::read_to_string(&skill_md)?;
138 let name = parse_skill_name(path);
139 let metadata = parse_frontmatter(&content, &name);
140 let signature_status = self.verify_skill(path);
141
142 let scan_result = self.scanner.as_ref().map(|s| s.scan_skill(path));
143
144 Ok(LoadedSkill {
145 name,
146 path: path.to_path_buf(),
147 signature_status,
148 content,
149 metadata,
150 scan_result,
151 })
152 }
153
154 pub fn verify_skill(&mut self, path: &Path) -> SignatureStatus {
156 let sig = match load_signature(path) {
158 Ok(sig) => sig,
159 Err(_) => {
160 if self.is_unsigned_allowed(path) {
162 return SignatureStatus::Unsigned;
163 }
164 if self.config.require_signed {
165 return SignatureStatus::Invalid {
166 reason: "No signature file (.schemapin.sig) found".into(),
167 };
168 }
169 return SignatureStatus::Unsigned;
170 }
171 };
172
173 let domain = &sig.domain;
174
175 let pin_store = if self.config.auto_pin {
184 Some(&mut self.pin_store)
185 } else {
186 None
187 };
188
189 let tool_id = sig.skill_name.clone();
190
191 if let Some(store) = &pin_store {
196 if store.get_tool(&tool_id, domain).is_some() {
197 return SignatureStatus::Pinned {
198 domain: domain.clone(),
199 developer: None,
200 };
201 }
202 }
203
204 SignatureStatus::Invalid {
207 reason: format!(
208 "Signature found for domain '{}' but no discovery document available for offline verification",
209 domain
210 ),
211 }
212 }
213
214 pub fn verify_skill_with_discovery(
216 &mut self,
217 path: &Path,
218 discovery: &schemapin::types::discovery::WellKnownResponse,
219 ) -> SignatureStatus {
220 let sig = match load_signature(path) {
221 Ok(sig) => sig,
222 Err(_) => {
223 return SignatureStatus::Invalid {
224 reason: "No signature file (.schemapin.sig) found".into(),
225 };
226 }
227 };
228
229 let tool_id = sig.skill_name.clone();
230
231 let pin_store = if self.config.auto_pin {
232 Some(&mut self.pin_store)
233 } else {
234 None
235 };
236
237 let result = verify_skill_offline(
238 path,
239 discovery,
240 Some(&sig),
241 None, pin_store,
243 Some(&tool_id),
244 );
245
246 if result.valid {
247 let domain = result.domain.clone().unwrap_or_default();
248 let developer = result.developer_name.clone();
249
250 if let Some(ref pin_status) = result.key_pinning {
251 if pin_status.status == "first_use" {
252 return SignatureStatus::Pinned { domain, developer };
253 }
254 }
255
256 SignatureStatus::Verified { domain, developer }
257 } else {
258 let reason = result
259 .error_message
260 .unwrap_or_else(|| "Verification failed".into());
261
262 if result
263 .error_code
264 .map(|c| c == schemapin::error::ErrorCode::KeyRevoked)
265 .unwrap_or(false)
266 {
267 SignatureStatus::Revoked { reason }
268 } else {
269 SignatureStatus::Invalid { reason }
270 }
271 }
272 }
273
274 fn is_unsigned_allowed(&self, path: &Path) -> bool {
276 for allowed in &self.config.allow_unsigned_from {
277 if let (Ok(canonical_allowed), Ok(canonical_path)) =
278 (std::fs::canonicalize(allowed), std::fs::canonicalize(path))
279 {
280 if canonical_path.starts_with(&canonical_allowed) {
281 return true;
282 }
283 }
284 if path.starts_with(allowed) {
286 return true;
287 }
288 }
289 false
290 }
291}
292
293fn parse_frontmatter(content: &str, fallback_name: &str) -> SkillMetadata {
295 let mut raw_frontmatter = HashMap::new();
296 let mut name = fallback_name.to_string();
297 let mut description = None;
298
299 if let Some(after_open) = content.strip_prefix("---") {
301 if let Some(end) = after_open.find("---") {
302 let fm_content = &after_open[..end];
303 for line in fm_content.lines() {
304 let line = line.trim();
305 if let Some(idx) = line.find(':') {
306 let key = line[..idx].trim().to_string();
307 let value = line[idx + 1..].trim().to_string();
308 if key == "name" && !value.is_empty() {
309 name = value.clone();
310 }
311 if key == "description" && !value.is_empty() {
312 description = Some(value.clone());
313 }
314 raw_frontmatter.insert(key, value);
315 }
316 }
317 }
318 }
319
320 SkillMetadata {
321 name,
322 description,
323 raw_frontmatter,
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn parse_frontmatter_with_name() {
333 let content = "---\nname: my-skill\ndescription: A test skill\n---\n# Content";
334 let meta = parse_frontmatter(content, "fallback");
335 assert_eq!(meta.name, "my-skill");
336 assert_eq!(meta.description.as_deref(), Some("A test skill"));
337 }
338
339 #[test]
340 fn parse_frontmatter_fallback() {
341 let content = "# No frontmatter here";
342 let meta = parse_frontmatter(content, "fallback");
343 assert_eq!(meta.name, "fallback");
344 assert!(meta.description.is_none());
345 }
346
347 #[test]
348 fn parse_frontmatter_empty() {
349 let content = "---\n---\n# Empty frontmatter";
350 let meta = parse_frontmatter(content, "fallback");
351 assert_eq!(meta.name, "fallback");
352 }
353
354 #[test]
355 fn load_skill_from_tempdir() {
356 let dir = tempfile::tempdir().unwrap();
357 let skill_dir = dir.path().join("test-skill");
358 std::fs::create_dir(&skill_dir).unwrap();
359 std::fs::write(
360 skill_dir.join("SKILL.md"),
361 "---\nname: test-skill\ndescription: A test\n---\n# Test Skill\nHello.",
362 )
363 .unwrap();
364
365 let config = SkillsConfig {
366 load_paths: vec![],
367 require_signed: false,
368 allow_unsigned_from: vec![dir.path().to_path_buf()],
369 auto_pin: false,
370 scan_enabled: true,
371 custom_deny_patterns: vec![],
372 };
373 let mut loader = SkillLoader::new(config);
374 let skill = loader.load_skill(&skill_dir).unwrap();
375 assert_eq!(skill.name, "test-skill");
376 assert!(matches!(skill.signature_status, SignatureStatus::Unsigned));
377 assert!(skill.scan_result.is_some());
378 assert!(skill.scan_result.unwrap().passed);
379 }
380
381 #[test]
382 fn load_skill_missing_skill_md() {
383 let dir = tempfile::tempdir().unwrap();
384 let skill_dir = dir.path().join("empty-skill");
385 std::fs::create_dir(&skill_dir).unwrap();
386
387 let config = SkillsConfig::default();
388 let mut loader = SkillLoader::new(config);
389 assert!(loader.load_skill(&skill_dir).is_err());
390 }
391
392 #[test]
393 fn load_all_from_empty_paths() {
394 let config = SkillsConfig {
395 load_paths: vec![PathBuf::from("/nonexistent/path")],
396 require_signed: false,
397 allow_unsigned_from: vec![],
398 auto_pin: false,
399 scan_enabled: false,
400 custom_deny_patterns: vec![],
401 };
402 let mut loader = SkillLoader::new(config);
403 let skills = loader.load_all();
404 assert!(skills.is_empty());
405 }
406
407 #[test]
408 fn load_all_discovers_skills() {
409 let dir = tempfile::tempdir().unwrap();
410 for name in &["skill-a", "skill-b"] {
412 let skill_dir = dir.path().join(name);
413 std::fs::create_dir(&skill_dir).unwrap();
414 std::fs::write(
415 skill_dir.join("SKILL.md"),
416 format!("---\nname: {}\n---\n# {}", name, name),
417 )
418 .unwrap();
419 }
420
421 let config = SkillsConfig {
422 load_paths: vec![dir.path().to_path_buf()],
423 require_signed: false,
424 allow_unsigned_from: vec![dir.path().to_path_buf()],
425 auto_pin: false,
426 scan_enabled: false,
427 custom_deny_patterns: vec![],
428 };
429 let mut loader = SkillLoader::new(config);
430 let skills = loader.load_all();
431 assert_eq!(skills.len(), 2);
432 }
433
434 #[test]
435 fn unsigned_allowed_check() {
436 let dir = tempfile::tempdir().unwrap();
437 let allowed = dir.path().join("allowed");
438 std::fs::create_dir(&allowed).unwrap();
439 let skill_dir = allowed.join("my-skill");
440 std::fs::create_dir(&skill_dir).unwrap();
441
442 let config = SkillsConfig {
443 load_paths: vec![],
444 require_signed: true,
445 allow_unsigned_from: vec![allowed.clone()],
446 auto_pin: false,
447 scan_enabled: false,
448 custom_deny_patterns: vec![],
449 };
450 let loader = SkillLoader::new(config);
451 assert!(loader.is_unsigned_allowed(&skill_dir));
452 }
453}