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::verify::{inspect_archive_for, inspect_release_document, InspectedRelease};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum PreparedStatus {
37 Prepared,
39 Attached,
41}
42
43#[derive(Debug, Clone, Default)]
45pub struct EnvironmentReportOptions {
46 pub env_report: bool,
48 pub env_report_values: bool,
50 pub host_environment: Option<Vec<(String, String)>>,
53}
54
55#[cfg(unix)]
61type RootIdentity = (u64, u64);
62#[cfg(not(unix))]
63type RootIdentity = PathBuf;
64
65#[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#[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#[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 adapter: &'static BoxTargetAdapter,
110 root_identity: RootIdentity,
111}
112
113impl PreparedBox {
114 #[must_use]
116 pub fn status(&self) -> PreparedStatus {
117 self.status
118 }
119
120 #[must_use]
122 pub fn root(&self) -> &Path {
123 &self.root
124 }
125
126 #[must_use]
128 pub fn box_id(&self) -> &str {
129 &self.release.box_id
130 }
131
132 #[must_use]
134 pub fn model_id(&self) -> &str {
135 &self.release.model_id
136 }
137
138 #[must_use]
140 pub fn runtime_id(&self) -> &str {
141 &self.release.runtime_id
142 }
143
144 #[must_use]
146 pub fn version(&self) -> &str {
147 &self.release.version
148 }
149
150 #[must_use]
152 pub fn target_id(&self) -> &str {
153 &self.target_id
154 }
155
156 #[must_use]
158 pub fn python_entry_point(&self) -> &str {
159 &self.release.python_entry_point
160 }
161
162 #[must_use]
164 pub fn execution(&self) -> Option<&Execution> {
165 self.release.execution.as_ref()
166 }
167
168 #[must_use]
170 pub fn required_assets(&self) -> &[AssetDescriptor] {
171 required_assets_of(&self.release)
172 }
173
174 #[must_use]
176 pub fn signing_key_ids(&self) -> &[String] {
177 &self.signing_key_ids
178 }
179
180 #[must_use]
182 pub fn release_payload_sha256(&self) -> &str {
183 &self.release_payload_sha256
184 }
185
186 #[must_use]
188 pub fn archive_sha256(&self) -> &str {
189 &self.release.archive.sha256
190 }
191
192 #[must_use]
194 pub fn archive_size_bytes(&self) -> u64 {
195 self.release.archive.size_bytes
196 }
197
198 #[must_use]
203 pub fn installed_size_bytes(&self) -> u64 {
204 self.installed_size_bytes
205 }
206
207 #[must_use]
209 pub fn environment_report(&self) -> &EnvironmentReport {
210 &self.environment_report
211 }
212
213 pub(crate) fn release(&self) -> &ReleaseManifest {
215 &self.release
216 }
217
218 pub(crate) fn adapter(&self) -> &'static BoxTargetAdapter {
220 self.adapter
221 }
222
223 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
235fn 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
244pub 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
281fn 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
348pub struct PrepareOptions<'a> {
350 pub public_key_path: &'a Path,
352 pub archive: Option<&'a Path>,
354 pub destination: &'a Path,
356 pub environment: EnvironmentReportOptions,
358}
359
360pub 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 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
457pub struct AttachOptions<'a> {
459 pub public_key_path: &'a Path,
461 pub root: &'a Path,
463 pub environment: EnvironmentReportOptions,
465}
466
467fn 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 if !metadata.is_dir() {
476 fail!("{} is not an extracted box directory.", resolved.display());
477 }
478 Ok((resolved, metadata))
479}
480
481pub 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 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#[derive(Debug, Clone)]
548pub struct PayloadVerification {
549 pub root: PathBuf,
551 pub box_id: String,
553 pub version: String,
555 pub target_id: String,
557 pub entry_count: usize,
559 pub environment_report: EnvironmentReport,
561}
562
563pub 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 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 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}