Skip to main content

hyperlight_host/sandbox/snapshot/file/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2025 The Hyperlight Authors.
3
4//! OCI Image Layout serde for [`Snapshot`]. See
5//! `docs/snapshot-oci-format.md` for the on-disk format.
6
7mod config;
8mod digest;
9mod fsutil;
10mod media_types;
11pub(crate) mod reference;
12
13use std::path::{Path, PathBuf};
14
15use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails;
16use hyperlight_common::vmem::PAGE_SIZE;
17use oci_spec::image::{
18    Descriptor, DescriptorBuilder, ImageIndex, ImageIndexBuilder, ImageManifest,
19    ImageManifestBuilder, MediaType, SCHEMA_VERSION,
20};
21
22use self::config::{Arch, CpuVendor, HostFunction, Hypervisor, MemoryLayout, OciSnapshotConfig};
23use self::digest::{Digest256, oci_digest, parse_oci_digest, verify_blob_bytes, verify_blob_file};
24use self::fsutil::{put_blob, put_blob_if_absent, read_bounded, replace_file_atomic};
25use self::media_types::{
26    ANNOTATION_ARCH, ANNOTATION_CPU, ANNOTATION_HYPERVISOR, ANNOTATION_REF_NAME,
27};
28pub(super) use self::media_types::{
29    MT_CONFIG_CURRENT, MT_CONFIG_V1, MT_SNAPSHOT_CURRENT, MT_SNAPSHOT_V1, SNAPSHOT_ABI_VERSION,
30};
31use self::reference::{OciDigest, OciReference, OciTag};
32use super::{NextAction, Snapshot};
33use crate::mem::layout::SandboxMemoryLayout;
34use crate::mem::memory_region::MemoryRegionFlags;
35use crate::mem::shared_mem::{ReadonlySharedMemory, SharedMemory};
36
37pub(super) const OCI_LAYOUT_VERSION: &str = "1.0.0";
38
39/// Short golden-tag token for the host CPU vendor, or `None` if the
40/// goldens do not cover it. Exposed for the snapshot golden tests so
41/// they share the host crate's vendor detection.
42#[doc(hidden)]
43pub fn host_cpu_vendor_golden_tag() -> Option<&'static str> {
44    CpuVendor::current().golden_tag()
45}
46
47/// Maximum size of any JSON blob read from disk during load:
48/// `oci-layout`, `index.json`, the OCI image manifest, and the
49/// Hyperlight config blob. Bounds the allocation done before parsing.
50const MAX_JSON_BLOB_SIZE: u64 = 1024 * 1024;
51
52/// Reject a JSON artifact larger than the cap the loader reads with
53/// [`read_bounded`]. The writer holds to the same cap so every layout
54/// it writes can be read back. `what` names the artifact in the error.
55fn check_json_blob_size(what: &str, len: usize) -> crate::Result<()> {
56    if len as u64 > MAX_JSON_BLOB_SIZE {
57        return Err(crate::new_error!(
58            "{} of {} bytes exceeds the {} byte maximum for a snapshot artifact",
59            what,
60            len,
61            MAX_JSON_BLOB_SIZE
62        ));
63    }
64    Ok(())
65}
66
67/// Select one manifest descriptor from `index` by `reference`.
68///
69/// A tag matches the `org.opencontainers.image.ref.name` annotation
70/// and must be unique. A digest matches the manifest content digest.
71/// Identical manifests shared across tags select the first, since
72/// they are byte-for-byte equal.
73fn select_manifest<'a>(
74    index: &'a ImageIndex,
75    reference: &OciReference,
76    path: &Path,
77) -> crate::Result<&'a Descriptor> {
78    match reference {
79        OciReference::Tag(tag) => {
80            let mut matching = index.manifests().iter().filter(|d| {
81                d.annotations()
82                    .as_ref()
83                    .and_then(|a| a.get(ANNOTATION_REF_NAME))
84                    .map(|s| s.as_str() == tag.as_str())
85                    .unwrap_or(false)
86            });
87            match (matching.next(), matching.next()) {
88                (None, _) => {
89                    let known: Vec<&str> = index
90                        .manifests()
91                        .iter()
92                        .filter_map(|d| {
93                            d.annotations()
94                                .as_ref()
95                                .and_then(|a| a.get(ANNOTATION_REF_NAME))
96                                .map(|s| s.as_str())
97                        })
98                        .collect();
99                    Err(crate::new_error!(
100                        "no manifest tagged {:?} in OCI layout {:?}. Available tags: {:?}",
101                        tag.as_str(),
102                        path,
103                        known
104                    ))
105                }
106                (Some(_), Some(_)) => Err(crate::new_error!(
107                    "OCI layout {:?} has multiple manifests tagged {:?}; tags must be unique",
108                    path,
109                    tag.as_str()
110                )),
111                (Some(d), None) => Ok(d),
112            }
113        }
114        OciReference::Digest(digest) => index
115            .manifests()
116            .iter()
117            .find(|d| d.digest().to_string() == digest.as_str())
118            .ok_or_else(|| {
119                crate::new_error!(
120                    "no manifest with digest {} in OCI layout {:?}",
121                    digest.as_str(),
122                    path
123                )
124            }),
125    }
126}
127
128fn read_layout_marker(path: &Path) -> crate::Result<()> {
129    let layout_bytes = read_bounded(&path.join("oci-layout"), MAX_JSON_BLOB_SIZE)
130        .map_err(|e| crate::new_error!("failed to read oci-layout: {}", e))?;
131    let layout_json: serde_json::Value = serde_json::from_slice(&layout_bytes)
132        .map_err(|e| crate::new_error!("oci-layout is not valid JSON: {}", e))?;
133    let v = layout_json
134        .get("imageLayoutVersion")
135        .and_then(|v| v.as_str())
136        .ok_or_else(|| crate::new_error!("oci-layout missing imageLayoutVersion field"))?;
137    if v != OCI_LAYOUT_VERSION {
138        return Err(crate::new_error!(
139            "unsupported OCI image layout version {:?} (expected {:?})",
140            v,
141            OCI_LAYOUT_VERSION
142        ));
143    }
144    Ok(())
145}
146
147fn load_manifest(
148    path: &Path,
149    blobs_dir: &Path,
150    reference: &OciReference,
151    verify_blobs: bool,
152) -> crate::Result<ImageManifest> {
153    let index_bytes = read_bounded(&path.join("index.json"), MAX_JSON_BLOB_SIZE)
154        .map_err(|e| crate::new_error!("failed to read index.json: {}", e))?;
155    let index: ImageIndex = serde_json::from_slice(&index_bytes)
156        .map_err(|e| crate::new_error!("failed to parse index.json: {}", e))?;
157    if index.schema_version() != SCHEMA_VERSION {
158        return Err(crate::new_error!(
159            "unsupported OCI index schemaVersion {} (expected {})",
160            index.schema_version(),
161            SCHEMA_VERSION
162        ));
163    }
164    if let Some(media_type) = index.media_type()
165        && !matches!(media_type, MediaType::ImageIndex)
166    {
167        return Err(crate::new_error!(
168            "OCI index has unexpected media type {} (expected {})",
169            media_type.to_string(),
170            MediaType::ImageIndex.to_string()
171        ));
172    }
173    let manifest_desc = select_manifest(&index, reference, path)?;
174    if !matches!(manifest_desc.media_type(), MediaType::ImageManifest) {
175        return Err(crate::new_error!(
176            "manifest descriptor for {} has unexpected media type {} (expected {})",
177            reference,
178            manifest_desc.media_type().to_string(),
179            MediaType::ImageManifest.to_string()
180        ));
181    }
182    let manifest_hex = parse_oci_digest(manifest_desc.digest())?;
183    let manifest_path = blobs_dir.join(&manifest_hex);
184    let manifest_bytes = read_bounded(&manifest_path, MAX_JSON_BLOB_SIZE)?;
185    if manifest_bytes.len() as u64 != manifest_desc.size() {
186        return Err(crate::new_error!(
187            "OCI manifest size mismatch: descriptor says {}, file is {}",
188            manifest_desc.size(),
189            manifest_bytes.len()
190        ));
191    }
192    if verify_blobs {
193        verify_blob_bytes("manifest", &manifest_bytes, &manifest_hex)?;
194    }
195    let manifest: ImageManifest = serde_json::from_slice(&manifest_bytes)
196        .map_err(|e| crate::new_error!("failed to parse OCI manifest JSON: {}", e))?;
197    if manifest.schema_version() != SCHEMA_VERSION {
198        return Err(crate::new_error!(
199            "unsupported OCI manifest schemaVersion {} (expected {})",
200            manifest.schema_version(),
201            SCHEMA_VERSION
202        ));
203    }
204    if let Some(media_type) = manifest.media_type()
205        && !matches!(media_type, MediaType::ImageManifest)
206    {
207        return Err(crate::new_error!(
208            "OCI manifest has unexpected media type {} (expected {})",
209            media_type.to_string(),
210            MediaType::ImageManifest.to_string()
211        ));
212    }
213    Ok(manifest)
214}
215
216fn load_config(
217    blobs_dir: &Path,
218    cfg_desc: &Descriptor,
219    verify_blobs: bool,
220) -> crate::Result<OciSnapshotConfig> {
221    let cfg_hex = parse_oci_digest(cfg_desc.digest())?;
222    let cfg_path = blobs_dir.join(&cfg_hex);
223    let cfg_bytes = read_bounded(&cfg_path, MAX_JSON_BLOB_SIZE)?;
224    if cfg_bytes.len() as u64 != cfg_desc.size() {
225        return Err(crate::new_error!(
226            "config blob size mismatch: descriptor says {}, file is {}",
227            cfg_desc.size(),
228            cfg_bytes.len()
229        ));
230    }
231    if verify_blobs {
232        verify_blob_bytes("config", &cfg_bytes, &cfg_hex)?;
233    }
234    let cfg: OciSnapshotConfig = serde_json::from_slice(&cfg_bytes)
235        .map_err(|e| crate::new_error!("failed to parse Hyperlight config JSON: {}", e))?;
236    cfg.validate_for_load()?;
237    Ok(cfg)
238}
239
240fn open_snapshot_blob(
241    blobs_dir: &Path,
242    snap_desc: &Descriptor,
243    expected_blob_len: u64,
244    verify_blobs: bool,
245) -> crate::Result<std::fs::File> {
246    let snap_hex = parse_oci_digest(snap_desc.digest())?;
247    let snap_path = blobs_dir.join(&snap_hex);
248
249    let mut snap_file = self::fsutil::open_no_follow(&snap_path)?;
250
251    let snap_file_len = snap_file
252        .metadata()
253        .map_err(|e| crate::new_error!("failed to stat snapshot blob: {}", e))?
254        .len();
255    if snap_file_len != expected_blob_len {
256        return Err(crate::new_error!(
257            "snapshot blob size mismatch: file is {} bytes, expected {} (memory_size)",
258            snap_file_len,
259            expected_blob_len,
260        ));
261    }
262    if snap_file_len != snap_desc.size() {
263        return Err(crate::new_error!(
264            "snapshot blob size {} disagrees with OCI descriptor size {}",
265            snap_file_len,
266            snap_desc.size()
267        ));
268    }
269    if verify_blobs {
270        verify_blob_file("snapshot", &mut snap_file, &snap_hex)?;
271    }
272    Ok(snap_file)
273}
274
275impl Snapshot {
276    /// Save this snapshot into an OCI Image Layout directory on disk.
277    /// The saved snapshot can be loaded later with
278    /// [`Snapshot::load`].
279    ///
280    /// Returns the [`OciDigest`] of the manifest that was written,
281    /// which [`Snapshot::load`] accepts as a stable handle to
282    /// this exact snapshot.
283    ///
284    /// # `path`
285    ///
286    /// The OCI Image Layout directory to write to. The directory at
287    /// `path` is created if absent. Its parent directory must exist.
288    ///
289    /// If `path` holds no OCI layout, a new one is created. If it
290    /// holds one, this snapshot is added alongside the others. If
291    /// `path` holds something that is not a readable OCI layout, the
292    /// call fails and the directory is left unchanged.
293    ///
294    /// # `tag`
295    ///
296    /// A standard OCI tag that names this snapshot within the layout.
297    /// [`Snapshot::load`] can load the snapshot back by this tag.
298    ///
299    /// A tag points to one snapshot at a time. If the layout has a
300    /// snapshot under this tag, the tag is moved to the new snapshot.
301    /// The old snapshot's data stays on disk, reachable by its
302    /// digest but not by this tag. Snapshots under other tags are
303    /// untouched.
304    ///
305    /// # Portability
306    ///
307    /// Snapshot images are bound to the specific CPU architecture,
308    /// hypervisor, and CPU vendor that the snapshot was created on.
309    /// For example, a snapshot taken on an Intel x86_64 host with KVM
310    /// can only be loaded on an Intel x86_64 host running KVM. Loading
311    /// on any other host is rejected. A future version may relax this
312    /// binding once a wider compatibility set is proven safe.
313    ///
314    /// # Compatibility
315    ///
316    /// While Hyperlight is at version 0.x.y the on-disk format is not
317    /// stable. A snapshot written by one Hyperlight version is not
318    /// guaranteed to load on a different Hyperlight version. An
319    /// incompatible snapshot is always rejected at load time with a
320    /// clear error. It can never load and then misbehave once the
321    /// guest is running. Any release that breaks the format is called
322    /// out in the Hyperlight changelog.
323    ///
324    /// # Examples
325    ///
326    /// ```no_run
327    /// # use hyperlight_host::SandboxBuilder;
328    /// # use hyperlight_host::sandbox::snapshot::OciTag;
329    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
330    /// let mut sandbox = SandboxBuilder::from_file("guest.bin").build()?;
331    ///
332    /// // Capture the initialized state and write it to an OCI layout on disk.
333    /// let snapshot = sandbox.snapshot()?;
334    /// let tag = OciTag::new("latest")?;
335    /// let digest = snapshot.save("./guest_snapshot", &tag)?;
336    /// # Ok(())
337    /// # }
338    /// ```
339    pub fn save(&self, path: impl AsRef<Path>, tag: &OciTag) -> crate::Result<OciDigest> {
340        let path = path.as_ref();
341
342        // Building the config can reject the snapshot. Do it before
343        // writing any file.
344        let cfg = self.build_config()?;
345        let cfg_bytes = serde_json::to_vec_pretty(&cfg)
346            .map_err(|e| crate::new_error!("failed to serialise config JSON: {}", e))?;
347        check_json_blob_size("config blob", cfg_bytes.len())?;
348
349        // The parent directory must already exist. `path` itself is
350        // created if absent. An existing regular file at `path` is
351        // rejected by the underlying `create_dir`.
352        match path.parent() {
353            Some(p) if !p.as_os_str().is_empty() => {
354                let parent_meta = std::fs::metadata(p).map_err(|e| {
355                    crate::new_error!("save: parent directory {:?} not accessible: {}", p, e)
356                })?;
357                if !parent_meta.is_dir() {
358                    return Err(crate::new_error!(
359                        "save: parent of {:?} is not a directory",
360                        path
361                    ));
362                }
363            }
364            _ => {}
365        }
366        match std::fs::create_dir(path) {
367            Ok(()) => {}
368            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
369                let meta = std::fs::metadata(path)
370                    .map_err(|e| crate::new_error!("save: failed to stat {:?}: {}", path, e))?;
371                if !meta.is_dir() {
372                    return Err(crate::new_error!(
373                        "save: {:?} exists and is not a directory",
374                        path
375                    ));
376                }
377            }
378            Err(e) => {
379                return Err(crate::new_error!(
380                    "save: failed to create layout dir {:?}: {}",
381                    path,
382                    e
383                ));
384            }
385        }
386
387        // Validate any pre-existing `oci-layout` marker before
388        // touching anything else, so a foreign layout (future
389        // version, hand-edited file) is reported without altering
390        // the directory.
391        let layout_marker = path.join("oci-layout");
392        let marker_existed = layout_marker
393            .try_exists()
394            .map_err(|e| crate::new_error!("save: failed to stat {:?}: {}", layout_marker, e))?;
395        if marker_existed {
396            let bytes = read_bounded(&layout_marker, MAX_JSON_BLOB_SIZE).map_err(|e| {
397                crate::new_error!("save: failed to read existing oci-layout: {}", e)
398            })?;
399            let v: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
400                crate::new_error!("save: existing oci-layout is not valid JSON: {}", e)
401            })?;
402            match v.get("imageLayoutVersion").and_then(|s| s.as_str()) {
403                Some(s) if s == OCI_LAYOUT_VERSION => {}
404                Some(other) => {
405                    return Err(crate::new_error!(
406                        "save: existing imageLayoutVersion {:?} is unsupported (expected {:?})",
407                        other,
408                        OCI_LAYOUT_VERSION
409                    ));
410                }
411                None => {
412                    return Err(crate::new_error!(
413                        "save: existing oci-layout is missing imageLayoutVersion"
414                    ));
415                }
416            }
417        }
418
419        let index_path = path.join("index.json");
420        let index_existed = index_path
421            .try_exists()
422            .map_err(|e| crate::new_error!("save: failed to stat {:?}: {}", index_path, e))?;
423        let mut manifests: Vec<Descriptor> = if index_existed {
424            let bytes = read_bounded(&index_path, MAX_JSON_BLOB_SIZE).map_err(|e| {
425                crate::new_error!("save: failed to read existing index.json: {}", e)
426            })?;
427            let existing: ImageIndex = serde_json::from_slice(&bytes).map_err(|e| {
428                crate::new_error!(
429                    "save: existing index.json is not a valid OCI image index: {}",
430                    e
431                )
432            })?;
433            existing.manifests().to_vec()
434        } else {
435            Vec::new()
436        };
437
438        let new_desc = self.write_blobs_and_build_descriptor(path, tag, &cfg, &cfg_bytes)?;
439        let written_digest = OciDigest::from_oci_spec_digest(new_desc.digest());
440
441        // Replacement is by tag, not by digest: a new snapshot may
442        // hash to a different value but still claim the same logical
443        // ref. Blobs from the replaced manifest become orphans.
444        manifests.retain(|d| {
445            d.annotations()
446                .as_ref()
447                .and_then(|a| a.get(ANNOTATION_REF_NAME))
448                .map(|s| s.as_str() != tag.as_str())
449                .unwrap_or(true)
450        });
451        manifests.push(new_desc);
452
453        let index = ImageIndexBuilder::default()
454            .schema_version(SCHEMA_VERSION)
455            .media_type(MediaType::ImageIndex)
456            .manifests(manifests)
457            .build()
458            .map_err(|e| crate::new_error!("failed to build OCI index: {}", e))?;
459        let index_bytes = serde_json::to_vec_pretty(&index)
460            .map_err(|e| crate::new_error!("failed to serialise OCI index: {}", e))?;
461        check_json_blob_size("index.json", index_bytes.len())?;
462
463        // Write the marker before the index swap. A loader that sees
464        // the new index requires the marker; ordering them this way
465        // keeps the layout valid at every step.
466        if !marker_existed {
467            let layout_bytes = serde_json::to_vec(&serde_json::json!({
468                "imageLayoutVersion": OCI_LAYOUT_VERSION,
469            }))
470            .map_err(|e| crate::new_error!("failed to serialise oci-layout: {}", e))?;
471            replace_file_atomic(&layout_marker, &layout_bytes)?;
472        }
473
474        // Index swap is the commit point.
475        replace_file_atomic(&index_path, &index_bytes)?;
476
477        Ok(written_digest)
478    }
479
480    fn write_blobs_and_build_descriptor(
481        &self,
482        dir: &Path,
483        tag: &OciTag,
484        cfg: &OciSnapshotConfig,
485        cfg_bytes: &[u8],
486    ) -> crate::Result<Descriptor> {
487        let memory_bytes = self.memory.as_slice();
488        let memory_size = memory_bytes.len();
489        if memory_size == 0 || !memory_size.is_multiple_of(PAGE_SIZE) {
490            return Err(crate::new_error!(
491                "snapshot memory size {} must be a non-zero multiple of PAGE_SIZE",
492                memory_size
493            ));
494        }
495
496        let blobs_dir = dir.join("blobs").join("sha256");
497        std::fs::create_dir_all(&blobs_dir).map_err(|e| {
498            crate::new_error!("failed to create OCI blobs dir {:?}: {}", blobs_dir, e)
499        })?;
500
501        // Snapshot blob: the raw memory bytes.
502        let snapshot_digest = Digest256::from_bytes(memory_bytes);
503        put_blob_if_absent(&blobs_dir, &snapshot_digest, memory_bytes)?;
504
505        // Config blob.
506        let cfg_digest = Digest256::from_bytes(cfg_bytes);
507        put_blob(&blobs_dir, &cfg_digest, cfg_bytes)?;
508
509        // Manifest blob.
510        let config_descriptor = DescriptorBuilder::default()
511            .media_type(MediaType::Other(MT_CONFIG_CURRENT.to_string()))
512            .digest(oci_digest(&cfg_digest)?)
513            .size(cfg_bytes.len() as u64)
514            .build()
515            .map_err(|e| crate::new_error!("failed to build config descriptor: {}", e))?;
516        let snapshot_descriptor = DescriptorBuilder::default()
517            .media_type(MediaType::Other(MT_SNAPSHOT_CURRENT.to_string()))
518            .digest(oci_digest(&snapshot_digest)?)
519            .size(memory_size as u64)
520            .build()
521            .map_err(|e| crate::new_error!("failed to build snapshot descriptor: {}", e))?;
522        // `artifactType` is set equal to `config.mediaType` per OCI
523        // image-spec "Guidelines for Artifact Usage". Registries
524        // surface this on the distribution-spec referrers API. Tools
525        // that read only `config.mediaType` see the same value.
526        let manifest = ImageManifestBuilder::default()
527            .schema_version(SCHEMA_VERSION)
528            .media_type(MediaType::ImageManifest)
529            .artifact_type(MediaType::Other(MT_CONFIG_CURRENT.to_string()))
530            .config(config_descriptor)
531            .layers(vec![snapshot_descriptor])
532            .build()
533            .map_err(|e| crate::new_error!("failed to build OCI manifest: {}", e))?;
534        let manifest_bytes = serde_json::to_vec_pretty(&manifest)
535            .map_err(|e| crate::new_error!("failed to serialise OCI manifest: {}", e))?;
536        check_json_blob_size("manifest blob", manifest_bytes.len())?;
537        let manifest_digest = Digest256::from_bytes(&manifest_bytes);
538        put_blob(&blobs_dir, &manifest_digest, &manifest_bytes)?;
539
540        let mut anns = std::collections::HashMap::new();
541        anns.insert(ANNOTATION_REF_NAME.to_string(), tag.as_str().to_string());
542        anns.insert(ANNOTATION_ARCH.to_string(), cfg.arch.as_str().to_string());
543        anns.insert(
544            ANNOTATION_HYPERVISOR.to_string(),
545            cfg.hypervisor.as_str().to_string(),
546        );
547        anns.insert(
548            ANNOTATION_CPU.to_string(),
549            cfg.cpu_vendor.as_str().to_string(),
550        );
551        DescriptorBuilder::default()
552            .media_type(MediaType::ImageManifest)
553            .digest(oci_digest(&manifest_digest)?)
554            .size(manifest_bytes.len() as u64)
555            .annotations(anns)
556            .build()
557            .map_err(|e| crate::new_error!("failed to build manifest descriptor: {}", e))
558    }
559
560    fn build_config(&self) -> crate::Result<OciSnapshotConfig> {
561        let (entrypoint_addr, sregs) = match (self.next_action, self.sregs.as_ref()) {
562            (NextAction::Call(addr), Some(sregs)) => (addr, sregs),
563            (NextAction::Call(_), None) => {
564                return Err(crate::new_error!(
565                    "snapshot inconsistent: Call entrypoint must have sregs"
566                ));
567            }
568            (NextAction::Initialise(_), _) => {
569                return Err(crate::new_error!(
570                    "pre-init snapshots cannot be persisted. Only a snapshot taken after the guest has run can be saved to disk"
571                ));
572            }
573            #[cfg(test)]
574            (NextAction::None, _) => {
575                return Err(crate::new_error!(
576                    "snapshot with NextAction::None cannot be persisted"
577                ));
578            }
579        };
580
581        let host_functions = match &self.host_functions.host_functions {
582            Some(v) => v.iter().map(HostFunction::from).collect(),
583            None => Vec::new(),
584        };
585
586        let l = &self.layout;
587        Ok(OciSnapshotConfig {
588            hyperlight_version: env!("CARGO_PKG_VERSION").to_string(),
589            arch: Arch::current(),
590            abi_version: SNAPSHOT_ABI_VERSION,
591            hypervisor: Hypervisor::current()
592                .ok_or_else(|| crate::new_error!("no hypervisor available to tag snapshot"))?,
593            cpu_vendor: CpuVendor::current(),
594            stack_top_gva: self.stack_top_gva,
595            entrypoint_addr,
596            original_entrypoint_addr: self.original_entrypoint,
597            sregs: *sregs,
598            #[cfg(target_arch = "x86_64")]
599            msrs: self
600                .msrs
601                .as_ref()
602                .ok_or_else(|| crate::new_error!("snapshot has no MSR state"))?
603                .clone(),
604            layout: MemoryLayout {
605                input_data_size: l.input_data_size(),
606                output_data_size: l.output_data_size(),
607                heap_size: l.heap_size(),
608                code_size: l.code_size(),
609                init_data_size: l.init_data_size(),
610                init_data_permissions: l.init_data_permissions().map(|f| f.bits()),
611                scratch_size: l.get_scratch_size(),
612                snapshot_size: l.snapshot_size(),
613                pt_size: l.pt_size(),
614            },
615            memory_size: self.memory.mem_size() as u64,
616            host_functions,
617            snapshot_generation: self.snapshot_generation,
618        })
619    }
620
621    /// Load a snapshot from an OCI Image Layout directory produced by
622    /// [`Snapshot::save`].
623    ///
624    /// # `path`
625    ///
626    /// The OCI Image Layout directory to read from. It must hold a
627    /// readable OCI layout containing at least one Hyperlight
628    /// snapshot.
629    ///
630    /// # `reference`
631    ///
632    /// Determines which snapshot in the layout to load, given as
633    /// either an [`OciTag`] or an [`OciDigest`]. Loading fails if no
634    /// snapshot in the layout has the given tag or digest.
635    ///
636    /// # Portability
637    ///
638    /// Snapshot images are bound to the specific CPU architecture,
639    /// hypervisor, and CPU vendor that the snapshot was created on.
640    /// For example, a snapshot taken on an Intel x86_64 host with KVM
641    /// can only be loaded on an Intel x86_64 host running KVM. Loading
642    /// on any other host is rejected. A future version may relax this
643    /// binding once a wider compatibility set is proven safe.
644    ///
645    /// # Compatibility
646    ///
647    /// While Hyperlight is at version 0.x.y the on-disk format is not
648    /// stable. A snapshot written by one Hyperlight version is not
649    /// guaranteed to load on a different Hyperlight version. An
650    /// incompatible snapshot is always rejected at load time with a
651    /// clear error. It can never load and then misbehave once the
652    /// guest is running. Any release that breaks the format is called
653    /// out in the Hyperlight changelog.
654    ///
655    /// # Verification
656    ///
657    /// This method does not check the manifest, config, or snapshot
658    /// blobs against their recorded sha256 digests. Load only from a
659    /// layout you trust.
660    ///
661    /// To check the digests on load at the expense of some
662    /// performance, use [`Snapshot::checked_load`].
663    ///
664    /// # File-mutation hazard
665    ///
666    /// The snapshot blob stays memory-mapped while the returned
667    /// `Snapshot` or any sandbox built from it is alive. The existing
668    /// blob files in the layout at `path` must not be overwritten,
669    /// truncated, or deleted while the mapping is live. Doing so can
670    /// corrupt guest memory and can lead to undefined behavior.
671    ///
672    /// # Examples
673    ///
674    /// ```no_run
675    /// # use std::sync::Arc;
676    /// # use hyperlight_host::{HostFunctions, MultiUseSandbox};
677    /// # use hyperlight_host::sandbox::snapshot::{OciTag, Snapshot};
678    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
679    /// let tag = OciTag::new("latest")?;
680    /// let snapshot = Arc::new(Snapshot::load("./guest_snapshot", tag)?);
681    /// let mut sandbox = MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), None)?;
682    /// let result: String = sandbox.call("Echo", "hello".to_string())?;
683    /// # Ok(())
684    /// # }
685    /// ```
686    pub fn load(path: impl AsRef<Path>, reference: impl Into<OciReference>) -> crate::Result<Self> {
687        Self::load_inner(path.as_ref(), &reference.into(), false)
688    }
689
690    /// Loads a snapshot like [`Snapshot::load`]. See its rustdoc for
691    /// `path`, `reference`, portability, and the file-mutation
692    /// hazard. This method additionally checks the manifest, config,
693    /// and snapshot blobs against their recorded sha256 digests
694    /// before use, at the expense of some performance.
695    ///
696    /// # Trust
697    ///
698    /// A digest check does not prove the bytes are authentic. Anyone
699    /// who edits a blob can recompute its digest to match, so a
700    /// hostile layout passes the check. Load only from a source you
701    /// trust.
702    pub fn checked_load(
703        path: impl AsRef<Path>,
704        reference: impl Into<OciReference>,
705    ) -> crate::Result<Self> {
706        Self::load_inner(path.as_ref(), &reference.into(), true)
707    }
708
709    fn load_inner(
710        path: &Path,
711        reference: &OciReference,
712        verify_blobs: bool,
713    ) -> crate::Result<Self> {
714        let meta = std::fs::metadata(path)
715            .map_err(|e| crate::new_error!("load failed to stat {:?}: {}", path, e))?;
716        if !meta.is_dir() {
717            return Err(crate::new_error!("load path {:?} is not a directory", path));
718        }
719
720        let blobs_dir: PathBuf = path.join("blobs").join("sha256");
721
722        // 1. oci-layout
723        read_layout_marker(path)?;
724
725        // 2. index.json -> manifest descriptor for `reference`.
726        //    Multiple manifests are valid in an OCI Image Layout. A
727        //    tag selects the one whose
728        //    `org.opencontainers.image.ref.name` annotation matches it
729        //    (two manifests sharing a tag is a malformed layout). A
730        //    digest selects the descriptor carrying that manifest
731        //    digest.
732        let manifest = load_manifest(path, &blobs_dir, reference, verify_blobs)?;
733        let cfg_desc = manifest.config();
734        // Loader dispatch on config media type. A future v2 lands
735        // as a new arm that converts to the in-memory current shape.
736        let cfg_media = cfg_desc.media_type().to_string();
737        match cfg_media.as_str() {
738            MT_CONFIG_V1 => {}
739            other => {
740                return Err(crate::new_error!(
741                    "unexpected config media type {:?} (supported: {:?})",
742                    other,
743                    MT_CONFIG_V1
744                ));
745            }
746        }
747        // `artifactType` mirrors `config.mediaType` (manifest.md
748        // "Guidelines for Artifact Usage"). The OCI spec leaves this
749        // field OPTIONAL. A Hyperlight snapshot requires it to be
750        // present and equal to `config.mediaType` so loaders can
751        // distinguish a Hyperlight artifact from an arbitrary
752        // manifest that happens to share blob layout.
753        match manifest.artifact_type() {
754            Some(at) if at.to_string() == cfg_media => {}
755            Some(at) => {
756                return Err(crate::new_error!(
757                    "OCI manifest artifactType {:?} does not match config media type {:?}",
758                    at.to_string(),
759                    cfg_media
760                ));
761            }
762            None => {
763                return Err(crate::new_error!(
764                    "OCI manifest is missing required artifactType (expected {:?})",
765                    cfg_media
766                ));
767            }
768        }
769        let layers = manifest.layers();
770        if layers.len() != 1 {
771            return Err(crate::new_error!(
772                "expected exactly one OCI layer (the snapshot), found {}",
773                layers.len()
774            ));
775        }
776        let snap_desc = &layers[0];
777        let snap_media = snap_desc.media_type().to_string();
778        match snap_media.as_str() {
779            MT_SNAPSHOT_V1 => {}
780            other => {
781                return Err(crate::new_error!(
782                    "unexpected snapshot layer media type {:?} (supported: {:?})",
783                    other,
784                    MT_SNAPSHOT_V1
785                ));
786            }
787        }
788
789        // 4. config blob
790        let cfg = load_config(&blobs_dir, cfg_desc, verify_blobs)?;
791
792        // 5. snapshot blob: open once, hash and mmap the same
793        //    handle so an attacker cannot swap the file between
794        //    verification and mapping.
795        let snap_file = open_snapshot_blob(&blobs_dir, snap_desc, cfg.memory_size, verify_blobs)?;
796
797        // 6. Reconstruct layout.
798        let mut sbox_cfg = crate::sandbox::SandboxConfiguration::default();
799        sbox_cfg.set_input_data_size(cfg.layout.input_data_size);
800        sbox_cfg.set_output_data_size(cfg.layout.output_data_size);
801        sbox_cfg.set_heap_size(cfg.layout.heap_size as u64);
802        sbox_cfg.set_scratch_size(cfg.layout.scratch_size);
803        let init_data_perms = match cfg.layout.init_data_permissions {
804            None => None,
805            Some(bits) => Some(MemoryRegionFlags::from_bits(bits).ok_or_else(|| {
806                crate::new_error!(
807                    "snapshot init_data_permissions {:#x} contains unknown flag bits",
808                    bits
809                )
810            })?),
811        };
812        let mut layout = SandboxMemoryLayout::new(
813            sbox_cfg,
814            cfg.layout.code_size,
815            cfg.layout.init_data_size,
816            init_data_perms,
817        )?;
818        // `snapshot_size` and `pt_size` are independent fields.
819        if let Some(pt) = cfg.layout.pt_size {
820            layout.set_pt_size(pt)?;
821        }
822        layout.set_snapshot_size(cfg.layout.snapshot_size);
823
824        // `snapshot_size` is the guest-visible prefix mapped into the
825        // snapshot region. It must cover at least the regions the
826        // layout fields describe (code, PEB, heap, init data),
827        // otherwise the guest mapping is too short to back them. The
828        // `snapshot_size + pt_size == memory_size` invariant alone
829        // does not bound `snapshot_size` from below, since a smaller
830        // `snapshot_size` can be offset by a larger `pt_size`.
831        let required_memory_size = layout.get_memory_size()? as u64;
832        if (layout.snapshot_size() as u64) < required_memory_size {
833            return Err(crate::new_error!(
834                "snapshot snapshot_size ({}) is smaller than the layout size ({})",
835                layout.snapshot_size(),
836                required_memory_size
837            ));
838        }
839
840        // 7. mmap the snapshot blob (file-backed CoW). The blob is
841        //    the raw memory image. `ReadonlySharedMemory::from_file`
842        //    surrounds it with host guard pages. The guest mapping
843        //    of the snapshot region covers only the data prefix
844        //    (`snapshot_size`). The PT tail sits past that prefix
845        //    in the host mapping and is copied into the scratch
846        //    region on restore. Keeping it out of the guest mapping
847        //    of the snapshot region avoids overlap with
848        //    `map_file_cow` regions installed immediately after the
849        //    snapshot in guest PA space.
850        let memory = ReadonlySharedMemory::from_file(&snap_file, layout.snapshot_size())?;
851
852        // The size validation in `open_snapshot_blob` stats the file
853        // before mapping. Nothing prevents the file from being
854        // truncated between that stat and the mmap, which would leave
855        // the mapping shorter than the config claims and make restore
856        // read past the end. Compare the mapped length against
857        // `memory_size` to reject a file mutated under us.
858        if memory.mem_size() as u64 != cfg.memory_size {
859            return Err(crate::new_error!(
860                "mapped snapshot size ({}) does not match config memory_size ({}); the blob may have changed during loading",
861                memory.mem_size(),
862                cfg.memory_size
863            ));
864        }
865
866        // 8. Build the next action + sregs back from the config.
867        let next_action = NextAction::Call(cfg.entrypoint_addr);
868
869        // 9. Reconstitute host_functions metadata.
870        let snapshot_generation = cfg.snapshot_generation;
871        let host_funcs_vec: Vec<
872            hyperlight_common::flatbuffer_wrappers::host_function_definition::HostFunctionDefinition,
873        > = cfg.host_functions.into_iter().map(Into::into).collect();
874        let host_functions = if host_funcs_vec.is_empty() {
875            HostFunctionDetails {
876                host_functions: None,
877            }
878        } else {
879            HostFunctionDetails {
880                host_functions: Some(host_funcs_vec),
881            }
882        };
883
884        Ok(Snapshot {
885            layout,
886            memory,
887            load_info: crate::mem::exe::LoadInfo::dummy(),
888            stack_top_gva: cfg.stack_top_gva,
889            sregs: Some(cfg.sregs),
890            #[cfg(target_arch = "x86_64")]
891            msrs: Some(cfg.msrs),
892            next_action,
893            original_entrypoint: cfg.original_entrypoint_addr,
894            snapshot_generation,
895            host_functions,
896        })
897    }
898}