oxios_kernel/skill/
archive.rs1use std::fs;
10use std::io::{Read, Seek};
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use zip::ZipArchive;
15
16const MARKERS: &[&str] = &["SKILL.md", "skill.md", "skills.md"];
18
19pub 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 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 if let Some(last_slash) = name.rfind('/') {
47 return Ok(name[..=last_slash].to_string());
48 }
49 return Ok(String::new());
51 }
52 }
53
54 tracing::warn!("no SKILL.md marker found in archive, extracting all entries");
56 Ok(String::new())
57}
58
59pub 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 let relative = match name.strip_prefix(&root_prefix) {
71 Some(rest) => rest.to_string(),
72 None => continue, };
74
75 let relative = relative.replace('\\', "/");
77 if relative.is_empty() || relative == "/" {
78 continue;
79 }
80
81 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
103pub 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 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()); }
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}