Skip to main content

vtcode_core/skills/
bundle.rs

1//! Skill bundle import/export
2//!
3//! Supports zip-based packaging of skills for distribution, version management,
4//! and inline bundle injection. Follows OpenAI Skills API packaging patterns:
5//! - Safe zip extraction with path traversal protection
6//! - Size limits (50MB compressed, 25MB per file, 500 files max)
7//! - Manifest validation after extraction
8//! - Versioned storage layout
9
10use anyhow::{Context, Result, bail};
11use hashbrown::HashMap;
12use std::fs;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15use tracing::{debug, info, warn};
16
17/// Maximum compressed bundle size (50 MB)
18pub const MAX_BUNDLE_SIZE: usize = 50 * 1024 * 1024;
19/// Maximum uncompressed file size (25 MB)
20pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
21/// Maximum file count per bundle
22pub const MAX_FILE_COUNT: usize = 500;
23
24/// Result of importing a skill bundle
25#[derive(Debug, Clone)]
26pub struct ImportedSkillInfo {
27    pub name: String,
28    pub version: Option<String>,
29    pub description: String,
30    pub path: PathBuf,
31    pub file_count: usize,
32    pub total_size: u64,
33}
34
35/// Export a skill directory to a zip bundle (bytes)
36///
37/// Walks the skill directory and creates a zip archive.
38/// The archive preserves the directory structure with the skill name as root.
39pub fn export_skill_bundle(skill_root: &Path) -> Result<Vec<u8>> {
40    let skill_md = skill_root.join("SKILL.md");
41    if !skill_md.exists() {
42        bail!("No SKILL.md found at {}", skill_root.display());
43    }
44
45    let mut buf = Vec::new();
46    {
47        let cursor = std::io::Cursor::new(&mut buf);
48        let mut zip_writer = zip::ZipWriter::new(cursor);
49        let options = zip::write::SimpleFileOptions::default()
50            .compression_method(zip::CompressionMethod::Deflated);
51
52        add_dir_to_zip(&mut zip_writer, skill_root, skill_root, options)?;
53        zip_writer
54            .finish()
55            .context("Failed to finalize zip archive")?;
56    }
57
58    info!(
59        "Exported skill bundle from {}: {} bytes",
60        skill_root.display(),
61        buf.len()
62    );
63    Ok(buf)
64}
65
66fn add_dir_to_zip<W: Write + std::io::Seek>(
67    zip_writer: &mut zip::ZipWriter<W>,
68    root: &Path,
69    dir: &Path,
70    options: zip::write::SimpleFileOptions,
71) -> Result<()> {
72    for entry in fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
73        let entry = entry?;
74        let path = entry.path();
75        let rel = path
76            .strip_prefix(root)
77            .with_context(|| format!("stripping prefix from {}", path.display()))?;
78
79        if path.is_dir() {
80            let dir_name = format!("{}/", rel.to_string_lossy());
81            zip_writer
82                .add_directory(&dir_name, options)
83                .with_context(|| format!("adding directory {dir_name}"))?;
84            add_dir_to_zip(zip_writer, root, &path, options)?;
85        } else {
86            let name = rel.to_string_lossy().to_string();
87            zip_writer
88                .start_file(&name, options)
89                .with_context(|| format!("starting file {name}"))?;
90            let data =
91                fs::read(&path).with_context(|| format!("reading file {}", path.display()))?;
92            zip_writer.write_all(&data)?;
93        }
94    }
95    Ok(())
96}
97
98/// Import a skill bundle (zip bytes) into the skill store
99///
100/// Extracts the zip, validates SKILL.md, and moves to versioned storage.
101/// Storage layout: `<dest_store>/<skill-name>/<version>/...`
102pub fn import_skill_bundle(zip_bytes: &[u8], dest_store: &Path) -> Result<ImportedSkillInfo> {
103    if zip_bytes.len() > MAX_BUNDLE_SIZE {
104        bail!(
105            "Bundle size {} bytes exceeds maximum {} bytes",
106            zip_bytes.len(),
107            MAX_BUNDLE_SIZE
108        );
109    }
110
111    let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?;
112    let temp_path = temp_dir.path();
113
114    extract_zip_safely(zip_bytes, temp_path)?;
115
116    let skill_md_path = find_skill_md(temp_path)?;
117    let skill_root = skill_md_path.parent().unwrap_or(temp_path);
118
119    validate_extracted_bundle(skill_root)?;
120
121    let (manifest, _instructions) = crate::skills::manifest::parse_skill_content(
122        &fs::read_to_string(&skill_md_path).context("Failed to read extracted SKILL.md")?,
123    )?;
124
125    let version = manifest
126        .version
127        .clone()
128        .unwrap_or_else(|| "0.0.0".to_string());
129
130    let dest_dir = dest_store.join(&manifest.name).join(&version);
131    if dest_dir.exists() {
132        warn!(
133            "Overwriting existing skill version at {}",
134            dest_dir.display()
135        );
136        fs::remove_dir_all(&dest_dir).context("Failed to remove existing version")?;
137    }
138    fs::create_dir_all(&dest_dir).context("Failed to create destination directory")?;
139
140    let (file_count, total_size) = copy_dir_recursive(skill_root, &dest_dir)?;
141
142    info!(
143        "Imported skill '{}' v{} ({} files, {} bytes) to {}",
144        manifest.name,
145        version,
146        file_count,
147        total_size,
148        dest_dir.display()
149    );
150
151    update_skill_index(dest_store, &manifest.name, &version)?;
152
153    Ok(ImportedSkillInfo {
154        name: manifest.name,
155        version: Some(version),
156        description: manifest.description,
157        path: dest_dir,
158        file_count,
159        total_size,
160    })
161}
162
163/// Import a skill from base64-encoded zip (inline bundle)
164pub fn import_inline_bundle(base64_data: &str, dest_store: &Path) -> Result<ImportedSkillInfo> {
165    use base64::Engine;
166    let bytes = base64::engine::general_purpose::STANDARD
167        .decode(base64_data)
168        .context("Failed to decode base64 bundle")?;
169    import_skill_bundle(&bytes, dest_store)
170}
171
172/// Safely extract a zip archive with path-traversal and size protections
173fn extract_zip_safely(zip_bytes: &[u8], dest: &Path) -> Result<()> {
174    let cursor = std::io::Cursor::new(zip_bytes);
175    let mut archive = zip::ZipArchive::new(cursor).context("Failed to open zip archive")?;
176
177    if archive.len() > MAX_FILE_COUNT {
178        bail!(
179            "Zip contains {} entries, exceeds maximum {}",
180            archive.len(),
181            MAX_FILE_COUNT
182        );
183    }
184
185    for i in 0..archive.len() {
186        let mut file = archive
187            .by_index(i)
188            .with_context(|| format!("reading zip entry {i}"))?;
189        let raw_name = file.name().to_string();
190
191        if raw_name.contains("..") {
192            bail!("Path traversal detected in zip entry: {raw_name}");
193        }
194
195        let out_path = dest.join(&raw_name);
196
197        if !out_path.starts_with(dest) {
198            bail!("Zip entry escapes destination: {}", out_path.display());
199        }
200
201        if file.is_dir() {
202            fs::create_dir_all(&out_path)
203                .with_context(|| format!("creating dir {}", out_path.display()))?;
204        } else {
205            if file.size() > MAX_FILE_SIZE as u64 {
206                bail!(
207                    "Zip entry '{}' ({} bytes) exceeds maximum {} bytes",
208                    raw_name,
209                    file.size(),
210                    MAX_FILE_SIZE
211                );
212            }
213
214            if let Some(parent) = out_path.parent() {
215                fs::create_dir_all(parent)?;
216            }
217
218            let mut out_file = fs::File::create(&out_path)
219                .with_context(|| format!("creating file {}", out_path.display()))?;
220            std::io::copy(&mut file, &mut out_file)
221                .with_context(|| format!("writing file {}", out_path.display()))?;
222        }
223    }
224
225    Ok(())
226}
227
228/// Find SKILL.md in extracted directory (handles nested structures)
229fn find_skill_md(dir: &Path) -> Result<PathBuf> {
230    let direct = dir.join("SKILL.md");
231    if direct.exists() {
232        return Ok(direct);
233    }
234    let direct_lower = dir.join("skill.md");
235    if direct_lower.exists() {
236        return Ok(direct_lower);
237    }
238
239    for entry in fs::read_dir(dir).context("Failed to read extracted directory")? {
240        let entry = entry?;
241        if entry.path().is_dir() {
242            let nested = entry.path().join("SKILL.md");
243            if nested.exists() {
244                return Ok(nested);
245            }
246            let nested_lower = entry.path().join("skill.md");
247            if nested_lower.exists() {
248                return Ok(nested_lower);
249            }
250        }
251    }
252
253    bail!("No SKILL.md found in extracted bundle")
254}
255
256/// Validate extracted bundle for security
257fn validate_extracted_bundle(skill_root: &Path) -> Result<()> {
258    let mut file_count = 0u64;
259    let mut total_size = 0u64;
260
261    validate_dir_recursive(skill_root, skill_root, &mut file_count, &mut total_size)?;
262
263    if file_count > MAX_FILE_COUNT as u64 {
264        bail!("Bundle contains {file_count} files, exceeds maximum {MAX_FILE_COUNT}");
265    }
266
267    Ok(())
268}
269
270fn validate_dir_recursive(
271    root: &Path,
272    dir: &Path,
273    file_count: &mut u64,
274    total_size: &mut u64,
275) -> Result<()> {
276    for entry in fs::read_dir(dir)? {
277        let entry = entry?;
278        let path = entry.path();
279
280        if path.is_symlink() {
281            bail!("Symlinks not allowed in skill bundles: {}", path.display());
282        }
283
284        let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
285        let root_canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
286        if !canonical.starts_with(&root_canonical) {
287            bail!(
288                "Path traversal detected: {} escapes bundle root",
289                path.display()
290            );
291        }
292
293        if path.is_dir() {
294            validate_dir_recursive(root, &path, file_count, total_size)?;
295        } else {
296            *file_count += 1;
297            let size = entry.metadata()?.len();
298            if size > MAX_FILE_SIZE as u64 {
299                bail!(
300                    "File {} ({size} bytes) exceeds maximum {MAX_FILE_SIZE} bytes",
301                    path.display(),
302                );
303            }
304            *total_size += size;
305        }
306    }
307    Ok(())
308}
309
310/// Copy directory recursively, returning (file_count, total_bytes)
311fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(usize, u64)> {
312    let mut count = 0usize;
313    let mut size = 0u64;
314
315    for entry in fs::read_dir(src)? {
316        let entry = entry?;
317        let src_path = entry.path();
318        let file_name = entry.file_name();
319        let dst_path = dst.join(&file_name);
320
321        if src_path.is_dir() {
322            fs::create_dir_all(&dst_path)?;
323            let (c, s) = copy_dir_recursive(&src_path, &dst_path)?;
324            count += c;
325            size += s;
326        } else {
327            fs::copy(&src_path, &dst_path).with_context(|| {
328                format!(
329                    "failed to copy {} to {}",
330                    src_path.display(),
331                    dst_path.display()
332                )
333            })?;
334            count += 1;
335            size += entry
336                .metadata()
337                .with_context(|| format!("failed to stat {}", src_path.display()))?
338                .len();
339        }
340    }
341
342    Ok((count, size))
343}
344
345/// Skill store index for tracking versions
346#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
347pub struct SkillStoreIndex {
348    pub skills: HashMap<String, SkillVersionIndex>,
349}
350
351#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
352pub struct SkillVersionIndex {
353    pub latest_version: String,
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub default_version: Option<String>,
356    pub versions: Vec<String>,
357}
358
359/// Update the skill index after import
360fn update_skill_index(store_path: &Path, skill_name: &str, version: &str) -> Result<()> {
361    let index_path = store_path.join("index.json");
362
363    let mut index: SkillStoreIndex = if index_path.exists() {
364        let content = fs::read_to_string(&index_path)?;
365        serde_json::from_str(&content).unwrap_or_default()
366    } else {
367        SkillStoreIndex::default()
368    };
369
370    let entry = index
371        .skills
372        .entry(skill_name.to_string())
373        .or_insert_with(|| SkillVersionIndex {
374            latest_version: version.to_string(),
375            default_version: None,
376            versions: Vec::new(),
377        });
378
379    if !entry.versions.contains(&version.to_string()) {
380        entry.versions.push(version.to_string());
381    }
382    entry.latest_version = version.to_string();
383
384    fs::create_dir_all(store_path)
385        .with_context(|| format!("failed to create store dir at {}", store_path.display()))?;
386    let index_json = serde_json::to_string_pretty(&index)
387        .with_context(|| format!("failed to serialize index for {}", skill_name))?;
388    fs::write(&index_path, &index_json)
389        .with_context(|| format!("failed to write index at {}", index_path.display()))?;
390
391    debug!("Updated skill index at {}", index_path.display());
392    Ok(())
393}
394
395/// Load the skill store index
396pub fn load_skill_index(store_path: &Path) -> Result<SkillStoreIndex> {
397    let index_path = store_path.join("index.json");
398    if !index_path.exists() {
399        return Ok(SkillStoreIndex::default());
400    }
401    let content = fs::read_to_string(&index_path)?;
402    serde_json::from_str(&content).context("Failed to parse skill store index")
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_skill_store_index_default() {
411        let index = SkillStoreIndex::default();
412        assert!(index.skills.is_empty());
413    }
414
415    #[test]
416    fn test_skill_store_index_roundtrip() {
417        let mut index = SkillStoreIndex::default();
418        index.skills.insert(
419            "test-skill".to_string(),
420            SkillVersionIndex {
421                latest_version: "1.0.0".to_string(),
422                default_version: Some("1.0.0".to_string()),
423                versions: vec!["0.9.0".to_string(), "1.0.0".to_string()],
424            },
425        );
426        let json = serde_json::to_string(&index).expect("serialize");
427        let parsed: SkillStoreIndex = serde_json::from_str(&json).expect("deserialize");
428        assert_eq!(parsed.skills["test-skill"].latest_version, "1.0.0");
429        assert_eq!(parsed.skills["test-skill"].versions.len(), 2);
430    }
431
432    #[test]
433    fn test_bundle_size_limit() {
434        let oversized = vec![0u8; MAX_BUNDLE_SIZE + 1];
435        let temp = tempfile::tempdir().expect("tempdir");
436        let result = import_skill_bundle(&oversized, temp.path());
437        assert!(result.is_err());
438        assert!(
439            result
440                .expect_err("should fail")
441                .to_string()
442                .contains("exceeds maximum")
443        );
444    }
445}