diff --git a/Cargo.lock b/Cargo.lock index e53ffdcd9..0fdcfa6bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4714,6 +4714,7 @@ dependencies = [ "tracing", "tracing-subscriber", "unicode-width", + "uuid", "zbus", ] diff --git a/crates/hypercolor-cli/Cargo.toml b/crates/hypercolor-cli/Cargo.toml index ad15c5c4c..afafcc976 100644 --- a/crates/hypercolor-cli/Cargo.toml +++ b/crates/hypercolor-cli/Cargo.toml @@ -44,6 +44,7 @@ opaline = { workspace = true } owo-colors = { workspace = true } unicode-width = { workspace = true } toml = { workspace = true } +uuid = { workspace = true } hypercolor-tui = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/hypercolor-cli/src/install/coordinator.rs b/crates/hypercolor-cli/src/install/coordinator.rs index f3a2103ee..88ef2d3cd 100644 --- a/crates/hypercolor-cli/src/install/coordinator.rs +++ b/crates/hypercolor-cli/src/install/coordinator.rs @@ -157,6 +157,34 @@ impl<'a, P: InstallPlatform> InstallCoordinator<'a, P> { return self.resume(journal, lock); } + let journal = self.prepare_with_lock(request, lock)?; + self.store.write_journal(&journal, lock)?; + self.drive_forward(journal, lock) + } + + /// Build an exact initial journal without publishing or driving it. + /// + /// Platform preparation retains the original inspection and candidate + /// bindings. Callers may durably bind that journal to an authority handoff + /// before writing it and entering the existing recovery path. + /// + /// # Errors + /// Returns an error when another transaction needs recovery, the lock is + /// foreign, or the original platform and transaction plan cannot be proven. + pub fn prepare_with_lock( + &mut self, + request: InstallRequest, + lock: &InstallLock, + ) -> Result { + if let Some(journal) = self.store.load_journal(lock)? + && matches!( + journal.disposition, + InstallDisposition::Forward | InstallDisposition::Rollback + ) + { + return Err(InstallCoordinatorError::PendingPreparation); + } + let prior_active_unit = self.store.active_unit(lock)?; let prior_platform = self .platform @@ -198,8 +226,7 @@ impl<'a, P: InstallPlatform> InstallCoordinator<'a, P> { prepared_platform.layout_operation_count, prepared_platform.record, )?; - self.store.write_journal(&journal, lock)?; - self.drive_forward(journal, lock) + Ok(journal) } pub fn recover(&mut self) -> Result, InstallCoordinatorError> { @@ -1295,6 +1322,8 @@ fn truncate_detail(mut detail: String) -> String { #[derive(Debug, thiserror::Error)] pub enum InstallCoordinatorError { + #[error("an existing install transaction must be recovered before preparing another")] + PendingPreparation, #[error(transparent)] Store(#[from] InstallStoreError), #[error(transparent)] diff --git a/crates/hypercolor-cli/src/install/linux/adoption.rs b/crates/hypercolor-cli/src/install/linux/adoption.rs new file mode 100644 index 000000000..a0c6cbc63 --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/adoption.rs @@ -0,0 +1,211 @@ +//! Prepare managed authority while the historical installer still owns activation. + +use std::path::{Path, PathBuf}; + +use super::super::{ + InstallCoordinator, InstallCoordinatorError, InstallDisposition, InstallJournalV1, InstallLock, + InstallPlatform, InstallPlatformError, InstallRequest, InstallStore, InstallStoreError, + ReleasePayloadError, UnitRecord, copy_installed_release_unit, +}; +use super::{ + LinuxInstallAuthority, LinuxInstallElection, LinuxInstallLocation, LinuxInstallLocator, + LinuxLocatorError, LinuxManagedAuthority, retain_linux_unit, +}; + +#[derive(Debug, thiserror::Error)] +pub enum LinuxAdoptionError { + #[error("legacy transaction must be recovered before managed adoption")] + LegacyRecoveryRequired, + #[error("managed adoption preparation belongs to another authority or candidate")] + ConflictingPreparation, + #[error(transparent)] + Locator(#[from] LinuxLocatorError), + #[error(transparent)] + Store(#[from] InstallStoreError), + #[error(transparent)] + Payload(#[from] ReleasePayloadError), + #[error(transparent)] + Platform(#[from] InstallPlatformError), + #[error(transparent)] + Coordinator(#[from] InstallCoordinatorError), +} + +/// A private managed preparation retaining old then new locks until publication. +pub struct LinuxAdoption { + home: PathBuf, + old_lock: InstallLock, + locator: LinuxInstallLocator, + location: LinuxInstallLocation, + store: InstallStore, + lock: InstallLock, + prior: Option, +} + +impl LinuxAdoption { + /// Bootstrap recorded roots and copy the original active unit privately. + /// + /// The new pointer initially names a verified copy of the old logical unit. + /// The old physical pointer and all public launcher/layout entries stay put. + /// + /// # Errors + /// Refuses pending legacy transactions, unsafe roots and conflicting state. + pub fn begin( + home: &Path, + elected: LinuxInstallElection, + proposed: LinuxInstallLocation, + ) -> Result { + let LinuxInstallElection::Legacy { + store: old, + lock: old_lock, + locator, + } = elected + else { + return Err(LinuxAdoptionError::ConflictingPreparation); + }; + let old_root = home.join(".local/lib/hypercolor"); + if old.root() != old_root + || old.state_root() != old_root + || !old_lock.guards_roots(&old_root, &old_root) + { + return Err(LinuxAdoptionError::ConflictingPreparation); + } + // Rebind the fixed path to the elected lock rather than trusting a + // separately supplied locator capability in a constructed enum value. + drop(locator); + let locator = LinuxInstallLocator::retain(home, &old_lock)?; + match locator.read()? { + LinuxInstallAuthority::Legacy(Some(journal)) + if matches!( + journal.disposition, + InstallDisposition::Forward | InstallDisposition::Rollback + ) => + { + return Err(LinuxAdoptionError::LegacyRecoveryRequired); + } + LinuxInstallAuthority::Legacy(_) => {} + LinuxInstallAuthority::Managed(_) => { + return Err(LinuxAdoptionError::ConflictingPreparation); + } + } + let original_id = old.active_unit(&old_lock)?; + let prior = original_id + .as_ref() + .map(|id| retain_linux_unit(&old, &old_lock, id)) + .transpose()?; + let (location, store, lock) = + super::adoption_roots::prepare_roots(home, &old_lock, proposed)?; + if let Some(prior) = &prior { + copy_installed_release_unit(&store, &lock, prior)?; + } + match (store.active_unit(&lock)?, &original_id) { + (None, Some(id)) => store.set_active(Some(id), &lock)?, + (actual, expected) if actual.as_ref() == expected.as_ref() => {} + _ => return Err(LinuxAdoptionError::ConflictingPreparation), + } + Ok(Self { + home: home.to_owned(), + old_lock, + locator, + location, + store, + lock, + prior, + }) + } + + #[must_use] + pub fn store(&self) -> &InstallStore { + &self.store + } + #[must_use] + pub fn lock(&self) -> &InstallLock { + &self.lock + } + #[must_use] + pub fn location(&self) -> &LinuxInstallLocation { + &self.location + } + #[must_use] + pub fn original_prior(&self) -> Option<&UnitRecord> { + self.prior.as_ref() + } + + /// Return the identical initial proposal for cold prior-role reconstruction. + /// + /// # Errors + /// Refuses an unbound journal or disagreement between the two durable copies. + pub fn prepared_journal(&self) -> Result, LinuxAdoptionError> { + let receipt = self + .locator + .prepared_journal(&self.location, &self.store, &self.lock)?; + let journal = self.store.load_journal(&self.lock)?; + match (receipt, journal) { + (None, None) => Ok(None), + (Some(receipt), None) => Ok(Some(receipt)), + (Some(receipt), Some(journal)) if receipt == journal => Ok(Some(journal)), + _ => Err(LinuxAdoptionError::ConflictingPreparation), + } + } + + /// Prepare or resume the identical initial journal and persist its receipt. + /// + /// # Errors + /// Refuses another candidate, changed old state or a journal lacking its + /// original receipt. No service or public layout transition occurs here. + pub fn prepare( + &self, + platform: &mut impl InstallPlatform, + request: InstallRequest, + ) -> Result { + let journal = if let Some(existing) = self.prepared_journal()? { + if existing.candidate_unit != *request.candidate.id() { + return Err(LinuxAdoptionError::ConflictingPreparation); + } + existing + } else { + InstallCoordinator::new(&self.store, platform).prepare_with_lock(request, &self.lock)? + }; + self.locator.prepare_adoption( + &self.location, + &journal, + &self.store, + &self.lock, + platform, + )?; + Ok(journal) + } + + /// Publish prepared authority and hand back only the managed state lock. + /// + /// The existing coordinator recovery path drives activation afterward. Any + /// error forbids activation, including an error after locator visibility. + /// + /// # Errors + /// Refuses changed preparation, failed durability or failed managed rebind. + pub fn publish( + self, + journal: &InstallJournalV1, + platform: &mut impl InstallPlatform, + ) -> Result { + self.locator.prepare_adoption( + &self.location, + journal, + &self.store, + &self.lock, + platform, + )?; + self.store.write_journal(journal, &self.lock)?; + self.locator + .publish_prepared(&self.location, &self.store, &self.lock, platform)?; + self.locator.confirm_durable(&self.location)?; + let authority = + LinuxManagedAuthority::retain(&self.home, &self.store, &self.lock, self.location)?; + drop(self.locator); + drop(self.old_lock); + Ok(LinuxInstallElection::Managed { + store: self.store, + lock: self.lock, + authority, + }) + } +} diff --git a/crates/hypercolor-cli/src/install/linux/adoption_roots.rs b/crates/hypercolor-cli/src/install/linux/adoption_roots.rs new file mode 100644 index 000000000..72f3bc0b2 --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/adoption_roots.rs @@ -0,0 +1,142 @@ +use std::io::{self, Read as _}; +use std::path::{Component, Path}; + +use hypercolor_platform_fs::PublicDirectoryAuthority; + +use super::super::{InstallLock, InstallStore, MAX_INSTALL_JOURNAL_BYTES}; +use super::{LinuxInstallLocation, LinuxLocatorError}; + +pub(super) fn prepare_roots( + home: &Path, + old_lock: &InstallLock, + proposed: LinuxInstallLocation, +) -> Result<(LinuxInstallLocation, InstallStore, InstallLock), LinuxLocatorError> { + let owner = old_lock.open_public_directory(home)?.metadata()?; + if owner.owner_uid() != proposed.uid() || !owner.is_owned_by_current_user() { + return Err(LinuxLocatorError::Unprepared); + } + let paths = [ + proposed.data_root(), + proposed.state_root(), + proposed.release_root(), + proposed.config_root(), + ]; + let mut original = Vec::new(); + for path in paths { + original.push(bootstrap_root(old_lock, path)?); + } + proposed.retain_existing(home, old_lock)?.validate()?; + let store = InstallStore::with_roots( + proposed.release_root(), + proposed.state_root(), + MAX_INSTALL_JOURNAL_BYTES, + )?; + let lock = store.acquire_lock()?; + for (path, retained) in paths.into_iter().zip(&original) { + let before = retained.metadata()?; + let after = lock.open_public_directory(path)?.metadata()?; + if (before.device(), before.inode()) != (after.device(), after.inode()) { + return Err(LinuxLocatorError::Unprepared); + } + } + proposed.retain_existing(home, &lock)?.validate()?; + let location = retain_identity(home, &lock, proposed)?; + for root in &original { + root.validate_ancestry()?; + } + Ok((location, store, lock)) +} + +fn bootstrap_root( + lock: &InstallLock, + path: &Path, +) -> Result { + let mut components = path.components(); + if components.next() != Some(Component::RootDir) { + return Err(LinuxLocatorError::Unprepared); + } + let Some(Component::Normal(first)) = components.next() else { + return Err(LinuxLocatorError::Unprepared); + }; + let mut authority = lock.open_public_directory(&Path::new("/").join(first))?; + for component in components { + let name = match component { + Component::RootDir => continue, + Component::Normal(name) => name, + _ => return Err(LinuxLocatorError::Unprepared), + }; + authority = match authority.open_child_directory(Path::new(name)) { + Ok(child) => child, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let owner = authority.metadata()?; + if !owner.is_owned_by_current_user() || owner.mode() & 0o022 != 0 { + return Err(LinuxLocatorError::Unprepared); + } + authority.durable_ensure_child_directory(Path::new(name), 0o755)? + } + Err(error) => return Err(error.into()), + }; + } + let metadata = authority.metadata()?; + if !metadata.is_owned_by_current_user() || metadata.mode() & 0o022 != 0 { + return Err(LinuxLocatorError::Unprepared); + } + Ok(authority) +} + +fn retain_identity( + home: &Path, + lock: &InstallLock, + proposed: LinuxInstallLocation, +) -> Result { + let state = lock.open_public_directory(proposed.state_root())?; + let name = Path::new("installation.json"); + let location = match state.open_regular_file(name) { + Ok(mut file) => { + require_owner(file.metadata(), proposed.uid())?; + let mut bytes = Vec::new(); + file.file_mut() + .take(MAX_INSTALL_JOURNAL_BYTES as u64 + 1) + .read_to_end(&mut bytes)?; + let recorded = LinuxInstallLocation::parse(&bytes, home)?; + if recorded.uid() != proposed.uid() + || recorded.data_root() != proposed.data_root() + || recorded.state_root() != proposed.state_root() + || recorded.release_root() != proposed.release_root() + || recorded.config_root() != proposed.config_root() + { + return Err(LinuxLocatorError::Unprepared); + } + recorded + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let bytes = serde_json::to_vec(&proposed)?; + lock.open_public_directory(proposed.state_root())? + .into_directory_authority()? + .create_regular_file(name, 0o600, bytes.len() as u64, &mut bytes.as_slice())?; + proposed + } + Err(error) => return Err(error.into()), + }; + let identity = state.open_regular_file(name)?; + require_owner(identity.metadata(), location.uid())?; + identity.file().sync_all()?; + lock.open_public_directory(location.state_root())? + .into_directory_authority()? + .sync()?; + state.validate_ancestry()?; + Ok(location) +} + +fn require_owner( + metadata: hypercolor_platform_fs::DirectoryEntryMetadata, + uid: u32, +) -> Result<(), LinuxLocatorError> { + if metadata.owner_uid() != uid + || !metadata.is_owned_by_current_user() + || metadata.mode() & 0o022 != 0 + { + return Err(LinuxLocatorError::Unprepared); + } + Ok(()) +} diff --git a/crates/hypercolor-cli/src/install/linux/directory.rs b/crates/hypercolor-cli/src/install/linux/directory.rs index 2f5d3693f..a7bb70ef1 100644 --- a/crates/hypercolor-cli/src/install/linux/directory.rs +++ b/crates/hypercolor-cli/src/install/linux/directory.rs @@ -29,6 +29,7 @@ impl DirectoryObservation { #[derive(Debug)] pub struct LinuxPublicTree { home: PublicDirectoryAuthority, + home_path: std::path::PathBuf, direct_fragment_path: String, directories: BTreeMap, } @@ -40,11 +41,13 @@ impl LinuxPublicTree { .to_str() .ok_or_else(|| error("Linux HOME must be exact UTF-8"))? .to_owned(); + let home_path = home.to_owned(); let home = lock .open_public_directory(home) .map_err(|source| error(source.to_string()))?; let mut tree = Self { home, + home_path, direct_fragment_path, directories: BTreeMap::new(), }; @@ -58,6 +61,22 @@ impl LinuxPublicTree { Ok(tree) } + pub(super) fn historical_units( + &self, + ) -> Result<(std::path::PathBuf, PublicDirectoryAuthority), InstallPlatformError> { + let relative = Path::new(".local/lib/hypercolor/units"); + let mut authority = self + .home + .open_child_directory(Path::new(".local")) + .map_err(|source| error(source.to_string()))?; + for name in ["lib", "hypercolor", "units"] { + authority = authority + .open_child_directory(Path::new(name)) + .map_err(|source| error(source.to_string()))?; + } + Ok((self.home_path.join(relative), authority)) + } + pub(super) fn direct_fragment_path(&self) -> &str { &self.direct_fragment_path } diff --git a/crates/hypercolor-cli/src/install/linux/election.rs b/crates/hypercolor-cli/src/install/linux/election.rs new file mode 100644 index 000000000..2a65e6b3b --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/election.rs @@ -0,0 +1,200 @@ +//! Select the permanent locator before taking an installation authority lock. + +use std::io::{self, Read as _}; +use std::path::Path; + +use hypercolor_platform_fs::{ExactEntry, PublicDirectoryAuthority, ReadOnlyDirectoryAuthority}; + +use super::{ + InstallLock, InstallStore, LOCATOR_NAME, LinuxInstallAuthority, LinuxInstallLocation, + LinuxInstallLocator, LinuxLocatorError, MAX_INSTALL_JOURNAL_BYTES, MAX_LOCATOR_BYTES, +}; +use crate::install::linux::RetainedLinuxInstallLocation; + +/// The exclusive authority selected after rereading the permanent locator. +#[derive(Debug)] +pub enum LinuxInstallElection { + Legacy { + store: InstallStore, + lock: InstallLock, + locator: LinuxInstallLocator, + }, + Managed { + store: InstallStore, + lock: InstallLock, + authority: LinuxManagedAuthority, + }, +} + +/// Managed authority retains only the elected state lock, never the old lock. +#[derive(Debug)] +pub struct LinuxManagedAuthority { + locator: LinuxInstallLocator, + location: LinuxInstallLocation, + roots: RetainedLinuxInstallLocation, + state: PublicDirectoryAuthority, + identity: ExactEntry, +} + +impl LinuxManagedAuthority { + #[must_use] + pub fn location(&self) -> &LinuxInstallLocation { + &self.location + } + + /// Reconfirm the permanent locator barrier and retained topology. + /// + /// # Errors + /// Refuses changed ancestry, identity, roots or an unsuccessful barrier. + pub fn confirm_durable(&self) -> Result<(), LinuxLocatorError> { + self.roots.validate()?; + let journal = self.state.open_regular_file(Path::new(LOCATOR_NAME))?; + super::preparation::require_file_owner(journal.metadata(), self.location.uid())?; + if self.state.observe_entry(Path::new("installation.json"))? != self.identity { + return Err(LinuxLocatorError::Unprepared); + } + self.locator.confirm_durable(&self.location)?; + self.roots.validate()?; + if self.state.observe_entry(Path::new("installation.json"))? != self.identity { + return Err(LinuxLocatorError::Unprepared); + } + Ok(()) + } +} + +/// Elect legacy or managed authority without acquiring locks in reverse order. +/// +/// A managed hint only selects which existing state lock to attempt. The exact +/// locator, identity file, journal and roots are checked again under that lock. +/// +/// # Errors +/// Refuses malformed locators, contended locks, missing managed preparation, +/// changed authority or failed directory durability. Never falls back from V2. +pub fn elect_linux_installation(home: &Path) -> Result { + elect_with(home, || {}) +} + +pub(super) fn elect_with( + home: &Path, + after_hint: impl FnOnce(), +) -> Result { + let hint = read_hint(home)?; + after_hint(); + if let LinuxInstallAuthority::Managed(location) = hint { + return elect_managed(home, location); + } + let root = home.join(".local/lib/hypercolor"); + let store = InstallStore::new(root, MAX_INSTALL_JOURNAL_BYTES); + let lock = store.acquire_anchored_lock(home)?; + let locator = LinuxInstallLocator::retain(home, &lock)?; + match locator.read()? { + LinuxInstallAuthority::Legacy(_) => Ok(LinuxInstallElection::Legacy { + store, + lock, + locator, + }), + LinuxInstallAuthority::Managed(location) => { + // A prior adopter may publish between the hint and old-lock grant. + // Old then state is the only permitted two-lock acquisition order. + let elected = elect_managed(home, location)?; + drop(locator); + drop(lock); + Ok(elected) + } + } +} + +fn elect_managed( + home: &Path, + location: LinuxInstallLocation, +) -> Result { + let store = InstallStore::with_roots( + location.release_root(), + location.state_root(), + MAX_INSTALL_JOURNAL_BYTES, + )?; + // Split-root acquire_lock opens existing roots; it does not bootstrap them. + let lock = store.acquire_lock()?; + let authority = LinuxManagedAuthority::retain(home, &store, &lock, location)?; + Ok(LinuxInstallElection::Managed { + store, + lock, + authority, + }) +} + +impl LinuxManagedAuthority { + pub(crate) fn retain( + home: &Path, + store: &InstallStore, + lock: &InstallLock, + location: LinuxInstallLocation, + ) -> Result { + if store.root() != location.release_root() + || store.state_root() != location.state_root() + || !lock.guards_roots(location.release_root(), location.state_root()) + { + return Err(LinuxLocatorError::WrongLock); + } + let root = home.join(".local/lib/hypercolor"); + let locator = LinuxInstallLocator { + home: home.to_owned(), + public: lock.open_public_directory(&root)?, + directory: lock + .open_public_directory(&root)? + .into_directory_authority()?, + }; + let roots = location.retain_existing(home, lock)?; + locator.confirm_durable(&location)?; + let state = lock.open_public_directory(location.state_root())?; + let identity_observation = state.observe_entry(Path::new("installation.json"))?; + let mut identity = state.open_regular_file(Path::new("installation.json"))?; + super::preparation::require_file_owner(identity.metadata(), location.uid())?; + let mut bytes = Vec::new(); + identity + .file_mut() + .take(MAX_LOCATOR_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_LOCATOR_BYTES + || LinuxInstallLocation::parse(&bytes, home)? != location + || store.load_journal(lock)?.is_none() + { + return Err(LinuxLocatorError::Unprepared); + } + roots.validate()?; + let authority = LinuxManagedAuthority { + locator, + location, + roots, + state, + identity: identity_observation, + }; + authority.confirm_durable()?; + Ok(authority) + } +} + +fn read_hint(home: &Path) -> Result { + let root = match ReadOnlyDirectoryAuthority::open(&home.join(".local/lib/hypercolor")) { + Ok(root) => root, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(LinuxInstallAuthority::Legacy(None)); + } + Err(error) => return Err(error.into()), + }; + let mut file = match root.open_regular_file(Path::new(LOCATOR_NAME)) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(LinuxInstallAuthority::Legacy(None)); + } + Err(error) => return Err(error.into()), + }; + let mut bytes = Vec::new(); + file.file_mut() + .take(MAX_LOCATOR_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_LOCATOR_BYTES { + return Err(LinuxLocatorError::InvalidLocator); + } + LinuxInstallLocator::decode_at(home, Some(bytes)) +} diff --git a/crates/hypercolor-cli/src/install/linux/executor.rs b/crates/hypercolor-cli/src/install/linux/executor.rs index 8f31607d3..b4cb8bd6f 100644 --- a/crates/hypercolor-cli/src/install/linux/executor.rs +++ b/crates/hypercolor-cli/src/install/linux/executor.rs @@ -41,6 +41,10 @@ pub trait LinuxInstallExecutor { config: &super::model::LinuxInstallConfig, ) -> Result<(), InstallPlatformError>; fn validate_unit_authority(&mut self, unit: &UnitRecord) -> Result<(), InstallPlatformError>; + /// Resolve an explicitly retained prior role; executors deny it by default. + fn prior_units_root(&self, _unit: &UnitRecord) -> Result { + Err(error("executor has no retained prior units authority")) + } fn active_unit(&mut self) -> Result, InstallPlatformError>; fn systemd_show(&mut self, max_bytes: usize) -> Result, InstallPlatformError>; fn launcher_entry( @@ -113,9 +117,10 @@ impl LinuxPublicEntry { #[derive(Debug)] pub struct LinuxNativeExecutor { active: LinuxPublicEntry, - public_tree: LinuxPublicTree, - units: DirectoryAuthority, - units_root_hint: PathBuf, + pub(super) public_tree: LinuxPublicTree, + pub(super) prior_units: Option, + pub(super) units: DirectoryAuthority, + pub(super) units_root_hint: PathBuf, http_address: SocketAddr, systemd_connection: LinuxSystemdConnection, runtime_manager: LinuxRuntimeManager, @@ -155,6 +160,7 @@ impl LinuxNativeExecutor { Ok(Self { active, public_tree, + prior_units: None, units, units_root_hint, http_address, @@ -212,6 +218,10 @@ impl LinuxInstallExecutor for LinuxNativeExecutor { Ok(()) } + fn prior_units_root(&self, unit: &UnitRecord) -> Result { + self.validate_prior_unit(unit) + } + fn active_unit(&mut self) -> Result, InstallPlatformError> { let exact = self .active @@ -590,7 +600,7 @@ fn replace_entry( } } -fn retained_unit( +pub(super) fn retained_unit( units: &DirectoryAuthority, units_root_hint: &Path, unit: UnitId, diff --git a/crates/hypercolor-cli/src/install/linux/executor_tests.rs b/crates/hypercolor-cli/src/install/linux/executor_tests.rs index bd244289f..02d76b0db 100644 --- a/crates/hypercolor-cli/src/install/linux/executor_tests.rs +++ b/crates/hypercolor-cli/src/install/linux/executor_tests.rs @@ -382,3 +382,81 @@ fn public_exact_read_rejects_growth_after_the_metadata_bound() { assert!(error.to_string().contains("changed size")); } + +#[test] +fn historical_prior_authority_rejects_copy_and_replaced_ancestor() { + use super::executor::LinuxInstallExecutor as _; + use crate::install::{UnitId, UnitRecord}; + use hypercolor_platform_fs::ReadOnlyDirectoryAuthority; + + let id = UnitId::new("a".repeat(64)).expect("unit ID"); + with_native_public_tree( + |home| { + fs::create_dir_all(home.join(".local/lib/hypercolor/units").join(id.as_str())) + .expect("historical unit"); + fs::create_dir(home.join("copied-unit")).expect("copied unit"); + }, + |home, executor| { + let path = home.join(".local/lib/hypercolor/units"); + let original = UnitRecord::new( + id.clone(), + home.join("diagnostic-only"), + ReadOnlyDirectoryAuthority::open(&path.join(id.as_str())) + .expect("fixture authority"), + ) + .expect("fixture authority"); + assert!(executor.prior_units_root(&original).is_err()); + executor + .retain_prior_units() + .expect("retain historical authority"); + assert_eq!( + executor + .prior_units_root(&original) + .expect("fixture authority"), + path + ); + assert!(executor.retain_prior_units().is_err()); + let copied = UnitRecord::new( + id.clone(), + path.join(id.as_str()), + ReadOnlyDirectoryAuthority::open(&home.join("copied-unit")) + .expect("fixture authority"), + ) + .expect("fixture authority"); + assert!(executor.prior_units_root(&copied).is_err()); + fs::rename(home.join(".local/lib"), home.join(".local/displaced-lib")) + .expect("fixture authority"); + fs::create_dir(home.join(".local/lib")).expect("fixture authority"); + fs::rename( + home.join(".local/displaced-lib/hypercolor"), + home.join(".local/lib/hypercolor"), + ) + .expect("fixture authority"); + assert!(executor.prior_units_root(&original).is_err()); + }, + ); +} + +#[test] +fn historical_prior_authority_refuses_missing_or_symlinked_root() { + with_native_public_tree( + |_| {}, + |_, executor| { + assert!(executor.retain_prior_units().is_err()); + }, + ); + with_native_public_tree( + |home| { + fs::create_dir_all(home.join(".local/lib/hypercolor")).expect("fixture authority"); + fs::create_dir(home.join("foreign-units")).expect("fixture authority"); + std::os::unix::fs::symlink( + home.join("foreign-units"), + home.join(".local/lib/hypercolor/units"), + ) + .expect("fixture authority"); + }, + |_, executor| { + assert!(executor.retain_prior_units().is_err()); + }, + ); +} diff --git a/crates/hypercolor-cli/src/install/linux/location.rs b/crates/hypercolor-cli/src/install/linux/location.rs new file mode 100644 index 000000000..9ff4d4838 --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/location.rs @@ -0,0 +1,304 @@ +//! Recorded per-user installation topology, independent of ambient XDG changes. + +use std::path::{Component, Path, PathBuf}; + +use hypercolor_platform_fs::PublicDirectoryAuthority; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::super::{InstallLock, InstallStoreError}; + +const LOCATION_SCHEMA: u32 = 2; +const LAUNCHER_CONTRACT: u32 = 1; +const MAX_LOCATION_BYTES: usize = 32 * 1024; + +/// Installer-recorded roots and owner identity for a managed Linux installation. +/// +/// Parsing verifies the topology contract. Filesystem ownership and retained +/// directory relationships must also be validated before using these paths. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LinuxInstallLocation { + schema_version: u32, + kind: LocationKind, + installation_id: Uuid, + uid: u32, + data_root: PathBuf, + state_root: PathBuf, + release_root: PathBuf, + config_root: PathBuf, + service_name: String, + launcher_contract: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum LocationKind { + ManagedLocation, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawLocation { + schema_version: u32, + kind: LocationKind, + installation_id: Uuid, + uid: u32, + data_root: PathBuf, + state_root: PathBuf, + release_root: PathBuf, + config_root: PathBuf, + service_name: String, + launcher_contract: u32, +} + +/// Invalid recorded installation identity or root topology. +#[derive(Debug, thiserror::Error)] +pub enum InstallLocationError { + #[error("installation location exceeds its byte bound")] + TooLarge, + #[error("invalid installation location: {0}")] + Decode(#[from] serde_json::Error), + #[error("unsupported managed installation contract")] + UnsupportedContract, + #[error("installation root must be a bounded normalized absolute path: {}", .0.display())] + InvalidPath(PathBuf), + #[error("installation roots overlap protected or legacy installation state")] + OverlappingRoots, + #[error("installation directory authority could not be retained: {0}")] + Authority(#[from] std::io::Error), + #[error("installation lock cannot authorize the recorded roots: {0}")] + Store(#[from] InstallStoreError), + #[error("installation directory ownership or permissions do not match its recorded owner")] + InvalidOwner, +} + +impl LinuxInstallLocation { + /// Retain existing roots through the caller's exclusive installation lock. + /// + /// Ownership, original ancestry and physical root relationships are checked + /// before returning. This method creates no directories or locator files. + /// + /// # Errors + /// Returns an error for missing, replaced, aliased or foreign-owned roots. + pub fn retain_existing( + &self, + home: &Path, + gate: &InstallLock, + ) -> Result { + self.validate(home)?; + let retained = RetainedLinuxInstallLocation { + uid: self.uid, + data: gate.open_public_directory(&self.data_root)?, + state: gate.open_public_directory(&self.state_root)?, + releases: gate.open_public_directory(&self.release_root)?, + config: gate.open_public_directory(&self.config_root)?, + legacy: gate.open_public_directory(&home.join(".local/lib/hypercolor"))?, + }; + retained.validate()?; + Ok(retained) + } + /// Construct a fresh installation identity from explicit XDG base paths. + /// + /// Callers resolve absent environment values to their normal defaults before + /// calling this method; recorded paths are never recomputed during recovery. + /// + /// # Errors + /// Returns an error for unsafe paths or overlapping installation roots. + pub fn new( + home: &Path, + data_base: &Path, + state_base: &Path, + config_base: &Path, + uid: u32, + ) -> Result { + let data_root = data_base.join("hypercolor"); + let location = Self { + schema_version: LOCATION_SCHEMA, + kind: LocationKind::ManagedLocation, + installation_id: Uuid::new_v4(), + uid, + release_root: data_root.join("releases"), + data_root, + state_root: state_base.join("hypercolor/update"), + config_root: config_base.join("hypercolor"), + service_name: "hypercolor.service".to_owned(), + launcher_contract: LAUNCHER_CONTRACT, + }; + location.validate(home)?; + Ok(location) + } + + /// Decode the fixed legacy-path locator without consulting the environment. + /// + /// # Errors + /// Returns an error for unknown fields/contracts, oversized input or unsafe + /// topology. A caller must never reinterpret a failed V2 parse as legacy V1. + pub fn parse(bytes: &[u8], home: &Path) -> Result { + if bytes.len() > MAX_LOCATION_BYTES { + return Err(InstallLocationError::TooLarge); + } + let raw: RawLocation = serde_json::from_slice(bytes)?; + let location = Self { + schema_version: raw.schema_version, + kind: raw.kind, + installation_id: raw.installation_id, + uid: raw.uid, + data_root: raw.data_root, + state_root: raw.state_root, + release_root: raw.release_root, + config_root: raw.config_root, + service_name: raw.service_name, + launcher_contract: raw.launcher_contract, + }; + location.validate(home)?; + Ok(location) + } + + fn validate(&self, home: &Path) -> Result<(), InstallLocationError> { + if self.schema_version != LOCATION_SCHEMA + || self.launcher_contract != LAUNCHER_CONTRACT + || self.service_name != "hypercolor.service" + || self.installation_id.is_nil() + { + return Err(InstallLocationError::UnsupportedContract); + } + for root in [ + home, + &self.data_root, + &self.state_root, + &self.release_root, + &self.config_root, + ] { + validate_path(root)?; + } + let legacy = home.join(".local/lib/hypercolor"); + if self.release_root != self.data_root.join("releases") + || overlaps(&self.state_root, &self.release_root) + || overlaps(&self.state_root, &legacy) + || overlaps(&self.release_root, &legacy) + || self.config_root.starts_with(&self.release_root) + || self.config_root.starts_with(&self.state_root) + || self.data_root.starts_with(&self.state_root) + { + return Err(InstallLocationError::OverlappingRoots); + } + Ok(()) + } + + /// Stable installation identity, independent of a selected release. + #[must_use] + pub fn installation_id(&self) -> Uuid { + self.installation_id + } + + /// Expected filesystem owner, checked against retained directory metadata. + #[must_use] + pub fn uid(&self) -> u32 { + self.uid + } + + /// Container for mutable application data and the reserved releases subtree. + #[must_use] + pub fn data_root(&self) -> &Path { + &self.data_root + } + + /// Root containing the sole managed transaction journal, lock and staging. + #[must_use] + pub fn state_root(&self) -> &Path { + &self.state_root + } + + /// Root containing immutable units and the active pointer. + #[must_use] + pub fn release_root(&self) -> &Path { + &self.release_root + } + + /// Protected user configuration directory. + #[must_use] + pub fn config_root(&self) -> &Path { + &self.config_root + } +} + +/// Retained roots whose filesystem identities govern a managed installation. +/// +/// The caller keeps this authority alive while preparing the location handoff +/// and revalidates immediately before publishing the locator. +#[derive(Debug)] +pub struct RetainedLinuxInstallLocation { + uid: u32, + data: PublicDirectoryAuthority, + state: PublicDirectoryAuthority, + releases: PublicDirectoryAuthority, + config: PublicDirectoryAuthority, + legacy: PublicDirectoryAuthority, +} + +impl RetainedLinuxInstallLocation { + /// Revalidate ownership, original paths and physical protected-root bounds. + /// + /// # Errors + /// Returns an error when any retained relationship can no longer be proven. + pub fn validate(&self) -> Result<(), InstallLocationError> { + for root in [ + &self.data, + &self.state, + &self.releases, + &self.config, + &self.legacy, + ] { + let metadata = root.metadata()?; + if metadata.owner_uid() != self.uid + || !metadata.is_owned_by_current_user() + || metadata.mode() & 0o700 != 0o700 + || metadata.mode() & 0o022 != 0 + { + return Err(InstallLocationError::InvalidOwner); + } + } + for (left, right) in [ + (&self.state, &self.releases), + (&self.releases, &self.state), + (&self.state, &self.legacy), + (&self.legacy, &self.state), + (&self.releases, &self.legacy), + (&self.legacy, &self.releases), + (&self.config, &self.releases), + (&self.config, &self.state), + (&self.data, &self.state), + ] { + if left.is_within(right)? { + return Err(InstallLocationError::OverlappingRoots); + } + } + if !self.releases.is_within(&self.data)? { + return Err(InstallLocationError::OverlappingRoots); + } + Ok(()) + } +} + +fn overlaps(left: &Path, right: &Path) -> bool { + left.starts_with(right) || right.starts_with(left) +} + +fn validate_path(path: &Path) -> Result<(), InstallLocationError> { + let Some(text) = path.to_str() else { + return Err(InstallLocationError::InvalidPath(path.to_path_buf())); + }; + if !path.is_absolute() + || text.len() > 4096 + || text.contains("//") + || (text.len() > 1 && text.ends_with('/')) + || text.split('/').any(|part| matches!(part, "." | "..")) + || text.bytes().any(|byte| byte < b' ' || byte == 127) + || path + .components() + .any(|component| !matches!(component, Component::RootDir | Component::Normal(_))) + { + return Err(InstallLocationError::InvalidPath(path.to_path_buf())); + } + Ok(()) +} diff --git a/crates/hypercolor-cli/src/install/linux/location_tests.rs b/crates/hypercolor-cli/src/install/linux/location_tests.rs new file mode 100644 index 000000000..634a6e955 --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/location_tests.rs @@ -0,0 +1,201 @@ +use std::path::Path; + +use super::location::{InstallLocationError, LinuxInstallLocation}; + +#[test] +fn retained_location_rejects_changed_ancestry_after_acquisition() { + use crate::install::InstallStore; + use std::fs; + use std::os::unix::fs::MetadataExt as _; + + let fixture = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("fixture"); + let home = fixture.path(); + let location = LinuxInstallLocation::new( + home, + &home.join("data"), + &home.join("state"), + &home.join("config"), + fs::metadata(home).expect("owner").uid(), + ) + .expect("location"); + for root in [ + location.data_root(), + location.state_root(), + location.release_root(), + location.config_root(), + &home.join(".local/lib/hypercolor"), + ] { + fs::create_dir_all(root).expect("prepare root"); + } + let gate = InstallStore::new(home.join(".local/lib/hypercolor"), 65536) + .acquire_anchored_lock(home) + .expect("gate"); + let retained = location.retain_existing(home, &gate).expect("retain"); + retained.validate().expect("valid"); + let state_base = home.join("state"); + fs::rename(&state_base, home.join("displaced")).expect("displace state parent"); + fs::create_dir(&state_base).expect("replacement parent"); + fs::rename( + home.join("displaced/hypercolor"), + state_base.join("hypercolor"), + ) + .expect("preserve state leaf"); + assert!(retained.validate().is_err()); +} + +#[test] +fn recorded_owner_must_match_retained_root_owner() { + use crate::install::InstallStore; + use std::fs; + use std::os::unix::fs::MetadataExt as _; + + let fixture = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("fixture"); + let home = fixture.path(); + let wrong_uid = fs::metadata(home).expect("owner").uid().wrapping_add(1); + let location = LinuxInstallLocation::new( + home, + &home.join("data"), + &home.join("state"), + &home.join("config"), + wrong_uid, + ) + .expect("location"); + for root in [ + location.data_root(), + location.state_root(), + location.release_root(), + location.config_root(), + &home.join(".local/lib/hypercolor"), + ] { + fs::create_dir_all(root).expect("prepare root"); + } + let gate = InstallStore::new(home.join(".local/lib/hypercolor"), 65536) + .acquire_anchored_lock(home) + .expect("gate"); + assert!(matches!( + location.retain_existing(home, &gate), + Err(InstallLocationError::InvalidOwner) + )); +} + +fn standard_location() -> LinuxInstallLocation { + LinuxInstallLocation::new( + Path::new("/home/test"), + Path::new("/home/test/.local/share"), + Path::new("/home/test/.local/state"), + Path::new("/home/test/.config"), + 1000, + ) + .expect("standard location") +} + +#[test] +fn recorded_location_round_trips_without_recomputing_environment() { + let location = standard_location(); + let bytes = serde_json::to_vec(&location).expect("serialize"); + let parsed = LinuxInstallLocation::parse(&bytes, Path::new("/home/test")).expect("parse"); + assert_eq!(location, parsed); + assert!(!parsed.installation_id().is_nil()); + assert_eq!(parsed.uid(), 1000); + assert_eq!( + parsed.data_root(), + Path::new("/home/test/.local/share/hypercolor") + ); + assert_eq!(parsed.release_root(), parsed.data_root().join("releases")); + assert_eq!( + parsed.state_root(), + Path::new("/home/test/.local/state/hypercolor/update") + ); + assert_eq!( + parsed.config_root(), + Path::new("/home/test/.config/hypercolor") + ); +} + +#[test] +fn shared_xdg_base_keeps_state_and_releases_as_siblings() { + let location = LinuxInstallLocation::new( + Path::new("/home/test"), + Path::new("/volume/data"), + Path::new("/volume/data"), + Path::new("/volume/config"), + 1000, + ) + .expect("shared base is allowed"); + assert_eq!( + location.state_root(), + Path::new("/volume/data/hypercolor/update") + ); + assert_eq!( + location.release_root(), + Path::new("/volume/data/hypercolor/releases") + ); +} + +#[test] +fn protected_roots_cannot_be_nested_in_cleanup_owned_roots() { + for (field, path) in [ + ( + "state_root", + "/home/test/.local/share/hypercolor/releases/state", + ), + ("state_root", "/home/test/.local/share/hypercolor"), + ( + "config_root", + "/home/test/.local/share/hypercolor/releases/config", + ), + ( + "config_root", + "/home/test/.local/state/hypercolor/update/config", + ), + ("state_root", "/home/test/.local/lib/hypercolor/state"), + ("state_root", "/home/test/.local/lib"), + ] { + let mut value = serde_json::to_value(standard_location()).expect("value"); + value[field] = path.into(); + let bytes = serde_json::to_vec(&value).expect("serialize"); + assert!( + matches!( + LinuxInstallLocation::parse(&bytes, Path::new("/home/test")), + Err(InstallLocationError::OverlappingRoots) + ), + "{field}={path}" + ); + } +} + +#[test] +fn invalid_contracts_and_paths_do_not_fall_back_to_legacy() { + for (field, replacement) in [ + ("schema_version", serde_json::json!(3)), + ("launcher_contract", serde_json::json!(2)), + ("service_name", serde_json::json!("other.service")), + ( + "installation_id", + serde_json::json!("00000000-0000-0000-0000-000000000000"), + ), + ("kind", serde_json::json!("unknown")), + ("state_root", serde_json::json!("relative/state")), + ("state_root", serde_json::json!("/state/../state")), + ("state_root", serde_json::json!("/state/./update")), + ("state_root", serde_json::json!("/state//update")), + ("state_root", serde_json::json!("/state/update/")), + ("state_root", serde_json::json!("/state\nupdate")), + ("unexpected", serde_json::json!(true)), + ] { + let mut value = serde_json::to_value(standard_location()).expect("value"); + value[field] = replacement; + assert!( + LinuxInstallLocation::parse( + &serde_json::to_vec(&value).expect("serialize"), + Path::new("/home/test") + ) + .is_err(), + "invalid {field}" + ); + } + assert!(matches!( + LinuxInstallLocation::parse(&vec![b' '; 32769], Path::new("/home/test")), + Err(InstallLocationError::TooLarge) + )); +} diff --git a/crates/hypercolor-cli/src/install/linux/locator.rs b/crates/hypercolor-cli/src/install/linux/locator.rs new file mode 100644 index 000000000..b85d9179d --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/locator.rs @@ -0,0 +1,269 @@ +//! Permanent discovery authority at the historical installation journal path. + +use std::io::{self, Read as _}; +use std::path::{Path, PathBuf}; + +use hypercolor_platform_fs::{DirectoryAuthority, ExactEntry, PublicDirectoryAuthority}; +use uuid::Uuid; + +use super::super::{ + INSTALL_JOURNAL_SCHEMA_VERSION, InstallJournalV1, InstallLock, InstallPlatform, + InstallPlatformError, InstallStore, InstallStoreError, MAX_INSTALL_JOURNAL_BYTES, +}; +use super::location::{InstallLocationError, LinuxInstallLocation}; +use super::locator_receipt::{AdoptionPreparation, MAX_PREPARATION_BYTES, RECEIPT_NAME}; + +#[path = "election.rs"] +mod election; +#[path = "locator_preparation.rs"] +mod preparation; +pub use election::{LinuxInstallElection, LinuxManagedAuthority, elect_linux_installation}; + +const LOCATOR_NAME: &str = "install-journal.json"; +const MAX_LOCATOR_BYTES: u64 = MAX_INSTALL_JOURNAL_BYTES as u64; + +#[cfg(test)] +#[path = "locator_tests.rs"] +mod tests; + +/// Authority selected by the fixed historical journal, without fallback. +#[derive(Debug)] +pub enum LinuxInstallAuthority { + Legacy(Option), + Managed(LinuxInstallLocation), +} + +/// Failure to prove the permanent discovery authority. +#[derive(Debug, thiserror::Error)] +pub enum LinuxLocatorError { + #[error("locator requires the exact historical installation lock")] + WrongLock, + #[error("installation locator is oversized, malformed or changed during observation")] + InvalidLocator, + #[error("managed installation preparation is incomplete or has another identity")] + Unprepared, + #[error("managed installation authority cannot be replaced")] + AlreadyManaged, + #[error("installation locator filesystem operation failed: {0}")] + Io(#[from] io::Error), + #[error("installation locator JSON is invalid: {0}")] + Json(#[from] serde_json::Error), + #[error(transparent)] + Location(#[from] InstallLocationError), + #[error(transparent)] + Store(#[from] InstallStoreError), + #[error("legacy platform preparation could not be proven: {0}")] + Platform(#[from] InstallPlatformError), +} + +/// Retained historical directory guarded by its original install lock. +#[derive(Debug)] +pub struct LinuxInstallLocator { + home: PathBuf, + public: PublicDirectoryAuthority, + directory: DirectoryAuthority, +} + +impl LinuxInstallLocator { + /// Retain the permanent locator under the exact historical store lock. + /// + /// # Errors + /// Returns an error for a foreign lock or unsafe directory ancestry. + pub fn retain(home: &Path, lock: &InstallLock) -> Result { + let root = home.join(".local/lib/hypercolor"); + if !lock.guards_roots(&root, &root) { + return Err(LinuxLocatorError::WrongLock); + } + Ok(Self { + home: home.to_path_buf(), + public: lock.open_public_directory(&root)?, + directory: lock + .open_public_directory(&root)? + .into_directory_authority()?, + }) + } + + /// Read the selected authority. Invalid V2 never becomes legacy V1. + /// + /// # Errors + /// Returns an error for unknown schemas, malformed journals or changed files. + pub fn read(&self) -> Result { + let (_, bytes) = self.observe()?; + self.decode(bytes) + } + + fn decode(&self, bytes: Option>) -> Result { + Self::decode_at(&self.home, bytes) + } + + fn decode_at( + home: &Path, + bytes: Option>, + ) -> Result { + let Some(bytes) = bytes else { + return Ok(LinuxInstallAuthority::Legacy(None)); + }; + let value: serde_json::Value = serde_json::from_slice(&bytes)?; + match value + .get("schema_version") + .and_then(serde_json::Value::as_u64) + { + Some(schema) if schema == u64::from(INSTALL_JOURNAL_SCHEMA_VERSION) => { + let journal: InstallJournalV1 = serde_json::from_slice(&bytes)?; + journal + .validate() + .map_err(|_| LinuxLocatorError::InvalidLocator)?; + Ok(LinuxInstallAuthority::Legacy(Some(journal))) + } + Some(2) => Ok(LinuxInstallAuthority::Managed(LinuxInstallLocation::parse( + &bytes, home, + )?)), + _ => Err(LinuxLocatorError::InvalidLocator), + } + } + + /// Publish prepared managed authority without reverting after visibility. + /// + /// The state journal and installation identity must already be durable. + /// Any error leaves activation forbidden; callers reread the locator and + /// retry its directory barrier before proceeding under managed authority. + /// + /// # Errors + /// Returns an error for incomplete preparation, existing managed authority, + /// changed ancestry or failed publication/durability. An error never means + /// that the old authority remains selected. + pub fn publish_prepared( + &self, + location: &LinuxInstallLocation, + state_store: &InstallStore, + state_lock: &InstallLock, + platform: &mut impl InstallPlatform, + ) -> Result<(), LinuxLocatorError> { + self.publish_prepared_with( + location, + state_store, + state_lock, + platform, + DirectoryAuthority::durable_replace_file, + ) + } + + fn publish_prepared_with( + &self, + location: &LinuxInstallLocation, + state_store: &InstallStore, + state_lock: &InstallLock, + platform: &mut impl InstallPlatform, + publish: impl FnOnce(&DirectoryAuthority, &Path, &Path) -> io::Result<()>, + ) -> Result<(), LinuxLocatorError> { + preparation::require_state(location, state_store, state_lock)?; + let journal = state_store + .load_journal(state_lock)? + .ok_or(LinuxLocatorError::Unprepared)?; + preparation::require_initial(&journal)?; + let expected = self.capture_preparation(location, &journal)?; + let roots = location.retain_existing(&self.home, state_lock)?; + let state = state_lock.open_public_directory(location.state_root())?; + let mut receipt = state.open_regular_file(Path::new(RECEIPT_NAME))?; + preparation::require_file_owner(receipt.metadata(), location.uid())?; + let mut receipt_bytes = Vec::new(); + receipt + .file_mut() + .take(MAX_PREPARATION_BYTES + 1) + .read_to_end(&mut receipt_bytes)?; + if receipt_bytes.len() as u64 > MAX_PREPARATION_BYTES + || serde_json::from_slice::(&receipt_bytes)? != expected + { + return Err(LinuxLocatorError::Unprepared); + } + let mut identity = state.open_regular_file(Path::new("installation.json"))?; + preparation::require_file_owner(identity.metadata(), location.uid())?; + let mut bytes = Vec::new(); + identity + .file_mut() + .take(MAX_LOCATOR_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_LOCATOR_BYTES + || LinuxInstallLocation::parse(&bytes, &self.home)? != *location + { + return Err(LinuxLocatorError::Unprepared); + } + preparation::validate_platform(platform, &journal)?; + receipt.file().sync_all()?; + identity.file().sync_all()?; + let journal_file = state.open_regular_file(Path::new(LOCATOR_NAME))?; + preparation::require_file_owner(journal_file.metadata(), location.uid())?; + journal_file.file().sync_all()?; + state_lock + .open_public_directory(location.state_root())? + .into_directory_authority()? + .sync()?; + state.validate_ancestry()?; + if state_store.load_journal(state_lock)?.as_ref() != Some(&journal) { + return Err(LinuxLocatorError::Unprepared); + } + let bytes = serde_json::to_vec(location)?; + let staging = PathBuf::from(format!(".managed-location-{}.tmp", Uuid::new_v4())); + self.directory.create_regular_file( + &staging, + 0o600, + bytes.len() as u64, + &mut bytes.as_slice(), + )?; + roots.validate()?; + if self.capture_preparation(location, &journal)? != expected { + return Err(LinuxLocatorError::InvalidLocator); + } + self.public.validate_ancestry()?; + // This existing primitive deliberately preserves a visible replacement + // when its parent fsync fails. Never use rollback-on-error publication. + publish(&self.directory, &staging, Path::new(LOCATOR_NAME))?; + self.public.validate_ancestry()?; + Ok(()) + } + + /// Retry the locator directory barrier before any managed transitions. + /// + /// # Errors + /// Returns an error unless the exact managed identity is visible and its + /// original directory ancestry and successful fsync are proven. + pub fn confirm_durable( + &self, + expected: &LinuxInstallLocation, + ) -> Result<(), LinuxLocatorError> { + match self.read()? { + LinuxInstallAuthority::Managed(actual) if actual == *expected => {} + _ => return Err(LinuxLocatorError::InvalidLocator), + } + self.public.validate_ancestry()?; + let locator_file = self.public.open_regular_file(Path::new(LOCATOR_NAME))?; + preparation::require_file_owner(locator_file.metadata(), expected.uid())?; + self.directory.sync()?; + self.public.validate_ancestry()?; + match self.read()? { + LinuxInstallAuthority::Managed(actual) if actual == *expected => {} + _ => return Err(LinuxLocatorError::InvalidLocator), + } + Ok(()) + } + + fn observe(&self) -> Result<(ExactEntry, Option>), LinuxLocatorError> { + let name = Path::new(LOCATOR_NAME); + let exact = self.public.observe_entry(name)?; + if exact == ExactEntry::Absent { + return Ok((exact, None)); + } + let mut file = self.public.open_regular_file(name)?; + if file.metadata().size() > MAX_LOCATOR_BYTES { + return Err(LinuxLocatorError::InvalidLocator); + } + let mut bytes = Vec::new(); + file.file_mut() + .take(MAX_LOCATOR_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_LOCATOR_BYTES || self.public.observe_entry(name)? != exact { + return Err(LinuxLocatorError::InvalidLocator); + } + Ok((exact, Some(bytes))) + } +} diff --git a/crates/hypercolor-cli/src/install/linux/locator_preparation.rs b/crates/hypercolor-cli/src/install/linux/locator_preparation.rs new file mode 100644 index 000000000..a4952549b --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/locator_preparation.rs @@ -0,0 +1,215 @@ +use std::io::{self, Read as _}; +use std::path::Path; + +use crate::install::{ + InstallAction, InstallDisposition, InstallJournalV1, InstallLock, InstallPlatform, + InstallStore, PlatformCheckpoint, +}; + +use super::super::locator_receipt::{AdoptionPreparation, MAX_PREPARATION_BYTES, RECEIPT_NAME}; +use super::{LinuxInstallAuthority, LinuxInstallLocation, LinuxInstallLocator, LinuxLocatorError}; + +impl LinuxInstallLocator { + /// Bind exact legacy observations to the intended initial managed journal. + /// + /// The receipt is durable before the caller writes the state journal. A + /// retry may reuse only the identical receipt and unchanged legacy state. + /// Existing state journals without that receipt cannot be adopted. + /// + /// # Errors + /// Returns an error for changed legacy state, an unrelated orphan journal, + /// refused platform proof or failed durable receipt publication. + pub fn prepare_adoption( + &self, + location: &LinuxInstallLocation, + journal: &InstallJournalV1, + state_store: &InstallStore, + state_lock: &InstallLock, + platform: &mut impl InstallPlatform, + ) -> Result<(), LinuxLocatorError> { + require_initial(journal)?; + require_state(location, state_store, state_lock)?; + let expected = self.capture_preparation(location, journal)?; + validate_platform(platform, journal)?; + let state = state_lock.open_public_directory(location.state_root())?; + match state.open_regular_file(Path::new(RECEIPT_NAME)) { + Ok(mut file) => { + require_file_owner(file.metadata(), location.uid())?; + let mut bytes = Vec::new(); + file.file_mut() + .take(MAX_PREPARATION_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_PREPARATION_BYTES + || serde_json::from_slice::(&bytes)? != expected + { + return Err(LinuxLocatorError::Unprepared); + } + if let Some(existing) = state_store.load_journal(state_lock)? + && existing != *journal + { + return Err(LinuxLocatorError::Unprepared); + } + file.file().sync_all()?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + if state_store.load_journal(state_lock)?.is_some() { + return Err(LinuxLocatorError::Unprepared); + } + let bytes = serde_json::to_vec(&expected)?; + if bytes.len() as u64 > MAX_PREPARATION_BYTES { + return Err(LinuxLocatorError::Unprepared); + } + state_lock + .open_public_directory(location.state_root())? + .into_directory_authority()? + .create_regular_file( + Path::new(RECEIPT_NAME), + 0o600, + bytes.len() as u64, + &mut bytes.as_slice(), + )?; + } + Err(error) => return Err(error.into()), + } + state_lock + .open_public_directory(location.state_root())? + .into_directory_authority()? + .sync()?; + state.validate_ancestry()?; + if self.capture_preparation(location, journal)? != expected { + return Err(LinuxLocatorError::Unprepared); + } + Ok(()) + } + + /// Read an exact initial proposal retained before the state journal exists. + /// + /// This does not authorize publication; the live platform proof must still + /// pass prepare_adoption and publish_prepared after restoring its bindings. + /// + /// # Errors + /// Refuses corrupt receipts, inconsistent hashes or changed legacy authority. + pub fn prepared_journal( + &self, + location: &LinuxInstallLocation, + state_store: &InstallStore, + state_lock: &InstallLock, + ) -> Result, LinuxLocatorError> { + require_state(location, state_store, state_lock)?; + let state = state_lock.open_public_directory(location.state_root())?; + let mut file = match state.open_regular_file(Path::new(RECEIPT_NAME)) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + require_file_owner(file.metadata(), location.uid())?; + let mut bytes = Vec::new(); + file.file_mut() + .take(MAX_PREPARATION_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_PREPARATION_BYTES { + return Err(LinuxLocatorError::Unprepared); + } + let receipt: AdoptionPreparation = serde_json::from_slice(&bytes)?; + require_initial(&receipt.initial_journal)?; + if self.capture_preparation(location, &receipt.initial_journal)? != receipt { + return Err(LinuxLocatorError::Unprepared); + } + state.validate_ancestry()?; + Ok(Some(receipt.initial_journal)) + } + + pub(super) fn capture_preparation( + &self, + location: &LinuxInstallLocation, + journal: &InstallJournalV1, + ) -> Result { + let (exact, bytes) = self.observe()?; + match self.decode(bytes)? { + LinuxInstallAuthority::Legacy(old) => { + if old.is_some_and(|old| { + matches!( + old.disposition, + InstallDisposition::Forward | InstallDisposition::Rollback + ) + }) { + return Err(LinuxLocatorError::Unprepared); + } + } + LinuxInstallAuthority::Managed(_) => return Err(LinuxLocatorError::AlreadyManaged), + } + AdoptionPreparation::capture( + location.installation_id(), + journal, + &exact, + &self.public.observe_entry(Path::new("active"))?, + ) + } +} + +pub(super) fn require_initial(journal: &InstallJournalV1) -> Result<(), LinuxLocatorError> { + journal + .validate() + .map_err(|_| LinuxLocatorError::Unprepared)?; + if journal.disposition != InstallDisposition::Forward + || journal.next_action != Some(InstallAction::PreflightCandidate) + || journal.revision != 1 + || journal.layout_operation_index != 0 + { + return Err(LinuxLocatorError::Unprepared); + } + Ok(()) +} + +pub(super) fn require_state( + location: &LinuxInstallLocation, + store: &InstallStore, + lock: &InstallLock, +) -> Result<(), LinuxLocatorError> { + if store.root() != location.release_root() + || store.state_root() != location.state_root() + || !lock.guards_roots(location.release_root(), location.state_root()) + { + return Err(LinuxLocatorError::Unprepared); + } + Ok(()) +} + +pub(super) fn validate_platform( + platform: &mut impl InstallPlatform, + journal: &InstallJournalV1, +) -> Result<(), LinuxLocatorError> { + platform.validate_transaction_plan( + &journal.prior_platform, + &journal.target_platform, + &journal.transition_states, + journal.layout_operation_count, + &journal.platform_record, + )?; + if platform.inspect()? != journal.prior_platform { + return Err(LinuxLocatorError::Unprepared); + } + if !platform.matches_exact_state( + PlatformCheckpoint::PriorOriginal, + &journal.prior_platform, + 0, + &journal.platform_record, + None, + )? { + return Err(LinuxLocatorError::Unprepared); + } + Ok(()) +} + +pub(super) fn require_file_owner( + metadata: hypercolor_platform_fs::DirectoryEntryMetadata, + uid: u32, +) -> Result<(), LinuxLocatorError> { + if metadata.owner_uid() != uid + || !metadata.is_owned_by_current_user() + || metadata.mode() & 0o022 != 0 + { + return Err(LinuxLocatorError::Unprepared); + } + Ok(()) +} diff --git a/crates/hypercolor-cli/src/install/linux/locator_receipt.rs b/crates/hypercolor-cli/src/install/linux/locator_receipt.rs new file mode 100644 index 000000000..ac1c3691c --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/locator_receipt.rs @@ -0,0 +1,99 @@ +use std::path::PathBuf; + +use hypercolor_platform_fs::ExactEntry; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use uuid::Uuid; + +use super::super::InstallJournalV1; +use super::locator::LinuxLocatorError; + +pub(super) const MAX_PREPARATION_BYTES: u64 = (super::super::MAX_INSTALL_JOURNAL_BYTES * 2) as u64; + +pub(super) const RECEIPT_NAME: &str = "adoption-preparation.json"; + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct AdoptionPreparation { + schema_version: u32, + installation_id: Uuid, + journal_sha256: [u8; 32], + pub(super) initial_journal: InstallJournalV1, + legacy_journal: RecordedEntry, + legacy_active: RecordedEntry, +} + +impl AdoptionPreparation { + pub(super) fn capture( + installation_id: Uuid, + journal: &InstallJournalV1, + legacy_journal: &ExactEntry, + legacy_active: &ExactEntry, + ) -> Result { + if !matches!( + legacy_journal, + ExactEntry::Absent | ExactEntry::RegularFile { .. } + ) || !matches!( + legacy_active, + ExactEntry::Absent | ExactEntry::Symlink { .. } + ) { + return Err(LinuxLocatorError::InvalidLocator); + } + Ok(Self { + schema_version: 2, + installation_id, + journal_sha256: Sha256::digest(serde_json::to_vec(journal)?).into(), + initial_journal: journal.clone(), + legacy_journal: RecordedEntry::from(legacy_journal), + legacy_active: RecordedEntry::from(legacy_active), + }) + } +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "kind", rename_all = "snake_case")] +enum RecordedEntry { + Absent, + File { + mode: u32, + size: u64, + sha256: [u8; 32], + device: u64, + inode: u64, + }, + Symlink { + target: PathBuf, + device: u64, + inode: u64, + }, +} + +impl From<&ExactEntry> for RecordedEntry { + fn from(value: &ExactEntry) -> Self { + match value { + ExactEntry::Absent => Self::Absent, + ExactEntry::RegularFile { + mode, + size, + sha256, + device, + inode, + } => Self::File { + mode: *mode, + size: *size, + sha256: *sha256, + device: *device, + inode: *inode, + }, + ExactEntry::Symlink { + target, + device, + inode, + } => Self::Symlink { + target: target.clone(), + device: *device, + inode: *inode, + }, + } + } +} diff --git a/crates/hypercolor-cli/src/install/linux/locator_test_platform.rs b/crates/hypercolor-cli/src/install/linux/locator_test_platform.rs new file mode 100644 index 000000000..858bc966b --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/locator_test_platform.rs @@ -0,0 +1,83 @@ +use crate::install::{ + InstallPlatform, InstallPlatformError, InstallationState, PlatformCheckpoint, + PlatformOwnerReceipt, PlatformState, PlatformTransactionRecord, PlatformTransitionStates, + PreparedPlatformTransaction, UnitId, UnitRecord, +}; + +pub(super) struct PriorProof { + pub(super) matches: bool, +} + +impl PriorProof { + pub(super) fn valid() -> Self { + Self { matches: true } + } +} + +macro_rules! unexpected_mutations { + ($($name:ident($($arg:ident: $ty:ty),*) -> $output:ty;)*) => { + $(fn $name(&mut self, $($arg: $ty),*) -> $output { + panic!(concat!(stringify!($name), " is forbidden during locator preparation")) + })* + }; +} + +impl InstallPlatform for PriorProof { + fn inspect(&mut self) -> Result { + Ok(PlatformState { + layout_unit: None, + launcher_unit: None, + loaded: false, + running_unit: None, + autostart_enabled: false, + }) + } + fn validate_transaction_plan( + &mut self, + _prior: &PlatformState, + _target: &PlatformState, + _transitions: &PlatformTransitionStates, + _count: u16, + _record: &PlatformTransactionRecord, + ) -> Result<(), InstallPlatformError> { + Ok(()) + } + + fn matches_exact_state( + &mut self, + checkpoint: PlatformCheckpoint, + _expected: &PlatformState, + index: u16, + _record: &PlatformTransactionRecord, + receipt: Option<&PlatformOwnerReceipt>, + ) -> Result { + assert_eq!(checkpoint, PlatformCheckpoint::PriorOriginal); + assert_eq!(index, 0); + assert!(receipt.is_none()); + Ok(self.matches) + } + + unexpected_mutations! { + prepare_transaction(_candidate: &UnitRecord, _prior: &InstallationState, + _target: &PlatformState) -> Result; + capture_candidate_owner_receipt(_expected: &PlatformState, + _record: &PlatformTransactionRecord) -> Result; + preflight_authority(_candidate: &UnitId, _prior: &InstallationState, + _record: &PlatformTransactionRecord) -> Result<(), InstallPlatformError>; + wait_for_guard_release(_unloaded: &PlatformState, + _record: &PlatformTransactionRecord) -> Result<(), InstallPlatformError>; + install_launcher(_checkpoint: PlatformCheckpoint, _unit: Option<&UnitId>, + _record: &PlatformTransactionRecord) -> Result<(), InstallPlatformError>; + install_layout_operation(_checkpoint: PlatformCheckpoint, _unit: Option<&UnitId>, + _index: u16, _record: &PlatformTransactionRecord) -> Result<(), InstallPlatformError>; + reload_manager(_expected: &PlatformState, _record: &PlatformTransactionRecord) + -> Result<(), InstallPlatformError>; + restore_autostart(_expected: &PlatformState, _record: &PlatformTransactionRecord) + -> Result<(), InstallPlatformError>; + restore_runtime(_expected: &PlatformState, _record: &PlatformTransactionRecord, + _receipt: Option<&PlatformOwnerReceipt>) -> Result<(), InstallPlatformError>; + wait_for_newer_owner(_checkpoint: PlatformCheckpoint, _expected: &PlatformState, + _record: &PlatformTransactionRecord, _receipt: Option<&PlatformOwnerReceipt>) + -> Result<(), InstallPlatformError>; + } +} diff --git a/crates/hypercolor-cli/src/install/linux/locator_tests.rs b/crates/hypercolor-cli/src/install/linux/locator_tests.rs new file mode 100644 index 000000000..afb977637 --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/locator_tests.rs @@ -0,0 +1,831 @@ +use std::fs; +use std::os::unix::fs::MetadataExt as _; + +use crate::install::{ + InstallJournalV1, InstallLock, InstallStore, InstallTargetPolicy, InstallTransactionId, + PlatformState, PlatformTransactionRecord, PlatformTransitionStates, UnitId, +}; + +use super::{LinuxInstallAuthority, LinuxInstallLocation, LinuxInstallLocator, LinuxLocatorError}; + +#[path = "locator_test_platform.rs"] +mod platform; +use platform::PriorProof; + +struct Fixture { + home: tempfile::TempDir, + old: InstallStore, + old_lock: InstallLock, + locator: LinuxInstallLocator, + location: LinuxInstallLocation, + state: InstallStore, + state_lock: InstallLock, +} + +impl Fixture { + fn new() -> Self { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let old = InstallStore::new(home.path().join(".local/lib/hypercolor"), 65536); + let old_lock = old + .acquire_anchored_lock(home.path()) + .expect("old lock first"); + let locator = LinuxInstallLocator::retain(home.path(), &old_lock).expect("locator"); + let location = LinuxInstallLocation::new( + home.path(), + &home.path().join("data"), + &home.path().join("state"), + &home.path().join("config"), + fs::metadata(home.path()).expect("owner").uid(), + ) + .expect("location"); + let state = InstallStore::with_roots(location.release_root(), location.state_root(), 65536) + .expect("state"); + let state_lock = state + .acquire_anchored_lock(home.path()) + .expect("state lock second"); + fs::create_dir_all(location.config_root()).expect("config root"); + Self { + home, + old, + old_lock, + locator, + location, + state, + state_lock, + } + } + + fn prepare(&self) { + self.locator + .prepare_adoption( + &self.location, + &journal(), + &self.state, + &self.state_lock, + &mut PriorProof::valid(), + ) + .expect("bound preparation receipt"); + self.state + .write_journal(&journal(), &self.state_lock) + .expect("prepared journal"); + fs::write( + self.location.state_root().join("installation.json"), + serde_json::to_vec(&self.location).expect("identity"), + ) + .expect("write identity"); + } +} + +fn journal() -> InstallJournalV1 { + let candidate = UnitId::new("a".repeat(64)).expect("candidate"); + let prior = PlatformState { + layout_unit: None, + launcher_unit: None, + loaded: false, + running_unit: None, + autostart_enabled: false, + }; + let target = PlatformState { + layout_unit: Some(candidate.clone()), + ..prior.clone() + }; + InstallJournalV1::new( + InstallTransactionId::new("test-adoption").expect("transaction"), + None, + candidate, + prior.clone(), + InstallTargetPolicy::Preserve, + PlatformTransitionStates { + prior_unloaded: prior.clone(), + candidate_manager: target.clone(), + candidate_autostart: target, + prior_manager: prior.clone(), + prior_autostart: prior, + }, + 1, + PlatformTransactionRecord::linux(1, b"fixture platform preparation".to_vec()) + .expect("record"), + ) + .expect("journal") +} + +#[test] +fn absent_and_valid_v1_journals_select_legacy() { + let fixture = Fixture::new(); + assert!(matches!( + fixture.locator.read().expect("absent"), + LinuxInstallAuthority::Legacy(None) + )); + fixture + .old + .write_journal(&journal(), &fixture.old_lock) + .expect("legacy journal"); + assert!(matches!( + fixture.locator.read().expect("v1"), + LinuxInstallAuthority::Legacy(Some(_)) + )); +} + +#[test] +fn incomplete_preparation_and_pending_legacy_transaction_refuse_publication() { + let fixture = Fixture::new(); + assert!(matches!( + fixture.locator.publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid() + ), + Err(LinuxLocatorError::Unprepared) + )); + assert!(!fixture.old.journal_path().exists()); + fixture.prepare(); + fixture + .old + .write_journal(&journal(), &fixture.old_lock) + .expect("legacy pending"); + assert!(matches!( + fixture.locator.publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid() + ), + Err(LinuxLocatorError::Unprepared) + )); + assert!(matches!( + fixture.locator.read().expect("legacy intact"), + LinuxInstallAuthority::Legacy(Some(_)) + )); +} + +#[test] +fn prepared_publication_permanently_fences_the_old_journal_decoder() { + let fixture = Fixture::new(); + fixture.prepare(); + fixture + .locator + .publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid(), + ) + .expect("publish"); + assert!(matches!( + fixture.locator.read().expect("managed"), + LinuxInstallAuthority::Managed(_) + )); + assert!(fixture.old.load_journal(&fixture.old_lock).is_err()); + fixture + .locator + .confirm_durable(&fixture.location) + .expect("durability"); + assert!(matches!( + fixture.locator.publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid() + ), + Err(LinuxLocatorError::AlreadyManaged) + )); + assert!( + fixture + .state + .load_journal(&fixture.state_lock) + .expect("prepared retained") + .is_some() + ); +} + +#[test] +fn ambiguous_publication_error_keeps_managed_authority_and_requires_barrier_retry() { + let fixture = Fixture::new(); + fixture.prepare(); + let result = fixture.locator.publish_prepared_with( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid(), + |directory, source, destination| { + directory.durable_replace_file(source, destination)?; + Err(std::io::Error::other( + "injected unknown postvisibility result", + )) + }, + ); + assert!(result.is_err()); + assert!(matches!( + fixture.locator.read().expect("visible managed authority"), + LinuxInstallAuthority::Managed(_) + )); + assert!(fixture.old.load_journal(&fixture.old_lock).is_err()); + fixture + .locator + .confirm_durable(&fixture.location) + .expect("barrier retry"); + let other = LinuxInstallLocation::new( + fixture.home.path(), + &fixture.home.path().join("data"), + &fixture.home.path().join("state"), + &fixture.home.path().join("config"), + fixture.location.uid(), + ) + .expect("different identity"); + assert!(fixture.locator.confirm_durable(&other).is_err()); +} + +#[test] +fn malformed_or_unknown_location_never_falls_back_to_legacy() { + let fixture = Fixture::new(); + for bytes in [ + b"not json".as_slice(), + br#"{"schema_version":2}"#, + br#"{"schema_version":3}"#, + br#"{"schema_version":1}"#, + ] { + fs::write(fixture.old.journal_path(), bytes).expect("malformed"); + assert!(fixture.locator.read().is_err()); + } + assert!(matches!( + LinuxInstallLocator::retain(fixture.home.path(), &fixture.state_lock), + Err(LinuxLocatorError::WrongLock) + )); +} + +#[test] +fn missing_identity_advanced_journal_and_wrong_state_lock_cannot_publish() { + for scenario in 0..3 { + let fixture = Fixture::new(); + fixture.prepare(); + match scenario { + 0 => fs::remove_file(fixture.location.state_root().join("installation.json")) + .expect("remove identity"), + 1 => { + let mut advanced = journal(); + advanced.revision = 2; + fixture + .state + .write_journal(&advanced, &fixture.state_lock) + .expect("advanced journal"); + } + _ => {} + } + let lock = if scenario == 2 { + &fixture.old_lock + } else { + &fixture.state_lock + }; + assert!( + fixture + .locator + .publish_prepared( + &fixture.location, + &fixture.state, + lock, + &mut PriorProof::valid() + ) + .is_err() + ); + assert!(!fixture.old.journal_path().exists()); + } +} + +#[test] +fn orphan_journal_cannot_acquire_a_receipt_after_the_fact() { + let fixture = Fixture::new(); + fixture + .state + .write_journal(&journal(), &fixture.state_lock) + .expect("orphan journal"); + assert!(matches!( + fixture.locator.prepare_adoption( + &fixture.location, + &journal(), + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid() + ), + Err(LinuxLocatorError::Unprepared) + )); + assert!( + !fixture + .location + .state_root() + .join("adoption-preparation.json") + .exists() + ); + assert!(!fixture.old.journal_path().exists()); +} + +#[test] +fn receipt_binds_initial_journal_identity_and_unchanged_legacy_observations() { + for scenario in 0..3 { + let fixture = Fixture::new(); + fixture.prepare(); + match scenario { + 0 => { + let mut unrelated = journal(); + unrelated.transaction_id = + InstallTransactionId::new("unrelated-orphan").expect("id"); + fixture + .state + .write_journal(&unrelated, &fixture.state_lock) + .expect("unrelated initial journal"); + } + 1 => { + let mut settled = journal(); + settled.disposition = crate::install::InstallDisposition::Committed; + settled.next_action = None; + settled.layout_operation_index = settled.layout_operation_count; + fixture + .old + .write_journal(&settled, &fixture.old_lock) + .expect("changed settled journal"); + } + _ => fixture + .old + .set_active( + Some(&UnitId::new("b".repeat(64)).expect("other unit")), + &fixture.old_lock, + ) + .expect("changed active pointer"), + } + assert!(matches!( + fixture.locator.publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid() + ), + Err(LinuxLocatorError::Unprepared) + )); + assert!(matches!( + fixture.locator.read().expect("legacy retained"), + LinuxInstallAuthority::Legacy(_) + )); + } +} + +#[test] +fn current_platform_must_still_match_the_prepared_prior_original() { + let fixture = Fixture::new(); + fixture.prepare(); + assert!(matches!( + fixture.locator.publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof { matches: false } + ), + Err(LinuxLocatorError::Unprepared) + )); + assert!(!fixture.old.journal_path().exists()); +} + +#[test] +fn identical_preparation_reuses_the_durable_receipt_without_replacing_it() { + let fixture = Fixture::new(); + fixture.prepare(); + let path = fixture + .location + .state_root() + .join("adoption-preparation.json"); + let before = fs::metadata(&path).expect("receipt").ino(); + fixture + .locator + .prepare_adoption( + &fixture.location, + &journal(), + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid(), + ) + .expect("exact retry"); + assert_eq!(fs::metadata(path).expect("same receipt").ino(), before); +} + +#[test] +fn receipt_only_restart_retains_the_exact_initial_transaction() { + let fixture = Fixture::new(); + let intended = journal(); + fixture + .locator + .prepare_adoption( + &fixture.location, + &intended, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid(), + ) + .expect("durable receipt before state journal"); + assert!(!fixture.state.journal_path().exists()); + let Fixture { + home, + old, + old_lock, + locator, + location, + state, + state_lock, + } = fixture; + drop(locator); + drop(state_lock); + drop(old_lock); + let old_lock = old.acquire_lock().expect("cold old lock first"); + let locator = LinuxInstallLocator::retain(home.path(), &old_lock).expect("cold locator"); + let state_lock = state.acquire_lock().expect("cold state lock second"); + let recovered = locator + .prepared_journal(&location, &state, &state_lock) + .expect("unchanged receipt") + .expect("original proposal"); + assert_eq!(recovered, intended); + locator + .prepare_adoption( + &location, + &recovered, + &state, + &state_lock, + &mut PriorProof::valid(), + ) + .expect("resume exact proposal"); + state + .write_journal(&recovered, &state_lock) + .expect("finish state journal"); + assert_eq!( + locator + .prepared_journal(&location, &state, &state_lock) + .expect("read after journal") + .expect("proposal"), + intended + ); +} + +#[test] +fn receipt_resume_rejects_changed_body_hash_and_legacy_authority() { + for scenario in 0..4 { + let fixture = Fixture::new(); + fixture.prepare(); + let path = fixture + .location + .state_root() + .join("adoption-preparation.json"); + let mut receipt: serde_json::Value = + serde_json::from_slice(&fs::read(&path).expect("receipt bytes")).expect("receipt JSON"); + match scenario { + 0 => receipt["initial_journal"]["transaction_id"] = "different-transaction".into(), + 1 => { + receipt["journal_sha256"][0] = + (receipt["journal_sha256"][0].as_u64().expect("digest byte") ^ 1).into(); + } + 2 => receipt["schema_version"] = 1.into(), + _ => fixture + .old + .set_active( + Some(&UnitId::new("b".repeat(64)).expect("changed unit")), + &fixture.old_lock, + ) + .expect("changed legacy active"), + } + fs::write( + &path, + serde_json::to_vec(&receipt).expect("changed receipt"), + ) + .expect("write receipt"); + assert!( + fixture + .locator + .prepared_journal(&fixture.location, &fixture.state, &fixture.state_lock,) + .is_err() + ); + assert!(!fixture.old.journal_path().exists()); + } +} + +#[test] +fn writable_preparation_files_cannot_authorize_locator_publication() { + use std::os::unix::fs::PermissionsExt as _; + for name in [ + "installation.json", + "adoption-preparation.json", + "install-journal.json", + ] { + let fixture = Fixture::new(); + fixture.prepare(); + fs::set_permissions( + fixture.location.state_root().join(name), + fs::Permissions::from_mode(0o666), + ) + .expect("make preparation writable"); + assert!(matches!( + fixture.locator.publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid() + ), + Err(LinuxLocatorError::Unprepared) + )); + assert!(!fixture.old.journal_path().exists()); + } +} + +#[test] +fn managed_election_uses_only_state_lock_while_old_lock_is_held() { + let fixture = Fixture::new(); + fixture.prepare(); + fixture + .locator + .publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid(), + ) + .expect("publish"); + drop(fixture.state_lock); + let elected = + super::elect_linux_installation(fixture.home.path()).expect("state-only election"); + let super::LinuxInstallElection::Managed { + store, + lock, + authority, + } = elected + else { + panic!("managed authority") + }; + assert_eq!(store.root(), fixture.location.release_root()); + assert_eq!(authority.location(), &fixture.location); + authority.confirm_durable().expect("durable authority"); + let identity = fixture.location.state_root().join("installation.json"); + let bytes = fs::read(&identity).expect("identity bytes"); + fs::rename( + &identity, + fixture.location.state_root().join("previous-identity.json"), + ) + .expect("retain previous identity inode"); + fs::write(&identity, bytes).expect("replace identical identity"); + assert!( + authority.confirm_durable().is_err(), + "replacement identity is not original authority" + ); + assert!(LinuxInstallLocator::retain(fixture.home.path(), &lock).is_err()); + assert!( + fixture.old.acquire_lock().is_err(), + "old lock remains held independently" + ); +} + +#[test] +fn managed_election_never_bootstraps_missing_state_or_falls_back() { + let fixture = Fixture::new(); + fixture.prepare(); + fixture + .locator + .publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid(), + ) + .expect("publish"); + drop(fixture.state_lock); + let relocated = fixture.home.path().join("displaced-state"); + fs::rename(fixture.location.state_root(), &relocated).expect("displace state"); + assert!(super::elect_linux_installation(fixture.home.path()).is_err()); + assert!(!fixture.location.state_root().exists()); + assert!(matches!( + fixture.locator.read().expect("permanent locator"), + LinuxInstallAuthority::Managed(_) + )); +} + +#[test] +fn managed_election_requires_matching_identity_and_existing_journal() { + let fixture = Fixture::new(); + fixture.prepare(); + fixture + .locator + .publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid(), + ) + .expect("publish"); + drop(fixture.state_lock); + let journal = fixture.location.state_root().join("install-journal.json"); + let bytes = fs::read(&journal).expect("journal"); + fs::remove_file(&journal).expect("remove journal"); + assert!(super::elect_linux_installation(fixture.home.path()).is_err()); + fs::write(&journal, bytes).expect("restore journal"); + fs::write( + fixture.location.state_root().join("installation.json"), + b"{}", + ) + .expect("corrupt identity"); + assert!(super::elect_linux_installation(fixture.home.path()).is_err()); +} + +#[test] +fn legacy_election_holds_the_original_install_lock() { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let elected = super::elect_linux_installation(home.path()).expect("legacy authority"); + let super::LinuxInstallElection::Legacy { store, locator, .. } = elected else { + panic!("legacy authority") + }; + assert!(matches!( + locator.read().expect("legacy journal"), + LinuxInstallAuthority::Legacy(None) + )); + assert!(store.acquire_lock().is_err()); +} + +#[test] +fn adoption_refuses_pending_legacy_before_creating_recorded_roots() { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let elected = super::elect_linux_installation(home.path()).expect("legacy election"); + let super::LinuxInstallElection::Legacy { store, lock, .. } = &elected else { + panic!("legacy authority") + }; + store + .write_journal(&journal(), lock) + .expect("pending legacy transaction"); + let proposed = LinuxInstallLocation::new( + home.path(), + &home.path().join("new-data"), + &home.path().join("new-state"), + &home.path().join("new-config"), + fs::metadata(home.path()).expect("owner").uid(), + ) + .expect("location"); + assert!(matches!( + crate::install::LinuxAdoption::begin(home.path(), elected, proposed), + Err(crate::install::LinuxAdoptionError::LegacyRecoveryRequired) + )); + for name in ["new-data", "new-state", "new-config"] { + assert!(!home.path().join(name).exists()); + } +} + +#[test] +fn adoption_reuses_recorded_identity_and_refuses_orphan_state_journal() { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let propose = || { + LinuxInstallLocation::new( + home.path(), + &home.path().join("data"), + &home.path().join("state"), + &home.path().join("config"), + fs::metadata(home.path()).expect("owner").uid(), + ) + .expect("location") + }; + let adoption = crate::install::LinuxAdoption::begin( + home.path(), + super::elect_linux_installation(home.path()).expect("election"), + propose(), + ) + .expect("begin preparation"); + let original_id = adoption.location().installation_id(); + adoption + .store() + .write_journal(&journal(), adoption.lock()) + .expect("unbound orphan"); + drop(adoption); + let adoption = crate::install::LinuxAdoption::begin( + home.path(), + super::elect_linux_installation(home.path()).expect("cold election"), + propose(), + ) + .expect("retain original identity"); + assert_eq!(adoption.location().installation_id(), original_id); + assert!(adoption.prepared_journal().is_err()); + assert!(matches!( + super::LinuxInstallLocator::retain(home.path(), adoption.lock()), + Err(LinuxLocatorError::WrongLock) + )); +} + +#[test] +fn managed_election_rechecks_the_locator_after_the_unlocked_hint() { + let fixture = Fixture::new(); + fixture.prepare(); + fixture + .locator + .publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid(), + ) + .expect("publish"); + drop(fixture.state_lock); + let result = super::election::elect_with(fixture.home.path(), || { + fs::write( + fixture.old.root().join("install-journal.json"), + br#"{"schema_version":99}"#, + ) + .expect("replace locator after hint"); + }); + assert!(result.is_err()); + assert!( + fixture.state.acquire_lock().is_ok(), + "failed election releases state lock" + ); +} + +#[test] +fn legacy_hint_rechecks_a_concurrently_published_managed_locator() { + let fixture = Fixture::new(); + fixture.prepare(); + let Fixture { + home, + old: _, + old_lock, + locator, + location, + state, + state_lock, + } = fixture; + let expected = location.clone(); + let elected = super::election::elect_with(home.path(), move || { + locator + .publish_prepared(&location, &state, &state_lock, &mut PriorProof::valid()) + .expect("publish after legacy hint"); + drop(locator); + drop(state_lock); + drop(old_lock); + }) + .expect("reread selects managed authority"); + let super::LinuxInstallElection::Managed { authority, .. } = elected else { + panic!("managed authority") + }; + assert_eq!(authority.location(), &expected); +} + +#[test] +fn managed_election_refuses_a_writable_permanent_locator() { + use std::os::unix::fs::PermissionsExt as _; + let fixture = Fixture::new(); + fixture.prepare(); + fixture + .locator + .publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid(), + ) + .expect("publish"); + drop(fixture.state_lock); + fs::set_permissions( + fixture.old.root().join("install-journal.json"), + fs::Permissions::from_mode(0o666), + ) + .expect("make locator writable"); + assert!(super::elect_linux_installation(fixture.home.path()).is_err()); +} + +#[test] +fn managed_election_refuses_writable_state_journal_and_allows_normal_replacement() { + use std::os::unix::fs::PermissionsExt as _; + let fixture = Fixture::new(); + fixture.prepare(); + fixture + .locator + .publish_prepared( + &fixture.location, + &fixture.state, + &fixture.state_lock, + &mut PriorProof::valid(), + ) + .expect("publish"); + drop(fixture.state_lock); + let path = fixture.location.state_root().join("install-journal.json"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).expect("writable journal"); + assert!(super::elect_linux_installation(fixture.home.path()).is_err()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("private journal"); + let elected = super::elect_linux_installation(fixture.home.path()).expect("valid journal"); + let super::LinuxInstallElection::Managed { + store, + lock, + authority, + } = elected + else { + panic!("managed authority") + }; + let journal = store + .load_journal(&lock) + .expect("read journal") + .expect("journal exists"); + store + .write_journal(&journal, &lock) + .expect("normal atomic journal replacement"); + authority + .confirm_durable() + .expect("journal is mutable during recovery"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).expect("late writable journal"); + assert!(authority.confirm_durable().is_err()); +} diff --git a/crates/hypercolor-cli/src/install/linux/mod.rs b/crates/hypercolor-cli/src/install/linux/mod.rs index 314822cdd..daf2900c1 100644 --- a/crates/hypercolor-cli/src/install/linux/mod.rs +++ b/crates/hypercolor-cli/src/install/linux/mod.rs @@ -1,3 +1,5 @@ +mod adoption; +mod adoption_roots; mod directory; mod effects; mod executor; @@ -8,8 +10,14 @@ mod legacy; #[cfg(test)] mod legacy_tests; mod legacy_validation; +mod location; +#[cfg(test)] +mod location_tests; +mod locator; +mod locator_receipt; mod model; mod platform; +mod prior; mod proof; mod record; mod runtime; @@ -21,8 +29,14 @@ use std::collections::BTreeMap; use super::{InstallLock, InstallPlatformError, InstallStore, UnitId, UnitRecord}; +pub use adoption::{LinuxAdoption, LinuxAdoptionError}; pub use directory::LinuxPublicTree; pub use executor::{LinuxInstallExecutor, LinuxNativeExecutor, LinuxPublicEntry}; +pub use location::{InstallLocationError, LinuxInstallLocation, RetainedLinuxInstallLocation}; +pub use locator::{ + LinuxInstallAuthority, LinuxInstallElection, LinuxInstallLocator, LinuxLocatorError, + LinuxManagedAuthority, elect_linux_installation, +}; pub use model::{ LINUX_DIRECTORY_ITEMS, LINUX_LAYOUT_ITEMS, LinuxDirectoryItem, LinuxDirectoryState, LinuxExactEntry, LinuxFilePublication, LinuxHttpResponse, LinuxInstallConfig, LinuxLayoutItem, @@ -84,6 +98,7 @@ pub struct LinuxInstallPlatform { pub(super) executor: E, pub(super) config: LinuxInstallConfig, pub(super) known_units: Vec, + prior_unit: Option, pub(super) last_inspection: Option, pub(super) legacy_unit: Option, } @@ -142,6 +157,7 @@ impl LinuxInstallPlatform { executor, config, known_units: units, + prior_unit: None, last_inspection: None, legacy_unit, }) diff --git a/crates/hypercolor-cli/src/install/linux/platform.rs b/crates/hypercolor-cli/src/install/linux/platform.rs index 8e91e0f15..5e8058bb5 100644 --- a/crates/hypercolor-cli/src/install/linux/platform.rs +++ b/crates/hypercolor-cli/src/install/linux/platform.rs @@ -163,13 +163,7 @@ impl InstallPlatform for LinuxInstallPlatform { } let mut prior_binding = prior_platform_unit .as_ref() - .map(|unit| { - self.known_units - .iter() - .find(|known| known.id() == unit) - .ok_or_else(|| error("loaded direct service lacks a retained synthetic legacy or immutable prior UnitRecord")) - .and_then(|record| self.unit_binding(record)) - }) + .map(|unit| self.prior_unit_binding(unit)) .transpose()?; if prior .platform @@ -556,7 +550,7 @@ impl InstallPlatform for LinuxInstallPlatform { &record, prior, candidate_target, - &self.known_units, + &self.prior_layout_units(), )?; require_exact_entry( ¤t, diff --git a/crates/hypercolor-cli/src/install/linux/prior.rs b/crates/hypercolor-cli/src/install/linux/prior.rs new file mode 100644 index 000000000..76d7b2c84 --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/prior.rs @@ -0,0 +1,242 @@ +use std::path::PathBuf; + +use hypercolor_platform_fs::{DirectoryAuthority, PublicDirectoryAuthority}; + +use super::super::{InstallPlatformError, UnitId, UnitRecord}; +use super::LinuxInstallPlatform; +use super::executor::{LinuxInstallExecutor, LinuxNativeExecutor, retained_unit}; +use super::model::{LinuxUnitBinding, error}; + +pub(super) struct PriorUnitAuthority { + unit: UnitRecord, + units_root: PathBuf, +} + +#[derive(Debug)] +pub(super) struct NativePriorUnits { + ancestry: PublicDirectoryAuthority, + directory: DirectoryAuthority, + path: PathBuf, +} + +impl LinuxNativeExecutor { + /// Retain the historical units directory through the existing HOME authority. + /// + /// No second install lock is acquired. The caller's retained transaction + /// authority must already be the elected authority for the installation. + /// + /// # Errors + /// + /// Refuses missing, replaced, aliased, or already bound historical roots. + pub fn retain_prior_units(&mut self) -> Result<(), InstallPlatformError> { + if self.prior_units.is_some() { + return Err(error("historical units authority is already bound")); + } + let (path, ancestry) = self.public_tree.historical_units()?; + if path == self.units_root_hint { + return Err(error("historical units root is the current units root")); + } + let (_, fresh) = self.public_tree.historical_units()?; + let directory = fresh + .into_directory_authority() + .map_err(|source| error(source.to_string()))?; + let original = ancestry + .metadata() + .map_err(|source| error(source.to_string()))?; + if !original.is_owned_by_current_user() || original.mode() & 0o022 != 0 { + return Err(error( + "historical units root has unsafe ownership or permissions", + )); + } + let retained = directory + .metadata() + .map_err(|source| error(source.to_string()))?; + if (original.device(), original.inode()) != (retained.device(), retained.inode()) { + return Err(error("historical units root changed during retention")); + } + let current = self + .units + .metadata() + .map_err(|source| error(source.to_string()))?; + if (original.device(), original.inode()) == (current.device(), current.inode()) { + return Err(error( + "historical units root aliases the current units authority", + )); + } + self.prior_units = Some(NativePriorUnits { + ancestry, + directory, + path, + }); + Ok(()) + } + + /// Retain a historical prior only when the persisted path selects it. + /// + /// Current-store and synthetic legacy bindings remain with their existing + /// validators. A historical binding must match its original path, inode, + /// digest, size and version before returning any authority. + /// + /// # Errors + /// Refuses unknown records, foreign paths and changed original releases. + pub fn retain_recorded_prior( + &mut self, + encoded: &super::super::PlatformTransactionRecord, + ) -> Result, InstallPlatformError> { + encoded + .validate() + .map_err(|source| error(source.to_string()))?; + let record = super::record::decode_record(encoded)?; + let Some(binding) = record.prior else { + return Ok(None); + }; + if binding.unit.as_str().starts_with("legacy-") { + return Ok(None); + } + let current_path = self + .units_root_hint + .join(binding.unit.as_str()) + .join(super::model::DAEMON_RELATIVE_PATH); + if current_path.to_str() == Some(binding.daemon_path.as_str()) { + return Ok(None); + } + if self.prior_units.is_none() { + self.retain_prior_units()?; + } + let prior = self + .prior_units + .as_ref() + .ok_or_else(|| error("missing prior authority"))?; + let expected_path = prior + .path + .join(binding.unit.as_str()) + .join(super::model::DAEMON_RELATIVE_PATH); + if expected_path.to_str() != Some(binding.daemon_path.as_str()) { + return Err(error( + "recorded prior path does not select an authorized store", + )); + } + let unit = retained_unit(&prior.directory, &prior.path, binding.unit.clone())?; + self.validate_prior_unit(&unit)?; + super::super::payload::validate_installed_release_record(&unit) + .map_err(|source| error(source.to_string()))?; + if super::proof::retained_unit_binding(&unit, &prior.path)? != binding { + return Err(error( + "recorded historical prior changed its executable identity", + )); + } + self.validate_prior_unit(&unit)?; + Ok(Some(unit)) + } + + pub(super) fn validate_prior_unit( + &self, + unit: &UnitRecord, + ) -> Result { + let prior = self + .prior_units + .as_ref() + .ok_or_else(|| error("historical units authority has not been retained"))?; + prior + .ancestry + .validate_ancestry() + .map_err(|source| error(source.to_string()))?; + let retained = retained_unit(&prior.directory, &prior.path, unit.id().clone())?; + prior + .ancestry + .validate_ancestry() + .map_err(|source| error(source.to_string()))?; + if &retained != unit { + return Err(error( + "prior unit does not belong to the retained historical authority", + )); + } + Ok(prior.path.clone()) + } +} + +impl LinuxInstallPlatform { + /// Bind the original prior unit independently of the candidate's store. + /// + /// Identical release digests may name distinct copied inodes. The executor + /// supplies the authoritative prior path; UnitRecord diagnostics never do. + /// + /// # Errors + /// + /// Refuses rebinding, binding after inspection, or an unrecognized authority. + pub fn with_prior_unit(mut self, unit: UnitRecord) -> Result { + if self.prior_unit.is_some() || self.last_inspection.is_some() { + return Err(error( + "prior unit authority must be bound once before inspection", + )); + } + let units_root = self.executor.prior_units_root(&unit)?; + if !units_root.is_absolute() + || units_root.components().any(|part| { + !matches!( + part, + std::path::Component::RootDir | std::path::Component::Normal(_) + ) + }) + { + return Err(error("prior units root must be absolute and normalized")); + } + super::require_systemd_safe_root(&units_root)?; + self.prior_unit = Some(PriorUnitAuthority { unit, units_root }); + Ok(self) + } + + pub(super) fn prior_retained_unit( + &self, + id: &UnitId, + ) -> Result<&UnitRecord, InstallPlatformError> { + if let Some(prior) = &self.prior_unit { + if prior.unit.id() != id + || self.executor.prior_units_root(&prior.unit)? != prior.units_root + { + return Err(error("prior unit role does not match retained authority")); + } + return Ok(&prior.unit); + } + self.known_units + .iter() + .find(|known| known.id() == id) + .ok_or_else(|| error("prior unit lacks retained authority")) + } + + pub(super) fn prior_layout_units(&self) -> Vec { + self.prior_unit + .iter() + .map(|prior| prior.unit.clone()) + .chain(self.known_units.iter().cloned()) + .collect() + } + + pub(super) fn prior_unit_binding( + &self, + id: &UnitId, + ) -> Result { + let unit = self.prior_retained_unit(id)?; + match &self.prior_unit { + Some(prior) => self.unit_binding_at(unit, &prior.units_root), + None => self.unit_binding(unit), + } + } +} + +impl LinuxInstallPlatform { + /// Restore prior-role selection and validate a cold transaction record. + /// + /// # Errors + /// Refuses any prior or candidate record inconsistent with retained authority. + pub fn with_recorded_prior( + mut self, + record: &super::super::PlatformTransactionRecord, + ) -> Result { + if let Some(unit) = self.executor.retain_recorded_prior(record)? { + self = self.with_prior_unit(unit)?; + } + self.validated_record(record)?; + Ok(self) + } +} diff --git a/crates/hypercolor-cli/src/install/linux/proof.rs b/crates/hypercolor-cli/src/install/linux/proof.rs index c1ede9c67..016f4af1e 100644 --- a/crates/hypercolor-cli/src/install/linux/proof.rs +++ b/crates/hypercolor-cli/src/install/linux/proof.rs @@ -18,41 +18,15 @@ impl LinuxInstallPlatform { &self, unit: &UnitRecord, ) -> Result { - let daemon = open_unit_file(unit, DAEMON_RELATIVE_PATH)?; - let daemon_size = daemon.metadata().size(); - let daemon_device = daemon.metadata().device(); - let daemon_inode = daemon.metadata().inode(); - let daemon_sha256 = hash_opened(daemon, daemon_size)?; - let manifest = read_unit_file( - unit, - "manifest.json", - super::model::MAX_MANIFEST_BYTES as u64, - )?; - let manifest: serde_json::Value = serde_json::from_slice(&manifest) - .map_err(|source| error(format!("invalid retained unit manifest: {source}")))?; - let version = manifest - .get("version") - .and_then(serde_json::Value::as_str) - .filter(|version| !version.is_empty() && version.len() <= 128) - .ok_or_else(|| error("retained unit manifest has no bounded version"))? - .to_owned(); - let daemon_path = self - .config - .immutable_units_root - .join(unit.id().as_str()) - .join(DAEMON_RELATIVE_PATH) - .to_str() - .expect("Linux install roots were validated as exact UTF-8") - .to_owned(); - Ok(LinuxUnitBinding { - unit: unit.id().clone(), - daemon_path, - daemon_sha256, - daemon_size, - daemon_device, - daemon_inode, - version, - }) + self.unit_binding_at(unit, &self.config.immutable_units_root) + } + + pub(super) fn unit_binding_at( + &self, + unit: &UnitRecord, + units_root: &Path, + ) -> Result { + retained_unit_binding(unit, units_root) } pub(super) fn candidate_launcher(&self) -> Result { @@ -271,6 +245,45 @@ impl LinuxInstallPlatform { } } +pub(super) fn retained_unit_binding( + unit: &UnitRecord, + units_root: &Path, +) -> Result { + let daemon = open_unit_file(unit, DAEMON_RELATIVE_PATH)?; + let daemon_size = daemon.metadata().size(); + let daemon_device = daemon.metadata().device(); + let daemon_inode = daemon.metadata().inode(); + let daemon_sha256 = hash_opened(daemon, daemon_size)?; + let manifest = read_unit_file( + unit, + "manifest.json", + super::model::MAX_MANIFEST_BYTES as u64, + )?; + let manifest: serde_json::Value = serde_json::from_slice(&manifest) + .map_err(|source| error(format!("invalid retained unit manifest: {source}")))?; + let version = manifest + .get("version") + .and_then(serde_json::Value::as_str) + .filter(|version| !version.is_empty() && version.len() <= 128) + .ok_or_else(|| error("retained unit manifest has no bounded version"))? + .to_owned(); + let daemon_path = units_root + .join(unit.id().as_str()) + .join(DAEMON_RELATIVE_PATH) + .to_str() + .expect("Linux install roots were validated as exact UTF-8") + .to_owned(); + Ok(LinuxUnitBinding { + unit: unit.id().clone(), + daemon_path, + daemon_sha256, + daemon_size, + daemon_device, + daemon_inode, + version, + }) +} + pub(super) fn validate_prior_launcher_entry( entry: &LinuxExactEntry, bytes: &[u8], diff --git a/crates/hypercolor-cli/src/install/linux/validation.rs b/crates/hypercolor-cli/src/install/linux/validation.rs index 5118e5e3c..7abf4ee57 100644 --- a/crates/hypercolor-cli/src/install/linux/validation.rs +++ b/crates/hypercolor-cli/src/install/linux/validation.rs @@ -89,8 +89,11 @@ impl LinuxInstallPlatform { prior: bool, record: &LinuxRecord, ) -> Result<(), InstallPlatformError> { - let unit = self.retained_unit(&binding.unit)?; - let mut expected = self.unit_binding(unit)?; + let mut expected = if prior { + self.prior_unit_binding(&binding.unit)? + } else { + self.unit_binding(self.retained_unit(&binding.unit)?)? + }; if prior && binding.unit.as_str().starts_with("legacy-") { let launcher = require_notify_launcher(&record.prior_launcher_bytes)?; expected.daemon_path = canonical_executable(&launcher)?; @@ -190,7 +193,7 @@ impl LinuxInstallPlatform { "prior regular entry snapshot unit is not the prior binding", )); } - let unit = self.retained_unit(snapshot_unit)?; + let unit = self.prior_retained_unit(snapshot_unit)?; let opened = open_unit_file(unit, snapshot_path)?; if opened.metadata().mode() & 0o7777 != *mode { return Err(error("prior regular entry snapshot mode changed")); diff --git a/crates/hypercolor-cli/src/install/mod.rs b/crates/hypercolor-cli/src/install/mod.rs index 9cf91bf03..650a80a16 100644 --- a/crates/hypercolor-cli/src/install/mod.rs +++ b/crates/hypercolor-cli/src/install/mod.rs @@ -13,12 +13,15 @@ pub use coordinator::{ }; #[cfg(unix)] pub use linux::{ - LINUX_DIRECTORY_ITEMS, LINUX_LAYOUT_ITEMS, LinuxDirectoryItem, LinuxDirectoryState, - LinuxExactEntry, LinuxFilePublication, LinuxHttpResponse, LinuxInstallConfig, - LinuxInstallExecutor, LinuxInstallPlatform, LinuxLayoutItem, LinuxLayoutPublication, - LinuxLegacyFile, LinuxLegacySnapshot, LinuxNativeExecutor, LinuxProcessExecutable, - LinuxPublicEntry, LinuxPublicTree, LinuxSystemdConnection, LinuxSystemdObservation, - bind_linux_retained_unit, parse_systemd_show, retain_linux_unit, + InstallLocationError, LINUX_DIRECTORY_ITEMS, LINUX_LAYOUT_ITEMS, LinuxAdoption, + LinuxAdoptionError, LinuxDirectoryItem, LinuxDirectoryState, LinuxExactEntry, + LinuxFilePublication, LinuxHttpResponse, LinuxInstallAuthority, LinuxInstallConfig, + LinuxInstallElection, LinuxInstallExecutor, LinuxInstallLocation, LinuxInstallLocator, + LinuxInstallPlatform, LinuxLayoutItem, LinuxLayoutPublication, LinuxLegacyFile, + LinuxLegacySnapshot, LinuxLocatorError, LinuxManagedAuthority, LinuxNativeExecutor, + LinuxProcessExecutable, LinuxPublicEntry, LinuxPublicTree, LinuxSystemdConnection, + LinuxSystemdObservation, RetainedLinuxInstallLocation, bind_linux_retained_unit, + elect_linux_installation, parse_systemd_show, retain_linux_unit, }; pub use model::{ INSTALL_JOURNAL_SCHEMA_VERSION, InstallAction, InstallDisposition, InstallJournalV1, @@ -31,9 +34,9 @@ pub use model::{ #[cfg(unix)] pub use payload::{ MAX_RELEASE_MANIFEST_BYTES, MAX_RELEASE_MEMBER_BYTES, MAX_RELEASE_MEMBERS, - MAX_RELEASE_PATH_BYTES, MAX_RELEASE_PAYLOAD_BYTES, ReleasePayloadError, stage_release_payload, - stage_release_payload_from_authority, validate_release_payload, - validate_release_payload_from_authority, + MAX_RELEASE_PATH_BYTES, MAX_RELEASE_PAYLOAD_BYTES, ReleasePayloadError, + copy_installed_release_unit, stage_release_payload, stage_release_payload_from_authority, + validate_release_payload, validate_release_payload_from_authority, }; #[cfg(target_os = "macos")] pub use payload::{MacosReleaseProvenance, bind_macos_release_provenance}; diff --git a/crates/hypercolor-cli/src/install/payload/mod.rs b/crates/hypercolor-cli/src/install/payload/mod.rs index f1bb49c37..ed7471557 100644 --- a/crates/hypercolor-cli/src/install/payload/mod.rs +++ b/crates/hypercolor-cli/src/install/payload/mod.rs @@ -82,7 +82,61 @@ pub fn stage_release_payload_from_authority( expected_unit: &UnitId, ) -> Result { let manifest = validate_release_payload_authority(source, candidate_executable, expected_unit)?; + stage_validated_payload(store, lock, source, manifest, tree::TreeMode::Source) +} + +/// Copy a verified installed release into another store without modifying it. +/// +/// The original retained unit remains read-only. Copying uses release-local +/// staging and no-replace publication, so source and destination may be on +/// different filesystems. A matching existing destination is fully revalidated. +/// +/// # Errors +/// Refuses changed manifests, invalid installed modes, altered members, foreign +/// locks, unsafe destination entries or incomplete durable publication. +pub fn copy_installed_release_unit( + store: &InstallStore, + lock: &InstallLock, + source: &UnitRecord, +) -> Result { + let manifest = validated_installed_manifest(source)?; + stage_validated_payload( + store, + lock, + source.directory(), + manifest, + tree::TreeMode::Installed, + ) +} + +pub(crate) fn validate_installed_release_record( + source: &UnitRecord, +) -> Result<(), ReleasePayloadError> { + validated_installed_manifest(source).map(drop) +} +fn validated_installed_manifest( + source: &UnitRecord, +) -> Result { + let manifest = + ValidatedManifest::parse(tree::read_retained_manifest_bytes(source.directory())?)?; + if manifest.unit_id != *source.id() { + return Err(ReleasePayloadError::UnexpectedManifestDigest { + expected: source.id().as_str().to_owned(), + actual: manifest.unit_id.as_str().to_owned(), + }); + } + tree::validate_copy_source(source.directory(), &manifest, tree::TreeMode::Installed)?; + Ok(manifest) +} + +fn stage_validated_payload( + store: &InstallStore, + lock: &InstallLock, + source: &ReadOnlyDirectoryAuthority, + manifest: ValidatedManifest, + mode: tree::TreeMode, +) -> Result { let units = store.units_authority(lock)?; let unit_name = Path::new(manifest.unit_id.as_str()); if let Some(metadata) = @@ -109,8 +163,8 @@ pub fn stage_release_payload_from_authority( } let staging = create_staging_directory(&units)?; - if let Err(error) = tree::populate_staging(&staging, source, &manifest) - .and_then(|()| tree::validate_source(source, &manifest)) + if let Err(error) = tree::populate_staging(&staging, source, &manifest, mode) + .and_then(|()| tree::validate_copy_source(source, &manifest, mode)) .and_then(|()| tree::finalize_staging(&staging, &manifest)) .and_then(|()| tree::validate_installed(staging.directory(), &manifest)) { diff --git a/crates/hypercolor-cli/src/install/payload/tree.rs b/crates/hypercolor-cli/src/install/payload/tree.rs index eb008c8b4..01713db9c 100644 --- a/crates/hypercolor-cli/src/install/payload/tree.rs +++ b/crates/hypercolor-cli/src/install/payload/tree.rs @@ -32,7 +32,6 @@ pub(super) fn read_installed_manifest_bytes( read_manifest_with_mode(root, MANIFEST_INSTALLED_MODE) } -#[cfg(target_os = "macos")] pub(super) fn read_retained_manifest_bytes( root: &ReadOnlyDirectoryAuthority, ) -> Result, ReleasePayloadError> { @@ -67,6 +66,7 @@ pub(super) fn populate_staging( staging: &PrivateStagingDirectory, source: &ReadOnlyDirectoryAuthority, manifest: &ValidatedManifest, + mode: TreeMode, ) -> Result<(), ReleasePayloadError> { let root = staging.directory(); let manifest_size = u64::try_from(manifest.bytes.len()).map_err(|_| { @@ -121,10 +121,10 @@ pub(super) fn populate_staging( })?; require_file_metadata( opened.metadata(), - *source_mode, + mode.select_mode(*source_mode), *size, path, - TreeMode::Source, + mode, )?; let mut hashing = HashingReader::new(opened.file_mut()); with_directory(root, parent, |directory| { @@ -156,7 +156,7 @@ pub(super) fn populate_staging( })?, opened.metadata(), path, - TreeMode::Source, + mode, )?; } Ok(()) @@ -255,6 +255,14 @@ pub(super) fn validate_source( validate_tree(root, manifest, TreeMode::Source, EnumerationMode::Existing) } +pub(super) fn validate_copy_source( + root: &ReadOnlyDirectoryAuthority, + manifest: &ValidatedManifest, + mode: TreeMode, +) -> Result<(), ReleasePayloadError> { + validate_tree(root, manifest, mode, EnumerationMode::Existing) +} + pub(super) fn validate_installed( root: &DirectoryAuthority, manifest: &ValidatedManifest, @@ -281,7 +289,7 @@ pub(super) fn validate_retained( } #[derive(Debug, Clone, Copy)] -enum TreeMode { +pub(super) enum TreeMode { Source, Installed, } diff --git a/crates/hypercolor-cli/src/install/store.rs b/crates/hypercolor-cli/src/install/store.rs index 70d497e81..8cef165d6 100644 --- a/crates/hypercolor-cli/src/install/store.rs +++ b/crates/hypercolor-cli/src/install/store.rs @@ -20,18 +20,54 @@ static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone)] pub struct InstallStore { root: PathBuf, + state_root: PathBuf, max_journal_bytes: usize, } impl InstallStore { #[must_use] pub fn new(root: impl Into, max_journal_bytes: usize) -> Self { + let root = root.into(); Self { - root: root.into(), + state_root: root.clone(), + root, max_journal_bytes, } } + /// Use separate immutable-release and mutable-journal roots. + /// + /// Roots must be absolute, normalized, distinct and mutually nonnested. + /// Acquisition additionally verifies their modes and directory identity. + /// Existing single-root stores continue to use `new`. + /// + /// # Errors + /// Returns an error for invalid or overlapping roots. + pub fn with_roots( + release_root: impl Into, + state_root: impl Into, + max_journal_bytes: usize, + ) -> Result { + let root = release_root.into(); + let state_root = state_root.into(); + validate_bootstrap_root(&root)?; + validate_bootstrap_root(&state_root)?; + if root.starts_with(&state_root) || state_root.starts_with(&root) { + return Err(InstallStoreError::OverlappingRoots); + } + Ok(Self { + root, + state_root, + max_journal_bytes, + }) + } + + /// Directory containing the transaction lock and durable journal. + #[must_use] + pub fn state_root(&self) -> &Path { + &self.state_root + } + #[must_use] pub fn root(&self) -> &Path { &self.root @@ -39,12 +75,12 @@ impl InstallStore { #[must_use] pub fn journal_path(&self) -> PathBuf { - self.root.join(INSTALL_JOURNAL_FILE) + self.state_root.join(INSTALL_JOURNAL_FILE) } #[must_use] pub fn lock_path(&self) -> PathBuf { - self.root.join(INSTALL_LOCK_FILE) + self.state_root.join(INSTALL_LOCK_FILE) } #[must_use] @@ -59,18 +95,61 @@ impl InstallStore { } pub fn acquire_lock(&self) -> Result { - fs::create_dir_all(&self.root).map_err(InstallStoreError::CreateRoot)?; - let gate = ExclusiveDirectory::try_acquire(&self.root, Path::new(INSTALL_LOCK_FILE)) + if self.root == self.state_root { + fs::create_dir_all(&self.root).map_err(InstallStoreError::CreateRoot)?; + } + let gate = ExclusiveDirectory::try_acquire(&self.state_root, Path::new(INSTALL_LOCK_FILE)) .map_err(InstallStoreError::AcquireLock)? .ok_or(InstallStoreError::LockContended)?; - let directory = gate + self.retain_roots(gate) + } + + fn retain_roots(&self, gate: ExclusiveDirectory) -> Result { + let state_directory = gate .root_directory() .map_err(InstallStoreError::OpenRootAuthority)?; - Ok(InstallLock { + let directory = if self.root == self.state_root { + gate.root_directory() + } else { + gate.open_public_directory(&self.root) + .and_then(PublicDirectoryAuthority::into_directory_authority) + } + .map_err(InstallStoreError::OpenRootAuthority)?; + if self.root != self.state_root { + let state = state_directory + .metadata() + .map_err(InstallStoreError::OpenRootAuthority)?; + let release = directory + .metadata() + .map_err(InstallStoreError::OpenRootAuthority)?; + require_safe_bootstrap_directory(state, &self.state_root)?; + require_safe_bootstrap_directory(release, &self.root)?; + if state.device() == release.device() && state.inode() == release.inode() { + return Err(InstallStoreError::OverlappingRoots); + } + } + let root_anchors = if self.root == self.state_root { + None + } else { + Some(( + gate.open_public_directory(&self.root) + .map_err(InstallStoreError::OpenPublicDirectory)?, + gate.open_public_directory(&self.state_root) + .map_err(InstallStoreError::OpenPublicDirectory)?, + )) + }; + let lock = InstallLock { root: self.root.clone(), + state_root: self.state_root.clone(), gate, directory, - }) + state_directory, + root_anchors, + }; + if self.root != self.state_root { + lock.validate_roots()?; + } + Ok(lock) } /// Acquire one user-scoped install lock before durably bootstrapping the @@ -86,7 +165,16 @@ impl InstallStore { /// lock contention, ancestry drift, unsafe existing components, directory /// creation failure, or failure to retain the final store inode. pub fn acquire_anchored_lock(&self, anchor: &Path) -> Result { + self.acquire_anchored_lock_after_bootstrap(anchor, || {}) + } + + fn acquire_anchored_lock_after_bootstrap( + &self, + anchor: &Path, + after_bootstrap: impl FnOnce(), + ) -> Result { validate_bootstrap_root(&self.root)?; + validate_bootstrap_root(&self.state_root)?; validate_bootstrap_root(anchor)?; let relative = self.root @@ -101,6 +189,12 @@ impl InstallStore { anchor: anchor.to_path_buf(), }); } + let state_relative = self.state_root.strip_prefix(anchor).map_err(|_| { + InstallStoreError::RootOutsideAnchor { + root: self.state_root.clone(), + anchor: anchor.to_path_buf(), + } + })?; let anchor_preflight = ReadOnlyDirectoryAuthority::open(anchor).map_err(InstallStoreError::BootstrapRoot)?; let anchor_metadata = anchor_preflight @@ -119,25 +213,35 @@ impl InstallStore { .map_err(InstallStoreError::OpenRootAuthority)?, )?; let bootstrapped = bootstrap_store_root(&bootstrap, anchor, relative)?; - let gate = ExclusiveDirectory::try_acquire(&self.root, Path::new(INSTALL_LOCK_FILE)) + let state_bootstrapped = bootstrap_store_root(&bootstrap, anchor, state_relative)?; + after_bootstrap(); + bootstrapped + .validate_ancestry() + .map_err(InstallStoreError::BootstrapRoot)?; + state_bootstrapped + .validate_ancestry() + .map_err(InstallStoreError::BootstrapRoot)?; + let gate = ExclusiveDirectory::try_acquire(&self.state_root, Path::new(INSTALL_LOCK_FILE)) .map_err(InstallStoreError::AcquireLock)? .ok_or(InstallStoreError::LockContended)?; - let directory = gate - .root_directory() - .map_err(InstallStoreError::OpenRootAuthority)?; + let lock = self.retain_roots(gate)?; require_same_directory_identity( - directory + lock.directory .metadata() .map_err(InstallStoreError::OpenRootAuthority)?, bootstrapped .metadata() .map_err(InstallStoreError::BootstrapRoot)?, )?; - Ok(InstallLock { - root: self.root.clone(), - gate, - directory, - }) + require_same_directory_identity( + lock.state_directory + .metadata() + .map_err(InstallStoreError::OpenRootAuthority)?, + state_bootstrapped + .metadata() + .map_err(InstallStoreError::BootstrapRoot)?, + )?; + Ok(lock) } pub fn active_unit(&self, lock: &InstallLock) -> Result, InstallStoreError> { @@ -192,7 +296,7 @@ impl InstallStore { &self, lock: &InstallLock, ) -> Result, InstallStoreError> { - let directory = self.authority(lock)?; + let directory = self.state_authority(lock)?; let mut file = match directory.open_file(Path::new(INSTALL_JOURNAL_FILE)) { Ok(file) => file, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), @@ -227,7 +331,7 @@ impl InstallStore { journal: &InstallJournalV1, lock: &InstallLock, ) -> Result<(), InstallStoreError> { - let directory = self.authority(lock)?; + let directory = self.state_authority(lock)?; journal .validate() .map_err(InstallStoreError::InvalidJournal)?; @@ -251,12 +355,23 @@ impl InstallStore { &self, lock: &'a InstallLock, ) -> Result<&'a DirectoryAuthority, InstallStoreError> { - if lock.root != self.root { + if lock.root != self.root || lock.state_root != self.state_root { return Err(InstallStoreError::WrongLock); } + if self.root != self.state_root { + lock.validate_roots()?; + } Ok(&lock.directory) } + fn state_authority<'a>( + &self, + lock: &'a InstallLock, + ) -> Result<&'a DirectoryAuthority, InstallStoreError> { + self.authority(lock)?; + Ok(&lock.state_directory) + } + pub(crate) fn units_authority( &self, lock: &InstallLock, @@ -389,6 +504,7 @@ fn require_safe_bootstrap_directory( path: &Path, ) -> Result<(), InstallStoreError> { if metadata.kind() != DirectoryEntryKind::Directory + || !metadata.is_owned_by_current_user() || metadata.mode() & 0o700 != 0o700 || metadata.mode() & 0o022 != 0 { @@ -416,11 +532,46 @@ fn require_same_directory_identity( #[derive(Debug)] pub struct InstallLock { root: PathBuf, + state_root: PathBuf, + state_directory: DirectoryAuthority, + root_anchors: Option<(PublicDirectoryAuthority, PublicDirectoryAuthority)>, gate: ExclusiveDirectory, directory: DirectoryAuthority, } impl InstallLock { + pub(crate) fn guards_roots(&self, release_root: &Path, state_root: &Path) -> bool { + self.root == release_root && self.state_root == state_root + } + fn validate_roots(&self) -> Result<(), InstallStoreError> { + if let Some((release, state)) = &self.root_anchors { + release + .validate_ancestry() + .map_err(InstallStoreError::OpenPublicDirectory)?; + state + .validate_ancestry() + .map_err(InstallStoreError::OpenPublicDirectory)?; + } + for (path, retained) in [ + (&self.root, &self.directory), + (&self.state_root, &self.state_directory), + ] { + let current = self + .gate + .open_public_directory(path) + .map_err(InstallStoreError::OpenPublicDirectory)?; + let retained_metadata = retained + .metadata() + .map_err(InstallStoreError::OpenRootAuthority)?; + let current_metadata = current + .metadata() + .map_err(InstallStoreError::OpenPublicDirectory)?; + require_same_directory_identity(retained_metadata, current_metadata)?; + require_safe_bootstrap_directory(current_metadata, path)?; + } + Ok(()) + } + /// Open the canonical store root only when it still names this lock's /// retained store inode. /// @@ -431,6 +582,9 @@ impl InstallLock { pub fn open_store_public_directory( &self, ) -> Result { + if self.root != self.state_root { + self.validate_roots()?; + } let public = self .gate .open_public_directory(&self.root) @@ -459,6 +613,9 @@ impl InstallLock { &self, directory: &Path, ) -> Result { + if self.root != self.state_root { + self.validate_roots()?; + } self.gate .open_public_directory(directory) .map_err(InstallStoreError::OpenPublicDirectory) @@ -467,6 +624,8 @@ impl InstallLock { #[derive(Debug, thiserror::Error)] pub enum InstallStoreError { + #[error("release and state roots overlap or alias the same directory")] + OverlappingRoots, #[error("failed to create install state directory: {0}")] CreateRoot(io::Error), #[error("failed to acquire install transaction authority: {0}")] @@ -681,3 +840,7 @@ mod tests { } } } + +#[cfg(test)] +#[path = "store_acquisition_tests.rs"] +mod acquisition_tests; diff --git a/crates/hypercolor-cli/src/install/store_acquisition_tests.rs b/crates/hypercolor-cli/src/install/store_acquisition_tests.rs new file mode 100644 index 000000000..1399ebd9d --- /dev/null +++ b/crates/hypercolor-cli/src/install/store_acquisition_tests.rs @@ -0,0 +1,72 @@ +use std::fs; + +use super::{InstallStore, InstallStoreError}; + +#[test] +fn bootstrap_state_replacement_is_rejected_before_lock_creation() { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let store = InstallStore::with_roots( + home.path().join("releases"), + home.path().join("state"), + 65536, + ) + .expect("roots"); + let result = store.acquire_anchored_lock_after_bootstrap(home.path(), || { + fs::rename(store.state_root(), home.path().join("displaced")).expect("displace state"); + fs::create_dir(store.state_root()).expect("replacement"); + }); + assert!(matches!(result, Err(InstallStoreError::BootstrapRoot(_)))); + assert!(!store.lock_path().exists()); +} + +#[test] +fn bootstrap_ancestor_replacement_cannot_preserve_leaf_authority() { + for replace_state in [false, true] { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let store = InstallStore::with_roots( + home.path().join("data/releases"), + home.path().join("state/update"), + 65536, + ) + .expect("roots"); + let result = store.acquire_anchored_lock_after_bootstrap(home.path(), || { + let root = if replace_state { + store.state_root() + } else { + store.root() + }; + let parent = root.parent().expect("parent"); + let displaced = parent.with_extension("displaced"); + fs::rename(parent, &displaced).expect("displace parent"); + fs::create_dir(parent).expect("replacement parent"); + fs::rename(displaced.join(root.file_name().expect("root name")), root) + .expect("preserve leaf"); + }); + assert!(matches!(result, Err(InstallStoreError::BootstrapRoot(_)))); + assert!(!store.lock_path().exists()); + } +} + +#[test] +fn bootstrap_rejects_a_real_foreign_owner_despite_safe_mode() { + use hypercolor_platform_fs::ReadOnlyDirectoryAuthority; + use std::path::Path; + + let root = ReadOnlyDirectoryAuthority::open(Path::new("/")).expect("root metadata"); + let root_metadata = root.metadata().expect("root metadata"); + let fixture = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("fixture"); + let (metadata, path) = if root_metadata.is_owned_by_current_user() { + // Root-run CI can create a foreign-owned fixture without touching host data. + let owned = fixture.path().join("foreign"); + fs::create_dir(&owned).expect("foreign fixture"); + std::os::unix::fs::chown(&owned, Some(1), None).expect("set fixture owner"); + let authority = ReadOnlyDirectoryAuthority::open(&owned).expect("fixture authority"); + (authority.metadata().expect("fixture metadata"), owned) + } else { + (root_metadata, Path::new("/").to_path_buf()) + }; + assert!(matches!( + super::require_safe_bootstrap_directory(metadata, &path), + Err(InstallStoreError::UnsafeBootstrapDirectory(_)) + )); +} diff --git a/crates/hypercolor-cli/src/install_command.rs b/crates/hypercolor-cli/src/install_command.rs index 929090e94..c086b05f4 100644 --- a/crates/hypercolor-cli/src/install_command.rs +++ b/crates/hypercolor-cli/src/install_command.rs @@ -4,6 +4,8 @@ use anyhow::{Context as _, Result, bail}; use crate::InstallReleaseArgs; +#[cfg(target_os = "linux")] +mod linux; #[cfg(target_os = "macos")] mod macos; @@ -35,22 +37,11 @@ pub(crate) fn parse_manifest_digest(value: &str) -> Result Result<()> { - use std::collections::BTreeSet; - use std::net::{Ipv4Addr, SocketAddr}; - - use crate::install::{ - InstallCoordinator, InstallDisposition, InstallRequest, InstallStore, InstallTargetPolicy, - LinuxInstallConfig, LinuxInstallPlatform, LinuxNativeExecutor, LinuxPublicTree, - MAX_INSTALL_JOURNAL_BYTES, stage_release_payload_from_authority, - validate_release_payload_from_authority, - }; - let home = linux_home()?; require_bounded_absolute(&home, "HOME")?; let topology = LinuxInstallTopology::new(&args.install_prefix, &args.install_dir, &home)?; let (source, candidate_executable) = running_linux_candidate()?; - - validate_release_payload_from_authority( + crate::install::validate_release_payload_from_authority( &source, &candidate_executable, &args.expected_manifest_sha256, @@ -60,144 +51,13 @@ fn execute_linux(args: &InstallReleaseArgs) -> Result<()> { return Ok(()); } - let store = InstallStore::new(&topology.store_root, MAX_INSTALL_JOURNAL_BYTES); - let mut lock = store - .acquire_anchored_lock(&home) - .context("failed to acquire the release install lock")?; - let public_tree = LinuxPublicTree::new(&lock, &home) - .context("failed to retain the Linux public install tree")?; - let candidate = stage_release_payload_from_authority( - &store, - &lock, + linux::execute( + args, + &home, + &topology.store_root, &source, &candidate_executable, - &args.expected_manifest_sha256, ) - .context("release candidate revalidation and staging failed")?; - let journal = store - .load_journal(&lock) - .context("failed to inspect the release install journal")?; - let pending_recovery = journal.as_ref().is_some_and(|journal| { - matches!( - journal.disposition, - InstallDisposition::Forward | InstallDisposition::Rollback - ) - }); - let active_unit = store - .active_unit(&lock) - .context("failed to inspect the active release unit")?; - - let mut known_units = vec![candidate.clone()]; - let mut seen = BTreeSet::from([candidate.id().as_str().to_owned()]); - if let Some(unit) = active_unit { - retain_unit(&store, &lock, unit, &mut seen, &mut known_units)?; - } - if let Some(journal) = journal.as_ref().filter(|_| pending_recovery) { - retain_unit( - &store, - &lock, - journal.candidate_unit.clone(), - &mut seen, - &mut known_units, - )?; - if let Some(unit) = journal.prior_active_unit.clone() { - retain_unit(&store, &lock, unit, &mut seen, &mut known_units)?; - } - retain_platform_units( - &store, - &lock, - &journal.prior_platform, - &mut seen, - &mut known_units, - )?; - retain_platform_units( - &store, - &lock, - &journal.target_platform, - &mut seen, - &mut known_units, - )?; - } - - let config = LinuxInstallConfig { - direct_fragment_path: home - .join(".config/systemd/user/hypercolor.service") - .to_str() - .expect("HOME was validated as exact UTF-8") - .to_owned(), - immutable_units_root: topology.store_root.join("units"), - active_root: topology.store_root.join("active"), - }; - let executor = LinuxNativeExecutor::new( - &store, - &lock, - public_tree, - SocketAddr::from((Ipv4Addr::LOCALHOST, 9420)), - ) - .context("failed to construct the native Linux install executor")?; - let mut platform = LinuxInstallPlatform::new(executor, config, known_units) - .context("failed to bind the Linux install transaction")?; - let mut coordinator = InstallCoordinator::new(&store, &mut platform); - - if pending_recovery { - let outcome = coordinator - .recover_with_lock(&mut lock) - .context("failed to recover the interrupted release transaction")? - .ok_or_else(|| anyhow::anyhow!("the interrupted release journal disappeared"))?; - return require_candidate_committed(outcome, &args.expected_manifest_sha256, true); - } - - let request = InstallRequest { - transaction_id: transaction_id(&args.expected_manifest_sha256)?, - candidate, - target_policy: if args.no_service { - InstallTargetPolicy::Preserve - } else { - InstallTargetPolicy::EnableOnFirstInstall - }, - }; - let outcome = coordinator - .install_with_lock(request, &mut lock) - .context("transactional Linux release installation failed")?; - require_candidate_committed(outcome, &args.expected_manifest_sha256, false) -} - -#[cfg(target_os = "linux")] -fn retain_platform_units( - store: &crate::install::InstallStore, - lock: &crate::install::InstallLock, - state: &crate::install::PlatformState, - seen: &mut std::collections::BTreeSet, - known_units: &mut Vec, -) -> Result<()> { - for unit in [ - state.layout_unit.clone(), - state.launcher_unit.clone(), - state.running_unit.clone(), - ] - .into_iter() - .flatten() - { - retain_unit(store, lock, unit, seen, known_units)?; - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn retain_unit( - store: &crate::install::InstallStore, - lock: &crate::install::InstallLock, - unit: crate::install::UnitId, - seen: &mut std::collections::BTreeSet, - known_units: &mut Vec, -) -> Result<()> { - if seen.insert(unit.as_str().to_owned()) { - known_units.push( - crate::install::retain_linux_unit(store, lock, &unit) - .with_context(|| format!("failed to retain installed unit {}", unit.as_str()))?, - ); - } - Ok(()) } fn require_candidate_committed( diff --git a/crates/hypercolor-cli/src/install_command/linux.rs b/crates/hypercolor-cli/src/install_command/linux.rs new file mode 100644 index 000000000..c4282fae9 --- /dev/null +++ b/crates/hypercolor-cli/src/install_command/linux.rs @@ -0,0 +1,284 @@ +use std::collections::BTreeSet; +use std::fs::File; +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::Path; + +use anyhow::{Context as _, Result, bail}; +use hypercolor_platform_fs::ReadOnlyDirectoryAuthority; + +use crate::InstallReleaseArgs; +use crate::install::{ + InstallCoordinator, InstallDisposition, InstallJournalV1, InstallLock, InstallRequest, + InstallStore, InstallTargetPolicy, LinuxAdoption, LinuxInstallConfig, LinuxInstallElection, + LinuxInstallLocation, LinuxInstallPlatform, LinuxNativeExecutor, LinuxPublicTree, UnitRecord, + elect_linux_installation, retain_linux_unit, stage_release_payload_from_authority, +}; + +pub(super) fn execute( + args: &InstallReleaseArgs, + home: &Path, + legacy_root: &Path, + source: &ReadOnlyDirectoryAuthority, + executable: &File, +) -> Result<()> { + let elected = elect_linux_installation(home).context("failed to elect install authority")?; + match elected { + LinuxInstallElection::Legacy { + store, + mut lock, + locator, + } => { + if store.root() != legacy_root { + bail!("legacy authority disagrees with the install prefix"); + } + let journal = store.load_journal(&lock)?; + if let Some(journal) = journal.as_ref().filter(|journal| pending(journal)) { + let mut platform = platform(home, &store, &lock, None, Some(journal), false, None)?; + return recover(args, &store, &mut lock, &mut platform); + } + let uid = lock.open_public_directory(home)?.metadata()?.owner_uid(); + let proposed = proposed_location(home, uid)?; + let adoption = LinuxAdoption::begin( + home, + LinuxInstallElection::Legacy { + store, + lock, + locator, + }, + proposed, + )?; + let candidate = stage(args, adoption.store(), adoption.lock(), source, executable)?; + let prepared = adoption.prepared_journal()?; + let mut platform = platform( + home, + adoption.store(), + adoption.lock(), + Some(&candidate), + prepared.as_ref(), + true, + adoption.original_prior(), + )?; + let journal = adoption.prepare(&mut platform, request(args, candidate)?)?; + let LinuxInstallElection::Managed { + store, + mut lock, + authority, + } = adoption.publish(&journal, &mut platform)? + else { + bail!("adoption did not elect managed authority"); + }; + authority.confirm_durable()?; + recover(args, &store, &mut lock, &mut platform) + } + LinuxInstallElection::Managed { + store, + mut lock, + authority, + } => { + let journal = store + .load_journal(&lock)? + .ok_or_else(|| anyhow::anyhow!("managed journal disappeared"))?; + authority.confirm_durable()?; + if pending(&journal) { + let mut platform = platform(home, &store, &lock, None, Some(&journal), true, None)?; + return recover(args, &store, &mut lock, &mut platform); + } + let candidate = stage(args, &store, &lock, source, executable)?; + let prior_record = + (journal.disposition == InstallDisposition::RolledBack).then_some(&journal); + let mut platform = platform( + home, + &store, + &lock, + Some(&candidate), + prior_record, + true, + None, + )?; + authority.confirm_durable()?; + let outcome = InstallCoordinator::new(&store, &mut platform) + .install_with_lock(request(args, candidate)?, &mut lock)?; + super::require_candidate_committed(outcome, &args.expected_manifest_sha256, false) + } + } +} + +fn pending(journal: &InstallJournalV1) -> bool { + matches!( + journal.disposition, + InstallDisposition::Forward | InstallDisposition::Rollback + ) +} + +fn stage( + args: &InstallReleaseArgs, + store: &InstallStore, + lock: &InstallLock, + source: &ReadOnlyDirectoryAuthority, + executable: &File, +) -> Result { + stage_release_payload_from_authority( + store, + lock, + source, + executable, + &args.expected_manifest_sha256, + ) + .context("release revalidation and staging failed") +} + +fn request(args: &InstallReleaseArgs, candidate: UnitRecord) -> Result { + Ok(InstallRequest { + transaction_id: super::transaction_id(&args.expected_manifest_sha256)?, + candidate, + target_policy: if args.no_service { + InstallTargetPolicy::Preserve + } else { + InstallTargetPolicy::EnableOnFirstInstall + }, + }) +} + +fn recover( + args: &InstallReleaseArgs, + store: &InstallStore, + lock: &mut InstallLock, + platform: &mut LinuxInstallPlatform, +) -> Result<()> { + let outcome = InstallCoordinator::new(store, platform) + .recover_with_lock(lock)? + .ok_or_else(|| anyhow::anyhow!("interrupted journal disappeared"))?; + super::require_candidate_committed(outcome, &args.expected_manifest_sha256, true) +} + +fn platform( + home: &Path, + store: &InstallStore, + lock: &InstallLock, + candidate: Option<&UnitRecord>, + journal: Option<&InstallJournalV1>, + managed: bool, + original: Option<&UnitRecord>, +) -> Result> { + let known = known_units(store, lock, candidate, journal)?; + let tree = LinuxPublicTree::new(lock, home)?; + let executor = LinuxNativeExecutor::new( + store, + lock, + tree, + SocketAddr::from((Ipv4Addr::LOCALHOST, 9420)), + )?; + bind_platform( + home, + store, + known, + executor, + journal + .filter(|_| managed) + .map(|journal| &journal.platform_record), + original, + ) +} + +fn bind_platform( + home: &Path, + store: &InstallStore, + known: Vec, + mut executor: LinuxNativeExecutor, + record: Option<&crate::install::PlatformTransactionRecord>, + original: Option<&UnitRecord>, +) -> Result> { + if original.is_some() && record.is_none() { + executor.retain_prior_units()?; + } + let config = LinuxInstallConfig { + direct_fragment_path: home + .join(".config/systemd/user/hypercolor.service") + .to_str() + .expect("validated HOME") + .to_owned(), + immutable_units_root: store.root().join("units"), + active_root: store.active_path(), + }; + let mut platform = LinuxInstallPlatform::new(executor, config, known)?; + if let Some(original) = original.filter(|_| record.is_none()) { + platform = platform.with_prior_unit(original.clone())?; + } + match record { + Some(record) => platform + .with_recorded_prior(record) + .context("failed to restore recorded prior authority"), + None => Ok(platform), + } +} + +fn known_units( + store: &InstallStore, + lock: &InstallLock, + candidate: Option<&UnitRecord>, + journal: Option<&InstallJournalV1>, +) -> Result> { + let mut units: Vec<_> = candidate.into_iter().cloned().collect(); + let mut seen: BTreeSet<_> = units + .iter() + .map(|unit| unit.id().as_str().to_owned()) + .collect(); + let mut ids: Vec<_> = store.active_unit(lock)?.into_iter().collect(); + if let Some(journal) = journal { + ids.push(journal.candidate_unit.clone()); + ids.extend(journal.prior_active_unit.clone()); + for state in [&journal.prior_platform, &journal.target_platform] { + ids.extend( + [ + state.layout_unit.clone(), + state.launcher_unit.clone(), + state.running_unit.clone(), + ] + .into_iter() + .flatten(), + ); + } + } + for id in ids { + if seen.insert(id.as_str().to_owned()) { + units.push( + retain_linux_unit(store, lock, &id) + .with_context(|| format!("failed to retain installed unit {}", id.as_str()))?, + ); + } + } + Ok(units) +} + +fn proposed_location(home: &Path, uid: u32) -> Result { + proposed_location_with(home, uid, |name| std::env::var_os(name)) +} + +fn proposed_location_with( + home: &Path, + uid: u32, + lookup: impl Fn(&str) -> Option, +) -> Result { + let base = |name, fallback: &str| { + lookup(name) + .filter(|value| !value.is_empty()) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| home.join(fallback)) + }; + LinuxInstallLocation::new( + home, + &base("XDG_DATA_HOME", ".local/share"), + &base("XDG_STATE_HOME", ".local/state"), + &base("XDG_CONFIG_HOME", ".config"), + uid, + ) + .context("invalid managed XDG topology") +} + +#[cfg(test)] +#[path = "linux_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "linux_binding_tests.rs"] +mod binding_tests; diff --git a/crates/hypercolor-cli/src/install_command/linux_binding_tests.rs b/crates/hypercolor-cli/src/install_command/linux_binding_tests.rs new file mode 100644 index 000000000..bd1ba1a38 --- /dev/null +++ b/crates/hypercolor-cli/src/install_command/linux_binding_tests.rs @@ -0,0 +1,219 @@ +use std::fs::{self, File}; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; +use std::os::unix::net::UnixListener; +use std::path::Path; + +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::*; +use crate::install::{ + LINUX_DIRECTORY_ITEMS, LINUX_LAYOUT_ITEMS, LinuxDirectoryState, LinuxSystemdConnection, + PlatformTransactionRecord, UnitId, copy_installed_release_unit, stage_release_payload, +}; + +#[test] +fn native_command_constructor_binds_initial_and_recorded_prior_exactly_once() { + let home = + tempfile::tempdir_in(std::env::var_os("HOME").expect("owned home")).expect("isolated home"); + let old = InstallStore::new(home.path().join(".local/lib/hypercolor"), 65536); + let old_lock = old.acquire_anchored_lock(home.path()).expect("old lock"); + let source = home.path().join("source"); + fs::create_dir(&source).expect("source"); + let executable = write_release(&source); + let id = UnitId::new(digest( + &fs::read(source.join("manifest.json")).expect("manifest"), + )) + .expect("id"); + let original = stage_release_payload(&old, &old_lock, &source, &executable, &id) + .expect("original release"); + let store = InstallStore::with_roots( + home.path().join("release"), + home.path().join("state"), + 65536, + ) + .expect("split store"); + let lock = store + .acquire_anchored_lock(home.path()) + .expect("state lock"); + let copied = copy_installed_release_unit(&store, &lock, &original).expect("copied release"); + assert_eq!(original.id(), copied.id()); + assert_ne!(original, copied); + let runtime = home.path().join("runtime"); + fs::create_dir(&runtime).expect("runtime"); + fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700)).expect("private runtime"); + let _bus = UnixListener::bind(runtime.join("bus")).expect("isolated bus endpoint"); + let connection = LinuxSystemdConnection::from_runtime_directory( + &runtime, + fs::metadata(&runtime).expect("owner").uid(), + ) + .expect("connection authority"); + let record = record(&store, &copied, &old, &original); + // Receipt-only and journal-backed resumes feed the same exact durable record. + for durable_record in [None, Some(&record), Some(&record)] { + let tree = LinuxPublicTree::new(&lock, home.path()).expect("public tree"); + let executor = LinuxNativeExecutor::new_with_connection( + &store, + &lock, + tree, + "127.0.0.1:9420".parse().expect("loopback"), + connection.clone(), + ) + .expect("native executor"); + let platform = bind_platform( + home.path(), + &store, + vec![copied.clone()], + executor, + durable_record, + Some(&original), + ) + .expect("single native prior binding"); + drop(platform); + } + writable_directories(home.path()); +} + +fn record( + store: &InstallStore, + copied: &UnitRecord, + old: &InstallStore, + original: &UnitRecord, +) -> PlatformTransactionRecord { + let paths = [ + "bin/hypercolor", + "bin/hypercolor-daemon", + "bin/hypercolor-app", + "bin/hypercolor-tui", + "bin/hypercolor-open", + "share/applications/hypercolor.desktop", + "share/bash-completion/completions/hypercolor", + "share/zsh/site-functions/_hypercolor", + "share/fish/vendor_completions.d/hypercolor.fish", + "share/icons/hicolor/48x48/apps/hypercolor.png", + "share/icons/hicolor/128x128/apps/hypercolor.png", + "share/icons/hicolor/256x256/apps/hypercolor.png", + ]; + let directories: std::collections::BTreeMap<_, _> = LINUX_DIRECTORY_ITEMS + .into_iter() + .map(|item| (item, LinuxDirectoryState::Present)) + .collect(); + let layout: Vec<_> = LINUX_LAYOUT_ITEMS + .into_iter() + .zip(paths) + .map(|(item, path)| { + json!({"effect":{"kind":"entry", "item":item,"prior":{"kind":"absent"}, + "candidate_target":store.active_path().join(path)}}) + }) + .collect(); + PlatformTransactionRecord::linux( + 1, + serde_json::to_vec(&json!({ + "candidate":binding(copied,store), "prior":binding(original,old), + "baseline_systemd":{"load_state":"not-found","active_state":"inactive", + "sub_state":"dead","unit_file_state":"","fragment_path":"", + "exec_start":"","main_pid":0,"invocation_id":""}, + "prior_launcher":{"kind":"absent"},"prior_launcher_bytes":[], + "candidate_launcher":null,"prior_directories":directories,"layout":layout, + "first_conversion":false + })) + .expect("complete record"), + ) + .expect("record bound") +} + +fn binding(unit: &UnitRecord, store: &InstallStore) -> Value { + let path = store.unit_path(unit.id()).join("bin/hypercolor-daemon"); + let metadata = fs::metadata(&path).expect("daemon"); + json!({"unit":unit.id(),"daemon_path":path,"daemon_sha256":digest(b"daemon"), + "daemon_size":metadata.len(),"daemon_device":metadata.dev(), + "daemon_inode":metadata.ino(),"version":"9.8.7"}) +} + +fn digest(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn writable_directories(path: &Path) { + if !fs::symlink_metadata(path).expect("metadata").is_dir() { + return; + } + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("cleanup mode"); + for entry in fs::read_dir(path).expect("directory") { + writable_directories(&entry.expect("entry").path()); + } +} + +fn write_release(root: &Path) -> File { + let version = "9.8.7"; + let daemon = b"daemon".as_slice(); + let directories = [ + "bin", + "share", + "share/hypercolor", + "share/hypercolor/ui", + "share/hypercolor/effects", + "share/hypercolor/effects/bundled", + "share/hypercolor/docs", + "share/hypercolor/agents", + "share/hypercolor/agents/skills", + "share/hypercolor/agents/agents", + "share/hypercolor/skills", + "share/hypercolor/site", + ]; + let files = [ + ("bin/hypercolor-daemon", daemon), + ("bin/hypercolor", b"candidate".as_slice()), + ("bin/hypercolor-app", b"app".as_slice()), + ("bin/hypercolor-tui", b"tui".as_slice()), + ("bin/hypercolor-open", b"open".as_slice()), + ("share/hypercolor/ui/index.html", b"ui".as_slice()), + ( + "share/hypercolor/effects/bundled/effect.html", + b"effect".as_slice(), + ), + ( + "share/hypercolor/agents/skills/skill.md", + b"skill".as_slice(), + ), + ( + "share/hypercolor/agents/agents/agent.md", + b"agent".as_slice(), + ), + ("share/hypercolor/skills/skill.md", b"user skill".as_slice()), + ]; + let mut members = Vec::new(); + for directory in directories { + fs::create_dir_all(root.join(directory)).expect("directory"); + fs::set_permissions(root.join(directory), fs::Permissions::from_mode(0o755)).expect("mode"); + members.push(json!({"path":directory,"type":"directory","mode":0o755})); + } + for (path, bytes) in files { + fs::write(root.join(path), bytes).expect("file"); + let mode = if path.starts_with("bin/") { + 0o755 + } else { + 0o644 + }; + fs::set_permissions(root.join(path), fs::Permissions::from_mode(mode)).expect("mode"); + members.push(json!({ + "path":path,"type":"file","mode":mode,"size":bytes.len(),"sha256":digest(bytes) + })); + } + members.sort_by(|left, right| left["path"].as_str().cmp(&right["path"].as_str())); + let manifest = serde_json::to_vec_pretty(&json!({ + "name":"hypercolor","version":version,"platform":"linux-x86_64", + "rust_target":"x86_64-unknown-linux-gnu", + "binaries":["hypercolor-daemon","hypercolor","hypercolor-app","hypercolor-tui","hypercolor-open"], + "assets":{"ui_files":1,"bundled_effect_files":1,"docs_files":0,"skill_files":1,"user_skill_files":1,"agent_files":1,"site_files":0}, + "members":members, + })) + .expect("manifest JSON"); + fs::write(root.join("manifest.json"), manifest).expect("manifest"); + fs::set_permissions( + root.join("manifest.json"), + fs::Permissions::from_mode(0o644), + ) + .expect("manifest mode"); + File::open(root.join("bin/hypercolor")).expect("candidate") +} diff --git a/crates/hypercolor-cli/src/install_command/linux_tests.rs b/crates/hypercolor-cli/src/install_command/linux_tests.rs new file mode 100644 index 000000000..1390cacb1 --- /dev/null +++ b/crates/hypercolor-cli/src/install_command/linux_tests.rs @@ -0,0 +1,77 @@ +use std::fs; +use std::path::Path; + +use super::*; + +#[test] +fn proposed_xdg_roots_are_explicit_and_reject_relative_or_overlapping_roots() { + let home = Path::new("/home/test"); + let defaults = proposed_location_with(home, 1000, |_| None).expect("default topology"); + assert_eq!( + defaults.release_root(), + home.join(".local/share/hypercolor/releases") + ); + assert_eq!( + defaults.state_root(), + home.join(".local/state/hypercolor/update") + ); + assert_eq!(defaults.config_root(), home.join(".config/hypercolor")); + let custom = proposed_location_with(home, 1000, |name| { + Some( + match name { + "XDG_DATA_HOME" => "/owned/data", + "XDG_STATE_HOME" => "/other/state", + "XDG_CONFIG_HOME" => "/owned/config", + _ => unreachable!(), + } + .into(), + ) + }) + .expect("separate roots"); + assert_eq!( + custom.release_root(), + Path::new("/owned/data/hypercolor/releases") + ); + assert_eq!( + custom.state_root(), + Path::new("/other/state/hypercolor/update") + ); + for state in ["relative", "/home/test/.local/share/hypercolor/releases"] { + assert!( + proposed_location_with(home, 1000, |name| (name == "XDG_STATE_HOME") + .then(|| state.into())) + .is_err() + ); + } +} + +#[test] +fn invalid_permanent_locator_refuses_normal_and_no_service_before_staging() { + for no_service in [false, true] { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("owned home"); + let prefix = home.path().join(".local"); + let old_root = prefix.join("lib/hypercolor"); + fs::create_dir_all(&old_root).expect("legacy discovery directory"); + fs::write( + old_root.join("install-journal.json"), + br#"{"schema_version":99}"#, + ) + .expect("unknown permanent locator"); + let source = ReadOnlyDirectoryAuthority::open(home.path()).expect("source authority"); + let executable = File::open(old_root.join("install-journal.json")).expect("source file"); + let args = InstallReleaseArgs { + install_prefix: prefix.clone(), + install_dir: prefix.join("bin"), + expected_manifest_sha256: crate::install::UnitId::new("a".repeat(64)).expect("id"), + no_service, + validate_only: false, + }; + let error = execute(&args, home.path(), &old_root, &source, &executable) + .expect_err("unknown authority cannot fall back"); + assert!(error.to_string().contains("elect install authority")); + assert!(!old_root.join("units").exists()); + assert!(!home.path().join(".local/share/hypercolor").exists()); + assert!(!home.path().join(".local/state/hypercolor").exists()); + assert!(!old_root.join("install.lock").exists()); + } +} diff --git a/crates/hypercolor-cli/tests/install_payload_tests.rs b/crates/hypercolor-cli/tests/install_payload_tests.rs index 1fb16e11f..bf40c430e 100644 --- a/crates/hypercolor-cli/tests/install_payload_tests.rs +++ b/crates/hypercolor-cli/tests/install_payload_tests.rs @@ -11,8 +11,8 @@ use std::time::{Duration, Instant}; use hypercolor_cli::install::bind_macos_release_provenance; use hypercolor_cli::install::{ InstallLock, InstallStore, MAX_RELEASE_MANIFEST_BYTES, ReleasePayloadError, UnitId, UnitRecord, - retain_linux_unit, stage_release_payload, stage_release_payload_from_authority, - validate_release_payload, + copy_installed_release_unit, retain_linux_unit, stage_release_payload, + stage_release_payload_from_authority, validate_release_payload, }; use hypercolor_platform_fs::{DirectoryEntryKind, ReadOnlyDirectoryAuthority}; use serde_json::{Value, json}; @@ -1155,3 +1155,229 @@ fn corrupt_existing_digest_unit_is_refused_without_replacement() { ); assert_no_private_residue(&store); } + +#[test] +fn installed_copy_preserves_read_only_source_and_publishes_distinct_inode() { + let fixture = ReleaseFixture::new(); + let (_source_parent, source_store) = new_store(); + let source_lock = source_store.acquire_lock().expect("source lock"); + let original = stage_fixture(&source_store, &source_lock, &fixture).expect("original unit"); + let source_path = source_store.unit_path(original.id()); + let before = + fs::metadata(source_path.join("bin/hypercolor-daemon")).expect("original metadata"); + let (_destination_parent, destination) = new_store(); + let destination_lock = destination.acquire_lock().expect("destination lock"); + let copied = copy_installed_release_unit(&destination, &destination_lock, &original) + .expect("copy installed unit"); + assert_eq!(original.id(), copied.id()); + assert_ne!(original, copied); + let after = fs::metadata(source_path.join("bin/hypercolor-daemon")).expect("source unchanged"); + assert_eq!( + (before.dev(), before.ino(), before.mode()), + (after.dev(), after.ino(), after.mode()) + ); + assert_eq!(after.mode() & 0o777, 0o555); + assert_eq!( + fs::read(source_path.join("manifest.json")).expect("source manifest"), + fs::read(destination.unit_path(copied.id()).join("manifest.json")) + .expect("copied manifest") + ); + let reused = copy_installed_release_unit(&destination, &destination_lock, &original) + .expect("reuse verified destination"); + assert_eq!(copied, reused); + assert_no_private_residue(&destination); +} + +#[test] +fn installed_copy_refuses_mutated_source_before_destination_staging() { + let fixture = ReleaseFixture::new(); + let (_source_parent, source_store) = new_store(); + let source_lock = source_store.acquire_lock().expect("source lock"); + let original = stage_fixture(&source_store, &source_lock, &fixture).expect("original unit"); + let daemon = source_store + .unit_path(original.id()) + .join("bin/hypercolor-daemon"); + fs::set_permissions(&daemon, fs::Permissions::from_mode(0o755)).expect("make source writable"); + let (_destination_parent, destination) = new_store(); + let destination_lock = destination.acquire_lock().expect("destination lock"); + assert!(copy_installed_release_unit(&destination, &destination_lock, &original).is_err()); + assert!(!destination.unit_path(original.id()).exists()); + fs::write(&daemon, b"altered executable").expect("alter contents"); + fs::set_permissions(&daemon, fs::Permissions::from_mode(0o555)) + .expect("restore installed mode"); + assert!(copy_installed_release_unit(&destination, &destination_lock, &original).is_err()); + assert_no_private_residue(&destination); +} + +#[test] +#[cfg(target_os = "linux")] +fn installed_copy_crosses_filesystems_without_renaming_source() { + let fixture = ReleaseFixture::new(); + let (_source_parent, source_store) = new_store(); + let source_lock = source_store.acquire_lock().expect("source lock"); + let original = stage_fixture(&source_store, &source_lock, &fixture).expect("original unit"); + let destination_parent = tempfile::Builder::new() + .prefix("hypercolor-installed-copy-") + .tempdir_in("/dev/shm") + .expect("Linux shared-memory filesystem fixture"); + let destination = InstallStore::new(destination_parent.path().join("store"), 64 * 1024); + let destination_lock = destination.acquire_lock().expect("destination lock"); + assert_ne!( + fs::metadata(source_store.root()) + .expect("source filesystem") + .dev(), + fs::metadata(destination.root()) + .expect("destination filesystem") + .dev(), + "fixture must cross filesystems" + ); + let copied = copy_installed_release_unit(&destination, &destination_lock, &original) + .expect("cross-filesystem copy"); + assert_eq!(copied.id(), original.id()); + assert!(source_store.unit_path(original.id()).exists()); + assert_ne!(copied, original); + let daemon = destination + .unit_path(copied.id()) + .join("bin/hypercolor-daemon"); + fs::set_permissions(&daemon, fs::Permissions::from_mode(0o755)) + .expect("writable destination fixture"); + fs::write(&daemon, b"foreign destination").expect("corrupt existing destination"); + fs::set_permissions(&daemon, fs::Permissions::from_mode(0o555)) + .expect("restore destination mode"); + assert!(copy_installed_release_unit(&destination, &destination_lock, &original).is_err()); + assert_eq!( + fs::read(&daemon).expect("unchanged refused destination"), + b"foreign destination" + ); + assert_no_private_residue(&destination); +} + +#[test] +fn cold_recorded_prior_selects_exact_historical_path_and_original_inode() { + use hypercolor_cli::install::{LinuxNativeExecutor, LinuxPublicTree, LinuxSystemdConnection}; + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("owned home"); + let old = InstallStore::new(home.path().join(".local/lib/hypercolor"), 64 * 1024); + let old_lock = old.acquire_anchored_lock(home.path()).expect("old lock"); + let fixture = ReleaseFixture::new(); + let original = stage_fixture(&old, &old_lock, &fixture).expect("original installed release"); + let current = InstallStore::with_roots( + home.path().join("releases"), + home.path().join("state"), + 64 * 1024, + ) + .expect("split store"); + let lock = current + .acquire_anchored_lock(home.path()) + .expect("state lock after old"); + let copied = copy_installed_release_unit(¤t, &lock, &original).expect("copied release"); + let runtime = tempfile::tempdir().expect("runtime"); + fs::set_permissions(runtime.path(), fs::Permissions::from_mode(0o700)) + .expect("private runtime"); + let _bus = UnixListener::bind(runtime.path().join("bus")).expect("fixture bus"); + let connection = LinuxSystemdConnection::from_runtime_directory( + runtime.path(), + fs::metadata(runtime.path()).expect("runtime owner").uid(), + ) + .expect("connection authority"); + let tree = LinuxPublicTree::new(&lock, home.path()).expect("public tree"); + let mut executor = LinuxNativeExecutor::new_with_connection( + ¤t, + &lock, + tree, + "127.0.0.1:9420".parse().expect("address"), + connection, + ) + .expect("native executor"); + let current_binding = cold_prior_binding(&copied, ¤t); + let historical_binding = cold_prior_binding(&original, &old); + assert!( + executor + .retain_recorded_prior(&cold_prior_record(¤t_binding)) + .expect("current-store selection") + .is_none() + ); + let restored = executor + .retain_recorded_prior(&cold_prior_record(&historical_binding)) + .expect("historical selection") + .expect("historical authority"); + assert_eq!(restored, original); + assert_ne!(restored, copied); + let mut wrong_inode = historical_binding.clone(); + wrong_inode["daemon_inode"] = current_binding["daemon_inode"].clone(); + assert!( + executor + .retain_recorded_prior(&cold_prior_record(&wrong_inode)) + .is_err() + ); + let mut foreign_path = historical_binding.clone(); + foreign_path["daemon_path"] = json!(home.path().join("foreign/bin/hypercolor-daemon")); + assert!( + executor + .retain_recorded_prior(&cold_prior_record(&foreign_path)) + .is_err() + ); + let manifest = old.unit_path(original.id()).join("manifest.json"); + fs::set_permissions(&manifest, fs::Permissions::from_mode(0o644)) + .expect("alter installed manifest mode"); + assert!( + executor + .retain_recorded_prior(&cold_prior_record(&historical_binding)) + .is_err() + ); + fs::set_permissions(&manifest, fs::Permissions::from_mode(0o444)) + .expect("restore manifest mode"); + fs::rename( + home.path().join(".local/lib"), + home.path().join(".local/previous-lib"), + ) + .expect("displace ancestor"); + fs::create_dir(home.path().join(".local/lib")).expect("replacement ancestor"); + fs::rename( + home.path().join(".local/previous-lib/hypercolor"), + home.path().join(".local/lib/hypercolor"), + ) + .expect("retain original unit below new ancestor"); + assert!( + executor + .retain_recorded_prior(&cold_prior_record(&historical_binding)) + .is_err() + ); + make_fixture_directories_writable(home.path()); +} + +fn cold_prior_binding(unit: &UnitRecord, store: &InstallStore) -> Value { + let path = store.unit_path(unit.id()).join("bin/hypercolor-daemon"); + let metadata = fs::metadata(&path).expect("daemon metadata"); + json!({"unit": unit.id(), "daemon_path": path, + "daemon_sha256": sha256(&fs::read(&path).expect("daemon bytes")), + "daemon_size": metadata.len(), "daemon_device": metadata.dev(), + "daemon_inode": metadata.ino(), "version": "0.3.2"}) +} + +fn cold_prior_record(binding: &Value) -> hypercolor_cli::install::PlatformTransactionRecord { + hypercolor_cli::install::PlatformTransactionRecord::linux( + 1, + serde_json::to_vec(&json!({ + "candidate": binding, "prior": binding, + "baseline_systemd": {"load_state":"not-found", "active_state":"inactive", + "sub_state":"dead", "unit_file_state":"disabled", "fragment_path":"", + "exec_start":"", "main_pid":0, "invocation_id":""}, + "prior_launcher":{"kind":"absent"}, "prior_launcher_bytes":[], + "candidate_launcher":null, "prior_directories":{}, "layout":[], "first_conversion":false + })) + .expect("strict record bytes"), + ) + .expect("bounded selection record") +} + +fn make_fixture_directories_writable(root: &Path) { + let metadata = fs::symlink_metadata(root).expect("fixture metadata"); + if !metadata.is_dir() { + return; + } + fs::set_permissions(root, fs::Permissions::from_mode(0o755)) + .expect("writable fixture directory"); + for entry in fs::read_dir(root).expect("fixture entries") { + make_fixture_directories_writable(&entry.expect("fixture entry").path()); + } +} diff --git a/crates/hypercolor-cli/tests/install_store_roots_tests.rs b/crates/hypercolor-cli/tests/install_store_roots_tests.rs new file mode 100644 index 000000000..3960732f9 --- /dev/null +++ b/crates/hypercolor-cli/tests/install_store_roots_tests.rs @@ -0,0 +1,183 @@ +#![cfg(unix)] + +use std::fs; +use std::path::Path; + +use hypercolor_cli::install::{InstallStore, InstallStoreError, UnitId}; + +fn split(home: &Path) -> InstallStore { + InstallStore::with_roots(home.join("data/releases"), home.join("state/update"), 65536) + .expect("separate normalized roots") +} + +#[test] +fn immutable_units_and_active_are_separate_from_lock_and_journal() { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let store = split(home.path()); + let lock = store.acquire_anchored_lock(home.path()).expect("lock"); + let unit = UnitId::new("a".repeat(64)).expect("unit"); + store.set_active(Some(&unit), &lock).expect("active"); + assert!(store.active_path().is_symlink()); + assert_eq!(store.active_unit(&lock).expect("read active"), Some(unit)); + assert!(store.state_root().join("install.lock").is_file()); + assert!(!store.root().join("install.lock").exists()); + fs::write(store.journal_path(), b"unknown journal").expect("journal fixture"); + assert!(matches!( + store.load_journal(&lock), + Err(InstallStoreError::DecodeJournal(_)) + )); + assert!(!store.root().join("install-journal.json").exists()); +} + +#[test] +fn split_state_lock_excludes_another_release_store() { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let first = split(home.path()); + let _lock = first + .acquire_anchored_lock(home.path()) + .expect("first lock"); + let second = InstallStore::with_roots( + home.path().join("other/releases"), + first.state_root(), + 65536, + ) + .expect("roots"); + assert!(matches!( + second.acquire_anchored_lock(home.path()), + Err(InstallStoreError::LockContended) + )); +} + +#[test] +fn a_lock_cannot_be_reused_with_another_state_root() { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let first = split(home.path()); + let lock = first.acquire_anchored_lock(home.path()).expect("lock"); + let other = InstallStore::with_roots(first.root(), home.path().join("other-state"), 65536) + .expect("roots"); + assert!(matches!( + other.set_active(None, &lock), + Err(InstallStoreError::WrongLock) + )); +} + +#[test] +fn equal_nested_and_relative_roots_are_rejected() { + for (release, state) in [ + ("/data", "/data"), + ("/data", "/data/state"), + ("/state/release", "/state"), + ("relative", "/state"), + ("/release", "/state/../other"), + ] { + assert!( + InstallStore::with_roots(release, state, 65536).is_err(), + "{release} {state}" + ); + } + assert!(InstallStore::with_roots("/data/releases", "/state/update", 65536).is_ok()); +} + +#[test] +fn either_root_replacement_refuses_mutation_and_journal_reads() { + for replace_state in [false, true] { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let store = split(home.path()); + let lock = store.acquire_anchored_lock(home.path()).expect("lock"); + let path = if replace_state { + store.state_root() + } else { + store.root() + }; + let displaced = path.with_extension("displaced"); + fs::rename(path, &displaced).expect("displace"); + fs::create_dir(path).expect("replacement"); + fs::write(path.join("sentinel"), b"untouched").expect("sentinel"); + assert!(store.set_active(None, &lock).is_err()); + assert!(store.load_journal(&lock).is_err()); + assert_eq!( + fs::read(path.join("sentinel")).expect("sentinel"), + b"untouched" + ); + assert!(!path.join("active").exists()); + } +} + +#[test] +fn symlinked_release_root_cannot_alias_state() { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let state = home.path().join("state"); + let release = home.path().join("release"); + fs::create_dir(&state).expect("state"); + std::os::unix::fs::symlink(&state, &release).expect("alias"); + let store = InstallStore::with_roots(release, state, 65536).expect("lexically distinct"); + assert!(store.acquire_lock().is_err()); +} + +#[test] +fn legacy_store_keeps_its_original_layout() { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let store = InstallStore::new(home.path().join("legacy"), 65536); + let lock = store + .acquire_anchored_lock(home.path()) + .expect("legacy lock"); + assert_eq!(store.state_root(), store.root()); + assert!(store.lock_path().is_file()); + assert!(store.load_journal(&lock).expect("journal").is_none()); +} + +#[test] +fn root_permissions_are_revalidated_before_mutation() { + use std::os::unix::fs::PermissionsExt as _; + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let store = split(home.path()); + let lock = store.acquire_anchored_lock(home.path()).expect("lock"); + fs::set_permissions(store.state_root(), fs::Permissions::from_mode(0o777)) + .expect("change mode"); + assert!(matches!( + store.set_active(None, &lock), + Err(InstallStoreError::UnsafeBootstrapDirectory(_)) + )); + assert!(lock.open_store_public_directory().is_err()); + assert!(lock.open_public_directory(home.path()).is_err()); +} + +#[test] +fn replacing_an_ancestor_cannot_be_hidden_by_preserving_the_root_inode() { + for replace_state in [false, true] { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let store = split(home.path()); + let lock = store.acquire_anchored_lock(home.path()).expect("lock"); + let root = if replace_state { + store.state_root() + } else { + store.root() + }; + let parent = root.parent().expect("parent"); + let displaced = parent.with_extension("displaced"); + fs::rename(parent, &displaced).expect("displace parent"); + fs::create_dir(parent).expect("new parent"); + fs::rename(displaced.join(root.file_name().expect("root name")), root) + .expect("preserve root inode"); + assert!(store.set_active(None, &lock).is_err()); + assert!(store.load_journal(&lock).is_err()); + } +} + +#[test] +fn state_outside_bootstrap_anchor_is_rejected_before_creating_release_root() { + let home = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("home"); + let outside = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("outside"); + let store = InstallStore::with_roots( + home.path().join("data/releases"), + outside.path().join("state"), + 65536, + ) + .expect("separate roots"); + assert!(matches!( + store.acquire_anchored_lock(home.path()), + Err(InstallStoreError::RootOutsideAnchor { .. }) + )); + assert!(!home.path().join("data").exists()); + assert!(!outside.path().join("state").exists()); +} diff --git a/crates/hypercolor-cli/tests/install_transaction_tests.rs b/crates/hypercolor-cli/tests/install_transaction_tests.rs index b43f7adb3..583c07d46 100644 --- a/crates/hypercolor-cli/tests/install_transaction_tests.rs +++ b/crates/hypercolor-cli/tests/install_transaction_tests.rs @@ -938,6 +938,78 @@ fn seed_state_neutral_rollback_manager(fixture: &Fixture, action: InstallAction) platform } +#[test] +fn preparation_returns_exact_journal_before_publication_or_platform_transitions() { + let fixture = Fixture::new(); + let mut platform = FakePlatform::new(fixture.prior_state(), &fixture.store); + let mut lock = fixture.store.acquire_lock().expect("preparation lock"); + let journal = InstallCoordinator::new(&fixture.store, &mut platform) + .prepare_with_lock(fixture.request(), &lock) + .expect("prepare"); + assert_eq!(journal.prior_platform, fixture.prior_state()); + assert_eq!(journal.candidate_unit, *fixture.candidate.id()); + assert_eq!(journal.next_action, Some(InstallAction::PreflightCandidate)); + assert!( + fixture + .store + .load_journal(&lock) + .expect("unpublished") + .is_none() + ); + assert_eq!( + fixture.store.active_unit(&lock).expect("active unchanged"), + Some(fixture.prior.id().clone()) + ); + assert!(platform.effects.is_empty()); + assert_eq!(platform.state, fixture.prior_state()); + fixture + .store + .write_journal(&journal, &lock) + .expect("caller publishes after binding"); + let outcome = InstallCoordinator::new(&fixture.store, &mut platform) + .recover_with_lock(&mut lock) + .expect("existing recovery path") + .expect("transaction"); + assert!(matches!(outcome, InstallOutcome::Committed { .. })); + assert_eq!( + fixture.store.active_unit(&lock).expect("candidate active"), + Some(fixture.candidate.id().clone()) + ); + fixture.assert_sentinels(); +} + +#[test] +fn preparation_does_not_replace_a_pending_transaction_or_accept_a_foreign_lock() { + let fixture = Fixture::new(); + let mut platform = FakePlatform::new(fixture.prior_state(), &fixture.store); + let lock = fixture.store.acquire_lock().expect("lock"); + let journal = InstallCoordinator::new(&fixture.store, &mut platform) + .prepare_with_lock(fixture.request(), &lock) + .expect("prepare"); + fixture + .store + .write_journal(&journal, &lock) + .expect("existing transaction"); + assert!(matches!( + InstallCoordinator::new(&fixture.store, &mut platform) + .prepare_with_lock(fixture.request(), &lock), + Err(InstallCoordinatorError::PendingPreparation) + )); + assert_eq!( + fixture.store.load_journal(&lock).expect("unchanged"), + Some(journal) + ); + let foreign = InstallStore::new(fixture.directory.path().join("foreign"), 65536); + let foreign_lock = foreign.acquire_lock().expect("foreign lock"); + assert!(matches!( + InstallCoordinator::new(&fixture.store, &mut platform) + .prepare_with_lock(fixture.request(), &foreign_lock), + Err(InstallCoordinatorError::Store(InstallStoreError::WrongLock)) + )); + assert!(platform.effects.is_empty()); + fixture.assert_sentinels(); +} + #[test] fn successful_install_preserves_write_ahead_order_and_private_journal() { let fixture = Fixture::new(); diff --git a/crates/hypercolor-cli/tests/linux_install_platform_tests.rs b/crates/hypercolor-cli/tests/linux_install_platform_tests.rs index 642ead3bf..8532881ca 100644 --- a/crates/hypercolor-cli/tests/linux_install_platform_tests.rs +++ b/crates/hypercolor-cli/tests/linux_install_platform_tests.rs @@ -27,6 +27,7 @@ const UNITS_ROOT: &str = "/home/test/.local/lib/hypercolor/units"; #[derive(Debug, Clone)] struct FakeExecutor { + expected_topology: Option, launcher_discovery: Option, active_path: PathBuf, launcher: LinuxExactEntry, @@ -39,6 +40,7 @@ struct FakeExecutor { daemon_digests: BTreeMap, daemon_identities: BTreeMap, expected_unit_authorities: Option>, + expected_prior: Option<(hypercolor_cli::install::UnitRecord, PathBuf)>, versions: BTreeMap, invocation: u32, http_calls: usize, @@ -77,6 +79,7 @@ struct FakeSystemd { impl FakeExecutor { fn absent(active_path: PathBuf, daemon_digest: String) -> Self { Self { + expected_topology: None, launcher_discovery: None, active_path, launcher: LinuxExactEntry::Absent, @@ -103,6 +106,7 @@ impl FakeExecutor { daemon_digests: BTreeMap::new(), daemon_identities: BTreeMap::new(), expected_unit_authorities: None, + expected_prior: None, versions: BTreeMap::new(), invocation: 0, http_calls: 0, @@ -207,7 +211,7 @@ impl LinuxInstallExecutor for FakeExecutor { &mut self, topology: &LinuxInstallConfig, ) -> Result<(), hypercolor_cli::install::InstallPlatformError> { - if topology != &config() { + if topology != self.expected_topology.as_ref().unwrap_or(&config()) { return Err(hypercolor_cli::install::InstallPlatformError::new( "split install-store topology", )); @@ -242,6 +246,19 @@ impl LinuxInstallExecutor for FakeExecutor { Ok(()) } + fn prior_units_root( + &self, + unit: &hypercolor_cli::install::UnitRecord, + ) -> Result { + self.expected_prior + .as_ref() + .filter(|(expected, _)| expected == unit) + .map(|(_, root)| root.clone()) + .ok_or_else(|| { + hypercolor_cli::install::InstallPlatformError::new("unretained prior role") + }) + } + fn active_unit( &mut self, ) -> Result, hypercolor_cli::install::InstallPlatformError> { @@ -341,7 +358,10 @@ impl LinuxInstallExecutor for FakeExecutor { && !matches!(self.launcher, LinuxExactEntry::Absent) { self.systemd.load = "loaded"; - FRAGMENT.clone_into(&mut self.systemd.fragment); + self.systemd.fragment = self.expected_topology.as_ref().map_or_else( + || FRAGMENT.to_owned(), + |config| config.direct_fragment_path.clone(), + ); self.systemd.exec_start = launcher_exec(&self.launcher_bytes); discover(&mut self.systemd); } @@ -395,7 +415,10 @@ impl LinuxInstallExecutor for FakeExecutor { self.systemd.exec_start.clear(); } else { self.systemd.load = "loaded"; - FRAGMENT.clone_into(&mut self.systemd.fragment); + self.systemd.fragment = self.expected_topology.as_ref().map_or_else( + || FRAGMENT.to_owned(), + |config| config.direct_fragment_path.clone(), + ); self.systemd.exec_start = launcher_exec(&self.launcher_bytes); } Self::finish_effect(fail_after) @@ -441,10 +464,48 @@ impl LinuxInstallExecutor for FakeExecutor { if let Some(process) = &self.process_override { return Ok(process.clone()); } + if let Some((prior, root)) = &self.expected_prior + && self.systemd.exec_start.starts_with( + root.parent() + .expect("prior store") + .join("active/bin/hypercolor-daemon") + .to_str() + .expect("prior executable path"), + ) + { + let executable = prior + .directory() + .open_child_directory(Path::new("bin")) + .expect("original bin") + .open_regular_file(Path::new("hypercolor-daemon")) + .expect("original daemon"); + return Ok(LinuxProcessExecutable { + path: root + .join(prior.id().as_str()) + .join("bin/hypercolor-daemon") + .to_str() + .expect("original path") + .to_owned(), + sha256: self.daemon_digest.clone(), + device: executable.metadata().device(), + inode: executable.metadata().inode(), + }); + } let unit = self.active().expect("running unit"); let (device, inode) = self.daemon_identities[unit.as_str()]; Ok(LinuxProcessExecutable { - path: format!("{UNITS_ROOT}/{}/bin/hypercolor-daemon", unit.as_str()), + path: self + .expected_topology + .as_ref() + .map_or_else( + || PathBuf::from(UNITS_ROOT), + |config| config.immutable_units_root.clone(), + ) + .join(unit.as_str()) + .join("bin/hypercolor-daemon") + .to_str() + .expect("fixture UTF-8 path") + .to_owned(), sha256: self .daemon_digests .get(unit.as_str()) @@ -983,6 +1044,359 @@ fn unloaded_disabled_upgrade_preserves_service_state_and_user_data() { assert_eq!(fs::read(effects_sentinel).expect("effect"), b"effect"); } +#[test] +fn managed_adoption_preserves_real_old_paths_until_cold_state_recovery() { + for journal_written in [false, true] { + cold_managed_adoption(journal_written, false); + } +} + +#[test] +fn managed_adoption_rollback_restores_original_launcher_layout_and_inode() { + cold_managed_adoption(true, true); +} + +fn cold_managed_adoption(journal_written: bool, rollback: bool) { + use hypercolor_cli::install::{ + LinuxAdoption, LinuxInstallElection, LinuxInstallLocation, elect_linux_installation, + }; + let home = tempfile::tempdir_in(std::env::var_os("HOME").expect("owned test home")) + .expect("owned home"); + let source = home.path().join("source"); + fs::create_dir(&source).expect("source"); + let executable = write_release(&source); + let id = UnitId::new(sha256( + &fs::read(source.join("manifest.json")).expect("manifest"), + )) + .expect("unit"); + let old = InstallStore::new(home.path().join(".local/lib/hypercolor"), 65536); + let mut old_lock = old.acquire_anchored_lock(home.path()).expect("old lock"); + let original = stage_release_payload(&old, &old_lock, &source, &executable, &id) + .expect("original installed unit"); + let fragment = home + .path() + .join(".config/systemd/user/hypercolor.service") + .to_str() + .expect("fragment") + .to_owned(); + let old_config = LinuxInstallConfig { + direct_fragment_path: fragment.clone(), + immutable_units_root: old.root().join("units"), + active_root: old.active_path(), + }; + let mut executor = FakeExecutor::absent(old.active_path(), sha256(b"daemon")); + executor.expected_topology = Some(old_config.clone()); + let mut platform = LinuxInstallPlatform::new(executor, old_config, []).expect("old platform"); + InstallCoordinator::new(&old, &mut platform) + .install_with_lock( + InstallRequest { + transaction_id: InstallTransactionId::new("original-install").expect("id"), + candidate: original.clone(), + target_policy: InstallTargetPolicy::EnableOnFirstInstall, + }, + &mut old_lock, + ) + .expect("old service installation"); + let mut executor = platform.into_executor(); + let old_launcher = executor.launcher_bytes.clone(); + let old_layout = executor.layout.clone(); + executor.effects.clear(); + drop(old_lock); + let proposed = LinuxInstallLocation::new( + home.path(), + &home.path().join("data"), + &home.path().join("state"), + &home.path().join("config"), + fs::metadata(home.path()).expect("owner").uid(), + ) + .expect("location"); + let adoption = LinuxAdoption::begin( + home.path(), + elect_linux_installation(home.path()).expect("legacy election"), + proposed, + ) + .expect("begin adoption"); + let location = adoption.location().clone(); + let copied = retain_linux_unit(adoption.store(), adoption.lock(), &id).expect("copied unit"); + assert_ne!(original, copied); + let new_config = LinuxInstallConfig { + direct_fragment_path: fragment, + immutable_units_root: adoption.store().root().join("units"), + active_root: adoption.store().active_path(), + }; + executor.active_path = adoption.store().active_path(); + executor.expected_topology = Some(new_config.clone()); + executor.expected_prior = Some((original.clone(), old.root().join("units"))); + let metadata = + fs::metadata(old.unit_path(&id).join("bin/hypercolor-daemon")).expect("original inode"); + executor.process_override = Some(LinuxProcessExecutable { + path: old + .unit_path(&id) + .join("bin/hypercolor-daemon") + .to_str() + .expect("old path") + .to_owned(), + sha256: sha256(b"daemon"), + device: metadata.dev(), + inode: metadata.ino(), + }); + let mut platform = LinuxInstallPlatform::new(executor, new_config.clone(), [copied.clone()]) + .expect("managed platform") + .with_prior_unit(original.clone()) + .expect("original prior"); + let journal = adoption + .prepare( + &mut platform, + InstallRequest { + transaction_id: InstallTransactionId::new("adopt-original").expect("id"), + candidate: copied.clone(), + target_policy: InstallTargetPolicy::Preserve, + }, + ) + .expect("prepare original layout under managed candidate topology"); + let executor = platform.into_executor(); + assert!(executor.effects.is_empty()); + assert_eq!(executor.launcher_bytes, old_launcher); + assert_eq!(executor.layout, old_layout); + if journal_written { + adoption + .store() + .write_journal(&journal, adoption.lock()) + .expect("crash after state journal durability"); + } + drop(adoption); + let adoption = LinuxAdoption::begin( + home.path(), + elect_linux_installation(home.path()).expect("cold legacy election"), + location.clone(), + ) + .expect("receipt-only resume"); + assert_eq!( + adoption.prepared_journal().expect("read receipt"), + Some(journal.clone()) + ); + let copied = + retain_linux_unit(adoption.store(), adoption.lock(), &id).expect("cold copied unit"); + let mut platform = LinuxInstallPlatform::new(executor, new_config.clone(), [copied]) + .expect("cold managed platform") + .with_prior_unit(original.clone()) + .expect("cold prior"); + let managed = adoption + .publish(&journal, &mut platform) + .expect("publish authority"); + let mut executor = platform.into_executor(); + assert!(executor.effects.is_empty()); + drop(managed); + if rollback { + executor.fault = Some(("runtime:true".to_owned(), FaultPoint::Before)); + } + let LinuxInstallElection::Managed { + store, + mut lock, + authority, + } = elect_linux_installation(home.path()).expect("cold state-only election") + else { + panic!("managed authority") + }; + let old_lock = old.acquire_lock().expect("old lock released after handoff"); + assert!( + old.load_journal(&old_lock).is_err(), + "old decoder permanently fenced" + ); + assert_eq!( + old.active_unit(&old_lock).expect("old pointer"), + Some(id.clone()) + ); + let copied = retain_linux_unit(&store, &lock, &id).expect("managed unit"); + let mut platform = LinuxInstallPlatform::new(executor, new_config.clone(), [copied.clone()]) + .expect("recovery platform") + .with_prior_unit(original.clone()) + .expect("recorded original role"); + let result = InstallCoordinator::new(&store, &mut platform).recover_with_lock(&mut lock); + if rollback { + assert!(matches!( + result.expect("settled recovery"), + Some(hypercolor_cli::install::InstallOutcome::RolledBack { .. }) + )); + } else { + result.expect("cold activation recovery"); + } + authority + .confirm_durable() + .expect("authority remains valid"); + assert_eq!( + store + .load_journal(&lock) + .expect("settled journal") + .expect("journal") + .disposition, + if rollback { + InstallDisposition::RolledBack + } else { + InstallDisposition::Committed + } + ); + let executor = platform.into_executor(); + if rollback { + assert_eq!(executor.launcher_bytes, old_launcher); + assert_eq!(executor.layout, old_layout); + } else { + assert_ne!(executor.launcher_bytes, old_launcher); + assert_ne!(executor.layout, old_layout); + } + assert!(executor.systemd.active); + assert!( + executor.systemd.exec_start.contains( + if rollback { + old.root() + } else { + location.release_root() + } + .to_str() + .expect("path") + ) + ); + let mut next = LinuxInstallPlatform::new(executor, new_config, [copied.clone()]) + .expect("next command platform"); + if rollback { + next = next + .with_prior_unit(original) + .expect("rollback still runs historical prior"); + } + let outcome = InstallCoordinator::new(&store, &mut next) + .install_with_lock( + InstallRequest { + transaction_id: InstallTransactionId::new("next-managed-attempt").expect("id"), + candidate: copied, + target_policy: InstallTargetPolicy::Preserve, + }, + &mut lock, + ) + .expect("next attempt uses disposition-appropriate prior"); + assert!(matches!( + outcome, + hypercolor_cli::install::InstallOutcome::Committed { .. } + )); + drop(next); + drop(authority); + drop(lock); + drop(old_lock); + make_fixture_directories_writable(home.path()); +} + +#[test] +fn copied_same_id_keeps_the_original_prior_inode_and_path() { + let original = Fixture::new(); + let copied = Fixture::new(); + assert_eq!(original.candidate.id(), copied.candidate.id()); + assert_ne!(original.candidate, copied.candidate); + let executor = + FakeExecutor::absent(original.store.active_path(), original.daemon_digest.clone()); + let mut initial = LinuxInstallPlatform::new(executor, config(), []).expect("initial platform"); + let mut lock = original.store.acquire_lock().expect("original lock"); + InstallCoordinator::new(&original.store, &mut initial) + .install_with_lock( + original.request(InstallTargetPolicy::EnableOnFirstInstall), + &mut lock, + ) + .expect("original service"); + let mut executor = initial.into_executor(); + executor.effects.clear(); + let root = PathBuf::from("/home/test/historical/units"); + executor.expected_prior = Some((original.candidate.clone(), root.clone())); + let metadata = fs::metadata( + original + .store + .unit_path(original.candidate.id()) + .join("bin/hypercolor-daemon"), + ) + .expect("original executable"); + executor.process_override = Some(LinuxProcessExecutable { + path: root + .join(original.candidate.id().as_str()) + .join("bin/hypercolor-daemon") + .to_str() + .expect("fixture authority") + .to_owned(), + sha256: original.daemon_digest.clone(), + device: metadata.dev(), + inode: metadata.ino(), + }); + let mut platform = LinuxInstallPlatform::new(executor, config(), [copied.candidate.clone()]) + .expect("candidate authority") + .with_prior_unit(original.candidate.clone()) + .expect("prior authority"); + let prior = InstallationState { + active_unit: Some(original.candidate.id().clone()), + platform: platform.inspect().expect("inspect"), + }; + let prepared = platform + .prepare_transaction(&copied.candidate, &prior, &prior.platform) + .expect("prepare"); + let PlatformTransactionRecord::Linux { payload, .. } = &prepared.record else { + panic!("Linux record") + }; + let record: serde_json::Value = serde_json::from_slice(payload).expect("fixture authority"); + assert_eq!(record["candidate"]["unit"], record["prior"]["unit"]); + assert_ne!( + record["candidate"]["daemon_inode"], + record["prior"]["daemon_inode"] + ); + assert_ne!( + record["candidate"]["daemon_path"], + record["prior"]["daemon_path"] + ); + platform + .preflight_authority(copied.candidate.id(), &prior, &prepared.record) + .expect("original owner proof"); + let forged = forge_record(&prepared.record, |value| { + value["prior"] = value["candidate"].clone(); + }); + assert!( + platform + .preflight_authority(copied.candidate.id(), &prior, &forged) + .is_err() + ); + let mut executor = platform.into_executor(); + assert!(executor.effects.is_empty()); + let copied_metadata = fs::metadata( + copied + .store + .unit_path(copied.candidate.id()) + .join("bin/hypercolor-daemon"), + ) + .expect("fixture authority"); + executor + .process_override + .as_mut() + .expect("fixture authority") + .inode = copied_metadata.ino(); + let mut platform = LinuxInstallPlatform::new(executor, config(), [copied.candidate.clone()]) + .expect("fixture authority") + .with_prior_unit(original.candidate.clone()) + .expect("fixture authority"); + assert!( + platform + .preflight_authority(copied.candidate.id(), &prior, &prepared.record) + .is_err() + ); +} + +#[test] +fn prior_role_refuses_an_unrecognized_same_id_copy() { + let original = Fixture::new(); + let copied = Fixture::new(); + let mut executor = + FakeExecutor::absent(original.store.active_path(), original.daemon_digest.clone()); + executor.expected_prior = Some(( + original.candidate.clone(), + PathBuf::from("/home/test/prior/units"), + )); + let platform = LinuxInstallPlatform::new(executor, config(), [copied.candidate.clone()]) + .expect("fixture authority"); + assert!(platform.with_prior_unit(copied.candidate.clone()).is_err()); +} + #[test] fn running_enabled_upgrade_preserves_policy_and_proves_fresh_owner() { let mut fixture = Fixture::new(); @@ -1970,6 +2384,19 @@ fn config() -> LinuxInstallConfig { } } +fn make_fixture_directories_writable(root: &Path) { + if !fs::symlink_metadata(root) + .expect("fixture metadata") + .is_dir() + { + return; + } + fs::set_permissions(root, fs::Permissions::from_mode(0o755)).expect("fixture cleanup mode"); + for entry in fs::read_dir(root).expect("fixture directory") { + make_fixture_directories_writable(&entry.expect("fixture entry").path()); + } +} + fn write_release(root: &Path) -> File { write_release_with(root, VERSION, b"daemon") } diff --git a/crates/hypercolor-platform-fs/src/unix/tree.rs b/crates/hypercolor-platform-fs/src/unix/tree.rs index b02bd7dcb..e429d2e79 100644 --- a/crates/hypercolor-platform-fs/src/unix/tree.rs +++ b/crates/hypercolor-platform-fs/src/unix/tree.rs @@ -215,6 +215,7 @@ pub struct DirectoryEntryMetadata { pub(super) link_count: u64, pub(super) device: u64, pub(super) inode: u64, + pub(super) owner_uid: u32, } impl DirectoryEntryMetadata { @@ -248,6 +249,18 @@ impl DirectoryEntryMetadata { self.device } + /// Return the owning Unix user ID observed on the retained file handle. + #[must_use] + pub fn owner_uid(self) -> u32 { + self.owner_uid + } + + /// Whether the observed owner is the process's effective Unix user. + #[must_use] + pub fn is_owned_by_current_user(self) -> bool { + self.owner_uid == rustix::process::geteuid().as_raw() + } + /// Return the inode number. #[must_use] pub fn inode(self) -> u64 { diff --git a/crates/hypercolor-platform-fs/src/unix/tree/entry.rs b/crates/hypercolor-platform-fs/src/unix/tree/entry.rs index 527551b25..ef4b5785d 100644 --- a/crates/hypercolor-platform-fs/src/unix/tree/entry.rs +++ b/crates/hypercolor-platform-fs/src/unix/tree/entry.rs @@ -339,11 +339,24 @@ pub(super) fn durable_replace_file_at( directory: &File, source: &OsStr, destination: &OsStr, +) -> io::Result<()> { + durable_replace_file_at_with(directory, source, destination, File::sync_all) +} + +fn durable_replace_file_at_with( + directory: &File, + source: &OsStr, + destination: &OsStr, + sync: impl FnOnce(&File) -> io::Result<()>, ) -> io::Result<()> { renameat(directory, source, directory, destination).map_err(io::Error::from)?; - directory.sync_all() + sync(directory) } +#[cfg(test)] +#[path = "entry_publication_tests.rs"] +mod publication_tests; + pub(super) fn durable_replace_symlink_at( directory: &File, target: &Path, diff --git a/crates/hypercolor-platform-fs/src/unix/tree/entry_publication_tests.rs b/crates/hypercolor-platform-fs/src/unix/tree/entry_publication_tests.rs new file mode 100644 index 000000000..a0e752955 --- /dev/null +++ b/crates/hypercolor-platform-fs/src/unix/tree/entry_publication_tests.rs @@ -0,0 +1,48 @@ +use std::ffi::OsStr; +use std::fs::{self, File}; +use std::io; + +use super::durable_replace_file_at_with; + +#[test] +fn directory_sync_failure_does_not_restore_displaced_authority() { + let fixture = tempfile::tempdir().expect("fixture"); + fs::write(fixture.path().join("locator"), b"legacy-v1").expect("legacy"); + fs::write(fixture.path().join("staged"), b"managed-v2").expect("prepared locator"); + let directory = File::open(fixture.path()).expect("directory"); + let result = durable_replace_file_at_with( + &directory, + OsStr::new("staged"), + OsStr::new("locator"), + |_| Err(io::Error::other("injected directory fsync failure")), + ); + assert!(result.is_err()); + assert_eq!( + fs::read(fixture.path().join("locator")).expect("visible authority"), + b"managed-v2" + ); + assert!(!fixture.path().join("staged").exists()); + directory.sync_all().expect("retry durability barrier"); + assert_eq!( + fs::read(fixture.path().join("locator")).expect("durable authority"), + b"managed-v2" + ); +} + +#[test] +fn failed_rename_preserves_prior_authority_without_attempting_sync() { + let fixture = tempfile::tempdir().expect("fixture"); + fs::write(fixture.path().join("locator"), b"legacy-v1").expect("legacy"); + let directory = File::open(fixture.path()).expect("directory"); + let result = durable_replace_file_at_with( + &directory, + OsStr::new("missing"), + OsStr::new("locator"), + |_| panic!("rename failure must precede fsync"), + ); + assert!(result.is_err()); + assert_eq!( + fs::read(fixture.path().join("locator")).expect("prior authority"), + b"legacy-v1" + ); +} diff --git a/crates/hypercolor-platform-fs/src/unix/tree/replacement.rs b/crates/hypercolor-platform-fs/src/unix/tree/replacement.rs index e1dc6fba5..ac8d6b29b 100644 --- a/crates/hypercolor-platform-fs/src/unix/tree/replacement.rs +++ b/crates/hypercolor-platform-fs/src/unix/tree/replacement.rs @@ -7,6 +7,7 @@ mod exact; mod metadata_tests; mod operation; mod read; +mod relationship; mod rollback; mod staging; diff --git a/crates/hypercolor-platform-fs/src/unix/tree/replacement/relationship.rs b/crates/hypercolor-platform-fs/src/unix/tree/replacement/relationship.rs new file mode 100644 index 000000000..36e43363c --- /dev/null +++ b/crates/hypercolor-platform-fs/src/unix/tree/replacement/relationship.rs @@ -0,0 +1,36 @@ +use std::io; +use std::sync::Arc; + +use super::super::PublicDirectoryAuthority; +use super::super::traversal::metadata_for_file; + +impl PublicDirectoryAuthority { + /// Test whether this directory is the other directory or lies below it. + /// + /// Comparison uses retained device/inode identities throughout the original + /// ancestry, so distinct path spellings do not hide an aliased ancestor. + /// Both authorities must share the same exclusive operation gate. + /// + /// # Errors + /// Returns an error for different gates, changed ancestry or failed handle + /// inspection. A failed comparison must not be treated as disjointness. + pub fn is_within(&self, ancestor: &Self) -> io::Result { + if !Arc::ptr_eq(&self.shared, &ancestor.shared) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "directory relationship requires one exclusive authority", + )); + } + let _operation = self.operation_guard()?; + self.validate_ancestry_inner()?; + ancestor.validate_ancestry_inner()?; + let expected = metadata_for_file(&ancestor.directory)?; + let contained = self.ancestry.iter().any(|entry| { + entry.expected.device() == expected.device() + && entry.expected.inode() == expected.inode() + }); + self.validate_ancestry_inner()?; + ancestor.validate_ancestry_inner()?; + Ok(contained) + } +} diff --git a/crates/hypercolor-platform-fs/src/unix/tree/traversal.rs b/crates/hypercolor-platform-fs/src/unix/tree/traversal.rs index 015657d24..51bebd6fa 100644 --- a/crates/hypercolor-platform-fs/src/unix/tree/traversal.rs +++ b/crates/hypercolor-platform-fs/src/unix/tree/traversal.rs @@ -160,6 +160,7 @@ fn metadata_from_stat(metadata: &rustix::fs::Stat) -> io::Result