Skip to main content

scrollcase_consumer/
verify.rs

1//! The half of the trust chain that needs no archive.
2//!
3//! Everything here answers questions about the signed document alone — is the signature good, is the
4//! payload a schema-version-2 release, does it describe a target this build understands. It is split
5//! out for the same reason Node splits it: a box that is already extracted has no archive to check,
6//! and re-deriving these steps beside the ones that do would create the second interpretation of a
7//! signed release that the shared inspection exists to prevent.
8
9use std::collections::BTreeSet;
10use std::path::{Path, PathBuf};
11
12use crate::archive::{list_zip_entries, read_zip_entry_text, ArchiveEntry};
13use crate::contract::documents::SignedDocument;
14use crate::contract::links::EntryKind;
15use crate::contract::targets::{assert_python_entry_point, box_target_adapter, BoxTargetAdapter};
16use crate::error::{fail, Error, Result};
17use crate::execution::assert_execution_files;
18use crate::filesystem::sha256_file;
19use crate::release::{BoxManifest, ReleaseManifest};
20use crate::trust::{load_trusted_keys, verify_signed_document, TrustedKey};
21
22/// A signed release that has passed every check possible without its archive.
23#[derive(Debug, Clone)]
24pub struct InspectedRelease {
25    /// Where the document was read from.
26    pub release_path: PathBuf,
27    /// The envelope exactly as received, kept so a caller can persist what it verified.
28    pub signed: SignedDocument,
29    /// The verified release.
30    pub release: ReleaseManifest,
31    /// The adapter for the release's target.
32    pub adapter: &'static BoxTargetAdapter,
33}
34
35/// Verifies a signed release document against a caller-supplied trust file.
36///
37/// The order is the guarantee. The envelope is shape-checked, then its signature is verified, and
38/// only then is the payload interpreted as a release: nothing about the release is believed — not
39/// its target, not its paths, not its provenance — until a trusted key has vouched for the exact
40/// bytes it was read from.
41///
42/// # Errors
43///
44/// When the document cannot be read, is not a v2 envelope, carries no signature from a trusted key,
45/// or describes a release this build cannot accept.
46pub fn inspect_release_document(
47    release_document_path: &Path,
48    public_key_path: &Path,
49) -> Result<InspectedRelease> {
50    let trusted = load_trusted_keys(public_key_path)?;
51    inspect_release_document_with_keys(release_document_path, &trusted)
52}
53
54/// The same inspection against keys a caller already holds in memory.
55///
56/// # Errors
57///
58/// See [`inspect_release_document`].
59pub fn inspect_release_document_with_keys(
60    release_document_path: &Path,
61    trusted: &[TrustedKey],
62) -> Result<InspectedRelease> {
63    let release_path = release_document_path
64        .canonicalize()
65        .unwrap_or_else(|_| release_document_path.to_path_buf());
66    let raw = std::fs::read(&release_path).map_err(|error| {
67        Error::new(format!(
68            "Invalid signed release document {}: {error}",
69            release_path.display()
70        ))
71    })?;
72
73    let signed = SignedDocument::parse(&raw)?;
74    let payload = verify_signed_document(&signed, trusted)?;
75
76    // A v1 payload inside a v2 envelope is refused by name rather than reinterpreted.
77    if payload.value.get("schemaVersion").and_then(serde_json::Value::as_u64) == Some(1) {
78        fail!("Unsupported schemaVersion 1; rebuild this box with Scrollcase v2.");
79    }
80    let release: ReleaseManifest = serde_json::from_value(payload.value)
81        .map_err(|error| Error::new(format!("Invalid release manifest: {error}.")))?;
82    release.validate()?;
83
84    let adapter = box_target_adapter(&release.target)?;
85    assert_python_entry_point(adapter, &release.python_entry_point)?;
86
87    Ok(InspectedRelease {
88        release_path,
89        signed,
90        release,
91        adapter,
92    })
93}
94
95/// Binds the self-description inside the archive to the signed release outside it.
96///
97/// Only fields present in both schema-version-2 documents belong here. Release-only transport data
98/// has no counterpart in `box.json`; every shared identity, target, layout, self-test, environment,
99/// asset-policy and provenance field must agree. Without this, a correctly hashed archive could be
100/// paired with a signed manifest describing something else entirely.
101///
102/// # Errors
103///
104/// When any shared field differs, naming the first one that does.
105pub fn assert_box_manifest_agreement(
106    box_manifest: &BoxManifest,
107    release: &ReleaseManifest,
108) -> Result<()> {
109    // Written as explicit pairs rather than a derived comparison so the field name in the message is
110    // the field that actually differed — the Node and Python consumers report the same way, and the
111    // conformance fixture pins `box.json mismatch: modelId` among others.
112    let mismatch = if box_manifest.schema_version != release.schema_version {
113        Some("schemaVersion")
114    } else if box_manifest.box_id != release.box_id {
115        Some("boxId")
116    } else if box_manifest.model_id != release.model_id {
117        Some("modelId")
118    } else if box_manifest.runtime_id != release.runtime_id {
119        Some("runtimeId")
120    } else if box_manifest.version != release.version {
121        Some("version")
122    } else if box_manifest.target != release.target {
123        Some("target")
124    } else if box_manifest.python_entry_point != release.python_entry_point {
125        Some("pythonEntryPoint")
126    } else if box_manifest.model_cache_subdir != release.model_cache_subdir {
127        Some("modelCacheSubdir")
128    } else if box_manifest.environment != release.environment {
129        Some("environment")
130    } else if box_manifest.self_test != release.self_test {
131        Some("selfTest")
132    } else if box_manifest.execution != release.execution {
133        Some("execution")
134    } else if box_manifest.weights != release.weights {
135        Some("weights")
136    } else if box_manifest.assets != release.assets {
137        Some("assets")
138    } else if box_manifest.provenance != release.provenance {
139        Some("provenance")
140    } else {
141        None
142    };
143    if let Some(field) = mismatch {
144        fail!("box.json mismatch: {field}");
145    }
146    Ok(())
147}
148
149/// A signed release together with the archive it commits to, both checked.
150#[derive(Debug, Clone)]
151pub struct InspectedArchive {
152    /// The archive-free half of the chain.
153    pub release: InspectedRelease,
154    /// Where the archive was read from.
155    pub archive_path: PathBuf,
156    /// The box's own self-description, proved to agree with the release.
157    pub box_manifest: BoxManifest,
158    /// Every validated archive entry.
159    pub entries: Vec<ArchiveEntry>,
160}
161
162/// Performs the complete read-only trust chain, archive included.
163///
164/// Keeping this as one operation matters: an execution API must not create a second, subtly
165/// different interpretation of a signed release. The caller receives validated in-memory objects and
166/// the exact archive path, while extraction and execution remain separate steps.
167///
168/// # Errors
169///
170/// When the release fails inspection, the archive is missing or does not match its signed size and
171/// hash, the archive holds an entry the format forbids, or `box.json` disagrees with the release.
172pub fn inspect_box_archive(
173    release_document_path: &Path,
174    public_key_path: &Path,
175    archive_override: Option<&Path>,
176) -> Result<InspectedArchive> {
177    let release = inspect_release_document(release_document_path, public_key_path)?;
178    inspect_archive_for(release, archive_override)
179}
180
181/// The archive half, against a release this process already inspected.
182///
183/// # Errors
184///
185/// See [`inspect_box_archive`].
186pub fn inspect_archive_for(
187    inspected: InspectedRelease,
188    archive_override: Option<&Path>,
189) -> Result<InspectedArchive> {
190    let release = &inspected.release;
191    // By convention the archive sits next to its release document under the hash that document
192    // commits to — the same name it is published under, so this resolves identically against a local
193    // dist tree and a directory copied from a mirror.
194    let archive_path = match archive_override {
195        Some(path) => path.to_path_buf(),
196        None => inspected
197            .release_path
198            .parent()
199            .unwrap_or(Path::new("."))
200            .join(format!("{}.zip", release.archive.sha256)),
201    };
202    let metadata = std::fs::metadata(&archive_path)
203        .map_err(|_| Error::new(format!("Archive not found: {}", archive_path.display())))?;
204    if metadata.len() != release.archive.size_bytes {
205        fail!("Archive size mismatch.");
206    }
207    if sha256_file(&archive_path)? != release.archive.sha256 {
208        fail!("Archive SHA-256 mismatch.");
209    }
210
211    let entries = list_zip_entries(&archive_path)?;
212    // Two questions, deliberately not the same set. `box.json` is read out of the archive, so it must
213    // be an entry with its own bytes. Everything else asks only whether a path resolves — and a link
214    // does resolve, to a file inside this same payload, because nothing else was allowed in.
215    let files: BTreeSet<String> = entries
216        .iter()
217        .filter(|entry| entry.kind == EntryKind::File)
218        .map(|entry| entry.path.clone())
219        .collect();
220    let resolvable: BTreeSet<String> = entries
221        .iter()
222        .filter(|entry| matches!(entry.kind, EntryKind::File | EntryKind::Link))
223        .map(|entry| entry.path.clone())
224        .collect();
225
226    if !files.contains("box.json") {
227        fail!("Archive is missing box.json.");
228    }
229    let raw = read_zip_entry_text(&archive_path, "box.json")?;
230    let box_manifest: BoxManifest = serde_json::from_str(&raw)
231        .map_err(|error| Error::new(format!("Invalid box.json: {error}.")))?;
232    assert_box_manifest_agreement(&box_manifest, release)?;
233
234    if !resolvable.contains(&release.python_entry_point) {
235        fail!("Archive is missing {}.", release.python_entry_point);
236    }
237    assert_execution_files(
238        release.execution.as_ref(),
239        inspected.adapter,
240        &release.provenance.python_version,
241        &resolvable,
242    )?;
243
244    Ok(InspectedArchive {
245        release: inspected,
246        archive_path,
247        box_manifest,
248        entries,
249    })
250}