Skip to main content

oxios_kernel/skill/
archive.rs

1//! Shared skill-archive extraction.
2//!
3//! Stateless helpers for extracting skill `.zip` archives into a target
4//! directory. Both the ClawHub marketplace installer and the user-driven
5//! `.skill` file import use these so that the Zip-Slip defense
6//! ([`crate::skill::is_safe_relative_path`]) and the `SKILL.md` marker
7//! detection live in exactly one place.
8
9use std::fs;
10use std::io::{Read, Seek};
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use zip::ZipArchive;
15
16/// Marker filenames that identify a skill root, in order of preference.
17const MARKERS: &[&str] = &["SKILL.md", "skill.md", "skills.md"];
18
19/// Find the directory prefix inside the archive that contains a `SKILL.md`
20/// marker. Returns `""` when the marker sits at the archive root.
21///
22/// Some archives zip the skill directory directly (`code-review/SKILL.md`),
23/// others zip its contents (`SKILL.md`). This detects the common prefix so
24/// extraction can strip it and land contents flat in `target`.
25pub fn find_skill_root<R: Read + Seek>(zip: &mut ZipArchive<R>) -> Result<String> {
26    for i in 0..zip.len() {
27        let name = zip
28            .by_index(i)
29            .context("read zip entry")?
30            .name()
31            .to_string();
32        let name_lower = name.to_lowercase();
33        if MARKERS.iter().any(|m| {
34            name_lower.ends_with(&format!("/{}", m.to_lowercase()))
35                || name_lower == m.to_lowercase()
36        }) {
37            // Return everything up to and including the directory component.
38            if let Some(slash) = name
39                .strip_prefix('/')
40                .and_then(|s| s.rfind('/'))
41                .map(|p| p + 1)
42            {
43                return Ok(name[..slash].to_string());
44            }
45            // File is at root level.
46            if let Some(last_slash) = name.rfind('/') {
47                return Ok(name[..=last_slash].to_string());
48            }
49            // No slash — the root is the archive root (empty prefix).
50            return Ok(String::new());
51        }
52    }
53
54    // Fallback: no marker found — extract everything at root.
55    tracing::warn!("no SKILL.md marker found in archive, extracting all entries");
56    Ok(String::new())
57}
58
59/// Extract a skill zip into `target`, stripping the detected skill-root prefix
60/// so contents land flat in `target`. Defends against Zip Slip via
61/// [`crate::skill::is_safe_relative_path`].
62pub fn extract_skill_zip<R: Read + Seek>(zip: &mut ZipArchive<R>, target: &Path) -> Result<()> {
63    let root_prefix = find_skill_root(zip).context("parse zip archive")?;
64
65    for i in 0..zip.len() {
66        let mut file = zip.by_index(i).context("read zip entry")?;
67        let name = file.name().to_string();
68
69        // Strip the detected root prefix.
70        let relative = match name.strip_prefix(&root_prefix) {
71            Some(rest) => rest.to_string(),
72            None => continue, // entry outside the root — skip
73        };
74
75        // Normalize separators.
76        let relative = relative.replace('\\', "/");
77        if relative.is_empty() || relative == "/" {
78            continue;
79        }
80
81        // Zip Slip defense: reject entries whose relative path could escape
82        // `target` (absolute paths, `..`, drive prefixes).
83        if !crate::skill::is_safe_relative_path(&relative) {
84            tracing::warn!("skill archive: skipping entry with unsafe path: {relative}");
85            continue;
86        }
87        let out_path = target.join(&relative);
88
89        if file.is_dir() {
90            fs::create_dir_all(&out_path).context("create extracted dir")?;
91        } else {
92            if let Some(parent) = out_path.parent() {
93                fs::create_dir_all(parent).context("create parent dir")?;
94            }
95            let mut dst = fs::File::create(&out_path).context("create output file")?;
96            std::io::copy(&mut file, &mut dst).context("copy zip entry")?;
97        }
98    }
99
100    Ok(())
101}
102
103/// Locate the first `SKILL.md` (case-insensitive) within `dir` recursively.
104/// Returns its path, or `None` if no marker is present.
105pub fn find_skill_md(dir: &Path) -> Option<PathBuf> {
106    let mut stack = vec![dir.to_path_buf()];
107    while let Some(d) = stack.pop() {
108        let Ok(entries) = fs::read_dir(&d) else {
109            continue;
110        };
111        for entry in entries.flatten() {
112            let path = entry.path();
113            if path.is_dir() {
114                stack.push(path);
115            } else if path
116                .file_name()
117                .and_then(|n| n.to_str())
118                .map(|n| matches!(n.to_lowercase().as_str(), "skill.md"))
119                .unwrap_or(false)
120            {
121                return Some(path);
122            }
123        }
124    }
125    None
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::io::Write;
132
133    fn build_zip(entries: &[(&str, &[u8])]) -> ZipArchive<std::io::Cursor<Vec<u8>>> {
134        let mut buf = Vec::new();
135        {
136            let mut zipw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
137            for (name, data) in entries {
138                zipw.start_file(*name, zip::write::SimpleFileOptions::default())
139                    .unwrap();
140                zipw.write_all(data).unwrap();
141            }
142            zipw.finish().unwrap();
143        }
144        zip::ZipArchive::new(std::io::Cursor::new(buf)).unwrap()
145    }
146
147    #[test]
148    fn test_find_skill_root_nested() {
149        let mut arch = build_zip(&[("code-review/SKILL.md", b"# Code Review\n")]);
150        assert_eq!(find_skill_root(&mut arch).unwrap(), "code-review/");
151    }
152
153    #[test]
154    fn test_find_skill_root_root_level() {
155        let mut arch = build_zip(&[("SKILL.md", b"# Skill\n")]);
156        assert_eq!(find_skill_root(&mut arch).unwrap(), "");
157    }
158
159    #[test]
160    fn test_extract_flattens_prefix() {
161        // Nested skill dir → contents land flat in target.
162        let mut arch = build_zip(&[
163            ("my-skill/SKILL.md", b"# Skill\n"),
164            ("my-skill/scripts/run.sh", b"#!/bin/sh\n"),
165        ]);
166        let tmp = tempfile::tempdir().unwrap();
167        extract_skill_zip(&mut arch, tmp.path()).unwrap();
168        assert!(tmp.path().join("SKILL.md").exists());
169        assert!(tmp.path().join("scripts/run.sh").exists());
170        assert!(!tmp.path().join("my-skill").exists()); // prefix stripped
171    }
172
173    #[test]
174    fn test_find_skill_md_walks_subdirs() {
175        let tmp = tempfile::tempdir().unwrap();
176        let sub = tmp.path().join("a/b");
177        fs::create_dir_all(&sub).unwrap();
178        fs::write(sub.join("SKILL.md"), b"# x\n").unwrap();
179        let found = find_skill_md(tmp.path()).unwrap();
180        assert!(found.ends_with("SKILL.md"));
181    }
182
183    #[test]
184    fn test_find_skill_md_none() {
185        let tmp = tempfile::tempdir().unwrap();
186        fs::write(tmp.path().join("README.md"), b"nope\n").unwrap();
187        assert!(find_skill_md(tmp.path()).is_none());
188    }
189}