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