Skip to main content

scrollcase_consumer/
prepare.rs

1//! Verification and durable preparation of a caller-supplied local box.
2//!
3//! A [`PreparedBox`] is deliberately opaque. Its accessors expose useful signed identity and audit
4//! data, while the verified release and the root's filesystem identity stay private. A caller
5//! therefore cannot construct something that looks prepared and use it to skip the trust chain before
6//! execution — in Rust that is not a convention but a property of the type: the fields are private,
7//! there is no public constructor, and the only values in existence came from a function that
8//! performed the checks.
9//!
10//! `status` says which of the two producers minted a receipt, because they do not prove the same
11//! thing. `Prepared` means the bytes came from an archive whose signed hash was checked in this
12//! process. `Attached` means an existing directory was re-identified against a signed release with no
13//! archive to check it against — and the receipt must not claim more than that.
14
15use std::collections::BTreeMap;
16use std::fs::Metadata;
17use std::path::{Path, PathBuf};
18
19use crate::archive::extract_zip_archive;
20use crate::contract::payload_digest::{
21    parse_payload_digest_stream, PayloadDigestKind, MAX_PAYLOAD_DIGEST_BYTES, PAYLOAD_DIGEST_FILE,
22};
23use crate::contract::targets::{assert_native_host, box_target_id, BoxTargetAdapter};
24use crate::environment::{
25    resolve_environment, EnvironmentLayer, EnvironmentReport, EnvironmentSource, ResolveOptions,
26};
27use crate::error::{fail, Error, Result};
28use crate::execution::assert_execution_files;
29use crate::filesystem::{collect_files, payload_size, sha256_file};
30use crate::path::{join_relative, safe_relative_path};
31use crate::release::{AssetDescriptor, Execution, ReleaseManifest};
32use crate::verify::{inspect_archive_for, inspect_release_document, InspectedRelease};
33
34/// Which producer minted a receipt.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum PreparedStatus {
37    /// Extracted in this process from an archive whose signed hash was checked.
38    Prepared,
39    /// Re-identified from an existing directory, with no archive to check it against.
40    Attached,
41}
42
43/// How much of the environment a verification receipt should describe.
44#[derive(Debug, Clone, Default)]
45pub struct EnvironmentReportOptions {
46    /// List every variable rather than only the actionable ones.
47    pub env_report: bool,
48    /// Show inherited host values rather than masking them. Implies `env_report`.
49    pub env_report_values: bool,
50    /// The inherited environment the report resolves against. Defaults to this process's, and is
51    /// injectable so a test can state one without mutating what every thread in the process shares.
52    pub host_environment: Option<Vec<(String, String)>>,
53}
54
55/// On unix a root is identified by the pair that survives a rename; elsewhere by its canonical path.
56///
57/// The unix form is strictly stronger: it detects a directory swapped for another at the same name.
58/// The fallback is what the platform makes available without opening a directory handle, and saying
59/// so is better than implying a guarantee that is not there.
60#[cfg(unix)]
61type RootIdentity = (u64, u64);
62#[cfg(not(unix))]
63type RootIdentity = PathBuf;
64
65// Fallible on the other branch, where canonicalising can fail, so both keep one signature.
66#[cfg_attr(unix, allow(clippy::unnecessary_wraps))]
67#[cfg(unix)]
68fn root_identity(_path: &Path, metadata: &Metadata) -> Result<RootIdentity> {
69    use std::os::unix::fs::MetadataExt as _;
70    Ok((metadata.dev(), metadata.ino()))
71}
72
73#[cfg(not(unix))]
74fn root_identity(path: &Path, _metadata: &Metadata) -> Result<RootIdentity> {
75    std::fs::canonicalize(path)
76        .map_err(|error| Error::new(format!("cannot identify {}: {error}", path.display())))
77}
78
79/// Whether the directory that landed at the destination is the one that was staged.
80///
81/// On unix the inode pair survives a rename, so this is a real check: it catches the staged tree
82/// being swapped for another between the move and the receipt. Elsewhere a directory's identity *is*
83/// its path, and the rename changed the path deliberately, so there is nothing to compare — saying
84/// so is better than inventing a comparison that would either always pass or always fail.
85#[cfg(unix)]
86fn survived_the_rename(staged: &Metadata, installed: &Metadata) -> bool {
87    use std::os::unix::fs::MetadataExt as _;
88    (staged.dev(), staged.ino()) == (installed.dev(), installed.ino())
89}
90
91#[cfg(not(unix))]
92fn survived_the_rename(_staged: &Metadata, installed: &Metadata) -> bool {
93    installed.is_dir()
94}
95
96/// The immutable result of a successfully verified box.
97#[derive(Debug, Clone)]
98pub struct PreparedBox {
99    status: PreparedStatus,
100    root: PathBuf,
101    target_id: String,
102    signing_key_ids: Vec<String>,
103    release_payload_sha256: String,
104    installed_size_bytes: u64,
105    environment_report: EnvironmentReport,
106    release: ReleaseManifest,
107    // Private state read only by the execution surface. Never accessors: nothing outside this crate
108    // may reach them, which is what stops a caller from reconstructing a receipt.
109    adapter: &'static BoxTargetAdapter,
110    root_identity: RootIdentity,
111}
112
113impl PreparedBox {
114    /// Which producer minted this receipt, and therefore what it proves.
115    #[must_use]
116    pub fn status(&self) -> PreparedStatus {
117        self.status
118    }
119
120    /// Absolute path of the extracted box root.
121    #[must_use]
122    pub fn root(&self) -> &Path {
123        &self.root
124    }
125
126    /// Box identity, from the signed release.
127    #[must_use]
128    pub fn box_id(&self) -> &str {
129        &self.release.box_id
130    }
131
132    /// Model identity, from the signed release.
133    #[must_use]
134    pub fn model_id(&self) -> &str {
135        &self.release.model_id
136    }
137
138    /// Installed-directory identity, from the signed release.
139    #[must_use]
140    pub fn runtime_id(&self) -> &str {
141        &self.release.runtime_id
142    }
143
144    /// Box version, from the signed release.
145    #[must_use]
146    pub fn version(&self) -> &str {
147        &self.release.version
148    }
149
150    /// Canonical target slug.
151    #[must_use]
152    pub fn target_id(&self) -> &str {
153        &self.target_id
154    }
155
156    /// Interpreter path, relative to the box root.
157    #[must_use]
158    pub fn python_entry_point(&self) -> &str {
159        &self.release.python_entry_point
160    }
161
162    /// The declared application entry point, if the box has one.
163    #[must_use]
164    pub fn execution(&self) -> Option<&Execution> {
165        self.release.execution.as_ref()
166    }
167
168    /// Assets the caller must materialise. Scrollcase never downloads them.
169    #[must_use]
170    pub fn required_assets(&self) -> &[AssetDescriptor] {
171        required_assets_of(&self.release)
172    }
173
174    /// Which keys signed the release this box was verified against.
175    #[must_use]
176    pub fn signing_key_ids(&self) -> &[String] {
177        &self.signing_key_ids
178    }
179
180    /// SHA-256 of the signed release payload.
181    #[must_use]
182    pub fn release_payload_sha256(&self) -> &str {
183        &self.release_payload_sha256
184    }
185
186    /// SHA-256 the signed release commits the archive to.
187    #[must_use]
188    pub fn archive_sha256(&self) -> &str {
189        &self.release.archive.sha256
190    }
191
192    /// Size the signed release commits the archive to.
193    #[must_use]
194    pub fn archive_size_bytes(&self) -> u64 {
195        self.release.archive.size_bytes
196    }
197
198    /// Logical size of the box root when this receipt was produced.
199    ///
200    /// On an attached receipt this is a current measurement, never an agreement with the release: an
201    /// installed tree legitimately grows after extraction.
202    #[must_use]
203    pub fn installed_size_bytes(&self) -> u64 {
204        self.installed_size_bytes
205    }
206
207    /// Diagnostic snapshot of this process's environment against the signed declaration.
208    #[must_use]
209    pub fn environment_report(&self) -> &EnvironmentReport {
210        &self.environment_report
211    }
212
213    /// The verified release, for code inside this crate only.
214    pub(crate) fn release(&self) -> &ReleaseManifest {
215        &self.release
216    }
217
218    /// The target adapter, for code inside this crate only.
219    pub(crate) fn adapter(&self) -> &'static BoxTargetAdapter {
220        self.adapter
221    }
222
223    /// Re-checks that the root is still the directory this receipt was minted for.
224    pub(crate) fn assert_root_unchanged(&self) -> Result<()> {
225        let Ok(metadata) = std::fs::symlink_metadata(&self.root) else {
226            fail!("Prepared box root no longer matches the prepared box.");
227        };
228        if !metadata.is_dir() || root_identity(&self.root, &metadata)? != self.root_identity {
229            fail!("Prepared box root no longer matches the prepared box.");
230        }
231        Ok(())
232    }
233}
234
235/// The on-demand descriptors a release requires a caller to have materialised.
236fn required_assets_of(release: &ReleaseManifest) -> &[AssetDescriptor] {
237    if release.weights.as_deref() == Some("on-demand") {
238        release.assets.as_deref().unwrap_or(&[])
239    } else {
240        &[]
241    }
242}
243
244/// Checks the assets a caller was told to place, against their signed descriptors.
245///
246/// # Errors
247///
248/// When an asset is missing, is not a regular file, or does not match its signed size or digest.
249pub fn verify_required_assets(root: &Path, assets: &[AssetDescriptor]) -> Result<()> {
250    for asset in assets {
251        let relative = safe_relative_path(&asset.relative_path)?;
252        let path = join_relative(root, &relative);
253        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
254            fail!(
255                "Required on-demand asset is missing: {}.",
256                asset.relative_path
257            );
258        };
259        if !metadata.is_file() {
260            fail!(
261                "Required on-demand asset is not a regular file: {}.",
262                asset.relative_path
263            );
264        }
265        if metadata.len() != asset.size_bytes {
266            fail!(
267                "Required on-demand asset size mismatch: {}.",
268                asset.relative_path
269            );
270        }
271        if sha256_file(&path)? != asset.sha256 {
272            fail!(
273                "Required on-demand asset SHA-256 mismatch: {}.",
274                asset.relative_path
275            );
276        }
277    }
278    Ok(())
279}
280
281/// The diagnostic every verification receipt carries.
282fn release_environment_report(
283    release: &ReleaseManifest,
284    adapter: &BoxTargetAdapter,
285    options: &EnvironmentReportOptions,
286) -> Result<EnvironmentReport> {
287    let host: Vec<(String, String)> = options
288        .host_environment
289        .clone()
290        .unwrap_or_else(|| std::env::vars().collect());
291    let host_pairs: Vec<(&str, &str)> = host
292        .iter()
293        .map(|(name, value)| (name.as_str(), value.as_str()))
294        .collect();
295    let declared: BTreeMap<String, String> = release.environment.clone().unwrap_or_default();
296    let release_pairs: Vec<(&str, &str)> = declared
297        .iter()
298        .map(|(name, value)| (name.as_str(), value.as_str()))
299        .collect();
300
301    Ok(resolve_environment(&ResolveOptions {
302        platform: adapter.platform,
303        layers: vec![
304            EnvironmentLayer {
305                source: EnvironmentSource::Host,
306                values: host_pairs,
307            },
308            EnvironmentLayer {
309                source: EnvironmentSource::Release,
310                values: release_pairs,
311            },
312        ],
313        execution_affecting_variables: adapter.execution_affecting_environment_variables,
314        expanded: options.env_report || options.env_report_values,
315        reveal_host_values: options.env_report_values,
316    })?
317    .report)
318}
319
320fn mint(
321    status: PreparedStatus,
322    root: PathBuf,
323    inspected: &InspectedRelease,
324    installed_size_bytes: u64,
325    identity: RootIdentity,
326    options: &EnvironmentReportOptions,
327) -> Result<PreparedBox> {
328    let release = inspected.release.clone();
329    Ok(PreparedBox {
330        status,
331        root,
332        target_id: box_target_id(&release.target)?,
333        signing_key_ids: inspected
334            .signed
335            .signatures
336            .iter()
337            .map(|signature| signature.key_id.clone())
338            .collect(),
339        release_payload_sha256: inspected.signed.payload_sha256.clone(),
340        installed_size_bytes,
341        environment_report: release_environment_report(&release, inspected.adapter, options)?,
342        release,
343        adapter: inspected.adapter,
344        root_identity: identity,
345    })
346}
347
348/// Where the caller wants a box prepared, and how much to say about the environment.
349pub struct PrepareOptions<'a> {
350    /// Trust file naming the keys the caller accepts.
351    pub public_key_path: &'a Path,
352    /// The archive, when it is not beside its release document under its own hash.
353    pub archive: Option<&'a Path>,
354    /// Where the box must end up. Must not already exist.
355    pub destination: &'a Path,
356    /// Environment reporting.
357    pub environment: EnvironmentReportOptions,
358}
359
360/// Verifies and extracts one local box without executing any code from it.
361///
362/// The destination must not exist. Extraction happens in a fresh sibling directory so the final
363/// rename stays on one filesystem and exposes either the complete verified tree or nothing at all —
364/// a box is never observed half-installed.
365///
366/// # Errors
367///
368/// When the destination exists, the trust chain fails, the extracted size disagrees with the signed
369/// release, or the archive changed while it was being read.
370pub fn verify_and_extract_box(
371    release_document_path: &Path,
372    options: &PrepareOptions<'_>,
373) -> Result<PreparedBox> {
374    let final_root = absolute(options.destination);
375    if std::fs::symlink_metadata(&final_root).is_ok() {
376        fail!("Destination already exists: {}", final_root.display());
377    }
378
379    let inspected = inspect_release_document(release_document_path, options.public_key_path)?;
380    let archive = inspect_archive_for(inspected, options.archive)?;
381    let release = &archive.release.release;
382
383    let parent = final_root
384        .parent()
385        .ok_or_else(|| Error::new("A destination must have a parent directory."))?
386        .to_path_buf();
387    std::fs::create_dir_all(&parent)?;
388    if std::fs::symlink_metadata(&final_root).is_ok() {
389        fail!("Destination already exists: {}", final_root.display());
390    }
391
392    let stage_root = parent.join(format!(
393        ".scrollcase-prepare-{}-{}",
394        final_root
395            .file_name()
396            .and_then(std::ffi::OsStr::to_str)
397            .unwrap_or("box"),
398        unique_suffix()
399    ));
400    std::fs::create_dir_all(&stage_root)?;
401    let result = prepare_into(&stage_root, &final_root, &archive.archive_path, &archive.release, release, options);
402    let _ = std::fs::remove_dir_all(&stage_root);
403    result
404}
405
406fn prepare_into(
407    stage_root: &Path,
408    final_root: &Path,
409    archive_path: &Path,
410    inspected: &InspectedRelease,
411    release: &ReleaseManifest,
412    options: &PrepareOptions<'_>,
413) -> Result<PreparedBox> {
414    let extracted_root = stage_root.join("payload");
415    extract_zip_archive(archive_path, &extracted_root)?;
416
417    let extracted_size = payload_size(&extracted_root)?;
418    if release
419        .installed_size_bytes
420        .is_some_and(|declared| declared != extracted_size)
421    {
422        fail!("Extracted payload size does not match the signed release.");
423    }
424
425    // Re-checked after extraction: this catches a local archive being replaced between the initial
426    // trust decision and the move into the caller's durable destination.
427    if sha256_file(archive_path)? != release.archive.sha256 {
428        fail!("Archive SHA-256 changed during extraction.");
429    }
430
431    let staged = std::fs::symlink_metadata(&extracted_root)?;
432    if std::fs::symlink_metadata(final_root).is_ok() {
433        fail!("Destination already exists: {}", final_root.display());
434    }
435    std::fs::rename(&extracted_root, final_root).map_err(|error| {
436        Error::new(format!(
437            "cannot install into {}: {error}",
438            final_root.display()
439        ))
440    })?;
441
442    let installed = std::fs::symlink_metadata(final_root)?;
443    if !survived_the_rename(&staged, &installed) {
444        fail!("Prepared destination identity changed during installation.");
445    }
446
447    mint(
448        PreparedStatus::Prepared,
449        final_root.to_path_buf(),
450        inspected,
451        extracted_size,
452        root_identity(final_root, &installed)?,
453        &options.environment,
454    )
455}
456
457/// Where an already-extracted box lives.
458pub struct AttachOptions<'a> {
459    /// Trust file naming the keys the caller accepts.
460    pub public_key_path: &'a Path,
461    /// The extracted box root.
462    pub root: &'a Path,
463    /// Environment reporting.
464    pub environment: EnvironmentReportOptions,
465}
466
467/// Resolves a directory a caller claims holds an extracted box, refusing anything that is not one.
468fn resolve_extracted_root(root: &Path) -> Result<(PathBuf, Metadata)> {
469    let resolved = absolute(root);
470    let Ok(metadata) = std::fs::symlink_metadata(&resolved) else {
471        fail!("{} is not an extracted box directory.", resolved.display());
472    };
473    // `symlink_metadata`, so a link reports false here. That is deliberate: running a box requires a
474    // real directory, and accepting a link would mint a receipt that can never be executed.
475    if !metadata.is_dir() {
476        fail!("{} is not an extracted box directory.", resolved.display());
477    }
478    Ok((resolved, metadata))
479}
480
481/// Re-identifies a box that is already extracted, without its archive.
482///
483/// This is what lets an application install a box once and run it across restarts. It performs every
484/// check that needs no data beyond the signed release — signature and schema, a target this host can
485/// run, the interpreter and execution files present, the signed digests of on-demand assets — and
486/// deliberately does not read the payload. Proving the installed bytes is
487/// [`verify_extracted_payload`], a separate decision with a separate cost.
488///
489/// Unlike preparation, this asserts the native host: preparing only writes files, but a receipt
490/// minted here exists to be executed.
491///
492/// # Errors
493///
494/// When the root is not a directory, the trust chain fails, the host cannot run the target, the
495/// interpreter or execution files are absent, or an on-demand asset does not match its descriptor.
496pub fn attach_extracted_box(
497    release_document_path: &Path,
498    options: &AttachOptions<'_>,
499) -> Result<PreparedBox> {
500    let (root, metadata) = resolve_extracted_root(options.root)?;
501    let inspected = inspect_release_document(release_document_path, options.public_key_path)?;
502    let release = &inspected.release;
503
504    if assert_native_host(inspected.adapter).is_err() {
505        fail!(
506            "Box target {} cannot run on {}/{}; it requires {}/{}.",
507            box_target_id(&release.target)?,
508            std::env::consts::OS,
509            std::env::consts::ARCH,
510            inspected.adapter.host_os,
511            inspected.adapter.host_arch
512        );
513    }
514
515    let files = collect_files(&root)?;
516    if !files.contains(&release.python_entry_point) {
517        fail!("Attached box is missing {}.", release.python_entry_point);
518    }
519    assert_execution_files(
520        release.execution.as_ref(),
521        inspected.adapter,
522        &release.provenance.python_version,
523        &files,
524    )?;
525    verify_required_assets(&root, required_assets_of(release))?;
526
527    // Measured, never compared: an installed tree legitimately grows after extraction — on-demand
528    // assets, caches, whatever the application writes — so holding it to the signed figure would
529    // fail honest boxes.
530    let installed_size_bytes = payload_size(&root)?;
531    let settled = std::fs::symlink_metadata(&root)?;
532    if root_identity(&root, &settled)? != root_identity(&root, &metadata)? {
533        fail!("Attached box root changed while it was being checked.");
534    }
535
536    mint(
537        PreparedStatus::Attached,
538        root.clone(),
539        &inspected,
540        installed_size_bytes,
541        root_identity(&root, &settled)?,
542        &options.environment,
543    )
544}
545
546/// The result of comparing an extracted tree against the entry list its release commits to.
547#[derive(Debug, Clone)]
548pub struct PayloadVerification {
549    /// The tree that was checked.
550    pub root: PathBuf,
551    /// Box identity, from the signed release.
552    pub box_id: String,
553    /// Box version, from the signed release.
554    pub version: String,
555    /// Canonical target slug.
556    pub target_id: String,
557    /// How many payload entries were checked.
558    pub entry_count: usize,
559    /// Diagnostic snapshot of the environment.
560    pub environment_report: EnvironmentReport,
561}
562
563/// Proves an extracted tree is the one a signed release describes.
564///
565/// Deliberately standalone. Nothing calls it — not preparation, not attachment, not execution —
566/// because it reads every byte the box carries, and because a check that passed at one moment says
567/// nothing about the next: between here and a later import the tree can change, and no library can
568/// close that window. Filesystem permissions do, and they belong to the operating system and the
569/// application. What this answers is narrower and worth answering: is this directory the box that
570/// release describes, and is it still whole.
571///
572/// # Errors
573///
574/// When the release commits to no payload digest, the list is missing or does not match the signed
575/// value, or any entry it names is absent, of the wrong kind, or of different content.
576pub fn verify_extracted_payload(
577    release_document_path: &Path,
578    options: &AttachOptions<'_>,
579) -> Result<PayloadVerification> {
580    let (root, _) = resolve_extracted_root(options.root)?;
581    let inspected = inspect_release_document(release_document_path, options.public_key_path)?;
582    let release = &inspected.release;
583
584    let Some(commitment) = release.payload_digest.as_ref() else {
585        fail!("This release does not commit to a payload digest; it was built before payload verification existed.");
586    };
587
588    let list_path = root.join(PAYLOAD_DIGEST_FILE);
589    let Ok(list_metadata) = std::fs::symlink_metadata(&list_path) else {
590        fail!("Attached box is missing its payload digest list: {PAYLOAD_DIGEST_FILE}.");
591    };
592    if list_metadata.len() > MAX_PAYLOAD_DIGEST_BYTES {
593        fail!("Payload digest list is larger than this consumer will read.");
594    }
595    // Hashed before it is parsed. The list arrives with the untrusted tree it describes, so until it
596    // matches the signed value it is not a list — it is input.
597    if sha256_file(&list_path)? != commitment.sha256 {
598        fail!("Payload digest list does not match the signed release.");
599    }
600
601    let bytes = std::fs::read(&list_path)?;
602    let entries = parse_payload_digest_stream(&bytes)
603        .map_err(|error| Error::new(format!("Invalid payload digest list: {error}")))?;
604
605    for entry in &entries {
606        let relative = safe_relative_path(&entry.path)?;
607        let path = join_relative(&root, &relative);
608        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
609            fail!(
610                "Payload does not match the signed release: {} is missing.",
611                entry.path
612            );
613        };
614        let kind = if metadata.is_symlink() {
615            Some(PayloadDigestKind::Link)
616        } else if metadata.is_file() {
617            Some(PayloadDigestKind::File)
618        } else {
619            None
620        };
621        if kind != Some(entry.kind) {
622            let expected = match entry.kind {
623                PayloadDigestKind::File => "file",
624                PayloadDigestKind::Link => "link",
625            };
626            fail!(
627                "Payload does not match the signed release: {} is not a {expected}.",
628                entry.path
629            );
630        }
631        // A link is compared by its target string, never opened: following it would compare the
632        // target's bytes under two names and make a link indistinguishable from a copy.
633        let actual = if entry.kind == PayloadDigestKind::Link {
634            let target = std::fs::read_link(&path)?;
635            crate::contract::documents::sha256_hex(
636                target.to_string_lossy().replace('\\', "/").as_bytes(),
637            )
638        } else {
639            sha256_file(&path)?
640        };
641        if actual != entry.content_sha256 {
642            fail!(
643                "Payload does not match the signed release: {}.",
644                entry.path
645            );
646        }
647    }
648
649    Ok(PayloadVerification {
650        root,
651        box_id: release.box_id.clone(),
652        version: release.version.clone(),
653        target_id: box_target_id(&release.target)?,
654        entry_count: entries.len(),
655        environment_report: release_environment_report(
656            release,
657            inspected.adapter,
658            &options.environment,
659        )?,
660    })
661}
662
663fn absolute(path: &Path) -> PathBuf {
664    if path.is_absolute() {
665        path.to_path_buf()
666    } else {
667        std::env::current_dir().map_or_else(|_| path.to_path_buf(), |current| current.join(path))
668    }
669}
670
671fn unique_suffix() -> String {
672    format!(
673        "{}-{}",
674        std::process::id(),
675        std::time::SystemTime::now()
676            .duration_since(std::time::UNIX_EPOCH)
677            .map(|elapsed| elapsed.as_nanos())
678            .unwrap_or_default()
679    )
680}