use std::collections::BTreeMap;
use std::fs::Metadata;
use std::path::{Path, PathBuf};
use crate::archive::extract_zip_archive;
use crate::contract::payload_digest::{
parse_payload_digest_stream, PayloadDigestKind, MAX_PAYLOAD_DIGEST_BYTES, PAYLOAD_DIGEST_FILE,
};
use crate::contract::targets::{assert_native_host, box_target_id, BoxTargetAdapter};
use crate::environment::{
resolve_environment, EnvironmentLayer, EnvironmentReport, EnvironmentSource, ResolveOptions,
};
use crate::error::{fail, Error, Result};
use crate::execution::assert_execution_files;
use crate::filesystem::{collect_files, payload_size, sha256_file};
use crate::path::{join_relative, safe_relative_path};
use crate::release::{AssetDescriptor, Execution, ReleaseManifest};
use crate::trust::TrustAnchors;
use crate::verify::{inspect_archive_for, inspect_release_document, InspectedRelease};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreparedStatus {
Prepared,
Attached,
}
#[derive(Debug, Clone, Default)]
pub struct EnvironmentReportOptions {
pub env_report: bool,
pub env_report_values: bool,
pub host_environment: Option<Vec<(String, String)>>,
}
#[cfg(unix)]
type RootIdentity = (u64, u64);
#[cfg(not(unix))]
type RootIdentity = PathBuf;
#[cfg_attr(unix, allow(clippy::unnecessary_wraps))]
#[cfg(unix)]
fn root_identity(_path: &Path, metadata: &Metadata) -> Result<RootIdentity> {
use std::os::unix::fs::MetadataExt as _;
Ok((metadata.dev(), metadata.ino()))
}
#[cfg(not(unix))]
fn root_identity(path: &Path, _metadata: &Metadata) -> Result<RootIdentity> {
std::fs::canonicalize(path)
.map_err(|error| Error::new(format!("cannot identify {}: {error}", path.display())))
}
#[cfg(unix)]
fn survived_the_rename(staged: &Metadata, installed: &Metadata) -> bool {
use std::os::unix::fs::MetadataExt as _;
(staged.dev(), staged.ino()) == (installed.dev(), installed.ino())
}
#[cfg(not(unix))]
fn survived_the_rename(_staged: &Metadata, installed: &Metadata) -> bool {
installed.is_dir()
}
#[derive(Debug, Clone)]
pub struct PreparedBox {
status: PreparedStatus,
root: PathBuf,
target_id: String,
signing_key_ids: Vec<String>,
release_payload_sha256: String,
installed_size_bytes: u64,
environment_report: EnvironmentReport,
release: ReleaseManifest,
adapter: &'static BoxTargetAdapter,
root_identity: RootIdentity,
}
impl PreparedBox {
#[must_use]
pub fn status(&self) -> PreparedStatus {
self.status
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn box_id(&self) -> &str {
&self.release.box_id
}
#[must_use]
pub fn model_id(&self) -> &str {
&self.release.model_id
}
#[must_use]
pub fn runtime_id(&self) -> &str {
&self.release.runtime_id
}
#[must_use]
pub fn version(&self) -> &str {
&self.release.version
}
#[must_use]
pub fn target_id(&self) -> &str {
&self.target_id
}
#[must_use]
pub fn python_entry_point(&self) -> &str {
&self.release.python_entry_point
}
#[must_use]
pub fn execution(&self) -> Option<&Execution> {
self.release.execution.as_ref()
}
#[must_use]
pub fn required_assets(&self) -> &[AssetDescriptor] {
required_assets_of(&self.release)
}
#[must_use]
pub fn signing_key_ids(&self) -> &[String] {
&self.signing_key_ids
}
#[must_use]
pub fn release_payload_sha256(&self) -> &str {
&self.release_payload_sha256
}
#[must_use]
pub fn archive_sha256(&self) -> &str {
&self.release.archive.sha256
}
#[must_use]
pub fn archive_size_bytes(&self) -> u64 {
self.release.archive.size_bytes
}
#[must_use]
pub fn installed_size_bytes(&self) -> u64 {
self.installed_size_bytes
}
#[must_use]
pub fn environment_report(&self) -> &EnvironmentReport {
&self.environment_report
}
pub(crate) fn release(&self) -> &ReleaseManifest {
&self.release
}
pub(crate) fn adapter(&self) -> &'static BoxTargetAdapter {
self.adapter
}
pub(crate) fn assert_root_unchanged(&self) -> Result<()> {
let Ok(metadata) = std::fs::symlink_metadata(&self.root) else {
fail!("Prepared box root no longer matches the prepared box.");
};
if !metadata.is_dir() || root_identity(&self.root, &metadata)? != self.root_identity {
fail!("Prepared box root no longer matches the prepared box.");
}
Ok(())
}
}
fn required_assets_of(release: &ReleaseManifest) -> &[AssetDescriptor] {
if release.weights.as_deref() == Some("on-demand") {
release.assets.as_deref().unwrap_or(&[])
} else {
&[]
}
}
pub fn verify_required_assets(root: &Path, assets: &[AssetDescriptor]) -> Result<()> {
for asset in assets {
let relative = safe_relative_path(&asset.relative_path)?;
let path = join_relative(root, &relative);
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
fail!(
"Required on-demand asset is missing: {}.",
asset.relative_path
);
};
if !metadata.is_file() {
fail!(
"Required on-demand asset is not a regular file: {}.",
asset.relative_path
);
}
if metadata.len() != asset.size_bytes {
fail!(
"Required on-demand asset size mismatch: {}.",
asset.relative_path
);
}
if sha256_file(&path)? != asset.sha256 {
fail!(
"Required on-demand asset SHA-256 mismatch: {}.",
asset.relative_path
);
}
}
Ok(())
}
fn release_environment_report(
release: &ReleaseManifest,
adapter: &BoxTargetAdapter,
options: &EnvironmentReportOptions,
) -> Result<EnvironmentReport> {
let host: Vec<(String, String)> = options
.host_environment
.clone()
.unwrap_or_else(|| std::env::vars().collect());
let host_pairs: Vec<(&str, &str)> = host
.iter()
.map(|(name, value)| (name.as_str(), value.as_str()))
.collect();
let declared: BTreeMap<String, String> = release.environment.clone().unwrap_or_default();
let release_pairs: Vec<(&str, &str)> = declared
.iter()
.map(|(name, value)| (name.as_str(), value.as_str()))
.collect();
Ok(resolve_environment(&ResolveOptions {
platform: adapter.platform,
layers: vec![
EnvironmentLayer {
source: EnvironmentSource::Host,
values: host_pairs,
},
EnvironmentLayer {
source: EnvironmentSource::Release,
values: release_pairs,
},
],
execution_affecting_variables: adapter.execution_affecting_environment_variables,
expanded: options.env_report || options.env_report_values,
reveal_host_values: options.env_report_values,
})?
.report)
}
fn mint(
status: PreparedStatus,
root: PathBuf,
inspected: &InspectedRelease,
installed_size_bytes: u64,
identity: RootIdentity,
options: &EnvironmentReportOptions,
) -> Result<PreparedBox> {
let release = inspected.release.clone();
Ok(PreparedBox {
status,
root,
target_id: box_target_id(&release.target)?,
signing_key_ids: inspected
.signed
.signatures
.iter()
.map(|signature| signature.key_id.clone())
.collect(),
release_payload_sha256: inspected.signed.payload_sha256.clone(),
installed_size_bytes,
environment_report: release_environment_report(&release, inspected.adapter, options)?,
release,
adapter: inspected.adapter,
root_identity: identity,
})
}
pub struct PrepareOptions<'a> {
pub trust: TrustAnchors<'a>,
pub archive: Option<&'a Path>,
pub destination: &'a Path,
pub environment: EnvironmentReportOptions,
}
pub fn verify_and_extract_box(
release_document_path: &Path,
options: &PrepareOptions<'_>,
) -> Result<PreparedBox> {
let final_root = absolute(options.destination);
if std::fs::symlink_metadata(&final_root).is_ok() {
fail!("Destination already exists: {}", final_root.display());
}
let inspected = inspect_release_document(release_document_path, options.trust)?;
let archive = inspect_archive_for(inspected, options.archive)?;
let release = &archive.release.release;
let parent = final_root
.parent()
.ok_or_else(|| Error::new("A destination must have a parent directory."))?
.to_path_buf();
std::fs::create_dir_all(&parent)?;
if std::fs::symlink_metadata(&final_root).is_ok() {
fail!("Destination already exists: {}", final_root.display());
}
let stage_root = parent.join(format!(
".scrollcase-prepare-{}-{}",
final_root
.file_name()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or("box"),
unique_suffix()
));
std::fs::create_dir_all(&stage_root)?;
let result = prepare_into(&stage_root, &final_root, &archive.archive_path, &archive.release, release, options);
let _ = std::fs::remove_dir_all(&stage_root);
result
}
fn prepare_into(
stage_root: &Path,
final_root: &Path,
archive_path: &Path,
inspected: &InspectedRelease,
release: &ReleaseManifest,
options: &PrepareOptions<'_>,
) -> Result<PreparedBox> {
let extracted_root = stage_root.join("payload");
extract_zip_archive(archive_path, &extracted_root)?;
let extracted_size = payload_size(&extracted_root)?;
if release
.installed_size_bytes
.is_some_and(|declared| declared != extracted_size)
{
fail!("Extracted payload size does not match the signed release.");
}
if sha256_file(archive_path)? != release.archive.sha256 {
fail!("Archive SHA-256 changed during extraction.");
}
let staged = std::fs::symlink_metadata(&extracted_root)?;
if std::fs::symlink_metadata(final_root).is_ok() {
fail!("Destination already exists: {}", final_root.display());
}
std::fs::rename(&extracted_root, final_root).map_err(|error| {
Error::new(format!(
"cannot install into {}: {error}",
final_root.display()
))
})?;
let installed = std::fs::symlink_metadata(final_root)?;
if !survived_the_rename(&staged, &installed) {
fail!("Prepared destination identity changed during installation.");
}
mint(
PreparedStatus::Prepared,
final_root.to_path_buf(),
inspected,
extracted_size,
root_identity(final_root, &installed)?,
&options.environment,
)
}
pub struct AttachOptions<'a> {
pub trust: TrustAnchors<'a>,
pub root: &'a Path,
pub environment: EnvironmentReportOptions,
}
fn resolve_extracted_root(root: &Path) -> Result<(PathBuf, Metadata)> {
let resolved = absolute(root);
let Ok(metadata) = std::fs::symlink_metadata(&resolved) else {
fail!("{} is not an extracted box directory.", resolved.display());
};
if !metadata.is_dir() {
fail!("{} is not an extracted box directory.", resolved.display());
}
Ok((resolved, metadata))
}
pub fn attach_extracted_box(
release_document_path: &Path,
options: &AttachOptions<'_>,
) -> Result<PreparedBox> {
let (root, metadata) = resolve_extracted_root(options.root)?;
let inspected = inspect_release_document(release_document_path, options.trust)?;
let release = &inspected.release;
if assert_native_host(inspected.adapter).is_err() {
fail!(
"Box target {} cannot run on {}/{}; it requires {}/{}.",
box_target_id(&release.target)?,
std::env::consts::OS,
std::env::consts::ARCH,
inspected.adapter.host_os,
inspected.adapter.host_arch
);
}
let files = collect_files(&root)?;
if !files.contains(&release.python_entry_point) {
fail!("Attached box is missing {}.", release.python_entry_point);
}
assert_execution_files(
release.execution.as_ref(),
inspected.adapter,
&release.provenance.python_version,
&files,
)?;
verify_required_assets(&root, required_assets_of(release))?;
let installed_size_bytes = payload_size(&root)?;
let settled = std::fs::symlink_metadata(&root)?;
if root_identity(&root, &settled)? != root_identity(&root, &metadata)? {
fail!("Attached box root changed while it was being checked.");
}
mint(
PreparedStatus::Attached,
root.clone(),
&inspected,
installed_size_bytes,
root_identity(&root, &settled)?,
&options.environment,
)
}
#[derive(Debug, Clone)]
pub struct PayloadVerification {
pub root: PathBuf,
pub box_id: String,
pub version: String,
pub target_id: String,
pub entry_count: usize,
pub environment_report: EnvironmentReport,
}
pub fn verify_extracted_payload(
release_document_path: &Path,
options: &AttachOptions<'_>,
) -> Result<PayloadVerification> {
let (root, _) = resolve_extracted_root(options.root)?;
let inspected = inspect_release_document(release_document_path, options.trust)?;
let release = &inspected.release;
let Some(commitment) = release.payload_digest.as_ref() else {
fail!("This release does not commit to a payload digest; it was built before payload verification existed.");
};
let list_path = root.join(PAYLOAD_DIGEST_FILE);
let Ok(list_metadata) = std::fs::symlink_metadata(&list_path) else {
fail!("Attached box is missing its payload digest list: {PAYLOAD_DIGEST_FILE}.");
};
if list_metadata.len() > MAX_PAYLOAD_DIGEST_BYTES {
fail!("Payload digest list is larger than this consumer will read.");
}
if sha256_file(&list_path)? != commitment.sha256 {
fail!("Payload digest list does not match the signed release.");
}
let bytes = std::fs::read(&list_path)?;
let entries = parse_payload_digest_stream(&bytes)
.map_err(|error| Error::new(format!("Invalid payload digest list: {error}")))?;
for entry in &entries {
let relative = safe_relative_path(&entry.path)?;
let path = join_relative(&root, &relative);
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
fail!(
"Payload does not match the signed release: {} is missing.",
entry.path
);
};
let kind = if metadata.is_symlink() {
Some(PayloadDigestKind::Link)
} else if metadata.is_file() {
Some(PayloadDigestKind::File)
} else {
None
};
if kind != Some(entry.kind) {
let expected = match entry.kind {
PayloadDigestKind::File => "file",
PayloadDigestKind::Link => "link",
};
fail!(
"Payload does not match the signed release: {} is not a {expected}.",
entry.path
);
}
let actual = if entry.kind == PayloadDigestKind::Link {
let target = std::fs::read_link(&path)?;
crate::contract::documents::sha256_hex(
target.to_string_lossy().replace('\\', "/").as_bytes(),
)
} else {
sha256_file(&path)?
};
if actual != entry.content_sha256 {
fail!(
"Payload does not match the signed release: {}.",
entry.path
);
}
}
Ok(PayloadVerification {
root,
box_id: release.box_id.clone(),
version: release.version.clone(),
target_id: box_target_id(&release.target)?,
entry_count: entries.len(),
environment_report: release_environment_report(
release,
inspected.adapter,
&options.environment,
)?,
})
}
fn absolute(path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().map_or_else(|_| path.to_path_buf(), |current| current.join(path))
}
}
fn unique_suffix() -> String {
format!(
"{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or_default()
)
}