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