From ac3bc40bdd22b900eb2d37077113e52c6107cd48 Mon Sep 17 00:00:00 2001 From: Ari Date: Thu, 12 Nov 2026 13:53:57 -0800 Subject: [PATCH 1/7] fix(runtime): guard trusted snapshot publication lifecycle (cherry picked from commit 3cef8eab1102e2e4e0718dee121e6bead85b1e1a) --- .../src/trusted_snapshot_service.rs | 392 ++++++++++++++++-- 1 file changed, 361 insertions(+), 31 deletions(-) diff --git a/crates/mvm-runtime/src/trusted_snapshot_service.rs b/crates/mvm-runtime/src/trusted_snapshot_service.rs index ddeb344dc5..6cb725df38 100644 --- a/crates/mvm-runtime/src/trusted_snapshot_service.rs +++ b/crates/mvm-runtime/src/trusted_snapshot_service.rs @@ -8,29 +8,42 @@ use std::collections::BTreeMap; use std::path::Path; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use anyhow::{Context, Result, anyhow, bail}; use mvm_fs::clone::CloneStrategy; use mvm_fs::snapshot_store::SnapshotId; use mvm_fs::trusted_snapshot::TrustedSnapshotBackend; +use uuid::Uuid; /// Opaque service-issued reference to one sealed snapshot publication. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TrustedSnapshotHandle { id: SnapshotId, + generation: Uuid, } impl TrustedSnapshotHandle { fn new(id: SnapshotId) -> Self { - Self { id } + Self { + id, + generation: Uuid::new_v4(), + } } } +enum PublicationState { + Reserved, + Published(TrustedSnapshotHandle), + Removing(TrustedSnapshotHandle), +} + /// Resident owner of trusted snapshot publications. pub struct TrustedSnapshotService { backend: Arc, - publications: Mutex>, + // Reserved also quarantines an ID after failed staging or cleanup. + // The per-ID lock holds claims open until materialization finishes. + publications: Mutex>>>, } impl TrustedSnapshotService { @@ -44,37 +57,43 @@ impl TrustedSnapshotService { /// Stage and seal a publication, then return its opaque claim handle. /// - /// The handle is inserted only after sealing succeeds. A failed stage or - /// seal therefore cannot become claimable through this service. + /// The ID is reserved before staging; only sealed publications are claimable. pub fn publish(&self, id: SnapshotId, source: &Path) -> Result { let key = id.id().to_owned(); - { - let publications = self - .publications - .lock() - .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))?; - if publications.contains_key(&key) { - bail!("trusted snapshot '{key}' is already published"); - } + let entry = Arc::new(RwLock::new(PublicationState::Reserved)); + let mut publications = self + .publications + .lock() + .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))?; + if publications.contains_key(&key) { + bail!("trusted snapshot '{key}' is already published or reserved"); } + publications.insert(key.clone(), Arc::clone(&entry)); + drop(publications); + // A failed stage may have left partial backend state, or encountered + // state belonging to another owner. Without an ownership proof, do + // not remove that state or release this ID for reuse. self.backend .stage(&id, source) .with_context(|| format!("stage trusted snapshot {key}"))?; if let Err(error) = self.backend.seal(&id) { - let _ = self.backend.remove(&id); + // A successful stage belongs to this reservation; cleanup before + // releasing the ID so a later publisher cannot race old bytes. + if self.backend.remove(&id).is_ok() { + self.publications + .lock() + .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))? + .remove(&key); + } return Err(error).with_context(|| format!("seal trusted snapshot {key}")); } let handle = TrustedSnapshotHandle::new(id); - let mut publications = self - .publications - .lock() - .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))?; - if publications.insert(key.clone(), handle.clone()).is_some() { - let _ = self.backend.remove(&handle.id); - bail!("trusted snapshot '{key}' was published concurrently"); - } + *entry + .write() + .map_err(|_| anyhow!("trusted snapshot publication is poisoned"))? = + PublicationState::Published(handle.clone()); Ok(handle) } @@ -87,15 +106,21 @@ impl TrustedSnapshotService { handle: &TrustedSnapshotHandle, destination: &Path, ) -> Result { - let known = self + let entry = self .publications .lock() .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))? .get(handle.id.id()) - .is_some_and(|published| published == handle); - if !known { + .cloned() + .ok_or_else(|| anyhow!("trusted snapshot handle is not owned by this service"))?; + let published = entry + .read() + .map_err(|_| anyhow!("trusted snapshot publication is poisoned"))?; + if !matches!(&*published, PublicationState::Published(current) if current == handle) { bail!("trusted snapshot handle is not owned by this service"); } + // Keep the read lease through the backend call so removal cannot + // reclaim the sealed read view while a claim is in progress. self.backend .materialize(&handle.id, destination) .with_context(|| format!("materialize trusted snapshot {}", handle.id)) @@ -103,26 +128,49 @@ impl TrustedSnapshotService { /// Remove a publication after all claims have released it. pub fn remove(&self, handle: TrustedSnapshotHandle) -> Result<()> { - let removed = self + let entry = self .publications .lock() .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))? - .remove(handle.id.id()); - if removed.as_ref() != Some(&handle) { + .get(handle.id.id()) + .cloned() + .ok_or_else(|| anyhow!("trusted snapshot handle is not owned by this service"))?; + let mut state = entry + .write() + .map_err(|_| anyhow!("trusted snapshot publication is poisoned"))?; + if !matches!( + &*state, + PublicationState::Published(current) | PublicationState::Removing(current) + if current == &handle + ) { bail!("trusted snapshot handle is not owned by this service"); } + *state = PublicationState::Removing(handle.clone()); self.backend .remove(&handle.id) - .with_context(|| format!("remove trusted snapshot {}", handle.id)) + .with_context(|| format!("remove trusted snapshot {}", handle.id))?; + self.publications + .lock() + .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))? + .remove(handle.id.id()); + Ok(()) } /// Number of sealed publications currently owned by the service. pub fn publication_count(&self) -> Result { - Ok(self + let entries: Vec<_> = self .publications .lock() .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))? - .len()) + .values() + .cloned() + .collect(); + entries.into_iter().try_fold(0, |count, entry| { + let state = entry + .read() + .map_err(|_| anyhow!("trusted snapshot publication is poisoned"))?; + Ok(count + usize::from(matches!(&*state, PublicationState::Published(_)))) + }) } } @@ -131,9 +179,13 @@ mod tests { use super::*; use mvm_fs::snapshot_store::{FsSnapshotStore, SnapshotStore}; use std::io; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::mpsc; struct TestBackend { store: FsSnapshotStore, + fail_next_seal: AtomicBool, + fail_next_remove: AtomicBool, } impl TrustedSnapshotBackend for TestBackend { @@ -142,6 +194,9 @@ mod tests { } fn seal(&self, _id: &SnapshotId) -> io::Result<()> { + if self.fail_next_seal.swap(false, Ordering::SeqCst) { + return Err(io::Error::other("injected sealing failure")); + } Ok(()) } @@ -166,6 +221,9 @@ mod tests { } fn remove(&self, id: &SnapshotId) -> io::Result<()> { + if self.fail_next_remove.swap(false, Ordering::SeqCst) { + return Err(io::Error::other("injected removal failure")); + } self.store.remove(id) } @@ -182,6 +240,8 @@ mod tests { std::fs::write(source.join("rootfs.ext4"), b"immutable-test-bytes").unwrap(); let service = TrustedSnapshotService::new(Arc::new(TestBackend { store: FsSnapshotStore::new(tmp.path().join("snapshots")).unwrap(), + fail_next_seal: AtomicBool::new(false), + fail_next_remove: AtomicBool::new(false), })); let handle = service .publish(SnapshotId::new("snapshot-1"), &source) @@ -198,11 +258,281 @@ mod tests { assert_eq!(service.publication_count().unwrap(), 0); } + #[test] + fn a_failed_seal_releases_its_reservation_only_after_cleanup() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + let backend = Arc::new(TestBackend { + store: FsSnapshotStore::new(tmp.path().join("snapshots")).unwrap(), + fail_next_seal: AtomicBool::new(true), + fail_next_remove: AtomicBool::new(false), + }); + let service = TrustedSnapshotService::new(backend); + let id = SnapshotId::new("seal-retry"); + assert!(service.publish(id.clone(), &source).is_err()); + assert_eq!(service.publication_count().unwrap(), 0); + let handle = service.publish(id, &source).unwrap(); + service.remove(handle).unwrap(); + } + + #[test] + fn failed_seal_cleanup_quarantines_the_id() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + let backend = Arc::new(TestBackend { + store: FsSnapshotStore::new(tmp.path().join("snapshots")).unwrap(), + fail_next_seal: AtomicBool::new(true), + fail_next_remove: AtomicBool::new(true), + }); + let service = TrustedSnapshotService::new(backend); + let id = SnapshotId::new("quarantined"); + assert!(service.publish(id.clone(), &source).is_err()); + assert_eq!(service.publication_count().unwrap(), 0); + assert!(service.publish(id, &source).is_err()); + } + + #[test] + fn failed_removal_refuses_claims_and_can_be_retried_by_the_owner() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + let backend = Arc::new(TestBackend { + store: FsSnapshotStore::new(tmp.path().join("snapshots")).unwrap(), + fail_next_seal: AtomicBool::new(false), + fail_next_remove: AtomicBool::new(true), + }); + let service = TrustedSnapshotService::new(backend); + let id = SnapshotId::new("retry-removal"); + let handle = service.publish(id.clone(), &source).unwrap(); + assert!(service.remove(handle.clone()).is_err()); + assert_eq!(service.publication_count().unwrap(), 0); + assert!( + service + .materialize(&handle, &tmp.path().join("claim")) + .is_err() + ); + assert!(service.publish(id, &source).is_err()); + service.remove(handle).unwrap(); + assert_eq!(service.publication_count().unwrap(), 0); + } + + #[test] + fn a_removed_handle_cannot_claim_a_new_publication_under_the_same_id() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("rootfs.ext4"), b"first").unwrap(); + let service = TrustedSnapshotService::new(Arc::new(TestBackend { + store: FsSnapshotStore::new(tmp.path().join("snapshots")).unwrap(), + fail_next_seal: AtomicBool::new(false), + fail_next_remove: AtomicBool::new(false), + })); + let id = SnapshotId::new("reused-id"); + let stale = service.publish(id.clone(), &source).unwrap(); + service.remove(stale.clone()).unwrap(); + std::fs::write(source.join("rootfs.ext4"), b"second").unwrap(); + let current = service.publish(id, &source).unwrap(); + let destination = tmp.path().join("destination"); + + assert_ne!( + stale, current, + "a new publication must have a new generation" + ); + assert!(service.materialize(&stale, &destination).is_err()); + assert!(!destination.exists(), "stale handle reached the backend"); + assert!(service.remove(stale).is_err()); + assert_eq!(service.publication_count().unwrap(), 1); + service.materialize(¤t, &destination).unwrap(); + assert_eq!( + std::fs::read(destination.join("rootfs.ext4")).unwrap(), + b"second" + ); + } + + struct PausedPublisher { + entered: mpsc::Sender<()>, + resume: Mutex>, + staged: AtomicUsize, + removed: AtomicUsize, + } + + impl TrustedSnapshotBackend for PausedPublisher { + fn stage(&self, _id: &SnapshotId, _source: &Path) -> io::Result<()> { + if self.staged.fetch_add(1, Ordering::SeqCst) == 0 { + self.entered.send(()).unwrap(); + self.resume + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + } + Ok(()) + } + + fn seal(&self, _id: &SnapshotId) -> io::Result<()> { + Ok(()) + } + + fn validate( + &self, + _id: &SnapshotId, + _manifest_digest: &str, + _trusted_signer: &[u8; 32], + ) -> io::Result<()> { + Ok(()) + } + + fn materialize(&self, _id: &SnapshotId, _dst: &Path) -> io::Result { + Ok(CloneStrategy::Copied) + } + + fn remove(&self, _id: &SnapshotId) -> io::Result<()> { + self.removed.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn name(&self) -> &'static str { + "paused-test" + } + } + + #[test] + fn a_concurrent_publisher_cannot_stage_or_remove_an_owners_publication() { + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let backend = Arc::new(PausedPublisher { + entered: entered_tx, + resume: Mutex::new(resume_rx), + staged: AtomicUsize::new(0), + removed: AtomicUsize::new(0), + }); + let service = Arc::new(TrustedSnapshotService::new(backend.clone())); + let first = { + let service = service.clone(); + std::thread::spawn(move || service.publish(SnapshotId::new("same-id"), Path::new("."))) + }; + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("first publisher reached the backend"); + let (second_tx, second_rx) = mpsc::channel(); + let second = { + let service = service.clone(); + std::thread::spawn(move || { + second_tx + .send(service.publish(SnapshotId::new("same-id"), Path::new("."))) + .unwrap(); + }) + }; + let second_result = second_rx.recv_timeout(std::time::Duration::from_secs(2)); + resume_tx.send(()).unwrap(); + let first_handle = first.join().unwrap().expect("first publisher owns the ID"); + second.join().unwrap(); + assert!( + second_result + .expect("duplicate must refuse while first is staging") + .is_err(), + "a concurrent caller published the same ID" + ); + assert_eq!(service.publication_count().unwrap(), 1); + assert_eq!(backend.staged.load(Ordering::SeqCst), 1); + assert_eq!(backend.removed.load(Ordering::SeqCst), 0); + service.materialize(&first_handle, Path::new(".")).unwrap(); + } + + struct PausedClaim { + entered: mpsc::Sender<()>, + resume: Mutex>, + removed: AtomicUsize, + } + + impl TrustedSnapshotBackend for PausedClaim { + fn stage(&self, _id: &SnapshotId, _source: &Path) -> io::Result<()> { + Ok(()) + } + + fn seal(&self, _id: &SnapshotId) -> io::Result<()> { + Ok(()) + } + + fn validate( + &self, + _id: &SnapshotId, + _manifest_digest: &str, + _trusted_signer: &[u8; 32], + ) -> io::Result<()> { + Ok(()) + } + + fn materialize(&self, _id: &SnapshotId, _dst: &Path) -> io::Result { + self.entered.send(()).unwrap(); + self.resume + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + assert_eq!(self.removed.load(Ordering::SeqCst), 0); + Ok(CloneStrategy::Copied) + } + + fn remove(&self, _id: &SnapshotId) -> io::Result<()> { + self.removed.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn name(&self) -> &'static str { + "paused-claim-test" + } + } + + #[test] + fn removal_waits_for_an_in_flight_claim() { + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let backend = Arc::new(PausedClaim { + entered: entered_tx, + resume: Mutex::new(resume_rx), + removed: AtomicUsize::new(0), + }); + let service = Arc::new(TrustedSnapshotService::new(backend.clone())); + let handle = service + .publish(SnapshotId::new("leased"), Path::new(".")) + .unwrap(); + let claim = { + let service = service.clone(); + let handle = handle.clone(); + std::thread::spawn(move || service.materialize(&handle, Path::new("."))) + }; + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("the backend entered materialization"); + let (removed_tx, removed_rx) = mpsc::channel(); + let removal = { + let service = service.clone(); + std::thread::spawn(move || removed_tx.send(service.remove(handle)).unwrap()) + }; + assert!( + removed_rx + .recv_timeout(std::time::Duration::from_millis(100)) + .is_err(), + "removal completed before the active claim" + ); + resume_tx.send(()).unwrap(); + claim.join().unwrap().unwrap(); + removal.join().unwrap(); + removed_rx.recv().unwrap().unwrap(); + assert_eq!(backend.removed.load(Ordering::SeqCst), 1); + assert_eq!(service.publication_count().unwrap(), 0); + } + #[test] fn service_rejects_unknown_handle_without_materializing() { let tmp = tempfile::tempdir().unwrap(); let service = TrustedSnapshotService::new(Arc::new(TestBackend { store: FsSnapshotStore::new(tmp.path().join("snapshots")).unwrap(), + fail_next_seal: AtomicBool::new(false), + fail_next_remove: AtomicBool::new(false), })); let unknown = TrustedSnapshotHandle::new(SnapshotId::new("not-published")); let destination = tmp.path().join("destination"); From 757ca8bc462421750bd9f37c6233db2d9fa9d2f8 Mon Sep 17 00:00:00 2001 From: Ari Date: Thu, 12 Nov 2026 17:39:43 -0800 Subject: [PATCH 2/7] feat(fs): seal budgeted Linux boot inputs in memfd (cherry picked from commit 9a589dd4bd878b5dd643e84b6d2b1d8bad1c441c) --- crates/mvm-fs/README.md | 7 +- crates/mvm-fs/src/lib.rs | 5 +- crates/mvm-fs/src/trusted_snapshot.rs | 4 + .../src/trusted_snapshot/sealed_boot_file.rs | 262 ++++++++++++++++++ 4 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 crates/mvm-fs/src/trusted_snapshot/sealed_boot_file.rs diff --git a/crates/mvm-fs/README.md b/crates/mvm-fs/README.md index bd92288f32..ef2be6ef97 100644 --- a/crates/mvm-fs/README.md +++ b/crates/mvm-fs/README.md @@ -42,15 +42,16 @@ on read, so warm-pool state is addressed by content rather than mutable names. | `clone` | Reflink/copy-on-write cloning | | `hash` / `manifest` / `snapshot_store` | Content identity and durable snapshots; `manifest` is the chunked v2 form that lets a CoW-chain child reuse its parent's chunk digests where the kernel proves the bytes unchanged | | `extension_image` / `sdk_sidecar` | Auxiliary guest image assembly | -| `trusted_snapshot` | Platform-backed trusted snapshot support | +| `trusted_snapshot` | Platform-backed snapshot support; Linux sealed-file construction is a budgeted building block, not an admitted warm claim | ## Security boundaries Registry and archive input is untrusted. Paths are normalized before writes, digests are checked before content is trusted, allocations are capped, and special files or traversal attempts are rejected. The crate denies unsafe code -except for small reviewed platform calls in the cloning and extent-map -implementations. +except for small reviewed platform calls in cloning, extent-map, APFS snapshot +and Linux memfd sealing modules. A sealed input's digest and budget do not +authorize a launch; its owner must retain custody through VMM termination. ## Developing diff --git a/crates/mvm-fs/src/lib.rs b/crates/mvm-fs/src/lib.rs index 63a41f30e1..a7ea1fa7a9 100644 --- a/crates/mvm-fs/src/lib.rs +++ b/crates/mvm-fs/src/lib.rs @@ -27,8 +27,9 @@ //! The crate-level lint is `deny(unsafe_code)` rather than `forbid` so that //! the [`clone`] module can use the small, platform-specific unsafe blocks //! required to invoke `clonefile(2)` and `FICLONE`, and so the crate-private -//! `extent` module can issue the Linux `FIEMAP` ioctl. Everywhere else in -//! this crate remains unsafe-free. +//! `extent` module can issue the Linux `FIEMAP` ioctl. The Linux sealed boot +//! file module also calls `memfd_create`, `fcntl`, and `sysconf`; elsewhere +//! the crate remains unsafe-free. #[cfg(unix)] pub mod clone; diff --git a/crates/mvm-fs/src/trusted_snapshot.rs b/crates/mvm-fs/src/trusted_snapshot.rs index 6d514d6583..d00b8dfa9a 100644 --- a/crates/mvm-fs/src/trusted_snapshot.rs +++ b/crates/mvm-fs/src/trusted_snapshot.rs @@ -18,6 +18,10 @@ use std::path::Path; use crate::clone::CloneStrategy; use crate::snapshot_store::SnapshotId; +/// Linux-only sealed-file building block; it grants no launch authority. +#[cfg(target_os = "linux")] +pub mod sealed_boot_file; + /// A snapshot provider whose sealed read view is immutable to the claim path. /// /// Implementations must complete all expensive hashing and publication work in diff --git a/crates/mvm-fs/src/trusted_snapshot/sealed_boot_file.rs b/crates/mvm-fs/src/trusted_snapshot/sealed_boot_file.rs new file mode 100644 index 0000000000..bad67ad6f1 --- /dev/null +++ b/crates/mvm-fs/src/trusted_snapshot/sealed_boot_file.rs @@ -0,0 +1,262 @@ +//! Budgeted Linux memfds for immutable boot input bytes. +//! +//! Sealing an input proves byte immutability, not its signature, admission, +//! VMM custody or permission to reuse a warm parent. + +#![allow(unsafe_code)] + +use std::fs::File; +use std::io::{self, Write}; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::fs::FileExt; +use std::sync::{Arc, Mutex}; + +use sha2::{Digest, Sha256}; + +#[derive(Debug, Default)] +struct Usage { + bytes: u64, + files: usize, +} + +/// Page-rounded memfd data and file-count limits shared by one owner. +#[derive(Debug, Clone)] +pub struct SealedBootBudget { + max_bytes: u64, + max_files: usize, + usage: Arc>, +} + +impl SealedBootBudget { + /// Set the maximum charged data bytes and owned files. + pub fn new(max_bytes: u64, max_files: usize) -> Self { + Self { + max_bytes, + max_files, + usage: Arc::new(Mutex::new(Usage::default())), + } + } + + fn reserve(&self, bytes: u64) -> io::Result { + // SAFETY: sysconf only queries the host page size. + let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + if page <= 0 { + return Err(io::Error::last_os_error()); + } + let page = page as u64; + let charged = bytes + .checked_add(page - 1) + .map(|rounded| rounded / page * page) + .ok_or_else(|| io::Error::other("sealed boot input size overflow"))?; + let mut usage = self + .usage + .lock() + .map_err(|_| io::Error::other("boot budget poisoned"))?; + if bytes == 0 + || usage + .bytes + .checked_add(charged) + .is_none_or(|sum| sum > self.max_bytes) + || usage.files >= self.max_files + { + return Err(io::Error::other( + "sealed boot input exceeds resident budget", + )); + } + usage.bytes += charged; + usage.files += 1; + Ok(Reservation { + usage: Arc::clone(&self.usage), + bytes: charged, + }) + } + + /// Copy one already authenticated file into a sealed, inode-independent view. + /// + /// The digest expectation must come from an independently verified signed + /// closure; a caller-supplied digest alone does not authorize a boot. + /// + /// # Errors + /// + /// Refuses size/digest mismatch, non-files, quota exhaustion or failed + /// kernel sealing without exposing an unsealed descriptor. + pub fn seal( + &self, + source: &File, + expected_size: u64, + expected_sha256: &str, + ) -> io::Result { + let mut expected = [0u8; 32]; + hex::decode_to_slice(expected_sha256, &mut expected) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid SHA-256 digest"))?; + let metadata = source.metadata()?; + if !metadata.is_file() || metadata.len() != expected_size { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "boot input is not a regular file of the expected size", + )); + } + let reservation = self.reserve(expected_size)?; + // SAFETY: `memfd_create` receives a constant NUL-terminated name; + // the returned descriptor is owned only when the syscall succeeds. + let fd = unsafe { + libc::memfd_create( + c"mvm-sealed-boot".as_ptr(), + libc::MFD_CLOEXEC | libc::MFD_ALLOW_SEALING, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: successful memfd_create returned a fresh owned descriptor. + let mut file = unsafe { File::from_raw_fd(fd) }; + let mut hash = Sha256::new(); + let mut offset = 0; + let mut buffer = [0u8; 64 * 1024]; + while offset < expected_size { + let remaining = (expected_size - offset).min(buffer.len() as u64) as usize; + let read = match source.read_at(&mut buffer[..remaining], offset) { + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + result => result?, + }; + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "boot input shrank", + )); + } + hash.update(&buffer[..read]); + file.write_all(&buffer[..read])?; + offset += read as u64; + } + let actual: [u8; 32] = hash.finalize().into(); + if actual != expected { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "boot input digest changed", + )); + } + let seals = + libc::F_SEAL_WRITE | libc::F_SEAL_GROW | libc::F_SEAL_SHRINK | libc::F_SEAL_SEAL; + // SAFETY: these calls only seal and inspect the owned memfd. + let applied = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_ADD_SEALS, seals) }; + if applied != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: F_GET_SEALS reads the seal mask from the same owned memfd. + if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GET_SEALS) } != seals { + return Err(io::Error::other( + "sealed boot input lost its required seals", + )); + } + Ok(SealedBootFile { + file, + _reservation: reservation, + sha256: expected, + }) + } +} + +#[derive(Debug)] +struct Reservation { + usage: Arc>, + bytes: u64, +} + +impl Drop for Reservation { + fn drop(&mut self) { + if let Ok(mut usage) = self.usage.lock() { + usage.bytes -= self.bytes; + usage.files -= 1; + } + } +} + +/// An immutable file and its resident reservation, not an admitted VM lease. +#[derive(Debug)] +#[must_use = "retain the sealed file until every VMM inheriting it has exited"] +pub struct SealedBootFile { + file: File, + _reservation: Reservation, + sha256: [u8; 32], +} + +impl SealedBootFile { + /// Borrow the original while retaining this owner through the VMM's exit. + /// + /// Duplicating the descriptor without retaining this value would release + /// its memory reservation while another process still holds those pages. + pub fn file(&self) -> &File { + &self.file + } + + /// Return the digest verified while copying, not a publisher identity. + pub fn sha256(&self) -> [u8; 32] { + self.sha256 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sealed_bytes_refuse_mutation_and_release_the_reservation() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("kernel"); + std::fs::write(&source, b"authenticated-by-caller").unwrap(); + let input = File::open(&source).unwrap(); + let digest = hex::encode(Sha256::digest(b"authenticated-by-caller")); + // SAFETY: sysconf only queries the page size for this test's budget. + let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64; + let budget = SealedBootBudget::new(page, 1); + let sealed = budget.seal(&input, 23, &digest).unwrap(); + assert_eq!( + sealed.sha256(), + <[u8; 32]>::from(Sha256::digest(b"authenticated-by-caller")) + ); + assert!(budget.seal(&input, 23, &digest).is_err()); + assert_eq!( + sealed + .file() + .write_at(b"tamper", 0) + .unwrap_err() + .raw_os_error(), + Some(libc::EPERM) + ); + std::fs::write(&source, b"changed-after-sealing").unwrap(); + let mut bytes = [0u8; 23]; + sealed.file().read_exact_at(&mut bytes, 0).unwrap(); + assert_eq!(&bytes, b"authenticated-by-caller"); + drop(sealed); + std::fs::write(&source, b"authenticated-by-caller").unwrap(); + assert!(budget.seal(&input, 23, &digest).is_ok()); + } + + #[test] + fn bad_input_cannot_allocate_or_publish_a_sealed_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("input"); + std::fs::write(&path, b"valid").unwrap(); + let input = File::open(&path).unwrap(); + let digest = hex::encode(Sha256::digest(b"valid")); + // SAFETY: sysconf only queries the page size for this test's budget. + let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64; + let budget = SealedBootBudget::new(page, 1); + assert!( + SealedBootBudget::new(page - 1, 1) + .seal(&input, 5, &digest) + .is_err() + ); + assert!(budget.seal(&input, 4, &digest).is_err()); + assert!(budget.seal(&input, 5, &"0".repeat(64)).is_err()); + assert!(budget.seal(&input, 5, "not-a-digest").is_err()); + assert!( + budget + .seal(&File::open(tmp.path()).unwrap(), 5, &digest) + .is_err() + ); + let sealed = budget.seal(&input, 5, &digest).unwrap(); + assert_eq!(sealed.sha256(), <[u8; 32]>::from(Sha256::digest(b"valid"))); + } +} From ecd064b49884ba20956daf9ab17ddcd225d1e515 Mon Sep 17 00:00:00 2001 From: Ari Date: Thu, 12 Nov 2026 18:01:42 -0800 Subject: [PATCH 3/7] docs(adr): scope immutable warm boot read leases --- .../059-immutable-warm-boot-read-leases.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 specs/adrs/059-immutable-warm-boot-read-leases.md diff --git a/specs/adrs/059-immutable-warm-boot-read-leases.md b/specs/adrs/059-immutable-warm-boot-read-leases.md new file mode 100644 index 0000000000..8305ff8db7 --- /dev/null +++ b/specs/adrs/059-immutable-warm-boot-read-leases.md @@ -0,0 +1,94 @@ +# ADR-059: Immutable read leases for warm boot inputs + +## Status + +Accepted direction. The owner approved one typed publication/claim API with +bounded sealed memfds, inode-pinned fs-verity where supported, and fail-closed +warm claims. No backend or launch path implements this ADR yet; enabling warm +claims still requires an owner/descriptor API review and live witnesses. + +## Context + +ADR-001 trusts the host, not a compromised guest or another local process. +ADR-025 permits fresh children of a clean, authority-free parent but refuses +reuse of a dirty guest. The composable-pack contract requires fresh admission +and <300 ms cold / <50 ms warm launch including admission. Today +`TrustedSnapshotBackend::materialize` returns a writable destination, the +named path disables its warm pool, and PR runtime and kernel descriptor +custody refuse standby. None is an immutable VMM read lease. + +On a Raspberry Pi 4 Linux/KVM host, a test-only direct Firecracker probe +sealed an 8.5 MB kernel and 71.3 MB ext4 rootfs in memfds. A write after +`F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK | F_SEAL_SEAL` failed; inherited +`/proc/self/fd/N` paths were accepted by pinned Firecracker, and the guest +mounted the root ext4 read-only and ran `/bin/sh` as init. The probe had no +admission, scoped launcher, runtime overlay or parent claim. The Pi kernel +does not advertise fs-verity. The ordinary named start on that host still +took 53 s, including fresh admission; the probe proves no speed target. + +## Decision + +Extend the existing `TrustedSnapshotBackend` and `TrustedSnapshotService`. +The owner accepts an authenticated, exact boot closure (selected kernel, +rootfs, full runtime, parent device shape and publisher/revocation evidence), +reserves its memory and descriptor budget, then publishes and seals its bytes. +A successful publication issues a generation-bound, nonserializable handle; +a claim yields an **owned read lease**, never an arbitrary source path or a +writable materialization. A handle or cache hit grants no plan authority. + +On Linux, sealed memfds serve small boot inputs and explicitly budgeted +resident parents. A separately validated fs-verity provider may pin open +inodes for large rootfs files; its measured verity identity must be bound +to the authenticated byte digest at publication. No permission bit, reflink +or sidecar signature stands in for kernel-backed immutability. A requested +warm guarantee refuses before execution when the selected provider lacks +support, sealing fails, or the byte/FD/parent budget is exhausted. A normal +cold start can proceed through its own full verification and admission, but +must not be reported as a warm claim. The provider is fixed at publication; +claim does not silently switch mechanisms. + +The lease owns its descriptors and publication generation through the actual +VMM's last child, not only until a copy or launch call returns. Reuse the +Firecracker launcher descriptor handoff and verify the VMM's opened objects, +not the diagnostic pathname. The scoped launcher, device layout, guest +no-NIC/vsock-only network path and host budget remain mandatory. APFS must +provide an independently witnessed read-only mount lease before enabling +this claim on macOS; unsupported platforms refuse it. + +A parent is clean: no tenant plan, secret, writable volume, egress destination +or previous child's guest memory. Each child gets fresh signed-plan, +revocation, policy, grant and one-use admission before any admitted effect or +readiness claim. A changed closure, stale handle, lost owner or expired proof +refuses a claim. Owner restart loses memfds and must rebuild from newly +authenticated inputs; no persisted filename recovers a lease. A VMM that +retains its own original descriptors cannot become a new parent after owner +loss; existing children remain subject to their original admitted bounds +or are stopped when those bounds cannot be enforced. + +The owner moves an ID through reserved, sealed, leased, removing and +quarantined states. Failed staging or cleanup keeps it unclaimable. Removal +waits for every active child and VMM descriptor; cancellation and process +death release reservations without reassigning stale generations. Claim-time +checks inspect sealed objects and current authority, not payload hashes. + +## Alternatives + +- Unlimited memfds are simple and work on the Pi, but one copy per distinct + parent shape can exhaust RAM or swap; no 1,000-workload capacity claim + follows without sharing and an enforced host budget. +- fs-verity-only storage avoids the shmem copy but is unavailable on the Pi + and some hosts. It still needs an inode-pinned descriptor and a signed + identity binding rather than trusting a replaceable path. +- Reflinks, read-only modes and host-signed cache sidecars do not seal the + bytes a VMM opens. They cannot satisfy a warm read lease. + +## Evidence required before enabling + +Exercise seal/mutate refusal, manifest/signer mismatch, stale generations, +concurrent publish/claim/remove, memory and FD exhaustion, cancellation, +owner crash/restart, pathname replacement and actual VMM descriptor identity. +Run normal scoped Firecracker launches on Linux x86_64 and arm64 with fresh +admission, verified guest output and no guest NIC. Witness APFS mount custody +separately. Measure first-use publication apart from cold and warm readiness; +the latter include admission and must meet the stated thresholds on named +hosts. Tests or a direct-VMM probe cannot substitute for those live claims. From bf85c63cf3d3d0fcd691c418eea2130bedc70da8 Mon Sep 17 00:00:00 2001 From: Ari Date: Thu, 12 Nov 2026 18:10:17 -0800 Subject: [PATCH 4/7] chore(footprint): admit snapshot custody regressions --- xtask/src/check_rust_footprint.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/xtask/src/check_rust_footprint.rs b/xtask/src/check_rust_footprint.rs index 2a4927dd5c..830c88891e 100644 --- a/xtask/src/check_rust_footprint.rs +++ b/xtask/src/check_rust_footprint.rs @@ -34,16 +34,13 @@ use std::process::Command; use crate::rust_source::{CommentSyntax, blank_comments}; -// CI witness repair adds checked timeout and enrollment regressions; admit its -// small measured increase rather than delete security coverage to fit the ratchet. -// The overclaim lint's agent-sandbox walk test adds measured lines for the same -// reason: the gate fix needs the regression coverage more than the headroom. -// The tree sits at this ratchet (966,126 measured on the merge commit), so a -// small margin is admitted until the footprint-reduction lane tightens it again. +// The snapshot-owner race regressions and Linux sealed-file checks increase +// measured lines on this branch; keep their evidence rather than trim it to +// fit an older ratchet. The margin remains narrow for later reductions. /// Ceiling on total SLOC, all categories. -const MAX_TOTAL_SLOC: usize = 966_160; +const MAX_TOTAL_SLOC: usize = 966_800; /// Ceiling on total physical lines, all categories. -const MAX_TOTAL_PHYSICAL: usize = 1_232_000; +const MAX_TOTAL_PHYSICAL: usize = 1_232_200; /// How far under a ceiling the tree may sit before the ceiling must drop. const SLACK: usize = 10_000; From aa6c5af0d21b6e3bde2ede16dacafc80cc3750be Mon Sep 17 00:00:00 2001 From: Ari Date: Thu, 12 Nov 2026 18:17:22 -0800 Subject: [PATCH 5/7] fix(fs): defer sealed files until VMM lease owns their budget --- crates/mvm-fs/README.md | 7 +- crates/mvm-fs/src/lib.rs | 5 +- crates/mvm-fs/src/trusted_snapshot.rs | 4 - .../src/trusted_snapshot/sealed_boot_file.rs | 262 ------------------ xtask/src/check_rust_footprint.rs | 9 +- 5 files changed, 9 insertions(+), 278 deletions(-) delete mode 100644 crates/mvm-fs/src/trusted_snapshot/sealed_boot_file.rs diff --git a/crates/mvm-fs/README.md b/crates/mvm-fs/README.md index ef2be6ef97..bd92288f32 100644 --- a/crates/mvm-fs/README.md +++ b/crates/mvm-fs/README.md @@ -42,16 +42,15 @@ on read, so warm-pool state is addressed by content rather than mutable names. | `clone` | Reflink/copy-on-write cloning | | `hash` / `manifest` / `snapshot_store` | Content identity and durable snapshots; `manifest` is the chunked v2 form that lets a CoW-chain child reuse its parent's chunk digests where the kernel proves the bytes unchanged | | `extension_image` / `sdk_sidecar` | Auxiliary guest image assembly | -| `trusted_snapshot` | Platform-backed snapshot support; Linux sealed-file construction is a budgeted building block, not an admitted warm claim | +| `trusted_snapshot` | Platform-backed trusted snapshot support | ## Security boundaries Registry and archive input is untrusted. Paths are normalized before writes, digests are checked before content is trusted, allocations are capped, and special files or traversal attempts are rejected. The crate denies unsafe code -except for small reviewed platform calls in cloning, extent-map, APFS snapshot -and Linux memfd sealing modules. A sealed input's digest and budget do not -authorize a launch; its owner must retain custody through VMM termination. +except for small reviewed platform calls in the cloning and extent-map +implementations. ## Developing diff --git a/crates/mvm-fs/src/lib.rs b/crates/mvm-fs/src/lib.rs index a7ea1fa7a9..63a41f30e1 100644 --- a/crates/mvm-fs/src/lib.rs +++ b/crates/mvm-fs/src/lib.rs @@ -27,9 +27,8 @@ //! The crate-level lint is `deny(unsafe_code)` rather than `forbid` so that //! the [`clone`] module can use the small, platform-specific unsafe blocks //! required to invoke `clonefile(2)` and `FICLONE`, and so the crate-private -//! `extent` module can issue the Linux `FIEMAP` ioctl. The Linux sealed boot -//! file module also calls `memfd_create`, `fcntl`, and `sysconf`; elsewhere -//! the crate remains unsafe-free. +//! `extent` module can issue the Linux `FIEMAP` ioctl. Everywhere else in +//! this crate remains unsafe-free. #[cfg(unix)] pub mod clone; diff --git a/crates/mvm-fs/src/trusted_snapshot.rs b/crates/mvm-fs/src/trusted_snapshot.rs index d00b8dfa9a..6d514d6583 100644 --- a/crates/mvm-fs/src/trusted_snapshot.rs +++ b/crates/mvm-fs/src/trusted_snapshot.rs @@ -18,10 +18,6 @@ use std::path::Path; use crate::clone::CloneStrategy; use crate::snapshot_store::SnapshotId; -/// Linux-only sealed-file building block; it grants no launch authority. -#[cfg(target_os = "linux")] -pub mod sealed_boot_file; - /// A snapshot provider whose sealed read view is immutable to the claim path. /// /// Implementations must complete all expensive hashing and publication work in diff --git a/crates/mvm-fs/src/trusted_snapshot/sealed_boot_file.rs b/crates/mvm-fs/src/trusted_snapshot/sealed_boot_file.rs deleted file mode 100644 index bad67ad6f1..0000000000 --- a/crates/mvm-fs/src/trusted_snapshot/sealed_boot_file.rs +++ /dev/null @@ -1,262 +0,0 @@ -//! Budgeted Linux memfds for immutable boot input bytes. -//! -//! Sealing an input proves byte immutability, not its signature, admission, -//! VMM custody or permission to reuse a warm parent. - -#![allow(unsafe_code)] - -use std::fs::File; -use std::io::{self, Write}; -use std::os::fd::{AsRawFd, FromRawFd}; -use std::os::unix::fs::FileExt; -use std::sync::{Arc, Mutex}; - -use sha2::{Digest, Sha256}; - -#[derive(Debug, Default)] -struct Usage { - bytes: u64, - files: usize, -} - -/// Page-rounded memfd data and file-count limits shared by one owner. -#[derive(Debug, Clone)] -pub struct SealedBootBudget { - max_bytes: u64, - max_files: usize, - usage: Arc>, -} - -impl SealedBootBudget { - /// Set the maximum charged data bytes and owned files. - pub fn new(max_bytes: u64, max_files: usize) -> Self { - Self { - max_bytes, - max_files, - usage: Arc::new(Mutex::new(Usage::default())), - } - } - - fn reserve(&self, bytes: u64) -> io::Result { - // SAFETY: sysconf only queries the host page size. - let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; - if page <= 0 { - return Err(io::Error::last_os_error()); - } - let page = page as u64; - let charged = bytes - .checked_add(page - 1) - .map(|rounded| rounded / page * page) - .ok_or_else(|| io::Error::other("sealed boot input size overflow"))?; - let mut usage = self - .usage - .lock() - .map_err(|_| io::Error::other("boot budget poisoned"))?; - if bytes == 0 - || usage - .bytes - .checked_add(charged) - .is_none_or(|sum| sum > self.max_bytes) - || usage.files >= self.max_files - { - return Err(io::Error::other( - "sealed boot input exceeds resident budget", - )); - } - usage.bytes += charged; - usage.files += 1; - Ok(Reservation { - usage: Arc::clone(&self.usage), - bytes: charged, - }) - } - - /// Copy one already authenticated file into a sealed, inode-independent view. - /// - /// The digest expectation must come from an independently verified signed - /// closure; a caller-supplied digest alone does not authorize a boot. - /// - /// # Errors - /// - /// Refuses size/digest mismatch, non-files, quota exhaustion or failed - /// kernel sealing without exposing an unsealed descriptor. - pub fn seal( - &self, - source: &File, - expected_size: u64, - expected_sha256: &str, - ) -> io::Result { - let mut expected = [0u8; 32]; - hex::decode_to_slice(expected_sha256, &mut expected) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid SHA-256 digest"))?; - let metadata = source.metadata()?; - if !metadata.is_file() || metadata.len() != expected_size { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "boot input is not a regular file of the expected size", - )); - } - let reservation = self.reserve(expected_size)?; - // SAFETY: `memfd_create` receives a constant NUL-terminated name; - // the returned descriptor is owned only when the syscall succeeds. - let fd = unsafe { - libc::memfd_create( - c"mvm-sealed-boot".as_ptr(), - libc::MFD_CLOEXEC | libc::MFD_ALLOW_SEALING, - ) - }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - // SAFETY: successful memfd_create returned a fresh owned descriptor. - let mut file = unsafe { File::from_raw_fd(fd) }; - let mut hash = Sha256::new(); - let mut offset = 0; - let mut buffer = [0u8; 64 * 1024]; - while offset < expected_size { - let remaining = (expected_size - offset).min(buffer.len() as u64) as usize; - let read = match source.read_at(&mut buffer[..remaining], offset) { - Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, - result => result?, - }; - if read == 0 { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "boot input shrank", - )); - } - hash.update(&buffer[..read]); - file.write_all(&buffer[..read])?; - offset += read as u64; - } - let actual: [u8; 32] = hash.finalize().into(); - if actual != expected { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "boot input digest changed", - )); - } - let seals = - libc::F_SEAL_WRITE | libc::F_SEAL_GROW | libc::F_SEAL_SHRINK | libc::F_SEAL_SEAL; - // SAFETY: these calls only seal and inspect the owned memfd. - let applied = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_ADD_SEALS, seals) }; - if applied != 0 { - return Err(io::Error::last_os_error()); - } - // SAFETY: F_GET_SEALS reads the seal mask from the same owned memfd. - if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GET_SEALS) } != seals { - return Err(io::Error::other( - "sealed boot input lost its required seals", - )); - } - Ok(SealedBootFile { - file, - _reservation: reservation, - sha256: expected, - }) - } -} - -#[derive(Debug)] -struct Reservation { - usage: Arc>, - bytes: u64, -} - -impl Drop for Reservation { - fn drop(&mut self) { - if let Ok(mut usage) = self.usage.lock() { - usage.bytes -= self.bytes; - usage.files -= 1; - } - } -} - -/// An immutable file and its resident reservation, not an admitted VM lease. -#[derive(Debug)] -#[must_use = "retain the sealed file until every VMM inheriting it has exited"] -pub struct SealedBootFile { - file: File, - _reservation: Reservation, - sha256: [u8; 32], -} - -impl SealedBootFile { - /// Borrow the original while retaining this owner through the VMM's exit. - /// - /// Duplicating the descriptor without retaining this value would release - /// its memory reservation while another process still holds those pages. - pub fn file(&self) -> &File { - &self.file - } - - /// Return the digest verified while copying, not a publisher identity. - pub fn sha256(&self) -> [u8; 32] { - self.sha256 - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sealed_bytes_refuse_mutation_and_release_the_reservation() { - let tmp = tempfile::tempdir().unwrap(); - let source = tmp.path().join("kernel"); - std::fs::write(&source, b"authenticated-by-caller").unwrap(); - let input = File::open(&source).unwrap(); - let digest = hex::encode(Sha256::digest(b"authenticated-by-caller")); - // SAFETY: sysconf only queries the page size for this test's budget. - let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64; - let budget = SealedBootBudget::new(page, 1); - let sealed = budget.seal(&input, 23, &digest).unwrap(); - assert_eq!( - sealed.sha256(), - <[u8; 32]>::from(Sha256::digest(b"authenticated-by-caller")) - ); - assert!(budget.seal(&input, 23, &digest).is_err()); - assert_eq!( - sealed - .file() - .write_at(b"tamper", 0) - .unwrap_err() - .raw_os_error(), - Some(libc::EPERM) - ); - std::fs::write(&source, b"changed-after-sealing").unwrap(); - let mut bytes = [0u8; 23]; - sealed.file().read_exact_at(&mut bytes, 0).unwrap(); - assert_eq!(&bytes, b"authenticated-by-caller"); - drop(sealed); - std::fs::write(&source, b"authenticated-by-caller").unwrap(); - assert!(budget.seal(&input, 23, &digest).is_ok()); - } - - #[test] - fn bad_input_cannot_allocate_or_publish_a_sealed_file() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("input"); - std::fs::write(&path, b"valid").unwrap(); - let input = File::open(&path).unwrap(); - let digest = hex::encode(Sha256::digest(b"valid")); - // SAFETY: sysconf only queries the page size for this test's budget. - let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64; - let budget = SealedBootBudget::new(page, 1); - assert!( - SealedBootBudget::new(page - 1, 1) - .seal(&input, 5, &digest) - .is_err() - ); - assert!(budget.seal(&input, 4, &digest).is_err()); - assert!(budget.seal(&input, 5, &"0".repeat(64)).is_err()); - assert!(budget.seal(&input, 5, "not-a-digest").is_err()); - assert!( - budget - .seal(&File::open(tmp.path()).unwrap(), 5, &digest) - .is_err() - ); - let sealed = budget.seal(&input, 5, &digest).unwrap(); - assert_eq!(sealed.sha256(), <[u8; 32]>::from(Sha256::digest(b"valid"))); - } -} diff --git a/xtask/src/check_rust_footprint.rs b/xtask/src/check_rust_footprint.rs index 830c88891e..f23b5a695d 100644 --- a/xtask/src/check_rust_footprint.rs +++ b/xtask/src/check_rust_footprint.rs @@ -34,13 +34,12 @@ use std::process::Command; use crate::rust_source::{CommentSyntax, blank_comments}; -// The snapshot-owner race regressions and Linux sealed-file checks increase -// measured lines on this branch; keep their evidence rather than trim it to -// fit an older ratchet. The margin remains narrow for later reductions. +// The snapshot-owner race regressions add measured lines on this branch; +// retain the security coverage rather than trim it to fit an older ratchet. /// Ceiling on total SLOC, all categories. -const MAX_TOTAL_SLOC: usize = 966_800; +const MAX_TOTAL_SLOC: usize = 966_500; /// Ceiling on total physical lines, all categories. -const MAX_TOTAL_PHYSICAL: usize = 1_232_200; +const MAX_TOTAL_PHYSICAL: usize = 1_232_000; /// How far under a ceiling the tree may sit before the ceiling must drop. const SLACK: usize = 10_000; From bc70835ddaf94ef3854295888ea25a39ddd4336b Mon Sep 17 00:00:00 2001 From: Ari Date: Thu, 12 Nov 2026 18:24:05 -0800 Subject: [PATCH 6/7] fix(runtime): retain snapshot handle for failed removal retry --- .../src/trusted_snapshot_service.rs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/mvm-runtime/src/trusted_snapshot_service.rs b/crates/mvm-runtime/src/trusted_snapshot_service.rs index 6cb725df38..2f3a025efe 100644 --- a/crates/mvm-runtime/src/trusted_snapshot_service.rs +++ b/crates/mvm-runtime/src/trusted_snapshot_service.rs @@ -127,7 +127,12 @@ impl TrustedSnapshotService { } /// Remove a publication after all claims have released it. - pub fn remove(&self, handle: TrustedSnapshotHandle) -> Result<()> { + /// + /// # Errors + /// + /// Refuses a foreign handle or failed backend removal. The owner retains + /// its handle to retry after a backend failure; claims stay blocked. + pub fn remove(&self, handle: &TrustedSnapshotHandle) -> Result<()> { let entry = self .publications .lock() @@ -141,7 +146,7 @@ impl TrustedSnapshotService { if !matches!( &*state, PublicationState::Published(current) | PublicationState::Removing(current) - if current == &handle + if current == handle ) { bail!("trusted snapshot handle is not owned by this service"); } @@ -254,7 +259,7 @@ mod tests { b"immutable-test-bytes" ); assert_eq!(service.publication_count().unwrap(), 1); - service.remove(handle).unwrap(); + service.remove(&handle).unwrap(); assert_eq!(service.publication_count().unwrap(), 0); } @@ -273,7 +278,7 @@ mod tests { assert!(service.publish(id.clone(), &source).is_err()); assert_eq!(service.publication_count().unwrap(), 0); let handle = service.publish(id, &source).unwrap(); - service.remove(handle).unwrap(); + service.remove(&handle).unwrap(); } #[test] @@ -306,7 +311,7 @@ mod tests { let service = TrustedSnapshotService::new(backend); let id = SnapshotId::new("retry-removal"); let handle = service.publish(id.clone(), &source).unwrap(); - assert!(service.remove(handle.clone()).is_err()); + assert!(service.remove(&handle).is_err()); assert_eq!(service.publication_count().unwrap(), 0); assert!( service @@ -314,7 +319,7 @@ mod tests { .is_err() ); assert!(service.publish(id, &source).is_err()); - service.remove(handle).unwrap(); + service.remove(&handle).unwrap(); assert_eq!(service.publication_count().unwrap(), 0); } @@ -331,7 +336,7 @@ mod tests { })); let id = SnapshotId::new("reused-id"); let stale = service.publish(id.clone(), &source).unwrap(); - service.remove(stale.clone()).unwrap(); + service.remove(&stale).unwrap(); std::fs::write(source.join("rootfs.ext4"), b"second").unwrap(); let current = service.publish(id, &source).unwrap(); let destination = tmp.path().join("destination"); @@ -342,7 +347,7 @@ mod tests { ); assert!(service.materialize(&stale, &destination).is_err()); assert!(!destination.exists(), "stale handle reached the backend"); - assert!(service.remove(stale).is_err()); + assert!(service.remove(&stale).is_err()); assert_eq!(service.publication_count().unwrap(), 1); service.materialize(¤t, &destination).unwrap(); assert_eq!( @@ -510,7 +515,7 @@ mod tests { let (removed_tx, removed_rx) = mpsc::channel(); let removal = { let service = service.clone(); - std::thread::spawn(move || removed_tx.send(service.remove(handle)).unwrap()) + std::thread::spawn(move || removed_tx.send(service.remove(&handle)).unwrap()) }; assert!( removed_rx From 488ad667996887aa8991d03e055a2955260c9108 Mon Sep 17 00:00:00 2001 From: Ari Date: Thu, 12 Nov 2026 19:06:39 -0800 Subject: [PATCH 7/7] fix(runtime): retire removed snapshot entries before concurrent retries --- .../src/trusted_snapshot_service.rs | 59 ++++++++++++++++++- .../059-immutable-warm-boot-read-leases.md | 5 +- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/mvm-runtime/src/trusted_snapshot_service.rs b/crates/mvm-runtime/src/trusted_snapshot_service.rs index 2f3a025efe..4f8501c269 100644 --- a/crates/mvm-runtime/src/trusted_snapshot_service.rs +++ b/crates/mvm-runtime/src/trusted_snapshot_service.rs @@ -36,6 +36,7 @@ enum PublicationState { Reserved, Published(TrustedSnapshotHandle), Removing(TrustedSnapshotHandle), + Removed, } /// Resident owner of trusted snapshot publications. @@ -119,8 +120,8 @@ impl TrustedSnapshotService { if !matches!(&*published, PublicationState::Published(current) if current == handle) { bail!("trusted snapshot handle is not owned by this service"); } - // Keep the read lease through the backend call so removal cannot - // reclaim the sealed read view while a claim is in progress. + // Hold the read lock through materialization so removal cannot + // reclaim backend state during this call. This is not a VMM read lease. self.backend .materialize(&handle.id, destination) .with_context(|| format!("materialize trusted snapshot {}", handle.id)) @@ -154,6 +155,9 @@ impl TrustedSnapshotService { self.backend .remove(&handle.id) .with_context(|| format!("remove trusted snapshot {}", handle.id))?; + // A concurrent remover may already hold this entry after looking it up + // in the registry. Retire it before allowing that waiter to proceed. + *state = PublicationState::Removed; self.publications .lock() .map_err(|_| anyhow!("trusted snapshot registry is poisoned"))? @@ -446,6 +450,57 @@ mod tests { service.materialize(&first_handle, Path::new(".")).unwrap(); } + #[test] + fn concurrent_removers_cannot_reuse_a_retired_entry() { + let (entered_tx, _entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let backend = Arc::new(PausedPublisher { + entered: entered_tx, + resume: Mutex::new(resume_rx), + staged: AtomicUsize::new(0), + removed: AtomicUsize::new(0), + }); + resume_tx.send(()).unwrap(); + let service = Arc::new(TrustedSnapshotService::new(backend.clone())); + let handle = service + .publish(SnapshotId::new("retired"), Path::new(".")) + .unwrap(); + let entry = service + .publications + .lock() + .unwrap() + .get(handle.id.id()) + .unwrap() + .clone(); + let guard = entry.write().unwrap(); + let removals: Vec<_> = (0..2) + .map(|_| { + let service = service.clone(); + let handle = handle.clone(); + std::thread::spawn(move || service.remove(&handle)) + }) + .collect(); + // Both callers have fetched the same entry but cannot enter removal + // until this guard is released. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while Arc::strong_count(&entry) < 4 && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + assert_eq!( + Arc::strong_count(&entry), + 4, + "both removers must be waiting" + ); + drop(guard); + let results: Vec<_> = removals + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!(backend.removed.load(Ordering::SeqCst), 1); + assert_eq!(service.publication_count().unwrap(), 0); + } + struct PausedClaim { entered: mpsc::Sender<()>, resume: Mutex>, diff --git a/specs/adrs/059-immutable-warm-boot-read-leases.md b/specs/adrs/059-immutable-warm-boot-read-leases.md index 8305ff8db7..affb82b618 100644 --- a/specs/adrs/059-immutable-warm-boot-read-leases.md +++ b/specs/adrs/059-immutable-warm-boot-read-leases.md @@ -1,5 +1,8 @@ # ADR-059: Immutable read leases for warm boot inputs +Backing: preview +Validation: none — this is an accepted design direction, not a shipped warm read lease + ## Status Accepted direction. The owner approved one typed publication/claim API with @@ -24,7 +27,7 @@ sealed an 8.5 MB kernel and 71.3 MB ext4 rootfs in memfds. A write after mounted the root ext4 read-only and ran `/bin/sh` as init. The probe had no admission, scoped launcher, runtime overlay or parent claim. The Pi kernel does not advertise fs-verity. The ordinary named start on that host still -took 53 s, including fresh admission; the probe proves no speed target. +took 53 s, including fresh admission; the probe is not evidence for a speed target. ## Decision