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` almost everywhere and a typed `deny_unknown_fields`
6//! parse says exactly that — while a JSON Schema evaluator for the eighteen keywords these schemas
7//! actually use would be six hundred lines of validation logic whose failure mode is validating
8//! *less* than it claims.
9//!
10//! *Almost* everywhere: [`Compatibility`] is the one object the schemas leave open, and the types
11//! leave it open too. A typed parse is a faithful stand-in for a schema only where the schema is
12//! closed, and reading `deny_unknown_fields` as the house style rather than as a per-object
13//! statement is exactly how this crate once came to refuse releases the format defines as valid.
14//!
15//! The equivalence is not assumed. `tests/schema.rs` validates the same documents against the
16//! bundled canonical schemas and asserts the two agree, so a type that drifts from its schema is a
17//! red test rather than a silent divergence between implementations.
18//!
19//! What the types cannot express — patterns, non-empty strings, positive integers, and the
20//! `weights`/`assets` co-requirement — is checked explicitly in [`ReleaseManifest::validate`].
21
22use 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/// Host requirements a caller may enforce. Scrollcase records them; it does not decide policy.
33///
34/// This is the one object the canonical schemas declare `additionalProperties: true`. A publishing
35/// project may state constraints of its own beside the defined ones, and the builder copies them
36/// through verbatim without interpreting any of them, so a parse that refused an unrecognised key
37/// would make this crate stricter than the schema it ships — and would refuse boxes the schema, the
38/// builder, and the Node and Python consumers all accept. Unknown constraints therefore land in
39/// [`Compatibility::additional`], carried rather than dropped or rejected.
40///
41/// Carrying them costs no safety, because the schema puts the obligation on the application, not on
42/// the parser: *a consumer that cannot evaluate a constraint must refuse the box rather than assume
43/// it passes.* That decision needs the constraint in hand to be made at all, which is precisely what
44/// refusing the document took away.
45#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
46#[serde(rename_all = "camelCase")]
47pub struct Compatibility {
48    /// Minimum version of the consuming application.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub min_host_app_version: Option<String>,
51    /// First version of the consuming application this box no longer supports.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub max_host_app_version_exclusive: Option<String>,
54    /// Minimum macOS version.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub min_macos_version: Option<String>,
57    /// Minimum installed memory, in decimal gigabytes.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub min_ram_gb: Option<f64>,
60    /// Minimum NVIDIA driver version.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub min_nvidia_driver_version: Option<String>,
63    /// Execution environments this payload was validated for.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub host_environments: Option<Vec<String>>,
66    /// Constraints this format does not define, in the publishing project's own vocabulary.
67    ///
68    /// Empty for a box that declares only the fields above. Scrollcase never evaluates an entry
69    /// here; an application that finds one it does not understand must refuse the box.
70    #[serde(flatten)]
71    pub additional: BTreeMap<String, Value>,
72}
73
74/// Where the archive lives and what it must hash and weigh.
75#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct Archive {
78    /// Always `zip`.
79    pub format: String,
80    /// Where the archive was published. This crate never fetches it.
81    pub url: String,
82    /// Lowercase hex SHA-256 of the archive bytes.
83    pub sha256: String,
84    /// Exact archive size.
85    pub size_bytes: u64,
86}
87
88/// What a release commits to about its own extracted tree.
89#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
90#[serde(rename_all = "camelCase", deny_unknown_fields)]
91pub struct PayloadDigestCommitment {
92    /// Always `sha256-path-list-v1`.
93    pub format: String,
94    /// Lowercase hex SHA-256 of the canonical entry list.
95    pub sha256: String,
96}
97
98/// The import check a box must pass with its own interpreter.
99#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
100#[serde(rename_all = "camelCase", deny_unknown_fields)]
101pub struct SelfTest {
102    /// Modules to import.
103    pub python_imports: Vec<String>,
104    /// How long the import check may take.
105    pub timeout_seconds: u64,
106}
107
108/// The optional, shell-free application entry point.
109#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
110#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
111pub enum Execution {
112    /// Run one regular payload file with the box's own Python interpreter.
113    #[serde(rename_all = "camelCase")]
114    PythonScript {
115        /// Safe path to a regular Python file inside the box.
116        script: String,
117        /// Arguments always passed before a caller's own.
118        default_args: Vec<String>,
119    },
120    /// Run one dotted Python module.
121    #[serde(rename_all = "camelCase")]
122    PythonModule {
123        /// Strict dotted-module name, with no command-line or shell syntax.
124        module: String,
125        /// Arguments always passed before a caller's own.
126        default_args: Vec<String>,
127    },
128}
129
130/// How the box was produced. Recorded, signed, and never fabricated.
131#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct Provenance {
134    /// Identity of the scroll that produced this box.
135    pub scroll_id: String,
136    /// Version declared by that scroll.
137    pub scroll_version: String,
138    /// Commit of Scrollcase itself.
139    pub builder_revision: String,
140    /// Whether the source tree was dirty at build time. Never quietly downgraded.
141    pub source_tree_dirty: bool,
142    /// Commit the box was built from.
143    pub source_revision: String,
144    /// Python version inside the box.
145    pub python_version: String,
146    /// SHA-256 of the dependency lock.
147    pub dependency_lock_sha256: String,
148    /// When the box was built.
149    pub built_at: String,
150    /// pixi version that solved the environment.
151    pub pixi_version: String,
152}
153
154/// One on-demand asset a caller must materialise before execution.
155#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
156#[serde(rename_all = "camelCase", deny_unknown_fields)]
157pub struct AssetDescriptor {
158    /// Where the asset was published. This crate never fetches it.
159    pub url: String,
160    /// Where it must be placed, relative to the box root.
161    pub relative_path: String,
162    /// Exact size the placed file must have.
163    pub size_bytes: u64,
164    /// Lowercase hex SHA-256 the placed file must have.
165    pub sha256: String,
166}
167
168/// The immutable description of one built box.
169#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
170#[serde(rename_all = "camelCase", deny_unknown_fields)]
171pub struct ReleaseManifest {
172    /// Format version; always 2.
173    pub schema_version: u32,
174    /// `<namespace>.release`, in the publishing project's own namespace.
175    pub kind: String,
176    /// Box identity.
177    pub box_id: String,
178    /// Model identity.
179    pub model_id: String,
180    /// Installed-directory identity.
181    pub runtime_id: String,
182    /// Box version.
183    pub version: String,
184    /// The target this box was built for.
185    pub target: BoxTarget,
186    /// Host requirements.
187    pub compatibility: Compatibility,
188    /// The archive this release commits to.
189    pub archive: Archive,
190    /// Logical payload size before anything is written into the installed tree.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub installed_size_bytes: Option<u64>,
193    /// Commitment to the extracted tree, absent on boxes built before it existed.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub payload_digest: Option<PayloadDigestCommitment>,
196    /// Interpreter path, relative to the box root.
197    pub python_entry_point: String,
198    /// Where a caller's model cache belongs inside the box.
199    pub model_cache_subdir: String,
200    /// Signed environment applied whenever Scrollcase runs the interpreter.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub environment: Option<BTreeMap<String, String>>,
203    /// The import check.
204    pub self_test: SelfTest,
205    /// The optional application entry point.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub execution: Option<Execution>,
208    /// How the box was produced.
209    pub provenance: Provenance,
210    /// Present only when assets are carried outside the archive.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub weights: Option<String>,
213    /// Descriptors of the assets a caller must materialise.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub assets: Option<Vec<AssetDescriptor>>,
216}
217
218/// The box's self-description, carried inside the archive.
219///
220/// Every field it holds also appears in the release, and all of them must agree: without that, a
221/// correctly hashed archive could be paired with a signed manifest describing something else.
222#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
223#[serde(rename_all = "camelCase", deny_unknown_fields)]
224pub struct BoxManifest {
225    /// Format version; always 2.
226    pub schema_version: u32,
227    /// Box identity.
228    pub box_id: String,
229    /// Model identity.
230    pub model_id: String,
231    /// Installed-directory identity.
232    pub runtime_id: String,
233    /// Box version.
234    pub version: String,
235    /// The target this box was built for.
236    pub target: BoxTarget,
237    /// Interpreter path, relative to the box root.
238    pub python_entry_point: String,
239    /// Where a caller's model cache belongs inside the box.
240    pub model_cache_subdir: String,
241    /// Signed environment applied whenever Scrollcase runs the interpreter.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub environment: Option<BTreeMap<String, String>>,
244    /// The import check.
245    pub self_test: SelfTest,
246    /// The optional application entry point.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub execution: Option<Execution>,
249    /// How the box was produced.
250    pub provenance: Provenance,
251    /// Present only when assets are carried outside the archive.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub weights: Option<String>,
254    /// Descriptors of the assets a caller must materialise.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub assets: Option<Vec<AssetDescriptor>>,
257}
258
259/// Whether a value is lowercase hex of the given length.
260fn 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
267/// Whether a value is the format's dotted-lowercase identifier.
268fn 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
283/// Whether a value is a strict Python dotted-module name.
284fn 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    /// Checks the constraints the type cannot express.
297    ///
298    /// # Errors
299    ///
300    /// When the script path is unsafe or the module name is not a strict dotted name.
301    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    /// Checks every constraint the schema states that the types cannot.
318    ///
319    /// # Errors
320    ///
321    /// When any pattern, bound, or co-requirement the schema states is violated.
322    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        // The target model is a contract rule rather than a field check: this rejects a triple
335        // outside the supported matrix, a CUDA target without a version, and a version on a target
336        // that may not carry one, all in the same call the slug is derived from.
337        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    /// The `weights`/`assets` co-requirement, and the descriptors themselves.
399    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            // `dependentRequired` in both directions: neither field means anything alone.
407            (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            // Screened before anything is joined onto a caller's directory.
416            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
431/// Environment names and values, as the schema constrains them.
432fn 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        // The name is passed to `python -m`, so anything that is not a dotted identifier — a space,
519        // a semicolon, a flag, a path — must never reach the argument list.
520        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}