1use std::collections::BTreeMap;
23
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26
27use crate::contract::documents::{parse_document_kind, DocumentType, BOX_SCHEMA_VERSION};
28use crate::contract::targets::BoxTarget;
29use crate::error::{fail, Result};
30use crate::path::safe_relative_path;
31
32#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
46#[serde(rename_all = "camelCase")]
47pub struct Compatibility {
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub min_host_app_version: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub max_host_app_version_exclusive: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub min_macos_version: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub min_ram_gb: Option<f64>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub min_nvidia_driver_version: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub host_environments: Option<Vec<String>>,
66 #[serde(flatten)]
71 pub additional: BTreeMap<String, Value>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct Archive {
78 pub format: String,
80 pub url: String,
82 pub sha256: String,
84 pub size_bytes: u64,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
90#[serde(rename_all = "camelCase", deny_unknown_fields)]
91pub struct PayloadDigestCommitment {
92 pub format: String,
94 pub sha256: String,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
100#[serde(rename_all = "camelCase", deny_unknown_fields)]
101pub struct SelfTest {
102 pub python_imports: Vec<String>,
104 pub timeout_seconds: u64,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
110#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
111pub enum Execution {
112 #[serde(rename_all = "camelCase")]
114 PythonScript {
115 script: String,
117 default_args: Vec<String>,
119 },
120 #[serde(rename_all = "camelCase")]
122 PythonModule {
123 module: String,
125 default_args: Vec<String>,
127 },
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct Provenance {
134 pub scroll_id: String,
136 pub scroll_version: String,
138 pub builder_revision: String,
140 pub source_tree_dirty: bool,
142 pub source_revision: String,
144 pub python_version: String,
146 pub dependency_lock_sha256: String,
148 pub built_at: String,
150 pub pixi_version: String,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
156#[serde(rename_all = "camelCase", deny_unknown_fields)]
157pub struct AssetDescriptor {
158 pub url: String,
160 pub relative_path: String,
162 pub size_bytes: u64,
164 pub sha256: String,
166}
167
168#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
170#[serde(rename_all = "camelCase", deny_unknown_fields)]
171pub struct ReleaseManifest {
172 pub schema_version: u32,
174 pub kind: String,
176 pub box_id: String,
178 pub model_id: String,
180 pub runtime_id: String,
182 pub version: String,
184 pub target: BoxTarget,
186 pub compatibility: Compatibility,
188 pub archive: Archive,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub installed_size_bytes: Option<u64>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub payload_digest: Option<PayloadDigestCommitment>,
196 pub python_entry_point: String,
198 pub model_cache_subdir: String,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub environment: Option<BTreeMap<String, String>>,
203 pub self_test: SelfTest,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub execution: Option<Execution>,
208 pub provenance: Provenance,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub weights: Option<String>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub assets: Option<Vec<AssetDescriptor>>,
216}
217
218#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
223#[serde(rename_all = "camelCase", deny_unknown_fields)]
224pub struct BoxManifest {
225 pub schema_version: u32,
227 pub box_id: String,
229 pub model_id: String,
231 pub runtime_id: String,
233 pub version: String,
235 pub target: BoxTarget,
237 pub python_entry_point: String,
239 pub model_cache_subdir: String,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub environment: Option<BTreeMap<String, String>>,
244 pub self_test: SelfTest,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub execution: Option<Execution>,
249 pub provenance: Provenance,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub weights: Option<String>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub assets: Option<Vec<AssetDescriptor>>,
257}
258
259fn is_lowercase_hex(value: &str, length: usize) -> bool {
261 value.len() == length
262 && value
263 .bytes()
264 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
265}
266
267fn is_identifier(value: &str) -> bool {
269 if value.is_empty() {
270 return false;
271 }
272 let mut group_is_empty = true;
273 for character in value.chars() {
274 match character {
275 'a'..='z' | '0'..='9' => group_is_empty = false,
276 '-' | '.' if !group_is_empty => group_is_empty = true,
277 _ => return false,
278 }
279 }
280 !group_is_empty
281}
282
283fn is_python_module(value: &str) -> bool {
285 !value.is_empty()
286 && value.split('.').all(|segment| {
287 let mut characters = segment.chars();
288 characters
289 .next()
290 .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
291 && characters.all(|rest| rest.is_ascii_alphanumeric() || rest == '_')
292 })
293}
294
295impl Execution {
296 pub fn validate(&self) -> Result<()> {
302 match self {
303 Self::PythonScript { script, .. } => {
304 safe_relative_path(script)?;
305 }
306 Self::PythonModule { module, .. } => {
307 if !is_python_module(module) {
308 fail!("Invalid release manifest: execution module {module} is not a dotted Python module name.");
309 }
310 }
311 }
312 Ok(())
313 }
314}
315
316impl ReleaseManifest {
317 pub fn validate(&self) -> Result<()> {
323 if self.schema_version != BOX_SCHEMA_VERSION {
324 fail!(
325 "Unsupported schemaVersion {}; expected {BOX_SCHEMA_VERSION}.",
326 self.schema_version
327 );
328 }
329 if parse_document_kind(&self.kind).map(|parsed| parsed.document_type)
330 != Some(DocumentType::Release)
331 {
332 fail!("Document is not a box release.");
333 }
334 crate::contract::targets::box_target_id(&self.target)?;
338 for (label, value) in [
339 ("boxId", &self.box_id),
340 ("modelId", &self.model_id),
341 ("runtimeId", &self.runtime_id),
342 ] {
343 if !is_identifier(value) {
344 fail!("Invalid release manifest: {label} is not a valid identifier.");
345 }
346 }
347 for (label, value) in [
348 ("version", &self.version),
349 ("pythonEntryPoint", &self.python_entry_point),
350 ("modelCacheSubdir", &self.model_cache_subdir),
351 ("archive.url", &self.archive.url),
352 ] {
353 if value.is_empty() {
354 fail!("Invalid release manifest: {label} must not be empty.");
355 }
356 }
357 if self.archive.format != "zip" {
358 fail!("Invalid release manifest: archive format must be zip.");
359 }
360 if !is_lowercase_hex(&self.archive.sha256, 64) {
361 fail!("Invalid release manifest: archive sha256 is not a SHA-256 digest.");
362 }
363 if self.archive.size_bytes == 0 {
364 fail!("Invalid release manifest: archive sizeBytes must be positive.");
365 }
366 if self.installed_size_bytes == Some(0) {
367 fail!("Invalid installed size.");
368 }
369 if let Some(digest) = &self.payload_digest {
370 if digest.format != crate::contract::payload_digest::PAYLOAD_DIGEST_FORMAT
371 || !is_lowercase_hex(&digest.sha256, 64)
372 {
373 fail!("Invalid release manifest: payloadDigest is not a supported commitment.");
374 }
375 }
376 if self.self_test.python_imports.is_empty()
377 || self
378 .self_test
379 .python_imports
380 .iter()
381 .any(std::string::String::is_empty)
382 {
383 fail!("Invalid release manifest: selfTest pythonImports must be non-empty.");
384 }
385 if self.self_test.timeout_seconds == 0 {
386 fail!("Invalid release manifest: selfTest timeoutSeconds must be positive.");
387 }
388 validate_environment(self.environment.as_ref())?;
389 if let Some(execution) = &self.execution {
390 execution.validate()?;
391 }
392 validate_provenance(&self.provenance)?;
393 validate_compatibility(&self.compatibility)?;
394 self.validate_assets()?;
395 Ok(())
396 }
397
398 fn validate_assets(&self) -> Result<()> {
400 let assets = match (self.weights.as_deref(), self.assets.as_deref()) {
401 (None, None) => return Ok(()),
402 (Some("on-demand"), Some(assets)) => assets,
403 (Some(other), Some(_)) => {
404 fail!("Invalid release manifest: unsupported weights value {other}.")
405 }
406 (Some(_), None) | (None, Some(_)) => {
408 fail!("Invalid release manifest: weights and assets must be declared together.")
409 }
410 };
411 if assets.is_empty() {
412 fail!("Invalid release manifest: assets must not be empty.");
413 }
414 for asset in assets {
415 safe_relative_path(&asset.relative_path)?;
417 if asset.url.is_empty() {
418 fail!("Invalid release manifest: asset url must not be empty.");
419 }
420 if asset.size_bytes == 0 {
421 fail!("Invalid release manifest: asset sizeBytes must be positive.");
422 }
423 if !is_lowercase_hex(&asset.sha256, 64) {
424 fail!("Invalid release manifest: asset sha256 is not a SHA-256 digest.");
425 }
426 }
427 Ok(())
428 }
429}
430
431fn validate_environment(environment: Option<&BTreeMap<String, String>>) -> Result<()> {
433 let Some(environment) = environment else {
434 return Ok(());
435 };
436 for (name, value) in environment {
437 if name.is_empty() || name.contains('=') || name.contains('\0') || value.contains('\0') {
438 fail!("Invalid release manifest: environment variable {name} is not a valid name.");
439 }
440 }
441 Ok(())
442}
443
444fn validate_provenance(provenance: &Provenance) -> Result<()> {
445 if !is_lowercase_hex(&provenance.builder_revision, 40) {
446 fail!("Invalid release manifest: provenance builderRevision is not a commit.");
447 }
448 if !is_lowercase_hex(&provenance.dependency_lock_sha256, 64) {
449 fail!("Invalid release manifest: provenance dependencyLockSha256 is not a SHA-256 digest.");
450 }
451 for (label, value) in [
452 ("scrollId", &provenance.scroll_id),
453 ("scrollVersion", &provenance.scroll_version),
454 ("sourceRevision", &provenance.source_revision),
455 ("pythonVersion", &provenance.python_version),
456 ("builtAt", &provenance.built_at),
457 ("pixiVersion", &provenance.pixi_version),
458 ] {
459 if value.is_empty() {
460 fail!("Invalid release manifest: provenance {label} must not be empty.");
461 }
462 }
463 Ok(())
464}
465
466fn validate_compatibility(compatibility: &Compatibility) -> Result<()> {
467 if compatibility.min_ram_gb.is_some_and(|value| value <= 0.0) {
468 fail!("Invalid release manifest: minRamGb must be positive.");
469 }
470 if let Some(environments) = &compatibility.host_environments {
471 if environments.is_empty() {
472 fail!("Invalid release manifest: hostEnvironments must not be empty.");
473 }
474 for environment in environments {
475 if environment != "native" && environment != "windows-wsl2" {
476 fail!("Invalid release manifest: unsupported host environment {environment}.");
477 }
478 }
479 }
480 for (label, value) in [
481 ("minHostAppVersion", &compatibility.min_host_app_version),
482 (
483 "maxHostAppVersionExclusive",
484 &compatibility.max_host_app_version_exclusive,
485 ),
486 ("minMacosVersion", &compatibility.min_macos_version),
487 (
488 "minNvidiaDriverVersion",
489 &compatibility.min_nvidia_driver_version,
490 ),
491 ] {
492 if value.as_deref().is_some_and(str::is_empty) {
493 fail!("Invalid release manifest: compatibility {label} must not be empty.");
494 }
495 }
496 Ok(())
497}
498
499#[cfg(test)]
500mod tests {
501 use super::{is_identifier, is_python_module, Execution};
502
503 #[test]
504 fn identifiers_follow_the_shared_pattern() {
505 for valid in ["hello-box", "a", "example.model-1", "b0x"] {
506 assert!(is_identifier(valid), "{valid} was refused");
507 }
508 for invalid in ["", "-a", "a-", "a..b", "A", "a_b", "a b", ".a"] {
509 assert!(!is_identifier(invalid), "{invalid} was accepted");
510 }
511 }
512
513 #[test]
514 fn module_names_carry_no_command_line_syntax() {
515 for valid in ["main", "_pkg.main", "example_model.cli.main"] {
516 assert!(is_python_module(valid), "{valid} was refused");
517 }
518 for invalid in ["", "a b", "a;b", "-c", "a/b", "1abc", "a..b", "a."] {
521 assert!(!is_python_module(invalid), "{invalid} was accepted");
522 }
523 }
524
525 #[test]
526 fn execution_paths_are_screened_before_they_are_joined() {
527 let escape = Execution::PythonScript {
528 script: "../outside.py".to_string(),
529 default_args: vec![],
530 };
531 assert!(escape.validate().is_err());
532
533 let ok = Execution::PythonScript {
534 script: "app/main.py".to_string(),
535 default_args: vec![],
536 };
537 assert!(ok.validate().is_ok());
538 }
539}