1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum PreparedStatus {
38 Prepared,
40 Attached,
42}
43
44#[derive(Debug, Clone, Default)]
46pub struct EnvironmentReportOptions {
47 pub env_report: bool,
49 pub env_report_values: bool,
51 pub host_environment: Option<Vec<(String, String)>>,
54}
55
56#[cfg(unix)]
62type RootIdentity = (u64, u64);
63#[cfg(not(unix))]
64type RootIdentity = PathBuf;
65
66#[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#[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#[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 adapter: &'static BoxTargetAdapter,
111 root_identity: RootIdentity,
112}
113
114impl PreparedBox {
115 #[must_use]
117 pub fn status(&self) -> PreparedStatus {
118 self.status
119 }
120
121 #[must_use]
123 pub fn root(&self) -> &Path {
124 &self.root
125 }
126
127 #[must_use]
129 pub fn box_id(&self) -> &str {
130 &self.release.box_id
131 }
132
133 #[must_use]
135 pub fn model_id(&self) -> &str {
136 &self.release.model_id
137 }
138
139 #[must_use]
141 pub fn runtime_id(&self) -> &str {
142 &self.release.runtime_id
143 }
144
145 #[must_use]
147 pub fn version(&self) -> &str {
148 &self.release.version
149 }
150
151 #[must_use]
153 pub fn target_id(&self) -> &str {
154 &self.target_id
155 }
156
157 #[must_use]
159 pub fn python_entry_point(&self) -> &str {
160 &self.release.python_entry_point
161 }
162
163 #[must_use]
165 pub fn execution(&self) -> Option<&Execution> {
166 self.release.execution.as_ref()
167 }
168
169 #[must_use]
171 pub fn required_assets(&self) -> &[AssetDescriptor] {
172 required_assets_of(&self.release)
173 }
174
175 #[must_use]
177 pub fn signing_key_ids(&self) -> &[String] {
178 &self.signing_key_ids
179 }
180
181 #[must_use]
183 pub fn release_payload_sha256(&self) -> &str {
184 &self.release_payload_sha256
185 }
186
187 #[must_use]
189 pub fn archive_sha256(&self) -> &str {
190 &self.release.archive.sha256
191 }
192
193 #[must_use]
195 pub fn archive_size_bytes(&self) -> u64 {
196 self.release.archive.size_bytes
197 }
198
199 #[must_use]
204 pub fn installed_size_bytes(&self) -> u64 {
205 self.installed_size_bytes
206 }
207
208 #[must_use]
210 pub fn environment_report(&self) -> &EnvironmentReport {
211 &self.environment_report
212 }
213
214 pub(crate) fn release(&self) -> &ReleaseManifest {
216 &self.release
217 }
218
219 pub(crate) fn adapter(&self) -> &'static BoxTargetAdapter {
221 self.adapter
222 }
223
224 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
236fn 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
245pub 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
282fn 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
349pub struct PrepareOptions<'a> {
351 pub trust: TrustAnchors<'a>,
353 pub archive: Option<&'a Path>,
355 pub destination: &'a Path,
357 pub environment: EnvironmentReportOptions,
359}
360
361pub 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 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
458pub struct AttachOptions<'a> {
460 pub trust: TrustAnchors<'a>,
462 pub root: &'a Path,
464 pub environment: EnvironmentReportOptions,
466}
467
468fn 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 if !metadata.is_dir() {
477 fail!("{} is not an extracted box directory.", resolved.display());
478 }
479 Ok((resolved, metadata))
480}
481
482pub 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 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#[derive(Debug, Clone)]
549pub struct PayloadVerification {
550 pub root: PathBuf,
552 pub box_id: String,
554 pub version: String,
556 pub target_id: String,
558 pub entry_count: usize,
560 pub environment_report: EnvironmentReport,
562}
563
564pub 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 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 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}