1use std::{
11 fs::File,
12 io::Read,
13 path::{Path, PathBuf},
14};
15
16use anyhow::{Context, Result, anyhow, bail};
17use flate2::{Compression, write::GzEncoder};
18use tar::{Builder, Header};
19
20use crate::{collect_files, read_skill, validate_skill_metadata};
21
22pub fn create_skill_archive(skill_dir: &Path) -> Result<Vec<u8>> {
37 let skill = read_skill(skill_dir)?;
38 validate_skill_metadata(&skill)?;
39
40 let encoder = GzEncoder::new(Vec::new(), Compression::default());
41 let mut archive = Builder::new(encoder);
42 for file in collect_files(skill_dir)? {
43 let relative = file.strip_prefix(skill_dir).with_context(|| {
44 format!(
45 "failed to make {} relative to {}",
46 file.display(),
47 skill_dir.display()
48 )
49 })?;
50 let archive_name = Path::new(&skill.name).join(relative);
51 append_file(&mut archive, &file, &archive_name)?;
52 }
53 archive.finish()?;
54 let encoder = archive.into_inner()?;
55 Ok(encoder.finish()?)
56}
57
58fn append_file(
59 archive: &mut Builder<GzEncoder<Vec<u8>>>,
60 source: &Path,
61 archive_name: &Path,
62) -> Result<()> {
63 let mut file =
64 File::open(source).with_context(|| format!("failed to open {}", source.display()))?;
65 let metadata = file
66 .metadata()
67 .with_context(|| format!("failed to stat {}", source.display()))?;
68 if !metadata.is_file() {
69 bail!("not a file: {}", source.display());
70 }
71
72 let mut header = Header::new_gnu();
73 header.set_size(metadata.len());
74 header.set_mode(0o644);
75 header.set_mtime(0);
76 header.set_uid(0);
77 header.set_gid(0);
78 header.set_cksum();
79
80 archive
81 .append_data(&mut header, archive_name, &mut file)
82 .with_context(|| format!("failed to archive {}", source.display()))?;
83 Ok(())
84}
85
86pub fn unpack_skill_archive<R: Read>(reader: R, dest: &Path) -> Result<PathBuf> {
96 let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(reader));
97 archive
98 .unpack(dest)
99 .context("failed to unpack skill archive")?;
100
101 let mut root: Option<PathBuf> = None;
102 for entry in std::fs::read_dir(dest)
103 .with_context(|| format!("failed to read unpacked archive at {}", dest.display()))?
104 {
105 let entry = entry?;
106 if !entry.file_type()?.is_dir() {
107 bail!(
108 "skill archive must contain a single top-level directory, \
109 found stray entry: {}",
110 entry.path().display()
111 );
112 }
113 if root.replace(entry.path()).is_some() {
114 bail!("skill archive must contain exactly one top-level directory, found several");
115 }
116 }
117 root.ok_or_else(|| anyhow!("skill archive is empty"))
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::{checksum_dir, validate_skill};
124
125 fn write_skill(dir: &Path, name: &str) {
126 std::fs::create_dir_all(dir.join("references")).unwrap();
127 std::fs::write(
128 dir.join("SKILL.md"),
129 format!("---\nname: {name}\ndescription: \"A test skill.\"\n---\n\n# {name}\n"),
130 )
131 .unwrap();
132 std::fs::write(dir.join("references/notes.md"), "extra content\n").unwrap();
133 }
134
135 #[test]
136 fn archive_round_trips_and_preserves_checksum() {
137 let src = tempfile::tempdir().unwrap();
138 let skill_dir = src.path().join("demo");
139 write_skill(&skill_dir, "demo");
140
141 let bytes = create_skill_archive(&skill_dir).unwrap();
142
143 let out = tempfile::tempdir().unwrap();
144 let root = unpack_skill_archive(std::io::Cursor::new(&bytes), out.path()).unwrap();
145 assert_eq!(root.file_name().unwrap(), "demo");
146
147 let skill = read_skill(&root).unwrap();
148 validate_skill(&skill).unwrap();
149 assert_eq!(
150 std::fs::read_to_string(root.join("references/notes.md")).unwrap(),
151 "extra content\n"
152 );
153 assert_eq!(
154 checksum_dir(&skill_dir).unwrap(),
155 checksum_dir(&root).unwrap()
156 );
157 }
158
159 #[test]
160 fn archive_root_uses_frontmatter_name_not_dirname() {
161 let src = tempfile::tempdir().unwrap();
165 let skill_dir = src.path().join("composition-patterns");
166 write_skill(&skill_dir, "vendor-composition-patterns");
167
168 let bytes = create_skill_archive(&skill_dir).unwrap();
169 let out = tempfile::tempdir().unwrap();
170 let root = unpack_skill_archive(std::io::Cursor::new(&bytes), out.path()).unwrap();
171 assert_eq!(root.file_name().unwrap(), "vendor-composition-patterns");
172 }
173
174 #[test]
175 fn create_rejects_invalid_metadata() {
176 let src = tempfile::tempdir().unwrap();
177 let skill_dir = src.path().join("bad");
178 std::fs::create_dir_all(&skill_dir).unwrap();
179 std::fs::write(
180 skill_dir.join("SKILL.md"),
181 "---\nname: Bad Name\ndescription: \"x\"\n---\n",
182 )
183 .unwrap();
184 assert!(create_skill_archive(&skill_dir).is_err());
185 }
186
187 #[test]
188 fn unpack_rejects_multiple_top_level_directories() {
189 let encoder = GzEncoder::new(Vec::new(), Compression::default());
190 let mut archive = Builder::new(encoder);
191 for dir in ["one", "two"] {
192 let mut header = Header::new_gnu();
193 header.set_size(0);
194 header.set_mode(0o644);
195 header.set_cksum();
196 archive
197 .append_data(
198 &mut header,
199 Path::new(dir).join("SKILL.md"),
200 std::io::empty(),
201 )
202 .unwrap();
203 }
204 archive.finish().unwrap();
205 let bytes = archive.into_inner().unwrap().finish().unwrap();
206
207 let out = tempfile::tempdir().unwrap();
208 let err = unpack_skill_archive(std::io::Cursor::new(&bytes), out.path()).unwrap_err();
209 assert!(err.to_string().contains("exactly one top-level directory"));
210 }
211
212 #[test]
213 fn unpack_rejects_stray_top_level_files() {
214 let encoder = GzEncoder::new(Vec::new(), Compression::default());
215 let mut archive = Builder::new(encoder);
216 let mut header = Header::new_gnu();
217 header.set_size(0);
218 header.set_mode(0o644);
219 header.set_cksum();
220 archive
221 .append_data(&mut header, Path::new("stray.txt"), std::io::empty())
222 .unwrap();
223 archive.finish().unwrap();
224 let bytes = archive.into_inner().unwrap().finish().unwrap();
225
226 let out = tempfile::tempdir().unwrap();
227 let err = unpack_skill_archive(std::io::Cursor::new(&bytes), out.path()).unwrap_err();
228 assert!(err.to_string().contains("stray entry"));
229 }
230}