From 7cd704aefe01ed90169037415dd1574f876fdfea Mon Sep 17 00:00:00 2001 From: casey-allard Date: Wed, 11 Nov 2026 07:32:50 -0500 Subject: [PATCH] feat(vmm): add installed resource-holder epoch exclusion Extract the MIT resource-owner import from c009524ea onto upstream a707f5e8 without mailbox API, SDK, journal or service changes. Preserve the exact owner and holder implementation, immutable state format, license and source provenance. The identical component files passed 11 integration entries, six private units, strict scoped Clippy and independent code/security review on the declared development composition. Qualification of this standalone upstream composition is pending; this draft enables early review and grants no runtime activation, authority, original-child collection or installed/WAN/FIPS claim. --- Cargo.lock | 1 + crates/mvm-vmm/Cargo.toml | 1 + crates/mvm-vmm/src/host/installed_owner.rs | 754 +++++++++++++++++ .../src/host/installed_owner/binding_tests.rs | 90 ++ .../mvm-vmm/src/host/installed_owner/tests.rs | 416 ++++++++++ crates/mvm-vmm/src/host/mod.rs | 2 + crates/mvm-vmm/tests/installed_owner.rs | 779 ++++++++++++++++++ .../spiralcore-installed-owner/LICENSE-MIT | 21 + .../spiralcore-installed-owner/README.md | 33 + .../provenance.json | 32 + 10 files changed, 2129 insertions(+) create mode 100644 crates/mvm-vmm/src/host/installed_owner.rs create mode 100644 crates/mvm-vmm/src/host/installed_owner/binding_tests.rs create mode 100644 crates/mvm-vmm/src/host/installed_owner/tests.rs create mode 100644 crates/mvm-vmm/tests/installed_owner.rs create mode 100644 third_party/spiralcore-installed-owner/LICENSE-MIT create mode 100644 third_party/spiralcore-installed-owner/README.md create mode 100644 third_party/spiralcore-installed-owner/provenance.json 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." +}