diff --git a/CHANGELOG.md b/CHANGELOG.md index 177b60918..755e50454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,13 +24,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). fallback dispatch. * Support overriding the guest log level when building or restoring initialized snapshots. -* `Snapshot::save` now writes the guest memory blob sparsely, skipping all-zero - blocks instead of writing them. A guest memory image is mostly untouched - pages, so this cuts the bytes actually written by roughly the proportion of - the guest's memory it never touched. The saved layout is byte-for-byte - identical and its digest is unchanged, so this is transparent to readers and - to previously saved snapshots. Filesystems that do not support sparse files - store the blob as before. * Expose C guest `ByteChunks` values as pointer and length arrays. * Return typed `hl_ReturnValue` objects from C guest functions through `hl_result_from_*` constructors. diff --git a/src/hyperlight_host/Cargo.toml b/src/hyperlight_host/Cargo.toml index ebf3ddead..80c81b60e 100644 --- a/src/hyperlight_host/Cargo.toml +++ b/src/hyperlight_host/Cargo.toml @@ -73,13 +73,7 @@ windows = { version = "0.62", features = [ "Win32_System_JobObjects", "Win32_System_SystemServices", ] } -windows-sys = { version = "0.61", features = [ - "Win32", - # FSCTL_SET_SPARSE and DeviceIoControl, used to mark the snapshot - # blob sparse. Not implied by "Win32". - "Win32_System_IO", - "Win32_System_Ioctl", -] } +windows-sys = { version = "0.61", features = ["Win32"] } windows-result = "0.4" rust-embed = { version = "8.11.0", features = ["debug-embed", "include-exclude", "interpolate-folder-path"] } windows-version = "0.1" diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/fsutil.rs b/src/hyperlight_host/src/sandbox/snapshot/file/fsutil.rs index d6aeacd02..abd74e87e 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/fsutil.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/fsutil.rs @@ -1,141 +1,27 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 The Hyperlight Authors. -use std::io::{Read, Seek, SeekFrom, Write}; +use std::io::{Read, Write}; use std::path::Path; use tempfile::NamedTempFile; use super::digest::{Digest256, verify_blob_file}; -/// Granularity at which [`write_sparse`] looks for all-zero regions. -/// A hole can only exist on a filesystem-block boundary, and 4 KiB is -/// the block size of every filesystem we target, so scanning finer -/// would cost comparisons that can never turn into a hole. -const SPARSE_BLOCK: usize = 4096; - -/// Ask the filesystem to treat `file` as sparse, so that ranges left -/// unwritten by [`write_sparse`] become holes. -/// -/// Unix filesystems make a file sparse implicitly, so there is -/// nothing to request. NTFS does not: unless a file is explicitly -/// marked sparse, it zero-fills any range skipped by a seek, and the -/// file ends up fully allocated. Marking it costs one ioctl on an -/// empty file. -/// -/// Best-effort by design. A filesystem that does not support sparse -/// files (FAT32, exFAT) rejects the request, which is not an error: -/// the write that follows is still correct, just dense. -#[cfg(not(windows))] -fn try_set_sparse(_file: &std::fs::File) {} - -#[cfg(windows)] -fn try_set_sparse(file: &std::fs::File) { - use std::os::windows::io::AsRawHandle; - - use windows_sys::Win32::System::IO::DeviceIoControl; - use windows_sys::Win32::System::Ioctl::FSCTL_SET_SPARSE; - - let mut returned: u32 = 0; - // SAFETY: the handle is valid and owned by `file` for the - // duration of the call. FSCTL_SET_SPARSE takes no input or output - // buffer, so all buffer pointers are null with zero lengths, and - // `returned` is a valid writable u32. The return value is - // deliberately ignored: see the doc comment. - unsafe { - DeviceIoControl( - file.as_raw_handle() as _, - FSCTL_SET_SPARSE, - std::ptr::null(), - 0, - std::ptr::null_mut(), - 0, - &mut returned, - std::ptr::null_mut(), - ); - } -} - -/// Write `bytes` to `file`, seeking over all-zero blocks instead of -/// writing them. -/// -/// `write_all` allocates blocks for zeros like any other data: the -/// kernel cannot know the caller would accept a hole. Seeking past a -/// range and extending the file with `set_len` leaves that range -/// unwritten, and a filesystem that supports sparse files reports it -/// as a hole. -/// -/// The result is byte-for-byte identical to a dense write — reads of -/// a hole return zeros — so this is invisible to anything that reads -/// the file, including digest verification. Only the blocks the file -/// occupies on disk differ. On a filesystem without sparse support -/// the range is zero-filled instead, which is still correct. -/// -/// Runs of adjacent non-zero blocks are written in a single call, so -/// a blob with no zeros costs exactly one `write_all` plus one seek. -fn write_sparse(file: &mut std::fs::File, bytes: &[u8]) -> std::io::Result<()> { - // Must happen while the file is still empty, and before any seek - // past a gap, or NTFS zero-fills the skipped ranges instead of - // leaving holes. - try_set_sparse(file); - let zero = [0u8; SPARSE_BLOCK]; - let len = bytes.len(); - let mut pos = 0; - while pos < len { - let end = (pos + SPARSE_BLOCK).min(len); - // Skipped: leave a hole and move on. - if bytes[pos..end] == zero[..end - pos] { - pos = end; - continue; - } - // Extend over adjacent non-zero blocks so the whole run goes - // out in one write rather than one write per block. - let start = pos; - pos = end; - while pos < len { - let block_end = (pos + SPARSE_BLOCK).min(len); - if bytes[pos..block_end] == zero[..block_end - pos] { - break; - } - pos = block_end; - } - file.seek(SeekFrom::Start(start as u64))?; - file.write_all(&bytes[start..pos])?; - } - // The file must reach its full length even when it ends in zeros, - // which are skipped above and so never extend it. - file.set_len(len as u64)?; - Ok(()) -} - /// Replace `target` atomically: a reader either sees the old /// contents or the full new contents, never a partial write. A /// failure before commit leaves `target` untouched and removes the /// staging file. pub(super) fn replace_file_atomic(target: &Path, bytes: &[u8]) -> crate::Result<()> { - replace_file_atomic_inner(target, bytes, false) -} - -/// [`replace_file_atomic`], but skipping all-zero blocks so the file -/// lands sparse. See [`write_sparse`] for why the contents are -/// unchanged. -pub(super) fn replace_file_atomic_sparse(target: &Path, bytes: &[u8]) -> crate::Result<()> { - replace_file_atomic_inner(target, bytes, true) -} - -fn replace_file_atomic_inner(target: &Path, bytes: &[u8], sparse: bool) -> crate::Result<()> { let parent = target.parent().ok_or_else(|| { crate::new_error!("atomic write: target {:?} has no parent directory", target) })?; let mut tmp = NamedTempFile::new_in(parent).map_err(|e| { crate::new_error!("atomic write: failed to create tmp in {:?}: {}", parent, e) })?; - if sparse { - write_sparse(tmp.as_file_mut(), bytes) - } else { - tmp.write_all(bytes) - } - .map_err(|e| crate::new_error!("atomic write: failed to write tmp {:?}: {}", tmp.path(), e))?; + tmp.write_all(bytes).map_err(|e| { + crate::new_error!("atomic write: failed to write tmp {:?}: {}", tmp.path(), e) + })?; tmp.as_file_mut().sync_all().map_err(|e| { crate::new_error!("atomic write: failed to sync tmp {:?}: {}", tmp.path(), e) })?; @@ -162,10 +48,6 @@ pub(super) fn put_blob(blobs_dir: &Path, digest: &Digest256, bytes: &[u8]) -> cr /// Intended for the large snapshot blob, where the cost of one full /// re-hash of the existing file is far less than the cost of an /// unconditional rewrite. -/// -/// Written sparsely: a guest memory image is mostly untouched pages, -/// so writing its zeros would dominate the cost of saving a snapshot -/// while adding nothing a reader can observe. pub(super) fn put_blob_if_absent( blobs_dir: &Path, digest: &Digest256, @@ -180,7 +62,7 @@ pub(super) fn put_blob_if_absent( { return Ok(()); } - replace_file_atomic_sparse(&target, bytes) + replace_file_atomic(&target, bytes) } /// Reject a path that is a symbolic link. @@ -269,160 +151,6 @@ pub(super) fn read_bounded(path: &Path, max_size: u64) -> crate::Result> Ok(buf) } -#[cfg(test)] -mod sparse_tests { - use super::*; - - /// Every pattern must round-trip byte-for-byte. The interesting - /// cases are the ones where a hole meets data: leading and - /// trailing zeros, a gap in the middle, and a final block shorter - /// than `SPARSE_BLOCK` (so the tail is compared against a partial - /// zero block, not a whole one). - fn patterns() -> Vec<(&'static str, Vec)> { - let b = SPARSE_BLOCK; - vec![ - ("empty", vec![]), - ("all zero, one block", vec![0u8; b]), - ("all zero, many blocks", vec![0u8; b * 4]), - ("all data", vec![0xABu8; b * 4]), - ("leading zeros", [vec![0u8; b * 2], vec![7u8; b]].concat()), - ("trailing zeros", [vec![7u8; b], vec![0u8; b * 2]].concat()), - ( - "hole in the middle", - [vec![7u8; b], vec![0u8; b * 2], vec![9u8; b]].concat(), - ), - ("unaligned tail of data", vec![3u8; b * 2 + 17]), - ("unaligned tail of zeros", { - let mut v = vec![3u8; b]; - v.extend(std::iter::repeat_n(0u8, 17)); - v - }), - ("single non-zero byte in a block", { - let mut v = vec![0u8; b * 3]; - v[b + 5] = 1; - v - }), - ("shorter than one block", vec![1u8, 0, 2]), - ] - } - - #[test] - fn sparse_write_round_trips_every_pattern() { - let dir = tempfile::tempdir().unwrap(); - for (name, bytes) in patterns() { - let path = dir.path().join("blob"); - replace_file_atomic_sparse(&path, &bytes).unwrap(); - let read_back = std::fs::read(&path).unwrap(); - assert_eq!(read_back, bytes, "content mismatch for {name}"); - assert_eq!( - std::fs::metadata(&path).unwrap().len(), - bytes.len() as u64, - "length mismatch for {name}" - ); - std::fs::remove_file(&path).unwrap(); - } - } - - /// A sparse write must be indistinguishable from a dense one to - /// anything that reads the file. This is what lets the snapshot - /// blob go out sparse without touching the digest it is addressed - /// by, or the format version. - #[test] - fn sparse_and_dense_writes_produce_identical_files() { - let dir = tempfile::tempdir().unwrap(); - for (name, bytes) in patterns() { - let dense = dir.path().join("dense"); - let sparse = dir.path().join("sparse"); - replace_file_atomic(&dense, &bytes).unwrap(); - replace_file_atomic_sparse(&sparse, &bytes).unwrap(); - assert_eq!( - std::fs::read(&dense).unwrap(), - std::fs::read(&sparse).unwrap(), - "dense and sparse differ for {name}" - ); - } - } - - /// The blob must still hash to the digest it is stored under, and - /// the reuse path must accept a sparse file it wrote itself — - /// otherwise every save would rewrite the blob. - #[test] - fn put_blob_if_absent_writes_a_verifiable_blob_and_reuses_it() { - let dir = tempfile::tempdir().unwrap(); - let mut bytes = vec![0u8; SPARSE_BLOCK * 8]; - bytes[SPARSE_BLOCK * 3..SPARSE_BLOCK * 4].fill(0x5A); - let digest = Digest256::from_bytes(&bytes); - - put_blob_if_absent(dir.path(), &digest, &bytes).unwrap(); - let target = dir.path().join(&digest.hex); - assert_eq!(std::fs::read(&target).unwrap(), bytes); - - let mut file = std::fs::File::open(&target).unwrap(); - verify_blob_file("test", &mut file, &digest.hex).unwrap(); - - // Second call must take the reuse path and leave the file be. - let before = std::fs::metadata(&target).unwrap().modified().unwrap(); - put_blob_if_absent(dir.path(), &digest, &bytes).unwrap(); - let after = std::fs::metadata(&target).unwrap().modified().unwrap(); - assert_eq!(before, after, "blob was rewritten instead of reused"); - } -} - -#[cfg(all(test, unix))] -mod sparse_unix_tests { - use std::os::unix::fs::MetadataExt; - - use super::*; - - /// The point of the change: a mostly-zero blob, like a guest - /// memory image, must occupy far fewer blocks on disk than its - /// length. - /// - /// Sparseness is a filesystem capability, so the test first - /// probes whether the temp filesystem supports holes at all and - /// skips if not (HFS+, FAT). The probe keeps the assertion sharp - /// where holes *are* supported, rather than weakening it - /// everywhere to accommodate filesystems where they are not. - #[test] - fn sparse_write_allocates_fewer_blocks_than_dense() { - let dir = tempfile::tempdir().unwrap(); - - // Probe: a file with a large unwritten gap should occupy far - // fewer blocks than its length if this filesystem has holes. - let probe_path = dir.path().join("probe"); - { - let mut probe = std::fs::File::create(&probe_path).unwrap(); - probe.seek(SeekFrom::Start(8 * 1024 * 1024)).unwrap(); - probe.write_all(b"x").unwrap(); - probe.sync_all().unwrap(); - } - let probe_meta = std::fs::metadata(&probe_path).unwrap(); - let supports_holes = - probe_meta.blocks() > 0 && probe_meta.blocks() * 512 < probe_meta.len() / 2; - if !supports_holes { - eprintln!("filesystem does not support sparse files; skipping"); - return; - } - - // 8 MiB, of which one 64 KiB run is non-zero. - let mut bytes = vec![0u8; 8 * 1024 * 1024]; - bytes[1024 * 1024..1024 * 1024 + 64 * 1024].fill(0xC3); - - let dense = dir.path().join("dense"); - let sparse = dir.path().join("sparse"); - replace_file_atomic(&dense, &bytes).unwrap(); - replace_file_atomic_sparse(&sparse, &bytes).unwrap(); - - let dense_blocks = std::fs::metadata(&dense).unwrap().blocks(); - let sparse_blocks = std::fs::metadata(&sparse).unwrap().blocks(); - - assert!( - sparse_blocks * 4 < dense_blocks, - "expected a large saving, got sparse={sparse_blocks} dense={dense_blocks} blocks" - ); - } -} - #[cfg(all(test, unix))] mod tests { use std::ffi::CString;