use std::io::{Read, Seek};
use thiserror::Error;
use zip::ZipArchive;
use crate::zip_guard::sanitize_entry_path;
pub const CAPSULE_WASM_ENTRY: &str = "capsule.wasm";
pub const SKILL_MD_ENTRY: &str = "SKILL.md";
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum SkillPayloadError {
#[error("it holds skill.md, so it was built before skills followed the Agent Skills standard, which names the file SKILL.md")]
BuiltBeforeStandard,
#[error("it holds no SKILL.md at its root")]
MissingSkillMd,
}
pub fn check_skill_payload_entries<S: AsRef<str>>(
raw_names: impl IntoIterator<Item = S>,
) -> Result<(), SkillPayloadError> {
let mut misspelled = false;
for raw in raw_names {
let Ok(path) = sanitize_entry_path(raw.as_ref()) else {
continue;
};
let mut components = path.components();
let (Some(first), None) = (components.next(), components.next()) else {
continue;
};
let name = first.as_os_str().to_string_lossy();
if name == SKILL_MD_ENTRY {
return Ok(());
}
misspelled |= name.to_lowercase() == SKILL_MD_ENTRY.to_lowercase();
}
Err(if misspelled {
SkillPayloadError::BuiltBeforeStandard
} else {
SkillPayloadError::MissingSkillMd
})
}
pub const WASM_EXTENSION: &str = ".wasm";
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum PayloadShapeError {
#[error("missing root .wasm file (expected capsule.wasm or one root *.wasm)")]
MissingRootWasm,
#[error("multiple root .wasm files found: {}", names.join(", "))]
MultipleRootWasm { names: Vec<String> },
}
#[must_use]
pub fn is_root_wasm_candidate(raw_name: &str) -> bool {
if !raw_name.ends_with(WASM_EXTENSION) {
return false;
}
let Ok(sanitized) = sanitize_entry_path(raw_name) else {
return false;
};
!raw_name.starts_with('/') && sanitized.components().count() == 1
}
pub fn root_wasm_candidates<I, S>(entry_names: I) -> Vec<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
entry_names
.into_iter()
.filter(|name| is_root_wasm_candidate(name.as_ref()))
.map(|name| name.as_ref().to_string())
.collect()
}
pub fn select_root_wasm<S: AsRef<str>>(candidates: &[S]) -> Result<String, PayloadShapeError> {
if candidates
.iter()
.any(|name| name.as_ref() == CAPSULE_WASM_ENTRY)
{
return Ok(CAPSULE_WASM_ENTRY.to_string());
}
match candidates.len() {
1 => Ok(candidates[0].as_ref().to_string()),
0 => Err(PayloadShapeError::MissingRootWasm),
_ => {
let mut names: Vec<String> = candidates
.iter()
.map(|name| name.as_ref().to_string())
.collect();
names.sort();
Err(PayloadShapeError::MultipleRootWasm { names })
}
}
}
pub fn select_root_wasm_from_entries<I, S>(entry_names: I) -> Result<String, PayloadShapeError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
select_root_wasm(&root_wasm_candidates(entry_names))
}
pub fn select_root_wasm_in_archive<R: Read + Seek>(
archive: &mut ZipArchive<R>,
) -> Result<String, PayloadShapeError> {
let mut entry_names = Vec::with_capacity(archive.len());
for idx in 0..archive.len() {
let Ok(file) = archive.by_index(idx) else {
continue;
};
let Ok(name) = file.name() else {
continue;
};
entry_names.push(name.into_owned());
}
select_root_wasm_from_entries(entry_names)
}
#[cfg(test)]
mod tests {
use std::io::Write;
use zip::write::SimpleFileOptions;
use super::*;
#[test]
fn a_skill_payload_needs_a_root_skill_md_spelled_exactly() {
assert_eq!(
check_skill_payload_entries(["murmur.yaml", "SKILL.md", "references/a.md"]),
Ok(())
);
assert_eq!(check_skill_payload_entries(["/SKILL.md"]), Ok(()));
assert_eq!(
check_skill_payload_entries(["murmur.yaml", "skill.md"]),
Err(SkillPayloadError::BuiltBeforeStandard)
);
assert_eq!(
check_skill_payload_entries(["Skill.md"]),
Err(SkillPayloadError::BuiltBeforeStandard)
);
assert_eq!(
check_skill_payload_entries(["murmur.yaml", "references/SKILL.md"]),
Err(SkillPayloadError::MissingSkillMd)
);
}
fn archive_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
{
let mut zip = zip::ZipWriter::new(&mut cursor);
let options = SimpleFileOptions::default();
for (name, bytes) in files {
zip.start_file(*name, options).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
}
cursor.into_inner()
}
fn open(bytes: &[u8]) -> ZipArchive<std::io::Cursor<&[u8]>> {
ZipArchive::new(std::io::Cursor::new(bytes)).unwrap()
}
#[test]
fn zero_candidates_is_missing_root_wasm() {
let err = select_root_wasm_from_entries(["murmur.yaml", "README.md"]).unwrap_err();
assert_eq!(err, PayloadShapeError::MissingRootWasm);
assert_eq!(
err.to_string(),
"missing root .wasm file (expected capsule.wasm or one root *.wasm)"
);
}
#[test]
fn exactly_one_candidate_is_selected() {
let selected = select_root_wasm_from_entries(["murmur.yaml", "tool.wasm"]).unwrap();
assert_eq!(selected, "tool.wasm");
}
#[test]
fn multiple_candidates_report_sorted_comma_joined_names() {
let err =
select_root_wasm_from_entries(["zeta.wasm", "alpha.wasm", "murmur.yaml", "mid.wasm"])
.unwrap_err();
assert_eq!(
err,
PayloadShapeError::MultipleRootWasm {
names: vec![
"alpha.wasm".to_string(),
"mid.wasm".to_string(),
"zeta.wasm".to_string(),
]
}
);
assert_eq!(
err.to_string(),
"multiple root .wasm files found: alpha.wasm, mid.wasm, zeta.wasm"
);
}
#[test]
fn capsule_wasm_is_preferred_among_multiple_candidates() {
let selected =
select_root_wasm_from_entries(["tool.wasm", "capsule.wasm", "other.wasm"]).unwrap();
assert_eq!(selected, CAPSULE_WASM_ENTRY);
}
#[test]
fn nested_subdirectory_wasm_is_not_a_root_candidate() {
assert!(!is_root_wasm_candidate("sub/capsule.wasm"));
let err = select_root_wasm_from_entries(["sub/capsule.wasm", "murmur.yaml"]).unwrap_err();
assert_eq!(err, PayloadShapeError::MissingRootWasm);
}
#[test]
fn traversal_and_absolute_entries_are_not_root_candidates() {
assert!(!is_root_wasm_candidate("../../evil.wasm"));
assert!(!is_root_wasm_candidate("/capsule.wasm"));
let err = select_root_wasm_from_entries(["../../evil.wasm", "/capsule.wasm"]).unwrap_err();
assert_eq!(err, PayloadShapeError::MissingRootWasm);
}
#[test]
fn non_wasm_entries_are_not_root_candidates() {
assert!(!is_root_wasm_candidate("capsule.wat"));
assert!(!is_root_wasm_candidate("SKILL.md"));
assert!(is_root_wasm_candidate("capsule.wasm"));
}
#[test]
fn root_wasm_candidates_preserves_input_order() {
let candidates =
root_wasm_candidates(["zeta.wasm", "murmur.yaml", "alpha.wasm", "sub/nested.wasm"]);
assert_eq!(candidates, vec!["zeta.wasm", "alpha.wasm"]);
}
#[test]
fn select_root_wasm_in_archive_prefers_capsule_wasm() {
let bytes = archive_with_files(&[
("tool.wasm", b"tool"),
("capsule.wasm", b"capsule"),
("murmur.yaml", b"name: demo"),
]);
let selected = select_root_wasm_in_archive(&mut open(&bytes)).unwrap();
assert_eq!(selected, CAPSULE_WASM_ENTRY);
}
#[test]
fn select_root_wasm_in_archive_reports_missing_for_nested_only_wasm() {
let bytes = archive_with_files(&[("sub/capsule.wasm", b"nested")]);
let err = select_root_wasm_in_archive(&mut open(&bytes)).unwrap_err();
assert_eq!(err, PayloadShapeError::MissingRootWasm);
}
#[test]
fn select_root_wasm_in_archive_reports_multiple_sorted() {
let bytes = archive_with_files(&[("zeta.wasm", b"z"), ("alpha.wasm", b"a")]);
let err = select_root_wasm_in_archive(&mut open(&bytes)).unwrap_err();
assert_eq!(
err.to_string(),
"multiple root .wasm files found: alpha.wasm, zeta.wasm"
);
}
}