Skip to main content

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