1use std::path::PathBuf;
4
5use serde::Deserialize;
6
7use crate::records::{Capability, ExecutionMode, Modality};
8
9use super::provenance::RuntimeProvenance;
10
11#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
13#[error("{0}")]
14pub struct ManifestValidationError(String);
15
16fn invalid(message: impl Into<String>) -> ManifestValidationError {
17 ManifestValidationError(message.into())
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct ManifestDetect {
24 pub file: Option<String>,
25 pub contains: Option<String>,
26 pub file_extension: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
31pub struct ManifestEnv {
32 pub manager: String,
33 pub python: String,
34 pub lockfile: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub struct ManifestServe {
40 pub entrypoint: String,
41 pub wire_protocol: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
46pub struct ManifestInvoke {
47 pub command: String,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct ManifestPermissions {
53 pub network: bool,
54 pub paths: Vec<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Hash)]
59pub struct ManifestVm {
60 pub image: String,
61 pub setup: Vec<String>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
66pub struct RuntimeManifest {
67 pub id: String,
68 pub modalities: Vec<Modality>,
69 pub capabilities: Vec<Capability>,
70 pub execution: ExecutionMode,
71 pub alternatives: Vec<String>,
72 pub detect: Option<ManifestDetect>,
73 pub env: Option<ManifestEnv>,
74 pub serve: Option<ManifestServe>,
75 pub invoke: Option<ManifestInvoke>,
76 pub permissions: ManifestPermissions,
77 pub vm: Option<ManifestVm>,
78 pub directory: Option<PathBuf>,
79 pub provenance: Option<RuntimeProvenance>,
80 pub content_hash: Option<String>,
81}
82
83impl RuntimeManifest {
84 pub fn parse(text: &str, directory: Option<PathBuf>) -> Result<Self, ManifestValidationError> {
87 let raw: RawManifest = toml::from_str(text)
88 .map_err(|err| invalid(format!("manifest is not valid TOML: {err}")))?;
89 raw.validate(directory)
90 }
91}
92
93#[derive(Deserialize)]
94struct RawManifest {
95 id: Option<String>,
96 #[serde(default)]
97 modalities: Vec<String>,
98 #[serde(default)]
99 capabilities: Vec<String>,
100 execution: Option<String>,
101 #[serde(default)]
102 alternatives: Vec<String>,
103 detect: Option<RawDetect>,
104 env: Option<RawEnv>,
105 serve: Option<RawServe>,
106 invoke: Option<RawInvoke>,
107 permissions: Option<RawPermissions>,
108 vm: Option<RawVm>,
109}
110
111#[derive(Deserialize)]
112struct RawDetect {
113 file: Option<String>,
114 contains: Option<String>,
115 extension: Option<String>,
116}
117
118#[derive(Deserialize)]
119struct RawEnv {
120 manager: Option<String>,
121 python: Option<String>,
122 lockfile: Option<String>,
123}
124
125#[derive(Deserialize)]
126struct RawServe {
127 entrypoint: Option<String>,
128 protocol: Option<String>,
129}
130
131#[derive(Deserialize)]
132struct RawInvoke {
133 command: Option<String>,
134}
135
136#[derive(Deserialize, Default)]
137struct RawPermissions {
138 network: Option<bool>,
139 paths: Option<Vec<String>>,
140}
141
142#[derive(Deserialize)]
143struct RawVm {
144 image: Option<String>,
145 #[serde(default)]
146 setup: Vec<String>,
147}
148
149impl RawManifest {
150 fn validate(
151 self,
152 directory: Option<PathBuf>,
153 ) -> Result<RuntimeManifest, ManifestValidationError> {
154 let id = self
155 .id
156 .filter(|id| !id.is_empty())
157 .ok_or_else(|| invalid("manifest is missing an id"))?;
158 validate_id(&id)?;
159
160 let modalities: Vec<Modality> = self
161 .modalities
162 .iter()
163 .map(|m| Modality::from(m.as_str()))
164 .collect();
165 let capabilities: Vec<Capability> = self
166 .capabilities
167 .iter()
168 .map(|c| Capability::from(c.as_str()))
169 .collect();
170 if capabilities.is_empty() {
171 return Err(invalid(format!("manifest {id} declares no capabilities")));
172 }
173
174 let execution_raw = self
175 .execution
176 .ok_or_else(|| invalid(format!("manifest {id} is missing an execution mode")))?;
177 let execution = parse_execution(&execution_raw)
178 .ok_or_else(|| invalid(format!("manifest {id} has an unknown execution mode")))?;
179
180 let detect = match self.detect {
181 Some(raw) => {
182 let detect = ManifestDetect {
183 file: raw.file,
184 contains: raw.contains,
185 file_extension: raw.extension,
186 };
187 if detect.file.is_none() && detect.file_extension.is_none() {
188 return Err(invalid(format!(
189 "manifest {id} has a detect rule with no file or extension"
190 )));
191 }
192 Some(detect)
193 }
194 None => None,
195 };
196
197 let env = match self.env {
198 Some(raw) => {
199 let lockfile = raw.lockfile.ok_or_else(|| {
200 invalid(format!("manifest {id} declares [env] without a lockfile"))
201 })?;
202 Some(ManifestEnv {
203 manager: raw.manager.unwrap_or_else(|| "uv".to_owned()),
204 python: raw.python.unwrap_or_else(|| "3.12".to_owned()),
205 lockfile,
206 })
207 }
208 None => None,
209 };
210
211 let serve = match self.serve {
212 Some(raw) => {
213 let entrypoint = raw.entrypoint.ok_or_else(|| {
214 invalid(format!(
215 "manifest {id} declares [serve] without an entrypoint"
216 ))
217 })?;
218 Some(ManifestServe {
219 entrypoint,
220 wire_protocol: raw.protocol.unwrap_or_else(|| "ndjson+frames".to_owned()),
221 })
222 }
223 None => None,
224 };
225
226 let invoke = match self.invoke {
227 Some(raw) => {
228 let command = raw
229 .command
230 .filter(|command| !command.is_empty())
231 .ok_or_else(|| {
232 invalid(format!("manifest {id} declares [invoke] without a command"))
233 })?;
234 Some(ManifestInvoke { command })
235 }
236 None => None,
237 };
238
239 if serve.is_some() && invoke.is_some() {
240 return Err(invalid(format!(
241 "manifest {id} declares both [serve] and [invoke]"
242 )));
243 }
244 if serve.is_none() && invoke.is_none() {
245 return Err(invalid(format!(
246 "manifest {id} declares neither [serve] nor [invoke]"
247 )));
248 }
249 if invoke.is_some() && execution == ExecutionMode::Stream {
250 return Err(invalid(
251 "invoke manifests run to completion — declare sync (or job), or use [serve] to stream",
252 ));
253 }
254
255 let serves_job = capabilities.contains(&Capability::image());
256 if (execution == ExecutionMode::Job) != serves_job {
257 return Err(invalid(format!(
258 "manifest {id} execution \"{execution_raw}\" does not match its capabilities"
259 )));
260 }
261
262 let raw_permissions = self.permissions.unwrap_or_default();
263 let permissions = ManifestPermissions {
264 network: raw_permissions.network.unwrap_or(false),
265 paths: raw_permissions
266 .paths
267 .unwrap_or_else(|| vec!["{model}".to_owned(), "{workdir}".to_owned()]),
268 };
269
270 let vm = match self.vm {
271 Some(raw) => {
272 let image = raw.image.filter(|image| !image.is_empty()).ok_or_else(|| {
273 invalid(format!("manifest {id} declares [vm] without an image"))
274 })?;
275 if !image.contains("@sha256:") {
276 return Err(invalid(format!(
277 "manifest {id} [vm] image must be digest-pinned (…@sha256:…) — tags can move"
278 )));
279 }
280 if serve.is_some() {
281 return Err(invalid(format!(
282 "manifest {id} [vm] runtimes support [invoke] only"
283 )));
284 }
285 if env.is_some() {
286 return Err(invalid(format!(
287 "manifest {id} declares both [vm] and [env] — the image and its setup are the environment"
288 )));
289 }
290 if permissions.network {
291 return Err(invalid(
292 "vm runtimes always run offline — remove permissions.network",
293 ));
294 }
295 Some(ManifestVm {
296 image,
297 setup: raw.setup,
298 })
299 }
300 None => None,
301 };
302
303 Ok(RuntimeManifest {
304 id,
305 modalities,
306 capabilities,
307 execution,
308 alternatives: self.alternatives,
309 detect,
310 env,
311 serve,
312 invoke,
313 permissions,
314 vm,
315 directory,
316 provenance: None,
317 content_hash: None,
318 })
319 }
320}
321
322fn parse_execution(raw: &str) -> Option<ExecutionMode> {
323 match raw {
324 "stream" => Some(ExecutionMode::Stream),
325 "job" => Some(ExecutionMode::Job),
326 "sync" => Some(ExecutionMode::Sync),
327 _ => None,
328 }
329}
330
331fn validate_id(id: &str) -> Result<(), ManifestValidationError> {
332 let allowed = |c: char| c.is_ascii() && (c.is_ascii_alphanumeric() || "._:-".contains(c));
333 let has_alnum = id.chars().any(|c| c.is_ascii_alphanumeric());
334 let not_all_dots = id.chars().any(|c| c != '.');
335 if id.chars().all(allowed) && has_alnum && not_all_dots {
336 Ok(())
337 } else {
338 Err(invalid(
339 "manifest id may only contain letters, digits, dots, underscores, colons, and hyphens",
340 ))
341 }
342}