1use std::collections::BTreeMap;
18
19use serde::{Deserialize, Serialize};
20
21use crate::contract::documents::{parse_document_kind, DocumentType, BOX_SCHEMA_VERSION};
22use crate::contract::targets::BoxTarget;
23use crate::error::{fail, Result};
24use crate::path::safe_relative_path;
25
26#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub struct Compatibility {
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub min_host_app_version: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub max_host_app_version_exclusive: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub min_macos_version: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub min_ram_gb: Option<f64>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub min_nvidia_driver_version: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub host_environments: Option<Vec<String>>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53pub struct Archive {
54 pub format: String,
56 pub url: String,
58 pub sha256: String,
60 pub size_bytes: u64,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
66#[serde(rename_all = "camelCase", deny_unknown_fields)]
67pub struct PayloadDigestCommitment {
68 pub format: String,
70 pub sha256: String,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct SelfTest {
78 pub python_imports: Vec<String>,
80 pub timeout_seconds: u64,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
86#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
87pub enum Execution {
88 #[serde(rename_all = "camelCase")]
90 PythonScript {
91 script: String,
93 default_args: Vec<String>,
95 },
96 #[serde(rename_all = "camelCase")]
98 PythonModule {
99 module: String,
101 default_args: Vec<String>,
103 },
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
108#[serde(rename_all = "camelCase", deny_unknown_fields)]
109pub struct Provenance {
110 pub scroll_id: String,
112 pub scroll_version: String,
114 pub builder_revision: String,
116 pub source_tree_dirty: bool,
118 pub source_revision: String,
120 pub python_version: String,
122 pub dependency_lock_sha256: String,
124 pub built_at: String,
126 pub pixi_version: String,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct AssetDescriptor {
134 pub url: String,
136 pub relative_path: String,
138 pub size_bytes: u64,
140 pub sha256: String,
142}
143
144#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
146#[serde(rename_all = "camelCase", deny_unknown_fields)]
147pub struct ReleaseManifest {
148 pub schema_version: u32,
150 pub kind: String,
152 pub box_id: String,
154 pub model_id: String,
156 pub runtime_id: String,
158 pub version: String,
160 pub target: BoxTarget,
162 pub compatibility: Compatibility,
164 pub archive: Archive,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub installed_size_bytes: Option<u64>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub payload_digest: Option<PayloadDigestCommitment>,
172 pub python_entry_point: String,
174 pub model_cache_subdir: String,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub environment: Option<BTreeMap<String, String>>,
179 pub self_test: SelfTest,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub execution: Option<Execution>,
184 pub provenance: Provenance,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub weights: Option<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub assets: Option<Vec<AssetDescriptor>>,
192}
193
194#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
199#[serde(rename_all = "camelCase", deny_unknown_fields)]
200pub struct BoxManifest {
201 pub schema_version: u32,
203 pub box_id: String,
205 pub model_id: String,
207 pub runtime_id: String,
209 pub version: String,
211 pub target: BoxTarget,
213 pub python_entry_point: String,
215 pub model_cache_subdir: String,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub environment: Option<BTreeMap<String, String>>,
220 pub self_test: SelfTest,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub execution: Option<Execution>,
225 pub provenance: Provenance,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub weights: Option<String>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub assets: Option<Vec<AssetDescriptor>>,
233}
234
235fn is_lowercase_hex(value: &str, length: usize) -> bool {
237 value.len() == length
238 && value
239 .bytes()
240 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
241}
242
243fn is_identifier(value: &str) -> bool {
245 if value.is_empty() {
246 return false;
247 }
248 let mut group_is_empty = true;
249 for character in value.chars() {
250 match character {
251 'a'..='z' | '0'..='9' => group_is_empty = false,
252 '-' | '.' if !group_is_empty => group_is_empty = true,
253 _ => return false,
254 }
255 }
256 !group_is_empty
257}
258
259fn is_python_module(value: &str) -> bool {
261 !value.is_empty()
262 && value.split('.').all(|segment| {
263 let mut characters = segment.chars();
264 characters
265 .next()
266 .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
267 && characters.all(|rest| rest.is_ascii_alphanumeric() || rest == '_')
268 })
269}
270
271impl Execution {
272 pub fn validate(&self) -> Result<()> {
278 match self {
279 Self::PythonScript { script, .. } => {
280 safe_relative_path(script)?;
281 }
282 Self::PythonModule { module, .. } => {
283 if !is_python_module(module) {
284 fail!("Invalid release manifest: execution module {module} is not a dotted Python module name.");
285 }
286 }
287 }
288 Ok(())
289 }
290}
291
292impl ReleaseManifest {
293 pub fn validate(&self) -> Result<()> {
299 if self.schema_version != BOX_SCHEMA_VERSION {
300 fail!(
301 "Unsupported schemaVersion {}; expected {BOX_SCHEMA_VERSION}.",
302 self.schema_version
303 );
304 }
305 if parse_document_kind(&self.kind).map(|parsed| parsed.document_type)
306 != Some(DocumentType::Release)
307 {
308 fail!("Document is not a box release.");
309 }
310 crate::contract::targets::box_target_id(&self.target)?;
314 for (label, value) in [
315 ("boxId", &self.box_id),
316 ("modelId", &self.model_id),
317 ("runtimeId", &self.runtime_id),
318 ] {
319 if !is_identifier(value) {
320 fail!("Invalid release manifest: {label} is not a valid identifier.");
321 }
322 }
323 for (label, value) in [
324 ("version", &self.version),
325 ("pythonEntryPoint", &self.python_entry_point),
326 ("modelCacheSubdir", &self.model_cache_subdir),
327 ("archive.url", &self.archive.url),
328 ] {
329 if value.is_empty() {
330 fail!("Invalid release manifest: {label} must not be empty.");
331 }
332 }
333 if self.archive.format != "zip" {
334 fail!("Invalid release manifest: archive format must be zip.");
335 }
336 if !is_lowercase_hex(&self.archive.sha256, 64) {
337 fail!("Invalid release manifest: archive sha256 is not a SHA-256 digest.");
338 }
339 if self.archive.size_bytes == 0 {
340 fail!("Invalid release manifest: archive sizeBytes must be positive.");
341 }
342 if self.installed_size_bytes == Some(0) {
343 fail!("Invalid installed size.");
344 }
345 if let Some(digest) = &self.payload_digest {
346 if digest.format != crate::contract::payload_digest::PAYLOAD_DIGEST_FORMAT
347 || !is_lowercase_hex(&digest.sha256, 64)
348 {
349 fail!("Invalid release manifest: payloadDigest is not a supported commitment.");
350 }
351 }
352 if self.self_test.python_imports.is_empty()
353 || self
354 .self_test
355 .python_imports
356 .iter()
357 .any(std::string::String::is_empty)
358 {
359 fail!("Invalid release manifest: selfTest pythonImports must be non-empty.");
360 }
361 if self.self_test.timeout_seconds == 0 {
362 fail!("Invalid release manifest: selfTest timeoutSeconds must be positive.");
363 }
364 validate_environment(self.environment.as_ref())?;
365 if let Some(execution) = &self.execution {
366 execution.validate()?;
367 }
368 validate_provenance(&self.provenance)?;
369 validate_compatibility(&self.compatibility)?;
370 self.validate_assets()?;
371 Ok(())
372 }
373
374 fn validate_assets(&self) -> Result<()> {
376 let assets = match (self.weights.as_deref(), self.assets.as_deref()) {
377 (None, None) => return Ok(()),
378 (Some("on-demand"), Some(assets)) => assets,
379 (Some(other), Some(_)) => {
380 fail!("Invalid release manifest: unsupported weights value {other}.")
381 }
382 (Some(_), None) | (None, Some(_)) => {
384 fail!("Invalid release manifest: weights and assets must be declared together.")
385 }
386 };
387 if assets.is_empty() {
388 fail!("Invalid release manifest: assets must not be empty.");
389 }
390 for asset in assets {
391 safe_relative_path(&asset.relative_path)?;
393 if asset.url.is_empty() {
394 fail!("Invalid release manifest: asset url must not be empty.");
395 }
396 if asset.size_bytes == 0 {
397 fail!("Invalid release manifest: asset sizeBytes must be positive.");
398 }
399 if !is_lowercase_hex(&asset.sha256, 64) {
400 fail!("Invalid release manifest: asset sha256 is not a SHA-256 digest.");
401 }
402 }
403 Ok(())
404 }
405}
406
407fn validate_environment(environment: Option<&BTreeMap<String, String>>) -> Result<()> {
409 let Some(environment) = environment else {
410 return Ok(());
411 };
412 for (name, value) in environment {
413 if name.is_empty() || name.contains('=') || name.contains('\0') || value.contains('\0') {
414 fail!("Invalid release manifest: environment variable {name} is not a valid name.");
415 }
416 }
417 Ok(())
418}
419
420fn validate_provenance(provenance: &Provenance) -> Result<()> {
421 if !is_lowercase_hex(&provenance.builder_revision, 40) {
422 fail!("Invalid release manifest: provenance builderRevision is not a commit.");
423 }
424 if !is_lowercase_hex(&provenance.dependency_lock_sha256, 64) {
425 fail!("Invalid release manifest: provenance dependencyLockSha256 is not a SHA-256 digest.");
426 }
427 for (label, value) in [
428 ("scrollId", &provenance.scroll_id),
429 ("scrollVersion", &provenance.scroll_version),
430 ("sourceRevision", &provenance.source_revision),
431 ("pythonVersion", &provenance.python_version),
432 ("builtAt", &provenance.built_at),
433 ("pixiVersion", &provenance.pixi_version),
434 ] {
435 if value.is_empty() {
436 fail!("Invalid release manifest: provenance {label} must not be empty.");
437 }
438 }
439 Ok(())
440}
441
442fn validate_compatibility(compatibility: &Compatibility) -> Result<()> {
443 if compatibility.min_ram_gb.is_some_and(|value| value <= 0.0) {
444 fail!("Invalid release manifest: minRamGb must be positive.");
445 }
446 if let Some(environments) = &compatibility.host_environments {
447 if environments.is_empty() {
448 fail!("Invalid release manifest: hostEnvironments must not be empty.");
449 }
450 for environment in environments {
451 if environment != "native" && environment != "windows-wsl2" {
452 fail!("Invalid release manifest: unsupported host environment {environment}.");
453 }
454 }
455 }
456 for (label, value) in [
457 ("minHostAppVersion", &compatibility.min_host_app_version),
458 (
459 "maxHostAppVersionExclusive",
460 &compatibility.max_host_app_version_exclusive,
461 ),
462 ("minMacosVersion", &compatibility.min_macos_version),
463 (
464 "minNvidiaDriverVersion",
465 &compatibility.min_nvidia_driver_version,
466 ),
467 ] {
468 if value.as_deref().is_some_and(str::is_empty) {
469 fail!("Invalid release manifest: compatibility {label} must not be empty.");
470 }
471 }
472 Ok(())
473}
474
475#[cfg(test)]
476mod tests {
477 use super::{is_identifier, is_python_module, Execution};
478
479 #[test]
480 fn identifiers_follow_the_shared_pattern() {
481 for valid in ["hello-box", "a", "example.model-1", "b0x"] {
482 assert!(is_identifier(valid), "{valid} was refused");
483 }
484 for invalid in ["", "-a", "a-", "a..b", "A", "a_b", "a b", ".a"] {
485 assert!(!is_identifier(invalid), "{invalid} was accepted");
486 }
487 }
488
489 #[test]
490 fn module_names_carry_no_command_line_syntax() {
491 for valid in ["main", "_pkg.main", "example_model.cli.main"] {
492 assert!(is_python_module(valid), "{valid} was refused");
493 }
494 for invalid in ["", "a b", "a;b", "-c", "a/b", "1abc", "a..b", "a."] {
497 assert!(!is_python_module(invalid), "{invalid} was accepted");
498 }
499 }
500
501 #[test]
502 fn execution_paths_are_screened_before_they_are_joined() {
503 let escape = Execution::PythonScript {
504 script: "../outside.py".to_string(),
505 default_args: vec![],
506 };
507 assert!(escape.validate().is_err());
508
509 let ok = Execution::PythonScript {
510 script: "app/main.py".to_string(),
511 default_args: vec![],
512 };
513 assert!(ok.validate().is_ok());
514 }
515}