diff --git a/Cargo.lock b/Cargo.lock index 1fb60d64bd..db0f2adea7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4312,6 +4312,7 @@ dependencies = [ "security-framework", "serde", "serde_json", + "sha2 0.11.0", "socket2", "tempfile", "tracing", diff --git a/crates/mvm-vmm/Cargo.toml b/crates/mvm-vmm/Cargo.toml index 9f5c406cd7..3d0660ac9b 100644 --- a/crates/mvm-vmm/Cargo.toml +++ b/crates/mvm-vmm/Cargo.toml @@ -31,6 +31,7 @@ tracing = { workspace = true } # JSON serialization for host-service configs. serde_json = { workspace = true } serde = { workspace = true } +sha2.workspace = true # The per-VM egress CA key is held and serialized here; `Zeroizing` keeps the # in-memory copies from outliving their use. Already in this crate's closure # through mvm-core, so it adds nothing to the shipped dependency set. diff --git a/crates/mvm-vmm/src/host/installed_owner.rs b/crates/mvm-vmm/src/host/installed_owner.rs new file mode 100644 index 0000000000..75588e5149 --- /dev/null +++ b/crates/mvm-vmm/src/host/installed_owner.rs @@ -0,0 +1,754 @@ +//! Local installation exclusion and resource-holder epoch leases. +//! Every installed resource must actually retain a lease before this component +//! can establish recovery of that resource. It grants no mailbox/boot authority. +use sha2::{Digest, Sha256}; +use std::{ + ffi::CString, + fmt, + fs::{self, File}, + io::{self, Read, Write}, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::{ffi::OsStrExt, fs::MetadataExt}, + }, + path::{Component, Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +const STATE: &str = "state.bin"; +const NEXT: &str = ".state.next"; +const INTENT: &str = ".state.intent"; +const OWNER: &str = "owner.lock"; +const HOLDERS: &str = "holders.lock"; +const RECORD_BYTES: usize = 176; +const BODY_BYTES: usize = 144; +const DOMAIN: &[u8] = b"spiralcore/installed-owner/v1\0"; + +#[cfg(test)] +#[path = "installed_owner/binding_tests.rs"] +mod binding_tests; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InstallationKey { + pub installation: [u8; 32], + pub profile: [u8; 32], +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Epoch { + key: InstallationKey, + generation: u64, +} +/// Fresh provisioning facts for library consumers holding an owner and its +/// original Epoch. These values are not credentials or a worker activation +/// grant, and cannot reconstruct an owner or resource descriptor. +pub struct InstallationBinding { + pub root: PathBuf, + pub key: InstallationKey, + pub generation: u64, +} +impl Epoch { + pub fn generation(&self) -> u64 { + self.generation + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Phase { + Clean, + Active, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OwnerSnapshot { + pub phase: Phase, + pub epoch: u64, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Recovery { + AlreadyClean, + Retired { epoch: u64 }, +} +#[derive(Debug)] +pub enum OwnerError { + Busy, + RecoveryRequired, + InvalidStorage, + StaleEpoch, + EpochExhausted, + Fenced, + Io(io::Error), +} +impl fmt::Display for OwnerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "installed owner: {self:?}") + } +} +impl std::error::Error for OwnerError {} +impl From for OwnerError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} +type Result = std::result::Result; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ObjectId { + device: u64, + inode: u64, +} +fn object_id(file: &File) -> Result { + let m = file.metadata()?; + Ok(ObjectId { + device: m.dev(), + inode: m.ino(), + }) +} +fn check_key(key: InstallationKey) -> Result<()> { + if key.installation == [0; 32] || key.profile == [0; 32] { + return Err(OwnerError::InvalidStorage); + } + Ok(()) +} +fn c_path(path: &Path) -> Result { + CString::new(path.as_os_str().as_bytes()).map_err(|_| OwnerError::InvalidStorage) +} +fn storage_open_error(error: io::Error) -> OwnerError { + match error.raw_os_error() { + Some(libc::ENOENT | libc::ENOTDIR | libc::ELOOP | libc::EISDIR) => { + OwnerError::InvalidStorage + } + _ => OwnerError::Io(error), + } +} +fn open_root(path: &Path) -> Result { + if !path.is_absolute() + || path + .components() + .any(|p| matches!(p, Component::ParentDir | Component::CurDir)) + { + return Err(OwnerError::InvalidStorage); + } + let path = c_path(path)?; + // SAFETY: live NUL-terminated path; open returns a new owned descriptor. + let fd = unsafe { + libc::open( + path.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(storage_open_error(io::Error::last_os_error())); + } + // SAFETY: successful open returned a fresh owned descriptor. + let file = unsafe { File::from_raw_fd(fd) }; + check_private(&file, true)?; + check_filesystem(&file)?; + Ok(file) +} +fn check_private(file: &File, directory: bool) -> Result<()> { + let m = file.metadata()?; + // SAFETY: geteuid has no inputs and does not change process credentials. + let uid = unsafe { libc::geteuid() }; + if m.uid() != uid + || m.mode() & 0o777 != if directory { 0o700 } else { 0o600 } + || if directory { + !m.is_dir() + } else { + !m.is_file() || m.nlink() != 1 + } + { + return Err(OwnerError::InvalidStorage); + } + check_acl(file) +} + +#[cfg(not(target_os = "macos"))] +fn check_acl(_file: &File) -> Result<()> { + Ok(()) +} + +#[cfg(target_os = "macos")] +fn check_acl(file: &File) -> Result<()> { + // Apple sys/acl.h: ACL_TYPE_EXTENDED=0x100, ACL_FIRST_ENTRY=0. + unsafe extern "C" { + fn acl_get_fd_np(fd: libc::c_int, kind: libc::c_int) -> *mut libc::c_void; + fn acl_get_entry( + acl: *mut libc::c_void, + entry: libc::c_int, + out: *mut *mut libc::c_void, + ) -> libc::c_int; + fn acl_free(value: *mut libc::c_void) -> libc::c_int; + } + // SAFETY: borrowed live descriptor, supported extended ACL type. + let acl = unsafe { acl_get_fd_np(file.as_raw_fd(), 0x100) }; + if acl.is_null() { + let error = io::Error::last_os_error(); + // Apple Libc obtains FILESEC_ACL from fstatx_np. An absent ACL + // property returns ENOENT even when the descriptor's object exists; + // check_private already validated this live descriptor's metadata. + return if matches!(error.raw_os_error(), Some(libc::ENOENT | libc::ENOATTR)) { + Ok(()) + } else { + Err(error.into()) + }; + } + let mut entry = std::ptr::null_mut(); + // SAFETY: kernel-produced ACL, valid first index and writable output. + let found = unsafe { acl_get_entry(acl, 0, &mut entry) }; + let error = io::Error::last_os_error(); + // SAFETY: release the sole copy returned by acl_get_fd_np exactly once. + let freed = unsafe { acl_free(acl) }; + if freed != 0 { + return Err(io::Error::last_os_error().into()); + } + // Darwin returns zero for an entry, -1/EINVAL for an empty valid ACL. + if found == 0 { + return Err(OwnerError::InvalidStorage); + } + if found == -1 && error.raw_os_error() == Some(libc::EINVAL) { + Ok(()) + } else { + Err(error.into()) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn check_filesystem(file: &File) -> Result<()> { + let mut info = std::mem::MaybeUninit::::uninit(); + // SAFETY: live directory descriptor and appropriately sized writable output. + if unsafe { libc::fstatfs(file.as_raw_fd(), info.as_mut_ptr()) } != 0 { + return Err(io::Error::last_os_error().into()); + } + // SAFETY: successful fstatfs initialized the whole structure. + let info = unsafe { info.assume_init() }; + #[cfg(target_os = "linux")] + let supported = matches!( + info.f_type, + libc::EXT4_SUPER_MAGIC + | libc::XFS_SUPER_MAGIC + | libc::BTRFS_SUPER_MAGIC + | libc::TMPFS_MAGIC + | libc::OVERLAYFS_SUPER_MAGIC + ); + #[cfg(target_os = "macos")] + let supported = { + let name: Vec<_> = info + .f_fstypename + .iter() + .take_while(|c| **c != 0) + .map(|c| *c as u8) + .collect(); + info.f_flags & libc::MNT_LOCAL as u32 != 0 && matches!(name.as_slice(), b"apfs" | b"hfs") + }; + if supported { + Ok(()) + } else { + Err(OwnerError::InvalidStorage) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn check_filesystem(_file: &File) -> Result<()> { + Err(OwnerError::InvalidStorage) +} +fn open_at(dir: &File, name: &str, flags: i32) -> Result { + let name = CString::new(name).map_err(|_| OwnerError::InvalidStorage)?; + // SAFETY: live directory/path, fixed flags/mode; fresh descriptor on success. + let fd = unsafe { + libc::openat( + dir.as_raw_fd(), + name.as_ptr(), + flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK, + 0o600, + ) + }; + if fd < 0 { + return Err(storage_open_error(io::Error::last_os_error())); + } + // SAFETY: this successful openat returned a new owned descriptor. + let file = unsafe { File::from_raw_fd(fd) }; + check_private(&file, false)?; + Ok(file) +} +fn lock(file: &File, shared: bool) -> Result<()> { + let result = if shared { + file.try_lock_shared() + } else { + file.try_lock() + }; + result.map_err(|e| match e { + fs::TryLockError::WouldBlock => OwnerError::Busy, + fs::TryLockError::Error(e) => OwnerError::Io(e), + }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Record { + key: InstallationKey, + generation: u64, + phase: Phase, + directory: ObjectId, + owner: ObjectId, + holders: ObjectId, +} +impl Record { + fn encode(self) -> [u8; RECORD_BYTES] { + let mut b = [0; RECORD_BYTES]; + b[..8].copy_from_slice(b"SCOWNER1"); + b[8] = u8::from(self.phase == Phase::Active); + b[16..24].copy_from_slice(&self.generation.to_be_bytes()); + b[24..56].copy_from_slice(&self.key.installation); + b[56..88].copy_from_slice(&self.key.profile); + for (offset, id) in [(88, self.directory), (104, self.owner), (120, self.holders)] { + b[offset..offset + 8].copy_from_slice(&id.device.to_be_bytes()); + b[offset + 8..offset + 16].copy_from_slice(&id.inode.to_be_bytes()); + } + b[136..144].copy_from_slice(b"ENDOWN01"); + let digest = Sha256::new() + .chain_update(DOMAIN) + .chain_update(&b[..BODY_BYTES]) + .finalize(); + b[BODY_BYTES..].copy_from_slice(&digest); + b + } + fn decode(b: &[u8]) -> Result { + if b.len() != RECORD_BYTES + || &b[..8] != b"SCOWNER1" + || b[8] > 1 + || b[9..16] != [0; 7] + || &b[136..144] != b"ENDOWN01" + { + return Err(OwnerError::InvalidStorage); + } + let digest = Sha256::new() + .chain_update(DOMAIN) + .chain_update(&b[..BODY_BYTES]) + .finalize(); + if digest[..] != b[BODY_BYTES..] { + return Err(OwnerError::InvalidStorage); + } + let number = + |at| u64::from_be_bytes(b[at..at + 8].try_into().expect("checked fixed record")); + let id = |at| ObjectId { + device: number(at), + inode: number(at + 8), + }; + let key = InstallationKey { + installation: b[24..56].try_into().expect("fixed"), + profile: b[56..88].try_into().expect("fixed"), + }; + check_key(key)?; + Ok(Self { + key, + generation: number(16), + phase: if b[8] == 0 { + Phase::Clean + } else { + Phase::Active + }, + directory: id(88), + owner: id(104), + holders: id(120), + }) + } +} + +struct Storage { + root: PathBuf, + directory: File, + key: InstallationKey, + ids: [ObjectId; 3], +} +impl Storage { + fn open(root: &Path, key: InstallationKey) -> Result<(Arc, File)> { + check_key(key)?; + let directory = open_root(root)?; + let owner = open_at(&directory, OWNER, libc::O_RDWR)?; + let holders = open_at(&directory, HOLDERS, libc::O_RDWR)?; + let ids = [ + object_id(&directory)?, + object_id(&owner)?, + object_id(&holders)?, + ]; + Ok(( + Arc::new(Self { + root: root.to_path_buf(), + directory, + key, + ids, + }), + owner, + )) + } + fn check(&self) -> Result<()> { + if object_id(&open_root(&self.root)?)? != self.ids[0] { + return Err(OwnerError::InvalidStorage); + } + check_private(&self.directory, true)?; + for (name, id) in [(OWNER, self.ids[1]), (HOLDERS, self.ids[2])] { + if object_id(&open_at(&self.directory, name, libc::O_RDWR)?)? != id { + return Err(OwnerError::InvalidStorage); + } + } + Ok(()) + } + fn no_temp(&self) -> Result<()> { + for name in [NEXT, INTENT] { + let name = CString::new(name).expect("fixed name"); + let mut stat = std::mem::MaybeUninit::::uninit(); + // SAFETY: live directory/name, writable output; output is not read. + let result = unsafe { + libc::fstatat( + self.directory.as_raw_fd(), + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if result == 0 { + return Err(OwnerError::Fenced); + } + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::NotFound { + return Err(error.into()); + } + } + Ok(()) + } + fn read(&self) -> Result { + self.check()?; + self.no_temp()?; + let mut b = Vec::with_capacity(RECORD_BYTES + 1); + open_at(&self.directory, STATE, libc::O_RDONLY)? + .take((RECORD_BYTES + 1) as u64) + .read_to_end(&mut b)?; + let r = Record::decode(&b)?; + if r.key != self.key || [r.directory, r.owner, r.holders] != self.ids { + return Err(OwnerError::InvalidStorage); + } + Ok(r) + } + fn publish(&self, record: Record) -> Result<()> { + self.check()?; + self.no_temp()?; + let intent = self.start_intent(record)?; + let mut next = open_at( + &self.directory, + NEXT, + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL, + )?; + next.write_all(&record.encode())?; + next.sync_all()?; + let from = CString::new(NEXT).expect("fixed name"); + let to = CString::new(STATE).expect("fixed name"); + self.check()?; + // SAFETY: both fixed names are relative to the live validated directory. + if unsafe { + libc::renameat( + self.directory.as_raw_fd(), + from.as_ptr(), + self.directory.as_raw_fd(), + to.as_ptr(), + ) + } != 0 + { + return Err(io::Error::last_os_error().into()); + } + #[cfg(test)] + tests::before_state_directory_sync(self)?; + self.directory.sync_all()?; + self.clear_intent(intent)?; + Ok(()) + } + fn start_intent(&self, record: Record) -> Result { + let mut file = open_at( + &self.directory, + INTENT, + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL, + )?; + file.write_all(b"SCINT001")?; + file.write_all(&record.generation.to_be_bytes())?; + file.write_all(&record.encode()[BODY_BYTES..])?; + #[cfg(test)] + tests::before_intent_file_sync(self)?; + file.sync_all()?; + #[cfg(test)] + tests::before_intent_directory_sync(self)?; + self.directory.sync_all()?; + // No state mutation precedes this confirmed intent-directory barrier. + Ok(file) + } + fn clear_intent(&self, intent: File) -> Result<()> { + self.check()?; + let path_file = open_at(&self.directory, INTENT, libc::O_RDONLY)?; + if object_id(&path_file)? != object_id(&intent)? { + return Err(OwnerError::Fenced); + } + let name = CString::new(INTENT).expect("fixed name"); + // SAFETY: fixed private name in the retained validated directory. + if unsafe { libc::unlinkat(self.directory.as_raw_fd(), name.as_ptr(), 0) } != 0 { + return Err(io::Error::last_os_error().into()); + } + // Marker removal is post-commit: state-directory sync already succeeded. + #[cfg(test)] + tests::before_intent_cleanup_directory_sync(self)?; + self.directory.sync_all()?; + Ok(()) + } + fn exclusive_holders(&self) -> Result { + self.locked_holders(false) + } + fn locked_holders(&self, shared: bool) -> Result { + self.check()?; + #[cfg(test)] + tests::before_holders_open(self)?; + let file = open_at(&self.directory, HOLDERS, libc::O_RDWR)?; + #[cfg(test)] + tests::after_holders_open(self)?; + if object_id(&file)? != self.ids[2] { + return Err(OwnerError::InvalidStorage); + } + lock(&file, shared)?; + self.check()?; + Ok(file) + } +} + +/// Owned resources for the fixed local backend handoff, not boot authority. +pub struct HolderHandoff { + pub directory: File, + pub holder: File, + pub state_bytes: u16, + pub state_sha256: [u8; 32], +} + +pub struct HolderLease { + _storage: Arc, + _holder: File, + generation: u64, +} +impl HolderLease { + pub fn acquire(root: &Path, key: InstallationKey, expected_generation: u64) -> Result { + let (storage, _unlocked_owner) = Storage::open(root, key)?; + Self::from_storage(storage, expected_generation) + } + fn from_storage(storage: Arc, expected_generation: u64) -> Result { + let holder = storage.locked_holders(true)?; + let record = storage.read()?; + if record.phase != Phase::Active || record.generation != expected_generation { + return Err(OwnerError::StaleEpoch); + } + Ok(Self { + _storage: storage, + _holder: holder, + generation: expected_generation, + }) + } + + /// Revalidate the acquired epoch before exporting duplicate descriptions. + /// Their shared lock lasts until the final holder description is closed. + pub fn prepare_handoff(&self) -> Result { + let record = self._storage.read()?; + if record.phase != Phase::Active || record.generation != self.generation { + return Err(OwnerError::StaleEpoch); + } + let directory = self._storage.directory.try_clone()?; + let holder = self._holder.try_clone()?; + if object_id(&directory)? != self._storage.ids[0] + || object_id(&holder)? != self._storage.ids[2] + { + return Err(OwnerError::InvalidStorage); + } + Ok(HolderHandoff { + directory, + holder, + state_bytes: RECORD_BYTES as u16, + state_sha256: Sha256::digest(record.encode()).into(), + }) + } +} + +#[cfg(test)] +#[path = "installed_owner/tests.rs"] +mod tests; + +pub struct InstallationOwner { + storage: Arc, + record: Record, + fenced: AtomicBool, + supervisor: Option, + // Drop owner authority last, after this owner's SH lease. Never unlock a + // borrowed/inherited descriptor or publish Clean in Drop. + _owner: File, +} +impl InstallationOwner { + /// Fresh local provisioning facts for the original active epoch. This is + /// neither a resource descriptor nor permission to activate a worker. + pub fn active_binding(&self, expected: Epoch) -> Result { + let record = self.current()?; + if record.phase != Phase::Active + || record.key != expected.key + || record.generation != expected.generation + { + return Err(OwnerError::StaleEpoch); + } + Ok(InstallationBinding { + root: self.storage.root.clone(), + key: record.key, + generation: record.generation, + }) + } + + /// Fresh local provisioning identity; it conveys no guest boot permission. + pub fn profile_hash(&self) -> Result<[u8; 32]> { + Ok(self.current()?.key.profile) + } + + pub fn initialize(root: &Path, key: InstallationKey) -> Result { + use std::os::unix::fs::DirBuilderExt; + check_key(key)?; + let parent = open_root(root.parent().ok_or(OwnerError::InvalidStorage)?)?; + fs::DirBuilder::new().mode(0o700).create(root)?; + let directory = open_root(root)?; + for name in [OWNER, HOLDERS] { + open_at( + &directory, + name, + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + )? + .sync_all()?; + } + let (storage, owner) = Storage::open(root, key)?; + lock(&owner, false)?; + let record = Record { + key, + generation: 0, + phase: Phase::Clean, + directory: storage.ids[0], + owner: storage.ids[1], + holders: storage.ids[2], + }; + storage.publish(record)?; + parent.sync_all()?; + Ok(Self { + storage, + record, + fenced: AtomicBool::new(false), + supervisor: None, + _owner: owner, + }) + } + pub fn open(root: &Path, key: InstallationKey) -> Result { + let (storage, owner) = Storage::open(root, key)?; + lock(&owner, false)?; + let record = storage.read()?; + Ok(Self { + storage, + record, + fenced: AtomicBool::new(false), + supervisor: None, + _owner: owner, + }) + } + fn current(&self) -> Result { + if self.fenced.load(Ordering::Acquire) { + return Err(OwnerError::Fenced); + } + match self.storage.read() { + Ok(record) if record == self.record => Ok(record), + Ok(_) => { + self.fenced.store(true, Ordering::Release); + Err(OwnerError::Fenced) + } + Err(OwnerError::InvalidStorage | OwnerError::Fenced) => { + self.fenced.store(true, Ordering::Release); + Err(OwnerError::Fenced) + } + Err(e) => { + self.fenced.store(true, Ordering::Release); + Err(e) + } + } + } + fn commit(&mut self, record: Record) -> Result<()> { + if let Err(e) = self.storage.publish(record) { + self.fenced.store(true, Ordering::Release); + return Err(e); + } + self.record = record; + Ok(()) + } + fn holders_for_transition(&self) -> Result { + match self.storage.exclusive_holders() { + Ok(file) => Ok(file), + Err(OwnerError::Busy) => Err(OwnerError::Busy), + Err(OwnerError::InvalidStorage | OwnerError::Fenced) => { + self.fenced.store(true, Ordering::Release); + Err(OwnerError::Fenced) + } + Err(error) => { + self.fenced.store(true, Ordering::Release); + Err(error) + } + } + } + pub fn snapshot(&self) -> Result { + let record = self.current()?; + Ok(OwnerSnapshot { + phase: record.phase, + epoch: record.generation, + }) + } + pub fn begin(&mut self) -> Result { + let mut record = self.current()?; + if record.phase != Phase::Clean { + return Err(OwnerError::RecoveryRequired); + } + let exclusive = self.holders_for_transition()?; + record.generation = record + .generation + .checked_add(1) + .ok_or(OwnerError::EpochExhausted)?; + record.phase = Phase::Active; + self.commit(record)?; + drop(exclusive); + match HolderLease::from_storage(self.storage.clone(), record.generation) { + Ok(lease) => self.supervisor = Some(lease), + Err(e) => { + self.fenced.store(true, Ordering::Release); + return Err(e); + } + } + Ok(Epoch { + key: record.key, + generation: record.generation, + }) + } + pub fn finish(&mut self, epoch: Epoch) -> Result<()> { + let mut record = self.current()?; + if epoch.key != record.key + || epoch.generation != record.generation + || record.phase != Phase::Active + { + return Err(OwnerError::StaleEpoch); + } + self.supervisor.take(); + let _exclusive = self.holders_for_transition()?; + record.phase = Phase::Clean; + self.commit(record) + } + pub fn reconcile(&mut self) -> Result { + let mut record = self.current()?; + if record.phase == Phase::Clean { + return Ok(Recovery::AlreadyClean); + } + self.supervisor.take(); + let _exclusive = self.holders_for_transition()?; + record.phase = Phase::Clean; + self.commit(record)?; + Ok(Recovery::Retired { + epoch: record.generation, + }) + } +} diff --git a/crates/mvm-vmm/src/host/installed_owner/binding_tests.rs b/crates/mvm-vmm/src/host/installed_owner/binding_tests.rs new file mode 100644 index 0000000000..16d4b17e51 --- /dev/null +++ b/crates/mvm-vmm/src/host/installed_owner/binding_tests.rs @@ -0,0 +1,90 @@ +use super::*; +use std::{ + fs, + os::unix::fs::DirBuilderExt, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +struct BindingCase { + base: PathBuf, + root: PathBuf, + key: InstallationKey, +} +impl BindingCase { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let base = std::env::temp_dir().join(format!( + "spiral-owner-binding-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::DirBuilder::new().mode(0o700).create(&base).unwrap(); + Self { + root: base.join("installation"), + base, + key: InstallationKey { + installation: [0x91; 32], + profile: [0xa4; 32], + }, + } + } +} +impl Drop for BindingCase { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.base); + } +} + +#[test] +fn active_binding_returns_only_original_identity_and_current_active_epoch() { + let fixture = BindingCase::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, fixture.key).unwrap(); + let first = owner.begin().unwrap(); + let binding = owner.active_binding(first).unwrap(); + assert_eq!(binding.root, fixture.root); + assert_eq!(binding.key, fixture.key); + assert_eq!(binding.generation, first.generation()); + + owner.finish(first).unwrap(); + assert!(matches!( + owner.active_binding(first), + Err(OwnerError::StaleEpoch) + )); + let second = owner.begin().unwrap(); + assert_eq!(second.generation(), first.generation() + 1); + assert!(matches!( + owner.active_binding(first), + Err(OwnerError::StaleEpoch) + )); + let binding = owner.active_binding(second).unwrap(); + assert_eq!(binding.root, fixture.root); + assert_eq!(binding.key, fixture.key); + assert_eq!(binding.generation, second.generation()); + owner.finish(second).unwrap(); +} + +#[test] +fn active_binding_uses_the_existing_canonical_storage_fence() { + let fixture = BindingCase::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, fixture.key).unwrap(); + let epoch = owner.begin().unwrap(); + owner.active_binding(epoch).unwrap(); + // Damage the real fixed record without duplicating its parser/checksum. + // A later metadata query must not use cached identity to bypass current(). + let state_path = fixture.root.join("state.bin"); + let mut damaged = fs::read(&state_path).unwrap(); + *damaged.last_mut().unwrap() ^= 1; + fs::write(&state_path, &damaged).unwrap(); + assert!(matches!( + owner.active_binding(epoch), + Err(OwnerError::Fenced) + )); + assert!(matches!(owner.snapshot(), Err(OwnerError::Fenced))); + assert_eq!(fs::read(state_path).unwrap(), damaged); +} diff --git a/crates/mvm-vmm/src/host/installed_owner/tests.rs b/crates/mvm-vmm/src/host/installed_owner/tests.rs new file mode 100644 index 0000000000..9a5d7e2b86 --- /dev/null +++ b/crates/mvm-vmm/src/host/installed_owner/tests.rs @@ -0,0 +1,416 @@ +use super::*; +use std::{ + cell::RefCell, + fs::OpenOptions, + os::unix::fs::{DirBuilderExt, OpenOptionsExt}, + sync::atomic::AtomicU64, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mode { + HoldersAba, + StateDirectorySyncFailure, + IntentFileSyncFailure, + IntentDirectorySyncFailure, + IntentCleanupDirectorySyncFailure, +} +struct Fault { + root: PathBuf, + saved_a: PathBuf, + saved_b: PathBuf, + mode: Mode, + before: usize, + after: usize, + sync_failures: usize, + original: Option, + replacement: Option, +} +thread_local! { static FAULT: RefCell> = const { RefCell::new(None) }; } + +struct Scope; +impl Scope { + fn arm(root: &Path, mode: Mode) -> Self { + FAULT.with(|slot| { + let mut slot = slot.borrow_mut(); + assert!( + slot.is_none(), + "fault scopes must not overlap on one thread" + ); + let parent = root.parent().unwrap(); + *slot = Some(Fault { + root: root.to_path_buf(), + saved_a: parent.join("holders-original-A"), + saved_b: parent.join("holders-replacement-B"), + mode, + before: 0, + after: 0, + sync_failures: 0, + original: None, + replacement: None, + }); + }); + Self + } + fn counts(&self) -> (usize, usize, usize) { + FAULT.with(|slot| { + let slot = slot.borrow(); + let fault = slot.as_ref().unwrap(); + (fault.before, fault.after, fault.sync_failures) + }) + } + fn different_objects(&self) -> bool { + FAULT.with(|slot| { + let slot = slot.borrow(); + let fault = slot.as_ref().unwrap(); + fault.original.is_some() + && fault.replacement.is_some() + && fault.original != fault.replacement + }) + } +} +impl Drop for Scope { + fn drop(&mut self) { + FAULT.with(|slot| { + if let Some(fault) = slot.borrow_mut().take() { + // Restore A even when source validation returns early or a test + // assertion unwinds before the after-open hook is reached. + if fault.saved_a.exists() { + let _ = fs::remove_file(fault.root.join(HOLDERS)); + let _ = fs::rename(&fault.saved_a, fault.root.join(HOLDERS)); + } + let _ = fs::remove_file(fault.saved_b); + } + }); + } +} + +pub(super) fn before_holders_open(storage: &Storage) -> Result<()> { + FAULT.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(fault) = slot.as_mut() else { + return Ok(()); + }; + if fault.root != storage.root || !matches!(fault.mode, Mode::HoldersAba) { + return Ok(()); + } + fault.before += 1; + if fault.before != 1 { + return Err(OwnerError::Io(io::Error::other( + "unexpected second ABA open", + ))); + } + let original = OpenOptions::new() + .read(true) + .write(true) + .open(storage.root.join(HOLDERS))?; + fault.original = Some(object_id(&original)?); + fs::rename(storage.root.join(HOLDERS), &fault.saved_a)?; + let replacement = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(storage.root.join(HOLDERS))?; + fault.replacement = Some(object_id(&replacement)?); + Ok(()) + }) +} + +pub(super) fn after_holders_open(storage: &Storage) -> Result<()> { + FAULT.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(fault) = slot.as_mut() else { + return Ok(()); + }; + if fault.root != storage.root || !matches!(fault.mode, Mode::HoldersAba) { + return Ok(()); + } + fault.after += 1; + // The actual intervening open obtained B. Restore the pathname to A so + // a later pathname-only validation cannot discover that wrong FD. + fs::rename(storage.root.join(HOLDERS), &fault.saved_b)?; + fs::rename(&fault.saved_a, storage.root.join(HOLDERS))?; + Ok(()) + }) +} + +pub(super) fn before_state_directory_sync(storage: &Storage) -> Result<()> { + FAULT.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(fault) = slot.as_mut() else { + return Ok(()); + }; + if fault.root != storage.root || !matches!(fault.mode, Mode::StateDirectorySyncFailure) { + return Ok(()); + } + fault.sync_failures += 1; + Err(OwnerError::Io(io::Error::from_raw_os_error(libc::EIO))) + }) +} + +fn fail_at_intent_barrier(storage: &Storage, mode: Mode) -> Result<()> { + FAULT.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(fault) = slot.as_mut() else { + return Ok(()); + }; + if fault.root != storage.root || fault.mode != mode { + return Ok(()); + } + fault.sync_failures += 1; + Err(OwnerError::Io(io::Error::from_raw_os_error(libc::EIO))) + }) +} + +pub(super) fn before_intent_file_sync(storage: &Storage) -> Result<()> { + fail_at_intent_barrier(storage, Mode::IntentFileSyncFailure) +} + +pub(super) fn before_intent_directory_sync(storage: &Storage) -> Result<()> { + fail_at_intent_barrier(storage, Mode::IntentDirectorySyncFailure) +} + +pub(super) fn before_intent_cleanup_directory_sync(storage: &Storage) -> Result<()> { + fail_at_intent_barrier(storage, Mode::IntentCleanupDirectorySyncFailure) +} + +struct Fixture { + base: PathBuf, + root: PathBuf, +} +impl Fixture { + fn new() -> Self { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + let base = std::env::temp_dir().join(format!( + "spiral-owner-unit-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::DirBuilder::new().mode(0o700).create(&base).unwrap(); + Self { + root: base.join("installation"), + base, + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.base); + } +} +fn key() -> InstallationKey { + InstallationKey { + installation: [0x51; 32], + profile: [0x63; 32], + } +} + +#[derive(Debug)] +struct AbaObservation { + operation: &'static str, + accepted: bool, + state_unchanged: bool, +} + +#[test] +fn holders_aba_must_reject_the_actual_wrong_fd_and_never_retire_live_original_holders() { + let mut observations = Vec::new(); + for operation in ["acquire-sh", "finish-ex", "reconcile-ex"] { + let fixture = Fixture::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, key()).unwrap(); + let epoch = owner.begin().unwrap(); + // Independently opened A remains SH-held throughout every ABA action. + let actual_holder = HolderLease::acquire(&fixture.root, key(), epoch.generation()).unwrap(); + if operation == "reconcile-ex" { + drop(owner); + owner = InstallationOwner::open(&fixture.root, key()).unwrap(); + } + let before = fs::read(fixture.root.join(STATE)).unwrap(); + let scope = Scope::arm(&fixture.root, Mode::HoldersAba); + let accepted = match operation { + "acquire-sh" => { + let result = HolderLease::acquire(&fixture.root, key(), epoch.generation()); + let accepted = result.is_ok(); + drop(result); + accepted + } + "finish-ex" => owner.finish(epoch).is_ok(), + "reconcile-ex" => owner.reconcile().is_ok(), + _ => unreachable!(), + }; + assert_eq!(scope.counts(), (1, 1, 0)); + assert!( + scope.different_objects(), + "fixture must open a genuinely different B inode" + ); + let state_unchanged = fs::read(fixture.root.join(STATE)).unwrap() == before; + observations.push(AbaObservation { + operation, + accepted, + state_unchanged, + }); + drop(scope); + drop(actual_holder); + drop(owner); + } + // Evaluate only after all three actual opens: one failing SH regression + // cannot hide evidence that finish/reconcile also bypassed the real A lock. + for observation in &observations { + assert!( + !observation.accepted && observation.state_unchanged, + "{} accepted the ABA FD or published over an actual live holder: {observations:?}", + observation.operation + ); + } +} + +#[derive(Debug)] +struct SyncObservation { + operation: &'static str, + reopened_fenced: bool, +} + +#[test] +fn postrename_directory_sync_failure_must_survive_owner_drop_as_a_durable_fence() { + let mut observations = Vec::new(); + for operation in ["finish", "reconcile"] { + let fixture = Fixture::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, key()).unwrap(); + let epoch = owner.begin().unwrap(); + if operation == "reconcile" { + drop(owner); + owner = InstallationOwner::open(&fixture.root, key()).unwrap(); + } + let scope = Scope::arm(&fixture.root, Mode::StateDirectorySyncFailure); + let result = if operation == "finish" { + owner.finish(epoch).map(|_| ()) + } else { + owner.reconcile().map(|_| ()) + }; + assert!( + matches!(result, Err(OwnerError::Io(ref error)) if error.raw_os_error()==Some(libc::EIO)), + "{operation}" + ); + assert_eq!(scope.counts(), (0, 0, 1)); + let visible = Record::decode(&fs::read(fixture.root.join(STATE)).unwrap()).unwrap(); + assert_eq!( + visible.phase, + Phase::Clean, + "fault must occur after the actual rename" + ); + assert_eq!(visible.generation, epoch.generation()); + assert!(matches!(owner.snapshot(), Err(OwnerError::Fenced))); + drop(scope); + drop(owner); + let reopened_fenced = matches!( + InstallationOwner::open(&fixture.root, key()), + Err(OwnerError::Fenced) + ); + observations.push(SyncObservation { + operation, + reopened_fenced, + }); + } + for observation in &observations { + assert!( + observation.reopened_fenced, + "{} allowed a visible but unconfirmed Clean to regain admission after owner Drop: {observations:?}", + observation.operation + ); + } +} + +#[test] +fn failed_initial_intent_barriers_preserve_original_state_and_fence_reopen() { + for mode in [ + Mode::IntentFileSyncFailure, + Mode::IntentDirectorySyncFailure, + ] { + for operation in ["begin", "finish", "reconcile"] { + let fixture = Fixture::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, key()).unwrap(); + let epoch = if operation == "begin" { + None + } else { + Some(owner.begin().unwrap()) + }; + if operation == "reconcile" { + drop(owner); + owner = InstallationOwner::open(&fixture.root, key()).unwrap(); + } + let original = fs::read(fixture.root.join(STATE)).unwrap(); + let scope = Scope::arm(&fixture.root, mode); + let result = match operation { + "begin" => owner.begin().map(|_| ()), + "finish" => owner.finish(epoch.unwrap()), + "reconcile" => owner.reconcile().map(|_| ()), + _ => unreachable!(), + }; + assert!( + matches!(result, Err(OwnerError::Io(ref error)) if error.raw_os_error() == Some(libc::EIO)), + "{operation}/{mode:?}" + ); + assert_eq!(scope.counts(), (0, 0, 1), "{operation}/{mode:?}"); + // These faults precede every STATE write, not merely its rename. + assert_eq!(fs::read(fixture.root.join(STATE)).unwrap(), original); + assert!(fixture.root.join(INTENT).is_file()); + assert!(!fixture.root.join(NEXT).exists()); + assert!(matches!(owner.snapshot(), Err(OwnerError::Fenced))); + drop(scope); + drop(owner); + assert!( + matches!( + InstallationOwner::open(&fixture.root, key()), + Err(OwnerError::Fenced) + ), + "{operation}/{mode:?}" + ); + } + } +} + +#[test] +fn failed_postcommit_cleanup_sync_fences_owner_but_absent_marker_allows_durable_successor() { + for operation in ["finish", "reconcile"] { + let fixture = Fixture::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, key()).unwrap(); + let epoch = owner.begin().unwrap(); + if operation == "reconcile" { + drop(owner); + owner = InstallationOwner::open(&fixture.root, key()).unwrap(); + } + let scope = Scope::arm(&fixture.root, Mode::IntentCleanupDirectorySyncFailure); + let result = if operation == "finish" { + owner.finish(epoch) + } else { + owner.reconcile().map(|_| ()) + }; + assert!( + matches!(result, Err(OwnerError::Io(ref error)) if error.raw_os_error() == Some(libc::EIO)), + "{operation}" + ); + assert_eq!(scope.counts(), (0, 0, 1)); + // The hook is after the confirmed STATE barrier and actual unlink. + assert!(!fixture.root.join(INTENT).exists()); + assert!(!fixture.root.join(NEXT).exists()); + let visible = Record::decode(&fs::read(fixture.root.join(STATE)).unwrap()).unwrap(); + assert_eq!(visible.phase, Phase::Clean); + assert_eq!(visible.generation, epoch.generation()); + assert!(matches!(owner.snapshot(), Err(OwnerError::Fenced))); + drop(scope); + drop(owner); + let mut successor = InstallationOwner::open(&fixture.root, key()).unwrap(); + let snapshot = successor.snapshot().unwrap(); + assert_eq!(snapshot.phase, Phase::Clean); + assert_eq!(snapshot.epoch, epoch.generation()); + assert_eq!( + successor.begin().unwrap().generation(), + epoch.generation() + 1 + ); + } +} diff --git a/crates/mvm-vmm/src/host/mod.rs b/crates/mvm-vmm/src/host/mod.rs index 54da9f488a..13e243e68e 100644 --- a/crates/mvm-vmm/src/host/mod.rs +++ b/crates/mvm-vmm/src/host/mod.rs @@ -26,6 +26,8 @@ pub mod host_agent_spawn; pub mod hvf_control_transport; pub mod hvf_stop; pub mod hvf_supervisor; +#[cfg(unix)] +pub mod installed_owner; pub mod late_volumes; pub mod linux_env; pub mod network_endpoint_spawn; diff --git a/crates/mvm-vmm/tests/installed_owner.rs b/crates/mvm-vmm/tests/installed_owner.rs new file mode 100644 index 0000000000..928237bdd6 --- /dev/null +++ b/crates/mvm-vmm/tests/installed_owner.rs @@ -0,0 +1,779 @@ +#![cfg(unix)] + +use mvm_vmm::host::installed_owner::{ + HolderLease, InstallationKey, InstallationOwner, OwnerError, Phase, Recovery, +}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, OpenOptions}, + io::{BufRead, BufReader, Write}, + os::unix::{ + fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt, symlink}, + io::AsRawFd, + }, + path::{Path, PathBuf}, + process::{Child, ChildStdin, Command, Stdio}, + sync::{ + atomic::{AtomicU64, Ordering}, + mpsc, + }, + thread::JoinHandle, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +fn key() -> InstallationKey { + InstallationKey { + installation: [0x31; 32], + profile: [0x47; 32], + } +} +struct Fixture { + base: PathBuf, + root: PathBuf, +} +impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let base = std::env::temp_dir().join(format!( + "spiral-installed-owner-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::DirBuilder::new().mode(0o700).create(&base).unwrap(); + let root = base.join("installation"); + Self { base, root } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.base); + } +} +fn identity(path: &std::path::Path) -> (u64, u64) { + let metadata = fs::symlink_metadata(path).unwrap(); + (metadata.dev(), metadata.ino()) +} + +#[test] +fn handoff_exports_exact_current_state_and_pins_original_holder_until_final_close() { + let fixture = Fixture::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, key()).unwrap(); + let epoch = owner.begin().unwrap(); + let lease = HolderLease::acquire(&fixture.root, key(), epoch.generation()).unwrap(); + let record = fs::read(fixture.root.join("state.bin")).unwrap(); + assert_eq!(record.len(), 176); + let handoff = lease.prepare_handoff().unwrap(); + let directory = handoff.directory.metadata().unwrap(); + let holder = handoff.holder.metadata().unwrap(); + assert_eq!((directory.dev(), directory.ino()), identity(&fixture.root)); + assert_eq!( + (holder.dev(), holder.ino()), + identity(&fixture.root.join("holders.lock")) + ); + assert_eq!(usize::from(handoff.state_bytes), record.len()); + let digest: [u8; 32] = Sha256::digest(&record).into(); + assert_eq!(handoff.state_sha256, digest); + for file in [&handoff.directory, &handoff.holder] { + // SAFETY: inspect only each live, borrowed exported descriptor. + let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) }; + assert!(flags >= 0); + assert_ne!(flags & libc::FD_CLOEXEC, 0); + } + drop(lease); + assert!(matches!(owner.finish(epoch), Err(OwnerError::Busy))); + assert_eq!(owner.snapshot().unwrap().phase, Phase::Active); + drop(handoff); + owner.finish(epoch).unwrap(); + assert_eq!(owner.snapshot().unwrap().phase, Phase::Clean); + assert_eq!(owner.snapshot().unwrap().epoch, epoch.generation()); +} + +#[test] +fn handoff_freshly_refuses_changed_generation_phase_corruption_and_replaced_root() { + for fault in ["generation", "phase", "checksum", "root"] { + let fixture = Fixture::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, key()).unwrap(); + let epoch = owner.begin().unwrap(); + let lease = HolderLease::acquire(&fixture.root, key(), epoch.generation()).unwrap(); + let probe = OpenOptions::new() + .read(true) + .write(true) + .open(fixture.root.join("holders.lock")) + .unwrap(); + // Only the acquired guard retains SH during the refusal checks. + drop(owner); + let mut state = fixture.root.join("state.bin"); + let mut record = fs::read(&state).unwrap(); + match fault { + "generation" => { + record[16..24] + .copy_from_slice(&epoch.generation().checked_add(1).unwrap().to_be_bytes()); + checksum(&mut record); + fs::write(&state, &record).unwrap(); + } + "phase" => { + record[8] = 0; + checksum(&mut record); + fs::write(&state, &record).unwrap(); + } + "checksum" => { + record[175] ^= 1; + fs::write(&state, &record).unwrap(); + } + "root" => { + let saved = fixture.base.join("saved-installation"); + fs::rename(&fixture.root, &saved).unwrap(); + fs::DirBuilder::new() + .mode(0o700) + .create(&fixture.root) + .unwrap(); + state = saved.join("state.bin"); + } + _ => unreachable!(), + } + assert!(lease.prepare_handoff().is_err(), "{fault}"); + assert_eq!(fs::read(state).unwrap(), record, "{fault}"); + // SAFETY: independent original holder FD; refusal must not drop SH. + let blocked = unsafe { libc::flock(probe.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + assert_eq!(blocked, -1, "{fault}"); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EWOULDBLOCK), + "{fault}" + ); + drop(lease); + // SAFETY: the same nonblocking probe after the last guard closes. + assert_eq!( + unsafe { libc::flock(probe.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, + 0, + "{fault}" + ); + } +} + +#[test] +fn exclusive_owner_issues_one_epoch_and_finishes_after_last_holder_exits() { + let fixture = Fixture::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, key()).unwrap(); + assert!(matches!(owner.snapshot().unwrap().phase, Phase::Clean)); + assert_eq!(owner.snapshot().unwrap().epoch, 0); + let locks = [ + identity(&fixture.root.join("owner.lock")), + identity(&fixture.root.join("holders.lock")), + ]; + assert!(matches!( + InstallationOwner::open(&fixture.root, key()), + Err(OwnerError::Busy) + )); + let epoch = owner.begin().unwrap(); + assert_eq!(epoch.generation(), 1); + assert!(matches!(owner.snapshot().unwrap().phase, Phase::Active)); + let holder = HolderLease::acquire(&fixture.root, key(), epoch.generation()).unwrap(); + drop(holder); + owner.finish(epoch).unwrap(); + assert!(matches!(owner.snapshot().unwrap().phase, Phase::Clean)); + assert_eq!(owner.snapshot().unwrap().epoch, 1); + assert_eq!( + locks, + [ + identity(&fixture.root.join("owner.lock")), + identity(&fixture.root.join("holders.lock")) + ] + ); +} + +const WATCHDOG: Duration = Duration::from_secs(5); +const CHILD_MODE: &str = "SPIRAL_OWNER_TEST_CHILD_MODE"; + +struct Process { + child: Child, + input: ChildStdin, + events: mpsc::Receiver, + reader: Option>, + reaped: bool, +} +impl Process { + fn spawn(mode: &str, root: &Path, generation: u64) -> Self { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "child_process_entry", + "--nocapture", + "--test-threads=1", + ]) + .env(CHILD_MODE, mode) + .env("SPIRAL_OWNER_TEST_ROOT", root) + .env("SPIRAL_OWNER_TEST_GENERATION", generation.to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + let input = child.stdin.take().unwrap(); + let output = child.stdout.take().unwrap(); + let (send, events) = mpsc::sync_channel(8); + let reader = std::thread::spawn(move || { + for line in BufReader::new(output).lines() { + let Ok(line) = line else { + break; + }; + // libtest can prefix the first captured line with its test + // name. Only our explicit protocol suffix enters the channel. + if let Some(start) = line.find("EVENT ") + && send.try_send(line[start..].to_owned()).is_err() + { + return; + } + } + let _ = send.try_send("EVENT EOF".into()); + }); + Self { + child, + input, + events, + reader: Some(reader), + reaped: false, + } + } + fn event(&self, prefix: &str) -> String { + let event = self + .events + .recv_timeout(WATCHDOG) + .expect("child readiness watchdog"); + assert!(event.starts_with(prefix), "expected {prefix}, got {event}"); + event + } + fn kill(mut self) { + self.child.kill().unwrap(); + let status = self.child.wait().unwrap(); + self.reaped = true; + assert!( + !status.success(), + "SIGKILL must not look like orderly shutdown" + ); + self.reader.take().unwrap().join().unwrap(); + } + fn finish(mut self, orderly_exit: bool) { + if orderly_exit { + self.input.write_all(b"exit\n").unwrap(); + self.input.flush().unwrap(); + self.event("EVENT RELEASED"); + } + let deadline = Instant::now() + WATCHDOG; + loop { + if let Some(status) = self.child.try_wait().unwrap() { + self.reaped = true; + assert!(status.success(), "owned child failed: {status}"); + break; + } + assert!(Instant::now() < deadline, "child exit watchdog"); + std::thread::yield_now(); + } + self.reader.take().unwrap().join().unwrap(); + } +} +impl Drop for Process { + fn drop(&mut self) { + if !self.reaped { + let _ = self.child.kill(); + let _ = self.child.wait(); + self.reaped = true; + } + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } +} +fn ready(message: &str) { + println!("EVENT {message}"); + std::io::stdout().flush().unwrap(); +} +fn release_command() { + let mut command = String::new(); + std::io::stdin().read_line(&mut command).unwrap(); + assert_eq!(command, "exit\n"); +} + +// This helper is selected with --exact only by this test binary's trusted +// parent. No mode creates a VM, grants authority or accepts a network key. +#[test] +fn child_process_entry() { + let Ok(mode) = std::env::var(CHILD_MODE) else { + return; + }; + let root = PathBuf::from(std::env::var_os("SPIRAL_OWNER_TEST_ROOT").unwrap()); + let generation = std::env::var("SPIRAL_OWNER_TEST_GENERATION") + .unwrap() + .parse::() + .unwrap(); + match mode.as_str() { + "owner" => { + let mut owner = InstallationOwner::open(&root, key()).unwrap(); + let epoch = owner.begin().unwrap(); + let (dev, inode) = identity(&root.join("owner.lock")); + ready(&format!("ACTIVE {} {dev} {inode}", epoch.generation())); + release_command(); + drop(owner); + } + "holder" => { + let holder = HolderLease::acquire(&root, key(), generation).unwrap(); + ready(&format!("HELD {generation}")); + release_command(); + drop(holder); + } + "contender" => { + assert!(matches!( + InstallationOwner::open(&root, key()), + Err(OwnerError::Busy) + )); + let (dev, inode) = identity(&root.join("owner.lock")); + ready(&format!("BUSY {dev} {inode}")); + return; + } + "raw-clean-holder" => { + // Negative kernel-lock fixture only, not an authorized resource. + let lock = OpenOptions::new() + .read(true) + .write(true) + .open(root.join("holders.lock")) + .unwrap(); + // SAFETY: this owned descriptor is open for the duration of SH. + assert_eq!( + unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) }, + 0 + ); + ready("RAW_HELD"); + release_command(); + drop(lock); // close only; no explicit unlock operation. + } + _ => panic!("unknown owned test mode"), + } + ready("RELEASED"); +} + +#[test] +fn owner_death_does_not_retire_an_epoch_while_an_independent_process_holds_sh() { + let fixture = Fixture::new(); + drop(InstallationOwner::initialize(&fixture.root, key()).unwrap()); + let lock = identity(&fixture.root.join("owner.lock")); + let owner = Process::spawn("owner", &fixture.root, 0); + assert_eq!( + owner.event("EVENT ACTIVE"), + format!("EVENT ACTIVE 1 {} {}", lock.0, lock.1) + ); + let contender = Process::spawn("contender", &fixture.root, 0); + assert_eq!( + contender.event("EVENT BUSY"), + format!("EVENT BUSY {} {}", lock.0, lock.1) + ); + contender.finish(false); + let holder = Process::spawn("holder", &fixture.root, 1); + holder.event("EVENT HELD 1"); + owner.kill(); + let mut replacement = InstallationOwner::open(&fixture.root, key()).unwrap(); + assert_eq!(replacement.snapshot().unwrap().phase, Phase::Active); + assert_eq!(replacement.snapshot().unwrap().epoch, 1); + assert!(matches!( + replacement.begin(), + Err(OwnerError::RecoveryRequired) + )); + let state = fs::read(fixture.root.join("state.bin")).unwrap(); + assert!(matches!(replacement.reconcile(), Err(OwnerError::Busy))); + assert_eq!(fs::read(fixture.root.join("state.bin")).unwrap(), state); + holder.finish(true); + assert_eq!( + replacement.reconcile().unwrap(), + Recovery::Retired { epoch: 1 } + ); + let epoch = replacement.begin().unwrap(); + assert_eq!(epoch.generation(), 2); + assert!(matches!( + HolderLease::acquire(&fixture.root, key(), 1), + Err(OwnerError::StaleEpoch) + )); + let current = HolderLease::acquire(&fixture.root, key(), 2).unwrap(); + drop(current); + replacement.finish(epoch).unwrap(); + assert_eq!(identity(&fixture.root.join("owner.lock")), lock); +} + +#[test] +fn finish_busy_retains_active_until_actual_holder_exit_and_can_retry_same_epoch() { + let fixture = Fixture::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, key()).unwrap(); + let epoch = owner.begin().unwrap(); + let holder = Process::spawn("holder", &fixture.root, epoch.generation()); + holder.event("EVENT HELD 1"); + let state = fs::read(fixture.root.join("state.bin")).unwrap(); + assert!(matches!(owner.finish(epoch), Err(OwnerError::Busy))); + assert_eq!(owner.snapshot().unwrap().phase, Phase::Active); + assert_eq!(fs::read(fixture.root.join("state.bin")).unwrap(), state); + holder.finish(true); + owner.finish(epoch).unwrap(); + assert_eq!(owner.snapshot().unwrap().phase, Phase::Clean); + assert_eq!(owner.snapshot().unwrap().epoch, 1); +} + +fn checksum(record: &mut [u8]) { + assert_eq!(record.len(), 176); + let mut hash = Sha256::new(); + hash.update(b"spiralcore/installed-owner/v1\0"); + hash.update(&record[..144]); + let digest: [u8; 32] = hash.finalize().into(); + record[144..].copy_from_slice(&digest); +} +fn invalid(root: &Path) { + assert!(matches!( + InstallationOwner::open(root, key()), + Err(OwnerError::InvalidStorage) + )); +} + +#[test] +fn fixed_record_detects_torn_oversized_reserved_and_checksummed_semantic_corruption() { + for mode in [ + "missing", "empty", "torn", "trailing", "checksum", "phase", "reserved", "magic", + "trailer", "identity", + ] { + let fixture = Fixture::new(); + drop(InstallationOwner::initialize(&fixture.root, key()).unwrap()); + let path = fixture.root.join("state.bin"); + let mut record = fs::read(&path).unwrap(); + assert_eq!(record.len(), 176); + assert_eq!(&record[..8], b"SCOWNER1"); + assert_eq!(&record[136..144], b"ENDOWN01"); + assert_eq!(&record[24..56], &key().installation); + assert_eq!(&record[56..88], &key().profile); + let mut recomputed = record.clone(); + checksum(&mut recomputed); + assert_eq!(record, recomputed); + match mode { + "missing" => { + fs::remove_file(&path).unwrap(); + invalid(&fixture.root); + continue; + } + "empty" => record.clear(), + "torn" => { + record.pop(); + } + "trailing" => record.push(0), + "checksum" => record[175] ^= 1, + "phase" => { + record[8] = 2; + checksum(&mut record); + } + "reserved" => { + record[9] = 1; + checksum(&mut record); + } + "magic" => { + record[0] ^= 1; + checksum(&mut record); + } + "trailer" => { + record[136] ^= 1; + checksum(&mut record); + } + "identity" => { + record[112] ^= 1; + checksum(&mut record); + } + _ => unreachable!(), + } + fs::write(&path, &record).unwrap(); + invalid(&fixture.root); + assert_eq!( + fs::read(&path).unwrap(), + record, + "refusal must not repair/reset {mode}" + ); + } + let fixture = Fixture::new(); + drop(InstallationOwner::initialize(&fixture.root, key()).unwrap()); + let path = fixture.root.join("state.bin"); + let mut record = fs::read(&path).unwrap(); + record[16..24].copy_from_slice(&u64::MAX.to_be_bytes()); + checksum(&mut record); + fs::write(&path, &record).unwrap(); + let mut owner = InstallationOwner::open(&fixture.root, key()).unwrap(); + assert!(matches!(owner.begin(), Err(OwnerError::EpochExhausted))); + assert_eq!(owner.snapshot().unwrap().epoch, u64::MAX); + assert_eq!(fs::read(path).unwrap(), record); +} + +#[test] +fn key_profile_modes_symlinks_hardlinks_and_nonregular_objects_refuse_without_reset() { + for mode in [ + "wrong-key", + "wrong-profile", + "root-mode", + "root-symlink", + "owner-mode", + "holders-mode", + "state-mode", + "owner-symlink", + "holders-symlink", + "state-symlink", + "owner-hardlink", + "holders-hardlink", + "state-hardlink", + "owner-directory", + "holders-directory", + "state-directory", + ] { + let fixture = Fixture::new(); + drop(InstallationOwner::initialize(&fixture.root, key()).unwrap()); + let original = fs::read(fixture.root.join("state.bin")).unwrap(); + let mut target = fixture.root.clone(); + let mut supplied = key(); + match mode { + "wrong-key" => supplied.installation[0] ^= 1, + "wrong-profile" => supplied.profile[0] ^= 1, + "root-mode" => fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(), + "root-symlink" => { + target = fixture.base.join("alias"); + symlink(&fixture.root, &target).unwrap(); + } + _ => { + let object = if mode.starts_with("owner-") { + "owner.lock" + } else if mode.starts_with("holders-") { + "holders.lock" + } else { + "state.bin" + }; + let path = fixture.root.join(object); + if mode.ends_with("-mode") { + fs::set_permissions(path, fs::Permissions::from_mode(0o640)).unwrap(); + } else if mode.ends_with("-hardlink") { + fs::hard_link(path, fixture.base.join("extra-link")).unwrap(); + } else { + let saved = fixture.base.join("saved-object"); + fs::rename(&path, &saved).unwrap(); + if mode.ends_with("-symlink") { + symlink(saved, path).unwrap(); + } else { + fs::DirBuilder::new().mode(0o700).create(path).unwrap(); + } + } + } + } + assert!( + matches!( + InstallationOwner::open(&target, supplied), + Err(OwnerError::InvalidStorage) + ), + "{mode}" + ); + if !mode.starts_with("state-") { + assert_eq!( + fs::read(fixture.root.join("state.bin")).unwrap(), + original, + "{mode}" + ); + } + } +} + +#[cfg(target_os = "macos")] +#[test] +fn extended_acl_access_refuses_even_when_posix_modes_remain_private() { + // A component privacy gate, not a foreign-UID access or VM-launch witness. + // chmod mutates only this test's private fixtures; no ACL is repaired/reset. + let mut observations = Vec::new(); + for object in ["parent", "root", "owner.lock", "holders.lock", "state.bin"] { + let fixture = Fixture::new(); + let original = if object == "parent" { + None + } else { + drop(InstallationOwner::initialize(&fixture.root, key()).unwrap()); + Some(fs::read(fixture.root.join("state.bin")).unwrap()) + }; + let target = match object { + "parent" => fixture.base.clone(), + "root" => fixture.root.clone(), + name => fixture.root.join(name), + }; + let original_mode = fs::symlink_metadata(&target).unwrap().mode() & 0o7777; + assert_eq!( + original_mode, + if object == "parent" || object == "root" { + 0o700 + } else { + 0o600 + } + ); + let ace = if object == "parent" { + "everyone allow read,write,execute,file_inherit,directory_inherit" + } else if object == "root" { + "everyone allow read,write,execute" + } else { + "everyone allow read,write" + }; + let chmod = Command::new("/bin/chmod") + .args(["+a", ace]) + .arg(&target) + .output() + .unwrap(); + assert!( + chmod.status.success(), + "{object}: {}", + String::from_utf8_lossy(&chmod.stderr) + ); + assert_eq!( + fs::symlink_metadata(&target).unwrap().mode() & 0o7777, + original_mode, + "ACL setup must leave the private POSIX bits unchanged" + ); + let listing = Command::new("/bin/ls") + .arg("-lde") + .arg(&target) + .output() + .unwrap(); + assert!(listing.status.success()); + let listing = String::from_utf8(listing.stdout).unwrap(); + assert!(listing.contains("group:everyone allow"), "{listing}"); + if object == "parent" { + assert!(listing.contains("file_inherit,directory_inherit")); + } + let result = if object == "parent" { + InstallationOwner::initialize(&fixture.root, key()) + } else { + InstallationOwner::open(&fixture.root, key()) + } + .map(|_| ()); + observations.push(( + object, + matches!(&result, Err(OwnerError::InvalidStorage)), + format!("{result:?}"), + )); + if let Some(original) = original { + assert_eq!(fs::read(fixture.root.join("state.bin")).unwrap(), original); + } + // The ACL itself is retained until owned-fixture removal, including + // when initialization/open erroneously succeeds on the baseline. + let after = Command::new("/bin/ls") + .arg("-lde") + .arg(&target) + .output() + .unwrap(); + assert!(after.status.success()); + let after = String::from_utf8(after.stdout).unwrap(); + assert_eq!( + after.lines().skip(1).collect::>(), + listing.lines().skip(1).collect::>() + ); + } + for (object, refused, _) in &observations { + assert!( + refused, + "{object} accepted an ACL privacy violation: {observations:?}" + ); + } +} + +#[test] +fn live_directory_or_lock_replacement_fences_before_any_new_epoch_publication() { + for object in ["directory", "owner.lock", "holders.lock"] { + let fixture = Fixture::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, key()).unwrap(); + let state = fs::read(fixture.root.join("state.bin")).unwrap(); + let state_path = if object == "directory" { + let saved = fixture.base.join("original-directory"); + fs::rename(&fixture.root, &saved).unwrap(); + fs::DirBuilder::new() + .mode(0o700) + .create(&fixture.root) + .unwrap(); + saved.join("state.bin") + } else { + let path = fixture.root.join(object); + fs::rename(&path, fixture.base.join("original-lock")).unwrap(); + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .unwrap(); + fixture.root.join("state.bin") + }; + assert!(matches!(owner.begin(), Err(OwnerError::Fenced)), "{object}"); + assert!(matches!(owner.begin(), Err(OwnerError::Fenced)), "{object}"); + assert_eq!(fs::read(state_path).unwrap(), state); + } +} + +#[test] +fn drop_never_cleans_and_retirement_preserves_history_and_rejects_late_old_token() { + let fixture = Fixture::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, key()).unwrap(); + let old = owner.begin().unwrap(); + drop(owner); + let mut owner = InstallationOwner::open(&fixture.root, key()).unwrap(); + assert_eq!(owner.snapshot().unwrap().phase, Phase::Active); + assert!(matches!(owner.begin(), Err(OwnerError::RecoveryRequired))); + assert_eq!(owner.reconcile().unwrap(), Recovery::Retired { epoch: 1 }); + assert_eq!(owner.reconcile().unwrap(), Recovery::AlreadyClean); + assert!(matches!( + HolderLease::acquire(&fixture.root, key(), 1), + Err(OwnerError::StaleEpoch) + )); + let current = owner.begin().unwrap(); + assert_eq!(current.generation(), 2); + assert!(matches!(owner.finish(old), Err(OwnerError::StaleEpoch))); + assert_eq!(owner.snapshot().unwrap().phase, Phase::Active); + owner.finish(current).unwrap(); + drop(owner); + let owner = InstallationOwner::open(&fixture.root, key()).unwrap(); + assert_eq!(owner.snapshot().unwrap().phase, Phase::Clean); + assert_eq!(owner.snapshot().unwrap().epoch, 2); +} + +#[test] +fn occupied_clean_holder_blocks_activation_and_uncertain_temp_and_zero_keys_refuse() { + let fixture = Fixture::new(); + let mut owner = InstallationOwner::initialize(&fixture.root, key()).unwrap(); + let holder = Process::spawn("raw-clean-holder", &fixture.root, 0); + holder.event("EVENT RAW_HELD"); + let state = fs::read(fixture.root.join("state.bin")).unwrap(); + assert!(matches!(owner.begin(), Err(OwnerError::Busy))); + assert_eq!(owner.snapshot().unwrap().phase, Phase::Clean); + assert_eq!(fs::read(fixture.root.join("state.bin")).unwrap(), state); + holder.finish(true); + let epoch = owner.begin().unwrap(); + assert_eq!(epoch.generation(), 1); + owner.finish(epoch).unwrap(); + let finished = fs::read(fixture.root.join("state.bin")).unwrap(); + assert!(InstallationOwner::initialize(&fixture.root, key()).is_err()); + assert_eq!(fs::read(fixture.root.join("state.bin")).unwrap(), finished); + drop(owner); + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(fixture.root.join(".state.next")) + .unwrap(); + assert!(matches!( + InstallationOwner::open(&fixture.root, key()), + Err(OwnerError::Fenced) + )); + assert!(fixture.root.join(".state.next").exists()); + for zero_installation in [true, false] { + let fresh = Fixture::new(); + let mut invalid = key(); + if zero_installation { + invalid.installation = [0; 32]; + } else { + invalid.profile = [0; 32]; + } + assert!(matches!( + InstallationOwner::initialize(&fresh.root, invalid), + Err(OwnerError::InvalidStorage) + )); + } +} diff --git a/third_party/spiralcore-installed-owner/LICENSE-MIT b/third_party/spiralcore-installed-owner/LICENSE-MIT new file mode 100644 index 0000000000..db8f777969 --- /dev/null +++ b/third_party/spiralcore-installed-owner/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SpiralCore contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/spiralcore-installed-owner/README.md b/third_party/spiralcore-installed-owner/README.md new file mode 100644 index 0000000000..49bf73d082 --- /dev/null +++ b/third_party/spiralcore-installed-owner/README.md @@ -0,0 +1,33 @@ +# Installed resource ownership component + +This MIT-licensed component is imported from SpiralCore. The full copyright +notice is preserved in `LICENSE-MIT`; `provenance.json` records the source +revision, paths, exact imported hashes and integration-test import edit. + +The original owner holds an exclusive owner lock. Every participating resource +retains its own shared holder lease for the original installation/profile/epoch. +Recovery requires a fresh, separately opened exclusive holder lock; it does not +infer cleanup from a PID, timeout or metadata assertion. Drop never publishes a +clean epoch. Stable descriptor identities, private local storage and durable +publication barriers remain mandatory. + +The import preserves the fixed 176-byte record and checksum domain. It uses +MVM's already locked SHA-256 dependency and adds no package version. The record +checksum is damage detection, not authentication or a FIPS-validation claim. + +`mvm-vmm::host::installed_owner` is a Unix resource-lifetime primitive. Its +successful retirement proves exclusion only of resources that actually retain +the lease. It neither grants mailbox/boot authority nor proves original Child +collection. Host-agent, signer, helper and cleanup-job participation must be +integrated and tested before it can qualify installed-service recovery. + +Focused Linux validation commands (serial subprocess fixtures): + +```sh +cargo test --release --locked -p mvm-vmm --test installed_owner -- --test-threads=1 +cargo test --release --locked -p mvm-vmm --lib host::installed_owner -- --test-threads=1 +cargo clippy --release --locked -p mvm-vmm --lib --test installed_owner -- -D warnings +``` + +These commands exercise the imported production module. They boot no MVM and +do not establish native macOS, Windows, WAN, durable mailbox or fleet acceptance. diff --git a/third_party/spiralcore-installed-owner/provenance.json b/third_party/spiralcore-installed-owner/provenance.json new file mode 100644 index 0000000000..70fa020b1e --- /dev/null +++ b/third_party/spiralcore-installed-owner/provenance.json @@ -0,0 +1,32 @@ +{ + "source_repository": "https://github.com/R4-Laboratories/SpiralCore", + "source_revision": "179fb1eba84e1056b86ac7027de5dc777138c512", + "files": [ + { + "source_path": "crates/spiralcore-mvm-live/src/installed_owner.rs", + "destination_path": "crates/mvm-vmm/src/host/installed_owner.rs", + "sha256": "94006668298fbb172ae52e23105db51b57a44aa881ae6a84c556e0f85991100c", + "portability_edits": [] + }, + { + "source_path": "crates/spiralcore-mvm-live/src/installed_owner/tests.rs", + "destination_path": "crates/mvm-vmm/src/host/installed_owner/tests.rs", + "sha256": "4d9b5151bdf013e2c27e553dd74d69599fa058b3d141017a64a2e722382f66bf", + "portability_edits": [] + }, + { + "source_path": "crates/spiralcore-mvm-live/src/installed_owner/binding_tests.rs", + "destination_path": "crates/mvm-vmm/src/host/installed_owner/binding_tests.rs", + "sha256": "e992c83c683dfc8b597f0fcbf0c6dde7a35195de517149977377b2073f4087fa", + "portability_edits": [] + } + ], + "integration_test": { + "source_path": "crates/spiralcore-mvm-live/tests/installed-owner-harness/tests/lifecycle.rs", + "destination_path": "crates/mvm-vmm/tests/installed_owner.rs", + "edits": [ + "Change library import from installed_owner to mvm_vmm::host::installed_owner." + ] + }, + "dependencies": "Existing libc and workspace sha2 0.11.0; original component used sha2 0.10. No format or checksum-domain change." +}