From 15727dd5333d3134b4da8c106b3e841eb2ead07b Mon Sep 17 00:00:00 2001 From: FeathBow Date: Fri, 21 Aug 2026 23:04:14 +0100 Subject: [PATCH] feat(core): bound sealed-tree descriptors and raise directory ceiling --- crates/degu-core/src/backend.rs | 1 + crates/degu-core/src/backend/held.rs | 808 ++++++++++++++--- crates/degu-core/src/backend/held/tests.rs | 459 +++++++++- crates/degu-core/src/seal/wal.rs | 4 + crates/degu-core/src/staging.rs | 16 +- crates/degu-core/src/staging/recovery.rs | 846 ++++++++++++------ .../degu-core/src/staging/recovery/tests.rs | 197 ++-- crates/degu-core/src/staging/rename.rs | 6 +- crates/degu-core/src/staging/rename/tests.rs | 372 +++++++- crates/degu-core/src/staging/tests.rs | 7 +- .../tests/held_tree_policy_facade.rs | 21 +- crates/degu/src/lifecycle/stage/production.rs | 29 +- crates/degu/tests/clean/lifecycle.rs | 70 +- crates/degu/tests/clean/sealed_admission.rs | 17 +- 14 files changed, 2328 insertions(+), 525 deletions(-) diff --git a/crates/degu-core/src/backend.rs b/crates/degu-core/src/backend.rs index 74e1f6d..3961d05 100644 --- a/crates/degu-core/src/backend.rs +++ b/crates/degu-core/src/backend.rs @@ -546,6 +546,7 @@ fn map_tree_assessment_failure(e: held::HeldTreeError) -> HeldTreeAssessmentFail use held::{HeldTreeError as E, HeldTreeLimit as L}; let (kind, path) = match e { E::InvalidRoot => (HeldTreeAssessmentFailureKind::InvalidRoot, None), + E::InvalidDirectoryPath(p) => (HeldTreeAssessmentFailureKind::IdentityChanged, Some(p)), E::InvalidDirectoryLimit => (HeldTreeAssessmentFailureKind::InvalidDirectoryLimit, None), E::Limit { kind, .. } => ( match kind { diff --git a/crates/degu-core/src/backend/held.rs b/crates/degu-core/src/backend/held.rs index eac7c23..37ac3c9 100644 --- a/crates/degu-core/src/backend/held.rs +++ b/crates/degu-core/src/backend/held.rs @@ -19,7 +19,9 @@ use crate::seal::executor::{ LocalModeExecutionError, LocalModeMutationRequest, LocalModeMutationResult, LocalModeTransform, RecoveryLocator, execute_staging_local_mode_mutation, }; -use crate::seal::wal::{DurableWrite, SealWal, StrongObjectIdentity, TransactionId}; +use crate::seal::wal::{ + DurableWrite, RECOVERY_MAX_ACTIVE_PERMISSIONS, SealWal, StrongObjectIdentity, TransactionId, +}; use rustix::fs::{AtFlags, Dir, FileType, Mode, OFlags}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; @@ -33,7 +35,11 @@ const OPEN_DIRECTORY: OFlags = OFlags::RDONLY .union(OFlags::DIRECTORY) .union(OFlags::NOFOLLOW) .union(OFlags::CLOEXEC); -const HARD_DIRECTORY_CAP: u64 = 1_024; +/// Recovery may retain this many active permission operations internally. +/// Tree requests must reserve one operation for the source-parent seal, so the +/// root-inclusive request ceiling is one lower for every schema and traversal. +const HARD_DIRECTORY_CAP: u64 = RECOVERY_MAX_ACTIVE_PERMISSIONS as u64; +pub(crate) const MAX_TREE_DIRECTORIES: u64 = HARD_DIRECTORY_CAP - 1; const MANIFEST_DOMAIN_V1: &[u8] = b"degu-held-tree-manifest-v1\0"; const MANIFEST_DOMAIN_V2: &[u8] = b"degu-held-tree-manifest-v2-content\0"; const CONTENT_PROOF_VERSION: u16 = 2; @@ -44,12 +50,93 @@ const PURGE_IO_ATTEMPTS: usize = 3; const XATTR_LIST_ATTEMPTS: usize = 3; const MAX_XATTR_NAME_LIST_BYTES: usize = 64 * 1024; const MAX_XATTR_NAMES: usize = 1_024; +#[cfg(test)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ReopenerTestPhase { + AfterOpenBeforeValidation, + AfterValidatedHopBeforeNextOperation, +} + +#[cfg(test)] +type ReopenerTestCallback = Box; +#[cfg(test)] +type TransientSealTestCallback = Box; + #[cfg(test)] std::thread_local! { pub(crate) static PURGE_FAIL_AFTER_REMOVALS: std::cell::Cell> = const { std::cell::Cell::new(None) }; pub(crate) static PURGE_FAIL_PARENT_FSYNC: std::cell::Cell = const { std::cell::Cell::new(false) }; + static REOPENER_MAX_NON_ROOT_FDS: std::cell::Cell = + const { std::cell::Cell::new(0) }; + static REGULAR_CONTENT_BYTES_READ: std::cell::Cell = + const { std::cell::Cell::new(0) }; + static REOPENER_TEST_CALLBACK: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; + static BEFORE_TRANSIENT_SEAL: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +pub(crate) struct ReopenerTestHookGuard; + +#[cfg(test)] +impl Drop for ReopenerTestHookGuard { + fn drop(&mut self) { + REOPENER_TEST_CALLBACK.with(|slot| *slot.borrow_mut() = None); + } +} + +#[cfg(test)] +pub(crate) fn install_transient_seal_test_hook(callback: impl FnOnce(&Path) + 'static) { + BEFORE_TRANSIENT_SEAL.with(|slot| { + let previous = slot.borrow_mut().replace(Box::new(callback)); + assert!( + previous.is_none(), + "a transient seal test hook is already installed" + ); + }); +} + +#[cfg(test)] +fn fire_transient_seal_test_hook(path: &Path) { + BEFORE_TRANSIENT_SEAL.with(|slot| { + if let Some(callback) = slot.borrow_mut().take() { + callback(path); + } + }); +} + +#[cfg(test)] +pub(crate) fn install_reopener_test_hook( + callback: impl FnMut(ReopenerTestPhase, &Path) + 'static, +) -> ReopenerTestHookGuard { + REOPENER_TEST_CALLBACK.with(|slot| { + let previous = slot.borrow_mut().replace(Box::new(callback)); + assert!( + previous.is_none(), + "a reopener test hook is already installed" + ); + }); + ReopenerTestHookGuard +} + +#[cfg(test)] +fn fire_reopener_test_hook(phase: ReopenerTestPhase, path: &Path) { + // Temporarily remove the callback so fixture namespace operations cannot + // accidentally re-enter a mutably borrowed thread-local hook. + let callback = REOPENER_TEST_CALLBACK.with(|slot| slot.borrow_mut().take()); + if let Some(mut callback) = callback { + callback(phase, path); + REOPENER_TEST_CALLBACK.with(|slot| { + let previous = slot.borrow_mut().replace(callback); + assert!( + previous.is_none(), + "reopener test hook was replaced while firing" + ); + }); + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -67,7 +154,7 @@ impl Default for HeldTreeLimits { fn default() -> Self { Self { max_entries: 100_000, - max_directories: 256, + max_directories: MAX_TREE_DIRECTORIES, max_depth: 128, max_path_bytes: 16 * 1024 * 1024, max_manifest_bytes: 64 * 1024 * 1024, @@ -102,7 +189,9 @@ impl From for HeldTreePurgeError { pub(crate) enum HeldTreeError { #[error("held tree root must be exactly one normal component")] InvalidRoot, - #[error("requested directory limit exceeds the hard FD cap")] + #[error("directory evidence path contains a non-normal relative component at {0}")] + InvalidDirectoryPath(PathBuf), + #[error("requested tree directory limit exceeds 1023 total directories (including the root)")] InvalidDirectoryLimit, #[error("held tree exceeded its {kind:?} limit of {limit}")] Limit { kind: HeldTreeLimit, limit: u64 }, @@ -155,6 +244,8 @@ pub(crate) enum HeldTreeError { #[derive(Debug, thiserror::Error)] pub(crate) enum HeldTreeSealError { + #[error("held tree seal validation failed: {0}")] + Tree(#[from] HeldTreeError), #[error("tree seal mutation id space is exhausted")] MutationIdExhausted, #[error("held tree seal failed at {path}: {source}")] @@ -338,17 +429,67 @@ pub(crate) struct HeldDirectoryOrder { pub observed_mode: u32, } +/// Descriptor-free evidence for reopening one collected directory from the +/// retained certified root. This is identity evidence only and grants no +/// namespace or mutation authority. +#[derive(Clone, Debug, Eq, PartialEq)] +struct DirectoryEvidence { + relative_path: PathBuf, + depth: u32, + identity: NodeIdentity, + owner_uid: u32, + group_gid: u32, + observed_mode: u32, +} + #[derive(Debug)] struct HeldDirectory { held: HeldLocalBackendEvidence, - path: PathBuf, - depth: u32, - incarnation: u64, + evidence: DirectoryEvidence, +} + +impl HeldDirectory { + fn new( + held: HeldLocalBackendEvidence, + relative_path: PathBuf, + depth: u32, + identity: NodeIdentity, + ) -> Self { + let evidence = DirectoryEvidence { + relative_path, + depth, + identity, + owner_uid: held.owner_uid(), + group_gid: held.group_gid(), + observed_mode: held.mode(), + }; + Self { held, evidence } + } +} + +#[derive(Debug)] +enum ReopenedHeldDirectory<'a> { + Root(&'a HeldLocalBackendEvidence), + Descendant(HeldLocalBackendEvidence), +} + +#[derive(Debug)] +struct ReopenedDirectory<'a> { + held: ReopenedHeldDirectory<'a>, +} + +impl ReopenedDirectory<'_> { + fn held(&self) -> &HeldLocalBackendEvidence { + match &self.held { + ReopenedHeldDirectory::Root(held) => held, + ReopenedHeldDirectory::Descendant(held) => held, + } + } } -/// Private bounded inventory retaining all directory FDs for exact rewalk and -/// lease-bound minimal sealing. By itself it carries no WAL lease and is not -/// rename, restore, purge, or deletion authority. +/// Private bounded inventory retaining the source parent and tree root as +/// reopen anchors. Descendant directories are data-only evidence; transient +/// certified descriptors provide exact rewalk, sealing, and purge authority. #[derive(Debug)] pub(crate) struct HeldTreeInventory { parent: HeldLocalBackendEvidence, @@ -359,7 +500,12 @@ pub(crate) struct HeldTreeInventory { protected_names: Vec, limits: HeldTreeLimits, manifest_schema: u16, - directories: Vec, + /// Prevalidated lookup evidence. `directories` remains in BFS order for + /// deterministic mutation ordering; it contains no retained descriptors. + /// The separately retained `root` is the only tree-directory authority. + directory_index: BTreeMap, + directories: Vec, + root: HeldDirectory, manifest: Vec, } @@ -453,15 +599,22 @@ impl V2Traversal for AssessTraversal { } } +#[derive(Debug)] +struct WalkDirectory { + evidence: DirectoryEvidence, +} + struct V2Walk { parent: HeldLocalBackendEvidence, root_name: OsString, root_identity: NodeIdentity, + root: HeldDirectory, backend: CertifiedLocalBackend, mount_id: u64, protected_names: Vec, limits: HeldTreeLimits, - directories: Vec, + directory_index: BTreeMap, + directories: Vec, entries: Vec, budget: Budget, } @@ -473,7 +626,7 @@ fn validate_v2_inputs( ) -> Result<(), HeldTreeError> { require_one_component(root_name)?; validate_policy(protected_names)?; - if limits.max_directories > HARD_DIRECTORY_CAP { + if limits.max_directories > MAX_TREE_DIRECTORIES { return Err(HeldTreeError::InvalidDirectoryLimit); } if protected_names.iter().any(|name| name == root_name) { @@ -495,6 +648,13 @@ fn collect_proven_v2( limits, ParentAdmission::CurrentExclusive, )?; + let root = walked.root; + let directories = walked + .directories + .into_iter() + .map(|directory| directory.evidence) + .collect::>(); + let directory_index = build_directory_index(&directories)?; Ok(HeldTreeInventory { parent: walked.parent, root_name: walked.root_name, @@ -504,7 +664,9 @@ fn collect_proven_v2( protected_names: walked.protected_names, limits: walked.limits, manifest_schema: CONTENT_PROOF_VERSION, - directories: walked.directories, + directory_index, + directories, + root, manifest: walked.entries, }) } @@ -542,23 +704,27 @@ fn traverse_v2( return Err(HeldTreeError::BackendBoundary(root_path)); } let root_identity = opened.identity; + let parent_mount_id = held.mount_id(); let root_entry = M::make_root(opened); let mut budget = Budget::new(limits, CONTENT_PROOF_VERSION); budget.add_path(M::path(&root_entry), 0)?; budget.add_directory()?; + let root = HeldDirectory::new(held, PathBuf::new(), 0, root_identity); + let root_evidence = root.evidence.clone(); + let mut directory_index = BTreeMap::new(); + directory_index.insert(PathBuf::new(), root_evidence.clone()); let mut walked = V2Walk:: { parent, root_name: root_name.to_os_string(), root_identity, + root, backend, - mount_id: held.mount_id(), + mount_id: parent_mount_id, protected_names, limits, - directories: vec![HeldDirectory { - held, - path: PathBuf::new(), - depth: 0, - incarnation: root_identity.incarnation, + directory_index, + directories: vec![WalkDirectory { + evidence: root_evidence, }], entries: vec![root_entry], budget, @@ -591,14 +757,40 @@ fn read_v2_children( walked: &mut V2Walk, index: usize, ) -> Result<(), HeldTreeError> { - let parent_path = walked.directories[index].path.clone(); - let parent_depth = walked.directories[index].depth; - require_directory_current(&walked.directories[index], walked.backend, walked.mount_id)?; - let fresh = with_fd(&walked.directories[index].held, |fd| { + let parent_path = walked.directories[index].evidence.relative_path.clone(); + let parent_depth = walked.directories[index].evidence.depth; + let reopened = if index == 0 { + require_directory_current(&walked.root, walked.backend, walked.mount_id)?; + ReopenedDirectory { + held: ReopenedHeldDirectory::Root(&walked.root.held), + } + } else { + reopen_directory_from_root( + &walked.root, + &parent_path, + |path| walked.directory_index.get(path), + walked.backend, + walked.mount_id, + || { + verify_root_binding_fields( + &walked.parent, + &walked.root_name, + walked.root_identity, + walked.mount_id, + walked.backend, + ) + }, + false, + )? + }; + let parent = reopened.held(); + let fresh = with_fd(parent, |fd| { rustix::fs::openat(fd, c".", OPEN_DIRECTORY, Mode::empty()) }) .map_err(|error| io_error(&parent_path, error))?; let entries = Dir::new(fresh).map_err(|error| io_error(&parent_path, error))?; + let mut new_entries = Vec::new(); + let mut new_directories = Vec::new(); for entry in entries { let entry = entry.map_err(|error| io_error(&parent_path, error))?; if matches!(entry.file_name().to_bytes(), b"." | b"..") { @@ -607,14 +799,12 @@ fn read_v2_children( let name = OsStr::from_bytes(entry.file_name().to_bytes()); let path = parent_path.join(name); require_unprotected(&walked.protected_names, name, &path)?; - let inspected = with_fd(&walked.directories[index].held, |fd| { - inspect_at(fd, name, &path) - })?; + let inspected = with_fd(parent, |fd| inspect_at(fd, name, &path))?; require_owner(&path, inspected.uid, rustix::process::geteuid().as_raw())?; require_boundary(&path, walked.backend, walked.mount_id, &inspected)?; let depth = parent_depth.saturating_add(1); let child = if inspected.identity.kind == NodeKind::Directory { - let fd = with_fd(&walked.directories[index].held, |parent_fd| { + let fd = with_fd(parent, |parent_fd| { rustix::fs::openat(parent_fd, name, OPEN_DIRECTORY, Mode::empty()) }) .map_err(|error| io_error(&path, error))?; @@ -632,28 +822,39 @@ fn read_v2_children( } else { None }; - let result = M::inspect_entry( - &walked.directories[index].held, - name, - &path, - &inspected, - &mut walked.budget, - )?; + let result = M::inspect_entry(parent, name, &path, &inspected, &mut walked.budget)?; walked.budget.add_path(M::path(&result), depth)?; if child.is_some() { walked.budget.add_directory()?; } - let incarnation = M::identity(&result).incarnation; - walked.entries.push(result); + let directory_identity = M::identity(&result); + new_entries.push(result); if let Some(held) = child { - walked.directories.push(HeldDirectory { - held, - path, + let evidence = DirectoryEvidence { + relative_path: path, depth, - incarnation, - }); + identity: directory_identity, + owner_uid: held.owner_uid(), + group_gid: held.group_gid(), + observed_mode: held.mode(), + }; + drop(held); + new_directories.push(WalkDirectory { evidence }); } } + drop(reopened); + walked.entries.extend(new_entries); + for directory in new_directories { + let path = directory.evidence.relative_path.clone(); + if walked + .directory_index + .insert(path.clone(), directory.evidence.clone()) + .is_some() + { + return Err(HeldTreeError::IdentityChanged(path)); + } + walked.directories.push(directory); + } Ok(()) } @@ -675,8 +876,8 @@ impl HeldTreeInventory { } /// Collects only the evidence committed by the requested durable schema. - /// Schema 1 preserves historical recovery semantics and never reads file - /// content or imposes v2 hardlink/xattr/content-budget constraints. + /// Schema 1 certifies each discovered directory immediately, never reads + /// file content, and imposes no v2 hardlink/xattr/content-budget constraints. pub(crate) fn collect_for_schema( parent: HeldLocalBackendEvidence, root_name: &OsStr, @@ -692,7 +893,7 @@ impl HeldTreeInventory { } require_one_component(root_name)?; validate_policy(&protected_names)?; - if limits.max_directories > HARD_DIRECTORY_CAP { + if limits.max_directories > MAX_TREE_DIRECTORIES { return Err(HeldTreeError::InvalidDirectoryLimit); } if protected_names.iter().any(|name| name == root_name) { @@ -732,21 +933,20 @@ impl HeldTreeInventory { let mut budget = Budget::new(limits, manifest_schema); budget.add_path(&root_entry.path, 0)?; budget.add_directory()?; + let root = HeldDirectory::new(held, PathBuf::new(), 0, root_identity); + let root_evidence = root.evidence.clone(); let mut tree = Self { parent, root_name: root_name.to_os_string(), root_identity, backend, - mount_id: held.mount_id(), + mount_id: root.held.mount_id(), protected_names, limits, manifest_schema, - directories: vec![HeldDirectory { - held, - path: PathBuf::new(), - depth: 0, - incarnation: root_identity.incarnation, - }], + directory_index: BTreeMap::from([(PathBuf::new(), 0)]), + directories: vec![root_evidence], + root, manifest: vec![root_entry], }; let mut index = 0; @@ -756,6 +956,7 @@ impl HeldTreeInventory { } tree.manifest .sort_by(|left, right| left.path.cmp(&right.path)); + tree.directory_index = build_directory_index(&tree.directories)?; tree.verify_root_binding()?; Ok(tree) } @@ -820,12 +1021,12 @@ impl HeldTreeInventory { .iter() .rev() .map(|directory| HeldDirectoryOrder { - relative_path: directory.path.clone(), + relative_path: directory.relative_path.clone(), depth: directory.depth, - device: directory.held.device(), - inode: directory.held.inode(), - incarnation: directory.incarnation, - observed_mode: directory.held.mode(), + device: directory.identity.device, + inode: directory.identity.inode, + incarnation: directory.identity.incarnation, + observed_mode: directory.observed_mode, }) } @@ -839,8 +1040,8 @@ impl HeldTreeInventory { } /// Consumes the freshly verified inventory and removes that exact tree using - /// only retained directory descriptors. Every non-directory is revalidated - /// (including content, one-link policy, ownership, type and strong + /// root-relative transient directory descriptors. Every non-directory is + /// revalidated (including content, one-link policy, ownership, type and strong /// incarnation) immediately before unlink. Directories are removed in /// bounded postorder, symlinks are never followed, and the retained trash /// parent is synced only after the exact root name has been removed. @@ -875,26 +1076,17 @@ impl HeldTreeInventory { .path .file_name() .ok_or(HeldTreeError::InvalidRoot)?; - let parent = self - .directories - .iter() - .find(|directory| directory.path == parent_path) - .ok_or_else(|| HeldTreeError::IdentityChanged(expected.path.clone()))?; - require_directory_current(parent, self.backend, self.mount_id)?; - let before = with_fd(&parent.held, |fd| inspect_at(fd, name, &expected.path))?; + let reopened = self.reopen_directory(parent_path)?; + let parent = reopened.held(); + let before = with_fd(parent, |fd| inspect_at(fd, name, &expected.path))?; require_owner( &expected.path, before.uid, rustix::process::geteuid().as_raw(), )?; require_boundary(&expected.path, self.backend, self.mount_id, &before)?; - let content = inspect_content_at( - &parent.held, - name, - &expected.path, - &before, - &mut content_budget, - )?; + let content = + inspect_content_at(parent, name, &expected.path, &before, &mut content_budget)?; let actual = before.into_manifest(expected.path.clone(), content); if &actual != expected { return Err(HeldTreeError::IdentityChanged(expected.path.clone()).into()); @@ -904,8 +1096,9 @@ impl HeldTreeInventory { } else { AtFlags::empty() }; - retry_interrupted(|| with_fd(&parent.held, |fd| rustix::fs::unlinkat(fd, name, flags))) + retry_interrupted(|| with_fd(parent, |fd| rustix::fs::unlinkat(fd, name, flags))) .map_err(|error| io_error(&expected.path, error))?; + drop(reopened); removed = removed.checked_add(1).ok_or(HeldTreeError::Limit { kind: HeldTreeLimit::Entries, limit: self.limits.max_entries, @@ -919,7 +1112,7 @@ impl HeldTreeInventory { let named_root = with_fd(&self.parent, |fd| { inspect_at(fd, &self.root_name, Path::new("")) })?; - let held_root = inspect_held(&self.directories[0].held, Path::new(""))?; + let held_root = inspect_held(&self.root.held, Path::new(""))?; require_same_identity(Path::new(""), self.root_identity, named_root.identity)?; require_same_identity(Path::new(""), self.root_identity, held_root.identity)?; retry_interrupted(|| { @@ -954,33 +1147,74 @@ impl HeldTreeInventory { first_mutation_id: u64, ) -> Result { let mut mutation_id = first_mutation_id; - for directory in self.directories.iter_mut().rev() { - let relative_path = source_root.join(&directory.path); - let result = execute_staging_local_mode_mutation( - wal, - &mut directory.held, - LocalModeMutationRequest { - transaction, - mutation_id, - locator: RecoveryLocator::held_staging( - relative_path, - filesystem_id.to_owned(), - directory.incarnation, - ), - transform: LocalModeTransform::Seal { - acquire_owner_write_search: false, + for position in (0..self.directories.len()).rev() { + let evidence = self.directories[position].clone(); + let relative_path = source_root.join(&evidence.relative_path); + let path = evidence.relative_path.clone(); + let result = if position == 0 { + execute_staging_local_mode_mutation( + wal, + &mut self.root.held, + LocalModeMutationRequest { + transaction, + mutation_id, + locator: RecoveryLocator::held_staging( + relative_path, + filesystem_id.to_owned(), + evidence.identity.incarnation, + ), + transform: LocalModeTransform::Seal { + acquire_owner_write_search: false, + }, }, - }, - ) + ) + } else { + let mut reopened = + self.reopen_directory_for_transient_seal(&evidence.relative_path)?; + let held = match &mut reopened.held { + ReopenedHeldDirectory::Descendant(held) => held, + ReopenedHeldDirectory::Root(_) => { + return Err(HeldTreeSealError::Mutation { + path, + source: LocalModeExecutionError::InvalidRequest( + "reopener returned retained root unexpectedly", + ), + }); + } + }; + execute_staging_local_mode_mutation( + wal, + held, + LocalModeMutationRequest { + transaction, + mutation_id, + locator: RecoveryLocator::held_staging( + relative_path, + filesystem_id.to_owned(), + evidence.identity.incarnation, + ), + transform: LocalModeTransform::Seal { + acquire_owner_write_search: false, + }, + }, + ) + } .map_err(|source| HeldTreeSealError::Mutation { - path: directory.path.clone(), + path: evidence.relative_path.clone(), source, })?; - if result == LocalModeMutationResult::ConfirmedNotApplied { - return Err(HeldTreeSealError::ConfirmedNotApplied( - directory.path.clone(), - )); - } + let applied_mode = match result { + LocalModeMutationResult::Applied { applied_mode, .. } => applied_mode, + LocalModeMutationResult::ConfirmedNotApplied => { + return Err(HeldTreeSealError::ConfirmedNotApplied( + evidence.relative_path.clone(), + )); + } + }; + // Only the executor's WAL-bound, verified Applied result advances + // inventory evidence. Error and unknown outcomes return above and + // can never synthesize a post-mutation mode from stale evidence. + self.directories[position].observed_mode = applied_mode; mutation_id = mutation_id .checked_add(1) .ok_or(HeldTreeSealError::MutationIdExhausted)?; @@ -1004,7 +1238,7 @@ impl HeldTreeInventory { let sealed_modes = self .directories .iter() - .map(|directory| (directory.path.as_path(), directory.held.mode())) + .map(|directory| (directory.relative_path.as_path(), directory.observed_mode)) .collect::>(); for (before, after) in self.manifest.iter().zip(&post.manifest) { if before.path != after.path @@ -1045,32 +1279,35 @@ impl HeldTreeInventory { } else { ContentProof::Legacy }; - let root = inspect_held(&self.directories[0].held, Path::new(""))? + let reopened_root = self.reopen_directory(Path::new(""))?; + let root = inspect_held(reopened_root.held(), Path::new(""))? .into_manifest(PathBuf::new(), root_content); budget.add_path(&root.path, 0)?; budget.add_directory()?; actual.insert(PathBuf::new(), root); for directory in &self.directories { - require_directory_current(directory, self.backend, self.mount_id)?; - let fresh = with_fd(&directory.held, |fd| { + let reopened = self.reopen_directory(&directory.relative_path)?; + let held = reopened.held(); + let fresh = with_fd(held, |fd| { rustix::fs::openat(fd, c".", OPEN_DIRECTORY, Mode::empty()) }) - .map_err(|error| io_error(&directory.path, error))?; - let entries = Dir::new(fresh).map_err(|error| io_error(&directory.path, error))?; + .map_err(|error| io_error(&directory.relative_path, error))?; + let entries = + Dir::new(fresh).map_err(|error| io_error(&directory.relative_path, error))?; for entry in entries { - let entry = entry.map_err(|error| io_error(&directory.path, error))?; + let entry = entry.map_err(|error| io_error(&directory.relative_path, error))?; if matches!(entry.file_name().to_bytes(), b"." | b"..") { continue; } let name = OsStr::from_bytes(entry.file_name().to_bytes()); - let path = directory.path.join(name); + let path = directory.relative_path.join(name); require_unprotected(&self.protected_names, name, &path)?; - let inspected = with_fd(&directory.held, |fd| inspect_at(fd, name, &path))?; + let inspected = with_fd(held, |fd| inspect_at(fd, name, &path))?; require_owner(&path, inspected.uid, rustix::process::geteuid().as_raw())?; require_boundary(&path, self.backend, self.mount_id, &inspected)?; let content = inspect_content_for_schema( self.manifest_schema, - &directory.held, + held, name, &path, &inspected, @@ -1100,15 +1337,57 @@ impl HeldTreeInventory { self.verify_root_binding() } + /// Reopens one recorded directory from the retained certified root. Only + /// literal normal relative components are accepted. Each next component is + /// opened and fully certified before the prior non-root descriptor is + /// dropped, so any depth uses at most two transient non-root descriptors. + fn reopen_directory( + &self, + relative_path: &Path, + ) -> Result, HeldTreeError> { + reopen_directory_from_root( + &self.root, + relative_path, + |path| self.directory_evidence(path), + self.backend, + self.mount_id, + || self.verify_root_binding(), + false, + ) + } + + fn reopen_directory_for_transient_seal( + &self, + relative_path: &Path, + ) -> Result, HeldTreeError> { + reopen_directory_from_root( + &self.root, + relative_path, + |path| self.directory_evidence(path), + self.backend, + self.mount_id, + || self.verify_root_binding(), + true, + ) + } + + fn directory_evidence(&self, relative_path: &Path) -> Option<&DirectoryEvidence> { + let position = *self.directory_index.get(relative_path)?; + let evidence = self.directories.get(position)?; + (evidence.relative_path == relative_path).then_some(evidence) + } + fn read_children(&mut self, index: usize, budget: &mut Budget) -> Result<(), HeldTreeError> { - let parent_path = self.directories[index].path.clone(); + let parent_path = self.directories[index].relative_path.clone(); let parent_depth = self.directories[index].depth; - require_directory_current(&self.directories[index], self.backend, self.mount_id)?; - let fresh = with_fd(&self.directories[index].held, |fd| { + let reopened = self.reopen_directory(&parent_path)?; + let parent = reopened.held(); + let fresh = with_fd(parent, |fd| { rustix::fs::openat(fd, c".", OPEN_DIRECTORY, Mode::empty()) }) .map_err(|error| io_error(&parent_path, error))?; let entries = Dir::new(fresh).map_err(|error| io_error(&parent_path, error))?; + let mut children = Vec::new(); for entry in entries { let entry = entry.map_err(|error| io_error(&parent_path, error))?; if matches!(entry.file_name().to_bytes(), b"." | b"..") { @@ -1117,14 +1396,15 @@ impl HeldTreeInventory { let name = OsStr::from_bytes(entry.file_name().to_bytes()); let path = parent_path.join(name); require_unprotected(&self.protected_names, name, &path)?; - let inspected = with_fd(&self.directories[index].held, |fd| { - inspect_at(fd, name, &path) - })?; + let inspected = with_fd(parent, |fd| inspect_at(fd, name, &path))?; require_owner(&path, inspected.uid, rustix::process::geteuid().as_raw())?; require_boundary(&path, self.backend, self.mount_id, &inspected)?; let depth = parent_depth.saturating_add(1); let child = if inspected.identity.kind == NodeKind::Directory { - let fd = with_fd(&self.directories[index].held, |parent_fd| { + // Preserve schema-v1's immediate directory certification and + // error ordering while retaining the descriptor only for this + // entry's collection. Later traversal reopens from the root. + let fd = with_fd(parent, |parent_fd| { rustix::fs::openat(parent_fd, name, OPEN_DIRECTORY, Mode::empty()) }) .map_err(|error| io_error(&path, error))?; @@ -1144,7 +1424,7 @@ impl HeldTreeInventory { }; let content = inspect_content_for_schema( self.manifest_schema, - &self.directories[index].held, + parent, name, &path, &inspected, @@ -1155,15 +1435,23 @@ impl HeldTreeInventory { if child.is_some() { budget.add_directory()?; } - let incarnation = manifest.identity.incarnation; + let evidence = child.as_ref().map(|held| DirectoryEvidence { + relative_path: path.clone(), + depth, + identity: manifest.identity, + owner_uid: held.owner_uid(), + group_gid: held.group_gid(), + observed_mode: held.mode(), + }); + children.push((manifest, evidence)); + } + drop(reopened); + for (manifest, evidence) in children { self.manifest.push(manifest); - if let Some(held) = child { - self.directories.push(HeldDirectory { - held, - path, - depth, - incarnation, - }); + if let Some(evidence) = evidence { + self.directory_index + .insert(evidence.relative_path.clone(), self.directories.len()); + self.directories.push(evidence); } } Ok(()) @@ -1171,15 +1459,13 @@ impl HeldTreeInventory { fn verify_root_binding(&self) -> Result<(), HeldTreeError> { require_exclusive_parent(&self.parent, self.backend)?; - let inspected = with_fd(&self.parent, |fd| { - inspect_at(fd, &self.root_name, Path::new("")) - }) - .map_err(|_| HeldTreeError::RootBindingChanged)?; - if root_binding_matches(self.root_identity, self.mount_id, self.backend, &inspected) { - Ok(()) - } else { - Err(HeldTreeError::RootBindingChanged) - } + verify_root_binding_fields( + &self.parent, + &self.root_name, + self.root_identity, + self.mount_id, + self.backend, + ) } } @@ -1584,6 +1870,10 @@ fn inspect_regular_content( if read == 0 { break; } + #[cfg(test)] + REGULAR_CONTENT_BYTES_READ.with(|bytes| { + bytes.set(bytes.get().saturating_add(read as u64)); + }); total = total .checked_add(read as u64) .ok_or_else(|| HeldTreeError::ContentChangedDuringHash(path.to_path_buf()))?; @@ -2113,25 +2403,259 @@ fn require_exclusive_parent( } } +fn verify_root_binding_fields( + parent: &HeldLocalBackendEvidence, + root_name: &OsStr, + root_identity: NodeIdentity, + mount_id: u64, + backend: CertifiedLocalBackend, +) -> Result<(), HeldTreeError> { + let inspected = with_fd(parent, |fd| inspect_at(fd, root_name, Path::new(""))) + .map_err(|_| HeldTreeError::RootBindingChanged)?; + if root_binding_matches(root_identity, mount_id, backend, &inspected) { + Ok(()) + } else { + Err(HeldTreeError::RootBindingChanged) + } +} + +/// Opens a recorded directory strictly beneath the retained root descriptor. +/// Every component is NOFOLLOW-opened and certified against data-only evidence +/// before traversal advances; only the current and next non-root FDs overlap. +fn reopen_directory_from_root<'root, 'e>( + root: &'root HeldDirectory, + relative_path: &Path, + directory_evidence: impl Fn(&Path) -> Option<&'e DirectoryEvidence>, + backend: CertifiedLocalBackend, + mount_id: u64, + verify_root_binding: impl FnOnce() -> Result<(), HeldTreeError>, + transient_seal_final_validation: bool, +) -> Result, HeldTreeError> { + let components = normal_relative_components(relative_path)?; + let component_count = components.len(); + let target = directory_evidence(relative_path) + .ok_or_else(|| HeldTreeError::IdentityChanged(relative_path.to_path_buf()))?; + if usize::try_from(target.depth).ok() != Some(components.len()) { + return Err(HeldTreeError::InvalidDirectoryPath( + relative_path.to_path_buf(), + )); + } + if !root.evidence.relative_path.as_os_str().is_empty() || root.evidence.depth != 0 { + return Err(HeldTreeError::InvalidDirectoryPath(PathBuf::new())); + } + validate_reopened_directory(&root.held, &root.evidence, backend, mount_id)?; + #[cfg(test)] + fire_reopener_test_hook( + ReopenerTestPhase::AfterValidatedHopBeforeNextOperation, + Path::new(""), + ); + verify_root_binding()?; + if components.is_empty() { + return Ok(ReopenedDirectory { + held: ReopenedHeldDirectory::Root(&root.held), + }); + } + + let mut prefix = PathBuf::new(); + let mut current: Option = None; + let mut live_non_root = 0_usize; + for (index, component) in components.into_iter().enumerate() { + prefix.push(component); + let expected = directory_evidence(&prefix) + .ok_or_else(|| HeldTreeError::IdentityChanged(prefix.clone()))?; + if usize::try_from(expected.depth).ok() != Some(index + 1) { + return Err(HeldTreeError::InvalidDirectoryPath(prefix)); + } + let parent = current.as_ref().unwrap_or(&root.held); + let fd = with_fd(parent, |parent_fd| { + rustix::fs::openat(parent_fd, component, OPEN_DIRECTORY, Mode::empty()) + }) + .map_err(|error| io_error(&prefix, error))?; + #[cfg(test)] + fire_reopener_test_hook(ReopenerTestPhase::AfterOpenBeforeValidation, &prefix); + let next = certify_held_fd(fd).map_err(|reason| HeldTreeError::Certification { + path: prefix.clone(), + reason, + })?; + live_non_root += 1; + note_reopener_live_non_root_fds(live_non_root); + validate_reopened_directory(&next, expected, backend, mount_id)?; + #[cfg(test)] + fire_reopener_test_hook( + ReopenerTestPhase::AfterValidatedHopBeforeNextOperation, + &prefix, + ); + validate_reopened_name(parent, component, expected, backend, mount_id)?; + if transient_seal_final_validation && index + 1 == component_count { + // Tests may inject a race here, but the validation itself is the + // unconditional production path immediately before the caller can + // append a WAL intent or invoke fchmod. + #[cfg(test)] + fire_transient_seal_test_hook(&prefix); + validate_reopened_directory(&next, expected, backend, mount_id)?; + validate_reopened_name(parent, component, expected, backend, mount_id)?; + } + + let previous = current.replace(next); + if previous.is_some() { + drop(previous); + live_non_root -= 1; + } + } + + Ok(ReopenedDirectory { + held: ReopenedHeldDirectory::Descendant( + current.ok_or_else(|| HeldTreeError::IdentityChanged(relative_path.to_path_buf()))?, + ), + }) +} + +fn build_directory_index( + directories: &[DirectoryEvidence], +) -> Result, HeldTreeError> { + let mut index = BTreeMap::new(); + for (position, directory) in directories.iter().enumerate() { + let path = &directory.relative_path; + let components = normal_relative_components(path)?; + if usize::try_from(directory.depth).ok() != Some(components.len()) { + return Err(HeldTreeError::InvalidDirectoryPath(path.clone())); + } + if index.insert(path.clone(), position).is_some() { + return Err(HeldTreeError::IdentityChanged(path.clone())); + } + } + if index.get(Path::new("")) != Some(&0) { + return Err(HeldTreeError::InvalidDirectoryPath(PathBuf::new())); + } + Ok(index) +} + +fn normal_relative_components(path: &Path) -> Result, HeldTreeError> { + let bytes = path.as_os_str().as_bytes(); + if bytes.is_empty() { + return Ok(Vec::new()); + } + let mut components = Vec::new(); + for component in bytes.split(|byte| *byte == b'/') { + if component.is_empty() || matches!(component, b"." | b"..") { + return Err(HeldTreeError::InvalidDirectoryPath(path.to_path_buf())); + } + components.push(OsStr::from_bytes(component)); + } + Ok(components) +} + +fn validate_reopened_name( + parent: &HeldLocalBackendEvidence, + name: &OsStr, + expected: &DirectoryEvidence, + backend: CertifiedLocalBackend, + mount_id: u64, +) -> Result<(), HeldTreeError> { + let inspected = with_fd(parent, |fd| inspect_at(fd, name, &expected.relative_path))?; + require_same_identity( + &expected.relative_path, + expected.identity, + inspected.identity, + )?; + require_owner(&expected.relative_path, inspected.uid, expected.owner_uid)?; + require_owner( + &expected.relative_path, + expected.owner_uid, + rustix::process::geteuid().as_raw(), + )?; + if inspected.gid != expected.group_gid || inspected.mode != expected.observed_mode { + return Err(HeldTreeError::IdentityChanged( + expected.relative_path.clone(), + )); + } + require_boundary(&expected.relative_path, backend, mount_id, &inspected) +} + +fn validate_reopened_directory( + held: &HeldLocalBackendEvidence, + expected: &DirectoryEvidence, + backend: CertifiedLocalBackend, + mount_id: u64, +) -> Result<(), HeldTreeError> { + with_fd(held, |fd| crate::backend::require_held_fd_acl_absent(fd)).map_err(|reason| { + HeldTreeError::Certification { + path: expected.relative_path.clone(), + reason, + } + })?; + let fresh_backend = + with_fd(held, |fd| crate::backend::certify_held_fd_backend(fd)).map_err(|reason| { + HeldTreeError::Certification { + path: expected.relative_path.clone(), + reason, + } + })?; + let inspected = inspect_held(held, &expected.relative_path)?; + require_same_identity( + &expected.relative_path, + expected.identity, + inspected.identity, + )?; + require_owner(&expected.relative_path, inspected.uid, expected.owner_uid)?; + require_owner( + &expected.relative_path, + expected.owner_uid, + rustix::process::geteuid().as_raw(), + )?; + if inspected.gid != expected.group_gid || inspected.mode != expected.observed_mode { + return Err(HeldTreeError::IdentityChanged( + expected.relative_path.clone(), + )); + } + if held.backend() != backend || fresh_backend != backend || held.mount_id() != mount_id { + return Err(HeldTreeError::BackendBoundary( + expected.relative_path.clone(), + )); + } + require_boundary(&expected.relative_path, backend, mount_id, &inspected) +} + +#[cfg(test)] +fn note_reopener_live_non_root_fds(live: usize) { + REOPENER_MAX_NON_ROOT_FDS.with(|maximum| maximum.set(maximum.get().max(live))); +} + +#[cfg(not(test))] +fn note_reopener_live_non_root_fds(_live: usize) {} + fn require_directory_current( directory: &HeldDirectory, backend: CertifiedLocalBackend, mount_id: u64, ) -> Result<(), HeldTreeError> { - with_fd(&directory.held, |fd| { - crate::backend::require_held_fd_acl_absent(fd) - }) - .map_err(|reason| HeldTreeError::Certification { - path: directory.path.clone(), - reason, + require_directory_evidence_current(&directory.held, &directory.evidence, backend, mount_id) +} + +fn require_directory_evidence_current( + held: &HeldLocalBackendEvidence, + evidence: &DirectoryEvidence, + backend: CertifiedLocalBackend, + mount_id: u64, +) -> Result<(), HeldTreeError> { + with_fd(held, |fd| crate::backend::require_held_fd_acl_absent(fd)).map_err(|reason| { + HeldTreeError::Certification { + path: evidence.relative_path.clone(), + reason, + } })?; - let inspected = inspect_held(&directory.held, &directory.path)?; + let inspected = inspect_held(held, &evidence.relative_path)?; require_owner( - &directory.path, + &evidence.relative_path, inspected.uid, rustix::process::geteuid().as_raw(), )?; - require_boundary(&directory.path, backend, mount_id, &inspected) + if inspected.gid != evidence.group_gid || inspected.mode != evidence.observed_mode { + return Err(HeldTreeError::IdentityChanged( + evidence.relative_path.clone(), + )); + } + require_boundary(&evidence.relative_path, backend, mount_id, &inspected) } fn require_unprotected( diff --git a/crates/degu-core/src/backend/held/tests.rs b/crates/degu-core/src/backend/held/tests.rs index e2f267e..b3be632 100644 --- a/crates/degu-core/src/backend/held/tests.rs +++ b/crates/degu-core/src/backend/held/tests.rs @@ -163,6 +163,86 @@ fn set_symlink_test_xattr(path: &Path) -> io::Result<()> { } } +#[test] +fn production_directory_budget_fits_the_recovery_permission_envelope() { + let limits = HeldTreeLimits::default(); + assert_eq!(limits.max_directories, MAX_TREE_DIRECTORIES); + assert_eq!(MAX_TREE_DIRECTORIES + 1, HARD_DIRECTORY_CAP); + assert_eq!( + HARD_DIRECTORY_CAP, + crate::seal::wal::RECOVERY_MAX_ACTIVE_PERMISSIONS as u64 + ); + assert_eq!(limits.max_entries, 100_000); + assert_eq!(limits.max_depth, 128); + assert_eq!(limits.max_path_bytes, 16 * 1024 * 1024); + assert_eq!(limits.max_manifest_bytes, 64 * 1024 * 1024); + assert_eq!(limits.max_content_bytes, 1024 * 1024 * 1024); +} + +#[test] +fn every_traversal_rejects_an_explicit_1024_directory_request() { + let (temp, _) = setup_tree(); + let limits = HeldTreeLimits { + max_directories: HARD_DIRECTORY_CAP, + ..HeldTreeLimits::default() + }; + let v2 = collect(&temp, vec![], limits).unwrap_err(); + let assessment = assess(&temp, vec![], limits).unwrap_err(); + let legacy = HeldTreeInventory::collect_for_schema( + certify_held_fd(open_directory(temp.path())).unwrap(), + OsStr::new("root"), + vec![], + limits, + 1, + ) + .unwrap_err(); + + for error in [v2, assessment, legacy] { + assert!(matches!(error, HeldTreeError::InvalidDirectoryLimit)); + assert_eq!( + error.to_string(), + "requested tree directory limit exceeds 1023 total directories (including the root)" + ); + } +} + +#[test] +fn production_boundary_accepts_1023_total_directories_and_rejects_1024() { + let (temp, root) = setup_tree(); + // setup_tree contains root, a, and a/b. Add 1,020 siblings for 1,023 total. + for index in 0..(MAX_TREE_DIRECTORIES - 3) { + std::fs::create_dir(root.join(format!("boundary-{index:04}"))).unwrap(); + } + let inventory = collect(&temp, vec![], HeldTreeLimits::default()).unwrap(); + assert_eq!(inventory.directories.len() as u64, MAX_TREE_DIRECTORIES); + let (assessment, _) = + unwrap_tree_assessment(assess(&temp, vec![], HeldTreeLimits::default()).unwrap()); + assert_eq!(assessment.directories, MAX_TREE_DIRECTORIES); + let legacy = HeldTreeInventory::collect_for_schema( + certify_held_fd(open_directory(temp.path())).unwrap(), + OsStr::new("root"), + vec![], + HeldTreeLimits::default(), + 1, + ) + .unwrap(); + assert_eq!(legacy.directories.len() as u64, MAX_TREE_DIRECTORIES); + + std::fs::create_dir(root.join("boundary-over-limit")).unwrap(); + for error in [ + collect(&temp, vec![], HeldTreeLimits::default()).unwrap_err(), + assess(&temp, vec![], HeldTreeLimits::default()).unwrap_err(), + ] { + assert!(matches!( + error, + HeldTreeError::Limit { + kind: HeldTreeLimit::Directories, + limit: MAX_TREE_DIRECTORIES, + } + )); + } +} + #[test] fn metadata_only_tree_policy_matches_clean_v2_admission_without_claiming_seal_readiness() { fn assert_data_traits() {} @@ -185,6 +265,128 @@ fn metadata_only_tree_policy_matches_clean_v2_admission_without_claiming_seal_re assert_eq!(tree.content_bytes, 4); // "one" plus the one-byte symlink target. } +#[test] +fn assessment_regular_files_remain_metadata_only_while_proving_reads_content() { + let (temp, _) = setup_tree(); + REGULAR_CONTENT_BYTES_READ.with(|bytes| bytes.set(0)); + unwrap_tree_assessment(assess(&temp, vec![], HeldTreeLimits::default()).unwrap()); + assert_eq!(REGULAR_CONTENT_BYTES_READ.with(std::cell::Cell::get), 0); + + let proved = collect(&temp, vec![], HeldTreeLimits::default()).unwrap(); + assert_eq!(REGULAR_CONTENT_BYTES_READ.with(std::cell::Cell::get), 3); + assert_eq!(proved.entry_count(), 5); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn assert_assessment_fd_peak_is_independent_of_directory_count() { + use std::cell::Cell; + use std::rc::Rc; + + let temp = tempfile::tempdir().unwrap(); + std::fs::set_permissions(temp.path(), Permissions::from_mode(0o700)).unwrap(); + let root = temp.path().join("root"); + std::fs::create_dir(&root).unwrap(); + for index in 0..240 { + std::fs::create_dir(root.join(format!("sibling-{index:03}"))).unwrap(); + } + + let baseline = observed_process_fd_count(); + let peak = Rc::new(Cell::new(baseline)); + let fired = Rc::new(Cell::new(0_u64)); + let observed_peak = Rc::clone(&peak); + let observed_fired = Rc::clone(&fired); + let _hook = install_reopener_test_hook(move |_, path| { + if !path.as_os_str().is_empty() { + observed_fired.set(observed_fired.get() + 1); + observed_peak.set(observed_peak.get().max(observed_process_fd_count())); + } + }); + let (tree, _) = + unwrap_tree_assessment(assess(&temp, vec![], HeldTreeLimits::default()).unwrap()); + assert_eq!(tree.directories, 241); + assert!(fired.get() >= 240, "descendant reopener hook did not fire"); + assert!( + peak.get().saturating_sub(baseline) <= 4, + "assessment retained directory descriptors: baseline={baseline}, peak={}", + peak.get() + ); + assert_eq!(observed_process_fd_count(), baseline); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test] +fn assessment_fd_peak_is_bounded_across_240_sibling_directories() { + const CHILD_MARKER_ENV: &str = "DEGU_ASSESSMENT_FD_OBSERVATION_CHILD_MARKER"; + if let Some(marker) = std::env::var_os(CHILD_MARKER_ENV) { + assert_assessment_fd_peak_is_independent_of_directory_count(); + std::fs::write(marker, b"observed").unwrap(); + return; + } + + let marker_dir = tempfile::tempdir().unwrap(); + let marker = marker_dir.path().join("completed"); + let test_name = format!( + "{}::assessment_fd_peak_is_bounded_across_240_sibling_directories", + module_path!() + .strip_prefix("degu_core::") + .unwrap_or(module_path!()) + ); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name, "--nocapture"]) + .env(CHILD_MARKER_ENV, &marker) + .status() + .unwrap(); + assert!(status.success(), "isolated assessment FD test failed"); + assert!( + marker.exists(), + "isolated assessment FD test did not execute" + ); +} + +#[test] +#[allow(clippy::disallowed_methods)] +fn assessment_reopener_fails_closed_on_descendant_replacement() { + use std::cell::Cell; + use std::rc::Rc; + + let (temp, root) = setup_tree(); + let moved = temp.path().join("moved-assessment-a"); + let fired = Rc::new(Cell::new(false)); + let hook_fired = Rc::clone(&fired); + let hook_root = root.clone(); + let _hook = install_reopener_test_hook(move |phase, path| { + if phase == ReopenerTestPhase::AfterOpenBeforeValidation + && path == Path::new("a") + && !hook_fired.replace(true) + { + std::fs::rename(hook_root.join("a"), &moved).unwrap(); + std::fs::create_dir(hook_root.join("a")).unwrap(); + } + }); + let error = assess(&temp, vec![], HeldTreeLimits::default()).unwrap_err(); + assert!( + fired.get(), + "assessment did not use the root-relative reopener" + ); + assert!( + matches!(error, HeldTreeError::IdentityChanged(ref path) if path == Path::new("a")), + "replacement returned {error:?}" + ); +} + +#[test] +fn proving_manifest_is_stable_across_bounded_assessment() { + let (temp, _) = setup_tree(); + let baseline = collect(&temp, vec![], HeldTreeLimits::default()).unwrap(); + let baseline_fingerprint = baseline.fingerprint(); + drop(baseline); + + unwrap_tree_assessment(assess(&temp, vec![], HeldTreeLimits::default()).unwrap()); + let proved = collect(&temp, vec![], HeldTreeLimits::default()).unwrap(); + assert_eq!(proved.fingerprint(), baseline_fingerprint); + assert_eq!(proved.directories.len(), 3); +} + #[test] fn writable_parent_allows_tree_policy_assessment_but_seal_remains_unvalidated() { let (temp, _) = setup_tree(); @@ -237,7 +439,7 @@ fn unsearchable_source_parent_defers_the_entire_tree_policy_assessment() { } #[test] -fn syntactic_root_policy_and_hard_cap_errors_precede_tree_policy_deferral() { +fn syntactic_root_policy_and_tree_request_cap_errors_precede_tree_policy_deferral() { let (temp, _) = setup_tree(); let invalid_root = certify_held_fd(open_directory(temp.path())).unwrap(); let protected_root = certify_held_fd(open_directory(temp.path())).unwrap(); @@ -268,7 +470,7 @@ fn syntactic_root_policy_and_hard_cap_errors_precede_tree_policy_deferral() { OsStr::new("root"), vec![], HeldTreeLimits { - max_directories: HARD_DIRECTORY_CAP + 1, + max_directories: HARD_DIRECTORY_CAP, ..HeldTreeLimits::default() }, ), @@ -360,8 +562,8 @@ fn assessment_and_prove_share_hardlink_protected_special_and_directory_cap_rejec ); let (temp, root) = setup_tree(); - for index in 0..256 { - std::fs::create_dir(root.join(format!("d{index:03}"))).unwrap(); + for index in 0..MAX_TREE_DIRECTORIES { + std::fs::create_dir(root.join(format!("d{index:04}"))).unwrap(); } assert_same_admission_error( collect(&temp, vec![], HeldTreeLimits::default()), @@ -553,7 +755,7 @@ fn synthetic_owner_and_mount_reasons_remain_the_shared_traversal_errors() { } #[test] -fn bounded_collect_retains_every_directory_and_exact_rewalks() { +fn bounded_collect_records_every_directory_and_exact_rewalks() { let (temp, _) = setup_tree(); let tree = collect(&temp, vec![], HeldTreeLimits::default()).unwrap(); assert_eq!(tree.entry_count(), 5); @@ -573,16 +775,235 @@ fn bounded_collect_retains_every_directory_and_exact_rewalks() { } #[test] -fn policy_wiring_preserves_clean_v2_fingerprint_across_fresh_collection() { +fn directory_evidence_is_descriptor_free_data_and_clean_rewalk_succeeds() { + fn assert_data_only() {} + assert_data_only::(); + let (temp, _) = setup_tree(); - let first = collect(&temp, vec![], HeldTreeLimits::default()) - .unwrap() - .fingerprint(); - let second = collect(&temp, vec![], HeldTreeLimits::default()) + collect(&temp, vec![], HeldTreeLimits::default()) .unwrap() - .fingerprint(); - assert_eq!(first.schema_version, CONTENT_PROOF_VERSION); - assert_eq!(first, second); + .rewalk_exact() + .unwrap(); +} + +#[test] +fn directory_evidence_index_rejects_duplicate_paths() { + let (temp, _) = setup_tree(); + let mut tree = collect(&temp, vec![], HeldTreeLimits::default()).unwrap(); + let a = tree + .directories + .iter() + .position(|directory| directory.relative_path == Path::new("a")) + .unwrap(); + let ab = tree + .directories + .iter() + .position(|directory| directory.relative_path == Path::new("a/b")) + .unwrap(); + tree.directories[ab].relative_path = PathBuf::from("a"); + tree.directories[ab].depth = tree.directories[a].depth; + assert!(matches!( + build_directory_index(&tree.directories), + Err(HeldTreeError::IdentityChanged(path)) if path == Path::new("a") + )); +} + +#[test] +fn confined_reopener_rejects_every_non_normal_relative_form() { + let (temp, _) = setup_tree(); + let tree = collect(&temp, vec![], HeldTreeLimits::default()).unwrap(); + for invalid in ["../a", "/a", "./a", "a/../b", "a/./b", "a//b", "a/"] { + assert!(matches!( + tree.reopen_directory(Path::new(invalid)), + Err(HeldTreeError::InvalidDirectoryPath(path)) if path == Path::new(invalid) + )); + } + assert!(tree.reopen_directory(Path::new("")).is_ok()); + assert!(tree.reopen_directory(Path::new("a/b")).is_ok()); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn observed_process_fd_count() -> usize { + #[cfg(target_os = "linux")] + let directory = "/proc/self/fd"; + #[cfg(target_os = "macos")] + let directory = "/dev/fd"; + std::fs::read_dir(directory).unwrap().count() +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn assert_confined_reopener_process_fd_bound() { + use std::cell::Cell; + use std::rc::Rc; + + let temp = tempfile::tempdir().unwrap(); + std::fs::set_permissions(temp.path(), Permissions::from_mode(0o700)).unwrap(); + let root = temp.path().join("root"); + std::fs::create_dir(&root).unwrap(); + for sibling in 0..239 { + std::fs::create_dir(root.join(format!("s{sibling}"))).unwrap(); + } + let mut path = root.join("s0"); + for depth in 1..=16 { + path.push(format!("d{depth}")); + std::fs::create_dir(&path).unwrap(); + } + + let before_inventory = observed_process_fd_count(); + let tree = collect(&temp, vec![], HeldTreeLimits::default()).unwrap(); + let baseline = observed_process_fd_count(); + assert_eq!(tree.directories.len(), 256); + assert_eq!( + baseline.checked_sub(before_inventory), + Some(2), + "production proving retains exactly the source parent and tree root FDs" + ); + + for target in [ + PathBuf::from("s0"), + path.strip_prefix(temp.path().join("root")) + .unwrap() + .to_path_buf(), + ] { + let peak = Rc::new(Cell::new(baseline)); + let fired = Rc::new(Cell::new(0_u64)); + let observed_peak = Rc::clone(&peak); + let observed_fired = Rc::clone(&fired); + let _hook = install_reopener_test_hook(move |_, _| { + observed_fired.set(observed_fired.get() + 1); + observed_peak.set(observed_peak.get().max(observed_process_fd_count())); + }); + REOPENER_MAX_NON_ROOT_FDS.with(|maximum| maximum.set(0)); + drop(tree.reopen_directory(&target).unwrap()); + assert!( + fired.get() > 0, + "the in-reopener observation hook did not fire" + ); + let transient = peak + .get() + .checked_sub(baseline) + .expect("process FD count fell below the post-inventory baseline"); + assert!( + transient <= 2, + "reopening depth {} used {transient} transient process FDs", + normal_relative_components(&target).unwrap().len() + ); + let depth = normal_relative_components(&target).unwrap().len(); + assert_eq!(transient, depth.min(2)); + assert_eq!( + REOPENER_MAX_NON_ROOT_FDS.with(std::cell::Cell::get), + depth.min(2), + "the internal rolling-FD counter is only a secondary invariant" + ); + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test] +fn confined_reopener_process_fd_delta_is_bounded_independent_of_depth() { + const CHILD_MARKER_ENV: &str = "DEGU_REOPENER_FD_OBSERVATION_CHILD_MARKER"; + if let Some(marker) = std::env::var_os(CHILD_MARKER_ENV) { + assert_confined_reopener_process_fd_bound(); + std::fs::write(marker, b"observed").unwrap(); + return; + } + + let marker_dir = tempfile::tempdir().unwrap(); + let marker = marker_dir.path().join("completed"); + let test_name = format!( + "{}::confined_reopener_process_fd_delta_is_bounded_independent_of_depth", + module_path!() + .strip_prefix("degu_core::") + .unwrap_or(module_path!()) + ); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name, "--nocapture"]) + .env(CHILD_MARKER_ENV, &marker) + .status() + .unwrap(); + assert!( + status.success(), + "isolated FD observation subprocess failed" + ); + assert!( + marker.exists(), + "isolated FD observation test did not execute" + ); +} + +#[test] +#[allow(clippy::disallowed_methods)] +fn in_reopener_namespace_replacement_move_and_detach_fail_closed() { + use std::cell::Cell; + use std::rc::Rc; + + for phase in [ + ReopenerTestPhase::AfterOpenBeforeValidation, + ReopenerTestPhase::AfterValidatedHopBeforeNextOperation, + ] { + let (temp, root) = setup_tree(); + let tree = collect(&temp, vec![], HeldTreeLimits::default()).unwrap(); + let moved = temp.path().join("moved"); + let fired = Rc::new(Cell::new(false)); + let hook_fired = Rc::clone(&fired); + let hook_root = root.clone(); + let _hook = install_reopener_test_hook(move |observed_phase, path| { + if observed_phase == phase && path == Path::new("a") && !hook_fired.replace(true) { + std::fs::rename(hook_root.join("a"), &moved).unwrap(); + if phase == ReopenerTestPhase::AfterOpenBeforeValidation { + std::fs::create_dir(hook_root.join("a")).unwrap(); + } + } + }); + let error = tree.reopen_directory(Path::new("a/b")).unwrap_err(); + assert!(fired.get(), "the requested in-reopener phase did not fire"); + match phase { + ReopenerTestPhase::AfterOpenBeforeValidation => assert!( + matches!(error, HeldTreeError::IdentityChanged(ref path) if path == Path::new("a")), + "in-window replacement returned {error:?}" + ), + ReopenerTestPhase::AfterValidatedHopBeforeNextOperation => assert!( + matches!(error, HeldTreeError::Io { ref path, .. } if path == Path::new("a")), + "in-window move returned {error:?}" + ), + } + } + + let (temp, root) = setup_tree(); + let tree = collect(&temp, vec![], HeldTreeLimits::default()).unwrap(); + let detached = temp.path().join("detached-root"); + let fired = Rc::new(Cell::new(false)); + let hook_fired = Rc::clone(&fired); + let _hook = install_reopener_test_hook(move |phase, path| { + if phase == ReopenerTestPhase::AfterValidatedHopBeforeNextOperation + && path.as_os_str().is_empty() + && !hook_fired.replace(true) + { + std::fs::rename(&root, &detached).unwrap(); + } + }); + let error = tree.rewalk_exact().unwrap_err(); + assert!(fired.get(), "the root-detach in-reopener hook did not fire"); + assert!( + matches!(error, HeldTreeError::RootBindingChanged), + "in-window root detach returned {error:?}" + ); +} + +#[test] +fn confined_reopener_compares_the_recorded_strong_incarnation() { + let (temp, _) = setup_tree(); + let mut tree = collect(&temp, vec![], HeldTreeLimits::default()).unwrap(); + let directory = tree + .directories + .iter_mut() + .find(|directory| directory.relative_path == Path::new("a")) + .unwrap(); + directory.identity.incarnation ^= 1; + assert!(matches!( + tree.reopen_directory(Path::new("a")), + Err(HeldTreeError::IdentityChanged(path)) if path == Path::new("a") + )); } #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -940,16 +1361,6 @@ fn reused_device_and_inode_with_a_new_incarnation_is_changed() { )); } -#[test] -fn fingerprint_is_stable_after_inventory_sorting() { - let (temp, _) = setup_tree(); - let tree = collect(&temp, vec![], HeldTreeLimits::default()).unwrap(); - let first = tree.fingerprint(); - let second = tree.fingerprint(); - assert_eq!(first, second); - assert_eq!(first.entry_count, tree.entry_count()); -} - #[test] #[allow(clippy::disallowed_methods)] fn content_proof_rejects_same_size_overwrite_and_symlink_target_change() { @@ -1202,7 +1613,7 @@ fn rewalk_rejects_acl_planted_after_collect() { 0x10, 0, 7, 0, 0xff, 0xff, 0xff, 0xff, // ACL_MASK 0x20, 0, 5, 0, 0xff, 0xff, 0xff, 0xff, // ACL_OTHER ]; - let result = with_fd(&tree.directories[0].held, |fd| { + let result = with_fd(&tree.root.held, |fd| { // SAFETY: the FD and ACL buffer remain live for this syscall. unsafe { libc::fsetxattr( diff --git a/crates/degu-core/src/seal/wal.rs b/crates/degu-core/src/seal/wal.rs index f4deabe..d73dd96 100644 --- a/crates/degu-core/src/seal/wal.rs +++ b/crates/degu-core/src/seal/wal.rs @@ -26,6 +26,10 @@ const CONTENT_PROOF_MANIFEST_VERSION: u16 = 2; const HEADER_LEN: usize = 20; const MAX_PAYLOAD_LEN: usize = 1024 * 1024; const MAX_WAL_LEN: u64 = 64 * 1024 * 1024; +/// Maximum permission mutations that startup recovery may need to resolve or +/// reverse for one transaction. Forward staging reserves one operation for the +/// source-parent seal; every tree-directory seal consumes one more. +pub(crate) const RECOVERY_MAX_ACTIVE_PERMISSIONS: usize = 1_024; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct TransactionId(pub [u8; 16]); diff --git a/crates/degu-core/src/staging.rs b/crates/degu-core/src/staging.rs index fb2a876..461afb1 100644 --- a/crates/degu-core/src/staging.rs +++ b/crates/degu-core/src/staging.rs @@ -17,9 +17,10 @@ pub(crate) mod rename; use crate::authority::TransactionState; use crate::seal::store::{SealWalStore, StoreError}; use crate::seal::wal::{ - AppendError, ProductionAssociation, RecoveryIdentity, RecoverySession, RecoveryWork, - ReplayError, ReplayedTransaction, SealWal, StagingTransactionMetadata, StrongObjectIdentity, - TransactionId, decide_recovery, quarantined_transaction_retains_active_permission_seals, + AppendError, ProductionAssociation, RECOVERY_MAX_ACTIVE_PERMISSIONS, RecoveryIdentity, + RecoverySession, RecoveryWork, ReplayError, ReplayedTransaction, SealWal, + StagingTransactionMetadata, StrongObjectIdentity, TransactionId, decide_recovery, + quarantined_transaction_retains_active_permission_seals, }; use crate::staging::recovery::{ RecoveryAnchors, RecoveryFilesystemAnchor, RecoveryRebindError, StagedVerificationFailure, @@ -47,7 +48,6 @@ std::thread_local! { const MAX_RECOVERY_STEPS_PER_TRANSACTION: usize = 4; const MAX_RECOVERY_TRANSACTIONS: usize = 64; const MAX_RECOVERY_PERMISSION_RECORDS: usize = 4096; -const MAX_RECOVERY_PERMISSION_OPERATIONS: usize = 1024; const MAX_RECOVERY_PATH_COMPONENTS: usize = 128; const MAX_RECOVERY_PATH_BYTES: usize = 64 * 1024; @@ -1238,11 +1238,9 @@ fn validate_recovery_workload(snapshot: &ReplayedTransaction) -> io::Result<()> }) }) .count(); - if unresolved > MAX_RECOVERY_PERMISSION_OPERATIONS - || active > MAX_RECOVERY_PERMISSION_OPERATIONS - { + if unresolved > RECOVERY_MAX_ACTIVE_PERMISSIONS || active > RECOVERY_MAX_ACTIVE_PERMISSIONS { return Err(io::Error::other(format!( - "transaction exceeds the {MAX_RECOVERY_PERMISSION_OPERATIONS}-operation permission recovery limit" + "transaction exceeds the {RECOVERY_MAX_ACTIVE_PERMISSIONS}-operation permission recovery limit" ))); } let validate_path = |path: &Path| -> io::Result<()> { @@ -1294,7 +1292,7 @@ impl SealedStagingEngine { )); } // Enumerate candidate recovery ordering without granting authority. Every - // item is subsequently required to pass staging::recovery's fresh held-FD + // item is subsequently required to pass staging_recovery's fresh held-FD // rebind; this callback cannot itself authorize chmod or namespace work. let recovery_generation = NEXT_RECOVERY_GENERATION.fetch_add(1, Ordering::Relaxed); let mut work = replay diff --git a/crates/degu-core/src/staging/recovery.rs b/crates/degu-core/src/staging/recovery.rs index e6c0580..513c1e1 100644 --- a/crates/degu-core/src/staging/recovery.rs +++ b/crates/degu-core/src/staging/recovery.rs @@ -22,7 +22,7 @@ use crate::seal::wal::{ }; use rustix::fd::OwnedFd; use rustix::fs::{Mode, OFlags}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ffi::{OsStr, OsString}; use std::io; #[cfg(target_os = "macos")] @@ -43,6 +43,41 @@ std::thread_local! { const { std::cell::Cell::new(None) }; pub(crate) static PURGE_FAIL_AFTER_OUTCOME: std::cell::Cell = const { std::cell::Cell::new(false) }; + static RECOVERY_FD_OBSERVER: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +pub(crate) struct RecoveryFdObserverGuard; + +#[cfg(test)] +impl Drop for RecoveryFdObserverGuard { + fn drop(&mut self) { + RECOVERY_FD_OBSERVER.with(|observer| *observer.borrow_mut() = None); + } +} + +#[cfg(test)] +pub(crate) fn install_recovery_fd_observer( + observer: impl FnMut() + 'static, +) -> RecoveryFdObserverGuard { + RECOVERY_FD_OBSERVER.with(|slot| { + assert!( + slot.borrow().is_none(), + "recovery FD observer already installed" + ); + *slot.borrow_mut() = Some(Box::new(observer)); + }); + RecoveryFdObserverGuard +} + +#[cfg(test)] +fn observe_recovery_fds() { + RECOVERY_FD_OBSERVER.with(|observer| { + if let Some(observer) = observer.borrow_mut().as_mut() { + observer(); + } + }); } const OPEN_DIRECTORY: OFlags = OFlags::RDONLY @@ -289,11 +324,48 @@ impl ReboundObject { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecoveryAnchorSide { + Source, + Destination, +} + +#[derive(Debug)] +struct PlannedDirectoryEvidence { + identity: StrongObjectIdentity, + owner_uid: u32, + group_gid: u32, + mode: u32, + backend: CertifiedLocalBackend, + mount_id: u64, + effective_uid: u32, + effective_groups: BTreeSet, +} + +/// Durable permission data plus a non-authoritative snapshot of every directory +/// crossed while reopening it. No descriptor is retained by an entry: authority +/// always comes from the separately retained recovery anchor at execution time. +#[derive(Debug)] +struct RecoveryPermissionPlan { + permission: DurablePermission, + side: RecoveryAnchorSide, + relative_path: PathBuf, + chain: Vec, +} + +impl RecoveryPermissionPlan { + fn permission(&self) -> &DurablePermission { + &self.permission + } +} + #[derive(Debug)] struct ReboundRestore { transaction: TransactionId, source_parent_last: PathBuf, - entries: Vec<(DurablePermission, ReboundObject)>, + anchors: RecoveryAnchors, + metadata: StagingTransactionMetadata, + entries: Vec, completion: TransactionState, } @@ -434,16 +506,18 @@ pub(crate) enum StagedVerificationOutcome<'a> { Quarantined(StagedVerificationFailure), } -/// Capability returned for post-rename verification. It keeps the exact -/// destination parent/name, staged root, every rebound tree-seal descriptor, -/// and WAL lease live but exposes no chmod, unlink, rename, or purge operation. +/// Capability returned for post-rename verification. It keeps the exact staged +/// root, destination anchor, descriptor-free tree-seal plan, and WAL lease live, +/// but exposes no chmod, unlink, rename, or purge operation. pub(crate) struct CertifiedStagedRecovery<'a> { wal: &'a mut SealWal, startup_blocked: &'a mut bool, transaction: TransactionId, + destination_anchor: RecoveryFilesystemAnchor, + metadata: StagingTransactionMetadata, root: ReboundObject, expected_manifest: Option, - rebound_tree_seals: Vec<(DurablePermission, ReboundObject)>, + tree_seal_plan: Vec, } impl<'a> CertifiedStagedRecovery<'a> { @@ -511,19 +585,21 @@ impl<'a> CertifiedStagedRecovery<'a> { .expected_manifest .ok_or(StagedVerificationFailure::MissingManifest)?; self.root.verify_fresh_binding()?; - for (permission, rebound) in &self.rebound_tree_seals { - rebound.verify_fresh_sealed_directory(permission.expected_mode)?; + for plan in &self.tree_seal_plan { + let rebound = reopen_permission_plan(&self.destination_anchor, plan, &self.metadata)?; + rebound.verify_fresh_sealed_directory(plan.permission.expected_mode)?; } let inventory = collect_rebound_staged_tree(&self.root, limits, expected.schema_version)?; require_exact_tree_seal_coverage( &inventory, &self.root.relative_path, - &self.rebound_tree_seals, + &self.tree_seal_plan, )?; inventory.rewalk_exact()?; self.root.verify_fresh_binding()?; - for (permission, rebound) in &self.rebound_tree_seals { - rebound.verify_fresh_sealed_directory(permission.expected_mode)?; + for plan in &self.tree_seal_plan { + let rebound = reopen_permission_plan(&self.destination_anchor, plan, &self.metadata)?; + rebound.verify_fresh_sealed_directory(plan.permission.expected_mode)?; } let actual = inventory .fingerprint_for_schema(expected.schema_version) @@ -569,7 +645,7 @@ fn collect_rebound_staged_tree( fn require_exact_tree_seal_coverage( inventory: &HeldTreeInventory, destination_root: &Path, - seals: &[(DurablePermission, ReboundObject)], + seals: &[RecoveryPermissionPlan], ) -> Result<(), StagedVerificationFailure> { let mut held = BTreeMap::new(); for directory in inventory.directories_deepest_first() { @@ -589,15 +665,21 @@ fn require_exact_tree_seal_coverage( } } let mut durable = BTreeMap::new(); - for (permission, rebound) in seals { + for plan in seals { + let permission = plan.permission(); if permission.phase != TransactionState::TreeSealIntent || permission.application != ApplicationStatus::Applied || permission.reverses_mutation_id.is_some() - || permission.expected_mode != rebound.held.mode() + || permission.expected_mode + != plan + .chain + .last() + .ok_or(StagedVerificationFailure::SealCoverage)? + .mode { return Err(StagedVerificationFailure::SealCoverage); } - let suffix = rebound + let suffix = plan .relative_path .strip_prefix(destination_root) .map_err(|_| StagedVerificationFailure::SealCoverage)? @@ -627,7 +709,7 @@ fn require_exact_tree_seal_coverage( } pub(crate) enum StartupRecoveryCapability<'a> { - Restore(RecoveryRestoreSession<'a>), + Restore(Box>), /// Nonforgeable continuation requiring staged-tree verification. Merely /// checking liveness never clears the startup mutation block. PendingVerification(Box>), @@ -635,8 +717,8 @@ pub(crate) enum StartupRecoveryCapability<'a> { VerifiedUndo(Box>), } -/// Owns all rebound FDs and borrows the exact leased WAL until ordered restore -/// completes or the session is dropped. +/// Owns only the recovery anchors and borrows the exact leased WAL until ordered +/// transient restore completes or the session is dropped. pub(crate) struct RecoveryRestoreSession<'a> { wal: &'a mut SealWal, startup_blocked: &'a mut bool, @@ -667,9 +749,15 @@ impl RecoveryRestoreSession<'_> { // Sorting is repeated here rather than trusting serialized/report order. // The exact source parent is forced after every descendant and sibling. - sort_restore_entries(&mut self.restore.entries, &self.restore.source_parent_last); + sort_restore_plans(&mut self.restore.entries, &self.restore.source_parent_last); - for (original, mut rebound) in self.restore.entries { + for plan in self.restore.entries { + let original = plan.permission.clone(); + let anchor = match plan.side { + RecoveryAnchorSide::Source => &self.restore.anchors.source, + RecoveryAnchorSide::Destination => &self.restore.anchors.destination, + }; + let mut rebound = reopen_permission_plan(anchor, &plan, &self.restore.metadata)?; rebound.verify_fresh_binding()?; rebound .held @@ -678,10 +766,7 @@ impl RecoveryRestoreSession<'_> { original.mutation_id, original.pre_mode, original.expected_mode, - self.wal - .staging_metadata(transaction) - .ok_or(RecoveryRebindError::TransactionMismatch)? - .backend(), + self.restore.metadata.backend(), original.evidence.device(), original.evidence.inode(), ) @@ -724,13 +809,13 @@ struct ReboundVerifiedUndo { source_parent: OwnedFd, destination_parent: OwnedFd, root: ReboundObject, - tree_seals: Vec<(DurablePermission, ReboundObject)>, + tree_seals: Vec, expected_manifest: DurableTreeManifest, } -/// One-shot continuation for committed-mode restoration and rename-back. Every -/// namespace and object check is performed through descriptors retained here; -/// durable paths and identities are never execution authority by themselves. +/// One-shot continuation for committed-mode restoration and rename-back. Root +/// and parent descriptors remain retained; descendants are transiently reopened +/// from the authenticated anchor and durable data is never authority by itself. pub(crate) struct VerifiedUndoRecoverySession<'a> { wal: &'a mut SealWal, startup_blocked: &'a mut bool, @@ -872,9 +957,11 @@ impl VerifiedUndoRecoverySession<'_> { } if self.wal.transaction_state(transaction) == Some(TransactionState::UndoIntent) { - // Durable evidence order is ignored; the live rebound objects are + // Durable evidence order is ignored; descriptor-free plans are // independently sorted deepest-first before any inverse fchmod. - self.undo.tree_seals.sort_by(|(left, _), (right, _)| { + self.undo.tree_seals.sort_by(|left, right| { + let left = left.permission(); + let right = right.permission(); right .evidence .relative_path() @@ -883,7 +970,8 @@ impl VerifiedUndoRecoverySession<'_> { .cmp(&left.evidence.relative_path().components().count()) .then_with(|| left.mutation_id.cmp(&right.mutation_id)) }); - for (original, rebound) in &mut self.undo.tree_seals { + for plan in &self.undo.tree_seals { + let original = plan.permission.clone(); let snapshot = self .wal .recovery_snapshot(transaction) @@ -894,12 +982,13 @@ impl VerifiedUndoRecoverySession<'_> { && permission.reverses_mutation_id == Some(original.mutation_id) }); if restored { - rebound - .held - .verify_current_mode(original.pre_mode) - .map_err(RecoveryRebindError::SealChanged)?; continue; } + let mut rebound = reopen_permission_plan( + &self.undo.anchors.destination, + plan, + &self.undo.metadata, + )?; rebound.verify_fresh_sealed_directory(original.expected_mode)?; rebound .held @@ -926,12 +1015,32 @@ impl VerifiedUndoRecoverySession<'_> { rebound.relative_path.clone(), original.evidence.filesystem_id().map(str::to_owned), ), - transform: LocalModeTransform::Restore { - original: original.clone(), - }, + transform: LocalModeTransform::Restore { original }, }, )?; } + let restored_modes = self + .undo + .tree_seals + .iter() + .map(|plan| { + let target = plan + .chain + .last() + .ok_or(RecoveryRebindError::InvalidLocator)?; + Ok((target.identity, plan.permission.pre_mode)) + }) + .collect::, RecoveryRebindError>>()?; + for plan in &mut self.undo.tree_seals { + for evidence in &mut plan.chain { + if let Some((_, mode)) = restored_modes + .iter() + .find(|(identity, _)| *identity == evidence.identity) + { + evidence.mode = *mode; + } + } + } self.wal.record_undo_modes_restored(transaction)?; #[cfg(test)] if UNDO_FAIL_STEP.with(|step| step.get() == Some("modes")) { @@ -1039,14 +1148,25 @@ impl VerifiedUndoRecoverySession<'_> { fn collect_exact_tree(&self) -> Result { self.undo.root.verify_fresh_binding()?; - for (_, rebound) in &self.undo.tree_seals { - if strong_identity_fd(&rebound.object_check_fd)? != rebound.identity { - return Err(RecoveryRebindError::BindingChanged); + let modes_restored = self.wal.transaction_state(self.transaction) + == Some(TransactionState::UndoModesRestored); + if !modes_restored { + for plan in &self.undo.tree_seals { + let rebound = reopen_permission_plan( + &self.undo.anchors.destination, + plan, + &self.undo.metadata, + )?; + rebound + .held + .verify_current_mode( + plan.chain + .last() + .ok_or(RecoveryRebindError::InvalidLocator)? + .mode, + ) + .map_err(RecoveryRebindError::SealChanged)?; } - rebound - .held - .verify_current_mode(rebound.held.mode()) - .map_err(RecoveryRebindError::SealChanged)?; } let inventory = collect_rebound_staged_tree( &self.undo.root, @@ -1062,7 +1182,8 @@ impl VerifiedUndoRecoverySession<'_> { .join(self.undo.metadata.source_basename()); let mut expected_modes = BTreeMap::new(); let mut expected_identities = BTreeMap::new(); - for (permission, _) in &self.undo.tree_seals { + for plan in &self.undo.tree_seals { + let permission = plan.permission(); let suffix = permission .evidence .relative_path() @@ -1196,22 +1317,35 @@ impl VerifiedUndoRecoverySession<'_> { } } -fn sort_restore_entries(entries: &mut [(DurablePermission, T)], source_parent: &Path) { - entries.sort_by(|(left, _), (right, _)| { - let left_is_parent = left.evidence.relative_path() == source_parent; - let right_is_parent = right.evidence.relative_path() == source_parent; - left_is_parent.cmp(&right_is_parent).then_with(|| { - right - .evidence - .relative_path() - .components() - .count() - .cmp(&left.evidence.relative_path().components().count()) - .then_with(|| left.mutation_id.cmp(&right.mutation_id)) - }) +fn sort_restore_plans(entries: &mut [RecoveryPermissionPlan], source_parent: &Path) { + entries.sort_by(|left, right| { + compare_restore_permissions(left.permission(), right.permission(), source_parent) }); } +fn sort_restore_entries(entries: &mut [(DurablePermission, T)], source_parent: &Path) { + entries + .sort_by(|(left, _), (right, _)| compare_restore_permissions(left, right, source_parent)); +} + +fn compare_restore_permissions( + left: &DurablePermission, + right: &DurablePermission, + source_parent: &Path, +) -> std::cmp::Ordering { + let left_is_parent = left.evidence.relative_path() == source_parent; + let right_is_parent = right.evidence.relative_path() == source_parent; + left_is_parent.cmp(&right_is_parent).then_with(|| { + right + .evidence + .relative_path() + .components() + .count() + .cmp(&left.evidence.relative_path().components().count()) + .then_with(|| left.mutation_id.cmp(&right.mutation_id)) + }) +} + /// Rebind startup work under fresh authenticated anchors. On any inability to /// establish strong authority, no path is mutated and the transaction is moved /// to durable `RecoveryRequired` when that transition can be recorded. @@ -1285,26 +1419,30 @@ pub(crate) fn prepare_startup_recovery<'a>( wal.transaction_state(transaction), Some(TransactionState::Prepared | TransactionState::ParentSealIntent) ) { - return Ok(StartupRecoveryCapability::Restore(RecoveryRestoreSession { - wal, - startup_blocked, - restore: ReboundRestore { - transaction, - source_parent_last: metadata.source_parent().relative_path().to_path_buf(), - entries: Vec::new(), - completion: TransactionState::Restored, + return Ok(StartupRecoveryCapability::Restore(Box::new( + RecoveryRestoreSession { + wal, + startup_blocked, + restore: ReboundRestore { + transaction, + source_parent_last: metadata.source_parent().relative_path().to_path_buf(), + anchors, + metadata, + entries: Vec::new(), + completion: TransactionState::Restored, + }, }, - })); + ))); } match rebind_work(&metadata, tree_manifest, work, &anchors) { - Ok(ReboundWork::Restore(restore)) => { - Ok(StartupRecoveryCapability::Restore(RecoveryRestoreSession { + Ok(ReboundWork::Restore(restore)) => Ok(StartupRecoveryCapability::Restore(Box::new( + RecoveryRestoreSession { wal, startup_blocked, - restore, - })) - } + restore: *restore, + }, + ))), Ok(ReboundWork::VerifyStaged(staged)) => { staged.root.verify_fresh_binding()?; // A crash after the durable applied+parents-synced outcome but @@ -1322,9 +1460,11 @@ pub(crate) fn prepare_startup_recovery<'a>( wal, startup_blocked, transaction, + destination_anchor: staged.destination_anchor, + metadata: staged.metadata, root: staged.root, expected_manifest: staged.expected_manifest, - rebound_tree_seals: staged.tree_seals, + tree_seal_plan: staged.tree_seals, }, ))) } @@ -1438,13 +1578,15 @@ fn fail_closed( } struct ReboundStaged { + destination_anchor: RecoveryFilesystemAnchor, + metadata: StagingTransactionMetadata, root: ReboundObject, expected_manifest: Option, - tree_seals: Vec<(DurablePermission, ReboundObject)>, + tree_seals: Vec, } enum ReboundWork { - Restore(ReboundRestore), + Restore(Box), VerifyStaged(Box), VerifiedUndo(Box), } @@ -1652,12 +1794,14 @@ fn rebind_work( drop(root); drop(destination_parent); let entries = rebind_permissions(&anchors.source, metadata, permissions)?; - Ok(ReboundWork::Restore(ReboundRestore { + Ok(ReboundWork::Restore(Box::new(ReboundRestore { transaction, source_parent_last: metadata.source_parent().relative_path().to_path_buf(), + anchors: anchors.duplicate()?, + metadata: metadata.clone(), entries, completion: TransactionState::Restored, - })) + }))) } RecoveryWork::RestoreSourceParentAfterRename { transaction: work_transaction, @@ -1681,12 +1825,14 @@ fn rebind_work( drop(staged); drop(source_parent); let entries = rebind_permissions(&anchors.source, metadata, permissions)?; - Ok(ReboundWork::Restore(ReboundRestore { + Ok(ReboundWork::Restore(Box::new(ReboundRestore { transaction, source_parent_last: metadata.source_parent().relative_path().to_path_buf(), + anchors: anchors.duplicate()?, + metadata: metadata.clone(), entries, completion: TransactionState::SourceParentRestored, - })) + }))) } RecoveryWork::RestoreQuarantinedSeals { transaction: work_transaction, @@ -1710,12 +1856,14 @@ fn rebind_work( drop(staged); drop(source_parent); let entries = rebind_quarantined_permissions(anchors, metadata, permissions)?; - Ok(ReboundWork::Restore(ReboundRestore { + Ok(ReboundWork::Restore(Box::new(ReboundRestore { transaction, source_parent_last: metadata.source_parent().relative_path().to_path_buf(), + anchors: anchors.duplicate()?, + metadata: metadata.clone(), entries, completion: TransactionState::Quarantined, - })) + }))) } RecoveryWork::VerifyOrQuarantineAfterRename { transaction: work_transaction, @@ -1739,6 +1887,8 @@ fn rebind_work( )?; let tree_seals = rebind_staged_tree_seals(&anchors.destination, metadata, permissions)?; Ok(ReboundWork::VerifyStaged(Box::new(ReboundStaged { + destination_anchor: anchors.destination.duplicate_authority()?, + metadata: metadata.clone(), root, expected_manifest: tree_manifest, tree_seals, @@ -1865,11 +2015,242 @@ fn rebind_locator( Ok(fd) } +fn capture_directory_evidence( + fd: &OwnedFd, + metadata: &StagingTransactionMetadata, +) -> Result { + let identity = strong_identity_fd(fd)?; + let held = certify_held_fd( + rustix::io::dup(fd) + .map_err(io::Error::from) + .map_err(RecoveryRebindError::Io)?, + )?; + #[cfg(test)] + observe_recovery_fds(); + if held.backend() != metadata.backend() { + return Err(RecoveryRebindError::BackendChanged); + } + if held.mount_id() != identity.mount_id() + || held.mount_id() != metadata.root_identity().mount_id() + { + return Err(RecoveryRebindError::MountChanged); + } + held.verify_current_mode(held.mode()) + .map_err(RecoveryRebindError::SealChanged)?; + Ok(PlannedDirectoryEvidence { + identity, + owner_uid: held.owner_uid(), + group_gid: held.group_gid(), + mode: held.mode(), + backend: held.backend(), + mount_id: held.mount_id(), + effective_uid: held.effective_uid(), + effective_groups: held.effective_groups().clone(), + }) +} + +fn build_permission_plan( + anchor: &RecoveryFilesystemAnchor, + side: RecoveryAnchorSide, + relative_path: PathBuf, + permission: DurablePermission, + expected_current_mode: u32, + metadata: &StagingTransactionMetadata, +) -> Result { + if relative_path.is_absolute() + || relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || permission.evidence.filesystem_id() != Some(metadata.filesystem_id()) + || permission.evidence.expected_mode() != permission.expected_mode + || permission.expected_mode > 0o7777 + || permission.pre_mode > 0o7777 + { + return Err(RecoveryRebindError::TransactionMismatch); + } + let expected = StrongObjectIdentity::new_with_mount( + permission.evidence.device(), + permission.evidence.inode(), + crate::seal::wal::ObjectIncarnation::new( + permission + .evidence + .generation_or_btime() + .ok_or(RecoveryRebindError::StrongIdentityUnavailable)?, + ), + anchor.mount_key, + ); + let mut current = rustix::io::dup(&anchor.fd) + .map_err(io::Error::from) + .map_err(RecoveryRebindError::Io)?; + let mut chain = Vec::new(); + require_exclusive_controller(¤t)?; + if relative_path.as_os_str().is_empty() { + chain.push(capture_directory_evidence(¤t, metadata)?); + } else { + let mut components = relative_path.components().peekable(); + while let Some(component) = components.next() { + let Component::Normal(name) = component else { + return Err(RecoveryRebindError::InvalidLocator); + }; + current = open_directory_at(¤t, name)?; + if held_mount_key(¤t)? != anchor.mount_key { + return Err(RecoveryRebindError::MountChanged); + } + if components.peek().is_some() { + require_exclusive_controller(¤t)?; + } + chain.push(capture_directory_evidence(¤t, metadata)?); + } + } + let target = chain.last().ok_or(RecoveryRebindError::InvalidLocator)?; + if target.identity != expected || target.mode != expected_current_mode { + return Err(RecoveryRebindError::BindingChanged); + } + Ok(RecoveryPermissionPlan { + permission, + side, + relative_path, + chain, + }) +} + +fn verify_held_evidence( + held: &HeldLocalBackendEvidence, + expected: &PlannedDirectoryEvidence, +) -> Result<(), RecoveryRebindError> { + held.verify_current_mode(expected.mode) + .map_err(RecoveryRebindError::SealChanged)?; + if held.backend() != expected.backend { + return Err(RecoveryRebindError::BackendChanged); + } + if held.mount_id() != expected.mount_id { + return Err(RecoveryRebindError::MountChanged); + } + if held.device() != expected.identity.device() + || held.inode() != expected.identity.inode() + || held.owner_uid() != expected.owner_uid + || held.group_gid() != expected.group_gid + || held.effective_uid() != expected.effective_uid + || held.effective_groups() != &expected.effective_groups + { + return Err(RecoveryRebindError::BindingChanged); + } + Ok(()) +} + +fn verify_planned_evidence( + fd: &OwnedFd, + expected: &PlannedDirectoryEvidence, +) -> Result { + if strong_identity_fd(fd)? != expected.identity { + return Err(RecoveryRebindError::BindingChanged); + } + let held = certify_held_fd( + rustix::io::dup(fd) + .map_err(io::Error::from) + .map_err(RecoveryRebindError::Io)?, + )?; + verify_held_evidence(&held, expected)?; + Ok(held) +} + +/// Reopen one plan entry from its retained authenticated anchor. All ancestor +/// descriptors are transient and dropped before the next entry is considered. +fn reopen_permission_plan( + anchor: &RecoveryFilesystemAnchor, + plan: &RecoveryPermissionPlan, + metadata: &StagingTransactionMetadata, +) -> Result { + if plan.relative_path.is_absolute() + || plan + .relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || anchor.backend != metadata.backend() + || anchor.mount_key != metadata.root_identity().mount_id() + || plan.permission.evidence.filesystem_id() != Some(metadata.filesystem_id()) + || plan.permission.evidence.expected_mode() != plan.permission.expected_mode + { + return Err(RecoveryRebindError::TransactionMismatch); + } + let mut current = rustix::io::dup(&anchor.fd) + .map_err(io::Error::from) + .map_err(RecoveryRebindError::Io)?; + let mut parent = None; + let mut basename = None; + require_exclusive_controller(¤t)?; + if plan.relative_path.as_os_str().is_empty() { + if plan.chain.len() != 1 { + return Err(RecoveryRebindError::InvalidLocator); + } + verify_planned_evidence(¤t, &plan.chain[0])?; + } else { + let components = plan.relative_path.components().collect::>(); + if components.len() != plan.chain.len() { + return Err(RecoveryRebindError::InvalidLocator); + } + for (index, component) in components.into_iter().enumerate() { + let Component::Normal(name) = component else { + return Err(RecoveryRebindError::InvalidLocator); + }; + let next = open_directory_at(¤t, name)?; + if index + 1 < plan.chain.len() { + require_exclusive_controller(&next)?; + } + verify_planned_evidence(&next, &plan.chain[index])?; + if index + 1 == plan.chain.len() { + parent = Some(current); + basename = Some(name.to_os_string()); + } + current = next; + } + } + let target = plan + .chain + .last() + .ok_or(RecoveryRebindError::InvalidLocator)?; + if target.identity.device() != plan.permission.evidence.device() + || target.identity.inode() != plan.permission.evidence.inode() + || target.identity.incarnation().get() + != plan + .permission + .evidence + .generation_or_btime() + .ok_or(RecoveryRebindError::StrongIdentityUnavailable)? + || target.identity.mount_id() != anchor.mount_key + { + return Err(RecoveryRebindError::TransactionMismatch); + } + let object_check_fd = rustix::io::dup(¤t) + .map_err(io::Error::from) + .map_err(RecoveryRebindError::Io)?; + let held = certify_held_fd(current)?; + verify_held_evidence(&held, target)?; + let binding = match (parent, basename) { + (Some(parent), Some(basename)) => ReboundBinding::Named { + attachment: None, + parent, + basename, + }, + (None, None) => ReboundBinding::Anchor(anchor.duplicate_authority()?), + _ => return Err(RecoveryRebindError::InvalidLocator), + }; + #[cfg(test)] + observe_recovery_fds(); + Ok(ReboundObject { + binding, + object_check_fd, + relative_path: plan.relative_path.clone(), + identity: target.identity, + held, + }) +} + fn rebind_staged_tree_seals( destination_anchor: &RecoveryFilesystemAnchor, metadata: &StagingTransactionMetadata, permissions: Vec, -) -> Result, RecoveryRebindError> { +) -> Result, RecoveryRebindError> { let source_root = metadata .source_parent() .relative_path() @@ -1878,10 +2259,12 @@ fn rebind_staged_tree_seals( .destination_parent() .relative_path() .join(metadata.destination_basename()); - let mut rebound = Vec::new(); + let mut plans = Vec::new(); + let mut mutation_ids = BTreeSet::new(); for permission in permissions { if permission.application != ApplicationStatus::Applied || permission.reverses_mutation_id.is_some() + || !mutation_ids.insert(permission.mutation_id) || permission.evidence.filesystem_id() != Some(metadata.filesystem_id()) || permission.evidence.expected_mode() != permission.expected_mode { @@ -1899,38 +2282,24 @@ fn rebind_staged_tree_seals( .strip_prefix(&source_root) .map_err(|_| RecoveryRebindError::InvalidLocator)?; let path = destination_root.join(suffix); - let expected = StrongObjectIdentity::new_with_mount( - permission.evidence.device(), - permission.evidence.inode(), - crate::seal::wal::ObjectIncarnation::new( - permission - .evidence - .generation_or_btime() - .ok_or(RecoveryRebindError::StrongIdentityUnavailable)?, - ), - destination_anchor.mount_key, - ); - let (parent, basename) = - open_confined_parent(&destination_anchor.fd, &path, destination_anchor.mount_key)?; - let object = rebind_named_child( + let expected_mode = permission.expected_mode; + plans.push(build_permission_plan( destination_anchor, - parent, - &basename, - expected, + RecoveryAnchorSide::Destination, path, + permission, + expected_mode, metadata, - Some(permission.expected_mode), - )?; - rebound.push((permission, object)); + )?); } - Ok(rebound) + Ok(plans) } fn rebind_verified_undo_tree_seals( destination_anchor: &RecoveryFilesystemAnchor, metadata: &StagingTransactionMetadata, permissions: Vec, -) -> Result, RecoveryRebindError> { +) -> Result, RecoveryRebindError> { let source_root = metadata .source_parent() .relative_path() @@ -1939,15 +2308,6 @@ fn rebind_verified_undo_tree_seals( .destination_parent() .relative_path() .join(metadata.destination_basename()); - let destination_parent = rebind_locator( - destination_anchor, - metadata.destination_parent(), - metadata.destination_parent_identity(), - )?; - let staged_root_fd = open_directory_at(&destination_parent, metadata.destination_basename())?; - if strong_identity_fd(&staged_root_fd)? != metadata.root_identity() { - return Err(RecoveryRebindError::BindingChanged); - } let originals = permissions .iter() .filter(|permission| { @@ -1957,89 +2317,114 @@ fn rebind_verified_undo_tree_seals( }) .cloned() .collect::>(); - let mut rebound = Vec::with_capacity(originals.len()); + let mut mutation_ids = BTreeSet::new(); + let mut plans = Vec::with_capacity(originals.len()); for original in originals { + if !mutation_ids.insert(original.mutation_id) + || original.evidence.filesystem_id() != Some(metadata.filesystem_id()) + || original.evidence.expected_mode() != original.expected_mode + { + return Err(RecoveryRebindError::TransactionMismatch); + } let suffix = original .evidence .relative_path() .strip_prefix(&source_root) .map_err(|_| RecoveryRebindError::InvalidLocator)?; - let path = destination_root.join(suffix); let inverse_applied = permissions.iter().any(|inverse| { inverse.phase == TransactionState::UndoIntent && inverse.application == ApplicationStatus::Applied && inverse.reverses_mutation_id == Some(original.mutation_id) + && inverse.pre_mode == original.expected_mode + && inverse.expected_mode == original.pre_mode + && inverse.evidence.filesystem_id() == original.evidence.filesystem_id() + && inverse.evidence.device() == original.evidence.device() + && inverse.evidence.inode() == original.evidence.inode() + && inverse.evidence.generation_or_btime() == original.evidence.generation_or_btime() }); - let expected_mode = if inverse_applied { - original.pre_mode - } else { - original.expected_mode - }; - let expected = StrongObjectIdentity::new_with_mount( - original.evidence.device(), - original.evidence.inode(), - crate::seal::wal::ObjectIncarnation::new( - original - .evidence - .generation_or_btime() - .ok_or(RecoveryRebindError::StrongIdentityUnavailable)?, - ), - destination_anchor.mount_key, - ); - let (parent, basename) = if suffix.as_os_str().is_empty() { - ( - rustix::io::dup(&destination_parent) - .map_err(io::Error::from) - .map_err(RecoveryRebindError::Io)?, - metadata.destination_basename().to_os_string(), - ) + let target_path = destination_root.join(suffix); + if inverse_applied { + // A crash can occur after a descendant was restored but before the + // durable UndoModesRestored marker. Reopening that already-restored + // entry would require traversing a parent whose namespace-write + // mode was intentionally restored. Keep its durable identity as a + // data-only plan and skip it during replay; remaining plans are + // ordered deepest-first, so their controllers are still sealed. + plans.push(RecoveryPermissionPlan { + permission: original.clone(), + side: RecoveryAnchorSide::Destination, + relative_path: target_path, + chain: vec![PlannedDirectoryEvidence { + identity: StrongObjectIdentity::new_with_mount( + original.evidence.device(), + original.evidence.inode(), + crate::seal::wal::ObjectIncarnation::new( + original + .evidence + .generation_or_btime() + .ok_or(RecoveryRebindError::StrongIdentityUnavailable)?, + ), + destination_anchor.mount_key, + ), + owner_uid: rustix::process::geteuid().as_raw(), + group_gid: 0, + mode: original.pre_mode, + backend: metadata.backend(), + mount_id: destination_anchor.mount_key, + effective_uid: rustix::process::geteuid().as_raw(), + effective_groups: BTreeSet::new(), + }], + }); } else { - open_parent_beneath_held_root(&staged_root_fd, suffix, destination_anchor.mount_key)? - }; - let object = rebind_named_child_from_held_parent( - parent, - &basename, - expected, - path, - metadata, - expected_mode, - )?; - rebound.push((original, object)); + plans.push(build_permission_plan( + destination_anchor, + RecoveryAnchorSide::Destination, + target_path, + original.clone(), + original.expected_mode, + metadata, + )?); + } } - if rebound.is_empty() { + if plans.is_empty() { return Err(RecoveryRebindError::UndoManifestChanged); } - Ok(rebound) + Ok(plans) } fn rebind_permissions( anchor: &RecoveryFilesystemAnchor, metadata: &StagingTransactionMetadata, permissions: Vec, -) -> Result, RecoveryRebindError> { +) -> Result, RecoveryRebindError> { + let source_parent = metadata.source_parent().relative_path(); + let source_root = source_parent.join(metadata.source_basename()); + let mut mutation_ids = BTreeSet::new(); permissions .into_iter() .map(|permission| { let path = permission.evidence.relative_path().to_path_buf(); - let expected = StrongObjectIdentity::new_with_mount( - permission.evidence.device(), - permission.evidence.inode(), - crate::seal::wal::ObjectIncarnation::new( - permission - .evidence - .generation_or_btime() - .ok_or(RecoveryRebindError::StrongIdentityUnavailable)?, - ), - anchor.mount_key, - ); - let rebound = rebind_permission_object( + let path_matches_phase = match permission.phase { + TransactionState::ParentSealIntent => path == source_parent, + TransactionState::TreeSealIntent => path.starts_with(&source_root), + _ => false, + }; + if permission.application != ApplicationStatus::Applied + || permission.reverses_mutation_id.is_some() + || !path_matches_phase + || !mutation_ids.insert(permission.mutation_id) + { + return Err(RecoveryRebindError::TransactionMismatch); + } + let expected_mode = permission.expected_mode; + build_permission_plan( anchor, - &path, - expected, + RecoveryAnchorSide::Source, + path, + permission, + expected_mode, metadata, - Some(permission.expected_mode), - )?; - Ok((permission, rebound)) + ) }) .collect() } @@ -2048,7 +2433,7 @@ fn rebind_quarantined_permissions( anchors: &RecoveryAnchors, metadata: &StagingTransactionMetadata, permissions: Vec, -) -> Result, RecoveryRebindError> { +) -> Result, RecoveryRebindError> { let source_root = metadata .source_parent() .relative_path() @@ -2057,12 +2442,23 @@ fn rebind_quarantined_permissions( .destination_parent() .relative_path() .join(metadata.destination_basename()); + let mut mutation_ids = BTreeSet::new(); permissions .into_iter() .map(|permission| { - let (anchor, path) = if permission.phase == TransactionState::ParentSealIntent { + if permission.application != ApplicationStatus::Applied + || permission.reverses_mutation_id.is_some() + || !mutation_ids.insert(permission.mutation_id) + { + return Err(RecoveryRebindError::TransactionMismatch); + } + let (anchor, side, path) = if permission.phase == TransactionState::ParentSealIntent { + if permission.evidence.relative_path() != metadata.source_parent().relative_path() { + return Err(RecoveryRebindError::TransactionMismatch); + } ( &anchors.source, + RecoveryAnchorSide::Source, permission.evidence.relative_path().to_path_buf(), ) } else if permission.phase == TransactionState::TreeSealIntent { @@ -2071,110 +2467,20 @@ fn rebind_quarantined_permissions( .relative_path() .strip_prefix(&source_root) .map_err(|_| RecoveryRebindError::InvalidLocator)?; - (&anchors.destination, destination_root.join(suffix)) + ( + &anchors.destination, + RecoveryAnchorSide::Destination, + destination_root.join(suffix), + ) } else { return Err(RecoveryRebindError::TransactionMismatch); }; - let expected = StrongObjectIdentity::new_with_mount( - permission.evidence.device(), - permission.evidence.inode(), - crate::seal::wal::ObjectIncarnation::new( - permission - .evidence - .generation_or_btime() - .ok_or(RecoveryRebindError::StrongIdentityUnavailable)?, - ), - anchor.mount_key, - ); - let rebound = rebind_permission_object( - anchor, - &path, - expected, - metadata, - Some(permission.expected_mode), - )?; - Ok((permission, rebound)) + let expected_mode = permission.expected_mode; + build_permission_plan(anchor, side, path, permission, expected_mode, metadata) }) .collect() } -fn open_parent_beneath_held_root( - root: &OwnedFd, - suffix: &Path, - expected_mount: u64, -) -> Result<(OwnedFd, OsString), RecoveryRebindError> { - if suffix.is_absolute() - || suffix - .components() - .any(|component| !matches!(component, Component::Normal(_))) - { - return Err(RecoveryRebindError::InvalidLocator); - } - let basename = suffix - .file_name() - .ok_or(RecoveryRebindError::InvalidLocator)? - .to_os_string(); - let mut current = rustix::io::dup(root) - .map_err(io::Error::from) - .map_err(RecoveryRebindError::Io)?; - if let Some(parent) = suffix.parent() { - for component in parent.components() { - let Component::Normal(name) = component else { - return Err(RecoveryRebindError::InvalidLocator); - }; - current = open_directory_at(¤t, name)?; - if held_mount_key(¤t)? != expected_mount { - return Err(RecoveryRebindError::MountChanged); - } - } - } - Ok((current, basename)) -} - -fn rebind_named_child_from_held_parent( - parent: OwnedFd, - basename: &OsStr, - expected: StrongObjectIdentity, - relative_path: PathBuf, - metadata: &StagingTransactionMetadata, - expected_mode: u32, -) -> Result { - if !normal_basename(basename) { - return Err(RecoveryRebindError::InvalidLocator); - } - let fd = open_directory_at(&parent, basename)?; - if strong_identity_fd(&fd)? != expected { - return Err(RecoveryRebindError::BindingChanged); - } - let object_check_fd = rustix::io::dup(&fd) - .map_err(io::Error::from) - .map_err(RecoveryRebindError::Io)?; - let held = certify_held_fd(fd)?; - if held.backend() != metadata.backend() { - return Err(RecoveryRebindError::BackendChanged); - } - if held.mount_id() != expected.mount_id() { - return Err(RecoveryRebindError::MountChanged); - } - if held.device() != expected.device() - || held.inode() != expected.inode() - || held.mode() != expected_mode - { - return Err(RecoveryRebindError::BindingChanged); - } - Ok(ReboundObject { - binding: ReboundBinding::Named { - attachment: None, - parent, - basename: basename.to_os_string(), - }, - object_check_fd, - relative_path, - identity: expected, - held, - }) -} - fn rebind_permission_object( anchor: &RecoveryFilesystemAnchor, relative_path: &Path, diff --git a/crates/degu-core/src/staging/recovery/tests.rs b/crates/degu-core/src/staging/recovery/tests.rs index 55f16d9..21d1e59 100644 --- a/crates/degu-core/src/staging/recovery/tests.rs +++ b/crates/degu-core/src/staging/recovery/tests.rs @@ -14,6 +14,7 @@ fn open_dir(path: &Path) -> OwnedFd { struct Fixture { _temp: tempfile::TempDir, + base: PathBuf, source_anchor: OwnedFd, destination_anchor: OwnedFd, metadata: StagingTransactionMetadata, @@ -23,8 +24,9 @@ struct Fixture { fn fixture(staged: bool) -> Option { let temp = crate::secure_test_tempdir().unwrap(); - let source = temp.path().join("source"); - let destination = temp.path().join("destination"); + let base = temp.path().canonicalize().unwrap(); + let source = base.join("source"); + let destination = base.join("destination"); fs::create_dir(&source).unwrap(); fs::create_dir(&destination).unwrap(); fs::set_permissions(&source, fs::Permissions::from_mode(0o700)).unwrap(); @@ -37,16 +39,23 @@ fn fixture(staged: bool) -> Option { fs::create_dir(&root).unwrap(); fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); - let source_anchor = open_dir(temp.path()); - let destination_anchor = open_dir(temp.path()); + let source_anchor = open_dir(&base); + let destination_anchor = open_dir(&base); let backend = match certify_held_fd_backend(&source_anchor) { Ok(backend) => backend, - Err(_) => return None, + Err( + error @ (CertificationError::UnsupportedPlatform + | CertificationError::UnsupportedFilesystem), + ) => { + eprintln!("SKIP staging-recovery fixture: {error:?}"); + return None; + } + Err(error) => panic!("staging-recovery fixture certification failed: {error:?}"), }; - let filesystem_id = held_filesystem_id(&source_anchor).ok()?; - let source_identity = strong_identity_fd(&open_dir(&source)).ok()?; - let destination_identity = strong_identity_fd(&open_dir(&destination)).ok()?; - let root_identity = strong_identity_fd(&open_dir(&root)).ok()?; + let filesystem_id = held_filesystem_id(&source_anchor).unwrap(); + let source_identity = strong_identity_fd(&open_dir(&source)).unwrap(); + let destination_identity = strong_identity_fd(&open_dir(&destination)).unwrap(); + let root_identity = strong_identity_fd(&open_dir(&root)).unwrap(); let metadata = StagingTransactionMetadata::new( StagingLocator::new(PathBuf::from("source"), filesystem_id.clone()).unwrap(), source_identity, @@ -61,6 +70,7 @@ fn fixture(staged: bool) -> Option { .unwrap(); Some(Fixture { _temp: temp, + base, source_anchor, destination_anchor, metadata, @@ -160,7 +170,7 @@ fn exact_name_replacement_is_rejected_even_on_same_backend() { let Some(fixture) = fixture(false) else { return; }; - let source = fixture._temp.path().join("source"); + let source = fixture.base.as_path().join("source"); fs::rename(source.join("root"), source.join("old-root")).unwrap(); fs::create_dir(source.join("root")).unwrap(); let result = rebind_work( @@ -193,7 +203,7 @@ fn capability_rechecks_name_immediately_before_use() { let ReboundWork::VerifyStaged(staged) = rebound else { panic!("expected staged capability"); }; - let destination = fixture._temp.path().join("destination"); + let destination = fixture.base.as_path().join("destination"); fs::rename(destination.join("staged"), destination.join("old-staged")).unwrap(); fs::create_dir(destination.join("staged")).unwrap(); assert!(matches!( @@ -221,7 +231,7 @@ fn recovery_capability_rechecks_final_namespace_controller_exclusivity() { panic!("expected staged capability"); }; fs::set_permissions( - fixture._temp.path().join("destination"), + fixture.base.as_path().join("destination"), fs::Permissions::from_mode(0o770), ) .unwrap(); @@ -236,7 +246,7 @@ fn recovery_rebind_rejects_writable_anchor_controller() { let Some(fixture) = fixture(true) else { return; }; - fs::set_permissions(fixture._temp.path(), fs::Permissions::from_mode(0o770)).unwrap(); + fs::set_permissions(fixture.base.as_path(), fs::Permissions::from_mode(0o770)).unwrap(); assert!(matches!( rebind_work( &fixture.metadata, @@ -278,6 +288,85 @@ fn mount_drift_between_authenticated_anchors_fails_closed() { )); } +fn permission_for_directory( + fixture: &Fixture, + path: &Path, + relative_path: &str, +) -> DurablePermission { + let identity = strong_identity_fd(&open_dir(path)).unwrap(); + DurablePermission { + mutation_id: 1, + phase: TransactionState::TreeSealIntent, + evidence: PersistentRecoveryEvidence::new( + PathBuf::from(relative_path), + Some(fixture.filesystem_id.clone()), + identity.device(), + identity.inode(), + Some(identity.incarnation().get()), + 0o700, + ) + .unwrap(), + pre_mode: 0o700, + expected_mode: 0o700, + reverses_mutation_id: None, + application: ApplicationStatus::Applied, + } +} + +#[test] +fn build_permission_plan_rejects_group_writable_intermediate_ancestor_in_three_level_path() { + let Some(fixture) = fixture(false) else { + return; + }; + let root = fixture.base.join("source/root"); + let child = root.join("child"); + fs::create_dir(&child).unwrap(); + fs::set_permissions(&child, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o770)).unwrap(); + let permission = permission_for_directory(&fixture, &child, "source/root/child"); + let recovery_anchors = anchors(&fixture); + + assert!(matches!( + build_permission_plan( + &recovery_anchors.source, + RecoveryAnchorSide::Source, + PathBuf::from("source/root/child"), + permission, + 0o700, + &fixture.metadata, + ), + Err(RecoveryRebindError::LocatorControllerNotExclusive) + )); +} + +#[test] +fn reopen_permission_plan_rejects_group_writable_intermediate_ancestor_in_three_level_path() { + let Some(fixture) = fixture(false) else { + return; + }; + let root = fixture.base.join("source/root"); + let child = root.join("child"); + fs::create_dir(&child).unwrap(); + fs::set_permissions(&child, fs::Permissions::from_mode(0o700)).unwrap(); + let permission = permission_for_directory(&fixture, &child, "source/root/child"); + let recovery_anchors = anchors(&fixture); + let plan = build_permission_plan( + &recovery_anchors.source, + RecoveryAnchorSide::Source, + PathBuf::from("source/root/child"), + permission, + 0o700, + &fixture.metadata, + ) + .unwrap(); + + fs::set_permissions(&root, fs::Permissions::from_mode(0o770)).unwrap(); + assert!(matches!( + reopen_permission_plan(&recovery_anchors.source, &plan, &fixture.metadata), + Err(RecoveryRebindError::LocatorControllerNotExclusive) + )); +} + #[test] fn unknown_rename_outcome_forbids_all_source_destination_lookup() { assert!(recovery_lookup_is_forbidden( @@ -302,7 +391,7 @@ fn uncertain_staging_intent_resolves_before_after_and_at_fresh_resolution() { let Some(fixture) = fixture(false) else { return; }; - let source_path = fixture._temp.path().join("source"); + let source_path = fixture.base.as_path().join("source"); fs::set_permissions(&source_path, fs::Permissions::from_mode(0o770)).unwrap(); let wal_temp = crate::secure_test_tempdir().unwrap(); let store = SealWalStore::open_or_create( @@ -432,9 +521,9 @@ fn uncertain_inverse_intents_resolve_before_and_after_fchmod_in_every_restore_ph let Some(fixture) = fixture(false) else { return; }; - let source_path = fixture._temp.path().join("source"); + let source_path = fixture.base.as_path().join("source"); let root_path = source_path.join("root"); - let staged_path = fixture._temp.path().join("destination/staged"); + let staged_path = fixture.base.as_path().join("destination/staged"); fs::set_permissions(&source_path, fs::Permissions::from_mode(0o770)).unwrap(); fs::set_permissions(&root_path, fs::Permissions::from_mode(0o770)).unwrap(); let wal_temp = crate::secure_test_tempdir().unwrap(); @@ -696,7 +785,7 @@ fn exact_staging_snapshot_restores_all_applied_permissions_and_reaches_restored( let Some(fixture) = fixture(false) else { return; }; - let source_path = fixture._temp.path().join("source"); + let source_path = fixture.base.as_path().join("source"); let root_path = source_path.join("root"); fs::set_permissions(&source_path, fs::Permissions::from_mode(0o770)).unwrap(); fs::set_permissions(&root_path, fs::Permissions::from_mode(0o770)).unwrap(); @@ -816,8 +905,8 @@ fn quarantined_active_seals_restore_in_place_and_unblock_without_unquarantining( let Some(fixture) = fixture(true) else { return; }; - let source_path = fixture._temp.path().join("source"); - let staged_path = fixture._temp.path().join("destination/staged"); + let source_path = fixture.base.as_path().join("source"); + let staged_path = fixture.base.as_path().join("destination/staged"); fs::set_permissions(&source_path, fs::Permissions::from_mode(0o770)).unwrap(); fs::set_permissions(&staged_path, fs::Permissions::from_mode(0o770)).unwrap(); let wal_temp = crate::secure_test_tempdir().unwrap(); @@ -1011,18 +1100,18 @@ fn staged_pending( ) -> Option<(Fixture, SealWal, bool, TransactionId)> { let fixture = fixture(true)?; let transaction = TransactionId([0xa3; 16]); - let store_path = fixture._temp.path().join("verifier-wal"); - let store = SealWalStore::open_or_create(&store_path).ok()?; - let mut wal = store.try_lease().ok()?.into_new_wal().ok()?; + let store_path = fixture.base.as_path().join("verifier-wal"); + let store = SealWalStore::open_or_create(&store_path).unwrap(); + let mut wal = store.try_lease().unwrap().into_new_wal().unwrap(); wal.begin_staging(transaction, fixture.metadata.clone()) - .ok()?; + .unwrap(); - let source = fixture._temp.path().join("source"); - fs::set_permissions(&source, fs::Permissions::from_mode(0o770)).ok()?; + let source = fixture.base.as_path().join("source"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o770)).unwrap(); let source_identity = fixture.metadata.source_parent_identity(); let source_fd = open_dir(&source); wal.transition_staging_for_test(transaction, TransactionState::ParentSealIntent) - .ok()?; + .unwrap(); wal.apply_staging_permission_mutation( PermissionIntent { transaction, @@ -1034,25 +1123,26 @@ fn staged_pending( source_identity.inode(), Some(source_identity.incarnation().get()), 0o750, - )?, + ) + .unwrap(), pre_mode: 0o770, expected_mode: 0o750, reverses_mutation_id: None, }, || rustix::fs::fchmod(&source_fd, Mode::from_raw_mode(0o750)).map_err(io::Error::from), ) - .ok()?; + .unwrap(); wal.transition_staging_for_test(transaction, TransactionState::ParentSealed) - .ok()?; + .unwrap(); wal.transition_staging_for_test(transaction, TransactionState::TreeSealIntent) - .ok()?; + .unwrap(); - let destination = fixture._temp.path().join("destination"); + let destination = fixture.base.as_path().join("destination"); let staged = destination.join("staged"); let child = staged.join("child"); - fs::create_dir(&child).ok()?; - fs::set_permissions(&child, fs::Permissions::from_mode(0o700)).ok()?; - let child_identity = strong_identity_fd(&open_dir(&child)).ok()?; + fs::create_dir(&child).unwrap(); + fs::set_permissions(&child, fs::Permissions::from_mode(0o700)).unwrap(); + let child_identity = strong_identity_fd(&open_dir(&child)).unwrap(); let root_identity = fixture.metadata.root_identity(); if include_tree_seal { let root_fd = open_dir(&staged); @@ -1067,14 +1157,15 @@ fn staged_pending( root_identity.inode(), Some(root_identity.incarnation().get()), 0o500, - )?, + ) + .unwrap(), pre_mode: 0o700, expected_mode: 0o500, reverses_mutation_id: None, }, || rustix::fs::fchmod(&root_fd, Mode::from_raw_mode(0o500)).map_err(io::Error::from), ) - .ok()?; + .unwrap(); let child_fd = open_dir(&child); wal.apply_staging_permission_mutation( PermissionIntent { @@ -1087,26 +1178,28 @@ fn staged_pending( child_identity.inode(), Some(child_identity.incarnation().get()), 0o500, - )?, + ) + .unwrap(), pre_mode: 0o700, expected_mode: 0o500, reverses_mutation_id: None, }, || rustix::fs::fchmod(&child_fd, Mode::from_raw_mode(0o500)).map_err(io::Error::from), ) - .ok()?; + .unwrap(); } - let inventory = HeldTreeInventory::collect( - certify_held_fd(open_dir(&destination)).ok()?, + let inventory = HeldTreeInventory::collect_for_schema( + certify_held_fd(open_dir(&destination)).unwrap(), OsStr::new("staged"), crate::safety::PROTECTED_DESCENDANT_DIR_NAMES .iter() .map(OsString::from) .collect(), HeldTreeLimits::default(), + 2, ) - .ok()?; - let fingerprint = inventory.fingerprint(); + .unwrap(); + let fingerprint = inventory.fingerprint_for_schema(2).unwrap(); let manifest = DurableTreeManifest { schema_version: 2, entry_count: fingerprint.entry_count, @@ -1116,13 +1209,13 @@ fn staged_pending( [0x55; 32] }, }; - wal.complete_tree_manifest(transaction, manifest).ok()?; + wal.complete_tree_manifest(transaction, manifest).unwrap(); wal.transition_staging_for_test(transaction, TransactionState::TreeSealed) - .ok()?; - wal.record_rename_intent(transaction).ok()?; - wal.record_applied_rename_for_test(transaction).ok()?; + .unwrap(); + wal.record_rename_intent(transaction).unwrap(); + wal.record_applied_rename_for_test(transaction).unwrap(); wal.transition_staging_for_test(transaction, TransactionState::StagedUnverified) - .ok()?; + .unwrap(); Some((fixture, wal, true, transaction)) } @@ -1176,7 +1269,7 @@ fn dropping_pending_before_transition_replays_staged_unverified() { drop(capability); drop(wal); - let store = SealWalStore::open_or_create(&fixture._temp.path().join("verifier-wal")).unwrap(); + let store = SealWalStore::open_or_create(&fixture.base.as_path().join("verifier-wal")).unwrap(); let (reopened, report) = crate::staging::SealedStagingEngine::open(&store).unwrap(); assert_eq!(report.candidates().len(), 1); assert_eq!( @@ -1208,7 +1301,7 @@ fn durable_staged_sealed_replays_without_commit_promotion() { )); drop(wal); - let store = SealWalStore::open_or_create(&fixture._temp.path().join("verifier-wal")).unwrap(); + let store = SealWalStore::open_or_create(&fixture.base.as_path().join("verifier-wal")).unwrap(); let (reopened, report) = crate::staging::SealedStagingEngine::open(&store).unwrap(); assert_eq!(report.candidates().len(), 1); assert_eq!( @@ -1227,7 +1320,7 @@ fn manifest_mismatch_is_durably_quarantined_without_mode_restore() { else { return; }; - let staged = fixture._temp.path().join("destination/staged"); + let staged = fixture.base.as_path().join("destination/staged"); let mode_before = fs::metadata(&staged).unwrap().permissions().mode() & 0o7777; let capability = prepare_startup_recovery( &mut wal, @@ -1271,7 +1364,7 @@ fn mode_drift_after_capability_creation_is_durably_quarantined() { panic!("expected pending verification"); }; fs::set_permissions( - fixture._temp.path().join("destination/staged/child"), + fixture.base.as_path().join("destination/staged/child"), fs::Permissions::from_mode(0o700), ) .unwrap(); @@ -1291,7 +1384,7 @@ fn added_entry_after_manifest_is_durably_quarantined() { else { return; }; - let staged = fixture._temp.path().join("destination/staged"); + let staged = fixture.base.as_path().join("destination/staged"); fs::set_permissions(&staged, fs::Permissions::from_mode(0o700)).unwrap(); fs::write(staged.join("added"), b"late").unwrap(); // Re-seal so the extra entry is the only divergence from the manifest. diff --git a/crates/degu-core/src/staging/rename.rs b/crates/degu-core/src/staging/rename.rs index 0e21dbc..eb8f4df 100644 --- a/crates/degu-core/src/staging/rename.rs +++ b/crates/degu-core/src/staging/rename.rs @@ -352,9 +352,9 @@ pub(crate) enum StagingRenameError { } /// Nonforgeable live result of the held-FD seal/rename sequence. It retains -/// the exact leased WAL, both parents, the staged root, and sealed directory -/// descriptors, but exposes no -/// namespace, restore, commit, purge, unlink, or deletion operation. +/// the exact leased WAL, both parents, the staged root, and the data-only tree +/// inventory with its root reopen anchor, but exposes no namespace, restore, +/// commit, purge, unlink, or deletion operation. pub(crate) struct StagedUnverifiedTree<'a> { wal: &'a mut SealWal, startup_blocked: &'a mut bool, diff --git a/crates/degu-core/src/staging/rename/tests.rs b/crates/degu-core/src/staging/rename/tests.rs index fe32469..fb8cb3a 100644 --- a/crates/degu-core/src/staging/rename/tests.rs +++ b/crates/degu-core/src/staging/rename/tests.rs @@ -2,7 +2,8 @@ use super::*; use crate::seal::store::SealWalStore; use crate::seal::wal::{DurableRenameOutcome, ProductionAssociation}; use crate::staging::recovery::{ - RecoveryAnchors, RecoveryFilesystemAnchor, StagedVerificationOutcome, StartupRecoveryCapability, + RecoveryAnchors, RecoveryFilesystemAnchor, StagedVerificationFailure, + StagedVerificationOutcome, StartupRecoveryCapability, install_recovery_fd_observer, }; use crate::staging::{ ForwardFailureDisposition, SealedStagingEngine, StartupRecoveryAnchors, @@ -156,6 +157,47 @@ fn set_mode(path: &Path, mode: u32) { std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); } +fn assert_only_parent_seal_is_durable(fixture: &Fixture, transaction: TransactionId) { + let mut lease = fixture.store.try_lease().unwrap(); + let replay = lease.replay_and_repair().unwrap(); + let recovered = &replay.transactions[&transaction]; + assert_eq!(recovered.permissions.len(), 1); + assert_eq!(recovered.permissions[0].mutation_id, 0); + assert_eq!( + recovered.permissions[0].evidence.relative_path(), + Path::new("source-parent") + ); + assert_eq!(mode(&fixture.source_parent), 0o750); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn supplementary_group_other_than(current: u32) -> Option { + let count = unsafe { libc::getgroups(0, std::ptr::null_mut()) }; + if count <= 0 { + return None; + } + let mut groups = vec![0 as libc::gid_t; count as usize]; + let filled = unsafe { libc::getgroups(count, groups.as_mut_ptr()) }; + (filled == count) + .then_some(groups) + .into_iter() + .flatten() + .find(|group| *group != current) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn set_group(path: &Path, gid: u32) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + let directory = std::fs::File::open(path)?; + let result = + unsafe { libc::fchown(directory.as_raw_fd(), !0 as libc::uid_t, gid as libc::gid_t) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + #[test] fn exact_held_tree_reaches_only_staged_unverified() { let Some(fixture) = Fixture::new() else { @@ -196,6 +238,23 @@ fn exact_held_tree_reaches_only_staged_unverified() { if identity == recovered.staging.as_ref().unwrap().root_identity() )); assert_eq!(recovered.permissions.len(), 3); + assert_eq!( + recovered + .permissions + .iter() + .map(|permission| permission.mutation_id) + .collect::>(), + vec![0, 1, 2], + "source parent remains mutation 0, then reverse-BFS child and root" + ); + assert_eq!( + recovered.permissions[1].evidence.relative_path(), + Path::new("source-parent/root/child") + ); + assert_eq!( + recovered.permissions[2].evidence.relative_path(), + Path::new("source-parent/root") + ); assert!(recovered.tree_manifest.is_some()); drop(lease); @@ -217,6 +276,182 @@ fn exact_held_tree_reaches_only_staged_unverified() { assert!(verified.startup_is_blocked()); } +#[test] +fn transient_seal_race_fails_identity_before_fchmod_and_keeps_parent_anchor_stable() { + let Some(fixture) = Fixture::new() else { + return; + }; + let source_parent_identity = std::fs::metadata(&fixture.source_parent).unwrap(); + let child = fixture.source_root.join("child"); + let displaced = fixture.source_root.join("displaced-child"); + let replacement = child.clone(); + crate::backend::held::install_transient_seal_test_hook(move |path| { + assert_eq!(path, Path::new("child")); + std::fs::rename(&child, &displaced).unwrap(); + std::fs::create_dir(&replacement).unwrap(); + set_mode(&replacement, 0o700); + }); + + let transaction = TransactionId([0xb7; 16]); + let (mut engine, report) = SealedStagingEngine::open(&fixture.store).unwrap(); + assert!(report.is_empty()); + let error = match engine.stage_prepared_root(transaction, fixture.prepare()) { + Ok(_) => panic!("identity replacement must fail before transient fchmod"), + Err(error) => error, + }; + assert!(matches!( + error, + StagingRenameError::TreeSeal(HeldTreeSealError::Tree( + HeldTreeError::IdentityChanged(ref path) + )) if path == Path::new("child") + )); + assert_eq!(mode(&fixture.source_root.join("displaced-child")), 0o770); + assert_eq!(mode(&fixture.source_root.join("child")), 0o700); + let after_parent = std::fs::metadata(&fixture.source_parent).unwrap(); + use std::os::unix::fs::MetadataExt; + assert_eq!(source_parent_identity.dev(), after_parent.dev()); + assert_eq!(source_parent_identity.ino(), after_parent.ino()); + + drop(engine); + assert_only_parent_seal_is_durable(&fixture, transaction); +} + +#[test] +fn transient_seal_mode_drift_fails_before_child_intent_or_fchmod() { + use std::cell::Cell; + use std::rc::Rc; + + let Some(fixture) = Fixture::new() else { + return; + }; + let child = fixture.source_root.join("child"); + let fired = Rc::new(Cell::new(false)); + let hook_fired = Rc::clone(&fired); + crate::backend::held::install_transient_seal_test_hook(move |path| { + assert_eq!(path, Path::new("child")); + assert!(!hook_fired.replace(true), "transient hook fired twice"); + set_mode(&child, 0o700); + }); + + let transaction = TransactionId([0xb8; 16]); + let (mut engine, report) = SealedStagingEngine::open(&fixture.store).unwrap(); + assert!(report.is_empty()); + let error = match engine.stage_prepared_root(transaction, fixture.prepare()) { + Ok(_) => panic!("mode drift must fail before transient fchmod"), + Err(error) => error, + }; + assert!(fired.get(), "transient seal race hook did not fire"); + assert!(matches!( + error, + StagingRenameError::TreeSeal(HeldTreeSealError::Tree( + HeldTreeError::IdentityChanged(ref path) + )) if path == Path::new("child") + )); + assert_eq!(mode(&fixture.source_root.join("child")), 0o700); + + drop(engine); + assert_only_parent_seal_is_durable(&fixture, transaction); +} + +#[test] +fn transient_seal_rejects_drift_to_same_minimal_target_as_old_mode() { + use std::cell::Cell; + use std::rc::Rc; + + let Some(fixture) = Fixture::new() else { + return; + }; + let child = fixture.source_root.join("child"); + let fired = Rc::new(Cell::new(false)); + let hook_fired = Rc::clone(&fired); + crate::backend::held::install_transient_seal_test_hook(move |path| { + assert_eq!(path, Path::new("child")); + assert!(!hook_fired.replace(true), "transient hook fired twice"); + // 0770 seals to 0750. Planting 0750 proves the executor must not + // accept a new pre_mode merely because its target would be identical. + set_mode(&child, 0o750); + }); + + let transaction = TransactionId([0xb9; 16]); + let (mut engine, report) = SealedStagingEngine::open(&fixture.store).unwrap(); + assert!(report.is_empty()); + let error = match engine.stage_prepared_root(transaction, fixture.prepare()) { + Ok(_) => panic!("same-target pre-mode drift must fail before transient fchmod"), + Err(error) => error, + }; + assert!(fired.get(), "transient seal race hook did not fire"); + assert!(matches!( + error, + StagingRenameError::TreeSeal(HeldTreeSealError::Tree( + HeldTreeError::IdentityChanged(ref path) + )) if path == Path::new("child") + )); + assert_eq!(mode(&fixture.source_root.join("child")), 0o750); + + drop(engine); + assert_only_parent_seal_is_durable(&fixture, transaction); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test] +fn transient_seal_group_drift_fails_before_child_intent_or_fchmod_when_permitted() { + use std::cell::Cell; + use std::os::unix::fs::MetadataExt; + use std::rc::Rc; + + let Some(fixture) = Fixture::new() else { + return; + }; + let child = fixture.source_root.join("child"); + let collected_gid = std::fs::metadata(&child).unwrap().gid(); + let Some(alternate_gid) = supplementary_group_other_than(collected_gid) else { + eprintln!( + "group-drift fixture skipped: process has no supplementary group distinct from gid {collected_gid}" + ); + return; + }; + let probe = tempfile::tempdir_in(&fixture.base).unwrap(); + if let Err(error) = set_group(probe.path(), alternate_gid) { + eprintln!( + "group-drift fixture skipped: platform refused fchown to supplementary gid {alternate_gid}: {error}" + ); + return; + } + drop(probe); + + let fired = Rc::new(Cell::new(false)); + let hook_fired = Rc::clone(&fired); + crate::backend::held::install_transient_seal_test_hook(move |path| { + assert_eq!(path, Path::new("child")); + assert!(!hook_fired.replace(true), "transient hook fired twice"); + set_group(&child, alternate_gid).unwrap(); + }); + + let transaction = TransactionId([0xba; 16]); + let (mut engine, report) = SealedStagingEngine::open(&fixture.store).unwrap(); + assert!(report.is_empty()); + let error = match engine.stage_prepared_root(transaction, fixture.prepare()) { + Ok(_) => panic!("gid drift must fail before transient fchmod"), + Err(error) => error, + }; + assert!(fired.get(), "transient seal race hook did not fire"); + assert!(matches!( + error, + StagingRenameError::TreeSeal(HeldTreeSealError::Tree( + HeldTreeError::IdentityChanged(ref path) + )) if path == Path::new("child") + )); + assert_eq!( + std::fs::metadata(fixture.source_root.join("child")) + .unwrap() + .gid(), + alternate_gid + ); + + drop(engine); + assert_only_parent_seal_is_durable(&fixture, transaction); +} + #[test] fn forward_coordinator_reaches_verified_commit_before_returning() { let Some(fixture) = Fixture::new() else { @@ -2163,3 +2398,138 @@ fn every_postorder_progress_boundary_stops_without_outcome_or_replacement_deleti ); } } + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn assert_recovery_tree_seals_fit_bounded_process_fd_budget() { + let Some(fixture) = Fixture::new() else { + return; + }; + for index in 0..240 { + let sibling = fixture.source_root.join(format!("bounded-{index:03}")); + std::fs::create_dir(&sibling).unwrap(); + set_mode(&sibling, 0o770); + } + + // This subprocess-only reduction never raises the inherited limit. The old + // rebound vector needed several descriptors per directory and deterministically + // exhausted this budget before staged verification or verified undo. + let mut limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + assert_eq!( + unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) }, + 0 + ); + limit.rlim_cur = limit.rlim_cur.min(128); + assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &limit) }, 0); + + let fd_directory = if cfg!(target_os = "linux") { + "/proc/self/fd" + } else { + "/dev/fd" + }; + let baseline = std::fs::read_dir(fd_directory).unwrap().count(); + let peak = std::rc::Rc::new(std::cell::Cell::new(baseline)); + let observed_peak = std::rc::Rc::clone(&peak); + let _observer = install_recovery_fd_observer(move || { + observed_peak.set( + observed_peak + .get() + .max(std::fs::read_dir(fd_directory).unwrap().count()), + ); + }); + + let transaction = TransactionId([0xf6; 16]); + let mut ready = stage_production(&fixture, transaction); + assert_eq!( + ready.state(transaction), + Some(TransactionState::VerifiedCommitted) + ); + let token = ready + .verified_undo_token(transaction, "undo-group") + .unwrap(); + ready + .undo_verified(token, verified_undo_request(&fixture)) + .unwrap(); + assert_eq!(ready.state(transaction), Some(TransactionState::Restored)); + assert!(fixture.source_root.is_dir()); + assert!( + peak.get().saturating_sub(baseline) <= 24, + "recovery retained per-directory descriptors: baseline={baseline}, peak={}", + peak.get() + ); + assert_eq!( + std::fs::read_dir(&fixture.source_root).unwrap().count(), + 241, + "all 240 siblings plus the content-bearing child must survive undo" + ); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test] +fn staged_verification_and_verified_undo_are_bounded_across_240_tree_seals() { + const CHILD_MARKER_ENV: &str = "DEGU_RECOVERY_FD_OBSERVATION_CHILD_MARKER"; + if let Some(marker) = std::env::var_os(CHILD_MARKER_ENV) { + assert_recovery_tree_seals_fit_bounded_process_fd_budget(); + std::fs::write(marker, b"observed").unwrap(); + return; + } + + let marker_dir = tempfile::tempdir().unwrap(); + let marker = marker_dir.path().join("completed"); + let test_name = format!( + "{}::staged_verification_and_verified_undo_are_bounded_across_240_tree_seals", + module_path!() + .strip_prefix("degu_core::") + .unwrap_or(module_path!()) + ); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name, "--nocapture"]) + .env(CHILD_MARKER_ENV, &marker) + .status() + .unwrap(); + assert!(status.success(), "isolated recovery FD test failed"); + assert!(marker.exists(), "isolated recovery FD test did not execute"); +} + +#[test] +fn staged_recovery_descendant_replacement_is_quarantined_without_chmod_replacement() { + let Some(fixture) = Fixture::new() else { + return; + }; + let transaction = TransactionId([0xf7; 16]); + let binding = fixture.prepare(); + let (mut engine, report) = SealedStagingEngine::open(&fixture.store).unwrap(); + assert!(report.is_empty()); + drop(engine.stage_prepared_root(transaction, binding).unwrap()); + drop(engine); + + let (mut recovered, report) = SealedStagingEngine::open(&fixture.store).unwrap(); + let candidate = report.into_candidates().pop().unwrap(); + let capability = recovered + .prepare_startup_recovery(candidate, fixture.anchors()) + .unwrap(); + let StartupRecoveryCapability::PendingVerification(pending) = capability else { + panic!("applied rename must require staged verification") + }; + + let child = fixture.destination_root.join("child"); + let detached = fixture.destination_root.join("detached-child"); + std::fs::rename(&child, &detached).unwrap(); + std::fs::create_dir(&child).unwrap(); + set_mode(&child, 0o700); + let outcome = pending.verify_or_quarantine().unwrap(); + assert!(matches!( + outcome, + StagedVerificationOutcome::Quarantined(StagedVerificationFailure::Rebind( + RecoveryRebindError::BindingChanged + )) + )); + assert_eq!(mode(&child), 0o700, "replacement must never be chmoded"); + assert_eq!(mode(&detached), 0o750, "moved original remains sealed"); + assert_eq!( + recovered.state(transaction), + Some(TransactionState::Quarantined) + ); +} diff --git a/crates/degu-core/src/staging/tests.rs b/crates/degu-core/src/staging/tests.rs index 1903e62..9509525 100644 --- a/crates/degu-core/src/staging/tests.rs +++ b/crates/degu-core/src/staging/tests.rs @@ -350,7 +350,12 @@ fn permission_and_path_workload_limits_are_checked_before_rebind() { assert!(validate_recovery_workload(&snapshot).is_err()); let permission = snapshot.permissions[0].clone(); - snapshot.permissions = vec![permission; MAX_RECOVERY_PERMISSION_OPERATIONS + 1]; + snapshot.permissions = vec![permission.clone(); RECOVERY_MAX_ACTIVE_PERMISSIONS]; + assert!( + validate_recovery_workload(&snapshot).is_ok(), + "the exact 1,024-operation recovery envelope must remain accepted" + ); + snapshot.permissions = vec![permission; RECOVERY_MAX_ACTIVE_PERMISSIONS + 1]; assert!(validate_recovery_workload(&snapshot).is_err()); let deep = (0..=MAX_RECOVERY_PATH_COMPONENTS) diff --git a/crates/degu-core/tests/held_tree_policy_facade.rs b/crates/degu-core/tests/held_tree_policy_facade.rs index 1e4f5da..9cdad0c 100644 --- a/crates/degu-core/tests/held_tree_policy_facade.rs +++ b/crates/degu-core/tests/held_tree_policy_facade.rs @@ -75,14 +75,29 @@ fn public_clean_tree_is_assessed_but_never_claims_seal_validation() { } #[test] -fn public_default_directory_cap_and_hardlink_are_structured_failures() { +fn public_default_directory_boundary_and_hardlink_are_structured() { + const MAX_TREE_DIRECTORIES: usize = 1_023; + let (temp, root) = setup(); - for index in 0..257 { - std::fs::create_dir(root.join(format!("d{index:03}"))).unwrap(); + // The root is included, so 1,022 children are exactly the production bound. + for index in 0..(MAX_TREE_DIRECTORIES - 1) { + std::fs::create_dir(root.join(format!("d{index:04}"))).unwrap(); } let Some(parent) = certified(temp.path()) else { return; }; + let outcome = assess_held_tree_policy_metadata(parent, OsStr::new("root")).unwrap(); + let HeldTreePolicyAssessmentOutcome::TreePolicyAssessed { tree, .. } = outcome else { + panic!("searchable boundary tree assessment unexpectedly deferred") + }; + assert_eq!(tree.directories, MAX_TREE_DIRECTORIES as u64); + + // One more child makes 1,024 total tree directories. Recovery also needs + // one source-parent permission, so policy must reject this boundary. + std::fs::create_dir(root.join("over-limit")).unwrap(); + let Some(parent) = certified(temp.path()) else { + return; + }; let error = assess_held_tree_policy_metadata(parent, OsStr::new("root")).unwrap_err(); assert_eq!( error.kind(), diff --git a/crates/degu/src/lifecycle/stage/production.rs b/crates/degu/src/lifecycle/stage/production.rs index ecc4d25..d23d122 100644 --- a/crates/degu/src/lifecycle/stage/production.rs +++ b/crates/degu/src/lifecycle/stage/production.rs @@ -122,19 +122,20 @@ fn preflight_item( // Match ordinary production's primary-failure order exactly. In particular, // a pathname policy failure must not be masked by a held-tree deferral or // assessment error that ordinary `execute` would never reach first. - let _policy = preflight_policy(ctx, finding, identity)?; + let policy = preflight_policy(ctx, finding, identity)?; + preflight_tree_policy(&policy.canonical_source) +} - let lexical_parent = finding - .path() +fn preflight_tree_policy( + canonical_source: &Path, +) -> Result { + let canonical_parent = canonical_source .parent() .ok_or_else(|| "sealed staging source has no parent".to_string())?; - let canonical_parent = std::fs::canonicalize(lexical_parent) - .map_err(|error| format!("failed to canonicalize sealed staging source parent: {error}"))?; - let root_basename = finding - .path() + let root_basename = canonical_source .file_name() .ok_or_else(|| "sealed staging source has no basename".to_string())?; - let source_parent = open_directory(&canonical_parent) + let source_parent = open_directory(canonical_parent) .map_err(|error| format!("failed to hold sealed staging source parent: {error}"))?; let parent_evidence = certify_held_fd(source_parent) .map_err(|error| format!("sealed staging source-parent certification failed: {error:?}"))?; @@ -274,9 +275,17 @@ pub(super) fn execute( Ok(policy) => policy, Err(reason) => return failed(finding, reason, false), }; + if let Err(reason) = preflight_tree_policy(&policy.canonical_source) { + return failed(finding, reason, false); + } - // Root creation is deliberately after all static production-policy gates. - // Reservation is later still, after the exact managed root is authenticated. + // Root creation is deliberately after all pathname and data-only tree-policy + // error gates. A deferred result is intentionally allowed for ordinary + // full-scope execution: the core's execution-authority source-parent seal + // is what makes that assessment evaluable. Explicit atomic selection is + // stricter and rejects deferred results in batch_preflight above. The core + // repeats assessment under execution authority to detect races, but known + // failures cannot create trash, claim, or WAL state. let trash_root = match super::prepare_trash_root(run.ctx, finding.path()) { Ok(root) => root, Err(reason) => return failed(finding, reason, false), diff --git a/crates/degu/tests/clean/lifecycle.rs b/crates/degu/tests/clean/lifecycle.rs index 04279ff..7b352bd 100644 --- a/crates/degu/tests/clean/lifecycle.rs +++ b/crates/degu/tests/clean/lifecycle.rs @@ -1,6 +1,6 @@ use super::support::*; -use assert_cmd::Command; use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::os::unix::process::CommandExt; use std::path::Path; struct Lifecycle { @@ -114,12 +114,35 @@ fn assert_purge(fixture: &Lifecycle, trash_entry: &str) { /// from an ambient test variable. #[test] fn production_sealed_staging_cli_clean_undo_and_direct_purge() { + // The held-tree inventory includes the cache root: root + 1,022 siblings is + // the exact 1,023-total production boundary. + const SIBLING_DIRECTORIES: usize = 1_022; + const TOTAL_DIRECTORIES: usize = SIBLING_DIRECTORIES + 1; + const CHILD_NOFILE_LIMIT: libc::rlim_t = 64; + let home = tempfile::tempdir().unwrap(); let Some(backend) = require_sealed_fixture_backend(home.path()) else { return; }; let (cache, state) = fake_pip_cache(&home, ".cache/pip"); + for index in 0..SIBLING_DIRECTORIES { + let directory = cache.join(format!("sibling-{index:04}")); + std::fs::create_dir(&directory).unwrap(); + // Group-writable trees are deliberately demoted by classification, so + // pin the fixture mode instead of inheriting the ambient umask. + std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write( + directory.join("payload.bin"), + format!("bounded-fd-payload-{index:04}"), + ) + .unwrap(); + } + assert_eq!( + count_directories(&cache), + TOTAL_DIRECTORIES, + "fixture is not the exact root-inclusive production boundary" + ); let source_backend = certify_backend(&cache).unwrap(); let state_backend = certify_backend(state.path()).unwrap(); assert_eq!( @@ -142,7 +165,7 @@ fn production_sealed_staging_cli_clean_undo_and_direct_purge() { let anchor = std::fs::canonicalize(anchor).unwrap(); let run = |args: &[&str]| { - let mut command = Command::new(assert_cmd::cargo::cargo_bin("degu")); + let mut command = std::process::Command::new(assert_cmd::cargo::cargo_bin("degu")); command .env_clear() .env("HOME", home.path()) @@ -152,6 +175,24 @@ fn production_sealed_staging_cli_clean_undo_and_direct_purge() { .env("DEGU_INTEGRATION_TEST_ANCHOR", &anchor) // Intentionally do not set DEGU_INTEGRATION_TEST_LEGACY_CLEAN. .args(args); + // Every lifecycle command is a fresh process/session and must complete + // with the inherited descriptor ceiling lowered, never raised, to 64. + unsafe { + command.pre_exec(|| { + let mut limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) != 0 { + return Err(std::io::Error::last_os_error()); + } + limit.rlim_cur = limit.rlim_cur.min(CHILD_NOFILE_LIMIT); + if libc::setrlimit(libc::RLIMIT_NOFILE, &limit) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } command.output().unwrap() }; @@ -188,6 +229,23 @@ fn production_sealed_staging_cli_clean_undo_and_direct_purge() { assert!(undo_report[section].as_array().unwrap().is_empty()); } assert!(cache.join("wheel.whl").is_file()); + assert_eq!( + count_directories(&cache), + TOTAL_DIRECTORIES, + "undo did not restore the complete 1,023-directory inventory" + ); + for index in 0..SIBLING_DIRECTORIES { + assert_eq!( + std::fs::read_to_string( + cache + .join(format!("sibling-{index:04}")) + .join("payload.bin") + ) + .unwrap(), + format!("bounded-fd-payload-{index:04}"), + "undo did not restore regular payload {index}" + ); + } assert!(visible_trash_entries(&state.path().join("degu/trash")).is_empty()); assert_eq!(activation_snapshot(&anchor, state.path()), activation); let restored_wal_len = std::fs::metadata(&wal).unwrap().len(); @@ -231,6 +289,14 @@ fn production_sealed_staging_cli_clean_undo_and_direct_purge() { assert_eq!(purged_records[2]["trash_entry"], first_trash); } +fn count_directories(root: &Path) -> usize { + 1 + std::fs::read_dir(root) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .count() +} + fn activation_snapshot(anchor: &Path, state: &Path) -> Vec> { [ anchor.join("sealed-staging.authority"), diff --git a/crates/degu/tests/clean/sealed_admission.rs b/crates/degu/tests/clean/sealed_admission.rs index 02e9e47..841e75f 100644 --- a/crates/degu/tests/clean/sealed_admission.rs +++ b/crates/degu/tests/clean/sealed_admission.rs @@ -7,7 +7,7 @@ use std::os::unix::ffi::OsStringExt; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::{Path, PathBuf}; -const HELD_TREE_MAX_DIRECTORIES: usize = 256; +const MAX_TREE_DIRECTORIES: usize = 1_023; #[test] fn clean_tree_preview_assessment_does_not_activate_or_create_lifecycle_state() { @@ -18,23 +18,24 @@ fn clean_tree_preview_assessment_does_not_activate_or_create_lifecycle_state() { } #[test] -fn preview_blocks_tree_just_over_sealed_directory_limit_and_production_rejects() { +fn preview_blocks_1024_tree_directories_before_any_production_mutation() { let Some(fixture) = Fixture::new() else { return; }; - // The sealed inventory counts its root, so root + 256 children is the - // smallest tree above the default 256-total-directory admission bound. - for index in 0..HELD_TREE_MAX_DIRECTORIES { - let dir = fixture.cache.join(format!("dir-{index:03}")); + // The inventory counts its root. Root + 1,023 children is 1,024 tree + // directories and would require 1,025 active recovery permissions once the + // source-parent seal is included. Reject it before any lifecycle mutation. + for index in 0..MAX_TREE_DIRECTORIES { + let dir = fixture.cache.join(format!("dir-{index:04}")); std::fs::create_dir(&dir).unwrap(); // Group-writable trees are deliberately demoted by classification, so // pin the fixture mode instead of inheriting the ambient umask. std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); } - assert_eq!(count_directories(&fixture.cache), 257); + assert_eq!(count_directories(&fixture.cache), 1_024); fixture.assert_preview_blocked("directory_limit_exceeded", "directory limit exceeded"); fixture.assert_production_rejects("directory limit exceeded"); - assert_eq!(count_directories(&fixture.cache), 257); + assert_eq!(count_directories(&fixture.cache), 1_024); assert!(fixture.cache.join("wheel.whl").is_file()); }