Skip to main content

hyperlight_host/sandbox/snapshot/file/
mod.rs

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