From 0e44c1f3d91fe8be6e6435e7d36b39c224544949 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sat, 12 Sep 2026 19:32:14 -0700 Subject: [PATCH 01/13] feat(install): separate release and journal filesystem authority Managed installation needs immutable releases in its data root and a persistent transaction journal in its state root. Add a split-root store that retains both directory identities and ancestry under one state lock, while preserving the existing single-root installer contract. Validate both roots through bootstrap and every later store operation. Reject substituted ancestry, changed permissions, overlapping roots and foreign locks before active-pointer or journal operations proceed. Co-Authored-By: Nova (GPT-6) --- crates/hypercolor-cli/src/install/store.rs | 201 ++++++++++++++++-- .../src/install/store_acquisition_tests.rs | 48 +++++ .../tests/install_store_roots_tests.rs | 183 ++++++++++++++++ 3 files changed, 411 insertions(+), 21 deletions(-) create mode 100644 crates/hypercolor-cli/src/install/store_acquisition_tests.rs create mode 100644 crates/hypercolor-cli/tests/install_store_roots_tests.rs diff --git a/crates/hypercolor-cli/src/install/store.rs b/crates/hypercolor-cli/src/install/store.rs index 70d497e81..53b4cf108 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, @@ -416,11 +531,43 @@ 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 { + 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 +578,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 +609,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 +620,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 +836,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..278d0d13c --- /dev/null +++ b/crates/hypercolor-cli/src/install/store_acquisition_tests.rs @@ -0,0 +1,48 @@ +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()); + } +} 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()); +} From a290bba95f87e81d2b9a6c950ef77d8eac36ebb3 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sat, 12 Sep 2026 19:49:32 -0700 Subject: [PATCH 02/13] fix(install): verify retained directory ownership during bootstrap Permission bits cannot establish per-user installation ownership. Read owner identity from the retained file handle and require the effective user to own every bootstrapped installation directory. Cover replacement-resistant metadata and a foreign-owned directory with otherwise safe permissions before accepting filesystem authority. --- crates/hypercolor-cli/src/install/store.rs | 1 + .../src/install/store_acquisition_tests.rs | 24 ++++++++++++ .../hypercolor-platform-fs/src/unix/tree.rs | 13 +++++++ .../src/unix/tree/traversal.rs | 1 + .../tests/metadata_owner_tests.rs | 37 +++++++++++++++++++ 5 files changed, 76 insertions(+) create mode 100644 crates/hypercolor-platform-fs/tests/metadata_owner_tests.rs diff --git a/crates/hypercolor-cli/src/install/store.rs b/crates/hypercolor-cli/src/install/store.rs index 53b4cf108..64784d072 100644 --- a/crates/hypercolor-cli/src/install/store.rs +++ b/crates/hypercolor-cli/src/install/store.rs @@ -504,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 { diff --git a/crates/hypercolor-cli/src/install/store_acquisition_tests.rs b/crates/hypercolor-cli/src/install/store_acquisition_tests.rs index 278d0d13c..1399ebd9d 100644 --- a/crates/hypercolor-cli/src/install/store_acquisition_tests.rs +++ b/crates/hypercolor-cli/src/install/store_acquisition_tests.rs @@ -46,3 +46,27 @@ fn bootstrap_ancestor_replacement_cannot_preserve_leaf_authority() { 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-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/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 Date: Sat, 12 Sep 2026 19:59:17 -0700 Subject: [PATCH 03/13] feat(install): define retained managed installation topology Record installation identity and fixed XDG roots without reconstructing paths from later environment changes. Reject invalid contracts and root overlap before filesystem authority can be used for adoption. Validate directory ownership and physical ancestry under the existing install lock, including replacement that preserves a leaf inode. Keep locator publication and activation outside this topology checkpoint. --- Cargo.lock | 1 + crates/hypercolor-cli/Cargo.toml | 1 + .../src/install/linux/location.rs | 304 ++++++++++++++++++ .../src/install/linux/location_tests.rs | 201 ++++++++++++ .../hypercolor-cli/src/install/linux/mod.rs | 4 + crates/hypercolor-cli/src/install/mod.rs | 11 +- .../src/unix/tree/replacement.rs | 1 + .../src/unix/tree/replacement/relationship.rs | 36 +++ .../tests/directory_relationship_tests.rs | 78 +++++ 9 files changed, 632 insertions(+), 5 deletions(-) create mode 100644 crates/hypercolor-cli/src/install/linux/location.rs create mode 100644 crates/hypercolor-cli/src/install/linux/location_tests.rs create mode 100644 crates/hypercolor-platform-fs/src/unix/tree/replacement/relationship.rs create mode 100644 crates/hypercolor-platform-fs/tests/directory_relationship_tests.rs 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/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/mod.rs b/crates/hypercolor-cli/src/install/linux/mod.rs index 314822cdd..b14c0c280 100644 --- a/crates/hypercolor-cli/src/install/linux/mod.rs +++ b/crates/hypercolor-cli/src/install/linux/mod.rs @@ -8,6 +8,9 @@ mod legacy; #[cfg(test)] mod legacy_tests; mod legacy_validation; +mod location; +#[cfg(test)] +mod location_tests; mod model; mod platform; mod proof; @@ -23,6 +26,7 @@ use super::{InstallLock, InstallPlatformError, InstallStore, UnitId, UnitRecord} pub use directory::LinuxPublicTree; pub use executor::{LinuxInstallExecutor, LinuxNativeExecutor, LinuxPublicEntry}; +pub use location::{InstallLocationError, LinuxInstallLocation, RetainedLinuxInstallLocation}; pub use model::{ LINUX_DIRECTORY_ITEMS, LINUX_LAYOUT_ITEMS, LinuxDirectoryItem, LinuxDirectoryState, LinuxExactEntry, LinuxFilePublication, LinuxHttpResponse, LinuxInstallConfig, LinuxLayoutItem, diff --git a/crates/hypercolor-cli/src/install/mod.rs b/crates/hypercolor-cli/src/install/mod.rs index 9cf91bf03..468c27676 100644 --- a/crates/hypercolor-cli/src/install/mod.rs +++ b/crates/hypercolor-cli/src/install/mod.rs @@ -13,11 +13,12 @@ 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, + InstallLocationError, LINUX_DIRECTORY_ITEMS, LINUX_LAYOUT_ITEMS, LinuxDirectoryItem, + LinuxDirectoryState, LinuxExactEntry, LinuxFilePublication, LinuxHttpResponse, + LinuxInstallConfig, LinuxInstallExecutor, LinuxInstallLocation, LinuxInstallPlatform, + LinuxLayoutItem, LinuxLayoutPublication, LinuxLegacyFile, LinuxLegacySnapshot, + LinuxNativeExecutor, LinuxProcessExecutable, LinuxPublicEntry, LinuxPublicTree, + LinuxSystemdConnection, LinuxSystemdObservation, RetainedLinuxInstallLocation, bind_linux_retained_unit, parse_systemd_show, retain_linux_unit, }; pub use model::{ 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/tests/directory_relationship_tests.rs b/crates/hypercolor-platform-fs/tests/directory_relationship_tests.rs new file mode 100644 index 000000000..6d76882fc --- /dev/null +++ b/crates/hypercolor-platform-fs/tests/directory_relationship_tests.rs @@ -0,0 +1,78 @@ +#![cfg(unix)] + +use std::fs; +use std::path::Path; + +use hypercolor_platform_fs::ExclusiveDirectory; + +#[test] +fn retained_relationship_distinguishes_equal_nested_and_sibling_directories() { + let fixture = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("fixture"); + fs::create_dir_all(fixture.path().join("data/releases")).expect("release"); + fs::create_dir(fixture.path().join("state")).expect("state"); + let gate = ExclusiveDirectory::try_acquire(fixture.path(), Path::new("lock")) + .expect("lock") + .expect("exclusive"); + let data = gate + .open_public_directory(&fixture.path().join("data")) + .expect("data"); + let release = gate + .open_public_directory(&fixture.path().join("data/releases")) + .expect("release"); + let same = gate + .open_public_directory(&fixture.path().join("data/releases")) + .expect("same"); + let state = gate + .open_public_directory(&fixture.path().join("state")) + .expect("state"); + assert!(release.is_within(&data).expect("nested")); + assert!(release.is_within(&same).expect("equal")); + assert!(!data.is_within(&release).expect("reverse")); + assert!(!state.is_within(&release).expect("sibling")); + assert!(!release.is_within(&state).expect("sibling")); +} + +#[test] +fn moved_ancestor_is_an_error_even_when_leaf_inode_survives() { + let fixture = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("fixture"); + let data_path = fixture.path().join("data"); + fs::create_dir_all(data_path.join("releases")).expect("release"); + let gate = ExclusiveDirectory::try_acquire(fixture.path(), Path::new("lock")) + .expect("lock") + .expect("exclusive"); + let data = gate.open_public_directory(&data_path).expect("data"); + let release = gate + .open_public_directory(&data_path.join("releases")) + .expect("release"); + fs::rename(&data_path, fixture.path().join("old")).expect("displace parent"); + fs::create_dir(&data_path).expect("replacement parent"); + fs::rename( + fixture.path().join("old/releases"), + data_path.join("releases"), + ) + .expect("preserve leaf inode"); + assert!(release.is_within(&data).is_err()); + assert!(data.is_within(&release).is_err()); +} + +#[test] +fn separate_operation_gates_cannot_be_combined_into_one_authority() { + let fixture = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("fixture"); + fs::create_dir(fixture.path().join("data")).expect("data"); + let left = ExclusiveDirectory::try_acquire(fixture.path(), Path::new("left.lock")) + .expect("left") + .expect("exclusive"); + let right = ExclusiveDirectory::try_acquire(fixture.path(), Path::new("right.lock")) + .expect("right") + .expect("exclusive"); + let left = left + .open_public_directory(&fixture.path().join("data")) + .expect("left data"); + let right = right + .open_public_directory(&fixture.path().join("data")) + .expect("right data"); + assert_eq!( + left.is_within(&right).expect_err("foreign gate").kind(), + std::io::ErrorKind::InvalidInput + ); +} From 69347980035aa8c6d9c23b60f38bbfd7d85e3b8a Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sat, 12 Sep 2026 20:29:13 -0700 Subject: [PATCH 04/13] feat(install): bind permanent locator publication to prepared state Persist exact legacy observations and the intended journal digest before creating managed transaction state. Revalidate the original platform and sync retained preparation files before publishing the managed locator. Reuse nonrollback file replacement so an ambiguous directory fsync never restores old authority. Require an explicit durability retry and reject unknown locators, unrelated orphan journals, and changed preparation. --- .../src/install/linux/locator.rs | 257 +++++++++++ .../src/install/linux/locator_preparation.rs | 175 +++++++ .../src/install/linux/locator_receipt.rs | 95 ++++ .../install/linux/locator_test_platform.rs | 75 +++ .../src/install/linux/locator_tests.rs | 435 ++++++++++++++++++ .../hypercolor-cli/src/install/linux/mod.rs | 3 + crates/hypercolor-cli/src/install/mod.rs | 11 +- crates/hypercolor-cli/src/install/store.rs | 3 + .../src/unix/tree/entry.rs | 15 +- .../src/unix/tree/entry_publication_tests.rs | 48 ++ 10 files changed, 1111 insertions(+), 6 deletions(-) create mode 100644 crates/hypercolor-cli/src/install/linux/locator.rs create mode 100644 crates/hypercolor-cli/src/install/linux/locator_preparation.rs create mode 100644 crates/hypercolor-cli/src/install/linux/locator_receipt.rs create mode 100644 crates/hypercolor-cli/src/install/linux/locator_test_platform.rs create mode 100644 crates/hypercolor-cli/src/install/linux/locator_tests.rs create mode 100644 crates/hypercolor-platform-fs/src/unix/tree/entry_publication_tests.rs 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..38b293155 --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/locator.rs @@ -0,0 +1,257 @@ +//! 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, RECEIPT_NAME}; + +#[path = "locator_preparation.rs"] +mod preparation; + +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 { + 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, &self.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_LOCATOR_BYTES + 1) + .read_to_end(&mut receipt_bytes)?; + if receipt_bytes.len() as u64 > MAX_LOCATOR_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()?; + 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..d46d075ac --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/locator_preparation.rs @@ -0,0 +1,175 @@ +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, RECEIPT_NAME}; +use super::{ + LinuxInstallAuthority, LinuxInstallLocation, LinuxInstallLocator, LinuxLocatorError, + MAX_LOCATOR_BYTES, +}; + +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_LOCATOR_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_LOCATOR_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)?; + 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(()) + } + + 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.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..c6f8b842c --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/locator_receipt.rs @@ -0,0 +1,95 @@ +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 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], + 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: 1, + installation_id, + journal_sha256: Sha256::digest(serde_json::to_vec(journal)?).into(), + 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..6388d5248 --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/locator_test_platform.rs @@ -0,0 +1,75 @@ +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 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! { + inspect() -> Result; + 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..e01cffa14 --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/locator_tests.rs @@ -0,0 +1,435 @@ +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 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()); + } +} diff --git a/crates/hypercolor-cli/src/install/linux/mod.rs b/crates/hypercolor-cli/src/install/linux/mod.rs index b14c0c280..f110aa032 100644 --- a/crates/hypercolor-cli/src/install/linux/mod.rs +++ b/crates/hypercolor-cli/src/install/linux/mod.rs @@ -11,6 +11,8 @@ mod legacy_validation; mod location; #[cfg(test)] mod location_tests; +mod locator; +mod locator_receipt; mod model; mod platform; mod proof; @@ -27,6 +29,7 @@ use super::{InstallLock, InstallPlatformError, InstallStore, UnitId, UnitRecord} pub use directory::LinuxPublicTree; pub use executor::{LinuxInstallExecutor, LinuxNativeExecutor, LinuxPublicEntry}; pub use location::{InstallLocationError, LinuxInstallLocation, RetainedLinuxInstallLocation}; +pub use locator::{LinuxInstallAuthority, LinuxInstallLocator, LinuxLocatorError}; pub use model::{ LINUX_DIRECTORY_ITEMS, LINUX_LAYOUT_ITEMS, LinuxDirectoryItem, LinuxDirectoryState, LinuxExactEntry, LinuxFilePublication, LinuxHttpResponse, LinuxInstallConfig, LinuxLayoutItem, diff --git a/crates/hypercolor-cli/src/install/mod.rs b/crates/hypercolor-cli/src/install/mod.rs index 468c27676..956242127 100644 --- a/crates/hypercolor-cli/src/install/mod.rs +++ b/crates/hypercolor-cli/src/install/mod.rs @@ -15,11 +15,12 @@ pub use coordinator::{ pub use linux::{ InstallLocationError, LINUX_DIRECTORY_ITEMS, LINUX_LAYOUT_ITEMS, LinuxDirectoryItem, LinuxDirectoryState, LinuxExactEntry, LinuxFilePublication, LinuxHttpResponse, - LinuxInstallConfig, LinuxInstallExecutor, LinuxInstallLocation, LinuxInstallPlatform, - LinuxLayoutItem, LinuxLayoutPublication, LinuxLegacyFile, LinuxLegacySnapshot, - LinuxNativeExecutor, LinuxProcessExecutable, LinuxPublicEntry, LinuxPublicTree, - LinuxSystemdConnection, LinuxSystemdObservation, RetainedLinuxInstallLocation, - bind_linux_retained_unit, parse_systemd_show, retain_linux_unit, + LinuxInstallAuthority, LinuxInstallConfig, LinuxInstallExecutor, LinuxInstallLocation, + LinuxInstallLocator, LinuxInstallPlatform, LinuxLayoutItem, LinuxLayoutPublication, + LinuxLegacyFile, LinuxLegacySnapshot, LinuxLocatorError, LinuxNativeExecutor, + LinuxProcessExecutable, LinuxPublicEntry, LinuxPublicTree, LinuxSystemdConnection, + LinuxSystemdObservation, RetainedLinuxInstallLocation, bind_linux_retained_unit, + parse_systemd_show, retain_linux_unit, }; pub use model::{ INSTALL_JOURNAL_SCHEMA_VERSION, InstallAction, InstallDisposition, InstallJournalV1, diff --git a/crates/hypercolor-cli/src/install/store.rs b/crates/hypercolor-cli/src/install/store.rs index 64784d072..8cef165d6 100644 --- a/crates/hypercolor-cli/src/install/store.rs +++ b/crates/hypercolor-cli/src/install/store.rs @@ -540,6 +540,9 @@ pub struct InstallLock { } 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 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" + ); +} From cbb550b17f54849ee45576928e6f22d5cf7829f2 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sat, 12 Sep 2026 20:40:07 -0700 Subject: [PATCH 05/13] refactor(install): expose validated journal preparation before publication Managed authority handoff must bind the exact intended transaction before writing its journal. Extract the existing platform preparation path so callers can persist that binding before entering normal recovery. Reject pending transactions and foreign locks before preparation. Keep ordinary install behavior and the existing recovery driver unchanged. --- .../hypercolor-cli/src/install/coordinator.rs | 33 ++++++++- .../tests/install_transaction_tests.rs | 72 +++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) 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/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(); From c7830c413c1b482d5139a2ef5ab28e6c59586ac3 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sat, 12 Sep 2026 20:52:55 -0700 Subject: [PATCH 06/13] feat(install): retain distinct prior and candidate unit authority Copied releases can share a digest while identifying different executable inodes. Bind the prior role through retained historical ancestry and keep candidate validation tied to the current store during owner proof. Reject copied or replaced prior authority and restore prior snapshot bytes from the original retained unit. Exercise same-ID different-inode proofs and native historical ancestry replacement without changing live services. --- .../src/install/linux/directory.rs | 19 ++ .../src/install/linux/executor.rs | 18 +- .../src/install/linux/executor_tests.rs | 78 ++++++++ .../hypercolor-cli/src/install/linux/mod.rs | 3 + .../src/install/linux/platform.rs | 10 +- .../hypercolor-cli/src/install/linux/prior.rs | 167 ++++++++++++++++++ .../hypercolor-cli/src/install/linux/proof.rs | 12 +- .../src/install/linux/validation.rs | 9 +- .../tests/linux_install_platform_tests.rs | 128 ++++++++++++++ 9 files changed, 426 insertions(+), 18 deletions(-) create mode 100644 crates/hypercolor-cli/src/install/linux/prior.rs 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/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/mod.rs b/crates/hypercolor-cli/src/install/linux/mod.rs index f110aa032..a2aa0b2b5 100644 --- a/crates/hypercolor-cli/src/install/linux/mod.rs +++ b/crates/hypercolor-cli/src/install/linux/mod.rs @@ -15,6 +15,7 @@ mod locator; mod locator_receipt; mod model; mod platform; +mod prior; mod proof; mod record; mod runtime; @@ -91,6 +92,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, } @@ -149,6 +151,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..0a34b8ab4 --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/prior.rs @@ -0,0 +1,167 @@ +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(()) + } + + 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), + } + } +} diff --git a/crates/hypercolor-cli/src/install/linux/proof.rs b/crates/hypercolor-cli/src/install/linux/proof.rs index c1ede9c67..b9de90dca 100644 --- a/crates/hypercolor-cli/src/install/linux/proof.rs +++ b/crates/hypercolor-cli/src/install/linux/proof.rs @@ -17,6 +17,14 @@ impl LinuxInstallPlatform { pub(super) fn unit_binding( &self, unit: &UnitRecord, + ) -> Result { + self.unit_binding_at(unit, &self.config.immutable_units_root) + } + + pub(super) fn unit_binding_at( + &self, + unit: &UnitRecord, + units_root: &Path, ) -> Result { let daemon = open_unit_file(unit, DAEMON_RELATIVE_PATH)?; let daemon_size = daemon.metadata().size(); @@ -36,9 +44,7 @@ impl LinuxInstallPlatform { .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 + let daemon_path = units_root .join(unit.id().as_str()) .join(DAEMON_RELATIVE_PATH) .to_str() 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/tests/linux_install_platform_tests.rs b/crates/hypercolor-cli/tests/linux_install_platform_tests.rs index 642ead3bf..44214f23e 100644 --- a/crates/hypercolor-cli/tests/linux_install_platform_tests.rs +++ b/crates/hypercolor-cli/tests/linux_install_platform_tests.rs @@ -39,6 +39,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, @@ -103,6 +104,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, @@ -242,6 +244,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> { @@ -983,6 +998,119 @@ fn unloaded_disabled_upgrade_preserves_service_state_and_user_data() { assert_eq!(fs::read(effects_sentinel).expect("effect"), b"effect"); } +#[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(); From 408575a7440f35a9a09486636c7269617a413ad4 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sat, 12 Sep 2026 21:06:51 -0700 Subject: [PATCH 07/13] feat(install): elect managed authority through the recorded state lock Treat the permanent locator as a hint until its identity, journal and retained roots are verified under the recorded state lock. Confirm the locator directory barrier before managed transitions and never bootstrap missing managed state or reacquire the historical lock after election. Recheck legacy hints under the historical lock to preserve old-then-state ordering across concurrent adoption. Refuse writable journals and locator files while allowing ordinary atomic journal replacement during recovery. --- .../src/install/linux/election.rs | 182 ++++++++++++++ .../src/install/linux/locator.rs | 14 +- .../src/install/linux/locator_tests.rs | 233 ++++++++++++++++++ .../hypercolor-cli/src/install/linux/mod.rs | 5 +- crates/hypercolor-cli/src/install/mod.rs | 12 +- 5 files changed, 438 insertions(+), 8 deletions(-) create mode 100644 crates/hypercolor-cli/src/install/linux/election.rs 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..e22036de6 --- /dev/null +++ b/crates/hypercolor-cli/src/install/linux/election.rs @@ -0,0 +1,182 @@ +//! 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 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(LinuxInstallElection::Managed { + store, + lock, + 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/locator.rs b/crates/hypercolor-cli/src/install/linux/locator.rs index 38b293155..8671116ba 100644 --- a/crates/hypercolor-cli/src/install/linux/locator.rs +++ b/crates/hypercolor-cli/src/install/linux/locator.rs @@ -13,8 +13,11 @@ use super::super::{ use super::location::{InstallLocationError, LinuxInstallLocation}; use super::locator_receipt::{AdoptionPreparation, 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; @@ -90,6 +93,13 @@ impl LinuxInstallLocator { } 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)); }; @@ -106,7 +116,7 @@ impl LinuxInstallLocator { Ok(LinuxInstallAuthority::Legacy(Some(journal))) } Some(2) => Ok(LinuxInstallAuthority::Managed(LinuxInstallLocation::parse( - &bytes, &self.home, + &bytes, home, )?)), _ => Err(LinuxLocatorError::InvalidLocator), } @@ -226,6 +236,8 @@ impl LinuxInstallLocator { _ => 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()? { diff --git a/crates/hypercolor-cli/src/install/linux/locator_tests.rs b/crates/hypercolor-cli/src/install/linux/locator_tests.rs index e01cffa14..23b8bdeae 100644 --- a/crates/hypercolor-cli/src/install/linux/locator_tests.rs +++ b/crates/hypercolor-cli/src/install/linux/locator_tests.rs @@ -433,3 +433,236 @@ fn writable_preparation_files_cannot_authorize_locator_publication() { 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 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 a2aa0b2b5..758aa775c 100644 --- a/crates/hypercolor-cli/src/install/linux/mod.rs +++ b/crates/hypercolor-cli/src/install/linux/mod.rs @@ -30,7 +30,10 @@ use super::{InstallLock, InstallPlatformError, InstallStore, UnitId, UnitRecord} pub use directory::LinuxPublicTree; pub use executor::{LinuxInstallExecutor, LinuxNativeExecutor, LinuxPublicEntry}; pub use location::{InstallLocationError, LinuxInstallLocation, RetainedLinuxInstallLocation}; -pub use locator::{LinuxInstallAuthority, LinuxInstallLocator, LinuxLocatorError}; +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, diff --git a/crates/hypercolor-cli/src/install/mod.rs b/crates/hypercolor-cli/src/install/mod.rs index 956242127..3fd7c8c7a 100644 --- a/crates/hypercolor-cli/src/install/mod.rs +++ b/crates/hypercolor-cli/src/install/mod.rs @@ -15,12 +15,12 @@ pub use coordinator::{ pub use linux::{ InstallLocationError, LINUX_DIRECTORY_ITEMS, LINUX_LAYOUT_ITEMS, LinuxDirectoryItem, LinuxDirectoryState, LinuxExactEntry, LinuxFilePublication, LinuxHttpResponse, - LinuxInstallAuthority, LinuxInstallConfig, LinuxInstallExecutor, LinuxInstallLocation, - LinuxInstallLocator, LinuxInstallPlatform, LinuxLayoutItem, LinuxLayoutPublication, - LinuxLegacyFile, LinuxLegacySnapshot, LinuxLocatorError, LinuxNativeExecutor, - LinuxProcessExecutable, LinuxPublicEntry, LinuxPublicTree, LinuxSystemdConnection, - LinuxSystemdObservation, RetainedLinuxInstallLocation, bind_linux_retained_unit, - parse_systemd_show, retain_linux_unit, + 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, From 1898024e7d1a6adc30aa108419cd3e2618be27c6 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sat, 12 Sep 2026 21:13:42 -0700 Subject: [PATCH 08/13] feat(install): copy retained installed releases across store roots Installed trees have immutable modes that differ from extracted release payloads. Reuse the verified staging path with an explicit installed-source mode instead of changing permissions or moving the original release. Validate the source before and after copying, publish only within the new store filesystem, and verify any existing destination before reuse. Cover different-device copying and refusal of altered source or target. --- crates/hypercolor-cli/src/install/mod.rs | 6 +- .../hypercolor-cli/src/install/payload/mod.rs | 45 +++++++- .../src/install/payload/tree.rs | 18 +++- .../tests/install_payload_tests.rs | 100 +++++++++++++++++- 4 files changed, 157 insertions(+), 12 deletions(-) diff --git a/crates/hypercolor-cli/src/install/mod.rs b/crates/hypercolor-cli/src/install/mod.rs index 3fd7c8c7a..5dea1924b 100644 --- a/crates/hypercolor-cli/src/install/mod.rs +++ b/crates/hypercolor-cli/src/install/mod.rs @@ -33,9 +33,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..91d7d235a 100644 --- a/crates/hypercolor-cli/src/install/payload/mod.rs +++ b/crates/hypercolor-cli/src/install/payload/mod.rs @@ -82,7 +82,48 @@ 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 = + 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)?; + stage_validated_payload( + store, + lock, + source.directory(), + manifest, + tree::TreeMode::Installed, + ) +} + +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 +150,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/tests/install_payload_tests.rs b/crates/hypercolor-cli/tests/install_payload_tests.rs index 1fb16e11f..ffa496a0a 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,99 @@ 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); +} From bf5389a854aeeba2bcffb8183a68d7d10b6f5b25 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sat, 12 Sep 2026 21:24:37 -0700 Subject: [PATCH 09/13] feat(install): restore recorded historical prior authority on recovery Select historical prior authority from its exact persisted executable path, then validate the original installed tree and executable identity. Keep current-store and synthetic legacy roles on their existing validators. Share immutable binding proof with normal platform validation and validate the complete record after restoring its prior role. Cover copied-inode, foreign-path, manifest-mode and retained-ancestor refusal. --- .../hypercolor-cli/src/install/linux/prior.rs | 75 +++++++++++ .../hypercolor-cli/src/install/linux/proof.rs | 73 ++++++----- .../hypercolor-cli/src/install/payload/mod.rs | 27 ++-- .../tests/install_payload_tests.rs | 117 ++++++++++++++++++ 4 files changed, 252 insertions(+), 40 deletions(-) diff --git a/crates/hypercolor-cli/src/install/linux/prior.rs b/crates/hypercolor-cli/src/install/linux/prior.rs index 0a34b8ab4..76d7b2c84 100644 --- a/crates/hypercolor-cli/src/install/linux/prior.rs +++ b/crates/hypercolor-cli/src/install/linux/prior.rs @@ -71,6 +71,64 @@ impl LinuxNativeExecutor { 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, @@ -165,3 +223,20 @@ impl LinuxInstallPlatform { } } } + +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 b9de90dca..016f4af1e 100644 --- a/crates/hypercolor-cli/src/install/linux/proof.rs +++ b/crates/hypercolor-cli/src/install/linux/proof.rs @@ -26,39 +26,7 @@ impl LinuxInstallPlatform { 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, - }) + retained_unit_binding(unit, units_root) } pub(super) fn candidate_launcher(&self) -> Result { @@ -277,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/payload/mod.rs b/crates/hypercolor-cli/src/install/payload/mod.rs index 91d7d235a..ed7471557 100644 --- a/crates/hypercolor-cli/src/install/payload/mod.rs +++ b/crates/hypercolor-cli/src/install/payload/mod.rs @@ -99,6 +99,25 @@ pub fn copy_installed_release_unit( 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() { @@ -108,13 +127,7 @@ pub fn copy_installed_release_unit( }); } tree::validate_copy_source(source.directory(), &manifest, tree::TreeMode::Installed)?; - stage_validated_payload( - store, - lock, - source.directory(), - manifest, - tree::TreeMode::Installed, - ) + Ok(manifest) } fn stage_validated_payload( diff --git a/crates/hypercolor-cli/tests/install_payload_tests.rs b/crates/hypercolor-cli/tests/install_payload_tests.rs index ffa496a0a..2a47cd645 100644 --- a/crates/hypercolor-cli/tests/install_payload_tests.rs +++ b/crates/hypercolor-cli/tests/install_payload_tests.rs @@ -1251,3 +1251,120 @@ fn installed_copy_crosses_filesystems_without_renaming_source() { ); 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() + ); +} + +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") +} From 24fc07256c07ba23243495e8420a11c3479f9ceb Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sat, 12 Sep 2026 21:27:01 -0700 Subject: [PATCH 10/13] test(install): clean immutable cold-recovery fixture directories Restore directory write permission only inside the completed synthetic fixture so TempDir can remove immutable installed trees. Inspect entries without following symlinks and keep failed fixtures available for diagnosis. --- .../hypercolor-cli/tests/install_payload_tests.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/hypercolor-cli/tests/install_payload_tests.rs b/crates/hypercolor-cli/tests/install_payload_tests.rs index 2a47cd645..bf40c430e 100644 --- a/crates/hypercolor-cli/tests/install_payload_tests.rs +++ b/crates/hypercolor-cli/tests/install_payload_tests.rs @@ -1342,6 +1342,7 @@ fn cold_recorded_prior_selects_exact_historical_path_and_original_inode() { .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 { @@ -1368,3 +1369,15 @@ fn cold_prior_record(binding: &Value) -> hypercolor_cli::install::PlatformTransa ) .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()); + } +} From 19cd5cec53b05bb793c1f76e936e3e3273aa89ba Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sat, 12 Sep 2026 21:54:57 -0700 Subject: [PATCH 11/13] feat(install): orchestrate durable managed authority adoption Retain old then state locks while copying the original release and preparing recorded roots. Persist the exact initial journal before its state copy so interrupted preparation resumes the same transaction. Publish the permanent locator only after fresh prior-platform proof and durability checks. Cold recovery uses the existing coordinator and keeps the original inode and public paths available for rollback. --- .../src/install/linux/adoption.rs | 211 +++++++++++++ .../src/install/linux/adoption_roots.rs | 142 +++++++++ .../src/install/linux/election.rs | 86 +++--- .../src/install/linux/locator.rs | 6 +- .../src/install/linux/locator_preparation.rs | 54 +++- .../src/install/linux/locator_receipt.rs | 6 +- .../install/linux/locator_test_platform.rs | 10 +- .../src/install/linux/locator_tests.rs | 163 ++++++++++ .../hypercolor-cli/src/install/linux/mod.rs | 3 + crates/hypercolor-cli/src/install/mod.rs | 17 +- .../tests/linux_install_platform_tests.rs | 285 +++++++++++++++++- 11 files changed, 925 insertions(+), 58 deletions(-) create mode 100644 crates/hypercolor-cli/src/install/linux/adoption.rs create mode 100644 crates/hypercolor-cli/src/install/linux/adoption_roots.rs 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/election.rs b/crates/hypercolor-cli/src/install/linux/election.rs index e22036de6..2a65e6b3b 100644 --- a/crates/hypercolor-cli/src/install/linux/election.rs +++ b/crates/hypercolor-cli/src/install/linux/election.rs @@ -115,40 +115,7 @@ fn elect_managed( )?; // Split-root acquire_lock opens existing roots; it does not bootstrap them. let lock = store.acquire_lock()?; - 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()?; + let authority = LinuxManagedAuthority::retain(home, &store, &lock, location)?; Ok(LinuxInstallElection::Managed { store, lock, @@ -156,6 +123,57 @@ fn elect_managed( }) } +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, diff --git a/crates/hypercolor-cli/src/install/linux/locator.rs b/crates/hypercolor-cli/src/install/linux/locator.rs index 8671116ba..b85d9179d 100644 --- a/crates/hypercolor-cli/src/install/linux/locator.rs +++ b/crates/hypercolor-cli/src/install/linux/locator.rs @@ -11,7 +11,7 @@ use super::super::{ InstallPlatformError, InstallStore, InstallStoreError, MAX_INSTALL_JOURNAL_BYTES, }; use super::location::{InstallLocationError, LinuxInstallLocation}; -use super::locator_receipt::{AdoptionPreparation, RECEIPT_NAME}; +use super::locator_receipt::{AdoptionPreparation, MAX_PREPARATION_BYTES, RECEIPT_NAME}; #[path = "election.rs"] mod election; @@ -169,9 +169,9 @@ impl LinuxInstallLocator { let mut receipt_bytes = Vec::new(); receipt .file_mut() - .take(MAX_LOCATOR_BYTES + 1) + .take(MAX_PREPARATION_BYTES + 1) .read_to_end(&mut receipt_bytes)?; - if receipt_bytes.len() as u64 > MAX_LOCATOR_BYTES + if receipt_bytes.len() as u64 > MAX_PREPARATION_BYTES || serde_json::from_slice::(&receipt_bytes)? != expected { return Err(LinuxLocatorError::Unprepared); diff --git a/crates/hypercolor-cli/src/install/linux/locator_preparation.rs b/crates/hypercolor-cli/src/install/linux/locator_preparation.rs index d46d075ac..a4952549b 100644 --- a/crates/hypercolor-cli/src/install/linux/locator_preparation.rs +++ b/crates/hypercolor-cli/src/install/linux/locator_preparation.rs @@ -6,11 +6,8 @@ use crate::install::{ InstallStore, PlatformCheckpoint, }; -use super::super::locator_receipt::{AdoptionPreparation, RECEIPT_NAME}; -use super::{ - LinuxInstallAuthority, LinuxInstallLocation, LinuxInstallLocator, LinuxLocatorError, - MAX_LOCATOR_BYTES, -}; +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. @@ -40,9 +37,9 @@ impl LinuxInstallLocator { require_file_owner(file.metadata(), location.uid())?; let mut bytes = Vec::new(); file.file_mut() - .take(MAX_LOCATOR_BYTES + 1) + .take(MAX_PREPARATION_BYTES + 1) .read_to_end(&mut bytes)?; - if bytes.len() as u64 > MAX_LOCATOR_BYTES + if bytes.len() as u64 > MAX_PREPARATION_BYTES || serde_json::from_slice::(&bytes)? != expected { return Err(LinuxLocatorError::Unprepared); @@ -59,6 +56,9 @@ impl LinuxInstallLocator { 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()? @@ -82,6 +82,43 @@ impl LinuxInstallLocator { 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, @@ -149,6 +186,9 @@ pub(super) fn validate_platform( 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, diff --git a/crates/hypercolor-cli/src/install/linux/locator_receipt.rs b/crates/hypercolor-cli/src/install/linux/locator_receipt.rs index c6f8b842c..ac1c3691c 100644 --- a/crates/hypercolor-cli/src/install/linux/locator_receipt.rs +++ b/crates/hypercolor-cli/src/install/linux/locator_receipt.rs @@ -8,6 +8,8 @@ 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)] @@ -16,6 +18,7 @@ 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, } @@ -37,9 +40,10 @@ impl AdoptionPreparation { return Err(LinuxLocatorError::InvalidLocator); } Ok(Self { - schema_version: 1, + 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), }) diff --git a/crates/hypercolor-cli/src/install/linux/locator_test_platform.rs b/crates/hypercolor-cli/src/install/linux/locator_test_platform.rs index 6388d5248..858bc966b 100644 --- a/crates/hypercolor-cli/src/install/linux/locator_test_platform.rs +++ b/crates/hypercolor-cli/src/install/linux/locator_test_platform.rs @@ -23,6 +23,15 @@ macro_rules! unexpected_mutations { } 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, @@ -49,7 +58,6 @@ impl InstallPlatform for PriorProof { } unexpected_mutations! { - inspect() -> Result; prepare_transaction(_candidate: &UnitRecord, _prior: &InstallationState, _target: &PlatformState) -> Result; capture_candidate_owner_receipt(_expected: &PlatformState, diff --git a/crates/hypercolor-cli/src/install/linux/locator_tests.rs b/crates/hypercolor-cli/src/install/linux/locator_tests.rs index 23b8bdeae..afb977637 100644 --- a/crates/hypercolor-cli/src/install/linux/locator_tests.rs +++ b/crates/hypercolor-cli/src/install/linux/locator_tests.rs @@ -406,6 +406,103 @@ fn identical_preparation_reuses_the_durable_receipt_without_replacing_it() { 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 _; @@ -545,6 +642,72 @@ fn legacy_election_holds_the_original_install_lock() { 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(); diff --git a/crates/hypercolor-cli/src/install/linux/mod.rs b/crates/hypercolor-cli/src/install/linux/mod.rs index 758aa775c..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; @@ -27,6 +29,7 @@ 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}; diff --git a/crates/hypercolor-cli/src/install/mod.rs b/crates/hypercolor-cli/src/install/mod.rs index 5dea1924b..650a80a16 100644 --- a/crates/hypercolor-cli/src/install/mod.rs +++ b/crates/hypercolor-cli/src/install/mod.rs @@ -13,14 +13,15 @@ pub use coordinator::{ }; #[cfg(unix)] pub use linux::{ - InstallLocationError, LINUX_DIRECTORY_ITEMS, LINUX_LAYOUT_ITEMS, 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, + 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, diff --git a/crates/hypercolor-cli/tests/linux_install_platform_tests.rs b/crates/hypercolor-cli/tests/linux_install_platform_tests.rs index 44214f23e..7ff59cacd 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, @@ -78,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, @@ -209,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", )); @@ -356,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); } @@ -410,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) @@ -456,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()) @@ -998,6 +1044,224 @@ 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, [copied]) + .expect("recovery platform") + .with_prior_unit(original) + .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") + ) + ); + 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(); @@ -2098,6 +2362,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") } From 8e7e23544ec550721859a24b2f5b75577c9ce088 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sat, 12 Sep 2026 22:29:44 -0700 Subject: [PATCH 12/13] feat(install): route Linux commands through managed authority Elect recorded authority before staging and recover pending journals before attempting a new release. Preserve historical prior bindings only while rollback or interrupted adoption still requires the original unit. Restore recorded prior authority once during cold native construction. Exercise that constructor against retained original and copied releases, and preserve strict candidate validation before installer bootstrap. --- crates/hypercolor-cli/src/install_command.rs | 154 +--------- .../src/install_command/linux.rs | 284 ++++++++++++++++++ .../install_command/linux_binding_tests.rs | 217 +++++++++++++ .../src/install_command/linux_tests.rs | 76 +++++ .../tests/linux_install_platform_tests.rs | 26 +- 5 files changed, 608 insertions(+), 149 deletions(-) create mode 100644 crates/hypercolor-cli/src/install_command/linux.rs create mode 100644 crates/hypercolor-cli/src/install_command/linux_binding_tests.rs create mode 100644 crates/hypercolor-cli/src/install_command/linux_tests.rs 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..d7093cb96 --- /dev/null +++ b/crates/hypercolor-cli/src/install_command/linux_binding_tests.rs @@ -0,0 +1,217 @@ +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/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(), + ), + ]; + 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,"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..4ff80329b --- /dev/null +++ b/crates/hypercolor-cli/src/install_command/linux_tests.rs @@ -0,0 +1,76 @@ +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, + }; + 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/linux_install_platform_tests.rs b/crates/hypercolor-cli/tests/linux_install_platform_tests.rs index 7ff59cacd..8532881ca 100644 --- a/crates/hypercolor-cli/tests/linux_install_platform_tests.rs +++ b/crates/hypercolor-cli/tests/linux_install_platform_tests.rs @@ -1208,9 +1208,9 @@ fn cold_managed_adoption(journal_written: bool, rollback: bool) { Some(id.clone()) ); let copied = retain_linux_unit(&store, &lock, &id).expect("managed unit"); - let mut platform = LinuxInstallPlatform::new(executor, new_config, [copied]) + let mut platform = LinuxInstallPlatform::new(executor, new_config.clone(), [copied.clone()]) .expect("recovery platform") - .with_prior_unit(original) + .with_prior_unit(original.clone()) .expect("recorded original role"); let result = InstallCoordinator::new(&store, &mut platform).recover_with_lock(&mut lock); if rollback { @@ -1256,6 +1256,28 @@ fn cold_managed_adoption(journal_written: bool, rollback: bool) { .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); From c15ccd69c1114a0e95a4b91237ce57ef4ce9c1c5 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 13 Sep 2026 10:14:35 -0700 Subject: [PATCH 13/13] test(install): align managed authority fixtures with the v0.5.1 manifest The v0.5.1 release added a validate_only flag to InstallReleaseArgs and made user_skill_files mandatory for candidate manifests after this branch forked. The rebased legacy-authority fixture no longer compiled, and the binding fixture's release was rejected as missing the user skills tree. Both fixtures now match the shape the installer requires. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014466GJcZeEz2om6JDZ3Chm --- .../hypercolor-cli/src/install_command/linux_binding_tests.rs | 4 +++- crates/hypercolor-cli/src/install_command/linux_tests.rs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/hypercolor-cli/src/install_command/linux_binding_tests.rs b/crates/hypercolor-cli/src/install_command/linux_binding_tests.rs index d7093cb96..bd1ba1a38 100644 --- a/crates/hypercolor-cli/src/install_command/linux_binding_tests.rs +++ b/crates/hypercolor-cli/src/install_command/linux_binding_tests.rs @@ -158,6 +158,7 @@ fn write_release(root: &Path) -> File { "share/hypercolor/agents", "share/hypercolor/agents/skills", "share/hypercolor/agents/agents", + "share/hypercolor/skills", "share/hypercolor/site", ]; let files = [ @@ -179,6 +180,7 @@ fn write_release(root: &Path) -> File { "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 { @@ -203,7 +205,7 @@ fn write_release(root: &Path) -> File { "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,"agent_files":1,"site_files":0}, + "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"); diff --git a/crates/hypercolor-cli/src/install_command/linux_tests.rs b/crates/hypercolor-cli/src/install_command/linux_tests.rs index 4ff80329b..1390cacb1 100644 --- a/crates/hypercolor-cli/src/install_command/linux_tests.rs +++ b/crates/hypercolor-cli/src/install_command/linux_tests.rs @@ -64,6 +64,7 @@ fn invalid_permanent_locator_refuses_normal_and_no_service_before_staging() { 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");