Skip to main content

fastskill_core/http/handlers/
skill_storage.rs

1//! Skill storage service for managing uploaded skills
2
3use crate::core::service::FastSkillService;
4use crate::security::validate_path_component;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use tokio::fs;
11use uuid::Uuid;
12
13/// Skill metadata stored locally
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SkillMetadata {
16    pub id: String,
17    pub name: String,
18    pub description: String,
19    pub directory: String,
20    pub created_at: DateTime<Utc>,
21    pub updated_at: DateTime<Utc>,
22    pub latest_version: String,
23}
24
25/// Skill version information
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SkillVersion {
28    pub id: String,
29    pub skill_id: String,
30    pub version: String, // epoch timestamp
31    pub created_at: DateTime<Utc>,
32    pub name: String,
33    pub description: String,
34    pub directory: String,
35}
36
37/// Skill storage service for managing uploaded skills
38pub struct SkillStorage {
39    #[allow(dead_code)] // Reserved for future functionality
40    service: Arc<FastSkillService>,
41    skills_dir: PathBuf,
42}
43
44impl SkillStorage {
45    /// Create a new skill storage service
46    pub fn new(service: Arc<FastSkillService>, skills_dir: PathBuf) -> Self {
47        let canonical_skills_dir = Self::canonicalize_path(skills_dir);
48        Self {
49            service,
50            skills_dir: canonical_skills_dir,
51        }
52    }
53
54    /// Canonicalize a path if it exists, otherwise return as-is
55    fn canonicalize_path(path: PathBuf) -> PathBuf {
56        path.canonicalize().unwrap_or(path)
57    }
58
59    /// Generate a skill ID following Anthropic format (skill_01...)
60    pub fn generate_skill_id() -> String {
61        format!("skill_{}", Uuid::new_v4().simple())
62    }
63
64    /// Generate a version ID (epoch timestamp)
65    pub fn generate_version_id() -> String {
66        chrono::Utc::now().timestamp_millis().to_string()
67    }
68
69    /// Store a skill version from uploaded files
70    pub async fn store_skill_version(
71        &self,
72        skill_id: &str,
73        files: HashMap<String, Vec<u8>>,
74    ) -> Result<SkillVersion, crate::http::errors::HttpError> {
75        // Find SKILL.md file
76        let skill_md_content = files
77            .get("SKILL.md")
78            .or_else(|| {
79                // Look for files with SKILL.md in the path
80                files
81                    .keys()
82                    .find(|k| k.ends_with("SKILL.md"))
83                    .and_then(|k| files.get(k))
84            })
85            .ok_or_else(|| {
86                crate::http::errors::HttpError::BadRequest(
87                    "SKILL.md file not found in upload".to_string(),
88                )
89            })?;
90
91        // Parse SKILL.md frontmatter
92        let metadata = self.parse_skill_metadata(skill_md_content)?;
93
94        // Extract directory name from file paths
95        let directory_raw = self.extract_directory_name(&files)?;
96
97        // Validate and get safe directory name for path construction
98        let directory = validate_path_component(&directory_raw).map_err(|e| {
99            crate::http::errors::HttpError::BadRequest(format!("Invalid directory name: {}", e))
100        })?;
101
102        // Create version
103        let version_id = Self::generate_version_id();
104        let version = SkillVersion {
105            id: format!("skillver_{}", Uuid::new_v4().simple()),
106            skill_id: skill_id.to_string(),
107            version: version_id.clone(),
108            created_at: Utc::now(),
109            name: metadata.name,
110            description: metadata
111                .description
112                .unwrap_or_else(|| "No description provided".to_string()),
113            directory: directory.clone(),
114        };
115
116        // Store files - use validated directory string
117        let skill_path = self.skills_dir.join(&directory);
118        fs::create_dir_all(&skill_path).await.map_err(|e| {
119            crate::http::errors::HttpError::InternalServerError(format!(
120                "Failed to create skill directory: {}",
121                e
122            ))
123        })?;
124
125        for (filename, content) in files {
126            // Validate each path component of filename to prevent path traversal
127            let filename_safe = validate_path_component(&filename).map_err(|e| {
128                crate::http::errors::HttpError::BadRequest(format!("Invalid filename: {}", e))
129            })?;
130
131            let file_path = skill_path.join(&filename_safe);
132
133            // Ensure the file path is still under skill_path (prevent traversal)
134            let canonical_skill_path = skill_path.canonicalize().map_err(|e| {
135                crate::http::errors::HttpError::InternalServerError(format!(
136                    "Failed to resolve skill path: {}",
137                    e
138                ))
139            })?;
140
141            // Ensure parent directories exist
142            if let Some(parent) = file_path.parent() {
143                fs::create_dir_all(parent).await.map_err(|e| {
144                    crate::http::errors::HttpError::InternalServerError(format!(
145                        "Failed to create parent directory: {}",
146                        e
147                    ))
148                })?;
149            }
150            // Verify the resolved file path is under the canonical skill path
151            if file_path.starts_with(&canonical_skill_path) {
152                fs::write(file_path, content).await.map_err(|e| {
153                    crate::http::errors::HttpError::InternalServerError(format!(
154                        "Failed to write file: {}",
155                        e
156                    ))
157                })?;
158            } else {
159                return Err(crate::http::errors::HttpError::BadRequest(format!(
160                    "File path escapes skill directory: {}",
161                    filename_safe
162                )));
163            }
164        }
165
166        // Update skill metadata
167        self.update_skill_metadata(skill_id, &version).await?;
168
169        Ok(version)
170    }
171
172    /// Parse SKILL.md frontmatter to extract metadata
173    fn parse_skill_metadata(
174        &self,
175        content: &[u8],
176    ) -> Result<SkillFrontmatter, crate::http::errors::HttpError> {
177        let content_str = String::from_utf8(content.to_vec()).map_err(|e| {
178            crate::http::errors::HttpError::BadRequest(format!("Invalid UTF-8 content: {}", e))
179        })?;
180        let lines: Vec<&str> = content_str.lines().collect();
181
182        // Find YAML frontmatter (between ---)
183        let mut in_frontmatter = false;
184        let mut frontmatter_lines = Vec::new();
185
186        for line in lines {
187            if line.trim() == "---" {
188                if in_frontmatter {
189                    break; // End of frontmatter
190                } else {
191                    in_frontmatter = true;
192                    continue;
193                }
194            }
195
196            if in_frontmatter {
197                frontmatter_lines.push(line);
198            }
199        }
200
201        if frontmatter_lines.is_empty() {
202            return Err(crate::http::errors::HttpError::BadRequest(
203                "No YAML frontmatter found in SKILL.md".to_string(),
204            ));
205        }
206
207        let yaml_content = frontmatter_lines.join("\n");
208        let frontmatter: SkillFrontmatter = serde_yaml::from_str(&yaml_content).map_err(|e| {
209            crate::http::errors::HttpError::BadRequest(format!("Invalid YAML frontmatter: {}", e))
210        })?;
211
212        Ok(frontmatter)
213    }
214
215    /// Extract directory name from uploaded file paths
216    fn extract_directory_name(
217        &self,
218        files: &HashMap<String, Vec<u8>>,
219    ) -> Result<String, crate::http::errors::HttpError> {
220        // Find the common directory prefix
221        let mut directories = Vec::new();
222
223        for filename in files.keys() {
224            if let Some(dir) = Path::new(filename).parent() {
225                if let Some(dir_str) = dir.to_str() {
226                    directories.push(dir_str.to_string());
227                }
228            }
229        }
230
231        // Find the most common directory (should be the skill directory)
232        if directories.is_empty() {
233            return Err(crate::http::errors::HttpError::BadRequest(
234                "No directory structure found in uploaded files".to_string(),
235            ));
236        }
237
238        // Use the first directory as the skill directory name
239        // This assumes all files are under a single top-level directory
240        let directory = directories.into_iter().next().ok_or_else(|| {
241            crate::http::errors::HttpError::InternalServerError(
242                "Unexpected empty directories list".to_string(),
243            )
244        })?;
245        Ok(directory)
246    }
247
248    /// Update or create skill metadata
249    async fn update_skill_metadata(
250        &self,
251        skill_id: &str,
252        version: &SkillVersion,
253    ) -> Result<(), crate::http::errors::HttpError> {
254        let metadata_path = self.skills_dir.join("metadata.json");
255
256        // Load existing metadata or create new
257        let mut metadata: HashMap<String, SkillMetadata> = if metadata_path.exists() {
258            let content = fs::read_to_string(&metadata_path).await.map_err(|e| {
259                crate::http::errors::HttpError::InternalServerError(format!(
260                    "Failed to read metadata: {}",
261                    e
262                ))
263            })?;
264            serde_json::from_str(&content).map_err(|e| {
265                crate::http::errors::HttpError::InternalServerError(format!(
266                    "Failed to parse metadata: {}",
267                    e
268                ))
269            })?
270        } else {
271            HashMap::new()
272        };
273
274        // Update or create skill metadata
275        let skill_meta = metadata
276            .entry(skill_id.to_string())
277            .or_insert(SkillMetadata {
278                id: skill_id.to_string(),
279                name: version.name.clone(),
280                description: version.description.clone(),
281                directory: version.directory.clone(),
282                created_at: version.created_at,
283                updated_at: version.created_at,
284                latest_version: version.version.clone(),
285            });
286
287        skill_meta.updated_at = version.created_at;
288        skill_meta.latest_version = version.version.clone();
289
290        // Save metadata
291        let content = serde_json::to_string_pretty(&metadata).map_err(|e| {
292            crate::http::errors::HttpError::InternalServerError(format!(
293                "Failed to serialize metadata: {}",
294                e
295            ))
296        })?;
297        fs::write(metadata_path, content).await.map_err(|e| {
298            crate::http::errors::HttpError::InternalServerError(format!(
299                "Failed to write metadata: {}",
300                e
301            ))
302        })?;
303
304        Ok(())
305    }
306
307    /// Get skill metadata by ID
308    pub async fn get_skill(
309        &self,
310        skill_id: &str,
311    ) -> Result<Option<SkillMetadata>, crate::http::errors::HttpError> {
312        let metadata_path = self.skills_dir.join("metadata.json");
313
314        if !metadata_path.exists() {
315            return Ok(None);
316        }
317
318        let content = fs::read_to_string(metadata_path).await.map_err(|e| {
319            crate::http::errors::HttpError::InternalServerError(format!(
320                "Failed to read metadata: {}",
321                e
322            ))
323        })?;
324        let metadata: HashMap<String, SkillMetadata> =
325            serde_json::from_str(&content).map_err(|e| {
326                crate::http::errors::HttpError::InternalServerError(format!(
327                    "Failed to parse metadata: {}",
328                    e
329                ))
330            })?;
331
332        Ok(metadata.get(skill_id).cloned())
333    }
334
335    /// List all skills
336    pub async fn list_skills(&self) -> Result<Vec<SkillMetadata>, crate::http::errors::HttpError> {
337        let metadata_path = self.skills_dir.join("metadata.json");
338
339        if !metadata_path.exists() {
340            return Ok(Vec::new());
341        }
342
343        let content = fs::read_to_string(metadata_path).await.map_err(|e| {
344            crate::http::errors::HttpError::InternalServerError(format!(
345                "Failed to read metadata: {}",
346                e
347            ))
348        })?;
349        let metadata: HashMap<String, SkillMetadata> =
350            serde_json::from_str(&content).map_err(|e| {
351                crate::http::errors::HttpError::InternalServerError(format!(
352                    "Failed to parse metadata: {}",
353                    e
354                ))
355            })?;
356
357        Ok(metadata.values().cloned().collect())
358    }
359
360    /// Delete a skill and all its versions
361    pub async fn delete_skill(&self, skill_id: &str) -> Result<(), crate::http::errors::HttpError> {
362        // Get skill metadata
363        let skill_meta = match self.get_skill(skill_id).await? {
364            Some(meta) => meta,
365            None => return Ok(()), // Skill doesn't exist
366        };
367
368        // Validate directory name and get safe string for path construction
369        let directory_safe = validate_path_component(&skill_meta.directory).map_err(|e| {
370            crate::http::errors::HttpError::InternalServerError(format!(
371                "Invalid skill directory: {}",
372                e
373            ))
374        })?;
375
376        // Remove skill directory - use validated directory string
377        let skill_path = self.skills_dir.join(&directory_safe);
378
379        // Ensure the skill path is under skills_dir (prevent traversal)
380        let canonical_skills_dir = self.skills_dir.canonicalize().map_err(|e| {
381            crate::http::errors::HttpError::InternalServerError(format!(
382                "Failed to resolve skills directory: {}",
383                e
384            ))
385        })?;
386
387        if skill_path.exists() {
388            let canonical_skill_path = skill_path.canonicalize().map_err(|e| {
389                crate::http::errors::HttpError::InternalServerError(format!(
390                    "Failed to resolve skill path: {}",
391                    e
392                ))
393            })?;
394
395            if !canonical_skill_path.starts_with(&canonical_skills_dir) {
396                return Err(crate::http::errors::HttpError::BadRequest(
397                    "Skill path escapes skills directory".to_string(),
398                ));
399            }
400
401            fs::remove_dir_all(canonical_skill_path)
402                .await
403                .map_err(|e| {
404                    crate::http::errors::HttpError::InternalServerError(format!(
405                        "Failed to remove skill directory: {}",
406                        e
407                    ))
408                })?;
409        }
410
411        // Update metadata file
412        let metadata_path = self.skills_dir.join("metadata.json");
413        if metadata_path.exists() {
414            let content = fs::read_to_string(&metadata_path).await.map_err(|e| {
415                crate::http::errors::HttpError::InternalServerError(format!(
416                    "Failed to read metadata: {}",
417                    e
418                ))
419            })?;
420            let mut metadata: HashMap<String, SkillMetadata> = serde_json::from_str(&content)
421                .map_err(|e| {
422                    crate::http::errors::HttpError::InternalServerError(format!(
423                        "Failed to parse metadata: {}",
424                        e
425                    ))
426                })?;
427            metadata.remove(skill_id);
428
429            let updated_content = serde_json::to_string_pretty(&metadata).map_err(|e| {
430                crate::http::errors::HttpError::InternalServerError(format!(
431                    "Failed to serialize metadata: {}",
432                    e
433                ))
434            })?;
435            fs::write(metadata_path, updated_content)
436                .await
437                .map_err(|e| {
438                    crate::http::errors::HttpError::InternalServerError(format!(
439                        "Failed to write metadata: {}",
440                        e
441                    ))
442                })?;
443        }
444
445        Ok(())
446    }
447}
448
449/// Frontmatter structure from SKILL.md
450#[derive(Debug, Deserialize)]
451struct SkillFrontmatter {
452    name: String,
453    description: Option<String>,
454}
455
456impl Default for SkillFrontmatter {
457    fn default() -> Self {
458        Self {
459            name: "Unnamed Skill".to_string(),
460            description: Some("No description provided".to_string()),
461        }
462    }
463}