pub mod patches;
use crate::core::file_error::{FileOperation, FileResultExt};
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const MAX_SKILL_FILES: usize = 1000;
const MAX_SKILL_SIZE_BYTES: u64 = 100 * 1024 * 1024;
const MAX_FRONTMATTER_SIZE_BYTES: usize = 64 * 1024;
const MAX_NAME_LENGTH: usize = 100;
const MAX_DESCRIPTION_LENGTH: usize = 1000;
#[derive(Debug, Clone)]
pub struct SkillDirectoryInfo {
pub files: Vec<String>,
pub total_size: u64,
pub skill_md_path: Option<PathBuf>,
pub skill_md_content: Option<String>,
}
fn collect_skill_directory_info(skill_path: &Path) -> Result<SkillDirectoryInfo> {
use walkdir::WalkDir;
if !skill_path.is_dir() {
return Err(anyhow!("Skill path {} is not a directory", skill_path.display()));
}
let mut files = Vec::new();
let mut total_size = 0u64;
let mut skill_md_path = None;
let mut skill_md_content = None;
for entry in WalkDir::new(skill_path).follow_links(false) {
let entry = entry?;
if entry.file_type().is_symlink() {
return Err(anyhow!(
"Skill at {} contains symlinks, which are not allowed for security reasons. \
Symlinks could point to sensitive files or cause unexpected behavior across platforms.",
skill_path.display()
));
}
if entry.file_type().is_file() {
let file_path = entry.path();
let relative_path = file_path
.strip_prefix(skill_path)
.map_err(|e| anyhow!("Failed to get relative path: {}", e))?
.to_string_lossy()
.to_string();
if relative_path == "SKILL.md" {
skill_md_path = Some(file_path.to_path_buf());
skill_md_content = Some(std::fs::read_to_string(file_path).with_file_context(
FileOperation::Read,
file_path,
"loading skill metadata",
"collect_skill_directory_info",
)?);
}
let metadata = entry.metadata()?;
total_size += metadata.len();
files.push(relative_path);
if files.len() > MAX_SKILL_FILES {
return Err(anyhow!(
"Skill at {} contains {} files, which exceeds the maximum limit of {} files. \
Skills should be focused and minimal. Consider splitting into multiple skills.",
skill_path.display(),
files.len(),
MAX_SKILL_FILES
));
}
if total_size > MAX_SKILL_SIZE_BYTES {
let size_mb = total_size as f64 / (1024.0 * 1024.0);
let limit_mb = MAX_SKILL_SIZE_BYTES as f64 / (1024.0 * 1024.0);
return Err(anyhow!(
"Skill at {} total size is {:.2} MB, which exceeds the maximum limit of {:.0} MB. \
Skills should be focused and minimal. Consider optimizing file sizes or removing unnecessary files.",
skill_path.display(),
size_mb,
limit_mb
));
}
}
}
files.sort();
Ok(SkillDirectoryInfo {
files,
total_size,
skill_md_path,
skill_md_content,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillFrontmatter {
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(rename = "allowed-tools", skip_serializing_if = "Option::is_none")]
pub allowed_tools: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dependencies: Option<serde_yaml::Value>,
}
pub fn validate_skill_frontmatter(content: &str) -> Result<SkillFrontmatter> {
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return Err(anyhow!(
"SKILL.md missing required YAML frontmatter. Format:\n---\nname: Skill Name\ndescription: What it does\n---\n# Content"
));
}
let frontmatter_str = parts[1].trim();
if frontmatter_str.len() > MAX_FRONTMATTER_SIZE_BYTES {
return Err(anyhow!(
"SKILL.md frontmatter exceeds maximum size of {} KB",
MAX_FRONTMATTER_SIZE_BYTES / 1024
));
}
let frontmatter: SkillFrontmatter = serde_yaml::from_str(frontmatter_str).map_err(|e| {
let char_count = frontmatter_str.chars().count();
let yaml_preview = if char_count > 80 {
let truncated: String = frontmatter_str.chars().take(80).collect();
format!("{}... ({} chars total)", truncated, char_count)
} else {
frontmatter_str.to_string()
};
anyhow!("Invalid SKILL.md frontmatter: {}\nYAML content:\n{}", e, yaml_preview)
})?;
if frontmatter.name.trim().is_empty() {
return Err(anyhow!("SKILL.md frontmatter missing required 'name' field"));
}
if frontmatter.description.trim().is_empty() {
return Err(anyhow!("SKILL.md frontmatter missing required 'description' field"));
}
if frontmatter.name.len() > MAX_NAME_LENGTH {
return Err(anyhow!("Skill name exceeds maximum length of {} characters", MAX_NAME_LENGTH));
}
if frontmatter.description.len() > MAX_DESCRIPTION_LENGTH {
return Err(anyhow!(
"Skill description exceeds maximum length of {} characters",
MAX_DESCRIPTION_LENGTH
));
}
if frontmatter.name.contains("..")
|| frontmatter.name.contains('/')
|| frontmatter.name.contains('\\')
{
return Err(anyhow!(
"Skill name contains path traversal sequences or path separators. \
Use ASCII letters, numbers, spaces, hyphens, and underscores only"
));
}
if !frontmatter
.name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' ')
{
return Err(anyhow!(
"Skill name contains invalid characters. Use ASCII letters, numbers, spaces, hyphens, and underscores only"
));
}
Ok(frontmatter)
}
pub async fn validate_skill_size(skill_path: &Path) -> Result<SkillDirectoryInfo> {
let path = skill_path.to_path_buf();
tokio::task::spawn_blocking(move || collect_skill_directory_info(&path))
.await
.map_err(|e| anyhow!("Task join error during skill validation: {}", e))?
}
pub async fn extract_skill_metadata(skill_path: &Path) -> Result<(SkillFrontmatter, Vec<String>)> {
tracing::debug!("extract_skill_metadata called with path: {}", skill_path.display());
let path = skill_path.to_path_buf();
let display_path = skill_path.display().to_string();
let info = tokio::task::spawn_blocking(move || collect_skill_directory_info(&path))
.await
.map_err(|e| anyhow!("Task join error during skill metadata extraction: {}", e))??;
let skill_md_content = info
.skill_md_content
.ok_or_else(|| anyhow!("Skill at {} missing required SKILL.md file", display_path))?;
let frontmatter = validate_skill_frontmatter(&skill_md_content)?;
tracing::debug!(
"Extracted metadata for skill '{}': {} files, {} bytes",
frontmatter.name,
info.files.len(),
info.total_size
);
Ok((frontmatter, info.files))
}
pub fn extract_skill_metadata_from_info(
info: &SkillDirectoryInfo,
skill_path: &Path,
) -> Result<(SkillFrontmatter, Vec<String>)> {
let skill_md_content = info.skill_md_content.as_ref().ok_or_else(|| {
anyhow!("Skill at {} missing required SKILL.md file", skill_path.display())
})?;
let frontmatter = validate_skill_frontmatter(skill_md_content)?;
Ok((frontmatter, info.files.clone()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_skill_frontmatter_valid() {
let content = r#"---
name: Test Skill
description: A test skill
version: 1.0.0
allowed-tools:
- Read
- Write
dependencies:
agents:
- path: helper.md
---
# Test Skill
This is a test skill.
"#;
let result = validate_skill_frontmatter(content).unwrap();
assert_eq!(result.name, "Test Skill");
assert_eq!(result.description, "A test skill");
assert_eq!(result.version, Some("1.0.0".to_string()));
assert_eq!(result.allowed_tools, Some(vec!["Read".to_string(), "Write".to_string()]));
}
#[test]
fn test_validate_skill_frontmatter_missing_fields() {
let content = r#"---
name: Test Skill
---
# Test Skill
"#;
let result = validate_skill_frontmatter(content);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("description"));
}
#[test]
fn test_validate_skill_frontmatter_no_frontmatter() {
let content = r#"# Test Skill
This skill has no frontmatter.
"#;
let result = validate_skill_frontmatter(content);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("missing required YAML frontmatter"));
}
#[test]
fn test_validate_skill_frontmatter_invalid_yaml() {
let content = r#"---
name: Test Skill
description: Invalid YAML
unclosed: [ "item1", "item2"
---
# Test Skill
"#;
let result = validate_skill_frontmatter(content);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid SKILL.md frontmatter"));
}
}