Skip to main content

knack_core/
archive.rs

1//! Skill tarball create/unpack, shared by the knack CLI (`knack pack`,
2//! HTTP publish) and knack-registry (archive serving, `build-static`,
3//! and the publish endpoint). One implementation so the bytes a client
4//! uploads are exactly the bytes the registry would have produced
5//! itself, and so checksum-based change detection agrees on both ends.
6//!
7//! Gated behind the `archive` cargo feature (default-on) because
8//! `tar`/`flate2` have no business in wasm32 builds of knack-core.
9
10use 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
22/// Package a skill directory into a deterministic gzip tarball.
23///
24/// The archive root is a single directory named after the skill's
25/// *frontmatter* name (not the on-disk directory name — vendors
26/// commonly use unprefixed directory names with brand-prefixed
27/// frontmatter names, and the archive is the point where the
28/// canonical name wins). Every entry gets fixed mode/mtime/uid/gid
29/// so the same content always produces the same bytes, which keeps
30/// checksums stable across hosts and rebuilds.
31///
32/// Validates metadata (name well-formed, description present) but
33/// not the dir-name-matches-frontmatter invariant; callers that
34/// require the strict form (e.g. `knack pack`) run `validate_skill`
35/// first.
36pub 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
86/// Unpack a gzip skill tarball (as produced by [`create_skill_archive`]
87/// / `knack pack`) into `dest` and return the extracted skill root.
88///
89/// tar-rs's `unpack` already refuses entries that would escape `dest`
90/// (path traversal); on top of that we enforce the skill-archive
91/// shape: exactly one top-level directory and nothing else. Callers
92/// still validate the returned directory's contents (`read_skill`,
93/// `validate_skill`) — this function only guarantees safe extraction
94/// and the single-root layout.
95pub 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        // Vendors ship `skills/composition-patterns/` containing
162        // `name: vercel-composition-patterns`; the archive renames on
163        // the way out so installs land under the canonical name.
164        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}