Skip to main content

scrollcase_consumer/
release.rs

1//! The signed release manifest, and the `box.json` that must agree with it.
2//!
3//! These types are the runtime shape check. Node and Python validate the same documents against the
4//! canonical JSON schemas at runtime; this crate encodes those schemas as types instead, because the
5//! schemas set `additionalProperties: false` throughout and a typed `deny_unknown_fields` parse says
6//! exactly that — while a JSON Schema evaluator for the eighteen keywords these schemas actually use
7//! would be six hundred lines of validation logic whose failure mode is validating *less* than it
8//! claims.
9//!
10//! The equivalence is not assumed. `tests/schema.rs` validates the same documents against the
11//! bundled canonical schemas and asserts the two agree, so a type that drifts from its schema is a
12//! red test rather than a silent divergence between implementations.
13//!
14//! What the types cannot express — patterns, non-empty strings, positive integers, and the
15//! `weights`/`assets` co-requirement — is checked explicitly in [`ReleaseManifest::validate`].
16
17use 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/// Host requirements a caller may enforce. Scrollcase records them; it does not decide policy.
27#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub struct Compatibility {
30    /// Minimum version of the consuming application.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub min_host_app_version: Option<String>,
33    /// First version of the consuming application this box no longer supports.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub max_host_app_version_exclusive: Option<String>,
36    /// Minimum macOS version.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub min_macos_version: Option<String>,
39    /// Minimum installed memory, in decimal gigabytes.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub min_ram_gb: Option<f64>,
42    /// Minimum NVIDIA driver version.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub min_nvidia_driver_version: Option<String>,
45    /// Execution environments this payload was validated for.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub host_environments: Option<Vec<String>>,
48}
49
50/// Where the archive lives and what it must hash and weigh.
51#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53pub struct Archive {
54    /// Always `zip`.
55    pub format: String,
56    /// Where the archive was published. This crate never fetches it.
57    pub url: String,
58    /// Lowercase hex SHA-256 of the archive bytes.
59    pub sha256: String,
60    /// Exact archive size.
61    pub size_bytes: u64,
62}
63
64/// What a release commits to about its own extracted tree.
65#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
66#[serde(rename_all = "camelCase", deny_unknown_fields)]
67pub struct PayloadDigestCommitment {
68    /// Always `sha256-path-list-v1`.
69    pub format: String,
70    /// Lowercase hex SHA-256 of the canonical entry list.
71    pub sha256: String,
72}
73
74/// The import check a box must pass with its own interpreter.
75#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct SelfTest {
78    /// Modules to import.
79    pub python_imports: Vec<String>,
80    /// How long the import check may take.
81    pub timeout_seconds: u64,
82}
83
84/// The optional, shell-free application entry point.
85#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
86#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
87pub enum Execution {
88    /// Run one regular payload file with the box's own Python interpreter.
89    #[serde(rename_all = "camelCase")]
90    PythonScript {
91        /// Safe path to a regular Python file inside the box.
92        script: String,
93        /// Arguments always passed before a caller's own.
94        default_args: Vec<String>,
95    },
96    /// Run one dotted Python module.
97    #[serde(rename_all = "camelCase")]
98    PythonModule {
99        /// Strict dotted-module name, with no command-line or shell syntax.
100        module: String,
101        /// Arguments always passed before a caller's own.
102        default_args: Vec<String>,
103    },
104}
105
106/// How the box was produced. Recorded, signed, and never fabricated.
107#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
108#[serde(rename_all = "camelCase", deny_unknown_fields)]
109pub struct Provenance {
110    /// Identity of the scroll that produced this box.
111    pub scroll_id: String,
112    /// Version declared by that scroll.
113    pub scroll_version: String,
114    /// Commit of Scrollcase itself.
115    pub builder_revision: String,
116    /// Whether the source tree was dirty at build time. Never quietly downgraded.
117    pub source_tree_dirty: bool,
118    /// Commit the box was built from.
119    pub source_revision: String,
120    /// Python version inside the box.
121    pub python_version: String,
122    /// SHA-256 of the dependency lock.
123    pub dependency_lock_sha256: String,
124    /// When the box was built.
125    pub built_at: String,
126    /// pixi version that solved the environment.
127    pub pixi_version: String,
128}
129
130/// One on-demand asset a caller must materialise before execution.
131#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct AssetDescriptor {
134    /// Where the asset was published. This crate never fetches it.
135    pub url: String,
136    /// Where it must be placed, relative to the box root.
137    pub relative_path: String,
138    /// Exact size the placed file must have.
139    pub size_bytes: u64,
140    /// Lowercase hex SHA-256 the placed file must have.
141    pub sha256: String,
142}
143
144/// The immutable description of one built box.
145#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
146#[serde(rename_all = "camelCase", deny_unknown_fields)]
147pub struct ReleaseManifest {
148    /// Format version; always 2.
149    pub schema_version: u32,
150    /// `<namespace>.release`, in the publishing project's own namespace.
151    pub kind: String,
152    /// Box identity.
153    pub box_id: String,
154    /// Model identity.
155    pub model_id: String,
156    /// Installed-directory identity.
157    pub runtime_id: String,
158    /// Box version.
159    pub version: String,
160    /// The target this box was built for.
161    pub target: BoxTarget,
162    /// Host requirements.
163    pub compatibility: Compatibility,
164    /// The archive this release commits to.
165    pub archive: Archive,
166    /// Logical payload size before anything is written into the installed tree.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub installed_size_bytes: Option<u64>,
169    /// Commitment to the extracted tree, absent on boxes built before it existed.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub payload_digest: Option<PayloadDigestCommitment>,
172    /// Interpreter path, relative to the box root.
173    pub python_entry_point: String,
174    /// Where a caller's model cache belongs inside the box.
175    pub model_cache_subdir: String,
176    /// Signed environment applied whenever Scrollcase runs the interpreter.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub environment: Option<BTreeMap<String, String>>,
179    /// The import check.
180    pub self_test: SelfTest,
181    /// The optional application entry point.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub execution: Option<Execution>,
184    /// How the box was produced.
185    pub provenance: Provenance,
186    /// Present only when assets are carried outside the archive.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub weights: Option<String>,
189    /// Descriptors of the assets a caller must materialise.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub assets: Option<Vec<AssetDescriptor>>,
192}
193
194/// The box's self-description, carried inside the archive.
195///
196/// Every field it holds also appears in the release, and all of them must agree: without that, a
197/// correctly hashed archive could be paired with a signed manifest describing something else.
198#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
199#[serde(rename_all = "camelCase", deny_unknown_fields)]
200pub struct BoxManifest {
201    /// Format version; always 2.
202    pub schema_version: u32,
203    /// Box identity.
204    pub box_id: String,
205    /// Model identity.
206    pub model_id: String,
207    /// Installed-directory identity.
208    pub runtime_id: String,
209    /// Box version.
210    pub version: String,
211    /// The target this box was built for.
212    pub target: BoxTarget,
213    /// Interpreter path, relative to the box root.
214    pub python_entry_point: String,
215    /// Where a caller's model cache belongs inside the box.
216    pub model_cache_subdir: String,
217    /// Signed environment applied whenever Scrollcase runs the interpreter.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub environment: Option<BTreeMap<String, String>>,
220    /// The import check.
221    pub self_test: SelfTest,
222    /// The optional application entry point.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub execution: Option<Execution>,
225    /// How the box was produced.
226    pub provenance: Provenance,
227    /// Present only when assets are carried outside the archive.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub weights: Option<String>,
230    /// Descriptors of the assets a caller must materialise.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub assets: Option<Vec<AssetDescriptor>>,
233}
234
235/// Whether a value is lowercase hex of the given length.
236fn 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
243/// Whether a value is the format's dotted-lowercase identifier.
244fn 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
259/// Whether a value is a strict Python dotted-module name.
260fn 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    /// Checks the constraints the type cannot express.
273    ///
274    /// # Errors
275    ///
276    /// When the script path is unsafe or the module name is not a strict dotted name.
277    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    /// Checks every constraint the schema states that the types cannot.
294    ///
295    /// # Errors
296    ///
297    /// When any pattern, bound, or co-requirement the schema states is violated.
298    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        // The target model is a contract rule rather than a field check: this rejects a triple
311        // outside the supported matrix, a CUDA target without a version, and a version on a target
312        // that may not carry one, all in the same call the slug is derived from.
313        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    /// The `weights`/`assets` co-requirement, and the descriptors themselves.
375    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            // `dependentRequired` in both directions: neither field means anything alone.
383            (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            // Screened before anything is joined onto a caller's directory.
392            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
407/// Environment names and values, as the schema constrains them.
408fn 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        // The name is passed to `python -m`, so anything that is not a dotted identifier — a space,
495        // a semicolon, a flag, a path — must never reach the argument list.
496        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}