1pub mod binary_analyzer;
2pub mod compiled_rules;
3pub mod markdown_analyzer;
4pub mod report;
5pub mod resource_analyzer;
6pub mod rules;
7pub mod script_analyzer;
8
9use serde::{Deserialize, Serialize};
10use std::fmt;
11use std::path::Path;
12
13use crate::error::Result;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum RiskLevel {
19 Pass,
20 Info,
21 Warn,
22 Danger,
23 Block,
24}
25
26impl fmt::Display for RiskLevel {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 match self {
29 RiskLevel::Pass => write!(f, "PASS"),
30 RiskLevel::Info => write!(f, "INFO"),
31 RiskLevel::Warn => write!(f, "WARN"),
32 RiskLevel::Danger => write!(f, "DANGER"),
33 RiskLevel::Block => write!(f, "BLOCK"),
34 }
35 }
36}
37
38impl std::str::FromStr for RiskLevel {
39 type Err = String;
40
41 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
42 match s.to_lowercase().as_str() {
43 "pass" => Ok(RiskLevel::Pass),
44 "info" => Ok(RiskLevel::Info),
45 "warn" => Ok(RiskLevel::Warn),
46 "danger" => Ok(RiskLevel::Danger),
47 "block" => Ok(RiskLevel::Block),
48 _ => Err(format!("invalid risk level: '{s}'")),
49 }
50 }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Finding {
56 pub rule_id: String,
57 pub level: RiskLevel,
58 pub message: String,
59 pub file: String,
60 pub line: Option<usize>,
61 pub context: Option<String>,
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct ScanReport {
67 pub findings: Vec<Finding>,
68}
69
70impl ScanReport {
71 pub fn new() -> Self {
72 Self::default()
73 }
74
75 pub fn overall_level(&self) -> RiskLevel {
77 self.findings
78 .iter()
79 .map(|f| f.level)
80 .max()
81 .unwrap_or(RiskLevel::Pass)
82 }
83
84 pub fn add(&mut self, finding: Finding) {
85 self.findings.push(finding);
86 }
87
88 pub fn merge(&mut self, other: ScanReport) {
89 self.findings.extend(other.findings);
90 }
91}
92
93pub struct ScanEngine;
95
96impl ScanEngine {
97 pub fn scan(skill_dir: &Path) -> Result<ScanReport> {
99 let mut report = ScanReport::new();
100
101 let skill_md = skill_dir.join("SKILL.md");
103 if skill_md.exists() {
104 let content = std::fs::read_to_string(&skill_md).map_err(|e| {
105 crate::error::SkillxError::Scan(format!("failed to read SKILL.md: {e}"))
106 })?;
107 let md_report = markdown_analyzer::MarkdownAnalyzer::analyze(&content, "SKILL.md");
108 report.merge(md_report);
109 }
110
111 let scripts_dir = skill_dir.join("scripts");
113 if scripts_dir.is_dir() {
114 Self::scan_directory(&scripts_dir, skill_dir, &mut report, true)?;
115 }
116
117 let refs_dir = skill_dir.join("references");
119 if refs_dir.is_dir() {
120 Self::scan_directory(&refs_dir, skill_dir, &mut report, false)?;
121 }
122
123 Self::scan_root_files(skill_dir, &mut report)?;
125
126 Ok(report)
127 }
128
129 fn scan_directory(
130 dir: &Path,
131 skill_dir: &Path,
132 report: &mut ScanReport,
133 is_scripts: bool,
134 ) -> Result<()> {
135 let entries = std::fs::read_dir(dir)
136 .map_err(|e| crate::error::SkillxError::Scan(format!("failed to read dir: {e}")))?;
137
138 for entry in entries {
139 let entry = entry
140 .map_err(|e| crate::error::SkillxError::Scan(format!("dir entry error: {e}")))?;
141 let path = entry.path();
142 let rel_path = path
143 .strip_prefix(skill_dir)
144 .unwrap_or(&path)
145 .to_string_lossy()
146 .to_string();
147
148 if path.is_dir() {
149 Self::scan_directory(&path, skill_dir, report, is_scripts)?;
150 continue;
151 }
152
153 if is_scripts {
154 let script_report = script_analyzer::ScriptAnalyzer::analyze(&path, &rel_path)?;
156 report.merge(script_report);
157 } else {
158 let res_report = resource_analyzer::ResourceAnalyzer::analyze(&path, &rel_path)?;
160 report.merge(res_report);
161 }
162 }
163
164 Ok(())
165 }
166
167 fn scan_root_files(skill_dir: &Path, report: &mut ScanReport) -> Result<()> {
168 let entries = std::fs::read_dir(skill_dir)
169 .map_err(|e| crate::error::SkillxError::Scan(format!("failed to read dir: {e}")))?;
170
171 for entry in entries {
172 let entry = entry
173 .map_err(|e| crate::error::SkillxError::Scan(format!("dir entry error: {e}")))?;
174 let path = entry.path();
175 if path.is_dir() {
176 continue;
177 }
178 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
179 if name == "SKILL.md" {
180 continue; }
182
183 let rel_path = path
184 .strip_prefix(skill_dir)
185 .unwrap_or(&path)
186 .to_string_lossy()
187 .to_string();
188
189 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
191 if matches!(
192 ext,
193 "py" | "sh" | "bash" | "js" | "ts" | "rb" | "pl" | "ps1"
194 ) {
195 let script_report = script_analyzer::ScriptAnalyzer::analyze(&path, &rel_path)?;
196 report.merge(script_report);
197 }
198 }
199
200 Ok(())
201 }
202}