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, 200MB total uncompressed, 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::{Read, 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 total uncompressed size (200 MB)
23///
24/// `MAX_BUNDLE_SIZE` bounds the *compressed* input, but DEFLATE can expand by
25/// ~1000x, so an uncompressed budget must be enforced while writing. Without it
26/// a single small bundle can fill the disk (zip bomb).
27const MAX_TOTAL_UNCOMPRESSED_SIZE: u64 = 200 * 1024 * 1024;
28/// Maximum file count per bundle
29const MAX_FILE_COUNT: usize = 500;
30
31/// Whether `total` uncompressed bytes fit within the bundle budget.
32///
33/// Shared by extraction (incremental, per entry) and post-extraction
34/// validation so the two enforcement points cannot drift.
35fn within_total_budget(total: u64) -> bool {
36    total <= MAX_TOTAL_UNCOMPRESSED_SIZE
37}
38
39/// Result of importing a skill bundle
40#[derive(Debug, Clone)]
41pub struct ImportedSkillInfo {
42    name: String,
43    version: Option<String>,
44    description: String,
45    path: PathBuf,
46    file_count: usize,
47    total_size: u64,
48}
49
50/// Export a skill directory to a zip bundle (bytes)
51///
52/// Walks the skill directory and creates a zip archive.
53/// The archive preserves the directory structure with the skill name as root.
54pub fn export_skill_bundle(skill_root: &Path) -> Result<Vec<u8>> {
55    let skill_md = skill_root.join("SKILL.md");
56    if !skill_md.exists() {
57        bail!("No SKILL.md found at {}", skill_root.display());
58    }
59
60    let mut buf = Vec::new();
61    {
62        let cursor = std::io::Cursor::new(&mut buf);
63        let mut zip_writer = zip::ZipWriter::new(cursor);
64        let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
65
66        add_dir_to_zip(&mut zip_writer, skill_root, skill_root, options)?;
67        zip_writer.finish().context("Failed to finalize zip archive")?;
68    }
69
70    info!("Exported skill bundle from {}: {} bytes", skill_root.display(), buf.len());
71    Ok(buf)
72}
73
74fn add_dir_to_zip<W: Write + std::io::Seek>(
75    zip_writer: &mut zip::ZipWriter<W>,
76    root: &Path,
77    dir: &Path,
78    options: zip::write::SimpleFileOptions,
79) -> Result<()> {
80    for entry in fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
81        let entry = entry?;
82        let path = entry.path();
83        let rel = path
84            .strip_prefix(root)
85            .with_context(|| format!("stripping prefix from {}", path.display()))?;
86
87        if path.is_dir() {
88            let dir_name = format!("{}/", rel.to_string_lossy());
89            zip_writer
90                .add_directory(&dir_name, options)
91                .with_context(|| format!("adding directory {dir_name}"))?;
92            add_dir_to_zip(zip_writer, root, &path, options)?;
93        } else {
94            let name = rel.to_string_lossy().to_string();
95            zip_writer
96                .start_file(&name, options)
97                .with_context(|| format!("starting file {name}"))?;
98            let data = fs::read(&path).with_context(|| format!("reading file {}", path.display()))?;
99            zip_writer.write_all(&data)?;
100        }
101    }
102    Ok(())
103}
104
105/// Import a skill bundle (zip bytes) into the skill store
106///
107/// Extracts the zip, validates SKILL.md, and moves to versioned storage.
108/// Storage layout: `<dest_store>/<skill-name>/<version>/...`
109pub fn import_skill_bundle(zip_bytes: &[u8], dest_store: &Path) -> Result<ImportedSkillInfo> {
110    if zip_bytes.len() > MAX_BUNDLE_SIZE {
111        bail!("Bundle size {} bytes exceeds maximum {} bytes", zip_bytes.len(), MAX_BUNDLE_SIZE);
112    }
113
114    let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?;
115    let temp_path = temp_dir.path();
116
117    extract_zip_safely(zip_bytes, temp_path)?;
118
119    let skill_md_path = find_skill_md(temp_path)?;
120    let skill_root = skill_md_path.parent().unwrap_or(temp_path);
121
122    validate_extracted_bundle(skill_root)?;
123
124    let (manifest, _instructions) = crate::manifest::parse_skill_content(
125        &fs::read_to_string(&skill_md_path).context("Failed to read extracted SKILL.md")?,
126    )?;
127
128    let version = manifest.version.clone().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!("Overwriting existing skill version at {}", dest_dir.display());
133        fs::remove_dir_all(&dest_dir).context("Failed to remove existing version")?;
134    }
135    fs::create_dir_all(&dest_dir).context("Failed to create destination directory")?;
136
137    let (file_count, total_size) = copy_dir_recursive(skill_root, &dest_dir)?;
138
139    info!(
140        "Imported skill '{}' v{} ({} files, {} bytes) to {}",
141        manifest.name,
142        version,
143        file_count,
144        total_size,
145        dest_dir.display()
146    );
147
148    update_skill_index(dest_store, &manifest.name, &version)?;
149
150    Ok(ImportedSkillInfo {
151        name: manifest.name,
152        version: Some(version),
153        description: manifest.description,
154        path: dest_dir,
155        file_count,
156        total_size,
157    })
158}
159
160/// Import a skill from base64-encoded zip (inline bundle)
161pub fn import_inline_bundle(base64_data: &str, dest_store: &Path) -> Result<ImportedSkillInfo> {
162    use base64::Engine;
163    let bytes = base64::engine::general_purpose::STANDARD
164        .decode(base64_data)
165        .context("Failed to decode base64 bundle")?;
166    import_skill_bundle(&bytes, dest_store)
167}
168
169/// Safely extract a zip archive with path-traversal and size protections
170fn extract_zip_safely(zip_bytes: &[u8], dest: &Path) -> Result<()> {
171    let cursor = std::io::Cursor::new(zip_bytes);
172    let mut archive = zip::ZipArchive::new(cursor).context("Failed to open zip archive")?;
173
174    if archive.len() > MAX_FILE_COUNT {
175        bail!("Zip contains {} entries, exceeds maximum {}", archive.len(), MAX_FILE_COUNT);
176    }
177
178    let mut total_uncompressed: u64 = 0;
179
180    for i in 0..archive.len() {
181        let mut file = archive.by_index(i).with_context(|| format!("reading zip entry {i}"))?;
182        let raw_name = file.name().to_string();
183
184        if raw_name.contains("..") {
185            bail!("Path traversal detected in zip entry: {raw_name}");
186        }
187
188        let out_path = dest.join(&raw_name);
189
190        if !out_path.starts_with(dest) {
191            bail!("Zip entry escapes destination: {}", out_path.display());
192        }
193
194        if file.is_dir() {
195            fs::create_dir_all(&out_path).with_context(|| format!("creating dir {}", out_path.display()))?;
196        } else {
197            if file.size() > MAX_FILE_SIZE as u64 {
198                bail!("Zip entry '{}' ({} bytes) exceeds maximum {} bytes", raw_name, file.size(), MAX_FILE_SIZE);
199            }
200
201            if let Some(parent) = out_path.parent() {
202                fs::create_dir_all(parent)?;
203            }
204
205            let mut out_file =
206                fs::File::create(&out_path).with_context(|| format!("creating file {}", out_path.display()))?;
207            // Bound by bytes actually written: the archive's declared `file.size()`
208            // cannot be trusted, and the cumulative budget stops zip bombs.
209            let written = copy_bounded(&mut file, &mut out_file, MAX_FILE_SIZE as u64)
210                .with_context(|| format!("writing file {}", out_path.display()))?;
211            total_uncompressed += written;
212            if !within_total_budget(total_uncompressed) {
213                bail!(
214                    "Zip uncompressed size {total_uncompressed} bytes exceeds maximum {MAX_TOTAL_UNCOMPRESSED_SIZE} bytes"
215                );
216            }
217        }
218    }
219
220    Ok(())
221}
222
223/// Copy at most `limit` bytes from `reader` into `writer`.
224///
225/// Fails rather than silently truncating when the reader yields more than
226/// `limit`, so untrusted input whose declared size lies is still capped by the
227/// bytes actually written.
228fn copy_bounded<R: Read, W: Write>(reader: &mut R, writer: &mut W, limit: u64) -> Result<u64> {
229    let mut bounded = reader.take(limit.saturating_add(1));
230    let written = std::io::copy(&mut bounded, writer)?;
231    if written > limit {
232        bail!("entry exceeds maximum {limit} bytes");
233    }
234    Ok(written)
235}
236
237/// Find SKILL.md in extracted directory (handles nested structures)
238fn find_skill_md(dir: &Path) -> Result<PathBuf> {
239    let direct = dir.join("SKILL.md");
240    if direct.exists() {
241        return Ok(direct);
242    }
243    let direct_lower = dir.join("skill.md");
244    if direct_lower.exists() {
245        return Ok(direct_lower);
246    }
247
248    for entry in fs::read_dir(dir).context("Failed to read extracted directory")? {
249        let entry = entry?;
250        if entry.path().is_dir() {
251            let nested = entry.path().join("SKILL.md");
252            if nested.exists() {
253                return Ok(nested);
254            }
255            let nested_lower = entry.path().join("skill.md");
256            if nested_lower.exists() {
257                return Ok(nested_lower);
258            }
259        }
260    }
261
262    bail!("No SKILL.md found in extracted bundle")
263}
264
265/// Validate extracted bundle for security
266fn validate_extracted_bundle(skill_root: &Path) -> Result<()> {
267    let mut file_count = 0u64;
268    let mut total_size = 0u64;
269
270    validate_dir_recursive(skill_root, skill_root, &mut file_count, &mut total_size)?;
271
272    if file_count > MAX_FILE_COUNT as u64 {
273        bail!("Bundle contains {file_count} files, exceeds maximum {MAX_FILE_COUNT}");
274    }
275
276    if !within_total_budget(total_size) {
277        bail!("Bundle uncompressed size {total_size} bytes exceeds maximum {MAX_TOTAL_UNCOMPRESSED_SIZE} bytes");
278    }
279
280    Ok(())
281}
282
283fn validate_dir_recursive(root: &Path, dir: &Path, file_count: &mut u64, total_size: &mut u64) -> Result<()> {
284    for entry in fs::read_dir(dir)? {
285        let entry = entry?;
286        let path = entry.path();
287
288        if path.is_symlink() {
289            bail!("Symlinks not allowed in skill bundles: {}", path.display());
290        }
291
292        let canonical = canonicalize(&path).unwrap_or_else(|_| path.clone());
293        let root_canonical = canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
294        if !canonical.starts_with(&root_canonical) {
295            bail!("Path traversal detected: {} escapes bundle root", path.display());
296        }
297
298        if path.is_dir() {
299            validate_dir_recursive(root, &path, file_count, total_size)?;
300        } else {
301            *file_count += 1;
302            let size = entry.metadata()?.len();
303            if size > MAX_FILE_SIZE as u64 {
304                bail!("File {} ({size} bytes) exceeds maximum {MAX_FILE_SIZE} bytes", path.display(),);
305            }
306            *total_size += size;
307        }
308    }
309    Ok(())
310}
311
312/// Copy directory recursively, returning (file_count, total_bytes)
313fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(usize, u64)> {
314    let mut count = 0usize;
315    let mut size = 0u64;
316
317    for entry in fs::read_dir(src)? {
318        let entry = entry?;
319        let src_path = entry.path();
320        let file_name = entry.file_name();
321        let dst_path = dst.join(&file_name);
322
323        if src_path.is_dir() {
324            fs::create_dir_all(&dst_path)?;
325            let (c, s) = copy_dir_recursive(&src_path, &dst_path)?;
326            count += c;
327            size += s;
328        } else {
329            fs::copy(&src_path, &dst_path)
330                .with_context(|| format!("failed to copy {} to {}", src_path.display(), dst_path.display()))?;
331            count += 1;
332            size += entry
333                .metadata()
334                .with_context(|| format!("failed to stat {}", src_path.display()))?
335                .len();
336        }
337    }
338
339    Ok((count, size))
340}
341
342/// Skill store index for tracking versions
343#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
344pub struct SkillStoreIndex {
345    skills: HashMap<String, SkillVersionIndex>,
346}
347
348#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
349pub struct SkillVersionIndex {
350    latest_version: String,
351    #[serde(skip_serializing_if = "Option::is_none")]
352    default_version: Option<String>,
353    versions: Vec<String>,
354}
355
356/// Update the skill index after import
357fn update_skill_index(store_path: &Path, skill_name: &str, version: &str) -> Result<()> {
358    let index_path = store_path.join("index.json");
359
360    let mut index: SkillStoreIndex = if index_path.exists() {
361        let content = fs::read_to_string(&index_path)?;
362        serde_json::from_str(&content).unwrap_or_default()
363    } else {
364        SkillStoreIndex::default()
365    };
366
367    let entry = index.skills.entry(skill_name.to_string()).or_insert_with(|| SkillVersionIndex {
368        latest_version: version.to_string(),
369        default_version: None,
370        versions: Vec::new(),
371    });
372
373    if !entry.versions.contains(&version.to_string()) {
374        entry.versions.push(version.to_string());
375    }
376    entry.latest_version = version.to_string();
377
378    fs::create_dir_all(store_path)
379        .with_context(|| format!("failed to create store dir at {}", store_path.display()))?;
380    let index_json =
381        serde_json::to_vec_pretty(&index).with_context(|| format!("failed to serialize index for {skill_name}"))?;
382    fs::write(&index_path, index_json).with_context(|| format!("failed to write index at {}", index_path.display()))?;
383
384    debug!("Updated skill index at {}", index_path.display());
385    Ok(())
386}
387
388/// Load the skill store index
389pub fn load_skill_index(store_path: &Path) -> Result<SkillStoreIndex> {
390    let index_path = store_path.join("index.json");
391    if !index_path.exists() {
392        return Ok(SkillStoreIndex::default());
393    }
394    let content = fs::read_to_string(&index_path)?;
395    serde_json::from_str(&content).context("Failed to parse skill store index")
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_skill_store_index_default() {
404        let index = SkillStoreIndex::default();
405        assert!(index.skills.is_empty());
406    }
407
408    #[test]
409    fn test_skill_store_index_roundtrip() {
410        let mut index = SkillStoreIndex::default();
411        index.skills.insert(
412            "test-skill".to_string(),
413            SkillVersionIndex {
414                latest_version: "1.0.0".to_string(),
415                default_version: Some("1.0.0".to_string()),
416                versions: vec!["0.9.0".to_string(), "1.0.0".to_string()],
417            },
418        );
419        let json = serde_json::to_string(&index).expect("serialize");
420        let parsed: SkillStoreIndex = serde_json::from_str(&json).expect("deserialize");
421        assert_eq!(parsed.skills["test-skill"].latest_version, "1.0.0");
422        assert_eq!(parsed.skills["test-skill"].versions.len(), 2);
423    }
424
425    #[test]
426    fn test_bundle_size_limit() {
427        let oversized = vec![0u8; MAX_BUNDLE_SIZE + 1];
428        let temp = tempfile::tempdir().expect("tempdir");
429        let result = import_skill_bundle(&oversized, temp.path());
430        assert!(result.is_err());
431        assert!(result.expect_err("should fail").to_string().contains("exceeds maximum"));
432    }
433
434    /// Build an uncompressed zip archive from `(name, contents)` entries.
435    fn zip_bytes(entries: &[(&str, &[u8])]) -> Vec<u8> {
436        let mut buf = Vec::new();
437        {
438            let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
439            let options = zip::write::SimpleFileOptions::default();
440            for (name, data) in entries {
441                writer.start_file(*name, options).expect("start_file");
442                writer.write_all(data).expect("write_all");
443            }
444            writer.finish().expect("finish");
445        }
446        buf
447    }
448
449    #[test]
450    fn copy_bounded_accepts_exactly_the_limit() {
451        let data = vec![7u8; 10];
452        let mut out = Vec::new();
453        let written = copy_bounded(&mut data.as_slice(), &mut out, 10).expect("exact limit is allowed");
454        assert_eq!(written, 10);
455        assert_eq!(out.len(), 10);
456    }
457
458    #[test]
459    fn copy_bounded_rejects_one_byte_over_the_limit() {
460        let data = vec![7u8; 11];
461        let mut out = Vec::new();
462        let err = copy_bounded(&mut data.as_slice(), &mut out, 10).expect_err("over limit must fail");
463        assert!(err.to_string().contains("exceeds maximum"), "unexpected error: {err}");
464    }
465
466    #[test]
467    fn within_total_budget_boundaries() {
468        assert!(within_total_budget(0));
469        assert!(within_total_budget(MAX_TOTAL_UNCOMPRESSED_SIZE));
470        assert!(!within_total_budget(MAX_TOTAL_UNCOMPRESSED_SIZE + 1));
471    }
472
473    #[test]
474    fn extract_rejects_parent_directory_traversal() {
475        let bytes = zip_bytes(&[("../evil.txt", b"pwned")]);
476        let temp = tempfile::tempdir().expect("tempdir");
477        let err = extract_zip_safely(&bytes, temp.path()).expect_err("traversal must fail");
478        assert!(err.to_string().contains("Path traversal"), "unexpected error: {err}");
479        assert!(!temp.path().join("../evil.txt").exists());
480    }
481
482    #[test]
483    fn extract_rejects_absolute_path_entries() {
484        let bytes = zip_bytes(&[("/abs.txt", b"pwned")]);
485        let temp = tempfile::tempdir().expect("tempdir");
486        let err = extract_zip_safely(&bytes, temp.path()).expect_err("absolute path must fail");
487        assert!(err.to_string().contains("escapes destination"), "unexpected error: {err}");
488    }
489
490    #[test]
491    fn import_round_trips_an_exported_bundle() {
492        let source = tempfile::tempdir().expect("source tempdir");
493        let skill_dir = source.path().join("roundtrip-skill");
494        fs::create_dir_all(&skill_dir).expect("create skill dir");
495        fs::write(
496            skill_dir.join("SKILL.md"),
497            "---\nname: roundtrip-skill\ndescription: Round trip test skill\nversion: 1.2.3\n---\n# Body\n",
498        )
499        .expect("write SKILL.md");
500
501        let bytes = export_skill_bundle(&skill_dir).expect("export");
502
503        let store = tempfile::tempdir().expect("store tempdir");
504        let imported = import_skill_bundle(&bytes, store.path()).expect("import");
505        assert_eq!(imported.name, "roundtrip-skill");
506        assert!(imported.file_count >= 1, "expected at least SKILL.md to be copied");
507        assert!(imported.path.join("SKILL.md").exists(), "SKILL.md must land in the versioned store");
508    }
509}