1use 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
19const MAX_BUNDLE_SIZE: usize = 50 * 1024 * 1024;
21const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
23const MAX_TOTAL_UNCOMPRESSED_SIZE: u64 = 200 * 1024 * 1024;
29const MAX_FILE_COUNT: usize = 500;
31
32fn within_total_budget(total: u64) -> bool {
37 total <= MAX_TOTAL_UNCOMPRESSED_SIZE
38}
39
40#[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
51pub 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
106pub 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
161pub 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
170fn 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 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
225fn 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
239fn 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
267fn 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
314fn 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#[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
358fn 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
390pub 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 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 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}