diff --git a/Cargo.lock b/Cargo.lock index 86b87ae74..3c0c76176 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -530,6 +530,27 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -730,6 +751,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -741,6 +797,37 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -1154,6 +1241,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "gimli" version = "0.33.0" @@ -1362,6 +1461,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -1587,6 +1692,7 @@ dependencies = [ "gdbstub", "gdbstub_arch", "goblin", + "hex", "hyperlight-common", "hyperlight-component-macro", "hyperlight-guest-tracing", @@ -1602,6 +1708,7 @@ dependencies = [ "metrics-util", "mshv-bindings", "mshv-ioctls", + "oci-spec", "opentelemetry", "opentelemetry-otlp", "opentelemetry-semantic-conventions", @@ -1614,6 +1721,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "signal-hook-registry", "tempfile", "termcolor", @@ -1792,6 +1900,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1915,6 +2029,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kurbo" version = "0.11.3" @@ -2335,6 +2464,23 @@ dependencies = [ "ruzstd", ] +[[package]] +name = "oci-spec" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3da52b83ce3258fbf29f66ac784b279453c2ac3c22c5805371b921ede0d308" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum", + "strum_macros", + "thiserror", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2781,6 +2927,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3455,6 +3623,24 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "2.0.117" diff --git a/src/hyperlight_host/Cargo.toml b/src/hyperlight_host/Cargo.toml index eb37fdddc..4a2dbdfe7 100644 --- a/src/hyperlight_host/Cargo.toml +++ b/src/hyperlight_host/Cargo.toml @@ -49,9 +49,13 @@ thiserror = "2.0.18" chrono = { version = "0.4", optional = true } anyhow = "1.0" metrics = "0.24.6" +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" elfcore = { version = "2.0", optional = true } uuid = { version = "1.23.1", features = ["v4"] } +oci-spec = { version = "0.8", default-features = false, features = ["image"] } +sha2 = "0.10" +hex = "0.4" [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ @@ -83,7 +87,6 @@ mshv-ioctls = { version = "0.6", optional = true} [dev-dependencies] uuid = { version = "1.23.1", features = ["v4"] } signal-hook-registry = "1.4.8" -serde = "1.0" iced-x86 = { version = "1.21", default-features = false, features = ["std", "code_asm"] } proptest = "1.11.0" tempfile = "3.27.0" diff --git a/src/hyperlight_host/benches/benchmarks.rs b/src/hyperlight_host/benches/benchmarks.rs index 462e8908d..0f9ca5b2a 100644 --- a/src/hyperlight_host/benches/benchmarks.rs +++ b/src/hyperlight_host/benches/benchmarks.rs @@ -153,6 +153,15 @@ fn sandbox_lifecycle_benchmark(c: &mut Criterion) { ); } + // Isolates the cost of building a MultiUseSandbox from an + // already-resident Snapshot. The Snapshot is loaded outside the + // timed region. + for size in SandboxSize::all() { + group.bench_function(format!("sandbox_from_snapshot/{}", size.name()), |b| { + bench_sandbox_from_snapshot(b, size) + }); + } + group.finish(); } @@ -347,6 +356,25 @@ fn bench_snapshot_restore(b: &mut criterion::Bencher, size: SandboxSize) { }); } +fn bench_sandbox_from_snapshot(b: &mut criterion::Bencher, size: SandboxSize) { + use hyperlight_host::HostFunctions; + use hyperlight_host::sandbox::snapshot::Snapshot; + + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join("bench"); + { + let mut sbox = create_multiuse_sandbox_with_size(size); + let snapshot = sbox.snapshot().unwrap(); + snapshot.to_oci(&snap_path, "latest").unwrap(); + } + let loaded = std::sync::Arc::new(Snapshot::from_oci(&snap_path, "latest").unwrap()); + + b.iter(|| { + let _ = + MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None).unwrap(); + }); +} + fn snapshots_benchmark(c: &mut Criterion) { let mut group = c.benchmark_group("snapshots"); @@ -551,6 +579,118 @@ fn shared_memory_benchmark(c: &mut Criterion) { group.finish(); } +// ============================================================================ +// Benchmark Category: Snapshot Files +// ============================================================================ + +fn snapshot_file_benchmark(c: &mut Criterion) { + use hyperlight_host::HostFunctions; + use hyperlight_host::sandbox::snapshot::Snapshot; + + let mut group = c.benchmark_group("snapshot_files"); + + // Pre-create OCI snapshot images for all sizes. + let dirs: Vec<_> = SandboxSize::all() + .iter() + .map(|size| { + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join(size.name()); + let snapshot = { + let mut sbox = create_multiuse_sandbox_with_size(*size); + sbox.snapshot().unwrap() + }; + snapshot.to_oci(&snap_path, "latest").unwrap(); + (dir, snapshot, snap_path) + }) + .collect(); + + // Benchmark: save_snapshot. Wipe the layout between iterations + // so each save measures a fresh write rather than a tag-append. + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_dir = tempfile::tempdir().unwrap(); + let path = snap_dir.path().join("bench"); + let snapshot = &dirs[i].1; + group.bench_function(format!("save_snapshot/{}", size.name()), |b| { + b.iter_batched( + || { + let _ = std::fs::remove_dir_all(&path); + }, + |_| snapshot.to_oci(&path, "latest").unwrap(), + criterion::BatchSize::PerIteration, + ); + }); + } + + // Benchmark: load_snapshot (parse manifest + config + mmap blob). + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].2.clone(); + group.bench_function(format!("load_snapshot/{}", size.name()), |b| { + b.iter(|| { + let _ = Snapshot::from_oci(&snap_path, "latest").unwrap(); + }); + }); + } + + // Benchmark: load_snapshot_unchecked (skip blob digest verification). + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].2.clone(); + group.bench_function(format!("load_snapshot_unchecked/{}", size.name()), |b| { + b.iter(|| { + let _ = Snapshot::from_oci_unchecked(&snap_path, "latest").unwrap(); + }); + }); + } + + // Benchmark: cold_start_via_evolve (new + evolve + call) + for size in SandboxSize::all() { + group.bench_function(format!("cold_start_via_evolve/{}", size.name()), |b| { + b.iter(|| { + let mut sbox = create_multiuse_sandbox_with_size(size); + sbox.call::("Echo", "hello\n".to_string()).unwrap(); + }); + }); + } + + // Benchmark: cold_start_via_snapshot (load + from_snapshot + call) + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].2.clone(); + group.bench_function(format!("cold_start_via_snapshot/{}", size.name()), |b| { + b.iter(|| { + let loaded = Snapshot::from_oci(&snap_path, "latest").unwrap(); + let mut sbox = MultiUseSandbox::from_snapshot( + std::sync::Arc::new(loaded), + HostFunctions::default(), + None, + ) + .unwrap(); + sbox.call::("Echo", "hello\n".to_string()).unwrap(); + }); + }); + } + + // Benchmark: cold_start_via_snapshot_unchecked (load unchecked + from_snapshot + call) + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].2.clone(); + group.bench_function( + format!("cold_start_via_snapshot_unchecked/{}", size.name()), + |b| { + b.iter(|| { + let loaded = Snapshot::from_oci_unchecked(&snap_path, "latest").unwrap(); + let mut sbox = MultiUseSandbox::from_snapshot( + std::sync::Arc::new(loaded), + HostFunctions::default(), + None, + ) + .unwrap(); + sbox.call::("Echo", "hello\n".to_string()).unwrap(); + }); + }, + ); + } + + group.finish(); +} + criterion_group! { name = benches; config = Criterion::default(); @@ -561,6 +701,7 @@ criterion_group! { guest_call_benchmark_large_param, function_call_serialization_benchmark, sample_workloads_benchmark, - shared_memory_benchmark + shared_memory_benchmark, + snapshot_file_benchmark } criterion_main!(benches); diff --git a/src/hyperlight_host/examples/guest-debugging/main.rs b/src/hyperlight_host/examples/guest-debugging/main.rs index dc42b0aea..4ced2f8cf 100644 --- a/src/hyperlight_host/examples/guest-debugging/main.rs +++ b/src/hyperlight_host/examples/guest-debugging/main.rs @@ -115,6 +115,76 @@ mod tests { #[cfg(windows)] const GDB_COMMAND: &str = "gdb"; + /// Construct the (out_file_path, cmd_file_path, manifest_dir) + /// triple every gdb test needs. + fn gdb_test_paths(name: &str) -> (String, String, String) { + let out_dir = std::env::var("OUT_DIR").expect("Failed to get out dir"); + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") + .expect("Failed to get manifest dir") + .replace('\\', "/"); + let out_file_path = format!("{out_dir}/{name}.output"); + let cmd_file_path = format!("{out_dir}/{name}-commands.txt"); + (out_file_path, cmd_file_path, manifest_dir) + } + + /// Build a gdb script that connects to `port`, sets a single + /// breakpoint at `breakpoint`, prints `echo_msg` when hit, and + /// detaches before quitting. + /// + /// The breakpoint commands end with `detach` + `quit` instead of + /// `continue`. The previous "inner continue, outer continue, quit" + /// shape races with the inferior exit. After the breakpoint hits + /// and the inner `continue` resumes the guest, the guest may run + /// to completion and the gdb stub may close the remote before gdb + /// has dispatched the outer `continue`, producing a non-zero exit + /// with `Remote connection closed`. Detaching from the breakpoint + /// commands removes that window. The host process keeps running + /// the guest call to completion on its own after detach. + fn single_breakpoint_script( + manifest_dir: &str, + port: u16, + out_file_path: &str, + breakpoint: &str, + echo_msg: &str, + ) -> String { + let cmd = format!( + "file {manifest_dir}/../tests/rust_guests/bin/debug/simpleguest + target remote :{port} + + set pagination off + set logging file {out_file_path} + set logging enabled on + + break {breakpoint} + commands + echo \"{echo_msg}\\n\" + backtrace + + set logging enabled off + detach + quit + end + + continue + " + ); + #[cfg(windows)] + let cmd = format!("set osabi none\n{cmd}"); + cmd + } + + /// Spawn the gdb client to execute the script in `cmd_file_path`. + fn spawn_gdb_client(cmd_file_path: &str) -> std::process::Child { + Command::new(GDB_COMMAND) + .arg("-nx") + .arg("--nw") + .arg("--batch") + .arg("-x") + .arg(cmd_file_path) + .spawn() + .expect("Failed to start gdb") + } + fn write_cmds_file(cmd_file_path: &str, cmd: &str) -> io::Result<()> { let file = File::create(cmd_file_path)?; let mut writer = BufWriter::new(file); @@ -163,14 +233,7 @@ mod tests { // wait 3 seconds for the gdb to connect thread::sleep(Duration::from_secs(3)); - let mut gdb = Command::new(GDB_COMMAND) - .arg("-nx") // Don't load any .gdbinit files - .arg("--nw") - .arg("--batch") - .arg("-x") - .arg(cmd_file_path) - .spawn() - .map_err(|e| new_error!("Failed to start gdb process: {}", e))?; + let mut gdb = spawn_gdb_client(cmd_file_path); // wait 3 seconds for the gdb to connect thread::sleep(Duration::from_secs(10)); @@ -245,38 +308,16 @@ mod tests { #[test] #[serial] fn test_gdb_end_to_end() { - let out_dir = std::env::var("OUT_DIR").expect("Failed to get out dir"); - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") - .expect("Failed to get manifest dir") - .replace('\\', "/"); - let out_file_path = format!("{out_dir}/gdb.output"); - let cmd_file_path = format!("{out_dir}/gdb-commands.txt"); - - let cmd = format!( - "file {manifest_dir}/../tests/rust_guests/bin/debug/simpleguest - target remote :8080 - - set pagination off - set logging file {out_file_path} - set logging enabled on - - break hyperlight_main - commands - echo \"Stopped at hyperlight_main breakpoint\\n\" - backtrace - - set logging enabled off - detach - quit - end - - continue - " + let (out_file_path, cmd_file_path, manifest_dir) = gdb_test_paths("gdb"); + + let cmd = single_breakpoint_script( + &manifest_dir, + 8080, + &out_file_path, + "hyperlight_main", + "Stopped at hyperlight_main breakpoint", ); - #[cfg(windows)] - let cmd = format!("set osabi none\n{}", cmd); - let checker = |contents: String| contents.contains("Stopped at hyperlight_main breakpoint"); let result = run_guest_and_gdb(&cmd_file_path, &out_file_path, &cmd, checker); @@ -288,13 +329,8 @@ mod tests { #[test] #[serial] fn test_gdb_sse_check() { - let out_dir = std::env::var("OUT_DIR").expect("Failed to get out dir"); - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") - .expect("Failed to get manifest dir") - .replace('\\', "/"); + let (out_file_path, cmd_file_path, manifest_dir) = gdb_test_paths("gdb-sse"); println!("manifest dir {manifest_dir}"); - let out_file_path = format!("{out_dir}/gdb-sse.output"); - let cmd_file_path = format!("{out_dir}/gdb-sse--commands.txt"); let cmd = format!( "file {manifest_dir}/../tests/rust_guests/bin/debug/simpleguest @@ -330,4 +366,74 @@ mod tests { cleanup(&out_file_path, &cmd_file_path); assert!(result.is_ok(), "{}", result.unwrap_err()); } + + #[test] + #[serial] + fn test_gdb_from_snapshot() { + use hyperlight_host::HostFunctions; + + const PORT: u16 = 8081; + + let (out_file_path, cmd_file_path, manifest_dir) = gdb_test_paths("gdb-from-snapshot"); + + // Build a sandbox the normal way and snapshot it in-memory. + let mut producer: MultiUseSandbox = UninitializedSandbox::new( + hyperlight_host::GuestBinary::FilePath( + hyperlight_testing::simple_guest_as_string().unwrap(), + ), + None, + ) + .unwrap() + .evolve() + .unwrap(); + let snap = producer.snapshot().unwrap(); + + // Order matters. The gdb stub event loop must enter (i.e. + // `VcpuStopped` must be sent on the channel) before the gdb + // client connects, otherwise the wire protocol desyncs. The + // evolve case gets this for free because `evolve()` runs + // `vm.initialise()` which trips the entry breakpoint + // immediately. For a `Call` snapshot `vm.initialise` is a + // no-op, so we trigger the breakpoint by running `sbox.call` + // here before the client is launched below. + let snap_thread = snap.clone(); + let sandbox_thread = thread::spawn(move || -> Result<()> { + let mut cfg = SandboxConfiguration::default(); + cfg.set_guest_debug_info(DebugInfo { port: PORT }); + + let mut sbox = + MultiUseSandbox::from_snapshot(snap_thread, HostFunctions::default(), Some(cfg))?; + sbox.call::( + "PrintOutput", + "Hello from a from_snapshot sandbox\n".to_string(), + )?; + Ok(()) + }); + + // Wait for the sandbox thread to bind the listener, install + // the one-shot breakpoint, and trip it. + thread::sleep(Duration::from_secs(3)); + + let cmd = single_breakpoint_script( + &manifest_dir, + PORT, + &out_file_path, + "main.rs:simpleguest::print_output", + "Stopped at print_output breakpoint", + ); + write_cmds_file(&cmd_file_path, &cmd).expect("Failed to write gdb commands"); + + let mut gdb = spawn_gdb_client(&cmd_file_path); + let _ = gdb.wait(); + let sandbox_result = sandbox_thread + .join() + .expect("from_snapshot sandbox thread panicked"); + + let checker = |contents: String| contents.contains("Stopped at print_output breakpoint"); + let result = check_output(&out_file_path, checker); + + cleanup(&out_file_path, &cmd_file_path); + sandbox_result.expect("from_snapshot sandbox returned error"); + result.expect("gdb output missing expected breakpoint hit"); + } } diff --git a/src/hyperlight_host/src/func/host_functions.rs b/src/hyperlight_host/src/func/host_functions.rs index e87fa70b0..ec720d911 100644 --- a/src/hyperlight_host/src/func/host_functions.rs +++ b/src/hyperlight_host/src/func/host_functions.rs @@ -52,7 +52,8 @@ impl Registerable for UninitializedSandbox { return_type: Output::TYPE, }; - (*hfs).register_host_function(name.to_string(), entry) + (*hfs).register_host_function(name.to_string(), entry); + Ok(()) } } @@ -92,7 +93,31 @@ impl Registerable for crate::MultiUseSandbox { return_type: Output::TYPE, }; - (*hfs).register_host_function(name.to_string(), entry) + (*hfs).register_host_function(name.to_string(), entry); + + // Registration mutates the host-function set captured in + // snapshots. Invalidate the cached snapshot so the next + // `snapshot()` call reflects the updated registry. + self.snapshot = None; + Ok(()) + } +} + +impl Registerable for crate::HostFunctions { + fn register_host_function( + &mut self, + name: &str, + hf: impl Into>, + ) -> Result<()> { + let entry = FunctionEntry { + function: hf.into().into(), + parameter_types: Args::TYPE, + return_type: Output::TYPE, + }; + + self.inner_mut() + .register_host_function(name.to_string(), entry); + Ok(()) } } @@ -236,7 +261,7 @@ pub(crate) fn register_host_function std::result::Result { let CommonRegisters { rip, .. } = vm.regs()?; @@ -81,10 +80,6 @@ pub(crate) fn vcpu_stop_reason( // Check page 19-4 Vol. 3B of Intel 64 and IA-32 // Architectures Software Developer's Manual if DR6_HW_BP_FLAGS_MASK & dr6 != 0 { - if rip == entrypoint { - vm.remove_hw_breakpoint(entrypoint)?; - return Ok(VcpuStopReason::EntryPointBp); - } return Ok(VcpuStopReason::HwBp); } } @@ -98,12 +93,10 @@ pub(crate) fn vcpu_stop_reason( r"The vCPU exited because of an unknown reason: rip: {:?} dr6: {:?} - entrypoint: {:?} exception: {:?} ", rip, dr6, - entrypoint, exception, ); diff --git a/src/hyperlight_host/src/hypervisor/gdb/event_loop.rs b/src/hyperlight_host/src/hypervisor/gdb/event_loop.rs index bc7c9fd14..5edd81b50 100644 --- a/src/hyperlight_host/src/hypervisor/gdb/event_loop.rs +++ b/src/hyperlight_host/src/hypervisor/gdb/event_loop.rs @@ -59,7 +59,6 @@ impl run_blocking::BlockingEventLoop for GdbBlockingEventLoop { // Resume execution if unknown reason for stop let stop_response = match stop_reason { VcpuStopReason::DoneStep => BaseStopReason::DoneStep, - VcpuStopReason::EntryPointBp => BaseStopReason::HwBreak(()), VcpuStopReason::SwBp => BaseStopReason::SwBreak(()), VcpuStopReason::HwBp => BaseStopReason::HwBreak(()), // This is a consequence of the GDB client sending an interrupt signal diff --git a/src/hyperlight_host/src/hypervisor/gdb/mod.rs b/src/hyperlight_host/src/hypervisor/gdb/mod.rs index 94396e5ae..0a0685f71 100644 --- a/src/hyperlight_host/src/hypervisor/gdb/mod.rs +++ b/src/hyperlight_host/src/hypervisor/gdb/mod.rs @@ -171,10 +171,6 @@ impl DebugMemoryAccess { pub enum VcpuStopReason { Crash, DoneStep, - /// Hardware breakpoint inserted by the hypervisor so the guest can be stopped - /// at the entry point. This is used to avoid the guest from executing - /// the entry point code before the debugger is connected - EntryPointBp, HwBp, SwBp, Interrupt, diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs index 830b856c0..5268bd665 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs @@ -389,6 +389,13 @@ pub(crate) struct HyperlightVm { pub(super) gdb_conn: Option>, #[cfg(gdb)] pub(super) sw_breakpoints: HashMap, // addr -> original instruction + /// One-shot hw breakpoint installed at the entry address when gdb is + /// enabled, so the gdb stub gets a `VcpuStopped` to enter its event + /// loop on the first vCPU run after construction. Cleared by the + /// `VmExit::Debug` arm of `run` the first time a `HwBp` stop fires + /// at the entry address. + #[cfg(gdb)] + pub(super) one_shot_entry_bp: Option, #[cfg(feature = "mem_profile")] pub(super) trace_info: MemTraceInfo, #[cfg(crashdump)] @@ -598,17 +605,28 @@ impl HyperlightVm { match exit_reason { #[cfg(gdb)] Ok(VmExit::Debug { dr6, exception }) => { - let initialise = match self.entrypoint { - NextAction::Initialise(initialise) => initialise, - _ => 0, - }; - // Handle debug event (breakpoints) + // Classify the debug exit. `vcpu_stop_reason` is a + // pure classifier and has no side effects on the VM. let stop_reason = crate::hypervisor::gdb::arch::vcpu_stop_reason( - self.vm.as_mut(), + self.vm.as_ref(), dr6, - initialise, exception, )?; + // Remove the one-shot entry breakpoint installed by + // `HyperlightVm::new` the first time it fires so it + // does not interfere with later user-installed + // breakpoints at the same address. + if matches!(stop_reason, VcpuStopReason::HwBp) + && let Some(entry_addr) = self.one_shot_entry_bp + { + let rip = self.vm.regs().map_err(VcpuStopReasonError::GetRegs)?.rip; + if rip == entry_addr { + self.vm + .remove_hw_breakpoint(entry_addr) + .map_err(VcpuStopReasonError::RemoveHwBreakpoint)?; + self.one_shot_entry_bp = None; + } + } if let Err(e) = self.handle_debug(dbg_mem_access_fn.clone(), stop_reason) { break Err(e.into()); } diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs index 16ac55ad3..791a4e976 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs @@ -168,6 +168,8 @@ impl HyperlightVm { gdb_conn, #[cfg(gdb)] sw_breakpoints: HashMap::new(), + #[cfg(gdb)] + one_shot_entry_bp: None, #[cfg(feature = "mem_profile")] trace_info, #[cfg(crashdump)] @@ -182,12 +184,21 @@ impl HyperlightVm { #[cfg(gdb)] if ret.gdb_conn.is_some() { ret.send_dbg_msg(DebugResponse::InterruptHandle(ret.interrupt_handle.clone()))?; - // Add breakpoint to the entry point address, if we are going to initialise + // Add breakpoint at the entry point address. The breakpoint + // is removed on first hit by the run loop. Tracked via + // `one_shot_entry_bp` so it does not interfere with later + // user-installed breakpoints at the same address. ret.vm.set_debug(true).map_err(VmError::Debug)?; - if let NextAction::Initialise(initialise) = entrypoint { + let entry_addr = match entrypoint { + NextAction::Initialise(addr) | NextAction::Call(addr) => Some(addr), + #[cfg(test)] + NextAction::None => None, + }; + if let Some(addr) = entry_addr { ret.vm - .add_hw_breakpoint(initialise) + .add_hw_breakpoint(addr) .map_err(CreateHyperlightVmError::AddHwBreakpoint)?; + ret.one_shot_entry_bp = Some(addr); } } @@ -1486,7 +1497,7 @@ mod tests { [layout.get_guest_code_offset()..layout.get_guest_code_offset() + code.len()] .copy_from_slice(code); layout.write_peb(&mut snapshot_contents).unwrap(); - let ro_mem = ReadonlySharedMemory::from_bytes(&snapshot_contents).unwrap(); + let ro_mem = ReadonlySharedMemory::from_bytes(&snapshot_contents, None).unwrap(); let scratch_mem = ExclusiveSharedMemory::new(config.get_scratch_size()).unwrap(); let mem_mgr = SandboxMemoryManager::new( diff --git a/src/hyperlight_host/src/lib.rs b/src/hyperlight_host/src/lib.rs index b21f413e3..c1d51cd5b 100644 --- a/src/hyperlight_host/src/lib.rs +++ b/src/hyperlight_host/src/lib.rs @@ -91,6 +91,9 @@ pub use hypervisor::virtual_machine::is_hypervisor_present; pub use sandbox::MultiUseSandbox; /// The re-export for the `UninitializedSandbox` type pub use sandbox::UninitializedSandbox; +/// A collection of host functions that can be supplied to a sandbox +/// constructor (e.g. [`MultiUseSandbox::from_snapshot`]). +pub use sandbox::host_funcs::HostFunctions; /// The re-export for the `GuestBinary` type pub use sandbox::uninitialized::GuestBinary; /// The re-export for the `GuestCounter` type diff --git a/src/hyperlight_host/src/mem/layout.rs b/src/hyperlight_host/src/mem/layout.rs index 07f82a829..97ff33ec2 100644 --- a/src/hyperlight_host/src/mem/layout.rs +++ b/src/hyperlight_host/src/mem/layout.rs @@ -226,16 +226,23 @@ pub(crate) struct SandboxMemoryLayout { /// The size of the guest code section. pub(crate) code_size: usize, /// The size of the init data section (guest blob). - init_data_size: usize, + pub(crate) init_data_size: usize, /// Permission flags for the init data region. #[cfg_attr(feature = "i686-guest", allow(unused))] - init_data_permissions: Option, + pub(crate) init_data_permissions: Option, /// The size of the scratch region in physical memory. - scratch_size: usize, - /// The size of the snapshot region in physical memory. - snapshot_size: usize, - /// The size of the page tables (None if not yet set). - pt_size: Option, + pub(crate) scratch_size: usize, + /// Size of the guest-visible prefix of the snapshot blob. + /// This is the number of bytes from the start of the blob that + /// the hypervisor exposes to the guest at `BASE_ADDRESS`. The + /// page-table tail (`pt_size`) sits after it in the blob and + /// the host mapping, and is copied into the scratch region on + /// restore (see `SandboxMemoryManager::update_scratch_bookkeeping`). + /// Invariant: `blob_size == snapshot_size + pt_size`. + pub(crate) snapshot_size: usize, + /// Size of the page-table tail appended to the snapshot blob. + /// `None` during construction before page tables are built. + pub(crate) pt_size: Option, } impl Debug for SandboxMemoryLayout { @@ -295,7 +302,7 @@ impl SandboxMemoryLayout { /// Both the scratch region and the snapshot region are bounded by /// this size. The value is arbitrary but chosen to be large enough /// for most workloads while preventing accidental resource exhaustion. - const MAX_MEMORY_SIZE: usize = (16 * 1024 * 1024 * 1024) - Self::BASE_ADDRESS; // 16 GiB - BASE_ADDRESS + pub(crate) const MAX_MEMORY_SIZE: usize = (16 * 1024 * 1024 * 1024) - Self::BASE_ADDRESS; // 16 GiB - BASE_ADDRESS /// The base address of the sandbox's memory. pub(crate) const BASE_ADDRESS: usize = 0x1000; @@ -497,7 +504,12 @@ impl SandboxMemoryLayout { } } - /// Sets the size of the memory region used for page tables + /// Record the size of the page-table tail appended to the + /// snapshot blob. The PT bytes live at the end of the blob and + /// the host mapping, outside the guest mapping of the snapshot + /// region, and are copied into the scratch region on restore. + /// `snapshot_size` (the guest-visible prefix of the blob) is an + /// independent field and must be set separately. #[instrument(skip_all, parent = Span::current(), level= "Trace")] pub(crate) fn set_pt_size(&mut self, size: usize) -> Result<()> { let min_fixed_scratch = hyperlight_common::layout::min_scratch_size( @@ -508,8 +520,6 @@ impl SandboxMemoryLayout { if self.scratch_size < min_scratch { return Err(MemoryRequestTooSmall(self.scratch_size, min_scratch)); } - let old_pt_size = self.pt_size.unwrap_or(0); - self.snapshot_size = self.snapshot_size - old_pt_size + size; self.pt_size = Some(size); Ok(()) } diff --git a/src/hyperlight_host/src/mem/memory_region.rs b/src/hyperlight_host/src/mem/memory_region.rs index 615fe9cac..de3c83a20 100644 --- a/src/hyperlight_host/src/mem/memory_region.rs +++ b/src/hyperlight_host/src/mem/memory_region.rs @@ -158,7 +158,9 @@ impl MemoryRegionType { /// shared memory mapping with guard pages. pub fn surrogate_mapping(&self) -> SurrogateMapping { match self { - MemoryRegionType::MappedFile => SurrogateMapping::ReadOnlyFile, + MemoryRegionType::MappedFile | MemoryRegionType::Snapshot => { + SurrogateMapping::ReadOnlyFile + } _ => SurrogateMapping::SandboxMemory, } } diff --git a/src/hyperlight_host/src/mem/mgr.rs b/src/hyperlight_host/src/mem/mgr.rs index 9e5d843d1..d77779ce3 100644 --- a/src/hyperlight_host/src/mem/mgr.rs +++ b/src/hyperlight_host/src/mem/mgr.rs @@ -22,6 +22,7 @@ use hyperlight_common::flatbuffer_wrappers::function_call::{ }; use hyperlight_common::flatbuffer_wrappers::function_types::FunctionCallResult; use hyperlight_common::flatbuffer_wrappers::guest_log_data::GuestLogData; +use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; use hyperlight_common::vmem::{self, PAGE_TABLE_SIZE}; #[cfg(all(feature = "crashdump", not(feature = "i686-guest")))] use hyperlight_common::vmem::{BasicMapping, MappingKind}; @@ -298,6 +299,7 @@ where } /// Create a snapshot with the given mapped regions + #[allow(clippy::too_many_arguments)] pub(crate) fn snapshot( &mut self, sandbox_id: u64, @@ -306,6 +308,7 @@ where rsp_gva: u64, sregs: CommonSpecialRegisters, entrypoint: NextAction, + host_functions: HostFunctionDetails, ) -> Result { self.snapshot_count += 1; Snapshot::new( @@ -320,6 +323,7 @@ where sregs, entrypoint, self.snapshot_count, + host_functions, ) } } @@ -330,7 +334,13 @@ impl SandboxMemoryManager { let shared_mem = s.memory().to_mgr_snapshot_mem()?; let scratch_mem = ExclusiveSharedMemory::new(s.layout().get_scratch_size())?; let entrypoint = s.entrypoint(); - Ok(Self::new(layout, shared_mem, scratch_mem, entrypoint)) + let mut mgr = Self::new(layout, shared_mem, scratch_mem, entrypoint); + // Inherit the snapshot's generation number for the same + // reason `restore_snapshot` does: the guest-visible counter + // reflects "which snapshot is the sandbox currently a clone + // of", not "how many snapshots this partition has taken". + mgr.snapshot_count = s.snapshot_generation(); + Ok(mgr) } /// Wraps ExclusiveSharedMemory::build diff --git a/src/hyperlight_host/src/mem/shared_mem.rs b/src/hyperlight_host/src/mem/shared_mem.rs index 5f975f605..6b248fc9a 100644 --- a/src/hyperlight_host/src/mem/shared_mem.rs +++ b/src/hyperlight_host/src/mem/shared_mem.rs @@ -30,8 +30,11 @@ use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; use windows::Win32::System::Memory::PAGE_READWRITE; #[cfg(target_os = "windows")] use windows::Win32::System::Memory::{ - CreateFileMappingA, FILE_MAP_ALL_ACCESS, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, - PAGE_NOACCESS, PAGE_PROTECTION_FLAGS, UnmapViewOfFile, VirtualProtect, + CreateFileMappingA, FILE_MAP_ALL_ACCESS, MEM_PRESERVE_PLACEHOLDER, MEM_RELEASE, + MEM_REPLACE_PLACEHOLDER, MEM_RESERVE, MEM_RESERVE_PLACEHOLDER, MEMORY_MAPPED_VIEW_ADDRESS, + MapViewOfFile, MapViewOfFile3, PAGE_NOACCESS, PAGE_PROTECTION_FLAGS, PAGE_READONLY, + UnmapViewOfFile, VIRTUAL_ALLOCATION_TYPE, VIRTUAL_FREE_TYPE, VirtualAlloc2, VirtualFree, + VirtualProtect, }; #[cfg(target_os = "windows")] use windows::core::PCSTR; @@ -87,6 +90,25 @@ macro_rules! generate_writer { }; } +/// On Windows, [`HostMapping`]s come in two flavours that need +/// different teardown sequences. +#[cfg(target_os = "windows")] +#[derive(Debug, Clone, Copy)] +enum WinTeardown { + /// Created via `CreateFileMapping + MapViewOfFile`. The view base + /// is exactly `ptr` and the view extent is `size`. Drop calls + /// `UnmapViewOfFile(ptr)` and then `CloseHandle(handle)`. + Mapped, + /// Created via `VirtualAlloc2 + (split) + MapViewOfFile3`. A view + /// of size `size - 2 * PAGE_SIZE` lives at `ptr + PAGE_SIZE`, + /// inside a `[guard][view][guard]` layout that spans + /// `[ptr, ptr + size)`. After the placeholder split each of the + /// three slots is an independent allocation. Drop reconstructs + /// a [`MappedView`] for the middle and a [`Placeholder`] for + /// each guard so each slot is torn down by its own RAII type. + Placeholder, +} + /// A representation of a host mapping of a shared memory region, /// which will be released when this structure is Drop'd. This is not /// individually Clone (since it holds ownership of the mapping), or @@ -97,6 +119,8 @@ pub struct HostMapping { size: usize, #[cfg(target_os = "windows")] handle: HANDLE, + #[cfg(target_os = "windows")] + teardown: WinTeardown, } impl Drop for HostMapping { @@ -110,19 +134,45 @@ impl Drop for HostMapping { } #[cfg(target_os = "windows")] fn drop(&mut self) { - let mem_mapped_address = MEMORY_MAPPED_VIEW_ADDRESS { - Value: self.ptr as *mut c_void, - }; - if let Err(e) = unsafe { UnmapViewOfFile(mem_mapped_address) } { - tracing::error!( - "Failed to drop HostMapping (UnmapViewOfFile failed): {:?}", - e - ); + match self.teardown { + WinTeardown::Mapped => { + let mem_mapped_address = MEMORY_MAPPED_VIEW_ADDRESS { + Value: self.ptr as *mut c_void, + }; + if let Err(e) = unsafe { UnmapViewOfFile(mem_mapped_address) } { + tracing::error!( + "Failed to drop HostMapping (UnmapViewOfFile failed): {:?}", + e + ); + } + } + WinTeardown::Placeholder => { + // Reconstruct per-slot owners. Each Drop releases + // its own slot: + // * `MappedView` -> `UnmapViewOfFile` releases + // the middle view's address range. + // * `Placeholder` -> `VirtualFree(MEM_RELEASE)` + // releases its placeholder slot. + // The three slots are independent allocations, so + // drop order does not matter. + let _view = MappedView { + addr: self.ptr.wrapping_add(PAGE_SIZE_USIZE) as *mut c_void, + len: self.size - 2 * PAGE_SIZE_USIZE, + }; + let _trailing = Placeholder { + addr: self.ptr.wrapping_add(self.size - PAGE_SIZE_USIZE) as *mut c_void, + size: PAGE_SIZE_USIZE, + }; + let _leading = Placeholder { + addr: self.ptr as *mut c_void, + size: PAGE_SIZE_USIZE, + }; + } } let file_handle: HANDLE = self.handle; if let Err(e) = unsafe { CloseHandle(file_handle) } { - tracing::error!("Failed to drop HostMapping (CloseHandle failed): {:?}", e); + tracing::error!("Failed to drop HostMapping (CloseHandle failed): {:?}", e); } } } @@ -539,6 +589,7 @@ impl ExclusiveSharedMemory { ptr: addr.Value as *mut u8, size: total_size, handle, + teardown: WinTeardown::Mapped, }), }) } @@ -2026,25 +2077,243 @@ pub struct ReadonlySharedMemory { unsafe impl Send for ReadonlySharedMemory {} unsafe impl Sync for ReadonlySharedMemory {} -impl ReadonlySharedMemory { - pub(crate) fn from_bytes(contents: &[u8]) -> Result { - let mut anon = ExclusiveSharedMemory::new(contents.len())?; - anon.copy_from_slice(contents, 0)?; - Ok(ReadonlySharedMemory { - region: anon.region, - guest_mapped_size: None, - }) +/// RAII guard for an `mmap` reservation. Calls `munmap` on drop. +#[cfg(target_os = "linux")] +struct MmapGuard { + base: *mut c_void, + len: usize, +} + +#[cfg(target_os = "linux")] +impl MmapGuard { + /// Consume the guard without unmapping and return the base pointer. + fn into_raw(self) -> *mut c_void { + std::mem::ManuallyDrop::new(self).base } +} - pub(crate) fn from_bytes_with_mapped_size( - contents: &[u8], - guest_mapped_size: usize, - ) -> Result { +#[cfg(target_os = "linux")] +impl Drop for MmapGuard { + fn drop(&mut self) { + unsafe { + if libc::munmap(self.base, self.len) != 0 { + tracing::error!( + "MmapGuard::drop: munmap failed: {:?}", + std::io::Error::last_os_error() + ); + } + } + } +} + +/// A `VirtualAlloc2(MEM_RESERVE_PLACEHOLDER)` placeholder slot. +/// +/// `Placeholder` is a linear, owning handle to a single placeholder +/// allocation. State transitions consume `self`, so: +/// +/// * a failure between `reserve` and the final transfer to +/// [`HostMapping`] tears the placeholder down automatically via +/// `Drop`, +/// * after a successful `split_front` or `map_file_view`, the +/// parent value is forgotten and only the new owners (two child +/// placeholders or a [`MappedView`]) can be released. +/// +/// On `Drop`, the slot is released with +/// `VirtualFree(addr, 0, MEM_RELEASE)`. Each post-split slot is an +/// independent allocation, so each must be owned by its own +/// `Placeholder`. +#[cfg(target_os = "windows")] +struct Placeholder { + addr: *mut c_void, + size: usize, +} + +#[cfg(target_os = "windows")] +impl Placeholder { + /// Reserve a fresh placeholder of `size` bytes at an OS-chosen + /// address. + unsafe fn reserve(size: usize) -> Result { + let addr = unsafe { + VirtualAlloc2( + None, + None, + size, + VIRTUAL_ALLOCATION_TYPE(MEM_RESERVE.0 | MEM_RESERVE_PLACEHOLDER.0), + PAGE_NOACCESS.0, + None, + ) + }; + if addr.is_null() { + log_then_return!(HyperlightError::MemoryAllocationFailed( + Error::last_os_error().raw_os_error() + )); + } + Ok(Placeholder { addr, size }) + } + + /// Split `front_size` bytes off the front. Consumes `self` and + /// returns two adjacent placeholders that together cover the + /// same range. Releases the original via `Drop` if the underlying + /// `VirtualFree` split call fails. + unsafe fn split_front(self, front_size: usize) -> Result<(Placeholder, Placeholder)> { + debug_assert!(front_size > 0 && front_size < self.size); + debug_assert!(front_size.is_multiple_of(PAGE_SIZE_USIZE)); + // `MEM_RELEASE | MEM_PRESERVE_PLACEHOLDER` is the Win32 idiom for + // splitting one placeholder into two. Despite the flag name, no + // memory is released. Both ranges remain reserved as independent + // placeholders. + if let Err(e) = unsafe { + VirtualFree( + self.addr, + front_size, + VIRTUAL_FREE_TYPE(MEM_RELEASE.0 | MEM_PRESERVE_PLACEHOLDER.0), + ) + } { + // `self` drops here, releasing the unsplit reservation. + log_then_return!(WindowsAPIError(e.clone())); + } + let addr = self.addr; + let total = self.size; + // The single allocation is now two adjacent allocations. + // Forget the parent so its `Drop` does not try to release + // them as one. + std::mem::forget(self); + let front = Placeholder { + addr, + size: front_size, + }; + let back = Placeholder { + addr: unsafe { (addr as *mut u8).add(front_size) as *mut c_void }, + size: total - front_size, + }; + Ok((front, back)) + } + + /// Carve the placeholder into three adjacent slots of sizes + /// `front_size`, `middle_size`, and `self.size - front_size - + /// middle_size`. Consumes `self`. + unsafe fn split_into_three( + self, + front_size: usize, + middle_size: usize, + ) -> Result<(Placeholder, Placeholder, Placeholder)> { + let (front, rest) = unsafe { self.split_front(front_size) }?; + let (middle, back) = unsafe { rest.split_front(middle_size) }?; + Ok((front, middle, back)) + } + + /// Replace the placeholder with a read-only view of `section` + /// covering exactly the placeholder's range. Consumes `self` + /// and yields a [`MappedView`] that owns the slot. + unsafe fn map_file_view(self, section: HANDLE) -> Result { + let mapped = unsafe { + MapViewOfFile3( + section, + None, + Some(self.addr), + 0, + self.size, + MEM_REPLACE_PLACEHOLDER, + PAGE_READONLY.0, + None, + ) + }; + if mapped.Value.is_null() { + // `self` drops here, releasing the placeholder. + log_then_return!(HyperlightError::MemoryAllocationFailed( + Error::last_os_error().raw_os_error() + )); + } + let addr = self.addr; + let len = self.size; + std::mem::forget(self); + Ok(MappedView { addr, len }) + } +} + +#[cfg(target_os = "windows")] +impl Drop for Placeholder { + fn drop(&mut self) { + if let Err(e) = unsafe { VirtualFree(self.addr, 0, VIRTUAL_FREE_TYPE(MEM_RELEASE.0)) } { + tracing::error!( + "Placeholder::drop(addr={:?}, size={}) VirtualFree failed: {:?}", + self.addr, + self.size, + e + ); + } + } +} + +/// A read-only file-mapping view that occupies one former placeholder +/// slot. On `Drop`, the view is unmapped with `UnmapViewOfFile`, +/// which releases its address range back to the OS (see the +/// `VirtualAlloc2` ring-buffer example in MSDN). +#[cfg(target_os = "windows")] +struct MappedView { + addr: *mut c_void, + len: usize, +} + +#[cfg(target_os = "windows")] +impl Drop for MappedView { + fn drop(&mut self) { + let view = MEMORY_MAPPED_VIEW_ADDRESS { Value: self.addr }; + if let Err(e) = unsafe { UnmapViewOfFile(view) } { + tracing::error!( + "MappedView::drop(addr={:?}, len={}) UnmapViewOfFile failed: {:?}", + self.addr, + self.len, + e + ); + } + } +} + +/// RAII guard for a Win32 `HANDLE`. Calls `CloseHandle` on drop. +#[cfg(target_os = "windows")] +struct HandleGuard(HANDLE); + +#[cfg(target_os = "windows")] +impl HandleGuard { + /// Consume the guard without closing and return the raw handle. + fn into_raw(self) -> HANDLE { + std::mem::ManuallyDrop::new(self).0 + } +} + +#[cfg(target_os = "windows")] +impl Drop for HandleGuard { + fn drop(&mut self) { + unsafe { + if let Err(e) = CloseHandle(self.0) { + tracing::error!("HandleGuard::drop: CloseHandle failed: {:?}", e); + } + } + } +} + +impl ReadonlySharedMemory { + /// Create a `ReadonlySharedMemory` from an in-memory byte slice. + /// + /// `guest_mapped_size` is the number of bytes from the start of + /// the blob that should be exposed to the guest. `None` means + /// expose the full blob. + pub(crate) fn from_bytes(contents: &[u8], guest_mapped_size: Option) -> Result { + if let Some(gms) = guest_mapped_size + && gms > contents.len() + { + return Err(new_error!( + "guest_mapped_size {} exceeds blob length {}", + gms, + contents.len() + )); + } let mut anon = ExclusiveSharedMemory::new(contents.len())?; anon.copy_from_slice(contents, 0)?; Ok(ReadonlySharedMemory { region: anon.region, - guest_mapped_size: Some(guest_mapped_size), + guest_mapped_size, }) } @@ -2055,6 +2324,186 @@ impl ReadonlySharedMemory { self.guest_mapped_size.unwrap_or_else(|| self.mem_size()) } + /// Create a `ReadonlySharedMemory` backed by a file on disk. + /// + /// The entire file is treated as a raw memory blob: its contents + /// are exposed via `base_ptr()` and `mem_size()`, surrounded on + /// the host by `PAGE_SIZE` guard pages that are not part of the + /// file. + /// + /// The file's length must be a non-zero multiple of `PAGE_SIZE`. + /// If `guest_mapped_size` is set, it must be a non-zero multiple + /// of `PAGE_SIZE` no greater than the file's length. + pub(crate) fn from_file( + file: &std::fs::File, + guest_mapped_size: Option, + ) -> Result { + let len: usize = file + .metadata() + .map_err(|e| new_error!("Failed to read file metadata: {}", e))? + .len() + .try_into() + .map_err(|_| new_error!("File length exceeds usize::MAX"))?; + + if len == 0 { + return Err(new_error!( + "Cannot create file-backed shared memory with size 0" + )); + } + + if !len.is_multiple_of(PAGE_SIZE_USIZE) { + return Err(new_error!( + "file length {} must be a multiple of PAGE_SIZE", + len + )); + } + + if let Some(gms) = guest_mapped_size + && (gms == 0 || gms > len || !gms.is_multiple_of(PAGE_SIZE_USIZE)) + { + return Err(new_error!( + "guest_mapped_size {} must be a non-zero multiple of PAGE_SIZE no greater than file length {}", + gms, + len + )); + } + + let region = Self::map_file(file, len)?; + Ok(ReadonlySharedMemory { + region, + guest_mapped_size, + }) + } + + /// Linux: reserve `[guard][blob][guard]` as one anonymous + /// `PROT_NONE` mapping, then `MAP_FIXED` the file over the + /// middle slot. + #[cfg(target_os = "linux")] + fn map_file(file: &std::fs::File, len: usize) -> Result> { + use std::os::unix::io::AsRawFd; + + use libc::{ + MAP_ANONYMOUS, MAP_FAILED, MAP_FIXED, MAP_NORESERVE, MAP_PRIVATE, PROT_NONE, PROT_READ, + PROT_WRITE, mmap, off_t, size_t, + }; + + let total_size = len.checked_add(2 * PAGE_SIZE_USIZE).ok_or_else(|| { + new_error!("Memory required for file-backed mapping exceeded usize::MAX") + })?; + + let fd = file.as_raw_fd(); + + // Allocate the full region (guard + usable + guard) as anonymous. + let base = unsafe { + mmap( + null_mut(), + total_size as size_t, + PROT_NONE, + MAP_ANONYMOUS | MAP_PRIVATE | MAP_NORESERVE, + -1, + 0 as off_t, + ) + }; + if base == MAP_FAILED { + return Err(HyperlightError::MmapFailed( + std::io::Error::last_os_error().raw_os_error(), + )); + } + // Hand the reservation to a guard so any early return releases it. + let reservation = MmapGuard { + base, + len: total_size, + }; + + // Map the file content over the usable portion (between guard pages). + // PROT_READ | PROT_WRITE: KVM/MSHV require writable host mappings + // to handle copy-on-write page faults from the guest. + // MAP_PRIVATE: writes go to private copies, not the file. + let usable_ptr = unsafe { (base as *mut u8).add(PAGE_SIZE_USIZE) }; + let mapped = unsafe { + mmap( + usable_ptr as *mut c_void, + len as size_t, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_FIXED | MAP_NORESERVE, + fd, + 0 as off_t, + ) + }; + if mapped == MAP_FAILED { + return Err(HyperlightError::MmapFailed( + std::io::Error::last_os_error().raw_os_error(), + )); + } + + // Guard pages at base and base+total_size-PAGE_SIZE are already + // PROT_NONE from the anonymous mapping; MAP_FIXED only replaced + // the middle portion. + + #[allow(clippy::arc_with_non_send_sync)] + Ok(Arc::new(HostMapping { + ptr: reservation.into_raw() as *mut u8, + size: total_size, + })) + } + + /// Windows: reserve `[guard][blob][guard]` as one + /// `VirtualAlloc2` placeholder, split the middle slot out, and + /// `MapViewOfFile3` the file over the middle slot. + #[cfg(target_os = "windows")] + fn map_file(file: &std::fs::File, len: usize) -> Result> { + use std::os::windows::io::AsRawHandle; + + let total_size = len.checked_add(2 * PAGE_SIZE_USIZE).ok_or_else(|| { + new_error!("Memory required for file-backed mapping exceeded usize::MAX") + })?; + + let file_handle = HANDLE(file.as_raw_handle()); + + // 1. Reserve [guard][view][guard] as one placeholder. + let whole = unsafe { Placeholder::reserve(total_size) }?; + + // 2. Carve into three adjacent placeholders. A failure + // after this point owns exactly the right slots and + // cleans them up via `Drop`. + let (leading, middle, trailing) = unsafe { whole.split_into_three(PAGE_SIZE_USIZE, len) }?; + + // 3. Create a read-only section covering the entire file. + let raw_handle = + unsafe { CreateFileMappingA(file_handle, None, PAGE_READONLY, 0, 0, PCSTR::null()) }?; + if raw_handle.is_invalid() { + log_then_return!(HyperlightError::MemoryAllocationFailed( + Error::last_os_error().raw_os_error() + )); + } + let section = HandleGuard(raw_handle); + + // 4. Replace the middle placeholder with a view of the file. + let view = unsafe { middle.map_file_view(raw_handle) }?; + + // Success. Transfer ownership of every slot into + // `HostMapping`; its `Drop` reconstructs the per-slot + // owners and releases them. + let base = leading.addr as *mut u8; + debug_assert_eq!(view.addr, unsafe { + base.add(PAGE_SIZE_USIZE) as *mut c_void + }); + debug_assert_eq!(trailing.addr, unsafe { + base.add(PAGE_SIZE_USIZE + len) as *mut c_void + }); + std::mem::forget(leading); + std::mem::forget(view); + std::mem::forget(trailing); + + #[allow(clippy::arc_with_non_send_sync)] + Ok(Arc::new(HostMapping { + ptr: base, + size: total_size, + handle: section.into_raw(), + teardown: WinTeardown::Placeholder, + })) + } + pub(crate) fn as_slice(&self) -> &[u8] { unsafe { std::slice::from_raw_parts(self.base_ptr(), self.mem_size()) } } @@ -2098,6 +2547,42 @@ impl SharedMemory for ReadonlySharedMemory { fn region(&self) -> &HostMapping { &self.region } + // The trait defaults for `base_addr`, `base_ptr`, and `mem_size` + // already do the right thing: every `ReadonlySharedMemory` is + // backed by a `[guard][blob][guard]` host region, so the blob + // starts at `region.ptr + PAGE_SIZE` and runs for + // `region.size - 2 * PAGE_SIZE` bytes. + // + // `host_region_base` differs per Windows mapping flavour: + // + // * `WinTeardown::Mapped` (in-memory `from_bytes*`, backed by + // an anonymous `CreateFileMapping`). The file mapping section + // spans the entire `[guard][blob][guard]` reservation, so the + // surrogate maps the whole section and we hand out + // `handle_base = region.ptr`, `handle_size = region.size`, + // `offset = PAGE_SIZE`. Same as the trait default. + // + // * `WinTeardown::Placeholder` (`from_file`). The file mapping + // section covers only the blob, with the host's guard pages + // held as `VirtualAlloc2` placeholders in the surrounding + // address space. Expose only the blob to the surrogate. + #[cfg(windows)] + fn host_region_base(&self) -> ::HostBaseType { + match self.region().teardown { + WinTeardown::Mapped => super::memory_region::HostRegionBase { + from_handle: self.region().handle.into(), + handle_base: self.region().ptr as usize, + handle_size: self.region().size, + offset: PAGE_SIZE_USIZE, + }, + WinTeardown::Placeholder => super::memory_region::HostRegionBase { + from_handle: self.region().handle.into(), + handle_base: self.base_ptr() as usize, + handle_size: self.mem_size(), + offset: 0, + }, + } + } // There's no way to get exclusive (and therefore writable) access // to a ReadonlySharedMemory. fn with_exclusivity T>( diff --git a/src/hyperlight_host/src/sandbox/host_funcs.rs b/src/hyperlight_host/src/sandbox/host_funcs.rs index a1430338b..c271e6c17 100644 --- a/src/hyperlight_host/src/sandbox/host_funcs.rs +++ b/src/hyperlight_host/src/sandbox/host_funcs.rs @@ -35,8 +35,80 @@ pub struct FunctionRegistry { functions_map: HashMap, } -impl From<&mut FunctionRegistry> for HostFunctionDetails { - fn from(registry: &mut FunctionRegistry) -> Self { +/// A collection of host functions that can be supplied to a sandbox +/// constructor (e.g. [`crate::MultiUseSandbox::from_snapshot`]) to +/// expose host-side functionality to the guest. +/// +/// Use [`HostFunctions::default`] to start with the standard +/// `HostPrint` function pre-registered (matches the registry that the +/// regular `UninitializedSandbox` → `evolve()` path constructs), or +/// [`HostFunctions::empty`] to start with an empty registry. +/// +/// Add additional host functions via the +/// [`crate::func::Registerable`] trait, just as you would on an +/// `UninitializedSandbox`. +/// +/// ```no_run +/// # use hyperlight_host::{HostFunctions, Result}; +/// # use hyperlight_host::func::Registerable; +/// # fn example() -> Result<()> { +/// // Default: HostPrint already registered. +/// let mut funcs = HostFunctions::default(); +/// funcs.register_host_function("Add", |a: i32, b: i32| Ok(a + b))?; +/// # Ok(()) +/// # } +/// ``` +pub struct HostFunctions(FunctionRegistry); + +impl HostFunctions { + /// Create an empty `HostFunctions` with no host functions + /// registered. + /// + /// Most callers want [`HostFunctions::default`] instead, which + /// pre-registers the standard `HostPrint` function. An empty + /// registry will fail snapshot validation against any snapshot + /// that captured `HostPrint`, and any guest code that tries to + /// `printf` into an empty registry will get an EIO from + /// `write(2)`. + pub fn empty() -> Self { + Self(FunctionRegistry::default()) + } + + /// Consume this `HostFunctions` and return the inner registry. + pub(crate) fn into_inner(self) -> FunctionRegistry { + self.0 + } + + /// Borrow the inner registry mutably. + pub(crate) fn inner_mut(&mut self) -> &mut FunctionRegistry { + &mut self.0 + } + + /// Borrow the inner registry immutably. + pub(crate) fn inner(&self) -> &FunctionRegistry { + &self.0 + } +} + +impl Default for HostFunctions { + /// Create a `HostFunctions` pre-populated with the standard + /// `HostPrint` function (writes UTF-8 strings to the host's + /// stdout in green). + /// + /// This matches the default registry installed by + /// `UninitializedSandbox::new()`, so a snapshot taken from a + /// regular sandbox can be loaded with + /// `MultiUseSandbox::from_snapshot(snap, HostFunctions::default(), None)` + /// without registering anything else. + /// + /// Use [`HostFunctions::empty`] for an empty registry. + fn default() -> Self { + Self(FunctionRegistry::with_default_host_print()) + } +} + +impl From<&FunctionRegistry> for HostFunctionDetails { + fn from(registry: &FunctionRegistry) -> Self { let host_functions = registry .functions_map .iter() @@ -61,15 +133,26 @@ pub struct FunctionEntry { impl FunctionRegistry { /// Register a host function with the sandbox. - #[instrument(err(Debug), skip_all, parent = Span::current(), level = "Trace")] - pub(crate) fn register_host_function( - &mut self, - name: String, - func: FunctionEntry, - ) -> Result<()> { + #[instrument(skip_all, parent = Span::current(), level = "Trace")] + pub(crate) fn register_host_function(&mut self, name: String, func: FunctionEntry) { self.functions_map.insert(name, func); + } - Ok(()) + /// Create a `FunctionRegistry` pre-populated with the default + /// `HostPrint` function (writes to stdout with green text). + pub(crate) fn with_default_host_print() -> Self { + use crate::func::host_functions::HostFunction; + use crate::func::{ParameterTuple, SupportedReturnType}; + + let mut registry = Self::default(); + let hf: HostFunction = default_writer_func.into(); + let entry = FunctionEntry { + function: hf.into(), + parameter_types: <(String,)>::TYPE, + return_type: ::TYPE, + }; + registry.register_host_function("HostPrint".to_string(), entry); + registry } /// Assuming a host function called `"HostPrint"` exists, and takes a @@ -118,7 +201,7 @@ impl FunctionRegistry { /// The default writer function is to write to stdout with green text. #[instrument(err(Debug), skip_all, parent = Span::current(), level = "Trace")] -pub(super) fn default_writer_func(s: String) -> Result { +fn default_writer_func(s: String) -> Result { match std::io::stdout().is_terminal() { false => { print!("{}", s); diff --git a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs index 241622cab..16135f245 100644 --- a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs +++ b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs @@ -93,7 +93,7 @@ pub struct MultiUseSandbox { dbg_mem_access_fn: Arc>>, /// If the current state of the sandbox has been captured in a snapshot, /// that snapshot is stored here. - snapshot: Option>, + pub(crate) snapshot: Option>, /// Optional callback to discover page table roots from guest memory. /// Given (snapshot_mem, scratch_mem, cr3), returns a list of root GPAs. /// If not set, only CR3 is used as the single root. @@ -145,6 +145,193 @@ impl MultiUseSandbox { self.pt_root_finder = Some(finder); } + /// Create a `MultiUseSandbox` directly from a [`Snapshot`], + /// bypassing [`UninitializedSandbox`](crate::UninitializedSandbox) + /// and [`evolve()`](crate::UninitializedSandbox::evolve). + /// + /// This is useful for fast sandbox creation when a snapshot of + /// an already-initialized guest is available, either saved to disk + /// or captured in memory from another sandbox. + /// + /// The provided [`HostFunctions`] must include every host function + /// that was registered on the sandbox at the time the snapshot was + /// taken (matched by name and signature). Additional host functions + /// not present in the snapshot are allowed. + /// + /// An optional [`SandboxConfiguration`](crate::sandbox::SandboxConfiguration) + /// can be supplied to override runtime settings such as timeouts and + /// interrupt behavior. Memory layout fields + /// (`input_data_size`, `output_data_size`, `heap_size`, `scratch_size`) + /// are always taken from the snapshot. Any values supplied in + /// `config` for those fields are ignored. + /// + /// # Examples + /// + /// From a snapshot taken on another sandbox: + /// + /// ```no_run + /// # use std::sync::Arc; + /// # use hyperlight_host::{HostFunctions, MultiUseSandbox, UninitializedSandbox, GuestBinary}; + /// # fn example() -> Result<(), Box> { + /// // Create and initialize a sandbox the normal way + /// let mut sandbox: MultiUseSandbox = UninitializedSandbox::new( + /// GuestBinary::FilePath("guest.bin".into()), + /// None, + /// )?.evolve()?; + /// + /// // Capture a snapshot of the initialized state + /// let snapshot = sandbox.snapshot()?; + /// + /// // Create a new sandbox directly from the snapshot + /// let mut sandbox2 = MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), None)?; + /// let result: i32 = sandbox2.call("GetValue", ())?; + /// # Ok(()) + /// # } + /// ``` + /// + /// From a snapshot loaded from disk: + /// + /// ```no_run + /// # use std::sync::Arc; + /// # use hyperlight_host::{HostFunctions, MultiUseSandbox}; + /// # use hyperlight_host::sandbox::snapshot::Snapshot; + /// # fn example() -> Result<(), Box> { + /// let snapshot = Arc::new(Snapshot::from_oci("./guest_snapshot", "latest")?); + /// let mut sandbox = MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), None)?; + /// let result: String = sandbox.call("Echo", "hello".to_string())?; + /// # Ok(()) + /// # } + /// ``` + #[instrument(err(Debug), skip_all, parent = Span::current(), level = "Trace")] + pub fn from_snapshot( + snapshot: Arc, + host_funcs: crate::HostFunctions, + config: Option, + ) -> Result { + use rand::RngExt; + + use crate::mem::ptr::RawPtr; + use crate::sandbox::uninitialized_evolve::set_up_hypervisor_partition; + + // Validate that the provided host functions are a superset of + // those required by the snapshot. + snapshot.validate_host_functions(&host_funcs)?; + + let host_funcs = Arc::new(Mutex::new(host_funcs.into_inner())); + + let stack_top_gva = snapshot.stack_top_gva(); + // Start from the caller's config (if any) so runtime fields + // such as timeouts and interrupt knobs are honored, then + // overwrite the layout fields from the snapshot. The on-disk + // layout is fixed, so any layout values supplied by the + // caller are silently ignored. Warn if the caller passed a + // config whose layout fields disagree with the snapshot, so + // the override is at least visible. + let caller_supplied_config = config.is_some(); + let mut config = config.unwrap_or_default(); + if caller_supplied_config { + warn_on_layout_override(&config, snapshot.layout()); + } + config.set_input_data_size(snapshot.layout().input_data_size); + config.set_output_data_size(snapshot.layout().output_data_size); + config.set_heap_size(snapshot.layout().heap_size as u64); + config.set_scratch_size(snapshot.layout().get_scratch_size()); + let load_info = snapshot.load_info(); + + let mgr = crate::mem::mgr::SandboxMemoryManager::from_snapshot(&snapshot)?; + let (mut hshm, gshm) = mgr.build()?; + + let page_size = u32::try_from(page_size::get())? as usize; + + #[cfg(target_os = "linux")] + crate::signal_handlers::setup_signal_handlers(&config)?; + + // Build the runtime config from the caller's `SandboxConfiguration` + // so that `guest_core_dump` (crashdump) and `guest_debug_info` (gdb) + // take effect just like they do in the normal evolve path. + // `binary_path` and `entry_point` are not available from a snapshot + // and are left unset. This only affects metadata in core dumps. + #[cfg(any(crashdump, gdb))] + let rt_cfg = crate::sandbox::uninitialized::SandboxRuntimeConfig { + #[cfg(crashdump)] + binary_path: None, + #[cfg(gdb)] + debug_info: config.get_guest_debug_info(), + #[cfg(crashdump)] + guest_core_dump: config.get_guest_core_dump(), + #[cfg(crashdump)] + entry_point: None, + }; + + let mut vm = set_up_hypervisor_partition( + gshm, + &config, + stack_top_gva, + page_size, + #[cfg(any(crashdump, gdb))] + rt_cfg, + load_info, + )?; + + let seed = { + let mut rng = rand::rng(); + rng.random::() + }; + let peb_addr = RawPtr::from(u64::try_from(hshm.layout.peb_address())?); + + #[cfg(gdb)] + let dbg_mem_access_hdl = Arc::new(Mutex::new(hshm.clone())); + + vm.initialise( + peb_addr, + seed, + page_size as u32, + &mut hshm, + &host_funcs, + None, + #[cfg(gdb)] + dbg_mem_access_hdl, + ) + .map_err(crate::hypervisor::hyperlight_vm::HyperlightVmError::Initialize)?; + + // If the snapshot was taken from an already-initialized guest + // (NextAction::Call), apply the captured special registers so + // the guest resumes in the correct CPU state. + #[cfg(not(feature = "i686-guest"))] + if matches!(snapshot.entrypoint(), super::snapshot::NextAction::Call(_)) { + let sregs = snapshot.sregs().ok_or_else(|| { + crate::new_error!("snapshot with NextAction::Call must have captured sregs") + })?; + vm.apply_sregs(hshm.layout.get_pt_base_gpa(), sregs) + .map_err(|e| { + crate::HyperlightError::HyperlightVmError( + crate::hypervisor::hyperlight_vm::HyperlightVmError::Restore(e), + ) + })?; + } + + #[cfg(gdb)] + let dbg_mem_wrapper = Arc::new(Mutex::new(hshm.clone())); + + let mut sbox = MultiUseSandbox::from_uninit( + host_funcs, + hshm, + vm, + #[cfg(gdb)] + dbg_mem_wrapper, + ); + // Use the snapshot's sandbox_id so that restore() back to this + // snapshot is permitted. The id is process-local and never + // persisted to disk: `Snapshot::from_oci` assigns a fresh id + // on every load, so two `from_file` calls of the same path + // yield restore-incompatible sandboxes (which is the intended + // safer default). Sandboxes built from clones of the same + // in-memory `Arc` share the id and are mutually + // restore-compatible. + sbox.id = snapshot.sandbox_id(); + Ok(sbox) + } + /// Creates a snapshot of the sandbox's current memory state. /// /// The snapshot is tied to this specific sandbox instance and can only be @@ -207,6 +394,11 @@ impl MultiUseSandbox { .get_snapshot_sregs() .map_err(|e| HyperlightError::HyperlightVmError(e.into()))?; let entrypoint = self.vm.get_entrypoint(); + let host_functions = (&*self.host_funcs.try_lock().map_err(|e| { + crate::new_error!("Error locking host_funcs at {}:{}: {}", file!(), line!(), e) + })?) + .into(); + let memory_snapshot = self.mem_mgr.snapshot( self.id, mapped_regions_vec, @@ -214,6 +406,7 @@ impl MultiUseSandbox { stack_top_gpa, sregs, entrypoint, + host_functions, )?; let snapshot = Arc::new(memory_snapshot); self.snapshot = Some(snapshot.clone()); @@ -943,6 +1136,48 @@ impl std::fmt::Debug for MultiUseSandbox { } } +/// Emit a warning for each memory-layout field in `caller` that +/// disagrees with `snapshot`. Used by [`MultiUseSandbox::from_snapshot`] +/// to surface ignored caller-supplied layout values, since those +/// fields are always taken from the snapshot. +fn warn_on_layout_override( + caller: &crate::sandbox::SandboxConfiguration, + snapshot: &crate::mem::layout::SandboxMemoryLayout, +) { + let mismatches: &[(&str, u64, u64)] = &[ + ( + "input_data_size", + caller.get_input_data_size() as u64, + snapshot.input_data_size as u64, + ), + ( + "output_data_size", + caller.get_output_data_size() as u64, + snapshot.output_data_size as u64, + ), + ( + "heap_size", + caller.get_heap_size(), + snapshot.heap_size as u64, + ), + ( + "scratch_size", + caller.get_scratch_size() as u64, + snapshot.get_scratch_size() as u64, + ), + ]; + for (name, supplied, snap) in mismatches { + if supplied != snap { + tracing::warn!( + "from_snapshot ignoring caller-supplied {} ({}); using snapshot value ({})", + name, + supplied, + snap + ); + } + } +} + #[cfg(test)] mod tests { use std::sync::{Arc, Barrier}; @@ -2588,4 +2823,244 @@ mod tests { } let _ = std::fs::remove_file(&path); } + + /// Tests for [`MultiUseSandbox::from_snapshot`] in-memory. + mod from_snapshot { + use std::sync::Arc; + + use hyperlight_testing::simple_guest_as_string; + + use crate::func::Registerable; + use crate::sandbox::SandboxConfiguration; + use crate::sandbox::snapshot::Snapshot; + use crate::{GuestBinary, HostFunctions, MultiUseSandbox, UninitializedSandbox}; + + fn make_sandbox() -> MultiUseSandbox { + let path = simple_guest_as_string().unwrap(); + UninitializedSandbox::new(GuestBinary::FilePath(path), None) + .unwrap() + .evolve() + .unwrap() + } + + /// Sandbox with an extra `Add(i32, i32) -> i32` host function. + fn make_sandbox_with_add() -> MultiUseSandbox { + let path = simple_guest_as_string().unwrap(); + let mut u = UninitializedSandbox::new(GuestBinary::FilePath(path), None).unwrap(); + u.register_host_function("Add", |a: i32, b: i32| Ok(a + b)) + .unwrap(); + u.evolve().unwrap() + } + + fn host_funcs_with_matching_add() -> HostFunctions { + let mut hf = HostFunctions::default(); + hf.register_host_function("Add", |a: i32, b: i32| Ok(a + b)) + .unwrap(); + hf + } + + #[test] + fn round_trip_running_sandbox() { + let mut sbox = make_sandbox(); + sbox.call::("AddToStatic", 11i32).unwrap(); + let snapshot = sbox.snapshot().unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), None).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 11); + let echoed: String = sbox2.call("Echo", "hi".to_string()).unwrap(); + assert_eq!(echoed, "hi"); + } + + #[test] + fn round_trip_pre_init_snapshot() { + let path = simple_guest_as_string().unwrap(); + let snap = + Snapshot::from_env(GuestBinary::FilePath(path), SandboxConfiguration::default()) + .unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(snap), HostFunctions::default(), None) + .unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + } + + /// Sandboxes built from clones of one `Arc` share + /// `sandbox_id` (so both can `restore` to it) but are + /// memory-isolated from each other. + #[test] + fn arc_clone_isolation_and_restore_compat() { + let mut sbox = make_sandbox(); + sbox.call::("AddToStatic", 3i32).unwrap(); + let snapshot = sbox.snapshot().unwrap(); + + let mut a = + MultiUseSandbox::from_snapshot(snapshot.clone(), HostFunctions::default(), None) + .unwrap(); + let mut b = + MultiUseSandbox::from_snapshot(snapshot.clone(), HostFunctions::default(), None) + .unwrap(); + assert_eq!(a.call::("GetStatic", ()).unwrap(), 3); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 3); + + a.call::("AddToStatic", 7i32).unwrap(); + assert_eq!(a.call::("GetStatic", ()).unwrap(), 10); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 3); + + a.restore(snapshot.clone()).unwrap(); + b.restore(snapshot).unwrap(); + assert_eq!(a.call::("GetStatic", ()).unwrap(), 3); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 3); + } + + #[test] + fn accepts_matching_host_functions() { + let mut sbox = make_sandbox_with_add(); + sbox.call::("AddToStatic", 5i32).unwrap(); + let snap = sbox.snapshot().unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(snap, host_funcs_with_matching_add(), None).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 5); + } + + #[test] + fn rejects_missing_host_function() { + let mut sbox = make_sandbox_with_add(); + let snap = sbox.snapshot().unwrap(); + let err = MultiUseSandbox::from_snapshot(snap, HostFunctions::default(), None) + .expect_err("missing `Add` must be rejected"); + let msg = format!("{}", err); + assert!(msg.contains("Add"), "got: {}", msg); + } + + #[test] + fn rejects_signature_mismatch() { + let mut sbox = make_sandbox_with_add(); + let snap = sbox.snapshot().unwrap(); + let mut hf = HostFunctions::default(); + hf.register_host_function("Add", |a: String, b: String| Ok(format!("{a}{b}"))) + .unwrap(); + let err = MultiUseSandbox::from_snapshot(snap, hf, None) + .expect_err("signature mismatch on `Add` must be rejected"); + let msg = format!("{}", err); + assert!(msg.contains("Add"), "got: {}", msg); + } + + /// Supplied host-function set may be a strict superset of the + /// snapshot's required set. + #[test] + fn accepts_extra_host_functions() { + let mut sbox = make_sandbox_with_add(); + sbox.call::("AddToStatic", 9i32).unwrap(); + let snap = sbox.snapshot().unwrap(); + let mut hf = host_funcs_with_matching_add(); + hf.register_host_function("Mul", |a: i32, b: i32| Ok(a * b)) + .unwrap(); + let mut sbox2 = MultiUseSandbox::from_snapshot(snap, hf, None).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 9); + } + + /// A sandbox built via `from_snapshot` can itself be snapshotted + /// and restored, and its snapshots are restore-compatible with it. + #[test] + fn re_snapshot_after_from_snapshot() { + let mut sbox = make_sandbox(); + sbox.call::("AddToStatic", 4i32).unwrap(); + let snap1 = sbox.snapshot().unwrap(); + + let mut sbox2 = + MultiUseSandbox::from_snapshot(snap1, HostFunctions::default(), None).unwrap(); + sbox2.call::("AddToStatic", 6i32).unwrap(); + let snap2 = sbox2.snapshot().unwrap(); + + sbox2.call::("AddToStatic", 100i32).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 110); + + sbox2.restore(snap2.clone()).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 10); + + let mut sbox3 = + MultiUseSandbox::from_snapshot(snap2, HostFunctions::default(), None).unwrap(); + assert_eq!(sbox3.call::("GetStatic", ()).unwrap(), 10); + } + + /// The host function closure supplied to `from_snapshot` (not the + /// original sandbox's closure) is the one invoked at runtime. + #[test] + fn supplied_host_function_is_callable() { + let path = simple_guest_as_string().unwrap(); + let mut u = UninitializedSandbox::new(GuestBinary::FilePath(path), None).unwrap(); + u.register_host_function("Echo42", || Ok(1i64)).unwrap(); + let mut sbox = u.evolve().unwrap(); + let snap = sbox.snapshot().unwrap(); + + let mut hf = HostFunctions::default(); + hf.register_host_function("Echo42", || Ok(42i64)).unwrap(); + let mut sbox2 = MultiUseSandbox::from_snapshot(snap, hf, None).unwrap(); + + let got: i64 = sbox2 + .call( + "CallGivenParamlessHostFuncThatReturnsI64", + "Echo42".to_string(), + ) + .unwrap(); + assert_eq!(got, 42); + } + + /// Pre-init snapshots record no required host functions, so any + /// `HostFunctions` set is accepted. + #[test] + fn pre_init_snapshot_accepts_arbitrary_host_functions() { + let path = simple_guest_as_string().unwrap(); + let snap = + Snapshot::from_env(GuestBinary::FilePath(path), SandboxConfiguration::default()) + .unwrap(); + let mut hf = HostFunctions::default(); + hf.register_host_function("Unrelated", |a: i32| Ok(a + 1)) + .unwrap(); + let mut sbox = MultiUseSandbox::from_snapshot(Arc::new(snap), hf, None).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + } + + /// Snapshots taken from a sandbox built via `from_snapshot` + /// must continue the generation counter of the snapshot they + /// were constructed from, matching `restore`. + #[test] + fn snapshot_generation_propagates() { + let mut sbox = make_sandbox(); + sbox.call::("AddToStatic", 1i32).unwrap(); + let snap1 = sbox.snapshot().unwrap(); + let gen1 = snap1.snapshot_generation(); + sbox.call::("AddToStatic", 1i32).unwrap(); + let snap2 = sbox.snapshot().unwrap(); + let gen2 = snap2.snapshot_generation(); + assert_eq!(gen2, gen1 + 1); + + let mut sbox2 = + MultiUseSandbox::from_snapshot(snap2, HostFunctions::default(), None).unwrap(); + sbox2.call::("AddToStatic", 1i32).unwrap(); + let snap3 = sbox2.snapshot().unwrap(); + assert_eq!(snap3.snapshot_generation(), gen2 + 1); + } + + /// Registering a host function on an already-evolved + /// `MultiUseSandbox` must invalidate its cached snapshot, so + /// that the next `snapshot()` reflects the new required + /// host-function set. + #[test] + fn late_register_invalidates_snapshot_cache() { + let mut sbox = make_sandbox(); + // Force a cached snapshot to exist. + let _ = sbox.snapshot().unwrap(); + + sbox.register_host_function("Echo42", || Ok(42i64)).unwrap(); + + // The next snapshot must include `Echo42` as a required + // host function, so building a sandbox from it without + // `Echo42` must fail. + let snap = sbox.snapshot().unwrap(); + let err = MultiUseSandbox::from_snapshot(snap, HostFunctions::default(), None) + .expect_err("late-registered `Echo42` must be required by the new snapshot"); + let msg = format!("{}", err); + assert!(msg.contains("Echo42"), "got: {}", msg); + } + } } diff --git a/src/hyperlight_host/src/sandbox/snapshot/file.rs b/src/hyperlight_host/src/sandbox/snapshot/file.rs new file mode 100644 index 000000000..53a5171e5 --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file.rs @@ -0,0 +1,1392 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! On-disk snapshot format: an OCI Image Layout directory. +//! +//! Layout produced by `Snapshot::to_oci`: +//! +//! ```text +//! path/ +//! oci-layout {"imageLayoutVersion":"1.0.0"} +//! index.json one descriptor per tagged snapshot, +//! each tagged via the OCI standard +//! `org.opencontainers.image.ref.name` +//! annotation +//! blobs/sha256/ +//! OCI image manifest JSON +//! Hyperlight config JSON +//! raw memory bytes +//! (`memory_size` bytes) +//! ``` +//! +//! ## Multiple snapshots per layout +//! +//! A single layout directory can hold any number of snapshots, each +//! addressed by tag. Adding a snapshot to an existing layout is the +//! point of OCI Image Layout: blobs are content-addressed by sha256, +//! so two snapshots that share bytes (a common base, an identical +//! config, and so on) share files in `blobs/sha256/`. This is how +//! the format dovetails with `oras cp` / registry pull-push pipelines +//! that move many tagged snapshots through one store. +//! +//! ## Strictness boundary +//! +//! The Hyperlight config blob (`HlConfig` and friends) uses +//! `#[serde(deny_unknown_fields)]` everywhere: any unknown key is a +//! breaking media-type bump, by design. +//! +//! The OCI manifest and index JSON are parsed via `oci-spec`'s +//! `ImageManifest` / `ImageIndex`, which do **not** use +//! `deny_unknown_fields`. This is intentional: third-party tools +//! (cosign, ORAS, build pipelines) routinely add manifest- and +//! index-level annotations, and a sandbox image must continue to +//! load even when those extras are present. +//! +//! ## Snapshot blob layout +//! +//! The snapshot blob is the raw memory image: exactly +//! `memory_size` bytes. Guard pages live in the host mapping +//! around the blob: +//! +//! * On Linux, an anonymous `PROT_NONE` reservation surrounds a +//! `MAP_FIXED` file mapping in the middle. +//! * On Windows, a `VirtualAlloc2` placeholder reservation surrounds +//! a `MapViewOfFile3` view in the middle, with guard pages held as +//! reserved (but unmapped) address space. +//! +//! The blob is byte-identical across platforms, so sharing it +//! through OCI registries works unchanged. + +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; + +use hyperlight_common::flatbuffer_wrappers::function_types::{ParameterType, ReturnType}; +use hyperlight_common::flatbuffer_wrappers::host_function_definition::HostFunctionDefinition; +use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; +use hyperlight_common::vmem::PAGE_SIZE; +use oci_spec::image::{ + DescriptorBuilder, Digest, ImageIndex, ImageIndexBuilder, ImageManifest, ImageManifestBuilder, + MediaType, SCHEMA_VERSION, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +use super::{NextAction, SANDBOX_CONFIGURATION_COUNTER, Snapshot}; +use crate::hypervisor::regs::{CommonSegmentRegister, CommonSpecialRegisters, CommonTableRegister}; +use crate::mem::layout::SandboxMemoryLayout; +use crate::mem::memory_region::MemoryRegionFlags; +use crate::mem::shared_mem::{ReadonlySharedMemory, SharedMemory}; + +// --- Constants ------------------------------------------------------ + +const OCI_LAYOUT_VERSION: &str = "1.0.0"; + +// Media types are versioned by suffix. The loader matches each +// version specifically (no `_CURRENT` shortcut on the read side); the +// writer always emits `_CURRENT`. A new version is added by: +// +// 1. Declare `MT_FOO_V2` next to `MT_FOO_V1`. +// 2. Point `MT_FOO_CURRENT` at `MT_FOO_V2`. +// 3. Add a dispatch arm in the loader that converts v1 -> v2 (or +// rejects v1 if no compatibility window is offered). +const MT_CONFIG_V1: &str = "application/vnd.hyperlight.sandbox.config.v1+json"; +const MT_CONFIG_CURRENT: &str = MT_CONFIG_V1; +const MT_SNAPSHOT_V1: &str = "application/vnd.hyperlight.snapshot.v1"; +const MT_SNAPSHOT_CURRENT: &str = MT_SNAPSHOT_V1; + +/// ABI version for the snapshot memory blob. Bumped whenever the +/// host-guest contract for the bytes inside the snapshot blob changes +/// (PEB layout, calling convention, init state, etc.). Independent of +/// the config blob's media-type version. +const SNAPSHOT_ABI_VERSION: u32 = 1; + +/// Maximum size of the config JSON blob. Bounds the allocation done +/// before we parse the JSON. +const MAX_CONFIG_BLOB_SIZE: u64 = 1024 * 1024; + +impl Snapshot { + /// Save this snapshot as a tagged manifest inside an OCI Image + /// Layout directory at `path`. + /// + /// `tag` is written to `index.json` as + /// `org.opencontainers.image.ref.name` and must satisfy the OCI + /// tag grammar (`[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}`). + /// + /// If `path` does not exist, a new OCI layout is created. If it + /// exists, it must already be a valid OCI layout. Existing blobs + /// are deduplicated by digest. Existing tags are not overwritten. + /// + /// Save is not crash-atomic. If interrupted, delete the layout and + /// re-save. + /// + /// # Portability + /// + /// Snapshot images are not portable across CPU architectures, + /// hypervisors, or operating systems. All three are recorded in + /// the config blob and checked at load time; mismatches are + /// rejected with a clear error. + pub fn to_oci(&self, path: impl AsRef, tag: &str) -> crate::Result<()> { + let path = path.as_ref(); + validate_tag(tag)?; + + // Decide whether we are creating a fresh layout or appending + // to an existing one. + let appending = match std::fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() => { + // Directory exists. It must be a valid (empty or + // populated) OCI layout, otherwise we refuse to + // touch it. An empty directory is treated as + // not-a-layout to avoid quietly turning user data + // into a snapshot store. + if !read_oci_layout_marker(path)? { + return Err(crate::new_error!( + "to_oci refusing to write: {:?} exists but is not an OCI image layout \ + (no `oci-layout` marker). Remove it or choose a different path.", + path + )); + } + true + } + Ok(_) => { + return Err(crate::new_error!( + "to_oci refusing to write: path {:?} exists but is not a directory", + path + )); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => { + return Err(crate::new_error!("to_oci failed to stat {:?}: {}", path, e)); + } + }; + + // Load the existing index (if any) so we can detect a tag + // collision before doing any work. + let mut existing_manifests: Vec = if appending { + let idx = ImageIndex::from_file(path.join("index.json")).map_err(|e| { + crate::new_error!( + "to_oci: failed to read existing index.json at {:?}: {}", + path.join("index.json"), + e + ) + })?; + if let Some(existing) = idx.manifests().iter().find(|d| { + d.annotations() + .as_ref() + .and_then(|a| a.get(ANNOTATION_REF_NAME)) + .map(|s| s.as_str() == tag) + .unwrap_or(false) + }) { + return Err(crate::new_error!( + "to_oci refusing to overwrite tag {:?} in {:?} (existing digest {}). \ + Delete the tag first or use a different name.", + tag, + path, + existing.digest().as_ref() + )); + } + idx.manifests().clone() + } else { + Vec::new() + }; + + let blobs_dir = path.join("blobs").join("sha256"); + std::fs::create_dir_all(&blobs_dir) + .map_err(|e| crate::new_error!("failed to create OCI layout dir {:?}: {}", path, e))?; + + // 1. Snapshot blob: the raw memory bytes. Stream sha256 + // hasher over the bytes as we write, then rename to the + // digest filename. If a blob with the same digest + // already exists in this layout (another snapshot shares + // it), discard the temp file. + let memory_bytes = self.memory.as_slice(); + let memory_size = memory_bytes.len(); + if memory_size == 0 || memory_size % PAGE_SIZE != 0 { + return Err(crate::new_error!( + "snapshot memory size {} must be a non-zero multiple of PAGE_SIZE", + memory_size + )); + } + let blob_total = memory_size; + + let snapshot_digest = { + let tmp_path = blobs_dir.join(".tmp-snapshot"); + let mut f = std::fs::File::create(&tmp_path).map_err(|e| { + crate::new_error!("failed to create snapshot blob temp {:?}: {}", tmp_path, e) + })?; + let mut hasher = Sha256::new(); + + f.write_all(memory_bytes) + .map_err(|e| crate::new_error!("snapshot blob write error: {}", e))?; + hasher.update(memory_bytes); + + let digest = Digest256::from_hasher(hasher); + let final_path = blobs_dir.join(&digest.hex); + if final_path.exists() { + // Same content already on disk, contributed by an + // earlier tag. Discard the temp file. + let _ = std::fs::remove_file(&tmp_path); + } else { + std::fs::rename(&tmp_path, &final_path).map_err(|e| { + crate::new_error!( + "failed to rename snapshot blob {:?} -> {:?}: {}", + tmp_path, + final_path, + e + ) + })?; + } + digest + }; + + // 2. Config blob. + let cfg = self.build_config()?; + let cfg_bytes = serde_json::to_vec_pretty(&cfg) + .map_err(|e| crate::new_error!("failed to serialise config JSON: {}", e))?; + let cfg_digest = Digest256::from_bytes(&cfg_bytes); + write_blob_dedup(&blobs_dir, &cfg_digest.hex, &cfg_bytes)?; + + // 3. Manifest. + let config_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other(MT_CONFIG_CURRENT.to_string())) + .digest(oci_digest(&cfg_digest)?) + .size(cfg_bytes.len() as u64) + .build() + .map_err(|e| crate::new_error!("failed to build config descriptor: {}", e))?; + let snapshot_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other(MT_SNAPSHOT_CURRENT.to_string())) + .digest(oci_digest(&snapshot_digest)?) + .size(blob_total as u64) + .build() + .map_err(|e| crate::new_error!("failed to build snapshot descriptor: {}", e))?; + let manifest = ImageManifestBuilder::default() + .schema_version(SCHEMA_VERSION) + .media_type(MediaType::ImageManifest) + .config(config_descriptor) + .layers(vec![snapshot_descriptor]) + .build() + .map_err(|e| crate::new_error!("failed to build OCI manifest: {}", e))?; + let manifest_bytes = serde_json::to_vec_pretty(&manifest) + .map_err(|e| crate::new_error!("failed to serialise OCI manifest: {}", e))?; + let manifest_digest = Digest256::from_bytes(&manifest_bytes); + write_blob_dedup(&blobs_dir, &manifest_digest.hex, &manifest_bytes)?; + + // 4. Append manifest descriptor (with the tag annotation) + // to index.json. + let mut anns = std::collections::HashMap::new(); + anns.insert(ANNOTATION_REF_NAME.to_string(), tag.to_string()); + let manifest_descriptor = DescriptorBuilder::default() + .media_type(MediaType::ImageManifest) + .digest(oci_digest(&manifest_digest)?) + .size(manifest_bytes.len() as u64) + .annotations(anns) + .build() + .map_err(|e| crate::new_error!("failed to build manifest descriptor: {}", e))?; + existing_manifests.push(manifest_descriptor); + let index = ImageIndexBuilder::default() + .schema_version(SCHEMA_VERSION) + .media_type(MediaType::ImageIndex) + .manifests(existing_manifests) + .build() + .map_err(|e| crate::new_error!("failed to build OCI index: {}", e))?; + let index_bytes = serde_json::to_vec_pretty(&index) + .map_err(|e| crate::new_error!("failed to serialise OCI index: {}", e))?; + std::fs::write(path.join("index.json"), &index_bytes) + .map_err(|e| crate::new_error!("failed to write index.json: {}", e))?; + + // 5. oci-layout marker (idempotent: same content every time). + if !appending { + let layout_bytes = serde_json::to_vec(&serde_json::json!({ + "imageLayoutVersion": OCI_LAYOUT_VERSION, + })) + .map_err(|e| crate::new_error!("failed to serialise oci-layout: {}", e))?; + std::fs::write(path.join("oci-layout"), &layout_bytes) + .map_err(|e| crate::new_error!("failed to write oci-layout: {}", e))?; + } + + Ok(()) + } + + fn build_config(&self) -> crate::Result { + let entrypoint = match (self.entrypoint, self.sregs.as_ref()) { + (NextAction::Initialise(addr), None) => EntrypointRepr::Initialise { addr }, + (NextAction::Call(addr), Some(sregs)) => EntrypointRepr::Call { + addr, + sregs: Box::new(SregsRepr::from(sregs)), + }, + (NextAction::Initialise(_), Some(_)) => { + return Err(crate::new_error!( + "snapshot inconsistent: Initialise entrypoint must not have sregs" + )); + } + (NextAction::Call(_), None) => { + return Err(crate::new_error!( + "snapshot inconsistent: Call entrypoint must have sregs" + )); + } + #[cfg(test)] + (NextAction::None, _) => { + return Err(crate::new_error!( + "snapshot with NextAction::None cannot be persisted" + )); + } + }; + + let host_functions = match &self.host_functions.host_functions { + Some(v) => v.iter().map(HostFunctionRepr::from).collect(), + None => Vec::new(), + }; + + let l = &self.layout; + Ok(HlConfig { + hyperlight_version: env!("CARGO_PKG_VERSION").to_string(), + arch: ArchTag::current(), + abi_version: SNAPSHOT_ABI_VERSION, + hypervisor: HypervisorTag::current() + .ok_or_else(|| crate::new_error!("no hypervisor available to tag snapshot"))?, + stack_top_gva: self.stack_top_gva, + entrypoint, + layout: LayoutFields { + input_data_size: l.input_data_size, + output_data_size: l.output_data_size, + heap_size: l.heap_size, + code_size: l.code_size, + init_data_size: l.init_data_size, + init_data_permissions: l.init_data_permissions.map(|f| f.bits()), + scratch_size: l.get_scratch_size(), + snapshot_size: l.snapshot_size, + pt_size: l.pt_size, + }, + memory_size: self.memory.mem_size() as u64, + host_functions, + }) + } + + /// Load the snapshot tagged `tag` from an OCI Image Layout + /// directory at `path`. + /// + /// `tag` selects one manifest from `index.json` using + /// `org.opencontainers.image.ref.name`. Missing tags and duplicate + /// tags are rejected. + /// + /// This verifies sha256 for manifest, config, and snapshot blobs. + /// Use [`Snapshot::from_oci_unchecked`] to skip digest verification + /// in trusted paths. + /// + /// Returns an error for arch, hypervisor, OS, and ABI mismatches. + /// + /// # File-mutation hazard + /// + /// Do not modify or replace files in `path` while the returned + /// `Snapshot` (or sandboxes built from it) is still alive. + pub fn from_oci(path: impl AsRef, tag: &str) -> crate::Result { + Self::from_oci_inner(path.as_ref(), tag, true) + } + + /// Like [`Snapshot::from_oci`] but **skips sha256 verification of + /// the manifest, config, and snapshot blob bytes**, trading + /// integrity checking for performance. All other validation + /// (OCI structure, descriptor sizes, schema versions, arch / + /// hypervisor / ABI tags, layout bounds, entrypoint bounds) is + /// unchanged. + pub fn from_oci_unchecked(path: impl AsRef, tag: &str) -> crate::Result { + Self::from_oci_inner(path.as_ref(), tag, false) + } + + fn from_oci_inner(path: &Path, tag: &str, verify_blobs: bool) -> crate::Result { + validate_tag(tag)?; + let meta = std::fs::metadata(path) + .map_err(|e| crate::new_error!("from_oci failed to stat {:?}: {}", path, e))?; + if !meta.is_dir() { + return Err(crate::new_error!( + "from_oci path {:?} is not a directory", + path + )); + } + + // 1. oci-layout + let layout_bytes = std::fs::read(path.join("oci-layout")).map_err(|e| { + crate::new_error!( + "missing or unreadable oci-layout at {:?}: {}", + path.join("oci-layout"), + e + ) + })?; + let layout_json: serde_json::Value = serde_json::from_slice(&layout_bytes) + .map_err(|e| crate::new_error!("oci-layout is not valid JSON: {}", e))?; + let v = layout_json + .get("imageLayoutVersion") + .and_then(|v| v.as_str()) + .ok_or_else(|| crate::new_error!("oci-layout missing imageLayoutVersion field"))?; + if v != OCI_LAYOUT_VERSION { + return Err(crate::new_error!( + "unsupported OCI image layout version {:?} (expected {:?})", + v, + OCI_LAYOUT_VERSION + )); + } + + // 2. index.json -> manifest descriptor for `tag`. Multiple + // manifests are fine in OCI Image Layout; we select the + // one whose `org.opencontainers.image.ref.name` annotation + // matches the requested tag. Two manifests with the same + // tag is a malformed layout. + let index = ImageIndex::from_file(path.join("index.json")) + .map_err(|e| crate::new_error!("failed to read or parse index.json: {}", e))?; + let mut matching = index.manifests().iter().filter(|d| { + d.annotations() + .as_ref() + .and_then(|a| a.get(ANNOTATION_REF_NAME)) + .map(|s| s.as_str() == tag) + .unwrap_or(false) + }); + let manifest_desc = match (matching.next(), matching.next()) { + (None, _) => { + let known: Vec<&str> = index + .manifests() + .iter() + .filter_map(|d| { + d.annotations() + .as_ref() + .and_then(|a| a.get(ANNOTATION_REF_NAME)) + .map(|s| s.as_str()) + }) + .collect(); + return Err(crate::new_error!( + "no manifest tagged {:?} in OCI layout {:?}. Available tags: {:?}", + tag, + path, + known + )); + } + (Some(_), Some(_)) => { + return Err(crate::new_error!( + "OCI layout {:?} has multiple manifests tagged {:?}; tags must be unique", + path, + tag + )); + } + (Some(d), None) => d, + }; + let manifest_hex = parse_oci_digest(manifest_desc.digest().as_ref())?; + + // 3. manifest blob + let manifest_path = path.join("blobs").join("sha256").join(&manifest_hex); + let manifest_bytes = read_bounded(&manifest_path, MAX_CONFIG_BLOB_SIZE)?; + if manifest_bytes.len() as u64 != manifest_desc.size() { + return Err(crate::new_error!( + "OCI manifest size mismatch: descriptor says {}, file is {}", + manifest_desc.size(), + manifest_bytes.len() + )); + } + if verify_blobs { + verify_blob_bytes("manifest", &manifest_bytes, &manifest_hex)?; + } + let manifest: ImageManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|e| crate::new_error!("failed to parse OCI manifest JSON: {}", e))?; + if manifest.schema_version() != SCHEMA_VERSION { + return Err(crate::new_error!( + "unsupported OCI manifest schemaVersion {} (expected {})", + manifest.schema_version(), + SCHEMA_VERSION + )); + } + let cfg_desc = manifest.config(); + // Loader dispatch on config media type. Today only v1 exists; + // v2 lands as a new arm here that converts to the in-memory + // current shape. + let cfg_media = cfg_desc.media_type().to_string(); + match cfg_media.as_str() { + MT_CONFIG_V1 => {} + other => { + return Err(crate::new_error!( + "unexpected config media type {:?} (supported: {:?})", + other, + MT_CONFIG_V1 + )); + } + } + let layers = manifest.layers(); + if layers.len() != 1 { + return Err(crate::new_error!( + "expected exactly one OCI layer (the snapshot), found {}", + layers.len() + )); + } + let snap_desc = &layers[0]; + let snap_media = snap_desc.media_type().to_string(); + match snap_media.as_str() { + MT_SNAPSHOT_V1 => {} + other => { + return Err(crate::new_error!( + "unexpected snapshot layer media type {:?} (supported: {:?})", + other, + MT_SNAPSHOT_V1 + )); + } + } + + // 4. config blob + let cfg_hex = parse_oci_digest(cfg_desc.digest().as_ref())?; + let cfg_path = path.join("blobs").join("sha256").join(&cfg_hex); + let cfg_bytes = read_bounded(&cfg_path, MAX_CONFIG_BLOB_SIZE)?; + if cfg_bytes.len() as u64 != cfg_desc.size() { + return Err(crate::new_error!( + "config blob size mismatch: descriptor says {}, file is {}", + cfg_desc.size(), + cfg_bytes.len() + )); + } + if verify_blobs { + verify_blob_bytes("config", &cfg_bytes, &cfg_hex)?; + } + let cfg: HlConfig = serde_json::from_slice(&cfg_bytes) + .map_err(|e| crate::new_error!("failed to parse Hyperlight config JSON: {}", e))?; + cfg.validate_for_load()?; + + // 5. snapshot blob: open once, hash and mmap the same + // handle so an attacker cannot swap the file between + // verification and mapping. + let snap_hex = parse_oci_digest(snap_desc.digest().as_ref())?; + let snap_path = path.join("blobs").join("sha256").join(&snap_hex); + let mut snap_file = std::fs::File::open(&snap_path).map_err(|e| { + crate::new_error!("failed to open snapshot blob {:?}: {}", snap_path, e) + })?; + let snap_file_len = snap_file + .metadata() + .map_err(|e| crate::new_error!("failed to stat snapshot blob: {}", e))? + .len(); + let expected_blob_len = cfg.memory_size; + if snap_file_len != expected_blob_len { + return Err(crate::new_error!( + "snapshot blob size mismatch: file is {} bytes, expected {} \ + (memory_size)", + snap_file_len, + expected_blob_len, + )); + } + if snap_file_len != snap_desc.size() { + return Err(crate::new_error!( + "snapshot blob size {} disagrees with OCI descriptor size {}", + snap_file_len, + snap_desc.size() + )); + } + if verify_blobs { + verify_blob_file("snapshot", &mut snap_file, &snap_hex)?; + } + + // 6. Reconstruct layout. + let mut sbox_cfg = crate::sandbox::SandboxConfiguration::default(); + sbox_cfg.set_input_data_size(cfg.layout.input_data_size); + sbox_cfg.set_output_data_size(cfg.layout.output_data_size); + sbox_cfg.set_heap_size(cfg.layout.heap_size as u64); + sbox_cfg.set_scratch_size(cfg.layout.scratch_size); + let init_data_perms = match cfg.layout.init_data_permissions { + None => None, + Some(bits) => Some(MemoryRegionFlags::from_bits(bits).ok_or_else(|| { + crate::new_error!( + "snapshot init_data_permissions {:#x} contains unknown flag bits", + bits + ) + })?), + }; + let mut layout = SandboxMemoryLayout::new( + sbox_cfg, + cfg.layout.code_size, + cfg.layout.init_data_size, + init_data_perms, + )?; + // `snapshot_size` and `pt_size` are independent fields. + if let Some(pt) = cfg.layout.pt_size { + layout.set_pt_size(pt)?; + } + layout.set_snapshot_size(cfg.layout.snapshot_size); + + // 7. mmap the snapshot blob (file-backed CoW). The blob is + // the raw memory image. `ReadonlySharedMemory::from_file` + // surrounds it with host guard pages. The guest mapping + // of the snapshot region covers only the data prefix + // (`snapshot_size`). The PT tail sits past that prefix + // in the host mapping and is copied into the scratch + // region on restore. Keeping it out of the guest mapping + // of the snapshot region avoids overlap with + // `map_file_cow` regions installed immediately after the + // snapshot in guest PA space. + let memory = ReadonlySharedMemory::from_file(&snap_file, Some(layout.snapshot_size))?; + + // 8. Build entrypoint + sregs back from the tagged enum. + let (entrypoint, sregs) = match cfg.entrypoint { + EntrypointRepr::Initialise { addr } => (NextAction::Initialise(addr), None), + EntrypointRepr::Call { addr, sregs } => ( + NextAction::Call(addr), + Some(CommonSpecialRegisters::from(*sregs)), + ), + }; + + // 9. Reconstitute host_functions metadata. + let host_funcs_vec: Vec = + cfg.host_functions.into_iter().map(Into::into).collect(); + let host_functions = if host_funcs_vec.is_empty() { + HostFunctionDetails { + host_functions: None, + } + } else { + HostFunctionDetails { + host_functions: Some(host_funcs_vec), + } + }; + + Ok(Snapshot { + sandbox_id: SANDBOX_CONFIGURATION_COUNTER + .fetch_add(1, std::sync::atomic::Ordering::Relaxed), + layout, + memory, + regions: Vec::new(), + load_info: crate::mem::exe::LoadInfo::dummy(), + stack_top_gva: cfg.stack_top_gva, + sregs, + entrypoint, + snapshot_generation: 0, + host_functions, + }) + } +} + +// --- Hypervisor / arch tags ----------------------------------------- + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ArchTag { + X86_64, + Aarch64, + I686, +} + +impl ArchTag { + fn current() -> Self { + #[cfg(feature = "i686-guest")] + { + Self::I686 + } + #[cfg(all(not(feature = "i686-guest"), target_arch = "x86_64"))] + { + Self::X86_64 + } + #[cfg(all(not(feature = "i686-guest"), target_arch = "aarch64"))] + { + Self::Aarch64 + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(super) enum HypervisorTag { + Kvm, + Mshv, + Whp, +} + +impl HypervisorTag { + pub(super) fn current() -> Option { + #[allow(unused_imports)] + use crate::hypervisor::virtual_machine::HypervisorType; + use crate::hypervisor::virtual_machine::get_available_hypervisor; + + match get_available_hypervisor() { + #[cfg(kvm)] + Some(HypervisorType::Kvm) => Some(Self::Kvm), + #[cfg(mshv3)] + Some(HypervisorType::Mshv) => Some(Self::Mshv), + #[cfg(target_os = "windows")] + Some(HypervisorType::Whp) => Some(Self::Whp), + None => None, + } + } + + fn name(&self) -> &'static str { + match self { + Self::Kvm => "KVM", + Self::Mshv => "MSHV", + Self::Whp => "WHP", + } + } +} + +// --- Config JSON shape ---------------------------------------------- + +/// Top-level Hyperlight sandbox config JSON. Lives at +/// `blobs/sha256/` with media type +/// `application/vnd.hyperlight.sandbox.config.v1+json`. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct HlConfig { + /// Hyperlight crate version that produced this config. Recorded + /// for diagnostics. Not checked on load. + hyperlight_version: String, + arch: ArchTag, + /// Memory blob ABI version. See [`SNAPSHOT_ABI_VERSION`]. + abi_version: u32, + hypervisor: HypervisorTag, + /// Top of the guest stack, in guest virtual address space. + stack_top_gva: u64, + /// Tagged enum: `Initialise` carries an entry-point address only; + /// `Call` carries the dispatch function pointer plus the captured + /// sregs from the running vCPU. The shape itself enforces the + /// "Call has sregs, Initialise does not" invariant. + entrypoint: EntrypointRepr, + layout: LayoutFields, + /// Total size of the memory blob in bytes (including the guest + /// page-table tail, if any). Equal to `self.memory.mem_size()`. + memory_size: u64, + /// Names and signatures of host functions registered when this + /// snapshot was taken. Validated against the loader's registry. + host_functions: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] +/// On-disk next action stored with a snapshot. +/// +/// The enum shape enforces the invariant that `Call` includes `sregs` +/// and `Initialise` does not. Serde rejects missing or extra `sregs` +/// at parse time. +enum EntrypointRepr { + Initialise { addr: u64 }, + Call { addr: u64, sregs: Box }, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct LayoutFields { + input_data_size: usize, + output_data_size: usize, + heap_size: usize, + code_size: usize, + init_data_size: usize, + /// Memory region flag bits. `None` means default permissions. + init_data_permissions: Option, + scratch_size: usize, + snapshot_size: usize, + pt_size: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct HostFunctionRepr { + function_name: String, + parameter_types: Vec, + return_type: ReturnTypeRepr, +} + +/// JSON-friendly mirror of +/// [`hyperlight_common::flatbuffer_wrappers::function_types::ParameterType`]. +/// Kept local so we don't have to plumb serde through `hyperlight_common`. +/// The `match`es below are exhaustive: any new variant upstream forces +/// an explicit decision here. +#[derive(Serialize, Deserialize, Copy, Clone)] +#[serde(rename_all = "snake_case")] +enum ParameterTypeRepr { + Int, + UInt, + Long, + ULong, + Float, + Double, + String, + Bool, + VecBytes, +} + +/// JSON-friendly mirror of +/// [`hyperlight_common::flatbuffer_wrappers::function_types::ReturnType`]. +#[derive(Serialize, Deserialize, Copy, Clone)] +#[serde(rename_all = "snake_case")] +enum ReturnTypeRepr { + Int, + UInt, + Long, + ULong, + Float, + Double, + String, + Bool, + Void, + VecBytes, +} + +impl From<&ParameterType> for ParameterTypeRepr { + fn from(p: &ParameterType) -> Self { + match p { + ParameterType::Int => Self::Int, + ParameterType::UInt => Self::UInt, + ParameterType::Long => Self::Long, + ParameterType::ULong => Self::ULong, + ParameterType::Float => Self::Float, + ParameterType::Double => Self::Double, + ParameterType::String => Self::String, + ParameterType::Bool => Self::Bool, + ParameterType::VecBytes => Self::VecBytes, + } + } +} + +impl From for ParameterType { + fn from(r: ParameterTypeRepr) -> Self { + match r { + ParameterTypeRepr::Int => Self::Int, + ParameterTypeRepr::UInt => Self::UInt, + ParameterTypeRepr::Long => Self::Long, + ParameterTypeRepr::ULong => Self::ULong, + ParameterTypeRepr::Float => Self::Float, + ParameterTypeRepr::Double => Self::Double, + ParameterTypeRepr::String => Self::String, + ParameterTypeRepr::Bool => Self::Bool, + ParameterTypeRepr::VecBytes => Self::VecBytes, + } + } +} + +impl From<&ReturnType> for ReturnTypeRepr { + fn from(r: &ReturnType) -> Self { + match r { + ReturnType::Int => Self::Int, + ReturnType::UInt => Self::UInt, + ReturnType::Long => Self::Long, + ReturnType::ULong => Self::ULong, + ReturnType::Float => Self::Float, + ReturnType::Double => Self::Double, + ReturnType::String => Self::String, + ReturnType::Bool => Self::Bool, + ReturnType::Void => Self::Void, + ReturnType::VecBytes => Self::VecBytes, + } + } +} + +impl From for ReturnType { + fn from(r: ReturnTypeRepr) -> Self { + match r { + ReturnTypeRepr::Int => Self::Int, + ReturnTypeRepr::UInt => Self::UInt, + ReturnTypeRepr::Long => Self::Long, + ReturnTypeRepr::ULong => Self::ULong, + ReturnTypeRepr::Float => Self::Float, + ReturnTypeRepr::Double => Self::Double, + ReturnTypeRepr::String => Self::String, + ReturnTypeRepr::Bool => Self::Bool, + ReturnTypeRepr::Void => Self::Void, + ReturnTypeRepr::VecBytes => Self::VecBytes, + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SregsRepr { + cs: SegmentRegisterRepr, + ds: SegmentRegisterRepr, + es: SegmentRegisterRepr, + fs: SegmentRegisterRepr, + gs: SegmentRegisterRepr, + ss: SegmentRegisterRepr, + tr: SegmentRegisterRepr, + ldt: SegmentRegisterRepr, + gdt: TableRegisterRepr, + idt: TableRegisterRepr, + cr0: u64, + cr2: u64, + cr3: u64, + cr4: u64, + cr8: u64, + efer: u64, + apic_base: u64, + interrupt_bitmap: [u64; 4], +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SegmentRegisterRepr { + base: u64, + limit: u32, + selector: u16, + type_: u8, + present: u8, + dpl: u8, + db: u8, + s: u8, + l: u8, + g: u8, + avl: u8, + unusable: u8, + padding: u8, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TableRegisterRepr { + base: u64, + limit: u16, +} + +// --- Conversions between repr and runtime types --------------------- + +impl From<&CommonSpecialRegisters> for SregsRepr { + fn from(s: &CommonSpecialRegisters) -> Self { + let seg = |r: &CommonSegmentRegister| SegmentRegisterRepr { + base: r.base, + limit: r.limit, + selector: r.selector, + type_: r.type_, + present: r.present, + dpl: r.dpl, + db: r.db, + s: r.s, + l: r.l, + g: r.g, + avl: r.avl, + unusable: r.unusable, + padding: r.padding, + }; + let tab = |r: &CommonTableRegister| TableRegisterRepr { + base: r.base, + limit: r.limit, + }; + Self { + cs: seg(&s.cs), + ds: seg(&s.ds), + es: seg(&s.es), + fs: seg(&s.fs), + gs: seg(&s.gs), + ss: seg(&s.ss), + tr: seg(&s.tr), + ldt: seg(&s.ldt), + gdt: tab(&s.gdt), + idt: tab(&s.idt), + cr0: s.cr0, + cr2: s.cr2, + cr3: s.cr3, + cr4: s.cr4, + cr8: s.cr8, + efer: s.efer, + apic_base: s.apic_base, + interrupt_bitmap: s.interrupt_bitmap, + } + } +} + +impl From for CommonSpecialRegisters { + fn from(r: SregsRepr) -> Self { + let seg = |s: SegmentRegisterRepr| CommonSegmentRegister { + base: s.base, + limit: s.limit, + selector: s.selector, + type_: s.type_, + present: s.present, + dpl: s.dpl, + db: s.db, + s: s.s, + l: s.l, + g: s.g, + avl: s.avl, + unusable: s.unusable, + padding: s.padding, + }; + let tab = |t: TableRegisterRepr| CommonTableRegister { + base: t.base, + limit: t.limit, + }; + Self { + cs: seg(r.cs), + ds: seg(r.ds), + es: seg(r.es), + fs: seg(r.fs), + gs: seg(r.gs), + ss: seg(r.ss), + tr: seg(r.tr), + ldt: seg(r.ldt), + gdt: tab(r.gdt), + idt: tab(r.idt), + cr0: r.cr0, + cr2: r.cr2, + cr3: r.cr3, + cr4: r.cr4, + cr8: r.cr8, + efer: r.efer, + apic_base: r.apic_base, + interrupt_bitmap: r.interrupt_bitmap, + } + } +} + +impl From<&HostFunctionDefinition> for HostFunctionRepr { + fn from(d: &HostFunctionDefinition) -> Self { + let parameter_types = d + .parameter_types + .as_ref() + .map(|v| v.iter().map(ParameterTypeRepr::from).collect()) + .unwrap_or_default(); + Self { + function_name: d.function_name.clone(), + parameter_types, + return_type: ReturnTypeRepr::from(&d.return_type), + } + } +} + +impl From for HostFunctionDefinition { + fn from(r: HostFunctionRepr) -> Self { + Self { + function_name: r.function_name, + parameter_types: Some(r.parameter_types.into_iter().map(Into::into).collect()), + return_type: r.return_type.into(), + } + } +} + +// --- sha256 helper -------------------------------------------------- + +/// A `sha256:` digest as recorded in OCI manifests. The bare hex +/// (without prefix) is also the blob's filename inside `blobs/sha256/`. +#[derive(Clone)] +struct Digest256 { + /// Lowercase hex of the 32-byte sha256 output. + hex: String, +} + +impl Digest256 { + fn from_bytes(bytes: &[u8]) -> Self { + let arr: [u8; 32] = Sha256::digest(bytes).into(); + Self { + hex: hex::encode(arr), + } + } + + fn from_hasher(h: Sha256) -> Self { + let arr: [u8; 32] = h.finalize().into(); + Self { + hex: hex::encode(arr), + } + } +} + +/// Build an `oci_spec::image::Digest` from a [`Digest256`]. +fn oci_digest(d: &Digest256) -> crate::Result { + Digest::try_from(format!("sha256:{}", d.hex)) + .map_err(|e| crate::new_error!("failed to construct OCI digest: {}", e)) +} + +fn parse_oci_digest(s: &str) -> crate::Result { + let rest = s.strip_prefix("sha256:").ok_or_else(|| { + crate::new_error!( + "OCI descriptor digest {:?} is not a sha256 digest (only sha256 is supported)", + s + ) + })?; + if rest.len() != 64 || !rest.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(crate::new_error!( + "OCI descriptor digest {:?} is not a 64-character lowercase hex string", + s + )); + } + Ok(rest.to_lowercase()) +} + +// --- HlConfig validation -------------------------------------------- + +impl HlConfig { + fn validate_for_load(&self) -> crate::Result<()> { + if self.arch != ArchTag::current() { + return Err(crate::new_error!( + "snapshot architecture mismatch: file is {:?}, current host is {:?}", + self.arch, + ArchTag::current() + )); + } + if self.abi_version != SNAPSHOT_ABI_VERSION { + return Err(crate::new_error!( + "snapshot ABI version mismatch: file has version {}, this build expects {}. \ + The snapshot must be regenerated from the guest binary.", + self.abi_version, + SNAPSHOT_ABI_VERSION + )); + } + let current_hv = HypervisorTag::current() + .ok_or_else(|| crate::new_error!("no hypervisor available to load snapshot"))?; + if self.hypervisor != current_hv { + return Err(crate::new_error!( + "snapshot hypervisor mismatch: file was created on {} but the current hypervisor is {}", + self.hypervisor.name(), + current_hv.name() + )); + } + // Bound memory size early so the subsequent file-size check + // does not have to deal with absurd values. + if self.memory_size == 0 || self.memory_size > SandboxMemoryLayout::MAX_MEMORY_SIZE as u64 { + return Err(crate::new_error!( + "snapshot memory_size ({}) is out of range", + self.memory_size + )); + } + if self.memory_size as usize % PAGE_SIZE != 0 { + return Err(crate::new_error!( + "snapshot memory_size ({}) is not a multiple of PAGE_SIZE", + self.memory_size + )); + } + // Invariant: `snapshot_size + pt_size == memory_size`. + // `snapshot_size` is the guest-visible prefix of the blob, + // mapped into guest PA space at `BASE_ADDRESS`. `pt_size` + // is the page-table tail that sits after it in the blob and + // the host mapping, outside the guest mapping of the + // snapshot region. + if self.layout.snapshot_size == 0 { + return Err(crate::new_error!("snapshot snapshot_size must be nonzero")); + } + if self.layout.snapshot_size % PAGE_SIZE != 0 { + return Err(crate::new_error!( + "snapshot snapshot_size ({}) is not a multiple of PAGE_SIZE", + self.layout.snapshot_size + )); + } + let pt = self.layout.pt_size.unwrap_or(0); + if pt % PAGE_SIZE != 0 { + return Err(crate::new_error!( + "snapshot pt_size ({}) is not a multiple of PAGE_SIZE", + pt + )); + } + if (self.layout.snapshot_size as u64).saturating_add(pt as u64) != self.memory_size { + return Err(crate::new_error!( + "snapshot snapshot_size ({}) + pt_size ({}) does not equal memory_size ({})", + self.layout.snapshot_size, + pt, + self.memory_size + )); + } + if let Some(bits) = self.layout.init_data_permissions { + MemoryRegionFlags::from_bits(bits).ok_or_else(|| { + crate::new_error!( + "snapshot init_data_permissions {:#x} contains unknown flag bits", + bits + ) + })?; + } + + // Entrypoint address must point inside the guest snapshot + // region. Hyperlight identity-maps the snapshot region in low + // GPAs, so the same bounds apply to virtual and physical + // addresses there. A crafted config could otherwise direct + // execution into unmapped GPA space (which only catches the + // bug at vCPU run time) or, worse, into the scratch region + // (which is writable). The bound here is + // `[BASE_ADDRESS, BASE_ADDRESS + snapshot_size)` because the + // snapshot blob covers exactly the snapshot region. + let snap_lo = SandboxMemoryLayout::BASE_ADDRESS as u64; + let snap_hi = snap_lo + .checked_add(self.layout.snapshot_size as u64) + .ok_or_else(|| { + crate::new_error!( + "snapshot layout overflow: BASE_ADDRESS + snapshot_size ({}) does not fit in u64", + self.layout.snapshot_size + ) + })?; + let entry_addr = match &self.entrypoint { + EntrypointRepr::Initialise { addr } => *addr, + EntrypointRepr::Call { addr, .. } => *addr, + }; + if entry_addr < snap_lo || entry_addr >= snap_hi { + return Err(crate::new_error!( + "snapshot entrypoint addr {:#x} is outside the snapshot region [{:#x}, {:#x})", + entry_addr, + snap_lo, + snap_hi + )); + } + Ok(()) + } +} + +// --- Save ----------------------------------------------------------- + +/// OCI standard annotation key for a manifest's tag inside an image +/// index. Set on the manifest descriptor in `index.json`, not on the +/// manifest blob itself. See the OCI Image Spec, "Annotations" and +/// the Image Layout spec. +const ANNOTATION_REF_NAME: &str = "org.opencontainers.image.ref.name"; + +/// Validate a tag against the OCI Distribution spec rules: +/// `[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}`. Required so that the same +/// strings work both in our local layout and when pushed to a +/// registry via `oras` / `crane` / `skopeo`. +fn validate_tag(tag: &str) -> crate::Result<()> { + let bytes = tag.as_bytes(); + if bytes.is_empty() || bytes.len() > 128 { + return Err(crate::new_error!( + "tag {:?} is invalid: must be 1..=128 bytes", + tag + )); + } + let first = bytes[0]; + if !(first.is_ascii_alphanumeric() || first == b'_') { + return Err(crate::new_error!( + "tag {:?} is invalid: first character must be alphanumeric or '_'", + tag + )); + } + for &b in &bytes[1..] { + if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'-') { + return Err(crate::new_error!( + "tag {:?} is invalid: characters after the first must be \ + alphanumeric or one of '_', '.', '-'", + tag + )); + } + } + Ok(()) +} + +/// Read and parse `path/oci-layout`, asserting the version we +/// support. Returns `Ok(true)` if a valid layout marker was found, +/// `Ok(false)` if the file is absent (so the caller can decide +/// whether to create a fresh layout), or `Err` if the file is +/// present but malformed (treat as user data; refuse to touch). +fn read_oci_layout_marker(path: &Path) -> crate::Result { + let marker_path = path.join("oci-layout"); + let bytes = match std::fs::read(&marker_path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => { + return Err(crate::new_error!( + "failed to read oci-layout marker at {:?}: {}", + marker_path, + e + )); + } + }; + let v: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| { + crate::new_error!("oci-layout at {:?} is not valid JSON: {}", marker_path, e) + })?; + let version = v + .get("imageLayoutVersion") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + crate::new_error!("oci-layout at {:?} missing imageLayoutVersion", marker_path) + })?; + if version != OCI_LAYOUT_VERSION { + return Err(crate::new_error!( + "unsupported OCI image layout version {:?} at {:?} (expected {:?})", + version, + marker_path, + OCI_LAYOUT_VERSION + )); + } + Ok(true) +} + +/// Write a blob at `blobs/sha256/`, but skip the actual +/// write if a file with that name already exists. OCI blobs are +/// content-addressed, so an existing file with the same digest must +/// have the same bytes. Skipping reuses the blob across snapshots +/// in the same layout. +fn write_blob_dedup(blobs_dir: &Path, hex_digest: &str, bytes: &[u8]) -> crate::Result<()> { + let final_path = blobs_dir.join(hex_digest); + if final_path.exists() { + return Ok(()); + } + std::fs::write(&final_path, bytes) + .map_err(|e| crate::new_error!("failed to write blob {:?}: {}", final_path, e)) +} + +/// Compute sha256 of `bytes` and verify it equals `expected_hex`. +/// Used to validate manifest and config blobs (small, already in +/// memory). +fn verify_blob_bytes(label: &str, bytes: &[u8], expected_hex: &str) -> crate::Result<()> { + let actual = Digest256::from_bytes(bytes); + if actual.hex != expected_hex { + return Err(crate::new_error!( + "{} blob digest mismatch: descriptor declares sha256:{}, file hashes to sha256:{}", + label, + expected_hex, + actual.hex + )); + } + Ok(()) +} + +/// Stream-hash an already-open file and verify its sha256 equals +/// `expected_hex`. +/// +/// Takes the same `File` handle the caller will subsequently `mmap`, +/// not a path. Hashing one open and mapping another is open-then- +/// replace TOCTOU bait. Seeks to start before and after so the +/// caller's file position is unchanged. +fn verify_blob_file( + label: &str, + file: &mut std::fs::File, + expected_hex: &str, +) -> crate::Result<()> { + file.seek(SeekFrom::Start(0)) + .map_err(|e| crate::new_error!("failed to seek {} blob: {}", label, e))?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = file + .read(&mut buf) + .map_err(|e| crate::new_error!("failed to read {} blob: {}", label, e))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + file.seek(SeekFrom::Start(0)) + .map_err(|e| crate::new_error!("failed to rewind {} blob: {}", label, e))?; + let actual = Digest256::from_hasher(hasher); + if actual.hex != expected_hex { + return Err(crate::new_error!( + "{} blob digest mismatch: descriptor declares sha256:{}, file hashes to sha256:{}", + label, + expected_hex, + actual.hex + )); + } + Ok(()) +} + +/// Read a file in full, refusing if the file is bigger than `max_size`. +/// +/// The cap is enforced on the actual byte stream via [`Read::take`], so files +/// whose `metadata().len()` is misleading cannot exceed the limit. +fn read_bounded(path: &Path, max_size: u64) -> crate::Result> { + let f = std::fs::File::open(path) + .map_err(|e| crate::new_error!("failed to open {:?}: {}", path, e))?; + let hint = f.metadata().map(|m| m.len().min(max_size)).unwrap_or(0); + let mut buf = Vec::with_capacity(hint as usize); + // Read one extra byte so we can distinguish "exactly at the limit" from + // "over the limit" instead of silently truncating an oversize file. + f.take(max_size.saturating_add(1)) + .read_to_end(&mut buf) + .map_err(|e| crate::new_error!("failed to read {:?}: {}", path, e))?; + if buf.len() as u64 > max_size { + return Err(crate::new_error!( + "file {:?} exceeds maximum allowed {} bytes", + path, + max_size + )); + } + Ok(buf) +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs new file mode 100644 index 000000000..739db196c --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs @@ -0,0 +1,2574 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Tests for the OCI Image Layout snapshot format (`super::file`). + +#![cfg(test)] + +use std::sync::Arc; + +use hyperlight_testing::simple_guest_as_string; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +use crate::func::Registerable; +use crate::sandbox::snapshot::Snapshot; +use crate::{GuestBinary, HostFunctions, MultiUseSandbox, UninitializedSandbox}; + +fn create_test_sandbox() -> MultiUseSandbox { + let path = simple_guest_as_string().unwrap(); + UninitializedSandbox::new(GuestBinary::FilePath(path), None) + .unwrap() + .evolve() + .unwrap() +} + +fn create_snapshot_from_binary() -> Snapshot { + let path = simple_guest_as_string().unwrap(); + Snapshot::from_env( + GuestBinary::FilePath(path), + crate::sandbox::SandboxConfiguration::default(), + ) + .unwrap() +} + +/// `Result::unwrap_err` requires `T: Debug`, but `Snapshot` is not +/// `Debug`. This wrapper is the test-side equivalent. +#[track_caller] +fn unwrap_err_snapshot(r: crate::Result) -> crate::HyperlightError { + match r { + Err(e) => e, + Ok(_) => panic!("expected Snapshot::from_oci to fail"), + } +} + +/// Locate the single config blob inside `oci_dir`. Returns its full +/// path. Used by tests that mutate the on-disk JSON. +fn find_config_blob(oci_dir: &std::path::Path) -> std::path::PathBuf { + let manifest_bytes = std::fs::read(oci_dir.join("index.json")).unwrap(); + let index: Value = serde_json::from_slice(&manifest_bytes).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let manifest_path = oci_dir.join("blobs").join("sha256").join(manifest_digest); + let manifest: Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let cfg_digest = manifest["config"]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + oci_dir.join("blobs").join("sha256").join(cfg_digest) +} + +// ============================================================================= +// In-memory `from_snapshot` round-trips (no file I/O). +// ============================================================================= + +#[test] +fn from_snapshot_already_initialized_in_memory() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), None).unwrap(); + let result: i32 = sbox2.call("GetStatic", ()).unwrap(); + assert_eq!(result, 0); +} + +#[test] +fn from_snapshot_in_memory_pre_init() { + let snap = create_snapshot_from_binary(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(snap), HostFunctions::default(), None).unwrap(); + let result: i32 = sbox.call("GetStatic", ()).unwrap(); + assert_eq!(result, 0); +} + +// ============================================================================= +// Round-trip via OCI layout on disk. +// ============================================================================= + +#[test] +fn round_trip_save_load_call() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let oci = dir.path().join("snap"); + snapshot.to_oci(&oci, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&oci, "latest").unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + + let result: String = sbox2.call("Echo", "hello\n".to_string()).unwrap(); + assert_eq!(result, "hello\n"); +} + +#[test] +fn snapshot_and_pt_size_round_trip() { + // Running-sandbox snapshot. + let mut sbox = create_test_sandbox(); + let snap = sbox.snapshot().unwrap(); + let original_snapshot_size = snap.layout().snapshot_size; + let original_pt_size = snap.layout().pt_size; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("running"); + snap.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + assert_eq!(loaded.layout().snapshot_size, original_snapshot_size); + assert_eq!(loaded.layout().pt_size, original_pt_size); + + // Pre-init snapshot. + let preinit = create_snapshot_from_binary(); + let preinit_snapshot_size = preinit.layout().snapshot_size; + let preinit_pt_size = preinit.layout().pt_size; + + let path = dir.path().join("preinit"); + preinit.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + assert_eq!(loaded.layout().snapshot_size, preinit_snapshot_size); + assert_eq!(loaded.layout().pt_size, preinit_pt_size); +} + +#[test] +fn pre_init_snapshot_save_load() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("preinit"); + snap.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); +} + +// ============================================================================= +// Restore semantics (id/generation gating). +// ============================================================================= + +#[test] +fn restore_from_loaded_snapshot() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let loaded = Arc::new(Snapshot::from_oci(&path, "latest").unwrap()); + let mut sbox2 = + MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None).unwrap(); + + sbox2.call::("AddToStatic", 5i32).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 5); + + sbox2.restore(loaded).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn restore_to_different_oci_loaded_snapshot_rejected() { + let mut sbox = create_test_sandbox(); + let snap1 = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let p1 = dir.path().join("snap1"); + snap1.to_oci(&p1, "latest").unwrap(); + let p2 = dir.path().join("snap2"); + // Two `from_oci` calls of the same image yield distinct + // `sandbox_id`s, so a sandbox built from snap2 must refuse to + // restore to a snapshot loaded as snap1 (different id). + snap1.to_oci(&p2, "latest").unwrap(); + + let loaded1 = Arc::new(Snapshot::from_oci(&p1, "latest").unwrap()); + let loaded2 = Arc::new(Snapshot::from_oci(&p2, "latest").unwrap()); + + let mut sbox = MultiUseSandbox::from_snapshot(loaded2, HostFunctions::default(), None).unwrap(); + let err = sbox.restore(loaded1).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("Snapshot") || msg.contains("snapshot") || msg.contains("Mismatch"), + "expected sandbox/snapshot mismatch error, got: {}", + msg + ); +} + +#[test] +fn many_sandboxes_share_single_arc_snapshot() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let loaded = Arc::new(Snapshot::from_oci(&path, "latest").unwrap()); + let mut sandboxes = Vec::new(); + for _ in 0..4 { + sandboxes.push( + MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None).unwrap(), + ); + } + for sbox in sandboxes.iter_mut() { + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + } +} + +#[test] +fn concurrent_sandboxes_from_same_oci() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let path = std::sync::Arc::new(path); + let mut handles = Vec::new(); + for _ in 0..4 { + let p = path.clone(); + handles.push(std::thread::spawn(move || { + let loaded = Snapshot::from_oci(p.as_ref(), "latest").unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None) + .unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + })); + } + for h in handles { + h.join().unwrap(); + } +} + +#[test] +fn cow_does_not_mutate_backing_file() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + // Hash every blob file to verify nothing changes after a CoW write + // through the loaded sandbox. + let blobs_dir = path.join("blobs").join("sha256"); + let snapshot_before: std::collections::BTreeMap<_, _> = std::fs::read_dir(&blobs_dir) + .unwrap() + .map(|e| { + let e = e.unwrap(); + let bytes = std::fs::read(e.path()).unwrap(); + (e.file_name(), bytes) + }) + .collect(); + + { + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None) + .unwrap(); + sbox.call::("AddToStatic", 99).unwrap(); + } + + let snapshot_after: std::collections::BTreeMap<_, _> = std::fs::read_dir(&blobs_dir) + .unwrap() + .map(|e| { + let e = e.unwrap(); + let bytes = std::fs::read(e.path()).unwrap(); + (e.file_name(), bytes) + }) + .collect(); + assert_eq!( + snapshot_before, snapshot_after, + "CoW writes must not mutate any blob in the OCI layout" + ); +} + +// ============================================================================= +// Architecture / hypervisor / ABI gating. +// ============================================================================= + +/// Compute sha256 of `bytes` and return the lowercase hex digest. +fn sha256_hex(bytes: &[u8]) -> String { + let arr: [u8; 32] = Sha256::digest(bytes).into(); + hex::encode(arr) +} + +fn rewrite_config(oci_dir: &std::path::Path, mutate: F) { + // Mutate the config blob and rewrite the manifest + index so the + // OCI layout stays self-consistent: blob filenames, descriptor + // sizes, and descriptor sha256 digests all match the current + // bytes on disk. The point of these helpers is to exercise + // field-level validators (arch, abi_version, hypervisor, etc.), + // not the digest layer; tests that want to probe the digest + // layer write raw bytes directly. + let cfg_path = find_config_blob(oci_dir); + let mut cfg: Value = serde_json::from_slice(&std::fs::read(&cfg_path).unwrap()).unwrap(); + mutate(&mut cfg); + let new_cfg_bytes = serde_json::to_vec_pretty(&cfg).unwrap(); + let new_cfg_hex = sha256_hex(&new_cfg_bytes); + let blobs_dir = oci_dir.join("blobs").join("sha256"); + let new_cfg_path = blobs_dir.join(&new_cfg_hex); + std::fs::write(&new_cfg_path, &new_cfg_bytes).unwrap(); + if new_cfg_path != cfg_path { + std::fs::remove_file(&cfg_path).ok(); + } + + let mp = manifest_path(oci_dir); + let mut manifest: Value = serde_json::from_slice(&std::fs::read(&mp).unwrap()).unwrap(); + manifest["config"]["digest"] = Value::from(format!("sha256:{}", new_cfg_hex)); + manifest["config"]["size"] = Value::from(new_cfg_bytes.len() as u64); + let new_manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + let new_manifest_hex = sha256_hex(&new_manifest_bytes); + let new_manifest_path = blobs_dir.join(&new_manifest_hex); + std::fs::write(&new_manifest_path, &new_manifest_bytes).unwrap(); + if new_manifest_path != mp { + std::fs::remove_file(&mp).ok(); + } + + let index_path = oci_dir.join("index.json"); + let mut index: Value = serde_json::from_slice(&std::fs::read(&index_path).unwrap()).unwrap(); + index["manifests"][0]["digest"] = Value::from(format!("sha256:{}", new_manifest_hex)); + index["manifests"][0]["size"] = Value::from(new_manifest_bytes.len() as u64); + std::fs::write(index_path, serde_json::to_vec_pretty(&index).unwrap()).unwrap(); +} + +/// Locate the manifest blob path inside `oci_dir`. +fn manifest_path(oci_dir: &std::path::Path) -> std::path::PathBuf { + let index: Value = + serde_json::from_slice(&std::fs::read(oci_dir.join("index.json")).unwrap()).unwrap(); + let digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap() + .to_string(); + oci_dir.join("blobs").join("sha256").join(digest) +} + +/// Mutate the on-disk manifest JSON. Updates the index's manifest +/// descriptor `size` and `digest` to match the new manifest bytes +/// so the test exercises the field-level validator we care about, +/// not the digest layer. +fn rewrite_manifest(oci_dir: &std::path::Path, mutate: F) { + let mp = manifest_path(oci_dir); + let mut manifest: Value = serde_json::from_slice(&std::fs::read(&mp).unwrap()).unwrap(); + mutate(&mut manifest); + let new_bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + let new_hex = sha256_hex(&new_bytes); + let blobs_dir = oci_dir.join("blobs").join("sha256"); + let new_path = blobs_dir.join(&new_hex); + std::fs::write(&new_path, &new_bytes).unwrap(); + if new_path != mp { + std::fs::remove_file(&mp).ok(); + } + + let index_path = oci_dir.join("index.json"); + let mut index: Value = serde_json::from_slice(&std::fs::read(&index_path).unwrap()).unwrap(); + index["manifests"][0]["digest"] = Value::from(format!("sha256:{}", new_hex)); + index["manifests"][0]["size"] = Value::from(new_bytes.len() as u64); + std::fs::write(index_path, serde_json::to_vec_pretty(&index).unwrap()).unwrap(); +} + +/// Mutate the on-disk index JSON in place. The index is the root of +/// the OCI layout and is not itself referenced by any digest, so +/// nothing further needs to be updated. +fn rewrite_index(oci_dir: &std::path::Path, mutate: F) { + let path = oci_dir.join("index.json"); + let mut index: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + mutate(&mut index); + std::fs::write(path, serde_json::to_vec_pretty(&index).unwrap()).unwrap(); +} + +#[test] +fn arch_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + rewrite_config(&path, |cfg| { + cfg["arch"] = Value::from("aarch64"); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("architecture") || msg.contains("arch"), + "expected architecture mismatch, got: {}", + msg + ); +} + +#[test] +fn abi_version_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + rewrite_config(&path, |cfg| { + cfg["abi_version"] = Value::from(9999u32); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("ABI") || msg.contains("abi"), + "expected ABI version mismatch, got: {}", + msg + ); +} + +#[test] +fn hypervisor_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + // Pick a hypervisor that is not the current one. + let current = cfg_current_hypervisor(); + let other = if current == "kvm" { "mshv" } else { "kvm" }; + + rewrite_config(&path, |cfg| { + cfg["hypervisor"] = Value::from(other); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("hypervisor"), + "expected hypervisor mismatch, got: {}", + msg + ); +} + +fn cfg_current_hypervisor() -> &'static str { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("probe"); + create_snapshot_from_binary() + .to_oci(&path, "latest") + .unwrap(); + let cfg_path = find_config_blob(&path); + let cfg: Value = serde_json::from_slice(&std::fs::read(&cfg_path).unwrap()).unwrap(); + match cfg["hypervisor"].as_str().unwrap() { + "kvm" => "kvm", + "mshv" => "mshv", + "whp" => "whp", + other => panic!("unknown hypervisor tag {other}"), + } +} + +// ============================================================================= +// Entrypoint vs sregs invariants enforced by serde shape. +// ============================================================================= + +#[test] +fn call_snapshot_without_sregs_rejected() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + // Strip sregs from the entrypoint variant. serde must reject the + // missing field at parse time. + rewrite_config(&path, |cfg| { + let entry = cfg["entrypoint"].as_object_mut().unwrap(); + assert_eq!(entry["kind"].as_str().unwrap(), "call"); + entry.remove("sregs"); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("sregs") || msg.contains("missing field") || msg.contains("config"), + "expected serde error about missing sregs, got: {}", + msg + ); +} + +#[test] +fn initialise_snapshot_with_sregs_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + // Add a bogus sregs field to the Initialise variant. serde must + // reject the unknown field (variant has deny_unknown_fields). + rewrite_config(&path, |cfg| { + let entry = cfg["entrypoint"].as_object_mut().unwrap(); + assert_eq!(entry["kind"].as_str().unwrap(), "initialise"); + entry.insert("sregs".to_string(), Value::from("{}")); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("sregs") || msg.contains("unknown field") || msg.contains("config"), + "expected serde error about unknown field sregs, got: {}", + msg + ); +} + +// ============================================================================= +// Host functions validation. +// +// `validate_host_functions` enforces a superset relation: every host +// function registered when the snapshot was taken must be present in +// the loaded sandbox's `HostFunctions` with a matching signature. +// Extras in the loaded set are allowed. +// ============================================================================= + +/// Build a `MultiUseSandbox` with the default host functions plus a +/// custom `Add(i32, i32) -> i32`. Used to seed the snapshot side of +/// the host-function validation tests so the snapshot has a +/// non-default required function. +fn create_sandbox_with_custom_host_funcs() -> MultiUseSandbox { + let path = simple_guest_as_string().unwrap(); + let mut u = UninitializedSandbox::new(GuestBinary::FilePath(path), None).unwrap(); + u.register_host_function("Add", |a: i32, b: i32| Ok(a + b)) + .unwrap(); + u.evolve().unwrap() +} + +/// `HostFunctions::default()` plus a matching `Add(i32, i32) -> i32`. +fn host_funcs_with_matching_add() -> HostFunctions { + let mut hf = HostFunctions::default(); + hf.register_host_function("Add", |a: i32, b: i32| Ok(a + b)) + .unwrap(); + hf +} + +#[test] +fn from_snapshot_accepts_matching_host_functions() { + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), host_funcs_with_matching_add(), None) + .unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn from_snapshot_rejects_missing_host_function() { + // Snapshot was taken with `Add` registered. Loading with the + // default `HostFunctions` (no `Add`) must be rejected. + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let err = MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None) + .expect_err("from_snapshot must reject a HostFunctions set missing `Add`"); + let msg = format!("{}", err); + assert!( + msg.contains("missing") && msg.contains("Add"), + "expected missing-host-function error mentioning Add, got: {}", + msg + ); +} + +#[test] +fn from_snapshot_rejects_signature_mismatch() { + // Snapshot has `Add(i32, i32) -> i32`. Load registers an `Add` + // with a different signature. validate_host_functions must + // refuse the mismatch. + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + + let mut hf = HostFunctions::default(); + hf.register_host_function("Add", |a: String, b: String| Ok(format!("{a}{b}"))) + .unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let err = MultiUseSandbox::from_snapshot(Arc::new(loaded), hf, None) + .expect_err("from_snapshot must reject a signature mismatch on Add"); + let msg = format!("{}", err); + assert!( + msg.contains("signature_mismatches") && msg.contains("Add"), + "expected signature-mismatch error mentioning Add, got: {}", + msg + ); +} + +#[test] +fn from_snapshot_accepts_extra_host_functions() { + // Snapshot has `Add`. Load registers `Add` (matching) plus an + // unrelated `Mul`. Extras are allowed. + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + + let mut hf = host_funcs_with_matching_add(); + hf.register_host_function("Mul", |a: i32, b: i32| Ok(a * b)) + .unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let mut sbox2 = MultiUseSandbox::from_snapshot(Arc::new(loaded), hf, None).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn from_snapshot_accepts_zero_arg_host_function() { + // A zero-arg host function must round-trip through OCI. + let path = simple_guest_as_string().unwrap(); + let mut u = UninitializedSandbox::new(GuestBinary::FilePath(path), None).unwrap(); + u.register_host_function("Zero", || Ok(7i64)).unwrap(); + let mut sbox = u.evolve().unwrap(); + + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + + let mut hf = HostFunctions::default(); + hf.register_host_function("Zero", || Ok(7i64)).unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let _sbox2 = MultiUseSandbox::from_snapshot(Arc::new(loaded), hf, None) + .expect("zero-arg host function must round-trip through OCI"); +} + +#[test] +fn from_snapshot_has_default_host_print() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + let _ = sbox2.call::("PrintTwoArgs", ("hi".to_string(), 42i32)); +} + +// ============================================================================= +// OCI-shape invariants. +// ============================================================================= + +#[test] +fn missing_oci_layout_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + std::fs::remove_file(path.join("oci-layout")).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("oci-layout"), + "expected missing oci-layout error, got: {}", + msg + ); +} + +#[test] +fn wrong_image_layout_version_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + std::fs::write( + path.join("oci-layout"), + r#"{"imageLayoutVersion":"99.0.0"}"#, + ) + .unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("image layout version") || msg.contains("imageLayoutVersion"), + "expected layout version error, got: {}", + msg + ); +} + +#[test] +fn missing_index_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + std::fs::remove_file(path.join("index.json")).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("index.json"), + "expected missing index.json error, got: {}", + msg + ); +} + +#[test] +fn snapshot_blob_size_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + // Truncate the snapshot blob by one byte. + let blobs_dir = path.join("blobs").join("sha256"); + let manifest_bytes = std::fs::read(path.join("index.json")).unwrap(); + let index: Value = serde_json::from_slice(&manifest_bytes).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let manifest_path = blobs_dir.join(manifest_digest); + let manifest: Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let snap_digest = manifest["layers"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let snap_path = blobs_dir.join(snap_digest); + let bytes = std::fs::read(&snap_path).unwrap(); + std::fs::write(&snap_path, &bytes[..bytes.len() - 1]).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("size") || msg.contains("mismatch"), + "expected size mismatch error, got: {}", + msg + ); +} + +#[test] +fn snapshot_layout_snapshot_size_zero_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + rewrite_config(&path, |cfg| { + cfg["layout"]["snapshot_size"] = Value::from(0u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("snapshot_size"), + "expected snapshot_size error, got: {}", + msg + ); +} + +#[test] +fn snapshot_layout_snapshot_size_unaligned_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + rewrite_config(&path, |cfg| { + let s = cfg["layout"]["snapshot_size"].as_u64().unwrap(); + cfg["layout"]["snapshot_size"] = Value::from(s + 1); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("PAGE_SIZE") || msg.contains("multiple"), + "expected page alignment error, got: {}", + msg + ); +} + +#[test] +fn snapshot_layout_snapshot_size_must_match_memory_size() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + let page = hyperlight_common::vmem::PAGE_SIZE as u64; + rewrite_config(&path, |cfg| { + let m = cfg["memory_size"].as_u64().unwrap(); + cfg["layout"]["snapshot_size"] = Value::from(m + page); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("does not equal memory_size"), + "expected snapshot_size + pt_size != memory_size error, got: {}", + msg + ); +} + +#[test] +fn snapshot_layout_pt_size_unaligned_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + rewrite_config(&path, |cfg| { + if let Some(p) = cfg["layout"]["pt_size"].as_u64() { + cfg["layout"]["pt_size"] = Value::from(p + 1); + } else { + cfg["layout"]["pt_size"] = Value::from(1u64); + } + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("pt_size") || msg.contains("PAGE_SIZE") || msg.contains("multiple"), + "expected pt_size validation error, got: {}", + msg + ); +} + +#[test] +fn missing_snapshot_blob_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let blobs_dir = path.join("blobs").join("sha256"); + let manifest_bytes = std::fs::read(path.join("index.json")).unwrap(); + let index: Value = serde_json::from_slice(&manifest_bytes).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let manifest_path = blobs_dir.join(manifest_digest); + let manifest: Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let snap_digest = manifest["layers"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + std::fs::remove_file(blobs_dir.join(snap_digest)).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("snapshot blob") || msg.contains("No such") || msg.contains("not found"), + "expected missing-blob error, got: {}", + msg + ); +} + +// ============================================================================= +// Path semantics. +// ============================================================================= + +#[test] +fn from_oci_nonexistent_path_returns_error() { + let err = unwrap_err_snapshot(Snapshot::from_oci("/nonexistent/path/to/oci", "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("stat") || msg.contains("No such") || msg.contains("not found"), + "expected missing-path error, got: {}", + msg + ); +} + +#[test] +fn from_oci_file_not_directory_rejected() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("not-a-dir"); + std::fs::write(&file_path, b"hello").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&file_path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("not a directory"), + "expected not-a-directory error, got: {}", + msg + ); +} + +#[test] +fn to_oci_refuses_existing_non_oci_directory() { + // The contract is: `to_oci` errors out if a non-OCI-layout + // directory exists at `path`, without modifying it. The caller + // is responsible for cleaning up the previous directory (or + // choosing a different path). This eliminates the foot-gun of + // `remove_dir_all` wiping arbitrary user data on a typo. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + std::fs::create_dir(&path).unwrap(); + std::fs::write(path.join("stale.txt"), b"do-not-delete").unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("not an OCI image layout"), + "expected non-OCI-layout refusal error, got: {}", + msg + ); + + // Unrelated content must survive untouched. + assert_eq!( + std::fs::read(path.join("stale.txt")).unwrap(), + b"do-not-delete", + "to_oci must not delete unrelated files in the target directory" + ); +} + +#[test] +fn to_oci_refuses_existing_file() { + // Same contract for a regular file at `path`: refuse without + // touching it. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + std::fs::write(&path, b"i am a file, not a snapshot").unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("not a directory"), + "expected refusal-on-non-directory error, got: {}", + msg + ); + assert_eq!( + std::fs::read(&path).unwrap(), + b"i am a file, not a snapshot", + "to_oci must not touch a pre-existing file at the target path" + ); +} + +#[test] +fn to_oci_refuses_duplicate_tag() { + // Saving the same tag twice into one layout is rejected: the + // caller has to delete the existing tag first, or pick a + // different name. This avoids accidental in-place replacement. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("refusing to overwrite tag") && msg.contains("\"latest\""), + "expected duplicate-tag refusal error, got: {}", + msg + ); + // The first tag must still be loadable. + let _ = Snapshot::from_oci(&path, "latest").unwrap(); +} + +/// Asserts the integrity contract: a snapshot blob whose bytes have +/// been replaced (without changing length, so descriptor sizes still +/// match) must be rejected on load via digest mismatch. +#[test] +fn from_oci_rejects_snapshot_blob_byte_mutation() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + // Locate the snapshot blob via the manifest, then flip one byte + // somewhere in the middle. Length is preserved so all descriptor + // size checks still pass. Only a digest re-hash can detect this. + let blobs_dir = path.join("blobs").join("sha256"); + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap() + .to_string(); + let manifest: Value = + serde_json::from_slice(&std::fs::read(blobs_dir.join(&manifest_digest)).unwrap()).unwrap(); + let snap_digest = manifest["layers"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap() + .to_string(); + let snap_path = blobs_dir.join(&snap_digest); + let mut bytes = std::fs::read(&snap_path).unwrap(); + let mid = bytes.len() / 2; + bytes[mid] ^= 0xFF; + std::fs::write(&snap_path, &bytes).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("digest") || msg.contains("hash") || msg.contains("sha256"), + "expected digest-mismatch error, got: {}", + msg + ); +} + +/// Same idea as `from_oci_rejects_snapshot_blob_byte_mutation`, but +/// targeting the config blob. A config-blob mutation that preserves +/// the descriptor size and the structural fields the loader +/// validates today (e.g. flipping a byte inside the host-function +/// flatbuffer payload) must be caught by digest verification. +#[test] +fn from_oci_rejects_config_blob_byte_mutation() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let cfg_path = find_config_blob(&path); + let mut bytes = std::fs::read(&cfg_path).unwrap(); + // Replace the first ASCII brace `{` with a different byte that + // keeps the file the same length but yields a different sha256. + // This will also break JSON parsing, but the point is to assert + // the digest layer rejects it before the parser ever runs. + bytes[0] = b' '; + std::fs::write(&cfg_path, &bytes).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("digest") || msg.contains("hash") || msg.contains("sha256"), + "expected digest-mismatch error, got: {}", + msg + ); +} + +#[test] +fn from_oci_observes_per_path_contents() { + // `to_oci` no longer permits overwriting, so verifying that two + // independent saves have independent contents is done by writing + // each snapshot to its own path and asserting the loaded + // contents differ. + let mut sbox = create_test_sandbox(); + sbox.call::("AddToStatic", 11i32).unwrap(); + let snap_x = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path_x = dir.path().join("snap_x"); + snap_x.to_oci(&path_x, "latest").unwrap(); + + let loaded_x = Snapshot::from_oci(&path_x, "latest").unwrap(); + let mut sbox_x = + MultiUseSandbox::from_snapshot(Arc::new(loaded_x), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox_x.call::("GetStatic", ()).unwrap(), 11); + + sbox.call::("AddToStatic", 44i32).unwrap(); + let snap_y = sbox.snapshot().unwrap(); + let path_y = dir.path().join("snap_y"); + snap_y.to_oci(&path_y, "latest").unwrap(); + + let loaded_y = Snapshot::from_oci(&path_y, "latest").unwrap(); + let mut sbox_y = + MultiUseSandbox::from_snapshot(Arc::new(loaded_y), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox_y.call::("GetStatic", ()).unwrap(), 55); +} + +// ============================================================================= +// Exhaustive input-validation tests for `from_oci`. +// +// Every load-side error path in `super::file::from_oci` should be +// exercised here. +// ============================================================================= + +fn save_for_mutation() -> (tempfile::TempDir, std::path::PathBuf) { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + (dir, path) +} + +fn assert_err_contains(err: crate::HyperlightError, needle: &str) { + let msg = format!("{}", err); + assert!( + msg.contains(needle), + "expected error to contain {:?}, got: {}", + needle, + msg + ); +} + +#[test] +fn malformed_oci_layout_rejected() { + let (_dir, path) = save_for_mutation(); + std::fs::write(path.join("oci-layout"), b"not-valid-json{").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "oci-layout"); +} + +#[test] +fn oci_layout_missing_version_field_rejected() { + let (_dir, path) = save_for_mutation(); + std::fs::write(path.join("oci-layout"), r#"{"unrelated":"field"}"#).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "imageLayoutVersion"); +} + +#[test] +fn malformed_index_json_rejected() { + let (_dir, path) = save_for_mutation(); + std::fs::write(path.join("index.json"), b"{not json").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "index.json"); +} + +#[test] +fn empty_index_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + idx["manifests"] = Value::Array(Vec::new()); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "no manifest tagged"); +} + +#[test] +fn from_oci_rejects_duplicate_tag_in_index() { + // A valid OCI layout has unique tags. Two manifests sharing the + // same `org.opencontainers.image.ref.name` annotation is + // malformed and from_oci must refuse rather than silently + // pick one. + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + let first = idx["manifests"][0].clone(); + idx["manifests"].as_array_mut().unwrap().push(first); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "multiple manifests tagged"); +} + +#[test] +fn missing_manifest_blob_rejected() { + let (_dir, path) = save_for_mutation(); + std::fs::remove_file(manifest_path(&path)).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("open") || msg.contains("No such") || msg.contains("not found"), + "expected missing-manifest error, got: {}", + msg + ); +} + +#[test] +fn bad_digest_format_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + // Strip the algorithm prefix entirely. `oci-spec` validates + // descriptor digests on parse, so the index parser rejects + // this before our own digest helper sees it. + idx["manifests"][0]["digest"] = Value::from("deadbeef"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("digest") || msg.contains("index.json"), + "expected digest or parse error, got: {}", + msg + ); +} + +#[test] +fn malformed_manifest_json_rejected() { + // Probes the manifest JSON parser. Under `from_oci`, the + // digest-verification step would fire first and short-circuit + // this; that path is covered by + // `from_oci_rejects_manifest_blob_byte_mutation`. Use + // `from_oci_unchecked` here to reach the parser. + let (_dir, path) = save_for_mutation(); + let mp = manifest_path(&path); + std::fs::write(&mp, b"{not json").unwrap(); + // Update index size to match so we hit the JSON parser, not the + // size check. + let new_len = std::fs::metadata(&mp).unwrap().len(); + rewrite_index(&path, |idx| { + idx["manifests"][0]["size"] = Value::from(new_len); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + assert_err_contains(err, "manifest"); +} + +#[test] +fn wrong_manifest_schema_version_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["schemaVersion"] = Value::from(99u32); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "schemaVersion"); +} + +#[test] +fn unknown_config_media_type_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["config"]["mediaType"] = Value::from("application/vnd.example.unknown.v1+json"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "config media type"); +} + +#[test] +fn empty_layers_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["layers"] = Value::Array(Vec::new()); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "layer"); +} + +#[test] +fn extra_layers_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + let first = m["layers"][0].clone(); + m["layers"].as_array_mut().unwrap().push(first); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "layer"); +} + +#[test] +fn unknown_snapshot_layer_media_type_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["layers"][0]["mediaType"] = Value::from("application/vnd.example.unknown.v1"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "snapshot layer media type"); +} + +/// Manifest- and index-level annotations injected by third-party +/// tools (cosign, ORAS, build pipelines, etc.) must NOT break load. +/// `HlConfig` is intentionally strict (`deny_unknown_fields`) but +/// the OCI envelope around it is parsed via `oci-spec`'s lenient +/// types. +#[test] +fn manifest_and_index_annotations_tolerated() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + rewrite_manifest(&path, |m| { + let mut anns = serde_json::Map::new(); + anns.insert( + "org.opencontainers.image.created".to_string(), + Value::from("2024-01-01T00:00:00Z"), + ); + anns.insert( + "dev.sigstore.cosign/signature".to_string(), + Value::from("MEUCIQDsignature"), + ); + m["annotations"] = Value::Object(anns); + }); + rewrite_index(&path, |idx| { + let mut anns = serde_json::Map::new(); + anns.insert( + "org.opencontainers.image.ref.name".to_string(), + Value::from("v1.2.3"), + ); + idx["annotations"] = Value::Object(anns); + }); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn config_blob_size_descriptor_mismatch_rejected() { + let (_dir, path) = save_for_mutation(); + // Bump the config descriptor's claimed size by one without + // touching the actual blob. + rewrite_manifest(&path, |m| { + let sz = m["config"]["size"].as_u64().unwrap(); + m["config"]["size"] = Value::from(sz + 1); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "config blob size mismatch"); +} + +#[test] +fn malformed_config_json_rejected() { + // Probes the config JSON parser. Under `from_oci` the + // digest-verification step would fire first; that path is + // covered by `from_oci_rejects_config_blob_byte_mutation`. + // Use `from_oci_unchecked` here to reach the parser. + let (_dir, path) = save_for_mutation(); + let cfg_path = find_config_blob(&path); + std::fs::write(&cfg_path, b"{not json").unwrap(); + // Update both the manifest's config descriptor size and the + // index's manifest descriptor size to match so we reach the + // JSON parser, not the size check. + let new_cfg_len = std::fs::metadata(&cfg_path).unwrap().len(); + rewrite_manifest(&path, |m| { + m["config"]["size"] = Value::from(new_cfg_len); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + assert_err_contains(err, "config JSON"); +} + +#[test] +fn memory_size_zero_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + cfg["memory_size"] = Value::from(0u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "memory_size"); +} + +#[test] +fn memory_size_unaligned_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + let sz = cfg["memory_size"].as_u64().unwrap(); + cfg["memory_size"] = Value::from(sz + 1); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + // Either the page-alignment check or the file-size check trips. + // Both are valid signals that the value was rejected. + assert!( + msg.contains("memory_size") || msg.contains("PAGE_SIZE") || msg.contains("size"), + "expected memory_size rejection, got: {}", + msg + ); +} + +#[test] +fn bad_init_data_permissions_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + // 1u32 << 31 is well outside the defined READ|WRITE|EXECUTE bits. + cfg["layout"]["init_data_permissions"] = Value::from(0x8000_0000u32); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "init_data_permissions"); +} + +#[test] +fn entrypoint_addr_outside_snapshot_region_rejected() { + // A crafted config can claim any u64 as the entry point. The + // loader must refuse addresses that don't lie within + // [BASE_ADDRESS, BASE_ADDRESS + snapshot_size) so a malicious + // image can't direct execution into unmapped GPA space or into + // the writable scratch region. + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + let entry = cfg["entrypoint"].as_object_mut().unwrap(); + // 0xDEAD_BEEF_0000 is far above any plausible snapshot + // region (snapshot_size is bounded by MAX_MEMORY_SIZE, + // ~16 GiB) and outside guest mapped memory. + entry["addr"] = Value::from(0xDEAD_BEEF_0000u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "entrypoint addr"); +} + +#[test] +fn entrypoint_addr_below_base_address_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + let entry = cfg["entrypoint"].as_object_mut().unwrap(); + // 0 is below BASE_ADDRESS (0x1000); rejected as "outside the + // snapshot region". + entry["addr"] = Value::from(0u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "entrypoint addr"); +} + +// ============================================================================= +// `from_oci_unchecked`: skips blob digest verification but still runs +// every other validator (OCI structure, descriptor sizes, schema +// versions, arch / hypervisor / ABI tags, layout bounds, entrypoint +// bounds). +// ============================================================================= + +#[test] +fn from_oci_unchecked_round_trips() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci_unchecked(&path, "latest").unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + let result: String = sbox2.call("Echo", "hi\n".to_string()).unwrap(); + assert_eq!(result, "hi\n"); +} + +#[test] +fn from_oci_unchecked_still_validates_config_fields() { + // Field-level validators (arch, abi, hypervisor, layout bounds, + // entrypoint bounds) must still fire under `from_oci_unchecked`. + // Use `rewrite_config` so the layout stays self-consistent + // (otherwise the checked path would also catch this via the + // descriptor-size check before the field validator runs). + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + cfg["arch"] = Value::from("aarch64"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("architecture") || msg.contains("arch"), + "expected architecture mismatch under from_oci_unchecked, got: {}", + msg + ); +} + +#[test] +fn from_oci_rejects_manifest_blob_byte_mutation() { + // Mutate a manifest body byte (without updating the index's + // descriptor digest) and confirm the loader catches it via + // digest verification before any of the field-level manifest + // validators (schema version, media type, etc.) run. + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let mp = manifest_path(&path); + let mut bytes = std::fs::read(&mp).unwrap(); + // Flip the first byte. Length is preserved so the descriptor + // size check still passes; only digest verification can detect + // this. The byte will also break JSON parsing, but the digest + // check fires first. + bytes[0] ^= 0x20; + std::fs::write(&mp, &bytes).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "digest mismatch"); +} + +// ============================================================================= +// Multi-tag layouts. +// +// One OCI Image Layout directory can hold any number of snapshots, +// each addressed by tag. Blobs (manifest, config, snapshot memory) +// are deduplicated across tags by content digest. +// ============================================================================= + +#[test] +fn append_distinct_tags_to_one_layout() { + // Two distinguishable snapshots saved into the same layout + // under different tags. Each is loadable by its own tag and has + // the right contents. + let mut sbox = create_test_sandbox(); + + sbox.call::("AddToStatic", 7i32).unwrap(); + let snap_a = sbox.snapshot().unwrap(); + + sbox.call::("AddToStatic", 30i32).unwrap(); + let snap_b = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap_a.to_oci(&path, "snap-a").unwrap(); + snap_b.to_oci(&path, "snap-b").unwrap(); + + let loaded_a = Snapshot::from_oci(&path, "snap-a").unwrap(); + let loaded_b = Snapshot::from_oci(&path, "snap-b").unwrap(); + + let mut sbox_a = + MultiUseSandbox::from_snapshot(Arc::new(loaded_a), HostFunctions::default(), None).unwrap(); + let mut sbox_b = + MultiUseSandbox::from_snapshot(Arc::new(loaded_b), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox_a.call::("GetStatic", ()).unwrap(), 7); + assert_eq!(sbox_b.call::("GetStatic", ()).unwrap(), 37); +} + +#[test] +fn appending_dedupes_identical_blobs() { + // Two saves of the SAME snapshot under different tags must not + // duplicate any blob in `blobs/sha256/`. Every blob in the + // layout must be the union of the per-tag manifest, config, and + // snapshot blob digests, but with each unique digest counted + // once. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "v1").unwrap(); + snap.to_oci(&path, "v2").unwrap(); + + let blobs_dir = path.join("blobs").join("sha256"); + let blob_count = std::fs::read_dir(&blobs_dir).unwrap().count(); + // Identical snapshots share the snapshot blob, the config blob, + // and the manifest blob; only the index distinguishes them. + // Therefore exactly 3 blobs. + assert_eq!( + blob_count, 3, + "expected 3 blobs (snapshot + config + manifest, all dedup'd) in {:?}", + blobs_dir + ); + + // Both tags load and produce sandboxes with the same state. + let _ = Snapshot::from_oci(&path, "v1").unwrap(); + let _ = Snapshot::from_oci(&path, "v2").unwrap(); +} + +#[test] +fn appending_distinct_snapshots_dedups_shared_blobs() { + // Two DIFFERENT snapshots saved under different tags. Each has + // its own snapshot memory blob and its own manifest blob, but + // the config blob (which captures arch / hypervisor / layout / + // host functions / entrypoint, but not guest memory) is + // typically identical across snapshots from the same sandbox, + // so we expect 5 blobs: 2 memory + 1 shared config + 2 + // manifests. + let mut sbox = create_test_sandbox(); + sbox.call::("AddToStatic", 1i32).unwrap(); + let snap_a = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 2i32).unwrap(); + let snap_b = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap_a.to_oci(&path, "a").unwrap(); + snap_b.to_oci(&path, "b").unwrap(); + + let blobs_dir = path.join("blobs").join("sha256"); + let blob_count = std::fs::read_dir(&blobs_dir).unwrap().count(); + assert_eq!( + blob_count, 5, + "expected 5 distinct blobs (2 memory + 1 shared config + 2 manifests) in {:?}", + blobs_dir + ); +} + +#[test] +fn from_oci_unknown_tag_lists_available_tags() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "alpha").unwrap(); + snap.to_oci(&path, "beta").unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "missing")); + let msg = format!("{}", err); + assert!( + msg.contains("no manifest tagged") && msg.contains("\"missing\""), + "expected unknown-tag error mentioning the requested tag, got: {}", + msg + ); + assert!( + msg.contains("alpha") && msg.contains("beta"), + "expected available-tags listing, got: {}", + msg + ); +} + +#[test] +fn manifest_descriptor_carries_ref_name_annotation() { + // The OCI standard tag annotation must be set on the manifest + // descriptor in `index.json` so external tools (`oras`, + // `crane manifest`, `skopeo inspect`) see the tag. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "production-v3").unwrap(); + + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + let manifest = &index["manifests"][0]; + assert_eq!( + manifest["annotations"]["org.opencontainers.image.ref.name"] + .as_str() + .unwrap(), + "production-v3" + ); +} + +// ============================================================================= +// Tag validation. +// ============================================================================= + +#[test] +fn empty_tag_rejected_on_save() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let err = snap.to_oci(dir.path().join("snap"), "").unwrap_err(); + assert!(format!("{err}").contains("tag")); +} + +#[test] +fn empty_tag_rejected_on_load() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "")); + assert!(format!("{err}").contains("tag")); +} + +#[test] +fn tag_with_illegal_leading_char_rejected() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let err = snap + .to_oci(dir.path().join("snap"), ".dotleader") + .unwrap_err(); + assert!(format!("{err}").contains("tag")); + + let err = snap + .to_oci(dir.path().join("snap"), "-dashleader") + .unwrap_err(); + assert!(format!("{err}").contains("tag")); +} + +#[test] +fn tag_with_illegal_chars_rejected() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let err = snap + .to_oci(dir.path().join("snap"), "with/slash") + .unwrap_err(); + assert!(format!("{err}").contains("tag")); + + let err = snap + .to_oci(dir.path().join("snap"), "with space") + .unwrap_err(); + assert!(format!("{err}").contains("tag")); +} + +#[test] +fn long_tag_within_limit_accepted() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let tag: String = "a".repeat(128); + snap.to_oci(dir.path().join("snap"), &tag).unwrap(); + let _ = Snapshot::from_oci(dir.path().join("snap"), &tag).unwrap(); +} + +#[test] +fn over_long_tag_rejected() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let tag: String = "a".repeat(129); + let err = snap.to_oci(dir.path().join("snap"), &tag).unwrap_err(); + assert!(format!("{err}").contains("tag")); +} + +// ============================================================================= +// Append-side error paths for `to_oci`. +// ============================================================================= + +#[test] +fn to_oci_refuses_layout_with_malformed_oci_layout_marker() { + // Directory exists with a corrupt oci-layout marker. We must + // refuse to touch it, even though it superficially looks like + // an OCI layout. Same rule applies for missing version field. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + std::fs::create_dir(&path).unwrap(); + std::fs::write(path.join("oci-layout"), b"{garbage").unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + assert!( + format!("{err}").contains("oci-layout"), + "expected oci-layout marker error, got: {err}" + ); + + // The corrupt marker must not have been replaced. + assert_eq!(std::fs::read(path.join("oci-layout")).unwrap(), b"{garbage"); +} + +#[test] +fn to_oci_refuses_layout_with_oci_layout_missing_version() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + std::fs::create_dir(&path).unwrap(); + std::fs::write(path.join("oci-layout"), b"{}").unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + assert!( + format!("{err}").contains("imageLayoutVersion"), + "expected missing-version error, got: {err}" + ); +} + +#[test] +fn to_oci_refuses_layout_with_unsupported_version() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + std::fs::create_dir(&path).unwrap(); + std::fs::write( + path.join("oci-layout"), + br#"{"imageLayoutVersion":"99.0.0"}"#, + ) + .unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + assert!( + format!("{err}").contains("image layout version"), + "expected unsupported-version error, got: {err}" + ); +} + +#[test] +fn to_oci_refuses_layout_with_missing_index_on_append() { + // Layout with a valid `oci-layout` marker but no `index.json` + // is malformed. `to_oci` must refuse to append and not write a + // new (single-tag) index that would mask the corruption. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + std::fs::create_dir(&path).unwrap(); + std::fs::write( + path.join("oci-layout"), + br#"{"imageLayoutVersion":"1.0.0"}"#, + ) + .unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + assert!( + format!("{err}").contains("index.json"), + "expected index-related error, got: {err}" + ); +} + +#[test] +fn to_oci_refuses_layout_with_malformed_index_on_append() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + std::fs::create_dir(&path).unwrap(); + std::fs::write( + path.join("oci-layout"), + br#"{"imageLayoutVersion":"1.0.0"}"#, + ) + .unwrap(); + std::fs::write(path.join("index.json"), b"{not json").unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + assert!( + format!("{err}").contains("index.json"), + "expected index-parse error, got: {err}" + ); + // The malformed index must not have been overwritten. + assert_eq!( + std::fs::read(path.join("index.json")).unwrap(), + b"{not json" + ); +} + +#[test] +fn to_oci_does_not_rewrite_oci_layout_on_append() { + // The oci-layout marker is created once, on the fresh-write + // path. On append, the writer must leave it alone (no spurious + // mtime bump, no risk of clobbering a marker the user is + // happy with). + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "first").unwrap(); + + let marker_path = path.join("oci-layout"); + let mtime_before = std::fs::metadata(&marker_path).unwrap().modified().unwrap(); + // Bound the test so the second save is reliably distinguishable + // from the first if it did rewrite. Filesystems generally have + // millisecond-or-better mtime resolution. + std::thread::sleep(std::time::Duration::from_millis(50)); + + snap.to_oci(&path, "second").unwrap(); + let mtime_after = std::fs::metadata(&marker_path).unwrap().modified().unwrap(); + assert_eq!( + mtime_before, mtime_after, + "oci-layout marker must not be rewritten on append" + ); +} + +// ============================================================================= +// Save-shape invariants. Verify the on-disk JSON we hand to standard +// OCI tools matches what the spec prescribes. +// ============================================================================= + +#[test] +fn manifest_descriptor_uses_image_manifest_media_type() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + assert_eq!( + index["manifests"][0]["mediaType"].as_str().unwrap(), + "application/vnd.oci.image.manifest.v1+json" + ); +} + +#[test] +fn manifest_uses_correct_config_and_layer_media_types() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let manifest: Value = + serde_json::from_slice(&std::fs::read(manifest_path(&path)).unwrap()).unwrap(); + assert_eq!( + manifest["config"]["mediaType"].as_str().unwrap(), + "application/vnd.hyperlight.sandbox.config.v1+json" + ); + assert_eq!(manifest["layers"].as_array().unwrap().len(), 1); + assert_eq!( + manifest["layers"][0]["mediaType"].as_str().unwrap(), + "application/vnd.hyperlight.snapshot.v1" + ); +} + +#[test] +fn save_writes_oci_layout_marker() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let marker: Value = + serde_json::from_slice(&std::fs::read(path.join("oci-layout")).unwrap()).unwrap(); + assert_eq!(marker["imageLayoutVersion"].as_str().unwrap(), "1.0.0"); +} + +// ============================================================================= +// Tag selection edge cases. +// ============================================================================= + +#[test] +fn tag_lookup_is_case_sensitive() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "MyTag").unwrap(); + + // Different case must NOT match. + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "mytag")); + assert_err_contains(err, "no manifest tagged"); + + // Exact case loads. + let _ = Snapshot::from_oci(&path, "MyTag").unwrap(); +} + +#[test] +fn ref_name_annotation_key_is_case_sensitive() { + // If the index uses a misspelled annotation key (e.g. + // `org.OpenContainers.image.ref.name`), the manifest is treated + // as untagged and from_oci must not load it under any name. + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + let anns = idx["manifests"][0]["annotations"].as_object_mut().unwrap(); + let value = anns.remove("org.opencontainers.image.ref.name").unwrap(); + anns.insert("org.OpenContainers.image.ref.name".to_string(), value); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "no manifest tagged"); +} + +#[test] +fn tag_with_all_valid_special_chars_accepted() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + let tag = "v1.2.3-rc.1_build"; + snap.to_oci(&path, tag).unwrap(); + let _ = Snapshot::from_oci(&path, tag).unwrap(); +} + +#[test] +fn three_tags_in_one_layout_each_loads() { + let mut sbox = create_test_sandbox(); + sbox.call::("AddToStatic", 1i32).unwrap(); + let s_a = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 2i32).unwrap(); + let s_b = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 4i32).unwrap(); + let s_c = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + s_a.to_oci(&path, "a").unwrap(); + s_b.to_oci(&path, "b").unwrap(); + s_c.to_oci(&path, "c").unwrap(); + + for (tag, expected) in [("a", 1), ("b", 3), ("c", 7)] { + let loaded = Snapshot::from_oci(&path, tag).unwrap(); + let mut s = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None) + .unwrap(); + assert_eq!( + s.call::("GetStatic", ()).unwrap(), + expected, + "tag {tag}" + ); + } +} + +#[test] +fn other_descriptor_annotations_do_not_interfere() { + // A manifest descriptor with the standard ref.name annotation + // PLUS unrelated annotations (cosign signatures, build + // pipelines, etc.) must still resolve by tag. + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + let anns = idx["manifests"][0]["annotations"].as_object_mut().unwrap(); + anns.insert( + "dev.sigstore.cosign/signature".to_string(), + Value::from("MEUCIQDfake"), + ); + anns.insert("io.example.build.id".to_string(), Value::from("12345")); + }); + let _ = Snapshot::from_oci(&path, "latest").unwrap(); +} + +// ============================================================================= +// Bad sha256 digest format on the inner descriptors (config and snapshot +// layer). The index-side equivalent is `bad_digest_format_rejected`. +// ============================================================================= + +#[test] +fn bad_config_descriptor_digest_format_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["config"]["digest"] = Value::from("md5:deadbeef"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{err}"); + assert!( + msg.contains("digest"), + "expected digest-format error, got: {msg}" + ); +} + +#[test] +fn bad_snapshot_layer_descriptor_digest_format_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["layers"][0]["digest"] = Value::from("sha256:tooshort"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{err}"); + assert!( + msg.contains("digest"), + "expected digest-format error, got: {msg}" + ); +} + +// ============================================================================= +// Missing inner blobs. +// ============================================================================= + +#[test] +fn missing_config_blob_rejected() { + let (_dir, path) = save_for_mutation(); + let cfg_path = find_config_blob(&path); + std::fs::remove_file(&cfg_path).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{err}"); + assert!( + msg.contains("open") || msg.contains("No such") || msg.contains("not found"), + "expected missing-config-blob error, got: {msg}" + ); +} + +// ============================================================================= +// Size-bound enforcement. +// ============================================================================= + +#[test] +fn manifest_blob_too_large_rejected() { + // The manifest reader bounds to 1 MiB. Replace the manifest + // with junk longer than that and confirm the bound trips + // before any parsing. + let (_dir, path) = save_for_mutation(); + let mp = manifest_path(&path); + let huge = vec![b'a'; (1024 * 1024 + 16) as usize]; + std::fs::write(&mp, &huge).unwrap(); + // Update descriptor size to match so we hit the bound check, + // not the size mismatch check. + rewrite_index(&path, |idx| { + idx["manifests"][0]["size"] = Value::from(huge.len() as u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + assert_err_contains(err, "exceeds maximum allowed"); +} + +#[test] +fn config_blob_too_large_rejected() { + let (_dir, path) = save_for_mutation(); + let cfg_path = find_config_blob(&path); + let huge = vec![b'a'; (1024 * 1024 + 16) as usize]; + std::fs::write(&cfg_path, &huge).unwrap(); + rewrite_manifest(&path, |m| { + m["config"]["size"] = Value::from(huge.len() as u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + assert_err_contains(err, "exceeds maximum allowed"); +} + +#[test] +fn memory_size_too_large_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + // 16 GiB exceeds MAX_MEMORY_SIZE. + cfg["memory_size"] = Value::from(16u64 * 1024 * 1024 * 1024); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "memory_size"); +} + +#[test] +fn snapshot_descriptor_size_disagrees_with_file_rejected() { + // Snapshot descriptor claims a different size than the actual + // blob file. The loader must reject before mmap-ing. + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + let sz = m["layers"][0]["size"].as_u64().unwrap(); + m["layers"][0]["size"] = Value::from(sz + 1); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + let msg = format!("{err}"); + assert!( + msg.contains("snapshot blob size"), + "expected snapshot-blob descriptor disagreement error, got: {msg}" + ); +} + +// ============================================================================= +// `from_oci_unchecked` shares the same non-digest validators with +// `from_oci`. The key safety claim of the unchecked path is that it +// is faster, NOT that it is more permissive about anything other +// than digest checks. Pin that contract down here. +// ============================================================================= + +#[test] +fn from_oci_unchecked_validates_tag_format() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "bad/tag")); + assert_err_contains(err, "tag"); +} + +#[test] +fn from_oci_unchecked_rejects_unknown_tag() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "nosuch")); + assert_err_contains(err, "no manifest tagged"); +} + +#[test] +fn from_oci_unchecked_rejects_path_not_directory() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("not-a-dir"); + std::fs::write(&file_path, b"hi").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&file_path, "latest")); + assert_err_contains(err, "not a directory"); +} + +#[test] +fn from_oci_unchecked_rejects_missing_oci_layout_marker() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + std::fs::remove_file(path.join("oci-layout")).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + assert_err_contains(err, "oci-layout"); +} + +// ============================================================================= +// Round-trip data fidelity. +// +// The serde shape tests already prove individual fields parse, but +// they don't prove that all the values that came out of the producer +// reach the loaded snapshot. These tests pin down full round-trip +// fidelity for fields that are not exercised by the +// "load-then-call-the-guest" round-trip tests above. +// ============================================================================= + +#[test] +fn round_trip_preserves_stack_top_gva() { + let mut sbox = create_test_sandbox(); + let snap = sbox.snapshot().unwrap(); + let original = snap.stack_top_gva(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + assert_eq!(loaded.stack_top_gva(), original); +} + +#[test] +fn round_trip_preserves_non_default_scratch_size() { + use crate::sandbox::SandboxConfiguration; + let mut cfg = SandboxConfiguration::default(); + let custom_scratch: usize = 256 * 1024; + cfg.set_scratch_size(custom_scratch); + let snap = Snapshot::from_env( + GuestBinary::FilePath(simple_guest_as_string().unwrap()), + cfg, + ) + .unwrap(); + let original = snap.layout().get_scratch_size(); + assert_eq!(original, custom_scratch); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + assert_eq!(loaded.layout().get_scratch_size(), custom_scratch); +} + +#[test] +fn pre_init_snapshot_writes_initialise_entrypoint_kind() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let cfg: Value = + serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap(); + assert_eq!(cfg["entrypoint"]["kind"].as_str().unwrap(), "initialise"); + assert!( + cfg["entrypoint"].get("sregs").is_none(), + "Initialise snapshot must not carry sregs in the config" + ); +} + +#[test] +fn already_initialised_snapshot_writes_call_entrypoint_kind() { + let mut sbox = create_test_sandbox(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let cfg: Value = + serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap(); + assert_eq!(cfg["entrypoint"]["kind"].as_str().unwrap(), "call"); + assert!( + cfg["entrypoint"]["sregs"].is_object(), + "Call snapshot must carry sregs in the config" + ); +} + +#[test] +fn round_trip_preserves_host_function_signatures() { + // Save a snapshot with a custom host function signature, load + // it, and confirm the recorded signatures survive. + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + + let cfg: Value = + serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap(); + let funcs = cfg["host_functions"].as_array().unwrap(); + let add = funcs + .iter() + .find(|f| f["function_name"].as_str().unwrap() == "Add") + .expect("Add must be recorded"); + assert_eq!( + add["parameter_types"].as_array().unwrap().len(), + 2, + "Add signature must record two parameters" + ); + // Loading and using the snapshot must accept the same signature. + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let _ = MultiUseSandbox::from_snapshot(Arc::new(loaded), host_funcs_with_matching_add(), None) + .unwrap(); +} + +#[test] +fn snapshot_with_no_host_functions_round_trips() { + // A snapshot with `host_functions: []` must round-trip without + // confusing the loader (which has special handling for the + // empty-vs-None case). + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + + let cfg: Value = + serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap(); + assert!( + cfg["host_functions"].as_array().unwrap().is_empty(), + "expected empty host_functions array for pre-init snapshot" + ); + + // The default HostFunctions set is sufficient because the + // snapshot requires nothing. + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let _ = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); +} + +// ============================================================================= +// Snapshot lineage and restore semantics. +// +// Hyperlight's snapshot model is NOT a tree. Each `MultiUseSandbox` +// has a process-local `sandbox_id`; `snapshot()` tags the snapshot +// with that id; `from_snapshot(snap)` adopts `snap.sandbox_id()` so +// the new sandbox can restore back to it; and `restore(snap)` +// requires `self.id == snap.sandbox_id()`. So sandboxes built from +// clones of the same `Arc` form a flat id-equivalence +// class within which restore is freely interchangeable. +// +// These tests pin down all the combinations of build-from-snapshot, +// take-more-snapshots, restore-out-of-order, and reject-across-class +// that follow from that model. +// ============================================================================= + +#[test] +fn linear_chain_restore_in_order() { + // Take three snapshots at different states in one sandbox, then + // restore to each in chronological order. After each restore, + // the static counter must read the value it had when that + // snapshot was taken. + let mut sbox = create_test_sandbox(); + let s0 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 10i32).unwrap(); + let s10 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 20i32).unwrap(); + let s30 = sbox.snapshot().unwrap(); + + sbox.restore(s0.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + sbox.restore(s10.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 10); + sbox.restore(s30.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 30); +} + +#[test] +fn linear_chain_restore_out_of_order() { + // Restore through the same chain but in a non-monotonic order + // (forward, back, forward, back). Snapshots within one + // id-equivalence class are NOT ordered by when they were + // taken: any can be restored to from any other. + let mut sbox = create_test_sandbox(); + let s0 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 7i32).unwrap(); + let s7 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 100i32).unwrap(); + let s107 = sbox.snapshot().unwrap(); + + let order = [&s107, &s0, &s7, &s107, &s0]; + let expected = [107, 0, 7, 107, 0]; + for (snap, want) in order.iter().zip(expected.iter()) { + sbox.restore((*snap).clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), *want); + } +} + +#[test] +fn restore_then_call_then_snapshot_then_restore() { + // Restore changes the live state, but it must NOT invalidate + // the snapshot that was just used. After restoring to S1, the + // sandbox can still take a new snapshot and restore back to + // either S1 or the new one. + let mut sbox = create_test_sandbox(); + let s_init = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 4i32).unwrap(); + + // Restore back to init. + sbox.restore(s_init.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + + // Mutate again, snapshot, mutate further. + sbox.call::("AddToStatic", 9i32).unwrap(); + let s_post_restore = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 100i32).unwrap(); + + // Restore to either reachable snapshot. + sbox.restore(s_post_restore.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 9); + sbox.restore(s_init.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn restore_idempotent() { + // Restoring to the same snapshot twice in a row must produce + // the same observable state both times. + let mut sbox = create_test_sandbox(); + sbox.call::("AddToStatic", 11i32).unwrap(); + let s = sbox.snapshot().unwrap(); + + sbox.call::("AddToStatic", 22i32).unwrap(); + sbox.restore(s.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 11); + + // No mutation between restores. + sbox.restore(s.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 11); + + // Mutation after the second restore must take effect. + sbox.call::("AddToStatic", 1i32).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 12); +} + +#[test] +fn from_snapshot_then_snapshot_then_restore_to_both() { + // Build sandbox B from snapshot S0 (B inherits S0's id). + // B takes its own snapshot S1 (also tagged with S0's id). Both + // S0 and S1 must be reachable from B via `restore`. + // + // Note: only snapshots taken from a RUNNING sandbox (with + // sregs) are valid restore targets. We therefore start from a + // snapshot of a running sandbox, not a pre-init snapshot. + let mut seed = create_test_sandbox(); + let s0 = seed.snapshot().unwrap(); + + let mut b = MultiUseSandbox::from_snapshot(s0.clone(), HostFunctions::default(), None).unwrap(); + b.call::("AddToStatic", 5i32).unwrap(); + let s1 = b.snapshot().unwrap(); + b.call::("AddToStatic", 10i32).unwrap(); + + // Restore back to S1. + b.restore(s1.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 5); + + // Restore back further to the constructor snapshot S0. + b.restore(s0.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn arc_clone_lineage_two_sandboxes_each_restores_to_either() { + // Two sandboxes built from the SAME Arc share the + // sandbox_id. Each takes its own snapshot. Each must be + // restorable to (a) its own derived snapshot, (b) the shared + // root snapshot, and (c) the OTHER sandbox's derived snapshot + // (because all four snapshots share one id). + // + // Note: the shared root must be a running-sandbox snapshot so + // that restore() can use its sregs. + let mut seed = create_test_sandbox(); + let snap_root = seed.snapshot().unwrap(); + + let mut a = + MultiUseSandbox::from_snapshot(snap_root.clone(), HostFunctions::default(), None).unwrap(); + let mut b = + MultiUseSandbox::from_snapshot(snap_root.clone(), HostFunctions::default(), None).unwrap(); + + a.call::("AddToStatic", 3i32).unwrap(); + let snap_a = a.snapshot().unwrap(); + + b.call::("AddToStatic", 70i32).unwrap(); + let snap_b = b.snapshot().unwrap(); + + // a: own snap then root then b's snap. + a.restore(snap_a.clone()).unwrap(); + assert_eq!(a.call::("GetStatic", ()).unwrap(), 3); + a.restore(snap_root.clone()).unwrap(); + assert_eq!(a.call::("GetStatic", ()).unwrap(), 0); + a.restore(snap_b.clone()).unwrap(); + assert_eq!(a.call::("GetStatic", ()).unwrap(), 70); + + // b: cross-restore the other way. + b.restore(snap_a.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 3); + b.restore(snap_root.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 0); + b.restore(snap_b.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 70); +} + +#[test] +fn separate_from_snapshot_calls_share_id_class_through_lineage() { + // Build sandbox A from a running-sandbox snapshot snap_root. + // A takes snap_a. Then build sandbox B from snap_a (a different + // Arc, but B adopts snap_a.sandbox_id == snap_root.sandbox_id). + // B must be restorable to BOTH snap_a and snap_root because + // they all share one id. + let mut seed = create_test_sandbox(); + let snap_root = seed.snapshot().unwrap(); + + let mut a = + MultiUseSandbox::from_snapshot(snap_root.clone(), HostFunctions::default(), None).unwrap(); + a.call::("AddToStatic", 5i32).unwrap(); + let snap_a = a.snapshot().unwrap(); + + let mut b = + MultiUseSandbox::from_snapshot(snap_a.clone(), HostFunctions::default(), None).unwrap(); + b.restore(snap_a.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 5); + b.restore(snap_root.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn separate_oci_loads_yield_independent_id_classes() { + // `Snapshot::from_oci` assigns a fresh id on every load. Two + // loads of the same OCI tag therefore produce snapshots with + // DIFFERENT ids. Sandboxes built from one cannot restore to a + // snapshot from the other, even though the underlying bytes + // are identical. + // + // Save a running-sandbox snapshot so the loaded snapshot is + // a valid restore target. + let mut seed = create_test_sandbox(); + let snap = seed.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "v1").unwrap(); + + let s_x = Arc::new(Snapshot::from_oci(&path, "v1").unwrap()); + let s_y = Arc::new(Snapshot::from_oci(&path, "v1").unwrap()); + + let mut sbox_x = + MultiUseSandbox::from_snapshot(s_x.clone(), HostFunctions::default(), None).unwrap(); + let err = sbox_x.restore(s_y.clone()).unwrap_err(); + assert!( + format!("{err}").to_lowercase().contains("snapshot") + || format!("{err}").to_lowercase().contains("mismatch"), + "expected sandbox/snapshot id-mismatch error, got: {err}" + ); + + // sbox_x can still restore to its own loaded snapshot. + sbox_x.restore(s_x.clone()).unwrap(); + assert_eq!(sbox_x.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn oci_loaded_snapshot_supports_full_lifecycle() { + // Full round-trip: save (from a running sandbox so the loaded + // snapshot is a valid restore target), load, build sandbox, + // mutate, snapshot, mutate, restore, mutate, snapshot, restore. + // Both pre- and post-load snapshots in the loaded id class must + // remain restore-compatible across an arbitrary number of + // cycles. + let mut seed = create_test_sandbox(); + let snap = seed.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "v1").unwrap(); + + let loaded = Arc::new(Snapshot::from_oci(&path, "v1").unwrap()); + let mut sbox = + MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None).unwrap(); + + sbox.call::("AddToStatic", 1i32).unwrap(); + let s1 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 2i32).unwrap(); + let s3 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 4i32).unwrap(); + + sbox.restore(s1.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 1); + sbox.restore(s3.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 3); + sbox.restore(loaded.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + + // Take a fresh snapshot post-restore. It is in the same id + // class and remains interchangeable with the others. + let s_post = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 50i32).unwrap(); + sbox.restore(s_post.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + sbox.restore(s3.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 3); +} + +#[test] +fn restore_invariant_under_intermediate_mutations() { + // Restoring to S followed by an arbitrary number of + // mutate-then-restore cycles must always produce the same + // post-restore observable state. This is the core "snapshot + // and restore really mean what they say" property. + let mut sbox = create_test_sandbox(); + sbox.call::("AddToStatic", 13i32).unwrap(); + let s = sbox.snapshot().unwrap(); + + let mutations = [3, 5, 7, 11, 13, 17, 19]; + for m in mutations { + sbox.call::("AddToStatic", m).unwrap(); + sbox.restore(s.clone()).unwrap(); + assert_eq!( + sbox.call::("GetStatic", ()).unwrap(), + 13, + "restore must reset to the snapshotted value regardless of intermediate mutation {m}" + ); + } +} + +#[test] +fn many_arc_clones_one_snapshot_share_id() { + // Cloning Arc N times yields N references with + // identical sandbox_id. Each sandbox built from a clone shares + // the id and is mutually restore-compatible. Verifies that the + // id-equivalence-class semantics hold for arbitrary fan-out. + // + // The shared root must be a running-sandbox snapshot so the + // sandboxes can restore to it. + let mut seed = create_test_sandbox(); + let snap = seed.snapshot().unwrap(); + let mut sandboxes: Vec = (0..4) + .map(|_| { + MultiUseSandbox::from_snapshot(snap.clone(), HostFunctions::default(), None).unwrap() + }) + .collect(); + + // Each sandbox takes its own derived snapshot tagged with a + // unique value. + let mut snaps: Vec> = Vec::new(); + for (i, s) in sandboxes.iter_mut().enumerate() { + s.call::("AddToStatic", (i as i32 + 1) * 10).unwrap(); + snaps.push(s.snapshot().unwrap()); + } + + // Every sandbox can restore to every snapshot in the class. + for (i, sbox) in sandboxes.iter_mut().enumerate() { + for (j, target) in snaps.iter().enumerate() { + sbox.restore(target.clone()).unwrap(); + let want = (j as i32 + 1) * 10; + assert_eq!( + sbox.call::("GetStatic", ()).unwrap(), + want, + "sandbox {i} restored to snapshot {j} should observe value {want}" + ); + } + // And to the root snapshot. + sbox.restore(snap.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + } +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/mod.rs index e4c7b1133..878b9040c 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/mod.rs @@ -14,9 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +mod file; +mod file_tests; + use std::collections::{BTreeMap, HashMap}; use std::sync::atomic::{AtomicU64, Ordering}; +use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; use hyperlight_common::layout::{scratch_base_gpa, scratch_base_gva}; use hyperlight_common::vmem; use hyperlight_common::vmem::{ @@ -24,7 +28,6 @@ use hyperlight_common::vmem::{ }; use tracing::{Span, instrument}; -use crate::HyperlightError::MemoryRegionSizeMismatch; use crate::Result; use crate::hypervisor::regs::CommonSpecialRegisters; use crate::mem::exe::{ExeInfo, LoadInfo}; @@ -88,14 +91,6 @@ pub struct Snapshot { /// things like persisting a snapshot and reloading it are likely /// to destroy this information. load_info: LoadInfo, - /// The hash of the other portions of the snapshot. Morally, this - /// is just a memoization cache for [`hash`], below, but it is not - /// a [`std::sync::OnceLock`] because it may be persisted to disk - /// without being recomputed on load. - /// - /// It is not a [`blake3::Hash`] because we do not presently - /// require constant-time equality checking - hash: [u8; 32], /// The address of the top of the guest stack stack_top_gva: u64, @@ -115,6 +110,13 @@ pub struct Snapshot { /// restored sandbox's guest-visible counter so the guest can tell /// which snapshot it is currently a clone of. snapshot_generation: u64, + + /// Names and signatures of host functions registered on the + /// sandbox at the time this snapshot was taken. Used by + /// [`crate::MultiUseSandbox::from_snapshot`] to reject a + /// `HostFunctions` set that is missing required functions or + /// has mismatched signatures. + host_functions: HostFunctionDetails, } impl core::convert::AsRef for Snapshot { fn as_ref(&self) -> &Self { @@ -152,41 +154,6 @@ impl hyperlight_common::vmem::TableReadOps for Snapshot { } } -/// Compute a deterministic hash of a snapshot. -/// -/// This does not include the load info from the snapshot, because -/// that is only used for debugging builds. -fn hash(memory: &[u8], regions: &[MemoryRegion]) -> Result<[u8; 32]> { - let mut hasher = blake3::Hasher::new(); - hasher.update(memory); - for rgn in regions { - hasher.update(&usize::to_le_bytes(rgn.guest_region.start)); - let guest_len = rgn.guest_region.end - rgn.guest_region.start; - #[allow(clippy::useless_conversion)] - let host_start_addr: usize = rgn.host_region.start.into(); - #[allow(clippy::useless_conversion)] - let host_end_addr: usize = rgn.host_region.end.into(); - hasher.update(&usize::to_le_bytes(host_start_addr)); - let host_len = host_end_addr - host_start_addr; - if guest_len != host_len { - return Err(MemoryRegionSizeMismatch( - host_len, - guest_len, - format!("{:?}", rgn), - )); - } - // Ignore [`MemoryRegion::region_type`], since it is extra - // information for debugging rather than a core part of the - // identity of the snapshot/workload. - hasher.update(&usize::to_le_bytes(guest_len)); - hasher.update(&u32::to_le_bytes(rgn.flags.bits())); - } - // Ignore [`load_info`], since it is extra information for - // debugging rather than a core part of the identity of the - // snapshot/workload. - Ok(hasher.finalize().into()) -} - pub(crate) fn access_gpa<'a>( snap: &'a [u8], scratch: &'a [u8], @@ -410,19 +377,20 @@ impl Snapshot { + 1; let extra_regions = Vec::new(); - let hash = hash(&memory, &extra_regions)?; Ok(Self { sandbox_id: SANDBOX_CONFIGURATION_COUNTER.fetch_add(1, Ordering::Relaxed), - memory: ReadonlySharedMemory::from_bytes(&memory)?, + memory: ReadonlySharedMemory::from_bytes(&memory, Some(layout.snapshot_size))?, layout, regions: extra_regions, load_info, - hash, stack_top_gva: exn_stack_top_gva, sregs: None, entrypoint: NextAction::Initialise(load_addr + entrypoint_va - base_va), snapshot_generation: 0, + host_functions: HostFunctionDetails { + host_functions: None, + }, }) } @@ -447,6 +415,7 @@ impl Snapshot { sregs: CommonSpecialRegisters, entrypoint: NextAction, snapshot_generation: u64, + host_functions: HostFunctionDetails, ) -> Result { let mut phys_seen = HashMap::::new(); let scratch_gva = scratch_base_gva(layout.get_scratch_size()); @@ -582,9 +551,12 @@ impl Snapshot { Ok::<_, crate::HyperlightError>(snapshot_memory) }) })???; - // Only map the data portion into guest PA space. The PT tail - // must stay out of the KVM slot to avoid overlapping with - // map_file_cow regions that sit right after the snapshot. + // Only the data prefix is exposed to the guest. The PT tail + // sits past it in the host mapping and is copied into the + // scratch region on restore. Keeping it out of the guest + // mapping of the snapshot region avoids overlap with + // `map_file_cow` regions installed immediately after the + // snapshot in guest PA space. let guest_visible_size = memory.len() - layout.get_pt_size(); debug_assert!(guest_visible_size.is_multiple_of(PAGE_SIZE)); layout.set_snapshot_size(guest_visible_size); @@ -598,18 +570,17 @@ impl Snapshot { // PAs overlap the old region PAs. let regions: Vec = Vec::new(); - let hash = hash(&memory, ®ions)?; Ok(Self { sandbox_id, layout, - memory: ReadonlySharedMemory::from_bytes_with_mapped_size(&memory, guest_visible_size)?, + memory: ReadonlySharedMemory::from_bytes(&memory, Some(guest_visible_size))?, regions, load_info, - hash, stack_top_gva, sregs: Some(sregs), entrypoint, snapshot_generation, + host_functions, }) } @@ -663,17 +634,71 @@ impl Snapshot { pub(crate) fn entrypoint(&self) -> NextAction { self.entrypoint } -} -impl PartialEq for Snapshot { - fn eq(&self, other: &Snapshot) -> bool { - self.hash == other.hash + /// Validate that `provided` is a superset of the host functions + /// recorded in this snapshot: every function that was registered + /// at snapshot time must also be present in `provided` with a + /// matching signature. Extras in `provided` are allowed. + /// + /// A snapshot with no recorded host functions (e.g. one + /// produced by a test-only constructor) accepts any `provided` + /// set. + pub(crate) fn validate_host_functions(&self, provided: &crate::HostFunctions) -> Result<()> { + let required = match &self.host_functions.host_functions { + Some(v) => v, + None => return Ok(()), + }; + if required.is_empty() { + return Ok(()); + } + + // Build a HostFunctionDetails view of the provided registry + // using the existing `From<&FunctionRegistry>` impl. + let provided_details: HostFunctionDetails = provided.inner().into(); + let provided_funcs = provided_details.host_functions.unwrap_or_default(); + + let mut missing: Vec = Vec::new(); + let mut signature_mismatches: Vec = Vec::new(); + + for req in required { + match provided_funcs + .iter() + .find(|f| f.function_name == req.function_name) + { + None => missing.push(req.function_name.clone()), + Some(found) + if found.parameter_types != req.parameter_types + || found.return_type != req.return_type => + { + signature_mismatches.push(format!( + "{}: snapshot has {:?} -> {:?}, registered {:?} -> {:?}", + req.function_name, + req.parameter_types, + req.return_type, + found.parameter_types, + found.return_type, + )); + } + Some(_) => {} + } + } + + if missing.is_empty() && signature_mismatches.is_empty() { + return Ok(()); + } + + Err(crate::new_error!( + "snapshot host function mismatch: missing={:?}, signature_mismatches={:?}", + missing, + signature_mismatches + )) } } #[cfg(test)] #[cfg(not(feature = "i686-guest"))] mod tests { + use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; use hyperlight_common::vmem::{self, BasicMapping, Mapping, MappingKind, PAGE_SIZE}; use crate::hypervisor::regs::CommonSpecialRegisters; @@ -710,7 +735,7 @@ mod tests { let mut snapshot_mem = vec![0u8; PAGE_SIZE + pt_bytes.len()]; snapshot_mem[0..PAGE_SIZE].copy_from_slice(contents); snapshot_mem[PAGE_SIZE..].copy_from_slice(&pt_bytes); - ReadonlySharedMemory::from_bytes(&snapshot_mem) + ReadonlySharedMemory::from_bytes(&snapshot_mem, None) .unwrap() .to_mgr_snapshot_mem() .unwrap() @@ -747,6 +772,7 @@ mod tests { default_sregs(), super::NextAction::None, 1, + HostFunctionDetails::default(), ) .unwrap(); @@ -764,6 +790,7 @@ mod tests { default_sregs(), super::NextAction::None, 2, + HostFunctionDetails::default(), ) .unwrap(); diff --git a/src/hyperlight_host/src/sandbox/uninitialized.rs b/src/hyperlight_host/src/sandbox/uninitialized.rs index 23c01be28..b65b1e655 100644 --- a/src/hyperlight_host/src/sandbox/uninitialized.rs +++ b/src/hyperlight_host/src/sandbox/uninitialized.rs @@ -22,7 +22,7 @@ use std::sync::{Arc, Mutex}; use tracing::{Span, instrument}; use tracing_core::LevelFilter; -use super::host_funcs::{FunctionRegistry, default_writer_func}; +use super::host_funcs::FunctionRegistry; use super::snapshot::Snapshot; use super::uninitialized_evolve::evolve_impl_multi_use; use crate::func::host_functions::{HostFunction, register_host_function}; @@ -365,9 +365,9 @@ impl UninitializedSandbox { let mem_mgr_wrapper = SandboxMemoryManager::::from_snapshot(snapshot.as_ref())?; - let host_funcs = Arc::new(Mutex::new(FunctionRegistry::default())); + let host_funcs = Arc::new(Mutex::new(FunctionRegistry::with_default_host_print())); - let mut sandbox = Self { + let sandbox = Self { host_funcs, mgr: mem_mgr_wrapper, max_guest_log_level: None, @@ -383,9 +383,6 @@ impl UninitializedSandbox { pending_file_mappings: Vec::new(), }; - // If we were passed a writer for host print register it otherwise use the default. - sandbox.register_print(default_writer_func)?; - crate::debug!("Sandbox created: {:#?}", sandbox); Ok(sandbox)