1use 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
18const MAX_BUNDLE_SIZE: usize = 50 * 1024 * 1024;
20const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
22const MAX_FILE_COUNT: usize = 500;
24
25#[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
36pub 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
91pub 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
146pub 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
155fn 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
198fn 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
226fn 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
269fn 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#[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
313fn 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
345pub 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}