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